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

@ -123,6 +123,10 @@
- { cle: "OS_APPLICATION_CREDENTIAL_SECRET", valeur: "" } - { cle: "OS_APPLICATION_CREDENTIAL_SECRET", valeur: "" }
- { cle: "REDIS_PREFIX", valeur: "" } - { cle: "REDIS_PREFIX", valeur: "" }
- { cle: "LABS_DOSSIER", valeur: "/opt/labs" } - { cle: "LABS_DOSSIER", valeur: "/opt/labs" }
# Banque de quiz officielle (quiz:synchroniser-banque) : contenu
# éditorial source (cours, TD, quiz), pas seulement les lab.yaml
# déployés — voir 08-banque-quiz.yml.
- { cle: "CONTENUS_DOSSIER", valeur: "/opt/paillasse-contenus" }
# > timeout des superviseurs Horizon (300 s, config/horizon.php) # > timeout des superviseurs Horizon (300 s, config/horizon.php)
- { cle: "REDIS_QUEUE_RETRY_AFTER", valeur: "330" } - { cle: "REDIS_QUEUE_RETRY_AFTER", valeur: "330" }
# Étape 12 : HTTPS. Cookies de session envoyés en HTTPS seulement. # Étape 12 : HTTPS. Cookies de session envoyés en HTTPS seulement.

View file

@ -0,0 +1,29 @@
---
# Banque de quiz officielle (demande d'Adam, 2026-10-13) : paillasse-contenus/
# est le contenu éditorial source (cours, TD, quiz), synchronisé à part des
# lab.yaml déployés (voir 04-labs.yml), lu par
# App\Services\Quiz\LecteurGift pour peupler la banque.
- name: créer le dossier du contenu éditorial
ansible.builtin.file:
path: /opt/paillasse-contenus
state: directory
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
mode: "0755"
- name: synchroniser le contenu éditorial
become: false
ansible.posix.synchronize:
src: "{{ playbook_dir }}/../paillasse-contenus/"
dest: /opt/paillasse-contenus/
delete: true
# Ne supprime jamais un quiz officiel : seules ses questions sont
# remplacées à chaque synchronisation (lui seul les écrit).
- name: synchroniser la banque de quiz officielle
ansible.builtin.command:
cmd: runuser -u www-data -- php artisan quiz:synchroniser-banque
args:
chdir: "{{ portail_dossier_app }}"
register: portail_banque_quiz_synchronisee
changed_when: "'créé' in portail_banque_quiz_synchronisee.stdout"

View file

@ -11,6 +11,9 @@
- name: labs (copie de travail des lab.yaml) - name: labs (copie de travail des lab.yaml)
ansible.builtin.import_tasks: 04-labs.yml ansible.builtin.import_tasks: 04-labs.yml
- name: banque de quiz officielle (contenu éditorial de paillasse-contenus/)
ansible.builtin.import_tasks: 08-banque-quiz.yml
- name: Nginx - name: Nginx
ansible.builtin.import_tasks: 05-nginx.yml ansible.builtin.import_tasks: 05-nginx.yml

View file

@ -29,4 +29,5 @@ 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 | | 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. N'affecte pas les quiz officiels (voir ligne suivante). | Si ça gêne en usage réel | 2026-10-13 |
| Banque de quiz officielle : établissement, prof et classe techniques invisibles (« Bibliothèque Paillasse » / « Banque officielle », `App\Services\Quiz\BanqueOfficielle`) | Simple propriétaire stable pour les quiz synchronisés depuis `paillasse-contenus/`, jamais archivé donc jamais purgé — mais apparaîtrait comme un établissement et un prof fictifs dans toute future liste d'administration globale. À filtrer explicitement si un tel écran est ajouté. | Si un écran d'admin globale est ajouté | 2026-10-13 |

View file

