From 5bc7592c1a1d17382c854d9627d89efe4efe9d43 Mon Sep 17 00:00:00 2001 From: NguyenND Date: Thu, 6 Aug 2026 11:38:22 -0400 Subject: [PATCH] Aug 6 - Update enrollment page --- CLAUDE.md | 22 +- app/enrollment/routes.py | 198 +++++--- app/enrollment/schema.py | 167 +++++-- .../templates/enrollment/admin_detail.html | 56 +-- .../templates/enrollment/admin_list.html | 3 +- app/enrollment/templates/enrollment/form.html | 430 ++++++++++++------ 6 files changed, 597 insertions(+), 279 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8fe7a51..dad913e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 1–5) 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__*`, `task__person_`, `mobile_person_` where `` 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 1–5), `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 `-< ### 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/.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/.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 `''`. diff --git a/app/enrollment/routes.py b/app/enrollment/routes.py index dd633da..5af49d3 100644 --- a/app/enrollment/routes.py +++ b/app/enrollment/routes.py @@ -26,6 +26,7 @@ its own standalone template with no authenticated nav. import csv import io import logging +import re from datetime import datetime from flask import (Blueprint, render_template, request, redirect, url_for, @@ -59,13 +60,78 @@ def _clean(value, limit=_MAX_TEXT): return (value or '').strip()[:limit] +#: Person rows are named person__. The client controls (rows can +#: be added and removed in any order), so the server discovers the indexes that +#: were actually posted rather than trusting a count field. +_PERSON_FIELD_RE = re.compile(r'^person_(\d+)_role$') + + +def _parse_people(form): + """Return the submitted people as an ordered list of dicts. + + Each entry gets a stable `key` (p1, p2, …) assigned by POSITION, not by the + client's index — so the matrix keys in a stored submission are always dense + and predictable no matter which rows the customer deleted before sending. + """ + indexes = sorted( + int(m.group(1)) + for m in (_PERSON_FIELD_RE.match(k) for k in form.keys()) if m + ) + + people = [] + for idx in indexes: + role = form.get(f'person_{idx}_role', '') + if role not in schema.ROLE_KEYS: + role = schema.DEFAULT_FIRST_ROLE + name = _clean(form.get(f'person_{idx}_name')) + job_title = _clean(form.get(f'person_{idx}_job_title')) + email = _clean(form.get(f'person_{idx}_email')) + # Drop rows the customer added but left completely blank. + if not (name or job_title or email): + continue + people.append({ + 'key': f'p{len(people) + 1}', + 'form_index': idx, # so the matrix cells can be read back + 'role': role, + 'name': name, + 'job_title': job_title, + 'email': email, + }) + if len(people) >= schema.MAX_PEOPLE: + logger.warning('ENROLLMENT | people capped at %d', schema.MAX_PEOPLE) + break + return people + + +def _seed_people(people, matrix, mobile_app): + """Shape the submitted people for the page to re-render after an error. + + Folds each person's ticked tasks into their own row, so the browser can + rebuild the table from scratch with fresh row indexes and still restore + every answer. + """ + seed = [] + for person in people: + seed.append({ + 'role': person['role'], + 'name': person['name'], + 'job_title': person['job_title'], + 'email': person['email'], + 'tasks': [ref for ref, _l, _s in schema.TASKS + if matrix.get(str(ref), {}).get(person['key'])], + 'mobile': bool(mobile_app.get(person['key'])), + }) + return seed + + # ── Public form ────────────────────────────────────────────────────────────── @bp.route('', methods=['GET']) @bp.route('/', methods=['GET']) def form(): """Render the blank enrollment form. No login — the link is emailed out.""" - return render_template('enrollment/form.html', schema=schema) + return render_template('enrollment/form.html', schema=schema, + seed_people=[]) @bp.route('', methods=['POST']) @@ -82,82 +148,80 @@ def submit(): project_name = _clean(request.form.get('project_name')) request_by = _clean(request.form.get('request_by')) - # ── Step 1 matrix ──────────────────────────────────────────────────── + people = _parse_people(request.form) + + # ── Step 2 matrix + Step 3 mobile, keyed by person ─────────────────── + # Only cells the person's role actually offers are read, so a crafted POST + # cannot record an admin-only task against an inspector. matrix = {} for ref, _label, scope in schema.TASKS: row = {} - for col_key, _col_label in schema.columns_for(scope): - row[col_key] = bool(request.form.get(f'task_{ref}_{col_key}')) + for person in people: + if schema.task_applies(scope, person['role']): + row[person['key']] = bool( + request.form.get(f'task_{ref}_person_{person["form_index"]}')) matrix[str(ref)] = row - # ── Step 2 registrants ─────────────────────────────────────────────── - registrants = [] - for key, label in schema.REGISTRANTS: - entry = { - 'key': key, - 'label': label, - 'include': bool(request.form.get(f'reg_{key}_include')), - 'name': _clean(request.form.get(f'reg_{key}_name')), - 'job_title': _clean(request.form.get(f'reg_{key}_job_title')), - 'email': _clean(request.form.get(f'reg_{key}_email')), - } - registrants.append(entry) - - # ── Step 3 mobile app ──────────────────────────────────────────────── mobile_app = { - col_key: bool(request.form.get(f'mobile_{col_key}')) - for col_key, _ in schema.COLUMNS + p['key']: bool(request.form.get(f'mobile_person_{p["form_index"]}')) + for p in people } # ── Validation ─────────────────────────────────────────────────────── - # A row counts as a real person only when it has BOTH a name and an email — - # a half-filled row cannot be set up, so it must not pass as one. - named = [r for r in registrants if r['name'] and r['email']] + # A person counts only with BOTH a name and an email — a half-filled row + # cannot be set up, so it must not pass as one. + named = [p for p in people if p['name'] and p['email']] errors = [] if not project_name: errors.append('Project Name is required.') if not request_by: errors.append('Request by is required.') if not named: - errors.append('Please provide at least one user with both a name and ' - 'an email address in Step 2.') - for r in registrants: - if r['email'] and '@' not in r['email']: - errors.append(f'"{r["label"]}" has an email address that does not ' - f'look valid.') + errors.append('Please add at least one person with both a name and an ' + 'email address.') + for p in people: + if p['email'] and '@' not in p['email']: + errors.append(f'"{p["name"] or p["key"]}" has an email address that ' + f'does not look valid.') + seen = set() + for p in named: + low = p['email'].lower() + if low in seen: + errors.append(f'{p["email"]} is listed more than once — each person ' + f'needs their own email address.') + seen.add(low) + + prior = { + 'project_name': project_name, + 'request_by': request_by, + 'date_requested': _clean(request.form.get('date_requested')), + 'notes': _clean(request.form.get('notes'), _MAX_NOTES), + 'people': people, + 'matrix': matrix, + 'mobile_app': mobile_app, + } if errors: for e in errors: flash(e, 'danger') # Re-render with what they typed so nothing is retyped. return render_template( - 'enrollment/form.html', schema=schema, - submitted={'project_name': project_name, 'request_by': request_by, - 'date_requested': _clean(request.form.get('date_requested')), - 'notes': _clean(request.form.get('notes'), _MAX_NOTES), - 'matrix': matrix, 'registrants': registrants, - 'mobile_app': mobile_app}, - ), 400 + 'enrollment/form.html', schema=schema, submitted=prior, + seed_people=_seed_people(people, matrix, mobile_app)), 400 now = datetime.now() - record = { - 'id': storage.new_id(now), - 'submitted_at': now.isoformat(timespec='seconds'), - 'project_name': project_name, - 'request_by': request_by, - 'date_requested': _clean(request.form.get('date_requested')), - 'notes': _clean(request.form.get('notes'), _MAX_NOTES), - 'matrix': matrix, - 'registrants': registrants, - 'mobile_app': mobile_app, + record = dict(prior) + record.update({ + 'id': storage.new_id(now), + 'submitted_at': now.isoformat(timespec='seconds'), # Filled in later by staff on the admin page. - 'office': {k: '' for k, _ in schema.OFFICE_FIELDS}, - 'status': 'new', + 'office': {k: '' for k, _ in schema.OFFICE_FIELDS}, + 'status': 'new', 'meta': { 'ip': request.remote_addr, 'user_agent': (request.headers.get('User-Agent') or '')[:300], }, - } + }) try: storage.save(record) @@ -165,9 +229,11 @@ def submit(): logger.exception('ENROLLMENT | save failed | project=%r', project_name) flash('Sorry — we could not save your form. Please try again, or ' 'email us directly.', 'danger') - return render_template('enrollment/form.html', schema=schema), 500 + return render_template( + 'enrollment/form.html', schema=schema, submitted=prior, + seed_people=_seed_people(people, matrix, mobile_app)), 500 - logger.info('ENROLLMENT | submitted | id=%s project=%r users=%d ip=%s', + logger.info('ENROLLMENT | submitted | id=%s project=%r people=%d ip=%s', record['id'], project_name, len(named), request.remote_addr) return render_template('enrollment/submitted.html', reference=record['id']) @@ -229,22 +295,20 @@ def admin_download(submission_id): @login_required @admin_required def admin_export_csv(): - """One row per REGISTRANT (not per submission) — that is the unit of work - when actually setting the accounts up.""" + """One row per PERSON (not per submission) — that is the unit of work when + actually setting the accounts up. Reads through schema.people_of(), so + submissions stored in the older fixed-seat format export identically.""" records = storage.load_all() buf = io.StringIO() w = csv.writer(buf) task_headers = [f'{ref}. {label}' for ref, label, _ in schema.TASKS] w.writerow(['Submission ID', 'Submitted At', 'Status', 'Project Name', - 'Requested By', 'Date Requested', 'Seat', 'Name', 'Job Title', + 'Requested By', 'Date Requested', 'Role', 'Name', 'Job Title', 'Email', 'Mobile App'] + task_headers) for rec in records: - for reg in rec.get('registrants', []): - if not (reg.get('name') or reg.get('email')): - continue - key = reg.get('key') + for person in schema.people_of(rec): row = [ rec.get('id', ''), rec.get('submitted_at', ''), @@ -252,15 +316,17 @@ def admin_export_csv(): rec.get('project_name', ''), rec.get('request_by', ''), rec.get('date_requested', ''), - reg.get('label', ''), - reg.get('name', ''), - reg.get('job_title', ''), - reg.get('email', ''), - 'Yes' if rec.get('mobile_app', {}).get(key) else '', + person['role_label'], + person['name'], + person['job_title'], + person['email'], + 'Yes' if schema.wants_mobile(rec, person['key']) else '', ] - for ref, _label, _scope in schema.TASKS: - cell = rec.get('matrix', {}).get(str(ref), {}).get(key) - row.append('Yes' if cell else '') + for ref, _label, scope in schema.TASKS: + if not schema.task_applies(scope, person['role']): + row.append('n/a') + else: + row.append('Yes' if schema.cell(rec, ref, person['key']) else '') w.writerow(row) logger.info('ENROLLMENT | csv export | submissions=%d', len(records)) diff --git a/app/enrollment/schema.py b/app/enrollment/schema.py index 54a4e3e..b54dca5 100644 --- a/app/enrollment/schema.py +++ b/app/enrollment/schema.py @@ -5,32 +5,50 @@ The JQC Enrollment Form, expressed as data. This is the SINGLE source of truth for the form's shape. The public template renders from it, the POST handler parses against it, and the admin detail view -re-renders a stored submission through it. Changing a task label or adding an -inspector column is a one-line edit here — no template or parser change. +re-renders a stored submission through it. Changing a task label or adding a +role is a one-line edit here — no template or parser change. Deliberately free of any app model / DB import: the enrollment form describes what a prospective customer *wants set up*, not anything that exists in the -system yet. Keep it that way (see app/enrollment/__init__.py). +system yet. Keep it that way (see app/enrollment/__init__.py). The ROLES below +happen to mirror the app's user roles, but they are a COPY on purpose — the +public form must not import the User model. """ -# ── Step 1 columns ─────────────────────────────────────────────────────────── -# key -> display label. 'admin' is the Admin/Director column; the rest are the -# five inspector seats on the printed form. -COLUMNS = [ - ('admin', 'Admin /\nDirector'), - ('inspector_1', 'User /\nInspector 1'), - ('inspector_2', 'User /\nInspector 2'), - ('inspector_3', 'User /\nInspector 3'), - ('inspector_4', 'User /\nInspector 4'), - ('inspector_5', 'User /\nInspector 5'), +# ── Roles a person can be enrolled as ──────────────────────────────────────── +# key -> label, shown in the Step 1 role dropdown. +ROLES = [ + ('admin', 'Admin'), + ('director', 'Director'), + ('auditor', 'Auditor'), + ('inspector', 'Inspector'), + ('external_inspector', 'External Inspector'), ] -INSPECTOR_COLUMNS = [c for c in COLUMNS if c[0] != 'admin'] +ROLE_LABELS = dict(ROLES) +ROLE_KEYS = [k for k, _ in ROLES] + +#: Roles that act on the administrative side of the printed form (the +#: "Admin / Director" column). Everything else is an inspector seat. Drives +#: both the recommendation preset and eligibility for admin-only tasks. +ADMIN_ROLES = {'admin', 'director', 'auditor'} + +#: Role pre-selected for the first row — the form starts with one +#: administrative contact, as on the printed sheet. +DEFAULT_FIRST_ROLE = 'admin' + +#: Upper bound on people per submission. Generous for a real enrollment, but +#: bounded so a scripted POST cannot make us build an unbounded matrix. +MAX_PEOPLE = 25 -# ── Step 1 rows ────────────────────────────────────────────────────────────── -# ref, label, columns_offered. Ref 10 (Search/Export Reports) is an -# Admin/Director-only capability on the printed form, so it offers one cell. +def is_admin_role(role): + return role in ADMIN_ROLES + + +# ── Task rows ──────────────────────────────────────────────────────────────── +# ref, label, scope. scope 'admin_only' means the cell is offered only to +# people in an ADMIN_ROLES role (ref 10 on the printed form). TASKS = [ (1, 'Receive new inspection submitted notification', 'all'), (2, 'Receive issue-related notification', 'all'), @@ -44,30 +62,26 @@ TASKS = [ (10, 'Search/Export Reports (inspection/issue)', 'admin_only'), ] - -def columns_for(scope): - """Return the column list a task row offers.""" - return COLUMNS if scope == 'all' else [('admin', 'Admin /\nDirector')] +TASK_LABELS = {ref: label for ref, label, _ in TASKS} -# ── Step 2 registrants ─────────────────────────────────────────────────────── -# key -> row label on the printed form. -REGISTRANTS = [ - ('admin', 'Administrative Roles (Admin/Director/Auditor)'), - ('inspector_1', 'User / Inspector 1'), - ('inspector_2', 'User / Inspector 2'), - ('inspector_3', 'User / Inspector 3'), - ('inspector_4', 'User / Inspector 4'), - ('inspector_5', 'User / Inspector 5'), -] +def task_applies(scope, role): + """True when a task row offers a checkbox to someone in `role`.""" + return scope == 'all' or is_admin_role(role) # ── Step 3 ─────────────────────────────────────────────────────────────────── MOBILE_APP_LABEL = 'JQC Mobile App For Smart Device' -# ── Recommendation block (static reference, not an input) ──────────────────── -# ref -> (admin_recommended, inspector_recommended). None = no cell on the form. +# ── Recommended defaults ───────────────────────────────────────────────────── +# ref -> (recommended for admin-side roles, recommended for inspector roles). +# None = the row offers that side no cell. +# +# The printed form showed this as a separate RECOMMENDATION table for the +# customer to copy by hand. It is now applied by the "Recommendation selection" +# button instead, so the table is no longer rendered — but this mapping is +# still the authority, and is handed to the page as JSON. RECOMMENDATION = { 1: (False, True), 2: (False, True), @@ -81,10 +95,30 @@ RECOMMENDATION = { 10: (True, None), } -RECOMMENDATION_INTRO = ( - 'To prevent the administrator or director from receiving an overwhelming ' - 'number of email notifications, we recommend the following:' -) + +def recommendation_for(role): + """Return {task_ref: bool} — the recommended preset for one role. + + Rows that offer this role no cell are omitted rather than set False, so + the caller never ticks a checkbox that does not exist. + """ + admin_side = is_admin_role(role) + preset = {} + for ref, _label, scope in TASKS: + if not task_applies(scope, role): + continue + rec = RECOMMENDATION.get(ref, (False, False)) + value = rec[0] if admin_side else rec[1] + if value is None: + continue + preset[ref] = bool(value) + return preset + + +def recommendation_map(): + """{role_key: {task_ref: bool}} for every role — serialised to the page.""" + return {role: recommendation_for(role) for role in ROLE_KEYS} + NOTES = [ 'Each user will receive instructions on how to sign up and install the app ' @@ -108,3 +142,62 @@ STATUS_LABELS = { 'in_progress': 'In Progress', 'completed': 'Completed', } + + +# ── Legacy record support ──────────────────────────────────────────────────── +# Submissions taken before the form moved to free-form people used six fixed +# seats. Stored files are never rewritten, so the admin views normalise on +# read instead — one shape to render, whichever format is on disk. +_LEGACY_SEAT_ROLES = { + 'admin': 'admin', + 'inspector_1': 'inspector', + 'inspector_2': 'inspector', + 'inspector_3': 'inspector', + 'inspector_4': 'inspector', + 'inspector_5': 'inspector', +} + + +def people_of(record): + """Return a submission's people as a uniform list, old format or new. + + Each entry: {key, role, role_label, name, job_title, email}. + """ + if record.get('people'): + out = [] + for p in record['people']: + role = p.get('role', 'inspector') + out.append({ + 'key': p.get('key', ''), + 'role': role, + 'role_label': ROLE_LABELS.get(role, role.replace('_', ' ').title()), + 'name': p.get('name', ''), + 'job_title': p.get('job_title', ''), + 'email': p.get('email', ''), + }) + return out + + # Legacy: fixed seats under 'registrants'. + out = [] + for reg in record.get('registrants', []): + if not (reg.get('name') or reg.get('email')): + continue + role = _LEGACY_SEAT_ROLES.get(reg.get('key'), 'inspector') + out.append({ + 'key': reg.get('key', ''), + 'role': role, + 'role_label': ROLE_LABELS.get(role, role.title()), + 'name': reg.get('name', ''), + 'job_title': reg.get('job_title', ''), + 'email': reg.get('email', ''), + }) + return out + + +def cell(record, ref, person_key): + """True when `person_key` was ticked for task `ref` in this submission.""" + return bool(record.get('matrix', {}).get(str(ref), {}).get(person_key)) + + +def wants_mobile(record, person_key): + return bool(record.get('mobile_app', {}).get(person_key)) diff --git a/app/enrollment/templates/enrollment/admin_detail.html b/app/enrollment/templates/enrollment/admin_detail.html index 820e627..6c7e631 100644 --- a/app/enrollment/templates/enrollment/admin_detail.html +++ b/app/enrollment/templates/enrollment/admin_detail.html @@ -81,43 +81,41 @@ -{# ── Step 2: who to set up ──────────────────────────────────────────── #} +{# ── The people to set up ───────────────────────────────────────────── #} +{% set people = schema.people_of(record) %}
-
Users to Register
+
+ Users to Register + {{ people | length }} +
- + + - {% for reg in record.registrants %} - {% if reg.name or reg.email %} + {% for person in people %} + + + + - - - - {% endif %} {% endfor %}
SeatNameJob TitleEmailNo.RoleNameJob TitleEmail Mobile App
{{ loop.index }}{{ person.role_label }}{{ person.name or '—' }}{{ person.job_title or '—' }} - {{ reg.label }} - {% if reg.include %} - - {% endif %} - {{ reg.name or '—' }}{{ reg.job_title or '—' }} - {% if reg.email %} - {{ reg.email }} + {% if person.email %} + {{ person.email }} {% else %}—{% endif %} - {% if record.mobile_app.get(reg.key) %} + {% if schema.wants_mobile(record, person.key) %} {% else %}{% endif %}
@@ -125,7 +123,7 @@
-{# ── Step 1: the requested matrix ───────────────────────────────────── #} +{# ── The requested task matrix — one column per person ──────────────── #}
Requested Tasks & Functions
@@ -134,25 +132,27 @@ Ref - Task / Function - {% for col_key, col_label in schema.COLUMNS %} - - {{ col_label.replace('\n', ' ') }} + Task / Function + {% for person in people %} + + {{ person.name or 'Person ' ~ loop.index }} +
+ {{ person.role_label }} +
{% endfor %} {% for ref, label, scope in schema.TASKS %} - {% set row = record.matrix.get(ref|string, {}) %} {{ ref }} {{ label }} - {% for col_key, _col_label in schema.COLUMNS %} + {% for person in people %} - {% if scope != 'all' and col_key != 'admin' %} - · - {% elif row.get(col_key) %} + {% if not schema.task_applies(scope, person.role) %} + · + {% elif schema.cell(record, ref, person.key) %} {% else %} diff --git a/app/enrollment/templates/enrollment/admin_list.html b/app/enrollment/templates/enrollment/admin_list.html index 4dc4d17..c3641e3 100644 --- a/app/enrollment/templates/enrollment/admin_list.html +++ b/app/enrollment/templates/enrollment/admin_list.html @@ -50,7 +50,8 @@ {% for r in records %} - {% set named = r.registrants | selectattr('email') | selectattr('name') | list %} + {# people_of() normalises both the current and the legacy stored shape #} + {% set named = schema.people_of(r) %} {% set mobile_count = r.mobile_app.values() | select | list | length %} diff --git a/app/enrollment/templates/enrollment/form.html b/app/enrollment/templates/enrollment/form.html index 46f0203..d3b7344 100644 --- a/app/enrollment/templates/enrollment/form.html +++ b/app/enrollment/templates/enrollment/form.html @@ -9,7 +9,7 @@