# Moodle bootstrap runbook

Moodle runs as a docker-compose service alongside the Django app (the home-grown
REP LMS was retired). This runbook covers first boot, enabling Web Services for
the MEL outcome pull, and wiring "Log in with IILMP" SSO.

## 1. Bring Moodle up

```bash
docker compose up -d moodle-mariadb moodle
# First boot installs Moodle — this is slow (~2–3 min). Watch health:
docker compose ps moodle          # waits on start_period (180s) then healthy
docker compose logs -f moodle     # until "moodle ... started"
```

- Admin UI: <http://localhost:8081> (host port `MOODLE_HOST_PORT`, default 8081).
- Admin login: `MOODLE_ADMIN_USER` / `MOODLE_ADMIN_PASSWORD` (see `.env`).
- The Django app reaches Moodle server-to-server at `http://moodle:8080`
  (`MOODLE_BASE_URL`); Moodle reaches Django at `http://web:8000`.

## 2. Enable Web Services + a token (for the MEL outcome pull — Phase F)

In the Moodle admin UI:

1. **Site administration → Advanced features** → tick **Enable web services** → Save.
2. **Site administration → Server → Web services → Manage protocols** → enable **REST**.
3. **Server → Web services → External services** → **Add** a service
   "IILMP MEL Pull" (enabled, authorised users only). Add these functions:
   - `core_course_get_courses`
   - `core_course_get_courses_by_field` (landing-page course catalogue + thumbnails)
   - `core_enrol_get_enrolled_users`
   - `core_completion_get_course_completion_status`
   - `gradereport_user_get_grade_items`
   - `mod_quiz_get_quizzes_by_courses`
   - `mod_quiz_get_user_attempts`
   - `mod_feedback_get_feedbacks_by_courses`
   - `mod_feedback_get_analysis`
   - `core_user_get_users_by_field` (for lms_ref back-linking)
   - `core_role_assign_roles` + `core_role_unassign_roles` (IILMP→Moodle role
     projection — see "Role sync" below; the token user must be a siteadmin or
     hold `moodle/role:assign` for manager/coursecreator at system context)
4. Create/choose a dedicated service user, grant it the
   `webservice/rest:use` capability and add it as an authorised user of the service.
   **Tick "Can download files"** on the service, or the landing-page course
   thumbnails (served via `webservice/pluginfile.php`) fail with an
   `accessexception`.
5. **Server → Web services → Manage tokens** → create a token for that user + the
   "IILMP MEL Pull" service. Copy it into `.env` as `MOODLE_WS_TOKEN`, then
   `docker compose up -d web celery_worker celery_beat` to pick it up.

Smoke-test the token from the `web` container:

```bash
docker compose exec web python - <<'PY'
from apps.mel.tracking.moodle_client import MoodleClient
from django.conf import settings
c = MoodleClient(settings.MOODLE_BASE_URL, settings.MOODLE_WS_TOKEN)
print(c.call("core_webservice_get_site_info").get("sitename"))
PY
```

## 3. SSO — "Log in with IILMP" (Phase E)

IILMP is the OAuth2/OIDC provider (django-oauth-toolkit, endpoints under `/o/`).

1. In IILMP, create the OAuth `Application` for Moodle (confidential,
   authorization-code, redirect URI `https://moodle.local/admin/oauth2callback.php`
   in dev / your prod Moodle URL). Note its client id/secret. **Set
   `OAUTH2_PKCE_REQUIRED=False`** — Moodle core `auth_oauth2` does not send a PKCE
   `code_challenge`, so with PKCE required the flow fails with "Code challenge
   required". (Confidential client + secret keeps the token exchange protected.)
   For a truly seamless hand-off, set the Application's `skip_authorization=True`.
2. In Moodle: **Site administration → Server → OAuth 2 services** → **Custom** →
   name "Log in with IILMP". Set client id/secret, and the service URL to IILMP's
   OIDC discovery document: `http://web:8000/o/.well-known/openid-configuration/`.
   Map fields email → email, firstname → given_name, lastname → family_name.
3. Enable the **OAuth 2** auth plugin (Site administration → Plugins →
   Authentication) and allow account creation/linking on first login.
4. First login by an IILMP user creates the matching `mdl_user`; the Phase-F pull
   stamps `CustomUser.lms_ref` = the Moodle user id when it sees a matching email.
