Browse Source

Premier commit

master
scayac 3 months ago
commit
70f4c717ca
  1. 11
      .gitignore
  2. 23
      Dockerfile
  3. 43
      README.md
  4. 4
      conf.env
  5. 17
      crossapp/asgi.py
  6. 6
      crossapp/routing.py
  7. 142
      crossapp/settings.py
  8. 14
      crossapp/urls.py
  9. 7
      crossapp/wsgi.py
  10. 23
      main/admin.py
  11. 6
      main/apps.py
  12. 26
      main/consumers.py
  13. 33
      main/forms.py
  14. 52
      main/migrations/0001_initial.py
  15. 36
      main/models.py
  16. 8
      main/templates/404.html
  17. BIN
      main/templates/500.html
  18. BIN
      main/templates/arrivees_tbody.html
  19. 94
      main/templates/base.html
  20. 167
      main/templates/course_detail.html
  21. 32
      main/templates/dossards.html
  22. 100
      main/templates/main.html
  23. 41
      main/templates/registration/login.html
  24. 203
      main/templates/scan.html
  25. 11
      main/templates/scan_result.html
  26. 24
      main/templatetags/temps_format.py
  27. 11
      main/urls.py
  28. 287
      main/views.py
  29. 22
      manage.py
  30. 10
      requirements.txt
  31. 7
      static/bootstrap/bootstrap.bundle.min.js
  32. 1
      static/bootstrap/bootstrap.bundle.min.js.map
  33. 1
      static/bootstrap/dataTables.bootstrap4.min.css
  34. 4
      static/bootstrap/dataTables.bootstrap4.min.js
  35. 1
      static/html5-qrcode/html5-qrcode.min.js
  36. 6
      static/jquery/datatables.fr.js
  37. 2
      static/jquery/jquery-3.6.0.min.js
  38. 4
      static/jquery/jquery.dataTables.min.js
  39. BIN
      static/person-running-solid-full.png
  40. 10
      static/sb-admin-2/sb-admin-2.min.css
  41. 7
      static/sb-admin-2/sb-admin-2.min.js
  42. 3
      websocket/admin.py
  43. 6
      websocket/apps.py
  44. 25
      websocket/consumers.py
  45. 3
      websocket/models.py
  46. 6
      websocket/routing.py
  47. 3
      websocket/tests.py
  48. 3
      websocket/views.py

11
.gitignore vendored

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

23
Dockerfile

@ -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 8003
# Start Daphne
CMD ["daphne", "-b", "0.0.0.0", "-p", "8003", "crossapp.asgi:application"]

43
README.md

@ -0,0 +1,43 @@
# 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 makemigrations coureurs courses
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)

4
conf.env

@ -0,0 +1,4 @@
DJANGO_SECRET_KEY=your-secret-key
DJANGO_DEBUG=True
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,192.168.0.14
DJANGO_CSRF_TRUSTED_ORIGINS=http://localhost,http://127.0.0.1

17
crossapp/asgi.py

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

142
crossapp/settings.py

@ -0,0 +1,142 @@
from pathlib import Path
import os
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
from dotenv import load_dotenv
# Load environment variables from conf.env
load_dotenv(BASE_DIR / 'conf.env')
# 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 = os.getenv('DJANGO_SECRET_KEY', 'django-insecure-(norsp!z18z298=sociz_3n57ml+u2$%hmsphkk_udgg*414#*')
OPENAI_API_KEY = os.getenv('OPENAI_API_KEY', '')
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = os.getenv('DJANGO_DEBUG', 'True') == 'True'
ALLOWED_HOSTS = os.getenv('DJANGO_ALLOWED_HOSTS', 'localhost,127.0.0.1').split(',')
CSRF_TRUSTED_ORIGINS = os.getenv('DJANGO_CSRF_TRUSTED_ORIGINS', 'http://localhost,http://127.0.0.1').split(',')
# Application definition
INSTALLED_APPS = [
'daphne',
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'fontawesomefree',
'channels',
'main',
]
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'
# Redirection après login
LOGIN_REDIRECT_URL = '/'
# Redirection après logout
LOGOUT_REDIRECT_URL = '/accounts/login/'

14
crossapp/urls.py

