Skip to main content
Unlisted page
This page is unlisted. Search engines will not index it, and only users having a direct link can access it.
Exercices

Pilotage actif en direct — module exercizer (D3)

Fonctionnement technique de l'écran de pilotage actif en direct d'un sujet planifié (SubjectScheduled) : les 5 nouvelles routes REST, le canal WebSocket applicatif qui pousse les événements en temps réel, le repli HTTP si ce canal échoue, et le modèle de données associé.

Fiche fonctionnelle

Ce que voit l'utilisateur (scénario enseignant / élève) : Exercices — Piloter une séance en direct ; actions autorisées par profil : Exercices — Capacités par profil.

Vue d'ensemble

Un enseignant qui a planifié un sujet peut, pendant la fenêtre horaire prévue (entre begin_date et due_date), suivre l'avancement de chaque élève et agir sur la séance : pause/reprise, prolongation de temps, remise forcée, message ciblé. Deux canaux coexistent :

  • les actions de pilotage passent par les routes REST habituelles du module (port 8105) ;
  • la diffusion en direct des changements passe par un WebSocket applicatif dédié (port 8106), avec un repli par sondage HTTP si ce canal n'est pas disponible.

Le WebSocket est push-only : le client n'envoie jamais d'action dessus, seulement les routes REST en émettent. Le canal ne sert qu'à prévenir les navigateurs connectés qu'un événement a eu lieu ; les calculs (statut consolidé, temps restant) restent calculés côté serveur.

Routes REST