5. **"Learning" hand-off.** IILMP's "Learning" nav link goes to
   `…/login/index.php?authprovider=iilmp` (`moodle_learning_url` in
   `apps/core/nav_context.py`). Theme JS (`autoStartIilmpSso` in
   `moodle/theme/iilmp/javascript/iilmp.js`) makes **both** Moodle session states
   land the user in Moodle with no interstitial:
   - **logged out of Moodle** → the page renders the "Log in with IILMP" IdP button
     (it carries the sesskey `auth/oauth2/login.php` needs — un-buildable from
     IILMP); the JS follows it, and the signed-in IILMP session lands in Moodle;
   - **already logged into Moodle** → Moodle would serve "already logged in as X,
     log out first" (no IdP button); the JS detects that and redirects to `/my/`.

   A bare `/login/index.php` (no `authprovider`) is left alone — that's the manual
   form for local Moodle accounts. Set the OAuth Application's
   `skip_authorization=True` so there is no consent prompt. The IILMP login also
   preserves `?next`, so a logged-out-of-IILMP user completes the whole round-trip
   in one sign-in. **After changing this JS, purge the theme cache** (JS is cached
   by themerev): `docker compose exec -u daemon moodle php admin/cli/purge_caches.php`.

> Before enabling auto-provision in prod, audit the ~16,778 IILMP users for blank
> or duplicate emails — email is the join key and duplicates misfire account links.

### Role sync — IILMP roles → Moodle system roles

SSO creates Moodle users as plain "authenticated users". Course roles
(student/teacher) rightly come from enrolment, but instructors/admins would land
powerless, so IILMP projects the few roles meaningful at Moodle's **system
context** (`MOODLE_ROLE_MAP` in `config/settings/base.py`):

| IILMP role | Moodle role (system context) |
|---|---|
| `admin`, `system_admin`, `rep_learning_admin` | manager |
| `instructor` | course creator |
| everything else | *(none — enrolment drives course roles)* |

- **At SSO login:** `IILMPOAuth2Validator` queues `apps.core.sync_moodle_role`
  (celery, 20 s countdown + retries for the first-login race). Assign-only — a
  login never demotes.
- **Nightly reconcile:** beat runs `manage.py sync_moodle_roles --demote` at
  04:15 — catches role changes/revocations made in IILMP after first login.
- **Manual:** `manage.py sync_moodle_roles [--email X] [--demote] [--dry-run]`.
- Uses the same `MOODLE_WS_TOKEN` as the MEL pull; requires the three
  `core_role_*`/`core_user_get_users_by_field` service functions (section 2) and
  a token user that may assign roles (siteadmin qualifies).
- **After a legacy DB restore, verify role ids** (`SELECT id, shortname FROM
  mdl_role`) match `MOODLE_ROLE_IDS` (defaults: manager=1, coursecreator=2);
  override via `MOODLE_ROLE_ID_MANAGER` / `MOODLE_ROLE_ID_COURSECREATOR` env.
- Kill switch: `MOODLE_ROLE_SYNC_ENABLED=False`.

### Dev-only networking for a live SSO + MEL round-trip

Both SSO (Moodle→IILMP token/userinfo) and the MEL pull (IILMP→Moodle WS) are
**server-to-server between containers**, and Moodle 303-redirects any request whose
host ≠ its `wwwroot`. In dev, `localhost:8081` / `localhost:8000` mean different
things inside each container, so there is no single host both the browser and the
containers can use. The fix is a shared hostname:

1. Add to the host's `/etc/hosts` (needs sudo):
   `127.0.0.1  moodle.local  iilmp.local`
2. `docker-compose.yml` gives `web`/`celery*`/`moodle` `extra_hosts: <name>:host-gateway`
   so containers reach the host's published ports via those names.
3. Point Moodle's `wwwroot` at the shared name: set `MOODLE_HOST=moodle.local:8081`
   in `.env`. Bitnami only writes `wwwroot` on *first* install, so on an existing
   instance also edit `$CFG->wwwroot` in `config.php` (→ `http://moodle.local:8081`)
   and `purge_caches.php`. MEL uses `MOODLE_BASE_URL=http://moodle.local:8081`.
