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>
256 lines
10 KiB
PHP
256 lines
10 KiB
PHP
<?php
|
||
|
||
namespace App\Http\Controllers;
|
||
|
||
use App\Models\Classe;
|
||
use App\Models\Correction;
|
||
use App\Models\DemandeInscription;
|
||
use App\Models\Eleve;
|
||
use App\Models\LabSession;
|
||
use App\Models\Seance;
|
||
use App\Services\GenerateurCompteEleve;
|
||
use App\Services\Labs\DefinitionLab;
|
||
use Illuminate\Http\JsonResponse;
|
||
use Illuminate\Http\RedirectResponse;
|
||
use Illuminate\Http\Request;
|
||
use Illuminate\Support\Facades\Auth;
|
||
use Illuminate\Support\Facades\Hash;
|
||
use Illuminate\Support\Str;
|
||
use Illuminate\View\View;
|
||
|
||
class ClasseController extends Controller
|
||
{
|
||
public function __construct(private readonly GenerateurCompteEleve $generateur) {}
|
||
|
||
public function index(): View
|
||
{
|
||
$classes = Auth::guard('web')->user()->classes()->withCount('eleves')->latest()->get();
|
||
|
||
return view('mes-classes.index', ['classes' => $classes]);
|
||
}
|
||
|
||
/**
|
||
* Le tableau de bord (étape 1 du chantier « vrai cockpit », 2026-10-12)
|
||
* ne montre que des chiffres réels : rien n'est affiché qui ne vienne
|
||
* pas d'une vraie table ou d'un vrai cache (CLAUDE.md : pas de KPI
|
||
* inventé). Les fonctions sans backend (quiz, DM, observations, examen)
|
||
* n'ont pas de carte ici — elles arriveront une par une.
|
||
*/
|
||
public function show(Classe $classe): View
|
||
{
|
||
$this->verifierProprietaire($classe);
|
||
|
||
// La séance la plus pertinente à montrer : en cours avant salle
|
||
// 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', 'evenementsExamen'])])
|
||
->latest('demarree_a')
|
||
->get()
|
||
->sortByDesc(fn ($s) => $s->statut === Seance::EN_COURS)
|
||
->first();
|
||
|
||
// Déjà chargées par le eager loading ci-dessus (labSessions.derniereCorrection) :
|
||
// repasser par une requête séparée perdait le type précis pour l'analyse statique.
|
||
$corrections = $seance
|
||
? $seance->labSessions->map(fn (LabSession $ls) => $ls->derniereCorrection)->filter()->values()
|
||
: collect();
|
||
|
||
// Répartition par vérification du lab (équivalent réel du « répartition
|
||
// par étape » de la démo) : l'ordre et les titres viennent du lab.yaml,
|
||
// le compte de réussite des vraies corrections. Jamais de bonus ici
|
||
// (ils ajoutent des points, ils ne font pas partie du parcours normal).
|
||
$avancement = collect();
|
||
if ($seance && $corrections->isNotEmpty()) {
|
||
$definition = DefinitionLab::depuis($seance->lab->identifiant);
|
||
$avancement = collect($definition->verifications)
|
||
->reject(fn ($v) => $v['bonus'])
|
||
->map(fn ($v) => [
|
||
'titre' => $v['titre'],
|
||
'reussi' => $corrections->filter(fn (Correction $c) => collect($c->details)->firstWhere('id', $v['id'])['reussi'] ?? false)->count(),
|
||
'total' => $corrections->count(),
|
||
]);
|
||
}
|
||
|
||
// Progression dans le temps de la séance, d'après la durée estimée
|
||
// du lab (Lab::duree_min) : absente si l'une des deux manque, jamais
|
||
// une estimation inventée.
|
||
$progressionSeance = ($seance?->demarree_a && $seance->lab->duree_min)
|
||
? min(100, (int) round($seance->demarree_a->diffInMinutes(now()) / $seance->lab->duree_min * 100))
|
||
: null;
|
||
|
||
return view('mes-classes.show', [
|
||
'classes' => Auth::guard('web')->user()->classes()->withCount('eleves')->orderBy('nom')->get(),
|
||
'classe' => $classe->load(['eleves', 'annonces.auteur', 'observations.eleve']),
|
||
'demandes' => $classe->demandesInscription()->where('statut', DemandeInscription::EN_ATTENTE)->oldest()->get(),
|
||
'seance' => $seance,
|
||
'avancement' => $avancement,
|
||
'progressionSeance' => $progressionSeance,
|
||
'reussiteMoyenne' => $corrections->isNotEmpty() ? (int) round($corrections->avg(fn ($c) => $c->note_max > 0 ? $c->note / $c->note_max * 100 : 0)) : null,
|
||
'mains' => ($seance && $seance->visioOuverte()) ? VisioController::mainsLevees($seance) : [],
|
||
]);
|
||
}
|
||
|
||
public function storeEleve(Request $request, Classe $classe): RedirectResponse
|
||
{
|
||
$this->verifierProprietaire($classe);
|
||
|
||
$donnees = $request->validate([
|
||
'prenom' => ['required', 'string', 'max:100'],
|
||
'nom' => ['nullable', 'string', 'max:100'],
|
||
]);
|
||
|
||
$fiche = $this->creerEleve($classe, $donnees['prenom'], $donnees['nom'] ?? null);
|
||
|
||
return redirect()->route('mes-classes.show', $classe)
|
||
->with('fiches', [$fiche]);
|
||
}
|
||
|
||
public function importerCsv(Request $request, Classe $classe): RedirectResponse
|
||
{
|
||
$this->verifierProprietaire($classe);
|
||
|
||
$request->validate([
|
||
'fichier' => ['required', 'file', 'mimes:csv,txt'],
|
||
]);
|
||
|
||
$lignes = file($request->file('fichier')->getRealPath(), FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||
$fiches = [];
|
||
|
||
foreach ($lignes as $ligne) {
|
||
$colonnes = str_getcsv($ligne);
|
||
$prenom = trim($colonnes[0] ?? '');
|
||
|
||
if ($prenom === '' || Str::lower($prenom) === 'prenom') {
|
||
continue; // ligne vide ou en-tête
|
||
}
|
||
|
||
$nom = trim($colonnes[1] ?? '') ?: null;
|
||
$fiches[] = $this->creerEleve($classe, $prenom, $nom);
|
||
}
|
||
|
||
return redirect()->route('mes-classes.show', $classe)
|
||
->with('fiches', $fiches)
|
||
->with('nombre_importes', count($fiches));
|
||
}
|
||
|
||
public function regenererCode(Classe $classe, Eleve $eleve): RedirectResponse
|
||
{
|
||
$this->verifierProprietaire($classe);
|
||
abort_if($eleve->classe_id !== $classe->id, 404);
|
||
|
||
$codeSecret = $this->generateur->genererCodeSecret();
|
||
$jetonQr = Str::random(32);
|
||
|
||
$eleve->forceFill([
|
||
'code_secret' => Hash::make($codeSecret),
|
||
'jeton_qr' => $jetonQr,
|
||
])->save();
|
||
|
||
return redirect()->route('mes-classes.show', $classe)
|
||
->with('fiches', [[
|
||
'identifiant' => $eleve->identifiant,
|
||
'prenom' => $eleve->prenom,
|
||
'nom' => $eleve->nom,
|
||
'code_secret' => $codeSecret,
|
||
'jeton_qr' => $jetonQr,
|
||
]]);
|
||
}
|
||
|
||
/**
|
||
* @return array{identifiant: string, prenom: string, nom: ?string, code_secret: string, jeton_qr: string}
|
||
*/
|
||
private function creerEleve(Classe $classe, string $prenom, ?string $nom): array
|
||
{
|
||
$fiche = $this->generateur->creerDans($classe, $prenom, $nom);
|
||
unset($fiche['eleve']);
|
||
|
||
return $fiche;
|
||
}
|
||
|
||
public function accepterDemande(Classe $classe, DemandeInscription $demande): RedirectResponse
|
||
{
|
||
$this->verifierProprietaire($classe);
|
||
abort_if($demande->classe_id !== $classe->id, 404);
|
||
|
||
if ($demande->statut === DemandeInscription::EN_ATTENTE && ! $classe->estArchivee()) {
|
||
$fiche = $this->generateur->creerDans($classe, $demande->prenom, $demande->nom);
|
||
$demande->forceFill([
|
||
'statut' => DemandeInscription::ACCEPTEE,
|
||
'eleve_id' => $fiche['eleve']->id,
|
||
'code_secret_chiffre' => $fiche['code_secret'],
|
||
])->save();
|
||
}
|
||
|
||
return redirect()->route('mes-classes.show', $classe);
|
||
}
|
||
|
||
public function refuserDemande(Classe $classe, DemandeInscription $demande): RedirectResponse
|
||
{
|
||
$this->verifierProprietaire($classe);
|
||
abort_if($demande->classe_id !== $classe->id, 404);
|
||
|
||
$demande->forceFill(['statut' => DemandeInscription::REFUSEE])->save();
|
||
|
||
return redirect()->route('mes-classes.show', $classe);
|
||
}
|
||
|
||
public function nombreDemandes(Classe $classe): JsonResponse
|
||
{
|
||
$this->verifierProprietaire($classe);
|
||
|
||
return response()->json(['en_attente' => $classe->demandesInscription()->where('statut', DemandeInscription::EN_ATTENTE)->count()]);
|
||
}
|
||
|
||
/**
|
||
* Nouvelles fiches (nouveau code, nouveau QR) pour les élèves cochés :
|
||
* la fiche n'est visible qu'une fois, ceci permet de la refaire en lot.
|
||
*/
|
||
public function nouvellesFiches(Request $request, Classe $classe): RedirectResponse
|
||
{
|
||
$this->verifierProprietaire($classe);
|
||
$ids = $request->validate(['eleves' => ['required', 'array'], 'eleves.*' => ['integer']])['eleves'];
|
||
|
||
$fiches = [];
|
||
foreach ($classe->eleves()->whereIn('id', $ids)->orderBy('prenom')->get() as $eleve) {
|
||
$codeSecret = $this->generateur->genererCodeSecret();
|
||
$jetonQr = Str::random(32);
|
||
$eleve->forceFill(['code_secret' => Hash::make($codeSecret), 'jeton_qr' => $jetonQr])->save();
|
||
$fiches[] = ['identifiant' => $eleve->identifiant, 'prenom' => $eleve->prenom, 'nom' => $eleve->nom, 'code_secret' => $codeSecret, 'jeton_qr' => $jetonQr];
|
||
}
|
||
|
||
return redirect()->route('mes-classes.show', $classe)->with('fiches', $fiches);
|
||
}
|
||
|
||
/**
|
||
* Fin d'année : plus de séance possible, et suppression définitive de la
|
||
* classe et des données de ses élèves 90 jours plus tard.
|
||
*/
|
||
public function archiver(Classe $classe): RedirectResponse
|
||
{
|
||
$this->verifierProprietaire($classe);
|
||
|
||
if ($classe->aDesLabsEnCours()) {
|
||
return back()->withErrors(['archivage' => 'Des élèves ont encore un lab en cours : termine la séance avant d’archiver.']);
|
||
}
|
||
|
||
$classe->forceFill(['archivee_a' => now()])->save();
|
||
$classe->seances()->whereIn('statut', [Seance::PLANIFIEE, Seance::SALLE_ATTENTE, Seance::EN_COURS])
|
||
->update(['statut' => Seance::TERMINEE, 'terminee_a' => now()]);
|
||
|
||
return redirect()->route('mes-classes.show', $classe);
|
||
}
|
||
|
||
public function desarchiver(Classe $classe): RedirectResponse
|
||
{
|
||
$this->verifierProprietaire($classe);
|
||
$classe->forceFill(['archivee_a' => null])->save();
|
||
|
||
return redirect()->route('mes-classes.show', $classe);
|
||
}
|
||
|
||
private function verifierProprietaire(Classe $classe): void
|
||
{
|
||
abort_if($classe->user_id !== Auth::guard('web')->id(), 403);
|
||
}
|
||
}
|