Toutes scopées par :id = subjectScheduledId, protégées par le filtre SubjectScheduledOwner (seul l'enseignant propriétaire du sujet planifié peut piloter sa séance) :

RouteUsage
GET /subject-scheduled/:id/pilotageétat consolidé de la séance : statut de chaque élève, temps restant calculé côté serveur (buildPilotageStateResponse)
PUT /subject-scheduled/:id/pilotage/pausemet la séance en pause (tous les élèves)
PUT /subject-scheduled/:id/pilotage/resumereprend une séance en pause
PUT /subject-scheduled/:id/pilotage/extend-timeprolonge le temps ; corps { minutes, studentId? }studentId absent = toute la classe
PUT /subject-scheduled/:id/pilotage/force-submitforce la remise de la copie d'un élève ; corps { studentId }
POST /subject-scheduled/:id/pilotage/messagediffuse un message, non persisté ; corps { message, studentId? }

Les actions relance (POST /subject-copy/custom/reminder) et exclusion (POST /subject-copy/action/exclude) existaient déjà : aucune route backend ajoutée pour elles, seulement exposées depuis le nouvel écran de pilotage.

Chaque route qui modifie un état (pause, resume, extend-time, force-submit, message) répond en REST et appelle broadcastPilotage(subjectScheduledId, event), qui relaie l'événement sur le WebSocket si un PilotageWebSocketController est configuré (voir plus bas — il peut être null si le bloc real-time est absent de la configuration, auquel cas seul le repli HTTP fonctionne).

Règles métier appliquées côté backend

  • extend-time ignore silencieusement les copies déjà rendues (WHERE submitted_date IS NULL) plutôt que de faire échouer toute l'action de classe pour un seul élève déjà rendu.
  • force-submit répond en erreur (exercizer.pilotage.force.submit.refused) si la copie est déjà rendue ou si l'élève n'a pas de copie sur cette séance — c'est le backend qui refuse, pas seulement le frontend qui masque le bouton.
  • message n'est pas persisté : diffusé uniquement aux sockets connectés au moment de l'envoi, hors périmètre CCTP (ce n'est pas un canal de messagerie).

Mécanisme WebSocket

Pattern repris de collaborative-wall

Aucun bridge générique Vert.x EventBus↔navigateur n'existe dans le socle commun entcore. Le pilotage réplique donc le pattern déjà utilisé en production par collaborative-wall (WallWebSocketController) plutôt que le pattern chat-nats (fan-out multi-instances via NATS) :

  • authentification par cookie de session signé (CookieHelper / UserAuthFilter.SESSION_ID, résolu en UserInfos via UserUtils.getSession) ;
  • une map en mémoire subjectScheduledId → wsId → ServerWebSocket (PilotageWebSocketController, modules/exercizer/src/main/java/fr/openent/exercizer/controllers/PilotageWebSocketController.java) ;
  • diffusion texte (JSON encodé) à tous les sockets connectés sur un même subjectScheduledId — enseignant et élèves reçoivent le même flux, chacun filtrant côté client ce qui le concerne (scope / studentId dans le payload) ;
  • le contrôle d'accès à la connexion réutilise subjectScheduledService.canAccessPilotage (403 si l'utilisateur n'a pas le droit de rejoindre ce canal).
// PilotageWebSocketController.java
private final Map<String, Map<String, ServerWebSocket>> subjectScheduledIdToWSIdToWS = new HashMap<>();
Limite connue : mono-instance

Contrairement à chat-nats, cette implémentation reste mono-instance (map en mémoire, pas de fan-out NATS). Si exercizer tourne un jour en plusieurs répliques sans affinité de session, un élève connecté à une autre instance que l'enseignant ne recevra pas les diffusions. C'est une limite documentée dès l'origine (cf. spec D3), pas corrigée dans cette itération — la même limite s'applique déjà à collaborative-wall avec le même pattern. Point à vérifier auprès de l'équipe ops avant de généraliser ce pattern à un module qui tournerait effectivement en plusieurs répliques en production/recette.

Port dédié, configurable

Le WebSocket écoute sur un port distinct du port HTTP principal du module, déclaré dans le bloc real-time de la configuration du module (starter/ent-core.yaml) :

- name: fr.openent~exercizer~${EXERCIZER_VERSION}
config:
port: 8105 # HTTP principal (routes REST habituelles)
real-time:
port: 8106 # WebSocket applicatif dédié au pilotage

Si le bloc real-time est absent, Exercizer.initExercizer ne monte pas de PilotageWebSocketController : les routes REST de pilotage restent pleinement fonctionnelles, seule la diffusion en direct est indisponible (repli HTTP systématique côté client).

Le port n'est pas découvert dynamiquement : comme collaborative-wall (qui code en dur son port 9091 côté front dans useWsMode.ts), le frontend le connaît en dur (PILOTAGE_WS_PORT = 8106 dans PilotageService.ts).

Repli HTTP (sondage)

Deux directives frontend ouvrent le WebSocket et basculent sur un sondage HTTP s'il n'est pas ouvert sous 5 secondes (WS_OPEN_TIMEOUT_MS), avec un sondage toutes les 20 secondes (POLL_INTERVAL_MS) tant que le canal reste indisponible :

  • écran enseignant — teacherDashboardPilotage.ts : au moindre événement WS reçu (hors message), redemande simplement l'état consolidé via GET …/pilotage (le calcul reste serveur) ; en repli, interroge cette même route toutes les 20 s ;
  • écran élève — subjectPerformCopyPilotage.ts : ne peut pas appeler GET …/pilotage (réservé au propriétaire du sujet), donc applique les événements WS en local avec les mêmes formules que le backend, et en repli réinterroge les deux endpoints élève déjà existants (GET /subjects-scheduled-by-subjects-copy/:offset, GET /subjects-copy) — aucun nouvel endpoint n'a été nécessaire côté élève.
// PilotageService.ts — construction de l'URL WS, dev vs prod
function buildUrl(): string {
return isLocalhost
? 'ws://' + hostname + ':' + PILOTAGE_WS_PORT + '/exercizer/pilotage/' + subjectScheduledId
: 'wss://' + host + '/exercizer/pilotage/realtime/' + subjectScheduledId;
}

Piège CSP : ${WS_URL} jamais défini avant ce chantier

ent-core.yaml référence depuis longtemps ${WS_URL} dans la directive connect-src de la content-security-policy :

connect-src 'self' ${FRONTEND_DEV_URL:-} ${FRONTEND_URL} ${WS_URL} https://*.screeb.app …

Cette variable n'était définie dans aucun .env avant l'implémentation du pilotage — elle figurait dans le gabarit CSP sans jamais avoir été câblée. Tant qu'aucun module n'ouvrait de WebSocket applicatif depuis le navigateur, l'absence passait inaperçue (${WS_URL} se résolvait en chaîne vide, sans effet visible). Avec le pilotage, une connexion new WebSocket(...) sans WS_URL dans connect-src est bloquée par le navigateur :

Refused to connect to 'ws://localhost:8106/exercizer/pilotage/123' because it violates the following
Content Security Policy directive: "connect-src 'self' ...".

Le symptôme est trompeur : le port 8106 répond correctement en local (curl/test manuel du socket suffit à s'en convaincre), la route REST de pilotage fonctionne, seul le navigateur refuse silencieusement la connexion WS — rien côté serveur ne signale l'échec.

Fix appliqué — définir WS_URL dans les fichiers .env.localhost* du dépôt starter/ :

# starter/.env.localhost
WS_URL=ws://localhost:*
À retenir pour tout futur WebSocket applicatif

Un module qui ouvre un WebSocket depuis le navigateur (sur le modèle collaborative-wall / chat-nats / exercizer) doit vérifier que WS_URL est bien défini dans l'environnement cible (dev et production/recette), pas seulement que sa route/son port répond. ${WS_URL} est présent dans le gabarit CSP d'ent-core.yaml depuis longtemps mais n'était câblé nulle part avant ce chantier — un futur module qui s'appuierait sur sa seule présence dans le YAML sans vérifier sa valeur effective retombera dans le même piège. En production, la valeur doit lister les hosts WebSocket publics réels (schéma wss:// + host), pas le joker ws://localhost:* qui ne convient qu'au développement local.

Modèle de données

Migration 036-add-pilotage-session-state.sql :

-- État de la séance elle-même (distinct de l'état de chaque copie élève).
ALTER TABLE exercizer.subject_scheduled
ADD COLUMN session_state VARCHAR(20) NOT NULL DEFAULT 'en_cours',
ADD CONSTRAINT subject_scheduled_session_state_check CHECK (session_state IN ('en_cours', 'en_pause')),
ADD COLUMN paused_at TIMESTAMP NULL,
ADD COLUMN paused_duration_seconds INTEGER NOT NULL DEFAULT 0;

-- Prolongation de temps et traçabilité d'une remise forcée, par copie élève.
ALTER TABLE exercizer.subject_copy
ADD COLUMN extra_time_minutes INTEGER NOT NULL DEFAULT 0,
ADD COLUMN is_forced_submit BOOLEAN NOT NULL DEFAULT FALSE;
ColonneTableRôle
session_statesubject_scheduleden_cours (défaut, comportement inchangé) / en_pause
paused_atsubject_scheduledhorodatage du début de la pause en cours (NULL si pas en pause)
paused_duration_secondssubject_scheduledcumul des pauses déjà terminées ; la pause en cours s'ajoute au calcul à la volée, pas à cette colonne
extra_time_minutessubject_copyminutes ajoutées à l'échéance de cette copie (prolongation individuelle ou de classe)
is_forced_submitsubject_copydistingue une remise forcée par l'enseignant d'une remise volontaire de l'élève

Le temps restant n'est jamais stocké : il est recalculé à chaque appel (dueDate + extraTimeMinutes×60000 + pausedDurationSeconds×1000 − now), aussi bien côté backend (buildPilotageStateResponse) que côté frontend élève (subjectPerformCopyPilotage.ts, computeRemainingLabel, mêmes formules), pour rester cohérent malgré les fuseaux horaires et une pause en cours au moment du calcul.

Diagnostic

SymptômeCause probableVérification
WS refusé silencieusement, écran bascule en sondage (exercizer.pilotage.connection.poll)WS_URL absent/mal formé dans la CSPconsole navigateur : violates … connect-src
Écran élève ne reçoit rien mais le sondage rattrape sous 20 scomportement attendu du repli, pas un bugvérifier wsConnected dans le scope Angular
Élève sur une autre instance que l'enseignant ne reçoit jamais l'événement (WS ouvert des deux côtés)plusieurs répliques exercizer sans affinité de session (limite mono-instance connue)topologie de déploiement (nombre de pods/répliques)
PUT …/pilotage/extend-time renvoie 400 pour une copiecopie déjà rendue (submitted/corrected), refus attenduexercizer.pilotage.extend.invalid.minutes / réponse renderError
Bouton « Piloter la séance » absent dans l'onglet Correctionhors fenêtre horaire (begin_date/due_date) — comportement attendu, pas un bugisPilotageAvailable() (teacherDashboardCorrectionCopyList.ts)

Couverture de tests

Tests e2e à écrire

Aucun test Playwright n'existe encore pour le pilotage à la date de cette documentation. La spec fonctionnelle (SPEC-D3-animation-pilotage.md) prévoit un scénario par action, à deux contextes de navigateur (un profil enseignant, un profil élève), pour vérifier que l'effet est visible côté élève sans rechargement et que le backend refuse les actions incohérentes avec l'état de la copie. Tant que ces tests ne sont pas écrits, seule la partie API a été exercée manuellement ; la vérification navigateur complète (et les captures de la doc fonctionnelle) restent à produire.