peuple la banque de quiz avec du vrai contenu, rangé par thème

Un quiz.gift par séquence (01 à 07, le 08 existait déjà), lu par un
nouveau service LecteurGift et synchronisé par quiz:synchroniser-banque
(même esprit que labs:synchroniser) vers un propriétaire technique
jamais archivé (BanqueOfficielle), pour ne pas reproduire la dette du
quiz partagé qui disparaît à la purge RGPD. /banque-quiz regroupe
maintenant par thème comme /bibliotheque.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Adam Belkacemi 2026-10-13 16:14:36 +02:00
parent 33531de44d
commit a41951d4e6
21 changed files with 628 additions and 32 deletions

View file

@ -0,0 +1,74 @@
<?php
namespace App\Console\Commands;
use App\Models\Quiz;
use App\Services\Quiz\BanqueOfficielle;
use App\Services\Quiz\LecteurGift;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
use Symfony\Component\Yaml\Yaml;
/**
* Peuple la banque de quiz officielle depuis les quiz.gift de
* paillasse-contenus/ (demande d'Adam, 2026-10-13) : même esprit que
* labs:synchroniser, jamais de suppression — les questions d'un quiz
* officiel sont remplacées à chaque synchronisation (lui seul les écrit,
* aucun prof ne peut l'éditer), jamais le quiz lui-même.
*/
class SynchroniserBanqueQuiz extends Command
{
protected $signature = 'quiz:synchroniser-banque';
protected $description = 'Crée ou met à jour la banque de quiz officielle depuis les quiz.gift de paillasse-contenus/';
public function handle(): void
{
$dossier = config('contenus.dossier');
$themes = config('contenus.themes');
$classe = BanqueOfficielle::classe();
foreach (File::directories($dossier) as $chemin) {
$identifiant = basename($chemin);
$cheminGift = "{$chemin}/quiz.gift";
$cheminLab = "{$chemin}/lab.yaml";
if (! File::exists($cheminGift) || ! File::exists($cheminLab)) {
continue;
}
$titre = Yaml::parseFile($cheminLab)['titre'];
$lecture = LecteurGift::lire($cheminGift);
$quiz = Quiz::updateOrCreate(
['classe_id' => $classe->id, 'titre' => $titre],
['theme' => $themes[$identifiant] ?? null, 'partage' => true],
);
$quiz->questions()->delete();
foreach ($lecture['questions'] as $ordre => $donnees) {
$question = $quiz->questions()->create([
'enonce' => $donnees['enonce'],
'type' => $donnees['type'],
'ordre' => $ordre,
'reponse_vrai_faux' => $donnees['reponse_vrai_faux'],
]);
foreach ($donnees['choix'] as $ordreChoix => $choix) {
$question->choix()->create([
'texte' => $choix['texte'],
'est_correct' => $choix['est_correct'],
'ordre' => $ordreChoix,
]);
}
}
$this->line(($quiz->wasRecentlyCreated ? 'créé' : 'à jour')." : {$identifiant} ({$titre}), ".count($lecture['questions']).' question(s)');
foreach ($lecture['avertissements'] as $avertissement) {
$this->warn("{$identifiant} : {$avertissement}");
}
}
}
}

View file

