Browse Source

Initial commit

master
scayac 3 months ago
commit
8cdfcbcdca
  1. 11
      .gitignore
  2. 23
      Dockerfile
  3. 42
      README.md
  4. 9
      coureurs/admin.py
  5. 6
      coureurs/apps.py
  6. 22
      coureurs/migrations/0001_initial.py
  7. 9
      coureurs/models.py
  8. 3
      coureurs/tests.py
  9. 3
      coureurs/views.py
  10. 15
      courses/admin.py
  11. 6
      courses/apps.py
  12. 24
      courses/forms.py
  13. 45
      courses/migrations/0001_initial.py
  14. 32
      courses/models.py
  15. 3
      courses/tests.py
  16. 9
      courses/urls.py
  17. 105
      courses/views.py
  18. 27
      crossapp/asgi.py
  19. 6
      crossapp/routing.py
  20. 136
      crossapp/settings.py
  21. 34
      crossapp/urls.py
  22. 16
      crossapp/wsgi.py
  23. 35
      docker-compose.yml
  24. 3
      dossards/admin.py
  25. 6
      dossards/apps.py
  26. 6
      dossards/forms.py
  27. 3
      dossards/models.py
  28. 3
      dossards/tests.py
  29. 6
      dossards/urls.py
  30. 90
      dossards/views.py
  31. 22
      manage.py
  32. 8
      requirements.txt
  33. 3
      scan/admin.py
  34. 6
      scan/apps.py
  35. 5
      scan/forms.py
  36. 3
      scan/models.py
  37. 3
      scan/tests.py
  38. 6
      scan/urls.py
  39. 69
      scan/views.py
  40. 7
      static/bootstrap/bootstrap.bundle.min.js
  41. 1
      static/html5-qrcode/html5-qrcode.min.js
  42. 2
      static/jquery/jquery-3.6.0.min.js
  43. 10
      static/sb-admin-2/sb-admin-2.min.css
  44. 7
      static/sb-admin-2/sb-admin-2.min.js
  45. 10
      templates/arrivees_tbody.html
  46. 26
      templates/base.html
  47. 78
      templates/course_detail.html
  48. 31
      templates/dossards.html
  49. 99
      templates/main.html
  50. 24
      templates/registration/login.html
  51. 140
      templates/scan.html
  52. 11
      templates/scan_result.html
  53. 3
      websocket/admin.py
  54. 6
      websocket/apps.py
  55. 25
      websocket/consumers.py
  56. 3
      websocket/models.py
  57. 6
      websocket/routing.py
  58. 3
      websocket/tests.py
  59. 3
      websocket/views.py

11
.gitignore vendored

