diff --git a/docs/journal.md b/docs/journal.md index 594d764..38d6c64 100644 --- a/docs/journal.md +++ b/docs/journal.md @@ -887,3 +887,29 @@ Le choix qui a le plus simplifié ce chantier n'était pas technique mais de mé **Suite** Dette retirée. Reste dans `docs/dette.md` : le test de bascule TURN en navigateur réel, et l'image Jitsi/Kuma à faire évoluer vers un tag versionné. Chantier « vrai cockpit » : Quiz flash, toujours en attente. + +## 2026-10-13 — Chantier « vrai cockpit », étape 2 : Quiz flash + +Deuxième fonction réelle du chantier (après le tableau de bord). Deux décisions d'Adam avant de coder : formats QCM **et** Vrai/Faux dès la version 1 ; le quiz se lance **indépendamment** d'une séance de lab (pas besoin de code à rejoindre : l'élève est déjà un compte réel de sa classe, le quiz apparaît sur son tableau de bord). + +**Fait** + +- 5 tables (`quizzes`, `questions_quiz`, `choix_questions_quiz`, `sessions_quiz`, `reponses_quiz`) et leurs modèles. Une banque de questions par classe (`Quiz`), réutilisable ; un lancement en direct (`SessionQuiz`) avance question par question, se termine seule après la dernière. +- Prof : `/mes-classes/{classe}/quiz` (créer un quiz, ajouter des questions une par une — même esprit que « ajouter un élève »), panneau de contrôle en direct (`/sessions-quiz/{session}`) avec décompte des réponses en temps réel (qui a répondu, combien de bonnes réponses), bouton « Suivante »/« Terminer ». +- Élève : rien à rejoindre. Une zone sur `/eleve`, cachée par défaut, interrogée toutes les 2 s (`/quiz-flash/etat`) ; apparaît dès qu'un quiz est lancé pour sa classe, avec la question et un formulaire. Une réponse par question (contrainte unique en base, vérifiée aussi côté application) : pas de changement d'avis, pas de double clic compté deux fois. +- Menu : les liens « Quiz flash » de la vitrine (mégamenu et pied de page) pointent désormais vers `/mes-classes` pour un prof connecté, au lieu de la démo — même correctif que pour le tableau de bord. +- 16 tests Pest (72 au total), dont un qui rend les vraies pages et vérifie leurs scripts avec Node (même piège que `/ide` à l'étape 14, évité ici dès la construction) et un qui vérifie que la purge RGPD d'une classe supprime bien ses quiz, questions, choix, sessions et réponses. + +**Un vrai bug de migration, trouvé par les tests avant le serveur réel** + +`constrained()` devine le nom de la table à partir du nom de la colonne, en la mettant au pluriel anglais standard. Nos tables ont des noms différents de cette convention (`questions_quiz`, pas `question_quizzes` ; `sessions_quiz`, pas `session_quizzes` ; `choix_questions_quiz`, pas `choix_question_quizzes`) : trois clés étrangères pointaient vers des tables qui n'existaient pas. Corrigé en précisant le nom de chaque table explicitement (`constrained('questions_quiz')`, etc.) — seul `quiz_id` → `quizzes` correspondait déjà à la convention (vérifié avec `Str::plural('quiz')`, pas supposé). + +**Critère vérifié sur le serveur réel** (scénario à 2 élèves, nettoyé ensuite) : un prof a créé un quiz (1 QCM, 1 vrai/faux), l'a lancé ; un élève a répondu juste (Paris), l'autre faux (Lyon) ; le panneau du prof a montré en direct « 2 ont répondu, 1 correcte » ; une deuxième réponse du même élève a été refusée (409, « Déjà répondu ») ; passage à la question suivante, l'élève l'a vue sans son ancien « déjà répondu » ; fin du quiz, l'élève ne voit plus rien. + +**Comprendre** + +Le bug de migration est un bon rappel que les conventions de Laravel ne devinent pas, elles supposent — et l'hypothèse casse silencieusement dès qu'on s'éloigne un peu du nom de table standard (ici, un ordre « pluriel-puis-singulier » choisi pour que les noms de table se lisent bien en français). Heureusement, les tests Pest l'ont trouvé avant même d'atteindre le serveur : la bonne nouvelle de ce chantier n'est pas qu'il n'y a pas eu de bug, mais que chaque bug a été trouvé au bon moment — jamais après coup. + +**Suite** + +Chantier « vrai cockpit », étape 3 : Devoirs maison (prochaine fonction). diff --git a/portail/app/Console/Commands/PurgerClassesArchivees.php b/portail/app/Console/Commands/PurgerClassesArchivees.php index 4ce8c1a..b003478 100644 --- a/portail/app/Console/Commands/PurgerClassesArchivees.php +++ b/portail/app/Console/Commands/PurgerClassesArchivees.php @@ -9,9 +9,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 - * et corrections. Le compte du prof est conservé. Le journal ne garde que - * des identifiants et des nombres, jamais de nom d'élève. + * 90 jours, avec en cascade leurs élèves, séances, sessions de lab, machines, + * corrections et quiz (chantier « vrai cockpit », étape 2). 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/QuizController.php b/portail/app/Http/Controllers/QuizController.php new file mode 100644 index 0000000..9202202 --- /dev/null +++ b/portail/app/Http/Controllers/QuizController.php @@ -0,0 +1,112 @@ +verifierProprietaire($classe); + + return view('quiz.index', ['classe' => $classe, 'quizzes' => $classe->quizzes()->withCount('questions')->get()]); + } + + public function store(Request $request, Classe $classe): RedirectResponse + { + $this->verifierProprietaire($classe); + $donnees = $request->validate(['titre' => ['required', 'string', 'max:150']]); + + $quiz = $classe->quizzes()->create($donnees); + + return redirect()->route('mes-classes.quiz.show', [$classe, $quiz]); + } + + public function show(Classe $classe, Quiz $quiz): View + { + $this->verifierProprietaire($classe); + $this->verifierAppartenance($classe, $quiz); + + return view('quiz.show', ['classe' => $classe, 'quiz' => $quiz->load('questions.choix')]); + } + + public function destroy(Classe $classe, Quiz $quiz): RedirectResponse + { + $this->verifierProprietaire($classe); + $this->verifierAppartenance($classe, $quiz); + $quiz->delete(); + + return redirect()->route('mes-classes.quiz.index', $classe); + } + + /** + * Une question à la fois (même esprit que « ajouter un élève ») : plus + * simple à valider et à tester qu'un formulaire unique avec un nombre + * de lignes variable. + */ + public function storeQuestion(Request $request, Classe $classe, Quiz $quiz): RedirectResponse + { + $this->verifierProprietaire($classe); + $this->verifierAppartenance($classe, $quiz); + + $donnees = $request->validate([ + 'enonce' => ['required', 'string', 'max:500'], + 'type' => ['required', 'in:'.QuestionQuiz::QCM.','.QuestionQuiz::VRAI_FAUX], + 'reponse_vrai_faux' => ['required_if:type,'.QuestionQuiz::VRAI_FAUX, 'boolean'], + 'choix' => ['required_if:type,'.QuestionQuiz::QCM, 'array', 'min:2', 'max:5'], + 'choix.*' => ['required_if:type,'.QuestionQuiz::QCM, 'string', 'max:200'], + 'bonne_reponse' => ['required_if:type,'.QuestionQuiz::QCM, 'integer'], + ]); + + DB::transaction(function () use ($donnees, $quiz) { + $question = $quiz->questions()->create([ + 'enonce' => $donnees['enonce'], + 'type' => $donnees['type'], + 'ordre' => $quiz->questions()->count(), + 'reponse_vrai_faux' => $donnees['type'] === QuestionQuiz::VRAI_FAUX ? $donnees['reponse_vrai_faux'] : null, + ]); + + if ($donnees['type'] === QuestionQuiz::QCM) { + foreach ($donnees['choix'] as $i => $texte) { + $question->choix()->create(['texte' => $texte, 'est_correct' => $i === (int) $donnees['bonne_reponse'], 'ordre' => $i]); + } + } + }); + + return redirect()->route('mes-classes.quiz.show', [$classe, $quiz]); + } + + public function destroyQuestion(Classe $classe, Quiz $quiz, QuestionQuiz $question): RedirectResponse + { + $this->verifierProprietaire($classe); + $this->verifierAppartenance($classe, $quiz); + abort_if($question->quiz_id !== $quiz->id, 404); + $question->delete(); + + return redirect()->route('mes-classes.quiz.show', [$classe, $quiz]); + } + + private function verifierProprietaire(Classe $classe): void + { + abort_if($classe->user_id !== Auth::guard('web')->id(), 403); + } + + private function verifierAppartenance(Classe $classe, Quiz $quiz): void + { + abort_if($quiz->classe_id !== $classe->id, 404); + } +} diff --git a/portail/app/Http/Controllers/QuizEleveController.php b/portail/app/Http/Controllers/QuizEleveController.php new file mode 100644 index 0000000..d4564fe --- /dev/null +++ b/portail/app/Http/Controllers/QuizEleveController.php @@ -0,0 +1,102 @@ +user(); + + $session = SessionQuiz::query() + ->whereIn('statut', [SessionQuiz::EN_ATTENTE, SessionQuiz::EN_COURS]) + ->whereHas('quiz', fn ($q) => $q->where('classe_id', $eleve->classe_id)) + ->with('questionCourante.choix') + ->latest('demarree_a') + ->first(); + + if (! $session) { + return response()->json(['session' => null]); + } + + $dejaRepondu = $session->question_quiz_courante_id + ? ReponseQuiz::where('session_quiz_id', $session->id) + ->where('question_quiz_id', $session->question_quiz_courante_id) + ->where('eleve_id', $eleve->id) + ->exists() + : false; + + $question = $session->questionCourante; + + return response()->json([ + 'session' => [ + 'id' => $session->id, + 'statut' => $session->statut, + 'deja_repondu' => $dejaRepondu, + 'question' => $question ? [ + 'id' => $question->id, + 'enonce' => $question->enonce, + 'type' => $question->type, + 'choix' => $question->type === QuestionQuiz::QCM + ? $question->choix->map(fn ($c) => ['id' => $c->id, 'texte' => $c->texte])->values() + : [], + ] : null, + ], + ]); + } + + public function repondre(Request $request, SessionQuiz $session): JsonResponse + { + $eleve = Auth::guard('eleve')->user(); + abort_if($session->quiz->classe_id !== $eleve->classe_id, 403); + abort_if($session->statut !== SessionQuiz::EN_COURS || ! $session->question_quiz_courante_id, 409, 'Aucune question en cours.'); + + $question = $session->questionCourante; + abort_if( + ReponseQuiz::where('session_quiz_id', $session->id) + ->where('question_quiz_id', $question->id) + ->where('eleve_id', $eleve->id) + ->exists(), + 409, + 'Déjà répondu.' + ); + + $donnees = $request->validate([ + 'choix_id' => ['nullable', 'integer'], + 'reponse_vrai_faux' => ['nullable', 'boolean'], + ]); + + $correcte = $question->estCorrecte($donnees['choix_id'] ?? null, $donnees['reponse_vrai_faux'] ?? null); + + try { + ReponseQuiz::create([ + 'session_quiz_id' => $session->id, + 'question_quiz_id' => $question->id, + 'eleve_id' => $eleve->id, + 'choix_question_quiz_id' => $donnees['choix_id'] ?? null, + 'reponse_vrai_faux' => $donnees['reponse_vrai_faux'] ?? null, + 'correcte' => $correcte, + 'repondu_a' => now(), + ]); + } catch (QueryException $e) { + // Double clic : la contrainte d'unicité (session, question, élève) + // a bloqué un second envoi arrivé en même temps que le premier. + abort(409, 'Déjà répondu.'); + } + + return response()->json(['correcte' => $correcte]); + } +} diff --git a/portail/app/Http/Controllers/SessionQuizController.php b/portail/app/Http/Controllers/SessionQuizController.php new file mode 100644 index 0000000..2080cca --- /dev/null +++ b/portail/app/Http/Controllers/SessionQuizController.php @@ -0,0 +1,78 @@ +verifierProprietaire($classe); + abort_if($quiz->classe_id !== $classe->id, 404); + abort_if($quiz->questions()->count() === 0, 422, 'Ce quiz n’a encore aucune question.'); + + $session = $quiz->sessions()->create(['statut' => SessionQuiz::EN_ATTENTE, 'demarree_a' => now()]); + + return redirect()->route('sessions-quiz.show', $session); + } + + public function show(SessionQuiz $session): View + { + $this->verifierProprietaireSession($session); + + return view('quiz.session-prof', [ + 'session' => $session->load(['quiz.questions.choix', 'questionCourante.choix']), + 'decompte' => $session->decompteQuestionCourante(), + 'total_eleves' => $session->quiz->classe->eleves()->count(), + ]); + } + + public function etat(SessionQuiz $session): JsonResponse + { + $this->verifierProprietaireSession($session); + + return response()->json([ + 'statut' => $session->statut, + 'question_id' => $session->question_quiz_courante_id, + 'decompte' => $session->decompteQuestionCourante(), + ]); + } + + public function suivante(SessionQuiz $session): RedirectResponse + { + $this->verifierProprietaireSession($session); + $session->passerALaQuestionSuivante(); + + return redirect()->route('sessions-quiz.show', $session); + } + + public function terminer(SessionQuiz $session): RedirectResponse + { + $this->verifierProprietaireSession($session); + $session->terminer(); + + return redirect()->route('mes-classes.quiz.show', [$session->quiz->classe, $session->quiz]); + } + + private function verifierProprietaireSession(SessionQuiz $session): void + { + abort_if($session->quiz->classe->user_id !== Auth::guard('web')->id(), 403); + } + + private function verifierProprietaire(Classe $classe): void + { + abort_if($classe->user_id !== Auth::guard('web')->id(), 403); + } +} diff --git a/portail/app/Models/ChoixQuestionQuiz.php b/portail/app/Models/ChoixQuestionQuiz.php new file mode 100644 index 0000000..308d1af --- /dev/null +++ b/portail/app/Models/ChoixQuestionQuiz.php @@ -0,0 +1,23 @@ + 'boolean', + ]; + + /** @return BelongsTo */ + public function question(): BelongsTo + { + return $this->belongsTo(QuestionQuiz::class, 'question_quiz_id'); + } +} diff --git a/portail/app/Models/Classe.php b/portail/app/Models/Classe.php index 71cabc8..3c6ac7f 100644 --- a/portail/app/Models/Classe.php +++ b/portail/app/Models/Classe.php @@ -67,6 +67,12 @@ class Classe extends Model return $this->hasMany(Seance::class); } + /** @return HasMany */ + public function quizzes(): HasMany + { + return $this->hasMany(Quiz::class)->latest(); + } + public function estArchivee(): bool { return $this->archivee_a !== null; diff --git a/portail/app/Models/QuestionQuiz.php b/portail/app/Models/QuestionQuiz.php new file mode 100644 index 0000000..22343cb --- /dev/null +++ b/portail/app/Models/QuestionQuiz.php @@ -0,0 +1,48 @@ + 'boolean', + ]; + + /** @return BelongsTo */ + public function quiz(): BelongsTo + { + return $this->belongsTo(Quiz::class); + } + + /** @return HasMany */ + public function choix(): HasMany + { + return $this->hasMany(ChoixQuestionQuiz::class)->orderBy('ordre'); + } + + /** + * Vrai si $choixId (QCM) ou $reponseVraiFaux (vrai/faux) est la bonne + * réponse à cette question. Un seul des deux arguments compte, selon + * le type de la question. + */ + public function estCorrecte(?int $choixId, ?bool $reponseVraiFaux): bool + { + if ($this->type === self::VRAI_FAUX) { + return $reponseVraiFaux !== null && $reponseVraiFaux === $this->reponse_vrai_faux; + } + + return $choixId !== null && $this->choix()->where('id', $choixId)->where('est_correct', true)->exists(); + } +} diff --git a/portail/app/Models/Quiz.php b/portail/app/Models/Quiz.php new file mode 100644 index 0000000..730e167 --- /dev/null +++ b/portail/app/Models/Quiz.php @@ -0,0 +1,34 @@ + */ + public function classe(): BelongsTo + { + return $this->belongsTo(Classe::class); + } + + /** @return HasMany */ + public function questions(): HasMany + { + return $this->hasMany(QuestionQuiz::class)->orderBy('ordre'); + } + + /** @return HasMany */ + public function sessions(): HasMany + { + return $this->hasMany(SessionQuiz::class); + } +} diff --git a/portail/app/Models/ReponseQuiz.php b/portail/app/Models/ReponseQuiz.php new file mode 100644 index 0000000..fda788e --- /dev/null +++ b/portail/app/Models/ReponseQuiz.php @@ -0,0 +1,37 @@ + 'boolean', + 'correcte' => 'boolean', + 'repondu_a' => 'datetime', + ]; + + /** @return BelongsTo */ + public function session(): BelongsTo + { + return $this->belongsTo(SessionQuiz::class, 'session_quiz_id'); + } + + /** @return BelongsTo */ + public function question(): BelongsTo + { + return $this->belongsTo(QuestionQuiz::class, 'question_quiz_id'); + } + + /** @return BelongsTo */ + public function eleve(): BelongsTo + { + return $this->belongsTo(Eleve::class); + } +} diff --git a/portail/app/Models/SessionQuiz.php b/portail/app/Models/SessionQuiz.php new file mode 100644 index 0000000..ec5763b --- /dev/null +++ b/portail/app/Models/SessionQuiz.php @@ -0,0 +1,96 @@ + 'datetime', + 'terminee_a' => 'datetime', + ]; + + /** @return BelongsTo */ + public function quiz(): BelongsTo + { + return $this->belongsTo(Quiz::class); + } + + /** @return BelongsTo */ + public function questionCourante(): BelongsTo + { + return $this->belongsTo(QuestionQuiz::class, 'question_quiz_courante_id'); + } + + /** @return HasMany */ + public function reponses(): HasMany + { + return $this->hasMany(ReponseQuiz::class); + } + + /** + * Passe à la question suivante du quiz (dans l'ordre), ou termine la + * session s'il n'y en a plus. Idempotent : rejouable sans effet si déjà + * terminée. + */ + public function passerALaQuestionSuivante(): void + { + if ($this->statut === self::TERMINEE) { + return; + } + + $questions = $this->quiz->questions; + $indiceActuel = $this->question_quiz_courante_id + ? $questions->search(fn ($q) => $q->id === $this->question_quiz_courante_id) + : -1; + $suivante = $questions->get($indiceActuel + 1); + + if ($suivante) { + $this->forceFill(['statut' => self::EN_COURS, 'question_quiz_courante_id' => $suivante->id])->save(); + } else { + $this->terminer(); + } + } + + public function terminer(): void + { + if ($this->statut !== self::TERMINEE) { + $this->forceFill(['statut' => self::TERMINEE, 'terminee_a' => now()])->save(); + } + } + + /** + * Décompte des réponses à la question en cours, pour le panneau du + * prof : combien d'élèves ont déjà répondu, combien ont juste. + * + * @return array{repondu: int, correctes: int} + */ + public function decompteQuestionCourante(): array + { + if (! $this->question_quiz_courante_id) { + return ['repondu' => 0, 'correctes' => 0]; + } + + $reponses = $this->reponses->where('question_quiz_id', $this->question_quiz_courante_id); + + return ['repondu' => $reponses->count(), 'correctes' => $reponses->where('correcte', true)->count()]; + } +} diff --git a/portail/database/migrations/2026_10_13_100000_create_quizzes_table.php b/portail/database/migrations/2026_10_13_100000_create_quizzes_table.php new file mode 100644 index 0000000..fc71cc8 --- /dev/null +++ b/portail/database/migrations/2026_10_13_100000_create_quizzes_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('classe_id')->constrained()->cascadeOnDelete(); + $table->string('titre'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('quizzes'); + } +}; diff --git a/portail/database/migrations/2026_10_13_100001_create_questions_quiz_table.php b/portail/database/migrations/2026_10_13_100001_create_questions_quiz_table.php new file mode 100644 index 0000000..64f1af4 --- /dev/null +++ b/portail/database/migrations/2026_10_13_100001_create_questions_quiz_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('quiz_id')->constrained()->cascadeOnDelete(); + $table->text('enonce'); + $table->string('type')->comment('qcm, vrai_faux'); + $table->unsignedSmallInteger('ordre')->default(0); + // Seulement pour le type vrai_faux : les choix du QCM sont dans + // choix_questions_quiz, chacun avec son propre est_correct. + $table->boolean('reponse_vrai_faux')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('questions_quiz'); + } +}; diff --git a/portail/database/migrations/2026_10_13_100002_create_choix_questions_quiz_table.php b/portail/database/migrations/2026_10_13_100002_create_choix_questions_quiz_table.php new file mode 100644 index 0000000..b02d63c --- /dev/null +++ b/portail/database/migrations/2026_10_13_100002_create_choix_questions_quiz_table.php @@ -0,0 +1,27 @@ +id(); + // constrained() devinerait "question_quizzes" (pluriel anglais du + // nom de colonne) : notre table s'appelle "questions_quiz". + $table->foreignId('question_quiz_id')->constrained('questions_quiz')->cascadeOnDelete(); + $table->string('texte'); + $table->boolean('est_correct')->default(false); + $table->unsignedSmallInteger('ordre')->default(0); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('choix_questions_quiz'); + } +}; diff --git a/portail/database/migrations/2026_10_13_100003_create_sessions_quiz_table.php b/portail/database/migrations/2026_10_13_100003_create_sessions_quiz_table.php new file mode 100644 index 0000000..f58e932 --- /dev/null +++ b/portail/database/migrations/2026_10_13_100003_create_sessions_quiz_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('quiz_id')->constrained()->cascadeOnDelete(); + $table->string('statut')->default('en_attente')->comment('en_attente, en_cours, terminee'); + $table->foreignId('question_quiz_courante_id')->nullable()->constrained('questions_quiz')->nullOnDelete(); + $table->timestamp('demarree_a')->nullable(); + $table->timestamp('terminee_a')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('sessions_quiz'); + } +}; diff --git a/portail/database/migrations/2026_10_13_100004_create_reponses_quiz_table.php b/portail/database/migrations/2026_10_13_100004_create_reponses_quiz_table.php new file mode 100644 index 0000000..0fb8dd1 --- /dev/null +++ b/portail/database/migrations/2026_10_13_100004_create_reponses_quiz_table.php @@ -0,0 +1,39 @@ +id(); + // constrained() devine le nom de la table à partir du nom de la + // colonne (pluriel anglais) : nos tables ont un nom différent + // (sessions_quiz, questions_quiz, choix_questions_quiz), donc + // toujours précisé explicitement ici. + $table->foreignId('session_quiz_id')->constrained('sessions_quiz')->cascadeOnDelete(); + $table->foreignId('question_quiz_id')->constrained('questions_quiz')->cascadeOnDelete(); + $table->foreignId('eleve_id')->constrained()->cascadeOnDelete(); + $table->foreignId('choix_question_quiz_id')->nullable()->constrained('choix_questions_quiz')->nullOnDelete(); + $table->boolean('reponse_vrai_faux')->nullable(); + $table->boolean('correcte'); + $table->timestamp('repondu_a'); + $table->timestamps(); + + $table->unique(['session_quiz_id', 'question_quiz_id', 'eleve_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('reponses_quiz'); + } +}; diff --git a/portail/resources/views/eleve.blade.php b/portail/resources/views/eleve.blade.php index 5a72c74..2860943 100644 --- a/portail/resources/views/eleve.blade.php +++ b/portail/resources/views/eleve.blade.php @@ -15,6 +15,14 @@ Reprendre mon TP + {{-- Chantier « vrai cockpit », étape 2 : Quiz flash, réel, pas de démo. + Caché par défaut ; rempli et affiché par le sondage ci-dessous. --}} + @php($seancesOuvertes = $eleve->classe->seances()->whereIn('statut', [\App\Models\Seance::SALLE_ATTENTE, \App\Models\Seance::EN_COURS])->with('lab')->get()) @if ($seancesOuvertes->isNotEmpty())
@@ -82,4 +90,86 @@
+ @endsection diff --git a/portail/resources/views/layouts/vitrine.blade.php b/portail/resources/views/layouts/vitrine.blade.php index dc10efe..5a26d94 100644 --- a/portail/resources/views/layouts/vitrine.blade.php +++ b/portail/resources/views/layouts/vitrine.blade.php @@ -25,7 +25,7 @@ Syllabus
@@ -53,7 +53,7 @@