@ -953,3 +953,27 @@ Deux décisions d'Adam avant de coder : rendu en texte libre (pas de dépôt de
**Suite** **Suite**
Avec Quiz flash, la banque de quiz et les devoirs maison, trois des quatre fonctions fictives du tableau de bord démo sont maintenant réelles. Reste : Observations (puis Surveillance d'examen, la plus sensible — suivi de mineurs à cadrer avec Adam avant de coder). Avec Quiz flash, la banque de quiz et les devoirs maison, trois des quatre fonctions fictives du tableau de bord démo sont maintenant réelles. Reste : Observations (puis Surveillance d'examen, la plus sensible — suivi de mineurs à cadrer avec Adam avant de coder).
## 2026-10-13 — Peupler la banque de quiz avec du vrai contenu
Avant de continuer le chantier « vrai cockpit », demande d'Adam : la banque de quiz ne doit pas rester vide, elle doit puiser dans le contenu pédagogique déjà écrit (cours, TD, labs), rangée par thème comme la bibliothèque (`/bibliotheque`).
**Fait**
- Un fichier `quiz.gift` (format GIFT, déjà utilisé par le lab 08) écrit pour chacune des séquences 01 à 07 (01-shell-droits, 02-reseau-paquets-ip, 03-web-client-serveur, 04-processus, 05-routage-ospf, 06-chiffrement-https, 07-sql) — 6 à 7 questions QCM ou vrai/faux chacun, relues pour être justes (pas de question piège ni de réponse approximative).
- `App\Services\Quiz\LecteurGift` : lit un `quiz.gift` en questions structurées. Ignore avec un avertissement le type numérique du GIFT (`{#N}`, présent dans le lab 08) : non supporté par le schéma de questions_quiz, pas question d'inventer une réponse pour le faire rentrer.
- `App\Services\Quiz\BanqueOfficielle` : un établissement, un prof et une classe « Banque officielle » créés une seule fois (`firstOrCreate`), jamais archivés — propriétaire stable des quiz officiels, pour ne pas reproduire la dette du quiz partagé qui disparaît si sa classe d'origine est purgée (RGPD, 90 jours).
- `quiz:synchroniser-banque` (même esprit que `labs:synchroniser`) : lit `paillasse-contenus/`, crée ou met à jour un quiz par séquence (jamais supprimé), remplace ses questions à chaque passage (lui seul les écrit, aucun prof ne peut les éditer). Colonne `theme` ajoutée à `quizzes` (LIN, NET, SRV, SEC, BDD, selon `config/contenus.php`) pour le groupement.
- `paillasse-contenus/` synchronisé sur le serveur (`/opt/paillasse-contenus`, nouvelle tâche Ansible), comme `labs/` mais pour le contenu éditorial source entier, pas seulement les lab.yaml déployés.
- `/banque-quiz` regroupée par thème (badge + nom, comme `/bibliotheque`), les quiz partagés par des profs (sans thème) à la fin.
- 2 nouveaux tests Pest (84 au total) : création idempotente depuis un `quiz.gift` de test, pas de doublon à une deuxième synchronisation.
**Vérifié sur le serveur réel** : après déploiement, 8 quiz officiels créés (un par séquence, le lab 08 avec son avertissement attendu sur la question numérique ignorée). Scénario avec un prof jetable (supprimé ensuite) : `/banque-quiz` affiche bien 5 sections par thème (LIN ×2, NET ×3, SRV ×1, SEC ×1, BDD ×1) puis « Partagés par des profs » ; dupliqué le quiz officiel « Un serveur web et son client » (6 questions, 16 choix) dans la classe du prof jetable, copie identique et indépendante créée avec `theme` à nul. Le quiz réel d'Adam (« dfdffd ») et sa classe n'ont pas été touchés.
**Comprendre**
Le choix qui évite le plus de dette ici : ne pas réutiliser le mécanisme « partager » existant pour le contenu officiel (qui dépendrait d'une classe de prof, purgeable), mais lui donner un propriétaire à part qui n'est jamais archivé. Le prix à payer est une petite classe technique invisible dans l'usage normal — acceptable parce que documentée et jamais visible d'un vrai prof.
**Suite**
Reprendre le chantier « vrai cockpit » : Observations.

View file

@ -0,0 +1,29 @@
// Quiz séquence 01 : premiers pas dans le shell et droits (format GIFT)
::Q1 Créer un dossier::Quelle commande crée le dossier /home/eleve/projet ?{
=mkdir /home/eleve/projet
~touch /home/eleve/projet
~cd /home/eleve/projet
~rmdir /home/eleve/projet}
::Q2 chmod 640::Avec les droits 640 sur un fichier, le groupe propriétaire peut :{
=le lire, mais pas l'écrire ni l'exécuter
~le lire et l'écrire
~l'exécuter seulement
~tout faire}
::Q3 Appartenance à un groupe::Quelle commande ajoute l'utilisatrice alice au groupe equipe déjà existant ?{
=usermod -aG equipe alice
~groupadd equipe alice
~chown equipe alice
~adduser equipe alice}
::Q4 Rendre exécutable::chmod +x sur un script le rend exécutable pour tout le monde, y compris les autres utilisateurs.{T}
::Q5 chgrp::chgrp change le propriétaire d'un fichier.{F}
::Q6 Lire un rapport::Quelle commande affiche si le fichier rapport.txt contient le mot « paillasse » ?{
=grep paillasse rapport.txt
~cat paillasse rapport.txt
~find paillasse rapport.txt
~echo paillasse rapport.txt}

