# REP / Access Control — Test-Issue Investigation & Phased Mitigation Plan

**Source:** `REP TEST ISSUES.docx` (tester report, 6 issues — profile, REP course authoring,
access control, Repository)
**Scope:** `apps/core/authentication` (profile/avatar), `apps/rep/courses` (course wizard),
`apps/core` (access control / roles / users), `apps/repository/documents` (submission wizard)
**Authored:** 2026-06-30
**Tested against:** local Docker stack (`docker-compose.yml`), current `main` @ this commit,
via `agent-browser` browser automation per the QA flow in `CLAUDE.md`. The tester's screenshots
are dated **24/06/2026** against a different host (`173.249.17.166`) — six days before this
investigation and before today's repository remediation (`204139b`, see
`setup/repository_test_issues_mitigation.md`) landed on `main`.

---

## 1. How to read this document

Every reported issue was reproduced (or not) against a fresh local stack with seeded demo
accounts. Verdict legend (same convention as `repository_test_issues_mitigation.md`):

| Verdict | Meaning |
| --- | --- |
| **CONFIRMED** | The defect is real and reproducible from the code. Needs a build. |
| **UX-GAP** | The backend/data is correct, but the feature is incomplete or undiscoverable as shipped. Needs a front-end/flow change. |
| **NOT REPRODUCED** | The code already implements this correctly. The tester's environment was stale or misconfigured — verify there and add a regression test, do not rebuild. |

---

## 2. Issue register (verdict + root cause)

