Files
electron-rare d9bd5e0815 feat: certificate issuing provisioning
ops/provision-certs.php (idempotent): lock each customcert behind
AND-completion of the course 4 quizzes, emailstudents=1, SMTP config
from env (noreplyaddress = iCloud-owned From, 550 trap documented).
Host cron */2 on Tower runs admin/cli/cron.php; proof: gate-check (no
premature issue) + override-issue-revert cycle on freertos/userid 3.
2026-06-11 01:36:36 +02:00

434 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:**
1. Enables Moodle web services + REST protocol + built-in mobile service
(`enablewebservices`, `enablemobilewebservice`, `webserviceprotocols`).
2. Creates the service user `wsreader` (WS Reader, wsreader@saillant.cc)
if it does not already exist.
3. Enrols `wsreader` as a student in every real course (id > 1) so that
`core_course_get_contents` returns 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):**
```bash
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:
```bash
# 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:
```bash
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:
```json
[
{
"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`, and `subitems` (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.html`
Fetch with `?token=<TOKEN>` appended.
**Parser recipe for Task 3:**
1. Filter `contents` where `type == "file"` and `filename == "index.html"`.
2. Extract `chapterId` from `filepath` (strip slashes → integer).
3. Use `content` as the chapter title.
4. Use `fileurl + "?token=" + TOKEN` to fetch HTML.
5. Optionally parse the `structure` entry 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`):
```bash
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`
| endpoint | URL |
|---|---|
| authorization | https://auth.saillant.cc/realms/electron_rare/protocol/openid-connect/auth |
| token | https://auth.saillant.cc/realms/electron_rare/protocol/openid-connect/token |
| userinfo | https://auth.saillant.cc/realms/electron_rare/protocol/openid-connect/userinfo |
| end_session | https://auth.saillant.cc/realms/electron_rare/protocol/openid-connect/logout |
### Grist progression doc
- Doc `formations-progression`, id **`bPfitC7Poc4r2xGRx7FgHL`**, org
`electron-rare` (id 3), workspace `Home` (id 3) on grist.saillant.cc.
- Table `Progression`: `user_sub` Text, `email` Text, `course_slug` Text,
`module_n` Int, `chapitre_n` Int, `lu_le` DateTime (epoch seconds),
`moodle_synced` Bool.
- 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:
1. 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`.
- `viewalldetails` is required or `core_user_get_users_by_field` silently
returns `[]` (the WS only returns users whose queried field is visible).
- `site:viewuseridentity` is required for `field=email` lookups (outside a
course, email is only exposed through the site identity fields).
- `webservice:createtoken` is required by `login/token.php` for any
non-mobile service.
2. User `wssync` (password from env `WSSYNC_PASSWORD`), role assigned at
system context.
3. 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 stock `moodle_mobile_app` service does NOT include the completion
override function — tokens must be issued against `formations_sync`.
**How to run (from Tower):**
```bash
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
```bash
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)
```bash
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)
```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
```
### 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.
## SP4 — Certificate issuing (`provision-certs.php` + cron)
### provision-certs.php (idempotent)
For each real course (id > 1):
1. Locks the `customcert` activity (cmids 55-60, one per course) behind
**AND-completion of the course's 4 quizzes**:
`course_modules.availability` =
```json
{"op":"&","c":[{"type":"completion","cm":<quiz1>,"e":1},
{"type":"completion","cm":<quiz2>,"e":1},
{"type":"completion","cm":<quiz3>,"e":1},
{"type":"completion","cm":<quiz4>,"e":1}],
"showc":[true,true,true,true]}
```
(`e:1` = activity marked COMPLETE; quiz cmids derived from the DB in
quiz-name order, same convention as `export-quiz-bank.php`), then
`rebuild_course_cache($courseid, true)`. Closes the "any enrolled user
can download the certificate" hole.
2. Sets `customcert.emailstudents = 1` (issue task emails the PDF).
3. Configures outgoing SMTP from env: `smtphosts=<SMTP_HOST>:<SMTP_PORT>`,
`smtpsecure=tls`, `smtpuser`, `smtppass`,
`noreplyaddress=<SMTP_FROM>`, `allowedemaildomains=''`.
### SMTP source — the iCloud 550 trap
SMTP values come from electron-server
`/home/clems/electron-rare-site/mail-api.env` (`SMTP_HOST`, `SMTP_PORT`,
`SMTP_USER`, `SMTP_PASS`) at provisioning time — never stored in this
repo. **`SMTP_FROM` (→ `noreplyaddress`) MUST be the iCloud-owned
account address (= `SMTP_USER`), NOT `contact@lelectronrare.fr`** —
iCloud 550-rejects any From the account does not own (same trap as the
lelectronrare.fr contact form). The script warns if `SMTP_FROM`
differs from `SMTP_USER`.
**How to run (from Tower):**
```bash
docker cp ops/provision-certs.php moodle:/tmp/provision-certs.php
docker exec \
-e SMTP_HOST=... -e SMTP_PORT=587 \
-e SMTP_USER=<icloud-address> -e SMTP_PASS=... \
-e SMTP_FROM=<icloud-address> \
moodle php /tmp/provision-certs.php
```
### Cron (Tower, clems crontab — installed 2026-06-11)
The restored Moodle compose is web+db only (no cron runner), so scheduled
tasks (certificate issuing/emailing) need a host crontab line:
```
*/2 * * * * docker exec moodle php /var/www/html/admin/cli/cron.php >/dev/null 2>&1
```
Idempotent re-install:
```bash
CRON_LINE='*/2 * * * * docker exec moodle php /var/www/html/admin/cli/cron.php >/dev/null 2>&1'
(crontab -l 2>/dev/null | grep -vF "admin/cli/cron.php"; echo "$CRON_LINE") | crontab -
```
The certificate task is `\mod_customcert\task\issue_certificates_task`
(this customcert version renamed the historical `email_certificate_task`;
it both issues and emails). Verify enabled:
```bash
docker exec moodle-db sh -c 'psql -U $POSTGRES_USER -d $POSTGRES_DB -c \
"SELECT classname, disabled FROM mdl_task_scheduled WHERE classname LIKE \$\$%customcert%\$\$;"'
```
### Proof procedure (run 2026-06-11, then reverted)
1. Gate check: cron run BEFORE any override → 0 rows in
`mdl_customcert_issues` (availability really gates; wsreader is
enrolled everywhere with no quiz completions).
2. Override the 4 freertos quiz completions (course 5, cmids 43-46) for
wsreader (userid 3) via `MOODLE_SYNC_TOKEN` +
`core_completion_override_activity_completion_status` (newstate=1).
3. Cron run → `mdl_customcert_issues` row (userid 3, emailed=1) = issuance
+ email send work end-to-end.
4. Revert: same overrides with newstate=0, then
`DELETE FROM mdl_customcert_issues WHERE userid=3;` and one more cron
run to confirm no re-issue.
### Rollback
- Unlock certificates: `UPDATE mdl_course_modules SET availability=NULL
WHERE id IN (55,56,57,58,59,60);` + purge caches
(`docker exec moodle php /var/www/html/admin/cli/purge_caches.php`).
- Stop emailing: `UPDATE mdl_customcert SET emailstudents=0;` or remove
the crontab line (`crontab -l | grep -vF "admin/cli/cron.php" | crontab -`).
- Unset SMTP: `php admin/cli/cfg.php --name=smtphosts --unset` (idem
smtpuser/smtppass/noreplyaddress).