@ -0,0 +1,14 @@
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('main.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)

7
crossapp/wsgi.py

@ -0,0 +1,7 @@
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'crossapp.settings')
application = get_wsgi_application()

23
main/admin.py

@ -0,0 +1,23 @@
from django.contrib import admin
from .models import Course, Arrivee, Coureur
@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',)
@admin.register(Coureur)
class CoureurAdmin(admin.ModelAdmin):
list_display = ('nom', 'classe')
search_fields = ('nom', 'classe')
list_filter = ('classe',)

6
main/apps.py

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

26
main/consumers.py

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

33
main/forms.py

@ -0,0 +1,33 @@
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
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)
class ScanForm(forms.Form):
course_id = forms.IntegerField(widget=forms.HiddenInput)
qrcode = forms.CharField(max_length=200)

52
main/migrations/0001_initial.py

@ -0,0 +1,52 @@
# Generated by Django 5.2.7 on 2025-10-02 11:10
import django.db.models.deletion
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)),
],
),
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='main.coureur')),
('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='arrivees', to='main.course')),
],
options={
'ordering': ['rang'],
'unique_together': {('course', 'coureur')},
},
),
]

36
main/models.py

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

8
main/templates/404.html

@ -0,0 +1,8 @@
{% extends "base.html" %}
{% block content %}
<div class="container mt-5 text-center">
<h1 class="display-4 text-danger">Erreur 404</h1>
<p class="lead">La page demandée n'existe pas ou a été déplacée.</p>
<a href="/" class="btn btn-primary">Retour à l'accueil</a>
</div>
{% endblock %}

BIN
main/templates/500.html

Binary file not shown.

BIN
main/templates/arrivees_tbody.html

Binary file not shown.

94
main/templates/base.html

@ -0,0 +1,94 @@
<!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 %}
<link rel="icon" type="image/png" href="{% static 'person-running-solid-full.png' %}">
<!-- SB Admin 2 Bootstrap CSS -->
<link href="{% static 'sb-admin-2/sb-admin-2.min.css' %}" rel="stylesheet">
<!-- Font Awesome -->
<link href="{% static 'fontawesomefree/css/fontawesome.css' %}" rel="stylesheet">
<link href="{% static 'fontawesomefree/css/brands.css' %}" rel="stylesheet">
<link href="{% static 'fontawesomefree/css/solid.css' %}" rel="stylesheet">
<!-- Custom styles -->
{% block extra_css %}{% endblock %}
</head>
<body id="page-top">
<!-- Page Wrapper -->
<div id="wrapper" class="d-flex">
<!-- Sidebar (Accordion Navigation) -->
<nav class="bg-gradient-primary sidebar sidebar-dark accordion" id="accordionSidebar">
<!-- Sidebar - Brand -->
<a class="sidebar-brand d-flex align-items-center justify-content-center" href="/">
<div class="sidebar-brand-icon rotate-n-15">
<i class="fas fa-running"></i>
</div>
<div class="sidebar-brand-text mx-3">CrossApp</title></div>
</a>
<!-- Divider -->
<hr class="sidebar-divider my-0">
<!-- Nav Item - Dashboard -->
<li class="nav-item active">
<a class="nav-link" href="/">
<i class="fas fa-fw fa-tachometer-alt"></i>
<span>Dashboard</span></a>
</li>
<!-- Nav Item - Dossards -->
<li class="nav-item active">
<a class="nav-link" href="{% url 'dossards' %}">
<i class="fas fa-qrcode"></i>
<span>Dossards</span></a>
</li>
<!-- Divider -->
<hr class="sidebar-divider d-none d-md-block">
<!-- Logout Button -->
<li class="nav-item">
<form method="post" action="{% url 'logout' %}" style="margin:0;">
{% csrf_token %}
<button type="submit" class="nav-link btn btn-link text-left w-100" style="color:#fff;">
<i class="fas fa-sign-out-alt"></i>
<span>Déconnexion</span>
</button>
</form>
</li>
</nav>
<!-- Content Wrapper -->
<div id="content-wrapper" class="flex-fill">
<!-- Topbar déplacée ici -->
<nav class="navbar navbar-expand navbar-light bg-white topbar mb-4 static-top shadow">
<!-- Hamburger Sidebar Toggle (Topbar) -->
<button id="sidebarToggleTop" class="btn btn-link d-md-none rounded-circle mr-3">
<i class="fa fa-bars"></i>
</button>
<span class="navbar-brand font-weight-bold">{{ title }}</span>
</nav>
{% block content %}{% endblock %}
</div>
</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>
<script>
// Masquer la sidebar automatiquement si l'écran devient trop petit
function autoHideSidebar() {
if (window.innerWidth < 768) {
document.body.classList.add('sidebar-toggled');
var sidebar = document.querySelector('.sidebar');
if (sidebar) sidebar.classList.add('toggled');
} else {
document.body.classList.remove('sidebar-toggled');
var sidebar = document.querySelector('.sidebar');
if (sidebar) sidebar.classList.remove('toggled');
}
}
window.addEventListener('resize', autoHideSidebar);
window.addEventListener('DOMContentLoaded', autoHideSidebar);
</script>
{% block extra_js %}{% endblock %}
</body>
</html>

