Skip to main content

Établissements & structures — détails techniques

Fiche fonctionnelle

Description et usage : Établissements & structures.

Rangement des structures par académie

La vue arborescente s'appuie sur la hiérarchie de sous-structures d'Open ENT (relation Neo4j (:Structure)-[:HAS_ATTACHMENT]->(:Structure)). La mise en place de ce rangement est assurée par le script scripts/organize-structures-by-academy.mjs (dépôt open-ent-mods, Node ESM, sans dépendance).

Ce que fait le script

Constat : les établissements sont déjà regroupés par type via des conteneurs globaux (COLLEGE, LYCEE, PRIMAIRE, MATERNELLE) mais pas par académie. Le script crée le niveau académique manquant et y rattache ces conteneurs, sans déplacer le moindre établissement :

Académie de <Nom>
├── Collèges – <Nom> (= conteneur COLLEGE renommé)
├── Lycées – <Nom> (= conteneur LYCEE renommé)
└── DSDEN – <Nom> (niveau départemental, écoles)
├── Écoles élémentaires – <Nom> (= conteneur PRIMAIRE renommé)
└── Écoles maternelles – <Nom> (= conteneur MATERNELLE renommé)

Les collèges et lycées relèvent directement de l'académie (rectorat) ; les écoles maternelles et élémentaires relèvent de la DSDEN (DASEN).

