249 lines
10 KiB
Python
249 lines
10 KiB
Python
"""
|
|
app/enrollment/schema.py
|
|
------------------------
|
|
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 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). 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.
|
|
"""
|
|
|
|
# ── Roles a person can be enrolled as ────────────────────────────────────────
|
|
# key -> label, shown in the Step 1 role dropdown.
|
|
# phase51 — enrollment describes CUSTOMER-side people only, so the dropdown
|
|
# offers exactly the two customer roles. Our own staff roles (admin, auditor,
|
|
# internal inspector) are never enrolled through this form; they are created in
|
|
# User Management. The keys stay 'director'/'inspector' — they are the
|
|
# customer's words for the seat, mapped to app roles by APP_ROLE_FOR below.
|
|
ROLES = [
|
|
('director', 'Director'),
|
|
('inspector', 'Inspector'),
|
|
]
|
|
|
|
ROLE_LABELS = dict(ROLES)
|
|
ROLE_KEYS = [k for k, _ in ROLES]
|
|
|
|
#: App role each enrolled seat becomes when an admin actually creates the
|
|
#: account in Customer Management. A plain string map on purpose — the
|
|
#: enrollment package must not import app.models (see __init__.py, rule 88).
|
|
APP_ROLE_FOR = {
|
|
'director': 'customer', # "Customer Director"
|
|
'inspector': 'external_inspector', # "Customer Inspector"
|
|
}
|
|
|
|
#: 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' and 'auditor' are NOT selectable any more but stay in this set for
|
|
#: LEGACY tolerance: submissions taken before phase51 stored those roles, and
|
|
#: dropping them here would silently re-render their admin-only task cells
|
|
#: (ref 10) as "n/a" in the admin detail view and the CSV export. Selectable
|
|
#: roles shrink; the ability to read back what was already recorded does not.
|
|
ADMIN_ROLES = {'director', 'admin', 'auditor'}
|
|
|
|
#: Role pre-selected for the first row — the form starts with the customer's
|
|
#: administrative contact, as on the printed sheet.
|
|
DEFAULT_FIRST_ROLE = 'director'
|
|
|
|
#: 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
|
|
|
|
|
|
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'),
|
|
(3, 'New issue created', 'all'),
|
|
(4, 'Issue status updated', 'all'),
|
|
(5, 'Issue comment added', 'all'),
|
|
(6, 'Request follow up / re-inspection', 'all'),
|
|
(7, 'Add Comments (issue detail page)', 'all'),
|
|
(8, 'Receive issue SLA (at-risk, breached)', 'all'),
|
|
(9, 'Log new issue', 'all'),
|
|
(10, 'Search/Export Reports (inspection/issue)', 'admin_only'),
|
|
]
|
|
|
|
TASK_LABELS = {ref: label for ref, label, _ in TASKS}
|
|
|
|
#: Task rows that describe a CAPABILITY the role already carries, rather than a
|
|
#: notification we route. Every customer role can already do all three today —
|
|
#: comment on issues they follow or filed, log an issue at their own facility,
|
|
#: and search/export reports within their scope — so a tick here records what
|
|
#: the customer expects, it does not switch anything on. The remaining rows
|
|
#: (1-6, 8) are the ones that map to notification events and can be tuned
|
|
#: per account in Customer Management.
|
|
#:
|
|
#: Rendered as a footnote on the public form so the distinction is visible
|
|
#: without turning these into per-user permission flags (which would mean
|
|
#: adding deny-checks to routes that have none today — a fail-open surface for
|
|
#: no real gain).
|
|
ROLE_IMPLIED_TASKS = {7, 9, 10}
|
|
|
|
|
|
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'
|
|
|
|
|
|
# ── 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),
|
|
3: (True, True),
|
|
4: (False, True),
|
|
5: (True, True),
|
|
6: (True, True),
|
|
7: (True, True),
|
|
8: (False, True),
|
|
9: (True, True),
|
|
10: (True, None),
|
|
}
|
|
|
|
|
|
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}
|
|
|
|
|
|
#: Where a customer should write if their submission needs correcting. The
|
|
#: confirmation email is sent FROM the unmonitored no-reply identity
|
|
#: (branded_sender), so "reply to this email" would go nowhere — point them
|
|
#: here instead. Used by both the text and HTML bodies of the confirmation.
|
|
# MT: retained only as a last-resort default. The address actually shown to a
|
|
# customer is resolved per tenant at send time from TenantSettings.support_email
|
|
# — see mailer._tenant_branding(). Never send one tenant's customers another
|
|
# tenant's (or a developer's personal) address.
|
|
CORRECTIONS_EMAIL = ''
|
|
|
|
|
|
NOTES = [
|
|
'Each user will receive instructions on how to sign up and install the app '
|
|
'on their smart device.',
|
|
'Along with the installation instructions, users will receive a quick guide '
|
|
'to navigate the web portal and app based on their credentials.',
|
|
]
|
|
|
|
|
|
# ── Office-use fields ────────────────────────────────────────────────────────
|
|
# Filled in by the tenant AFTER receipt, on the admin detail page only. The
|
|
# printed sheet showed these to the customer as a blank "for office use" block;
|
|
# the web form does not render them at all — a customer cannot fill them in, so
|
|
# showing them was only noise.
|
|
OFFICE_FIELDS = [
|
|
('receive_date', 'Receive Date'),
|
|
('program_by', 'Program By'),
|
|
('date_email_invitation', 'Date email invitation'),
|
|
]
|
|
|
|
STATUSES = ['new', 'in_progress', 'completed']
|
|
|
|
STATUS_LABELS = {
|
|
'new': 'New',
|
|
'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))
|