From 2672cfce9b72c7c5cf8f482a548fd31776a32279 Mon Sep 17 00:00:00 2001 From: electron-rare <108685187+electron-rare@users.noreply.github.com> Date: Thu, 11 Jun 2026 00:04:24 +0200 Subject: [PATCH] feat: quiz bank export to grist --- fixtures/quizbank-3.json | 500 +++++++++++++++++++++++++++++++++++++++ ops/README.md | 59 +++++ ops/export-quiz-bank.php | 107 +++++++++ ops/push-quiz-bank.py | 97 ++++++++ 4 files changed, 763 insertions(+) create mode 100644 fixtures/quizbank-3.json create mode 100644 ops/export-quiz-bank.php create mode 100644 ops/push-quiz-bank.py diff --git a/fixtures/quizbank-3.json b/fixtures/quizbank-3.json new file mode 100644 index 0000000..69a054d --- /dev/null +++ b/fixtures/quizbank-3.json @@ -0,0 +1,500 @@ +{ + "courses": { + "3": { + "quizzes": [ + { + "quiz_n": 1, + "cmid": 51, + "name": "Quiz 1 : Découverte", + "gradepass": 60, + "questions": [ + { + "id": 106, + "text": "

Dans quel ordre s'effectuent les étapes de conception dans KiCad ?

", + "single": 1, + "choices": [ + { + "id": 421, + "text": "Schéma → Attribution des empreintes → PCB Layout → Fichiers Gerber" + }, + { + "id": 422, + "text": "PCB Layout → Schéma → Fichiers Gerber → Attribution des empreintes" + }, + { + "id": 423, + "text": "Attribution des empreintes → Schéma → PCB Layout → Fichiers Gerber" + }, + { + "id": 424, + "text": "Schéma → PCB Layout → Attribution des empreintes → Fichiers Gerber" + } + ] + }, + { + "id": 107, + "text": "

Qu'est-ce qu'une netlist dans KiCad ?

", + "single": 1, + "choices": [ + { + "id": 425, + "text": "Un fichier décrivant tous les nœuds électriques (nets) et les connexions entre composants" + }, + { + "id": 426, + "text": "La liste des composants avec leurs prix" + }, + { + "id": 427, + "text": "Le fichier envoyé directement au fabricant de PCB" + }, + { + "id": 428, + "text": "La liste des couches PCB actives" + } + ] + }, + { + "id": 108, + "text": "

Quelle est la différence entre un symbole et un footprint (empreinte) dans KiCad ?

", + "single": 1, + "choices": [ + { + "id": 429, + "text": "Le symbole représente le composant dans le schéma électrique (logique) ; le footprint représente ses pastilles physiques sur le PCB" + }, + { + "id": 430, + "text": "Le symbole est en 3D ; le footprint est en 2D" + }, + { + "id": 431, + "text": "Le symbole est pour les composants SMD ; le footprint pour les composants traversants" + }, + { + "id": 432, + "text": "C'est la même chose, juste deux noms différents dans KiCad" + } + ] + }, + { + "id": 109, + "text": "

Quel outil KiCad vérifie les erreurs dans le schéma électrique (connexions manquantes, conflits de type de pin) ?

", + "single": 1, + "choices": [ + { + "id": 433, + "text": "ERC (Electrical Rules Check)" + }, + { + "id": 434, + "text": "DRC (Design Rules Check)" + }, + { + "id": 435, + "text": "La visionneuse 3D" + }, + { + "id": 436, + "text": "Le gestionnaire de bibliothèques" + } + ] + }, + { + "id": 110, + "text": "

Dans KiCad, que sont les fichiers Gerber ?

", + "single": 1, + "choices": [ + { + "id": 437, + "text": "Un format de fichier standard décrivant chaque couche du PCB pour la fabrication industrielle" + }, + { + "id": 438, + "text": "Les fichiers de sauvegarde automatique de KiCad" + }, + { + "id": 439, + "text": "Les modèles 3D des composants" + }, + { + "id": 440, + "text": "Les bibliothèques de symboles KiCad" + } + ] + } + ] + }, + { + "quiz_n": 2, + "cmid": 52, + "name": "Quiz 2 : Schématique", + "gradepass": 60, + "questions": [ + { + "id": 111, + "text": "

Que signifie ERC dans KiCad et que vérifie-t-il ?

", + "single": 1, + "choices": [ + { + "id": 441, + "text": "Electrical Rule Check — vérifie la cohérence logique du schéma (connexions manquantes, conflits de type de pin)" + }, + { + "id": 442, + "text": "Electronic Routing Check — vérifie le routage des pistes sur le PCB" + }, + { + "id": 443, + "text": "External Reference Compiler — compile les références des composants externes" + }, + { + "id": 444, + "text": "Edge Resistance Calculator — calcule la résistance des pistes de bord" + } + ] + }, + { + "id": 112, + "text": "

Pourquoi faut-il ajouter des symboles PWR_FLAG dans le schéma KiCad ?

", + "single": 1, + "choices": [ + { + "id": 445, + "text": "Pour indiquer à l'ERC qu'un net d'alimentation est intentionnellement alimenté, évitant l'erreur \"Power pin not driven\"" + }, + { + "id": 446, + "text": "Pour limiter le courant sur les rails de puissance" + }, + { + "id": 447, + "text": "Pour générer automatiquement les plans de masse dans le PCB" + }, + { + "id": 448, + "text": "Pour attribuer automatiquement les footprints aux composants de puissance" + } + ] + }, + { + "id": 113, + "text": "

Quel est l'avantage d'utiliser des net labels plutôt que des fils directs pour connecter des composants éloignés dans le schéma ?

", + "single": 1, + "choices": [ + { + "id": 449, + "text": "Le schéma reste lisible sans fils qui traversent toute la feuille — deux labels identiques = connexion électrique identique" + }, + { + "id": 450, + "text": "Les net labels réduisent la résistance des connexions" + }, + { + "id": 451, + "text": "Les net labels sont obligatoires pour passer l'ERC" + }, + { + "id": 452, + "text": "Les net labels accélèrent le routage automatique du PCB" + } + ] + }, + { + "id": 114, + "text": "

Pourquoi mettre une résistance de 330Ω en série avec une LED alimentée en 3.3V ?

", + "single": 1, + "choices": [ + { + "id": 453, + "text": "Pour limiter le courant à environ 4 mA et protéger la LED — calcul : (3.3V - 2.0V) / 330Ω ≈ 4mA" + }, + { + "id": 454, + "text": "Pour augmenter la luminosité de la LED" + }, + { + "id": 455, + "text": "Pour filtrer le bruit haute fréquence sur la ligne d'alimentation" + }, + { + "id": 456, + "text": "C'est optionnel, on peut connecter la LED directement au GPIO de l'ESP32" + } + ] + }, + { + "id": 115, + "text": "

Pourquoi faut-il des résistances de 5.1kΩ sur les broches CC1/CC2 du connecteur USB-C ?

", + "single": 1, + "choices": [ + { + "id": 457, + "text": "Elles identifient l'appareil comme un \"sink\" USB-C (consommateur d'énergie) — sans elles, le port USB ne fournit pas de courant" + }, + { + "id": 458, + "text": "Elles protègent l'ESP32 contre les surtensions USB" + }, + { + "id": 459, + "text": "Elles permettent la communication de données USB (D+/D-)" + }, + { + "id": 460, + "text": "Elles sont optionnelles — le connecteur fonctionne sans elles" + } + ] + } + ] + }, + { + "quiz_n": 3, + "cmid": 53, + "name": "Quiz 3 : PCB", + "gradepass": 60, + "questions": [ + { + "id": 116, + "text": "

Pourquoi utilise-t-on des pistes plus larges (0.5mm) pour les rails d'alimentation VIN et +3V3 ?

", + "single": 1, + "choices": [ + { + "id": 461, + "text": "Les pistes plus larges ont une résistance plus faible et supportent plus de courant sans échauffement" + }, + { + "id": 462, + "text": "C'est purement esthétique pour distinguer visuellement les pistes de puissance" + }, + { + "id": 463, + "text": "Les pistes plus larges transmettent le signal plus rapidement" + }, + { + "id": 464, + "text": "C'est obligatoire pour passer le DRC de JLCPCB" + } + ] + }, + { + "id": 117, + "text": "

Qu'est-ce qu'un via dans KiCad et quand l'utilise-t-on ?

", + "single": 1, + "choices": [ + { + "id": 465, + "text": "Un trou métallisé qui connecte la couche dessus (F.Cu) à la couche dessous (B.Cu) — utilisé quand deux pistes doivent se croiser" + }, + { + "id": 466, + "text": "Un trou pour insérer un composant traversant" + }, + { + "id": 467, + "text": "Un point de test pour mesurer la tension" + }, + { + "id": 468, + "text": "Un trou de montage pour visser la carte dans un boîtier" + } + ] + }, + { + "id": 118, + "text": "

Quels sont les avantages d'un plan de masse (copper pour GND) sur la couche B.Cu ?

", + "single": 1, + "choices": [ + { + "id": 469, + "text": "Meilleur retour de courant GND, réduction des interférences EMI, et meilleure dissipation thermique" + }, + { + "id": 470, + "text": "Il sert uniquement à remplir l'espace vide pour économiser le cuivre de gravure" + }, + { + "id": 471, + "text": "Il est obligatoire pour tout PCB 2 couches selon la norme IPC" + }, + { + "id": 472, + "text": "Il remplace les condensateurs de découplage et simplifie le schéma" + } + ] + }, + { + "id": 119, + "text": "

Pourquoi la valeur de clearance (espacement minimum entre conducteurs) est-elle importante pour la fabrication ?

", + "single": 1, + "choices": [ + { + "id": 473, + "text": "Un espacement insuffisant peut causer des courts-circuits lors de la fabrication à cause des tolérances de gravure" + }, + { + "id": 474, + "text": "C'est purement esthétique, ça n'affecte pas la fabrication" + }, + { + "id": 475, + "text": "Plus la clearance est grande, plus la carte conduit bien le courant" + }, + { + "id": 476, + "text": "La clearance ne s'applique qu'aux composants traversants, pas aux SMD" + } + ] + }, + { + "id": 120, + "text": "

Que signifie le code \"0805\" pour un composant SMD et pourquoi le choisit-on pour un maker débutant ?

", + "single": 1, + "choices": [ + { + "id": 477, + "text": "Dimensions 0.08\" × 0.05\" (soit 2.0mm × 1.25mm) — le 0805 est le plus facile à souder à la main parmi les SMD courants" + }, + { + "id": 478, + "text": "C'est le numéro de série du fabricant YAGEO" + }, + { + "id": 479, + "text": "La puissance maximale du composant (805 mW)" + }, + { + "id": 480, + "text": "La tolérance de valeur (8.05%)" + } + ] + } + ] + }, + { + "quiz_n": 4, + "cmid": 54, + "name": "Quiz 4 : Fabrication", + "gradepass": 60, + "questions": [ + { + "id": 121, + "text": "

Pour un PCB 2 couches standard, combien de fichiers Gerber minimum faut-il générer (hors fichier de perçage) ?

", + "single": 1, + "choices": [ + { + "id": 481, + "text": "7 fichiers : F.Cu, B.Cu, F.Mask, B.Mask, F.SilkS, B.SilkS, Edge.Cuts" + }, + { + "id": 482, + "text": "2 fichiers : un pour le dessus, un pour le dessous" + }, + { + "id": 483, + "text": "1 seul fichier ZIP contenant toutes les couches" + }, + { + "id": 484, + "text": "3 fichiers : cuivre dessus, cuivre dessous, contour" + } + ] + }, + { + "id": 122, + "text": "

Quel format est utilisé pour le fichier de perçage (drill file) dans KiCad ?

", + "single": 1, + "choices": [ + { + "id": 485, + "text": "Excellon (.drl) — le standard industriel pour les données de perçage" + }, + { + "id": 486, + "text": "Gerber (.gbr) — le même format que les couches cuivre" + }, + { + "id": 487, + "text": "G-code (.nc) — le format des fraiseuses CNC" + }, + { + "id": 488, + "text": "DXF (.dxf) — le format de dessin CAO" + } + ] + }, + { + "id": 123, + "text": "

Que contient une BOM (Bill of Materials) et à quoi sert-elle ?

", + "single": 1, + "choices": [ + { + "id": 489, + "text": "La liste complète des composants avec références, valeurs, footprints et quantités — c'est la \"liste de courses\" pour assembler la carte" + }, + { + "id": 490, + "text": "Les instructions de routage des pistes cuivrées" + }, + { + "id": 491, + "text": "Les résultats du DRC avec les erreurs trouvées" + }, + { + "id": 492, + "text": "Le schéma de câblage pour souder les composants à la main" + } + ] + }, + { + "id": 124, + "text": "

Quelle finition de surface est la plus économique pour un prototype et pourquoi ?

", + "single": 1, + "choices": [ + { + "id": 493, + "text": "HASL (Hot Air Solder Leveling) — étain fondu nivelé à l'air chaud, le traitement le moins cher" + }, + { + "id": 494, + "text": "ENIG (Electroless Nickel Immersion Gold) — dorure, la plus répandue" + }, + { + "id": 495, + "text": "OSP (Organic Solderability Preservative) — le plus durable" + }, + { + "id": 496, + "text": "Immersion Silver — le plus commun chez JLCPCB" + } + ] + }, + { + "id": 125, + "text": "

Quel est le coût approximatif pour fabriquer 5 exemplaires d'un PCB 2 couches 50×30mm chez JLCPCB (hors livraison) ?

", + "single": 1, + "choices": [ + { + "id": 497, + "text": "Environ 2$ (deux dollars) — JLCPCB propose les petits PCB standards à des prix très bas" + }, + { + "id": 498, + "text": "Environ 50$ — la fabrication PCB est un processus industriel coûteux" + }, + { + "id": 499, + "text": "Environ 20$ par carte individuelle" + }, + { + "id": 500, + "text": "C'est gratuit pour les designs de moins de 100×100mm" + } + ] + } + ] + } + ] + } + } +} diff --git a/ops/README.md b/ops/README.md index e68f258..07d9c5a 100644 --- a/ops/README.md +++ b/ops/README.md @@ -266,3 +266,62 @@ curl -s "https://moodle.saillant.cc/webservice/rest/server.php" \ --data-urlencode "newstate=$STATE"; echo; done # expected: {"cmid":27,"userid":3,"state":,...,"overrideby":} ``` + +## SP3 — Quiz bank export → Grist (`QuizBank`) + +### export-quiz-bank.php (read-only) + +Dumps the whole quiz question bank (all courses id > 1) as ONE JSON object +on stdout. Pure `$DB` reads, never writes. Moodle 4.5 schema chain (verified +against the live DB — `quiz_slots` no longer carries `questionid`): + +``` +quiz_slots.id + → question_references (component='mod_quiz', questionarea='slot', + itemid = slot.id) + → questionbankentryid + → question_versions (MAX(version); reference.version is NULL = "always latest") + → question (+ qtype_multichoice_options.single, question_answers) +``` + +`gradepass` comes from `grade_items` (`itemmodule='quiz'`, +`iteminstance=quiz.id`) and may be 0/NULL. `correct = fraction > 0` on +`question_answers`. Quizzes are ordered by name ("Quiz N : …" = module +order); `quiz_n` is parsed from the name. + +### Re-export / re-push procedure (from Tower) + +```bash +docker cp ops/export-quiz-bank.php moodle:/tmp/export-quiz-bank.php +docker exec moodle php /tmp/export-quiz-bank.php > /tmp/quizbank.json +set -a; . /home/clems/formations-app.env; set +a +python3 ops/push-quiz-bank.py /tmp/quizbank.json +``` + +`push-quiz-bank.py` is idempotent: it creates the `QuizBank` table +(AddTable apply) if missing; otherwise it deletes ALL rows and re-inserts. +Expected: 24 rows (6 courses × 4 quizzes), 5 questions per quiz. + +### Grist table `QuizBank` (doc `bPfitC7Poc4r2xGRx7FgHL`) + +| column | type | content | +|---|---|---| +| `course_slug` | Text | app slug (id→slug map mirrors `src/config/courses.ts`) | +| `quiz_n` | Int | 1..4, parsed from the quiz name | +| `cmid` | Int | quiz course-module id (completion override target) | +| `name` | Text | quiz name, e.g. "Quiz 1 : Découverte" | +| `gradepass` | Numeric | pass threshold in % (0 = unset → app defaults to 50 %) | +| `payload` | Text | JSON string `{"questions": [{id, text, single, choices: [{id, text, correct, feedback}]}]}` | + +### ⚠️ Sanitization warning — repo is PUBLIC + +The export and the Grist `payload` contain the **correct flags and the +feedback**. **Answers live ONLY in Grist** (API-key protected). Never +commit a raw export to this repository. The committed fixture +`fixtures/quizbank-3.json` (course 3, kicad-makers) is **sanitized**: every +`correct` and `feedback` key stripped recursively. Proof after any +regeneration (must print 0): + +```bash +grep -c '"correct"\|"feedback"' fixtures/quizbank-3.json +``` diff --git a/ops/export-quiz-bank.php b/ops/export-quiz-bank.php new file mode 100644 index 0000000..58dbf3c --- /dev/null +++ b/ops/export-quiz-bank.php @@ -0,0 +1,107 @@ + quizbank.json +// +// Moodle 4.5 schema chain (quiz_slots no longer carries questionid): +// quiz_slots.id -> question_references (component='mod_quiz', +// questionarea='slot', itemid=slot.id) -> questionbankentryid -> +// question_versions (latest version; qr.version NULL = "always latest") +// -> question. Multichoice flag in qtype_multichoice_options.single, +// choices in question_answers (fraction>0 = correct). +// +// Output shape: +// {courses: {"": {quizzes: [{quiz_n, cmid, name, gradepass, +// questions: [{id, text, single, choices: [{id, text, correct, +// feedback}]}]}]}}} +// +// WARNING: the output contains the CORRECT flags and feedback — it must +// only ever land in Grist (API-key protected), NEVER in this public repo. +define('CLI_SCRIPT', true); +require('/var/www/html/config.php'); + +$quizmoduleid = $DB->get_field('modules', 'id', ['name' => 'quiz'], MUST_EXIST); + +// Quizzes are named "Quiz N : ..." — name order is module order. +$quizzes = $DB->get_records_sql(" + SELECT q.id, q.course, q.name, cm.id AS cmid + FROM {quiz} q + JOIN {course_modules} cm ON cm.instance = q.id AND cm.module = :mod + WHERE q.course > 1 + ORDER BY q.course ASC, q.name ASC", ['mod' => $quizmoduleid]); + +$out = ['courses' => []]; + +foreach ($quizzes as $quiz) { + $gradepass = $DB->get_field('grade_items', 'gradepass', + ['itemmodule' => 'quiz', 'iteminstance' => $quiz->id]); + $gradepass = $gradepass ? (float)$gradepass : 0.0; // may be 0/NULL + + $quizn = 0; + if (preg_match('/^Quiz\s+(\d+)/u', $quiz->name, $m)) { + $quizn = (int)$m[1]; + } + + $slotrows = $DB->get_records_sql(" + SELECT qs.id AS slotid, qs.slot, qv.questionid + FROM {quiz_slots} qs + JOIN {question_references} qr + ON qr.component = 'mod_quiz' + AND qr.questionarea = 'slot' + AND qr.itemid = qs.id + JOIN {question_versions} qv + ON qv.questionbankentryid = qr.questionbankentryid + AND qv.version = (SELECT MAX(v.version) + FROM {question_versions} v + WHERE v.questionbankentryid + = qr.questionbankentryid) + WHERE qs.quizid = :quizid + ORDER BY qs.slot ASC", ['quizid' => $quiz->id]); + + $questions = []; + foreach ($slotrows as $row) { + $question = $DB->get_record('question', + ['id' => $row->questionid], '*', MUST_EXIST); + if ($question->qtype !== 'multichoice') { + fwrite(STDERR, "skip q{$question->id}: qtype " + . "{$question->qtype} (only multichoice supported)\n"); + continue; + } + $opts = $DB->get_record('qtype_multichoice_options', + ['questionid' => $question->id], '*', MUST_EXIST); + $answers = $DB->get_records('question_answers', + ['question' => $question->id], 'id ASC'); + + $choices = []; + foreach ($answers as $a) { + $choices[] = [ + 'id' => (int)$a->id, + 'text' => $a->answer, + 'correct' => (float)$a->fraction > 0, + 'feedback' => $a->feedback, + ]; + } + $questions[] = [ + 'id' => (int)$question->id, + 'text' => $question->questiontext, + 'single' => (int)$opts->single, + 'choices' => $choices, + ]; + } + + $courseid = (string)$quiz->course; + if (!isset($out['courses'][$courseid])) { + $out['courses'][$courseid] = ['quizzes' => []]; + } + $out['courses'][$courseid]['quizzes'][] = [ + 'quiz_n' => $quizn, + 'cmid' => (int)$quiz->cmid, + 'name' => $quiz->name, + 'gradepass' => $gradepass, + 'questions' => $questions, + ]; +} + +echo json_encode($out, + JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT), "\n"; diff --git a/ops/push-quiz-bank.py b/ops/push-quiz-bank.py new file mode 100644 index 0000000..f0555e9 --- /dev/null +++ b/ops/push-quiz-bank.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Push the Moodle quiz-bank export into Grist table QuizBank (SP3). + +Usage (on Tower, after running ops/export-quiz-bank.php): + set -a; . /home/clems/formations-app.env; set +a + python3 ops/push-quiz-bank.py /tmp/quizbank.json + +Reads GRIST_API_KEY and GRIST_DOC_ID from the environment +(GRIST_URL optional, defaults to https://grist.saillant.cc). + +Idempotent: creates the QuizBank table if missing (AddTable apply, as +SP2 did); if it exists, deletes ALL rows then re-inserts. One row per +quiz; `payload` is the JSON string {"questions": [...]} INCLUDING the +correct flags and feedback — answers live ONLY in Grist, never in the +public repo. +""" +import json +import os +import sys +import urllib.request + +# Moodle course id -> app slug (mirrors src/config/courses.ts) +ID_TO_SLUG = { + 2: "esp32-ia", + 3: "kicad-makers", + 4: "llm-locaux", + 5: "freertos", + 6: "iot-az", + 7: "docker-selfhosting", +} + +COLUMNS = [ + {"id": "course_slug", "type": "Text"}, + {"id": "quiz_n", "type": "Int"}, + {"id": "cmid", "type": "Int"}, + {"id": "name", "type": "Text"}, + {"id": "gradepass", "type": "Numeric"}, + {"id": "payload", "type": "Text"}, +] + + +def api(base, key, path, payload=None, method=None): + req = urllib.request.Request( + base + path, + data=json.dumps(payload).encode() if payload is not None else None, + headers={ + "Authorization": "Bearer " + key, + "Content-Type": "application/json", + }, + method=method or ("POST" if payload is not None else "GET"), + ) + with urllib.request.urlopen(req) as resp: + return json.loads(resp.read() or b"null") + + +def main(): + key = os.environ["GRIST_API_KEY"] + doc = os.environ["GRIST_DOC_ID"] + url = os.environ.get("GRIST_URL", "https://grist.saillant.cc").rstrip("/") + base = f"{url}/api/docs/{doc}" + bank = json.load(open(sys.argv[1] if len(sys.argv) > 1 + else "/tmp/quizbank.json")) + + tables = [t["id"] for t in api(base, key, "/tables")["tables"]] + if "QuizBank" not in tables: + api(base, key, "/apply", [["AddTable", "QuizBank", COLUMNS]]) + print("table QuizBank created") + else: + ids = [r["id"] for r in + api(base, key, "/tables/QuizBank/records")["records"]] + if ids: + api(base, key, "/tables/QuizBank/data/delete", ids) + print(f"table QuizBank exists, {len(ids)} rows deleted") + + records = [] + for cid_str, course in sorted(bank["courses"].items(), key=lambda kv: int(kv[0])): + slug = ID_TO_SLUG[int(cid_str)] + for quiz in course["quizzes"]: + records.append({"fields": { + "course_slug": slug, + "quiz_n": quiz["quiz_n"], + "cmid": quiz["cmid"], + "name": quiz["name"], + "gradepass": quiz["gradepass"], + "payload": json.dumps({"questions": quiz["questions"]}, + ensure_ascii=False), + }}) + + api(base, key, "/tables/QuizBank/records", {"records": records}) + n = len(api(base, key, "/tables/QuizBank/records")["records"]) + print(f"inserted {len(records)} rows, table now has {n} rows") + if n != len(records): + sys.exit(f"row count mismatch: expected {len(records)}, got {n}") + + +if __name__ == "__main__": + main()