215 lines
8.4 KiB
Python
215 lines
8.4 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.
|
|
ROLES = [
|
|
('admin', 'Admin'),
|
|
('director', 'Director'),
|
|
('auditor', 'Auditor'),
|
|
('inspector', 'Inspector'),
|
|
('external_inspector', 'External Inspector'),
|
|
]
|
|
|
|
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
|
|
|
|
|
|
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}
|
|
|
|
|
|
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.
|
|
CORRECTIONS_EMAIL = 'da.nguyen8744@gmail.com'
|
|
|
|
|
|
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 L.T. Services 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))
|