ajoute la surveillance d'examen (chantier vrai cockpit, dernière étape)

Une séance peut être lancée en mode examen avec une durée dédiée.
Réutilise le mécanisme d'expiration déjà en place (labs:expirer-sessions,
expire_a) plutôt que d'en construire un nouveau. Le navigateur de
l'élève signale les changements d'onglet et les sorties de plein écran
(evenements_examen : un type et un horodatage, rien d'autre - pas de
capture d'écran ni de webcam) ; le prof voit le compte par élève et le
temps restant dans le cockpit de sa classe et la salle d'attente.

Choix d'Adam après discussion des options (suivi chronométré simple /
+ détection d'onglet / + tentative de plein écran) : la troisième,
honnêtement présentée - un navigateur ne permet pas de vraiment
empêcher de sortir, seulement de le signaler.

Avec cette étape, les quatre fonctions fictives du tableau de bord
démo (quiz, devoirs, observations, examen) sont maintenant réelles.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Adam Belkacemi 2026-10-14 00:35:52 +02:00
parent 954bb6ca6c
commit f2bca621cd
15 changed files with 392 additions and 11 deletions

View file

@ -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).

View file

@ -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)

View file

@ -0,0 +1,35 @@
<?php
namespace App\Http\Controllers;
use App\Models\EvenementExamen;
use App\Models\LabSession;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
/**
* Surveillance d'examen (chantier « vrai cockpit », 2026-10-14) : le
* navigateur de l'élève signale un changement d'onglet ou une sortie du
* plein écran. Jamais de capture d'écran ni de webcam — juste un type et
* un horodatage (CLAUDE.md : minimisation).
*/
class EvenementExamenController extends Controller
{
public function store(Request $request, LabSession $labSession): JsonResponse
{
$eleve = Auth::guard('eleve')->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);
}
}

View file

@ -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) {

View file

@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Sortie détectée pendant un examen (changement d'onglet, plein écran
* quitté) : jamais de capture d'écran ni de webcam.
*/
class EvenementExamen extends Model
{
public const CHANGEMENT_ONGLET = 'changement_onglet';
public const SORTIE_PLEIN_ECRAN = 'sortie_plein_ecran';
protected $table = 'evenements_examen';
protected $fillable = ['lab_session_id', 'type'];
/** @return BelongsTo<LabSession, $this> */
public function labSession(): BelongsTo
{
return $this->belongsTo(LabSession::class);
}
}

View file

@ -91,4 +91,20 @@ class LabSession extends Model
{
return $this->hasOne(Correction::class)->latestOfMany();
}
/** @return HasMany<EvenementExamen, $this> */
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);
}
}

View file

@ -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