167
main/templates/course_detail.html

@ -0,0 +1,167 @@
{% extends 'base.html' %}
{% block content %}
{% load static %}
{% load temps_format %}
<div class="container-fluid mt-4">
<div class="row">
<div class="col-12">
<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
<i class="fa-solid fa-play"></i>
</button>
{% elif not is_finished %}
<button type="button" id="btnFinish" class="btn btn-danger">Fin course
<i class="fa-solid fa-stop"></i>
</button>
<!-- Modal confirmation fin de course -->
<div class="modal fade" id="finishModal" tabindex="-1" role="dialog" aria-labelledby="finishModalLabel" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="finishModalLabel">Confirmer la fin de la course</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
Êtes-vous sûr de vouloir terminer la course ? Cette action est irréversible.
</div>
<div class="modal-footer">
<form method="post" style="margin:0;">
{% csrf_token %}
<button type="submit" name="finish" class="btn btn-danger">Valider la fin</button>
</form>
<button type="button" class="btn btn-secondary" data-dismiss="modal">Annuler</button>
</div>
</div>
</div>
</div>
{% 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>
<form id="exportCsvForm" method="post" action="{% url 'export_csv' course.id %}" style="display:inline;">
{% csrf_token %}
<input type="hidden" name="rows" id="csvRowsInput">
<button type="submit" class="btn btn-success mb-2" id="btnExportCsv">
<i class="fas fa-file-csv" title="Exporter en CSV"></i>
</button>
</form>
<form id="exportPdfForm" method="post" action="{% url 'export_pdf' course.id %}" style="display:inline;">
{% csrf_token %}
<input type="hidden" name="rows" id="pdfRowsInput">
<button type="submit" class="btn btn-danger mb-2" id="btnExportPdf">
<i class="fas fa-file-pdf" title="Exporter en PDF"></i>
</button>
</form>
</div>
</div>
<div class="card-body">
<div class="table-responsive">
<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>{% if a.temps %}{{ a.temps|seconds_to_hms }}{% endif %}</td>
</tr>
{% empty %}
<tr>
<td>Aucun coureur arrivé.</td>
<td></td>
<td></td>
<td></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<!-- DataTables JS & CSS -->
<link rel="stylesheet" href="{% static 'bootstrap/dataTables.bootstrap4.min.css' %}">
<script src="{% static 'jquery/jquery.dataTables.min.js' %}"></script>
<script src="{% static 'bootstrap/dataTables.bootstrap4.min.js' %}"></script>
<script src="{% static 'jquery/datatables.fr.js' %}"></script>
<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) {
// Ajoute dynamiquement la nouvelle ligne reçue via WebSocket
let data;
try {
data = JSON.parse(e.data);
} catch {
return;
}
// Vérifie le format des données reçues
let rowData;
if (Array.isArray(data)) {
rowData = data;
} else if (typeof data === 'object' && data !== null) {
// Transforme l'objet en tableau dans l'ordre attendu
rowData = [data.rang, data.nom || (data.coureur && data.coureur.nom), data.classe || (data.coureur && data.coureur.classe), data.temps];
} else {
// Format inconnu, ignore
return;
}
var dt = $('#arriveesTable').DataTable();
dt.row.add(rowData).draw(false);
};
// Modal confirmation fin de course
document.getElementById('btnFinish').onclick = function() {
$('#finishModal').modal('show');
};
// Initialisation DataTables au chargement
$(document).ready(function() {
$('#arriveesTable').DataTable();
});
// Export CSV/PDF des données filtrées
function getVisibleRows() {
var dt = $('#arriveesTable').DataTable();
var rows = dt.rows({search: 'applied'}).data().toArray();
return JSON.stringify(rows);
}
$('#exportCsvForm').on('submit', function(e) {
$('#csvRowsInput').val(getVisibleRows());
});
$('#exportPdfForm').on('submit', function(e) {
$('#pdfRowsInput').val(getVisibleRows());
});
</script>
{% endblock %}

