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. @@ -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 :

4
crossapp/settings.py

@ -34,6 +34,7 @@ CSRF_TRUSTED_ORIGINS = os.getenv('DJANGO_CSRF_TRUSTED_ORIGINS', 'http://localhos @@ -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 = [ @@ -42,8 +43,7 @@ INSTALLED_APPS = [
'django.contrib.staticfiles',
'fontawesomefree',
'channels',
'main',
]
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',

53
main/admin.py

@ -1,12 +1,17 @@ @@ -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): @@ -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})

3
main/forms.py

@ -6,11 +6,12 @@ from django.utils import timezone @@ -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

0
main/forms_coureur_import.py

14
main/migrations/0001_initial.py

@ -1,6 +1,8 @@ @@ -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): @@ -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): @@ -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'],

34
main/models.py

@ -1,5 +1,18 @@ @@ -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): @@ -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): @@ -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): @@ -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})"

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

@ -0,0 +1,7 @@ @@ -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 @@ @@ -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 @@ @@ -52,10 +52,17 @@
<label for="courseName">Nom de la course</label>
<input type="text" class="form-control" name="nom" id="courseName" required>
</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>
<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>
</form>
</div>
@ -73,6 +80,7 @@ document.getElementById('closeModal').onclick = function() { @@ -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) { @@ -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';

104
main/views.py

@ -31,8 +31,30 @@ def seconds_to_hms(delta: timedelta) -> str: @@ -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): @@ -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): @@ -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): @@ -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): @@ -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): @@ -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): @@ -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): @@ -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): @@ -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): @@ -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

Loading…
Cancel
Save