Aller au contenu

EPIDOCS API — Backend

Description

EPIDOCS est une plateforme de gestion documentaire pour EPITECH Bénin. Elle permet aux étudiants de générer leurs documents administratifs (certificats de scolarité, attestations, lettres de recommandation) et aux administrateurs de gérer les étudiants, les templates, les migrations de promotions et la génération en lot.

Fonctionnalités principales

  • Authentification Azure AD : SSO Microsoft pour étudiants et admins, sessions JWT en cookie HttpOnly
  • Génération de documents bilingues : templates Word (.docx) en FR et EN remplis dynamiquement, convertis en PDF avec QR code de vérification. Chaque document partage un seul tracking code pour ses deux versions.
  • Support multi-campus : chaque campus peut avoir ses propres templates de documents (surcharge CampusDocumentType). Si un campus est associé à l'étudiant, ses templates priment sur les templates globaux.
  • Génération en lot (batch) : génération de documents pour toute une promotion, export en ZIP
  • Gestion des étudiants : import CSV, migration de promotions (PGE1→PGE2, etc.), suspension de comptes
  • Demandes de modification : les étudiants peuvent demander la correction de leurs informations personnelles
  • Réclamations : système de tickets avec pièces jointes
  • Mode maintenance : middleware qui bloque l'accès avec maintenance planifiée
  • Annonces : système d'annonces ciblées par classe ou global
  • Notes d'information : upload de PDFs (convocations, plannings) ciblés par classe, téléchargeables par les étudiants
  • Audit log : traçabilité de toutes les actions admin
  • Présence Campus (Attendance) : chaque étudiant reçoit par e-mail un badge QR signé ; un agent de sécurité/chaperon (PIN, sans session admin/étudiant) ou un admin le scanne à l'entrée du campus pour autoriser/refuser l'accès. Historique et statistiques de présence, envoi de badges par email. Mécanique de signature/rotation et détails de rendu du badge dans apps/attendance/ plus bas sur cette page ; routes, modèle de données complet et pièges connus sur sa propre page — voir Présence Campus.
  • Vérifications d'intégrité des données : registre extensible de checks admin (/admin/data-checks) pour détecter et corriger des anomalies de données sans script one-off — voir Opérations manuelles
  • Intégration service-à-service (Zeno) : API dédiée permettant à un service externe (Zeno) de lister, consulter, générer et télécharger les documents des étudiants via une clé API (sans session navigateur). Comptes de service et clés gérés depuis le dashboard. Doc OpenAPI live (ReDoc/Swagger).
  • Droits fins par admin : au-delà du simple statut admin/superadmin, 24 droits (Right) accordables individuellement bornent l'accès aux actions sensibles (suspension, migration, gestion des templates, présence, etc.).

Architecture

Stack technique

Composant Technologie
Framework Django 5 + Django REST Framework
Base de données PostgreSQL 16
Authentification JWT (cookie HttpOnly) + Azure AD (MSAL) ; clé API pour les services externes
Génération de documents docxtpl + LibreOffice (conversion PDF)
QR Codes qrcode + Pillow
Stockage fichiers Local (media/) ou Cloudinary (remote)
Conteneur Docker (python:3.12 + LibreOffice)
WSGI Gunicorn (3 workers, timeout 120s)
API format CamelCase JSON (djangorestframework-camel-case)

Routes API

Authentification (/auth/, /student/auth/)

Méthode Route Description
GET /auth/sign_in Initie le flow Azure AD
GET /auth/callback Callback OAuth Azure AD
GET /auth/me Infos de l'admin connecté
POST /auth/sign_out Déconnexion
GET /student/auth/me Infos de l'étudiant connecté

Documents (/documents/)

Méthode Route Description
GET /documents/available-document-types Documents disponibles pour une classe
POST /documents/generate-document Générer un document (étudiant) — retourne le PDF FR
GET /documents/download-en/:tracking_code Télécharger la version EN d'un document existant
GET /documents/history Historique des documents générés
GET /documents/tracking/:doc_ref Vérification d'un document via QR code
POST /documents/admin/generate-for-student Générer un doc pour un étudiant (admin)
POST /documents/admin/batch-generate Génération en lot pour une classe (admin)
GET /documents/admin/dashboard/stats/ Statistiques du dashboard
GET /documents/admin/student-history/:email/ Historique d'un étudiant (admin)
DELETE /documents/admin/delete-document/:tracking_code/ Supprimer un document
GET/POST /documents/admin/info-notes Lister / créer des notes d'information
GET/PUT/DELETE /documents/admin/info-notes/:id Détail / modifier / supprimer une note
PATCH /documents/admin/info-notes/:id/toggle Activer / désactiver une note
GET /documents/info-notes/:id/download Télécharger le PDF d'une note (étudiant)

Étudiants (/student/)

Méthode Route Description
POST /student/upload-csv/ Import d'étudiants via CSV
POST /student/upload-tepitech-csv/ Import des scores TEPITECH
POST /student/migrate-students/ Migration de promotion
GET /student/admin/list/ Liste paginée des étudiants (filtres: section, annee, search, login)
POST /student/admin/set-graduation-status/ Marquer des étudiants PGE3/PGE5 comme diplômés en masse
POST /student/pending-students Ajouter un étudiant en attente
POST /student/validate-students Valider les étudiants en attente
POST /student/info-change-request/ Demande de modification (étudiant)
GET /student/admin/info-change-requests/ Liste des demandes (admin)

Administration (/admin/)

Méthode Route Description
GET/PUT /admin/system-status État du système / mode maintenance
GET/POST /admin/announcements Gestion des annonces
GET /admin/audit-logs Journal d'audit
GET /admin/analytics Tableau de bord analytique
POST /admin/backup/export Export de la base de données
POST /admin/backup/import Import de la base de données

Réclamations (/complaints/)

Méthode Route Description
GET/POST /complaints/student Réclamations de l'étudiant
GET/POST /complaints/admin Gestion des réclamations (admin)

Intégration Zeno — service-à-service (/integrations/v1/)

Authentification par clé API : header Authorization: Api-Key <clé> (pas de cookie). Réponses JSON camelCase, rate limit 1000 req/h par clé, accès global (tous étudiants / tous campus).

Scopes (permissions par clé) : chaque compte de service porte des scopes qui bornent ses clés :

Scope Autorise
read Lister, détail, available-documents, télécharger
generate Générer un document (POST)