4. Django: add `iilmp.local,web,host.docker.internal` to `ALLOWED_HOSTS`.
5. **HTTPS everywhere via Caddy.** Moodle requires HTTPS for OAuth2 issuers
   (`lib/classes/oauth2/endpoint.php`, `issuer.php` reject `http://`), and Chrome's
   HTTPS-First upgrades `.local` navigations to `https` (so plain-HTTP Moodle threw
   `ERR_SSL_PROTOCOL_ERROR`). The `caddy` service terminates TLS for **both**
   `https://iilmp.local` → `web:8000` and `https://moodle.local` → `moodle:8080`
   using its own internal CA.
   - Django: `SECURE_PROXY_SSL_HEADER` (dev) so it emits `https://` OIDC discovery URLs.
   - Moodle: `$CFG->wwwroot = https://moodle.local` and `$CFG->sslproxy = true` in
     `config.php` (bitnami only writes wwwroot on first install → edit + purge). Access
     Moodle at **`https://moodle.local`** (no port); the `8081` mapping stays only as an
     http→https redirect.
   - Server-to-server (MEL pull, thumbnail proxy, `MOODLE_BASE_URL=https://moodle.local`)
     sets `MOODLE_WS_VERIFY=False` in dev (Caddy's cert is self-signed); prod uses a real
     cert so it defaults to True.
   - Trust Caddy's root CA (`/data/caddy/pki/authorities/local/root.crt`) in Moodle's
     container (`update-ca-certificates`) and, for a clean browser padlock, in the host
     keychain — one CA covers both `.local` hosts.
   - The Moodle OAuth2 issuer's service URL is `https://iilmp.local/o`; the OAuth
     Application's redirect URI is `https://moodle.local/admin/oauth2callback.php`.

Prod does not need any of this — real domains behind a TLS ingress resolve
identically from every party, so SSO and the pull work with only the config above.

## Notes / prod

- Prod: mirror the two services in `docker-compose.prod.yml` behind nginx (a
  `moodle.` vhost or `/moodle` path), pin image digests, use real secrets, and
  give Moodle its own backed-up volume. Never point Moodle at the pgvector `postgres`.
- Bitnami moved much of its free catalog to a "Legacy" tier in 2025; pin a specific
  image digest and, if a tag stops pulling, fall back to `bitnamilegacy/*` or a
  community/self-built Moodle image. (Prod images ARE pinned by digest in
  `docker-compose.prod.yml`.)

### Production deploy (EC2, real domains, Cloudflare Flexible)

App on `iilmp.ruforum.org`, Moodle on **`rep.ruforum.org`** — both proxied through
Cloudflare (Flexible: CF terminates TLS, the origin stays HTTP; Django/Moodle read
`X-Forwarded-Proto: https`). `docker-compose.prod.yml` already defines `moodle` +
`moodle-mariadb`; `docker/nginx.conf` has a `rep.ruforum.org` → `moodle:8080` vhost;
`scripts/deploy.sh redeploy` now starts the Moodle services.

Set in `.env.production` (synced to the server as `.env`):

```
MOODLE_HOST=rep.ruforum.org
MOODLE_DB_ROOT_PASSWORD=…  MOODLE_DB_PASSWORD=…  MOODLE_ADMIN_PASSWORD=…  MOODLE_ADMIN_EMAIL=…
MOODLE_BASE_URL=https://rep.ruforum.org   MOODLE_WS_VERIFY=True   # MOODLE_WS_TOKEN filled by configure-moodle-sso
OIDC_RSA_PRIVATE_KEY="$(openssl genrsa 4096)"   OAUTH2_PKCE_REQUIRED=False
MOODLE_SSO_CLIENT_ID=moodle-sso   MOODLE_SSO_CLIENT_SECRET=…   IILMP_BASE_URL=https://iilmp.ruforum.org
PUBLIC_APP_BASE_URL=https://iilmp.ruforum.org
SECURE_SSL_REDIRECT=true  SESSION_COOKIE_SECURE=true  CSRF_COOKIE_SECURE=true  SECURE_HSTS_SECONDS=31536000
```

Sequence (merge branch → main first; put `moodle_src.sql` + `moodle_filedir.tar.gz` on
the server at `$DEPLOY_PATH`):

1. `make redeploy` — deploys code, starts web/celery/nginx **and Moodle**.
2. `make deploy-migrate-moodle` — **native restore** into the prod Moodle
   (`scripts/deploy.sh cmd_migrate_moodle`, mirrors this dev script; ⚠ drops the Moodle DB).
3. `make deploy-configure-moodle-sso` — seeds the Django OAuth app
   (`seed_moodle_oauth_app`), runs `scripts/configure_moodle_prod.php` (issuer + endpoint
   discovery + `auth_oauth2` + WS service), and writes the captured `MOODLE_WS_TOKEN` into
   `.env.production` + restarts web/celery.
4. Verify: `https://rep.ruforum.org/login` (200), OIDC discovery at
   `https://iilmp.ruforum.org/o/.well-known/openid-configuration/`, the MEL pull records
   points, and the SSO hand-off (sign in → "Learning" → land in Moodle authenticated).

The dev-only `caddy` service, `.local` hosts, `MOODLE_WS_VERIFY=False`, and `/etc/hosts`
are **not** used in prod.

## IILMP theme (`theme_iilmp`)

A Boost **child** theme that reskins Moodle with the IILMP brand (olive/cream
palette, Barlow body + Spectral headings, dark-brown topbar, no gradients). All
changes are SCSS — Boost's mustache templates are never forked, so it stays
upgrade-safe.

Location: `moodle/theme/iilmp/` in this repo, bind-mounted into the container at
`/bitnami/moodle/theme/iilmp` (see the `moodle` service `volumes:` in
`docker-compose.yml`). Edits to the SCSS are live — just purge caches to recompile.

Fonts are **self-hosted** under `moodle/theme/iilmp/fonts/` (Moodle's bundled
scssphp rejects external `@import url(...)`), referenced via the `[[font:theme|..]]`
placeholder and served by `/theme/font.php`. Offline-safe, no Google dependency.

### Install / enable

```bash
docker compose up -d moodle          # bind mount picks up the theme dir

# IMPORTANT: run admin/cli as the *daemon* web user, NOT root. Running these as
# root (the docker-exec default) makes Moodle create cache dirs under
# /bitnami/moodledata owned by root, which the daemon web process then cannot
# write to -> every page 500s with "invaliddatarootpermissions". If that happens:
#   docker compose exec moodle chown -R daemon:daemon /bitnami/moodledata

# Register the plugin (themes have no DB tables, so this reports "no upgrade
# needed" — that is expected; the theme is detected at runtime regardless):
docker compose exec -u daemon moodle php admin/cli/upgrade.php --non-interactive

# Set it as the site default theme:
docker compose exec -u daemon moodle php admin/cli/cfg.php --name=theme --set=iilmp

# Purge caches (recompiles the SCSS):
docker compose exec -u daemon moodle php admin/cli/purge_caches.php
```

### Live SCSS edits

Edit `moodle/theme/iilmp/scss/pre.scss` (variables, fonts) or `scss/post.scss`
(component rules), then:

```bash
docker compose exec -u daemon moodle php admin/cli/purge_caches.php
```

If a stale compile sticks (styles.php keeps serving old CSS), hard-reset the
compiled-theme cache:

```bash
docker compose exec -u daemon moodle sh -c 'rm -rf /bitnami/moodledata/localcache/theme'
docker compose exec -u daemon moodle php admin/cli/purge_caches.php
```

### Verify

- Site admin → Appearance → Themes lists **IILMP**.
- http://localhost:8081 login + dashboard show the olive/cream palette, Spectral
  headings, Barlow body, dark-brown topbar, IILMP logo, flat fills.
- `docker compose restart moodle` → theme persists as default (config lives in the
  MariaDB volume; the theme code lives in the bind mount).
- Optional: upload a logo under Site admin → Appearance → Logos (or the theme's
  own **IILMP settings → Logo**). When unset, the bundled `pix/logo.png` is used
  on the navbar and login page automatically.

### Non-SCSS overrides (renderer, template, JS)

The theme is SCSS-first, but three small, deliberately contained overrides go
beyond CSS. None fork a Boost mustache except the one noted, so upgrades stay safe:

- **`classes/output/core_renderer.php`** — one method, `context_header()`, extends
  Boost. On the `mydashboard` layout it swaps the static "Dashboard" heading for the
  signed-in user's first name; the SCSS then frames it with the "Welcome back"
  eyebrow + supporting line. Everything else falls through to Boost. Enabled by
  `$THEME->rendererfactory = 'theme_overridden_renderer_factory'` in `config.php`.

- **Forked core templates (three)** — each is a copy of a core template and therefore
  **version-coupled**: on a Moodle upgrade, re-diff against the new core version and
  re-apply. If one drifts, its enhancement is lost (or shows stale markup) — check
  here first. Delete a file to fall back to that core template. The override path is
  `templates/<component>/<name>.mustache` (matching the core component being
  overridden, e.g. `theme_boost/…`, not the iilmp theme).
  - `progress-bar.mustache` — core ships a text-only "N% complete"; this adds the
    visual olive progress bar (`.iilmp-progress*` markup the SCSS styles).
  - `block_myoverview/view-list.mustache` — the dashboard course-overview list rows.
    Divergence from core is a single block flagged `IILMP:` — a right-aligned
    "Continue" CTA that shares the progress row. (The category chip needs no fork —
    it's SCSS on the `.categoryname` span core already renders.) A per-course
    **teacher name** was intentionally *not* added: it isn't in the block's template
    context and would require overriding the course exporter
    (`course_summary_exporter`), which a theme can't do upgrade-safely — revisit via a
    small local plugin if wanted.
  - `theme_boost/footer.mustache` — Boost's footer is a floating "?" popover, so the
    page has no visible footer. Divergence from core is one block flagged `IILMP:` at
    the top — a visible branded footer strip (brand + tagline + copyright). The
    popover `<footer id="page-footer">` below it is verbatim core. The copyright year
    is filled by `javascript/iilmp.js` (`[data-iilmp-year]`) so no date is baked into
    the cached template.

- **`javascript/iilmp.js`** (loaded via `$THEME->javascripts_footer`) — opens the
  IILMP cross-app top-nav links (the `custommenuitems`, which live on a different
  host than Moodle) in a new tab. Detected by host, not by label, so no list to keep
  in sync.

After editing any of these, bump `version.php`, then
`admin/cli/upgrade.php --non-interactive` (registers new PHP classes) and
`admin/cli/purge_caches.php` (picks up template/JS/config changes).

### Site name / navbar wordmark

The navbar wordmark is the **site shortname** (course id 1), not a theme setting —
the migration script sets both the shortname and fullname to "REP" (the login-page
heading "Log in to …" uses the fullname), overridable via `MOODLE_SITE_SHORTNAME` /
`MOODLE_SITE_FULLNAME`. To change them by hand:

```bash
docker compose exec -u daemon moodle php -r '
define("CLI_SCRIPT",true); require("/opt/bitnami/moodle/config.php");
$DB->set_field("course","shortname","REP",["id"=>1]);
$DB->set_field("course","fullname","REP",["id"=>1]);'
docker compose exec -u daemon moodle php /opt/bitnami/moodle/admin/cli/purge_caches.php
```

## Migrating a legacy Moodle into the dockerized Moodle

`scripts/migrate_moodle_into_docker.sh` (`make migrate-moodle-local`) restores a
legacy Moodle **natively** into the local docker-compose Moodle — a "move to a new
server" (DB dump + `moodledata/filedir`), NOT the retired `manage.py import_moodle`
Django importer (that fed the old home-grown REP LMS, replaced by real Moodle).

What it does:
1. Drops & recreates the `moodle-mariadb` database and loads the dump (prefix `mdl_`).
2. Streams the legacy `filedir` blob store (and the `secret/` sodium key, so encrypted
   values still decrypt) into the `moodle_moodledata` volume, then `chown`s to `daemon`.
3. Runs `admin/cli/upgrade.php` (a legacy 5.0.1 dump upgrades to our 5.0.2).
4. Re-applies `theme=iilmp` (the legacy theme `mb2nl` isn't installed here) and rewrites
   the top-nav `custommenuitems` to link to the IILMP platform modules (Repository,
   Funding, Marketplace, Alumni, MEL) — override the base with `IILMP_BASE_URL`.
5. Resets the primary site-admin password so you can log in, and prints the count of
   users/courses + the login URL.

Config comes from `.env.deploy` / `.env` (same vars as the remote importer):

```bash
# .env.deploy
MOODLE_DUMP_FILE="/abs/path/db_moodle_db_*.sql"
MOODLE_FILEDIR_ARCHIVE="/abs/path/moodledata_*.tar.gz"   # used only if no extracted dir
```

The script auto-detects an already-extracted `moodledata/` sitting next to the dump
(faster than re-extracting ~1 GB); or point `MOODLE_MOODLEDATA_DIR` at one.

Run it (⚠ **replaces** the current dockerized Moodle DB + filedir):

```bash
make migrate-moodle-local
# or, non-interactive with a chosen admin password:
YES=1 MOODLE_RESET_ADMIN_PASSWORD='S3cret!2026' bash scripts/migrate_moodle_into_docker.sh
# DB-only (skip the ~1 GB filedir copy) while iterating:
SKIP_FILES=1 bash scripts/migrate_moodle_into_docker.sh
```

Plugins in the legacy site but absent from this build (e.g. `theme_mb2nl`, `local_obf`)
show as "missing from disk" under Site admin → Plugins — harmless.
