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:
parent
33531de44d
commit
a41951d4e6
21 changed files with 628 additions and 32 deletions
|
|
@ -123,6 +123,10 @@
|
|||
- { cle: "OS_APPLICATION_CREDENTIAL_SECRET", valeur: "" }
|
||||
- { cle: "REDIS_PREFIX", valeur: "" }
|
||||
- { 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)
|
||||
- { cle: "REDIS_QUEUE_RETRY_AFTER", valeur: "330" }
|
||||
# Étape 12 : HTTPS. Cookies de session envoyés en HTTPS seulement.
|
||||
|
|
|
|||
29
ansible/roles/portail/tasks/08-banque-quiz.yml
Normal file
29
ansible/roles/portail/tasks/08-banque-quiz.yml
Normal 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"
|
||||
|
|
@ -11,6 +11,9 @@
|
|||
- name: labs (copie de travail des lab.yaml)
|
||||
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
|
||||
ansible.builtin.import_tasks: 05-nginx.yml
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
|
|
|||
|
|
@ -953,3 +953,27 @@ Deux décisions d'Adam avant de coder : rendu en texte libre (pas de dépôt de
|
|||
**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).
|
||||
|
||||
## 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.
|
||||
|
|
|
|||
29
paillasse-contenus/01-shell-droits/quiz.gift
Normal file
29
paillasse-contenus/01-shell-droits/quiz.gift
Normal 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}
|
||||
29
paillasse-contenus/02-reseau-paquets-ip/quiz.gift
Normal file
29
paillasse-contenus/02-reseau-paquets-ip/quiz.gift
Normal 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}
|
||||
29
paillasse-contenus/03-web-client-serveur/quiz.gift
Normal file
29
paillasse-contenus/03-web-client-serveur/quiz.gift
Normal 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}
|
||||
29
paillasse-contenus/04-processus/quiz.gift
Normal file
29
paillasse-contenus/04-processus/quiz.gift
Normal 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/}
|
||||
27
paillasse-contenus/05-routage-ospf/quiz.gift
Normal file
27
paillasse-contenus/05-routage-ospf/quiz.gift
Normal 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}
|
||||
31
paillasse-contenus/06-chiffrement-https/quiz.gift
Normal file
31
paillasse-contenus/06-chiffrement-https/quiz.gift
Normal 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}
|
||||
31
paillasse-contenus/07-sql/quiz.gift
Normal file
31
paillasse-contenus/07-sql/quiz.gift
Normal 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}
|
||||
74
portail/app/Console/Commands/SynchroniserBanqueQuiz.php
Normal file
74
portail/app/Console/Commands/SynchroniserBanqueQuiz.php
Normal 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
37
portail/app/Services/Quiz/BanqueOfficielle.php
Normal file
37
portail/app/Services/Quiz/BanqueOfficielle.php
Normal 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'],
|
||||
);
|
||||
}
|
||||
}
|
||||
72
portail/app/Services/Quiz/LecteurGift.php
Normal file
72
portail/app/Services/Quiz/LecteurGift.php
Normal 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];
|
||||
}
|
||||
}
|
||||
33
portail/config/contenus.php
Normal file
33
portail/config/contenus.php
Normal 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',
|
||||
],
|
||||
];
|
||||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
|
|||
66
portail/tests/Feature/BanqueQuizSynchronisationTest.php
Normal file
66
portail/tests/Feature/BanqueQuizSynchronisationTest.php
Normal 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);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue