Browse Source

Gestion multi caméra dans le scan

Ajout de fontawesome pour les icônes
Ajout d'un bouton de déconnexion sur la page principale
Ajout de la redirection après login dans settings.py
Ajout de la gestion des erreurs 404 et 500 avec des pages personnalisées
Ajout de DataTables pour la gestion des tableaux dans course_detail.html
master
scayac 3 months ago
parent
commit
b04157ff7a
  1. 24
      courses/templatetags/temps_format.py
  2. 5
      crossapp/settings.py
  3. 1
      requirements.txt
  4. 4
      scan/views.py
  5. 1
      static/bootstrap/dataTables.bootstrap4.min.css
  6. 4
      static/bootstrap/dataTables.bootstrap4.min.js
  7. 6
      static/jquery/datatables.fr.js
  8. 4
      static/jquery/jquery.dataTables.min.js
  9. BIN
      static/person-running-solid-full.png
  10. 8
      templates/404.html
  11. 8
      templates/500.html
  12. 5
      templates/base.html
  13. 59
      templates/course_detail.html
  14. 4
      templates/dossards.html
  15. 39
      templates/main.html
  16. 2
      templates/registration/login.html
  17. 87
      templates/scan.html

24
courses/templatetags/temps_format.py

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

5
crossapp/settings.py

@ -54,6 +54,7 @@ INSTALLED_APPS = [ @@ -54,6 +54,7 @@ INSTALLED_APPS = [
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'fontawesomefree',
'channels',
'courses',
'coureurs',
@ -144,7 +145,11 @@ USE_TZ = True @@ -144,7 +145,11 @@ USE_TZ = True
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 = '/'

1
requirements.txt

@ -1,6 +1,7 @@ @@ -1,6 +1,7 @@
Django>=5.2.6
python-dotenv
channels
fontawesomefree==6.6.0
reportlab
qrcode
pandas

4
scan/views.py

@ -6,6 +6,8 @@ from django.contrib.auth.decorators import login_required @@ -6,6 +6,8 @@ from django.contrib.auth.decorators import login_required
from courses.models import Course, Arrivee
from coureurs.models import Coureur
from django.utils import timezone
from courses.templatetags.temps_format import seconds_to_hms
from .forms import ScanForm
@login_required
@ -40,7 +42,7 @@ def scan_view(request): @@ -40,7 +42,7 @@ def scan_view(request):
'nom': coureur.nom,
'classe': coureur.classe,
'rang': rang,
'temps': str(temps)
'temps': str(seconds_to_hms(temps))
}
# Broadcast websocket
channel_layer = get_channel_layer()

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 @@ @@ -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});

6
static/jquery/datatables.fr.js

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

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

8
templates/404.html

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

8
templates/500.html

@ -0,0 +1,8 @@ @@ -0,0 +1,8 @@
{% extends "base.html" %}
{% block content %}
<div class="container mt-5 text-center">
<h1 class="display-4 text-danger">Erreur 500</h1>
<p class="lead">Une erreur interne est survenue. Veuillez réessayer plus tard.</p>
<a href="/" class="btn btn-primary">Retour à l'accueil</a>
</div>
{% endblock %}

5
templates/base.html

@ -5,8 +5,13 @@ @@ -5,8 +5,13 @@
<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>

59
templates/course_detail.html

