Aller au contenu

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_attendance peut 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) dans apps/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() dans apps/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" }
  • reason est obligatoire (400 sinon), tout comme le scan doit être granted et pas déjà annulé.
  • decision sur le log n'est jamais modifié — c'est un fait historique (ce qui a été décidé sur le moment). L'annulation vit dans cancelled_at/cancelled_by/cancel_reason.
  • Effet sur toutes les stats (summary, daily, sections, by-scanner, fiche étudiant) : un scan annulé sort du calcul de granted/rate sans repasser en denied — un nouveau champ cancelled apparaî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 que guard_name_snapshot.
  • Logs pré-existants (avant le 2026-09-09) : guard_role_snapshot est vide. Partout où c'est agrégé, le fallback est : 'admin' si guard_name_snapshot commence par "Admin: ", sinon 'agent' (aucun chaperon n'existait avant ce changement, donc ce fallback est fiable rétroactivement).
  • GET /attendance/stats/by-scanner est 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 avec credentials: 'include' (ou withCredentials: true)
  • une balise <img src=...> simple (les cookies suivent les règles normales du navigateur, indépendamment de tout mode credentials — ça ne concerne que fetch()/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.md dans le repo epidocs-api — changelog technique brut, payloads JSON complets, historique de toutes les PRs