diff --git a/README.md b/README.md index c78d246..e289ff1 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Application Django pour l’enregistrement des temps de courses des coureurs. ``` 3. Appliquez les migrations : ```bash - python manage.py makemigrations coureurs courses + python manage.py makemigrations main python manage.py migrate ``` 4. Créez un superutilisateur : diff --git a/crossapp/settings.py b/crossapp/settings.py index cc8d6c4..2c06554 100644 --- a/crossapp/settings.py +++ b/crossapp/settings.py @@ -34,6 +34,7 @@ CSRF_TRUSTED_ORIGINS = os.getenv('DJANGO_CSRF_TRUSTED_ORIGINS', 'http://localhos INSTALLED_APPS = [ 'daphne', + 'main', 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', @@ -42,8 +43,7 @@ INSTALLED_APPS = [ 'django.contrib.staticfiles', 'fontawesomefree', 'channels', - 'main', - ] +] MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', diff --git a/main/admin.py b/main/admin.py index fa3a350..3d4c705 100644 --- a/main/admin.py +++ b/main/admin.py @@ -1,12 +1,17 @@ -from django.contrib import admin + +from django.contrib import admin, messages +from django.urls import path +from django.shortcuts import render, redirect +from django import forms from .models import Course, Arrivee, Coureur @admin.register(Course) class CourseAdmin(admin.ModelAdmin): - list_display = ('nom', 'date', 'depart', 'fin') - search_fields = ('nom',) - list_filter = ('date',) + list_display = ('nom', 'date', 'type', 'owner', 'depart', 'fin') + search_fields = ('nom', 'owner__username') + list_filter = ('date', 'type') + fields = ('nom', 'date', 'type', 'owner', 'depart', 'fin') @admin.register(Arrivee) @@ -18,6 +23,42 @@ class ArriveeAdmin(admin.ModelAdmin): @admin.register(Coureur) class CoureurAdmin(admin.ModelAdmin): - list_display = ('nom', 'classe') - search_fields = ('nom', 'classe') + list_display = ('id', 'nom', 'prenom', 'classe') + search_fields = ('nom', 'prenom', 'classe') list_filter = ('classe',) + + change_list_template = "admin/main/coureur_changelist.html" + + def get_urls(self): + urls = super().get_urls() + custom_urls = [ + path('import-csv/', self.admin_site.admin_view(self.import_csv), name='main_coureur_import_csv'), + ] + return custom_urls + urls + + class CsvImportForm(forms.Form): + csv_file = forms.FileField(label="Fichier CSV (nom;prenom;classe)") + + def import_csv(self, request): + if request.method == "POST": + form = self.CsvImportForm(request.POST, request.FILES) + if form.is_valid(): + csv_file = form.cleaned_data['csv_file'] + imported = 0 + ignored = 0 + for line in csv_file.read().decode('utf-8').splitlines(): + parts = [p.strip() for p in line.split(';')] + if len(parts) != 3: + continue + nom, prenom, classe = parts + classe = classe.replace(' ', '') + if Coureur.objects.filter(nom=nom, prenom=prenom, classe=classe).exists(): + ignored += 1 + continue + Coureur.objects.create(nom=nom, prenom=prenom, classe=classe) + imported += 1 + self.message_user(request, f"Import terminé : {imported} ajoutés, {ignored} ignorés (doublons).", messages.SUCCESS) + return redirect("..") + else: + form = self.CsvImportForm() + return render(request, "admin/main/import_csv.html", {"form": form}) diff --git a/main/forms.py b/main/forms.py index 16b71cb..359f995 100644 --- a/main/forms.py +++ b/main/forms.py @@ -6,11 +6,12 @@ from django.utils import timezone class CourseForm(forms.ModelForm): class Meta: model = Course - fields = ['nom'] + fields = ['nom', 'type'] def save(self, commit=True): instance = super().save(commit=False) instance.date = timezone.localdate() + # owner is set in the view prior to saving (we keep that behaviour) if commit: instance.save() return instance diff --git a/main/forms_coureur_import.py b/main/forms_coureur_import.py new file mode 100644 index 0000000..e69de29 diff --git a/main/migrations/0001_initial.py b/main/migrations/0001_initial.py index 17cbf50..32a0602 100644 --- a/main/migrations/0001_initial.py +++ b/main/migrations/0001_initial.py @@ -1,6 +1,8 @@ -# Generated by Django 5.2.7 on 2025-10-02 11:10 +# Generated by Django 5.2.7 on 2025-10-03 12:23 import django.db.models.deletion +import main.models +from django.conf import settings from django.db import migrations, models @@ -9,16 +11,22 @@ class Migration(migrations.Migration): initial = True dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.CreateModel( name='Coureur', fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('id', models.CharField(default=main.models.generate_coureur_id, editable=False, max_length=10, primary_key=True, serialize=False)), ('nom', models.CharField(max_length=100)), + ('prenom', models.CharField(blank=True, max_length=100)), ('classe', models.CharField(max_length=50)), ], + options={ + 'ordering': ['nom', 'prenom', 'classe'], + 'unique_together': {('nom', 'prenom', 'classe')}, + }, ), migrations.CreateModel( name='Course', @@ -28,6 +36,8 @@ class Migration(migrations.Migration): ('date', models.DateField()), ('depart', models.DateTimeField(blank=True, null=True)), ('fin', models.DateTimeField(blank=True, null=True)), + ('type', models.CharField(choices=[('unique', 'Unique'), ('multi', 'Multi')], default='unique', max_length=6)), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='courses', to=settings.AUTH_USER_MODEL)), ], options={ 'ordering': ['-date', 'nom'], diff --git a/main/models.py b/main/models.py index fca4ed0..490f360 100644 --- a/main/models.py +++ b/main/models.py @@ -1,5 +1,18 @@ from django.db import models +from django.conf import settings +from django.utils.crypto import get_random_string + + +def generate_coureur_id(): + """Generate a unique 10-character id for Coureur.""" + # allowed chars: letters + digits + # loop until we find an unused id (call happens at instance creation time) + while True: + s = get_random_string(10, allowed_chars='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789') + if not Coureur.objects.filter(id=s).exists(): + return s + class Course(models.Model): nom = models.CharField(max_length=100) @@ -7,6 +20,16 @@ class Course(models.Model): depart = models.DateTimeField(null=True, blank=True) fin = models.DateTimeField(null=True, blank=True) + TYPE_UNIQUE = 'unique' + TYPE_MULTI = 'multi' + TYPE_CHOICES = [ + (TYPE_UNIQUE, 'Unique'), + (TYPE_MULTI, 'Multi'), + ] + type = models.CharField(max_length=6, choices=TYPE_CHOICES, default=TYPE_UNIQUE) + + owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='courses') + class Meta: unique_together = ('nom', 'date') ordering = ['-date', 'nom'] @@ -14,6 +37,7 @@ class Course(models.Model): def __str__(self): return f"{self.nom} ({self.date})" + class Arrivee(models.Model): course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name='arrivees') coureur = models.ForeignKey('Coureur', on_delete=models.CASCADE) @@ -28,9 +52,17 @@ class Arrivee(models.Model): def __str__(self): return f"{self.coureur.nom} - {self.course.nom} ({self.temps})" + class Coureur(models.Model): + # 10-char primary key + id = models.CharField(primary_key=True, max_length=10, editable=False, default=generate_coureur_id) nom = models.CharField(max_length=100) + prenom = models.CharField(max_length=100, blank=True) classe = models.CharField(max_length=50) + class Meta: + unique_together = ('nom', 'prenom', 'classe') + ordering = ['nom', 'prenom', 'classe'] + def __str__(self): - return f"{self.nom} ({self.classe})" + return f"{self.nom} {self.prenom} ({self.classe})" diff --git a/main/templates/admin/main/coureur_changelist.html b/main/templates/admin/main/coureur_changelist.html new file mode 100644 index 0000000..02a475f --- /dev/null +++ b/main/templates/admin/main/coureur_changelist.html @@ -0,0 +1,7 @@ +{% extends "admin/change_list.html" %} +{% block object-tools %} +{{ block.super }} +
  • + Importer CSV +
  • +{% endblock %} diff --git a/main/templates/admin/main/import_csv.html b/main/templates/admin/main/import_csv.html new file mode 100644 index 0000000..e888ef7 --- /dev/null +++ b/main/templates/admin/main/import_csv.html @@ -0,0 +1,9 @@ +{% extends "admin/base_site.html" %} +{% block content %} +

    Importer des coureurs depuis un CSV

    +
    {% csrf_token %} + {{ form.as_p }} + + Annuler +
    +{% endblock %} diff --git a/main/templates/main.html b/main/templates/main.html index 4e75666..5aefb23 100644 --- a/main/templates/main.html +++ b/main/templates/main.html @@ -52,10 +52,17 @@ +
    + + +
    @@ -73,6 +80,7 @@ document.getElementById('closeModal').onclick = function() { document.getElementById('newCourseForm').onsubmit = function(e) { e.preventDefault(); const nom = document.getElementById('courseName').value; + const type = document.getElementById('courseType').value; const csrf = document.querySelector('[name=csrfmiddlewaretoken]').value; fetch('', { method: 'POST', @@ -80,12 +88,13 @@ document.getElementById('newCourseForm').onsubmit = function(e) { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Requested-With': 'XMLHttpRequest' }, - body: `csrfmiddlewaretoken=${encodeURIComponent(csrf)}&nom=${encodeURIComponent(nom)}` + body: `csrfmiddlewaretoken=${encodeURIComponent(csrf)}&nom=${encodeURIComponent(nom)}&type=${encodeURIComponent(type)}` }) .then(response => response.json()) .then(data => { if (data.success) { - window.location.href = `/scan/?course_id=${data.course_id}`; + // Redirige vers la vue principale (reload la page) + window.location.href = '/'; } else { document.getElementById('newCourseError').textContent = data.error; document.getElementById('newCourseError').style.display = 'block'; diff --git a/main/views.py b/main/views.py index 6901a0f..654b799 100644 --- a/main/views.py +++ b/main/views.py @@ -31,8 +31,30 @@ def seconds_to_hms(delta: timedelta) -> str: seconds = total % 60 return f"{hours}:{minutes:02d}:{seconds:02d}" +@login_required +def main_view(request): + courses = Course.objects.filter(owner=request.user) + if request.method == 'POST' and request.headers.get('x-requested-with') == 'XMLHttpRequest': + from django.http import JsonResponse + nom = request.POST.get('nom') + date = timezone.localdate() + if not nom: + return JsonResponse({'success': False, 'error': "Le nom de la course est requis."}) + if Course.objects.filter(nom=nom, date=date).exists(): + return JsonResponse({'success': False, 'error': "Une course avec ce nom existe d\u00e9j\u00e0 aujourd'hui."}) + course = Course.objects.create(nom=nom, date=date, owner=request.user) + return JsonResponse({'success': True, 'course_id': course.id}) + form = CourseForm() + return render(request, 'main.html', { + 'title': 'Accueil', + 'courses': courses, + 'form': form, + 'now': timezone.localdate() + }) + +@login_required def export_csv(request, course_id): - course = get_object_or_404(Course, id=course_id) + course = get_object_or_404(Course, id=course_id, owner=request.user) response = HttpResponse(content_type='text/csv') response['Content-Disposition'] = f'attachment; filename="course_{course_id}_resultats.csv"' writer = csv.writer(response) @@ -52,8 +74,9 @@ def export_csv(request, course_id): writer.writerow([a.rang, a.coureur.nom, a.coureur.classe, str(a.temps)]) return response +@login_required def export_pdf(request, course_id): - course = get_object_or_404(Course, id=course_id) + course = get_object_or_404(Course, id=course_id, owner=request.user) response = HttpResponse(content_type='application/pdf') response['Content-Disposition'] = f'attachment; filename="course_{course_id}_resultats.pdf"' p = canvas.Canvas(response, pagesize=A4) @@ -102,7 +125,7 @@ def export_pdf(request, course_id): @login_required def course_detail_view(request, course_id): - course = get_object_or_404(Course, id=course_id) + course = get_object_or_404(Course, id=course_id, owner=request.user) arrivees = course.arrivees.select_related('coureur').order_by('rang') is_started = course.depart is not None is_finished = course.fin is not None @@ -129,48 +152,30 @@ def course_detail_view(request, course_id): 'is_started': is_started, 'is_finished': is_finished }) - -@login_required -def main_view(request): - courses = Course.objects.all() - if request.method == 'POST' and request.headers.get('x-requested-with') == 'XMLHttpRequest': - from django.http import JsonResponse - nom = request.POST.get('nom') - date = timezone.localdate() - if not nom: - return JsonResponse({'success': False, 'error': "Le nom de la course est requis."}) - if Course.objects.filter(nom=nom, date=date).exists(): - return JsonResponse({'success': False, 'error': "Une course avec ce nom existe d\u00e9j\u00e0 aujourd'hui."}) - course = Course.objects.create(nom=nom, date=date) - return JsonResponse({'success': True, 'course_id': course.id}) - form = CourseForm() - return render(request, 'main.html', { - 'title': 'Accueil', - 'courses': courses, - 'form': form, - 'now': timezone.localdate() - }) - @login_required def scan_view(request): - courses = Course.objects.filter(depart__isnull=False, fin__isnull=True) + courses = Course.objects.filter(owner=request.user, depart__isnull=False, fin__isnull=True) result = None error = None course = None + if request.method == 'POST' and request.headers.get('x-requested-with') == 'XMLHttpRequest': course_id = request.POST.get('course_id') qrcode = request.POST.get('qrcode') if not course_id or not qrcode: error = "Param\u00e8tres manquants." - elif qrcode.count(';') != 1: - error = "Format QR code invalide." else: - nom, classe = qrcode.split(';') - course = get_object_or_404(Course, id=course_id) - if not course.depart: - error = "La course n'a pas d\u00e9marr\u00e9." - else: - coureur, _ = Coureur.objects.get_or_create(nom=nom.strip(), classe=classe.strip()) + # ensure user can only scan their own course + course = get_object_or_404(Course, id=course_id, owner=request.user) + + # QR code now contains the unique Coureur id + coureur = None + try: + coureur = Coureur.objects.get(id=qrcode.strip()) + except Coureur.DoesNotExist: + error = "Coureur introuvable pour ce code QR." + + if coureur: if Arrivee.objects.filter(course=course, coureur=coureur).exists(): error = "Ce coureur a d\u00e9j\u00e0 \u00e9t\u00e9 scann\u00e9." else: @@ -179,6 +184,7 @@ def scan_view(request): Arrivee.objects.create(course=course, coureur=coureur, temps=temps, rang=rang) result = { 'nom': coureur.nom, + 'prenom': coureur.prenom, 'classe': coureur.classe, 'rang': rang, 'temps': str(seconds_to_hms(temps)) @@ -191,14 +197,17 @@ def scan_view(request): 'data': result } ) + if result: return render(request, 'scan_result.html', {'result': result}) else: return render(request, 'scan_result.html', {'error': error}) + else: course_id = request.GET.get('course_id') if course_id: - course = get_object_or_404(Course, id=course_id) + course = get_object_or_404(Course, id=course_id, owner=request.user) + return render(request, 'scan.html', { 'title': 'Scan course : '+(course.nom+" ("+str(course.date)+")" if course else ''), 'courses': courses, @@ -219,11 +228,19 @@ def dossards_view(request): rows = form.cleaned_data['rows'] cols = form.cleaned_data['cols'] try: + # Build or find Coureur objects for each line. Accept either 'nom;classe' or 'nom;prenom;classe'. data = [] for line in csv_file.read().decode('utf-8').splitlines(): - if line.count(';') == 1: - nom, classe = line.split(';') - data.append((nom.strip(), classe.strip())) + parts = [p.strip() for p in line.split(';') if p.strip() != ''] + if len(parts) == 2: + nom, classe = parts + prenom = '' + elif len(parts) == 3: + nom, prenom, classe = parts + else: + continue + coureur, _ = Coureur.objects.get_or_create(nom=nom, prenom=prenom, classe=classe) + data.append(coureur) total = len(data) progress = f"G\u00e9n\u00e9ration des dossards : 0/{total}..." buffer = generate_dossards_pdf(data, rows, cols) @@ -258,6 +275,8 @@ def generate_dossards_pdf(data, rows, cols): x0, y0 = margin, height - margin - label_h qr_scale = 0.8 # 80% of the label area for the QR for idx, (nom, classe) in enumerate(data): + # data is now a list of Coureur objects + coureur = data[idx] col = idx % cols row = (idx // cols) % rows page = idx // (rows * cols) @@ -269,7 +288,8 @@ def generate_dossards_pdf(data, rows, cols): c.setStrokeColorRGB(0, 0, 0) c.rect(x, y, label_w, label_h) # Generate QR - qr = qrcode.make(f"{nom};{classe}") + # QR contains the unique coureur id + qr = qrcode.make(f"{coureur.id}") qr_img = io.BytesIO() qr.save(qr_img, format='PNG') qr_img.seek(0) @@ -279,9 +299,11 @@ def generate_dossards_pdf(data, rows, cols): qr_y = y + (label_h - qr_size) / 2 c.drawInlineImage(qr_pil, qr_x, qr_y, qr_size, qr_size) c.setFont("Helvetica-Bold", 12) - c.drawCentredString(x + label_w/2, qr_y - 10, nom) + # Display nom, prenom, classe + full_name = f"{coureur.nom} {coureur.prenom}".strip() + c.drawCentredString(x + label_w/2, qr_y - 10, full_name) c.setFont("Helvetica", 10) - c.drawCentredString(x + label_w/2, qr_y - 24, classe) + c.drawCentredString(x + label_w/2, qr_y - 24, coureur.classe) c.save() buffer.seek(0) return buffer