ops/README.md — Moodle web-service provisioning
provision-ws.php
Idempotent script that bootstraps the read-only WS integration on the live Moodle.
What it does:
- Enables Moodle web services + REST protocol + built-in mobile service
(
enablewebservices,enablemobilewebservice,webserviceprotocols). - Creates the service user
wsreader(WS Reader, wsreader@saillant.cc) if it does not already exist. - Enrols
wsreaderas a student in every real course (id > 1) so thatcore_course_get_contentsreturns content for each course.
Script is safe to re-run: user creation is skipped if the username exists; enrolment is idempotent via the manual enrolment plugin.
How to run (from Tower, where Moodle containers live):
WSPASS=$(grep WSREADER_PASSWORD ~/formations-app.env | cut -d= -f2-)
docker cp ops/provision-ws.php moodle:/tmp/provision-ws.php
docker exec -e WSREADER_PASSWORD="$WSPASS" moodle php /tmp/provision-ws.php
Expected output:
user created: <id> (or "user exists: <id>" on re-run)
enrolled: freertos
enrolled: kicad-makers
enrolled: esp32-ia
enrolled: docker-selfhosting
enrolled: iot-az
enrolled: llm-locaux
OK
Env file — Tower: /home/clems/formations-app.env
WSREADER_PASSWORD=<generated on first run>
MOODLE_WS_TOKEN=<issued by Moodle mobile service>
Never commit this file. It lives on Tower only.
Token renewal
The mobile-service token does not expire by default in Moodle 4.5. To revoke and renew:
# Revoke current token via Moodle admin UI:
# Site admin → Server → Web services → Manage tokens → delete wsreader token
# Then re-issue:
WSPASS=$(grep WSREADER_PASSWORD ~/formations-app.env | cut -d= -f2-)
TOKEN=$(curl -s "https://moodle.saillant.cc/login/token.php" \
--data-urlencode "username=wsreader" \
--data-urlencode "password=$WSPASS" \
--data-urlencode "service=moodle_mobile_app" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])')
sed -i "s/^MOODLE_WS_TOKEN=.*/MOODLE_WS_TOKEN=$TOKEN/" ~/formations-app.env
echo "New token: $TOKEN"
Fixture provenance — fixtures/course-contents-3.json
Captured: 2026-06-10 from live Moodle (https://moodle.saillant.cc)
Course: id=3 (kicad-makers — "KiCad pour Makers")
Command:
TOKEN=$(grep MOODLE_WS_TOKEN ~/formations-app.env | cut -d= -f2-)
curl -s "https://moodle.saillant.cc/webservice/rest/server.php" \
--data-urlencode "wstoken=$TOKEN" \
--data-urlencode "wsfunction=core_course_get_contents" \
--data-urlencode "moodlewsrestformat=json" \
--data-urlencode "courseid=3" > fixtures/course-contents-3.json
No tokens are embedded in the fixture. pluginfile.php URLs in the
fixture do NOT include the token — authentication is added at fetch-time
by appending ?token=<TOKEN> to the URL.
Where chapter titles live (CRITICAL for Task 3 parser)
The fixture is a JSON array of sections. Each section has a modules[]
array. Book modules have modname == "book".
Each book module's contents[] array contains two entry types:
1. The structure entry (type="content", filename="structure", filepath="/")
contents[0] is always a special entry with:
-
type: "content" -
filename: "structure" -
filepath: "/" -
content: a JSON string encoding an array of chapter objects:[ { "title": "Introduction", "href": "242/index.html", "level": 0, "hidden": "0", "subitems": [...] }, ... ]Each object has
title(human-readable chapter title),href(chapter id from the path, e.g.242),level(0=top-level, 1=subchapter),hidden, andsubitems(nested subchapters with the same shape).This is the authoritative TOC with hierarchy — use this to build the navigation tree.
2. Per-chapter file entries (type="file", filename="index.html")
Each subsequent entry has:
type: "file"filename: "index.html"filepath: "/<chapterId>/"— e.g."/242/"(chapter id is the path segment)content: the plain-text chapter title (e.g."Introduction")fileurl: pluginfile URL to fetch the chapter HTML, e.g.:https://moodle.saillant.cc/webservice/pluginfile.php/69/mod_book/chapter/242/index.htmlFetch with?token=<TOKEN>appended.
Parser recipe for Task 3:
- Filter
contentswheretype == "file"andfilename == "index.html". - Extract
chapterIdfromfilepath(strip slashes → integer). - Use
contentas the chapter title. - Use
fileurl + "?token=" + TOKENto fetch HTML. - Optionally parse the
structureentry for hierarchy (level/subitems).
Example (from module "Module 1 : Découverte de KiCad"):
| filepath | content (title) | fileurl (fetch with ?token=…) |
|---|---|---|
| /242/ | Introduction | …/pluginfile.php/69/mod_book/chapter/242/index.html |
| /243/ | 1.1 Introduction et installation | …/pluginfile.php/69/mod_book/chapter/243/index.html |
| /244/ | Pourquoi KiCad plutôt qu'Eagle, … | …/pluginfile.php/69/mod_book/chapter/244/index.html |
| /254/ | Récapitulatif | …/pluginfile.php/69/mod_book/chapter/254/index.html |
SP2 — Keycloak SSO, Grist progression, Moodle wssync
New env vars — Tower: /home/clems/formations-app.env
OIDC_ISSUER=https://auth.saillant.cc/realms/electron_rare
OIDC_CLIENT_ID=formations-app
OIDC_CLIENT_SECRET=<Keycloak client secret, see below>
SESSION_SECRET=<openssl rand -hex 32>
APP_BASE_URL=https://formations.saillant.cc
GRIST_BASE_URL=https://grist.saillant.cc
GRIST_API_KEY=<Grist API key>
GRIST_DOC_ID=bPfitC7Poc4r2xGRx7FgHL
WSSYNC_PASSWORD=<generated on first provision run>
MOODLE_SYNC_TOKEN=<issued against the formations_sync service, see below>
Never commit this file. It lives on Tower only.
Keycloak client (realm electron_rare)
Provisioned 2026-06-10 with kcadm inside the suite-keycloak container on
electron-server (admin creds come from the container env
KC_BOOTSTRAP_ADMIN_USERNAME / KC_BOOTSTRAP_ADMIN_PASSWORD):
docker exec suite-keycloak bash -c '
/opt/keycloak/bin/kcadm.sh config credentials --server http://localhost:8080 \
--realm master --user "$KC_BOOTSTRAP_ADMIN_USERNAME" --password "$KC_BOOTSTRAP_ADMIN_PASSWORD"
/opt/keycloak/bin/kcadm.sh create clients -r electron_rare \
-s clientId=formations-app -s protocol=openid-connect \
-s publicClient=false -s standardFlowEnabled=true -s directAccessGrantsEnabled=false \
-s "redirectUris=[\"https://formations.saillant.cc/auth/callback\"]" \
-s "webOrigins=[\"https://formations.saillant.cc\"]"
# client uuid: 120bb8ff-5dc5-4790-be0e-724a5f485da7
/opt/keycloak/bin/kcadm.sh get clients/120bb8ff-5dc5-4790-be0e-724a5f485da7/client-secret -r electron_rare'
Discovery (verified): https://auth.saillant.cc/realms/electron_rare/.well-known/openid-configuration
Grist progression doc
- Doc
formations-progression, idbPfitC7Poc4r2xGRx7FgHL, orgelectron-rare(id 3), workspaceHome(id 3) on grist.saillant.cc. - Table
Progression:user_subText,emailText,course_slugText,module_nInt,chapitre_nInt,lu_leDateTime (epoch seconds),moodle_syncedBool. - Smoke-tested add/list/delete via
/api/docs/bPfitC7Poc4r2xGRx7FgHL/tables/Progression/records. - CAUTION: Grist Bool filters are quirky — filter booleans in app code; only simple equality filters on Text columns are reliable as API filter params.
provision-sync-role.php (Moodle wssync)
Idempotent script that provisions the privileged sync side:
- Role
wssync(system context, assignable at system level) with caps:moodle/user:create,moodle/user:viewdetails,moodle/user:viewalldetails,moodle/site:viewuseridentity,moodle/user:update,enrol/manual:enrol,moodle/course:overridecompletion,moodle/course:viewparticipants,webservice/rest:use,moodle/webservice:createtoken.viewalldetailsis required orcore_user_get_users_by_fieldsilently returns[](the WS only returns users whose queried field is visible).site:viewuseridentityis required forfield=emaillookups (outside a course, email is only exposed through the site identity fields).webservice:createtokenis required bylogin/token.phpfor any non-mobile service.
- User
wssync(password from envWSSYNC_PASSWORD), role assigned at system context. - Dedicated external service
formations_sync(restricted users, wssync authorised) with functions:core_user_get_users_by_field,core_user_create_users,core_user_update_users,enrol_manual_enrol_users,core_course_get_contents,core_completion_override_activity_completion_status. The stockmoodle_mobile_appservice does NOT include the completion override function — tokens must be issued againstformations_sync.
How to run (from Tower):
WSPASS=$(grep WSSYNC_PASSWORD ~/formations-app.env | cut -d= -f2-)
docker cp ops/provision-sync-role.php moodle:/tmp/provision-sync-role.php
docker exec -e WSSYNC_PASSWORD="$WSPASS" moodle php /tmp/provision-sync-role.php
MOODLE_SYNC_TOKEN issue / renewal
WSPASS=$(grep WSSYNC_PASSWORD ~/formations-app.env | cut -d= -f2-)
TOKEN=$(curl -s "https://moodle.saillant.cc/login/token.php" \
--data-urlencode "username=wssync" \
--data-urlencode "password=$WSPASS" \
--data-urlencode "service=formations_sync" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])')
sed -i "s/^MOODLE_SYNC_TOKEN=.*/MOODLE_SYNC_TOKEN=$TOKEN/" ~/formations-app.env
Capability proofs (run after provisioning; both verified 2026-06-10)
TOKEN=$(grep MOODLE_SYNC_TOKEN ~/formations-app.env | cut -d= -f2-)
# 1) user lookup (must return the user, not [] nor an exception)
curl -s "https://moodle.saillant.cc/webservice/rest/server.php" \
--data-urlencode "wstoken=$TOKEN" \
--data-urlencode "wsfunction=core_user_get_users_by_field" \
--data-urlencode "moodlewsrestformat=json" \
--data-urlencode "field=username" --data-urlencode "values[0]=wsreader"
# 2) completion override on course 3's first book (cmid 27), then revert
for STATE in 1 0; do
curl -s "https://moodle.saillant.cc/webservice/rest/server.php" \
--data-urlencode "wstoken=$TOKEN" \
--data-urlencode "wsfunction=core_completion_override_activity_completion_status" \
--data-urlencode "moodlewsrestformat=json" \
--data-urlencode "cmid=27" --data-urlencode "userid=3" \
--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)
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):
grep -c '"correct"\|"feedback"' fixtures/quizbank-3.json
Grist table QuizAttempts (doc bPfitC7Poc4r2xGRx7FgHL)
Created one-shot via the Grist API (/apply AddTable) on 2026-06-10 — the
app never creates tables. Columns: user_sub Text, email Text,
course_slug Text, quiz_n Int, score Int, total Int, reponses
Text (JSON {questionId: [choiceIds]}), date DateTime (epoch seconds),
moodle_synced Bool. One row per submitted attempt (retakes unlimited,
best wins); same Bool-filter caveat as Progression — filter quiz_n /
moodle_synced in app code, only Text equality filters via the API.