Browse Source

Mise à jour des modèles et ajout de l'import CSV pour les coureurs

master
scayac 3 months ago
parent
commit
515cbe9cd5
  1. 2
      README.md
  2. 4
      crossapp/settings.py
  3. 53
      main/admin.py
  4. 3
      main/forms.py
  5. 0
      main/forms_coureur_import.py
  6. 14
      main/migrations/0001_initial.py
  7. 34
      main/models.py
  8. 7
      main/templates/admin/main/coureur_changelist.html
  9. 9
      main/templates/admin/main/import_csv.html
  10. 15
      main/templates/main.html
  11. 104
      main/views.py

2
README.md

@ -19,7 +19,7 @@ Application Django pour l’enregistrement des temps de courses des coureurs.
``` ```
3. Appliquez les migrations : 3. Appliquez les migrations :
```bash ```bash
python manage.py makemigrations coureurs courses python manage.py makemigrations main
python manage.py migrate python manage.py migrate
``` ```
4. Créez un superutilisateur : 4. Créez un superutilisateur :

4
crossapp/settings.py

@ -34,6 +34,7 @@ CSRF_TRUSTED_ORIGINS = os.getenv('DJANGO_CSRF_TRUSTED_ORIGINS', 'http://localhos
INSTALLED_APPS = [ INSTALLED_APPS = [
'daphne', 'daphne',
'main',
'django.contrib.admin', 'django.contrib.admin',
'django.contrib.auth', 'django.contrib.auth',
'django.contrib.contenttypes', 'django.contrib.contenttypes',
@ -42,8 +43,7 @@ INSTALLED_APPS = [
'django.contrib.staticfiles', 'django.contrib.staticfiles',
'fontawesomefree', 'fontawesomefree',
'channels', 'channels',
'main', ]
]
MIDDLEWARE = [ MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware', 'django.middleware.security.SecurityMiddleware',

53
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 from .models import Course, Arrivee, Coureur
@admin.register(Course) @admin.register(Course)
class CourseAdmin(admin.ModelAdmin): class CourseAdmin(admin.ModelAdmin):
list_display = ('nom', 'date', 'depart', 'fin') list_display = ('nom', 'date', 'type', 'owner', 'depart', 'fin')
search_fields = ('nom',) search_fields = ('nom', 'owner__username')
list_filter = ('date',) list_filter = ('date', 'type')
fields = ('nom', 'date', 'type', 'owner', 'depart', 'fin')
@admin.register(Arrivee) @admin.register(Arrivee)
@ -18,6 +23,42 @@ class ArriveeAdmin(admin.ModelAdmin):
@admin.register(Coureur) @admin.register(Coureur)
class CoureurAdmin(admin.ModelAdmin): class CoureurAdmin(admin.ModelAdmin):
list_display = ('nom', 'classe') list_display = ('id', 'nom', 'prenom', 'classe')
search_fields = ('nom', 'classe') search_fields = ('nom', 'prenom', 'classe')
list_filter = ('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})

3
main/forms.py

@ -6,11 +6,12 @@ from django.utils import timezone
class CourseForm(forms.ModelForm): class CourseForm(forms.ModelForm):
class Meta: class Meta:
model = Course model = Course
fields = ['nom'] fields = ['nom', 'type']
def save(self, commit=True): def save(self, commit=True):
instance = super().save(commit=False) instance = super().save(commit=False)
instance.date = timezone.localdate() instance.date = timezone.localdate()
# owner is set in the view prior to saving (we keep that behaviour)
if commit: if commit:
instance.save() instance.save()
return instance return instance

0
main/forms_coureur_import.py

14
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 django.db.models.deletion
import main.models
from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
@ -9,16 +11,22 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
] ]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='Coureur', name='Coureur',
fields=[ 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)), ('nom', models.CharField(max_length=100)),
('prenom', models.CharField(blank=True, max_length=100)),
('classe', models.CharField(max_length=50)), ('classe', models.CharField(max_length=50)),
], ],
options={
'ordering': ['nom', 'prenom', 'classe'],
'unique_together': {('nom', 'prenom', 'classe')},
},
), ),
migrations.CreateModel( migrations.CreateModel(
name='Course', name='Course',
@ -28,6 +36,8 @@ class Migration(migrations.Migration):
('date', models.DateField()), ('date', models.DateField()),
('depart', models.DateTimeField(blank=True, null=True)), ('depart', models.DateTimeField(blank=True, null=True)),
('fin', 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={ options={
'ordering': ['-date', 'nom'], 'ordering': ['-date', 'nom'],

34
main/models.py

@ -1,5 +1,18 @@
from django.db import models 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): class Course(models.Model):
nom = models.CharField(max_length=100) nom = models.CharField(max_length=100)
@ -7,6 +20,16 @@ class Course(models.Model):
depart = models.DateTimeField(null=True, blank=True) depart = models.DateTimeField(null=True, blank=True)
fin = 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: class Meta:
unique_together = ('nom', 'date') unique_together = ('nom', 'date')
ordering = ['-date', 'nom'] ordering = ['-date', 'nom']
@ -14,6 +37,7 @@ class Course(models.Model):
def __str__(self): def __str__(self):
return f"{self.nom} ({self.date})" return f"{self.nom} ({self.date})"
class Arrivee(models.Model): class Arrivee(models.Model):
course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name='arrivees') course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name='arrivees')
coureur = models.ForeignKey('Coureur', on_delete=models.CASCADE) coureur = models.ForeignKey('Coureur', on_delete=models.CASCADE)
@ -28,9 +52,17 @@ class Arrivee(models.Model):
def __str__(self): def __str__(self):
return f"{self.coureur.nom} - {self.course.nom} ({self.temps})" return f"{self.coureur.nom} - {self.course.nom} ({self.temps})"
class Coureur(models.Model): 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) nom = models.CharField(max_length=100)
prenom = models.CharField(max_length=100, blank=True)
classe = models.CharField(max_length=50) classe = models.CharField(max_length=50)
class Meta:
unique_together = ('nom', 'prenom', 'classe')
ordering = ['nom', 'prenom', 'classe']
def __str__(self): def __str__(self):
return f"{self.nom} ({self.classe})" return f"{self.nom} {self.prenom} ({self.classe})"

7
main/templates/admin/main/coureur_changelist.html

@ -0,0 +1,7 @@
{% extends "admin/change_list.html" %}
{% block object-tools %}
{{ block.super }}
<li>
<a href="import-csv/" class="addlink">Importer CSV</a>
</li>
{% endblock %}

9
main/templates/admin/main/import_csv.html

@ -0,0 +1,9 @@
{% extends "admin/base_site.html" %}
{% block content %}
<h1>Importer des coureurs depuis un CSV</h1>
<form method="post" enctype="multipart/form-data">{% csrf_token %}
{{ form.as_p }}
<button type="submit" class="default">Importer</button>
<a href=".." class="button">Annuler</a>
</form>
{% endblock %}

15
main/templates/main.html

@ -52,10 +52,17 @@
<label for="courseName">Nom de la course</label> <label for="courseName">Nom de la course</label>
<input type="text" class="form-control" name="nom" id="courseName" required> <input type="text" class="form-control" name="nom" id="courseName" required>
</div> </div>
<div class="form-group">
<label for="courseType">Type de course</label>
<select class="form-control" name="type" id="courseType" required>
<option value="unique">Unique</option>
<option value="multi">Multi</option>
</select>
</div>
<div id="newCourseError" class="alert alert-danger" style="display:none;"></div> <div id="newCourseError" class="alert alert-danger" style="display:none;"></div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="submit" class="btn btn-success">Créer et scanner</button> <button type="submit" class="btn btn-success">Créer la course</button>
</div> </div>
</form> </form>
</div> </div>
@ -73,6 +80,7 @@ document.getElementById('closeModal').onclick = function() {
document.getElementById('newCourseForm').onsubmit = function(e) { document.getElementById('newCourseForm').onsubmit = function(e) {
e.preventDefault(); e.preventDefault();
const nom = document.getElementById('courseName').value; const nom = document.getElementById('courseName').value;
const type = document.getElementById('courseType').value;
const csrf = document.querySelector('[name=csrfmiddlewaretoken]').value; const csrf = document.querySelector('[name=csrfmiddlewaretoken]').value;
fetch('', { fetch('', {
method: 'POST', method: 'POST',
@ -80,12 +88,13 @@ document.getElementById('newCourseForm').onsubmit = function(e) {
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
'X-Requested-With': 'XMLHttpRequest' '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(response => response.json())
.then(data => { .then(data => {
if (data.success) { if (data.success) {
window.location.href = `/scan/?course_id=${data.course_id}`; // Redirige vers la vue principale (reload la page)
window.location.href = '/';
} else { } else {
document.getElementById('newCourseError').textContent = data.error; document.getElementById('newCourseError').textContent = data.error;
document.getElementById('newCourseError').style.display = 'block'; document.getElementById('newCourseError').style.display = 'block';

104
main/views.py

@ -31,8 +31,30 @@ def seconds_to_hms(delta: timedelta) -> str:
seconds = total % 60 seconds = total % 60
return f"{hours}:{minutes:02d}:{seconds:02d}" 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): 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 = HttpResponse(content_type='text/csv')
response['Content-Disposition'] = f'attachment; filename="course_{course_id}_resultats.csv"' response['Content-Disposition'] = f'attachment; filename="course_{course_id}_resultats.csv"'
writer = csv.writer(response) 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)]) writer.writerow([a.rang, a.coureur.nom, a.coureur.classe, str(a.temps)])
return response return response
@login_required
def export_pdf(request, course_id): 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 = HttpResponse(content_type='application/pdf')
response['Content-Disposition'] = f'attachment; filename="course_{course_id}_resultats.pdf"' response['Content-Disposition'] = f'attachment; filename="course_{course_id}_resultats.pdf"'
p = canvas.Canvas(response, pagesize=A4) p = canvas.Canvas(response, pagesize=A4)
@ -102,7 +125,7 @@ def export_pdf(request, course_id):
@login_required @login_required
def course_detail_view(request, course_id): 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') arrivees = course.arrivees.select_related('coureur').order_by('rang')
is_started = course.depart is not None is_started = course.depart is not None
is_finished = course.fin 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_started': is_started,
'is_finished': is_finished '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 @login_required
def scan_view(request): 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 result = None
error = None error = None
course = None course = None
if request.method == 'POST' and request.headers.get('x-requested-with') == 'XMLHttpRequest': if request.method == 'POST' and request.headers.get('x-requested-with') == 'XMLHttpRequest':
course_id = request.POST.get('course_id') course_id = request.POST.get('course_id')
qrcode = request.POST.get('qrcode') qrcode = request.POST.get('qrcode')
if not course_id or not qrcode: if not course_id or not qrcode:
error = "Param\u00e8tres manquants." error = "Param\u00e8tres manquants."
elif qrcode.count(';') != 1:
error = "Format QR code invalide."
else: else:
nom, classe = qrcode.split(';') # ensure user can only scan their own course
course = get_object_or_404(Course, id=course_id) course = get_object_or_404(Course, id=course_id, owner=request.user)
if not course.depart:
error = "La course n'a pas d\u00e9marr\u00e9." # QR code now contains the unique Coureur id
else: coureur = None
coureur, _ = Coureur.objects.get_or_create(nom=nom.strip(), classe=classe.strip()) 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(): if Arrivee.objects.filter(course=course, coureur=coureur).exists():
error = "Ce coureur a d\u00e9j\u00e0 \u00e9t\u00e9 scann\u00e9." error = "Ce coureur a d\u00e9j\u00e0 \u00e9t\u00e9 scann\u00e9."
else: else:
@ -179,6 +184,7 @@ def scan_view(request):
Arrivee.objects.create(course=course, coureur=coureur, temps=temps, rang=rang) Arrivee.objects.create(course=course, coureur=coureur, temps=temps, rang=rang)
result = { result = {
'nom': coureur.nom, 'nom': coureur.nom,
'prenom': coureur.prenom,
'classe': coureur.classe, 'classe': coureur.classe,
'rang': rang, 'rang': rang,
'temps': str(seconds_to_hms(temps)) 'temps': str(seconds_to_hms(temps))
@ -191,14 +197,17 @@ def scan_view(request):
'data': result 'data': result
} }
) )
if result: if result:
return render(request, 'scan_result.html', {'result': result}) return render(request, 'scan_result.html', {'result': result})
else: else:
return render(request, 'scan_result.html', {'error': error}) return render(request, 'scan_result.html', {'error': error})
else: else:
course_id = request.GET.get('course_id') course_id = request.GET.get('course_id')
if 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', { return render(request, 'scan.html', {
'title': 'Scan course : '+(course.nom+" ("+str(course.date)+")" if course else ''), 'title': 'Scan course : '+(course.nom+" ("+str(course.date)+")" if course else ''),
'courses': courses, 'courses': courses,
@ -219,11 +228,19 @@ def dossards_view(request):
rows = form.cleaned_data['rows'] rows = form.cleaned_data['rows']
cols = form.cleaned_data['cols'] cols = form.cleaned_data['cols']
try: try:
# Build or find Coureur objects for each line. Accept either 'nom;classe' or 'nom;prenom;classe'.
data = [] data = []
for line in csv_file.read().decode('utf-8').splitlines(): for line in csv_file.read().decode('utf-8').splitlines():
if line.count(';') == 1: parts = [p.strip() for p in line.split(';') if p.strip() != '']
nom, classe = line.split(';') if len(parts) == 2:
data.append((nom.strip(), classe.strip())) 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) total = len(data)
progress = f"G\u00e9n\u00e9ration des dossards : 0/{total}..." progress = f"G\u00e9n\u00e9ration des dossards : 0/{total}..."
buffer = generate_dossards_pdf(data, rows, cols) 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 x0, y0 = margin, height - margin - label_h
qr_scale = 0.8 # 80% of the label area for the QR qr_scale = 0.8 # 80% of the label area for the QR
for idx, (nom, classe) in enumerate(data): for idx, (nom, classe) in enumerate(data):
# data is now a list of Coureur objects
coureur = data[idx]
col = idx % cols col = idx % cols
row = (idx // cols) % rows row = (idx // cols) % rows
page = idx // (rows * cols) page = idx // (rows * cols)
@ -269,7 +288,8 @@ def generate_dossards_pdf(data, rows, cols):
c.setStrokeColorRGB(0, 0, 0) c.setStrokeColorRGB(0, 0, 0)
c.rect(x, y, label_w, label_h) c.rect(x, y, label_w, label_h)
# Generate QR # 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_img = io.BytesIO()
qr.save(qr_img, format='PNG') qr.save(qr_img, format='PNG')
qr_img.seek(0) qr_img.seek(0)
@ -279,9 +299,11 @@ def generate_dossards_pdf(data, rows, cols):
qr_y = y + (label_h - qr_size) / 2 qr_y = y + (label_h - qr_size) / 2
c.drawInlineImage(qr_pil, qr_x, qr_y, qr_size, qr_size) c.drawInlineImage(qr_pil, qr_x, qr_y, qr_size, qr_size)
c.setFont("Helvetica-Bold", 12) 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.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() c.save()
buffer.seek(0) buffer.seek(0)
return buffer return buffer

Loading…
Cancel
Save