| # | Issue (tester's words) | Verdict | Root cause (file:line) |
| --- | --- | --- | --- |
| 1 | Profile photo upload didn't show up in the navbar icon | **NOT REPRODUCED locally; root cause still unknown** (see Phase 0 below) | Reproduced end-to-end locally: `ProfileForm` (`apps/core/authentication/forms.py:247-289`) saves the file via `profile_view` (`apps/core/authentication/views.py:336-392`), `CustomUser.avatar_url` (`apps/core/authentication/models.py:132-141`) resolves it, and `components/user_avatar.html:3-14` renders `<img src="{{ user.avatar_url }}">` — confirmed loading (HTTP 200, `naturalWidth=300`) in the navbar immediately after save. **Phase 0 update:** the "stale nginx media-serving gap" theory originally written here does **not** hold on `173.249.17.166` — directly curled an existing avatar through that host's nginx on the port the tester used (port 80) and got `200 OK`; no avatar/profile-related commit exists anywhere between that host's last image build and current `main`. The tester's broken-image icon remains unexplained by this investigation; treat as still-open, not stale-environment. |
| 2 | Instructor: after saving a new course's "Basics" step, the page reloads back at step 1 instead of advancing to Modules & Lessons → Publish; no save confirmation | **CONFIRMED — P0, blocks all course creation — FIXED, see Phase 1 below (2026-07-01)** | `CourseForm.Meta.fields` (`apps/rep/courses/forms.py:16-40`) includes `completion_rule` and `completion_min_quiz_average` as **required** model fields (`apps/rep/courses/models.py:293-301`, both `required=True` per `CourseForm().fields[...].required`), but `course_create.html`'s "Completion & certification" step (lines 369-396) only renders `pass_mark` and `certificate_template` — `completion_rule`/`completion_min_quiz_average` have **no markup anywhere in the wizard**. Every "Save draft" POST therefore omits them, Django's `ModelForm.is_valid()` fails with "This field is required" on both, and `CreateView.form_invalid()` re-renders `course_create.html` with the bound (failed) form. Because the Alpine wizard's `x-data` hard-codes `step: 1` (`course_create.html:145-158`) on every fresh page load and there is no JS to jump to the first error, the user always lands back at step 1 — and since the two failing fields aren't rendered at all, **no error is visible anywhere on the page**. Reproduced live: identical POST with the two fields injected via JS succeeds and redirects to `manage_course_edit`; without them, it 100%-of-the-time silently re-renders step 1. Separately, even a *successful* save shows no confirmation: `CourseCreateView.form_valid` / `CourseUpdateView.form_valid` (`apps/rep/courses/views.py:571-616`) never call `messages.success(...)`, unlike every other course action in the same file (publish, enrol, certificate — lines 655-1180 all message). |
| 3 | Custom role "Facilitator" created via Access control → stays in the Custom roles list | **Expected behaviour, not a defect** | `role_create_view` (`apps/core/views_access.py:157-194`) creates a plain Django `Group`; `role_list_view` (lines 133-154) correctly buckets it under "Custom roles" (excluded from the enum-mapped group set). Confirmed locally: creating "Facilitator" produces exactly this list placement. This is item #4's root cause, not a bug in its own right. |
| 4 | Facilitator role not available when creating a new user | **CONFIRMED — UX-GAP — FIXED, see Phase 2 below (2026-07-01)** | Two parallel role systems exist and are not connected. (a) `CustomUser.role` is a `CharField(choices=UserRole.choices)` (`apps/core/authentication/models.py:62-67`) drawing from a **33-value hard-coded enum** (`apps/core/permissions/roles.py:4-41`); `UserCreateForm` is a `ModelForm` over this field (`apps/core/forms_user_access.py:14-43`), so the "New user" role `<select>` can only ever offer the 33 enum values. (b) Custom roles created via `/access/roles/new/` are bare `Group` rows with **no link to the enum** and no in-app UI to attach a user to them — `role_create_view`'s own docstring admits it (`apps/core/views_access.py:159-163`: *"Custom roles aren't selectable in the user.role enum field... they can still hold permissions and be attached to users via Django admin or future group-membership flows"*), and `role_create.html` repeats the caveat to the admin at creation time. Net effect: a non-technical admin can create a role that looks identical to the seeded ones in the Role permissions table, assign it 43 permissions (as the tester did), and then discover — only when creating a user — that it's a dead end without Django admin access. Confirmed locally: "Facilitator" never appears in `/access/users/new/`'s role dropdown. |
| 5 | Learner can't upload a document via Repository in the navbar; no submission confirmation | **NOT REPRODUCED** | Full wizard walked end-to-end as the seeded `learner`-role account (`student.demo@iilmp.local`): File → Collection → Details → Tags → Review → **"Submission received: REPO-2026-127698."** success message + redirect to the new document's detail page, which shows the live `messages.success` banner from `apps/repository/documents/views.py`. The Repository "Submit document" nav entry is unconditional for any authenticated user (`templates/components/topnav.html:1335-1349`) — no role gate to misconfigure. This matches `repository_test_issues_mitigation.md` item #24 (already "NOT REPRODUCED" before today) and was very likely additionally affected by whatever blocked item #6 (below) on the tester's host. |
| 6 | Language field shows "No results found" with no options, in the Repository submission metadata step | **NOT REPRODUCED locally; CONFIRMED stale-deploy on `173.249.17.166`** | `language` is a `forms.Select(choices=LANGUAGE_CHOICES, ...)` with 8 hard-coded options (English/French/Portuguese/Spanish/Swahili/Arabic/Amharic/Other — `apps/repository/documents/forms.py`), `data-s2="1"` initializes a plain (non-Ajax) Select2 over those static `<option>`s. Confirmed locally: all 8 options render and are selectable. The tester's screenshot shows a *different* wizard layout (5 steps: File/Metadata/Authors/Collection/Tags) than what ships today (File/Collection/Details/Tags/Review) — the submission UI was restructured by today's repository remediation (commit `204139b`). **Phase 0 update:** confirmed directly on the tester's host — its running container has no `LANGUAGE_CHOICES` (added in `6c593c0`, today 13:07) and the old `stepLabels` wizard, proving this was a real, already-fixed bug the tester's host simply hadn't received yet. Same root cause as #5. |

