ajoute la banque de quiz : partager un quiz, le dupliquer dans une autre classe

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Adam Belkacemi 2026-10-13 12:25:42 +02:00
parent 16d1293911
commit bd74eaf20f
10 changed files with 271 additions and 5 deletions

View file

@ -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 | | 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 : 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 | | 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 |

View file

@ -913,3 +913,25 @@ Le bug de migration est un bon rappel que les conventions de Laravel ne devinent
**Suite** **Suite**
Chantier « vrai cockpit », étape 3 : Devoirs maison (prochaine fonction). 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.

View file

@ -0,0 +1,63 @@
<?php
namespace App\Http\Controllers;
use App\Models\Classe;
use App\Models\Quiz;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
/**
* Banque de quiz (demande d'Adam, 2026-10-13) : tout prof peut partager un
* de ses quiz pour que les autres le dupliquent dans leur propre classe —
* même esprit que « Dupliquer et adapter » prévu pour la bibliothèque de
* cours (CLAUDE.md). Dette connue : un quiz partagé disparaît de la banque
* si la classe d'où il vient est archivée puis purgée (RGPD, 90 jours) —
* la banque n'a pas de propriétaire indépendant d'une classe pour l'instant.
*/
class BanqueQuizController extends Controller
{
public function index(): View
{
$prof = Auth::guard('web')->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);
}
}

View file

@ -5,6 +5,7 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\DB;
/** /**
* Banque de questions réutilisable, propre à une classe (chantier « vrai * Banque de questions réutilisable, propre à une classe (chantier « vrai
@ -12,7 +13,11 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
*/ */
class Quiz extends Model class Quiz extends Model
{ {
protected $fillable = ['classe_id', 'titre']; protected $fillable = ['classe_id', 'titre', 'partage'];
protected $casts = [
'partage' => 'boolean',
];
/** @return BelongsTo<Classe, $this> */ /** @return BelongsTo<Classe, $this> */
public function classe(): BelongsTo public function classe(): BelongsTo
@ -31,4 +36,36 @@ class Quiz extends Model
{ {
return $this->hasMany(SessionQuiz::class); 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;
});
}
} }

View file

@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Banque de quiz (demande d'Adam, 2026-10-13) : un prof peut partager un
* quiz pour que les autres le dupliquent dans leur propre classe — même
* esprit que la bibliothèque de cours du CLAUDE.md (« Dupliquer et
* adapter »). Pas de nouvelle table : partager un quiz ne change pas son
* propriétaire, dupliquer en crée toujours une copie indépendante.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('quizzes', function (Blueprint $table) {
$table->boolean('partage')->default(false)->after('titre');
});
}
public function down(): void
{
Schema::table('quizzes', function (Blueprint $table) {
$table->dropColumn('partage');
});
}
};

View file

@ -0,0 +1,43 @@
@extends('layouts.vitrine')
@section('titre', 'Banque de quiz')
@section('contenu')
<div class="mx-auto max-w-4xl px-5 py-14 lg:px-10">
<p class="font-mono text-sm font-semibold"><a href="{{ route('mes-classes.index') }}" class="underline underline-offset-4">Mes classes</a></p>
<h1 class="mt-1 text-4xl font-extrabold tracking-tight">Banque de quiz</h1>
<p class="mt-2 font-semibold">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.</p>
@if (session('quiz_duplique'))
<p class="mt-4 rounded-2xl border border-[#e3e5e8] bg-[#e6f4ea] p-4 font-semibold">Quiz dupliqué dans ta classe.</p>
@endif
@if ($quizzes->isEmpty())
<p class="mt-8 font-semibold">Aucun quiz partagé pour l'instant.</p>
@elseif ($mesClasses->isEmpty())
<p class="mt-8 font-semibold">Crée d'abord une classe pour pouvoir y dupliquer un quiz.</p>
@else
<ul class="mt-8 grid gap-4">
@foreach ($quizzes as $quiz)
<li class="rounded-2xl border border-[#e3e5e8] bg-white p-5">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<p class="text-lg font-extrabold">{{ $quiz->titre }}</p>
<p class="font-mono text-sm font-semibold text-[#5f6368]">{{ $quiz->questions_count }} question(s) · partagé par {{ $quiz->classe->etablissement->nom }}</p>
</div>
<form action="{{ route('banque-quiz.dupliquer', $quiz) }}" method="post" class="flex flex-wrap items-center gap-2">
@csrf
<select name="classe_id" required class="rounded-xl border border-[#c4c7c5] bg-white px-3 py-2 font-semibold">
@foreach ($mesClasses as $classe)
<option value="{{ $classe->id }}">{{ $classe->nom }}</option>
@endforeach
</select>
<button type="submit" class="inline-flex h-11 items-center rounded-full bg-[#0b57d0] px-5 font-bold text-white">Dupliquer</button>
</form>
</div>
</li>
@endforeach
</ul>
@endif
</div>
@endsection

View file