Une clé lecture seule a ["read"] (la génération renvoie 403) ; une clé complète a ["read", "generate"]. Défaut à la création : accès complet. Un appel hors scope renvoie 403.

Méthode Route Description
POST /integrations/v1/documents/generate Générer (ou réutiliser) un doc — body { studentLogin, docId }, renvoie trackingCode + URLs de téléchargement
GET /integrations/v1/students/:login/documents Lister les documents générés d'un étudiant (filtre ?section=)
GET /integrations/v1/students/:login/available-documents Types de documents générables selon la classe de l'étudiant
GET /integrations/v1/documents/:tracking_code Métadonnées + URLs de téléchargement fraîches
GET /integrations/v1/documents/:tracking_code/download Télécharger le PDF (?lang=fr\|en, storage-agnostic)

Documentation live (publique, sans clé) :

Format URL
ReDoc /integrations/v1/docs
Swagger UI /integrations/v1/swagger
Schéma OpenAPI /integrations/v1/schema.json

Administration des comptes & clés (session admin JWT, super-admin uniquement) :

Méthode Route Description
GET/POST /integrations/v1/admin/service-accounts Lister / créer un compte de service (la création renvoie la clé une seule fois)
GET/PATCH/DELETE /integrations/v1/admin/service-accounts/:id Détail / modifier (isActive, name…) / supprimer
POST /integrations/v1/admin/service-accounts/:id/keys Émettre une nouvelle clé
POST /integrations/v1/admin/keys/:id/revoke Révoquer une clé
POST /integrations/v1/admin/keys/:id/rotate Révoquer la clé et en émettre une nouvelle

Clé en clair affichée une seule fois : seules les opérations de création / émission / rotation renvoient le champ apiKey. Le backend ne stocke que le hash SHA-256. La création en CLI est possible : python manage.py create_service_account zeno.

Présence campus (/attendance/)

Version détaillée

Cette table est le résumé rapide. Modèle de données complet (rôle chaperon, annulation de scan), pièges cross-origin et incident de perf sur la génération de badge : Présence Campus (Attendance).

Authentification par cookie de session dédié (attendance_session, JWT, 12h) pour un agent/chaperon, ou cookie auth_token admin avec le droit can_scan_attendance pour scan/.../note/.../cancel — totalement séparé de l'auth admin/étudiant classique pour le reste. Chaque route existe avec et sans slash final (APPEND_SLASH=False au niveau du projet, voir troubleshooting).

Méthode Route Auth Description
POST /attendance/auth aucune Connexion agent/chaperon par PIN → pose le cookie attendance_session
GET /attendance/me cookie agent Vérification proactive de la session (sans effet de bord)
POST /attendance/logout cookie agent Invalide la session courante
POST /attendance/scan cookie agent OU admin Scanne un QR ({ "payload": "<signature:login>" }) → granted/denied + fiche étudiant
PATCH /attendance/scan/:log_id/note cookie agent OU admin Ajoute/édite une remarque libre sur un scan déjà effectué
PATCH /attendance/scan/:log_id/cancel cookie agent OU admin Nouveau (2026-09-09) — annule un scan granted par erreur ({ "reason": "..." } obligatoire)
GET/POST /attendance/reasons admin + can_manage_attendance Lister / créer un motif de suspension
GET/POST /attendance/guards?role= admin + can_manage_attendance Lister / créer un agent ou chaperon (PIN affiché une seule fois)
PATCH /attendance/guards/:id/revoke admin + can_manage_attendance Désactive un agent (réversible)
POST /attendance/guards/:id/reset-pin admin + can_manage_attendance Régénère le PIN, invalide les sessions en cours
DELETE /attendance/guards/:id admin + can_manage_attendance Suppression définitive (n'efface pas l'historique des scans)
GET /attendance/logs admin + can_manage_attendance Historique paginé (filtres student_login, decision, guard_id, guard_role, cancelled, ordering)
GET /attendance/stats/by-scanner admin + can_manage_attendance Nouveau — activité agrégée par personne ayant scanné (agent/chaperon/admin)
POST /attendance/qrcodes/audience admin + can_manage_attendance Aperçu en lecture seule de qui recevrait un envoi de badges
POST /attendance/qrcodes/send admin + can_manage_attendance Envoi en masse des badges (asynchrone, 202 Accepted)
GET /attendance/media/photos/:file_name cookie agent/admin/étudiant Sert une photo étudiant
GET /attendance/media/qrcodes/:file_name admin Sert un badge/QR généré

Endpoints connexes sur apps/student/ et apps/user/ :

Méthode Route Description
GET/POST /student/:login/photo Consulter / mettre à jour la photo d'un étudiant (admin, étudiant sur sa propre photo, ou agent)
POST /student/photos/bulk-upload/ Import en masse des photos via ZIP ({login}.jpg)
GET /student/:login/qrcode Génère/prévisualise le badge d'un étudiant (admin, avant envoi)
POST /student/admin/:login/qrcode/send Nouveau — renvoie le badge d'UN étudiant, éventuellement vers une adresse email personnelle
PUT /students/suspend Suspendre/réactiver — exige désormais reason_id ou reason_label à la suspension

Modèle de données

Student
├── login (PK, email EPITECH)
├── civilite, nom, prenom
├── date_naissance, ville_naissance, nationalite
├── section (PGE1, PGE2, PGE3, etc.)
├── promo, annee, ville_etude
├── inscription_year, inscription_month
├── nom_pere, prenom_pere, nom_mere, prenom_mere
├── specialite
├── PGE3_status, PGE5_status, Codingacademy_status (success/null)
├── campus → FK(Campus, nullable)
├── account_suspended (0/1)
├── suspension_reason → FK(SuspensionReason, nullable) — motif figé au moment de la suspension
└── photo (ImageField, upload_to=student_photos/, nullable) — badge de présence

SuspensionReason
├── label (unique, ex: "Renvoi pour raisons de scolarité")
└── created_at

User (Admin)
├── email (PK), first_name, last_name
├── is_staff, is_superuser
├── rights → ManyToMany(Right, via UserRight)
└── azure_id

Right
└── name (unique, ex: can_manage_attendance — voir § Système de permissions)

UserRight
├── user → FK(User)
├── right → FK(Right)
└── granted_at
    (unique_together: user + right)

Campus
├── name (ex: "Bénin")
├── code (ex: "BJ")
├── director_title (titre du directeur en FR)
├── director_title_en (titre du directeur en EN)
└── extra_fields (JSON — champs supplémentaires par campus)