View file

@ -0,0 +1,29 @@
// Quiz séquence 02 : configurer le réseau et installer un paquet (format GIFT)
::Q1 Adresse et masque::Sur un réseau 10.10.0.0/24, l'adresse 10.10.0.11/24 est-elle valide pour un poste ?{T}
::Q2 Installer un paquet local::Sans accès Internet, quelle commande installe un paquet .deb déjà présent sur la machine ?{
=dpkg -i paquet.deb
~apt install paquet.deb
~wget paquet.deb
~dpkg -r paquet.deb}
::Q3 Vérifier qu'un paquet est installé::Quelle commande confirme qu'un paquet Debian est bien installé ?{
=dpkg -s paquet
~ls /opt/paquets/
~ping paquet
~ip link show paquet}
::Q4 Joindre un voisin::Quelle commande teste si deux postes se joignent sur le réseau ?{
=ping
~dpkg
~ip addr
~tree}
::Q5 ip addr add::La commande `ip addr add` est permanente après un redémarrage.{F}
::Q6 Nom d'hôte::Quelle commande affiche le nom d'hôte de la machine locale ?{
=hostname
~ping
~whoami
~ip route}

View file

@ -0,0 +1,29 @@
// Quiz séquence 03 : un serveur web et son client (format GIFT)
::Q1 Démarrer et activer::Quelle commande démarre nginx immédiatement et l'active au démarrage, en une seule fois ?{
=systemctl enable --now nginx
~systemctl start nginx
~systemctl enable nginx
~systemctl status nginx}
::Q2 Page par défaut::Où se trouve la page d'accueil par défaut d'nginx sur Debian ?{
=/var/www/html/
~/etc/nginx/
~/home/eleve/
~/var/log/nginx/}
::Q3 Voir uniquement les en-têtes::Quelle option de curl affiche seulement les en-têtes HTTP de la réponse ?{
=-I
~-v
~-o
~-s}
::Q4 Code de statut::Un serveur web qui répond correctement à une requête renvoie le code HTTP :{
=200
~404
~500
~301}
::Q5 Client et serveur::Dans ce lab, le client et le serveur sont la même machine virtuelle.{F}
::Q6 Redémarrer après une modification::Après avoir changé la page d'accueil d'nginx, il faut redémarrer le service pour que la page soit prise en compte.{F}

View file

