""" 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. 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))