@ -7,6 +7,7 @@
<p class="font-mono text-sm font-semibold"><a href="{{ route('mes-classes.show', $classe) }}" class="underline underline-offset-4">{{ $classe->nom }}</a></p> <p class="font-mono text-sm font-semibold"><a href="{{ route('mes-classes.show', $classe) }}" class="underline underline-offset-4">{{ $classe->nom }}</a></p>
<h1 class="mt-1 text-4xl font-extrabold tracking-tight">Quiz</h1> <h1 class="mt-1 text-4xl font-extrabold tracking-tight">Quiz</h1>
<p class="mt-2 font-semibold">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.</p> <p class="mt-2 font-semibold">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.</p>
<p class="mt-2"><a href="{{ route('banque-quiz.index') }}" class="font-bold underline underline-offset-4">Puiser dans la banque de quiz partagés par d'autres profs →</a></p>
<section class="mt-8 rounded-2xl border border-[#e3e5e8] bg-white p-6" aria-labelledby="t-nouveau"> <section class="mt-8 rounded-2xl border border-[#e3e5e8] bg-white p-6" aria-labelledby="t-nouveau">
<h2 id="t-nouveau" class="text-xl font-extrabold">Nouveau quiz</h2> <h2 id="t-nouveau" class="text-xl font-extrabold">Nouveau quiz</h2>

View file

@ -7,11 +7,20 @@
<p class="font-mono text-sm font-semibold"><a href="{{ route('mes-classes.quiz.index', $classe) }}" class="underline underline-offset-4">Quiz · {{ $classe->nom }}</a></p> <p class="font-mono text-sm font-semibold"><a href="{{ route('mes-classes.quiz.index', $classe) }}" class="underline underline-offset-4">Quiz · {{ $classe->nom }}</a></p>
<div class="mt-1 flex flex-wrap items-center justify-between gap-4"> <div class="mt-1 flex flex-wrap items-center justify-between gap-4">
<h1 class="text-4xl font-extrabold tracking-tight">{{ $quiz->titre }}</h1> <h1 class="text-4xl font-extrabold tracking-tight">{{ $quiz->titre }}</h1>
<span class="flex flex-wrap gap-2">
<form action="{{ route('mes-classes.quiz.partager', [$classe, $quiz]) }}" method="post">
@csrf
<button type="submit" class="inline-flex h-11 items-center rounded-full border border-[#dadce0] px-5 font-bold hover:bg-[#f8f9fa]">{{ $quiz->partage ? 'Retirer de la banque' : 'Partager dans la banque' }}</button>
</form>
<form action="{{ route('mes-classes.quiz.lancer', [$classe, $quiz]) }}" method="post"> <form action="{{ route('mes-classes.quiz.lancer', [$classe, $quiz]) }}" method="post">
@csrf @csrf
<button type="submit" class="inline-flex h-11 items-center rounded-full bg-black px-5 font-bold text-white" @disabled($quiz->questions->isEmpty())>Lancer ce quiz</button> <button type="submit" class="inline-flex h-11 items-center rounded-full bg-black px-5 font-bold text-white" @disabled($quiz->questions->isEmpty())>Lancer ce quiz</button>
</form> </form>
</span>
</div> </div>
@if ($quiz->partage)
<p class="mt-2 font-mono text-sm font-semibold text-[#0b57d0]">Partagé dans la banque de quiz : les autres profs peuvent le dupliquer.</p>
@endif
@error('questions')<p class="mt-2 font-bold text-[#A63D2A]">Ajoute au moins une question avant de lancer.</p>@enderror @error('questions')<p class="mt-2 font-bold text-[#A63D2A]">Ajoute au moins une question avant de lancer.</p>@enderror
<section class="mt-8" aria-labelledby="t-questions"> <section class="mt-8" aria-labelledby="t-questions">

View file

@ -1,6 +1,7 @@
<?php <?php
use App\Http\Controllers\Auth\ConnexionController; use App\Http\Controllers\Auth\ConnexionController;
use App\Http\Controllers\BanqueQuizController;
use App\Http\Controllers\ClasseController; use App\Http\Controllers\ClasseController;
use App\Http\Controllers\ConsoleController; use App\Http\Controllers\ConsoleController;
use App\Http\Controllers\CorrectionController; use App\Http\Controllers\CorrectionController;
@ -145,4 +146,7 @@ Route::middleware(['auth:web', 'session.unique:web'])->group(function () {
Route::get('/sessions-quiz/{session}/etat', [SessionQuizController::class, 'etat'])->name('sessions-quiz.etat'); 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}/suivante', [SessionQuizController::class, 'suivante'])->name('sessions-quiz.suivante');
Route::post('/sessions-quiz/{session}/terminer', [SessionQuizController::class, 'terminer'])->name('sessions-quiz.terminer'); 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');
}); });

View file

@ -207,3 +207,60 @@ test('les scripts en ligne des pages du quiz flash sont du JavaScript valide', f
expect($erreurs)->toBe([]); 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();
});