commit
8cdfcbcdca
59 changed files with 1358 additions and 0 deletions
@ -0,0 +1,11 @@ |
|||||||
|
*.log |
||||||
|
*.pot |
||||||
|
*.pyc |
||||||
|
.venv |
||||||
|
.vscode |
||||||
|
staticfiles/* |
||||||
|
__pycache__ |
||||||
|
__init__.py |
||||||
|
db.sqlite3 |
||||||
|
*.crt |
||||||
|
*.key |
||||||
@ -0,0 +1,23 @@ |
|||||||
|
# Dockerfile for Django + Daphne |
||||||
|
FROM python:3.11-slim |
||||||
|
|
||||||
|
WORKDIR /app |
||||||
|
|
||||||
|
# Install system dependencies |
||||||
|
RUN apt-get update && apt-get install -y build-essential libpq-dev && rm -rf /var/lib/apt/lists/* |
||||||
|
|
||||||
|
# Install Python dependencies |
||||||
|
COPY requirements.txt ./ |
||||||
|
RUN pip install --no-cache-dir -r requirements.txt |
||||||
|
|
||||||
|
# Copy project files |
||||||
|
COPY . . |
||||||
|
|
||||||
|
# Collect static files |
||||||
|
RUN python manage.py collectstatic --noinput |
||||||
|
|
||||||
|
# Expose Daphne port |
||||||
|
EXPOSE 8000 |
||||||
|
|
||||||
|
# Start Daphne |
||||||
|
CMD ["daphne", "-b", "0.0.0.0", "-p", "8000", "crossapp.asgi:application"] |
||||||
@ -0,0 +1,42 @@ |
|||||||
|
# CrossApp |
||||||
|
|
||||||
|
Application Django pour l’enregistrement des temps de courses des coureurs. |
||||||
|
|
||||||
|
## Fonctionnalités principales |
||||||
|
- Authentification utilisateur |
||||||
|
- Vue principale responsive (Bootstrap SB Admin 2) |
||||||
|
- Gestion des courses (création, historique, vue course) |
||||||
|
- Scan QR code (html5-qrcode) |
||||||
|
- Génération de dossards PDF |
||||||
|
- Export des résultats en CSV/PDF |
||||||
|
- Module admin Django |
||||||
|
|
||||||
|
## Installation |
||||||
|
1. Créez et activez un environnement virtuel Python |
||||||
|
2. Installez les dépendances : |
||||||
|
```bash |
||||||
|
pip install django channels reportlab qrcode pandas |
||||||
|
``` |
||||||
|
3. Appliquez les migrations : |
||||||
|
```bash |
||||||
|
python manage.py migrate |
||||||
|
``` |
||||||
|
4. Créez un superutilisateur : |
||||||
|
```bash |
||||||
|
python manage.py createsuperuser |
||||||
|
``` |
||||||
|
5. Lancez le serveur : |
||||||
|
```bash |
||||||
|
python manage.py runserver |
||||||
|
``` |
||||||
|
|
||||||
|
## Structure des apps |
||||||
|
- `courses` : gestion des courses |
||||||
|
- `coureurs` : gestion des coureurs |
||||||
|
- `scan` : scan QR code et gestion des arrivées |
||||||
|
- `dossards` : génération PDF des dossards |
||||||
|
- `websocket` : affichage en direct |
||||||
|
|
||||||
|
## Frontend |
||||||
|
- Bootstrap SB Admin 2 |
||||||
|
- html5-qrcode (scan) |
||||||
@ -0,0 +1,9 @@ |
|||||||
|
|
||||||
|
from django.contrib import admin |
||||||
|
from .models import Coureur |
||||||
|
|
||||||
|
@admin.register(Coureur) |
||||||
|
class CoureurAdmin(admin.ModelAdmin): |
||||||
|
list_display = ('nom', 'classe') |
||||||
|
search_fields = ('nom', 'classe') |
||||||
|
list_filter = ('classe',) |
||||||
@ -0,0 +1,6 @@ |
|||||||
|
from django.apps import AppConfig |
||||||
|
|
||||||
|
|
||||||
|
class CoureursConfig(AppConfig): |
||||||
|
default_auto_field = 'django.db.models.BigAutoField' |
||||||
|
name = 'coureurs' |
||||||
@ -0,0 +1,22 @@ |
|||||||
|
# Generated by Django 5.2.6 on 2025-09-17 19:57 |
||||||
|
|
||||||
|
from django.db import migrations, models |
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration): |
||||||
|
|
||||||
|
initial = True |
||||||
|
|
||||||
|
dependencies = [ |
||||||
|
] |
||||||
|
|
||||||
|
operations = [ |
||||||
|
migrations.CreateModel( |
||||||
|
name='Coureur', |
||||||
|
fields=[ |
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
||||||
|
('nom', models.CharField(max_length=100)), |
||||||
|
('classe', models.CharField(max_length=50)), |
||||||
|
], |
||||||
|
), |
||||||
|
] |
||||||
@ -0,0 +1,9 @@ |
|||||||
|
|
||||||
|
from django.db import models |
||||||
|
|
||||||
|
class Coureur(models.Model): |
||||||
|
nom = models.CharField(max_length=100) |
||||||
|
classe = models.CharField(max_length=50) |
||||||
|
|
||||||
|
def __str__(self): |
||||||
|
return f"{self.nom} ({self.classe})" |
||||||
@ -0,0 +1,3 @@ |
|||||||
|
from django.test import TestCase |
||||||
|
|
||||||
|
# Create your tests here. |
||||||
@ -0,0 +1,3 @@ |
|||||||
|
from django.shortcuts import render |
||||||
|
|
||||||
|
# Create your views here. |
||||||
@ -0,0 +1,15 @@ |
|||||||
|
|
||||||
|
from django.contrib import admin |
||||||
|
from .models import Course, Arrivee |
||||||
|
|
||||||
|
@admin.register(Course) |
||||||
|
class CourseAdmin(admin.ModelAdmin): |
||||||
|
list_display = ('nom', 'date', 'depart', 'fin') |
||||||
|
search_fields = ('nom',) |
||||||
|
list_filter = ('date',) |
||||||
|
|
||||||
|
@admin.register(Arrivee) |
||||||
|
class ArriveeAdmin(admin.ModelAdmin): |
||||||
|
list_display = ('course', 'coureur', 'temps', 'rang', 'date_arrivee') |
||||||
|
search_fields = ('course__nom', 'coureur__nom') |
||||||
|
list_filter = ('course',) |
||||||
@ -0,0 +1,6 @@ |
|||||||
|
from django.apps import AppConfig |
||||||
|
|
||||||
|
|
||||||
|
class CoursesConfig(AppConfig): |
||||||
|
default_auto_field = 'django.db.models.BigAutoField' |
||||||
|
name = 'courses' |
||||||
@ -0,0 +1,24 @@ |
|||||||
|
from django import forms |
||||||
|
from .models import Course |
||||||
|
|
||||||
|
from django.utils import timezone |
||||||
|
|
||||||
|
class CourseForm(forms.ModelForm): |
||||||
|
class Meta: |
||||||
|
model = Course |
||||||
|
fields = ['nom'] |
||||||
|
|
||||||
|
def save(self, commit=True): |
||||||
|
instance = super().save(commit=False) |
||||||
|
instance.date = timezone.localdate() |
||||||
|
if commit: |
||||||
|
instance.save() |
||||||
|
return instance |
||||||
|
|
||||||
|
def clean(self): |
||||||
|
cleaned_data = super().clean() |
||||||
|
nom = cleaned_data.get('nom') |
||||||
|
date = timezone.localdate() |
||||||
|
if Course.objects.filter(nom=nom, date=date).exists(): |
||||||
|
raise forms.ValidationError("Une course avec ce nom existe déjà aujourd'hui.") |
||||||
|
return cleaned_data |
||||||
@ -0,0 +1,45 @@ |
|||||||
|
# Generated by Django 5.2.6 on 2025-09-17 19:57 |
||||||
|
|
||||||
|
import django.db.models.deletion |
||||||
|
from django.db import migrations, models |
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration): |
||||||
|
|
||||||
|
initial = True |
||||||
|
|
||||||
|
dependencies = [ |
||||||
|
('coureurs', '0001_initial'), |
||||||
|
] |
||||||
|
|
||||||
|
operations = [ |
||||||
|
migrations.CreateModel( |
||||||
|
name='Course', |
||||||
|
fields=[ |
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
||||||
|
('nom', models.CharField(max_length=100)), |
||||||
|
('date', models.DateField()), |
||||||
|
('depart', models.DateTimeField(blank=True, null=True)), |
||||||
|
('fin', models.DateTimeField(blank=True, null=True)), |
||||||
|
], |
||||||
|
options={ |
||||||
|
'ordering': ['-date', 'nom'], |
||||||
|
'unique_together': {('nom', 'date')}, |
||||||
|
}, |
||||||
|
), |
||||||
|
migrations.CreateModel( |
||||||
|
name='Arrivee', |
||||||
|
fields=[ |
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
||||||
|
('temps', models.DurationField()), |
||||||
|
('rang', models.PositiveIntegerField()), |
||||||
|
('date_arrivee', models.DateTimeField(auto_now_add=True)), |
||||||
|
('coureur', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='coureurs.coureur')), |
||||||
|
('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='arrivees', to='courses.course')), |
||||||
|
], |
||||||
|
options={ |
||||||
|
'ordering': ['rang'], |
||||||
|
'unique_together': {('course', 'coureur')}, |
||||||
|
}, |
||||||
|
), |
||||||
|
] |
||||||
@ -0,0 +1,32 @@ |
|||||||
|
|
||||||
|
from django.db import models |
||||||
|
|
||||||
|
class Course(models.Model): |
||||||
|
nom = models.CharField(max_length=100) |
||||||
|
date = models.DateField() |
||||||
|
depart = models.DateTimeField(null=True, blank=True) |
||||||
|
fin = models.DateTimeField(null=True, blank=True) |
||||||
|
|
||||||
|
class Meta: |
||||||
|
unique_together = ('nom', 'date') |
||||||
|
ordering = ['-date', 'nom'] |
||||||
|
|
||||||
|
def __str__(self): |
||||||
|
return f"{self.nom} ({self.date})" |
||||||
|
|
||||||
|
|
||||||
|
from coureurs.models import Coureur |
||||||
|
|
||||||
|
class Arrivee(models.Model): |
||||||
|
course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name='arrivees') |
||||||
|
coureur = models.ForeignKey(Coureur, on_delete=models.CASCADE) |
||||||
|
temps = models.DurationField() |
||||||
|
rang = models.PositiveIntegerField() |
||||||
|
date_arrivee = models.DateTimeField(auto_now_add=True) |
||||||
|
|
||||||
|
class Meta: |
||||||
|
unique_together = ('course', 'coureur') |
||||||
|
ordering = ['rang'] |
||||||
|
|
||||||
|
def __str__(self): |
||||||
|
return f"{self.coureur.nom} - {self.course.nom} ({self.temps})" |
||||||
@ -0,0 +1,3 @@ |
|||||||
|
from django.test import TestCase |
||||||
|
|
||||||
|
# Create your tests here. |
||||||
@ -0,0 +1,9 @@ |
|||||||
|
from django.urls import path |
||||||
|
from .views import main_view, course_detail_view, export_csv, export_pdf |
||||||
|
|
||||||
|
urlpatterns = [ |
||||||
|
path('', main_view, name='main'), |
||||||
|
path('course/<int:course_id>/', course_detail_view, name='course_detail'), |
||||||
|
path('course/<int:course_id>/export_csv/', export_csv, name='export_csv'), |
||||||
|
path('course/<int:course_id>/export_pdf/', export_pdf, name='export_pdf'), |
||||||
|
] |
||||||
@ -0,0 +1,105 @@ |
|||||||
|
import csv |
||||||
|
from django.http import HttpResponse |
||||||
|
from reportlab.pdfgen import canvas |
||||||
|
from reportlab.lib.pagesizes import A4 |
||||||
|
from django.contrib.auth.decorators import login_required |
||||||
|
def export_csv(request, course_id): |
||||||
|
course = get_object_or_404(Course, id=course_id) |
||||||
|
arrivees = course.arrivees.select_related('coureur').order_by('rang') |
||||||
|
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']) |
||||||
|
for a in arrivees: |
||||||
|
writer.writerow([a.rang, a.coureur.nom, a.coureur.classe, str(a.temps)]) |
||||||
|
return response |
||||||
|
|
||||||
|
def export_pdf(request, course_id): |
||||||
|
course = get_object_or_404(Course, id=course_id) |
||||||
|
arrivees = course.arrivees.select_related('coureur').order_by('rang') |
||||||
|
response = HttpResponse(content_type='application/pdf') |
||||||
|
response['Content-Disposition'] = f'attachment; filename="course_{course_id}_resultats.pdf"' |
||||||
|
p = canvas.Canvas(response, pagesize=A4) |
||||||
|
width, height = A4 |
||||||
|
y = height - 50 |
||||||
|
p.setFont("Helvetica-Bold", 16) |
||||||
|
p.drawString(50, y, f"Résultats - {course.nom} ({course.date})") |
||||||
|
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 |
||||||
|
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 |
||||||
|
if y < 50: |
||||||
|
p.showPage() |
||||||
|
y = height - 50 |
||||||
|
p.save() |
||||||
|
return response |
||||||
|
|
||||||
|
from django.shortcuts import get_object_or_404 |
||||||
|
from django.utils import timezone |
||||||
|
from .models import Arrivee |
||||||
|
|
||||||
|
@login_required |
||||||
|
def course_detail_view(request, course_id): |
||||||
|
course = get_object_or_404(Course, id=course_id) |
||||||
|
arrivees = course.arrivees.select_related('coureur').order_by('rang') |
||||||
|
is_started = course.depart is not None |
||||||
|
is_finished = course.fin is not None |
||||||
|
|
||||||
|
# Gestion du bouton départ/fin |
||||||
|
if request.method == 'POST': |
||||||
|
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) |
||||||
|
|
||||||
|
if request.headers.get('x-requested-with') == 'XMLHttpRequest': |
||||||
|
# Ne renvoie que le tbody pour le rafraîchissement websocket |
||||||
|
from django.template.loader import render_to_string |
||||||
|
tbody = render_to_string('arrivees_tbody.html', {'arrivees': arrivees}) |
||||||
|
return HttpResponse(tbody) |
||||||
|
return render(request, 'course_detail.html', { |
||||||
|
'course': course, |
||||||
|
'arrivees': arrivees, |
||||||
|
'is_started': is_started, |
||||||
|
'is_finished': is_finished |
||||||
|
}) |
||||||
|
|
||||||
|
from django.shortcuts import render, redirect |
||||||
|
from django.contrib.auth.decorators import login_required |
||||||
|
from .models import Course |
||||||
|
from .forms import CourseForm |
||||||
|
|
||||||
|
@login_required |
||||||
|
def main_view(request): |
||||||
|
from django.utils import timezone |
||||||
|
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éjà 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', { |
||||||
|
'courses': courses, |
||||||
|
'form': form, |
||||||
|
'now': timezone.localdate() |
||||||
|
}) |
||||||
@ -0,0 +1,27 @@ |
|||||||
|
""" |
||||||
|
ASGI config for crossapp project. |
||||||
|
|
||||||
|
It exposes the ASGI callable as a module-level variable named ``application``. |
||||||
|
|
||||||
|
For more information on this file, see |
||||||
|
https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/ |
||||||
|
""" |
||||||
|
|
||||||
|
|
||||||
|
import os |
||||||
|
from channels.routing import ProtocolTypeRouter, URLRouter |
||||||
|
from django.core.asgi import get_asgi_application |
||||||
|
from channels.auth import AuthMiddlewareStack |
||||||
|
|
||||||
|
import crossapp.routing |
||||||
|
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'crossapp.settings') |
||||||
|
|
||||||
|
application = ProtocolTypeRouter({ |
||||||
|
"http": get_asgi_application(), |
||||||
|
"websocket": AuthMiddlewareStack( |
||||||
|
URLRouter( |
||||||
|
crossapp.routing.websocket_urlpatterns |
||||||
|
) |
||||||
|
), |
||||||
|
}) |
||||||
@ -0,0 +1,6 @@ |
|||||||
|
from django.urls import re_path |
||||||
|
from websocket.consumers import ArriveeConsumer |
||||||
|
|
||||||
|
websocket_urlpatterns = [ |
||||||
|
re_path(r'ws/course/(?P<course_id>\d+)/$', ArriveeConsumer.as_asgi()), |
||||||
|
] |
||||||
@ -0,0 +1,136 @@ |
|||||||
|
""" |
||||||
|
Django settings for crossapp project. |
||||||
|
|
||||||
|
Generated by 'django-admin startproject' using Django 5.2.6. |
||||||
|
|
||||||
|
For more information on this file, see |
||||||
|
https://docs.djangoproject.com/en/5.2/topics/settings/ |
||||||
|
|
||||||
|
For the full list of settings and their values, see |
||||||
|
https://docs.djangoproject.com/en/5.2/ref/settings/ |
||||||
|
""" |
||||||
|
|
||||||
|
from pathlib import Path |
||||||
|
|
||||||
|
# Build paths inside the project like this: BASE_DIR / 'subdir'. |
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent |
||||||
|
STATIC_ROOT = BASE_DIR / 'staticfiles' |
||||||
|
|
||||||
|
|
||||||
|
# Quick-start development settings - unsuitable for production |
||||||
|
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/ |
||||||
|
|
||||||
|
# SECURITY WARNING: keep the secret key used in production secret! |
||||||
|
SECRET_KEY = 'django-insecure-(norsp!z18z298=sociz_3n57ml+u2$%hmsphkk_udgg*414#*' |
||||||
|
|
||||||
|
# SECURITY WARNING: don't run with debug turned on in production! |
||||||
|
DEBUG = True |
||||||
|
|
||||||
|
ALLOWED_HOSTS = ['localhost', '127.0.0.1', '192.168.0.14'] |
||||||
|
|
||||||
|
|
||||||
|
# Application definition |
||||||
|
|
||||||
|
INSTALLED_APPS = [ |
||||||
|
'daphne', |
||||||
|
'django.contrib.admin', |
||||||
|
'django.contrib.auth', |
||||||
|
'django.contrib.contenttypes', |
||||||
|
'django.contrib.sessions', |
||||||
|
'django.contrib.messages', |
||||||
|
'django.contrib.staticfiles', |
||||||
|
'channels', |
||||||
|
'courses', |
||||||
|
'coureurs', |
||||||
|
] |
||||||
|
|
||||||
|
MIDDLEWARE = [ |
||||||
|
'django.middleware.security.SecurityMiddleware', |
||||||
|
'django.contrib.sessions.middleware.SessionMiddleware', |
||||||
|
'django.middleware.common.CommonMiddleware', |
||||||
|
'django.middleware.csrf.CsrfViewMiddleware', |
||||||
|
'django.contrib.auth.middleware.AuthenticationMiddleware', |
||||||
|
'django.contrib.messages.middleware.MessageMiddleware', |
||||||
|
'django.middleware.clickjacking.XFrameOptionsMiddleware', |
||||||
|
] |
||||||
|
|
||||||
|
ROOT_URLCONF = 'crossapp.urls' |
||||||
|
|
||||||
|
TEMPLATES = [ |
||||||
|
{ |
||||||
|
'BACKEND': 'django.template.backends.django.DjangoTemplates', |
||||||
|
'DIRS': [BASE_DIR / 'templates'], |
||||||
|
'APP_DIRS': True, |
||||||
|
'OPTIONS': { |
||||||
|
'context_processors': [ |
||||||
|
'django.template.context_processors.request', |
||||||
|
'django.contrib.auth.context_processors.auth', |
||||||
|
'django.contrib.messages.context_processors.messages', |
||||||
|
], |
||||||
|
}, |
||||||
|
}, |
||||||
|
] |
||||||
|
|
||||||
|
|
||||||
|
ASGI_APPLICATION = 'crossapp.asgi.application' |
||||||
|
WSGI_APPLICATION = 'crossapp.wsgi.application' |
||||||
|
# Channels |
||||||
|
CHANNEL_LAYERS = { |
||||||
|
'default': { |
||||||
|
'BACKEND': 'channels.layers.InMemoryChannelLayer', |
||||||
|
}, |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
# Database |
||||||
|
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases |
||||||
|
|
||||||
|
DATABASES = { |
||||||
|
'default': { |
||||||
|
'ENGINE': 'django.db.backends.sqlite3', |
||||||
|
'NAME': BASE_DIR / 'db.sqlite3', |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
# Password validation |
||||||
|
# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators |
||||||
|
|
||||||
|
AUTH_PASSWORD_VALIDATORS = [ |
||||||
|
{ |
||||||
|
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', |
||||||
|
}, |
||||||
|
{ |
||||||
|
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', |
||||||
|
}, |
||||||
|
{ |
||||||
|
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', |
||||||
|
}, |
||||||
|
{ |
||||||
|
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', |
||||||
|
}, |
||||||
|
] |
||||||
|
|
||||||
|
|
||||||
|
# Internationalization |
||||||
|
# https://docs.djangoproject.com/en/5.2/topics/i18n/ |
||||||
|
|
||||||
|
LANGUAGE_CODE = 'fr-fr' |
||||||
|
|
||||||
|
TIME_ZONE = 'UTC' |
||||||
|
|
||||||
|
USE_I18N = True |
||||||
|
|
||||||
|
USE_TZ = True |
||||||
|
|
||||||
|
# Static files (CSS, JavaScript, Images) |
||||||
|
# https://docs.djangoproject.com/en/5.2/howto/static-files/ |
||||||
|
|
||||||
|
STATIC_URL = '/static/' |
||||||
|
STATICFILES_DIRS = [BASE_DIR / 'static'] |
||||||
|
|
||||||
|
# Default primary key field type |
||||||
|
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field |
||||||
|
|
||||||
|
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' |
||||||
@ -0,0 +1,34 @@ |
|||||||
|
""" |
||||||
|
URL configuration for crossapp project. |
||||||
|
|
||||||
|
The `urlpatterns` list routes URLs to views. For more information please see: |
||||||
|
https://docs.djangoproject.com/en/5.2/topics/http/urls/ |
||||||
|
Examples: |
||||||
|
Function views |
||||||
|
1. Add an import: from my_app import views |
||||||
|
2. Add a URL to urlpatterns: path('', views.home, name='home') |
||||||
|
Class-based views |
||||||
|
1. Add an import: from other_app.views import Home |
||||||
|
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') |
||||||
|
Including another URLconf |
||||||
|
1. Import the include() function: from django.urls import include, path |
||||||
|
2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) |
||||||
|
""" |
||||||
|
|
||||||
|
from django.contrib import admin |
||||||
|
from django.urls import path, include |
||||||
|
from django.conf import settings |
||||||
|
from django.conf.urls.static import static |
||||||
|
|
||||||
|
|
||||||
|
urlpatterns = [ |
||||||
|
path('admin/', admin.site.urls), |
||||||
|
path('', include('courses.urls')), |
||||||
|
path('scan/', include('scan.urls')), |
||||||
|
path('dossards/', include('dossards.urls')), |
||||||
|
path('accounts/', include('django.contrib.auth.urls')), |
||||||
|
] |
||||||
|
|
||||||
|
# Sert les fichiers statiques en développement |
||||||
|
if settings.DEBUG: |
||||||
|
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) |
||||||
@ -0,0 +1,16 @@ |
|||||||
|
""" |
||||||
|
WSGI config for crossapp project. |
||||||
|
|
||||||
|
It exposes the WSGI callable as a module-level variable named ``application``. |
||||||
|
|
||||||
|
For more information on this file, see |
||||||
|
https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/ |
||||||
|
""" |
||||||
|
|
||||||
|
import os |
||||||
|
|
||||||
|
from django.core.wsgi import get_wsgi_application |
||||||
|
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'crossapp.settings') |
||||||
|
|
||||||
|
application = get_wsgi_application() |
||||||
@ -0,0 +1,35 @@ |
|||||||
|
version: '3.8' |
||||||
|
|
||||||
|
services: |
||||||
|
django: |
||||||
|
build: . |
||||||
|
container_name: crossapp_django |
||||||
|
command: daphne -b 0.0.0.0 -p 8000 crossapp.asgi:application |
||||||
|
volumes: |
||||||
|
- .:/app |
||||||
|
- static_volume:/app/static |
||||||
|
env_file: |
||||||
|
- .env |
||||||
|
expose: |
||||||
|
- "8000" |
||||||
|
depends_on: |
||||||
|
- nginx |
||||||
|
|
||||||
|
nginx: |
||||||
|
image: jwilder/nginx-proxy |
||||||
|
container_name: crossapp_nginx |
||||||
|
restart: always |
||||||
|
ports: |
||||||
|
- "80:80" |
||||||
|
volumes: |
||||||
|
- static_volume:/app/static:ro |
||||||
|
- /var/run/docker.sock:/tmp/docker.sock:ro |
||||||
|
environment: |
||||||
|
- NGINX_PROXY_CONTAINER=crossapp_nginx |
||||||
|
- STATIC_URL=/static/ |
||||||
|
- STATIC_PATH=/app/static |
||||||
|
depends_on: |
||||||
|
- django |
||||||
|
|
||||||
|
volumes: |
||||||
|
static_volume: |
||||||
@ -0,0 +1,3 @@ |
|||||||
|
from django.contrib import admin |
||||||
|
|
||||||
|
# Register your models here. |
||||||
@ -0,0 +1,6 @@ |
|||||||
|
from django.apps import AppConfig |
||||||
|
|
||||||
|
|
||||||
|
class DossardsConfig(AppConfig): |
||||||
|
default_auto_field = 'django.db.models.BigAutoField' |
||||||
|
name = 'dossards' |
||||||
@ -0,0 +1,6 @@ |
|||||||
|
from django import forms |
||||||
|
|
||||||
|
class DossardForm(forms.Form): |
||||||
|
csv_file = forms.FileField(label="Fichier CSV (nom;classe)") |
||||||
|
rows = forms.IntegerField(label="Étiquettes par colonne", min_value=1, initial=2) |
||||||
|
cols = forms.IntegerField(label="Étiquettes par ligne", min_value=1, initial=2) |
||||||
@ -0,0 +1,3 @@ |
|||||||
|
from django.db import models |
||||||
|
|
||||||
|
# Create your models here. |
||||||
@ -0,0 +1,3 @@ |
|||||||
|
from django.test import TestCase |
||||||
|
|
||||||
|
# Create your tests here. |
||||||
@ -0,0 +1,6 @@ |
|||||||
|
from django.urls import path |
||||||
|
from .views import dossards_view |
||||||
|
|
||||||
|
urlpatterns = [ |
||||||
|
path('', dossards_view, name='dossards'), |
||||||
|
] |
||||||
@ -0,0 +1,90 @@ |
|||||||
|
|
||||||
|
import csv |
||||||
|
from django.contrib.auth.decorators import login_required |
||||||
|
import io |
||||||
|
from django.shortcuts import render |
||||||
|
from django.http import HttpResponse |
||||||
|
from .forms import DossardForm |
||||||
|
from reportlab.pdfgen import canvas |
||||||
|
from reportlab.lib.pagesizes import A4 |
||||||
|
from reportlab.lib.units import mm |
||||||
|
import qrcode |
||||||
|
from PIL import Image |
||||||
|
from django.core.files.storage import default_storage |
||||||
|
|
||||||
|
def generate_dossards_pdf(data, rows, cols): |
||||||
|
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.8 # 80% de la zone pour le QR code |
||||||
|
for idx, (nom, classe) 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 |
||||||
|
# Dessiner le rectangle de la zone d'étiquette |
||||||
|
c.setLineWidth(3) |
||||||
|
c.setStrokeColorRGB(0, 0, 0) |
||||||
|
c.rect(x, y, label_w, label_h) |
||||||
|
# Générer QR code |
||||||
|
qr = qrcode.make(f"{nom};{classe}") |
||||||
|
qr_img = io.BytesIO() |
||||||
|
qr.save(qr_img, format='PNG') |
||||||
|
qr_img.seek(0) |
||||||
|
qr_pil = Image.open(qr_img) |
||||||
|
# Calculer la taille maximale du QR code (carré) dans la zone |
||||||
|
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) |
||||||
|
# Afficher le nom et la classe sous le QR code |
||||||
|
c.setFont("Helvetica-Bold", 12) |
||||||
|
c.drawCentredString(x + label_w/2, qr_y - 10, nom) |
||||||
|
c.setFont("Helvetica", 10) |
||||||
|
c.drawCentredString(x + label_w/2, qr_y - 24, classe) |
||||||
|
c.save() |
||||||
|
buffer.seek(0) |
||||||
|
return buffer |
||||||
|
|
||||||
|
@login_required |
||||||
|
def dossards_view(request): |
||||||
|
error = None |
||||||
|
progress = None |
||||||
|
pdf_url = None |
||||||
|
if request.method == 'POST': |
||||||
|
form = DossardForm(request.POST, request.FILES) |
||||||
|
if form.is_valid(): |
||||||
|
csv_file = form.cleaned_data['csv_file'] |
||||||
|
rows = form.cleaned_data['rows'] |
||||||
|
cols = form.cleaned_data['cols'] |
||||||
|
try: |
||||||
|
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())) |
||||||
|
total = len(data) |
||||||
|
progress = f"Génération des dossards : 0/{total}..." |
||||||
|
buffer = generate_dossards_pdf(data, 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) |
||||||
|
else: |
||||||
|
error = "Formulaire invalide." |
||||||
|
else: |
||||||
|
form = DossardForm() |
||||||
|
return render(request, 'dossards.html', { |
||||||
|
'form': form, |
||||||
|
'error': error, |
||||||
|
'progress': progress, |
||||||
|
'pdf_url': pdf_url |
||||||
|
}) |
||||||
@ -0,0 +1,22 @@ |
|||||||
|
#!/usr/bin/env python |
||||||
|
"""Django's command-line utility for administrative tasks.""" |
||||||
|
import os |
||||||
|
import sys |
||||||
|
|
||||||
|
|
||||||
|
def main(): |
||||||
|
"""Run administrative tasks.""" |
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'crossapp.settings') |
||||||
|
try: |
||||||
|
from django.core.management import execute_from_command_line |
||||||
|
except ImportError as exc: |
||||||
|
raise ImportError( |
||||||
|
"Couldn't import Django. Are you sure it's installed and " |
||||||
|
"available on your PYTHONPATH environment variable? Did you " |
||||||
|
"forget to activate a virtual environment?" |
||||||
|
) from exc |
||||||
|
execute_from_command_line(sys.argv) |
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__': |
||||||
|
main() |
||||||
@ -0,0 +1,8 @@ |
|||||||
|
Django>=5.2.6 |
||||||
|
channels |
||||||
|
reportlab |
||||||
|
qrcode |
||||||
|
pandas |
||||||
|
Pillow |
||||||
|
pyopenssl |
||||||
|
daphne |
||||||
@ -0,0 +1,3 @@ |
|||||||
|
from django.contrib import admin |
||||||
|
|
||||||
|
# Register your models here. |
||||||
@ -0,0 +1,6 @@ |
|||||||
|
from django.apps import AppConfig |
||||||
|
|
||||||
|
|
||||||
|
class ScanConfig(AppConfig): |
||||||
|
default_auto_field = 'django.db.models.BigAutoField' |
||||||
|
name = 'scan' |
||||||
@ -0,0 +1,5 @@ |
|||||||
|
from django import forms |
||||||
|
|
||||||
|
class ScanForm(forms.Form): |
||||||
|
course_id = forms.IntegerField(widget=forms.HiddenInput) |
||||||
|
qrcode = forms.CharField(max_length=200) |
||||||
@ -0,0 +1,3 @@ |
|||||||
|
from django.db import models |
||||||
|
|
||||||
|
# Create your models here. |
||||||
@ -0,0 +1,3 @@ |
|||||||
|
from django.test import TestCase |
||||||
|
|
||||||
|
# Create your tests here. |
||||||
@ -0,0 +1,6 @@ |
|||||||
|
from django.urls import path |
||||||
|
from .views import scan_view |
||||||
|
|
||||||
|
urlpatterns = [ |
||||||
|
path('', scan_view, name='scan'), |
||||||
|
] |
||||||
@ -0,0 +1,69 @@ |
|||||||
|
from channels.layers import get_channel_layer |
||||||
|
from asgiref.sync import async_to_sync |
||||||
|
|
||||||
|
from django.shortcuts import render, redirect |
||||||
|
from django.contrib.auth.decorators import login_required |
||||||
|
from courses.models import Course, Arrivee |
||||||
|
from coureurs.models import Coureur |
||||||
|
from django.utils import timezone |
||||||
|
from .forms import ScanForm |
||||||
|
|
||||||
|
@login_required |
||||||
|
def scan_view(request): |
||||||
|
from django.shortcuts import get_object_or_404 |
||||||
|
courses = Course.objects.filter(depart__isnull=False, fin__isnull=True) |
||||||
|
result = None |
||||||
|
error = None |
||||||
|
course = None |
||||||
|
# Traitement AJAX POST |
||||||
|
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." |
||||||
|
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émarré." |
||||||
|
else: |
||||||
|
coureur, _ = Coureur.objects.get_or_create(nom=nom.strip(), classe=classe.strip()) |
||||||
|
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 |
||||||
|
Arrivee.objects.create(course=course, coureur=coureur, temps=temps, rang=rang) |
||||||
|
result = { |
||||||
|
'nom': coureur.nom, |
||||||
|
'classe': coureur.classe, |
||||||
|
'rang': rang, |
||||||
|
'temps': str(temps) |
||||||
|
} |
||||||
|
# Broadcast websocket |
||||||
|
channel_layer = get_channel_layer() |
||||||
|
async_to_sync(channel_layer.group_send)( |
||||||
|
f'course_{course.id}', |
||||||
|
{ |
||||||
|
'type': 'send_arrivee', |
||||||
|
'data': result |
||||||
|
} |
||||||
|
) |
||||||
|
# Retourne juste le fragment HTML pour scanResult |
||||||
|
if result: |
||||||
|
return render(request, 'scan_result.html', {'result': result}) |
||||||
|
else: |
||||||
|
return render(request, 'scan_result.html', {'error': error}) |
||||||
|
# Affichage classique GET |
||||||
|
else: |
||||||
|
course_id = request.GET.get('course_id') |
||||||
|
if course_id: |
||||||
|
course = get_object_or_404(Course, id=course_id) |
||||||
|
return render(request, 'scan.html', { |
||||||
|
'courses': courses, |
||||||
|
'result': result, |
||||||
|
'error': error, |
||||||
|
'course': course |
||||||
|
}) |
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -0,0 +1,7 @@ |
|||||||
|
/*! |
||||||
|
* Start Bootstrap - SB Admin 2 v4.1.4 (https://startbootstrap.com/theme/sb-admin-2)
|
||||||
|
* Copyright 2013-2021 Start Bootstrap |
||||||
|
* Licensed under MIT (https://github.com/StartBootstrap/startbootstrap-sb-admin-2/blob/master/LICENSE)
|
||||||
|
*/ |
||||||
|
|
||||||
|
!function(l){"use strict";l("#sidebarToggle, #sidebarToggleTop").on("click",function(e){l("body").toggleClass("sidebar-toggled"),l(".sidebar").toggleClass("toggled"),l(".sidebar").hasClass("toggled")&&l(".sidebar .collapse").collapse("hide")}),l(window).resize(function(){l(window).width()<768&&l(".sidebar .collapse").collapse("hide"),l(window).width()<480&&!l(".sidebar").hasClass("toggled")&&(l("body").addClass("sidebar-toggled"),l(".sidebar").addClass("toggled"),l(".sidebar .collapse").collapse("hide"))}),l("body.fixed-nav .sidebar").on("mousewheel DOMMouseScroll wheel",function(e){var o;768<l(window).width()&&(o=(o=e.originalEvent).wheelDelta||-o.detail,this.scrollTop+=30*(o<0?1:-1),e.preventDefault())}),l(document).on("scroll",function(){100<l(this).scrollTop()?l(".scroll-to-top").fadeIn():l(".scroll-to-top").fadeOut()}),l(document).on("click","a.scroll-to-top",function(e){var o=l(this);l("html, body").stop().animate({scrollTop:l(o.attr("href")).offset().top},1e3,"easeInOutExpo"),e.preventDefault()})}(jQuery); |
||||||
@ -0,0 +1,10 @@ |
|||||||
|
{% for a in arrivees %} |
||||||
|
<tr> |
||||||
|
<td>{{ a.rang }}</td> |
||||||
|
<td>{{ a.coureur.nom }}</td> |
||||||
|
<td>{{ a.coureur.classe }}</td> |
||||||
|
<td>{{ a.temps }}</td> |
||||||
|
</tr> |
||||||
|
{% empty %} |
||||||
|
<tr><td colspan="4">Aucun coureur arrivé.</td></tr> |
||||||
|
{% endfor %} |
||||||
@ -0,0 +1,26 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="fr"> |
||||||
|
<head> |
||||||
|
<meta charset="UTF-8"> |
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> |
||||||
|
<title>CrossApp</title> |
||||||
|
{% load static %} |
||||||
|
<!-- SB Admin 2 Bootstrap CSS --> |
||||||
|
<link href="{% static 'sb-admin-2/sb-admin-2.min.css' %}" rel="stylesheet"> |
||||||
|
<!-- Custom styles --> |
||||||
|
{% block extra_css %}{% endblock %} |
||||||
|
</head> |
||||||
|
<body id="page-top"> |
||||||
|
<!-- Page Wrapper --> |
||||||
|
<div id="wrapper"> |
||||||
|
{% block content %}{% endblock %} |
||||||
|
</div> |
||||||
|
<!-- jQuery (nécessaire pour Bootstrap) --> |
||||||
|
<script src="{% static 'jquery/jquery-3.6.0.min.js' %}"></script> |
||||||
|
<!-- Bootstrap core JavaScript --> |
||||||
|
<script src="{% static 'bootstrap/bootstrap.bundle.min.js' %}"></script> |
||||||
|
<!-- SB Admin 2 JS --> |
||||||
|
<script src="{% static 'sb-admin-2/sb-admin-2.min.js' %}"></script> |
||||||
|
{% block extra_js %}{% endblock %} |
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -0,0 +1,78 @@ |
|||||||
|
{% extends 'base.html' %} |
||||||
|
{% block content %} |
||||||
|
<div class="container-fluid mt-4"> |
||||||
|
<h1 class="h3 mb-4 text-gray-800">Course : {{ course.nom }} ({{ course.date }})</h1> |
||||||
|
<div class="row"> |
||||||
|
<div class="col-lg-8 mx-auto"> |
||||||
|
<div class="card shadow mb-4"> |
||||||
|
<div class="card-header py-3"> |
||||||
|
<h6 class="m-0 font-weight-bold text-primary">Gestion de la course</h6> |
||||||
|
</div> |
||||||
|
<div class="card-body"> |
||||||
|
<form method="post"> |
||||||
|
{% csrf_token %} |
||||||
|
{% if not is_started %} |
||||||
|
<button type="submit" name="start" class="btn btn-success">Départ</button> |
||||||
|
{% elif not is_finished %} |
||||||
|
<button type="submit" name="finish" class="btn btn-danger">Fin course</button> |
||||||
|
{% else %} |
||||||
|
<span class="badge badge-secondary">Course terminée</span> |
||||||
|
{% endif %} |
||||||
|
</form> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<div class="card shadow mb-4"> |
||||||
|
<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> |
||||||
|
<div> |
||||||
|
<a href="{% url 'export_csv' course.id %}" class="btn btn-outline-primary btn-sm mr-2">Export CSV</a> |
||||||
|
<a href="{% url 'export_pdf' course.id %}" class="btn btn-outline-danger btn-sm">Export PDF</a> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<div class="card-body"> |
||||||
|
<table class="table table-striped" id="arriveesTable"> |
||||||
|
<thead> |
||||||
|
<tr> |
||||||
|
<th>Rang</th> |
||||||
|
<th>Nom</th> |
||||||
|
<th>Classe</th> |
||||||
|
<th>Temps</th> |
||||||
|
</tr> |
||||||
|
</thead> |
||||||
|
<tbody> |
||||||
|
{% for a in arrivees %} |
||||||
|
<tr> |
||||||
|
<td>{{ a.rang }}</td> |
||||||
|
<td>{{ a.coureur.nom }}</td> |
||||||
|
<td>{{ a.coureur.classe }}</td> |
||||||
|
<td>{{ a.temps }}</td> |
||||||
|
</tr> |
||||||
|
{% empty %} |
||||||
|
<tr><td colspan="4">Aucun coureur arrivé.</td></tr> |
||||||
|
{% endfor %} |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{% endblock %} |
||||||
|
{% block extra_js %} |
||||||
|
<script> |
||||||
|
const courseId = "{{ course.id }}"; |
||||||
|
const wsScheme = window.location.protocol === "https:" ? "wss" : "ws"; |
||||||
|
const wsUrl = `${wsScheme}://${window.location.host}/ws/course/${courseId}/`; |
||||||
|
const socket = new WebSocket(wsUrl); |
||||||
|
|
||||||
|
socket.onmessage = function(e) { |
||||||
|
// Recharge juste le tbody via AJAX |
||||||
|
fetch(window.location.href, { headers: { 'X-Requested-With': 'XMLHttpRequest' } }) |
||||||
|
.then(response => response.text()) |
||||||
|
.then(html => { |
||||||
|
const table = document.getElementById('arriveesTable').getElementsByTagName('tbody')[0]; |
||||||
|
table.innerHTML = html; |
||||||
|
}); |
||||||
|
}; |
||||||
|
</script> |
||||||
|
{% endblock %} |
||||||
@ -0,0 +1,31 @@ |
|||||||
|
{% extends 'base.html' %} |
||||||
|
{% block content %} |
||||||
|
<div class="container-fluid mt-4"> |
||||||
|
<h1 class="h3 mb-4 text-gray-800">Génération des dossards PDF</h1> |
||||||
|
<div class="row"> |
||||||
|
<div class="col-lg-8 mx-auto"> |
||||||
|
<div class="card shadow mb-4"> |
||||||
|
<div class="card-header py-3"> |
||||||
|
<h6 class="m-0 font-weight-bold text-primary">Importer le fichier CSV</h6> |
||||||
|
</div> |
||||||
|
<div class="card-body"> |
||||||
|
<form method="post" enctype="multipart/form-data"> |
||||||
|
{% csrf_token %} |
||||||
|
{{ form.as_p }} |
||||||
|
<button type="submit" class="btn btn-success">Générer PDF</button> |
||||||
|
</form> |
||||||
|
{% if error %} |
||||||
|
<div class="alert alert-danger mt-3">{{ error }}</div> |
||||||
|
{% endif %} |
||||||
|
{% if progress %} |
||||||
|
<div class="alert alert-info mt-3">{{ progress }}</div> |
||||||
|
{% endif %} |
||||||
|
{% if pdf_url %} |
||||||
|
<a href="{{ pdf_url }}" class="btn btn-primary mt-3">Télécharger le PDF</a> |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{% endblock %} |
||||||
@ -0,0 +1,99 @@ |
|||||||
|
{% extends 'base.html' %} |
||||||
|
{% block content %} |
||||||
|
<div class="container-fluid mt-4"> |
||||||
|
<h1 class="h3 mb-4 text-gray-800">Bienvenue sur CrossApp</h1> |
||||||
|
<div class="row align-items-stretch"> |
||||||
|
<div class="col-lg-6 mb-4 d-flex"> |
||||||
|
<div class="card shadow mb-4 h-100 w-100"> |
||||||
|
<div class="card-header py-3"> |
||||||
|
<h6 class="m-0 font-weight-bold text-primary">Liste des courses</h6> |
||||||
|
</div> |
||||||
|
<div class="card-body"> |
||||||
|
<ul class="list-group"> |
||||||
|
{% for course in courses %} |
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-center"> |
||||||
|
{{ course.nom }} ({{ course.date }}) |
||||||
|
<div> |
||||||
|
<a href="{% url 'course_detail' course.id %}" class="btn btn-primary btn-sm mr-2">Voir</a> |
||||||
|
<a href="{% url 'scan' %}?course_id={{ course.id }}" class="btn btn-info btn-sm">Scan</a> |
||||||
|
</div> |
||||||
|
</li> |
||||||
|
{% empty %} |
||||||
|
<li class="list-group-item">Aucune course enregistrée.</li> |
||||||
|
{% endfor %} |
||||||
|
</ul> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<div class="row align-items-stretch"> |
||||||
|
<div class="col-lg-6 mb-4 d-flex"> |
||||||
|
<div class="card shadow mb-4 h-100 w-100"> |
||||||
|
<div class="card-header py-3"> |
||||||
|
<h6 class="m-0 font-weight-bold text-primary">Actions</h6> |
||||||
|
</div> |
||||||
|
<div class="card-body"> |
||||||
|
<button class="btn btn-success mb-2" id="btnNewCourse">Créer une nouvelle course</button> |
||||||
|
<a href="{% url 'dossards' %}" class="btn btn-warning mb-2 ml-2">Générer les dossards</a> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<div id="newCourseModal" class="modal" tabindex="-1" role="dialog" style="display:none;"> |
||||||
|
<div class="modal-dialog" role="document"> |
||||||
|
<div class="modal-content"> |
||||||
|
<form id="newCourseForm" method="post"> |
||||||
|
{% csrf_token %} |
||||||
|
<div class="modal-header"> |
||||||
|
<h5 class="modal-title">Créer une nouvelle course</h5> |
||||||
|
<button type="button" class="close" id="closeModal" aria-label="Close"><span aria-hidden="true">×</span></button> |
||||||
|
</div> |
||||||
|
<div class="modal-body"> |
||||||
|
<div class="form-group"> |
||||||
|
<label for="courseName">Nom de la course</label> |
||||||
|
<input type="text" class="form-control" name="nom" id="courseName" required> |
||||||
|
</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> |
||||||
|
</div> |
||||||
|
</form> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{% block extra_js %} |
||||||
|
<script> |
||||||
|
document.getElementById('btnNewCourse').onclick = function() { |
||||||
|
document.getElementById('newCourseModal').style.display = 'block'; |
||||||
|
}; |
||||||
|
document.getElementById('closeModal').onclick = function() { |
||||||
|
document.getElementById('newCourseModal').style.display = 'none'; |
||||||
|
}; |
||||||
|
document.getElementById('newCourseForm').onsubmit = function(e) { |
||||||
|
e.preventDefault(); |
||||||
|
const nom = document.getElementById('courseName').value; |
||||||
|
const csrf = document.querySelector('[name=csrfmiddlewaretoken]').value; |
||||||
|
fetch('', { |
||||||
|
method: 'POST', |
||||||
|
headers: { |
||||||
|
'Content-Type': 'application/x-www-form-urlencoded', |
||||||
|
'X-Requested-With': 'XMLHttpRequest' |
||||||
|
}, |
||||||
|
body: `csrfmiddlewaretoken=${encodeURIComponent(csrf)}&nom=${encodeURIComponent(nom)}` |
||||||
|
}) |
||||||
|
.then(response => response.json()) |
||||||
|
.then(data => { |
||||||
|
if (data.success) { |
||||||
|
window.location.href = `/scan/?course_id=${data.course_id}`; |
||||||
|
} else { |
||||||
|
document.getElementById('newCourseError').textContent = data.error; |
||||||
|
document.getElementById('newCourseError').style.display = 'block'; |
||||||
|
} |
||||||
|
}); |
||||||
|
}; |
||||||
|
</script> |
||||||
|
{% endblock %} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{% endblock %} |
||||||
@ -0,0 +1,24 @@ |
|||||||
|
{% extends 'base.html' %} |
||||||
|
{% block content %} |
||||||
|
<div class="container mt-5"> |
||||||
|
<div class="row justify-content-center"> |
||||||
|
<div class="col-md-6"> |
||||||
|
<div class="card shadow"> |
||||||
|
<div class="card-header bg-primary text-white"> |
||||||
|
<h4 class="mb-0">Connexion</h4> |
||||||
|
</div> |
||||||
|
<div class="card-body"> |
||||||
|
<form method="post"> |
||||||
|
{% csrf_token %} |
||||||
|
{{ form.as_p }} |
||||||
|
<button type="submit" class="btn btn-primary btn-block">Se connecter</button> |
||||||
|
</form> |
||||||
|
{% if form.errors %} |
||||||
|
<div class="alert alert-danger mt-3">Identifiants invalides.</div> |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{% endblock %} |
||||||
@ -0,0 +1,140 @@ |
|||||||
|
{% extends 'base.html' %} |
||||||
|
{% block content %} |
||||||
|
<div class="container-fluid mt-4"> |
||||||
|
<h1 class="h3 mb-4 text-gray-800">Mode Scan : {% if course %}{{ course.nom }} ({{ course.date }}){% endif %}</h1> |
||||||
|
<div class="row"> |
||||||
|
<div class="col-lg-8 mx-auto"> |
||||||
|
<div class="card shadow mb-4"> |
||||||
|
<div class="card-header py-3"> |
||||||
|
<h6 class="m-0 font-weight-bold text-primary">Scanner un coureur</h6> |
||||||
|
</div> |
||||||
|
<div class="card-body"> |
||||||
|
<div id="reader" style="width:100%; max-width:400px; margin:auto;"></div> |
||||||
|
<div id="scanResult" class="mt-3"></div> |
||||||
|
{% if error %} |
||||||
|
<div class="alert alert-danger mt-3">{{ error }}</div> |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<div class="card shadow mb-4"> |
||||||
|
<div class="card-header py-3"> |
||||||
|
<h6 class="m-0 font-weight-bold text-primary">Actions</h6> |
||||||
|
</div> |
||||||
|
<div class="card-body d-flex justify-content-between"> |
||||||
|
<a href="/" class="btn btn-secondary">Menu principal</a> |
||||||
|
<button id="toggleBeep" type="button" class="btn btn-info">Désactiver bip scan</button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{% endblock %} |
||||||
|
{% block extra_js %} |
||||||
|
<script src="{% static 'html5-qrcode/html5-qrcode.min.js' %}"></script> |
||||||
|
<script> |
||||||
|
function beep() { |
||||||
|
const ctx = new(window.AudioContext || window.webkitAudioContext)(); |
||||||
|
const oscillator = ctx.createOscillator(); |
||||||
|
oscillator.type = 'sine'; |
||||||
|
oscillator.frequency.setValueAtTime(1000, ctx.currentTime); |
||||||
|
oscillator.connect(ctx.destination); |
||||||
|
oscillator.start(); |
||||||
|
setTimeout(() => { oscillator.stop(); ctx.close(); }, 150); |
||||||
|
} |
||||||
|
|
||||||
|
let lastScanned = ''; |
||||||
|
let html5QrCode; |
||||||
|
|
||||||
|
function getCookie(name) { |
||||||
|
var cookieValue = null; |
||||||
|
if (document.cookie && document.cookie !== '') { |
||||||
|
var cookies = document.cookie.split(';'); |
||||||
|
for (var i = 0; i < cookies.length; i++) { |
||||||
|
var cookie = jQuery.trim(cookies[i]); |
||||||
|
// Does this cookie string begin with the name we want? |
||||||
|
if (cookie.substring(0, name.length + 1) === (name + '=')) { |
||||||
|
cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); |
||||||
|
break; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
return cookieValue; |
||||||
|
} |
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() { |
||||||
|
html5QrCode = new Html5Qrcode("reader"); |
||||||
|
Html5Qrcode.getCameras().then(cameras => { |
||||||
|
if (cameras && cameras.length) { |
||||||
|
html5QrCode.start( |
||||||
|
cameras[0].id, |
||||||
|
{ fps: 10, qrbox: 250 }, |
||||||
|
onScanSuccess |
||||||
|
); |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
// Plus aucune référence à l’ancien form ou input qrcode |
||||||
|
}); |
||||||
|
function getCourseIdFromUrl() { |
||||||
|
const params = new URLSearchParams(window.location.search); |
||||||
|
return params.get('course_id'); |
||||||
|
} |
||||||
|
function onScanSuccess(decodedText, decodedResult) { |
||||||
|
console.log('Scan détecté:', decodedText, 'Course:', getCourseIdFromUrl()); |
||||||
|
if (decodedText === lastScanned || window.scanDebounce) return; |
||||||
|
window.scanDebounce = true; |
||||||
|
lastScanned = decodedText; |
||||||
|
beep(); |
||||||
|
const courseId = getCourseIdFromUrl(); |
||||||
|
if (!courseId) { |
||||||
|
window.scanDebounce = false; |
||||||
|
return; |
||||||
|
} |
||||||
|
fetch("{% url 'scan' %}" + window.location.search, { |
||||||
|
method: 'POST', |
||||||
|
headers: { |
||||||
|
'Content-Type': 'application/x-www-form-urlencoded', |
||||||
|
'X-CSRFToken': getCookie('csrftoken'), |
||||||
|
'X-Requested-With': 'XMLHttpRequest' |
||||||
|
}, |
||||||
|
body: `course_id=${courseId}&qrcode=${encodeURIComponent(decodedText)}` |
||||||
|
}) |
||||||
|
.then(response => response.text()) |
||||||
|
.then(html => { |
||||||
|
document.getElementById('scanResult').innerHTML = html; |
||||||
|
window.scanDebounce = false; |
||||||
|
}) |
||||||
|
.catch(() => { |
||||||
|
window.scanDebounce = false; |
||||||
|
}); |
||||||
|
} |
||||||
|
document.addEventListener('DOMContentLoaded', function() { |
||||||
|
const beepBtn = document.getElementById('toggleBeep'); |
||||||
|
let beepEnabled = true; |
||||||
|
beepBtn.onclick = function() { |
||||||
|
beepEnabled = !beepEnabled; |
||||||
|
beepBtn.textContent = beepEnabled ? 'Désactiver bip scan' : 'Activer bip scan'; |
||||||
|
}; |
||||||
|
window.beep = function() { |
||||||
|
if (!beepEnabled) return; |
||||||
|
const ctx = new(window.AudioContext || window.webkitAudioContext)(); |
||||||
|
const oscillator = ctx.createOscillator(); |
||||||
|
oscillator.type = 'sine'; |
||||||
|
oscillator.frequency.setValueAtTime(1000, ctx.currentTime); |
||||||
|
oscillator.connect(ctx.destination); |
||||||
|
oscillator.start(); |
||||||
|
setTimeout(() => { oscillator.stop(); ctx.close(); }, 150); |
||||||
|
}; |
||||||
|
}); |
||||||
|
|
||||||
|
Html5Qrcode.getCameras().then(cameras => { |
||||||
|
if (cameras && cameras.length) { |
||||||
|
html5QrCode.start( |
||||||
|
cameras[0].id, |
||||||
|
{ fps: 10, qrbox: 250 }, |
||||||
|
onScanSuccess |
||||||
|
); |
||||||
|
} |
||||||
|
}); |
||||||
|
</script> |
||||||
|
{% endblock %} |
||||||
@ -0,0 +1,11 @@ |
|||||||
|
{% if result %} |
||||||
|
<div class="alert alert-success"> |
||||||
|
<strong>Arrivée enregistrée :</strong><br> |
||||||
|
Nom : {{ result.nom }}<br> |
||||||
|
Classe : {{ result.classe }}<br> |
||||||
|
Rang : {{ result.rang }}<br> |
||||||
|
Temps : {{ result.temps }} |
||||||
|
</div> |
||||||
|
{% elif error %} |
||||||
|
<div class="alert alert-danger">{{ error }}</div> |
||||||
|
{% endif %} |
||||||
@ -0,0 +1,3 @@ |
|||||||
|
from django.contrib import admin |
||||||
|
|
||||||
|
# Register your models here. |
||||||
@ -0,0 +1,6 @@ |
|||||||
|
from django.apps import AppConfig |
||||||
|
|
||||||
|
|
||||||
|
class WebsocketConfig(AppConfig): |
||||||
|
default_auto_field = 'django.db.models.BigAutoField' |
||||||
|
name = 'websocket' |
||||||
@ -0,0 +1,25 @@ |
|||||||
|
import json |
||||||
|
from channels.generic.websocket import AsyncWebsocketConsumer |
||||||
|
|
||||||
|
class ArriveeConsumer(AsyncWebsocketConsumer): |
||||||
|
async def connect(self): |
||||||
|
self.course_id = self.scope['url_route']['kwargs']['course_id'] |
||||||
|
self.group_name = f'course_{self.course_id}' |
||||||
|
await self.channel_layer.group_add( |
||||||
|
self.group_name, |
||||||
|
self.channel_name |
||||||
|
) |
||||||
|
await self.accept() |
||||||
|
|
||||||
|
async def disconnect(self, close_code): |
||||||
|
await self.channel_layer.group_discard( |
||||||
|
self.group_name, |
||||||
|
self.channel_name |
||||||
|
) |
||||||
|
|
||||||
|
async def receive(self, text_data): |
||||||
|
# Optionnel : traiter les messages entrants |
||||||
|
pass |
||||||
|
|
||||||
|
async def send_arrivee(self, event): |
||||||
|
await self.send(text_data=json.dumps(event['data'])) |
||||||
@ -0,0 +1,3 @@ |
|||||||
|
from django.db import models |
||||||
|
|
||||||
|
# Create your models here. |
||||||
@ -0,0 +1,6 @@ |
|||||||
|
from django.urls import re_path |
||||||
|
from . import consumers |
||||||
|
|
||||||
|
websocket_urlpatterns = [ |
||||||
|
re_path(r'ws/course/(?P<course_id>\d+)/$', consumers.ArriveeConsumer.as_asgi()), |
||||||
|
] |
||||||
@ -0,0 +1,3 @@ |
|||||||
|
from django.test import TestCase |
||||||
|
|
||||||
|
# Create your tests here. |
||||||
Loading…
Reference in new issue