**Summary:** of 6 reported items — **1 CONFIRMED (P0, fixed in Phase 1 below)**, **1 CONFIRMED UX-GAP**, **1 expected-behaviour
(non-issue)**, **2 CONFIRMED stale-deploy** (#5, #6 — directly verified against the tester's actual
host in Phase 0, root cause fixed on `main` but not yet redeployed there), **1 still unresolved**
(#1 — Phase 0 ruled out the stale-environment theory; needs a fresh repro, not a rebuild).

---

## 3. Phased mitigation plan

### Phase 0 — Verify the stale-environment theory — **DONE, 2026-06-30**

*Goal: rule out a code regression before spending Phase 1+ effort, and stop the report from
recurring for the same reasons.*

**0.1/0.1b findings (SSH access confirmed via `~/.ssh/iilmp_deploy`, `root@173.249.17.166`,
hostname `vmi3305818` — a Contabo VPS, consistent with `project_ec2_migration_2026_06_13`):**

- **Confirmed by the user (2026-06-30): `173.249.17.166` is where the team tests from.** It's
  not the abandoned pre-migration relic the 2026-06-13 EC2-migration memory implied was pending
  retirement — that memory has been corrected (`project_ec2_migration_2026_06_13`). This resolves
  the disposition question to **(b)**: a long-lived box distinct from EC2 prod, meant to stay
  running, and actively used as the team's real test environment (which is why the tester landed
  there in the first place — not a misdirect).
- **It serves two things on two ports**, both from the same checkout
  (`/var/www/ruforum-iilmp`, `docker-compose.prod.yml`):
  - **Port 80** (`web` service) — the main test environment, and what the tester hit
    (`http://173.249.17.166`, no port). Its DB has **16,778 users** (the same count as the EC2
    migration — likely a realistic data copy carried over for testing). Its Docker image (`docker
    images` → `ruforum-iilmp-web`) was last **built 2026-06-18**, i.e. it has not been rebuilt in
    12 days even though the git tree has moved forward (manual `git pull origin main`, reflog:
    ~15 sequential fast-forwards, most recent today 2026-06-30 04:41:28 → commit `52205b8`) —
    code changes from `git pull` never reached the running container because no `docker build`
    followed. This drift (pull without rebuild, and not going through `scripts/deploy.sh`, which
    only targets the EC2 host) is the actual mechanism behind #5/#6 below, and is exactly what
    will keep producing false "bug" reports against code that's already fixed on `main`.
  - **Port 8001** (`web_demo` service) — a second, separate environment; `docker/nginx.conf`
    comments it as *"Demo environment — seeded fake data, safe for testers."* Its DB has 323
    seeded `*.demo.local` accounts. Its image was rebuilt more recently (today, 03:43) but still
    trails `main`. (Not established whether this is meant for the same testers or a different
    audience — noted as a secondary finding below, not a correction to where testing happens.)
- **Items #5 and #6 are CONFIRMED stale-deploy artifacts — direct proof, not inference.**
  Grepped the *running* container's files (`docker exec ruforum-iilmp-web_demo-1 ...`), not just
  the git tree:
  - `document_submit.html` still has `stepLabels: ['File', 'Metadata', 'Authors', 'Collection',
    'Tags']` — the exact 5-step layout from the tester's screenshot, not today's restructured
    File/Collection/Details/Tags/Review wizard.
  - `forms.py` has **no `LANGUAGE_CHOICES`** — the static-choices fix (current `main`'s comment:
    *"...so it isn't an empty dropdown"*) landed in commit `6c593c0`, authored **today at
    13:07**, which is 13 commits ahead of what's on this box and was never pulled/rebuilt. This
    confirms #6 was a **real, already-fixed bug**, not tester error — the old box just predates
    the fix by hours.
  - Both findings hold for the `52205b8` checkout generally, so #5 (wizard submission failing)
    is the same root cause as #6, consistent with the doc's existing reasoning.
