Aug 6 - Update enrollment page

This commit is contained in:
2026-08-06 11:38:22 -04:00
parent 633a5b865f
commit 5bc7592c1a
6 changed files with 597 additions and 279 deletions
+19 -3
View File
@@ -1655,9 +1655,25 @@ app/enrollment/
If it ever needs to *create* the accounts it describes, do that as a **separate explicit admin action** that reads a stored submission. Do not let the public form reach into the models.
### Form flow (people first, then the matrix)
The printed form had six fixed seats (Admin/Director + Inspector 15) and a static RECOMMENDATION table for the customer to copy by hand. The web form reworks that:
1. **Step 1 — the people.** Free-form rows, each with a **role dropdown** (`schema.ROLES`: Admin / Director / Auditor / Inspector / External Inspector), name, job title, email. Starts with one row defaulted to `DEFAULT_FIRST_ROLE`; **"Add another person"** appends more, capped at `MAX_PEOPLE` (25). The last row cannot be removed.
2. **Step 2 — the task matrix**, with **one column per person from Step 1**, rebuilt in the browser whenever a name, role or row changes. Existing ticks survive a rebuild (preserved by field name).
3. **Step 3 — mobile app**, likewise one column per person.
A **"Recommendation selection"** button applies `schema.recommendation_map()` per person's role — admin-side roles get the Admin/Director column of the old table, inspector roles the Inspectors column — after which any box can be changed. The RECOMMENDATION table itself is **no longer rendered**; `schema.RECOMMENDATION` remains the authority behind the button. The button deliberately leaves **Step 3 alone** — who carries a tablet is not something a preset can guess.
**Field naming — the client index and the stored key are independent.** The browser names fields `person_<n>_*`, `task_<ref>_person_<n>`, `mobile_person_<n>` where `<n>` is a monotonic row counter (gaps appear when rows are removed). The server discovers which indexes were actually posted (`_PERSON_FIELD_RE`, never a client-supplied count), drops entirely blank rows, and re-keys people **by position** into `p1, p2, …` for storage. So a customer deleting a middle row cannot shift anyone's answers, and stored matrix keys are always dense.
**Admin-only tasks are enforced server-side.** `task_applies()` gates ref 10 (Search/Export Reports) to `ADMIN_ROLES`; the POST parser only reads cells the person's role offers, so a crafted POST cannot record an admin-only task against an inspector — verified.
### `schema.py` is the source of truth
`COLUMNS` (Admin/Director + Inspector 15), `TASKS` (the 10 rows; ref 10 "Search/Export Reports" is `admin_only` and renders a single cell), `REGISTRANTS` (6 seats), `RECOMMENDATION`, `OFFICE_FIELDS`, `STATUSES`. The public template renders from it, the POST parser iterates it, and the admin detail view re-renders stored answers through it — so adding a task row or a 6th inspector seat is a one-line edit with no template or parser change.
`ROLES`, `ADMIN_ROLES`, `TASKS` (10 rows; ref 10 is `admin_only`), `RECOMMENDATION`, `OFFICE_FIELDS`, `STATUSES`. The public template renders from it *and hands it to the page as JSON* (`ROLES`, `TASKS`, `ADMIN_ROLES`, `recommendation_map()`), the POST parser iterates it, and the admin views re-render stored answers through it — so adding a task row or a role is a one-line edit with no template, JS or parser change.
**Legacy submissions.** Files stored in the original fixed-seat format are never rewritten; `schema.people_of()` / `cell()` / `wants_mobile()` normalise on read, so the admin list, detail view and CSV render both shapes identically — verified against a hand-written legacy file.
### Storage
@@ -1670,8 +1686,8 @@ One JSON document per submission in `ENROLLMENT_DIR`, named `<YYYYmmdd-HHMMSS>-<
### Public page hardening (same posture as rule 74)
Login-free, so: CSRF-protected form, `@limiter.limit('5 per hour')` on POST only, honeypot field (`website`, CSS-hidden — a bot that fills it gets a 200 and no file), submit button disabled on first click, `noindex` meta, and a standalone template with no authenticated nav. Validation requires a project name, a requester, and at least one registrant with **both** a name and an email (a half-filled row cannot be set up, so it must not pass as one); on failure it re-renders with the customer's input intact and returns 400.
Login-free, so: CSRF-protected form, `@limiter.limit('5 per hour')` on POST only, honeypot field (`website`, CSS-hidden — a bot that fills it gets a 200 and no file), submit button disabled on first click, `noindex` meta, and a standalone template with no authenticated nav. Validation requires a project name, a requester, at least one person with **both** a name and an email (a half-filled row cannot be set up, so it must not pass as one), and no duplicate email addresses; on failure it re-renders with the customer's input intact — including their ticked boxes, folded per-person into the `seed_people` payload — and returns 400.
### Admin
`/enrollment/admin` (admin-only, linked from the **Admin** nav dropdown in both layouts). List → detail → office-use fields (Receive Date / Program By / Date email invitation) + status (new / in_progress / completed). `GET /admin/<id>.json` downloads the raw file; `GET /admin/export.csv` emits **one row per registrant, not per submission** — that is the unit of work when actually creating the accounts.
`/enrollment/admin` (admin-only, linked from the **Admin** nav dropdown in both layouts). List → detail → office-use fields (Receive Date / Program By / Date email invitation) + status (new / in_progress / completed). `GET /admin/<id>.json` downloads the raw file; `GET /admin/export.csv` emits **one row per person, not per submission** — that is the unit of work when actually creating the accounts. Task cells a person's role cannot have export as `n/a`, distinct from an unticked `''`.