diff --git a/ops/README.md b/ops/README.md index bba6c68..d3b7668 100644 --- a/ops/README.md +++ b/ops/README.md @@ -335,3 +335,99 @@ 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":,"e":1}, + {"type":"completion","cm":,"e":1}, + {"type":"completion","cm":,"e":1}, + {"type":"completion","cm":,"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=:`, + `smtpsecure=tls`, `smtpuser`, `smtppass`, + `noreplyaddress=`, `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= -e SMTP_PASS=... \ + -e SMTP_FROM= \ + 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). diff --git a/ops/provision-certs.php b/ops/provision-certs.php new file mode 100644 index 0000000..e1cc961 --- /dev/null +++ b/ops/provision-certs.php @@ -0,0 +1,105 @@ + 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";