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
+59
View File
@@ -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":<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()