Paillasse/portail/app/Http/Controllers/ClasseController.php
Adam Belkacemi ca544ac620 ajoute le cache Redis sur les lectures lourdes (bloc B)
Catalogue (labs, banque de quiz officielle, cours) et liste des
classes du tableau de bord prof - jamais d'expiration, invalidation
explicite par les commandes de synchronisation et par
archiver/désarchiver/purge RGPD.

Bug trouvé en vérifiant en réel sur le serveur (pas en local, où
CACHE_STORE=array ne sérialise jamais rien) : mettre en cache des
modèles Eloquent hydratés plante à la relecture via Redis
("incomplete class" à l'unserialize). Corrigé en cachant les attributs
bruts et en reconstruisant de vrais modèles avec Model::hydrate().

Mesuré en réel : 5 appels directs = 5 requêtes SQL, 5 appels via le
cache = 1 requête SQL.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-14 07:34:55 +02:00

261 lines
10 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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\CacheTableauDeBordProf;
use App\Services\Catalogue\CacheCatalogue;
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 = CacheTableauDeBordProf::classes(Auth::guard('web')->id())->sortByDesc('created_at')->values();
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' => CacheTableauDeBordProf::classes(Auth::guard('web')->id()),
'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) : [],
'labsCatalogue' => CacheCatalogue::labs()->sortBy('titre')->values(),
]);
}
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()]);
CacheTableauDeBordProf::vider($classe->user_id);
return redirect()->route('mes-classes.show', $classe);
}
public function desarchiver(Classe $classe): RedirectResponse
{
$this->verifierProprietaire($classe);
$classe->forceFill(['archivee_a' => null])->save();
CacheTableauDeBordProf::vider($classe->user_id);
return redirect()->route('mes-classes.show', $classe);
}
private function verifierProprietaire(Classe $classe): void
{
abort_if($classe->user_id !== Auth::guard('web')->id(), 403);
}
}