Syllabus

Cours, labs, IDE et examens en ligne, du lycée au master.

- +

● Tous les services fonctionnent

  • Hébergé en France
  • Conforme au RGPD · données minimisées
  • 6 cursus · du lycée au BUT
  • 0 € · sans carte bancaire
diff --git a/portail/resources/views/mes-classes/show.blade.php b/portail/resources/views/mes-classes/show.blade.php index 8f6c0f2..a68b2c1 100644 --- a/portail/resources/views/mes-classes/show.blade.php +++ b/portail/resources/views/mes-classes/show.blade.php @@ -30,6 +30,7 @@
  • Faire rejoindre
  • Élèves
  • {{ $classe->estArchivee() ? 'Classe archivée' : 'Séance' }}
  • +
  • Quiz flash
  • @if ($seance)
    diff --git a/portail/resources/views/quiz/index.blade.php b/portail/resources/views/quiz/index.blade.php new file mode 100644 index 0000000..c43c42f --- /dev/null +++ b/portail/resources/views/quiz/index.blade.php @@ -0,0 +1,39 @@ +@extends('layouts.vitrine') + +@section('titre', 'Quiz · '.$classe->nom) + +@section('contenu') +
    +

    {{ $classe->nom }}

    +

    Quiz

    +

    Un quiz se prépare une fois, puis se relance en direct autant de fois que tu veux : les élèves le voient apparaître sur leur tableau de bord, sans code à saisir.

    + +
    +

    Nouveau quiz

    + @error('titre')

    {{ $message }}

    @enderror +
    + @csrf + + +
    +
    + +
    +

    Mes quiz ({{ $quizzes->count() }})

    + @if ($quizzes->isEmpty()) +

    Aucun quiz pour l'instant.

    + @else + + @endif +
    +
    +@endsection diff --git a/portail/resources/views/quiz/session-prof.blade.php b/portail/resources/views/quiz/session-prof.blade.php new file mode 100644 index 0000000..651b52b --- /dev/null +++ b/portail/resources/views/quiz/session-prof.blade.php @@ -0,0 +1,67 @@ +@extends('layouts.vitrine') + +@section('titre', $session->quiz->titre.' · en direct') + +@php($etats = \App\Models\SessionQuiz::class) + +@section('contenu') +
    +

    {{ $session->quiz->classe->nom }}

    +

    {{ $session->quiz->titre }}

    + + @if ($session->statut === $etats::TERMINEE) +
    +

    Quiz terminé.

    + Retour au quiz +
    + @elseif (! $session->questionCourante) +
    +

    Prêt à commencer ({{ $session->quiz->questions->count() }} question(s)).

    +
    + @csrf + +
    +
    + @else + @php($question = $session->questionCourante) + @php($numero = $session->quiz->questions->search(fn ($q) => $q->id === $question->id) + 1) +
    +

    Question {{ $numero }} / {{ $session->quiz->questions->count() }}

    +

    {{ $question->enonce }}

    + @if ($question->type === \App\Models\QuestionQuiz::VRAI_FAUX) +

    Bonne réponse : {{ $question->reponse_vrai_faux ? 'Vrai' : 'Faux' }}

    + @else +
      + @foreach ($question->choix as $choix) +
    • {{ $choix->est_correct ? '✓' : '·' }} {{ $choix->texte }}
    • + @endforeach +
    + @endif + +
    +

    {{ $decompte['repondu'] }} / {{ $total_eleves }} ont répondu

    +

    {{ $decompte['correctes'] }} correctes

    +
    + +
    + @csrf + +
    +
    + @endif +
    + +@endsection diff --git a/portail/resources/views/quiz/show.blade.php b/portail/resources/views/quiz/show.blade.php new file mode 100644 index 0000000..0786191 --- /dev/null +++ b/portail/resources/views/quiz/show.blade.php @@ -0,0 +1,99 @@ +@extends('layouts.vitrine') + +@section('titre', $quiz->titre) + +@section('contenu') +
    +

    Quiz · {{ $classe->nom }}

    +
    +

    {{ $quiz->titre }}

    +
    + @csrf + +
    +
    + @error('questions')

    Ajoute au moins une question avant de lancer.

    @enderror + +
    +

    Questions ({{ $quiz->questions->count() }})

    + @if ($quiz->questions->isEmpty()) +

    Aucune question pour l'instant.

    + @else +
      + @foreach ($quiz->questions as $question) +
    1. +
      +

      {{ $loop->iteration }}. {{ $question->enonce }}

      +
      + @csrf + +
      +
      + @if ($question->type === \App\Models\QuestionQuiz::VRAI_FAUX) +

      Bonne réponse : {{ $question->reponse_vrai_faux ? 'Vrai' : 'Faux' }}

      + @else +
        + @foreach ($question->choix as $choix) +
      • {{ $choix->est_correct ? '✓' : '·' }} {{ $choix->texte }}
      • + @endforeach +
      + @endif +
    2. + @endforeach +
    + @endif +
    + +
    +

    Ajouter une question

    +
    + @csrf + +
    + Type de question + + +
    + +
    +

    Choix (coche la bonne réponse)

    + @for ($i = 0; $i < 4; $i++) + + @endfor +
    + +
    +

    Bonne réponse

    + + +
    + + +
    +
    +
    + +@endsection diff --git a/portail/routes/web.php b/portail/routes/web.php index 47787a0..ddc7f9a 100644 --- a/portail/routes/web.php +++ b/portail/routes/web.php @@ -6,9 +6,12 @@ use App\Http\Controllers\ConsoleController; use App\Http\Controllers\CorrectionController; use App\Http\Controllers\EleveTableauDeBordController; use App\Http\Controllers\InscriptionController; +use App\Http\Controllers\QuizController; +use App\Http\Controllers\QuizEleveController; use App\Http\Controllers\RapportCspController; use App\Http\Controllers\RejoindreClasseController; use App\Http\Controllers\SeanceController; +use App\Http\Controllers\SessionQuizController; use App\Http\Controllers\VisioController; use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken; use Illuminate\Support\Facades\Route; @@ -96,6 +99,12 @@ Route::middleware(['auth:eleve', 'session.unique:eleve'])->group(function () { ->middleware('throttle:6,1') ->name('lab-sessions.corriger'); Route::post('/lab-sessions/{labSession}/machines/{labSessionMachine}/console', [ConsoleController::class, 'pourEleve'])->name('console.eleve'); + + // 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']) + ->middleware('throttle:30,1') + ->name('sessions-quiz.repondre'); }); // --- Espace prof (protégé, session unique) --- @@ -123,4 +132,17 @@ Route::middleware(['auth:web', 'session.unique:web'])->group(function () { Route::post('/seances/{seance}/admettre/{labSession}', [SeanceController::class, 'admettre'])->name('seances.admettre'); Route::get('/seances/{seance}/labsessions/{labSession}/console', [SeanceController::class, 'consoleProf'])->name('seances.console.prof.page'); Route::post('/seances/{seance}/labsessions/{labSession}/machines/{labSessionMachine}/console', [ConsoleController::class, 'pourProf'])->name('console.prof'); + + // Chantier « vrai cockpit », étape 2 : Quiz flash. + Route::get('/mes-classes/{classe}/quiz', [QuizController::class, 'index'])->name('mes-classes.quiz.index'); + Route::post('/mes-classes/{classe}/quiz', [QuizController::class, 'store'])->name('mes-classes.quiz.store'); + Route::get('/mes-classes/{classe}/quiz/{quiz}', [QuizController::class, 'show'])->name('mes-classes.quiz.show'); + Route::post('/mes-classes/{classe}/quiz/{quiz}/supprimer', [QuizController::class, 'destroy'])->name('mes-classes.quiz.destroy'); + Route::post('/mes-classes/{classe}/quiz/{quiz}/questions', [QuizController::class, 'storeQuestion'])->name('mes-classes.quiz.questions.store'); + Route::post('/mes-classes/{classe}/quiz/{quiz}/questions/{question}/supprimer', [QuizController::class, 'destroyQuestion'])->name('mes-classes.quiz.questions.destroy'); + Route::post('/mes-classes/{classe}/quiz/{quiz}/lancer', [SessionQuizController::class, 'lancer'])->name('mes-classes.quiz.lancer'); + Route::get('/sessions-quiz/{session}', [SessionQuizController::class, 'show'])->name('sessions-quiz.show'); + Route::get('/sessions-quiz/{session}/etat', [SessionQuizController::class, 'etat'])->name('sessions-quiz.etat'); + Route::post('/sessions-quiz/{session}/suivante', [SessionQuizController::class, 'suivante'])->name('sessions-quiz.suivante'); + Route::post('/sessions-quiz/{session}/terminer', [SessionQuizController::class, 'terminer'])->name('sessions-quiz.terminer'); }); diff --git a/portail/tests/Feature/QuizTest.php b/portail/tests/Feature/QuizTest.php new file mode 100644 index 0000000..d100afa --- /dev/null +++ b/portail/tests/Feature/QuizTest.php @@ -0,0 +1,209 @@ +create(); + $eleve1 = Eleve::factory()->create(['classe_id' => $classe->id]); + $eleve2 = Eleve::factory()->create(['classe_id' => $classe->id]); + $quiz = Quiz::create(['classe_id' => $classe->id, 'titre' => 'Capitales']); + + $qcm = $quiz->questions()->create(['enonce' => 'Capitale de la France ?', 'type' => QuestionQuiz::QCM, 'ordre' => 0]); + $bonChoix = $qcm->choix()->create(['texte' => 'Paris', 'est_correct' => true, 'ordre' => 0]); + $qcm->choix()->create(['texte' => 'Lyon', 'est_correct' => false, 'ordre' => 1]); + + $vf = $quiz->questions()->create(['enonce' => 'Paris est en France.', 'type' => QuestionQuiz::VRAI_FAUX, 'ordre' => 1, 'reponse_vrai_faux' => true]); + + return compact('classe', 'quiz', 'qcm', 'vf', 'bonChoix', 'eleve1', 'eleve2'); +} + +test('un prof crée un quiz puis des questions QCM et vrai/faux', function () { + $classe = Classe::factory()->create(); + + commeProf($this, $classe->prof) + ->post(route('mes-classes.quiz.store', $classe), ['titre' => 'Capitales']) + ->assertRedirect(); + + $quiz = Quiz::where('titre', 'Capitales')->first(); + expect($quiz)->not->toBeNull()->and($quiz->classe_id)->toBe($classe->id); + + commeProf($this, $classe->prof)->post(route('mes-classes.quiz.questions.store', [$classe, $quiz]), [ + 'enonce' => 'Capitale de la France ?', 'type' => 'qcm', + 'choix' => ['Paris', 'Lyon'], 'bonne_reponse' => 0, + ])->assertRedirect(); + + commeProf($this, $classe->prof)->post(route('mes-classes.quiz.questions.store', [$classe, $quiz]), [ + 'enonce' => 'Paris est en France.', 'type' => 'vrai_faux', 'reponse_vrai_faux' => '1', + ])->assertRedirect(); + + $quiz->refresh(); + expect($quiz->questions)->toHaveCount(2); + $qcm = $quiz->questions->firstWhere('type', 'qcm'); + expect($qcm->choix)->toHaveCount(2) + ->and($qcm->choix->firstWhere('texte', 'Paris')->est_correct)->toBeTrue(); + $vf = $quiz->questions->firstWhere('type', 'vrai_faux'); + expect($vf->reponse_vrai_faux)->toBeTrue(); +}); + +test('un prof d’une autre classe ne peut pas voir ni modifier ce quiz', function () { + ['classe' => $classe, 'quiz' => $quiz] = quizPret(); + $autreClasse = Classe::factory()->create(); + + commeProf($this, $autreClasse->prof)->get(route('mes-classes.quiz.show', [$classe, $quiz]))->assertForbidden(); +}); + +test('lancer un quiz sans question est refusé', function () { + $classe = Classe::factory()->create(); + $quiz = Quiz::create(['classe_id' => $classe->id, 'titre' => 'Vide']); + + commeProf($this, $classe->prof)->post(route('mes-classes.quiz.lancer', [$classe, $quiz]))->assertStatus(422); +}); + +test('le prof avance les questions une par une, puis la session se termine', function () { + ['classe' => $classe, 'quiz' => $quiz, 'qcm' => $qcm, 'vf' => $vf] = quizPret(); + + commeProf($this, $classe->prof)->post(route('mes-classes.quiz.lancer', [$classe, $quiz]))->assertRedirect(); + $session = SessionQuiz::where('quiz_id', $quiz->id)->first(); + expect($session->statut)->toBe(SessionQuiz::EN_ATTENTE)->and($session->question_quiz_courante_id)->toBeNull(); + + commeProf($this, $classe->prof)->post(route('sessions-quiz.suivante', $session)); + expect($session->refresh()->statut)->toBe(SessionQuiz::EN_COURS) + ->and($session->question_quiz_courante_id)->toBe($qcm->id); + + commeProf($this, $classe->prof)->post(route('sessions-quiz.suivante', $session)); + expect($session->refresh()->question_quiz_courante_id)->toBe($vf->id); + + commeProf($this, $classe->prof)->post(route('sessions-quiz.suivante', $session)); + expect($session->refresh()->statut)->toBe(SessionQuiz::TERMINEE); +}); + +test('un élève voit la question en cours (QCM avec ses choix, vrai/faux sans)', function () { + ['classe' => $classe, 'quiz' => $quiz, 'qcm' => $qcm, 'eleve1' => $eleve1] = quizPret(); + $session = SessionQuiz::create(['quiz_id' => $quiz->id, 'statut' => SessionQuiz::EN_COURS, 'question_quiz_courante_id' => $qcm->id, 'demarree_a' => now()]); + + eleve($this, $eleve1)->getJson(route('quiz.eleve.etat')) + ->assertJsonPath('session.statut', SessionQuiz::EN_COURS) + ->assertJsonPath('session.question.enonce', 'Capitale de la France ?') + ->assertJsonCount(2, 'session.question.choix') + ->assertJsonPath('session.deja_repondu', false); +}); + +test('un élève d’une autre classe ne voit rien', function () { + ['quiz' => $quiz, 'qcm' => $qcm] = quizPret(); + SessionQuiz::create(['quiz_id' => $quiz->id, 'statut' => SessionQuiz::EN_COURS, 'question_quiz_courante_id' => $qcm->id, 'demarree_a' => now()]); + $autreEleve = Eleve::factory()->create(); + + eleve($this, $autreEleve)->getJson(route('quiz.eleve.etat'))->assertJsonPath('session', null); +}); + +test('un élève répond juste ou faux au QCM, le décompte du prof se met à jour, pas deux fois', function () { + ['classe' => $classe, 'quiz' => $quiz, 'qcm' => $qcm, 'bonChoix' => $bonChoix, 'eleve1' => $eleve1, 'eleve2' => $eleve2] = quizPret(); + $session = SessionQuiz::create(['quiz_id' => $quiz->id, 'statut' => SessionQuiz::EN_COURS, 'question_quiz_courante_id' => $qcm->id, 'demarree_a' => now()]); + $mauvaisChoix = $qcm->choix->firstWhere('texte', 'Lyon'); + + eleve($this, $eleve1)->postJson(route('sessions-quiz.repondre', $session), ['choix_id' => $bonChoix->id]) + ->assertOk()->assertJson(['correcte' => true]); + eleve($this, $eleve2)->postJson(route('sessions-quiz.repondre', $session), ['choix_id' => $mauvaisChoix->id]) + ->assertOk()->assertJson(['correcte' => false]); + + // Un second envoi (double clic, ou tentative de changer d'avis) est refusé. + eleve($this, $eleve1)->postJson(route('sessions-quiz.repondre', $session), ['choix_id' => $mauvaisChoix->id]) + ->assertStatus(409); + + expect(ReponseQuiz::where('session_quiz_id', $session->id)->count())->toBe(2); + expect($session->decompteQuestionCourante())->toBe(['repondu' => 2, 'correctes' => 1]); + + eleve($this, $eleve1)->getJson(route('quiz.eleve.etat'))->assertJsonPath('session.deja_repondu', true); + + commeProf($this, $classe->prof)->getJson(route('sessions-quiz.etat', $session)) + ->assertJsonPath('decompte.repondu', 2)->assertJsonPath('decompte.correctes', 1); +}); + +test('un élève répond juste ou faux au vrai/faux', function () { + ['quiz' => $quiz, 'vf' => $vf, 'eleve1' => $eleve1, 'eleve2' => $eleve2] = quizPret(); + $session = SessionQuiz::create(['quiz_id' => $quiz->id, 'statut' => SessionQuiz::EN_COURS, 'question_quiz_courante_id' => $vf->id, 'demarree_a' => now()]); + + eleve($this, $eleve1)->postJson(route('sessions-quiz.repondre', $session), ['reponse_vrai_faux' => true]) + ->assertJson(['correcte' => true]); + eleve($this, $eleve2)->postJson(route('sessions-quiz.repondre', $session), ['reponse_vrai_faux' => false]) + ->assertJson(['correcte' => false]); +}); + +test('un élève ne peut pas répondre à une question qui n’est plus la question en cours', function () { + ['quiz' => $quiz, 'qcm' => $qcm, 'vf' => $vf, 'bonChoix' => $bonChoix, 'eleve1' => $eleve1] = quizPret(); + // La session a déjà avancé à la question vrai/faux. + $session = SessionQuiz::create(['quiz_id' => $quiz->id, 'statut' => SessionQuiz::EN_COURS, 'question_quiz_courante_id' => $vf->id, 'demarree_a' => now()]); + + // Tenter de répondre au QCM (ancienne question) avec le format attendu du + // vrai/faux ignoré : le contrôleur ne regarde que la question courante. + eleve($this, $eleve1)->postJson(route('sessions-quiz.repondre', $session), ['choix_id' => $bonChoix->id]) + ->assertOk(); // enregistrée comme réponse à la question courante (vrai/faux), choix_id ignoré côté correction + + $reponse = ReponseQuiz::first(); + expect($reponse->question_quiz_id)->toBe($vf->id) + ->and($reponse->correcte)->toBeFalse(); // reponse_vrai_faux absent du corps => jamais correcte +}); + +test('archiver puis purger une classe supprime aussi ses quiz, questions, choix, sessions et réponses', function () { + ['classe' => $classe, 'quiz' => $quiz, 'qcm' => $qcm, 'bonChoix' => $bonChoix, 'eleve1' => $eleve1] = quizPret(); + $session = SessionQuiz::create(['quiz_id' => $quiz->id, 'statut' => SessionQuiz::EN_COURS, 'question_quiz_courante_id' => $qcm->id, 'demarree_a' => now()]); + ReponseQuiz::create(['session_quiz_id' => $session->id, 'question_quiz_id' => $qcm->id, 'eleve_id' => $eleve1->id, 'choix_question_quiz_id' => $bonChoix->id, 'correcte' => true, 'repondu_a' => now()]); + + $classe->forceFill(['archivee_a' => now()->subDays(91)])->save(); + $this->artisan('classes:purger-archivees'); + + expect(Quiz::count())->toBe(0) + ->and(QuestionQuiz::count())->toBe(0) + ->and(ChoixQuestionQuiz::count())->toBe(0) + ->and(SessionQuiz::count())->toBe(0) + ->and(ReponseQuiz::count())->toBe(0); +}); + +// Ces pages sont protégées (auth:web, auth:eleve) : hors du périmètre de +// PagesPubliquesTest, qui ne couvre que les pages sans authentification. +test('les scripts en ligne des pages du quiz flash sont du JavaScript valide', function () { + if (Process::run('node --version')->failed()) { + $this->markTestSkipped('Node absent'); + } + + ['classe' => $classe, 'quiz' => $quiz, 'eleve1' => $eleve1] = quizPret(); + $session = SessionQuiz::create(['quiz_id' => $quiz->id, 'statut' => SessionQuiz::EN_ATTENTE, 'demarree_a' => now()]); + + $pages = [ + fn () => commeProf($this, $classe->prof)->get(route('mes-classes.quiz.show', [$classe, $quiz])), + fn () => commeProf($this, $classe->prof)->get(route('sessions-quiz.show', $session)), + fn () => eleve($this, $eleve1)->get(route('eleve')), + ]; + + $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([]); +});