diff --git a/docs/journal.md b/docs/journal.md index 48e88dc..871b062 100644 --- a/docs/journal.md +++ b/docs/journal.md @@ -1025,3 +1025,25 @@ Retour d'Adam après avoir vu la coquille : du texte clair illisible par endroit **Suite** Adam vérifie visuellement (thème sombre, menu avatar, mobile) et me dit si autre chose cloche. Sinon : Surveillance d'examen. + +## 2026-10-14 — Surveillance d'examen (chantier « vrai cockpit », dernière étape) + +Adam confirme que le correctif du thème sombre et du menu avatar est bon, sans navigateur disponible pour le vérifier moi-même cette fois. Avant de coder la dernière fonction fictive du tableau de bord démo, j'ai demandé à Adam ce qui est acceptable de suivre pendant un examen (élèves parfois mineurs). Son choix : suivi chronométré + détection de changement d'onglet + tentative de plein écran — en étant honnête sur la limite : un navigateur ne permet pas de vraiment empêcher l'élève de sortir, seulement de le signaler. + +**Fait** + +- Une séance peut être lancée en « mode examen » avec une durée dédiée (pas celle du lab), choisie par le prof au moment de lancer. Réutilise le mécanisme d'expiration déjà en place (`labs:expirer-sessions`, `expire_a` sur `LabSession`) plutôt que d'en construire un nouveau : l'admission en mode examen pose juste `expire_a` sur la durée de l'examen au lieu de la durée du lab. +- `evenements_examen` : un type (`changement_onglet` ou `sortie_plein_ecran`) et un horodatage, rien d'autre — pas de capture d'écran, pas de webcam (minimisation, CLAUDE.md). Le navigateur de l'élève écoute `visibilitychange` et `fullscreenchange` et signale au serveur ; un bouton propose le plein écran, jamais forcé. +- Le prof voit, dans le cockpit de sa classe et dans la salle d'attente : un badge « Mode examen · durée », le nombre de sorties par élève, le temps restant. +- Purge RGPD : les événements d'examen partent avec la classe (vérifié par un test). +- 7 nouveaux tests Pest (100 au total) : durée d'examen appliquée à l'admission, un élève ne peut signaler que sur sa propre session, aucun événement possible hors mode examen, type invalide refusé, purge RGPD, JavaScript valide des deux nouveaux scripts (barre prof, page élève). + +**Comprendre** + +Le choix qui a le plus simplifié ce chantier : ne pas inventer un nouveau mécanisme de fin de session pour l'examen. `expire_a` et `labs:expirer-sessions` existaient déjà pour une autre raison (libérer les VM d'un TP normal) ; l'examen ne fait qu'y mettre une durée différente. Même vérification de temps restant côté élève (JS, toutes les 10 s) que côté prof — pas de nouveau canal de communication. + +**Vérifié sur le serveur réel** (prof et élève jetables, sans VM réelle créée — session de lab montée directement en base, comme pour vérifier Quiz flash, pour ne pas consommer de capacité OpenStack juste pour tester) : le prof voit bien « Mode examen · 45 min » et la colonne Sorties dans le cockpit de sa classe ; l'élève, sur sa page de lab actif, voit le bandeau temps restant ; un événement `changement_onglet` posté par l'élève apparaît aussitôt chez le prof (passé de 0 à 1, en rouge) ; un type d'événement invalide est refusé (422) ; un élève ne peut pas poster sur la session d'un autre (403, déjà couvert par les tests). Classe de test supprimée, rien touché chez Adam. + +**Suite** + +Chantier « vrai cockpit » terminé : les quatre fonctions fictives du tableau de bord démo (quiz, devoirs, observations, examen) sont maintenant réelles. Reprendre le plan du bloc A (CLAUDE.md, section 2) là où il en était. diff --git a/portail/app/Console/Commands/PurgerClassesArchivees.php b/portail/app/Console/Commands/PurgerClassesArchivees.php index 7f3ce47..c0b8ec2 100644 --- a/portail/app/Console/Commands/PurgerClassesArchivees.php +++ b/portail/app/Console/Commands/PurgerClassesArchivees.php @@ -11,9 +11,10 @@ use Illuminate\Support\Facades\Log; * RGPD (CLAUDE.md, étape 14) : supprime les classes archivées depuis * 90 jours, avec en cascade leurs élèves, séances, sessions de lab, machines, * corrections, quiz et devoirs maison (chantier « vrai cockpit »), ainsi que - * les annonces, observations et le chat élève ↔ prof (séparation vitrine/ - * application, 2026-10-13). Le compte du prof est conservé. Le journal ne - * garde que des identifiants et des nombres, jamais de nom d'élève. + * les annonces, observations, le chat élève ↔ prof (séparation vitrine/ + * application, 2026-10-13) et les événements d'examen (2026-10-14). Le + * compte du prof est conservé. Le journal ne garde que des identifiants et + * des nombres, jamais de nom d'élève. * * Les sauvegardes chiffrées contenant encore ces données expirent d'elles- * mêmes au plus 4 semaines plus tard (rotation, docs/runbooks/sauvegardes.md). diff --git a/portail/app/Http/Controllers/ClasseController.php b/portail/app/Http/Controllers/ClasseController.php index 3d0834f..d7bf73b 100644 --- a/portail/app/Http/Controllers/ClasseController.php +++ b/portail/app/Http/Controllers/ClasseController.php @@ -44,7 +44,7 @@ class ClasseController extends Controller // d'attente, la plus récemment démarrée d'abord. $seance = $classe->seances() ->whereIn('statut', [Seance::SALLE_ATTENTE, Seance::EN_COURS]) - ->with(['lab', 'labSessions' => fn ($q) => $q->with(['eleve', 'derniereCorrection', 'machines'])]) + ->with(['lab', 'labSessions' => fn ($q) => $q->with(['eleve', 'derniereCorrection', 'machines', 'evenementsExamen'])]) ->latest('demarree_a') ->get() ->sortByDesc(fn ($s) => $s->statut === Seance::EN_COURS) diff --git a/portail/app/Http/Controllers/EvenementExamenController.php b/portail/app/Http/Controllers/EvenementExamenController.php new file mode 100644 index 0000000..e4fbe6a --- /dev/null +++ b/portail/app/Http/Controllers/EvenementExamenController.php @@ -0,0 +1,35 @@ +user(); + abort_if($labSession->eleve_id !== $eleve->id, 403); + + $seance = $labSession->seance; + abort_unless($seance->mode_examen, 403); + + $donnees = $request->validate([ + 'type' => ['required', 'in:'.implode(',', [EvenementExamen::CHANGEMENT_ONGLET, EvenementExamen::SORTIE_PLEIN_ECRAN])], + ]); + + $labSession->evenementsExamen()->create($donnees); + + return response()->json([], 201); + } +} diff --git a/portail/app/Http/Controllers/SeanceController.php b/portail/app/Http/Controllers/SeanceController.php index a797f57..341ca68 100644 --- a/portail/app/Http/Controllers/SeanceController.php +++ b/portail/app/Http/Controllers/SeanceController.php @@ -31,8 +31,12 @@ class SeanceController extends Controller $donnees = $request->validate([ 'lab_id' => ['required', 'exists:labs,id'], 'prevue_a' => ['nullable', 'date_format:Y-m-d\TH:i'], + 'mode_examen' => ['nullable', 'boolean'], + 'duree_examen_min' => ['required_if:mode_examen,1', 'nullable', 'integer', 'min:5', 'max:300'], ]); + $modeExamen = $request->boolean('mode_examen'); + if (! empty($donnees['prevue_a'])) { $prevueA = Carbon::createFromFormat('Y-m-d\TH:i', $donnees['prevue_a'], config('app.fuseau_utilisateurs'))->utc(); @@ -44,6 +48,8 @@ class SeanceController extends Controller 'lab_id' => $donnees['lab_id'], 'statut' => Seance::PLANIFIEE, 'prevue_a' => $prevueA, + 'mode_examen' => $modeExamen, + 'duree_examen_min' => $modeExamen ? $donnees['duree_examen_min'] : null, ]); return redirect()->route('mes-classes.show', $classe)->with('seance_planifiee', true); @@ -53,6 +59,8 @@ class SeanceController extends Controller 'lab_id' => $donnees['lab_id'], 'statut' => Seance::SALLE_ATTENTE, 'demarree_a' => now(), + 'mode_examen' => $modeExamen, + 'duree_examen_min' => $modeExamen ? $donnees['duree_examen_min'] : null, ]); return redirect()->route('seances.attente', $seance); @@ -74,7 +82,7 @@ class SeanceController extends Controller abort_if($seance->classe->user_id !== Auth::guard('web')->id(), 403); return view('seances.attente', [ - 'seance' => $seance->load(['classe', 'lab', 'labSessions.eleve', 'labSessions.machines', 'labSessions.derniereCorrection']), + 'seance' => $seance->load(['classe', 'lab', 'labSessions.eleve', 'labSessions.machines', 'labSessions.derniereCorrection', 'labSessions.evenementsExamen']), ]); } @@ -107,7 +115,7 @@ class SeanceController extends Controller ->update([ 'etat' => LabSession::ADMIS, 'raison_echec' => null, - 'expire_a' => now()->addMinutes($labSession->lab->duree_min ?? 45), + 'expire_a' => now()->addMinutes($seance->mode_examen ? $seance->duree_examen_min : ($labSession->lab->duree_min ?? 45)), ]); if ($pris === 0) { diff --git a/portail/app/Models/EvenementExamen.php b/portail/app/Models/EvenementExamen.php new file mode 100644 index 0000000..f7b7d01 --- /dev/null +++ b/portail/app/Models/EvenementExamen.php @@ -0,0 +1,27 @@ + */ + public function labSession(): BelongsTo + { + return $this->belongsTo(LabSession::class); + } +} diff --git a/portail/app/Models/LabSession.php b/portail/app/Models/LabSession.php index 1f861ea..c6a9da5 100644 --- a/portail/app/Models/LabSession.php +++ b/portail/app/Models/LabSession.php @@ -91,4 +91,20 @@ class LabSession extends Model { return $this->hasOne(Correction::class)->latestOfMany(); } + + /** @return HasMany */ + public function evenementsExamen(): HasMany + { + return $this->hasMany(EvenementExamen::class)->latest(); + } + + /** Nul si la session n'expire jamais ou si l'expiration est déjà passée. */ + public function minutesRestantes(): ?int + { + if ($this->expire_a === null || $this->expire_a->isPast()) { + return null; + } + + return (int) ceil(now()->diffInSeconds($this->expire_a) / 60); + } } diff --git a/portail/app/Models/Seance.php b/portail/app/Models/Seance.php index fce0886..79a4450 100644 --- a/portail/app/Models/Seance.php +++ b/portail/app/Models/Seance.php @@ -20,7 +20,7 @@ class Seance extends Model public const TERMINEE = 'terminee'; - protected $fillable = ['classe_id', 'lab_id', 'statut', 'prevue_a', 'demarree_a', 'terminee_a', 'visio_ouverte_a', 'visio_salle', 'visio_chat']; + protected $fillable = ['classe_id', 'lab_id', 'statut', 'prevue_a', 'demarree_a', 'terminee_a', 'visio_ouverte_a', 'visio_salle', 'visio_chat', 'mode_examen', 'duree_examen_min']; protected $casts = [ 'prevue_a' => 'datetime', @@ -28,6 +28,7 @@ class Seance extends Model 'terminee_a' => 'datetime', 'visio_ouverte_a' => 'datetime', 'visio_chat' => 'boolean', + 'mode_examen' => 'boolean', ]; public function visioOuverte(): bool diff --git a/portail/database/migrations/2026_10_14_150000_add_mode_examen_to_seances_table.php b/portail/database/migrations/2026_10_14_150000_add_mode_examen_to_seances_table.php new file mode 100644 index 0000000..a051297 --- /dev/null +++ b/portail/database/migrations/2026_10_14_150000_add_mode_examen_to_seances_table.php @@ -0,0 +1,31 @@ +boolean('mode_examen')->default(false)->after('statut'); + $table->unsignedSmallInteger('duree_examen_min')->nullable()->after('mode_examen'); + }); + } + + public function down(): void + { + Schema::table('seances', function (Blueprint $table) { + $table->dropColumn(['mode_examen', 'duree_examen_min']); + }); + } +}; diff --git a/portail/database/migrations/2026_10_14_150001_create_evenements_examen_table.php b/portail/database/migrations/2026_10_14_150001_create_evenements_examen_table.php new file mode 100644 index 0000000..acbfd9f --- /dev/null +++ b/portail/database/migrations/2026_10_14_150001_create_evenements_examen_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignId('lab_session_id')->constrained()->cascadeOnDelete(); + $table->string('type')->comment('changement_onglet, sortie_plein_ecran'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('evenements_examen'); + } +}; diff --git a/portail/resources/views/mes-classes/show.blade.php b/portail/resources/views/mes-classes/show.blade.php index 06043c9..0469cf0 100644 --- a/portail/resources/views/mes-classes/show.blade.php +++ b/portail/resources/views/mes-classes/show.blade.php @@ -153,7 +153,11 @@ {{-- Vraies données de la séance en cours : un élève, son état, sa dernière vérification. --}}
-