@ -0,0 +1,29 @@
// Quiz séquence 04 : processus et services (format GIFT)
::Q1 Trouver le processus gourmand::Quelle commande trie les processus par consommation CPU décroissante ?{
=ps aux --sort=-%cpu
~ps aux --sort=+%cpu
~kill -l
~systemctl list-units}
::Q2 Signal par défaut::La commande `kill <PID>` envoie quel signal par défaut ?{
=SIGTERM
~SIGKILL
~SIGSTOP
~SIGHUP}
::Q3 Arrêt forcé::`kill -9 <PID>` envoie un signal que le processus peut ignorer pour se fermer proprement.{F}
::Q4 Service actif et activé::Quelle commande affiche à la fois si un service tourne maintenant et s'il démarrera au prochain redémarrage ?{
=systemctl status leservice
~systemctl is-enabled leservice
~ps aux leservice
~journalctl leservice}
::Q5 Recharger systemd::Après avoir créé un nouveau fichier .service dans /etc/systemd/system/, il faut lancer `systemctl daemon-reload` avant de pouvoir l'activer.{T}
::Q6 Emplacement des services::Où placer un fichier .service personnalisé ?{
=/etc/systemd/system/
~/home/eleve/
~/var/log/
~/opt/paquets/}

View file

@ -0,0 +1,27 @@
// Quiz séquence 05 : routage dynamique avec OSPF (format GIFT)
::Q1 Type de protocole::OSPF est un protocole de routage :{
=dynamique, à état de liens (link-state)
~statique, configuré route par route
~de couche application
~de résolution de noms}
::Q2 Rôle du routeur::Sans règle de routage, un routeur ne peut transmettre que vers les réseaux directement connectés à ses interfaces.{T}
::Q3 Convergence::Avec OSPF, si un lien tombe, les routeurs mettent à jour leurs tables de routage automatiquement, sans intervention manuelle.{T}
::Q4 Vérifier les voisins::Quelle commande affiche les routeurs voisins OSPF découverts ?{
=show ip ospf neighbor
~show ip route static
~ping voisin
~show interfaces}
::Q5 Aire OSPF::Dans OSPF, les routeurs d'une même aire (area) échangent leurs informations de topologie entre eux.{T}
::Q6 Table de routage::Quelle commande affiche la table de routage d'un routeur ?{
=show ip route
~show ip ospf neighbor
~ping
~traceroute}
::Q7 Route statique vs dynamique::Une route statique s'adapte automatiquement si la topologie du réseau change.{F}

View file

@ -0,0 +1,31 @@
// Quiz séquence 06 : chiffrer les échanges avec HTTPS (format GIFT)
::Q1 Rôle du certificat::Un certificat TLS sert à :{
=prouver l'identité du serveur et permettre le chiffrement de la connexion
~accélérer le chargement des pages
~compresser les données échangées
~remplacer le nom de domaine}
::Q2 Port HTTPS::Quel port utilise HTTPS par défaut ?{
=443
~80
~22
~21}
::Q3 HTTP vs HTTPS::En HTTP simple (sans TLS), le contenu échangé entre le client et le serveur peut être lu en clair par un tiers sur le réseau.{T}
::Q4 Générer une clé privée et un certificat auto-signé::Quel outil permet de générer une paire clé privée / certificat avec OpenSSL ?{
=openssl req -x509 -newkey rsa -keyout cle.pem -out cert.pem
~apt install certificat
~ping -T
~curl -X CERT}
::Q5 Certificat auto-signé::Un navigateur fait automatiquement confiance à un certificat auto-signé, sans avertissement.{F}
::Q6 Vérifier la configuration TLS::Quelle commande curl force l'utilisation de HTTPS et affiche les détails de la négociation TLS ?{
=curl -v https://exemple.test
~curl -I http://exemple.test
~ping https://exemple.test
~dpkg -s https}
::Q7 Redirection::Rediriger le port 80 vers le port 443 permet à un client qui tape l'adresse en HTTP d'être automatiquement basculé en HTTPS.{T}

View file