32
main/templates/dossards.html

@ -0,0 +1,32 @@
{% extends 'base.html' %}
{% block content %}
<div class="container-fluid mt-4">
<div class="row">
<div class="col-12">
<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
<i class="fas fa-file-pdf" title="G\00e9n\00e9rer PDF"></i>
</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\00e9l\00e9charger le PDF</a>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endblock %}

100
main/templates/main.html

@ -0,0 +1,100 @@
{% extends 'base.html' %}
{% block content %}
<div class="container-fluid mt-4">
<div class="row">
<div class="col-12">
<div class="card shadow mb-4">
<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">
<i class="fas fa-eye" title="Détails de la course"></i>
</a>
<a href="{% url 'scan' %}?course_id={{ course.id }}" class="btn btn-info btn-sm">
<i class="fas fa-qrcode" title="Accès au mode scan"></i>
</a>
</div>
</li>
{% empty %}
<li class="list-group-item">Aucune course enregistrée.</li>
{% endfor %}
</ul>
</div>
<div class="card-body d-flex">
<button class="btn btn-success mb-2" id="btnNewCourse">
<i class="fas fa-plus" title="Créer une nouvelle course"></i>
&nbsp;
<i class="fas fa-running" title="Créer une nouvelle course"></i>
</button>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<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 %}

41
main/templates/registration/login.html

@ -0,0 +1,41 @@
{% load static %}
<!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>Connexion - CrossApp</title>
<link href="{% static 'sb-admin-2/sb-admin-2.min.css' %}" rel="stylesheet">
<link href="{% static 'fontawesomefree/css/fontawesome.css' %}" rel="stylesheet">
<link href="{% static 'fontawesomefree/css/brands.css' %}" rel="stylesheet">
<link href="{% static 'fontawesomefree/css/solid.css' %}" rel="stylesheet">
</head>
<body class="bg-gradient-primary">
<div class="container">
<div class="row justify-content-center">
<div class="col-xl-6 col-lg-8 col-md-9">
<div class="card o-hidden border-0 shadow-lg my-5">
<div class="card-body p-5">
<div class="text-center mb-4">
<h1 class="h4 text-gray-900">Connexion à CrossApp</h1>
</div>
<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>
<script src="{% static 'jquery/jquery-3.6.0.min.js' %}"></script>
<script src="{% static 'bootstrap/bootstrap.bundle.min.js' %}"></script>
<script src="{% static 'sb-admin-2/sb-admin-2.min.js' %}"></script>
</body>
</html>

203
main/templates/scan.html

