commit
70f4c717ca
48 changed files with 1545 additions and 0 deletions
@ -0,0 +1,11 @@ |
|||||||
|
*.log |
||||||
|
*.pot |
||||||
|
*.pyc |
||||||
|
.venv |
||||||
|
.vscode |
||||||
|
staticfiles/* |
||||||
|
__pycache__ |
||||||
|
__init__.py |
||||||
|
db.sqlite3 |
||||||
|
*.crt |
||||||
|
*.key |
||||||
@ -0,0 +1,23 @@ |
|||||||
|
# Dockerfile for Django + Daphne |
||||||
|
FROM python:3.11-slim |
||||||
|
|
||||||
|
WORKDIR /app |
||||||
|
|
||||||
|
# Install system dependencies |
||||||
|
RUN apt-get update && apt-get install -y build-essential libpq-dev && rm -rf /var/lib/apt/lists/* |
||||||
|
|
||||||
|
# Install Python dependencies |
||||||
|
COPY requirements.txt ./ |
||||||
|
RUN pip install --no-cache-dir -r requirements.txt |
||||||
|
|
||||||
|
# Copy project files |
||||||
|
COPY . . |
||||||
|
|
||||||
|
# Collect static files |
||||||
|
RUN python manage.py collectstatic --noinput |
||||||
|
|
||||||
|
# Expose Daphne port |
||||||
|
EXPOSE 8003 |
||||||
|
|
||||||
|
# Start Daphne |
||||||
|
CMD ["daphne", "-b", "0.0.0.0", "-p", "8003", "crossapp.asgi:application"] |
||||||
@ -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) |
||||||
@ -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 |
||||||
@ -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 |
||||||
|
) |
||||||
|
), |
||||||
|
}) |
||||||
@ -0,0 +1,6 @@ |
|||||||
|
from django.urls import re_path |
||||||
|
from websocket.consumers import ArriveeConsumer |
||||||
|
|
||||||
|
websocket_urlpatterns = [ |
||||||
|
re_path(r'ws/course/(?P<course_id>\d+)/$', ArriveeConsumer.as_asgi()), |
||||||
|
] |
||||||
@ -0,0 +1,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/' |
||||||
@ -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) |
||||||
@ -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() |
||||||
@ -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',) |
||||||
@ -0,0 +1,6 @@ |
|||||||
|
from django.apps import AppConfig |
||||||
|
|
||||||
|
|
||||||
|
class MainConfig(AppConfig): |
||||||
|
default_auto_field = 'django.db.models.BigAutoField' |
||||||
|
name = 'main' |
||||||
@ -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'])) |
||||||
@ -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) |
||||||
@ -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')}, |
||||||
|
}, |
||||||
|
), |
||||||
|
] |
||||||
@ -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})" |
||||||
@ -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 %} |
||||||
Binary file not shown.
Binary file not shown.
@ -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> |
||||||
@ -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">×</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 %} |
||||||
@ -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 %} |
||||||
@ -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> |
||||||
|
|
||||||
|
<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">×</span></button> |
||||||
|
</div> |
||||||
|
<div class="modal-body"> |
||||||
|
<div class="form-group"> |
||||||
|
<label for="courseName">Nom de la course</label> |
||||||
|
<input type="text" class="form-control" name="nom" id="courseName" required> |
||||||
|
</div> |
||||||
|
<div id="newCourseError" class="alert alert-danger" style="display:none;"></div> |
||||||
|
</div> |
||||||
|
<div class="modal-footer"> |
||||||
|
<button type="submit" class="btn btn-success">Créer et scanner</button> |
||||||
|
</div> |
||||||
|
</form> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{% block extra_js %} |
||||||
|
<script> |
||||||
|
document.getElementById('btnNewCourse').onclick = function() { |
||||||
|
document.getElementById('newCourseModal').style.display = 'block'; |
||||||
|
}; |
||||||
|
document.getElementById('closeModal').onclick = function() { |
||||||
|
document.getElementById('newCourseModal').style.display = 'none'; |
||||||
|
}; |
||||||
|
document.getElementById('newCourseForm').onsubmit = function(e) { |
||||||
|
e.preventDefault(); |
||||||
|
const nom = document.getElementById('courseName').value; |
||||||
|
const csrf = document.querySelector('[name=csrfmiddlewaretoken]').value; |
||||||
|
fetch('', { |
||||||
|
method: 'POST', |
||||||
|
headers: { |
||||||
|
'Content-Type': 'application/x-www-form-urlencoded', |
||||||
|
'X-Requested-With': 'XMLHttpRequest' |
||||||
|
}, |
||||||
|
body: `csrfmiddlewaretoken=${encodeURIComponent(csrf)}&nom=${encodeURIComponent(nom)}` |
||||||
|
}) |
||||||
|
.then(response => response.json()) |
||||||
|
.then(data => { |
||||||
|
if (data.success) { |
||||||
|
window.location.href = `/scan/?course_id=${data.course_id}`; |
||||||
|
} else { |
||||||
|
document.getElementById('newCourseError').textContent = data.error; |
||||||
|
document.getElementById('newCourseError').style.display = 'block'; |
||||||
|
} |
||||||
|
}); |
||||||
|
}; |
||||||
|
</script> |
||||||
|
{% endblock %} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{% endblock %} |
||||||
@ -0,0 +1,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> |
||||||
@ -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> |
||||||
|
<button id="toggleBeep" type="button" class="btn btn-info " title="Bip scan" > |
||||||
|
<i id="beepIcon" class="fas fa-volume-up mx-auto"></i> |
||||||
|
</button> |
||||||
|
<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">×</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 %} |
||||||
@ -0,0 +1,11 @@ |
|||||||
|
{% if result %} |
||||||
|
<div class="alert alert-success"> |
||||||
|
<strong>Arrivée enregistrée :</strong><br> |
||||||
|
Nom : {{ result.nom }}<br> |
||||||
|
Classe : {{ result.classe }}<br> |
||||||
|
Rang : {{ result.rang }}<br> |
||||||
|
Temps : {{ result.temps }} |
||||||
|
</div> |
||||||
|
{% elif error %} |
||||||
|
<div class="alert alert-danger">{{ error }}</div> |
||||||
|
{% endif %} |
||||||
@ -0,0 +1,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) |
||||||
@ -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'), |
||||||
|
] |
||||||
@ -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 |
||||||
@ -0,0 +1,22 @@ |
|||||||
|
#!/usr/bin/env python |
||||||
|
"""Django's command-line utility for administrative tasks.""" |
||||||
|
import os |
||||||
|
import sys |
||||||
|
|
||||||
|
|
||||||
|
def main(): |
||||||
|
"""Run administrative tasks.""" |
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'crossapp.settings') |
||||||
|
try: |
||||||
|
from django.core.management import execute_from_command_line |
||||||
|
except ImportError as exc: |
||||||
|
raise ImportError( |
||||||
|
"Couldn't import Django. Are you sure it's installed and " |
||||||
|
"available on your PYTHONPATH environment variable? Did you " |
||||||
|
"forget to activate a virtual environment?" |
||||||
|
) from exc |
||||||
|
execute_from_command_line(sys.argv) |
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__': |
||||||
|
main() |
||||||
@ -0,0 +1,10 @@ |
|||||||
|
Django>=5.2.6 |
||||||
|
python-dotenv |
||||||
|
channels |
||||||
|
fontawesomefree==6.6.0 |
||||||
|
reportlab |
||||||
|
qrcode |
||||||
|
pandas |
||||||
|
Pillow |
||||||
|
pyopenssl |
||||||
|
daphne |
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -0,0 +1,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="…",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}); |
||||||
File diff suppressed because one or more lines are too long
@ -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" |
||||||
|
} |
||||||
|
}); |
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 696 B |
File diff suppressed because one or more lines are too long
@ -0,0 +1,7 @@ |
|||||||
|
/*! |
||||||
|
* Start Bootstrap - SB Admin 2 v4.1.4 (https://startbootstrap.com/theme/sb-admin-2)
|
||||||
|
* Copyright 2013-2021 Start Bootstrap |
||||||
|
* Licensed under MIT (https://github.com/StartBootstrap/startbootstrap-sb-admin-2/blob/master/LICENSE)
|
||||||
|
*/ |
||||||
|
|
||||||
|
!function(l){"use strict";l("#sidebarToggle, #sidebarToggleTop").on("click",function(e){l("body").toggleClass("sidebar-toggled"),l(".sidebar").toggleClass("toggled"),l(".sidebar").hasClass("toggled")&&l(".sidebar .collapse").collapse("hide")}),l(window).resize(function(){l(window).width()<768&&l(".sidebar .collapse").collapse("hide"),l(window).width()<480&&!l(".sidebar").hasClass("toggled")&&(l("body").addClass("sidebar-toggled"),l(".sidebar").addClass("toggled"),l(".sidebar .collapse").collapse("hide"))}),l("body.fixed-nav .sidebar").on("mousewheel DOMMouseScroll wheel",function(e){var o;768<l(window).width()&&(o=(o=e.originalEvent).wheelDelta||-o.detail,this.scrollTop+=30*(o<0?1:-1),e.preventDefault())}),l(document).on("scroll",function(){100<l(this).scrollTop()?l(".scroll-to-top").fadeIn():l(".scroll-to-top").fadeOut()}),l(document).on("click","a.scroll-to-top",function(e){var o=l(this);l("html, body").stop().animate({scrollTop:l(o.attr("href")).offset().top},1e3,"easeInOutExpo"),e.preventDefault()})}(jQuery); |
||||||
@ -0,0 +1,3 @@ |
|||||||
|
from django.contrib import admin |
||||||
|
|
||||||
|
# Register your models here. |
||||||
@ -0,0 +1,6 @@ |
|||||||
|
from django.apps import AppConfig |
||||||
|
|
||||||
|
|
||||||
|
class WebsocketConfig(AppConfig): |
||||||
|
default_auto_field = 'django.db.models.BigAutoField' |
||||||
|
name = 'websocket' |
||||||
@ -0,0 +1,25 @@ |
|||||||
|
import json |
||||||
|
from channels.generic.websocket import AsyncWebsocketConsumer |
||||||
|
|
||||||
|
class ArriveeConsumer(AsyncWebsocketConsumer): |
||||||
|
async def connect(self): |
||||||
|
self.course_id = self.scope['url_route']['kwargs']['course_id'] |
||||||
|
self.group_name = f'course_{self.course_id}' |
||||||
|
await self.channel_layer.group_add( |
||||||
|
self.group_name, |
||||||
|
self.channel_name |
||||||
|
) |
||||||
|
await self.accept() |
||||||
|
|
||||||
|
async def disconnect(self, close_code): |
||||||
|
await self.channel_layer.group_discard( |
||||||
|
self.group_name, |
||||||
|
self.channel_name |
||||||
|
) |
||||||
|
|
||||||
|
async def receive(self, text_data): |
||||||
|
# Optionnel : traiter les messages entrants |
||||||
|
pass |
||||||
|
|
||||||
|
async def send_arrivee(self, event): |
||||||
|
await self.send(text_data=json.dumps(event['data'])) |
||||||
@ -0,0 +1,3 @@ |
|||||||
|
from django.db import models |
||||||
|
|
||||||
|
# Create your models here. |
||||||
@ -0,0 +1,6 @@ |
|||||||
|
from django.urls import re_path |
||||||
|
from . import consumers |
||||||
|
|
||||||
|
websocket_urlpatterns = [ |
||||||
|
re_path(r'ws/course/(?P<course_id>\d+)/$', consumers.ArriveeConsumer.as_asgi()), |
||||||
|
] |
||||||
@ -0,0 +1,3 @@ |
|||||||
|
from django.test import TestCase |
||||||
|
|
||||||
|
# Create your tests here. |
||||||
Loading…
Reference in new issue