CampusDocumentType
├── campus → FK(Campus)
├── document_type → FK(DocumentType)
├── modele (template FR spécifique au campus, optionnel)
└── modele_en (template EN spécifique au campus, optionnel)

ClassDocumentType  (override par classe — même principe que CampusDocumentType, axe différent)
├── classe (PGE1, PGE2, ... — clé de settings.CLASSES)
├── document_type → FK(DocumentType, related_name=class_overrides)
├── modele (template FR spécifique à la classe, optionnel)
└── modele_en (template EN spécifique à la classe, optionnel)

DocumentType
├── doc_id (certificat_scolarite, attestation_passage_pge, etc.)
├── modele (fichier .docx template FR)
├── modele_en (fichier .docx template EN, optionnel)
├── is_active
└── created_by → FK(User)

TrackingCode
├── tracking_code (PK, UUID)
├── doc_ref (REF/CS/abc/def)
├── generated_by → FK(Student)
└── generated_by_admin → FK(User)

GenDocumentHistory
├── tracking_code → OneToOne(TrackingCode)
├── doc_id, current_classe
├── generation_count
├── remote_url (chemin local ou Cloudinary public_id — version FR)
├── remote_url_en (chemin local ou Cloudinary public_id — version EN, nullable)
├── generated_by → FK(Student)
└── generated_by_admin → FK(User)

UserSession
├── session_id (UUID)
├── user → FK(User) ou student → FK(Student)
├── role (admin/student)
├── is_valid, expires_at
└── ip_address, user_agent

AuditLog
├── action (login, document_generated, document_downloaded, batch_generate_by_admin,
│           info_note_created/deleted, service_account_created/updated/deleted,
│           service_key_issued/revoked/rotated, etc.)
├── actor_email, actor_role (admin/superadmin/student/service)
├── target_type, target_id
├── details (JSON)
└── ip_address, timestamp

ServiceAccount  (intégration Zeno)
├── name (unique, ex: "zeno")
├── description
├── is_active
└── created_at