@ -0,0 +1,203 @@
{% extends 'base.html' %}
{% block content %}
{% load static %}
<div class="container-fluid mt-4">
<div class="row">
<div class="col-12">
<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-center gap-3">
<a href="/" class="btn btn-secondary" title="Accueil">
<i class="fas fa-home mx-auto"></i>
</a>&nbsp;
<button id="toggleBeep" type="button" class="btn btn-info " title="Bip scan" >
<i id="beepIcon" class="fas fa-volume-up mx-auto"></i>
</button>&nbsp;
<button id="showCameras" type="button" class="btn btn-warning " title="Changer de caméra" data-toggle="modal" data-target="#cameraModal">
<i class="fas fa-camera mx-auto"></i>
</button>
</div>
<!-- Modal pour la liste des caméras -->
<div class="modal fade" id="cameraModal" tabindex="-1" role="dialog" aria-labelledby="cameraModalLabel" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="cameraModalLabel">Sélectionner une caméra</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<ul id="cameraList" class="list-group"></ul>
</div>
</div>
</div>
</div>
</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;
}
let camerasAvailable = [];
let currentCameraId = null;
document.addEventListener('DOMContentLoaded', function() {
html5Qrcode = new Html5Qrcode("reader");
Html5Qrcode.getCameras().then(cameras => {
camerasAvailable = cameras || [];
if (camerasAvailable.length) {
currentCameraId = camerasAvailable[0].id;
html5Qrcode.start(
currentCameraId,
{ fps: 10, qrbox: 250 },
onScanSuccess
);
}
});
// Remplir la liste des caméras à chaque ouverture du modal
$('#cameraModal').on('show.bs.modal', function () {
const cameraList = document.getElementById('cameraList');
cameraList.innerHTML = '';
camerasAvailable.forEach((cam, idx) => {
const li = document.createElement('li');
li.className = 'list-group-item list-group-item-action';
li.textContent = cam.label || `Caméra ${idx+1}`;
li.style.cursor = 'pointer';
li.onclick = function() {
if (currentCameraId !== cam.id) {
html5Qrcode.stop().then(() => {
currentCameraId = cam.id;
html5Qrcode.start(
currentCameraId,
{ fps: 10, qrbox: 250 },
onScanSuccess
);
});
}
// Fermer le modal
$('#cameraModal').modal('hide');
};
cameraList.appendChild(li);
});
});
});
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');
const beepIcon = document.getElementById('beepIcon');
let beepEnabled = true;
beepBtn.onclick = function() {
beepEnabled = !beepEnabled;
if (beepEnabled) {
beepIcon.classList.remove('fa-volume-mute');
beepIcon.classList.add('fa-volume-up');
beepBtn.title = 'Désactiver bip scan';
} else {
beepIcon.classList.remove('fa-volume-up');
beepIcon.classList.add('fa-volume-mute');
beepBtn.title = '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
main/templates/scan_result.html

@ -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 %}

24
main/templatetags/temps_format.py

@ -0,0 +1,24 @@
from django import template
register = template.Library()
import datetime
def seconds_to_hms(value):
try:
# Si value est un datetime, on convertit en secondes
if isinstance(value, datetime.timedelta):
total_seconds = int(value.total_seconds())
elif isinstance(value, datetime.datetime):
# Si value est un datetime, on prend l'heure/min/sec
total_seconds = value.hour * 3600 + value.minute * 60 + value.second
else:
total_seconds = int(value)
h = total_seconds // 3600
m = (total_seconds % 3600) // 60
s = total_seconds % 60
return f"{h:02}h{m:02}m{s:02}s"
except (ValueError, TypeError, AttributeError):
return "--:--:--"
register.filter('seconds_to_hms', seconds_to_hms)

11
main/urls.py

@ -0,0 +1,11 @@
from django.urls import path
from . import views
urlpatterns = [
path('', views.main_view, name='main'),
path('course/<int:course_id>/', views.course_detail_view, name='course_detail'),
path('course/<int:course_id>/export_csv/', views.export_csv, name='export_csv'),
path('course/<int:course_id>/export_pdf/', views.export_pdf, name='export_pdf'),
path('scan/', views.scan_view, name='scan'),
path('dossards/', views.dossards_view, name='dossards'),
]

287
main/views.py

@ -0,0 +1,287 @@
import csv
import io
from datetime import timedelta
from django.http import HttpResponse
from django.shortcuts import render, redirect, get_object_or_404
from django.contrib.auth.decorators import login_required
from django.utils import timezone
from reportlab.pdfgen import canvas
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm
from channels.layers import get_channel_layer
from asgiref.sync import async_to_sync
from .models import Course, Arrivee, Coureur
from .forms import CourseForm, ScanForm, DossardForm
import qrcode
from PIL import Image
def seconds_to_hms(delta: timedelta) -> str:
"""Format a timedelta as H:MM:SS (handles days too)."""
if delta is None:
return ''
total = int(delta.total_seconds())
hours = total // 3600
minutes = (total % 3600) // 60
seconds = total % 60
return f"{hours}:{minutes:02d}:{seconds:02d}"
def export_csv(request, course_id):
course = get_object_or_404(Course, id=course_id)
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'])
import json
rows_json = request.POST.get('rows')
if request.method == "POST" and rows_json:
try:
rows = json.loads(rows_json)
for row in rows:
writer.writerow(row)
except Exception:
pass
else:
arrivees = course.arrivees.select_related('coureur').order_by('rang')
for a in arrivees:
writer.writerow([a.rang, a.coureur.nom, a.coureur.classe, str(a.temps)])
return response
def export_pdf(request, course_id):
course = get_object_or_404(Course, id=course_id)
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\u00e9sultats - {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
import json
rows_json = request.POST.get('rows')
if request.method == "POST" and rows_json:
try:
rows = json.loads(rows_json)
for row in rows:
p.drawString(50, y, str(row[0]))
p.drawString(100, y, str(row[1]))
p.drawString(300, y, str(row[2]))
p.drawString(400, y, str(row[3]))
y -= 20
if y < 50:
p.showPage()
y = height - 50
except Exception:
pass
else:
arrivees = course.arrivees.select_related('coureur').order_by('rang')
for a in arrivees:
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
@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
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':
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', {
'title': 'Course : '+course.nom+" ("+str(course.date)+")",
'course': course,
'arrivees': arrivees,
'is_started': is_started,
'is_finished': is_finished
})
@login_required
def main_view(request):
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\u00e9j\u00e0 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', {
'title': 'Accueil',
'courses': courses,
'form': form,
'now': timezone.localdate()
})
@login_required
def scan_view(request):
courses = Course.objects.filter(depart__isnull=False, fin__isnull=True)
result = None
error = None
course = None
if request.method == 'POST' and request.headers.get('x-requested-with') == 'XMLHttpRequest':
course_id = request.POST.get('course_id')
qrcode = request.POST.get('qrcode')
if not course_id or not qrcode:
error = "Param\u00e8tres 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\u00e9marr\u00e9."
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\u00e9j\u00e0 \u00e9t\u00e9 scann\u00e9."
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(seconds_to_hms(temps))
}
channel_layer = get_channel_layer()
async_to_sync(channel_layer.group_send)(
f'course_{course.id}',
{
'type': 'send_arrivee',
'data': result
}
)
if result:
return render(request, 'scan_result.html', {'result': result})
else:
return render(request, 'scan_result.html', {'error': error})
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', {
'title': 'Scan course : '+(course.nom+" ("+str(course.date)+")" if course else ''),
'courses': courses,
'result': result,
'error': error,
'course': course
})
@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\u00e9n\u00e9ration 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', {
'title': 'G\u00e9n\u00e9ration des dossards PDF',
'form': form,
'error': error,
'progress': progress,
'pdf_url': pdf_url
})
def generate_dossards_pdf(data, rows, cols):
import io
from reportlab.pdfgen import canvas
from reportlab.lib.pagesizes import A4
buffer = io.BytesIO()
c = canvas.Canvas(buffer, pagesize=A4)
width, height = A4
margin = 10 * mm
label_w = (width - 2 * margin) / cols
label_h = (height - 2 * margin) / rows
x0, y0 = margin, height - margin - label_h
qr_scale = 0.8 # 80% of the label area for the QR
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
c.setLineWidth(3)
c.setStrokeColorRGB(0, 0, 0)
c.rect(x, y, label_w, label_h)
# Generate QR
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)
qr_size = min(label_w, label_h) * qr_scale
qr_x = x + (label_w - qr_size) / 2
qr_y = y + (label_h - qr_size) / 2
c.drawInlineImage(qr_pil, qr_x, qr_y, qr_size, qr_size)
c.setFont("Helvetica-Bold", 12)
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

