# Bibliothèques standard import csv import io from datetime import timedelta # Django (importations) from django.db import models from django.db.models import Count from django.http import HttpResponse, JsonResponse from django.shortcuts import render, redirect, get_object_or_404 from django.contrib.auth.decorators import login_required # Bibliothèques standard import csv import io from datetime import timedelta # Django (importations) from django.db import models from django.db.models import Count from django.http import HttpResponse, JsonResponse from django.shortcuts import render, redirect, get_object_or_404 from django.contrib.auth.decorators import login_required from django.utils import timezone from django.views.decorators.http import require_GET, require_POST # Bibliothèques tierces from reportlab.pdfgen import canvas from reportlab.lib.pagesizes import A4 from reportlab.lib.units import mm import qrcode from PIL import Image from channels.layers import get_channel_layer from asgiref.sync import async_to_sync # Importations locales from .models import Course, Arrivee, Coureur from .forms import CourseForm, ScanForm, DossardForm from main.templatetags.temps_format import * from main.templatetags.temps_format import seconds_to_hms # ===================================== # Fonctions utilitaires # ===================================== @login_required @require_GET def coureur_autocomplete(request): """Point d'API AJAX pour l'autocomplétion des coureurs. Recherche par nom, prénom, classe ou identifiant (dossard). Retourne les 10 premiers résultats sous la forme [{'id': ..., 'label': ...}].""" q = request.GET.get('q', '').strip() results = [] if len(q) >= 2: qs = Coureur.objects.filter( models.Q(nom__icontains=q) | models.Q(prenom__icontains=q) | models.Q(classe__icontains=q) | models.Q(id__icontains=q) ).order_by('nom', 'prenom')[:10] for c in qs: label = f"{c.nom} {c.prenom} ({c.classe})" results.append({'id': c.id, 'label': label}) return JsonResponse(results, safe=False) # ===================================== # Vues principales # ===================================== @login_required def main_view(request): """Page d'accueil listant les courses de l'utilisateur. Permet également la création AJAX de nouvelles courses.""" # Annotate each course with the number of scans (arrivees) courses = Course.objects.filter(owner=request.user).annotate(scan_count=Count('arrivees')) # Ajoute un attribut simple 'status' à chaque instance de course afin que les # templates et les appels AJAX puissent connaître l'état : non démarrée / en cours / terminée. for c in courses: if c.depart is None: c.status = 'not_started' elif c.fin is None: c.status = 'ongoing' else: c.status = 'finished' if request.method == 'POST' and request.headers.get('x-requested-with') == 'XMLHttpRequest': nom = request.POST.get('nom') type_ = request.POST.get('type', 'unique') date = timezone.localdate() if not nom: return JsonResponse({'success': False, 'error': "Le nom de la course est requis."}) if type_ not in ['unique', 'multi']: return JsonResponse({'success': False, 'error': "Type de course invalide."}) if Course.objects.filter(nom=nom, date=date).exists(): return JsonResponse({'success': False, 'error': "Une course avec ce nom existe déjà aujourd'hui."}) course = Course.objects.create(nom=nom, date=date, type=type_, owner=request.user) # newly created course hasn't started yet return JsonResponse({'success': True, 'course_id': course.id, 'status': 'not_started', 'scan_count': 0}) form = CourseForm() return render(request, 'main.html', { 'title': 'Accueil', 'courses': courses, 'form': form, 'now': timezone.localdate() }) @login_required @require_POST def delete_course(request, course_id): """Supprime une course appartenant à l'utilisateur via une requête AJAX.""" try: course = Course.objects.get(id=course_id, owner=request.user) course.delete() return JsonResponse({'success': True}) except Course.DoesNotExist: return JsonResponse({'success': False, 'error': "Course introuvable ou non autorisée."}) # ===================================== # Vues d'export # ===================================== @login_required def export_csv(request, course_id): """Export CSV des résultats d'une course. Supporte l'export direct depuis la base ou l'export des lignes filtrées envoyées en POST.""" 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) writer.writerow(['Rang', 'Nom', 'Classe', 'Temps']) import json rows_json = request.POST.get('rows') if request.method == "POST" and rows_json: try: rows = json.loads(rows_json) for row in rows: writer.writerow(row) except Exception: pass else: arrivees = course.arrivees.select_related('coureur').order_by('rang') for a in arrivees: writer.writerow([a.rang, a.coureur.nom, a.coureur.classe, str(a.temps)]) return response @login_required def export_pdf(request, course_id): """Export PDF des résultats d'une course. Supporte l'export direct depuis la base ou l'export des lignes filtrées envoyées en POST.""" 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"' # Configuration du document PDF p = canvas.Canvas(response, pagesize=A4) width, height = A4 # En-tête du document y = height - 50 p.setFont("Helvetica-Bold", 16) p.drawString(50, y, f"Résultats - {course.nom} ({course.date})") # En-tête du tableau y -= 40 p.setFont("Helvetica", 12) p.drawString(50, y, "Rang") p.drawString(100, y, "Nom") p.drawString(300, y, "Classe") p.drawString(400, y, "Temps") y -= 20 # Contenu : soit les lignes filtrées envoyées en POST, soit toutes les arrivées en base import json rows_json = request.POST.get('rows') if request.method == "POST" and rows_json: try: rows = json.loads(rows_json) for row in rows: p.drawString(50, y, str(row[0])) p.drawString(100, y, str(row[1])) p.drawString(300, y, str(row[2])) p.drawString(400, y, str(row[3])) y -= 20 # Nouvelle page si nécessaire if y < 50: p.showPage() y = height - 50 except Exception: pass else: arrivees = course.arrivees.select_related('coureur').order_by('rang') for a in arrivees: p.drawString(50, y, str(a.rang)) p.drawString(100, y, a.coureur.nom) p.drawString(300, y, a.coureur.classe) p.drawString(400, y, str(a.temps)) y -= 20 # Nouvelle page si nécessaire if y < 50: p.showPage() y = height - 50 p.save() return response @login_required def course_detail_view(request, 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 if request.method == 'POST': # Démarrer ou terminer la course via le formulaire de détail if 'start' in request.POST and not is_started: course.depart = timezone.now() course.save() is_started = True elif 'finish' in request.POST and is_started and not is_finished: course.fin = timezone.now() course.save() is_finished = True return redirect('course_detail', course_id=course.id) # Formatage de la date pour affichage JJ/MM/AAAA from main.templatetags.temps_format import format_date date_str = format_date(course.date) return render(request, 'course_detail.html', { 'title': f'Course : {course.nom} ({date_str})', 'course': course, 'arrivees': arrivees, 'is_started': is_started, 'is_finished': is_finished }) @login_required def scan_view(request): # Courses démarrées et pas encore terminées (candidates pour le scan) 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ètres manquants." else: # S'assurer que l'utilisateur scanne uniquement ses propres courses course = get_object_or_404(Course, id=course_id, owner=request.user) # Le QR code contient maintenant l'identifiant unique du Coureur coureur = None try: coureur = Coureur.objects.get(id=qrcode.strip()) except Coureur.DoesNotExist: error = "Coureur introuvable pour ce code QR." if coureur: # Si la course est de type unique : comportement existant (1 arrivée par coureur) if course.type == Course.TYPE_UNIQUE: if Arrivee.objects.filter(course=course, coureur=coureur).exists(): error = "Ce coureur a déjà été scanné." else: temps = timezone.now() - course.depart rang = Arrivee.objects.filter(course=course).count() + 1 arr = Arrivee.objects.create(course=course, coureur=coureur, temps=temps, rang=rang, tour=1) temps_str = seconds_to_hms(temps) # nombre total de scans pour cette course scan_count = Arrivee.objects.filter(course=course).count() result = { 'nom': coureur.nom, 'prenom': coureur.prenom, 'classe': coureur.classe, 'rang': arr.rang, 'temps': temps_str, 'tour': arr.tour, 'scan_count': scan_count } # Pour les courses multi, autoriser plusieurs scans par coureur et calculer le tour/temps de tour else: # Compter les arrivées précédentes pour ce coureur sur cette course previous = Arrivee.objects.filter(course=course, coureur=coureur).order_by('tour', 'date_arrivee') last_arr = previous.last() if last_arr: # prochain tour = dernier tour + 1 next_tour = last_arr.tour + 1 lap_time = timezone.now() - last_arr.date_arrivee else: # premier scan pour ce coureur : tour 1, temps depuis le départ de la course next_tour = 1 lap_time = timezone.now() - course.depart # rang = nombre d'arrivées global + 1 rang = Arrivee.objects.filter(course=course).count() + 1 arr = Arrivee.objects.create(course=course, coureur=coureur, temps=lap_time, rang=rang, tour=next_tour) lap_str = seconds_to_hms(lap_time) scan_count = Arrivee.objects.filter(course=course).count() result = { 'nom': coureur.nom, 'prenom': coureur.prenom, 'classe': coureur.classe, 'rang': arr.rang, 'temps': lap_str, 'tour': arr.tour, 'lap_seconds': int(lap_time.total_seconds()), 'scan_count': scan_count } # Diffuser via le canal websocket channel_layer = get_channel_layer() async_to_sync(channel_layer.group_send)( f'course_{course.id}', { 'type': 'send_arrivee', 'data': result } ) if result: return render(request, 'scan_result.html', {'result': result, 'course_type': course.type if course else None}) else: return render(request, 'scan_result.html', {'error': error, 'course_type': course.type if course else None}) else: course_id = request.GET.get('course_id') if course_id: course = get_object_or_404(Course, id=course_id, owner=request.user) # Formatage de la date pour affichage JJ/MM/AAAA date_str = format_date(course.date) if course else '' # nombre actuel de scans pour cette course scan_count = Arrivee.objects.filter(course=course).count() if course else 0 return render(request, 'scan.html', { 'title': f'Scan course : {course.nom} ({date_str})' if course else '', 'courses': courses, 'result': result, 'error': error, 'course': course, 'scan_count': scan_count }) @login_required def dossards_view(request): error = None progress = None pdf_url = None if request.method == 'POST': # Récupère la liste des IDs de Coureurs envoyés en POST coureur_ids_str = request.POST.get('coureur_ids', '') rows = int(request.POST.get('rows', 2)) cols = int(request.POST.get('cols', 2)) try: coureur_ids = [cid for cid in coureur_ids_str.split(',') if cid.strip()] if not coureur_ids: error = "Aucun coureur sélectionné." else: # Accepte à la fois les PKs et les noms en secours (compatibilité) coureurs = list(Coureur.objects.filter(id__in=coureur_ids)) # En cas de fallback : tenter par nom si non trouvé par id if len(coureurs) < len(coureur_ids): missing = set(coureur_ids) - set(str(c.id) for c in coureurs) for nom in missing: c = Coureur.objects.filter(nom=nom).first() if c: coureurs.append(c) if not coureurs: error = "Aucun coureur trouvé pour les IDs fournis." else: buffer = generate_dossards_pdf(coureurs, rows, cols) response = HttpResponse(buffer, content_type='application/pdf') response['Content-Disposition'] = f'attachment; filename="dossards_{rows}x{cols}.pdf"' return response except Exception as e: error = str(e) # Liste des coureurs et des classes distinctes pour la DataTable coureurs = Coureur.objects.all().order_by('nom', 'prenom', 'classe') classes = Coureur.objects.values_list('classe', flat=True).distinct().order_by('classe') return render(request, 'dossards.html', { 'title': 'G\u00e9n\u00e9ration des dossards PDF', 'error': error, 'progress': progress, 'pdf_url': pdf_url, 'coureurs': coureurs, 'classes': classes, }) def generate_dossards_pdf(data, rows, cols): import io from reportlab.pdfgen import canvas from reportlab.lib.pagesizes import A4 buffer = io.BytesIO() c = canvas.Canvas(buffer, pagesize=A4) width, height = A4 margin = 10 * mm label_w = (width - 2 * margin) / cols label_h = (height - 2 * margin) / rows x0, y0 = margin, height - margin - label_h qr_scale = 0.9 # 90% of the label area for the QR for idx, coureur in enumerate(data): col = idx % cols row = (idx // cols) % rows page = idx // (rows * cols) if idx > 0 and row == 0 and col == 0: c.showPage() x = x0 + col * label_w y = y0 - row * label_h #c.setLineWidth(3) #c.setStrokeColorRGB(0, 0, 0) #c.rect(x, y, label_w, label_h) # Génération du QR # Le QR contient l'identifiant unique du coureur qr = qrcode.make(f"{coureur.id}") qr_img = io.BytesIO() qr.save(qr_img, format='PNG') qr_img.seek(0) qr_pil = Image.open(qr_img) qr_size = min(label_w, label_h) * qr_scale qr_x = x + (label_w - qr_size) / 2 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) # 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, coureur.classe) c.save() buffer.seek(0) return buffer