ServiceApiKey
├── service → FK(ServiceAccount)
├── prefix (8 premiers caractères, pour identification)
├── key_hash (SHA-256 — la clé brute n'est jamais stockée)
├── is_active, revoked_at
├── last_used_at
└── created_at

SystemStatus
├── is_maintenance (bool)
├── maintenance_message
├── scheduled_start, scheduled_end
└── updated_by → FK(User)

Announcement
├── title, message
├── target (all/students/admins)
├── target_class (PGE1, PGE2, etc.)
├── priority (info/warning/danger)
├── is_active, start_date, end_date
└── created_by → FK(User)

InfoNote
├── title, description
├── pdf_file (upload PDF)
├── target_classes (JSONField, ex: ["PGE1", "PGE2"])
├── is_active
├── created_by → FK(User)
├── created_at, updated_at
└── downloads → InfoNoteDownload[]

InfoNoteDownload
├── info_note → FK(InfoNote)
├── student → FK(Student)
└── downloaded_at

PendingStudent   (inscription via le formulaire public, avant validation admin)
├── login, nom, prenom, ...  (mêmes champs que Student, PAS de champ campus —
│                              voir troubleshooting.md, incident du 2026-09-07)
└── status (pending/rejected)

Right / UserRight   (droits fins, indépendants d'is_staff/is_superuser)
├── Right.name (ex: can_manage_attendance, can_edit_student_info, ...)
└── UserRight (M2M User↔Right, + granted_at)

SecurityGuard / AttendanceSession / AttendanceLog / SuspensionReason
└── modèle complet (avec le rôle chaperon et l'annulation de scan ajoutés le
    2026-09-09) documenté sur la page dédiée : Présence Campus (Attendance)

Système de permissions

Modèle Right (nom unique) + UserRight (M2M User↔Right, table de jointure explicite avec granted_at). Vérifié par HasRightPermission (libs/permissions.py) : un superadmin (is_superuser) passe toujours, un admin (is_staff) doit avoir le droit nommé dans request.user.rights, tout autre type d'utilisateur (étudiant, agent de sécurité PIN) est rejeté proprement. Chaque droit a sa propre classe de permission dédiée dans libs/permissions.py (ex. CanManageAttendancePermission, CanEditStudentInfoPermission) — jamais de vérification de droit inline dans une vue.

24 droits existent aujourd'hui (liste faisant autorité : apps/user/management/commands/seed_rights.py). Seuls 4 sont accordés automatiquement aux admins non-superadmin (base_rights) ; les 20 autres sont à accorder explicitement, y compris pour des comptes admin déjà existants :

Droit Description Auto-accordé (base_rights)
can_upload_tepitech Import des scores TEPITECH oui
can_upload_user Import CSV des étudiants oui
can_generate Générer des documents oui
can_generate_for_student Générer pour un étudiant oui
can_migrate_students Migrer les promotions non
can_batch_generate Génération en lot non
manage_campus Gérer campus/overrides campus non
can_manage_attendance Gérer la présence campus (agents, motifs, logs, envoi QR) non
can_scan_attendance Scanner un QR depuis le dashboard admin (sans compte agent PIN) non
can_manage_data_integrity Utiliser le registre de vérifications d'intégrité des données non
can_edit_student_info Corriger les infos d'un étudiant sans passer par une demande non
can_view_students Consulter la liste des étudiants non
can_export_students Exporter les étudiants en CSV non
can_suspend_students Suspendre/réactiver un étudiant non
can_transfer_students Transférer le campus d'un étudiant non
can_view_student_logins Consulter les logins étudiants non
can_review_pending_students Valider/rejeter les étudiants en attente non
can_review_info_changes Traiter les demandes de modification d'info non
can_manage_complaints Gérer/résoudre les réclamations non
can_manage_document_types Gérer les types de documents et leurs overrides (campus/classe) non
can_delete_documents Supprimer un document généré non
can_view_document_history Consulter l'historique d'un étudiant non
can_view_dashboard_stats Consulter les statistiques du dashboard non
can_manage_info_notes Gérer les notes d'information non

Les superadmins reçoivent automatiquement tous les droits.

Ne pas dupliquer cette table indéfiniment — elle driftera

Elle a déjà dû être corrigée une fois (20 → 24 droits entre le 2026-09-01 et le 2026-09-09). La liste vit dans le code (seed_rights.py pour la liste brute, RIGHT_METADATA dans apps/user/admin_views.py pour le libellé/description/groupe de chacun) et est exposée en direct via GET /admin/rights — en cas de doute sur l'exhaustivité de cette table, c'est la source à consulter, pas cette page. Catégories actuelles (group dans RIGHT_METADATA) : students, documents, complaints, presence, campus, reports, uploads.

Comment les droits sont initialisés/synchronisés :

python manage.py seed_rights
  • Crée tous les Right de la liste s'ils n'existent pas (idempotent)
  • Assigne tous les droits à chaque is_superuser
  • Assigne base_rights (can_upload_tepitech, can_upload_user, can_generate, can_generate_for_student) à chaque admin non-superadmin qui n'en a pas déjà

Cette commande tourne automatiquement à chaque démarrage du conteneur (docker-compose.prod.yml, chaîne migrate && seed_rights && seed_suspension_reasons && ...) — le système de droits (et les motifs de suspension par défaut) sont donc auto-réparants à chaque déploiement.

Un nouveau droit n'est PAS auto-attribué aux admins non-superadmin

Sauf s'il est explicitement ajouté à base_rights dans seed_rights.py — décision volontaire ("c'est moi qui attribue les droits"). Ajouter un droit à la liste rights seule ne le donne qu'aux superadmins ; il faut l'attribuer manuellement aux autres admins via le dashboard (gestion des droits) ou l'API rights. Le pattern complet pour ajouter un nouveau droit :

  1. L'ajouter à la liste rights dans seed_rights.py (et à base_rights seulement s'il doit être auto-accordé).
  2. Sous-classer HasRightPermission dans libs/permissions.py (ex. class CanFooPermission(HasRightPermission): def __init__(self): super().__init__('can_foo')).
  3. L'ajouter à permission_classes de la vue, en plus de permissions.IsAdminUser (jamais à la place) : permission_classes = [permissions.IsAdminUser, CanFooPermission].

Implication opérationnelle : accorder un nouveau droit sur un endpoint déjà utilisé est un breaking change silencieux pour tout admin non-superadmin qui l'utilisait déjà — l'accès échoue fermé (403), pas ouvert, tant que quelqu'un n'a pas explicitement accordé le droit à ce compte.

Actions volontairement non-délégables (réservées IsSuperAdmin, jamais via Right) : création d'autres comptes admin (CreateUserView, apps/user/views.py) et assignation de gestionnaires de campus (CampusManagerListView/CampusManagerDeleteView, apps/campus/views.py) — ce sont des actions d'élévation de privilège, pas des tâches métier déléguables.

Types de documents

ID Nom Classes autorisées Condition
certificat_scolarite Certificat de scolarité Toutes Étudiant actif (non diplômé)
attestation_scolarite Attestation de scolarité PGE3, PGE5 PGE3_status=success ou PGE5_status=success
attestation_passage_pge Attestation de passage PGE PGE2+ —
attestation_validation_tepitech Attestation TEPITECH Toutes Score TEPITECH requis
attestation_fin_succes_bachelor Attestation fin de Bachelor PGE3 PGE3_status=success
attestation_fin_success_master Attestation fin de Master PGE5 PGE5_status=success
lettre_recommandation Lettre de recommandation PGE2+ —
attestation_passage_coding_academy Attestation Coding Academy CodingAcademy —

Logique diplômé PGE3/PGE5 : dès que PGE3_status ou PGE5_status est success, l'étudiant ne peut plus générer le certificat_scolarite. Il accède à la place à l'attestation_scolarite. Le statut est posé par l'admin via la route POST /student/admin/set-graduation-status/.

Présence campus, badges QR (apps/attendance/)

Demande d'Ernesto, agent de sécurité au portail campus : scanner le QR d'un étudiant à l'entrée et savoir instantanément s'il doit autoriser ou refuser l'accès. Conception détaillée : ATTENDANCE_IMPLEMENTATION.md et API_CHANGES_ATTENDANCE.md à la racine du repo. Pour les chaperons, l'annulation de scan, et le suivi par personne (ajoutés le 2026-09-09) : voir Présence Campus (Attendance).

Authentification isolée : apps/attendance/authentication.py (AttendanceGuardAuthentication) vérifie le cookie attendance_session (JWT, AttendanceSession en base, 12h) et pose request.user = SecurityGuard. Volontairement séparé de JWTSessionAuthentication/UserSession (admin/étudiant) : un agent de sécurité n'a ni email ni droits, et on ne veut prendre aucun risque de régression sur le chemin d'authentification JWT partagé, critique en production, pour une fonctionnalité annexe. Le PIN est hashé (SecurityGuard.pin_hash, SHA-256 salé au SECRET_KEY) — le vrai contrôle d'accès reste humain (l'agent physique), le PIN n'a pas besoin d'un hash lent type bcrypt.

QR signé HMAC (apps/attendance/qr.py) : le contenu encodé est signature:login — login (e-mail Epitech), pas id_etudiant (matricule non encore renseigné pour la plupart des étudiants). La signature est un HMAC-SHA256 calculé avec ATTENDANCE_QR_SECRETS (project/settings.py, dérivé de la variable d'env ATTENDANCE_QR_SECRET, une liste séparée par des virgules, défaut = SECRET_KEY si non définie) :

  • build_qr_payload() signe toujours avec le premier secret de la liste.
  • verify_qr_payload() accepte une signature valide pour n'importe lequel des secrets de la liste.

Cette dissymétrie permet une rotation sans cutover brutal : ajouter le nouveau secret en tête de la variable d'env (nouveau,ancien), redéployer, renvoyer les badges à tous (POST /attendance/qrcodes/send), laisser une fenêtre de transition, puis retirer l'ancien secret.

Limite assumée : signer empêche de fabriquer un faux QR pour l'identité d'un autre étudiant, mais n'empêche pas un étudiant de présenter le vrai QR de quelqu'un d'autre (capture d'écran, téléphone prêté). decision: granted signifie « ce code est authentique et cet étudiant n'est pas suspendu », pas « la personne en face est bien celle-là » — c'est pour ça que la fiche (photo, nom, prénom) est renvoyée au scan : la protection contre ce scénario est humaine, pas cryptographique.

