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:
@@ -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":<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).
|
||||
|
||||
@@ -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";
|
||||
Reference in New Issue
Block a user