feat: quiz bank export to grist
This commit is contained in:
@@ -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
|
||||
```
|
||||
|
||||
@@ -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";
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user