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
+130 -37
View File
@@ -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))