From bd74eaf20f81591ff45114f6003f3d5dc83698aa Mon Sep 17 00:00:00 2001 From: Adam Belkacemi Date: Tue, 13 Oct 2026 12:25:42 +0200 Subject: [PATCH] ajoute la banque de quiz : partager un quiz, le dupliquer dans une autre classe Co-Authored-By: Claude Sonnet 5 --- docs/dette.md | 1 + docs/journal.md | 22 +++++++ .../Http/Controllers/BanqueQuizController.php | 63 +++++++++++++++++++ portail/app/Models/Quiz.php | 39 +++++++++++- ...13_110000_add_partage_to_quizzes_table.php | 29 +++++++++ portail/resources/views/quiz/banque.blade.php | 43 +++++++++++++ portail/resources/views/quiz/index.blade.php | 1 + portail/resources/views/quiz/show.blade.php | 17 +++-- portail/routes/web.php | 4 ++ portail/tests/Feature/QuizTest.php | 57 +++++++++++++++++ 10 files changed, 271 insertions(+), 5 deletions(-) create mode 100644 portail/app/Http/Controllers/BanqueQuizController.php create mode 100644 portail/database/migrations/2026_10_13_110000_add_partage_to_quizzes_table.php create mode 100644 portail/resources/views/quiz/banque.blade.php diff --git a/docs/dette.md b/docs/dette.md index b22c11a..f98aa95 100644 --- a/docs/dette.md +++ b/docs/dette.md @@ -29,3 +29,4 @@ retirer la ligne (et le noter dans le journal) quand c'est fait. | Image Jitsi figée sans procédure de mise à jour | `stable-11248` : comme pour Kuma, prévoir la montée de version par Ansible (variable `visio_version`) et rejouer `visio-e2e.mjs`. | Bloc B | Étape 15 | | Self-healing : pas d'alerte Prometheus dédiée | `syllabus_vm_panne_generale` et `syllabus_vm_irreparables` sont publiées mais sans règle d'alerte ; seul le prof voit une VM irréparable dans la salle d'attente. | Avant le pilote | 2026-10-12 | | Self-healing : ne couvre que les VM (pas le serveur, déjà en dette, ni les conteneurs/services, déjà `Restart=always`) | Documenté dans `docs/runbooks/self-healing.md`, pas une lacune à corriger — rappel pour ne pas le redemander. | — | 2026-10-12 | +| Banque de quiz : un quiz partagé disparaît si la classe d'origine est archivée puis purgée (RGPD, 90 jours) | La banque n'a pas de propriétaire indépendant d'une classe (pas de `user_id` ni de `classe_id` nullable sur `quizzes`, pour rester simple). À corriger si des profs partagent des quiz qu'ils veulent garder au-delà de la durée de vie d'une classe. | Si ça gêne en usage réel | 2026-10-13 | diff --git a/docs/journal.md b/docs/journal.md index 38d6c64..a51a497 100644 --- a/docs/journal.md +++ b/docs/journal.md @@ -913,3 +913,25 @@ Le bug de migration est un bon rappel que les conventions de Laravel ne devinent **Suite** Chantier « vrai cockpit », étape 3 : Devoirs maison (prochaine fonction). + +## 2026-10-13 — Chantier « vrai cockpit » : banque de quiz + +Demande d'Adam en continuant Quiz flash : « c'est dommage qu'on ne puisse pas puiser dans une banque de quiz ! » — cohérent avec la bibliothèque déjà prévue au CLAUDE.md (« Dupliquer et adapter »), étendue ici au quiz. + +**Fait** + +- `Quiz.partage` (booléen) : un prof partage un de ses quiz, visible par tous les autres profs dans `/banque-quiz` (établissement d'origine affiché, jamais les notes des élèves). +- `Quiz::dupliquerDans(Classe)` : copie indépendante (questions et choix compris, jamais les sessions ni les réponses) dans une classe du prof qui duplique. La copie n'est jamais partagée d'office : modifier la copie ne touche jamais l'original, et réciproquement. +- Pas de nouvelle table : partager ne change pas le propriétaire du quiz d'origine, dupliquer en crée toujours une copie à part. +- 3 nouveaux tests Pest (75 au total) : partage puis duplication avec vérification que la copie est une vraie copie (identifiants différents) et non liée ; refus de dupliquer un quiz non partagé ou vers la classe d'un autre ; seul le propriétaire peut partager/retirer. +- Dette notée : un quiz partagé disparaît de la banque si sa classe d'origine est purgée (RGPD, 90 jours) — pas de propriétaire indépendant d'une classe pour l'instant, acceptable tant que ça ne gêne pas en usage réel. + +**Vérifié sur le serveur réel** (deux profs d'établissements différents, scénario jetable supprimé ensuite) : le prof A partage son quiz « Capitale du Japon » ; le prof B le voit dans la banque avec le nom de l'établissement d'origine, le duplique dans sa propre classe ; la copie a un identifiant différent, le même contenu (question et choix), `partage` à faux ; l'original du prof A n'a pas bougé. + +**Comprendre** + +La bonne nouvelle de cette fonction, c'est qu'elle n'a presque rien coûté en complexité : un seul booléen et une méthode de copie, parce que le modèle de données du quiz (questions, choix, séparés de leur lancement en direct) était déjà pensé pour qu'un quiz soit un objet autonome, réutilisable. C'est souvent le signe qu'une étape précédente a été bien conçue : la suivante devient presque gratuite. + +**Suite** + +Chantier « vrai cockpit », étape 3 : Devoirs maison. diff --git a/portail/app/Http/Controllers/BanqueQuizController.php b/portail/app/Http/Controllers/BanqueQuizController.php new file mode 100644 index 0000000..1b35fe2 --- /dev/null +++ b/portail/app/Http/Controllers/BanqueQuizController.php @@ -0,0 +1,63 @@ +user(); + + return view('quiz.banque', [ + 'quizzes' => Quiz::where('partage', true) + ->with(['classe.etablissement']) + ->withCount('questions') + ->latest() + ->get(), + 'mesClasses' => $prof->classes()->whereNull('archivee_a')->orderBy('nom')->get(), + ]); + } + + public function partager(Classe $classe, Quiz $quiz): RedirectResponse + { + $this->verifierProprietaire($classe); + abort_if($quiz->classe_id !== $classe->id, 404); + + $quiz->update(['partage' => ! $quiz->partage]); + + return redirect()->route('mes-classes.quiz.show', [$classe, $quiz]); + } + + public function dupliquer(Request $request, Quiz $quiz): RedirectResponse + { + abort_unless($quiz->partage, 404); + $prof = Auth::guard('web')->user(); + + $donnees = $request->validate(['classe_id' => ['required', 'integer']]); + $classe = $prof->classes()->whereNull('archivee_a')->findOrFail($donnees['classe_id']); + + $copie = $quiz->dupliquerDans($classe); + + return redirect()->route('mes-classes.quiz.show', [$classe, $copie])->with('quiz_duplique', true); + } + + private function verifierProprietaire(Classe $classe): void + { + abort_if($classe->user_id !== Auth::guard('web')->id(), 403); + } +} diff --git a/portail/app/Models/Quiz.php b/portail/app/Models/Quiz.php index 730e167..d9f4002 100644 --- a/portail/app/Models/Quiz.php +++ b/portail/app/Models/Quiz.php @@ -5,6 +5,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Support\Facades\DB; /** * Banque de questions réutilisable, propre à une classe (chantier « vrai @@ -12,7 +13,11 @@ use Illuminate\Database\Eloquent\Relations\HasMany; */ class Quiz extends Model { - protected $fillable = ['classe_id', 'titre']; + protected $fillable = ['classe_id', 'titre', 'partage']; + + protected $casts = [ + 'partage' => 'boolean', + ]; /** @return BelongsTo */ public function classe(): BelongsTo @@ -31,4 +36,36 @@ class Quiz extends Model { return $this->hasMany(SessionQuiz::class); } + + /** + * Copie indépendante de ce quiz (questions et choix compris) dans une + * autre classe — jamais ses sessions ni ses réponses. La copie n'est + * jamais partagée elle-même : partager de nouveau est un choix + * volontaire du prof qui la reçoit, pas hérité. + */ + public function dupliquerDans(Classe $classe): self + { + return DB::transaction(function () use ($classe) { + $copie = self::create(['classe_id' => $classe->id, 'titre' => $this->titre, 'partage' => false]); + + foreach ($this->questions as $question) { + $copieQuestion = $copie->questions()->create([ + 'enonce' => $question->enonce, + 'type' => $question->type, + 'ordre' => $question->ordre, + 'reponse_vrai_faux' => $question->reponse_vrai_faux, + ]); + + foreach ($question->choix as $choix) { + $copieQuestion->choix()->create([ + 'texte' => $choix->texte, + 'est_correct' => $choix->est_correct, + 'ordre' => $choix->ordre, + ]); + } + } + + return $copie; + }); + } } diff --git a/portail/database/migrations/2026_10_13_110000_add_partage_to_quizzes_table.php b/portail/database/migrations/2026_10_13_110000_add_partage_to_quizzes_table.php new file mode 100644 index 0000000..5050d93 --- /dev/null +++ b/portail/database/migrations/2026_10_13_110000_add_partage_to_quizzes_table.php @@ -0,0 +1,29 @@ +boolean('partage')->default(false)->after('titre'); + }); + } + + public function down(): void + { + Schema::table('quizzes', function (Blueprint $table) { + $table->dropColumn('partage'); + }); + } +}; diff --git a/portail/resources/views/quiz/banque.blade.php b/portail/resources/views/quiz/banque.blade.php new file mode 100644 index 0000000..f5ec9d1 --- /dev/null +++ b/portail/resources/views/quiz/banque.blade.php @@ -0,0 +1,43 @@ +@extends('layouts.vitrine') + +@section('titre', 'Banque de quiz') + +@section('contenu') +
+