- **Item #1 (avatar) is NOT explained by a stale-environment / nginx-media theory on this host —
  this revises the doc's current root-cause text.** Directly tested: queried `core_userprofile`
  on the port-80 (real-data) DB for an existing avatar (`core/avatars/2026/06/moodle_user_2.jpg`)
  and curled it through nginx → **`200 OK`, served correctly**. `git log` shows **zero**
  avatar/profile-related commits anywhere between the box's last image build (2026-06-18) and
  current `main` HEAD. So neither the nginx `/uploads/` config nor any app-code fix accounts for
  a broken avatar on this host — the tester's broken-image screenshot cannot be reproduced or
  explained by this investigation. **#1 stays genuinely unresolved**, not "stale-deploy,
  expect-to-clear" as previously written; it needs a fresh repro attempt (ideally with the
  tester present) rather than being assumed fixed.
- **Did not reset any real user's password to behaviorally re-test port 80** — that DB holds real
  RUFORUM user accounts/PII, and resetting credentials there is exactly the kind of action this
  phase's constraints say to avoid without sign-off. Instead corroborated #5/#6 directly against
  the **safe, seeded** demo account (`scholar.demo@iilmp.local`, port 8001) — code/template
  inspection above already gives stronger proof than a click-through would.
- **Bonus finding (not asked for, but blocking the "use the demo box" remediation):** Port 8001
  itself is currently **broken for any POST**, including login. `.env`'s
  `CSRF_TRUSTED_ORIGINS`/`CORS_ALLOWED_ORIGINS` list only `http://173.249.17.166` (no `:8001`),
  so submitting the login form against the demo port throws a CSRF 403. Confirmed live
  (`scholar.demo@iilmp.local` login attempt on `:8001` → `403 CSRF verification failed`). The
  one environment explicitly designated "safe for testers" can't currently process a login.