22
manage.py

@ -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()

10
requirements.txt

@ -0,0 +1,10 @@
Django>=5.2.6
python-dotenv
channels
fontawesomefree==6.6.0
reportlab
qrcode
pandas
Pillow
pyopenssl
daphne

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

File diff suppressed because one or more lines are too long

1
static/bootstrap/bootstrap.bundle.min.js.map

File diff suppressed because one or more lines are too long

1
static/bootstrap/dataTables.bootstrap4.min.css vendored

File diff suppressed because one or more lines are too long

4
static/bootstrap/dataTables.bootstrap4.min.js vendored

@ -0,0 +1,4 @@
/*! DataTables Bootstrap 4 integration
* ©2011-2017 SpryMedia Ltd - datatables.net/license
*/
!function(t){var n,o;"function"==typeof define&&define.amd?define(["jquery","datatables.net"],function(e){return t(e,window,document)}):"object"==typeof exports?(n=require("jquery"),o=function(e,a){a.fn.dataTable||require("datatables.net")(e,a)},"undefined"==typeof window?module.exports=function(e,a){return e=e||window,a=a||n(e),o(e,a),t(a,0,e.document)}:(o(window,n),module.exports=t(n,window,window.document))):t(jQuery,window,document)}(function(x,e,n,o){"use strict";var r=x.fn.dataTable;return x.extend(!0,r.defaults,{dom:"<'row'<'col-sm-12 col-md-6'l><'col-sm-12 col-md-6'f>><'row'<'col-sm-12'tr>><'row'<'col-sm-12 col-md-5'i><'col-sm-12 col-md-7'p>>",renderer:"bootstrap"}),x.extend(r.ext.classes,{sWrapper:"dataTables_wrapper dt-bootstrap4",sFilterInput:"form-control form-control-sm",sLengthSelect:"custom-select custom-select-sm form-control form-control-sm",sProcessing:"dataTables_processing card",sPageButton:"paginate_button page-item"}),r.ext.renderer.pageButton.bootstrap=function(i,e,d,a,l,c){function u(e,a){for(var t,n,o=function(e){e.preventDefault(),x(e.currentTarget).hasClass("disabled")||m.page()==e.data.action||m.page(e.data.action).draw("page")},r=0,s=a.length;r<s;r++)if(t=a[r],Array.isArray(t))u(e,t);else{switch(f=p="",t){case"ellipsis":p="&#x2026;",f="disabled";break;case"first":p=g.sFirst,f=t+(0<l?"":" disabled");break;case"previous":p=g.sPrevious,f=t+(0<l?"":" disabled");break;case"next":p=g.sNext,f=t+(l<c-1?"":" disabled");break;case"last":p=g.sLast,f=t+(l<c-1?"":" disabled");break;default:p=t+1,f=l===t?"active":""}p&&(n=-1!==f.indexOf("disabled"),n=x("<li>",{class:b.sPageButton+" "+f,id:0===d&&"string"==typeof t?i.sTableId+"_"+t:null}).append(x("<a>",{href:n?null:"#","aria-controls":i.sTableId,"aria-disabled":n?"true":null,"aria-label":w[t],role:"link","aria-current":"active"===f?"page":null,"data-dt-idx":t,tabindex:n?-1:i.iTabIndex,class:"page-link"}).html(p)).appendTo(e),i.oApi._fnBindAction(n,{action:t},o))}}var p,f,t,m=new r.Api(i),b=i.oClasses,g=i.oLanguage.oPaginate,w=i.oLanguage.oAria.paginate||{};try{t=x(e).find(n.activeElement).data("dt-idx")}catch(e){}u(x(e).empty().html('<ul class="pagination"/>').children("ul"),a),t!==o&&x(e).find("[data-dt-idx="+t+"]").trigger("focus")},r});

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

File diff suppressed because one or more lines are too long

6
static/jquery/datatables.fr.js

@ -0,0 +1,6 @@
/*! DataTables French translation - v1.10.20 */
$.extend(true, $.fn.dataTable.defaults, {
language: {
url: "https://cdn.datatables.net/plug-ins/1.13.7/i18n/fr-FR.json"
}
});

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

File diff suppressed because one or more lines are too long

4
static/jquery/jquery.dataTables.min.js vendored

File diff suppressed because one or more lines are too long

BIN
static/person-running-solid-full.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 696 B

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 @@
/*!
* 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);

3
websocket/admin.py

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

6
websocket/apps.py

@ -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 @@
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 @@
from django.db import models
# Create your models here.

6
websocket/routing.py

@ -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 @@
from django.test import TestCase
# Create your tests here.

3
websocket/views.py

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