@ -0,0 +1,31 @@
// Quiz séquence 07 : bases de données et SQL (format GIFT)
::Q1 Sélectionner des lignes::Quelle instruction SQL renvoie toutes les colonnes de la table eleves ?{
=SELECT * FROM eleves;
~GET * FROM eleves;
~FIND eleves;
~SHOW eleves;}
::Q2 Filtrer::Quelle clause permet de ne garder que certaines lignes dans un SELECT ?{
=WHERE
~ORDER BY
~GROUP BY
~FROM}
::Q3 Clé primaire::Une clé primaire peut contenir des doublons dans une table.{F}
::Q4 Jointure::Quelle clause permet de combiner les lignes de deux tables à partir d'une colonne commune ?{
=JOIN
~UNION
~WHERE
~LIMIT}
::Q5 Suppression définitive::La commande DELETE FROM table; (sans clause WHERE) supprime toutes les lignes de la table.{T}
::Q6 Ajouter une ligne::Quelle instruction insère une nouvelle ligne dans une table ?{
=INSERT INTO table (colonne) VALUES (valeur);
~UPDATE table SET colonne = valeur;
~CREATE TABLE table (colonne);
~ADD table colonne valeur;}
::Q7 Agrégation::La fonction COUNT(*) dans une requête SQL renvoie la somme des valeurs numériques d'une colonne.{F}

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 * 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 * 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) — * 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 class BanqueQuizController extends Controller
{ {
@ -23,12 +28,14 @@ class BanqueQuizController extends Controller
{ {
$prof = Auth::guard('web')->user(); $prof = Auth::guard('web')->user();
return view('quiz.banque', [ $quizzes = Quiz::where('partage', true)
'quizzes' => Quiz::where('partage', true)
->with(['classe.etablissement']) ->with(['classe.etablissement'])
->withCount('questions') ->withCount('questions')
->latest() ->latest()
->get(), ->get();
return view('quiz.banque', [
'groupes' => $quizzes->groupBy(fn (Quiz $quiz) => $quiz->theme ?? '')->sortKeys(),
'mesClasses' => $prof->classes()->whereNull('archivee_a')->orderBy('nom')->get(), 'mesClasses' => $prof->classes()->whereNull('archivee_a')->orderBy('nom')->get(),
]); ]);
} }

View file

@ -13,7 +13,7 @@ use Illuminate\Support\Facades\DB;
*/ */
class Quiz extends Model class Quiz extends Model
{ {
protected $fillable = ['classe_id', 'titre', 'partage']; protected $fillable = ['classe_id', 'titre', 'theme', 'partage'];
protected $casts = [ protected $casts = [
'partage' => 'boolean', '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,21 +3,33 @@
@section('titre', 'Banque de quiz') @section('titre', 'Banque de quiz')
@section('contenu') @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> <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> <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')) @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> <p class="mt-4 rounded-2xl border border-[#e3e5e8] bg-[#e6f4ea] p-4 font-semibold">Quiz dupliqué dans ta classe.</p>
@endif @endif
@if ($quizzes->isEmpty()) @if ($groupes->isEmpty())
<p class="mt-8 font-semibold">Aucun quiz partagé pour l'instant.</p> <p class="mt-8 font-semibold">Aucun quiz partagé pour l'instant.</p>
@elseif ($mesClasses->isEmpty()) @elseif ($mesClasses->isEmpty())
<p class="mt-8 font-semibold">Crée d'abord une classe pour pouvoir y dupliquer un quiz.</p> <p class="mt-8 font-semibold">Crée d'abord une classe pour pouvoir y dupliquer un quiz.</p>
@else @else
<ul class="mt-8 grid gap-4"> @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) @foreach ($quizzes as $quiz)
<li class="rounded-2xl border border-[#e3e5e8] bg-white p-5"> <li class="rounded-2xl border border-[#e3e5e8] bg-white p-5">
<div class="flex flex-wrap items-center justify-between gap-3"> <div class="flex flex-wrap items-center justify-between gap-3">
@ -38,6 +50,8 @@
</li> </li>
@endforeach @endforeach
</ul> </ul>
</section>
@endforeach
@endif @endif
</div> </div>
@endsection @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);
});