Mécanismes (API officielle de l'annuaire, super-admin)

OpérationEndpoint
Créer un conteneurPOST /directory/school {name} (le feeder génère id/externalId, pose source=MANUAL et crée les ProfileGroups)
Renommer une structurePUT /directory/structure/:id {name}
Rattacher une sous-structurePUT /directory/structure/:enfant/parent/:parent (crée HAS_ATTACHMENT, avec garde anti-boucle)

Les libellés d'académie (academy) et les types sont lus dans Neo4j. Le script est idempotent (re-jouable sans effet de bord) et tourne en DRY-RUN par défaut ; ajouter --apply pour exécuter.

node scripts/organize-structures-by-academy.mjs            # simulation
node scripts/organize-structures-by-academy.mjs --apply # exécution

Variables d'environnement : ENT_URL, ENT_USER, ENT_PASSWORD, NEO4J_URL, ONLY_ACADEMY.

Mode « réutilisation »

Ce mode suppose une académie unique (réutilisation des conteneurs de type existants). Pour plusieurs académies, un mode « recréer par académie + déplacer les établissements » serait nécessaire. Les structures purement administratives (rectorat, inspections, services) restent au niveau racine.

Actions groupées sur une sélection d'établissements rattachés

Écran apps/dashboard/src/app/structure-info (StructureChildrenTable.tsx pour la sélection, BulkStructureActionsDialog.tsx pour les quatre actions).

Périmètre côté serveur

Aucune route ne se fie à la liste de structureId envoyée par le client. Chacune vérifie le périmètre réel de l'appelant via GET /directory/structure/admin/list (tous les établissements pour un SUPER_ADMIN, le scope de la fonction — hérité aux rattachés — pour un ADMIN_LOCAL), grâce au helper partagé src/pages/api/_perimeter.ts (resolveAdminPerimeter, validateBulkTargets) — le même que celui du paramétrage en masse des droits applicatifs : tout structureId hors périmètre est rejeté (403), et le nombre d'établissements par requête est plafonné à MAX_STRUCTURES = 40 (au-delà, l'appelant doit découper sa sélection).

ADMIN_COLLECTIVITE n'apparaît dans aucune condition CASL de ce périmètre : cette fonction n'accorde par elle-même aucun droit d'écriture entcore (mesuré : 0 rôle appregistry pour un compte ne portant que cette fonction). Le panneau d'actions groupées reste néanmoins visible pour ce profil côté frontend (page-view.tsx : canBulkManage) — c'est le 403 serveur qui protège réellement, pas l'affichage. Un compte de collectivité opérationnel doit porter aussi ADMIN_LOCAL avec héritage sur la structure de tête du territoire.

Les quatre actions

ActionRouteDétail
ApplicationPOST /api/admin/access-bulk (existante, réutilisée telle quelle)Boucle un appel par profil coché, levels: { [displayName]: niveau }
BlocagePUT /api/users/block (étendue)Accepte structureId (historique) ou structureIds: string[] ; proxifie PUT /directory/structure/:id/profile/block par établissement
Export CSVPOST /api/users/export-bulk (nouvelle)Boucle GET /directory/export/users?format=csv par établissement, fusionne en un seul tableau (colonnes Établissement/UAI ajoutées) via un analyseur CSV tolérant aux guillemets ; bascule sur un fichier à sections séparées si les en-têtes diffèrent d'un établissement à l'autre
RGPDDELETE /api/users/reset-preference (étendue)Accepte structureId: string | 'all' (historique) ou structureIds: string[] ; boucle la requête Cypher REMOVE uac.<application> par établissement

Chaque route renvoie un résultat par établissement (applied / skipped / error), affiché tel quel dans le dialogue — jamais un simple booléen global.

Ouverture d'un établissement et compte de direction

L'assistant /structure-info/new-etablissement écrit par deux appels seulement.

Création de la structure

POST /api/structures (Pages Router, pages/api/structures/index.ts) : relit la fiche du référentiel national, refuse un UAI déjà présent, crée l'école (POST /directory/school) puis ses classes (POST /directory/class/:schoolId), et enrichit le nœud Structure de sa position (latitude/longitude/uaiRef) avec un GeoJSON minimal poussé côté admin-dashboard.

Le name n'est transmis que si l'administrateur a modifié le nom pré-rempli ; sinon le serveur applique la norme <nom>-<commune>, et renvoie le nom réellement retenu.

Création du chef d'établissement

POST /api/structures/:id/direction (App Router, app/api/structures/[id]/direction/route.ts), corps { firstName, lastName, email, sendMail }.

#Sous-étapeAppel entcore
1Fonction « Chef d'établissement »GET/POST /directory/positions (UserPosition, source MANUAL)
2ComptePOST /directory/api/usertype=Personnel, structureId, positionIds
3Adresse emailPUT /directory/user/:id
4Administrateur localPOST /directory/user/function/:idADMIN_LOCAL, scope:[structureId], inherit:"s"
5Groupe Direction (si présent)GET /directory/group/admin/list puis POST /directory/user/group/:userId/:groupId
6Courriel de bienvenuetransporteur du dashboard (createTestTransporter)

Pourquoi une route d'orchestration. Ces six opérations existent déjà séparément dans le dashboard, mais n'ont de sens qu'ensemble : un compte sans ADML est inutile, un ADML qui n'a jamais reçu son code ne se connectera pas. Les enchaîner côté serveur évite qu'un onglet fermé au mauvais moment laisse un établissement à moitié ouvert.

Pourquoi ce n'est pas tout-ou-rien. Seule la création du compte est bloquante. Les autres sous-étapes rendent compte de leur sort dans un tableau steps: { key, label, ok, skipped?, note?, error? }[] sans faire échouer l'appel : l'échec le plus probable — le SMTP injoignable — ne doit pas annuler un compte déjà créé, qu'entcore ne sait pas défaire proprement (son identifiant resterait pris et le prochain essai buterait sur l'unicité). L'interface affiche alors identifiant et code d'activation à recopier.

Deux pièges du modèle entcore

  • createUser ne prend pas d'adresse email. DirectoryController#createUser ne lit que firstname, lastname, type, birthDate, childrenIds, positionIds et le rattachement (classId / structureId) : l'adresse ne peut être posée qu'en second appel.
  • Le groupe « Direction » n'existe pas sur une structure créée à la main. Le DirectionGroup natif et le FunctionGroup « DIRECTION-Func » sont tous deux produits par l'import AAF. D'où le recours à une UserPosition, portée par la structure et créable depuis l'interface, pour dire que ce compte dirige l'établissement ; le rattachement au groupe n'est tenté que là où il existe.

Reprise de l'étape sur un compte existant

POST /api/users/:id/invitation (app/api/users/[id]/invitation/route.ts), corps { structureId, recipient?, grantAdml? }. Même forme de réponse que la route ci-dessus — le tableau steps s'affiche avec le même composant.

Les sous-étapes vivent dans utils/directionAccount.ts, partagé par les deux routes : ensureChefPosition, addPositionToUser, grantAdminLocal, attachToDirectionGroup, sendDirectionWelcomeMail, plus le contrôle d'accès callerMayActOn (repris de /api/users/[id]/activation-mail, qui l'importe désormais au lieu d'en garder une copie).

Un GET sur la même route prépare le dialogue : le compte est-il encore activable, quelle adresse porte-t-il, dans quels établissements est-il, et y est-il déjà administrateur local. La fiche ne répond à aucune de ces questions de façon sûre, et l'IHM ne doit pas proposer d'accorder un droit déjà accordé.

Trois garde-fous côté serveur :

  • Compte encore activable — sans code d'activation, le courriel n'a rien à transmettre : 409, avec renvoi vers « Code renouvellement ».
  • Établissement rattaché au compte — le courriel le nomme, et les droits y seraient sinon posés hors de tout rattachement : 400.
  • callerMayActOn — ce chemin n'envoie pas le courriel via entcore, il reproduit donc son filtre AnyAdminOfUser ; sans quoi la route serait la porte dérobée par laquelle n'importe quel compte connecté ferait envoyer un code d'activation. L'attribution de l'ADML, elle, reste filtrée par entcore (AddFunctionFilter).

Deux différences de comportement avec la création :

  • L'adresse n'est écrite sur la fiche que si le compte n'en avait aucune. L'administrateur qui écrit ponctuellement ailleurs (un secrétariat, le temps d'une prise de fonction) ne demande pas pour autant à remplacer celle du compte.
  • Les positions sont fusionnées, jamais remplacées. PUT /directory/user/:id écrase l'ensemble des positionIds : addPositionToUser repart de userPositions relu sur la fiche, et abandonne l'étape si cette liste n'est pas lisible — écraser les fonctions déjà portées par le compte serait pire que de ne rien faire.

Le bouton, lui, est réservé au super-administrateur (currentUser.permissions.isSuperAdmin) et n'apparaît que sur un compte non activé.

Courriel de bienvenue

Gabarit dédié utils/directionWelcomeMail.ts, et non massmail.mail.txt du thème : le chef d'établissement reçoit ce message une seule fois, au moment de l'ouverture de son établissement, et doit y lire le nom de la structure et son rôle d'administrateur local — pas un simple rappel d'identifiant. Le gabarit vit dans le dashboard plutôt que dans assets/themes/<skin>/template/ pour n'être à recopier dans aucun thème ; en contrepartie il part par le transporteur du dashboard (Administration ▸ Configuration ▸ Email), comme le fait déjà /api/users/[id]/activation-mail pour une adresse hors compte.

L'adresse publique de l'ENT est reconstruite depuis l'hôte du navigateur (X-Forwarded-Host) : l'appel serveur à serveur ne connaît que l'hôte interne du cluster, et un courriel invitant à se connecter sur http://springboard:8090 n'aiderait personne.

Gabarit éditable. Le texte du courriel — objet, titre, accroche, paragraphe sur le rôle, libellé du bouton, signature, mention de pied de page — et son habillage — couleur d'accent, nom de marque, logo — sont réglés dans Administration ▸ Configuration ▸ ENT ▸ Emails, onglet « Invitation chef d'établissement ». Ils sont stockés par admin-dashboard dans la collection Mongo email_templates sous la clé direction-invitation (EmailTemplateResource.defaultsFor), et lus par fetchInvitationTemplate.

Les champs texte acceptent les jetons {{etablissement}}, {{prenom}}, {{nom}}, {{identifiant}}, {{code}} et {{url}}, substitués par applyMailTokens. Un jeton inconnu est laissé tel quel plutôt que vidé : l'administrateur qui s'est trompé de nom le voit dans l'aperçu, au lieu d'un trou silencieux dans le courriel envoyé.

Deux champs vont par paire avec leur variante « sans droits » — subjectNoAdmin et introNoAdmin : l'invitation peut partir sans accorder l'ADML, et annoncer un rôle que le compte n'a pas mènerait son titulaire à un écran d'administration vide. Le paragraphe adminNote disparaît alors lui aussi.

Trois niveaux de repli, pour qu'un courriel porteur d'un code d'activation parte toujours :

  1. le gabarit enregistré ;
  2. les champs de marque du gabarit demo-request — celui-ci existe depuis plus longtemps, et le courriel garde ainsi les couleurs de la plateforme pendant toute la fenêtre de déploiement, avant qu'admin-dashboard ne connaisse la nouvelle clé (il répond alors un objet vide). Les champs de texte ne sont jamais empruntés à demo-request, qui parle d'une demande de découverte ;
  3. DIRECTION_INVITATION_DEFAULTS, copie côté dashboard des valeurs par défaut du serveur.

Un champ enregistré vide est une valeur voulue (une signature effacée) et n'est pas remplacé par le défaut ; seul un champ absent l'est.

Résolution du nom d'établissement. GET /directory/structure/:id n'est pas fiable : sa réponse dépend du vhost servi, et c'est ce qui faisait tomber le courriel sur son repli « votre établissement » en production. resolveStructureName retombe donc sur GET /directory/structure/admin/list, celle qu'utilise déjà l'annuaire des structures. Mieux : les deux appelants passent le nom qu'ils connaissent déjà — la route d'invitation le lit dans les structureNodes de la fiche, l'assistant transmet le nom retenu à la création.

Le rôle annoncé est conditionnel (isAdml) : l'invitation peut partir sans que les droits d'administrateur local soient accordés, et annoncer un rôle que le compte n'a pas mènerait son titulaire à un écran d'administration vide.

Couverture de tests

La vue arborescence est couverte par les tests e2e du dashboard — dossier apps/dashboard-e2e/src/modules/03_admin.

Scénario e2e — voir le détail

Admin — gestion des structures 03_gestion_structures.spec.ts

  • liste des structures, recherche, détail, métriques
  • vue arborescence par académie (?view=tree)

Admin — opérations de masse 07_operations_masse.spec.ts

  • blocage/déblocage, export CSV, réinitialisation RGPD (établissement courant)
  • actions groupées sur une sélection d'établissements rattachés (structure-info) : découverte dynamique d'un établissement de tête dans le jeu de données, sélection par case à cocher, ouverture des quatre onglets — non destructif (aucune action confirmée)
  • cas négatif : un administrateur local simple ne voit ni case à cocher ni bouton « Actions groupées »

Admin — ouverture d'un établissement et compte de direction 16_creation_chef_etablissement.spec.ts

  • lien pré-rempli : UAI non modifiable, effectif et chef d'établissement repris de l'URL
  • parcours complet jusqu'à la confirmation : identifiant et code d'activation affichés
  • étape facultative passée : l'établissement reste créé, aucun compte de direction
  • courriel en échec : le compte existe et son code reste lisible
  • les deux écritures (POST /api/structures et POST /api/structures/:id/direction) sont interceptées : elles ne se défont pas proprement, et ce test a vocation à être rejoué pour régénérer les captures de la documentation

Admin — invitation depuis la fiche utilisateur (même fichier)

  • présence du bouton et pré-remplissage du dialogue (établissement, destinataire, case des droits cochée quand le compte n'est pas encore ADML)
  • envoi et compte rendu par action, code d'activation toujours affiché
  • cas négatif : aucun bouton sur un compte déjà activé
  • la fiche et les deux appels d'invitation sont simulés ; /api/me ne l'est pas — c'est de lui que dépend l'affichage du bouton, et le simuler reviendrait à tester le décor plutôt que la règle. Le test se passe quand le profil connecté n'est pas super-administrateur.