Compare commits

..

2 Commits

Author SHA1 Message Date
scayac ddfd32ceb8 Gestion courses multi tours 2 months ago
scayac eb626d5323 mise à jour affichage login 2 months ago
  1. 2
      .gitignore
  2. 26
      main/migrations/0002_alter_arrivee_unique_together_arrivee_tour_and_more.py
  3. 6
      main/models.py
  4. 58
      main/templates/course_detail.html
  5. 20
      main/templates/main.html
  6. 17
      main/templates/registration/login.html
  7. 5
      main/templates/scan_result.html
  8. 151
      main/views.py

2
.gitignore vendored

@ -6,6 +6,6 @@
staticfiles/* staticfiles/*
__pycache__ __pycache__
__init__.py __init__.py
db.sqlite3 *.sqlite3
*.crt *.crt
*.key *.key

26
main/migrations/0002_alter_arrivee_unique_together_arrivee_tour_and_more.py

@ -0,0 +1,26 @@
# Generated by Django 5.2.7 on 2025-10-08 18:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('main', '0001_initial'),
]
operations = [
migrations.AlterUniqueTogether(
name='arrivee',
unique_together=set(),
),
migrations.AddField(
model_name='arrivee',
name='tour',
field=models.PositiveIntegerField(default=1),
),
migrations.AlterUniqueTogether(
name='arrivee',
unique_together={('course', 'coureur', 'tour')},
),
]

6
main/models.py

@ -43,10 +43,14 @@ class Arrivee(models.Model):
coureur = models.ForeignKey('Coureur', on_delete=models.CASCADE) coureur = models.ForeignKey('Coureur', on_delete=models.CASCADE)
temps = models.DurationField() temps = models.DurationField()
rang = models.PositiveIntegerField() rang = models.PositiveIntegerField()
# numéro de tour (pour les courses de type multi)
tour = models.PositiveIntegerField(default=1)
date_arrivee = models.DateTimeField(auto_now_add=True) date_arrivee = models.DateTimeField(auto_now_add=True)
class Meta: class Meta:
unique_together = ('course', 'coureur') # Allow multiple arrivals for the same coureur in the same course
# by including the tour in the uniqueness constraint
unique_together = ('course', 'coureur', 'tour')
ordering = ['rang'] ordering = ['rang']
def __str__(self): def __str__(self):

58
main/templates/course_detail.html

@ -53,6 +53,9 @@
<div class="card-header py-3 d-flex justify-content-between align-items-center"> <div class="card-header py-3 d-flex justify-content-between align-items-center">
<h6 class="m-0 font-weight-bold text-primary">Arrivées</h6> <h6 class="m-0 font-weight-bold text-primary">Arrivées</h6>
<div> <div>
{% if course.type == 'multi' %}
<button id="btnGroup" type="button" class="btn btn-info mb-2">Grouper par coureur</button>
{% endif %}
<form id="exportCsvForm" method="post" action="{% url 'export_csv' course.id %}" style="display:inline;"> <form id="exportCsvForm" method="post" action="{% url 'export_csv' course.id %}" style="display:inline;">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="rows" id="csvRowsInput"> <input type="hidden" name="rows" id="csvRowsInput">
@ -74,7 +77,7 @@
<table class="table table-striped" id="arriveesTable"> <table class="table table-striped" id="arriveesTable">
<thead> <thead>
<tr> <tr>
<th style="width: 1%; white-space: nowrap;">Rang</th> <th style="width: 1%; white-space: nowrap;">{% if course.type == 'multi' %}Tour{% else %}Rang{% endif %}</th>
<th>Nom</th> <th>Nom</th>
<th>Prénom</th> <th>Prénom</th>
<th>Classe</th> <th>Classe</th>
@ -84,7 +87,7 @@
<tbody> <tbody>
{% for a in arrivees %} {% for a in arrivees %}
<tr> <tr>
<td>{{ a.rang }}</td> <td>{% if course.type == 'multi' %}{{ a.tour }}{% else %}{{ a.rang }}{% endif %}</td>
<td>{{ a.coureur.nom }}</td> <td>{{ a.coureur.nom }}</td>
<td>{{ a.coureur.prenom }}</td> <td>{{ a.coureur.prenom }}</td>
<td>{{ a.coureur.classe }}</td> <td>{{ a.coureur.classe }}</td>
@ -115,7 +118,11 @@
<script src="{% static 'bootstrap/dataTables.bootstrap4.min.js' %}"></script> <script src="{% static 'bootstrap/dataTables.bootstrap4.min.js' %}"></script>
<script src="{% static 'jquery/datatables.fr.js' %}"></script> <script src="{% static 'jquery/datatables.fr.js' %}"></script>
<script> <script>
// Style for inserted group header rows
$('<style>').prop('type', 'text/css').html('\n #arriveesTable tbody tr.group td { background: #f8f9fa; font-weight: 600; }\n').appendTo('head');
const courseId = "{{ course.id }}"; const courseId = "{{ course.id }}";
const courseType = "{{ course.type }}";
const wsScheme = window.location.protocol === "https:" ? "wss" : "ws"; const wsScheme = window.location.protocol === "https:" ? "wss" : "ws";
const wsUrl = `${wsScheme}://${window.location.host}/ws/course/${courseId}/`; const wsUrl = `${wsScheme}://${window.location.host}/ws/course/${courseId}/`;
const socket = new WebSocket(wsUrl); const socket = new WebSocket(wsUrl);
@ -133,9 +140,10 @@ socket.onmessage = function(e) {
let rowData; let rowData;
if (typeof data === 'object' && data !== null) { if (typeof data === 'object' && data !== null) {
// Transforme l'objet en tableau dans l'ordre attendu // Transforme l'objet en tableau dans l'ordre attendu (tour/rang, nom, prenom, classe, temps)
let firstCell = (courseType === 'multi') ? data.tour : (data.rang || data.tour);
rowData = [ rowData = [
data.rang, firstCell,
data.nom || (data.coureur && data.coureur.nom), data.nom || (data.coureur && data.coureur.nom),
data.prenom || (data.coureur && data.coureur.prenom), data.prenom || (data.coureur && data.coureur.prenom),
data.classe || (data.coureur && data.coureur.classe), data.classe || (data.coureur && data.coureur.classe),
@ -156,7 +164,47 @@ document.getElementById('btnFinish').onclick = function() {
// Initialisation DataTables au chargement // Initialisation DataTables au chargement
$(document).ready(function() { $(document).ready(function() {
$('#arriveesTable').DataTable(); // Initialize DataTable with options
var groupBy = false;
var table = $('#arriveesTable').DataTable({
order: [],
pageLength: 25,
drawCallback: function(settings) {
var api = this.api();
// Remove previously inserted group rows
api.rows().nodes().to$().filter('tr.group').remove();
if (!groupBy) return;
var rows = api.rows({page:'current'}).nodes();
var last = null;
api.column(1, {page:'current'} ).data().each(function(name, i){
var prenom = api.column(2, {page:'current'} ).data()[i];
var key = name + '|' + prenom;
if (last !== key) {
$(rows).eq(i).before(
'<tr class="group"><td colspan="5">'+ $('<div>').text(name + ' ' + prenom).html() +'</td></tr>'
);
last = key;
}
});
}
});
// Keep the current ordering so we can restore it
var storedOrder = table.order();
$('#btnGroup').on('click', function(){
groupBy = !groupBy;
$(this).text(groupBy ? 'Désactiver le groupement' : 'Grouper par coureur');
if (groupBy) {
// Sort by Nom (col 1) then Prénom (col 2) to make grouping contiguous
table.order([[1, 'asc'], [2, 'asc']]);
} else {
// Restore previous ordering
table.order(storedOrder);
}
table.draw();
});
}); });
// Export CSV/PDF des données filtrées // Export CSV/PDF des données filtrées
function getVisibleRows() { function getVisibleRows() {

20
main/templates/main.html

@ -12,13 +12,21 @@
<ul class="list-group"> <ul class="list-group">
{% for course in courses %} {% for course in courses %}
<li class="list-group-item d-flex justify-content-between align-items-center"> <li class="list-group-item d-flex justify-content-between align-items-center">
<span>{{ course.nom }} ({{ course.date|format_date }}) <div class="row align-items-center col-xs-12 col-lg-4">
<span>{{ course.nom }}<br/>{{ course.date|format_date }}<br/>{{ course.scan_count }} scan{% if course.scan_count > 1 %}s{% endif %}</span>
{% if course.type == 'unique' %} {% if course.type == 'unique' %}
<span class="badge badge-primary ml-1">Unique</span> <span class="badge badge-primary ml-2 mr-1">Unique</span>
{% elif course.type == 'multi' %} {% elif course.type == 'multi' %}
<span class="badge badge-info ml-1">Multi</span> <span class="badge badge-info ml-2 mr-1">Multi</span>
{% endif %} {% endif %}
</span> {% if course.status == 'not_started' %}
<span class="badge badge-danger ml-1 mr-1">En attente</span>
{% elif course.status == 'ongoing' %}
<span class="badge badge-success ml-1 mr-1">En cours</span>
{% elif course.status == 'finished' %}
<span class="badge badge-secondary ml-1 mr-1">Terminée</span>
{% endif %}
</div>
<div> <div>
<a href="{% url 'course_detail' course.id %}" class="btn btn-primary btn-sm mr-2"> <a href="{% url 'course_detail' course.id %}" class="btn btn-primary btn-sm mr-2">
<i class="fas fa-eye" title="Détails de la course"></i> <i class="fas fa-eye" title="Détails de la course"></i>
@ -102,8 +110,8 @@
{% block extra_js %} {% block extra_js %}
<script> <script>
// Suppression d'une course avec confirmation via modal // Suppression d'une course avec confirmation via modal
let courseIdToDelete = null; var courseIdToDelete = null;
let courseNomToDelete = ''; var courseNomToDelete = '';
document.querySelectorAll('.btn-delete-course').forEach(function(btn) { document.querySelectorAll('.btn-delete-course').forEach(function(btn) {
btn.addEventListener('click', function() { btn.addEventListener('click', function() {
courseIdToDelete = this.getAttribute('data-course-id'); courseIdToDelete = this.getAttribute('data-course-id');

17
main/templates/registration/login.html

@ -9,6 +9,13 @@
<link href="{% static 'fontawesomefree/css/fontawesome.css' %}" rel="stylesheet"> <link href="{% static 'fontawesomefree/css/fontawesome.css' %}" rel="stylesheet">
<link href="{% static 'fontawesomefree/css/brands.css' %}" rel="stylesheet"> <link href="{% static 'fontawesomefree/css/brands.css' %}" rel="stylesheet">
<link href="{% static 'fontawesomefree/css/solid.css' %}" rel="stylesheet"> <link href="{% static 'fontawesomefree/css/solid.css' %}" rel="stylesheet">
<style>
.label {
min-width: 120px !important;
display: inline-block !important;
text-align: right !important;
}
</style>
</head> </head>
<body class="bg-gradient-primary"> <body class="bg-gradient-primary">
@ -22,7 +29,15 @@
</div> </div>
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
{{ form.as_p }} {{ form.non_field_errors }}
<div class="form-group text-center">
<label class="label" for="{{ form.username.id_for_label }}">Login :</label>
{{ form.username }}
</div>
<div class="form-group text-center">
<label class="label" for="{{ form.password.id_for_label }}">Mot de passe :</label>
{{ form.password }}
</div>
<button type="submit" class="btn btn-primary btn-block">Se connecter</button> <button type="submit" class="btn btn-primary btn-block">Se connecter</button>
</form> </form>
{% if form.errors %} {% if form.errors %}

5
main/templates/scan_result.html

@ -3,8 +3,13 @@
<strong>Arrivée enregistrée :</strong><br> <strong>Arrivée enregistrée :</strong><br>
Coureur : {{ result.nom }} {{ result.prenom }}<br> Coureur : {{ result.nom }} {{ result.prenom }}<br>
Classe : {{ result.classe }}<br> Classe : {{ result.classe }}<br>
{% if course_type == 'multi' %}
Tour : {{ result.tour }}<br>
Temps (tour) : {{ result.temps }}
{% else %}
Rang : {{ result.rang }}<br> Rang : {{ result.rang }}<br>
Temps : {{ result.temps }} Temps : {{ result.temps }}
{% endif %}
</div> </div>
{% elif error %} {% elif error %}
<div class="alert alert-danger">{{ error }}</div> <div class="alert alert-danger">{{ error }}</div>

151
main/views.py

@ -1,17 +1,29 @@
# Standard library # Bibliothèques standard
import csv import csv
import io import io
from datetime import timedelta from datetime import timedelta
# Django # Django (importations)
from django.db import models 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.http import HttpResponse, JsonResponse
from django.shortcuts import render, redirect, get_object_or_404 from django.shortcuts import render, redirect, get_object_or_404
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.utils import timezone from django.utils import timezone
from django.views.decorators.http import require_GET, require_POST from django.views.decorators.http import require_GET, require_POST
# Third party # Bibliothèques tierces
from reportlab.pdfgen import canvas from reportlab.pdfgen import canvas
from reportlab.lib.pagesizes import A4 from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm from reportlab.lib.units import mm
@ -20,21 +32,24 @@ from PIL import Image
from channels.layers import get_channel_layer from channels.layers import get_channel_layer
from asgiref.sync import async_to_sync from asgiref.sync import async_to_sync
# Local # Importations locales
from .models import Course, Arrivee, Coureur from .models import Course, Arrivee, Coureur
from .forms import CourseForm, ScanForm, DossardForm from .forms import CourseForm, ScanForm, DossardForm
from main.templatetags.temps_format import * from main.templatetags.temps_format import *
from main.templatetags.temps_format import seconds_to_hms
# ===================================== # =====================================
# Fonctions utilitaires # Fonctions utilitaires
# ===================================== # =====================================
@login_required @login_required
@require_GET @require_GET
def coureur_autocomplete(request): def coureur_autocomplete(request):
"""Endpoint AJAX pour l'autocomplétion des coureurs. """Point d'API AJAX pour l'autocomplétion des coureurs.
Recherche sur nom, prénom, classe et dossard. Recherche par nom, prénom, classe ou identifiant (dossard).
Retourne les 10 premiers résultats au format {id, label}.""" Retourne les 10 premiers résultats sous la forme [{'id': ..., 'label': ...}]."""
q = request.GET.get('q', '').strip() q = request.GET.get('q', '').strip()
results = [] results = []
if len(q) >= 2: if len(q) >= 2:
@ -45,19 +60,31 @@ def coureur_autocomplete(request):
models.Q(id__icontains=q) models.Q(id__icontains=q)
).order_by('nom', 'prenom')[:10] ).order_by('nom', 'prenom')[:10]
for c in qs: for c in qs:
label = f"{c.nom} {c.prenom} ({c.classe}) [dossard: {c.id}]" label = f"{c.nom} {c.prenom} ({c.classe})"
results.append({'id': c.id, 'label': label}) results.append({'id': c.id, 'label': label})
return JsonResponse(results, safe=False) return JsonResponse(results, safe=False)
# ===================================== # =====================================
# Vues principales # Vues principales
# ===================================== # =====================================
@login_required @login_required
def main_view(request): def main_view(request):
"""Page d'accueil listant les courses de l'utilisateur. """Page d'accueil listant les courses de l'utilisateur.
Permet aussi la création AJAX de nouvelles courses.""" Permet également la création AJAX de nouvelles courses."""
courses = Course.objects.filter(owner=request.user) # 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': if request.method == 'POST' and request.headers.get('x-requested-with') == 'XMLHttpRequest':
nom = request.POST.get('nom') nom = request.POST.get('nom')
type_ = request.POST.get('type', 'unique') type_ = request.POST.get('type', 'unique')
@ -69,7 +96,8 @@ def main_view(request):
if Course.objects.filter(nom=nom, date=date).exists(): if Course.objects.filter(nom=nom, date=date).exists():
return JsonResponse({'success': False, 'error': "Une course avec ce nom existe déjà aujourd'hui."}) 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) course = Course.objects.create(nom=nom, date=date, type=type_, owner=request.user)
return JsonResponse({'success': True, 'course_id': course.id}) # newly created course hasn't started yet
return JsonResponse({'success': True, 'course_id': course.id, 'status': 'not_started', 'scan_count': 0})
form = CourseForm() form = CourseForm()
return render(request, 'main.html', { return render(request, 'main.html', {
@ -79,10 +107,12 @@ def main_view(request):
'now': timezone.localdate() 'now': timezone.localdate()
}) })
@login_required @login_required
@require_POST @require_POST
def delete_course(request, course_id): def delete_course(request, course_id):
"""Supprime une course appartenant à l'utilisateur (AJAX).""" """Supprime une course appartenant à l'utilisateur via une requête AJAX."""
try: try:
course = Course.objects.get(id=course_id, owner=request.user) course = Course.objects.get(id=course_id, owner=request.user)
course.delete() course.delete()
@ -90,14 +120,16 @@ def delete_course(request, course_id):
except Course.DoesNotExist: except Course.DoesNotExist:
return JsonResponse({'success': False, 'error': "Course introuvable ou non autorisée."}) return JsonResponse({'success': False, 'error': "Course introuvable ou non autorisée."})
# ===================================== # =====================================
# Vues d'export # Vues d'export
# ===================================== # =====================================
@login_required @login_required
def export_csv(request, course_id): def export_csv(request, course_id):
"""Export des résultats d'une course en CSV. """Export CSV des résultats d'une course.
Supporte soit l'export direct depuis la base, soit l'export des lignes filtrées envoyées en POST.""" 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) 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"'
@ -119,10 +151,11 @@ 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 @login_required
def export_pdf(request, course_id): def export_pdf(request, course_id):
"""Export des résultats d'une course en PDF. """Export PDF des résultats d'une course.
Supporte soit l'export direct depuis la base, soit l'export des lignes filtrées envoyées en POST.""" 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) 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"'
@ -131,7 +164,7 @@ def export_pdf(request, course_id):
p = canvas.Canvas(response, pagesize=A4) p = canvas.Canvas(response, pagesize=A4)
width, height = A4 width, height = A4
# En-tête # En-tête du document
y = height - 50 y = height - 50
p.setFont("Helvetica-Bold", 16) p.setFont("Helvetica-Bold", 16)
p.drawString(50, y, f"Résultats - {course.nom} ({course.date})") p.drawString(50, y, f"Résultats - {course.nom} ({course.date})")
@ -145,7 +178,7 @@ def export_pdf(request, course_id):
p.drawString(400, y, "Temps") p.drawString(400, y, "Temps")
y -= 20 y -= 20
# Contenu : soit les lignes filtrées, soit toutes les arrivées # Contenu : soit les lignes filtrées envoyées en POST, soit toutes les arrivées en base
import json import json
rows_json = request.POST.get('rows') rows_json = request.POST.get('rows')
if request.method == "POST" and rows_json: if request.method == "POST" and rows_json:
@ -179,6 +212,7 @@ def export_pdf(request, course_id):
p.save() p.save()
return response return response
@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, owner=request.user) course = get_object_or_404(Course, id=course_id, owner=request.user)
@ -187,6 +221,7 @@ def course_detail_view(request, course_id):
is_finished = course.fin is not None is_finished = course.fin is not None
if request.method == 'POST': 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: if 'start' in request.POST and not is_started:
course.depart = timezone.now() course.depart = timezone.now()
course.save() course.save()
@ -208,8 +243,10 @@ def course_detail_view(request, course_id):
'is_finished': is_finished 'is_finished': is_finished
}) })
@login_required @login_required
def scan_view(request): 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) courses = Course.objects.filter(owner=request.user, depart__isnull=False, fin__isnull=True)
result = None result = None
error = None error = None
@ -219,12 +256,12 @@ def scan_view(request):
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ètres manquants."
else: else:
# ensure user can only scan their own course # S'assurer que l'utilisateur scanne uniquement ses propres courses
course = get_object_or_404(Course, id=course_id, owner=request.user) course = get_object_or_404(Course, id=course_id, owner=request.user)
# QR code now contains the unique Coureur id # Le QR code contient maintenant l'identifiant unique du Coureur
coureur = None coureur = None
try: try:
coureur = Coureur.objects.get(id=qrcode.strip()) coureur = Coureur.objects.get(id=qrcode.strip())
@ -232,20 +269,55 @@ def scan_view(request):
error = "Coureur introuvable pour ce code QR." error = "Coureur introuvable pour ce code QR."
if coureur: 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(): 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éjà été scanné."
else: else:
temps = timezone.now() - course.depart temps = timezone.now() - course.depart
rang = Arrivee.objects.filter(course=course).count() + 1 rang = Arrivee.objects.filter(course=course).count() + 1
Arrivee.objects.create(course=course, coureur=coureur, temps=temps, rang=rang) arr = Arrivee.objects.create(course=course, coureur=coureur, temps=temps, rang=rang, tour=1)
temps = seconds_to_hms(temps) 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 = { result = {
'nom': coureur.nom, 'nom': coureur.nom,
'prenom': coureur.prenom, 'prenom': coureur.prenom,
'classe': coureur.classe, 'classe': coureur.classe,
'rang': rang, 'rang': arr.rang,
'temps': temps '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() channel_layer = get_channel_layer()
async_to_sync(channel_layer.group_send)( async_to_sync(channel_layer.group_send)(
f'course_{course.id}', f'course_{course.id}',
@ -256,9 +328,9 @@ def scan_view(request):
) )
if result: if result:
return render(request, 'scan_result.html', {'result': result}) return render(request, 'scan_result.html', {'result': result, 'course_type': course.type if course else None})
else: else:
return render(request, 'scan_result.html', {'error': error}) return render(request, 'scan_result.html', {'error': error, 'course_type': course.type if course else None})
else: else:
course_id = request.GET.get('course_id') course_id = request.GET.get('course_id')
@ -267,12 +339,15 @@ def scan_view(request):
# Formatage de la date pour affichage JJ/MM/AAAA # Formatage de la date pour affichage JJ/MM/AAAA
date_str = format_date(course.date) if course else '' 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', { return render(request, 'scan.html', {
'title': f'Scan course : {course.nom} ({date_str})' if course else '', 'title': f'Scan course : {course.nom} ({date_str})' if course else '',
'courses': courses, 'courses': courses,
'result': result, 'result': result,
'error': error, 'error': error,
'course': course 'course': course,
'scan_count': scan_count
}) })
@login_required @login_required
@ -281,7 +356,7 @@ def dossards_view(request):
progress = None progress = None
pdf_url = None pdf_url = None
if request.method == 'POST': if request.method == 'POST':
# Get the list of Coureur IDs from the POST data # Récupère la liste des IDs de Coureurs envoyés en POST
coureur_ids_str = request.POST.get('coureur_ids', '') coureur_ids_str = request.POST.get('coureur_ids', '')
rows = int(request.POST.get('rows', 2)) rows = int(request.POST.get('rows', 2))
cols = int(request.POST.get('cols', 2)) cols = int(request.POST.get('cols', 2))
@ -290,9 +365,9 @@ def dossards_view(request):
if not coureur_ids: if not coureur_ids:
error = "Aucun coureur sélectionné." error = "Aucun coureur sélectionné."
else: else:
# Accept both PKs and names for fallback (legacy/fallback) # Accepte à la fois les PKs et les noms en secours (compatibilité)
coureurs = list(Coureur.objects.filter(id__in=coureur_ids)) coureurs = list(Coureur.objects.filter(id__in=coureur_ids))
# If fallback: try by name if not found by id # En cas de fallback : tenter par nom si non trouvé par id
if len(coureurs) < len(coureur_ids): if len(coureurs) < len(coureur_ids):
missing = set(coureur_ids) - set(str(c.id) for c in coureurs) missing = set(coureur_ids) - set(str(c.id) for c in coureurs)
for nom in missing: for nom in missing:
@ -332,7 +407,7 @@ def generate_dossards_pdf(data, rows, cols):
label_w = (width - 2 * margin) / cols label_w = (width - 2 * margin) / cols
label_h = (height - 2 * margin) / rows label_h = (height - 2 * margin) / rows
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.9 # 90% of the label area for the QR
for idx, coureur in enumerate(data): for idx, coureur in enumerate(data):
col = idx % cols col = idx % cols
row = (idx // cols) % rows row = (idx // cols) % rows
@ -341,11 +416,11 @@ def generate_dossards_pdf(data, rows, cols):
c.showPage() c.showPage()
x = x0 + col * label_w x = x0 + col * label_w
y = y0 - row * label_h y = y0 - row * label_h
c.setLineWidth(3) #c.setLineWidth(3)
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 # Génération du QR
# QR contains the unique coureur id # Le QR contient l'identifiant unique du coureur
qr = qrcode.make(f"{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')

Loading…
Cancel
Save