EPIDOCS API — Erreurs courantes¶
Erreurs d'authentification¶
Cookie auth_token non envoyé / session invalide¶
Symptôme : L'API retourne 401 Unauthorized alors que l'utilisateur vient de se connecter.
Cause : Le cookie auth_token n'est pas envoyé par le navigateur (problème CORS / SameSite), ou la session a expiré.
Solution :
# Vérifier les sessions actives dans la base
docker compose exec web python manage.py shell -c "
from apps.user.models import UserSession
sessions = UserSession.objects.filter(is_valid=True)
for s in sessions:
print(f'{s.user or s.student} - expires: {s.expires_at} - valid: {s.is_valid}')
"
- En production : vérifier que
JWT_COOKIE_DOMAINest bien défini (ex:.epitools.bj) - En dev : vérifier que le frontend envoie
credentials: 'include'dans les requêtes fetch
Azure AD callback échoue¶
Symptôme : Après la connexion Microsoft, le callback retourne une erreur ou redirige en boucle.
Cause : CALLBACK_URL ne correspond pas à l'URL enregistrée dans l'App Registration Azure.
Solution :
- Vérifier la variable
CALLBACK_URLdans le.env - Vérifier que cette URL est enregistrée dans Azure Portal → App Registrations → Redirect URIs
- S'assurer que le tenant ID est correct (
AZURE_TENANT_ID)
AZURE_CLIENT_SECRET expiré — plus AUCUNE connexion possible (admins ET étudiants)¶
Panne totale de connexion, silencieuse jusqu'à ce que quelqu'un se connecte
Un secret d'App Registration Azure AD a toujours une date d'expiration fixée à sa création (souvent 6 mois, 1 an ou 2 ans max — jamais "jamais"). Rien ne prévient automatiquement côté EPIDOCS quand cette date arrive : le symptôme n'apparaît qu'au moment où quelqu'un essaie de se connecter, après l'expiration.
Symptôme : GET /auth/callback renvoie systématiquement 401 { "error": "NON AUTORISÉ" } pour tout le monde (admins et étudiants), juste après avoir validé son compte Microsoft — la redirection Microsoft elle-même fonctionne, c'est l'échange du code contre un token qui échoue côté backend.
Cause : dans CallbackView.get() (apps/user/views.py), msal.ConfidentialClientApplication(...).acquire_token_by_authorization_code(...) renvoie un résultat sans access_token — un secret expiré, révoqué, ou faux produit exactement ce même symptôme générique (401 "NON AUTORISÉ"), donc il faut regarder les logs pour distinguer les cas.
Diagnostic :
# Chercher l'erreur MSAL exacte dans les logs (error_description donne la vraie raison)
docker compose logs web --tail 200 | grep "MSAL token exchange failed"
# Exemple attendu si le secret est expiré :
# MSAL token exchange failed: error=invalid_client, error_description=AADSTS7000222: The provided client secret keys for app '...' are expired. ...
Solution :
- Azure Portal → App Registrations → l'app EPIDOCS → Certificates & secrets → vérifier la date d'expiration du secret actif.
- Si expiré (ou proche de l'être) : créer un nouveau secret (le portail affiche sa valeur en clair une seule fois, la copier immédiatement).
- Mettre à jour
AZURE_CLIENT_SECRETdans le.envdu VPS. - Redémarrer le conteneur pour recharger la variable :
- Tester immédiatement un cycle de connexion complet (
/auth/sign_in→ Microsoft →/auth/callback) avant de considérer l'incident clos. - Supprimer l'ancien secret expiré dans Azure Portal une fois le nouveau confirmé fonctionnel (ne pas laisser de secrets morts trainer).
Prévenir plutôt que subir
Noter la date d'expiration du secret actuel quelque part de durable (ce document, un rappel calendaire) et la renouveler avant l'échéance plutôt que de découvrir la panne en prod. Azure Portal permet de créer le secret de remplacement en avance et de ne basculer AZURE_CLIENT_SECRET que plus tard — pas besoin d'attendre l'expiration effective pour agir.
Erreurs de génération de documents¶
La version EN n'est pas générée pour un document existant¶
Symptôme : L'étudiant clique "générer" après l'ajout du support multilingue, le document s'affiche comme "terminé" mais X-Has-En retourne false — seule la version FR est disponible.
Cause : Le document avait déjà été généré avant l'ajout du modele_en sur le DocumentType. L'entrée GenDocumentHistory existe avec remote_url_en = NULL. L'API détecte le document existant et le retourne sans vérifier si un template EN est maintenant disponible.
Solution : Depuis la version 621602b, ce cas est géré automatiquement. Lors d'une nouvelle demande de génération, si modele_en est disponible mais remote_url_en est NULL, la version EN est générée et sauvegardée sans toucher au document FR ni au tracking code. Il suffit que l'étudiant reclique "générer".
Pour forcer la mise à jour manuellement :
docker compose exec web python manage.py shell -c "
from apps.document_type.models import GenDocumentHistory, DocumentType
from apps.document_type.utils import generate_en_for_existing
# Trouver les documents sans EN pour un doc_id donné
doc_id = 'certificat_scolarite'
doc = DocumentType.objects.get(doc_id=doc_id)
if not doc.modele_en:
print('Pas de template EN configuré pour ce document')
else:
histories = GenDocumentHistory.objects.filter(doc_id=doc_id, remote_url_en__isnull=True)
print(f'{histories.count()} documents à mettre à jour')
for h in histories:
ok = generate_en_for_existing(h, doc.modele_en.path)
print(f'{h.tracking_code_id}: {\"OK\" if ok else \"ERREUR\"}')
"
X-Has-En: false alors que le template EN est configuré¶
Symptôme : Le frontend n'affiche pas le bouton de téléchargement EN malgré un modele_en uploadé sur le DocumentType.
Causes possibles :
- Le document a été généré avant l'upload du template EN (voir cas ci-dessus)
- Le campus de l'étudiant a un
CampusDocumentTypesansmodele_en - La génération EN a échoué silencieusement (voir logs)
Solution :
# Vérifier les logs de génération EN
docker compose logs web --tail 200 | grep -i "EN PDF\|generating EN\|EN version"
# Vérifier le campus override
docker compose exec web python manage.py shell -c "
from apps.campus.models import CampusDocumentType
from apps.student.models import Student
email = 'etudiant@epitech.bj'
s = Student.objects.get(login=email)
if s.campus:
override = CampusDocumentType.objects.filter(campus=s.campus, document_type__doc_id='certificat_scolarite').first()
if override:
print(f'Campus override: modele_en={override.modele_en}')
else:
print('Pas de campus override — template global utilisé')
"
Erreur lors de la génération du document (500)¶
Symptôme : L'API retourne une erreur 500 lors de la génération.
Causes possibles :
- Template manquant : le fichier
.docxn'existe pas dansmedia/modeles/ - LibreOffice crash : la conversion DOCX → PDF échoue
- Attribut manquant : le template contient un placeholder
{{attribut}}que le code ne connaît pas
Solution :
# Vérifier que le template existe
docker compose exec web ls -la /epidocs/media/modeles/
# Vérifier les logs de génération
docker compose logs web --tail 100 | grep -i "erreur\|error\|generation"
# Vérifier que LibreOffice fonctionne
docker compose exec web libreoffice --version
Impossible de trouver la valeur de l'attribut xxx¶
Symptôme : Erreur lors de la génération, un attribut du template Word n'est pas reconnu.
Cause : Le fichier .docx contient un placeholder {{xxx}} qui n'est pas défini dans prepare_attributes() ni dans les champs du modèle Student.
Solution :
- Ouvrir le fichier
.docxet vérifier les placeholders - Vérifier que l'attribut existe dans
apps/document_type/utils.py→prepare_attributes() - Si c'est un champ étudiant, vérifier qu'il existe dans le modèle
Student
'User' object has no attribute 'section'¶
Symptôme : Un admin appelle /documents/available-document-types sans query param.
Cause : L'admin (modèle User) n'a pas d'attribut section, contrairement aux étudiants.
Solution : L'admin doit passer ?studentEmail=xxx ou ?classe=PGE1 en paramètre.
Erreurs de présence (Attendance)¶
Voir aussi
Ces entrées sont résumées ici pour la cohérence du format ; le détail complet (modèle de données, routes, pièges) vit sur la page Présence Campus (Attendance).
Une requête tourne ~1 minute puis renvoie 404 (pas de rapport avec l'étudiant/le routing)¶
Symptôme : GET /student/<login>/qrcode (ou tout endpoint qui génère un badge QR) tourne longtemps côté navigateur puis échoue avec un 404 dont le content-type est text/html (page 404 générique, pas la réponse JSON habituelle de l'API).
Cause réelle (diagnostiquée le 2026-09-09, pas une intuition) : _render_qr() (apps/attendance/badge.py) générait le QR à box_size=50. La librairie qrcode applique son masque de couleur pixel par pixel en pure Python, même pour un simple remplissage solide — ça prenait ~14 secondes pour un seul badge. Avec seulement 3 workers Gunicorn (--timeout 120), quelques requêtes concurrentes sur ce chemin suffisaient à épuiser les workers ([CRITICAL] WORKER TIMEOUT puis SIGKILL dans les logs Gunicorn) — nginx retombe alors sur sa page 404 par défaut avant même que Django ait fini de traiter la requête. Aucun rapport avec l'étudiant ou une route mal enregistrée, malgré les apparences.
Diagnostic :
# Chercher des WORKER TIMEOUT récents dans les logs
docker compose logs web --tail 500 | grep -i "WORKER TIMEOUT\|SIGKILL"
# Mesurer le temps de génération d'un badge 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)
"
Solution : déjà corrigée (box_size=20 au lieu de 50, mesuré ~14s → ~2s pour le rendu QR seul). Si ce symptôme revient ailleurs dans le code (autre endroit qui génère une image), chercher la même classe de bug : un rendu pixel-par-pixel en pure Python sur une image trop grande.
GET /attendance/media/qrcodes/....png renvoie 401 alors que l'aperçu du QR fonctionne¶
Symptôme : GET /student/<login>/qrcode réussit et renvoie qrCodeUrl, l'aperçu s'affiche bien dans l'écran admin, mais télécharger cette même image renvoie 401 Unauthorized — malgré une session admin manifestement valide (confirmée dans les logs Authenticated superadmin via JWT juste avant l'échec).
Cause : qrCodeUrl est une URL absolue cross-origin (https://api.mydocs.epitools.bj/..., différent sous-domaine du frontend mydocs.epitools.bj). Une balise <img src=...> envoie les cookies normalement (pas soumis au mode credentials de fetch()), mais un fetch(qrCodeUrl) nu (sans credentials: 'include') ne les envoie pas — le défaut du navigateur pour fetch() cross-origin est credentials: 'same-origin', qui exclut justement ce sous-domaine différent. Incident réel (2026-09-09) : le bouton "Télécharger" faisait un fetch() nu là où l'aperçu utilisait une simple balise <img>.
Solution : côté frontend, router l'appel via le client HTTP partagé de l'app (axios avec withCredentials: true, ou fetch(url, {credentials: 'include'})) plutôt qu'un fetch() nu. Pas un bug backend — reproduit en local avec le même cookie sur les deux appels, ça fonctionne. Si ce 401 apparaît sur un nouvel endpoint /attendance/media/..., vérifier en premier comment le frontend consomme l'URL renvoyée avant de suspecter le backend.
Un document affiche la mauvaise civilité, ou une année de délivrance incohérente avec la promo¶
Symptôme : Une attestation ou un certificat généré accorde le texte au féminin pour un étudiant, ou affiche une année de délivrance de diplôme qui ne correspond pas à la promo de l'étudiant (ex : "délivré en 2023" pour un étudiant promo=2024).
Cause : La grammaire (né/née, il/elle, etc.) et l'année de délivrance bachelor/master (annee_bachelor_attestation = promo - 1, annee_master_attestation = promo + 1, dans apps/document_type/utils.py) sont dérivées de student.civilite et student.promo — il n'y a pas de champ dédié. Une valeur civilite non canonique ou un promo incorrect (souvent hérité d'un mauvais import CSV, voir ci-dessous) se répercute silencieusement sur tous les documents générés pour cet étudiant.
Solution :
# Vérifier civilite, promo et section de l'étudiant
docker compose exec web python manage.py shell -c "
from apps.student.models import Student
s = Student.objects.get(login='etudiant@epitech.eu')
print(f'civilite={s.civilite!r} promo={s.promo!r} section={s.section!r}')"
Corriger via l'admin Django (/admin/student/student/) plutôt qu'en SQL direct : civilite y est une liste déroulante Monsieur/Madame depuis PR #13, qui évite de réintroduire une variante non reconnue ("M.", "mme", etc.). Un document déjà généré avec une valeur incorrecte doit être régénéré après correction — corriger la donnée source, pas le PDF.
Erreurs d'import CSV étudiants¶
400 { missingColumns: [...] } lors de l'upload¶
Symptôme : POST /student/upload-csv/ renvoie 400 avec une liste missingColumns au lieu de démarrer l'import.
Cause : Depuis PR #13, les colonnes du CSV sont matchées par nom d'en-tête (insensible à la casse/aux accents), non plus par position fixe — une colonne obligatoire manque ou est mal orthographiée dans le fichier source.
Solution :
- Télécharger le template de référence (
GET /documents/download/student-template) et comparer son en-tête à celui du fichier importé. - Colonnes obligatoires :
id_etudiant,civilite,login,nom,prenom,ville,pays,date_naissance,ville_naissance,inscription_year,inscription_month,annee,ville_etude,section,promo,nationalite,prenom_mere,nom_mere,prenom_pere,nom_pere,specialite. - Colonnes optionnelles (peuvent être absentes) :
PGE3_status,PGE5_status,Codingacademy_status,account_suspended.
Des étudiants manquent après un import qui a répondu 200¶
Symptôme : La réponse {"message": "CSV file processing started in background"} est bien reçue, mais certains étudiants du fichier ne sont pas créés/mis à jour.
Cause : Le traitement tourne en tâche de fond ; une ligne avec un login vide ou avec moins de colonnes que l'en-tête (fichier tronqué par un export) est silencieusement comptée en échec, pour éviter d'écraser un étudiant existant avec des champs vides.
Solution :
Erreurs de base de données¶
OperationalError: could not connect to server¶
Symptôme : L'API ne démarre pas, erreur de connexion PostgreSQL.
Cause : Le conteneur db n'est pas prêt ou POSTGRES_HOST est mal configuré.
Solution :
# Vérifier que le conteneur db tourne
docker compose ps
# Vérifier la connectivité
docker compose exec web python -c "
import psycopg2
conn = psycopg2.connect(host='db', dbname='epidocs_db', user='postgres', password='CHANGE_ME')
print('OK')
conn.close()
"
# Vérifier les logs du conteneur db
docker compose logs db --tail 20
relation "xxx" does not exist¶
Symptôme : Erreur au démarrage ou lors d'une requête API.
Cause : Les migrations n'ont pas été appliquées.
Solution :
Erreurs de mode maintenance¶
Les étudiants ne peuvent plus accéder après désactivation du mode maintenance¶
Symptôme : Le mode maintenance est désactivé mais les étudiants reçoivent toujours une erreur 503.
Cause : La fenêtre de maintenance planifiée (scheduled_start / scheduled_end) est toujours active.
Solution :
# Vérifier l'état du système
docker compose exec web python manage.py shell -c "
from apps.user.models import SystemStatus
s = SystemStatus.objects.first()
if s:
print(f'Maintenance: {s.is_maintenance}')
print(f'Scheduled: {s.scheduled_start} → {s.scheduled_end}')
"
Mettre à jour via l'API : PUT /admin/system-status avec is_maintenance: false et scheduled_start: null, scheduled_end: null.
Erreurs de fichiers statiques¶
Assets ReDoc / Swagger / admin Django en 404 (page docs cassée)¶
Symptôme : la page /integrations/v1/docs (ou /redoc/, ou l'admin Django) s'affiche mais reste blanche/sans style. La console montre :
GET /static/drf-yasg/redoc/redoc.min.js 404 (Not Found)
Refused to apply style from '/static/drf-yasg/style.css' because its MIME type ('text/html')...
Cause : en production DEBUG=False, et Gunicorn ne sert pas les fichiers /static/ par défaut. Si aucun middleware (WhiteNoise) ni Nginx ne pointe vers le dossier staticfiles/, toutes les requêtes /static/... tombent sur Django qui renvoie une 404 HTML — d'où l'erreur de MIME type.
Solution : depuis la version d4f7f89, WhiteNoise est activé dans MIDDLEWARE (juste après SecurityMiddleware) et sert les fichiers collectés par collectstatic (exécuté au démarrage du conteneur). Aucune config Nginx supplémentaire n'est requise.
Vérifier que tout est en place :
# WhiteNoise présent dans le middleware
docker compose exec web python -c "from django.conf import settings; print([m for m in settings.MIDDLEWARE if 'white' in m.lower()])"
# Les fichiers statiques ont bien été collectés
docker compose exec web ls /epidocs/staticfiles/drf-yasg/redoc/
# Re-collecter au besoin
docker compose exec web python manage.py collectstatic --noinput
# Tester le serving (doit renvoyer 200 + content-type text/javascript)
curl -s -o /dev/null -w "%{http_code} %{content_type}\n" \
https://api.mydocs.epitools.bj/static/drf-yasg/redoc/redoc.min.js
Erreurs de présence campus (attendance)¶
Un envoi de badges QR a touché des étudiants déjà diplômés¶
Symptôme : un envoi filtré par { "section": "PGE3" } (ou toute autre section) a fait recevoir un badge à des étudiants déjà sortis de l'école.
Cause : un étudiant diplômé garde sa section en base — aucune section "ALUMNI" n'existe côté modèle. Seul PGE3_status/PGE5_status/Codingacademy_status passe à 'success' à la sortie, sans jamais toucher section. Filtrer un envoi par section seul inclut donc aussi les diplômés (incident réel du 2026-08-29).
Solution : depuis ce correctif, _build_qr_audience() (apps/attendance/views.py) est partagée entre l'aperçu (POST /attendance/qrcodes/audience) et l'envoi réel (POST /attendance/qrcodes/send), et exclut par défaut les diplômés et les comptes suspendus (désactivable via include_graduated/include_suspended). Toujours appeler /attendance/qrcodes/audience avant /attendance/qrcodes/send et relire la liste affichée avant de confirmer. Pour vérifier a posteriori qui a reçu un envoi :
docker compose exec web python manage.py shell -c "
from apps.user.models import AuditLog
logs = AuditLog.objects.filter(action='attendance_qr_sent').order_by('-timestamp')[:50]
for l in logs:
print(f'{l.timestamp} - {l.target_id}')
"
Route /attendance/... renvoie 404 alors que la vue existe¶
Symptôme : un appel à une route /attendance/xxx (typiquement avec ou sans slash final) renvoie 404, sans lien apparent avec un timeout ou une erreur serveur.
Cause : APPEND_SLASH=False est activé au niveau du projet (project/settings.py) — Django ne redirige pas automatiquement entre /xxx et /xxx/. Si le frontend appelle avec un slash final et que la route n'est enregistrée que sans (ou l'inverse), la requête ne matche tout simplement aucun pattern.
Solution : apps/attendance/urls.py enregistre chaque route deux fois (avec et sans slash) dans une boucle — vérifier que la route appelée est bien dans cette liste, avec le bon nom :
docker compose exec web python manage.py shell -c "
from django.urls import get_resolver
for p in get_resolver().url_patterns:
print(p)
" | grep -i attendance
Si une nouvelle route est ajoutée à cette app (ou toute app avec le même pattern), l'ajouter à _routes dans urls.py, pas directement via path() — sinon elle ne bénéficiera pas du double enregistrement.
Un envoi de badges à toute une section semble bloqué / ne rien faire¶
Symptôme : POST /attendance/qrcodes/send sur un lot d'étudiants semble ne jamais répondre, ou le frontend affiche une erreur (404/504) alors que les e-mails finissent par partir.
Cause historique (corrigée) : la génération d'un badge (rendu PIL suréchantillonné) est assez lente par étudiant pour que faire ça en boucle de façon synchrone dépasse le timeout nginx→gunicorn (~60s) dès qu'on envoie à plus d'un ou deux étudiants d'un coup. Nginx coupe la connexion et renvoie sa propre erreur pendant que Django continue de tourner en tâche de fond et envoie quand même les mails — invisible côté admin, qui a l'impression que ça n'a pas marché (et peut re-cliquer, causant un double-envoi).
État actuel : AttendanceQRSendView répond immédiatement (202 Accepted) et lance l'envoi dans un threading.Thread daemon. Si ce symptôme réapparaît (nouvelle route qui fait une boucle lente sur plusieurs étudiants), c'est le même piège — répondre vite et tracer le résultat dans AuditLog plutôt que de faire attendre la requête HTTP. Vérifier la progression réelle :
docker compose logs web --tail 100 | grep -i "attendance_qr"
docker compose exec web python manage.py shell -c "
from apps.user.models import AuditLog
print(AuditLog.objects.filter(action='attendance_qr_send_failed').order_by('-timestamp').values('target_id', 'details')[:10])
"
Incidents de données (résolus)¶
Étudiants validés via l'inscription publique sans campus assigné (2026-09-07)¶
Symptôme : des documents générés pour certains étudiants avaient tous leurs champs campus_* vides (nom du directeur, ville, etc. absents du PDF).
Cause : PendingStudent (créé par le formulaire d'inscription publique) n'a pas de champ campus. ValidatePendingStudentsView copiait génériquement les champs de PendingStudent vers Student — sans jamais toucher campus, qui restait donc NULL. La génération de document ne vérifiait pas non plus que campus était renseigné, donc produisait un document avec des champs vides plutôt que d'échouer bruyamment.
Résolution :
- 9 étudiants concernés identifiés, campus assigné manuellement (EPITECH Bénin pour tous, cas confirmé) via un script one-off.
- Les 12 documents déjà générés avec des champs vides ont été supprimés.
- Les 5 étudiants affectés par cette suppression ont été notifiés par email pour régénérer leurs documents.
- Fix de code :
ValidatePendingStudentsView.postappelle maintenantresolve_student_campus(student.ville_etude)(le même helper que l'import CSV, résolution parcity__iexactavec repli sur le campusBEN) sistudent.campus is Noneaprès la copie des champs. - Garde-fou supplémentaire :
generate_new_document(apps/document_type/utils.py) bloque désormais avec400sistudent.campus is None, plutôt que de générer silencieusement un document avec des champscampus_*vides. - Le cas est aussi devenu le premier check du registre
DataCheck(students_without_campus, voir Opérations manuelles) pour détecter toute récidive sans script one-off.
Si ça se reproduit (un étudiant avec campus=None malgré le fix) : vérifier GET /admin/data-checks/students_without_campus en premier plutôt que de ré-investiguer depuis zéro.
Erreurs de templates par classe/campus (ClassDocumentType / CampusDocumentType)¶
GET /documents/ (ou toute route utilisant DocumentTypeSerializer) renvoie 500¶
Symptôme : la liste des types de documents (GET /documents/) plante en 500, souvent juste après un déploiement touchant apps/document_type/serializers.py.
Cause réelle rencontrée : une édition de texte qui insère une nouvelle classe de serializer entre la classe Meta d'une classe existante et ses méthodes (get_name, get_created_by, etc.) reparente silencieusement ces méthodes sur la nouvelle classe — Python ne signale aucune erreur, une méthode définie au mauvais endroit dans un fichier est parfaitement valide syntaxiquement. C'est exactement ce qui est arrivé en insérant ClassDocumentTypeSerializer au milieu de DocumentTypeSerializer (corrigé par fix: restore DocumentTypeSerializer methods severed by ClassDocumentType edit).
Solution :
# Vérifier que les méthodes sont bien rattachées à la bonne classe
docker compose exec web python -c "
from apps.document_type.serializers import DocumentTypeSerializer
print([m for m in ('get_name', 'get_created_by', 'get_variables', 'get_variables_en') if hasattr(DocumentTypeSerializer, m)])
"
Si la liste est incomplète, relire tout le fichier apps/document_type/serializers.py pour repérer où les méthodes ont atterri — ne jamais supposer qu'une édition "insérée entre deux blocs" est tombée à l'endroit voulu ; après toute édition de ce type, relire le fichier complet et confirmer les frontières de classe avant de commiter.
Upload d'un template par classe plante avec cannot pickle 'BufferedRandom'¶
Symptôme : POST /documents/admin/classes/:classe/templates avec un fichier .docx attaché renvoie une 500 avec TypeError: cannot pickle '_io.BufferedRandom' object dans la stack trace.
Cause : le code faisait data = request.data.copy() pour injecter une valeur (ex: classe) dans le payload avant validation. .copy() sur un QueryDict multipart déclenche un deepcopy, qui échoue dès qu'un fichier uploadé (flux ouvert, non copiable) est présent dans la requête.
Solution : ne jamais appeler .copy() sur request.data d'une requête multipart pour lui injecter une valeur. Si la valeur doit venir d'ailleurs que du body (ex: un paramètre d'URL), la garder hors du serializer (l'utiliser directement dans la vue, comme classe dans ClassDocumentTypeListView.post) plutôt que de la forcer dans le payload — et si une injection est vraiment nécessaire, reconstruire un dict simple à partir des champs utiles au lieu de copier le QueryDict original.
Erreurs de droits (permissions fines)¶
Un admin qui utilisait un endpoint reçoit soudainement 403¶
Symptôme : un compte admin non-superadmin qui utilisait normalement une fonctionnalité (suspension d'étudiant, gestion des templates, réclamations, etc.) se met à recevoir 403 Forbidden du jour au lendemain, sans changement de mot de passe ni de session.
Cause : l'endpoint vient de recevoir un nouveau Right obligatoire (voir overview.md § Système de permissions) — ce n'est pas une régression, c'est le comportement voulu : le déploiement d'un droit plus fin sur un endpoint déjà utilisé échoue fermé par défaut pour tout compte qui n'a pas encore reçu ce droit explicitement.
Solution :
# Vérifier les droits actuels d'un admin
docker compose exec web python manage.py shell -c "
from apps.user.models import User
u = User.objects.get(email='admin@epitech.bj') # remplacer
print(f'superuser={u.is_superuser}')
print(list(u.rights.values_list('name', flat=True)))
"
# Accorder le droit manquant
docker compose exec web python manage.py shell -c "
from apps.user.models import User, Right, UserRight
u = User.objects.get(email='admin@epitech.bj')
r = Right.objects.get(name='can_manage_document_types') # remplacer
UserRight.objects.get_or_create(user=u, right=r)
print('Droit accordé')
"
Après tout déploiement qui ajoute un droit (seed_rights crée le Right mais ne l'accorde jamais automatiquement aux admins non-superadmin existants — seuls les 4 base_rights le sont), prévenir les admins concernés ou l'accorder proactivement.
Erreurs d'environnement de développement local¶
curl: Connection refused sur un port pourtant mappé par Docker (Colima)¶
Symptôme : docker ps affiche bien le port mappé (0.0.0.0:7010->8000/tcp) et le conteneur est healthy, mais toute requête vers localhost:7010 (ou le port concerné) échoue avec Connection refused côté hôte — alors que tout fonctionne si on exécute la requête depuis l'intérieur du conteneur.
Cause : sous Colima (alternative à Docker Desktop sur macOS), le transfert de port hôte↔VM peut se casser après un colima restart, même si docker ps continue d'afficher le mapping comme correct et que le conteneur est sain en interne.
Solution : redémarrer le conteneur, pas Colima :
Cela rétablit le transfert de port. Relancer colima restart une seconde fois ne corrige généralement pas le problème.
Ça ressemble à un bug CORS (Access-Control-Allow-Origin manquant) mais les headers CORS sont bien configurés¶
Symptôme : le frontend en dev affiche une erreur CORS dans la console, alors que la config CORS backend n'a pas changé et semble correcte.
Cause possible : un processus orphelin (laissé par un outil sans rapport — ex. un serveur de mock API) squatte silencieusement le même port qu'un service Dockerisé. Sur macOS, localhost résout souvent en IPv6 (::1) avant IPv4 — si le processus orphelin écoute sur le wildcard IPv6 et que le forwarder Docker écoute en IPv4, les requêtes partent silencieusement vers le mauvais processus, qui répond sans les headers CORS attendus. Ça ressemble exactement à un bug CORS mais n'en est pas un.
Solution : diagnostiquer avec lsof, pas docker ps (qui ne montre que ce que Docker croit avoir mappé, pas qui écoute réellement) :
Si un processus inattendu (ex. node) apparaît sur le port, le tuer (kill <pid>) et relancer le conteneur Docker concerné.
python manage.py test apps.student échoue avec TypeError: expected str, bytes or os.PathLike object, not NoneType¶
Symptôme : la commande plante dans unittest/loader.py (_get_directory_containing_module) au lieu de lancer les tests.
Cause : apps/ est un namespace package (pas de apps/__init__.py) — fonctionne normalement pour Django (qui utilise importlib), mais unittest.TestLoader.discover() a besoin de module.__file__ pour résoudre un label en pointillés comme apps.student, qui vaut None sur un namespace package.
Solution : cibler explicitement le module tests, pas le package de l'app :
# Ne fonctionne pas (ou plante) :
python manage.py test apps.student
# Fonctionne :
python manage.py test apps.student.tests apps.user.tests apps.document_type.tests
python manage.py test sans aucun argument ne fonctionne pas non plus dans ce repo (0 tests trouvés, mauvais rootdir de découverte).
makemigrations affiche "No changes detected" mais écrit quand même un fichier de migration¶
Symptôme : la commande affiche No changes detected, mais un nouveau fichier apparaît bien dans apps/<app>/migrations/.
Cause : artefact d'affichage observé plusieurs fois dans ce projet — pas un vrai bug qui empêcherait la migration de fonctionner.
Solution : ne jamais se fier au seul message affiché. Toujours vérifier :
Si le fichier existe et son contenu est cohérent avec le changement de modèle attendu, il est valide malgré le message — appliquer normalement avec migrate.
Erreurs de déploiement¶
Volume PostgreSQL introuvable¶
Symptôme : docker compose up échoue avec une erreur sur le volume externe.
Cause : Le volume epidocs-api_postgres_data n'existe pas sur le VPS.
Solution :
# Créer le volume
docker volume create epidocs-api_postgres_data
# Vérifier
docker volume inspect epidocs-api_postgres_data
Attention
Ne jamais supprimer ce volume en production ! Il contient toutes les données de la base.
Port 7010 déjà utilisé¶
Symptôme : docker compose up échoue car le port est déjà occupé.
Solution :
# Trouver le processus qui utilise le port
lsof -i :7010
# Arrêter l'ancien conteneur
docker stop epidocs_api && docker rm epidocs_api
La pipeline GitHub Actions ne démarre même pas ("recent account payments have failed...")¶
Symptôme : un push sur deploy ne déclenche aucun job visible, ou le job échoue immédiatement avec un message du type "recent account payments have failed or your spending limit needs to be increased".
Cause : ce n'est pas un problème de code ni de config du workflow — c'est un blocage au niveau facturation de l'organisation GitHub, qui empêche tous les jobs Actions de démarrer, quel que soit le repo.
Solution : seul un owner/admin de l'organisation GitHub peut corriger ça (Settings de l'organisation → Billing). Un token avec le scope OAuth admin:org ne suffit pas — il faut le rôle admin/owner réel de l'organisation, un axe séparé du scope OAuth. En attendant que ce soit résolu, voir la procédure de déploiement manuel via le VPS dans runbook.md.