diff --git a/CLAUDE.md b/CLAUDE.md index fa47a59..8fe7a51 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -164,6 +164,7 @@ part of the tree — see §7. Device registration on the API side lives in | `REDIS_URL` | Optional. When set, Flask-Limiter uses Redis for shared rate-limit counters across Gunicorn workers. | | `GROQ_API_KEY` | Optional. When set, enables the AI chatbot at `/support/chat`. Absent → chat input disabled; customers see a "Submit to Support" fallback only. | | `GROQ_MODEL` | Optional. Groq model ID. Defaults to `llama-3.3-70b-versatile`. | +| `ENROLLMENT_DIR` | Optional. Directory for enrollment-form JSON submissions. Defaults to `/enrollments` (git-ignored). Created at boot. | | `PHOTO_STAMP_ENABLED` | Optional, default `true`. Burns a capture-time + geo overlay into photos uploaded via `POST /api/v1/photos/upload`. Set `false` to store raw uploads. | ### Email SSL Auto-Detection @@ -535,6 +536,7 @@ Management (`/scheduled-inspections/new|edit|delete`) is `@project_manager_requi | `scheduled_reports` | `/scheduled-reports` | CRUD + manual trigger (accessible via Reports sub-nav) | | `scheduled_inspections` | `/scheduled-inspections` | list (`?tab=pending\|completed` — Phase 47), new/edit/delete (PM+), `GET //start` (**assigned inspector only** → creates linked inspection; 403 for non-assignees incl. managers), `POST //acknowledge` (**assigned inspector only** → confirms receipt, sets `acknowledged_at`, notifies creator; idempotent — Phase 47), `GET /confirm/` (**login-free** one-click email confirm; signed `itsdangerous` token binding schedule+inspector — Phase 47), `POST /run` (cron reminders, `token=DIGEST_SECRET`) | | `support` | `/support` | `GET /chat` (loads latest saved session; `?new=1` to start fresh), `POST /chat/message` (AJAX→Groq; **persists** user+assistant turns, returns `session_id`), `GET /my-conversations`, `GET /my-conversations/` (customer chat history), `GET /admin/conversations`, `GET /admin/conversations/` (staff, read-only), `GET /admin/knowledge` + `/new`, `//edit`, `//delete` (admin/director — chatbot knowledge base), `POST /tickets`, `GET /my-tickets`, `GET/POST /my-tickets/`, `GET /admin/tickets`, `GET/POST /admin/tickets/` | +| `enrollment` | `/enrollment` | **Self-contained onboarding intake — see §24.** `GET/POST /` (**login-free** public form), `GET /admin` (admin inbox), `GET/POST /admin/` (detail + office-use fields), `GET /admin/.json`, `GET /admin/export.csv`. Lives in `app/enrollment/` with its own templates; touches **no** DB table. | | `broadcast` | `/admin/broadcast` | `GET /` (compose + history), `POST /send` (admin-only; fans out one Notification per targeted user) | | `devices` | `/admin/devices` | `GET /` (device list from `api_device_tokens`), `POST /notify` (admin-only) | | `api` | `/api/v1` | parent blueprint | @@ -1486,6 +1488,7 @@ timeout = 30 | 84 | **"Instructions" is a LABEL over `notes` — never rename the field, attribute, column or API key** | `ScheduledInspectionForm.notes` renders as "Instructions" and both the web execute page and the iPad say "Instructions". The wire key stays `notes` (`api/scheduled.py::_scheduled_payload`), which is what `APIScheduledInspection.notes` decodes into `LocalScheduledInspection.notes`; the iPad exposes it through a computed `instructions` accessor that also trims blank text. Renaming any of the storage identifiers would silently break the iPad decode — the field is `try?`-decoded, so it would fail to nil rather than throwing. | | 85 | **`next_due_date` is mutable state, `end_date` is a fixed boundary — never conflate them** | `fulfill()` rewrites `next_due_date` after every completed inspection; `end_date` is set by the manager and never touched by the app. The old single label "Start / Due Date" said both at once, which is what users reported as confusing. The label now follows context — `form.next_due_date.label.text` is set to "Start Date" in `create()` and "Next Due Date" in `edit()`. Do not rename the `next_due_date` column to match a label: it is indexed, it is the API payload key the iPad decodes, and the reminder cron filters on it. | | 87 | **Never write `role == 'inspector'` — use `user.is_inspector` (`User.INSPECTOR_ROLES`)** | phase49 added `external_inspector`, which must behave as an inspector everywhere. An equality check silently drops it into the *privileged* branch of every `if inspector: scope … else: org-wide` block — i.e. a third-party inspector would see **every contract in the system**. This is a fail-OPEN mistake: nothing errors, the data just leaks. The sweep converted ~44 Python sites and 7 template sites; the only surviving `== 'inspector'` literals are the matrix docstring, the `MATRIX_DEFAULTS` mirror comprehension, and the default-checked box in `admin/broadcast.html`. Query-level checks use `User.role.in_(User.INSPECTOR_ROLES)` (never `filter_by(role='inspector')`). A **new** `app/api/*` blueprint's `_ALLOWED_ROLES` must include `external_inspector`, same as rule 79 requires for `auditor`. | +| 88 | **`app/enrollment/` imports no model and writes no DB row — keep the vertical slice sealed** | The enrollment form describes accounts that do NOT exist yet (no contract, facility or user to key a row against), so it stores flat JSON in `ENROLLMENT_DIR` and owns its own templates. Adding a model/migration for it, or letting the public POST create Users, would couple an unauthenticated endpoint to the account system — the exact thing the separation buys. If enrollment must ever provision accounts, do it as a separate admin-triggered action that reads a stored submission. Submission ids are filesystem paths: validate against `_ID_RE` before every open (path traversal). See §24. | | 81 | **Photo timestamp/geo overlay is burned at UPLOAD, never on `PATCH /issues//photos`** | That PATCH receives only path strings — the bytes are already in storage and the payload carries no capture metadata. Burning there would need a read-modify-write per key plus an overwrite-in-place primitive (`storage.save()` mints a NEW uuid key, and §22 requires key == DB path), and would risk a **double burn** since the endpoint is deliberately idempotent/retry-safe (rule 45). Stamp in `POST /photos/upload`, where the raw bytes + EXIF are in hand and each call writes exactly one already-stamped object. Stamping failures must always fall back to storing the ORIGINAL bytes — never lose a photo to a stamping bug. See §23. | --- @@ -1623,3 +1626,52 @@ timeout = 30 - Web-form uploads (`_save_photo` in `routes/inspections.py`) are **not** stamped — browsers rarely supply reliable capture/GPS metadata. The helper is reusable if that changes. - Only the stamped image is stored; no pristine original is retained. Since the burn happens *before* the first write, nothing stored is ever destroyed. - EXIF is not re-written into the output (the overlay is the record). Add it here if a machine-readable copy is ever needed. + +--- + +## 24. Enrollment Form (`/enrollment`) + +A customer-facing onboarding intake reproducing the printed **JQC Enrollment Form**, held **deliberately apart** from the rest of the application. It is the one feature in the tree that owns its whole vertical slice. + +``` +app/enrollment/ +├── __init__.py register_enrollment(app) + the separation contract +├── schema.py the form AS DATA — single source of truth +├── storage.py JSON-file persistence (no model, no migration) +├── routes.py public form + admin inbox +└── templates/enrollment/ + ├── form.html standalone public page (no base.html) + ├── submitted.html thank-you + reference number + ├── admin_list.html extends base.html + └── admin_detail.html extends base.html +``` + +### Separation contract — keep this true + +1. **No `app.models` import, nothing written to the database.** Enrollment happens *before* any contract, facility or user exists, so there is nothing to key a row against. Deleting the package would remove the routes and nothing else. +2. No migration, no model, no notification-matrix event, no API/iPad surface. +3. Its own `template_folder` — enrollment markup never mixes into `app/templates/`. +4. The only shared code it uses is what it should not reinvent: the app factory, Flask-WTF CSRF, the rate limiter, `@admin_required`. + +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. + +### `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. + +### Storage + +One JSON document per submission in `ENROLLMENT_DIR`, named `-<8 hex>.json` — time-ordered so a directory listing sorts chronologically, random suffix so two submissions in the same second cannot collide. The stem is the submission id and the **only** thing the admin URLs accept. + +- **`_ID_RE` guards every filesystem access.** Ids are validated against `^\d{8}-\d{6}-[0-9a-f]{8}$` before being joined to a path, so a crafted id (`../../etc/passwd`) can never escape the directory — verified. +- **Writes are atomic** (`tempfile` in the same dir → `os.replace`), so a crash mid-write cannot leave truncated JSON that would break the admin list for every other submission. +- `load_all()` skips a corrupt file with a log line rather than failing the whole page. +- **Customer answers are immutable after submission.** `update_office()` merges only the office block + status, so the file stays a faithful record of what was actually requested. + +### 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. + +### 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. diff --git a/app/__init__.py b/app/__init__.py index 4c049ed..f51640f 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -288,6 +288,13 @@ def create_app(config_name='default'): app.register_blueprint(scheduled_inspections.bp) app.register_blueprint(ui.bp) + # ── Enrollment form (self-contained — see app/enrollment/__init__.py) ──── + # Deliberately NOT part of the app's data model: it owns its own templates + # and stores submissions as JSON files, so it touches no table and needs no + # migration. Registered last because nothing else depends on it. + from app.enrollment import register_enrollment + register_enrollment(app) + # ── Mobile API (Phase 7 / Phase A / Phase B / Phase C) ─────────────────── # The /api/v1 blueprint group uses JWT Bearer tokens — no CSRF cookies needed. # diff --git a/app/enrollment/__init__.py b/app/enrollment/__init__.py new file mode 100644 index 0000000..de1fcf9 --- /dev/null +++ b/app/enrollment/__init__.py @@ -0,0 +1,40 @@ +""" +app/enrollment +-------------- +The JQC Enrollment Form — a self-contained onboarding intake, deliberately +held apart from the rest of the application. + + /enrollment public form emailed to a prospective customer + /enrollment/admin admin-only inbox of submissions + +Separation contract (please keep this true) +------------------------------------------- +1. NO app.models imports, and nothing here writes to the database. Enrollment + happens before any contract, facility or user exists, so there is nothing to + key a row against. Submissions are flat JSON files (see storage.py). +2. NO migration, NO model, NO notification-matrix event, NO iPad/API surface. + Deleting this package would remove the two routes and nothing else. +3. Its own template folder (app/enrollment/templates/enrollment/) — enrollment + markup never mixes into app/templates. +4. The only shared code it uses is what it should not reinvent: the app factory, + Flask-WTF CSRF, the rate limiter, and @admin_required. + +If this 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 app's models. +""" + +from .routes import bp # noqa: F401 (re-exported for register_enrollment) + + +def register_enrollment(app): + """Register the blueprint and make sure the storage directory exists.""" + import os + + app.config.setdefault( + 'ENROLLMENT_DIR', + os.path.join(app.instance_path, 'enrollments'), + ) + os.makedirs(app.config['ENROLLMENT_DIR'], exist_ok=True) + app.register_blueprint(bp) + app.logger.info('Enrollment | storage dir: %s', app.config['ENROLLMENT_DIR']) diff --git a/app/enrollment/routes.py b/app/enrollment/routes.py new file mode 100644 index 0000000..dd633da --- /dev/null +++ b/app/enrollment/routes.py @@ -0,0 +1,273 @@ +""" +app/enrollment/routes.py +------------------------ +The JQC Enrollment Form. + + GET /enrollment public form (NO login) + POST /enrollment submit → thank-you page + GET /enrollment/admin admin: all submissions + GET /enrollment/admin/ admin: one submission + POST /enrollment/admin/ admin: office-use fields + status + GET /enrollment/admin/.json admin: raw JSON download + GET /enrollment/admin/export.csv admin: all submissions as CSV + +Separation +---------- +This module imports NOTHING from app.models and writes NOTHING to the database +(see app/enrollment/__init__.py). Its only couplings to the rest of the app are +the ones it cannot sensibly reinvent: the app factory, CSRF, the rate limiter, +and @admin_required for the admin views. + +The public page is login-free, so it follows the same hardening as the `public` +blueprint (rule 74): CSRF-protected form, rate limited, honeypot-guarded, and +its own standalone template with no authenticated nav. +""" + +import csv +import io +import logging +from datetime import datetime + +from flask import (Blueprint, render_template, request, redirect, url_for, + flash, abort, Response, current_app) +from flask_login import login_required + +from app import limiter +from app.utils.decorators import admin_required + +from . import schema, storage + +logger = logging.getLogger(__name__) + +bp = Blueprint( + 'enrollment', __name__, + url_prefix='/enrollment', + # Own template folder — enrollment templates never mix into app/templates. + template_folder='templates', +) + +#: Bots find public forms fast. A human filling in a 6-person enrollment form +#: does not need more than a few attempts an hour from one address. +_SUBMIT_RATE_LIMIT = '5 per hour' + +_MAX_TEXT = 200 # per free-text field; anything longer is truncated +_MAX_NOTES = 2000 + + +def _clean(value, limit=_MAX_TEXT): + """Trim and length-cap one submitted text field.""" + return (value or '').strip()[:limit] + + +# ── 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) + + +@bp.route('', methods=['POST']) +@bp.route('/', methods=['POST']) +@limiter.limit(_SUBMIT_RATE_LIMIT) +def submit(): + """Parse, validate and store one enrollment submission.""" + # Honeypot: a field hidden from humans via CSS. Anything that fills it in + # is a bot. Answer 200 as though accepted so it learns nothing. + if (request.form.get('website') or '').strip(): + logger.info('ENROLLMENT | honeypot tripped | ip=%s', request.remote_addr) + return render_template('enrollment/submitted.html', reference=None) + + project_name = _clean(request.form.get('project_name')) + request_by = _clean(request.form.get('request_by')) + + # ── Step 1 matrix ──────────────────────────────────────────────────── + 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}')) + 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 + } + + # ── 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']] + 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.') + + 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 + + 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, + # Filled in later by staff on the admin page. + '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) + except Exception: + 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 + + logger.info('ENROLLMENT | submitted | id=%s project=%r users=%d ip=%s', + record['id'], project_name, len(named), request.remote_addr) + + return render_template('enrollment/submitted.html', reference=record['id']) + + +# ── Admin ──────────────────────────────────────────────────────────────────── + +@bp.route('/admin') +@login_required +@admin_required +def admin_list(): + records = storage.load_all() + logger.info('ENROLLMENT | admin_list | count=%d', len(records)) + return render_template('enrollment/admin_list.html', + records=records, schema=schema) + + +@bp.route('/admin/', methods=['GET', 'POST']) +@login_required +@admin_required +def admin_detail(submission_id): + record = storage.load(submission_id) + if record is None: + abort(404) + + if request.method == 'POST': + office = {k: _clean(request.form.get(k)) for k, _ in schema.OFFICE_FIELDS} + status = request.form.get('status', 'new') + if status not in schema.STATUSES: + status = record.get('status', 'new') + record = storage.update_office(submission_id, office, status) + if record is None: + abort(404) + flash('Enrollment record updated.', 'success') + return redirect(url_for('enrollment.admin_detail', + submission_id=submission_id)) + + return render_template('enrollment/admin_detail.html', + record=record, schema=schema) + + +@bp.route('/admin/.json') +@login_required +@admin_required +def admin_download(submission_id): + import json + record = storage.load(submission_id) + if record is None: + abort(404) + return Response( + json.dumps(record, indent=2, ensure_ascii=False), + mimetype='application/json', + headers={'Content-Disposition': + f'attachment; filename=enrollment-{submission_id}.json'}, + ) + + +@bp.route('/admin/export.csv') +@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.""" + 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', + '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') + row = [ + rec.get('id', ''), + rec.get('submitted_at', ''), + schema.STATUS_LABELS.get(rec.get('status'), rec.get('status', '')), + 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 '', + ] + for ref, _label, _scope in schema.TASKS: + cell = rec.get('matrix', {}).get(str(ref), {}).get(key) + row.append('Yes' if cell else '') + w.writerow(row) + + logger.info('ENROLLMENT | csv export | submissions=%d', len(records)) + stamp = datetime.now().strftime('%Y%m%d') + return Response( + buf.getvalue(), + mimetype='text/csv', + headers={'Content-Disposition': + f'attachment; filename=jqc-enrollments-{stamp}.csv'}, + ) diff --git a/app/enrollment/schema.py b/app/enrollment/schema.py new file mode 100644 index 0000000..54a4e3e --- /dev/null +++ b/app/enrollment/schema.py @@ -0,0 +1,110 @@ +""" +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 an +inspector column 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). +""" + +# ── 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'), +] + +INSPECTOR_COLUMNS = [c for c in COLUMNS if c[0] != 'admin'] + + +# ── 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. +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'), +] + + +def columns_for(scope): + """Return the column list a task row offers.""" + return COLUMNS if scope == 'all' else [('admin', 'Admin /\nDirector')] + + +# ── 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'), +] + + +# ── 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. +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), +} + +RECOMMENDATION_INTRO = ( + 'To prevent the administrator or director from receiving an overwhelming ' + 'number of email notifications, we recommend the following:' +) + +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) ───────────── +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', +} diff --git a/app/enrollment/storage.py b/app/enrollment/storage.py new file mode 100644 index 0000000..317ced8 --- /dev/null +++ b/app/enrollment/storage.py @@ -0,0 +1,149 @@ +""" +app/enrollment/storage.py +------------------------- +Flat-file persistence for enrollment submissions — one JSON document per +submission, in the directory named by config ENROLLMENT_DIR. + +Why files and not a table +------------------------- +Enrollment happens BEFORE anything exists in the system: there is no contract, +no facility and no user account to key a row against, and the volume is a +handful of documents a year. A directory of readable JSON keeps this feature +completely outside the schema — no model, no migration, nothing to keep in sync +with the rest of the app. It can be backed up with `cp` and read with `cat`. + +File naming +----------- + -<8 hex>.json + +Time-ordered so a plain directory listing sorts chronologically, with random +suffix so two submissions in the same second cannot collide. The stem is the +submission's id and is the ONLY thing the admin URLs accept — see _safe_id(). +""" + +import json +import logging +import os +import re +import secrets +import tempfile +from datetime import datetime + +from flask import current_app + +logger = logging.getLogger(__name__) + +#: Submission ids are generated by us and must round-trip through a URL and a +#: file path. Anything not matching is rejected before touching the filesystem, +#: so a crafted id can never escape the enrollment directory (path traversal). +_ID_RE = re.compile(r'^\d{8}-\d{6}-[0-9a-f]{8}$') + + +def enrollment_dir(): + """Absolute path of the submission directory, created on first use.""" + path = current_app.config['ENROLLMENT_DIR'] + os.makedirs(path, exist_ok=True) + return path + + +def new_id(when=None): + """Mint a time-ordered, collision-safe submission id.""" + when = when or datetime.now() + return f'{when:%Y%m%d-%H%M%S}-{secrets.token_hex(4)}' + + +def _safe_id(submission_id): + """Return the id if it is one of ours, else None. + + Never interpolate an unvalidated id into a path — `../../etc/passwd` and + friends. Callers should 404 on None. + """ + if not submission_id or not _ID_RE.match(submission_id): + logger.warning('ENROLLMENT | rejected malformed id=%r', submission_id) + return None + return submission_id + + +def _path_for(submission_id): + sid = _safe_id(submission_id) + if sid is None: + return None + return os.path.join(enrollment_dir(), f'{sid}.json') + + +def save(record): + """Write a submission atomically. Returns the id. + + Written to a temp file in the same directory then os.replace()d, so a + crash mid-write can never leave a truncated JSON document that would break + the admin list for every other submission. + """ + sid = record['id'] + path = _path_for(sid) + if path is None: + raise ValueError(f'refusing to save malformed id {sid!r}') + + directory = os.path.dirname(path) + fd, tmp = tempfile.mkstemp(dir=directory, suffix='.tmp') + try: + with os.fdopen(fd, 'w', encoding='utf-8') as fh: + json.dump(record, fh, indent=2, ensure_ascii=False) + os.replace(tmp, path) + except Exception: + # Never leave the temp file behind on a failed write. + try: + os.unlink(tmp) + except OSError: + pass + raise + + logger.info('ENROLLMENT | saved | id=%s project=%r', + sid, record.get('project_name')) + return sid + + +def load(submission_id): + """Return one submission dict, or None if unknown/unreadable.""" + path = _path_for(submission_id) + if path is None or not os.path.isfile(path): + return None + try: + with open(path, encoding='utf-8') as fh: + return json.load(fh) + except (OSError, ValueError): + logger.exception('ENROLLMENT | unreadable submission id=%s', submission_id) + return None + + +def load_all(): + """Return every submission, newest first. + + A single corrupt file is skipped with a log line rather than breaking the + whole admin list. + """ + directory = enrollment_dir() + records = [] + for name in sorted(os.listdir(directory), reverse=True): + if not name.endswith('.json'): + continue + rec = load(name[:-len('.json')]) + if rec is not None: + records.append(rec) + return records + + +def update_office(submission_id, office, status): + """Merge the office-use block + status into a stored submission. + + Returns the updated record, or None if the id is unknown. Only these + fields are writable after submission — the customer's own answers are + immutable, so the file stays an accurate record of what they asked for. + """ + rec = load(submission_id) + if rec is None: + return None + rec.setdefault('office', {}).update(office) + rec['status'] = status + rec['updated_at'] = datetime.now().isoformat(timespec='seconds') + save(rec) + return rec diff --git a/app/enrollment/templates/enrollment/admin_detail.html b/app/enrollment/templates/enrollment/admin_detail.html new file mode 100644 index 0000000..820e627 --- /dev/null +++ b/app/enrollment/templates/enrollment/admin_detail.html @@ -0,0 +1,175 @@ +{% extends "base.html" %} +{% block title %}Enrollment — {{ record.project_name }}{% endblock %} + +{# One submitted enrollment form, rendered through the same schema the public + page uses. The customer's answers are READ-ONLY here — only the office-use + block and the status are editable, so the file stays a faithful record of + what was actually requested. #} + +{% block content %} +
+
+

