Aug 6 - Add enrollment page
This commit is contained in:
@@ -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. |
|
| `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_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`. |
|
| `GROQ_MODEL` | Optional. Groq model ID. Defaults to `llama-3.3-70b-versatile`. |
|
||||||
|
| `ENROLLMENT_DIR` | Optional. Directory for enrollment-form JSON submissions. Defaults to `<instance_path>/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. |
|
| `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
|
### 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_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 /<id>/start` (**assigned inspector only** → creates linked inspection; 403 for non-assignees incl. managers), `POST /<id>/acknowledge` (**assigned inspector only** → confirms receipt, sets `acknowledged_at`, notifies creator; idempotent — Phase 47), `GET /confirm/<token>` (**login-free** one-click email confirm; signed `itsdangerous` token binding schedule+inspector — Phase 47), `POST /run` (cron reminders, `token=DIGEST_SECRET`) |
|
| `scheduled_inspections` | `/scheduled-inspections` | list (`?tab=pending\|completed` — Phase 47), new/edit/delete (PM+), `GET /<id>/start` (**assigned inspector only** → creates linked inspection; 403 for non-assignees incl. managers), `POST /<id>/acknowledge` (**assigned inspector only** → confirms receipt, sets `acknowledged_at`, notifies creator; idempotent — Phase 47), `GET /confirm/<token>` (**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/<id>` (customer chat history), `GET /admin/conversations`, `GET /admin/conversations/<id>` (staff, read-only), `GET /admin/knowledge` + `/new`, `/<id>/edit`, `/<id>/delete` (admin/director — chatbot knowledge base), `POST /tickets`, `GET /my-tickets`, `GET/POST /my-tickets/<id>`, `GET /admin/tickets`, `GET/POST /admin/tickets/<id>` |
|
| `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/<id>` (customer chat history), `GET /admin/conversations`, `GET /admin/conversations/<id>` (staff, read-only), `GET /admin/knowledge` + `/new`, `/<id>/edit`, `/<id>/delete` (admin/director — chatbot knowledge base), `POST /tickets`, `GET /my-tickets`, `GET/POST /my-tickets/<id>`, `GET /admin/tickets`, `GET/POST /admin/tickets/<id>` |
|
||||||
|
| `enrollment` | `/enrollment` | **Self-contained onboarding intake — see §24.** `GET/POST /` (**login-free** public form), `GET /admin` (admin inbox), `GET/POST /admin/<id>` (detail + office-use fields), `GET /admin/<id>.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) |
|
| `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) |
|
| `devices` | `/admin/devices` | `GET /` (device list from `api_device_tokens`), `POST /notify` (admin-only) |
|
||||||
| `api` | `/api/v1` | parent blueprint |
|
| `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. |
|
| 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. |
|
| 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`. |
|
| 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/<id>/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. |
|
| 81 | **Photo timestamp/geo overlay is burned at UPLOAD, never on `PATCH /issues/<id>/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.
|
- 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.
|
- 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.
|
- 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 `<YYYYmmdd-HHMMSS>-<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/<id>.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.
|
||||||
|
|||||||
@@ -288,6 +288,13 @@ def create_app(config_name='default'):
|
|||||||
app.register_blueprint(scheduled_inspections.bp)
|
app.register_blueprint(scheduled_inspections.bp)
|
||||||
app.register_blueprint(ui.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) ───────────────────
|
# ── Mobile API (Phase 7 / Phase A / Phase B / Phase C) ───────────────────
|
||||||
# The /api/v1 blueprint group uses JWT Bearer tokens — no CSRF cookies needed.
|
# The /api/v1 blueprint group uses JWT Bearer tokens — no CSRF cookies needed.
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -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'])
|
||||||
@@ -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/<id> admin: one submission
|
||||||
|
POST /enrollment/admin/<id> admin: office-use fields + status
|
||||||
|
GET /enrollment/admin/<id>.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/<submission_id>', 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/<submission_id>.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'},
|
||||||
|
)
|
||||||
@@ -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',
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
-----------
|
||||||
|
<YYYYmmdd-HHMMSS>-<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
|
||||||
@@ -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 %}
|
||||||
|
<div class="d-flex flex-wrap justify-content-between align-items-center mb-3 gap-2">
|
||||||
|
<div>
|
||||||
|
<h2 class="mb-0"><i class="bi bi-clipboard-check"></i> {{ record.project_name }}</h2>
|
||||||
|
<div class="text-muted small">
|
||||||
|
Reference {{ record.id }} · submitted {{ record.submitted_at | replace('T', ' ') }}
|
||||||
|
{% if record.updated_at %}
|
||||||
|
· updated {{ record.updated_at | replace('T', ' ') }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a href="{{ url_for('enrollment.admin_list') }}" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-arrow-left"></i> Back
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('enrollment.admin_download', submission_id=record.id) }}"
|
||||||
|
class="btn btn-outline-primary">
|
||||||
|
<i class="bi bi-filetype-json"></i> Download JSON
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
{# ── Request details ─────────────────────────────────────────────── #}
|
||||||
|
<div class="col-12 col-lg-6">
|
||||||
|
<div class="card shadow-sm h-100">
|
||||||
|
<div class="card-header fw-semibold">Request</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<dl class="row mb-0">
|
||||||
|
<dt class="col-5">Project Name</dt><dd class="col-7">{{ record.project_name or '—' }}</dd>
|
||||||
|
<dt class="col-5">Request by</dt><dd class="col-7">{{ record.request_by or '—' }}</dd>
|
||||||
|
<dt class="col-5">Date Requested</dt><dd class="col-7">{{ record.date_requested or '—' }}</dd>
|
||||||
|
</dl>
|
||||||
|
{% if record.notes %}
|
||||||
|
<hr>
|
||||||
|
<div class="fw-semibold small text-muted mb-1">Customer notes</div>
|
||||||
|
<div style="white-space:pre-wrap;">{{ record.notes }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Office use — the only editable part ─────────────────────────── #}
|
||||||
|
<div class="col-12 col-lg-6">
|
||||||
|
<div class="card shadow-sm h-100">
|
||||||
|
<div class="card-header fw-semibold">For Office Use</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="POST">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
{% for key, label in schema.OFFICE_FIELDS %}
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label small mb-1">{{ label }}</label>
|
||||||
|
<input type="text" name="{{ key }}" class="form-control form-control-sm"
|
||||||
|
value="{{ record.office.get(key, '') }}">
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label small mb-1">Status</label>
|
||||||
|
<select name="status" class="form-select form-select-sm">
|
||||||
|
{% for s in schema.STATUSES %}
|
||||||
|
<option value="{{ s }}" {{ 'selected' if record.status == s }}>
|
||||||
|
{{ schema.STATUS_LABELS[s] }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-sm btn-primary">
|
||||||
|
<i class="bi bi-save"></i> Save
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Step 2: who to set up ──────────────────────────────────────────── #}
|
||||||
|
<div class="card shadow-sm mt-3">
|
||||||
|
<div class="card-header fw-semibold">Users to Register</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Seat</th><th>Name</th><th>Job Title</th><th>Email</th>
|
||||||
|
<th class="text-center">Mobile App</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for reg in record.registrants %}
|
||||||
|
{% if reg.name or reg.email %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{{ reg.label }}
|
||||||
|
{% if reg.include %}
|
||||||
|
<i class="bi bi-check-circle-fill text-success ms-1"
|
||||||
|
title="Ticked on the form"></i>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="fw-semibold">{{ reg.name or '—' }}</td>
|
||||||
|
<td>{{ reg.job_title or '—' }}</td>
|
||||||
|
<td>
|
||||||
|
{% if reg.email %}
|
||||||
|
<a href="mailto:{{ reg.email }}">{{ reg.email }}</a>
|
||||||
|
{% else %}—{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
{% if record.mobile_app.get(reg.key) %}
|
||||||
|
<i class="bi bi-phone-fill text-primary" title="Wants the mobile app"></i>
|
||||||
|
{% else %}<span class="text-muted">—</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Step 1: the requested matrix ───────────────────────────────────── #}
|
||||||
|
<div class="card shadow-sm mt-3">
|
||||||
|
<div class="card-header fw-semibold">Requested Tasks & Functions</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th style="width:50px;">Ref</th>
|
||||||
|
<th>Task / Function</th>
|
||||||
|
{% for col_key, col_label in schema.COLUMNS %}
|
||||||
|
<th class="text-center" style="width:110px;">
|
||||||
|
{{ col_label.replace('\n', ' ') }}
|
||||||
|
</th>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for ref, label, scope in schema.TASKS %}
|
||||||
|
{% set row = record.matrix.get(ref|string, {}) %}
|
||||||
|
<tr>
|
||||||
|
<td class="text-center">{{ ref }}</td>
|
||||||
|
<td>{{ label }}</td>
|
||||||
|
{% for col_key, _col_label in schema.COLUMNS %}
|
||||||
|
<td class="text-center">
|
||||||
|
{% if scope != 'all' and col_key != 'admin' %}
|
||||||
|
<span class="text-muted">·</span>
|
||||||
|
{% elif row.get(col_key) %}
|
||||||
|
<i class="bi bi-check-square-fill text-success"></i>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if record.meta %}
|
||||||
|
<div class="text-muted small mt-3">
|
||||||
|
Submitted from {{ record.meta.ip or 'unknown address' }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -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 %}
|
||||||
|
<div class="d-flex flex-wrap justify-content-between align-items-center mb-4 gap-2">
|
||||||
|
<h2 class="mb-0"><i class="bi bi-clipboard-plus"></i> Enrollment Forms</h2>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a href="{{ url_for('enrollment.form') }}" target="_blank"
|
||||||
|
class="btn btn-outline-secondary" title="Open the public form in a new tab">
|
||||||
|
<i class="bi bi-box-arrow-up-right"></i> View public form
|
||||||
|
</a>
|
||||||
|
{% if records %}
|
||||||
|
<a href="{{ url_for('enrollment.admin_export_csv') }}" class="btn btn-outline-success">
|
||||||
|
<i class="bi bi-file-earmark-spreadsheet"></i> Export CSV
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-info d-flex align-items-start gap-2">
|
||||||
|
<i class="bi bi-info-circle mt-1"></i>
|
||||||
|
<div>
|
||||||
|
Send customers this link to enroll:
|
||||||
|
<code>{{ url_for('enrollment.form', _external=True) }}</code><br>
|
||||||
|
<span class="small text-muted">
|
||||||
|
Submissions are stored as JSON files on the server, outside the database —
|
||||||
|
one file per form.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if records %}
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Submitted</th>
|
||||||
|
<th>Project</th>
|
||||||
|
<th>Requested By</th>
|
||||||
|
<th class="text-center">Users</th>
|
||||||
|
<th class="text-center">Mobile App</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for r in records %}
|
||||||
|
{% set named = r.registrants | selectattr('email') | selectattr('name') | list %}
|
||||||
|
{% set mobile_count = r.mobile_app.values() | select | list | length %}
|
||||||
|
<tr>
|
||||||
|
<td class="text-nowrap">
|
||||||
|
<small>{{ r.submitted_at | replace('T', ' ') }}</small>
|
||||||
|
</td>
|
||||||
|
<td class="fw-semibold">{{ r.project_name or '—' }}</td>
|
||||||
|
<td>{{ r.request_by or '—' }}</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<span class="badge bg-secondary">{{ named | length }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
{% if mobile_count %}
|
||||||
|
<span class="badge bg-info text-dark">{{ mobile_count }}</span>
|
||||||
|
{% else %}<span class="text-muted">—</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-{{ 'success' if r.status == 'completed'
|
||||||
|
else 'warning text-dark' if r.status == 'in_progress'
|
||||||
|
else 'danger' }}">
|
||||||
|
{{ schema.STATUS_LABELS.get(r.status, r.status) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-end text-nowrap">
|
||||||
|
<a href="{{ url_for('enrollment.admin_detail', submission_id=r.id) }}"
|
||||||
|
class="btn btn-sm btn-outline-primary">
|
||||||
|
<i class="bi bi-eye"></i> Open
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body text-center py-5 text-muted">
|
||||||
|
<i class="bi bi-inbox fs-2 d-block mb-2"></i>
|
||||||
|
No enrollment forms have been submitted yet.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="robots" content="noindex, nofollow">
|
||||||
|
<title>JQC Enrollment Form — L.T. Services, Inc</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
|
||||||
|
<style>
|
||||||
|
body { background:#f1f5f9; color:#1f2937; }
|
||||||
|
.sheet { max-width:1140px; margin:24px auto 60px; background:#fff;
|
||||||
|
border:1px solid #d7dee6; border-radius:10px; padding:32px 34px 40px; }
|
||||||
|
.form-title { color:#1a6fb5; font-weight:800; font-size:2rem; text-align:center; margin:0; }
|
||||||
|
.form-sub { text-align:center; color:#6b7280; margin-bottom:26px; }
|
||||||
|
.step-head { font-weight:700; margin:30px 0 10px; }
|
||||||
|
.step-head span { font-weight:400; }
|
||||||
|
table.grid { width:100%; border-collapse:collapse; }
|
||||||
|
table.grid th, table.grid td { border:1px solid #cbd5e1; padding:6px 9px; vertical-align:middle; }
|
||||||
|
table.grid thead th { background:#dbeafe; font-weight:700; text-align:center; font-size:.86rem; line-height:1.25; }
|
||||||
|
table.grid thead th.left { text-align:left; }
|
||||||
|
.ref-col { width:52px; text-align:center; }
|
||||||
|
.chk-col { width:118px; text-align:center; }
|
||||||
|
.chk-col input { width:18px; height:18px; }
|
||||||
|
.reg-table thead th { background:#dcfce7; }
|
||||||
|
.rec-table thead th { background:#fde4d3; }
|
||||||
|
.hdr-table td { border:1px solid #cbd5e1; padding:6px 9px; }
|
||||||
|
.hdr-table .lbl { background:#f8fafc; font-weight:600; width:170px; white-space:nowrap; }
|
||||||
|
.hdr-table input { border:none; outline:none; width:100%; }
|
||||||
|
.hdr-table input:focus { background:#eff6ff; }
|
||||||
|
.cell-input { border:1px solid transparent; background:transparent; width:100%;
|
||||||
|
padding:2px 4px; border-radius:4px; }
|
||||||
|
.cell-input:focus { border-color:#1a6fb5; background:#fff; outline:none; }
|
||||||
|
.office-note { color:#6b7280; font-size:.82rem; }
|
||||||
|
.note-list { font-size:.92rem; }
|
||||||
|
/* Honeypot — hidden from humans, visible to naive bots. Not type=hidden:
|
||||||
|
some bots skip those. */
|
||||||
|
.hp { position:absolute; left:-9999px; width:1px; height:1px; overflow:hidden; }
|
||||||
|
@media (max-width: 820px) {
|
||||||
|
.sheet { padding:18px 14px 30px; margin:10px; }
|
||||||
|
table.grid { font-size:.8rem; }
|
||||||
|
.chk-col { width:64px; }
|
||||||
|
.scroll-x { overflow-x:auto; }
|
||||||
|
}
|
||||||
|
@media print {
|
||||||
|
body { background:#fff; }
|
||||||
|
.sheet { border:none; margin:0; max-width:none; }
|
||||||
|
.no-print { display:none !important; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="sheet">
|
||||||
|
|
||||||
|
<h1 class="form-title">JQC Enrollment Form</h1>
|
||||||
|
<div class="form-sub">by L.T Services, Inc</div>
|
||||||
|
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="alert alert-{{ category }} no-print">{{ message }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<form method="POST" action="{{ url_for('enrollment.submit') }}" id="enrollForm">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
|
||||||
|
{# Honeypot — must stay empty. #}
|
||||||
|
<div class="hp" aria-hidden="true">
|
||||||
|
<label>Website<input type="text" name="website" tabindex="-1" autocomplete="off"></label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Header ─────────────────────────────────────────────────────── #}
|
||||||
|
<div class="row g-3 mb-2">
|
||||||
|
<div class="col-12 col-lg-6">
|
||||||
|
<table class="hdr-table" style="width:100%;">
|
||||||
|
<tr>
|
||||||
|
<td class="lbl">Project Name</td>
|
||||||
|
<td><input type="text" name="project_name" required maxlength="200"
|
||||||
|
value="{{ submitted.project_name if submitted else '' }}"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="lbl">Request by:</td>
|
||||||
|
<td><input type="text" name="request_by" required maxlength="200"
|
||||||
|
value="{{ submitted.request_by if submitted else '' }}"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="lbl">Date Requested:</td>
|
||||||
|
<td><input type="date" name="date_requested"
|
||||||
|
value="{{ submitted.date_requested if submitted else '' }}"></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-lg-6">
|
||||||
|
<table class="hdr-table" style="width:100%;">
|
||||||
|
{% for key, label in schema.OFFICE_FIELDS %}
|
||||||
|
<tr>
|
||||||
|
<td class="lbl">{{ label }}</td>
|
||||||
|
<td class="office-note">For office use</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Step 1 ─────────────────────────────────────────────────────── #}
|
||||||
|
<div class="step-head">
|
||||||
|
Step 1: <span>Please check the task/function for each user. Refer to our
|
||||||
|
recommendations at the bottom of this page.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="scroll-x">
|
||||||
|
<table class="grid">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="ref-col">Ref</th>
|
||||||
|
<th class="left">Role Descriptions: Tasks and Functions</th>
|
||||||
|
{% for col_key, col_label in schema.COLUMNS %}
|
||||||
|
<th class="chk-col">{{ col_label.replace('\n', ' ') }}</th>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for ref, label, scope in schema.TASKS %}
|
||||||
|
<tr>
|
||||||
|
<td class="ref-col">{{ ref }}</td>
|
||||||
|
<td>{{ label }}</td>
|
||||||
|
{% for col_key, _col_label in schema.COLUMNS %}
|
||||||
|
{% if scope == 'all' or col_key == 'admin' %}
|
||||||
|
<td class="chk-col">
|
||||||
|
<input type="checkbox" class="form-check-input"
|
||||||
|
name="task_{{ ref }}_{{ col_key }}"
|
||||||
|
aria-label="{{ label }} — {{ _col_label.replace('\n',' ') }}"
|
||||||
|
{% if submitted and submitted.matrix[ref|string][col_key] %}checked{% endif %}>
|
||||||
|
</td>
|
||||||
|
{% else %}
|
||||||
|
{# Ref 10 is Admin/Director only on the printed form. #}
|
||||||
|
<td class="chk-col"></td>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Step 2 ─────────────────────────────────────────────────────── #}
|
||||||
|
<div class="step-head">
|
||||||
|
Step 2: <span>Please provide the following information for setup. Each user
|
||||||
|
will receive a notification at the email provided below to sign in.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="scroll-x">
|
||||||
|
<table class="grid reg-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="ref-col">No.</th>
|
||||||
|
<th class="left">Role Descriptions Register</th>
|
||||||
|
<th style="width:52px;"></th>
|
||||||
|
<th class="left">First and last name</th>
|
||||||
|
<th class="left">Job Title</th>
|
||||||
|
<th class="left">Email Address</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for key, label in schema.REGISTRANTS %}
|
||||||
|
{% set reg = (submitted.registrants[loop.index0] if submitted else none) %}
|
||||||
|
<tr>
|
||||||
|
<td class="ref-col">{{ loop.index }}</td>
|
||||||
|
<td>{{ label }}</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<input type="checkbox" class="form-check-input" name="reg_{{ key }}_include"
|
||||||
|
aria-label="Register {{ label }}"
|
||||||
|
{% if reg and reg.include %}checked{% endif %}>
|
||||||
|
</td>
|
||||||
|
<td><input type="text" class="cell-input" name="reg_{{ key }}_name" maxlength="200"
|
||||||
|
value="{{ reg.name if reg else '' }}"></td>
|
||||||
|
<td><input type="text" class="cell-input" name="reg_{{ key }}_job_title" maxlength="200"
|
||||||
|
value="{{ reg.job_title if reg else '' }}"></td>
|
||||||
|
<td><input type="email" class="cell-input" name="reg_{{ key }}_email" maxlength="200"
|
||||||
|
value="{{ reg.email if reg else '' }}"></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Step 3 ─────────────────────────────────────────────────────── #}
|
||||||
|
<div class="step-head">
|
||||||
|
Step 3: <span>Please check the box next to the user who will receive the
|
||||||
|
app for smart devices.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="scroll-x">
|
||||||
|
<table class="grid">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="ref-col">No.</th>
|
||||||
|
<th class="left">Mobile App</th>
|
||||||
|
{% for col_key, col_label in schema.COLUMNS %}
|
||||||
|
<th class="chk-col">{{ '' if col_key == 'admin' else col_label.replace('\n',' ') }}</th>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="ref-col">7</td>
|
||||||
|
<td>{{ schema.MOBILE_APP_LABEL }}</td>
|
||||||
|
{% for col_key, col_label in schema.COLUMNS %}
|
||||||
|
<td class="chk-col">
|
||||||
|
<input type="checkbox" class="form-check-input" name="mobile_{{ col_key }}"
|
||||||
|
aria-label="Mobile app — {{ col_label.replace('\n',' ') }}"
|
||||||
|
{% if submitted and submitted.mobile_app[col_key] %}checked{% endif %}>
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Notes from the customer ────────────────────────────────────── #}
|
||||||
|
<div class="step-head">Anything else we should know? <span>(optional)</span></div>
|
||||||
|
<textarea name="notes" class="form-control" rows="3" maxlength="2000"
|
||||||
|
placeholder="Special requirements, timing, additional users…">{{ submitted.notes if submitted else '' }}</textarea>
|
||||||
|
|
||||||
|
{# ── Recommendation (reference only) ────────────────────────────── #}
|
||||||
|
<div class="step-head">RECOMMENDATION</div>
|
||||||
|
<p class="mb-2">{{ schema.RECOMMENDATION_INTRO }}</p>
|
||||||
|
|
||||||
|
<div class="scroll-x">
|
||||||
|
<table class="grid rec-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="ref-col">Ref</th>
|
||||||
|
<th class="left">Role Descriptions: Tasks and Functions</th>
|
||||||
|
<th class="chk-col">Admin / Director</th>
|
||||||
|
<th class="chk-col">User / Inspectors</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for ref, label, scope in schema.TASKS %}
|
||||||
|
{% set rec = schema.RECOMMENDATION[ref] %}
|
||||||
|
<tr>
|
||||||
|
<td class="ref-col">{{ ref }}</td>
|
||||||
|
<td>{{ label }}</td>
|
||||||
|
<td class="chk-col">
|
||||||
|
{% if rec[0] %}<i class="bi bi-check-square-fill text-success"></i>
|
||||||
|
{% else %}<i class="bi bi-square text-muted"></i>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="chk-col">
|
||||||
|
{% if rec[1] is none %}—
|
||||||
|
{% elif rec[1] %}<i class="bi bi-check-square-fill text-success"></i>
|
||||||
|
{% else %}<i class="bi bi-square text-muted"></i>{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="step-head">Note:</div>
|
||||||
|
<ol class="note-list">
|
||||||
|
{% for note in schema.NOTES %}<li>{{ note }}</li>{% endfor %}
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<div class="d-flex gap-2 mt-4 no-print">
|
||||||
|
<button type="submit" class="btn btn-primary px-4" id="submitBtn">
|
||||||
|
<i class="bi bi-send"></i> Submit Enrollment
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary" onclick="window.print()">
|
||||||
|
<i class="bi bi-printer"></i> Print
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script>
|
||||||
|
// Disable on first submit — a double tap must not file two enrollments.
|
||||||
|
document.getElementById('enrollForm').addEventListener('submit', function () {
|
||||||
|
var btn = document.getElementById('submitBtn');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = 'Submitting…';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="robots" content="noindex, nofollow">
|
||||||
|
<title>Enrollment received — JQC</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
|
||||||
|
<style>
|
||||||
|
body { background:#f1f5f9; }
|
||||||
|
.card-wrap { max-width:600px; margin:80px auto; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card-wrap">
|
||||||
|
<div class="card shadow-sm border-0">
|
||||||
|
<div class="card-body text-center p-5">
|
||||||
|
<i class="bi bi-check-circle-fill text-success" style="font-size:3.2rem;"></i>
|
||||||
|
<h1 class="h4 mt-3 mb-2">Thank you — your enrollment form has been received.</h1>
|
||||||
|
<p class="text-muted mb-4">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
{% if reference %}
|
||||||
|
<div class="border rounded-3 p-3 bg-light d-inline-block">
|
||||||
|
<div class="text-muted small">Your reference number</div>
|
||||||
|
<div class="fw-bold" style="letter-spacing:.02em;">{{ reference }}</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted small mt-3 mb-0">
|
||||||
|
Please quote this reference if you contact us about your enrollment.
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-center text-muted small mt-3">JQC by L.T Services, Inc</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -230,6 +230,7 @@
|
|||||||
or request.endpoint == 'auth.notification_matrix'
|
or request.endpoint == 'auth.notification_matrix'
|
||||||
or request.endpoint.startswith('broadcast.')
|
or request.endpoint.startswith('broadcast.')
|
||||||
or request.endpoint.startswith('devices.')
|
or request.endpoint.startswith('devices.')
|
||||||
|
or request.endpoint.startswith('enrollment.')
|
||||||
or (request.endpoint.startswith('auth.') and 'user' in request.endpoint)
|
or (request.endpoint.startswith('auth.') and 'user' in request.endpoint)
|
||||||
) %}
|
) %}
|
||||||
<li class="nav-item dropdown">
|
<li class="nav-item dropdown">
|
||||||
@@ -258,6 +259,10 @@
|
|||||||
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('devices.') }}"
|
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('devices.') }}"
|
||||||
href="{{ url_for('devices.index') }}">Devices</a>
|
href="{{ url_for('devices.index') }}">Devices</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('enrollment.') }}"
|
||||||
|
href="{{ url_for('enrollment.admin_list') }}">Enrollment Forms</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -264,6 +264,7 @@
|
|||||||
or request.endpoint == 'auth.notification_matrix'
|
or request.endpoint == 'auth.notification_matrix'
|
||||||
or request.endpoint.startswith('broadcast.')
|
or request.endpoint.startswith('broadcast.')
|
||||||
or request.endpoint.startswith('devices.')
|
or request.endpoint.startswith('devices.')
|
||||||
|
or request.endpoint.startswith('enrollment.')
|
||||||
or (request.endpoint.startswith('auth.') and 'user' in request.endpoint)
|
or (request.endpoint.startswith('auth.') and 'user' in request.endpoint)
|
||||||
) %}
|
) %}
|
||||||
<a class="jqc-nav-link {{ 'active' if admin_active }}" data-bs-toggle="collapse"
|
<a class="jqc-nav-link {{ 'active' if admin_active }}" data-bs-toggle="collapse"
|
||||||
@@ -282,6 +283,8 @@
|
|||||||
href="{{ url_for('broadcast.index') }}">Broadcast</a>
|
href="{{ url_for('broadcast.index') }}">Broadcast</a>
|
||||||
<a class="jqc-nav-sublink {{ 'active' if request.endpoint and request.endpoint.startswith('devices.') }}"
|
<a class="jqc-nav-sublink {{ 'active' if request.endpoint and request.endpoint.startswith('devices.') }}"
|
||||||
href="{{ url_for('devices.index') }}">Devices</a>
|
href="{{ url_for('devices.index') }}">Devices</a>
|
||||||
|
<a class="jqc-nav-sublink {{ 'active' if request.endpoint and request.endpoint.startswith('enrollment.') }}"
|
||||||
|
href="{{ url_for('enrollment.admin_list') }}">Enrollment Forms</a>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user