{{ $seance->lab->titre }} — {{ $seance->statut === \App\Models\Seance::EN_COURS ? 'en cours' : 'salle d\'attente' }}

+

{{ $seance->lab->titre }} — {{ $seance->statut === \App\Models\Seance::EN_COURS ? 'en cours' : 'salle d\'attente' }} + @if ($seance->mode_examen) + Mode examen · {{ $seance->duree_examen_min }} min + @endif +

Salle d'attente {{ $seance->visioOuverte() ? 'Rejoindre la visio' : 'Lancer la visio' }} @@ -164,7 +168,9 @@ @else
- + + @if ($seance->mode_examen)@endif + @foreach ($seance->labSessions as $labSession) @@ -195,6 +201,15 @@ — @endif + @if ($seance->mode_examen) + + @endif
ÉlèveÉtatDernière vérification
ÉlèveÉtatDernière vérificationSorties
+ @if ($labSession->evenementsExamen->isNotEmpty()) + {{ $labSession->evenementsExamen->count() }} + @else + 0 + @endif + @if ($labSession->etat === $etats::ACTIF) Voir sa console @@ -414,7 +429,7 @@ @if (session('seance_planifiee'))

Séance planifiée.

@endif -
+ @csrf + +
@error('prevue_a')

{{ $message }}

