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. |
|
||||
| `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 `<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. |
|
||||
|
||||
### 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 /<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>` |
|
||||
| `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) |
|
||||
| `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/<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.
|
||||
- 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 `<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.
|
||||
|
||||
Reference in New Issue
Block a user