Badge visuel (apps/attendance/badge.py) : ce qui est envoyé n'est pas un QR nu mais un badge complet — QR signé + identité + logo Epitech + campus de l'étudiant (résolu dynamiquement, jamais codé en dur), sans section/promo (valable toute la scolarité). Rendu suréchantillonné (SCALE = 5, dessiné à 5× puis réduit en LANCZOS) pour un anti-aliasing net en PIL ; polices réelles de la charte (IBM Plex Sans/Mono, fichiers dans apps/attendance/assets/fonts/) pour un rendu identique quel que soit l'environnement de déploiement. Le badge part en pièce jointe e-mail réelle, pas en <img src="data:..."> inline — cassé sur Outlook desktop classique (moteur Word), qui ne rend pas les images en data: URI.

Exclusion des diplômés/suspendus par défaut (_build_qr_audience(), apps/attendance/views.py) — voir l'incident détaillé dans troubleshooting.md. En bref : un étudiant diplômé garde sa section en base (aucune section "ALUMNI" n'existe), donc filtrer un envoi par section seul touche aussi les diplômés. _build_qr_audience() est partagée entre l'aperçu (AttendanceQRAudienceView, lecture seule) et l'envoi réel (AttendanceQRSendView) pour qu'ils ne puissent jamais diverger, et exclut par défaut :

  • les diplômés (PGE3_status/PGE5_status/Codingacademy_status == 'success' pour la section correspondante) ;
  • les comptes suspendus (account_suspended == 1).

Désactivable ponctuellement via include_graduated/include_suspended dans le body. Toujours appeler /attendance/qrcodes/audience avant /attendance/qrcodes/send pour vérifier la liste exacte des destinataires.

Envoi asynchrone (threading.Thread) : la génération de badge (rendu PIL suréchantillonné) est trop lente par étudiant pour tenir dans le timeout nginx→gunicorn (~60s) au-delà d'un ou deux étudiants — nginx renvoyait sa propre page 404/504 pendant que Django continuait de tourner en tâche de fond, invisible côté admin (incident du 2026-08-29). AttendanceQRSendView.post matérialise la liste des étudiants avant de lancer le thread (le queryset ne doit pas être évalué paresseusement dans le thread), répond immédiatement 202 Accepted, et trace chaque résultat individuel dans AuditLog (attendance_qr_sent / attendance_qr_send_failed) — à consulter via GET /admin/audit-logs?action=attendance_qr_sent.

APPEND_SLASH=False et les routes doublées : project/settings.py désactive la redirection automatique Django vers/depuis le slash final. apps/attendance/urls.py enregistre donc chaque route deux fois (avec et sans slash) dans une boucle, pour tolérer les deux conventions côté frontend — pattern à réutiliser pour toute nouvelle app plutôt que de découvrir un 404 en prod sur une route qui existe mais ne matche pas l'URL appelée. Voir troubleshooting.md.

Diagrammes

Architecture globale

graph TB
    Student[Étudiant / Navigateur]
    Admin[Admin / Navigateur]
    Frontend["Frontend React<br/>api.mydocs.epitools.bj"]
    Nginx["Nginx<br/>Reverse proxy + TLS"]
    Backend["Backend API<br/>Django :8000"]
    PostgreSQL["PostgreSQL 16<br/>(conteneur)"]
    AzureAD["Azure AD<br/>(SSO Microsoft)"]
    LibreOffice["LibreOffice<br/>(conversion PDF)"]
    Media["Media<br/>(bind mount)"]

    Student --> Nginx
    Admin --> Nginx
    Nginx --> Frontend
    Nginx --> Backend
    Backend --> PostgreSQL
    Backend --> AzureAD
    Backend --> LibreOffice
    Backend --> Media

Flux de génération de document

sequenceDiagram
    participant User as Étudiant/Admin
    participant API as Backend API
    participant DB as PostgreSQL
    participant TPL as DocxTemplate
    participant LO as LibreOffice
    participant FS as Stockage (media/)

    User->>API: POST /documents/generate-document
    API->>DB: Vérifier historique existant

    alt Document déjà généré (FR + EN)
        API->>FS: Récupérer le PDF FR existant
        FS-->>API: PDF
        API-->>User: PDF + X-Has-En: true + X-Tracking-Code
    else Document déjà généré (FR seulement, template EN maintenant dispo)
        API->>TPL: Remplir template EN (.docx)
        TPL-->>API: .docx EN rempli
        API->>LO: Convertir .docx → .pdf EN
        API->>FS: Sauvegarder PDF EN
        API->>DB: Mettre à jour remote_url_en (même tracking code conservé)
        API->>FS: Récupérer le PDF FR existant
        API-->>User: PDF FR + X-Has-En: true + X-Tracking-Code
    else Nouveau document
        API->>DB: Résoudre template (campus override si applicable)
        API->>DB: Générer tracking code + référence
        API->>TPL: Remplir template FR .docx
        TPL-->>API: .docx FR rempli
        API->>LO: Convertir .docx → .pdf FR
        API->>TPL: Remplir template EN .docx (si disponible)
        TPL-->>API: .docx EN rempli
        API->>LO: Convertir .docx → .pdf EN
        API->>API: Ajouter QR code, nettoyer pages vides
        API->>FS: Sauvegarder PDF FR + PDF EN
        API->>DB: Créer GenDocumentHistory (remote_url + remote_url_en)
        API-->>User: PDF FR + X-Has-En: true/false + X-Tracking-Code
    end

Headers de réponse : chaque génération retourne X-Tracking-Code (code de suivi) et X-Has-En (true/false). Le frontend utilise X-Has-En pour afficher ou non le bouton de téléchargement EN.

Résolution des templates (campus + classe) : avant la génération, resolve_template_paths() (apps/document_type/views.py) applique les overrides dans l'ordre CampusDocumentType > ClassDocumentType > DocumentType global. Les deux axes d'override sont indépendants (un par campus, un par classe PGE1/PGE2/.../CodingAcademy) ; si les deux existent pour le même document, celui du campus l'emporte — pas de fusion des deux. Voir § Surcharge des templates par classe plus bas.

Flux de génération en lot (batch)

sequenceDiagram
    participant Admin as Admin
    participant API as Backend API
    participant DB as PostgreSQL
    participant Gen as Génération unitaire
    participant ZIP as Archive ZIP

    Admin->>API: POST /documents/admin/batch-generate
    API->>DB: Récupérer tous les étudiants de la classe

    loop Pour chaque étudiant
        API->>DB: Vérifier si document existe
        alt Existe
            API->>Gen: Récupérer document existant
        else N'existe pas
            API->>Gen: Générer nouveau document
        end
        Gen-->>API: PDF
        API->>ZIP: Ajouter au ZIP
    end

    API->>DB: AuditLog (batch_generate_by_admin)
    API-->>Admin: ZIP (Content-Disposition: attachment)