@ -1,5 +1,7 @@ @@ -1,5 +1,7 @@
{% extends 'base.html' %}
{% block content %}
{% load static %}
{% load temps_format %}
<div class="container-fluid mt-4">
<h1 class="h3 mb-4 text-gray-800">Course : {{ course.nom }} ({{ course.date }})</h1>
<div class="row">
@ -12,9 +14,36 @@ @@ -12,9 +14,36 @@
<form method="post">
{% csrf_token %}
{% if not is_started %}
<button type="submit" name="start" class="btn btn-success">Départ</button>
<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="submit" name="finish" class="btn btn-danger">Fin course</button>
<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 %}
@ -25,8 +54,12 @@ @@ -25,8 +54,12 @@
<div class="card-header py-3 d-flex justify-content-between align-items-center">
<h6 class="m-0 font-weight-bold text-primary">Arrivées</h6>
<div>
<a href="{% url 'export_csv' course.id %}" class="btn btn-outline-primary btn-sm mr-2">Export CSV</a>
<a href="{% url 'export_pdf' course.id %}" class="btn btn-outline-danger btn-sm">Export PDF</a>
<a href="{% url 'export_csv' course.id %}" class="btn btn-success mb-2">
<i class="fas fa-file-csv" title="Exporter en CSV"></i>
</a>
<a href="{% url 'export_pdf' course.id %}" class="btn btn-danger mb-2">
<i class="fas fa-file-pdf" title="Exporter en PDF"></i>
</a>
</div>
</div>
<div class="card-body">
@ -45,7 +78,7 @@ @@ -45,7 +78,7 @@
<td>{{ a.rang }}</td>
<td>{{ a.coureur.nom }}</td>
<td>{{ a.coureur.classe }}</td>
<td>{{ a.temps }}</td>
<td>{% if a.temps %}{{ a.temps|seconds_to_hms }}{% endif %}</td>
</tr>
{% empty %}
<tr><td colspan="4">Aucun coureur arrivé.</td></tr>
@ -59,6 +92,11 @@ @@ -59,6 +92,11 @@
</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";
@ -72,7 +110,18 @@ socket.onmessage = function(e) { @@ -72,7 +110,18 @@ socket.onmessage = function(e) {
.then(html => {
const table = document.getElementById('arriveesTable').getElementsByTagName('tbody')[0];
table.innerHTML = html;
$('#arriveesTable').DataTable();
});
};
// Modal confirmation fin de course
document.getElementById('btnFinish').onclick = function() {
$('#finishModal').modal('show');
};
// Initialisation DataTables au chargement
$(document).ready(function() {
$('#arriveesTable').DataTable();
});
</script>
{% endblock %}

4
templates/dossards.html

@ -12,7 +12,9 @@ @@ -12,7 +12,9 @@
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form.as_p }}
<button type="submit" class="btn btn-success">Générer PDF</button>
<button type="submit" class="btn btn-success">Générer PDF
<i class="fas fa-file-pdf" title="Générer PDF"></i>
</button>
</form>
{% if error %}
<div class="alert alert-danger mt-3">{{ error }}</div>

39
templates/main.html

@ -2,9 +2,9 @@ @@ -2,9 +2,9 @@
{% block content %}
<div class="container-fluid mt-4">
<h1 class="h3 mb-4 text-gray-800">Bienvenue sur CrossApp</h1>
<div class="row align-items-stretch">
<div class="col-lg-6 mb-4 d-flex">
<div class="card shadow mb-4 h-100 w-100">
<div class="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>
@ -14,8 +14,12 @@ @@ -14,8 +14,12 @@
<li class="list-group-item d-flex justify-content-between align-items-center">
{{ course.nom }} ({{ course.date }})
<div>
<a href="{% url 'course_detail' course.id %}" class="btn btn-primary btn-sm mr-2">Voir</a>
<a href="{% url 'scan' %}?course_id={{ course.id }}" class="btn btn-info btn-sm">Scan</a>
<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 %}
@ -26,15 +30,28 @@ @@ -26,15 +30,28 @@
</div>
</div>
</div>
<div class="row align-items-stretch">
<div class="col-lg-6 mb-4 d-flex">
<div class="card shadow mb-4 h-100 w-100">
<div class="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">Actions</h6>
</div>
<div class="card-body">
<button class="btn btn-success mb-2" id="btnNewCourse">Créer une nouvelle course</button>
<a href="{% url 'dossards' %}" class="btn btn-warning mb-2 ml-2">Générer les dossards</a>
<div class="card-body d-flex justify-content-center gap-3">
<button class="btn btn-success mb-2" id="btnNewCourse">
<i class="fas fa-plus"></i>
&nbsp;Créer une nouvelle course
</button>
<a href="{% url 'dossards' %}" class="btn btn-warning mb-2 ml-2">
<i class="fas fa-plus" title="Générer les dossards"></i>
&nbsp;Générer les dossards
</a>
<form action="{% url 'logout' %}" method="post" style="display:inline;">
{% csrf_token %}
<input type="hidden" name="next" value="{% url 'login' %}">
<button type="submit" class="btn btn-danger mb-2 ml-2">
<i class="fas fa-sign-out-alt"></i> Déconnexion
</button>
</form>
</div>
</div>
<div id="newCourseModal" class="modal" tabindex="-1" role="dialog" style="display:none;">

