EPIDOCS API — Opérations manuelles¶
Pourquoi cette page existe
Ce sont des tâches que Yémalin fait à la main (via un LLM en terminal ou directement), pas des features en libre-service dans le dashboard. Si Yémalin n'est plus disponible, quelqu'un — humain ou IA — doit pouvoir reprendre ces procédures sans deviner les prérequis, les identifiants d'environnement, ni les pièges déjà rencontrés.
Cette page ne contient QUE des procédures à reproduire — pas d'historique d'incidents ni de fixes déjà appliqués. Ce genre de contenu vit dans Erreurs courantes (symptôme → cause → solution, résolu) ou sur Présence Campus (architecture d'un sous-système). Si une étape ci-dessous référence un incident passé, c'est uniquement pour expliquer pourquoi la procédure est ce qu'elle est, jamais pour raconter l'incident en détail.
1. Récupérer les photos étudiants depuis l'intranet Epitech¶
But : l'intranet Epitech (intra.epitech.eu) héberge la photo officielle de chaque étudiant. EPIDOCS n'y a pas d'accès API — la seule façon de les récupérer est de rejouer une session de navigateur authentifiée.
Script : scripts/fetch_intra_photos.py (racine du repo epidocs-api).
Étape 1 — Obtenir le cookie de session intranet¶
- Se connecter à intra.epitech.eu dans un navigateur.
- Ouvrir les DevTools → onglet Network.
- Cliquer sur n'importe quelle requête vers
intra.epitech.eu. - Dans les headers de la requête, copier la valeur complète du header Cookie (plusieurs paires
nom=valeur;concaténées — le cookieuser=eyJ...est le JWT de session, c'est le plus important, mais copier tout le header tel quel).
Ce cookie est une credential personnelle
Il donne accès à la session Epitech de la personne connectée. Ne jamais le committer, l'écrire dans un fichier suivi par git, ni le garder après usage. Le script le lit uniquement depuis la variable d'environnement INTRA_COOKIE — ne pas créer de fichier .env avec cette valeur dedans.
Étape 2 — Préparer la liste des logins à traiter¶
Deux formats acceptés pour --logins-file :
- Un
.txtavec un login par ligne - Le CSV produit par
GET /student/admin/export-csv/(détecté automatiquement si la première ligne contient un en-têtelogin)
Pour exporter une liste depuis l'API (nécessite un cookie auth_token admin valide) :
curl -b "auth_token=TOKEN" \
"https://api.mydocs.epitools.bj/student/admin/export-csv/?section=PGE2&fields=login,nom,prenom" \
-o logins_pge2.csv
Étape 3 — Lancer le script¶
export INTRA_COOKIE='gdpr=1; user=eyJ...; autres_cookies=...' # coller le header complet
python3 scripts/fetch_intra_photos.py \
--logins-file logins_pge2.csv \
--section PGE2 \
[--limit 5] # optionnel, pour tester sur un échantillon avant un lot complet
[--out mon_fichier.zip] # optionnel, défaut: intra_photos_<section>.zip
unset INTRA_COOKIE # ne pas laisser la variable traîner dans le shell
--section ne fait QUE nommer le fichier ZIP de sortie
Il ne filtre rien côté script — la liste d'étudiants traitée est entièrement déterminée par --logins-file. Décision explicite (2026-09) : c'est Yémalin qui fournit toujours son propre fichier de logins déjà filtré, pas la peine de dupliquer cette logique dans le script.
Le script :
- lit chaque login, tente
GET https://intra.epitech.eu/file/userprofil/profilview/<login>.jpgavec le cookie - attend
0.4sentre chaque requête (REQUEST_DELAY_SECONDS) pour rester poli avec l'intranet — ne pas réduire ce délai sur un gros lot - écrit un ZIP local, un fichier par étudiant nommé
<login>.jpg— jamais d'upload automatique vers la prod, c'est un fichier local à vérifier avant d'agir
Étape 4 — Récupérer la photo d'UN SEUL étudiant (cas ponctuel)¶
Même procédure, mais avec un fichier de login à une seule ligne :
export INTRA_COOKIE='...'
echo "prenom.nom@epitech.eu" > /tmp/one_login.txt
python3 scripts/fetch_intra_photos.py --logins-file /tmp/one_login.txt --out /tmp/photo.zip
unset INTRA_COOKIE
unzip /tmp/photo.zip -d /tmp/photo_extraite/
Étape 5 — Uploader le résultat vers la plateforme¶
Le ZIP produit est directement compatible avec l'endpoint d'import en masse :
curl -b "auth_token=TOKEN" -X POST \
https://api.mydocs.epitools.bj/student/photos/bulk-upload/ \
-F "zip_file=@intra_photos_pge2.zip"
- Droit requis :
can_manage_attendance - Chaque fichier du ZIP doit être nommé
<login>.jpg/.png/.jpeg(extensions acceptées) — exactement la convention produite par le script - Un fichier dont le nom ne correspond à aucun login connu remonte dans
unmatchedde la réponse, sans bloquer les autres - Les fichiers
._xxx(métadonnées AppleDouble, systématiques dans un ZIP créé sur macOS) sont ignorés automatiquement
2. Import CSV des étudiants¶
POST /student/upload-csv/ (droit can_upload_user). Le parsing (process_csv dans apps/student/views.py) matche les colonnes par nom d'en-tête (_normalize_header, insensible à la casse/aux accents/à l'ordre — plus de position fixe), rejette l'import avec 400 { "missingColumns": [...] } si une colonne obligatoire manque, civilite est normalisée vers Monsieur/Madame via une table d'alias (M, Mr, Mme, Mlle, etc. → STUDENT_CIVILITE_ALIASES), et le champ campus est résolu via resolve_student_campus(ville_etude) — recherche Campus.objects.filter(city__iexact=ville_etude), avec repli sur le campus BEN (EPITECH Bénin) si aucune correspondance ou si ville_etude est vide.
Ce n'est plus un point sensible depuis le fix par nom d'en-tête
Avant ce fix, le parsing était positionnel et une colonne manquante/décalée dans le fichier source décalait silencieusement tous les champs suivants — d'où des corrections manuelles récurrentes sur civilite/promo. Ce n'est plus le cas : une colonne manquante bloque l'import avec un message clair plutôt que de décaler les données. Si un souci de civilité/promo apparaît malgré tout après un import, voir Erreurs courantes.
3. Valider des étudiants en attente (inscription publique)¶
Flux : un étudiant s'inscrit via le formulaire public → PendingStudent créé → un admin valide via POST /student/validate-students → Student créé/mis à jour.
curl -b "auth_token=TOKEN" -X POST https://api.mydocs.epitools.bj/student/validate-students \
-H "Content-Type: application/json" \
-d '{"logins": ["nouvel.etudiant@epitech.eu"]}'
Le campus de l'étudiant est résolu automatiquement à partir de sa ville d'étude (resolve_student_campus, même logique que l'import CSV — §2). Rien à faire de spécial ici ; voir Erreurs courantes si un étudiant validé se retrouve quand même sans campus.
4. Vérifications d'intégrité des données (DataCheck)¶
Framework générique pour détecter et corriger des anomalies de données sans écrire un script one-off à chaque fois. Registre dans apps/user/data_checks.py (liste CHECKS).
GET /admin/data-checks → liste tous les checks + leur nombre d'anomalies
GET /admin/data-checks/<check_id> → liste paginée des enregistrements concernés
POST /admin/data-checks/<check_id>/fix → corrige (body optionnel {"ids": [...]} pour cibler, sinon corrige tout)
Droit requis : can_manage_data_integrity. Chaque check expose idField (le champ qui identifie chaque enregistrement — variable selon le check, ex. login pour students_without_campus).
Check existant : students_without_campus (étudiants dont campus est NULL).
Utilisation typique : lister les checks → si count > 0 sur l'un d'eux, consulter le détail (GET .../<check_id>) pour voir qui est concerné → lancer le fix (sur une sélection ciblée via ids, ou sur tout).
Ajouter un nouveau check¶
Dans apps/user/data_checks.py, sous-classer DataCheck :
class MonNouveauCheck(DataCheck):
id = 'mon_check'
id_field = 'login' # ou 'id', selon serialize()
label = 'Description courte'
description = "Explication de l'anomalie et de son origine."
def get_affected(self):
return MonModel.objects.filter(...) # queryset des enregistrements en anomalie
def serialize(self, obj):
return {'login': obj.login, ...} # forme légère pour l'affichage
def fix_one(self, obj):
obj.champ = ...
obj.save(update_fields=['champ'])
return "résumé de la correction"
Puis l'ajouter à la liste CHECKS en bas du fichier. Pas de migration nécessaire, pas de nouvel endpoint — les 3 vues génériques s'appliquent automatiquement à tout ce qui est dans CHECKS.
5. Corriger les infos d'un étudiant sans l'interpeller¶
PATCH /student/admin/<login>/edit (droit can_edit_student_info). Body : un ou plusieurs champs parmi :
id_etudiant, civilite, nom, prenom, ville, pays, date_naissance,
ville_naissance, ville_etude, nationalite,
prenom_mere, nom_mere, prenom_pere, nom_pere, specialite
Volontairement exclus de cette liste (ils ont leur propre flux dédié, ne pas les ajouter ici) : login, section, promo, annee, campus, account_suspended, suspension_reason, PGE3_status/PGE5_status/Codingacademy_status.
La réponse inclut generatedDocumentsCount — le nombre de documents déjà générés pour cet étudiant. Aucune suppression automatique (corriger une info n'invalide pas forcément un document déjà émis), mais ce chiffre permet de savoir immédiatement s'il faut aller vérifier via GET /documents/admin/student-history/<login>/ et éventuellement supprimer via DELETE /documents/admin/delete-document/<tracking_code>/ (endpoints existants, pas dupliqués). Chaque modification est tracée dans l'audit log (action: "student_info_edited", détail des champs avant/après).
6. Envoyer le QR/badge d'un étudiant précis vers une adresse email personnelle¶
Cas d'usage : un étudiant n'a plus accès à sa boîte @epitech.eu (diplômé, compte désactivé, etc.) mais a besoin de son badge de présence.
curl -b "auth_token=TOKEN" -X POST \
https://api.mydocs.epitools.bj/student/admin/<login>/qrcode/send \
-H "Content-Type: application/json" \
-d '{"email": "adresse.perso@exemple.com"}'
emailoptionnel — si omis, renvoie vers le login@epitech.eucomme d'habitude.- Rien n'est stocké en base (pas de champ email ajouté au modèle
Student) — envoi ponctuel. - Voir Présence Campus pour le détail (audit log, nature asynchrone de l'envoi).
7. Créer un chaperon (surveillance de nuit)¶
Identique à créer un agent de sécurité, avec role: "chaperon" dans le body :
curl -b "auth_token=TOKEN" -X POST https://api.mydocs.epitools.bj/attendance/guards \
-H "Content-Type: application/json" \
-d '{"name": "Chaperon Dortoir A", "role": "chaperon"}'
Le PIN généré est retourné une seule fois dans la réponse (pin) — le communiquer immédiatement à la personne concernée, il n'est jamais réaffichable après coup (seul le hash est stocké). Voir Présence Campus pour le suivi de l'activité par personne.
8. Miner une session de test en local (sans navigateur)¶
Utile pour tester un endpoint authentifié via curl sans passer par le flow Azure AD complet. Local/dev uniquement — ne jamais faire ça en production (créer une vraie session admin manuellement en base prod).
Session admin (cookie auth_token)¶
docker compose exec -T web python manage.py shell -c "
from apps.user.models import UserSession, User
import jwt, uuid
from django.conf import settings
from django.utils import timezone
admin = User.objects.filter(is_superuser=True).first()
session = UserSession.objects.create(
session_id=uuid.uuid4(), role='admin', is_valid=True,
expires_at=timezone.now() + timezone.timedelta(minutes=10), user=admin,
)
token = jwt.encode(
{'session_id': str(session.session_id), 'exp': session.expires_at, 'iat': timezone.now()},
settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM,
)
print(token)
"
# Puis : curl -b "auth_token=<token>" http://localhost:7010/...
session_id doit être un vrai UUID
UserSession.session_id est un UUIDField — passer une chaîne arbitraire ('cancel-test-session') lève une ValidationError lors du .create(). Toujours uuid.uuid4().
Session agent PIN (cookie attendance_session)¶
docker compose exec -T web python manage.py shell -c "
from apps.attendance.models import SecurityGuard, AttendanceSession
import jwt
from django.conf import settings
from django.utils import timezone
guard = SecurityGuard.objects.first() # ou en créer un de test
session = AttendanceSession.objects.create(guard=guard)
token = jwt.encode(
{'session_id': str(session.session_id), 'exp': session.expires_at, 'iat': timezone.now()},
settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM,
)
print(token)
"
# Puis : curl -b "attendance_session=<token>" http://localhost:7010/attendance/...
Le champ UserSession.student n'existe pas — c'est student_login (un CharField, pas une FK). Une tentative UserSession.objects.create(student=student, ...) lève TypeError: unexpected keyword arguments: 'student'.
9. Envoyer un email depuis un script one-off (manage.py shell)¶
send_mail_async() (apps/student/utils.py) lance l'envoi dans un thread daemon et retourne immédiatement — conçu pour un process serveur long-lived (Gunicorn/runserver). Dans un script manage.py shell -c "...", le process principal se termine presque immédiatement après le corps du script, avant que le thread daemon ait fini son requests.post(...) vers Resend → l'email ne part jamais, silencieusement.
Solution : appeler directement la fonction synchrone interne _send_mail(...) (mêmes arguments que send_mail_async) plutôt que send_mail_async quand on écrit un script ponctuel.
from apps.student.utils import _send_mail
_send_mail(['destinataire@exemple.com'], "Sujet", "<p>Corps HTML</p>")
10. Corriger des données directement en base (dernier recours)¶
Procédure à suivre quand un bug de données est découvert en production (des enregistrements existants sont incorrects, pas juste un bug de code à corriger pour l'avenir) :
- Root-cause d'abord — comprendre précisément pourquoi la donnée est incorrecte avant de la corriger à la main. Un exemple concret de cette investigation est documenté dans Erreurs courantes.
- Corriger les données existantes — script one-off ponctuel (
manage.py shell), ou mieux : ajouter un check au registre DataCheck (§4) si le cas est susceptible de se reproduire. - Nettoyer les artefacts déjà produits avec la mauvaise donnée (documents générés, emails déjà envoyés avec des infos fausses, etc.).
- Notifier les personnes affectées si des artefacts ont dû être supprimés ou invalidés.
- Livrer un correctif de code qui empêche la récidive à la source — pas seulement une correction des données existantes.
- Si le type d'incident est générique (pas un cas unique), l'ajouter au registre DataCheck (§4) plutôt que de refaire un script one-off la prochaine fois.
11. Rollover annuel — faire passer les étudiants à la classe suivante¶
But : contrairement à current_academic_year (le placeholder d'année scolaire dans les documents générés, calculé automatiquement à partir de la date système — rien à faire pour ça), le passage effectif des étudiants d'une classe à l'autre (PGE1→PGE2, etc.) est entièrement manuel. Rien ne se déclenche automatiquement : sans cette étape, les étudiants restent dans leur ancienne section indéfiniment.
curl -b "auth_token=TOKEN" -X POST https://api.mydocs.epitools.bj/student/migrate-students/ \
-H "Content-Type: application/json" \
-d '{
"emails": ["etudiant1@epitech.eu", "etudiant2@epitech.eu"],
"new_section": "PGE2",
"annee": "2026/2027"
}'
new_sectiondoit être une clé existante desettings.CLASSES(PGE1,PGE2, ... — sinon400avec la liste des sections valides).anneeest une chaîne libre ("YYYY/YYYY+1"par convention) — aucune validation de cohérence avec la date réelle, une faute de frappe ici n'est pas détectée.- Droit requis :
can_migrate_students.
Pour les finissants (PGE3/PGE5), marquer aussi le diplôme obtenu — bascule certificat_scolarite → attestation_scolarite pour ces étudiants (voir overview.md § Types de documents) :
curl -b "auth_token=TOKEN" -X POST https://api.mydocs.epitools.bj/student/admin/set-graduation-status/ \
-H "Content-Type: application/json" \
-d '{ "emails": ["finissant1@epitech.eu"], "section": "PGE3" }'
section doit être exactement "PGE3" ou "PGE5".