EPIDOCS API — Présence Campus (Attendance)¶
Pourquoi cette page existe
Ce sous-système a été construit sur plusieurs sessions (fin août → septembre 2026) et n'est pas dans overview.md pour ne pas alourdir la page générale. Le changelog technique brut (payloads exacts, exemples JSON complets) vit dans API_CHANGES_ATTENDANCE.md à la racine du repo epidocs-api — cette page-ci est la version synthétisée et à jour, pensée pour comprendre le système sans relire tout l'historique des PRs.
Le besoin¶
Ernesto (agent de sécurité au portail EPITECH Bénin) doit scanner un QR code à l'entrée du campus et voir instantanément s'il doit autoriser ou refuser l'accès (avec motif si refus). Le système distingue trois catégories de personnes qui scannent, avec un besoin de suivi séparé pour chacune :
- Agents de sécurité (
role=agent, défaut) — présence de jour au portail - Chaperons (
role=chaperon) — surveillance de nuit dans les dortoirs, ajoutés en septembre 2026 - Admins — un admin avec le droit
can_scan_attendancepeut aussi scanner depuis le dashboard normal, sans compte agent dédié
Authentification — deux mécanismes séparés, volontairement¶
| Population | Mécanisme | Détails |
|---|---|---|
| Agent/chaperon | PIN → cookie attendance_session |
AttendanceGuardAuthentication, JWT signé avec session_id d'un AttendanceSession, 12h de validité |
| Admin | Session normale → cookie auth_token |
JWTSessionAuthentication, la même que pour tout le reste du dashboard |
Pourquoi séparé : un agent de sécurité n'a ni email ni droits, et le choix a été de ne jamais risquer de régression sur le chemin d'auth JWT admin (critique en prod) pour cette fonctionnalité annexe. POST /attendance/scan et PATCH /attendance/scan/<id>/note et /cancel acceptent les deux cookies (voir CanScanOrIsGuardPermission dans libs/permissions.py) ; tout le reste de l'espace admin présence n'accepte que auth_token.
Droits (rights)¶
| Droit | Couvre |
|---|---|
can_scan_attendance |
Scanner depuis le dashboard admin (sans compte agent) |
can_manage_attendance |
Gérer les agents/chaperons, motifs de suspension, consulter logs/stats, envoyer des QR, annuler un scan |
Les deux sont indépendants — un admin peut avoir l'un sans l'autre (vérifié par lecture directe du code, pas par supposition). is_superuser bypass les deux via HasRightPermission.
Modèle de données¶
SecurityGuard
├── name
├── role ('agent' par défaut, ou 'chaperon')
├── campus → FK(Campus, nullable)
├── pin_hash (sha256(pin + SECRET_KEY) — le PIN n'est pas le contrôle d'accès
│ principal, le vrai contrôle reste humain à la porte)
├── is_active
└── revoked_at
AttendanceSession
├── session_id (UUID)
├── guard → FK(SecurityGuard)
├── expires_at (12h après création)
└── is_valid
AttendanceLog (auto-suffisant, tout est dénormalisé en snapshot)
├── student → FK(Student, SET_NULL)
├── raw_payload (le QR décodé brut, "signature:login")
├── decision ('granted' / 'denied')
├── denial_reason
├── guard → FK(SecurityGuard, SET_NULL)
├── guard_name_snapshot (nom figé au moment du scan, ou "Admin: email@...")
├── guard_role_snapshot ('agent' / 'chaperon' / 'admin', figé — ajouté 2026-09-09)
├── guard_note (note libre ajoutée après coup par l'agent/admin)
├── student_nom_snapshot / student_prenom_snapshot / student_section_snapshot
├── cancelled_at / cancelled_by / cancel_reason (ajoutés 2026-09-09, voir plus bas)
└── scanned_at
SuspensionReason
├── label
└── created_at
Pourquoi tout est dénormalisé (snapshots) : AttendanceLog doit rester exploitable même si l'étudiant est supprimé, si l'agent change de rôle, ou si son compte est supprimé — l'historique de présence ne doit jamais se corrompre rétroactivement.
Le QR code — génération et vérification¶
- Payload signé :
build_qr_payload(login)/verify_qr_payload(payload)dansapps/attendance/qr.py. Format :<signature_hmac>:<login>. Secret(s) :settings.ATTENDANCE_QR_SECRETS(liste — le premier sert à signer les nouveaux QR, tous servent à vérifier, pour permettre une rotation de secret sans invalider les QR déjà distribués). - Génération du badge visuel (QR habillé avec identité + logo) :
generate_badge_image()/_render_qr()dansapps/attendance/badge.py.
Incident de perf résolu — 2026-09-09 — ne pas régresser sur box_size
_render_qr() générait le QR à box_size=50. La librairie qrcode applique son masque de couleur pixel par pixel en pure Python (image.getpixel/putpixel en boucle), même pour un simple remplissage solide (SolidFillColorMask) — à box_size=50 ça donnait une image ~2250×2250px et ~14 secondes de rendu pour un seul badge. Avec seulement 3 workers Gunicorn (--timeout 120), quelques requêtes concurrentes sur ce chemin (aperçu QR, envoi email) suffisaient à épuiser tous les workers (WORKER TIMEOUT → SIGKILL), et nginx retombait sur sa page 404 par défaut avant même que Django ait fini — symptôme observé : "ça tourne ~1 min puis 404", aucun rapport avec le routing ou l'étudiant.
Fix : box_size=20 (image source ~900×900, encore largement suffisante avant le redimensionnement LANCZOS final vers la taille d'affichage du badge). Mesuré : rendu QR ~14s → ~2s, badge complet ~16,6s → ~3,5s.
Si ce symptôme revient (une requête qui tourne longtemps puis 404, avec des WORKER TIMEOUT dans les logs Gunicorn) : c'est très probablement la même classe de bug ailleurs dans le code qui touche à la génération d'image — chercher un rendu pixel-par-pixel en pure Python sur une image trop grande, pas un problème réseau/routing.
# Reproduire/mesurer localement pour un login donné
docker compose exec web python manage.py shell -c "
import time
from apps.student.models import Student
from apps.attendance.badge import generate_and_store_badge
student = Student.objects.get(login='LOGIN_A_TESTER')
t0 = time.time()
generate_and_store_badge(student, 'timing-test.png')
print('duration:', time.time() - t0)
"
Routes API¶
Écran PIN + scanner¶
| Méthode | Route | Auth | Description |
|---|---|---|---|
| POST | /attendance/auth |
— | Connexion agent/chaperon par PIN → cookie attendance_session |
| GET | /attendance/me |
guard | Vérif proactive de session (pas d'effet de bord) |
| POST | /attendance/logout |
guard | Déconnexion |
| POST | /attendance/scan |
guard OU admin | Scanner un QR — voir forme de réponse ci-dessous |
| PATCH | /attendance/scan/<id>/note |
guard OU admin | Ajouter une note libre à un scan |
| PATCH | /attendance/scan/<id>/cancel |
guard OU admin | Nouveau — annuler un scan granted par erreur (voir plus bas) |
Administration¶
| Méthode | Route | Droit | Description |
|---|---|---|---|
| GET/POST | /attendance/guards?role=agent\|chaperon |
can_manage_attendance |
Lister/créer un agent ou chaperon |
| PATCH | /attendance/guards/<id>/revoke |
idem | Révoquer (réversible dans les faits — juste is_active=False) |
| POST | /attendance/guards/<id>/reset-pin |
idem | Régénérer le PIN |
| DELETE | /attendance/guards/<id> |
idem | Suppression définitive (les logs déjà créés restent, via snapshot) |
| GET/POST | /attendance/reasons |
idem | Motifs de suspension (utilisés dans les refus) |
| GET | /attendance/logs |
idem | Historique brut, paginé — filtres ci-dessous |
| GET | /attendance/stats/summary |
idem | Vue d'ensemble (bornes date_from/date_to optionnelles) |
| GET | /attendance/stats/daily |
idem | Une entrée par jour (bornes obligatoires, max 366 jours) |
| GET | /attendance/stats/reasons |
idem | Motifs de refus par fréquence |
| GET | /attendance/stats/sections |
idem | Taux de présence par section |
| GET | /attendance/stats/by-scanner |
idem | Nouveau — agrégat par personne ayant scanné (agent/chaperon/admin) |
| GET | /attendance/students/search?q= |
idem | Autocomplete étudiant (login/nom/prénom) |
| GET | /attendance/students/<login>/summary?days=14 |
idem | Fiche présence d'un étudiant (fenêtre glissante) |
| GET | /attendance/qrcodes/audience |
idem | Aperçu (lecture seule) de qui recevrait un envoi de QR en masse |
| POST | /attendance/qrcodes/send |
idem | Envoi de QR en masse (async, 202 Accepted) |
| GET | /student/<login>/qrcode |
idem | Aperçu QR d'un étudiant (génère à la volée) |
| POST | /student/admin/<login>/qrcode/send |
idem | Nouveau — renvoyer le QR d'UN étudiant, éventuellement vers une adresse email personnelle |
Filtres sur GET /attendance/logs : student_login, decision (granted/denied), date_from/date_to, guard_id (une personne précise), guard_role (agent/chaperon/admin), cancelled (true/false), ordering (scanned_at/student_login, préfixe - pour décroissant).
Forme de réponse POST /attendance/scan / .../note / .../cancel¶
{
"logId": 42,
"decision": "granted",
"student": { "nom": "DOE", "prenom": "John", "section": "PGE3", "photoUrl": "/attendance/media/photos/..." },
"reason": null,
"guardNote": "",
"cancelledAt": null,
"cancelReason": null
}
L'agent DOIT vérifier la photo, même sur un scan vert
decision: "granted" signifie seulement « ce QR est authentique et l'étudiant n'est pas suspendu » — pas que la personne qui le présente est bien celle sur la photo. Rien n'empêche un étudiant de présenter le vrai code d'un autre (capture d'écran, téléphone prêté). C'est le rôle de photoUrl/nom/prenom dans la réponse : l'agent doit comparer visuellement. Voir la fonctionnalité d'annulation ci-dessous pour le cas où cette vérification arrive trop tard.
Annuler un scan accepté par erreur (2026-09-09)¶
Cas d'usage : un étudiant présente le badge d'un autre (vol, prêt, capture d'écran). L'agent/admin laisse passer, puis se rend compte de l'erreur — trop tard pour refuser sur le moment, mais il faut pouvoir signaler et invalider l'accès accordé.
PATCH /attendance/scan/<logId>/cancel
Body: { "reason": "Badge présenté par un autre étudiant que celui sur la photo" }
reasonest obligatoire (400 sinon), tout comme le scan doit êtregrantedet pas déjà annulé.decisionsur le log n'est jamais modifié — c'est un fait historique (ce qui a été décidé sur le moment). L'annulation vit danscancelled_at/cancelled_by/cancel_reason.- Effet sur toutes les stats (
summary,daily,sections,by-scanner, fiche étudiant) : un scan annulé sort du calcul degranted/ratesans repasser endenied— un nouveau champcancelledapparaît en parallèle pour ne pas le perdre silencieusement des chiffres. - Tracé dans l'audit log (
action: "attendance_scan_cancelled").
Ce qui est dans l'audit log admin — et ce qui n'y est jamais¶
Question revenue plusieurs fois côté frontend. GET /admin/audit-logs :
| Dans l'audit log | Jamais dans l'audit log |
|---|---|
| Création/révocation/suppression/reset PIN d'un agent | Les scans eux-mêmes (POST /attendance/scan) — volontaire, trop haut volume pour cette table générique. Ils vivent dans GET /attendance/logs, leur historique dédié |
Envoi de QR (masse et individuel — attendance_qr_sent/attendance_qr_send_failed) |
Ajout d'une note libre (.../note) |
Annulation d'un scan (attendance_scan_cancelled) |
attendance_qr_sent = envoi déclenché, PAS livraison confirmée
Que ce soit l'envoi en masse ou individuel, l'email part dans un thread daemon (send_mail_async) qui ne remonte jamais son résultat à l'appelant. attendance_qr_sent dans l'audit log confirme seulement que la génération du badge a réussi et que l'envoi a été lancé — un échec Resend (adresse invalide, API down) part dans les logs serveur, pas dans l'audit log, ni pour l'un ni pour l'autre chemin. Ne jamais afficher ça côté UI comme "email livré".
Chaperons — suivi par personne (2026-09-09)¶
Avant cet ajout, AttendanceLog ne distinguait que "scan agent" vs "scan admin" (via le préfixe "Admin: " dans guard_name_snapshot). Le besoin : suivre/agréger/chercher l'activité d'un chaperon (surveillance de nuit dans les dortoirs) séparément des agents de jour.
SecurityGuard.role:'agent'(défaut) ou'chaperon'— même mécanisme PIN pour les deux.AttendanceLog.guard_role_snapshot: figé au scan, même logique queguard_name_snapshot.- Logs pré-existants (avant le 2026-09-09) :
guard_role_snapshotest vide. Partout où c'est agrégé, le fallback est :'admin'siguard_name_snapshotcommence par"Admin: ", sinon'agent'(aucun chaperon n'existait avant ce changement, donc ce fallback est fiable rétroactivement). GET /attendance/stats/by-scannerest le point d'entrée pour "qui a scanné, combien de fois, avec quel taux d'acceptation" — une ligne par(guard_id, name, role).
Pièges connus (cookies cross-origin)¶
Le frontend (mydocs.epitools.bj) et l'API (api.mydocs.epitools.bj) sont des sous-domaines différents — même eTLD+1 (epitools.bj), donc le cookie auth_token (SameSite=None; Secure; Domain=.epitools.bj) part correctement pour :
- une navigation classique / un appel
fetch()/axios aveccredentials: 'include'(ouwithCredentials: true) - une balise
<img src=...>simple (les cookies suivent les règles normales du navigateur, indépendamment de tout modecredentials— ça ne concerne quefetch()/XHR)
Mais pas pour un fetch() cross-origin sans credentials: 'include' explicite (défaut du navigateur : 'same-origin', qui exclut un sous-domaine différent). Incident réel (2026-09-09) : le bouton "Télécharger" du QR faisait fetch(qrCodeUrl) nu → 401, alors que l'aperçu (<img src={qrCodeUrl}>) fonctionnait très bien avec la même session. Fix côté frontend : router l'appel via leur client axios partagé (withCredentials: true).
À vérifier en premier si un endpoint /attendance/media/... ou /student/.../qrcode renvoie 401 malgré une session admin valide : comment le frontend consomme l'URL renvoyée, pas le backend.
Voir aussi¶
- Flux — Présence — le même sujet, version guide pratique sans jargon technique (à donner à quelqu'un qui ne lira jamais le code)
- EPIDOCS Frontend — Présence Campus — architecture frontend (les 2 services, la console de scan partagée, le bug de credentials cross-origin)
- Opérations manuelles — envoyer le QR d'un étudiant précis, créer un chaperon, etc.
- Erreurs courantes — entrées détaillées sur l'incident de timeout et le 401 cross-origin
API_CHANGES_ATTENDANCE.mddans le repoepidocs-api— changelog technique brut, payloads JSON complets, historique de toutes les PRs