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.
This commit is contained in:
electron-rare
2026-06-11 01:36:36 +02:00
parent b7258be8cc
commit d9bd5e0815
2 changed files with 201 additions and 0 deletions
+96
View File
@@ -335,3 +335,99 @@ Text (JSON `{questionId: [choiceIds]}`), `date` DateTime (epoch seconds),
`moodle_synced` Bool. One row per submitted attempt (retakes unlimited, `moodle_synced` Bool. One row per submitted attempt (retakes unlimited,
best wins); same Bool-filter caveat as Progression — filter `quiz_n` / best wins); same Bool-filter caveat as Progression — filter `quiz_n` /
`moodle_synced` in app code, only Text equality filters via the API. `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).
+105
View File
@@ -0,0 +1,105 @@
<?php
// Idempotent provisioning of certificate issuing (SP4 Task 1).
// For every real course (id > 1):
// - locks the customcert activity behind AND-completion of the course's
// 4 quizzes (course_modules.availability), closing the "any enrolled
// user can download the certificate" hole;
// - sets customcert.emailstudents = 1 so the issue task emails the PDF.
// Then configures Moodle SMTP from env (secrets never live in this repo).
//
// ⚠️ SMTP_FROM (-> noreplyaddress) MUST be the iCloud-owned account
// address (SMTP_USER). iCloud rejects any other From with a 550 — the
// same trap as the lelectronrare.fr contact form.
//
// Run: docker cp ops/provision-certs.php moodle:/tmp/ &&
// docker exec -e SMTP_HOST=... -e SMTP_PORT=... -e SMTP_USER=... \
// -e SMTP_PASS=... -e SMTP_FROM=... \
// moodle php /tmp/provision-certs.php
define('CLI_SCRIPT', true);
require('/var/www/html/config.php');
require_once($CFG->dirroot.'/course/lib.php'); // rebuild_course_cache()
$certmoduleid = $DB->get_field('modules', 'id', ['name' => 'customcert'], MUST_EXIST);
$quizmoduleid = $DB->get_field('modules', 'id', ['name' => 'quiz'], MUST_EXIST);
foreach ($DB->get_records_select('course', 'id > 1', null, 'id ASC') as $course) {
// The course's customcert course-module (exactly one per course).
$certcms = $DB->get_records('course_modules',
['course' => $course->id, 'module' => $certmoduleid], 'id ASC');
if (count($certcms) !== 1) {
fwrite(STDERR, "skip {$course->shortname}: " . count($certcms)
. " customcert cms (expected 1)\n");
continue;
}
$certcm = reset($certcms);
// The 4 quiz cmids, in module order ("Quiz N : ..." => name order,
// same convention as export-quiz-bank.php).
$quizcms = $DB->get_records_sql("
SELECT cm.id AS cmid, q.name
FROM {course_modules} cm
JOIN {quiz} q ON q.id = cm.instance
WHERE cm.course = :courseid AND cm.module = :mod
ORDER BY q.name ASC",
['courseid' => $course->id, 'mod' => $quizmoduleid]);
if (count($quizcms) !== 4) {
fwrite(STDERR, "skip {$course->shortname}: " . count($quizcms)
. " quizzes (expected 4)\n");
continue;
}
// availability = AND of "quiz marked complete" (e:1 = COMPLETE).
$conditions = [];
$showc = [];
foreach ($quizcms as $qcm) {
$conditions[] = ['type' => 'completion', 'cm' => (int)$qcm->cmid, 'e' => 1];
$showc[] = true;
}
$availability = json_encode(
['op' => '&', 'c' => $conditions, 'showc' => $showc],
JSON_UNESCAPED_SLASHES);
if ((string)$certcm->availability === $availability) {
echo "{$course->shortname}: availability unchanged (cm {$certcm->id})\n";
} else {
$DB->set_field('course_modules', 'availability', $availability,
['id' => $certcm->id]);
rebuild_course_cache($course->id, true);
echo "{$course->shortname}: availability set on cm {$certcm->id} = "
. $availability . "\n";
}
// Email the student the PDF once issued.
if ((int)$DB->get_field('customcert', 'emailstudents',
['id' => $certcm->instance]) !== 1) {
$DB->set_field('customcert', 'emailstudents', 1, ['id' => $certcm->instance]);
echo "{$course->shortname}: emailstudents set to 1\n";
} else {
echo "{$course->shortname}: emailstudents already 1\n";
}
}
// SMTP outgoing mail config from env (values from ES mail-api.env at
// provisioning time; stored in Moodle config, never in git).
$smtphost = getenv('SMTP_HOST');
$smtpport = getenv('SMTP_PORT');
$smtpuser = getenv('SMTP_USER');
$smtppass = getenv('SMTP_PASS');
$smtpfrom = getenv('SMTP_FROM');
if (!$smtphost || !$smtpport || !$smtpuser || !$smtppass || !$smtpfrom) {
fwrite(STDERR, "SMTP_HOST/SMTP_PORT/SMTP_USER/SMTP_PASS/SMTP_FROM required\n");
exit(1);
}
if (strcasecmp($smtpfrom, $smtpuser) !== 0) {
fwrite(STDERR, "WARNING: SMTP_FROM != SMTP_USER — iCloud 550-rejects "
. "From addresses the account does not own\n");
}
set_config('smtphosts', $smtphost . ':' . $smtpport);
set_config('smtpsecure', 'tls');
set_config('smtpuser', $smtpuser);
set_config('smtppass', $smtppass);
set_config('noreplyaddress', $smtpfrom);
set_config('allowedemaildomains', '');
echo "smtp: smtphosts={$smtphost}:{$smtpport} smtpsecure=tls "
. "smtpuser={$smtpuser} noreplyaddress={$smtpfrom} (smtppass set)\n";
echo "OK\n";