Paillasse/portail/app/Http/Controllers/SeanceController.php
Adam Belkacemi f2bca621cd 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>
2026-10-14 00:35:52 +02:00

188 lines
6.8 KiB
PHP

<?php
namespace App\Http\Controllers;
use App\Jobs\CreerStackLab;
use App\Models\Classe;
use App\Models\LabSession;
use App\Models\Seance;
use App\Services\Labs\StockChaud;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
class SeanceController extends Controller
{
/**
* Sans date : la salle d'attente s'ouvre tout de suite. Avec une date :
* séance planifiée, le stock chaud du lab se remplit 10 minutes avant.
*/
public function store(Request $request, Classe $classe): RedirectResponse
{
abort_if($classe->user_id !== Auth::guard('web')->id(), 403);
if ($classe->estArchivee()) {
return back()->withErrors(['prevue_a' => 'Cette classe est archivée : désarchive-la pour lancer une séance.']);
}
$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();
if ($prevueA->isPast()) {
return back()->withErrors(['prevue_a' => 'Choisis une date à venir.'])->withInput();
}
$classe->seances()->create([
'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);
}
$seance = $classe->seances()->create([
'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);
}
public function ouvrir(Seance $seance): RedirectResponse
{
abort_if($seance->classe->user_id !== Auth::guard('web')->id(), 403);
if ($seance->statut === Seance::PLANIFIEE) {
$seance->update(['statut' => Seance::SALLE_ATTENTE, 'demarree_a' => now()]);
}
return redirect()->route('seances.attente', $seance);
}
public function attente(Seance $seance): View
{
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', 'labSessions.evenementsExamen']),
]);
}
public function consoleProf(Seance $seance, LabSession $labSession): View
{
abort_if($seance->classe->user_id !== Auth::guard('web')->id(), 403);
abort_if($labSession->seance_id !== $seance->id, 404);
return view('seances.console-prof', [
'seance' => $seance->load('lab'),
'labSession' => $labSession->load(['eleve', 'machines']),
]);
}
/**
* Admettre un élève, c'est lancer son lab. Chemin rapide : une stack
* prête est prise dans le stock chaud et attribuée tout de suite.
* Chemin lent (stock vide) : CreerStackLab la construit (20 à 40 s).
* Aussi utilisé pour relancer une session en échec.
*/
public function admettre(Seance $seance, LabSession $labSession, StockChaud $stock): RedirectResponse
{
abort_if($seance->classe->user_id !== Auth::guard('web')->id(), 403);
abort_if($labSession->seance_id !== $seance->id, 404);
// Mise à jour conditionnelle en une seule requête SQL : un double clic
// (ou deux onglets) ne peut pas lancer deux stacks pour le même élève.
$pris = LabSession::whereKey($labSession->id)
->whereIn('etat', [LabSession::EN_ATTENTE, LabSession::ECHEC])
->update([
'etat' => LabSession::ADMIS,
'raison_echec' => null,
'expire_a' => now()->addMinutes($seance->mode_examen ? $seance->duree_examen_min : ($labSession->lab->duree_min ?? 45)),
]);
if ($pris === 0) {
return redirect()->route('seances.attente', $seance);
}
$labSession->refresh();
$pret = $stock->prendre($labSession->lab->identifiant);
if ($pret !== null) {
$labSession->attribuerStack($pret);
$stock->confirmerAttribution($pret);
} else {
CreerStackLab::dispatch($labSession);
}
return redirect()->route('seances.attente', $seance);
}
/**
* Un élève rejoint la séance : il patiente jusqu'à ce que le prof l'admette.
*/
public function rejoindre(Request $request, Seance $seance): RedirectResponse
{
$eleve = Auth::guard('eleve')->user();
abort_if($eleve->classe_id !== $seance->classe_id, 403);
abort_if($seance->classe->estArchivee(), 403);
LabSession::firstOrCreate(
['seance_id' => $seance->id, 'eleve_id' => $eleve->id],
['lab_id' => $seance->lab_id, 'etat' => LabSession::EN_ATTENTE],
);
return redirect()->route('seances.eleve.attente', $seance);
}
public function attenteEleve(Seance $seance): View|RedirectResponse
{
$labSession = $this->sessionDeLEleve($seance);
if ($labSession === null) {
return redirect()->route('eleve');
}
return view('seances.attente-eleve', [
'seance' => $seance->load('lab'),
'labSession' => $labSession->load(['machines', 'derniereCorrection']),
]);
}
/**
* Interrogé chaque seconde par la page d'attente de l'élève : la page se
* recharge dès que l'état change (au lieu d'un rafraîchissement toutes
* les 5 s, incompatible avec un lab ouvert en moins de 5 s).
*/
public function etatEleve(Seance $seance): JsonResponse
{
return response()->json(['etat' => $this->sessionDeLEleve($seance)?->etat]);
}
private function sessionDeLEleve(Seance $seance): ?LabSession
{
$eleve = Auth::guard('eleve')->user();
abort_if($eleve->classe_id !== $seance->classe_id, 403);
return LabSession::where('seance_id', $seance->id)
->where('eleve_id', $eleve->id)
->first();
}
}