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.
 
 
 
 
 

608 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
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,
})
@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