Middleware de maintenance

graph TD
    Request[Requête entrante] --> CheckPath{Path exempté ?}
    CheckPath -->|Oui| Pass[Laisser passer]
    CheckPath -->|Non| CheckMaintenance{Mode maintenance actif ?}
    CheckMaintenance -->|Non| Pass
    CheckMaintenance -->|Oui| CheckAdmin{Utilisateur admin ?}
    CheckAdmin -->|Oui| Pass
    CheckAdmin -->|Non| Block[503 Service Unavailable]

Flux des notes d'information

Les notes d'information permettent aux admins de publier des PDFs (convocations, plannings, circulaires) ciblés par classe. Contrairement aux documents générés, il n'y a ni template docx, ni QR code, ni tracking code — juste un PDF uploadé et téléchargeable.

sequenceDiagram
    participant Admin as Admin
    participant API as Backend API
    participant DB as PostgreSQL
    participant FS as Stockage (media/info_notes/)

    Admin->>API: POST /documents/admin/info-notes<br/>(multipart: title, pdf_file, target_classes)
    API->>FS: Sauvegarder le PDF
    API->>DB: Créer InfoNote
    API-->>Admin: 201 Created

    Note over Admin,API: Plus tard, l'admin peut toggle le statut
    Admin->>API: PATCH /documents/admin/info-notes/:id/toggle
    API->>DB: Inverser is_active
    API-->>Admin: {id, isActive: false}
sequenceDiagram
    participant Student as Étudiant (PGE1)
    participant API as Backend API
    participant DB as PostgreSQL
    participant FS as Stockage

    Student->>API: GET /documents/available-document-types?classe=PGE1
    API->>DB: Récupérer docs + InfoNote actives pour PGE1
    API-->>Student: {documents: [...], infoNotes: [{id, title, description}]}

    Student->>API: GET /documents/info-notes/1/download
    API->>DB: Vérifier classe étudiant ∈ target_classes
    API->>DB: Enregistrer InfoNoteDownload
    API->>FS: Lire le PDF
    API-->>Student: PDF (Content-Disposition: attachment)

Paths exemptés du mode maintenance :

  • /auth/sign_in, /auth/callback, /auth/me
  • /student/auth/me
  • /admin/system-status, /admin/announcements

Flux d'intégration Zeno (service-à-service)

Zeno (service backend externe) consomme l'API EPIDOCS avec une clé API. Aucun cookie/session : l'authentification se fait par le header Authorization: Api-Key <clé>, validé contre le hash stocké. La génération réutilise exactement le moteur des documents étudiants/admin (generate_document_for_student).

sequenceDiagram
    participant Zeno as Zeno (service)
    participant API as Backend API
    participant Auth as ServiceAccountAuthentication
    participant Gen as Service génération
    participant DB as PostgreSQL
    participant FS as Stockage (media/)

    Zeno->>API: POST /integrations/v1/documents/generate<br/>Authorization: Api-Key xxx
    API->>Auth: Vérifier la clé (hash SHA-256, active, non révoquée)
    Auth-->>API: ServiceAccount (sinon 401)
    API->>Gen: generate_document_for_student(student, docId)
    Gen->>DB: Vérifier classe + historique existant
    alt Document déjà généré
        Gen-->>API: GenDocumentHistory existant
    else Nouveau
        Gen->>FS: Générer + sauvegarder PDF (FR/EN)
        Gen->>DB: Créer GenDocumentHistory
    end
    API->>DB: AuditLog (document_generated, role=service)
    API-->>Zeno: JSON { trackingCode, downloadUrl, downloadUrlEn, ... }

    Zeno->>API: GET /integrations/v1/documents/:tc/download?lang=fr
    API->>Auth: Vérifier la clé
    API->>FS: retrieve_student_document (local ou Cloudinary)
    API->>DB: AuditLog (document_downloaded, role=service, IP)
    API-->>Zeno: PDF (application/pdf)

Audit : chaque génération et chaque téléchargement effectués par un service sont tracés dans AuditLog avec actor_role = service (incluant trackingCode, studentLogin, langue et IP). Les lectures de métadonnées (liste/détail) ne sont volontairement pas tracées pour éviter le bruit.

Flux de scan de présence (Ernesto)

sequenceDiagram
    participant Guard as Agent (Ernesto)
    participant API as Backend API
    participant Auth as AttendanceGuardAuthentication
    participant QR as qr.verify_qr_payload
    participant DB as PostgreSQL

    Guard->>API: POST /attendance/auth { pin }
    API->>DB: SecurityGuard.authenticate(pin) — compare le hash pour chaque agent actif
    API->>DB: Créer AttendanceSession (12h)
    API-->>Guard: 200 + cookie attendance_session

    Guard->>API: POST /attendance/scan { payload }
    API->>Auth: Vérifier le cookie attendance_session
    Auth-->>API: SecurityGuard (sinon 401)
    API->>QR: verify_qr_payload(payload)
    alt Signature invalide ou login inconnu
        QR-->>API: None
        API->>DB: AttendanceLog(decision=denied, reason="QR invalide"/"Étudiant introuvable")
    else Login valide mais suspendu
        QR-->>API: login
        API->>DB: AttendanceLog(decision=denied, reason=motif de suspension)
    else Login valide et actif
        QR-->>API: login
        API->>DB: AttendanceLog(decision=granted)
    end
    API-->>Guard: 200 { logId, decision, student: {nom, prenom, section, photoUrl}, reason }

Toujours 200 : la décision (granted/denied) est dans le corps de la réponse, pas dans le status HTTP — un AttendanceLog est créé systématiquement, y compris en cas de refus/erreur.

Gestion des clés : les comptes de service et leurs clés se gèrent depuis le dashboard (super-admin). Une clé peut être révoquée ou régénérée (rotate) à tout moment, coupant immédiatement l'accès du service concerné.

Surcharge des templates par classe (ClassDocumentType)

Ajouté pour la lettre de recommandation, dont le contenu doit différer selon le niveau de l'étudiant (PGE2/PGE3/PGE4/PGE5/CodingAcademy) — un seul DocumentType ne porte qu'un modele/modele_en global. ClassDocumentType reprend exactement le principe de CampusDocumentType (apps/campus/models.py) mais sur l'axe classe : classe (clé de settings.CLASSES) + document_type (FK) + modele/modele_en optionnels.