@enderror + @error('duree_examen_min') +

{{ $message }}

+ @enderror +

Mode examen : chronométré, détecte les changements d'onglet et propose le plein écran. Ça ne bloque pas vraiment l'élève — un navigateur ne le permet pas — mais tu vois les sorties.

@php($planifiees = $classe->seances()->where('statut', \App\Models\Seance::PLANIFIEE)->with('lab')->orderBy('prevue_a')->get()) @if ($planifiees->isNotEmpty()) @@ -489,6 +510,10 @@ if (!occupe) location.reload(); }, 15000); @endif + var caseModeExamen = document.getElementById('case-mode-examen'); + if (caseModeExamen) caseModeExamen.addEventListener('change', function () { + document.getElementById('champ-duree-examen').hidden = !caseModeExamen.checked; + }); document.querySelectorAll('[data-imprimer]').forEach(function (b) { b.addEventListener('click', function () { window.print(); }); }); document.querySelectorAll('form[data-confirmer]').forEach(function (f) { f.addEventListener('submit', function (e) { if (!window.confirm(f.dataset.confirmer)) e.preventDefault(); }); diff --git a/portail/resources/views/seances/attente-eleve.blade.php b/portail/resources/views/seances/attente-eleve.blade.php index 6b12963..9ecf746 100644 --- a/portail/resources/views/seances/attente-eleve.blade.php +++ b/portail/resources/views/seances/attente-eleve.blade.php @@ -26,6 +26,13 @@

Ton prof le voit et peut relancer. Cette page se mettra à jour toute seule.

@elseif ($labSession->etat === $etats::ACTIF && $labSession->machines->isNotEmpty()) + @if ($seance->mode_examen) +
+

Mode examen · temps restant : …

+ +
+

Reste sur cette page : les changements d'onglet et les sorties du plein écran sont signalés à ton prof.

+ @endif

Tes machines

@foreach ($labSession->machines as $i => $machine) @@ -96,6 +103,43 @@ @endforeach }); + @if ($seance->mode_examen) + + @endif @else

