Compare commits

..
35 Commits
Author SHA1 Message Date
nngo e9005b9b9c Sep 4 - Add link relavant issues function 2026-09-04 16:53:53 -04:00
nngo d291dfc513 Aug 27 - Update free-plan gate, MFA is no longer bypassable via the app 2026-08-27 12:14:09 -04:00
nngo 2d68bad966 Aug 27 - Update code to follow-up with ST functions 2026-08-27 11:54:07 -04:00
nngo 3bfd81c84c Aug 21 - Fixed Forwarded host 2026-08-21 13:14:37 -04:00
nngo d04190ba09 Aug 21 - Fernet key rotation 2026-08-21 10:18:22 -04:00
nngo cb7c244872 Aug 21 - Fix plan usage counter 2026-08-21 09:40:25 -04:00
nngo 6371b13c13 Aug 21 - MT22 logo seam fix 2026-08-21 09:19:42 -04:00
nngo 54a4a44bae Aug 20 - Session tenant binding 2026-08-20 14:24:41 -04:00
nngo 8b2582705d Aug 19 - Update test files 2026-08-19 16:34:42 -04:00
nngo a4a8d84801 Aug 19 - Re-evaluation 2026-08-19 16:26:32 -04:00
nngo 7efc9bb572 Aug 19 - Update remove Contract Work Order from the issue details 2026-08-19 15:24:53 -04:00
nngo 36d1e62151 Aug 19 - Fix New Schedule assignment crash inspector's Dashboard 2026-08-19 15:08:06 -04:00
nngo 14535ca422 Aug 19 - Update New Schedule assignment scoped to contract inspector only 2026-08-19 15:00:00 -04:00
nngo 291d566f17 Aug 19 - Fix New Inspection Internal Server error 2026-08-19 14:51:16 -04:00
nngo 88af636912 Aug 19 - Update New Inspection Schedule page layout 2026-08-19 14:43:16 -04:00
nngo 12141c2f75 Aug 19 - Update code to catch up with ST 2026-08-19 14:05:18 -04:00
nngo c9984e7ae6 Aug 11 - Update photo storage using r2 2026-08-11 11:30:57 -04:00
nngo 3c35835505 Aug 7 - Update: knowledge base MT19 2026-08-07 17:32:43 -04:00
nngo 38ab66d021 Aug 7 - Update: documents, no code update 2026-08-07 17:13:08 -04:00
nngo f3eb4badef Aug 7 - Update: Enrollment intake form 2026-08-07 16:52:49 -04:00
nngo 513f708ee9 Aug 7 - Update: UI change - MT16 2026-08-07 16:32:40 -04:00
nngo 6ca30c0dea Aug 7 - Update: add external inspector 2026-08-07 16:08:10 -04:00
nngo 97c1dec54d Aug 5 - Update code to follow up ST - Fix pending items 2026-08-05 12:40:31 -04:00
nngo 9bd6b364b7 Aug 5 - Update code to follow up ST - MT14c 2026-08-05 10:02:12 -04:00
nngo be5484e1fb Aug 5 - Update code to follow up ST - MT14b 2026-08-05 09:43:00 -04:00
nngo 45ad924df8 Aug 4 - Update code to follow up - MT13b 2026-08-04 14:51:53 -04:00
nngo 0b20e16e1f Aug 4 - Update code to follow up - MT12b (1) 2026-08-04 13:41:12 -04:00
nngo f4c80cfcef Aug 4 - Update code to follow up - MT12b 2026-08-04 13:37:29 -04:00
nngo 73ed0157fc Aug 4 - Update code to follow up - MT11 b 2026-08-04 13:24:43 -04:00
nngo 3c2489e289 Aug 4 - Update code to follow up - MT11 2026-08-04 13:00:21 -04:00
nngo ceb0b806af Jul 30 - Internal handler files 2026-07-30 12:14:04 -04:00
nngo d9bf4be709 Jul 29 - link mobile inspections to their schedule and fulfil it 2026-07-29 21:49:19 -04:00
nngo 60a9af106a Jul 29 - Photo absolute media URLs for mobile; add photo retention purge 2026-07-29 19:43:40 -04:00
nngo bd994e75e6 Jul 29 - Photo stamping files 2026-07-29 17:35:54 -04:00
nngo 7c064b6ca1 Jul 29 - Hotfix auth email sender 2026-07-29 17:20:04 -04:00
149 changed files with 24029 additions and 1285 deletions
+499 -4
View File
@@ -34,6 +34,8 @@
24. [Backup CLI](#24-backup-cli-controlbackuppy) 24. [Backup CLI](#24-backup-cli-controlbackuppy)
25. [Health Dashboard](#25-health-dashboard-health-on-panel) 25. [Health Dashboard](#25-health-dashboard-health-on-panel)
26. [Coding Rules for AI Assistants](#26-coding-rules-for-ai-assistants) 26. [Coding Rules for AI Assistants](#26-coding-rules-for-ai-assistants)
29. [Photo Object Storage (R2)](#29-photo-object-storage-r2)
30. [Database Health Check](#30-database-health-check-scriptsdb_healthpy)
--- ---
@@ -223,6 +225,8 @@ lt_janitorial_quality_control/
| `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`. |
| `DB_POOL_RECYCLE` | Optional, default `1800` (seconds). Retires a pooled connection on the **default bind** after this long. **Must stay below the server's `wait_timeout`** or MySQL closes the socket first and the next request gets `OperationalError 2006`. `scripts/db_health.py` cross-checks the two. Per-tenant engines use `TENANT_ENGINE_POOL_RECYCLE` instead. |
| `DB_POOL_SIZE` / `DB_MAX_OVERFLOW` | Optional, default `5` / `5`. Per-**worker** pool on the default bind. In MT the real ceiling is `workers x [ (default pool) + TENANT_ENGINE_CACHE_MAX x (tenant pool) ]` — the library defaults (5+10) alone put a 9-worker box at 135 against a `max_connections` of 151, before any tenant engine is counted. |
| `MULTI_TENANT_ENABLED` | `false` by default. Set `true` to activate Host→tenant routing. Requires all control-plane vars below. | | `MULTI_TENANT_ENABLED` | `false` by default. Set `true` to activate Host→tenant routing. Requires all control-plane vars below. |
| `CONTROL_DATABASE_URL` | Control-plane MySQL URI, e.g. `mysql+pymysql://jqc_control:pw@127.0.0.1/jqc_control`. Required when `MULTI_TENANT_ENABLED=true`. | | `CONTROL_DATABASE_URL` | Control-plane MySQL URI, e.g. `mysql+pymysql://jqc_control:pw@127.0.0.1/jqc_control`. Required when `MULTI_TENANT_ENABLED=true`. |
| `CONTROL_FERNET_KEY` | Fernet key for encrypting tenant DB passwords. Generate once; store in `/etc/jqc/control.env`. | | `CONTROL_FERNET_KEY` | Fernet key for encrypting tenant DB passwords. Generate once; store in `/etc/jqc/control.env`. |
@@ -362,6 +366,28 @@ notifications: id, user_id, title, body, link, is_read, created_at, issue_id,
notification_preferences: id, user_id, event_type, email_enabled, digest_mode, digest_frequency notification_preferences: id, user_id, event_type, email_enabled, digest_mode, digest_frequency
``` ```
### IssueLink (phase57)
```
issue_links: id, issue_id (FK→issues CASCADE, indexed),
linked_issue_id (FK→issues CASCADE, indexed),
link_type ENUM('duplicate','related') NOT NULL DEFAULT 'related',
created_by (FK→users SET NULL), created_at
UniqueConstraint(issue_id, linked_issue_id) — uq_issue_link
```
**Connects a duplicate to its original, or two issues about the same thing**, so whoever picks one up can reach the other.
**One row is stored per pair and shown on BOTH issues.** The stored direction carries meaning for `duplicate``issue_id` is a duplicate *of* `linked_issue_id` — so the same row reads differently at each end: "Duplicate of #B" on one, "Duplicated by #A" on the other. `related` is symmetric and reads "Related to" from either side. `IssueLink.LABELS` is keyed `(link_type, is_source)` and is the only place that wording lives; `label_for(viewing_issue_id)` / `other_issue(viewing_issue_id)` resolve a row against whichever issue is on screen.
Storing one row rather than a mirrored pair keeps the direction unambiguous and makes unlinking a single delete. The cost: **uniqueness cannot be expressed by the UniqueConstraint alone.** `(A,B)` and `(B,A)` are distinct rows to MySQL but the same link to a person, so **`IssueLink.exists_between(a, b)` is the only correct duplicate check** — it looks both ways. The constraint catches the exact-duplicate row; `exists_between()` catches the reverse.
**A link is PURELY NAVIGATIONAL.** Marking a duplicate does **not** touch either issue's status, `resolved_at`, SLA, assignee or followers, and fires no notification. Closing the duplicate stays a separate, deliberate action. Do not add side effects here without saying so in the UI — the link control reads as navigation, and a status write from it would be invisible.
**Two FKs from one table to `issues`, so both relationships pin `foreign_keys`** — the same failure mode as phase56's third `inspections``users` FK (§17), which raises on first ORM *use*, not at import. `Issue.links_from` / `Issue.links_to` are the two storage directions; **`Issue.all_links()` merges them** into the single list a person actually sees. Both relationships cascade `all, delete-orphan` (and both FKs are `ON DELETE CASCADE`), so deleting an issue takes its links from *either* end — a surviving link would render a dead row on the other issue's page.
**Nothing here is tenant-aware, deliberately.** The table lives in the tenant DB and routes through `RoutingSession`, so a link can only reach an issue in the same tenant. Scope *within* a tenant is the caller's job — **see rule 110.**
### IssueComment ### IssueComment
``` ```
@@ -428,6 +454,36 @@ notification_matrix: id, event_type, role_key, enabled, custom_emails (JSON)
UniqueConstraint(event_type, role_key) UniqueConstraint(event_type, role_key)
``` ```
### UserNotificationMatrix (phase54)
```
user_notification_matrix: id, user_id (FK→users CASCADE, indexed),
event_type VARCHAR(50), enabled BOOL
UniqueConstraint(user_id, event_type)
```
Per-account override of the global matrix, for the two customer-side roles
only. `enabled=True` = send even if the global column is OFF; `enabled=False` =
never send even if it is ON; **no row = inherit**. Setting a row back to
inherit DELETES it, which is what keeps an account that never expressed an
opinion tracking the global matrix. Helpers: `overrides_for_user()`,
`override_for()`, `overrides_for_event()`, `set_overrides()` (none commit).
See §27b and rules 100101.
### TemplateContract (phase55)
```
template_contracts: id, template_id (FK→inspection_templates CASCADE, indexed),
project_id (FK→projects CASCADE, indexed), created_at
UniqueConstraint(template_id, project_id)
```
Restricts a form to specific contracts. **No rows means the form is SHARED**
(available on every contract) — rule 102. `InspectionTemplate` helpers:
`contract_ids`, `is_shared`, `available_for_project()`, `set_contracts()` (does
not commit), and the static `available_query(project_id)` — the single
definition of "which forms may this contract use".
### InspectionSchedule (phase34) ### InspectionSchedule (phase34)
``` ```
@@ -534,7 +590,7 @@ The `DeviceRegistration` model and the duplicate `api_devices` blueprint were **
| `customers` | `/customers` | list, invite, set-password, manage, import CSV | | `customers` | `/customers` | list, invite, set-password, manage, import CSV |
| `inspections` | `/inspections` | list, start, execute, view, PDF export, flag-issue, save-draft (AJAX), flag-followup, reinspect, upload-photo (AJAX) | | `inspections` | `/inspections` | list, start, execute, view, PDF export, flag-issue, save-draft (AJAX), flag-followup, reinspect, upload-photo (AJAX) |
| `templates` | `/templates` | list, create, edit, delete, form editor, preview | | `templates` | `/templates` | list, create, edit, delete, form editor, preview |
| `issues` | `/issues` | list, view, create, update, verify, comment, follow/unfollow, verification queue, bulk-verify, delete, quick-assign | | `issues` | `/issues` | list, view, create, update, verify, comment, follow/unfollow, verification queue, bulk-verify, delete, quick-assign, **issue links** (`POST /<id>/links` add, `POST /<id>/links/<link_id>/delete` remove, `GET /<id>/link-search` scoped JSON picker — phase57) |
| `notifications` | `/notifications` | list, mark-read, preferences, send-digest (cron), check-sla (cron), cleanup-tokens (cron), trial-reminders (cron), dunning-reminders (cron) | | `notifications` | `/notifications` | list, mark-read, preferences, send-digest (cron), check-sla (cron), cleanup-tokens (cron), trial-reminders (cron), dunning-reminders (cron) |
| `audit` | `/audit` | list (admin only), view, purge | | `audit` | `/audit` | list (admin only), view, purge |
| `reports` | `/reports` | index, facility report, scorecard, CSV/PDF/Excel export, issues-aging, sla-compliance, followup-closure, facility summary PDF | | `reports` | `/reports` | index, facility report, scorecard, CSV/PDF/Excel export, issues-aging, sla-compliance, followup-closure, facility summary PDF |
@@ -812,7 +868,7 @@ limiter = Limiter(
## 17. Alembic Migration Chain ## 17. Alembic Migration Chain
**Current HEAD:** `phase40_support_chat_kb` (38 migrations total). **Current HEAD:** `phase57_issue_links`.
**Chain root:** `0003_add_user_active` — a guarded squashed baseline (MT-2) that recreates the full 25-table schema with INFORMATION_SCHEMA guards. The original baseline migrations (0001/0002/0003) were lost; this file restores the chain root so Alembic can build the revision map. `down_revision = None`. **Chain root:** `0003_add_user_active` — a guarded squashed baseline (MT-2) that recreates the full 25-table schema with INFORMATION_SCHEMA guards. The original baseline migrations (0001/0002/0003) were lost; this file restores the chain root so Alembic can build the revision map. `down_revision = None`.
@@ -846,9 +902,101 @@ limiter = Limiter(
→ phase36_issue_work_orders → phase36_issue_work_orders
→ phase37_contract_recipients → phase37_contract_recipients
→ phase38_facility_qr → phase38_facility_qr
→ phase39_issue_handler_type → phase40_support_chat_kb ← HEAD → phase39_issue_handler_type → phase40_support_chat_kb
→ phase41_auditor_role → phase42_area_qr_token → phase43_schedule_plan_fields
→ phase44_internal_handler → phase45_schedule_frequency_enum
→ phase46_schedule_recurrence → phase47_schedule_end_date
→ phase48_schedule_parent_inspection → phase49_followup_requested_by
→ phase50_sched_acknowledged → phase51_external_inspector
→ phase52_user_ui_theme → phase53_knowledge_sort_order
→ phase54_user_notif_matrix → phase55_template_contracts
→ phase56_followup_assignee → phase57_issue_links ← HEAD
``` ```
`phase54` / `phase55` port the ST August-2026 work (ST calls them phase51 /
phase52; the ids differ because MT's chain was already past those numbers —
match by NAME, not number, when comparing the two repos).
#### phase54 — per-account notification overrides
Creates `user_notification_matrix` (§5). **No backfill, deliberately** — an
empty table means every account inherits the global matrix, i.e. exactly
today's routing, so the migration cannot change who gets notified. Backfilling
from the current global columns would freeze every account at today's routing
and silently break later changes to those columns. Table-existence check —
safe to re-run.
#### phase55 — restrict forms to specific contracts
Creates `template_contracts` (§5 `TemplateContract`). **No rows for a template
means SHARED**, so every pre-existing form stays available everywhere and the
migration cannot change behaviour on deploy. Table-existence check — safe to
re-run.
#### phase56 — assign a follow-up to another inspector
Adds `inspections.follow_up_assigned_to` (FK → `users.id`, ON DELETE SET NULL)
— see §27c. **No backfill:** NULL means the follow-up belongs to the
inspection's own inspector, which is what every existing row already means, so
deploying cannot change who owns anything.
**This is the THIRD FK from `inspections` to `users`** (`inspector_id`,
`follow_up_requested_by`, and now this). Every relationship spanning the two
tables must pin `foreign_keys` explicitly or the mapper is ambiguous — and it
raises on first ORM *use*, not at import, so the app starts cleanly and then
every request 500s. Column + constraint checks — safe to re-run.
ST calls this phase53; MT's chain was already past that number. Match by NAME.
#### phase57 — link related and duplicate issues
Creates `issue_links` (§5 `IssueLink`). **Purely additive** — nothing reads the
table until a person creates a link, so an empty table is exactly today's
behaviour and there is nothing to backfill.
Both issue FKs are `ON DELETE CASCADE`, so a direct SQL delete of an issue
cannot leave a link pointing at a row that no longer exists (the ORM cascade on
`links_from`/`links_to` covers the application path). The index names
deliberately match what SQLAlchemy's `index=True` generates —
`ix_issue_links_issue_id`, `ix_issue_links_linked_issue_id` — which matters more
in MT than in ST: a tenant **bootstrapped** from the baseline and one
**upgraded** through the chain must end up with the same schema, down to the
index names.
Table-existence check — safe to re-run, as every migration from phase33 on must
be. `downgrade()` drops the table, discarding every link; no issue is affected,
since a link never held state belonging to one.
ST calls this phase54; MT's chain was already past that number. Match by NAME.
**Deploy order (tenant DBs):**
```bash
python -m control.tenant_migrate upgrade --tenant all
sudo systemctl restart gunicorn
```
**Deploy order (tenant DBs):**
```bash
python -m control.tenant_migrate upgrade --tenant all
sudo systemctl restart gunicorn
```
`phase41``phase52` are the single-tenant feature-parity track — see
MULTI_TENANT_PLAN.md §12. Two notes on that tail:
* **`phase51_external_inspector`** widens the `users.role` ENUM. It must be
deployed together with its code: MT tested `role == 'inspector'` literally in
~80 places, and widening the ENUM alone drops external inspectors into the
*unscoped* branch, which is a cross-tenant data leak rather than a cosmetic
bug. Use `User.INSPECTOR_ROLES` / `user.is_inspector`, never a literal.
* **ST's `phase50_default_modern` is deliberately NOT ported.** It overwrites
every saved `ui_theme` preference, which in MT would run against every tenant
DB. Set `DEFAULT_UI_THEME=modern` per tenant instead. See
MULTI_TENANT_PLAN.md §12.3.
Migration revision IDs must be **≤ 32 characters** to fit
`alembic_version.version_num VARCHAR(32)`.
### phase40_support_chat_kb ### phase40_support_chat_kb
Creates three tables backing support chat persistence and the AI knowledge base: Creates three tables backing support chat persistence and the AI knowledge base:
@@ -1418,6 +1566,20 @@ set -a; . /etc/jqc/control.env; set +a
| 95 | **Chat history is loaded from DB — never pass client-sent history to Groq** | phase40. `POST /support/chat/message` loads prior turns from `SupportChatMessage` (newest-first, limit 40, reversed). The JSON body sends only `{ message, session_id }` — no history array. This prevents history tampering by clients and ensures accuracy across page reloads. | | 95 | **Chat history is loaded from DB — never pass client-sent history to Groq** | phase40. `POST /support/chat/message` loads prior turns from `SupportChatMessage` (newest-first, limit 40, reversed). The JSON body sends only `{ message, session_id }` — no history array. This prevents history tampering by clients and ensures accuracy across page reloads. |
| 96 | **`db.session.flush()` to get session ID before first message insert** | When creating a new `SupportChatSession` in `chat_message()`, call `db.session.flush()` after `db.session.add(chat_session)` to get the autoincrement `id` before constructing `SupportChatMessage` rows. If Groq fails, `db.session.rollback()` undoes the flush — no orphaned empty session is left in the DB. | | 96 | **`db.session.flush()` to get session ID before first message insert** | When creating a new `SupportChatSession` in `chat_message()`, call `db.session.flush()` after `db.session.add(chat_session)` to get the autoincrement `id` before constructing `SupportChatMessage` rows. If Groq fails, `db.session.rollback()` undoes the flush — no orphaned empty session is left in the DB. |
| 97 | **`send_billing_email()` derives the From address from `APP_BASE_URL` — same pattern as rule 64** | `urlparse(app.config['APP_BASE_URL']).netloc` is extracted before the background thread starts and passed as `sender=f'noreply@{netloc}'` to `Message()`. Falls back to `MAIL_DEFAULT_SENDER` when `APP_BASE_URL` is absent or yields an empty netloc (`sender=None` triggers Flask-Mail's default). Do not hardcode a sender string or duplicate the derivation logic — extend via `send_billing_email()` only. | | 97 | **`send_billing_email()` derives the From address from `APP_BASE_URL` — same pattern as rule 64** | `urlparse(app.config['APP_BASE_URL']).netloc` is extracted before the background thread starts and passed as `sender=f'noreply@{netloc}'` to `Message()`. Falls back to `MAIL_DEFAULT_SENDER` when `APP_BASE_URL` is absent or yields an empty netloc (`sender=None` triggers Flask-Mail's default). Do not hardcode a sender string or duplicate the derivation logic — extend via `send_billing_email()` only. |
| 99 | **`User.CUSTOMER_ROLES` is for ACCOUNT MANAGEMENT; `role == 'customer'` is for CAPABILITY — never swap them** | Widening a capability check to `CUSTOMER_ROLES` hands a third-party Customer Inspector the customer portal (fails OPEN, nothing errors). Narrowing an account-management check back to `'customer'` strands every Customer Inspector in a page that no longer lists or edits them. `CUSTOMER_ROLES` / `is_customer_account` appear ONLY in: the `/customers` list query and its guards, the `auth.list_users` exclusion, the customer-facing support surface (`_is_customer_side()`), and narrowing uses that WITHHOLD something from an external account (`_assignable_staff_for()`). |
| 100 | **A per-account notification opt-IN must survive a globally-OFF column** | `notify_by_matrix()` skips a role column early when the matrix says off. For the two customer columns that `continue` must also ask `any(overrides.values())`, or the override saves, displays as on, and never sends. Equally, `notify_customers_for_facility()` re-queries recipients from assignment rows, so `notify_by_matrix()` must hand it `allowed_user_ids` or the facility-scoped path bypasses every override. Both halves are needed. |
| 101 | **Per-account overrides are enforced in `notify()`, not only `notify_by_matrix()`** | Follower fan-out and direct assignee notifications call `notify()` straight. Gating only the matrix left the editor offering rows ("Issue assigned") that read as Off while the notifications kept arriving. Only an explicit `False` suppresses; the `getattr(recipient, 'is_customer_account', False)` test is deliberate so an unavailable attribute SENDS rather than silently dropping. |
| 102 | **A template with NO `template_contracts` rows is SHARED, not hidden** | The empty set means "available on every contract" — that is what makes phase55 additive and why it needed no backfill. Reading it the other way hides every pre-existing form from every contract at once. The convention lives in exactly one place, `InspectionTemplate.available_query()`. A facility with no contract gets shared forms only (fail-closed). |
| 103 | **A bulk-action form must live OUTSIDE the table; row checkboxes join it via the HTML5 `form=` attribute** | Wrapping the table nests the per-row delete/unfollow forms inside the bulk form, and browsers silently discard nested forms (rule 9) — the row buttons post nothing, with no console error and no server log. Applies to all four list templates (classic + modern). |
| 104 | **Bulk deletes: DB rows first, storage files second** | Collect the keys, `db.session.delete()` every row, `commit()`, and only then `storage.delete()`. `_collect_inspection_photos()` is shared by the single and bulk inspection delete paths so the two cannot drift — a key missed there is an invisible permanent storage leak. |
| 105 | **The flag-issue assignee list is contract-scoped, and BOTH call sites must use `_assignable_staff_for()`; a failed flag-issue POST must return non-2xx** | `execute()` renders the dropdown, `flag_issue()` builds the choices that validate the POST — the choices are the security boundary. An org-wide list let anyone assign another client's Customer Inspector, who was then emailed the facility name and issue description. And the offcanvas JS branches on `res.ok`, so a 200 re-render of an invalid form reads as success: the panel closes, the page reloads, and no issue exists. |
| 106 | **Name the Groq model in the chat error log, and keep `_DEFAULT_GROQ_MODEL` current** | Groq retires models without notice; when the configured one disappears the API 404s and EVERY question returns the generic "problem reaching the AI assistant" reply, with nothing else broken — invisible until a customer complains. The fix needs no deploy, only `GROQ_MODEL`, which is exactly what the log line must say. |
| 107 | **`viewer_is_our_staff` in `issues/view.html` is an explicit role ALLOWLIST, and `external_inspector` is absent on purpose** | `not current_user.is_customer_account` fails OPEN — a missing attribute yields Jinja `Undefined`, `not Undefined` is true, and the internal-process chrome renders for exactly the accounts it must be hidden from. This is not a rule-87 violation: rule 87 governs capability/scoping, where a Customer Inspector must behave like our inspector; this asks "does this person work for us?", the one place the two genuinely differ. |
| 108 | **A follow-up has exactly ONE owner: use `follow_up_owner` (row) / `follow_up_owned_by()` (query) — never re-derive it** | Assignee when set, original inspector otherwise. The API's two arms must be mutually exclusive (`follow_up_assigned_to == me` OR `assigned_to IS NULL AND inspector_id == me`); drop the `IS NULL` and two people turn up for the same re-inspection. The authorship filter must be DEFERRED when `follow_up_required=true` is requested, or the rows the assignee needs are hidden before the ownership test runs. |
| 109 | **Inspector READ access is facility scope; WRITE access is authorship** | `index()` lists by facility (rule 58), so `view()`/`export_pdf()` must too — scoping reads by authorship made the list offer rows that said "Access denied" on click, and locked the follow-up assignee out of the parent inspection. `execute`, `save_draft_ajax`, `upload_photo_ajax` and `flag_issue` keep the authorship check: readable is not editable. |
| 110 | **An issue link is a pointer to another issue — filter it by scope on ALL THREE surfaces** | A link exposes the far issue's id, description, facility and status, so an unfiltered panel lets a customer read an issue at a facility they hold no assignment to, simply because one of our staff linked it. Three surfaces have to hold the line and only one is a real boundary: `_readable_links()` filters what the panel RENDERS, `link_search()` scopes what the picker FINDS, and `add_link()` re-checks on POST — the search is a convenience and must never be trusted as the gate. All three resolve scope through `_viewer_facility_scope()` / `_issue_in_scope()`, the same pair `issues.view()` now uses, so the panel cannot end up more permissive than the page it sits on. `_issue_in_scope` takes a resolved scope rather than a user, so filtering a list costs one assignment query, not one per row. A link to an issue outside your scope reports "not found", never "access denied" — whether another customer's issue exists is not something the link box should confirm. Cross-TENANT isolation is a different layer and is already handled: `RoutingSession` has bound the session to `g.tenant`'s database, so an id from another tenant does not resolve at all. |
| 111 | **The MT connection ceiling is `workers x [ default pool + cache_cap x tenant pool ]` — never just the default pool** | `config.SQLALCHEMY_ENGINE_OPTIONS` governs only the default bind; in MT nearly every request runs on a per-tenant engine from `app/tenancy/engine_cache.py` with its own `TENANT_ENGINE_*` pool, and each worker caches up to `TENANT_ENGINE_CACHE_MAX` of them. Sizing against the default pool alone is how a box reaches "Too many connections" while the health check reports headroom. `scripts/db_health.py` does the full arithmetic; the cache cap is the multiplier worth lowering first. Both pools set `pool_pre_ping` — without it an idle overnight surfaces as `OperationalError 2006` on the next request. |
| 112 | **The SLA cron narrows to candidates in SQL; the prefilter is a conservative SUPERSET, never an equality** | `send_sla_alerts()` runs every 30 minutes **per tenant**, so reading the whole open-issue backlog to decide in Python multiplied by the tenant count. The three filters each mirror a `continue` in the loop, and one of them — `reported_at IS NOT NULL` — is a correctness fix, not a speed one: `sla_status()` raises `TypeError` on a NULL and one such row aborted that tenant's entire run. The prefilter deliberately does NOT replicate the "already notified at_risk and still only at_risk" skip (that would mean writing the per-severity deadline arithmetic a second time, in SQL); the loop still applies it, so extra rows are read but no extra notification is sent. Pinned by `tests/test_sla_candidate_query.py` — a change that makes the prefilter narrower than the loop is a silently unsent alert. |
| 98 | **Reports R1 + R2 contract cascade is client-side only — facility_id is the sole DB filter** | The Contract dropdown in `reports/issues_aging.html` and `reports/sla_compliance.html` has no `name` attribute and is never submitted. It exists only to narrow the Facility `<select>` in the browser via `GET /inspections/facilities_for_project/<id>`. The routes receive and filter on `facility_id`; `contract_id` plays no role server-side. Do not add server-side `contract_id` filtering to these routes — it would duplicate what `facility_id` already provides. | | 98 | **Reports R1 + R2 contract cascade is client-side only — facility_id is the sole DB filter** | The Contract dropdown in `reports/issues_aging.html` and `reports/sla_compliance.html` has no `name` attribute and is never submitted. It exists only to narrow the Facility `<select>` in the browser via `GET /inspections/facilities_for_project/<id>`. The routes receive and filter on `facility_id`; `contract_id` plays no role server-side. Do not add server-side `contract_id` filtering to these routes — it would duplicate what `facility_id` already provides. |
--- ---
@@ -1734,7 +1896,213 @@ Row highlights: yellow = suspended, red = trial expired.
--- ---
## 26. Coding Rules for AI Assistants ## 26. Web Portal Design (MT-16)
Two designs share one set of page templates.
* `base.html` is a **one-line dispatcher**: `{% extends jqc_layout %}`. Page
templates keep `{% extends "base.html" %}` and need no edits.
* `layouts/classic.html` is the original chrome, verbatim.
`layouts/modern.html` is the sidebar shell.
* `jqc_layout` comes from `inject_ui_theme()` in `app/__init__.py`, driven by
`users.ui_theme` with config `DEFAULT_UI_THEME` as the fallback.
* Per-page overrides live at `templates/modern/<same path>.html` and are indexed
once at boot. Look for `UI themes | modern overrides indexed: N` in the log —
`0` on a host that should have them means the directory did not deploy.
**Do not move the template swap into the Jinja loader.** It lives in
`ThemedEnvironment.get_template()` so the template cache is keyed on the
*rewritten* name. A loader-level swap caches under the original name, so a
modern template can be served to a classic user — and in MT, where one worker
serves many tenants, across tenants.
When adding a page: write it once as a normal template. Only add a
`modern/` override if the layout genuinely differs; styling alone is handled by
`static/css/theme_modern.css`, which is scoped to `body.jqc-modern`.
---
## 27. Roles (MT-15)
`users.role` ENUM: `admin`, `director`, `inspector`, `external_inspector`,
`project_manager`, `customer`, `auditor`.
**Never test `role == 'inspector'`.** `external_inspector` (customer /
third-party inspectors) has identical capabilities and identical
`InspectorAssignment` scoping. Use:
* `user.is_inspector` — true for both inspector roles; use for every capability
and scoping check
* `User.INSPECTOR_ROLES` — for `User.role.in_(...)` queries
* `user.is_external_inspector` — only where the two genuinely differ (display)
* `user.role_label` / `ROLE_LABELS` — for any role name shown in the UI
A literal comparison sends external inspectors down the unscoped branch, where
`get_inspector_scope()` returns `None` and every downstream query drops its
facility filter. That is a cross-customer leak.
External inspectors are **invited**, never given a password: `password_set=False`
plus an emailed 72-hour token, with `auth.resend_invite` for bounced invitations.
---
## 27b. Customer-side roles, per-account notifications, per-contract forms (Aug 2026 ST parity)
Ported from the single-tenant app (its phase51 + phase52 + the August fixes).
Nothing here is tenant-aware in its own right — every table lives in the tenant
DB and every query routes through `RoutingSession` as usual.
### The two customer-side roles
| Stored ENUM value | Display label | Scoped by | Capabilities |
|---|---|---|---|
| `customer` | **Customer Director** | `CustomerAssignment` | The portal, unchanged — plus planning scheduled inspections at their own facilities |
| `external_inspector` | **Customer Inspector** | `InspectorAssignment` | Identical to the internal `inspector`, plus the customer support surface (AI chat + tickets) |
**LABEL-only rename** — the ENUM values are untouched, so no migration and no
role check moved. `User.ROLE_LABELS` is the one place the names live.
`User.CUSTOMER_ROLES = ('customer', 'external_inspector')` and
`User.is_customer_account` answer an **account-management** question ("is this
managed under `/customers`?"). Every **capability** check — portal gates,
`@customer_required`, `get_customer_scope()`, `notify_customers_for_facility()`,
the customer branch of each `app/api/*` module — keeps testing
`role == 'customer'` exactly (rule 99).
Both roles are now created, invited, assigned and switched in **Customer
Management** (`/customers`); `auth.list_users` excludes them and the
`/auth/users/...` URLs redirect to `customers.manage`. `UserForm` no longer
offers `external_inspector`, and `auth.create_user` always requires a password
— customer-side accounts are invited (they choose their own username and
password) via `customers.create()`. `POST /customers/<id>/switch-role`
(admin only) mirrors contracts across the two scoping tables and revokes the
account's refresh tokens + device rows, because API access differs between them.
### `UserNotificationMatrix` (per-account overrides)
Row `enabled=True` = send even if the global column is OFF; `enabled=False` =
never send even if it is ON; **no row = inherit**. Inherit is the default, so
the table shipped empty and changed routing for nobody, and setting a row back
to inherit DELETES it. Helpers live in
`app/models/user_notification_matrix.py`; edited on the account's Customer
Management page as a tri-state. Enforced in **`notify()` as well as**
`notify_by_matrix()` — follower fan-out and direct assignee notifications reach
`notify()` straight, so gating only the matrix left rows that read as Off while
notifications kept arriving. See rules 100 and 101.
### `TemplateContract` (forms per contract)
`InspectionTemplate.available_query(project_id)` is the single definition of
"which forms may this contract use" — pickers, the POST validation behind them,
the schedule form and `GET /api/v1/templates` all call it. **No rows = shared**
(rule 102). Managed in three places: the template list's Edit modal
(`POST /templates/<id>/rename`, carrying a hidden `contracts_present=1`
marker), Create Template, and the full form editor. `duplicate_template()`
copies the restrictions.
### Other ported behaviour
* **Bulk actions** on the issues and inspections lists (`POST /issues/bulk`,
`POST /inspections/bulk`) with shared partials in `templates/partials/`.
Toolbar form sits OUTSIDE the table; row checkboxes join it with the HTML5
`form=` attribute (rule 103). Deletes remove DB rows first, storage keys
second (rule 104).
* **List filter preservation** — `current_url()` (Jinja global) +
`return_url(fallback)` (`utils/decorators`) round-trip the full list URL as
`next`, so an edit or delete returns to the filtered page.
`safe_redirect_url` still guards every hop.
* **Flag-issue assignee scoping** — `_assignable_staff_for(inspection, actor)`
in `routes/inspections.py` is the single source for both the offcanvas
dropdown and `form.assigned_to.choices` (the actual POST validation). A
failed flag-issue POST now returns **400**, because the offcanvas JS branches
on `res.ok` (rule 105).
* **Customer Directors plan inspections** — `schedule_manager_required` in
`routes/inspection_schedules.py` = the manager set plus `role == 'customer'`.
Because that blueprint builds its form by hand (no WTForms SelectField),
narrowing the choice lists is NOT the validation: `_scope_errors()`
re-checks facility, inspector and form-vs-contract on every POST, and
`_schedule_in_scope()` guards edit/delete. Start remains the assignee's.
* **Support chat serves both customer roles** — `_is_customer_side()` opens the
door, then `_support_facilities()` branches per role (CustomerAssignment vs
InspectorAssignment) and `_system_prompt_for()` appends
`_INSPECTOR_ADDENDUM` for a Customer Inspector. The curated knowledge base is
now spliced in **before** the `Rules:` heading (`_STYLE_MARKER`) — appended
after it, the prompt's own "ground answers in everything above" put it out of
scope, which is why KB entries looked ignored. `/support/admin/knowledge/preview`
shows the exact prompt. `GROQ_MODEL` defaults to `_DEFAULT_GROQ_MODEL`
(`openai/gpt-oss-120b`); Groq retires models without notice, and the error
handler names the model and says to set `GROQ_MODEL` (rule 106).
* **`COMMENTS_VISIBLE_TO_ALL`** (config, default true) lifts the phase22 read
filter so customers see every comment. `is_customer_visible` is still
written, so flipping it back restores the old behaviour with nothing to
repair. `issues/view.html` gates the internal chrome on
`viewer_is_our_staff` — an explicit allowlist of OUR roles, which fails
closed and deliberately excludes `external_inspector` (rule 107).
---
## 27c. Follow-up assignment + inspector read access (Aug 2026 ST parity)
Ported from ST (its phase53 + the two fixes shipped beside it).
### Assigning a follow-up (phase56)
A follow-up used to belong implicitly to whoever performed the original
inspection. `inspections.follow_up_assigned_to` lets a director — or a
**Customer Director**, for their own facilities — hand the re-inspection to
someone else. `Inspection.follow_up_owner` (assignee *or* inspector) is the
single definition of ownership, so the web display, the notification and the
mobile API filter cannot disagree.
**The assignee takes over**: only the owner is notified, and only the owner
sees it. In `GET /api/v1/inspections?follow_up_required=true` the two arms are
mutually exclusive — without `is_(None)` on the second arm the original
inspector keeps seeing a follow-up handed to someone else and two people turn
up to do it. The generic "inspectors see only their own inspections" filter is
**deferred** when follow-ups are requested, because an assigned follow-up lives
on an inspection somebody else performed.
The picker (`_followup_assignees_for()`) is contract-scoped for the same reason
the flag-issue list is (rule 105), offers only the two INSPECTOR roles, and the
POST re-validates against it. A facility with no contract offers nobody —
fail-closed, the follow-up stays with the original inspector.
**MT-only gap closed on the way:** MT's `GET /api/v1/inspections` had no
`follow_up_required` filter at all, so the iPad's Follow-up Requests screen
received the inspector's entire history. The filter now matches the web's
definition of "follow-up" — flagged, completed, and not yet answered by a
linked re-inspection (`~follow_ups.any()`).
### Inspector READ access follows the list, not authorship
`index()` scopes an inspector by FACILITY (rule 58), but `view()` and
`export_pdf()` scoped by authorship — so the list offered rows that answered
"Access denied" on click, and the follow-up assignee could not open the parent
inspection they had just been asked to re-inspect. Both reads now use
`_inspector_may_read()` (facility scope). **Writes stay owner-only**: `execute`,
`save_draft_ajax`, `upload_photo_ajax` and `flag_issue` keep the authorship
check. `reinspect()` belongs to the follow-up's owner; the buttons render only
for `is_own_inspection or owns_follow_up`, so the page never shows a control
that fails on click.
### One rule, two expressions, three callers
Ownership has to be stated twice — once for a loaded row, once in SQL — so both
live together in `models/inspection.py`:
* `follow_up_owner` — the property (assignee, else inspector)
* `follow_up_owned_by(user_id)` — the query predicate
Every query that scopes follow-ups calls the predicate: the mobile list filter,
the web dashboard card, and the iPad stats KPI. They each used to write their
own version and three tested AUTHORSHIP, so an assignee saw the work in their
list while both dashboards read 0 — the stats KPI sitting directly above the
Follow-up Requests list it disagreed with. Fixed in ST at the same time.
Pinned by `tests/test_followup_ownership.py`.
---
## 28. Coding Rules for AI Assistants
These rules apply to every change made to this codebase, without exception. These rules apply to every change made to this codebase, without exception.
@@ -1866,3 +2234,130 @@ curl -sI -H "Host: ztest.jqc.app" http://127.0.0.1:8000/ | head -2
# 6. Add to /etc/jqc/control.env: MULTI_TENANT_ENABLED=true # 6. Add to /etc/jqc/control.env: MULTI_TENANT_ENABLED=true
# 7. sudo systemctl daemon-reload && sudo systemctl restart jqc # 7. sudo systemctl daemon-reload && sudo systemctl restart jqc
``` ```
---
## 29. Photo Object Storage (R2)
**Goal:** photo **files** live in Cloudflare R2 (S3-compatible, $0 egress), not on
the server's local disk. The DB is not the bottleneck — rows are tiny, PDFs are
streamed via `BytesIO` and never written to disk. Only photos accumulate, and in
a multi-tenant deployment they accumulate from every tenant onto one volume.
**No schema change, ever.** The DB stores an unprefixed relative path
(`uploads/issue_photos/abc.jpg`) and continues to. That string is the storage
**key**; `app/utils/storage.py` maps it to a backend.
### Key mapping (the rule that matters)
| Layer | Value |
|---|---|
| DB (`issues.photo_path`, `result_photos[]`, `mobile_photo_paths[]`, `inspections.form_data`, `inspection_results.photo_path`) | `uploads/issue_photos/abc.jpg` |
| Local backend, on disk | `app/static/uploads/issue_photos/abc.jpg` |
| S3 backend, object key | `t<tenant_id>/uploads/issue_photos/abc.jpg` |
The `t<tenant_id>/` prefix is applied **only** inside `S3Backend._object_key()`,
from `g.tenant`. It never enters the DB, a template, or an API payload — so the
tenant DB stays portable and every caller stays tenant-agnostic. The **local**
backend deliberately does not prefix: prefixing would relocate every existing
file, and the local layout must remain byte-identical to what predates the seam.
Local mode therefore shares one uploads directory across tenants — an isolation
weakness inherited from before multi-tenancy, and the reason to move to `s3`.
`S3Backend.save()` **raises** when `MULTI_TENANT_ENABLED` is true and no tenant
is bound, rather than writing an unprefixed key that a second tenant could later
collide with. Reads are more forgiving: `read()` / `exists()` / `delete()` try
the prefixed key and then the bare key, so objects written before the prefix
existed stay reachable. Pinned by `tests/test_storage_backend.py`.
### Config (all env, per deployment)
`STORAGE_BACKEND=local|s3` (default `local`), plus `R2_ENDPOINT_URL`,
`R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_BUCKET`, `R2_PRESIGN_TTL`
(default 86400), `R2_MEDIA_FALLBACK` (default false). `boto3` is imported lazily,
so `local` deployments never touch it — but it **is** in `requirements.txt`,
because `STORAGE_BACKEND=s3` fails at first upload without it.
`STORAGE_BACKEND` is process-wide, not per-tenant: one bucket, one backend, all
tenants, isolated by prefix. A per-tenant backend would need the resolver to
carry a storage selector and `get_backend()` to cache per tenant instead of per
app — do not half-build it.
**CSP:** `set_security_headers` in `app/__init__.py` derives the R2 host from
`R2_ENDPOINT_URL` and appends it to `img-src` automatically. Presigned images are
blocked by the browser without this. A custom R2 domain must be added too.
### Operator scripts
- `scripts/audit_photos.py` — read-only. Per tenant, collects every key its DB
references and reconciles against disk. Records the baseline that must still
resolve after cutover, and flags any key claimed by more than one tenant.
- `scripts/migrate_photos_to_r2.py` — copy-only, idempotent, resumable,
MD5+size verified. Uploads each tenant's referenced files to `t<id>/…`.
Exit 0 only when everything verifies. Orphans (referenced by no tenant) are
**not** uploaded — no prefix could legitimately claim them.
Both establish file ownership from tenant DB references, because the shared
local directory carries none. Both need `CONTROL_DATABASE_URL` +
`CONTROL_FERNET_KEY`; neither writes to any database.
**Rollback is one env var:** `STORAGE_BACKEND=local` + restart. The sync never
deletes local files, so the old tree is intact indefinitely.
---
## 30. Database Health Check (`scripts/db_health.py`)
A standalone operations tool for the MySQL side. It imports the app factory for
config and nothing else — no request layer, no uploads tree — and the plain
invocation is **strictly read-only** (INFORMATION_SCHEMA / SHOW / EXPLAIN only).
```bash
set -a; . /etc/jqc/control.env; set +a # needed for any --tenant but 'default'
python scripts/db_health.py # default bind, read-only
python scripts/db_health.py --tenant all # every tenant schema
python scripts/db_health.py --tenant acme --json /tmp/db.json
python scripts/db_health.py --tenant all --analyze # refresh optimizer stats (safe)
python scripts/db_health.py --tenant acme --optimize --yes # rebuild (LOCKS)
python scripts/db_health.py --tenant all \
--emit-migration migrations/versions/phase58_perf_indexes.py \
--revision phase58_perf_indexes
```
**Every tenant has its own database built from the same chain, so a schema check
is per tenant, not per deployment.** `--tenant` takes `default` (the
`SQLALCHEMY_DATABASE_URI` bind — the whole story in single-tenant mode, and the
fallback bind in MT), a slug, or `all`. Tenant modes resolve through the control
plane exactly as `control/backup.py` does, and open each tenant with a
`NullPool` engine: a one-shot CLI must not open a full pool per tenant against
the very `max_connections` it is checking.
The **server-level** checks (`max_connections`, `wait_timeout`,
`innodb_buffer_pool_size`, slow-query log, STRICT mode) describe the MySQL
instance, not a schema, so they run **once** against the first database opened.
Everything schema-shaped — hygiene, footprint, missing/redundant indexes,
unindexed FKs, EXPLAIN — runs per tenant, and each finding is tagged with the
tenant it came from.
**It never DROPs anything.** Redundant indexes are reported with the SQL to run
by hand, because "unused" is a judgement the tool should not make for you. It
also refuses to offer an **FK-backed** index as a drop candidate — dropping one
fails with errno 150.
**Prefer `--emit-migration` over `--apply-indexes` in MT.** An index applied by
hand to one tenant leaves every other tenant's schema different from it; a
migration reaches all of them through the normal
`python -m control.tenant_migrate upgrade --tenant all`. `--emit-migration`
writes ONE re-runnable migration for the union of what every inspected tenant is
missing (the chain is shared — emitting one per tenant would produce conflicting
revisions), with INFORMATION_SCHEMA guards per rule 16. It guesses
`down_revision` from the versions directory — confirm against
`python -m control.tenant_migrate heads` before committing.
`RECOMMENDED_INDEXES` in the script is the **single place** the index wish-list
lives, and every entry names the query that justifies it. An index nothing runs
is pure write-amplification, so keep speculative entries out — and when a new
hot query lands, add its index there rather than to an ad-hoc migration, so the
checker keeps agreeing with the schema.
The connection-ceiling check is MT-aware — see **rule 111**.
+214
View File
@@ -3,6 +3,8 @@
> **Audience:** AI assistants and developers extending JQC into a multi-tenant SaaS. > **Audience:** AI assistants and developers extending JQC into a multi-tenant SaaS.
> **Companion to:** `CLAUDE.md` (single-tenant architecture reference). > **Companion to:** `CLAUDE.md` (single-tenant architecture reference).
> **Status:** MT-0 through MT-8 complete and deployed (MT-8 billing is flag-gated behind `BILLING_ENABLED`, default off). MT-9 (iOS multi-tenant) is fully pending — both the server-side discovery endpoints and the iOS client are unbuilt. > **Status:** MT-0 through MT-8 complete and deployed (MT-8 billing is flag-gated behind `BILLING_ENABLED`, default off). MT-9 (iOS multi-tenant) is fully pending — both the server-side discovery endpoints and the iOS client are unbuilt.
>
> **Feature parity with the single-tenant tree (MT-10 → MT-17): complete.** MT forked from ST before ST kept shipping, and that gap has now been closed phase by phase — see §12. The only deliberate divergence is ST's `phase50_default_modern`, which MT does not adopt (§12.3). Tenant migration head: `phase52_user_ui_theme`.
--- ---
@@ -225,6 +227,61 @@ Stripe per-plan subscription, fully implemented in `app/billing/` (`routes.py`,
**MT-9 — iOS multi-tenant. 🔲 PENDING (server + client both unbuilt).** **MT-9 — iOS multi-tenant. 🔲 PENDING (server + client both unbuilt).**
Planned server side: `GET /api/v1/discover?subdomain=acme` and `GET /api/v1/tenant` public endpoints — to be exempt from tenant middleware via `MULTI_TENANT_EXEMPT_PATHS`. **Neither endpoint exists in the code yet** — no `api_discovery` blueprint is registered. iOS side: also pending (web-first priority). Planned server side: `GET /api/v1/discover?subdomain=acme` and `GET /api/v1/tenant` public endpoints — to be exempt from tenant middleware via `MULTI_TENANT_EXEMPT_PATHS`. **Neither endpoint exists in the code yet** — no `api_discovery` blueprint is registered. iOS side: also pending (web-first priority).
**MT-20 — R2 photo storage cutover. ✅ CODE DONE / 🔲 CUTOVER PENDING.**
No schema change, no migration. The data-plane code was already fully on the
storage seam (`storage.save()` / `media_url()` / `materialize_to_dir()` /
`delete()` at every inspection and issue photo site) — MT served local purely
because `STORAGE_BACKEND` had never been flipped, and it **could not** be:
- `boto3` was missing from `requirements.txt`, so `S3Backend.__init__`'s lazy
import would have raised `ModuleNotFoundError` on the first upload after the
flip — at request time, not at boot. Now pinned (`boto3>=1.34`, matching ST).
- there was no cutover tooling, and ST's could not be reused (below).
**Why ST's sync script does not port.** The local backend deliberately does not
prefix keys, so one shared `app/static/uploads/` holds every tenant's photos and
a file on disk carries no ownership marker. ST's script walks the disk and
uploads everything, which on MT writes objects with no tenant prefix — keys
`S3Backend._object_key()` will never read. Ownership must instead be derived
from each tenant DB's references, then written under `t<tenant_id>/`.
| Layer | Value |
|---|---|
| DB | `uploads/issue_photos/abc.jpg` |
| Disk (local backend) | `app/static/uploads/issue_photos/abc.jpg` |
| R2 object (s3 backend) | `t3/uploads/issue_photos/abc.jpg` |
Delivered:
- `scripts/audit_photos.py` — read-only. Per tenant, collects every key its DB
references (5 sources: `issues.photo_path`, `.mobile_photo_paths[]`,
`.result_photos[]`, `inspections.form_data`, `inspection_results.photo_path`)
and reconciles against disk. Emits the baseline count that must still resolve
after cutover, plus orphans and any key claimed by more than one tenant.
- `scripts/migrate_photos_to_r2.py` — copy-only, idempotent, resumable,
MD5+size verified, tenant-prefixed. Exit 0 only on full verification.
Orphans are **not** uploaded: no prefix could legitimately claim them.
- `tests/test_storage_backend.py` — pins the prefix arithmetic, the bare key
returned to the DB, the refusal to write unprefixed with no tenant bound, and
the legacy-unprefixed read/delete fallback.
Both scripts read the control DB then each tenant DB via raw SQL (no Flask app
context — the ORM's default bind is the wrong database for every tenant), and
the sync imports `collect_referenced` from the audit script so the two key sets
can never diverge. Neither writes to any database.
`STORAGE_BACKEND` is process-wide, so cutover is all-tenants-at-once; isolation
comes from the prefix, not from separate backends. Per-tenant backend selection
would require the resolver to carry a storage selector and `get_backend()` to
cache per tenant rather than per app — do not half-build it.
Rollback is one env var (`STORAGE_BACKEND=local` + restart); the sync never
deletes local files.
**Open, deliberately not done in MT-20:** `routes/tenant_settings.py::_save_logo`
still writes tenant logos directly to `static/uploads/logos/` with `os.path.join`,
bypassing the seam. After cutover it is the only remaining local-disk writer, so
logos would sit outside whatever backs up R2.
--- ---
## 8. Tenant-zero (LT Services) migration ## 8. Tenant-zero (LT Services) migration
@@ -329,3 +386,160 @@ python -m control.tenant_migrate bootstrap --tenant acme # fresh DB only
sudo systemctl status jqc-panel sudo systemctl status jqc-panel
sudo systemctl restart jqc-panel sudo systemctl restart jqc-panel
``` ```
---
## 12. Feature parity with the single-tenant tree (MT-10 → MT-17)
MT forked from the single-tenant codebase (`LT_Janitorial_Quality_Control`, "ST")
before ST continued shipping features. This section records how that gap was
closed. It is **complete** as of MT-17.
### 12.1 Why the two trees look more different than they are
A file-by-file comparison of the two trees overstates the gap. Several ST files
have no MT counterpart *by name* while the feature is fully present under MT's
own naming. These are **not** gaps and must not be "fixed":
| ST | MT equivalent |
|---|---|
| `models/scheduled_inspection.py` | `models/inspection_schedule.py` |
| `routes/scheduled_inspections.py` | `routes/inspection_schedules.py` |
| `routes/public.py` | `routes/facility_qr.py` |
| `models/notification_recipient.py` | `models/project_recipient.py` |
| `ContractNotificationRecipient` / `get_event_types()` | `ProjectNotificationRecipient` / `get_events()` |
| `app/add_form_schema.py` | `scripts/add_form_schema.py` |
| `support/admin_conversation_detail.html` | `support/conversation_detail.html` |
There is also one place where **MT is ahead of ST**: `utils/forms.py`
`strong_password()` (length + complexity + common-password blocklist) versus
ST's bare `Length(min=6)`. Porting ST's version would be a downgrade.
### 12.2 Phase log
Migrations `phase41``phase56` in `migrations/versions/` are the parity track:
auditor role, area QR tokens, schedule plan fields, internal handler, frequency
ENUM widening, recurrence, end date, parent inspection, follow-up attribution,
schedule acknowledgement, and then:
**Aug 2026 tail — ST parity, ported after MT-17.** ST's chain numbers these
differently (its phase51/52/53); MT's was already past those numbers, so match
by NAME:
| MT | ST | What |
|---|---|---|
| `phase54_user_notif_matrix` | phase51 | Per-account notification overrides for the two customer-side roles. Empty table = everyone inherits the global matrix, so it shipped changing nothing. See CLAUDE.md §27b. |
| `phase55_template_contracts` | phase52 | Forms restricted to specific contracts. **No rows = shared**, which is why it needed no backfill. |
| `phase56_followup_assignee` | phase53 | `inspections.follow_up_assigned_to` — hand a re-inspection to another inspector. NULL = the inspection's own inspector, as before. See CLAUDE.md §27c. |
Shipped alongside them, without migrations: customer-role management under
`/customers`, bulk actions on the issues/inspections lists, list-filter
preservation, contract-scoped flag-issue assignees, Customer Directors planning
their own inspections, and the support surface serving both customer roles.
**MT-15 — External Inspector role. ✅ DONE** (`phase51_external_inspector`)
Adds `external_inspector` to the `users.role` ENUM: an inspector employed by the
customer or a third party, with identical capabilities to `inspector` and scoped
the same way through `InspectorAssignment`.
The ENUM widening and the code **must ship together**. MT had ~80 sites testing
`role == 'inspector'` with a literal comparison; widening the ENUM alone would
make every one of them evaluate False for the new role and fall through to the
*unscoped* branch — `get_inspector_scope()` returns `None`, downstream queries
drop their facility filter, and an inspector employed by one customer sees every
other customer's contracts. `User.INSPECTOR_ROLES` (exposed as the
`is_inspector` property) is now the single definition, and
`test_external_inspector_scope_is_not_unrestricted` fails loudly if anyone
reverts a membership test to a literal.
Also in this phase: external inspectors are **invited**, never given a password
(`password_set=False` + emailed 72-hour token, reusing the customer invite mail),
with a new `auth.resend_invite` route so a bounced invitation cannot brick an
account permanently. Creating any other role with a blank password is now
rejected — it previously stored the hash of the empty string.
**MT-16 — Modern web portal design. ✅ DONE** (`phase52_user_ui_theme`)
Ports ST's `phase48`. `base.html` became a one-line dispatcher
(`{% extends jqc_layout %}`); the old chrome moved verbatim to
`layouts/classic.html`; `layouts/modern.html` is the sidebar shell. All existing
page templates needed **zero edits** — Jinja resolves `{% block %}` overrides
through the whole inheritance chain.
`ThemedEnvironment.get_template()` swaps `x.html``modern/x.html` for modern
users. **The swap is in `get_template()`, not the loader, on purpose:** Jinja's
template cache is keyed on the name `get_template()` receives, so a cached
modern template can never be served to a classic user — and in MT, where one
Gunicorn worker serves many tenants, a loader-level swap would leak across
tenants too.
MT-specific adaptations that ST's files required: tenant branding (ST hardcodes
its own company name), `inspection_schedules` for ST's `scheduled_inspections`,
`facilities.qr_print_all` for ST's `facility_qr_print_all`, the billing banner,
`role_label` for MT-15's new role, and a rewritten tenant-neutral About page.
`_quota_warning.html` is deliberately **not** in the modern layout — it is a
per-form include, not chrome, and would render twice on four pages.
**MT-17 — Enrollment intake form. ✅ DONE** (no migration)
Ports ST's `app/enrollment/` — a public, login-free intake form plus an
admin-only inbox, kept deliberately outside the schema (flat JSON, no model, no
migration, deletable package).
**Tenant isolation was the change ST's version required.** ST keeps every
submission in one flat directory; in MT that directory is shared by every tenant
on the host, so `/enrollment/admin` would list other organisations' submissions.
Submissions are now filed under `<ENROLLMENT_DIR>/t<tenant_id>/`, mirroring
`storage.tenant_key_prefix()`. When multi-tenancy is on and no tenant is bound,
`storage.enrollment_dir()` **raises `TenantUnresolved` rather than falling back
to the root** — a fallback would be a silent cross-tenant leak; an exception is
loud and safe.
Branding was the second change: ST hardcodes its company name in four places and
a personal Gmail address as the customer-facing "corrections" contact. Both now
resolve from `TenantSettings`, with a test that greps the package so they cannot
silently return.
### 12.3 Deliberate divergence: ST `phase50_default_modern` is NOT ported
ST's `phase50` flips the `ui_theme` column default to `modern` **and** runs:
```sql
UPDATE users SET ui_theme = 'modern' WHERE ui_theme = 'classic';
```
That overwrites every saved preference. It was defensible for a single-tenant
deployment deciding for its own staff after its own A/B test.
**It is not portable to MT.** The same statement runs against *every tenant
database*, flipping the entire UI for tenants who never saw the test and never
asked. MT therefore ships the `phase48` semantics only: default `classic`, **no
backfill of any kind**.
The effective default for accounts that never chose is config
`DEFAULT_UI_THEME` (`app/__init__.py::resolve_ui_theme`), which reads the
environment and itself defaults to `classic`. A stored `users.ui_theme` always
wins. **To put a tenant on the modern design, set `DEFAULT_UI_THEME=modern` in
that tenant's process environment** — a config change, reversible, with no
preferences destroyed. `test_new_user_defaults_to_classic` pins this so a future
port of `phase50` has to be a deliberate act.
### 12.4 Open decisions
- **Seat quota.** `tenancy/quota.py::count_active_users()` counts all active
users regardless of role, so external inspectors consume a seat against
`max_users`. Intentional (they are real accounts), but tenants near their cap
will hit `@quota_soft_check('users')` when inviting third parties. Excluding
them is a billing-policy decision, not a bug fix.
- **`enrollment/schema.py::CORRECTIONS_EMAIL`** is now an empty last-resort
default. Decide whether to drop the constant and its two config fallbacks in
favour of requiring `TenantSettings.support_email`.
### 12.5 Deferred, with reasons
- **`_handler_split` dashboard cards.** ST's classic dashboard shows handler
breakdowns for *opened today* and *unassigned* as well as open issues. MT
supplies `handler_breakdown` (open issues) and both MT dashboards render it;
the other two would require converting `.count()` queries to `.all()` and
fetching full rows for a cosmetic card, which regresses large tenants. If
wanted, do it as a SQL `GROUP BY handler_type` rather than ST's Python-side
count over fetched rows.
- **PDF / audit hardening.**
- **MT-9 iOS client** (see §7).
+137 -2
View File
@@ -18,8 +18,25 @@ login_manager = LoginManager()
migrate = Migrate() migrate = Migrate()
mail = Mail() mail = Mail()
csrf = CSRFProtect() # initialized here; .init_app() called in create_app() csrf = CSRFProtect() # initialized here; .init_app() called in create_app()
def _rate_limit_key():
"""MT-21: scope rate-limit buckets per tenant as well as per client IP.
With a bare remote-address key, two tenants behind the same NAT egress
share every route's counter, so one tenant's traffic can lock another out
of (for example) /auth/login. Falls back to the plain address in
single-tenant mode and on tenant-exempt paths, leaving today's buckets
unchanged there.
"""
from flask import g, has_request_context
addr = get_remote_address()
if not has_request_context():
return addr
tenant = getattr(g, 'tenant', None)
return f't{tenant.id}|{addr}' if tenant is not None else addr
limiter = Limiter( limiter = Limiter(
key_func = get_remote_address, key_func = _rate_limit_key,
default_limits = [], # no global limit — applied per-route only default_limits = [], # no global limit — applied per-route only
# Use Redis when REDIS_URL is set in the environment (production multi-worker). # Use Redis when REDIS_URL is set in the environment (production multi-worker).
# Falls back to in-process memory for local development (single-worker only; # Falls back to in-process memory for local development (single-worker only;
@@ -28,15 +45,57 @@ limiter = Limiter(
) )
# ── Web portal design: per-request template overrides (MT-16) ────────────────
# A user on the 'modern' design gets templates/modern/<name>.html in place of
# templates/<name>.html whenever that override exists; otherwise the normal
# template is used and only the layout shell + CSS differ.
#
# The rewrite happens in get_template() (not in the loader) so Jinja's template
# cache is keyed on the REWRITTEN name — a cached modern template can never be
# served to a classic user, or vice versa. A loader-level swap would have that
# bug, and in MT it would leak across tenants sharing a worker process.
from flask.templating import Environment as _FlaskJinjaEnvironment
class ThemedEnvironment(_FlaskJinjaEnvironment):
"""Jinja environment that redirects template names to modern/<name>."""
# Populated once in create_app() by scanning templates/modern/.
jqc_modern_templates: set = set()
def get_template(self, name, parent=None, globals=None):
if (isinstance(name, str)
and self.jqc_modern_templates
and not name.startswith('modern/')):
candidate = 'modern/' + name
if candidate in self.jqc_modern_templates:
from flask import g, has_request_context
if has_request_context() and getattr(g, 'jqc_theme', 'classic') == 'modern':
name = candidate
return super().get_template(name, parent, globals)
def create_app(config_name='default'): def create_app(config_name='default'):
app = Flask(__name__) app = Flask(__name__)
# Must be assigned BEFORE app.jinja_env is first touched (it is a cached
# property), so the themed subclass is the one actually instantiated.
app.jinja_environment = ThemedEnvironment
app.config.from_object(config[config_name]) app.config.from_object(config[config_name])
# Unwrap X-Forwarded-For / X-Forwarded-Proto set by Nginx so Flask sees # Unwrap X-Forwarded-For / X-Forwarded-Proto set by Nginx so Flask sees
# the real client IP (needed for rate limiting and fail2ban logging) and # the real client IP (needed for rate limiting and fail2ban logging) and
# the real scheme (needed for HTTPS URL generation in emails). # the real scheme (needed for HTTPS URL generation in emails).
#
# MT-24: x_host is deliberately 0. With x_host=1, `request.host` was taken
# from the X-Forwarded-Host header — and nginx forwards unrecognised client
# headers upstream, so any client could supply that header and choose which
# tenant database the request bound to. Nginx already sets `Host $host`
# from the real SNI/Host, so HTTP_HOST is the trustworthy source and
# X-Forwarded-Host adds nothing but an attacker-controlled input.
# The nginx configs also pin X-Forwarded-Host explicitly (defence in depth);
# neither layer alone is relied upon. See deploy/nginx/README.md.
from werkzeug.middleware.proxy_fix import ProxyFix from werkzeug.middleware.proxy_fix import ProxyFix
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1) app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=0)
db.init_app(app) db.init_app(app)
login_manager.init_app(app) login_manager.init_app(app)
@@ -127,6 +186,74 @@ def create_app(config_name='default'):
from app.utils import storage as _storage from app.utils import storage as _storage
app.jinja_env.globals['media_url'] = _storage.media_url app.jinja_env.globals['media_url'] = _storage.media_url
# Current page URL including its query string — what list pages hand to
# their actions as `next` so filters survive an edit/delete round trip
# (see utils/decorators.return_url). full_path always appends '?', which
# is harmless but makes for ugly links, so strip a bare trailing one.
def _current_url():
from flask import request
return request.full_path.rstrip('?') if request else ''
app.jinja_env.globals['current_url'] = _current_url
# ── Web portal design wiring (MT-16) ──────────────────────────────────
# Index the modern/ override templates once at boot, so get_template()
# never has to touch the filesystem per request.
_modern_root = os.path.join(app.template_folder or 'templates', 'modern')
if not os.path.isabs(_modern_root):
_modern_root = os.path.join(app.root_path, _modern_root)
_modern_set = set()
if os.path.isdir(_modern_root):
for _dirpath, _dirnames, _filenames in os.walk(_modern_root):
for _fn in _filenames:
if _fn.endswith('.html'):
_rel = os.path.relpath(os.path.join(_dirpath, _fn), _modern_root)
_modern_set.add('modern/' + _rel.replace(os.sep, '/'))
ThemedEnvironment.jqc_modern_templates = _modern_set
app.logger.info('UI themes | modern overrides indexed: %s', len(_modern_set))
from flask import g, request as _request
@app.before_request
def resolve_ui_theme():
"""Stash the active design on `g` for ThemedEnvironment.get_template()."""
# The mobile API renders no templates and authenticates by JWT — skip it
# so this never touches the Flask-Login session loader on API traffic.
if _request.path.startswith('/api/'):
# The API renders no templates; 'classic' here only means "never
# rewrite a template name" (see ThemedEnvironment.get_template).
g.jqc_theme = 'classic'
return
from flask_login import current_user as _cu
# MT-16 — the fallback is configurable per deployment. It defaults to
# 'classic' so an existing tenant's users see no change until they opt
# in; a stored users.ui_theme always wins over the default.
default = app.config.get('DEFAULT_UI_THEME', 'classic')
theme = default
try:
if _cu.is_authenticated:
theme = _cu.ui_theme or default
except Exception: # DB column missing (migration not yet run)
theme = default
g.jqc_theme = theme if theme in ('classic', 'modern') else default
@app.context_processor
def inject_ui_theme():
"""Give base.html the shell to extend."""
from app.utils.time_utils import now_eastern
theme = getattr(g, 'jqc_theme',
app.config.get('DEFAULT_UI_THEME', 'classic'))
_now = now_eastern()
return {
'jqc_theme': theme,
'jqc_layout': 'layouts/modern.html' if theme == 'modern'
else 'layouts/classic.html',
# Long-form date shown in the modern dashboard header. The day is
# interpolated rather than formatted with '%-d' — that flag is a
# glibc extension and raises ValueError on Windows, which would
# 500 every page (this context processor runs on both themes).
'now_display': f'{_now.strftime("%A, %B")} {_now.day}, {_now.year}',
}
# ── Inject unread notification count into every template context ────── # ── Inject unread notification count into every template context ──────
# This powers the red badge on the navbar bell icon without requiring # This powers the red badge on the navbar bell icon without requiring
# individual routes to pass the count manually. # individual routes to pass the count manually.
@@ -221,6 +348,7 @@ def create_app(config_name='default'):
from app.routes import tenant_settings # MT-7 — tenant self-service from app.routes import tenant_settings # MT-7 — tenant self-service
from app.routes import signup # MT-8+ — public self-service signup from app.routes import signup # MT-8+ — public self-service signup
from app.routes import landing # Public apex marketing/landing page from app.routes import landing # Public apex marketing/landing page
from app.routes import ui # MT-16 — design switch + new pages
from app.billing import bp as billing_bp # MT-8 — Stripe billing from app.billing import bp as billing_bp # MT-8 — Stripe billing
app.register_blueprint(auth.bp) app.register_blueprint(auth.bp)
@@ -244,6 +372,13 @@ def create_app(config_name='default'):
app.register_blueprint(tenant_settings.bp) app.register_blueprint(tenant_settings.bp)
app.register_blueprint(signup.bp) app.register_blueprint(signup.bp)
app.register_blueprint(landing.bp) app.register_blueprint(landing.bp)
app.register_blueprint(ui.bp)
# ── Enrollment form (self-contained — see app/enrollment/__init__.py) ────
# Registered last and via its own helper so the package stays deletable:
# removing app/enrollment/ and these two lines removes the feature entirely.
from app.enrollment import register_enrollment
register_enrollment(app)
# Billing blueprint is CSRF-exempt: /billing/webhook receives raw POST from # Billing blueprint is CSRF-exempt: /billing/webhook receives raw POST from
# Stripe and cannot carry a CSRF token. Subscribe/portal are GET redirects # Stripe and cannot carry a CSRF token. Subscribe/portal are GET redirects
# which Flask-WTF does not protect anyway (CSRF only applies to unsafe methods). # which Flask-WTF does not protect anyway (CSRF only applies to unsafe methods).
+60
View File
@@ -32,6 +32,7 @@ import logging
from flask import Blueprint, request, g from flask import Blueprint, request, g
from app import db, limiter from app import db, limiter
from app.tenancy.gates import feature_required
from app.models.user import User from app.models.user import User
from app.models.api_token import RefreshToken, DeviceToken from app.models.api_token import RefreshToken, DeviceToken
from app.api.errors import api_ok, api_error from app.api.errors import api_ok, api_error
@@ -61,6 +62,13 @@ def _user_payload(user: User) -> dict:
@bp.route('/auth/login', methods=['POST']) @bp.route('/auth/login', methods=['POST'])
@limiter.limit('10 per minute; 3 per second') @limiter.limit('10 per minute; 3 per second')
# The plan gate belongs HERE, not only on the write endpoints. It used to sit
# on POST /inspections and POST /issues alone, so a tenant without mobile API
# access could sign in, sync reference data and let an inspector complete a
# whole inspection on site — and only then get a 403, with the work already
# done and no way to submit it. Refusing at the door is the honest answer.
# Inert in single-tenant mode and for any plan that allows the mobile API.
@feature_required('mobile_api')
def login(): def login():
""" """
Authenticate with username + password. Authenticate with username + password.
@@ -70,10 +78,22 @@ def login():
{ {
"username": "john", "username": "john",
"password": "secret", "password": "secret",
"mfa_code": "123456", // required IF the account has 2FA on
"device_id": "A1B2C3D4...", // UIDevice.identifierForVendor (optional) "device_id": "A1B2C3D4...", // UIDevice.identifierForVendor (optional)
"device_name": "John's iPhone" // (optional) "device_name": "John's iPhone" // (optional)
} }
Response 401 — second factor needed
-----------------------------------
{
"ok": false,
"error": "A verification code is required for this account.",
"mfa_required": true
}
The password was correct; the client should prompt for the 6-digit code
(or a recovery code) and POST again with `mfa_code`.
Response 200 Response 200
------------ ------------
{ {
@@ -106,6 +126,41 @@ def login():
if not user.active: if not user.active:
return api_error('Account is disabled. Please contact an administrator.', 401) return api_error('Account is disabled. Please contact an administrator.', 401)
# ── Two-factor (phase35 parity) ───────────────────────────────────────
# The web login defers identity to /auth/mfa when an account has TOTP
# enabled. This endpoint did not, so anyone who turned MFA on could skip it
# entirely by signing in through the app — the factor was decorative for
# exactly the accounts that chose to enable it.
#
# Accepts either a TOTP code or a single-use recovery code, the same two
# the web challenge accepts. A missing code is answered with
# `mfa_required: true` so a client can prompt for it rather than treating
# this as a wrong password.
if user.mfa_enabled and user.mfa_secret:
from app.utils.mfa import verify_totp, check_and_consume_recovery
code = (data.get('mfa_code') or '').strip()
if not code:
logger.info('API login | mfa_required | username=%s', user.username)
return api_error('A verification code is required for this account.',
401, extra={'mfa_required': True})
if not verify_totp(user.mfa_secret, code):
matched, remaining = check_and_consume_recovery(
user.mfa_recovery_codes, code)
if not matched:
logger.warning('API login | mfa_failed | username=%s | ip=%s',
user.username, request.remote_addr)
return api_error('That verification code is not valid.',
401, extra={'mfa_required': True})
# Recovery codes are single-use — persist the shortened list before
# any token is issued, so a crash cannot hand out a login while
# leaving the code usable again.
user.mfa_recovery_codes = remaining
db.session.commit()
logger.warning('API login | recovery_code_used | username=%s | '
'remaining=%d', user.username, len(remaining))
device_id = (data.get('device_id') or '')[:64] or None device_id = (data.get('device_id') or '')[:64] or None
device_name = (data.get('device_name') or '')[:100] or None device_name = (data.get('device_name') or '')[:100] or None
@@ -154,6 +209,11 @@ def login():
@bp.route('/auth/refresh', methods=['POST']) @bp.route('/auth/refresh', methods=['POST'])
@limiter.limit('30 per minute; 5 per second') @limiter.limit('30 per minute; 5 per second')
# Gated too: without it a device that signed in before the plan changed would
# keep rotating tokens forever and never notice it had lost access.
# logout stays open on purpose — a blocked device must still be able to
# surrender its refresh token and clean up.
@feature_required('mobile_api')
def refresh(): def refresh():
""" """
Exchange a valid refresh token for a new access token. Exchange a valid refresh token for a new access token.
+3 -2
View File
@@ -29,7 +29,8 @@ logger = logging.getLogger(__name__)
bp = Blueprint('api_comments', __name__) bp = Blueprint('api_comments', __name__)
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager', 'auditor'} _ALLOWED_ROLES = {'admin', 'director', 'inspector', 'external_inspector',
'project_manager', 'auditor'}
def _comment_payload(comment: IssueComment) -> dict: def _comment_payload(comment: IssueComment) -> dict:
@@ -47,7 +48,7 @@ def _comment_payload(comment: IssueComment) -> dict:
def _check_issue_access(issue: Issue, user) -> bool: def _check_issue_access(issue: Issue, user) -> bool:
"""Return True if user may read/write this issue. False = 403.""" """Return True if user may read/write this issue. False = 403."""
if user.role == 'inspector': if user.is_inspector:
fids = get_inspector_scope(user) fids = get_inspector_scope(user)
facility = issue.resolved_facility facility = issue.resolved_facility
if not fids or not facility or facility.id not in fids: if not fids or not facility or facility.id not in fids:
+14
View File
@@ -62,6 +62,20 @@ def jwt_required(f):
if payload is None: if payload is None:
return api_error('Access token is invalid or expired', 401) return api_error('Access token is invalid or expired', 401)
# MT-21: a token signed for another tenant verifies fine here (shared
# SECRET_KEY), so check the tenant claim before 'sub' is resolved
# against the bound database. No-op in single-tenant mode.
from app.tenancy.session_binding import current_tenant_id
tenant_id = current_tenant_id()
if tenant_id is not None:
token_tid = payload.get('tid')
if token_tid != tenant_id:
logger.warning(
'API tenant mismatch | token_tid=%s resolved=%s endpoint=%s',
token_tid, tenant_id, request.endpoint,
)
return api_error('Access token is not valid for this workspace', 401)
user_id = int(payload.get('sub', 0)) user_id = int(payload.get('sub', 0))
user = db.session.get(User, user_id) user = db.session.get(User, user_id)
+15 -4
View File
@@ -31,13 +31,24 @@ def api_ok(data=None, status=200):
}), status }), status
def api_error(message: str, status: int = 400): def api_error(message: str, status: int = 400, extra: dict | None = None):
"""Return an error JSON response.""" """Return an error JSON response.
return jsonify({
`extra` merges additional top-level keys into the envelope for flags a
client must branch on rather than parse out of the message, e.g.
`mfa_required` on a login that needs a second factor. Reserved keys
(ok/data/error) always win, so a caller cannot accidentally rewrite the
envelope's shape.
"""
payload = {
'ok': False, 'ok': False,
'data': None, 'data': None,
'error': message, 'error': message,
}), status }
if extra:
for k, v in extra.items():
payload.setdefault(k, v)
return jsonify(payload), status
# ── Registered error handlers (attached to the api blueprint) ───────────────── # ── Registered error handlers (attached to the api blueprint) ─────────────────
+260 -11
View File
@@ -35,7 +35,8 @@ logger = logging.getLogger(__name__)
bp = Blueprint('api_inspections', __name__) bp = Blueprint('api_inspections', __name__)
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager', 'auditor'} _ALLOWED_ROLES = {'admin', 'director', 'inspector', 'external_inspector',
'project_manager', 'auditor'}
def _merge_form_data(existing: dict, incoming: dict) -> dict: def _merge_form_data(existing: dict, incoming: dict) -> dict:
@@ -96,6 +97,84 @@ def _parse_datetime(value):
return None return None
def _schedule_id_from(data):
"""Read the schedule link from a request body, accepting either key.
The iPad sends `scheduled_inspection_id` (the single-tenant column name it
was built against); MT's column is `inspection_schedule_id`. Both are
accepted so shipped iPad builds keep working and a future build can migrate
to the MT name without a flag day. MT's own name wins if both are present.
"""
for key in ('inspection_schedule_id', 'scheduled_inspection_id'):
if data.get(key):
return data[key]
return None
def _resolve_schedule(schedule_id, user):
"""Resolve a client-supplied schedule id to an InspectionSchedule, or None.
The iPad sends this when the inspector taps Start on a scheduled row;
without it the inspection lands unlinked and the schedule is never fulfilled
(no link on the detail page, and the schedule stays due forever).
NON-BLOCKING BY DESIGN. A bad link drops the link and logs a warning it
never fails the submission. The app is offline-first, so a schedule can
legitimately be deleted or reassigned while a completed inspection sits in
the outbox for days; erroring here would retry-fail that inspection and
strand the inspector's work (and its photos) permanently. A missed fulfil is
recoverable from the web UI; a stranded submission is not.
The ownership check still matters: accepting a foreign link would let one
inspector fulfil another's schedule. So the link is refused — but the
inspection itself is still accepted.
"""
from app.models.inspection_schedule import InspectionSchedule
sched = db.session.get(InspectionSchedule, schedule_id)
if sched is None:
logger.warning('API INSPECTIONS | unknown schedule id=%s from user=%s '
'— submitting unlinked', schedule_id, user.username)
return None
if user.is_inspector and sched.inspector_id != user.id:
logger.warning('API INSPECTIONS | schedule id=%s not assigned to user=%s '
'— submitting unlinked', schedule_id, user.username)
return None
return sched
def _fulfill_schedule(inspection):
"""Roll the originating schedule forward. Caller commits.
Mirrors routes/inspections.py exactly, including passing `_compute_next_run`
as `next_run_fn`. As of phase46 that argument is accepted and ignored: the
cadence maths moved onto `InspectionSchedule.advance_due_date()`, which owns
the recurrence columns and the end-date boundary. Before phase46 omitting it
silently left `next_run_at` untouched and the schedule stayed permanently
due; the call is kept as-is so this file needs no behavioural change. The
deferred import mirrors the web route and avoids a module-load cycle between
the api and routes packages.
"""
if not inspection.inspection_schedule_id:
return
from app.models.inspection_schedule import InspectionSchedule
from app.routes.inspection_schedules import _compute_next_run
sched = db.session.get(InspectionSchedule, inspection.inspection_schedule_id)
if sched is None:
return
sched.fulfill(next_run_fn=_compute_next_run)
logger.info('API INSPECTIONS | schedule fulfilled | schedule=%s | inspection=%s '
'| next_due=%s', sched.id, inspection.id, sched.next_run_at)
def _media(key):
"""Absolute display URL for a storage key (presigned on R2, absolute-static
on local). '' for falsy keys. Used for iPad image rendering."""
from app.utils import storage
return storage.media_url(key, external=True) if key else ''
def _inspection_payload(inspection): def _inspection_payload(inspection):
"""Serialize an Inspection to the dict returned in API responses.""" """Serialize an Inspection to the dict returned in API responses."""
# Extract form responses from the notes JSON blob. # Extract form responses from the notes JSON blob.
@@ -137,12 +216,32 @@ def _inspection_payload(inspection):
if inspection.completed_at else None, if inspection.completed_at else None,
'mobile_local_id': inspection.mobile_local_id, 'mobile_local_id': inspection.mobile_local_id,
'form_data': form_data, 'form_data': form_data,
# Absolute display URLs for image form fields (presigned on R2,
# absolute-static on local): {field_id: url}. The iPad prefers this
# over building ServerConfig + /static/ + value.
'form_media': {
fid: _media(v)
for fid, v in (form_data or {}).items()
if isinstance(v, str) and v.startswith('uploads/')
},
'form_schema': form_schema, 'form_schema': form_schema,
'inspector_notes': inspector_notes, 'inspector_notes': inspector_notes,
# ── Follow-up / re-inspection fields ────────────────────────────── # ── Follow-up / re-inspection fields ──────────────────────────────
'follow_up_required': inspection.follow_up_required, 'follow_up_required': inspection.follow_up_required,
# phase56 — who is to perform the follow-up. NULL means the
# inspection's own inspector, which is what it always meant.
'follow_up_assigned_to': inspection.follow_up_assigned_to,
'follow_up_assigned_to_name': (inspection.follow_up_assignee.display_name
if inspection.follow_up_assignee else None),
'follow_up_note': inspection.follow_up_note, 'follow_up_note': inspection.follow_up_note,
'parent_inspection_id': inspection.parent_inspection_id, 'parent_inspection_id': inspection.parent_inspection_id,
# ── Originating schedule (MT-14) ──────────────────────────────────
# Emitted under BOTH names: `inspection_schedule_id` is MT's column,
# `scheduled_inspection_id` is the name shipped iPad builds decode.
# They always carry the same value. Drop the legacy alias once every
# deployed client has moved to the MT name.
'inspection_schedule_id': inspection.inspection_schedule_id,
'scheduled_inspection_id': inspection.inspection_schedule_id,
} }
@@ -163,6 +262,9 @@ def list_inspections():
offset int default 0 offset int default 0
facility_id int filter by facility facility_id int filter by facility
status str filter by status (completed, in_progress, flagged) status str filter by status (completed, in_progress, flagged)
follow_up_required
bool 'true'/'1' only inspections awaiting a re-inspection,
scoped to the caller's own follow-ups (see below)
from_date str ISO date (YYYY-MM-DD) include inspections on/after this date from_date str ISO date (YYYY-MM-DD) include inspections on/after this date
to_date str ISO date (YYYY-MM-DD) include inspections on/before this date to_date str ISO date (YYYY-MM-DD) include inspections on/before this date
@@ -183,13 +285,20 @@ def list_inspections():
if user.role not in _ALLOWED_ROLES: if user.role not in _ALLOWED_ROLES:
return api_error('Access denied', 403) return api_error('Access denied', 403)
limit = min(int(request.args.get('limit', 50)), 200) limit = min(request.args.get('limit', 50, type=int) or 50, 200)
offset = max(int(request.args.get('offset', 0)), 0) offset = max(request.args.get('offset', 0, type=int) or 0, 0)
query = Inspection.query query = Inspection.query
# Inspectors only see their own inspections # Inspectors only see their own inspections.
if user.role == 'inspector': #
# EXCEPT when asking for follow-up requests: a follow-up can now be handed
# to a different inspector (phase56), and that request lives on an
# inspection somebody ELSE performed. Applying this filter first would hide
# exactly the rows the assignee needs, so it is deferred to the follow-up
# block below, which applies ownership instead of authorship.
wants_follow_ups = request.args.get('follow_up_required', '').lower() in ('true', '1')
if user.is_inspector and not wants_follow_ups:
query = query.filter(Inspection.inspector_id == user.id) query = query.filter(Inspection.inspector_id == user.id)
# Optional filters # Optional filters
@@ -201,6 +310,38 @@ def list_inspections():
if status: if status:
query = query.filter(Inspection.status == status) query = query.filter(Inspection.status == status)
if wants_follow_ups:
# MT had no follow_up_required filter at all, so the iPad's Follow-up
# Requests screen — which calls ?follow_up_required=true — received the
# inspector's ENTIRE history and presented it as outstanding requests.
#
# "Follow-up" must mean exactly what it means everywhere on the web
# (inspections.index / reports status_filter == 'follow_up'): flagged,
# completed, and not yet answered by a linked re-inspection.
#
# The ~follow_ups.any() clause is the one that matters. The web execute
# route never clears follow_up_required on the parent — it only stops
# listing it once a child exists — so filtering on the flag alone would
# return follow-ups that were already satisfied on the web, forever.
# On the iPad those rows are undismissable: pull_follow_up_requests()
# keeps receiving them and update(from:) resets fulfilledLocally, so the
# FOLLOW-UP REQUESTED card would never clear. (The mobile POST path does
# clear the parent flag, so only web-completed re-inspections stick.)
query = query.filter(
Inspection.follow_up_required.is_(True),
Inspection.status == 'completed',
).filter(~Inspection.follow_ups.any())
# Ownership, not authorship (phase56). Mirrors
# Inspection.follow_up_owner: an assigned follow-up belongs to the
# assignee ALONE, an unassigned one to the inspection's own inspector.
#
# The two arms are mutually exclusive on purpose. Without the second
# arm's `is_(None)` an inspector would keep seeing a follow-up that had
# been handed to someone else, and two people would turn up to do it.
if user.is_inspector:
query = query.filter(Inspection.follow_up_owned_by(user.id))
from_date_str = request.args.get('from_date') from_date_str = request.args.get('from_date')
if from_date_str: if from_date_str:
try: try:
@@ -264,9 +405,16 @@ def create_inspection():
"overall_score": 87.5, "overall_score": 87.5,
"inspection_date": "2026-05-01T14:30:00", "inspection_date": "2026-05-01T14:30:00",
"completed_at": "2026-05-01T15:00:00", "completed_at": "2026-05-01T15:00:00",
"mobile_local_id": "uuid-string" "mobile_local_id": "uuid-string",
"inspection_schedule_id": 12
} }
`inspection_schedule_id` links the inspection to the schedule it fulfils
(sent when the inspector taps Start on a scheduled row).
`scheduled_inspection_id` is accepted as an alias for shipped iPad builds.
An unresolvable or foreign id is dropped with a warning it never fails the
submission. The schedule is rolled forward only when status is "completed".
Response 200 Response 200
------------ ------------
{ "ok": true, "data": { "inspection_id": 42, "duplicate": false } } { "ok": true, "data": { "inspection_id": 42, "duplicate": false } }
@@ -357,6 +505,29 @@ def create_inspection():
submit_latitude = None submit_latitude = None
submit_longitude = None submit_longitude = None
# ── Originating schedule (MT-14) ──────────────────────────────────────
# Sent when the inspector taps Start on a scheduled row. Resolution is
# non-blocking: an unresolvable or foreign id drops the link and logs, but
# the inspection is still accepted (see _resolve_schedule).
inspection_schedule_id = None
_sched_id = _schedule_id_from(data)
if _sched_id:
_sched = _resolve_schedule(_sched_id, user)
inspection_schedule_id = _sched.id if _sched else None
# phase48 — inherit the follow-up link from the schedule when the client
# did not send one. A schedule created by "Schedule Follow-up" knows
# which inspection it answers, so the link must not depend on the client
# remembering to pass it: an older build, or a draft resumed after the
# cached row was refreshed, would otherwise submit a plain inspection
# and leave the parent flagged forever. Never overrides an explicit
# parent_inspection_id.
if not parent_inspection_id and _sched is not None and _sched.parent_inspection_id:
parent_inspection_id = _sched.parent_inspection_id
logger.info('API INSPECTIONS | parent inherited from schedule | '
'schedule=%s | parent=%s | user=%s',
_sched.id, parent_inspection_id, user.username)
inspection = Inspection( inspection = Inspection(
template_id = template_id, template_id = template_id,
facility_id = facility_id, facility_id = facility_id,
@@ -371,11 +542,19 @@ def create_inspection():
parent_inspection_id = parent_inspection_id, parent_inspection_id = parent_inspection_id,
submit_latitude = submit_latitude, submit_latitude = submit_latitude,
submit_longitude = submit_longitude, submit_longitude = submit_longitude,
inspection_schedule_id = inspection_schedule_id,
) )
db.session.add(inspection) db.session.add(inspection)
db.session.flush() db.session.flush()
# ── Fulfil the originating schedule ───────────────────────────────────
# Staged into the same atomic commit as the inspection, mirroring the web
# route. Only on completion: an in_progress submission has not satisfied
# the occurrence, so rolling the schedule forward there would skip a cycle.
if status == 'completed':
_fulfill_schedule(inspection)
# ── Auto-clear follow-up flag on parent ─────────────────────────────── # ── Auto-clear follow-up flag on parent ───────────────────────────────
# When a completed re-inspection arrives that links to a parent, clear # When a completed re-inspection arrives that links to a parent, clear
# follow_up_required on the parent automatically. This mirrors the web # follow_up_required on the parent automatically. This mirrors the web
@@ -465,8 +644,13 @@ def update_inspection(inspection_id):
"form_data": { ... }, "form_data": { ... },
"notes": "...", "notes": "...",
"overall_score": 91.0, "overall_score": 91.0,
"completed_at": "2026-05-01T15:30:00" "completed_at": "2026-05-01T15:30:00",
"inspection_schedule_id": 12
} }
`inspection_schedule_id` links the inspection to the schedule it fulfils.
`scheduled_inspection_id` is accepted as an alias for shipped iPad builds.
The schedule is rolled forward only on the draft completed transition.
""" """
user = g.api_user user = g.api_user
@@ -477,7 +661,7 @@ def update_inspection(inspection_id):
if inspection is None: if inspection is None:
return api_error('Inspection not found', 404) return api_error('Inspection not found', 404)
if user.role == 'inspector' and inspection.inspector_id != user.id: if user.is_inspector and inspection.inspector_id != user.id:
return api_error('Access denied', 403) return api_error('Access denied', 403)
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
@@ -500,6 +684,31 @@ def update_inspection(inspection_id):
prev_status = inspection.status prev_status = inspection.status
# Allow the link to be set/corrected on PATCH too — the iPad may create the
# inspection as a draft first and only attach the schedule on submit.
# Same non-blocking semantics as create: a bad id leaves the link untouched.
_sched_id = _schedule_id_from(data)
if _sched_id:
_sched = _resolve_schedule(_sched_id, user)
if _sched is not None:
inspection.inspection_schedule_id = _sched.id
# phase48 parity with the POST path: a schedule created by
# "Schedule Follow-up" knows which inspection it answers, so a draft
# that only gets its schedule attached here still becomes a properly
# linked re-inspection. Never overrides an explicit parent.
if not inspection.parent_inspection_id and _sched.parent_inspection_id:
inspection.parent_inspection_id = _sched.parent_inspection_id
logger.info('API INSPECTIONS | parent inherited from schedule on '
'PATCH | schedule=%s | parent=%s | user=%s',
_sched.id, _sched.parent_inspection_id, user.username)
# An explicitly supplied parent still wins, and can be set on the draft
# before submit — mirrors the POST handler's field list.
if 'parent_inspection_id' in data:
_pid = data.get('parent_inspection_id')
if isinstance(_pid, int) and db.session.get(Inspection, _pid) is not None:
inspection.parent_inspection_id = _pid
if 'status' in data: if 'status' in data:
inspection.status = data['status'] inspection.status = data['status']
@@ -515,12 +724,52 @@ def update_inspection(inspection_id):
elif data.get('status') == 'completed' and not inspection.completed_at: elif data.get('status') == 'completed' and not inspection.completed_at:
inspection.completed_at = now_eastern() inspection.completed_at = now_eastern()
db.session.commit() # Computed BEFORE the commit so the schedule fulfil can be staged into the
# same transaction; reused after the commit for the notification below.
# Notify when a draft transitions to completed — mirrors the POST handler.
transitioning_to_complete = ( transitioning_to_complete = (
data.get('status') == 'completed' and prev_status != 'completed' data.get('status') == 'completed' and prev_status != 'completed'
) )
# Fulfil on the draft → completed transition ONLY, so a later PATCH on an
# already-completed inspection cannot roll the schedule forward twice.
if transitioning_to_complete:
_fulfill_schedule(inspection)
# ── Auto-clear follow-up flag on parent ───────────────────────────────
# Mirrors the POST handler. This was previously MISSING here, so an iPad
# that created a follow-up as a draft and submitted it via PATCH left the
# parent flagged forever — the re-inspection happened, but the parent still
# showed "Follow-up Inspection Required" and stayed in every manager's
# outstanding list. phase48 made that a normal path, since a schedule-started
# follow-up is a draft first.
#
# Same commit-ordering rule as the POST handler: log_action() must fire AFTER
# db.session.commit(), because audit.py commits internally and would
# otherwise persist the parent's flag change before this inspection's own
# changes are committed — a partial state if the main commit then failed.
_parent_log_args = None
if transitioning_to_complete and inspection.parent_inspection_id:
parent_insp = db.session.get(Inspection, inspection.parent_inspection_id)
if parent_insp and parent_insp.follow_up_required:
parent_insp.follow_up_required = False
logger.info(
'API INSPECTIONS | follow_up cleared on PATCH | parent_id=%s | '
'by_inspection_id=%s | user=%s',
parent_insp.id, inspection.id, user.username,
)
# Snapshot label strings now — ORM objects may be expired after commit.
_parent_log_args = (
parent_insp.id,
f'{parent_insp.template.name} @ {parent_insp.facility.name}',
f'follow_up_required=False (cleared by re-inspection '
f'#{inspection.id} via mobile API)',
)
db.session.commit()
if _parent_log_args:
log_action(ACTION_UPDATE, 'Inspection', *_parent_log_args)
# Notify when a draft transitions to completed — mirrors the POST handler.
if transitioning_to_complete: if transitioning_to_complete:
score_val = inspection.overall_score score_val = inspection.overall_score
score_display = f'{score_val:.1f}%' if score_val is not None else 'N/A' score_display = f'{score_val:.1f}%' if score_val is not None else 'N/A'
+34 -11
View File
@@ -42,7 +42,8 @@ logger = logging.getLogger(__name__)
bp = Blueprint('api_issues', __name__) bp = Blueprint('api_issues', __name__)
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager', 'auditor'} _ALLOWED_ROLES = {'admin', 'director', 'inspector', 'external_inspector',
'project_manager', 'auditor'}
_VALID_SEVERITY = {'low', 'medium', 'high', 'critical'} _VALID_SEVERITY = {'low', 'medium', 'high', 'critical'}
_VALID_STATUSES = {'open', 'in_progress', 'resolved', 'pending_verification'} _VALID_STATUSES = {'open', 'in_progress', 'resolved', 'pending_verification'}
_VALID_HANDLERS = {'internal', 'facility', 'vendor'} _VALID_HANDLERS = {'internal', 'facility', 'vendor'}
@@ -52,6 +53,13 @@ _UUID_RE = re.compile(
) )
def _photo_urls(keys):
"""Map storage keys to absolute display URLs (presigned on R2, absolute-static
on local). Falsy keys are skipped. Used for iPad photo rendering."""
from app.utils import storage
return [storage.media_url(k, external=True) for k in keys if k]
def _issue_payload(issue): def _issue_payload(issue):
"""Serialise an Issue to the dict returned in list/detail responses.""" """Serialise an Issue to the dict returned in list/detail responses."""
facility = issue.resolved_facility facility = issue.resolved_facility
@@ -72,6 +80,14 @@ def _issue_payload(issue):
'photo_path': issue.photo_path or None, 'photo_path': issue.photo_path or None,
'mobile_photo_paths': issue.mobile_photo_paths or [], 'mobile_photo_paths': issue.mobile_photo_paths or [],
'result_photos': issue.result_photos or [], 'result_photos': issue.result_photos or [],
# Absolute display URLs (presigned on R2, absolute-static on local) for
# the iPad, which loads photos off-origin. Relative keys above stay as
# keys. photo_urls order mirrors the iPad's evidence merge:
# [photo_path] + mobile_photo_paths.
'photo_urls': _photo_urls(
([issue.photo_path] if issue.photo_path else [])
+ (issue.mobile_photo_paths or [])),
'result_photo_urls': _photo_urls(issue.result_photos or []),
# Resolution details — set by web staff after fixing the issue. # Resolution details — set by web staff after fixing the issue.
'result_notes': issue.result_notes or None, 'result_notes': issue.result_notes or None,
# Verification fields — set after a director/admin confirms fix. # Verification fields — set after a director/admin confirms fix.
@@ -95,6 +111,10 @@ def _issue_payload(issue):
'vendor_name': issue.vendor_name or None, 'vendor_name': issue.vendor_name or None,
'vendor_contact': issue.vendor_contact or None, 'vendor_contact': issue.vendor_contact or None,
'vendor_notes': issue.vendor_notes or None, 'vendor_notes': issue.vendor_notes or None,
# Janitorial staff handler — used when handler_type == 'internal'.
# Distinct from assigned_to: the crew member may not be a system user.
'internal_handler_name': issue.internal_handler_name or None,
'internal_handler_contact': issue.internal_handler_contact or None,
} }
@@ -132,12 +152,12 @@ def list_issues():
if user.role not in _ALLOWED_ROLES: if user.role not in _ALLOWED_ROLES:
return api_error('Access denied', 403) return api_error('Access denied', 403)
limit = min(int(request.args.get('limit', 100)), 200) limit = min(request.args.get('limit', 100, type=int) or 100, 200)
offset = max(int(request.args.get('offset', 0)), 0) offset = max(request.args.get('offset', 0, type=int) or 0, 0)
query = Issue.query query = Issue.query
if user.role == 'inspector': if user.is_inspector:
fids = get_inspector_scope(user) fids = get_inspector_scope(user)
if not fids: if not fids:
return api_ok({'issues': [], 'total': 0, 'limit': limit, 'offset': offset}) return api_ok({'issues': [], 'total': 0, 'limit': limit, 'offset': offset})
@@ -231,7 +251,7 @@ def create_issue():
if facility is None: if facility is None:
return api_error('Facility not found', 404) return api_error('Facility not found', 404)
if user.role == 'inspector': if user.is_inspector:
fids = get_inspector_scope(user) fids = get_inspector_scope(user)
if not fids or facility_id not in fids: if not fids or facility_id not in fids:
return api_error('Access denied — facility is not in your assigned contracts', 403) return api_error('Access denied — facility is not in your assigned contracts', 403)
@@ -320,7 +340,7 @@ def get_issue(issue_id):
if issue is None: if issue is None:
return api_error('Issue not found', 404) return api_error('Issue not found', 404)
if user.role == 'inspector': if user.is_inspector:
fids = get_inspector_scope(user) fids = get_inspector_scope(user)
facility = issue.resolved_facility facility = issue.resolved_facility
if not fids or not facility or facility.id not in fids: if not fids or not facility or facility.id not in fids:
@@ -353,7 +373,7 @@ def update_issue_status(issue_id):
if issue is None: if issue is None:
return api_error('Issue not found', 404) return api_error('Issue not found', 404)
if user.role == 'inspector': if user.is_inspector:
fids = get_inspector_scope(user) fids = get_inspector_scope(user)
facility = issue.resolved_facility facility = issue.resolved_facility
if not fids or not facility or facility.id not in fids: if not fids or not facility or facility.id not in fids:
@@ -418,7 +438,7 @@ def update_issue_photos(issue_id):
if issue is None: if issue is None:
return api_error('Issue not found', 404) return api_error('Issue not found', 404)
if user.role == 'inspector': if user.is_inspector:
fids = get_inspector_scope(user) fids = get_inspector_scope(user)
facility = issue.resolved_facility facility = issue.resolved_facility
if not fids or not facility or facility.id not in fids: if not fids or not facility or facility.id not in fids:
@@ -479,7 +499,7 @@ def update_issue_result_photos(issue_id):
if issue is None: if issue is None:
return api_error('Issue not found', 404) return api_error('Issue not found', 404)
if user.role == 'inspector': if user.is_inspector:
fids = get_inspector_scope(user) fids = get_inspector_scope(user)
facility = issue.resolved_facility facility = issue.resolved_facility
if not fids or not facility or facility.id not in fids: if not fids or not facility or facility.id not in fids:
@@ -535,7 +555,9 @@ def update_issue_handler(issue_id):
"facility_handler_notes": "...", // optional "facility_handler_notes": "...", // optional
"vendor_name": "...", // optional (vendor handler) "vendor_name": "...", // optional (vendor handler)
"vendor_contact": "...", // optional "vendor_contact": "...", // optional
"vendor_notes": "..." // optional "vendor_notes": "...", // optional
"internal_handler_name": "...", // optional (janitorial staff handler)
"internal_handler_contact": "..." // optional
} }
Only keys present in the body are updated; empty strings clear a field. Only keys present in the body are updated; empty strings clear a field.
@@ -554,7 +576,7 @@ def update_issue_handler(issue_id):
if issue is None: if issue is None:
return api_error('Issue not found', 404) return api_error('Issue not found', 404)
if user.role == 'inspector': if user.is_inspector:
fids = get_inspector_scope(user) fids = get_inspector_scope(user)
facility = issue.resolved_facility facility = issue.resolved_facility
if not fids or not facility or facility.id not in fids: if not fids or not facility or facility.id not in fids:
@@ -576,6 +598,7 @@ def update_issue_handler(issue_id):
_text_fields = ( _text_fields = (
'facility_handler_name', 'facility_handler_contact', 'facility_handler_notes', 'facility_handler_name', 'facility_handler_contact', 'facility_handler_notes',
'vendor_name', 'vendor_contact', 'vendor_notes', 'vendor_name', 'vendor_contact', 'vendor_notes',
'internal_handler_name', 'internal_handler_contact',
) )
for field in _text_fields: for field in _text_fields:
if field in data: if field in data:
+15
View File
@@ -10,6 +10,7 @@ claims needed to identify the caller:
{ {
"sub": "42", # user.id as string "sub": "42", # user.id as string
"role": "inspector", # user.role "role": "inspector", # user.role
"tid": 3, # issuing tenant id (multi-tenant mode only)
"iat": 1710000000, # issued-at (UTC epoch) "iat": 1710000000, # issued-at (UTC epoch)
"exp": 1710003600, # expiry (UTC epoch, 60 min later) "exp": 1710003600, # expiry (UTC epoch, 60 min later)
} }
@@ -33,6 +34,12 @@ def _secret():
return current_app.config['SECRET_KEY'] return current_app.config['SECRET_KEY']
def _current_tenant_id():
"""Resolved tenant id, or None in single-tenant / unbound contexts."""
from app.tenancy.session_binding import current_tenant_id
return current_tenant_id()
def generate_access_token(user, lifetime_minutes: int = ACCESS_TOKEN_LIFETIME_MINUTES) -> str: def generate_access_token(user, lifetime_minutes: int = ACCESS_TOKEN_LIFETIME_MINUTES) -> str:
""" """
Create and sign a new access token for the given user. Create and sign a new access token for the given user.
@@ -54,6 +61,14 @@ def generate_access_token(user, lifetime_minutes: int = ACCESS_TOKEN_LIFETIME_MI
'iat': now, 'iat': now,
'exp': now + timedelta(minutes=lifetime_minutes), 'exp': now + timedelta(minutes=lifetime_minutes),
} }
# MT-21: bind the token to the issuing tenant. Every tenant is signed with
# the same SECRET_KEY, so without this claim a token minted at one tenant
# host verifies at another and 'sub' resolves against whichever database
# the middleware bound. Omitted in single-tenant mode so token shape is
# unchanged there.
tid = _current_tenant_id()
if tid is not None:
payload['tid'] = tid
return jwt.encode(payload, _secret(), algorithm='HS256') return jwt.encode(payload, _secret(), algorithm='HS256')
+1 -1
View File
@@ -73,7 +73,7 @@ def list_notifications():
""" """
user = g.api_user user = g.api_user
since = _parse_since(request.args.get('since')) since = _parse_since(request.args.get('since'))
limit = min(int(request.args.get('limit', 50)), 50) limit = min(request.args.get('limit', 50, type=int) or 50, 50)
def _run_orm(): def _run_orm():
q = Notification.query.filter_by(user_id=user.id, is_read=False) q = Notification.query.filter_by(user_id=user.id, is_read=False)
+68 -6
View File
@@ -25,7 +25,8 @@ logger = logging.getLogger(__name__)
bp = Blueprint('api_photos', __name__) bp = Blueprint('api_photos', __name__)
_ALLOWED_EXTENSIONS = {'jpg', 'jpeg', 'png', 'gif'} _ALLOWED_EXTENSIONS = {'jpg', 'jpeg', 'png', 'gif'}
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager', 'auditor'} _ALLOWED_ROLES = {'admin', 'director', 'inspector', 'external_inspector',
'project_manager', 'auditor'}
def _allowed_file(filename: str) -> bool: def _allowed_file(filename: str) -> bool:
@@ -47,31 +48,58 @@ def upload_photo():
--------------------- ---------------------
file binary image data (jpg / png / gif) file binary image data (jpg / png / gif)
entity_type "inspection" | "issue" | "issue_result" (controls subfolder) entity_type "inspection" | "issue" | "issue_result" (controls subfolder)
captured_at OPTIONAL ISO-8601 capture time (e.g. 2026-07-20T09:14:22-04:00)
latitude OPTIONAL decimal degrees at capture
longitude OPTIONAL decimal degrees at capture
A capture-time + geo overlay is burned into the image before it is stored
(see app/utils/photo_stamp.py). Metadata is taken from the client fields
above, falling back to the image's EXIF, then to server receipt time.
Sending captured_at/latitude/longitude is strongly preferred for an
offline-first client: a photo taken at 09:14 but synced at 16:00 would
otherwise be stamped with the sync time.
Response 200 Response 200
------------ ------------
{ {
"ok": true, "ok": true,
"data": { "data": {
"server_path": "uploads/inspection_photos/abc123.jpg" "server_path": "uploads/inspection_photos/abc123.jpg",
"stamped": true,
"captured_at": "2026-07-20T09:14:22",
"capture_source": "client"
} }
} }
The three stamp keys are additive an iPad build that predates them decodes
explicit CodingKeys and ignores what it doesn't know.
""" """
user = g.api_user user = g.api_user
if user.role not in _ALLOWED_ROLES: if user.role not in _ALLOWED_ROLES:
return api_error('Access denied', 403) return api_error('Access denied', 403)
# Every rejection below is logged at WARNING with the user. Only SUCCESSES
# were logged before, so when an inspector's photos failed repeatedly there
# was nothing server-side to explain why — and a photo that exhausts its
# upload attempts costs the inspection its evidence (see the iPad's
# PendingPhoto.lastUploadError for the device half of this).
if 'file' not in request.files: if 'file' not in request.files:
logger.warning('API PHOTOS | rejected | reason=no_file_part | user=%s',
user.username)
return api_error('No file provided', 400) return api_error('No file provided', 400)
file_obj = request.files['file'] file_obj = request.files['file']
entity_type = request.form.get('entity_type', 'inspection') entity_type = request.form.get('entity_type', 'inspection')
if not file_obj or not file_obj.filename: if not file_obj or not file_obj.filename:
logger.warning('API PHOTOS | rejected | reason=empty_file | user=%s',
user.username)
return api_error('Empty file', 400) return api_error('Empty file', 400)
if not _allowed_file(file_obj.filename): if not _allowed_file(file_obj.filename):
logger.warning('API PHOTOS | rejected | reason=bad_extension | file=%r | user=%s',
file_obj.filename, user.username)
return api_error( return api_error(
f'File type not allowed. Accepted: {", ".join(sorted(_ALLOWED_EXTENSIONS))}', f'File type not allowed. Accepted: {", ".join(sorted(_ALLOWED_EXTENSIONS))}',
400 400
@@ -85,12 +113,46 @@ def upload_photo():
else: else:
subfolder = 'inspection_photos' subfolder = 'inspection_photos'
# Burn the capture-time + geo overlay before the bytes are ever stored, so
# exactly one (already-stamped) object is written and nothing has to be
# read back out of R2. Any stamping failure returns the original bytes.
meta = {'stamped': False, 'captured_at': None, 'source': None}
if current_app.config.get('PHOTO_STAMP_ENABLED', True):
from app.utils.photo_stamp import stamp_file_storage
file_obj, meta = stamp_file_storage(
file_obj,
captured_at = request.form.get('captured_at'),
latitude = request.form.get('latitude'),
longitude = request.form.get('longitude'),
)
# Write via the active storage backend (local disk or R2). Key format # Write via the active storage backend (local disk or R2). Key format
# 'uploads/<subfolder>/<uuid>.<ext>' is unchanged across backends. # 'uploads/<subfolder>/<uuid>.<ext>' is unchanged across backends. The
# stamped FileStorage keeps the original filename, so the derived key — and
# the tenant prefix applied inside S3Backend — are unaffected.
from app.utils import storage from app.utils import storage
try:
server_path = storage.save(file_obj, subfolder) server_path = storage.save(file_obj, subfolder)
except Exception as exc:
# A storage failure is the most likely cause of a REPEATED upload
# failure (disk full, R2 credentials/quota). Name it explicitly —
# otherwise it surfaces only as a generic 500 with no link to the
# inspector who is losing evidence photos.
logger.error('API PHOTOS | STORAGE WRITE FAILED | user=%s | entity_type=%s | '
'subfolder=%s | error=%s', user.username, entity_type, subfolder, exc)
return api_error('Could not store the photo. Please retry.', 500)
logger.info('API PHOTOS | uploaded | entity_type=%s | path=%s | user=%s', logger.info(
entity_type, server_path, user.username) 'API PHOTOS | uploaded | entity_type=%s | path=%s | user=%s | '
'stamped=%s | capture_source=%s',
entity_type, server_path, user.username,
meta.get('stamped'), meta.get('source'),
)
return api_ok({'server_path': server_path}) captured_at = meta.get('captured_at')
return api_ok({
'server_path': server_path,
'stamped': bool(meta.get('stamped')),
'captured_at': captured_at.isoformat() if captured_at else None,
'capture_source': meta.get('source'),
})
+172 -4
View File
@@ -24,17 +24,24 @@ app/models/inspection_schedule.py for the full lifecycle.
""" """
import logging import logging
from datetime import datetime
from flask import Blueprint, request, g from flask import Blueprint, request, g
from app import db
from app.models.inspection import Inspection
from app.models.inspection_schedule import InspectionSchedule from app.models.inspection_schedule import InspectionSchedule
from app.api.errors import api_ok, api_error from app.api.errors import api_ok, api_error
from app.api.decorators import jwt_required from app.api.decorators import jwt_required
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE
from app.utils.scope import get_inspector_scope
from app.utils.time_utils import now_eastern
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
bp = Blueprint('api_scheduled', __name__) bp = Blueprint('api_scheduled', __name__)
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager', 'auditor'} _ALLOWED_ROLES = {'admin', 'director', 'inspector', 'external_inspector',
'project_manager', 'auditor'}
def _scheduled_payload(s): def _scheduled_payload(s):
@@ -56,8 +63,27 @@ def _scheduled_payload(s):
'frequency': s.frequency, 'frequency': s.frequency,
'frequency_label': s.frequency_label, 'frequency_label': s.frequency_label,
'mode': s.mode, 'mode': s.mode,
# phase46 recurrence detail. `recurrence_label` is the display string
# ("Weekly · Mon, Wed, Fri"); the raw fields let the iPad render its own.
'recurrence_label': s.recurrence_label,
'weekdays': s.weekday_list,
'month_mode': s.month_mode,
'day_of_month': s.day_of_month,
'nth_week': s.nth_week,
'nth_weekday': s.nth_weekday,
'next_due_date': s.next_run_at.date().isoformat() if s.next_run_at else None, 'next_due_date': s.next_run_at.date().isoformat() if s.next_run_at else None,
# phase47. Additive: the iPad decodes explicit CodingKeys, so a build
# that predates this key ignores it rather than failing to decode.
'end_date': s.end_date.isoformat() if s.end_date else None,
'is_overdue': s.is_overdue(), 'is_overdue': s.is_overdue(),
# phase48 — non-NULL when this schedule is a planned follow-up of a
# completed inspection. The iPad uses it to badge the row and to open
# the parent from the schedule detail.
'parent_inspection_id': s.parent_inspection_id,
# phase50 — receipt acknowledgement, per assignment rather than per
# occurrence. Lets the iPad badge unconfirmed assignments.
'is_acknowledged': s.is_acknowledged,
'acknowledged_at': s.acknowledged_at.isoformat() if s.acknowledged_at else None,
'notes': s.notes or None, 'notes': s.notes or None,
} }
@@ -92,8 +118,8 @@ def list_scheduled():
return api_error('Access denied', 403) return api_error('Access denied', 403)
try: try:
limit = min(int(request.args.get('limit', 100)), 200) limit = min(request.args.get('limit', 100, type=int) or 100, 200)
offset = max(int(request.args.get('offset', 0)), 0) offset = max(request.args.get('offset', 0, type=int) or 0, 0)
except (TypeError, ValueError): except (TypeError, ValueError):
return api_error('limit and offset must be integers', 400) return api_error('limit and offset must be integers', 400)
@@ -102,7 +128,7 @@ def list_scheduled():
InspectionSchedule.mode == 'plan', InspectionSchedule.mode == 'plan',
) )
if user.role == 'inspector': if user.is_inspector:
# Inspectors only see schedules assigned directly to them. # Inspectors only see schedules assigned directly to them.
query = query.filter(InspectionSchedule.inspector_id == user.id) query = query.filter(InspectionSchedule.inspector_id == user.id)
@@ -122,3 +148,145 @@ def list_scheduled():
return api_ok({'scheduled': payload, 'total': total, return api_ok({'scheduled': payload, 'total': total,
'limit': limit, 'offset': offset}) 'limit': limit, 'offset': offset})
# ── Create a scheduled follow-up (phase48) ────────────────────────────────────
@bp.route('/scheduled-inspections/follow-up', methods=['POST'])
@jwt_required
def create_follow_up():
"""
Plan a follow-up re-inspection of a completed inspection for a later date.
Backs "Schedule Follow-up" in the iPad's inspection history detail, the
deferred twin of "Re-inspect Now". Creates a one-time (`frequency='once'`),
plan-mode schedule carrying `parent_inspection_id`, so the inspection
eventually started from it is a true linked re-inspection.
Deliberately narrow: this is NOT a general schedule-creation endpoint. The
facility, area, template and assignee are all derived from the parent
inspection rather than taken from the client, so a follow-up can only ever
target the thing it is a follow-up of. Recurring schedules stay web-only
(`@project_manager_required`).
Mode is forced to 'plan', never 'auto': a follow-up is something a person
goes and does, and an auto schedule would drop an in-progress inspection
into the queue unannounced on the due date.
Request body
------------
parent_inspection_id int required the completed inspection to follow up
due_date str required ISO date (YYYY-MM-DD), today or later
notes str optional what the follow-up should address
Response 200 (reused existing) / 201 (created)
---------------------------------------------
{ "ok": true, "data": { "scheduled": {...}, "created": true } }
"""
user = g.api_user
# Auditor is read-only everywhere else; keep it that way here.
if user.role not in {'admin', 'director', 'inspector', 'external_inspector',
'project_manager'}:
return api_error('Access denied', 403)
body = request.get_json(silent=True) or {}
parent_id = body.get('parent_inspection_id')
if not isinstance(parent_id, int):
return api_error('parent_inspection_id is required', 400)
parent = db.session.get(Inspection, parent_id)
if parent is None:
return api_error('Inspection not found', 404)
# An inspector may only schedule a follow-up of their own work, and only
# within their assigned contracts — the same two gates the rest of the
# mobile API applies. Managers are unrestricted, matching the web.
if user.is_inspector:
if parent.inspector_id != user.id:
return api_error('Access denied', 403)
fids = get_inspector_scope(user)
if not fids or parent.facility_id not in fids:
return api_error('Access denied', 403)
# A follow-up only makes sense once there is something to follow up on.
if parent.status != 'completed':
return api_error('Only a completed inspection can have a follow-up '
'scheduled', 400)
due_raw = (body.get('due_date') or '').strip()
try:
due_date = datetime.strptime(due_raw, '%Y-%m-%d').date()
except ValueError:
return api_error('due_date must be an ISO date (YYYY-MM-DD)', 400)
# Today is allowed — "later today" is a legitimate plan; yesterday is not.
if due_date < now_eastern().date():
return api_error('due_date cannot be in the past', 400)
notes = (body.get('notes') or '').strip() or None
# Idempotent: the iPad may retry a request whose response was lost, and a
# second identical schedule would put a duplicate row in the inspector's
# Scheduled list with no way to tell them apart. Reuse the existing active
# follow-up for this parent instead, updating the date they just picked.
existing = (InspectionSchedule.query
.filter_by(parent_inspection_id=parent.id, active=True)
.order_by(InspectionSchedule.id.desc())
.first())
if existing is not None:
existing.set_next_run_date(due_date)
if notes:
existing.notes = notes
# A moved due date is a new occurrence — the reminders already sent for
# the old one no longer apply.
existing.advance_notified = False
existing.due_notified = False
existing.overdue_notified = False
db.session.commit()
log_action(ACTION_UPDATE, 'InspectionSchedule', existing.id, existing.name,
f'follow-up rescheduled via mobile API by {user.username}; '
f'parent_inspection_id={parent.id}; due={due_date}')
logger.info('API SCHEDULED | follow-up updated | schedule=%s | '
'parent=%s | due=%s | user=%s',
existing.id, parent.id, due_date, user.username)
return api_ok({'scheduled': _scheduled_payload(existing),
'created': False})
fac_name = parent.facility.name if parent.facility else 'facility'
sched = InspectionSchedule(
# MT requires a name (ST's table does not). Build one rather than asking
# the client for it, so the row is identifiable in the web schedule list
# without the iPad needing to know MT's schema.
name = f'Follow-up: {fac_name} (inspection #{parent.id})',
facility_id = parent.facility_id,
area_id = parent.area_id,
template_id = parent.template_id,
# Assign to whoever performed the original — they are the one being
# asked to put it right. Falls back to the caller when the parent has
# no inspector (its account was deleted).
inspector_id = parent.inspector_id or user.id,
frequency = 'once',
mode = 'plan',
active = True,
notes = notes,
parent_inspection_id = parent.id,
created_by = user.id,
created_at = now_eastern(),
)
# set_next_run_date() rather than a raw next_run_at so the due date gets the
# schedule's standard time-of-day (06:00 for a row with no next_run_at yet).
sched.set_next_run_date(due_date)
db.session.add(sched)
db.session.commit()
log_action(ACTION_CREATE, 'InspectionSchedule', sched.id, sched.name,
f'follow-up created via mobile API by {user.username}; '
f'parent_inspection_id={parent.id}; facility_id={parent.facility_id}; '
f'due={due_date}')
logger.info('API SCHEDULED | follow-up created | schedule=%s | parent=%s | '
'facility=%s | due=%s | user=%s',
sched.id, parent.id, parent.facility_id, due_date, user.username)
return api_ok({'scheduled': _scheduled_payload(sched), 'created': True}, 201)
+17 -4
View File
@@ -40,7 +40,8 @@ logger = logging.getLogger(__name__)
bp = Blueprint('api_stats', __name__) bp = Blueprint('api_stats', __name__)
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager', 'auditor'} _ALLOWED_ROLES = {'admin', 'director', 'inspector', 'external_inspector',
'project_manager', 'auditor'}
@bp.route('/stats/dashboard', methods=['GET']) @bp.route('/stats/dashboard', methods=['GET'])
@@ -74,7 +75,7 @@ def dashboard_stats():
today_end = today_start + timedelta(days=1) today_end = today_start + timedelta(days=1)
thirty_days_ago = now - timedelta(days=30) thirty_days_ago = now - timedelta(days=30)
is_inspector = user.role == 'inspector' is_inspector = user.is_inspector
fids = get_inspector_scope(user) if is_inspector else None # None = no scoping fids = get_inspector_scope(user) if is_inspector else None # None = no scoping
# ── Today's inspections ─────────────────────────────────────────────── # ── Today's inspections ───────────────────────────────────────────────
@@ -110,7 +111,14 @@ def dashboard_stats():
) )
) )
open_issues_all = open_q.all() # Counts and buckets only — never a hydrated Issue. For an admin this is
# every open issue in the system, fetched on every iPad dashboard refresh;
# the full entity would drag the description TEXT and the JSON photo
# columns along with it. A Row exposes the same attribute names, so
# sla_status() below works unchanged.
open_issues_all = open_q.with_entities(
Issue.id, Issue.severity, Issue.status, Issue.reported_at
).all()
open_issues = len(open_issues_all) open_issues = len(open_issues_all)
# ── Severity breakdown (derived from the same open_issues_all list) ─── # ── Severity breakdown (derived from the same open_issues_all list) ───
@@ -151,9 +159,14 @@ def dashboard_stats():
if not fids: if not fids:
followup_q = followup_q.filter(False) followup_q = followup_q.filter(False)
else: else:
# OWNERSHIP, not authorship: a follow-up handed to this inspector
# belongs to them even though somebody else performed the original.
# This tile sits directly above the Follow-up Requests list, which
# filters the same way — counting authorship here made the two
# disagree on the same screen.
followup_q = followup_q.filter( followup_q = followup_q.filter(
Inspection.facility_id.in_(fids), Inspection.facility_id.in_(fids),
Inspection.inspector_id == user.id, Inspection.follow_up_owned_by(user.id),
) )
pending_followups = followup_q.count() pending_followups = followup_q.count()
+90 -10
View File
@@ -16,18 +16,86 @@ GET /api/v1/templates/<template_id>
import logging import logging
from flask import Blueprint, g from flask import Blueprint, g, request
from app import db from app import db
from app.models.inspection import InspectionTemplate from app.models.inspection import InspectionTemplate
from app.models.facility import Facility
from app.api.errors import api_ok, api_error from app.api.errors import api_ok, api_error
from app.api.decorators import jwt_required from app.api.decorators import jwt_required
from app.utils.scope import get_inspector_scope
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
bp = Blueprint('api_templates', __name__) bp = Blueprint('api_templates', __name__)
# Customer role cannot access template data — inspectors and above only # Customer role cannot access template data — inspectors and above only
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager', 'auditor'} _ALLOWED_ROLES = {'admin', 'director', 'inspector', 'external_inspector',
'project_manager', 'auditor'}
def _visible_project_ids(user):
"""Contract ids whose forms this user may see, or None for "no limit".
An inspector (ours or a customer's) is limited to the contracts they are
assigned; every other allowed role sees all. Derived from the facilities
get_inspector_scope() returns, so the API can never disagree with the web.
"""
if not user.is_inspector:
return None
fids = get_inspector_scope(user) or []
if not fids:
return []
return sorted({
f.project_id
for f in Facility.query.filter(Facility.id.in_(fids)).all()
if f.project_id
})
def _visible_templates(user, project_id=None):
"""Forms this user may use, optionally narrowed to one contract.
phase52 a form attached to specific contracts must not reach an
inspector working for a different customer. Shared forms (no contract
links) stay visible to everyone, which is what keeps existing installs
behaving exactly as before.
With `project_id`: exactly the web picker's list for that contract.
Without: the union across every contract the user can reach the iPad
caches templates up-front and picks the facility later, so it needs the
whole set it might legitimately use.
"""
pids = _visible_project_ids(user)
if project_id is not None:
# The caller names a contract. Their OWN scope still applies — without
# this, passing another customer's facility_id would list that
# customer's form names back to an inspector who has no business
# seeing them. Empty list, not an error: the endpoint must not confirm
# whether that contract exists either.
if pids is not None and project_id not in pids:
logger.warning('API TEMPLATES | out-of-scope project filter | '
'user=%s | project_id=%s', user.username, project_id)
return []
return InspectionTemplate.available_query(project_id).all()
if pids is None:
return (InspectionTemplate.query
.filter_by(active=True)
.order_by(InspectionTemplate.name)
.all())
seen, out = set(), []
# Always include the shared forms, even when the user has no contracts —
# otherwise an unassigned inspector would see nothing at all rather than
# the standard forms.
for pid in list(pids) + [None]:
for t in InspectionTemplate.available_query(pid).all():
if t.id not in seen:
seen.add(t.id)
out.append(t)
out.sort(key=lambda t: (t.name or '').lower())
return out
def _template_summary_payload(template: InspectionTemplate) -> dict: def _template_summary_payload(template: InspectionTemplate) -> dict:
@@ -87,17 +155,21 @@ def list_templates():
user.username, user.role) user.username, user.role)
return api_error('Access denied', 403) return api_error('Access denied', 403)
templates = ( # Optional ?project_id= narrows to one contract (matches the web picker);
InspectionTemplate.query # ?facility_id= is accepted as a convenience and resolved to its contract.
.filter_by(active=True) project_id = request.args.get('project_id', type=int)
.order_by(InspectionTemplate.name) if project_id is None:
.all() facility_id = request.args.get('facility_id', type=int)
) if facility_id is not None:
facility = db.session.get(Facility, facility_id)
project_id = facility.project_id if facility else None
templates = _visible_templates(user, project_id)
payload = [_template_summary_payload(t) for t in templates] payload = [_template_summary_payload(t) for t in templates]
logger.info('API TEMPLATES | list | user=%s | count=%d', logger.info('API TEMPLATES | list | user=%s | project_id=%s | count=%d',
user.username, len(payload)) user.username, project_id, len(payload))
return api_ok({'templates': payload, 'count': len(payload)}) return api_ok({'templates': payload, 'count': len(payload)})
@@ -143,6 +215,14 @@ def get_template(template_id):
if template is None: if template is None:
return api_error('Template not found', 404) return api_error('Template not found', 404)
# phase52 — a restricted form must not be fetchable by an inspector on a
# different customer's contracts. 404 rather than 403: whether another
# customer's form exists is itself not this user's business.
if template.id not in {t.id for t in _visible_templates(user)}:
logger.warning('API TEMPLATES | out-of-contract fetch blocked | '
'user=%s | template_id=%s', user.username, template_id)
return api_error('Template not found', 404)
logger.info('API TEMPLATES | detail | user=%s | template_id=%d | name=%s', logger.info('API TEMPLATES | detail | user=%s | template_id=%d | name=%s',
user.username, template_id, template.name) user.username, template_id, template.name)
+48
View File
@@ -0,0 +1,48 @@
"""
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).
MT NOTE: "no database" does NOT mean "no tenant". Submissions are filed per
tenant on disk, and the admin views only ever list the calling tenant's own
directory see storage.enrollment_dir().
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'),
)
# Only the ROOT is created at boot. Per-tenant subdirectories are created
# lazily on first use by storage.enrollment_dir(), because the tenant is
# not known until a request is bound.
os.makedirs(app.config['ENROLLMENT_DIR'], exist_ok=True)
app.register_blueprint(bp)
app.logger.info('Enrollment | storage root: %s | per-tenant=%s',
app.config['ENROLLMENT_DIR'],
bool(app.config.get('MULTI_TENANT_ENABLED')))
+229
View File
@@ -0,0 +1,229 @@
"""
app/enrollment/mailer.py
------------------------
The enrollment confirmation email.
Sent to the requester after a submission is stored. One job, and it must never
be able to break that: the record is already safely on disk before this runs,
so every failure path here is logged and swallowed. A bounced confirmation must
not cost the customer their enrollment.
Sending happens on a background thread (rule 14 never block the HTTP
response), and the From identity comes from branded_sender() so it stays an
SMTP-authorized address that actually delivers (rules 64 / 76).
This is the only part of app/enrollment that touches shared mail
infrastructure. It performs exactly ONE database read resolving the active
admin accounts to notify and no write. That read is a deliberate, narrowed
exception to the package's no-models rule (rule 88): the alternative, a
hand-maintained recipient list in config, drifts out of step with reality the
first time someone joins or leaves. Everything else here stays model-free.
"""
import logging
import threading
from flask import current_app, render_template
logger = logging.getLogger(__name__)
def _tenant_branding():
"""(display_name, support_email) for the calling tenant.
MT: ST hardcodes its own company name and a personal corrections address in
this module and in schema.py. Sending either to another tenant's customers
would be wrong and confusing, so both are resolved from TenantSettings at
send time, falling back to neutral defaults.
Best-effort: any failure returns the defaults rather than blocking the
email, matching the rest of this module's never-raise contract.
"""
name, support = 'Janitorial QC', None
try:
from app.models.tenant_settings import TenantSettings
row = TenantSettings.query.first()
if row is not None:
name = row.display_name or name
support = row.support_email or None
except Exception:
logger.debug('ENROLLMENT | tenant branding unavailable, using defaults')
if not support:
support = (current_app.config.get('ENROLLMENT_CORRECTIONS_EMAIL')
or current_app.config.get('MAIL_DEFAULT_SENDER') or '')
return name, support
def _text_body(record, people, corrections_email):
"""Plain-text alternative — some recipients see only this."""
lines = [
f'Hi {record.get("request_by") or "there"},',
'',
'Thank you — we have received your JQC enrollment form.',
'',
f'Reference: {record.get("id")}',
f'Project: {record.get("project_name")}',
'',
f'People to be set up ({len(people)}):',
]
for i, person in enumerate(people, start=1):
lines.append(
f' {i}. {person["name"]}{person["role_label"]}{person["email"]}'
)
lines += [
'',
'Our team will create these accounts. Each person will receive their own '
'email invitation with sign-in instructions.',
'',
f'If anything above is wrong, simply send an email to '
f'{corrections_email}, and we will correct it.',
'',
_tenant_branding()[0],
]
return '\n'.join(lines)
def _dispatch(msg, label, record):
"""Send one message on a background thread. Never raises.
Rule 14 the HTTP response must not wait on SMTP. The submission is
already on disk by the time anything here runs, so a mail failure is
logged and dropped rather than surfaced to the customer.
"""
app = current_app._get_current_object()
def _send():
with app.app_context():
try:
from app import mail
mail.send(msg)
logger.info('ENROLLMENT %s SENT | to=%s | id=%s',
label, msg.recipients, record.get('id'))
except Exception as exc:
logger.error('ENROLLMENT %s FAILED | to=%s | id=%s | error=%s',
label, msg.recipients, record.get('id'), exc)
threading.Thread(target=_send, daemon=True).start()
def _admin_recipients():
"""Addresses to alert when a new enrollment arrives.
Active `admin` accounts, plus any extra addresses in the optional
ENROLLMENT_NOTIFY_EMAILS config (comma-separated) for people who should be
told but do not hold a JQC login. Deduplicated case-insensitively.
The User import is function-local and read-only see the module docstring.
"""
emails = []
try:
from app.models.user import User
rows = User.query.filter(User.role == 'admin',
User.active == True).all() # noqa: E712
emails += [u.email for u in rows if u.email]
except Exception:
# A DB problem must not stop the confirmation going out, nor the
# submission from succeeding.
logger.exception('ENROLLMENT | could not resolve admin recipients')
extra = current_app.config.get('ENROLLMENT_NOTIFY_EMAILS') or ''
emails += [e.strip() for e in extra.split(',') if e.strip()]
seen, out = set(), []
for e in emails:
low = e.lower()
if low not in seen:
seen.add(low)
out.append(e)
return out
def send_admin_notification(record, base_url=None):
"""Alert JQC admins that a new enrollment form has arrived. Never raises."""
if not current_app.config.get('MAIL_SERVER'):
logger.warning('ENROLLMENT ADMIN EMAIL SKIPPED | no MAIL_SERVER | id=%s',
record.get('id'))
return
try:
from flask_mail import Message
from app.utils.mail_utils import branded_sender
from . import schema
recipients = _admin_recipients()
if not recipients:
logger.warning('ENROLLMENT | no admin recipients for id=%s',
record.get('id'))
return
effective_base = (base_url
or current_app.config.get('APP_BASE_URL', '')).rstrip('/')
people = schema.people_of(record)
link = f'{effective_base}/enrollment/admin/{record.get("id")}'
lines = [
'A new JQC enrollment form has been submitted.',
'',
f'Project: {record.get("project_name")}',
f'Requester: {record.get("request_by")} <{record.get("requester_email")}>',
f'Reference: {record.get("id")}',
f'People: {len(people)}',
'',
f'Open it here: {link}',
]
if record.get('notes'):
lines += ['', f'Customer notes: {record["notes"]}']
msg = Message(
subject = f'[JQC] New enrollment — {record.get("project_name")}',
sender = branded_sender(effective_base),
recipients = recipients,
body = '\n'.join(lines),
html = render_template('enrollment/email_admin_notice.html',
record=record, people=people,
schema=schema, link=link),
)
_dispatch(msg, 'ADMIN EMAIL', record)
except Exception:
logger.exception('ENROLLMENT ADMIN EMAIL BUILD FAILED | id=%s',
record.get('id'))
def send_confirmation(record, base_url=None):
"""Email the requester a copy of what they submitted. Never raises."""
email = (record.get('requester_email') or '').strip()
if not email:
return
if not current_app.config.get('MAIL_SERVER'):
logger.warning('ENROLLMENT EMAIL SKIPPED | no MAIL_SERVER | id=%s',
record.get('id'))
return
try:
from flask_mail import Message
from app.utils.mail_utils import branded_sender
from . import schema
effective_base = (base_url
or current_app.config.get('APP_BASE_URL', '')).rstrip('/')
people = schema.people_of(record)
msg = Message(
subject = f'[JQC] Enrollment received — {record.get("project_name")}',
sender = branded_sender(effective_base),
recipients = [email],
body = _text_body(record, people, _tenant_branding()[1]),
html = render_template('enrollment/email_confirmation.html',
record=record, people=people,
schema=schema,
corrections_email=_tenant_branding()[1]),
)
_dispatch(msg, 'EMAIL', record)
except Exception:
# Building the message failed (bad template, mail misconfigured, …).
# The submission is already saved — log it and move on.
logger.exception('ENROLLMENT EMAIL BUILD FAILED | id=%s', record.get('id'))
+352
View File
@@ -0,0 +1,352 @@
"""
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
import re
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 mailer, 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]
#: Person rows are named person_<n>_<field>. The client controls <n> (rows can
#: be added and removed in any order), so the server discovers the indexes that
#: were actually posted rather than trusting a count field.
_PERSON_FIELD_RE = re.compile(r'^person_(\d+)_role$')
def _parse_people(form):
"""Return the submitted people as an ordered list of dicts.
Each entry gets a stable `key` (p1, p2, ) assigned by POSITION, not by the
client's index — so the matrix keys in a stored submission are always dense
and predictable no matter which rows the customer deleted before sending.
"""
indexes = sorted(
int(m.group(1))
for m in (_PERSON_FIELD_RE.match(k) for k in form.keys()) if m
)
people = []
for idx in indexes:
role = form.get(f'person_{idx}_role', '')
if role not in schema.ROLE_KEYS:
role = schema.DEFAULT_FIRST_ROLE
name = _clean(form.get(f'person_{idx}_name'))
job_title = _clean(form.get(f'person_{idx}_job_title'))
email = _clean(form.get(f'person_{idx}_email'))
# Drop rows the customer added but left completely blank.
if not (name or job_title or email):
continue
people.append({
'key': f'p{len(people) + 1}',
'form_index': idx, # so the matrix cells can be read back
'role': role,
'name': name,
'job_title': job_title,
'email': email,
})
if len(people) >= schema.MAX_PEOPLE:
logger.warning('ENROLLMENT | people capped at %d', schema.MAX_PEOPLE)
break
return people
def _seed_people(people, matrix, mobile_app):
"""Shape the submitted people for the page to re-render after an error.
Folds each person's ticked tasks into their own row, so the browser can
rebuild the table from scratch with fresh row indexes and still restore
every answer.
"""
seed = []
for person in people:
seed.append({
'role': person['role'],
'name': person['name'],
'job_title': person['job_title'],
'email': person['email'],
'tasks': [ref for ref, _l, _s in schema.TASKS
if matrix.get(str(ref), {}).get(person['key'])],
'mobile': bool(mobile_app.get(person['key'])),
})
return seed
# ── 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,
seed_people=[])
@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'))
requester_email = _clean(request.form.get('requester_email'))
people = _parse_people(request.form)
# ── Step 2 matrix + Step 3 mobile, keyed by person ───────────────────
# Only cells the person's role actually offers are read, so a crafted POST
# cannot record an admin-only task against an inspector.
matrix = {}
for ref, _label, scope in schema.TASKS:
row = {}
for person in people:
if schema.task_applies(scope, person['role']):
row[person['key']] = bool(
request.form.get(f'task_{ref}_person_{person["form_index"]}'))
matrix[str(ref)] = row
mobile_app = {
p['key']: bool(request.form.get(f'mobile_person_{p["form_index"]}'))
for p in people
}
# ── Validation ───────────────────────────────────────────────────────
# A person counts only with BOTH a name and an email — a half-filled row
# cannot be set up, so it must not pass as one.
named = [p for p in people if p['name'] and p['email']]
errors = []
if not project_name:
errors.append('Project Name is required.')
if not request_by:
errors.append('Request by is required.')
if not requester_email:
errors.append('Requester email is required — we send your confirmation '
'there.')
elif '@' not in requester_email:
errors.append('The requester email address does not look valid.')
if not named:
errors.append('Please add at least one person with both a name and an '
'email address.')
for p in people:
if p['email'] and '@' not in p['email']:
errors.append(f'"{p["name"] or p["key"]}" has an email address that '
f'does not look valid.')
seen = set()
for p in named:
low = p['email'].lower()
if low in seen:
errors.append(f'{p["email"]} is listed more than once — each person '
f'needs their own email address.')
seen.add(low)
prior = {
'project_name': project_name,
'request_by': request_by,
'requester_email': requester_email,
'date_requested': _clean(request.form.get('date_requested')),
'notes': _clean(request.form.get('notes'), _MAX_NOTES),
'people': people,
'matrix': matrix,
'mobile_app': mobile_app,
}
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=prior,
seed_people=_seed_people(people, matrix, mobile_app)), 400
now = datetime.now()
record = dict(prior)
record.update({
'id': storage.new_id(now),
'submitted_at': now.isoformat(timespec='seconds'),
# 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, submitted=prior,
seed_people=_seed_people(people, matrix, mobile_app)), 500
logger.info('ENROLLMENT | submitted | id=%s project=%r people=%d ip=%s',
record['id'], project_name, len(named), request.remote_addr)
# Both emails fire AFTER the save and are fully guarded — a mail problem
# must never cost the customer their submission.
mailer.send_confirmation(record, base_url=request.host_url) # requester
mailer.send_admin_notification(record, base_url=request.host_url) # JQC admins
return render_template('enrollment/submitted.html', reference=record['id'],
email=requester_email)
# ── 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 PERSON (not per submission) — that is the unit of work when
actually setting the accounts up. Reads through schema.people_of(), so
submissions stored in the older fixed-seat format export identically."""
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', 'Role', 'Name', 'Job Title',
'Email', 'Mobile App'] + task_headers)
for rec in records:
for person in schema.people_of(rec):
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', ''),
person['role_label'],
person['name'],
person['job_title'],
person['email'],
'Yes' if schema.wants_mobile(rec, person['key']) else '',
]
for ref, _label, scope in schema.TASKS:
if not schema.task_applies(scope, person['role']):
row.append('n/a')
else:
row.append('Yes' if schema.cell(rec, ref, person['key']) 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'},
)
+248
View File
@@ -0,0 +1,248 @@
"""
app/enrollment/schema.py
------------------------
The JQC Enrollment Form, expressed as data.
This is the SINGLE source of truth for the form's shape. The public template
renders from it, the POST handler parses against it, and the admin detail view
re-renders a stored submission through it. Changing a task label or adding a
role is a one-line edit here no template or parser change.
Deliberately free of any app model / DB import: the enrollment form describes
what a prospective customer *wants set up*, not anything that exists in the
system yet. Keep it that way (see app/enrollment/__init__.py). The ROLES below
happen to mirror the app's user roles, but they are a COPY on purpose — the
public form must not import the User model.
"""
# ── Roles a person can be enrolled as ────────────────────────────────────────
# key -> label, shown in the Step 1 role dropdown.
# phase51 — enrollment describes CUSTOMER-side people only, so the dropdown
# offers exactly the two customer roles. Our own staff roles (admin, auditor,
# internal inspector) are never enrolled through this form; they are created in
# User Management. The keys stay 'director'/'inspector' — they are the
# customer's words for the seat, mapped to app roles by APP_ROLE_FOR below.
ROLES = [
('director', 'Director'),
('inspector', 'Inspector'),
]
ROLE_LABELS = dict(ROLES)
ROLE_KEYS = [k for k, _ in ROLES]
#: App role each enrolled seat becomes when an admin actually creates the
#: account in Customer Management. A plain string map on purpose — the
#: enrollment package must not import app.models (see __init__.py, rule 88).
APP_ROLE_FOR = {
'director': 'customer', # "Customer Director"
'inspector': 'external_inspector', # "Customer Inspector"
}
#: Roles that act on the administrative side of the printed form (the
#: "Admin / Director" column). Everything else is an inspector seat. Drives
#: both the recommendation preset and eligibility for admin-only tasks.
#
#: 'admin' and 'auditor' are NOT selectable any more but stay in this set for
#: LEGACY tolerance: submissions taken before phase51 stored those roles, and
#: dropping them here would silently re-render their admin-only task cells
#: (ref 10) as "n/a" in the admin detail view and the CSV export. Selectable
#: roles shrink; the ability to read back what was already recorded does not.
ADMIN_ROLES = {'director', 'admin', 'auditor'}
#: Role pre-selected for the first row — the form starts with the customer's
#: administrative contact, as on the printed sheet.
DEFAULT_FIRST_ROLE = 'director'
#: Upper bound on people per submission. Generous for a real enrollment, but
#: bounded so a scripted POST cannot make us build an unbounded matrix.
MAX_PEOPLE = 25
def is_admin_role(role):
return role in ADMIN_ROLES
# ── Task rows ────────────────────────────────────────────────────────────────
# ref, label, scope. scope 'admin_only' means the cell is offered only to
# people in an ADMIN_ROLES role (ref 10 on the printed form).
TASKS = [
(1, 'Receive new inspection submitted notification', 'all'),
(2, 'Receive issue-related notification', 'all'),
(3, 'New issue created', 'all'),
(4, 'Issue status updated', 'all'),
(5, 'Issue comment added', 'all'),
(6, 'Request follow up / re-inspection', 'all'),
(7, 'Add Comments (issue detail page)', 'all'),
(8, 'Receive issue SLA (at-risk, breached)', 'all'),
(9, 'Log new issue', 'all'),
(10, 'Search/Export Reports (inspection/issue)', 'admin_only'),
]
TASK_LABELS = {ref: label for ref, label, _ in TASKS}
#: Task rows that describe a CAPABILITY the role already carries, rather than a
#: notification we route. Every customer role can already do all three today —
#: comment on issues they follow or filed, log an issue at their own facility,
#: and search/export reports within their scope — so a tick here records what
#: the customer expects, it does not switch anything on. The remaining rows
#: (1-6, 8) are the ones that map to notification events and can be tuned
#: per account in Customer Management.
#:
#: Rendered as a footnote on the public form so the distinction is visible
#: without turning these into per-user permission flags (which would mean
#: adding deny-checks to routes that have none today — a fail-open surface for
#: no real gain).
ROLE_IMPLIED_TASKS = {7, 9, 10}
def task_applies(scope, role):
"""True when a task row offers a checkbox to someone in `role`."""
return scope == 'all' or is_admin_role(role)
# ── Step 3 ───────────────────────────────────────────────────────────────────
MOBILE_APP_LABEL = 'JQC Mobile App For Smart Device'
# ── Recommended defaults ─────────────────────────────────────────────────────
# ref -> (recommended for admin-side roles, recommended for inspector roles).
# None = the row offers that side no cell.
#
# The printed form showed this as a separate RECOMMENDATION table for the
# customer to copy by hand. It is now applied by the "Recommendation selection"
# button instead, so the table is no longer rendered — but this mapping is
# still the authority, and is handed to the page as JSON.
RECOMMENDATION = {
1: (False, True),
2: (False, True),
3: (True, True),
4: (False, True),
5: (True, True),
6: (True, True),
7: (True, True),
8: (False, True),
9: (True, True),
10: (True, None),
}
def recommendation_for(role):
"""Return {task_ref: bool} — the recommended preset for one role.
Rows that offer this role no cell are omitted rather than set False, so
the caller never ticks a checkbox that does not exist.
"""
admin_side = is_admin_role(role)
preset = {}
for ref, _label, scope in TASKS:
if not task_applies(scope, role):
continue
rec = RECOMMENDATION.get(ref, (False, False))
value = rec[0] if admin_side else rec[1]
if value is None:
continue
preset[ref] = bool(value)
return preset
def recommendation_map():
"""{role_key: {task_ref: bool}} for every role — serialised to the page."""
return {role: recommendation_for(role) for role in ROLE_KEYS}
#: Where a customer should write if their submission needs correcting. The
#: confirmation email is sent FROM the unmonitored no-reply identity
#: (branded_sender), so "reply to this email" would go nowhere — point them
#: here instead. Used by both the text and HTML bodies of the confirmation.
# MT: retained only as a last-resort default. The address actually shown to a
# customer is resolved per tenant at send time from TenantSettings.support_email
# — see mailer._tenant_branding(). Never send one tenant's customers another
# tenant's (or a developer's personal) address.
CORRECTIONS_EMAIL = ''
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 the tenant AFTER receipt, on the admin detail page only. The
# printed sheet showed these to the customer as a blank "for office use" block;
# the web form does not render them at all — a customer cannot fill them in, so
# showing them was only noise.
OFFICE_FIELDS = [
('receive_date', 'Receive Date'),
('program_by', 'Program By'),
('date_email_invitation', 'Date email invitation'),
]
STATUSES = ['new', 'in_progress', 'completed']
STATUS_LABELS = {
'new': 'New',
'in_progress': 'In Progress',
'completed': 'Completed',
}
# ── Legacy record support ────────────────────────────────────────────────────
# Submissions taken before the form moved to free-form people used six fixed
# seats. Stored files are never rewritten, so the admin views normalise on
# read instead — one shape to render, whichever format is on disk.
_LEGACY_SEAT_ROLES = {
'admin': 'admin',
'inspector_1': 'inspector',
'inspector_2': 'inspector',
'inspector_3': 'inspector',
'inspector_4': 'inspector',
'inspector_5': 'inspector',
}
def people_of(record):
"""Return a submission's people as a uniform list, old format or new.
Each entry: {key, role, role_label, name, job_title, email}.
"""
if record.get('people'):
out = []
for p in record['people']:
role = p.get('role', 'inspector')
out.append({
'key': p.get('key', ''),
'role': role,
'role_label': ROLE_LABELS.get(role, role.replace('_', ' ').title()),
'name': p.get('name', ''),
'job_title': p.get('job_title', ''),
'email': p.get('email', ''),
})
return out
# Legacy: fixed seats under 'registrants'.
out = []
for reg in record.get('registrants', []):
if not (reg.get('name') or reg.get('email')):
continue
role = _LEGACY_SEAT_ROLES.get(reg.get('key'), 'inspector')
out.append({
'key': reg.get('key', ''),
'role': role,
'role_label': ROLE_LABELS.get(role, role.title()),
'name': reg.get('name', ''),
'job_title': reg.get('job_title', ''),
'email': reg.get('email', ''),
})
return out
def cell(record, ref, person_key):
"""True when `person_key` was ticked for task `ref` in this submission."""
return bool(record.get('matrix', {}).get(str(ref), {}).get(person_key))
def wants_mobile(record, person_key):
return bool(record.get('mobile_app', {}).get(person_key))
+194
View File
@@ -0,0 +1,194 @@
"""
app/enrollment/storage.py
-------------------------
Flat-file persistence for enrollment submissions one JSON document per
submission, under the directory named by config ENROLLMENT_DIR, in a
per-tenant subdirectory (see 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}$')
class TenantUnresolved(RuntimeError):
"""Raised when multi-tenancy is on but no tenant is bound to the request.
Deliberately fatal rather than falling back to the shared root directory:
a fallback would put one tenant's submissions where every other tenant's
admin can read them.
"""
def enrollment_dir():
"""Absolute path of THIS TENANT's submission directory, created on first use.
Multi-tenant isolation (MT-17)
------------------------------
ST keeps every submission in one flat directory. In MT that directory is
shared by every tenant on the host, so /enrollment/admin would list other
organisations' submissions — names, emails and phone numbers of people at
another company. Submissions are therefore filed under a per-tenant
subdirectory:
<ENROLLMENT_DIR>/t<tenant_id>/<submission>.json
``t<id>`` mirrors ``storage.tenant_key_prefix()`` so the on-disk layout is
the same shape as the media object keys.
When MULTI_TENANT_ENABLED is false the root directory is used unchanged,
so a single-tenant deploy behaves exactly like ST.
When multi-tenancy IS enabled but no tenant is bound, this raises rather
than falling back to the root see TenantUnresolved.
"""
root = current_app.config['ENROLLMENT_DIR']
if not current_app.config.get('MULTI_TENANT_ENABLED'):
os.makedirs(root, exist_ok=True)
return root
from flask import g
tenant = getattr(g, 'tenant', None)
if tenant is None:
logger.error('ENROLLMENT | no tenant bound — refusing to touch storage')
raise TenantUnresolved(
'enrollment storage requires a resolved tenant when '
'MULTI_TENANT_ENABLED is set'
)
path = os.path.join(root, f't{tenant.id}')
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,181 @@
{% 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">Requester email</dt>
<dd class="col-7">
{% if record.requester_email %}
<a href="mailto:{{ record.requester_email }}">{{ record.requester_email }}</a>
{% else %}<span class="text-muted"></span>{% endif %}
</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>
{# ── The people to set up ───────────────────────────────────────────── #}
{% set people = schema.people_of(record) %}
<div class="card shadow-sm mt-3">
<div class="card-header fw-semibold">
Users to Register
<span class="badge bg-secondary rounded-pill ms-1">{{ people | length }}</span>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table mb-0">
<thead class="table-light">
<tr>
<th style="width:50px;">No.</th>
<th>Role</th><th>Name</th><th>Job Title</th><th>Email</th>
<th class="text-center">Mobile App</th>
</tr>
</thead>
<tbody>
{% for person in people %}
<tr>
<td>{{ loop.index }}</td>
<td><span class="badge bg-light text-dark border">{{ person.role_label }}</span></td>
<td class="fw-semibold">{{ person.name or '—' }}</td>
<td>{{ person.job_title or '—' }}</td>
<td>
{% if person.email %}
<a href="mailto:{{ person.email }}">{{ person.email }}</a>
{% else %}—{% endif %}
</td>
<td class="text-center">
{% if schema.wants_mobile(record, person.key) %}
<i class="bi bi-phone-fill text-primary" title="Wants the mobile app"></i>
{% else %}<span class="text-muted"></span>{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{# ── The requested task matrix — one column per person ──────────────── #}
<div class="card shadow-sm mt-3">
<div class="card-header fw-semibold">Requested Tasks &amp; 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 style="min-width:280px;">Task / Function</th>
{% for person in people %}
<th class="text-center" style="min-width:120px;">
{{ person.name or 'Person ' ~ loop.index }}
<div class="fw-normal text-muted" style="font-size:.75rem;">
{{ person.role_label }}
</div>
</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for ref, label, scope in schema.TASKS %}
<tr>
<td class="text-center">{{ ref }}</td>
<td>{{ label }}</td>
{% for person in people %}
<td class="text-center">
{% if not schema.task_applies(scope, person.role) %}
<span class="text-muted" title="Not available for this role">·</span>
{% elif schema.cell(record, ref, person.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,98 @@
{% 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 %}
{# people_of() normalises both the current and the legacy stored shape #}
{% set named = schema.people_of(r) %}
{% 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,89 @@
{# Internal alert to JQC admins when a new enrollment form arrives. Inline
styles only and no external assets — mail clients strip <style> blocks and
block remote resources. #}
<!DOCTYPE html>
<html>
<body style="font-family:Arial,Helvetica,sans-serif;color:#333;max-width:640px;margin:auto;padding:12px;">
<h2 style="color:#1a6fb5;margin:0 0 4px;">New enrollment form</h2>
<p style="color:#6b7280;margin:0 0 20px;">JQC · internal notification</p>
<table style="border-collapse:collapse;margin:0 0 18px;">
<tr>
<td style="padding:4px 14px 4px 0;color:#6b7280;">Project</td>
<td style="padding:4px 0;font-weight:bold;">{{ record.project_name }}</td>
</tr>
<tr>
<td style="padding:4px 14px 4px 0;color:#6b7280;">Requester</td>
<td style="padding:4px 0;">
{{ record.request_by }}
{% if record.requester_email %}
&lt;<a href="mailto:{{ record.requester_email }}">{{ record.requester_email }}</a>&gt;
{% endif %}
</td>
</tr>
{% if record.date_requested %}
<tr>
<td style="padding:4px 14px 4px 0;color:#6b7280;">Date requested</td>
<td style="padding:4px 0;">{{ record.date_requested }}</td>
</tr>
{% endif %}
<tr>
<td style="padding:4px 14px 4px 0;color:#6b7280;">Reference</td>
<td style="padding:4px 0;">{{ record.id }}</td>
</tr>
</table>
<p style="margin:0 0 22px;">
<a href="{{ link }}"
style="background:#1a6fb5;color:#fff;text-decoration:none;padding:10px 18px;
border-radius:6px;display:inline-block;font-weight:bold;">
Open in JQC
</a>
</p>
<h3 style="font-size:1rem;margin:0 0 8px;">
Accounts requested ({{ people | length }})
</h3>
<table style="border-collapse:collapse;width:100%;font-size:.92rem;">
<thead>
<tr style="background:#dbeafe;">
<th style="border:1px solid #cbd5e1;padding:6px 9px;text-align:left;">Name</th>
<th style="border:1px solid #cbd5e1;padding:6px 9px;text-align:left;">Role</th>
<th style="border:1px solid #cbd5e1;padding:6px 9px;text-align:left;">Email</th>
<th style="border:1px solid #cbd5e1;padding:6px 9px;text-align:center;">Mobile App</th>
</tr>
</thead>
<tbody>
{% for person in people %}
<tr>
<td style="border:1px solid #cbd5e1;padding:6px 9px;">
{{ person.name }}
{% if person.job_title %}
<div style="color:#6b7280;font-size:.82rem;">{{ person.job_title }}</div>
{% endif %}
</td>
<td style="border:1px solid #cbd5e1;padding:6px 9px;">{{ person.role_label }}</td>
<td style="border:1px solid #cbd5e1;padding:6px 9px;">{{ person.email }}</td>
<td style="border:1px solid #cbd5e1;padding:6px 9px;text-align:center;">
{{ 'Yes' if schema.wants_mobile(record, person.key) else '—' }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if record.notes %}
<h3 style="font-size:1rem;margin:22px 0 6px;">Customer notes</h3>
<div style="white-space:pre-wrap;background:#f8fafc;border:1px solid #e5e7eb;
border-radius:6px;padding:10px;">{{ record.notes }}</div>
{% endif %}
<hr style="border:none;border-top:1px solid #e5e7eb;margin:26px 0 12px;">
<p style="color:#9ca3af;font-size:.8rem;margin:0;">
You are receiving this because you hold a JQC admin account. The full
selection of tasks per person is on the enrollment page.
</p>
</body>
</html>
@@ -0,0 +1,77 @@
{# Confirmation email sent to the requester. Inline styles only and no external
assets — mail clients strip <style> blocks and block remote resources. #}
<!DOCTYPE html>
<html>
<body style="font-family:Arial,Helvetica,sans-serif;color:#333;max-width:640px;margin:auto;padding:12px;">
<h2 style="color:#1a6fb5;margin:0 0 4px;">Enrollment received</h2>
<p style="color:#6b7280;margin:0 0 20px;">{{ tenant_branding.display_name if tenant_branding else 'Janitorial QC' }}</p>
<p>Hi {{ record.request_by or 'there' }},</p>
<p>Thank you — we have received your JQC enrollment form. Our team will set up
the accounts listed below.</p>
<table style="border-collapse:collapse;margin:18px 0;">
<tr>
<td style="padding:4px 14px 4px 0;color:#6b7280;">Reference</td>
<td style="padding:4px 0;font-weight:bold;">{{ record.id }}</td>
</tr>
<tr>
<td style="padding:4px 14px 4px 0;color:#6b7280;">Project</td>
<td style="padding:4px 0;font-weight:bold;">{{ record.project_name }}</td>
</tr>
{% if record.date_requested %}
<tr>
<td style="padding:4px 14px 4px 0;color:#6b7280;">Date requested</td>
<td style="padding:4px 0;">{{ record.date_requested }}</td>
</tr>
{% endif %}
</table>
<h3 style="font-size:1rem;margin:22px 0 8px;">
People to be set up ({{ people | length }})
</h3>
<table style="border-collapse:collapse;width:100%;font-size:.92rem;">
<thead>
<tr style="background:#dbeafe;">
<th style="border:1px solid #cbd5e1;padding:6px 9px;text-align:left;">Name</th>
<th style="border:1px solid #cbd5e1;padding:6px 9px;text-align:left;">Role</th>
<th style="border:1px solid #cbd5e1;padding:6px 9px;text-align:left;">Email</th>
<th style="border:1px solid #cbd5e1;padding:6px 9px;text-align:center;">Mobile App</th>
</tr>
</thead>
<tbody>
{% for person in people %}
<tr>
<td style="border:1px solid #cbd5e1;padding:6px 9px;">
{{ person.name }}
{% if person.job_title %}
<div style="color:#6b7280;font-size:.82rem;">{{ person.job_title }}</div>
{% endif %}
</td>
<td style="border:1px solid #cbd5e1;padding:6px 9px;">{{ person.role_label }}</td>
<td style="border:1px solid #cbd5e1;padding:6px 9px;">{{ person.email }}</td>
<td style="border:1px solid #cbd5e1;padding:6px 9px;text-align:center;">
{{ 'Yes' if schema.wants_mobile(record, person.key) else '—' }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<p style="margin-top:22px;">
Each person will receive their own email invitation with sign-in
instructions, along with a quick guide to the web portal and the mobile app.
</p>
<p>If anything above is wrong, simply send an email to
<a href="mailto:{{ corrections_email }}">{{ corrections_email }}</a>,
and we will correct it.</p>
<hr style="border:none;border-top:1px solid #e5e7eb;margin:26px 0 12px;">
<p style="color:#9ca3af;font-size:.8rem;margin:0;">
You are receiving this because this address was given as the requester on a
JQC enrollment form. Reference {{ record.id }}.
</p>
</body>
</html>
@@ -0,0 +1,445 @@
<!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 Form — {{ tenant_branding.display_name if tenant_branding else 'Janitorial QC' }}</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:1180px; 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 { min-width:104px; text-align:center; }
.chk-col input { width:18px; height:18px; }
.people-table thead th { background:#dcfce7; }
.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; }
.col-person { font-weight:700; font-size:.84rem; line-height:1.2; }
.col-role { font-weight:400; font-size:.76rem; color:#4b5563; display:block; margin-top:2px; }
.cell-na { color:#cbd5e1; }
.office-note { color:#6b7280; font-size:.82rem; }
.note-list { font-size:.92rem; }
.scroll-x { overflow-x:auto; }
.empty-hint { border:1px dashed #cbd5e1; border-radius:8px; padding:20px;
text-align:center; color:#6b7280; }
/* 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 { min-width:70px; }
}
@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">{{ tenant_branding.display_name if tenant_branding else 'Janitorial QC' }}</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 %}
<noscript>
<div class="alert alert-warning no-print">
This form needs JavaScript enabled — the task table is built from the
people you add. Please enable JavaScript, or contact us and we will send
you a printable copy.
</div>
</noscript>
<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 ─────────────────────────────────────────────────────── #}
{# The printed sheet carried a blank "for office use" block here. It is not
rendered on the web form — a customer cannot fill it in. Those fields
still exist and are filled by staff on the admin detail page. #}
<div class="row g-3 mb-2">
<div class="col-12 col-lg-8">
<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"
placeholder="Your name"
value="{{ submitted.request_by if submitted else '' }}"></td>
</tr>
<tr>
<td class="lbl">Requester email:</td>
<td><input type="email" name="requester_email" required maxlength="200"
placeholder="you@company.com"
value="{{ submitted.requester_email 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 class="form-text mt-1">
We send your confirmation, with a copy of everything below, to the
requester email.
</div>
</div>
</div>
{# ── Step 1 — the people ────────────────────────────────────────── #}
<div class="step-head">
Step 1: <span>Please list everyone who needs access. Each person will
receive an email invitation at the address you provide.</span>
</div>
<div class="scroll-x">
<table class="grid people-table">
<thead>
<tr>
<th class="ref-col">No.</th>
<th class="left" style="min-width:190px;">Role</th>
<th class="left" style="min-width:190px;">First and last name</th>
<th class="left" style="min-width:160px;">Job Title</th>
<th class="left" style="min-width:210px;">Email Address</th>
<th style="width:52px;"></th>
</tr>
</thead>
<tbody id="peopleBody"><!-- rows injected by JS --></tbody>
</table>
</div>
<div class="mt-2 no-print">
<button type="button" class="btn btn-sm btn-outline-primary" id="addPersonBtn">
<i class="bi bi-plus-lg"></i> Add another person
</button>
<span class="text-muted small ms-2" id="peopleCount"></span>
</div>
{# ── Step 2 — the task matrix, built from Step 1 ────────────────── #}
<div class="step-head">
Step 2: <span>Please check the task/function for each user, or apply our
recommended selection and adjust it.</span>
</div>
<div class="mb-2 no-print">
<button type="button" class="btn btn-sm btn-primary" id="recommendBtn">
<i class="bi bi-magic"></i> Recommendation selection
</button>
<button type="button" class="btn btn-sm btn-outline-secondary ms-1" id="clearBtn">
Clear all
</button>
<div class="form-text">
Our recommendation keeps administrators and directors from receiving an
overwhelming number of email notifications. You can change any box afterwards.
</div>
</div>
<div class="scroll-x" id="matrixWrap"><!-- table injected by JS --></div>
{# Rows that come WITH the role rather than being switched on per person.
Ticking them records what you expect; it does not change access. #}
<div class="form-text mt-2">
Rows
{% for ref in schema.ROLE_IMPLIED_TASKS | sort %}{% if not loop.first %}{{ ', ' if not loop.last else ' and ' }}{% endif %}{{ ref }}{% endfor %}
({% for ref in schema.ROLE_IMPLIED_TASKS | sort %}{{ schema.TASK_LABELS[ref] }}{{ '; ' if not loop.last }}{% endfor %})
are included with the user's role where their access allows it — tick them
to record what you expect. The remaining rows control which email and
in-app notifications each user receives.
</div>
{# ── Step 3 — mobile app ────────────────────────────────────────── #}
<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" id="mobileWrap"><!-- table injected by JS --></div>
{# ── Notes ──────────────────────────────────────────────────────── #}
<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>
<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>
(function () {
'use strict';
// ── Data handed over from schema.py — the single source of truth ────────
var ROLES = {{ schema.ROLES | tojson }};
var TASKS = {{ schema.TASKS | tojson }};
var ADMIN_ROLES = {{ schema.ADMIN_ROLES | list | tojson }};
var RECOMMENDATION = {{ schema.recommendation_map() | tojson }};
var DEFAULT_ROLE = {{ schema.DEFAULT_FIRST_ROLE | tojson }};
var MOBILE_LABEL = {{ schema.MOBILE_APP_LABEL | tojson }};
var MAX_PEOPLE = {{ schema.MAX_PEOPLE | tojson }};
var SEED = {{ seed_people | tojson }};
var peopleBody = document.getElementById('peopleBody');
var matrixWrap = document.getElementById('matrixWrap');
var mobileWrap = document.getElementById('mobileWrap');
var countLabel = document.getElementById('peopleCount');
// Row indexes only ever increase, so removing a middle row can never make a
// new row reuse a departed row's field names. The server re-keys people by
// position on receipt, so gaps here are harmless.
var nextIndex = 0;
function isAdminRole(role) { return ADMIN_ROLES.indexOf(role) !== -1; }
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g,'&amp;').replace(/</g,'&lt;')
.replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ── Step 1 rows ─────────────────────────────────────────────────────────
function addPerson(seed) {
if (peopleBody.rows.length >= MAX_PEOPLE) return;
seed = seed || {};
var i = nextIndex++;
var tr = document.createElement('tr');
tr.dataset.index = i;
var options = ROLES.map(function (r) {
var sel = (seed.role || DEFAULT_ROLE) === r[0] ? ' selected' : '';
return '<option value="' + esc(r[0]) + '"' + sel + '>' + esc(r[1]) + '</option>';
}).join('');
tr.innerHTML =
'<td class="ref-col row-num"></td>' +
'<td><select class="form-select form-select-sm person-role" ' +
'name="person_' + i + '_role" aria-label="Role">' + options + '</select></td>' +
'<td><input type="text" class="cell-input person-name" name="person_' + i + '_name" ' +
'maxlength="200" placeholder="First and last name" value="' + esc(seed.name) + '"></td>' +
'<td><input type="text" class="cell-input" name="person_' + i + '_job_title" ' +
'maxlength="200" placeholder="Job title" value="' + esc(seed.job_title) + '"></td>' +
'<td><input type="email" class="cell-input" name="person_' + i + '_email" ' +
'maxlength="200" placeholder="name@company.com" value="' + esc(seed.email) + '"></td>' +
'<td class="text-center no-print">' +
'<button type="button" class="btn btn-sm btn-link text-danger p-0 remove-person" ' +
'title="Remove this person" aria-label="Remove this person">' +
'<i class="bi bi-x-circle"></i></button></td>';
peopleBody.appendChild(tr);
if (seed.tasks) { tr.dataset.seedTasks = seed.tasks.join(','); }
if (seed.mobile) { tr.dataset.seedMobile = '1'; }
return tr;
}
function renumber() {
Array.prototype.forEach.call(peopleBody.rows, function (tr, n) {
tr.querySelector('.row-num').textContent = n + 1;
});
var n = peopleBody.rows.length;
countLabel.textContent = n + (n === 1 ? ' person' : ' people')
+ (n >= MAX_PEOPLE ? ' (maximum reached)' : '');
// Never let the last row be removed — the form needs at least one person.
Array.prototype.forEach.call(peopleBody.rows, function (tr) {
tr.querySelector('.remove-person').style.visibility = n > 1 ? '' : 'hidden';
});
document.getElementById('addPersonBtn').disabled = n >= MAX_PEOPLE;
}
// ── Read the current people out of Step 1 ───────────────────────────────
function currentPeople() {
return Array.prototype.map.call(peopleBody.rows, function (tr, n) {
var name = tr.querySelector('.person-name').value.trim();
var role = tr.querySelector('.person-role').value;
return {
index: tr.dataset.index,
role: role,
label: name || ('Person ' + (n + 1)),
roleLabel: (ROLES.filter(function (r) { return r[0] === role; })[0] || ['', role])[1]
};
});
}
// ── Step 2 + Step 3 tables ──────────────────────────────────────────────
// Rebuilt whenever Step 1 changes. Existing ticks are preserved by field
// name, so renaming someone or adding a colleague never clears the grid.
function renderMatrix() {
var people = currentPeople();
var checked = {};
document.querySelectorAll('.matrix-box:checked, .mobile-box:checked')
.forEach(function (cb) { checked[cb.name] = true; });
// Seeded state from a validation-error re-render, applied once.
Array.prototype.forEach.call(peopleBody.rows, function (tr) {
if (tr.dataset.seedTasks) {
tr.dataset.seedTasks.split(',').filter(Boolean).forEach(function (ref) {
checked['task_' + ref + '_person_' + tr.dataset.index] = true;
});
delete tr.dataset.seedTasks;
}
if (tr.dataset.seedMobile) {
checked['mobile_person_' + tr.dataset.index] = true;
delete tr.dataset.seedMobile;
}
});
if (!people.length) {
matrixWrap.innerHTML = '<div class="empty-hint">Add someone in Step 1 and ' +
'their column will appear here.</div>';
mobileWrap.innerHTML = '';
return;
}
var head = '<tr><th class="ref-col">Ref</th>' +
'<th class="left">Role Descriptions: Tasks and Functions</th>' +
people.map(function (p) {
return '<th class="chk-col"><span class="col-person">' + esc(p.label) +
'</span><span class="col-role">' + esc(p.roleLabel) + '</span></th>';
}).join('') + '</tr>';
var body = TASKS.map(function (t) {
var ref = t[0], label = t[1], scope = t[2];
var cells = people.map(function (p) {
// An admin-only row offers no cell to an inspector — matching the
// server, which refuses to record one.
if (scope !== 'all' && !isAdminRole(p.role)) {
return '<td class="chk-col cell-na" title="Not available for this role">·</td>';
}
var nm = 'task_' + ref + '_person_' + p.index;
return '<td class="chk-col"><input type="checkbox" class="form-check-input matrix-box" ' +
'name="' + nm + '" data-ref="' + ref + '" data-index="' + p.index + '" ' +
'aria-label="' + esc(label) + ' — ' + esc(p.label) + '"' +
(checked[nm] ? ' checked' : '') + '></td>';
}).join('');
return '<tr><td class="ref-col">' + ref + '</td><td>' + esc(label) + '</td>' + cells + '</tr>';
}).join('');
matrixWrap.innerHTML = '<table class="grid"><thead>' + head + '</thead><tbody>' +
body + '</tbody></table>';
var mobileCells = people.map(function (p) {
var nm = 'mobile_person_' + p.index;
return '<td class="chk-col"><input type="checkbox" class="form-check-input mobile-box" ' +
'name="' + nm + '" aria-label="Mobile app — ' + esc(p.label) + '"' +
(checked[nm] ? ' checked' : '') + '></td>';
}).join('');
mobileWrap.innerHTML =
'<table class="grid"><thead><tr><th class="ref-col">No.</th>' +
'<th class="left">Mobile App</th>' +
people.map(function (p) {
return '<th class="chk-col"><span class="col-person">' + esc(p.label) + '</span></th>';
}).join('') +
'</tr></thead><tbody><tr><td class="ref-col">7</td><td>' + esc(MOBILE_LABEL) + '</td>' +
mobileCells + '</tr></tbody></table>';
}
// ── Recommendation preset ───────────────────────────────────────────────
// Applies the mapping from schema.RECOMMENDATION for each person's role.
// Overwrites the grid (that is what "apply the recommendation" means), and
// leaves Step 3 alone — who carries a tablet is not something we can guess.
function applyRecommendation() {
var roleByIndex = {};
Array.prototype.forEach.call(peopleBody.rows, function (tr) {
roleByIndex[tr.dataset.index] = tr.querySelector('.person-role').value;
});
document.querySelectorAll('.matrix-box').forEach(function (cb) {
var preset = RECOMMENDATION[roleByIndex[cb.dataset.index]] || {};
cb.checked = !!preset[cb.dataset.ref];
});
}
// ── Wiring ──────────────────────────────────────────────────────────────
document.getElementById('addPersonBtn').addEventListener('click', function () {
addPerson(); renumber(); renderMatrix();
var rows = peopleBody.rows;
rows[rows.length - 1].querySelector('.person-name').focus();
});
peopleBody.addEventListener('click', function (e) {
var btn = e.target.closest('.remove-person');
if (!btn || peopleBody.rows.length <= 1) return;
btn.closest('tr').remove();
renumber(); renderMatrix();
});
// Role changes the available cells; the name changes the column heading.
peopleBody.addEventListener('change', function (e) {
if (e.target.classList.contains('person-role')) renderMatrix();
});
peopleBody.addEventListener('input', function (e) {
if (e.target.classList.contains('person-name')) renderMatrix();
});
document.getElementById('recommendBtn').addEventListener('click', applyRecommendation);
document.getElementById('clearBtn').addEventListener('click', function () {
document.querySelectorAll('.matrix-box, .mobile-box').forEach(function (cb) {
cb.checked = false;
});
});
// 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…';
});
// ── Initial state ───────────────────────────────────────────────────────
if (SEED && SEED.length) {
SEED.forEach(function (p) { addPerson(p); });
} else {
addPerson(); // one administrative contact to start
}
renumber();
renderMatrix();
})();
</script>
</body>
</html>
@@ -0,0 +1,46 @@
<!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 email %}
<p class="mb-4">
<i class="bi bi-envelope-check text-success"></i>
A confirmation has been sent to <strong>{{ email }}</strong>.
</p>
{% endif %}
{% 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">{{ tenant_branding.display_name if tenant_branding else 'Janitorial QC' }}</div>
</div>
</body>
</html>
+3 -1
View File
@@ -1,11 +1,13 @@
from app.models.user import User from app.models.user import User
from app.models.facility import Facility, Area from app.models.facility import Facility, Area
from app.models.inspection import (InspectionTemplate, ChecklistItem, from app.models.inspection import (InspectionTemplate, ChecklistItem,
Inspection, InspectionResult) Inspection, InspectionResult,
TemplateContract)
from app.models.issue import Issue from app.models.issue import Issue
from app.models.project import Project, CustomerAssignment from app.models.project import Project, CustomerAssignment
from app.models.project_recipient import ProjectNotificationRecipient from app.models.project_recipient import ProjectNotificationRecipient
from app.models.api_token import RefreshToken, DeviceToken from app.models.api_token import RefreshToken, DeviceToken
from app.models.notification_matrix import NotificationMatrix from app.models.notification_matrix import NotificationMatrix
from app.models.user_notification_matrix import UserNotificationMatrix
from app.models.inspection_schedule import InspectionSchedule from app.models.inspection_schedule import InspectionSchedule
from app.models.work_order import IssueWorkOrder from app.models.work_order import IssueWorkOrder
+193
View File
@@ -3,6 +3,43 @@ from app.utils.time_utils import now_eastern
import json import json
class TemplateContract(db.Model):
"""Restricts a form to specific contracts (phase52).
A customer's bespoke form must not be visible to — or startable against —
another customer's facilities. One row = "this template is available on
this contract".
**No rows means the template is SHARED** (available on every contract), not
"available nowhere". That is what makes the feature additive: every
template that existed before phase52 has no rows, so nothing changed on
deploy, and a form becomes customer-specific only when an admin attaches it
to at least one contract. The empty-set-means-all convention is the whole
migration story do not "fix" it to mean the opposite.
"""
__tablename__ = 'template_contracts'
id = db.Column(db.Integer, primary_key=True)
template_id = db.Column(db.Integer,
db.ForeignKey('inspection_templates.id', ondelete='CASCADE'),
nullable=False, index=True)
project_id = db.Column(db.Integer,
db.ForeignKey('projects.id', ondelete='CASCADE'),
nullable=False, index=True)
created_at = db.Column(db.DateTime, default=now_eastern, nullable=False)
project = db.relationship('Project', backref='template_contracts')
__table_args__ = (
db.UniqueConstraint('template_id', 'project_id',
name='uq_template_contract'),
)
def __repr__(self):
return f'<TemplateContract template={self.template_id} project={self.project_id}>'
class InspectionTemplate(db.Model): class InspectionTemplate(db.Model):
__tablename__ = 'inspection_templates' __tablename__ = 'inspection_templates'
@@ -18,6 +55,82 @@ class InspectionTemplate(db.Model):
checklist_items = db.relationship('ChecklistItem', backref='template', lazy='dynamic', cascade='all, delete-orphan') checklist_items = db.relationship('ChecklistItem', backref='template', lazy='dynamic', cascade='all, delete-orphan')
inspections = db.relationship('Inspection', backref='template', lazy='dynamic') inspections = db.relationship('Inspection', backref='template', lazy='dynamic')
# phase52 — contract restrictions. Deleting a template removes its links.
contract_links = db.relationship('TemplateContract', backref='template',
lazy='dynamic',
cascade='all, delete-orphan')
# ── Contract availability (phase52) ──────────────────────────────────
@property
def contract_ids(self):
"""Project ids this form is restricted to; empty = shared with all."""
return sorted(l.project_id for l in self.contract_links.all())
@property
def is_shared(self):
"""True when the form carries no restriction and is available anywhere."""
return self.contract_links.count() == 0
def available_for_project(self, project_id):
"""Can this form be used on `project_id`?
Shared forms are usable anywhere, including on a facility that has no
contract at all. A restricted form needs an explicit link, so a
facility with no contract (project_id None) can only ever use shared
forms fail-closed, which is the right side to err on.
"""
if self.is_shared:
return True
if project_id is None:
return False
return project_id in set(self.contract_ids)
@staticmethod
def available_query(project_id, active_only=True):
"""Query of templates usable on `project_id` (shared + linked).
The single definition of "which forms may this contract use". Every
picker, the POST validation behind it, and the mobile API all go
through here so they cannot disagree a picker that offers more than
the validator accepts silently drops work (see rule 93 for the same
failure in the assignee dropdown).
"""
q = InspectionTemplate.query
if active_only:
q = q.filter(InspectionTemplate.active == True)
shared = ~InspectionTemplate.contract_links.any()
if project_id is None:
# No contract to match against — only unrestricted forms apply.
return q.filter(shared).order_by(InspectionTemplate.name)
linked = InspectionTemplate.contract_links.any(
TemplateContract.project_id == project_id
)
return q.filter(db.or_(shared, linked)).order_by(InspectionTemplate.name)
def set_contracts(self, project_ids):
"""Replace this form's contract restrictions.
Pass an empty list to make the form shared again. Does NOT commit
the caller owns the transaction. Returns True if anything changed.
"""
wanted = {int(p) for p in project_ids or []}
existing = {l.project_id: l for l in self.contract_links.all()}
changed = False
for pid, link in existing.items():
if pid not in wanted:
db.session.delete(link)
changed = True
for pid in wanted:
if pid not in existing:
db.session.add(TemplateContract(template_id=self.id, project_id=pid))
changed = True
return changed
def get_form_schema(self): def get_form_schema(self):
if self.form_schema is None: if self.form_schema is None:
return [] return []
@@ -81,11 +194,91 @@ class Inspection(db.Model):
) )
follow_up_required = db.Column(db.Boolean, nullable=False, default=False) follow_up_required = db.Column(db.Boolean, nullable=False, default=False)
follow_up_note = db.Column(db.Text, nullable=True) follow_up_note = db.Column(db.Text, nullable=True)
# phase49 — WHO asked for the follow-up and when. `follow_up_required` alone
# cannot distinguish a client request from an internal one, and staff need to
# know who is waiting. Set by flag_followup(), nulled by clear_followup().
# NULL on every pre-phase49 row, which the UI renders as an unattributed
# follow-up exactly as before.
follow_up_requested_by = db.Column(
db.Integer,
db.ForeignKey('users.id', ondelete='SET NULL',
name='fk_inspections_follow_up_requested_by'),
nullable=True,
)
follow_up_requested_at = db.Column(db.DateTime, nullable=True)
# phase56 — who is to PERFORM the follow-up re-inspection.
#
# NULL keeps the original behaviour: the follow-up belongs to the
# inspection's own inspector. When set, that person owns it instead — they
# are the one notified, and the one it appears for on the iPad. Lets a
# director (or a Customer Director) hand a re-inspection to someone other
# than whoever did the original.
#
# This is the THIRD FK from inspections to users: every relationship
# spanning the two must pin foreign_keys explicitly, or the mapper is
# ambiguous and blows up on first ORM USE rather than at import — the app
# starts cleanly and then every request 500s.
follow_up_assigned_to = db.Column(
db.Integer,
db.ForeignKey('users.id', ondelete='SET NULL',
name='fk_inspections_followup_assignee'),
nullable=True,
)
results = db.relationship('InspectionResult', backref='inspection', lazy='dynamic', cascade='all, delete-orphan') results = db.relationship('InspectionResult', backref='inspection', lazy='dynamic', cascade='all, delete-orphan')
issues = db.relationship('Issue', backref='inspection', lazy='dynamic', cascade='all, delete-orphan') issues = db.relationship('Issue', backref='inspection', lazy='dynamic', cascade='all, delete-orphan')
follow_ups = db.relationship('Inspection', backref=db.backref('parent', remote_side='Inspection.id'), follow_ups = db.relationship('Inspection', backref=db.backref('parent', remote_side='Inspection.id'),
lazy='dynamic', foreign_keys='Inspection.parent_inspection_id') lazy='dynamic', foreign_keys='Inspection.parent_inspection_id')
# phase49. Explicit foreign_keys is required: inspector_id also points at
# users.id, so SQLAlchemy cannot infer which column this relationship uses.
follow_up_requester = db.relationship('User',
foreign_keys=[follow_up_requested_by])
follow_up_assignee = db.relationship('User',
foreign_keys=[follow_up_assigned_to])
@property
def follow_up_owner(self):
"""Who is expected to carry out the follow-up.
The explicit assignee when one is set, otherwise the inspection's own
inspector the single definition of ownership, so the web display, the
notification and the mobile API filter cannot disagree about who owns a
follow-up.
"""
return self.follow_up_assignee or self.inspector
@staticmethod
def follow_up_owned_by(user_id):
"""SQL predicate: *user_id* owns this inspection's follow-up.
The query-side mirror of `follow_up_owner` above. Ownership has to be
expressed twice once for a loaded row, once in SQL so both live
here, together, and every caller uses one of them.
The two arms are mutually exclusive on purpose. Drop the `is_(None)`
from the second and an inspector keeps matching a follow-up that was
handed to someone else: two people turn up for the same re-inspection.
Callers: the mobile list filter, the web dashboard card, and the iPad
stats KPI. They previously each wrote their own version, and three of
them tested AUTHORSHIP so an assignee saw the work in their list but
a 0 on both dashboards.
"""
return db.or_(
Inspection.follow_up_assigned_to == user_id,
db.and_(
Inspection.follow_up_assigned_to.is_(None),
Inspection.inspector_id == user_id,
),
)
# The schedule this inspection was started from / materialised by, so the
# detail view can show the cadence and who set it up. Explicit foreign_keys
# again: inspection_schedules.parent_inspection_id points back here (phase48),
# so neither side's join is inferable.
inspection_schedule = db.relationship(
'InspectionSchedule', foreign_keys=[inspection_schedule_id])
def __repr__(self): def __repr__(self):
return f'<Inspection {self.id} - {self.inspection_date}>' return f'<Inspection {self.id} - {self.inspection_date}>'
+374 -8
View File
@@ -27,12 +27,93 @@ phase43 adds the single-tenant "plan" semantics alongside that:
deactivates a one-time schedule or rolls a recurring one forward. deactivates a one-time schedule or rolls a recurring one forward.
`next_run_at` is the due datetime for both modes. `next_run_at` is the due datetime for both modes.
phase45/46/47 port the single-tenant recurrence + end-date model
(ST phase43 + phase44) onto this table without renaming anything:
phase45 frequency ENUM gains 'once', 'bi-annually' and 'annually'
phase46 weekdays / month_mode / day_of_month / nth_week / nth_weekday
phase47 end_date
`next_run_at` keeps its name, its DATETIME type and its index it remains the
due datetime, and is ST's `next_due_date` by another name. All recurrence maths
happens on its DATE part; the TIME part is preserved across roll-forwards
(defaulting to 06:00, the hour `_compute_next_run()` has always used) so the
auto-mode cron keeps firing at the same time of day.
Two dates, deliberately distinct:
next_run_at mutable state. The next occurrence. Rewritten by fulfill()
after every completed inspection.
end_date fixed boundary. The last date an occurrence may fall on, set by
the manager and never rewritten. NULL = repeat indefinitely.
""" """
import calendar
from datetime import date, datetime, time, timedelta
from app import db from app import db
from app.utils.time_utils import now_eastern from app.utils.time_utils import now_eastern
# Every frequency this table accepts (phase45). 'bi-annually' is every 6 months.
FREQUENCY_CHOICES = ('once', 'daily', 'weekly', 'monthly',
'quarterly', 'bi-annually', 'annually')
# Recurring frequencies that advance by whole calendar months.
_MONTH_STEPS = {
'monthly': 1,
'quarterly': 3,
'bi-annually': 6,
'annually': 12,
}
# Monthly recurrence styles (phase46). Stored as VARCHAR, not ENUM, so adding a
# style later needs no 3-step MySQL ENUM migration.
MONTH_MODE_DAY = 'day_of_month' # "the 15th of every month"
MONTH_MODE_NTH = 'nth_weekday' # "the 2nd Tuesday of every month"
# Python weekday numbering: Monday=0 … Sunday=6 (matches date.weekday()).
WEEKDAY_NAMES = ('Monday', 'Tuesday', 'Wednesday', 'Thursday',
'Friday', 'Saturday', 'Sunday')
WEEKDAY_ABBREV = ('Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun')
# nth_week: 14 are literal, 5 means "5th (or last if the month is short)",
# -1 means "last" explicitly.
NTH_WEEK_LABELS = {1: '1st', 2: '2nd', 3: '3rd', 4: '4th', 5: '5th', -1: 'Last'}
# The hour auto-mode schedules have always been due at (see _compute_next_run()
# in app/routes/inspection_schedules.py). Used when a schedule has no
# next_run_at yet and therefore no time-of-day to preserve.
DEFAULT_RUN_HOUR = 6
def _last_day_of(year, month):
return calendar.monthrange(year, month)[1]
def _shift_month(year, month, n=1):
"""Return (year, month) shifted by *n* months."""
idx = year * 12 + (month - 1) + n
return idx // 12, idx % 12 + 1
def _nth_weekday_of(year, month, weekday, nth):
"""Date of the *nth* *weekday* in a month.
``nth == -1`` means the last one. A requested 5th occurrence that does not
exist falls back to the 4th, so every month yields a valid date.
"""
last = _last_day_of(year, month)
if nth == -1:
d = date(year, month, last)
return d - timedelta(days=(d.weekday() - weekday) % 7)
first = date(year, month, 1)
day = 1 + ((weekday - first.weekday()) % 7) + (nth - 1) * 7
while day > last:
day -= 7
return date(year, month, day)
class InspectionSchedule(db.Model): class InspectionSchedule(db.Model):
__tablename__ = 'inspection_schedules' __tablename__ = 'inspection_schedules'
@@ -56,9 +137,11 @@ class InspectionSchedule(db.Model):
nullable=False nullable=False
) )
# daily | weekly | monthly | quarterly — mirrors InspectionTemplate.frequency # once | daily | weekly | monthly | quarterly | bi-annually | annually
# (phase45 — 'once', 'bi-annually' and 'annually' added; the original four
# values are unchanged, so no existing row is affected.)
frequency = db.Column( frequency = db.Column(
db.Enum('daily', 'weekly', 'monthly', 'quarterly'), db.Enum(*FREQUENCY_CHOICES),
nullable=False, default='weekly' nullable=False, default='weekly'
) )
active = db.Column(db.Boolean, nullable=False, default=True) active = db.Column(db.Boolean, nullable=False, default=True)
@@ -68,6 +151,41 @@ class InspectionSchedule(db.Model):
mode = db.Column(db.Enum('auto', 'plan'), nullable=False, default='auto') mode = db.Column(db.Enum('auto', 'plan'), nullable=False, default='auto')
notes = db.Column(db.Text, nullable=True) notes = db.Column(db.Text, nullable=True)
# ── Follow-up link (phase48) ─────────────────────────────────────────────
# Set when this schedule was created as a follow-up of a specific completed
# inspection ("Schedule Follow-up" in the iPad's history detail — the
# deferred twin of "Re-inspect Now"). The inspection eventually started from
# this schedule inherits it as its own parent_inspection_id, so the run
# lands as a true linked re-inspection: pre-filled from the parent, and
# clearing the parent's follow_up_required on submit. NULL = an ordinary
# schedule, which is what every pre-phase48 row is.
parent_inspection_id = db.Column(
db.Integer,
# use_alter + an explicit name: inspections and inspection_schedules now
# reference each other, so metadata-driven CREATE/DROP cannot topologically
# sort them. The name matches the constraint phase48 creates, so the ORM's
# view of the schema and the migration's agree.
db.ForeignKey('inspections.id', ondelete='SET NULL',
name='fk_inspection_schedules_parent_inspection',
use_alter=True),
nullable=True, index=True,
)
# ── Receipt acknowledgement (phase50) ────────────────────────────────────
# Stamped when the assigned inspector confirms they received the request.
# NULL = awaiting confirmation.
#
# Per ASSIGNMENT, not per occurrence: fulfill() and advance_due_date()
# deliberately leave this alone as the schedule rolls forward, so an
# inspector who confirmed "yes, this weekly round is mine" is not asked
# again every week. The edit route resets it to NULL on reassignment to a
# DIFFERENT inspector, who has confirmed nothing.
#
# The acknowledger is always `inspector` — the only person the routes let
# confirm — so no separate acknowledged_by column is needed. Only meaningful
# for mode='plan'; an auto schedule has no request to receive.
acknowledged_at = db.Column(db.DateTime, nullable=True)
created_by = db.Column( created_by = db.Column(
db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'), db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'),
nullable=True nullable=True
@@ -76,6 +194,30 @@ class InspectionSchedule(db.Model):
last_run_at = db.Column(db.DateTime, nullable=True) # last successful materialisation last_run_at = db.Column(db.DateTime, nullable=True) # last successful materialisation
next_run_at = db.Column(db.DateTime, nullable=True) # when the next inspection is due next_run_at = db.Column(db.DateTime, nullable=True) # when the next inspection is due
# ── Recurrence detail (phase46) ──────────────────────────────────────────
# weekly : CSV of Python weekday ints, e.g. '0,2,4' = Mon/Wed/Fri.
# NULL/empty falls back to the legacy "every 7 days" behaviour.
# monthly and the other month-stepping frequencies:
# month_mode picks which pair of columns applies —
# MONTH_MODE_DAY → day_of_month; MONTH_MODE_NTH → nth_week + nth_weekday.
# NULL falls back to the legacy "same day next period" behaviour.
# Every pre-phase46 row keeps NULLs here and therefore keeps its exact
# current cadence.
weekdays = db.Column(db.String(20), nullable=True)
month_mode = db.Column(db.String(20), nullable=True)
day_of_month = db.Column(db.SmallInteger, nullable=True)
nth_week = db.Column(db.SmallInteger, nullable=True)
nth_weekday = db.Column(db.SmallInteger, nullable=True)
# ── End-date boundary (phase47) ──────────────────────────────────────────
# Fixed boundary set by the manager, never rewritten by the app — unlike
# next_run_at, which fulfill() advances after every completed inspection.
# NULL = repeat indefinitely, which is what every pre-phase47 row is. Only
# meaningful for recurring schedules; the create/edit routes force it to
# NULL when frequency == 'once'. Applies to BOTH modes: without it an auto
# schedule would keep materialising inspections past its boundary forever.
end_date = db.Column(db.Date, nullable=True)
# phase43 — plan mode bookkeeping # phase43 — plan mode bookkeeping
last_completed_at = db.Column(db.DateTime, nullable=True) last_completed_at = db.Column(db.DateTime, nullable=True)
# Per-occurrence reminder de-dup flags; reset when a recurring schedule rolls forward. # Per-occurrence reminder de-dup flags; reset when a recurring schedule rolls forward.
@@ -89,12 +231,31 @@ class InspectionSchedule(db.Model):
area = db.relationship('Area', foreign_keys=[area_id]) area = db.relationship('Area', foreign_keys=[area_id])
inspector = db.relationship('User', foreign_keys=[inspector_id]) inspector = db.relationship('User', foreign_keys=[inspector_id])
creator = db.relationship('User', foreign_keys=[created_by]) creator = db.relationship('User', foreign_keys=[created_by])
# phase48. Explicit foreign_keys is required, not optional: inspections and
# inspection_schedules now reference each other (Inspection
# .inspection_schedule_id points here, parent_inspection_id points back), so
# SQLAlchemy cannot infer the join for either side.
parent_inspection = db.relationship('Inspection',
foreign_keys=[parent_inspection_id])
@property
def is_follow_up(self):
"""True when this schedule was created to follow up an inspection."""
return self.parent_inspection_id is not None
@property
def is_acknowledged(self):
"""True once the assigned inspector has confirmed receipt (phase50)."""
return self.acknowledged_at is not None
FREQUENCY_LABELS = { FREQUENCY_LABELS = {
'once': 'One-time',
'daily': 'Daily', 'daily': 'Daily',
'weekly': 'Weekly', 'weekly': 'Weekly',
'monthly': 'Monthly', 'monthly': 'Monthly',
'quarterly': 'Quarterly', 'quarterly': 'Quarterly',
'bi-annually': 'Every 6 months',
'annually': 'Annually',
} }
@property @property
@@ -113,18 +274,223 @@ class InspectionSchedule(db.Model):
today = today or now_eastern().date() today = today or now_eastern().date()
return self.next_run_at.date() < today return self.next_run_at.date() < today
# ── Recurrence accessors (phase46) ───────────────────────────────────────
@property
def weekday_list(self):
"""Selected weekdays as a sorted list of ints (Mon=0). [] if unset."""
if not self.weekdays:
return []
out = set()
for part in str(self.weekdays).split(','):
part = part.strip()
if part.lstrip('-').isdigit() and 0 <= int(part) <= 6:
out.add(int(part))
return sorted(out)
def set_weekdays(self, values):
"""Store an iterable of weekday ints as the CSV column (None if empty)."""
clean = sorted({int(v) for v in (values or []) if 0 <= int(v) <= 6})
self.weekdays = ','.join(str(v) for v in clean) or None
@property
def recurrence_label(self):
"""Human summary of the recurrence rule, e.g. 'Weekly · Mon, Wed, Fri'."""
base = self.frequency_label
if self.frequency == 'weekly':
days = self.weekday_list
if days:
return f"{base} · {', '.join(WEEKDAY_ABBREV[d] for d in days)}"
elif self.frequency in _MONTH_STEPS:
if self.month_mode == MONTH_MODE_NTH and self.nth_week and self.nth_weekday is not None:
nth = NTH_WEEK_LABELS.get(self.nth_week, str(self.nth_week))
return f'{base} · {nth} {WEEKDAY_NAMES[self.nth_weekday]}'
if self.day_of_month:
return f'{base} · day {self.day_of_month}'
return base
# ── Date arithmetic (phase46) ────────────────────────────────────────────
@property
def run_time(self):
"""Time-of-day this schedule is due at.
Preserved across roll-forwards so an auto schedule keeps materialising
at the hour it always has. Falls back to DEFAULT_RUN_HOUR for a schedule
that has no next_run_at yet.
"""
if self.next_run_at is not None:
return self.next_run_at.time()
return time(hour=DEFAULT_RUN_HOUR)
def set_next_run_date(self, d):
"""Set next_run_at to date *d* keeping the current time-of-day."""
if d is None:
self.next_run_at = None
else:
self.next_run_at = datetime.combine(d, self.run_time)
@staticmethod
def _add_interval(d, frequency):
"""Return date *d* advanced by one plain interval of *frequency*.
Fallback used when no day-of-week / day-of-month detail is configured
(every pre-phase46 row). Prefer :meth:`next_occurrence_after`.
"""
if frequency == 'daily':
return d + timedelta(days=1)
if frequency == 'weekly':
return d + timedelta(weeks=1)
step = _MONTH_STEPS.get(frequency)
if step:
year, month = _shift_month(d.year, d.month, step)
return date(year, month, min(d.day, _last_day_of(year, month)))
return d # 'once' has no next interval
def next_occurrence_after(self, d):
"""First occurrence strictly after date *d*, honouring the day rules."""
if self.frequency == 'weekly':
days = self.weekday_list
if days:
for step in range(1, 8):
cand = d + timedelta(days=step)
if cand.weekday() in days:
return cand
elif self.frequency in _MONTH_STEPS:
year, month = _shift_month(d.year, d.month, _MONTH_STEPS[self.frequency])
if self.month_mode == MONTH_MODE_NTH and self.nth_week and self.nth_weekday is not None:
return _nth_weekday_of(year, month, self.nth_weekday, self.nth_week)
if self.day_of_month:
return date(year, month, min(self.day_of_month, _last_day_of(year, month)))
return self._add_interval(d, self.frequency)
def align_due_date(self, d):
"""Snap *d* forward to the first date on/after it that fits the rule.
Lets a manager pick any start date and still get, say, Mon/Wed/Fri:
picking a Tuesday for a Mon/Wed/Fri schedule yields that Wednesday.
"""
if self.frequency == 'weekly':
days = self.weekday_list
if days:
for step in range(0, 7):
cand = d + timedelta(days=step)
if cand.weekday() in days:
return cand
elif self.frequency in _MONTH_STEPS:
if self.month_mode == MONTH_MODE_NTH and self.nth_week and self.nth_weekday is not None:
cand = _nth_weekday_of(d.year, d.month, self.nth_weekday, self.nth_week)
elif self.day_of_month:
cand = date(d.year, d.month,
min(self.day_of_month, _last_day_of(d.year, d.month)))
else:
return d
if cand < d:
return self.next_occurrence_after(cand)
return cand
return d
# ── End-date boundary (phase47) ──────────────────────────────────────────
def is_within_end_date(self, d):
"""True if date *d* is on or before the end date (inclusive).
No end date means the schedule repeats indefinitely, so every date
qualifies.
"""
return self.end_date is None or d <= self.end_date
@property
def is_expired(self):
"""True once the end date has passed.
Independent of `active`: a schedule can be inactive because it expired
or because a manager paused it, and the list view distinguishes the two.
Compares against the *end date* rather than next_run_at, which may have
been advanced past the boundary by fulfill().
"""
if self.end_date is None:
return False
return self.end_date < now_eastern().date()
def expire_if_past_end_date(self, today=None):
"""Deactivate a schedule whose end date has passed. Caller commits.
Returns True if this call changed anything. Needed because a schedule
can reach its end date *without ever being completed* fulfill() never
runs, so the boundary would otherwise be checked nowhere and the cron
would keep materialising (auto) or re-alerting as overdue (plan)
forever. Called from the /run cron endpoint.
"""
today = today or now_eastern().date()
if self.active and self.end_date is not None and self.end_date < today:
self.active = False
return True
return False
# ── Roll-forward ─────────────────────────────────────────────────────────
def advance_due_date(self, now=None):
"""Move next_run_at to the first occurrence after today. Caller commits.
Shared by fulfill() (plan mode, the inspector submitted the inspection)
and the cron materialiser (auto mode, an occurrence was produced), so
both modes obey the same recurrence rules and the same end-date
boundary. A 'once' schedule deactivates and its due date is left where
it is. Returns the new due date, or None for 'once'.
Advances from the current due date rather than from now, so a Mon/Wed/Fri
schedule dealt with late stays on Mon/Wed/Fri.
"""
now = now or now_eastern()
if self.frequency == 'once':
self.active = False
return None
today = now.date()
base = self.due_date or today
nxt = self.next_occurrence_after(base)
guard = 0
# Guard stops a misconfigured row spinning; 400 covers a daily schedule
# left untouched for over a year.
while nxt <= today and guard < 400:
nxt = self.next_occurrence_after(nxt)
guard += 1
self.set_next_run_date(nxt)
# Past the manager's boundary: this was the last occurrence. next_run_at
# is left at the computed value rather than clamped, so the row still
# shows which occurrence it stopped before.
if not self.is_within_end_date(nxt):
self.active = False
return nxt
def fulfill(self, next_run_fn=None): def fulfill(self, next_run_fn=None):
"""Mark this occurrence complete. Caller commits. """Mark this occurrence complete. Caller commits.
Recurring schedules roll their due date forward past today and reset the One-time schedules deactivate. Recurring ones roll their due date
reminder flags; MT has no 'once' frequency, so a schedule stays active. forward past today, honouring the phase46 day rules, and reset the
`next_run_fn(frequency, from_dt)` computes the next due datetime the reminder flags. A recurring schedule whose next occurrence would fall
route passes `_compute_next_run` so the cadence math lives in one place. past its end date deactivates instead (phase47).
`next_run_fn` is accepted and IGNORED, retained only so the existing
`fulfill(next_run_fn=_compute_next_run)` call sites in
routes/inspections.py and api/inspections.py keep working unchanged.
Before phase46 the cadence maths lived in the route and omitting this
argument silently left next_run_at untouched leaving the schedule
perpetually due. The maths now lives here, on the object that owns the
recurrence columns, so that failure mode is unreachable.
""" """
now = now_eastern() now = now_eastern()
self.last_completed_at = now self.last_completed_at = now
if next_run_fn is not None:
self.next_run_at = next_run_fn(self.frequency, now) self.advance_due_date(now)
if not self.active:
# 'once', or the roll-forward crossed the end date. Either way this
# was the last occurrence — leave the reminder flags set so nothing
# re-fires against a closed schedule.
return
self.advance_notified = False self.advance_notified = False
self.due_notified = False self.due_notified = False
self.overdue_notified = False self.overdue_notified = False
+165 -9
View File
@@ -43,6 +43,122 @@ class IssueFollower(db.Model):
return f'<IssueFollower issue={self.issue_id} user={self.user_id}>' return f'<IssueFollower issue={self.issue_id} user={self.user_id}>'
# ── Issue Link ────────────────────────────────────────────────────────────────
# Connects two issues so staff can jump between a duplicate and the original, or
# between issues that are simply about the same thing.
class IssueLink(db.Model):
"""One directed link between two issues, displayed on BOTH of them.
Only one row is stored per pair. The stored direction carries meaning for
'duplicate' issue_id is a duplicate OF linked_issue_id so the two issues
read the same row differently:
on issue_id -> "Duplicate of #B"
on linked_issue_id -> "Duplicated by #A"
'related' is symmetric and reads "Related to" from either side.
Storing one row rather than a mirrored pair is what keeps the direction
unambiguous and makes unlinking a single delete. The cost is that uniqueness
cannot be expressed by the UniqueConstraint alone: (A,B) and (B,A) are
distinct rows to the database but the same link to a person, so the
duplicate check has to look in both directions. exists_between() is that
check, and it is the only thing callers should use.
A link is PURELY NAVIGATIONAL. Marking a duplicate does not touch either
issue's status, SLA, assignee or followers — closing the duplicate stays a
deliberate, separate action.
Multi-tenant: nothing here is tenant-aware, and deliberately so. The table
lives in the tenant database and every query routes through RoutingSession,
so a link can only ever reach an issue in the same tenant. Scope WITHIN a
tenant is the caller's job — see _readable_links() in routes/issues.py.
"""
__tablename__ = 'issue_links'
TYPE_DUPLICATE = 'duplicate'
TYPE_RELATED = 'related'
# How each link type reads from the two sides, keyed by (type, is_source).
LABELS = {
('duplicate', True): 'Duplicate of',
('duplicate', False): 'Duplicated by',
('related', True): 'Related to',
('related', False): 'Related to',
}
# Offered in the "Link an issue" picker. The value is what gets stored; the
# phrasing is from the point of view of the issue being viewed.
TYPE_CHOICES = [
('duplicate', 'Duplicate of'),
('related', 'Related to'),
]
id = db.Column(db.Integer, primary_key=True)
issue_id = db.Column(db.Integer,
db.ForeignKey('issues.id', ondelete='CASCADE'),
nullable=False, index=True)
linked_issue_id = db.Column(db.Integer,
db.ForeignKey('issues.id', ondelete='CASCADE'),
nullable=False, index=True)
link_type = db.Column(db.Enum('duplicate', 'related'),
nullable=False, default='related')
created_by = db.Column(db.Integer,
db.ForeignKey('users.id', ondelete='SET NULL'),
nullable=True)
created_at = db.Column(db.DateTime, default=now_eastern, nullable=False)
__table_args__ = (
# Catches the exact-duplicate row at the database level. The REVERSE
# direction is caught by exists_between() — see the class docstring.
db.UniqueConstraint('issue_id', 'linked_issue_id', name='uq_issue_link'),
)
# BOTH relationships must pin foreign_keys: two FKs from this table to
# issues leave the join condition ambiguous otherwise, and the mapper raises
# on first ORM USE rather than at import — the app starts cleanly and then
# every request 500s (the phase56 lesson, CLAUDE.md §17).
issue = db.relationship('Issue', foreign_keys=[issue_id],
back_populates='links_from')
linked_issue = db.relationship('Issue', foreign_keys=[linked_issue_id],
back_populates='links_to')
creator = db.relationship('User', foreign_keys=[created_by])
def label_for(self, viewing_issue_id):
"""How this link reads on the issue currently being viewed."""
return self.LABELS[(self.link_type, self.issue_id == viewing_issue_id)]
def other_issue(self, viewing_issue_id):
"""The issue at the far end of this link from the one being viewed."""
return (self.linked_issue if self.issue_id == viewing_issue_id
else self.issue)
@staticmethod
def exists_between(issue_id, other_id):
"""True when the two issues are already linked, in EITHER direction.
The UniqueConstraint only covers the stored direction, so this is what
stops #A being linked to #B and then #B linked back to #A as a second,
contradictory row.
"""
return db.session.query(
IssueLink.query.filter(
db.or_(
db.and_(IssueLink.issue_id == issue_id,
IssueLink.linked_issue_id == other_id),
db.and_(IssueLink.issue_id == other_id,
IssueLink.linked_issue_id == issue_id),
)
).exists()
).scalar()
def __repr__(self):
return (f'<IssueLink {self.issue_id} {self.link_type} '
f'{self.linked_issue_id}>')
class Issue(db.Model): class Issue(db.Model):
__tablename__ = 'issues' __tablename__ = 'issues'
@@ -85,18 +201,24 @@ class Issue(db.Model):
vendor_notes = db.Column(db.Text, nullable=True) vendor_notes = db.Column(db.Text, nullable=True)
# Handler type — who is responsible for resolving the issue (phase39). # Handler type — who is responsible for resolving the issue (phase39).
# NULL and 'internal' both mean janitorial staff (the default); 'facility' # 'internal' means janitorial staff (the default); 'facility' unlocks the
# unlocks the facility_handler_* sub-fields; 'vendor' points to vendor_*. # facility_handler_* sub-fields; 'vendor' points to vendor_*.
handler_type = db.Column(db.Enum('internal', 'facility', 'vendor'), nullable=True) # NOT NULL DEFAULT 'internal' since phase44 — previously nullable, with NULL
# treated as a synonym for 'internal'. Existing NULLs were backfilled by that
# migration, so the two representations are now one.
handler_type = db.Column(
db.Enum('internal', 'facility', 'vendor'),
nullable=False, default='internal',
)
facility_handler_name = db.Column(db.String(100), nullable=True) facility_handler_name = db.Column(db.String(100), nullable=True)
facility_handler_contact = db.Column(db.String(200), nullable=True) facility_handler_contact = db.Column(db.String(200), nullable=True)
facility_handler_notes = db.Column(db.Text, nullable=True) facility_handler_notes = db.Column(db.Text, nullable=True)
# Free-text name of the janitorial staff member who will handle the issue,
HANDLER_LABELS = { # used when handler_type == 'internal'. Distinct from assigned_to (the JQC
'internal': 'Janitorial Staff', # User who owns follow-up): the actual crew member may not be a system user.
'facility': 'Facility Staff', # (phase44)
'vendor': 'External Vendor', internal_handler_name = db.Column(db.String(100), nullable=True)
} internal_handler_contact = db.Column(db.String(200), nullable=True) # phone or email
# Relationships # Relationships
# NOTE: Issue.area is provided by the backref on Area.issues (facility.py). # NOTE: Issue.area is provided by the backref on Area.issues (facility.py).
@@ -112,10 +234,37 @@ class Issue(db.Model):
followers = db.relationship('IssueFollower', back_populates='issue', followers = db.relationship('IssueFollower', back_populates='issue',
cascade='all, delete-orphan', lazy='dynamic') cascade='all, delete-orphan', lazy='dynamic')
# An issue link is stored once and shown on both issues, so each issue has
# rows pointing OUT of it and rows pointing AT it. Deleting an issue must
# take its links with it from BOTH sides, or the surviving issue keeps a row
# referencing one that no longer exists.
links_from = db.relationship('IssueLink', back_populates='issue',
foreign_keys='IssueLink.issue_id',
cascade='all, delete-orphan', lazy='dynamic')
links_to = db.relationship('IssueLink', back_populates='linked_issue',
foreign_keys='IssueLink.linked_issue_id',
cascade='all, delete-orphan', lazy='dynamic')
def is_followed_by(self, user): def is_followed_by(self, user):
"""Return True if the given user is currently following this issue.""" """Return True if the given user is currently following this issue."""
return self.followers.filter_by(user_id=user.id).first() is not None return self.followers.filter_by(user_id=user.id).first() is not None
def all_links(self):
"""Every link touching this issue, from both directions, newest first.
The two relationships are a storage detail a link is one thing to the
person reading it, so callers get a single list and ask each row how it
reads via label_for() / other_issue().
Nothing here filters by permission. The caller MUST drop links whose far
end the viewer cannot access, or a link becomes a way to read an issue
at a facility they hold no assignment to. See _readable_links() in
routes/issues.py.
"""
links = list(self.links_from) + list(self.links_to)
links.sort(key=lambda link: link.created_at, reverse=True)
return links
# Display labels for handler_type. The web templates hardcode these inline; # Display labels for handler_type. The web templates hardcode these inline;
# this mapping exists so the mobile API can return a human-readable label # this mapping exists so the mobile API can return a human-readable label
# without the client duplicating the strings. (phase43) # without the client duplicating the strings. (phase43)
@@ -124,6 +273,13 @@ class Issue(db.Model):
'facility': 'Facility Staff', 'facility': 'Facility Staff',
'vendor': 'External Vendor', 'vendor': 'External Vendor',
} }
# One-line explanation per handler type, shown under the radio options on the
# issue form so staff pick the right one. (phase44)
HANDLER_DESCRIPTIONS = {
'internal': 'Our janitorial crew handles it.',
'facility': "The facility's own on-site staff handle it.",
'vendor': 'An outside contractor handles it.',
}
@property @property
def handler_label(self): def handler_label(self):
+7
View File
@@ -37,6 +37,12 @@ EVENT_INSPECTION_SCHEDULED = 'inspection_scheduled'
# order via the tokenized public link (phase36). # order via the tokenized public link (phase36).
EVENT_WORK_ORDER = 'work_order_update' EVENT_WORK_ORDER = 'work_order_update'
# Fired when a follow-up re-inspection is requested — by a manager, or (phase49)
# by a customer against their own facility. Routed through notify_by_matrix so
# recipients stay admin-configurable; the inspection's own inspector is notified
# directly by the route rather than through the matrix.
EVENT_FOLLOWUP_REQUESTED = 'followup_requested'
ALL_EVENT_TYPES = { ALL_EVENT_TYPES = {
EVENT_ISSUE_ASSIGNED: 'Issue assigned to me', EVENT_ISSUE_ASSIGNED: 'Issue assigned to me',
EVENT_ISSUE_STATUS: 'Issue status changed', EVENT_ISSUE_STATUS: 'Issue status changed',
@@ -48,6 +54,7 @@ ALL_EVENT_TYPES = {
EVENT_ADMIN_BROADCAST: 'Admin broadcast (system announcements)', EVENT_ADMIN_BROADCAST: 'Admin broadcast (system announcements)',
EVENT_INSPECTION_SCHEDULED: 'Scheduled inspection due (assigned to me)', EVENT_INSPECTION_SCHEDULED: 'Scheduled inspection due (assigned to me)',
EVENT_WORK_ORDER: 'Contractor updated a work order', EVENT_WORK_ORDER: 'Contractor updated a work order',
EVENT_FOLLOWUP_REQUESTED: 'Follow-up re-inspection requested',
# Customer-facing — only relevant for customer role accounts # Customer-facing — only relevant for customer role accounts
EVENT_CUSTOMER_INSPECTION_DONE: 'Inspection completed at my facility (portal)', EVENT_CUSTOMER_INSPECTION_DONE: 'Inspection completed at my facility (portal)',
EVENT_CUSTOMER_ISSUE_UPDATED: 'Issue created or updated at my facility (portal)', EVENT_CUSTOMER_ISSUE_UPDATED: 'Issue created or updated at my facility (portal)',
+37 -1
View File
@@ -10,6 +10,14 @@ role_key values
admin all users with role='admin' admin all users with role='admin'
director all users with role='director' director all users with role='director'
inspector all users with role='inspector' inspector all users with role='inspector'
EXCEPTION: for event 'inspection_completed', the inspector
column notifies ONLY the inspection's own inspector
(the submitter), not the whole inspector pool. Scoping is
applied in notify_by_matrix() via the inspection_id.
external_inspector all users with role='external_inspector' (customer /
third-party inspectors). Separate column so third parties
can be routed differently from the tenant's own crew; the
'inspection_completed' scoping above applies here too.
project_manager all users with role='project_manager' project_manager all users with role='project_manager'
customer all customer-portal users assigned to the relevant facility customer all customer-portal users assigned to the relevant facility
assignee the specific user the issue/inspection is assigned to assignee the specific user the issue/inspection is assigned to
@@ -27,6 +35,7 @@ issue_flagged : admin ✓ director ✓ inspector ✗ pm ✗ cust
issue_created : admin director inspector pm customer (assignee implicit) issue_created : admin director inspector pm customer (assignee implicit)
issue_updated_customer : admin director inspector pm customer issue_updated_customer : admin director inspector pm customer
verification_requested : admin director inspector pm customer verification_requested : admin director inspector pm customer
followup_requested : admin director inspector pm customer (inspection's own inspector implicit)
sla_alert : admin director inspector pm customer (assignee + followers implicit) sla_alert : admin director inspector pm customer (assignee + followers implicit)
score_alert : admin director inspector pm customer (facility score drop cron) score_alert : admin director inspector pm customer (facility score drop cron)
""" """
@@ -35,13 +44,17 @@ import json
from app import db from app import db
# Role keys available in the matrix UI # Role keys available in the matrix UI
# NOTE: the two customer-side labels are a display rename only (phase51) — the
# role_key values stored in notification_matrix.role_key are unchanged, so no
# data migration was needed. See User.CUSTOMER_ROLES.
MATRIX_ROLES = [ MATRIX_ROLES = [
('admin', 'Admin'), ('admin', 'Admin'),
('director', 'Director'), ('director', 'Director'),
('inspector', 'Inspector'), ('inspector', 'Inspector'),
('external_inspector', 'Customer Inspector'),
('project_manager', 'Project Manager'), ('project_manager', 'Project Manager'),
('auditor', 'Auditor'), ('auditor', 'Auditor'),
('customer', 'Customer'), ('customer', 'Customer Director'),
('custom', 'Custom Recipients'), ('custom', 'Custom Recipients'),
] ]
@@ -59,6 +72,7 @@ MATRIX_EVENTS = {
'issue_created': 'Issue created (standalone)', 'issue_created': 'Issue created (standalone)',
'issue_updated_customer': 'Issue updated (customer)', 'issue_updated_customer': 'Issue updated (customer)',
'verification_requested': 'Verification requested', 'verification_requested': 'Verification requested',
'followup_requested': 'Follow-up requested (incl. by customer)',
'sla_alert': 'SLA at-risk / breached', 'sla_alert': 'SLA at-risk / breached',
'score_alert': 'Facility score trend alert (significant drop)', 'score_alert': 'Facility score trend alert (significant drop)',
} }
@@ -143,6 +157,16 @@ MATRIX_DEFAULTS = {
('verification_requested', 'project_manager'): False, ('verification_requested', 'project_manager'): False,
('verification_requested', 'customer'): False, ('verification_requested', 'customer'): False,
('verification_requested', 'custom'): False, ('verification_requested', 'custom'): False,
# followup_requested (phase49) — a customer (or manager) asks for a
# re-inspection. On for the roles who action it; the inspection's own
# inspector is notified directly by the route, so the inspector column stays
# off to avoid alerting the whole inspector pool.
('followup_requested', 'admin'): True,
('followup_requested', 'director'): True,
('followup_requested', 'inspector'): False,
('followup_requested', 'project_manager'): True,
('followup_requested', 'customer'): False,
('followup_requested', 'custom'): False,
# sla_alert (assignee + followers always notified implicitly) # sla_alert (assignee + followers always notified implicitly)
('sla_alert', 'admin'): True, ('sla_alert', 'admin'): True,
('sla_alert', 'director'): False, ('sla_alert', 'director'): False,
@@ -159,6 +183,18 @@ MATRIX_DEFAULTS = {
('score_alert', 'custom'): False, ('score_alert', 'custom'): False,
} }
# MT-15 — the External Inspector column defaults to whatever the internal
# Inspector column defaults to, for every event. Mirroring rather than listing
# 14 more literals means a future event added for 'inspector' automatically
# gets a matching external default instead of silently falling back to the
# is_enabled() fallback. Admins can diverge the two columns in the UI at any
# time; this only seeds rows that do not exist yet.
MATRIX_DEFAULTS.update({
(_event, 'external_inspector'): _enabled
for (_event, _role), _enabled in list(MATRIX_DEFAULTS.items())
if _role == 'inspector'
})
class NotificationMatrix(db.Model): class NotificationMatrix(db.Model):
"""Admin-controlled per-event notification routing.""" """Admin-controlled per-event notification routing."""
+28
View File
@@ -18,6 +18,29 @@ class SupportChatSession(db.Model):
order_by='SupportChatMessage.created_at', order_by='SupportChatMessage.created_at',
) )
@property
def message_count(self):
"""Number of turns in this session.
`messages` is a plain list relationship here (ST's is lazy='dynamic'),
so this is len() rather than .count(). It is already loaded whenever the
session is, so this costs no extra query.
"""
return len(self.messages)
@property
def preview(self):
"""First user message, for list views.
Scans the loaded list instead of ST's .filter_by(role='user').first(),
for the same reason. `messages` is ordered by created_at, so the first
match is the opening question.
"""
for m in self.messages:
if m.role == 'user':
return m.content
return '(no messages)'
def __repr__(self): def __repr__(self):
return f'<SupportChatSession {self.id}>' return f'<SupportChatSession {self.id}>'
@@ -46,6 +69,11 @@ class SupportKnowledge(db.Model):
title = db.Column(db.String(200), nullable=False) title = db.Column(db.String(200), nullable=False)
body = db.Column(db.Text, nullable=False) body = db.Column(db.Text, nullable=False)
active = db.Column(db.Boolean, default=True, nullable=False) active = db.Column(db.Boolean, default=True, nullable=False)
# MT-19 — admin-controlled ordering. Entries are injected into the support
# chat's system prompt in this order, so a low sort_order is how an admin
# promotes the guidance the assistant should reach for first. Ties break on
# id, keeping the order stable.
sort_order = db.Column(db.Integer, nullable=False, default=0)
created_by = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'), nullable=True) created_by = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
created_at = db.Column(db.DateTime, default=now_eastern, nullable=False) created_at = db.Column(db.DateTime, default=now_eastern, nullable=False)
updated_at = db.Column(db.DateTime, default=now_eastern, nullable=False) updated_at = db.Column(db.DateTime, default=now_eastern, nullable=False)
+133 -3
View File
@@ -3,14 +3,75 @@ from flask_login import UserMixin
from werkzeug.security import generate_password_hash, check_password_hash from werkzeug.security import generate_password_hash, check_password_hash
from app.utils.time_utils import now_eastern from app.utils.time_utils import now_eastern
# Display labels for the role ENUM — the single place a role's user-facing name
# is defined.
#
# The two customer-side roles are a LABEL-ONLY rename (same idea as rule 19,
# "Project" -> "Contract"): the stored ENUM values are still 'customer' and
# 'external_inspector', so no migration and no role check anywhere had to move.
# 'customer' -> "Customer Director" (portal access, CustomerAssignment scope)
# 'external_inspector' -> "Customer Inspector" (inspector powers, InspectorAssignment scope)
ROLE_LABELS = {
'admin': 'Admin',
'director': 'Director',
'project_manager': 'Project Manager',
'auditor': 'Auditor',
'inspector': 'Inspector',
'external_inspector': 'Customer Inspector',
'customer': 'Customer Director',
}
@login_manager.user_loader @login_manager.user_loader
def load_user(user_id): def load_user(user_id):
from app import db from app import db
return db.session.get(User, int(user_id)) from app.tenancy.session_binding import parse_user_id
# MT-21: the identity string is tenant-tagged in multi-tenant mode. A tag
# naming another tenant (a session or remember-me cookie replayed onto this
# host) resolves to None here rather than loading the same-numbered user out
# of whichever database happens to be bound.
uid = parse_user_id(user_id)
if uid is None:
return None
return db.session.get(User, uid)
class User(UserMixin, db.Model): class User(UserMixin, db.Model):
__tablename__ = 'users' __tablename__ = 'users'
# ── Inspector roles (MT-15) ───────────────────────────────────────────────
# 'external_inspector' is an inspector employed by the customer or a third
# party rather than by the tenant. It has exactly the same capabilities as
# the internal 'inspector' role and is scoped the same way — through
# InspectorAssignment rows, via get_inspector_scope().
#
# Every place that used to test `role == 'inspector'` must test membership
# of this tuple instead, or external inspectors silently fall into the
# privileged (org-wide) branch and see every contract. Use the
# `is_inspector` property below — it is an ordinary attribute, so it reads
# the same way in Python and in Jinja (`current_user.is_inspector`).
INSPECTOR_ROLES = ('inspector', 'external_inspector')
# ── Customer-side roles (phase51) ────────────────────────────────
# Accounts that belong to the CUSTOMER, not to us. Both are created,
# invited, assigned and switched from Customer Management (/customers) —
# they never appear in User Management.
# 'customer' = Customer Director — portal access, read-mostly,
# scoped by CustomerAssignment.
# 'external_inspector' = Customer Inspector — full inspector capabilities,
# scoped by InspectorAssignment (see INSPECTOR_ROLES).
#
# CAUTION — this tuple is NOT interchangeable with `role == 'customer'`.
# A Customer Inspector is an INSPECTOR everywhere it matters: portal
# read-only gates, @customer_required, get_customer_scope(), support chat
# and the customer branch of every API scope check must keep testing
# `role == 'customer'` exactly. Use CUSTOMER_ROLES / is_customer_account
# ONLY for account-management surfaces (who is listed, invited, edited,
# assigned or switched under /customers). Widening a capability check to
# this tuple hands a third-party inspector the customer portal; narrowing
# an account-management check to 'customer' strands the inspectors in a
# page that no longer manages them.
CUSTOMER_ROLES = ('customer', 'external_inspector')
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(100), unique=True, nullable=False, index=True) username = db.Column(db.String(100), unique=True, nullable=False, index=True)
full_name = db.Column(db.String(150), nullable=True) full_name = db.Column(db.String(150), nullable=True)
@@ -19,12 +80,26 @@ class User(UserMixin, db.Model):
role = db.Column( role = db.Column(
# Phase 11 migration complete — 'supervisor' removed from both the DB # Phase 11 migration complete — 'supervisor' removed from both the DB
# ENUM and this Python-side declaration. Director is the canonical role. # ENUM and this Python-side declaration. Director is the canonical role.
db.Enum('admin', 'director', 'inspector', 'project_manager', 'customer', 'auditor'), # MT-15 — 'external_inspector' added: a customer / third-party
# inspector with identical capabilities to 'inspector'.
db.Enum('admin', 'director', 'inspector', 'project_manager', 'customer',
'auditor', 'external_inspector'),
nullable=False nullable=False
) )
created_at = db.Column(db.DateTime, default=now_eastern) created_at = db.Column(db.DateTime, default=now_eastern)
active = db.Column(db.Boolean, default=True, nullable=False) active = db.Column(db.Boolean, default=True, nullable=False)
# ── Web portal design preference (MT-16) ──────────────────────────────
# 'classic' = the original top-navbar design. 'modern' = the sidebar design.
# Drives base.html's layout dispatch via the inject_ui_theme() context
# processor. Persisted per user so the choice survives logout.
#
# Defaults to 'classic' so existing tenants see no change on deploy; the
# effective fallback for accounts that never choose is config
# DEFAULT_UI_THEME, which a tenant can be provisioned with as 'modern'.
ui_theme = db.Column(db.String(16), nullable=False,
server_default='classic', default='classic')
# ── Customer password-setup workflow ────────────────────────────────── # ── Customer password-setup workflow ──────────────────────────────────
# password_set: False for newly created customer accounts until they # password_set: False for newly created customer accounts until they
# complete the set-password flow via emailed link. # complete the set-password flow via emailed link.
@@ -43,7 +118,13 @@ class User(UserMixin, db.Model):
mfa_recovery_codes = db.Column(db.JSON, nullable=True) mfa_recovery_codes = db.Column(db.JSON, nullable=True)
# Relationships # Relationships
inspections = db.relationship('Inspection', backref='inspector', lazy='dynamic') # phase49: inspections now has TWO foreign keys to users.id — inspector_id
# and follow_up_requested_by — so the join is otherwise ambiguous and every
# mapper configuration fails with AmbiguousForeignKeysError. This
# relationship means "inspections I performed": inspector_id only.
inspections = db.relationship('Inspection', backref='inspector',
lazy='dynamic',
foreign_keys='Inspection.inspector_id')
# ── Flask-Login integration ──────────────────────────────────────────── # ── Flask-Login integration ────────────────────────────────────────────
# Override UserMixin.is_active so that disabled accounts are rejected # Override UserMixin.is_active so that disabled accounts are rejected
@@ -52,12 +133,61 @@ class User(UserMixin, db.Model):
def is_active(self): def is_active(self):
return self.active return self.active
# MT-21: Flask-Login derives BOTH the session '_user_id' and the
# remember-me cookie payload from get_id(), and feeds both back through
# load_user(). Tagging the tenant here is therefore the single seam that
# binds every persisted identity to the tenant that issued it. Returns a
# bare id (today's format) whenever no tenant is bound.
def get_id(self):
from app.tenancy.session_binding import tag_user_id
return tag_user_id(self.id)
def set_password(self, password): def set_password(self, password):
self.password_hash = generate_password_hash(password) self.password_hash = generate_password_hash(password)
def check_password(self, password): def check_password(self, password):
return check_password_hash(self.password_hash, password) return check_password_hash(self.password_hash, password)
@property
def is_inspector(self):
"""True for both the internal and the external inspector role.
Prefer this over `role == 'inspector'` for capability and scoping
checks. Use an explicit `role == 'external_inspector'` test only where
the two genuinely differ (currently: display labelling only).
"""
return self.role in self.INSPECTOR_ROLES
@property
def is_external_inspector(self):
"""True only for third-party / customer-employed inspectors.
Display name: "Customer Inspector". The attribute keeps its MT-15
name so the existing call sites stay put (the rename is a label,
never an identifier).
"""
return self.role == 'external_inspector'
@property
def is_customer_account(self):
"""True for BOTH customer-side roles — an account-management question.
Answers "is this account managed under /customers?", NOT "does this
account get the customer portal". For the latter keep testing
`role == 'customer'`. See the CUSTOMER_ROLES note above.
"""
return self.role in self.CUSTOMER_ROLES
@property
def is_customer_director(self):
"""True for the portal-side customer role ('customer')."""
return self.role == 'customer'
@property
def role_label(self):
"""Human-readable role name, used in staff-facing lists."""
return ROLE_LABELS.get(self.role, (self.role or '').replace('_', ' ').title())
@property @property
def display_name(self): def display_name(self):
"""Return full name if set, otherwise fall back to username.""" """Return full name if set, otherwise fall back to username."""
+146
View File
@@ -0,0 +1,146 @@
"""
app/models/user_notification_matrix.py
--------------------------------------
Per-account notification overrides (phase51).
The global NotificationMatrix routes an event to whole ROLES: "every Customer
Director hears about issue_created". That is the wrong grain for customers —
each customer organisation states on its enrollment form which notifications
each of its people wants, and two directors on two contracts rarely want the
same set.
This table is the per-account layer on top. One row = one account's explicit
answer for one event:
enabled=True send it to this account even if the global column is OFF
enabled=False do not send it to this account even if the global column is ON
NO ROW inherit whatever the global matrix column says
Inheritance is the default and the safe state: an account with no rows behaves
exactly as it did before this table existed, so the feature ships without
changing routing for anyone. Setting a row back to "inherit" DELETES it rather
than storing a copy of the current global value, so a later change to the
global matrix still reaches accounts that never expressed an opinion.
Scope: consulted for the two customer-side roles only (User.CUSTOMER_ROLES).
Staff roles keep using the global matrix alone an admin who wants fewer
emails uses NotificationPreference, which is a different question (how to
deliver, not whether to route).
"""
from app import db
class UserNotificationMatrix(db.Model):
"""One account's override of the global matrix for one event."""
__tablename__ = 'user_notification_matrix'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer,
db.ForeignKey('users.id', ondelete='CASCADE'),
nullable=False, index=True)
event_type = db.Column(db.String(50), nullable=False)
enabled = db.Column(db.Boolean, nullable=False, default=True)
user = db.relationship('User', foreign_keys=[user_id],
backref=db.backref('notification_overrides',
lazy='dynamic',
cascade='all, delete-orphan'))
__table_args__ = (
db.UniqueConstraint('user_id', 'event_type',
name='uq_user_notif_matrix_user_event'),
)
def __repr__(self):
return (f'<UserNotificationMatrix user={self.user_id} '
f'event={self.event_type} enabled={self.enabled}>')
def overrides_for_user(user_id) -> dict:
"""Return {event_type: bool} — every override this account has set."""
return {
row.event_type: row.enabled
for row in UserNotificationMatrix.query.filter_by(user_id=user_id).all()
}
def override_for(user_id, event_type):
"""One account's answer for one event: True, False, or None (inherit).
Used by notify() to enforce the override on EVERY delivery path, not just
matrix broadcasts. Best-effort: any failure returns None (inherit), so a
lookup problem can never silently swallow a notification.
"""
import logging
if not user_id or not event_type:
return None
try:
row = UserNotificationMatrix.query.filter_by(
user_id=user_id, event_type=event_type).first()
return row.enabled if row is not None else None
except Exception as exc:
logging.getLogger(__name__).error(
'USER MATRIX | single override lookup failed | user=%s event=%s | %s',
user_id, event_type, exc,
)
return None
def overrides_for_event(event_type) -> dict:
"""Return {user_id: bool} — every account's override for one event.
One query per dispatch rather than one per candidate recipient. The table
holds only explicitly-set rows (inherit deletes), so it stays small.
Best-effort: a failure here must never take down a notification dispatch,
so callers get an empty dict (= everyone inherits) if the query fails.
"""
import logging
try:
return {
row.user_id: row.enabled
for row in UserNotificationMatrix.query.filter_by(
event_type=event_type).all()
}
except Exception as exc:
logging.getLogger(__name__).error(
'USER MATRIX | override lookup failed | event=%s | error=%s',
event_type, exc,
)
return {}
def set_overrides(user_id, values: dict):
"""Replace an account's overrides.
`values` maps event_type -> True / False / None, where None means inherit
(the row is deleted). Events absent from `values` are left untouched, so a
caller can update one event without resending the whole matrix.
Does NOT commit the caller owns the transaction (same contract as
notify()). Returns the number of rows added, updated or deleted.
"""
existing = {
row.event_type: row
for row in UserNotificationMatrix.query.filter_by(user_id=user_id).all()
}
changed = 0
for event_type, wanted in values.items():
row = existing.get(event_type)
if wanted is None:
if row is not None:
db.session.delete(row)
changed += 1
continue
wanted = bool(wanted)
if row is None:
db.session.add(UserNotificationMatrix(
user_id=user_id, event_type=event_type, enabled=wanted))
changed += 1
elif row.enabled != wanted:
row.enabled = wanted
changed += 1
return changed
+297 -14
View File
@@ -1,5 +1,4 @@
from flask import Blueprint, render_template, redirect, url_for, flash, request, abort, session from flask import Blueprint, render_template, redirect, url_for, flash, request, abort, session
from urllib.parse import urlparse
from flask_login import login_user, logout_user, login_required, current_user from flask_login import login_user, logout_user, login_required, current_user
from app import db, limiter from app import db, limiter
from app.models.user import User from app.models.user import User
@@ -9,6 +8,7 @@ from app.utils import mfa
import logging import logging
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE, ACTION_LOGIN, ACTION_LOGOUT from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE, ACTION_LOGIN, ACTION_LOGOUT
from app.tenancy.gates import quota_soft_check from app.tenancy.gates import quota_soft_check
from app.tenancy.session_binding import bind_session_tenant, SESSION_TENANT_KEY
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -41,6 +41,7 @@ def login():
# second-factor step. Password is verified; identity is NOT yet # second-factor step. Password is verified; identity is NOT yet
# established until the code is confirmed at /auth/mfa. # established until the code is confirmed at /auth/mfa.
if user.mfa_enabled and user.mfa_secret: if user.mfa_enabled and user.mfa_secret:
bind_session_tenant() # MT-21: tag before identity enters the session
session['mfa_pending_user_id'] = user.id session['mfa_pending_user_id'] = user.id
session['mfa_pending_remember'] = bool(form.remember_me.data) session['mfa_pending_remember'] = bool(form.remember_me.data)
session['mfa_pending_next'] = safe_redirect_url(request.args.get('next')) session['mfa_pending_next'] = safe_redirect_url(request.args.get('next'))
@@ -48,6 +49,7 @@ def login():
return redirect(url_for('auth.mfa_challenge')) return redirect(url_for('auth.mfa_challenge'))
login_user(user, remember=form.remember_me.data) login_user(user, remember=form.remember_me.data)
bind_session_tenant() # MT-21
# Use validated next URL — never redirect blindly to request.args['next'] # Use validated next URL — never redirect blindly to request.args['next']
next_page = safe_redirect_url(request.args.get('next')) next_page = safe_redirect_url(request.args.get('next'))
log_action(ACTION_LOGIN, 'User', user.id, user.username) log_action(ACTION_LOGIN, 'User', user.id, user.username)
@@ -107,6 +109,7 @@ def mfa_challenge():
next_page = session.pop('mfa_pending_next', None) next_page = session.pop('mfa_pending_next', None)
session.pop('mfa_pending_user_id', None) session.pop('mfa_pending_user_id', None)
login_user(user, remember=remember) login_user(user, remember=remember)
bind_session_tenant() # MT-21
log_action(ACTION_LOGIN, 'User', user.id, user.username, f'2fa via {via}') log_action(ACTION_LOGIN, 'User', user.id, user.username, f'2fa via {via}')
if via == 'recovery': if via == 'recovery':
remaining_n = len(user.mfa_recovery_codes or []) remaining_n = len(user.mfa_recovery_codes or [])
@@ -199,7 +202,7 @@ def profile():
if form.validate_on_submit(): if form.validate_on_submit():
current_user.full_name = form.full_name.data.strip() or None current_user.full_name = form.full_name.data.strip() or None
current_user.email = form.email.data current_user.email = form.email.data.strip().lower()
if form.new_password.data: if form.new_password.data:
current_user.set_password(form.new_password.data) current_user.set_password(form.new_password.data)
@@ -242,14 +245,163 @@ def profile():
) )
# ── Self-service data export (GDPR Art. 15/20, CCPA right-to-know) ────────────
@bp.route('/my-data/export')
@login_required
def export_my_data():
"""Download a JSON snapshot of everything this account's own records hold:
profile fields, inspections performed, issues reported/assigned/commented
on, and the audit log entries recorded against this user id.
Read-only, and scoped to the caller. Records that merely *reference* this
user are included only as the user's own row — related entities are NOT
expanded, so an issue this user commented on contributes the comment, not
the facility details or the other participants. That keeps a subject-access
request from becoming a data leak about everyone else.
Multi-tenant note: this runs against the caller's own tenant DB via the
normal request routing, so it can only ever see that tenant's data.
"""
from flask import Response
import json
from app.models.inspection import Inspection
from app.models.issue import Issue, IssueComment
from app.models.audit import AuditLog
from app.utils.time_utils import now_eastern
user = current_user
payload = {
'exported_at': now_eastern().isoformat(),
'profile': {
'id': user.id,
'username': user.username,
'full_name': user.full_name,
'email': user.email,
'role': user.role,
'created_at': user.created_at.isoformat() if user.created_at else None,
'active': user.active,
},
'inspections_performed': [
{'id': i.id, 'facility_id': i.facility_id,
'inspection_date': i.inspection_date.isoformat() if i.inspection_date else None,
'overall_score': i.overall_score, 'status': i.status}
for i in Inspection.query.filter_by(inspector_id=user.id).all()
],
'issues_reported': [
{'id': iss.id, 'facility_id': iss.facility_id, 'description': iss.description,
'status': iss.status, 'severity': iss.severity,
'reported_at': iss.reported_at.isoformat() if iss.reported_at else None}
for iss in Issue.query.filter_by(reported_by=user.id).all()
],
'issues_assigned': [
{'id': iss.id, 'facility_id': iss.facility_id, 'description': iss.description,
'status': iss.status, 'severity': iss.severity}
for iss in Issue.query.filter_by(assigned_to=user.id).all()
],
'issue_comments_authored': [
{'id': c.id, 'issue_id': c.issue_id, 'body': c.body,
'created_at': c.created_at.isoformat() if c.created_at else None}
for c in IssueComment.query.filter_by(user_id=user.id).all()
],
'audit_log_entries': [
{'id': a.id, 'action': a.action, 'entity_type': a.entity_type,
'entity_id': a.entity_id, 'entity_label': a.entity_label,
'created_at': a.created_at.isoformat() if a.created_at else None}
for a in AuditLog.query.filter_by(user_id=user.id).all()
],
}
log_action(ACTION_UPDATE, 'User', user.id, user.username, 'self-service data export')
logger.info('AUTH | export_my_data | user_id=%s username=%s', user.id, user.username)
body = json.dumps(payload, indent=2, default=str)
return Response(
body,
mimetype='application/json',
headers={'Content-Disposition': f'attachment; filename=jqc_my_data_{user.id}.json'},
)
# ── Self-service erasure request (GDPR Art. 17, CCPA right-to-delete) ─────────
@bp.route('/my-data/delete-request', methods=['POST'])
@login_required
def request_my_data_deletion():
"""Erase this account's PII on request.
Two outcomes, chosen automatically:
* No records that a hard delete would orphan (same guard rails as the admin
delete_user route) the account is deleted outright.
* Otherwise the common case, since staff usually have inspection or issue
history that must be kept for business and audit continuity the account
is ANONYMIZED in place: name/email/username replaced with a
non-identifying placeholder, the password hash invalidated so nobody can
ever log in as it again, and the account deactivated.
Historical records reference the user *id*, not the PII, so they survive the
anonymization unchanged and the audit trail stays intact. This is the
balance the regulations expect: erase the identity, keep the ledger.
"""
import secrets
from app.models.issue import Issue as _Issue, IssueComment as _IssueComment
from app.models.inspection import InspectionTemplate as _InspectionTemplate
user = current_user
blocking = (
user.inspections.count() > 0
or _Issue.query.filter_by(assigned_to=user.id).count() > 0
or _IssueComment.query.filter_by(user_id=user.id).count() > 0
or _InspectionTemplate.query.filter_by(created_by=user.id).count() > 0
)
username = user.username
user_id = user.id
if not blocking:
db.session.delete(user)
db.session.commit()
logout_user()
logger.info('AUTH | self_delete | user_id=%s username=%s', user_id, username)
log_action(ACTION_DELETE, 'User', user_id, username,
'self-service account deletion')
flash('Your account and data have been permanently deleted.', 'success')
return redirect(url_for('auth.login'))
placeholder = f'deleted_user_{user_id}'
user.full_name = None
user.email = f'{placeholder}@deleted.local'
user.username = placeholder
# Random hash nobody holds — the account can never be logged into again.
user.set_password(secrets.token_hex(32))
user.active = False
db.session.commit()
logger.info('AUTH | self_anonymize | user_id=%s '
'(had blocking records, hard delete not possible)', user_id)
log_action(ACTION_UPDATE, 'User', user_id, placeholder,
'self-service erasure request — anonymized '
'(blocking records retained for audit/business continuity)')
logout_user()
flash('Your personal information has been removed and your account '
'deactivated. Historical records tied to your account id are retained '
'for audit continuity but no longer identify you.', 'success')
return redirect(url_for('auth.login'))
@bp.route('/users') @bp.route('/users')
@login_required @login_required
@admin_required @admin_required
def list_users(): def list_users():
# Exclude customer accounts — those are managed exclusively via /customers # Exclude customer-side accounts — Customer Director AND Customer Inspector
# are both managed exclusively via /customers (phase51). Using
# User.CUSTOMER_ROLES rather than != 'customer' is what moves the customer
# inspectors off this page.
users = ( users = (
User.query User.query
.filter(User.role != 'customer') .filter(~User.role.in_(User.CUSTOMER_ROLES))
.order_by(User.created_at.desc()) .order_by(User.created_at.desc())
.all() .all()
) )
@@ -273,6 +425,25 @@ def list_users():
inspector_contract_counts=inspector_contract_counts) inspector_contract_counts=inspector_contract_counts)
def _redirect_if_customer_account(user):
"""Send customer-side accounts back to Customer Management.
phase51 moved Customer Director + Customer Inspector wholly under
/customers. These accounts are no longer listed here, but the /auth/users
URLs are still reachable by hand and editing one through UserForm would
fail anyway ('external_inspector' is no longer an offered role choice, so
SelectField would reject the existing value). Redirect instead of 404 so an
old bookmark lands on the page that now owns the account.
Returns a response to return, or None to continue.
"""
if user is not None and user.is_customer_account:
flash(f'{user.display_name} is a {user.role_label} account and is '
f'managed in Customer Management.', 'info')
return redirect(url_for('customers.manage', customer_id=user.id))
return None
@bp.route('/users/new', methods=['GET', 'POST']) @bp.route('/users/new', methods=['GET', 'POST'])
@login_required @login_required
@admin_required @admin_required
@@ -286,19 +457,39 @@ def create_user():
if form.validate_on_submit(): if form.validate_on_submit():
role = 'inspector' if director_editing else form.role.data role = 'inspector' if director_editing else form.role.data
# phase51 — the invitation branch that used to live here moved to
# Customer Management along with the Customer Inspector role. Every
# role this form still offers is OUR OWN staff, created with an
# admin-set password. Customer-side accounts are invited (they choose
# their own username and password) via customers.create().
#
# UserForm.password is Optional() because the same form is used for
# EDIT, where blank means "keep current". On CREATE a blank password
# would otherwise store the hash of an empty string, so require one.
if not form.password.data:
flash('Please set a password for the new user.', 'danger')
return render_template('auth/user_form.html', form=form, user=None,
title='Create User',
director_editing=director_editing)
user = User( user = User(
username=form.username.data, username=form.username.data,
full_name=form.full_name.data.strip() or None, full_name=form.full_name.data.strip() or None,
email=form.email.data, email=form.email.data.strip().lower(),
role=role role=role,
password_set=True,
) )
user.set_password(form.password.data) user.set_password(form.password.data)
db.session.add(user) db.session.add(user)
db.session.commit() db.session.commit()
logger.info('AUTH | user_create | admin_id=%s admin=%s new_user=%s role=%s', logger.info('AUTH | user_create | admin_id=%s admin=%s new_user=%s role=%s',
current_user.id, current_user.username, user.username, user.role) current_user.id, current_user.username, user.username,
user.role)
log_action(ACTION_CREATE, 'User', user.id, user.username, log_action(ACTION_CREATE, 'User', user.id, user.username,
f'role={user.role}; email={user.email}') f'role={user.role}; email={user.email}')
flash(f'User {user.username} created successfully.', 'success') flash(f'User {user.username} created successfully.', 'success')
return redirect(url_for('auth.list_users')) return redirect(url_for('auth.list_users'))
@@ -313,6 +504,9 @@ def edit_user(user_id):
user = db.session.get(User, user_id) user = db.session.get(User, user_id)
if user is None: if user is None:
abort(404) abort(404)
moved = _redirect_if_customer_account(user)
if moved:
return moved
form = UserForm(user=user, obj=user) form = UserForm(user=user, obj=user)
# Directors may not change another user's role — that privilege is admin-only. # Directors may not change another user's role — that privilege is admin-only.
@@ -323,7 +517,7 @@ def edit_user(user_id):
if form.validate_on_submit(): if form.validate_on_submit():
user.username = form.username.data user.username = form.username.data
user.full_name = form.full_name.data.strip() or None user.full_name = form.full_name.data.strip() or None
user.email = form.email.data user.email = form.email.data.strip().lower()
if not director_editing: if not director_editing:
user.role = form.role.data user.role = form.role.data
@@ -343,13 +537,58 @@ def edit_user(user_id):
title='Edit User', director_editing=director_editing) title='Edit User', director_editing=director_editing)
@bp.route('/users/<int:user_id>/resend-invite', methods=['POST'])
@login_required
@admin_required
def resend_invite(user_id):
"""Re-send the set-password invitation for an account still awaiting setup.
Without this an invitation that bounces, is deleted or expires leaves the
account permanently unusable password_set=False blocks login and only a
valid token can clear it. Mirrors customers.resend_invite for staff-side
accounts (currently only external inspectors are ever invited this way).
"""
user = db.session.get(User, user_id)
if user is None:
abort(404)
moved = _redirect_if_customer_account(user)
if moved:
return moved
if user.password_set:
flash(f'{user.display_name} has already completed their account setup.',
'info')
return redirect(url_for('auth.list_users'))
# A fresh token invalidates the previous link.
token = user.generate_set_password_token(expires_hours=72)
db.session.commit()
logger.info('AUTH | resend_invite | admin=%s user=%s',
current_user.username, user.username)
log_action(ACTION_UPDATE, 'User', user.id, user.username,
'invitation email resent')
from app.routes.customers import _send_invite_email
_send_invite_email(user, token, base_url=request.host_url)
flash(f'Invitation resent to {user.email}.', 'success')
return redirect(url_for('auth.list_users'))
@bp.route('/users/<int:user_id>/assign-contracts', methods=['GET', 'POST']) @bp.route('/users/<int:user_id>/assign-contracts', methods=['GET', 'POST'])
@login_required @login_required
@admin_required @admin_required
def assign_inspector_contracts(user_id): def assign_inspector_contracts(user_id):
user = db.session.get(User, user_id) user = db.session.get(User, user_id)
if user is None or user.role != 'inspector': # MT-15: external inspectors are scoped by the same InspectorAssignment
# rows, so this page must accept them too.
if user is None or not user.is_inspector:
abort(404) abort(404)
# A Customer Inspector is scoped by exactly these rows, but the page that
# owns them is now customers.manage — one editor per account, not two.
moved = _redirect_if_customer_account(user)
if moved:
return moved
from app.models.project import Project from app.models.project import Project
from app.models.inspector_assignment import InspectorAssignment from app.models.inspector_assignment import InspectorAssignment
@@ -536,7 +775,6 @@ def _send_password_reset_email(user, token, base_url=None):
from flask import current_app, render_template_string, url_for as _url_for from flask import current_app, render_template_string, url_for as _url_for
from flask_mail import Message from flask_mail import Message
from app import mail from app import mail
from urllib.parse import urlparse
import threading import threading
if not current_app.config.get('MAIL_SERVER'): if not current_app.config.get('MAIL_SERVER'):
@@ -545,8 +783,17 @@ def _send_password_reset_email(user, token, base_url=None):
effective_base = (base_url or current_app.config.get('APP_BASE_URL', '')).rstrip('/') effective_base = (base_url or current_app.config.get('APP_BASE_URL', '')).rstrip('/')
reset_link = f'{effective_base}{_url_for("auth.reset_password", token=token)}' reset_link = f'{effective_base}{_url_for("auth.reset_password", token=token)}'
host = urlparse(effective_base).netloc or 'janitorialqc.local'
sender = f'noreply@{host}' # Branded From as a (display_name, address) tuple, exactly as
# customers._send_invite_email does. The display NAME tracks the tenant; the
# ADDRESS is branded only for DNS-authorized domains and otherwise stays the
# authenticated SMTP identity so the mail still delivers. The previous
# `noreply@{host}` sent from whatever host the browser was on, which is not
# an authorized sender for that domain — the mail server accepted it and it
# was then dropped downstream by SPF/DMARC. See app/utils/mail_utils.py and
# CLAUDE.md rule 64.
from app.utils.mail_utils import branded_sender
sender = branded_sender(effective_base)
html_body = render_template_string("""<!DOCTYPE html> html_body = render_template_string("""<!DOCTYPE html>
<html> <html>
@@ -612,12 +859,26 @@ def forgot_password():
form = ForgotPasswordForm() form = ForgotPasswordForm()
if form.validate_on_submit(): if form.validate_on_submit():
user = User.query.filter_by(email=form.email.data.strip().lower()).first() # Explicit case-insensitive lookup. This is NOT fixing a live bug: no
# table here declares a COLLATE, so `users.email` inherits the utf8mb4
# default (utf8mb4_general_ci / utf8mb4_0900_ai_ci), both of which are
# case-insensitive — a bare `== lower(input)` already matched a
# mixed-case stored address. The point is to stop depending on that
# server default: under a binary/_bin collation the bare comparison
# would silently find nothing and still show the success message below.
email_input = form.email.data.strip().lower()
user = User.query.filter(
db.func.lower(User.email) == email_input
).first()
if user and user.active: if user and user.active:
token = user.generate_set_password_token(expires_hours=1) token = user.generate_set_password_token(expires_hours=1)
db.session.commit() db.session.commit()
_send_password_reset_email(user, token, base_url=request.host_url) _send_password_reset_email(user, token, base_url=request.host_url)
logger.info('AUTH | forgot_password | user=%s | email=%s', user.username, user.email) logger.info('AUTH | forgot_password | reset link dispatched | user=%s | email=%s',
user.username, user.email)
else:
# No leak to the user (generic message below), but log for diagnosis.
logger.info('AUTH | forgot_password | no active account for email=%s', email_input)
# Always show the same message — never reveal whether the email exists # Always show the same message — never reveal whether the email exists
flash( flash(
'If an account with that email address exists, a password reset link ' 'If an account with that email address exists, a password reset link '
@@ -691,6 +952,23 @@ def impersonate_entry():
flask_session['impersonating_tenant_id'] = tenant_id flask_session['impersonating_tenant_id'] = tenant_id
flask_session['impersonating_superadmin_id'] = superadmin_id flask_session['impersonating_superadmin_id'] = superadmin_id
# ── MT-21: re-tag the session for the impersonated tenant ────────────────
# Identity in the session is tenant-tagged (User.get_id), and from the next
# request onward the middleware binds the impersonated tenant's database.
# Without re-tagging, load_user() would correctly reject the tag issued by
# the host tenant and the superadmin would land on a login page.
#
# This preserves the pre-existing impersonation semantics EXACTLY: the
# numeric user id carries over, so the superadmin is loaded as the
# same-numbered user in the target tenant's database. That behaviour is
# arbitrary and worth revisiting (see MULTI_TENANT_PLAN.md open items) —
# but changing it is a separate decision, not a security fix.
flask_session[SESSION_TENANT_KEY] = tenant_id
raw_uid = flask_session.get('_user_id')
if raw_uid is not None:
numeric_uid = str(raw_uid).rpartition(':')[2]
flask_session['_user_id'] = f'{tenant_id}:{numeric_uid}'
logger.info('AUTH | impersonate_start | sa=%s tenant=%s', superadmin_id, tenant_id) logger.info('AUTH | impersonate_start | sa=%s tenant=%s', superadmin_id, tenant_id)
import os import os
@@ -714,6 +992,11 @@ def impersonate_end():
import os import os
flask_session.pop('impersonating_tenant_id', None) flask_session.pop('impersonating_tenant_id', None)
flask_session.pop('impersonating_superadmin_id', None) flask_session.pop('impersonating_superadmin_id', None)
# MT-21: the session identity is still tagged for the impersonated tenant.
# Drop it rather than carrying it back to the superadmin's own host, where
# the middleware would clear it on tenant mismatch anyway.
logout_user()
flask_session.clear()
panel_url = f"https://admin.{os.environ.get('TENANT_BASE_DOMAIN', 'jqc.app')}" panel_url = f"https://admin.{os.environ.get('TENANT_BASE_DOMAIN', 'jqc.app')}"
logger.info('AUTH | impersonate_end | redirecting to panel') logger.info('AUTH | impersonate_end | redirecting to panel')
return redirect(panel_url) return redirect(panel_url)
+3 -1
View File
@@ -22,10 +22,12 @@ logger = logging.getLogger(__name__)
bp = Blueprint('broadcast', __name__, url_prefix='/admin/broadcast') bp = Blueprint('broadcast', __name__, url_prefix='/admin/broadcast')
# All roles that can hold an active iOS session # All roles that can hold an active iOS session
BROADCAST_ROLES = ['inspector', 'project_manager', 'director', 'admin'] BROADCAST_ROLES = ['inspector', 'external_inspector', 'project_manager',
'director', 'admin']
ROLE_LABELS = { ROLE_LABELS = {
'inspector': 'Inspectors', 'inspector': 'Inspectors',
'external_inspector': 'Customer Inspectors',
'project_manager': 'Project Managers', 'project_manager': 'Project Managers',
'director': 'Directors', 'director': 'Directors',
'admin': 'Admins', 'admin': 'Admins',
+385 -53
View File
@@ -3,13 +3,26 @@ app/routes/customers.py
----------------------- -----------------------
Customer Management admin-only consolidated view. Customer Management admin-only consolidated view.
Owns BOTH customer-side roles (phase51 see User.CUSTOMER_ROLES):
Customer Director role='customer' portal access, read-mostly,
scoped by CustomerAssignment
Customer Inspector role='external_inspector' full inspector capabilities,
scoped by InspectorAssignment
They are two seats of the same customer organisation, so they are listed,
invited, edited, assigned, switched and disabled here rather than in User
Management which now excludes both.
Provides a single screen to: Provides a single screen to:
- List all customer-role users with their assignment summary - List all customer-side users with their assignment summary
- Create a new customer account - Invite a new customer account in either role
- Edit an existing customer (username / email / password / active) - Edit an existing account (username / email / password / active)
- Manage assignments for a customer (add / remove) - Manage assignments (contracts/facilities for a director, contracts for an
- Quick-disable / enable a customer account inspector)
- View a customer's scoped facility access at a glance - Switch an account between the two roles
- Quick-disable / enable an account
- View the account's scoped facility access at a glance
""" """
import logging import logging
@@ -18,6 +31,7 @@ from flask_login import login_required, current_user
from app import db from app import db
from app.models.user import User from app.models.user import User
from app.models.project import Project, CustomerAssignment from app.models.project import Project, CustomerAssignment
from app.models.inspector_assignment import InspectorAssignment
from app.models.facility import Facility from app.models.facility import Facility
from app.utils.forms import CustomerUserForm, CustomerAssignmentForm, CustomerInviteForm, SetPasswordForm from app.utils.forms import CustomerUserForm, CustomerAssignmentForm, CustomerInviteForm, SetPasswordForm
from app.utils.decorators import admin_required, supervisor_required, safe_redirect_url from app.utils.decorators import admin_required, supervisor_required, safe_redirect_url
@@ -29,16 +43,46 @@ logger = logging.getLogger(__name__)
bp = Blueprint('customers', __name__, url_prefix='/customers') bp = Blueprint('customers', __name__, url_prefix='/customers')
def _get_customer_or_redirect(customer_id):
"""Load a customer-side account, or return a redirect response.
Returns (account, None) on success and (None, response) when the id is not
a customer-side account. Every route here used to test
`customer.role != 'customer'`, which would now reject the Customer
Inspectors this page owns the check is CUSTOMER_ROLES, once, here.
"""
account = db.session.get(User, customer_id)
if account is None:
abort(404)
if not account.is_customer_account:
flash('This page is only for customer accounts.', 'warning')
return None, redirect(url_for('customers.index'))
return account, None
def _inspector_scope_ids(user, project_facilities_map):
"""Facility IDs a Customer Inspector reaches, from its contract rows.
Mirrors get_inspector_scope() but reuses the caller's already-loaded
project facilities map so the list view stays free of N+1 queries
(rule 13).
"""
ids = set()
for a in InspectorAssignment.query.filter_by(user_id=user.id).all():
ids.update(project_facilities_map.get(a.project_id, []))
return ids
# ── List ────────────────────────────────────────────────────────────────────── # ── List ──────────────────────────────────────────────────────────────────────
@bp.route('/') @bp.route('/')
@login_required @login_required
@supervisor_required @supervisor_required
def index(): def index():
"""Consolidated customer management dashboard.""" """Consolidated customer management dashboard — both customer roles."""
customers = ( customers = (
User.query User.query
.filter_by(role='customer') .filter(User.role.in_(User.CUSTOMER_ROLES))
.order_by(User.username) .order_by(User.username)
.all() .all()
) )
@@ -57,10 +101,27 @@ def index():
for a in all_assignments: for a in all_assignments:
assignment_map[a.user_id].append(a) assignment_map[a.user_id].append(a)
# ── Bulk query for the inspector-side assignments ─────────────────────
# Customer Inspectors are scoped by InspectorAssignment, not
# CustomerAssignment — the two roles read different tables for the same
# question ("which facilities does this account see?").
all_inspector_assignments = (
InspectorAssignment.query
.filter(InspectorAssignment.user_id.in_(customer_ids))
.all()
) if customer_ids else []
inspector_assignment_map = {c.id: [] for c in customers}
for a in all_inspector_assignments:
inspector_assignment_map[a.user_id].append(a)
# ── Single bulk query for all active facilities in assigned projects ── # ── Single bulk query for all active facilities in assigned projects ──
# Resolves facility scope for every customer without repeated DB round-trips. # Resolves facility scope for every customer without repeated DB round-trips.
from collections import defaultdict from collections import defaultdict
assigned_project_ids = {a.project_id for a in all_assignments} assigned_project_ids = (
{a.project_id for a in all_assignments}
| {a.project_id for a in all_inspector_assignments}
)
project_facilities_map = defaultdict(list) # project_id → [facility_id, ...] project_facilities_map = defaultdict(list) # project_id → [facility_id, ...]
if assigned_project_ids: if assigned_project_ids:
@@ -78,6 +139,12 @@ def index():
scope_map = {} # user_id → sorted list[int] facility IDs scope_map = {} # user_id → sorted list[int] facility IDs
for customer in customers: for customer in customers:
ids = set() ids = set()
if customer.is_inspector:
# Customer Inspector — contract-level rows only, no facility-level
# narrowing exists for inspectors (rule 57: no rows = sees nothing).
for a in inspector_assignment_map[customer.id]:
ids.update(project_facilities_map.get(a.project_id, []))
else:
for a in assignment_map[customer.id]: for a in assignment_map[customer.id]:
if a.facility_id: if a.facility_id:
ids.add(a.facility_id) ids.add(a.facility_id)
@@ -103,6 +170,7 @@ def index():
'customers/index.html', 'customers/index.html',
customers = customers, customers = customers,
assignment_map = assignment_map, assignment_map = assignment_map,
inspector_assignment_map = inspector_assignment_map,
scope_map = scope_map, scope_map = scope_map,
projects = projects, projects = projects,
expired_invitations = expired_invitations, expired_invitations = expired_invitations,
@@ -115,12 +183,16 @@ def index():
@login_required @login_required
@supervisor_required @supervisor_required
def create(): def create():
"""Create a customer account via email invitation. """Create a customer-side account via email invitation.
Admin enters Full Name and Email only. A temporary username is Admin enters Full Name, Email and the role (Customer Director or Customer
auto-generated from the email address. A one-time set-password link Inspector). A temporary username is auto-generated from the email address.
is emailed; the customer chooses their own username and password when A one-time set-password link is emailed; the invitee chooses their own
they click it. The account is activated on completion. username and password when they click it. The account is activated on
completion.
Both roles take this identical path an account that belongs to the
customer is never given a password we chose.
""" """
form = CustomerInviteForm() form = CustomerInviteForm()
@@ -129,6 +201,11 @@ def create():
full_name = form.full_name.data.strip() full_name = form.full_name.data.strip()
email = form.email.data.strip().lower() email = form.email.data.strip().lower()
role = form.role.data
# Defence in depth: never let a crafted POST mint a staff role through
# the customer invitation form, which sets no password.
if role not in User.CUSTOMER_ROLES:
role = 'customer'
# Auto-generate a temporary username from the email local part. # Auto-generate a temporary username from the email local part.
# The customer replaces this with their preferred username when # The customer replaces this with their preferred username when
@@ -146,7 +223,7 @@ def create():
username = username, username = username,
full_name = full_name, full_name = full_name,
email = email, email = email,
role = 'customer', role = role,
active = True, active = True,
password_set = False, password_set = False,
) )
@@ -157,15 +234,15 @@ def create():
token = user.generate_set_password_token(expires_hours=72) token = user.generate_set_password_token(expires_hours=72)
db.session.commit() db.session.commit()
logger.info('CUSTOMERS | invite | admin=%s new_customer=%s email=%s', logger.info('CUSTOMERS | invite | admin=%s new_customer=%s role=%s email=%s',
current_user.username, user.username, user.email) current_user.username, user.username, user.role, user.email)
log_action(ACTION_CREATE, 'User', user.id, user.username, log_action(ACTION_CREATE, 'User', user.id, user.username,
f'role=customer; email={user.email}; invite_sent=True') f'role={user.role}; email={user.email}; invite_sent=True')
_send_invite_email(user, token, base_url=request.host_url) _send_invite_email(user, token, base_url=request.host_url)
flash( flash(
f'Customer account created for {full_name}. ' f'{user.role_label} account created for {full_name}. '
f'An invitation email has been sent to {email} with a link to set their username and password.', f'An invitation email has been sent to {email} with a link to set their username and password.',
'success' 'success'
) )
@@ -259,12 +336,9 @@ def _send_invite_email(user, token, base_url=None):
@supervisor_required @supervisor_required
def resend_invite(customer_id): def resend_invite(customer_id):
"""Generate a fresh token and resend the set-password invitation email.""" """Generate a fresh token and resend the set-password invitation email."""
customer = db.session.get(User, customer_id) customer, moved = _get_customer_or_redirect(customer_id)
if customer is None: if moved:
abort(404) return moved
if customer.role != 'customer':
flash('This action is only for customer accounts.', 'warning')
return redirect(url_for('customers.index'))
token = customer.generate_set_password_token(expires_hours=72) token = customer.generate_set_password_token(expires_hours=72)
customer.password_set = False customer.password_set = False
@@ -321,19 +395,16 @@ def set_password(token):
@login_required @login_required
@supervisor_required @supervisor_required
def edit(customer_id): def edit(customer_id):
customer = db.session.get(User, customer_id) customer, moved = _get_customer_or_redirect(customer_id)
if customer is None: if moved:
abort(404) return moved
if customer.role != 'customer':
flash('This page is only for customer accounts.', 'warning')
return redirect(url_for('customers.index'))
form = CustomerUserForm(user=customer, obj=customer) form = CustomerUserForm(user=customer, obj=customer)
if form.validate_on_submit(): if form.validate_on_submit():
customer.username = form.username.data customer.username = form.username.data
customer.full_name = form.full_name.data.strip() or None customer.full_name = form.full_name.data.strip() or None
customer.email = form.email.data customer.email = form.email.data.strip().lower()
if form.password.data: if form.password.data:
customer.set_password(form.password.data) customer.set_password(form.password.data)
db.session.commit() db.session.commit()
@@ -354,16 +425,26 @@ def edit(customer_id):
@login_required @login_required
@supervisor_required @supervisor_required
def manage(customer_id): def manage(customer_id):
"""Single-customer detail page: profile + all assignments.""" """Single-account detail page: profile + assignments + notification matrix.
customer = db.session.get(User, customer_id)
if customer is None:
abort(404)
if customer.role != 'customer':
flash('This page is only for customer accounts.', 'warning')
return redirect(url_for('customers.index'))
assignments = CustomerAssignment.query.filter_by(user_id=customer_id).all() The assignment editor differs by role. A Customer Director gets the
contract/facility assignment list (CustomerAssignment); a Customer
Inspector gets the contract checkbox set (InspectorAssignment) that used to
live on /auth/users/<id>/assign-contracts.
"""
customer, moved = _get_customer_or_redirect(customer_id)
if moved:
return moved
# Resolve the account's facility scope through the SAME helper the app uses
# at request time, so this page can never disagree with what the account
# actually sees.
if customer.is_inspector:
from app.utils.scope import get_inspector_scope
facility_ids = get_inspector_scope(customer) or []
else:
facility_ids = get_customer_scope(customer) or [] facility_ids = get_customer_scope(customer) or []
facilities = ( facilities = (
Facility.query Facility.query
.filter(Facility.id.in_(facility_ids), Facility.active == True) .filter(Facility.id.in_(facility_ids), Facility.active == True)
@@ -371,19 +452,51 @@ def manage(customer_id):
.all() .all()
) if facility_ids else [] ) if facility_ids else []
projects = Project.query.filter_by(active=True).order_by(Project.name).all()
assignments = []
assigned_pids = set()
if customer.is_inspector:
assigned_pids = {
a.project_id
for a in InspectorAssignment.query.filter_by(user_id=customer_id).all()
}
else:
assignments = CustomerAssignment.query.filter_by(user_id=customer_id).all()
# Assignment form (populated here so it can be rendered inline) # Assignment form (populated here so it can be rendered inline)
aform = CustomerAssignmentForm() aform = CustomerAssignmentForm()
projects = Project.query.filter_by(active=True).order_by(Project.name).all()
aform.user_id.choices = [(customer.id, customer.username)] aform.user_id.choices = [(customer.id, customer.username)]
aform.facility_id.choices = [(0, '— All facilities in contract —')] aform.facility_id.choices = [(0, '— All facilities in contract —')]
# ── Per-account notification matrix ───────────────────────────────────
# For each event: what the global matrix would do for this account's role,
# and whether the account overrides it. The template renders a tri-state
# (Inherit / On / Off) so "inherit" stays visibly distinct from "explicitly
# set to the same value the global happens to have today".
from app.models.notification_matrix import MATRIX_EVENTS, is_enabled
from app.models.user_notification_matrix import overrides_for_user
overrides = overrides_for_user(customer.id)
matrix_rows = [
{
'event': event_key,
'label': label,
'global': is_enabled(event_key, customer.role),
'override': overrides.get(event_key), # True / False / None
}
for event_key, label in MATRIX_EVENTS.items()
]
return render_template( return render_template(
'customers/manage.html', 'customers/manage.html',
customer = customer, customer = customer,
assignments = assignments, assignments = assignments,
assigned_pids = assigned_pids,
facilities = facilities, facilities = facilities,
aform = aform, aform = aform,
projects = projects, projects = projects,
matrix_rows = matrix_rows,
) )
@@ -393,12 +506,16 @@ def manage(customer_id):
@login_required @login_required
@supervisor_required @supervisor_required
def add_assignment(customer_id): def add_assignment(customer_id):
customer = db.session.get(User, customer_id) customer, moved = _get_customer_or_redirect(customer_id)
if customer is None: if moved:
abort(404) return moved
if customer.role != 'customer': if customer.is_inspector:
flash('Assignments are only for customer accounts.', 'warning') # A Customer Inspector is scoped by InspectorAssignment — writing a
return redirect(url_for('customers.index')) # CustomerAssignment row for them would grant nothing while looking
# like it had.
flash('Customer Inspectors are assigned whole contracts — use the '
'contract list on this page.', 'warning')
return redirect(url_for('customers.manage', customer_id=customer_id))
project_id = request.form.get('project_id', type=int) project_id = request.form.get('project_id', type=int)
facility_id = request.form.get('facility_id', type=int) or None facility_id = request.form.get('facility_id', type=int) or None
@@ -467,18 +584,233 @@ def remove_assignment(assignment_id):
return redirect(url_for('customers.manage', customer_id=customer_id)) return redirect(url_for('customers.manage', customer_id=customer_id))
# ── Contract assignments for a Customer Inspector ────────────────────────────
@bp.route('/<int:customer_id>/contracts', methods=['POST'])
@login_required
@supervisor_required
def assign_contracts(customer_id):
"""Replace a Customer Inspector's whole InspectorAssignment set.
Same replace-the-entire-set semantics as auth.assign_inspector_contracts
(rule 59) the form posts the complete checked list, rows not in the POST
body are deleted. Callers must always send the full desired set, never a
diff.
"""
customer, moved = _get_customer_or_redirect(customer_id)
if moved:
return moved
if not customer.is_inspector:
flash('Contract assignment is for Customer Inspector accounts. '
'Customer Directors are assigned per contract or facility below.',
'warning')
return redirect(url_for('customers.manage', customer_id=customer_id))
from app.utils.time_utils import now_eastern
selected_ids = set(request.form.getlist('project_ids', type=int))
existing = InspectorAssignment.query.filter_by(user_id=customer_id).all()
existing_pids = {a.project_id for a in existing}
for a in existing:
if a.project_id not in selected_ids:
db.session.delete(a)
for pid in selected_ids:
if pid not in existing_pids:
db.session.add(InspectorAssignment(
user_id = customer_id,
project_id = pid,
created_at = now_eastern(),
))
db.session.commit()
logger.info('CUSTOMERS | assign_contracts | admin=%s customer=%s projects=%s',
current_user.username, customer.username, sorted(selected_ids))
log_action(ACTION_UPDATE, 'User', customer.id, customer.username,
f'inspector_assignments={sorted(selected_ids)}')
if not selected_ids:
# Rule 57 is strict, and silently is exactly how it bites.
flash(f'{customer.display_name} now has no contracts assigned and will '
f'see nothing until at least one is granted.', 'warning')
else:
flash(f'Contract assignments updated for {customer.display_name}.', 'success')
return redirect(url_for('customers.manage', customer_id=customer_id))
# ── Per-account notification matrix ──────────────────────────────────────────
@bp.route('/<int:customer_id>/notifications', methods=['POST'])
@login_required
@supervisor_required
def save_notifications(customer_id):
"""Save this account's per-event notification overrides.
Each event posts one of 'inherit' / 'on' / 'off'. 'inherit' DELETES the row
rather than storing the global column's current value — so an account that
never expressed an opinion keeps following the global matrix when it
changes later.
"""
customer, moved = _get_customer_or_redirect(customer_id)
if moved:
return moved
from app.models.notification_matrix import MATRIX_EVENTS
from app.models.user_notification_matrix import set_overrides
tri = {'inherit': None, 'on': True, 'off': False}
values = {}
for event_key in MATRIX_EVENTS:
# Only events this form actually posted; an unknown or missing value
# is treated as inherit rather than guessed at.
choice = request.form.get(f'event_{event_key}')
if choice is not None:
values[event_key] = tri.get(choice)
changed = set_overrides(customer.id, values)
db.session.commit()
logger.info('CUSTOMERS | notif_matrix | admin=%s customer=%s changed=%s',
current_user.username, customer.username, changed)
log_action(ACTION_UPDATE, 'User', customer.id, customer.username,
f'notification overrides updated ({changed} change(s))')
flash(f'Notification settings saved for {customer.display_name}.'
if changed else 'No notification changes to save.',
'success' if changed else 'info')
return redirect(url_for('customers.manage', customer_id=customer.id))
# ── Switch between the two customer roles ────────────────────────────────────
@bp.route('/<int:customer_id>/switch-role', methods=['POST'])
@login_required
@admin_required
def switch_role(customer_id):
"""Flip an account between Customer Director and Customer Inspector.
The two roles read DIFFERENT scoping tables, so flipping the column alone
would leave the account correctly labelled and seeing nothing (rule 57 is
strict for inspectors, and a director with no CustomerAssignment rows is
equally blind). The contracts are therefore mirrored across: every contract
the account could reach before, it can reach after.
Facility-level narrowing does NOT survive a switch to inspector there is
no per-facility row for inspectors, so a director scoped to one building in
a contract becomes an inspector on that whole contract. The confirm dialog
says so; the flash repeats it. Switching BACK is lossless though: the
original facility-level rows were never deleted, and the reverse mirror
skips contracts the account can already reach, so it does not pile a
contract-wide grant on top of them.
API access changes in both directions ('external_inspector' has mobile API
access, 'customer' is 403 everywhere), so the account's refresh tokens and
device registrations are revoked an issued JWT would otherwise keep
working until it expired, and a signed-in iPad would keep syncing.
"""
customer, moved = _get_customer_or_redirect(customer_id)
if moved:
return moved
from app.utils.time_utils import now_eastern
from app.models.api_token import RefreshToken, DeviceToken
old_role = customer.role
new_role = 'external_inspector' if old_role == 'customer' else 'customer'
widened = False
if new_role == 'external_inspector':
# Director → Inspector: CustomerAssignment (contract or facility) →
# InspectorAssignment (contract only).
existing_pids = {
a.project_id
for a in InspectorAssignment.query.filter_by(user_id=customer.id).all()
}
for a in CustomerAssignment.query.filter_by(user_id=customer.id).all():
if a.facility_id:
widened = True
if a.project_id not in existing_pids:
db.session.add(InspectorAssignment(
user_id = customer.id,
project_id = a.project_id,
created_at = now_eastern(),
))
existing_pids.add(a.project_id)
else:
# Inspector → Director: contract-level CustomerAssignment rows
# (facility_id NULL = all facilities in the contract).
#
# `existing_pids` counts ANY row for the contract, facility-level ones
# included — NOT just the contract-wide ones. That is what makes a
# round trip lossless: an account narrowed to one facility, switched to
# inspector (which can only hold whole contracts) and switched back
# would otherwise gain a contract-wide row on top of its original
# facility row and come back with the whole contract. Skipping
# contracts the account can already reach as a director leaves the
# original narrowing intact, while contracts granted during the
# inspector spell still carry over.
existing_pids = {
a.project_id
for a in CustomerAssignment.query.filter_by(user_id=customer.id).all()
}
for a in InspectorAssignment.query.filter_by(user_id=customer.id).all():
if a.project_id not in existing_pids:
db.session.add(CustomerAssignment(
user_id = customer.id,
project_id = a.project_id,
facility_id = None,
))
existing_pids.add(a.project_id)
# The stale rows for the role being left are kept on purpose: switching
# back restores the account's original scope, including any facility-level
# narrowing that the inspector side cannot express. They are inert while
# the other role is active — each scope helper reads only its own table.
customer.role = new_role
revoked = (
RefreshToken.query
.filter_by(user_id=customer.id, revoked=False)
.update({'revoked': True}, synchronize_session=False)
)
devices = (
DeviceToken.query
.filter_by(user_id=customer.id)
.delete(synchronize_session=False)
)
db.session.commit()
logger.info('CUSTOMERS | switch_role | admin=%s customer=%s %s -> %s '
'tokens_revoked=%s devices_cleared=%s',
current_user.username, customer.username, old_role, new_role,
revoked, devices)
log_action(ACTION_UPDATE, 'User', customer.id, customer.username,
f'role switched {old_role} -> {new_role}; '
f'refresh_tokens_revoked={revoked}; devices_cleared={devices}')
msg = (f'{customer.display_name} is now a {customer.role_label}. '
f'Their contracts were carried across; any signed-in device must log in again.')
if widened:
msg += (' Note: facility-level limits do not exist for inspectors, so '
'this account now covers every facility in those contracts — '
'review the contract list below.')
flash(msg, 'warning' if widened else 'success')
return redirect(url_for('customers.manage', customer_id=customer.id))
# ── Toggle active ───────────────────────────────────────────────────────────── # ── Toggle active ─────────────────────────────────────────────────────────────
@bp.route('/<int:customer_id>/toggle-active', methods=['POST']) @bp.route('/<int:customer_id>/toggle-active', methods=['POST'])
@login_required @login_required
@supervisor_required @supervisor_required
def toggle_active(customer_id): def toggle_active(customer_id):
customer = db.session.get(User, customer_id) customer, moved = _get_customer_or_redirect(customer_id)
if customer is None: if moved:
abort(404) return moved
if customer.role != 'customer':
flash('This action is only for customer accounts.', 'warning')
return redirect(url_for('customers.index'))
customer.active = not customer.active customer.active = not customer.active
db.session.commit() db.session.commit()
+79 -5
View File
@@ -17,6 +17,16 @@ bp = Blueprint('dashboard', __name__)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Columns the dashboard actually reads off an issue row. The cards below need
# counts and buckets, never a hydrated Issue — loading the full entity pulls the
# description TEXT and three JSON photo columns for every open issue in scope,
# on every dashboard load, and registers each one in the identity map.
# A Row exposes the same attribute names, so the severity/handler tallies and
# sla_status() work against these unchanged.
_ISSUE_CARD_COLS = (Issue.id, Issue.severity, Issue.status,
Issue.reported_at, Issue.handler_type)
@bp.route('/') @bp.route('/')
@bp.route('/dashboard') @bp.route('/dashboard')
@login_required @login_required
@@ -24,8 +34,12 @@ def index():
now = now_eastern() now = now_eastern()
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
today_end = today_start + timedelta(days=1) today_end = today_start + timedelta(days=1)
# MT-16 — Monday 00:00 of the current week, for the modern dashboard's
# "submitted this week" tile. Derived from today_start so it inherits
# now_eastern() rather than mixing in a second clock.
week_start = today_start - timedelta(days=today_start.weekday())
is_inspector = current_user.role == 'inspector' is_inspector = current_user.is_inspector
is_privileged = current_user.role in ['admin', 'director'] is_privileged = current_user.role in ['admin', 'director']
is_customer = current_user.role == 'customer' is_customer = current_user.role == 'customer'
is_project_manager = current_user.role == 'project_manager' is_project_manager = current_user.role == 'project_manager'
@@ -62,6 +76,14 @@ def index():
Inspection.inspection_date < today_end, Inspection.inspection_date < today_end,
).count() ).count()
# MT-16 — fully completed & submitted so far this week (Monday → now).
# Reuses base_q, so it inherits the same role scoping as every other tile.
submitted_this_week = base_q.filter(
Inspection.status == 'completed',
Inspection.inspection_date >= week_start,
Inspection.inspection_date < today_end,
).count()
# ── Open issues (inspector: all issues in contracted facilities) ─────── # ── Open issues (inspector: all issues in contracted facilities) ───────
open_issues_q = Issue.query.filter(Issue.status.in_(['open', 'in_progress'])) open_issues_q = Issue.query.filter(Issue.status.in_(['open', 'in_progress']))
if is_inspector: if is_inspector:
@@ -88,7 +110,7 @@ def index():
)) ))
# Single query — derive count from the list to avoid hitting the DB twice # Single query — derive count from the list to avoid hitting the DB twice
open_issues_all = open_issues_q.all() open_issues_all = open_issues_q.with_entities(*_ISSUE_CARD_COLS).all()
open_issues = len(open_issues_all) open_issues = len(open_issues_all)
severity_breakdown = { severity_breakdown = {
'critical': sum(1 for i in open_issues_all if i.severity == 'critical'), 'critical': sum(1 for i in open_issues_all if i.severity == 'critical'),
@@ -163,9 +185,13 @@ def index():
if not inspector_facility_ids: if not inspector_facility_ids:
followup_q = followup_q.filter(False) followup_q = followup_q.filter(False)
else: else:
# OWNERSHIP, not authorship — see Inspection.follow_up_owned_by().
# An assigned follow-up lives on an inspection somebody else
# performed, so testing inspector_id made the card read 0 for the
# very person who had been asked to do the work.
followup_q = followup_q.filter( followup_q = followup_q.filter(
Inspection.facility_id.in_(inspector_facility_ids), Inspection.facility_id.in_(inspector_facility_ids),
Inspection.inspector_id == current_user.id, Inspection.follow_up_owned_by(current_user.id),
) )
elif is_customer: elif is_customer:
if customer_facility_ids: if customer_facility_ids:
@@ -210,7 +236,7 @@ def index():
elif is_customer and not customer_facility_ids: elif is_customer and not customer_facility_ids:
all_open_issues = [] all_open_issues = []
else: else:
all_open_issues = sla_q.all() all_open_issues = sla_q.with_entities(*_ISSUE_CARD_COLS).all()
sla_breached = sum(1 for i in all_open_issues if sla_status(i) == 'breached') sla_breached = sum(1 for i in all_open_issues if sla_status(i) == 'breached')
sla_at_risk = sum(1 for i in all_open_issues if sla_status(i) == 'at_risk') sla_at_risk = sum(1 for i in all_open_issues if sla_status(i) == 'at_risk')
@@ -282,6 +308,26 @@ def index():
stale_q = stale_q.filter(False) stale_q = stale_q.filter(False)
stale_in_progress = stale_q.count() stale_in_progress = stale_q.count()
# ── In-progress inspections, all ages (MT-16) ─────────────────────────────
# stale_in_progress above counts only those older than 24h. The modern
# dashboard shows the full in-progress count as its own tile, so this is a
# separate query with the SAME role scoping rather than a reuse of stale_q.
inprog_q = Inspection.query.filter(Inspection.status == 'in_progress')
if is_inspector:
if not inspector_facility_ids:
inprog_q = inprog_q.filter(False)
else:
inprog_q = inprog_q.filter(
Inspection.facility_id.in_(inspector_facility_ids),
Inspection.inspector_id == current_user.id,
)
elif is_customer:
if customer_facility_ids:
inprog_q = inprog_q.filter(Inspection.facility_id.in_(customer_facility_ids))
else:
inprog_q = inprog_q.filter(False)
in_progress_total = inprog_q.count()
# ── Unassigned open issues ──────────────────────────────────────────────── # ── Unassigned open issues ────────────────────────────────────────────────
from app.models.facility import Area as _AreaU from app.models.facility import Area as _AreaU
unassigned_q = Issue.query.outerjoin(_AreaU, Issue.area_id == _AreaU.id).filter( unassigned_q = Issue.query.outerjoin(_AreaU, Issue.area_id == _AreaU.id).filter(
@@ -305,7 +351,7 @@ def index():
if is_privileged or is_project_manager or is_auditor: if is_privileged or is_project_manager or is_auditor:
active_inspectors = ( active_inspectors = (
User.query User.query
.filter_by(role='inspector', active=True) .filter(User.role.in_(User.INSPECTOR_ROLES), User.active == True)
.order_by(User.full_name, User.username) .order_by(User.full_name, User.username)
.all() .all()
) )
@@ -349,6 +395,11 @@ def index():
# inspections list, so surfacing them here would double-report the work. # inspections list, so surfacing them here would double-report the work.
sched_upcoming = [] sched_upcoming = []
sched_overdue_count = 0 sched_overdue_count = 0
# MT-16 — the modern dashboard additionally shows the total number of active
# plans ("On Schedules") and offers Continue instead of a duplicate Start
# where an inspection is already underway for that plan.
sched_total = 0
sched_open_inspections = {}
if not is_customer: if not is_customer:
from app.models.inspection_schedule import InspectionSchedule from app.models.inspection_schedule import InspectionSchedule
_today = now_eastern().date() _today = now_eastern().date()
@@ -365,11 +416,34 @@ def index():
s for s in _all_sched s for s in _all_sched
if s.next_run_at and _today <= s.next_run_at.date() <= _today + timedelta(days=7) if s.next_run_at and _today <= s.next_run_at.date() <= _today + timedelta(days=7)
][:8] ][:8]
sched_total = len(_all_sched) # active plan-mode schedules
# {schedule_id: inspection_id} for plans with an inspection already in
# progress. MT's FK is Inspection.inspection_schedule_id (ST calls it
# scheduled_inspection_id). Ordered ascending so that when a plan somehow
# has more than one open inspection, the dict keeps the LOWEST id — the
# original, not a later duplicate.
_sched_ids = [s.id for s in sched_upcoming if s.id]
if _sched_ids:
_open_rows = (
Inspection.query
.filter(Inspection.inspection_schedule_id.in_(_sched_ids),
Inspection.status == 'in_progress')
.order_by(Inspection.id.desc())
.all()
)
sched_open_inspections = {
r.inspection_schedule_id: r.id for r in _open_rows
}
return render_template( return render_template(
'dashboard.html', 'dashboard.html',
sched_upcoming = sched_upcoming, sched_upcoming = sched_upcoming,
sched_overdue_count = sched_overdue_count, sched_overdue_count = sched_overdue_count,
sched_total = sched_total,
sched_open_inspections = sched_open_inspections,
in_progress_total = in_progress_total,
submitted_this_week = submitted_this_week,
week_start_str = week_start.strftime('%Y-%m-%d'),
today_inspections = today_inspections, today_inspections = today_inspections,
completed_today = completed_today, completed_today = completed_today,
open_issues = open_issues, open_issues = open_issues,
+6 -6
View File
@@ -23,7 +23,7 @@ def list_facilities():
facilities = Facility.query.filter( facilities = Facility.query.filter(
Facility.id.in_(cids), Facility.active == True Facility.id.in_(cids), Facility.active == True
).order_by(Facility.name).all() ).order_by(Facility.name).all()
elif current_user.role == 'inspector': elif current_user.is_inspector:
fids = get_inspector_scope(current_user) or [] fids = get_inspector_scope(current_user) or []
facilities = Facility.query.filter( facilities = Facility.query.filter(
Facility.id.in_(fids), Facility.active == True Facility.id.in_(fids), Facility.active == True
@@ -262,7 +262,7 @@ def _facility_for_qr_or_403(facility_id):
if facility is None: if facility is None:
abort(404) abort(404)
# QR management is not an inspector task (matches qr_print_all/qr_export_pdf). # QR management is not an inspector task (matches qr_print_all/qr_export_pdf).
if current_user.role == 'inspector': if current_user.is_inspector:
abort(403) abort(403)
if current_user.role == 'customer': if current_user.role == 'customer':
cids = get_customer_scope(current_user) or [] cids = get_customer_scope(current_user) or []
@@ -329,7 +329,7 @@ def qr_sheet():
"""Bulk print sheet — one labeled QR card per active facility.""" """Bulk print sheet — one labeled QR card per active facility."""
from app.utils.qr import qr_svg from app.utils.qr import qr_svg
if current_user.role == 'inspector': if current_user.is_inspector:
abort(403) abort(403)
if current_user.role == 'customer': if current_user.role == 'customer':
@@ -395,7 +395,7 @@ def _area_for_qr_or_403(area_id):
if area is None: if area is None:
abort(404) abort(404)
# QR management is not an inspector task (matches qr_print_all/qr_export_pdf). # QR management is not an inspector task (matches qr_print_all/qr_export_pdf).
if current_user.role == 'inspector': if current_user.is_inspector:
abort(403) abort(403)
if current_user.role == 'customer': if current_user.role == 'customer':
cids = get_customer_scope(current_user) or [] cids = get_customer_scope(current_user) or []
@@ -483,7 +483,7 @@ def qr_print_all():
Inspectors have no QR management (403); customers are scoped to their Inspectors have no QR management (403); customers are scoped to their
assigned facilities; managers see all active facilities. assigned facilities; managers see all active facilities.
""" """
if current_user.role == 'inspector': if current_user.is_inspector:
abort(403) abort(403)
contract_id = request.args.get('contract_id', type=int) contract_id = request.args.get('contract_id', type=int)
@@ -552,7 +552,7 @@ def qr_export_pdf():
Scope is enforced per-id via the same helpers as the QR pages, so a Scope is enforced per-id via the same helpers as the QR pages, so a
customer can never export a code outside their assigned facilities. customer can never export a code outside their assigned facilities.
""" """
if current_user.role == 'inspector': if current_user.is_inspector:
abort(403) abort(403)
facility_ids = request.form.getlist('facility_ids', type=int) facility_ids = request.form.getlist('facility_ids', type=int)
+1 -1
View File
@@ -125,7 +125,7 @@ def _can_view_full(facility):
return False return False
if current_user.role in ('admin', 'director', 'project_manager'): if current_user.role in ('admin', 'director', 'project_manager'):
return True return True
if current_user.role == 'inspector': if current_user.is_inspector:
return facility.id in (get_inspector_scope(current_user) or []) return facility.id in (get_inspector_scope(current_user) or [])
if current_user.role == 'customer': if current_user.role == 'customer':
return facility.id in (get_customer_scope(current_user) or []) return facility.id in (get_customer_scope(current_user) or [])
File diff suppressed because it is too large Load Diff
+670 -62
View File
@@ -15,12 +15,13 @@ from app.models.project import Project
from app.models.issue import Issue from app.models.issue import Issue
from app.models.user import User from app.models.user import User
from app.utils.forms import StartInspectionForm, IssueForm from app.utils.forms import StartInspectionForm, IssueForm
from app.utils.decorators import supervisor_required from app.utils.decorators import supervisor_required, return_url
from app.utils.pdf_export import generate_inspection_pdf, generate_inspections_list_pdf from app.utils.pdf_export import generate_inspection_pdf, generate_inspections_list_pdf
from app.utils.notifications import notify, notify_customers_for_facility, notify_by_matrix from app.utils.notifications import notify, notify_customers_for_facility, notify_by_matrix
from app.models.notification import ( from app.models.notification import (
EVENT_INSPECTION_DONE, EVENT_ISSUE_ASSIGNED, EVENT_INSPECTION_DONE, EVENT_ISSUE_ASSIGNED,
EVENT_CUSTOMER_INSPECTION_DONE, EVENT_CUSTOMER_ISSUE_UPDATED, EVENT_CUSTOMER_INSPECTION_DONE, EVENT_CUSTOMER_ISSUE_UPDATED,
EVENT_FOLLOWUP_REQUESTED,
) )
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE, ACTION_EXPORT from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE, ACTION_EXPORT
from app.tenancy.gates import quota_soft_check from app.tenancy.gates import quota_soft_check
@@ -215,7 +216,7 @@ def index():
joinedload(Inspection.area), joinedload(Inspection.area),
).order_by(Inspection.inspection_date.desc()) ).order_by(Inspection.inspection_date.desc())
if current_user.role == 'inspector': if current_user.is_inspector:
fids = get_inspector_scope(current_user) fids = get_inspector_scope(current_user)
if not fids: if not fids:
q = q.filter(False) q = q.filter(False)
@@ -253,7 +254,8 @@ def index():
q = q.filter(Inspection.status == status_filter) q = q.filter(Inspection.status == status_filter)
if contract_filter.isdigit(): if contract_filter.isdigit():
_contract_fids = [ _contract_fids = [
f.id for f in Facility.query.filter_by(project_id=int(contract_filter)).all() fid for (fid,) in db.session.query(Facility.id)
.filter(Facility.project_id == int(contract_filter)).all()
] ]
q = q.filter(Inspection.facility_id.in_(_contract_fids)) if _contract_fids else q.filter(False) q = q.filter(Inspection.facility_id.in_(_contract_fids)) if _contract_fids else q.filter(False)
if facility_filter.isdigit(): if facility_filter.isdigit():
@@ -284,12 +286,12 @@ def index():
q = q.filter(Inspection.overall_score <= float(score_max_filter)) q = q.filter(Inspection.overall_score <= float(score_max_filter))
except ValueError: except ValueError:
score_max_filter = '' score_max_filter = ''
if inspector_filter.isdigit() and current_user.role != 'inspector': if inspector_filter.isdigit() and not current_user.is_inspector:
q = q.filter(Inspection.inspector_id == int(inspector_filter)) q = q.filter(Inspection.inspector_id == int(inspector_filter))
inspections = q.paginate(page=page, per_page=20, error_out=False) inspections = q.paginate(page=page, per_page=20, error_out=False)
if current_user.role == 'inspector': if current_user.is_inspector:
fids = get_inspector_scope(current_user) or [] fids = get_inspector_scope(current_user) or []
_fq = Facility.query.filter(Facility.id.in_(fids), Facility.active == True) _fq = Facility.query.filter(Facility.id.in_(fids), Facility.active == True)
elif current_user.role == 'customer': elif current_user.role == 'customer':
@@ -316,9 +318,9 @@ def index():
projects = Project.query.filter_by(active=True).order_by(Project.name).all() projects = Project.query.filter_by(active=True).order_by(Project.name).all()
# Inspector dropdown — shown to all roles except inspector (they only see their own) # Inspector dropdown — shown to all roles except inspector (they only see their own)
if current_user.role != 'inspector': if not current_user.is_inspector:
inspectors = (User.query inspectors = (User.query
.filter(User.role == 'inspector', User.active == True) .filter(User.role.in_(User.INSPECTOR_ROLES), User.active == True)
.order_by(User.full_name, User.username).all()) .order_by(User.full_name, User.username).all())
else: else:
inspectors = [] inspectors = []
@@ -349,11 +351,10 @@ def index():
def start(): def start():
form = StartInspectionForm() form = StartInspectionForm()
templates = InspectionTemplate.query.filter_by(active=True).order_by(InspectionTemplate.name).all()
projects = Project.query.filter_by(active=True).order_by(Project.name).all() projects = Project.query.filter_by(active=True).order_by(Project.name).all()
# Scope projects to inspector's assigned contracts # Scope projects to inspector's assigned contracts
if current_user.role == 'inspector': if current_user.is_inspector:
from app.models.inspector_assignment import InspectorAssignment from app.models.inspector_assignment import InspectorAssignment
assigned_pids = { assigned_pids = {
a.project_id for a in a.project_id for a in
@@ -361,7 +362,6 @@ def start():
} }
projects = [p for p in projects if p.id in assigned_pids] projects = [p for p in projects if p.id in assigned_pids]
form.template_id.choices = [(t.id, t.name) for t in templates]
form.project_id.choices = [(p.id, p.name) for p in projects] form.project_id.choices = [(p.id, p.name) for p in projects]
# Seed facility choices: use submitted project_id, session value, or first project # Seed facility choices: use submitted project_id, session value, or first project
@@ -375,6 +375,13 @@ def start():
else: else:
selected_project_id = projects[0].id if projects else None selected_project_id = projects[0].id if projects else None
# phase52 — forms are offered per CONTRACT: shared forms plus any attached
# to the selected contract. This is also the POST validation (SelectField
# validates against its choices), so a crafted template_id for another
# customer's form is rejected here, not merely hidden in the UI.
templates = InspectionTemplate.available_query(selected_project_id).all()
form.template_id.choices = [(t.id, t.name) for t in templates]
if selected_project_id: if selected_project_id:
facilities = Facility.query.filter_by(active=True, project_id=selected_project_id).order_by(Facility.name).all() facilities = Facility.query.filter_by(active=True, project_id=selected_project_id).order_by(Facility.name).all()
else: else:
@@ -407,9 +414,22 @@ def start():
if template is None: if template is None:
abort(404) abort(404)
# Belt-and-braces: the choices above already reject a form that is not
# available on this contract, but that guard lives in how the list was
# built. Re-assert it against the FACILITY actually chosen, so a future
# change to the choice-building cannot quietly open a cross-customer
# hole here.
_fac = db.session.get(Facility, form.facility_id.data)
if not template.available_for_project(_fac.project_id if _fac else None):
logger_msg = ('INSPECTION START BLOCKED | template=%s not available for '
'facility=%s | user=%s')
current_app.logger.warning(logger_msg, template.id,
form.facility_id.data, current_user.username)
abort(403)
# Inspector facility scope check — prevent crafted POST from selecting # Inspector facility scope check — prevent crafted POST from selecting
# a facility outside their assigned contracts. # a facility outside their assigned contracts.
if current_user.role == 'inspector': if current_user.is_inspector:
fids = get_inspector_scope(current_user) fids = get_inspector_scope(current_user)
if not fids or form.facility_id.data not in fids: if not fids or form.facility_id.data not in fids:
abort(403) abort(403)
@@ -457,13 +477,65 @@ def areas_for_facility(facility_id):
@bp.route('/facilities_for_project/<int:project_id>') @bp.route('/facilities_for_project/<int:project_id>')
@login_required @login_required
def facilities_for_project(project_id): def facilities_for_project(project_id):
facilities = (Facility.query """Active facilities on one contract, for the Contract -> Facility cascade.
.filter_by(active=True, project_id=project_id)
.order_by(Facility.name) **Scoped to the caller.** Every page that renders a facility dropdown
.all()) already limits it to what the viewer may see; this endpoint refills that
same dropdown, so without the same scope it would happily list another
customer's building names to anyone who guessed a contract id — the leak
rule 96 describes on the mobile API. Empty list rather than 403, so it does
not confirm whether the contract exists either.
"""
q = Facility.query.filter_by(active=True, project_id=project_id)
if current_user.is_inspector:
fids = get_inspector_scope(current_user) or []
q = q.filter(Facility.id.in_(fids)) if fids else q.filter(False)
elif current_user.role == 'customer':
fids = get_customer_scope(current_user) or []
q = q.filter(Facility.id.in_(fids)) if fids else q.filter(False)
facilities = q.order_by(Facility.name).all()
return jsonify([{'id': f.id, 'name': f.name} for f in facilities]) return jsonify([{'id': f.id, 'name': f.name} for f in facilities])
# ── AJAX: forms available on a given contract (phase52) ──────────────────────
@bp.route('/templates_for_project/<int:project_id>')
@login_required
def templates_for_project(project_id):
"""Forms usable on this contract — shared ones plus any attached to it.
Powers the Contract -> Form cascade on the start-inspection page, the same
way facilities_for_project powers Contract -> Facility.
**Scoped to the caller's own contracts.** The POST validation in start() is
what stops a form being *used* across contracts, but this endpoint would
otherwise happily list one customer's bespoke form NAMES to another
customer's inspector who simply asked for a contract id — the same leak
that rule 96 covers on the mobile API. Empty list rather than 403, so it
does not confirm whether the contract exists either.
"""
if current_user.is_inspector:
fids = get_inspector_scope(current_user) or []
allowed = {
f.project_id
for f in Facility.query.filter(Facility.id.in_(fids)).all()
} if fids else set()
if project_id not in allowed:
logger_msg = ('TEMPLATES_FOR_PROJECT | out-of-scope request | '
'user=%s | project_id=%s')
current_app.logger.warning(logger_msg, current_user.username, project_id)
return jsonify([])
elif current_user.role == 'customer':
# Customers never start inspections; nothing here is theirs to see.
return jsonify([])
templates = InspectionTemplate.available_query(project_id).all()
return jsonify([
{'id': t.id, 'name': t.name, 'shared': t.is_shared}
for t in templates
])
# ── Execute ─────────────────────────────────────────────────────────────────── # ── Execute ───────────────────────────────────────────────────────────────────
@bp.route('/<int:inspection_id>/execute', methods=['GET', 'POST']) @bp.route('/<int:inspection_id>/execute', methods=['GET', 'POST'])
@@ -473,12 +545,12 @@ def execute(inspection_id):
if inspection is None: if inspection is None:
abort(404) abort(404)
if current_user.role == 'inspector' and inspection.inspector_id != current_user.id: if current_user.is_inspector and inspection.inspector_id != current_user.id:
flash('Access denied.', 'danger') flash('Access denied.', 'danger')
return redirect(url_for('inspections.index')) return redirect(url_for('inspections.index'))
if inspection.status == 'completed': if inspection.status == 'completed':
return redirect(url_for('inspections.view', inspection_id=inspection_id)) return redirect(_view_url(inspection_id))
template = inspection.template template = inspection.template
form_fields = template.get_form_schema() form_fields = template.get_form_schema()
@@ -600,7 +672,7 @@ def execute(inspection_id):
f'status=completed; score={score}') f'status=completed; score={score}')
flash('Inspection submitted successfully!', 'success') flash('Inspection submitted successfully!', 'success')
return redirect(url_for('inspections.view', inspection_id=inspection_id)) return redirect(_view_url(inspection_id))
else: else:
_save_draft(inspection, responses) _save_draft(inspection, responses)
@@ -608,10 +680,10 @@ def execute(inspection_id):
flash('Draft saved. You can continue filling in the form later.', 'success') flash('Draft saved. You can continue filling in the form later.', 'success')
return redirect(url_for('inspections.execute', inspection_id=inspection_id)) return redirect(url_for('inspections.execute', inspection_id=inspection_id))
staff_for_flag_issue = User.query.filter( # Scoped to this inspection's contract — see _assignable_staff_for().
User.role.in_(['director', 'inspector', 'project_manager', 'auditor']), # Must match flag_issue()'s choices exactly or the offcanvas silently
User.active == True, # fails to save (rule 60).
).order_by(User.full_name, User.username).all() staff_for_flag_issue = _assignable_staff_for(inspection, current_user)
return render_template('inspections/execute.html', return render_template('inspections/execute.html',
inspection=inspection, inspection=inspection,
@@ -649,7 +721,7 @@ def save_draft_ajax(inspection_id):
if inspection is None: if inspection is None:
abort(404) abort(404)
if current_user.role == 'inspector' and inspection.inspector_id != current_user.id: if current_user.is_inspector and inspection.inspector_id != current_user.id:
return jsonify({'ok': False, 'error': 'Access denied'}), 403 return jsonify({'ok': False, 'error': 'Access denied'}), 403
if inspection.status == 'completed': if inspection.status == 'completed':
@@ -688,7 +760,7 @@ def upload_photo_ajax(inspection_id):
if inspection is None: if inspection is None:
return jsonify({'ok': False, 'error': 'Not found'}), 404 return jsonify({'ok': False, 'error': 'Not found'}), 404
if current_user.role == 'inspector' and inspection.inspector_id != current_user.id: if current_user.is_inspector and inspection.inspector_id != current_user.id:
return jsonify({'ok': False, 'error': 'Access denied'}), 403 return jsonify({'ok': False, 'error': 'Access denied'}), 403
if inspection.status == 'completed': if inspection.status == 'completed':
@@ -715,7 +787,11 @@ def view(inspection_id):
if inspection is None: if inspection is None:
abort(404) abort(404)
if current_user.role == 'inspector' and inspection.inspector_id != current_user.id: # Read access matches the LIST (rule 58) — an inspector may open anything
# at their contracted facilities, not only what they performed. Editing
# someone else's inspection is still refused (execute / save-draft /
# upload-photo / flag-issue keep the authorship check).
if current_user.is_inspector and not _inspector_may_read(inspection, current_user):
flash('Access denied.', 'danger') flash('Access denied.', 'danger')
return redirect(url_for('inspections.index')) return redirect(url_for('inspections.index'))
if current_user.role == 'customer': if current_user.role == 'customer':
@@ -912,7 +988,23 @@ def view(inspection_id):
'unchanged': sum(1 for r in rows if r['delta'] == 0), 'unchanged': sum(1 for r in rows if r['delta'] == 0),
} }
followup_assignees = _followup_assignees_for(inspection, current_user)
# An inspector viewing SOMEBODY ELSE's inspection gets a read-only page.
# Without this the buttons would all render and then fail on click — the
# same list-says-yes / page-says-no mismatch this change removes.
is_own_inspection = (not current_user.is_inspector
or inspection.inspector_id == current_user.id)
# Whoever is expected to carry out the follow-up (phase56) may start the
# re-inspection even though the original inspection is not theirs.
owns_follow_up = bool(
inspection.follow_up_required
and inspection.follow_up_owner
and inspection.follow_up_owner.id == current_user.id
)
return render_template('inspections/view.html', return render_template('inspections/view.html',
followup_assignees=followup_assignees,
is_own_inspection=is_own_inspection,
owns_follow_up=owns_follow_up,
inspection=inspection, inspection=inspection,
form_fields=form_fields, form_fields=form_fields,
form_data=form_data, form_data=form_data,
@@ -922,6 +1014,95 @@ def view(inspection_id):
# ── Flag issue during inspection ────────────────────────────────────────────── # ── Flag issue during inspection ──────────────────────────────────────────────
#: Internal roles that are NOT contract-scoped — they work across the whole
#: organisation, so they are offered regardless of which contract the
#: inspection belongs to. Only ever shown to our own people.
_ORG_WIDE_ASSIGNEE_ROLES = ('director', 'project_manager', 'auditor')
def _assignable_staff_for(inspection, actor):
"""Users `actor` may assign an issue to, for THIS inspection.
The candidate list is scoped by the inspection's CONTRACT, not taken
org-wide. Two distinct problems this fixes:
1. **Cross-customer leak.** A Customer Inspector could assign an issue to
anyone in the system including another client's Customer Inspector.
The assignee is notified by email and in-app with the facility name and
issue description, so this handed one customer's data to another. It is
a leak whoever flags the issue, so the contract scope is applied to the
two inspector roles for EVERY actor, not just customer ones.
2. An external account should not see our internal org chart at all. For a
customer-side actor the list is their co-workers on shared contracts
inspectors assigned to this inspection's contract — and nothing else.
Rules applied:
* inspector / external_inspector -> only those holding an
InspectorAssignment on this inspection's contract (the same rows
get_inspector_scope() reads, so the list can never disagree with what
the assignee can actually open).
* director / project_manager / auditor -> org-wide, but offered ONLY to
our own staff. These roles carry no InspectorAssignment rows, so
contract-scoping them would remove them entirely and break the normal
"escalate to the contract manager" flow.
* inactive accounts are never offered.
A facility with no contract yields no contract-scoped candidates; that is
fail-closed and correct an external actor then gets an empty list and can
only leave the issue unassigned.
Used by BOTH the offcanvas dropdown in execute() and the choices that
validate the POST in flag_issue(). They MUST stay identical: a value the UI
offers but the choices reject fails `validate_on_submit()`, and the
offcanvas JS treats the resulting 200 as success the issue is silently
never saved (rule 60's failure mode, which is exactly what the two
hand-maintained lists were already doing to project_manager and auditor).
"""
from app.models.inspector_assignment import InspectorAssignment
# `is_customer_account` is used here to WITHHOLD internal staff from an
# external account — the narrowing direction, which rule 89 permits. It
# must never be used to grant a customer-side account anything.
actor_is_external = bool(actor) and actor.is_customer_account
project_id = inspection.facility.project_id if inspection.facility else None
candidates = []
if project_id:
candidates = (
User.query
.join(InspectorAssignment, InspectorAssignment.user_id == User.id)
.filter(
InspectorAssignment.project_id == project_id,
User.role.in_(User.INSPECTOR_ROLES),
User.active == True,
)
.order_by(User.full_name, User.username)
.all()
)
if not actor_is_external:
candidates += (
User.query
.filter(
User.role.in_(_ORG_WIDE_ASSIGNEE_ROLES),
User.active == True,
)
.order_by(User.full_name, User.username)
.all()
)
# The join can repeat a user across assignment rows; dedupe by id, keeping
# a stable display order.
seen, out = set(), []
for u in candidates:
if u.id not in seen:
seen.add(u.id)
out.append(u)
out.sort(key=lambda u: (u.display_name or '').lower())
return out
@bp.route('/<int:inspection_id>/flag-issue', methods=['GET', 'POST']) @bp.route('/<int:inspection_id>/flag-issue', methods=['GET', 'POST'])
@login_required @login_required
def flag_issue(inspection_id): def flag_issue(inspection_id):
@@ -929,15 +1110,23 @@ def flag_issue(inspection_id):
if inspection is None: if inspection is None:
abort(404) abort(404)
if current_user.role == 'inspector' and inspection.inspector_id != current_user.id: if current_user.is_inspector and inspection.inspector_id != current_user.id:
flash('Access denied.', 'danger') flash('Access denied.', 'danger')
return redirect(url_for('inspections.index')) return redirect(url_for('inspections.index'))
form = IssueForm() form = IssueForm()
staff = User.query.filter(User.role.in_(['director', 'inspector'])).order_by(User.username).all() # SAME list the offcanvas rendered — this is what actually validates the
# POST, so it is also the security boundary: a crafted assigned_to for
# someone outside this contract fails validation rather than being stored.
staff = _assignable_staff_for(inspection, current_user)
form.facility_id.choices = [(inspection.facility_id, inspection.facility.name)] form.facility_id.choices = [(inspection.facility_id, inspection.facility.name)]
form.assigned_to.choices = [(0, '— Unassigned —')] + [(u.id, u.username) for u in staff] # Suffix customer-employed inspectors so whoever is triaging can see the
# work is going outside the company. Display only.
form.assigned_to.choices = [(0, '— Unassigned —')] + [
(u.id, u.display_name + (' (Customer)' if u.is_external_inspector else ''))
for u in staff
]
if form.validate_on_submit(): if form.validate_on_submit():
photo_path = _save_photo(form.photo.data, subfolder='issue_photos') photo_path = _save_photo(form.photo.data, subfolder='issue_photos')
@@ -1002,6 +1191,21 @@ def flag_issue(inspection_id):
flash('Issue logged successfully.', 'success') flash('Issue logged successfully.', 'success')
return redirect(url_for('inspections.execute', inspection_id=inspection_id)) return redirect(url_for('inspections.execute', inspection_id=inspection_id))
# A failed POST must NOT come back 200. The flag-issue offcanvas treats
# `res.ok` as success and reloads the page, so a 200 here means the issue
# is silently discarded with the user believing it was logged — the exact
# failure rule 60 describes. Returning 400 routes it to the JS error branch
# so the reason is shown and the form stays open with its input intact.
if request.method == 'POST':
if form.assigned_to.errors:
# Most likely an assignee outside this inspection's contract:
# either a stale page rendered before the assignment changed, or a
# crafted id. Say something actionable rather than "invalid choice".
flash('That person cannot be assigned to an issue on this contract. '
'Reopen the panel to refresh the list.', 'danger')
return render_template('inspections/flag_issue.html',
form=form, inspection=inspection), 400
return render_template('inspections/flag_issue.html', return render_template('inspections/flag_issue.html',
form=form, inspection=inspection) form=form, inspection=inspection)
@@ -1019,7 +1223,7 @@ def export_list_pdf():
joinedload(Inspection.area), joinedload(Inspection.area),
).order_by(Inspection.inspection_date.desc()) ).order_by(Inspection.inspection_date.desc())
if current_user.role == 'inspector': if current_user.is_inspector:
fids = get_inspector_scope(current_user) fids = get_inspector_scope(current_user)
if not fids: if not fids:
q = q.filter(False) q = q.filter(False)
@@ -1056,7 +1260,8 @@ def export_list_pdf():
q = q.filter(Inspection.status == status_filter) q = q.filter(Inspection.status == status_filter)
if contract_filter.isdigit(): if contract_filter.isdigit():
_contract_fids = [ _contract_fids = [
f.id for f in Facility.query.filter_by(project_id=int(contract_filter)).all() fid for (fid,) in db.session.query(Facility.id)
.filter(Facility.project_id == int(contract_filter)).all()
] ]
q = q.filter(Inspection.facility_id.in_(_contract_fids)) if _contract_fids else q.filter(False) q = q.filter(Inspection.facility_id.in_(_contract_fids)) if _contract_fids else q.filter(False)
if facility_filter.isdigit(): if facility_filter.isdigit():
@@ -1082,7 +1287,7 @@ def export_list_pdf():
q = q.filter(Inspection.overall_score <= float(score_max_filter)) q = q.filter(Inspection.overall_score <= float(score_max_filter))
except ValueError: except ValueError:
pass pass
if inspector_filter.isdigit() and current_user.role != 'inspector': if inspector_filter.isdigit() and not current_user.is_inspector:
q = q.filter(Inspection.inspector_id == int(inspector_filter)) q = q.filter(Inspection.inspector_id == int(inspector_filter))
inspections = q.all() inspections = q.all()
@@ -1112,7 +1317,7 @@ def export_list_pdf():
filter_parts.append(f'Min score: {score_min_filter}%') filter_parts.append(f'Min score: {score_min_filter}%')
if score_max_filter: if score_max_filter:
filter_parts.append(f'Max score: {score_max_filter}%') filter_parts.append(f'Max score: {score_max_filter}%')
if inspector_filter.isdigit() and current_user.role != 'inspector': if inspector_filter.isdigit() and not current_user.is_inspector:
u = db.session.get(User, int(inspector_filter)) u = db.session.get(User, int(inspector_filter))
if u: if u:
filter_parts.append(f'Inspector: {u.display_name}') filter_parts.append(f'Inspector: {u.display_name}')
@@ -1142,7 +1347,11 @@ def export_pdf(inspection_id):
if inspection is None: if inspection is None:
abort(404) abort(404)
if current_user.role == 'inspector' and inspection.inspector_id != current_user.id: # Read access matches the LIST (rule 58) — an inspector may open anything
# at their contracted facilities, not only what they performed. Editing
# someone else's inspection is still refused (execute / save-draft /
# upload-photo / flag-issue keep the authorship check).
if current_user.is_inspector and not _inspector_may_read(inspection, current_user):
flash('Access denied.', 'danger') flash('Access denied.', 'danger')
return redirect(url_for('inspections.index')) return redirect(url_for('inspections.index'))
if current_user.role == 'customer': if current_user.role == 'customer':
@@ -1214,31 +1423,397 @@ def export_pdf(inspection_id):
# ── Flag / clear follow-up required ────────────────────────────────────────── # ── Flag / clear follow-up required ──────────────────────────────────────────
def _view_url(inspection_id):
"""inspections.view URL that carries the list `next` through.
Actions posted from the detail page redirect back to that same page;
re-attaching `next` is what keeps its Back button (and the next action)
pointed at the filtered list the user arrived from.
"""
nxt = request.form.get('next') or request.args.get('next')
if nxt:
return url_for('inspections.view', inspection_id=inspection_id, next=nxt)
return url_for('inspections.view', inspection_id=inspection_id)
def _inspector_may_read(inspection, user):
"""May this inspector OPEN someone else's inspection?
Yes, when it happened at a facility on one of their contracts the same
scope `index()` uses (rule 58: an inspector's scope covers all data in their
contracted facilities, not just their own work).
This used to test authorship instead, and the two disagreed: the list
showed every inspection at the inspector's facilities, then clicking one
said "Access denied". It also blocked the phase56 follow-up assignee from
opening the parent inspection they had just been asked to re-inspect
the button they needed was on a page they could not reach.
READ only. Editing someone else's inspection is still refused: execute,
save-draft, upload-photo and flag-issue all keep the authorship check.
"""
fids = get_inspector_scope(user)
if fids is None: # not an inspector — no scoping applies here
return True
return bool(fids) and inspection.facility_id in fids
def _followup_assignees_for(inspection, actor):
"""Inspectors who may be handed this inspection's follow-up.
Contract-scoped, for the same reason the flag-issue list is (rule 93): a
Customer Director must never see let alone assign work to another
client's inspector, and one of our own directors picking the wrong name
would leak this facility to an outsider.
Only the two INSPECTOR roles are offered: a follow-up is an inspection, and
directors/PMs/auditors hold no InspectorAssignment, so they cannot be
scoped to a contract and could not open the re-inspection anyway.
A facility with no contract yields nobody fail-closed, leaving the
follow-up with the original inspector.
"""
from app.models.inspector_assignment import InspectorAssignment
project_id = inspection.facility.project_id if inspection.facility else None
if not project_id:
return []
users = (
User.query
.join(InspectorAssignment, InspectorAssignment.user_id == User.id)
.filter(
InspectorAssignment.project_id == project_id,
User.role.in_(User.INSPECTOR_ROLES),
User.active == True,
)
.order_by(User.full_name, User.username)
.all()
)
seen, out = set(), []
for u in users: # the join repeats across assignments
if u.id not in seen:
seen.add(u.id)
out.append(u)
return out
def _collect_inspection_photos(inspection):
"""Relative storage keys owned by an inspection, for cleanup after delete.
Two sources: image field values inside the submitted form data (stored as
`uploads/...` strings in the notes JSON), and the primary photo of each
issue flagged during the inspection. Shared by the single and bulk delete
paths so they cannot drift a miss here leaves orphaned files in storage
forever, and it is invisible.
"""
paths = []
if inspection.notes:
try:
notes_data = json.loads(inspection.notes)
form_data = notes_data.get('_form_data', {}) if isinstance(notes_data, dict) else {}
for val in form_data.values():
if isinstance(val, str) and val.startswith('uploads/'):
paths.append(val)
except (json.JSONDecodeError, TypeError):
pass
for issue in inspection.issues.all():
if issue.photo_path:
paths.append(issue.photo_path)
return paths
# ── Bulk actions from the inspections list ───────────────────────────────────
@bp.route('/bulk', methods=['POST'])
@login_required
def bulk_action():
"""Apply one action to every ticked inspection on the list page.
Partial-failure policy: act on every eligible row, skip the rest, and
report exact counts. Permission is checked per ACTION (all are
admin/director level except the PDF export, which anyone who can see the
list may run); `skipped` therefore means "this row was not in a state the
action applies to".
"""
back = return_url(url_for('inspections.index'))
action = request.form.get('action', '')
ids = request.form.getlist('inspection_ids', type=int)
if not ids:
flash('No inspections selected.', 'warning')
return redirect(back)
supervisor = current_user.role in ('admin', 'director')
allowed = {
'export': True, # read-only, already scoped below
'delete': supervisor,
'flag_followup': supervisor,
'clear_followup': supervisor,
}
if action not in allowed:
flash('Unknown bulk action.', 'danger')
return redirect(back)
if not allowed[action]:
flash('You do not have permission for that bulk action.', 'danger')
return redirect(back)
q = Inspection.query.options(
joinedload(Inspection.facility),
joinedload(Inspection.template),
joinedload(Inspection.inspector),
).filter(Inspection.id.in_(ids))
# Re-apply the viewer's facility scope to the SELECTED ids. The list page
# only ever shows in-scope rows, but the id list arrives in the POST body
# and must not be trusted — a crafted request could otherwise name any
# inspection in the system.
if current_user.is_inspector:
fids = get_inspector_scope(current_user) or []
q = q.filter(Inspection.facility_id.in_(fids)) if fids else q.filter(False)
elif current_user.role == 'customer':
fids = get_customer_scope(current_user) or []
q = q.filter(Inspection.facility_id.in_(fids)) if fids else q.filter(False)
inspections = q.order_by(Inspection.inspection_date.desc()).all()
out_of_scope = len(ids) - len(inspections)
changed = 0
skipped = out_of_scope
# ── Export selected to PDF ───────────────────────────────────────────
if action == 'export':
if not inspections:
flash('None of the selected inspections are available to you.', 'warning')
return redirect(back)
from flask import Response
pdf = generate_inspections_list_pdf(
inspections,
f'Selected inspections ({len(inspections)})',
)
log_action(ACTION_EXPORT, 'Inspection', None, 'bulk PDF export',
f'ids={[i.id for i in inspections]}')
return Response(
pdf,
mimetype='application/pdf',
headers={'Content-Disposition':
'attachment; filename="selected_inspections.pdf"'},
)
# ── Delete ───────────────────────────────────────────────────────────
if action == 'delete':
from app.utils import storage
photo_paths = []
# Snapshot (id, label) BEFORE deleting: the objects are expired after
# the commit, and the audit pass must run after it. log_action()
# commits internally (rule 41), so auditing inside this loop would
# commit the deletes one at a time — and a mid-loop failure would
# leave rows gone with the photo cleanup below never reached.
deleted = []
for insp in inspections:
photo_paths.extend(_collect_inspection_photos(insp))
deleted.append((
insp.id,
f'{insp.template.name if insp.template else ""} @ '
f'{insp.facility.name if insp.facility else ""}',
))
db.session.delete(insp)
changed += 1
db.session.commit()
for insp_id, label in deleted:
log_action(ACTION_DELETE, 'Inspection', insp_id, label,
f'bulk deleted by {current_user.username}')
# Files only after the rows are gone — an orphaned file is recoverable,
# a deleted file belonging to a surviving row is not.
for rel_path in photo_paths:
storage.delete(rel_path)
_flash_bulk(changed, skipped, 'permanently deleted')
# ── Request follow-up ────────────────────────────────────────────────
elif action == 'flag_followup':
note = request.form.get('follow_up_note', '').strip() or None
# Only the rows this run actually flagged. Re-deriving it afterwards
# from `follow_up_requested_by == current_user.id` would also match
# inspections this same user flagged on an EARLIER run and that were
# skipped here as already-flagged — re-notifying their inspectors.
flagged = []
for insp in inspections:
# Same two guards as the single-inspection route: nothing to follow
# up on before submission, and a repeat request must not overwrite
# the pending one's note or attribution.
if insp.status != 'completed' or insp.follow_up_required:
skipped += 1
continue
insp.follow_up_required = True
insp.follow_up_note = note
insp.follow_up_requested_by = current_user.id
insp.follow_up_requested_at = now_eastern()
flagged.append(insp)
changed += 1
db.session.commit()
for insp in flagged:
body = (f'{current_user.display_name} has requested a follow-up '
f're-inspection of "{insp.template.name if insp.template else ""}" '
f'at {insp.facility.name if insp.facility else ""}.'
+ (f' Note: {note}' if note else ''))
inspector = db.session.get(User, insp.inspector_id)
if inspector and inspector.id != current_user.id:
notify(
recipient = inspector,
title = f'Follow-Up Required: Inspection #{insp.id}',
body = body,
link = url_for('inspections.view', inspection_id=insp.id),
inspection_id = insp.id,
event_type = EVENT_INSPECTION_DONE,
send_email = True,
)
# Through the matrix, not straight to managers — rule 73, so
# per-contract recipients fire here exactly as they do for a
# single request.
notify_by_matrix(
event_type = EVENT_FOLLOWUP_REQUESTED,
title = f'Follow-Up Requested: Inspection #{insp.id}',
body = body,
link = url_for('inspections.view', inspection_id=insp.id),
inspection_id = insp.id,
facility_id = insp.facility_id,
exclude_user_ids = {current_user.id,
inspector.id if inspector else None} - {None},
)
db.session.commit() # notify() does not commit — rule 70
# Audited after the commit (rule 41) — log_action commits internally.
for insp in flagged:
log_action(ACTION_UPDATE, 'Inspection', insp.id,
f'{insp.template.name if insp.template else ""}',
f'bulk follow_up_required=True by {current_user.username}')
_flash_bulk(changed, skipped, 'flagged for follow-up',
skip_reason='not submitted, or already flagged')
# ── Clear follow-up ──────────────────────────────────────────────────
elif action == 'clear_followup':
cleared = []
for insp in inspections:
if not insp.follow_up_required:
skipped += 1
continue
insp.follow_up_required = False
insp.follow_up_note = None
insp.follow_up_requested_by = None
insp.follow_up_requested_at = None
insp.follow_up_assigned_to = None
cleared.append(insp)
changed += 1
db.session.commit()
for insp in cleared: # after the commit — rule 41
log_action(ACTION_UPDATE, 'Inspection', insp.id,
f'{insp.template.name if insp.template else ""}',
f'bulk follow_up cleared by {current_user.username}')
_flash_bulk(changed, skipped, 'cleared of the follow-up flag',
skip_reason='not flagged')
current_app.logger.info(
'INSPECTIONS | bulk | action=%s user=%s selected=%s changed=%s skipped=%s',
action, current_user.username, len(ids), changed, skipped,
)
return redirect(back)
def _flash_bulk(changed, skipped, verb, skip_reason='no change needed'):
"""One consistent result message for every bulk action."""
if not changed and not skipped:
flash('Nothing to do.', 'info')
return
parts = [f'{changed} inspection{"s" if changed != 1 else ""} {verb}']
if skipped:
parts.append(f'{skipped} skipped ({skip_reason})')
flash('. '.join(parts) + '.', 'success' if changed else 'warning')
@bp.route('/<int:inspection_id>/flag-followup', methods=['POST']) @bp.route('/<int:inspection_id>/flag-followup', methods=['POST'])
@login_required @login_required
@supervisor_required
def flag_followup(inspection_id): def flag_followup(inspection_id):
"""Mark an inspection as requiring a follow-up re-inspection.""" """Mark an inspection as requiring a follow-up re-inspection.
phase49: no longer @supervisor_required. Open to admin/director AND to
customers for their own facilities a client unhappy with a result can ask
for a re-inspection directly rather than going through support. Every other
role is refused, so inspectors and auditors are no worse off than before.
Customers may only *request*: they cannot clear the flag (clear_followup is
still @supervisor_required) nor run the re-inspection itself.
"""
inspection = db.session.get(Inspection, inspection_id) inspection = db.session.get(Inspection, inspection_id)
if inspection is None: if inspection is None:
abort(404) abort(404)
is_customer = current_user.role == 'customer'
if is_customer:
# Same facility scope as view() — a customer must not be able to reach
# another client's inspection with a crafted POST.
if inspection.facility_id not in (get_customer_scope(current_user) or []):
abort(403)
# Nothing to follow up on until the inspection has been submitted.
if inspection.status != 'completed':
flash('You can only request a follow-up on a completed inspection.', 'warning')
return redirect(_view_url(inspection_id))
# Don't let a repeat request overwrite the note/attribution of a pending
# one — the flag is already raised and staff are already on it.
if inspection.follow_up_required:
flash('A follow-up has already been requested for this inspection.', 'info')
return redirect(_view_url(inspection_id))
elif current_user.role not in ('admin', 'director'):
abort(403)
note = request.form.get('follow_up_note', '').strip() or None note = request.form.get('follow_up_note', '').strip() or None
# ── Assignee (phase56) ────────────────────────────────────────────────
# Optional. Blank keeps the original behaviour: the follow-up belongs to
# the inspection's own inspector. Validated against the contract-scoped
# list rather than trusted, so a crafted id cannot hand work to another
# customer's inspector (and tell them this facility's name in the email).
assignee_id = request.form.get('follow_up_assigned_to', type=int) or None
if assignee_id:
allowed = {u.id for u in _followup_assignees_for(inspection, current_user)}
if assignee_id not in allowed:
current_app.logger.warning(
'FOLLOW-UP | out-of-contract assignee blocked | inspection=%s | '
'assignee=%s | by=%s',
inspection_id, assignee_id, current_user.username)
flash('That inspector is not assigned to this facility\'s contract.',
'danger')
return redirect(_view_url(inspection_id))
inspection.follow_up_required = True inspection.follow_up_required = True
inspection.follow_up_note = note inspection.follow_up_note = note
inspection.follow_up_requested_by = current_user.id
inspection.follow_up_requested_at = now_eastern()
inspection.follow_up_assigned_to = assignee_id
db.session.commit() db.session.commit()
# Notify the original inspector so they see it on the iPad
inspector = db.session.get(User, inspection.inspector_id)
if inspector and inspector.id != current_user.id:
note_suffix = f' Note: {note}' if note else '' note_suffix = f' Note: {note}' if note else ''
who = (f'The customer ({current_user.display_name})' if is_customer
else current_user.display_name)
assigned_suffix = ''
if inspection.follow_up_assignee:
assigned_suffix = (f' It has been assigned to '
f'{inspection.follow_up_assignee.display_name}.')
body = (
f'{who} has requested a follow-up re-inspection '
f'of "{inspection.template.name}" at {inspection.facility.name}.'
f'{assigned_suffix}{note_suffix}'
)
# Notify whoever now OWNS the follow-up — the assignee when one was named,
# otherwise the original inspector (Inspection.follow_up_owner). Notifying
# the original inspector for work that has been handed to someone else is
# noise, and worse, it implies they are expected to do it.
inspector = inspection.follow_up_owner
if inspector and inspector.id != current_user.id:
notify( notify(
recipient = inspector, recipient = inspector,
title = f'Follow-Up Required: Inspection #{inspection_id}', title = f'Follow-Up Required: Inspection #{inspection_id}',
body = ( body = body,
f'{current_user.username} has requested a follow-up re-inspection '
f'of "{inspection.template.name}" at {inspection.facility.name}.{note_suffix}'
),
link = url_for('inspections.view', inspection_id=inspection_id), link = url_for('inspections.view', inspection_id=inspection_id),
inspection_id = inspection_id, inspection_id = inspection_id,
event_type = EVENT_INSPECTION_DONE, event_type = EVENT_INSPECTION_DONE,
@@ -1246,15 +1821,35 @@ def flag_followup(inspection_id):
) )
db.session.commit() db.session.commit()
# Route to the staff who action follow-ups. Going through notify_by_matrix
# rather than notifying managers directly keeps recipients admin-configurable
# and lets per-contract recipients fire too. This matters most for a customer
# request: without it only the inspector would hear about it and nobody would
# be accountable for scheduling the re-inspection.
notify_by_matrix(
event_type = EVENT_FOLLOWUP_REQUESTED,
title = f'Follow-Up Requested: Inspection #{inspection_id}',
body = body,
link = url_for('inspections.view', inspection_id=inspection_id),
inspection_id = inspection_id,
facility_id = inspection.facility_id,
exclude_user_ids = {current_user.id,
inspector.id if inspector else None} - {None},
)
db.session.commit()
current_app.logger.info( current_app.logger.info(
'INSPECTION FOLLOW-UP FLAGGED | id=%s | by=%s | note=%r', 'INSPECTION FOLLOW-UP FLAGGED | id=%s | by=%s (%s) | note=%r',
inspection_id, current_user.username, note, inspection_id, current_user.username, current_user.role, note,
) )
log_action(ACTION_UPDATE, 'Inspection', inspection_id, log_action(ACTION_UPDATE, 'Inspection', inspection_id,
f'{inspection.template.name} @ {inspection.facility.name}', f'{inspection.template.name} @ {inspection.facility.name}',
f'follow_up_required=True; note={note!r}') f'follow_up_required=True; by_role={current_user.role}; note={note!r}')
if is_customer:
flash('Follow-up re-inspection requested. The team has been notified.', 'success')
else:
flash('Follow-up inspection required flag set.', 'warning') flash('Follow-up inspection required flag set.', 'warning')
return redirect(url_for('inspections.view', inspection_id=inspection_id)) return redirect(_view_url(inspection_id))
@bp.route('/<int:inspection_id>/clear-followup', methods=['POST']) @bp.route('/<int:inspection_id>/clear-followup', methods=['POST'])
@@ -1267,12 +1862,18 @@ def clear_followup(inspection_id):
abort(404) abort(404)
inspection.follow_up_required = False inspection.follow_up_required = False
inspection.follow_up_note = None inspection.follow_up_note = None
# phase49 — clear the attribution with the flag. Leaving it behind would
# make the next unattributed follow-up appear to have been requested by
# whoever raised the previous one.
inspection.follow_up_requested_by = None
inspection.follow_up_requested_at = None
inspection.follow_up_assigned_to = None
db.session.commit() db.session.commit()
log_action(ACTION_UPDATE, 'Inspection', inspection_id, log_action(ACTION_UPDATE, 'Inspection', inspection_id,
f'{inspection.template.name} @ {inspection.facility.name}', f'{inspection.template.name} @ {inspection.facility.name}',
'follow_up_required=False (cleared)') 'follow_up_required=False (cleared)')
flash('Follow-up flag cleared.', 'success') flash('Follow-up flag cleared.', 'success')
return redirect(url_for('inspections.view', inspection_id=inspection_id)) return redirect(_view_url(inspection_id))
# ── Start a re-inspection (linked to parent) ────────────────────────────────── # ── Start a re-inspection (linked to parent) ──────────────────────────────────
@@ -1291,6 +1892,26 @@ def reinspect(inspection_id):
flash('Access denied.', 'danger') flash('Access denied.', 'danger')
return redirect(url_for('inspections.index')) return redirect(url_for('inspections.index'))
# An inspector may re-inspect their OWN work, or work they have been
# handed the follow-up for (phase56). Anything else at a contracted
# facility is readable but not theirs to redo — starting a re-inspection
# of a colleague's inspection uninvited only creates confusion about who
# is doing it.
if current_user.is_inspector:
if parent.follow_up_required and parent.follow_up_owner:
# A live follow-up has exactly ONE owner (phase56). Even the
# original inspector does not start it once it has been handed to
# someone else — that is the whole point of assigning it, and two
# people turning up is the failure being designed out.
may = parent.follow_up_owner.id == current_user.id
else:
# No follow-up outstanding: re-inspecting your own work is fine,
# someone else's is not yours to redo uninvited.
may = parent.inspector_id == current_user.id
if not may:
flash('That re-inspection has been assigned to someone else.', 'warning')
return redirect(_view_url(inspection_id))
session['reinspect_parent_id'] = parent.id session['reinspect_parent_id'] = parent.id
session['reinspect_template_id'] = parent.template_id session['reinspect_template_id'] = parent.template_id
session['reinspect_facility_id'] = parent.facility_id session['reinspect_facility_id'] = parent.facility_id
@@ -1318,20 +1939,7 @@ def delete(inspection_id):
template_name = inspection.template.name template_name = inspection.template.name
inspector_name = inspection.inspector.username inspector_name = inspection.inspector.username
photo_paths = [] photo_paths = _collect_inspection_photos(inspection)
if inspection.notes:
try:
notes_data = json.loads(inspection.notes)
form_data = notes_data.get('_form_data', {}) if isinstance(notes_data, dict) else {}
for val in form_data.values():
if isinstance(val, str) and val.startswith('uploads/'):
photo_paths.append(val)
except (json.JSONDecodeError, TypeError):
pass
for issue in inspection.issues.all():
if issue.photo_path:
photo_paths.append(issue.photo_path)
db.session.delete(inspection) db.session.delete(inspection)
db.session.commit() db.session.commit()
@@ -1358,4 +1966,4 @@ def delete(inspection_id):
f'has been permanently deleted.', f'has been permanently deleted.',
'success' 'success'
) )
return redirect(url_for('inspections.index')) return redirect(return_url(url_for('inspections.index')))
+575 -44
View File
@@ -6,7 +6,7 @@ from flask import (Blueprint, render_template, redirect, url_for,
flash, request, current_app, jsonify, abort, Response) flash, request, current_app, jsonify, abort, Response)
from flask_login import login_required, current_user from flask_login import login_required, current_user
from app import db from app import db
from app.models.issue import Issue, IssueComment, IssueFollower from app.models.issue import Issue, IssueComment, IssueFollower, IssueLink
from app.models.facility import Facility, Area from app.models.facility import Facility, Area
from app.models.user import User from app.models.user import User
from app.models.notification import ( from app.models.notification import (
@@ -16,7 +16,7 @@ from app.models.notification import (
) )
from app.utils.forms import IssueForm, IssueUpdateForm from app.utils.forms import IssueForm, IssueUpdateForm
from app.utils.decorators import (supervisor_required, project_manager_required, from app.utils.decorators import (supervisor_required, project_manager_required,
issue_manager_required) issue_manager_required, return_url)
from app.utils.notifications import notify, notify_customers_for_facility, notify_by_matrix from app.utils.notifications import notify, notify_customers_for_facility, notify_by_matrix
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE, ACTION_EXPORT from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE, ACTION_EXPORT
from app.tenancy.gates import quota_soft_check from app.tenancy.gates import quota_soft_check
@@ -71,6 +71,60 @@ class _SLAFilteredPage:
return iter([1]) return iter([1])
# ── Issue read access ─────────────────────────────────────────────────────────
# One definition of "may this person open this issue", used by the detail view,
# by the linked-issues panel, and by the link picker's search. They must not
# drift: the picker is what a person searches, but the panel is what actually
# renders another issue's description, and the POST is the real boundary.
#
# All three are WITHIN one tenant. Cross-tenant isolation is not their job and
# never can be — RoutingSession has already bound the session to g.tenant's
# database, so an id from another tenant simply does not resolve here.
def _viewer_facility_scope(user):
"""Facility ids this user is confined to, or None when unrestricted.
Returns a LIST (possibly empty) for the two scoped role groups and None for
everyone else. Empty list and None mean opposite things [] is "no access
to anything", None is "no restriction" — so callers must test `is None`
rather than truthiness (CLAUDE.md rule 57's failure mode).
"""
if user.is_inspector: # rule 87 — never role == 'inspector'
return get_inspector_scope(user) or []
if user.role == 'customer': # rule 99 — capability check, exact match
return get_customer_scope(user) or []
return None
def _issue_in_scope(issue, scope_ids):
"""Whether one issue falls inside an already-resolved facility scope.
Takes the scope rather than the user so a caller filtering a list of issues
resolves it once instead of re-querying the assignment tables per row.
"""
if scope_ids is None:
return True
facility = issue.resolved_facility
return facility is not None and facility.id in scope_ids
def _issue_readable_by(issue, user):
"""Single-issue convenience wrapper around the two helpers above."""
return _issue_in_scope(issue, _viewer_facility_scope(user))
def _assignee_label(user):
"""Dropdown label for an assignee.
MT-15 external (customer / third-party) inspectors are assignable just
like the tenant's own crew, but are suffixed so whoever is triaging can see
at a glance that the work is going outside the company. Display only; the
stored value is still the user id.
"""
return (f'{user.display_name} (Customer)'
if user.is_external_inspector else user.display_name)
@bp.route('/export-list-pdf') @bp.route('/export-list-pdf')
@login_required @login_required
def export_list_pdf(): def export_list_pdf():
@@ -86,7 +140,7 @@ def export_list_pdf():
.order_by(Issue.reported_at.desc()) .order_by(Issue.reported_at.desc())
) )
if current_user.role == 'inspector': if current_user.is_inspector:
fids = get_inspector_scope(current_user) fids = get_inspector_scope(current_user)
if not fids: if not fids:
q = q.filter(False) q = q.filter(False)
@@ -134,13 +188,14 @@ def export_list_pdf():
date_to_filter = '' date_to_filter = ''
if contract_filter.isdigit(): if contract_filter.isdigit():
_contract_fids = [ _contract_fids = [
f.id for f in Facility.query.filter_by(project_id=int(contract_filter)).all() fid for (fid,) in db.session.query(Facility.id)
.filter(Facility.project_id == int(contract_filter)).all()
] ]
q = q.filter(db.or_( q = q.filter(db.or_(
Issue.facility_id.in_(_contract_fids), Issue.facility_id.in_(_contract_fids),
Area.facility_id.in_(_contract_fids), Area.facility_id.in_(_contract_fids),
)) if _contract_fids else q.filter(False) )) if _contract_fids else q.filter(False)
if facility_filter: if facility_filter.isdigit():
fid = int(facility_filter) fid = int(facility_filter)
q = q.filter(db.or_(Issue.facility_id == fid, Area.facility_id == fid)) q = q.filter(db.or_(Issue.facility_id == fid, Area.facility_id == fid))
if reporter_filter.isdigit(): if reporter_filter.isdigit():
@@ -169,7 +224,7 @@ def export_list_pdf():
p = db.session.get(Project, int(contract_filter)) p = db.session.get(Project, int(contract_filter))
if p: if p:
filter_parts.append(f'Contract: {p.name}') filter_parts.append(f'Contract: {p.name}')
if facility_filter: if facility_filter.isdigit():
f = db.session.get(Facility, int(facility_filter)) f = db.session.get(Facility, int(facility_filter))
if f: if f:
filter_parts.append(f'Facility: {f.name}') filter_parts.append(f'Facility: {f.name}')
@@ -211,7 +266,7 @@ def index():
.order_by(Issue.reported_at.desc()) .order_by(Issue.reported_at.desc())
) )
if current_user.role == 'inspector': if current_user.is_inspector:
fids = get_inspector_scope(current_user) fids = get_inspector_scope(current_user)
if not fids: if not fids:
q = q.filter(False) q = q.filter(False)
@@ -267,7 +322,8 @@ def index():
date_to_filter = '' date_to_filter = ''
if contract_filter.isdigit(): if contract_filter.isdigit():
_contract_fids = [ _contract_fids = [
f.id for f in Facility.query.filter_by(project_id=int(contract_filter)).all() fid for (fid,) in db.session.query(Facility.id)
.filter(Facility.project_id == int(contract_filter)).all()
] ]
q = q.filter(db.or_( q = q.filter(db.or_(
Issue.facility_id.in_(_contract_fids), Issue.facility_id.in_(_contract_fids),
@@ -275,7 +331,7 @@ def index():
)) if _contract_fids else q.filter(False) )) if _contract_fids else q.filter(False)
if reporter_filter.isdigit(): if reporter_filter.isdigit():
q = q.filter(Issue.reported_by == int(reporter_filter)) q = q.filter(Issue.reported_by == int(reporter_filter))
if facility_filter: if facility_filter.isdigit():
fid = int(facility_filter) fid = int(facility_filter)
q = q.filter( q = q.filter(
db.or_( db.or_(
@@ -313,13 +369,14 @@ def index():
# can render the following badge and inline unfollow button without an # can render the following badge and inline unfollow button without an
# additional query per row. # additional query per row.
followed_ids = { followed_ids = {
f.issue_id iid for (iid,) in
for f in IssueFollower.query.filter_by(user_id=current_user.id).all() db.session.query(IssueFollower.issue_id)
.filter(IssueFollower.user_id == current_user.id).all()
} }
# Facilities for the filter dropdown — scoped for inspectors/customers, # Facilities for the filter dropdown — scoped for inspectors/customers,
# then narrowed to the selected contract when contract_filter is active. # then narrowed to the selected contract when contract_filter is active.
if current_user.role == 'inspector': if current_user.is_inspector:
fids = get_inspector_scope(current_user) or [] fids = get_inspector_scope(current_user) or []
_fq = Facility.query.filter(Facility.id.in_(fids), Facility.active == True) _fq = Facility.query.filter(Facility.id.in_(fids), Facility.active == True)
elif current_user.role == 'customer': elif current_user.role == 'customer':
@@ -347,7 +404,8 @@ def index():
# Staff for quick-assign dropdown — same roles as the full issue form # Staff for quick-assign dropdown — same roles as the full issue form
staff = User.query.filter( staff = User.query.filter(
User.role.in_(['director', 'inspector', 'auditor']), User.active == True User.role.in_(['director', 'inspector', 'external_inspector', 'auditor']),
User.active == True
).order_by(User.username).all() ).order_by(User.username).all()
# Reporters dropdown — users who have actually filed at least one issue # Reporters dropdown — users who have actually filed at least one issue
@@ -385,18 +443,14 @@ def view(issue_id):
if issue is None: if issue is None:
abort(404) abort(404)
if current_user.role == 'inspector': # Scope gate — see _issue_readable_by(). This was two inline blocks that the
fids = get_inspector_scope(current_user) # linked-issues panel would have had to reproduce a third time; it is now
facility = issue.resolved_facility # one definition so the panel cannot end up more permissive than the page.
if not fids or not facility or facility.id not in fids: if not _issue_readable_by(issue, current_user):
flash('Access denied.', 'danger') flash('Access denied.', 'danger')
return redirect(url_for('issues.index')) return redirect(url_for('issues.index'))
if current_user.role == 'customer': if current_user.role == 'customer':
cids = get_customer_scope(current_user) or []
facility = issue.resolved_facility
if not facility or facility.id not in cids:
flash('Access denied.', 'danger')
return redirect(url_for('issues.index'))
if request.method == 'POST': if request.method == 'POST':
# Customers may only add a comment, and only on issues they follow or reported # Customers may only add a comment, and only on issues they follow or reported
can_comment = (issue.is_followed_by(current_user) or issue.reported_by == current_user.id) can_comment = (issue.is_followed_by(current_user) or issue.reported_by == current_user.id)
@@ -405,7 +459,7 @@ def view(issue_id):
comment_body = request.form.get('update_notes', '').strip() comment_body = request.form.get('update_notes', '').strip()
if not comment_body: if not comment_body:
flash('Comment cannot be empty.', 'warning') flash('Comment cannot be empty.', 'warning')
return redirect(url_for('issues.view', issue_id=issue_id)) return redirect(_view_url(issue_id))
comment = IssueComment( comment = IssueComment(
issue_id=issue.id, issue_id=issue.id,
user_id=current_user.id, user_id=current_user.id,
@@ -419,10 +473,10 @@ def view(issue_id):
f'#{issue.id}', f'#{issue.id}',
'customer comment added') 'customer comment added')
flash('Comment posted.', 'success') flash('Comment posted.', 'success')
return redirect(url_for('issues.view', issue_id=issue_id)) return redirect(_view_url(issue_id))
form = IssueUpdateForm(obj=issue) form = IssueUpdateForm(obj=issue)
staff = User.query.filter(User.role.in_(['director', 'inspector', 'auditor'])).order_by(User.username).all() staff = User.query.filter(User.role.in_(['director', 'inspector', 'external_inspector', 'auditor'])).order_by(User.username).all()
# Preserve any pre-existing assignee who is no longer in the assignable set # Preserve any pre-existing assignee who is no longer in the assignable set
# (e.g. an admin assigned before admins were removed from the dropdown) so # (e.g. an admin assigned before admins were removed from the dropdown) so
# saving the form doesn't silently unassign them. # saving the form doesn't silently unassign them.
@@ -430,7 +484,9 @@ def view(issue_id):
current_assignee = db.session.get(User, issue.assigned_to) current_assignee = db.session.get(User, issue.assigned_to)
if current_assignee: if current_assignee:
staff.append(current_assignee) staff.append(current_assignee)
form.assigned_to.choices = [(0, '— Unassigned —')] + [(u.id, u.display_name) for u in staff] form.assigned_to.choices = [(0, '— Unassigned —')] + [
(u.id, _assignee_label(u)) for u in staff
]
form.status.data = form.status.data or issue.status form.status.data = form.status.data or issue.status
if form.validate_on_submit(): if form.validate_on_submit():
@@ -462,8 +518,19 @@ def view(issue_id):
issue.vendor_contact = form.vendor_contact.data.strip() or None issue.vendor_contact = form.vendor_contact.data.strip() or None
issue.vendor_notes = form.vendor_notes.data.strip() or None issue.vendor_notes = form.vendor_notes.data.strip() or None
# Handler type (phase39) # Handler type (phase39; NOT NULL since phase44)
ht = form.handler_type.data or None # Coerce empty/unknown to 'internal' explicitly. This is NOT
# preventing a crash: handler_type carries a Python-side
# default='internal', and SQLAlchemy applies a column default when
# the attribute is None — so the previous `or None` would have been
# silently rescued to 'internal' rather than raising. The point is to
# not depend on that fairly obscure behaviour, and to state the
# intended value at the point of assignment. The membership check
# also backstops a crafted POST, though SelectField.pre_validate
# already rejects out-of-choice values.
ht = form.handler_type.data or 'internal'
if ht not in ('internal', 'facility', 'vendor'):
ht = 'internal'
issue.handler_type = ht issue.handler_type = ht
if ht == 'facility': if ht == 'facility':
issue.facility_handler_name = form.facility_handler_name.data.strip() or None issue.facility_handler_name = form.facility_handler_name.data.strip() or None
@@ -474,6 +541,13 @@ def view(issue_id):
issue.facility_handler_contact = None issue.facility_handler_contact = None
issue.facility_handler_notes = None issue.facility_handler_notes = None
# Janitorial staff handler (phase44). Written unconditionally, the
# same way vendor_* above is: the work-order dispatch route also
# writes vendor_name, so clearing non-active handler fields here
# would discard data set elsewhere.
issue.internal_handler_name = (form.internal_handler_name.data or '').strip() or None
issue.internal_handler_contact = (form.internal_handler_contact.data or '').strip() or None
from app.routes.inspections import _save_photo from app.routes.inspections import _save_photo
new_photos = [] new_photos = []
for file_obj in request.files.getlist('result_photos'): for file_obj in request.files.getlist('result_photos'):
@@ -633,10 +707,16 @@ def view(issue_id):
f'#{issue.id} in {issue.area.name if issue.area else issue.resolved_facility.name if issue.resolved_facility else ''}', f'#{issue.id} in {issue.area.name if issue.area else issue.resolved_facility.name if issue.resolved_facility else ''}',
f'status={issue.status}; assigned_to={issue.assigned_to}') f'status={issue.status}; assigned_to={issue.assigned_to}')
flash('Issue updated.', 'success') flash('Issue updated.', 'success')
return redirect(url_for('issues.view', issue_id=issue_id)) return redirect(_view_url(issue_id))
is_following = issue.is_followed_by(current_user) is_following = issue.is_followed_by(current_user)
if current_user.role == 'customer': # TEMPORARY (Aug 2026) — COMMENTS_VISIBLE_TO_ALL lifts the phase22
# restriction so customers see every comment on the issue, not only the
# ones ticked "Share with customer". is_customer_visible is still recorded
# on every comment, so setting the flag back to false restores the old
# filtering with nothing to repair. See config.py.
comments_open = current_app.config.get('COMMENTS_VISIBLE_TO_ALL', False)
if current_user.role == 'customer' and not comments_open:
comments = (issue.comments comments = (issue.comments
.filter_by(is_customer_visible=True) .filter_by(is_customer_visible=True)
.order_by(IssueComment.created_at.asc()).all()) .order_by(IssueComment.created_at.asc()).all())
@@ -646,7 +726,210 @@ def view(issue_id):
issue=issue, issue=issue,
form=form, form=form,
comments=comments, comments=comments,
is_following=is_following) comments_open=comments_open,
is_following=is_following,
# Already filtered to links whose far end this viewer
# may open — see _readable_links().
issue_links=_readable_links(issue, current_user),
link_types=IssueLink.TYPE_CHOICES,
can_manage_links=_can_manage_links(issue, current_user))
# ── Issue links ───────────────────────────────────────────────────────────────
# Connect a duplicate to its original, or two issues about the same thing, so
# whoever picks one up can reach the other. Links are purely navigational: they
# never touch status, SLA, assignee or followers on either issue.
def _can_manage_links(issue, user):
"""Who may add or remove a link on this issue.
Deliberately the SAME set as the page's `can_edit` (the Update Issue panel):
admin / director / auditor, or the person the issue is assigned to. Keeping
the two identical means the panel's buttons and this gate cannot disagree —
the alternative is a second, slightly different rule that nobody remembers.
Widening it (to project_manager, or to the reporter) is a one-line change
here, but change `can_edit` in issues/view.html at the same time.
"""
return (user.role in ('admin', 'director', 'auditor')
or issue.assigned_to == user.id)
def _readable_links(issue, user):
"""Links on this issue whose FAR END the viewer may also open.
A link is a pointer to another issue's id, description and facility, so an
unfiltered panel would let a customer read an issue at a facility they have
no assignment to simply because one of our staff linked it. The scope is
resolved once for the whole list rather than per row.
Returns a list of (link, other_issue, label) ready for the template.
"""
scope = _viewer_facility_scope(user)
visible = []
for link in issue.all_links():
other = link.other_issue(issue.id)
if other is None or not _issue_in_scope(other, scope):
continue
visible.append((link, other, link.label_for(issue.id)))
return visible
@bp.route('/<int:issue_id>/links', methods=['POST'])
@login_required
def add_link(issue_id):
"""Link this issue to another one."""
issue = db.session.get(Issue, issue_id)
if issue is None:
abort(404)
if not _issue_readable_by(issue, current_user):
abort(403)
if not _can_manage_links(issue, current_user):
abort(403)
link_type = request.form.get('link_type', '')
if link_type not in (IssueLink.TYPE_DUPLICATE, IssueLink.TYPE_RELATED):
flash('Choose how the two issues are related.', 'warning')
return redirect(_view_url(issue_id))
raw_target = (request.form.get('linked_issue_id') or '').strip().lstrip('#')
if not raw_target.isdigit():
flash('Enter the number of the issue to link, e.g. 412.', 'warning')
return redirect(_view_url(issue_id))
target_id = int(raw_target)
if target_id == issue.id:
flash('An issue cannot be linked to itself.', 'warning')
return redirect(_view_url(issue_id))
target = db.session.get(Issue, target_id)
# A 404 and a 403 are the same message here on purpose: whether an issue
# outside your scope EXISTS is not something the link box should confirm.
if target is None or not _issue_readable_by(target, current_user):
flash(f'Issue #{target_id} was not found.', 'warning')
return redirect(_view_url(issue_id))
if IssueLink.exists_between(issue.id, target.id):
flash(f'Issue #{issue.id} and #{target.id} are already linked.', 'info')
return redirect(_view_url(issue_id))
link = IssueLink(
issue_id = issue.id,
linked_issue_id = target.id,
link_type = link_type,
created_by = current_user.id,
)
db.session.add(link)
db.session.commit()
log_action(ACTION_UPDATE, 'Issue', issue.id, f'#{issue.id}',
f'linked to #{target.id} as {link_type}')
current_app.logger.info(
'ISSUE LINK | issue_id=%s | linked_issue_id=%s | type=%s | user=%s',
issue.id, target.id, link_type, current_user.username,
)
flash(f'Issue #{issue.id} is now linked to #{target.id}.', 'success')
return redirect(_view_url(issue_id))
@bp.route('/<int:issue_id>/links/<int:link_id>/delete', methods=['POST'])
@login_required
def remove_link(issue_id, link_id):
"""Remove a link. Either end of it may do this."""
issue = db.session.get(Issue, issue_id)
if issue is None:
abort(404)
if not _issue_readable_by(issue, current_user):
abort(403)
if not _can_manage_links(issue, current_user):
abort(403)
link = db.session.get(IssueLink, link_id)
# The link must actually touch THIS issue. Without the check, anyone able to
# manage links on any one issue could delete a link between two others by
# posting its id here.
if link is None or issue.id not in (link.issue_id, link.linked_issue_id):
flash('That link no longer exists.', 'info')
return redirect(_view_url(issue_id))
other_id = link.linked_issue_id if link.issue_id == issue.id else link.issue_id
db.session.delete(link)
db.session.commit()
log_action(ACTION_UPDATE, 'Issue', issue.id, f'#{issue.id}',
f'unlinked from #{other_id}')
current_app.logger.info(
'ISSUE UNLINK | issue_id=%s | linked_issue_id=%s | user=%s',
issue.id, other_id, current_user.username,
)
flash(f'Removed the link to issue #{other_id}.', 'info')
return redirect(_view_url(issue_id))
@bp.route('/<int:issue_id>/link-search')
@login_required
def link_search(issue_id):
"""JSON candidates for the link picker.
Scoped exactly like the issue list, so an inspector or customer can only
find issues they could already open searching must not become a way to
enumerate another contract's issues. The results are a convenience; the POST
in add_link() re-checks access and is the real boundary.
"""
issue = db.session.get(Issue, issue_id)
if issue is None:
abort(404)
if not _issue_readable_by(issue, current_user):
abort(403)
term = (request.args.get('q') or '').strip().lstrip('#')
if len(term) < 1:
return jsonify({'results': []})
q = (
Issue.query
.outerjoin(Area, Issue.area_id == Area.id)
.options(joinedload(Issue.facility), contains_eager(Issue.area))
.filter(Issue.id != issue.id)
)
scope = _viewer_facility_scope(current_user)
if scope is not None:
if not scope:
return jsonify({'results': []})
q = q.filter(db.or_(
Issue.facility_id.in_(scope),
db.and_(Issue.area_id.isnot(None), Area.facility_id.in_(scope)),
))
# Exclude issues already linked in either direction — offering them only
# produces an "already linked" flash.
linked_ids = {other.id for _l, other, _lbl in _readable_links(issue, current_user)}
if linked_ids:
q = q.filter(Issue.id.notin_(linked_ids))
if term.isdigit():
# A number is almost always an issue number, so match the id first and
# fall back to the description for things like "Room 204".
q = q.filter(db.or_(Issue.id == int(term),
Issue.description.ilike(f'%{term}%')))
else:
q = q.filter(Issue.description.ilike(f'%{term}%'))
matches = q.order_by(Issue.reported_at.desc()).limit(10).all()
return jsonify({'results': [
{
'id': i.id,
'description': (i.description or '')[:110],
'status': (i.status or '').replace('_', ' ').title(),
'severity': (i.severity or '').title(),
'location': (i.area.name if i.area
else i.resolved_facility.name if i.resolved_facility
else ''),
'reported_at': i.reported_at.strftime('%Y-%m-%d') if i.reported_at else '',
}
for i in matches
]})
# ── Follow ──────────────────────────────────────────────────────────────────── # ── Follow ────────────────────────────────────────────────────────────────────
@@ -668,7 +951,7 @@ def follow(issue_id):
flash('You are now following this issue and will receive notifications for any updates.', 'success') flash('You are now following this issue and will receive notifications for any updates.', 'success')
else: else:
flash('You are already following this issue.', 'info') flash('You are already following this issue.', 'info')
return redirect(url_for('issues.view', issue_id=issue_id)) return redirect(_view_url(issue_id))
# ── Unfollow ────────────────────────────────────────────────────────────────── # ── Unfollow ──────────────────────────────────────────────────────────────────
@@ -725,10 +1008,12 @@ def create():
else: else:
facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all() facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all()
projects = Project.query.filter_by(active=True).order_by(Project.name).all() projects = Project.query.filter_by(active=True).order_by(Project.name).all()
staff = User.query.filter(User.role.in_(['director', 'inspector', 'auditor'])).order_by(User.username).all() staff = User.query.filter(User.role.in_(['director', 'inspector', 'external_inspector', 'auditor'])).order_by(User.username).all()
form.facility_id.choices = [(f.id, f.name) for f in facilities] form.facility_id.choices = [(f.id, f.name) for f in facilities]
form.assigned_to.choices = [(0, '— Unassigned —')] + [(u.id, u.display_name) for u in staff] form.assigned_to.choices = [(0, '— Unassigned —')] + [
(u.id, _assignee_label(u)) for u in staff
]
# On POST validation error: identify which contract the submitted facility # On POST validation error: identify which contract the submitted facility
# belongs to so the contract selector can be restored on re-render. # belongs to so the contract selector can be restored on re-render.
@@ -752,6 +1037,25 @@ def create():
reported_at = now_eastern(), reported_at = now_eastern(),
reported_by = current_user.id, reported_by = current_user.id,
) )
# "Handled By" — staff only; customer-created issues stay internal.
# (phase44) Previously the create form carried no handler fields at all,
# so a handler chosen here was silently discarded and had to be re-entered
# on the update form.
if current_user.role != 'customer':
handler = form.handler_type.data or 'internal'
if handler not in ('internal', 'facility', 'vendor'):
handler = 'internal'
issue.handler_type = handler
issue.facility_handler_name = (form.facility_handler_name.data or '').strip() or None
issue.facility_handler_contact = (form.facility_handler_contact.data or '').strip() or None
issue.facility_handler_notes = (form.facility_handler_notes.data or '').strip() or None
issue.vendor_name = (form.vendor_name.data or '').strip() or None
issue.vendor_contact = (form.vendor_contact.data or '').strip() or None
issue.vendor_notes = (form.vendor_notes.data or '').strip() or None
issue.internal_handler_name = (form.internal_handler_name.data or '').strip() or None
issue.internal_handler_contact = (form.internal_handler_contact.data or '').strip() or None
db.session.add(issue) db.session.add(issue)
db.session.commit() db.session.commit()
current_app.logger.info( current_app.logger.info(
@@ -801,10 +1105,11 @@ def create():
) )
db.session.commit() db.session.commit()
flash('Issue created.', 'success') flash('Issue created.', 'success')
return redirect(url_for('issues.index')) return redirect(return_url(url_for('issues.index')))
return render_template('issues/form.html', form=form, title='Log New Issue', return render_template('issues/form.html', form=form, title='Log New Issue',
projects=projects, selected_project_id=selected_project_id) projects=projects, selected_project_id=selected_project_id,
issue_handler_descriptions=Issue.HANDLER_DESCRIPTIONS)
# ── Supervisor verify resolved issue ───────────────────────────────────────── # ── Supervisor verify resolved issue ─────────────────────────────────────────
@@ -820,7 +1125,7 @@ def verify(issue_id):
if issue.status not in ('resolved', 'pending_verification'): if issue.status not in ('resolved', 'pending_verification'):
flash('Only resolved or pending-verification issues can be verified.', 'warning') flash('Only resolved or pending-verification issues can be verified.', 'warning')
return redirect(url_for('issues.view', issue_id=issue_id)) return redirect(_view_url(issue_id))
note = request.form.get('verification_note', '').strip() or None note = request.form.get('verification_note', '').strip() or None
@@ -840,7 +1145,21 @@ def verify(issue_id):
f'#{issue_id} in {issue.area.name if issue.area else issue.resolved_facility.name if issue.resolved_facility else ''}', f'#{issue_id} in {issue.area.name if issue.area else issue.resolved_facility.name if issue.resolved_facility else ''}',
f'verified_by={current_user.username}') f'verified_by={current_user.username}')
flash(f'Issue #{issue_id} verified and closed.', 'success') flash(f'Issue #{issue_id} verified and closed.', 'success')
return redirect(url_for('issues.view', issue_id=issue_id)) return redirect(_view_url(issue_id))
def _view_url(issue_id):
"""issues.view URL that carries the list `next` through.
An update posted from the detail page redirects back to that same detail
page; without re-attaching `next`, the Back button would lose the filters
the user arrived with and the next action from this page would too. Only
added when there is something to carry, so ordinary links stay clean.
"""
nxt = request.form.get('next') or request.args.get('next')
if nxt:
return url_for('issues.view', issue_id=issue_id, next=nxt)
return url_for('issues.view', issue_id=issue_id)
@bp.route('/bulk-verify', methods=['POST']) @bp.route('/bulk-verify', methods=['POST'])
@@ -875,7 +1194,205 @@ def bulk_verify():
f'bulk_verified_by={current_user.username}') f'bulk_verified_by={current_user.username}')
flash(f'{verified_count} issue{"s" if verified_count != 1 else ""} verified and closed.', 'success') flash(f'{verified_count} issue{"s" if verified_count != 1 else ""} verified and closed.', 'success')
return redirect(url_for('issues.verification_queue')) # Reachable from BOTH the verification queue and the issues list, so honour
# the caller's `next` and fall back to the queue as before.
return redirect(return_url(url_for('issues.verification_queue')))
# ── Bulk actions from the issues list ────────────────────────────────────────
#: Statuses a bulk status change may set, and what an issue must already be in
#: for the change to mean anything. Moving an issue to the state it is already
#: in is a no-op, so it counts as skipped rather than changed.
_BULK_STATUSES = ('open', 'in_progress', 'resolved', 'pending_verification')
@bp.route('/bulk', methods=['POST'])
@login_required
def bulk_action():
"""Apply one action to every ticked issue on the list page.
Partial-failure policy (matches bulk_verify): act on every eligible row,
skip the rest, and report exact counts never silently drop rows, and
never let one ineligible row block the batch.
Permission is checked per ACTION here rather than per row: all four actions
are manager-level, and the roles that hold them have org-wide issue access,
so there is no per-row scope question to answer. `skipped` therefore only
ever means "this row was not in a state the action applies to".
"""
back = return_url(url_for('issues.index'))
action = request.form.get('action', '')
ids = request.form.getlist('issue_ids', type=int)
if not ids:
flash('No issues selected.', 'warning')
return redirect(back)
manager = current_user.role in ('admin', 'director', 'auditor')
deleter = current_user.role in ('admin', 'director')
allowed = {
'assign': manager,
'status': manager,
'verify': manager,
'delete': deleter,
}
if action not in allowed:
flash('Unknown bulk action.', 'danger')
return redirect(back)
if not allowed[action]:
flash('You do not have permission for that bulk action.', 'danger')
return redirect(back)
issues = [i for i in (db.session.get(Issue, i_id) for i_id in ids) if i is not None]
missing = len(ids) - len(issues)
changed = 0
skipped = missing
# ── Assign ───────────────────────────────────────────────────────────
if action == 'assign':
raw = request.form.get('assigned_to', '')
user = None
if raw and raw != '0':
user = db.session.get(User, int(raw)) if raw.isdigit() else None
if user is None:
flash('That user no longer exists.', 'danger')
return redirect(back)
# Track what actually moved. Re-deriving this after the commit by
# testing `issue.assigned_to == user.id` would also match the issues
# that were ALREADY assigned to that person — they were counted as
# skipped, but would still be emailed "assigned to you" every time
# anyone ran a bulk assign over them.
newly_assigned = []
for issue in issues:
if issue.assigned_to == (user.id if user else None):
skipped += 1
continue
issue.assigned_to = user.id if user else None
newly_assigned.append(issue)
changed += 1
db.session.commit()
if user:
for issue in newly_assigned:
notify(
recipient = user,
title = f'Issue #{issue.id} assigned to you',
body = (f'{issue.severity.title()}-severity issue at '
f'{issue.resolved_facility.name if issue.resolved_facility else ""}: '
f'{issue.description[:120]}'),
link = url_for('issues.view', issue_id=issue.id),
issue_id = issue.id,
event_type = EVENT_ISSUE_ASSIGNED,
send_email = True,
)
db.session.commit() # notify() does not commit — rule 70
label = user.display_name if user else 'Unassigned'
log_action(ACTION_UPDATE, 'Issue', None, f'bulk assign → {label}',
f'ids={[i.id for i in issues]}; changed={changed}')
_flash_bulk(changed, skipped, f'assigned to {label}')
# ── Status ───────────────────────────────────────────────────────────
elif action == 'status':
new_status = request.form.get('status', '')
if new_status not in _BULK_STATUSES:
flash('Please choose a status to set.', 'warning')
return redirect(back)
# (issue, old_status) for the audit pass, which must run AFTER the
# commit — log_action() commits internally (rule 41), so calling it
# inside this loop would commit each row separately and lose the
# batch's atomicity.
moved = []
for issue in issues:
if issue.status == new_status:
skipped += 1
continue
old = issue.status
moved.append((issue, old))
issue.status = new_status
# Keep resolved_at consistent with the status, the same way the
# single-issue update does — a resolved issue with no resolved_at
# breaks the SLA compliance report and the aging buckets.
if new_status == 'resolved' and not issue.resolved_at:
issue.resolved_at = now_eastern()
elif new_status in ('open', 'in_progress'):
issue.resolved_at = None
changed += 1
db.session.commit()
for issue, old in moved:
log_action(ACTION_UPDATE, 'Issue', issue.id, f'#{issue.id}',
f'bulk status {old}{new_status} by {current_user.username}')
_flash_bulk(changed, skipped,
f'set to {new_status.replace("_", " ").title()}')
# ── Verify & close ───────────────────────────────────────────────────
elif action == 'verify':
verified = []
for issue in issues:
if issue.status not in ('resolved', 'pending_verification'):
skipped += 1
continue
issue.status = 'resolved'
issue.verified_by = current_user.id
issue.verified_at = now_eastern()
if not issue.resolved_at:
issue.resolved_at = now_eastern()
verified.append(issue)
changed += 1
db.session.commit()
for issue in verified: # after the commit — rule 41
log_action(ACTION_UPDATE, 'Issue', issue.id, f'#{issue.id}',
f'bulk_verified_by={current_user.username}')
_flash_bulk(changed, skipped, 'verified and closed',
skip_reason='not awaiting verification')
# ── Delete ───────────────────────────────────────────────────────────
elif action == 'delete':
from app.utils import storage
photo_paths = []
# Snapshot the ids BEFORE deleting — the objects are expired after the
# commit, and the audit pass has to run after it (rule 41: log_action
# commits internally, so auditing inside this loop would commit the
# deletes one at a time and, on a mid-loop failure, leave rows gone
# with the photo cleanup below never reached).
deleted_ids = []
for issue in issues:
if issue.photo_path:
photo_paths.append(issue.photo_path)
for lst in (issue.mobile_photo_paths, issue.result_photos):
if lst:
photo_paths.extend(lst)
deleted_ids.append(issue.id)
db.session.delete(issue)
changed += 1
db.session.commit()
for issue_id in deleted_ids:
log_action(ACTION_DELETE, 'Issue', issue_id, f'#{issue_id}',
f'bulk deleted by {current_user.username}')
# Files go only after the rows are safely gone — a failure here leaves
# an orphaned file, which is recoverable; the reverse is not.
for rel_path in photo_paths:
storage.delete(rel_path)
_flash_bulk(changed, skipped, 'permanently deleted')
logger.info('ISSUES | bulk | action=%s user=%s selected=%s changed=%s skipped=%s',
action, current_user.username, len(ids), changed, skipped)
return redirect(back)
def _flash_bulk(changed, skipped, verb, skip_reason='no change needed'):
"""One consistent result message for every bulk action."""
if not changed and not skipped:
flash('Nothing to do.', 'info')
return
parts = [f'{changed} issue{"s" if changed != 1 else ""} {verb}']
if skipped:
parts.append(f'{skipped} skipped ({skip_reason})')
flash('. '.join(parts) + '.', 'success' if changed else 'warning')
@bp.route('/<int:issue_id>/request-verification', methods=['POST']) @bp.route('/<int:issue_id>/request-verification', methods=['POST'])
@@ -897,11 +1414,11 @@ def request_verification(issue_id):
) )
if not can_act: if not can_act:
flash('Access denied.', 'danger') flash('Access denied.', 'danger')
return redirect(url_for('issues.view', issue_id=issue_id)) return redirect(_view_url(issue_id))
if issue.status not in ('in_progress',): if issue.status not in ('in_progress',):
flash('Issue must be in progress to request verification.', 'warning') flash('Issue must be in progress to request verification.', 'warning')
return redirect(url_for('issues.view', issue_id=issue_id)) return redirect(_view_url(issue_id))
issue.status = 'pending_verification' issue.status = 'pending_verification'
db.session.commit() db.session.commit()
@@ -929,7 +1446,7 @@ def request_verification(issue_id):
) )
db.session.commit() db.session.commit()
flash('Issue marked as pending verification. Supervisors have been notified.', 'info') flash('Issue marked as pending verification. Supervisors have been notified.', 'info')
return redirect(url_for('issues.view', issue_id=issue_id)) return redirect(_view_url(issue_id))
# ── Verification queue ──────────────────────────────────────────────────────── # ── Verification queue ────────────────────────────────────────────────────────
@@ -1022,7 +1539,7 @@ def delete(issue_id):
f'facility={facility_name}; description={issue_desc}') f'facility={facility_name}; description={issue_desc}')
flash(f'Issue #{issue_id_snap} has been permanently deleted.', 'success') flash(f'Issue #{issue_id_snap} has been permanently deleted.', 'success')
return redirect(url_for('issues.index')) return redirect(return_url(url_for('issues.index')))
# ── Quick-assign (AJAX) ─────────────────────────────────────────────────────── # ── Quick-assign (AJAX) ───────────────────────────────────────────────────────
@@ -1091,7 +1608,7 @@ def export_pdf(issue_id):
if issue is None: if issue is None:
abort(404) abort(404)
if current_user.role == 'inspector': if current_user.is_inspector:
fids = get_inspector_scope(current_user) fids = get_inspector_scope(current_user)
facility = issue.resolved_facility facility = issue.resolved_facility
if not fids or not facility or facility.id not in fids: if not fids or not facility or facility.id not in fids:
@@ -1114,6 +1631,20 @@ def export_pdf(issue_id):
# ── Vendor work order dispatch (phase36) ───────────────────────────────────── # ── Vendor work order dispatch (phase36) ─────────────────────────────────────
#
# NOT LINKED FROM THE UI (Aug 2026). The "Contractor Work Orders" card was
# removed from the issue detail page, so nothing posts here any more. The
# endpoint is kept deliberately rather than deleted:
#
# * it is the ONLY way to create a work order, so removing it would strand
# the public contractor pages (/work-orders/<token>), the model, the email
# template and the phase36 migration — a whole feature, not dead code;
# * tests/test_work_orders.py drives the end-to-end flow through it.
#
# To bring the feature back, restore the card in templates/issues/view.html —
# nothing here needs to change. To retire it for good, remove this route, the
# work_orders blueprint, its templates, the model and those tests together,
# and only once no tokenized links are still outstanding with contractors.
@bp.route('/<int:issue_id>/work-order', methods=['POST']) @bp.route('/<int:issue_id>/work-order', methods=['POST'])
@login_required @login_required
+112
View File
@@ -308,6 +308,118 @@ def check_score_trends():
return jsonify({'ok': True, 'alerts_sent': sent}) return jsonify({'ok': True, 'alerts_sent': sent})
# ── Photo retention purge (called by cron) ────────────────────────────────────
@bp.route('/purge-old-photos', methods=['POST'])
@csrf.exempt
def purge_old_photos():
"""Delete photo FILES (not the issue records) for issues resolved longer
ago than PHOTO_RETENTION_DAYS, addressing GDPR Art. 5(1)(e) storage
limitation evidence photos otherwise persist forever.
Disabled by default (no-op) unless PHOTO_RETENTION_DAYS is set in config/
env this is a data-minimization policy the operator opts into, not a
forced deletion, since some deployments may have a longer required
retention for their own contractual/audit reasons.
Only touches RESOLVED issues whose resolved_at predates the cutoff.
Clears photo_path / mobile_photo_paths / result_photos to null/empty and
deletes the underlying files via the storage abstraction (safe on both
the local and R2 backends, and tenant-prefix aware storage.delete()
resolves the same key the write used).
PER-TENANT, deliberately. Unlike /trial-reminders and /dunning-reminders
(which walk the control DB), this operates on `issues` in whichever tenant
DB the request resolves to, and the retention window is that tenant's own
policy. So it must be invoked once per tenant Host, exactly like
/check-sla and /check-score-trends. A cross-tenant variant would have to
read each tenant's own retention setting, which does not exist yet.
Recommended cron schedule nightly is sufficient, per tenant host:
0 4 * * * curl -s -X POST https://lts.jqc.app/notifications/purge-old-photos \\
-d "token=YOUR_DIGEST_SECRET"
"""
token = request.form.get('token') or request.args.get('token')
expected = current_app.config.get('DIGEST_SECRET')
if not expected or token != expected:
logger.warning('PHOTO PURGE REJECTED | bad or missing token')
abort(403)
retention_days = current_app.config.get('PHOTO_RETENTION_DAYS')
if not retention_days:
return jsonify({'ok': True, 'skipped': 'PHOTO_RETENTION_DAYS not configured',
'issues_purged': 0})
from datetime import timedelta
from app.models.issue import Issue
from app.utils.time_utils import now_eastern
from app.utils.audit import log_action, ACTION_UPDATE
from app.utils import storage
cutoff = now_eastern() - timedelta(days=int(retention_days))
candidates = (
Issue.query
.filter(Issue.status == 'resolved')
.filter(Issue.resolved_at.isnot(None))
.filter(Issue.resolved_at < cutoff)
.filter(
db.or_(
Issue.photo_path.isnot(None),
Issue.mobile_photo_paths.isnot(None),
Issue.result_photos.isnot(None),
)
)
.all()
)
purged_count = 0
for issue in candidates:
keys = []
if issue.photo_path:
keys.append(issue.photo_path)
keys.extend(issue.mobile_photo_paths or [])
keys.extend(issue.result_photos or [])
# Skip rows that hold no actual photo keys. The or_() above cannot do
# this on its own: db.JSON defaults to none_as_null=False, so a Python
# None assigned to mobile_photo_paths / result_photos is persisted as
# the JSON scalar `null` — which is NOT SQL NULL and therefore still
# satisfies isnot(None). Without this guard the endpoint would (a) count
# and rewrite every old resolved issue even when it has no photos, and
# (b) never become idempotent: clearing the fields writes JSON `null`
# again, so the next nightly run would re-select the very same rows
# forever, churning UPDATEs and logging a purge that did nothing.
if not keys:
continue
for key in keys:
try:
storage.delete(key)
except Exception as exc:
logger.warning('PHOTO PURGE | failed to delete key=%s issue_id=%s: %s',
key, issue.id, exc)
issue.photo_path = None
issue.mobile_photo_paths = None
issue.result_photos = None
purged_count += 1
db.session.commit()
if purged_count:
log_action(
ACTION_UPDATE, 'Issue', None,
f'Photo retention purge — {purged_count} resolved issue(s)',
f'cutoff={cutoff.strftime("%Y-%m-%d %H:%M:%S")}; retention_days={retention_days}',
)
logger.info('PHOTO PURGE TRIGGERED | issues_purged=%s | retention_days=%s',
purged_count, retention_days)
return jsonify({'ok': True, 'issues_purged': purged_count,
'retention_days': retention_days})
# ── Trial-ending reminder (called by cron) ──────────────────────────────────── # ── Trial-ending reminder (called by cron) ────────────────────────────────────
@bp.route('/trial-reminders', methods=['POST']) @bp.route('/trial-reminders', methods=['POST'])
+43 -31
View File
@@ -57,7 +57,8 @@ def index():
# Inspectors get a scoped view of their own inspections and related issues. # Inspectors get a scoped view of their own inspections and related issues.
# Customers get a facility-scoped view. # Customers get a facility-scoped view.
# Internal management roles (director+) get the full unscoped view. # Internal management roles (director+) get the full unscoped view.
if current_user.role not in ['admin', 'director', 'project_manager', 'inspector', 'customer']: if current_user.role not in ['admin', 'director', 'project_manager', 'inspector',
'external_inspector', 'customer']:
from flask import flash, redirect, url_for from flask import flash, redirect, url_for
flash('Access denied.', 'danger') flash('Access denied.', 'danger')
return redirect(url_for('dashboard.index')) return redirect(url_for('dashboard.index'))
@@ -66,30 +67,36 @@ def index():
# Resolve scoping for customers (facility list) and inspectors (inspector_id) # Resolve scoping for customers (facility list) and inspectors (inspector_id)
customer_facility_ids = get_customer_scope(current_user) # None = unrestricted customer_facility_ids = get_customer_scope(current_user) # None = unrestricted
is_inspector = current_user.role == 'inspector' is_inspector = current_user.is_inspector
# Inspector filter — admin / director / project_manager only # Inspector filter — admin / director / project_manager only
inspector_filter = None inspector_filter = None
if current_user.role in ('admin', 'director', 'project_manager'): if current_user.role in ('admin', 'director', 'project_manager'):
inspector_filter = request.args.get('inspector_id', type=int) or None inspector_filter = request.args.get('inspector_id', type=int) or None
# Pre-compute inspection ID sets used by _scope_issue to avoid join conflicts. # Scope issues by the relevant inspector's inspections, as a SUBQUERY rather
inspector_inspection_ids = [] # own inspections (inspector role) # than a materialised id list. The previous form pulled every inspection id
filter_inspection_ids = None # filtered inspector's inspections (admin/dir/PM) # that inspector had ever performed into Python and sent them straight back
# as a literal IN (1, 2, 3, ... N): the round trip is wasted, the statement
# grows without bound with the inspector's history, and a long enough list
# eventually trips max_allowed_packet. A subquery is also still a single
# statement, so the "avoid join conflicts" reason for pre-computing holds.
#
# IN (empty subquery) already matches nothing, so the explicit empty-list
# guards the old code needed are gone rather than merely moved.
inspector_insp_subq = None
if is_inspector: if is_inspector:
inspector_inspection_ids = [ inspector_insp_subq = (
row[0] for row in
db.session.query(Inspection.id) db.session.query(Inspection.id)
.filter(Inspection.inspector_id == current_user.id) .filter(Inspection.inspector_id == current_user.id)
.all() .scalar_subquery()
] )
elif inspector_filter: elif inspector_filter:
filter_inspection_ids = [ inspector_insp_subq = (
row[0] for row in
db.session.query(Inspection.id) db.session.query(Inspection.id)
.filter(Inspection.inspector_id == inspector_filter) .filter(Inspection.inspector_id == inspector_filter)
.all() .scalar_subquery()
] )
def _scope_insp(q): def _scope_insp(q):
if is_inspector: if is_inspector:
@@ -103,14 +110,8 @@ def index():
return q return q
def _scope_issue(q): def _scope_issue(q):
if is_inspector: if inspector_insp_subq is not None:
if not inspector_inspection_ids: return q.filter(Issue.inspection_id.in_(inspector_insp_subq))
return q.filter(False)
return q.filter(Issue.inspection_id.in_(inspector_inspection_ids))
if filter_inspection_ids is not None:
if not filter_inspection_ids:
return q.filter(False)
return q.filter(Issue.inspection_id.in_(filter_inspection_ids))
if customer_facility_ids is not None: if customer_facility_ids is not None:
if not customer_facility_ids: if not customer_facility_ids:
return q.filter(False) return q.filter(False)
@@ -262,7 +263,8 @@ def index():
inspectors = [] inspectors = []
if current_user.role in ('admin', 'director', 'project_manager'): if current_user.role in ('admin', 'director', 'project_manager'):
inspectors = User.query.filter_by(role='inspector', active=True)\ inspectors = User.query.filter(User.role.in_(User.INSPECTOR_ROLES),
User.active == True)\
.order_by(User.full_name, User.username).all() .order_by(User.full_name, User.username).all()
facility_scores_list = [{ facility_scores_list = [{
@@ -307,7 +309,8 @@ def index():
@bp.route('/facility/<int:facility_id>') @bp.route('/facility/<int:facility_id>')
@login_required @login_required
def facility_report(facility_id): def facility_report(facility_id):
if current_user.role not in ['admin', 'director', 'project_manager', 'inspector', 'customer']: if current_user.role not in ['admin', 'director', 'project_manager', 'inspector',
'external_inspector', 'customer']:
from flask import flash, redirect, url_for from flask import flash, redirect, url_for
flash('Access denied.', 'danger') flash('Access denied.', 'danger')
return redirect(url_for('dashboard.index')) return redirect(url_for('dashboard.index'))
@@ -320,7 +323,7 @@ def facility_report(facility_id):
from flask import flash, redirect, url_for from flask import flash, redirect, url_for
flash('Access denied.', 'danger') flash('Access denied.', 'danger')
return redirect(url_for('reports.index')) return redirect(url_for('reports.index'))
if current_user.role == 'inspector': if current_user.is_inspector:
# Inspectors may only view the facility report for facilities where # Inspectors may only view the facility report for facilities where
# they have personally conducted at least one inspection. # they have personally conducted at least one inspection.
has_access = Inspection.query.filter_by( has_access = Inspection.query.filter_by(
@@ -369,7 +372,8 @@ def facility_report(facility_id):
def facility_scorecard(facility_id): def facility_scorecard(facility_id):
"""Comprehensive per-facility scorecard: score trend, SLA compliance, """Comprehensive per-facility scorecard: score trend, SLA compliance,
issue breakdown by severity, inspection frequency.""" issue breakdown by severity, inspection frequency."""
if current_user.role not in ['admin', 'director', 'project_manager', 'inspector', 'customer']: if current_user.role not in ['admin', 'director', 'project_manager', 'inspector',
'external_inspector', 'customer']:
from flask import flash, redirect, url_for from flask import flash, redirect, url_for
flash('Access denied.', 'danger') flash('Access denied.', 'danger')
return redirect(url_for('dashboard.index')) return redirect(url_for('dashboard.index'))
@@ -385,7 +389,7 @@ def facility_scorecard(facility_id):
flash('Access denied.', 'danger') flash('Access denied.', 'danger')
return redirect(url_for('reports.index')) return redirect(url_for('reports.index'))
if current_user.role == 'inspector': if current_user.is_inspector:
has_access = Inspection.query.filter_by( has_access = Inspection.query.filter_by(
facility_id=facility_id, facility_id=facility_id,
inspector_id=current_user.id, inspector_id=current_user.id,
@@ -701,7 +705,8 @@ def _build_inspector_stats(start, end):
all_ids = set(total_map.keys()) all_ids = set(total_map.keys())
active_inspectors = ( active_inspectors = (
User.query User.query
.filter(User.id.in_(all_ids), User.active == True, User.role == 'inspector') .filter(User.id.in_(all_ids), User.active == True,
User.role.in_(User.INSPECTOR_ROLES))
.order_by(User.full_name, User.username) .order_by(User.full_name, User.username)
.all() .all()
) if all_ids else [] ) if all_ids else []
@@ -717,6 +722,10 @@ def _build_inspector_stats(start, end):
inspector_stats.append({ inspector_stats.append({
'id': u.id, 'id': u.id,
'display_name': u.display_name, 'display_name': u.display_name,
# MT-15 — customer / third-party inspectors appear in the same
# table as the tenant's own crew, badged so the numbers can be read
# in context. Consumed by the HTML table and the export.
'external': u.is_external_inspector,
'total': tot, 'total': tot,
'completed': comp, 'completed': comp,
'completion_rate': round(comp / tot * 100) if tot else 0, 'completion_rate': round(comp / tot * 100) if tot else 0,
@@ -759,7 +768,7 @@ def inspector_performance():
if selected_id: if selected_id:
selected_inspector = db.session.get(User, selected_id) selected_inspector = db.session.get(User, selected_id)
if selected_inspector and selected_inspector.role == 'inspector': if selected_inspector and selected_inspector.is_inspector:
selected_kpis = next((s for s in inspector_stats if s['id'] == selected_id), None) selected_kpis = next((s for s in inspector_stats if s['id'] == selected_id), None)
trend_rows = db.session.query( trend_rows = db.session.query(
@@ -857,7 +866,7 @@ def export_inspector_performance():
.filter( .filter(
Inspection.inspection_date >= start, Inspection.inspection_date >= start,
Inspection.inspection_date <= end, Inspection.inspection_date <= end,
User.role == 'inspector', User.role.in_(User.INSPECTOR_ROLES),
) )
if selected_id: if selected_id:
detail_q = detail_q.filter(Inspection.inspector_id == selected_id) detail_q = detail_q.filter(Inspection.inspector_id == selected_id)
@@ -931,7 +940,10 @@ def export_inspector_performance():
for row_idx, s in enumerate(inspector_stats, start=3): for row_idx, s in enumerate(inspector_stats, start=3):
stripe = sub_fill if row_idx % 2 == 0 else None stripe = sub_fill if row_idx % 2 == 0 else None
row_data = [ row_data = [
s['display_name'], # Customer-employed inspectors share this table with our own crew;
# suffixed rather than given a column so the index-based styling
# below (score = col 5, vs_avg = col 6, …) stays correct.
s['display_name'] + (' (Customer)' if s.get('external') else ''),
s['total'], s['total'],
s['completed'], s['completed'],
s['completion_rate'], s['completion_rate'],
@@ -1628,7 +1640,7 @@ def facility_summary_pdf(facility_id):
cids = get_customer_scope(current_user) or [] cids = get_customer_scope(current_user) or []
if facility_id not in cids: if facility_id not in cids:
abort(403) abort(403)
elif current_user.role == 'inspector': elif current_user.is_inspector:
has = Inspection.query.filter_by(facility_id=facility_id, has = Inspection.query.filter_by(facility_id=facility_id,
inspector_id=current_user.id).first() inspector_id=current_user.id).first()
if not has: if not has:
+265 -29
View File
@@ -12,7 +12,7 @@ from app.models.support import (SupportTicket, SupportTicketReply,
from app.models.user import User from app.models.user import User
from app.models.facility import Facility from app.models.facility import Facility
from app.utils.decorators import supervisor_required from app.utils.decorators import supervisor_required
from app.utils.scope import get_customer_scope from app.utils.scope import get_customer_scope, get_inspector_scope
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE
from app.utils.time_utils import now_eastern from app.utils.time_utils import now_eastern
from app.utils.notifications import notify from app.utils.notifications import notify
@@ -20,6 +20,38 @@ from app.utils.notifications import notify
bp = Blueprint('support', __name__, url_prefix='/support') bp = Blueprint('support', __name__, url_prefix='/support')
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# ── Outbound PII redaction ────────────────────────────────────────────────────
# Groq is a THIRD PARTY. Customers routinely paste contact details (their own,
# or a coworker's) into a support question, and none of that needs to leave the
# app to get a helpful, generic answer. This scrubs a best-effort set of PII
# patterns from the copy of the text sent to Groq ONLY — the original is still
# stored verbatim in support_chat_messages, so the customer's own conversation
# history reads normally in the app and staff see what was actually said.
#
# Best-effort by design: over-redacting a support question costs nothing, while
# under-redacting leaks a real address. Order matters — the 1319 digit card
# pattern runs before the phone pattern so a card number is not partly consumed
# as a phone number first.
import re as _re
_PII_PATTERNS = [
(_re.compile(r'[\w.+-]+@[\w-]+\.[\w.-]+'), '[redacted-email]'),
(_re.compile(r'\b\d{3}-\d{2}-\d{4}\b'), '[redacted-ssn]'),
(_re.compile(r'\b(?:\d[ -]?){13,19}\b'), '[redacted-number]'),
(_re.compile(r'\b(?:\+?1[ .-]?)?\(?\d{3}\)?[ .-]?\d{3}[ .-]?\d{4}\b'), '[redacted-phone]'),
]
def _redact_pii(text):
"""Best-effort scrub of email/phone/SSN/card-like sequences from outbound text."""
if not text:
return text
redacted = text
for pattern, placeholder in _PII_PATTERNS:
redacted = pattern.sub(placeholder, redacted)
return redacted
# ── Groq system prompt ──────────────────────────────────────────────────────── # ── Groq system prompt ────────────────────────────────────────────────────────
_SYSTEM_PROMPT = """\ _SYSTEM_PROMPT = """\
@@ -37,6 +69,12 @@ Help customers with:
Rules: Rules:
- Keep answers concise (3-5 sentences max) and friendly. - Keep answers concise (3-5 sentences max) and friendly.
- Ground answers in everything above, INCLUDING the "ADDITIONAL KNOWLEDGE" section when \
one is present that section is curated by the provider's team and is authoritative. \
If it answers the question, use it.
- When the knowledge above contains a link (URL), email address or exact wording, quote \
it EXACTLY as written. Repeating something given to you here is not inventing do it \
freely. Never alter a URL, shorten it, or replace it with a description.
- Never invent specific staff names, contract prices, schedules, or contact numbers. - Never invent specific staff names, contract prices, schedules, or contact numbers.
- If the customer has an access problem, billing question, or a concern you genuinely \ - If the customer has an access problem, billing question, or a concern you genuinely \
cannot resolve through guidance, say so clearly and suggest they click \ cannot resolve through guidance, say so clearly and suggest they click \
@@ -53,21 +91,161 @@ FAQS = [
{'icon': 'bi-megaphone', 'text': 'How do I report a cleaning concern?'}, {'icon': 'bi-megaphone', 'text': 'How do I report a cleaning concern?'},
{'icon': 'bi-alarm', 'text': 'What is SLA and how does it work?'}, {'icon': 'bi-alarm', 'text': 'What is SLA and how does it work?'},
{'icon': 'bi-bell', 'text': 'How do I get notified on issue updates?'}, {'icon': 'bi-bell', 'text': 'How do I get notified on issue updates?'},
{'icon': 'bi-phone', 'text': 'Can our own staff use the JQC app to conduct inspections?'},
]
#: Extra chips shown to a Customer Inspector, whose questions are about doing
#: the work rather than reading the results. Appended to FAQS, not replacing
#: them — they still care about scores and issues.
INSPECTOR_FAQS = [
{'icon': 'bi-clipboard-plus', 'text': 'How do I start an inspection on the iPad?'},
{'icon': 'bi-wifi-off', 'text': 'What happens if I lose signal during an inspection?'},
{'icon': 'bi-flag', 'text': 'How do I flag an issue while inspecting?'},
{'icon': 'bi-search', 'text': "Why can't I see a form for this facility?"},
] ]
#: Groq model used when GROQ_MODEL is unset. Verified available Aug 2026.
#: Groq RETIRES models without notice, and when the configured one disappears
#: every question fails with the generic "problem reaching the AI assistant"
#: reply — invisible until a customer complains. That is exactly how
#: llama-3.3-70b-versatile took the chat down. See the error handler in
#: chat_message(): it names the model and says to set GROQ_MODEL, which fixes
#: it with an env change and a restart — no deploy.
_DEFAULT_GROQ_MODEL = 'openai/gpt-oss-120b'
def _is_customer_side(user):
"""True for both customer-side roles — Director and Customer Inspector.
The AI assistant and the ticket flow are for the CUSTOMER organisation, and
a Customer Inspector is part of it: they work at the customer's facilities
and have the same questions about scores, issues and the app. This is one
of the few places where User.CUSTOMER_ROLES is the right test; every
capability/scoping decision below still branches per role (see
_support_facilities and _system_prompt_for) the two roles get the same
DOOR, not the same answers.
"""
return getattr(user, 'is_customer_account', False)
def _support_facilities(user):
"""The facilities this user may pick on a support ticket.
Directors are scoped by CustomerAssignment, Customer Inspectors by
InspectorAssignment reusing the customer helper for both would silently
return nothing for an inspector (it returns None for any non-'customer'
role) and the facility dropdown would come up empty.
"""
if getattr(user, 'is_inspector', False):
fids = get_inspector_scope(user) or []
else:
fids = get_customer_scope(user) or []
if not fids:
return []
return (Facility.query
.filter(Facility.id.in_(fids), Facility.active == True)
.order_by(Facility.name).all())
#: Appended to the system prompt for a Customer Inspector. The base prompt is
#: written for the read-mostly portal customer and explicitly tells the model
#: NOT to describe staff actions; without this the assistant would deny a
#: Customer Inspector the very things they are employed to do.
_INSPECTOR_ADDENDUM = """
=== ABOUT THE PERSON YOU ARE TALKING TO: CUSTOMER INSPECTOR ===
This user works FOR the customer but holds an inspecting role in JQC, limited to
the contracts they have been assigned. This section OVERRIDES the "only describe
what a customer can do" restriction above, for this user only.
Everything above about the portal still applies to their assigned facilities. IN
ADDITION, they can:
- Conduct inspections themselves start one on the web (Inspections -> New
Inspection) or in the JQC iPad app, fill in the checklist form, add photos, and
submit it.
- Use the iPad app OFFLINE: inspections and photos are stored on the device and
sync automatically when back online.
- Flag an issue during an inspection, and log new issues at their facilities.
- Assign an issue to an inspector working on the SAME contract (their own
colleagues, or the provider's inspectors) — never to anyone outside it.
- Update an issue's status, add comments, and set "Handled By"
(Janitorial Staff / Facility Staff / External Vendor) from the iPad.
- Work from Scheduled Inspections assigned to them.
They CANNOT: verify or close out issues (the provider's admin/director does that),
manage users, create or edit inspection forms, change the notification matrix, or
see anything outside their assigned contracts. If they ask for one of those, say
who to ask instead their own Customer Director, or the provider's team via
"Submit to Support".
Note on forms: the inspection forms they can choose from are the shared standard
forms plus any built specifically for their contract. A form built for a different
customer will never appear.
"""
#: The curated knowledge is spliced in immediately BEFORE this heading, not
#: appended after it. The rules under it say "ground answers in everything
#: above", so knowledge appended after them was, by the prompt's own
#: instruction, out of scope — which is exactly why admin KB entries appeared
#: to be ignored. Keep this marker in sync with the heading in _SYSTEM_PROMPT.
_STYLE_MARKER = 'Rules:'
def _system_prompt_for(user):
"""Base prompt + curated knowledge, plus the addendum for this user's role.
Kept separate from _system_prompt_with_kb() so the curated knowledge base
still lands at the same marker regardless of role.
"""
prompt = _system_prompt_with_kb()
if getattr(user, 'is_external_inspector', False):
prompt += _INSPECTOR_ADDENDUM
return prompt
def _system_prompt_with_kb(): def _system_prompt_with_kb():
"""Return the Groq system prompt, appending active knowledge base entries.""" """Return the Groq system prompt with active knowledge entries spliced in.
Best-effort a knowledge-base failure never breaks the chat.
"""
try: try:
entries = SupportKnowledge.query.filter_by(active=True).order_by(SupportKnowledge.id).all() entries = (SupportKnowledge.query.filter_by(active=True)
except Exception: .order_by(SupportKnowledge.sort_order.asc(),
return _SYSTEM_PROMPT SupportKnowledge.id.asc()).all())
if not entries: if not entries:
logger.info('SUPPORT | KB | no active entries — base prompt only')
return _SYSTEM_PROMPT
parts = ['=== ADDITIONAL KNOWLEDGE (curated by the provider team; authoritative '
'— prefer it over general guesses, and quote any link in it exactly) ===']
total = 0
used = 0
for e in entries:
block = f'\n\nTopic: {e.title}\n{(e.body or "").strip()}'
if total + len(block) > _KB_MAX_CHARS:
logger.warning('SUPPORT | KB | %d of %d entries dropped — %d char cap '
'reached', len(entries) - used, len(entries), _KB_MAX_CHARS)
break
parts.append(block)
total += len(block)
used += 1
kb_block = ''.join(parts)
idx = _SYSTEM_PROMPT.find(_STYLE_MARKER)
if idx == -1: # marker renamed — fall back to append
logger.warning('SUPPORT | KB | style marker not found; appending at end')
prompt = f'{_SYSTEM_PROMPT}\n\n{kb_block}'
else:
prompt = f'{_SYSTEM_PROMPT[:idx]}{kb_block}\n\n{_SYSTEM_PROMPT[idx:]}'
logger.info('SUPPORT | KB | %d/%d entries injected (%d chars), prompt=%d chars',
used, len(entries), total, len(prompt))
return prompt
except Exception as exc:
logger.warning('SUPPORT | knowledge-base load failed: %s', exc)
return _SYSTEM_PROMPT return _SYSTEM_PROMPT
kb_text = '\n\n'.join(f'[{e.title}]\n{e.body}' for e in entries)
if len(kb_text) > _KB_MAX_CHARS:
kb_text = kb_text[:_KB_MAX_CHARS] + '\n…(truncated)'
return _SYSTEM_PROMPT + '\n\n# Additional Context\n' + kb_text
# ── Customer chat page ──────────────────────────────────────────────────────── # ── Customer chat page ────────────────────────────────────────────────────────
@@ -75,13 +253,10 @@ def _system_prompt_with_kb():
@bp.route('/chat') @bp.route('/chat')
@login_required @login_required
def chat(): def chat():
if current_user.role != 'customer': if not _is_customer_side(current_user):
return redirect(url_for('support.admin_tickets')) return redirect(url_for('support.admin_tickets'))
cids = get_customer_scope(current_user) or [] facilities = _support_facilities(current_user)
facilities = (Facility.query
.filter(Facility.id.in_(cids), Facility.active == True)
.order_by(Facility.name).all()) if cids else []
groq_ready = bool(os.environ.get('GROQ_API_KEY')) groq_ready = bool(os.environ.get('GROQ_API_KEY'))
session_id = request.args.get('session_id', type=int) session_id = request.args.get('session_id', type=int)
@@ -96,8 +271,9 @@ def chat():
if chat_session: if chat_session:
db_history = list(chat_session.messages) db_history = list(chat_session.messages)
faqs = (FAQS + INSPECTOR_FAQS) if current_user.is_external_inspector else FAQS
return render_template('support/chat.html', return render_template('support/chat.html',
faqs=FAQS, faqs=faqs,
facilities=facilities, facilities=facilities,
groq_ready=groq_ready, groq_ready=groq_ready,
chat_session=chat_session, chat_session=chat_session,
@@ -109,7 +285,7 @@ def chat():
@bp.route('/chat/message', methods=['POST']) @bp.route('/chat/message', methods=['POST'])
@login_required @login_required
def chat_message(): def chat_message():
if current_user.role != 'customer': if not _is_customer_side(current_user):
return jsonify({'error': 'Forbidden'}), 403 return jsonify({'error': 'Forbidden'}), 403
api_key = os.environ.get('GROQ_API_KEY') api_key = os.environ.get('GROQ_API_KEY')
@@ -158,12 +334,14 @@ def chat_message():
from groq import Groq from groq import Groq
client = Groq(api_key=api_key) client = Groq(api_key=api_key)
messages = [{'role': 'system', 'content': _system_prompt_with_kb()}] messages = [{'role': 'system', 'content': _system_prompt_for(current_user)}]
# Redact before the text leaves the app for Groq. The unredacted
# originals are persisted below, so nothing is lost in-app.
for m in prior[-20:]: for m in prior[-20:]:
messages.append({'role': m.role, 'content': m.content}) messages.append({'role': m.role, 'content': _redact_pii(m.content)})
messages.append({'role': 'user', 'content': user_message}) messages.append({'role': 'user', 'content': _redact_pii(user_message)})
model = os.environ.get('GROQ_MODEL', 'llama-3.3-70b-versatile') model = os.environ.get('GROQ_MODEL', _DEFAULT_GROQ_MODEL)
completion = client.chat.completions.create( completion = client.chat.completions.create(
model=model, model=model,
messages=messages, messages=messages,
@@ -184,7 +362,18 @@ def chat_message():
return jsonify({'reply': reply, 'session_id': chat_session.id}) return jsonify({'reply': reply, 'session_id': chat_session.id})
except Exception as exc: except Exception as exc:
logger.error('SUPPORT | Groq error: %s', exc) # Always name the model — a bare "Groq error" gives whoever reads the
# log nothing to act on, and a retired model is the most likely cause
# of a total outage here.
_model = locals().get('model') or os.environ.get('GROQ_MODEL', _DEFAULT_GROQ_MODEL)
if 'model_not_found' in str(exc) or 'does not exist' in str(exc):
logger.error(
'SUPPORT | Groq model %r is not available on this account — '
'the assistant is DOWN for every user. Set GROQ_MODEL to a '
'current model (see https://console.groq.com/docs/models). '
'Underlying error: %s', _model, exc)
else:
logger.error('SUPPORT | Groq error (model=%r): %s', _model, exc)
db.session.rollback() db.session.rollback()
return jsonify({'reply': ( return jsonify({'reply': (
"I ran into a problem reaching the AI assistant. " "I ran into a problem reaching the AI assistant. "
@@ -197,7 +386,7 @@ def chat_message():
@bp.route('/my-conversations') @bp.route('/my-conversations')
@login_required @login_required
def my_conversations(): def my_conversations():
if current_user.role != 'customer': if not _is_customer_side(current_user):
abort(403) abort(403)
sessions = (SupportChatSession.query sessions = (SupportChatSession.query
.filter_by(customer_id=current_user.id) .filter_by(customer_id=current_user.id)
@@ -209,7 +398,7 @@ def my_conversations():
@bp.route('/my-conversations/<int:session_id>') @bp.route('/my-conversations/<int:session_id>')
@login_required @login_required
def my_conversation_detail(session_id): def my_conversation_detail(session_id):
if current_user.role != 'customer': if not _is_customer_side(current_user):
abort(403) abort(403)
chat_session = db.session.get(SupportChatSession, session_id) chat_session = db.session.get(SupportChatSession, session_id)
if chat_session is None or chat_session.customer_id != current_user.id: if chat_session is None or chat_session.customer_id != current_user.id:
@@ -225,7 +414,7 @@ def my_conversation_detail(session_id):
@bp.route('/tickets', methods=['POST']) @bp.route('/tickets', methods=['POST'])
@login_required @login_required
def submit_ticket(): def submit_ticket():
if current_user.role != 'customer': if not _is_customer_side(current_user):
abort(403) abort(403)
subject = request.form.get('subject', '').strip() subject = request.form.get('subject', '').strip()
@@ -236,8 +425,9 @@ def submit_ticket():
flash('Please fill in both subject and description.', 'warning') flash('Please fill in both subject and description.', 'warning')
return redirect(url_for('support.chat')) return redirect(url_for('support.chat'))
# Validate facility belongs to this customer # Validate the facility belongs to this user — by whichever assignment
cids = get_customer_scope(current_user) or [] # table their role is scoped through.
cids = [f.id for f in _support_facilities(current_user)]
if facility_id and facility_id not in cids: if facility_id and facility_id not in cids:
facility_id = None facility_id = None
@@ -267,7 +457,7 @@ def submit_ticket():
@bp.route('/my-tickets') @bp.route('/my-tickets')
@login_required @login_required
def my_tickets(): def my_tickets():
if current_user.role != 'customer': if not _is_customer_side(current_user):
abort(403) abort(403)
tickets = (SupportTicket.query tickets = (SupportTicket.query
@@ -282,7 +472,7 @@ def my_tickets():
@bp.route('/my-tickets/<int:ticket_id>', methods=['GET', 'POST']) @bp.route('/my-tickets/<int:ticket_id>', methods=['GET', 'POST'])
@login_required @login_required
def my_ticket_detail(ticket_id): def my_ticket_detail(ticket_id):
if current_user.role != 'customer': if not _is_customer_side(current_user):
abort(403) abort(403)
ticket = db.session.get(SupportTicket, ticket_id) ticket = db.session.get(SupportTicket, ticket_id)
@@ -482,10 +672,53 @@ def admin_conversation_detail(session_id):
@login_required @login_required
@supervisor_required @supervisor_required
def admin_knowledge(): def admin_knowledge():
entries = SupportKnowledge.query.order_by(SupportKnowledge.created_at.desc()).all() # Same order the chat prompt uses, so the admin list shows the real
# priority rather than a different one.
entries = (SupportKnowledge.query
.order_by(SupportKnowledge.sort_order.asc(),
SupportKnowledge.id.asc()).all())
return render_template('support/admin_knowledge.html', entries=entries) return render_template('support/admin_knowledge.html', entries=entries)
@bp.route('/admin/knowledge/preview')
@login_required
@supervisor_required
def admin_knowledge_preview():
"""Show the exact system prompt the chatbot receives, knowledge included.
Added after admin entries appeared to be ignored: without this there is no
way to tell "my entry never reached the prompt" from "the model saw it and
chose not to use it". Read-only, builds nothing of its own — it calls the
same _system_prompt_with_kb() the chat endpoint calls.
"""
prompt = _system_prompt_with_kb()
active_count = SupportKnowledge.query.filter_by(active=True).count()
total_count = SupportKnowledge.query.count()
return render_template('support/admin_knowledge_preview.html',
prompt=prompt,
active_count=active_count,
total_count=total_count,
kb_included='=== ADDITIONAL KNOWLEDGE' in prompt,
kb_cap=_KB_MAX_CHARS)
def _parse_sort_order(raw, fallback=0):
"""Coerce a submitted sort_order to a sane int.
The column is NOT NULL, so a blank or non-numeric field must not reach the
DB. Clamped to 0..9999 to match the range ST validates, and falls back to
the existing value on edit so a blank field means "leave it alone" rather
than silently resetting the entry to the top.
"""
raw = (raw or '').strip()
if not raw:
return fallback
try:
return max(0, min(9999, int(raw)))
except (TypeError, ValueError):
return fallback
@bp.route('/admin/knowledge/add', methods=['POST']) @bp.route('/admin/knowledge/add', methods=['POST'])
@login_required @login_required
@supervisor_required @supervisor_required
@@ -500,6 +733,7 @@ def admin_knowledge_add():
title = title, title = title,
body = body, body = body,
active = True, active = True,
sort_order = _parse_sort_order(request.form.get('sort_order')),
created_by = current_user.id, created_by = current_user.id,
created_at = now_eastern(), created_at = now_eastern(),
updated_at = now_eastern(), updated_at = now_eastern(),
@@ -527,6 +761,8 @@ def admin_knowledge_edit(entry_id):
return redirect(url_for('support.admin_knowledge_edit', entry_id=entry_id)) return redirect(url_for('support.admin_knowledge_edit', entry_id=entry_id))
entry.title = title entry.title = title
entry.body = body entry.body = body
entry.sort_order = _parse_sort_order(request.form.get('sort_order'),
entry.sort_order)
entry.updated_at = now_eastern() entry.updated_at = now_eastern()
db.session.commit() db.session.commit()
log_action(ACTION_UPDATE, 'SupportKnowledge', entry.id, title[:60], 'edited') log_action(ACTION_UPDATE, 'SupportKnowledge', entry.id, title[:60], 'edited')
+64 -11
View File
@@ -2,7 +2,8 @@ import logging
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify, abort from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify, abort
from flask_login import login_required, current_user from flask_login import login_required, current_user
from app import db from app import db
from app.models.inspection import InspectionTemplate, ChecklistItem from app.models.inspection import InspectionTemplate, ChecklistItem, TemplateContract
from app.models.project import Project
from app.utils.forms import InspectionTemplateForm, ChecklistItemForm from app.utils.forms import InspectionTemplateForm, ChecklistItemForm
from app.utils.decorators import supervisor_required from app.utils.decorators import supervisor_required
import json import json
@@ -13,6 +14,17 @@ bp = Blueprint('templates', __name__, url_prefix='/templates')
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _populate_contract_choices(form):
"""Contract options for the "Available on contracts" multi-select.
Selecting none leaves the form SHARED (usable on every contract) that is
the default and what every template did before phase52. See
TemplateContract.
"""
contracts = Project.query.filter_by(active=True).order_by(Project.name).all()
form.contract_ids.choices = [(p.id, p.name) for p in contracts]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Template CRUD # Template CRUD
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -21,7 +33,12 @@ logger = logging.getLogger(__name__)
@login_required @login_required
def index(): def index():
templates = InspectionTemplate.query.order_by(InspectionTemplate.name).all() templates = InspectionTemplate.query.order_by(InspectionTemplate.name).all()
return render_template('templates/list.html', templates=templates) # Contract options for the Edit Template modal's "Available on contracts"
# picker (phase52) — this modal is the edit UI reached from the list, so it
# needs the same control the full editor has.
contracts = Project.query.filter_by(active=True).order_by(Project.name).all()
return render_template('templates/list.html',
templates=templates, contracts=contracts)
@bp.route('/new', methods=['GET', 'POST']) @bp.route('/new', methods=['GET', 'POST'])
@@ -29,6 +46,7 @@ def index():
@supervisor_required @supervisor_required
def create_template(): def create_template():
form = InspectionTemplateForm() form = InspectionTemplateForm()
_populate_contract_choices(form)
if form.validate_on_submit(): if form.validate_on_submit():
template = InspectionTemplate( template = InspectionTemplate(
@@ -38,11 +56,15 @@ def create_template():
created_by=current_user.id created_by=current_user.id
) )
db.session.add(template) db.session.add(template)
db.session.flush() # need template.id before linking contracts
template.set_contracts(form.contract_ids.data)
db.session.commit() db.session.commit()
logger.info('TEMPLATES | create | user=%s | template_id=%s name=%r', logger.info('TEMPLATES | create | user=%s | template_id=%s name=%r contracts=%s',
current_user.username, template.id, template.name) current_user.username, template.id, template.name,
template.contract_ids or 'shared')
log_action(ACTION_CREATE, 'Template', template.id, template.name, log_action(ACTION_CREATE, 'Template', template.id, template.name,
f'frequency={template.frequency}') f'frequency={template.frequency}; '
f'contracts={template.contract_ids or "shared"}')
flash(f'Template "{template.name}" created successfully.', 'success') flash(f'Template "{template.name}" created successfully.', 'success')
return redirect(url_for('templates.form_editor', template_id=template.id)) return redirect(url_for('templates.form_editor', template_id=template.id))
@@ -72,16 +94,23 @@ def edit_template(template_id):
if template is None: if template is None:
abort(404) abort(404)
form = InspectionTemplateForm(obj=template) form = InspectionTemplateForm(obj=template)
_populate_contract_choices(form)
if request.method == 'GET':
# obj= cannot read the association rows; seed the multi-select from them.
form.contract_ids.data = template.contract_ids
if form.validate_on_submit(): if form.validate_on_submit():
template.name = form.name.data template.name = form.name.data
template.description = form.description.data template.description = form.description.data
template.frequency = form.frequency.data template.frequency = form.frequency.data
template.set_contracts(form.contract_ids.data)
db.session.commit() db.session.commit()
logger.info('TEMPLATES | edit | user=%s | template_id=%s name=%r', logger.info('TEMPLATES | edit | user=%s | template_id=%s name=%r contracts=%s',
current_user.username, template.id, template.name) current_user.username, template.id, template.name,
template.contract_ids or 'shared')
log_action(ACTION_UPDATE, 'Template', template.id, template.name, log_action(ACTION_UPDATE, 'Template', template.id, template.name,
f'frequency={template.frequency}') f'frequency={template.frequency}; '
f'contracts={template.contract_ids or "shared"}')
flash(f'Template "{template.name}" updated successfully.', 'success') flash(f'Template "{template.name}" updated successfully.', 'success')
return redirect(url_for('templates.view_template', template_id=template.id)) return redirect(url_for('templates.view_template', template_id=template.id))
@@ -120,11 +149,29 @@ def rename_template(template_id):
template.description = request.form.get('description', '').strip() or None template.description = request.form.get('description', '').strip() or None
template.frequency = new_frequency template.frequency = new_frequency
# phase52 — contract restrictions are edited from this modal too, since it
# is the Edit Template dialog people actually reach from the list. The
# hidden marker distinguishes "the form posted an empty selection" (make
# the template shared) from "the form has no contracts field at all", which
# must leave the existing restrictions untouched rather than silently
# sharing the template with every customer.
if request.form.get('contracts_present') == '1':
valid_pids = {
p.id for p in Project.query.filter_by(active=True).all()
}
posted = {
pid for pid in request.form.getlist('contract_ids', type=int)
if pid in valid_pids
}
template.set_contracts(posted)
db.session.commit() db.session.commit()
logger.info('TEMPLATES | rename | user=%s | template_id=%s name=%r', logger.info('TEMPLATES | rename | user=%s | template_id=%s name=%r contracts=%s',
current_user.username, template.id, template.name) current_user.username, template.id, template.name,
template.contract_ids or 'shared')
log_action(ACTION_UPDATE, 'Template', template.id, template.name, log_action(ACTION_UPDATE, 'Template', template.id, template.name,
f'frequency={template.frequency}; via=rename') f'frequency={template.frequency}; '
f'contracts={template.contract_ids or "shared"}; via=rename')
flash(f'Template "{template.name}" updated successfully.', 'success') flash(f'Template "{template.name}" updated successfully.', 'success')
return redirect(url_for('templates.index')) return redirect(url_for('templates.index'))
@@ -188,6 +235,12 @@ def duplicate_template(template_id):
db.session.add(new_tpl) db.session.add(new_tpl)
db.session.flush() # get new_tpl.id before committing db.session.flush() # get new_tpl.id before committing
# phase52 — carry the contract restrictions across. Duplicating a
# customer's bespoke form must not produce a copy that is silently shared
# with every other customer; copying a shared form still yields a shared
# one (no links to copy).
new_tpl.set_contracts(src.contract_ids)
# Duplicate all checklist items # Duplicate all checklist items
for item in src.checklist_items.order_by(ChecklistItem.display_order).all(): for item in src.checklist_items.order_by(ChecklistItem.display_order).all():
new_item = ChecklistItem( new_item = ChecklistItem(
+45 -25
View File
@@ -29,6 +29,7 @@ from app.models.tenant_settings import TenantSettings
from app.utils.decorators import admin_required from app.utils.decorators import admin_required
from app.utils.audit import log_action, ACTION_UPDATE, ACTION_CREATE, ACTION_DELETE from app.utils.audit import log_action, ACTION_UPDATE, ACTION_CREATE, ACTION_DELETE
from app.utils.time_utils import now_eastern from app.utils.time_utils import now_eastern
from app.utils import storage
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -43,7 +44,19 @@ _DOMAIN_RE = re.compile(
# ── helpers ─────────────────────────────────────────────────────────────────── # ── helpers ───────────────────────────────────────────────────────────────────
def _save_logo(file_obj): def _save_logo(file_obj):
"""Save uploaded logo to static/uploads/logos/; return relative URL or None.""" """Save an uploaded logo via the storage seam; return its key or None.
MT-22: this previously wrote straight to ``UPLOAD_FOLDER/logos/`` with
``file_obj.save()`` the last direct-to-disk writer in the app. On an
R2-backed tenant the logo never reached the bucket, and on the local
backend every tenant's logo landed in one shared directory.
Validation stays here by design: ``storage.py`` only moves bytes, and
callers own extension / magic-byte checks (see its module docstring).
The returned key keeps the exact ``uploads/logos/<file>`` shape already
stored in ``TenantSettings.logo_url``, so no migration and no data rewrite.
"""
if not file_obj or not file_obj.filename: if not file_obj or not file_obj.filename:
return None return None
allowed = {'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp'} allowed = {'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp'}
@@ -63,27 +76,22 @@ def _save_logo(file_obj):
} }
if not any(header.startswith(m) for m in magic): if not any(header.startswith(m) for m in magic):
return None return None
import secrets file_obj.seek(0)
logos_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], 'logos') return storage.save(file_obj, 'logos')
os.makedirs(logos_dir, exist_ok=True)
filename = f'{secrets.token_hex(12)}.{ext}'
file_obj.save(os.path.join(logos_dir, filename))
return f'uploads/logos/{filename}'
def _delete_logo(logo_url): def _delete_logo(logo_url):
"""Remove a logo file from disk. Silently ignores missing files. """Remove a stored logo. Silently ignores missing objects.
Safety guard: only deletes files inside the uploads/logos/ subfolder.""" Safety guard: only deletes keys inside the uploads/logos/ subfolder."""
if not logo_url:
return # nothing stored — never issue a delete for 'uploads/logos/'
try: try:
# Use only the basename to avoid any path-traversal via the stored URL. # Use only the basename to avoid any path-traversal via the stored URL.
# All logos are written into logos_dir by _save_logo(), so joining the # All logos are written to 'uploads/logos/' by _save_logo(), so
# basename back to that directory is always the correct path. # rebuilding the key from the basename is always the correct target.
logos_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], 'logos') key = f'uploads/logos/{os.path.basename(str(logo_url or ""))}'
abs_logos = os.path.abspath(logos_dir) storage.delete(key)
abs_path = os.path.join(abs_logos, os.path.basename(logo_url)) logger.info('SETTINGS | logo_deleted | key=%s', key)
if os.path.isfile(abs_path):
os.remove(abs_path)
logger.info('SETTINGS | logo_deleted | path=%s', abs_path)
except Exception as exc: except Exception as exc:
logger.warning('SETTINGS | logo_delete_failed | url=%s err=%s', logo_url, exc) logger.warning('SETTINGS | logo_delete_failed | url=%s err=%s', logo_url, exc)
@@ -211,20 +219,32 @@ def plan():
'allow_custom_domain': tenant.allow_custom_domain, 'allow_custom_domain': tenant.allow_custom_domain,
} }
# Live counts (tenant DB) # Live counts (tenant DB), one axis at a time.
#
# These used to be four calls inside a single dict literal in one
# try/except. Python evaluates every value before assigning, so ONE
# failing counter discarded the whole dict and the page rendered 0
# for all four axes — including users and facilities, which were
# fine. That is exactly how a wrong column name in the issues
# counter presented as "no usage number ever updates".
from app.tenancy.quota import ( from app.tenancy.quota import (
count_active_users, count_active_facilities, count_active_users, count_active_facilities,
count_inspections_this_month, count_issues_this_month, count_inspections_this_month, count_issues_this_month,
) )
for axis, counter in (
('users', count_active_users),
('facilities', count_active_facilities),
('inspections', count_inspections_this_month),
('issues', count_issues_this_month),
):
try: try:
quota_usage = { quota_usage[axis] = counter()
'users': count_active_users(),
'facilities': count_active_facilities(),
'inspections': count_inspections_this_month(),
'issues': count_issues_this_month(),
}
except Exception as exc: except Exception as exc:
logger.error('tenant_settings.plan: quota count failed: %s', exc) # None (not 0) so the page shows "—": an unknown count and
# a genuine zero must not look the same.
quota_usage[axis] = None
logger.error('tenant_settings.plan: %s count failed: %s',
axis, exc)
# MT-8: billing fields are already on g.tenant — no extra DB query needed. # MT-8: billing fields are already on g.tenant — no extra DB query needed.
billing_enabled = current_app.config.get('BILLING_ENABLED', False) billing_enabled = current_app.config.get('BILLING_ENABLED', False)
+140
View File
@@ -0,0 +1,140 @@
"""
app/routes/ui.py
----------------
Web portal design switch and the pages the modern sidebar links to (MT-16).
Routes
POST /ui/theme switch_theme() flip users.ui_theme classic modern
GET /ui/about about() About Us page
GET /ui/support-center support_center() support hub of how-to cards
GET /ui/theme-votes theme_votes() admin tally of design choices
Nothing here changes existing behaviour: the theme flag only selects which
layout shell base.html extends. Every page template is untouched.
Multi-tenant note
-----------------
`users` is a per-tenant table, so every query in this module is automatically
scoped to the caller's tenant by the routing session — the vote tally shows one
tenant's users, never the estate. There is deliberately no cross-tenant rollup
here; that belongs in the control plane if it is ever wanted.
"""
import logging
from flask import (Blueprint, render_template, redirect, request,
url_for, flash, current_app)
from flask_login import login_required, current_user
from sqlalchemy import func
from app import db
from app.models.user import User, ROLE_LABELS
from app.utils.audit import log_action, ACTION_UPDATE
bp = Blueprint('ui', __name__, url_prefix='/ui')
logger = logging.getLogger(__name__)
VALID_THEMES = ('classic', 'modern')
def _default_theme():
"""Fallback design for accounts that have never chosen one."""
default = (current_app.config.get('DEFAULT_UI_THEME') or 'classic').lower()
return default if default in VALID_THEMES else 'classic'
def _safe_next(target):
"""Only allow same-site relative redirects (open-redirect guard).
A protocol-relative URL ('//evil.com') is a valid redirect target to the
browser but points off-site, so the leading-slash test alone is not enough.
"""
if not target:
return url_for('dashboard.index')
if target.startswith('/') and not target.startswith('//'):
return target
return url_for('dashboard.index')
# ── Design switch ────────────────────────────────────────────────────────────
@bp.route('/theme', methods=['POST'])
@login_required
def switch_theme():
"""Persist the user's design choice, then return them to the same page."""
theme = (request.form.get('theme') or '').strip().lower()
if theme not in VALID_THEMES:
flash('Unknown design option.', 'warning')
return redirect(_safe_next(request.form.get('next')))
previous = current_user.ui_theme or _default_theme()
if previous != theme:
current_user.ui_theme = theme
db.session.commit()
# log_action() commits internally — always AFTER the business commit.
log_action(
action = ACTION_UPDATE,
entity_type = 'User',
entity_id = current_user.id,
entity_label = current_user.username,
details = f'ui_theme={previous}{theme}',
)
logger.info('UI | theme switch | user=%s | %s -> %s',
current_user.username, previous, theme)
flash('Now showing the {} design. You can switch back any time from '
'the account menu.'.format('new' if theme == 'modern' else 'classic'),
'info')
return redirect(_safe_next(request.form.get('next')))
# ── New pages (linked from the modern sidebar) ───────────────────────────────
@bp.route('/about')
@login_required
def about():
return render_template('ui/about.html')
@bp.route('/support-center')
@login_required
def support_center():
return render_template('ui/support_center.html')
# ── Admin: which design are people actually keeping? ─────────────────────────
@bp.route('/theme-votes')
@login_required
def theme_votes():
if current_user.role != 'admin':
flash('You do not have permission to view the design vote tally.', 'danger')
return redirect(url_for('dashboard.index'))
default = _default_theme()
rows = (db.session.query(User.ui_theme, func.count(User.id))
.filter(User.active == True) # noqa: E712 — SQL boolean
.group_by(User.ui_theme)
.all())
tally = {t: 0 for t in VALID_THEMES}
for theme, count in rows:
key = theme if theme in VALID_THEMES else default
tally[key] = tally.get(key, 0) + count
total = sum(tally.values())
by_role = (db.session.query(User.role, User.ui_theme, func.count(User.id))
.filter(User.active == True) # noqa: E712
.group_by(User.role, User.ui_theme)
.order_by(User.role)
.all())
return render_template('ui/theme_votes.html',
tally=tally, total=total, by_role=by_role,
default_theme=default,
# by_role yields raw role strings from a group_by,
# so hand the template the same label source the
# User.role_label property uses (MT-15).
role_labels=ROLE_LABELS)
+20 -4
View File
@@ -13,15 +13,31 @@
/* Minimum 44px touch targets on interactive elements */ /* Minimum 44px touch targets on interactive elements */
.btn, .btn,
.nav-link, .nav-link,
.dropdown-item, .dropdown-item {
input[type="checkbox"],
input[type="radio"],
.form-check-input {
min-height: 44px; min-height: 44px;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
} }
/* Checkboxes and radios are deliberately NOT in the rule above.
`display: inline-flex` on a native <input type="radio"> replaces its
intrinsic box with a flex container: the control keeps painting its glyph
at its natural ~16px size while the element claims a 44px-tall box, so the
visible dot and the actual hit area stop coinciding. Taps land next to the
control and nothing happens which is exactly how the per-account
notification matrix (Inherit / On / Off) came to look unclickable.
Grow the target without touching `display`: keep the native box, scale the
glyph up, and give it margin so neighbouring options stay separable. Any
control that needs a genuinely large tap area should wrap the input in a
<label> that fills the cell (see customers/manage.html). */
input[type="checkbox"],
input[type="radio"],
.form-check-input {
transform: scale(1.35);
margin: 6px;
}
/* Slightly larger form controls for finger input */ /* Slightly larger form controls for finger input */
.form-control, .form-control,
.form-select { .form-select {
+482
View File
@@ -0,0 +1,482 @@
/*
Janitorial QC MODERN design skin (design A/B test "modern")
Loaded ONLY by templates/layouts/modern.html, and always AFTER
theme.css, so every token below overrides the classic one.
The classic design is completely untouched by this file.
Palette sampled from the JQC_design deck:
brand #155F82 deep teal-blue top bar / table headers
brand-700 #0F4A66 hover / pressed
brand-050 #DCEBF5 soft icon tiles, active rail rows
page #EAEEF1 page background
surface #FFFFFF cards
ink #1D2A32 headings
muted #6B7A85 secondary text
*/
/* ── 1. Tokens ───────────────────────────────────────────────────────────── */
body.jqc-modern {
--jqc-brand: #155F82;
--jqc-brand-700: #0F4A66;
--jqc-brand-600: #1B6E93;
--jqc-brand-050: #DCEBF5;
--jqc-brand-025: #E9F0F8;
--jqc-page: #EAEEF1;
--jqc-ink: #1D2A32;
--jqc-heading: #1D2A32;
--jqc-muted: #6B7A85;
--jqc-faint: #93A1AB;
--jqc-border: #E3E8EC;
--jqc-border-2: #CFD9E0;
--jqc-surface: #F5F8FA; /* subtle fill — hovers, muted rows (theme.css) */
--jqc-surface-2: #EEF3F6;
--jqc-card-bg: #FFFFFF; /* raised surfaces — cards, KPI tiles, sidebar */
--jqc-accent: #155F82;
--jqc-accent-700: #0F4A66;
--jqc-accent-50: #DCEBF5;
--jqc-shadow: 0 1px 2px rgba(21, 46, 62, .05), 0 6px 18px rgba(21, 46, 62, .06);
--jqc-shadow-md: 0 10px 30px rgba(21, 46, 62, .12);
--bs-primary: #155F82;
--bs-primary-rgb: 21, 95, 130;
--bs-link-color: #155F82;
--bs-link-color-rgb: 21, 95, 130;
--bs-link-hover-color: #0F4A66;
--bs-link-hover-color-rgb: 15, 74, 102;
--bs-body-bg: #EAEEF1;
--bs-body-color: #1D2A32;
--bs-border-color: #E3E8EC;
--bs-border-radius: .6rem;
--bs-border-radius-sm: .45rem;
--bs-border-radius-lg: 1rem;
--bs-border-radius-xl: 1.15rem;
--jqc-topbar-h: 72px;
--jqc-sidebar-w: 232px;
background-color: var(--jqc-page);
color: var(--jqc-ink);
font-family: 'DM Sans', system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
/* ── 2. Top bar ──────────────────────────────────────────────────────────── */
.jqc-modern .jqc-topbar {
position: fixed;
top: 0; left: 0; right: 0;
height: var(--jqc-topbar-h);
z-index: 1035;
background: var(--jqc-brand);
display: flex;
align-items: center;
gap: 14px;
padding: 0 20px;
padding-top: env(safe-area-inset-top);
box-shadow: 0 1px 0 rgba(0, 0, 0, .10);
}
.jqc-modern .jqc-brand {
text-decoration: none;
color: #fff;
line-height: 1;
flex: 0 0 auto;
}
.jqc-modern .jqc-brand-mark {
display: block;
font-size: 1.75rem;
font-weight: 800;
letter-spacing: -.02em;
}
.jqc-modern .jqc-brand-sub {
display: block;
font-size: .68rem;
opacity: .82;
margin-top: 3px;
}
/* MT: a tenant with an uploaded logo renders it in place of the "JQC" wordmark.
Capped in height so a tall logo cannot stretch the top bar. */
.jqc-modern .jqc-brand-logo {
display: block;
max-height: 30px;
max-width: 150px;
object-fit: contain;
}
.jqc-modern .jqc-search {
position: relative;
margin-left: auto;
width: min(420px, 42vw);
}
.jqc-modern .jqc-search i {
position: absolute;
left: 16px; top: 50%;
transform: translateY(-50%);
color: var(--jqc-muted);
pointer-events: none;
}
.jqc-modern .jqc-search .form-control {
border: none;
border-radius: 999px;
height: 42px;
padding-left: 44px;
background: #fff;
font-size: .92rem;
}
.jqc-modern .jqc-search .form-control:focus {
box-shadow: 0 0 0 .2rem rgba(255, 255, 255, .35);
}
.jqc-modern .jqc-topbar-actions {
display: flex;
align-items: center;
gap: 12px;
flex: 0 0 auto;
}
.jqc-modern .jqc-icon-btn {
color: #fff;
font-size: 1.2rem;
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px; height: 40px;
border-radius: 50%;
transition: background-color .15s;
}
.jqc-modern .jqc-icon-btn:hover { background: rgba(255, 255, 255, .14); color: #fff; }
.jqc-modern .jqc-avatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px; height: 44px;
border-radius: 50%;
background: var(--jqc-brand-700);
border: 2px solid rgba(255, 255, 255, .85);
color: #fff;
font-weight: 700;
font-size: .9rem;
letter-spacing: .02em;
text-decoration: none;
}
.jqc-modern .jqc-avatar:hover { background: #0b3b53; color: #fff; }
.jqc-modern .jqc-hamburger {
background: transparent;
border: none;
color: #fff;
font-size: 1.5rem;
line-height: 1;
padding: 4px 6px;
}
/* ── 3. Sidebar ──────────────────────────────────────────────────────────── */
.jqc-modern .jqc-sidebar {
position: fixed;
top: var(--jqc-topbar-h);
bottom: 0;
left: 0;
width: var(--jqc-sidebar-w);
background: var(--jqc-card-bg);
border-right: 1px solid var(--jqc-border);
overflow-y: auto;
z-index: 1030;
display: flex;
flex-direction: column;
padding-top: 10px;
}
.jqc-modern .jqc-nav { flex: 1 1 auto; }
.jqc-modern .jqc-nav-link {
position: relative;
display: flex;
align-items: center;
gap: 14px;
padding: 13px 18px 13px 22px;
color: #43525C;
text-decoration: none;
font-size: .95rem;
font-weight: 500;
transition: background-color .15s, color .15s;
}
.jqc-modern .jqc-nav-link i { font-size: 1.15rem; width: 22px; text-align: center; }
.jqc-modern .jqc-nav-link span { flex: 1 1 auto; }
.jqc-modern .jqc-nav-link:hover { background: var(--jqc-brand-025); color: var(--jqc-brand); }
.jqc-modern .jqc-nav-link.active {
background: var(--jqc-brand-025);
color: var(--jqc-brand);
font-weight: 700;
}
.jqc-modern .jqc-nav-link.active::before {
content: '';
position: absolute;
left: 0; top: 0; bottom: 0;
width: 5px;
background: var(--jqc-brand);
}
.jqc-modern .jqc-nav-caret { font-size: .7rem !important; width: auto !important; opacity: .6; }
.jqc-modern .jqc-nav-badge {
background: #D9534F;
color: #fff;
border-radius: 999px;
font-size: .68rem;
font-weight: 700;
padding: 1px 7px;
line-height: 1.5;
}
.jqc-modern .jqc-nav-sublink {
display: block;
padding: 9px 18px 9px 58px;
font-size: .88rem;
color: #5A6A75;
text-decoration: none;
}
.jqc-modern .jqc-nav-sublink:hover { background: var(--jqc-brand-025); color: var(--jqc-brand); }
.jqc-modern .jqc-nav-sublink.active { color: var(--jqc-brand); font-weight: 700; }
/* .jqc-sidebar-foot / .jqc-switch-btn were dropped in phase50 along with the
sidebar design switcher no template references them any more. */
.jqc-modern .jqc-sidebar-backdrop {
position: fixed;
inset: 0;
background: rgba(15, 34, 46, .45);
z-index: 1029;
display: none;
}
.jqc-modern .jqc-sidebar-backdrop.show { display: block; }
/* ── 4. Main region ──────────────────────────────────────────────────────── */
.jqc-modern .jqc-main {
margin-left: var(--jqc-sidebar-w);
padding: calc(var(--jqc-topbar-h) + 22px) 10px 40px;
min-height: 100vh;
}
.jqc-modern .jqc-main > .container-fluid { padding-inline: 14px; }
@media (max-width: 991.98px) {
.jqc-modern .jqc-sidebar {
transform: translateX(-100%);
transition: transform .2s ease;
box-shadow: 0 0 24px rgba(15, 34, 46, .18);
}
.jqc-modern .jqc-sidebar.open { transform: translateX(0); }
.jqc-modern .jqc-main { margin-left: 0; }
.jqc-modern .jqc-search { width: auto; flex: 1 1 auto; }
.jqc-modern .jqc-brand-sub { display: none; }
}
/* ── 5. Page heading block (used by the rebuilt modern pages) ────────────── */
.jqc-modern .jqc-page-head { margin-bottom: 20px; }
.jqc-modern .jqc-page-head h1,
.jqc-modern .jqc-page-title {
font-size: 2rem;
font-weight: 800;
letter-spacing: -.02em;
color: var(--jqc-ink);
margin: 0;
}
/* Opt-in centring the default is left-aligned. `.center` on the head block
centres the title and its sub-line together. */
.jqc-modern .jqc-page-head.center,
.jqc-modern .jqc-page-title.center { text-align: center; }
.jqc-modern .jqc-page-sub {
color: var(--jqc-muted);
font-size: .95rem;
margin-top: 4px;
}
/* ── 6. Cards / surfaces (applies to every page, rebuilt or not) ─────────── */
.jqc-modern .card {
border: 1px solid var(--jqc-border);
border-radius: 16px;
box-shadow: var(--jqc-shadow);
}
.jqc-modern .card-header {
background: var(--jqc-card-bg);
border-bottom: 1px solid var(--jqc-border);
color: var(--jqc-ink);
font-weight: 700;
padding: .9rem 1.15rem;
}
.jqc-modern .card-header.bg-light,
.jqc-modern .card-header.bg-white { background: var(--jqc-card-bg) !important; }
.jqc-modern .card-body { padding: 1.15rem; }
.jqc-modern .jqc-card {
background: var(--jqc-card-bg);
border: 1px solid var(--jqc-border);
border-radius: 16px;
box-shadow: var(--jqc-shadow);
padding: 20px 22px;
margin-bottom: 22px;
}
.jqc-modern .jqc-card-title {
display: flex;
align-items: center;
gap: 12px;
font-size: 1.15rem;
font-weight: 800;
color: var(--jqc-ink);
margin-bottom: 16px;
}
/* Soft square icon tile — the deck's signature element */
.jqc-modern .jqc-tile-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 42px; height: 42px;
border-radius: 11px;
background: var(--jqc-brand-050);
color: var(--jqc-brand);
font-size: 1.15rem;
flex: 0 0 auto;
}
.jqc-modern .jqc-tile-icon.lg { width: 74px; height: 74px; border-radius: 18px; font-size: 2rem; }
/* KPI tiles */
.jqc-modern .jqc-kpi {
background: var(--jqc-card-bg);
border: 1px solid var(--jqc-border);
border-radius: 16px;
box-shadow: var(--jqc-shadow);
padding: 18px 20px;
height: 100%;
display: block;
text-decoration: none;
color: inherit;
transition: box-shadow .15s, transform .15s;
}
a.jqc-kpi:hover { box-shadow: var(--jqc-shadow-md); transform: translateY(-1px); color: inherit; }
.jqc-modern .jqc-kpi-value {
font-size: 2.1rem;
font-weight: 800;
line-height: 1.05;
color: var(--jqc-ink);
margin-top: 10px;
}
.jqc-modern .jqc-kpi-label { font-size: .85rem; color: var(--jqc-muted); margin-top: 2px; }
/* Label / value rows inside summary cards */
.jqc-modern .jqc-stat-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 11px 2px;
border-bottom: 1px solid var(--jqc-border);
text-decoration: none;
color: var(--jqc-ink);
}
.jqc-modern .jqc-stat-row:last-child { border-bottom: none; }
.jqc-modern .jqc-stat-row:hover { color: var(--jqc-brand); }
.jqc-modern .jqc-stat-label { font-size: .95rem; display: flex; align-items: center; gap: 9px; }
.jqc-modern .jqc-stat-value { font-size: 1.05rem; font-weight: 800; white-space: nowrap; }
.jqc-modern .jqc-dot {
width: 9px; height: 9px; border-radius: 50%;
display: inline-block; flex: 0 0 auto;
}
/* Hub cards (Facility / Support pages) */
.jqc-modern .jqc-hub-card {
display: flex;
flex-direction: column;
height: 100%;
background: var(--jqc-card-bg);
border: 1px solid var(--jqc-border);
border-radius: 16px;
box-shadow: var(--jqc-shadow);
padding: 24px 26px;
text-decoration: none;
color: inherit;
transition: box-shadow .15s, transform .15s;
}
.jqc-modern .jqc-hub-card:hover { box-shadow: var(--jqc-shadow-md); transform: translateY(-2px); color: inherit; }
.jqc-modern .jqc-hub-title { font-size: 1.3rem; font-weight: 800; color: var(--jqc-ink); }
.jqc-modern .jqc-hub-text { color: var(--jqc-muted); font-size: .93rem; margin-top: 6px; }
.jqc-modern .jqc-hub-open { color: var(--jqc-brand); font-weight: 700; font-size: .9rem; margin-top: auto; padding-top: 18px; }
.jqc-modern .jqc-hub-card.dark { background: var(--jqc-brand); border-color: var(--jqc-brand); }
.jqc-modern .jqc-hub-card.dark .jqc-hub-title,
.jqc-modern .jqc-hub-card.dark .jqc-hub-text { color: #fff; }
.jqc-modern .jqc-hub-card.dark .jqc-tile-icon { background: #fff; }
/* The default .jqc-hub-open is brand-coloured, which is invisible on the dark
(brand-filled) card it needs its own colour. */
.jqc-modern .jqc-hub-card.dark .jqc-hub-open { color: #fff; }
/* ── 7. Tables — dark teal header, as in the deck ────────────────────────── */
.jqc-modern .table { --bs-table-border-color: var(--jqc-border); margin-bottom: 0; }
/* Recoloured via Bootstrap's own table CSS variables rather than !important, so
a page that deliberately wants a different header (table-dark, a tinted
report header) can still override it with a normal rule. */
.jqc-modern .table > thead > tr > th,
.jqc-modern .table thead.table-light > tr > th,
.jqc-modern .table > thead th {
--bs-table-bg: var(--jqc-brand);
--bs-table-color: #fff;
background-color: var(--jqc-brand);
color: #fff;
border-color: var(--jqc-brand-700);
font-weight: 600;
font-size: .88rem;
vertical-align: middle;
}
/* nowrap only where the column set is known-narrow (dashboard panels); wide
tables such as the issues list and audit trail must be free to wrap rather
than force a horizontal scroll on iPad portrait. */
.jqc-modern .jqc-card .table > thead th { white-space: nowrap; }
.jqc-modern .table > tbody > tr > td { vertical-align: middle; font-size: .92rem; }
.jqc-modern .table-hover > tbody > tr:hover > * { background-color: var(--jqc-brand-025); }
.jqc-modern .jqc-table-wrap {
border: 1px solid var(--jqc-border);
border-radius: 12px;
overflow: hidden;
}
/* ── 8. Buttons, badges, forms ───────────────────────────────────────────── */
.jqc-modern .btn { border-radius: .6rem; font-weight: 600; }
.jqc-modern .btn-primary {
--bs-btn-bg: var(--jqc-brand); --bs-btn-border-color: var(--jqc-brand);
--bs-btn-hover-bg: var(--jqc-brand-700); --bs-btn-hover-border-color: var(--jqc-brand-700);
--bs-btn-active-bg: var(--jqc-brand-700); --bs-btn-active-border-color: var(--jqc-brand-700);
--bs-btn-disabled-bg: var(--jqc-brand); --bs-btn-disabled-border-color: var(--jqc-brand);
}
.jqc-modern .btn-outline-primary {
--bs-btn-color: var(--jqc-brand); --bs-btn-border-color: var(--jqc-brand);
--bs-btn-hover-bg: var(--jqc-brand); --bs-btn-hover-border-color: var(--jqc-brand);
--bs-btn-active-bg: var(--jqc-brand); --bs-btn-active-border-color: var(--jqc-brand);
}
.jqc-modern .bg-primary { background-color: var(--jqc-brand) !important; }
.jqc-modern .text-primary { color: var(--jqc-brand) !important; }
.jqc-modern .badge { border-radius: 999px; font-weight: 700; padding: .35em .7em; }
.jqc-modern .form-control,
.jqc-modern .form-select {
border-radius: .6rem;
border-color: var(--jqc-border-2);
}
.jqc-modern .form-control:focus,
.jqc-modern .form-select:focus {
border-color: var(--jqc-brand);
box-shadow: 0 0 0 .18rem rgba(21, 95, 130, .18);
}
/* Filter bar — the rounded pill row from the deck */
.jqc-modern .jqc-filter-bar {
background: var(--jqc-card-bg);
border: 1px solid var(--jqc-border);
border-radius: 16px;
box-shadow: var(--jqc-shadow);
padding: 14px 16px;
margin-bottom: 20px;
}
.jqc-modern .jqc-filter-bar .form-control,
.jqc-modern .jqc-filter-bar .form-select { border-radius: 999px; padding-inline: 16px; }
/* ── 9. Alerts / misc ────────────────────────────────────────────────────── */
.jqc-modern .alert { border-radius: 12px; border: 1px solid var(--jqc-border); }
.jqc-modern .dropdown-menu { border-radius: 12px; border-color: var(--jqc-border); box-shadow: var(--jqc-shadow-md); }
.jqc-modern .nav-tabs .nav-link.active { color: var(--jqc-brand); }
.jqc-modern .progress-bar.bg-success { background-color: #2E7D4F !important; }
/* Print: drop the chrome entirely */
@media print {
.jqc-modern .jqc-topbar,
.jqc-modern .jqc-sidebar,
.jqc-modern .jqc-sidebar-backdrop { display: none !important; }
.jqc-modern .jqc-main { margin-left: 0; padding-top: 0; }
}
+55
View File
@@ -81,6 +81,27 @@
</div> </div>
</div> </div>
<!-- ── My Data & Privacy (phase51) ───────────────────────────── -->
<div class="card shadow-sm mt-4">
<div class="card-header bg-light">
<h6 class="mb-0 fw-semibold"><i class="bi bi-shield-lock me-1"></i>My Data &amp; Privacy</h6>
</div>
<div class="card-body">
<p class="text-muted small mb-3">
Download a copy of the data tied to your account, or request its
erasure.
</p>
<a href="{{ url_for('auth.export_my_data') }}"
class="btn btn-outline-secondary btn-sm w-100 mb-2">
<i class="bi bi-download me-1"></i>Export My Data
</a>
<button type="button" class="btn btn-outline-danger btn-sm w-100"
data-bs-toggle="modal" data-bs-target="#deleteMyDataModal">
<i class="bi bi-trash me-1"></i>Delete My Account &amp; Data
</button>
</div>
</div>
</div> </div>
<!-- ── Right column: edit form + recent inspections ──────────────── --> <!-- ── Right column: edit form + recent inspections ──────────────── -->
@@ -263,4 +284,38 @@
</div> </div>
</div> </div>
<!-- ── Delete My Data Modal (phase51) ──────────────────────────────────── -->
<div class="modal fade" id="deleteMyDataModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header bg-danger text-white">
<h5 class="modal-title"><i class="bi bi-exclamation-triangle me-2"></i>Delete My Account &amp; Data</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<form method="POST" action="{{ url_for('auth.request_my_data_deletion') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="modal-body">
<div class="alert alert-warning mb-3">
<i class="bi bi-exclamation-triangle-fill me-1"></i>
This action is <strong>permanent</strong> and logs you out immediately.
</div>
<p class="mb-0">
If you have no inspection or issue history tied to your account, it will be
<strong>permanently deleted</strong>. If you do have history (common for staff
accounts), your name, username, and email will be replaced with a
non-identifying placeholder and the account deactivated — historical records
stay intact for audit continuity but will no longer identify you.
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-danger">
<i class="bi bi-trash me-1"></i>Confirm Deletion
</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %} {% endblock %}
+6 -1
View File
@@ -80,7 +80,11 @@
</div> </div>
</div> </div>
<div class="row"> {# phase51 — the email-invitation branch that used to live here
moved to Customer Management along with the Customer
Inspector role. Every role this form still offers is our own
staff, created with an admin-set password. #}
<div class="row" id="passwordFields">
<div class="col-md-6 mb-3"> <div class="col-md-6 mb-3">
{{ form.password.label(class="form-label") }} {{ form.password.label(class="form-label") }}
{{ form.password(class="form-control", placeholder="Leave blank to keep current" if user else "") }} {{ form.password(class="form-control", placeholder="Leave blank to keep current" if user else "") }}
@@ -128,4 +132,5 @@
</div> </div>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
+15 -4
View File
@@ -37,12 +37,12 @@
<td>{{ user.full_name or '—' }}</td> <td>{{ user.full_name or '—' }}</td>
<td>{{ user.email }}</td> <td>{{ user.email }}</td>
<td> <td>
<span class="badge bg-{% if user.role == 'admin' %}danger{% elif user.role == 'director' %}warning{% elif user.role == 'project_manager' %}primary{% elif user.role == 'auditor' %}secondary{% elif user.role == 'customer' %}success{% else %}info{% endif %}"> <span class="badge bg-{% if user.role == 'admin' %}danger{% elif user.role == 'director' %}warning{% elif user.role == 'project_manager' %}primary{% elif user.role == 'auditor' %}secondary{% elif user.role == 'customer' %}success{% elif user.role == 'external_inspector' %}dark{% else %}info{% endif %}">
{{ user.role.replace('_',' ')|title }} {{ user.role_label }}
</span> </span>
</td> </td>
<td> <td>
{% if user.role == 'inspector' %} {% if user.is_inspector %}
{% set cnt = inspector_contract_counts.get(user.id, 0) %} {% set cnt = inspector_contract_counts.get(user.id, 0) %}
{% if cnt > 0 %} {% if cnt > 0 %}
<span class="badge bg-success">{{ cnt }} contract{{ 's' if cnt != 1 else '' }}</span> <span class="badge bg-success">{{ cnt }} contract{{ 's' if cnt != 1 else '' }}</span>
@@ -65,12 +65,23 @@
<a href="{{ url_for('auth.edit_user', user_id=user.id) }}" class="btn btn-sm btn-outline-primary" title="Edit"> <a href="{{ url_for('auth.edit_user', user_id=user.id) }}" class="btn btn-sm btn-outline-primary" title="Edit">
<i class="bi bi-pencil"></i> <i class="bi bi-pencil"></i>
</a> </a>
{% if user.role == 'inspector' %} {% if user.is_inspector %}
<a href="{{ url_for('auth.assign_inspector_contracts', user_id=user.id) }}" <a href="{{ url_for('auth.assign_inspector_contracts', user_id=user.id) }}"
class="btn btn-sm btn-outline-secondary" title="Assign contracts"> class="btn btn-sm btn-outline-secondary" title="Assign contracts">
<i class="bi bi-briefcase"></i> <i class="bi bi-briefcase"></i>
</a> </a>
{% endif %} {% endif %}
{% if not user.password_set %}
<form method="POST" action="{{ url_for('auth.resend_invite', user_id=user.id) }}"
class="d-inline"
onsubmit="return confirm('Resend the invitation email to {{ user.email }}? The previous link will stop working.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-warning"
title="Resend invitation email">
<i class="bi bi-envelope-arrow-up"></i>
</button>
</form>
{% endif %}
{% if user.id != current_user.id %} {% if user.id != current_user.id %}
<form method="POST" action="{{ url_for('auth.toggle_active', user_id=user.id) }}" class="d-inline"> <form method="POST" action="{{ url_for('auth.toggle_active', user_id=user.id) }}" class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
+12 -527
View File
@@ -1,530 +1,15 @@
<!DOCTYPE html> {# ────────────────────────────────────────────────────────────────────────────
<html lang="en"> base.html — layout dispatcher (MT-16)
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<!-- iOS / iPadOS web app meta tags -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="mobile-web-app-capable" content="yes">
<title>{% block title %}Janitorial QC System{% endblock %}</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">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&display=swap">
<link rel="stylesheet" href="{{ url_for('static', filename='css/theme.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/ipad_responsive.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/mobile_phone.css') }}">
{% if tenant_branding %}
<style>
:root {
--bs-primary: {{ tenant_branding.primary_color or '#1a56db' }};
--bs-primary-rgb: {{ tenant_branding.primary_color|hex_to_rgb if tenant_branding.primary_color else '26,86,219' }};
--jqc-accent: {{ tenant_branding.accent_color or '#16a34a' }};
}
.bg-primary { background-color: {{ tenant_branding.primary_color or '#1a56db' }} !important; }
.btn-primary { background-color: {{ tenant_branding.primary_color or '#1a56db' }} !important;
border-color: {{ tenant_branding.primary_color or '#1a56db' }} !important; }
</style>
{% endif %}
{% block extra_css %}{% endblock %}
<style>
/* ── Notification bell styles ── */
.notif-bell-wrapper { position: relative; }
.notif-badge {
position: absolute;
top: 2px; right: 2px;
font-size: 0.6rem;
min-width: 16px; height: 16px; line-height: 16px;
padding: 0 4px; border-radius: 8px;
pointer-events: none;
}
.notif-dropdown {
width: 380px;
max-height: 520px;
overflow-y: auto;
padding: 0;
}
.notif-item {
border-left: 3px solid transparent;
transition: background 0.15s;
cursor: pointer;
}
.notif-item.unread {
border-left-color: #0d6efd;
background-color: #f0f6ff;
}
.notif-item:hover { background-color: #e8f0fe; }
.notif-title { font-size: 0.85rem; font-weight: 600; margin-bottom: 2px; }
.notif-body { font-size: 0.78rem; color: #555; white-space: normal; }
.notif-time { font-size: 0.7rem; color: #999; }
.notif-empty { padding: 24px; text-align: center; color: #aaa; font-size: 0.85rem; }
/* ── Active nav tab ── */ This file used to hold the entire page chrome. That markup now lives in
.navbar-dark .navbar-nav .nav-link.active { layouts/classic.html, unchanged.
background-color: rgba(255, 255, 255, 0.18);
color: #ffffff !important;
border-radius: 6px;
font-weight: 600;
box-shadow: inset 0 -2px 0 rgba(255,255,255,0.6);
}
.navbar-dark .navbar-nav .nav-link:not(.active):hover {
background-color: rgba(255, 255, 255, 0.08);
border-radius: 6px;
}
</style>
</head>
<body>
{% if current_user.is_authenticated %}
<nav class="navbar navbar-expand-xxl navbar-dark bg-primary">
<div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('dashboard.index') }}">
{% if tenant_branding and tenant_branding.logo_url %}
<img src="{{ url_for('static', filename=tenant_branding.logo_url) }}"
alt="{{ tenant_branding.display_name }}"
style="max-height:32px; border-radius:4px; margin-right:.35rem;">
{% else %}
<i class="bi bi-clipboard-check"></i>
{% endif %}
{{ tenant_branding.display_name if tenant_branding else 'Janitorial QC' }}
</a>
<!-- ── Bell + toggler always visible on mobile/tablet ── -->
<div class="d-flex align-items-center gap-2 ms-auto me-2 d-xxl-none">
<!-- Notification bell (always visible) -->
<div class="dropdown">
<a class="nav-link position-relative notif-bell-wrapper text-white"
href="#"
id="notifDropdownMobile"
role="button"
data-bs-toggle="dropdown"
aria-expanded="false"
title="Notifications">
<i class="bi bi-bell fs-5"></i>
{% if unread_notification_count > 0 %}
<span class="badge bg-danger notif-badge" id="notif-count-badge-mobile">
{{ unread_notification_count if unread_notification_count <= 99 else '99+' }}
</span>
{% else %}
<span class="badge bg-danger notif-badge d-none" id="notif-count-badge-mobile"></span>
{% endif %}
</a>
<div class="dropdown-menu dropdown-menu-end notif-dropdown shadow"
id="notif-dropdown-menu-mobile">
<div class="d-flex justify-content-between align-items-center px-3 py-2 border-bottom">
<span class="fw-semibold" style="font-size:.9rem;">Notifications</span>
<button class="btn btn-link btn-sm p-0 text-muted text-decoration-none mark-all-read-btn"
style="font-size:.75rem;">Mark all as read</button>
</div>
<div class="notif-list-mobile">
<div class="notif-empty">Loading…</div>
</div>
<div class="border-top d-flex justify-content-between px-3 py-2" style="font-size:.8rem;">
<a href="{{ url_for('notifications.index') }}" class="text-decoration-none">
<i class="bi bi-list-ul me-1"></i>View all
</a>
<a href="{{ url_for('notifications.preferences') }}" class="text-decoration-none text-muted">
<i class="bi bi-gear me-1"></i>Preferences
</a>
</div>
</div>
</div>
</div>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('dashboard.') }}" href="{{ url_for('dashboard.index') }}">Dashboard</a>
</li>
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and (request.endpoint.startswith('reports.') or request.endpoint.startswith('scheduled_reports.')) }}" href="{{ url_for('reports.index') }}">Reports</a>
</li>
{% if current_user.role in ['admin', 'director', 'project_manager', 'auditor'] %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('projects.') }}" href="{{ url_for('projects.index') }}">Contracts</a>
</li>
{% endif %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('facilities.') }}" href="{{ url_for('facilities.list_facilities') }}">Facilities</a>
</li>
{% if current_user.role in ['admin', 'director'] %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('templates.') }}" href="{{ url_for('templates.index') }}">Templates</a>
</li>
{% endif %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('inspections.') }}" href="{{ url_for('inspections.index') }}">Inspections</a>
</li>
{% if current_user.role in ['admin', 'director', 'project_manager', 'auditor'] %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('inspection_schedules.') }}" href="{{ url_for('inspection_schedules.index') }}">Schedules</a>
</li>
{% endif %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('issues.') and request.endpoint != 'issues.verification_queue' }}" href="{{ url_for('issues.index') }}">Issues</a>
</li>
{% if current_user.role in ['admin', 'director', 'auditor'] %}
<li class="nav-item">
<a class="nav-link d-flex align-items-center gap-1 {{ 'active' if request.endpoint == 'issues.verification_queue' }}"
href="{{ url_for('issues.verification_queue') }}">
Verify
{% if pending_verification_count and pending_verification_count > 0 %}
<span class="badge bg-info text-dark"
style="font-size:.65rem;line-height:1;">
{{ pending_verification_count }}
</span>
{% endif %}
</a>
</li>
{% endif %}
{% if current_user.role in ['admin', 'director'] %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('customers.') }}" href="{{ url_for('customers.index') }}">Customers</a>
</li>
{% endif %}
{% if current_user.role in ['admin', 'director'] %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('support.') }}"
href="{{ url_for('support.admin_tickets') }}">
Support
{% if open_support_tickets_count > 0 %}
<span class="badge bg-danger ms-1">{{ open_support_tickets_count }}</span>
{% endif %}
</a>
</li>
{% endif %}
{% if current_user.role == 'customer' %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle {{ 'active' if request.endpoint and request.endpoint.startswith('support.') }}"
href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-chat-dots me-1"></i>Support
</a>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item" href="{{ url_for('support.chat') }}">
<i class="bi bi-chat-dots me-2"></i>Ask a Question
</a>
</li>
<li>
<a class="dropdown-item" href="{{ url_for('support.my_conversations') }}">
<i class="bi bi-clock-history me-2"></i>My Conversations
</a>
</li>
<li>
<a class="dropdown-item" href="{{ url_for('support.my_tickets') }}">
<i class="bi bi-inbox me-2"></i>My Requests
</a>
</li>
</ul>
</li>
{% endif %}
{% if current_user.role == 'admin' %}
{% set admin_active = request.endpoint and (
(request.endpoint.startswith('auth.') and 'user' in request.endpoint)
or request.endpoint.startswith('audit.')
or request.endpoint == 'auth.notification_matrix'
or request.endpoint.startswith('broadcast.')
or request.endpoint.startswith('devices.')
or request.endpoint.startswith('tenant_settings.')
) %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle {{ 'active' if admin_active }}"
href="#" id="adminMenu" role="button"
data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-sliders me-1"></i>Admin
</a>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="adminMenu">
<li>
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('auth.') and 'user' in request.endpoint }}"
href="{{ url_for('auth.list_users') }}">
<i class="bi bi-people me-2"></i>Users
</a>
</li>
<li>
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('audit.') }}"
href="{{ url_for('audit.index') }}">
<i class="bi bi-clipboard-data me-2"></i>Audit Trail
</a>
</li>
<li>
<a class="dropdown-item {{ 'active' if request.endpoint == 'auth.notification_matrix' }}"
href="{{ url_for('auth.notification_matrix') }}">
<i class="bi bi-grid-3x3-gap-fill me-2"></i>Notification Matrix
</a>
</li>
<li>
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('broadcast.') }}"
href="{{ url_for('broadcast.index') }}">
<i class="bi bi-megaphone-fill me-2"></i>Broadcast
</a>
</li>
<li>
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('devices.') }}"
href="{{ url_for('devices.index') }}">
<i class="bi bi-tablet me-2"></i>Devices
</a>
</li>
<li><hr class="dropdown-divider"></li>
<li>
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('tenant_settings.') }}"
href="{{ url_for('tenant_settings.branding') }}">
<i class="bi bi-gear me-2"></i>Workspace Settings
</a>
</li>
</ul>
</li>
{% endif %}
</ul>
<ul class="navbar-nav align-items-center">
<!-- ── Notification Bell (desktop lg+ only) ── --> Every page template still says {% extends "base.html" %} and needed ZERO
<li class="nav-item dropdown me-2 d-none d-xxl-block"> edits: Jinja resolves {% block %} overrides through the whole inheritance
<a class="nav-link position-relative notif-bell-wrapper" chain, so one extra link in that chain is invisible to them.
href="#"
id="notifDropdown"
role="button"
data-bs-toggle="dropdown"
aria-expanded="false"
title="Notifications">
<i class="bi bi-bell fs-5"></i>
{% if unread_notification_count > 0 %}
<span class="badge bg-danger notif-badge" id="notif-count-badge">
{{ unread_notification_count if unread_notification_count <= 99 else '99+' }}
</span>
{% else %}
<span class="badge bg-danger notif-badge d-none" id="notif-count-badge"></span>
{% endif %}
</a>
<div class="dropdown-menu dropdown-menu-end notif-dropdown shadow"
id="notif-dropdown-menu">
<!-- Header -->
<div class="d-flex justify-content-between align-items-center
px-3 py-2 border-bottom">
<span class="fw-semibold" style="font-size:.9rem;">Notifications</span>
<button class="btn btn-link btn-sm p-0 text-muted text-decoration-none"
id="mark-all-read-btn" style="font-size:.75rem;">
Mark all as read
</button>
</div>
<!-- Items -->
<div id="notif-list">
<div class="notif-empty">Loading…</div>
</div>
<!-- Footer -->
<div class="border-top d-flex justify-content-between px-3 py-2"
style="font-size:.8rem;">
<a href="{{ url_for('notifications.index') }}"
class="text-decoration-none">
<i class="bi bi-list-ul me-1"></i>View all
</a>
<a href="{{ url_for('notifications.preferences') }}"
class="text-decoration-none text-muted">
<i class="bi bi-gear me-1"></i>Preferences
</a>
</div>
</div>
</li>
<!-- ── End Notification Bell ── -->
<!-- User menu --> `jqc_layout` is supplied by the inject_ui_theme() context processor in
<li class="nav-item dropdown"> app/__init__.py, driven by users.ui_theme ('classic' | 'modern') with
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" config DEFAULT_UI_THEME as the fallback for accounts that never chose.
role="button" data-bs-toggle="dropdown"> ──────────────────────────────────────────────────────────────────────────── #}
<i class="bi bi-person-circle"></i> {{ current_user.username }} {% extends jqc_layout %}
</a>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<a class="dropdown-item"
href="{{ url_for('auth.profile') }}">
<i class="bi bi-person-circle me-1"></i>My Profile
</a>
</li>
<li><hr class="dropdown-divider"></li>
<li>
<a class="dropdown-item"
href="{{ url_for('notifications.preferences') }}">
<i class="bi bi-bell-slash me-1"></i>Notification Preferences
</a>
</li>
<li><hr class="dropdown-divider"></li>
<li>
<a class="dropdown-item" href="{{ url_for('auth.logout') }}">
<i class="bi bi-box-arrow-right me-1"></i>Logout
</a>
</li>
</ul>
</li>
</ul>
</div>
</div>
</nav>
{% endif %}
<div class="container-fluid mt-4">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% include 'billing/_billing_banner.html' %}
{% block content %}{% endblock %}
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
{% block extra_js %}{% endblock %}
{% if current_user.is_authenticated %}
<script>
(function () {
'use strict';
const FEED_URL = '{{ url_for("notifications.feed") }}';
const MARK_READ_BASE = '/notifications/';
const MARK_ALL_URL = '{{ url_for("notifications.mark_all_read") }}';
const CSRF_TOKEN = '{{ csrf_token() }}';
const POLL_INTERVAL = 60000; // 60 seconds
// ── Element refs — desktop bell (lg+) and mobile/tablet bell (<lg)
const badgeDesktop = document.getElementById('notif-count-badge');
const badgeMobile = document.getElementById('notif-count-badge-mobile');
const listDesktop = document.getElementById('notif-list');
const listMobile = document.querySelector('.notif-list-mobile');
// ── Update both badge instances ────────────────────────────────────────
function updateBadge(count) {
[badgeDesktop, badgeMobile].forEach(function(badge) {
if (!badge) return;
if (count > 0) {
badge.textContent = count > 99 ? '99+' : count;
badge.classList.remove('d-none');
} else {
badge.textContent = '';
badge.classList.add('d-none');
}
});
}
// ── Render notification items into a given container ───────────────────
function renderInto(container, notifications) {
if (!container) return;
if (!notifications.length) {
container.innerHTML = '<div class="notif-empty">'
+ '<i class="bi bi-check2-circle me-1"></i>You\'re all caught up!</div>';
return;
}
container.innerHTML = notifications.map(function(n) {
return '<div class="d-block text-decoration-none text-dark notif-item px-3 py-2 border-bottom '
+ (n.is_read ? '' : 'unread') + '"'
+ ' data-notif-id="' + n.id + '"'
+ ' data-link="' + escapeAttr(n.link || '') + '">'
+ '<div class="notif-title">' + escapeHtml(n.title) + '</div>'
+ '<div class="notif-body">' + escapeHtml(n.body) + '</div>'
+ '<div class="notif-time">' + escapeHtml(n.created_at) + '</div>'
+ '</div>';
}).join('');
container.querySelectorAll('.notif-item').forEach(function(el) {
el.addEventListener('click', function() {
var id = this.dataset.notifId;
var link = this.dataset.link;
markRead(id, function() {
el.classList.remove('unread');
if (link) window.location.href = link;
});
});
});
}
function renderNotifications(notifications) {
renderInto(listDesktop, notifications);
renderInto(listMobile, notifications);
}
function escapeHtml(str) {
if (!str) return '';
return str.replace(/&/g,'&amp;').replace(/</g,'&lt;')
.replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function escapeAttr(str) { return escapeHtml(str); }
// ── Fetch + update ─────────────────────────────────────────────────────
window.fetchNotifications = function fetchNotifications() {
fetch(FEED_URL, { credentials: 'same-origin' })
.then(function(r) { return r.json(); })
.then(function(data) {
updateBadge(data.unread_count);
window._jqcNotifications = data.notifications;
var deskEl = document.getElementById('notifDropdown');
var mobileEl = document.getElementById('notifDropdownMobile');
var deskOpen = deskEl && deskEl.getAttribute('aria-expanded') === 'true';
var mobileOpen = mobileEl && mobileEl.getAttribute('aria-expanded') === 'true';
if (deskOpen || mobileOpen) {
renderNotifications(data.notifications);
}
})
.catch(function() {});
};
function markRead(id, callback) {
fetch(MARK_READ_BASE + id + '/mark-read', {
method: 'POST',
headers: { 'X-CSRFToken': CSRF_TOKEN, 'Content-Type': 'application/json' },
credentials: 'same-origin',
})
.then(function(r) { return r.json(); })
.then(function() { if (callback) callback(); fetchNotifications(); })
.catch(function() { if (callback) callback(); });
}
// ── Show dropdown → render cached data immediately ─────────────────────
['notifDropdown', 'notifDropdownMobile'].forEach(function(id) {
var el = document.getElementById(id);
if (!el) return;
el.addEventListener('show.bs.dropdown', function() {
if (window._jqcNotifications) {
renderNotifications(window._jqcNotifications);
} else {
fetchNotifications();
}
});
});
// ── Mark all read — works from either bell ─────────────────────────────
document.querySelectorAll('#mark-all-read-btn, .mark-all-read-btn').forEach(function(btn) {
btn.addEventListener('click', function(e) {
e.stopPropagation();
fetch(MARK_ALL_URL, {
method: 'POST',
headers: {
'X-CSRFToken': CSRF_TOKEN,
'X-Requested-With': 'XMLHttpRequest',
},
credentials: 'same-origin',
})
.then(function(r) { return r.json(); })
.then(function() {
updateBadge(0);
document.querySelectorAll('.notif-item.unread').forEach(function(el) {
el.classList.remove('unread');
});
if (window._jqcNotifications) {
window._jqcNotifications.forEach(function(n) { n.is_read = true; });
}
})
.catch(function() {});
});
});
fetchNotifications();
setInterval(fetchNotifications, POLL_INTERVAL);
})();
</script>
{% endif %}
</body>
</html>
+18 -2
View File
@@ -5,7 +5,11 @@
<div class="row mb-4 align-items-center"> <div class="row mb-4 align-items-center">
<div class="col"> <div class="col">
<h2><i class="bi bi-person-badge"></i> Customer Management</h2> <h2><i class="bi bi-person-badge"></i> Customer Management</h2>
<p class="text-muted mb-0">Manage portal access for all customer accounts.</p> <p class="text-muted mb-0">
Manage both customer-side roles — <strong>Customer Directors</strong>
(portal access) and <strong>Customer Inspectors</strong> (perform
inspections on their contracts).
</p>
</div> </div>
<div class="col-auto d-flex gap-2"> <div class="col-auto d-flex gap-2">
<a href="{{ url_for('customers.bulk_import') }}" class="btn btn-outline-success"> <a href="{{ url_for('customers.bulk_import') }}" class="btn btn-outline-success">
@@ -55,6 +59,7 @@
<tr> <tr>
<th>Username</th> <th>Username</th>
<th>Full Name</th> <th>Full Name</th>
<th>Role</th>
<th>Email</th> <th>Email</th>
<th>Status</th> <th>Status</th>
<th>Assigned Contracts</th> <th>Assigned Contracts</th>
@@ -65,7 +70,11 @@
</thead> </thead>
<tbody> <tbody>
{% for customer in customers %} {% for customer in customers %}
{% set assignments = assignment_map[customer.id] %} {# Inspectors are scoped by InspectorAssignment, directors by
CustomerAssignment — read the map that matches the role. #}
{% set assignments = inspector_assignment_map[customer.id]
if customer.is_inspector
else assignment_map[customer.id] %}
{% set facility_ids = scope_map[customer.id] %} {% set facility_ids = scope_map[customer.id] %}
<tr class="{{ 'table-secondary text-muted' if not customer.active else '' }}"> <tr class="{{ 'table-secondary text-muted' if not customer.active else '' }}">
<td> <td>
@@ -77,6 +86,11 @@
</strong> </strong>
</td> </td>
<td>{{ customer.full_name or '—' }}</td> <td>{{ customer.full_name or '—' }}</td>
<td>
<span class="badge {{ 'bg-info text-dark' if customer.is_inspector else 'bg-primary' }}">
{{ customer.role_label }}
</span>
</td>
<td class="small text-muted">{{ customer.email }}</td> <td class="small text-muted">{{ customer.email }}</td>
<td> <td>
{% if customer.active %} {% if customer.active %}
@@ -136,6 +150,8 @@
<div class="mt-3 text-muted small"> <div class="mt-3 text-muted small">
{{ customers|length }} customer account{{ 's' if customers|length != 1 else '' }} total {{ customers|length }} customer account{{ 's' if customers|length != 1 else '' }} total
· {{ customers|selectattr('active')|list|length }} active · {{ customers|selectattr('active')|list|length }} active
· {{ customers|rejectattr('is_inspector')|list|length }} director{{ 's' if customers|rejectattr('is_inspector')|list|length != 1 else '' }}
· {{ customers|selectattr('is_inspector')|list|length }} inspector{{ 's' if customers|selectattr('is_inspector')|list|length != 1 else '' }}
</div> </div>
{% else %} {% else %}
+16 -1
View File
@@ -31,7 +31,7 @@
{% endfor %} {% endfor %}
</div> </div>
<div class="mb-4"> <div class="mb-3">
{{ form.email.label(class="form-label fw-semibold") }} {{ form.email.label(class="form-label fw-semibold") }}
{{ form.email(class="form-control" + (" is-invalid" if form.email.errors else ""), {{ form.email(class="form-control" + (" is-invalid" if form.email.errors else ""),
placeholder="jane@example.com") }} placeholder="jane@example.com") }}
@@ -44,6 +44,21 @@
</div> </div>
</div> </div>
<div class="mb-4">
{{ form.role.label(class="form-label fw-semibold") }}
{{ form.role(class="form-select" + (" is-invalid" if form.role.errors else "")) }}
{% for error in form.role.errors %}
<div class="invalid-feedback">{{ error }}</div>
{% endfor %}
<div class="form-text">
A <strong>Director</strong> gets portal access to their facilities'
inspections, issues and reports. An <strong>Inspector</strong>
performs inspections and manages issues on the contracts you assign
them — the same tools as our own inspectors, limited to their
contracts. You can switch an account between the two later.
</div>
</div>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<button type="submit" class="btn btn-primary"> <button type="submit" class="btn btn-primary">
<i class="bi bi-send me-1"></i>Create &amp; Send Invitation <i class="bi bi-send me-1"></i>Create &amp; Send Invitation
+226 -1
View File
@@ -6,6 +6,9 @@
<div class="col"> <div class="col">
<h2> <h2>
<i class="bi bi-person-badge"></i> {{ customer.display_name }} <i class="bi bi-person-badge"></i> {{ customer.display_name }}
<span class="badge {{ 'bg-info text-dark' if customer.is_inspector else 'bg-primary' }} ms-2 fs-6">
{{ customer.role_label }}
</span>
{% if not customer.active %} {% if not customer.active %}
<span class="badge bg-secondary ms-2 fs-6">Disabled</span> <span class="badge bg-secondary ms-2 fs-6">Disabled</span>
{% else %} {% else %}
@@ -19,6 +22,20 @@
class="btn btn-outline-secondary btn-sm"> class="btn btn-outline-secondary btn-sm">
<i class="bi bi-pencil"></i> Edit Account <i class="bi bi-pencil"></i> Edit Account
</a> </a>
{# ── Switch role (admin only) ── #}
{% if current_user.role == 'admin' %}
{% set to_label = 'Customer Director' if customer.is_inspector else 'Customer Inspector' %}
<form method="POST"
action="{{ url_for('customers.switch_role', customer_id=customer.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-info"
title="Change what this account can do"
onclick="return confirm('Switch {{ customer.display_name }} from {{ customer.role_label }} to {{ to_label }}?\n\nTheir contracts are carried across.{% if not customer.is_inspector %}\n\nFacility-level limits do not exist for inspectors — an account limited to specific facilities will gain the whole contract.{% endif %}\n\nAny signed-in device will be logged out.')">
<i class="bi bi-arrow-left-right me-1"></i> Switch to {{ to_label }}
</button>
</form>
{% endif %}
<form method="POST" <form method="POST"
action="{{ url_for('customers.toggle_active', customer_id=customer.id) }}"> action="{{ url_for('customers.toggle_active', customer_id=customer.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
@@ -48,6 +65,19 @@
<dl class="row mb-0 small"> <dl class="row mb-0 small">
<dt class="col-5 text-muted">Full Name</dt> <dt class="col-5 text-muted">Full Name</dt>
<dd class="col-7">{{ customer.full_name or '—' }}</dd> <dd class="col-7">{{ customer.full_name or '—' }}</dd>
<dt class="col-5 text-muted">Role</dt>
<dd class="col-7">
<span class="badge {{ 'bg-info text-dark' if customer.is_inspector else 'bg-primary' }}">
{{ customer.role_label }}
</span>
<div class="text-muted" style="font-size:.72rem;">
{% if customer.is_inspector %}
Performs inspections and manages issues on their assigned contracts.
{% else %}
Portal access to their facilities' inspections, issues and reports.
{% endif %}
</div>
</dd>
<dt class="col-5 text-muted">Username</dt> <dt class="col-5 text-muted">Username</dt>
<dd class="col-7">{{ customer.username }}</dd> <dd class="col-7">{{ customer.username }}</dd>
<dt class="col-5 text-muted">Email</dt> <dt class="col-5 text-muted">Email</dt>
@@ -69,7 +99,7 @@
<dt class="col-5 text-muted">Created</dt> <dt class="col-5 text-muted">Created</dt>
<dd class="col-7">{{ customer.created_at.strftime('%Y-%m-%d') }}</dd> <dd class="col-7">{{ customer.created_at.strftime('%Y-%m-%d') }}</dd>
<dt class="col-5 text-muted">Assignments</dt> <dt class="col-5 text-muted">Assignments</dt>
<dd class="col-7">{{ assignments|length }}</dd> <dd class="col-7">{{ assigned_pids|length if customer.is_inspector else assignments|length }}</dd>
<dt class="col-5 text-muted">Facilities</dt> <dt class="col-5 text-muted">Facilities</dt>
<dd class="col-7">{{ facilities|length }}</dd> <dd class="col-7">{{ facilities|length }}</dd>
</dl> </dl>
@@ -120,6 +150,58 @@
{# ── Right column: assignments ── #} {# ── Right column: assignments ── #}
<div class="col-md-8"> <div class="col-md-8">
{% if customer.is_inspector %}
{# ══ Customer Inspector — whole contracts, no facility-level narrowing ══
Scoped by InspectorAssignment, the same rows an internal inspector uses.
Posts the COMPLETE checked set; unchecked contracts are removed. ══ #}
<div class="card shadow-sm mb-4">
<div class="card-header bg-light fw-semibold d-flex justify-content-between align-items-center">
<span><i class="bi bi-diagram-3 me-1"></i> Contract Assignments</span>
<span class="badge bg-secondary rounded-pill" id="assignedCount">
{{ assigned_pids|length }} assigned
</span>
</div>
<div class="card-body">
<p class="text-muted small">
A Customer Inspector sees only the contracts ticked here — with none
ticked they see nothing at all. Inspectors are assigned whole
contracts; there is no per-facility option for this role.
</p>
<form method="POST"
action="{{ url_for('customers.assign_contracts', customer_id=customer.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-2">
<button type="button" class="btn btn-sm btn-outline-secondary" id="selectAll">Select all</button>
<button type="button" class="btn btn-sm btn-outline-secondary" id="deselectAll">Deselect all</button>
</div>
{% if projects %}
<div class="list-group list-group-flush mb-3"
style="max-height:340px;overflow-y:auto;">
{% for p in projects %}
<label class="list-group-item d-flex align-items-center gap-2 py-2">
<input class="form-check-input m-0 contract-check" type="checkbox"
name="project_ids" value="{{ p.id }}"
{% if p.id in assigned_pids %}checked{% endif %}>
<span class="small">{{ p.name }}</span>
</label>
{% endfor %}
</div>
<button type="submit" class="btn btn-primary btn-sm">
<i class="bi bi-check2 me-1"></i> Save Contract Assignments
</button>
{% else %}
<p class="text-muted small mb-0">No active contracts exist yet.</p>
{% endif %}
</form>
</div>
</div>
{% else %}
{# ══ Customer Director — contract or single-facility assignments ══ #}
{# ── Current assignments table ── #} {# ── Current assignments table ── #}
<div class="card shadow-sm mb-4"> <div class="card shadow-sm mb-4">
<div class="card-header bg-light fw-semibold d-flex justify-content-between align-items-center"> <div class="card-header bg-light fw-semibold d-flex justify-content-between align-items-center">
@@ -213,6 +295,116 @@
</form> </form>
</div> </div>
</div> </div>
{% endif %}
{# ── Per-account notification matrix ─────────────────────────────────
Overrides the global Notification Matrix for THIS account only.
"Inherit" is the default and means "follow the global column", so it
keeps tracking future changes there — it is not a snapshot. #}
<div class="card shadow-sm mt-4">
<div class="card-header bg-light fw-semibold">
<i class="bi bi-bell me-1"></i> Notifications for this account
</div>
<div class="card-body">
<p class="text-muted small">
Each customer's enrollment form says which notifications their people
want, so these can differ per person. <strong>Inherit</strong> follows
the global Notification Matrix for
{{ customer.role_label }}s — including any later change to it. Choose
On or Off only where this account should differ.
</p>
<form method="POST"
action="{{ url_for('customers.save_notifications', customer_id=customer.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<style>
/* The whole cell is the control. Padding (not min-height on the
input) gives the touch target, so the native radio keeps its
own box — see the note in ipad_responsive.css. */
.matrix-opt {
display: block;
padding: .55rem .25rem;
margin: 0;
cursor: pointer;
text-align: center;
}
.matrix-opt:hover { background: rgba(13,110,253,.06); }
.matrix-opt input { cursor: pointer; }
.matrix-opt-sub {
display: block;
font-size: .62rem;
color: #6c757d;
margin-top: 2px;
}
</style>
<div class="d-flex flex-wrap align-items-center gap-2 mb-2">
<span class="small text-muted">Set every row:</span>
<button type="button" class="btn btn-sm btn-outline-secondary" data-matrix-all="inherit">Inherit</button>
<button type="button" class="btn btn-sm btn-outline-success" data-matrix-all="on">On</button>
<button type="button" class="btn btn-sm btn-outline-danger" data-matrix-all="off">Off</button>
</div>
<div class="table-responsive">
<table class="table table-sm align-middle mb-3">
<thead class="table-light">
<tr>
<th>Event</th>
<th class="text-center" style="width:110px;">Inherit</th>
<th class="text-center" style="width:70px;">On</th>
<th class="text-center" style="width:70px;">Off</th>
</tr>
</thead>
<tbody>
{% for row in matrix_rows %}
<tr>
<td class="small">
{{ row.label }}
{% if row.override is not none %}
<span class="badge bg-warning text-dark ms-1" style="font-size:.6rem;">custom</span>
{% endif %}
</td>
{# Each option is a <label> filling its whole cell, so the
click target is the cell rather than the ~16px glyph. A
bare <input> in a centred <td> was effectively unclickable
at touch/narrow widths. #}
<td class="p-0">
<label class="matrix-opt" title="Follow the global matrix">
<input class="form-check-input" type="radio"
name="event_{{ row.event }}" value="inherit"
{% if row.override is none %}checked{% endif %}>
<span class="matrix-opt-sub">
currently {{ 'on' if row.global else 'off' }}
</span>
</label>
</td>
<td class="p-0">
<label class="matrix-opt" title="Always send this to this account">
<input class="form-check-input" type="radio"
name="event_{{ row.event }}" value="on"
{% if row.override is true %}checked{% endif %}>
</label>
</td>
<td class="p-0">
<label class="matrix-opt" title="Never send this to this account">
<input class="form-check-input" type="radio"
name="event_{{ row.event }}" value="off"
{% if row.override is false %}checked{% endif %}>
</label>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<button type="submit" class="btn btn-primary btn-sm">
<i class="bi bi-check2 me-1"></i> Save Notification Settings
</button>
</form>
</div>
</div>
</div> </div>
</div> </div>
@@ -223,8 +415,41 @@
(function () { (function () {
'use strict'; 'use strict';
// ── Customer Inspector: contract checkbox helpers ──
const checks = document.querySelectorAll('.contract-check');
const countBadge = document.getElementById('assignedCount');
function refreshCount() {
if (!countBadge) return;
const n = document.querySelectorAll('.contract-check:checked').length;
countBadge.textContent = n + ' assigned';
}
function setAll(state) {
checks.forEach(function (c) { c.checked = state; });
refreshCount();
}
const selectAll = document.getElementById('selectAll');
const deselectAll = document.getElementById('deselectAll');
if (selectAll) selectAll.addEventListener('click', function () { setAll(true); });
if (deselectAll) deselectAll.addEventListener('click', function () { setAll(false); });
checks.forEach(function (c) { c.addEventListener('change', refreshCount); });
// ── Notification matrix: set every row at once ──
document.querySelectorAll('[data-matrix-all]').forEach(function (btn) {
btn.addEventListener('click', function () {
var want = btn.getAttribute('data-matrix-all');
document.querySelectorAll('.matrix-opt input[type=radio]').forEach(function (r) {
if (r.value === want) { r.checked = true; }
});
});
});
// ── Customer Director: contract → facility cascade ──
// Both selects are absent on the inspector view, so bail out rather than
// throwing on addEventListener of null (which would kill the handlers above).
const projSelect = document.getElementById('proj-select'); const projSelect = document.getElementById('proj-select');
const facSelect = document.getElementById('fac-select'); const facSelect = document.getElementById('fac-select');
if (!projSelect || !facSelect) return;
projSelect.addEventListener('change', function () { projSelect.addEventListener('change', function () {
const projectId = this.value; const projectId = this.value;
@@ -0,0 +1,116 @@
{# ── "Confirm receipt" result page (phase50) ─────────────────────────────────
Landing page for the one-click link in a scheduled-inspection assignment or
reminder email. Standalone — NOT extending base.html — because the viewer is
typically not logged in and base.html's nav assumes current_user. Same shape
as the public QR scan pages (facility_qr/area.html).
`status` is always set; `schedule` only for the statuses that found one.
confirmed — newly acknowledged (the happy path)
already — acknowledged before; a re-click or an email prefetch
reassigned — the token's inspector is no longer the assignee
inactive — schedule paused or ended since the email went out
expired — token older than 30 days
invalid — bad signature / mangled link
missing — schedule deleted since the email went out
#}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>Confirm Receipt — Scheduled Inspection</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/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; }
.cf-wrap { max-width:520px; margin:3rem auto; padding:0 1rem; }
.cf-card { background:#fff; border-radius:.75rem; padding:2rem 1.5rem; text-align:center;
box-shadow:0 1px 3px rgba(0,0,0,.08); }
.cf-icon { font-size:3.5rem; line-height:1; }
.cf-meta { background:#f8fafc; border-radius:.5rem; padding:.9rem 1rem; text-align:left;
font-size:.9rem; margin-top:1.25rem; }
.cf-meta .lbl { color:#64748b; font-size:.75rem; text-transform:uppercase;
letter-spacing:.03em; }
</style>
</head>
<body>
<div class="cf-wrap">
<div class="cf-card">
{% if status == 'confirmed' %}
<div class="cf-icon text-success"><i class="bi bi-check-circle-fill"></i></div>
<h1 class="h4 mt-3 mb-2">Receipt confirmed</h1>
<p class="text-muted mb-0">
Thanks — we've let the scheduler know you've seen this request.
Nothing else to do right now.
</p>
{% elif status == 'already' %}
<div class="cf-icon text-success"><i class="bi bi-check-circle"></i></div>
<h1 class="h4 mt-3 mb-2">Already confirmed</h1>
<p class="text-muted mb-0">
You confirmed this one earlier. No need to do anything else.
</p>
{% elif status == 'reassigned' %}
<div class="cf-icon text-warning"><i class="bi bi-person-x"></i></div>
<h1 class="h4 mt-3 mb-2">No longer assigned to you</h1>
<p class="text-muted mb-0">
This scheduled inspection has been reassigned to someone else since the
email was sent, so there's nothing for you to confirm.
</p>
{% elif status == 'inactive' %}
<div class="cf-icon text-secondary"><i class="bi bi-pause-circle"></i></div>
<h1 class="h4 mt-3 mb-2">This schedule is no longer active</h1>
<p class="text-muted mb-0">
It has been paused or has reached its end date. No action is needed.
</p>
{% elif status == 'expired' %}
<div class="cf-icon text-secondary"><i class="bi bi-hourglass-bottom"></i></div>
<h1 class="h4 mt-3 mb-2">This link has expired</h1>
<p class="text-muted mb-0">
Confirmation links are good for 30 days. Sign in to the schedules page to
confirm, or ask your supervisor to resend the assignment.
</p>
{% elif status == 'missing' %}
<div class="cf-icon text-secondary"><i class="bi bi-question-circle"></i></div>
<h1 class="h4 mt-3 mb-2">Schedule not found</h1>
<p class="text-muted mb-0">
This scheduled inspection has since been removed. No action is needed.
</p>
{% else %}
<div class="cf-icon text-danger"><i class="bi bi-x-circle"></i></div>
<h1 class="h4 mt-3 mb-2">This link isn't valid</h1>
<p class="text-muted mb-0">
The link may have been copied incompletely. Try tapping it directly from
the email, or sign in to confirm from the schedules page.
</p>
{% endif %}
{% if schedule %}
<div class="cf-meta">
<div class="lbl">Inspection</div>
<div class="mb-2">
{{ schedule.template.name if schedule.template else '—' }}
at {{ schedule.facility.name if schedule.facility else '—' }}
</div>
<div class="lbl">Schedule</div>
<div>
{{ schedule.recurrence_label }}{% if schedule.due_date %} · due
{{ schedule.due_date.strftime('%b %d, %Y') }}{% endif %}
</div>
</div>
{% endif %}
</div>
<p class="text-center text-muted small mt-3 mb-0">
Janitorial QC System — automated message. Do not reply to the email.
</p>
</div>
</body>
</html>
+413 -93
View File
@@ -1,51 +1,242 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{{ title }}{% endblock %} {% block title %}{{ title }}{% endblock %}
{# Laid out to match the single-tenant scheduled-inspection form: one narrow
card, Contract → Facility cascade at the top, then what/who, then when.
MT keeps three fields ST does not have — the schedule NAME (required by
inspection_schedules.name), the AREA (a schedule may target one area) and
the auto/plan MODE. They are placed next to the field they qualify rather
than in a block of their own. #}
{# ── Sticky values ────────────────────────────────────────────────────────
This form is hand-built (no WTForms), so a re-render after a validation
error would otherwise come back blank and the user would retype everything.
On POST every field reads back from request.form; otherwise from the saved
schedule (edit) or its default (create). ST gets this free from WTForms —
this is the equivalent. #}
{% set posted = request.form if request.method == 'POST' else None %}
{% set v_name = posted.get('name') if posted else (schedule.name if schedule else '') %}
{% set v_facility = (posted.get('facility_id')|int(0)) if posted else (schedule.facility_id if schedule else 0) %}
{% set v_area = (posted.get('area_id')|int(0)) if posted else (schedule.area_id if schedule and schedule.area_id else 0) %}
{% set v_template = (posted.get('template_id')|int(0)) if posted else (schedule.template_id if schedule else 0) %}
{% set v_inspector = (posted.get('inspector_id')|int(0)) if posted else (schedule.inspector_id if schedule else 0) %}
{% set v_frequency = posted.get('frequency') if posted else (schedule.frequency if schedule else 'weekly') %}
{% set v_due = posted.get('next_due_date') if posted else (schedule.due_date.isoformat() if schedule and schedule.due_date else '') %}
{% set v_end = posted.get('end_date') if posted else (schedule.end_date.isoformat() if schedule and schedule.end_date else '') %}
{% set v_mode = posted.get('mode') if posted else (schedule.mode if schedule else 'auto') %}
{% set v_notes = posted.get('notes') if posted else (schedule.notes if schedule and schedule.notes else '') %}
{% set v_month_mode = posted.get('month_mode') if posted else (schedule.month_mode if schedule else 'day_of_month') %}
{% set v_dom = posted.get('day_of_month') if posted else (schedule.day_of_month if schedule and schedule.day_of_month else '') %}
{% set v_nth_week = (posted.get('nth_week')|int(0)) if posted else (schedule.nth_week if schedule and schedule.nth_week else 0) %}
{% set v_nth_weekday = (posted.get('nth_weekday')|int(-1)) if posted else (schedule.nth_weekday if schedule and schedule.nth_weekday is not none else -1) %}
{% set v_weekdays = (posted.getlist('weekdays')|map('int')|list) if posted else (schedule.weekday_list if schedule else []) %}
{% set v_active = (posted.get('active') is not none) if posted else (schedule.active if schedule else True) %}
{% block content %} {% block content %}
<div class="row justify-content-center"> <div class="row justify-content-center">
<div class="col-lg-8"> <div class="col-lg-7">
<h2 class="mb-4"><i class="bi bi-calendar2-week"></i> {{ title }}</h2> <div class="card shadow-sm">
<div class="card-header bg-light"><h5 class="mb-0">{{ title }}</h5></div>
<form method="POST" class="card shadow-sm">
<div class="card-body"> <div class="card-body">
<form method="POST" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Schedule name</label> <label class="form-label fw-semibold" for="name">Schedule name</label>
<input type="text" name="name" class="form-control" required <input type="text" name="name" id="name" class="form-control" required
value="{{ schedule.name if schedule else '' }}" value="{{ v_name }}"
placeholder="e.g. Weekly restroom check — Main Office"> placeholder="e.g. Weekly restroom check — Main Office">
<div class="form-text">Shown in the schedule list and in the inspector's reminder.</div>
</div>
{# Contract selector — UI only; narrows the facility list via AJAX.
It carries no name attribute and is never submitted: the facility
is what the route validates (rule 61). #}
<div class="mb-3">
<label class="form-label fw-semibold" for="contract_select">Contract</label>
<select id="contract_select" class="form-select">
<option value="">— Select Contract —</option>
{% for p in projects %}
<option value="{{ p.id }}">{{ p.name }}</option>
{% endfor %}
</select>
</div> </div>
<div class="row"> <div class="row">
<div class="col-md-6 mb-3"> <div class="col-md-6 mb-3">
<label class="form-label">Template</label> <label class="form-label fw-semibold" for="facility_id">Facility</label>
<select name="template_id" class="form-select" required> <select name="facility_id" id="facility_id" class="form-select" required>
<option value="">Choose a template</option> <option value="">Select a Contract first</option>
{% for f in facilities %}
<option value="{{ f.id }}"
{{ 'selected' if v_facility == f.id }}>{{ f.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-6 mb-3">
<label class="form-label fw-semibold" for="area_id">
Area <span class="text-muted small fw-normal">(optional)</span>
</label>
<select name="area_id" id="area_id" class="form-select">
<option value="">— Whole facility —</option>
{# Refilled by JS from the chosen facility; this keeps the saved
or just-submitted area selected until that call returns. #}
{% if schedule and schedule.area %}
<option value="{{ schedule.area.id }}"
{{ 'selected' if v_area == schedule.area.id }}>{{ schedule.area.name }}</option>
{% endif %}
</select>
</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold" for="template_id">Inspection Form</label>
<select name="template_id" id="template_id" class="form-select" required>
<option value="">— Choose a form —</option>
{% for t in templates %} {% for t in templates %}
<option value="{{ t.id }}" <option value="{{ t.id }}"
{{ 'selected' if schedule and schedule.template_id == t.id }}>{{ t.name }}</option> {{ 'selected' if v_template == t.id }}>{{ t.name }}</option>
{% endfor %} {% endfor %}
</select> </select>
<div class="form-text">
Shared forms plus any built for this contract. A form belonging to
another contract is rejected on save, not merely hidden here.
</div> </div>
<div class="col-md-6 mb-3"> </div>
<label class="form-label">Frequency</label>
<select name="frequency" class="form-select" required> <div class="mb-3">
{% for f in frequencies %} <label class="form-label fw-semibold" for="inspector_id">Assign to inspector</label>
<option value="{{ f }}" <select name="inspector_id" id="inspector_id" class="form-select" required>
{{ 'selected' if (schedule and schedule.frequency == f) or (not schedule and f == 'weekly') }}> <option value="">— Select a Contract first —</option>
{{ f|title }}</option> {% for u in inspectors %}
<option value="{{ u.id }}"
{{ 'selected' if v_inspector == u.id }}>
{{ u.display_name }}{{ ' (Customer)' if u.is_external_inspector }}</option>
{% endfor %} {% endfor %}
</select> </select>
<div class="form-text">
Inspectors assigned to this contract — the person named here has
to be able to open the inspection. Managers are never listed: a
manager who will do the work holds a contract assignment like
anyone else. If the contract has nobody assigned yet, every
inspector is offered so the schedule is not blocked.
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-md-6 mb-3"> <div class="col-md-6 mb-3">
<label class="form-label">Mode</label> <label class="form-label fw-semibold" for="frequency">Frequency</label>
<select name="mode" class="form-select"> <select name="frequency" id="frequency" class="form-select" required>
<option value="auto" {{ 'selected' if not schedule or schedule.mode != 'plan' }}> {% for f in frequencies %}
<option value="{{ f }}" {{ 'selected' if v_frequency == f }}>
{{ frequency_labels.get(f, f|title) }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-6 mb-3">
<label class="form-label fw-semibold" id="due_date_label" for="next_due_date">
{{ 'Next Due Date' if schedule else 'Start Date' }}
</label>
<input type="date" name="next_due_date" id="next_due_date" class="form-control"
value="{{ v_due }}">
<div class="form-text">
{% if schedule %}
Snapped forward to the first matching day. Leave it unchanged
and saving will not move it.
{% else %}
Snapped forward to the first matching day. Leave blank to start
one full period from now.
{% endif %}
</div>
</div>
</div>
{# ── End date ──
Hidden for one-time schedules, which end by deactivating when
completed. syncFrequency() toggles it; the route clears the column
for 'once', so a stale DOM value cannot survive a frequency change. #}
<div class="row" id="end_date_row" hidden>
<div class="col-md-6 mb-3">
<label class="form-label fw-semibold" for="end_date">
End Date <span class="text-muted small fw-normal">(optional)</span>
</label>
<input type="date" name="end_date" id="end_date" class="form-control"
value="{{ v_end }}">
<div class="form-text">
The last date this schedule may run — leave blank to repeat indefinitely.
</div>
</div>
</div>
{# ── Weekly: which days of the week ──────────────────────────── #}
<div class="mb-3 p-3 rounded bg-light border" id="weekly_block" hidden>
<label class="form-label fw-semibold d-block">Days of the Week</label>
<div class="d-flex flex-wrap gap-3">
{% for i, day in [(0,'Mon'),(1,'Tue'),(2,'Wed'),(3,'Thu'),(4,'Fri'),(5,'Sat'),(6,'Sun')] %}
<div class="form-check">
<input class="form-check-input" type="checkbox" name="weekdays"
id="weekday_{{ i }}" value="{{ i }}" {{ 'checked' if i in v_weekdays }}>
<label class="form-check-label" for="weekday_{{ i }}">{{ day }}</label>
</div>
{% endfor %}
</div>
<div class="form-text mb-0">
Pick every day the inspection recurs — e.g. Mon, Wed, Fri gives three
inspections a week. The due date rolls to the next selected day each
time one is submitted.
</div>
</div>
{# ── Monthly: day-of-month OR nth weekday ────────────────────── #}
<div class="mb-3 p-3 rounded bg-light border" id="monthly_block" hidden>
<label class="form-label fw-semibold d-block">Monthly rule</label>
<div class="form-check">
<input class="form-check-input" type="radio" name="month_mode"
id="month_mode_day" value="day_of_month"
{% if v_month_mode != 'nth_weekday' %}checked{% endif %}>
<label class="form-check-label" for="month_mode_day">On a day of the month</label>
</div>
<div class="ms-4 mb-2" id="dom_row">
<div class="input-group input-group-sm" style="max-width:16rem;">
<span class="input-group-text">Day</span>
<input type="number" name="day_of_month" class="form-control"
min="1" max="31" placeholder="15"
value="{{ v_dom }}">
</div>
<div class="form-text mb-0">Months without that day use their last day.</div>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="month_mode"
id="month_mode_nth" value="nth_weekday"
{% if v_month_mode == 'nth_weekday' %}checked{% endif %}>
<label class="form-check-label" for="month_mode_nth">On a weekday of the month</label>
</div>
<div class="ms-4" id="nth_row">
<div class="d-flex gap-2 flex-wrap" style="max-width:24rem;">
<select name="nth_week" class="form-select form-select-sm" style="max-width:7rem;">
{% for v, lbl in [(1,'1st'),(2,'2nd'),(3,'3rd'),(4,'4th'),(5,'5th'),(-1,'Last')] %}
<option value="{{ v }}" {{ 'selected' if v_nth_week == v }}>{{ lbl }}</option>
{% endfor %}
</select>
<select name="nth_weekday" class="form-select form-select-sm" style="max-width:11rem;">
{% for i, day in [(0,'Monday'),(1,'Tuesday'),(2,'Wednesday'),(3,'Thursday'),(4,'Friday'),(5,'Saturday'),(6,'Sunday')] %}
<option value="{{ i }}" {{ 'selected' if v_nth_weekday == i }}>{{ day }}</option>
{% endfor %}
</select>
</div>
<div class="form-text mb-0">e.g. the 2nd Tuesday of every month.</div>
</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold" for="mode">Mode</label>
<select name="mode" id="mode" class="form-select">
<option value="auto" {{ 'selected' if v_mode != 'plan' }}>
Auto — create the inspection automatically each period</option> Auto — create the inspection automatically each period</option>
<option value="plan" {{ 'selected' if schedule and schedule.mode == 'plan' }}> <option value="plan" {{ 'selected' if v_mode == 'plan' }}>
Plan — inspector presses Start (with due/overdue reminders)</option> Plan — inspector presses Start (with due/overdue reminders)</option>
</select> </select>
<div class="form-text"> <div class="form-text">
@@ -54,108 +245,237 @@
the day, and alerts managers once it's overdue. the day, and alerts managers once it's overdue.
</div> </div>
</div> </div>
<div class="col-md-6 mb-3">
<label class="form-label">Notes for the inspector <span class="text-muted small">(optional)</span></label>
<textarea name="notes" class="form-control" rows="2"
placeholder="Anything the inspector should know before starting">{{ schedule.notes if schedule and schedule.notes else '' }}</textarea>
<div class="form-text">Copied onto the inspection when it starts.</div>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Facility</label>
<select name="facility_id" id="facilitySelect" class="form-select" required>
<option value="">— Choose a facility —</option>
{% for f in facilities %}
<option value="{{ f.id }}"
{{ 'selected' if schedule and schedule.facility_id == f.id }}>{{ f.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Area <span class="text-muted small">(optional)</span></label>
<select name="area_id" id="areaSelect" class="form-select">
<option value="">— Whole facility —</option>
{% if schedule and schedule.area %}
<option value="{{ schedule.area.id }}" selected>{{ schedule.area.name }}</option>
{% endif %}
</select>
</div>
</div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Assign to inspector</label> <label class="form-label fw-semibold" for="notes">
<select name="inspector_id" class="form-select" required> Instructions for the inspector <span class="text-muted small fw-normal">(optional)</span>
<option value="">— Choose an inspector —</option> </label>
{% for u in inspectors %} <textarea name="notes" id="notes" class="form-control" rows="3"
<option value="{{ u.id }}" placeholder="e.g. Front lobby carpet needs extra attention. Check loading dock after 3 PM — key is at the front desk.">{{ v_notes }}</textarea>
{{ 'selected' if schedule and schedule.inspector_id == u.id }}> <div class="form-text">
{{ u.display_name }} ({{ u.role }})</option> <i class="bi bi-info-circle"></i>
{% endfor %} Shown to the assigned inspector when they open this inspection, on the web and on the iPad.
</select> </div>
</div> </div>
{% if schedule %} {% if schedule %}
<div class="form-check form-switch mb-1"> <div class="form-check mb-3">
<input class="form-check-input" type="checkbox" name="active" id="activeSwitch" <input class="form-check-input" type="checkbox" name="active" id="active"
{{ 'checked' if schedule.active }}> {{ 'checked' if v_active }}>
<label class="form-check-label" for="activeSwitch">Active</label> <label class="form-check-label" for="active">Active</label>
</div> </div>
<p class="text-muted small">
Saving recomputes the next {{ 'due date' if schedule.mode == 'plan' else 'run' }}
from now, and resets this occurrence's reminders. Currently:
{{ schedule.next_run_at.strftime('%Y-%m-%d %H:%M') if schedule.next_run_at else '—' }}
{% if schedule.last_completed_at %}
· last completed {{ schedule.last_completed_at.strftime('%Y-%m-%d %H:%M') }}
{% endif %} {% endif %}
</p>
{% endif %}
</div>
<div class="card-footer d-flex justify-content-between"> <div class="d-flex gap-2">
<a href="{{ url_for('inspection_schedules.index') }}" class="btn btn-outline-secondary">Cancel</a>
<button type="submit" class="btn btn-primary"> <button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg"></i> Save Schedule <i class="bi bi-check-lg"></i> Save
</button> </button>
<a href="{{ url_for('inspection_schedules.index') }}" class="btn btn-outline-secondary">Cancel</a>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
<script> <p class="text-muted small mt-2">
// Facility → Area cascade, reusing the existing inspections AJAX endpoint. Recurring schedules automatically roll their due date forward each time the
(function () { inspection is completed. The assigned inspector is reminded the day before
var facilitySelect = document.getElementById('facilitySelect'); and on the due date; managers are alerted if it becomes overdue.
var areaSelect = document.getElementById('areaSelect'); {% if schedule %}
if (!facilitySelect || !areaSelect) return; <br>
Current {{ 'due date' if schedule.mode == 'plan' else 'run' }}:
{{ schedule.next_run_at.strftime('%Y-%m-%d %H:%M') if schedule.next_run_at else '—' }}
{% if schedule.last_completed_at %}
· last completed {{ schedule.last_completed_at.strftime('%Y-%m-%d %H:%M') }}
{% endif %}
{% if schedule.end_date %}· ends {{ schedule.end_date.strftime('%Y-%m-%d') }}{% endif %}.
Reminders for this occurrence are only reset if the due date actually moves,
so renaming or re-noting a schedule will not re-send them.
{% endif %}
</p>
</div>
</div>
{% endblock %}
var preselectedAreaId = {{ (schedule.area_id if schedule and schedule.area_id else 0) | tojson }}; {% block extra_js %}
<script>
// ── Contract → Facility → Area cascade ──────────────────────────────────────
// The contract selector is UI-only (no name attribute): it never reaches the
// server, it only narrows the facility list. facilities_for_project is scoped
// to the caller, so a Customer Director asking for another contract's id gets
// an empty list rather than that customer's building names.
(function () {
'use strict';
var contractSel = document.getElementById('contract_select');
var facilitySel = document.getElementById('facility_id');
var areaSel = document.getElementById('area_id');
var inspectorSel = document.getElementById('inspector_id');
if (!contractSel || !facilitySel) { return; }
var FACILITIES_URL = '{{ url_for("inspections.facilities_for_project", project_id=0) }}'.replace('/0', '/');
var INSPECTORS_URL = '{{ url_for("inspection_schedules.inspectors_for_contract", project_id=0) }}'.replace('/0', '/');
var AREAS_URL = '{{ url_for("inspections.areas_for_facility", facility_id=0) }}'.replace('/0', '/');
var preProjectId = {{ selected_project_id | tojson }};
var preFacilityId = {{ v_facility | tojson }};
var preAreaId = {{ v_area | tojson }};
var preInspectorId = {{ v_inspector | tojson }};
function setPlaceholder() {
facilitySel.innerHTML = '<option value="">— Select a Contract first —</option>';
facilitySel.disabled = true;
loadAreas('', false);
if (inspectorSel) {
inspectorSel.innerHTML = '<option value="">— Select a Contract first —</option>';
inspectorSel.disabled = true;
}
}
// Inspectors come from the CONTRACT, not the facility: assignment rows are
// per contract. The server re-checks the chosen id against this same list.
function loadInspectors(projectId, restoreInspectorId) {
if (!inspectorSel) { return; }
inspectorSel.disabled = true;
inspectorSel.innerHTML = '<option value="">Loading…</option>';
fetch(INSPECTORS_URL + projectId)
.then(function (r) { return r.json(); })
.then(function (data) {
if (!data.length) {
// Only reachable for a Customer Director: our own staff fall back to
// the full inspector pool server-side. Say what is wrong rather than
// leaving an empty dropdown.
inspectorSel.innerHTML =
'<option value="">— No inspectors on this contract —</option>';
inspectorSel.disabled = false;
return;
}
inspectorSel.innerHTML = '<option value="">— Choose an inspector —</option>';
data.forEach(function (u) {
var opt = document.createElement('option');
opt.value = u.id;
opt.textContent = u.name;
if (restoreInspectorId && u.id === restoreInspectorId) { opt.selected = true; }
inspectorSel.appendChild(opt);
});
inspectorSel.disabled = false;
})
.catch(function () {
inspectorSel.innerHTML = '<option value="">Could not load inspectors</option>';
});
}
function loadAreas(facilityId, keepSelection) { function loadAreas(facilityId, keepSelection) {
areaSelect.innerHTML = '<option value="">— Whole facility —</option>'; if (!areaSel) { return; }
if (!facilityId) return; areaSel.innerHTML = '<option value="">— Whole facility —</option>';
fetch('{{ url_for('inspections.areas_for_facility', facility_id=0) }}'.replace('/0', '/' + facilityId)) if (!facilityId) { return; }
fetch(AREAS_URL + facilityId)
.then(function (r) { return r.json(); }) .then(function (r) { return r.json(); })
.then(function (areas) { .then(function (areas) {
areas.forEach(function (a) { areas.forEach(function (a) {
var opt = document.createElement('option'); var opt = document.createElement('option');
opt.value = a.id; opt.value = a.id;
opt.textContent = a.name; opt.textContent = a.name;
if (keepSelection && a.id === preselectedAreaId) opt.selected = true; if (keepSelection && a.id === preAreaId) { opt.selected = true; }
areaSelect.appendChild(opt); areaSel.appendChild(opt);
}); });
}) })
.catch(function () { /* leave the whole-facility default in place */ }); .catch(function () { /* leave the whole-facility default in place */ });
} }
facilitySelect.addEventListener('change', function () { function loadFacilities(projectId, restoreFacilityId) {
preselectedAreaId = 0; facilitySel.disabled = true;
facilitySel.innerHTML = '<option value="">Loading…</option>';
fetch(FACILITIES_URL + projectId)
.then(function (r) { return r.json(); })
.then(function (data) {
facilitySel.innerHTML = '<option value="">— Select Facility —</option>';
data.forEach(function (f) {
var opt = document.createElement('option');
opt.value = f.id;
opt.textContent = f.name;
if (restoreFacilityId && f.id === restoreFacilityId) { opt.selected = true; }
facilitySel.appendChild(opt);
});
facilitySel.disabled = false;
if (restoreFacilityId) { loadAreas(restoreFacilityId, true); }
})
.catch(function () {
facilitySel.innerHTML = '<option value="">Could not load facilities</option>';
});
}
contractSel.addEventListener('change', function () {
if (this.value) {
loadFacilities(this.value, null);
loadInspectors(this.value, null);
} else {
setPlaceholder();
}
});
facilitySel.addEventListener('change', function () {
preAreaId = 0; // a new facility invalidates the saved area
loadAreas(this.value, false); loadAreas(this.value, false);
}); });
// On edit load, refresh the area list for the saved facility and keep the saved area. // Initial state: restore the contract, facility and area on edit / re-render.
if (facilitySelect.value) loadAreas(facilitySelect.value, true); if (preProjectId) {
})(); contractSel.value = String(preProjectId);
loadFacilities(preProjectId, preFacilityId);
loadInspectors(preProjectId, preInspectorId);
} else if (preFacilityId) {
// Facility on no contract (or one the selector cannot name): keep the
// server-rendered options and the current choice rather than clearing it.
facilitySel.disabled = false;
loadAreas(String(preFacilityId), true);
if (inspectorSel) { inspectorSel.disabled = false; }
} else {
setPlaceholder();
}
}());
// ── Recurrence blocks follow the chosen frequency ───────────────────────────
// Display only — the server re-validates and clears the unused blocks on save,
// so stale values left in the DOM never take effect.
(function () {
'use strict';
var freq = document.getElementById('frequency');
var weekly = document.getElementById('weekly_block');
var monthly = document.getElementById('monthly_block');
var endRow = document.getElementById('end_date_row');
var dueLabel = document.getElementById('due_date_label');
if (!freq || !weekly || !monthly) { return; }
var domRadio = document.getElementById('month_mode_day');
var nthRadio = document.getElementById('month_mode_nth');
var domRow = document.getElementById('dom_row');
var nthRow = document.getElementById('nth_row');
var isEdit = {{ 'true' if schedule else 'false' }};
// Every frequency that repeats on a month boundary uses the monthly rule.
var MONTHLY = ['monthly', 'quarterly', 'bi-annually', 'annually'];
function syncMonthMode() {
if (!domRow || !nthRow) { return; }
var useNth = nthRadio && nthRadio.checked;
domRow.style.opacity = useNth ? '.45' : '1';
nthRow.style.opacity = useNth ? '1' : '.45';
}
function syncFrequency() {
weekly.hidden = freq.value !== 'weekly';
monthly.hidden = MONTHLY.indexOf(freq.value) === -1;
// End date is a recurring-only concept.
if (endRow) { endRow.hidden = freq.value === 'once'; }
if (dueLabel) {
dueLabel.textContent = isEdit ? 'Next Due Date'
: (freq.value === 'once' ? 'Date' : 'Start Date');
}
syncMonthMode();
}
freq.addEventListener('change', syncFrequency);
[domRadio, nthRadio].forEach(function (r) {
if (r) { r.addEventListener('change', syncMonthMode); }
});
syncFrequency();
}());
</script> </script>
{% endblock %} {% endblock %}
+110 -9
View File
@@ -1,17 +1,31 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Inspection Schedules{% endblock %} {% block title %}Inspection Schedules{% endblock %}
{% block content %} {% block content %}
{# Who may create/edit/delete a schedule — mirrors schedule_manager_required
in routes/inspection_schedules.py. 'customer' is the Customer DIRECTOR, who
plans work for their own facilities; a Customer Inspector ('external_inspector')
performs schedules and is covered by is_inspector below. #}
{% set can_manage_schedules = current_user.role in
['admin','director','project_manager','auditor','customer'] %}
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-calendar2-week"></i> Inspection Schedules</h2> <h2><i class="bi bi-calendar2-week"></i> Inspection Schedules</h2>
{% if current_user.role != 'inspector' %} {# This page is reached from the Inspections list ("Scheduled") and has no
entry of its own in the main nav, so without this button the only way back
is the browser control. #}
<div class="d-flex gap-2">
<a href="{{ url_for('inspections.index') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Inspections
</a>
{% if can_manage_schedules %}
<a href="{{ url_for('inspection_schedules.create') }}" class="btn btn-primary"> <a href="{{ url_for('inspection_schedules.create') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> New Schedule <i class="bi bi-plus-circle"></i> New Schedule
</a> </a>
{% endif %} {% endif %}
</div> </div>
</div>
<p class="text-muted small mb-4"> <p class="text-muted small mb-4">
{% if current_user.role == 'inspector' %} {% if current_user.is_inspector %}
Inspections scheduled for you. <strong>Auto</strong> schedules appear in your Inspections scheduled for you. <strong>Auto</strong> schedules appear in your
Inspections list on their own each period; <strong>Plan</strong> schedules wait Inspections list on their own each period; <strong>Plan</strong> schedules wait
for you to press Start. for you to press Start.
@@ -23,8 +37,27 @@
{% endif %} {% endif %}
</p> </p>
{# Pending / Completed tabs (phase51). The partition is on `active`, so it is
exhaustive — no schedule can fall between the two tabs. #}
<ul class="nav nav-tabs mb-0">
<li class="nav-item">
<a class="nav-link {{ 'active' if tab == 'pending' }}"
href="{{ url_for('inspection_schedules.index', tab='pending') }}">
<i class="bi bi-hourglass-split"></i> Pending
<span class="badge rounded-pill bg-{{ 'primary' if tab == 'pending' else 'secondary' }} ms-1">{{ pending_count }}</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link {{ 'active' if tab == 'completed' }}"
href="{{ url_for('inspection_schedules.index', tab='completed') }}">
<i class="bi bi-check2-circle"></i> Completed
<span class="badge rounded-pill bg-{{ 'primary' if tab == 'completed' else 'secondary' }} ms-1">{{ completed_count }}</span>
</a>
</li>
</ul>
{% if schedules %} {% if schedules %}
<div class="card shadow-sm"> <div class="card shadow-sm border-top-0 rounded-top-0">
<div class="card-body p-0"> <div class="card-body p-0">
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover mb-0 align-middle"> <table class="table table-hover mb-0 align-middle">
@@ -32,20 +65,31 @@
<tr> <tr>
<th>Name</th><th>Template</th><th>Facility / Area</th> <th>Name</th><th>Template</th><th>Facility / Area</th>
<th>Inspector</th><th>Frequency</th><th>Mode</th><th>Next Due</th> <th>Inspector</th><th>Frequency</th><th>Mode</th><th>Next Due</th>
<th>Last Run</th><th>Status</th><th width="190"></th> <th>Ends</th><th>Last Run</th><th>Status</th><th width="190"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for s in schedules %} {% for s in schedules %}
<tr class="{{ 'text-muted' if not s.active else '' }}"> <tr class="{{ 'text-muted' if not s.active else '' }}">
<td><strong>{{ s.name }}</strong></td> <td>
<strong>{{ s.name }}</strong>
{% if s.is_follow_up %}
{# phase48 — a schedule planned as the deferred twin of
"Re-inspect Now". Starting it produces a linked re-inspection. #}
<a href="{{ url_for('inspections.view', inspection_id=s.parent_inspection_id) }}"
class="badge bg-warning text-dark text-decoration-none ms-1"
title="Follow-up of inspection #{{ s.parent_inspection_id }}">
<i class="bi bi-arrow-repeat"></i> Follow-up #{{ s.parent_inspection_id }}
</a>
{% endif %}
</td>
<td>{{ s.template.name if s.template else '—' }}</td> <td>{{ s.template.name if s.template else '—' }}</td>
<td> <td>
{{ s.facility.name if s.facility else '—' }} {{ s.facility.name if s.facility else '—' }}
{% if s.area %}<span class="text-muted small">/ {{ s.area.name }}</span>{% endif %} {% if s.area %}<span class="text-muted small">/ {{ s.area.name }}</span>{% endif %}
</td> </td>
<td>{{ s.inspector.display_name if s.inspector else '—' }}</td> <td>{{ s.inspector.display_name if s.inspector else '—' }}</td>
<td><span class="badge bg-secondary">{{ s.frequency|title }}</span></td> <td><span class="badge bg-secondary">{{ s.recurrence_label }}</span></td>
<td> <td>
{% if s.mode == 'plan' %} {% if s.mode == 'plan' %}
<span class="badge bg-info text-dark" title="Inspector presses Start">Plan</span> <span class="badge bg-info text-dark" title="Inspector presses Start">Plan</span>
@@ -59,22 +103,63 @@
<span class="badge bg-danger ms-1">Overdue</span> <span class="badge bg-danger ms-1">Overdue</span>
{% endif %} {% endif %}
</td> </td>
<td class="small text-muted">
{% if s.frequency == 'once' %}—
{% elif s.end_date %}{{ s.end_date.strftime('%Y-%m-%d') }}
{% else %}No end{% endif %}
</td>
<td class="small text-muted"> <td class="small text-muted">
{{ s.last_run_at.strftime('%Y-%m-%d %H:%M') if s.last_run_at else 'Never' }} {{ s.last_run_at.strftime('%Y-%m-%d %H:%M') if s.last_run_at else 'Never' }}
</td> </td>
<td> <td>
{# "Ended" separates a schedule that reached its end date from one a
manager switched off — both are inactive, for different reasons. #}
{% if s.active %}<span class="badge bg-success">Active</span> {% if s.active %}<span class="badge bg-success">Active</span>
{% elif s.is_expired %}<span class="badge bg-dark">Ended</span>
{% else %}<span class="badge bg-secondary">Paused</span>{% endif %} {% else %}<span class="badge bg-secondary">Paused</span>{% endif %}
{# phase50 — receipt acknowledgement. Only meaningful for plan
mode: an auto schedule materialises itself, so there is no
request for anyone to receive. #}
{% if s.mode == 'plan' and s.inspector_id %}
{% if s.is_acknowledged %}
<span class="badge bg-light text-success border border-success ms-1"
title="Inspector confirmed receipt on {{ s.acknowledged_at.strftime('%b %d, %Y %I:%M %p') }}">
<i class="bi bi-check-circle"></i> Confirmed
</span>
{% elif s.active %}
<span class="badge bg-light text-warning border border-warning ms-1"
title="The assigned inspector has not confirmed receipt yet">
<i class="bi bi-hourglass-split"></i> Awaiting confirmation
</span>
{% endif %}
{% endif %}
</td> </td>
<td class="text-end"> <td class="text-end">
{# Assignee-only, like Start: a manager must not be able to confirm
on someone's behalf, since the record means "this person saw it". #}
{% if s.active and s.mode == 'plan' and not s.is_acknowledged
and s.inspector_id == current_user.id %}
<form method="POST" class="d-inline"
action="{{ url_for('inspection_schedules.acknowledge', schedule_id=s.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-success"
title="Confirm you have received this request">
<i class="bi bi-check-lg"></i> Confirm
</button>
</form>
{% endif %}
{# Start is for whoever must DO the work. A Customer Director can
plan a schedule but never execute one — the route 403s them, so
the button must not be offered either. #}
{% if s.active and s.mode == 'plan' {% if s.active and s.mode == 'plan'
and (current_user.role != 'inspector' or s.inspector_id == current_user.id) %} and current_user.role != 'customer'
and (not current_user.is_inspector or s.inspector_id == current_user.id) %}
<a href="{{ url_for('inspection_schedules.start', schedule_id=s.id) }}" <a href="{{ url_for('inspection_schedules.start', schedule_id=s.id) }}"
class="btn btn-sm btn-primary" title="Start this inspection now"> class="btn btn-sm btn-primary" title="Start this inspection now">
<i class="bi bi-play-fill"></i> Start <i class="bi bi-play-fill"></i> Start
</a> </a>
{% endif %} {% endif %}
{% if current_user.role != 'inspector' %} {% if can_manage_schedules %}
<a href="{{ url_for('inspection_schedules.edit', schedule_id=s.id) }}" <a href="{{ url_for('inspection_schedules.edit', schedule_id=s.id) }}"
class="btn btn-sm btn-outline-secondary" title="Edit"> class="btn btn-sm btn-outline-secondary" title="Edit">
<i class="bi bi-pencil"></i> <i class="bi bi-pencil"></i>
@@ -107,13 +192,29 @@
</div> </div>
</div> </div>
{% else %} {% else %}
<div class="card shadow-sm"> <div class="card shadow-sm border-top-0 rounded-top-0">
<div class="card-body text-center py-5 text-muted"> <div class="card-body text-center py-5 text-muted">
{% if tab == 'completed' %}
<i class="bi bi-check2-circle fs-1 d-block mb-3 opacity-25"></i>
<p class="mb-0">No completed schedules yet.</p>
{% elif completed_count %}
{# Nothing pending but there IS history — offer the other tab rather than
inviting them to create a duplicate of something already closed. #}
<i class="bi bi-calendar-check fs-1 d-block mb-3 opacity-25"></i>
<p class="mb-3">Nothing pending — all schedules are complete.</p>
<a href="{{ url_for('inspection_schedules.index', tab='completed') }}"
class="btn btn-outline-secondary">
<i class="bi bi-check2-circle"></i> View Completed ({{ completed_count }})
</a>
{% else %}
<i class="bi bi-calendar-x fs-1 d-block mb-3 opacity-25"></i> <i class="bi bi-calendar-x fs-1 d-block mb-3 opacity-25"></i>
<p class="mb-3">No inspection schedules configured yet.</p> <p class="mb-3">No inspection schedules configured yet.</p>
{% if can_manage_schedules %}
<a href="{{ url_for('inspection_schedules.create') }}" class="btn btn-primary"> <a href="{{ url_for('inspection_schedules.create') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Create First Schedule <i class="bi bi-plus-circle"></i> Create First Schedule
</a> </a>
{% endif %}
{% endif %}
</div> </div>
</div> </div>
{% endif %} {% endif %}
+1 -1
View File
@@ -564,7 +564,7 @@
<option value="0">— Unassigned —</option> <option value="0">— Unassigned —</option>
{% set staff = staff_for_flag_issue %} {% set staff = staff_for_flag_issue %}
{% if staff %}{% for u in staff %} {% if staff %}{% for u in staff %}
<option value="{{ u.id }}">{{ u.display_name }}</option> <option value="{{ u.id }}">{{ u.display_name }}{{ ' (Customer)' if u.is_external_inspector }}</option>
{% endfor %}{% endif %} {% endfor %}{% endif %}
</select> </select>
</div> </div>
+18 -4
View File
@@ -3,17 +3,19 @@
{% block content %} {% block content %}
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-clipboard-data"></i> Inspections</h2> <h2><i class="bi bi-clipboard-data"></i> Inspections</h2>
{% if current_user.role != 'customer' %} {# Customer Directors schedule inspections for their own facilities, so the
Scheduled link is theirs too — but starting an ad-hoc inspection is not. #}
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<a href="{{ url_for('inspection_schedules.index') }}" class="btn btn-outline-secondary"> <a href="{{ url_for('inspection_schedules.index') }}" class="btn btn-outline-secondary">
<i class="bi bi-calendar-check"></i> Scheduled <i class="bi bi-calendar-check"></i> Scheduled
</a> </a>
{% if current_user.role != 'customer' %}
<a href="{{ url_for('inspections.start') }}" class="btn btn-primary"> <a href="{{ url_for('inspections.start') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> New Inspection <i class="bi bi-plus-circle"></i> New Inspection
</a> </a>
</div>
{% endif %} {% endif %}
</div> </div>
</div>
{# Filters #} {# Filters #}
<div class="card shadow-sm mb-4"> <div class="card shadow-sm mb-4">
@@ -106,10 +108,15 @@
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-body p-0"> <div class="card-body p-0">
{% if inspections.items %} {% if inspections.items %}
{% include 'partials/bulk_inspections_toolbar.html' %}
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover mb-0"> <table class="table table-hover mb-0">
<thead class="table-light"> <thead class="table-light">
<tr> <tr>
<th style="width:34px;">
<input type="checkbox" class="form-check-input bulk-check-all"
title="Select all on this page" aria-label="Select all">
</th>
<th>#</th><th>Date</th><th>Contract</th><th>Facility</th><th>Area</th> <th>#</th><th>Date</th><th>Contract</th><th>Facility</th><th>Area</th>
<th>Template</th><th>Inspector</th><th>Score</th> <th>Template</th><th>Inspector</th><th>Score</th>
<th>Status</th><th></th> <th>Status</th><th></th>
@@ -118,6 +125,11 @@
<tbody> <tbody>
{% for ins in inspections.items %} {% for ins in inspections.items %}
<tr> <tr>
<td>
<input type="checkbox" class="form-check-input bulk-check"
form="inspectionsBulkForm" name="inspection_ids" value="{{ ins.id }}"
aria-label="Select inspection #{{ ins.id }}">
</td>
<td><small class="text-muted">#{{ ins.id }}</small></td> <td><small class="text-muted">#{{ ins.id }}</small></td>
<td>{{ ins.inspection_date.strftime('%Y-%m-%d %H:%M') }}</td> <td>{{ ins.inspection_date.strftime('%Y-%m-%d %H:%M') }}</td>
<td><small>{{ ins.facility.project.name if ins.facility and ins.facility.project else '—' }}</small></td> <td><small>{{ ins.facility.project.name if ins.facility and ins.facility.project else '—' }}</small></td>
@@ -152,9 +164,9 @@
</td> </td>
<td class="text-nowrap"> <td class="text-nowrap">
{% if ins.status == 'in_progress' or ins.status == 'flagged' %} {% if ins.status == 'in_progress' or ins.status == 'flagged' %}
<a href="{{ url_for('inspections.execute', inspection_id=ins.id) }}" class="btn btn-sm btn-outline-primary insp-list-link">Continue</a> <a href="{{ url_for('inspections.execute', inspection_id=ins.id, next=current_url()) }}" class="btn btn-sm btn-outline-primary insp-list-link">Continue</a>
{% else %} {% else %}
<a href="{{ url_for('inspections.view', inspection_id=ins.id) }}" class="btn btn-sm btn-outline-secondary insp-list-link">View</a> <a href="{{ url_for('inspections.view', inspection_id=ins.id, next=current_url()) }}" class="btn btn-sm btn-outline-secondary insp-list-link">View</a>
{% endif %} {% endif %}
{% if current_user.role in ['admin', 'director'] %} {% if current_user.role in ['admin', 'director'] %}
<button type="button" <button type="button"
@@ -216,6 +228,7 @@
</button> </button>
<form id="deleteInspectionForm" method="POST" action="" class="d-inline"> <form id="deleteInspectionForm" method="POST" action="" class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="next" value="{{ current_url() }}">
<button type="submit" class="btn btn-danger"> <button type="submit" class="btn btn-danger">
<i class="bi bi-trash3-fill"></i> Delete Permanently <i class="bi bi-trash3-fill"></i> Delete Permanently
</button> </button>
@@ -228,6 +241,7 @@
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
{% include 'partials/bulk_select_js.html' %}
<script> <script>
(function () { (function () {
'use strict'; 'use strict';
+33 -1
View File
@@ -14,8 +14,14 @@
<div class="mb-3"> <div class="mb-3">
{{ form.template_id.label(class="form-label fw-semibold") }} {{ form.template_id.label(class="form-label fw-semibold") }}
{{ form.template_id(class="form-select" + (" is-invalid" if form.template_id.errors else "")) }} {{ form.template_id(class="form-select" + (" is-invalid" if form.template_id.errors else ""), id="templateSelect") }}
{% for e in form.template_id.errors %}<div class="invalid-feedback">{{ e }}</div>{% endfor %} {% for e in form.template_id.errors %}<div class="invalid-feedback">{{ e }}</div>{% endfor %}
{# phase52 — the list shows shared forms plus the ones attached to
the selected contract, refreshed by JS when the contract changes. #}
<div class="form-text">Shows forms available on the selected contract.</div>
<div id="templateEmpty" class="form-text text-danger d-none">
No forms are available on this contract yet.
</div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
@@ -55,6 +61,8 @@
<script> <script>
(function () { (function () {
const projectSel = document.getElementById('projectSelect'); const projectSel = document.getElementById('projectSelect');
const templateSel = document.getElementById('templateSelect');
const templateEmpty = document.getElementById('templateEmpty');
const facilitySel = document.getElementById('facilitySelect'); const facilitySel = document.getElementById('facilitySelect');
const spinner = document.getElementById('facilitySpinner'); const spinner = document.getElementById('facilitySpinner');
const emptyMsg = document.getElementById('facilityEmpty'); const emptyMsg = document.getElementById('facilityEmpty');
@@ -62,6 +70,7 @@
const areaSel = document.getElementById('areaSelect'); const areaSel = document.getElementById('areaSelect');
const FACILITIES_URL = `{{ url_for('inspections.facilities_for_project', project_id=0) }}`.replace('/0', '/'); const FACILITIES_URL = `{{ url_for('inspections.facilities_for_project', project_id=0) }}`.replace('/0', '/');
const TEMPLATES_URL = `{{ url_for('inspections.templates_for_project', project_id=0) }}`.replace('/0', '/');
const AREAS_URL = `{{ url_for('inspections.areas_for_facility', facility_id=0) }}`.replace('/0', '/'); const AREAS_URL = `{{ url_for('inspections.areas_for_facility', facility_id=0) }}`.replace('/0', '/');
function loadAreas(facilityId, selectedAreaId) { function loadAreas(facilityId, selectedAreaId) {
@@ -93,6 +102,28 @@
}); });
} }
// Forms are per-contract (phase52): a customer's bespoke form must not be
// offered on another customer's facilities. Keeps the currently selected
// form if it is still valid on the new contract.
function loadTemplates(projectId) {
if (!projectId || !templateSel) return;
const keep = templateSel.value;
fetch(TEMPLATES_URL + projectId)
.then(r => r.json())
.then(data => {
templateSel.innerHTML = '';
data.forEach(t => {
const opt = document.createElement('option');
opt.value = t.id;
opt.textContent = t.name;
if (String(t.id) === keep) opt.selected = true;
templateSel.appendChild(opt);
});
templateEmpty.classList.toggle('d-none', data.length > 0);
})
.catch(() => {}); // leave the server-rendered list in place
}
function loadFacilities(projectId, selectedFacilityId, selectedAreaId) { function loadFacilities(projectId, selectedFacilityId, selectedAreaId) {
if (!projectId) return; if (!projectId) return;
spinner.classList.remove('d-none'); spinner.classList.remove('d-none');
@@ -129,6 +160,7 @@
projectSel.addEventListener('change', function () { projectSel.addEventListener('change', function () {
loadFacilities(this.value, null, null); loadFacilities(this.value, null, null);
loadTemplates(this.value);
}); });
facilitySel.addEventListener('change', function () { facilitySel.addEventListener('change', function () {
+125 -6
View File
@@ -1,6 +1,13 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Inspection #{{ inspection.id }} — Results{% endblock %} {% block title %}Inspection #{{ inspection.id }} — Results{% endblock %}
{# The filtered list URL this page was opened from. Defined at TOP LEVEL, not
inside the content block: Jinja blocks do not share scope, and the follow-up
modal below sits in the extra_js block — from there a content-scoped
back_url is Undefined and posts next="", silently dropping the very filters
this mechanism exists to preserve. #}
{% set back_url = request.args.get('next') or url_for('inspections.index') %}
{% block extra_css %} {% block extra_css %}
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&display=swap" rel="stylesheet">
<style> <style>
@@ -338,7 +345,10 @@
{# Action bar #} {# Action bar #}
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
<a id="backToInspectionsBtn" href="{{ url_for('inspections.index') }}" class="btn btn-sm btn-outline-secondary"> {# `next` carries the filtered list URL from the list page; the
sessionStorage fallback below still covers links opened before
this page started sending one. #}
<a id="backToInspectionsBtn" href="{{ back_url }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Back to Inspections <i class="bi bi-arrow-left"></i> Back to Inspections
</a> </a>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
@@ -349,13 +359,28 @@
<button onclick="window.print()" class="btn btn-sm btn-outline-secondary"> <button onclick="window.print()" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-printer"></i> Print <i class="bi bi-printer"></i> Print
</button> </button>
{% if current_user.role not in ['customer'] %} {# Offered to managers, to the inspector who did this inspection, and to
whoever the follow-up was assigned to (phase56). Not to any other
inspector who can merely SEE it: reinspect() refuses them, and showing
a button that fails on click is the mismatch this page just fixed. #}
{% if current_user.role not in ['customer']
and (is_own_inspection or owns_follow_up) %}
<a href="{{ url_for('inspections.reinspect', inspection_id=inspection.id) }}" <a href="{{ url_for('inspections.reinspect', inspection_id=inspection.id) }}"
class="btn btn-sm btn-outline-primary" class="btn btn-sm btn-outline-primary"
title="Start a follow-up re-inspection with the same template and facility"> title="Start a follow-up re-inspection with the same template and facility">
<i class="bi bi-arrow-repeat"></i> Re-inspect <i class="bi bi-arrow-repeat"></i> Re-inspect
</a> </a>
{% endif %} {% endif %}
{# phase49 — customers may REQUEST a follow-up on their own completed
inspections; only admin/director can clear one. #}
{% if current_user.role == 'customer' and inspection.status == 'completed'
and not inspection.follow_up_required %}
<button type="button" class="btn btn-sm btn-outline-warning"
data-bs-toggle="modal" data-bs-target="#followupModal"
title="Ask the team to re-inspect this facility">
<i class="bi bi-flag"></i> Request Follow-up
</button>
{% endif %}
{% if current_user.role in ['admin','director'] %} {% if current_user.role in ['admin','director'] %}
{% if not inspection.follow_up_required %} {% if not inspection.follow_up_required %}
<button type="button" class="btn btn-sm btn-outline-warning" <button type="button" class="btn btn-sm btn-outline-warning"
@@ -368,6 +393,7 @@
action="{{ url_for('inspections.clear_followup', inspection_id=inspection.id) }}" action="{{ url_for('inspections.clear_followup', inspection_id=inspection.id) }}"
class="d-inline"> class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="next" value="{{ back_url }}">
<button class="btn btn-sm btn-warning"> <button class="btn btn-sm btn-warning">
<i class="bi bi-flag-fill"></i> Clear Follow-up <i class="bi bi-flag-fill"></i> Clear Follow-up
</button> </button>
@@ -376,6 +402,7 @@
<form method="post" action="{{ url_for('inspections.delete', inspection_id=inspection.id) }}" <form method="post" action="{{ url_for('inspections.delete', inspection_id=inspection.id) }}"
onsubmit="return confirm('Delete this inspection permanently?')"> onsubmit="return confirm('Delete this inspection permanently?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="next" value="{{ back_url }}">
<button class="btn btn-sm btn-outline-danger"><i class="bi bi-trash3"></i> Delete</button> <button class="btn btn-sm btn-outline-danger"><i class="bi bi-trash3"></i> Delete</button>
</form> </form>
{% endif %} {% endif %}
@@ -388,13 +415,43 @@
<i class="bi bi-flag-fill mt-1"></i> <i class="bi bi-flag-fill mt-1"></i>
<div> <div>
<strong>Follow-up Inspection Required</strong> <strong>Follow-up Inspection Required</strong>
{# phase49 — who asked, and whether it was the client or our own staff. #}
{% if inspection.follow_up_requester %}
<span class="badge {{ 'bg-info text-dark' if inspection.follow_up_requester.role == 'customer' else 'bg-secondary' }} ms-1">
{{ 'Requested by customer' if inspection.follow_up_requester.role == 'customer' else 'Requested by staff' }}:
{{ inspection.follow_up_requester.display_name }}
</span>
{% endif %}
{% if inspection.follow_up_requested_at %}
<span class="small text-muted ms-1">{{ inspection.follow_up_requested_at.strftime('%b %d, %Y %I:%M %p') }}</span>
{% endif %}
{# Who is expected to DO it — the assignee when one was named, otherwise
the original inspector (Inspection.follow_up_owner). #}
{% if inspection.follow_up_owner %}
<div class="small mt-1">
<i class="bi bi-person-check me-1"></i>Assigned to
<strong>{{ inspection.follow_up_owner.display_name }}</strong>
{% if not inspection.follow_up_assignee %}
<span class="text-muted">(original inspector)</span>
{% elif inspection.follow_up_owner.id == current_user.id %}
<span class="badge bg-warning text-dark ms-1">You</span>
{% endif %}
</div>
{% endif %}
{% if inspection.follow_up_note %}<br><span class="small">{{ inspection.follow_up_note }}</span>{% endif %} {% if inspection.follow_up_note %}<br><span class="small">{{ inspection.follow_up_note }}</span>{% endif %}
{# Re-inspection is staff work — reinspect() already refuses customers —
and among inspectors it belongs to the follow-up's OWNER. #}
{% if current_user.role != 'customer'
and (is_own_inspection or owns_follow_up) %}
<div class="mt-2"> <div class="mt-2">
<a href="{{ url_for('inspections.reinspect', inspection_id=inspection.id) }}" <a href="{{ url_for('inspections.reinspect', inspection_id=inspection.id) }}"
class="btn btn-sm btn-warning"> class="btn btn-sm btn-warning">
<i class="bi bi-arrow-repeat me-1"></i>Start Re-inspection <i class="bi bi-arrow-repeat me-1"></i>Start Re-inspection
</a> </a>
</div> </div>
{% else %}
<div class="small mt-1">The team has been notified and will schedule the re-inspection.</div>
{% endif %}
</div> </div>
</div> </div>
{% endif %} {% endif %}
@@ -475,6 +532,13 @@
</div> </div>
</div> </div>
<div class="d-flex align-items-center gap-2"> <div class="d-flex align-items-center gap-2">
{# Parity with ST phase43: show that this run came from a schedule. Uses
MT's own column/relationship names (inspection_schedule_id). #}
{% if inspection.inspection_schedule_id %}
<span class="badge bg-info text-dark fs-6" title="Created from a scheduled inspection">
<i class="bi bi-calendar-check"></i> Scheduled{% if inspection.inspection_schedule %} · {{ inspection.inspection_schedule.recurrence_label }}{% endif %}
</span>
{% endif %}
<span class="badge bg-{{ 'success' if inspection.status == 'completed' else 'danger' if inspection.status == 'flagged' else 'secondary' }} fs-6"> <span class="badge bg-{{ 'success' if inspection.status == 'completed' else 'danger' if inspection.status == 'flagged' else 'secondary' }} fs-6">
{{ inspection.status|replace('_',' ')|title }} {{ inspection.status|replace('_',' ')|title }}
</span> </span>
@@ -508,6 +572,12 @@
<span class="lbl">Frequency</span> <span class="lbl">Frequency</span>
<span class="val">{{ inspection.template.frequency|title }}</span> <span class="val">{{ inspection.template.frequency|title }}</span>
</div> </div>
{% if inspection.inspection_schedule and inspection.inspection_schedule.creator %}
<div class="meta-item">
<span class="lbl">Scheduled By</span>
<span class="val">{{ inspection.inspection_schedule.creator.display_name }}</span>
</div>
{% endif %}
</div> </div>
{# ── Submission GPS (admin / director only) ──────────────────────────── #} {# ── Submission GPS (admin / director only) ──────────────────────────── #}
@@ -854,7 +924,11 @@
{% block extra_js %} {% block extra_js %}
<script> <script>
(function () { (function () {
var backUrl = sessionStorage.getItem('insp_list_back_url'); // A server-provided `next` is authoritative — it reflects the list this
// page was actually opened from. Only fall back to sessionStorage when
// there is none (e.g. a link created before `next` was threaded in).
var hasNext = {{ 'true' if request.args.get('next') else 'false' }};
var backUrl = hasNext ? null : sessionStorage.getItem('insp_list_back_url');
if (backUrl) { if (backUrl) {
var btn = document.getElementById('backToInspectionsBtn'); var btn = document.getElementById('backToInspectionsBtn');
if (btn) btn.href = backUrl; if (btn) btn.href = backUrl;
@@ -880,20 +954,65 @@ document.addEventListener('keydown', e => { if (e.key === 'Escape') closeMedia()
<div class="modal-dialog"> <div class="modal-dialog">
<form method="POST" action="{{ url_for('inspections.flag_followup', inspection_id=inspection.id) }}"> <form method="POST" action="{{ url_for('inspections.flag_followup', inspection_id=inspection.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="next" value="{{ back_url }}">
<div class="modal-content"> <div class="modal-content">
{% set is_cust = current_user.role == 'customer' %}
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title"><i class="bi bi-flag me-2"></i>Flag Follow-up Required</h5> <h5 class="modal-title">
<i class="bi bi-flag me-2"></i>{{ 'Request a Follow-up Inspection' if is_cust else 'Flag Follow-up Required' }}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button> <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<label class="form-label fw-semibold">Reason / Notes <span class="text-muted small">(optional)</span></label> {% if is_cust %}
<p class="small text-muted">
Ask the team to re-inspect this facility. Your request is sent to the
inspector and management right away.
</p>
{% endif %}
<label class="form-label fw-semibold">
{{ 'What still needs attention?' if is_cust else 'Reason / Notes' }}
<span class="text-muted small">(optional)</span>
</label>
<textarea name="follow_up_note" class="form-control" rows="3" <textarea name="follow_up_note" class="form-control" rows="3"
placeholder="Describe what needs to be addressed in the follow-up inspection…"></textarea> placeholder="Describe what needs to be addressed in the follow-up inspection…"></textarea>
{# ── Assign it (phase56) ────────────────────────────────────────
Optional. Left blank, the follow-up stays with whoever performed
the original inspection — the behaviour before this existed. The
list is contract-scoped in _followup_assignees_for(), so a
Customer Director only ever sees inspectors on their own
contracts. #}
{% if followup_assignees %}
<div class="mt-3">
<label class="form-label fw-semibold">
Assign to
<span class="text-muted small">(optional)</span>
</label>
<select name="follow_up_assigned_to" class="form-select">
<option value="">
— {{ inspection.inspector.display_name }} (original inspector) —
</option>
{% for u in followup_assignees %}
{% if u.id != inspection.inspector_id %}
<option value="{{ u.id }}">
{{ u.display_name }}{{ ' (Customer)' if u.is_external_inspector }}
</option>
{% endif %}
{% endfor %}
</select>
<div class="form-text">
Choose someone else to carry out the re-inspection. They are
notified and it appears in their list on the web and the iPad;
the original inspector is not asked to do it.
</div>
</div>
{% endif %}
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-warning"> <button type="submit" class="btn btn-warning">
<i class="bi bi-flag me-1"></i>Flag Follow-up <i class="bi bi-flag me-1"></i>{{ 'Send Request' if is_cust else 'Flag Follow-up' }}
</button> </button>
</div> </div>
</div> </div>
+87
View File
@@ -46,6 +46,93 @@
</div> </div>
{% endif %} {% endif %}
{# ── Handled By (phase44) — staff only; customers stay internal ── #}
{% if current_user.role != 'customer' %}
<hr class="my-3">
<p class="fw-semibold mb-2">
<i class="bi bi-person-check me-1 text-secondary"></i>Handled By
</p>
<div class="mb-3">
{{ form.handler_type(class="form-select", id="handlerTypeSelect") }}
<div class="form-text" id="handlerTypeHelp"></div>
</div>
<div id="internalHandlerFields">
<div class="mb-2">
{{ form.internal_handler_name.label(class="form-label small fw-semibold mb-1") }}
{{ form.internal_handler_name(class="form-control form-control-sm",
placeholder="Crew member handling this") }}
</div>
<div class="mb-3">
{{ form.internal_handler_contact.label(class="form-label small fw-semibold mb-1") }}
{{ form.internal_handler_contact(class="form-control form-control-sm",
placeholder="Phone or email") }}
</div>
</div>
<div id="facilityHandlerFields" style="display:none;">
<div class="mb-2">
{{ form.facility_handler_name.label(class="form-label small fw-semibold mb-1") }}
{{ form.facility_handler_name(class="form-control form-control-sm",
placeholder="Contact name at the facility") }}
</div>
<div class="mb-2">
{{ form.facility_handler_contact.label(class="form-label small fw-semibold mb-1") }}
{{ form.facility_handler_contact(class="form-control form-control-sm",
placeholder="Phone or email") }}
</div>
<div class="mb-3">
{{ form.facility_handler_notes.label(class="form-label small fw-semibold mb-1") }}
{{ form.facility_handler_notes(class="form-control form-control-sm", rows=2,
placeholder="Notes about what they are handling…") }}
</div>
</div>
<div id="vendorHandlerFields" style="display:none;">
<div class="mb-2">
{{ form.vendor_name.label(class="form-label small fw-semibold mb-1") }}
{{ form.vendor_name(class="form-control form-control-sm",
placeholder="Contractor or vendor name") }}
</div>
<div class="mb-2">
{{ form.vendor_contact.label(class="form-label small fw-semibold mb-1") }}
{{ form.vendor_contact(class="form-control form-control-sm",
placeholder="Phone or email") }}
</div>
<div class="mb-3">
{{ form.vendor_notes.label(class="form-label small fw-semibold mb-1") }}
{{ form.vendor_notes(class="form-control form-control-sm", rows=2,
placeholder="Scope, quote reference, etc.") }}
</div>
</div>
<script>
(function(){
var sel = document.getElementById('handlerTypeSelect');
var help = document.getElementById('handlerTypeHelp');
var boxes = {
internal: document.getElementById('internalHandlerFields'),
facility: document.getElementById('facilityHandlerFields'),
vendor: document.getElementById('vendorHandlerFields')
};
var notes = {
internal: {{ (issue_handler_descriptions or {}).get('internal', '')|tojson }},
facility: {{ (issue_handler_descriptions or {}).get('facility', '')|tojson }},
vendor: {{ (issue_handler_descriptions or {}).get('vendor', '')|tojson }}
};
function sync(){
var v = sel ? sel.value : 'internal';
for (var k in boxes){
if (boxes[k]) { boxes[k].style.display = (k === v) ? '' : 'none'; }
}
if (help) { help.textContent = notes[v] || ''; }
}
if (sel){ sel.addEventListener('change', sync); }
sync();
})();
</script>
{% endif %}
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<button type="submit" class="btn btn-danger">Log Issue</button> <button type="submit" class="btn btn-danger">Log Issue</button>
<a href="{{ url_for('issues.index') }}" class="btn btn-outline-secondary">Cancel</a> <a href="{{ url_for('issues.index') }}" class="btn btn-outline-secondary">Cancel</a>
+15 -3
View File
@@ -115,10 +115,15 @@
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-body p-0"> <div class="card-body p-0">
{% if issues.items %} {% if issues.items %}
{% include 'partials/bulk_issues_toolbar.html' %}
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover mb-0"> <table class="table table-hover mb-0">
<thead class="table-light"> <thead class="table-light">
<tr> <tr>
<th style="width:34px;">
<input type="checkbox" class="form-check-input bulk-check-all"
title="Select all on this page" aria-label="Select all">
</th>
<th>#</th> <th>#</th>
<th>Reported</th> <th>Reported</th>
<th>Severity</th> <th>Severity</th>
@@ -137,6 +142,11 @@
{% set is_following = issue.id in followed_ids %} {% set is_following = issue.id in followed_ids %}
{% set sla = sla_status(issue) %} {% set sla = sla_status(issue) %}
<tr class="{{ 'table-danger' if sla == 'breached' else 'table-warning' if sla == 'at_risk' else '' }}"> <tr class="{{ 'table-danger' if sla == 'breached' else 'table-warning' if sla == 'at_risk' else '' }}">
<td>
<input type="checkbox" class="form-check-input bulk-check"
form="issuesBulkForm" name="issue_ids" value="{{ issue.id }}"
aria-label="Select issue #{{ issue.id }}">
</td>
<td><small class="text-muted">#{{ issue.id }}</small></td> <td><small class="text-muted">#{{ issue.id }}</small></td>
<td><small>{{ issue.reported_at.strftime('%Y-%m-%d %H:%M') }}</small></td> <td><small>{{ issue.reported_at.strftime('%Y-%m-%d %H:%M') }}</small></td>
<td> <td>
@@ -181,7 +191,7 @@
<select class="form-select form-select-sm quick-assign-select" style="min-width:110px;font-size:.78rem;"> <select class="form-select form-select-sm quick-assign-select" style="min-width:110px;font-size:.78rem;">
<option value="">— Unassigned —</option> <option value="">— Unassigned —</option>
{% for u in staff %} {% for u in staff %}
<option value="{{ u.id }}" {{ 'selected' if issue.assigned_to == u.id }}>{{ u.display_name }}</option> <option value="{{ u.id }}" {{ 'selected' if issue.assigned_to == u.id }}>{{ u.display_name }}{{ ' (Customer)' if u.is_external_inspector }}</option>
{% endfor %} {% endfor %}
</select> </select>
<span class="quick-assign-spinner spinner-border spinner-border-sm text-secondary d-none" role="status"></span> <span class="quick-assign-spinner spinner-border spinner-border-sm text-secondary d-none" role="status"></span>
@@ -202,7 +212,7 @@
class="d-inline" class="d-inline"
title="Unfollow this issue"> title="Unfollow this issue">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="next" value="{{ url_for('issues.index', page=issues.page, issue_id=issue_id_filter, severity=severity_filter, status=status_filter, contract_id=contract_filter, facility_id=facility_filter, date_from=date_from_filter, date_to=date_to_filter, reporter_id=reporter_filter) }}"> <input type="hidden" name="next" value="{{ current_url() }}">
<button type="submit" class="btn btn-sm btn-outline-primary p-0 px-1 me-1" <button type="submit" class="btn btn-sm btn-outline-primary p-0 px-1 me-1"
title="Unfollow"> title="Unfollow">
<i class="bi bi-bell-slash" style="font-size:.75rem;"></i> <i class="bi bi-bell-slash" style="font-size:.75rem;"></i>
@@ -210,7 +220,7 @@
</form> </form>
{% endif %} {% endif %}
<a href="{{ url_for('issues.view', issue_id=issue.id) }}" <a href="{{ url_for('issues.view', issue_id=issue.id, next=current_url()) }}"
class="btn btn-sm btn-outline-secondary"> class="btn btn-sm btn-outline-secondary">
{% if current_user.role in ['admin','director','auditor'] or issue.assigned_to == current_user.id %} {% if current_user.role in ['admin','director','auditor'] or issue.assigned_to == current_user.id %}
<i class="bi bi-pencil"></i> Edit <i class="bi bi-pencil"></i> Edit
@@ -223,6 +233,7 @@
class="d-inline" class="d-inline"
onsubmit="return confirm('Permanently delete Issue #{{ issue.id }}? This cannot be undone.');"> onsubmit="return confirm('Permanently delete Issue #{{ issue.id }}? This cannot be undone.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="next" value="{{ current_url() }}">
<button type="submit" class="btn btn-sm btn-outline-danger" <button type="submit" class="btn btn-sm btn-outline-danger"
title="Delete Issue #{{ issue.id }}"> title="Delete Issue #{{ issue.id }}">
<i class="bi bi-trash"></i> <i class="bi bi-trash"></i>
@@ -259,6 +270,7 @@
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
{% include 'partials/bulk_select_js.html' %}
<script> <script>
(function () { (function () {
'use strict'; 'use strict';
+353 -54
View File
@@ -19,6 +19,30 @@
{% block content %} {% block content %}
{% set can_edit = current_user.role in ['admin','director','auditor'] or issue.assigned_to == current_user.id %} {% set can_edit = current_user.role in ['admin','director','auditor'] or issue.assigned_to == current_user.id %}
{# The filtered list URL this page was opened from (phase: filter
preservation). Threaded into every action so an update or delete
returns to the same filtered page, and used by the Back button. #}
{% set back_url = request.args.get('next') or url_for('issues.index') %}
{# Is the viewer OUR staff? Drives the internal-only chrome on this page: the
"comments are visible to everyone" warning and the per-comment
"Customer visible" / "Staff only" badges. Both are instructions about how WE
work and must never reach a customer account.
Written as an explicit ALLOWLIST of our own roles, deliberately:
* It FAILS CLOSED. The obvious form, `not current_user.is_customer_account`,
fails OPEN — if the attribute is missing for any reason (a process still
running an older models/user.py after a template-only reload, say) Jinja
yields Undefined, `not Undefined` is true, and the internal text is shown
to exactly the people it must be hidden from. An allowlist of literal role
strings can only ever be true for a role we listed.
* `external_inspector` is absent ON PURPOSE. This is NOT the rule-87 case:
rule 87 is about capability/scoping, where a Customer Inspector must
behave exactly like our own inspector. Here the question is "does this
person work for us?", which is the one place the two genuinely differ.
Do not "fix" this by adding external_inspector to the list. #}
{% set viewer_is_our_staff = current_user.role in
['admin', 'director', 'project_manager', 'auditor', 'inspector'] %}
<div class="row"> <div class="row">
{# ══════════════════════════════════ LEFT COLUMN ══════════════════════════════════ #} {# ══════════════════════════════════ LEFT COLUMN ══════════════════════════════════ #}
@@ -76,10 +100,11 @@
<dt class="col-sm-3">Assigned To</dt> <dt class="col-sm-3">Assigned To</dt>
<dd class="col-sm-9">{{ issue.assigned_user.display_name if issue.assigned_user else '— Unassigned —' }}</dd> <dd class="col-sm-9">{{ issue.assigned_user.display_name if issue.assigned_user else '— Unassigned —' }}</dd>
{% if issue.handler_type and issue.handler_type != 'internal' %} {% set _ht = issue.handler_type or 'internal' %}
{% if _ht != 'internal' or issue.internal_handler_name or issue.internal_handler_contact %}
<dt class="col-sm-3">Handled By</dt> <dt class="col-sm-3">Handled By</dt>
<dd class="col-sm-9"> <dd class="col-sm-9">
{% if issue.handler_type == 'facility' %} {% if _ht == 'facility' %}
<span class="badge bg-secondary"> <span class="badge bg-secondary">
<i class="bi bi-building me-1"></i>Facility Staff <i class="bi bi-building me-1"></i>Facility Staff
</span> </span>
@@ -92,8 +117,18 @@
{% if issue.facility_handler_notes %} {% if issue.facility_handler_notes %}
<div class="text-muted small mt-1" style="white-space:pre-wrap;">{{ issue.facility_handler_notes }}</div> <div class="text-muted small mt-1" style="white-space:pre-wrap;">{{ issue.facility_handler_notes }}</div>
{% endif %} {% endif %}
{% elif issue.handler_type == 'vendor' %} {% elif _ht == 'vendor' %}
<span class="badge bg-dark"><i class="bi bi-person-gear me-1"></i>External Vendor</span> <span class="badge bg-dark"><i class="bi bi-person-gear me-1"></i>External Vendor</span>
{% else %}
<span class="badge bg-light text-dark border">
<i class="bi bi-people me-1"></i>Janitorial Staff
</span>
{% if issue.internal_handler_name %}
<span class="ms-2 fw-semibold">{{ issue.internal_handler_name }}</span>
{% endif %}
{% if issue.internal_handler_contact %}
<span class="text-muted ms-2">{{ issue.internal_handler_contact }}</span>
{% endif %}
{% endif %} {% endif %}
</dd> </dd>
{% endif %} {% endif %}
@@ -176,6 +211,7 @@
<strong>Awaiting director verification.</strong> <strong>Awaiting director verification.</strong>
{% if current_user.role in ['admin','director','auditor'] %} {% if current_user.role in ['admin','director','auditor'] %}
<form method="POST" action="{{ url_for('issues.verify', issue_id=issue.id) }}" class="mt-2"> <form method="POST" action="{{ url_for('issues.verify', issue_id=issue.id) }}" class="mt-2">
<input type="hidden" name="next" value="{{ back_url }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-2"> <div class="mb-2">
<input type="text" name="verification_note" class="form-control form-control-sm" <input type="text" name="verification_note" class="form-control form-control-sm"
@@ -191,6 +227,84 @@
</div> </div>
</div> </div>
{# ── Linked issues ──────────────────────────────────────────────────────
Duplicates and related issues, so whoever picks this one up can reach the
others. `issue_links` arrives already filtered to links whose far end this
viewer may open (_readable_links) — do NOT add links from the model
directly here, or a customer sees an issue at a facility they have no
assignment to. Links are navigational only: nothing here changes status,
SLA, assignee or followers on either issue. #}
<div class="card shadow-sm mb-4" id="linked-issues-section">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<h6 class="mb-0">
<i class="bi bi-link-45deg me-1"></i>Linked Issues
<span class="badge bg-secondary rounded-pill ms-1">{{ issue_links|length }}</span>
</h6>
{% if can_manage_links %}
<button type="button" class="btn btn-sm btn-outline-primary"
data-bs-toggle="modal" data-bs-target="#linkIssueModal">
<i class="bi bi-plus-lg me-1"></i>Link an Issue
</button>
{% endif %}
</div>
<div class="card-body py-2">
{% if issue_links %}
<div class="list-group list-group-flush">
{% for link, other, label in issue_links %}
<div class="list-group-item px-0 py-2 d-flex align-items-start gap-2 flex-wrap">
<span class="badge {{ 'bg-warning text-dark' if link.link_type == 'duplicate' else 'bg-info text-dark' }} mt-1"
style="min-width:7.5rem;">{{ label }}</span>
<div class="flex-grow-1" style="min-width:14rem;">
<a href="{{ url_for('issues.view', issue_id=other.id, next=back_url) }}"
class="fw-semibold text-decoration-none">#{{ other.id }}</a>
<span class="text-muted small ms-1">
{{ other.area.name if other.area
else other.resolved_facility.name if other.resolved_facility else '—' }}
</span>
<div class="small text-muted text-truncate" style="max-width:38rem;">
{{ other.description }}
</div>
</div>
<div class="d-flex align-items-center gap-1 mt-1">
<span class="badge bg-{{ 'danger' if other.severity in ['critical','high']
else 'warning text-dark' if other.severity == 'medium'
else 'secondary' }}">{{ other.severity|title }}</span>
<span class="badge bg-{{ 'success' if other.status == 'resolved'
else 'info text-dark' if other.status == 'pending_verification'
else 'light text-dark' }}">
{{ other.status|replace('_',' ')|title }}
</span>
{% if can_manage_links %}
<form method="POST" class="mb-0 ms-1"
action="{{ url_for('issues.remove_link', issue_id=issue.id, link_id=link.id) }}"
onsubmit="return confirm('Remove the link between #{{ issue.id }} and #{{ other.id }}? Neither issue is changed or deleted.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="next" value="{{ back_url }}">
<button type="submit" class="btn btn-sm btn-link text-muted p-0 px-1"
title="Remove this link">
<i class="bi bi-x-lg"></i>
</button>
</form>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-muted small mb-0 py-1">
<i class="bi bi-info-circle me-1"></i>
No linked issues.
{% if can_manage_links %}
Use <strong>Link an Issue</strong> to point at a duplicate or a related issue.
{% endif %}
</p>
{% endif %}
</div>
</div>
{# ── Comments ───────────────────────────────────────────────────────── #} {# ── Comments ───────────────────────────────────────────────────────── #}
<div class="card shadow-sm mb-4" id="comments-section"> <div class="card shadow-sm mb-4" id="comments-section">
<div class="card-header bg-light d-flex justify-content-between align-items-center"> <div class="card-header bg-light d-flex justify-content-between align-items-center">
@@ -219,8 +333,12 @@
{% else %} {% else %}
<span class="badge bg-secondary" style="font-size:.65rem;">{{ c.author.role|replace('_',' ')|title }}</span> <span class="badge bg-secondary" style="font-size:.65rem;">{{ c.author.role|replace('_',' ')|title }}</span>
{% endif %} {% endif %}
{# Visibility indicator — staff only #} {# Visibility indicator — OUR staff only, and only while the
{% if current_user.role != 'customer' %} per-comment flag still decides anything. While comments_open
is set EVERY comment reaches the customer, so a "Staff only"
badge would be a lie; it is suppressed rather than shown
incorrectly. #}
{% if viewer_is_our_staff and not comments_open %}
{% if c.is_customer_visible %} {% if c.is_customer_visible %}
<span class="badge bg-success bg-opacity-10 text-success border border-success" <span class="badge bg-success bg-opacity-10 text-success border border-success"
style="font-size:.6rem;" title="Customer can see this comment"> style="font-size:.6rem;" title="Customer can see this comment">
@@ -263,6 +381,7 @@
<div class="card-body"> <div class="card-body">
<p class="fw-semibold small mb-2">Add Comment</p> <p class="fw-semibold small mb-2">Add Comment</p>
<form method="post"> <form method="post">
<input type="hidden" name="next" value="{{ back_url }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="status" value="{{ issue.status }}"> <input type="hidden" name="status" value="{{ issue.status }}">
<input type="hidden" name="assigned_to" value="{{ issue.assigned_to or 0 }}"> <input type="hidden" name="assigned_to" value="{{ issue.assigned_to or 0 }}">
@@ -270,6 +389,25 @@
<textarea name="update_notes" class="form-control" rows="3" <textarea name="update_notes" class="form-control" rows="3"
placeholder="Write a comment…" required></textarea> placeholder="Write a comment…" required></textarea>
</div> </div>
{# While comments_open is set, every comment reaches the customer, so
the "Share with customer" tick decides nothing. Saying so plainly
matters: a staff member must not write something they believe is
private. The checkbox is still posted and recorded, so turning the
setting off restores its meaning immediately.
OUR STAFF ONLY. `can_edit` is also true for a Customer Inspector
assigned to the issue, and this banner is an internal-process
warning ("do not post internal-only notes") — showing it to a
customer account exposes how we work and reads as nonsense to
them, since nothing they write was ever private. #}
{% if comments_open and viewer_is_our_staff %}
<div class="alert alert-warning py-2 px-3 small mb-2">
<i class="bi bi-eye me-1"></i>
<strong>Comments are currently visible to everyone,</strong> including
the customer, regardless of the tick below. Do not post internal-only
notes here.
</div>
{% endif %}
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2"> <div class="d-flex align-items-center justify-content-between flex-wrap gap-2">
<div class="form-check form-check-inline mb-0"> <div class="form-check form-check-inline mb-0">
<input class="form-check-input" type="checkbox" <input class="form-check-input" type="checkbox"
@@ -290,6 +428,7 @@
<div class="card-body"> <div class="card-body">
<p class="fw-semibold small mb-2">Add Comment</p> <p class="fw-semibold small mb-2">Add Comment</p>
<form method="post"> <form method="post">
<input type="hidden" name="next" value="{{ back_url }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-2"> <div class="mb-2">
<textarea name="update_notes" class="form-control" rows="3" <textarea name="update_notes" class="form-control" rows="3"
@@ -358,6 +497,7 @@
<div class="card-header bg-light"><h6 class="mb-0">Update Issue</h6></div> <div class="card-header bg-light"><h6 class="mb-0">Update Issue</h6></div>
<div class="card-body"> <div class="card-body">
<form method="post" enctype="multipart/form-data"> <form method="post" enctype="multipart/form-data">
<input type="hidden" name="next" value="{{ back_url }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3"> <div class="mb-3">
{{ form.status.label(class="form-label fw-semibold") }} {{ form.status.label(class="form-label fw-semibold") }}
@@ -416,13 +556,30 @@
placeholder="Notes about what they are handling…") }} placeholder="Notes about what they are handling…") }}
</div> </div>
</div> </div>
<div id="internalHandlerFields"
style="{{ '' if (issue.handler_type or 'internal') == 'internal' else 'display:none;' }}">
<div class="mb-2">
{{ form.internal_handler_name.label(class="form-label small fw-semibold mb-1") }}
{{ form.internal_handler_name(class="form-control form-control-sm",
placeholder="Crew member handling this",
value=issue.internal_handler_name or '') }}
</div>
<div class="mb-3">
{{ form.internal_handler_contact.label(class="form-label small fw-semibold mb-1") }}
{{ form.internal_handler_contact(class="form-control form-control-sm",
placeholder="Phone or email",
value=issue.internal_handler_contact or '') }}
</div>
</div>
<script> <script>
(function(){ (function(){
var sel = document.getElementById('handlerTypeSelect'); var sel = document.getElementById('handlerTypeSelect');
var box = document.getElementById('facilityHandlerFields'); var box = document.getElementById('facilityHandlerFields');
if(sel && box){ var inv = document.getElementById('internalHandlerFields');
if(sel){
sel.addEventListener('change', function(){ sel.addEventListener('change', function(){
box.style.display = (this.value === 'facility') ? '' : 'none'; if(box){ box.style.display = (this.value === 'facility') ? '' : 'none'; }
if(inv){ inv.style.display = (this.value === 'internal') ? '' : 'none'; }
}); });
} }
})(); })();
@@ -466,58 +623,12 @@
</div> </div>
{% endif %} {% endif %}
{# ── Vendor Work Orders (phase36) ───────────────────────────────────── #}
{% if current_user.role in ['admin','director','project_manager','auditor'] %}
<div class="card shadow-sm mt-3">
<div class="card-header bg-light">
<h6 class="mb-0"><i class="bi bi-send me-1"></i>Contractor Work Orders</h6>
</div>
<div class="card-body">
{% set wos = issue.work_orders.all() %}
{% if wos %}
<ul class="list-unstyled small mb-3">
{% for wo in wos %}
{% set b = {'sent':'secondary','acknowledged':'info','completed':'success'}[wo.status] %}
<li class="d-flex justify-content-between align-items-center border-bottom py-1">
<span class="text-truncate me-2">{{ wo.vendor_name }}
<span class="text-muted d-block" style="font-size:.75rem;">{{ wo.vendor_email }}</span>
</span>
<span class="badge bg-{{ b }}">{{ wo.status_label }}</span>
</li>
{% endfor %}
</ul>
{% endif %}
<form method="POST" action="{{ url_for('issues.dispatch_work_order', issue_id=issue.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-2">
<label class="form-label small fw-semibold mb-1">Contractor name</label>
<input type="text" name="vendor_name" class="form-control form-control-sm"
value="{{ issue.vendor_name or '' }}" placeholder="e.g. Ace Plumbing" required>
</div>
<div class="mb-2">
<label class="form-label small fw-semibold mb-1">Contractor email</label>
<input type="email" name="vendor_email" class="form-control form-control-sm"
placeholder="name@contractor.com" required>
</div>
<div class="mb-2">
<label class="form-label small fw-semibold mb-1">Message <span class="text-muted">(optional)</span></label>
<textarea name="message" class="form-control form-control-sm" rows="2"
placeholder="Any specific instructions…"></textarea>
</div>
<button type="submit" class="btn btn-outline-primary btn-sm w-100">
<i class="bi bi-envelope-paper me-1"></i> Send Work Order
</button>
<div class="form-text">Emails the contractor a private link to acknowledge &amp; complete — no account needed.</div>
</form>
</div>
</div>
{% endif %}
</div>{# /col-lg-4 #} </div>{# /col-lg-4 #}
</div> </div>
<div class="d-flex align-items-center gap-2 mt-2"> <div class="d-flex align-items-center gap-2 mt-2">
<a href="{{ url_for('issues.index') }}" class="btn btn-outline-secondary btn-sm"> <a href="{{ back_url }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left"></i> Back to Issues <i class="bi bi-arrow-left"></i> Back to Issues
</a> </a>
<a href="{{ url_for('issues.export_pdf', issue_id=issue.id) }}" class="btn btn-outline-primary btn-sm"> <a href="{{ url_for('issues.export_pdf', issue_id=issue.id) }}" class="btn btn-outline-primary btn-sm">
@@ -531,6 +642,77 @@
{% endif %} {% endif %}
</div> </div>
{# ── Link an issue ────────────────────────────────────────────────────────────
Search is scoped server-side to issues this viewer could already open, so the
picker can never be used to enumerate another contract's issues. The POST
re-checks access — the search is only a convenience. #}
{% if can_manage_links %}
<div class="modal fade" id="linkIssueModal" tabindex="-1"
aria-labelledby="linkIssueModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<form method="POST" action="{{ url_for('issues.add_link', issue_id=issue.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="next" value="{{ back_url }}">
<div class="modal-header">
<h5 class="modal-title" id="linkIssueModalLabel">
<i class="bi bi-link-45deg me-1"></i>Link an issue to #{{ issue.id }}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label small fw-semibold" for="linkTypeSelect">
How are they related?
</label>
<select name="link_type" id="linkTypeSelect" class="form-select form-select-sm">
{% for value, label in link_types %}
<option value="{{ value }}">
#{{ issue.id }} is a <strong>{{ label|lower }}</strong>
</option>
{% endfor %}
</select>
<div class="form-text">
Linking is for navigation only — neither issue's status, SLA or
assignee changes.
</div>
</div>
<div class="mb-2">
<label class="form-label small fw-semibold" for="linkIssueSearch">
Which issue?
</label>
<input type="text" class="form-control form-control-sm" id="linkIssueSearch"
autocomplete="off" placeholder="Issue number, or words from the description…">
<input type="hidden" name="linked_issue_id" id="linkIssueId">
</div>
{# Chosen issue, shown once picked so nobody submits a mistyped number #}
<div id="linkIssueChosen" class="alert alert-primary py-2 small d-none mb-2">
<span id="linkIssueChosenText"></span>
<button type="button" class="btn btn-sm btn-link p-0 ms-2" id="linkIssueClear">change</button>
</div>
<div id="linkIssueResults" class="list-group small" style="max-height:16rem; overflow-y:auto;"></div>
<div id="linkIssueEmpty" class="text-muted small d-none py-2">
No matching issue you can access.
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary btn-sm" id="linkIssueSubmit" disabled>
<i class="bi bi-link-45deg me-1"></i>Link Issue
</button>
</div>
</form>
</div>
</div>
</div>
{% endif %}
{% if current_user.role in ['admin', 'director'] %} {% if current_user.role in ['admin', 'director'] %}
<div class="modal fade" id="deleteIssueModal" tabindex="-1" aria-labelledby="deleteIssueModalLabel" aria-hidden="true"> <div class="modal fade" id="deleteIssueModal" tabindex="-1" aria-labelledby="deleteIssueModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered"> <div class="modal-dialog modal-dialog-centered">
@@ -555,6 +737,7 @@
<i class="bi bi-x-circle"></i> Cancel <i class="bi bi-x-circle"></i> Cancel
</button> </button>
<form method="POST" action="{{ url_for('issues.delete', issue_id=issue.id) }}" class="d-inline"> <form method="POST" action="{{ url_for('issues.delete', issue_id=issue.id) }}" class="d-inline">
<input type="hidden" name="next" value="{{ back_url }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-danger"> <button type="submit" class="btn btn-danger">
<i class="bi bi-trash-fill"></i> Delete Permanently <i class="bi bi-trash-fill"></i> Delete Permanently
@@ -585,6 +768,122 @@
section.scrollIntoView({ behavior: 'smooth', block: 'start' }); section.scrollIntoView({ behavior: 'smooth', block: 'start' });
} }
} }
// ── Link-an-issue picker ────────────────────────────────────────────────
// Type a number or some words, pick from the scoped results, submit. The
// hidden linked_issue_id is only ever set by CHOOSING a result, so the
// number posted is always one the server just confirmed this user can see.
//
// Every result field is written with textContent / createTextNode, never
// innerHTML: `description` is text a person typed and would otherwise be
// an XSS hole straight into the page of whoever opens the picker.
var linkSearch = document.getElementById('linkIssueSearch');
if (linkSearch) {
var linkResults = document.getElementById('linkIssueResults');
var linkEmpty = document.getElementById('linkIssueEmpty');
var linkIdField = document.getElementById('linkIssueId');
var linkChosen = document.getElementById('linkIssueChosen');
var linkChosenText = document.getElementById('linkIssueChosenText');
var linkClear = document.getElementById('linkIssueClear');
var linkSubmit = document.getElementById('linkIssueSubmit');
var searchTimer = null;
var searchSeq = 0;
function clearChoice() {
linkIdField.value = '';
linkSubmit.disabled = true;
linkChosen.classList.add('d-none');
linkSearch.classList.remove('d-none');
}
function choose(item) {
linkIdField.value = item.id;
linkSubmit.disabled = false;
linkChosenText.textContent =
'#' + item.id + ' — ' + item.location + ' — ' + item.description;
linkChosen.classList.remove('d-none');
linkSearch.classList.add('d-none');
linkResults.innerHTML = '';
linkEmpty.classList.add('d-none');
}
function renderResults(items) {
linkResults.innerHTML = '';
linkEmpty.classList.toggle('d-none', items.length > 0);
items.forEach(function (item) {
var row = document.createElement('button');
row.type = 'button';
row.className = 'list-group-item list-group-item-action py-2';
var head = document.createElement('div');
head.className = 'd-flex justify-content-between gap-2';
var num = document.createElement('span');
num.className = 'fw-semibold';
num.textContent = '#' + item.id + ' · ' + item.location;
var meta = document.createElement('span');
meta.className = 'text-muted';
meta.textContent = item.severity + ' · ' + item.status +
(item.reported_at ? ' · ' + item.reported_at : '');
head.appendChild(num);
head.appendChild(meta);
var desc = document.createElement('div');
desc.className = 'text-muted text-truncate';
desc.textContent = item.description;
row.appendChild(head);
row.appendChild(desc);
row.addEventListener('click', function () { choose(item); });
linkResults.appendChild(row);
});
}
function runSearch() {
var term = linkSearch.value.trim();
if (!term) {
linkResults.innerHTML = '';
linkEmpty.classList.add('d-none');
return;
}
// Responses can arrive out of order; only the newest one may render.
var seq = ++searchSeq;
fetch('{{ url_for("issues.link_search", issue_id=issue.id) }}?q=' +
encodeURIComponent(term), { headers: { 'Accept': 'application/json' } })
.then(function (res) { return res.ok ? res.json() : { results: [] }; })
.then(function (data) {
if (seq !== searchSeq) { return; }
renderResults(data.results || []);
})
.catch(function () {
if (seq !== searchSeq) { return; }
renderResults([]);
});
}
linkSearch.addEventListener('input', function () {
clearTimeout(searchTimer);
searchTimer = setTimeout(runSearch, 250);
});
// The picker lives inside a form — Enter would submit it with no issue
// chosen instead of searching.
linkSearch.addEventListener('keydown', function (ev) {
if (ev.key === 'Enter') {
ev.preventDefault();
clearTimeout(searchTimer);
runSearch();
}
});
linkClear.addEventListener('click', function () {
clearChoice();
linkSearch.value = '';
linkSearch.focus();
});
}
})(); })();
</script> </script>
{% endblock %} {% endblock %}
+564
View File
@@ -0,0 +1,564 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<!-- iOS / iPadOS web app meta tags -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="mobile-web-app-capable" content="yes">
<title>{% block title %}Janitorial QC System{% endblock %}</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">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&display=swap">
<link rel="stylesheet" href="{{ url_for('static', filename='css/theme.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/ipad_responsive.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/mobile_phone.css') }}">
{% if tenant_branding %}
<style>
:root {
--bs-primary: {{ tenant_branding.primary_color or '#1a56db' }};
--bs-primary-rgb: {{ tenant_branding.primary_color|hex_to_rgb if tenant_branding.primary_color else '26,86,219' }};
--jqc-accent: {{ tenant_branding.accent_color or '#16a34a' }};
}
.bg-primary { background-color: {{ tenant_branding.primary_color or '#1a56db' }} !important; }
.btn-primary { background-color: {{ tenant_branding.primary_color or '#1a56db' }} !important;
border-color: {{ tenant_branding.primary_color or '#1a56db' }} !important; }
</style>
{% endif %}
{% block extra_css %}{% endblock %}
<style>
/* ── Notification bell styles ── */
.notif-bell-wrapper { position: relative; }
.notif-badge {
position: absolute;
top: 2px; right: 2px;
font-size: 0.6rem;
min-width: 16px; height: 16px; line-height: 16px;
padding: 0 4px; border-radius: 8px;
pointer-events: none;
}
.notif-dropdown {
width: 380px;
max-height: 520px;
overflow-y: auto;
padding: 0;
}
.notif-item {
border-left: 3px solid transparent;
transition: background 0.15s;
cursor: pointer;
}
.notif-item.unread {
border-left-color: #0d6efd;
background-color: #f0f6ff;
}
.notif-item:hover { background-color: #e8f0fe; }
.notif-title { font-size: 0.85rem; font-weight: 600; margin-bottom: 2px; }
.notif-body { font-size: 0.78rem; color: #555; white-space: normal; }
.notif-time { font-size: 0.7rem; color: #999; }
.notif-empty { padding: 24px; text-align: center; color: #aaa; font-size: 0.85rem; }
/* ── Active nav tab ── */
.navbar-dark .navbar-nav .nav-link.active {
background-color: rgba(255, 255, 255, 0.18);
color: #ffffff !important;
border-radius: 6px;
font-weight: 600;
box-shadow: inset 0 -2px 0 rgba(255,255,255,0.6);
}
.navbar-dark .navbar-nav .nav-link:not(.active):hover {
background-color: rgba(255, 255, 255, 0.08);
border-radius: 6px;
}
</style>
</head>
<body>
{% if current_user.is_authenticated %}
<nav class="navbar navbar-expand-xxl navbar-dark bg-primary">
<div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('dashboard.index') }}">
{% if tenant_branding and tenant_branding.logo_url %}
<img src="{{ media_url(tenant_branding.logo_url) }}"
alt="{{ tenant_branding.display_name }}"
style="max-height:32px; border-radius:4px; margin-right:.35rem;">
{% else %}
<i class="bi bi-clipboard-check"></i>
{% endif %}
{{ tenant_branding.display_name if tenant_branding else 'Janitorial QC' }}
</a>
<!-- ── Bell + toggler always visible on mobile/tablet ── -->
<div class="d-flex align-items-center gap-2 ms-auto me-2 d-xxl-none">
<!-- Notification bell (always visible) -->
<div class="dropdown">
<a class="nav-link position-relative notif-bell-wrapper text-white"
href="#"
id="notifDropdownMobile"
role="button"
data-bs-toggle="dropdown"
aria-expanded="false"
title="Notifications">
<i class="bi bi-bell fs-5"></i>
{% if unread_notification_count > 0 %}
<span class="badge bg-danger notif-badge" id="notif-count-badge-mobile">
{{ unread_notification_count if unread_notification_count <= 99 else '99+' }}
</span>
{% else %}
<span class="badge bg-danger notif-badge d-none" id="notif-count-badge-mobile"></span>
{% endif %}
</a>
<div class="dropdown-menu dropdown-menu-end notif-dropdown shadow"
id="notif-dropdown-menu-mobile">
<div class="d-flex justify-content-between align-items-center px-3 py-2 border-bottom">
<span class="fw-semibold" style="font-size:.9rem;">Notifications</span>
<button class="btn btn-link btn-sm p-0 text-muted text-decoration-none mark-all-read-btn"
style="font-size:.75rem;">Mark all as read</button>
</div>
<div class="notif-list-mobile">
<div class="notif-empty">Loading…</div>
</div>
<div class="border-top d-flex justify-content-between px-3 py-2" style="font-size:.8rem;">
<a href="{{ url_for('notifications.index') }}" class="text-decoration-none">
<i class="bi bi-list-ul me-1"></i>View all
</a>
<a href="{{ url_for('notifications.preferences') }}" class="text-decoration-none text-muted">
<i class="bi bi-gear me-1"></i>Preferences
</a>
</div>
</div>
</div>
</div>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('dashboard.') }}" href="{{ url_for('dashboard.index') }}">Dashboard</a>
</li>
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and (request.endpoint.startswith('reports.') or request.endpoint.startswith('scheduled_reports.')) }}" href="{{ url_for('reports.index') }}">Reports</a>
</li>
{% if current_user.role in ['admin', 'director', 'project_manager', 'auditor'] %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('projects.') }}" href="{{ url_for('projects.index') }}">Contracts</a>
</li>
{% endif %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('facilities.') }}" href="{{ url_for('facilities.list_facilities') }}">Facilities</a>
</li>
{% if current_user.role in ['admin', 'director'] %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('templates.') }}" href="{{ url_for('templates.index') }}">Templates</a>
</li>
{% endif %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('inspections.') }}" href="{{ url_for('inspections.index') }}">Inspections</a>
</li>
{% if current_user.role in ['admin', 'director', 'project_manager', 'auditor'] %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('inspection_schedules.') }}" href="{{ url_for('inspection_schedules.index') }}">Schedules</a>
</li>
{% endif %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('issues.') and request.endpoint != 'issues.verification_queue' }}" href="{{ url_for('issues.index') }}">Issues</a>
</li>
{% if current_user.role in ['admin', 'director', 'auditor'] %}
<li class="nav-item">
<a class="nav-link d-flex align-items-center gap-1 {{ 'active' if request.endpoint == 'issues.verification_queue' }}"
href="{{ url_for('issues.verification_queue') }}">
Verify
{% if pending_verification_count and pending_verification_count > 0 %}
<span class="badge bg-info text-dark"
style="font-size:.65rem;line-height:1;">
{{ pending_verification_count }}
</span>
{% endif %}
</a>
</li>
{% endif %}
{% if current_user.role in ['admin', 'director'] %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('customers.') }}" href="{{ url_for('customers.index') }}">Customers</a>
</li>
{% endif %}
{% if current_user.role in ['admin', 'director'] %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('support.') }}"
href="{{ url_for('support.admin_tickets') }}">
Support
{% if open_support_tickets_count > 0 %}
<span class="badge bg-danger ms-1">{{ open_support_tickets_count }}</span>
{% endif %}
</a>
</li>
{% endif %}
{# Both customer-side roles get the Support menu — a Customer
Inspector works at the customer's facilities and has the
same questions. The assistant answers them for their own
role (see _role addendum in routes/support.py). #}
{% if current_user.is_customer_account %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle {{ 'active' if request.endpoint and request.endpoint.startswith('support.') }}"
href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-chat-dots me-1"></i>Support
</a>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item" href="{{ url_for('support.chat') }}">
<i class="bi bi-chat-dots me-2"></i>Ask a Question
</a>
</li>
<li>
<a class="dropdown-item" href="{{ url_for('support.my_conversations') }}">
<i class="bi bi-clock-history me-2"></i>My Conversations
</a>
</li>
<li>
<a class="dropdown-item" href="{{ url_for('support.my_tickets') }}">
<i class="bi bi-inbox me-2"></i>My Requests
</a>
</li>
</ul>
</li>
{% endif %}
{% if current_user.role == 'admin' %}
{% set admin_active = request.endpoint and (
(request.endpoint.startswith('auth.') and 'user' in request.endpoint)
or request.endpoint.startswith('audit.')
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('tenant_settings.')
) %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle {{ 'active' if admin_active }}"
href="#" id="adminMenu" role="button"
data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-sliders me-1"></i>Admin
</a>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="adminMenu">
<li>
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('auth.') and 'user' in request.endpoint }}"
href="{{ url_for('auth.list_users') }}">
<i class="bi bi-people me-2"></i>Users
</a>
</li>
<li>
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('audit.') }}"
href="{{ url_for('audit.index') }}">
<i class="bi bi-clipboard-data me-2"></i>Audit Trail
</a>
</li>
<li>
<a class="dropdown-item {{ 'active' if request.endpoint == 'auth.notification_matrix' }}"
href="{{ url_for('auth.notification_matrix') }}">
<i class="bi bi-grid-3x3-gap-fill me-2"></i>Notification Matrix
</a>
</li>
<li>
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('broadcast.') }}"
href="{{ url_for('broadcast.index') }}">
<i class="bi bi-megaphone-fill me-2"></i>Broadcast
</a>
</li>
<li>
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('devices.') }}"
href="{{ url_for('devices.index') }}">
<i class="bi bi-tablet me-2"></i>Devices
</a>
</li>
<li>
{# The enrollment intake form is public (no login);
its submissions are read here. Admin-only. #}
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('enrollment.') }}"
href="{{ url_for('enrollment.admin_list') }}">
<i class="bi bi-person-plus-fill me-2"></i>Enrollment Forms
</a>
</li>
<li><hr class="dropdown-divider"></li>
<li>
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('tenant_settings.') }}"
href="{{ url_for('tenant_settings.branding') }}">
<i class="bi bi-gear me-2"></i>Workspace Settings
</a>
</li>
</ul>
</li>
{% endif %}
</ul>
<ul class="navbar-nav align-items-center">
<!-- ── Notification Bell (desktop lg+ only) ── -->
<li class="nav-item dropdown me-2 d-none d-xxl-block">
<a class="nav-link position-relative notif-bell-wrapper"
href="#"
id="notifDropdown"
role="button"
data-bs-toggle="dropdown"
aria-expanded="false"
title="Notifications">
<i class="bi bi-bell fs-5"></i>
{% if unread_notification_count > 0 %}
<span class="badge bg-danger notif-badge" id="notif-count-badge">
{{ unread_notification_count if unread_notification_count <= 99 else '99+' }}
</span>
{% else %}
<span class="badge bg-danger notif-badge d-none" id="notif-count-badge"></span>
{% endif %}
</a>
<div class="dropdown-menu dropdown-menu-end notif-dropdown shadow"
id="notif-dropdown-menu">
<!-- Header -->
<div class="d-flex justify-content-between align-items-center
px-3 py-2 border-bottom">
<span class="fw-semibold" style="font-size:.9rem;">Notifications</span>
<button class="btn btn-link btn-sm p-0 text-muted text-decoration-none"
id="mark-all-read-btn" style="font-size:.75rem;">
Mark all as read
</button>
</div>
<!-- Items -->
<div id="notif-list">
<div class="notif-empty">Loading…</div>
</div>
<!-- Footer -->
<div class="border-top d-flex justify-content-between px-3 py-2"
style="font-size:.8rem;">
<a href="{{ url_for('notifications.index') }}"
class="text-decoration-none">
<i class="bi bi-list-ul me-1"></i>View all
</a>
<a href="{{ url_for('notifications.preferences') }}"
class="text-decoration-none text-muted">
<i class="bi bi-gear me-1"></i>Preferences
</a>
</div>
</div>
</li>
<!-- ── End Notification Bell ── -->
<!-- User menu -->
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown"
role="button" data-bs-toggle="dropdown">
<i class="bi bi-person-circle"></i> {{ current_user.username }}
</a>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<a class="dropdown-item"
href="{{ url_for('auth.profile') }}">
<i class="bi bi-person-circle me-1"></i>My Profile
</a>
</li>
<li><hr class="dropdown-divider"></li>
<li>
<a class="dropdown-item"
href="{{ url_for('notifications.preferences') }}">
<i class="bi bi-bell-slash me-1"></i>Notification Preferences
</a>
</li>
<li><hr class="dropdown-divider"></li>
{# MT-16 — opt in to the sidebar design. POST so the
switch is not a GET side effect; `next` returns the
user to the page they were on. #}
<li>
<form method="POST" action="{{ url_for('ui.switch_theme') }}" class="px-0">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="theme" value="modern">
<input type="hidden" name="next" value="{{ request.full_path }}">
<button type="submit" class="dropdown-item">
<i class="bi bi-stars me-1"></i>Try the New Design
</button>
</form>
</li>
{% if current_user.role == 'admin' %}
<li>
<a class="dropdown-item" href="{{ url_for('ui.theme_votes') }}">
<i class="bi bi-bar-chart me-1"></i>Design Vote Tally
</a>
</li>
{% endif %}
<li><hr class="dropdown-divider"></li>
<li>
<a class="dropdown-item" href="{{ url_for('auth.logout') }}">
<i class="bi bi-box-arrow-right me-1"></i>Logout
</a>
</li>
</ul>
</li>
</ul>
</div>
</div>
</nav>
{% endif %}
<div class="container-fluid mt-4">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% include 'billing/_billing_banner.html' %}
{% block content %}{% endblock %}
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
{% block extra_js %}{% endblock %}
{% if current_user.is_authenticated %}
<script>
(function () {
'use strict';
const FEED_URL = '{{ url_for("notifications.feed") }}';
const MARK_READ_BASE = '/notifications/';
const MARK_ALL_URL = '{{ url_for("notifications.mark_all_read") }}';
const CSRF_TOKEN = '{{ csrf_token() }}';
const POLL_INTERVAL = 60000; // 60 seconds
// ── Element refs — desktop bell (lg+) and mobile/tablet bell (<lg)
const badgeDesktop = document.getElementById('notif-count-badge');
const badgeMobile = document.getElementById('notif-count-badge-mobile');
const listDesktop = document.getElementById('notif-list');
const listMobile = document.querySelector('.notif-list-mobile');
// ── Update both badge instances ────────────────────────────────────────
function updateBadge(count) {
[badgeDesktop, badgeMobile].forEach(function(badge) {
if (!badge) return;
if (count > 0) {
badge.textContent = count > 99 ? '99+' : count;
badge.classList.remove('d-none');
} else {
badge.textContent = '';
badge.classList.add('d-none');
}
});
}
// ── Render notification items into a given container ───────────────────
function renderInto(container, notifications) {
if (!container) return;
if (!notifications.length) {
container.innerHTML = '<div class="notif-empty">'
+ '<i class="bi bi-check2-circle me-1"></i>You\'re all caught up!</div>';
return;
}
container.innerHTML = notifications.map(function(n) {
return '<div class="d-block text-decoration-none text-dark notif-item px-3 py-2 border-bottom '
+ (n.is_read ? '' : 'unread') + '"'
+ ' data-notif-id="' + n.id + '"'
+ ' data-link="' + escapeAttr(n.link || '') + '">'
+ '<div class="notif-title">' + escapeHtml(n.title) + '</div>'
+ '<div class="notif-body">' + escapeHtml(n.body) + '</div>'
+ '<div class="notif-time">' + escapeHtml(n.created_at) + '</div>'
+ '</div>';
}).join('');
container.querySelectorAll('.notif-item').forEach(function(el) {
el.addEventListener('click', function() {
var id = this.dataset.notifId;
var link = this.dataset.link;
markRead(id, function() {
el.classList.remove('unread');
if (link) window.location.href = link;
});
});
});
}
function renderNotifications(notifications) {
renderInto(listDesktop, notifications);
renderInto(listMobile, notifications);
}
function escapeHtml(str) {
if (!str) return '';
return str.replace(/&/g,'&amp;').replace(/</g,'&lt;')
.replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function escapeAttr(str) { return escapeHtml(str); }
// ── Fetch + update ─────────────────────────────────────────────────────
window.fetchNotifications = function fetchNotifications() {
fetch(FEED_URL, { credentials: 'same-origin' })
.then(function(r) { return r.json(); })
.then(function(data) {
updateBadge(data.unread_count);
window._jqcNotifications = data.notifications;
var deskEl = document.getElementById('notifDropdown');
var mobileEl = document.getElementById('notifDropdownMobile');
var deskOpen = deskEl && deskEl.getAttribute('aria-expanded') === 'true';
var mobileOpen = mobileEl && mobileEl.getAttribute('aria-expanded') === 'true';
if (deskOpen || mobileOpen) {
renderNotifications(data.notifications);
}
})
.catch(function() {});
};
function markRead(id, callback) {
fetch(MARK_READ_BASE + id + '/mark-read', {
method: 'POST',
headers: { 'X-CSRFToken': CSRF_TOKEN, 'Content-Type': 'application/json' },
credentials: 'same-origin',
})
.then(function(r) { return r.json(); })
.then(function() { if (callback) callback(); fetchNotifications(); })
.catch(function() { if (callback) callback(); });
}
// ── Show dropdown → render cached data immediately ─────────────────────
['notifDropdown', 'notifDropdownMobile'].forEach(function(id) {
var el = document.getElementById(id);
if (!el) return;
el.addEventListener('show.bs.dropdown', function() {
if (window._jqcNotifications) {
renderNotifications(window._jqcNotifications);
} else {
fetchNotifications();
}
});
});
// ── Mark all read — works from either bell ─────────────────────────────
document.querySelectorAll('#mark-all-read-btn, .mark-all-read-btn').forEach(function(btn) {
btn.addEventListener('click', function(e) {
e.stopPropagation();
fetch(MARK_ALL_URL, {
method: 'POST',
headers: {
'X-CSRFToken': CSRF_TOKEN,
'X-Requested-With': 'XMLHttpRequest',
},
credentials: 'same-origin',
})
.then(function(r) { return r.json(); })
.then(function() {
updateBadge(0);
document.querySelectorAll('.notif-item.unread').forEach(function(el) {
el.classList.remove('unread');
});
if (window._jqcNotifications) {
window._jqcNotifications.forEach(function(n) { n.is_read = true; });
}
})
.catch(function() {});
});
});
fetchNotifications();
setInterval(fetchNotifications, POLL_INTERVAL);
})();
</script>
{% endif %}
</body>
</html>
+503
View File
@@ -0,0 +1,503 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<!-- iOS / iPadOS web app meta tags -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="mobile-web-app-capable" content="yes">
<title>{% block title %}Janitorial QC System{% endblock %}</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">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700;800&display=swap">
<link rel="stylesheet" href="{{ url_for('static', filename='css/theme.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/ipad_responsive.css') }}">
{# theme_modern.css loads LAST so it wins over theme.css tokens #}
<link rel="stylesheet" href="{{ url_for('static', filename='css/theme_modern.css') }}">
{# MT: per-tenant branding overrides, mirrored from layouts/classic.html.
Loaded AFTER theme_modern.css so a tenant's colours win over the modern
palette; the modern layout/structure is unaffected. #}
{% if tenant_branding %}
<style>
:root {
--bs-primary: {{ tenant_branding.primary_color or '#1a56db' }};
--bs-primary-rgb: {{ tenant_branding.primary_color|hex_to_rgb if tenant_branding.primary_color else '26,86,219' }};
--jqc-accent: {{ tenant_branding.accent_color or '#16a34a' }};
--jqc-brand: {{ tenant_branding.primary_color or '#1a56db' }};
}
.bg-primary { background-color: {{ tenant_branding.primary_color or '#1a56db' }} !important; }
.btn-primary { background-color: {{ tenant_branding.primary_color or '#1a56db' }} !important;
border-color: {{ tenant_branding.primary_color or '#1a56db' }} !important; }
</style>
{% endif %}
{% block extra_css %}{% endblock %}
<style>
/* ── Notification bell styles (shared with the classic layout) ── */
.notif-bell-wrapper { position: relative; }
.notif-badge {
position: absolute;
top: 2px; right: 2px;
font-size: 0.6rem;
min-width: 16px; height: 16px; line-height: 16px;
padding: 0 4px; border-radius: 8px;
pointer-events: none;
}
.notif-dropdown {
width: 380px;
max-height: 520px;
overflow-y: auto;
padding: 0;
}
.notif-item {
border-left: 3px solid transparent;
transition: background 0.15s;
cursor: pointer;
}
.notif-item.unread {
border-left-color: var(--jqc-brand);
background-color: #f0f6fa;
}
.notif-item:hover { background-color: #e9f0f8; }
.notif-title { font-size: 0.85rem; font-weight: 600; margin-bottom: 2px; }
.notif-body { font-size: 0.78rem; color: #555; white-space: normal; }
.notif-time { font-size: 0.7rem; color: #999; }
.notif-empty { padding: 24px; text-align: center; color: #aaa; font-size: 0.85rem; }
/* ── Shared list-page filter panel (modern tint) ── */
.filter-panel {
background: #ffffff;
border: 1px solid var(--jqc-border);
border-left: 4px solid var(--jqc-brand);
border-radius: 14px;
}
.filter-panel .filter-title {
font-weight: 700;
font-size: .82rem;
letter-spacing: .03em;
text-transform: uppercase;
color: var(--jqc-brand);
}
.filter-panel .form-label {
font-weight: 600;
color: #3f4652;
}
.filter-panel .form-control,
.filter-panel .form-select {
border: 1.5px solid #cfd9e0;
background-color: #ffffff;
}
.filter-panel .form-control:focus,
.filter-panel .form-select:focus {
border-color: var(--jqc-brand);
box-shadow: 0 0 0 .18rem rgba(21, 95, 130, .20);
}
.filter-panel .form-control::placeholder { color: #9aa4b2; }
</style>
</head>
<body class="jqc-modern">
{% if current_user.is_authenticated %}
<!-- ══════════════════════════ TOP BAR ══════════════════════════ -->
<header class="jqc-topbar">
<button class="jqc-hamburger d-lg-none" type="button" id="jqcSidebarToggle" aria-label="Menu">
<i class="bi bi-list"></i>
</button>
<a class="jqc-brand" href="{{ url_for('dashboard.index') }}">
{% if tenant_branding and tenant_branding.logo_url %}
<img src="{{ media_url(tenant_branding.logo_url) }}"
alt="{{ tenant_branding.display_name }}" class="jqc-brand-logo">
{% else %}
<span class="jqc-brand-mark">JQC</span>
{% endif %}
<span class="jqc-brand-sub">
{{ tenant_branding.display_name if tenant_branding else 'Janitorial QC' }}
</span>
</a>
{# Scoped to inspection ID only — the placeholder says so explicitly so
nobody types a facility name and assumes the search is broken. #}
<form class="jqc-search" method="GET" action="{{ url_for('inspections.index') }}" role="search">
<i class="bi bi-search"></i>
<input type="search" name="inspection_id" class="form-control" inputmode="numeric"
placeholder="Inspection # (e.g. 1423)" aria-label="Search by inspection number"
title="Search by inspection number">
</form>
<div class="jqc-topbar-actions">
<!-- ── Notification Bell ── -->
<div class="dropdown">
<a class="jqc-icon-btn position-relative notif-bell-wrapper"
href="#"
id="notifDropdown"
role="button"
data-bs-toggle="dropdown"
aria-expanded="false"
title="Notifications">
<i class="bi bi-bell"></i>
{% if unread_notification_count > 0 %}
<span class="badge bg-danger notif-badge" id="notif-count-badge">
{{ unread_notification_count if unread_notification_count <= 99 else '99+' }}
</span>
{% else %}
<span class="badge bg-danger notif-badge d-none" id="notif-count-badge"></span>
{% endif %}
</a>
<div class="dropdown-menu dropdown-menu-end notif-dropdown shadow" id="notif-dropdown-menu">
<div class="d-flex justify-content-between align-items-center px-3 py-2 border-bottom">
<span class="fw-semibold" style="font-size:.9rem;">Notifications</span>
<button class="btn btn-link btn-sm p-0 text-muted text-decoration-none"
id="mark-all-read-btn" style="font-size:.75rem;">
Mark all as read
</button>
</div>
<div id="notif-list">
<div class="notif-empty">Loading…</div>
</div>
<div class="border-top d-flex justify-content-between px-3 py-2" style="font-size:.8rem;">
<a href="{{ url_for('notifications.index') }}" class="text-decoration-none">
<i class="bi bi-list-ul me-1"></i>View all
</a>
<a href="{{ url_for('notifications.preferences') }}" class="text-decoration-none text-muted">
<i class="bi bi-gear me-1"></i>Preferences
</a>
</div>
</div>
</div>
<!-- ── User avatar menu ── -->
<div class="dropdown">
<a class="jqc-avatar" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown"
title="{{ current_user.display_name }}">
{{ (current_user.display_name.split() | map('first') | join)[:2] | upper }}
</a>
<ul class="dropdown-menu dropdown-menu-end">
<li class="px-3 py-2 border-bottom">
<div class="fw-semibold" style="font-size:.9rem;">{{ current_user.display_name }}</div>
<div class="text-muted" style="font-size:.75rem;">{{ current_user.role_label }}</div>
</li>
<li>
<a class="dropdown-item" href="{{ url_for('auth.profile') }}">
<i class="bi bi-person-circle me-1"></i>My Profile
</a>
</li>
<li>
<a class="dropdown-item" href="{{ url_for('notifications.preferences') }}">
<i class="bi bi-bell-slash me-1"></i>Notification Preferences
</a>
</li>
{# The design A/B test is over — modern is THE design (Aug 2026).
The switcher and the vote tally are gone from this menu.
ui.switch_theme / ui.theme_votes still exist and still work
if visited directly, so nothing is stranded mid-request;
they are simply no longer offered. #}
<li><hr class="dropdown-divider"></li>
<li>
<a class="dropdown-item" href="{{ url_for('auth.logout') }}">
<i class="bi bi-box-arrow-right me-1"></i>Logout
</a>
</li>
</ul>
</div>
</div>
</header>
<!-- ══════════════════════════ SIDEBAR ══════════════════════════ -->
<aside class="jqc-sidebar" id="jqcSidebar">
<nav class="jqc-nav">
<a class="jqc-nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('dashboard.') }}"
href="{{ url_for('dashboard.index') }}">
<i class="bi bi-grid"></i><span>Dashboard</span>
</a>
<a class="jqc-nav-link {{ 'active' if request.endpoint and (request.endpoint.startswith('inspections.') or request.endpoint.startswith('inspection_schedules.')) }}"
href="{{ url_for('inspections.index') }}">
<i class="bi bi-clipboard-check"></i><span>Inspections</span>
</a>
<a class="jqc-nav-link {{ 'active' if request.endpoint and (request.endpoint.startswith('reports.') or request.endpoint.startswith('scheduled_reports.')) }}"
href="{{ url_for('reports.index') }}">
<i class="bi bi-bar-chart-fill"></i><span>Reports &amp; Analytics</span>
</a>
<a class="jqc-nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('issues.') and request.endpoint != 'issues.verification_queue' }}"
href="{{ url_for('issues.index') }}">
<i class="bi bi-exclamation-triangle"></i><span>Issues</span>
</a>
{% if current_user.role in ['admin', 'director', 'auditor'] %}
<a class="jqc-nav-link {{ 'active' if request.endpoint == 'issues.verification_queue' }}"
href="{{ url_for('issues.verification_queue') }}">
<i class="bi bi-patch-check"></i><span>Verify</span>
{% if pending_verification_count and pending_verification_count > 0 %}
<span class="jqc-nav-badge">{{ pending_verification_count }}</span>
{% endif %}
</a>
{% endif %}
{% if current_user.role in ['admin', 'director', 'project_manager', 'auditor'] %}
<a class="jqc-nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('projects.') }}"
href="{{ url_for('projects.index') }}">
<i class="bi bi-file-earmark-text"></i><span>Contract</span>
</a>
{% endif %}
<a class="jqc-nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('facilities.') }}"
href="{{ url_for('facilities.list_facilities') }}">
<i class="bi bi-buildings"></i><span>Facility</span>
</a>
{% if current_user.role in ['admin', 'director'] %}
<a class="jqc-nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('templates.') }}"
href="{{ url_for('templates.index') }}">
<i class="bi bi-list-check"></i><span>Templates</span>
</a>
<a class="jqc-nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('customers.') }}"
href="{{ url_for('customers.index') }}">
<i class="bi bi-people"></i><span>Customer</span>
</a>
{% endif %}
<a class="jqc-nav-link {{ 'active' if request.endpoint and (request.endpoint.startswith('support.') or request.endpoint == 'ui.support_center') }}"
href="{{ url_for('ui.support_center') }}">
<i class="bi bi-life-preserver"></i><span>Supports</span>
{% if open_support_tickets_count > 0 %}
<span class="jqc-nav-badge">{{ open_support_tickets_count }}</span>
{% endif %}
</a>
{% if current_user.role == 'admin' %}
{% set admin_active = request.endpoint and (
request.endpoint.startswith('audit.')
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('tenant_settings.')
or (request.endpoint.startswith('auth.') and 'user' in request.endpoint)
) %}
<a class="jqc-nav-link {{ 'active' if admin_active }}" data-bs-toggle="collapse"
href="#jqcAdminMenu" role="button" aria-expanded="{{ 'true' if admin_active else 'false' }}">
<i class="bi bi-shield-lock"></i><span>Admin</span>
<i class="bi bi-chevron-down jqc-nav-caret"></i>
</a>
<div class="collapse {{ 'show' if admin_active }}" id="jqcAdminMenu">
<a class="jqc-nav-sublink {{ 'active' if request.endpoint and request.endpoint.startswith('auth.') and 'user' in request.endpoint }}"
href="{{ url_for('auth.list_users') }}">Users</a>
<a class="jqc-nav-sublink {{ 'active' if request.endpoint and request.endpoint.startswith('audit.') }}"
href="{{ url_for('audit.index') }}">Audit Trail</a>
<a class="jqc-nav-sublink {{ 'active' if request.endpoint == 'auth.notification_matrix' }}"
href="{{ url_for('auth.notification_matrix') }}">Notification Matrix</a>
<a class="jqc-nav-sublink {{ 'active' if request.endpoint and request.endpoint.startswith('broadcast.') }}"
href="{{ url_for('broadcast.index') }}">Broadcast</a>
<a class="jqc-nav-sublink {{ 'active' if request.endpoint and request.endpoint.startswith('devices.') }}"
href="{{ url_for('devices.index') }}">Devices</a>
{# The enrollment intake form is public (no login) and its
submissions are read here. Admin-only, same as ST. #}
<a class="jqc-nav-sublink {{ 'active' if request.endpoint and request.endpoint.startswith('enrollment.') }}"
href="{{ url_for('enrollment.admin_list') }}">Enrollment Forms</a>
<a class="jqc-nav-sublink {{ 'active' if request.endpoint and request.endpoint.startswith('tenant_settings.') }}"
href="{{ url_for('tenant_settings.branding') }}">Workspace Settings</a>
</div>
{% endif %}
<a class="jqc-nav-link {{ 'active' if request.endpoint == 'ui.about' }}" href="{{ url_for('ui.about') }}">
<i class="bi bi-info-circle"></i><span>About Us</span>
</a>
</nav>
{# The design switcher was removed from the sidebar in phase50, when
modern became the default — it no longer belongs in the primary nav.
The same action still exists in the account menu (top right), so
anyone who needs the classic design can still get to it. #}
</aside>
<div class="jqc-sidebar-backdrop d-lg-none" id="jqcSidebarBackdrop"></div>
{% endif %}
<!-- ══════════════════════════ MAIN ══════════════════════════ -->
<main class="{{ 'jqc-main' if current_user.is_authenticated else '' }}">
<div class="container-fluid {{ '' if current_user.is_authenticated else 'mt-4' }}">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
{# MT: the billing banner is layout chrome in layouts/classic.html and
must render here too — a user on the modern design must not miss a
suspension or dunning notice.
_quota_warning.html is deliberately NOT included here: it is a
per-form include (user_form, issue form, facility form,
inspection start), not layout chrome. Including it globally would
render it twice on exactly those pages. #}
{% include 'billing/_billing_banner.html' %}
{% block content %}{% endblock %}
</div>
</main>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
{% block extra_js %}{% endblock %}
{% if current_user.is_authenticated %}
<script>
// ── Sidebar off-canvas toggle (mobile / tablet portrait) ──────────────
(function () {
var btn = document.getElementById('jqcSidebarToggle');
var sidebar = document.getElementById('jqcSidebar');
var backdrop = document.getElementById('jqcSidebarBackdrop');
if (!btn || !sidebar) return;
function close() {
sidebar.classList.remove('open');
if (backdrop) backdrop.classList.remove('show');
}
btn.addEventListener('click', function () {
sidebar.classList.toggle('open');
if (backdrop) backdrop.classList.toggle('show');
});
if (backdrop) backdrop.addEventListener('click', close);
})();
</script>
<script>
(function () {
'use strict';
const FEED_URL = '{{ url_for("notifications.feed") }}';
const MARK_READ_BASE = '/notifications/';
const MARK_ALL_URL = '{{ url_for("notifications.mark_all_read") }}';
const CSRF_TOKEN = '{{ csrf_token() }}';
const POLL_INTERVAL = 60000; // 60 seconds
const badgeDesktop = document.getElementById('notif-count-badge');
const listDesktop = document.getElementById('notif-list');
function updateBadge(count) {
[badgeDesktop].forEach(function(badge) {
if (!badge) return;
if (count > 0) {
badge.textContent = count > 99 ? '99+' : count;
badge.classList.remove('d-none');
} else {
badge.textContent = '';
badge.classList.add('d-none');
}
});
}
function renderInto(container, notifications) {
if (!container) return;
if (!notifications.length) {
container.innerHTML = '<div class="notif-empty">'
+ '<i class="bi bi-check2-circle me-1"></i>You\'re all caught up!</div>';
return;
}
container.innerHTML = notifications.map(function(n) {
return '<div class="d-block text-decoration-none text-dark notif-item px-3 py-2 border-bottom '
+ (n.is_read ? '' : 'unread') + '"'
+ ' data-notif-id="' + n.id + '"'
+ ' data-link="' + escapeAttr(n.link || '') + '">'
+ '<div class="notif-title">' + escapeHtml(n.title) + '</div>'
+ '<div class="notif-body">' + escapeHtml(n.body) + '</div>'
+ '<div class="notif-time">' + escapeHtml(n.created_at) + '</div>'
+ '</div>';
}).join('');
container.querySelectorAll('.notif-item').forEach(function(el) {
el.addEventListener('click', function() {
var id = this.dataset.notifId;
var link = this.dataset.link;
markRead(id, function() {
el.classList.remove('unread');
if (link) window.location.href = link;
});
});
});
}
function renderNotifications(notifications) {
renderInto(listDesktop, notifications);
}
function escapeHtml(str) {
if (!str) return '';
return str.replace(/&/g,'&amp;').replace(/</g,'&lt;')
.replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function escapeAttr(str) { return escapeHtml(str); }
window.fetchNotifications = function fetchNotifications() {
fetch(FEED_URL, { credentials: 'same-origin' })
.then(function(r) { return r.json(); })
.then(function(data) {
updateBadge(data.unread_count);
window._jqcNotifications = data.notifications;
var deskEl = document.getElementById('notifDropdown');
var deskOpen = deskEl && deskEl.getAttribute('aria-expanded') === 'true';
if (deskOpen) {
renderNotifications(data.notifications);
}
})
.catch(function() {});
};
function markRead(id, callback) {
fetch(MARK_READ_BASE + id + '/mark-read', {
method: 'POST',
headers: { 'X-CSRFToken': CSRF_TOKEN, 'Content-Type': 'application/json' },
credentials: 'same-origin',
})
.then(function(r) { return r.json(); })
.then(function() { if (callback) callback(); fetchNotifications(); })
.catch(function() { if (callback) callback(); });
}
['notifDropdown'].forEach(function(id) {
var el = document.getElementById(id);
if (!el) return;
el.addEventListener('show.bs.dropdown', function() {
if (window._jqcNotifications) {
renderNotifications(window._jqcNotifications);
} else {
fetchNotifications();
}
});
});
document.querySelectorAll('#mark-all-read-btn, .mark-all-read-btn').forEach(function(btn) {
btn.addEventListener('click', function(e) {
e.stopPropagation();
fetch(MARK_ALL_URL, {
method: 'POST',
headers: {
'X-CSRFToken': CSRF_TOKEN,
'X-Requested-With': 'XMLHttpRequest',
},
credentials: 'same-origin',
})
.then(function(r) { return r.json(); })
.then(function() {
updateBadge(0);
document.querySelectorAll('.notif-item.unread').forEach(function(el) {
el.classList.remove('unread');
});
if (window._jqcNotifications) {
window._jqcNotifications.forEach(function(n) { n.is_read = true; });
}
})
.catch(function() {});
});
});
fetchNotifications();
setInterval(fetchNotifications, POLL_INTERVAL);
})();
</script>
{% endif %}
</body>
</html>
+421
View File
@@ -0,0 +1,421 @@
{% extends "base.html" %}
{% block title %}Dashboard{% endblock %}
{#
MODERN dashboard (design A/B test — slide 1 of JQC_design).
Uses exactly the same context variables as templates/dashboard.html — the
dashboard.index route is untouched. Every tile links to the same filtered
list view the classic dashboard links to, so no navigation path is lost.
#}
{% block content %}
{# ── Header ───────────────────────────────────────────────────────────── #}
<div class="d-flex flex-wrap justify-content-between align-items-end mb-4 gap-2">
<div>
<div class="jqc-page-title">Welcome, {{ current_user.display_name }}</div>
<div class="jqc-page-sub">{{ current_user.role.replace('_',' ')|title }}</div>
</div>
<div class="text-muted">{{ now_display }}</div>
</div>
{# ── Scheduled inspections ────────────────────────────────────────────── #}
{% if current_user.role != 'customer' %}
<div class="jqc-card">
<div class="d-flex justify-content-between align-items-center mb-3">
<div class="jqc-card-title mb-0">
<span class="jqc-tile-icon"><i class="bi bi-calendar2-week"></i></span>Scheduled Inspection In Progress
</div>
<a href="{{ url_for('inspection_schedules.index') }}" class="btn btn-sm btn-outline-primary">View all</a>
</div>
{% if sched_overdue_count %}
<div class="alert alert-danger py-2">
<i class="bi bi-alarm-fill me-1"></i>
<strong>{{ sched_overdue_count }}</strong> scheduled inspection{{ 's' if sched_overdue_count != 1 }}
{{ 'are' if sched_overdue_count != 1 else 'is' }} <strong>overdue</strong>.
</div>
{% endif %}
{% if sched_upcoming %}
<div class="jqc-table-wrap table-responsive">
<table class="table table-hover align-middle">
<thead>
<tr>
<th>Facility</th><th>Inspection Template</th><th>Inspector</th>
<th>How Often</th><th>Next Due Date</th><th class="text-end"></th>
</tr>
</thead>
<tbody>
{% for s in sched_upcoming %}
<tr>
<td>{{ s.facility.name if s.facility else '—' }}</td>
<td class="small">{{ s.template.name if s.template else '—' }}</td>
<td class="small">{{ s.inspector.display_name if s.inspector else '—' }}</td>
<td class="small text-muted">{{ s.recurrence_label }}</td>
{# MT's column is next_run_at (ST calls it next_due_date) — via the
due_date property, guarded: an active schedule can carry a NULL
next_run_at, and .strftime() on Undefined/None is a 500. #}
<td class="small">{{ s.due_date.strftime('%b %d, %Y') if s.due_date else '—' }}</td>
<td class="text-end text-nowrap">
{% if s.inspector_id and s.inspector_id == current_user.id %}
{% if s.is_acknowledged %}
<span class="badge bg-success" title="You confirmed receipt"><i class="bi bi-check-circle"></i> Confirmed</span>
{% else %}
<form method="POST" class="d-inline" action="{{ url_for('inspection_schedules.acknowledge', schedule_id=s.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-success py-0"
title="Confirm you received this request">
<i class="bi bi-check-lg"></i> Confirm</button>
</form>
{% endif %}
{% set open_id = sched_open_inspections.get(s.id) %}
{% if open_id %}
<a href="{{ url_for('inspections.execute', inspection_id=open_id) }}"
class="btn btn-sm btn-warning py-0" title="You already started this — resume it">
<i class="bi bi-pencil-square"></i> Continue</a>
{% else %}
<a href="{{ url_for('inspection_schedules.start', schedule_id=s.id) }}"
class="btn btn-sm btn-success py-0"><i class="bi bi-play-fill"></i> Start</a>
{% endif %}
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-muted small"><i class="bi bi-info-circle me-1"></i>No inspections due in the next 7 days.</div>
{% endif %}
</div>
{% endif %}
{# ── KPI row ──────────────────────────────────────────────────────────── #}
<div class="row row-cols-2 row-cols-lg-4 g-3 mb-4">
<div class="col">
<a class="jqc-kpi" href="{{ url_for('inspections.index', status='completed', date_from=today_str, date_to=today_str) }}">
<span class="jqc-tile-icon"><i class="bi bi-clipboard-check"></i></span>
<div class="jqc-kpi-value">{{ completed_today }}</div>
<div class="jqc-kpi-label">Submitted Today</div>
</a>
</div>
<div class="col">
<a class="jqc-kpi" href="{{ url_for('inspections.index', status='completed', date_from=week_start_str, date_to=today_str) }}">
<span class="jqc-tile-icon"><i class="bi bi-calendar-week"></i></span>
<div class="jqc-kpi-value">{{ submitted_this_week }}</div>
<div class="jqc-kpi-label">Submitted This Week</div>
</a>
</div>
<div class="col">
<a class="jqc-kpi" href="{{ url_for('issues.index', status='open') }}">
<span class="jqc-tile-icon"><i class="bi bi-exclamation-triangle"></i></span>
<div class="jqc-kpi-value">{{ open_issues }}</div>
<div class="jqc-kpi-label">Open Issues</div>
</a>
</div>
<div class="col">
{% if current_user.role != 'customer' %}
<a class="jqc-kpi" href="{{ url_for('inspection_schedules.index') }}">
<span class="jqc-tile-icon"><i class="bi bi-calendar2-check"></i></span>
<div class="jqc-kpi-value">{{ sched_total }}</div>
<div class="jqc-kpi-label">On Schedules</div>
</a>
{% else %}
<a class="jqc-kpi" href="{{ url_for('facilities.list_facilities') }}">
<span class="jqc-tile-icon"><i class="bi bi-buildings"></i></span>
<div class="jqc-kpi-value">{{ customer_facilities|length if customer_facilities else 0 }}</div>
<div class="jqc-kpi-label">Your Facilities</div>
</a>
{% endif %}
</div>
</div>
{# ── Three summary cards ──────────────────────────────────────────────── #}
<div class="row g-3 mb-2">
<!-- Inspection -->
<div class="col-12 col-lg-4">
<div class="jqc-card h-100">
<div class="jqc-card-title">
<span class="jqc-tile-icon"><i class="bi bi-clipboard-check"></i></span>Inspection
</div>
<a class="jqc-stat-row" href="{{ url_for('inspections.index', status='completed', date_from=today_str, date_to=today_str) }}">
<span class="jqc-stat-label">Submitted Today</span>
<span class="jqc-stat-value" style="color:var(--jqc-brand);">{{ completed_today }}</span>
</a>
<a class="jqc-stat-row" href="{{ url_for('inspections.index', status='completed', date_from=week_start_str, date_to=today_str) }}">
<span class="jqc-stat-label">Submitted This Week</span>
<span class="jqc-stat-value" style="color:var(--jqc-brand);">{{ submitted_this_week }}</span>
</a>
{% if current_user.role != 'customer' %}
<a class="jqc-stat-row" href="{{ url_for('inspections.index', status='in_progress') }}">
<span class="jqc-stat-label">
In Process
{% if stale_in_progress %}<span class="badge bg-warning text-dark">{{ stale_in_progress }} stale</span>{% endif %}
</span>
<span class="jqc-stat-value" style="color:#1B9AD1;">{{ in_progress_total }}</span>
</a>
<a class="jqc-stat-row" href="{{ url_for('inspections.index', status='follow_up') }}">
<span class="jqc-stat-label">Pending to follow up</span>
<span class="jqc-stat-value" style="color:#E0A800;">{{ pending_followups }}</span>
</a>
{% endif %}
</div>
</div>
<!-- Open Issues -->
<div class="col-12 col-lg-4">
<div class="jqc-card h-100">
<div class="jqc-card-title">
<span class="jqc-tile-icon"><i class="bi bi-exclamation-triangle"></i></span>Open Issues
</div>
<a class="jqc-stat-row" href="{{ url_for('issues.index', status='open', handler_type='internal') }}">
<span class="jqc-stat-label"><span class="jqc-dot" style="background:#E8722C;"></span>Janitorial</span>
<span class="jqc-stat-value">{{ handler_breakdown.internal }}</span>
</a>
<a class="jqc-stat-row" href="{{ url_for('issues.index', status='open', handler_type='facility') }}">
<span class="jqc-stat-label"><span class="jqc-dot" style="background:#155F82;"></span>Facility Staff</span>
<span class="jqc-stat-value">{{ handler_breakdown.facility }}</span>
</a>
<a class="jqc-stat-row" href="{{ url_for('issues.index', status='open', handler_type='vendor') }}">
<span class="jqc-stat-label"><span class="jqc-dot" style="background:#25AEE4;"></span>Vendors</span>
<span class="jqc-stat-value">{{ handler_breakdown.vendor }}</span>
</a>
{% if current_user.role != 'customer' %}
<a class="jqc-stat-row" href="{{ url_for('issues.index', status='pending_verification') }}">
<span class="jqc-stat-label"><span class="jqc-dot" style="background:#E0A800;"></span>Pending Verification</span>
<span class="jqc-stat-value">{{ pending_verification }}</span>
</a>
<a class="jqc-stat-row" href="{{ url_for('issues.index', status='open', unassigned='1') }}">
<span class="jqc-stat-label"><span class="jqc-dot" style="background:#D9534F;"></span>Unassigned Issue</span>
<span class="jqc-stat-value">{{ unassigned_open }}</span>
</a>
{% endif %}
</div>
</div>
<!-- SLA Issues -->
<div class="col-12 col-lg-4">
<div class="jqc-card h-100">
<div class="jqc-card-title">
<span class="jqc-tile-icon"><i class="bi bi-clock-history"></i></span>SLA Issues
</div>
<a class="jqc-stat-row" href="{{ url_for('issues.index', sla='breached') }}">
<span class="jqc-stat-label"><span class="jqc-dot" style="background:#D9534F;"></span>SLA Alert</span>
<span class="jqc-stat-value" style="color:#D9534F;">{{ sla_breached }}</span>
</a>
<a class="jqc-stat-row" href="{{ url_for('issues.index', sla='at_risk') }}">
<span class="jqc-stat-label"><span class="jqc-dot" style="background:#E0A800;"></span>SLA At Risk</span>
<span class="jqc-stat-value" style="color:#E0A800;">{{ sla_at_risk }}</span>
</a>
<a class="jqc-stat-row" href="{{ url_for('issues.index', date_from=today_str, date_to=today_str) }}">
<span class="jqc-stat-label"><span class="jqc-dot" style="background:#25AEE4;"></span>Issues Opened Today</span>
<span class="jqc-stat-value">{{ issues_opened_today }}</span>
</a>
<a class="jqc-stat-row" href="{{ url_for('issues.index', status='resolved', date_from=today_str, date_to=today_str) }}">
<span class="jqc-stat-label"><span class="jqc-dot" style="background:#2E7D4F;"></span>Resolved Today</span>
<span class="jqc-stat-value" style="color:#2E7D4F;">{{ resolved_today }}</span>
</a>
</div>
</div>
</div>
{# ── Recent activity ──────────────────────────────────────────────────── #}
<div class="jqc-card">
<div class="d-flex justify-content-between align-items-center mb-3">
<div class="jqc-card-title mb-0">
<span class="jqc-tile-icon"><i class="bi bi-clock-history"></i></span>Recent Activities
</div>
<a href="{{ url_for('inspections.index') }}" class="btn btn-sm btn-outline-primary">View all</a>
</div>
{% if recent_inspections %}
<div class="jqc-table-wrap table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Date</th>
<th>Facility Name</th>
<th>Area</th>
{% if not current_user.is_inspector %}<th>Inspector</th>{% endif %}
<th>Score</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{% for insp in recent_inspections %}
{# The row stays click-anywhere for the mouse, but the date is a real
link so the row is keyboard-reachable and openable in a new tab.
The guard stops the row handler from double-firing on that link. #}
<tr style="cursor:pointer;"
onclick="if(!event.target.closest('a')) window.location='{{ url_for('inspections.view', inspection_id=insp.id) }}'">
<td>
<a href="{{ url_for('inspections.view', inspection_id=insp.id) }}"
class="text-decoration-none"><small>{{ insp.inspection_date.strftime('%b %d, %Y') }}</small></a>
</td>
<td>{{ insp.facility.name }}</td>
<td>{{ insp.area.name if insp.area else '—' }}</td>
{% if not current_user.is_inspector %}<td>{{ insp.inspector.display_name }}</td>{% endif %}
<td>
{% if insp.overall_score %}
<span class="badge bg-{% if insp.overall_score >= 90 %}success{% elif insp.overall_score >= 70 %}warning{% else %}danger{% endif %}">
{{ insp.overall_score }}%
</span>
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td>
<span class="badge bg-{% if insp.status == 'completed' %}success{% elif insp.status == 'flagged' %}danger{% else %}secondary{% endif %}">
{{ 'Submitted' if insp.status == 'completed' else insp.status|title }}
</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-4 text-muted">
<i class="bi bi-inbox fs-2 d-block mb-2"></i>No recent inspections.
</div>
{% endif %}
</div>
{# ── My open issues (inspector widget) ────────────────────────────────── #}
{% if my_issues %}
<div class="jqc-card">
<div class="d-flex justify-content-between align-items-center mb-3">
<div class="jqc-card-title mb-0">
<span class="jqc-tile-icon"><i class="bi bi-person-check"></i></span>My Open Issues
</div>
<a href="{{ url_for('issues.index') }}" class="btn btn-sm btn-outline-primary">View all</a>
</div>
<div class="jqc-table-wrap table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th style="width:60px;">ID</th>
<th style="width:90px;">Severity</th>
<th>Facility / Description</th>
<th style="width:100px;">Status</th>
<th style="width:120px;">SLA</th>
</tr>
</thead>
<tbody>
{% for issue in my_issues %}
{% set sla = sla_status(issue) %}
<tr class="{{ 'table-danger' if sla == 'breached' else 'table-warning' if sla == 'at_risk' else '' }}">
<td>
<a href="{{ url_for('issues.view', issue_id=issue.id) }}" class="text-decoration-none fw-semibold">#{{ issue.id }}</a>
</td>
<td>
<span class="badge bg-{{ 'danger' if issue.severity in ['critical','high'] else 'warning text-dark' if issue.severity == 'medium' else 'secondary' }}">
{{ issue.severity|title }}
</span>
</td>
<td>
<div>{{ issue.resolved_facility.name if issue.resolved_facility else '—' }}</div>
<div class="text-muted small">{{ issue.description[:60] }}{% if issue.description|length > 60 %}…{% endif %}</div>
</td>
<td>
<span class="badge bg-{{ 'warning text-dark' if issue.status == 'in_progress' else 'danger' }}">
{{ issue.status|replace('_',' ')|title }}
</span>
</td>
<td>
{% if sla == 'breached' %}
<span class="badge bg-danger"><i class="bi bi-alarm me-1"></i>Breached</span>
{% elif sla == 'at_risk' %}
<span class="badge bg-warning text-dark"><i class="bi bi-hourglass-split me-1"></i>{{ sla_hours_remaining(issue)|abs|round(1) }}h left</span>
{% else %}
<span class="badge bg-secondary">OK</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{# ── Customer portal: scoped facilities panel ─────────────────────────── #}
{% if current_user.role == 'customer' and customer_facilities %}
<div class="jqc-card">
<div class="jqc-card-title">
<span class="jqc-tile-icon"><i class="bi bi-building"></i></span>Your Facilities
<span class="badge bg-secondary rounded-pill ms-2">{{ customer_facilities|length }}</span>
</div>
{% if customer_facilities|length > 6 %}
<div class="mb-3">
<input type="text" id="facilitySearch" class="form-control form-control-sm"
placeholder="Search facilities…" aria-label="Search facilities">
</div>
{% endif %}
<div class="row g-3" id="facilityGrid">
{% for f in customer_facilities %}
<div class="col-12 col-sm-6 col-lg-4 facility-col">
<div class="border rounded-3 p-3 h-100 d-flex flex-column facility-card">
<div class="fw-semibold mb-1">{{ f.name }}</div>
<div class="text-muted" style="font-size:.82rem;">{{ f.address or '—' }}</div>
<div class="my-2">
<span class="badge bg-light text-dark border">{{ f.project.name if f.project else '—' }}</span>
</div>
<div class="mt-auto pt-1">
<a href="{{ url_for('facilities.view_facility', facility_id=f.id) }}"
class="btn btn-sm btn-outline-primary"><i class="bi bi-eye"></i> View</a>
<a href="{{ url_for('reports.facility_report', facility_id=f.id) }}"
class="btn btn-sm btn-outline-secondary ms-1"><i class="bi bi-graph-up"></i> Report</a>
</div>
</div>
</div>
{% endfor %}
</div>
{% if customer_facilities|length > 9 %}
<div id="facilityShowMore" class="text-center mt-3">
<button class="btn btn-sm btn-link text-muted" id="toggleFacilities">
Show all {{ customer_facilities|length }} facilities <i class="bi bi-chevron-down"></i>
</button>
</div>
{% endif %}
</div>
{# Same VISIBLE = 9 / search > 6 thresholds as the classic dashboard (rule 65). #}
<script>
(function () {
{% if customer_facilities|length > 9 %}
var VISIBLE = 9;
var cols = document.querySelectorAll('#facilityGrid .facility-col');
var btn = document.getElementById('toggleFacilities');
var expanded = false;
cols.forEach(function (c, i) { if (i >= VISIBLE) c.style.display = 'none'; });
btn.addEventListener('click', function () {
expanded = !expanded;
cols.forEach(function (c, i) {
if (i >= VISIBLE) c.style.display = expanded ? '' : 'none';
});
btn.innerHTML = expanded
? 'Show fewer <i class="bi bi-chevron-up"></i>'
: 'Show all {{ customer_facilities|length }} facilities <i class="bi bi-chevron-down"></i>';
});
{% endif %}
{% if customer_facilities|length > 6 %}
document.getElementById('facilitySearch').addEventListener('input', function () {
var q = this.value.toLowerCase();
document.querySelectorAll('#facilityGrid .facility-col').forEach(function (col) {
var match = col.querySelector('.facility-card').textContent.toLowerCase().includes(q);
col.style.display = match ? '' : 'none';
});
var more = document.getElementById('facilityShowMore');
if (more) more.style.display = this.value ? 'none' : '';
});
{% endif %}
})();
</script>
{% endif %}
{% endblock %}
+271
View File
@@ -0,0 +1,271 @@
{% extends "base.html" %}
{% block title %}Facilities{% endblock %}
{#
MODERN facilities page (design A/B test — slide 5 of JQC_design).
The four hub cards are new; everything below them is the original grouped
facility list, delete modal and JS, unchanged — no functionality removed.
#}
{% block content %}
<div class="jqc-page-head center">
<div class="jqc-page-title">Facilities</div>
<div class="jqc-page-sub text-center">Manage facility records, statistics and QR access</div>
</div>
<div class="row g-4 mb-4">
{% if not current_user.is_inspector %}
<div class="col-12 col-lg-6">
<a class="jqc-hub-card" href="{{ url_for('facilities.qr_print_all') }}">
<div class="d-flex gap-4 align-items-start">
<span class="jqc-tile-icon lg"><i class="bi bi-qr-code"></i></span>
<div>
<div class="jqc-hub-title">Print QR Code</div>
<div class="jqc-hub-text">Generate and print scannable QR codes for every facility entrance and asset.</div>
</div>
</div>
<div class="jqc-hub-open">Open &rarr;</div>
</a>
</div>
{% endif %}
<div class="col-12 col-lg-6">
<a class="jqc-hub-card" href="#facility-list">
<div class="d-flex gap-4 align-items-start">
<span class="jqc-tile-icon lg"><i class="bi bi-buildings"></i></span>
<div>
<div class="jqc-hub-title">Facilities Information</div>
<div class="jqc-hub-text">View addresses, contacts, contracts and service details in one place.</div>
</div>
</div>
<div class="jqc-hub-open">Open &rarr;</div>
</a>
</div>
<div class="col-12 col-lg-6">
<a class="jqc-hub-card" href="{{ url_for('reports.index') }}">
<div class="d-flex gap-4 align-items-start">
<span class="jqc-tile-icon lg"><i class="bi bi-pie-chart"></i></span>
<div>
<div class="jqc-hub-title">Facilities Statistics</div>
<div class="jqc-hub-text">Track inspection scores and issue trends by location over time.</div>
</div>
</div>
<div class="jqc-hub-open">Open &rarr;</div>
</a>
</div>
{% if current_user.role in ['admin', 'director'] %}
<div class="col-12 col-lg-6">
<a class="jqc-hub-card" href="{{ url_for('templates.index') }}">
<div class="d-flex gap-4 align-items-start">
<span class="jqc-tile-icon lg"><i class="bi bi-gear"></i></span>
<div>
<div class="jqc-hub-title">Customize</div>
<div class="jqc-hub-text">Configure inspection templates, checklist items and scoring for your facilities.</div>
</div>
</div>
<div class="jqc-hub-open">Open &rarr;</div>
</a>
</div>
{% endif %}
</div>
{# ── Original facility list (unchanged) ───────────────────────────────── #}
<div id="facility-list" class="d-flex flex-wrap justify-content-between align-items-center mb-3 gap-2">
<h2 class="mb-0" style="font-size:1.4rem;font-weight:800;">
<i class="bi bi-building"></i> All Facilities
</h2>
<div>
{% if not current_user.is_inspector %}
<a href="{{ url_for('facilities.qr_print_all') }}"
class="btn btn-outline-dark" title="Printable sheet of your facilities' QR codes">
<i class="bi bi-qr-code"></i> Print All QR Codes
</a>
{% endif %}
{% if current_user.role in ['admin', 'director'] %}
<a href="{{ url_for('facilities.create_facility') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Add Facility
</a>
{% endif %}
</div>
</div>
{% if grouped %}
{% for group_key, group in grouped.items() %}
{# ── Contract group header ────────────────────────────────────────────── #}
{% set collapse_id = 'contract-' ~ loop.index %}
<div class="mb-4">
<div class="d-flex align-items-center mb-2">
<button class="btn btn-link text-decoration-none p-0 d-flex align-items-center gap-2 fw-semibold fs-5"
type="button"
data-bs-toggle="collapse"
data-bs-target="#{{ collapse_id }}"
aria-expanded="false"
aria-controls="{{ collapse_id }}">
<i class="bi bi-chevron-down contract-chevron" style="transition: transform .2s; transform: rotate(-90deg);"></i>
{% if group.project %}
<i class="bi bi-briefcase text-primary"></i>
{{ group.project.name }}
{% else %}
<i class="bi bi-dash-circle text-secondary"></i>
<span class="text-secondary">No Contract Assigned</span>
{% endif %}
</button>
<span class="badge bg-secondary ms-2">{{ group.facilities|length }}</span>
{% if group.project and current_user.role in ['admin', 'director', 'project_manager', 'auditor'] %}
<a href="{{ url_for('projects.view', project_id=group.project.id) }}"
class="btn btn-sm btn-outline-secondary ms-2"
title="View Contract">
<i class="bi bi-arrow-right-circle"></i>
</a>
{% endif %}
</div>
{# ── Collapsible card grid ─────────────────────────────────────────── #}
<div class="collapse" id="{{ collapse_id }}">
<div class="row">
{% for facility in group.facilities %}
<div class="col-sm-6 col-md-4 col-lg-3 mb-3">
<div class="card shadow-sm h-100">
<div class="card-body py-2 px-3">
<div class="mb-1" style="font-size:.875rem;font-weight:600;line-height:1.3;">
<a href="{{ url_for('facilities.view_facility', facility_id=facility.id) }}" class="text-decoration-none">
{{ facility.name }}
</a>
{% if not facility.active %}
<span class="badge bg-secondary" style="font-size:.7rem;">Inactive</span>
{% endif %}
</div>
{% if facility.address %}
<p class="card-text text-muted mb-1" style="font-size:.78rem;">
<i class="bi bi-geo-alt"></i> {{ facility.address }}
</p>
{% endif %}
<div class="mt-1">
<small class="text-muted" style="font-size:.78rem;">
<i class="bi bi-diagram-3"></i> {{ facility.areas.count() }} areas
</small>
</div>
</div>
<div class="card-footer bg-transparent d-flex gap-2 py-2 px-3">
<a href="{{ url_for('facilities.view_facility', facility_id=facility.id) }}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye"></i> View Details
</a>
{% if current_user.role == 'admin' %}
<button type="button"
class="btn btn-sm btn-outline-danger ms-auto"
data-bs-toggle="modal"
data-bs-target="#deleteModal"
data-facility-id="{{ facility.id }}"
data-facility-name="{{ facility.name }}"
data-inspection-count="{{ facility.inspections.count() }}">
<i class="bi bi-trash"></i> Delete
</button>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
{% endfor %}
{% else %}
<div class="alert alert-info">
<i class="bi bi-info-circle"></i> No facilities configured yet.
</div>
{% endif %}
{% if current_user.role == 'admin' %}
<!-- Delete Confirmation Modal -->
<div class="modal fade" id="deleteModal" tabindex="-1" aria-labelledby="deleteModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header bg-danger text-white">
<h5 class="modal-title" id="deleteModalLabel">
<i class="bi bi-exclamation-triangle-fill"></i> Confirm Deletion
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>You are about to permanently delete:</p>
<p class="fw-bold fs-5" id="modalFacilityName"></p>
<div id="modalWarningBlock" class="alert alert-danger d-none">
<i class="bi bi-x-circle-fill"></i>
<strong>Cannot delete this facility.</strong> It has existing inspection records.
Please remove all associated inspections first.
</div>
<div id="modalConfirmBlock">
<p class="text-muted mb-0">This action is <strong>irreversible</strong>. All areas associated with this facility will also be deleted.</p>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<i class="bi bi-x-circle"></i> Cancel
</button>
<form id="deleteFacilityForm" method="POST" action="" class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" id="confirmDeleteBtn" class="btn btn-danger">
<i class="bi bi-trash-fill"></i> Delete Permanently
</button>
</form>
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %}
{% block extra_js %}
<script>
document.addEventListener('DOMContentLoaded', function () {
// ── Rotate chevron on collapse toggle ────────────────────────────────
document.querySelectorAll('[data-bs-toggle="collapse"]').forEach(function (btn) {
const target = document.querySelector(btn.getAttribute('data-bs-target'));
if (!target) return;
const chevron = btn.querySelector('.contract-chevron');
target.addEventListener('hide.bs.collapse', function () {
if (chevron) chevron.style.transform = 'rotate(-90deg)';
});
target.addEventListener('show.bs.collapse', function () {
if (chevron) chevron.style.transform = 'rotate(0deg)';
});
});
{% if current_user.role == 'admin' %}
// ── Delete modal wiring ──────────────────────────────────────────────
const deleteModal = document.getElementById('deleteModal');
deleteModal.addEventListener('show.bs.modal', function (event) {
const button = event.relatedTarget;
const facilityId = button.getAttribute('data-facility-id');
const facilityName = button.getAttribute('data-facility-name');
const inspectionCount = parseInt(button.getAttribute('data-inspection-count'));
document.getElementById('modalFacilityName').textContent = facilityName;
document.getElementById('deleteFacilityForm').action = '/facilities/' + facilityId + '/delete';
const warningBlock = document.getElementById('modalWarningBlock');
const confirmBlock = document.getElementById('modalConfirmBlock');
const confirmBtn = document.getElementById('confirmDeleteBtn');
if (inspectionCount > 0) {
warningBlock.classList.remove('d-none');
confirmBlock.classList.add('d-none');
confirmBtn.disabled = true;
} else {
warningBlock.classList.add('d-none');
confirmBlock.classList.remove('d-none');
confirmBtn.disabled = false;
}
});
{% endif %}
});
</script>
{% endblock %}
+376
View File
@@ -0,0 +1,376 @@
{% extends "base.html" %}
{% block title %}Inspections{% endblock %}
{#
MODERN inspections list (design A/B test).
Same context variables, same query params, same form field names and the same
three JS blocks as templates/inspections/list.html — only the chrome differs.
Nothing was dropped: every filter, column, badge, the pagination links and the
delete modal are carried over verbatim. `insp-list-link` is preserved on the
View/Continue buttons so filter-state restore on Back still works.
#}
{% block content %}
{# Any non-empty query param other than the page number means the user has
actually filtered — used to show the match count only when it is meaningful. #}
{% set active_filters = [] %}
{% for _k, _v in request.args.items() %}
{% if _k != 'page' and _v %}{% set _ = active_filters.append(_k) %}{% endif %}
{% endfor %}
{# ── Header ───────────────────────────────────────────────────────────── #}
<div class="d-flex flex-wrap justify-content-between align-items-end mb-4 gap-2">
<div>
<div class="jqc-page-title">Inspections</div>
</div>
{# Customer Directors schedule inspections for their own facilities, so the
Scheduled link is theirs too — but starting an ad-hoc inspection is not. #}
<div class="d-flex gap-2">
<a href="{{ url_for('inspection_schedules.index') }}" class="btn btn-outline-primary">
<i class="bi bi-calendar-check"></i> Scheduled
</a>
{% if current_user.role != 'customer' %}
<a href="{{ url_for('inspections.start') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> New Inspection
</a>
{% endif %}
</div>
</div>
{# ── Filters ──────────────────────────────────────────────────────────── #}
{# Layout: fields fill two rows on the left; the actions sit in a block on the
right that spans both rows — Filter full-height, Clear above Export PDF.
Below the md breakpoint the action block wraps underneath, full width. #}
<div class="jqc-filter-bar">
<form method="get">
<div class="d-flex flex-wrap gap-3 align-items-stretch">
{# ── Fields ──────────────────────────────────────────────────────── #}
<div class="flex-grow-1" style="min-width:min(100%, 620px);">
<div class="row g-2 align-items-end">
<div class="col-6 col-md-2">
<label class="form-label small mb-1">Inspection #</label>
<input type="number" name="inspection_id" class="form-control form-control-sm"
min="1" placeholder="ID" value="{{ inspection_id_filter }}">
</div>
<div class="col-6 col-md-3">
<label class="form-label small mb-1">Status</label>
<select name="status" class="form-select form-select-sm">
<option value="">All Statuses</option>
{% for s in ['in_progress','completed','flagged'] %}
<option value="{{ s }}" {% if status_filter == s %}selected{% endif %}>{{ 'Submitted' if s == 'completed' else s|replace('_',' ')|title }}</option>
{% endfor %}
<option value="follow_up" {% if status_filter == 'follow_up' %}selected{% endif %}>Flagged Follow-up</option>
<option value="has_issues" {% if status_filter == 'has_issues' %}selected{% endif %}>Has Logged Issues</option>
</select>
</div>
<div class="col-12 col-md-3">
<label class="form-label small mb-1">Contract</label>
<select name="contract_id" id="insp_filter_contract" class="form-select form-select-sm">
<option value="">All Contracts</option>
{% for p in projects %}
<option value="{{ p.id }}" {% if contract_filter == p.id|string %}selected{% endif %}>{{ p.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-12 col-md-4">
<label class="form-label small mb-1">Facility</label>
<select name="facility_id" id="insp_filter_facility" class="form-select form-select-sm">
<option value="">All Facilities</option>
{% for f in facilities %}
<option value="{{ f.id }}" {% if facility_filter == f.id|string %}selected{% endif %}>{{ f.name }}</option>
{% endfor %}
</select>
</div>
</div>
{# Second row. The score fields absorb the Inspector column's width when
the viewer is an inspector (they only ever see their own work, so the
dropdown is not rendered for them) — the row always totals 12. #}
<div class="row g-2 align-items-end mt-2">
{% if inspectors %}
<div class="col-12 col-md-4">
<label class="form-label small mb-1">Inspector</label>
<select name="inspector_id" class="form-select form-select-sm">
<option value="">All Inspectors</option>
{% for u in inspectors %}
<option value="{{ u.id }}" {% if inspector_filter == u.id|string %}selected{% endif %}>{{ u.display_name }}</option>
{% endfor %}
</select>
</div>
{% endif %}
<div class="col-6 col-md-{{ 3 if inspectors else 4 }}">
<label class="form-label small mb-1">Date From</label>
<input type="date" name="date_from" class="form-control form-control-sm"
value="{{ date_from_filter }}">
</div>
<div class="col-6 col-md-{{ 3 if inspectors else 4 }}">
<label class="form-label small mb-1">Date To</label>
<input type="date" name="date_to" class="form-control form-control-sm"
value="{{ date_to_filter }}">
</div>
<div class="col-6 col-md-{{ 1 if inspectors else 2 }}">
<label class="form-label small mb-1">Min Score</label>
<input type="number" name="score_min" class="form-control form-control-sm"
min="0" max="100" placeholder="0" value="{{ score_min_filter }}">
</div>
<div class="col-6 col-md-{{ 1 if inspectors else 2 }}">
<label class="form-label small mb-1">Max Score</label>
<input type="number" name="score_max" class="form-control form-control-sm"
min="0" max="100" placeholder="100" value="{{ score_max_filter }}">
</div>
</div>
</div>
{# ── Actions — spans both field rows ─────────────────────────────── #}
{# mt-md-4 drops the block by roughly one label's height, so the buttons
line up with the first row's INPUTS rather than its labels — that is
what makes them shorter, since they still stretch to the bottom of the
second row. The margin is md-only; below that the block wraps
underneath the fields and needs its full width and natural height. #}
<div class="d-flex gap-2 align-items-stretch flex-grow-1 flex-md-grow-0 mt-md-4">
<button type="submit"
class="btn btn-sm btn-primary d-flex align-items-center justify-content-center flex-grow-1 flex-md-grow-0"
style="min-width:88px;">
<span><i class="bi bi-funnel"></i> Filter</span>
</button>
<div class="d-flex flex-column gap-2 flex-grow-1 flex-md-grow-0">
<a href="{{ url_for('inspections.index') }}"
class="btn btn-sm btn-outline-secondary d-flex align-items-center justify-content-center flex-grow-1"
style="min-width:104px;">Clear</a>
<a id="exportPdfBtn"
href="{{ url_for('inspections.export_list_pdf', **request.args) }}"
class="btn btn-sm btn-outline-danger d-flex align-items-center justify-content-center flex-grow-1 text-nowrap"
style="min-width:104px;">
<span><i class="bi bi-file-earmark-pdf"></i> Export PDF</span>
</a>
</div>
</div>
</div>
</form>
</div>
{# ── Results ──────────────────────────────────────────────────────────── #}
<div class="jqc-card">
{% if active_filters %}
<div class="text-muted small mb-2">
<i class="bi bi-funnel me-1"></i>
{{ inspections.total }} inspection{{ 's' if inspections.total != 1 }} match
{{ 'es' if inspections.total == 1 }} your filters
</div>
{% endif %}
{% if inspections.items %}
{% include 'partials/bulk_inspections_toolbar.html' %}
<div class="jqc-table-wrap table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th style="width:34px;">
<input type="checkbox" class="form-check-input bulk-check-all"
title="Select all on this page" aria-label="Select all">
</th>
<th>#</th><th>Date</th><th>Contract</th><th>Facility</th><th>Area</th>
<th>Template</th><th>Inspector</th><th>Score</th>
<th>Status</th><th></th>
</tr>
</thead>
<tbody>
{% for ins in inspections.items %}
<tr>
<td>
<input type="checkbox" class="form-check-input bulk-check"
form="inspectionsBulkForm" name="inspection_ids" value="{{ ins.id }}"
aria-label="Select inspection #{{ ins.id }}">
</td>
<td><small class="text-muted">#{{ ins.id }}</small></td>
<td class="text-nowrap">{{ ins.inspection_date.strftime('%Y-%m-%d %H:%M') }}</td>
<td><small>{{ ins.facility.project.name if ins.facility and ins.facility.project else '—' }}</small></td>
<td>{{ ins.facility.name }}</td>
<td>{% if ins.area %}{{ ins.area.name }}{% else %}<span class="text-muted"></span>{% endif %}</td>
<td>
{{ ins.template.name }}
{% if ins.scheduled_inspection_id %}
<span class="badge bg-info text-dark ms-1" title="From a scheduled inspection">
<i class="bi bi-calendar-check"></i> Scheduled
</span>
{% endif %}
</td>
<td>{{ ins.inspector.display_name }}</td>
<td>
{% if ins.overall_score %}
<span class="badge bg-{{ 'success' if ins.overall_score >= 90 else 'warning' if ins.overall_score >= 70 else 'danger' }}">
{{ ins.overall_score }}%
</span>
{% else %}<span class="text-muted"></span>{% endif %}
</td>
<td>
<span class="badge bg-{{ 'success' if ins.status == 'completed' else 'danger' if ins.status == 'flagged' else 'secondary' }}">
{{ 'Submitted' if ins.status == 'completed' else ins.status|replace('_',' ')|title }}
</span>
{% if ins.status == 'in_progress' %}
{% set hours_open = ((now - ins.inspection_date).total_seconds() / 3600) %}
{% if hours_open > 24 %}
<span class="badge bg-warning text-dark ms-1" title="In progress for over 24 hours — may be stale">
<i class="bi bi-clock-history"></i> Stale
</span>
{% endif %}
{% endif %}
{% if ins.follow_up_required and not ins.follow_ups.count() %}
<span class="badge bg-danger ms-1" title="Follow-up re-inspection required">
<i class="bi bi-arrow-repeat"></i> Follow-up
</span>
{% endif %}
</td>
<td class="text-nowrap">
{% if ins.status == 'in_progress' or ins.status == 'flagged' %}
<a href="{{ url_for('inspections.execute', inspection_id=ins.id, next=current_url()) }}" class="btn btn-sm btn-outline-primary insp-list-link">Continue</a>
{% else %}
<a href="{{ url_for('inspections.view', inspection_id=ins.id, next=current_url()) }}" class="btn btn-sm btn-outline-secondary insp-list-link">View</a>
{% endif %}
{% if current_user.role in ['admin', 'director'] %}
<button type="button"
class="btn btn-sm btn-outline-danger ms-1"
data-bs-toggle="modal"
data-bs-target="#deleteInspectionModal"
data-inspection-id="{{ ins.id }}"
data-inspection-label="{{ ins.template.name }} — {{ ins.facility.name }} ({{ ins.inspection_date.strftime('%Y-%m-%d') }})"
title="Delete inspection">
<i class="bi bi-trash3"></i>
</button>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{# Pagination #}
{% if inspections.pages > 1 %}
<div class="d-flex justify-content-center pt-3">
<nav><ul class="pagination pagination-sm mb-0">
{% for p in inspections.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
{% if p %}
<li class="page-item {{ 'active' if p == inspections.page }}">
<a class="page-link" href="{{ url_for('inspections.index', page=p, inspection_id=inspection_id_filter, status=status_filter, contract_id=contract_filter, facility_id=facility_filter, date_from=date_from_filter, date_to=date_to_filter, score_min=score_min_filter, score_max=score_max_filter, inspector_id=inspector_filter) }}">{{ p }}</a>
</li>
{% else %}
<li class="page-item disabled"><span class="page-link"></span></li>
{% endif %}
{% endfor %}
</ul></nav>
</div>
{% endif %}
{% else %}
<div class="text-center py-5 text-muted">
<i class="bi bi-clipboard-x fs-2 d-block mb-2"></i>
No inspections found.
<div class="mt-2">
<a href="{{ url_for('inspections.index') }}" class="btn btn-sm btn-outline-secondary">Clear filters</a>
</div>
</div>
{% endif %}
</div>
{% if current_user.role in ['admin', 'director'] %}
<!-- Delete Inspection Confirmation Modal -->
<div class="modal fade" id="deleteInspectionModal" tabindex="-1" aria-labelledby="deleteInspectionModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header bg-danger text-white">
<h5 class="modal-title" id="deleteInspectionModalLabel">
<i class="bi bi-exclamation-triangle-fill"></i> Confirm Deletion
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>You are about to permanently delete the following inspection:</p>
<p class="fw-bold" id="deleteInspectionLabel"></p>
<p class="text-muted mb-0">This will also remove all associated results, flagged issues, and uploaded photos. This action is <strong>irreversible</strong>.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<i class="bi bi-x-circle"></i> Cancel
</button>
<form id="deleteInspectionForm" method="POST" action="" class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="next" value="{{ current_url() }}">
<button type="submit" class="btn btn-danger">
<i class="bi bi-trash3-fill"></i> Delete Permanently
</button>
</form>
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %}
{% block extra_js %}
{% include 'partials/bulk_select_js.html' %}
<script>
(function () {
'use strict';
// Save current filtered URL so view/execute pages can restore it on Back
var links = document.querySelectorAll('.insp-list-link');
links.forEach(function (a) {
a.addEventListener('click', function () {
sessionStorage.setItem('insp_list_back_url', window.location.href);
});
});
}());
</script>
<script>
(function () {
'use strict';
var contractSel = document.getElementById('insp_filter_contract');
var facilitySel = document.getElementById('insp_filter_facility');
if (!contractSel || !facilitySel) return;
var FACILITIES_URL = '{{ url_for("inspections.facilities_for_project", project_id=0) }}'.replace('/0', '/');
contractSel.addEventListener('change', function () {
var projectId = this.value;
facilitySel.value = '';
if (!projectId) {
facilitySel.innerHTML = '<option value="">All Facilities</option>';
return;
}
facilitySel.disabled = true;
facilitySel.innerHTML = '<option value="">Loading…</option>';
fetch(FACILITIES_URL + projectId)
.then(function (r) { return r.json(); })
.then(function (data) {
var html = '<option value="">All Facilities</option>';
data.forEach(function (f) {
html += '<option value="' + f.id + '">' + f.name + '</option>';
});
facilitySel.innerHTML = html;
facilitySel.disabled = false;
})
.catch(function () { facilitySel.disabled = false; });
});
}());
</script>
{% if current_user.role in ['admin', 'director'] %}
<script>
document.addEventListener('DOMContentLoaded', function () {
const modal = document.getElementById('deleteInspectionModal');
modal.addEventListener('show.bs.modal', function (event) {
const btn = event.relatedTarget;
const id = btn.getAttribute('data-inspection-id');
const label = btn.getAttribute('data-inspection-label');
document.getElementById('deleteInspectionLabel').textContent = label;
document.getElementById('deleteInspectionForm').action = '/inspections/' + id + '/delete';
});
});
</script>
{% endif %}
{% endblock %}
+420
View File
@@ -0,0 +1,420 @@
{% extends "base.html" %}
{% block title %}Issues{% endblock %}
{#
MODERN issues list (design A/B test).
Same context variables, query params, form field names and JS as
templates/issues/list.html — only the chrome differs. Every filter, column,
badge, the quick-assign control, follow/unfollow, delete and the pagination
links are carried over verbatim.
#}
{% block content %}
{# Any non-empty query param other than the page number means the user has
actually filtered — used to show the match count only when it is meaningful. #}
{% set active_filters = [] %}
{% for _k, _v in request.args.items() %}
{% if _k != 'page' and _v %}{% set _ = active_filters.append(_k) %}{% endif %}
{% endfor %}
{# ── Header ───────────────────────────────────────────────────────────── #}
<div class="d-flex flex-wrap justify-content-between align-items-end mb-4 gap-2">
<div>
<div class="jqc-page-title">Issues</div>
</div>
{% if current_user.role in ['admin','director','customer','auditor'] %}
<a href="{{ url_for('issues.create') }}" class="btn btn-danger">
<i class="bi bi-plus-circle"></i> Log Issue
</a>
{% endif %}
</div>
{# ── Filters ──────────────────────────────────────────────────────────── #}
{# Same layout as the modern inspections list: fields fill two rows on the left,
actions in a block on the right spanning both — Filter full-height, Clear
above Export PDF. Contract and Facility stay adjacent because they cascade
(rule 61). Below md the action block wraps underneath, full width. #}
<div class="jqc-filter-bar">
<form method="get">
<div class="d-flex flex-wrap gap-3 align-items-stretch">
{# ── Fields ──────────────────────────────────────────────────────── #}
<div class="flex-grow-1" style="min-width:min(100%, 620px);">
<div class="row g-2 align-items-end">
<div class="col-6 col-md-2">
<label class="form-label small mb-1">Issue #</label>
<input type="number" name="issue_id" class="form-control form-control-sm"
min="1" placeholder="ID" value="{{ issue_id_filter }}">
</div>
<div class="col-6 col-md-2">
<label class="form-label small mb-1">Severity</label>
<select name="severity" class="form-select form-select-sm">
<option value="">All</option>
{% for s in ['critical','high','medium','low'] %}
<option value="{{ s }}" {{ 'selected' if severity_filter == s }}>{{ s|title }}</option>
{% endfor %}
</select>
</div>
<div class="col-6 col-md-2">
<label class="form-label small mb-1">Status</label>
<select name="status" class="form-select form-select-sm">
<option value="">All</option>
{% for s in ['open','in_progress','pending_verification','resolved'] %}
<option value="{{ s }}" {{ 'selected' if status_filter == s }}>{{ s|replace('_',' ')|title }}</option>
{% endfor %}
</select>
</div>
<div class="col-12 col-md-3">
<label class="form-label small mb-1">Contract</label>
<select name="contract_id" id="filter_contract_id" class="form-select form-select-sm">
<option value="">All Contracts</option>
{% for p in projects %}
<option value="{{ p.id }}" {{ 'selected' if contract_filter == p.id|string }}>{{ p.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-12 col-md-3">
<label class="form-label small mb-1">Facility</label>
<select name="facility_id" id="filter_facility_id" class="form-select form-select-sm">
<option value="">All Facilities</option>
{% for f in facilities %}
<option value="{{ f.id }}" {{ 'selected' if facility_filter == f.id|string }}>{{ f.name }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="row g-2 align-items-end mt-2">
<div class="col-6 col-md-2">
<label class="form-label small mb-1">SLA</label>
<select name="sla" class="form-select form-select-sm">
<option value="">All</option>
<option value="breached" {{ 'selected' if sla_filter == 'breached' }}>Breached</option>
<option value="at_risk" {{ 'selected' if sla_filter == 'at_risk' }}>At Risk</option>
<option value="ok" {{ 'selected' if sla_filter == 'ok' }}>OK</option>
</select>
</div>
<div class="col-6 col-md-3">
<label class="form-label small mb-1">Reported From</label>
<input type="date" name="date_from" class="form-control form-control-sm"
value="{{ date_from_filter }}">
</div>
<div class="col-6 col-md-3">
<label class="form-label small mb-1">Reported To</label>
<input type="date" name="date_to" class="form-control form-control-sm"
value="{{ date_to_filter }}">
</div>
<div class="col-6 col-md-2">
<label class="form-label small mb-1">Reporter</label>
<select name="reporter_id" class="form-select form-select-sm">
<option value="">All Reporters</option>
{% for u in reporters %}
<option value="{{ u.id }}" {{ 'selected' if reporter_filter == u.id|string }}>{{ u.display_name }}</option>
{% endfor %}
</select>
</div>
<div class="col-12 col-md-2">
<label class="form-label small mb-1">Handled By</label>
<select name="handler_type" class="form-select form-select-sm">
<option value="">All Handlers</option>
<option value="internal" {{ 'selected' if handler_type_filter == 'internal' }}>Janitorial Staff</option>
<option value="facility" {{ 'selected' if handler_type_filter == 'facility' }}>Facility Staff</option>
<option value="vendor" {{ 'selected' if handler_type_filter == 'vendor' }}>External Vendor</option>
</select>
</div>
</div>
</div>
{# ── Actions — spans both field rows ─────────────────────────────── #}
{# mt-md-4 drops the block by roughly one label's height, so the buttons
line up with the first row's INPUTS rather than its labels — that is
what makes them shorter, since they still stretch to the bottom of the
second row. The margin is md-only; below that the block wraps
underneath the fields and needs its full width and natural height. #}
<div class="d-flex gap-2 align-items-stretch flex-grow-1 flex-md-grow-0 mt-md-4">
<button type="submit"
class="btn btn-sm btn-primary d-flex align-items-center justify-content-center flex-grow-1 flex-md-grow-0"
style="min-width:88px;">
<span><i class="bi bi-funnel"></i> Filter</span>
</button>
<div class="d-flex flex-column gap-2 flex-grow-1 flex-md-grow-0">
<a href="{{ url_for('issues.index') }}"
class="btn btn-sm btn-outline-secondary d-flex align-items-center justify-content-center flex-grow-1"
style="min-width:104px;">Clear</a>
<a href="{{ url_for('issues.export_list_pdf', **request.args) }}"
class="btn btn-sm btn-outline-danger d-flex align-items-center justify-content-center flex-grow-1 text-nowrap"
style="min-width:104px;">
<span><i class="bi bi-file-earmark-pdf"></i> Export PDF</span>
</a>
</div>
</div>
</div>
</form>
</div>
{# ── Results ──────────────────────────────────────────────────────────── #}
<div class="jqc-card">
{% if active_filters %}
<div class="text-muted small mb-2">
<i class="bi bi-funnel me-1"></i>
{{ issues.total }} issue{{ 's' if issues.total != 1 }} match
{{ 'es' if issues.total == 1 }} your filters
</div>
{% endif %}
{% if issues.items %}
{% include 'partials/bulk_issues_toolbar.html' %}
<div class="jqc-table-wrap table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th style="width:34px;">
<input type="checkbox" class="form-check-input bulk-check-all"
title="Select all on this page" aria-label="Select all">
</th>
<th>#</th>
<th>Reported</th>
<th>Severity</th>
<th>Contract</th>
<th>Facility / Area</th>
<th>Description</th>
<th>Status</th>
<th>SLA</th>
<th>Reporter</th>
<th>Assigned</th>
<th></th>
</tr>
</thead>
<tbody>
{% for issue in issues.items %}
{% set is_following = issue.id in followed_ids %}
{% set sla = sla_status(issue) %}
<tr class="{{ 'table-danger' if sla == 'breached' else 'table-warning' if sla == 'at_risk' else '' }}">
<td>
<input type="checkbox" class="form-check-input bulk-check"
form="issuesBulkForm" name="issue_ids" value="{{ issue.id }}"
aria-label="Select issue #{{ issue.id }}">
</td>
<td><small class="text-muted">#{{ issue.id }}</small></td>
<td class="text-nowrap"><small>{{ issue.reported_at.strftime('%Y-%m-%d %H:%M') }}</small></td>
<td>
<span class="badge bg-{{ 'danger' if issue.severity in ['critical','high'] else 'warning text-dark' if issue.severity == 'medium' else 'secondary' }}">
{{ issue.severity|title }}
</span>
</td>
<td>
{% set _c = issue.resolved_facility.project if issue.resolved_facility else none %}
<small>{{ _c.name if _c else '—' }}</small>
</td>
<td>
{{ issue.resolved_facility.name if issue.resolved_facility else '—' }}<br>
<small class="text-muted">{{ issue.area.name if issue.area else '—' }}</small>
</td>
<td>
<span{% if issue.description|length > 60 %} title="{{ issue.description }}" style="cursor:help;"{% endif %}>
{{ issue.description[:60] }}{% if issue.description|length > 60 %}…{% endif %}
</span>
</td>
<td>
<span class="badge bg-{{ 'success' if issue.status == 'resolved' else 'info text-dark' if issue.status == 'pending_verification' else 'warning text-dark' if issue.status == 'in_progress' else 'danger' }}">
{{ issue.status|replace('_',' ')|title }}
</span>
</td>
<td>
{% if sla == 'breached' %}
<span class="badge bg-danger" title="SLA deadline has passed"><i class="bi bi-alarm me-1"></i>Breached</span>
{% elif sla == 'at_risk' %}
{% set hrs = sla_hours_remaining(issue) %}
<span class="badge bg-warning text-dark" title="Over 75% of SLA window elapsed"><i class="bi bi-hourglass-split me-1"></i>{{ hrs|abs|round(1) }}h left</span>
{% elif sla == 'ok' %}
<span class="badge bg-secondary">OK</span>
{% else %}
<span class="text-muted small"></span>
{% endif %}
</td>
<td>
{% if issue.reporter %}
<small>{{ issue.reporter.display_name }}</small>
{% else %}<span class="text-muted"></span>{% endif %}
</td>
<td>
{% if current_user.role in ['admin', 'director', 'auditor'] and issue.status != 'resolved' %}
<div class="d-flex align-items-center gap-1 quick-assign-wrap" data-issue-id="{{ issue.id }}">
<select class="form-select form-select-sm quick-assign-select" style="min-width:110px;font-size:.78rem;">
<option value="">— Unassigned —</option>
{% for u in staff %}
<option value="{{ u.id }}" {{ 'selected' if issue.assigned_to == u.id }}>{{ u.display_name }}{{ ' (Customer)' if u.is_external_inspector }}</option>
{% endfor %}
</select>
<span class="quick-assign-spinner spinner-border spinner-border-sm text-secondary d-none" role="status"></span>
</div>
{% else %}
{% if issue.assigned_user %}{{ issue.assigned_user.display_name }}
{% else %}<span class="text-muted"></span>{% endif %}
{% endif %}
{% if issue.handler_type == 'facility' %}
<div><span class="badge bg-info text-dark mt-1" title="Handled by facility staff"><i class="bi bi-building"></i> Facility</span></div>
{% elif issue.handler_type == 'vendor' %}
<div><span class="badge bg-warning text-dark mt-1" title="Handled by external vendor"><i class="bi bi-person-gear"></i> Vendor</span></div>
{% endif %}
</td>
<td class="text-nowrap">
{# Following badge + inline unfollow #}
{% if is_following %}
<span class="badge bg-primary me-1" title="You are following this issue">
<i class="bi bi-bell-fill"></i> Following
</span>
<form method="post"
action="{{ url_for('issues.unfollow', issue_id=issue.id) }}"
class="d-inline"
title="Unfollow this issue">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="next" value="{{ current_url() }}">
<button type="submit" class="btn btn-sm btn-outline-primary p-0 px-1 me-1"
title="Unfollow">
<i class="bi bi-bell-slash" style="font-size:.75rem;"></i>
</button>
</form>
{% endif %}
<a href="{{ url_for('issues.view', issue_id=issue.id, next=current_url()) }}"
class="btn btn-sm btn-outline-secondary">
{% if current_user.role in ['admin','director','auditor'] or issue.assigned_to == current_user.id %}
<i class="bi bi-pencil"></i> Edit
{% else %}
<i class="bi bi-eye"></i> View
{% endif %}
</a>
{% if current_user.role in ['admin', 'director'] %}
<form method="POST" action="{{ url_for('issues.delete', issue_id=issue.id) }}"
class="d-inline"
onsubmit="return confirm('Permanently delete Issue #{{ issue.id }}? This cannot be undone.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="next" value="{{ current_url() }}">
<button type="submit" class="btn btn-sm btn-outline-danger"
title="Delete Issue #{{ issue.id }}">
<i class="bi bi-trash"></i>
</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if issues.pages > 1 %}
<div class="d-flex justify-content-center pt-3">
<nav><ul class="pagination pagination-sm mb-0">
{% for p in issues.iter_pages(left_edge=1,right_edge=1,left_current=2,right_current=2) %}
{% if p %}
<li class="page-item {{ 'active' if p == issues.page }}">
<a class="page-link"
href="{{ url_for('issues.index', page=p, issue_id=issue_id_filter, severity=severity_filter, status=status_filter, sla=sla_filter, contract_id=contract_filter, facility_id=facility_filter, date_from=date_from_filter, date_to=date_to_filter, reporter_id=reporter_filter, handler_type=handler_type_filter, unassigned=unassigned_filter) }}">{{ p }}</a>
</li>
{% else %}<li class="page-item disabled"><span class="page-link"></span></li>{% endif %}
{% endfor %}
</ul></nav>
</div>
{% endif %}
{% else %}
<div class="text-center py-5 text-muted">
<i class="bi bi-check2-circle fs-2 d-block mb-2"></i>
No issues found.
<div class="mt-2">
<a href="{{ url_for('issues.index') }}" class="btn btn-sm btn-outline-secondary">Clear filters</a>
</div>
</div>
{% endif %}
</div>
{% endblock %}
{% block extra_js %}
{% include 'partials/bulk_select_js.html' %}
<script>
(function () {
'use strict';
var contractSel = document.getElementById('filter_contract_id');
var facilitySel = document.getElementById('filter_facility_id');
if (!contractSel || !facilitySel) return;
var FACILITIES_URL = '{{ url_for("inspections.facilities_for_project", project_id=0) }}'.replace('/0', '/');
contractSel.addEventListener('change', function () {
var projectId = this.value;
facilitySel.value = ''; // reset facility selection
if (!projectId) {
// No contract selected — restore all-facilities placeholder and submit
// (server will return unfiltered facility list)
facilitySel.innerHTML = '<option value="">All Facilities</option>';
return;
}
facilitySel.disabled = true;
facilitySel.innerHTML = '<option value="">Loading…</option>';
fetch(FACILITIES_URL + projectId)
.then(function (r) { return r.json(); })
.then(function (data) {
var html = '<option value="">All Facilities</option>';
data.forEach(function (f) {
html += '<option value="' + f.id + '">' + f.name + '</option>';
});
facilitySel.innerHTML = html;
facilitySel.disabled = false;
})
.catch(function () { facilitySel.disabled = false; });
});
}());
</script>
{% if current_user.role in ['admin', 'director', 'auditor'] %}
<script>
(function () {
'use strict';
document.querySelectorAll('.quick-assign-select').forEach(function (sel) {
sel.dataset.previous = sel.value;
sel.addEventListener('change', function () {
const wrap = sel.closest('.quick-assign-wrap');
const issueId = wrap.dataset.issueId;
const spinner = wrap.querySelector('.quick-assign-spinner');
const userId = sel.value || null;
sel.disabled = true;
spinner.classList.remove('d-none');
fetch('/issues/' + issueId + '/quick-assign', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() }}',
},
body: JSON.stringify({ user_id: userId ? parseInt(userId) : null }),
})
.then(function (r) { return r.json(); })
.then(function (data) {
if (!data.ok) {
alert('Assignment failed: ' + (data.error || 'Unknown error'));
sel.value = sel.dataset.previous;
} else {
sel.dataset.previous = sel.value;
}
})
.catch(function () {
alert('Network error — assignment not saved.');
sel.value = sel.dataset.previous;
})
.finally(function () {
sel.disabled = false;
spinner.classList.add('d-none');
});
});
});
}());
</script>
{% endif %}
{% endblock %}
@@ -0,0 +1,57 @@
{# ── Bulk-action toolbar for the inspections list ─────────────────────────────
Included by BOTH inspections/list.html and modern/inspections/list.html —
edit here, not in either copy.
Same structure as the issues toolbar: the form sits OUTSIDE the table and
row checkboxes join it via the HTML5 `form` attribute, so the per-row
delete form inside the table is never nested (rule 9).
Export is offered to anyone who can see the list — it is read-only and the
route re-applies the viewer's facility scope to the submitted ids. The three
mutating actions are admin/director only.
#}
{% set can_manage = current_user.role in ['admin', 'director'] %}
<form method="POST" id="inspectionsBulkForm"
action="{{ url_for('inspections.bulk_action') }}"
class="border-bottom bg-light px-3 py-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="next" value="{{ current_url() }}">
<div class="d-flex flex-wrap align-items-center gap-2">
<span class="small fw-semibold text-nowrap">
<span class="bulk-count">0</span> selected
</span>
<span class="text-muted small d-none d-md-inline">|</span>
<button type="submit" name="action" value="export"
class="btn btn-sm btn-outline-secondary text-nowrap" data-bulk-action>
<i class="bi bi-file-earmark-pdf"></i> Export Selected
</button>
{% if can_manage %}
<div class="d-flex align-items-center gap-1">
<input type="text" name="follow_up_note" class="form-control form-control-sm"
style="min-width:180px;font-size:.8rem;"
placeholder="Follow-up note (optional)"
aria-label="Follow-up note applied to all selected">
<button type="submit" name="action" value="flag_followup"
class="btn btn-sm btn-outline-warning text-nowrap" data-bulk-action
data-bulk-confirm="Request a follow-up on the selected inspections? Ones not yet submitted, or already flagged, are skipped.">
<i class="bi bi-flag"></i> Request Follow-up
</button>
</div>
<button type="submit" name="action" value="clear_followup"
class="btn btn-sm btn-outline-success text-nowrap" data-bulk-action
data-bulk-confirm="Clear the follow-up flag on the selected inspections?">
<i class="bi bi-flag-fill"></i> Clear Follow-up
</button>
<button type="submit" name="action" value="delete"
class="btn btn-sm btn-outline-danger text-nowrap ms-auto" data-bulk-action
data-bulk-confirm="Permanently delete the selected inspections and their photos? This cannot be undone.">
<i class="bi bi-trash"></i> Delete
</button>
{% endif %}
</div>
</form>
@@ -0,0 +1,79 @@
{# ── Bulk-action toolbar for the issues list ──────────────────────────────────
Included by BOTH issues/list.html and modern/issues/list.html — edit here,
not in either copy.
The form lives OUTSIDE the table on purpose. Row checkboxes join it with the
HTML5 `form="issuesBulkForm"` attribute instead of being wrapped by it, so
the per-row delete / unfollow forms inside the table are never nested inside
this one (rule 9 — browsers silently discard nested forms, and the row
actions would stop working with no error).
`next` carries the current filtered list URL so the action returns here
rather than to the bare index.
Requires from the view: `staff` (assignable users).
#}
{% set can_manage = current_user.role in ['admin', 'director', 'auditor'] %}
{% set can_delete = current_user.role in ['admin', 'director'] %}
{% if can_manage or can_delete %}
<form method="POST" id="issuesBulkForm"
action="{{ url_for('issues.bulk_action') }}"
class="border-bottom bg-light px-3 py-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="next" value="{{ current_url() }}">
<div class="d-flex flex-wrap align-items-center gap-2">
<span class="small fw-semibold text-nowrap">
<span class="bulk-count">0</span> selected
</span>
<span class="text-muted small d-none d-md-inline">|</span>
{% if can_manage %}
<div class="d-flex align-items-center gap-1">
<select name="assigned_to" class="form-select form-select-sm"
style="min-width:150px;font-size:.8rem;" aria-label="Assign selected to">
<option value="0">— Unassigned —</option>
{% for u in staff %}
<option value="{{ u.id }}">{{ u.display_name }}{{ ' (Customer)' if u.is_external_inspector }}</option>
{% endfor %}
</select>
<button type="submit" name="action" value="assign"
class="btn btn-sm btn-outline-primary text-nowrap" data-bulk-action
data-bulk-confirm="Assign the selected issues to the chosen user?">
<i class="bi bi-person-check"></i> Assign
</button>
</div>
<div class="d-flex align-items-center gap-1">
<select name="status" class="form-select form-select-sm"
style="min-width:150px;font-size:.8rem;" aria-label="Set status of selected">
<option value="">— Set status… —</option>
<option value="open">Open</option>
<option value="in_progress">In Progress</option>
<option value="pending_verification">Pending Verification</option>
<option value="resolved">Resolved</option>
</select>
<button type="submit" name="action" value="status"
class="btn btn-sm btn-outline-primary text-nowrap" data-bulk-action
data-bulk-confirm="Change the status of the selected issues?">
<i class="bi bi-arrow-repeat"></i> Apply
</button>
</div>
<button type="submit" name="action" value="verify"
class="btn btn-sm btn-outline-success text-nowrap" data-bulk-action
data-bulk-confirm="Verify and close the selected issues? Issues that are not awaiting verification are skipped.">
<i class="bi bi-patch-check"></i> Verify &amp; Close
</button>
{% endif %}
{% if can_delete %}
<button type="submit" name="action" value="delete"
class="btn btn-sm btn-outline-danger text-nowrap ms-auto" data-bulk-action
data-bulk-confirm="Permanently delete the selected issues and their photos? This cannot be undone.">
<i class="bi bi-trash"></i> Delete
</button>
{% endif %}
</div>
</form>
{% endif %}
@@ -0,0 +1,77 @@
{# ── Shared row-selection behaviour for bulk-action list pages ────────────────
Included by the issues and inspections list templates (classic + modern).
Generic on purpose — it keys off classes/attributes, not page-specific ids,
so both pages share one implementation:
.bulk-check one per row (name=issue_ids / inspection_ids)
.bulk-check-all the header select-all box
.bulk-count element whose text becomes the selected count
[data-bulk-action] submit buttons, disabled while nothing is selected
[data-bulk-confirm] optional confirm text, count substituted for {n}
Guarding the submit on a zero selection matters: the browser would happily
POST an empty id list, and the route would flash "No issues selected" after
a full page round trip.
#}
<script>
(function () {
'use strict';
var boxes = Array.prototype.slice.call(document.querySelectorAll('.bulk-check'));
var all = document.querySelector('.bulk-check-all');
var counts = Array.prototype.slice.call(document.querySelectorAll('.bulk-count'));
var btns = Array.prototype.slice.call(document.querySelectorAll('[data-bulk-action]'));
if (!boxes.length) return;
function selected() {
return boxes.filter(function (b) { return b.checked; });
}
function sync() {
var n = selected().length;
counts.forEach(function (el) { el.textContent = n; });
btns.forEach(function (b) { b.disabled = (n === 0); });
if (all) {
all.checked = (n > 0 && n === boxes.length);
// Distinguishes "some" from "none"/"all" in the header box.
all.indeterminate = (n > 0 && n < boxes.length);
}
}
boxes.forEach(function (b) { b.addEventListener('change', sync); });
if (all) {
all.addEventListener('change', function () {
boxes.forEach(function (b) { b.checked = all.checked; });
sync();
});
}
// Shift-click selects the range from the last clicked box — the usual
// convention, and the difference between ticking 3 boxes and 40.
var lastIndex = null;
boxes.forEach(function (b, i) {
b.addEventListener('click', function (e) {
if (e.shiftKey && lastIndex !== null) {
var lo = Math.min(lastIndex, i), hi = Math.max(lastIndex, i);
for (var j = lo; j <= hi; j++) { boxes[j].checked = b.checked; }
sync();
}
lastIndex = i;
});
});
btns.forEach(function (btn) {
btn.addEventListener('click', function (e) {
var n = selected().length;
if (n === 0) { e.preventDefault(); return; }
var msg = btn.getAttribute('data-bulk-confirm');
if (msg && !window.confirm(msg.replace('{n}', n) + '\n\n' + n + ' selected.')) {
e.preventDefault();
}
});
});
sync();
}());
</script>
@@ -115,6 +115,9 @@
data-inspector-id="{{ s.id }}"> data-inspector-id="{{ s.id }}">
<td class="fw-semibold"> <td class="fw-semibold">
{{ s.display_name }} {{ s.display_name }}
{% if s.external %}
<span class="badge bg-dark ms-1" title="Customer / third-party inspector">Customer</span>
{% endif %}
</td> </td>
<td class="text-center">{{ s.total }}</td> <td class="text-center">{{ s.total }}</td>
<td class="text-center">{{ s.completed }}</td> <td class="text-center">{{ s.completed }}</td>
@@ -189,6 +192,9 @@
<div class="card-header bg-primary text-white d-flex justify-content-between align-items-center"> <div class="card-header bg-primary text-white d-flex justify-content-between align-items-center">
<h6 class="mb-0"> <h6 class="mb-0">
<i class="bi bi-person-circle me-2"></i>{{ selected_inspector.display_name }} <i class="bi bi-person-circle me-2"></i>{{ selected_inspector.display_name }}
{% if selected_inspector.is_external_inspector %}
<span class="badge bg-dark ms-1" title="Customer / third-party inspector">Customer</span>
{% endif %}
</h6> </h6>
<a href="{{ url_for('reports.inspector_performance', start=start.strftime('%Y-%m-%d'), end=end.strftime('%Y-%m-%d')) }}" <a href="{{ url_for('reports.inspector_performance', start=start.strftime('%Y-%m-%d'), end=end.strftime('%Y-%m-%d')) }}"
class="btn btn-sm btn-light text-primary"> class="btn btn-sm btn-light text-primary">
@@ -11,6 +11,10 @@
<a href="{{ url_for('support.admin_tickets') }}" class="btn btn-outline-secondary btn-sm me-1"> <a href="{{ url_for('support.admin_tickets') }}" class="btn btn-outline-secondary btn-sm me-1">
<i class="bi bi-inbox me-1"></i>Tickets <i class="bi bi-inbox me-1"></i>Tickets
</a> </a>
<a href="{{ url_for('support.admin_knowledge_preview') }}" class="btn btn-outline-primary btn-sm"
title="See the exact prompt the chatbot receives, with your entries in it">
<i class="bi bi-eye me-1"></i>What the AI Sees
</a>
<a href="{{ url_for('support.admin_conversations') }}" class="btn btn-outline-secondary btn-sm"> <a href="{{ url_for('support.admin_conversations') }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-chat-dots me-1"></i>Conversations <i class="bi bi-chat-dots me-1"></i>Conversations
</a> </a>
@@ -34,6 +38,12 @@
placeholder="Add facts, FAQs, or instructions the AI should know about this customer's account…"></textarea> placeholder="Add facts, FAQs, or instructions the AI should know about this customer's account…"></textarea>
<div class="form-text">Keep entries focused and factual. Combined active entries are capped at 6,000 characters.</div> <div class="form-text">Keep entries focused and factual. Combined active entries are capped at 6,000 characters.</div>
</div> </div>
<div class="mb-3" style="max-width:200px;">
<label class="form-label fw-semibold">Sort Order</label>
<input type="number" name="sort_order" class="form-control"
min="0" max="9999" step="1" value="0">
<div class="form-text">Lower numbers are sent to the assistant first. Leave at 0 unless an entry should take priority.</div>
</div>
<button type="submit" class="btn btn-primary btn-sm"> <button type="submit" class="btn btn-primary btn-sm">
<i class="bi bi-plus me-1"></i>Add Entry <i class="bi bi-plus me-1"></i>Add Entry
</button> </button>
@@ -54,6 +64,7 @@
<div class="d-flex justify-content-between align-items-start"> <div class="d-flex justify-content-between align-items-start">
<div class="flex-grow-1 me-3"> <div class="flex-grow-1 me-3">
<div class="d-flex align-items-center gap-2 mb-1"> <div class="d-flex align-items-center gap-2 mb-1">
<span class="badge bg-light text-dark border" title="Sort order">#{{ entry.sort_order }}</span>
<span class="fw-semibold">{{ entry.title }}</span> <span class="fw-semibold">{{ entry.title }}</span>
{% if entry.active %} {% if entry.active %}
<span class="badge bg-success">Active</span> <span class="badge bg-success">Active</span>
@@ -23,6 +23,12 @@
<textarea name="body" class="form-control" rows="8" required>{{ entry.body }}</textarea> <textarea name="body" class="form-control" rows="8" required>{{ entry.body }}</textarea>
<div class="form-text">Combined active entries are capped at 6,000 characters in the AI prompt.</div> <div class="form-text">Combined active entries are capped at 6,000 characters in the AI prompt.</div>
</div> </div>
<div class="mb-3" style="max-width:200px;">
<label class="form-label fw-semibold">Sort Order</label>
<input type="number" name="sort_order" class="form-control"
min="0" max="9999" step="1" value="{{ entry.sort_order }}">
<div class="form-text">Lower numbers are sent to the assistant first. Leave at 0 unless an entry should take priority.</div>
</div>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<button type="submit" class="btn btn-primary"> <button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg me-1"></i>Save Changes <i class="bi bi-check-lg me-1"></i>Save Changes
@@ -0,0 +1,79 @@
{% extends "base.html" %}
{% block title %}What the AI Sees{% endblock %}
{# Read-only view of the assembled system prompt. Exists so an admin can tell
"my knowledge entry never reached the prompt" apart from "the model saw it
and chose not to use it" — the two have completely different fixes. #}
{% block content %}
<div class="d-flex flex-wrap justify-content-between align-items-center mb-3 gap-2">
<h2 class="mb-0"><i class="bi bi-eye"></i> What the AI Sees</h2>
<a href="{{ url_for('support.admin_knowledge') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Back to Knowledge Base
</a>
</div>
<div class="row g-3 mb-3">
<div class="col-6 col-md-3">
<div class="card shadow-sm h-100">
<div class="card-body text-center py-3">
<div class="fs-4 fw-bold">{{ active_count }}</div>
<div class="text-muted small">Active entries</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card shadow-sm h-100">
<div class="card-body text-center py-3">
<div class="fs-4 fw-bold">{{ total_count - active_count }}</div>
<div class="text-muted small">Inactive (not sent)</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card shadow-sm h-100">
<div class="card-body text-center py-3">
<div class="fs-4 fw-bold">{{ prompt | length }}</div>
<div class="text-muted small">Prompt characters</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card shadow-sm h-100">
<div class="card-body text-center py-3">
{% if kb_included %}
<div class="fs-4 fw-bold text-success"><i class="bi bi-check-circle"></i></div>
<div class="text-muted small">Knowledge included</div>
{% else %}
<div class="fs-4 fw-bold text-danger"><i class="bi bi-x-circle"></i></div>
<div class="text-muted small">Knowledge NOT included</div>
{% endif %}
</div>
</div>
</div>
</div>
{% if not kb_included and total_count %}
<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle me-1"></i>
You have {{ total_count }} knowledge entr{{ 'y' if total_count == 1 else 'ies' }}, but
none reached the prompt. Check that at least one is marked <strong>Active</strong>.
</div>
{% endif %}
<div class="alert alert-info">
<i class="bi bi-info-circle me-1"></i>
This is the exact text sent to the AI ahead of every customer question. Entries are
capped at {{ kb_cap }} characters in total — past that, later entries are dropped
(lowest sort order is kept first). If something you wrote appears here but the AI still
will not say it, the wording of the entry is the thing to change, not the setup.
</div>
<div class="card shadow-sm">
<div class="card-header bg-light fw-semibold">Assembled system prompt</div>
<div class="card-body p-0">
<pre class="mb-0 p-3" style="white-space:pre-wrap; font-size:.8rem; max-height:70vh;
overflow-y:auto; background:#f8fafc;">{{ prompt }}</pre>
</div>
</div>
{% endblock %}
+9
View File
@@ -160,6 +160,15 @@
{{ form.frequency.label(class="form-label fw-semibold small") }} {{ form.frequency.label(class="form-label fw-semibold small") }}
{{ form.frequency(class="form-select form-select-sm") }} {{ form.frequency(class="form-select form-select-sm") }}
</div> </div>
<div class="mb-3">
{{ form.contract_ids.label(class="form-label fw-semibold small") }}
{{ form.contract_ids(class="form-select form-select-sm", size=6) }}
<div class="form-text small">
Nothing selected = shared with every contract. Select contracts to
restrict this form to them (hidden from all other customers).
</div>
</div>
<div class="d-grid gap-2"> <div class="d-grid gap-2">
<button type="submit" class="btn btn-primary btn-sm"> <button type="submit" class="btn btn-primary btn-sm">
+12
View File
@@ -27,6 +27,18 @@
{{ form.frequency.label(class="form-label") }} {{ form.frequency.label(class="form-label") }}
{{ form.frequency(class="form-select") }} {{ form.frequency(class="form-select") }}
</div> </div>
<div class="mb-3">
{{ form.contract_ids.label(class="form-label") }}
{{ form.contract_ids(class="form-select", size=6) }}
<div class="form-text">
Leave <strong>nothing selected</strong> to share this form with
every contract. Select one or more contracts to make it
specific to them — it will then be hidden from every other
customer's facilities, on the web and in the iPad app.
Ctrl/Cmd-click to select several.
</div>
</div>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<button type="submit" class="btn btn-primary"> <button type="submit" class="btn btn-primary">
+67 -1
View File
@@ -38,6 +38,24 @@
<i class="bi bi-check2-square"></i> {{ template.checklist_items.count() }} items <i class="bi bi-check2-square"></i> {{ template.checklist_items.count() }} items
</small> </small>
</div> </div>
{# phase52 — who may use this form. No links = shared with all. #}
<div class="mt-2">
{% if template.is_shared %}
<span class="badge bg-light text-dark border"
title="Available on every contract">
<i class="bi bi-globe2"></i> Shared
</span>
{% else %}
{% for link in template.contract_links %}
<span class="badge bg-primary"
title="Only available on this contract">
<i class="bi bi-briefcase"></i>
{{ link.project.name if link.project else 'contract #' ~ link.project_id }}
</span>
{% endfor %}
{% endif %}
</div>
</div> </div>
<div class="card-footer bg-transparent d-flex gap-2 align-items-center flex-wrap"> <div class="card-footer bg-transparent d-flex gap-2 align-items-center flex-wrap">
@@ -56,6 +74,7 @@
data-template-name="{{ template.name }}" data-template-name="{{ template.name }}"
data-template-description="{{ template.description or '' }}" data-template-description="{{ template.description or '' }}"
data-template-frequency="{{ template.frequency or 'daily' }}" data-template-frequency="{{ template.frequency or 'daily' }}"
data-template-contracts="{{ template.contract_ids|join(',') }}"
title="Edit template details"> title="Edit template details">
<i class="bi bi-pencil"></i> Edit <i class="bi bi-pencil"></i> Edit
</button> </button>
@@ -142,7 +161,7 @@
placeholder="Optional description"></textarea> placeholder="Optional description"></textarea>
</div> </div>
<div class="mb-1"> <div class="mb-3">
<label for="editFrequency" class="form-label fw-semibold">Frequency</label> <label for="editFrequency" class="form-label fw-semibold">Frequency</label>
<select id="editFrequency" name="frequency" class="form-select"> <select id="editFrequency" name="frequency" class="form-select">
<option value="daily">Daily</option> <option value="daily">Daily</option>
@@ -151,6 +170,34 @@
<option value="quarterly">Quarterly</option> <option value="quarterly">Quarterly</option>
</select> </select>
</div> </div>
{# phase52 — which contracts may use this form. The hidden
marker tells the route this modal really did include the
field, so an empty selection means "share it" rather
than "no field was posted, leave it alone". #}
<div class="mb-1">
<label for="editContracts" class="form-label fw-semibold">
Available on contracts
</label>
<input type="hidden" name="contracts_present" value="1">
<select id="editContracts" name="contract_ids"
class="form-select" multiple size="6">
{% for p in contracts %}
<option value="{{ p.id }}">{{ p.name }}</option>
{% endfor %}
</select>
<div class="form-text">
Leave <strong>nothing selected</strong> to share this form with
every contract (this is how all existing forms are set).
Select one or more contracts to restrict it to them — it is
then hidden from every other customer, on the web and in the
iPad app. Ctrl/Cmd-click to select several.
</div>
<button type="button" id="clearContracts"
class="btn btn-sm btn-link px-0 mt-1">
Clear selection (make shared)
</button>
</div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal"> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
@@ -228,6 +275,17 @@ document.addEventListener('DOMContentLoaded', function () {
document.getElementById('renameInput').value = templateName; document.getElementById('renameInput').value = templateName;
document.getElementById('editDescription').value = templateDesc; document.getElementById('editDescription').value = templateDesc;
document.getElementById('editFrequency').value = templateFreq; document.getElementById('editFrequency').value = templateFreq;
// Pre-tick the contracts this form is currently restricted to. An
// empty attribute means it is shared, so nothing is selected.
const contractSel = document.getElementById('editContracts');
if (contractSel) {
const current = (btn.getAttribute('data-template-contracts') || '')
.split(',').filter(Boolean);
Array.from(contractSel.options).forEach(function (o) {
o.selected = current.indexOf(o.value) !== -1;
});
}
document.getElementById('renameTemplateForm').action = document.getElementById('renameTemplateForm').action =
'/templates/' + templateId + '/rename'; '/templates/' + templateId + '/rename';
@@ -238,6 +296,14 @@ document.addEventListener('DOMContentLoaded', function () {
}); });
}); });
const clearBtn = document.getElementById('clearContracts');
if (clearBtn) {
clearBtn.addEventListener('click', function () {
const sel = document.getElementById('editContracts');
Array.from(sel.options).forEach(function (o) { o.selected = false; });
});
}
const deleteModal = document.getElementById('deleteModal'); const deleteModal = document.getElementById('deleteModal');
deleteModal.addEventListener('show.bs.modal', function (event) { deleteModal.addEventListener('show.bs.modal', function (event) {
const btn = event.relatedTarget; const btn = event.relatedTarget;
+2 -2
View File
@@ -38,7 +38,7 @@
<label class="form-label fw-semibold">Logo</label> <label class="form-label fw-semibold">Logo</label>
{% if settings.logo_url %} {% if settings.logo_url %}
<div class="mb-2"> <div class="mb-2">
<img src="{{ url_for('static', filename=settings.logo_url) }}" <img src="{{ media_url(settings.logo_url) }}"
alt="Current logo" style="max-height:48px; border-radius:6px;"> alt="Current logo" style="max-height:48px; border-radius:6px;">
<div class="form-check mt-1"> <div class="form-check mt-1">
<input class="form-check-input" type="checkbox" name="clear_logo" id="clear_logo"> <input class="form-check-input" type="checkbox" name="clear_logo" id="clear_logo">
@@ -105,7 +105,7 @@
<nav class="navbar navbar-dark px-3 py-2" id="preview-navbar" <nav class="navbar navbar-dark px-3 py-2" id="preview-navbar"
style="background-color: {{ settings.primary_color or '#1a56db' }}; border-radius:0 0 6px 6px;"> style="background-color: {{ settings.primary_color or '#1a56db' }}; border-radius:0 0 6px 6px;">
{% if settings.logo_url %} {% if settings.logo_url %}
<img src="{{ url_for('static', filename=settings.logo_url) }}" <img src="{{ media_url(settings.logo_url) }}"
alt="logo" style="max-height:32px; margin-right:.5rem; border-radius:4px;"> alt="logo" style="max-height:32px; margin-right:.5rem; border-radius:4px;">
{% else %} {% else %}
<i class="bi bi-clipboard-check me-2"></i> <i class="bi bi-clipboard-check me-2"></i>
+10 -5
View File
@@ -98,17 +98,22 @@
('facilities', 'Active Facilities (total)', plan_info.max_facilities), ('facilities', 'Active Facilities (total)', plan_info.max_facilities),
] %} ] %}
{% for key, label, limit in axes %} {% for key, label, limit in axes %}
{% set current = quota_usage.get(key, 0) %} {# None = the counter failed (logged server-side). Rendered as "—"
{% set pct = ((current / limit * 100) | int) if limit else 0 %} rather than 0 so an unknown count is never mistaken for real usage. #}
{% set over = limit and current >= limit %} {% set current = quota_usage.get(key) %}
{% set unknown = current is none %}
{% set pct = ((current / limit * 100) | int) if (limit and not unknown) else 0 %}
{% set over = limit and not unknown and current >= limit %}
<div class="mb-3"> <div class="mb-3">
<div class="d-flex justify-content-between mb-1" style="font-size:.85rem;"> <div class="d-flex justify-content-between mb-1" style="font-size:.85rem;">
<span>{{ label }}</span> <span>{{ label }}</span>
<span class="{{ 'text-danger fw-semibold' if over else 'text-muted' }}"> <span class="{{ 'text-danger fw-semibold' if over else 'text-muted' }}">
{{ current }}{% if limit %} / {{ limit }}{% else %} / <em>unlimited</em>{% endif %} {% if unknown %}
<span title="This count could not be read — see the server log.">&mdash;</span>
{% else %}{{ current }}{% endif %}{% if limit %} / {{ limit }}{% else %} / <em>unlimited</em>{% endif %}
</span> </span>
</div> </div>
{% if limit %} {% if limit and not unknown %}
<div class="progress" style="height:6px;"> <div class="progress" style="height:6px;">
<div class="progress-bar {{ 'bg-danger' if over else ('bg-warning' if pct >= 80 else 'bg-success') }}" <div class="progress-bar {{ 'bg-danger' if over else ('bg-warning' if pct >= 80 else 'bg-success') }}"
role="progressbar" style="width:{{ [pct,100]|min }}%"></div> role="progressbar" style="width:{{ [pct,100]|min }}%"></div>
+142
View File
@@ -0,0 +1,142 @@
{% extends "base.html" %}
{% block title %}About{% endblock %}
{# About — linked from the modern sidebar.
MT note: ST's version of this page hardcodes its own company name and links
the `enrollment` blueprint, neither of which is portable here. This version
is tenant-neutral: the workspace name comes from tenant_branding (the same
source the layouts use), and "add people" points at the Users admin page,
which is how accounts are actually created in MT. #}
{% set workspace_name = tenant_branding.display_name if tenant_branding else 'this workspace' %}
{% block content %}
<div class="jqc-page-head center">
<div class="jqc-page-title">About</div>
<div class="jqc-page-sub text-center">
Janitorial Quality Control{% if tenant_branding %} — {{ tenant_branding.display_name }}{% endif %}
</div>
</div>
<div class="row g-4">
<div class="col-12 col-lg-7">
<div class="jqc-card h-100">
<div class="jqc-card-title">
<span class="jqc-tile-icon"><i class="bi bi-buildings"></i></span>{{ workspace_name }}
</div>
<p class="mb-3">
Quality is verified in the field, not assumed. Every contract is backed by
scheduled inspections, documented findings and tracked resolution, so what
was checked, when, and by whom is always on the record.
</p>
<p class="mb-0 text-muted">
JQC is the quality control platform behind that work. Inspectors work from an
offline-capable iPad app; managers, contract staff and customers work from
this web portal. Both share one record of every inspection, issue and photo.
</p>
</div>
</div>
<div class="col-12 col-lg-5">
<div class="jqc-card h-100">
<div class="jqc-card-title">
<span class="jqc-tile-icon"><i class="bi bi-shield-check"></i></span>Your data
</div>
<p class="mb-0 text-muted">
{{ workspace_name }} has its own isolated database. Inspections, issues,
photos and accounts belong to this workspace alone and are never shared
with, or visible to, any other organisation using JQC.
</p>
</div>
</div>
{% if current_user.role == 'admin' %}
<div class="col-12 col-lg-6">
<a class="jqc-hub-card" href="{{ url_for('auth.list_users') }}">
<div class="d-flex gap-3 align-items-start">
<span class="jqc-tile-icon lg"><i class="bi bi-person-plus"></i></span>
<div>
<div class="jqc-hub-title">Add More People</div>
<div class="jqc-hub-text">
Create accounts for colleagues and set what each person can do.
Customer Directors and Customer Inspectors are invited by email from
Customer Management and choose their own username and password.
</div>
</div>
</div>
<div class="jqc-hub-open">Open &rarr;</div>
</a>
</div>
{# The enrollment intake form is served by THIS app and is public (no login),
so it is linked with url_for() rather than an absolute URL: the link then
stays on whatever host the user is already on — which in MT is the tenant's
own subdomain or custom domain — and cannot rot if that domain changes.
Submissions are read at Admin -> Enrollment Forms. #}
<div class="col-12 col-lg-6">
<a class="jqc-hub-card" href="{{ url_for('enrollment.form') }}">
<div class="d-flex gap-3 align-items-start">
<span class="jqc-tile-icon lg"><i class="bi bi-clipboard-plus"></i></span>
<div>
<div class="jqc-hub-title">Enrollment Form</div>
<div class="jqc-hub-text">
Collect who needs access and what each person should be able to do.
No login is required to fill it in, so the link can be forwarded to a
customer; submissions arrive under Admin &rarr; Enrollment Forms.
</div>
</div>
</div>
<div class="jqc-hub-open">Open &rarr;</div>
</a>
</div>
<div class="col-12 col-lg-6">
<a class="jqc-hub-card" href="{{ url_for('enrollment.admin_list') }}">
<div class="d-flex gap-3 align-items-start">
<span class="jqc-tile-icon lg"><i class="bi bi-inboxes"></i></span>
<div>
<div class="jqc-hub-title">Enrollment Submissions</div>
<div class="jqc-hub-text">
Every enrollment form that has been submitted, with its office
notes, status and CSV export.
</div>
</div>
</div>
<div class="jqc-hub-open">Open &rarr;</div>
</a>
</div>
<div class="col-12 col-lg-6">
<a class="jqc-hub-card" href="{{ url_for('tenant_settings.branding') }}">
<div class="d-flex gap-3 align-items-start">
<span class="jqc-tile-icon lg"><i class="bi bi-palette"></i></span>
<div>
<div class="jqc-hub-title">Workspace Settings</div>
<div class="jqc-hub-text">
Set the workspace name, logo and colours used across the portal and
in outbound email.
</div>
</div>
</div>
<div class="jqc-hub-open">Open &rarr;</div>
</a>
</div>
{% endif %}
<div class="col-12">
<div class="jqc-card">
<div class="jqc-card-title">
<span class="jqc-tile-icon"><i class="bi bi-envelope"></i></span>Contact &amp; Support
</div>
<p class="mb-3">
Questions about an inspection, an issue on a site, or access to the portal —
start in the Support Center and it will be routed to the right person.
</p>
<a href="{{ url_for('ui.support_center') }}" class="btn btn-primary">
<i class="bi bi-life-preserver me-1"></i>Go to Support
</a>
</div>
</div>
</div>
{% endblock %}
+150
View File
@@ -0,0 +1,150 @@
{% extends "base.html" %}
{% block title %}Support{% endblock %}
{#
Support Center — new page (design A/B test, slide 6 of JQC_design).
Linked from the modern sidebar. Every card either opens an existing route or
expands an inline how-to, so nothing here dead-ends.
#}
{% block content %}
<div class="jqc-page-head center">
<div class="jqc-page-title">Support</div>
<div class="jqc-page-sub text-center">JQC Features — find answers and how-to guides</div>
</div>
{# ── Live support routes (role-aware) ──────────────────────────────────── #}
<div class="row g-3 mb-4">
{% if current_user.is_customer_account %}
<div class="col-12 col-md-4">
<a class="jqc-hub-card" href="{{ url_for('support.chat') }}">
<div class="d-flex gap-3 align-items-center">
<span class="jqc-tile-icon"><i class="bi bi-chat-dots"></i></span>
<div class="jqc-hub-title" style="font-size:1.05rem;">Ask a Question</div>
</div>
</a>
</div>
<div class="col-12 col-md-4">
<a class="jqc-hub-card" href="{{ url_for('support.my_conversations') }}">
<div class="d-flex gap-3 align-items-center">
<span class="jqc-tile-icon"><i class="bi bi-clock-history"></i></span>
<div class="jqc-hub-title" style="font-size:1.05rem;">My Conversations</div>
</div>
</a>
</div>
<div class="col-12 col-md-4">
<a class="jqc-hub-card" href="{{ url_for('support.my_tickets') }}">
<div class="d-flex gap-3 align-items-center">
<span class="jqc-tile-icon"><i class="bi bi-inbox"></i></span>
<div class="jqc-hub-title" style="font-size:1.05rem;">My Requests</div>
</div>
</a>
</div>
{% elif current_user.role in ['admin', 'director'] %}
<div class="col-12 col-md-4">
<a class="jqc-hub-card" href="{{ url_for('support.admin_tickets') }}">
<div class="d-flex gap-3 align-items-center">
<span class="jqc-tile-icon"><i class="bi bi-inbox"></i></span>
<div class="jqc-hub-title" style="font-size:1.05rem;">Support Requests</div>
</div>
{% if open_support_tickets_count > 0 %}
<div class="jqc-hub-text">{{ open_support_tickets_count }} open</div>
{% endif %}
</a>
</div>
<div class="col-12 col-md-4">
<a class="jqc-hub-card" href="{{ url_for('support.admin_conversations') }}">
<div class="d-flex gap-3 align-items-center">
<span class="jqc-tile-icon"><i class="bi bi-chat-square-text"></i></span>
<div class="jqc-hub-title" style="font-size:1.05rem;">Chat Conversations</div>
</div>
</a>
</div>
<div class="col-12 col-md-4">
<a class="jqc-hub-card" href="{{ url_for('support.admin_knowledge') }}">
<div class="d-flex gap-3 align-items-center">
<span class="jqc-tile-icon"><i class="bi bi-journal-text"></i></span>
<div class="jqc-hub-title" style="font-size:1.05rem;">Knowledge Base</div>
</div>
</a>
</div>
{% endif %}
</div>
{# ── How-to guides ─────────────────────────────────────────────────────── #}
{% set guides = [
('bi-clipboard-check', 'How to create a new inspection',
'Inspections → New Inspection. Pick the contract, facility, area and template, then Start. The checklist opens straight away and saves as you go — you can leave and resume from Inspections → In Progress.'),
('bi-search', 'How to follow up on an inspection',
'Open the inspection and use Request Follow-up. It moves to the Follow-up list, notifies the inspector, and stays there until a re-inspection is submitted against it.'),
('bi-calendar2-week', 'How to schedule an inspection',
'Inspections → Schedule. Choose facility, template, inspector and how often it repeats. Recurring schedules roll their due date forward automatically once the inspection is submitted.'),
('bi-exclamation-triangle', 'How to Flag For Attention',
'While executing an inspection, use Flag Issue on any failing item. Set severity and who handles it (janitorial crew, facility staff or an outside vendor) — the SLA clock starts from that moment.'),
('bi-qr-code', 'How QR code works',
"Every facility and area has a QR code. Scanning it opens that location's public page — anyone on site can report a problem without an account, and the request lands in Issues."),
('bi-file-earmark-text','Send a request without the app',
'Point the on-site contact at the facility QR code, or forward them the public facility link. Their submission arrives as an unassigned issue for triage.'),
('bi-search', 'How to search',
'Use the search box in the top bar for an inspection number. For anything broader, each list page has filters for contract, facility, inspector, status, date range and score.'),
('bi-chat-dots', 'How to comment',
'Open any inspection or issue and use the comment box at the bottom. Comments are visible to staff; sharing one with the customer is an explicit choice on the comment itself.'),
('bi-bar-chart', 'Create & print inspection reports',
'Reports & Analytics → filter by date, contract, facility or inspector → Apply. Export to CSV, or use the PDF export on an individual inspection or facility scorecard.'),
('bi-clock', 'What does SLA At Risk mean?',
'The issue is approaching its resolution deadline for its severity but has not passed it yet. Treat it as the last window to close the issue on time.'),
('bi-alarm', 'What does an SLA Alert mean?',
'The issue has passed its resolution deadline for its severity. It stays flagged until resolved and shows on the dashboard SLA card.'),
] %}
<div class="row g-3">
{% for icon, title, body in guides %}
<div class="col-12 col-md-6 col-xl-4">
<div class="jqc-hub-card" role="button" data-bs-toggle="collapse"
data-bs-target="#guide{{ loop.index }}" aria-expanded="false">
<div class="d-flex gap-3 align-items-center">
<span class="jqc-tile-icon"><i class="bi {{ icon }}"></i></span>
<div class="jqc-hub-title" style="font-size:1.02rem;">{{ title }}</div>
</div>
<div class="collapse" id="guide{{ loop.index }}">
<div class="jqc-hub-text mt-3">{{ body }}</div>
</div>
</div>
</div>
{% endfor %}
{# The AI chat is for customer-side accounts (support.chat redirects staff to the ticket
queue, which is itself @supervisor_required). So the destination is chosen
per role rather than pointed at support.chat for everyone — an inspector
following that chain would land on the dashboard with an access-denied
flash, and this page is meant never to dead-end. Roles with no support
destination get no card; the how-to guides below are their support. #}
{% if current_user.is_customer_account %}
<div class="col-12 col-md-6 col-xl-4">
<a class="jqc-hub-card dark" href="{{ url_for('support.chat') }}">
<div class="d-flex gap-3 align-items-center">
<span class="jqc-tile-icon"><i class="bi bi-life-preserver"></i></span>
<div>
<div class="jqc-hub-title" style="font-size:1.02rem;">AI Support</div>
<div class="jqc-hub-text">Ask a question and get an answer straight away.</div>
</div>
</div>
<div class="jqc-hub-open">Open &rarr;</div>
</a>
</div>
{% elif current_user.role in ['admin', 'director'] %}
<div class="col-12 col-md-6 col-xl-4">
<a class="jqc-hub-card dark" href="{{ url_for('support.admin_conversations') }}">
<div class="d-flex gap-3 align-items-center">
<span class="jqc-tile-icon"><i class="bi bi-life-preserver"></i></span>
<div>
<div class="jqc-hub-title" style="font-size:1.02rem;">AI Support</div>
<div class="jqc-hub-text">Review the AI chat conversations customers have had.</div>
</div>
</div>
<div class="jqc-hub-open">Open &rarr;</div>
</a>
</div>
{% endif %}
</div>
{% endblock %}
+71
View File
@@ -0,0 +1,71 @@
{% extends "base.html" %}
{% block title %}Design Vote Tally{% endblock %}
{# Admin-only: which design are active users currently keeping? #}
{% block content %}
<div class="row mb-4">
<div class="col">
<h2 class="mb-1"><i class="bi bi-bar-chart"></i> Design Vote Tally</h2>
<div class="text-muted">Which web portal design each active user is currently using.</div>
</div>
</div>
<div class="row g-3 mb-4">
<div class="col-12 col-md-4">
<div class="card h-100">
<div class="card-body">
<div class="text-muted small">Classic design</div>
<div class="fs-2 fw-bold">{{ tally.classic }}</div>
<div class="text-muted small">
{{ ((tally.classic / total * 100) | round(1)) if total else 0 }}% of {{ total }} active users
</div>
</div>
</div>
</div>
<div class="col-12 col-md-4">
<div class="card h-100">
<div class="card-body">
<div class="text-muted small">New design</div>
<div class="fs-2 fw-bold">{{ tally.modern }}</div>
<div class="text-muted small">
{{ ((tally.modern / total * 100) | round(1)) if total else 0 }}% of {{ total }} active users
</div>
</div>
</div>
</div>
<div class="col-12 col-md-4">
<div class="card h-100">
<div class="card-body">
<div class="text-muted small">Total active users</div>
<div class="fs-2 fw-bold">{{ total }}</div>
<div class="text-muted small">Every account defaults to classic</div>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header fw-semibold"><i class="bi bi-people me-1"></i>Breakdown by role</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr><th>Role</th><th>Design</th><th class="text-end">Users</th></tr>
</thead>
<tbody>
{% for role, theme, count in by_role %}
<tr>
<td>{{ role_labels.get(role, role.replace('_',' ')|title) }}</td>
<td>{{ 'New design' if theme == 'modern' else 'Classic design' }}</td>
<td class="text-end fw-semibold">{{ count }}</td>
</tr>
{% else %}
<tr><td colspan="3" class="text-center text-muted py-4">No active users.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
+11
View File
@@ -12,6 +12,10 @@ from app.tenancy.middleware import init_tenancy
from app.tenancy.context import TenantContext from app.tenancy.context import TenantContext
from app.tenancy.gates import feature_required, quota_soft_check from app.tenancy.gates import feature_required, quota_soft_check
from app.tenancy.quota import check_quota, check_feature from app.tenancy.quota import check_quota, check_feature
from app.tenancy.session_binding import (
bind_session_tenant, enforce_session_tenant, current_tenant_id,
tag_user_id, parse_user_id, SESSION_TENANT_KEY,
)
__all__ = [ __all__ = [
'RoutingSession', 'RoutingSession',
@@ -21,4 +25,11 @@ __all__ = [
'quota_soft_check', 'quota_soft_check',
'check_quota', 'check_quota',
'check_feature', 'check_feature',
# MT-21
'bind_session_tenant',
'enforce_session_tenant',
'current_tenant_id',
'tag_user_id',
'parse_user_id',
'SESSION_TENANT_KEY',
] ]
+44 -9
View File
@@ -5,40 +5,75 @@ Process-local cache of per-tenant SQLAlchemy engines, keyed by tenant id.
Each tenant has its own database, hence its own engine + connection pool. Each tenant has its own database, hence its own engine + connection pool.
Engines are created lazily on first use and reused across requests. Total Engines are created lazily on first use and reused across requests. Total
backend connections workers × cached-tenants × pool_size, so pool sizing is backend connections workers × cached-tenants × (pool_size + max_overflow),
a real scaling lever (see MULTI_TENANT_PLAN.md §4); tune via config, or set a so the cache is BOUNDED (MT-21): it holds at most
small pool / switch to NullPool when the tenant count grows large. ``TENANT_ENGINE_CACHE_MAX`` engines and evicts the least-recently-used one
beyond that, disposing it. Without the bound, a process that has served N
tenants holds N pools forever, and the connection count grows without limit
until MySQL's ``max_connections`` (default 151) rejects new sessions.
Worked example 8 workers, pool_size 2 + max_overflow 3, cache cap 32:
8 × 32 × 5 = 1280 worst case, vs. unbounded before this change.
Eviction disposes the engine, which closes its *idle* pooled connections.
Connections already checked out by another thread stay valid and are closed
when returned, so eviction is safe under concurrency; the evicted tenant
simply rebuilds its engine on the next request.
`invalidate(tenant_id)` drops a cached engine (e.g. after credential rotation `invalidate(tenant_id)` drops a cached engine (e.g. after credential rotation
or tenant suspension); the next request rebuilds it. or tenant suspension); the next request rebuilds it.
""" """
import threading import threading
from collections import OrderedDict
from flask import current_app from flask import current_app
from sqlalchemy import create_engine from sqlalchemy import create_engine
_engines = {} _engines = OrderedDict()
_lock = threading.Lock() _lock = threading.Lock()
# Fallback cap used when no application context is available (CLI/cron paths
# that touch the cache outside a request). Mirrors the config default.
_DEFAULT_CACHE_MAX = 32
def _cache_max():
try:
return int(current_app.config.get('TENANT_ENGINE_CACHE_MAX', _DEFAULT_CACHE_MAX))
except Exception:
return _DEFAULT_CACHE_MAX
def get_tenant_engine(tenant): def get_tenant_engine(tenant):
"""Return (building if needed) the cached engine for a TenantContext.""" """Return (building if needed) the cached engine for a TenantContext."""
engine = _engines.get(tenant.id) evicted = []
if engine is not None:
return engine
with _lock: with _lock:
engine = _engines.get(tenant.id) engine = _engines.get(tenant.id)
if engine is None: if engine is None:
engine = create_engine( engine = create_engine(
tenant.db_uri, tenant.db_uri,
pool_pre_ping=True, pool_pre_ping=True,
pool_size=current_app.config.get('TENANT_ENGINE_POOL_SIZE', 5), pool_size=current_app.config.get('TENANT_ENGINE_POOL_SIZE', 2),
max_overflow=current_app.config.get('TENANT_ENGINE_MAX_OVERFLOW', 5), max_overflow=current_app.config.get('TENANT_ENGINE_MAX_OVERFLOW', 3),
pool_recycle=current_app.config.get('TENANT_ENGINE_POOL_RECYCLE', 1800), pool_recycle=current_app.config.get('TENANT_ENGINE_POOL_RECYCLE', 1800),
future=True, future=True,
) )
_engines[tenant.id] = engine _engines[tenant.id] = engine
# Bound the cache: drop least-recently-used engines beyond the cap.
cap = _cache_max()
while cap > 0 and len(_engines) > cap:
old_id, old_engine = _engines.popitem(last=False)
evicted.append((old_id, old_engine))
else:
_engines.move_to_end(tenant.id)
# Dispose outside the lock — dispose() can block on socket teardown.
for old_id, old_engine in evicted:
try:
old_engine.dispose()
except Exception:
pass
return engine return engine
+62 -1
View File
@@ -27,7 +27,8 @@ g.tenant_engine — no teardown handler is needed here.
import logging import logging
from flask import g, request, current_app, Response, session, redirect, url_for from flask import (g, request, current_app, Response, session, redirect,
url_for, jsonify)
from app.tenancy.resolver import resolve_tenant from app.tenancy.resolver import resolve_tenant
from app.tenancy.engine_cache import get_tenant_engine from app.tenancy.engine_cache import get_tenant_engine
@@ -52,6 +53,26 @@ _UNKNOWN_TENANT_PAGE = (
) )
def _wants_json():
"""True when this request is the mobile API (or explicitly asks for JSON).
Mirrors gates._is_api_request(). The tenancy and billing gates run BEFORE
any route, so without this they answer an iPad with a 302 to an HTML page:
URLSession follows it, the client decodes the login/billing markup as JSON
and reports "the data couldn't be read". The inspector sees a parse error
instead of "your subscription has expired", and nothing in the app can tell
the two apart.
"""
return (request.path.startswith('/api/')
or request.accept_mimetypes.best == 'application/json')
def _json(payload, status):
"""Small local responder — the API error helpers live in a blueprint that
is not necessarily importable this early in the request."""
return jsonify(payload), status
def _is_exempt(path): def _is_exempt(path):
if path.startswith('/static/'): if path.startswith('/static/'):
return True return True
@@ -139,6 +160,13 @@ def init_tenancy(app):
) )
g.tenant = ctx g.tenant = ctx
g.tenant_engine = get_tenant_engine(ctx) g.tenant_engine = get_tenant_engine(ctx)
# MT-21: impersonation deliberately rebinds the session
# to the impersonated tenant, so stamp it rather than
# clearing it. /auth/impersonate has already written the
# same marker; this keeps it correct if the superadmin's
# target changes mid-session.
from app.tenancy.session_binding import bind_session_tenant
bind_session_tenant()
return # skip normal Host resolution return # skip normal Host resolution
except Exception: except Exception:
logger.warning('TENANCY | impersonation_failed | tenant_id=%s', imp_id) logger.warning('TENANCY | impersonation_failed | tenant_id=%s', imp_id)
@@ -151,11 +179,25 @@ def init_tenancy(app):
host = (request.host or '').split(':')[0].strip().lower() host = (request.host or '').split(':')[0].strip().lower()
tenant = resolve_tenant(host) tenant = resolve_tenant(host)
if tenant is None: if tenant is None:
if _wants_json():
# An HTML "Workspace not found" page is unreadable to the iPad
# — it decodes as a parse failure, which looks like a bug in
# the app rather than a wrong/retired server address.
return _json({'ok': False,
'error': 'Workspace not found for this address.'}, 404)
return Response(_UNKNOWN_TENANT_PAGE, status=404, mimetype='text/html') return Response(_UNKNOWN_TENANT_PAGE, status=404, mimetype='text/html')
g.tenant = tenant g.tenant = tenant
g.tenant_engine = get_tenant_engine(tenant) g.tenant_engine = get_tenant_engine(tenant)
# MT-21: a session cookie signed for a different tenant validates fine
# here — same app, same SECRET_KEY — so drop it before any downstream
# code reads identity out of it. Covers pre-auth session state
# (mfa_pending_user_id); the tenant tag in User.get_id() covers the
# authenticated session and the remember-me cookie.
from app.tenancy.session_binding import enforce_session_tenant
enforce_session_tenant()
@app.before_request @app.before_request
def _billing_gate(): def _billing_gate():
"""MT-8: Enforce subscription status. Inert when BILLING_ENABLED=False.""" """MT-8: Enforce subscription status. Inert when BILLING_ENABLED=False."""
@@ -190,6 +232,18 @@ def init_tenancy(app):
# they can still see their plan page and the subscribe button. # they can still see their plan page and the subscribe button.
if not (request.path.startswith('/billing/') if not (request.path.startswith('/billing/')
or request.path.startswith('/settings/')): or request.path.startswith('/settings/')):
if _wants_json():
# 402, not a redirect: the caller is a program.
# Distinct from 401 on purpose — the iPad retries a
# 401 by refreshing its token, which would loop
# forever against a billing block.
return _json({
'ok': False,
'error': 'This workspace\'s trial has ended. '
'An administrator needs to choose a plan '
'before the app can sync again.',
'billing_required': True,
}, 402)
return redirect(url_for('billing.subscribe')) return redirect(url_for('billing.subscribe'))
else: else:
days_left = (trial_ends_at - now).days days_left = (trial_ends_at - now).days
@@ -202,4 +256,11 @@ def init_tenancy(app):
return return
# status == 'cancelled' — block and redirect to subscription page. # status == 'cancelled' — block and redirect to subscription page.
if _wants_json():
return _json({
'ok': False,
'error': 'This workspace is suspended. An administrator needs to '
'reactivate the subscription before the app can sync again.',
'billing_required': True,
}, 402)
return redirect(url_for('billing.suspended')) return redirect(url_for('billing.suspended'))
+23 -5
View File
@@ -9,7 +9,7 @@ no second control-DB round-trip is needed.
Quota axes: Quota axes:
inspections Inspection.inspection_date in current month, status='completed' inspections Inspection.inspection_date in current month, status='completed'
issues Issue.created_at in current month issues Issue.reported_at in current month
users User.active == True (total, not monthly) users User.active == True (total, not monthly)
facilities Facility.active == True (total, not monthly) facilities Facility.active == True (total, not monthly)
@@ -18,10 +18,11 @@ all quota checks pass, single-tenant behaviour unchanged.
""" """
import logging import logging
from datetime import datetime
from flask import g, current_app from flask import g, current_app
from app.utils.time_utils import now_eastern
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -30,7 +31,15 @@ def _mt_enabled():
def _month_window(): def _month_window():
now = datetime.now() """[start, end) of the current month, in the timezone the rows are stamped in.
now_eastern(), not datetime.now(): every timestamp in the tenant DB is
written by now_eastern() (rule 2). On a UTC server the two differ by 4-5
hours, so a plain now() puts the month boundary in the wrong place and the
first hours of each month count the wrong rows a discrepancy that only
appears on the 1st and is gone before anyone investigates it.
"""
now = now_eastern()
start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
if now.month == 12: if now.month == 12:
end = now.replace(year=now.year + 1, month=1, day=1, end = now.replace(year=now.year + 1, month=1, day=1,
@@ -52,11 +61,20 @@ def count_inspections_this_month():
def count_issues_this_month(): def count_issues_this_month():
"""Issues filed this month.
`reported_at`, NOT `created_at` the issues table has no created_at
column. (IssueComment does, in the same module, which is how the wrong name
got here.) Referencing a missing column raises AttributeError while the
query is built, and every caller wraps this in a try/except, so the failure
was invisible: the plan page silently showed 0 for EVERY axis and the
issues quota was never evaluated at all.
"""
from app.models.issue import Issue from app.models.issue import Issue
start, end = _month_window() start, end = _month_window()
return (Issue.query return (Issue.query
.filter(Issue.created_at >= start, .filter(Issue.reported_at >= start,
Issue.created_at < end) Issue.reported_at < end)
.count()) .count())
+154
View File
@@ -0,0 +1,154 @@
"""
app/tenancy/session_binding.py
------------------------------
MT-21: bind a browser session to the tenant that issued it.
Why this exists
---------------
Every tenant is served by the same Flask application with the same
``SECRET_KEY``. Before this module, nothing in a signed cookie identified
*which* tenant it was issued by, so a cookie minted on one tenant host
validated perfectly on another:
1. A legitimate user of tenant A logs in at a.jqc.app.
2. They copy their session cookie onto b.jqc.app (devtools / curl
host-only cookie scoping stops the *browser* replaying it, but not a
person doing it by hand).
3. The tenancy middleware resolves Host tenant B and binds tenant B's
database.
4. Flask-Login calls load_user('7') and gets **tenant B's user #7**.
The signature was always valid, because it is the same key. The identity was
never checked against the tenant. Result: authentication as an arbitrary user
in any tenant whose hostname the attacker knows.
Two layers close this, and both live here:
``SESSION_TENANT_KEY``
A ``_tenant_id`` marker written into the session at every point where the
session starts carrying identity (login, MFA challenge hand-off,
impersonation). ``enforce_session_tenant()`` clears the whole session when
that marker disagrees with the resolved tenant, so pre-authentication
session state (``mfa_pending_user_id`` and friends) cannot cross tenants
either.
``tag_user_id`` / ``parse_user_id``
Flask-Login derives BOTH the session ``_user_id`` and the "remember me"
cookie payload from ``User.get_id()``, and feeds both back through
``user_loader``. Tagging the id there ``"<tenant_id>:<user_id>"`` is
therefore a single seam that covers both cookies. Clearing the session
alone would not have been enough: a remember-me cookie repopulates the
session immediately afterwards.
Single-tenant behaviour is unchanged. When ``MULTI_TENANT_ENABLED`` is False,
or no tenant is bound (cron, CLI, exempt paths), ids stay bare integers and
the enforcement is a no-op.
"""
import logging
from flask import current_app, g, session
logger = logging.getLogger(__name__)
SESSION_TENANT_KEY = '_tenant_id'
def current_tenant_id():
"""Resolved tenant id for this request, or None in single-tenant mode."""
if not current_app.config.get('MULTI_TENANT_ENABLED', False):
return None
tenant = getattr(g, 'tenant', None)
return tenant.id if tenant is not None else None
def bind_session_tenant():
"""Stamp the current session with the tenant that issued it.
Called at every point that puts identity into the session. No-op in
single-tenant mode so existing sessions keep working untouched.
"""
tid = current_tenant_id()
if tid is not None:
session[SESSION_TENANT_KEY] = tid
def enforce_session_tenant():
"""Clear the session if it was issued by a different tenant.
Returns True when the session was cleared. Runs after tenant resolution,
from the tenancy middleware.
"""
tid = current_tenant_id()
if tid is None:
return False
bound = session.get(SESSION_TENANT_KEY)
if bound is None:
# An untagged session carrying identity predates this binding, or was
# lifted from somewhere else. Either way it cannot be trusted here.
if '_user_id' in session or 'mfa_pending_user_id' in session:
logger.warning('TENANCY | session_untagged_cleared | tenant=%s', tid)
session.clear()
return True
return False
if bound != tid:
logger.warning('TENANCY | session_tenant_mismatch | bound=%s resolved=%s',
bound, tid)
session.clear()
return True
return False
def tag_user_id(user_id):
"""Render a Flask-Login identity string, tenant-tagged when applicable.
Used by ``User.get_id()``. Feeds both the session ``_user_id`` and the
remember-me cookie.
"""
tid = current_tenant_id()
if tid is None:
return str(user_id)
return f'{tid}:{user_id}'
def parse_user_id(raw):
"""Inverse of :func:`tag_user_id`, with the tenant check applied.
Returns the integer user id, or None when the identity must be rejected
a foreign tenant tag, an untagged id arriving in multi-tenant mode, or
anything unparseable. ``user_loader`` turns None into an anonymous user,
which sends the caller back to the login page.
"""
if raw is None:
return None
raw = str(raw)
tid = current_tenant_id()
if ':' in raw:
tag, _, uid = raw.partition(':')
if tid is None:
# Tagged id replayed at a single-tenant / unbound context.
return None
try:
if int(tag) != tid:
logger.warning('TENANCY | user_id_tenant_mismatch | tag=%s resolved=%s',
tag, tid)
return None
except (TypeError, ValueError):
return None
else:
uid = raw
if tid is not None:
# Untagged id in multi-tenant mode: either a session predating
# MT-21 or one lifted from another host. Force re-authentication.
logger.warning('TENANCY | user_id_untagged_rejected | tenant=%s', tid)
return None
try:
return int(uid)
except (TypeError, ValueError):
return None
+23
View File
@@ -28,6 +28,29 @@ def safe_redirect_url(url: str | None, fallback: str | None = None) -> str:
return fallback return fallback
return url return url
def return_url(fallback: str) -> str:
"""Where to go back to after a list-page action, preserving its filters.
Reads the `next` value the page carried through the action POST body
first (forms), then query string (links) and validates it with
safe_redirect_url, so a crafted `next` can never redirect off-site.
The problem this solves: a delete or an edit launched from a filtered list
used to redirect to the bare index, throwing away the filters the user had
set. Every list-page action now round-trips the list URL instead.
`next` is deliberately the FULL list URL (page number and all), not a
reconstructed set of arguments that keeps this helper working when a new
filter is added to either list page without anyone having to remember to
thread it through here.
"""
from flask import request
return safe_redirect_url(
request.form.get('next') or request.args.get('next'),
fallback=fallback,
)
def admin_required(f): def admin_required(f):
@wraps(f) @wraps(f)
def decorated_function(*args, **kwargs): def decorated_function(*args, **kwargs):
+45 -8
View File
@@ -2,7 +2,7 @@ from flask_wtf import FlaskForm
from flask_wtf.file import FileField, FileAllowed, MultipleFileField from flask_wtf.file import FileField, FileAllowed, MultipleFileField
from wtforms import (StringField, PasswordField, SelectField, TextAreaField, from wtforms import (StringField, PasswordField, SelectField, TextAreaField,
DecimalField, BooleanField, IntegerField, HiddenField, DecimalField, BooleanField, IntegerField, HiddenField,
RadioField) RadioField, SelectMultipleField)
from wtforms.validators import (DataRequired, Email, Length, EqualTo, from wtforms.validators import (DataRequired, Email, Length, EqualTo,
Optional, NumberRange, ValidationError) Optional, NumberRange, ValidationError)
import re as _re import re as _re
@@ -93,7 +93,11 @@ class UserForm(FlaskForm):
('inspector', 'Inspector'), ('inspector', 'Inspector'),
('project_manager', 'Project Manager'), ('project_manager', 'Project Manager'),
('auditor', 'Auditor'), ('auditor', 'Auditor'),
# 'customer' is intentionally excluded — customer accounts are managed via /customers # Both customer-side roles are intentionally excluded — 'customer'
# (Customer Director) and 'external_inspector' (Customer Inspector) are
# created, edited and switched exclusively in Customer Management
# (/customers). phase51 removed 'external_inspector' from here; see
# User.CUSTOMER_ROLES.
], validators=[Optional()]) ], validators=[Optional()])
# NOTE: Optional() here because directors submit no role value (the field is # NOTE: Optional() here because directors submit no role value (the field is
# hidden in user_form.html for them). Role enforcement is handled in the # hidden in user_form.html for them). Role enforcement is handled in the
@@ -160,6 +164,11 @@ class InspectionTemplateForm(FlaskForm):
('daily','Daily'), ('weekly','Weekly'), ('daily','Daily'), ('weekly','Weekly'),
('monthly','Monthly'), ('quarterly','Quarterly'), ('monthly','Monthly'), ('quarterly','Quarterly'),
], validators=[DataRequired()]) ], validators=[DataRequired()])
# phase52 — which contracts may use this form. Choices are populated in the
# route. Selecting NONE leaves the form shared with every contract, which
# is the default and what every pre-phase52 template does.
contract_ids = SelectMultipleField('Available on contracts', coerce=int,
validators=[Optional()])
class ChecklistItemForm(FlaskForm): class ChecklistItemForm(FlaskForm):
@@ -206,6 +215,23 @@ class IssueForm(FlaskForm):
FileAllowed(['jpg','jpeg','png','gif'], 'Images only.') FileAllowed(['jpg','jpeg','png','gif'], 'Images only.')
]) ])
assigned_to = SelectField('Assign To', coerce=int, validators=[Optional()]) assigned_to = SelectField('Assign To', coerce=int, validators=[Optional()])
# Who handles the issue (phase44) — set at creation by staff. MT previously
# exposed these only on the update form, so a handler chosen at creation had
# to be re-entered afterwards.
handler_type = SelectField('Handled By', choices=[
('internal', 'Janitorial Staff'),
('facility', 'Facility Staff'),
('vendor', 'External Vendor'),
], validators=[Optional()])
facility_handler_name = StringField('Facility Contact Name', validators=[Optional(), Length(max=100)])
facility_handler_contact = StringField('Facility Contact', validators=[Optional(), Length(max=200)])
facility_handler_notes = TextAreaField('Facility Handling Notes', validators=[Optional(), Length(max=1000)])
vendor_name = StringField('Contractor Name', validators=[Optional(), Length(max=100)])
vendor_contact = StringField('Contractor Contact', validators=[Optional(), Length(max=200)])
vendor_notes = TextAreaField('Contractor Notes', validators=[Optional(), Length(max=1000)])
# Janitorial staff member's name + contact — used when handler_type == 'internal'
internal_handler_name = StringField('Staff Name', validators=[Optional(), Length(max=100)])
internal_handler_contact = StringField('Staff Contact', validators=[Optional(), Length(max=200)])
class IssueUpdateForm(FlaskForm): class IssueUpdateForm(FlaskForm):
@@ -224,9 +250,9 @@ class IssueUpdateForm(FlaskForm):
vendor_name = StringField('Contractor Name', validators=[Optional(), Length(max=100)]) vendor_name = StringField('Contractor Name', validators=[Optional(), Length(max=100)])
vendor_contact = StringField('Contractor Contact', validators=[Optional(), Length(max=200)]) vendor_contact = StringField('Contractor Contact', validators=[Optional(), Length(max=200)])
vendor_notes = TextAreaField('Contractor Notes', validators=[Optional(), Length(max=1000)]) vendor_notes = TextAreaField('Contractor Notes', validators=[Optional(), Length(max=1000)])
# Handler type (phase39) # Handler type (phase39; empty option removed phase44 — handler_type is now
# NOT NULL DEFAULT 'internal', so "unset" is not a representable state)
handler_type = SelectField('Handled By', choices=[ handler_type = SelectField('Handled By', choices=[
('', '— Select —'),
('internal', 'Janitorial Staff'), ('internal', 'Janitorial Staff'),
('facility', 'Facility Staff'), ('facility', 'Facility Staff'),
('vendor', 'External Vendor'), ('vendor', 'External Vendor'),
@@ -234,6 +260,9 @@ class IssueUpdateForm(FlaskForm):
facility_handler_name = StringField('Facility Contact Name', validators=[Optional(), Length(max=100)]) facility_handler_name = StringField('Facility Contact Name', validators=[Optional(), Length(max=100)])
facility_handler_contact = StringField('Facility Contact Phone/Email', validators=[Optional(), Length(max=200)]) facility_handler_contact = StringField('Facility Contact Phone/Email', validators=[Optional(), Length(max=200)])
facility_handler_notes = TextAreaField('Facility Handler Notes', validators=[Optional(), Length(max=1000)]) facility_handler_notes = TextAreaField('Facility Handler Notes', validators=[Optional(), Length(max=1000)])
# Janitorial staff member's name + contact — used when handler_type == 'internal' (phase44)
internal_handler_name = StringField('Staff Name', validators=[Optional(), Length(max=100)])
internal_handler_contact = StringField('Staff Contact', validators=[Optional(), Length(max=200)])
# ── Projects ───────────────────────────────────────────────────────────────── # ── Projects ─────────────────────────────────────────────────────────────────
@@ -284,14 +313,22 @@ class CustomerUserForm(FlaskForm):
raise ValidationError('Password is required for new accounts.') raise ValidationError('Password is required for new accounts.')
class CustomerInviteForm(FlaskForm): class CustomerInviteForm(FlaskForm):
"""Simplified form for creating a customer account via email invitation. """Create a customer-side account via email invitation.
Admin enters Full Name and Email only. A username is auto-generated Admin enters Full Name, Email and which of the two customer roles the
from the email address. The customer sets their own username and person holds. A username is auto-generated from the email address; the
password via the emailed link. invitee sets their own username and password via the emailed link.
Both roles use the SAME invitation flow neither is an account we set a
password for. phase51 folded the Customer Inspector (stored as
'external_inspector') in here from User Management.
""" """
full_name = StringField('Full Name', validators=[DataRequired(), Length(max=150)]) full_name = StringField('Full Name', validators=[DataRequired(), Length(max=150)])
email = StringField('Email', validators=[DataRequired(), Email(), Length(max=255)]) email = StringField('Email', validators=[DataRequired(), Email(), Length(max=255)])
role = SelectField('Role', choices=[
('customer', 'Customer Director — portal access for their facilities'),
('external_inspector', 'Customer Inspector — performs inspections on their contracts'),
], default='customer', validators=[DataRequired()])
def validate_email(self, field): def validate_email(self, field):
if User.query.filter_by(email=field.data.strip().lower()).first(): if User.query.filter_by(email=field.data.strip().lower()).first():
+134 -7
View File
@@ -44,13 +44,26 @@ _EMAIL_HTML_SINGLE = """\
<body style="font-family:Arial,sans-serif;color:#333;max-width:600px;margin:auto;"> <body style="font-family:Arial,sans-serif;color:#333;max-width:600px;margin:auto;">
<h2 style="color:#0d6efd;">{{ title }}</h2> <h2 style="color:#0d6efd;">{{ title }}</h2>
<p>{{ body }}</p> <p>{{ body }}</p>
{% if link %} {% if link or extra_action %}
<p> <p>
{# extra_action (phase50) renders BEFORE "View Details" and in green: it is
the one-click action the email is asking for (e.g. "Confirm receipt"),
so it must be the primary button, not a footnote after the generic link. #}
{% if extra_action %}
<a href="{{ extra_action.url }}"
style="background:#198754;color:#fff;padding:10px 20px;
text-decoration:none;border-radius:4px;display:inline-block;
margin-right:8px;">
{{ extra_action.label }}
</a>
{% endif %}
{% if link %}
<a href="{{ base_url }}{{ link }}" <a href="{{ base_url }}{{ link }}"
style="background:#0d6efd;color:#fff;padding:10px 20px; style="background:#0d6efd;color:#fff;padding:10px 20px;
text-decoration:none;border-radius:4px;display:inline-block;"> text-decoration:none;border-radius:4px;display:inline-block;">
View Details View Details
</a> </a>
{% endif %}
</p> </p>
{% endif %} {% endif %}
<hr style="border:none;border-top:1px solid #eee;margin-top:32px;"> <hr style="border:none;border-top:1px solid #eee;margin-top:32px;">
@@ -68,6 +81,9 @@ _EMAIL_TEXT_SINGLE = """\
{{ title }} {{ title }}
{{ body }} {{ body }}
{% if extra_action %}
{{ extra_action.label }}: {{ extra_action.url }}
{% endif %}
{% if link %} {% if link %}
View: {{ base_url }}{{ link }} View: {{ base_url }}{{ link }}
{% endif %} {% endif %}
@@ -172,6 +188,7 @@ def notify(
event_type: str = None, event_type: str = None,
send_email: bool = True, send_email: bool = True,
respect_preferences: bool = True, respect_preferences: bool = True,
extra_action: dict = None,
): ):
"""Create an in-app Notification record and optionally send an email. """Create an in-app Notification record and optionally send an email.
@@ -189,7 +206,36 @@ def notify(
respect_preferences : When True (default), per-user email preferences gate delivery. respect_preferences : When True (default), per-user email preferences gate delivery.
Set False for matrix-routed broadcasts the matrix is the Set False for matrix-routed broadcasts the matrix is the
authority; individual opt-out should not override admin config. authority; individual opt-out should not override admin config.
extra_action : Optional dict {'label': str, 'url': str} rendered as a second,
primary button in the email (phase50). The URL must be
ABSOLUTE unlike `link`, it is not prefixed with base_url,
because it typically points at a login-free tokenised route
built with url_for(..., _external=True). EMAIL ONLY: the
in-app Notification row is unchanged, so a recipient reading
it in the bell menu simply follows `link` as before.
""" """
# ── Per-account override (phase51) ───────────────────────────────────
# A customer-side account's own notification matrix governs EVERY path
# that reaches it, not just matrix broadcasts: follower fan-out
# (_notify_followers) and direct assignee notifications both call notify()
# straight, so without this the editor would offer rows — "Issue follow
# update", "Issue assigned" — that appeared to be off while the
# notifications kept arriving.
#
# Only an explicit `False` suppresses. No row means inherit, which is the
# default for every account and leaves behaviour exactly as before. The
# getattr fallback is deliberate: if the attribute is unavailable for any
# reason we send, never silently drop.
if event_type and getattr(recipient, 'is_customer_account', False):
from app.models.user_notification_matrix import override_for
if override_for(recipient.id, event_type) is False:
logger.info(
'NOTIFICATION SUPPRESSED | user=%s | event=%s | '
'reason=per_account_override_off',
recipient.username, event_type,
)
return
# Determine digest flag before creating the record. # Determine digest flag before creating the record.
# Digest mode is only respected when individual preferences are in effect. # Digest mode is only respected when individual preferences are in effect.
hold_for_digest = ( hold_for_digest = (
@@ -246,10 +292,10 @@ def notify(
elif should_send: elif should_send:
logger.info('EMAIL SEND | user=%s | event=%s | to=%s', logger.info('EMAIL SEND | user=%s | event=%s | to=%s',
recipient.username, event_type, recipient.email) recipient.username, event_type, recipient.email)
_send_single_email(recipient, title, body, link) _send_single_email(recipient, title, body, link, extra_action)
def _send_single_email(recipient, title, body, link): def _send_single_email(recipient, title, body, link, extra_action=None):
"""Dispatch a single immediate notification email in a background thread. """Dispatch a single immediate notification email in a background thread.
Sending is offloaded to a daemon thread so SMTP latency never blocks the Sending is offloaded to a daemon thread so SMTP latency never blocks the
@@ -265,9 +311,11 @@ def _send_single_email(recipient, title, body, link):
) )
html_body = render_template_string( html_body = render_template_string(
_EMAIL_HTML_SINGLE, title=title, body=body, link=link, base_url=base_url, _EMAIL_HTML_SINGLE, title=title, body=body, link=link, base_url=base_url,
extra_action=extra_action,
) )
text_body = render_template_string( text_body = render_template_string(
_EMAIL_TEXT_SINGLE, title=title, body=body, link=link, base_url=base_url, _EMAIL_TEXT_SINGLE, title=title, body=body, link=link, base_url=base_url,
extra_action=extra_action,
) )
msg = Message( msg = Message(
subject = f'[JQC] {title}', subject = f'[JQC] {title}',
@@ -316,6 +364,7 @@ def notify_customers_for_facility(
link: str = None, link: str = None,
issue_id: int = None, issue_id: int = None,
inspection_id: int = None, inspection_id: int = None,
allowed_user_ids: set = None,
): ):
"""Dispatch in-app + email notifications to all customer users assigned """Dispatch in-app + email notifications to all customer users assigned
to the given facility. to the given facility.
@@ -336,6 +385,12 @@ def notify_customers_for_facility(
link : Relative URL for 'View Details'. link : Relative URL for 'View Details'.
issue_id : FK to issues.id (optional). issue_id : FK to issues.id (optional).
inspection_id : FK to inspections.id (optional). inspection_id : FK to inspections.id (optional).
allowed_user_ids :
Optional whitelist. When notify_by_matrix() calls this it has already
applied each account's per-event override (phase51), so it passes the
surviving ids here this function re-derives recipients from the
assignment rows and would otherwise notify accounts that opted out.
None (the default, used by direct callers) means no filtering.
""" """
try: try:
from app.models.project import CustomerAssignment from app.models.project import CustomerAssignment
@@ -373,8 +428,20 @@ def notify_customers_for_facility(
) )
return return
if allowed_user_ids is not None:
notified_user_ids &= set(allowed_user_ids)
if not notified_user_ids:
logger.debug(
'notify_customers_for_facility | facility_id=%s | all '
'assigned customers filtered out by per-account overrides',
facility_id,
)
return
for user_id in notified_user_ids: for user_id in notified_user_ids:
user = db.session.get(User, user_id) user = db.session.get(User, user_id)
# role != 'customer' stays an EQUALITY check: a Customer Inspector
# is not a portal customer and is routed by the inspector column.
if not user or not user.active or user.role != 'customer': if not user or not user.active or user.role != 'customer':
continue continue
try: try:
@@ -536,15 +603,25 @@ def notify_by_matrix(
from app.models.notification_matrix import ( from app.models.notification_matrix import (
is_enabled, get_custom_emails_for, MATRIX_ROLES, is_enabled, get_custom_emails_for, MATRIX_ROLES,
) )
from app.models.user_notification_matrix import overrides_for_event
from app.models.user import User from app.models.user import User
exclude = set(exclude_user_ids or []) exclude = set(exclude_user_ids or [])
notified = set() # deduplicate across roles notified = set() # deduplicate across roles
# ── Per-account overrides (phase51) ───────────────────────────────────
# {user_id: bool} for this event, one query. Applies to the two
# customer-side role columns only; staff roles use the global matrix alone.
# An account with no entry inherits the global column, which is why this
# feature is a no-op until an admin actually sets something.
overrides = overrides_for_event(event_type)
customer_keys = User.CUSTOMER_ROLES # ('customer', 'external_inspector')
role_to_db = { role_to_db = {
'admin': 'admin', 'admin': 'admin',
'director': 'director', 'director': 'director',
'inspector': 'inspector', 'inspector': 'inspector',
'external_inspector': 'external_inspector',
'project_manager': 'project_manager', 'project_manager': 'project_manager',
'auditor': 'auditor', 'auditor': 'auditor',
'customer': 'customer', 'customer': 'customer',
@@ -558,7 +635,14 @@ def notify_by_matrix(
enabled = is_enabled(event_type, role_key) enabled = is_enabled(event_type, role_key)
logger.info('MATRIX NOTIFY | event=%s | role=%s | enabled=%s', logger.info('MATRIX NOTIFY | event=%s | role=%s | enabled=%s',
event_type, role_key, enabled) event_type, role_key, enabled)
if not enabled:
# A customer-side column must NOT be skipped just because the global
# switch is off — an account that opted IN individually still has to be
# reached. Only skip when the column is off AND nobody opted in.
# (Getting this wrong is silent: the per-account "on" would save fine,
# show as on, and never send.)
is_customer_col = role_key in customer_keys
if not enabled and not (is_customer_col and any(overrides.values())):
continue continue
db_role = role_to_db.get(role_key) db_role = role_to_db.get(role_key)
@@ -569,6 +653,34 @@ def notify_by_matrix(
logger.info('MATRIX NOTIFY | event=%s | role=%s | users_found=%s', logger.info('MATRIX NOTIFY | event=%s | role=%s | users_found=%s',
event_type, role_key, [u.username for u in users]) event_type, role_key, [u.username for u in users])
# Scope the inspector role for "inspection_completed" to the inspection's
# OWN inspector — the person who did the work — not the whole inspector
# pool. Without this, switching the Inspector column on for this event
# notifies EVERY active inspector on EVERY submitted inspection, which on
# a tenant with a dozen inspectors is a mail storm and trains people to
# ignore notifications. Falls back to notifying nobody when the
# inspection cannot be resolved, rather than notifying everybody.
if (role_key in ('inspector', 'external_inspector')
and event_type == 'inspection_completed'):
target_id = None
if inspection_id:
from app.models.inspection import Inspection
insp = db.session.get(Inspection, inspection_id)
target_id = insp.inspector_id if insp else None
users = [u for u in users if u.id == target_id] if target_id else []
logger.info('MATRIX NOTIFY | event=%s | role=%s scoped to '
'submitting inspector_id=%s',
event_type, role_key, target_id)
# Apply the per-account overrides to the customer-side columns. An
# account with no override falls back to `enabled`, i.e. the global
# column — so this line is what makes both directions work: opt-in
# against an off column, and opt-out of an on one.
if is_customer_col:
users = [u for u in users if overrides.get(u.id, enabled)]
logger.info('MATRIX NOTIFY | event=%s | role=%s | after overrides=%s',
event_type, role_key, [u.username for u in users])
# Scope customer role to facility if provided # Scope customer role to facility if provided
if role_key == 'customer' and facility_id: if role_key == 'customer' and facility_id:
from app.utils.notifications import notify_customers_for_facility from app.utils.notifications import notify_customers_for_facility
@@ -580,6 +692,9 @@ def notify_by_matrix(
link = link, link = link,
issue_id = issue_id, issue_id = issue_id,
inspection_id = inspection_id, inspection_id = inspection_id,
# Without this the facility-scoped path would re-query customers
# itself and bypass every override applied just above.
allowed_user_ids = {u.id for u in users},
) )
continue # notify_customers_for_facility handles dedup internally continue # notify_customers_for_facility handles dedup internally
@@ -599,10 +714,22 @@ def notify_by_matrix(
) )
notified.add(user.id) notified.add(user.id)
# ── Custom email recipients ─────────────────────────────────────────── # ── Custom email recipients (global, per-event) ───────────────────────
# Deduplicated on the normalised address. The matrix stores this list as
# free text, so the same person can appear twice with different casing or
# stray whitespace ("Ops@x.com" and "ops@x.com "), which previously produced
# two identical emails. `sent_emails` is built AS WE SEND, so it reflects
# what actually went out — blank entries are skipped rather than being
# counted as sent — and is then handed to the per-contract pass below so a
# recipient listed both globally and on the contract is contacted once.
custom_emails = get_custom_emails_for(event_type) custom_emails = get_custom_emails_for(event_type)
sent_emails = set()
for email in custom_emails: for email in custom_emails:
norm = (email or '').strip().lower()
if not norm or norm in sent_emails:
continue
_send_custom_email(email, title, body, link) _send_custom_email(email, title, body, link)
sent_emails.add(norm)
# ── Per-contract additional recipients (phase37) ────────────────────── # ── Per-contract additional recipients (phase37) ──────────────────────
_notify_project_recipients( _notify_project_recipients(
@@ -615,12 +742,12 @@ def notify_by_matrix(
facility_id = facility_id, facility_id = facility_id,
exclude_user_ids = exclude, exclude_user_ids = exclude,
already_notified = notified, already_notified = notified,
already_emailed = {e.strip().lower() for e in custom_emails}, already_emailed = sent_emails,
) )
logger.info( logger.info(
'MATRIX NOTIFY | event=%s | notified=%s | custom_emails=%s', 'MATRIX NOTIFY | event=%s | notified=%s | custom_emails=%s',
event_type, len(notified), len(custom_emails), event_type, len(notified), len(sent_emails),
) )
+357
View File
@@ -0,0 +1,357 @@
"""
app/utils/photo_stamp.py
------------------------
Burn a capture-time + geolocation overlay into uploaded evidence photos.
Applied at UPLOAD time (``app/api/photos.py``) rather than on
``PATCH /issues/<id>/photos``. At upload the raw bytes and any camera EXIF are
in hand, so nothing has to be read back out of R2, and each upload writes
exactly one already-stamped object. That also keeps stamping clear of the
retry/double-burn hazard the PATCH endpoint would have it is deliberately
idempotent and re-runnable (CLAUDE.md rule 45), so burning there could stack a
second bar onto an already-stamped image.
Metadata resolution order
-------------------------
1. Client-supplied ``captured_at`` / ``latitude`` / ``longitude`` most
reliable for an offline-first app: the iPad knows when and where the shot
was taken even if it syncs hours later.
2. The image's own EXIF ``DateTimeOriginal`` / ``GPSInfo``.
3. Server receipt time (last resort; no geo).
FAILURE POLICY
--------------
Stamping must never cost us the photo. Every failure path falls back to
storing the original bytes unmodified an unstamped photo beats a lost one.
MULTI-TENANT NOTES (MT-12)
--------------------------
Ported from the single-tenant tree. This module is tenant-agnostic by
construction and needs no tenancy awareness:
- It only transforms bytes. It never touches the DB, ``g.tenant``, or a
storage key, so there is nothing here that could leak across tenants.
- ``stamp_file_storage()`` returns a FileStorage carrying the ORIGINAL
``filename`` and ``content_type``, so ``storage.save()`` derives exactly the
same ``uploads/<subfolder>/<uuid>.<ext>`` key it would have derived for the
unstamped upload. The per-tenant ``t<id>/`` prefix is applied further down,
inside ``S3Backend._object_key`` stamping happens strictly upstream of
that and cannot interfere with it.
- Because the bytes are stamped BEFORE ``storage.save()``, exactly one
already-stamped object is written per upload and nothing is ever read back
out of R2 to be re-encoded.
Scope matches the single-tenant tree deliberately: only the mobile API upload
path (``app/api/photos.py``) stamps. Web uploads through
``routes/inspections._save_photo`` are NOT stamped, there or here a desk user
attaching a file has no capture-time or GPS to burn in, and the server receipt
time would be misleading.
"""
import io
import logging
from datetime import datetime
from app.utils.time_utils import EASTERN, now_eastern
logger = logging.getLogger(__name__)
# Only these are stamped. GIF (possibly animated) and anything exotic passes
# through untouched rather than risking a broken re-encode.
_STAMPABLE_FORMATS = {'JPEG', 'PNG'}
# Candidate TrueType fonts, in preference order. Pillow does not reliably ship
# a TTF, and the bitmap default is unreadably small on a 4000px photo, so we
# probe the usual Linux locations and degrade gracefully.
_FONT_CANDIDATES = (
'/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',
'/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',
'/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf',
'/usr/share/fonts/truetype/freefont/FreeSansBold.ttf',
'C:/Windows/Fonts/arialbd.ttf',
'C:/Windows/Fonts/arial.ttf',
)
_EXIF_DATETIME_ORIGINAL = 36867 # 0x9003
_EXIF_DATETIME_DIGITIZED = 36868 # 0x9004
_EXIF_DATETIME = 306 # 0x0132
_EXIF_GPS_IFD = 34853 # 0x8825
# ── Font ──────────────────────────────────────────────────────────────────────
def _load_font(size):
"""Return a TrueType font at *size*, or Pillow's bitmap default."""
from PIL import ImageFont
for path in _FONT_CANDIDATES:
try:
return ImageFont.truetype(path, size)
except Exception:
continue
try:
# Pillow >= 9.2 can scale the default font.
return ImageFont.load_default(size=size)
except Exception:
return ImageFont.load_default()
# ── Metadata extraction ───────────────────────────────────────────────────────
def _parse_client_datetime(value):
"""Parse a client ISO-8601 timestamp into naive Eastern, or None.
Accepts offsets and a trailing 'Z'. An offset-aware value is converted to
Eastern; a naive value is taken as already-Eastern wall time.
"""
if not value:
return None
text = str(value).strip()
if not text:
return None
if text.endswith(('Z', 'z')):
text = text[:-1] + '+00:00'
try:
dt = datetime.fromisoformat(text)
except ValueError:
return None
if dt.tzinfo is not None:
dt = dt.astimezone(EASTERN).replace(tzinfo=None)
return dt
def _parse_exif_datetime(raw):
"""Parse an EXIF 'YYYY:MM:DD HH:MM:SS' string into a naive datetime."""
if not raw:
return None
try:
return datetime.strptime(str(raw).strip(), '%Y:%m:%d %H:%M:%S')
except (ValueError, TypeError):
return None
def _exif_datetime(exif):
"""Best available capture time from EXIF, or None."""
if not exif:
return None
for tag in (_EXIF_DATETIME_ORIGINAL, _EXIF_DATETIME_DIGITIZED, _EXIF_DATETIME):
dt = _parse_exif_datetime(exif.get(tag))
if dt:
return dt
return None
def _dms_to_decimal(dms, ref):
"""Convert EXIF degrees/minutes/seconds rationals to signed decimal."""
try:
deg, minutes, seconds = (float(x) for x in dms)
except (TypeError, ValueError):
return None
value = deg + minutes / 60.0 + seconds / 3600.0
if str(ref).upper().strip() in ('S', 'W'):
value = -value
return value
def _exif_gps(exif):
"""Return (lat, lng) decimal degrees from EXIF GPSInfo, or (None, None)."""
if not exif:
return (None, None)
try:
gps = exif.get_ifd(_EXIF_GPS_IFD)
except Exception:
gps = None
if not gps:
return (None, None)
# 1/2 = LatitudeRef/Latitude, 3/4 = LongitudeRef/Longitude
lat = _dms_to_decimal(gps.get(2), gps.get(1)) if gps.get(2) and gps.get(1) else None
lng = _dms_to_decimal(gps.get(4), gps.get(3)) if gps.get(4) and gps.get(3) else None
return (lat, lng)
def _coerce_coord(value):
"""Parse a coordinate to float, rejecting out-of-range/garbage values."""
if value is None or value == '':
return None
try:
num = float(value)
except (TypeError, ValueError):
return None
if num != num or abs(num) > 180: # NaN or impossible
return None
return num
def resolve_metadata(exif, captured_at=None, latitude=None, longitude=None):
"""Resolve (capture_dt, lat, lng, source) from client fields then EXIF.
``source`` is one of 'client', 'exif', or 'server' and describes where the
*timestamp* came from useful for logging and for judging trust later.
"""
dt = _parse_client_datetime(captured_at)
source = 'client' if dt else None
lat = _coerce_coord(latitude)
lng = _coerce_coord(longitude)
if dt is None:
dt = _exif_datetime(exif)
source = 'exif' if dt else None
if lat is None or lng is None:
ex_lat, ex_lng = _exif_gps(exif)
lat = lat if lat is not None else ex_lat
lng = lng if lng is not None else ex_lng
if dt is None:
dt = now_eastern()
source = 'server'
return dt, lat, lng, source
# ── Overlay rendering ─────────────────────────────────────────────────────────
def _tz_abbrev(dt):
"""EDT/EST label for a naive Eastern datetime."""
try:
return EASTERN.localize(dt).strftime('%Z')
except Exception:
return 'ET'
def _overlay_lines(dt, lat, lng):
"""Text lines for the overlay bar."""
lines = [f"{dt.strftime('%Y-%m-%d %H:%M:%S')} {_tz_abbrev(dt)}"]
if lat is not None and lng is not None:
lines.append(f'{lat:.5f}, {lng:.5f}')
return lines
def _draw_overlay(img, lines):
"""Draw a translucent bar with *lines* across the bottom of *img*."""
from PIL import Image, ImageDraw
if img.mode not in ('RGB', 'RGBA'):
img = img.convert('RGB')
width, height = img.size
# Scale everything off the short edge so portrait and landscape match.
base = min(width, height)
font_size = max(11, int(base * 0.020))
pad = max(6, int(base * 0.012))
font = _load_font(font_size)
measure = ImageDraw.Draw(img)
# Measure the block.
heights, widths = [], []
for line in lines:
box = measure.textbbox((0, 0), line, font=font)
widths.append(box[2] - box[0])
heights.append(box[3] - box[1])
line_gap = max(2, int(font_size * 0.25))
text_h = sum(heights) + line_gap * (len(lines) - 1)
bar_h = text_h + pad * 2
# Draw the whole overlay on a transparent layer so both the bar AND the
# text carry alpha, then composite once. Keeps the photo readable through
# the stamp instead of masking it behind a solid strip.
layer = Image.new('RGBA', (width, bar_h), (0, 0, 0, 0))
draw = ImageDraw.Draw(layer)
draw.rectangle((0, 0, width, bar_h), fill=(0, 0, 0, 80))
y = pad
for line, h in zip(lines, heights):
# Faint dark outline still keeps the text legible over a bright photo.
for dx, dy in ((-1, 0), (1, 0), (0, -1), (0, 1)):
draw.text((pad + dx, y + dy), line, font=font, fill=(0, 0, 0, 90))
draw.text((pad, y), line, font=font, fill=(255, 255, 255, 165))
y += h + line_gap
if img.mode == 'RGBA':
img.alpha_composite(layer, (0, height - bar_h))
else:
img.paste(Image.alpha_composite(
img.crop((0, height - bar_h, width, height)).convert('RGBA'), layer
).convert('RGB'), (0, height - bar_h))
return img
# ── Public API ────────────────────────────────────────────────────────────────
def stamp_image_bytes(data, captured_at=None, latitude=None, longitude=None):
"""Burn the capture-time/geo overlay into *data*.
Returns ``(out_bytes, meta)``. On any failure unsupported format, corrupt
image, missing Pillow returns the ORIGINAL bytes with ``meta['stamped']``
False rather than raising, so an upload is never lost to a stamping bug.
"""
meta = {'stamped': False, 'captured_at': None, 'latitude': None,
'longitude': None, 'source': None}
try:
from PIL import Image, ImageOps
img = Image.open(io.BytesIO(data))
fmt = (img.format or '').upper()
try:
exif = img.getexif()
except Exception:
exif = None
dt, lat, lng, source = resolve_metadata(exif, captured_at, latitude, longitude)
meta.update({'captured_at': dt, 'latitude': lat,
'longitude': lng, 'source': source})
if fmt not in _STAMPABLE_FORMATS:
logger.info('PHOTO STAMP | skipped unsupported format=%s', fmt or '?')
return data, meta
# Honour the camera's EXIF orientation BEFORE drawing, otherwise the
# bar lands on a rotated edge and the re-encode (which drops EXIF)
# would leave the photo visibly rotated versus the original.
img = ImageOps.exif_transpose(img)
img = _draw_overlay(img, _overlay_lines(dt, lat, lng))
out = io.BytesIO()
if fmt == 'JPEG':
if img.mode != 'RGB':
img = img.convert('RGB')
img.save(out, format='JPEG', quality=88, optimize=True)
else:
img.save(out, format='PNG', optimize=True)
meta['stamped'] = True
return out.getvalue(), meta
except Exception as exc: # never lose a photo
logger.warning('PHOTO STAMP | failed, storing original: %s', exc)
return data, meta
def stamp_file_storage(file_obj, captured_at=None, latitude=None, longitude=None):
"""Return a FileStorage of the stamped image, plus the resolved metadata.
The result is a drop-in replacement for the incoming upload: it keeps the
original ``filename``/``content_type``, so ``storage.save()`` derives the
same key and works unchanged on both the local and s3 backends.
"""
from werkzeug.datastructures import FileStorage
file_obj.stream.seek(0)
original = file_obj.stream.read()
out_bytes, meta = stamp_image_bytes(
original, captured_at=captured_at, latitude=latitude, longitude=longitude
)
if not meta['stamped']:
file_obj.stream.seek(0) # hand back the untouched upload
return file_obj, meta
return FileStorage(
stream=io.BytesIO(out_bytes),
filename=file_obj.filename,
content_type=file_obj.content_type,
), meta
+30 -15
View File
@@ -8,6 +8,8 @@ Facility-scoping utilities for the Janitorial QC portal.
get_inspector_scope(user) -> list[int] | None get_inspector_scope(user) -> list[int] | None
Facility IDs an inspector may access via InspectorAssignment rows. Facility IDs an inspector may access via InspectorAssignment rows.
Applies to BOTH 'inspector' (internal) and 'external_inspector'
(customer / third-party) see User.INSPECTOR_ROLES.
Returns [] (empty list) when the inspector has no contract assignments, Returns [] (empty list) when the inspector has no contract assignments,
meaning they see nothing (strict mode). meaning they see nothing (strict mode).
@@ -16,6 +18,7 @@ that no facility-level scoping is required (full access applies).
""" """
import logging import logging
from app import db
from app.models.project import CustomerAssignment from app.models.project import CustomerAssignment
from app.models.facility import Facility from app.models.facility import Facility
@@ -41,30 +44,34 @@ def get_customer_scope(user) -> list[int] | None:
if user.role != 'customer': if user.role != 'customer':
return None # no scoping needed for internal staff return None # no scoping needed for internal staff
assignments = CustomerAssignment.query.filter_by(user_id=user.id).all() # Select only the two columns needed. The previous .all() built full
# CustomerAssignment ORM objects (and their identity-map entries) purely to
# read two integers off each one; this function runs on nearly every
# request for a customer, sometimes more than once.
assignments = db.session.query(
CustomerAssignment.project_id,
CustomerAssignment.facility_id,
).filter(CustomerAssignment.user_id == user.id).all()
if not assignments: if not assignments:
return [] return []
# Separate direct facility assignments from project-level assignments # Separate direct facility assignments from project-level assignments
direct_facility_ids = {a.facility_id for a in assignments if a.facility_id} direct_facility_ids = {fac_id for _, fac_id in assignments if fac_id}
project_ids = {a.project_id for a in assignments if not a.facility_id} project_ids = {proj_id for proj_id, fac_id in assignments if not fac_id}
facility_ids = set(direct_facility_ids) facility_ids = set(direct_facility_ids)
# Single bulk query for all project-scoped facilities — replaces the # Single bulk query for all project-scoped facilities — replaces the
# previous per-assignment Facility.query loop (N+1 pattern). # previous per-assignment Facility.query loop (N+1 pattern). Only the id
# column is read; nothing here needs a hydrated Facility.
if project_ids: if project_ids:
project_facilities = ( facility_ids.update(
Facility.query fid for (fid,) in db.session.query(Facility.id).filter(
.filter(
Facility.project_id.in_(project_ids), Facility.project_id.in_(project_ids),
Facility.active == True, Facility.active == True,
).all()
) )
.all()
)
for f in project_facilities:
facility_ids.add(f.id)
logger.debug( logger.debug(
'SCOPE | customer_scope | user_id=%s username=%s facility_ids=%s', 'SCOPE | customer_scope | user_id=%s username=%s facility_ids=%s',
@@ -90,21 +97,29 @@ def get_inspector_scope(user) -> list[int] | None:
None None
Returned for non-inspector roles, indicating unrestricted access. Returned for non-inspector roles, indicating unrestricted access.
""" """
if user.role != 'inspector': # MT-15: covers BOTH 'inspector' and 'external_inspector'. An external
# (customer / third-party) inspector is scoped by exactly the same
# InspectorAssignment rows — the contracts an admin grants them.
from app.models.user import User
if user.role not in User.INSPECTOR_ROLES:
return None return None
from app.models.inspector_assignment import InspectorAssignment from app.models.inspector_assignment import InspectorAssignment
# Column-only selects — see the note in get_customer_scope(). This runs on
# every scoped request for both inspector roles.
project_ids = [ project_ids = [
a.project_id pid for (pid,) in
for a in InspectorAssignment.query.filter_by(user_id=user.id).all() db.session.query(InspectorAssignment.project_id)
.filter(InspectorAssignment.user_id == user.id).all()
] ]
if not project_ids: if not project_ids:
return [] # strict: no assignments = no access return [] # strict: no assignments = no access
facility_ids = [ facility_ids = [
f.id for f in Facility.query.filter( fid for (fid,) in db.session.query(Facility.id).filter(
Facility.project_id.in_(project_ids), Facility.project_id.in_(project_ids),
Facility.active == True, Facility.active == True,
).all() ).all()

Some files were not shown because too many files have changed in this diff Show More