You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
611 lines
22 KiB
611 lines
22 KiB
# 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', 'Pré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 |
|
|
|
# Choisit l'étiquette de la première colonne selon le type de course |
|
first_col_label = 'Tour' if course.type == 'multi' else 'Rang' |
|
|
|
# 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 (colonnes: premier, Nom, Prénom, Classe, Temps) |
|
y -= 40 |
|
p.setFont("Helvetica", 12) |
|
p.drawString(50, y, first_col_label) |
|
p.drawString(100, y, "Nom") |
|
p.drawString(220, y, "Prénom") |
|
p.drawString(340, y, "Classe") |
|
p.drawString(460, 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: |
|
# If we receive a group marker row (injected client-side when grouping), render it as a header |
|
if isinstance(row, list) and len(row) > 0 and row[0] == '__GROUP__': |
|
# Render group header spanning the table width |
|
p.setFont("Helvetica-Bold", 12) |
|
p.drawString(50, y, str(row[1])) |
|
p.setFont("Helvetica", 12) |
|
else: |
|
# Standard row: expected format [first, nom, prenom, classe, temps] |
|
first = row[0] if len(row) > 0 else '' |
|
nom = row[1] if len(row) > 1 else '' |
|
prenom = row[2] if len(row) > 2 else '' |
|
classe = row[3] if len(row) > 3 else '' |
|
temps_val = row[4] if len(row) > 4 else '' |
|
p.drawString(50, y, str(first)) |
|
p.drawString(100, y, str(nom)) |
|
p.drawString(220, y, str(prenom)) |
|
p.drawString(340, y, str(classe)) |
|
p.drawString(460, y, str(temps_val)) |
|
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: |
|
# Affiche le numéro de tour pour les courses multi, sinon le rang |
|
first_value = a.tour if course.type == 'multi' else a.rang |
|
p.drawString(50, y, str(first_value)) |
|
p.drawString(100, y, a.coureur.nom) |
|
p.drawString(220, y, a.coureur.prenom) |
|
p.drawString(340, y, a.coureur.classe) |
|
p.drawString(460, 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 |
|
|
|
# Liste des classes présentes dans les arrivées de cette course (pour le filtre) |
|
classes = course.arrivees.select_related('coureur').values_list('coureur__classe', flat=True).distinct().order_by('coureur__classe') |
|
|
|
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) |
|
|
|
# Définir la liste des exports disponibles selon le type de course |
|
# Format: list of dicts { 'label': 'Nom export', 'url_name': 'export_xxx' } |
|
export_options = [] |
|
if course.type == Course.TYPE_UNIQUE: |
|
export_options = [ |
|
{ |
|
'label': 'Export résultats (CSV)', |
|
'url_name': 'export_csv', |
|
'help': "Télécharge un fichier CSV contenant les rangs, noms, prénoms, classes et temps des arrivées. Si vous avez filtré ou groupé la table, seules les lignes visibles seront exportées.", |
|
}, |
|
{ |
|
'label': 'Export résultats (PDF)', |
|
'url_name': 'export_pdf', |
|
'help': "Télécharge un fichier PDF contenant les rangs, noms, prénoms, classes et temps des arrivées. Si vous avez filtré ou groupé la table, seules les lignes visibles seront exportées", |
|
}, |
|
{ |
|
'label': 'Calcul des scores par classe (CSV)', |
|
'url_name': 'export_classe_csv', |
|
'help': "Calcule pour chaque classe le score SOMMME DES RANGS / NOMBRE DE COUREURS, et télécharge le classement par score au format CSV.", |
|
}, |
|
{ |
|
'label': 'Calcul des scores par classe (PDF)', |
|
'url_name': 'export_classe', |
|
'help': "Calcule pour chaque classe le score SOMMME DES RANGS / NOMBRE DE COUREURS, et télécharge le classement par score au format PDF.", |
|
}, |
|
] |
|
elif course.type == Course.TYPE_MULTI: |
|
export_options = [ |
|
{ |
|
'label': 'Export résultats (CSV)', |
|
'url_name': 'export_csv', |
|
'help': "Télécharge un fichier CSV contenant les rangs, noms, prénoms, classes et temps des arrivées. Si vous avez filtré ou groupé la table, seules les lignes visibles seront exportées.", |
|
}, |
|
{ |
|
'label': 'Export résultats (PDF)', |
|
'url_name': 'export_pdf', |
|
'help': "Télécharge un fichier PDF contenant les rangs, noms, prénoms, classes et temps des arrivées. Si vous avez filtré ou groupé la table, seules les lignes visibles seront exportées", |
|
}, |
|
] |
|
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, |
|
'export_options': export_options, |
|
'classes': classes, |
|
}) |
|
|
|
|
|
|
|
@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) |
|
if course.fin: |
|
error = "Cette course est terminée. Le scan n'est plus possible." |
|
else: |
|
# 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) |
|
if course.fin: |
|
error = "Cette course est terminée. Le scan n'est plus possible." |
|
|
|
# Formatage de la date pour affichage JJ/MM/AAAA |
|
date_str = format_date(course.date) if course_id and 'course' in locals() else '' |
|
# nombre actuel de scans pour cette course |
|
scan_count = Arrivee.objects.filter(course=course).count() if course_id and 'course' in locals() else 0 |
|
return render(request, 'scan.html', { |
|
'title': f'Scan course : {course.nom} ({date_str})' if course_id and 'course' in locals() else '', |
|
'courses': courses, |
|
'result': result, |
|
'error': error, |
|
'course': course if course_id and 'course' in locals() else None, |
|
'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 |
|
|
|
|
|
@login_required |
|
def export_classe(request, course_id): |
|
"""Export PDF du classement par classe pour une course. |
|
Le score d'une classe = somme des rangs de la classe / nombre de coureurs de la classe. |
|
On ordonne les classes par score croissant (meilleur score en premier). |
|
""" |
|
course = get_object_or_404(Course, id=course_id, owner=request.user) |
|
# Récupérer toutes les arrivées pour cette course |
|
arrivees = course.arrivees.select_related('coureur').all() |
|
|
|
# Agréger par classe |
|
classe_stats = {} |
|
for a in arrivees: |
|
classe = a.coureur.classe or '—' |
|
if classe not in classe_stats: |
|
classe_stats[classe] = {'sum_rangs': 0, 'count': 0} |
|
classe_stats[classe]['sum_rangs'] += (a.rang or 0) |
|
classe_stats[classe]['count'] += 1 |
|
|
|
# Calculer le score pour chaque classe |
|
rows = [] |
|
for classe, stats in classe_stats.items(): |
|
count = stats['count'] |
|
sum_rangs = stats['sum_rangs'] |
|
score = sum_rangs / count if count else float('inf') |
|
rows.append({'classe': classe, 'count': count, 'sum_rangs': sum_rangs, 'score': score}) |
|
|
|
# Trier par score asc |
|
rows.sort(key=lambda r: (r['score'], r['classe'])) |
|
|
|
# Générer PDF |
|
response = HttpResponse(content_type='application/pdf') |
|
response['Content-Disposition'] = f'attachment; filename="course_{course_id}_classement_par_classe.pdf"' |
|
p = canvas.Canvas(response, pagesize=A4) |
|
width, height = A4 |
|
y = height - 50 |
|
p.setFont("Helvetica-Bold", 16) |
|
p.drawString(50, y, f"Classement par classe - {course.nom} ({course.date})") |
|
y -= 30 |
|
p.setFont("Helvetica-Bold", 12) |
|
p.drawString(50, y, "Classe") |
|
p.drawString(220, y, "Nombre") |
|
p.drawString(320, y, "Somme des rangs") |
|
p.drawString(460, y, "Score") |
|
y -= 20 |
|
p.setFont("Helvetica", 12) |
|
for r in rows: |
|
p.drawString(50, y, str(r['classe'])) |
|
p.drawString(220, y, str(r['count'])) |
|
p.drawString(320, y, str(r['sum_rangs'])) |
|
p.drawString(460, y, f"{r['score']:.2f}") |
|
y -= 18 |
|
if y < 50: |
|
p.showPage() |
|
y = height - 50 |
|
p.save() |
|
return response |
|
|
|
|
|
@login_required |
|
def export_classe_csv(request, course_id): |
|
"""Export CSV du classement par classe pour une course. |
|
Le score d'une classe = somme des rangs de la classe / nombre de coureurs de la classe. |
|
On ordonne les classes par score croissant (meilleur score en premier). |
|
""" |
|
course = get_object_or_404(Course, id=course_id, owner=request.user) |
|
arrivees = course.arrivees.select_related('coureur').all() |
|
|
|
# Agréger par classe |
|
classe_stats = {} |
|
for a in arrivees: |
|
classe = a.coureur.classe or '—' |
|
if classe not in classe_stats: |
|
classe_stats[classe] = {'sum_rangs': 0, 'count': 0} |
|
classe_stats[classe]['sum_rangs'] += (a.rang or 0) |
|
classe_stats[classe]['count'] += 1 |
|
|
|
# Calculer le score pour chaque classe |
|
rows = [] |
|
for classe, stats in classe_stats.items(): |
|
count = stats['count'] |
|
sum_rangs = stats['sum_rangs'] |
|
score = sum_rangs / count if count else float('inf') |
|
rows.append({'classe': classe, 'count': count, 'sum_rangs': sum_rangs, 'score': score}) |
|
|
|
# Trier par score asc |
|
rows.sort(key=lambda r: (r['score'], r['classe'])) |
|
|
|
# Préparer la réponse CSV |
|
response = HttpResponse(content_type='text/csv') |
|
response['Content-Disposition'] = f'attachment; filename="course_{course_id}_classement_par_classe.csv"' |
|
writer = csv.writer(response) |
|
writer.writerow(['Classe', 'Nombre', 'Somme des rangs', 'Score']) |
|
for r in rows: |
|
writer.writerow([r['classe'], r['count'], r['sum_rangs'], f"{r['score']:.2f}"]) |
|
return response
|
|
|