@ -15,7 +15,12 @@ use Illuminate\View\View;
* 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.
* la banque n'a pas de propriétaire indépendant d'une classe pour l'instant
* (sauf les quiz officiels, voir App\Services\Quiz\BanqueOfficielle).
*
* Regroupés par thème (docs/referentiel-pedagogique-paillasse.md), comme
* la bibliothèque de cours (/bibliotheque) : les quiz officiels d'abord,
* puis ceux partagés par des profs (theme nul) à la fin.
*/
class BanqueQuizController extends Controller
{
@ -23,12 +28,14 @@ class BanqueQuizController extends Controller
{
$prof = Auth::guard('web')->user();
$quizzes = Quiz::where('partage', true)
->with(['classe.etablissement'])
->withCount('questions')
->latest()
->get();
return view('quiz.banque', [
'quizzes' => Quiz::where('partage', true)
->with(['classe.etablissement'])
->withCount('questions')
->latest()
->get(),
'groupes' => $quizzes->groupBy(fn (Quiz $quiz) => $quiz->theme ?? '')->sortKeys(),
'mesClasses' => $prof->classes()->whereNull('archivee_a')->orderBy('nom')->get(),
]);
}

View file

@ -13,7 +13,7 @@ use Illuminate\Support\Facades\DB;
*/
class Quiz extends Model
{
protected $fillable = ['classe_id', 'titre', 'partage'];
protected $fillable = ['classe_id', 'titre', 'theme', 'partage'];
protected $casts = [
'partage' => 'boolean',

View file

@ -0,0 +1,37 @@
<?php
namespace App\Services\Quiz;
use App\Models\Classe;
use App\Models\Etablissement;
use App\Models\User;
use Illuminate\Support\Str;
/**
* Propriétaire des quiz synchronisés depuis paillasse-contenus/ (voir
* quiz:synchroniser-banque) : une classe qui n'est jamais archivée, pour
* que ce contenu officiel ne disparaisse pas à la purge RGPD (90 jours)
* comme le ferait un quiz partagé depuis une classe de prof — voir
* docs/dette.md.
*/
class BanqueOfficielle
{
public const UAI = 'PAILLASSE-BANQUE';
public static function classe(): Classe
{
$etablissement = Etablissement::firstOrCreate(
['uai' => self::UAI],
['nom' => 'Bibliothèque Paillasse'],
);
$prof = User::firstOrCreate(
['email' => 'banque-officielle@paillasse.interne'],
['name' => 'Bibliothèque Paillasse', 'password' => Str::random(40), 'etablissement_id' => $etablissement->id],
);
return Classe::firstOrCreate(
['user_id' => $prof->id, 'etablissement_id' => $etablissement->id, 'nom' => 'Banque officielle'],
);
}
}

View file

@ -0,0 +1,72 @@
<?php
namespace App\Services\Quiz;
use App\Models\QuestionQuiz;
use Illuminate\Support\Facades\File;
/**
* Lit un fichier quiz.gift (format GIFT, voir paillasse-contenus/README.md).
* Seuls les types QCM et vrai/faux sont supportés par la banque (schéma de
* questions_quiz) : un type numérique ({#N}, utilisable dans Moodle) est
* ignoré avec un avertissement plutôt que d'inventer une réponse.
*/
class LecteurGift
{
/**
* @return array{questions: array<int, array{enonce: string, type: string, reponse_vrai_faux: ?bool, choix: array<int, array{texte: string, est_correct: bool}>}>, avertissements: array<int, string>}
*/
public static function lire(string $chemin): array
{
$texte = preg_replace('/^[ \t]*\/\/.*$/m', '', File::get($chemin));
$questions = [];
$avertissements = [];
preg_match_all('/::(.*?)::(.*?)\{(.*?)\}/s', $texte, $blocs, PREG_SET_ORDER);
foreach ($blocs as $bloc) {
$enonce = trim($bloc[2]);
$contenu = trim($bloc[3]);
if ($contenu === 'T' || $contenu === 'F') {
$questions[] = [
'enonce' => $enonce,
'type' => QuestionQuiz::VRAI_FAUX,
'reponse_vrai_faux' => $contenu === 'T',
'choix' => [],
];
continue;
}
if (str_starts_with($contenu, '#')) {
$avertissements[] = "question numérique ignorée (type non supporté par la banque) : {$enonce}";
continue;
}
$choix = [];
foreach (preg_split('/\r?\n/', $contenu) as $ligne) {
$ligne = trim($ligne);
if ($ligne === '') {
continue;
}
$choix[] = [
'texte' => trim(substr($ligne, 1)),
'est_correct' => $ligne[0] === '=',
];
}
$questions[] = [
'enonce' => $enonce,
'type' => QuestionQuiz::QCM,
'reponse_vrai_faux' => null,
'choix' => $choix,
];
}
return ['questions' => $questions, 'avertissements' => $avertissements];
}
}

View file

@ -0,0 +1,33 @@
<?php
return [
// En local (dev, tests Pest) : paillasse-contenus/ est un dossier voisin
// de portail/ dans le dépôt. En production, synchronisé à part par
// Ansible (ansible/roles/portail/tasks/05-banque-quiz.yml), comme
// labs/ (voir config/labs.php) mais pour le contenu éditorial source
// (cours, TD, quiz), pas seulement les lab.yaml déployés.
'dossier' => env('CONTENUS_DOSSIER', dirname(base_path()).'/paillasse-contenus'),
// Thème de la bibliothèque (docs/referentiel-pedagogique-paillasse.md)
// associé à chaque séquence, pour grouper la banque de quiz officielle.
'themes' => [
'01-shell-droits' => 'LIN',
'02-reseau-paquets-ip' => 'NET',
'03-web-client-serveur' => 'SRV',
'04-processus' => 'LIN',
'05-routage-ospf' => 'NET',
'06-chiffrement-https' => 'SEC',
'07-sql' => 'BDD',
'08-deux-lan-routeur' => 'NET',
],
// Nom affiché pour chaque code de thème (section correspondante du
// référentiel). Pour l'affichage uniquement.
'noms_themes' => [
'LIN' => 'Linux et systèmes',
'NET' => 'Réseaux',
'SRV' => 'Services',
'SEC' => 'Cybersécurité',
'BDD' => 'Bases de données',
],
];

View file

@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Banque de quiz officielle (quiz:synchroniser-banque) : regroupe les quiz
* synchronisés depuis paillasse-contenus/ par thème de la bibliothèque
* (LIN, NET, SRV, SEC, BDD…). Nul pour les quiz créés par un prof.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('quizzes', function (Blueprint $table) {
$table->string('theme')->nullable()->after('titre');
});
}
public function down(): void
{
Schema::table('quizzes', function (Blueprint $table) {
$table->dropColumn('theme');
});
}
};

View file

@ -3,41 +3,55 @@
@section('titre', 'Banque de quiz')
@section('contenu')
<div class="mx-auto max-w-4xl px-5 py-14 lg:px-10">
<div class="mx-auto max-w-5xl 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>
<p class="mt-2 font-semibold">Les quiz officiels, rangés par thème du référentiel, et ceux 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())
@if ($groupes->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>
@foreach ($groupes as $theme => $quizzes)
<section class="mt-12">
<h2 class="flex flex-wrap items-baseline gap-3 text-2xl font-extrabold tracking-tight">
@if ($theme !== '')
<span class="rounded-2xl border border-[#e3e5e8] bg-[#eef0fd] px-2 font-mono text-xl">{{ $theme }}</span>
{{ config('contenus.noms_themes.'.$theme, $theme) }}
@else
<span class="rounded-2xl border border-[#e3e5e8] bg-white px-2 font-mono text-xl">—</span>
Partagés par des profs
@endif
<span class="font-mono text-base font-semibold">{{ $quizzes->count() }} quiz</span>
</h2>
<ul class="mt-5 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>
</section>
@endforeach
@endif
</div>
@endsection

View file

@ -0,0 +1,66 @@
<?php
use App\Models\Classe;
use App\Models\Quiz;
use Illuminate\Support\Facades\File;
function dossierContenusDeTest(): string
{
$dossier = sys_get_temp_dir().'/paillasse-contenus-test-'.uniqid();
File::ensureDirectoryExists("{$dossier}/01-sequence-test");
File::put("{$dossier}/01-sequence-test/lab.yaml", "id: 01-sequence-test\ntitre: Séquence de test\n");
File::put("{$dossier}/01-sequence-test/quiz.gift", <<<'GIFT'
// commentaire ignoré
::Q1 titre::Une question QCM ?{
=bonne réponse
~mauvaise réponse}
::Q2 titre::Une affirmation vraie.{T}
::Q3 titre::Une question numérique {#42}
GIFT);
config(['contenus.dossier' => $dossier, 'contenus.themes' => ['01-sequence-test' => 'LIN']]);
return $dossier;
}
it('crée la banque officielle et ses quiz depuis les quiz.gift', function () {
$dossier = dossierContenusDeTest();
$this->artisan('quiz:synchroniser-banque')->assertSuccessful();
$classe = Classe::where('nom', 'Banque officielle')->first();
expect($classe)->not->toBeNull();
$quiz = Quiz::where('classe_id', $classe->id)->where('titre', 'Séquence de test')->first();
expect($quiz)->not->toBeNull()
->and($quiz->theme)->toBe('LIN')
->and($quiz->partage)->toBeTrue()
->and($quiz->questions)->toHaveCount(2);
$qcm = $quiz->questions->firstWhere('type', 'qcm');
expect($qcm->choix)->toHaveCount(2)
->and($qcm->choix->firstWhere('est_correct', true)->texte)->toBe('bonne réponse');
$vraiFaux = $quiz->questions->firstWhere('type', 'vrai_faux');
expect($vraiFaux->reponse_vrai_faux)->toBeTrue();
File::deleteDirectory($dossier);
});
it('ne duplique rien et remplace les questions à une nouvelle synchronisation', function () {
$dossier = dossierContenusDeTest();
$this->artisan('quiz:synchroniser-banque')->assertSuccessful();
$this->artisan('quiz:synchroniser-banque')->assertSuccessful();
expect(Classe::where('nom', 'Banque officielle')->count())->toBe(1)
->and(Quiz::where('titre', 'Séquence de test')->count())->toBe(1);
File::deleteDirectory($dossier);
});