**0.1c disposition: (b) — confirmed by the user. `173.249.17.166` is where the team tests from,
and is meant to stay running.** Its actual problem is deploy hygiene: no rebuild step after
`git pull` (port 80's image is 12 days stale — this is the direct mechanism behind #5/#6), and
it's outside `scripts/deploy.sh`'s reach entirely. Separately, port 8001 (a second, seeded-data
environment on the same box) currently can't process logins at all (CSRF config gap, below) —
noted in case it's meant to be used for testing too, not as a suggestion to switch away from
port 80.

**Recommendation (infra changes below need owner/operator sign-off before being made — not done
by this phase per its constraints; the instance itself should NOT be stopped, per the
correction above):**
1. Add this box as a **named target** in `scripts/deploy.sh` (`git pull` + `docker build` +
   restart) so it stops silently drifting behind `main` — this is the fix that actually prevents
   #5/#6-style reports from recurring against code that's already been fixed.
2. If port 8001 (`web_demo`) is meant to be usable, fix `.env`'s
   `CSRF_TRUSTED_ORIGINS`/`CORS_ALLOWED_ORIGINS` to include the `:8001` origin — it currently
   can't process any login/POST.
3. Once redeployed, re-test #5/#6 — expect both to clear, matching local/HEAD results. #1 stays
   open and needs a fresh repro with the tester; don't close it as "stale environment."

### Phase 1 — Course wizard: stop the silent 100%-fail — **DONE, 2026-07-01** — P0

*Goal: an instructor can save a draft course at all, and gets feedback either way.*

1.1 **Render the missing required fields** (#2) — **done.** Added `completion_rule`,
    `completion_min_quiz_average`, and `completion_require_assignment_submission` to the
    "Completion & certification" section of both `course_create.html` and `course_edit.html`,
    next to `pass_mark` / `certificate_template` (no form/view schema change — `CourseForm.Meta.fields`
    already declared these). `completion_require_assignment_submission` renders as a checkbox
    styled to match the codebase's existing `_GRANT_CALL_CHECKBOX_ATTRS`-style convention.
    `requires_publish_review`, `use_waitlist`, and `rims_grant_call` were left **hidden** (already
    safe: Django forces `BooleanField.required=False`, and the FK is `null=True, blank=True`), but
    `CourseForm.__init__` (`apps/rep/courses/forms.py`) now sets `required=False` on all three
    explicitly, as a guardrail so a future model change can't silently reintroduce this bug.
1.2 **Make wizard failures visible** (#2) — **done.** `CourseWizardErrorStepMixin`
    (`apps/rep/courses/views.py`, applied to both `CourseCreateView` and `CourseUpdateView`) maps
    `form.errors` to a wizard step via `COURSE_WIZARD_STEP_FIELDS` and puts `error_step` in the
    template context; both templates emit `data-error-step="{{ error_step }}"` on the wizard's
    `x-data` root and read it in `x-init` to open on the correct step instead of always resetting
    to step 1. Verified live: stripping a required field and submitting now opens the wizard
    directly on "Completion" with the field error visible, instead of silently landing on step 1.
    *(Note for a future pass: `static/js/wizard-form.js` already ships a generic, unused-by-this-page
    `wizardForm()` Alpine component whose `init()` does this same job by scanning rendered
    `.field-error` elements client-side — no server `error_step` context needed. It'd be a cleaner
    long-term fit than this bespoke mixin, but adopting it means re-plumbing both templates' custom
    stepper/tabs markup onto `[data-step-pane]` + `goNext()/goPrev()/goTo()`, which is out of scope
    for this P0 pass.)*
1.3 **Add success messaging** (#2, second half of the complaint) — **done.** Added
    `messages.success(self.request, f'"{form.instance.title}" saved as a draft.')` to
    `CourseCreateView.form_valid` and `messages.success(self.request, f'"{self.object.title}"
    settings saved.')` to `CourseUpdateView.form_valid` (`apps/rep/courses/views.py`), matching
    every other course action in the file. Verified live in-browser: banner renders correctly on
    the post-redirect `manage_course_edit` page.
1.4 **Regression test** — **done.** `apps/rep/courses/tests/test_course_wizard_regression.py`:
    one test asserts every required-completion field is actually rendered by the GET page *and*
    that posting the full rendered payload succeeds with a 302 to `manage_course_edit`; a second
    asserts an incomplete payload re-renders with `error_step == 5` and `data-error-step="5"` in
    the response. Sanity-checked per this section's own instructions: temporarily reverted the
    `course_create.html` template change (`git stash`) and re-ran the suite — both tests failed
    (the field-presence assertion caught it first) — then restored the fix and confirmed green.
    Full `apps/rep/courses/` suite run: 86 passed, 2 pre-existing failures unrelated to this change
    (`test_certificate_email.py::test_email_channel_attaches_certificate_pdf_when_file_present`,
    `test_rims_auto_enrol_webhook.py::test_webhook_audit_row_records_rims_auto_source` — confirmed
    failing identically against unmodified `main`).

### Phase 2 — Custom-role usability — **DONE, 2026-07-01** — UX-GAP, not urgent

*Goal: a role created in Access control is actually usable, or the UI stops implying it is.*

2.1 **Set expectations at creation time — done.** `role_create.html`'s caveat was already above
    the form (`components/rims_page_intro.html` include, before the `<form>`) from prior work —
    verified, not re-touched. Added the second, still-outstanding half: a `.callout--warn-inline`
    note ("Not selectable in a user's Role field — assign these via a user's 'Additional roles'
    field instead.") on the Role permissions list page (`templates/core/access/role_list.html`),
    inside the "Custom roles" card, above the table — same compact pill component already used
    elsewhere for single-line notices (`qa_queue.html`'s overdue count, `document_detail.html`'s
    restricted-download note), so no new CSS.
2.2 **Real fix: wired custom Group membership into the user forms — done.** Added an
    `additional_roles` `ModelMultipleChoiceField` to both `UserCreateForm` and `UserManageForm`
    (`apps/core/forms_user_access.py`), queryset `Group.objects.exclude(pk__in=iilmp_role_groups())`
    (no new "what counts as custom" logic — reuses the existing helper). `UserManageForm.__init__`
    seeds its initial value from `instance.groups.exclude(pk__in=iilmp_role_groups())` since it
    isn't a real model field and Django can't infer it from the instance automatically.
    `user_create_view` adds the selected groups after `form.save()`; `user_edit_view` removes all
    of the user's current non-enum groups and re-adds the submitted set (`apps/core/views_access.py`)
    — enum-mapped groups are never touched by this code path. Both templates render the new
    multi-select (select2, matching the existing `user_permissions` widget) directly under Role,
    with a hint on each field distinguishing "Role" (drives most permission logic) from
    "Additional roles" (custom Groups, layered on top). `user_create.html` gained its own
    `select2_multiselect_head.html`/`select2_multiselect_init_js.html` includes (it had none
    before); `user_edit.html` already had the head include for `user_permissions` and now inits
    both selects.
    - **Correction to this doc's earlier framing:** the original task brief flagged
      `user_edit.html`'s "synced automatically on save" copy as misleading, since neither view
      calls `sync_user_role_group` directly. That's true in isolation, but `apps/core/signals.py`
      registers a `post_save` receiver on `CustomUser` that calls `sync_user_role_group` on
      *every* save — confirmed live (`docker compose exec web python manage.py shell`: creating a
      user with `role='reviewer'` immediately shows `groups=['reviewer']` with no manual sync
      call). So the copy is accurate today and was left unchanged; no `sync_user_role_group` call
      was added to either view (would have been redundant with the signal).
2.3 **Regression test — done.** New file
    `apps/core/permissions/tests/test_custom_role_membership.py`: one test posts to
    `user_manage_create` with a custom group selected in `additional_roles` and asserts the new
    user gains the group's permissions via `user_effective_perm_codes` (and that the group named
    after the enum role is *also* present, proving the two lanes don't conflict); a second posts
    to `user_manage_edit` toggling `additional_roles` from one custom group to another and
    asserts the removed group's permission is gone, the added one is present, and the
    enum-synced role group is untouched.
    **Test run (Docker, per `CLAUDE.md`):** `apps/core/permissions/ apps/core/authentication/
    apps/core/tests/` (the directories that actually import `views_access` per
    `grep -rl views_access.*apps/core --include=*.py | grep test`) — 180 passed, 3 pre-existing
    failures unrelated to this change (`test_mfa_policy.py` dashboard-redirect assertion,
    `test_multilingual.py` missing `locale/fr/LC_MESSAGES` dir, `test_pwa_shell.py` manifest
    `theme_color` mismatch — none touch forms/views/templates changed here, confirmed via
    `git diff --stat HEAD` showing zero diff against those three test files). Full `apps/core/`
    run: 283 passed, the same 4 pre-existing failures (plus one more of the same kind,
    `test_template_resolution.py`'s seeded-notification-template check, also untouched by this
    change) — no new regressions.
    **Browser QA (`agent-browser` against local `docker-compose.yml`, per `CLAUDE.md`):** as
    `admin@ruforum.org` (superuser) — created custom role "Facilitator" at `/access/roles/new/`,
    assigned it 3 permissions at `/access/roles/<id>/permissions/`; confirmed the new callout
    renders in the "Custom roles" table at `/access/roles/`; confirmed "Facilitator" is absent
    from the Role `<select>` but present in "Additional roles" at `/access/users/new/`; created a
    user with it selected and confirmed via `manage.py shell` that `user_effective_perm_codes`
    included all 3 granted permissions; edited the same user, cleared "Additional roles", saved,
    and confirmed the group membership was removed; re-added it alongside setting `role=reviewer`
    and confirmed both the enum `reviewer` group and the custom `Facilitator` group coexist after
    save with no interference. Cleaned up the test user/group afterward.

---

## 4. Out of scope / no action needed

- Items #5, #6 — **Phase 0 confirmed stale-deploy**, no code changes needed; will clear once
  `173.249.17.166` (or wherever it's next reported from) is redeployed past `6c593c0`/`204139b`.
- Item #1 — **not resolved by Phase 0.** The stale-environment theory was tested and ruled out
  (avatar media serves fine on the tester's host; no relevant code changed in the gap). This is
  not "out of scope" so much as "no lead yet" — needs a fresh repro with the tester before any
  further app-code work is justified.
- Item #3 — not a defect; custom roles are correctly listed separately from seeded roles.