2
templates/registration/login.html

@ -5,7 +5,7 @@ @@ -5,7 +5,7 @@
<div class="col-md-6">
<div class="card shadow">
<div class="card-header bg-primary text-white">
<h4 class="mb-0">Connexion</h4>
<h4 class="mb-0">Connexion à CrossApp</h4>
</div>
<div class="card-body">
<form method="post">

87
templates/scan.html

@ -4,7 +4,7 @@ @@ -4,7 +4,7 @@
<div class="container-fluid mt-4">
<h1 class="h3 mb-4 text-gray-800">Mode Scan : {% if course %}{{ course.nom }} ({{ course.date }}){% endif %}</h1>
<div class="row">
<div class="col-lg-8 mx-auto">
<div class="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>
@ -21,9 +21,33 @@ @@ -21,9 +21,33 @@
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">Actions</h6>
</div>
<div class="card-body d-flex justify-content-between">
<a href="/" class="btn btn-secondary">Menu principal</a>
<button id="toggleBeep" type="button" class="btn btn-info">Désactiver bip scan</button>
<div 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>
@ -44,7 +68,7 @@ function beep() { @@ -44,7 +68,7 @@ function beep() {
}
let lastScanned = '';
let html5QrCode;
let html5Qrcode;
function getCookie(name) {
var cookieValue = null;
@ -62,19 +86,49 @@ function getCookie(name) { @@ -62,19 +86,49 @@ function getCookie(name) {
return cookieValue;
}
let camerasAvailable = [];
let currentCameraId = null;
document.addEventListener('DOMContentLoaded', function() {
html5QrCode = new Html5Qrcode("reader");
html5Qrcode = new Html5Qrcode("reader");
Html5Qrcode.getCameras().then(cameras => {
if (cameras && cameras.length) {
html5QrCode.start(
cameras[0].id,
camerasAvailable = cameras || [];
if (camerasAvailable.length) {
currentCameraId = camerasAvailable[0].id;
html5Qrcode.start(
currentCameraId,
{ fps: 10, qrbox: 250 },
onScanSuccess
);
}
});
// Plus aucune référence à l’ancien form ou input qrcode
// 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);
@ -111,10 +165,19 @@ function onScanSuccess(decodedText, decodedResult) { @@ -111,10 +165,19 @@ function onScanSuccess(decodedText, decodedResult) {
}
document.addEventListener('DOMContentLoaded', function() {
const beepBtn = document.getElementById('toggleBeep');
const beepIcon = document.getElementById('beepIcon');
let beepEnabled = true;
beepBtn.onclick = function() {
beepEnabled = !beepEnabled;
beepBtn.textContent = beepEnabled ? 'Désactiver bip scan' : 'Activer bip scan';
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;
@ -130,7 +193,7 @@ document.addEventListener('DOMContentLoaded', function() { @@ -130,7 +193,7 @@ document.addEventListener('DOMContentLoaded', function() {
Html5Qrcode.getCameras().then(cameras => {
if (cameras && cameras.length) {
html5QrCode.start(
html5Qrcode.start(
cameras[0].id,
{ fps: 10, qrbox: 250 },
onScanSuccess

Loading…
Cancel
Save