{{ record.project_name }}

+
+ Reference {{ record.id }} · submitted {{ record.submitted_at | replace('T', ' ') }} + {% if record.updated_at %} + · updated {{ record.updated_at | replace('T', ' ') }} + {% endif %} +
+
+ +
+ +
+ {# ── Request details ─────────────────────────────────────────────── #} +
+
+
Request
+
+
+
Project Name
{{ record.project_name or '—' }}
+
Request by
{{ record.request_by or '—' }}
+
Date Requested
{{ record.date_requested or '—' }}
+
+ {% if record.notes %} +
+
Customer notes
+
{{ record.notes }}
+ {% endif %} +
+
+
+ + {# ── Office use — the only editable part ─────────────────────────── #} +
+
+
For Office Use
+
+
+ + {% for key, label in schema.OFFICE_FIELDS %} +
+ + +
+ {% endfor %} +
+ + +
+ +
+
+
+
+
+ +{# ── Step 2: who to set up ──────────────────────────────────────────── #} +
+
Users to Register
+
+
+ + + + + + + + + {% for reg in record.registrants %} + {% if reg.name or reg.email %} + + + + + + + + {% endif %} + {% endfor %} + +
SeatNameJob TitleEmailMobile App
+ {{ reg.label }} + {% if reg.include %} + + {% endif %} + {{ reg.name or '—' }}{{ reg.job_title or '—' }} + {% if reg.email %} + {{ reg.email }} + {% else %}—{% endif %} + + {% if record.mobile_app.get(reg.key) %} + + {% else %}{% endif %} +
+
+
+
+ +{# ── Step 1: the requested matrix ───────────────────────────────────── #} +
+
Requested Tasks & Functions
+
+
+ + + + + + {% for col_key, col_label in schema.COLUMNS %} + + {% endfor %} + + + + {% for ref, label, scope in schema.TASKS %} + {% set row = record.matrix.get(ref|string, {}) %} + + + + {% for col_key, _col_label in schema.COLUMNS %} + + {% endfor %} + + {% endfor %} + +
RefTask / Function + {{ col_label.replace('\n', ' ') }} +
{{ ref }}{{ label }} + {% if scope != 'all' and col_key != 'admin' %} + · + {% elif row.get(col_key) %} + + {% else %} + + {% endif %} +
+
+
+
+ +{% if record.meta %} +
+ Submitted from {{ record.meta.ip or 'unknown address' }} +
+{% endif %} +{% endblock %} diff --git a/app/enrollment/templates/enrollment/admin_list.html b/app/enrollment/templates/enrollment/admin_list.html new file mode 100644 index 0000000..4dc4d17 --- /dev/null +++ b/app/enrollment/templates/enrollment/admin_list.html @@ -0,0 +1,97 @@ +{% extends "base.html" %} +{% block title %}Enrollment Forms{% endblock %} + +{# Admin inbox of enrollment submissions. Extends base.html so it picks up + whichever design (classic / modern) the admin has selected. #} + +{% block content %} +
+

Enrollment Forms

+
+ + View public form + + {% if records %} + + Export CSV + + {% endif %} +
+
+ +
+ +
+ Send customers this link to enroll: + {{ url_for('enrollment.form', _external=True) }}
+ + Submissions are stored as JSON files on the server, outside the database — + one file per form. + +
+
+ +{% if records %} +
+
+
+ + + + + + + + + + + + + + {% for r in records %} + {% set named = r.registrants | selectattr('email') | selectattr('name') | list %} + {% set mobile_count = r.mobile_app.values() | select | list | length %} + + + + + + + + + + {% endfor %} + +
SubmittedProjectRequested ByUsersMobile AppStatus
+ {{ r.submitted_at | replace('T', ' ') }} + {{ r.project_name or '—' }}{{ r.request_by or '—' }} + {{ named | length }} + + {% if mobile_count %} + {{ mobile_count }} + {% else %}{% endif %} + + + {{ schema.STATUS_LABELS.get(r.status, r.status) }} + + + + Open + +
+
+
+
+{% else %} +
+
+ + No enrollment forms have been submitted yet. +
+
+{% endif %} +{% endblock %} diff --git a/app/enrollment/templates/enrollment/form.html b/app/enrollment/templates/enrollment/form.html new file mode 100644 index 0000000..46f0203 --- /dev/null +++ b/app/enrollment/templates/enrollment/form.html @@ -0,0 +1,288 @@ + + + + + + +JQC Enrollment Form — L.T. Services, Inc + + + + + +
+ +

JQC Enrollment Form

+
by L.T Services, Inc
+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} + {% endwith %} + +
+ + + {# Honeypot — must stay empty. #} + + + {# ── Header ─────────────────────────────────────────────────────── #} +
+
+ + + + + + + + + + + + + +
Project Name
Request by:
Date Requested:
+
+
+ + {% for key, label in schema.OFFICE_FIELDS %} + + + + + {% endfor %} +
{{ label }}For office use
+
+
+ + {# ── Step 1 ─────────────────────────────────────────────────────── #} +
+ Step 1: Please check the task/function for each user. Refer to our + recommendations at the bottom of this page. +
+ +
+ + + + + + {% for col_key, col_label in schema.COLUMNS %} + + {% endfor %} + + + + {% for ref, label, scope in schema.TASKS %} + + + + {% for col_key, _col_label in schema.COLUMNS %} + {% if scope == 'all' or col_key == 'admin' %} + + {% else %} + {# Ref 10 is Admin/Director only on the printed form. #} + + {% endif %} + {% endfor %} + + {% endfor %} + +
RefRole Descriptions: Tasks and Functions{{ col_label.replace('\n', ' ') }}
{{ ref }}{{ label }} + +
+
+ + {# ── Step 2 ─────────────────────────────────────────────────────── #} +
+ Step 2: Please provide the following information for setup. Each user + will receive a notification at the email provided below to sign in. +
+ +
+ + + + + + + + + + + + + {% for key, label in schema.REGISTRANTS %} + {% set reg = (submitted.registrants[loop.index0] if submitted else none) %} + + + + + + + + + {% endfor %} + +
No.Role Descriptions RegisterFirst and last nameJob TitleEmail Address
{{ loop.index }}{{ label }} + +
+
+ + {# ── Step 3 ─────────────────────────────────────────────────────── #} +
+ Step 3: Please check the box next to the user who will receive the + app for smart devices. +
+ +
+ + + + + + {% for col_key, col_label in schema.COLUMNS %} + + {% endfor %} + + + + + + + {% for col_key, col_label in schema.COLUMNS %} + + {% endfor %} + + +
No.Mobile App{{ '' if col_key == 'admin' else col_label.replace('\n',' ') }}
7{{ schema.MOBILE_APP_LABEL }} + +
+
+ + {# ── Notes from the customer ────────────────────────────────────── #} +
Anything else we should know? (optional)
+ + + {# ── Recommendation (reference only) ────────────────────────────── #} +
RECOMMENDATION
+

{{ schema.RECOMMENDATION_INTRO }}

+ +
+ + + + + + + + + + + {% for ref, label, scope in schema.TASKS %} + {% set rec = schema.RECOMMENDATION[ref] %} + + + + + + + {% endfor %} + +
RefRole Descriptions: Tasks and FunctionsAdmin / DirectorUser / Inspectors
{{ ref }}{{ label }} + {% if rec[0] %} + {% else %}{% endif %} + + {% if rec[1] is none %}— + {% elif rec[1] %} + {% else %}{% endif %} +
+
+ +
Note:
+
    + {% for note in schema.NOTES %}
  1. {{ note }}
  2. {% endfor %} +
+ +
+ + +
+
+
+ + + + + diff --git a/app/enrollment/templates/enrollment/submitted.html b/app/enrollment/templates/enrollment/submitted.html new file mode 100644 index 0000000..78f9b8d --- /dev/null +++ b/app/enrollment/templates/enrollment/submitted.html @@ -0,0 +1,40 @@ + + + + + + +Enrollment received — JQC + + + + + +
+
+
+ +

Thank you — your enrollment form has been received.

+

+ Our team will set up the accounts you listed. Each user will receive an + email invitation with sign-in instructions, and a quick guide for the + web portal and the mobile app. +

+ {% if reference %} +
+
Your reference number
+
{{ reference }}
+
+

+ Please quote this reference if you contact us about your enrollment. +

+ {% endif %} +
+
+
JQC by L.T Services, Inc
+
+ + diff --git a/app/templates/layouts/classic.html b/app/templates/layouts/classic.html index 17cafec..6f1550b 100644 --- a/app/templates/layouts/classic.html +++ b/app/templates/layouts/classic.html @@ -230,6 +230,7 @@ or request.endpoint == 'auth.notification_matrix' or request.endpoint.startswith('broadcast.') or request.endpoint.startswith('devices.') + or request.endpoint.startswith('enrollment.') or (request.endpoint.startswith('auth.') and 'user' in request.endpoint) ) %} +
  • + Enrollment Forms +
  • {% endif %} diff --git a/app/templates/layouts/modern.html b/app/templates/layouts/modern.html index 01aba72..57d94bb 100644 --- a/app/templates/layouts/modern.html +++ b/app/templates/layouts/modern.html @@ -264,6 +264,7 @@ or request.endpoint == 'auth.notification_matrix' or request.endpoint.startswith('broadcast.') or request.endpoint.startswith('devices.') + or request.endpoint.startswith('enrollment.') or (request.endpoint.startswith('auth.') and 'user' in request.endpoint) ) %} Broadcast Devices + Enrollment Forms {% endif %}