Compare commits

..
45 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
nngo 321965038d Jul 17 - Fill the gaps between Single-tenant mode and Multi-tenant mode - matching web UI 2026-07-17 13:26:25 -04:00
nngo c706489480 Jul 17 - Fill the gaps between Single-tenant mode and Multi-tenant mode - MT7 2026-07-17 12:54:07 -04:00
nngo 8d9730e3df Jul 17 - Fill the gaps between Single-tenant mode and Multi-tenant mode - MT5 2026-07-17 12:30:04 -04:00
nngo d44d761706 Jul 17 - Fill the gaps between Single-tenant mode and Multi-tenant mode - MT4 2026-07-17 11:36:05 -04:00
nngo 253291d5a4 Jul 16 - Fill the gaps between Single-tenant mode and Multi-tenant mode - MT6 2026-07-16 17:28:10 -04:00
nngo 02b030b1d2 Jul 16 - Fill the gaps between Single-tenant mode and Multi-tenant mode - MT3 2026-07-16 17:17:44 -04:00
nngo a60afc6c41 Jul 16 - Fill the gaps between Single-tenant mode and Multi-tenant mode - MT2 2026-07-16 16:59:17 -04:00
nngo 8ff38578ad Jul 16 - Fill the gaps between Single-tenant mode and Multi-tenant mode 2026-07-16 16:37:49 -04:00
nngo 99d966e6ad Jul 10 - Update documents 2026-07-10 13:17:44 -04:00
nngo 5bd30aeaa0 Jul 10 - Update codes to catch up with the single tenant project (Low) 2026-07-10 13:10:08 -04:00
171 changed files with 27016 additions and 1409 deletions
+523 -15
View File
@@ -2,7 +2,7 @@
> **Audience:** AI assistants and developers working on this codebase.
> **Purpose:** Authoritative reference for architecture, conventions, gotchas, and decisions.
> **Last reviewed:** July 2026 (doc-reconciliation pass — verified against code on disk. Adds previously-undocumented phase28 notify-fix, phase29 broadcasts, phase3032 device registry; `broadcast` + `devices` + `api_devices` blueprints; Broadcast + DeviceRegistration models; corrected MT-8 billing status to DONE; resolved the device-registration collision (rule 84 — removed duplicate `api_devices` blueprint + `DeviceRegistration` model, consolidated on `DeviceToken`). Prior: Phase 19 + mobile API Phases AE + Phase 22 comment visibility + Phase 23 support chat/tickets + inspector Excel export + inspection list filters + customer issue logging + AI chatbot + dashboard grouped sections + issues/inspections PDF export + Reports R1R4 + Phase 24 notify defaults + Phase 25 GPS + Phase 26 vendor fields + Phase 27 score alerts + **MT-0 through MT-8 complete; self-service signup; trial enforcement; billing emails; invoice history; superadmin billing controls; per-tenant backup CLI; health dashboard; fail2ban; welcome email; dunning day-3/7/14; ProxyFix middleware; QR occupant issue reporting; issue handler type (phase39); support chat persistence + knowledge base (phase40); MT-9 iOS pending**)
> **Last reviewed:** July 2026 (doc-reconciliation pass — verified against code on disk. Adds previously-undocumented phase28 notify-fix, phase29 broadcasts, phase3032 device registry; `broadcast` + `devices` + `api_devices` blueprints; Broadcast + DeviceRegistration models; corrected MT-8 billing status to DONE; resolved the device-registration collision (rule 84 — removed duplicate `api_devices` blueprint + `DeviceRegistration` model, consolidated on `DeviceToken`). Prior: Phase 19 + mobile API Phases AE + Phase 22 comment visibility + Phase 23 support chat/tickets + inspector Excel export + inspection list filters + customer issue logging + AI chatbot + dashboard grouped sections + issues/inspections PDF export + Reports R1R4 + Phase 24 notify defaults + Phase 25 GPS + Phase 26 vendor fields + Phase 27 score alerts + **MT-0 through MT-8 complete; self-service signup; trial enforcement; billing emails; invoice history; superadmin billing controls; per-tenant backup CLI; health dashboard; fail2ban; welcome email; dunning day-3/7/14; ProxyFix middleware; QR occupant issue reporting; issue handler type (phase39); support chat persistence + knowledge base (phase40); Reports R1+R2 contract cascade filter; billing emails branded From address; MT-9 iOS pending**)
---
@@ -34,6 +34,8 @@
24. [Backup CLI](#24-backup-cli-controlbackuppy)
25. [Health Dashboard](#25-health-dashboard-health-on-panel)
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)
---
@@ -116,7 +118,7 @@ lt_janitorial_quality_control/
│ ├── models/
│ │ ├── inspection.py # Inspection — mobile_local_id column (Phase B)
│ │ ├── issue.py # Issue — mobile_local_id (Phase B), reported_by (Phase 18), mobile_photo_paths (Phase 19)
│ │ ├── support.py # SupportTicket, SupportTicketReply (Phase 23)
│ │ ├── support.py # SupportChatSession, SupportChatMessage, SupportKnowledge (phase40) + SupportTicket, SupportTicketReply (Phase 23)
│ │ ├── tenant_settings.py # MT-7: TenantSettings — per-tenant branding (one row per tenant DB)
│ │ └── ...
│ ├── routes/
@@ -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. |
| `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`. |
| `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. |
| `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`. |
@@ -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
```
### 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
```
@@ -428,6 +454,36 @@ notification_matrix: id, event_type, role_key, enabled, custom_emails (JSON)
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)
```
@@ -534,7 +590,7 @@ The `DeviceRegistration` model and the duplicate `api_devices` blueprint were **
| `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) |
| `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) |
| `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 |
@@ -812,7 +868,7 @@ limiter = Limiter(
## 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`.
@@ -846,9 +902,101 @@ limiter = Limiter(
→ phase36_issue_work_orders
→ phase37_contract_recipients
→ 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
Creates three tables backing support chat persistence and the AI knowledge base:
@@ -1106,11 +1254,13 @@ Always use `user.display_name` in templates — never `.username` for display pu
The Contract selector is always a plain HTML `<select>` (never a WTForms field). On `change` it calls `GET /inspections/facilities_for_project/<project_id>` and replaces the Facility `<option>` list. When the Contract is cleared it restores the "All Facilities" placeholder. The filter bars auto-narrow the server-side facility dropdown on page load when `contract_id` is in the query string.
Pages using this pattern: `issues/form.html` (create), `issues/list.html` (filter bar), `inspections/list.html` (filter bar).
Pages using this pattern: `issues/form.html` (create), `issues/list.html` (filter bar), `inspections/list.html` (filter bar), `reports/issues_aging.html` (filter bar), `reports/sla_compliance.html` (filter bar).
The issues list and inspections list both accept a `contract_id` query param that filters the DB query to facilities belonging to that contract (`facility.project_id == contract_id`) and narrows the facility dropdown in the rendered HTML.
**Customer role — contract filter scoping:** In `inspections.index()` and `issues.index()`, the `projects` list passed to the template is scoped to contracts the customer is assigned to via `CustomerAssignment`. Non-customer roles still receive all active projects. This prevents customers from seeing contracts they have no assignment to in the Contract filter dropdown.
The **Reports R1 (Issues Aging) and R2 (SLA Compliance)** filter bars include a Contract cascade dropdown that is **client-side only** — selecting a contract calls `GET /inspections/facilities_for_project/<id>` to narrow the facility list in the browser; the actual DB filter still uses only `facility_id`. The route passes `projects` to the template (all active projects, scoped to the customer's assigned facilities when the role is `customer`). The cascade JS is guarded by `{% if projects %}` so it is omitted for empty lists (e.g. a customer with no facility assignments).
**Customer role — contract filter scoping:** In `inspections.index()`, `issues.index()`, `reports.issues_aging()`, and `reports.sla_compliance()`, the `projects` list passed to the template is scoped to contracts whose facilities overlap the customer's assigned facility set. Non-customer roles receive all active projects. This prevents customers from seeing contracts they have no assignment to in the Contract filter dropdown.
### Customer Dashboard — "Your Facilities" Panel
@@ -1196,6 +1346,8 @@ All report pages include `{% include 'reports/_subnav.html' %}` as the first ele
Loads all non-resolved issues scoped by role, groups into five age buckets (`<24h`, `13 days`, `37 days`, `14 weeks`, `>4 weeks`). SLA status computed per-issue via `sla_status()`. Filters: severity, facility (both applied in Python after the main query to avoid double-outerjoin conflicts with customer scope).
**Contract cascade filter:** A client-side Contract `<select>` (no name attribute — not submitted) appears above the Facility dropdown. On change, JS calls `GET /inspections/facilities_for_project/<id>` to narrow the Facility list in-browser; clearing the contract restores all options. The route passes `projects` (all active, or scoped to customer facility set). The actual DB filter uses only `facility_id`.
Excel export: `GET /reports/export/issues-aging` — one sheet, color-coded severity and SLA columns.
Helper: `_load_open_issues_scoped(customer_facility_ids, severity_filter, facility_id_filter)` — extracted so both the HTML route and the Excel export share identical query logic.
@@ -1207,6 +1359,8 @@ Loads resolved issues in the date range, computes `within_sla()` per issue (comp
- `by_severity` — dict with `total`, `met`, `pct`, `sla_hours` per severity tier
- `by_facility` — list sorted by compliance % descending
**Contract cascade filter:** Same client-side Contract → Facility cascade as R1. Route passes `projects`; DB filter uses only `facility_id`.
Helper: `_sla_within(issue)` — used by both the HTML route and the Excel export.
Excel export: `GET /reports/export/sla-compliance` — 2 sheets: **By Severity** (with totals row) and **By Facility**.
@@ -1227,12 +1381,15 @@ A **PDF Summary** button was added to `reports/scorecard.html` alongside the exi
### Support Chat — Customer UX
`GET /support/chat` — customer only. Renders:
- Greeting message with `current_user.display_name` (injected via `var userName = {{ current_user.display_name | tojson }}` — use `tojson` not inline interpolation to prevent XSS/quote breaks).
- FAQ quick-reply chips: text stored in `data-faq="..."` HTML attribute (HTML-escaped with `| e`), read in JS via `btn.dataset.faq`. **Never use `| tojson` in an `onclick=""` attribute** — it emits double-quoted JSON inside a double-quoted attribute, breaking HTML parsing and truncating the `<script>` tag.
- Chat history kept client-side in `let history = []`, sent with each AJAX `POST /support/chat/message`. Server caps at last 20 turns.
`GET /support/chat` — customer only. Accepts optional `?session_id=N` to reload a prior conversation.
Renders:
- Greeting message with `current_user.display_name` (injected via `var userName = {{ current_user.display_name | tojson }}` — use `tojson` not inline interpolation to prevent XSS/quote breaks). Greeting is hidden when loading a prior session (`{% if not db_history %}`).
- FAQ quick-reply chips: text stored in `data-faq="..."` HTML attribute (HTML-escaped with `| e`), read in JS via `btn.dataset.faq`. Hidden when a prior session is loaded. **Never use `| tojson` in an `onclick=""` attribute** — it emits double-quoted JSON inside a double-quoted attribute, breaking HTML parsing and truncating the `<script>` tag.
- **Chat history is DB-backed (phase40).** Prior turns are rendered server-side on page load from `db_history` (list of `SupportChatMessage`). The JS variable `let session_id` is seeded from `chat_session.id` (null for new chats). AJAX sends only `{ message, session_id }`**no history array** (rule 95). Server returns `{ reply, session_id }` and the JS stores/reuses `session_id` across subsequent messages.
- If `GROQ_API_KEY` is absent, input is disabled and a fallback "Submit to Support" link is shown.
- "Submit to Support" modal POSTs to `POST /support/tickets`; subject pre-filled from last user message in history.
- "Submit to Support" modal POSTs to `POST /support/tickets`; subject pre-filled from `last_user_msg` JS variable (last message typed, not scanned from history array).
- "History" button links to `support.my_conversations` (list of all past sessions). "New Chat" link starts a fresh session (`/support/chat` with no `session_id`).
### Inspection Execute Page — UX Patterns
@@ -1381,7 +1538,7 @@ set -a; . /etc/jqc/control.env; set +a
| 60 | **`flag_issue` offcanvas form must include `<input type="hidden" name="facility_id">`** | `IssueForm.facility_id` has `DataRequired()`. The hand-written offcanvas form in `execute.html` is not rendered by WTForms, so it must explicitly send `facility_id`. Without it, `form.validate_on_submit()` silently returns `False`, the server responds `200 OK` with the `flag_issue.html` template, and the JS treats `res.ok` as success — no issue is ever saved. Fix: `<input type="hidden" name="facility_id" value="{{ inspection.facility_id }}">` inside `#flagIssueForm`. |
| 61 | **Contract→Facility cascade UI pattern: contract selector is UI-only, not a WTForms field** | The "Log New Issue" form (`issues/form.html`) and both filter bars (`issues/list.html`, `inspections/list.html`) use a plain HTML `<select id="...contract...">` that triggers an AJAX call to `GET /inspections/facilities_for_project/<id>` on change, repopulating the facility dropdown. `IssueForm.facility_id.choices` is always set to ALL active facilities in the route so POST validation passes regardless of which contract was selected in the UI. On POST error re-render, the route derives `selected_project_id` from the submitted `facility_id`'s `project_id` and passes it to the template so JS can restore both selectors. |
| 62 | **`issue.resolved_facility.project` and `inspection.facility.project` give the contract** | `Project.facilities` declares `backref='project'`, so `facility.project` is a direct ORM attribute (not a dynamic query). Guard all template accesses: `ins.facility.project.name if ins.facility and ins.facility.project else '—'`. The contract name is displayed in the issues list, issues detail, and inspections list; the issues list also accepts a `contract_id` query param that pre-filters the facility dropdown server-side. |
| 63 | **Customer Contract filter scoped to assigned contracts only** | `inspections.index()` and `issues.index()` build the `projects` list differently for `customer` role: query `CustomerAssignment.query.filter_by(user_id=current_user.id)` to get assigned `project_id` values, then filter `Project` to that set. All other roles still receive all active projects. Pattern mirrors the existing inspector scoping in `inspections.start()`. |
| 63 | **Customer Contract filter scoped to assigned contracts only** | `inspections.index()`, `issues.index()`, `reports.issues_aging()`, and `reports.sla_compliance()` build the `projects` list differently for `customer` role. Inspections/issues use `CustomerAssignment` to get assigned `project_id` values. Reports use a join: `Project.query.join(Facility).filter(Facility.id.in_(customer_facility_ids))`. All other roles receive all active projects. This prevents customers from seeing contracts they have no assignment to in any Contract filter dropdown. |
| 64 | **Invitation email sender and link domain are derived from `request.host_url`** | `_send_invite_email(user, token, base_url=None)` in `customers.py` accepts an optional `base_url`. Both call sites (`invite` and `resend_invite`) pass `request.host_url`. Inside the function, `effective_base` is built from that value (falling back to `APP_BASE_URL`); `setup_link` uses `effective_base`; `sender` is `noreply@<netloc>` parsed from `effective_base`. The SMTP server and credentials are unchanged — only the `From` address and link URL vary per domain. |
| 65 | **Customer "Your Facilities" uses a card grid, not a table** | See §18 "Customer Dashboard — Your Facilities Panel". Never revert to a full-width table for this section. The show-more threshold is `VISIBLE = 9`; the search input threshold is `> 6`. Both thresholds live as JS/Jinja constants in `dashboard.html` and can be adjusted together if needed. |
| 66 | **FAQ chip text must use `data-faq` attribute, not `onclick` with `\| tojson`** | `\| tojson` emits `"text"` (double-quoted) inside `onclick="..."` (also double-quoted), breaking HTML parsing and silently truncating the `<script>` block. Use `data-faq="{{ text \| e }}"` and read via `btn.dataset.faq` in JS. |
@@ -1408,6 +1565,22 @@ set -a; . /etc/jqc/control.env; set +a
| 94 | **`handler_type` NULL and `'internal'` are equivalent** | NULL means the column was not set (pre-phase39 row or unmodified new row); the application treats both as "Janitorial Staff". The dashboard `handler_breakdown['internal']` counter and the `?handler_type=internal` issues-list filter both use `db.or_(Issue.handler_type == 'internal', Issue.handler_type.is_(None))`. Never coerce NULL to 'internal' at the DB layer — the nullable default is intentional for backwards compatibility. |
| 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. |
| 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. |
---
@@ -1616,7 +1789,9 @@ Stripe-backed subscription billing. Controlled by `BILLING_ENABLED` env var (def
### Billing emails (`app/billing/emails.py`)
`send_billing_email(to_addr, event_type, context_dict)` sends multipart HTML + plain text.
`send_billing_email(to_addr, event_type, context_dict)` sends multipart HTML + plain text in a background thread.
**Sender derivation:** Before launching the thread, `urlparse(app.config['APP_BASE_URL']).netloc` is extracted and the From address is set to `noreply@<netloc>`. This mirrors rule 64 (invitation emails) so billing emails carry the correct tenant domain in the From header rather than a hardcoded address. Falls back to `MAIL_DEFAULT_SENDER` when `APP_BASE_URL` is unset or unparseable (`sender=None` passes `None` to `Message()`, triggering Flask-Mail's default).
| `event_type` | Trigger | Required context keys |
|---|---|---|
@@ -1721,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.
@@ -1853,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
# 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.
> **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.
>
> **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).**
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
@@ -329,3 +386,160 @@ python -m control.tenant_migrate bootstrap --tenant acme # fresh DB only
sudo systemctl status 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).
+157 -3
View File
@@ -18,8 +18,25 @@ login_manager = LoginManager()
migrate = Migrate()
mail = Mail()
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(
key_func = get_remote_address,
key_func = _rate_limit_key,
default_limits = [], # no global limit — applied per-route only
# 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;
@@ -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'):
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])
# 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 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
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)
login_manager.init_app(app)
@@ -122,6 +181,79 @@ def create_app(config_name='default'):
app.jinja_env.globals['sla_hours_remaining'] = sla_hours_remaining
app.jinja_env.globals['SLA_HOURS'] = SLA_HOURS
# Photo URL resolver — routes through the active storage backend so templates
# work unchanged when the backend flips from local to R2 (see utils/storage.py).
from app.utils import storage as _storage
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 ──────
# This powers the red badge on the navbar bell icon without requiring
# individual routes to pass the count manually.
@@ -216,6 +348,7 @@ def create_app(config_name='default'):
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 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
app.register_blueprint(auth.bp)
@@ -239,6 +372,13 @@ def create_app(config_name='default'):
app.register_blueprint(tenant_settings.bp)
app.register_blueprint(signup.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
# 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).
@@ -261,6 +401,7 @@ def create_app(config_name='default'):
from app.api.notifications import bp as _api_notifications_bp
from app.api.stats import bp as _api_stats_bp
from app.api.comments import bp as _api_comments_bp
from app.api.scheduled import bp as _api_scheduled_bp
csrf.exempt(_api_auth_bp)
csrf.exempt(_api_facilities_bp)
csrf.exempt(_api_templates_bp)
@@ -270,11 +411,24 @@ def create_app(config_name='default'):
csrf.exempt(_api_notifications_bp)
csrf.exempt(_api_stats_bp)
csrf.exempt(_api_comments_bp)
csrf.exempt(_api_scheduled_bp)
register_api(app)
# ── Security response headers ─────────────────────────────────────────
# Applied to every response. Blocks clickjacking, MIME sniffing, and
# obvious XSS vectors without breaking Bootstrap CDN / Google Fonts.
# Allow R2 presigned photo URLs in the CSP img-src when the s3 storage
# backend is configured. Derived from R2_ENDPOINT_URL (the presigned URL
# host is the same R2 account endpoint), so nothing is hardcoded and the
# local backend is unaffected.
_r2_img_src = ''
_r2_endpoint = app.config.get('R2_ENDPOINT_URL')
if _r2_endpoint:
from urllib.parse import urlparse
_r2_host = urlparse(_r2_endpoint).netloc
if _r2_host:
_r2_img_src = f' https://{_r2_host}'
@app.after_request
def set_security_headers(response):
from flask import request as _request
@@ -287,7 +441,7 @@ def create_app(config_name='default'):
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com; "
"font-src 'self' data: https://fonts.gstatic.com https://cdn.jsdelivr.net; "
"img-src 'self' data: blob: https://maps.gstatic.com https://maps.googleapis.com; "
f"img-src 'self' data: blob: https://maps.gstatic.com https://maps.googleapis.com{_r2_img_src}; "
"connect-src 'self' https://cdn.jsdelivr.net; "
"frame-src https://maps.google.com https://www.google.com; "
# Hardening directives that don't affect existing inline scripts/styles:
+4
View File
@@ -50,6 +50,10 @@ def register_api(app):
from app.api.comments import bp as comments_bp
api_bp.register_blueprint(comments_bp)
# phase43: Planned inspection assignments (plan-mode schedules)
from app.api.scheduled import bp as scheduled_bp
api_bp.register_blueprint(scheduled_bp)
# NOTE: device registration lives on the auth blueprint
# (POST /api/v1/devices/register in app/api/auth.py) and writes to the
# canonical api_device_tokens table (model DeviceToken). A former duplicate
+60
View File
@@ -32,6 +32,7 @@ import logging
from flask import Blueprint, request, g
from app import db, limiter
from app.tenancy.gates import feature_required
from app.models.user import User
from app.models.api_token import RefreshToken, DeviceToken
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'])
@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():
"""
Authenticate with username + password.
@@ -70,10 +78,22 @@ def login():
{
"username": "john",
"password": "secret",
"mfa_code": "123456", // required IF the account has 2FA on
"device_id": "A1B2C3D4...", // UIDevice.identifierForVendor (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
------------
{
@@ -106,6 +126,41 @@ def login():
if not user.active:
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_name = (data.get('device_name') or '')[:100] or None
@@ -154,6 +209,11 @@ def login():
@bp.route('/auth/refresh', methods=['POST'])
@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():
"""
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__)
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'}
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'external_inspector',
'project_manager', 'auditor'}
def _comment_payload(comment: IssueComment) -> dict:
@@ -47,7 +48,7 @@ def _comment_payload(comment: IssueComment) -> dict:
def _check_issue_access(issue: Issue, user) -> bool:
"""Return True if user may read/write this issue. False = 403."""
if user.role == 'inspector':
if user.is_inspector:
fids = get_inspector_scope(user)
facility = issue.resolved_facility
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:
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 = db.session.get(User, user_id)
+15 -4
View File
@@ -31,13 +31,24 @@ def api_ok(data=None, status=200):
}), status
def api_error(message: str, status: int = 400):
"""Return an error JSON response."""
return jsonify({
def api_error(message: str, status: int = 400, extra: dict | None = None):
"""Return an error JSON response.
`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,
'data': None,
'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) ─────────────────
+260 -11
View File
@@ -35,7 +35,8 @@ logger = logging.getLogger(__name__)
bp = Blueprint('api_inspections', __name__)
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'}
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'external_inspector',
'project_manager', 'auditor'}
def _merge_form_data(existing: dict, incoming: dict) -> dict:
@@ -96,6 +97,84 @@ def _parse_datetime(value):
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):
"""Serialize an Inspection to the dict returned in API responses."""
# Extract form responses from the notes JSON blob.
@@ -137,12 +216,32 @@ def _inspection_payload(inspection):
if inspection.completed_at else None,
'mobile_local_id': inspection.mobile_local_id,
'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,
'inspector_notes': inspector_notes,
# ── Follow-up / re-inspection fields ──────────────────────────────
'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,
'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
facility_id int filter by facility
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
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:
return api_error('Access denied', 403)
limit = min(int(request.args.get('limit', 50)), 200)
offset = max(int(request.args.get('offset', 0)), 0)
limit = min(request.args.get('limit', 50, type=int) or 50, 200)
offset = max(request.args.get('offset', 0, type=int) or 0, 0)
query = Inspection.query
# Inspectors only see their own inspections
if user.role == 'inspector':
# Inspectors only see their own inspections.
#
# 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)
# Optional filters
@@ -201,6 +310,38 @@ def list_inspections():
if 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')
if from_date_str:
try:
@@ -264,9 +405,16 @@ def create_inspection():
"overall_score": 87.5,
"inspection_date": "2026-05-01T14:30: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
------------
{ "ok": true, "data": { "inspection_id": 42, "duplicate": false } }
@@ -357,6 +505,29 @@ def create_inspection():
submit_latitude = 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(
template_id = template_id,
facility_id = facility_id,
@@ -371,11 +542,19 @@ def create_inspection():
parent_inspection_id = parent_inspection_id,
submit_latitude = submit_latitude,
submit_longitude = submit_longitude,
inspection_schedule_id = inspection_schedule_id,
)
db.session.add(inspection)
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 ───────────────────────────────
# When a completed re-inspection arrives that links to a parent, clear
# follow_up_required on the parent automatically. This mirrors the web
@@ -465,8 +644,13 @@ def update_inspection(inspection_id):
"form_data": { ... },
"notes": "...",
"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
@@ -477,7 +661,7 @@ def update_inspection(inspection_id):
if inspection is None:
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)
data = request.get_json(silent=True) or {}
@@ -500,6 +684,31 @@ def update_inspection(inspection_id):
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:
inspection.status = data['status']
@@ -515,12 +724,52 @@ def update_inspection(inspection_id):
elif data.get('status') == 'completed' and not inspection.completed_at:
inspection.completed_at = now_eastern()
db.session.commit()
# Notify when a draft transitions to completed — mirrors the POST handler.
# Computed BEFORE the commit so the schedule fulfil can be staged into the
# same transaction; reused after the commit for the notification below.
transitioning_to_complete = (
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:
score_val = inspection.overall_score
score_display = f'{score_val:.1f}%' if score_val is not None else 'N/A'
+128 -9
View File
@@ -42,15 +42,24 @@ logger = logging.getLogger(__name__)
bp = Blueprint('api_issues', __name__)
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'}
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'external_inspector',
'project_manager', 'auditor'}
_VALID_SEVERITY = {'low', 'medium', 'high', 'critical'}
_VALID_STATUSES = {'open', 'in_progress', 'resolved', 'pending_verification'}
_VALID_HANDLERS = {'internal', 'facility', 'vendor'}
_UUID_RE = re.compile(
r'^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$',
re.IGNORECASE,
)
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):
"""Serialise an Issue to the dict returned in list/detail responses."""
facility = issue.resolved_facility
@@ -71,6 +80,14 @@ def _issue_payload(issue):
'photo_path': issue.photo_path or None,
'mobile_photo_paths': issue.mobile_photo_paths 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.
'result_notes': issue.result_notes or None,
# Verification fields — set after a director/admin confirms fix.
@@ -82,6 +99,22 @@ def _issue_payload(issue):
'area_name': issue.area.name if issue.area else None,
# Assigned-to display name — set when a director assigns the issue to a user.
'assigned_to_name': issue.assigned_user.display_name if issue.assigned_user else None,
# ── Handler ("Handled By", phase39) ───────────────────────────────
# handler_type categorises WHO resolves the issue:
# internal = our staff (assigned_to) facility = facility's own staff
# vendor = external contractor
'handler_type': issue.handler_type or 'internal',
'handler_label': issue.handler_label,
'facility_handler_name': issue.facility_handler_name or None,
'facility_handler_contact': issue.facility_handler_contact or None,
'facility_handler_notes': issue.facility_handler_notes or None,
'vendor_name': issue.vendor_name or None,
'vendor_contact': issue.vendor_contact 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,
}
@@ -119,12 +152,12 @@ def list_issues():
if user.role not in _ALLOWED_ROLES:
return api_error('Access denied', 403)
limit = min(int(request.args.get('limit', 100)), 200)
offset = max(int(request.args.get('offset', 0)), 0)
limit = min(request.args.get('limit', 100, type=int) or 100, 200)
offset = max(request.args.get('offset', 0, type=int) or 0, 0)
query = Issue.query
if user.role == 'inspector':
if user.is_inspector:
fids = get_inspector_scope(user)
if not fids:
return api_ok({'issues': [], 'total': 0, 'limit': limit, 'offset': offset})
@@ -218,7 +251,7 @@ def create_issue():
if facility is None:
return api_error('Facility not found', 404)
if user.role == 'inspector':
if user.is_inspector:
fids = get_inspector_scope(user)
if not fids or facility_id not in fids:
return api_error('Access denied — facility is not in your assigned contracts', 403)
@@ -307,7 +340,7 @@ def get_issue(issue_id):
if issue is None:
return api_error('Issue not found', 404)
if user.role == 'inspector':
if user.is_inspector:
fids = get_inspector_scope(user)
facility = issue.resolved_facility
if not fids or not facility or facility.id not in fids:
@@ -340,7 +373,7 @@ def update_issue_status(issue_id):
if issue is None:
return api_error('Issue not found', 404)
if user.role == 'inspector':
if user.is_inspector:
fids = get_inspector_scope(user)
facility = issue.resolved_facility
if not fids or not facility or facility.id not in fids:
@@ -405,7 +438,7 @@ def update_issue_photos(issue_id):
if issue is None:
return api_error('Issue not found', 404)
if user.role == 'inspector':
if user.is_inspector:
fids = get_inspector_scope(user)
facility = issue.resolved_facility
if not fids or not facility or facility.id not in fids:
@@ -466,7 +499,7 @@ def update_issue_result_photos(issue_id):
if issue is None:
return api_error('Issue not found', 404)
if user.role == 'inspector':
if user.is_inspector:
fids = get_inspector_scope(user)
facility = issue.resolved_facility
if not fids or not facility or facility.id not in fids:
@@ -497,3 +530,89 @@ def update_issue_result_photos(issue_id):
issue.id, len(new_photos), user.username)
return api_ok({'issue_id': issue.id, 'result_photos_count': len(merged)})
# ── Update Issue Handler ("Handled By") ───────────────────────────────────────
@bp.route('/issues/<int:issue_id>/handler', methods=['PATCH'])
@jwt_required
def update_issue_handler(issue_id):
"""
Set who handles an issue ("Handled By") from the mobile app.
Unlike the web form (which limits handler edits to admin/director/PM/auditor),
the iPad allows the assigned inspector to set the handler from the field,
scoped to issues at their assigned facilities. This divergence is deliberate:
the inspector is the one standing in the building who knows whether the
facility's own staff or a vendor should take it.
Request JSON
------------
{
"handler_type": "internal" | "facility" | "vendor",
"facility_handler_name": "...", // optional (facility handler)
"facility_handler_contact": "...", // optional
"facility_handler_notes": "...", // optional
"vendor_name": "...", // optional (vendor handler)
"vendor_contact": "...", // 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.
handler_type is required.
Access:
- admin / director / project_manager / auditor : any issue
- inspector : only issues at their assigned facilities
- customer : denied
"""
user = g.api_user
if user.role not in _ALLOWED_ROLES:
return api_error('Access denied', 403)
issue = db.session.get(Issue, issue_id)
if issue is None:
return api_error('Issue not found', 404)
if user.is_inspector:
fids = get_inspector_scope(user)
facility = issue.resolved_facility
if not fids or not facility or facility.id not in fids:
return api_error('Access denied', 403)
data = request.get_json(silent=True) or {}
handler = (data.get('handler_type') or '').strip().lower()
if handler not in _VALID_HANDLERS:
return api_error(
f'handler_type must be one of: {", ".join(sorted(_VALID_HANDLERS))}', 400
)
old_handler = issue.handler_type or 'internal'
issue.handler_type = handler
# Update only the detail fields that were supplied. Empty string clears
# the field (stored as NULL); a missing key leaves the field untouched.
_text_fields = (
'facility_handler_name', 'facility_handler_contact', 'facility_handler_notes',
'vendor_name', 'vendor_contact', 'vendor_notes',
'internal_handler_name', 'internal_handler_contact',
)
for field in _text_fields:
if field in data:
val = (data.get(field) or '').strip()
setattr(issue, field, val or None)
db.session.commit()
log_action(ACTION_UPDATE, 'Issue', issue.id,
f'handler {old_handler}{handler}',
f'source=mobile; updated_by={user.username}')
logger.info('API ISSUES | handler_updated | issue_id=%d | %s%s | user=%s',
issue.id, old_handler, handler, user.username)
return api_ok({'issue_id': issue.id, 'handler_type': issue.handler_type,
'handler_label': issue.handler_label})
+15
View File
@@ -10,6 +10,7 @@ claims needed to identify the caller:
{
"sub": "42", # user.id as string
"role": "inspector", # user.role
"tid": 3, # issuing tenant id (multi-tenant mode only)
"iat": 1710000000, # issued-at (UTC epoch)
"exp": 1710003600, # expiry (UTC epoch, 60 min later)
}
@@ -33,6 +34,12 @@ def _secret():
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:
"""
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,
'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')
+1 -1
View File
@@ -73,7 +73,7 @@ def list_notifications():
"""
user = g.api_user
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():
q = Notification.query.filter_by(user_id=user.id, is_read=False)
+70 -14
View File
@@ -25,7 +25,8 @@ logger = logging.getLogger(__name__)
bp = Blueprint('api_photos', __name__)
_ALLOWED_EXTENSIONS = {'jpg', 'jpeg', 'png', 'gif'}
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'}
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'external_inspector',
'project_manager', 'auditor'}
def _allowed_file(filename: str) -> bool:
@@ -47,31 +48,58 @@ def upload_photo():
---------------------
file binary image data (jpg / png / gif)
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
------------
{
"ok": true,
"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
if user.role not in _ALLOWED_ROLES:
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:
logger.warning('API PHOTOS | rejected | reason=no_file_part | user=%s',
user.username)
return api_error('No file provided', 400)
file_obj = request.files['file']
entity_type = request.form.get('entity_type', 'inspection')
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)
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(
f'File type not allowed. Accepted: {", ".join(sorted(_ALLOWED_EXTENSIONS))}',
400
@@ -85,18 +113,46 @@ def upload_photo():
else:
subfolder = 'inspection_photos'
ext = file_obj.filename.rsplit('.', 1)[-1].lower()
filename = f'{uuid.uuid4().hex}.{ext}'
# 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'),
)
dest_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], subfolder)
os.makedirs(dest_dir, exist_ok=True)
# Write via the active storage backend (local disk or R2). Key format
# '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
try:
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)
dest_path = os.path.join(dest_dir, filename)
file_obj.save(dest_path)
logger.info(
'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'),
)
server_path = f'uploads/{subfolder}/{filename}'
logger.info('API PHOTOS | uploaded | entity_type=%s | path=%s | user=%s',
entity_type, server_path, user.username)
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'),
})
+292
View File
@@ -0,0 +1,292 @@
"""
app/api/scheduled.py
--------------------
Mobile API endpoint for planned inspection assignments (phase43).
GET /api/v1/scheduled-inspections
Returns ACTIVE, PLAN-MODE schedules the caller is responsible for.
- inspector : only schedules where inspector_id == the caller
- admin / director / project_manager / auditor : all active plan schedules
Powers the "Scheduled" section on the iPad Dashboard and My Inspections
lists. The iPad taps "Start", which opens the normal new-inspection flow
with the facility + template preselected (client-side); the schedule
lifecycle (fulfil / roll-forward) continues to be driven by the web app.
Why plan-mode only
------------------
`mode='auto'` schedules materialise themselves into a real Inspection at
next_run_at, which the iPad already fetches via /api/v1/inspections. Returning
them here too would show the same work twice, and "Start" is meaningless for a
schedule that starts itself. This mirrors the web dashboard panel (phase43).
A plan-mode schedule is a PLAN, not an inspection see
app/models/inspection_schedule.py for the full lifecycle.
"""
import logging
from datetime import datetime
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.api.errors import api_ok, api_error
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__)
bp = Blueprint('api_scheduled', __name__)
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'external_inspector',
'project_manager', 'auditor'}
def _scheduled_payload(s):
"""Serialise an InspectionSchedule to the dict returned in list responses.
`next_due_date` is the date part of next_run_at MT reuses next_run_at as
the due datetime for both modes (see phase43).
"""
return {
'id': s.id,
'name': s.name,
'facility_id': s.facility_id,
'facility_name': s.facility.name if s.facility else None,
'area_id': s.area_id,
'area_name': s.area.name if s.area else None,
'template_id': s.template_id,
'template_name': s.template.name if s.template else None,
'inspector_id': s.inspector_id,
'frequency': s.frequency,
'frequency_label': s.frequency_label,
'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,
# 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(),
# 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,
}
# ── List Scheduled Inspections ────────────────────────────────────────────────
@bp.route('/scheduled-inspections', methods=['GET'])
@jwt_required
def list_scheduled():
"""
Return active plan-mode scheduled inspections for the authenticated user.
Query parameters
----------------
limit int Default 100, max 200.
offset int Default 0.
Response 200
------------
{
"ok": true,
"data": {
"scheduled": [...],
"total": 3,
"limit": 100,
"offset": 0
}
}
"""
user = g.api_user
if user.role not in _ALLOWED_ROLES:
return api_error('Access denied', 403)
try:
limit = min(request.args.get('limit', 100, type=int) or 100, 200)
offset = max(request.args.get('offset', 0, type=int) or 0, 0)
except (TypeError, ValueError):
return api_error('limit and offset must be integers', 400)
query = InspectionSchedule.query.filter(
InspectionSchedule.active.is_(True),
InspectionSchedule.mode == 'plan',
)
if user.is_inspector:
# Inspectors only see schedules assigned directly to them.
query = query.filter(InspectionSchedule.inspector_id == user.id)
total = query.count()
rows = (
query
.order_by(InspectionSchedule.next_run_at.asc())
.offset(offset)
.limit(limit)
.all()
)
payload = [_scheduled_payload(s) for s in rows]
logger.info('API SCHEDULED | list | user=%s | count=%d | total=%d',
user.username, len(payload), total)
return api_ok({'scheduled': payload, 'total': total,
'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__)
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'}
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'external_inspector',
'project_manager', 'auditor'}
@bp.route('/stats/dashboard', methods=['GET'])
@@ -74,7 +75,7 @@ def dashboard_stats():
today_end = today_start + timedelta(days=1)
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
# ── 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)
# ── Severity breakdown (derived from the same open_issues_all list) ───
@@ -151,9 +159,14 @@ def dashboard_stats():
if not fids:
followup_q = followup_q.filter(False)
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(
Inspection.facility_id.in_(fids),
Inspection.inspector_id == user.id,
Inspection.follow_up_owned_by(user.id),
)
pending_followups = followup_q.count()
+90 -10
View File
@@ -16,18 +16,86 @@ GET /api/v1/templates/<template_id>
import logging
from flask import Blueprint, g
from flask import Blueprint, g, request
from app import db
from app.models.inspection import InspectionTemplate
from app.models.facility import Facility
from app.api.errors import api_ok, api_error
from app.api.decorators import jwt_required
from app.utils.scope import get_inspector_scope
logger = logging.getLogger(__name__)
bp = Blueprint('api_templates', __name__)
# Customer role cannot access template data — inspectors and above only
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'}
_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:
@@ -87,17 +155,21 @@ def list_templates():
user.username, user.role)
return api_error('Access denied', 403)
templates = (
InspectionTemplate.query
.filter_by(active=True)
.order_by(InspectionTemplate.name)
.all()
)
# Optional ?project_id= narrows to one contract (matches the web picker);
# ?facility_id= is accepted as a convenience and resolved to its contract.
project_id = request.args.get('project_id', type=int)
if project_id is None:
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]
logger.info('API TEMPLATES | list | user=%s | count=%d',
user.username, len(payload))
logger.info('API TEMPLATES | list | user=%s | project_id=%s | count=%d',
user.username, project_id, len(payload))
return api_ok({'templates': payload, 'count': len(payload)})
@@ -143,6 +215,14 @@ def get_template(template_id):
if template is None:
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',
user.username, template_id, template.name)
+12
View File
@@ -91,6 +91,17 @@ def send_billing_email(to_addr: str, event_type: str, context_dict: dict):
app = current_app._get_current_object()
# Branded From: the display NAME tracks the tenant, the ADDRESS stays the
# authenticated identity unless the domain is DNS-authorized. Resolved HERE,
# in the request context, because _send() runs on a background thread where
# g.tenant no longer exists. The previous `noreply@{netloc}` sent from the
# tenant's host regardless of SPF authorization. See app/utils/mail_utils.py.
try:
from app.utils.mail_utils import branded_sender
_sender = branded_sender(app.config.get('APP_BASE_URL', ''))
except Exception:
_sender = app.config.get('MAIL_DEFAULT_SENDER', '')
def _send():
try:
with app.app_context():
@@ -105,6 +116,7 @@ def send_billing_email(to_addr: str, event_type: str, context_dict: dict):
msg = Message(
subject=subject,
recipients=[to_addr],
sender=_sender or None,
body=plain_body,
html=html_body,
)
+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.facility import Facility, Area
from app.models.inspection import (InspectionTemplate, ChecklistItem,
Inspection, InspectionResult)
Inspection, InspectionResult,
TemplateContract)
from app.models.issue import Issue
from app.models.project import Project, CustomerAssignment
from app.models.project_recipient import ProjectNotificationRecipient
from app.models.api_token import RefreshToken, DeviceToken
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.work_order import IssueWorkOrder
+12
View File
@@ -42,6 +42,18 @@ class Area(db.Model):
name = db.Column(db.String(255), nullable=False)
area_type = db.Column(db.String(50))
# phase42: unguessable token behind the public area scan page (/f/area/<token>).
# NULL until first requested — ensure_qr_token() generates it lazily, exactly
# like Facility.qr_token above.
qr_token = db.Column(db.String(64), unique=True, nullable=True)
def ensure_qr_token(self):
"""Generate the QR token on first use. Caller commits."""
if not self.qr_token:
import secrets
self.qr_token = secrets.token_urlsafe(32)
return self.qr_token
# Relationships
inspections = db.relationship('Inspection', backref='area', lazy='dynamic')
issues = db.relationship('Issue', backref='area', lazy='dynamic')
+200
View File
@@ -3,6 +3,43 @@ from app.utils.time_utils import now_eastern
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):
__tablename__ = 'inspection_templates'
@@ -18,6 +55,82 @@ class InspectionTemplate(db.Model):
checklist_items = db.relationship('ChecklistItem', backref='template', lazy='dynamic', cascade='all, delete-orphan')
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):
if self.form_schema is None:
return []
@@ -57,6 +170,13 @@ class Inspection(db.Model):
template_id = db.Column(db.Integer, db.ForeignKey('inspection_templates.id'), nullable=False)
facility_id = db.Column(db.Integer, db.ForeignKey('facilities.id'), nullable=False)
area_id = db.Column(db.Integer, db.ForeignKey('areas.id'))
# phase43: set when this inspection was started from / materialised by a
# schedule. ON DELETE SET NULL — deleting a schedule never deletes history.
inspection_schedule_id = db.Column(
db.Integer,
db.ForeignKey('inspection_schedules.id', ondelete='SET NULL'),
nullable=True, index=True
)
inspector_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
inspection_date = db.Column(db.DateTime, nullable=False, default=now_eastern)
overall_score = db.Column(db.Numeric(5, 2))
@@ -74,11 +194,91 @@ class Inspection(db.Model):
)
follow_up_required = db.Column(db.Boolean, nullable=False, default=False)
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')
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'),
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):
return f'<Inspection {self.id} - {self.inspection_date}>'
+435 -3
View File
@@ -13,12 +13,107 @@ their queue and fills it out through the normal execute flow.
This is purely additive: no existing inspection behaviour changes. A schedule is
just an automated `inspections.start()`.
phase43 adds the single-tenant "plan" semantics alongside that:
mode='auto' (default, phase34 behaviour)
Cron materialises the Inspection at next_run_at and notifies the inspector.
mode='plan' (ST behaviour)
Nothing is materialised. The schedule is a commitment with a due date; the
assigned inspector clicks "Start", which creates the Inspection linked back
via Inspection.inspection_schedule_id. Reminders fire in advance / on the
due date / once overdue. Completing the inspection calls fulfill(), which
deactivates a one-time schedule or rolls a recurring one forward.
`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.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):
__tablename__ = 'inspection_schedules'
@@ -42,13 +137,55 @@ class InspectionSchedule(db.Model):
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(
db.Enum('daily', 'weekly', 'monthly', 'quarterly'),
db.Enum(*FREQUENCY_CHOICES),
nullable=False, default='weekly'
)
active = db.Column(db.Boolean, nullable=False, default=True)
# phase43: 'auto' = cron materialises the inspection (phase34 behaviour,
# the default for every pre-existing row); 'plan' = the inspector starts it.
mode = db.Column(db.Enum('auto', 'plan'), nullable=False, default='auto')
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(
db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'),
nullable=True
@@ -57,12 +194,307 @@ class InspectionSchedule(db.Model):
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
# ── 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
last_completed_at = db.Column(db.DateTime, nullable=True)
# Per-occurrence reminder de-dup flags; reset when a recurring schedule rolls forward.
advance_notified = db.Column(db.Boolean, nullable=False, default=False)
due_notified = db.Column(db.Boolean, nullable=False, default=False)
overdue_notified = db.Column(db.Boolean, nullable=False, default=False)
# Relationships — explicit foreign_keys because two columns point at users.id.
template = db.relationship('InspectionTemplate', foreign_keys=[template_id])
facility = db.relationship('Facility', foreign_keys=[facility_id])
area = db.relationship('Area', foreign_keys=[area_id])
inspector = db.relationship('User', foreign_keys=[inspector_id])
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 = {
'once': 'One-time',
'daily': 'Daily',
'weekly': 'Weekly',
'monthly': 'Monthly',
'quarterly': 'Quarterly',
'bi-annually': 'Every 6 months',
'annually': 'Annually',
}
@property
def frequency_label(self):
return self.FREQUENCY_LABELS.get(self.frequency, self.frequency)
@property
def due_date(self):
"""The due date (date part of next_run_at), or None."""
return self.next_run_at.date() if self.next_run_at else None
def is_overdue(self, today=None):
"""True when an active schedule's due date has passed."""
if not self.active or self.next_run_at is None:
return False
today = today or now_eastern().date()
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):
"""Mark this occurrence complete. Caller commits.
One-time schedules deactivate. Recurring ones roll their due date
forward past today, honouring the phase46 day rules, and reset the
reminder flags. A recurring schedule whose next occurrence would fall
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()
self.last_completed_at = 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.due_notified = False
self.overdue_notified = False
def __repr__(self):
return f'<InspectionSchedule {self.id} {self.name!r} {self.frequency}>'
return (f'<InspectionSchedule {self.id} {self.name!r} '
f'{self.frequency} mode={self.mode}>')
+179 -9
View File
@@ -43,6 +43,122 @@ class IssueFollower(db.Model):
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):
__tablename__ = 'issues'
@@ -85,18 +201,24 @@ class Issue(db.Model):
vendor_notes = db.Column(db.Text, nullable=True)
# Handler type — who is responsible for resolving the issue (phase39).
# NULL and 'internal' both mean janitorial staff (the default); 'facility'
# unlocks the facility_handler_* sub-fields; 'vendor' points to vendor_*.
handler_type = db.Column(db.Enum('internal', 'facility', 'vendor'), nullable=True)
# 'internal' means janitorial staff (the default); 'facility' unlocks the
# facility_handler_* sub-fields; 'vendor' points to vendor_*.
# 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_contact = db.Column(db.String(200), nullable=True)
facility_handler_notes = db.Column(db.Text, nullable=True)
HANDLER_LABELS = {
'internal': 'Janitorial Staff',
'facility': 'Facility Staff',
'vendor': 'External Vendor',
}
# Free-text name of the janitorial staff member who will handle the issue,
# used when handler_type == 'internal'. Distinct from assigned_to (the JQC
# User who owns follow-up): the actual crew member may not be a system user.
# (phase44)
internal_handler_name = db.Column(db.String(100), nullable=True)
internal_handler_contact = db.Column(db.String(200), nullable=True) # phone or email
# Relationships
# NOTE: Issue.area is provided by the backref on Area.issues (facility.py).
@@ -112,10 +234,58 @@ class Issue(db.Model):
followers = db.relationship('IssueFollower', back_populates='issue',
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):
"""Return True if the given user is currently following this issue."""
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;
# this mapping exists so the mobile API can return a human-readable label
# without the client duplicating the strings. (phase43)
HANDLER_LABELS = {
'internal': 'Janitorial Staff',
'facility': 'Facility Staff',
'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
def handler_label(self):
"""Human-readable label for handler_type; defaults to internal."""
return self.HANDLER_LABELS.get(self.handler_type or 'internal', 'Janitorial Staff')
@property
def resolved_facility(self):
"""Returns the Facility for this issue regardless of which path was used to create it.
+7
View File
@@ -37,6 +37,12 @@ EVENT_INSPECTION_SCHEDULED = 'inspection_scheduled'
# order via the tokenized public link (phase36).
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 = {
EVENT_ISSUE_ASSIGNED: 'Issue assigned to me',
EVENT_ISSUE_STATUS: 'Issue status changed',
@@ -48,6 +54,7 @@ ALL_EVENT_TYPES = {
EVENT_ADMIN_BROADCAST: 'Admin broadcast (system announcements)',
EVENT_INSPECTION_SCHEDULED: 'Scheduled inspection due (assigned to me)',
EVENT_WORK_ORDER: 'Contractor updated a work order',
EVENT_FOLLOWUP_REQUESTED: 'Follow-up re-inspection requested',
# Customer-facing — only relevant for customer role accounts
EVENT_CUSTOMER_INSPECTION_DONE: 'Inspection completed at my facility (portal)',
EVENT_CUSTOMER_ISSUE_UPDATED: 'Issue created or updated at my facility (portal)',
+38 -1
View File
@@ -10,6 +10,14 @@ role_key values
admin all users with role='admin'
director all users with role='director'
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'
customer all customer-portal users assigned to the relevant facility
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_updated_customer : 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)
score_alert : admin director inspector pm customer (facility score drop cron)
"""
@@ -35,12 +44,17 @@ import json
from app import db
# 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 = [
('admin', 'Admin'),
('director', 'Director'),
('inspector', 'Inspector'),
('external_inspector', 'Customer Inspector'),
('project_manager', 'Project Manager'),
('customer', 'Customer'),
('auditor', 'Auditor'),
('customer', 'Customer Director'),
('custom', 'Custom Recipients'),
]
@@ -58,6 +72,7 @@ MATRIX_EVENTS = {
'issue_created': 'Issue created (standalone)',
'issue_updated_customer': 'Issue updated (customer)',
'verification_requested': 'Verification requested',
'followup_requested': 'Follow-up requested (incl. by customer)',
'sla_alert': 'SLA at-risk / breached',
'score_alert': 'Facility score trend alert (significant drop)',
}
@@ -142,6 +157,16 @@ MATRIX_DEFAULTS = {
('verification_requested', 'project_manager'): False,
('verification_requested', 'customer'): 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', 'admin'): True,
('sla_alert', 'director'): False,
@@ -158,6 +183,18 @@ MATRIX_DEFAULTS = {
('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):
"""Admin-controlled per-event notification routing."""
+28
View File
@@ -18,6 +18,29 @@ class SupportChatSession(db.Model):
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):
return f'<SupportChatSession {self.id}>'
@@ -46,6 +69,11 @@ class SupportKnowledge(db.Model):
title = db.Column(db.String(200), nullable=False)
body = db.Column(db.Text, 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_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 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
def load_user(user_id):
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):
__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)
username = db.Column(db.String(100), unique=True, nullable=False, index=True)
full_name = db.Column(db.String(150), nullable=True)
@@ -19,12 +80,26 @@ class User(UserMixin, db.Model):
role = db.Column(
# Phase 11 migration complete — 'supervisor' removed from both the DB
# ENUM and this Python-side declaration. Director is the canonical role.
db.Enum('admin', 'director', 'inspector', 'project_manager', 'customer'),
# 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
)
created_at = db.Column(db.DateTime, default=now_eastern)
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 ──────────────────────────────────
# password_set: False for newly created customer accounts until they
# 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)
# 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 ────────────────────────────────────────────
# Override UserMixin.is_active so that disabled accounts are rejected
@@ -52,12 +133,61 @@ class User(UserMixin, db.Model):
def is_active(self):
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):
self.password_hash = generate_password_hash(password)
def check_password(self, 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
def display_name(self):
"""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 urllib.parse import urlparse
from flask_login import login_user, logout_user, login_required, current_user
from app import db, limiter
from app.models.user import User
@@ -9,6 +8,7 @@ from app.utils import mfa
import logging
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.session_binding import bind_session_tenant, SESSION_TENANT_KEY
logger = logging.getLogger(__name__)
@@ -41,6 +41,7 @@ def login():
# second-factor step. Password is verified; identity is NOT yet
# established until the code is confirmed at /auth/mfa.
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_remember'] = bool(form.remember_me.data)
session['mfa_pending_next'] = safe_redirect_url(request.args.get('next'))
@@ -48,6 +49,7 @@ def login():
return redirect(url_for('auth.mfa_challenge'))
login_user(user, remember=form.remember_me.data)
bind_session_tenant() # MT-21
# Use validated next URL — never redirect blindly to request.args['next']
next_page = safe_redirect_url(request.args.get('next'))
log_action(ACTION_LOGIN, 'User', user.id, user.username)
@@ -107,6 +109,7 @@ def mfa_challenge():
next_page = session.pop('mfa_pending_next', None)
session.pop('mfa_pending_user_id', None)
login_user(user, remember=remember)
bind_session_tenant() # MT-21
log_action(ACTION_LOGIN, 'User', user.id, user.username, f'2fa via {via}')
if via == 'recovery':
remaining_n = len(user.mfa_recovery_codes or [])
@@ -199,7 +202,7 @@ def profile():
if form.validate_on_submit():
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:
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')
@login_required
@admin_required
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 = (
User.query
.filter(User.role != 'customer')
.filter(~User.role.in_(User.CUSTOMER_ROLES))
.order_by(User.created_at.desc())
.all()
)
@@ -273,6 +425,25 @@ def list_users():
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'])
@login_required
@admin_required
@@ -286,19 +457,39 @@ def create_user():
if form.validate_on_submit():
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(
username=form.username.data,
full_name=form.full_name.data.strip() or None,
email=form.email.data,
role=role
email=form.email.data.strip().lower(),
role=role,
password_set=True,
)
user.set_password(form.password.data)
db.session.add(user)
db.session.commit()
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,
f'role={user.role}; email={user.email}')
flash(f'User {user.username} created successfully.', 'success')
return redirect(url_for('auth.list_users'))
@@ -313,6 +504,9 @@ def edit_user(user_id):
user = db.session.get(User, user_id)
if user is None:
abort(404)
moved = _redirect_if_customer_account(user)
if moved:
return moved
form = UserForm(user=user, obj=user)
# 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():
user.username = form.username.data
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:
user.role = form.role.data
@@ -343,13 +537,58 @@ def edit_user(user_id):
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'])
@login_required
@admin_required
def assign_inspector_contracts(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)
# 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.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_mail import Message
from app import mail
from urllib.parse import urlparse
import threading
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('/')
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>
@@ -612,12 +859,26 @@ def forgot_password():
form = ForgotPasswordForm()
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:
token = user.generate_set_password_token(expires_hours=1)
db.session.commit()
_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
flash(
'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_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)
import os
@@ -714,6 +992,11 @@ def impersonate_end():
import os
flask_session.pop('impersonating_tenant_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')}"
logger.info('AUTH | impersonate_end | redirecting to panel')
return redirect(panel_url)
+3 -1
View File
@@ -22,10 +22,12 @@ logger = logging.getLogger(__name__)
bp = Blueprint('broadcast', __name__, url_prefix='/admin/broadcast')
# 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 = {
'inspector': 'Inspectors',
'external_inspector': 'Customer Inspectors',
'project_manager': 'Project Managers',
'director': 'Directors',
'admin': 'Admins',
+393 -56
View File
@@ -3,13 +3,26 @@ app/routes/customers.py
-----------------------
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:
- List all customer-role users with their assignment summary
- Create a new customer account
- Edit an existing customer (username / email / password / active)
- Manage assignments for a customer (add / remove)
- Quick-disable / enable a customer account
- View a customer's scoped facility access at a glance
- List all customer-side users with their assignment summary
- Invite a new customer account in either role
- Edit an existing account (username / email / password / active)
- Manage assignments (contracts/facilities for a director, contracts for an
inspector)
- Switch an account between the two roles
- Quick-disable / enable an account
- View the account's scoped facility access at a glance
"""
import logging
@@ -18,6 +31,7 @@ from flask_login import login_required, current_user
from app import db
from app.models.user import User
from app.models.project import Project, CustomerAssignment
from app.models.inspector_assignment import InspectorAssignment
from app.models.facility import Facility
from app.utils.forms import CustomerUserForm, CustomerAssignmentForm, CustomerInviteForm, SetPasswordForm
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')
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 ──────────────────────────────────────────────────────────────────────
@bp.route('/')
@login_required
@supervisor_required
def index():
"""Consolidated customer management dashboard."""
"""Consolidated customer management dashboard — both customer roles."""
customers = (
User.query
.filter_by(role='customer')
.filter(User.role.in_(User.CUSTOMER_ROLES))
.order_by(User.username)
.all()
)
@@ -57,10 +101,27 @@ def index():
for a in all_assignments:
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 ──
# Resolves facility scope for every customer without repeated DB round-trips.
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, ...]
if assigned_project_ids:
@@ -78,6 +139,12 @@ def index():
scope_map = {} # user_id → sorted list[int] facility IDs
for customer in customers:
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]:
if a.facility_id:
ids.add(a.facility_id)
@@ -103,6 +170,7 @@ def index():
'customers/index.html',
customers = customers,
assignment_map = assignment_map,
inspector_assignment_map = inspector_assignment_map,
scope_map = scope_map,
projects = projects,
expired_invitations = expired_invitations,
@@ -115,12 +183,16 @@ def index():
@login_required
@supervisor_required
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
auto-generated from the email address. A one-time set-password link
is emailed; the customer chooses their own username and password when
they click it. The account is activated on completion.
Admin enters Full Name, Email and the role (Customer Director or Customer
Inspector). A temporary username is auto-generated from the email address.
A one-time set-password link is emailed; the invitee chooses their own
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()
@@ -129,6 +201,11 @@ def create():
full_name = form.full_name.data.strip()
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.
# The customer replaces this with their preferred username when
@@ -146,7 +223,7 @@ def create():
username = username,
full_name = full_name,
email = email,
role = 'customer',
role = role,
active = True,
password_set = False,
)
@@ -157,15 +234,15 @@ def create():
token = user.generate_set_password_token(expires_hours=72)
db.session.commit()
logger.info('CUSTOMERS | invite | admin=%s new_customer=%s email=%s',
current_user.username, user.username, user.email)
logger.info('CUSTOMERS | invite | admin=%s new_customer=%s role=%s email=%s',
current_user.username, user.username, user.role, user.email)
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)
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.',
'success'
)
@@ -179,7 +256,6 @@ def _send_invite_email(user, token, base_url=None):
from flask import current_app, render_template_string
from flask_mail import Message
from app import mail
from urllib.parse import urlparse
import threading
if not current_app.config.get('MAIL_SERVER'):
@@ -189,8 +265,14 @@ def _send_invite_email(user, token, base_url=None):
effective_base = (base_url or current_app.config.get('APP_BASE_URL', '')).rstrip('/')
setup_link = f'{effective_base}{url_for("customers.set_password", token=token)}'
host = urlparse(effective_base).netloc or 'janitorialqc.local'
sender = f'noreply@{host}'
# Branded From as a (display_name, address) tuple. The display NAME tracks
# the tenant (e.g. "Gov Services QC"); the ADDRESS is branded only for
# DNS-authorized domains and otherwise stays the authenticated identity so
# the mail always delivers. The previous `noreply@{host}` sent from whatever
# host the browser was on, which fails SPF/DMARC for any domain this mail
# server isn't authorized for. See app/utils/mail_utils.py and rule 64.
from app.utils.mail_utils import branded_sender
sender = branded_sender(effective_base)
html_body = render_template_string("""<!DOCTYPE html>
<html>
@@ -254,12 +336,9 @@ def _send_invite_email(user, token, base_url=None):
@supervisor_required
def resend_invite(customer_id):
"""Generate a fresh token and resend the set-password invitation email."""
customer = db.session.get(User, customer_id)
if customer is None:
abort(404)
if customer.role != 'customer':
flash('This action is only for customer accounts.', 'warning')
return redirect(url_for('customers.index'))
customer, moved = _get_customer_or_redirect(customer_id)
if moved:
return moved
token = customer.generate_set_password_token(expires_hours=72)
customer.password_set = False
@@ -316,19 +395,16 @@ def set_password(token):
@login_required
@supervisor_required
def edit(customer_id):
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'))
customer, moved = _get_customer_or_redirect(customer_id)
if moved:
return moved
form = CustomerUserForm(user=customer, obj=customer)
if form.validate_on_submit():
customer.username = form.username.data
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:
customer.set_password(form.password.data)
db.session.commit()
@@ -349,16 +425,26 @@ def edit(customer_id):
@login_required
@supervisor_required
def manage(customer_id):
"""Single-customer detail page: profile + all assignments."""
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'))
"""Single-account detail page: profile + assignments + notification matrix.
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 []
facilities = (
Facility.query
.filter(Facility.id.in_(facility_ids), Facility.active == True)
@@ -366,19 +452,51 @@ def manage(customer_id):
.all()
) 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)
aform = CustomerAssignmentForm()
projects = Project.query.filter_by(active=True).order_by(Project.name).all()
aform.user_id.choices = [(customer.id, customer.username)]
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(
'customers/manage.html',
customer = customer,
assignments = assignments,
assigned_pids = assigned_pids,
facilities = facilities,
aform = aform,
projects = projects,
matrix_rows = matrix_rows,
)
@@ -388,12 +506,16 @@ def manage(customer_id):
@login_required
@supervisor_required
def add_assignment(customer_id):
customer = db.session.get(User, customer_id)
if customer is None:
abort(404)
if customer.role != 'customer':
flash('Assignments are only for customer accounts.', 'warning')
return redirect(url_for('customers.index'))
customer, moved = _get_customer_or_redirect(customer_id)
if moved:
return moved
if customer.is_inspector:
# A Customer Inspector is scoped by InspectorAssignment — writing a
# 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)
facility_id = request.form.get('facility_id', type=int) or None
@@ -462,18 +584,233 @@ def remove_assignment(assignment_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 ─────────────────────────────────────────────────────────────
@bp.route('/<int:customer_id>/toggle-active', methods=['POST'])
@login_required
@supervisor_required
def toggle_active(customer_id):
customer = db.session.get(User, customer_id)
if customer is None:
abort(404)
if customer.role != 'customer':
flash('This action is only for customer accounts.', 'warning')
return redirect(url_for('customers.index'))
customer, moved = _get_customer_or_redirect(customer_id)
if moved:
return moved
customer.active = not customer.active
db.session.commit()
+106 -7
View File
@@ -17,6 +17,16 @@ bp = Blueprint('dashboard', __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('/dashboard')
@login_required
@@ -24,11 +34,16 @@ def index():
now = now_eastern()
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
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_customer = current_user.role == 'customer'
is_project_manager = current_user.role == 'project_manager'
is_auditor = current_user.role == 'auditor'
# Resolve facility scope
customer_facility_ids = get_customer_scope(current_user) # None for non-customers
@@ -61,6 +76,14 @@ def index():
Inspection.inspection_date < today_end,
).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_q = Issue.query.filter(Issue.status.in_(['open', 'in_progress']))
if is_inspector:
@@ -87,7 +110,7 @@ def index():
))
# 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)
severity_breakdown = {
'critical': sum(1 for i in open_issues_all if i.severity == 'critical'),
@@ -162,9 +185,13 @@ def index():
if not inspector_facility_ids:
followup_q = followup_q.filter(False)
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(
Inspection.facility_id.in_(inspector_facility_ids),
Inspection.inspector_id == current_user.id,
Inspection.follow_up_owned_by(current_user.id),
)
elif is_customer:
if customer_facility_ids:
@@ -209,7 +236,7 @@ def index():
elif is_customer and not customer_facility_ids:
all_open_issues = []
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_at_risk = sum(1 for i in all_open_issues if sla_status(i) == 'at_risk')
@@ -281,6 +308,26 @@ def index():
stale_q = stale_q.filter(False)
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 ────────────────────────────────────────────────
from app.models.facility import Area as _AreaU
unassigned_q = Issue.query.outerjoin(_AreaU, Issue.area_id == _AreaU.id).filter(
@@ -299,12 +346,12 @@ def index():
unassigned_q = unassigned_q.filter(False) # not relevant for customers
unassigned_open = unassigned_q.count()
# ── Inspector activity today (admin / director / PM only) ─────────────────
# ── Inspector activity today (admin / director / PM / auditor only) ───────
inspector_activity = []
if is_privileged or is_project_manager:
if is_privileged or is_project_manager or is_auditor:
active_inspectors = (
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)
.all()
)
@@ -343,8 +390,60 @@ def index():
.all()
)
# ── Scheduled inspections (phase43): upcoming / overdue ───────────────────
# Plan-mode only: 'auto' schedules materialise themselves into the
# inspections list, so surfacing them here would double-report the work.
sched_upcoming = []
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:
from app.models.inspection_schedule import InspectionSchedule
_today = now_eastern().date()
_sq = InspectionSchedule.query.filter(
InspectionSchedule.active.is_(True),
InspectionSchedule.mode == 'plan',
)
if is_inspector:
_sq = _sq.filter(InspectionSchedule.inspector_id == current_user.id)
_all_sched = _sq.order_by(InspectionSchedule.next_run_at.asc()).all()
sched_overdue_count = sum(1 for s in _all_sched if s.is_overdue(_today))
# Upcoming = due today through the next 7 days (overdue shown separately)
sched_upcoming = [
s for s in _all_sched
if s.next_run_at and _today <= s.next_run_at.date() <= _today + timedelta(days=7)
][: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(
'dashboard.html',
sched_upcoming = sched_upcoming,
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,
completed_today = completed_today,
open_issues = open_issues,
+302 -12
View File
@@ -6,7 +6,8 @@ from app.models.facility import Facility, Area
from app.models.project import Project
from app.utils.forms import FacilityForm, AreaForm
from app.utils.decorators import supervisor_required, admin_required, project_manager_required
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,
ACTION_EXPORT)
from app.utils.scope import get_customer_scope, get_inspector_scope
from app.tenancy.gates import quota_soft_check
@@ -22,7 +23,7 @@ def list_facilities():
facilities = Facility.query.filter(
Facility.id.in_(cids), Facility.active == True
).order_by(Facility.name).all()
elif current_user.role == 'inspector':
elif current_user.is_inspector:
fids = get_inspector_scope(current_user) or []
facilities = Facility.query.filter(
Facility.id.in_(fids), Facility.active == True
@@ -244,20 +245,70 @@ def delete_area(area_id):
def _qr_scan_url(facility):
"""Absolute public scan URL, built from the current host (rule 64 pattern)."""
facility.ensure_qr_token()
return request.host_url.rstrip('/') + url_for('facility_qr.scan',
token=facility.qr_token)
def _facility_for_qr_or_403(facility_id):
"""Load a facility for a QR action, enforcing customer facility scope.
Customers may only touch QR codes for facilities they are assigned to; all
other roles have unrestricted QR access. Replaces the previous
@project_manager_required gate so a customer can print (and rotate) the
codes posted in their own building.
"""
facility = db.session.get(Facility, facility_id)
if facility is None:
abort(404)
# QR management is not an inspector task (matches qr_print_all/qr_export_pdf).
if current_user.is_inspector:
abort(403)
if current_user.role == 'customer':
cids = get_customer_scope(current_user) or []
if facility.id not in cids:
abort(403)
return facility
def _qr_png_bytes(url):
"""Return PNG bytes for a QR code encoding *url*."""
import io as _io
import qrcode
img = qrcode.make(url, box_size=10, border=2)
buf = _io.BytesIO()
img.save(buf, format='PNG')
return buf.getvalue()
@bp.route('/<int:facility_id>/qr.png')
@login_required
def facility_qr_png(facility_id):
"""Return the facility's QR code as a PNG image.
The printable card renders inline SVG; this PNG endpoint exists for the
print-all grid and is the same image the PDF export embeds.
"""
facility = _facility_for_qr_or_403(facility_id)
created = not facility.qr_token
url = _qr_scan_url(facility)
if created:
db.session.commit()
from flask import Response
return Response(_qr_png_bytes(url), mimetype='image/png', headers={
'Cache-Control': 'private, max-age=3600',
})
@bp.route('/<int:facility_id>/qr')
@login_required
@project_manager_required
def qr_card(facility_id):
"""Printable QR card for one facility. Generates the token on first use."""
from app.utils.qr import qr_svg
facility = db.session.get(Facility, facility_id)
if facility is None:
abort(404)
facility = _facility_for_qr_or_403(facility_id)
if not facility.qr_token:
facility.ensure_qr_token()
@@ -274,11 +325,19 @@ def qr_card(facility_id):
@bp.route('/qr-sheet')
@login_required
@project_manager_required
def qr_sheet():
"""Bulk print sheet — one labeled QR card per active facility."""
from app.utils.qr import qr_svg
if current_user.is_inspector:
abort(403)
if current_user.role == 'customer':
cids = get_customer_scope(current_user) or []
facilities = (Facility.query
.filter(Facility.id.in_(cids), Facility.active == True)
.order_by(Facility.name).all()) if cids else []
else:
facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all()
generated = 0
@@ -297,14 +356,17 @@ def qr_sheet():
@bp.route('/<int:facility_id>/qr/regenerate', methods=['POST'])
@login_required
@supervisor_required
def regenerate_qr(facility_id):
"""Rotate the QR token — invalidates every previously printed poster."""
"""Rotate the QR token — invalidates every previously printed poster.
Allowed for admin/director, and for customers on their own assigned
facilities. Project managers, auditors and inspectors cannot regenerate.
"""
import secrets
facility = db.session.get(Facility, facility_id)
if facility is None:
abort(404)
facility = _facility_for_qr_or_403(facility_id)
if current_user.role not in ('admin', 'director', 'customer'):
abort(403)
facility.qr_token = secrets.token_urlsafe(32)
db.session.commit()
@@ -315,3 +377,231 @@ def regenerate_qr(facility_id):
flash('QR code regenerated. Previously printed posters no longer work — '
'print and post the new code.', 'success')
return redirect(url_for('facilities.qr_card', facility_id=facility_id))
# ── Public Area QR codes (phase42) ────────────────────────────────────────────
# Mirrors the facility QR routes above, but scoped to a single area. Customer
# scope is enforced via the area's parent facility.
def _area_qr_scan_url(area):
"""Absolute public scan URL for an area, built from the current host."""
area.ensure_qr_token()
return request.host_url.rstrip('/') + url_for('facility_qr.area_scan',
token=area.qr_token)
def _area_for_qr_or_403(area_id):
"""Load an area for a QR action, enforcing customer facility scope."""
area = db.session.get(Area, area_id)
if area is None:
abort(404)
# QR management is not an inspector task (matches qr_print_all/qr_export_pdf).
if current_user.is_inspector:
abort(403)
if current_user.role == 'customer':
cids = get_customer_scope(current_user) or []
if area.facility_id not in cids:
abort(403)
return area
@bp.route('/areas/<int:area_id>/qr.png')
@login_required
def area_qr_png(area_id):
"""Return the area's QR code as a PNG image."""
area = _area_for_qr_or_403(area_id)
created = not area.qr_token
url = _area_qr_scan_url(area)
if created:
db.session.commit()
from flask import Response
return Response(_qr_png_bytes(url), mimetype='image/png', headers={
'Cache-Control': 'private, max-age=3600',
})
@bp.route('/areas/<int:area_id>/qr')
@login_required
def area_qr_card(area_id):
"""Printable page: area name + facility + QR + public URL + instructions."""
from app.utils.qr import qr_svg
area = _area_for_qr_or_403(area_id)
created = not area.qr_token
scan_url = _area_qr_scan_url(area)
if created:
db.session.commit()
logger.info('FACILITIES | area_qr_token_created | user=%s | area_id=%s',
current_user.username, area_id)
return render_template('facilities/area_qr.html',
area=area,
facility=area.facility,
scan_url=scan_url,
svg=qr_svg(scan_url))
@bp.route('/areas/<int:area_id>/qr/regenerate', methods=['POST'])
@login_required
def regenerate_area_qr(area_id):
"""Rotate an area's QR token — invalidates every previously printed poster.
Allowed for admin/director, and for customers on their own assigned
facilities. Project managers, auditors and inspectors cannot regenerate.
"""
import secrets
area = _area_for_qr_or_403(area_id)
if current_user.role not in ('admin', 'director', 'customer'):
abort(403)
area.qr_token = secrets.token_urlsafe(32)
db.session.commit()
logger.info('FACILITIES | area_qr_token_regenerated | user=%s | area_id=%s',
current_user.username, area_id)
log_action(ACTION_UPDATE, 'Area', area.id, area.name,
'QR token regenerated — previously printed QR posters are now invalid')
flash('QR code regenerated. Previously printed posters no longer work — '
'print and post the new code.', 'warning')
return redirect(url_for('facilities.area_qr_card', area_id=area.id))
# ── Bulk QR print / export (phase42) ──────────────────────────────────────────
@bp.route('/qr/print-all')
@login_required
def qr_print_all():
"""Printable / selectable sheet of the QR codes the user can see.
Query params (all optional):
?contract_id=<id> limit to one contract; narrows the facility dropdown
?facility_id=<id> limit to a single facility
?include_areas=1 also render each facility's per-area QR codes
Inspectors have no QR management (403); customers are scoped to their
assigned facilities; managers see all active facilities.
"""
if current_user.is_inspector:
abort(403)
contract_id = request.args.get('contract_id', type=int)
facility_id = request.args.get('facility_id', type=int)
include_areas = request.args.get('include_areas') in ('1', 'true', 'on')
# Facilities in the viewer's scope.
if current_user.role == 'customer':
fids = get_customer_scope(current_user) or []
scoped = Facility.query.filter(Facility.id.in_(fids), Facility.active == True)
else:
scoped = Facility.query.filter(Facility.active == True)
scoped_facilities = scoped.order_by(Facility.name).all()
# Contract dropdown — only contracts present among the scoped facilities.
contract_ids = {f.project_id for f in scoped_facilities if f.project_id}
contracts = (Project.query
.filter(Project.id.in_(contract_ids))
.order_by(Project.name).all()) if contract_ids else []
# Facility dropdown — narrowed by the selected contract.
facility_options = [f for f in scoped_facilities
if not contract_id or f.project_id == contract_id]
# The rendered grid — apply the contract + facility filters.
grid_facilities = facility_options
if facility_id:
grid_facilities = [f for f in grid_facilities if f.id == facility_id]
# Ensure every rendered facility (and area, if requested) has a token so
# its qr.png renders; collect areas keyed by facility id.
changed = False
areas_by_facility = {}
for f in grid_facilities:
if not f.qr_token:
f.ensure_qr_token()
changed = True
if include_areas:
fa = f.areas.order_by(Area.name).all()
for a in fa:
if not a.qr_token:
a.ensure_qr_token()
changed = True
areas_by_facility[f.id] = fa
if changed:
db.session.commit()
selected_contract = db.session.get(Project, contract_id) if contract_id else None
return render_template('facilities/qr_print_all.html',
facilities=grid_facilities,
areas_by_facility=areas_by_facility,
include_areas=include_areas,
contracts=contracts,
facility_options=facility_options,
selected_contract=selected_contract,
selected_contract_id=contract_id,
selected_facility_id=facility_id)
@bp.route('/qr/export-pdf', methods=['POST'])
@login_required
def qr_export_pdf():
"""Export the selected facility + area QR codes to a single PDF.
Selection arrives as repeated `facility_ids` / `area_ids` form fields.
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.
"""
if current_user.is_inspector:
abort(403)
facility_ids = request.form.getlist('facility_ids', type=int)
area_ids = request.form.getlist('area_ids', type=int)
if not facility_ids and not area_ids:
flash('Select at least one QR code to export.', 'warning')
return redirect(request.referrer or url_for('facilities.qr_print_all'))
items = []
for fid in facility_ids:
facility = _facility_for_qr_or_403(fid) # 403 if out of scope
url = _qr_scan_url(facility)
items.append({
'title': facility.name,
'subtitle': facility.project.name if facility.project else None,
'caption': 'Facility · Report a problem & view recent quality',
'png': _qr_png_bytes(url),
'_sort': ((facility.name or '').lower(), 0, ''),
})
for aid in area_ids:
area = _area_for_qr_or_403(aid) # 403 if out of scope
url = _area_qr_scan_url(area)
fac_name = area.facility.name if area.facility else ''
items.append({
'title': area.name,
'subtitle': fac_name or None,
'caption': 'Area · Report a problem & view recent quality',
'png': _qr_png_bytes(url),
'_sort': (fac_name.lower(), 1, (area.name or '').lower()),
})
# Persist any tokens minted by ensure_qr_token() above.
db.session.commit()
# Group each facility with its own areas: facility card first, then areas.
items.sort(key=lambda x: x['_sort'])
from app.utils.pdf_export import generate_qr_codes_pdf
summary = f'{len(facility_ids)} facilit' + ('y' if len(facility_ids) == 1 else 'ies')
summary += f', {len(area_ids)} area' + ('' if len(area_ids) == 1 else 's')
pdf_bytes = generate_qr_codes_pdf(items, filter_summary=summary)
logger.info('FACILITIES | qr_export_pdf | user=%s | facilities=%s | areas=%s',
current_user.username, len(facility_ids), len(area_ids))
log_action(ACTION_EXPORT, 'Facility', 0, 'QR Codes',
f'exported {len(facility_ids)} facility + {len(area_ids)} area QR codes to PDF')
from flask import Response
return Response(pdf_bytes, mimetype='application/pdf', headers={
'Content-Disposition': 'attachment; filename="qr_codes.pdf"',
})
+238 -39
View File
@@ -5,7 +5,13 @@ Public facility QR scan page (phase38).
`GET /f/<token>` public, tokenized, NO login (same authorization model as
vendor work orders, rule 89: the unguessable token IS the credential). Shows a
read-only, counts-and-scores-only snapshot of one facility:
read-only, counts-and-scores-only snapshot of one facility.
`GET /f/area/<token>` (phase42) the same page scoped to a single area, so a
code posted inside one restroom reports on that restroom. Metrics are scoped by
`Inspection.area_id` / `Issue.area_id`.
Both pages show:
* summary stats (90 days): completed inspections, average score,
resolved issues, last inspection date
@@ -25,7 +31,8 @@ In multi-tenant mode the printed URL is built from the tenant's own domain
import logging
from datetime import timedelta
from flask import Blueprint, render_template, redirect, request, url_for, abort
from flask import (Blueprint, render_template, redirect, request, url_for,
abort, flash)
from flask_login import current_user
from sqlalchemy import func, or_
@@ -33,6 +40,8 @@ from app import db, limiter
from app.models.facility import Facility, Area
from app.models.inspection import Inspection
from app.models.issue import Issue
from app.utils.forms import PublicIssueReportForm
from app.utils.notifications import notify_by_matrix
from app.utils.sla import sla_status
from app.utils.scope import get_customer_scope, get_inspector_scope
from app.utils.time_utils import now_eastern
@@ -43,6 +52,72 @@ bp = Blueprint('facility_qr', __name__, url_prefix='/f')
SEVERITY_ORDER = ('critical', 'high', 'medium', 'low')
#: Maximum number of photos an occupant may attach to a public report.
MAX_REPORT_PHOTOS = 5
def _save_report_photos(file_list):
"""Save up to MAX_REPORT_PHOTOS uploaded photos from a public report.
Returns (photo_path, extra_paths) where photo_path is the primary evidence
photo (or None) and extra_paths is a list of the remaining paths (or None).
Splitting this way mirrors the Issue photo model: the first photo lives in
`photo_path`, the rest in `mobile_photo_paths` so they all render together
under "Photo Evidence" on the web (rule 44 never `result_photos`).
Writes go through `_save_photo`, which validates magic bytes and routes to
the active storage backend (see app/utils/storage.py).
"""
from app.routes.inspections import _save_photo
saved = []
for f in (file_list or [])[:MAX_REPORT_PHOTOS]:
path = _save_photo(f, subfolder='issue_photos')
if path:
saved.append(path)
photo_path = saved[0] if saved else None
extra_paths = saved[1:] if len(saved) > 1 else None
return photo_path, extra_paths
def _facility_by_token_or_404(token):
"""Resolve an ACTIVE facility from its QR token, else 404."""
if not token:
abort(404)
facility = Facility.query.filter_by(qr_token=token).first()
if facility is None or not facility.active:
abort(404)
return facility
def _area_by_token_or_404(token):
"""Resolve an area (and its ACTIVE facility) from the area's QR token."""
if not token:
abort(404)
area = Area.query.filter_by(qr_token=token).first()
if area is None:
abort(404)
facility = db.session.get(Facility, area.facility_id)
if facility is None or not facility.active:
abort(404)
return area, facility
def _build_report_description(form, prefix):
"""Fold optional reporter identity + location into the issue description.
The public reporter is not a User, so `reported_by` stays NULL and this is
the only place their name/contact is recorded.
"""
parts = [prefix]
if form.area_label.data:
parts.append(f'Location: {form.area_label.data.strip()}')
reporter_bits = [b for b in (form.reporter_name.data, form.reporter_contact.data) if b]
if reporter_bits:
parts.append('Reporter: ' + ''.join(b.strip() for b in reporter_bits))
parts.append('')
parts.append(form.description.data.strip())
return '\n'.join(parts)
def _can_view_full(facility):
"""True when the logged-in scanner's role scope covers this facility."""
@@ -50,34 +125,44 @@ def _can_view_full(facility):
return False
if current_user.role in ('admin', 'director', 'project_manager'):
return True
if current_user.role == 'inspector':
if current_user.is_inspector:
return facility.id in (get_inspector_scope(current_user) or [])
if current_user.role == 'customer':
return facility.id in (get_customer_scope(current_user) or [])
return False
@bp.route('/<token>')
@limiter.limit('60 per hour')
def scan(token):
facility = Facility.query.filter_by(qr_token=token).first()
if facility is None or not facility.active:
abort(404)
def _build_snapshot(facility, area=None):
"""Assemble the public snapshot for a facility, or for one area of it.
When `area` is given every metric is scoped to that area via
`Inspection.area_id` / `Issue.area_id`; otherwise the facility-wide math is
used, unchanged from phase38. Returns the template context (minus `token`).
"""
now = now_eastern()
d30 = now - timedelta(days=30)
d60 = now - timedelta(days=60)
d90 = now - timedelta(days=90)
if area is not None:
insp_scope = (Inspection.area_id == area.id,)
issue_q = Issue.query.filter(Issue.area_id == area.id)
else:
insp_scope = (Inspection.facility_id == facility.id,)
issue_q = (Issue.query
.outerjoin(Area, Issue.area_id == Area.id)
.filter(or_(Issue.facility_id == facility.id,
Area.facility_id == facility.id)))
completed = Inspection.query.filter(
Inspection.facility_id == facility.id,
*insp_scope,
Inspection.status == 'completed',
)
# ── Summary stats (90 days) ───────────────────────────────────────────
total_90 = completed.filter(Inspection.inspection_date >= d90).count()
avg_90 = db.session.query(func.avg(Inspection.overall_score)).filter(
Inspection.facility_id == facility.id,
*insp_scope,
Inspection.status == 'completed',
Inspection.inspection_date >= d90,
Inspection.overall_score.isnot(None),
@@ -91,7 +176,7 @@ def scan(token):
# ── Score trend: last 30 days vs prior 30 (mirrors send_score_alerts) ─
def _avg_between(start, end):
return db.session.query(func.avg(Inspection.overall_score)).filter(
Inspection.facility_id == facility.id,
*insp_scope,
Inspection.status == 'completed',
Inspection.overall_score.isnot(None),
Inspection.inspection_date >= start,
@@ -104,11 +189,6 @@ def scan(token):
if (avg_cur is not None and avg_prior is not None) else None
# ── Open issues: counts by severity + SLA state (counts only) ─────────
issue_q = (Issue.query
.outerjoin(Area, Issue.area_id == Area.id)
.filter(or_(Issue.facility_id == facility.id,
Area.facility_id == facility.id)))
open_issues = issue_q.filter(
Issue.status.in_(('open', 'in_progress'))).all()
severity_counts = {s: 0 for s in SEVERITY_ORDER}
@@ -130,13 +210,9 @@ def scan(token):
Issue.resolved_at >= d90,
).count()
logger.info('FACILITY QR SCAN | facility_id=%s | authenticated=%s',
facility.id, current_user.is_authenticated)
return render_template(
'facility_qr/view.html',
token = token,
return dict(
facility = facility,
area = area,
contract = facility.project,
total_90 = total_90,
avg_90 = float(avg_90) if avg_90 is not None else None,
@@ -157,6 +233,39 @@ def scan(token):
)
@bp.route('/<token>')
@limiter.limit('60 per hour')
def scan(token):
facility = _facility_by_token_or_404(token)
logger.info('FACILITY QR SCAN | facility_id=%s | authenticated=%s',
facility.id, current_user.is_authenticated)
return render_template(
'facility_qr/view.html',
token = token,
form = PublicIssueReportForm(),
**_build_snapshot(facility),
)
@bp.route('/area/<token>')
@limiter.limit('60 per hour')
def area_scan(token):
"""Public snapshot for a single area (phase42)."""
area, facility = _area_by_token_or_404(token)
logger.info('AREA QR SCAN | area_id=%s | facility_id=%s | authenticated=%s',
area.id, facility.id, current_user.is_authenticated)
return render_template(
'facility_qr/area.html',
token = token,
form = PublicIssueReportForm(),
**_build_snapshot(facility, area=area),
)
@bp.route('/<token>/report', methods=['POST'])
@limiter.limit('5 per hour')
def report(token):
@@ -165,42 +274,132 @@ def report(token):
No login required the unguessable QR token is the sole authorization.
A honeypot field silently rejects bot submissions. Creates an Issue with
reported_by=None so staff know it came from a public form.
phase42: accepts up to 5 photos, an optional location label, and optional
reporter identity, on top of the description + severity taken previously.
"""
facility = Facility.query.filter_by(qr_token=token).first()
if facility is None or not facility.active:
abort(404)
facility = _facility_by_token_or_404(token)
form = PublicIssueReportForm()
# Honeypot — bots fill this field, humans leave it blank
if request.form.get('website', '').strip():
if form.website.data:
logger.warning('FACILITY QR REPORT | honeypot triggered | facility_id=%s', facility.id)
return redirect(url_for('facility_qr.scan', token=token) + '?reported=1')
description = request.form.get('description', '').strip()
severity = request.form.get('severity', 'medium')
if not form.validate_on_submit():
# Re-render with validation errors and the snapshot intact.
return render_template(
'facility_qr/view.html',
token = token,
form = form,
**_build_snapshot(facility),
), 400
if not description:
return redirect(url_for('facility_qr.scan', token=token))
severity = form.severity.data or 'medium'
if severity not in ('low', 'medium', 'high'):
severity = 'medium'
photo_path, extra_photos = _save_report_photos(form.photos.data)
description = _build_report_description(form, '[Reported via facility QR code]')
issue = Issue(
facility_id = facility.id,
area_id = None,
severity = severity,
description = description,
photo_path = photo_path,
mobile_photo_paths = extra_photos,
status = 'open',
reported_by = None, # anonymous public submission
)
db.session.add(issue)
db.session.commit()
logger.info('FACILITY QR REPORT | facility_id=%s issue_id=%s severity=%s',
facility.id, issue.id, severity)
_photo_count = (1 if photo_path else 0) + (len(extra_photos) if extra_photos else 0)
logger.info('FACILITY QR REPORT | facility_id=%s issue_id=%s severity=%s photos=%s',
facility.id, issue.id, severity, _photo_count)
# Notify staff via the notification matrix (same event as Issues → Create)
try:
from app.utils.notifications import notify_by_matrix
notify_by_matrix('issue_created', issue_id=issue.id, facility_id=facility.id)
except Exception as exc:
logger.error('FACILITY QR REPORT | notify_failed | err=%s', exc)
# Notify staff via the notification matrix (same event as Issues → Create).
# NOTE: title/body are REQUIRED positional args. The phase38 call omitted
# them, so every public QR report raised TypeError into the except below and
# nobody was ever notified — see MT3_DEPLOY.md §1.
_snippet = form.description.data.strip()
notify_by_matrix(
event_type = 'issue_created',
title = f'New Issue #{issue.id} at {facility.name} (QR report)',
body = (
f'A problem was reported at {facility.name} via the facility QR code. '
f'Description: {_snippet[:120]}{"" if len(_snippet) > 120 else ""}'
),
link = url_for('issues.view', issue_id=issue.id),
issue_id = issue.id,
facility_id = facility.id,
)
db.session.commit()
return redirect(url_for('facility_qr.scan', token=token) + '?reported=1')
@bp.route('/area/<token>/report', methods=['POST'])
@limiter.limit('5 per hour')
def area_report(token):
"""Public occupant issue report submitted from an area QR page (phase42).
The area is known from the token, so `area_id` is set directly staff see
exactly which room the report came from without the occupant describing it.
"""
area, facility = _area_by_token_or_404(token)
form = PublicIssueReportForm()
if form.website.data:
logger.warning('AREA QR REPORT | honeypot triggered | area_id=%s', area.id)
return redirect(url_for('facility_qr.area_scan', token=token) + '?reported=1')
if not form.validate_on_submit():
return render_template(
'facility_qr/area.html',
token = token,
form = form,
**_build_snapshot(facility, area=area),
), 400
severity = form.severity.data or 'medium'
if severity not in ('low', 'medium', 'high'):
severity = 'medium'
photo_path, extra_photos = _save_report_photos(form.photos.data)
description = _build_report_description(
form, f'[Reported via area QR code — {area.name}]')
issue = Issue(
facility_id = facility.id,
area_id = area.id,
severity = severity,
description = description,
photo_path = photo_path,
mobile_photo_paths = extra_photos,
status = 'open',
reported_by = None, # anonymous public submission
)
db.session.add(issue)
db.session.commit()
_photo_count = (1 if photo_path else 0) + (len(extra_photos) if extra_photos else 0)
logger.info('AREA QR REPORT | area_id=%s facility_id=%s issue_id=%s severity=%s photos=%s',
area.id, facility.id, issue.id, severity, _photo_count)
_snippet = form.description.data.strip()
notify_by_matrix(
event_type = 'issue_created',
title = f'New Issue #{issue.id} at {facility.name}{area.name} (QR report)',
body = (
f'A problem was reported in {area.name} at {facility.name} via the area '
f'QR code. Description: {_snippet[:120]}{"" if len(_snippet) > 120 else ""}'
),
link = url_for('issues.view', issue_id=issue.id),
issue_id = issue.id,
facility_id = facility.id,
)
db.session.commit()
return redirect(url_for('facility_qr.area_scan', token=token) + '?reported=1')
File diff suppressed because it is too large Load Diff
+693 -75
View File
File diff suppressed because it is too large Load Diff
+594 -60
View File
@@ -6,7 +6,7 @@ from flask import (Blueprint, render_template, redirect, url_for,
flash, request, current_app, jsonify, abort, Response)
from flask_login import login_required, current_user
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.user import User
from app.models.notification import (
@@ -15,7 +15,8 @@ from app.models.notification import (
EVENT_CUSTOMER_ISSUE_UPDATED,
)
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, return_url)
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.tenancy.gates import quota_soft_check
@@ -70,6 +71,60 @@ class _SLAFilteredPage:
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')
@login_required
def export_list_pdf():
@@ -85,7 +140,7 @@ def export_list_pdf():
.order_by(Issue.reported_at.desc())
)
if current_user.role == 'inspector':
if current_user.is_inspector:
fids = get_inspector_scope(current_user)
if not fids:
q = q.filter(False)
@@ -133,13 +188,14 @@ def export_list_pdf():
date_to_filter = ''
if contract_filter.isdigit():
_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_(
Issue.facility_id.in_(_contract_fids),
Area.facility_id.in_(_contract_fids),
)) if _contract_fids else q.filter(False)
if facility_filter:
if facility_filter.isdigit():
fid = int(facility_filter)
q = q.filter(db.or_(Issue.facility_id == fid, Area.facility_id == fid))
if reporter_filter.isdigit():
@@ -168,7 +224,7 @@ def export_list_pdf():
p = db.session.get(Project, int(contract_filter))
if p:
filter_parts.append(f'Contract: {p.name}')
if facility_filter:
if facility_filter.isdigit():
f = db.session.get(Facility, int(facility_filter))
if f:
filter_parts.append(f'Facility: {f.name}')
@@ -210,7 +266,7 @@ def index():
.order_by(Issue.reported_at.desc())
)
if current_user.role == 'inspector':
if current_user.is_inspector:
fids = get_inspector_scope(current_user)
if not fids:
q = q.filter(False)
@@ -266,7 +322,8 @@ def index():
date_to_filter = ''
if contract_filter.isdigit():
_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_(
Issue.facility_id.in_(_contract_fids),
@@ -274,7 +331,7 @@ def index():
)) if _contract_fids else q.filter(False)
if reporter_filter.isdigit():
q = q.filter(Issue.reported_by == int(reporter_filter))
if facility_filter:
if facility_filter.isdigit():
fid = int(facility_filter)
q = q.filter(
db.or_(
@@ -312,13 +369,14 @@ def index():
# can render the following badge and inline unfollow button without an
# additional query per row.
followed_ids = {
f.issue_id
for f in IssueFollower.query.filter_by(user_id=current_user.id).all()
iid for (iid,) in
db.session.query(IssueFollower.issue_id)
.filter(IssueFollower.user_id == current_user.id).all()
}
# Facilities for the filter dropdown — scoped for inspectors/customers,
# 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 []
_fq = Facility.query.filter(Facility.id.in_(fids), Facility.active == True)
elif current_user.role == 'customer':
@@ -346,7 +404,8 @@ def index():
# Staff for quick-assign dropdown — same roles as the full issue form
staff = User.query.filter(
User.role.in_(['admin', 'director', 'inspector']), User.active == True
User.role.in_(['director', 'inspector', 'external_inspector', 'auditor']),
User.active == True
).order_by(User.username).all()
# Reporters dropdown — users who have actually filed at least one issue
@@ -384,18 +443,14 @@ def view(issue_id):
if issue is None:
abort(404)
if current_user.role == 'inspector':
fids = get_inspector_scope(current_user)
facility = issue.resolved_facility
if not fids or not facility or facility.id not in fids:
# Scope gate — see _issue_readable_by(). This was two inline blocks that the
# linked-issues panel would have had to reproduce a third time; it is now
# one definition so the panel cannot end up more permissive than the page.
if not _issue_readable_by(issue, current_user):
flash('Access denied.', 'danger')
return redirect(url_for('issues.index'))
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':
# 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)
@@ -404,7 +459,7 @@ def view(issue_id):
comment_body = request.form.get('update_notes', '').strip()
if not comment_body:
flash('Comment cannot be empty.', 'warning')
return redirect(url_for('issues.view', issue_id=issue_id))
return redirect(_view_url(issue_id))
comment = IssueComment(
issue_id=issue.id,
user_id=current_user.id,
@@ -418,11 +473,20 @@ def view(issue_id):
f'#{issue.id}',
'customer comment added')
flash('Comment posted.', 'success')
return redirect(url_for('issues.view', issue_id=issue_id))
return redirect(_view_url(issue_id))
form = IssueUpdateForm(obj=issue)
staff = User.query.filter(User.role.in_(['admin', 'director', 'inspector'])).order_by(User.username).all()
form.assigned_to.choices = [(0, '— Unassigned —')] + [(u.id, u.display_name) for u in staff]
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
# (e.g. an admin assigned before admins were removed from the dropdown) so
# saving the form doesn't silently unassign them.
if issue.assigned_to and issue.assigned_to not in [u.id for u in staff]:
current_assignee = db.session.get(User, issue.assigned_to)
if current_assignee:
staff.append(current_assignee)
form.assigned_to.choices = [(0, '— Unassigned —')] + [
(u.id, _assignee_label(u)) for u in staff
]
form.status.data = form.status.data or issue.status
if form.validate_on_submit():
@@ -431,7 +495,7 @@ def view(issue_id):
issue.status = form.status.data
if current_user.role in ['admin', 'director']:
if current_user.role in ['admin', 'director', 'auditor']:
issue.assigned_to = form.assigned_to.data or None
if form.status.data == 'resolved' and not issue.resolved_at:
@@ -449,13 +513,24 @@ def view(issue_id):
issue.result_notes = form.result_notes.data or None
# Vendor / contractor assignment — admin, director, project_manager only
if current_user.role in ('admin', 'director', 'project_manager'):
if current_user.role in ('admin', 'director', 'project_manager', 'auditor'):
issue.vendor_name = form.vendor_name.data.strip() or None
issue.vendor_contact = form.vendor_contact.data.strip() or None
issue.vendor_notes = form.vendor_notes.data.strip() or None
# Handler type (phase39)
ht = form.handler_type.data or None
# Handler type (phase39; NOT NULL since phase44)
# 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
if ht == 'facility':
issue.facility_handler_name = form.facility_handler_name.data.strip() or None
@@ -466,6 +541,13 @@ def view(issue_id):
issue.facility_handler_contact = 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
new_photos = []
for file_obj in request.files.getlist('result_photos'):
@@ -625,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'status={issue.status}; assigned_to={issue.assigned_to}')
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)
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
.filter_by(is_customer_visible=True)
.order_by(IssueComment.created_at.asc()).all())
@@ -638,7 +726,210 @@ def view(issue_id):
issue=issue,
form=form,
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 ────────────────────────────────────────────────────────────────────
@@ -660,7 +951,7 @@ def follow(issue_id):
flash('You are now following this issue and will receive notifications for any updates.', 'success')
else:
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 ──────────────────────────────────────────────────────────────────
@@ -695,7 +986,7 @@ def unfollow(issue_id):
@login_required
@quota_soft_check('issues')
def create():
if current_user.role not in ('admin', 'director', 'customer'):
if current_user.role not in ('admin', 'director', 'customer', 'auditor'):
abort(403)
from app.models.project import Project, CustomerAssignment
@@ -717,10 +1008,12 @@ def create():
else:
facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all()
projects = Project.query.filter_by(active=True).order_by(Project.name).all()
staff = User.query.filter(User.role.in_(['admin', 'director', 'inspector'])).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.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
# belongs to so the contract selector can be restored on re-render.
@@ -744,6 +1037,25 @@ def create():
reported_at = now_eastern(),
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.commit()
current_app.logger.info(
@@ -793,17 +1105,18 @@ def create():
)
db.session.commit()
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',
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 ─────────────────────────────────────────
@bp.route('/<int:issue_id>/verify', methods=['POST'])
@login_required
@supervisor_required
@issue_manager_required
def verify(issue_id):
"""Supervisor sign-off: confirms resolution is satisfactory and closes the issue."""
issue = db.session.get(Issue, issue_id)
@@ -812,7 +1125,7 @@ def verify(issue_id):
if issue.status not in ('resolved', 'pending_verification'):
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
@@ -832,12 +1145,26 @@ 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'verified_by={current_user.username}')
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'])
@login_required
@supervisor_required
@issue_manager_required
def bulk_verify():
"""Verify multiple pending-verification issues in a single action."""
issue_ids = request.form.getlist('issue_ids', type=int)
@@ -867,7 +1194,205 @@ def bulk_verify():
f'bulk_verified_by={current_user.username}')
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'])
@@ -884,16 +1409,16 @@ def request_verification(issue_id):
# Only the assignee, director, or admin can request verification
can_act = (
current_user.role in ['admin', 'director']
current_user.role in ['admin', 'director', 'auditor']
or issue.assigned_to == current_user.id
)
if not can_act:
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',):
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'
db.session.commit()
@@ -921,13 +1446,13 @@ def request_verification(issue_id):
)
db.session.commit()
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 ────────────────────────────────────────────────────────
@bp.route('/verification-queue')
@login_required
@supervisor_required
@issue_manager_required
def verification_queue():
"""Supervisor queue of all issues awaiting verification, grouped by facility."""
from app.models.facility import Facility, Area
@@ -1000,15 +1525,10 @@ def delete(issue_id):
db.session.delete(issue)
db.session.commit()
# Remove orphaned photo files — best-effort, never block on failure
static_folder = current_app.root_path
# Remove orphaned photo files from the active storage backend — best-effort.
from app.utils import storage
for rel_path in photo_paths:
abs_path = os.path.normpath(os.path.join(static_folder, 'static', rel_path))
try:
if os.path.isfile(abs_path):
os.remove(abs_path)
except OSError:
pass
storage.delete(rel_path)
current_app.logger.info(
'ISSUE DELETED | id=%s | severity=%s | area=%s | facility=%s | deleted_by=%s',
@@ -1019,7 +1539,7 @@ def delete(issue_id):
f'facility={facility_name}; description={issue_desc}')
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) ───────────────────────────────────────────────────────
@@ -1028,7 +1548,7 @@ def delete(issue_id):
@login_required
def quick_assign(issue_id):
"""Inline assignee update from the issues list — returns JSON."""
if current_user.role not in ('admin', 'director'):
if current_user.role not in ('admin', 'director', 'auditor'):
return jsonify({'ok': False, 'error': 'Permission denied'}), 403
issue = db.session.get(Issue, issue_id)
@@ -1088,7 +1608,7 @@ def export_pdf(issue_id):
if issue is None:
abort(404)
if current_user.role == 'inspector':
if current_user.is_inspector:
fids = get_inspector_scope(current_user)
facility = issue.resolved_facility
if not fids or not facility or facility.id not in fids:
@@ -1111,6 +1631,20 @@ def export_pdf(issue_id):
# ── 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'])
@login_required
+112
View File
@@ -308,6 +308,118 @@ def check_score_trends():
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) ────────────────────────────────────
@bp.route('/trial-reminders', methods=['POST'])
+81 -34
View File
@@ -12,6 +12,7 @@ from app import db
from app.models.inspection import Inspection, InspectionTemplate
from app.models.facility import Facility, Area
from app.models.issue import Issue
from app.models.project import Project
from app.models.user import User
from app.utils.decorators import supervisor_required
from app.utils.scope import get_customer_scope
@@ -56,7 +57,8 @@ def index():
# Inspectors get a scoped view of their own inspections and related issues.
# Customers get a facility-scoped 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
flash('Access denied.', 'danger')
return redirect(url_for('dashboard.index'))
@@ -65,30 +67,36 @@ def index():
# Resolve scoping for customers (facility list) and inspectors (inspector_id)
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 = None
if current_user.role in ('admin', 'director', 'project_manager'):
inspector_filter = request.args.get('inspector_id', type=int) or None
# Pre-compute inspection ID sets used by _scope_issue to avoid join conflicts.
inspector_inspection_ids = [] # own inspections (inspector role)
filter_inspection_ids = None # filtered inspector's inspections (admin/dir/PM)
# Scope issues by the relevant inspector's inspections, as a SUBQUERY rather
# than a materialised id list. The previous form pulled every inspection id
# 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:
inspector_inspection_ids = [
row[0] for row in
inspector_insp_subq = (
db.session.query(Inspection.id)
.filter(Inspection.inspector_id == current_user.id)
.all()
]
.scalar_subquery()
)
elif inspector_filter:
filter_inspection_ids = [
row[0] for row in
inspector_insp_subq = (
db.session.query(Inspection.id)
.filter(Inspection.inspector_id == inspector_filter)
.all()
]
.scalar_subquery()
)
def _scope_insp(q):
if is_inspector:
@@ -102,14 +110,8 @@ def index():
return q
def _scope_issue(q):
if is_inspector:
if not inspector_inspection_ids:
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 inspector_insp_subq is not None:
return q.filter(Issue.inspection_id.in_(inspector_insp_subq))
if customer_facility_ids is not None:
if not customer_facility_ids:
return q.filter(False)
@@ -141,9 +143,11 @@ def index():
)
avg_score = _scope_insp(avg_score).scalar()
# Scores by facility (for bar chart)
# Scores by facility (for bar chart) — includes project_id so the report
# can group/filter facilities by Contract client-side.
fac_score_q = db.session.query(
Facility.name,
Facility.project_id,
func.avg(Inspection.overall_score).label('avg_score'),
func.count(Inspection.id).label('count'),
).join(Inspection, Facility.id == Inspection.facility_id)\
@@ -159,9 +163,16 @@ def index():
fac_score_q = fac_score_q.filter(
Facility.id.in_(customer_facility_ids) if customer_facility_ids else False
)
facility_scores = fac_score_q.group_by(Facility.id, Facility.name)\
facility_scores = fac_score_q.group_by(Facility.id, Facility.name, Facility.project_id)\
.order_by(func.avg(Inspection.overall_score).desc()).all()
# Resolve contract names for the facilities present.
_proj_ids = {r.project_id for r in facility_scores if r.project_id}
_proj_names = (
{p.id: p.name for p in Project.query.filter(Project.id.in_(_proj_ids)).all()}
if _proj_ids else {}
)
# Prior-period facility scores for period-over-period delta badges
period_len = end - start
prior_end = start
@@ -252,15 +263,28 @@ def index():
inspectors = []
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()
facility_scores_list = [{'name': r.name, 'avg_score': round(float(r.avg_score), 2), 'count': r.count} for r in facility_scores]
facility_scores_list = [{
'name': r.name,
'avg_score': round(float(r.avg_score), 2),
'count': r.count,
'project_id': r.project_id or 0,
'contract': _proj_names.get(r.project_id, 'No Contract'),
} for r in facility_scores]
# Attach prior avg and delta to each facility score dict for the template table
for row in facility_scores_list:
row['prior_avg'] = prior_scores_map.get(row['name'])
row['delta'] = facility_deltas.get(row['name'])
# Distinct contracts present, for the "Avg Score by Facility" contract filter.
score_contracts = sorted(
{(r['project_id'], r['contract']) for r in facility_scores_list},
key=lambda t: (t[1] or '').lower(),
)
return render_template('reports/index.html',
start=start, end=end,
total_inspections=total_inspections,
@@ -268,6 +292,7 @@ def index():
flagged=flagged,
avg_score=round(float(avg_score), 2) if avg_score else None,
facility_scores=facility_scores_list,
score_contracts=score_contracts,
daily_scores=[{'day': str(r.day), 'avg': round(float(r.avg), 2), 'count': r.count} for r in daily_scores],
issue_severity=[{'severity': r.severity, 'count': r.count} for r in issue_severity],
issue_status=[{'status': r.status, 'count': r.count} for r in issue_status],
@@ -284,7 +309,8 @@ def index():
@bp.route('/facility/<int:facility_id>')
@login_required
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
flash('Access denied.', 'danger')
return redirect(url_for('dashboard.index'))
@@ -297,7 +323,7 @@ def facility_report(facility_id):
from flask import flash, redirect, url_for
flash('Access denied.', 'danger')
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
# they have personally conducted at least one inspection.
has_access = Inspection.query.filter_by(
@@ -346,7 +372,8 @@ def facility_report(facility_id):
def facility_scorecard(facility_id):
"""Comprehensive per-facility scorecard: score trend, SLA compliance,
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
flash('Access denied.', 'danger')
return redirect(url_for('dashboard.index'))
@@ -362,7 +389,7 @@ def facility_scorecard(facility_id):
flash('Access denied.', 'danger')
return redirect(url_for('reports.index'))
if current_user.role == 'inspector':
if current_user.is_inspector:
has_access = Inspection.query.filter_by(
facility_id=facility_id,
inspector_id=current_user.id,
@@ -678,7 +705,8 @@ def _build_inspector_stats(start, end):
all_ids = set(total_map.keys())
active_inspectors = (
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)
.all()
) if all_ids else []
@@ -694,6 +722,10 @@ def _build_inspector_stats(start, end):
inspector_stats.append({
'id': u.id,
'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,
'completed': comp,
'completion_rate': round(comp / tot * 100) if tot else 0,
@@ -736,7 +768,7 @@ def inspector_performance():
if 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)
trend_rows = db.session.query(
@@ -834,7 +866,7 @@ def export_inspector_performance():
.filter(
Inspection.inspection_date >= start,
Inspection.inspection_date <= end,
User.role == 'inspector',
User.role.in_(User.INSPECTOR_ROLES),
)
if selected_id:
detail_q = detail_q.filter(Inspection.inspector_id == selected_id)
@@ -908,7 +940,10 @@ def export_inspector_performance():
for row_idx, s in enumerate(inspector_stats, start=3):
stripe = sub_fill if row_idx % 2 == 0 else None
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['completed'],
s['completion_rate'],
@@ -1105,8 +1140,13 @@ def issues_aging():
if customer_facility_ids is not None:
facilities = (Facility.query.filter(Facility.id.in_(customer_facility_ids), Facility.active == True)
.order_by(Facility.name).all()) if customer_facility_ids else []
projects = (Project.query
.join(Facility, Project.id == Facility.project_id)
.filter(Facility.id.in_(customer_facility_ids), Project.active == True)
.distinct().order_by(Project.name).all()) if customer_facility_ids else []
else:
facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all()
projects = Project.query.filter_by(active=True).order_by(Project.name).all()
return render_template('reports/issues_aging.html',
now=now,
@@ -1118,6 +1158,7 @@ def issues_aging():
severity_filter=severity_filter,
facility_id_filter=facility_id_filter,
facilities=facilities,
projects=projects,
)
@@ -1282,8 +1323,13 @@ def sla_compliance():
if customer_facility_ids is not None:
facilities = (Facility.query.filter(Facility.id.in_(customer_facility_ids), Facility.active == True)
.order_by(Facility.name).all()) if customer_facility_ids else []
projects = (Project.query
.join(Facility, Project.id == Facility.project_id)
.filter(Facility.id.in_(customer_facility_ids), Project.active == True)
.distinct().order_by(Project.name).all()) if customer_facility_ids else []
else:
facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all()
projects = Project.query.filter_by(active=True).order_by(Project.name).all()
return render_template('reports/sla_compliance.html',
start=start, end=end,
@@ -1292,6 +1338,7 @@ def sla_compliance():
by_facility=by_facility,
facility_id_filter=facility_id_filter,
facilities=facilities,
projects=projects,
)
@@ -1593,7 +1640,7 @@ def facility_summary_pdf(facility_id):
cids = get_customer_scope(current_user) or []
if facility_id not in cids:
abort(403)
elif current_user.role == 'inspector':
elif current_user.is_inspector:
has = Inspection.query.filter_by(facility_id=facility_id,
inspector_id=current_user.id).first()
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.facility import Facility
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.time_utils import now_eastern
from app.utils.notifications import notify
@@ -20,6 +20,38 @@ from app.utils.notifications import notify
bp = Blueprint('support', __name__, url_prefix='/support')
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 ────────────────────────────────────────────────────────
_SYSTEM_PROMPT = """\
@@ -37,6 +69,12 @@ Help customers with:
Rules:
- 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.
- 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 \
@@ -53,21 +91,161 @@ FAQS = [
{'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-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():
"""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:
entries = SupportKnowledge.query.filter_by(active=True).order_by(SupportKnowledge.id).all()
except Exception:
return _SYSTEM_PROMPT
entries = (SupportKnowledge.query.filter_by(active=True)
.order_by(SupportKnowledge.sort_order.asc(),
SupportKnowledge.id.asc()).all())
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
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 ────────────────────────────────────────────────────────
@@ -75,13 +253,10 @@ def _system_prompt_with_kb():
@bp.route('/chat')
@login_required
def chat():
if current_user.role != 'customer':
if not _is_customer_side(current_user):
return redirect(url_for('support.admin_tickets'))
cids = get_customer_scope(current_user) or []
facilities = (Facility.query
.filter(Facility.id.in_(cids), Facility.active == True)
.order_by(Facility.name).all()) if cids else []
facilities = _support_facilities(current_user)
groq_ready = bool(os.environ.get('GROQ_API_KEY'))
session_id = request.args.get('session_id', type=int)
@@ -96,8 +271,9 @@ def chat():
if chat_session:
db_history = list(chat_session.messages)
faqs = (FAQS + INSPECTOR_FAQS) if current_user.is_external_inspector else FAQS
return render_template('support/chat.html',
faqs=FAQS,
faqs=faqs,
facilities=facilities,
groq_ready=groq_ready,
chat_session=chat_session,
@@ -109,7 +285,7 @@ def chat():
@bp.route('/chat/message', methods=['POST'])
@login_required
def chat_message():
if current_user.role != 'customer':
if not _is_customer_side(current_user):
return jsonify({'error': 'Forbidden'}), 403
api_key = os.environ.get('GROQ_API_KEY')
@@ -158,12 +334,14 @@ def chat_message():
from groq import Groq
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:]:
messages.append({'role': m.role, 'content': m.content})
messages.append({'role': 'user', 'content': user_message})
messages.append({'role': m.role, 'content': _redact_pii(m.content)})
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(
model=model,
messages=messages,
@@ -184,7 +362,18 @@ def chat_message():
return jsonify({'reply': reply, 'session_id': chat_session.id})
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()
return jsonify({'reply': (
"I ran into a problem reaching the AI assistant. "
@@ -197,7 +386,7 @@ def chat_message():
@bp.route('/my-conversations')
@login_required
def my_conversations():
if current_user.role != 'customer':
if not _is_customer_side(current_user):
abort(403)
sessions = (SupportChatSession.query
.filter_by(customer_id=current_user.id)
@@ -209,7 +398,7 @@ def my_conversations():
@bp.route('/my-conversations/<int:session_id>')
@login_required
def my_conversation_detail(session_id):
if current_user.role != 'customer':
if not _is_customer_side(current_user):
abort(403)
chat_session = db.session.get(SupportChatSession, session_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'])
@login_required
def submit_ticket():
if current_user.role != 'customer':
if not _is_customer_side(current_user):
abort(403)
subject = request.form.get('subject', '').strip()
@@ -236,8 +425,9 @@ def submit_ticket():
flash('Please fill in both subject and description.', 'warning')
return redirect(url_for('support.chat'))
# Validate facility belongs to this customer
cids = get_customer_scope(current_user) or []
# Validate the facility belongs to this user — by whichever assignment
# 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:
facility_id = None
@@ -267,7 +457,7 @@ def submit_ticket():
@bp.route('/my-tickets')
@login_required
def my_tickets():
if current_user.role != 'customer':
if not _is_customer_side(current_user):
abort(403)
tickets = (SupportTicket.query
@@ -282,7 +472,7 @@ def my_tickets():
@bp.route('/my-tickets/<int:ticket_id>', methods=['GET', 'POST'])
@login_required
def my_ticket_detail(ticket_id):
if current_user.role != 'customer':
if not _is_customer_side(current_user):
abort(403)
ticket = db.session.get(SupportTicket, ticket_id)
@@ -482,10 +672,53 @@ def admin_conversation_detail(session_id):
@login_required
@supervisor_required
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)
@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'])
@login_required
@supervisor_required
@@ -500,6 +733,7 @@ def admin_knowledge_add():
title = title,
body = body,
active = True,
sort_order = _parse_sort_order(request.form.get('sort_order')),
created_by = current_user.id,
created_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))
entry.title = title
entry.body = body
entry.sort_order = _parse_sort_order(request.form.get('sort_order'),
entry.sort_order)
entry.updated_at = now_eastern()
db.session.commit()
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_login import login_required, current_user
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.decorators import supervisor_required
import json
@@ -13,6 +14,17 @@ bp = Blueprint('templates', __name__, url_prefix='/templates')
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
# ---------------------------------------------------------------------------
@@ -21,7 +33,12 @@ logger = logging.getLogger(__name__)
@login_required
def index():
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'])
@@ -29,6 +46,7 @@ def index():
@supervisor_required
def create_template():
form = InspectionTemplateForm()
_populate_contract_choices(form)
if form.validate_on_submit():
template = InspectionTemplate(
@@ -38,11 +56,15 @@ def create_template():
created_by=current_user.id
)
db.session.add(template)
db.session.flush() # need template.id before linking contracts
template.set_contracts(form.contract_ids.data)
db.session.commit()
logger.info('TEMPLATES | create | user=%s | template_id=%s name=%r',
current_user.username, template.id, template.name)
logger.info('TEMPLATES | create | user=%s | template_id=%s name=%r contracts=%s',
current_user.username, template.id, template.name,
template.contract_ids or 'shared')
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')
return redirect(url_for('templates.form_editor', template_id=template.id))
@@ -72,16 +94,23 @@ def edit_template(template_id):
if template is None:
abort(404)
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():
template.name = form.name.data
template.description = form.description.data
template.frequency = form.frequency.data
template.set_contracts(form.contract_ids.data)
db.session.commit()
logger.info('TEMPLATES | edit | user=%s | template_id=%s name=%r',
current_user.username, template.id, template.name)
logger.info('TEMPLATES | edit | user=%s | template_id=%s name=%r contracts=%s',
current_user.username, template.id, template.name,
template.contract_ids or 'shared')
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')
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.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()
logger.info('TEMPLATES | rename | user=%s | template_id=%s name=%r',
current_user.username, template.id, template.name)
logger.info('TEMPLATES | rename | user=%s | template_id=%s name=%r contracts=%s',
current_user.username, template.id, template.name,
template.contract_ids or 'shared')
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')
return redirect(url_for('templates.index'))
@@ -188,6 +235,12 @@ def duplicate_template(template_id):
db.session.add(new_tpl)
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
for item in src.checklist_items.order_by(ChecklistItem.display_order).all():
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.audit import log_action, ACTION_UPDATE, ACTION_CREATE, ACTION_DELETE
from app.utils.time_utils import now_eastern
from app.utils import storage
logger = logging.getLogger(__name__)
@@ -43,7 +44,19 @@ _DOMAIN_RE = re.compile(
# ── helpers ───────────────────────────────────────────────────────────────────
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:
return None
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):
return None
import secrets
logos_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], '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}'
file_obj.seek(0)
return storage.save(file_obj, 'logos')
def _delete_logo(logo_url):
"""Remove a logo file from disk. Silently ignores missing files.
Safety guard: only deletes files inside the uploads/logos/ subfolder."""
"""Remove a stored logo. Silently ignores missing objects.
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:
# 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
# basename back to that directory is always the correct path.
logos_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], 'logos')
abs_logos = os.path.abspath(logos_dir)
abs_path = os.path.join(abs_logos, os.path.basename(logo_url))
if os.path.isfile(abs_path):
os.remove(abs_path)
logger.info('SETTINGS | logo_deleted | path=%s', abs_path)
# All logos are written to 'uploads/logos/' by _save_logo(), so
# rebuilding the key from the basename is always the correct target.
key = f'uploads/logos/{os.path.basename(str(logo_url or ""))}'
storage.delete(key)
logger.info('SETTINGS | logo_deleted | key=%s', key)
except Exception as 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,
}
# 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 (
count_active_users, count_active_facilities,
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:
quota_usage = {
'users': count_active_users(),
'facilities': count_active_facilities(),
'inspections': count_inspections_this_month(),
'issues': count_issues_this_month(),
}
quota_usage[axis] = counter()
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.
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 */
.btn,
.nav-link,
.dropdown-item,
input[type="checkbox"],
input[type="radio"],
.form-check-input {
.dropdown-item {
min-height: 44px;
display: inline-flex;
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 */
.form-control,
.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>
<!-- ── 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>
<!-- ── Right column: edit form + recent inspections ──────────────── -->
@@ -263,4 +284,38 @@
</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 %}
+6 -1
View File
@@ -80,7 +80,11 @@
</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">
{{ form.password.label(class="form-label") }}
{{ form.password(class="form-control", placeholder="Leave blank to keep current" if user else "") }}
@@ -128,4 +132,5 @@
</div>
</div>
</div>
{% endblock %}
+15 -4
View File
@@ -37,12 +37,12 @@
<td>{{ user.full_name or '—' }}</td>
<td>{{ user.email }}</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 == 'customer' %}success{% else %}info{% endif %}">
{{ user.role.replace('_',' ')|title }}
<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_label }}
</span>
</td>
<td>
{% if user.role == 'inspector' %}
{% if user.is_inspector %}
{% set cnt = inspector_contract_counts.get(user.id, 0) %}
{% if cnt > 0 %}
<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">
<i class="bi bi-pencil"></i>
</a>
{% if user.role == 'inspector' %}
{% if user.is_inspector %}
<a href="{{ url_for('auth.assign_inspector_contracts', user_id=user.id) }}"
class="btn btn-sm btn-outline-secondary" title="Assign contracts">
<i class="bi bi-briefcase"></i>
</a>
{% 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 %}
<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() }}">
+12 -522
View File
@@ -1,525 +1,15 @@
<!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; }
{# ────────────────────────────────────────────────────────────────────────────
base.html — layout dispatcher (MT-16)
/* ── 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="{{ 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'] %}
<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'] %}
<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'] %}
<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_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">
This file used to hold the entire page chrome. That markup now lives in
layouts/classic.html, unchanged.
<!-- ── 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 ── -->
Every page template still says {% extends "base.html" %} and needed ZERO
edits: Jinja resolves {% block %} overrides through the whole inheritance
chain, so one extra link in that chain is invisible to them.
<!-- 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>
<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>
`jqc_layout` is supplied by the inject_ui_theme() context processor in
app/__init__.py, driven by users.ui_theme ('classic' | 'modern') with
config DEFAULT_UI_THEME as the fallback for accounts that never chose.
──────────────────────────────────────────────────────────────────────────── #}
{% extends jqc_layout %}
+18 -2
View File
@@ -5,7 +5,11 @@
<div class="row mb-4 align-items-center">
<div class="col">
<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 class="col-auto d-flex gap-2">
<a href="{{ url_for('customers.bulk_import') }}" class="btn btn-outline-success">
@@ -55,6 +59,7 @@
<tr>
<th>Username</th>
<th>Full Name</th>
<th>Role</th>
<th>Email</th>
<th>Status</th>
<th>Assigned Contracts</th>
@@ -65,7 +70,11 @@
</thead>
<tbody>
{% 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] %}
<tr class="{{ 'table-secondary text-muted' if not customer.active else '' }}">
<td>
@@ -77,6 +86,11 @@
</strong>
</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>
{% if customer.active %}
@@ -136,6 +150,8 @@
<div class="mt-3 text-muted small">
{{ customers|length }} customer account{{ 's' if customers|length != 1 else '' }} total
· {{ 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>
{% else %}
+16 -1
View File
@@ -31,7 +31,7 @@
{% endfor %}
</div>
<div class="mb-4">
<div class="mb-3">
{{ form.email.label(class="form-label fw-semibold") }}
{{ form.email(class="form-control" + (" is-invalid" if form.email.errors else ""),
placeholder="jane@example.com") }}
@@ -44,6 +44,21 @@
</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">
<button type="submit" class="btn btn-primary">
<i class="bi bi-send me-1"></i>Create &amp; Send Invitation
+226 -1
View File
@@ -6,6 +6,9 @@
<div class="col">
<h2>
<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 %}
<span class="badge bg-secondary ms-2 fs-6">Disabled</span>
{% else %}
@@ -19,6 +22,20 @@
class="btn btn-outline-secondary btn-sm">
<i class="bi bi-pencil"></i> Edit Account
</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"
action="{{ url_for('customers.toggle_active', customer_id=customer.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
@@ -48,6 +65,19 @@
<dl class="row mb-0 small">
<dt class="col-5 text-muted">Full Name</dt>
<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>
<dd class="col-7">{{ customer.username }}</dd>
<dt class="col-5 text-muted">Email</dt>
@@ -69,7 +99,7 @@
<dt class="col-5 text-muted">Created</dt>
<dd class="col-7">{{ customer.created_at.strftime('%Y-%m-%d') }}</dd>
<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>
<dd class="col-7">{{ facilities|length }}</dd>
</dl>
@@ -120,6 +150,58 @@
{# ── Right column: assignments ── #}
<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 ── #}
<div class="card shadow-sm mb-4">
<div class="card-header bg-light fw-semibold d-flex justify-content-between align-items-center">
@@ -213,6 +295,116 @@
</form>
</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>
@@ -223,8 +415,41 @@
(function () {
'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 facSelect = document.getElementById('fac-select');
if (!projSelect || !facSelect) return;
projSelect.addEventListener('change', function () {
const projectId = this.value;
+49 -1
View File
@@ -5,12 +5,60 @@
<div class="row mb-3 align-items-center">
<div class="col">
<h2 class="mb-0">Welcome, {{ current_user.display_name }}!</h2>
<span class="badge bg-{% if current_user.role == 'admin' %}danger{% elif current_user.role == 'director' %}warning{% elif current_user.role == 'project_manager' %}primary{% elif current_user.role == 'customer' %}success{% else %}info{% endif %} mt-1">
<span class="badge bg-{% if current_user.role == 'admin' %}danger{% elif current_user.role == 'director' %}warning{% elif current_user.role == 'project_manager' %}primary{% elif current_user.role == 'auditor' %}secondary{% elif current_user.role == 'customer' %}success{% else %}info{% endif %} mt-1">
{{ current_user.role.replace('_',' ')|title }}
</span>
</div>
</div>
{# ── Scheduled Inspections (phase43) — plan-mode, staff only ─────────────── #}
{% if current_user.role != 'customer' and (sched_upcoming or sched_overdue_count) %}
<div class="card shadow-sm mb-4 border-0" style="border-left:4px solid #6366f1 !important;">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-2">
<span class="fw-bold"><i class="bi bi-calendar-check text-primary"></i> Scheduled Inspections</span>
<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 mb-2">
<i class="bi bi-alarm-fill"></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="table-responsive">
<table class="table table-sm table-hover mb-0 align-middle">
<thead class="table-light">
<tr><th>Schedule</th><th>Facility</th><th>Template</th><th>Inspector</th><th>Due</th><th></th></tr>
</thead>
<tbody>
{% for s in sched_upcoming %}
<tr>
<td class="fw-semibold">{{ s.name }}</td>
<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">{{ s.next_run_at.strftime('%b %d') if s.next_run_at else '—' }}</td>
<td class="text-end">
{% if current_user.role in ['admin','director','project_manager','auditor']
or (current_user.role == 'inspector' and s.inspector_id == current_user.id) %}
<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 %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted small mb-0">No inspections due in the next 7 days.</p>
{% endif %}
</div>
</div>
{% endif %}
{# ── Inspections section ─────────────────────────────────────────────────── #}
<div class="d-flex align-items-center gap-2 mb-3">
<i class="bi bi-clipboard-data-fill text-primary"></i>
+81
View File
@@ -0,0 +1,81 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>QR Code — {{ area.name }}</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; }
.qr-card { max-width:420px; margin:2rem auto; background:#fff; border-radius:.75rem;
box-shadow:0 1px 3px rgba(0,0,0,.12); padding:2rem; text-align:center; }
.qr-box svg { width:260px; height:260px; }
.scan-url { word-break:break-all; font-size:.72rem; color:#94a3b8; }
@media print {
body { background:#fff; }
.no-print { display:none !important; }
.qr-card { box-shadow:none; margin:0 auto; }
}
</style>
</head>
<body>
<div class="text-center mt-3 no-print">
<a href="{{ url_for('facilities.view_facility', facility_id=facility.id) }}"
class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left"></i> Back to Facility
</a>
<button onclick="window.print()" class="btn btn-primary btn-sm">
<i class="bi bi-printer"></i> Print
</button>
<a href="{{ url_for('facilities.qr_print_all', facility_id=facility.id, include_areas=1) }}"
class="btn btn-outline-primary btn-sm">
<i class="bi bi-grid-3x3-gap"></i> Print All Codes
</a>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %}
<div class="alert alert-{{ 'success' if category == 'success' else 'warning' }} mx-auto mt-3 no-print" style="max-width:420px;">
{{ message }}
</div>
{% endfor %}
{% endwith %}
<div class="qr-card">
<div class="text-muted text-uppercase small" style="letter-spacing:.08em;">Area</div>
<h4 class="mb-0">{{ area.name }}</h4>
<div class="text-muted small">{{ facility.name }}</div>
{% if area.area_type %}<div class="text-muted small mb-2">{{ area.area_type }}</div>{% endif %}
<div class="qr-box my-3">{{ svg | safe }}</div>
<div class="fw-semibold mb-1">
<i class="bi bi-phone"></i> Scan to report a problem in this area
</div>
<div class="text-muted small mb-2">
Recent scores, open issues, and quality trend for {{ area.name }}.
</div>
<div class="scan-url">{{ scan_url }}</div>
</div>
<p class="text-center text-muted small no-print">
Tip: post this inside the area itself (e.g. on the restroom door), not at the building entrance.
</p>
{% if current_user.role in ['admin', 'director', 'customer'] %}
<div class="text-center mb-4 no-print">
<form method="POST" action="{{ url_for('facilities.regenerate_area_qr', area_id=area.id) }}"
onsubmit="return confirm('Regenerate this QR code? Every previously printed poster for {{ area.name }} will stop working.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-outline-danger btn-sm">
<i class="bi bi-arrow-repeat"></i> Regenerate QR Code
</button>
<div class="form-text">Use this if a printed poster leaked or was posted somewhere it shouldn't be.</div>
</form>
</div>
{% endif %}
</body>
</html>
+5 -4
View File
@@ -8,9 +8,10 @@
<h2><i class="bi bi-building"></i> Facilities</h2>
</div>
<div class="col-md-6 text-end">
{% if current_user.role in ['admin', 'director', 'project_manager'] %}
<a href="{{ url_for('facilities.qr_sheet') }}" class="btn btn-outline-secondary">
<i class="bi bi-qr-code"></i> Print QR Codes
{% if current_user.role != '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'] %}
@@ -43,7 +44,7 @@
{% 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'] %}
{% 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">
+182
View File
@@ -0,0 +1,182 @@
{% extends "base.html" %}
{% block title %}Print QR Codes{% endblock %}
{% block content %}
<style>
.qr-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; }
.qr-item { position: relative; break-inside: avoid; page-break-inside: avoid;
text-align: center; cursor: pointer; }
.qr-item img { width: 220px; height: 220px; max-width: 100%; }
.qr-item.qr-selected { outline: 3px solid #2563eb; outline-offset: -1px; }
.qr-check { position: absolute; top: 10px; left: 10px; }
.qr-check .form-check-input { width: 1.25rem; height: 1.25rem; }
.qr-kind { font-size: .68rem; letter-spacing: .08em; }
@media print {
.no-print { display: none !important; }
.navbar, nav, footer { display: none !important; }
.qr-item { border: 1px dashed #bbb !important; cursor: default; }
.qr-item.qr-selected { outline: none !important; }
.qr-grid { gap: 8px; }
/* When printing a selection, hide the unselected cards. */
body.print-selected-only .qr-item:not(.qr-selected) { display: none !important; }
}
</style>
<div class="d-flex justify-content-between align-items-center mb-3 no-print">
<div>
<h2 class="h4 mb-0"><i class="bi bi-qr-code"></i> QR Codes</h2>
<div class="text-muted small">
{% if selected_contract %}Contract: {{ selected_contract.name }} — {% endif %}
{{ facilities|length }} facilit{{ 'y' if facilities|length == 1 else 'ies' }}
{% if include_areas %}(with areas){% endif %}
</div>
</div>
<a href="{{ url_for('facilities.list_facilities') }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left"></i> Back
</a>
</div>
{# ── Filter bar (GET reload) ─────────────────────────────────────────────── #}
<form method="get" id="filterForm" class="card card-body mb-3 no-print">
<div class="row g-2 align-items-end">
<div class="col-md-4">
<label class="form-label small fw-semibold mb-1">Contract</label>
<select name="contract_id" class="form-select form-select-sm"
onchange="document.getElementById('facilitySelect').value=''; this.form.submit();">
<option value="">All Contracts</option>
{% for c in contracts %}
<option value="{{ c.id }}" {{ 'selected' if selected_contract_id == c.id }}>{{ c.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-4">
<label class="form-label small fw-semibold mb-1">Facility</label>
<select name="facility_id" id="facilitySelect" class="form-select form-select-sm">
<option value="">All Facilities</option>
{% for f in facility_options %}
<option value="{{ f.id }}" {{ 'selected' if selected_facility_id == f.id }}>{{ f.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-2">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="include_areas" value="1"
id="includeAreas" {{ 'checked' if include_areas }}>
<label class="form-check-label small" for="includeAreas">Include area QR codes</label>
</div>
</div>
<div class="col-md-2">
<button type="submit" class="btn btn-primary btn-sm w-100">
<i class="bi bi-funnel"></i> Apply
</button>
</div>
</div>
</form>
{# ── Selection toolbar + export form ─────────────────────────────────────── #}
<form method="post" action="{{ url_for('facilities.qr_export_pdf') }}" id="qrForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="d-flex align-items-center gap-2 mb-3 no-print flex-wrap">
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="selectAllQr(true)">Select All</button>
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="selectAllQr(false)">Clear</button>
<span class="text-muted small" id="selCount">0 selected</span>
<div class="ms-auto d-flex gap-2">
<button type="button" class="btn btn-primary btn-sm" onclick="printSelected()">
<i class="bi bi-printer"></i> Print Selected
</button>
<button type="submit" class="btn btn-danger btn-sm">
<i class="bi bi-file-earmark-pdf"></i> Export Selected to PDF
</button>
</div>
</div>
{% if facilities %}
<div class="qr-grid">
{% for f in facilities %}
{# Facility QR card #}
<label class="qr-item card shadow-sm p-3 mb-0">
<div class="qr-check no-print">
<input type="checkbox" class="form-check-input qr-cb" name="facility_ids" value="{{ f.id }}">
</div>
<div class="text-muted text-uppercase qr-kind">Facility</div>
<div class="fw-bold">{{ f.name }}</div>
{% if f.project %}
<div class="text-muted small mb-1">{{ f.project.name }}</div>
{% endif %}
<div>
<img src="{{ url_for('facilities.facility_qr_png', facility_id=f.id) }}"
alt="QR code for {{ f.name }}" loading="lazy">
</div>
<div class="small">Report a problem &amp; view recent quality</div>
</label>
{% if include_areas %}
{% for a in areas_by_facility.get(f.id, []) %}
{# Area QR card #}
<label class="qr-item card shadow-sm p-3 mb-0">
<div class="qr-check no-print">
<input type="checkbox" class="form-check-input qr-cb" name="area_ids" value="{{ a.id }}">
</div>
<div class="text-muted text-uppercase qr-kind">Area</div>
<div class="fw-bold">{{ a.name }}</div>
<div class="text-muted small mb-1">{{ f.name }}</div>
<div>
<img src="{{ url_for('facilities.area_qr_png', area_id=a.id) }}"
alt="QR code for {{ a.name }}" loading="lazy">
</div>
<div class="small">Report a problem &amp; view recent quality</div>
</label>
{% endfor %}
{% endif %}
{% endfor %}
</div>
{% else %}
<div class="alert alert-info">No facilities match the selected filters.</div>
{% endif %}
</form>
<p class="text-muted small mt-3 no-print">
Tip: tick the codes you want, then <strong>Print Selected</strong> or
<strong>Export Selected to PDF</strong>. With nothing ticked, Print Selected prints them all.
</p>
<script>
(function () {
'use strict';
function checkboxes() { return document.querySelectorAll('.qr-cb'); }
function updateCount() {
var n = document.querySelectorAll('.qr-cb:checked').length;
document.getElementById('selCount').textContent = n + ' selected';
}
function syncCard(cb) {
var card = cb.closest('.qr-item');
if (card) { card.classList.toggle('qr-selected', cb.checked); }
}
window.selectAllQr = function (state) {
checkboxes().forEach(function (cb) { cb.checked = state; syncCard(cb); });
updateCount();
};
window.printSelected = function () {
var anySelected = document.querySelectorAll('.qr-cb:checked').length > 0;
if (anySelected) { document.body.classList.add('print-selected-only'); }
window.print();
setTimeout(function () {
document.body.classList.remove('print-selected-only');
}, 500);
};
// Toggling a checkbox inside its <label> card also fires on the label click.
checkboxes().forEach(function (cb) {
cb.addEventListener('change', function () { syncCard(cb); updateCount(); });
});
updateCount();
}());
</script>
{% endblock %}
+12 -2
View File
@@ -11,17 +11,21 @@
<a href="{{ url_for('facilities.list_facilities') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Back to Facilities
</a>
{% if current_user.role in ['admin', 'director', 'project_manager', 'customer'] %}
{% if current_user.role in ['admin', 'director', 'project_manager', 'customer', 'auditor'] %}
<a href="{{ url_for('reports.facility_report', facility_id=facility.id) }}"
class="btn btn-outline-info">
<i class="bi bi-graph-up-arrow"></i> Scorecard
</a>
{% endif %}
{% if current_user.role in ['admin', 'director', 'project_manager'] %}
{% if current_user.role != 'inspector' %}
<a href="{{ url_for('facilities.qr_card', facility_id=facility.id) }}"
class="btn btn-outline-secondary">
<i class="bi bi-qr-code"></i> QR Code
</a>
<a href="{{ url_for('facilities.qr_print_all', facility_id=facility.id, include_areas=1) }}"
class="btn btn-outline-secondary" title="Print or export this facility's codes, including every area">
<i class="bi bi-grid-3x3-gap"></i> All Codes
</a>
{% endif %}
{% if current_user.role in ['admin', 'director'] %}
<a href="{{ url_for('facilities.edit_facility', facility_id=facility.id) }}" class="btn btn-outline-primary">
@@ -135,6 +139,12 @@
</td>
<td>{{ area.inspections.count() }}</td>
<td>
{% if current_user.role != 'inspector' %}
<a href="{{ url_for('facilities.area_qr_card', area_id=area.id) }}"
class="btn btn-sm btn-outline-secondary" title="Printable QR code for this area">
<i class="bi bi-qr-code"></i>
</a>
{% endif %}
{% if current_user.role in ['admin', 'director'] %}
<a href="{{ url_for('facilities.edit_area', area_id=area.id) }}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-pencil"></i>
@@ -0,0 +1,77 @@
{# ── Public "report a problem" form (phase42) ───────────────────────────────
Shared by the facility scan page and the area scan page. Caller passes:
action — the POST endpoint URL
form — a PublicIssueReportForm instance
Photos: up to 5, first becomes the issue's primary photo. Honeypot field
`website` is off-screen: humans never see it, bots fill it.
#}
<div class="card shadow-sm mb-3">
<div class="card-header bg-white fw-semibold py-2">
<i class="bi bi-megaphone text-danger"></i> Report a Problem
</div>
<div class="card-body">
<p class="text-muted small mb-3">
See something that needs attention? Let our team know and we'll take care of it.
</p>
{% if form.errors %}
<div class="alert alert-danger py-2 small">
<strong>Please check the form:</strong>
<ul class="mb-0 ps-3">
{% for field, errs in form.errors.items() %}
{% for e in errs %}<li>{{ e }}</li>{% endfor %}
{% endfor %}
</ul>
</div>
{% endif %}
<form method="POST" action="{{ action }}" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{# Honeypot — invisible to humans, filled by bots #}
<div style="position:absolute;left:-9999px;top:-9999px;" aria-hidden="true">
{{ form.website(tabindex="-1", autocomplete="off") }}
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">
What did you observe? <span class="text-danger">*</span>
</label>
{{ form.description(class="form-control form-control-sm", rows=3,
maxlength=2000,
placeholder="Describe the issue (e.g. restroom out of paper towels, spill in lobby…)") }}
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">Urgency</label>
{{ form.severity(class="form-select form-select-sm") }}
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">{{ area_label_prompt or 'Where in the building?' }}</label>
{{ form.area_label(class="form-control form-control-sm",
placeholder="e.g. 2nd floor men's restroom") }}
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">Add photos (optional, up to 5)</label>
{{ form.photos(class="form-control form-control-sm", accept="image/*") }}
<div class="form-text small">A photo helps our team find and fix it faster.</div>
</div>
<div class="row g-2 mb-3">
<div class="col-6">
<label class="form-label small fw-semibold">Your name (optional)</label>
{{ form.reporter_name(class="form-control form-control-sm") }}
</div>
<div class="col-6">
<label class="form-label small fw-semibold">Email or phone (optional)</label>
{{ form.reporter_contact(class="form-control form-control-sm") }}
</div>
</div>
<button type="submit" class="btn btn-danger btn-sm w-100">
<i class="bi bi-send me-1"></i> Submit Report
</button>
</form>
</div>
</div>
+196
View File
@@ -0,0 +1,196 @@
<!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>{{ area.name }} — {{ facility.name }}</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; }
.fq-wrap { max-width:640px; margin:2rem auto; padding:0 1rem; }
.stat-tile { background:#fff; border-radius:.5rem; padding:.9rem .5rem; text-align:center;
box-shadow:0 1px 2px rgba(0,0,0,.06); height:100%; }
.stat-tile .val { font-size:1.6rem; font-weight:700; line-height:1.2; }
.stat-tile .lbl { font-size:.72rem; color:#64748b; text-transform:uppercase;
letter-spacing:.03em; margin-top:.15rem; }
.sev-critical{background:#dc2626}.sev-high{background:#ea580c}
.sev-medium{background:#d97706}.sev-low{background:#64748b}
.trend-up { color:#15803d; }
.trend-down { color:#dc2626; }
.trend-flat { color:#64748b; }
@media (max-width:576px){ .fq-wrap{ margin:1rem auto; } }
</style>
</head>
<body>
<div class="fq-wrap">
{% if request.args.get('reported') == '1' %}
<div class="alert alert-success d-flex align-items-center gap-2 mb-3" role="alert">
<i class="bi bi-check-circle-fill fs-5"></i>
<div><strong>Report submitted.</strong> Our team has been notified and will follow up.</div>
</div>
{% endif %}
<div class="d-flex align-items-center gap-2 mb-3">
<i class="bi bi-door-open fs-3 text-primary"></i>
<div>
<div class="fw-bold">{{ area.name }}</div>
<div class="text-muted small">
{{ facility.name }}
{% if area.area_type %} · {{ area.area_type }}{% endif %}
</div>
</div>
</div>
<div class="alert alert-light border py-2 small mb-3">
<i class="bi bi-info-circle text-primary"></i>
Everything below is for <strong>{{ area.name }}</strong> only — not the whole building.
</div>
{# ── Summary stat tiles (90 days) ── #}
<div class="row g-2 mb-3">
<div class="col-3">
<div class="stat-tile">
<div class="val">{% if avg_90 is not none %}{{ '%.1f'|format(avg_90) }}%{% else %}—{% endif %}</div>
<div class="lbl">Avg Score<br>90 days</div>
</div>
</div>
<div class="col-3">
<div class="stat-tile">
<div class="val">{{ total_90 }}</div>
<div class="lbl">Inspections<br>90 days</div>
</div>
</div>
<div class="col-3">
<div class="stat-tile">
<div class="val">{{ open_total }}</div>
<div class="lbl">Open<br>Issues</div>
</div>
</div>
<div class="col-3">
<div class="stat-tile">
<div class="val">{{ resolved_90 }}</div>
<div class="lbl">Resolved<br>90 days</div>
</div>
</div>
</div>
{# ── Score trend ── #}
<div class="card shadow-sm mb-3">
<div class="card-body py-2 d-flex align-items-center justify-content-between">
<div class="text-muted small text-uppercase">Score trend — 30 days vs prior 30</div>
{% if trend_delta is not none %}
{% if trend_delta > 0.5 %}
<div class="trend-up fw-semibold">
<i class="bi bi-arrow-up-right"></i> Improving
(+{{ '%.1f'|format(trend_delta) }} pts, {{ '%.1f'|format(avg_prior) }}% → {{ '%.1f'|format(avg_cur) }}%)
</div>
{% elif trend_delta < -0.5 %}
<div class="trend-down fw-semibold">
<i class="bi bi-arrow-down-right"></i> Declining
({{ '%.1f'|format(trend_delta) }} pts, {{ '%.1f'|format(avg_prior) }}% → {{ '%.1f'|format(avg_cur) }}%)
</div>
{% else %}
<div class="trend-flat fw-semibold">
<i class="bi bi-arrow-right"></i> Steady ({{ '%.1f'|format(avg_cur) }}%)
</div>
{% endif %}
{% else %}
<div class="text-muted small">Not enough data yet</div>
{% endif %}
</div>
</div>
{# ── Open issues by severity + SLA state ── #}
<div class="card shadow-sm mb-3">
<div class="card-header bg-white fw-semibold py-2">
<i class="bi bi-exclamation-triangle"></i> Open Issues
</div>
<div class="card-body py-3">
{% if open_total or pending_verification %}
<div class="d-flex flex-wrap gap-2 mb-2">
{% for sev in severity_order %}
{% if severity_counts[sev] %}
<span class="badge sev-{{ sev }} text-white">
{{ severity_counts[sev] }} {{ sev }}
</span>
{% endif %}
{% endfor %}
{% if pending_verification %}
<span class="badge bg-info text-dark">{{ pending_verification }} pending verification</span>
{% endif %}
</div>
{% if sla_breached %}
<div class="small text-danger fw-semibold">
<i class="bi bi-alarm"></i> {{ sla_breached }} issue{{ 's' if sla_breached != 1 }} past the response-time target
</div>
{% endif %}
{% if sla_at_risk %}
<div class="small text-warning-emphasis fw-semibold">
<i class="bi bi-hourglass-split"></i> {{ sla_at_risk }} issue{{ 's' if sla_at_risk != 1 }} approaching the response-time target
</div>
{% endif %}
{% if not sla_breached and not sla_at_risk and open_total %}
<div class="small text-muted">All open issues are within response-time targets.</div>
{% endif %}
{% else %}
<div class="text-muted small"><i class="bi bi-check-circle text-success"></i> No open issues in this area right now.</div>
{% endif %}
</div>
</div>
{# ── Recent inspections ── #}
<div class="card shadow-sm mb-3">
<div class="card-header bg-white fw-semibold py-2">
<i class="bi bi-clipboard-check"></i> Recent Inspections
</div>
{% if recent %}
<div class="table-responsive">
<table class="table table-sm mb-0 align-middle">
<thead class="table-light">
<tr>
<th class="ps-3">Date</th>
<th>Checklist</th>
<th class="text-end pe-3">Score</th>
</tr>
</thead>
<tbody>
{% for ins in recent %}
<tr>
<td class="ps-3 text-nowrap">{{ ins.inspection_date.strftime('%b %d, %Y') }}</td>
<td class="text-muted small">{{ ins.template.name if ins.template else '—' }}</td>
<td class="text-end pe-3 fw-semibold">
{% if ins.overall_score is not none %}{{ '%.1f'|format(ins.overall_score) }}%{% else %}—{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="card-body py-3 text-muted small">No completed inspections for this area yet.</div>
{% endif %}
</div>
{% if can_view_full %}
<a href="{{ url_for('facilities.view_facility', facility_id=facility.id) }}"
class="btn btn-primary w-100 mb-3">
<i class="bi bi-box-arrow-in-right me-1"></i> Open Full Facility View
</a>
{% endif %}
{% with action = url_for('facility_qr.area_report', token=token),
area_label_prompt = 'Whereabouts in ' ~ area.name ~ '? (optional)' %}
{% include "facility_qr/_report_form.html" %}
{% endwith %}
<p class="text-center text-muted small mt-2 mb-1">
{% if last_date %}Last inspected {{ last_date.strftime('%b %d, %Y') }} · {% endif %}
Snapshot generated {{ generated_at.strftime('%b %d, %Y %I:%M %p') }} ET
</p>
<p class="text-center text-muted small">Janitorial QC — area quality snapshot</p>
</div>
</body>
</html>
+3 -35
View File
@@ -177,41 +177,9 @@
</a>
{% endif %}
{# ── Report a problem ── #}
<div class="card shadow-sm mb-3">
<div class="card-header bg-white fw-semibold py-2">
<i class="bi bi-megaphone text-danger"></i> Report a Problem
</div>
<div class="card-body">
<p class="text-muted small mb-3">
See something that needs attention? Let our team know and we'll take care of it.
</p>
<form method="POST" action="{{ url_for('facility_qr.report', token=token) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{# Honeypot — invisible to humans, filled by bots #}
<div style="position:absolute;left:-9999px;top:-9999px;" aria-hidden="true">
<input type="text" name="website" tabindex="-1" autocomplete="off">
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">What did you observe? <span class="text-danger">*</span></label>
<textarea name="description" class="form-control form-control-sm" rows="3"
placeholder="Describe the issue (e.g. restroom out of paper towels, spill in lobby…)"
required maxlength="2000"></textarea>
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">Urgency</label>
<select name="severity" class="form-select form-select-sm">
<option value="low">Low — not urgent</option>
<option value="medium" selected>Medium — needs attention soon</option>
<option value="high">High — urgent</option>
</select>
</div>
<button type="submit" class="btn btn-danger btn-sm w-100">
<i class="bi bi-send me-1"></i> Submit Report
</button>
</form>
</div>
</div>
{% with action = url_for('facility_qr.report', token=token) %}
{% include "facility_qr/_report_form.html" %}
{% endwith %}
<p class="text-center text-muted small mt-2 mb-1">
{% if last_date %}Last inspected {{ last_date.strftime('%b %d, %Y') }} · {% endif %}
@@ -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>
+417 -70
View File
@@ -1,134 +1,481 @@
{% extends "base.html" %}
{% 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 %}
<div class="row justify-content-center">
<div class="col-lg-8">
<h2 class="mb-4"><i class="bi bi-calendar2-week"></i> {{ title }}</h2>
<form method="POST" class="card shadow-sm">
<div class="col-lg-7">
<div class="card shadow-sm">
<div class="card-header bg-light"><h5 class="mb-0">{{ title }}</h5></div>
<div class="card-body">
<form method="POST" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label class="form-label">Schedule name</label>
<input type="text" name="name" class="form-control" required
value="{{ schedule.name if schedule else '' }}"
<label class="form-label fw-semibold" for="name">Schedule name</label>
<input type="text" name="name" id="name" class="form-control" required
value="{{ v_name }}"
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 class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Template</label>
<select name="template_id" class="form-select" required>
<option value="">Choose a template</option>
{% for t in templates %}
<option value="{{ t.id }}"
{{ 'selected' if schedule and schedule.template_id == t.id }}>{{ t.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Frequency</label>
<select name="frequency" class="form-select" required>
{% for f in frequencies %}
<option value="{{ f }}"
{{ 'selected' if (schedule and schedule.frequency == f) or (not schedule and f == 'weekly') }}>
{{ f|title }}</option>
{% endfor %}
</select>
</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>
<label class="form-label fw-semibold" for="facility_id">Facility</label>
<select name="facility_id" id="facility_id" class="form-select" required>
<option value="">Select a Contract first</option>
{% for f in facilities %}
<option value="{{ f.id }}"
{{ 'selected' if schedule and schedule.facility_id == f.id }}>{{ f.name }}</option>
{{ 'selected' if v_facility == 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">
<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>{{ schedule.area.name }}</option>
<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">Assign to inspector</label>
<select name="inspector_id" class="form-select" required>
<option value="">— Choose an inspector —</option>
<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 %}
<option value="{{ t.id }}"
{{ 'selected' if v_template == t.id }}>{{ t.name }}</option>
{% endfor %}
</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="mb-3">
<label class="form-label fw-semibold" for="inspector_id">Assign to inspector</label>
<select name="inspector_id" id="inspector_id" class="form-select" required>
<option value="">— Select a Contract first —</option>
{% for u in inspectors %}
<option value="{{ u.id }}"
{{ 'selected' if schedule and schedule.inspector_id == u.id }}>
{{ u.display_name }} ({{ u.role }})</option>
{{ 'selected' if v_inspector == u.id }}>
{{ u.display_name }}{{ ' (Customer)' if u.is_external_inspector }}</option>
{% endfor %}
</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 class="row">
<div class="col-md-6 mb-3">
<label class="form-label fw-semibold" for="frequency">Frequency</label>
<select name="frequency" id="frequency" class="form-select" required>
{% 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 %}
<div class="form-check form-switch mb-1">
<input class="form-check-input" type="checkbox" name="active" id="activeSwitch"
{{ 'checked' if schedule.active }}>
<label class="form-check-label" for="activeSwitch">Active</label>
</div>
<p class="text-muted small">
Saving recomputes the next run from now. Next run:
{{ schedule.next_run_at.strftime('%Y-%m-%d %H:%M') if schedule.next_run_at else '—' }}
</p>
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>
<div class="card-footer d-flex justify-content-between">
<a href="{{ url_for('inspection_schedules.index') }}" class="btn btn-outline-secondary">Cancel</a>
{# ── 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>
<option value="plan" {{ 'selected' if v_mode == 'plan' }}>
Plan — inspector presses Start (with due/overdue reminders)</option>
</select>
<div class="form-text">
Auto drops an in-progress inspection into the inspector's queue on
schedule. Plan assigns a due date and reminds them the day before, on
the day, and alerts managers once it's overdue.
</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold" for="notes">
Instructions for the inspector <span class="text-muted small fw-normal">(optional)</span>
</label>
<textarea name="notes" id="notes" class="form-control" rows="3"
placeholder="e.g. Front lobby carpet needs extra attention. Check loading dock after 3 PM — key is at the front desk.">{{ v_notes }}</textarea>
<div class="form-text">
<i class="bi bi-info-circle"></i>
Shown to the assigned inspector when they open this inspection, on the web and on the iPad.
</div>
</div>
{% if schedule %}
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" name="active" id="active"
{{ 'checked' if v_active }}>
<label class="form-check-label" for="active">Active</label>
</div>
{% endif %}
<div class="d-flex gap-2">
<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>
<a href="{{ url_for('inspection_schedules.index') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
<p class="text-muted small mt-2">
Recurring schedules automatically roll their due date forward each time the
inspection is completed. The assigned inspector is reminded the day before
and on the due date; managers are alerted if it becomes overdue.
{% if schedule %}
<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 %}
{% block extra_js %}
<script>
// Facility → Area cascade, reusing the existing inspections AJAX endpoint.
// ── 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 () {
var facilitySelect = document.getElementById('facilitySelect');
var areaSelect = document.getElementById('areaSelect');
if (!facilitySelect || !areaSelect) return;
'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 preselectedAreaId = {{ (schedule.area_id if schedule and schedule.area_id else 0) | tojson }};
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) {
areaSelect.innerHTML = '<option value="">— Whole facility —</option>';
if (!facilityId) return;
fetch('{{ url_for('inspections.areas_for_facility', facility_id=0) }}'.replace('/0', '/' + facilityId))
if (!areaSel) { return; }
areaSel.innerHTML = '<option value="">— Whole facility —</option>';
if (!facilityId) { return; }
fetch(AREAS_URL + facilityId)
.then(function (r) { return r.json(); })
.then(function (areas) {
areas.forEach(function (a) {
var opt = document.createElement('option');
opt.value = a.id;
opt.textContent = a.name;
if (keepSelection && a.id === preselectedAreaId) opt.selected = true;
areaSelect.appendChild(opt);
if (keepSelection && a.id === preAreaId) { opt.selected = true; }
areaSel.appendChild(opt);
});
})
.catch(function () { /* leave the whole-facility default in place */ });
}
facilitySelect.addEventListener('change', function () {
preselectedAreaId = 0;
function loadFacilities(projectId, restoreFacilityId) {
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);
});
// On edit load, refresh the area list for the saved facility and keep the saved area.
if (facilitySelect.value) loadAreas(facilitySelect.value, true);
})();
// Initial state: restore the contract, facility and area on edit / re-render.
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>
{% endblock %}
+138 -9
View File
@@ -1,53 +1,165 @@
{% extends "base.html" %}
{% block title %}Inspection Schedules{% endblock %}
{% 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">
<h2><i class="bi bi-calendar2-week"></i> Inspection Schedules</h2>
{# 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">
<i class="bi bi-plus-circle"></i> New Schedule
</a>
{% endif %}
</div>
</div>
<p class="text-muted small mb-4">
Recurring schedules automatically create an in-progress inspection for the
assigned inspector each period. The inspector is notified and opens it from
their Inspections list to complete it.
{% if current_user.is_inspector %}
Inspections scheduled for you. <strong>Auto</strong> schedules appear in your
Inspections list on their own each period; <strong>Plan</strong> schedules wait
for you to press Start.
{% else %}
<strong>Auto</strong> schedules automatically create an in-progress inspection
for the assigned inspector each period. <strong>Plan</strong> schedules assign a
due date and let the inspector press Start when they begin — with reminders the
day before, on the day, and an alert to managers once overdue.
{% endif %}
</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 %}
<div class="card shadow-sm">
<div class="card shadow-sm border-top-0 rounded-top-0">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0 align-middle">
<thead class="table-light">
<tr>
<th>Name</th><th>Template</th><th>Facility / Area</th>
<th>Inspector</th><th>Frequency</th><th>Next Run</th>
<th>Last Run</th><th>Status</th><th width="150"></th>
<th>Inspector</th><th>Frequency</th><th>Mode</th><th>Next Due</th>
<th>Ends</th><th>Last Run</th><th>Status</th><th width="190"></th>
</tr>
</thead>
<tbody>
{% for s in schedules %}
<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.facility.name if s.facility else '—' }}
{% if s.area %}<span class="text-muted small">/ {{ s.area.name }}</span>{% endif %}
</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>
{% if s.mode == 'plan' %}
<span class="badge bg-info text-dark" title="Inspector presses Start">Plan</span>
{% else %}
<span class="badge bg-light text-dark border" title="Cron creates the inspection">Auto</span>
{% endif %}
</td>
<td class="small {{ 'text-danger fw-semibold' if s.active and s.next_run_at and s.next_run_at <= now else 'text-muted' }}">
{{ s.next_run_at.strftime('%Y-%m-%d %H:%M') if s.next_run_at else '—' }}
{% if s.is_overdue(today) %}
<span class="badge bg-danger ms-1">Overdue</span>
{% endif %}
</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">
{{ s.last_run_at.strftime('%Y-%m-%d %H:%M') if s.last_run_at else 'Never' }}
</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>
{% elif s.is_expired %}<span class="badge bg-dark">Ended</span>
{% 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 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'
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) }}"
class="btn btn-sm btn-primary" title="Start this inspection now">
<i class="bi bi-play-fill"></i> Start
</a>
{% endif %}
{% if can_manage_schedules %}
<a href="{{ url_for('inspection_schedules.edit', schedule_id=s.id) }}"
class="btn btn-sm btn-outline-secondary" title="Edit">
<i class="bi bi-pencil"></i>
@@ -70,6 +182,7 @@
<i class="bi bi-trash3"></i>
</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
@@ -79,13 +192,29 @@
</div>
</div>
{% 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">
{% 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>
<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">
<i class="bi bi-plus-circle"></i> Create First Schedule
</a>
{% endif %}
{% endif %}
</div>
</div>
{% endif %}
+2 -2
View File
@@ -439,7 +439,7 @@
</label>
{# Thumbnail shown after AJAX upload or when a saved path exists #}
{% if saved %}
<img src="{{ url_for('static', filename=saved) }}"
<img src="{{ media_url(saved) }}"
id="thumb_{{ fid }}"
alt="Photo"
style="max-height:60px;max-width:100%;border-radius:4px;margin-top:.3rem;object-fit:cover;">
@@ -564,7 +564,7 @@
<option value="0">— Unassigned —</option>
{% set staff = staff_for_flag_issue %}
{% 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 %}
</select>
</div>
+21 -2
View File
@@ -3,11 +3,18 @@
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-clipboard-data"></i> Inspections</h2>
{# 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-secondary">
<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 #}
@@ -101,10 +108,15 @@
<div class="card shadow-sm">
<div class="card-body p-0">
{% if inspections.items %}
{% include 'partials/bulk_inspections_toolbar.html' %}
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<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>
@@ -113,6 +125,11 @@
<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>{{ 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>
@@ -147,9 +164,9 @@
</td>
<td class="text-nowrap">
{% 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 %}
<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 %}
{% if current_user.role in ['admin', 'director'] %}
<button type="button"
@@ -211,6 +228,7 @@
</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>
@@ -223,6 +241,7 @@
{% endblock %}
{% block extra_js %}
{% include 'partials/bulk_select_js.html' %}
<script>
(function () {
'use strict';
+33 -1
View File
@@ -14,8 +14,14 @@
<div class="mb-3">
{{ 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 %}
{# 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 class="mb-3">
@@ -55,6 +61,8 @@
<script>
(function () {
const projectSel = document.getElementById('projectSelect');
const templateSel = document.getElementById('templateSelect');
const templateEmpty = document.getElementById('templateEmpty');
const facilitySel = document.getElementById('facilitySelect');
const spinner = document.getElementById('facilitySpinner');
const emptyMsg = document.getElementById('facilityEmpty');
@@ -62,6 +70,7 @@
const areaSel = document.getElementById('areaSelect');
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', '/');
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) {
if (!projectId) return;
spinner.classList.remove('d-none');
@@ -129,6 +160,7 @@
projectSel.addEventListener('change', function () {
loadFacilities(this.value, null, null);
loadTemplates(this.value);
});
facilitySel.addEventListener('change', function () {
+126 -7
View File
@@ -1,6 +1,13 @@
{% extends "base.html" %}
{% 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 %}
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&display=swap" rel="stylesheet">
<style>
@@ -338,7 +345,10 @@
{# Action bar #}
<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
</a>
<div class="d-flex gap-2">
@@ -349,13 +359,28 @@
<button onclick="window.print()" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-printer"></i> Print
</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) }}"
class="btn btn-sm btn-outline-primary"
title="Start a follow-up re-inspection with the same template and facility">
<i class="bi bi-arrow-repeat"></i> Re-inspect
</a>
{% 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 not inspection.follow_up_required %}
<button type="button" class="btn btn-sm btn-outline-warning"
@@ -368,6 +393,7 @@
action="{{ url_for('inspections.clear_followup', inspection_id=inspection.id) }}"
class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="next" value="{{ back_url }}">
<button class="btn btn-sm btn-warning">
<i class="bi bi-flag-fill"></i> Clear Follow-up
</button>
@@ -376,6 +402,7 @@
<form method="post" action="{{ url_for('inspections.delete', inspection_id=inspection.id) }}"
onsubmit="return confirm('Delete this inspection permanently?')">
<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>
</form>
{% endif %}
@@ -388,13 +415,43 @@
<i class="bi bi-flag-fill mt-1"></i>
<div>
<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 %}
{# 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">
<a href="{{ url_for('inspections.reinspect', inspection_id=inspection.id) }}"
class="btn btn-sm btn-warning">
<i class="bi bi-arrow-repeat me-1"></i>Start Re-inspection
</a>
</div>
{% else %}
<div class="small mt-1">The team has been notified and will schedule the re-inspection.</div>
{% endif %}
</div>
</div>
{% endif %}
@@ -475,6 +532,13 @@
</div>
</div>
<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">
{{ inspection.status|replace('_',' ')|title }}
</span>
@@ -508,6 +572,12 @@
<span class="lbl">Frequency</span>
<span class="val">{{ inspection.template.frequency|title }}</span>
</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>
{# ── Submission GPS (admin / director only) ──────────────────────────── #}
@@ -688,7 +758,7 @@
<div style="display:flex;align-items:center;flex:1;min-height:0;">
{% if val %}
<button type="button" class="btn-view-media"
onclick="openMedia('{{ url_for('static', filename=val) }}','{{ field.label | e }}')">
onclick="openMedia('{{ media_url(val) }}','{{ field.label | e }}')">
<i class="bi bi-image"></i> View Photo
</button>
{% else %}
@@ -854,7 +924,11 @@
{% block extra_js %}
<script>
(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) {
var btn = document.getElementById('backToInspectionsBtn');
if (btn) btn.href = backUrl;
@@ -880,20 +954,65 @@ document.addEventListener('keydown', e => { if (e.key === 'Escape') closeMedia()
<div class="modal-dialog">
<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="next" value="{{ back_url }}">
<div class="modal-content">
{% set is_cust = current_user.role == 'customer' %}
<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>
</div>
<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"
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 class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<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>
</div>
</div>
+87
View File
@@ -46,6 +46,93 @@
</div>
{% 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">
<button type="submit" class="btn btn-danger">Log Issue</button>
<a href="{{ url_for('issues.index') }}" class="btn btn-outline-secondary">Cancel</a>
+19 -7
View File
@@ -3,7 +3,7 @@
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-exclamation-triangle"></i> Issues</h2>
{% if current_user.role in ['admin','director','customer'] %}
{% 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>
@@ -115,10 +115,15 @@
<div class="card shadow-sm">
<div class="card-body p-0">
{% if issues.items %}
{% include 'partials/bulk_issues_toolbar.html' %}
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<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>
@@ -137,6 +142,11 @@
{% 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><small>{{ issue.reported_at.strftime('%Y-%m-%d %H:%M') }}</small></td>
<td>
@@ -176,12 +186,12 @@
{% else %}<span class="text-muted"></span>{% endif %}
</td>
<td>
{% if current_user.role in ['admin', 'director'] and issue.status != 'resolved' %}
{% 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 }}</option>
<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>
@@ -202,7 +212,7 @@
class="d-inline"
title="Unfollow this issue">
<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"
title="Unfollow">
<i class="bi bi-bell-slash" style="font-size:.75rem;"></i>
@@ -210,9 +220,9 @@
</form>
{% 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">
{% if current_user.role in ['admin','director'] 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
{% else %}
<i class="bi bi-eye"></i> View
@@ -223,6 +233,7 @@
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>
@@ -259,6 +270,7 @@
{% endblock %}
{% block extra_js %}
{% include 'partials/bulk_select_js.html' %}
<script>
(function () {
'use strict';
@@ -294,7 +306,7 @@
}());
</script>
{% if current_user.role in ['admin', 'director'] %}
{% if current_user.role in ['admin', 'director', 'auditor'] %}
<script>
(function () {
'use strict';
+363 -64
View File
@@ -18,7 +18,31 @@
{% endblock %}
{% block content %}
{% set can_edit = current_user.role in ['admin','director'] 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">
{# ══════════════════════════════════ LEFT COLUMN ══════════════════════════════════ #}
@@ -76,10 +100,11 @@
<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>
{% 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>
<dd class="col-sm-9">
{% if issue.handler_type == 'facility' %}
{% if _ht == 'facility' %}
<span class="badge bg-secondary">
<i class="bi bi-building me-1"></i>Facility Staff
</span>
@@ -92,8 +117,18 @@
{% if issue.facility_handler_notes %}
<div class="text-muted small mt-1" style="white-space:pre-wrap;">{{ issue.facility_handler_notes }}</div>
{% 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>
{% 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 %}
</dd>
{% endif %}
@@ -127,14 +162,14 @@
<h6>Photo Evidence</h6>
<div class="d-flex flex-wrap gap-2">
{% if issue.photo_path %}
<a href="{{ url_for('static', filename=issue.photo_path) }}" target="_blank">
<img src="{{ url_for('static', filename=issue.photo_path) }}"
<a href="{{ media_url(issue.photo_path) }}" target="_blank">
<img src="{{ media_url(issue.photo_path) }}"
class="img-fluid rounded" style="max-height:300px; max-width:100%;">
</a>
{% endif %}
{% for photo in (issue.mobile_photo_paths or []) %}
<a href="{{ url_for('static', filename=photo) }}" target="_blank">
<img src="{{ url_for('static', filename=photo) }}"
<a href="{{ media_url(photo) }}" target="_blank">
<img src="{{ media_url(photo) }}"
class="rounded border" style="max-height:300px; max-width:100%; object-fit:cover;">
</a>
{% endfor %}
@@ -150,8 +185,8 @@
{% if issue.result_photos %}
<div class="d-flex flex-wrap gap-2 mt-2">
{% for photo in issue.result_photos %}
<a href="{{ url_for('static', filename=photo) }}" target="_blank">
<img src="{{ url_for('static', filename=photo) }}"
<a href="{{ media_url(photo) }}" target="_blank">
<img src="{{ media_url(photo) }}"
class="rounded border" style="max-height:120px; max-width:160px; object-fit:cover;"
alt="Result photo">
</a>
@@ -174,8 +209,9 @@
<div class="alert alert-info py-2 mb-0">
<i class="bi bi-hourglass-split me-1"></i>
<strong>Awaiting director verification.</strong>
{% if current_user.role in ['admin','director'] %}
{% if current_user.role in ['admin','director','auditor'] %}
<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() }}">
<div class="mb-2">
<input type="text" name="verification_note" class="form-control form-control-sm"
@@ -191,6 +227,84 @@
</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 ───────────────────────────────────────────────────────── #}
<div class="card shadow-sm mb-4" id="comments-section">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
@@ -219,8 +333,12 @@
{% else %}
<span class="badge bg-secondary" style="font-size:.65rem;">{{ c.author.role|replace('_',' ')|title }}</span>
{% endif %}
{# Visibility indicator — staff only #}
{% if current_user.role != 'customer' %}
{# Visibility indicator — OUR staff only, and only while the
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 %}
<span class="badge bg-success bg-opacity-10 text-success border border-success"
style="font-size:.6rem;" title="Customer can see this comment">
@@ -263,6 +381,7 @@
<div class="card-body">
<p class="fw-semibold small mb-2">Add Comment</p>
<form method="post">
<input type="hidden" name="next" value="{{ back_url }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="status" value="{{ issue.status }}">
<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"
placeholder="Write a comment…" required></textarea>
</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="form-check form-check-inline mb-0">
<input class="form-check-input" type="checkbox"
@@ -290,6 +428,7 @@
<div class="card-body">
<p class="fw-semibold small mb-2">Add Comment</p>
<form method="post">
<input type="hidden" name="next" value="{{ back_url }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-2">
<textarea name="update_notes" class="form-control" rows="3"
@@ -358,12 +497,13 @@
<div class="card-header bg-light"><h6 class="mb-0">Update Issue</h6></div>
<div class="card-body">
<form method="post" enctype="multipart/form-data">
<input type="hidden" name="next" value="{{ back_url }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
{{ form.status.label(class="form-label fw-semibold") }}
{{ form.status(class="form-select") }}
</div>
{% if current_user.role in ['admin','director'] %}
{% if current_user.role in ['admin','director','auditor'] %}
<div class="mb-3">
{{ form.assigned_to.label(class="form-label fw-semibold") }}
{{ form.assigned_to(class="form-select") }}
@@ -386,7 +526,7 @@
</div>
{% endif %}
</div>
{% if current_user.role in ['admin','director','project_manager'] %}
{% if current_user.role in ['admin','director','project_manager','auditor'] %}
<hr class="my-3">
<p class="fw-semibold small mb-2">
<i class="bi bi-person-check me-1 text-secondary"></i>Handler / Ownership
@@ -416,13 +556,30 @@
placeholder="Notes about what they are handling…") }}
</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>
(function(){
var sel = document.getElementById('handlerTypeSelect');
var box = document.getElementById('facilityHandlerFields');
if(sel && box){
var inv = document.getElementById('internalHandlerFields');
if(sel){
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>
{% endif %}
{# ── Vendor Work Orders (phase36) ───────────────────────────────────── #}
{% if current_user.role in ['admin','director','project_manager'] %}
<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>
<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
</a>
<a href="{{ url_for('issues.export_pdf', issue_id=issue.id) }}" class="btn btn-outline-primary btn-sm">
@@ -531,6 +642,77 @@
{% endif %}
</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'] %}
<div class="modal fade" id="deleteIssueModal" tabindex="-1" aria-labelledby="deleteIssueModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
@@ -555,6 +737,7 @@
<i class="bi bi-x-circle"></i> Cancel
</button>
<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() }}">
<button type="submit" class="btn btn-danger">
<i class="bi bi-trash-fill"></i> Delete Permanently
@@ -585,6 +768,122 @@
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>
{% 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>
+2 -2
View File
@@ -17,7 +17,7 @@
<i class="bi bi-shield-check me-1"></i>SLA Compliance
</a>
</li>
{% if current_user.role in ['admin', 'director', 'project_manager'] %}
{% if current_user.role in ['admin', 'director', 'project_manager', 'auditor'] %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint == 'reports.followup_closure' else '' }}"
href="{{ url_for('reports.followup_closure') }}">
@@ -33,7 +33,7 @@
</a>
</li>
{% endif %}
{% if current_user.role in ['admin', 'director', 'project_manager'] %}
{% 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('scheduled_reports.') else '' }}"
href="{{ url_for('scheduled_reports.index') }}">
+54 -8
View File
@@ -105,7 +105,17 @@
<div class="row mb-4">
<div class="col-lg-8 mb-3">
<div class="card shadow-sm h-100">
<div class="card-header bg-light"><h6 class="mb-0"><i class="bi bi-building"></i> Avg Score by Facility</h6></div>
<div class="card-header bg-light d-flex justify-content-between align-items-center gap-2 flex-wrap">
<h6 class="mb-0"><i class="bi bi-building"></i> Avg Score by Facility</h6>
{% if score_contracts %}
<select id="scoreContractFilter" class="form-select form-select-sm" style="max-width:230px;">
<option value="">All Contracts</option>
{% for pid, cname in score_contracts %}
<option value="{{ pid }}">{{ cname }}</option>
{% endfor %}
</select>
{% endif %}
</div>
<div class="card-body"><div class="chart-container"><canvas id="facilityChart"></canvas></div></div>
</div>
</div>
@@ -129,6 +139,7 @@
<thead class="table-light">
<tr>
<th>Facility</th>
<th>Contract</th>
<th class="text-end">Current Period</th>
<th class="text-end">Prior Period</th>
<th class="text-end">Change</th>
@@ -137,8 +148,9 @@
</thead>
<tbody>
{% for row in facility_scores %}
<tr>
<tr class="facility-score-row" data-project-id="{{ row.project_id }}">
<td class="fw-semibold">{{ row.name }}</td>
<td class="text-muted small">{{ row.contract }}</td>
<td class="text-end">
<span class="badge bg-{{ 'success' if row.avg_score >= 90 else 'warning text-dark' if row.avg_score >= 70 else 'danger' }}">
{{ '%.1f'|format(row.avg_score|float) }}%
@@ -266,16 +278,31 @@ new Chart(document.getElementById('trendChart'), {
}
});
// ── Facility bar chart ────────────────────────────────────────────────────────
new Chart(document.getElementById('facilityChart'), {
// ── Facility bar chart (filterable by contract) ───────────────────────────────
const FACILITY_SCORES = {{ facility_scores | tojson }};
let facilityChartObj = null;
function _facColors(data) { return data.map(s => s >= 90 ? GREEN : s >= 70 ? AMBER : RED); }
function renderFacilityChart(pid) {
const rows = (!pid)
? FACILITY_SCORES
: FACILITY_SCORES.filter(r => String(r.project_id) === String(pid));
const labels = rows.map(r => r.name);
const data = rows.map(r => r.avg_score);
if (facilityChartObj) {
facilityChartObj.data.labels = labels;
facilityChartObj.data.datasets[0].data = data;
facilityChartObj.data.datasets[0].backgroundColor = _facColors(data);
facilityChartObj.update();
return;
}
facilityChartObj = new Chart(document.getElementById('facilityChart'), {
type: 'bar',
data: {
labels: {{ facility_scores | map(attribute='name') | list | tojson }},
labels: labels,
datasets: [{
label: 'Avg Score (%)',
data: {{ facility_scores | map(attribute='avg_score') | list | tojson }},
backgroundColor: {{ facility_scores | map(attribute='avg_score') | list | tojson }}
.map(s => s >= 90 ? GREEN : s >= 70 ? AMBER : RED),
data: data,
backgroundColor: _facColors(data),
borderRadius: 4,
}]
},
@@ -285,6 +312,25 @@ new Chart(document.getElementById('facilityChart'), {
plugins: { legend: { display: false } }
}
});
}
function filterFacilityScoreTable(pid) {
document.querySelectorAll('.facility-score-row').forEach(function (tr) {
const rp = tr.getAttribute('data-project-id');
tr.style.display = (!pid || rp === String(pid)) ? '' : 'none';
});
}
(function () {
renderFacilityChart('');
const sel = document.getElementById('scoreContractFilter');
if (sel) {
sel.addEventListener('change', function () {
renderFacilityChart(this.value);
filterFacilityScoreTable(this.value);
});
}
}());
// ── Severity doughnut ─────────────────────────────────────────────────────────
const sevData = {{ issue_severity | tojson }};
@@ -115,6 +115,9 @@
data-inspector-id="{{ s.id }}">
<td class="fw-semibold">
{{ s.display_name }}
{% if s.external %}
<span class="badge bg-dark ms-1" title="Customer / third-party inspector">Customer</span>
{% endif %}
</td>
<td class="text-center">{{ s.total }}</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">
<h6 class="mb-0">
<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>
<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">
+43 -2
View File
@@ -29,9 +29,20 @@
{% endfor %}
</select>
</div>
<div class="col-md-4">
{% if projects %}
<div class="col-md-2">
<label class="form-label small mb-1">Contract</label>
<select id="aging_contract_sel" class="form-select form-select-sm">
<option value="">All Contracts</option>
{% for p in projects %}
<option value="{{ p.id }}">{{ p.name }}</option>
{% endfor %}
</select>
</div>
{% endif %}
<div class="col-md-3">
<label class="form-label small mb-1">Facility</label>
<select name="facility_id" class="form-select form-select-sm">
<select id="aging_facility_sel" name="facility_id" class="form-select form-select-sm">
<option value="">All Facilities</option>
{% for f in facilities %}
<option value="{{ f.id }}" {{ 'selected' if facility_id_filter == f.id }}>{{ f.name }}</option>
@@ -46,6 +57,36 @@
</div>
</div>
{% if projects %}
<script>
(function () {
var contractSel = document.getElementById('aging_contract_sel');
var facSel = document.getElementById('aging_facility_sel');
if (!contractSel || !facSel) return;
var allOpts = Array.from(facSel.options).map(function (o) { return {v: o.value, t: o.text}; });
contractSel.addEventListener('change', function () {
var pid = this.value;
facSel.innerHTML = '<option value="">All Facilities</option>';
if (!pid) {
allOpts.slice(1).forEach(function (o) {
var opt = document.createElement('option'); opt.value = o.v; opt.text = o.t;
facSel.appendChild(opt);
});
return;
}
fetch('/inspections/facilities_for_project/' + pid)
.then(function (r) { return r.json(); })
.then(function (data) {
data.forEach(function (f) {
var opt = document.createElement('option'); opt.value = f.id; opt.text = f.name;
facSel.appendChild(opt);
});
});
});
}());
</script>
{% endif %}
{# ── KPI row ── #}
<div class="row g-3 mb-4">
<div class="col-6 col-md-4">
+45 -4
View File
@@ -25,17 +25,28 @@
<div class="card shadow-sm mb-4">
<div class="card-body py-2">
<form method="get" class="row g-2 align-items-end">
<div class="col-md-3">
<div class="col-md-2">
<label class="form-label small mb-1">From</label>
<input type="date" name="start" class="form-control form-control-sm" value="{{ start.strftime('%Y-%m-%d') }}">
</div>
<div class="col-md-3">
<div class="col-md-2">
<label class="form-label small mb-1">To</label>
<input type="date" name="end" class="form-control form-control-sm" value="{{ end.strftime('%Y-%m-%d') }}">
</div>
<div class="col-md-4">
{% if projects %}
<div class="col-md-2">
<label class="form-label small mb-1">Contract</label>
<select id="sla_contract_sel" class="form-select form-select-sm">
<option value="">All Contracts</option>
{% for p in projects %}
<option value="{{ p.id }}">{{ p.name }}</option>
{% endfor %}
</select>
</div>
{% endif %}
<div class="col-md-3">
<label class="form-label small mb-1">Facility</label>
<select name="facility_id" class="form-select form-select-sm">
<select id="sla_facility_sel" name="facility_id" class="form-select form-select-sm">
<option value="">All Facilities</option>
{% for f in facilities %}
<option value="{{ f.id }}" {{ 'selected' if facility_id_filter == f.id }}>{{ f.name }}</option>
@@ -50,6 +61,36 @@
</div>
</div>
{% if projects %}
<script>
(function () {
var contractSel = document.getElementById('sla_contract_sel');
var facSel = document.getElementById('sla_facility_sel');
if (!contractSel || !facSel) return;
var allOpts = Array.from(facSel.options).map(function (o) { return {v: o.value, t: o.text}; });
contractSel.addEventListener('change', function () {
var pid = this.value;
facSel.innerHTML = '<option value="">All Facilities</option>';
if (!pid) {
allOpts.slice(1).forEach(function (o) {
var opt = document.createElement('option'); opt.value = o.v; opt.text = o.t;
facSel.appendChild(opt);
});
return;
}
fetch('/inspections/facilities_for_project/' + pid)
.then(function (r) { return r.json(); })
.then(function (data) {
data.forEach(function (f) {
var opt = document.createElement('option'); opt.value = f.id; opt.text = f.name;
facSel.appendChild(opt);
});
});
});
}());
</script>
{% endif %}
{% if total == 0 %}
<div class="alert alert-info">
<i class="bi bi-info-circle me-2"></i>No resolved issues found for this period and filter.
+12 -1
View File
@@ -2,7 +2,7 @@
{% block title %}Support Knowledge Base{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3">
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
<div>
<h4 class="mb-0"><i class="bi bi-book me-2 text-primary"></i>Support Knowledge Base</h4>
<small class="text-muted">Active entries are injected into the AI chatbot system prompt</small>
@@ -11,6 +11,10 @@
<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
</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">
<i class="bi bi-chat-dots me-1"></i>Conversations
</a>
@@ -34,6 +38,12 @@
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>
<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">
<i class="bi bi-plus me-1"></i>Add Entry
</button>
@@ -54,6 +64,7 @@
<div class="d-flex justify-content-between align-items-start">
<div class="flex-grow-1 me-3">
<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>
{% if entry.active %}
<span class="badge bg-success">Active</span>
@@ -23,6 +23,12 @@
<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>
<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">
<button type="submit" class="btn btn-primary">
<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 %}
+1 -1
View File
@@ -39,7 +39,7 @@
<div class="col-lg-8">
{# ── Header ── #}
<div class="d-flex justify-content-between align-items-center mb-3">
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
<div>
<h4 class="mb-0"><i class="bi bi-chat-dots me-2 text-primary"></i>JQC Support Chat</h4>
<small class="text-muted">
+9
View File
@@ -160,6 +160,15 @@
{{ form.frequency.label(class="form-label fw-semibold small") }}
{{ form.frequency(class="form-select form-select-sm") }}
</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">
<button type="submit" class="btn btn-primary btn-sm">
+12
View File
@@ -27,6 +27,18 @@
{{ form.frequency.label(class="form-label") }}
{{ form.frequency(class="form-select") }}
</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">
<button type="submit" class="btn btn-primary">

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