Cette séance est terminée pour toi

diff --git a/portail/resources/views/seances/attente.blade.php b/portail/resources/views/seances/attente.blade.php index 30da541..03227ad 100644 --- a/portail/resources/views/seances/attente.blade.php +++ b/portail/resources/views/seances/attente.blade.php @@ -8,7 +8,11 @@

{{ $seance->classe->nom }} · {{ $seance->lab->titre }}

-

Salle d'attente

+

Salle d'attente + @if ($seance->mode_examen) + Mode examen · {{ $seance->duree_examen_min }} min + @endif +

La page se rafraîchit toutes les 10 secondes.

{{ $seance->visioOuverte() ? 'Retourner à la visio' : 'Visio de la classe' }}

@@ -38,6 +42,12 @@ @endif Voir sa console + @if ($seance->mode_examen) + {{ $labSession->evenementsExamen->count() }} sortie(s) + @if ($labSession->minutesRestantes() !== null) + {{ $labSession->minutesRestantes() }} min restantes + @endif + @endif @break @case($etats::ECHEC) Échec du lancement (3 essais) diff --git a/portail/routes/web.php b/portail/routes/web.php index 1fbd9a4..1570ece 100644 --- a/portail/routes/web.php +++ b/portail/routes/web.php @@ -15,6 +15,7 @@ use App\Http\Controllers\DevoirController; use App\Http\Controllers\DevoirEleveController; use App\Http\Controllers\EleveTableauDeBordController; use App\Http\Controllers\EmploiDuTempsController; +use App\Http\Controllers\EvenementExamenController; use App\Http\Controllers\InscriptionController; use App\Http\Controllers\MaClasseController; use App\Http\Controllers\MesCoursController; @@ -140,6 +141,11 @@ Route::middleware(['auth:eleve', 'session.unique:eleve', 'maj.activite'])->group ->name('lab-sessions.corriger'); Route::post('/lab-sessions/{labSession}/machines/{labSessionMachine}/console', [ConsoleController::class, 'pourEleve'])->name('console.eleve'); + // Chantier « vrai cockpit », dernière étape : Surveillance d'examen. + Route::post('/lab-sessions/{labSession}/evenements-examen', [EvenementExamenController::class, 'store']) + ->middleware('throttle:20,1') + ->name('lab-sessions.evenements-examen'); + // Chantier « vrai cockpit », étape 2 : Quiz flash. Route::get('/quiz-flash/etat', [QuizEleveController::class, 'etat'])->name('quiz.eleve.etat'); Route::post('/sessions-quiz/{session}/repondre', [QuizEleveController::class, 'repondre']) diff --git a/portail/tests/Feature/SurveillanceExamenTest.php b/portail/tests/Feature/SurveillanceExamenTest.php new file mode 100644 index 0000000..a05bdc8 --- /dev/null +++ b/portail/tests/Feature/SurveillanceExamenTest.php @@ -0,0 +1,126 @@ +create(); + $lab = Lab::factory()->create(['identifiant' => '08-deux-lan-routeur']); + $seance = Seance::create([ + 'classe_id' => $classe->id, + 'lab_id' => $lab->id, + 'statut' => Seance::SALLE_ATTENTE, + 'demarree_a' => now(), + 'mode_examen' => true, + 'duree_examen_min' => $dureeExamenMin, + ]); + $eleve = Eleve::factory()->create(['classe_id' => $classe->id]); + + return LabSession::create(['seance_id' => $seance->id, 'eleve_id' => $eleve->id, 'lab_id' => $lab->id, 'etat' => LabSession::EN_ATTENTE]); +} + +test("admettre un élève en mode examen utilise la durée de l'examen, pas celle du lab", function () { + Queue::fake(); + $this->mock(StockChaud::class, function ($stock) { + $stock->shouldReceive('prendre')->once()->andReturn(null); + }); + $session = labSessionEnAttenteExamen(dureeExamenMin: 90); + + admettre($this, $session)->assertRedirect(); + + $session->refresh(); + expect(now()->diffInMinutes($session->expire_a))->toBeGreaterThan(85) + ->and(now()->diffInMinutes($session->expire_a))->toBeLessThanOrEqual(90); +}); + +test("un élève signale un événement d'examen sur sa propre session", function () { + $session = labSessionEnAttenteExamen(); + $session->update(['etat' => LabSession::ACTIF]); + + eleve($this, $session->eleve) + ->postJson(route('lab-sessions.evenements-examen', $session), ['type' => 'changement_onglet']) + ->assertCreated(); + + expect($session->evenementsExamen()->count())->toBe(1) + ->and($session->evenementsExamen()->first()->type)->toBe(EvenementExamen::CHANGEMENT_ONGLET); +}); + +test("un élève ne peut pas signaler un événement sur la session d'un autre élève", function () { + $session = labSessionEnAttenteExamen(); + $intrus = Eleve::factory()->create(); + + eleve($this, $intrus) + ->postJson(route('lab-sessions.evenements-examen', $session), ['type' => 'changement_onglet']) + ->assertForbidden(); + + expect($session->evenementsExamen()->count())->toBe(0); +}); + +test("pas d'événement d'examen possible sur une séance normale (mode examen désactivé)", function () { + $session = sessionEnAttente(); + $session->update(['etat' => LabSession::ACTIF]); + + eleve($this, $session->eleve) + ->postJson(route('lab-sessions.evenements-examen', $session), ['type' => 'changement_onglet']) + ->assertForbidden(); +}); + +test('type invalide refusé', function () { + $session = labSessionEnAttenteExamen(); + $session->update(['etat' => LabSession::ACTIF]); + + eleve($this, $session->eleve) + ->postJson(route('lab-sessions.evenements-examen', $session), ['type' => 'webcam_allumee']) + ->assertUnprocessable(); +}); + +test('purger une classe archivée supprime aussi les événements d’examen', function () { + $session = labSessionEnAttenteExamen(); + $session->update(['etat' => LabSession::TERMINE]); + $session->evenementsExamen()->create(['type' => EvenementExamen::SORTIE_PLEIN_ECRAN]); + + $classe = $session->seance->classe; + $classe->forceFill(['archivee_a' => now()->subDays(91)])->save(); + $this->artisan('classes:purger-archivees'); + + expect(EvenementExamen::count())->toBe(0); +}); + +test('les scripts en ligne du mode examen (barre prof et page élève) sont du JavaScript valide', function () { + if (Process::run('node --version')->failed()) { + $this->markTestSkipped('Node absent'); + } + + $session = labSessionEnAttenteExamen(); + $session->update(['etat' => LabSession::ACTIF]); + $session->machines()->create(['nom' => 'pc-a', 'instance_id' => 'uuid-a']); + + $pages = [ + fn () => prof($this, $session->seance->classe->prof)->get(route('mes-classes.show', $session->seance->classe)), + fn () => eleve($this, $session->eleve)->get(route('seances.eleve.attente', $session->seance)), + ]; + + $erreurs = []; + foreach ($pages as $i => $visite) { + preg_match_all('#]*\bsrc=)[^>]*>(.*?)#s', $visite()->getContent(), $scripts); + foreach ($scripts[1] as $j => $code) { + $fichier = tempnam(sys_get_temp_dir(), 'js').'.js'; + file_put_contents($fichier, $code); + $resultat = Process::run(['node', '--check', $fichier]); + unlink($fichier); + if ($resultat->failed()) { + $erreurs[] = "page {$i} script {$j} : ".strtok($resultat->errorOutput(), "\n"); + } + } + } + + expect($erreurs)->toBe([]); +});