feat: quiz bank export to grist

This commit is contained in:
electron-rare
2026-06-11 00:04:24 +02:00
parent 63302ce49e
commit 2672cfce9b
4 changed files with 763 additions and 0 deletions
+500
View File
@@ -0,0 +1,500 @@
{
"courses": {
"3": {
"quizzes": [
{
"quiz_n": 1,
"cmid": 51,
"name": "Quiz 1 : Découverte",
"gradepass": 60,
"questions": [
{
"id": 106,
"text": "<p>Dans quel ordre s'effectuent les étapes de conception dans KiCad ?</p>",
"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": "<p>Qu'est-ce qu'une <strong>netlist</strong> dans KiCad ?</p>",
"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": "<p>Quelle est la différence entre un <strong>symbole</strong> et un <strong>footprint (empreinte)</strong> dans KiCad ?</p>",
"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": "<p>Quel outil KiCad vérifie les erreurs dans le <strong>schéma électrique</strong> (connexions manquantes, conflits de type de pin) ?</p>",
"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": "<p>Dans KiCad, que sont les fichiers <strong>Gerber</strong> ?</p>",
"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": "<p>Que signifie <strong>ERC</strong> dans KiCad et que vérifie-t-il ?</p>",
"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": "<p>Pourquoi faut-il ajouter des symboles <strong>PWR_FLAG</strong> dans le schéma KiCad ?</p>",
"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": "<p>Quel est l'avantage d'utiliser des <strong>net labels</strong> plutôt que des fils directs pour connecter des composants éloignés dans le schéma ?</p>",
"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": "<p>Pourquoi mettre une résistance de 330Ω en série avec une LED alimentée en 3.3V ?</p>",
"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": "<p>Pourquoi faut-il des résistances de 5.1kΩ sur les broches <strong>CC1/CC2</strong> du connecteur USB-C ?</p>",
"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": "<p>Pourquoi utilise-t-on des pistes plus larges (0.5mm) pour les rails d'alimentation VIN et +3V3 ?</p>",
"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": "<p>Qu'est-ce qu'un <strong>via</strong> dans KiCad et quand l'utilise-t-on ?</p>",
"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": "<p>Quels sont les avantages d'un plan de masse (copper pour GND) sur la couche B.Cu ?</p>",
"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": "<p>Pourquoi la valeur de <strong>clearance</strong> (espacement minimum entre conducteurs) est-elle importante pour la fabrication ?</p>",
"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": "<p>Que signifie le code \"0805\" pour un composant SMD et pourquoi le choisit-on pour un maker débutant ?</p>",
"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": "<p>Pour un PCB 2 couches standard, combien de fichiers Gerber minimum faut-il générer (hors fichier de perçage) ?</p>",
"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": "<p>Quel format est utilisé pour le <strong>fichier de perçage</strong> (drill file) dans KiCad ?</p>",
"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": "<p>Que contient une <strong>BOM</strong> (Bill of Materials) et à quoi sert-elle ?</p>",
"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": "<p>Quelle finition de surface est la plus <strong>économique</strong> pour un prototype et pourquoi ?</p>",
"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": "<p>Quel est le coût approximatif pour fabriquer <strong>5 exemplaires</strong> d'un PCB 2 couches 50×30mm chez JLCPCB (hors livraison) ?</p>",
"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"
}
]
}
]
}
]
}
}
}
+59
View File
@@ -266,3 +266,62 @@ curl -s "https://moodle.saillant.cc/webservice/rest/server.php" \
--data-urlencode "newstate=$STATE"; echo; done --data-urlencode "newstate=$STATE"; echo; done
# expected: {"cmid":27,"userid":3,"state":<STATE>,...,"overrideby":<wssync id>} # expected: {"cmid":27,"userid":3,"state":<STATE>,...,"overrideby":<wssync id>}
``` ```
## 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
```
+107
View File
@@ -0,0 +1,107 @@
<?php
// Read-only export of the Moodle quiz question bank as ONE JSON object on
// stdout, consumed by the Grist QuizBank push (SP3). Never writes to the DB.
// Run: docker cp ops/export-quiz-bank.php moodle:/tmp/ &&
// docker exec moodle php /tmp/export-quiz-bank.php > 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: {"<courseid>": {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";
+97
View File
@@ -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()