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
+
+{% 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