Compare commits

...
78 Commits
Author SHA1 Message Date
nngo 50df63115e Sep 4 - Add link relavant issues function 2026-09-04 13:17:33 -04:00
nngo b7bc0f0335 Aug 27 - Add MySQL optimize and security check 2026-08-27 17:36:28 -04:00
nngo ba10ec42e0 Aug 27 - Updated web dashboard card, inspection ownership 2026-08-27 11:53:14 -04:00
nngo a36cc93594 Aug 25 - Fixed inspection access denied by inspector 2026-08-25 12:52:06 -04:00
nngo aced3d0602 Aug 25 - Implement new function allow Director (internal & customer) to assign and inspection to another inspector 2026-08-25 12:24:34 -04:00
nngo b31f5f03da Aug 25 - Fix inspection photo lost 2026-08-25 10:01:20 -04:00
nngo 7ec34e339b Aug 19 - Update: remove theme switch option 2026-08-19 12:13:50 -04:00
nngo 1a9666d731 Aug 19 - Update: AI model since the old one no longer exist 2026-08-19 11:29:12 -04:00
nngo 070e9be993 Aug 19 - Update: customer Inspector AI support chat 2026-08-19 11:16:47 -04:00
nngo 5df105a9d5 Aug 19 - Update: now customer Director user can create scheduled task for their staff 2026-08-19 10:18:18 -04:00
nngo d8b5ce201d Aug 18 - Fix customer roles's notification radio buttons issue 2026-08-18 10:21:50 -04:00
nngo 515aa07326 Aug 17 - Fix bug, cross-customer form-name leak 2026-08-17 17:35:40 -04:00
nngo 396b774216 Aug 17 - Fix customer inspector issue comment internal banner 2026-08-17 17:01:40 -04:00
nngo 98c0dcb7cc Aug 17 - Update forms will be assigned per contract, edit template page fix 2026-08-17 15:55:17 -04:00
nngo 9d7721213b Aug 17 - Update forms will be assigned per contract 2026-08-17 15:45:48 -04:00
nngo fb85f7dc28 Aug 17 - Fix customer inspector out-of-scope issue assign 2026-08-17 15:19:13 -04:00
nngo 1de15a925b Aug 17 - Update bulk actions for inspection/issue list 2026-08-17 14:48:20 -04:00
nngo 5486145a24 Aug 17 - Update customer roles management 2026-08-17 14:18:10 -04:00
nngo 3209a0c717 Aug 12 - Fix AI support bugs 2026-08-12 09:27:15 -04:00
nngo 59338f678d Aug 11 - Add customer support Q&A 2026-08-11 14:24:09 -04:00
nngo 9f32ea8286 Aug 7 - Update External Inspector invitation 2026-08-07 14:59:40 -04:00
nngo c686d12e64 Aug 7 - Update About us, add JQC features page, and enrollment page 2026-08-07 14:44:33 -04:00
nngo 1741c194f2 Aug 7 - Update remove the dashboard inspector activity 2026-08-07 13:17:00 -04:00
nngo 4db06b6139 Aug 7 - Update remove the switching design button from the sidebar 2026-08-07 11:29:48 -04:00
nngo 16d5e8a6fa Aug 7 - Update new design as default 2026-08-07 11:24:22 -04:00
nngo b12c019810 Aug 7 - Update issue page filter buttons size 2 2026-08-07 11:15:54 -04:00
nngo abfe10b6b4 Aug 7 - Update issue page filter buttons size 2026-08-07 10:52:21 -04:00
nngo 8cb7267e29 Aug 7 - Update issue page filter bar 2026-08-07 10:40:09 -04:00
nngo 005a56517a Aug 7 - Update inspection page filter bar 2026-08-07 10:34:38 -04:00
nngo 4a713cf186 Aug 7 - Update issue comment, customers now can see the comment 2026-08-07 10:01:56 -04:00
nngo ff1a3089bb Aug 7 - Update the AI Suport button 2026-08-07 09:42:26 -04:00
nngo 9586d21ca2 Aug 6 - Update enrollment page, notify admin user 2026-08-06 12:35:09 -04:00
nngo 264eeabd70 Aug 6 - Update enrollment page, correct confirmation email 2026-08-06 12:24:08 -04:00
nngo 548e262b2a Aug 6 - Update enrollment page, add email notification 2026-08-06 12:00:22 -04:00
nngo 5bc7592c1a Aug 6 - Update enrollment page 2026-08-06 11:38:22 -04:00
nngo 633a5b865f Aug 6 - Add enrollment page 2026-08-06 11:10:26 -04:00
nngo c988f8cabb Aug 6 - Add external inspector role 2026-08-06 10:40:33 -04:00
nngo 0830585ff5 Aug 5 - Update (new design) 3 2026-08-05 19:57:28 -04:00
nngo 9f8cbe1675 Aug 5 - Update (new design) 2 2026-08-05 19:30:57 -04:00
nngo eba535487e Aug 5 - Update (new design) 2026-08-05 19:06:07 -04:00
nngo 22b3afdb02 Aug 5 - Update (new design) - move scheduled Inspection section to top 2026-08-05 14:13:48 -04:00
nngo f92d8783bf Aug 5 - Add new design (switchable) 2026-08-05 14:07:38 -04:00
nngo 07379bd915 Aug 3 - Update the inspections/issues page 2026-08-03 14:39:19 -04:00
nngo f32a4f86c1 Aug 3 - Update the dashboard, remove the letter A,B,C,D 2026-08-03 14:12:45 -04:00
nngo d673d52f29 Aug 3 - Update the dashboard 2026-08-03 14:09:10 -04:00
nngo 991f242d12 Aug 3 - Add scheduled task confirm button in notification email, and separate pending/completed scheduled tasks UI 2026-08-03 13:40:11 -04:00
nngo 38acb6a52e Aug 3 - Update scheduled task acknowledged 2026-08-03 12:51:01 -04:00
nngo 6513e6b4f6 Jul 30 - Show scheduled inspection creator on list and task details page 2026-07-31 17:06:13 -04:00
nngo d424385747 Merge branch 'main' of https://gitea.ngodanguyen.tech/nngo/LT_Janitorial_Quality_Control 2026-07-31 15:04:10 -04:00
nngo f9c93ca3ed Jul 31 - Update to ensure customers don't get notification for internal comments 2026-07-31 15:03:47 -04:00
nngo 0808f8eaff Jul 30 - Allow customer to flag follow-up a inspection 2026-07-30 20:44:38 -04:00
Nguyen Ngo 5ed433aabe Jul 30 - Update backend for iPad - inspector can re-inspect and create follow-up 2026-07-30 16:08:58 -04:00
Nguyen Ngo 12bcda2994 Jul 30 - Update backend for iPad follow-up request function 2026-07-30 15:07:23 -04:00
Nguyen Ngo 455534ccda Jul 27 - Update code for scheduled tasks 2 2026-07-27 15:26:06 -04:00
Nguyen Ngo 44b577b59f Jul 27 - Update code for scheduled tasks 2026-07-27 12:55:54 -04:00
nngo 3ab84ac016 Jul 26 - Update scheduled inspection settings for iPad app 2026-07-26 14:51:50 -04:00
nngo 16858108b9 Jul 26 - Update scheduled inspection settings (weekly/monthly) 2026-07-26 13:47:17 -04:00
nngo 0c63c45b21 Jul 26 - Update timestamp smaller and more transparent 2026-07-26 11:09:01 -04:00
nngo 31ffeb0abf Jul 23 - Update PDF export file uses lighter color to save ink 2026-07-23 15:03:24 -04:00
nngo edc5398d75 Jul 21 - Update document 2026-07-21 16:33:24 -04:00
nngo 5a30abbea5 Jul 21 - Update uploaded photos location/timestamp 2026-07-21 14:06:26 -04:00
nngo 507d9b5c64 Jul 21 - Update QR code destination page, shows recent inspection cleaning quality 2026-07-21 12:00:27 -04:00
nngo fc366fbf8d Jul 21 - Update all issues description field is mandatory, including from QR code page 2026-07-21 11:49:23 -04:00
nngo fe8514d91c Jul 21 - Update all issues description field is mandatory 2026-07-21 10:00:08 -04:00
nngo 65666d19e8 Jul 20 - Update codes to comply with some framework (SOC 2 TYPE 2, ISO, etc) 2026-07-20 21:10:47 -04:00
nngo 66f9fc30f4 Jul 20 - Update Issue's description tooltip 2026-07-20 17:32:43 -04:00
nngo 30f5b56bbb Jul 20 - Update Inspection list with scheduled bagde 2026-07-20 17:27:56 -04:00
nngo 18e9090780 Jul 20 - Update The Scheduled inspection's Start button on the Dashboard, only for Assignee 2026-07-20 17:18:30 -04:00
nngo 08b9f088a8 Jul 17 - Update Facility/Area QR code report page with anti-duplication reports 2026-07-17 10:24:59 -04:00
nngo 4900ddc2cc Jul 16 - Update submission will trigger notification to self inspector only 2026-07-16 15:56:18 -04:00
nngo 56bcf9ed63 Jul 16 - Update issue handler Janitorial staff - add input staff contact 2026-07-16 15:48:15 -04:00
nngo 66dbf31c34 Jul 16 - Update issue handler Janitorial staff - add input staff name 2026-07-16 15:39:26 -04:00
nngo 5a42b21a04 Jul 16 - Update facilities QR codes - report can upload up to 5 photos 2026-07-16 10:32:47 -04:00
nngo faf4796b4a Jul 16 - Update facilities QR codes - add filters & export PDF 2026-07-16 09:39:05 -04:00
nngo 593c630e1b Jul 15 - Update Issue details page - add Follow button 2026-07-15 17:23:16 -04:00
nngo c64df7ca34 Jul 15 - Update Dashboard - Add and switch Total This Week card 2026-07-15 17:14:09 -04:00
nngo f9d00b7c19 Jul 15 - Add Auditor user role - fix Notification matrix 2026-07-15 13:54:51 -04:00
nngo fcb959900f Jul 15 - Add Auditor user role 2026-07-15 13:47:59 -04:00
122 changed files with 16691 additions and 1513 deletions
+20 -1
View File
@@ -5,7 +5,26 @@
"Bash(cd /d \"D:\\\\Projects\\\\LT_Janitorial_Quality_Control\")",
"Bash(python -c \"import docx; print\\('python-docx OK'\\)\")",
"Bash(pip install *)",
"Bash(python docs/convert_manual.py)"
"Bash(python docs/convert_manual.py)",
"Bash(grep -n -A3 dateOnlyFormatter __TRACKED_VAR__/Sync/SyncManager.swift)",
"Bash(python -c \"import ast; ast.parse\\(open\\('app/utils/pdf_export.py'\\).read\\(\\)\\); print\\('OK'\\)\")",
"Bash(python -c \"import ast,io; ast.parse\\(io.open\\('app/utils/pdf_export.py',encoding='utf-8'\\).read\\(\\)\\); print\\('OK'\\)\")",
"Bash(python -c \"import ast,io; ast.parse\\(io.open\\('app/routes/issues.py',encoding='utf-8'\\).read\\(\\)\\); print\\('OK'\\)\")",
"Bash(python -c \"print\\(len\\('phase47_sched_acknowledged'\\)\\)\")",
"Bash(python -c \"import ast,io; ast.parse\\(io.open\\('app/routes/scheduled_inspections.py',encoding='utf-8'\\).read\\(\\)\\); print\\('route OK'\\)\")",
"Bash(python -c \"import ast,io; ast.parse\\(io.open\\('app/utils/notifications.py',encoding='utf-8'\\).read\\(\\)\\); print\\('notif OK'\\)\")",
"Bash(SECRET_KEY=x DATABASE_URL=sqlite:///:memory: DIGEST_SECRET=x MAIL_SERVER=localhost MAIL_USERNAME=x MAIL_PASSWORD=x MAIL_PORT=587 APP_BASE_URL=http://localhost MAIL_DEFAULT_SENDER=x@x.com python -c ' *)",
"Bash(python -c \"import ast,io; ast.parse\\(io.open\\('app/routes/dashboard.py',encoding='utf-8'\\).read\\(\\)\\); print\\('dashboard route OK'\\)\")",
"Bash(python -c ' *)",
"Bash(git diff *)",
"Bash(python -c \"import ast;ast.parse\\(open\\('app/enrollment/routes.py',encoding='utf-8'\\).read\\(\\)\\);print\\('parses OK'\\)\")",
"Bash(python -c \"import ast;[ast.parse\\(open\\(f,encoding='utf-8'\\).read\\(\\)\\) for f in ['app/enrollment/mailer.py','app/enrollment/schema.py']];print\\('parses OK'\\)\")",
"Bash(python -c \"import ast;ast.parse\\(open\\('app/enrollment/mailer.py',encoding='utf-8'\\).read\\(\\)\\);print\\('parses OK'\\)\")",
"Bash(grep -n \"\\\\\\\\\\\\\\\\n\" app/enrollment/mailer.py)",
"Bash(git stash *)",
"Bash(python -c \"import ast;ast.parse\\(open\\('app/routes/auth.py',encoding='utf-8'\\).read\\(\\)\\);print\\('parses OK'\\)\")",
"Bash(python -c \"import ast;ast.parse\\(open\\('app/routes/support.py',encoding='utf-8'\\).read\\(\\)\\);print\\('parses OK'\\)\")",
"Bash(sed 's/\\\\$$/<EOL>/')"
]
}
}
+685 -27
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 (Phase 19 complete + mobile API gap-fill Phases AE + customer UI refinements + Phase 22 comment visibility + Phase 23 support chat/tickets + inspector performance Excel export + inspection list filters + customer issue logging + AI chatbot + dashboard grouped sections + issues/inspections PDF export + date/ID filters + Reports expansion Phases R1R4 + Phase 24 issue_created notify defaults + Phase 25 inspection GPS + Phase 26 issue vendor fields + Phase 27 facility score alerts + Phase 28 inspection-notify fix + Phase 29 admin broadcasts + Phases 3032 device registry consolidation + ProxyFix reverse-proxy fix + Phase 33 per-contract notification recipients + grouped Admin nav dropdown + forgot-password case-insensitive lookup & email normalization + transactional email sender/branding fix + Phase 34 facility QR public pages & report-a-problem + Phase 35 issue handler_type (our staff / facility / vendor) + Phase 36 scheduled inspections + Phase 37 support chat persistence + Phase 38 support knowledge base + Phase 39 per-area QR public pages)
> **Last reviewed:** July 2026 (Phase 19 complete + mobile API gap-fill Phases AE + customer UI refinements + Phase 22 comment visibility + Phase 23 support chat/tickets + inspector performance Excel export + inspection list filters + customer issue logging + AI chatbot + dashboard grouped sections + issues/inspections PDF export + date/ID filters + Reports expansion Phases R1R4 + Phase 24 issue_created notify defaults + Phase 25 inspection GPS + Phase 26 issue vendor fields + Phase 27 facility score alerts + Phase 28 inspection-notify fix + Phase 29 admin broadcasts + Phases 3032 device registry consolidation + ProxyFix reverse-proxy fix + Phase 33 per-contract notification recipients + grouped Admin nav dropdown + forgot-password case-insensitive lookup & email normalization + transactional email sender/branding fix + Phase 34 facility QR public pages & report-a-problem + Phase 35 issue handler_type (our staff / facility / vendor) + Phase 36 scheduled inspections + Phase 37 support chat persistence + Phase 38 support knowledge base + Phase 39 per-area QR public pages + Phase 40 auditor role + Phase 41 issue internal handler name + Phase 42 internal handler contact + Phase 51 customer roles: Customer Director / Customer Inspector, both owned by Customer Management, role switching, per-account notification overrides, enrollment form narrowed to the two customer seats)
---
@@ -30,6 +30,9 @@
20. [Known Constraints & Hard Rules](#20-known-constraints--hard-rules)
21. [Change Philosophy](#21-change-philosophy)
22. [Object Storage Migration (R2)](#22-object-storage-migration-r2)
23. [Photo Capture-Time / Geo Overlay](#23-photo-capture-time--geo-overlay)
24. [Enrollment Form](#24-enrollment-form-enrollment)
25. [Database Health Check](#25-database-health-check-scriptsdb_healthpy)
---
@@ -162,7 +165,14 @@ part of the tree — see §7. Device registration on the API side lives in
| `DIGEST_SECRET` | Authenticates all cron endpoints |
| `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`. |
| `GROQ_MODEL` | Optional. Groq model ID. Defaults to `_DEFAULT_GROQ_MODEL` in `routes/support.py` (`openai/gpt-oss-120b`, verified Aug 2026). **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, so it stays invisible until a customer complains. That is how `llama-3.3-70b-versatile` took the chat down. The error handler in `chat_message()` logs the model name and an explicit "set GROQ_MODEL" hint for exactly this case; fixing it needs no deploy, just the env var. |
| `ENROLLMENT_NOTIFY_EMAILS` | Optional. Comma-separated extra addresses alerted on a new enrollment, **in addition to** every active `admin` account. For people who should be told but hold no JQC login. |
| `ENROLLMENT_DIR` | Optional. Directory for enrollment-form JSON submissions. Defaults to `<instance_path>/enrollments` (git-ignored). Created at boot. |
| `DEFAULT_UI_THEME` | Optional, default `modern` (phase50). The design shown when a user has no stored preference — i.e. new accounts and unauthenticated pages. A stored `users.ui_theme` always wins. Set `classic` to revert the default **without** touching anyone's saved choice. |
| `COMMENTS_VISIBLE_TO_ALL` | Optional, default `true`. **TEMPORARY (Aug 2026).** When true, customers see *every* comment on an issue, not only those ticked "Share with customer". Set `false` to restore the phase22 staff-only filtering — `is_customer_visible` is still written on every comment, so the revert needs no data repair. |
| `DB_POOL_RECYCLE` | Optional, default `1800` (seconds). Retires a pooled connection 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. |
| `DB_POOL_SIZE` / `DB_MAX_OVERFLOW` | Optional, default `5` / `5`. Per-**worker** pool. Gunicorn runs `cpu*2+1` sync workers and each holds its own pool, so the ceiling is `workers x (size + overflow)` — the library defaults (5+10) put a 9-worker box at 135 against a `max_connections` of 151. A sync worker serves one request at a time and needs one connection in steady state; the overflow is headroom for the background email/notification threads. |
| `PHOTO_STAMP_ENABLED` | Optional, default `true`. Burns a capture-time + geo overlay into photos uploaded via `POST /api/v1/photos/upload`. Set `false` to store raw uploads. |
### Email SSL Auto-Detection
@@ -191,7 +201,34 @@ users: id, username (unique, indexed), full_name, email (unique, indexed),
password_set, set_password_token (indexed), set_password_token_expires
```
**Role ENUM:** `admin`, `director`, `inspector`, `project_manager`, `customer`
**Role ENUM:** `admin`, `director`, `inspector`, `project_manager`, `customer`, `auditor`, `external_inspector`
### The two customer-side roles (Phase 51)
Both roles below belong to the **customer**, not to us. They are the two seats the enrollment form offers, and both are created, invited, assigned, switched and disabled in **Customer Management** (`/customers`) — User Management excludes them entirely.
| Stored ENUM value | Display label | Scoped by | Capabilities |
|---|---|---|---|
| `customer` | **Customer Director** | `CustomerAssignment` (contract **or** single facility) | The portal, unchanged — read-mostly, own-facility issues/comments/follow-up requests |
| `external_inspector` | **Customer Inspector** | `InspectorAssignment` (whole contracts only) | Identical to the internal `inspector`, limited to their contracts, **plus the customer support surface** (AI chat + tickets) — see §7 `support` |
**This is a LABEL-only rename** — the same posture as rule 19 ("Project" → "Contract"). The ENUM values are unchanged, so phase51 needed **no user migration** and moved none of the ~63 `external_inspector` call sites or the many `role == 'customer'` checks. `User.ROLE_LABELS` is the one place the names live.
**`CUSTOMER_ROLES` is not interchangeable with `role == 'customer'` — see rule 89.** `User.CUSTOMER_ROLES = ('customer', 'external_inspector')` and `User.is_customer_account` answer an *account-management* question ("is this managed under /customers?"). Every *capability* check — the portal gates, `@customer_required`, `get_customer_scope()`, support chat, the customer branch in each API module, `notify_customers_for_facility()` — must keep testing `role == 'customer'` exactly, because a Customer Inspector is an **inspector** there.
**Switching between them**`POST /customers/<id>/switch-role`, admin-only. The two roles read different scoping tables, so the switch **mirrors the contracts across** (a bare role flip would leave the account correctly labelled and seeing nothing). Rows for the role being left are **kept**, not deleted. Two consequences worth knowing:
- Director → Inspector **widens** any facility-level narrowing to the whole contract — inspectors have no per-facility row. The confirm dialog and the flash both say so.
- Inspector → Director is **lossless on a round trip**: the reverse mirror skips contracts the account can already reach *by any* `CustomerAssignment` row, so it cannot stack a contract-wide grant on top of the original facility-level one.
API access changes in both directions (`external_inspector` has mobile-API access, `customer` is 403 everywhere), so the switch **revokes all `api_refresh_tokens` and deletes `api_device_tokens`** for the account — otherwise an issued JWT would keep working until expiry and a signed-in iPad would keep syncing.
**`external_inspector` (Phase 49) details, still current:** it has **exactly the same capabilities as `inspector`** and is scoped the **same way**`InspectorAssignment` rows resolved by `get_inspector_scope()`. Strict scoping applies unchanged: no assignments = sees nothing. `User.INSPECTOR_ROLES = ('inspector', 'external_inspector')` and the `User.is_inspector` property are the single definition — **every** capability/scoping check tests `is_inspector`, never `role == 'inspector'` (rule 87). `User.is_external_inspector` and `User.role_label` drive the badges: dashboard **Inspector Activity**, **Inspector Performance** report (HTML badge reads "Customer"; the Excel export suffixes the name cell `(Customer)` rather than gaining a column, so the index-based cell styling stays correct), and every assignee dropdown (`(Customer)` suffix — issues create/update, issue-list quick-assign, inspection flag-issue). Phase 51 changed those strings from "External"; the *attribute* names did not move (rule 84).
**Invited, never provisioned.** Neither customer role is given a password we chose. `customers.create()` stores the account with `password_set=False` and a random placeholder hash, mints a 72-hour `set_password_token`, and `_send_invite_email()` sends a link to **`/customers/set-password/<token>`** where they choose their own **username and password**. `login()` refuses `password_set=False` until they finish. `POST /customers/<id>/resend-invite` mints a fresh token and re-sends — without it a bounced or expired invitation leaves the account permanently unusable. Phase 51 moved this branch out of `auth.create_user()`, which now **requires** a password for every role it still offers (all of them ours).
Assignable (rule 80 set: `director`/`inspector`/`external_inspector`/`auditor`, plus `project_manager` on the inspection flag-issue dropdown), included in Inspector Performance and Inspector Activity, and has **mobile-API access**`external_inspector` is in the `_ALLOWED_ROLES` of every `app/api/*` module and falls into the inspector branch of every scoping check there. It gets its **own Notification Matrix column** (`external_inspector`, labelled "Customer Inspector"), whose defaults mirror the Inspector column (see §11), and both customer roles additionally support **per-account overrides** (§11).
**`auditor` (Phase 40):** A staff role with the **same access as `project_manager`** (it is included in `@project_manager_required` and everywhere `project_manager` is checked) **plus full issue-management powers** — create, assign, quick-assign, handler/vendor triage, request-verification, and verify/bulk-verify/verification-queue (via the new `@issue_manager_required` decorator). **Auditor does NOT get issue deletion** (that stays admin/director via `@supervisor_required`), nor any other admin/director-only area PM lacks (users, audit trail, notification matrix, customers, templates). Auditors are **assignable** as an issue/inspection assignee; **admin was removed** from the assignable set at the same time (assignee dropdowns are now `director`/`inspector`/`auditor`, plus `project_manager` on the inspection flag-issue dropdown). The issue-update route defensively keeps any pre-existing out-of-set assignee (e.g. a legacy admin assignment) in the dropdown so saving never silently unassigns. Auditor **has mobile-API access** — it is included in the `_ALLOWED_ROLES` set of every `app/api/*` module (comments, inspections, issues, photos, scheduled, stats, templates), so the iPad app accepts auditor logins. In every API endpoint that scopes by role, auditor falls into the non-inspector/non-customer (privileged) branch — org-wide data, same as admin/director/PM.
**Key property:** `display_name``full_name.strip()` or falls back to `username`.
@@ -206,7 +243,7 @@ areas: id, facility_id (FK), name, area_type,
**`public_token`** (Phase 34): unguessable per-facility token encoded in the facility's QR code. The QR points at `/f/<public_token>` — a **login-free** occupant summary page. `Facility.generate_public_token()` / `ensure_public_token()` mint one on demand; new facilities get one at creation, existing rows were backfilled by phase34. Rotating the token (regenerating it) invalidates any printed QR — intentional, for when a code is compromised.
**`Area.public_token`** (Phase 39): the same pattern applied per area. The QR points at `/f/area/<public_token>` — a **login-free** occupant summary scoped to that single area (its own avg score / inspection count / open-issue count / trend / recent inspection dates), with a "report a problem" form that files the issue with `area_id` set. `Area.generate_public_token()` / `ensure_public_token()` mirror the Facility methods; new areas get a token at creation, existing rows backfilled by phase39. Both public pages obey rule 74 (aggregate + dates only — never checklist names, per-inspection scores, or severity/SLA). Routing: `/f/area/<token>` and `/f/<token>` do not collide (tokens are single-segment; `area` is a literal first segment).
**`Area.public_token`** (Phase 39): the same pattern applied per area. The QR points at `/f/area/<public_token>` — a **login-free** occupant summary scoped to that single area (its own avg score / inspection count / open-issue count / trend / recent inspection dates), with a "report a problem" form that files the issue with `area_id` set. `Area.generate_public_token()` / `ensure_public_token()` mirror the Facility methods; new areas get a token at creation, existing rows backfilled by phase39. Both public pages obey rule 74 (aggregate quality only: rating, counts, trend, and recent inspections with date + quality label — never raw score percentages, checklist/template names, inspector names, per-item scores, or severity/SLA). Routing: `/f/area/<token>` and `/f/<token>` do not collide (tokens are single-segment; `area` is a literal first segment).
**`area_type` choices:** `restroom`, `lobby`, `hallway`, `office`, `kitchen`, `storage`, `floor`, `outdoor`, `other`
@@ -253,20 +290,24 @@ issues: id, inspection_id (nullable), area_id, facility_id (nullable), severity
handler_type ENUM('internal','facility','vendor') NOT NULL DEFAULT 'internal', ← Phase 35
facility_handler_name VARCHAR(100) nullable, ← Phase 35
facility_handler_contact VARCHAR(200) nullable, ← Phase 35
facility_handler_notes TEXT nullable ← Phase 35
facility_handler_notes TEXT nullable, ← Phase 35
internal_handler_name VARCHAR(100) nullable, ← Phase 41
internal_handler_contact VARCHAR(200) nullable ← Phase 42
```
**Handler (`handler_type`, Phase 35) — who is doing the work:**
| Value | Meaning | Detail fields | `assigned_to` role |
|---|---|---|---|
| `internal` (default) | Janitorial Staff (our crew) | — (the assignee IS the handler) | the handler |
| `internal` (default) | Janitorial Staff (our crew) | `internal_handler_name`/`internal_handler_contact` (Phase 41/42, free text — the crew member's name + phone/email, optional) | the handler |
| `facility` | The facility's own staff | `facility_handler_name/contact/notes` (free text) | internal **follow-up owner** |
| `vendor` | External contractor | `vendor_name/contact/notes` (Phase 26) | internal **follow-up owner** |
**Display labels are perspective-neutral** (they read the same for staff and customers) with a descriptor line under the selector and a tooltip on badges: `internal`**"Janitorial Staff"** ("Our janitorial crew handles it."), `facility`**"Facility Staff"** ("The facility's own on-site staff handle it."), `vendor`**"External Vendor"** ("An outside contractor handles it."). Labels/descriptions live in `Issue.HANDLER_LABELS` / `HANDLER_DESCRIPTIONS`, the WTForms `handler_type` choices, and the `HANDLER_DESC` JS map in both issue templates — keep these in sync. Do **not** use viewer-relative words like "Our"/"Your" for the stored categories.
`assigned_to` (a JQC User) is **always** available: it is the handler for `internal`, and the internal follow-up owner (e.g. the inspector who verifies/updates) for `facility`/`vendor`. Settable in **two places**, both with a "Handled By" selector that reveals the facility or vendor sub-fields via JS:
Free-text **`internal_handler_name`** (Phase 41) + **`internal_handler_contact`** (Phase 42, phone/email) capture the janitorial crew member's name and contact when `handler_type == 'internal'` — the actual person doing the work, who may not be a system User. They are distinct from `assigned_to` (the follow-up owner) and are revealed by the same "Handled By" selector JS as the facility/vendor blocks (`#internal_handler_block`). Displayed under a **"Staff"** row (name + contact) on the issue detail when set.
`assigned_to` (a JQC User) is **always** available: it is the handler for `internal`, and the internal follow-up owner (e.g. the inspector who verifies/updates) for `facility`/`vendor`. Settable in **two places**, both with a "Handled By" selector that reveals the janitorial/facility/vendor sub-fields via JS:
- **Log New Issue** form (`issues/form.html`) — at creation, for non-customer staff. Customer-created issues stay `internal` (the handler UI is hidden for them, same as `assigned_to`).
- **Update Issue** panel on the issue detail page (`issues/view.html`) — triage after creation.
@@ -277,13 +318,39 @@ Triage of `handler_type` + facility/vendor detail fields on the **update** panel
| Column | Type | Populated by | Displayed as |
|---|---|---|---|
| `photo_path` | `VARCHAR(255)` | Web form upload OR first iPad photo | "Photo Evidence" (primary) |
| `mobile_photo_paths` | `JSON` (`list[str]`) | iPad PATCH `/issues/<id>/photos` — extra evidence photos | "Photo Evidence" (additional) |
| `mobile_photo_paths` | `JSON` (`list[str]`) | iPad PATCH `/issues/<id>/photos` — extra evidence photos; **also public QR "report a problem" (photos 25)** | "Photo Evidence" (additional) |
| `result_photos` | `JSON` (`list[str]`) | Web update form file upload — resolution photos | "Resolution Details" |
**Rule:** Never write iPad evidence photos into `result_photos`. They belong in `mobile_photo_paths` so they appear under "Photo Evidence" on the web, not "Resolution Details".
**`reported_by`:** Added in phase18. Set at creation time to the user who filed the issue. Nullable for backward compatibility. Used by `GET /api/v1/issues` to return issues the inspector created but hasn't been assigned yet.
### TemplateContract (Phase 52)
```
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** — a customer's bespoke form must not be visible to, or startable against, another customer's facilities.
**No rows means the form is SHARED** (available on every contract), not "available nowhere". That convention is the whole migration story: 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. Inverting the default would silently hide every shared form from every contract.
`InspectionTemplate` helpers: `contract_ids`, `is_shared`, `available_for_project(project_id)`, `set_contracts([ids])` (does **not** commit), and the static **`available_query(project_id)`** — the single definition of "which forms may this contract use", used by every picker, by the POST validation behind it, and by the mobile API, so they cannot disagree. A facility with **no** contract can only use shared forms (fail-closed).
Managed by admin/director in **three** places, because the template screens have three separate edit paths — all must keep the picker or a form silently stays shared:
| Where | Route | Notes |
|---|---|---|
| **Edit Template modal** on the template list | `POST /templates/<id>/rename` | The one most people actually use. Posts a hidden `contracts_present=1` marker so an empty selection means "make it shared"; a POST **without** the marker (an older client, or another caller of this route) leaves the existing restrictions untouched rather than wiping them. Ids are validated against active contracts. |
| Create Template | `POST /templates/new` | |
| Full form editor | `POST /templates/<id>/edit` | `obj=` cannot read association rows, so the multi-select is seeded from `contract_ids` on GET. |
`duplicate_template()` copies the restrictions across — duplicating a customer's bespoke form must not yield a copy shared with everyone.
The template list shows a **Shared** badge or one badge per contract.
### Notification / NotificationPreference
```
@@ -292,6 +359,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 (Phase 54)
```
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** (decided Sep 2026). 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`** — rule 86's failure mode, 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.
**Scope is the thing to get right here — see rule 99.**
### IssueComment
```
@@ -357,6 +446,30 @@ notification_matrix: id, event_type, role_key, enabled, custom_emails (JSON)
UniqueConstraint(event_type, role_key)
```
### UserNotificationMatrix (Phase 51)
```
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. Each customer organisation states on its enrollment form which notifications each of its people wants, and the global matrix's grain (whole roles) cannot express that.
| Row state | Meaning |
|---|---|
| `enabled=True` | send even if the global column for this role is OFF |
| `enabled=False` | do not send even if the global column is ON |
| **no row** | **inherit** — follow the global column, including later changes to it |
Inherit is the default and the safe state, so the table shipped empty and changed routing for nobody. Setting a row back to inherit **deletes** it rather than snapshotting the current global value — that is what keeps an account that never expressed an opinion tracking the global matrix.
Helpers in `app/models/user_notification_matrix.py`: `overrides_for_user(user_id)``{event: bool}` (the editor), `overrides_for_event(event_type)``{user_id: bool}` (one query per dispatch, fails soft to `{}`), `set_overrides(user_id, {event: True|False|None})` (does **not** commit — caller owns the transaction, same contract as `notify()`).
Edited admin-side on the account's Customer Management page as a tri-state (Inherit / On / Off) with the global column's current value shown under "Inherit", plus Set-every-row shortcuts. **Each option is a `<label>` filling its table cell** — a bare centred `<input type=radio>` was effectively unclickable at touch/narrow widths, which is what made the editor look broken (see the `ipad_responsive.css` note below).
**Enforced in `notify()`, not only in `notify_by_matrix()` (Aug 2026).** A customer account's matrix governs every path that reaches it: matrix broadcasts, follower fan-out (`_notify_followers`), and direct assignee notifications all end at `notify()`. Gating only the matrix meant the editor offered rows — "Issue follow update", "Issue assigned" — that read as Off while the notifications kept arriving. Only an explicit `False` suppresses; no row means inherit. The recipient test uses `getattr(recipient, 'is_customer_account', False)` so an unavailable attribute **sends** rather than silently dropping. Staff roles are unaffected — they use the global matrix alone; `NotificationPreference` remains a different question (how to deliver, not whether to route).
### AuditLog
```
@@ -409,23 +522,73 @@ scheduled_inspections:
id, facility_id (FK→facilities CASCADE), template_id (FK→inspection_templates CASCADE),
inspector_id (FK→users SET NULL), frequency ENUM('once','daily','weekly','monthly'),
next_due_date DATE, active BOOL, notes TEXT, created_by, created_at,
last_completed_at DATETIME, advance_notified BOOL, due_notified BOOL, overdue_notified BOOL
last_completed_at DATETIME, advance_notified BOOL, due_notified BOOL, overdue_notified BOOL,
weekdays VARCHAR(20) nullable, -- Phase 43: CSV weekday ints, Mon=0, e.g. '0,2,4'
month_mode VARCHAR(20) nullable, -- Phase 43: 'day_of_month' | 'nth_weekday'
day_of_month SMALLINT nullable, -- Phase 43
nth_week SMALLINT nullable, -- Phase 43: 15, or -1 = last
nth_weekday SMALLINT nullable, -- Phase 43: 06, Mon=0
acknowledged_at DATETIME nullable -- Phase 47: inspector confirmed receipt
inspections.scheduled_inspection_id FK→scheduled_inspections SET NULL ← Phase 36
```
**Recurrence detail (Phase 43).** `frequency` says *how often*; these columns say *which day*:
| frequency | columns used | example |
|---|---|---|
| `once` / `daily` | none (all NULL) | — |
| `weekly` | `weekdays` | `'0,2,4'` → Mon/Wed/Fri |
| `monthly` + `month_mode='day_of_month'` | `day_of_month` | the 15th (clamped to the month's last day) |
| `monthly` + `month_mode='nth_weekday'` | `nth_week`, `nth_weekday` | 2nd Tuesday (`nth_week=-1` → last) |
All are nullable and **legacy phase36 rows keep NULLs**, falling back to `_add_interval()`'s "+7 days" / "same day next month" — no schedule changes cadence on deploy. `_apply_recurrence()` (in the blueprint) **clears the columns that don't apply** to the chosen frequency, so a weekly→monthly switch can't leave stale weekdays behind.
- `ScheduledInspection.next_occurrence_after(d)` — first occurrence strictly after `d`, honouring the rule. Used by `fulfill()`; a Mon/Wed/Fri schedule rolls Mon→Wed→Fri→Mon, so **one row yields three inspections a week**.
- `align_due_date(d)` — snaps the manager's picked start date forward to the first matching day (pick a Tuesday for Mon/Wed/Fri → get that Wednesday). Applied on both create and edit.
- `recurrence_label` — display string (`"Weekly · Mon, Wed, Fri"`), shown on the schedule list and the dashboard panel in place of the bare `frequency_label`.
- `weekday_list` / `set_weekdays()` — parse/format the CSV column. `ScheduledInspectionForm(obj=sched)` copies the raw CSV into the multi-select, so the edit route re-assigns `form.weekdays.data = sched.weekday_list` on GET.
- A requested 5th weekday that doesn't exist in a month falls back to the 4th; `day_of_month=31` clamps to Feb 28/29. Every month yields a valid date.
**Duplicate-Start guard.** `start()` returns the existing `in_progress` inspection for the schedule instead of creating a second one, and both the schedule list and the dashboard panel show **Continue** (via `_open_inspection_ids()`) rather than **Start** while one is underway.
**A plan, not an inspection.** Names a facility + template + assigned inspector + `next_due_date`. Lifecycle:
- The assigned inspector (or a manager) clicks **Start**`scheduled_inspections.start` creates a normal `in_progress` Inspection with `scheduled_inspection_id` set, then redirects to the execute flow.
- On **completion** (execute route, status → `completed`), `ScheduledInspection.fulfill()` runs in the same atomic commit: `once``active=False`; recurring → `next_due_date` rolls forward past today via `_add_interval()` and the three `*_notified` flags reset.
- **End date (phase44).** `end_date` is the manager's boundary; NULL = forever, and it is forced NULL for `once`. Inclusive — an occurrence landing exactly on it still runs. Two enforcement points, both needed: `fulfill()` deactivates when the rolled-forward `next_due_date` passes the boundary (the schedule that ends by being *completed*), and `run_reminders()` calls `expire_if_past_end_date()` on every active schedule before doing any reminder work (the schedule that reaches its boundary *without ever being done* — otherwise it re-alerts as overdue forever). `next_due_date` is left unclamped on expiry so the row shows which occurrence it stopped before.
- **Form validation.** `ScheduledInspectionForm.validate()` rejects an end date on a one-time schedule and one earlier than the due date. That is not sufficient alone: `align_due_date()` can push the picked date forward onto the rule (a Tuesday pick on a Mon/Wed/Fri schedule becomes Wednesday), so `_reject_if_past_end_date()` re-checks after `_apply_recurrence()` in both create and edit. Edit rolls back first — `sched` is persistent and already mutated at that point.
- **Three status states** in the list: Active, **Ended** (`is_expired` — ran its course), Inactive (a manager switched it off).
- **Assignment notification** (immediate): on **create**, the assigned inspector gets an in-app + email "assigned to you" notification; on **edit**, only when the inspector actually changes (a "reassigned to you" notification to the new assignee). Via `_notify_assignee()` in the blueprint using `event_type=EVENT_SCHEDULED_INSPECTION`.
- **Receipt acknowledgement (phase47).** `acknowledged_at` records when the assigned inspector confirms they received the request — a way for the manager to see the assignment was seen, distinct from starting it. `POST /<id>/acknowledge` is **assignee-only** (like Start; a manager cannot confirm on someone's behalf) and idempotent; on first confirm the schedule's **creator** is notified via `_notify_creator_acknowledged()` (`event_type=EVENT_SCHEDULED_INSPECTION`, skipped when creator is inactive or is the inspector). The stamp-log-notify body is factored into `_do_acknowledge(sched, actor_username)`, shared by the POST route and the email-token route. **Once per assignment, not per occurrence:** it is NOT reset when a recurring schedule rolls forward (`fulfill()` leaves it), but the **edit route resets it to NULL when the inspector changes** so a new assignee must re-confirm. Surfaced with a Confirmed/Awaiting badge + a "Confirm receipt" button (assignee only, active schedules) on both the scheduled-inspections **list** and the **dashboard** panel. `ScheduledInspection.is_acknowledged` is the boolean helper. Exposed **read-only** in the API payload (`acknowledged_at`) — confirming stays on the web per rule 77; an iPad confirm action would be a follow-up.
- **Confirm from the email (phase47).** The assignment email (and the advance/due reminder emails while still unconfirmed) carry a green **"Confirm receipt"** button beside "View Details". It links to `GET /scheduled-inspections/confirm/<token>` — a **login-free** landing (no `@login_required`, same pattern as the `public` blueprint) authorised by an `itsdangerous.URLSafeTimedSerializer` token (salt `scheduled-inspection-ack`, 30-day max age, signed with `SECRET_KEY`**no DB column**). The token binds `{sid, iid}` so a schedule **reassigned** to another inspector invalidates the previous assignee's emailed link (the route checks `sched.inspector_id == token iid`). The route renders the standalone `scheduled_inspections/confirm_result.html` with a `status` of confirmed / already / reassigned / inactive / expired / invalid / missing; the acknowledgement is idempotent so a re-click or email-client prefetch is harmless. The email button is built by `_confirm_action(sched)` (returns None once acknowledged), threaded into `notify(..., extra_action={'label','url'})` — an **email-only** second button; `extra_action.url` is absolute and is NOT prefixed with `base_url`. In-app notifications are unchanged.
- **List tabs (phase47).** The scheduled-inspections list (`index()`) has two tabs via `?tab=pending|completed` (default `pending`): **Pending** = active schedules (`active == True`, ordered by next due date), **Completed** = closed schedules (`active == False`, ordered by `last_completed_at` desc — fulfilled one-times, ended recurring, and manually-deactivated rows). Exhaustive, non-overlapping partition; the in-row Status badge (Active / Ended / Inactive) disambiguates the closed bucket, and a "last completed" date is shown when set. `pending_count` / `completed_count` drive the tab pill badges.
- **Reminders** are dispatched by the cron endpoint (see §11): advance (1 day before) + due-date to the inspector, overdue to admin/director — each fires at most once per occurrence via the `*_notified` flags. Uses `notify()` with `event_type=EVENT_SCHEDULED_INSPECTION`.
- Dashboard shows an **upcoming (next 7 days) / overdue** panel for non-customers (inspectors see only their own).
- **Instructions (July 2026).** `ScheduledInspectionForm.notes` is labelled **"Instructions"** and `scheduled_inspections/form.html` explains that the text reaches the inspector. The *field name*, `ScheduledInspection.notes`, the `scheduled_inspections.notes` column and the API key `notes` are all unchanged — the rename is a label only (rule 84). The text is surfaced to the inspector in two places: `inspections/execute.html` renders an indigo panel between the header and the form grid, guarded on `inspection.scheduled_inspection and .notes` (NULL for ad-hoc work and for schedules deleted after the start); the iPad shows it on the scheduled row, on the start screen and above the form.
- **"Scheduled" badge:** an inspection started from a schedule carries `scheduled_inspection_id`. `Inspection.scheduled_inspection` (relationship, foreign_keys on that column) resolves the source schedule (None if ad-hoc or the schedule was later deleted). The inspection **detail** view header shows a `bi-calendar-check` "Scheduled · <frequency>" badge, and the inspection **list** shows a compact "Scheduled" pill next to the template name — both gated on `scheduled_inspection_id` being set.
Management (`/scheduled-inspections/new|edit|delete`) is `@project_manager_required`; **Start** is the assigned inspector or a manager; inspectors' list/dashboard views are scoped to their own `inspector_id`.
**Customer Directors can plan their own inspections (Aug 2026).** A client asking for an extra clean no longer has to go through us. `@schedule_manager_required` (in the blueprint) = the old `@project_manager_required` set **plus `role == 'customer'`**; `@customer_required` is not reused because this is a capability grant, not a portal gate.
Everything a Customer Director sees is narrowed to the facilities on their `CustomerAssignment` rows, and **the narrowed choice lists ARE the POST validation**`SelectField` rejects anything not offered, so this is the security boundary rather than a tidier dropdown:
| List | Narrowed to |
|---|---|
| facility_id | `get_customer_scope()` — deliberately breaking rule 61's "all facilities" convention, which exists so the UI-only contract selector cannot fail validation; for a customer the scope is the point |
| contract selector (`_active_contracts`) | the contracts behind those facilities |
| template_id | shared forms + those attached to their contracts (phase52), so another customer's bespoke form NAMES never appear (rule 96) |
| inspector_id | inspectors holding an `InspectorAssignment` on their contracts — theirs and ours, never another client's Customer Inspector (rule 93) |
Plus `_reject_facility_out_of_scope()` re-checks the chosen facility after validation (belt-and-braces: the choice-narrowing is a property of how a list was *built*), `_schedule_in_scope()` 403s edit/delete of a schedule outside their facilities, and `index()` filters the list — with `filter(False)` on an empty scope, never a skipped filter (rule 57's failure mode).
**Customer Inspectors are excluded**`_is_customer_director()` tests `role == 'customer'` by equality (rule 89). They *perform* scheduled inspections and are scoped by `InspectorAssignment`; handing them this screen would scope it by the wrong table and show no facilities at all. **Start** is unchanged and still assignee-only, so a Customer Director can plan work but never execute it.
Management of the underlying routes is otherwise unchanged; **Start** is the **assigned inspector ONLY** (`sched.inspector_id == current_user.id`) — managers do NOT get a Start button and `GET /<id>/start` 403s for anyone who isn't the assignee (the inspection is theirs to do; a manager who must run it assigns it to themselves). The Start button is hidden for non-assignees on both the scheduled-inspections list and the dashboard panel. Inspectors' list/dashboard views are scoped to their own `inspector_id`.
---
## 6. Role & Permission Matrix
**`auditor` reads as a `project_manager` column** below, with these overrides: **Issues (quick-assign)** ✅, **Issue verification** ✅, and it appears in the **Issues (create/assign)** and **Issue verification** rows as ✅. It never gains issue *delete* or any admin/director-only row PM lacks. See the `auditor` note in §5.
| Area | admin | director | project_manager | inspector | customer |
|---|---|---|---|---|---|
| Dashboard | ✅ full | ✅ full | ✅ full | ✅ limited | ✅ scoped |
@@ -438,11 +601,14 @@ Management (`/scheduled-inspections/new|edit|delete`) is `@project_manager_requi
| Contracts | ✅ | ✅ | ✅ | read | scoped |
| Templates | ✅ | ✅ | ❌ | ❌ | ❌ |
| Inspections (execute) | ✅ | ✅ | ✅ | ✅ | read |
| Inspection follow-up (request + assign) | ✅ | ✅ | ❌ | ❌ | ✅ own facilities |
| Scheduled inspections (plan) | ✅ | ✅ | ✅ | ❌ | ✅ own contracts |
| Inspection follow-up (clear) | ✅ | ✅ | ❌ | ❌ | ❌ |
| Issues (create/assign) | ✅ | ✅ | ✅ | ✅ | ✅ create own |
| Issues (quick-assign) | ✅ | ✅ | ❌ | ❌ | ❌ |
| Issue verification | ✅ | ✅ | ❌ | ❌ | ❌ |
| Issue comments | ✅ | ✅ | ✅ | ✅ | followed/reported issues only |
| Support Chat (AI) | ❌ | ❌ | ❌ | ❌ | ✅ |
| Support Chat (AI) | ❌ | ❌ | ❌ | ❌ | ✅ (both customer roles) |
| Support Tickets (manage) | ✅ | ✅ | ❌ | ❌ | own only |
| Reports | ✅ | ✅ | ✅ | ✅ | scoped |
| Scheduled Reports | ✅ | ✅ | ✅ | ❌ | ❌ |
@@ -454,8 +620,13 @@ Management (`/scheduled-inspections/new|edit|delete`) is `@project_manager_requi
```python
@admin_required # role == 'admin' only
@supervisor_required # role in ('admin', 'director') — name kept to avoid touching 30+ routes
@project_manager_required # role in ('admin', 'director', 'project_manager')
@project_manager_required # role in ('admin', 'director', 'project_manager', 'auditor')
@issue_manager_required # role in ('admin', 'director', 'auditor') — issue verification (NOT delete)
@customer_required # role == 'customer' only
# Not a decorator, but the same idea for the two inspector roles:
# user.is_inspector → role in ('inspector', 'external_inspector')
# Never write `role == 'inspector'` for a capability or scoping check.
```
---
@@ -466,19 +637,20 @@ Management (`/scheduled-inspections/new|edit|delete`) is `@project_manager_requi
|---|---|---|
| `auth` | `/auth` | `/login`, `/logout`, `/profile`, `/users/*`, `/notification-matrix` |
| `dashboard` | `/` | `GET /`, `/facility-trend` (AJAX) |
| `facilities` | `/facilities` | CRUD + area management + QR code: `/<id>/qr` printable page, `/<id>/qr.png` image, `POST /<id>/qr/regenerate` (invalidates old printed code), `/qr/print-all[?contract_id=]` bulk sheet. **Per-area QR (Phase 39):** `/areas/<id>/qr`, `/areas/<id>/qr.png`, `POST /areas/<id>/qr/regenerate` — mirror the facility QR routes; scope enforced by `_area_for_qr_or_403()` via the area's parent facility. **Customers may use all QR actions (including regenerate) for their own assigned facilities**; inspectors/PM/admin/director for any. Scope enforced by `_facility_for_qr_or_403()` (customers) / `get_customer_scope` (print-all). Regenerate is limited to admin/director + scoped customer (PM/inspector excluded). |
| `public` | `/f` | **No login.** `GET /<token>` occupant facility summary + `POST /<token>/report` occupant issue report; `GET /area/<token>` per-area summary + `POST /area/<token>/report` (Phase 39, files with `area_id` set). All report POSTs rate-limited `5/hour`, honeypot-guarded. Resolves ACTIVE facility (area's parent must be active) by `public_token` or 404. |
| `facilities` | `/facilities` | CRUD + area management + QR code: `/<id>/qr` printable page, `/<id>/qr.png` image, `POST /<id>/qr/regenerate` (invalidates old printed code), `/qr/print-all[?contract_id=]` bulk sheet. **Per-area QR (Phase 39):** `/areas/<id>/qr`, `/areas/<id>/qr.png`, `POST /areas/<id>/qr/regenerate` — mirror the facility QR routes; scope enforced by `_area_for_qr_or_403()` via the area's parent facility. **Customers may use all QR actions (including regenerate) for their own assigned facilities**; inspectors/PM/admin/director for any. Scope enforced by `_facility_for_qr_or_403()` (customers) / `get_customer_scope` (print-all). Regenerate is limited to admin/director + scoped customer (PM/inspector excluded). **QR print/export page:** `GET /qr/print-all` is a selectable sheet with filters `?contract_id=` / `?facility_id=` / `?include_areas=1` (contract narrows the facility dropdown; areas render each facility's per-area QR cards). Each card is a `<label>` wrapping a checkbox; **Print Selected** (JS toggles `body.print-selected-only` so `@media print` hides unticked cards) and **Export Selected to PDF** (`POST /qr/export-pdf`, repeated `facility_ids`/`area_ids`, scope re-checked per id via the `_*_for_qr_or_403()` helpers, streams `generate_qr_codes_pdf()` output; logs `ACTION_EXPORT`). Inspectors 403. QR PNG bytes for the PDF come from `_qr_png_bytes(url)`. |
| `public` | `/f` | **No login.** `GET /<token>` occupant facility summary + `POST /<token>/report` occupant issue report; `GET /area/<token>` per-area summary + `POST /area/<token>/report` (Phase 39, files with `area_id` set). Report form accepts **up to 5 photos** (`_save_report_photos()``photo_path` + `mobile_photo_paths`). All report POSTs rate-limited `5/hour`, honeypot-guarded. Resolves ACTIVE facility (area's parent must be active) by `public_token` or 404. |
| `projects` | `/projects` | CRUD + customer assignment management + notification-recipient add/remove (`/<id>/notify-recipients/add`, `/notify-recipients/<rid>/remove` — admin only) |
| `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) |
| `customers` | `/customers` | **Owns BOTH customer roles (Phase 51).** `GET /` list (both roles, role badge + per-role scope column), `GET/POST /new` invite (role select: Customer Director / Customer Inspector — same invitation flow for both), `/set-password/<token>`, `GET /<id>` manage, `/<id>/edit`, `POST /<id>/assignments/add` + `/assignments/<aid>/remove` (**director only** — `CustomerAssignment`), `POST /<id>/contracts` (**inspector only** — replaces the whole `InspectorAssignment` set, rule 59 semantics), `POST /<id>/notifications` (per-account matrix overrides), `POST /<id>/switch-role` (**admin only** — mirrors contracts across, revokes tokens/devices), `POST /<id>/toggle-active`, `POST /<id>/resend-invite`, import CSV |
| `inspections` | `/inspections` | list, start, execute, view, PDF export, flag-issue, save-draft (AJAX), flag-followup, reinspect, upload-photo (AJAX), **`POST /bulk`** (bulk export-PDF / request-follow-up / clear-follow-up / delete from the list) |
| `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 — Phase 54), **`POST /bulk`** (bulk assign / status / verify / delete from the list). **verify / bulk-verify / verification-queue are `@issue_manager_required` (admin/director/auditor); delete stays `@supervisor_required` (admin/director).** |
| `notifications` | `/notifications` | list, mark-read, preferences, send-digest (cron), check-sla (cron), cleanup-tokens (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 |
| `scheduled_reports` | `/scheduled-reports` | CRUD + manual trigger (accessible via Reports sub-nav) |
| `scheduled_inspections` | `/scheduled-inspections` | list, new/edit/delete (PM+), `GET /<id>/start` (assigned inspector or manager → creates linked inspection), `POST /run` (cron reminders, `token=DIGEST_SECRET`) |
| `support` | `/support` | `GET /chat` (loads latest saved session; `?new=1` to start fresh), `POST /chat/message` (AJAX→Groq; **persists** user+assistant turns, returns `session_id`), `GET /my-conversations`, `GET /my-conversations/<id>` (customer chat history), `GET /admin/conversations`, `GET /admin/conversations/<id>` (staff, read-only), `GET /admin/knowledge` + `/new`, `/<id>/edit`, `/<id>/delete` (admin/director — chatbot knowledge base), `POST /tickets`, `GET /my-tickets`, `GET/POST /my-tickets/<id>`, `GET /admin/tickets`, `GET/POST /admin/tickets/<id>` |
| `scheduled_inspections` | `/scheduled-inspections` | list (`?tab=pending\|completed` — Phase 47), new/edit/delete (`@schedule_manager_required` — admin/director/PM/auditor **plus Customer Directors, scoped to their own contracts**), `GET /<id>/start` (**assigned inspector only** → creates linked inspection; 403 for non-assignees incl. managers), `POST /<id>/acknowledge` (**assigned inspector only** → confirms receipt, sets `acknowledged_at`, notifies creator; idempotent — Phase 47), `GET /confirm/<token>` (**login-free** one-click email confirm; signed `itsdangerous` token binding schedule+inspector — Phase 47), `POST /run` (cron reminders, `token=DIGEST_SECRET`) |
| `support` | `/support` | **Customer-facing routes serve BOTH customer roles** (`_is_customer_side()`, Aug 2026) — a Customer Inspector gets the same chat, conversations and tickets, with facilities resolved through `InspectorAssignment` and a role-specific addendum on the AI system prompt. `GET /chat` (loads latest saved session; `?new=1` to start fresh), `POST /chat/message` (AJAX→Groq; **persists** user+assistant turns, returns `session_id`), `GET /my-conversations`, `GET /my-conversations/<id>` (customer chat history), `GET /admin/conversations`, `GET /admin/conversations/<id>` (staff, read-only), `GET /admin/knowledge` + `/new`, `/<id>/edit`, `/<id>/delete` (admin/director — chatbot knowledge base), `POST /tickets`, `GET /my-tickets`, `GET/POST /my-tickets/<id>`, `GET /admin/tickets`, `GET/POST /admin/tickets/<id>` |
| `enrollment` | `/enrollment` | **Self-contained onboarding intake — see §24.** `GET/POST /` (**login-free** public form), `GET /admin` (admin inbox), `GET/POST /admin/<id>` (detail + office-use fields), `GET /admin/<id>.json`, `GET /admin/export.csv`. Lives in `app/enrollment/` with its own templates; touches **no** DB table. |
| `broadcast` | `/admin/broadcast` | `GET /` (compose + history), `POST /send` (admin-only; fans out one Notification per targeted user) |
| `devices` | `/admin/devices` | `GET /` (device list from `api_device_tokens`), `POST /notify` (admin-only) |
| `api` | `/api/v1` | parent blueprint |
@@ -510,6 +682,10 @@ Management (`/scheduled-inspections/new|edit|delete`) is `@project_manager_requi
So with no DNS work an invite from `jqc.govservicesinc.com` sends `From: "Gov Services QC" <jqc.noreply@ltservicesinc.com>` (branded name, deliverable address). After that domain's SPF `include:` + DKIM are live, add it to `SENDER_AUTHORIZED_DOMAINS` and it upgrades to `<jqc.noreply@govservicesinc.com>` — no code change. Falls back to the bare authenticated sender string for unparseable hosts (localhost, empty). Edit `BRAND_NAMES` / `SENDER_AUTHORIZED_DOMAINS` as brands and DNS come online. See rule 64.
### `decorators.py` — `return_url(fallback)`
Reads the `next` value a list-page action carried (POST body first, then query string), validates it with `safe_redirect_url`, and falls back. This is what makes an edit or delete return to the **filtered** list instead of the bare index. `next` is the FULL list URL — never a reconstructed argument set — so adding a filter to either list page needs no change here. See §18 "List filter preservation".
### `scope.py`
`get_customer_scope(user)` — returns `list[int]` facility IDs for customers, `None` for non-customers.
`get_inspector_scope(user)` — returns `list[int]` facility IDs for inspectors (empty list = no assignments = no access), `None` for non-inspectors. Derived from `InspectorAssignment` rows → project → active facilities.
@@ -531,6 +707,7 @@ ReportLab-based. 12-column grid must be preserved — never collapse in PDF view
- `generate_issues_list_pdf(issues, filter_summary)` — landscape issues list PDF (from issues list export)
- `generate_inspections_list_pdf(inspections, filter_summary)` — landscape inspections list PDF
- `generate_facility_summary_pdf(facility, days, start, now, total_inspections, avg_score, area_scores, open_issues, resolved_count)` — customer-facing one-page facility summary PDF (Phase R4)
- `generate_qr_codes_pdf(items, filter_summary='')` — grid of selected facility/area QR codes. `items` = list of `{title, subtitle, caption, png(bytes)}`; 3-per-row portrait sheet. Backs `POST /facilities/qr/export-pdf`.
**`_build_styles()` registered style names:** `ReportTitle`, `ReportSub`, `SectionHead`, `FieldLabel`, `FieldValue`, `MetaLabel`, `MetaValue`, `IssueDesc`, `FooterStyle`, `SummaryTitle`, `ReportSubtitle`, `Meta`, `ScoreValue`, `ScoreLabel`, `SectionHeader`, `TableHeader`, `TableCell`
@@ -556,8 +733,8 @@ The last eight styles (`SummaryTitle` through `TableCell`) were added for the fa
|---|---|---|
| `GET /api/v1/facilities` | jwt_required | All active facilities scoped to user |
| `GET /api/v1/facilities/<id>/areas` | jwt_required | Areas for a facility |
| `GET /api/v1/templates` | jwt_required | Template list (summary, no form_schema) |
| `GET /api/v1/templates/<id>` | jwt_required | Full template with form_schema |
| `GET /api/v1/templates` | jwt_required | Template list (summary, no form_schema). **Contract-scoped (phase52):** an inspector gets shared forms plus those attached to their assigned contracts. Optional `?project_id=` / `?facility_id=` narrows to one contract — **and is intersected with the caller's own scope**, so passing another customer's facility id returns `[]` rather than listing their form names. |
| `GET /api/v1/templates/<id>` | jwt_required | Full template with form_schema. **404** (not 403) when the form is restricted to a contract the caller cannot reach — whether another customer's form exists is not their business. |
### Phase B Endpoints
@@ -566,7 +743,7 @@ The last eight styles (`SummaryTitle` through `TableCell`) were added for the fa
| `POST /api/v1/inspections` | jwt_required | Create inspection; idempotent via `mobile_local_id` |
| `PATCH /api/v1/inspections/<id>` | jwt_required | Update inspection (draft → completed) |
| `POST /api/v1/issues` | jwt_required | Create issue; idempotent via `mobile_local_id`; accepts `result_photos` list stored in `mobile_photo_paths` |
| `POST /api/v1/photos/upload` | jwt_required | Multipart photo upload; returns `server_path` |
| `POST /api/v1/photos/upload` | jwt_required | Multipart photo upload; returns `server_path`, `stamped`, `captured_at`, `capture_source`. Optional form fields `captured_at` (ISO-8601), `latitude`, `longitude` drive the burned-in timestamp/geo overlay — see §23. |
### Phase C Endpoints
@@ -608,11 +785,18 @@ The last eight styles (`SummaryTitle` through `TableCell`) were added for the fa
| Endpoint | Auth | Description |
|---|---|---|
| `GET /api/v1/scheduled-inspections` | jwt_required | Active scheduled/recurring assignments (`app/api/scheduled.py`, new blueprint). **Inspector:** only rows where `inspector_id == self`. **admin/director/PM:** all active. Sorted by `next_due_date`. Returns per row: `id`, `facility_id`, `facility_name`, `template_id`, `template_name`, `inspector_id`, `frequency`, `frequency_label`, `next_due_date` (ISO date), `is_overdue`, `notes`, plus `total`/`limit`/`offset`. Powers the iPad "Scheduled" section on Dashboard + My Inspections. Read-only — the schedule lifecycle (fulfil/roll-forward) stays web-driven; the iPad "Start" just seeds the new-inspection flow. |
| `GET /api/v1/scheduled-inspections` | jwt_required | Active scheduled/recurring assignments (`app/api/scheduled.py`, new blueprint). **Inspector:** only rows where `inspector_id == self`. **admin/director/PM:** all active. Sorted by `next_due_date`. Returns per row: `id`, `facility_id`, `facility_name`, `template_id`, `template_name`, `inspector_id`, `frequency`, `frequency_label`, `next_due_date` (ISO date), `is_overdue`, `notes`, plus `total`/`limit`/`offset`. Powers the iPad "Scheduled" section on Dashboard + My Inspections. Read-only *as a collection* — schedules are created/edited on the web only — but the iPad **does** fulfil them by submitting an inspection with `scheduled_inspection_id` (see below). |
| `PATCH /api/v1/issues/<id>/handler` | jwt_required | Set "Handled By" from the iPad (`update_issue_handler`). Body: `{ "handler_type": "internal"\|"facility"\|"vendor", ...optional detail keys }`. Detail keys (`facility_handler_name/contact/notes`, `vendor_name/contact/notes`) are updated only when present; empty string clears a field. **`log_action()` after commit.** |
**Handler permission divergence — deliberate (see rule 78).** The web issue form limits handler edits to admin/director/PM. This API endpoint additionally allows the assigned **inspector**, scoped by `get_inspector_scope()` (403 if the issue's facility isn't in their contracted set). The iPad is a field tool; inspectors set the handler from Issue Detail. Do not "align" the API back to the web restriction without explicit direction.
**Schedule fulfilment from the iPad (July 2026 fix).** `POST /api/v1/inspections` and `PATCH /api/v1/inspections/<id>` both accept **`scheduled_inspection_id`**, and both call `_fulfill_schedule()` in the same atomic commit when the inspection reaches `completed` — mirroring the web execute route. Previously the iPad's Start passed only facility + template, so the inspection landed with `scheduled_inspection_id = NULL`: the schedule was never fulfilled (banner stayed on every dashboard, iPad "Scheduled" section never cleared) and the web inspection list showed no "Scheduled" badge. All three symptoms had this single cause.
- **PATCH fulfils only on the `draft → completed` transition**, so re-PATCHing a completed inspection can't roll a recurring schedule forward twice. POST is guarded by the existing `mobile_local_id` idempotency check (a duplicate returns early, before fulfilment).
- **`_resolve_schedule()` is deliberately NON-BLOCKING** — see rule 83.
- `_inspection_payload()` returns `scheduled_inspection_id`.
- No SyncManager change was needed: `pullScheduledInspections()` already runs after `processInspectionQueue()` in the same `triggerSync()` pass and deletes rows the server no longer returns, so the iPad banner clears on the same sync that submits the inspection.
The new `scheduled` blueprint is registered in `app/api/__init__.py` and CSRF-exempted in `app/__init__.py` (`csrf.exempt(_api_scheduled_bp)` — parent-exempt does not cascade to child blueprints, per the CSRF pattern above).
No migration was needed for either feature: the `scheduled_inspections` table (phase36) and the issue handler columns (phase35) already existed; both additions are pure serialization + one new route.
@@ -640,6 +824,7 @@ No migration was needed for either feature: the `scheduled_inspections` table (p
'handler_label', # human-readable label (Issue.handler_label property)
'facility_handler_name', 'facility_handler_contact', 'facility_handler_notes', # nullable
'vendor_name', 'vendor_contact', 'vendor_notes', # nullable
'internal_handler_name', 'internal_handler_contact', # Phase 41/42 — janitorial staff name + contact (nullable)
}
```
@@ -702,8 +887,70 @@ EVENT_CUSTOMER_INSPECTION_DONE = 'customer_inspection_completed'
EVENT_CUSTOMER_ISSUE_UPDATED = 'customer_issue_updated'
EVENT_SCORE_ALERT = 'score_alert' ← Phase 27
EVENT_SCHEDULED_INSPECTION = 'scheduled_inspection' ← Phase 36
EVENT_FOLLOWUP_REQUESTED = 'followup_requested' ← Phase 46
```
### External Inspector column (Phase 49)
`MATRIX_ROLES` gains `('external_inspector', 'External Inspector')`, and `notify_by_matrix()`'s `role_to_db` map routes it to the `external_inspector` DB role. `MATRIX_DEFAULTS` **mirrors** the Inspector column for every event (a comprehension, not 14 more literals) so a future event added for `inspector` automatically gets a matching external default. The `inspection_completed` scoping below applies to **both** inspector columns — without that, enabling the External column would notify every third-party inspector on every submission.
### Inspector role scoping for `inspection_completed`
`notify_by_matrix()` special-cases the **inspector** role for the `inspection_completed` event: instead of notifying every active inspector, it notifies **only the inspection's own inspector** (`Inspection.inspector_id`, resolved from the passed `inspection_id`). So enabling the "Inspector" column for "Inspection completed" in the matrix alerts just the inspector who submitted that inspection — not the whole inspector pool. All three dispatch sites (web `routes/inspections.py`, both mobile-API `api/inspections.py`) pass `inspection_id`, so the scoping applies uniformly; if `inspection_id` is ever omitted for this event, the inspector role notifies no one (fail-closed). Other roles/events are unaffected.
### Customer-requested follow-up (Phase 46)
`inspections.flag_followup` is no longer `@supervisor_required`. It gates in the body instead: **admin/director** as before, **plus customers for their own facilities** — a client unhappy with a result asks for a re-inspection directly instead of going through support. Inspector / PM / auditor stay refused (403).
Customers can only *request*. `clear_followup` remains admin/director, `reinspect()` still refuses customers, and the "Start Re-inspection" button inside the follow-up alert is hidden from them (it 403'd on click before). Three extra customer-only guards in the route:
- facility must be in `get_customer_scope()` — else 403 (a crafted POST must not reach another client's inspection);
- inspection must be `completed` — nothing to follow up on otherwise;
- if `follow_up_required` is already set the request is a **no-op**, so a repeat submission can't overwrite the pending note/attribution.
**Attribution** (`follow_up_requested_by` / `follow_up_requested_at`, phase46) records who asked and when; `clear_followup` nulls both. `inspections/view.html` renders a "Requested by customer" / "Requested by staff" badge from `inspection.follow_up_requester.role`, so staff can see at a glance that a client is waiting.
**Dispatch** goes through `notify_by_matrix(EVENT_FOLLOWUP_REQUESTED, ...)` — the new `followup_requested` matrix event (admin/director/PM on by default). The inspection's own inspector is notified directly by the route and passed in `exclude_user_ids` so they aren't double-notified; the requester is excluded too. Routing via the matrix (rather than hardcoding managers) is what makes per-contract recipients fire — rule 73. Without it a customer request would reach only the inspector and nobody would own scheduling the re-inspection.
### Assigning a follow-up to another inspector (Phase 53)
A follow-up used to belong implicitly to whoever performed the original inspection: they were the one notified, and `GET /api/v1/inspections?follow_up_required=true` filtered on `inspector_id == caller`, so nobody else could even see it. `inspections.follow_up_assigned_to` (FK → users, SET NULL) lets a director — or a **Customer Director**, for their own facilities — hand the re-inspection to someone else.
**NULL means what it always meant**: the follow-up belongs to the inspection's own inspector. No backfill, no behaviour change for existing rows. `Inspection.follow_up_owner` (assignee *or* inspector) is the single definition of ownership, so the web display, the notification and the API filter cannot disagree.
**The assignee takes over.** Only the owner is notified, and only the owner sees it — the original inspector's list no longer shows a follow-up that was handed to someone else. In the API that means the two arms must be mutually exclusive:
```python
Inspection.follow_up_owned_by(user.id) # models/inspection.py
# → follow_up_assigned_to == uid OR (follow_up_assigned_to IS NULL AND inspector_id == uid)
```
Without the `is_(None)` on the second arm the original inspector keeps seeing it and two people turn up to do the same re-inspection.
**Ownership is stated twice and both live in the model**`follow_up_owner` for a loaded row, `follow_up_owned_by(user_id)` for a query. Three surfaces scope follow-ups: the mobile list filter, the web dashboard card, and the iPad stats KPI. They each wrote their own version at first and the two dashboards tested AUTHORSHIP, so an assignee saw the work in their list while both cards read 0 — the stats KPI disagreeing with the Follow-up Requests list directly beneath it. All three now call the predicate.
**The generic "inspectors see only their own inspections" filter has to be deferred** when `follow_up_required=true` is requested — an assigned follow-up lives on an inspection somebody *else* performed, so applying authorship first hides exactly the rows the assignee needs.
**Read access follows the LIST, not authorship (Aug 2026).** `index()` scopes an inspector by FACILITY (rule 58 — their scope covers all data in their contracted facilities), but `view()` and `export_pdf()` scoped by authorship, so the list offered rows that answered "Access denied" on click. It also blocked the assignee from opening the parent inspection they had just been asked to re-inspect — the button they needed was on a page they could not reach. 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.
**A live follow-up has exactly one owner.** `reinspect()` lets the follow-up's owner start it; when a follow-up is assigned, even the original inspector is refused ("assigned to someone else") — that is the point of assigning it. With no follow-up outstanding, an inspector may re-inspect their own work and nobody else's. The Re-inspect buttons render only for `is_own_inspection or owns_follow_up`, so the page never shows a control that fails on click.
**The picker is contract-scoped** (`_followup_assignees_for()`), for the same reason the flag-issue list is (rule 93): a Customer Director must never see, or assign work to, another client's inspector. Only the two INSPECTOR roles are offered — directors/PMs/auditors hold no `InspectorAssignment`, so they could not open the re-inspection anyway. The POST re-validates against that list, and a facility with no contract offers nobody (fail-closed, follow-up stays with the original inspector). `clear_followup` (single and bulk) clears the assignment too.
### Per-Account Overrides for Customer Roles (Phase 51)
`notify_by_matrix()` consults `UserNotificationMatrix` (§5) for the two customer-side role columns. One query per dispatch (`overrides_for_event`), then `users = [u for u in users if overrides.get(u.id, enabled)]` — an account with no row falls back to the global column, which is what makes both directions work.
**The skip-early guard had to change, and this is the subtle part.** The role loop used to `continue` on `if not enabled` *before* loading the pool, so a per-account opt-IN against a globally-OFF column would have saved fine, displayed as on, and never sent — a silent failure. A customer column is now skipped only when it is off **and** nobody has opted in:
```python
is_customer_col = role_key in User.CUSTOMER_ROLES
if not enabled and not (is_customer_col and any(overrides.values())):
continue
```
**`notify_customers_for_facility()` needs the filter passed in.** It re-derives recipients from `CustomerAssignment` rows itself, so the facility-scoped `customer` branch would bypass every override applied a few lines above. `notify_by_matrix()` therefore passes `allowed_user_ids={u.id for u in users}`; direct callers omit it (`None` = no filtering) and behave as before. Its `user.role != 'customer'` check stays an **equality** test — a Customer Inspector is routed by the inspector column, not this one.
### Per-Contract Additional Recipients (Phase 33)
`notify_by_matrix()` is the single dispatch point for all broadcast events. After routing to the global matrix roles + global custom emails, it calls `_notify_contract_recipients()`, which:
@@ -806,7 +1053,146 @@ phase1_projects_roles → phase6_features → phase7_mobile_api → phase8_notif
→ phase36_scheduled_insp
→ phase37_support_chat
→ phase38_support_knowledge
→ phase39_area_public_token ← HEAD
→ phase39_area_public_token
→ phase40_auditor_role
→ phase41_internal_handler
→ phase42_internal_contact
→ phase43_sched_recurrence
→ phase44_sched_end_date
→ phase45_sched_parent_insp
→ phase46_followup_req_by
→ phase47_sched_acknowledged
→ phase48_user_ui_theme
→ phase49_external_inspector
→ phase50_default_modern
→ phase51_user_notif_matrix
→ phase52_template_contracts
→ phase53_followup_assignee
→ phase54_issue_links ← HEAD
#### phase54 — link related and duplicate issues
Revision id `phase54_issue_links`. Creates `issue_links` — see §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` — so the schema this migration builds is identical to the one `db.create_all()` builds, down to the index names.
Table-existence check — safe to re-run. `downgrade()` drops the table, discarding every link; no issue is affected, since a link never held state belonging to one.
**Deploy order:**
```bash
flask db upgrade
sudo systemctl restart gunicorn
```
#### phase53 — assign a follow-up to another inspector
Revision id `phase53_followup_assignee`. Adds `inspections.follow_up_assigned_to` (FK → `users.id`, ON DELETE SET NULL) — see §11 "Assigning a follow-up to another inspector".
**No backfill.** NULL means the follow-up belongs to the inspection's own inspector, which is exactly what every existing row already means, so this cannot change who owns anything on deploy.
**This is the THIRD FK from `inspections` to `users`** (rule 86). `Inspection.follow_up_assignee` pins `foreign_keys` explicitly; `User.inspections` was already pinned in phase46. Get this wrong and the mapper is ambiguous — and it raises on first ORM *use*, not at import, so the app starts cleanly and then every request 500s.
`INFORMATION_SCHEMA` column + constraint checks — safe to re-run. `downgrade()` drops the FK then the column, returning every follow-up to its original inspector.
**Deploy order:**
```bash
flask db upgrade
sudo systemctl restart gunicorn
```
#### phase52 — restrict forms to specific contracts
Revision id `phase52_template_contracts`. Creates `template_contracts` — see §5 `TemplateContract`.
**No backfill, and it cannot change behaviour on deploy.** Every existing template has no rows, and no rows means *shared with every contract*, which is exactly what they do today. Table-existence check — safe to re-run. `downgrade()` drops the table, returning every form to shared: no form becomes unusable, they just stop being restricted.
**Deploy order:**
```bash
flask db upgrade
sudo systemctl restart gunicorn
```
#### phase51 — per-account notification overrides
Revision id `phase51_user_notif_matrix` (file `phase51_user_notification_matrix.py`, down_revision `phase50_default_modern`). Creates `user_notification_matrix` — see §5 `UserNotificationMatrix` and §11.
**No backfill, deliberately.** An empty table means every account inherits the global matrix, which is exactly today's behaviour, so this migration cannot change who gets notified. Backfilling from the current global columns would freeze every account at today's routing and silently break future changes to those columns. Table-existence check — safe to re-run.
**The rest of phase51 needs no migration.** Customer Director / Customer Inspector is a **label-only** rename over the existing `customer` and `external_inspector` ENUM values (§5), so no ENUM change and no user row is touched. `downgrade()` drops the table, discarding every override and returning all accounts to global routing.
**Deploy order:**
```bash
flask db upgrade
sudo systemctl restart gunicorn
```
#### phase50 — modern design becomes the default
Revision id `phase50_default_modern`. Promotes the phase48 A/B-test design to the default. Two changes, **both required**: the `users.ui_theme` column default becomes `'modern'` (new accounts), and existing rows are moved `'classic'``'modern'`. phase48 stored a literal `'classic'` for everyone rather than NULL, so a default change alone would leave every current user on the old design.
**It overwrites a deliberate choice** — phase48 gave no way to tell "I picked classic" from "I never touched it", so anyone who actively preferred classic is moved too.
**The A/B test is closed (Aug 2026).** Modern is THE design. The **"Back to Classic Design"** switcher and the admin **"Design Vote Tally"** link were removed from the modern account menu, so a user on modern stays there. Two things were deliberately KEPT: the classic layout still offers **"Try the New Design"** (the only way off classic for anyone still on it — removing it would strand them on a retired design), and `ui.switch_theme` / `ui.theme_votes` still work if visited directly, so nothing 404s mid-request and the tally is still readable at `/ui/theme-votes`. Nobody's stored `ui_theme` was changed by this. `/ui/theme-votes` reads the same column, so the tally reads 100% modern afterwards — capture it first if the numbers matter. `downgrade()` returns *everyone* to classic (individual prior choices were never recorded).
To change the default without touching saved preferences, set `DEFAULT_UI_THEME=classic` instead of running this.
**Deploy order:**
```bash
flask db upgrade
sudo systemctl restart gunicorn
```
#### phase49 — External Inspector role
Revision id `phase49_external_inspector` (file `phase49_external_inspector_role.py`, down_revision `phase48_user_ui_theme` — note phase48, the design A/B test, is the real head, NOT phase47). Adds `external_inspector` to the `users.role` ENUM. **Pure ENUM expansion** (adds a value, migrates nothing), so the 3-step ENUM protocol does not apply and the `MODIFY` is idempotent — safe to re-run. `downgrade()` reassigns any `external_inspector` rows to `inspector` first, which preserves their `InspectorAssignment` scoping exactly.
**No matrix rows are seeded.** `MATRIX_DEFAULTS` mirrors every `('<event>', 'inspector')` default into `('<event>', 'external_inspector')` at import time, and `is_enabled()` falls back to that default when a row is absent — so an un-seeded install behaves identically to the Inspector column until an admin saves the matrix page.
**Deploy order:**
```bash
flask db upgrade # expands users.role ENUM with 'external_inspector'
sudo systemctl restart gunicorn
```
```
#### phase47 — scheduled inspection receipt acknowledgement
Revision id `phase47_sched_acknowledged` (file `phase47_sched_acknowledged.py`, down_revision `phase46_followup_req_by`). Adds `scheduled_inspections.acknowledged_at DATETIME NULL` — when the assigned inspector confirms they received the scheduled request. Backs the **receipt acknowledgement** feature — see §5 `ScheduledInspection` "Receipt acknowledgement". **No backfill**: legacy rows keep NULL and render as "Awaiting" confirmation, the correct initial state. `INFORMATION_SCHEMA` column check — safe to re-run.
**Deploy order:**
```bash
flask db upgrade
sudo systemctl restart gunicorn
```
#### phase46 — follow-up request attribution
Revision id `phase46_followup_req_by` (file `phase46_followup_requested_by.py`, down_revision `phase45_sched_parent_insp`). Adds `inspections.follow_up_requested_by INT NULL` (FK → `users.id` ON DELETE SET NULL) and `follow_up_requested_at DATETIME NULL`. Backs **customer-requested follow-ups** — see §11 "Customer-requested follow-up". **No backfill**: legacy rows keep NULL and render as an unattributed follow-up exactly as before.
**Breaking detail:** this is the *second* FK from `inspections` to `users`, which made `User.inspections` ambiguous at mapper-configure time (`AmbiguousForeignKeysError` on the first ORM use, not at import). `User.inspections` now declares `foreign_keys='Inspection.inspector_id'` — it means "inspections I performed". Any future FK from `inspections` to `users` needs the same treatment.
`INFORMATION_SCHEMA` column + constraint checks — safe to re-run.
**Deploy order:**
```bash
flask db upgrade
sudo systemctl restart gunicorn
```
#### phase44 — scheduled inspection end date
Revision id `phase44_sched_end_date` (file `phase44_scheduled_end_date.py`, down_revision `phase43_sched_recurrence`). Adds `scheduled_inspections.end_date DATE NULL` — the last date a recurring schedule may produce an occurrence. **No backfill**: NULL means "repeat indefinitely", which is exactly what every existing row does today, so nothing changes cadence on deploy. Splits the two meanings `next_due_date` was carrying (see §5 `ScheduledInspection` and rule 85). `INFORMATION_SCHEMA` column-existence check — safe to re-run.
### phase43_sched_recurrence
Revision id `phase43_sched_recurrence`. Adds the five nullable recurrence-detail columns to `scheduled_inspections` (`weekdays`, `month_mode`, `day_of_month`, `nth_week`, `nth_weekday`) so weekly schedules can name their weekdays and monthly schedules can use either a day-of-month or an nth-weekday rule — see §5 `ScheduledInspection`. **No backfill**: existing rows keep NULLs and retain their current cadence. `month_mode` is VARCHAR, not ENUM, so a future recurrence style needs no 3-step ENUM migration (rule 3). `INFORMATION_SCHEMA` column-existence checks — safe to re-run.
**Deploy order:**
```bash
flask db upgrade
sudo systemctl restart gunicorn
```
### phase21_performance_indexes
@@ -955,6 +1341,36 @@ flask db upgrade # adds + backfills areas.public_token
sudo systemctl restart gunicorn
```
### phase41_internal_handler
Revision id `phase41_internal_handler` (file `phase41_internal_handler_name.py`). Adds `issues.internal_handler_name VARCHAR(100) NULL` — the free-text janitorial staff member's name used when `handler_type == 'internal'` (see §5 Issue + the Handler section). `INFORMATION_SCHEMA` column-existence check — safe to re-run.
**Deploy order:**
```bash
flask db upgrade
sudo systemctl restart gunicorn
```
### phase42_internal_contact
Revision id `phase42_internal_contact` (file `phase42_internal_handler_contact.py`). Adds `issues.internal_handler_contact VARCHAR(200) NULL` — phone/email for the janitorial staff member handling the issue when `handler_type == 'internal'`; parallels `internal_handler_name`. `INFORMATION_SCHEMA` column-existence check — safe to re-run.
**Deploy order:**
```bash
flask db upgrade
sudo systemctl restart gunicorn
```
### phase40_auditor_role
Revision id `phase40_auditor_role`. Adds the `auditor` value to the `users.role` ENUM (`ALTER TABLE users MODIFY COLUMN role ENUM(...,'auditor') NOT NULL`). This is a **pure ENUM expansion** (adds a value, removes/migrates nothing), so the 3-step ENUM protocol does not apply and the `MODIFY` is idempotent — safe to re-run. `downgrade()` reassigns any `auditor` rows to `project_manager` before contracting the ENUM. Backs the new Auditor role — see the `auditor` note in §5 and the `@issue_manager_required` decorator in §6.
**Deploy order:**
```bash
flask db upgrade # expands users.role ENUM with 'auditor'
sudo systemctl restart gunicorn
```
**Deploy order for phases 2432:**
```bash
flask db upgrade
@@ -1065,7 +1481,48 @@ Rendered in `dashboard.html` for `current_user.role == 'customer'`. Uses a Boots
- Each bubble shows: colored avatar circle (color keyed to `author.id % 7`), display name, role badge (Staff / Customer), `is_customer_visible` badge (staff-only view), status-at-time badge, timestamp, and body.
- **Staff commenting:** A hidden checkbox `name="is_customer_visible"` in the Add Comment form defaults to unchecked (staff-only). Checking it marks the comment visible to customers.
- **Customer commenting:** Only shown when `can_customer_comment = is_following or issue.reported_by == current_user.id`. Customer POST bypasses `IssueUpdateForm`; the route sets `is_customer_visible=True` unconditionally.
- **Read filtering:** `GET issues/view` passes `filter_by(is_customer_visible=True)` to customers; staff receive all comments.
- **Read filtering:** `GET issues/view` passes `filter_by(is_customer_visible=True)` to customers; staff receive all comments. **Gated by `COMMENTS_VISIBLE_TO_ALL` (temporary, Aug 2026)** — while that config is true the filter is skipped entirely and customers see every comment. The route passes `comments_open` to the template, which then (a) suppresses the per-comment "Customer visible" / "Staff only" badges, since they would misstate what the customer can actually see, and (b) hides the "Share with customer" tick behind a warning banner reading *"Comments are currently visible to everyone… Do not post internal-only notes here."*
**Both are gated on `viewer_is_our_staff` (Aug 2026), not on `role != 'customer'`.** `can_edit` is true for a **Customer Inspector assigned to the issue**, so that account was being shown the staff comment form complete with an internal-process warning and the internal visibility badges. Neither means anything to a customer — nothing they write was ever private — and both expose how we work. The comment box itself is unaffected; only the internal chrome is hidden.
`viewer_is_our_staff` is one `{% set %}` at the top of the template, written as an explicit **allowlist**`current_user.role in ['admin','director','project_manager','auditor','inspector']` — for two reasons:
- **It fails closed.** The obvious form, `not current_user.is_customer_account`, fails **open**: if that attribute is missing for any reason — most realistically a process still running an older `models/user.py` while templates have already reloaded — Jinja yields `Undefined`, `not Undefined` is true, and the internal text renders for exactly the accounts it must be hidden from. This was observed in practice. An allowlist of literal role strings can only be true for a role actually listed.
- **`external_inspector` is absent on purpose, and this is NOT a rule-87 violation.** Rule 87 governs capability and scoping checks, where a Customer Inspector must behave exactly like our own inspector. This asks a different question — *does this person work for us?* — which is the one place the two roles genuinely differ. Do not add `external_inspector` to this list. The checkbox value is still posted and stored, so flipping the config back restores both the filtering and the badges immediately.
### List filter preservation (Aug 2026)
Filtering a list, then editing or deleting a row, used to dump the user back on the **unfiltered** index. Every list-page action now round-trips the list URL.
**The mechanism, end to end:**
1. `current_url()` — Jinja global registered in `app/__init__.py`, returns `request.full_path` with a bare trailing `?` stripped. The list templates put it in a `<input type="hidden" name="next">` on every action form, and append `?next=` to every link into a detail page.
2. The detail templates (`issues/view.html`, `inspections/view.html`) set `{% set back_url = request.args.get('next') or url_for('<bp>.index') %}` once, and thread it into their own action forms **and** the Back button.
3. `return_url(fallback)` (§8) resolves it after the action; `_view_url(id)` in each blueprint re-attaches `next` when an action redirects back to the detail page, so the chain survives an update.
**Carry the whole URL, not the filters.** The old unfollow form rebuilt `next` with an 11-argument `url_for(...)` that had to be hand-edited whenever a filter was added — and silently dropped any filter nobody remembered. `current_url()` cannot drift.
`safe_redirect_url` still guards every hop, so a crafted `next=https://evil.com` falls back to the index (rule 15) — verified.
The inspections list also keeps its older `sessionStorage['insp_list_back_url']` fallback for links created before `next` existed, but a server-provided `next` **wins**: `view.html` emits `var hasNext = true;` and skips the sessionStorage read, otherwise a stale stored URL would override the list the page was actually opened from.
### Bulk actions on the list pages (Aug 2026)
Both list pages carry a bulk toolbar above the table with per-row checkboxes.
| Page | Actions | Permission |
|---|---|---|
| Issues (`POST /issues/bulk`) | assign, set status, verify & close, delete | assign/status/verify: admin/director/auditor · delete: admin/director |
| Inspections (`POST /inspections/bulk`) | export selected to PDF, request follow-up (shared note), clear follow-up, delete | export: anyone who can see the list · rest: admin/director |
**The toolbar form sits OUTSIDE the table — this is load-bearing (rule 91).** Row checkboxes join it with the HTML5 `form="issuesBulkForm"` attribute rather than being wrapped by it. Wrapping the table would nest the per-row delete/unfollow forms inside the bulk form, and browsers silently discard nested forms (rule 9) — the row actions would stop working with no error anywhere.
**Shared partials, not four copies.** Both lists have classic *and* modern variants, so the markup lives in `templates/partials/bulk_issues_toolbar.html`, `bulk_inspections_toolbar.html` and `bulk_select_js.html`; each of the four list templates includes them. The JS is generic (`.bulk-check`, `.bulk-check-all`, `.bulk-count`, `[data-bulk-action]`, `[data-bulk-confirm]`) and supports shift-click range selection; it disables the action buttons while nothing is selected, so an empty POST can't cost a page round trip.
**Partial-failure policy: act, skip, report exact counts** — never block the batch on one ineligible row, never silently drop rows. `_flash_bulk()` in each blueprint emits the one message shape ("3 issues verified and closed. 2 skipped (not awaiting verification)."). Rows are skipped when the action does not apply (already in that status, not submitted yet, already flagged); *permission* is checked per action, up front, not per row.
**The inspections bulk route re-applies facility scope to the submitted ids.** The list only ever shows in-scope rows, but the id list arrives in the POST body and is not trusted — without the re-check a crafted request could name any inspection in the system. Issue bulk actions are all manager-level (org-wide access), so they have no per-row scope question.
**Deletes remove DB rows first, files second** (both blueprints). An orphaned file is recoverable; a file deleted out from under a surviving row is not. `_collect_inspection_photos()` was factored out of the single-delete path so bulk and single delete cannot drift — a miss there leaks storage silently, forever.
### Inspection List Filters
@@ -1178,6 +1635,24 @@ A **PDF Summary** button was added to `reports/scorecard.html` alongside the exi
- If `GROQ_API_KEY` is absent, input is disabled and a fallback "Submit to Support" link is shown (the reply is still persisted).
- "Submit to Support" modal POSTs to `POST /support/tickets`; subject pre-filled from last user message in history.
### Flag-issue assignee scoping (Aug 2026)
The "Assign to" dropdown in the flag-issue offcanvas was built from an **org-wide** query (`role in [director, inspector, external_inspector, ...]`), so a Customer Inspector could assign an issue to anyone in the system — including **another client's** Customer Inspector, who was then emailed the facility name and issue description. A cross-customer data leak, and the same leak in reverse whenever one of our own inspectors picked the wrong name.
`_assignable_staff_for(inspection, actor)` in `routes/inspections.py` is now the single source for that list:
| Role group | Scope |
|---|---|
| `inspector`, `external_inspector` | only those holding an `InspectorAssignment` on **this inspection's contract** — the same rows `get_inspector_scope()` reads, so the offered assignee can always open what they were given |
| `director`, `project_manager`, `auditor` | org-wide (they hold no `InspectorAssignment`, so contract-scoping would remove them entirely and break escalation) — but offered **only to our own staff** |
| inactive accounts | never offered |
So a **Customer Inspector sees only the inspectors on their own contracts** — their colleagues plus ours — and never our internal org chart. A facility with no contract yields no contract-scoped candidates: fail-closed, leaving "Unassigned" as the only option.
**Both call sites must use it.** `execute()` renders the dropdown; `flag_issue()` builds `form.assigned_to.choices`, which is what actually **validates the POST** — that is the security boundary, since the dropdown is only a UI hint. They were previously two hand-maintained queries that had already drifted: the offcanvas offered `project_manager` and `auditor` while the choices rejected them, so picking one **silently discarded the issue** (see below). One helper, one list, no drift.
**A failed flag-issue POST now returns 400, not 200.** The offcanvas JS treats `res.ok` as success and reloads the page, so a 200 on a validation failure means the inspector watches the panel close and believes the issue was logged when nothing was saved — rule 60's failure mode, reachable through the PM/auditor drift above and through any rejected assignee. The route returns the re-rendered form with **400** so the JS error branch fires, and flashes a specific message for an out-of-contract assignee rather than "Not a valid choice".
### Inspection Execute Page — UX Patterns
- **Photo upload-on-select**: `uploadPhotoField(input)` fires immediately on `<input type="file">` change. XHR to `POST /<id>/upload-photo`. On success, the server path is written to `<input type="hidden" id="field_<fid>_server_path">` and a `<img id="thumb_<fid>">` is shown.
@@ -1295,11 +1770,32 @@ timeout = 30
| 71 | **`ProxyFix` must wrap `app.wsgi_app` in `create_app()`** | Behind Nginx, `remote_addr` is `127.0.0.1` for every request without it, collapsing all Flask-Limiter keys into one bucket (global instead of per-client rate limiting). `x_for=1` trusts exactly one proxy hop. See §19. |
| 72 | **Device registration has exactly ONE implementation — `register_device()` in `app/api/auth.py` → `api_device_tokens`** | A second `POST /api/v1/devices/register` (`app/api/devices.py` + `DeviceRegistration` model) was removed July 2026. It was shadowed by the `api_auth` route at routing time and queried the dropped `device_registrations` table. Do not reintroduce a competing device model or duplicate register route. |
| 73 | **Per-contract recipients are dispatched ONLY inside `notify_by_matrix()` — never add a parallel path** | `_notify_contract_recipients()` runs after role + global-custom-email routing and shares the `notified` / `sent_emails` dedup sets. Any new event that should reach contract recipients must go through `notify_by_matrix()` (passing `facility_id`, or an `issue_id`/`inspection_id` that resolves to one). Bypassing it means contract recipients are silently skipped and dedup breaks. Commit stays the caller's responsibility. |
| 74 | **The `public` blueprint (`/f/*`) is login-free — keep it occupant-safe** | Pages are addressed by unguessable `public_token` (never facility id), 404 on inactive/unknown facilities, and expose only a quality rating, last-inspected date, and open-issue COUNT — **never** issue descriptions, inspector names, per-item scores, or any other facility's data. The `report` POST must stay CSRF-protected (Flask-WTF form), rate-limited, and honeypot-guarded; public-reported issues are created with `reported_by=NULL`, `severity='medium'`, and routed through `notify_by_matrix('issue_created', facility_id=...)`. Do not add fields that leak internal detail, and do not reuse `render_template('base.html')` here — the public page is a standalone template with no authenticated nav. |
| 74 | **The `public` blueprint (`/f/*`) is login-free — keep it occupant-safe** | Pages are addressed by unguessable `public_token` (never facility id), 404 on inactive/unknown facilities, and expose only aggregate quality data: the overall rating, inspection/issue COUNTS, the score trend, and a **Recent Inspections** list showing each inspection's date + quality **label** (July 2026 — `_recent_rows()` shapes these). `_recent_rows()` deliberately does **not** put the raw score in the payload, so the percentage cannot leak into the rendered page; `_rating_label(None)` yields "Not yet rated" so unscored inspections render safely. Still **never**: raw per-inspection score percentages, issue descriptions, inspector names, checklist/template names, per-checklist-item scores, severity/SLA detail, or any other facility's data. The `report` POST must stay CSRF-protected (Flask-WTF form), rate-limited, honeypot-guarded, and **idempotency-guarded** (`_recent_duplicate_report()` — an identical public report for the same facility/area within `DUPLICATE_REPORT_WINDOW_SECONDS`=60s is silently accepted as success without creating a second issue or saving its photos; the dedup check runs BEFORE `_save_report_photos()` to avoid orphaned uploads). The client also disables the submit button on first tap. Public-reported issues are created with `reported_by=NULL`, `severity='medium'`, and routed through `notify_by_matrix('issue_created', facility_id=...)`. **Photos:** the report form accepts **up to 5 photos** (`PublicIssueReportForm.photos`, a `MultipleFileField`); `_save_report_photos()` in `public.py` saves them via the shared magic-byte-validated `_save_photo()` (cap `MAX_REPORT_PHOTOS=5`) and stores the first in `Issue.photo_path`, the rest in `Issue.mobile_photo_paths` — never `result_photos` (rule 44), so they all render under "Photo Evidence". Do not add fields that leak internal detail, and do not reuse `render_template('base.html')` here — the public page is a standalone template with no authenticated nav. |
| 75 | **Email is stored lowercased; look it up case-insensitively** | User/customer email is normalized to `.strip().lower()` at every write site (`auth.py` profile/create/edit, `customers.py` invite/edit). Forgot-password lookup uses `db.func.lower(User.email) == input` so a mixed-case legacy row still matches — a plain `filter_by(email=...)` silently missed them and sent no reset (the failure was invisible because of the generic "if an account exists…" message). Keep both halves: normalize on write, case-insensitive on lookup. |
| 76 | **Transactional email `From` must be an SMTP-authorized identity, per-domain branding via display name only** | Reset-password sends from `MAIL_DEFAULT_SENDER`; customer invite sends from `branded_sender()` = `(per-domain display name, authorized address)`. A per-host `noreply@<subdomain>` sender is accepted by the relay then dropped by SPF/DMARC. See rule 64 and §8 `mail_utils.py`. |
| 77 | **`GET /api/v1/scheduled-inspections` is inspector-scoped by `inspector_id`, admin/director/PM see all** | New `app/api/scheduled.py` blueprint. Register in `app/api/__init__.py` AND `csrf.exempt(_api_scheduled_bp)` in `app/__init__.py` — the child-blueprint CSRF exemption never cascades from the parent. Read-only; do not add write/fulfil endpoints here (the schedule lifecycle stays in `routes/scheduled_inspections.py`). |
| 78 | **`PATCH /api/v1/issues/<id>/handler` allows the inspector on purpose — do NOT align it to the web form's admin/director/PM restriction** | The iPad lets the assigned inspector set "Handled By" from the field, scoped via `get_inspector_scope()` (403 if the issue's facility isn't contracted). This is a deliberate divergence from the web form. `_issue_payload()` must keep returning all 8 handler fields (`handler_type`, `handler_label`, `facility_handler_*`, `vendor_*`) or the iPad's "Handled By" panel silently blanks — same failure mode as rule 40. |
| 78 | **`PATCH /api/v1/issues/<id>/handler` allows the inspector on purpose — do NOT align it to the web form's admin/director/PM restriction** | The iPad lets the assigned inspector set "Handled By" from the field, scoped via `get_inspector_scope()` (403 if the issue's facility isn't contracted). This is a deliberate divergence from the web form. `_issue_payload()` must keep returning all handler fields (`handler_type`, `handler_label`, `facility_handler_*`, `vendor_*`, `internal_handler_name`, `internal_handler_contact`) or the iPad's "Handled By" panel silently blanks — same failure mode as rule 40. |
| 79 | **`auditor` = `project_manager` access + issue management, minus delete — keep the two decorators distinct** | Auditor is added to `@project_manager_required` (PM baseline) and to every `project_manager` role check in routes/templates. Its *extra* issue powers (verify/bulk-verify/verification-queue) go through the separate `@issue_manager_required` (admin/director/auditor). Issue **delete** stays `@supervisor_required` — never add auditor there. When adding a new PM-level gate, include `auditor`; when adding a director-only or delete-level gate, do not. The three issue **delete** template gates (spaced `['admin', 'director']` in `issues/list.html` + `issues/view.html`) are deliberately left without auditor. Auditor is also in the `_ALLOWED_ROLES` set of every `app/api/*` module — a **new** API blueprint's `_ALLOWED_ROLES` must include `auditor` for PM parity. |
| 80 | **Assignee dropdowns are `director`/`inspector`/`auditor` (admin removed, auditor added)** | The issue/inspection assignee `<select>`s query `User.role.in_([...])` — admin was removed and auditor added (the inspection flag-issue list also keeps `project_manager`). These lists control who can be *assigned*, distinct from who can *edit*. The issue-update route (`issues.view`) defensively appends any current `assigned_to` who is not in the set (e.g. a legacy admin assignment) to `form.assigned_to.choices` so saving the form never silently unassigns them. Do not remove that guard. |
| 86 | **A second FK from a table to `users` breaks any relationship that didn't pin `foreign_keys`** | Adding `inspections.follow_up_requested_by` (phase46) made `User.inspections` ambiguous — `AmbiguousForeignKeysError`, raised at first ORM *use*, not at import, so the app starts fine and then every request 500s. `User.inspections` now pins `foreign_keys='Inspection.inspector_id'`. Check existing relationships before adding another FK to `users` from a table that already has one. |
| 83 | **A bad `scheduled_inspection_id` must NEVER fail the inspection submission** | `_resolve_schedule()` in `app/api/inspections.py` drops an unknown or foreign link and logs a warning instead of returning 404/403. The app is offline-first: a completed inspection can sit in the outbox for days, during which the schedule may be deleted, reassigned, or rolled forward. Erroring would burn the 5 sync retries and permanently strand that inspection **and its photos** on the device. A missed fulfil is fixable from the web; a stranded submission is not. The ownership check still refuses to *link* a foreign schedule (one inspector must not fulfil another's) — it just accepts the inspection anyway. |
| 82 | **A schedule's recurrence columns must be CLEARED when they don't apply to the chosen frequency** | `_apply_recurrence()` in `routes/scheduled_inspections.py` is the single write path for `frequency` + `weekdays`/`month_mode`/`day_of_month`/`nth_week`/`nth_weekday`, and it NULLs the blocks that don't apply. Setting `sched.frequency` directly (as create/edit used to) leaves stale settings behind — a weekly→monthly switch would keep `weekdays` and `recurrence_label` would lie. The hidden form blocks still POST their values, so client-side hiding is not enough. |
| 84 | **"Instructions" is a LABEL over `notes` — never rename the field, attribute, column or API key** | `ScheduledInspectionForm.notes` renders as "Instructions" and both the web execute page and the iPad say "Instructions". The wire key stays `notes` (`api/scheduled.py::_scheduled_payload`), which is what `APIScheduledInspection.notes` decodes into `LocalScheduledInspection.notes`; the iPad exposes it through a computed `instructions` accessor that also trims blank text. Renaming any of the storage identifiers would silently break the iPad decode — the field is `try?`-decoded, so it would fail to nil rather than throwing. |
| 85 | **`next_due_date` is mutable state, `end_date` is a fixed boundary — never conflate them** | `fulfill()` rewrites `next_due_date` after every completed inspection; `end_date` is set by the manager and never touched by the app. The old single label "Start / Due Date" said both at once, which is what users reported as confusing. The label now follows context — `form.next_due_date.label.text` is set to "Start Date" in `create()` and "Next Due Date" in `edit()`. Do not rename the `next_due_date` column to match a label: it is indexed, it is the API payload key the iPad decodes, and the reminder cron filters on it. |
| 87 | **Never write `role == 'inspector'` — use `user.is_inspector` (`User.INSPECTOR_ROLES`)** | phase49 added `external_inspector`, which must behave as an inspector everywhere. An equality check silently drops it into the *privileged* branch of every `if inspector: scope … else: org-wide` block — i.e. a third-party inspector would see **every contract in the system**. This is a fail-OPEN mistake: nothing errors, the data just leaks. The sweep converted ~44 Python sites and 7 template sites; the only surviving `== 'inspector'` literals are the matrix docstring, the `MATRIX_DEFAULTS` mirror comprehension, and the default-checked box in `admin/broadcast.html`. Query-level checks use `User.role.in_(User.INSPECTOR_ROLES)` (never `filter_by(role='inspector')`). A **new** `app/api/*` blueprint's `_ALLOWED_ROLES` must include `external_inspector`, same as rule 79 requires for `auditor`. |
| 88 | **`app/enrollment/` writes no DB row and has exactly ONE read — keep the vertical slice sealed** | The enrollment form describes accounts that do NOT exist yet (no contract, facility or user to key a row against), so it stores flat JSON in `ENROLLMENT_DIR` and owns its own templates. The single permitted model access is `mailer._admin_recipients()` reading active `admin` users to address the new-enrollment alert — function-local, read-only, and guarded so a DB failure cannot break a submission. Adding a model/migration for enrollment, or letting the public POST **create** Users, would couple an unauthenticated endpoint to the account system — the exact thing the separation buys. If enrollment must ever provision accounts, do it as a separate admin-triggered action that reads a stored submission. Submission ids are filesystem paths: validate against `_ID_RE` before every open (path traversal). See §24. |
| 98 | **Never set `display` on a native checkbox or radio to give it a touch target** | `ipad_responsive.css` had `input[type=radio] { min-height: 44px; display: inline-flex }` inside a `(pointer: coarse), (max-width: 1194px)` query. Replacing a radio's intrinsic box with a flex container leaves the glyph painting at ~16px while the element claims 44px, so the visible dot and the hit area stop coinciding and taps land on nothing — the per-account notification matrix looked entirely unclickable because of it. Grow the target with `transform: scale()` + margin, or wrap the input in a `<label>` that fills the cell. |
| 97 | **The template list's Edit modal (`/rename`) is a THIRD edit path — keep it in sync with create and the form editor** | The modal on the template list posts to `rename_template`, not `edit_template`, so a field added only to the two WTForms pages is invisible to the people who edit templates from the list. It carries a hidden `contracts_present=1` marker: an empty selection with the marker means "make this shared", while a POST without it leaves restrictions untouched — otherwise any other caller of that route would silently share a restricted form with every customer. |
| 95 | **A template with NO `template_contracts` rows is SHARED, not hidden** | The empty set means "available on every contract" — that is what makes phase52 additive and why it needed no backfill. Reading it the other way would hide every pre-phase52 form from every contract at once. The convention lives in exactly one place, `InspectionTemplate.available_query()`; every picker, the POST validation behind it, and the mobile API call it rather than writing their own filter. A facility with no contract gets shared forms only (fail-closed). |
| 96 | **An explicit `?project_id=` / `?facility_id=` filter must still be intersected with the caller's own scope** | Accepting a caller-supplied contract filter *instead of* their scope is a leak, not a filter: a Customer Inspector could pass another customer's facility id and get that customer's form names back. `_visible_templates()` returns `[]` for an out-of-scope contract — empty rather than an error, so the endpoint does not confirm the contract exists either. Applies to any future endpoint that takes a scope-shaped query parameter. |
| 93 | **The flag-issue assignee list is contract-scoped, and BOTH call sites must use `_assignable_staff_for()`** | `execute()` renders the dropdown, `flag_issue()` builds the choices that validate the POST — the choices are the security boundary. Two separate queries had already drifted (offcanvas offered project_manager/auditor, choices rejected them), which silently discarded issues. Contract scoping applies to the two inspector roles for EVERY actor, not just customer ones: an org-wide list let anyone assign another client's Customer Inspector, who was then emailed that facility's name and issue description. Never widen this back to an unscoped `User.query.filter(role.in_(...))`. |
| 94 | **A failed flag-issue POST must return a non-2xx** | The offcanvas JS branches on `res.ok`, so a 200 re-render of the invalid form reads as success: the panel closes, the page reloads, and no issue exists — with nothing in the logs and no message to the user. `flag_issue()` returns 400 on a failed POST for exactly this reason. Any future AJAX-submitted form needs the same treatment (rule 60 is the same failure seen from the other end). |
| 91 | **A bulk-action form must live OUTSIDE the table; row checkboxes join it via the HTML5 `form=` attribute** | Wrapping the table in the bulk form nests the per-row delete/unfollow forms inside it, and browsers **silently discard** nested forms (rule 9) — the row buttons would post nothing, with no console error and no server log. `<form id="issuesBulkForm">` sits above the table and each checkbox carries `form="issuesBulkForm"`. Same for `inspectionsBulkForm`. Applies to all four list templates (classic + modern). |
| 92 | **Bulk deletes: DB rows first, storage files second** | Collect the keys, `db.session.delete()` every row, `commit()`, and only then `storage.delete()`. Deleting files first means a failed/rolled-back commit leaves surviving rows pointing at missing photos. `_collect_inspection_photos()` is shared by the single and bulk inspection delete paths precisely so the two cannot drift — a key missed there is an invisible permanent storage leak. |
| 89 | **`User.CUSTOMER_ROLES` is for ACCOUNT MANAGEMENT; `role == 'customer'` is for CAPABILITY — never swap them** | The inverse of rule 87, and it fails in both directions. Widening a capability check to `CUSTOMER_ROLES` hands a third-party Customer Inspector the customer portal (fail-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 (fail-closed, but invisible until someone looks for a missing account). `CUSTOMER_ROLES` / `is_customer_account` appear ONLY in: the `/customers` list query, its route guards, the `auth.list_users` exclusion, **the customer-facing support surface** (`_is_customer_side()` — both roles get the same door, then branch per role for scope and for the AI's system prompt), and **narrowing** uses that WITHHOLD something from an external account (`_assignable_staff_for()` uses it to hide our internal staff — safe direction, and commented as such). Everything else — portal gates, `@customer_required`, `get_customer_scope()`, `notify_customers_for_facility()`, the customer branch of every `app/api/*` scope check — keeps the equality test, because a Customer Inspector is an **inspector** there (rule 87 already routes it correctly). |
| 90 | **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 early `continue` has to also ask whether anyone opted in (`any(overrides.values())`), or the override saves, displays as on, and never sends — a silent failure with no error anywhere. 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; either one alone leaves a hole. See §11. |
| 99 | **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 of them 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 (the rule 93 lesson, applied before it could bite). `_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. |
| 81 | **Photo timestamp/geo overlay is burned at UPLOAD, never on `PATCH /issues/<id>/photos`** | That PATCH receives only path strings — the bytes are already in storage and the payload carries no capture metadata. Burning there would need a read-modify-write per key plus an overwrite-in-place primitive (`storage.save()` mints a NEW uuid key, and §22 requires key == DB path), and would risk a **double burn** since the endpoint is deliberately idempotent/retry-safe (rule 45). Stamp in `POST /photos/upload`, where the raw bytes + EXIF are in hand and each call writes exactly one already-stamped object. Stamping failures must always fall back to storing the ORIGINAL bytes — never lose a photo to a stamping bug. See §23. |
---
@@ -1404,3 +1900,165 @@ timeout = 30
### Rollback (any phase after cutover)
- [ ] `STORAGE_BACKEND=local` → restart. Instant revert; local files were never touched.
---
## 23. Photo Capture-Time / Geo Overlay
**Goal:** evidence photos carry a visible, tamper-evident record of *when* and *where* they were taken. Implemented in `app/utils/photo_stamp.py`, applied in `POST /api/v1/photos/upload`.
**Why upload-time and not `PATCH /issues/<id>/photos`** (rule 81): that PATCH only receives path strings — the bytes are already stored and it carries no capture metadata. Stamping there would require a read-modify-write per key, a new overwrite-in-place storage primitive (`storage.save()` mints a new uuid key; §22 requires key == DB path), and would risk a **double burn** on retry since the endpoint is intentionally idempotent (rule 45). At upload the raw bytes and camera EXIF are in hand and exactly one already-stamped object is written.
### Metadata resolution order
1. **Client fields**`captured_at` (ISO-8601, offsets and `Z` accepted), `latitude`, `longitude` multipart form fields. Preferred: the app is offline-first, so a photo taken at 09:14 may not sync until 16:00 — only the client knows the true capture moment.
2. **EXIF**`DateTimeOriginal``DateTimeDigitized``DateTime`; GPS from the GPS IFD (DMS rationals → signed decimal, honouring N/S/E/W refs).
3. **Server receipt time** — last resort, no geo.
`resolve_metadata()` returns `(dt, lat, lng, source)` where `source``client|exif|server`; it is echoed back as `capture_source` in the response and logged, so you can tell how much to trust a given stamp.
### Rendering
- Translucent black bar across the bottom; line 1 `YYYY-MM-DD HH:MM:SS EDT`, line 2 `lat, lng` (omitted when unknown).
- Font/padding scale off the image's **short edge**, so portrait and landscape look the same. TrueType is probed at the usual Linux/Windows paths with a graceful fall back to Pillow's default.
- White text with a 1px dark outline stays legible over bright surfaces.
- **`ImageOps.exif_transpose()` runs before drawing** — the re-encode drops EXIF, so without it an iPhone photo would come out visibly rotated and the bar would land on the wrong edge.
- JPEG (q88) and PNG are stamped; **GIF and anything else passes through untouched** rather than risking a broken re-encode.
### Hard guarantees
- **Never lose a photo.** Every failure path (corrupt bytes, unsupported format, missing Pillow, font problems) returns the ORIGINAL bytes with `stamped: False` and logs a warning — it never raises.
- **No storage/schema change.** `stamp_file_storage()` returns a `werkzeug` `FileStorage` with the same filename/content-type, so `storage.save()` derives the same key and both the `local` and `s3` backends work unchanged.
- Toggle with `PHOTO_STAMP_ENABLED=false` (default `true`) to store raw uploads.
### Not covered (deliberate)
- Web-form uploads (`_save_photo` in `routes/inspections.py`) are **not** stamped — browsers rarely supply reliable capture/GPS metadata. The helper is reusable if that changes.
- Only the stamped image is stored; no pristine original is retained. Since the burn happens *before* the first write, nothing stored is ever destroyed.
- EXIF is not re-written into the output (the overlay is the record). Add it here if a machine-readable copy is ever needed.
---
## 24. Enrollment Form (`/enrollment`)
A customer-facing onboarding intake reproducing the printed **JQC Enrollment Form**, held **deliberately apart** from the rest of the application. It is the one feature in the tree that owns its whole vertical slice.
```
app/enrollment/
├── __init__.py register_enrollment(app) + the separation contract
├── schema.py the form AS DATA — single source of truth
├── storage.py JSON-file persistence (no model, no migration)
├── routes.py public form + admin inbox
└── templates/enrollment/
├── form.html standalone public page (no base.html)
├── submitted.html thank-you + reference number
├── admin_list.html extends base.html
└── admin_detail.html extends base.html
```
### Separation contract — keep this true
1. **No `app.models` import, nothing written to the database.** Enrollment happens *before* any contract, facility or user exists, so there is nothing to key a row against. Deleting the package would remove the routes and nothing else.
2. No migration, no model, no notification-matrix event, no API/iPad surface.
3. Its own `template_folder` — enrollment markup never mixes into `app/templates/`.
4. The only shared code it uses is what it should not reinvent: the app factory, Flask-WTF CSRF, the rate limiter, `@admin_required`.
If it ever needs to *create* the accounts it describes, do that as a **separate explicit admin action** that reads a stored submission. Do not let the public form reach into the models.
### Form flow (people first, then the matrix)
The printed form had six fixed seats (Admin/Director + Inspector 15) and a static RECOMMENDATION table for the customer to copy by hand. The web form reworks that:
The header collects Project Name, **Request by**, **Requester email** (required — the confirmation goes there) and Date Requested. The printed sheet's blank *"for office use"* block 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.
1. **Step 1 — the people.** Free-form rows, each with a **role dropdown**, name, job title, email. Starts with one row defaulted to `DEFAULT_FIRST_ROLE` (`director`); **"Add another person"** appends more, capped at `MAX_PEOPLE` (25). The last row cannot be removed.
**`schema.ROLES` offers exactly two roles — Director and Inspector (Phase 51).** Enrollment describes *customer-side* people only; our own staff (admin, auditor, internal inspector) are created in User Management and were never really enrollable. `schema.APP_ROLE_FOR` maps the seat to the app role an admin creates later — `director``customer` (Customer Director), `inspector``external_inspector` (Customer Inspector). It is a plain string map: the package still imports nothing from `app.models` (rule 88).
**`ADMIN_ROLES` keeps `admin` and `auditor` even though neither is selectable** — legacy tolerance. Submissions taken before Phase 51 stored those roles, and dropping them from the set 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. (`routes.py` already coerces an unrecognised posted role to `DEFAULT_FIRST_ROLE`, so the narrower dropdown needs no parser change.)
2. **Step 2 — the task matrix**, with **one column per person from Step 1**, rebuilt in the browser whenever a name, role or row changes. Existing ticks survive a rebuild (preserved by field name).
3. **Step 3 — mobile app**, likewise one column per person.
A **"Recommendation selection"** button applies `schema.recommendation_map()` per person's role — admin-side roles get the Admin/Director column of the old table, inspector roles the Inspectors column — after which any box can be changed. The RECOMMENDATION table itself is **no longer rendered**; `schema.RECOMMENDATION` remains the authority behind the button. The button deliberately leaves **Step 3 alone** — who carries a tablet is not something a preset can guess.
**Field naming — the client index and the stored key are independent.** The browser names fields `person_<n>_*`, `task_<ref>_person_<n>`, `mobile_person_<n>` where `<n>` is a monotonic row counter (gaps appear when rows are removed). The server discovers which indexes were actually posted (`_PERSON_FIELD_RE`, never a client-supplied count), drops entirely blank rows, and re-keys people **by position** into `p1, p2, …` for storage. So a customer deleting a middle row cannot shift anyone's answers, and stored matrix keys are always dense.
**Admin-only tasks are enforced server-side.** `task_applies()` gates ref 10 (Search/Export Reports) to `ADMIN_ROLES`; the POST parser only reads cells the person's role offers, so a crafted POST cannot record an admin-only task against an inspector — verified.
**Rows 7, 9 and 10 record an expectation; they do not switch anything on** (`schema.ROLE_IMPLIED_TASKS`, rendered as a footnote under Step 2). Both customer roles already carry all three today — comment on issues they follow or filed, log an issue at their own facility, search/export reports within their scope — so a per-person flag would be a **deny**-check, meaning new gates on routes that have none, i.e. a fail-open surface for no gain (the rule 87 failure class, self-inflicted). They stay in the form and the CSV because they are a useful record of what the customer expected, and they drive the Recommendation preset. **Rows 16 and 8 are the ones that map to notification events** and can be tuned per account in Customer Management (§5 `UserNotificationMatrix`) — seeding those overrides from a submission's ticks is a deliberate follow-up, not built: it needs a person↔account match by email that nothing in the system does yet, and rule 88 forbids the public form reaching into accounts, so it can only ever be an admin-triggered, confirm-before-save action.
### `schema.py` is the source of truth
`ROLES`, `ADMIN_ROLES`, `TASKS` (10 rows; ref 10 is `admin_only`), `RECOMMENDATION`, `OFFICE_FIELDS`, `STATUSES`. The public template renders from it *and hands it to the page as JSON* (`ROLES`, `TASKS`, `ADMIN_ROLES`, `recommendation_map()`), the POST parser iterates it, and the admin views re-render stored answers through it — so adding a task row or a role is a one-line edit with no template, JS or parser change.
**Legacy submissions.** Files stored in the original fixed-seat format are never rewritten; `schema.people_of()` / `cell()` / `wants_mobile()` normalise on read, so the admin list, detail view and CSV render both shapes identically — verified against a hand-written legacy file.
### Emails on submission
Two messages, both fired *after* `storage.save()`, both on a background thread via `_dispatch()` (rule 14), both From `branded_sender()` (rules 64/76):
| To | Function | Template | Contents |
|---|---|---|---|
| The **requester** | `send_confirmation()` | `email_confirmation.html` | Reference, project, the people table. Corrections are directed to `schema.CORRECTIONS_EMAIL`, **not** a reply — the From is an unmonitored no-reply. |
| **JQC admins** | `send_admin_notification()` | `email_admin_notice.html` | Project, requester, reference, people table, customer notes, and an **"Open in JQC"** deep link to `/enrollment/admin/<id>` built from the submitting host (so a multi-domain deployment links to the host actually in use). |
**Recipients** come from `_admin_recipients()`: active `admin` accounts, plus any addresses in the optional **`ENROLLMENT_NOTIFY_EMAILS`** config (comma-separated) for people who should be told but hold no JQC login. Deduplicated case-insensitively. Directors, inspectors and *inactive* admins are excluded — verified.
**Neither email can cost a customer their enrollment.** Every failure path is caught and logged: no `MAIL_SERVER`, `mail.send` raising, the template blowing up, or the admin lookup failing because the DB is unreachable — all still return the normal thank-you page with the record safely on disk. Verified for all four. A missing admin list does not suppress the requester's confirmation.
### Storage
One JSON document per submission in `ENROLLMENT_DIR`, named `<YYYYmmdd-HHMMSS>-<8 hex>.json` — time-ordered so a directory listing sorts chronologically, random suffix so two submissions in the same second cannot collide. The stem is the submission id and the **only** thing the admin URLs accept.
- **`_ID_RE` guards every filesystem access.** Ids are validated against `^\d{8}-\d{6}-[0-9a-f]{8}$` before being joined to a path, so a crafted id (`../../etc/passwd`) can never escape the directory — verified.
- **Writes are atomic** (`tempfile` in the same dir → `os.replace`), so a crash mid-write cannot leave truncated JSON that would break the admin list for every other submission.
- `load_all()` skips a corrupt file with a log line rather than failing the whole page.
- **Customer answers are immutable after submission.** `update_office()` merges only the office block + status, so the file stays a faithful record of what was actually requested.
### Public page hardening (same posture as rule 74)
Login-free, so: CSRF-protected form, `@limiter.limit('5 per hour')` on POST only, honeypot field (`website`, CSS-hidden — a bot that fills it gets a 200 and no file), submit button disabled on first click, `noindex` meta, and a standalone template with no authenticated nav. Validation requires a project name, a requester, at least one person with **both** a name and an email (a half-filled row cannot be set up, so it must not pass as one), and no duplicate email addresses; on failure it re-renders with the customer's input intact — including their ticked boxes, folded per-person into the `seed_people` payload — and returns 400.
### Admin
`/enrollment/admin` (admin-only, linked from the **Admin** nav dropdown in both layouts). List → detail → office-use fields (Receive Date / Program By / Date email invitation) + status (new / in_progress / completed). `GET /admin/<id>.json` downloads the raw file; `GET /admin/export.csv` emits **one row per person, not per submission** — that is the unit of work when actually creating the accounts. Task cells a person's role cannot have export as `n/a`, distinct from an unticked `''`.
---
## 25. 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
python scripts/db_health.py # read-only report
python scripts/db_health.py --json /tmp/db.json # + machine-readable
python scripts/db_health.py --apply-indexes # create the missing indexes
python scripts/db_health.py --analyze # refresh optimizer stats (safe)
python scripts/db_health.py --optimize --yes # rebuild tables (LOCKS — window only)
python scripts/db_health.py --emit-migration migrations/versions/phase54_perf_indexes.py --revision phase54_perf_indexes
```
**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 a **FK-backed** index as a drop candidate — dropping one
fails with errno 150, since MySQL needs it for the constraint.
Checks, in order: SQLAlchemy pool options (`pool_pre_ping` / `pool_recycle`
the two that decide whether an idle overnight produces `MySQL server has gone
away`); server settings cross-checked against the app (`max_connections` vs the
worst-case Gunicorn pool, `wait_timeout` vs `pool_recycle`,
`innodb_buffer_pool_size` vs the live data size, slow-query log, STRICT mode);
schema hygiene (non-InnoDB, non-utf8mb4, mixed collations — a collation mismatch
on a join column silently disables the index); table footprint; missing indexes
against a curated list; redundant indexes; unindexed foreign keys; and EXPLAIN
over the dashboard, both list pages, the SLA cron and the mobile notification
poll, flagging full scans / filesorts / temp tables.
`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.
`--emit-migration` writes a re-runnable Alembic migration (INFORMATION_SCHEMA
guards per rule 16) rather than applying DDL out of band. It guesses
`down_revision` from the versions directory — confirm against `flask db heads`
before committing.
+181
View File
@@ -0,0 +1,181 @@
════════════════════════════════════════════════════════════════════════════
JQC — phase48: Modern web portal design (A/B test with user switch)
════════════════════════════════════════════════════════════════════════════
WHAT THIS DOES
──────────────
Adds a second, complete web portal design (sidebar shell, teal-blue palette
from JQC_design.pptx) alongside the existing one. Each user picks which design
they see; the choice is stored on their account and doubles as their vote.
The classic design is byte-for-byte unchanged apart from one added menu item
("Try the New Design"). No route, endpoint, function, model or column was
renamed, and no functionality was removed.
HOW IT WORKS (root of the design, not a workaround)
───────────────────────────────────────────────────
1. `app/templates/base.html` used to hold the entire page chrome. That markup
moved verbatim to `app/templates/layouts/classic.html`.
2. `base.html` is now a single line — `{% extends jqc_layout %}`. Jinja resolves
`{% block %}` overrides through the whole inheritance chain, so all 69 page
templates keep `{% extends "base.html" %}` and needed ZERO edits.
3. `jqc_layout` is supplied by the new `inject_ui_theme()` context processor in
`app/__init__.py`, driven by the new `users.ui_theme` column
('classic' | 'modern').
4. Pages whose layout genuinely differs in the deck get an override file under
`app/templates/modern/<same path>.html`. `ThemedEnvironment.get_template()`
(app/__init__.py) swaps `dashboard.html` → `modern/dashboard.html` only when
`g.jqc_theme == 'modern'`. The swap happens in `get_template()` rather than
in the loader **on purpose**: Jinja's template cache is keyed on the name
that `get_template()` receives, so a cached modern template can never be
served to a classic user or vice versa. A loader-level swap would have that
bug.
5. Every other page renders its existing markup inside the modern shell and is
restyled by `static/css/theme_modern.css`, which loads after `theme.css` and
is scoped to `body.jqc-modern`. Classic pages never load that file.
FILES — PLACEMENT MAP
─────────────────────
NEW
app/templates/layouts/classic.html ← old base.html verbatim + one
"Try the New Design" menu item in
the user dropdown
app/templates/layouts/modern.html ← new sidebar shell (top bar, search,
bell, avatar, sidebar nav, switch)
app/static/css/theme_modern.css ← modern skin, scoped to .jqc-modern
app/routes/ui.py ← blueprint `ui`
POST /ui/theme switch_theme()
GET /ui/about about()
GET /ui/support-center support_center()
GET /ui/theme-votes theme_votes() (admin)
app/templates/modern/dashboard.html ← deck slide 1
app/templates/modern/facilities/list.html ← deck slide 5 (hub cards + the
original list, unchanged, below)
app/templates/ui/about.html ← new About Us page
app/templates/ui/support_center.html ← deck slide 6 support hub
app/templates/ui/theme_votes.html ← admin vote tally
migrations/versions/phase48_user_ui_theme.py
migrations/versions/0003_add_user_active.py
NO-OP stub. Repairs a PRE-EXISTING break in the Alembic revision graph:
phase1_projects_roles.py declares down_revision = '0003_add_user_active'
but that script is not in the repo (the early 0001-0003 files were lost).
Alembic warns while walking the graph but raises KeyError as soon as it
builds the full revision map, which any `flask db upgrade <target>` does.
The stub restores the node with down_revision = None and empty
upgrade()/downgrade(). No schema effect. Do not delete it.
MODIFIED
app/templates/base.html
Entire file replaced by the one-line dispatcher (old content now lives in
layouts/classic.html).
app/models/user.py
class User — added `ui_theme` column after `active`.
VARCHAR(16) NOT NULL DEFAULT 'classic'.
app/__init__.py
+ `ThemedEnvironment` class above create_app()
+ `app.jinja_environment = ThemedEnvironment` as the FIRST statement in
create_app() (must precede any touch of app.jinja_env — it is a cached
property)
+ modern-template index built at boot, before_request `resolve_ui_theme()`,
context processor `inject_ui_theme()` (also exposes `now_display`)
+ `from app.routes import ui` and `app.register_blueprint(ui.bp)`
DEPLOY — STEP 1: CODE
─────────────────────
cd /home/jqc/janitorial_qc
# back up the two files being replaced wholesale
cp app/templates/base.html /tmp/base.html.bak
cp app/__init__.py /tmp/__init__.py.bak
# unzip the package over the repo root (paths already match)
unzip -o jqc_phase48_modern_design.zip -d /home/jqc/janitorial_qc
chown -R jqc:jqc /home/jqc/janitorial_qc/app
DEPLOY — STEP 2: MIGRATION (run separately, after the code is in place)
───────────────────────────────────────────────────────────────────────
cd /home/jqc/janitorial_qc
source venv/bin/activate # adjust if your venv path differs
flask db upgrade
Expect: phase47_sched_acknowledged → phase48_user_ui_theme
With the 0003 stub in place, `flask db heads` reports exactly one head
(phase48_user_ui_theme) and no "Revision ... is not present" warning.
The migration uses an INFORMATION_SCHEMA existence check and an idempotent
backfill — safe to re-run.
Verify:
mysql -e "SHOW COLUMNS FROM users LIKE 'ui_theme';" janitorial_qc
DEPLOY — STEP 3: RESTART
────────────────────────
sudo systemctl restart jqc # or your unit name
journalctl -u jqc -n 40 --no-pager
Look for: "UI themes | modern overrides indexed: 2"
No Nginx change is required — no new external host, no CSP change.
VERIFICATION
────────────
1. Log in. Portal looks exactly as before (everyone starts on classic).
2. Account menu (top right) → "Try the New Design" → same page reloads in the
sidebar design, flash message confirms.
3. Dashboard: 4 KPI tiles + Inspection / Open Issues / SLA Issues cards +
Scheduled + Recent Activities. Click each number — it lands on the same
filtered list the classic dashboard links to.
4. Facility: 4 hub cards, then the full grouped facility list underneath.
Add Facility / Print All QR / Delete modal all still work.
5. Sidebar → Supports and About Us render.
6. Bell icon: badge count and dropdown behave as on classic.
7. Sidebar → "Classic Design" button (or account menu) → returns to classic.
8. Log out and back in — the design choice persists.
9. Admin account menu → "Design Vote Tally" shows the split.
10. Audit Trail shows UPDATE / User / "ui_theme=classic→modern" for each switch.
11. Narrow the browser below 992px — the sidebar becomes an off-canvas drawer
behind the hamburger.
ROLLBACK
────────
Fastest (no deploy): reset everyone to classic —
mysql -e "UPDATE users SET ui_theme='classic';" janitorial_qc
The modern design becomes unreachable; nothing else changes.
Full rollback:
cp /tmp/base.html.bak app/templates/base.html
cp /tmp/__init__.py.bak app/__init__.py
rm -rf app/templates/layouts app/templates/modern app/templates/ui \
app/static/css/theme_modern.css app/routes/ui.py
flask db downgrade phase47_sched_acknowledged
sudo systemctl restart jqc
KNOWN SCOPE LIMITS (deliberate)
───────────────────────────────
• Deck slides 2 (Reports), 3 (Inspections) and 4 (Issues) are NOT rebuilt as
separate templates. Their existing structure already matches the deck
(title + subtitle, filter row, KPI row, cards, table) and theme_modern.css
restyles them — dark-teal table headers, pill filters, rounded cards. Building
parallel copies of those three templates would duplicate several hundred lines
of filter/permission/export logic and double the maintenance surface during a
vote. Say the word after the vote and I will rebuild whichever ones you keep.
• Slide 3's "Scheduled Inspection In Progress" panel appears on the modern
DASHBOARD (where the route already supplies that data). Putting it on the
Inspections page as well needs an additive query in `inspections.index` —
small, but it is a route change, so it is not in this package.
• The deck's "Overall Score" and "Avg. Score" KPI tiles are not on the modern
dashboard: `dashboard.index` does not compute either value today. Adding them
means new aggregate queries in the route — flag it and I will send that
separately.
• "Customize" on the Facility hub points at Templates (inspection templates),
the closest existing feature. There is no facility field/tag configuration
screen in the app yet.
+116 -1
View File
@@ -1,4 +1,4 @@
from flask import Flask
from flask import Flask, request
from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager
from flask_migrate import Migrate
@@ -27,8 +27,40 @@ limiter = Limiter(
)
# ── Design A/B test: per-request template overrides (phase48) ────────────────
# 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.
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])
# ── Reverse-proxy awareness (Nginx) ──────────────────────────────────────
@@ -111,6 +143,73 @@ def create_app(config_name='default'):
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
# ── Design A/B test wiring (phase48) ──────────────────────────────────
# 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
@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
# phase50 — the fallback is configurable and now defaults to 'modern'.
# A stored users.ui_theme still wins, so an explicit choice is kept.
default = app.config.get('DEFAULT_UI_THEME', 'modern')
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', 'modern'))
_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.
@@ -183,6 +282,7 @@ def create_app(config_name='default'):
from app.routes import devices # Admin device registry
from app.routes import public # Public facility QR pages (no login)
from app.routes import scheduled_inspections # Planned/recurring inspections
from app.routes import ui # phase48 — design A/B test + new pages
app.register_blueprint(auth.bp)
app.register_blueprint(dashboard.bp)
@@ -201,6 +301,14 @@ def create_app(config_name='default'):
app.register_blueprint(devices.bp)
app.register_blueprint(public.bp)
app.register_blueprint(scheduled_inspections.bp)
app.register_blueprint(ui.bp)
# ── Enrollment form (self-contained — see app/enrollment/__init__.py) ────
# Deliberately NOT part of the app's data model: it owns its own templates
# and stores submissions as JSON files, so it touches no table and needs no
# migration. Registered last because nothing else depends on it.
from app.enrollment import register_enrollment
register_enrollment(app)
# ── Mobile API (Phase 7 / Phase A / Phase B / Phase C) ───────────────────
# The /api/v1 blueprint group uses JWT Bearer tokens — no CSRF cookies needed.
@@ -251,6 +359,13 @@ def create_app(config_name='default'):
response.headers.setdefault('X-Content-Type-Options', 'nosniff')
response.headers.setdefault('X-Frame-Options', 'SAMEORIGIN')
response.headers.setdefault('Referrer-Policy', 'strict-origin-when-cross-origin')
# Only asserted over an actual HTTPS request — ProxyFix (x_proto=1) makes
# request.is_secure reflect the real client-facing scheme behind Nginx,
# so this never fires for plain-HTTP local/dev requests.
if request.is_secure:
response.headers.setdefault(
'Strict-Transport-Security', 'max-age=31536000; includeSubDomains'
)
response.headers.setdefault(
'Content-Security-Policy',
"default-src 'self'; "
+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:
+153 -9
View File
@@ -13,6 +13,7 @@ PATCH /api/v1/inspections/<inspection_id>
GET /api/v1/inspections
Returns the authenticated inspector's own inspection history.
Supports ?limit=N&offset=N&facility_id=N&status=completed
&follow_up_required=true
"""
import logging
@@ -34,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:
@@ -95,6 +97,58 @@ def _parse_datetime(value):
return None
def _resolve_schedule(schedule_id, user):
"""Resolve a client-supplied scheduled_inspection_id, 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 "Scheduled" badge, and the dashboard banner never clears).
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.scheduled_inspection import ScheduledInspection
sched = db.session.get(ScheduledInspection, schedule_id)
if sched is None:
logger.warning('API INSPECTIONS | unknown scheduled_inspection_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 | scheduled_inspection_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 / deactivate it. Caller commits.
Mirrors the web execute route: one-time schedules deactivate (so the
dashboard banner, which filters on active, disappears), recurring ones
advance to their next occurrence and reset the reminder flags.
"""
if not inspection.scheduled_inspection_id:
return
from app.models.scheduled_inspection import ScheduledInspection
sched = db.session.get(ScheduledInspection, inspection.scheduled_inspection_id)
if sched is None:
return
sched.fulfill()
logger.info('API INSPECTIONS | schedule fulfilled | schedule=%s | inspection=%s | next=%s',
sched.id, inspection.id,
sched.next_due_date if sched.active else 'deactivated')
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."""
@@ -156,7 +210,16 @@ def _inspection_payload(inspection):
# ── Follow-up / re-inspection fields ──────────────────────────────
'follow_up_required': inspection.follow_up_required,
'follow_up_note': inspection.follow_up_note,
# phase53 — 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),
'parent_inspection_id': inspection.parent_inspection_id,
# Set when this inspection was started from a ScheduledInspection —
# drives the "Scheduled" badge on the web list and lets the iPad show
# the same marker in history.
'scheduled_inspection_id': inspection.scheduled_inspection_id,
}
@@ -179,6 +242,13 @@ def list_inspections():
status str filter by status (completed, in_progress, flagged)
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
follow_up_required
str "true"/"1" only inspections a director has flagged as
needing a follow-up and that no re-inspection has answered
yet (flagged + completed + no child), matching what
"Follow-up" means on the web. Drives the iPad's FOLLOW-UP
REQUESTED card, so it must return the complete outstanding
set, not just the recent page the history list shows.
Response 200
------------
@@ -197,13 +267,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 (phase53), 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
@@ -215,6 +292,34 @@ def list_inspections():
if status:
query = query.filter(Inspection.status == status)
if wants_follow_ups:
# Must mean exactly what "Follow-up" means everywhere on the web
# (inspections.list / 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 (phase53). 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:
@@ -333,6 +438,25 @@ def create_inspection():
if parent is None:
return api_error('Parent inspection not found', 404)
# ── Optional schedule link (started from a ScheduledInspection) ───────
scheduled_inspection_id = None
if data.get('scheduled_inspection_id'):
sched = _resolve_schedule(data['scheduled_inspection_id'], user)
scheduled_inspection_id = sched.id if sched else None
# phase45 — 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 should 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 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)
# ── Score calculation ─────────────────────────────────────────────────
overall_score = data.get('overall_score')
if overall_score is None and status == 'completed':
@@ -383,11 +507,19 @@ def create_inspection():
parent_inspection_id = parent_inspection_id,
submit_latitude = submit_latitude,
submit_longitude = submit_longitude,
scheduled_inspection_id = scheduled_inspection_id,
)
db.session.add(inspection)
db.session.flush()
# ── Fulfil the originating schedule ───────────────────────────────────
# Staged into the same atomic commit as the inspection, mirroring the web
# execute route. Without this the schedule stays active: the dashboard
# banner and the iPad "Scheduled" section never clear.
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
@@ -489,7 +621,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 {}
@@ -512,6 +644,13 @@ 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.
if data.get('scheduled_inspection_id'):
sched = _resolve_schedule(data['scheduled_inspection_id'], user)
if sched is not None:
inspection.scheduled_inspection_id = sched.id
if 'status' in data:
inspection.status = data['status']
@@ -527,12 +666,17 @@ 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.
transitioning_to_complete = (
data.get('status') == 'completed' and prev_status != 'completed'
)
# Fulfil the schedule on the draft → completed transition only, so a later
# PATCH on an already-completed inspection can't roll it forward twice.
if transitioning_to_complete:
_fulfill_schedule(inspection)
db.session.commit()
# 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'
+17 -11
View File
@@ -41,7 +41,8 @@ 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'}
@@ -109,6 +110,8 @@ def _issue_payload(issue):
'vendor_name': issue.vendor_name or None,
'vendor_contact': issue.vendor_contact or None,
'vendor_notes': issue.vendor_notes or None,
'internal_handler_name': issue.internal_handler_name or None,
'internal_handler_contact': issue.internal_handler_contact or None,
}
@@ -146,12 +149,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})
@@ -243,7 +246,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)
@@ -332,7 +335,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:
@@ -365,7 +368,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:
@@ -423,7 +426,9 @@ def update_issue_handler(issue_id):
"facility_handler_notes": "...", // optional
"vendor_name": "...", // optional (vendor handler)
"vendor_contact": "...", // optional
"vendor_notes": "..." // optional
"vendor_notes": "...", // optional
"internal_handler_name": "...", // optional (janitorial staff name)
"internal_handler_contact": "..." // optional (janitorial staff contact)
}
Only keys present in the body are updated; empty strings clear a field.
@@ -441,7 +446,7 @@ def update_issue_handler(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:
@@ -463,6 +468,7 @@ def update_issue_handler(issue_id):
_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:
@@ -510,7 +516,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:
@@ -571,7 +577,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:
+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)
+62 -5
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,13 +48,26 @@ 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"
}
}
"""
@@ -62,16 +76,27 @@ def upload_photo():
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,12 +110,44 @@ def upload_photo():
else:
subfolder = 'inspection_photos'
# Burn the capture-time + geo overlay before the bytes are ever stored, so
# exactly one (already-stamped) object is written and nothing has to be
# read back out of R2. Any stamping failure returns the original bytes.
meta = {'stamped': False, 'captured_at': None, 'source': None}
if current_app.config.get('PHOTO_STAMP_ENABLED', True):
from app.utils.photo_stamp import stamp_file_storage
file_obj, meta = stamp_file_storage(
file_obj,
captured_at = request.form.get('captured_at'),
latitude = request.form.get('latitude'),
longitude = request.form.get('longitude'),
)
# Write via the active storage backend (local disk or R2). Key format
# 'uploads/<subfolder>/<uuid>.<ext>' is unchanged across backends.
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)
logger.info('API PHOTOS | uploaded | entity_type=%s | path=%s | user=%s',
entity_type, server_path, user.username)
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'),
)
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'),
})
+146 -4
View File
@@ -17,18 +17,23 @@ app/models/scheduled_inspection.py for the full lifecycle.
"""
import logging
from datetime import datetime
from flask import Blueprint, request, g
from app import db
from app.models.scheduled_inspection import ScheduledInspection
from app.models.inspection import Inspection
from app.api.errors import api_ok, api_error
from app.api.decorators import jwt_required
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', 'project_manager'}
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'external_inspector',
'project_manager', 'auditor'}
def _scheduled_payload(s):
@@ -42,9 +47,30 @@ def _scheduled_payload(s):
'inspector_id': s.inspector_id,
'frequency': s.frequency,
'frequency_label': s.frequency_label,
# phase43 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_due_date.isoformat() if s.next_due_date else None,
# phase44. 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(),
'notes': s.notes or None,
# phase45. Set when this schedule is a planned follow-up of a specific
# inspection; the iPad carries it onto the inspection it starts so the
# run lands as a linked re-inspection. Additive — older builds decode
# explicit CodingKeys and ignore it.
'parent_inspection_id': s.parent_inspection_id,
# phase47 — receipt acknowledgement. Read-only here: this collection is
# read-only per CLAUDE.md rule 77, so confirming happens on the web. The
# iPad can display "confirmed" state from this timestamp (NULL = the
# assigned inspector has not confirmed receipt yet).
'acknowledged_at': s.acknowledged_at.isoformat() if s.acknowledged_at else None,
}
@@ -77,12 +103,12 @@ def list_scheduled():
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 = ScheduledInspection.query.filter(ScheduledInspection.active.is_(True))
if user.role == 'inspector':
if user.is_inspector:
# Inspectors only see schedules assigned directly to them.
query = query.filter(ScheduledInspection.inspector_id == user.id)
@@ -102,3 +128,119 @@ def list_scheduled():
return api_ok({'scheduled': payload, 'total': total,
'limit': limit, 'offset': offset})
# ── Create a scheduled follow-up (phase45) ────────────────────────────────────
@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 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, 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`).
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/201
----------------
{ "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 = (ScheduledInspection.query
.filter_by(parent_inspection_id=parent.id, active=True)
.order_by(ScheduledInspection.id.desc())
.first())
if existing is not None:
existing.next_due_date = due_date
if notes:
existing.notes = notes
db.session.commit()
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})
sched = ScheduledInspection(
facility_id = parent.facility_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',
next_due_date = due_date,
active = True,
notes = notes,
parent_inspection_id = parent.id,
created_by = user.id,
)
db.session.add(sched)
db.session.commit()
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)
+40
View File
@@ -0,0 +1,40 @@
"""
app/enrollment
--------------
The JQC Enrollment Form a self-contained onboarding intake, deliberately
held apart from the rest of the application.
/enrollment public form emailed to a prospective customer
/enrollment/admin admin-only inbox of submissions
Separation contract (please keep this true)
-------------------------------------------
1. NO app.models imports, and nothing here writes to the database. Enrollment
happens before any contract, facility or user exists, so there is nothing to
key a row against. Submissions are flat JSON files (see storage.py).
2. NO migration, NO model, NO notification-matrix event, NO iPad/API surface.
Deleting this package would remove the two routes and nothing else.
3. Its own template folder (app/enrollment/templates/enrollment/) enrollment
markup never mixes into app/templates.
4. The only shared code it uses is what it should not reinvent: the app factory,
Flask-WTF CSRF, the rate limiter, and @admin_required.
If this ever needs to CREATE the accounts it describes, do that as a separate,
explicit admin action that reads a stored submission do not let the public
form reach into the app's models.
"""
from .routes import bp # noqa: F401 (re-exported for register_enrollment)
def register_enrollment(app):
"""Register the blueprint and make sure the storage directory exists."""
import os
app.config.setdefault(
'ENROLLMENT_DIR',
os.path.join(app.instance_path, 'enrollments'),
)
os.makedirs(app.config['ENROLLMENT_DIR'], exist_ok=True)
app.register_blueprint(bp)
app.logger.info('Enrollment | storage dir: %s', app.config['ENROLLMENT_DIR'])
+202
View File
@@ -0,0 +1,202 @@
"""
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 _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.',
'',
'JQC by L.T Services, Inc',
]
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, schema.CORRECTIONS_EMAIL),
html = render_template('enrollment/email_confirmation.html',
record=record, people=people,
schema=schema),
)
_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'},
)
+244
View File
@@ -0,0 +1,244 @@
"""
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.
CORRECTIONS_EMAIL = 'da.nguyen8744@gmail.com'
NOTES = [
'Each user will receive instructions on how to sign up and install the app '
'on their smart device.',
'Along with the installation instructions, users will receive a quick guide '
'to navigate the web portal and app based on their credentials.',
]
# ── Office-use fields ────────────────────────────────────────────────────────
# Filled in by L.T. Services AFTER receipt, 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))
+149
View File
@@ -0,0 +1,149 @@
"""
app/enrollment/storage.py
-------------------------
Flat-file persistence for enrollment submissions one JSON document per
submission, in the directory named by config ENROLLMENT_DIR.
Why files and not a table
-------------------------
Enrollment happens BEFORE anything exists in the system: there is no contract,
no facility and no user account to key a row against, and the volume is a
handful of documents a year. A directory of readable JSON keeps this feature
completely outside the schema no model, no migration, nothing to keep in sync
with the rest of the app. It can be backed up with `cp` and read with `cat`.
File naming
-----------
<YYYYmmdd-HHMMSS>-<8 hex>.json
Time-ordered so a plain directory listing sorts chronologically, with random
suffix so two submissions in the same second cannot collide. The stem is the
submission's id and is the ONLY thing the admin URLs accept — see _safe_id().
"""
import json
import logging
import os
import re
import secrets
import tempfile
from datetime import datetime
from flask import current_app
logger = logging.getLogger(__name__)
#: Submission ids are generated by us and must round-trip through a URL and a
#: file path. Anything not matching is rejected before touching the filesystem,
#: so a crafted id can never escape the enrollment directory (path traversal).
_ID_RE = re.compile(r'^\d{8}-\d{6}-[0-9a-f]{8}$')
def enrollment_dir():
"""Absolute path of the submission directory, created on first use."""
path = current_app.config['ENROLLMENT_DIR']
os.makedirs(path, exist_ok=True)
return path
def new_id(when=None):
"""Mint a time-ordered, collision-safe submission id."""
when = when or datetime.now()
return f'{when:%Y%m%d-%H%M%S}-{secrets.token_hex(4)}'
def _safe_id(submission_id):
"""Return the id if it is one of ours, else None.
Never interpolate an unvalidated id into a path `../../etc/passwd` and
friends. Callers should 404 on None.
"""
if not submission_id or not _ID_RE.match(submission_id):
logger.warning('ENROLLMENT | rejected malformed id=%r', submission_id)
return None
return submission_id
def _path_for(submission_id):
sid = _safe_id(submission_id)
if sid is None:
return None
return os.path.join(enrollment_dir(), f'{sid}.json')
def save(record):
"""Write a submission atomically. Returns the id.
Written to a temp file in the same directory then os.replace()d, so a
crash mid-write can never leave a truncated JSON document that would break
the admin list for every other submission.
"""
sid = record['id']
path = _path_for(sid)
if path is None:
raise ValueError(f'refusing to save malformed id {sid!r}')
directory = os.path.dirname(path)
fd, tmp = tempfile.mkstemp(dir=directory, suffix='.tmp')
try:
with os.fdopen(fd, 'w', encoding='utf-8') as fh:
json.dump(record, fh, indent=2, ensure_ascii=False)
os.replace(tmp, path)
except Exception:
# Never leave the temp file behind on a failed write.
try:
os.unlink(tmp)
except OSError:
pass
raise
logger.info('ENROLLMENT | saved | id=%s project=%r',
sid, record.get('project_name'))
return sid
def load(submission_id):
"""Return one submission dict, or None if unknown/unreadable."""
path = _path_for(submission_id)
if path is None or not os.path.isfile(path):
return None
try:
with open(path, encoding='utf-8') as fh:
return json.load(fh)
except (OSError, ValueError):
logger.exception('ENROLLMENT | unreadable submission id=%s', submission_id)
return None
def load_all():
"""Return every submission, newest first.
A single corrupt file is skipped with a log line rather than breaking the
whole admin list.
"""
directory = enrollment_dir()
records = []
for name in sorted(os.listdir(directory), reverse=True):
if not name.endswith('.json'):
continue
rec = load(name[:-len('.json')])
if rec is not None:
records.append(rec)
return records
def update_office(submission_id, office, status):
"""Merge the office-use block + status into a stored submission.
Returns the updated record, or None if the id is unknown. Only these
fields are writable after submission the customer's own answers are
immutable, so the file stays an accurate record of what they asked for.
"""
rec = load(submission_id)
if rec is None:
return None
rec.setdefault('office', {}).update(office)
rec['status'] = status
rec['updated_at'] = datetime.now().isoformat(timespec='seconds')
save(rec)
return rec
@@ -0,0 +1,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;">JQC by L.T Services, Inc</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:{{ schema.CORRECTIONS_EMAIL }}">{{ schema.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>JQC Enrollment Form — L.T. Services, Inc</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
<style>
body { background:#f1f5f9; color:#1f2937; }
.sheet { max-width: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">by L.T Services, Inc</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }} no-print">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
<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">JQC by L.T Services, Inc</div>
</div>
</body>
</html>
+3 -1
View File
@@ -1,10 +1,12 @@
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.api_token import RefreshToken, DeviceToken
from app.models.notification_matrix import NotificationMatrix
from app.models.user_notification_matrix import UserNotificationMatrix
from app.models.notification_recipient import ContractNotificationRecipient
from app.models.scheduled_inspection import ScheduledInspection
+182
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,81 @@ 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 []
@@ -81,9 +193,79 @@ class Inspection(db.Model):
)
follow_up_required = db.Column(db.Boolean, nullable=False, default=False)
follow_up_note = db.Column(db.Text, nullable=True)
# Who asked for the follow-up (phase46). NULL for legacy rows flagged before
# the column existed. Matters because customers can now raise the request
# themselves — staff need to see at a glance that the client is waiting on
# this one, not another internal reviewer.
follow_up_requested_by = db.Column(
db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'), nullable=True
)
follow_up_requested_at = db.Column(db.DateTime, nullable=True)
# phase53 — 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 (rule 86): 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.
follow_up_assigned_to = db.Column(
db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'), 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')
# The ScheduledInspection this inspection was started from (phase36), if any.
# None for ad-hoc/manual inspections or if the schedule was later deleted.
scheduled_inspection = db.relationship('ScheduledInspection',
foreign_keys=[scheduled_inspection_id])
# The user who requested the follow-up (phase46) — a customer or a manager.
# Explicit foreign_keys: `inspector_id` also points at users.
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,
),
)
follow_ups = db.relationship('Inspection', backref=db.backref('parent', remote_side='Inspection.id'),
lazy='dynamic', foreign_keys='Inspection.parent_inspection_id')
+141
View File
@@ -43,6 +43,116 @@ 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.
"""
__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 (CLAUDE.md rule 86).
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'
@@ -95,6 +205,11 @@ class Issue(db.Model):
facility_handler_name = db.Column(db.String(100), nullable=True)
facility_handler_contact = db.Column(db.String(200), nullable=True) # phone or email
facility_handler_notes = db.Column(db.Text, nullable=True)
# 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.
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).
@@ -110,10 +225,36 @@ 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 another
customer's issue. 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
# Human-readable label for the handler category (phase35).
# Perspective-neutral wording so it reads the same for staff and customers.
HANDLER_LABELS = {
+6
View File
@@ -33,6 +33,11 @@ EVENT_ADMIN_BROADCAST = 'admin_broadcast' # bulk messages sent by admin to all
# overdue to admin/director). Phase 36.
EVENT_SCHEDULED_INSPECTION = 'scheduled_inspection'
# Fired when someone asks for a follow-up re-inspection of a completed
# inspection. Raised by admin/director from the inspection page and — since
# phase46 — by CUSTOMERS for their own facilities. Phase 46.
EVENT_FOLLOWUP_REQUESTED = 'followup_requested'
ALL_EVENT_TYPES = {
EVENT_ISSUE_ASSIGNED: 'Issue assigned to me',
EVENT_ISSUE_STATUS: 'Issue status changed',
@@ -49,6 +54,7 @@ ALL_EVENT_TYPES = {
EVENT_SCORE_ALERT: 'Facility score trend alert (significant drop detected)',
# Scheduled inspection reminders (due/advance/overdue)
EVENT_SCHEDULED_INSPECTION: 'Scheduled inspection reminders (due / overdue)',
EVENT_FOLLOWUP_REQUESTED: 'Follow-up re-inspection requested',
}
+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 our 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 — 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,
}
# phase49 — 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."""
+237 -11
View File
@@ -17,15 +17,64 @@ POST /scheduled-inspections/run?token=DIGEST_SECRET:
- overdue alert to admin/director once the due date passes uncompleted
The *_notified flags make each of those fire at most once per occurrence and
reset when a recurring schedule rolls forward.
Two dates, deliberately distinct (phase44):
next_due_date 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 = forever.
"""
from datetime import timedelta
import calendar
from datetime import date, timedelta
from app import db
from app.utils.time_utils import now_eastern
FREQUENCY_CHOICES = ('once', 'daily', 'weekly', 'monthly')
# Monthly recurrence styles (phase43). Stored as VARCHAR, not ENUM, so adding a
# style later needs no 3-step MySQL ENUM dance (CLAUDE.md rule 3).
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'}
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 ScheduledInspection(db.Model):
__tablename__ = 'scheduled_inspections'
@@ -42,14 +91,55 @@ class ScheduledInspection(db.Model):
nullable=True, index=True)
frequency = db.Column(db.Enum(*FREQUENCY_CHOICES), nullable=False, default='once')
next_due_date = db.Column(db.Date, nullable=False, index=True)
# Fixed boundary set by the manager, never rewritten by the app — unlike
# next_due_date, which fulfill() advances after every completed inspection.
# NULL = repeat indefinitely. Only meaningful for recurring schedules; the
# create/edit routes force it to NULL when frequency == 'once'.
end_date = db.Column(db.Date, nullable=True)
active = db.Column(db.Boolean, nullable=False, default=True)
notes = db.Column(db.Text, nullable=True)
# ── Follow-up link (phase45) ─────────────────────────────────────────────
# 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
# inspection eventually started from this schedule inherits it as its
# parent_inspection_id, so it 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-phase45 row
# is.
parent_inspection_id = db.Column(
db.Integer,
db.ForeignKey('inspections.id', ondelete='SET NULL'),
nullable=True, index=True,
)
# ── Recurrence detail (phase43) ──────────────────────────────────────────
# 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 : 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 month" behaviour.
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)
created_by = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'),
nullable=True)
created_at = db.Column(db.DateTime, nullable=False, default=now_eastern)
last_completed_at = db.Column(db.DateTime, nullable=True)
# ── Receipt acknowledgement (phase47) ────────────────────────────────────
# When the assigned inspector confirms they have received/seen this
# assignment. Once per assignment: NULL means "awaiting confirmation"; it is
# reset to NULL when the schedule is reassigned to a different inspector so
# the new assignee must confirm afresh. It is NOT reset when a recurring
# schedule rolls forward — the acknowledgement is of the assignment, not of
# each occurrence. The acknowledger is always `inspector` (the only person
# allowed to confirm), so no separate acknowledged_by column is needed.
acknowledged_at = db.Column(db.DateTime, nullable=True)
# Per-occurrence reminder de-dup flags (reset when a recurring one rolls forward)
advance_notified = db.Column(db.Boolean, nullable=False, default=False)
due_notified = db.Column(db.Boolean, nullable=False, default=False)
@@ -60,6 +150,13 @@ class ScheduledInspection(db.Model):
template = db.relationship('InspectionTemplate', foreign_keys=[template_id])
inspector = db.relationship('User', foreign_keys=[inspector_id])
creator = db.relationship('User', foreign_keys=[created_by])
# Explicit foreign_keys is required, not optional: inspections and
# scheduled_inspections now reference each other (Inspection
# .scheduled_inspection_id points here, parent_inspection_id points back),
# so SQLAlchemy cannot infer the join for either side. Inspection
# .scheduled_inspection is already declared the same way.
parent_inspection = db.relationship('Inspection',
foreign_keys=[parent_inspection_id])
FREQUENCY_LABELS = {
'once': 'One-time',
@@ -72,42 +169,171 @@ class ScheduledInspection(db.Model):
def frequency_label(self):
return self.FREQUENCY_LABELS.get(self.frequency, self.frequency)
# ── Recurrence accessors ─────────────────────────────────────────────────
@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 == 'monthly':
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 ──────────────────────────────────────────────────────
@staticmethod
def _add_interval(d, frequency):
"""Return d advanced by one interval of the given frequency."""
"""Return d advanced by one plain interval of *frequency*.
Fallback used when no day-of-week / day-of-month detail is configured
(legacy phase36 rows). Prefer :meth:`next_occurrence_after`.
"""
if frequency == 'daily':
return d + timedelta(days=1)
if frequency == 'weekly':
return d + timedelta(weeks=1)
if frequency == 'monthly':
# Add ~1 month by stepping 2831 days to the same day-of-month where possible.
month = d.month + 1
year = d.year + (1 if month > 12 else 0)
month = 1 if month > 12 else month
day = min(d.day, 28) # clamp to avoid invalid dates (e.g. Feb 30)
return d.replace(year=year, month=month, day=day)
year, month = _shift_month(d.year, d.month, 1)
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 == 'monthly':
year, month = _shift_month(d.year, d.month, 1)
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 == 'monthly':
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
def is_overdue(self, today=None):
today = today or now_eastern().date()
return self.active and self.next_due_date < today
# ── End-date boundary (phase44) ──────────────────────────────────────────
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_acknowledged(self):
"""True once the assigned inspector has confirmed receipt (phase47)."""
return self.acknowledged_at is not None
@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 switched it off, and the list view distinguishes
the two. Compare against the *end date* rather than `next_due_date`,
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 firing overdue alerts forever. Called from run_reminders().
"""
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
def fulfill(self):
"""Mark this occurrence complete. One-time schedules deactivate;
recurring ones roll their due date forward past today and reset the
reminder flags. Caller commits."""
reminder flags. A recurring schedule whose next occurrence would fall
past its end date deactivates instead. Caller commits."""
self.last_completed_at = now_eastern()
if self.frequency == 'once':
self.active = False
return
# Recurring: advance until the next due date is in the future.
today = now_eastern().date()
nxt = self._add_interval(self.next_due_date, self.frequency)
nxt = self.next_occurrence_after(self.next_due_date)
guard = 0
while nxt <= today and guard < 400:
nxt = self._add_interval(nxt, self.frequency)
nxt = self.next_occurrence_after(nxt)
guard += 1
self.next_due_date = nxt
# Past the manager's boundary: this was the last occurrence. next_due_date
# 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
self.advance_notified = False
self.due_notified = False
self.overdue_notified = False
+112 -2
View File
@@ -3,6 +3,25 @@ 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
@@ -11,6 +30,40 @@ def load_user(user_id):
class User(UserMixin, db.Model):
__tablename__ = 'users'
# ── Inspector roles (phase49) ─────────────────────────────────────────────
# 'external_inspector' is an inspector employed by the customer or a third
# party rather than by us. 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 +72,24 @@ 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'),
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 (phase48 — design A/B test) ──────────
# 'classic' = the original top-navbar design (default for every account).
# 'modern' = the sidebar design from the JQC_design deck.
# Drives base.html's layout dispatch via the inject_ui_theme() context
# processor. Persisted per user so the choice survives logout and can be
# tallied as a vote (see /ui/theme-votes).
# phase50 — 'modern' is the default for new accounts. Existing rows were
# migrated in phase50; anyone who switches keeps their own choice.
ui_theme = db.Column(db.String(16), nullable=False,
server_default='modern', default='modern')
# ── Customer password-setup workflow ──────────────────────────────────
# password_set: False for newly created customer accounts until they
# complete the set-password flow via emailed link.
@@ -34,7 +99,12 @@ class User(UserMixin, db.Model):
set_password_token_expires = db.Column(db.DateTime, nullable=True)
# Relationships
inspections = db.relationship('Inspection', backref='inspector', lazy='dynamic')
# Explicit foreign_keys: inspections now has a SECOND FK to users
# (follow_up_requested_by, phase46), so the join is otherwise ambiguous.
# 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
@@ -49,6 +119,46 @@ class User(UserMixin, db.Model):
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 phase49
name so the ~60 existing call sites stay put (rule 84 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
+17 -8
View File
@@ -96,24 +96,29 @@ def view(log_id):
# ── Purge old logs ────────────────────────────────────────────────────────────
# Minimum floor of 1 year is deliberate: audit trails are the primary control
# evidence for SOC 2 / ISO 27001 access-monitoring, so shorter windows (the old
# 7/30/60/90/180-day options) are no longer offered — a purge can only ever
# remove entries old enough that they're outside any plausible audit lookback.
PURGE_OPTIONS = {
7: '7 days',
30: '30 days',
60: '60 days',
90: '90 days',
180: '180 days',
365: '1 year',
730: '2 years',
}
PURGE_CONFIRM_PHRASE = 'PURGE'
@bp.route('/purge', methods=['POST'])
@login_required
@admin_required
def purge():
"""Delete audit log entries older than the selected threshold.
Accepts a POST form field `older_than` (integer days).
The purge itself is recorded as a new audit log entry so there is
always a traceable record of who purged what and when.
Accepts POST form fields `older_than` (integer days, >= 1 year) and
`confirm_phrase` (must exactly equal PURGE_CONFIRM_PHRASE) the typed
confirmation is extra friction against an accidental click on an
otherwise-irreversible action. The purge itself is recorded as a new
audit log entry so there is always a traceable record of who purged
what and when.
"""
try:
older_than = int(request.form.get('older_than', 0))
@@ -124,6 +129,10 @@ def purge():
flash('Invalid purge threshold selected.', 'danger')
return redirect(url_for('audit.index'))
if request.form.get('confirm_phrase', '').strip() != PURGE_CONFIRM_PHRASE:
flash(f'You must type "{PURGE_CONFIRM_PHRASE}" to confirm this action.', 'danger')
return redirect(url_for('audit.index'))
cutoff = now_eastern() - timedelta(days=older_than)
deleted = AuditLog.query.filter(AuditLog.created_at < cutoff).delete()
db.session.flush()
+226 -8
View File
@@ -5,6 +5,7 @@ from app.models.user import User
from app.utils.forms import LoginForm, UserForm, ProfileForm, ForgotPasswordForm, ResetPasswordForm
from app.utils.decorators import admin_required, supervisor_required, safe_redirect_url
import logging
import secrets
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE, ACTION_LOGIN, ACTION_LOGOUT
logger = logging.getLogger(__name__)
@@ -65,7 +66,7 @@ def profile():
form = ProfileForm(user=current_user, obj=current_user)
if form.validate_on_submit():
current_user.full_name = form.full_name.data.strip() or None
current_user.full_name = (form.full_name.data or '').strip() or None
current_user.email = form.email.data.strip().lower()
if form.new_password.data:
@@ -109,14 +110,146 @@ 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 does not touch other users' data even where it references
this user (e.g. an issue this user commented on is included, but the
facility/other participants' details are not expanded)."""
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.
If the account has no records that would be orphaned by a hard delete
(same guard rails as the admin delete_user route), it is deleted outright.
Otherwise the common case, since inspectors/staff usually have
inspection or issue history that must be kept for business/audit
continuity the account is anonymized in place: name/email/username are
replaced with a non-identifying placeholder, the password hash is
invalidated, and the account is deactivated. Historical records (which
reference the user id, not the PII) are preserved unchanged."""
user = current_user
from app.models.issue import Issue as _Issue, IssueComment as _IssueComment
from app.models.inspection import InspectionTemplate as _InspectionTemplate
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
user.set_password(secrets.token_hex(32)) # invalidate — no one can log in as this account again
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()
)
@@ -140,6 +273,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
@@ -152,19 +304,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,
full_name=(form.full_name.data or '').strip() or None,
email=form.email.data.strip().lower(),
role=role
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'))
@@ -179,6 +351,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.
@@ -188,7 +363,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.full_name = (form.full_name.data or '').strip() or None
user.email = form.email.data.strip().lower()
if not director_editing:
@@ -209,13 +384,56 @@ 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':
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
+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',
+384 -52
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'
)
@@ -258,12 +335,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
@@ -320,12 +394,9 @@ 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)
@@ -353,16 +424,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)
@@ -370,19 +451,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,
)
@@ -392,12 +505,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
@@ -466,18 +583,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()
+56 -39
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 _handler_split() and sla_status()
# work against these unchanged.
_ISSUE_CARD_COLS = (Issue.id, Issue.severity, Issue.status,
Issue.reported_at, Issue.handler_type)
def _handler_split(issues):
"""Count a list of Issues by handler_type (phase35). Rows default to
'internal' when unset. Returns a dict keyed internal/facility/vendor."""
@@ -34,11 +44,14 @@ def index():
now = now_eastern()
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
today_end = today_start + timedelta(days=1)
# Start of the current week (Monday 00:00) for the "Submitted This Week" card.
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
@@ -60,14 +73,16 @@ def index():
else:
base_q = base_q.filter(Inspection.facility_id.in_(customer_facility_ids))
today_inspections = base_q.filter(
completed_today = base_q.filter(
Inspection.status == 'completed',
Inspection.inspection_date >= today_start,
Inspection.inspection_date < today_end,
).count()
completed_today = base_q.filter(
# Fully completed & submitted so far this week (Monday → now).
submitted_this_week = base_q.filter(
Inspection.status == 'completed',
Inspection.inspection_date >= today_start,
Inspection.inspection_date >= week_start,
Inspection.inspection_date < today_end,
).count()
@@ -97,7 +112,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'),
@@ -168,9 +183,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:
@@ -215,7 +234,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')
@@ -241,7 +260,7 @@ def index():
Issue.facility_id.in_(customer_facility_ids),
_AreaT.facility_id.in_(customer_facility_ids),
))
opened_today_all = opened_today_q.all()
opened_today_all = opened_today_q.with_entities(*_ISSUE_CARD_COLS).all()
issues_opened_today = len(opened_today_all)
opened_today_handler = _handler_split(opened_today_all)
@@ -289,6 +308,23 @@ def index():
stale_q = stale_q.filter(False)
stale_in_progress = stale_q.count()
# ── In-progress inspections (all, not just stale) — "In Progress" tile ─────
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(
@@ -305,38 +341,10 @@ def index():
))
elif is_customer:
unassigned_q = unassigned_q.filter(False) # not relevant for customers
unassigned_all = unassigned_q.all()
unassigned_all = unassigned_q.with_entities(*_ISSUE_CARD_COLS).all()
unassigned_open = len(unassigned_all)
unassigned_handler = _handler_split(unassigned_all)
# ── Inspector activity today (admin / director / PM only) ─────────────────
inspector_activity = []
if is_privileged or is_project_manager:
active_inspectors = (
User.query
.filter_by(role='inspector', active=True)
.order_by(User.full_name, User.username)
.all()
)
today_counts = dict(
db.session.query(
Inspection.inspector_id,
func.count(Inspection.id),
)
.filter(
Inspection.status == 'completed',
Inspection.inspection_date >= today_start,
Inspection.inspection_date < today_end,
)
.group_by(Inspection.inspector_id)
.all()
)
inspector_activity = sorted(
[{'name': u.display_name, 'count': today_counts.get(u.id, 0)}
for u in active_inspectors],
key=lambda x: (-x['count'], x['name']),
)
# ── My open issues (inspector dashboard widget) ───────────────────────────
# Issues assigned to the current inspector that are not yet resolved,
# ordered by SLA urgency (breached first, then at-risk, then ok).
@@ -356,25 +364,34 @@ def index():
# ── Scheduled inspections (phase36): upcoming / overdue ──────────────
sched_upcoming = []
sched_overdue_count = 0
sched_total = 0
sched_open_inspections = {}
if not is_customer:
from app.models.scheduled_inspection import ScheduledInspection
from app.routes.scheduled_inspections import _open_inspection_ids
_today = now.date()
_sq = ScheduledInspection.query.filter_by(active=True)
if is_inspector:
_sq = _sq.filter(ScheduledInspection.inspector_id == current_user.id)
_all_sched = _sq.order_by(ScheduledInspection.next_due_date.asc()).all()
sched_total = len(_all_sched) # active scheduled inspection plans ("On Schedules")
sched_overdue_count = sum(1 for s in _all_sched if s.next_due_date < _today)
# Upcoming = due today through the next 7 days (overdue shown separately)
sched_upcoming = [
s for s in _all_sched
if _today <= s.next_due_date <= _today + timedelta(days=7)
][:8]
# Offer Continue (not a duplicate Start) where one is already underway.
sched_open_inspections = _open_inspection_ids(sched_upcoming)
return render_template(
'dashboard.html',
sched_upcoming = sched_upcoming,
sched_overdue_count = sched_overdue_count,
today_inspections = today_inspections,
sched_total = sched_total,
sched_open_inspections = sched_open_inspections,
in_progress_total = in_progress_total,
submitted_this_week = submitted_this_week,
completed_today = completed_today,
open_issues = open_issues,
severity_breakdown = severity_breakdown,
@@ -387,7 +404,6 @@ def index():
stale_in_progress = stale_in_progress,
unassigned_open = unassigned_open,
unassigned_handler = unassigned_handler,
inspector_activity = inspector_activity,
recent_inspections = recent_inspections,
total_facilities = total_facilities,
total_templates = total_templates,
@@ -397,6 +413,7 @@ def index():
customer_facilities = customer_facilities,
my_issues = my_issues,
today_str = now.strftime('%Y-%m-%d'),
week_start_str = week_start.strftime('%Y-%m-%d'),
)
+123 -16
View File
@@ -6,7 +6,7 @@ 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
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
bp = Blueprint('facilities', __name__, url_prefix='/facilities')
@@ -21,7 +21,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
@@ -186,45 +186,152 @@ def facility_qr_regenerate(facility_id):
return redirect(url_for('facilities.facility_qr_page', facility_id=facility.id))
def _qr_png_bytes(url):
"""Return PNG bytes for a QR code encoding *url* (same params as qr.png)."""
import 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('/qr/print-all')
@login_required
def facility_qr_print_all():
"""Printable sheet of QR codes for all facilities the user can see.
"""Printable / selectable sheet of QR codes the user can see.
Optional ?contract_id=<id> limits the sheet to one contract. Inspectors have
no QR management (403); customers are scoped to their assigned facilities;
managers see all active facilities.
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.
"""
# QR management is not an inspector task.
if current_user.role == 'inspector':
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 []
query = Facility.query.filter(Facility.id.in_(fids), Facility.active == True)
scoped = Facility.query.filter(Facility.id.in_(fids), Facility.active == True)
else:
query = Facility.query.filter(Facility.active == True)
scoped = Facility.query.filter(Facility.active == True)
scoped_facilities = scoped.order_by(Facility.name).all()
if contract_id:
query = query.filter(Facility.project_id == contract_id)
# 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 []
facilities = query.order_by(Facility.name).all()
# 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]
# Ensure every facility on the sheet has a token so its qr.png renders.
# 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
for f in facilities:
areas_by_facility = {}
for f in grid_facilities:
if not f.public_token:
f.ensure_public_token()
changed = True
if include_areas:
fa = f.areas.order_by(Area.name).all()
for a in fa:
if not a.public_token:
a.ensure_public_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=facilities,
selected_contract=selected_contract)
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 facility_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.facility_qr_print_all'))
items = []
for fid in facility_ids:
facility = _facility_for_qr_or_403(fid) # 403 if out of scope
url = _public_facility_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 = _public_area_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_public_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"',
})
# ── Public Area QR code ───────────────────────────────────────────────────────
# Mirrors the facility QR routes above, but scoped to a single area. Customer
+647 -58
View File
@@ -15,12 +15,13 @@ from app.models.project import Project
from app.models.issue import Issue
from app.models.user import User
from app.utils.forms import StartInspectionForm, IssueForm
from app.utils.decorators import supervisor_required
from app.utils.decorators import supervisor_required, return_url
from app.utils.pdf_export import generate_inspection_pdf, generate_inspections_list_pdf
from app.utils.notifications import notify, notify_customers_for_facility, notify_by_matrix
from app.models.notification import (
EVENT_INSPECTION_DONE, EVENT_ISSUE_ASSIGNED,
EVENT_CUSTOMER_INSPECTION_DONE, EVENT_CUSTOMER_ISSUE_UPDATED,
EVENT_FOLLOWUP_REQUESTED,
)
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE, ACTION_EXPORT
from app.utils.scope import get_customer_scope, get_inspector_scope
@@ -214,7 +215,7 @@ def index():
joinedload(Inspection.area),
).order_by(Inspection.inspection_date.desc())
if current_user.role == 'inspector':
if current_user.is_inspector:
fids = get_inspector_scope(current_user)
if not fids:
q = q.filter(False)
@@ -252,7 +253,8 @@ def index():
q = q.filter(Inspection.status == status_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(Inspection.facility_id.in_(_contract_fids)) if _contract_fids else q.filter(False)
if facility_filter.isdigit():
@@ -283,12 +285,12 @@ def index():
q = q.filter(Inspection.overall_score <= float(score_max_filter))
except ValueError:
score_max_filter = ''
if inspector_filter.isdigit() and current_user.role != 'inspector':
if inspector_filter.isdigit() and not current_user.is_inspector:
q = q.filter(Inspection.inspector_id == int(inspector_filter))
inspections = q.paginate(page=page, per_page=20, error_out=False)
if current_user.role == 'inspector':
if current_user.is_inspector:
fids = get_inspector_scope(current_user) or []
_fq = Facility.query.filter(Facility.id.in_(fids), Facility.active == True)
elif current_user.role == 'customer':
@@ -315,9 +317,9 @@ def index():
projects = Project.query.filter_by(active=True).order_by(Project.name).all()
# Inspector dropdown — shown to all roles except inspector (they only see their own)
if current_user.role != 'inspector':
if not current_user.is_inspector:
inspectors = (User.query
.filter(User.role == 'inspector', User.active == True)
.filter(User.role.in_(User.INSPECTOR_ROLES), User.active == True)
.order_by(User.full_name, User.username).all())
else:
inspectors = []
@@ -347,11 +349,10 @@ def index():
def start():
form = StartInspectionForm()
templates = InspectionTemplate.query.filter_by(active=True).order_by(InspectionTemplate.name).all()
projects = Project.query.filter_by(active=True).order_by(Project.name).all()
# Scope projects to inspector's assigned contracts
if current_user.role == 'inspector':
if current_user.is_inspector:
from app.models.inspector_assignment import InspectorAssignment
assigned_pids = {
a.project_id for a in
@@ -359,7 +360,6 @@ def start():
}
projects = [p for p in projects if p.id in assigned_pids]
form.template_id.choices = [(t.id, t.name) for t in templates]
form.project_id.choices = [(p.id, p.name) for p in projects]
# Seed facility choices: use submitted project_id, session value, or first project
@@ -373,6 +373,13 @@ def start():
else:
selected_project_id = projects[0].id if projects else None
# phase52 — forms are offered per CONTRACT: shared forms plus any attached
# to the selected contract. This is also the POST validation (SelectField
# validates against its choices), so a crafted template_id for another
# customer's form is rejected here, not merely hidden in the UI.
templates = InspectionTemplate.available_query(selected_project_id).all()
form.template_id.choices = [(t.id, t.name) for t in templates]
if selected_project_id:
facilities = Facility.query.filter_by(active=True, project_id=selected_project_id).order_by(Facility.name).all()
else:
@@ -405,9 +412,22 @@ def start():
if template is None:
abort(404)
# Belt-and-braces: the choices above already reject a form that is not
# available on this contract, but that guard lives in how the list was
# built. Re-assert it against the FACILITY actually chosen, so a future
# change to the choice-building cannot quietly open a cross-customer
# hole here.
_fac = db.session.get(Facility, form.facility_id.data)
if not template.available_for_project(_fac.project_id if _fac else None):
logger_msg = ('INSPECTION START BLOCKED | template=%s not available for '
'facility=%s | user=%s')
current_app.logger.warning(logger_msg, template.id,
form.facility_id.data, current_user.username)
abort(403)
# Inspector facility scope check — prevent crafted POST from selecting
# a facility outside their assigned contracts.
if current_user.role == 'inspector':
if current_user.is_inspector:
fids = get_inspector_scope(current_user)
if not fids or form.facility_id.data not in fids:
abort(403)
@@ -462,6 +482,45 @@ def facilities_for_project(project_id):
return jsonify([{'id': f.id, 'name': f.name} for f in facilities])
# ── AJAX: forms available on a given contract (phase52) ──────────────────────
@bp.route('/templates_for_project/<int:project_id>')
@login_required
def templates_for_project(project_id):
"""Forms usable on this contract — shared ones plus any attached to it.
Powers the Contract -> Form cascade on the start-inspection page, the same
way facilities_for_project powers Contract -> Facility.
**Scoped to the caller's own contracts.** The POST validation in start() is
what stops a form being *used* across contracts, but this endpoint would
otherwise happily list one customer's bespoke form NAMES to another
customer's inspector who simply asked for a contract id — the same leak
that rule 96 covers on the mobile API. Empty list rather than 403, so it
does not confirm whether the contract exists either.
"""
if current_user.is_inspector:
fids = get_inspector_scope(current_user) or []
allowed = {
f.project_id
for f in Facility.query.filter(Facility.id.in_(fids)).all()
} if fids else set()
if project_id not in allowed:
logger_msg = ('TEMPLATES_FOR_PROJECT | out-of-scope request | '
'user=%s | project_id=%s')
current_app.logger.warning(logger_msg, current_user.username, project_id)
return jsonify([])
elif current_user.role == 'customer':
# Customers never start inspections; nothing here is theirs to see.
return jsonify([])
templates = InspectionTemplate.available_query(project_id).all()
return jsonify([
{'id': t.id, 'name': t.name, 'shared': t.is_shared}
for t in templates
])
# ── Execute ───────────────────────────────────────────────────────────────────
@bp.route('/<int:inspection_id>/execute', methods=['GET', 'POST'])
@@ -471,12 +530,12 @@ def execute(inspection_id):
if inspection is None:
abort(404)
if current_user.role == 'inspector' and inspection.inspector_id != current_user.id:
if current_user.is_inspector and inspection.inspector_id != current_user.id:
flash('Access denied.', 'danger')
return redirect(url_for('inspections.index'))
if inspection.status == 'completed':
return redirect(url_for('inspections.view', inspection_id=inspection_id))
return redirect(_view_url(inspection_id))
template = inspection.template
form_fields = template.get_form_schema()
@@ -599,7 +658,7 @@ def execute(inspection_id):
f'status=completed; score={score}')
flash('Inspection submitted successfully!', 'success')
return redirect(url_for('inspections.view', inspection_id=inspection_id))
return redirect(_view_url(inspection_id))
else:
_save_draft(inspection, responses)
@@ -607,10 +666,10 @@ def execute(inspection_id):
flash('Draft saved. You can continue filling in the form later.', 'success')
return redirect(url_for('inspections.execute', inspection_id=inspection_id))
staff_for_flag_issue = User.query.filter(
User.role.in_(['admin', 'director', 'inspector', 'project_manager']),
User.active == True,
).order_by(User.full_name, User.username).all()
# Scoped to this inspection's contract — see _assignable_staff_for().
# Must match flag_issue()'s choices exactly or the offcanvas silently
# fails to save (rule 60).
staff_for_flag_issue = _assignable_staff_for(inspection, current_user)
return render_template('inspections/execute.html',
inspection=inspection,
@@ -648,7 +707,7 @@ def save_draft_ajax(inspection_id):
if inspection is None:
abort(404)
if current_user.role == 'inspector' and inspection.inspector_id != current_user.id:
if current_user.is_inspector and inspection.inspector_id != current_user.id:
return jsonify({'ok': False, 'error': 'Access denied'}), 403
if inspection.status == 'completed':
@@ -687,7 +746,7 @@ def upload_photo_ajax(inspection_id):
if inspection is None:
return jsonify({'ok': False, 'error': 'Not found'}), 404
if current_user.role == 'inspector' and inspection.inspector_id != current_user.id:
if current_user.is_inspector and inspection.inspector_id != current_user.id:
return jsonify({'ok': False, 'error': 'Access denied'}), 403
if inspection.status == 'completed':
@@ -714,7 +773,11 @@ def view(inspection_id):
if inspection is None:
abort(404)
if current_user.role == 'inspector' and inspection.inspector_id != current_user.id:
# Read access matches the LIST (rule 58) — an inspector may open anything
# at their contracted facilities, not only what they performed. Editing
# someone else's inspection is still refused (execute / save-draft /
# upload-photo / flag-issue keep the authorship check).
if current_user.is_inspector and not _inspector_may_read(inspection, current_user):
flash('Access denied.', 'danger')
return redirect(url_for('inspections.index'))
if current_user.role == 'customer':
@@ -911,7 +974,23 @@ def view(inspection_id):
'unchanged': sum(1 for r in rows if r['delta'] == 0),
}
followup_assignees = _followup_assignees_for(inspection, current_user)
# An inspector viewing SOMEBODY ELSE's inspection gets a read-only page.
# Without this the buttons would all render and then fail on click — the
# same list-says-yes / page-says-no mismatch this change removes.
is_own_inspection = (not current_user.is_inspector
or inspection.inspector_id == current_user.id)
# Whoever is expected to carry out the follow-up (phase53) may start the
# re-inspection even though the original inspection is not theirs.
owns_follow_up = bool(
inspection.follow_up_required
and inspection.follow_up_owner
and inspection.follow_up_owner.id == current_user.id
)
return render_template('inspections/view.html',
followup_assignees=followup_assignees,
is_own_inspection=is_own_inspection,
owns_follow_up=owns_follow_up,
inspection=inspection,
form_fields=form_fields,
form_data=form_data,
@@ -921,6 +1000,95 @@ def view(inspection_id):
# ── Flag issue during inspection ──────────────────────────────────────────────
#: Internal roles that are NOT contract-scoped — they work across the whole
#: organisation, so they are offered regardless of which contract the
#: inspection belongs to. Only ever shown to our own people.
_ORG_WIDE_ASSIGNEE_ROLES = ('director', 'project_manager', 'auditor')
def _assignable_staff_for(inspection, actor):
"""Users `actor` may assign an issue to, for THIS inspection.
The candidate list is scoped by the inspection's CONTRACT, not taken
org-wide. Two distinct problems this fixes:
1. **Cross-customer leak.** A Customer Inspector could assign an issue to
anyone in the system including another client's Customer Inspector.
The assignee is notified by email and in-app with the facility name and
issue description, so this handed one customer's data to another. It is
a leak whoever flags the issue, so the contract scope is applied to the
two inspector roles for EVERY actor, not just customer ones.
2. An external account should not see our internal org chart at all. For a
customer-side actor the list is their co-workers on shared contracts
inspectors assigned to this inspection's contract — and nothing else.
Rules applied:
* inspector / external_inspector -> only those holding an
InspectorAssignment on this inspection's contract (the same rows
get_inspector_scope() reads, so the list can never disagree with what
the assignee can actually open).
* director / project_manager / auditor -> org-wide, but offered ONLY to
our own staff. These roles carry no InspectorAssignment rows, so
contract-scoping them would remove them entirely and break the normal
"escalate to the contract manager" flow.
* inactive accounts are never offered.
A facility with no contract yields no contract-scoped candidates; that is
fail-closed and correct an external actor then gets an empty list and can
only leave the issue unassigned.
Used by BOTH the offcanvas dropdown in execute() and the choices that
validate the POST in flag_issue(). They MUST stay identical: a value the UI
offers but the choices reject fails `validate_on_submit()`, and the
offcanvas JS treats the resulting 200 as success the issue is silently
never saved (rule 60's failure mode, which is exactly what the two
hand-maintained lists were already doing to project_manager and auditor).
"""
from app.models.inspector_assignment import InspectorAssignment
# `is_customer_account` is used here to WITHHOLD internal staff from an
# external account — the narrowing direction, which rule 89 permits. It
# must never be used to grant a customer-side account anything.
actor_is_external = bool(actor) and actor.is_customer_account
project_id = inspection.facility.project_id if inspection.facility else None
candidates = []
if project_id:
candidates = (
User.query
.join(InspectorAssignment, InspectorAssignment.user_id == User.id)
.filter(
InspectorAssignment.project_id == project_id,
User.role.in_(User.INSPECTOR_ROLES),
User.active == True,
)
.order_by(User.full_name, User.username)
.all()
)
if not actor_is_external:
candidates += (
User.query
.filter(
User.role.in_(_ORG_WIDE_ASSIGNEE_ROLES),
User.active == True,
)
.order_by(User.full_name, User.username)
.all()
)
# The join can repeat a user across assignment rows; dedupe by id, keeping
# a stable display order.
seen, out = set(), []
for u in candidates:
if u.id not in seen:
seen.add(u.id)
out.append(u)
out.sort(key=lambda u: (u.display_name or '').lower())
return out
@bp.route('/<int:inspection_id>/flag-issue', methods=['GET', 'POST'])
@login_required
def flag_issue(inspection_id):
@@ -928,15 +1096,21 @@ def flag_issue(inspection_id):
if inspection is None:
abort(404)
if current_user.role == 'inspector' and inspection.inspector_id != current_user.id:
if current_user.is_inspector and inspection.inspector_id != current_user.id:
flash('Access denied.', 'danger')
return redirect(url_for('inspections.index'))
form = IssueForm()
staff = User.query.filter(User.role.in_(['director', 'inspector'])).order_by(User.username).all()
# SAME list the offcanvas rendered — this is what actually validates the
# POST, so it is also the security boundary: a crafted assigned_to for
# someone outside this contract fails validation rather than being stored.
staff = _assignable_staff_for(inspection, current_user)
form.facility_id.choices = [(inspection.facility_id, inspection.facility.name)]
form.assigned_to.choices = [(0, '— Unassigned —')] + [(u.id, u.username) for u in staff]
form.assigned_to.choices = [(0, '— Unassigned —')] + [
(u.id, u.display_name + (' (Customer)' if u.is_external_inspector else ''))
for u in staff
]
if form.validate_on_submit():
photo_path = _save_photo(form.photo.data, subfolder='issue_photos')
@@ -1001,6 +1175,21 @@ def flag_issue(inspection_id):
flash('Issue logged successfully.', 'success')
return redirect(url_for('inspections.execute', inspection_id=inspection_id))
# A failed POST must NOT come back 200. The flag-issue offcanvas treats
# `res.ok` as success and reloads the page, so a 200 here means the issue
# is silently discarded with the user believing it was logged — the exact
# failure rule 60 describes. Returning 400 routes it to the JS error branch
# so the reason is shown and the form stays open with its input intact.
if request.method == 'POST':
if form.assigned_to.errors:
# Most likely an assignee outside this inspection's contract:
# either a stale page rendered before the assignment changed, or a
# crafted id. Say something actionable rather than "invalid choice".
flash('That person cannot be assigned to an issue on this contract. '
'Reopen the panel to refresh the list.', 'danger')
return render_template('inspections/flag_issue.html',
form=form, inspection=inspection), 400
return render_template('inspections/flag_issue.html',
form=form, inspection=inspection)
@@ -1018,7 +1207,7 @@ def export_list_pdf():
joinedload(Inspection.area),
).order_by(Inspection.inspection_date.desc())
if current_user.role == 'inspector':
if current_user.is_inspector:
fids = get_inspector_scope(current_user)
if not fids:
q = q.filter(False)
@@ -1055,7 +1244,8 @@ def export_list_pdf():
q = q.filter(Inspection.status == status_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(Inspection.facility_id.in_(_contract_fids)) if _contract_fids else q.filter(False)
if facility_filter.isdigit():
@@ -1081,7 +1271,7 @@ def export_list_pdf():
q = q.filter(Inspection.overall_score <= float(score_max_filter))
except ValueError:
pass
if inspector_filter.isdigit() and current_user.role != 'inspector':
if inspector_filter.isdigit() and not current_user.is_inspector:
q = q.filter(Inspection.inspector_id == int(inspector_filter))
inspections = q.all()
@@ -1111,7 +1301,7 @@ def export_list_pdf():
filter_parts.append(f'Min score: {score_min_filter}%')
if score_max_filter:
filter_parts.append(f'Max score: {score_max_filter}%')
if inspector_filter.isdigit() and current_user.role != 'inspector':
if inspector_filter.isdigit() and not current_user.is_inspector:
u = db.session.get(User, int(inspector_filter))
if u:
filter_parts.append(f'Inspector: {u.display_name}')
@@ -1141,7 +1331,11 @@ def export_pdf(inspection_id):
if inspection is None:
abort(404)
if current_user.role == 'inspector' and inspection.inspector_id != current_user.id:
# Read access matches the LIST (rule 58) — an inspector may open anything
# at their contracted facilities, not only what they performed. Editing
# someone else's inspection is still refused (execute / save-draft /
# upload-photo / flag-issue keep the authorship check).
if current_user.is_inspector and not _inspector_may_read(inspection, current_user):
flash('Access denied.', 'danger')
return redirect(url_for('inspections.index'))
if current_user.role == 'customer':
@@ -1213,31 +1407,396 @@ def export_pdf(inspection_id):
# ── Flag / clear follow-up required ──────────────────────────────────────────
def _view_url(inspection_id):
"""inspections.view URL that carries the list `next` through.
Actions posted from the detail page redirect back to that same page;
re-attaching `next` is what keeps its Back button (and the next action)
pointed at the filtered list the user arrived from.
"""
nxt = request.form.get('next') or request.args.get('next')
if nxt:
return url_for('inspections.view', inspection_id=inspection_id, next=nxt)
return url_for('inspections.view', inspection_id=inspection_id)
def _inspector_may_read(inspection, user):
"""May this inspector OPEN someone else's inspection?
Yes, when it happened at a facility on one of their contracts the same
scope `index()` uses (rule 58: an inspector's scope covers all data in their
contracted facilities, not just their own work).
This used to test authorship instead, and the two disagreed: the list
showed every inspection at the inspector's facilities, then clicking one
said "Access denied". It also blocked the phase53 follow-up assignee from
opening the parent inspection they had just been asked to re-inspect
the button they needed was on a page they could not reach.
READ only. Editing someone else's inspection is still refused: execute,
save-draft, upload-photo and flag-issue all keep the authorship check.
"""
fids = get_inspector_scope(user)
if fids is None: # not an inspector — no scoping applies here
return True
return bool(fids) and inspection.facility_id in fids
def _followup_assignees_for(inspection, actor):
"""Inspectors who may be handed this inspection's follow-up.
Contract-scoped, for the same reason the flag-issue list is (rule 93): a
Customer Director must never see let alone assign work to another
client's inspector, and one of our own directors picking the wrong name
would leak this facility to an outsider.
Only the two INSPECTOR roles are offered: a follow-up is an inspection, and
directors/PMs/auditors hold no InspectorAssignment, so they cannot be
scoped to a contract and could not open the re-inspection anyway.
A facility with no contract yields nobody fail-closed, leaving the
follow-up with the original inspector.
"""
from app.models.inspector_assignment import InspectorAssignment
project_id = inspection.facility.project_id if inspection.facility else None
if not project_id:
return []
users = (
User.query
.join(InspectorAssignment, InspectorAssignment.user_id == User.id)
.filter(
InspectorAssignment.project_id == project_id,
User.role.in_(User.INSPECTOR_ROLES),
User.active == True,
)
.order_by(User.full_name, User.username)
.all()
)
seen, out = set(), []
for u in users: # the join repeats across assignments
if u.id not in seen:
seen.add(u.id)
out.append(u)
return out
def _collect_inspection_photos(inspection):
"""Relative storage keys owned by an inspection, for cleanup after delete.
Two sources: image field values inside the submitted form data (stored as
`uploads/...` strings in the notes JSON), and the primary photo of each
issue flagged during the inspection. Shared by the single and bulk delete
paths so they cannot drift a miss here leaves orphaned files in storage
forever, and it is invisible.
"""
paths = []
if inspection.notes:
try:
notes_data = json.loads(inspection.notes)
form_data = notes_data.get('_form_data', {}) if isinstance(notes_data, dict) else {}
for val in form_data.values():
if isinstance(val, str) and val.startswith('uploads/'):
paths.append(val)
except (json.JSONDecodeError, TypeError):
pass
for issue in inspection.issues.all():
if issue.photo_path:
paths.append(issue.photo_path)
return paths
# ── Bulk actions from the inspections list ───────────────────────────────────
@bp.route('/bulk', methods=['POST'])
@login_required
def bulk_action():
"""Apply one action to every ticked inspection on the list page.
Partial-failure policy: act on every eligible row, skip the rest, and
report exact counts. Permission is checked per ACTION (all are
admin/director level except the PDF export, which anyone who can see the
list may run); `skipped` therefore means "this row was not in a state the
action applies to".
"""
back = return_url(url_for('inspections.index'))
action = request.form.get('action', '')
ids = request.form.getlist('inspection_ids', type=int)
if not ids:
flash('No inspections selected.', 'warning')
return redirect(back)
supervisor = current_user.role in ('admin', 'director')
allowed = {
'export': True, # read-only, already scoped below
'delete': supervisor,
'flag_followup': supervisor,
'clear_followup': supervisor,
}
if action not in allowed:
flash('Unknown bulk action.', 'danger')
return redirect(back)
if not allowed[action]:
flash('You do not have permission for that bulk action.', 'danger')
return redirect(back)
q = Inspection.query.options(
joinedload(Inspection.facility),
joinedload(Inspection.template),
joinedload(Inspection.inspector),
).filter(Inspection.id.in_(ids))
# Re-apply the viewer's facility scope to the SELECTED ids. The list page
# only ever shows in-scope rows, but the id list arrives in the POST body
# and must not be trusted — a crafted request could otherwise name any
# inspection in the system.
if current_user.is_inspector:
fids = get_inspector_scope(current_user) or []
q = q.filter(Inspection.facility_id.in_(fids)) if fids else q.filter(False)
elif current_user.role == 'customer':
fids = get_customer_scope(current_user) or []
q = q.filter(Inspection.facility_id.in_(fids)) if fids else q.filter(False)
inspections = q.order_by(Inspection.inspection_date.desc()).all()
out_of_scope = len(ids) - len(inspections)
changed = 0
skipped = out_of_scope
# ── Export selected to PDF ───────────────────────────────────────────
if action == 'export':
if not inspections:
flash('None of the selected inspections are available to you.', 'warning')
return redirect(back)
from flask import Response
pdf = generate_inspections_list_pdf(
inspections,
f'Selected inspections ({len(inspections)})',
)
log_action(ACTION_EXPORT, 'Inspection', None, 'bulk PDF export',
f'ids={[i.id for i in inspections]}')
return Response(
pdf,
mimetype='application/pdf',
headers={'Content-Disposition':
'attachment; filename="selected_inspections.pdf"'},
)
# ── Delete ───────────────────────────────────────────────────────────
if action == 'delete':
from app.utils import storage
photo_paths = []
# Snapshot (id, label) BEFORE deleting: the objects are expired after
# the commit, and the audit pass must run after it. log_action()
# commits internally (rule 41), so auditing inside this loop would
# commit the deletes one at a time — and a mid-loop failure would
# leave rows gone with the photo cleanup below never reached.
deleted = []
for insp in inspections:
photo_paths.extend(_collect_inspection_photos(insp))
deleted.append((
insp.id,
f'{insp.template.name if insp.template else ""} @ '
f'{insp.facility.name if insp.facility else ""}',
))
db.session.delete(insp)
changed += 1
db.session.commit()
for insp_id, label in deleted:
log_action(ACTION_DELETE, 'Inspection', insp_id, label,
f'bulk deleted by {current_user.username}')
# Files only after the rows are gone — an orphaned file is recoverable,
# a deleted file belonging to a surviving row is not.
for rel_path in photo_paths:
storage.delete(rel_path)
_flash_bulk(changed, skipped, 'permanently deleted')
# ── Request follow-up ────────────────────────────────────────────────
elif action == 'flag_followup':
note = request.form.get('follow_up_note', '').strip() or None
# Only the rows this run actually flagged. Re-deriving it afterwards
# from `follow_up_requested_by == current_user.id` would also match
# inspections this same user flagged on an EARLIER run and that were
# skipped here as already-flagged — re-notifying their inspectors.
flagged = []
for insp in inspections:
# Same two guards as the single-inspection route: nothing to follow
# up on before submission, and a repeat request must not overwrite
# the pending one's note or attribution.
if insp.status != 'completed' or insp.follow_up_required:
skipped += 1
continue
insp.follow_up_required = True
insp.follow_up_note = note
insp.follow_up_requested_by = current_user.id
insp.follow_up_requested_at = now_eastern()
flagged.append(insp)
changed += 1
db.session.commit()
for insp in flagged:
body = (f'{current_user.display_name} has requested a follow-up '
f're-inspection of "{insp.template.name if insp.template else ""}" '
f'at {insp.facility.name if insp.facility else ""}.'
+ (f' Note: {note}' if note else ''))
inspector = db.session.get(User, insp.inspector_id)
if inspector and inspector.id != current_user.id:
notify(
recipient = inspector,
title = f'Follow-Up Required: Inspection #{insp.id}',
body = body,
link = url_for('inspections.view', inspection_id=insp.id),
inspection_id = insp.id,
event_type = EVENT_INSPECTION_DONE,
send_email = True,
)
# Through the matrix, not straight to managers — rule 73, so
# per-contract recipients fire here exactly as they do for a
# single request.
notify_by_matrix(
event_type = EVENT_FOLLOWUP_REQUESTED,
title = f'Follow-Up Requested: Inspection #{insp.id}',
body = body,
link = url_for('inspections.view', inspection_id=insp.id),
inspection_id = insp.id,
facility_id = insp.facility_id,
exclude_user_ids = {current_user.id,
inspector.id if inspector else None} - {None},
)
db.session.commit() # notify() does not commit — rule 70
# Audited after the commit (rule 41) — log_action commits internally.
for insp in flagged:
log_action(ACTION_UPDATE, 'Inspection', insp.id,
f'{insp.template.name if insp.template else ""}',
f'bulk follow_up_required=True by {current_user.username}')
_flash_bulk(changed, skipped, 'flagged for follow-up',
skip_reason='not submitted, or already flagged')
# ── Clear follow-up ──────────────────────────────────────────────────
elif action == 'clear_followup':
cleared = []
for insp in inspections:
if not insp.follow_up_required:
skipped += 1
continue
insp.follow_up_required = False
insp.follow_up_note = None
insp.follow_up_requested_by = None
insp.follow_up_requested_at = None
insp.follow_up_assigned_to = None
cleared.append(insp)
changed += 1
db.session.commit()
for insp in cleared: # after the commit — rule 41
log_action(ACTION_UPDATE, 'Inspection', insp.id,
f'{insp.template.name if insp.template else ""}',
f'bulk follow_up cleared by {current_user.username}')
_flash_bulk(changed, skipped, 'cleared of the follow-up flag',
skip_reason='not flagged')
current_app.logger.info(
'INSPECTIONS | bulk | action=%s user=%s selected=%s changed=%s skipped=%s',
action, current_user.username, len(ids), changed, skipped,
)
return redirect(back)
def _flash_bulk(changed, skipped, verb, skip_reason='no change needed'):
"""One consistent result message for every bulk action."""
if not changed and not skipped:
flash('Nothing to do.', 'info')
return
parts = [f'{changed} inspection{"s" if changed != 1 else ""} {verb}']
if skipped:
parts.append(f'{skipped} skipped ({skip_reason})')
flash('. '.join(parts) + '.', 'success' if changed else 'warning')
@bp.route('/<int:inspection_id>/flag-followup', methods=['POST'])
@login_required
@supervisor_required
def flag_followup(inspection_id):
"""Mark an inspection as requiring a follow-up re-inspection."""
"""Mark an inspection as requiring a follow-up re-inspection.
Open to admin/director AND to customers for their own facilities a client
unhappy with a result can ask for a re-inspection directly rather than
going through support. Every other role is refused.
Customers may only *request*: they cannot clear the flag (see
clear_followup, still admin/director) nor run the re-inspection itself.
"""
inspection = db.session.get(Inspection, inspection_id)
if inspection is None:
abort(404)
is_customer = current_user.role == 'customer'
if is_customer:
# Same facility scope as view() — a customer must not be able to reach
# another client's inspection with a crafted POST.
if inspection.facility_id not in (get_customer_scope(current_user) or []):
abort(403)
# Nothing to follow up on until the inspection has been submitted.
if inspection.status != 'completed':
flash('You can only request a follow-up on a completed inspection.', 'warning')
return redirect(_view_url(inspection_id))
# Don't let a repeat request overwrite the note/attribution of a pending
# one — the flag is already raised and staff are already on it.
if inspection.follow_up_required:
flash('A follow-up has already been requested for this inspection.', 'info')
return redirect(_view_url(inspection_id))
elif current_user.role not in ('admin', 'director'):
abort(403)
note = request.form.get('follow_up_note', '').strip() or None
# ── Assignee (phase53) ────────────────────────────────────────────────
# Optional. Blank keeps the original behaviour: the follow-up belongs to
# the inspection's own inspector. Validated against the contract-scoped
# list rather than trusted, so a crafted id cannot hand work to another
# customer's inspector (and tell them this facility's name in the email).
assignee_id = request.form.get('follow_up_assigned_to', type=int) or None
if assignee_id:
allowed = {u.id for u in _followup_assignees_for(inspection, current_user)}
if assignee_id not in allowed:
current_app.logger.warning(
'FOLLOW-UP | out-of-contract assignee blocked | inspection=%s | '
'assignee=%s | by=%s',
inspection_id, assignee_id, current_user.username)
flash('That inspector is not assigned to this facility\'s contract.',
'danger')
return redirect(_view_url(inspection_id))
inspection.follow_up_required = True
inspection.follow_up_note = note
inspection.follow_up_requested_by = current_user.id
inspection.follow_up_requested_at = now_eastern()
inspection.follow_up_assigned_to = assignee_id
db.session.commit()
# Notify the original inspector so they see it on the iPad
inspector = db.session.get(User, inspection.inspector_id)
if inspector and inspector.id != current_user.id:
note_suffix = f' Note: {note}' if note else ''
who = (f'The customer ({current_user.display_name})' if is_customer
else current_user.display_name)
assigned_suffix = ''
if inspection.follow_up_assignee:
assigned_suffix = (f' It has been assigned to '
f'{inspection.follow_up_assignee.display_name}.')
body = (
f'{who} has requested a follow-up re-inspection '
f'of "{inspection.template.name}" at {inspection.facility.name}.'
f'{assigned_suffix}{note_suffix}'
)
# Notify whoever now OWNS the follow-up — the assignee when one was named,
# otherwise the original inspector (Inspection.follow_up_owner). Notifying
# the original inspector for work that has been handed to someone else is
# noise, and worse, it implies they are expected to do it.
inspector = inspection.follow_up_owner
if inspector and inspector.id != current_user.id:
notify(
recipient = inspector,
title = f'Follow-Up Required: Inspection #{inspection_id}',
body = (
f'{current_user.username} has requested a follow-up re-inspection '
f'of "{inspection.template.name}" at {inspection.facility.name}.{note_suffix}'
),
body = body,
link = url_for('inspections.view', inspection_id=inspection_id),
inspection_id = inspection_id,
event_type = EVENT_INSPECTION_DONE,
@@ -1245,15 +1804,35 @@ def flag_followup(inspection_id):
)
db.session.commit()
# Route to the staff who action follow-ups. Going through notify_by_matrix
# rather than notifying managers directly keeps recipients admin-configurable
# and lets per-contract recipients fire too (rule 73). This matters most for
# a customer request: without it only the inspector would hear about it and
# nobody would be accountable for scheduling the re-inspection.
notify_by_matrix(
event_type = EVENT_FOLLOWUP_REQUESTED,
title = f'Follow-Up Requested: Inspection #{inspection_id}',
body = body,
link = url_for('inspections.view', inspection_id=inspection_id),
inspection_id = inspection_id,
facility_id = inspection.facility_id,
exclude_user_ids = {current_user.id,
inspector.id if inspector else None} - {None},
)
db.session.commit()
current_app.logger.info(
'INSPECTION FOLLOW-UP FLAGGED | id=%s | by=%s | note=%r',
inspection_id, current_user.username, note,
'INSPECTION FOLLOW-UP FLAGGED | id=%s | by=%s (%s) | note=%r',
inspection_id, current_user.username, current_user.role, note,
)
log_action(ACTION_UPDATE, 'Inspection', inspection_id,
f'{inspection.template.name} @ {inspection.facility.name}',
f'follow_up_required=True; note={note!r}')
f'follow_up_required=True; by_role={current_user.role}; note={note!r}')
if is_customer:
flash('Follow-up re-inspection requested. The team has been notified.', 'success')
else:
flash('Follow-up inspection required flag set.', 'warning')
return redirect(url_for('inspections.view', inspection_id=inspection_id))
return redirect(_view_url(inspection_id))
@bp.route('/<int:inspection_id>/clear-followup', methods=['POST'])
@@ -1266,12 +1845,15 @@ def clear_followup(inspection_id):
abort(404)
inspection.follow_up_required = False
inspection.follow_up_note = None
inspection.follow_up_requested_by = None
inspection.follow_up_requested_at = None
inspection.follow_up_assigned_to = None
db.session.commit()
log_action(ACTION_UPDATE, 'Inspection', inspection_id,
f'{inspection.template.name} @ {inspection.facility.name}',
'follow_up_required=False (cleared)')
flash('Follow-up flag cleared.', 'success')
return redirect(url_for('inspections.view', inspection_id=inspection_id))
return redirect(_view_url(inspection_id))
# ── Start a re-inspection (linked to parent) ──────────────────────────────────
@@ -1290,6 +1872,26 @@ def reinspect(inspection_id):
flash('Access denied.', 'danger')
return redirect(url_for('inspections.index'))
# An inspector may re-inspect their OWN work, or work they have been
# handed the follow-up for (phase53). Anything else at a contracted
# facility is readable but not theirs to redo — starting a re-inspection
# of a colleague's inspection uninvited only creates confusion about who
# is doing it.
if current_user.is_inspector:
if parent.follow_up_required and parent.follow_up_owner:
# A live follow-up has exactly ONE owner (phase53). Even the
# original inspector does not start it once it has been handed to
# someone else — that is the whole point of assigning it, and two
# people turning up is the failure being designed out.
may = parent.follow_up_owner.id == current_user.id
else:
# No follow-up outstanding: re-inspecting your own work is fine,
# someone else's is not yours to redo uninvited.
may = parent.inspector_id == current_user.id
if not may:
flash('That re-inspection has been assigned to someone else.', 'warning')
return redirect(_view_url(inspection_id))
session['reinspect_parent_id'] = parent.id
session['reinspect_template_id'] = parent.template_id
session['reinspect_facility_id'] = parent.facility_id
@@ -1317,20 +1919,7 @@ def delete(inspection_id):
template_name = inspection.template.name
inspector_name = inspection.inspector.username
photo_paths = []
if inspection.notes:
try:
notes_data = json.loads(inspection.notes)
form_data = notes_data.get('_form_data', {}) if isinstance(notes_data, dict) else {}
for val in form_data.values():
if isinstance(val, str) and val.startswith('uploads/'):
photo_paths.append(val)
except (json.JSONDecodeError, TypeError):
pass
for issue in inspection.issues.all():
if issue.photo_path:
photo_paths.append(issue.photo_path)
photo_paths = _collect_inspection_photos(inspection)
db.session.delete(inspection)
db.session.commit()
@@ -1357,4 +1946,4 @@ def delete(inspection_id):
f'has been permanently deleted.',
'success'
)
return redirect(url_for('inspections.index'))
return redirect(return_url(url_for('inspections.index')))
+587 -59
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
from app.utils.decorators import (supervisor_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.utils.pdf_export import generate_issues_list_pdf
@@ -30,17 +31,31 @@ logger = logging.getLogger(__name__)
# ── Shared helper ─────────────────────────────────────────────────────────────
def _notify_followers(issue, title, body, exclude_user_ids=None):
"""Dispatch a notification to every follower of the given issue."""
def _notify_followers(issue, title, body, exclude_user_ids=None,
customer_body=None, skip_customers=False):
"""Dispatch a notification to every follower of the given issue.
Customer followers are handled separately so a staff-only (internal) comment
never leaks to them: pass ``skip_customers=True`` to omit customer followers
entirely, or ``customer_body`` to send them a customer-safe message in place
of ``body``. Non-customer followers always receive ``body``.
"""
exclude = set(exclude_user_ids or [])
issue_link = url_for('issues.view', issue_id=issue.id)
for follower in issue.followers.all():
if follower.user_id in exclude:
continue
is_customer = bool(follower.user) and follower.user.role == 'customer'
if is_customer:
if skip_customers:
continue
f_body = customer_body if customer_body is not None else body
else:
f_body = body
notify(
recipient = follower.user,
title = title,
body = body,
body = f_body,
link = issue_link,
issue_id = issue.id,
event_type = EVENT_ISSUE_FOLLOW,
@@ -69,6 +84,57 @@ 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.
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 89 — 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.
phase49 external (customer / third-party) inspectors are assignable just
like our 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():
@@ -84,7 +150,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)
@@ -138,13 +204,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():
@@ -173,7 +240,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}')
@@ -215,7 +282,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)
@@ -275,7 +342,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),
@@ -283,7 +351,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_(
@@ -310,13 +378,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':
@@ -344,7 +413,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
@@ -382,18 +452,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)
@@ -402,7 +468,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,
@@ -416,11 +482,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():
@@ -429,7 +504,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:
@@ -447,8 +522,8 @@ def view(issue_id):
issue.result_notes = form.result_notes.data or None
# Handler assignment (who handles it) + vendor/facility details —
# admin, director, project_manager only.
if current_user.role in ('admin', 'director', 'project_manager'):
# admin, director, project_manager, auditor only.
if current_user.role in ('admin', 'director', 'project_manager', 'auditor'):
handler = form.handler_type.data or 'internal'
if handler not in ('internal', 'facility', 'vendor'):
handler = 'internal'
@@ -462,6 +537,9 @@ def view(issue_id):
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.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'):
@@ -575,34 +653,58 @@ def view(issue_id):
if old_assigned_to:
exclude_ids.add(old_assigned_to)
# Whether the comment just added is visible to customers. Internal
# (staff-only) comments must NEVER reach customer accounts — they only
# ever hear about comments explicitly shared with them. Only staff reach
# this branch (customers POST via the earlier customer-only path), so the
# checkbox governs. `changes` drives staff-facing notifications;
# `customer_changes` drives every customer-facing dispatch.
comment_customer_visible = bool(comment_body) and ('is_customer_visible' in request.form)
changes = []
customer_changes = []
if old_status != issue.status:
changes.append(
_c = (
f'status changed from "{old_status.replace("_"," ").title()}" '
f'to "{issue.status.replace("_"," ").title()}"'
)
changes.append(_c)
customer_changes.append(_c)
if old_assigned_to != new_assigned_to:
_new_assignee_obj = db.session.get(User, new_assigned_to) if new_assigned_to else None
new_name = _new_assignee_obj.username if _new_assignee_obj else 'Unassigned'
changes.append(f'reassigned to {new_name}')
_c = f'reassigned to {new_name}'
changes.append(_c)
customer_changes.append(_c)
if comment_body:
changes.append(f'new comment added by {current_user.username}')
if comment_customer_visible:
customer_changes.append(f'new comment added by {current_user.username}')
if changes:
_loc = issue.area.name if issue.area else issue.resolved_facility.name if issue.resolved_facility else ''
customer_relevant = bool(customer_changes)
_notify_followers(
issue = issue,
title = f'Issue #{issue.id} Updated',
body = (
f'Issue #{issue.id} in {issue.area.name if issue.area else issue.resolved_facility.name if issue.resolved_facility else ''} was updated by '
f'Issue #{issue.id} in {_loc} was updated by '
f'{current_user.username}: {"; ".join(changes)}.'
),
customer_body = (
f'Issue #{issue.id} in {_loc} was updated by '
f'{current_user.username}: {"; ".join(customer_changes)}.'
) if customer_relevant else None,
skip_customers = not customer_relevant,
exclude_user_ids = exclude_ids,
)
# ── Notify via matrix (issue_updated_customer) ───────────────
# Gated on `customer_changes`: an update whose ONLY change is an internal
# comment leaves this empty, so no customer notification fires.
facility_id = issue.resolved_facility.id if issue.resolved_facility else None
if facility_id and changes:
changes_summary = '; '.join(changes)
if facility_id and customer_changes:
changes_summary = '; '.join(customer_changes)
notify_by_matrix(
event_type = 'issue_updated_customer',
title = f'Issue #{issue.id} Updated at {issue.resolved_facility.name if issue.resolved_facility else ''}',
@@ -621,10 +723,17 @@ 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}; handler={issue.handler_type}; 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())
@@ -634,7 +743,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 ────────────────────────────────────────────────────────────────────
@@ -656,7 +968,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 ──────────────────────────────────────────────────────────────────
@@ -690,7 +1002,7 @@ def unfollow(issue_id):
@bp.route('/new', methods=['GET', 'POST'])
@login_required
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
@@ -712,10 +1024,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.
@@ -752,6 +1066,8 @@ def create():
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()
@@ -802,7 +1118,7 @@ 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)
@@ -812,7 +1128,7 @@ def create():
@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)
@@ -821,7 +1137,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
@@ -841,12 +1157,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)
@@ -876,7 +1206,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'])
@@ -891,18 +1419,18 @@ def request_verification(issue_id):
flash('Access denied.', 'danger')
return redirect(url_for('issues.index'))
# Only the assignee, director, or admin can request verification
# Only the assignee, director, admin, or auditor 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()
@@ -930,13 +1458,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
@@ -1023,7 +1551,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) ───────────────────────────────────────────────────────
@@ -1032,7 +1560,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)
@@ -1092,7 +1620,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:
+90
View File
@@ -306,3 +306,93 @@ def check_score_trends():
logger.info('SCORE TREND CHECK TRIGGERED | alerts_sent=%s', sent)
return jsonify({'ok': True, 'alerts_sent': sent})
# ── Photo retention purge (called by cron) ────────────────────────────────────
@bp.route('/purge-old-photos', methods=['POST'])
@csrf.exempt
def purge_old_photos():
"""Delete photo FILES (not the issue records) for issues resolved longer
ago than PHOTO_RETENTION_DAYS, addressing GDPR Art. 5(1)(e) storage
limitation evidence photos otherwise persist forever.
Disabled by default (no-op) unless PHOTO_RETENTION_DAYS is set in config/
env this is a data-minimization policy the operator opts into, not a
forced deletion, since some deployments may have a longer required
retention for their own contractual/audit reasons.
Only touches RESOLVED issues whose resolved_at predates the cutoff.
Clears photo_path / mobile_photo_paths / result_photos to null/empty and
deletes the underlying files via the storage abstraction (safe on both
the local and R2 backends). The issue record itself, its description,
and its audit trail are untouched only the photo bytes are removed.
Recommended cron schedule nightly is sufficient:
0 4 * * * curl -s -X POST https://yourdomain.com/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 [])
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})
+112 -18
View File
@@ -38,6 +38,55 @@ logger = logging.getLogger(__name__)
bp = Blueprint('public', __name__, url_prefix='/f')
#: 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`).
"""
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
#: Window within which an identical public report is treated as a duplicate.
DUPLICATE_REPORT_WINDOW_SECONDS = 60
def _recent_duplicate_report(facility_id, area_id, description):
"""Return True if an identical public report was just filed.
Belt-and-suspenders against duplicate submissions (double-taps, JS-disabled
clients, retries): if a public issue (reported_by IS NULL) with the same
facility/area and identical description was created within the last
DUPLICATE_REPORT_WINDOW_SECONDS, treat this one as a duplicate and skip it.
"""
cutoff = now_eastern() - timedelta(seconds=DUPLICATE_REPORT_WINDOW_SECONDS)
q = Issue.query.filter(
Issue.reported_by.is_(None),
Issue.reported_at >= cutoff,
Issue.description == description,
)
if area_id is not None:
q = q.filter(Issue.area_id == area_id)
else:
q = q.filter(Issue.facility_id == facility_id, Issue.area_id.is_(None))
return db.session.query(q.exists()).scalar()
def _facility_by_token_or_404(token: str) -> Facility:
"""Resolve an ACTIVE facility from its public token, else 404."""
if not token:
@@ -61,12 +110,34 @@ def _rating_label(score):
return ('Needs attention', 'danger')
def _recent_rows(inspections):
"""Shape recent completed inspections for the occupant page.
Each row carries the date and the occupant-friendly quality label/colour.
The raw score percentage is deliberately NOT included in the payload the
occupant sees the quality label only, so the number cannot leak into the
rendered page. Also excludes inspector names, template/checklist names,
and any per-item detail (occupant-safe).
"""
rows = []
for i in inspections:
score = float(i.overall_score) if i.overall_score is not None else None
label, colour = _rating_label(score) # None -> 'Not yet rated'
rows.append({
'date': i.inspection_date,
'label': label,
'colour': colour,
})
return rows
def _build_summary(facility: Facility) -> dict:
"""Assemble the occupant-facing summary for a facility.
Occupant-safe (rule 74): aggregate rating, counts, a score trend, and
recent inspection DATES only no checklist/template names, no
per-inspection scores, no issue descriptions, and no severity/SLA detail.
recent inspections showing date + quality LABEL only (no score
percentage) no checklist/template names, no inspector names, no issue
descriptions, and no per-item or severity/SLA detail.
"""
fid = facility.id
now = now_eastern()
@@ -123,7 +194,8 @@ def _build_summary(facility: Facility) -> dict:
else:
trend_delta = None
# Recent inspection DATES only (no checklist names, no scores)
# Recent inspections: date + score + quality label (no checklist/template
# names, no inspector names, no per-item detail).
recent = (
Inspection.query
.filter(Inspection.facility_id == fid,
@@ -132,7 +204,7 @@ def _build_summary(facility: Facility) -> dict:
.limit(5)
.all()
)
recent_dates = [i.inspection_date for i in recent]
recent_inspections = _recent_rows(recent)
# Open-issue COUNT (linked directly or via an area) — no details exposed
open_issue_count = (
@@ -166,7 +238,7 @@ def _build_summary(facility: Facility) -> dict:
'resolved_90': resolved_90,
'trend_delta': trend_delta,
'last_inspected': last_insp.inspection_date if last_insp else None,
'recent_dates': recent_dates,
'recent_inspections': recent_inspections,
}
@@ -248,7 +320,7 @@ def _build_area_summary(area, facility) -> dict:
.limit(5)
.all()
)
recent_dates = [i.inspection_date for i in recent]
recent_inspections = _recent_rows(recent)
open_issue_count = (
Issue.query
@@ -279,7 +351,7 @@ def _build_area_summary(area, facility) -> dict:
'resolved_90': resolved_90,
'trend_delta': trend_delta,
'last_inspected': last_insp.inspection_date if last_insp else None,
'recent_dates': recent_dates,
'recent_inspections': recent_inspections,
}
@@ -320,10 +392,6 @@ def report_problem(token):
return render_template('public/facility.html',
form=form, token=token, **summary), 400
# Save optional photo through the shared, magic-byte-validated saver.
from app.routes.inspections import _save_photo
photo_path = _save_photo(form.photo.data, subfolder='issue_photos')
# Fold optional reporter identity + location into the description; the
# public reporter is not a User, so reported_by stays NULL.
parts = ['[Reported via facility QR code]']
@@ -336,12 +404,26 @@ def report_problem(token):
parts.append(form.description.data.strip())
description = '\n'.join(parts)
# Server-side idempotency: silently accept an identical repeat as success
# without creating a second issue (or saving its photos).
if _recent_duplicate_report(facility.id, None, description):
logger.info('PUBLIC REPORT | duplicate suppressed | facility_id=%s | ip=%s',
facility.id, request.remote_addr)
flash('Thank you — your report has been received and the team has been notified.',
'success')
return redirect(url_for('public.facility_summary', token=token))
# Save up to 5 optional photos through the shared, magic-byte-validated
# saver. First → photo_path, the rest → mobile_photo_paths.
photo_path, extra_photos = _save_report_photos(form.photos.data)
issue = Issue(
facility_id = facility.id,
area_id = None,
severity = 'medium',
description = description,
photo_path = photo_path,
mobile_photo_paths = extra_photos,
status = 'open',
reported_at = now_eastern(),
reported_by = None,
@@ -349,8 +431,9 @@ def report_problem(token):
db.session.add(issue)
db.session.commit()
logger.info('PUBLIC REPORT | issue_id=%s | facility_id=%s | ip=%s | photo=%s',
issue.id, facility.id, request.remote_addr, bool(photo_path))
_photo_count = (1 if photo_path else 0) + (len(extra_photos) if extra_photos else 0)
logger.info('PUBLIC REPORT | issue_id=%s | facility_id=%s | ip=%s | photos=%s',
issue.id, facility.id, request.remote_addr, _photo_count)
# Reuse the standard issue-created routing (staff + facility customers).
notify_by_matrix(
@@ -390,9 +473,6 @@ def area_report_problem(token):
return render_template('public/area.html',
form=form, token=token, **summary), 400
from app.routes.inspections import _save_photo
photo_path = _save_photo(form.photo.data, subfolder='issue_photos')
# The area is known from the QR token, so we set area_id directly and note
# the source. A public reporter is not a User, so reported_by stays NULL.
parts = [f'[Reported via area QR code — {area.name}]']
@@ -405,12 +485,25 @@ def area_report_problem(token):
parts.append(form.description.data.strip())
description = '\n'.join(parts)
# Server-side idempotency: silently accept an identical repeat as success
# without creating a second issue (or saving its photos).
if _recent_duplicate_report(facility.id, area.id, description):
logger.info('PUBLIC REPORT | duplicate suppressed | area_id=%s | facility_id=%s | ip=%s',
area.id, facility.id, request.remote_addr)
flash('Thank you — your report has been received and the team has been notified.',
'success')
return redirect(url_for('public.area_summary', token=token))
# Save up to 5 optional photos (first → photo_path, rest → mobile_photo_paths).
photo_path, extra_photos = _save_report_photos(form.photos.data)
issue = Issue(
facility_id = facility.id,
area_id = area.id,
severity = 'medium',
description = description,
photo_path = photo_path,
mobile_photo_paths = extra_photos,
status = 'open',
reported_at = now_eastern(),
reported_by = None,
@@ -418,8 +511,9 @@ def area_report_problem(token):
db.session.add(issue)
db.session.commit()
logger.info('PUBLIC REPORT | issue_id=%s | area_id=%s | facility_id=%s | ip=%s | photo=%s',
issue.id, area.id, facility.id, request.remote_addr, bool(photo_path))
_photo_count = (1 if photo_path else 0) + (len(extra_photos) if extra_photos else 0)
logger.info('PUBLIC REPORT | issue_id=%s | area_id=%s | facility_id=%s | ip=%s | photos=%s',
issue.id, area.id, facility.id, request.remote_addr, _photo_count)
notify_by_matrix(
event_type = 'issue_created',
+43 -31
View File
@@ -57,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'))
@@ -66,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:
@@ -103,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)
@@ -262,7 +263,8 @@ 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 = [{
@@ -307,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'))
@@ -320,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(
@@ -369,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'))
@@ -385,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,
@@ -701,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 []
@@ -717,6 +722,10 @@ def _build_inspector_stats(start, end):
inspector_stats.append({
'id': u.id,
'display_name': u.display_name,
# phase49 — customer / third-party inspectors appear in the same
# table as our own crew, badged so the numbers can be read in
# context. Consumed by the HTML table and the Excel export.
'external': u.is_external_inspector,
'total': tot,
'completed': comp,
'completion_rate': round(comp / tot * 100) if tot else 0,
@@ -759,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(
@@ -857,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)
@@ -931,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'],
# phase49 — external 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'],
@@ -1616,7 +1628,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:
+519 -36
View File
@@ -3,8 +3,12 @@ app/routes/scheduled_inspections.py
------------------------------------
Planned / recurring inspection assignments (phase36).
Management (list/new/edit/delete) : admin, director, project_manager
Start (execute the planned inspection): the assigned inspector, or admin/director/pm
Management (list/new/edit/delete) : admin, director, project_manager, auditor,
plus CUSTOMER DIRECTORS, scoped to the
facilities on their own contracts
Start (execute the planned inspection): the assigned inspector ONLY (the person
who must do it) not managers. A manager
who needs to run it assigns it to themselves.
Cron reminders : POST /run?token=DIGEST_SECRET (no login)
Fulfillment (marking a schedule done and rolling recurring ones forward) happens
@@ -18,15 +22,18 @@ from datetime import timedelta
from flask import (Blueprint, render_template, redirect, url_for, flash,
request, abort, current_app)
from flask_login import login_required, current_user
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
from app import db
from app.models.scheduled_inspection import ScheduledInspection
from app.models.scheduled_inspection import (ScheduledInspection,
MONTH_MODE_DAY, MONTH_MODE_NTH)
from app.models.facility import Facility
from app.models.inspection import Inspection, InspectionTemplate
from app.models.project import Project
from app.models.user import User
from app.utils.forms import ScheduledInspectionForm
from app.utils.decorators import project_manager_required
from functools import wraps
from app.utils.scope import get_customer_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
@@ -37,22 +44,180 @@ logger = logging.getLogger(__name__)
bp = Blueprint('scheduled_inspections', __name__, url_prefix='/scheduled-inspections')
# ── Email "Confirm receipt" one-click token (phase47) ─────────────────────────
# A signed, stateless token (no DB column) lets the assigned inspector confirm
# receipt straight from the assignment email, even when not logged in — the same
# login-free pattern the public QR pages use. The token binds the schedule id to
# the inspector id, so a schedule reassigned to someone else invalidates any
# link that was emailed to the previous assignee.
_ACK_SALT = 'scheduled-inspection-ack'
_ACK_MAX_AGE = 60 * 60 * 24 * 30 # 30 days — a link older than this is expired
def _ack_serializer():
return URLSafeTimedSerializer(current_app.config['SECRET_KEY'], salt=_ACK_SALT)
def _make_ack_token(sched):
"""Signed token embedding the schedule id + the assigned inspector id."""
return _ack_serializer().dumps({'sid': sched.id, 'iid': sched.inspector_id})
def _confirm_action(sched):
"""`extra_action` dict for the email "Confirm receipt" button, or None when
there is nothing to confirm (no inspector, or already acknowledged). Requires
a request context for the external URL. Reused by the assignment email and
the advance/due reminder emails so an unconfirmed inspector can always
confirm from whichever email reaches them."""
if not sched.inspector_id or sched.is_acknowledged:
return None
return {
'label': 'Confirm receipt',
'url': url_for('scheduled_inspections.confirm_email',
token=_make_ack_token(sched), _external=True),
}
#: Our own staff who plan inspections. Customer Directors are added on top by
#: schedule_manager_required — they plan work for their OWN facilities only.
_STAFF_SCHEDULERS = ('admin', 'director', 'project_manager', 'auditor')
def _is_customer_director(user):
"""True only for the portal customer role.
Equality on purpose (rule 89): a Customer Inspector PERFORMS scheduled
inspections, they do not plan them, and they are scoped by
InspectorAssignment rather than CustomerAssignment. Widening this to
User.CUSTOMER_ROLES would hand them a planning screen scoped by the wrong
table i.e. no facilities at all.
"""
return getattr(user, 'role', None) == 'customer'
def schedule_manager_required(f):
"""Who may create / edit / delete a scheduled inspection.
Our staff (_STAFF_SCHEDULERS) plus **Customer Directors**, who schedule
inspections for the facilities they are assigned. Every choice list and
every POST is narrowed to their own contracts see _populate_choices(),
_active_contracts() and _schedule_in_scope().
"""
@wraps(f)
def wrapper(*args, **kwargs):
if not current_user.is_authenticated:
abort(403)
if current_user.role in _STAFF_SCHEDULERS or _is_customer_director(current_user):
return f(*args, **kwargs)
flash('You do not have permission to manage scheduled inspections.', 'danger')
return redirect(url_for('dashboard.index'))
return wrapper
def _customer_facility_ids():
"""Facility ids the current Customer Director may schedule against."""
return set(get_customer_scope(current_user) or [])
def _customer_project_ids():
"""Contract ids behind those facilities.
Derived from the facilities rather than straight off CustomerAssignment, so
a facility-level assignment resolves to its owning contract and the
contract selector still lines up with the facilities on offer.
"""
fids = _customer_facility_ids()
if not fids:
return set()
return {
f.project_id
for f in Facility.query.filter(Facility.id.in_(fids)).all()
if f.project_id
}
def _schedule_in_scope(sched):
"""May the current user act on this schedule?
Staff: any. Customer Director: only schedules at a facility they are
assigned checked on edit and delete so a hand-typed id cannot reach
another customer's schedule.
"""
if not _is_customer_director(current_user):
return True
return sched.facility_id in _customer_facility_ids()
def _populate_choices(form):
# facility_id choices are ALL active facilities so POST validation passes
# regardless of which contract the UI-only contract selector had chosen
# (CLAUDE.md rule 61). The contract selector narrows the list client-side.
facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all()
#
# For a Customer Director every list is narrowed to their own contracts.
# These choices ARE the POST validation (SelectField rejects anything not
# offered), so this is the security boundary, not just a tidier dropdown:
# a hand-crafted facility_id or inspector_id from another customer fails
# validation here rather than being stored.
customer_scoped = _is_customer_director(current_user)
fac_q = Facility.query.filter_by(active=True)
if customer_scoped:
fids = _customer_facility_ids()
fac_q = fac_q.filter(Facility.id.in_(fids)) if fids else fac_q.filter(False)
facilities = fac_q.order_by(Facility.name).all()
if customer_scoped:
# Shared forms plus those attached to their contracts (phase52) — the
# same union the mobile API builds, so another customer's bespoke form
# NAMES never appear in the picker (rule 96).
seen, templates = set(), []
for pid in list(_customer_project_ids()) + [None]:
for t in InspectionTemplate.available_query(pid).all():
if t.id not in seen:
seen.add(t.id)
templates.append(t)
templates.sort(key=lambda t: (t.name or '').lower())
else:
templates = (InspectionTemplate.query
.filter_by(active=True).order_by(InspectionTemplate.name).all())
inspectors = (User.query.filter_by(role='inspector', active=True)
.order_by(User.username).all())
insp_q = User.query.filter(User.role.in_(User.INSPECTOR_ROLES),
User.active == True)
if customer_scoped:
# Only inspectors working on their contracts — theirs and ours. Without
# this a customer would see (and could assign) every inspector in the
# system, including another client's Customer Inspector, exactly as the
# flag-issue dropdown used to (rule 93).
from app.models.inspector_assignment import InspectorAssignment
pids = _customer_project_ids()
insp_q = (insp_q.join(InspectorAssignment,
InspectorAssignment.user_id == User.id)
.filter(InspectorAssignment.project_id.in_(pids))
if pids else insp_q.filter(False))
inspectors = insp_q.order_by(User.username).all()
# The join can repeat a user across assignment rows.
_seen_ids, _uniq = set(), []
for u in inspectors:
if u.id not in _seen_ids:
_seen_ids.add(u.id)
_uniq.append(u)
inspectors = _uniq
form.facility_id.choices = [(f.id, f.name) for f in facilities]
form.template_id.choices = [(t.id, t.name) for t in templates]
form.inspector_id.choices = [(u.id, u.display_name) for u in inspectors]
def _active_contracts():
return Project.query.filter_by(active=True).order_by(Project.name).all()
"""Contracts offered in the UI-only contract selector.
Narrowed to the Customer Director's own contracts so the selector cannot
even name another customer's contract.
"""
q = Project.query.filter_by(active=True)
if _is_customer_director(current_user):
pids = _customer_project_ids()
q = q.filter(Project.id.in_(pids)) if pids else q.filter(False)
return q.order_by(Project.name).all()
def _notify_assignee(sched, reassigned=False):
@@ -70,13 +235,151 @@ def _notify_assignee(sched, reassigned=False):
title = f'Scheduled inspection {verb}{fac}',
body = (f'A "{tpl}" inspection at {fac} has been {verb} '
f'({sched.frequency_label.lower()}), due '
f'{sched.next_due_date:%b %d, %Y}.'),
f'{sched.next_due_date:%b %d, %Y}. '
f'Please confirm you received this request.'),
link = url_for('scheduled_inspections.index'),
event_type = EVENT_SCHEDULED_INSPECTION,
send_email = True,
extra_action = _confirm_action(sched),
)
def _notify_creator_acknowledged(sched):
"""Notify the schedule's creator that the assigned inspector has confirmed
receipt of the request. No-op when there is no creator, the creator is
inactive, or the creator IS the inspector (self-assigned). Caller commits."""
creator = sched.creator
if not creator or not creator.active or creator.id == sched.inspector_id:
return
fac = sched.facility.name if sched.facility else ''
tpl = sched.template.name if sched.template else ''
who = sched.inspector.display_name if sched.inspector else 'The inspector'
notify(
recipient = creator,
title = f'Inspector confirmed receipt — {fac}',
body = (f'{who} confirmed receipt of the "{tpl}" scheduled '
f'inspection at {fac} ({sched.frequency_label.lower()}), '
f'due {sched.next_due_date:%b %d, %Y}.'),
link = url_for('scheduled_inspections.index'),
event_type = EVENT_SCHEDULED_INSPECTION,
send_email = True,
)
def _apply_recurrence(sched, form):
"""Copy the recurrence block for the chosen frequency onto *sched* and
clear the blocks that no longer apply, then snap next_due_date onto the
rule. Keeping the unused columns NULL means `recurrence_label` and the
date math never read stale settings after a frequency change."""
sched.frequency = form.frequency.data
if sched.frequency == 'weekly':
sched.set_weekdays(form.weekdays.data)
else:
sched.weekdays = None
if sched.frequency == 'monthly':
sched.month_mode = form.month_mode.data or MONTH_MODE_DAY
if sched.month_mode == MONTH_MODE_NTH:
sched.day_of_month = None
sched.nth_week = form.nth_week.data
sched.nth_weekday = form.nth_weekday.data
else:
sched.day_of_month = form.day_of_month.data
sched.nth_week = None
sched.nth_weekday = None
else:
sched.month_mode = sched.day_of_month = None
sched.nth_week = sched.nth_weekday = None
# End date (phase44) — a boundary, not a cadence setting. A one-time
# schedule has none: it ends by deactivating when it is completed.
sched.end_date = form.end_date.data if sched.frequency != 'once' else None
# Snap the picked date forward onto the first matching occurrence.
sched.next_due_date = sched.align_due_date(form.next_due_date.data)
def _reject_if_past_end_date(sched, form):
"""True (and a form error set) if the aligned first occurrence falls past
the end date.
The form already rejects an end date earlier than the *picked* due date, but
align_due_date() can push that date forward onto the recurrence rule pick
a Tuesday for a Mon/Wed/Fri schedule and the first occurrence is Wednesday.
Without this check that combination would save as active with no occurrence
it is ever allowed to run.
"""
if sched.is_within_end_date(sched.next_due_date):
return False
form.end_date.errors.append(
f'With this recurrence the first occurrence falls on '
f'{sched.next_due_date:%b %d, %Y}, after the end date.')
return True
def _reject_facility_out_of_scope(form):
"""True (and a form error set) if a Customer Director picked a facility
outside their own contracts.
Belt-and-braces: _populate_choices() already narrows facility_id, and
SelectField rejects anything not offered, so this should be unreachable.
It is here because that guard lives in how a list was BUILT one future
change to the choice-building and a crafted POST would otherwise schedule
work at another customer's building.
"""
if not _is_customer_director(current_user):
return False
if form.facility_id.data in _customer_facility_ids():
return False
current_app.logger.warning(
'SCHED INSP | out-of-scope facility blocked | user=%s | facility_id=%s',
current_user.username, form.facility_id.data,
)
form.facility_id.errors.append(
'That facility is not one of yours. Choose a facility from your contracts.')
return True
def _reject_template_not_on_contract(form):
"""True (and a form error set) if the chosen form isn't usable at the
chosen facility (phase52).
The template choices stay unrestricted for the same reason the facility
choices do (rule 61 the contract selector is UI-only, so POST validation
must not depend on it). That means the contract restriction has to be
enforced HERE, after validation, against the facility actually picked.
Without this a manager could schedule one customer's bespoke form against
another customer's facility, and the mismatch would only surface when the
inspector opened it.
"""
facility = db.session.get(Facility, form.facility_id.data)
template = db.session.get(InspectionTemplate, form.template_id.data)
if template is None or facility is None:
return False
if template.available_for_project(facility.project_id):
return False
form.template_id.errors.append(
f'"{template.name}" is not available on '
f'{facility.project.name if facility.project else "this facility\'s contract"}. '
f'Choose a form attached to that contract, or a shared form.')
return True
def _open_inspection_ids(schedules):
"""{schedule_id: inspection_id} for schedules with an inspection already
in progress, so the UI offers Continue instead of a duplicate Start."""
ids = [s.id for s in schedules if s.id]
if not ids:
return {}
rows = (Inspection.query
.filter(Inspection.scheduled_inspection_id.in_(ids),
Inspection.status == 'in_progress')
.order_by(Inspection.id.desc())
.all())
return {r.scheduled_inspection_id: r.id for r in rows}
def _selected_project_id(form):
"""Contract of the submitted facility (for restoring the selector on
re-render), or None."""
@@ -92,52 +395,93 @@ def _selected_project_id(form):
@bp.route('/')
@login_required
def index():
if current_user.role == 'customer':
abort(403)
# Customer Directors now plan inspections for their own facilities, so they
# reach this list too — narrowed below. Customer Inspectors already saw it
# (they are inspectors) and keep their own-assignments-only view.
# Two tabs (phase47): Pending = schedules still producing occurrences
# (active); Completed = closed schedules (fulfilled one-times, ended
# recurring, or manually deactivated). The partition is exhaustive and
# non-overlapping, so every schedule appears in exactly one tab; the in-row
# Status badge (Active / Ended / Inactive) disambiguates the closed ones.
tab = request.args.get('tab', 'pending')
if tab not in ('pending', 'completed'):
tab = 'pending'
today = now_eastern().date()
q = ScheduledInspection.query
base = ScheduledInspection.query
# Inspectors see only their own assignments; managers see everything.
if current_user.role == 'inspector':
q = q.filter(ScheduledInspection.inspector_id == current_user.id)
if current_user.is_inspector:
base = base.filter(ScheduledInspection.inspector_id == current_user.id)
elif _is_customer_director(current_user):
# Only schedules at facilities they are assigned. An empty scope must
# match nothing rather than everything — filter(False), not a skipped
# filter (rule 57's failure mode).
fids = _customer_facility_ids()
base = (base.filter(ScheduledInspection.facility_id.in_(fids))
if fids else base.filter(False))
schedules = q.order_by(
ScheduledInspection.active.desc(),
ScheduledInspection.next_due_date.asc(),
).all()
pending_count = base.filter(ScheduledInspection.active.is_(True)).count()
completed_count = base.filter(ScheduledInspection.active.is_(False)).count()
if tab == 'pending':
schedules = (base.filter(ScheduledInspection.active.is_(True))
.order_by(ScheduledInspection.next_due_date.asc()).all())
else:
# Most recently completed first; NULL last_completed (e.g. manually
# switched off before ever running) sorts last under MySQL DESC.
schedules = (base.filter(ScheduledInspection.active.is_(False))
.order_by(ScheduledInspection.last_completed_at.desc(),
ScheduledInspection.next_due_date.desc()).all())
return render_template('scheduled_inspections/list.html',
schedules=schedules, today=today)
schedules=schedules, today=today, tab=tab,
pending_count=pending_count, completed_count=completed_count,
open_inspections=_open_inspection_ids(schedules))
# ── Create ──────────────────────────────────────────────────────────────────
@bp.route('/new', methods=['GET', 'POST'])
@login_required
@project_manager_required
@schedule_manager_required
def create():
form = ScheduledInspectionForm()
_populate_choices(form)
# On a new schedule this date IS the start; on edit it is whatever the next
# occurrence happens to be. One field, two meanings — so the label follows
# the context instead of saying both at once.
form.next_due_date.label.text = 'Start Date'
if not form.next_due_date.data:
form.next_due_date.data = now_eastern().date()
if form.validate_on_submit():
if (form.validate_on_submit()
and not _reject_facility_out_of_scope(form)
and not _reject_template_not_on_contract(form)):
sched = ScheduledInspection(
facility_id = form.facility_id.data,
template_id = form.template_id.data,
inspector_id = form.inspector_id.data,
frequency = form.frequency.data,
next_due_date = form.next_due_date.data,
notes = (form.notes.data or '').strip() or None,
active = form.active.data,
created_by = current_user.id,
)
_apply_recurrence(sched, form)
if _reject_if_past_end_date(sched, form):
# sched was never added to the session — nothing to roll back.
return render_template('scheduled_inspections/form.html',
form=form, title='New Scheduled Inspection',
projects=_active_contracts(),
selected_project_id=_selected_project_id(form))
db.session.add(sched)
db.session.commit()
log_action(ACTION_CREATE, 'ScheduledInspection', sched.id,
f'{sched.template.name} @ {sched.facility.name}',
f'freq={sched.frequency}; due={sched.next_due_date}; inspector={sched.inspector_id}')
f'freq={sched.recurrence_label}; due={sched.next_due_date}; '
f'end={sched.end_date or ""}; '
f'inspector={sched.inspector_id}')
logger.info('SCHED INSP | create | by=%s | id=%s', current_user.username, sched.id)
# Notify the assigned inspector immediately.
@@ -157,27 +501,51 @@ def create():
@bp.route('/<int:schedule_id>/edit', methods=['GET', 'POST'])
@login_required
@project_manager_required
@schedule_manager_required
def edit(schedule_id):
sched = db.session.get(ScheduledInspection, schedule_id)
if sched is None:
abort(404)
if not _schedule_in_scope(sched):
abort(403)
form = ScheduledInspectionForm(obj=sched)
_populate_choices(form)
form.next_due_date.label.text = 'Next Due Date'
if request.method == 'GET':
# obj= copies the raw CSV column into a multi-select field; hand it the
# parsed int list instead so the checkboxes pre-tick correctly.
form.weekdays.data = sched.weekday_list
form.month_mode.data = sched.month_mode or MONTH_MODE_DAY
if form.validate_on_submit():
if (form.validate_on_submit()
and not _reject_facility_out_of_scope(form)
and not _reject_template_not_on_contract(form)):
old_inspector_id = sched.inspector_id
sched.facility_id = form.facility_id.data
sched.template_id = form.template_id.data
sched.inspector_id = form.inspector_id.data
sched.frequency = form.frequency.data
sched.next_due_date = form.next_due_date.data
sched.notes = (form.notes.data or '').strip() or None
sched.active = form.active.data
# Reassigning to a different inspector invalidates any prior confirmation
# — the new assignee has not yet acknowledged the request (phase47).
if sched.inspector_id != old_inspector_id:
sched.acknowledged_at = None
_apply_recurrence(sched, form)
if _reject_if_past_end_date(sched, form):
# sched is a persistent object and has already been mutated — discard
# those pending changes before re-rendering so nothing leaks out on
# the next flush.
db.session.rollback()
return render_template('scheduled_inspections/form.html',
form=form, title='Edit Scheduled Inspection',
schedule=sched, projects=_active_contracts(),
selected_project_id=_selected_project_id(form))
db.session.commit()
log_action(ACTION_UPDATE, 'ScheduledInspection', sched.id,
f'{sched.template.name} @ {sched.facility.name}',
f'freq={sched.frequency}; due={sched.next_due_date}; active={sched.active}')
f'freq={sched.recurrence_label}; due={sched.next_due_date}; '
f'end={sched.end_date or ""}; '
f'active={sched.active}')
# Notify the inspector if the assignment changed to them.
if sched.active and sched.inspector_id and sched.inspector_id != old_inspector_id:
@@ -202,11 +570,13 @@ def edit(schedule_id):
@bp.route('/<int:schedule_id>/delete', methods=['POST'])
@login_required
@project_manager_required
@schedule_manager_required
def delete(schedule_id):
sched = db.session.get(ScheduledInspection, schedule_id)
if sched is None:
abort(404)
if not _schedule_in_scope(sched):
abort(403)
label = f'{sched.template.name if sched.template else "?"} @ {sched.facility.name if sched.facility else "?"}'
sid = sched.id
db.session.delete(sched)
@@ -224,11 +594,11 @@ def start(schedule_id):
sched = db.session.get(ScheduledInspection, schedule_id)
if sched is None:
abort(404)
if current_user.role == 'customer':
abort(403)
# Only the assigned inspector, or a manager, may start it.
if current_user.role == 'inspector' and sched.inspector_id != current_user.id:
# Only the assigned inspector may start it — this inspection is theirs to do.
# Managers (admin/director/pm) manage the schedule but do not start it for
# someone else; if a manager needs to do the inspection, assign it to them.
if not sched.inspector_id or sched.inspector_id != current_user.id:
abort(403)
if not sched.active:
@@ -240,6 +610,16 @@ def start(schedule_id):
flash('The template for this schedule has no form fields yet.', 'warning')
return redirect(url_for('scheduled_inspections.index'))
# Already started but not submitted? Resume it rather than opening a second
# inspection against the same occurrence.
existing = (Inspection.query
.filter_by(scheduled_inspection_id=sched.id, status='in_progress')
.order_by(Inspection.id.desc())
.first())
if existing is not None:
flash('Resuming the inspection you already started for this schedule.', 'info')
return redirect(url_for('inspections.execute', inspection_id=existing.id))
inspection = Inspection(
template_id = sched.template_id,
facility_id = sched.facility_id,
@@ -248,6 +628,12 @@ def start(schedule_id):
inspection_date = now_eastern(),
status = 'in_progress',
scheduled_inspection_id = sched.id,
# phase45 — a schedule created by "Schedule Follow-up" carries the
# inspection it is a follow-up of. Inheriting it here is what makes the
# run a real linked re-inspection: execute() pre-fills from the parent
# and submit clears the parent's follow_up_required. NULL for ordinary
# schedules, which is every pre-phase45 row.
parent_inspection_id = sched.parent_inspection_id,
)
db.session.add(inspection)
db.session.commit()
@@ -260,6 +646,88 @@ def start(schedule_id):
return redirect(url_for('inspections.execute', inspection_id=inspection.id))
# ── Acknowledge (inspector confirms receipt of the request) ───────────────────
@bp.route('/<int:schedule_id>/acknowledge', methods=['POST'])
@login_required
def acknowledge(schedule_id):
"""The assigned inspector confirms they have received the scheduled request.
Assignee-only (like Start): a manager cannot confirm on someone's behalf.
Idempotent confirming an already-confirmed schedule is a no-op. On the
first confirmation the schedule's creator is notified."""
sched = db.session.get(ScheduledInspection, schedule_id)
if sched is None:
abort(404)
if not sched.inspector_id or sched.inspector_id != current_user.id:
abort(403)
if _do_acknowledge(sched, current_user.username):
flash('You have confirmed receipt of this scheduled inspection.', 'success')
else:
flash('You have already confirmed this scheduled inspection.', 'info')
return redirect(url_for('scheduled_inspections.index'))
def _do_acknowledge(sched, actor_username):
"""Stamp acknowledged_at, log the action, and notify the creator. Idempotent:
returns True if this call newly confirmed, False if it was already confirmed.
Shared by the logged-in POST route and the login-free email-token GET route.
The caller must have verified the actor is the assigned inspector."""
if sched.acknowledged_at is not None:
return False
sched.acknowledged_at = now_eastern()
db.session.commit()
log_action(ACTION_UPDATE, 'ScheduledInspection', sched.id,
f'{sched.template.name if sched.template else "?"} @ '
f'{sched.facility.name if sched.facility else "?"}',
'inspector confirmed receipt')
logger.info('SCHED INSP | acknowledge | schedule=%s | by=%s',
sched.id, actor_username)
_notify_creator_acknowledged(sched)
db.session.commit()
return True
# ── Confirm receipt from the assignment email (login-free, token-signed) ──────
@bp.route('/confirm/<token>')
def confirm_email(token):
"""One-click "Confirm receipt" landing from the assignment email (phase47).
Login-free: authorised by the signed token, which binds the schedule id to
the inspector id it was emailed to. Renders a standalone result page. The
acknowledgement is idempotent, so a re-click (or an email client prefetch)
is harmless."""
try:
data = _ack_serializer().loads(token, max_age=_ACK_MAX_AGE)
except SignatureExpired:
return render_template('scheduled_inspections/confirm_result.html',
status='expired'), 400
except BadSignature:
return render_template('scheduled_inspections/confirm_result.html',
status='invalid'), 400
sid = data.get('sid')
sched = db.session.get(ScheduledInspection, sid) if sid else None
if sched is None:
return render_template('scheduled_inspections/confirm_result.html',
status='missing'), 404
# The token's inspector must still be the assigned inspector — a schedule
# reassigned to someone else invalidates the previous assignee's link.
if not sched.inspector_id or sched.inspector_id != data.get('iid'):
return render_template('scheduled_inspections/confirm_result.html',
status='reassigned', sched=sched), 409
if not sched.active:
return render_template('scheduled_inspections/confirm_result.html',
status='inactive', sched=sched)
newly = _do_acknowledge(
sched, sched.inspector.username if sched.inspector else 'inspector')
return render_template('scheduled_inspections/confirm_result.html',
status='confirmed' if newly else 'already', sched=sched)
# ── Cron: reminders (advance / due / overdue) ─────────────────────────────────
@bp.route('/run', methods=['POST'])
@@ -269,10 +737,23 @@ def run_reminders():
abort(403)
today = now_eastern().date()
sent = {'advance': 0, 'due': 0, 'overdue': 0}
sent = {'advance': 0, 'due': 0, 'overdue': 0, 'expired': 0}
schedules = ScheduledInspection.query.filter_by(active=True).all()
# Expire schedules past their end date BEFORE any reminder work (phase44).
# fulfill() closes out a schedule that reaches its boundary by being
# completed; this covers the one that reaches it without ever being done —
# otherwise it stays active and re-alerts as overdue indefinitely.
live = []
for s in schedules:
if s.expire_if_past_end_date(today):
sent['expired'] += 1
logger.info('SCHED INSP | expired | id=%s | end=%s', s.id, s.end_date)
else:
live.append(s)
schedules = live
# Cache admin/director recipients for overdue alerts
managers = User.query.filter(
User.role.in_(['admin', 'director']), User.active == True # noqa: E712
@@ -295,6 +776,7 @@ def run_reminders():
link = link,
event_type = EVENT_SCHEDULED_INSPECTION,
send_email = True,
extra_action = _confirm_action(s),
)
s.advance_notified = True
sent['advance'] += 1
@@ -310,6 +792,7 @@ def run_reminders():
link = link,
event_type = EVENT_SCHEDULED_INSPECTION,
send_email = True,
extra_action = _confirm_action(s),
)
s.due_notified = True
sent['due'] += 1
@@ -331,6 +814,6 @@ def run_reminders():
sent['overdue'] += 1
db.session.commit()
logger.info('SCHED INSP | reminders | advance=%s due=%s overdue=%s',
sent['advance'], sent['due'], sent['overdue'])
logger.info('SCHED INSP | reminders | advance=%s due=%s overdue=%s expired=%s',
sent['advance'], sent['due'], sent['overdue'], sent['expired'])
return {'ok': True, 'sent': sent}, 200
+257 -36
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
@@ -27,8 +27,10 @@ You are JQC Support, a friendly assistant for CUSTOMERS of JQC (Janitorial Quali
Control), a commercial cleaning quality-management platform used by a janitorial \
service provider and its clients. You help the client (customer) understand and use \
their portal. Only describe what a CUSTOMER can do do not tell customers they can \
perform staff-only actions (assigning issues, running inspections, editing templates, \
managing users, notification matrix, etc.).
perform staff-only actions (assigning issues, editing templates, managing users, \
notification matrix, etc.) from their customer login. One important exception: a person \
who works for the customer CAN be enrolled with an inspecting role of their own and \
then conduct inspections in the app see USING THE APP TO INSPECT below.
=== WHAT JQC DOES ===
The janitorial provider performs quality inspections of the customer's facilities \
@@ -90,6 +92,30 @@ inspection completed at their facility, or updates on issues they follow/reporte
Notification Preferences let a customer turn specific email types off or switch to a \
digest.
=== USING THE APP TO INSPECT (client staff, contract administrators, inspectors) ===
Q: Can a client/customer, contract administrator, or inspector use the JQC app to \
conduct inspections?
A: Yes. A customer's employee can use the JQC app not only for cleaning inspections but \
also to carry out preventive maintenance on the property and to submit service requests \
to third parties. They download the app, complete the enrollment form, and they are \
ready to go.
Such a person is enrolled with an inspecting role of their own (rather than the \
read-only customer portal role), scoped to that customer's own contracts and facilities. \
See ENROLLING MORE PEOPLE below for the form.
=== ENROLLING MORE PEOPLE (adding users) ===
To add colleagues to JQC, use the JQC Enrollment Form:
https://jqc.ltservicesinc.com/enrollment
It is also linked from the About Us page in the portal ("Enroll More People"). No login \
is needed to fill it in, so it can be forwarded to anyone who needs to be set up.
On the form you list each person (name, job title, email and the role they need), tick \
what each of them should be able to do, and say who should get the mobile app. After \
submitting, the requester receives a confirmation email with a reference number, and \
each person listed then receives their own email invitation to choose a username and \
password.
When a customer asks how to add a user, get the app, or give someone inspecting access, \
give them this URL. It is the correct link do not alter it or invent another one.
=== GETTING HUMAN HELP ===
If the customer needs something this chat can't resolve — an access/login problem, a \
billing question, a specific scheduling request, or a concern that needs a person tell \
@@ -98,11 +124,16 @@ opens a request that the provider's admin team answers by email and in "My Reque
=== STYLE & RULES ===
- Be concise, warm, and practical. Prefer short paragraphs or numbered steps.
- Ground answers in the features above. If you are not sure or the app may differ, say \
so honestly rather than guessing and suggest "Submit to Support".
- Ground answers in everything above, INCLUDING the "ADDITIONAL KNOWLEDGE" section when \
one is present that section is curated by the JQC team and is authoritative. If it \
answers the question, use it. If you are not sure or the app may differ, say so \
honestly rather than guessing and suggest "Submit to Support".
- 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, cleaning schedules, phone numbers, \
facility data, or scores. You do not have access to the customer's live data — guide \
them to where to find it in the portal instead.
facility data, or scores that are NOT given above. You do not have access to the \
customer's live data — guide them to where to find it in the portal instead.
- Do not claim to perform actions yourself; explain where in the portal the customer does it.\
"""
@@ -116,37 +147,187 @@ FAQS = [
{'icon': 'bi-people', 'text': 'What does "Handled By" mean on an issue?'},
{'icon': 'bi-qr-code', 'text': "How do I print my facility's QR code?"},
{'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 periodically — see the error handler in chat_message().
_DEFAULT_GROQ_MODEL = 'openai/gpt-oss-120b'
# Soft cap on injected knowledge to keep prompt size (and token cost) reasonable.
_KB_MAX_CHARS = 6000
# ── PII redaction for the outbound Groq payload ───────────────────────────────
# Groq is a third-party processor. Customers may type identifying details
# (their own email/phone, or a coworker's) into a support question; there is
# no need for that 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 text is still saved as-is in support_chat_messages
# so the customer's own conversation history reads normally in the app.
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 _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 _role_addendum), which is what keeps rule 89
intact 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: confirm receipt of the \
request, then Start it when they are on site.
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.
"""
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
#: The curated knowledge is spliced in immediately BEFORE this heading, not
#: appended to the end of the prompt. 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 = '=== STYLE & RULES ==='
def _system_prompt_for(user):
"""Base prompt plus the addendum for this user's role, then the KB.
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 base system prompt plus all ACTIVE admin knowledge entries
(phase38), so staff can curate the chatbot's knowledge without code changes.
Best-effort a KB failure never breaks the chat."""
prompt = _SYSTEM_PROMPT
"""Return the base system prompt with all ACTIVE admin knowledge entries
(phase38) spliced in, so staff can curate the chatbot's knowledge without
code changes. Best-effort a KB failure never breaks the chat."""
try:
entries = (SupportKnowledge.query
.filter_by(active=True)
.order_by(SupportKnowledge.sort_order.asc(), SupportKnowledge.id.asc())
.all())
if entries:
parts = ["\n\n=== ADDITIONAL KNOWLEDGE (curated by the JQC team; "
"treat as authoritative and prefer it over general guesses) ==="]
if not entries:
logger.info('SUPPORT | KB | no active entries — base prompt only')
return _SYSTEM_PROMPT
parts = ['=== ADDITIONAL KNOWLEDGE (curated by the JQC 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.content.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)
prompt += ''.join(parts)
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 prompt
return _SYSTEM_PROMPT
# ── Customer chat page ────────────────────────────────────────────────────────
@@ -154,13 +335,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)
# Load the customer's most recent conversation so it continues on return.
# A ?new=1 param (New conversation button) starts a fresh, empty window.
@@ -179,8 +357,9 @@ def chat():
]
groq_ready = bool(os.environ.get('GROQ_API_KEY'))
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_id=(session.id if session else None),
@@ -192,7 +371,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
data = request.get_json(silent=True) or {}
@@ -213,14 +392,23 @@ def chat_message():
from groq import Groq
client = Groq(api_key=api_key)
messages = [{'role': 'system', 'content': _system_prompt_with_kb()}]
# Append prior conversation (cap at last 20 turns to control token usage)
messages = [{'role': 'system', 'content': _system_prompt_for(current_user)}]
# Append prior conversation (cap at last 20 turns to control token usage).
# Redact PII-shaped text before it leaves the app for the Groq API —
# the unredacted originals stay in support_chat_messages below.
for m in history[-20:]:
if m.get('role') in ('user', 'assistant') and m.get('content'):
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')
# Default model. 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 (404 model_not_found, Aug 2026). If this
# happens again, check the log line below and set GROQ_MODEL to a
# current model — no deploy needed.
model = os.environ.get('GROQ_MODEL', _DEFAULT_GROQ_MODEL)
completion = client.chat.completions.create(
model=model,
messages=messages,
@@ -229,7 +417,17 @@ def chat_message():
)
reply = completion.choices[0].message.content.strip()
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.
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)
reply = ("I ran into a problem reaching the AI assistant. "
"Please try again, or use **Submit to Support** to contact our team.")
@@ -261,7 +459,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)
@@ -273,7 +471,7 @@ def my_conversations():
@bp.route('/my-conversations/<int:session_id>')
@login_required
def conversation_detail(session_id):
if current_user.role != 'customer':
if not _is_customer_side(current_user):
abort(403)
session = db.session.get(SupportChatSession, session_id)
if session is None or session.customer_id != current_user.id:
@@ -288,7 +486,7 @@ def 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()
@@ -299,8 +497,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 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
@@ -330,7 +529,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
@@ -345,7 +544,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)
@@ -526,6 +725,28 @@ def admin_knowledge():
entries=entries, groq_ready=groq_ready)
@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)
@bp.route('/admin/knowledge/new', methods=['GET', 'POST'])
@login_required
@supervisor_required
+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(
+116
View File
@@ -0,0 +1,116 @@
"""
ui.py
-----
Web-portal design A/B test (phase48).
Routes
POST /ui/theme switch_theme() flip users.ui_theme classic modern
GET /ui/about about() About Us page (new, modern deck)
GET /ui/support-center support_center() Support hub of how-to cards (new)
GET /ui/theme-votes theme_votes() admin tally of which design users kept
Nothing here changes existing behaviour: the theme flag only selects which
layout shell base.html extends.
"""
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
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 _safe_next(target: str | None) -> str:
"""Only allow same-site relative redirects (open-redirect guard)."""
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 current_app.config.get(
'DEFAULT_UI_THEME', 'modern')
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'))
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:
tally[theme or 'classic'] = tally.get(theme or 'classic', 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)
+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 {
+474
View File
@@ -0,0 +1,474 @@
/*
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;
}
.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; }
}
+19 -8
View File
@@ -221,13 +221,20 @@
<label class="form-label fw-semibold">Delete logs older than</label>
<select name="older_than" class="form-select" id="purgeOlderThan" required>
<option value="">— Select a threshold —</option>
<option value="7">7 days</option>
<option value="30">30 days</option>
<option value="60">60 days</option>
<option value="90">90 days</option>
<option value="180">180 days</option>
<option value="365">1 year</option>
<option value="730">2 years</option>
</select>
<div class="form-text">
Minimum retention is 1 year, so audit history stays available for
a full compliance lookback window.
</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">
Type <code>PURGE</code> to confirm
</label>
<input type="text" name="confirm_phrase" class="form-control"
id="purgeConfirmPhrase" autocomplete="off" required>
</div>
<p class="text-muted small mb-0">
All audit log entries created before the selected threshold will be
@@ -247,8 +254,12 @@
</div>
<script>
document.getElementById('purgeOlderThan').addEventListener('change', function () {
document.getElementById('purgeSubmitBtn').disabled = !this.value;
});
function updatePurgeSubmitState() {
var thresholdOk = !!document.getElementById('purgeOlderThan').value;
var confirmOk = document.getElementById('purgeConfirmPhrase').value === 'PURGE';
document.getElementById('purgeSubmitBtn').disabled = !(thresholdOk && confirmOk);
}
document.getElementById('purgeOlderThan').addEventListener('change', updatePurgeSubmitState);
document.getElementById('purgeConfirmPhrase').addEventListener('input', updatePurgeSubmitState);
</script>
{% endblock %}
+1 -1
View File
@@ -95,7 +95,7 @@
{# Row 1: spanning group headers #}
<tr>
<th class="event-col" rowspan="2" style="min-width:200px;">Event</th>
<th class="group-hdr" colspan="4">Internal Recipients</th>
<th class="group-hdr" colspan="5">Internal Recipients</th>
<th class="group-hdr" colspan="1">Customer</th>
<th class="group-hdr" colspan="1">Custom Recipients</th>
</tr>
+54
View File
@@ -81,6 +81,26 @@
</div>
</div>
<!-- My Data & Privacy Card -->
<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 & 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 & Data
</button>
</div>
</div>
</div>
<!-- ── Right column: edit form + recent inspections ──────────────── -->
@@ -221,4 +241,38 @@
</div>
</div>
<!-- ── Delete My Data Modal ─────────────────────────────────────────────── -->
<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 & 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
@@ -79,7 +79,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 "") }}
@@ -127,4 +131,5 @@
</div>
</div>
</div>
{% endblock %}
+25 -7
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>
@@ -55,22 +55,40 @@
</td>
<td>{{ user.created_at.strftime('%Y-%m-%d') }}</td>
<td>
{% if user.active %}
<span class="badge bg-success">Active</span>
{% else %}
{% if not user.active %}
<span class="badge bg-secondary">Disabled</span>
{% elif not user.password_set %}
{# phase51 — invited but has not chosen a password yet;
the login route refuses them until they do. #}
<span class="badge bg-warning text-dark"
title="Invitation sent — this person has not set their password yet">
<i class="bi bi-envelope"></i> Invite pending
</span>
{% else %}
<span class="badge bg-success">Active</span>
{% endif %}
</td>
<td>
<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() }}">
+13 -480
View File
@@ -1,484 +1,17 @@
<!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') }}">
{% 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 (design A/B test)
/* ── 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-lg navbar-dark bg-primary">
<div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('dashboard.index') }}">
<i class="bi bi-clipboard-check"></i> Janitorial QC
</a>
<!-- ── Bell + toggler always visible on mobile/tablet ── -->
<div class="d-flex align-items-center gap-2 ms-auto me-2 d-lg-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>
<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_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('audit.')
or request.endpoint == 'auth.notification_matrix'
or request.endpoint.startswith('broadcast.')
or request.endpoint.startswith('devices.')
or (request.endpoint.startswith('auth.') and 'user' in request.endpoint)
) %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle {{ 'active' if admin_active }}"
href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
Admin
</a>
<ul class="dropdown-menu dropdown-menu-end">
<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') }}">Users</a>
</li>
<li>
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('audit.') }}"
href="{{ url_for('audit.index') }}">Audit Trail</a>
</li>
<li>
<a class="dropdown-item {{ 'active' if request.endpoint == 'auth.notification_matrix' }}"
href="{{ url_for('auth.notification_matrix') }}">Notification Matrix</a>
</li>
<li>
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('broadcast.') }}"
href="{{ url_for('broadcast.index') }}">Broadcast</a>
</li>
<li>
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('devices.') }}"
href="{{ url_for('devices.index') }}">Devices</a>
</li>
</ul>
</li>
{% endif %}
</ul>
<ul class="navbar-nav align-items-center">
Every page template still does {% extends "base.html" %} exactly as before.
This file no longer holds any markup; it forwards to whichever shell the
current user has selected:
<!-- ── Notification Bell (desktop lg+ only) ── -->
<li class="nav-item dropdown me-2 d-none d-lg-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 ── -->
jqc_layout == 'layouts/classic.html' → the original top-navbar design
jqc_layout == 'layouts/modern.html' → the new sidebar design
<!-- 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 %}
`jqc_layout` is injected by the inject_ui_theme() context processor in
app/__init__.py, driven by users.ui_theme (see app/routes/ui.py).
<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 %}
{% 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>
Jinja resolves {% block %} overrides through the whole inheritance chain,
so child templates need no change at all.
══════════════════════════════════════════════════════════════════════════ #}
{% 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;
+167 -281
View File
@@ -3,8 +3,34 @@
{% block content %}
{# Handler ("Handled by") breakdown chips for an issue KPI card. `base` is a
dict of extra issues.index query params identifying the card's scope. #}
{# ── Grouped dashboard styling (colored section cards + white stat tiles) ──── #}
<style>
.group-card { border-radius:16px; padding:14px 18px 18px; margin-bottom:22px; }
.group-title { font-weight:800; font-size:1.4rem; letter-spacing:.2px; margin:2px 0 12px; }
.group-blue { background:#4a90d9; } /* A — solid blue */
.group-green { background:#d9ead3; } /* B — light green */
.group-orange { background:#fce5cd; } /* C — light orange */
.group-grey { background:#efefef; } /* D — light grey */
.gt-white { color:#ffffff; }
.gt-green { color:#38761d; }
.gt-orange { color:#b45f06; }
.gt-red { color:#cc0000; }
.stat-tile { background:#fff; border:2px solid #cbd3dd; border-radius:11px;
padding:12px 16px; height:100%; display:flex; align-items:center;
justify-content:space-between; gap:10px; transition:box-shadow .15s; }
a.tile-link:hover .stat-tile { box-shadow:0 3px 10px rgba(0,0,0,.12); }
.st-label { font-size:.9rem; font-weight:600; color:#3f4652; line-height:1.15; }
.st-sub { font-size:.68rem; color:#8a93a2; margin-top:3px; }
.st-value { font-size:1.9rem; font-weight:800; line-height:1; white-space:nowrap; }
/* Scheduled-inspection table inside the solid-blue section A */
.sched-table { background:#fff; border-radius:8px; overflow:hidden; margin-bottom:0; }
.sched-table thead th { background:#5b9bd5; color:#fff; border-color:#4a90d9; font-weight:600; }
</style>
{# Handler ("Handled by") breakdown chips for an issue KPI tile. `base` is a
dict of extra issues.index query params identifying the tile's scope. #}
{% macro handler_chips(bd, base) %}
<div class="mt-1 d-flex flex-wrap align-items-center gap-1" style="font-size:.7rem;">
<span style="opacity:.75;">Handled by:</span>
@@ -20,23 +46,55 @@
</div>
{% endmacro %}
{# One white stat tile: label (+ optional subtitle OR handler chips) left, big
colored number right. Numbers render two-digit (01, 02, …) to match the design.
When chips_bd is given and the value is > 0, the handler chips replace the
subtitle; because the chips are themselves links, the whole tile is NOT
wrapped in an anchor (nested <a> is invalid) — only the number links. #}
{% macro stat_tile(label, value, accent, href=None, subtitle=None, chips_bd=None, chips_base=None) %}
{% set has_chips = chips_bd is not none and value and value > 0 %}
<div class="col">
{% if href and not has_chips %}<a href="{{ href }}" class="text-decoration-none tile-link d-block h-100">{% endif %}
<div class="stat-tile" style="border-color:{{ accent }};">
<div class="st-text">
<div class="st-label">{{ label }}</div>
{% if has_chips %}
{{ handler_chips(chips_bd, chips_base) }}
{% elif subtitle %}
<div class="st-sub">{{ subtitle }}</div>
{% endif %}
</div>
<div class="st-value" style="color:{{ accent }};">
{% if href and has_chips %}
<a href="{{ href }}" class="text-decoration-none" style="color:inherit;">{{ '%02d'|format(value) }}</a>
{% else %}
{{ '%02d'|format(value) if value is number else value }}
{% endif %}
</div>
</div>
{% if href and not has_chips %}</a>{% endif %}
</div>
{% endmacro %}
<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: upcoming / overdue (phase36) ─────────────────── #}
{% 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">
{# ══════════════════════════════════════════════════════════════════════════
A. SCHEDULED INSPECTION (blue)
══════════════════════════════════════════════════════════════════════════ #}
{% if current_user.role != 'customer' %}
<div class="group-card group-blue">
<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('scheduled_inspections.index') }}" class="btn btn-sm btn-outline-primary">View all</a>
<span class="group-title gt-white mb-0">Scheduled Inspection</span>
<a href="{{ url_for('scheduled_inspections.index') }}" class="btn btn-sm btn-light">View all</a>
</div>
{% if sched_overdue_count %}
<div class="alert alert-danger py-2 mb-2">
<i class="bi bi-alarm-fill"></i>
@@ -44,11 +102,15 @@
{{ '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>Facility</th><th>Template</th><th>Inspector</th><th>Due</th><th></th></tr>
<table class="table table-hover align-middle sched-table">
<thead>
<tr>
<th>Facility Name</th><th>Inspection Template</th><th>Inspector Name</th>
<th>How Often?</th><th>Due</th><th class="text-end"></th>
</tr>
</thead>
<tbody>
{% for s in sched_upcoming %}
@@ -56,13 +118,31 @@
<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>
<td class="small">{{ s.next_due_date.strftime('%b %d') }}</td>
<td class="text-end">
{% if current_user.role in ['admin','director','project_manager']
or (current_user.role == 'inspector' and s.inspector_id == current_user.id) %}
<td class="text-end text-nowrap">
{# Start is shown only to the assignee — the inspection is theirs to do. #}
{% if s.inspector_id and s.inspector_id == current_user.id %}
{# Receipt confirmation (phase47) — assignee confirms, or shows confirmed. #}
{% 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('scheduled_inspections.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('scheduled_inspections.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 %}
@@ -70,243 +150,92 @@
</table>
</div>
{% else %}
<p class="text-muted small mb-0">No inspections due in the next 7 days.</p>
{% endif %}
<div class="bg-white rounded p-3 text-muted small mb-0">
<i class="bi bi-info-circle me-1"></i>No inspections due in the next 7 days.
</div>
{% endif %}
</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>
<span class="fw-bold text-uppercase" style="font-size:.78rem;letter-spacing:.07em;color:#64748b;">Inspections</span>
<div style="flex:1;height:1px;background:#e2e8f0;"></div>
</div>
<div class="row g-3 mb-4">
<div class="col-6 col-md">
<a href="{{ url_for('inspections.index', date_from=today_str, date_to=today_str) }}" class="text-decoration-none">
<div class="card text-white bg-primary h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-1">
<span class="small text-white-50 fw-semibold">Today's Inspections</span>
<i class="bi bi-clipboard-data" style="font-size:1.4rem;opacity:.3;"></i>
</div>
<div class="fs-1 fw-bold lh-1">{{ today_inspections }}</div>
<div class="mt-2" style="font-size:.72rem;opacity:.7;">All inspections started today</div>
</div>
</div>
</a>
</div>
<div class="col-6 col-md">
<a href="{{ url_for('inspections.index', status='completed', date_from=today_str, date_to=today_str) }}" class="text-decoration-none">
<div class="card text-white bg-success h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-1">
<span class="small text-white-50 fw-semibold">Submitted Today</span>
<i class="bi bi-check-circle" style="font-size:1.4rem;opacity:.3;"></i>
</div>
<div class="fs-1 fw-bold lh-1">{{ completed_today }}</div>
<div class="mt-2" style="font-size:.72rem;opacity:.7;">Fully completed &amp; submitted today</div>
</div>
</div>
</a>
</div>
{# ══════════════════════════════════════════════════════════════════════════
B. QUICK REVIEW (green)
══════════════════════════════════════════════════════════════════════════ #}
{% set G = '#4e8a2f' %}
<div class="group-card group-green">
<div class="group-title gt-green">Quick Review</div>
<div class="row row-cols-2 row-cols-sm-3 row-cols-xl-5 g-3">
{{ stat_tile('Submitted Today', completed_today, G,
url_for('inspections.index', status='completed', date_from=today_str, date_to=today_str),
'Completed & submitted today') }}
{{ stat_tile('Submitted This Week', submitted_this_week, G,
url_for('inspections.index', status='completed', date_from=week_start_str, date_to=today_str),
'Completed & submitted this week') }}
{% if current_user.role != 'customer' %}
<div class="col-6 col-md">
<a href="{{ url_for('inspections.index', status='in_progress') }}" class="text-decoration-none">
<div class="card text-white h-100" style="background:#b45309;">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-1">
<span class="small fw-semibold" style="color:rgba(255,255,255,.6);">Stale In-Progress</span>
<i class="bi bi-hourglass-split" style="font-size:1.4rem;opacity:.3;"></i>
</div>
<div class="fs-1 fw-bold lh-1">{{ stale_in_progress }}</div>
<div class="mt-2" style="font-size:.72rem;opacity:.7;">Started &gt;24 h ago, not yet submitted</div>
</div>
</div>
</a>
</div>
<div class="col-6 col-md">
<a href="{{ url_for('inspections.index', status='follow_up') }}" class="text-decoration-none">
<div class="card text-white h-100" style="background:#7c3aed;">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-1">
<span class="small fw-semibold" style="color:rgba(255,255,255,.6);">Pending Follow-ups</span>
<i class="bi bi-arrow-repeat" style="font-size:1.4rem;opacity:.3;"></i>
</div>
<div class="fs-1 fw-bold lh-1">{{ pending_followups }}</div>
<div class="mt-2" style="font-size:.72rem;opacity:.7;">Flagged for a follow-up re-inspection</div>
</div>
</div>
</a>
</div>
{% endif %}
</div>
{# ── Issues section ──────────────────────────────────────────────────────── #}
<div class="d-flex align-items-center gap-2 mb-3">
<i class="bi bi-exclamation-triangle-fill text-danger"></i>
<span class="fw-bold text-uppercase" style="font-size:.78rem;letter-spacing:.07em;color:#64748b;">Issues</span>
<div style="flex:1;height:1px;background:#e2e8f0;"></div>
</div>
<div class="row g-3 mb-4">
{# Open issues split into three cards by who handles them. #}
<div class="col-6 col-md">
<a href="{{ url_for('issues.index', status='open', handler_type='internal') }}" class="text-decoration-none">
<div class="card text-white bg-primary h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-1">
<span class="small text-white-50 fw-semibold">Open · Janitorial</span>
<i class="bi bi-people" style="font-size:1.4rem;opacity:.3;"></i>
</div>
<div class="fs-1 fw-bold lh-1">{{ handler_breakdown.internal }}</div>
<div class="mt-2" style="font-size:.72rem;opacity:.7;">Open issues handled by Janitorial crew</div>
</div>
</div>
</a>
</div>
<div class="col-6 col-md">
<a href="{{ url_for('issues.index', status='open', handler_type='facility') }}" class="text-decoration-none">
<div class="card text-dark bg-info h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-1">
<span class="small fw-semibold" style="color:rgba(0,0,0,.55);">Open · Facility Staff</span>
<i class="bi bi-building" style="font-size:1.4rem;opacity:.3;"></i>
</div>
<div class="fs-1 fw-bold lh-1">{{ handler_breakdown.facility }}</div>
<div class="mt-2" style="font-size:.72rem;color:rgba(0,0,0,.6);">Open issues the facility staff handle</div>
</div>
</div>
</a>
</div>
<div class="col-6 col-md">
<a href="{{ url_for('issues.index', status='open', handler_type='vendor') }}" class="text-decoration-none">
<div class="card text-dark bg-warning h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-1">
<span class="small fw-semibold" style="color:rgba(0,0,0,.55);">Open · Vendor</span>
<i class="bi bi-person-gear" style="font-size:1.4rem;opacity:.3;"></i>
</div>
<div class="fs-1 fw-bold lh-1">{{ handler_breakdown.vendor }}</div>
<div class="mt-2" style="font-size:.72rem;color:rgba(0,0,0,.6);">Open issues an outside vendor handles</div>
</div>
</div>
</a>
</div>
<div class="col-6 col-md">
<div class="card text-white h-100" style="background:#ea580c;">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-1">
<span class="small fw-semibold" style="color:rgba(255,255,255,.6);">Issues Opened Today</span>
<i class="bi bi-flag" style="font-size:1.4rem;opacity:.3;"></i>
</div>
<a href="{{ url_for('issues.index', date_from=today_str, date_to=today_str) }}" class="text-white text-decoration-none">
<div class="fs-1 fw-bold lh-1">{{ issues_opened_today }}</div>
</a>
{% if issues_opened_today > 0 %}
{{ handler_chips(opened_today_handler, {'date_from': today_str, 'date_to': today_str}) }}
{% else %}
<div class="mt-2" style="font-size:.72rem;opacity:.7;">New issues reported today</div>
{{ stat_tile('In Progress', in_progress_total, G,
url_for('inspections.index', status='in_progress'),
(stale_in_progress ~ ' stale >24h') if stale_in_progress else 'Started, not yet submitted') }}
{{ stat_tile('Pending Follow-ups', pending_followups, G,
url_for('inspections.index', status='follow_up'),
'Flagged for re-inspection') }}
{{ stat_tile('On Schedules', sched_total, G,
url_for('scheduled_inspections.index'),
'Active scheduled plans') }}
{% endif %}
</div>
</div>
</div>
<div class="col-6 col-md">
<a href="{{ url_for('issues.index', status='resolved', date_from=today_str, date_to=today_str) }}" class="text-decoration-none">
<div class="card text-white bg-info h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-1">
<span class="small text-white-50 fw-semibold">Resolved Today</span>
<i class="bi bi-check2-all" style="font-size:1.4rem;opacity:.3;"></i>
</div>
<div class="fs-1 fw-bold lh-1">{{ resolved_today }}</div>
<div class="mt-2" style="font-size:.72rem;opacity:.7;">Issues closed and resolved today</div>
</div>
</div>
</a>
</div>
{# ══════════════════════════════════════════════════════════════════════════
C. REVIEW ISSUES (orange)
══════════════════════════════════════════════════════════════════════════ #}
{% set O = '#d5761a' %}
<div class="group-card group-orange">
<div class="group-title gt-orange">Review Issues</div>
<div class="row row-cols-2 row-cols-sm-3 row-cols-xl-5 g-3">
{{ stat_tile('Open · Janitorial', handler_breakdown.internal, O,
url_for('issues.index', status='open', handler_type='internal'),
'Handled by our crew') }}
{{ stat_tile('Open · Facility Staff', handler_breakdown.facility, O,
url_for('issues.index', status='open', handler_type='facility'),
'Handled by facility staff') }}
{{ stat_tile('Open · Vendors', handler_breakdown.vendor, O,
url_for('issues.index', status='open', handler_type='vendor'),
'Handled by outside vendor') }}
{% if current_user.role != 'customer' %}
<div class="col-6 col-md">
<a href="{{ url_for('issues.index', status='pending_verification') }}" class="text-decoration-none">
<div class="card text-white h-100" style="background:#2563eb;">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-1">
<span class="small fw-semibold" style="color:rgba(255,255,255,.6);">Pending Verification</span>
<i class="bi bi-clipboard2-check" style="font-size:1.4rem;opacity:.3;"></i>
</div>
<div class="fs-1 fw-bold lh-1">{{ pending_verification }}</div>
<div class="mt-2" style="font-size:.72rem;opacity:.7;">Resolved but awaiting supervisor sign-off</div>
</div>
</div>
</a>
</div>
<div class="col-6 col-md">
<div class="card text-white h-100" style="background:#16a34a;">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-1">
<span class="small fw-semibold" style="color:rgba(255,255,255,.6);">Unassigned Open</span>
<i class="bi bi-person-dash" style="font-size:1.4rem;opacity:.3;"></i>
</div>
<a href="{{ url_for('issues.index', status='open', unassigned='1') }}" class="text-white text-decoration-none">
<div class="fs-1 fw-bold lh-1">{{ unassigned_open }}</div>
</a>
{% if unassigned_open > 0 %}
{{ handler_chips(unassigned_handler, {'status': 'open', 'unassigned': '1'}) }}
{% else %}
<div class="mt-2" style="font-size:.72rem;opacity:.7;">Open issues with no one assigned</div>
{{ stat_tile('Open · Unassigned', unassigned_open, O,
url_for('issues.index', status='open', unassigned='1'),
'No one assigned yet',
chips_bd=unassigned_handler, chips_base={'status': 'open', 'unassigned': '1'}) }}
{{ stat_tile('Pending · Verification', pending_verification, O,
url_for('issues.index', status='pending_verification'),
'Awaiting sign-off') }}
{% endif %}
{# All the original Issues cards are kept here in the same colored group. #}
{{ stat_tile('Issues Opened Today', issues_opened_today, O,
url_for('issues.index', date_from=today_str, date_to=today_str),
'New issues reported today',
chips_bd=opened_today_handler, chips_base={'date_from': today_str, 'date_to': today_str}) }}
{{ stat_tile('Resolved Today', resolved_today, O,
url_for('issues.index', status='resolved', date_from=today_str, date_to=today_str),
'Issues closed today') }}
</div>
</div>
</div>
{% endif %}
</div>
{# ── SLA Summary ─────────────────────────────────────────────────────────── #}
{# ══════════════════════════════════════════════════════════════════════════
D. SLA ALERT (grey)
══════════════════════════════════════════════════════════════════════════ #}
{# Section D shows only when there is at least one at-risk or breached issue. #}
{% if sla_breached > 0 or sla_at_risk > 0 %}
<div class="row g-3 mb-4">
{% if sla_breached > 0 %}
<div class="col-6 col-md-3">
<a href="{{ url_for('issues.index', sla='breached') }}" class="text-decoration-none">
<div class="card border-danger h-100">
<div class="card-body d-flex justify-content-between align-items-center">
<div>
<div class="small text-danger fw-semibold">SLA Breached</div>
<div class="fs-2 fw-bold text-danger">{{ sla_breached }}</div>
{% set R = '#cc0000' %}
<div class="group-card group-grey">
<div class="group-title gt-red">SLA Alert</div>
<div class="row row-cols-1 row-cols-sm-2 g-3">
{{ stat_tile('SLA At Risk', sla_at_risk, R,
url_for('issues.index', sla='at_risk'),
'Approaching their SLA deadline') }}
{{ stat_tile('SLA Alert', sla_breached, R,
url_for('issues.index', sla='breached'),
'Past their SLA deadline') }}
</div>
<i class="bi bi-alarm text-danger" style="font-size:2.5rem;opacity:.3;"></i>
</div>
</div>
</a>
</div>
{% endif %}
{% if sla_at_risk > 0 %}
<div class="col-6 col-md-3">
<a href="{{ url_for('issues.index', sla='at_risk') }}" class="text-decoration-none">
<div class="card border-warning h-100">
<div class="card-body d-flex justify-content-between align-items-center">
<div>
<div class="small text-warning fw-semibold">SLA At Risk</div>
<div class="fs-2 fw-bold text-warning">{{ sla_at_risk }}</div>
</div>
<i class="bi bi-alarm text-warning" style="font-size:2.5rem;opacity:.3;"></i>
</div>
</div>
</a>
</div>
{% endif %}
</div>
{% endif %}
@@ -324,7 +253,7 @@
<th>Date</th>
<th>Facility</th>
<th>Area</th>
{% if current_user.role != 'inspector' %}<th>Inspector</th>{% endif %}
{% if not current_user.is_inspector %}<th>Inspector</th>{% endif %}
<th>Score</th>
<th>Status</th>
</tr>
@@ -335,7 +264,7 @@
<td><small>{{ insp.inspection_date.strftime('%Y-%m-%d %H:%M') }}</small></td>
<td>{{ insp.facility.name }}</td>
<td>{{ insp.area.name if insp.area else '—' }}</td>
{% if current_user.role != 'inspector' %}<td>{{ insp.inspector.display_name }}</td>{% endif %}
{% 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 %}">
@@ -365,49 +294,6 @@
{# ── Inspector activity today (admin / director / PM) ───────────────────── #}
{% if inspector_activity %}
<div class="card shadow-sm mb-4">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<span class="fw-semibold"><i class="bi bi-people me-1"></i>Inspector Activity Today</span>
<a href="{{ url_for('inspections.index', date_from=today_str, date_to=today_str) }}"
class="btn btn-sm btn-outline-secondary">View all</a>
</div>
<div class="card-body p-0">
<table class="table table-sm table-hover mb-0" style="font-size:.85rem;">
<thead class="table-light">
<tr>
<th>Inspector</th>
<th class="text-center" style="width:120px;">Submitted Today</th>
<th style="width:200px;"></th>
</tr>
</thead>
<tbody>
{% for row in inspector_activity %}
<tr class="{{ 'table-success' if row.count > 0 else '' }}">
<td>{{ row.name }}</td>
<td class="text-center">
{% if row.count > 0 %}
<span class="badge bg-success">{{ row.count }}</span>
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td>
<div class="progress" style="height:6px;margin-top:4px;">
{% set max_count = inspector_activity | map(attribute='count') | max %}
{% set pct = (row.count / max_count * 100) | int if max_count > 0 else 0 %}
<div class="progress-bar bg-success" style="width:{{ pct }}%;"></div>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{# ── My open issues (inspector widget) ──────────────────────────────────── #}
{% if my_issues %}
<div class="card shadow-sm mb-4">
+2 -2
View File
@@ -8,7 +8,7 @@
<h2><i class="bi bi-building"></i> Facilities</h2>
</div>
<div class="col-md-6 text-end">
{% if current_user.role != 'inspector' %}
{% if not current_user.is_inspector %}
<a href="{{ url_for('facilities.facility_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
@@ -44,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">
+132 -12
View File
@@ -4,31 +4,89 @@
{% block content %}
<style>
.qr-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; }
.qr-item { break-inside: avoid; page-break-inside: avoid; text-align: center; }
.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; }
/* Two per row, comfortable for cutting/posting */
.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> Facility QR Codes</h2>
<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>
<div class="d-flex gap-2">
<a href="{{ url_for('facilities.list_facilities') }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left"></i> Back
</a>
<button onclick="window.print()" class="btn btn-primary btn-sm">
<i class="bi bi-printer"></i> Print All
</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.facility_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>
@@ -36,10 +94,12 @@
{% if facilities %}
<div class="qr-grid">
{% for f in facilities %}
<div class="qr-item card shadow-sm p-3">
<div class="text-muted text-uppercase" style="font-size:.7rem;letter-spacing:.08em;">
Scan for Facility Status
{# 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>
@@ -49,14 +109,74 @@
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 active facilities to print QR codes for.</div>
<div class="alert alert-info">No facilities match the selected filters.</div>
{% endif %}
</form>
<p class="text-muted small mt-3 no-print">
Tip: print, cut along the dashed lines, and post each code at its facility.
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 %}
+3 -3
View File
@@ -11,13 +11,13 @@
<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', 'customer'] %}
{% if current_user.role in ['admin', 'director', 'project_manager', 'customer', 'auditor'] %}
<a href="{{ url_for('facilities.facility_qr_page', facility_id=facility.id) }}"
class="btn btn-outline-dark" title="Printable QR code for this facility">
<i class="bi bi-qr-code"></i> QR Code
@@ -135,7 +135,7 @@
</td>
<td>{{ area.inspections.count() }}</td>
<td>
{% 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('facilities.area_qr_page', area_id=area.id) }}"
class="btn btn-sm btn-outline-dark" title="Printable QR code for this area">
<i class="bi bi-qr-code"></i>
+16 -1
View File
@@ -263,6 +263,21 @@
</div>
</div>
{# ── Instructions from the schedule (phase36) ──
Only rendered when this inspection was started from a ScheduledInspection
that carries instructions. `scheduled_inspection` is NULL for ad-hoc work
and for schedules deleted after the inspection was started, so both the
relationship and the text are guarded. #}
{% if inspection.scheduled_inspection and inspection.scheduled_inspection.notes %}
<div style="background:#eef2ff;border:1px solid #c7d2fe;border-left:4px solid #6366f1;
padding:.9rem 1.1rem;margin-top:.85rem;border-radius:8px;">
<div class="fw-semibold mb-1" style="color:#3730a3;font-size:.9rem;">
<i class="bi bi-info-circle-fill"></i> Instructions for this inspection
</div>
<div style="white-space:pre-wrap;color:#1e1b4b;font-size:.9rem;">{{ inspection.scheduled_inspection.notes }}</div>
</div>
{% endif %}
{# ── Form body ── #}
<div class="insp-body">
{% if form_fields %}
@@ -564,7 +579,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>
+4 -1
View File
@@ -21,7 +21,10 @@
</div>
<div class="mb-3">
{{ form.description.label(class="form-label fw-semibold") }}
{{ form.description(class="form-control", rows=4, placeholder="Describe the issue in detail…") }}
<span class="text-danger">*</span>
{{ form.description(class="form-control", rows=4, required=True,
placeholder="Describe the issue in detail…") }}
{% for e in form.description.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
</div>
<div class="mb-3">
{{ form.photo.label(class="form-label fw-semibold") }}
+29 -7
View File
@@ -3,21 +3,24 @@
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-clipboard-data"></i> Inspections</h2>
{% if current_user.role != 'customer' %}
{# Customer Directors schedule inspections for their own facilities, so the
Scheduled link is theirs too — but starting an ad-hoc inspection is not. #}
<div class="d-flex gap-2">
<a href="{{ url_for('scheduled_inspections.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>
</div>
{% endif %}
</div>
</div>
{# Filters #}
<div class="card shadow-sm mb-4">
<div class="card-body py-2">
<div class="filter-panel mb-4">
<div class="p-3">
<div class="filter-title mb-2"><i class="bi bi-funnel-fill me-1"></i>Filters</div>
<form method="get">
<div class="row g-2 align-items-end">
<div class="col-md-1">
@@ -106,10 +109,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>
@@ -118,12 +126,24 @@
<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>
<td>{{ ins.facility.name }}</td>
<td>{% if ins.area %}{{ ins.area.name }}{% else %}<span class="text-muted"></span>{% endif %}</td>
<td>{{ ins.template.name }}</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 %}
@@ -152,9 +172,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"
@@ -216,6 +236,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>
@@ -228,6 +249,7 @@
{% endblock %}
{% block extra_js %}
{% include 'partials/bulk_select_js.html' %}
<script>
(function () {
'use strict';
+33 -1
View File
@@ -13,8 +13,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">
@@ -54,6 +60,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');
@@ -61,6 +69,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) {
@@ -92,6 +101,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');
@@ -128,6 +159,7 @@
projectSel.addEventListener('change', function () {
loadFacilities(this.value, null, null);
loadTemplates(this.value);
});
facilitySel.addEventListener('change', function () {
+116 -6
View File
@@ -338,7 +338,11 @@
{# 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. #}
{% set back_url = request.args.get('next') or url_for('inspections.index') %}
<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 +353,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 (phase53). 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 %}
{# 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 +387,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 +396,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 +409,42 @@
<i class="bi bi-flag-fill mt-1"></i>
<div>
<strong>Follow-up Inspection Required</strong>
{% 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 +525,11 @@
</div>
</div>
<div class="d-flex align-items-center gap-2">
{% if inspection.scheduled_inspection_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.scheduled_inspection %} · {{ inspection.scheduled_inspection.frequency_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 +563,12 @@
<span class="lbl">Frequency</span>
<span class="val">{{ inspection.template.frequency|title }}</span>
</div>
{% if inspection.scheduled_inspection and inspection.scheduled_inspection.creator %}
<div class="meta-item">
<span class="lbl">Scheduled By</span>
<span class="val">{{ inspection.scheduled_inspection.creator.display_name }}</span>
</div>
{% endif %}
</div>
{# ── Submission GPS (admin / director only) ──────────────────────────── #}
@@ -854,7 +915,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 +945,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 (phase53) ────────────────────────────────────────
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>
+34 -5
View File
@@ -30,13 +30,25 @@
</div>
{# Remaining fields — assigned_to hidden from customer role #}
{% for field in [form.severity, form.description, form.photo] %}
<div class="mb-3">
{{ field.label(class="form-label fw-semibold") }}
{{ field(class="form-select" if field.type == 'SelectField' else "form-control", rows=4 if field.type == 'TextAreaField' else none) }}
{% for e in field.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
{{ form.severity.label(class="form-label fw-semibold") }}
{{ form.severity(class="form-select") }}
{% for e in form.severity.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
</div>
<div class="mb-3">
{{ form.description.label(class="form-label fw-semibold") }}
<span class="text-danger">*</span>
{{ form.description(class="form-control", rows=4, required=True,
placeholder="Describe the issue in detail…") }}
{% for e in form.description.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
</div>
<div class="mb-3">
{{ form.photo.label(class="form-label fw-semibold") }}
{{ form.photo(class="form-control") }}
{% for e in form.photo.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
</div>
{% endfor %}
{% if current_user.role != 'customer' %}
<div class="mb-3">
{{ form.handler_type.label(class="form-label fw-semibold") }}
@@ -52,6 +64,21 @@
{% for e in form.assigned_to.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
</div>
{# Janitorial-staff handler — shown when Handled By = Janitorial Staff #}
<div id="internal_handler_block" style="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="Name of the crew member who will handle it") }}
<div class="form-text">Optional — the janitorial staff member doing the work.</div>
</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>
{# Facility-staff handler — shown when Handled By = Facility Staff #}
<div id="facility_handler_block" style="display:none;">
<div class="mb-2">
@@ -166,8 +193,10 @@
function syncHandlerUI() {
if (!handlerSelect) { return; }
var v = handlerSelect.value;
var intBlock = document.getElementById('internal_handler_block');
var facBlock = document.getElementById('facility_handler_block');
var venBlock = document.getElementById('vendor_block');
if (intBlock) { intBlock.style.display = (v === 'internal') ? '' : 'none'; }
if (facBlock) { facBlock.style.display = (v === 'facility') ? '' : 'none'; }
if (venBlock) { venBlock.style.display = (v === 'vendor') ? '' : 'none'; }
var desc = document.getElementById('handler_desc');
+27 -10
View File
@@ -3,15 +3,16 @@
{% 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>
{% endif %}
</div>
<div class="card shadow-sm mb-4">
<div class="card-body py-2">
<div class="filter-panel mb-4">
<div class="p-3">
<div class="filter-title mb-2"><i class="bi bi-funnel-fill me-1"></i>Filters</div>
<form method="get" class="row g-2 align-items-end">
<div class="col-md-1">
<label class="form-label small mb-1">Issue #</label>
@@ -106,10 +107,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>
@@ -128,6 +134,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>
@@ -143,7 +154,11 @@
{{ issue.resolved_facility.name if issue.resolved_facility else '—' }}<br>
<small class="text-muted">{{ issue.area.name if issue.area else '—' }}</small>
</td>
<td>{{ issue.description[:60] }}{% if issue.description|length > 60 %}…{% endif %}</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 }}
@@ -167,12 +182,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>
@@ -198,7 +213,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, handler_type=handler_filter, unassigned=unassigned_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>
@@ -206,9 +221,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
@@ -219,6 +234,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>
@@ -255,6 +271,7 @@
{% endblock %}
{% block extra_js %}
{% include 'partials/bulk_select_js.html' %}
<script>
(function () {
'use strict';
@@ -290,7 +307,7 @@
}());
</script>
{% if current_user.role in ['admin', 'director'] %}
{% if current_user.role in ['admin', 'director', 'auditor'] %}
<script>
(function () {
'use strict';
+364 -11
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 ══════════════════════════════════ #}
@@ -89,6 +113,17 @@
</dt>
<dd class="col-sm-9">{{ issue.assigned_user.display_name if issue.assigned_user else '— Unassigned —' }}</dd>
{% if (issue.handler_type or 'internal') == 'internal' and issue.internal_handler_name %}
<dt class="col-sm-3">Staff</dt>
<dd class="col-sm-9">
<i class="bi bi-people text-secondary me-1"></i>
<strong>{{ issue.internal_handler_name }}</strong>
{% if issue.internal_handler_contact %}
<span class="text-muted ms-2">{{ issue.internal_handler_contact }}</span>
{% endif %}
</dd>
{% endif %}
{% if issue.handler_type == 'facility' and issue.facility_handler_name %}
<dt class="col-sm-3">Facility Contact</dt>
<dd class="col-sm-9">
@@ -179,8 +214,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"
@@ -196,6 +232,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">
@@ -224,8 +338,11 @@
{% 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 — staff only.
While comments_open is set, EVERY comment is visible to the
customer, so the old "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">
@@ -268,6 +385,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 }}">
@@ -275,8 +393,26 @@
<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. 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">
<div class="form-check form-check-inline mb-0 {{ 'd-none' if comments_open }}">
<input class="form-check-input" type="checkbox"
name="is_customer_visible" id="is_customer_visible" value="1">
<label class="form-check-label small text-muted" for="is_customer_visible">
@@ -295,6 +431,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"
@@ -307,10 +444,16 @@
</div>
{% elif current_user.role == 'customer' %}
<div class="card-body py-2">
<div class="card-body py-2 d-flex align-items-center gap-2 flex-wrap">
<p class="text-muted small mb-0">
<i class="bi bi-bell me-1"></i>Follow this issue to add comments.
<i class="bi bi-bell me-1"></i>To add comments, click on the Follow button
</p>
<form method="post" action="{{ url_for('issues.follow', issue_id=issue.id) }}" class="mb-0">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-outline-primary btn-sm">
<i class="bi bi-bell"></i> Follow
</button>
</form>
</div>
{% else %}
@@ -363,12 +506,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','project_manager'] %}
{% if current_user.role in ['admin','director','project_manager','auditor'] %}
{# ── Who handles this issue ── #}
<div class="mb-3">
{{ form.handler_type.label(class="form-label fw-semibold") }}
@@ -377,7 +521,7 @@
</div>
{% endif %}
{% if current_user.role in ['admin','director'] %}
{% if current_user.role in ['admin','director','auditor'] %}
<div class="mb-3" id="assigned_to_wrap">
<label class="form-label fw-semibold" id="assigned_to_label">Assign To</label>
{{ form.assigned_to(class="form-select") }}
@@ -387,7 +531,27 @@
</div>
{% endif %}
{% if current_user.role in ['admin','director','project_manager'] %}
{% if current_user.role in ['admin','director','project_manager','auditor'] %}
{# ── Janitorial-staff handler (shown when Handled By = Janitorial Staff) ── #}
<div id="internal_handler_block" style="display:none;">
<hr class="my-3">
<p class="fw-semibold small mb-2">
<i class="bi bi-people me-1 text-secondary"></i>Janitorial Staff
</p>
<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="Name of the crew member who will handle it",
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>
{# ── Facility-staff handler (shown when Handled By = Facility Staff) ── #}
<div id="facility_handler_block" style="display:none;">
<hr class="my-3">
@@ -478,7 +642,7 @@
</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">
@@ -492,6 +656,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">
@@ -516,6 +751,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
@@ -547,6 +783,121 @@
}
}
// ── 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();
});
}
// ── "Handled By" — show the relevant sub-block (facility vs vendor) and
// relabel the assignee as a follow-up owner for facility/vendor. ──
var handlerSelect = document.getElementById('handler_type_select');
@@ -558,8 +909,10 @@
function syncHandlerUI() {
if (!handlerSelect) { return; }
var v = handlerSelect.value;
var intBlock = document.getElementById('internal_handler_block');
var facBlock = document.getElementById('facility_handler_block');
var venBlock = document.getElementById('vendor_block');
if (intBlock) { intBlock.style.display = (v === 'internal') ? '' : 'none'; }
if (facBlock) { facBlock.style.display = (v === 'facility') ? '' : 'none'; }
if (venBlock) { venBlock.style.display = (v === 'vendor') ? '' : 'none'; }
+536
View File
@@ -0,0 +1,536 @@
<!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') }}">
{% 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;
}
/* ── Shared list-page filter panel (matches dashboard blue theme) ── */
.filter-panel {
background: #eaf2fc; /* light blue tint */
border: 1px solid #bcd2ee;
border-left: 4px solid #4a90d9; /* dashboard blue accent */
border-radius: 12px;
}
.filter-panel .filter-title {
font-weight: 700;
font-size: .82rem;
letter-spacing: .03em;
text-transform: uppercase;
color: #2f6db3;
}
.filter-panel .form-label {
font-weight: 600;
color: #3f4652;
}
/* Give every input/select a clear border so they stand out on the tint */
.filter-panel .form-control,
.filter-panel .form-select {
border: 1.5px solid #9db8db;
background-color: #ffffff;
}
.filter-panel .form-control:focus,
.filter-panel .form-select:focus {
border-color: #4a90d9;
box-shadow: 0 0 0 .18rem rgba(74, 144, 217, .25);
}
.filter-panel .form-control::placeholder { color: #9aa4b2; }
</style>
</head>
<body>
{% if current_user.is_authenticated %}
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
<div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('dashboard.index') }}">
<i class="bi bi-clipboard-check"></i> Janitorial QC
</a>
<!-- ── Bell + toggler always visible on mobile/tablet ── -->
<div class="d-flex align-items-center gap-2 ms-auto me-2 d-lg-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>
<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('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('auth.') and 'user' in request.endpoint)
) %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle {{ 'active' if admin_active }}"
href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
Admin
</a>
<ul class="dropdown-menu dropdown-menu-end">
<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') }}">Users</a>
</li>
<li>
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('audit.') }}"
href="{{ url_for('audit.index') }}">Audit Trail</a>
</li>
<li>
<a class="dropdown-item {{ 'active' if request.endpoint == 'auth.notification_matrix' }}"
href="{{ url_for('auth.notification_matrix') }}">Notification Matrix</a>
</li>
<li>
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('broadcast.') }}"
href="{{ url_for('broadcast.index') }}">Broadcast</a>
</li>
<li>
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('devices.') }}"
href="{{ url_for('devices.index') }}">Devices</a>
</li>
<li>
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('enrollment.') }}"
href="{{ url_for('enrollment.admin_list') }}">Enrollment Forms</a>
</li>
</ul>
</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-lg-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>
<li>
{# ── Design switcher (classic → modern) ── #}
<form method="POST" action="{{ url_for('ui.switch_theme') }}" class="px-1">
<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>
<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 %}
{% 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>
+465
View File
@@ -0,0 +1,465 @@
<!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') }}">
{% 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') }}">
<span class="jqc-brand-mark">JQC</span>
<span class="jqc-brand-sub">By L.T. Services, Inc</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.replace('_',' ')|title }}</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('scheduled_inspections.')) }}"
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('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>
<a class="jqc-nav-sublink {{ 'active' if request.endpoint and request.endpoint.startswith('enrollment.') }}"
href="{{ url_for('enrollment.admin_list') }}">Enrollment Forms</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 %}
{% 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>
+418
View File
@@ -0,0 +1,418 @@
{% 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('scheduled_inspections.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>
<td class="small">{{ s.next_due_date.strftime('%b %d, %Y') }}</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('scheduled_inspections.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('scheduled_inspections.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('scheduled_inspections.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.facility_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.facility_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('scheduled_inspections.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_filter == 'internal' }}>Janitorial Staff</option>
<option value="facility" {{ 'selected' if handler_filter == 'facility' }}>Facility Staff</option>
<option value="vendor" {{ 'selected' if handler_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_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>
+58 -13
View File
@@ -102,14 +102,14 @@
<span class="badge bg-{{ rating_colour }} fs-6">{{ rating_label }}</span>
</div>
{# ── Recent inspections (DATES ONLY — occupant-safe) ── #}
{% if recent_dates %}
{# ── Recent inspections (date + quality label, no % — occupant-safe) ── #}
{% if recent_inspections %}
<div class="card-soft p-3 mb-3">
<div class="sec-title mb-2"><i class="bi bi-clipboard-check me-1"></i> Recent Inspections</div>
{% for d in recent_dates %}
<div class="list-line py-2 d-flex align-items-center justify-content-between">
<span>{{ d.strftime('%b %d, %Y') }}</span>
<span class="text-success small"><i class="bi bi-check-circle-fill"></i> Completed</span>
{% for r in recent_inspections %}
<div class="list-line py-2 d-flex align-items-center justify-content-between gap-2">
<span>{{ r.date.strftime('%b %d, %Y') }}</span>
<span class="badge bg-{{ r.colour }}">{{ r.label }}</span>
</div>
{% endfor %}
<div class="text-muted small mt-2">This area is inspected regularly by our quality team.</div>
@@ -124,9 +124,9 @@
Let the cleaning team know.
</p>
<form method="POST"
<form method="POST" id="reportForm"
action="{{ url_for('public.area_report_problem', token=token) }}"
enctype="multipart/form-data" novalidate>
enctype="multipart/form-data">
{{ form.hidden_tag() }}
{# Honeypot — hidden from humans; bots that fill it are rejected #}
@@ -141,7 +141,8 @@
<div class="mb-3">
{{ form.description.label(class="form-label small fw-semibold") }}
{{ form.description(class="form-control", rows="4",
<span class="text-danger">*</span>
{{ form.description(class="form-control", rows="4", required=True,
placeholder="Describe what you noticed…") }}
{% for e in form.description.errors %}
<div class="text-danger small mt-1">{{ e }}</div>
@@ -160,14 +161,18 @@
</div>
<div class="mb-3">
{{ form.photo.label(class="form-label small fw-semibold") }}
{{ form.photo(class="form-control", accept="image/*") }}
{% for e in form.photo.errors %}
{{ form.photos.label(class="form-label small fw-semibold") }}
{{ form.photos(class="form-control", accept="image/*", id="reportPhotos", multiple=true) }}
<div class="form-text">You can attach up to 5 photos.</div>
<div class="text-danger small mt-1" id="photoLimitMsg" style="display:none;">
Please select no more than 5 photos — only the first 5 will be used.
</div>
{% for e in form.photos.errors %}
<div class="text-danger small mt-1">{{ e }}</div>
{% endfor %}
</div>
<button type="submit" class="btn btn-primary w-100">
<button type="submit" class="btn btn-primary w-100" id="submitBtn">
<i class="bi bi-send"></i> Submit Report
</button>
</form>
@@ -177,5 +182,45 @@
Janitorial Quality Control
</div>
</div>
<script>
(function () {
'use strict';
// Cap photo selection at 5.
var input = document.getElementById('reportPhotos');
var msg = document.getElementById('photoLimitMsg');
if (input) {
input.addEventListener('change', function () {
if (input.files && input.files.length > 5) {
if (msg) { msg.style.display = 'block'; }
input.value = ''; // clear an over-limit selection so they re-pick
} else if (msg) {
msg.style.display = 'none';
}
});
}
// Prevent duplicate reports: disable the button on first submit so a slow
// network can't be double-tapped into multiple identical reports.
var form = document.getElementById('reportForm');
var btn = document.getElementById('submitBtn');
if (form) {
form.addEventListener('submit', function (e) {
if (form.dataset.submitting === '1') { e.preventDefault(); return; }
form.dataset.submitting = '1';
if (btn) {
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Submitting…';
}
});
}
// After a successful submit we redirect back here with a flash message;
// jump to the top so the confirmation is seen and the form isn't re-tapped.
if (document.querySelector('.alert')) {
window.scrollTo(0, 0);
}
}());
</script>
</body>
</html>
+57 -12
View File
@@ -103,13 +103,13 @@
</div>
{# ── Recent inspections (DATES ONLY — occupant-safe) ── #}
{% if recent_dates %}
{% if recent_inspections %}
<div class="card-soft p-3 mb-3">
<div class="sec-title mb-2"><i class="bi bi-clipboard-check me-1"></i> Recent Inspections</div>
{% for d in recent_dates %}
<div class="list-line py-2 d-flex align-items-center justify-content-between">
<span>{{ d.strftime('%b %d, %Y') }}</span>
<span class="text-success small"><i class="bi bi-check-circle-fill"></i> Completed</span>
{% for r in recent_inspections %}
<div class="list-line py-2 d-flex align-items-center justify-content-between gap-2">
<span>{{ r.date.strftime('%b %d, %Y') }}</span>
<span class="badge bg-{{ r.colour }}">{{ r.label }}</span>
</div>
{% endfor %}
<div class="text-muted small mt-2">This facility is inspected regularly by our quality team.</div>
@@ -123,9 +123,9 @@
Notice something that needs attention? Let the cleaning team know.
</p>
<form method="POST"
<form method="POST" id="reportForm"
action="{{ url_for('public.report_problem', token=token) }}"
enctype="multipart/form-data" novalidate>
enctype="multipart/form-data">
{{ form.hidden_tag() }}
{# Honeypot — hidden from humans; bots that fill it are rejected #}
@@ -140,7 +140,8 @@
<div class="mb-3">
{{ form.description.label(class="form-label small fw-semibold") }}
{{ form.description(class="form-control", rows="4",
<span class="text-danger">*</span>
{{ form.description(class="form-control", rows="4", required=True,
placeholder="Describe what you noticed…") }}
{% for e in form.description.errors %}
<div class="text-danger small mt-1">{{ e }}</div>
@@ -159,14 +160,18 @@
</div>
<div class="mb-3">
{{ form.photo.label(class="form-label small fw-semibold") }}
{{ form.photo(class="form-control", accept="image/*") }}
{% for e in form.photo.errors %}
{{ form.photos.label(class="form-label small fw-semibold") }}
{{ form.photos(class="form-control", accept="image/*", id="reportPhotos", multiple=true) }}
<div class="form-text">You can attach up to 5 photos.</div>
<div class="text-danger small mt-1" id="photoLimitMsg" style="display:none;">
Please select no more than 5 photos — only the first 5 will be used.
</div>
{% for e in form.photos.errors %}
<div class="text-danger small mt-1">{{ e }}</div>
{% endfor %}
</div>
<button type="submit" class="btn btn-primary w-100">
<button type="submit" class="btn btn-primary w-100" id="submitBtn">
<i class="bi bi-send"></i> Submit Report
</button>
</form>
@@ -176,5 +181,45 @@
Janitorial Quality Control
</div>
</div>
<script>
(function () {
'use strict';
// Cap photo selection at 5.
var input = document.getElementById('reportPhotos');
var msg = document.getElementById('photoLimitMsg');
if (input) {
input.addEventListener('change', function () {
if (input.files && input.files.length > 5) {
if (msg) { msg.style.display = 'block'; }
input.value = ''; // clear an over-limit selection so they re-pick
} else if (msg) {
msg.style.display = 'none';
}
});
}
// Prevent duplicate reports: disable the button on first submit so a slow
// network can't be double-tapped into multiple identical reports.
var form = document.getElementById('reportForm');
var btn = document.getElementById('submitBtn');
if (form) {
form.addEventListener('submit', function (e) {
if (form.dataset.submitting === '1') { e.preventDefault(); return; }
form.dataset.submitting = '1';
if (btn) {
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Submitting…';
}
});
}
// After a successful submit we redirect back here with a flash message;
// jump to the top so the confirmation is seen and the form isn't re-tapped.
if (document.querySelector('.alert')) {
window.scrollTo(0, 0);
}
}());
</script>
</body>
</html>
+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') }}">
@@ -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">
@@ -0,0 +1,73 @@
<!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 — Janitorial QC</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
<style>
body { background:#eef1f5; }
.wrap { max-width: 520px; margin: 8vh auto 0; padding: 16px; }
.card-soft { background:#fff; border:1px solid #e6e9ef; border-radius:.85rem; }
.badge-icon { width:64px; height:64px; border-radius:50%; display:flex; align-items:center;
justify-content:center; font-size:2rem; margin:0 auto 12px; }
</style>
</head>
<body>
<div class="wrap">
<div class="card-soft shadow-sm p-4 text-center">
{% set ok = status in ['confirmed', 'already'] %}
<div class="badge-icon"
style="background:{{ '#e6f7ec' if ok else '#fdecec' }};color:{{ '#198754' if ok else '#dc3545' }};">
<i class="bi bi-{{ 'check-circle-fill' if ok else 'exclamation-triangle-fill' }}"></i>
</div>
{% if status == 'confirmed' %}
<h4 class="mb-2">Receipt confirmed</h4>
<p class="text-muted mb-0">Thank you — you've confirmed you received this scheduled inspection request.</p>
{% elif status == 'already' %}
<h4 class="mb-2">Already confirmed</h4>
<p class="text-muted mb-0">You had already confirmed receipt of this scheduled inspection. No further action is needed.</p>
{% elif status == 'reassigned' %}
<h4 class="mb-2">This request was reassigned</h4>
<p class="text-muted mb-0">This scheduled inspection is no longer assigned to you, so it can't be confirmed from this link.</p>
{% elif status == 'inactive' %}
<h4 class="mb-2">No longer active</h4>
<p class="text-muted mb-0">This scheduled inspection is no longer active, so there's nothing to confirm.</p>
{% elif status == 'expired' %}
<h4 class="mb-2">Link expired</h4>
<p class="text-muted mb-0">This confirmation link has expired. Please sign in to confirm receipt from the Scheduled Inspections page.</p>
{% else %}
<h4 class="mb-2">Invalid link</h4>
<p class="text-muted mb-0">This confirmation link is not valid. Please sign in to confirm receipt from the Scheduled Inspections page.</p>
{% endif %}
{% if sched %}
<hr class="my-3">
<div class="text-start small">
<div class="mb-1"><span class="text-muted">Facility:</span>
<strong>{{ sched.facility.name if sched.facility else '—' }}</strong></div>
<div class="mb-1"><span class="text-muted">Template:</span>
{{ sched.template.name if sched.template else '—' }}</div>
<div><span class="text-muted">Due:</span>
{{ sched.next_due_date.strftime('%b %d, %Y') if sched.next_due_date else '—' }}
<span class="text-muted">·</span> {{ sched.recurrence_label }}</div>
</div>
{% endif %}
<div class="mt-4">
<a href="{{ url_for('scheduled_inspections.index') }}" class="btn btn-primary">
<i class="bi bi-box-arrow-in-right"></i> Open Scheduled Inspections
</a>
</div>
<p class="text-muted mt-3 mb-0" style="font-size:.75rem;">
You may be asked to sign in.
</p>
</div>
</div>
</body>
</html>
+120 -1
View File
@@ -49,12 +49,93 @@
{{ form.next_due_date.label(class="form-label fw-semibold") }}
{{ form.next_due_date(class="form-control", type="date") }}
{% for e in form.next_due_date.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
<div class="form-text">
Snapped forward to the first matching day.
Advances automatically after each completed inspection.
</div>
</div>
</div>
{# ── End date (phase44) ──
Hidden for one-time schedules, which end by deactivating when
completed. syncFrequency() toggles it; the route forces the column
to NULL when frequency == '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">
{{ form.end_date.label(class="form-label fw-semibold") }}
{{ form.end_date(class="form-control", type="date") }}
{% for e in form.end_date.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
<div class="form-text">
Optional. 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">{{ form.weekdays.label.text }}</label>
<div class="d-flex flex-wrap gap-3">
{% for value, label in form.weekdays.choices %}
<div class="form-check">
<input class="form-check-input" type="checkbox" name="weekdays"
id="weekday_{{ value }}" value="{{ value }}"
{% if form.weekdays.data and value in form.weekdays.data %}checked{% endif %}>
<label class="form-check-label" for="weekday_{{ value }}">{{ label[:3] }}</label>
</div>
{% endfor %}
</div>
{% for e in form.weekdays.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
<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">{{ form.month_mode.label.text }}</label>
<div class="form-check">
<input class="form-check-input" type="radio" name="month_mode"
id="month_mode_day" value="day_of_month"
{% if form.month_mode.data != '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>
{{ form.day_of_month(class="form-control", type="number", min=1, max=31,
placeholder="15") }}
</div>
{% for e in form.day_of_month.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
<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 form.month_mode.data == '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;">
{{ form.nth_week(class="form-select form-select-sm", style="max-width:7rem;") }}
{{ form.nth_weekday(class="form-select form-select-sm", style="max-width:11rem;") }}
</div>
{% for e in form.nth_week.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
<div class="form-text mb-0">e.g. the 2nd Tuesday of every month.</div>
</div>
</div>
<div class="mb-3">
{{ form.notes.label(class="form-label fw-semibold") }}
{{ form.notes(class="form-control", rows=2, placeholder="Optional instructions for the inspector…") }}
{{ form.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.") }}
<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>
<div class="form-check mb-3">
@@ -129,5 +210,43 @@
setPlaceholder();
}
}());
// Recurrence blocks: only the one matching the chosen frequency is shown.
// The server clears the columns for the hidden blocks on save, so stale values
// left in the DOM never take effect.
(function () {
'use strict';
var freq = document.getElementById('frequency') ||
document.querySelector('[name="frequency"]');
var weekly = document.getElementById('weekly_block');
var monthly = document.getElementById('monthly_block');
var endRow = document.getElementById('end_date_row');
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');
function syncMonthMode() {
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 = freq.value !== 'monthly';
// End date is a recurring-only concept.
if (endRow) { endRow.hidden = freq.value === 'once'; }
syncMonthMode();
}
freq.addEventListener('change', syncFrequency);
[domRadio, nthRadio].forEach(function (r) {
if (r) { r.addEventListener('change', syncMonthMode); }
});
syncFrequency();
}());
</script>
{% endblock %}
+88 -8
View File
@@ -11,7 +11,7 @@
<a href="{{ url_for('inspections.index') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Inspections
</a>
{% if current_user.role in ['admin','director','project_manager'] %}
{% if current_user.role in ['admin','director','project_manager','auditor','customer'] %}
<a href="{{ url_for('scheduled_inspections.create') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> New Schedule
</a>
@@ -19,7 +19,25 @@
</div>
</div>
<div class="card shadow-sm">
{# Pending / Completed tabs (phase47). #}
<ul class="nav nav-tabs mb-0">
<li class="nav-item">
<a class="nav-link {{ 'active' if tab == 'pending' }}"
href="{{ url_for('scheduled_inspections.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('scheduled_inspections.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>
<div class="card shadow-sm border-top-0" style="border-top-left-radius:0;border-top-right-radius:0;">
<div class="card-body p-0">
{% if schedules %}
<div class="table-responsive">
@@ -31,7 +49,10 @@
<th>Inspector</th>
<th>Frequency</th>
<th>Next Due</th>
<th>Ends</th>
<th>Status</th>
<th>Confirmation</th>
<th>Created By</th>
<th class="text-end"></th>
</tr>
</thead>
@@ -43,7 +64,7 @@
<td><strong>{{ s.facility.name if s.facility else '—' }}</strong></td>
<td>{{ s.template.name if s.template else '—' }}</td>
<td>{{ s.inspector.display_name if s.inspector else '— Unassigned —' }}</td>
<td><span class="badge bg-secondary">{{ s.frequency_label }}</span></td>
<td><span class="badge bg-secondary">{{ s.recurrence_label }}</span></td>
<td>
{{ s.next_due_date.strftime('%b %d, %Y') }}
{% if overdue %}
@@ -52,22 +73,77 @@
<span class="badge bg-warning text-dark ms-1">Due soon</span>
{% endif %}
</td>
<td class="text-nowrap">
{% if s.frequency == 'once' %}
<span class="text-muted"></span>
{% elif s.end_date %}
{{ s.end_date.strftime('%b %d, %Y') }}
{% else %}
<span class="text-muted" title="Repeats indefinitely">No end</span>
{% endif %}
</td>
<td>
{# Three states, not two: "Ended" distinguishes a schedule that ran
its course from one a manager switched off. #}
{% if s.active %}
<span class="badge bg-success">Active</span>
{% elif s.is_expired %}
<span class="badge bg-dark" title="Passed its end date">Ended</span>
{% else %}
<span class="badge bg-secondary">Inactive</span>
{% endif %}
{% if s.last_completed_at %}
<div class="small text-muted" title="Last completed">
<i class="bi bi-check2"></i> {{ s.last_completed_at.strftime('%b %d, %Y') }}
</div>
{% endif %}
</td>
<td>
{# Receipt confirmation by the assigned inspector (phase47). #}
{% if not s.inspector_id %}
<span class="text-muted"></span>
{% elif s.is_acknowledged %}
<span class="badge bg-success" title="Confirmed by {{ s.inspector.display_name if s.inspector else 'inspector' }}">
<i class="bi bi-check-circle"></i> Confirmed
</span>
<div class="small text-muted">{{ s.acknowledged_at.strftime('%b %d, %Y') }}</div>
{% else %}
<span class="badge bg-warning text-dark" title="The inspector has not confirmed receipt yet">
<i class="bi bi-hourglass-split"></i> Awaiting
</span>
{# The assignee can confirm right here (only while still active). #}
{% if s.active and s.inspector_id == current_user.id %}
<form method="POST" class="d-inline"
action="{{ url_for('scheduled_inspections.acknowledge', schedule_id=s.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-success mt-1" title="Confirm you received this request">
<i class="bi bi-check-lg"></i> Confirm receipt
</button>
</form>
{% endif %}
{% endif %}
</td>
<td>
{{ s.creator.display_name if s.creator else '—' }}
<div class="small text-muted">{{ s.created_at.strftime('%b %d, %Y') if s.created_at else '' }}</div>
</td>
<td class="text-end text-nowrap">
{% if s.active and (current_user.role in ['admin','director','project_manager']
or (current_user.role == 'inspector' and s.inspector_id == current_user.id)) %}
{# Start is shown only to the assignee — the inspection is theirs to do. #}
{% if s.active and s.inspector_id and s.inspector_id == current_user.id %}
{% set open_id = open_inspections.get(s.id) %}
{% if open_id %}
<a href="{{ url_for('inspections.execute', inspection_id=open_id) }}"
class="btn btn-sm btn-warning" title="You already started this — resume it">
<i class="bi bi-pencil-square"></i> Continue
</a>
{% else %}
<a href="{{ url_for('scheduled_inspections.start', schedule_id=s.id) }}"
class="btn btn-sm btn-success" title="Start this inspection">
<i class="bi bi-play-fill"></i> Start
</a>
{% endif %}
{% if current_user.role in ['admin','director','project_manager'] %}
{% endif %}
{% if current_user.role in ['admin','director','project_manager','auditor','customer'] %}
<a href="{{ url_for('scheduled_inspections.edit', schedule_id=s.id) }}"
class="btn btn-sm btn-outline-primary"><i class="bi bi-pencil"></i></a>
<form method="POST" class="d-inline"
@@ -85,10 +161,14 @@
</div>
{% else %}
<div class="p-4 text-muted text-center">
No scheduled inspections yet.
{% if current_user.role in ['admin','director','project_manager'] %}
{% if tab == 'completed' %}
No completed scheduled inspections yet.
{% else %}
No pending scheduled inspections.
{% if current_user.role in ['admin','director','project_manager','auditor','customer'] %}
<a href="{{ url_for('scheduled_inspections.create') }}">Create one</a>.
{% endif %}
{% endif %}
</div>
{% endif %}
</div>
@@ -11,6 +11,10 @@
<a href="{{ url_for('support.admin_tickets') }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-life-preserver me-1"></i>Support 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_knowledge_new') }}" class="btn btn-primary btn-sm">
<i class="bi bi-plus-circle me-1"></i>Add Entry
</a>
@@ -0,0 +1,79 @@
{% extends "base.html" %}
{% block title %}What the AI Sees{% endblock %}
{# Read-only view of the assembled system prompt. Exists so an admin can tell
"my knowledge entry never reached the prompt" apart from "the model saw it
and chose not to use it" — the two have completely different fixes. #}
{% block content %}
<div class="d-flex flex-wrap justify-content-between align-items-center mb-3 gap-2">
<h2 class="mb-0"><i class="bi bi-eye"></i> What the AI Sees</h2>
<a href="{{ url_for('support.admin_knowledge') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Back to Knowledge Base
</a>
</div>
<div class="row g-3 mb-3">
<div class="col-6 col-md-3">
<div class="card shadow-sm h-100">
<div class="card-body text-center py-3">
<div class="fs-4 fw-bold">{{ active_count }}</div>
<div class="text-muted small">Active entries</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card shadow-sm h-100">
<div class="card-body text-center py-3">
<div class="fs-4 fw-bold">{{ total_count - active_count }}</div>
<div class="text-muted small">Inactive (not sent)</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card shadow-sm h-100">
<div class="card-body text-center py-3">
<div class="fs-4 fw-bold">{{ prompt | length }}</div>
<div class="text-muted small">Prompt characters</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card shadow-sm h-100">
<div class="card-body text-center py-3">
{% if kb_included %}
<div class="fs-4 fw-bold text-success"><i class="bi bi-check-circle"></i></div>
<div class="text-muted small">Knowledge included</div>
{% else %}
<div class="fs-4 fw-bold text-danger"><i class="bi bi-x-circle"></i></div>
<div class="text-muted small">Knowledge NOT included</div>
{% endif %}
</div>
</div>
</div>
</div>
{% if not kb_included and total_count %}
<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle me-1"></i>
You have {{ total_count }} knowledge entr{{ 'y' if total_count == 1 else 'ies' }}, but
none reached the prompt. Check that at least one is marked <strong>Active</strong>.
</div>
{% endif %}
<div class="alert alert-info">
<i class="bi bi-info-circle me-1"></i>
This is the exact text sent to the AI ahead of every customer question. Entries are
capped at {{ kb_cap }} characters in total — past that, later entries are dropped
(lowest sort order is kept first). If something you wrote appears here but the AI still
will not say it, the wording of the entry is the thing to change, not the setup.
</div>
<div class="card shadow-sm">
<div class="card-header bg-light fw-semibold">Assembled system prompt</div>
<div class="card-body p-0">
<pre class="mb-0 p-3" style="white-space:pre-wrap; font-size:.8rem; max-height:70vh;
overflow-y:auto; background:#f8fafc;">{{ prompt }}</pre>
</div>
</div>
{% endblock %}
+9
View File
@@ -160,6 +160,15 @@
{{ form.frequency.label(class="form-label fw-semibold small") }}
{{ form.frequency(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">
+67 -1
View File
@@ -38,6 +38,24 @@
<i class="bi bi-check2-square"></i> {{ template.checklist_items.count() }} items
</small>
</div>
{# phase52 — who may use this form. No links = shared with all. #}
<div class="mt-2">
{% if template.is_shared %}
<span class="badge bg-light text-dark border"
title="Available on every contract">
<i class="bi bi-globe2"></i> Shared
</span>
{% else %}
{% for link in template.contract_links %}
<span class="badge bg-primary"
title="Only available on this contract">
<i class="bi bi-briefcase"></i>
{{ link.project.name if link.project else 'contract #' ~ link.project_id }}
</span>
{% endfor %}
{% endif %}
</div>
</div>
<div class="card-footer bg-transparent d-flex gap-2 align-items-center flex-wrap">
@@ -56,6 +74,7 @@
data-template-name="{{ template.name }}"
data-template-description="{{ template.description or '' }}"
data-template-frequency="{{ template.frequency or 'daily' }}"
data-template-contracts="{{ template.contract_ids|join(',') }}"
title="Edit template details">
<i class="bi bi-pencil"></i> Edit
</button>
@@ -142,7 +161,7 @@
placeholder="Optional description"></textarea>
</div>
<div class="mb-1">
<div class="mb-3">
<label for="editFrequency" class="form-label fw-semibold">Frequency</label>
<select id="editFrequency" name="frequency" class="form-select">
<option value="daily">Daily</option>
@@ -151,6 +170,34 @@
<option value="quarterly">Quarterly</option>
</select>
</div>
{# phase52 — which contracts may use this form. The hidden
marker tells the route this modal really did include the
field, so an empty selection means "share it" rather
than "no field was posted, leave it alone". #}
<div class="mb-1">
<label for="editContracts" class="form-label fw-semibold">
Available on contracts
</label>
<input type="hidden" name="contracts_present" value="1">
<select id="editContracts" name="contract_ids"
class="form-select" multiple size="6">
{% for p in contracts %}
<option value="{{ p.id }}">{{ p.name }}</option>
{% endfor %}
</select>
<div class="form-text">
Leave <strong>nothing selected</strong> to share this form with
every contract (this is how all existing forms are set).
Select one or more contracts to restrict it to them — it is
then hidden from every other customer, on the web and in the
iPad app. Ctrl/Cmd-click to select several.
</div>
<button type="button" id="clearContracts"
class="btn btn-sm btn-link px-0 mt-1">
Clear selection (make shared)
</button>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
@@ -228,6 +275,17 @@ document.addEventListener('DOMContentLoaded', function () {
document.getElementById('renameInput').value = templateName;
document.getElementById('editDescription').value = templateDesc;
document.getElementById('editFrequency').value = templateFreq;
// Pre-tick the contracts this form is currently restricted to. An
// empty attribute means it is shared, so nothing is selected.
const contractSel = document.getElementById('editContracts');
if (contractSel) {
const current = (btn.getAttribute('data-template-contracts') || '')
.split(',').filter(Boolean);
Array.from(contractSel.options).forEach(function (o) {
o.selected = current.indexOf(o.value) !== -1;
});
}
document.getElementById('renameTemplateForm').action =
'/templates/' + templateId + '/rename';
@@ -238,6 +296,14 @@ document.addEventListener('DOMContentLoaded', function () {
});
});
const clearBtn = document.getElementById('clearContracts');
if (clearBtn) {
clearBtn.addEventListener('click', function () {
const sel = document.getElementById('editContracts');
Array.from(sel.options).forEach(function (o) { o.selected = false; });
});
}
const deleteModal = document.getElementById('deleteModal');
deleteModal.addEventListener('show.bs.modal', function (event) {
const btn = event.relatedTarget;
+105
View File
@@ -0,0 +1,105 @@
{% extends "base.html" %}
{% block title %}About Us{% endblock %}
{# About Us — new page (design A/B test). Linked from the modern sidebar. #}
{% block content %}
<div class="jqc-page-head center">
<div class="jqc-page-title">About Us</div>
<div class="jqc-page-sub text-center">Janitorial Quality Control by L.T. Services, Inc.</div>
</div>
<div class="row g-4">
<div class="col-12 col-lg-7">
<div class="jqc-card h-100">
<div class="jqc-card-title">
<span class="jqc-tile-icon"><i class="bi bi-buildings"></i></span>L.T. Services, Inc.
</div>
<p class="mb-3">
L.T. Services, Inc. provides commercial janitorial services to public and private
facilities. Quality is verified in the field, not assumed — every contract is backed
by scheduled inspections, documented findings and tracked resolution.
</p>
<p class="mb-0 text-muted">
JQC is our in-house quality control platform. Inspectors work from an offline-capable
iPad app; managers, contract staff and customers work from this web portal. Both share
one record of every inspection, issue and photo.
</p>
</div>
</div>
<div class="col-12 col-lg-5">
<div class="jqc-card h-100">
<div class="jqc-card-title">
<span class="jqc-tile-icon"><i class="bi bi-check2-square"></i></span>What JQC Covers
</div>
<div class="jqc-stat-row"><span class="jqc-stat-label"><i class="bi bi-clipboard-check"></i>Facility inspections &amp; scoring</span></div>
<div class="jqc-stat-row"><span class="jqc-stat-label"><i class="bi bi-calendar2-week"></i>Recurring inspection schedules</span></div>
<div class="jqc-stat-row"><span class="jqc-stat-label"><i class="bi bi-exclamation-triangle"></i>Issue tracking with SLA deadlines</span></div>
<div class="jqc-stat-row"><span class="jqc-stat-label"><i class="bi bi-qr-code"></i>QR reporting from any facility</span></div>
<div class="jqc-stat-row"><span class="jqc-stat-label"><i class="bi bi-bar-chart"></i>Reports, scorecards and exports</span></div>
</div>
</div>
{# ── External resources ────────────────────────────────────────────────
The features site is a separate property, so it is an absolute URL and
opens in a new tab (rel="noopener" — never let a new tab reach back into
this one via window.opener).
The enrollment form is served by THIS app, so it is linked with url_for()
rather than the absolute https://jqc.ltservicesinc.com/enrollment. That
keeps the link on whatever host the user is already using (jqc1, or a
future brand domain) instead of bouncing them across hosts, and it cannot
rot if the domain changes. Same page either way. #}
<div class="col-12 col-lg-6">
<a class="jqc-hub-card" href="https://jqcfeatures.ltservicesinc.com/"
target="_blank" rel="noopener">
<div class="d-flex gap-3 align-items-start">
<span class="jqc-tile-icon lg"><i class="bi bi-stars"></i></span>
<div>
<div class="jqc-hub-title">JQC Features</div>
<div class="jqc-hub-text">
A summary of everything JQC does — inspections, issue tracking,
reporting and the mobile app.
</div>
</div>
</div>
<div class="jqc-hub-open">
Open <i class="bi bi-box-arrow-up-right ms-1" style="font-size:.8rem;"></i>
</div>
</a>
</div>
<div class="col-12 col-lg-6">
<a class="jqc-hub-card" href="{{ url_for('enrollment.form') }}">
<div class="d-flex gap-3 align-items-start">
<span class="jqc-tile-icon lg"><i class="bi bi-person-plus"></i></span>
<div>
<div class="jqc-hub-title">Enroll More People</div>
<div class="jqc-hub-text">
Add colleagues to JQC. Tell us who needs access and what each person
should be able to do — we set up the accounts and email their
invitations.
</div>
</div>
</div>
<div class="jqc-hub-open">Open &rarr;</div>
</a>
</div>
<div class="col-12">
<div class="jqc-card">
<div class="jqc-card-title">
<span class="jqc-tile-icon"><i class="bi bi-envelope"></i></span>Contact &amp; Support
</div>
<p class="mb-3">
Questions about an inspection, an issue on your site, or access to the portal —
start in the Support Center and we will route it to the right person.
</p>
<a href="{{ url_for('ui.support_center') }}" class="btn btn-primary">
<i class="bi bi-life-preserver me-1"></i>Go to Support
</a>
</div>
</div>
</div>
{% endblock %}
+152
View File
@@ -0,0 +1,152 @@
{% extends "base.html" %}
{% block title %}Support{% endblock %}
{#
Support Center — new page (design A/B test, slide 6 of JQC_design).
Linked from the modern sidebar. Every card either opens an existing route or
expands an inline how-to, so nothing here dead-ends.
#}
{% block content %}
<div class="jqc-page-head center">
<div class="jqc-page-title">Support</div>
<div class="jqc-page-sub text-center">JQC Features — find answers and how-to guides</div>
</div>
{# ── Live support routes (role-aware) ──────────────────────────────────── #}
<div class="row g-3 mb-4">
{% if current_user.is_customer_account %}
<div class="col-12 col-md-4">
<a class="jqc-hub-card" href="{{ url_for('support.chat') }}">
<div class="d-flex gap-3 align-items-center">
<span class="jqc-tile-icon"><i class="bi bi-chat-dots"></i></span>
<div class="jqc-hub-title" style="font-size:1.05rem;">Ask a Question</div>
</div>
</a>
</div>
<div class="col-12 col-md-4">
<a class="jqc-hub-card" href="{{ url_for('support.my_conversations') }}">
<div class="d-flex gap-3 align-items-center">
<span class="jqc-tile-icon"><i class="bi bi-clock-history"></i></span>
<div class="jqc-hub-title" style="font-size:1.05rem;">My Conversations</div>
</div>
</a>
</div>
<div class="col-12 col-md-4">
<a class="jqc-hub-card" href="{{ url_for('support.my_tickets') }}">
<div class="d-flex gap-3 align-items-center">
<span class="jqc-tile-icon"><i class="bi bi-inbox"></i></span>
<div class="jqc-hub-title" style="font-size:1.05rem;">My Requests</div>
</div>
</a>
</div>
{% elif current_user.role in ['admin', 'director'] %}
<div class="col-12 col-md-4">
<a class="jqc-hub-card" href="{{ url_for('support.admin_tickets') }}">
<div class="d-flex gap-3 align-items-center">
<span class="jqc-tile-icon"><i class="bi bi-inbox"></i></span>
<div class="jqc-hub-title" style="font-size:1.05rem;">Support Requests</div>
</div>
{% if open_support_tickets_count > 0 %}
<div class="jqc-hub-text">{{ open_support_tickets_count }} open</div>
{% endif %}
</a>
</div>
<div class="col-12 col-md-4">
<a class="jqc-hub-card" href="{{ url_for('support.admin_conversations') }}">
<div class="d-flex gap-3 align-items-center">
<span class="jqc-tile-icon"><i class="bi bi-chat-square-text"></i></span>
<div class="jqc-hub-title" style="font-size:1.05rem;">Chat Conversations</div>
</div>
</a>
</div>
<div class="col-12 col-md-4">
<a class="jqc-hub-card" href="{{ url_for('support.admin_knowledge') }}">
<div class="d-flex gap-3 align-items-center">
<span class="jqc-tile-icon"><i class="bi bi-journal-text"></i></span>
<div class="jqc-hub-title" style="font-size:1.05rem;">Knowledge Base</div>
</div>
</a>
</div>
{% endif %}
</div>
{# ── How-to guides ─────────────────────────────────────────────────────── #}
{% set guides = [
('bi-clipboard-check', 'How to create a new inspection',
'Inspections → New Inspection. Pick the contract, facility, area and template, then Start. The checklist opens straight away and saves as you go — you can leave and resume from Inspections → In Progress.'),
('bi-search', 'How to follow up on an inspection',
'Open the inspection and use Request Follow-up. It moves to the Follow-up list, notifies the inspector, and stays there until a re-inspection is submitted against it.'),
('bi-calendar2-week', 'How to schedule an inspection',
'Inspections → Schedule. Choose facility, template, inspector and how often it repeats. Recurring schedules roll their due date forward automatically once the inspection is submitted.'),
('bi-exclamation-triangle', 'How to Flag For Attention',
'While executing an inspection, use Flag Issue on any failing item. Set severity and who handles it (janitorial crew, facility staff or an outside vendor) — the SLA clock starts from that moment.'),
('bi-qr-code', 'How QR code works',
"Every facility and area has a QR code. Scanning it opens that location's public page — anyone on site can report a problem without an account, and the request lands in Issues."),
('bi-file-earmark-text','Send a request without the app',
'Point the on-site contact at the facility QR code, or forward them the public facility link. Their submission arrives as an unassigned issue for triage.'),
('bi-search', 'How to search',
'Use the search box in the top bar for an inspection number. For anything broader, each list page has filters for contract, facility, inspector, status, date range and score.'),
('bi-chat-dots', 'How to comment',
'Open any inspection or issue and use the comment box at the bottom. Comments are visible to staff; sharing one with the customer is an explicit choice on the comment itself.'),
('bi-bar-chart', 'Create & print inspection reports',
'Reports & Analytics → filter by date, contract, facility or inspector → Apply. Export to CSV, or use the PDF export on an individual inspection or facility scorecard.'),
('bi-clock', 'What does SLA At Risk mean?',
'The issue is approaching its resolution deadline for its severity but has not passed it yet. Treat it as the last window to close the issue on time.'),
('bi-alarm', 'What does an SLA Alert mean?',
'The issue has passed its resolution deadline for its severity. It stays flagged until resolved and shows on the dashboard SLA card.'),
('bi-phone', 'Can a client, contract administrator or inspector use the JQC app to conduct inspections?',
"Certainly. A customer's employee can use the JQC app not only for cleaning inspections but also to perform preventive maintenance on the property and submit service requests to third parties. Just download the app, fill out the enrollment form, and you are ready to go."),
] %}
<div class="row g-3">
{% for icon, title, body in guides %}
<div class="col-12 col-md-6 col-xl-4">
<div class="jqc-hub-card" role="button" data-bs-toggle="collapse"
data-bs-target="#guide{{ loop.index }}" aria-expanded="false">
<div class="d-flex gap-3 align-items-center">
<span class="jqc-tile-icon"><i class="bi {{ icon }}"></i></span>
<div class="jqc-hub-title" style="font-size:1.02rem;">{{ title }}</div>
</div>
<div class="collapse" id="guide{{ loop.index }}">
<div class="jqc-hub-text mt-3">{{ body }}</div>
</div>
</div>
</div>
{% endfor %}
{# The AI chat is for customer-side accounts (support.chat redirects staff to the ticket
queue, which is itself @supervisor_required). So the destination is chosen
per role rather than pointed at support.chat for everyone — an inspector
following that chain would land on the dashboard with an access-denied
flash, and this page is meant never to dead-end. Roles with no support
destination get no card; the how-to guides below are their support. #}
{% if current_user.is_customer_account %}
<div class="col-12 col-md-6 col-xl-4">
<a class="jqc-hub-card dark" href="{{ url_for('support.chat') }}">
<div class="d-flex gap-3 align-items-center">
<span class="jqc-tile-icon"><i class="bi bi-life-preserver"></i></span>
<div>
<div class="jqc-hub-title" style="font-size:1.02rem;">AI Support</div>
<div class="jqc-hub-text">Ask a question and get an answer straight away.</div>
</div>
</div>
<div class="jqc-hub-open">Open &rarr;</div>
</a>
</div>
{% elif current_user.role in ['admin', 'director'] %}
<div class="col-12 col-md-6 col-xl-4">
<a class="jqc-hub-card dark" href="{{ url_for('support.admin_conversations') }}">
<div class="d-flex gap-3 align-items-center">
<span class="jqc-tile-icon"><i class="bi bi-life-preserver"></i></span>
<div>
<div class="jqc-hub-title" style="font-size:1.02rem;">AI Support</div>
<div class="jqc-hub-text">Review the AI chat conversations customers have had.</div>
</div>
</div>
<div class="jqc-hub-open">Open &rarr;</div>
</a>
</div>
{% endif %}
</div>
{% endblock %}
+71
View File
@@ -0,0 +1,71 @@
{% extends "base.html" %}
{% block title %}Design Vote Tally{% endblock %}
{# Admin-only: which design are active users currently keeping? #}
{% block content %}
<div class="row mb-4">
<div class="col">
<h2 class="mb-1"><i class="bi bi-bar-chart"></i> Design Vote Tally</h2>
<div class="text-muted">Which web portal design each active user is currently using.</div>
</div>
</div>
<div class="row g-3 mb-4">
<div class="col-12 col-md-4">
<div class="card h-100">
<div class="card-body">
<div class="text-muted small">Classic design</div>
<div class="fs-2 fw-bold">{{ tally.classic }}</div>
<div class="text-muted small">
{{ ((tally.classic / total * 100) | round(1)) if total else 0 }}% of {{ total }} active users
</div>
</div>
</div>
</div>
<div class="col-12 col-md-4">
<div class="card h-100">
<div class="card-body">
<div class="text-muted small">New design</div>
<div class="fs-2 fw-bold">{{ tally.modern }}</div>
<div class="text-muted small">
{{ ((tally.modern / total * 100) | round(1)) if total else 0 }}% of {{ total }} active users
</div>
</div>
</div>
</div>
<div class="col-12 col-md-4">
<div class="card h-100">
<div class="card-body">
<div class="text-muted small">Total active users</div>
<div class="fs-2 fw-bold">{{ total }}</div>
<div class="text-muted small">Every account defaults to classic</div>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header fw-semibold"><i class="bi bi-people me-1"></i>Breakdown by role</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr><th>Role</th><th>Design</th><th class="text-end">Users</th></tr>
</thead>
<tbody>
{% for role, theme, count in by_role %}
<tr>
<td>{{ role.replace('_',' ')|title }}</td>
<td>{{ 'New design' if theme == 'modern' else 'Classic design' }}</td>
<td class="text-end fw-semibold">{{ count }}</td>
</tr>
{% else %}
<tr><td colspan="3" class="text-center text-muted py-4">No active users.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
+47 -2
View File
@@ -28,6 +28,29 @@ def safe_redirect_url(url: str | None, fallback: str | None = None) -> str:
return fallback
return url
def return_url(fallback: str) -> str:
"""Where to go back to after a list-page action, preserving its filters.
Reads the `next` value the page carried through the action POST body
first (forms), then query string (links) and validates it with
safe_redirect_url, so a crafted `next` can never redirect off-site.
The problem this solves: a delete or an edit launched from a filtered list
used to redirect to the bare index, throwing away the filters the user had
set. Every list-page action now round-trips the list URL instead.
`next` is deliberately the FULL list URL (page number and all), not a
reconstructed set of arguments that keeps this helper working when a new
filter is added to either list page without anyone having to remember to
thread it through here.
"""
from flask import request
return safe_redirect_url(
request.form.get('next') or request.args.get('next'),
fallback=fallback,
)
def admin_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
@@ -54,17 +77,39 @@ def supervisor_required(f):
return decorated_function
def project_manager_required(f):
"""Grants access to admin, director, and project_manager roles."""
"""Grants access to admin, director, project_manager, and auditor roles.
Auditor mirrors Project Manager for all baseline access, so it is included
here alongside project_manager.
"""
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_authenticated or current_user.role not in [
'admin', 'director', 'project_manager'
'admin', 'director', 'project_manager', 'auditor'
]:
flash('Project Manager access required.', 'danger')
return redirect(url_for('dashboard.index'))
return f(*args, **kwargs)
return decorated_function
def issue_manager_required(f):
"""Grants access to admin, director, and auditor roles.
Used for issue-management powers that go beyond the Project Manager
baseline (verification and the verification queue). Deliberately does NOT
include project_manager, and does NOT grant issue deletion delete stays
on @supervisor_required (admin/director only).
"""
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_authenticated or current_user.role not in [
'admin', 'director', 'auditor'
]:
flash('Issue management access required.', 'danger')
return redirect(url_for('dashboard.index'))
return f(*args, **kwargs)
return decorated_function
def customer_required(f):
"""Restricts access to customer-role users only.
+98 -9
View File
@@ -2,7 +2,7 @@ from flask_wtf import FlaskForm
from flask_wtf.file import FileField, FileAllowed, MultipleFileField
from wtforms import (StringField, PasswordField, SelectField, TextAreaField,
DecimalField, BooleanField, IntegerField, HiddenField,
RadioField, DateField)
RadioField, DateField, SelectMultipleField)
from wtforms.validators import (DataRequired, Email, Length, EqualTo,
Optional, NumberRange, ValidationError)
from app.models.user import User
@@ -55,7 +55,12 @@ class UserForm(FlaskForm):
('director', 'Director'),
('inspector', 'Inspector'),
('project_manager', 'Project Manager'),
# 'customer' is intentionally excluded — customer accounts are managed via /customers
('auditor', 'Auditor'),
# Both customer-side roles are intentionally excluded — 'customer'
# (Customer Director) and 'external_inspector' (Customer Inspector) are
# created, edited and switched exclusively in Customer Management
# (/customers). phase51 removed 'external_inspector' from here; see
# User.CUSTOMER_ROLES.
], validators=[Optional()])
# NOTE: Optional() here because directors submit no role value (the field is
# hidden in user_form.html for them). Role enforcement is handled in the
@@ -125,6 +130,11 @@ class InspectionTemplateForm(FlaskForm):
('daily','Daily'), ('weekly','Weekly'),
('monthly','Monthly'), ('quarterly','Quarterly'),
], validators=[DataRequired()])
# phase52 — which contracts may use this form. Choices are populated in the
# route. Selecting NONE leaves the form shared with every contract, which
# is the default and what every pre-phase52 template does.
contract_ids = SelectMultipleField('Available on contracts', coerce=int,
validators=[Optional()])
class ChecklistItemForm(FlaskForm):
@@ -183,6 +193,9 @@ class IssueForm(FlaskForm):
vendor_name = StringField('Contractor Name', validators=[Optional(), Length(max=100)])
vendor_contact = StringField('Contractor Contact', validators=[Optional(), Length(max=200)])
vendor_notes = TextAreaField('Contractor Notes', validators=[Optional(), Length(max=1000)])
# Janitorial staff member's name + contact — used when handler_type == 'internal'
internal_handler_name = StringField('Staff Name', validators=[Optional(), Length(max=100)])
internal_handler_contact = StringField('Staff Contact', validators=[Optional(), Length(max=200)])
class IssueUpdateForm(FlaskForm):
@@ -211,6 +224,9 @@ class IssueUpdateForm(FlaskForm):
vendor_name = StringField('Contractor Name', validators=[Optional(), Length(max=100)])
vendor_contact = StringField('Contractor Contact', validators=[Optional(), Length(max=200)])
vendor_notes = TextAreaField('Contractor Notes', validators=[Optional(), Length(max=1000)])
# Janitorial staff member's name + contact — used when handler_type == 'internal'
internal_handler_name = StringField('Staff Name', validators=[Optional(), Length(max=100)])
internal_handler_contact = StringField('Staff Contact', validators=[Optional(), Length(max=200)])
# ── Projects ─────────────────────────────────────────────────────────────────
@@ -261,14 +277,22 @@ class CustomerUserForm(FlaskForm):
raise ValidationError('Password is required for new accounts.')
class CustomerInviteForm(FlaskForm):
"""Simplified form for creating a customer account via email invitation.
"""Create a customer-side account via email invitation.
Admin enters Full Name and Email only. A username is auto-generated
from the email address. The customer sets their own username and
password via the emailed link.
Admin enters Full Name, Email and which of the two customer roles the
person holds. A username is auto-generated from the email address; the
invitee sets their own username and password via the emailed link.
Both roles use the SAME invitation flow neither is an account we set a
password for. phase51 folded the Customer Inspector (stored as
'external_inspector') in here from User Management.
"""
full_name = StringField('Full Name', validators=[DataRequired(), Length(max=150)])
email = StringField('Email', validators=[DataRequired(), Email(), Length(max=255)])
role = SelectField('Role', choices=[
('customer', 'Customer Director — portal access for their facilities'),
('external_inspector', 'Customer Inspector — performs inspections on their contracts'),
], default='customer', validators=[DataRequired()])
def validate_email(self, field):
if User.query.filter_by(email=field.data.strip().lower()).first():
@@ -314,7 +338,7 @@ class PublicIssueReportForm(FlaskForm):
validators=[Optional(), Length(max=100)])
reporter_contact = StringField('Email or phone (optional)',
validators=[Optional(), Length(max=120)])
photo = FileField('Add a photo (optional)',
photos = MultipleFileField('Add photos (optional, up to 5)',
validators=[Optional(),
FileAllowed(['jpg', 'jpeg', 'png', 'gif'],
'Images only (jpg, png, gif).')])
@@ -331,10 +355,75 @@ class ScheduledInspectionForm(FlaskForm):
('once', 'One-time'), ('daily', 'Daily'),
('weekly', 'Weekly'), ('monthly', 'Monthly'),
], validators=[DataRequired()])
next_due_date = DateField('Due Date', validators=[DataRequired()])
notes = TextAreaField('Notes', validators=[Optional(), Length(max=1000)])
next_due_date = DateField('Start / Due Date', validators=[DataRequired()])
# Label is overridden per context in routes/scheduled_inspections.py:
# "Start Date" when creating, "Next Due Date" when editing. The default
# above is only a fallback.
end_date = DateField('End Date', validators=[Optional()])
# UI label only. The field name, the ScheduledInspection.notes attribute and
# the scheduled_inspections.notes column all stay `notes` — renaming any of
# them would break the API payload key the iPad decodes.
notes = TextAreaField('Instructions', validators=[Optional(), Length(max=1000)])
active = BooleanField('Active', default=True)
# ── Recurrence detail (phase43) ──────────────────────────────────────────
# Only the block matching `frequency` is required; the rest is ignored and
# cleared on save. Shown/hidden client-side, enforced in validate() below.
weekdays = SelectMultipleField(
'Days of the Week', coerce=int, validators=[Optional()],
choices=[(i, n) for i, n in enumerate(
['Monday', 'Tuesday', 'Wednesday', 'Thursday',
'Friday', 'Saturday', 'Sunday'])],
)
month_mode = SelectField('Monthly Rule', validators=[Optional()], choices=[
('day_of_month', 'On a day of the month'),
('nth_weekday', 'On a weekday of the month'),
], default='day_of_month')
day_of_month = IntegerField(
'Day of Month', validators=[Optional(), NumberRange(min=1, max=31)])
nth_week = SelectField('Week', coerce=int, validators=[Optional()], choices=[
(1, '1st'), (2, '2nd'), (3, '3rd'), (4, '4th'), (5, '5th'), (-1, 'Last'),
], default=1)
nth_weekday = SelectField(
'Weekday', coerce=int, validators=[Optional()],
choices=[(i, n) for i, n in enumerate(
['Monday', 'Tuesday', 'Wednesday', 'Thursday',
'Friday', 'Saturday', 'Sunday'])],
default=0,
)
def validate(self, extra_validators=None):
"""Conditionally require the recurrence block for the chosen frequency."""
if not super().validate(extra_validators):
return False
ok = True
if self.frequency.data == 'weekly' and not self.weekdays.data:
self.weekdays.errors.append('Pick at least one day of the week.')
ok = False
elif self.frequency.data == 'monthly':
if self.month_mode.data == 'nth_weekday':
if not self.nth_week.data or self.nth_weekday.data is None:
self.nth_week.errors.append('Choose which weekday of the month.')
ok = False
elif not self.day_of_month.data:
self.day_of_month.errors.append('Enter a day of the month (131).')
ok = False
# End date (phase44). Only meaningful for recurring schedules — a
# one-time schedule ends by deactivating when it is completed. Rejecting
# an end date before the due date here is what makes the "already past
# its boundary on save" case unreachable in the routes.
if self.end_date.data:
if self.frequency.data == 'once':
self.end_date.errors.append(
'A one-time schedule has no end date — it closes when completed.')
ok = False
elif self.next_due_date.data and self.end_date.data < self.next_due_date.data:
self.end_date.errors.append(
'End date must be on or after the due date.')
ok = False
return ok
# ── Support Knowledge Base (phase38) ─────────────────────────────────────────
+112 -4
View File
@@ -44,13 +44,23 @@ _EMAIL_HTML_SINGLE = """\
<body style="font-family:Arial,sans-serif;color:#333;max-width:600px;margin:auto;">
<h2 style="color:#0d6efd;">{{ title }}</h2>
<p>{{ body }}</p>
{% if link %}
{% if link or extra_action %}
<p>
{% if extra_action %}
<a href="{{ extra_action.url }}"
style="background:#198754;color:#fff;padding:10px 20px;
text-decoration:none;border-radius:4px;display:inline-block;
margin-right:8px;">
{{ extra_action.label }}
</a>
{% endif %}
{% if link %}
<a href="{{ base_url }}{{ link }}"
style="background:#0d6efd;color:#fff;padding:10px 20px;
text-decoration:none;border-radius:4px;display:inline-block;">
View Details
</a>
{% endif %}
</p>
{% endif %}
<hr style="border:none;border-top:1px solid #eee;margin-top:32px;">
@@ -68,6 +78,9 @@ _EMAIL_TEXT_SINGLE = """\
{{ title }}
{{ body }}
{% if extra_action %}
{{ extra_action.label }}: {{ extra_action.url }}
{% endif %}
{% if link %}
View: {{ base_url }}{{ link }}
{% endif %}
@@ -172,6 +185,7 @@ def notify(
event_type: str = None,
send_email: bool = True,
respect_preferences: bool = True,
extra_action: dict = None,
):
"""Create an in-app Notification record and optionally send an email.
@@ -189,7 +203,34 @@ def notify(
respect_preferences : When True (default), per-user email preferences gate delivery.
Set False for matrix-routed broadcasts the matrix is the
authority; individual opt-out should not override admin config.
extra_action : Optional dict {'label': str, 'url': str} rendered as a second
(green) button in the email, before "View Details". `url` must
be an absolute URL (it is NOT prefixed with base_url). Used for
the scheduled-inspection "Confirm receipt" email link. In-app
notifications are unaffected this only shapes the email.
"""
# ── Per-account override (phase51) ───────────────────────────────────
# A customer-side account's own notification matrix governs EVERY path
# that reaches it, not just matrix broadcasts: follower fan-out
# (_notify_followers) and direct assignee notifications both call notify()
# straight, so without this the editor would offer rows — "Issue follow
# update", "Issue assigned" — that appeared to be off while the
# notifications kept arriving.
#
# Only an explicit `False` suppresses. No row means inherit, which is the
# default for every account and leaves behaviour exactly as before. The
# getattr fallback is deliberate: if the attribute is unavailable for any
# reason we send, never silently drop.
if event_type and getattr(recipient, 'is_customer_account', False):
from app.models.user_notification_matrix import override_for
if override_for(recipient.id, event_type) is False:
logger.info(
'NOTIFICATION SUPPRESSED | user=%s | event=%s | '
'reason=per_account_override_off',
recipient.username, event_type,
)
return
# Determine digest flag before creating the record.
# Digest mode is only respected when individual preferences are in effect.
hold_for_digest = (
@@ -246,10 +287,10 @@ def notify(
elif should_send:
logger.info('EMAIL SEND | user=%s | event=%s | to=%s',
recipient.username, event_type, recipient.email)
_send_single_email(recipient, title, body, link)
_send_single_email(recipient, title, body, link, extra_action)
def _send_single_email(recipient, title, body, link):
def _send_single_email(recipient, title, body, link, extra_action=None):
"""Dispatch a single immediate notification email in a background thread.
Sending is offloaded to a daemon thread so SMTP latency never blocks the
@@ -265,9 +306,11 @@ def _send_single_email(recipient, title, body, link):
)
html_body = render_template_string(
_EMAIL_HTML_SINGLE, title=title, body=body, link=link, base_url=base_url,
extra_action=extra_action,
)
text_body = render_template_string(
_EMAIL_TEXT_SINGLE, title=title, body=body, link=link, base_url=base_url,
extra_action=extra_action,
)
msg = Message(
subject = f'[JQC] {title}',
@@ -316,6 +359,7 @@ def notify_customers_for_facility(
link: str = None,
issue_id: int = None,
inspection_id: int = None,
allowed_user_ids: set = None,
):
"""Dispatch in-app + email notifications to all customer users assigned
to the given facility.
@@ -336,6 +380,12 @@ def notify_customers_for_facility(
link : Relative URL for 'View Details'.
issue_id : FK to issues.id (optional).
inspection_id : FK to inspections.id (optional).
allowed_user_ids :
Optional whitelist. When notify_by_matrix() calls this it has already
applied each account's per-event override (phase51), so it passes the
surviving ids here this function re-derives recipients from the
assignment rows and would otherwise notify accounts that opted out.
None (the default, used by direct callers) means no filtering.
"""
try:
from app.models.project import CustomerAssignment
@@ -373,8 +423,20 @@ def notify_customers_for_facility(
)
return
if allowed_user_ids is not None:
notified_user_ids &= set(allowed_user_ids)
if not notified_user_ids:
logger.debug(
'notify_customers_for_facility | facility_id=%s | all '
'assigned customers filtered out by per-account overrides',
facility_id,
)
return
for user_id in notified_user_ids:
user = db.session.get(User, user_id)
# role != 'customer' stays an EQUALITY check: a Customer Inspector
# is not a portal customer and is routed by the inspector column.
if not user or not user.active or user.role != 'customer':
continue
try:
@@ -536,16 +598,27 @@ def notify_by_matrix(
from app.models.notification_matrix import (
is_enabled, get_custom_emails_for, MATRIX_ROLES,
)
from app.models.user_notification_matrix import overrides_for_event
from app.models.user import User
exclude = set(exclude_user_ids or [])
notified = set() # deduplicate across roles
# ── Per-account overrides (phase51) ───────────────────────────────────
# {user_id: bool} for this event, one query. Applies to the two
# customer-side role columns only; staff roles use the global matrix alone.
# An account with no entry inherits the global column, which is why this
# feature is a no-op until an admin actually sets something.
overrides = overrides_for_event(event_type)
customer_keys = User.CUSTOMER_ROLES # ('customer', 'external_inspector')
role_to_db = {
'admin': 'admin',
'director': 'director',
'inspector': 'inspector',
'external_inspector': 'external_inspector',
'project_manager': 'project_manager',
'auditor': 'auditor',
'customer': 'customer',
}
@@ -557,7 +630,14 @@ def notify_by_matrix(
enabled = is_enabled(event_type, role_key)
logger.info('MATRIX NOTIFY | event=%s | role=%s | enabled=%s',
event_type, role_key, enabled)
if not enabled:
# A customer-side column must NOT be skipped just because the global
# switch is off — an account that opted IN individually still has to be
# reached. Only skip when the column is off AND nobody opted in.
# (Getting this wrong is silent: the per-account "on" would save fine,
# show as on, and never send.)
is_customer_col = role_key in customer_keys
if not enabled and not (is_customer_col and any(overrides.values())):
continue
db_role = role_to_db.get(role_key)
@@ -568,6 +648,31 @@ def notify_by_matrix(
logger.info('MATRIX NOTIFY | event=%s | role=%s | users_found=%s',
event_type, role_key, [u.username for u in users])
# Scope the inspector role for "inspection_completed" to the inspection's
# OWN inspector (the person who did the work), not the whole inspector
# pool. Without this, enabling the Inspector column for this event would
# notify every inspector on every submission.
if (role_key in ('inspector', 'external_inspector')
and event_type == 'inspection_completed'):
target_id = None
if inspection_id:
from app.models.inspection import Inspection
insp = db.session.get(Inspection, inspection_id)
target_id = insp.inspector_id if insp else None
users = [u for u in users if u.id == target_id] if target_id else []
logger.info('MATRIX NOTIFY | event=%s | role=%s scoped to '
'submitting inspector_id=%s',
event_type, role_key, target_id)
# Apply the per-account overrides to the customer-side columns. An
# account with no override falls back to `enabled`, i.e. the global
# column — so this line is what makes both directions work: opt-in
# against an off column, and opt-out of an on one.
if is_customer_col:
users = [u for u in users if overrides.get(u.id, enabled)]
logger.info('MATRIX NOTIFY | event=%s | role=%s | after overrides=%s',
event_type, role_key, [u.username for u in users])
# Scope customer role to facility if provided
if role_key == 'customer' and facility_id:
from app.utils.notifications import notify_customers_for_facility
@@ -579,6 +684,9 @@ def notify_by_matrix(
link = link,
issue_id = issue_id,
inspection_id = inspection_id,
# Without this the facility-scoped path would re-query customers
# itself and bypass every override applied just above.
allowed_user_ids = {u.id for u in users},
)
continue # notify_customers_for_facility handles dedup internally
+120 -17
View File
@@ -43,6 +43,9 @@ C_GREEN = colors.HexColor('#16a34a')
C_YELLOW = colors.HexColor('#d97706')
C_RED = colors.HexColor('#dc2626')
C_WHITE = colors.white
# Light table-header / banner fill — printer-friendly (low ink) replacement for the
# former solid-dark headers. Distinct enough from the C_LIGHT zebra row shade.
C_HEADER = colors.HexColor('#dbe4f0')
SEVERITY_COLORS = {
'critical': C_RED,
@@ -177,23 +180,29 @@ def _on_page(canvas, doc, title, generated_at):
w, h = letter
margin = 0.65 * inch
# ── Dark header band ──
# ── Header band (light — printer-friendly) ──
# Was a full-width solid-dark rectangle; replaced with a light tint + a thin
# blue accent rule so it costs almost no ink to print.
canvas.saveState()
canvas.setFillColor(C_DARK)
canvas.setFillColor(C_LIGHT)
canvas.rect(0, h - 1.1 * inch, w, 1.1 * inch, stroke=0, fill=1)
# Blue accent rule along the bottom edge of the band
canvas.setStrokeColor(C_BLUE)
canvas.setLineWidth(2)
canvas.line(0, h - 1.1 * inch, w, h - 1.1 * inch)
canvas.setFont('Helvetica-Bold', 13)
canvas.setFillColor(C_WHITE)
canvas.setFillColor(C_DARK)
canvas.drawString(margin, h - 0.55 * inch, title)
canvas.setFont('Helvetica', 8)
canvas.setFillColor(colors.HexColor('#94a3b8'))
canvas.setFillColor(C_SLATE)
canvas.drawString(margin, h - 0.78 * inch, 'Janitorial Quality Control System')
# Page number — right-aligned
page_txt = f'Page {doc.page}'
canvas.setFont('Helvetica', 8)
canvas.setFillColor(colors.HexColor('#94a3b8'))
canvas.setFillColor(C_SLATE)
canvas.drawRightString(w - margin, h - 0.66 * inch, page_txt)
canvas.restoreState()
@@ -610,8 +619,9 @@ def _issues_section(issues):
sev_styles.append(('FONTNAME', (1, r), (1, r), 'Helvetica-Bold'))
tbl.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, 0), C_DARK),
('TEXTCOLOR', (0, 0), (-1, 0), C_WHITE),
('BACKGROUND', (0, 0), (-1, 0), C_HEADER),
('TEXTCOLOR', (0, 0), (-1, 0), C_DARK),
('LINEBELOW', (0, 0), (-1, 0), 0.75, C_BLUE),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, -1), 8),
('INNERGRID', (0, 0), (-1, -1), 0.25, C_BORDER),
@@ -808,18 +818,19 @@ def generate_issue_pdf(issue, static_folder: str) -> bytes:
Paragraph(f'<font color="white"><b>{sev.upper()}</b></font>',
ParagraphStyle('b1', fontName='Helvetica-Bold', fontSize=10,
textColor=C_WHITE, alignment=TA_CENTER)),
Paragraph(f'<font color="white"><b>Issue #{issue.id}</b></font>',
Paragraph(f'<b>Issue #{issue.id}</b>',
ParagraphStyle('b2', fontName='Helvetica-Bold', fontSize=16,
textColor=C_WHITE, alignment=TA_CENTER)),
Paragraph(f'<font color="white"><b>{stat_txt}</b></font>',
textColor=C_DARK, alignment=TA_CENTER)),
Paragraph(f'<b>{stat_txt}</b>',
ParagraphStyle('b3', fontName='Helvetica-Bold', fontSize=10,
textColor=C_WHITE, alignment=TA_CENTER)),
textColor=C_DARK, alignment=TA_CENTER)),
]]
banner = Table(banner_data, colWidths=[pw * 0.20, pw * 0.55, pw * 0.25])
banner.setStyle(TableStyle([
('BACKGROUND', (0, 0), (0, 0), sev_color),
('BACKGROUND', (1, 0), (1, 0), C_DARK),
('BACKGROUND', (2, 0), (2, 0), C_SLATE),
('BACKGROUND', (1, 0), (1, 0), C_LIGHT),
('BACKGROUND', (2, 0), (2, 0), C_HEADER),
('LINEBELOW', (1, 0), (2, 0), 0.75, C_BLUE),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
('TOPPADDING', (0, 0), (-1, -1), 10),
('BOTTOMPADDING', (0, 0), (-1, -1), 10),
@@ -1219,7 +1230,7 @@ def generate_issues_list_pdf(issues, filter_summary: str = '') -> bytes:
]
hdr_style = ParagraphStyle('ILH', fontName='Helvetica-Bold', fontSize=7.5,
textColor=C_WHITE, leading=9)
textColor=C_DARK, leading=9)
val_style = ParagraphStyle('ILV', fontName='Helvetica', fontSize=7.5,
textColor=C_DARK, leading=9)
@@ -1298,7 +1309,8 @@ def generate_issues_list_pdf(issues, filter_summary: str = '') -> bytes:
tbl = Table(tbl_data, colWidths=col_w, repeatRows=1)
tbl.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, 0), C_DARK),
('BACKGROUND', (0, 0), (-1, 0), C_HEADER),
('LINEBELOW', (0, 0), (-1, 0), 0.75, C_BLUE),
('ROWBACKGROUNDS', (0, 1), (-1, -1), [C_WHITE, C_LIGHT]),
('INNERGRID', (0, 0), (-1, -1), 0.25, C_BORDER),
('BOX', (0, 0), (-1, -1), 0.5, C_BORDER),
@@ -1375,7 +1387,7 @@ def generate_inspections_list_pdf(inspections, filter_summary: str = '') -> byte
# ── Table header ──────────────────────────────────────────────────────────
hdr_style = ParagraphStyle('LH', fontName='Helvetica-Bold', fontSize=7.5,
textColor=C_WHITE, leading=9)
textColor=C_DARK, leading=9)
val_style = ParagraphStyle('LV', fontName='Helvetica', fontSize=7.5,
textColor=C_DARK, leading=9)
@@ -1435,7 +1447,8 @@ def generate_inspections_list_pdf(inspections, filter_summary: str = '') -> byte
tbl = Table(tbl_data, colWidths=col_w, repeatRows=1)
tbl.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, 0), C_DARK),
('BACKGROUND', (0, 0), (-1, 0), C_HEADER),
('LINEBELOW', (0, 0), (-1, 0), 0.75, C_BLUE),
('ROWBACKGROUNDS', (0, 1), (-1, -1), [C_WHITE, C_LIGHT]),
('INNERGRID', (0, 0), (-1, -1), 0.25, C_BORDER),
('BOX', (0, 0), (-1, -1), 0.5, C_BORDER),
@@ -1590,3 +1603,93 @@ def generate_facility_summary_pdf(facility, days, start, now,
doc.build(story)
return buf.getvalue()
return buf.getvalue()
def generate_qr_codes_pdf(items, filter_summary: str = '') -> bytes:
"""Return a PDF byte-string laying out selected QR codes in a grid.
Parameters
----------
items : list of dicts, each:
{
'title': str, # main label (facility or area name)
'subtitle': str | None, # e.g. contract name, or parent facility
'caption': str | None, # small line under the QR
'png': bytes, # QR code PNG image bytes
}
filter_summary : human-readable string describing the selection (optional)
"""
buf = io.BytesIO()
generated_at = datetime.now().strftime('%B %d, %Y %I:%M %p ET')
report_title = 'QR Codes'
doc = SimpleDocTemplate(
buf,
pagesize=letter,
leftMargin=0.65 * inch,
rightMargin=0.65 * inch,
topMargin=1.35 * inch,
bottomMargin=0.75 * inch,
title=report_title,
author='Janitorial QC System',
)
def _page_cb(canvas, doc):
_on_page(canvas, doc, report_title, generated_at)
story = []
if filter_summary:
story.append(Paragraph(f'Filters: {filter_summary}', STYLES['ReportSub']))
story.append(Paragraph(
f'Total codes: {len(items)}', STYLES['ReportSub']))
story.append(Spacer(1, 10))
if not items:
story.append(Paragraph('No QR codes selected.', STYLES['FieldValue']))
doc.build(story, onFirstPage=_page_cb, onLaterPages=_page_cb)
return buf.getvalue()
COLS = 3
pw = letter[0] - 1.3 * inch # usable width
cell_w = pw / COLS
qr_size = 1.7 * inch
title_style = ParagraphStyle('QRT', fontName='Helvetica-Bold', fontSize=9,
alignment=TA_CENTER, leading=11, textColor=C_DARK)
sub_style = ParagraphStyle('QRS', fontName='Helvetica', fontSize=7.5,
alignment=TA_CENTER, leading=9, textColor=C_SLATE)
cap_style = ParagraphStyle('QRC', fontName='Helvetica', fontSize=6.5,
alignment=TA_CENTER, leading=8, textColor=C_SLATE)
def _cell(item):
flow = [Paragraph(item.get('title') or '', title_style)]
if item.get('subtitle'):
flow.append(Paragraph(item['subtitle'], sub_style))
flow.append(Spacer(1, 4))
flow.append(RLImage(io.BytesIO(item['png']), width=qr_size, height=qr_size))
if item.get('caption'):
flow.append(Spacer(1, 3))
flow.append(Paragraph(item['caption'], cap_style))
return flow
rows = []
for i in range(0, len(items), COLS):
chunk = items[i:i + COLS]
row = [_cell(it) for it in chunk]
while len(row) < COLS:
row.append('') # filler cell to keep the grid rectangular
rows.append(row)
tbl = Table(rows, colWidths=[cell_w] * COLS)
tbl.setStyle(TableStyle([
('VALIGN', (0, 0), (-1, -1), 'TOP'),
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
('TOPPADDING', (0, 0), (-1, -1), 10),
('BOTTOMPADDING', (0, 0), (-1, -1), 16),
('LEFTPADDING', (0, 0), (-1, -1), 6),
('RIGHTPADDING', (0, 0), (-1, -1), 6),
]))
story.append(tbl)
doc.build(story, onFirstPage=_page_cb, onLaterPages=_page_cb)
return buf.getvalue()
+336
View File
@@ -0,0 +1,336 @@
"""
app/utils/photo_stamp.py
------------------------
Burn a capture-time + geolocation overlay into uploaded evidence photos.
Applied at UPLOAD time (``app/api/photos.py``) rather than on
``PATCH /issues/<id>/photos``. At upload the raw bytes and any camera EXIF are
in hand, so nothing has to be read back out of R2, and each upload writes
exactly one already-stamped object. That also keeps stamping clear of the
retry/double-burn hazard the PATCH endpoint would have it is deliberately
idempotent and re-runnable (CLAUDE.md rule 45), so burning there could stack a
second bar onto an already-stamped image.
Metadata resolution order
-------------------------
1. Client-supplied ``captured_at`` / ``latitude`` / ``longitude`` most
reliable for an offline-first app: the iPad knows when and where the shot
was taken even if it syncs hours later.
2. The image's own EXIF ``DateTimeOriginal`` / ``GPSInfo``.
3. Server receipt time (last resort; no geo).
FAILURE POLICY
--------------
Stamping must never cost us the photo. Every failure path falls back to
storing the original bytes unmodified an unstamped photo beats a lost one.
"""
import io
import logging
from datetime import datetime
import pytz
from app.utils.time_utils import EASTERN, now_eastern
logger = logging.getLogger(__name__)
# Only these are stamped. GIF (possibly animated) and anything exotic passes
# through untouched rather than risking a broken re-encode.
_STAMPABLE_FORMATS = {'JPEG', 'PNG'}
# Candidate TrueType fonts, in preference order. Pillow does not reliably ship
# a TTF, and the bitmap default is unreadably small on a 4000px photo, so we
# probe the usual Linux locations and degrade gracefully.
_FONT_CANDIDATES = (
'/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',
'/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',
'/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf',
'/usr/share/fonts/truetype/freefont/FreeSansBold.ttf',
'C:/Windows/Fonts/arialbd.ttf',
'C:/Windows/Fonts/arial.ttf',
)
_EXIF_DATETIME_ORIGINAL = 36867 # 0x9003
_EXIF_DATETIME_DIGITIZED = 36868 # 0x9004
_EXIF_DATETIME = 306 # 0x0132
_EXIF_GPS_IFD = 34853 # 0x8825
# ── Font ──────────────────────────────────────────────────────────────────────
def _load_font(size):
"""Return a TrueType font at *size*, or Pillow's bitmap default."""
from PIL import ImageFont
for path in _FONT_CANDIDATES:
try:
return ImageFont.truetype(path, size)
except Exception:
continue
try:
# Pillow >= 9.2 can scale the default font.
return ImageFont.load_default(size=size)
except Exception:
return ImageFont.load_default()
# ── Metadata extraction ───────────────────────────────────────────────────────
def _parse_client_datetime(value):
"""Parse a client ISO-8601 timestamp into naive Eastern, or None.
Accepts offsets and a trailing 'Z'. An offset-aware value is converted to
Eastern; a naive value is taken as already-Eastern wall time.
"""
if not value:
return None
text = str(value).strip()
if not text:
return None
if text.endswith(('Z', 'z')):
text = text[:-1] + '+00:00'
try:
dt = datetime.fromisoformat(text)
except ValueError:
return None
if dt.tzinfo is not None:
dt = dt.astimezone(EASTERN).replace(tzinfo=None)
return dt
def _parse_exif_datetime(raw):
"""Parse an EXIF 'YYYY:MM:DD HH:MM:SS' string into a naive datetime."""
if not raw:
return None
try:
return datetime.strptime(str(raw).strip(), '%Y:%m:%d %H:%M:%S')
except (ValueError, TypeError):
return None
def _exif_datetime(exif):
"""Best available capture time from EXIF, or None."""
if not exif:
return None
for tag in (_EXIF_DATETIME_ORIGINAL, _EXIF_DATETIME_DIGITIZED, _EXIF_DATETIME):
dt = _parse_exif_datetime(exif.get(tag))
if dt:
return dt
return None
def _dms_to_decimal(dms, ref):
"""Convert EXIF degrees/minutes/seconds rationals to signed decimal."""
try:
deg, minutes, seconds = (float(x) for x in dms)
except (TypeError, ValueError):
return None
value = deg + minutes / 60.0 + seconds / 3600.0
if str(ref).upper().strip() in ('S', 'W'):
value = -value
return value
def _exif_gps(exif):
"""Return (lat, lng) decimal degrees from EXIF GPSInfo, or (None, None)."""
if not exif:
return (None, None)
try:
gps = exif.get_ifd(_EXIF_GPS_IFD)
except Exception:
gps = None
if not gps:
return (None, None)
# 1/2 = LatitudeRef/Latitude, 3/4 = LongitudeRef/Longitude
lat = _dms_to_decimal(gps.get(2), gps.get(1)) if gps.get(2) and gps.get(1) else None
lng = _dms_to_decimal(gps.get(4), gps.get(3)) if gps.get(4) and gps.get(3) else None
return (lat, lng)
def _coerce_coord(value):
"""Parse a coordinate to float, rejecting out-of-range/garbage values."""
if value is None or value == '':
return None
try:
num = float(value)
except (TypeError, ValueError):
return None
if num != num or abs(num) > 180: # NaN or impossible
return None
return num
def resolve_metadata(exif, captured_at=None, latitude=None, longitude=None):
"""Resolve (capture_dt, lat, lng, source) from client fields then EXIF.
``source`` is one of 'client', 'exif', or 'server' and describes where the
*timestamp* came from useful for logging and for judging trust later.
"""
dt = _parse_client_datetime(captured_at)
source = 'client' if dt else None
lat = _coerce_coord(latitude)
lng = _coerce_coord(longitude)
if dt is None:
dt = _exif_datetime(exif)
source = 'exif' if dt else None
if lat is None or lng is None:
ex_lat, ex_lng = _exif_gps(exif)
lat = lat if lat is not None else ex_lat
lng = lng if lng is not None else ex_lng
if dt is None:
dt = now_eastern()
source = 'server'
return dt, lat, lng, source
# ── Overlay rendering ─────────────────────────────────────────────────────────
def _tz_abbrev(dt):
"""EDT/EST label for a naive Eastern datetime."""
try:
return EASTERN.localize(dt).strftime('%Z')
except Exception:
return 'ET'
def _overlay_lines(dt, lat, lng):
"""Text lines for the overlay bar."""
lines = [f"{dt.strftime('%Y-%m-%d %H:%M:%S')} {_tz_abbrev(dt)}"]
if lat is not None and lng is not None:
lines.append(f'{lat:.5f}, {lng:.5f}')
return lines
def _draw_overlay(img, lines):
"""Draw a translucent bar with *lines* across the bottom of *img*."""
from PIL import Image, ImageDraw
if img.mode not in ('RGB', 'RGBA'):
img = img.convert('RGB')
width, height = img.size
# Scale everything off the short edge so portrait and landscape match.
base = min(width, height)
font_size = max(11, int(base * 0.020))
pad = max(6, int(base * 0.012))
font = _load_font(font_size)
measure = ImageDraw.Draw(img)
# Measure the block.
heights, widths = [], []
for line in lines:
box = measure.textbbox((0, 0), line, font=font)
widths.append(box[2] - box[0])
heights.append(box[3] - box[1])
line_gap = max(2, int(font_size * 0.25))
text_h = sum(heights) + line_gap * (len(lines) - 1)
bar_h = text_h + pad * 2
# Draw the whole overlay on a transparent layer so both the bar AND the
# text carry alpha, then composite once. Keeps the photo readable through
# the stamp instead of masking it behind a solid strip.
layer = Image.new('RGBA', (width, bar_h), (0, 0, 0, 0))
draw = ImageDraw.Draw(layer)
draw.rectangle((0, 0, width, bar_h), fill=(0, 0, 0, 80))
y = pad
for line, h in zip(lines, heights):
# Faint dark outline still keeps the text legible over a bright photo.
for dx, dy in ((-1, 0), (1, 0), (0, -1), (0, 1)):
draw.text((pad + dx, y + dy), line, font=font, fill=(0, 0, 0, 90))
draw.text((pad, y), line, font=font, fill=(255, 255, 255, 165))
y += h + line_gap
if img.mode == 'RGBA':
img.alpha_composite(layer, (0, height - bar_h))
else:
img.paste(Image.alpha_composite(
img.crop((0, height - bar_h, width, height)).convert('RGBA'), layer
).convert('RGB'), (0, height - bar_h))
return img
# ── Public API ────────────────────────────────────────────────────────────────
def stamp_image_bytes(data, captured_at=None, latitude=None, longitude=None):
"""Burn the capture-time/geo overlay into *data*.
Returns ``(out_bytes, meta)``. On any failure unsupported format, corrupt
image, missing Pillow returns the ORIGINAL bytes with ``meta['stamped']``
False rather than raising, so an upload is never lost to a stamping bug.
"""
meta = {'stamped': False, 'captured_at': None, 'latitude': None,
'longitude': None, 'source': None}
try:
from PIL import Image, ImageOps
img = Image.open(io.BytesIO(data))
fmt = (img.format or '').upper()
try:
exif = img.getexif()
except Exception:
exif = None
dt, lat, lng, source = resolve_metadata(exif, captured_at, latitude, longitude)
meta.update({'captured_at': dt, 'latitude': lat,
'longitude': lng, 'source': source})
if fmt not in _STAMPABLE_FORMATS:
logger.info('PHOTO STAMP | skipped unsupported format=%s', fmt or '?')
return data, meta
# Honour the camera's EXIF orientation BEFORE drawing, otherwise the
# bar lands on a rotated edge and the re-encode (which drops EXIF)
# would leave the photo visibly rotated versus the original.
img = ImageOps.exif_transpose(img)
img = _draw_overlay(img, _overlay_lines(dt, lat, lng))
out = io.BytesIO()
if fmt == 'JPEG':
if img.mode != 'RGB':
img = img.convert('RGB')
img.save(out, format='JPEG', quality=88, optimize=True)
else:
img.save(out, format='PNG', optimize=True)
meta['stamped'] = True
return out.getvalue(), meta
except Exception as exc: # never lose a photo
logger.warning('PHOTO STAMP | failed, storing original: %s', exc)
return data, meta
def stamp_file_storage(file_obj, captured_at=None, latitude=None, longitude=None):
"""Return a FileStorage of the stamped image, plus the resolved metadata.
The result is a drop-in replacement for the incoming upload: it keeps the
original ``filename``/``content_type``, so ``storage.save()`` derives the
same key and works unchanged on both the local and s3 backends.
"""
from werkzeug.datastructures import FileStorage
file_obj.stream.seek(0)
original = file_obj.stream.read()
out_bytes, meta = stamp_image_bytes(
original, captured_at=captured_at, latitude=latitude, longitude=longitude
)
if not meta['stamped']:
file_obj.stream.seek(0) # hand back the untouched upload
return file_obj, meta
return FileStorage(
stream=io.BytesIO(out_bytes),
filename=file_obj.filename,
content_type=file_obj.content_type,
), meta
+30 -15
View File
@@ -8,6 +8,8 @@ Facility-scoping utilities for the Janitorial QC portal.
get_inspector_scope(user) -> list[int] | None
Facility IDs an inspector may access via InspectorAssignment rows.
Applies to BOTH 'inspector' (internal) and 'external_inspector'
(customer / third-party) see User.INSPECTOR_ROLES.
Returns [] (empty list) when the inspector has no contract assignments,
meaning they see nothing (strict mode).
@@ -16,6 +18,7 @@ that no facility-level scoping is required (full access applies).
"""
import logging
from app import db
from app.models.project import CustomerAssignment
from app.models.facility import Facility
@@ -41,30 +44,34 @@ def get_customer_scope(user) -> list[int] | None:
if user.role != 'customer':
return None # no scoping needed for internal staff
assignments = CustomerAssignment.query.filter_by(user_id=user.id).all()
# Select only the two columns needed. The previous .all() built full
# CustomerAssignment ORM objects (and their identity-map entries) purely to
# read two integers off each one; this function runs on nearly every
# request for a customer, sometimes more than once.
assignments = db.session.query(
CustomerAssignment.project_id,
CustomerAssignment.facility_id,
).filter(CustomerAssignment.user_id == user.id).all()
if not assignments:
return []
# Separate direct facility assignments from project-level assignments
direct_facility_ids = {a.facility_id for a in assignments if a.facility_id}
project_ids = {a.project_id for a in assignments if not a.facility_id}
direct_facility_ids = {fac_id for _, fac_id in assignments if fac_id}
project_ids = {proj_id for proj_id, fac_id in assignments if not fac_id}
facility_ids = set(direct_facility_ids)
# Single bulk query for all project-scoped facilities — replaces the
# previous per-assignment Facility.query loop (N+1 pattern).
# previous per-assignment Facility.query loop (N+1 pattern). Only the id
# column is read; nothing here needs a hydrated Facility.
if project_ids:
project_facilities = (
Facility.query
.filter(
facility_ids.update(
fid for (fid,) in db.session.query(Facility.id).filter(
Facility.project_id.in_(project_ids),
Facility.active == True,
).all()
)
.all()
)
for f in project_facilities:
facility_ids.add(f.id)
logger.debug(
'SCOPE | customer_scope | user_id=%s username=%s facility_ids=%s',
@@ -90,21 +97,29 @@ def get_inspector_scope(user) -> list[int] | None:
None
Returned for non-inspector roles, indicating unrestricted access.
"""
if user.role != 'inspector':
# phase49: covers BOTH 'inspector' and 'external_inspector'. An external
# (customer / third-party) inspector is scoped by exactly the same
# InspectorAssignment rows — the contracts an admin grants them.
from app.models.user import User
if user.role not in User.INSPECTOR_ROLES:
return None
from app.models.inspector_assignment import InspectorAssignment
# Column-only selects — see the note in get_customer_scope(). This runs on
# every scoped request for both inspector roles.
project_ids = [
a.project_id
for a in InspectorAssignment.query.filter_by(user_id=user.id).all()
pid for (pid,) in
db.session.query(InspectorAssignment.project_id)
.filter(InspectorAssignment.user_id == user.id).all()
]
if not project_ids:
return [] # strict: no assignments = no access
facility_ids = [
f.id for f in Facility.query.filter(
fid for (fid,) in db.session.query(Facility.id).filter(
Facility.project_id.in_(project_ids),
Facility.active == True,
).all()
+31 -4
View File
@@ -110,11 +110,38 @@ def send_sla_alerts():
logger = logging.getLogger(__name__)
# yield_per streams rows in batches of 100 rather than loading all open
# issues into memory at once. At current scale this is a no-op difference,
# but it prevents a memory spike if the issue count grows large.
# Narrow to actual CANDIDATES in SQL rather than reading every open issue
# and deciding in Python. This runs every 30 minutes forever, so the old
# form's cost grew with the whole open-issue backlog even on a quiet night
# where nothing was due. Three filters, each mirroring a `continue` below:
#
# 1. reported_at IS NOT NULL — the column is nullable, and sla_status()
# raises TypeError on a NULL (datetime + timedelta). One such row
# would abort the entire cron run, so exclude it in SQL.
# 2. sla_notified <> 'breached' — the highest level is already sent; the
# loop skips these unconditionally.
# 3. old enough to be at least at-risk for its OWN severity, i.e.
# reported_at <= now - (window * 0.75). A critical issue qualifies
# after 3h, a low one after 90h.
#
# Anything this excludes would have hit a `continue` anyway, so the set of
# notifications sent is unchanged — only the rows read are.
now = now_eastern()
age_clauses = [
db.and_(
Issue.severity == severity,
Issue.reported_at <= now - timedelta(hours=hours * AT_RISK_THRESHOLD),
)
for severity, hours in SLA_HOURS.items()
]
# yield_per streams the survivors in batches rather than materialising them
# all at once.
open_issues = Issue.query.filter(
Issue.status.in_(['open', 'in_progress', 'pending_verification'])
Issue.status.in_(['open', 'in_progress', 'pending_verification']),
Issue.reported_at.isnot(None),
db.or_(Issue.sla_notified.is_(None), Issue.sla_notified != 'breached'),
db.or_(*age_clauses),
).yield_per(100)
total_sent = 0

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