Mes classes

+

Banque de quiz

+

Les quiz que d'autres profs ont partagés. Dupliquer en fait une copie indépendante dans une de tes classes : tu peux ensuite la modifier sans toucher à l'original.

+ + @if (session('quiz_duplique')) +

Quiz dupliqué dans ta classe.

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

Aucun quiz partagé pour l'instant.

+ @elseif ($mesClasses->isEmpty()) +

Crée d'abord une classe pour pouvoir y dupliquer un quiz.

+ @else +
    + @foreach ($quizzes as $quiz) +
  • +
    +
    +

    {{ $quiz->titre }}

    +

    {{ $quiz->questions_count }} question(s) · partagé par {{ $quiz->classe->etablissement->nom }}

    +
    +
    + @csrf + + +
    +
    +
  • + @endforeach +
+ @endif +
+@endsection diff --git a/portail/resources/views/quiz/index.blade.php b/portail/resources/views/quiz/index.blade.php index c43c42f..73b7782 100644 --- a/portail/resources/views/quiz/index.blade.php +++ b/portail/resources/views/quiz/index.blade.php @@ -7,6 +7,7 @@

{{ $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.

+

Puiser dans la banque de quiz partagés par d'autres profs →

Nouveau quiz

diff --git a/portail/resources/views/quiz/show.blade.php b/portail/resources/views/quiz/show.blade.php index 0786191..27ab5ee 100644 --- a/portail/resources/views/quiz/show.blade.php +++ b/portail/resources/views/quiz/show.blade.php @@ -7,11 +7,20 @@

Quiz · {{ $classe->nom }}

{{ $quiz->titre }}

-
- @csrf - -
+ +
+ @csrf + +
+
+ @csrf + +
+
+ @if ($quiz->partage) +

Partagé dans la banque de quiz : les autres profs peuvent le dupliquer.

+ @endif @error('questions')

Ajoute au moins une question avant de lancer.

@enderror
diff --git a/portail/routes/web.php b/portail/routes/web.php index ddc7f9a..698cf98 100644 --- a/portail/routes/web.php +++ b/portail/routes/web.php @@ -1,6 +1,7 @@ group(function () { 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'); + Route::get('/banque-quiz', [BanqueQuizController::class, 'index'])->name('banque-quiz.index'); + Route::post('/mes-classes/{classe}/quiz/{quiz}/partager', [BanqueQuizController::class, 'partager'])->name('mes-classes.quiz.partager'); + Route::post('/banque-quiz/{quiz}/dupliquer', [BanqueQuizController::class, 'dupliquer'])->name('banque-quiz.dupliquer'); }); diff --git a/portail/tests/Feature/QuizTest.php b/portail/tests/Feature/QuizTest.php index d100afa..37bebfc 100644 --- a/portail/tests/Feature/QuizTest.php +++ b/portail/tests/Feature/QuizTest.php @@ -207,3 +207,60 @@ test('les scripts en ligne des pages du quiz flash sont du JavaScript valide', f expect($erreurs)->toBe([]); }); + +test('un prof partage un quiz, un autre prof le voit dans la banque et le duplique dans sa propre classe', function () { + ['classe' => $classe, 'quiz' => $quiz] = quizPret(); + $autreClasse = Classe::factory()->create(); + + commeProf($this, $classe->prof)->post(route('mes-classes.quiz.partager', [$classe, $quiz]))->assertRedirect(); + expect($quiz->refresh()->partage)->toBeTrue(); + + commeProf($this, $autreClasse->prof)->get(route('banque-quiz.index')) + ->assertSee('Capitales') + ->assertSee($classe->etablissement->nom); + + commeProf($this, $autreClasse->prof) + ->post(route('banque-quiz.dupliquer', $quiz), ['classe_id' => $autreClasse->id]) + ->assertRedirect(); + + $copie = Quiz::where('classe_id', $autreClasse->id)->first(); + expect($copie)->not->toBeNull() + ->and($copie->id)->not->toBe($quiz->id) + ->and($copie->titre)->toBe('Capitales') + ->and($copie->partage)->toBeFalse() // la copie n'est pas partagée d'office + ->and($copie->questions)->toHaveCount(2); + + $copieQcm = $copie->questions->firstWhere('type', 'qcm'); + expect($copieQcm->choix)->toHaveCount(2) + ->and($copieQcm->choix->firstWhere('texte', 'Paris')->est_correct)->toBeTrue() + ->and($copieQcm->id)->not->toBe($quiz->questions->firstWhere('type', 'qcm')->id); // vraie copie, pas une référence + + // Modifier la copie ne doit jamais toucher l'original. + $copie->update(['titre' => 'Capitales (adapté)']); + expect($quiz->refresh()->titre)->toBe('Capitales'); +}); + +test('un prof ne peut pas dupliquer un quiz non partagé, ni dans la classe d’un autre prof', function () { + ['classe' => $classe, 'quiz' => $quiz] = quizPret(); + $autreClasse = Classe::factory()->create(); + + // Pas partagé : 404. + commeProf($this, $autreClasse->prof) + ->post(route('banque-quiz.dupliquer', $quiz), ['classe_id' => $autreClasse->id]) + ->assertNotFound(); + + $quiz->update(['partage' => true]); + + // Partagé, mais vers une classe qui n'est pas la sienne : refusé. + commeProf($this, $autreClasse->prof) + ->post(route('banque-quiz.dupliquer', $quiz), ['classe_id' => $classe->id]) + ->assertNotFound(); +}); + +test('seul le prof propriétaire peut partager ou retirer son quiz de la banque', function () { + ['classe' => $classe, 'quiz' => $quiz] = quizPret(); + $autreClasse = Classe::factory()->create(); + + commeProf($this, $autreClasse->prof)->post(route('mes-classes.quiz.partager', [$classe, $quiz]))->assertForbidden(); + expect($quiz->refresh()->partage)->toBeFalse(); +});