@ -0,0 +1,11 @@ @@ -0,0 +1,11 @@
*.log
*.pot
*.pyc
.venv
.vscode
staticfiles/*
__pycache__
__init__.py
db.sqlite3
*.crt
*.key

23
Dockerfile

@ -0,0 +1,23 @@ @@ -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"]

42
README.md

@ -0,0 +1,42 @@ @@ -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)

9
coureurs/admin.py

@ -0,0 +1,9 @@ @@ -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',)

6
coureurs/apps.py

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
from django.apps import AppConfig
class CoureursConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'coureurs'

22
coureurs/migrations/0001_initial.py

@ -0,0 +1,22 @@ @@ -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)),
],
),
]

9
coureurs/models.py

@ -0,0 +1,9 @@ @@ -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})"

3
coureurs/tests.py

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

3
coureurs/views.py

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.

15
courses/admin.py

@ -0,0 +1,15 @@ @@ -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',)

6
courses/apps.py

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
from django.apps import AppConfig
class CoursesConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'courses'

24
courses/forms.py

@ -0,0 +1,24 @@ @@ -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

45
courses/migrations/0001_initial.py

@ -0,0 +1,45 @@ @@ -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')},
},
),
]

32
courses/models.py

@ -0,0 +1,32 @@ @@ -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})"

3
courses/tests.py

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

9
courses/urls.py

@ -0,0 +1,9 @@ @@ -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'),
]

105
courses/views.py

@ -0,0 +1,105 @@ @@ -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()
})

27
crossapp/asgi.py

@ -0,0 +1,27 @@ @@ -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
)
),
})

6
crossapp/routing.py

@ -0,0 +1,6 @@ @@ -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()),
]

136
crossapp/settings.py

@ -0,0 +1,136 @@ @@ -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'

34
crossapp/urls.py

@ -0,0 +1,34 @@ @@ -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)

16
crossapp/wsgi.py

@ -0,0 +1,16 @@ @@ -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()

35
docker-compose.yml

@ -0,0 +1,35 @@ @@ -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:

3
dossards/admin.py

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

6
dossards/apps.py

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
from django.apps import AppConfig
class DossardsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'dossards'

6
dossards/forms.py

@ -0,0 +1,6 @@ @@ -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)

3
dossards/models.py

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
from django.db import models
# Create your models here.

3
dossards/tests.py

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

6
dossards/urls.py

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
from django.urls import path
from .views import dossards_view
urlpatterns = [
path('', dossards_view, name='dossards'),
]

90
dossards/views.py

@ -0,0 +1,90 @@ @@ -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
})

22
manage.py

@ -0,0 +1,22 @@ @@ -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()

8
requirements.txt

@ -0,0 +1,8 @@ @@ -0,0 +1,8 @@
Django>=5.2.6
channels
reportlab
qrcode
pandas
Pillow
pyopenssl
daphne

3
scan/admin.py

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

6
scan/apps.py

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
from django.apps import AppConfig
class ScanConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'scan'

5
scan/forms.py

@ -0,0 +1,5 @@ @@ -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)

3
scan/models.py

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
from django.db import models
# Create your models here.

3
scan/tests.py

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

6
scan/urls.py

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
from django.urls import path
from .views import scan_view
urlpatterns = [
path('', scan_view, name='scan'),
]

69
scan/views.py

@ -0,0 +1,69 @@ @@ -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
})

7
static/bootstrap/bootstrap.bundle.min.js vendored

File diff suppressed because one or more lines are too long

1
static/html5-qrcode/html5-qrcode.min.js vendored

File diff suppressed because one or more lines are too long

2
static/jquery/jquery-3.6.0.min.js vendored

File diff suppressed because one or more lines are too long

10
static/sb-admin-2/sb-admin-2.min.css vendored

File diff suppressed because one or more lines are too long

7
static/sb-admin-2/sb-admin-2.min.js vendored

@ -0,0 +1,7 @@ @@ -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);

10
templates/arrivees_tbody.html

@ -0,0 +1,10 @@ @@ -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 %}

26
templates/base.html

@ -0,0 +1,26 @@ @@ -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>

78
templates/course_detail.html

@ -0,0 +1,78 @@ @@ -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 %}

31
templates/dossards.html

@ -0,0 +1,31 @@ @@ -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 %}

99
templates/main.html

@ -0,0 +1,99 @@ @@ -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">&times;</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 %}

24
templates/registration/login.html

@ -0,0 +1,24 @@ @@ -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 %}

140
templates/scan.html

@ -0,0 +1,140 @@ @@ -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 %}

11
templates/scan_result.html

@ -0,0 +1,11 @@ @@ -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 %}

3
websocket/admin.py

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

6
websocket/apps.py

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
from django.apps import AppConfig
class WebsocketConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'websocket'

25
websocket/consumers.py

@ -0,0 +1,25 @@ @@ -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']))

3
websocket/models.py

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
from django.db import models
# Create your models here.

6
websocket/routing.py

@ -0,0 +1,6 @@ @@ -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()),
]

3
websocket/tests.py

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

3
websocket/views.py

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.
Loading…
Cancel
Save