Endpoints admin (droit can_manage_document_types) :

Méthode Route Description
GET/POST /documents/admin/classes/:classe/templates Lister / créer-remplacer l'override d'une classe pour un document
DELETE /documents/admin/classes/:classe/templates/:doc_id Supprimer l'override

Le champ document_type du ClassDocumentTypeSerializer utilise SlugRelatedField(slug_field='doc_id', ...) plutôt que le PrimaryKeyRelatedField par défaut de DRF : identifier un type de document par son doc_id lisible (lettre_recommandation) plutôt que par son PK numérique opaque est plus pratique pour l'admin qui remplit le formulaire et pour le frontend, qui n'a jamais besoin de connaître le PK.

ClassDocumentTypeListView.post fait un get_or_create(classe=classe, document_type=...) puis assigne modele/modele_en champ par champ (pas update_or_create) : un admin doit pouvoir mettre à jour uniquement modele_en sans re-fournir modele, ce qu'un defaults={'modele': None} effacerait par erreur.

Deux bugs réels rencontrés en construisant cette fonctionnalité

1. Méthodes de DocumentTypeSerializer sectionnées par erreur — GET /documents/ (et tout SerializerMethodField de DocumentTypeSerializer) est parti en 500 en production après le déploiement de ClassDocumentType (PR #25). Cause : l'édition texte qui a inséré ClassDocumentTypeSerializer dans apps/document_type/serializers.py l'a insérée entre la classe Meta de DocumentTypeSerializer et ses méthodes (get_name, get_created_by, get_variables, get_variables_en) — celles-ci se sont retrouvées silencieusement reparentées sur ClassDocumentTypeSerializer, qui n'en avait pourtant pas besoin. Aucune erreur à l'édition ni à l'import : Python accepte parfaitement une méthode définie sur la mauvaise classe. Corrigé par fix: restore DocumentTypeSerializer methods severed by ClassDocumentType edit (PR #26). Leçon : après avoir inséré une classe/méthode par édition de texte "entre deux blocs", relire le fichier complet pour confirmer les frontières de classe — ne jamais supposer qu'une insertion "entre deux lignes vides" est tombée au bon endroit.

2. request.data.copy() plante sur un upload de fichier — ClassDocumentTypeListView.post faisait data = request.data.copy(); data['classe'] = classe pour injecter classe dans le payload avant validation. Deux problèmes : classe est read_only sur le serializer (l'injection ne servait déjà à rien, classe venant en réalité de l'URL) et .copy() sur un QueryDict multipart tente un deepcopy qui plante (cannot pickle 'BufferedRandom') dès qu'un fichier est attaché à la requête — le flux de fichier ouvert n'est pas copiable. Corrigé en passant request.data sans le copier. Leçon générale : si un champ doit être injecté avant validation par un serializer, ne jamais appeler .copy() sur un QueryDict multipart — soit éviter la mutation (faire venir la valeur de l'URL/vue plutôt que du body), soit reconstruire un dict simple à partir de zéro.

Docker

Dockerfile

L'image utilise python:3.12 avec LibreOffice installé (nécessaire pour la conversion DOCX → PDF). L'application ne tourne pas en tant qu'utilisateur non-root (à améliorer).

docker-compose.yml (production)

services:
  db:
    restart: always
    image: postgres:16
    container_name: epidocs_db
    volumes:
      - epidocs-api_postgres_data:/var/lib/postgresql/data/
    environment:
      POSTGRES_USER: ${POSTGRES_USER:-postgres}
      POSTGRES_DB: ${POSTGRES_DB:-epidocs_db}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 10s
      retries: 5
      start_period: 30s

  web:
    image: ghcr.io/epitechafrik/epidocs-api:deploy
    container_name: epidocs_api
    restart: unless-stopped
    ports:
      - "7010:8000"
    env_file:
      - .env
    volumes:
      - ./media:/epidocs/media
      - static_data:/epidocs/staticfiles
    command: >
      bash -c "python manage.py migrate &&
               python manage.py seed_rights &&
               python manage.py seed_suspension_reasons &&
               python manage.py collectstatic --noinput &&
               gunicorn project.wsgi:application --bind 0.0.0.0:8000 --workers 3 --timeout 120"
    depends_on:
      db:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8000/"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 40s

volumes:
  epidocs-api_postgres_data:
    external: true
  static_data:

Points importants :

  • Volume externe epidocs-api_postgres_data : les données PostgreSQL persistent entre les déploiements. Ce volume doit exister avant le premier docker compose up.
  • Bind mount ./media : les templates Word et les documents générés sont stockés sur le disque du VPS, pas dans le conteneur. Cela permet de conserver les templates entre les déploiements.
  • Startup : au démarrage, le conteneur exécute migrate + seed_rights + seed_suspension_reasons + collectstatic avant de lancer Gunicorn — le système de droits (et les motifs de suspension par défaut) sont donc auto-réparants à chaque déploiement.
  • Port : 7010 côté hôte, accessible uniquement via Nginx.
  • Le port PostgreSQL n'est pas exposé sur l'hôte (sécurité).

Variables d'environnement

Django

Variable Description Exemple
DJANGO_ENV Mode d'exécution production
SECRET_KEY Clé secrète Django chaîne aléatoire longue
ALLOWED_HOSTS Hôtes autorisés (séparés par virgule) api.mydocs.epitools.bj,localhost

Base de données

Variable Description Exemple
POSTGRES_DB Nom de la base epidocs_db
POSTGRES_USER Utilisateur PostgreSQL postgres
POSTGRES_PASSWORD Mot de passe PostgreSQL CHANGE_ME
POSTGRES_HOST Hôte PostgreSQL db (nom du service Docker)

Authentification

Variable Description Exemple
JWT_SECRET_KEY Clé secrète JWT (optionnel, défaut = SECRET_KEY) chaîne aléatoire
JWT_COOKIE_DOMAIN Domaine du cookie (prod uniquement) .epitools.bj
ATTENDANCE_QR_SECRET Secret(s) HMAC pour signer/vérifier les QR de présence — liste séparée par virgules pour rotation (signature = 1er secret, vérification = n'importe lequel). Optionnel, défaut = SECRET_KEY (déconseillé en prod : une fuite de SECRET_KEY, utilisé partout ailleurs, aurait alors le même impact que la fuite du secret QR) nouveau_secret,ancien_secret

Azure AD

Variable Description Exemple
AZURE_CLIENT_ID Client ID de l'App Registration UUID
AZURE_CLIENT_SECRET Client Secret Azure CHANGE_ME
AZURE_TENANT_ID Tenant ID Azure UUID
CALLBACK_URL URL de callback OAuth https://api.mydocs.epitools.bj/auth/callback

URLs

Variable Description Exemple
WEB_URL URL du frontend https://app.epitools.bj
SITE_URL URL de l'API https://api.mydocs.epitools.bj

Email (optionnel)

Variable Description Exemple
RESEND_API_KEY Clé API Resend re_xxxxx
SMTP_SERVER Serveur SMTP smtp.example.com
SMTP_PORT Port SMTP 465
SMTP_USERNAME Utilisateur SMTP no-reply@epitech.bj
SMTP_PASSWORD Mot de passe SMTP CHANGE_ME

Cloudinary (si STORAGE_TYPE=remote)

Variable Description Exemple
CLOUDINARY_CLOUD_NAME Nom du cloud my-cloud
CLOUDINARY_API_KEY Clé API 123456
CLOUDINARY_API_SECRET Secret API CHANGE_ME

Déploiement

Pipeline CI/CD

Le déploiement est déclenché automatiquement sur push vers la branche deploy via GitHub Actions.

graph LR
    Push["Push sur deploy"] --> CI["CI: Build & Test"]
    CI --> Docker["Build image Docker"]
    Docker --> GHCR["Push sur GHCR"]
    GHCR --> Deploy["SCP + SSH sur VPS"]
    Deploy --> Health["Health check"]

Étapes détaillées :

  1. CI : Python 3.12, PostgreSQL 16 de test, linting (flake8), vérification des migrations, tests
  2. Build Docker : construction de l'image et push sur ghcr.io/epitechafrik/epidocs-api:deploy
  3. Déploiement : SCP du docker-compose.prod.yml et .env sur le VPS, puis docker compose pull avant d'arrêter les anciens conteneurs, puis swap + docker compose up -d (voir encart ci-dessous)
  4. Health check : vérification que le conteneur répond sur le port 7010

Ordre pull avant stop — fenêtre de coupure réduite

Avant correctif (fix: pull the new image before stopping old containers on deploy, PR #23), le job arrêtait/supprimait les anciens conteneurs puis faisait docker compose pull — le service était donc down pendant tout le téléchargement de l'image en plus du redémarrage lui-même. Depuis, l'ordre est inversé :

graph LR
    subgraph "Avant (coupure longue)"
        A1["Stop anciens conteneurs"] --> A2["docker compose pull"] --> A3["docker compose up -d"]
    end
    subgraph "Après (coupure réduite)"
        B1["docker compose pull<br/>(anciens conteneurs encore up)"] --> B2["Stop anciens conteneurs"] --> B3["docker compose up -d"]
    end

La fenêtre de coupure réelle est réduite à la seule phase de swap des conteneurs, pas swap + téléchargement de l'image.

Panne connue : la pipeline ne démarre même pas (facturation GitHub Actions)

Un job peut échouer à démarrer avec le message "recent account payments have failed or your spending limit needs to be increased" — ce n'est pas un problème de code, ça bloque la pipeline au niveau de l'organisation GitHub entière. Seul un owner/admin de l'organisation peut corriger ça dans les paramètres de facturation GitHub (Settings → Billing). Attention : le scope OAuth admin:org d'un token ne suffit pas — il faut le rôle admin/owner réel dans l'organisation, un axe séparé du scope OAuth. Procédure de contournement manuel (déploiement direct sur le VPS sans passer par GHCR) : voir runbook.md.

Secrets GitHub Actions

Secret Description
SSH_PRIVATE_KEY Clé SSH pour accès au VPS
VPS_IP Adresse IP du VPS
VPS_USER Utilisateur SSH (root)
ENV_FILE Contenu complet du fichier .env
GITHUB_TOKEN Token pour GHCR (automatique)

Rollback

Quand faire un rollback

  • Le déploiement a cassé une fonctionnalité critique
  • Le health check échoue après déploiement
  • Régression détectée en production

Procédure

# 1. Se connecter au VPS
ssh root@VPS_IP

# 2. Aller dans le répertoire du projet
cd /root/projects/mydocs/epidocs-api

# 3. Voir les images disponibles
docker images ghcr.io/epitechafrik/epidocs-api --format "table {{.Tag}}\t{{.CreatedAt}}"

# 4. Modifier le tag dans docker-compose.yml
# Changer ghcr.io/epitechafrik/epidocs-api:deploy par le tag voulu
nano docker-compose.yml

# 5. Redéployer
docker compose pull
docker compose up -d

# 6. Vérifier
curl -s http://127.0.0.1:7010/
docker compose logs web --tail 30

Après le rollback

  1. Investiguer la cause sur la branche deploy
  2. Corriger et pusher le fix
  3. Le pipeline redéploiera automatiquement

Backups

PostgreSQL

La base de données utilise un volume Docker externe (epidocs-api_postgres_data). Les données persistent entre les redéploiements.

Backup manuel

# Depuis le VPS
cd /root/projects/mydocs/epidocs-api

# Dump complet
docker compose exec db pg_dump -U postgres epidocs_db > backup_$(date +%Y%m%d).sql

# Dump compressé
docker compose exec db pg_dump -U postgres -Fc epidocs_db > backup_$(date +%Y%m%d).dump

Restauration

# Restaurer un dump SQL
docker compose exec -T db psql -U postgres epidocs_db < backup_YYYYMMDD.sql

# Restaurer un dump compressé
docker compose exec -T db pg_restore -U postgres -d epidocs_db backup_YYYYMMDD.dump

Backup via l'API (superadmin)

# Export via l'API (nécessite cookie auth_token)
curl -b "auth_token=TOKEN" https://api.mydocs.epitools.bj/admin/backup/export -o backup.json

# Import
curl -b "auth_token=TOKEN" -X POST -F "file=@backup.json" https://api.mydocs.epitools.bj/admin/backup/import

Media (templates et documents générés)

Les fichiers media sont dans un bind mount ./media sur le VPS. Pour les sauvegarder :

# Depuis le VPS
cd /root/projects/mydocs/epidocs-api
tar czf media_backup_$(date +%Y%m%d).tar.gz media/