Compare commits
50
Commits
0808f8eaff
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50df63115e | ||
|
|
b7bc0f0335 | ||
|
|
ba10ec42e0 | ||
|
|
a36cc93594 | ||
|
|
aced3d0602 | ||
|
|
b31f5f03da | ||
|
|
7ec34e339b | ||
|
|
1a9666d731 | ||
|
|
070e9be993 | ||
|
|
5df105a9d5 | ||
|
|
d8b5ce201d | ||
|
|
515aa07326 | ||
|
|
396b774216 | ||
|
|
98c0dcb7cc | ||
|
|
9d7721213b | ||
|
|
fb85f7dc28 | ||
|
|
1de15a925b | ||
|
|
5486145a24 | ||
|
|
3209a0c717 | ||
|
|
59338f678d | ||
|
|
9f32ea8286 | ||
|
|
c686d12e64 | ||
|
|
1741c194f2 | ||
|
|
4db06b6139 | ||
|
|
16d5e8a6fa | ||
|
|
b12c019810 | ||
|
|
abfe10b6b4 | ||
|
|
8cb7267e29 | ||
|
|
005a56517a | ||
|
|
4a713cf186 | ||
|
|
ff1a3089bb | ||
|
|
9586d21ca2 | ||
|
|
264eeabd70 | ||
|
|
548e262b2a | ||
|
|
5bc7592c1a | ||
|
|
633a5b865f | ||
|
|
c988f8cabb | ||
|
|
0830585ff5 | ||
|
|
9f8cbe1675 | ||
|
|
eba535487e | ||
|
|
22b3afdb02 | ||
|
|
f92d8783bf | ||
|
|
07379bd915 | ||
|
|
f32a4f86c1 | ||
|
|
d673d52f29 | ||
|
|
991f242d12 | ||
|
|
38acb6a52e | ||
|
|
6513e6b4f6 | ||
|
|
d424385747 | ||
|
|
f9c93ca3ed |
+17
-1
@@ -8,7 +8,23 @@
|
||||
"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/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>/')"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 A–E + 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 R1–R4 + 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 30–32 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)
|
||||
> **Last reviewed:** July 2026 (Phase 19 complete + mobile API gap-fill Phases A–E + 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 R1–R4 + 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 30–32 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)
|
||||
|
||||
---
|
||||
|
||||
@@ -31,6 +31,8 @@
|
||||
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)
|
||||
|
||||
---
|
||||
|
||||
@@ -163,7 +165,13 @@ 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
|
||||
@@ -193,7 +201,32 @@ 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`, `auditor`
|
||||
**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.
|
||||
|
||||
@@ -292,6 +325,32 @@ Triage of `handler_type` + facility/vendor detail fields on the **update** panel
|
||||
|
||||
**`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
|
||||
|
||||
```
|
||||
@@ -300,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
|
||||
|
||||
```
|
||||
@@ -365,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
|
||||
|
||||
```
|
||||
@@ -422,7 +527,8 @@ scheduled_inspections:
|
||||
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: 1–5, or -1 = last
|
||||
nth_weekday SMALLINT nullable -- Phase 43: 0–6, Mon=0
|
||||
nth_weekday SMALLINT nullable, -- Phase 43: 0–6, Mon=0
|
||||
acknowledged_at DATETIME nullable -- Phase 47: inspector confirmed receipt
|
||||
inspections.scheduled_inspection_id FK→scheduled_inspections SET NULL ← Phase 36
|
||||
```
|
||||
|
||||
@@ -452,12 +558,30 @@ All are nullable and **legacy phase36 rows keep NULLs**, falling back to `_add_i
|
||||
- **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 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`.
|
||||
**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`.
|
||||
|
||||
---
|
||||
|
||||
@@ -477,13 +601,14 @@ Management (`/scheduled-inspections/new|edit|delete`) is `@project_manager_requi
|
||||
| Contracts | ✅ | ✅ | ✅ | read | scoped |
|
||||
| Templates | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||
| Inspections (execute) | ✅ | ✅ | ✅ | ✅ | read |
|
||||
| Inspection follow-up (request) | ✅ | ✅ | ❌ | ❌ | ✅ own facilities |
|
||||
| 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 | ✅ | ✅ | ✅ | ❌ | ❌ |
|
||||
@@ -498,6 +623,10 @@ Management (`/scheduled-inspections/new|edit|delete`) is `@project_manager_requi
|
||||
@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.
|
||||
```
|
||||
|
||||
---
|
||||
@@ -511,16 +640,17 @@ Management (`/scheduled-inspections/new|edit|delete`) is `@project_manager_requi
|
||||
| `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. **verify / bulk-verify / verification-queue are `@issue_manager_required` (admin/director/auditor); delete stays `@supervisor_required` (admin/director).** |
|
||||
| `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 only** → creates linked inspection; 403 for non-assignees incl. managers), `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 |
|
||||
@@ -552,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.
|
||||
@@ -599,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
|
||||
|
||||
@@ -756,6 +890,10 @@ 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.
|
||||
@@ -774,6 +912,45 @@ Customers can only *request*. `clear_followup` remains admin/director, `reinspec
|
||||
|
||||
**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:
|
||||
@@ -883,7 +1060,111 @@ phase1_projects_roles → phase6_features → phase7_mobile_api → phase8_notif
|
||||
→ phase43_sched_recurrence
|
||||
→ phase44_sched_end_date
|
||||
→ phase45_sched_parent_insp
|
||||
→ phase46_followup_req_by ← HEAD
|
||||
→ 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
|
||||
@@ -1200,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
|
||||
|
||||
@@ -1313,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.
|
||||
@@ -1442,6 +1782,19 @@ timeout = 30
|
||||
| 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. |
|
||||
|
||||
---
|
||||
@@ -1579,3 +1932,133 @@ timeout = 30
|
||||
- Web-form uploads (`_save_photo` in `routes/inspections.py`) are **not** stamped — browsers rarely supply reliable capture/GPS metadata. The helper is reusable if that changes.
|
||||
- Only the stamped image is stored; no pristine original is retained. Since the burn happens *before* the first write, nothing stored is ever destroyed.
|
||||
- EXIF is not re-written into the output (the overlay is the record). Add it here if a machine-readable copy is ever needed.
|
||||
|
||||
---
|
||||
|
||||
## 24. Enrollment Form (`/enrollment`)
|
||||
|
||||
A customer-facing onboarding intake reproducing the printed **JQC Enrollment Form**, held **deliberately apart** from the rest of the application. It is the one feature in the tree that owns its whole vertical slice.
|
||||
|
||||
```
|
||||
app/enrollment/
|
||||
├── __init__.py register_enrollment(app) + the separation contract
|
||||
├── schema.py the form AS DATA — single source of truth
|
||||
├── storage.py JSON-file persistence (no model, no migration)
|
||||
├── routes.py public form + admin inbox
|
||||
└── templates/enrollment/
|
||||
├── form.html standalone public page (no base.html)
|
||||
├── submitted.html thank-you + reference number
|
||||
├── admin_list.html extends base.html
|
||||
└── admin_detail.html extends base.html
|
||||
```
|
||||
|
||||
### Separation contract — keep this true
|
||||
|
||||
1. **No `app.models` import, nothing written to the database.** Enrollment happens *before* any contract, facility or user exists, so there is nothing to key a row against. Deleting the package would remove the routes and nothing else.
|
||||
2. No migration, no model, no notification-matrix event, no API/iPad surface.
|
||||
3. Its own `template_folder` — enrollment markup never mixes into `app/templates/`.
|
||||
4. The only shared code it uses is what it should not reinvent: the app factory, Flask-WTF CSRF, the rate limiter, `@admin_required`.
|
||||
|
||||
If it ever needs to *create* the accounts it describes, do that as a **separate explicit admin action** that reads a stored submission. Do not let the public form reach into the models.
|
||||
|
||||
### Form flow (people first, then the matrix)
|
||||
|
||||
The printed form had six fixed seats (Admin/Director + Inspector 1–5) 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 1–6 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.
|
||||
|
||||
|
||||
+170
-57
@@ -1,68 +1,181 @@
|
||||
JQC WEB — phase44: scheduled inspection End Date
|
||||
================================================
|
||||
Repo: lt_janitorial_quality_control
|
||||
Deploy root: /home/jqc/janitorial_qc/
|
||||
WEB ONLY. No iOS changes, no iPad rebuild.
|
||||
════════════════════════════════════════════════════════════════════════════
|
||||
JQC — phase48: Modern web portal design (A/B test with user switch)
|
||||
════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
NEW FILE
|
||||
migrations/versions/phase44_scheduled_end_date.py
|
||||
revision = 'phase44_sched_end_date'
|
||||
down_revision = 'phase43_sched_recurrence' <- verified current HEAD
|
||||
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.
|
||||
|
||||
OVERWRITE
|
||||
app/models/scheduled_inspection.py end_date column; is_within_end_date();
|
||||
is_expired; expire_if_past_end_date();
|
||||
fulfill() deactivates past the boundary
|
||||
app/utils/forms.py end_date DateField + validate() rules
|
||||
app/routes/scheduled_inspections.py _apply_recurrence() sets/clears end_date;
|
||||
_reject_if_past_end_date() guard;
|
||||
per-context next_due_date label;
|
||||
run_reminders() expiry sweep; audit detail
|
||||
app/api/scheduled.py 'end_date' in _scheduled_payload
|
||||
app/templates/scheduled_inspections/form.html End Date field + JS toggle
|
||||
app/templates/scheduled_inspections/list.html "Ends" column + "Ended" badge
|
||||
CLAUDE.md
|
||||
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.
|
||||
|
||||
DEPLOY (migration step first, then code)
|
||||
|
||||
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
|
||||
git pull
|
||||
# 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
|
||||
|
||||
# 1. MIGRATION
|
||||
source venv/bin/activate
|
||||
flask db current # expect phase43_sched_recurrence
|
||||
# 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
|
||||
flask db current # expect phase44_sched_end_date
|
||||
|
||||
# 2. CODE
|
||||
sudo systemctl restart janitorial_qc
|
||||
sudo systemctl status janitorial_qc --no-pager
|
||||
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
|
||||
flask db downgrade phase43_sched_recurrence # drops end_date, nothing else
|
||||
────────
|
||||
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.
|
||||
|
||||
VERIFY
|
||||
1. New Schedule -> frequency "One-time": End Date row is HIDDEN,
|
||||
date field reads "Start Date"
|
||||
2. Switch frequency to Weekly: End Date row appears
|
||||
3. Edit an existing schedule: date field reads "Next Due Date"
|
||||
4. Validation:
|
||||
- end date before the due date -> rejected
|
||||
- end date on a one-time schedule (via curl/devtools) -> rejected
|
||||
- Mon/Wed/Fri, pick a Tuesday, end date that same Tuesday
|
||||
-> rejected, message names the Wednesday
|
||||
5. List: "Ends" column shows the date, "No end" when blank, "—" for one-time
|
||||
6. Existing schedules: unchanged, "No end", still Active, cadence identical
|
||||
7. Boundary: set end date = next due date, complete the inspection
|
||||
-> schedule goes Inactive, badge reads "Ended"
|
||||
8. Cron sweep:
|
||||
curl -X POST "https://jqc.ltservicesinc.com/scheduled-inspections/run?token=$DIGEST_SECRET"
|
||||
-> JSON now includes "expired": N
|
||||
-> a schedule past its end date that was never completed goes Inactive
|
||||
and stops generating overdue alerts
|
||||
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
|
||||
|
||||
NOTES
|
||||
- Migration follows the phase42/phase43 idiom in this repo
|
||||
(op.get_bind() + sa.text() + INFORMATION_SCHEMA). Flag if you want the
|
||||
stricter plain-string-only form instead; 62 existing migrations use this one.
|
||||
- api/scheduled.py now returns "end_date". Additive and safe: the iPad
|
||||
decodes explicit CodingKeys, so current builds ignore the new key.
|
||||
|
||||
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.
|
||||
|
||||
+108
@@ -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.
|
||||
|
||||
+3
-2
@@ -29,7 +29,8 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
bp = Blueprint('api_comments', __name__)
|
||||
|
||||
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager', 'auditor'}
|
||||
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'external_inspector',
|
||||
'project_manager', 'auditor'}
|
||||
|
||||
|
||||
def _comment_payload(comment: IssueComment) -> dict:
|
||||
@@ -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:
|
||||
|
||||
+31
-8
@@ -35,7 +35,8 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
bp = Blueprint('api_inspections', __name__)
|
||||
|
||||
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager', 'auditor'}
|
||||
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'external_inspector',
|
||||
'project_manager', 'auditor'}
|
||||
|
||||
|
||||
def _merge_form_data(existing: dict, incoming: dict) -> dict:
|
||||
@@ -121,7 +122,7 @@ def _resolve_schedule(schedule_id, user):
|
||||
logger.warning('API INSPECTIONS | unknown scheduled_inspection_id=%s from user=%s '
|
||||
'— submitting unlinked', schedule_id, user.username)
|
||||
return None
|
||||
if user.role == 'inspector' and sched.inspector_id != user.id:
|
||||
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
|
||||
@@ -209,6 +210,11 @@ 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
|
||||
@@ -261,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
|
||||
@@ -279,7 +292,7 @@ def list_inspections():
|
||||
if status:
|
||||
query = query.filter(Inspection.status == status)
|
||||
|
||||
if request.args.get('follow_up_required', '').lower() in ('true', '1'):
|
||||
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.
|
||||
@@ -297,6 +310,16 @@ def list_inspections():
|
||||
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:
|
||||
@@ -598,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 {}
|
||||
|
||||
+11
-10
@@ -41,7 +41,8 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
bp = Blueprint('api_issues', __name__)
|
||||
|
||||
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager', 'auditor'}
|
||||
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'external_inspector',
|
||||
'project_manager', 'auditor'}
|
||||
_VALID_SEVERITY = {'low', 'medium', 'high', 'critical'}
|
||||
_VALID_STATUSES = {'open', 'in_progress', 'resolved', 'pending_verification'}
|
||||
_VALID_HANDLERS = {'internal', 'facility', 'vendor'}
|
||||
@@ -148,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})
|
||||
@@ -245,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)
|
||||
@@ -334,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:
|
||||
@@ -367,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:
|
||||
@@ -445,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:
|
||||
@@ -515,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:
|
||||
@@ -576,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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
+23
-2
@@ -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', 'auditor'}
|
||||
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'external_inspector',
|
||||
'project_manager', 'auditor'}
|
||||
|
||||
|
||||
def _allowed_file(filename: str) -> bool:
|
||||
@@ -75,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
|
||||
@@ -114,7 +126,16 @@ def upload_photo():
|
||||
# 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
|
||||
server_path = storage.save(file_obj, subfolder)
|
||||
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 | '
|
||||
|
||||
+13
-6
@@ -32,7 +32,8 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
bp = Blueprint('api_scheduled', __name__)
|
||||
|
||||
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager', 'auditor'}
|
||||
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'external_inspector',
|
||||
'project_manager', 'auditor'}
|
||||
|
||||
|
||||
def _scheduled_payload(s):
|
||||
@@ -65,6 +66,11 @@ def _scheduled_payload(s):
|
||||
# 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,
|
||||
}
|
||||
|
||||
|
||||
@@ -97,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)
|
||||
|
||||
@@ -156,7 +162,8 @@ def create_follow_up():
|
||||
user = g.api_user
|
||||
|
||||
# Auditor is read-only everywhere else; keep it that way here.
|
||||
if user.role not in {'admin', 'director', 'inspector', 'project_manager'}:
|
||||
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 {}
|
||||
@@ -172,7 +179,7 @@ def create_follow_up():
|
||||
# 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.role == 'inspector':
|
||||
if user.is_inspector:
|
||||
if parent.inspector_id != user.id:
|
||||
return api_error('Access denied', 403)
|
||||
fids = get_inspector_scope(user)
|
||||
|
||||
+17
-4
@@ -40,7 +40,8 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
bp = Blueprint('api_stats', __name__)
|
||||
|
||||
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager', 'auditor'}
|
||||
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'external_inspector',
|
||||
'project_manager', 'auditor'}
|
||||
|
||||
|
||||
@bp.route('/stats/dashboard', methods=['GET'])
|
||||
@@ -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
@@ -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', 'auditor'}
|
||||
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'external_inspector',
|
||||
'project_manager', 'auditor'}
|
||||
|
||||
|
||||
def _visible_project_ids(user):
|
||||
"""Contract ids whose forms this user may see, or None for "no limit".
|
||||
|
||||
An inspector (ours or a customer's) is limited to the contracts they are
|
||||
assigned; every other allowed role sees all. Derived from the facilities
|
||||
get_inspector_scope() returns, so the API can never disagree with the web.
|
||||
"""
|
||||
if not user.is_inspector:
|
||||
return None
|
||||
fids = get_inspector_scope(user) or []
|
||||
if not fids:
|
||||
return []
|
||||
return sorted({
|
||||
f.project_id
|
||||
for f in Facility.query.filter(Facility.id.in_(fids)).all()
|
||||
if f.project_id
|
||||
})
|
||||
|
||||
|
||||
def _visible_templates(user, project_id=None):
|
||||
"""Forms this user may use, optionally narrowed to one contract.
|
||||
|
||||
phase52 — a form attached to specific contracts must not reach an
|
||||
inspector working for a different customer. Shared forms (no contract
|
||||
links) stay visible to everyone, which is what keeps existing installs
|
||||
behaving exactly as before.
|
||||
|
||||
With `project_id`: exactly the web picker's list for that contract.
|
||||
Without: the union across every contract the user can reach — the iPad
|
||||
caches templates up-front and picks the facility later, so it needs the
|
||||
whole set it might legitimately use.
|
||||
"""
|
||||
pids = _visible_project_ids(user)
|
||||
|
||||
if project_id is not None:
|
||||
# The caller names a contract. Their OWN scope still applies — without
|
||||
# this, passing another customer's facility_id would list that
|
||||
# customer's form names back to an inspector who has no business
|
||||
# seeing them. Empty list, not an error: the endpoint must not confirm
|
||||
# whether that contract exists either.
|
||||
if pids is not None and project_id not in pids:
|
||||
logger.warning('API TEMPLATES | out-of-scope project filter | '
|
||||
'user=%s | project_id=%s', user.username, project_id)
|
||||
return []
|
||||
return InspectionTemplate.available_query(project_id).all()
|
||||
|
||||
if pids is None:
|
||||
return (InspectionTemplate.query
|
||||
.filter_by(active=True)
|
||||
.order_by(InspectionTemplate.name)
|
||||
.all())
|
||||
|
||||
seen, out = set(), []
|
||||
# Always include the shared forms, even when the user has no contracts —
|
||||
# otherwise an unassigned inspector would see nothing at all rather than
|
||||
# the standard forms.
|
||||
for pid in list(pids) + [None]:
|
||||
for t in InspectionTemplate.available_query(pid).all():
|
||||
if t.id not in seen:
|
||||
seen.add(t.id)
|
||||
out.append(t)
|
||||
out.sort(key=lambda t: (t.name or '').lower())
|
||||
return out
|
||||
|
||||
|
||||
def _template_summary_payload(template: InspectionTemplate) -> dict:
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"""
|
||||
app/enrollment
|
||||
--------------
|
||||
The JQC Enrollment Form — a self-contained onboarding intake, deliberately
|
||||
held apart from the rest of the application.
|
||||
|
||||
/enrollment public form emailed to a prospective customer
|
||||
/enrollment/admin admin-only inbox of submissions
|
||||
|
||||
Separation contract (please keep this true)
|
||||
-------------------------------------------
|
||||
1. NO app.models imports, and nothing here writes to the database. Enrollment
|
||||
happens before any contract, facility or user exists, so there is nothing to
|
||||
key a row against. Submissions are flat JSON files (see storage.py).
|
||||
2. NO migration, NO model, NO notification-matrix event, NO iPad/API surface.
|
||||
Deleting this package would remove the two routes and nothing else.
|
||||
3. Its own template folder (app/enrollment/templates/enrollment/) — enrollment
|
||||
markup never mixes into app/templates.
|
||||
4. The only shared code it uses is what it should not reinvent: the app factory,
|
||||
Flask-WTF CSRF, the rate limiter, and @admin_required.
|
||||
|
||||
If this ever needs to CREATE the accounts it describes, do that as a separate,
|
||||
explicit admin action that reads a stored submission — do not let the public
|
||||
form reach into the app's models.
|
||||
"""
|
||||
|
||||
from .routes import bp # noqa: F401 (re-exported for register_enrollment)
|
||||
|
||||
|
||||
def register_enrollment(app):
|
||||
"""Register the blueprint and make sure the storage directory exists."""
|
||||
import os
|
||||
|
||||
app.config.setdefault(
|
||||
'ENROLLMENT_DIR',
|
||||
os.path.join(app.instance_path, 'enrollments'),
|
||||
)
|
||||
os.makedirs(app.config['ENROLLMENT_DIR'], exist_ok=True)
|
||||
app.register_blueprint(bp)
|
||||
app.logger.info('Enrollment | storage dir: %s', app.config['ENROLLMENT_DIR'])
|
||||
@@ -0,0 +1,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'))
|
||||
@@ -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'},
|
||||
)
|
||||
@@ -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))
|
||||
@@ -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 & 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 %}
|
||||
<<a href="mailto:{{ record.requester_email }}">{{ record.requester_email }}</a>>
|
||||
{% 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,'&').replace(/</g,'<')
|
||||
.replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
// ── 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>
|
||||
@@ -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
|
||||
@@ -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 []
|
||||
@@ -90,6 +202,21 @@ class Inspection(db.Model):
|
||||
)
|
||||
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.
|
||||
@@ -100,6 +227,45 @@ class Inspection(db.Model):
|
||||
# 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')
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -115,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 = {
|
||||
|
||||
@@ -14,6 +14,10 @@ inspector — all users with role='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
|
||||
@@ -40,13 +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'),
|
||||
('auditor', 'Auditor'),
|
||||
('customer', 'Customer'),
|
||||
('customer', 'Customer Director'),
|
||||
('custom', 'Custom Recipients'),
|
||||
]
|
||||
|
||||
@@ -175,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."""
|
||||
|
||||
@@ -130,6 +130,16 @@ class ScheduledInspection(db.Model):
|
||||
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)
|
||||
@@ -269,6 +279,11 @@ class ScheduledInspection(db.Model):
|
||||
"""
|
||||
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.
|
||||
|
||||
+106
-1
@@ -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', 'auditor'),
|
||||
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.
|
||||
@@ -54,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."""
|
||||
|
||||
@@ -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
|
||||
+96
-8
@@ -66,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:
|
||||
@@ -243,10 +243,13 @@ def request_my_data_deletion():
|
||||
@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()
|
||||
)
|
||||
@@ -270,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
|
||||
@@ -282,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'))
|
||||
|
||||
@@ -309,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.
|
||||
@@ -318,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:
|
||||
@@ -339,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
|
||||
|
||||
@@ -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',
|
||||
'inspector': 'Inspectors',
|
||||
'external_inspector': 'Customer Inspectors',
|
||||
'project_manager': 'Project Managers',
|
||||
'director': 'Directors',
|
||||
'admin': 'Admins',
|
||||
|
||||
+401
-69
@@ -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,11 +139,17 @@ def index():
|
||||
scope_map = {} # user_id → sorted list[int] facility IDs
|
||||
for customer in customers:
|
||||
ids = set()
|
||||
for a in assignment_map[customer.id]:
|
||||
if a.facility_id:
|
||||
ids.add(a.facility_id)
|
||||
else:
|
||||
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)
|
||||
else:
|
||||
ids.update(project_facilities_map.get(a.project_id, []))
|
||||
scope_map[customer.id] = sorted(ids)
|
||||
|
||||
# All active projects for the assignment modal
|
||||
@@ -101,11 +168,12 @@ def index():
|
||||
|
||||
return render_template(
|
||||
'customers/index.html',
|
||||
customers = customers,
|
||||
assignment_map = assignment_map,
|
||||
scope_map = scope_map,
|
||||
projects = projects,
|
||||
expired_invitations = expired_invitations,
|
||||
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,36 +424,78 @@ 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()
|
||||
facility_ids = get_customer_scope(customer) or []
|
||||
facilities = (
|
||||
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)
|
||||
.order_by(Facility.name)
|
||||
.all()
|
||||
) if facility_ids else []
|
||||
|
||||
# Assignment form (populated here so it can be rendered inline)
|
||||
aform = CustomerAssignmentForm()
|
||||
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()
|
||||
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,
|
||||
facilities = facilities,
|
||||
aform = aform,
|
||||
projects = projects,
|
||||
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()
|
||||
|
||||
+41
-35
@@ -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."""
|
||||
@@ -37,7 +47,7 @@ def index():
|
||||
# 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'
|
||||
@@ -102,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'),
|
||||
@@ -173,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:
|
||||
@@ -220,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')
|
||||
|
||||
@@ -246,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)
|
||||
|
||||
@@ -294,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(
|
||||
@@ -310,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 / auditor only) ───────
|
||||
inspector_activity = []
|
||||
if is_privileged or is_project_manager or is_auditor:
|
||||
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).
|
||||
@@ -361,6 +364,7 @@ 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
|
||||
@@ -370,6 +374,7 @@ def index():
|
||||
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 = [
|
||||
@@ -383,7 +388,9 @@ def index():
|
||||
'dashboard.html',
|
||||
sched_upcoming = sched_upcoming,
|
||||
sched_overdue_count = sched_overdue_count,
|
||||
sched_total = sched_total,
|
||||
sched_open_inspections = sched_open_inspections,
|
||||
in_progress_total = in_progress_total,
|
||||
submitted_this_week = submitted_this_week,
|
||||
completed_today = completed_today,
|
||||
open_issues = open_issues,
|
||||
@@ -397,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,
|
||||
|
||||
@@ -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
|
||||
@@ -210,7 +210,7 @@ def facility_qr_print_all():
|
||||
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)
|
||||
@@ -279,7 +279,7 @@ def facility_qr_export_pdf():
|
||||
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.role == 'inspector':
|
||||
if current_user.is_inspector:
|
||||
abort(403)
|
||||
|
||||
facility_ids = request.form.getlist('facility_ids', type=int)
|
||||
|
||||
+585
-51
@@ -15,7 +15,7 @@ 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 (
|
||||
@@ -215,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)
|
||||
@@ -253,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():
|
||||
@@ -284,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':
|
||||
@@ -316,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 = []
|
||||
@@ -348,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
|
||||
@@ -360,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
|
||||
@@ -374,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:
|
||||
@@ -406,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)
|
||||
@@ -463,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'])
|
||||
@@ -472,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()
|
||||
@@ -600,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)
|
||||
@@ -608,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_(['director', 'inspector', 'project_manager', 'auditor']),
|
||||
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,
|
||||
@@ -649,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':
|
||||
@@ -688,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':
|
||||
@@ -715,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':
|
||||
@@ -912,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,
|
||||
@@ -922,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):
|
||||
@@ -929,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')
|
||||
@@ -1002,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)
|
||||
|
||||
@@ -1019,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)
|
||||
@@ -1056,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():
|
||||
@@ -1082,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()
|
||||
@@ -1112,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}')
|
||||
@@ -1142,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':
|
||||
@@ -1214,6 +1407,313 @@ 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
|
||||
def flag_followup(inspection_id):
|
||||
@@ -1239,33 +1739,59 @@ def flag_followup(inspection_id):
|
||||
# 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(url_for('inspections.view', inspection_id=inspection_id))
|
||||
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(url_for('inspections.view', inspection_id=inspection_id))
|
||||
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()
|
||||
|
||||
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}.{note_suffix}'
|
||||
f'of "{inspection.template.name}" at {inspection.facility.name}.'
|
||||
f'{assigned_suffix}{note_suffix}'
|
||||
)
|
||||
|
||||
# Notify the original inspector so they see it on the iPad.
|
||||
inspector = db.session.get(User, inspection.inspector_id)
|
||||
# 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,
|
||||
@@ -1306,7 +1832,7 @@ def flag_followup(inspection_id):
|
||||
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'])
|
||||
@@ -1321,12 +1847,13 @@ def clear_followup(inspection_id):
|
||||
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) ──────────────────────────────────
|
||||
@@ -1345,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
|
||||
@@ -1372,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()
|
||||
@@ -1412,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')))
|
||||
+567
-51
@@ -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, issue_manager_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_(['director', 'inspector', 'auditor']), 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:
|
||||
flash('Access denied.', 'danger')
|
||||
return redirect(url_for('issues.index'))
|
||||
# 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,10 +482,10 @@ 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_(['director', 'inspector', 'auditor'])).order_by(User.username).all()
|
||||
staff = User.query.filter(User.role.in_(['director', 'inspector', 'external_inspector', 'auditor'])).order_by(User.username).all()
|
||||
# Preserve any pre-existing assignee who is no longer in the assignable set
|
||||
# (e.g. an admin assigned before admins were removed from the dropdown) so
|
||||
# saving the form doesn't silently unassign them.
|
||||
@@ -427,7 +493,9 @@ def view(issue_id):
|
||||
current_assignee = db.session.get(User, issue.assigned_to)
|
||||
if current_assignee:
|
||||
staff.append(current_assignee)
|
||||
form.assigned_to.choices = [(0, '— Unassigned —')] + [(u.id, u.display_name) for u in staff]
|
||||
form.assigned_to.choices = [(0, '— Unassigned —')] + [
|
||||
(u.id, _assignee_label(u)) for u in staff
|
||||
]
|
||||
form.status.data = form.status.data or issue.status
|
||||
|
||||
if form.validate_on_submit():
|
||||
@@ -585,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 '—'}',
|
||||
@@ -631,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())
|
||||
@@ -644,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 ────────────────────────────────────────────────────────────────────
|
||||
@@ -666,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 ──────────────────────────────────────────────────────────────────
|
||||
@@ -722,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_(['director', 'inspector', 'auditor'])).order_by(User.username).all()
|
||||
staff = User.query.filter(User.role.in_(['director', 'inspector', 'external_inspector', 'auditor'])).order_by(User.username).all()
|
||||
|
||||
form.facility_id.choices = [(f.id, f.name) for f in facilities]
|
||||
form.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.
|
||||
@@ -814,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)
|
||||
@@ -833,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
|
||||
|
||||
@@ -853,7 +1157,21 @@ def verify(issue_id):
|
||||
f'#{issue_id} in {issue.area.name if issue.area else issue.resolved_facility.name if issue.resolved_facility else '—'}',
|
||||
f'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'])
|
||||
@@ -888,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'])
|
||||
@@ -910,11 +1426,11 @@ def request_verification(issue_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()
|
||||
@@ -942,7 +1458,7 @@ 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 ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1035,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) ───────────────────────────────────────────────────────
|
||||
@@ -1104,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:
|
||||
|
||||
+43
-31
@@ -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:
|
||||
|
||||
@@ -3,7 +3,9 @@ app/routes/scheduled_inspections.py
|
||||
------------------------------------
|
||||
Planned / recurring inspection assignments (phase36).
|
||||
|
||||
Management (list/new/edit/delete) : admin, director, project_manager
|
||||
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.
|
||||
@@ -20,6 +22,7 @@ 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,
|
||||
@@ -29,7 +32,8 @@ 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
|
||||
@@ -40,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()
|
||||
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())
|
||||
#
|
||||
# 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())
|
||||
|
||||
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):
|
||||
@@ -69,11 +231,35 @@ def _notify_assignee(sched, reassigned=False):
|
||||
tpl = sched.template.name if sched.template else '—'
|
||||
verb = 'reassigned to you' if reassigned else 'assigned to you'
|
||||
notify(
|
||||
recipient = inspector,
|
||||
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}.'),
|
||||
recipient = inspector,
|
||||
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'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,
|
||||
@@ -132,6 +318,54 @@ def _reject_if_past_end_date(sched, form):
|
||||
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."""
|
||||
@@ -161,23 +395,49 @@ 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))
|
||||
|
||||
|
||||
@@ -185,7 +445,7 @@ def index():
|
||||
|
||||
@bp.route('/new', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@project_manager_required
|
||||
@schedule_manager_required
|
||||
def create():
|
||||
form = ScheduledInspectionForm()
|
||||
_populate_choices(form)
|
||||
@@ -196,7 +456,9 @@ def create():
|
||||
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,
|
||||
@@ -239,11 +501,13 @@ 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'
|
||||
@@ -253,13 +517,19 @@ def edit(schedule_id):
|
||||
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.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
|
||||
@@ -300,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)
|
||||
@@ -374,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'])
|
||||
@@ -415,13 +769,14 @@ def run_reminders():
|
||||
if (not s.advance_notified and inspector and inspector.active
|
||||
and s.next_due_date == today + timedelta(days=1)):
|
||||
notify(
|
||||
recipient = inspector,
|
||||
title = f'Inspection due tomorrow — {fac_name}',
|
||||
body = (f'Reminder: a "{tpl_name}" inspection at {fac_name} '
|
||||
f'is scheduled for tomorrow ({s.next_due_date:%b %d, %Y}).'),
|
||||
link = link,
|
||||
event_type = EVENT_SCHEDULED_INSPECTION,
|
||||
send_email = True,
|
||||
recipient = inspector,
|
||||
title = f'Inspection due tomorrow — {fac_name}',
|
||||
body = (f'Reminder: a "{tpl_name}" inspection at {fac_name} '
|
||||
f'is scheduled for tomorrow ({s.next_due_date:%b %d, %Y}).'),
|
||||
link = link,
|
||||
event_type = EVENT_SCHEDULED_INSPECTION,
|
||||
send_email = True,
|
||||
extra_action = _confirm_action(s),
|
||||
)
|
||||
s.advance_notified = True
|
||||
sent['advance'] += 1
|
||||
@@ -430,13 +785,14 @@ def run_reminders():
|
||||
if (not s.due_notified and inspector and inspector.active
|
||||
and s.next_due_date <= today):
|
||||
notify(
|
||||
recipient = inspector,
|
||||
title = f'Inspection due today — {fac_name}',
|
||||
body = (f'A "{tpl_name}" inspection at {fac_name} is due '
|
||||
f'({s.next_due_date:%b %d, %Y}). Please complete it.'),
|
||||
link = link,
|
||||
event_type = EVENT_SCHEDULED_INSPECTION,
|
||||
send_email = True,
|
||||
recipient = inspector,
|
||||
title = f'Inspection due today — {fac_name}',
|
||||
body = (f'A "{tpl_name}" inspection at {fac_name} is due '
|
||||
f'({s.next_due_date:%b %d, %Y}). Please complete it.'),
|
||||
link = link,
|
||||
event_type = EVENT_SCHEDULED_INSPECTION,
|
||||
send_email = True,
|
||||
extra_action = _confirm_action(s),
|
||||
)
|
||||
s.due_notified = True
|
||||
sent['due'] += 1
|
||||
|
||||
+232
-40
@@ -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,8 +147,23 @@ 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
|
||||
@@ -140,6 +186,77 @@ _PII_PATTERNS = [
|
||||
]
|
||||
|
||||
|
||||
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:
|
||||
@@ -150,30 +267,67 @@ def _redact_pii(text):
|
||||
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) ==="]
|
||||
total = 0
|
||||
for e in entries:
|
||||
block = f"\n\nTopic: {e.title}\n{e.content.strip()}"
|
||||
if total + len(block) > _KB_MAX_CHARS:
|
||||
break
|
||||
parts.append(block)
|
||||
total += len(block)
|
||||
prompt += ''.join(parts)
|
||||
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)
|
||||
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 ────────────────────────────────────────────────────────
|
||||
@@ -181,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.
|
||||
@@ -206,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),
|
||||
@@ -219,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 {}
|
||||
@@ -240,7 +392,7 @@ def chat_message():
|
||||
from groq import Groq
|
||||
client = Groq(api_key=api_key)
|
||||
|
||||
messages = [{'role': 'system', 'content': _system_prompt_with_kb()}]
|
||||
messages = [{'role': 'system', 'content': _system_prompt_for(current_user)}]
|
||||
# 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.
|
||||
@@ -249,7 +401,14 @@ def chat_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,
|
||||
@@ -258,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.")
|
||||
|
||||
@@ -290,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)
|
||||
@@ -302,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:
|
||||
@@ -317,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()
|
||||
@@ -328,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
|
||||
|
||||
@@ -359,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
|
||||
@@ -374,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)
|
||||
@@ -555,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
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
@@ -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 {
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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 %}
|
||||
@@ -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 == 'auditor' %}secondary{% 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
@@ -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', '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 %}
|
||||
{% 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,'&').replace(/</g,'<')
|
||||
.replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 & Send Invitation
|
||||
|
||||
@@ -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;
|
||||
|
||||
+199
-321
@@ -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,6 +46,36 @@
|
||||
</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>
|
||||
@@ -29,292 +85,157 @@
|
||||
</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">
|
||||
<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>
|
||||
</div>
|
||||
{% if sched_overdue_count %}
|
||||
<div class="alert alert-danger py-2 mb-2">
|
||||
<i class="bi bi-alarm-fill"></i>
|
||||
<strong>{{ sched_overdue_count }}</strong> scheduled inspection{{ 's' if sched_overdue_count != 1 }}
|
||||
{{ 'are' if sched_overdue_count != 1 else 'is' }} <strong>overdue</strong>.
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if sched_upcoming %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover mb-0 align-middle">
|
||||
<thead class="table-light">
|
||||
<tr><th>Facility</th><th>Template</th><th>Inspector</th><th>Repeats</th><th>Due</th><th></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') }}</td>
|
||||
<td class="text-end">
|
||||
{# Start is shown only to the assignee — the inspection is theirs to do. #}
|
||||
{% if s.inspector_id and s.inspector_id == current_user.id %}
|
||||
{% 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 %}
|
||||
<p class="text-muted small mb-0">No inspections due in the next 7 days.</p>
|
||||
{% endif %}
|
||||
{# ══════════════════════════════════════════════════════════════════════════
|
||||
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="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>
|
||||
<strong>{{ sched_overdue_count }}</strong> scheduled inspection{{ 's' if sched_overdue_count != 1 }}
|
||||
{{ 'are' if sched_overdue_count != 1 else 'is' }} <strong>overdue</strong>.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if sched_upcoming %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-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 %}
|
||||
<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') }}</td>
|
||||
<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 %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<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', 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 & submitted today</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{# ══════════════════════════════════════════════════════════════════════════
|
||||
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' %}
|
||||
{{ 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 class="col-6 col-md">
|
||||
<a href="{{ url_for('inspections.index', status='completed', date_from=week_start_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">Submitted This Week</span>
|
||||
<i class="bi bi-calendar-check" style="font-size:1.4rem;opacity:.3;"></i>
|
||||
</div>
|
||||
<div class="fs-1 fw-bold lh-1">{{ submitted_this_week }}</div>
|
||||
<div class="mt-2" style="font-size:.72rem;opacity:.7;">Fully completed & submitted this week</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% 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 >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>
|
||||
{# ══════════════════════════════════════════════════════════════════════════
|
||||
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' %}
|
||||
{{ 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 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>
|
||||
{% 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>
|
||||
|
||||
{% 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>
|
||||
{% endif %}
|
||||
</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>
|
||||
</div>
|
||||
<i class="bi bi-alarm text-danger" style="font-size:2.5rem;opacity:.3;"></i>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{% 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>
|
||||
{% 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 %}
|
||||
|
||||
@@ -332,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>
|
||||
@@ -343,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 %}">
|
||||
@@ -373,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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -579,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>
|
||||
|
||||
@@ -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>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</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,6 +126,11 @@
|
||||
<tbody>
|
||||
{% for ins in inspections.items %}
|
||||
<tr>
|
||||
<td>
|
||||
<input type="checkbox" class="form-check-input bulk-check"
|
||||
form="inspectionsBulkForm" name="inspection_ids" value="{{ ins.id }}"
|
||||
aria-label="Select inspection #{{ ins.id }}">
|
||||
</td>
|
||||
<td><small class="text-muted">#{{ ins.id }}</small></td>
|
||||
<td>{{ ins.inspection_date.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
<td><small>{{ ins.facility.project.name if ins.facility and ins.facility.project else '—' }}</small></td>
|
||||
@@ -159,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"
|
||||
@@ -223,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>
|
||||
@@ -235,6 +249,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
{% include 'partials/bulk_select_js.html' %}
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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,7 +353,12 @@
|
||||
<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">
|
||||
@@ -378,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>
|
||||
@@ -386,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 %}
|
||||
@@ -407,9 +418,24 @@
|
||||
{% 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. #}
|
||||
{% if current_user.role != 'customer' %}
|
||||
{# 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">
|
||||
@@ -537,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) ──────────────────────────── #}
|
||||
@@ -883,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;
|
||||
@@ -909,6 +945,7 @@ 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">
|
||||
@@ -930,6 +967,38 @@ document.addEventListener('keydown', e => { if (e.key === 'Escape') closeMedia()
|
||||
</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>
|
||||
|
||||
@@ -10,8 +10,9 @@
|
||||
{% 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>
|
||||
@@ -176,7 +187,7 @@
|
||||
<select class="form-select form-select-sm quick-assign-select" style="min-width:110px;font-size:.78rem;">
|
||||
<option value="">— Unassigned —</option>
|
||||
{% for u in staff %}
|
||||
<option value="{{ u.id }}" {{ 'selected' if issue.assigned_to == u.id }}>{{ u.display_name }}</option>
|
||||
<option value="{{ u.id }}" {{ 'selected' if issue.assigned_to == u.id }}>{{ u.display_name }}{{ ' (Customer)' if u.is_external_inspector }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<span class="quick-assign-spinner spinner-border spinner-border-sm text-secondary d-none" role="status"></span>
|
||||
@@ -202,7 +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>
|
||||
@@ -210,7 +221,7 @@
|
||||
</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','auditor'] or issue.assigned_to == current_user.id %}
|
||||
<i class="bi bi-pencil"></i> Edit
|
||||
@@ -223,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>
|
||||
@@ -259,6 +271,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
{% include 'partials/bulk_select_js.html' %}
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
@@ -19,6 +19,30 @@
|
||||
|
||||
{% block content %}
|
||||
{% 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 ══════════════════════════════════ #}
|
||||
@@ -192,6 +216,7 @@
|
||||
<strong>Awaiting director verification.</strong>
|
||||
{% 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"
|
||||
@@ -207,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">
|
||||
@@ -235,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">
|
||||
@@ -279,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 }}">
|
||||
@@ -286,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">
|
||||
@@ -306,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"
|
||||
@@ -380,6 +506,7 @@
|
||||
<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") }}
|
||||
@@ -515,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">
|
||||
@@ -529,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">
|
||||
@@ -553,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
|
||||
@@ -584,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');
|
||||
|
||||
@@ -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,'&').replace(/</g,'<')
|
||||
.replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
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>
|
||||
@@ -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 & 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,'&').replace(/</g,'<')
|
||||
.replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
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>
|
||||
@@ -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 %}
|
||||
@@ -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 →</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 →</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 →</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 →</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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 & Close
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
{% if can_delete %}
|
||||
<button type="submit" name="action" value="delete"
|
||||
class="btn btn-sm btn-outline-danger text-nowrap ms-auto" data-bulk-action
|
||||
data-bulk-confirm="Permanently delete the selected issues and their photos? This cannot be undone.">
|
||||
<i class="bi bi-trash"></i> Delete
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,77 @@
|
||||
{# ── Shared row-selection behaviour for bulk-action list pages ────────────────
|
||||
Included by the issues and inspections list templates (classic + modern).
|
||||
Generic on purpose — it keys off classes/attributes, not page-specific ids,
|
||||
so both pages share one implementation:
|
||||
|
||||
.bulk-check one per row (name=issue_ids / inspection_ids)
|
||||
.bulk-check-all the header select-all box
|
||||
.bulk-count element whose text becomes the selected count
|
||||
[data-bulk-action] submit buttons, disabled while nothing is selected
|
||||
[data-bulk-confirm] optional confirm text, count substituted for {n}
|
||||
|
||||
Guarding the submit on a zero selection matters: the browser would happily
|
||||
POST an empty id list, and the route would flash "No issues selected" after
|
||||
a full page round trip.
|
||||
#}
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var boxes = Array.prototype.slice.call(document.querySelectorAll('.bulk-check'));
|
||||
var all = document.querySelector('.bulk-check-all');
|
||||
var counts = Array.prototype.slice.call(document.querySelectorAll('.bulk-count'));
|
||||
var btns = Array.prototype.slice.call(document.querySelectorAll('[data-bulk-action]'));
|
||||
if (!boxes.length) return;
|
||||
|
||||
function selected() {
|
||||
return boxes.filter(function (b) { return b.checked; });
|
||||
}
|
||||
|
||||
function sync() {
|
||||
var n = selected().length;
|
||||
counts.forEach(function (el) { el.textContent = n; });
|
||||
btns.forEach(function (b) { b.disabled = (n === 0); });
|
||||
if (all) {
|
||||
all.checked = (n > 0 && n === boxes.length);
|
||||
// Distinguishes "some" from "none"/"all" in the header box.
|
||||
all.indeterminate = (n > 0 && n < boxes.length);
|
||||
}
|
||||
}
|
||||
|
||||
boxes.forEach(function (b) { b.addEventListener('change', sync); });
|
||||
|
||||
if (all) {
|
||||
all.addEventListener('change', function () {
|
||||
boxes.forEach(function (b) { b.checked = all.checked; });
|
||||
sync();
|
||||
});
|
||||
}
|
||||
|
||||
// Shift-click selects the range from the last clicked box — the usual
|
||||
// convention, and the difference between ticking 3 boxes and 40.
|
||||
var lastIndex = null;
|
||||
boxes.forEach(function (b, i) {
|
||||
b.addEventListener('click', function (e) {
|
||||
if (e.shiftKey && lastIndex !== null) {
|
||||
var lo = Math.min(lastIndex, i), hi = Math.max(lastIndex, i);
|
||||
for (var j = lo; j <= hi; j++) { boxes[j].checked = b.checked; }
|
||||
sync();
|
||||
}
|
||||
lastIndex = i;
|
||||
});
|
||||
});
|
||||
|
||||
btns.forEach(function (btn) {
|
||||
btn.addEventListener('click', function (e) {
|
||||
var n = selected().length;
|
||||
if (n === 0) { e.preventDefault(); return; }
|
||||
var msg = btn.getAttribute('data-bulk-confirm');
|
||||
if (msg && !window.confirm(msg.replace('{n}', n) + '\n\n' + n + ' selected.')) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
sync();
|
||||
}());
|
||||
</script>
|
||||
@@ -115,6 +115,9 @@
|
||||
data-inspector-id="{{ s.id }}">
|
||||
<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>
|
||||
@@ -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','auditor'] %}
|
||||
{% 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">
|
||||
@@ -33,6 +51,8 @@
|
||||
<th>Next Due</th>
|
||||
<th>Ends</th>
|
||||
<th>Status</th>
|
||||
<th>Confirmation</th>
|
||||
<th>Created By</th>
|
||||
<th class="text-end"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -72,6 +92,40 @@
|
||||
{% 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">
|
||||
{# Start is shown only to the assignee — the inspection is theirs to do. #}
|
||||
@@ -89,7 +143,7 @@
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if current_user.role in ['admin','director','project_manager','auditor'] %}
|
||||
{% 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"
|
||||
@@ -107,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','auditor'] %}
|
||||
{% 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 %}
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 & 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 →</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 & 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 %}
|
||||
@@ -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 →</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 →</div>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -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 %}
|
||||
@@ -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):
|
||||
|
||||
+22
-5
@@ -56,7 +56,11 @@ class UserForm(FlaskForm):
|
||||
('inspector', 'Inspector'),
|
||||
('project_manager', 'Project Manager'),
|
||||
('auditor', 'Auditor'),
|
||||
# 'customer' is intentionally excluded — customer accounts are managed via /customers
|
||||
# Both customer-side roles are intentionally excluded — 'customer'
|
||||
# (Customer Director) and 'external_inspector' (Customer Inspector) are
|
||||
# created, edited and switched exclusively in Customer Management
|
||||
# (/customers). phase51 removed 'external_inspector' from here; see
|
||||
# User.CUSTOMER_ROLES.
|
||||
], validators=[Optional()])
|
||||
# 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
|
||||
@@ -126,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):
|
||||
@@ -268,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():
|
||||
|
||||
+101
-8
@@ -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,15 +598,25 @@ 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',
|
||||
'inspector': 'inspector',
|
||||
'external_inspector': 'external_inspector',
|
||||
'project_manager': 'project_manager',
|
||||
'auditor': 'auditor',
|
||||
'customer': 'customer',
|
||||
@@ -558,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)
|
||||
@@ -573,15 +652,26 @@ def notify_by_matrix(
|
||||
# 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 == 'inspector' and event_type == 'inspection_completed':
|
||||
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=inspector scoped to '
|
||||
'submitting inspector_id=%s', event_type, target_id)
|
||||
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:
|
||||
@@ -594,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
|
||||
|
||||
|
||||
+30
-15
@@ -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
@@ -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
|
||||
|
||||
@@ -32,6 +32,34 @@ class Config:
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
SQLALCHEMY_ECHO = False
|
||||
|
||||
# ── Connection pool ─────────────────────────────────────────────────────
|
||||
# Without these the pool runs on library defaults, which is where the
|
||||
# intermittent OperationalError 2006 ("MySQL server has gone away") comes
|
||||
# from: MySQL closes a connection after wait_timeout (8h by default) and
|
||||
# SQLAlchemy hands the dead socket to the next request.
|
||||
#
|
||||
# pool_pre_ping — cheap liveness check before a connection is handed out;
|
||||
# a dead one is discarded and replaced transparently.
|
||||
# pool_recycle — retire connections after 30 min, well under any sane
|
||||
# wait_timeout, so they are never the stale ones.
|
||||
# pool_size / — Gunicorn runs sync workers (cpu*2+1), and each worker
|
||||
# max_overflow holds its OWN pool. Library defaults (5 + 10) mean a
|
||||
# 9-worker box can open 135 connections against a
|
||||
# max_connections of 151. A sync worker serves one
|
||||
# request at a time, so it needs one connection in
|
||||
# steady state; the small overflow is headroom for
|
||||
# background email/notification threads.
|
||||
#
|
||||
# Tune with scripts/db_health.py, which cross-checks these against the
|
||||
# server's live max_connections and wait_timeout.
|
||||
SQLALCHEMY_ENGINE_OPTIONS = {
|
||||
'pool_pre_ping': True,
|
||||
'pool_recycle': int(os.environ.get('DB_POOL_RECYCLE', '1800')),
|
||||
'pool_size': int(os.environ.get('DB_POOL_SIZE', '5')),
|
||||
'max_overflow': int(os.environ.get('DB_MAX_OVERFLOW', '5')),
|
||||
'pool_timeout': 30,
|
||||
}
|
||||
|
||||
# ── File uploads ────────────────────────────────────────────────────────
|
||||
UPLOAD_FOLDER = os.path.join(basedir, 'app/static/uploads')
|
||||
MAX_CONTENT_LENGTH = 50 * 1024 * 1024 # 50 MB
|
||||
@@ -58,6 +86,29 @@ class Config:
|
||||
# bar into the image before storing it. Set false to store raw uploads.
|
||||
PHOTO_STAMP_ENABLED = os.environ.get('PHOTO_STAMP_ENABLED', 'true').lower() == 'true'
|
||||
|
||||
# ── Web portal design (phase50 — Aug 2026) ───────────────────────────────
|
||||
# The design a user sees when they have never chosen one. phase48 shipped
|
||||
# the modern design as an opt-in A/B test with 'classic' as the default;
|
||||
# phase50 promotes 'modern' to the default now that the test is settled.
|
||||
#
|
||||
# This is the FALLBACK only — a stored users.ui_theme always wins, so anyone
|
||||
# who switches keeps their choice. Set DEFAULT_UI_THEME=classic to revert
|
||||
# the default without touching a single saved preference.
|
||||
DEFAULT_UI_THEME = os.environ.get('DEFAULT_UI_THEME', 'modern')
|
||||
|
||||
# ── Issue comment visibility (TEMPORARY — Aug 2026) ──────────────────────
|
||||
# True = every comment on an issue is visible to everyone, customers
|
||||
# included; the per-comment is_customer_visible flag is ignored
|
||||
# when READING.
|
||||
# False = phase22 behaviour — customers see only comments explicitly shared
|
||||
# with them.
|
||||
#
|
||||
# The flag is still WRITTEN on every comment, so flipping this back to
|
||||
# 'false' restores the old behaviour exactly, with no data to repair.
|
||||
# Set COMMENTS_VISIBLE_TO_ALL=false in the environment to revert.
|
||||
COMMENTS_VISIBLE_TO_ALL = os.environ.get(
|
||||
'COMMENTS_VISIBLE_TO_ALL', 'true').lower() == 'true'
|
||||
|
||||
# ── Session / cookies ───────────────────────────────────────────────────
|
||||
PERMANENT_SESSION_LIFETIME = timedelta(hours=24)
|
||||
# Secure by default — subclasses must explicitly opt out for local dev.
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"""0003 — placeholder for the missing early revision `0003_add_user_active`
|
||||
|
||||
WHY THIS FILE EXISTS
|
||||
────────────────────
|
||||
`phase1_projects_roles.py` declares `down_revision = '0003_add_user_active'`,
|
||||
but no migration script with that revision id exists in migrations/versions/.
|
||||
The early 0001–0003 scripts were lost from the repository at some point; the
|
||||
schema changes they made are long since applied in every environment (the
|
||||
`users.active` column exists), so nothing is missing from the database — only
|
||||
the node in Alembic's revision graph.
|
||||
|
||||
Alembic tolerates that as a warning while it walks the graph, but as soon as it
|
||||
has to build the full revision map (which any `flask db upgrade <target>` does),
|
||||
it fails hard:
|
||||
|
||||
KeyError: '0003_add_user_active'
|
||||
|
||||
This file restores the node so the chain resolves. It is intentionally a NO-OP:
|
||||
it makes no schema change in either direction, and it declares itself the base
|
||||
of the chain (`down_revision = None`) because the revisions below it no longer
|
||||
exist as files.
|
||||
|
||||
It never runs in practice — every environment's alembic_version is already far
|
||||
past it — but if it ever did, upgrade() and downgrade() do nothing, so it is
|
||||
safe either way.
|
||||
|
||||
DO NOT DELETE. Removing it re-breaks `flask db upgrade`.
|
||||
"""
|
||||
|
||||
revision = '0003_add_user_active'
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# No-op: the schema change this revision originally made (users.active)
|
||||
# is already present in every environment.
|
||||
pass
|
||||
|
||||
|
||||
def downgrade():
|
||||
# No-op: nothing to reverse.
|
||||
pass
|
||||
@@ -0,0 +1,49 @@
|
||||
"""phase47 — scheduled inspection receipt acknowledgement
|
||||
|
||||
Adds to `scheduled_inspections`:
|
||||
|
||||
acknowledged_at DATETIME NULL
|
||||
|
||||
When the assigned inspector confirms they have received/seen a scheduled
|
||||
inspection request. NULL = awaiting confirmation. Reset to NULL by the edit
|
||||
route when the schedule is reassigned to a different inspector (once per
|
||||
assignment, not per occurrence). The acknowledger is always the assigned
|
||||
inspector, so no separate acknowledged_by column is stored.
|
||||
|
||||
No backfill: legacy rows keep NULL and render as "awaiting confirmation", which
|
||||
is the correct initial state for an assignment nobody has confirmed yet.
|
||||
|
||||
Uses INFORMATION_SCHEMA check — safe to re-run.
|
||||
"""
|
||||
|
||||
revision = 'phase47_sched_acknowledged'
|
||||
down_revision = 'phase46_followup_req_by'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
def _column_exists(conn, table, column):
|
||||
return conn.execute(sa.text(
|
||||
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS "
|
||||
"WHERE TABLE_SCHEMA = DATABASE() "
|
||||
"AND TABLE_NAME = :t AND COLUMN_NAME = :c"
|
||||
), {"t": table, "c": column}).scalar() > 0
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
if not _column_exists(bind, 'scheduled_inspections', 'acknowledged_at'):
|
||||
op.execute(sa.text(
|
||||
"ALTER TABLE scheduled_inspections ADD COLUMN acknowledged_at DATETIME NULL"
|
||||
))
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
if _column_exists(bind, 'scheduled_inspections', 'acknowledged_at'):
|
||||
op.execute(sa.text(
|
||||
"ALTER TABLE scheduled_inspections DROP COLUMN acknowledged_at"
|
||||
))
|
||||
@@ -0,0 +1,51 @@
|
||||
"""phase48 — per-user web portal design preference
|
||||
|
||||
Adds to `users`:
|
||||
|
||||
ui_theme VARCHAR(16) NOT NULL DEFAULT 'classic'
|
||||
|
||||
Drives the design A/B test: 'classic' renders the original top-navbar shell
|
||||
(layouts/classic.html), 'modern' renders the new sidebar shell
|
||||
(layouts/modern.html). Every existing account starts on 'classic', so the
|
||||
portal looks and behaves exactly as before until a user opts in.
|
||||
|
||||
The value doubles as the user's vote — /ui/theme-votes tallies it.
|
||||
|
||||
Uses an INFORMATION_SCHEMA existence check — safe to re-run.
|
||||
"""
|
||||
|
||||
revision = 'phase48_user_ui_theme'
|
||||
down_revision = 'phase47_sched_acknowledged'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
def _column_exists(conn, table, column):
|
||||
return conn.execute(sa.text(
|
||||
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS "
|
||||
"WHERE TABLE_SCHEMA = DATABASE() "
|
||||
"AND TABLE_NAME = :t AND COLUMN_NAME = :c"
|
||||
), {"t": table, "c": column}).scalar() > 0
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
if not _column_exists(bind, 'users', 'ui_theme'):
|
||||
op.execute(sa.text(
|
||||
"ALTER TABLE users "
|
||||
"ADD COLUMN ui_theme VARCHAR(16) NOT NULL DEFAULT 'classic'"
|
||||
))
|
||||
# Idempotent backfill for any row that predates the DEFAULT.
|
||||
op.execute(sa.text(
|
||||
"UPDATE users SET ui_theme = 'classic' "
|
||||
"WHERE ui_theme IS NULL OR ui_theme NOT IN ('classic', 'modern')"
|
||||
))
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
if _column_exists(bind, 'users', 'ui_theme'):
|
||||
op.execute(sa.text("ALTER TABLE users DROP COLUMN ui_theme"))
|
||||
@@ -0,0 +1,56 @@
|
||||
"""phase49 — add 'external_inspector' role to users.role ENUM
|
||||
|
||||
Introduces the External Inspector role: 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, resolved by get_inspector_scope().
|
||||
|
||||
Every capability/scoping check that used to test `role == 'inspector'` now
|
||||
tests membership of User.INSPECTOR_ROLES, so the new role picks up inspector
|
||||
behaviour everywhere without a per-route allowlist.
|
||||
|
||||
This is a pure ENUM expansion (adds a value, removes and migrates nothing), so
|
||||
the 3-step ENUM protocol does not apply and re-running the same MODIFY is a
|
||||
no-op — safe to re-run.
|
||||
|
||||
Notification matrix rows for the new 'external_inspector' column are NOT seeded
|
||||
here: MATRIX_DEFAULTS mirrors the Inspector column at runtime and is_enabled()
|
||||
falls back to that default when a row is absent, so an unseeded install behaves
|
||||
exactly like the Inspector column until an admin saves the matrix page.
|
||||
"""
|
||||
|
||||
revision = 'phase49_external_inspector'
|
||||
down_revision = 'phase48_user_ui_theme'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
_ENUM_WITH_EXTERNAL = (
|
||||
"ENUM('admin','director','inspector','project_manager','customer',"
|
||||
"'auditor','external_inspector')"
|
||||
)
|
||||
_ENUM_WITHOUT_EXTERNAL = (
|
||||
"ENUM('admin','director','inspector','project_manager','customer','auditor')"
|
||||
)
|
||||
|
||||
|
||||
def upgrade():
|
||||
# Idempotent: MODIFY to the expanded set is harmless if already applied.
|
||||
op.execute(sa.text(
|
||||
f"ALTER TABLE users MODIFY COLUMN role {_ENUM_WITH_EXTERNAL} NOT NULL"
|
||||
))
|
||||
|
||||
|
||||
def downgrade():
|
||||
# Reassign any external_inspector rows before contracting the ENUM so no
|
||||
# account is orphaned. They become internal inspectors, which keeps their
|
||||
# InspectorAssignment scoping intact — the same contracts still apply.
|
||||
op.execute(sa.text(
|
||||
"UPDATE users SET role = 'inspector' WHERE role = 'external_inspector'"
|
||||
))
|
||||
op.execute(sa.text(
|
||||
f"ALTER TABLE users MODIFY COLUMN role {_ENUM_WITHOUT_EXTERNAL} NOT NULL"
|
||||
))
|
||||
@@ -0,0 +1,57 @@
|
||||
"""phase50 — make the modern design the default
|
||||
|
||||
phase48 shipped the modern design as an opt-in A/B test: `users.ui_theme`
|
||||
defaulted to 'classic' and users switched themselves over. The test is settled,
|
||||
so this promotes 'modern' to the default.
|
||||
|
||||
Two separate things have to change, and BOTH are needed:
|
||||
|
||||
1. The column default, so NEW accounts start on modern.
|
||||
2. The existing rows. Every account created under phase48 has a literal
|
||||
'classic' stored — not a NULL — so a default change alone would leave every
|
||||
current user on the old design and the switch would look like it did nothing.
|
||||
|
||||
**This overwrites a deliberate choice.** phase48 gave no way to distinguish "I
|
||||
picked classic" from "I never touched it", so anyone who actively preferred
|
||||
classic is moved too. They can switch straight back from the account menu, and
|
||||
that choice will then stick — nothing here removes the switcher.
|
||||
|
||||
`/ui/theme-votes` reads this same column, so the A/B tally reads 100% modern
|
||||
once this runs. Capture it before deploying if the numbers still matter.
|
||||
|
||||
To change the default WITHOUT touching saved preferences, set
|
||||
DEFAULT_UI_THEME=classic in the environment instead of running this.
|
||||
"""
|
||||
|
||||
revision = 'phase50_default_modern'
|
||||
down_revision = 'phase49_external_inspector'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
def upgrade():
|
||||
# 1. New accounts start on modern.
|
||||
op.execute(sa.text(
|
||||
"ALTER TABLE users MODIFY COLUMN ui_theme VARCHAR(16) "
|
||||
"NOT NULL DEFAULT 'modern'"
|
||||
))
|
||||
# 2. Move everyone still on the phase48 default. Idempotent: re-running
|
||||
# matches nothing once every row is 'modern'.
|
||||
op.execute(sa.text(
|
||||
"UPDATE users SET ui_theme = 'modern' WHERE ui_theme = 'classic'"
|
||||
))
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.execute(sa.text(
|
||||
"ALTER TABLE users MODIFY COLUMN ui_theme VARCHAR(16) "
|
||||
"NOT NULL DEFAULT 'classic'"
|
||||
))
|
||||
# Individual pre-upgrade choices were not recorded, so this returns
|
||||
# EVERYONE to classic rather than restoring who had what.
|
||||
op.execute(sa.text(
|
||||
"UPDATE users SET ui_theme = 'classic' WHERE ui_theme = 'modern'"
|
||||
))
|
||||
@@ -0,0 +1,63 @@
|
||||
"""phase51 — per-account notification overrides
|
||||
|
||||
Creates `user_notification_matrix`, the per-account layer over the global
|
||||
NotificationMatrix. One row = one account's explicit answer for one event
|
||||
(enabled True/False); NO row means "inherit the global column".
|
||||
|
||||
See app/models/user_notification_matrix.py for the semantics.
|
||||
|
||||
**No backfill, deliberately.** An empty table means every account inherits,
|
||||
which is exactly today's behaviour — so this migration cannot change who gets
|
||||
notified. Overrides are created only when an admin sets one on the account's
|
||||
page in Customer Management. Backfilling from the current global matrix would
|
||||
freeze every account at today's routing and quietly break future changes to the
|
||||
global columns.
|
||||
|
||||
The rest of phase51 (Customer Director / Customer Inspector) is a LABEL-only
|
||||
rename over the existing 'customer' and 'external_inspector' ENUM values, so
|
||||
there is no ENUM change and no user row is touched here.
|
||||
|
||||
Table-existence check — safe to re-run.
|
||||
"""
|
||||
|
||||
revision = 'phase51_user_notif_matrix'
|
||||
down_revision = 'phase50_default_modern'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
def _has_table(conn, name):
|
||||
return conn.execute(sa.text(
|
||||
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES "
|
||||
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :t"
|
||||
), {'t': name}).scalar() > 0
|
||||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
if _has_table(conn, 'user_notification_matrix'):
|
||||
return
|
||||
|
||||
op.create_table(
|
||||
'user_notification_matrix',
|
||||
sa.Column('id', sa.Integer, primary_key=True),
|
||||
sa.Column('user_id', sa.Integer,
|
||||
sa.ForeignKey('users.id', ondelete='CASCADE'),
|
||||
nullable=False, index=True),
|
||||
sa.Column('event_type', sa.String(50), nullable=False),
|
||||
sa.Column('enabled', sa.Boolean, nullable=False,
|
||||
server_default=sa.text('1')),
|
||||
sa.UniqueConstraint('user_id', 'event_type',
|
||||
name='uq_user_notif_matrix_user_event'),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
conn = op.get_bind()
|
||||
if _has_table(conn, 'user_notification_matrix'):
|
||||
# Every row here is an explicit admin decision; dropping the table
|
||||
# discards them and returns all accounts to global-matrix routing.
|
||||
op.drop_table('user_notification_matrix')
|
||||
@@ -0,0 +1,61 @@
|
||||
"""phase52 — restrict forms to specific contracts
|
||||
|
||||
Creates `template_contracts`: one row = "this inspection template is available
|
||||
on this contract". Backs per-customer forms — a customer's bespoke form must
|
||||
not be visible to, or startable against, another customer's facilities.
|
||||
|
||||
**No rows for a template means SHARED (available on every contract)**, not
|
||||
"available nowhere". That convention is why this migration needs no backfill
|
||||
and cannot change behaviour on deploy: every template that exists today has no
|
||||
rows and therefore stays available everywhere, exactly as before. A form only
|
||||
becomes customer-specific once an admin attaches it to at least one contract.
|
||||
|
||||
Inverting that default later would silently hide every shared form from every
|
||||
contract, so it is enforced in one place — InspectionTemplate.available_query()
|
||||
— which the pickers, their POST validation, and the mobile API all use.
|
||||
|
||||
Table-existence check — safe to re-run.
|
||||
"""
|
||||
|
||||
revision = 'phase52_template_contracts'
|
||||
down_revision = 'phase51_user_notif_matrix'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
def _has_table(conn, name):
|
||||
return conn.execute(sa.text(
|
||||
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES "
|
||||
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :t"
|
||||
), {'t': name}).scalar() > 0
|
||||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
if _has_table(conn, 'template_contracts'):
|
||||
return
|
||||
|
||||
op.create_table(
|
||||
'template_contracts',
|
||||
sa.Column('id', sa.Integer, primary_key=True),
|
||||
sa.Column('template_id', sa.Integer,
|
||||
sa.ForeignKey('inspection_templates.id', ondelete='CASCADE'),
|
||||
nullable=False, index=True),
|
||||
sa.Column('project_id', sa.Integer,
|
||||
sa.ForeignKey('projects.id', ondelete='CASCADE'),
|
||||
nullable=False, index=True),
|
||||
sa.Column('created_at', sa.DateTime, nullable=False,
|
||||
server_default=sa.func.now()),
|
||||
sa.UniqueConstraint('template_id', 'project_id', name='uq_template_contract'),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
conn = op.get_bind()
|
||||
if _has_table(conn, 'template_contracts'):
|
||||
# Dropping the table returns every form to shared — no form becomes
|
||||
# unusable, they just stop being restricted.
|
||||
op.drop_table('template_contracts')
|
||||
@@ -0,0 +1,68 @@
|
||||
"""phase53 — assign a follow-up re-inspection to a specific inspector
|
||||
|
||||
Adds `inspections.follow_up_assigned_to` (FK -> users.id, ON DELETE SET NULL).
|
||||
|
||||
Until now a follow-up implicitly belonged to whoever performed the original
|
||||
inspection: they were the one notified, and the mobile API only ever showed
|
||||
follow-ups where `inspector_id == the caller`. A director could not hand the
|
||||
re-inspection to somebody else.
|
||||
|
||||
NULL means exactly what it meant before — the follow-up belongs to the
|
||||
inspection's own inspector — so every existing row keeps its current behaviour
|
||||
and no backfill is needed. `Inspection.follow_up_owner` is the one place that
|
||||
resolves assignee-or-inspector.
|
||||
|
||||
**This is the THIRD FK from inspections to users** (inspector_id,
|
||||
follow_up_requested_by, and now this). Rule 86: any relationship between the two
|
||||
tables must pin `foreign_keys` explicitly or the mapper is ambiguous — and it
|
||||
raises on first ORM *use*, not at import, so the app starts fine and then every
|
||||
request 500s. `Inspection.follow_up_assignee` pins it; `User.inspections` was
|
||||
already pinned in phase46.
|
||||
|
||||
INFORMATION_SCHEMA checks — safe to re-run.
|
||||
"""
|
||||
|
||||
revision = 'phase53_followup_assignee'
|
||||
down_revision = 'phase52_template_contracts'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
def _has_column(conn, table, column):
|
||||
return conn.execute(sa.text(
|
||||
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS "
|
||||
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :t AND COLUMN_NAME = :c"
|
||||
), {'t': table, 'c': column}).scalar() > 0
|
||||
|
||||
|
||||
def _has_constraint(conn, table, name):
|
||||
return conn.execute(sa.text(
|
||||
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS "
|
||||
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :t AND CONSTRAINT_NAME = :n"
|
||||
), {'t': table, 'n': name}).scalar() > 0
|
||||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
if not _has_column(conn, 'inspections', 'follow_up_assigned_to'):
|
||||
op.add_column('inspections',
|
||||
sa.Column('follow_up_assigned_to', sa.Integer, nullable=True))
|
||||
if not _has_constraint(conn, 'inspections', 'fk_inspections_followup_assignee'):
|
||||
op.create_foreign_key(
|
||||
'fk_inspections_followup_assignee', 'inspections', 'users',
|
||||
['follow_up_assigned_to'], ['id'], ondelete='SET NULL',
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
conn = op.get_bind()
|
||||
if _has_constraint(conn, 'inspections', 'fk_inspections_followup_assignee'):
|
||||
op.drop_constraint('fk_inspections_followup_assignee', 'inspections',
|
||||
type_='foreignkey')
|
||||
if _has_column(conn, 'inspections', 'follow_up_assigned_to'):
|
||||
# Assignments are discarded; every follow-up reverts to belonging to the
|
||||
# inspection's own inspector, which is where it started.
|
||||
op.drop_column('inspections', 'follow_up_assigned_to')
|
||||
@@ -0,0 +1,72 @@
|
||||
"""phase54 — link related and duplicate issues
|
||||
|
||||
Creates `issue_links`: one row per connection between two issues, displayed on
|
||||
both of them. See the IssueLink docstring in app/models/issue.py for why the
|
||||
direction is stored once rather than mirrored.
|
||||
|
||||
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 FKs use ON DELETE CASCADE so deleting an issue takes its links with it from
|
||||
either end — a surviving link pointing at a deleted issue would render a dead
|
||||
row on the other issue's page. The ORM cascade on Issue.links_from/links_to
|
||||
covers the application path; this covers a direct SQL delete.
|
||||
|
||||
Table-existence check — safe to re-run.
|
||||
|
||||
Revision ID: phase54_issue_links
|
||||
Revises: phase53_followup_assignee
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# Revision ids must be <= 32 chars — alembic_version.version_num is VARCHAR(32).
|
||||
revision = 'phase54_issue_links'
|
||||
down_revision = 'phase53_followup_assignee'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(conn, name):
|
||||
return conn.execute(sa.text("""
|
||||
SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :t
|
||||
"""), {'t': name}).scalar() > 0
|
||||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
if _table_exists(conn, 'issue_links'):
|
||||
return
|
||||
|
||||
op.execute(sa.text("""
|
||||
CREATE TABLE issue_links (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
issue_id INT NOT NULL,
|
||||
linked_issue_id INT NOT NULL,
|
||||
link_type ENUM('duplicate','related') NOT NULL DEFAULT 'related',
|
||||
created_by INT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
|
||||
CONSTRAINT uq_issue_link UNIQUE (issue_id, linked_issue_id),
|
||||
|
||||
-- Names match what SQLAlchemy's index=True generates, so the
|
||||
-- schema this migration builds and the one db.create_all() builds
|
||||
-- are identical down to the index names.
|
||||
INDEX ix_issue_links_issue_id (issue_id),
|
||||
INDEX ix_issue_links_linked_issue_id (linked_issue_id),
|
||||
|
||||
CONSTRAINT fk_issue_links_issue
|
||||
FOREIGN KEY (issue_id) REFERENCES issues (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_issue_links_linked
|
||||
FOREIGN KEY (linked_issue_id) REFERENCES issues (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_issue_links_creator
|
||||
FOREIGN KEY (created_by) REFERENCES users (id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
"""))
|
||||
|
||||
|
||||
def downgrade():
|
||||
conn = op.get_bind()
|
||||
if _table_exists(conn, 'issue_links'):
|
||||
op.execute(sa.text('DROP TABLE issue_links'))
|
||||
@@ -0,0 +1,963 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
scripts/db_health.py -- MySQL health check + index optimizer
|
||||
==============================================================
|
||||
|
||||
A standalone operations tool. It does NOT import the request layer, does not
|
||||
touch the uploads tree, and by DEFAULT writes nothing at all -- the plain
|
||||
invocation is strictly read-only (SELECTs against INFORMATION_SCHEMA / SHOW /
|
||||
EXPLAIN).
|
||||
|
||||
What it checks
|
||||
--------------
|
||||
1. SQLAlchemy engine options pool_pre_ping / pool_recycle -- the two that
|
||||
decide whether you get "MySQL server has
|
||||
gone away" after an idle overnight.
|
||||
2. Server + connection settings max_connections vs the Gunicorn pool,
|
||||
wait_timeout vs pool_recycle,
|
||||
innodb_buffer_pool_size vs the live data
|
||||
size, slow-query-log state, sql_mode.
|
||||
3. Schema hygiene non-InnoDB tables, non-utf8mb4 charsets,
|
||||
mixed collations (a collation mismatch on a
|
||||
join column silently disables the index).
|
||||
4. Table + index footprint row estimates, data/index bytes.
|
||||
5. Missing indexes a curated, individually justified list
|
||||
checked against what is really there.
|
||||
6. Redundant indexes leftmost-prefix duplicates. FK-backed
|
||||
indexes are excluded from drop candidates --
|
||||
dropping one fails with errno 150.
|
||||
7. Unindexed foreign keys should be empty on InnoDB; a gap here means
|
||||
every parent DELETE scans the child table.
|
||||
8. EXPLAIN on hot queries the dashboard, both list pages, the SLA
|
||||
cron, the mobile notification poll. Flags
|
||||
full table scans, filesorts, temp tables.
|
||||
|
||||
What it can change (only when explicitly asked)
|
||||
-----------------------------------------------
|
||||
--apply-indexes CREATE the missing recommended indexes (idempotent --
|
||||
re-checks INFORMATION_SCHEMA first, per CLAUDE.md rule 16;
|
||||
CREATE INDEX IF NOT EXISTS needs MySQL >= 8.0.12).
|
||||
--analyze ANALYZE TABLE on every table. Cheap, non-locking on
|
||||
InnoDB, refreshes optimizer cardinality statistics.
|
||||
Safe to run in production.
|
||||
--optimize OPTIMIZE TABLE -- rebuilds each table to reclaim space
|
||||
after large deletes. Requires --yes. This LOCKS each
|
||||
table for the rebuild: maintenance window only.
|
||||
|
||||
It never DROPs anything. Redundant indexes are reported with the exact SQL to
|
||||
run by hand, because "unused" is a judgement call the tool should not make for
|
||||
you.
|
||||
|
||||
Usage
|
||||
-----
|
||||
cd /home/jqc/janitorial_qc
|
||||
source venv/bin/activate
|
||||
|
||||
python scripts/db_health.py # read-only report
|
||||
python scripts/db_health.py --json /tmp/db.json # + machine-readable
|
||||
python scripts/db_health.py --no-explain # skip the EXPLAIN pass
|
||||
python scripts/db_health.py --emit-migration migrations/versions/phase54_perf_indexes.py \
|
||||
--revision phase54_perf_indexes
|
||||
python scripts/db_health.py --apply-indexes # create missing indexes
|
||||
python scripts/db_health.py --analyze # refresh statistics
|
||||
python scripts/db_health.py --optimize --yes # rebuild (locks!)
|
||||
|
||||
Exit codes
|
||||
----------
|
||||
0 no findings at or above --fail-on
|
||||
1 findings at or above --fail-on (default 'none' -- never fails)
|
||||
2 could not connect / not a MySQL database / refused a destructive flag
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import json
|
||||
import argparse
|
||||
from datetime import datetime
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
||||
|
||||
from sqlalchemy import text # noqa: E402
|
||||
from app import create_app, db # noqa: E402
|
||||
|
||||
|
||||
# -- Output helpers ----------------------------------------------------------
|
||||
|
||||
class C:
|
||||
OK = '\033[92m'
|
||||
WARN = '\033[93m'
|
||||
ERR = '\033[91m'
|
||||
DIM = '\033[90m'
|
||||
BOLD = '\033[1m'
|
||||
END = '\033[0m'
|
||||
|
||||
|
||||
_NO_COLOR = (not sys.stdout.isatty()) or bool(os.environ.get('NO_COLOR'))
|
||||
|
||||
|
||||
def _c(code, s):
|
||||
return s if _NO_COLOR else f'{code}{s}{C.END}'
|
||||
|
||||
|
||||
def header(title):
|
||||
print()
|
||||
print(_c(C.BOLD, f'-- {title} ' + '-' * max(0, 72 - len(title))))
|
||||
|
||||
|
||||
SEVERITY_ORDER = {'info': 0, 'low': 1, 'medium': 2, 'high': 3}
|
||||
|
||||
FINDINGS = []
|
||||
|
||||
|
||||
def finding(severity, area, message, fix=None):
|
||||
"""Record a finding and print it. severity: info|low|medium|high."""
|
||||
FINDINGS.append({'severity': severity, 'area': area,
|
||||
'message': message, 'fix': fix})
|
||||
tag = {
|
||||
'info': _c(C.DIM, 'INFO '),
|
||||
'low': _c(C.DIM, 'LOW '),
|
||||
'medium': _c(C.WARN, 'MED '),
|
||||
'high': _c(C.ERR, 'HIGH '),
|
||||
}[severity]
|
||||
print(f' {tag} {message}')
|
||||
if fix:
|
||||
for line in fix.strip().splitlines():
|
||||
print(_c(C.DIM, f' {line}'))
|
||||
|
||||
|
||||
def ok(message):
|
||||
print(f' {_c(C.OK, "OK ")} {message}')
|
||||
|
||||
|
||||
def human_bytes(n):
|
||||
n = float(n or 0)
|
||||
for unit in ('B', 'KB', 'MB', 'GB', 'TB'):
|
||||
if n < 1024:
|
||||
return f'{n:.1f} {unit}'
|
||||
n /= 1024
|
||||
return f'{n:.1f} PB'
|
||||
|
||||
|
||||
# -- The recommended index set -----------------------------------------------
|
||||
#
|
||||
# Each entry is justified by a specific query in the application. An index that
|
||||
# nothing runs is pure write-amplification, so nothing speculative belongs here.
|
||||
#
|
||||
# (table, index_name, 'col, col', priority, why)
|
||||
#
|
||||
# priority: 'high' -- a hot path scans without it (every page load / every cron)
|
||||
# 'medium' -- a heavy but less frequent report or filter
|
||||
# 'low' -- worth having as the table grows
|
||||
|
||||
RECOMMENDED_INDEXES = [
|
||||
# --- notifications: polled by the bell on every page AND by every iPad ---
|
||||
('notifications', 'ix_notif_user_read_created', 'user_id, is_read, created_at',
|
||||
'high',
|
||||
"GET /api/v1/notifications and the in-app bell both run "
|
||||
"WHERE user_id=? AND is_read=0 [AND created_at > ?] ORDER BY created_at. "
|
||||
"Only user_id is indexed today, so MySQL reads every notification ever "
|
||||
"sent to that user and then sorts them."),
|
||||
|
||||
('notifications', 'ix_notif_digest_user', 'digest_pending, user_id',
|
||||
'medium',
|
||||
"send_pending_digests() runs SELECT DISTINCT user_id WHERE "
|
||||
"digest_pending=1. The existing single-column digest_pending index cannot "
|
||||
"satisfy the DISTINCT from the index alone."),
|
||||
|
||||
# --- issues: the SLA cron scans these every 30 minutes, forever ----------
|
||||
('issues', 'ix_issues_status_reported', 'status, reported_at',
|
||||
'high',
|
||||
"send_sla_alerts() selects every open/in_progress/pending_verification "
|
||||
"issue every 30 min, and the issues list orders by reported_at DESC after "
|
||||
"filtering on status. One composite serves both; ix_issues_status then "
|
||||
"becomes a redundant prefix of it."),
|
||||
|
||||
('issues', 'ix_issues_resolved_at', 'resolved_at',
|
||||
'medium',
|
||||
"The dashboard 'Resolved Today' card and the SLA Compliance report both "
|
||||
"filter on resolved_at ranges; the column is unindexed."),
|
||||
|
||||
('issues', 'ix_issues_area_status', 'area_id, status',
|
||||
'medium',
|
||||
"Every facility-scoped issue query outer-joins areas and ORs on "
|
||||
"Area.facility_id. area_id carries only the plain FK index; pairing it "
|
||||
"with status lets the legacy area-routed half of the OR resolve from the "
|
||||
"index instead of reading rows."),
|
||||
|
||||
('issues', 'ix_issues_handler_status', 'handler_type, status',
|
||||
'low',
|
||||
"Dashboard handler-split cards and the ?handler_type= list filter."),
|
||||
|
||||
# --- inspections --------------------------------------------------------
|
||||
('inspections', 'ix_insp_followup_status', 'follow_up_required, status',
|
||||
'medium',
|
||||
"The Pending Follow-ups dashboard card and the ?status=follow_up filter "
|
||||
"run WHERE follow_up_required=1 AND status='completed' AND NOT EXISTS("
|
||||
"child re-inspection)."),
|
||||
|
||||
('inspections', 'ix_insp_followup_assignee', 'follow_up_assigned_to',
|
||||
'low',
|
||||
"phase53 follow-up ownership (Inspection.follow_up_owned_by) filters on "
|
||||
"this column. It is a late-added nullable FK -- confirm it is indexed."),
|
||||
|
||||
# --- audit_logs: append-only, and the fastest-growing table here ---------
|
||||
('audit_logs', 'ix_audit_entity', 'entity_type, entity_id',
|
||||
'medium',
|
||||
"The audit trail is filtered by entity. entity_type alone is very low "
|
||||
"cardinality (about a dozen distinct values), so it is close to useless "
|
||||
"on its own."),
|
||||
|
||||
('audit_logs', 'ix_audit_user_created', 'user_id, created_at',
|
||||
'low',
|
||||
"Per-user audit filtering with the default created_at DESC ordering."),
|
||||
|
||||
# --- issue_comments -----------------------------------------------------
|
||||
('issue_comments', 'ix_comments_issue_created', 'issue_id, created_at',
|
||||
'medium',
|
||||
"Both the web issue detail and GET /api/v1/issues/<id>/comments read the "
|
||||
"thread ordered by created_at. The plain FK index on issue_id leaves the "
|
||||
"sort to a filesort."),
|
||||
|
||||
# --- scheduled_inspections: cron every 30 min ---------------------------
|
||||
('scheduled_inspections', 'ix_sched_active_due', 'active, next_due_date',
|
||||
'medium',
|
||||
"The reminder cron and the dashboard panel both read "
|
||||
"WHERE active=1 ORDER BY next_due_date."),
|
||||
|
||||
# --- support ------------------------------------------------------------
|
||||
('support_chat_messages', 'ix_scm_session_created', 'session_id, created_at',
|
||||
'low',
|
||||
"Transcript rendering reads a session's messages in created_at order."),
|
||||
]
|
||||
|
||||
|
||||
# -- Representative hot queries for the EXPLAIN pass -------------------------
|
||||
#
|
||||
# Literal-free: every value is a bound parameter, so this pass reflects what the
|
||||
# app actually sends and cannot itself be a vector for anything.
|
||||
|
||||
EXPLAIN_QUERIES = [
|
||||
('dashboard: open issues (scoped)',
|
||||
"SELECT i.id FROM issues i LEFT JOIN areas a ON i.area_id = a.id "
|
||||
"WHERE i.status IN ('open','in_progress')",
|
||||
{}),
|
||||
|
||||
('dashboard: resolved today',
|
||||
"SELECT COUNT(*) FROM issues WHERE status='resolved' "
|
||||
"AND resolved_at >= :d0 AND resolved_at < :d1",
|
||||
{'d0': '2026-01-01 00:00:00', 'd1': '2026-01-02 00:00:00'}),
|
||||
|
||||
('dashboard: pending follow-ups',
|
||||
"SELECT COUNT(*) FROM inspections i WHERE i.follow_up_required = 1 "
|
||||
"AND i.status = 'completed' AND NOT EXISTS "
|
||||
"(SELECT 1 FROM inspections c WHERE c.parent_inspection_id = i.id)",
|
||||
{}),
|
||||
|
||||
('issues list: page 1, newest first',
|
||||
"SELECT i.id FROM issues i LEFT JOIN areas a ON i.area_id = a.id "
|
||||
"ORDER BY i.reported_at DESC LIMIT 25",
|
||||
{}),
|
||||
|
||||
('inspections list: page 1, newest first',
|
||||
"SELECT i.id FROM inspections i ORDER BY i.inspection_date DESC LIMIT 25",
|
||||
{}),
|
||||
|
||||
('SLA cron: candidate scan (every 30 min)',
|
||||
"SELECT id FROM issues WHERE status IN "
|
||||
"('open','in_progress','pending_verification')",
|
||||
{}),
|
||||
|
||||
('mobile: unread notification poll',
|
||||
"SELECT id FROM notifications WHERE user_id = :uid AND is_read = 0 "
|
||||
"AND created_at > :since ORDER BY created_at ASC LIMIT 50",
|
||||
{'uid': 1, 'since': '2026-01-01 00:00:00'}),
|
||||
|
||||
('digest cron: pending recipients',
|
||||
"SELECT DISTINCT user_id FROM notifications WHERE digest_pending = 1",
|
||||
{}),
|
||||
|
||||
('reports: avg score by facility (30d)',
|
||||
"SELECT f.id, AVG(i.overall_score) FROM facilities f "
|
||||
"JOIN inspections i ON f.id = i.facility_id "
|
||||
"WHERE i.inspection_date >= :d0 AND i.status='completed' "
|
||||
"AND i.overall_score IS NOT NULL GROUP BY f.id",
|
||||
{'d0': '2026-01-01 00:00:00'}),
|
||||
|
||||
('audit trail: newest first',
|
||||
"SELECT id FROM audit_logs ORDER BY created_at DESC LIMIT 50",
|
||||
{}),
|
||||
]
|
||||
|
||||
|
||||
# -- Introspection -----------------------------------------------------------
|
||||
|
||||
def db_name(conn):
|
||||
return conn.execute(text('SELECT DATABASE()')).scalar()
|
||||
|
||||
|
||||
def is_mysql(conn):
|
||||
return conn.dialect.name in ('mysql', 'mariadb')
|
||||
|
||||
|
||||
def server_version(conn):
|
||||
return conn.execute(text('SELECT VERSION()')).scalar()
|
||||
|
||||
|
||||
def global_vars(conn, names):
|
||||
out = {}
|
||||
for n in names:
|
||||
try:
|
||||
row = conn.execute(
|
||||
text('SHOW GLOBAL VARIABLES LIKE :n'), {'n': n}).fetchone()
|
||||
if row:
|
||||
out[row[0]] = row[1]
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def fetch_tables(conn, schema):
|
||||
rows = conn.execute(text("""
|
||||
SELECT TABLE_NAME, ENGINE, TABLE_ROWS, DATA_LENGTH, INDEX_LENGTH,
|
||||
TABLE_COLLATION
|
||||
FROM INFORMATION_SCHEMA.TABLES
|
||||
WHERE TABLE_SCHEMA = :s AND TABLE_TYPE = 'BASE TABLE'
|
||||
ORDER BY (DATA_LENGTH + INDEX_LENGTH) DESC
|
||||
"""), {'s': schema}).fetchall()
|
||||
return [{'name': r[0], 'engine': r[1], 'rows': r[2] or 0,
|
||||
'data': r[3] or 0, 'index': r[4] or 0, 'collation': r[5]}
|
||||
for r in rows]
|
||||
|
||||
|
||||
def fetch_indexes(conn, schema):
|
||||
"""Return {table: {index_name: {'cols': [...], 'unique': bool}}}."""
|
||||
rows = conn.execute(text("""
|
||||
SELECT TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX, COLUMN_NAME, NON_UNIQUE
|
||||
FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = :s
|
||||
ORDER BY TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX
|
||||
"""), {'s': schema}).fetchall()
|
||||
out = {}
|
||||
for tbl, idx, seq, col, non_unique in rows:
|
||||
entry = out.setdefault(tbl, {}).setdefault(
|
||||
idx, {'cols': [], 'unique': not non_unique})
|
||||
entry['cols'].append(col)
|
||||
return out
|
||||
|
||||
|
||||
def fetch_fk_protection(conn, schema):
|
||||
"""Index names and columns MySQL relies on for FOREIGN KEY constraints.
|
||||
|
||||
Dropping such an index fails with errno 150, so these must never be
|
||||
offered as drop candidates no matter how redundant they look.
|
||||
"""
|
||||
rows = conn.execute(text("""
|
||||
SELECT TABLE_NAME, CONSTRAINT_NAME
|
||||
FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS
|
||||
WHERE TABLE_SCHEMA = :s AND CONSTRAINT_TYPE = 'FOREIGN KEY'
|
||||
"""), {'s': schema}).fetchall()
|
||||
fk_names = {}
|
||||
for tbl, name in rows:
|
||||
fk_names.setdefault(tbl, set()).add(name)
|
||||
|
||||
rows = conn.execute(text("""
|
||||
SELECT TABLE_NAME, COLUMN_NAME
|
||||
FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE
|
||||
WHERE TABLE_SCHEMA = :s AND REFERENCED_TABLE_NAME IS NOT NULL
|
||||
"""), {'s': schema}).fetchall()
|
||||
fk_cols = {}
|
||||
for tbl, col in rows:
|
||||
fk_cols.setdefault(tbl, set()).add(col)
|
||||
return fk_names, fk_cols
|
||||
|
||||
|
||||
def index_exists(conn, schema, table, index_name):
|
||||
return conn.execute(text("""
|
||||
SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = :s AND TABLE_NAME = :t AND INDEX_NAME = :i
|
||||
"""), {'s': schema, 't': table, 'i': index_name}).scalar() > 0
|
||||
|
||||
|
||||
def table_exists(conn, schema, table):
|
||||
return conn.execute(text("""
|
||||
SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES
|
||||
WHERE TABLE_SCHEMA = :s AND TABLE_NAME = :t
|
||||
"""), {'s': schema, 't': table}).scalar() > 0
|
||||
|
||||
|
||||
def column_exists(conn, schema, table, column):
|
||||
return conn.execute(text("""
|
||||
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = :s AND TABLE_NAME = :t AND COLUMN_NAME = :c
|
||||
"""), {'s': schema, 't': table, 'c': column}).scalar() > 0
|
||||
|
||||
|
||||
# -- Checks ------------------------------------------------------------------
|
||||
|
||||
def check_engine_options(app):
|
||||
header('SQLAlchemy engine options')
|
||||
opts = app.config.get('SQLALCHEMY_ENGINE_OPTIONS') or {}
|
||||
uri = app.config.get('SQLALCHEMY_DATABASE_URI', '')
|
||||
|
||||
if not opts:
|
||||
finding('high', 'engine',
|
||||
'SQLALCHEMY_ENGINE_OPTIONS is not set -- the pool runs on '
|
||||
'library defaults with no liveness check and no recycle.',
|
||||
"Add to config.Config:\n"
|
||||
" SQLALCHEMY_ENGINE_OPTIONS = {\n"
|
||||
" 'pool_pre_ping': True, # kills 'MySQL server has gone away'\n"
|
||||
" 'pool_recycle': 1800, # must be < MySQL wait_timeout\n"
|
||||
" 'pool_size': 5, 'max_overflow': 5,\n"
|
||||
" 'pool_timeout': 30,\n"
|
||||
" }")
|
||||
else:
|
||||
if not opts.get('pool_pre_ping'):
|
||||
finding('high', 'engine',
|
||||
'pool_pre_ping is off -- an idle connection killed by MySQL '
|
||||
'surfaces as OperationalError 2006 on the next request.',
|
||||
"SQLALCHEMY_ENGINE_OPTIONS['pool_pre_ping'] = True")
|
||||
else:
|
||||
ok('pool_pre_ping enabled')
|
||||
|
||||
recycle = opts.get('pool_recycle')
|
||||
if not recycle or recycle < 0:
|
||||
finding('medium', 'engine',
|
||||
'pool_recycle is unset -- connections are held indefinitely '
|
||||
'and can outlive the server-side wait_timeout.',
|
||||
"SQLALCHEMY_ENGINE_OPTIONS['pool_recycle'] = 1800")
|
||||
else:
|
||||
ok(f'pool_recycle = {recycle}s')
|
||||
|
||||
if 'mysql' in uri and 'charset=' not in uri:
|
||||
finding('low', 'engine',
|
||||
'DATABASE_URL has no ?charset= -- PyMySQL negotiates the server '
|
||||
'default, which on older installs is latin1.',
|
||||
'Append ?charset=utf8mb4 to DATABASE_URL.')
|
||||
return opts
|
||||
|
||||
|
||||
def check_server(conn, opts, tables):
|
||||
header('MySQL server settings')
|
||||
print(f' {_c(C.DIM, "version")} {server_version(conn)}')
|
||||
|
||||
g = global_vars(conn, [
|
||||
'max_connections', 'wait_timeout', 'interactive_timeout',
|
||||
'innodb_buffer_pool_size', 'slow_query_log', 'long_query_time',
|
||||
'sql_mode', 'character_set_server', 'collation_server',
|
||||
'innodb_flush_log_at_trx_commit', 'table_open_cache',
|
||||
'performance_schema',
|
||||
])
|
||||
|
||||
# --- pool sizing vs max_connections -------------------------------------
|
||||
try:
|
||||
maxc = int(g.get('max_connections', 0))
|
||||
except (TypeError, ValueError):
|
||||
maxc = 0
|
||||
pool_size = (opts or {}).get('pool_size', 5)
|
||||
overflow = (opts or {}).get('max_overflow', 10)
|
||||
try:
|
||||
import multiprocessing
|
||||
workers = multiprocessing.cpu_count() * 2 + 1 # gunicorn_config.py
|
||||
except Exception:
|
||||
workers = 9
|
||||
worst_case = workers * (pool_size + overflow)
|
||||
print(f' {_c(C.DIM, "max_connections")} {maxc} '
|
||||
f'{_c(C.DIM, "gunicorn workers")} ~{workers} '
|
||||
f'{_c(C.DIM, "worst-case pool")} {worst_case}')
|
||||
if maxc and worst_case > maxc * 0.8:
|
||||
finding('high', 'server',
|
||||
f'{workers} sync workers x (pool_size {pool_size} + overflow '
|
||||
f'{overflow}) = {worst_case} possible connections against '
|
||||
f'max_connections={maxc}. Under load that returns "Too many '
|
||||
f'connections" instead of queueing.',
|
||||
'Either cap the pool (pool_size 5 / max_overflow 5) or raise '
|
||||
'max_connections. A sync worker only ever needs one connection '
|
||||
'in steady state.')
|
||||
elif maxc:
|
||||
ok(f'connection ceiling has headroom ({worst_case} worst case / {maxc})')
|
||||
|
||||
# --- wait_timeout vs pool_recycle ---------------------------------------
|
||||
try:
|
||||
wait = int(g.get('wait_timeout', 0))
|
||||
except (TypeError, ValueError):
|
||||
wait = 0
|
||||
recycle = (opts or {}).get('pool_recycle')
|
||||
if wait and recycle and recycle >= wait:
|
||||
finding('high', 'server',
|
||||
f'pool_recycle ({recycle}s) is not below wait_timeout ({wait}s) '
|
||||
f'-- the server closes the connection before SQLAlchemy retires '
|
||||
f'it, which is exactly the case pool_recycle exists to prevent.',
|
||||
f'Set pool_recycle to roughly {int(wait * 0.6)}.')
|
||||
elif wait:
|
||||
ok(f'wait_timeout = {wait}s')
|
||||
|
||||
# --- buffer pool vs live data -------------------------------------------
|
||||
try:
|
||||
pool_bytes = int(g.get('innodb_buffer_pool_size', 0))
|
||||
except (TypeError, ValueError):
|
||||
pool_bytes = 0
|
||||
total = sum(t['data'] + t['index'] for t in tables)
|
||||
print(f' {_c(C.DIM, "innodb_buffer_pool_size")} {human_bytes(pool_bytes)} '
|
||||
f'{_c(C.DIM, "data+indexes")} {human_bytes(total)}')
|
||||
if pool_bytes and total > pool_bytes:
|
||||
finding('medium', 'server',
|
||||
f'The working set ({human_bytes(total)}) exceeds the InnoDB '
|
||||
f'buffer pool ({human_bytes(pool_bytes)}) -- hot pages are being '
|
||||
f'evicted and re-read from disk.',
|
||||
'On a dedicated box set innodb_buffer_pool_size to about '
|
||||
'60-70% of RAM.')
|
||||
elif pool_bytes:
|
||||
ok('the entire dataset fits in the InnoDB buffer pool')
|
||||
|
||||
# --- slow query log ------------------------------------------------------
|
||||
if str(g.get('slow_query_log', 'OFF')).upper() in ('OFF', '0'):
|
||||
finding('medium', 'observability',
|
||||
'slow_query_log is OFF -- there is no record of which queries '
|
||||
'are actually slow in production, so tuning stays guesswork.',
|
||||
"SET GLOBAL slow_query_log = 'ON';\n"
|
||||
"SET GLOBAL long_query_time = 0.5;\n"
|
||||
"(and persist both in my.cnf so they survive a restart)")
|
||||
else:
|
||||
ok(f"slow_query_log ON (long_query_time={g.get('long_query_time')})")
|
||||
|
||||
# --- sql_mode ------------------------------------------------------------
|
||||
mode = g.get('sql_mode', '') or ''
|
||||
if 'STRICT_TRANS_TABLES' not in mode and 'STRICT_ALL_TABLES' not in mode:
|
||||
finding('medium', 'integrity',
|
||||
'sql_mode has no STRICT mode -- an over-long string or an '
|
||||
'invalid ENUM value is silently coerced instead of raising.',
|
||||
'Add STRICT_TRANS_TABLES to sql_mode in my.cnf.')
|
||||
else:
|
||||
ok('STRICT mode enabled')
|
||||
return g
|
||||
|
||||
|
||||
def check_schema_hygiene(conn, schema, tables):
|
||||
header('Schema hygiene')
|
||||
bad_engine = [t for t in tables if (t['engine'] or '').upper() != 'INNODB']
|
||||
if bad_engine:
|
||||
finding('high', 'schema',
|
||||
'Non-InnoDB tables found: ' +
|
||||
', '.join(f"{t['name']} ({t['engine']})" for t in bad_engine),
|
||||
'MyISAM has no transactions and locks the whole table on write.\n'
|
||||
+ '\n'.join(f'ALTER TABLE {t["name"]} ENGINE=InnoDB;'
|
||||
for t in bad_engine))
|
||||
else:
|
||||
ok('all tables are InnoDB')
|
||||
|
||||
collations = {}
|
||||
for t in tables:
|
||||
collations.setdefault(t['collation'] or 'unknown', []).append(t['name'])
|
||||
|
||||
non_utf8mb4 = {c: n for c, n in collations.items()
|
||||
if not str(c).startswith('utf8mb4')}
|
||||
if non_utf8mb4:
|
||||
for coll, names in non_utf8mb4.items():
|
||||
finding('medium', 'schema',
|
||||
f'{len(names)} table(s) are {coll}, not utf8mb4: '
|
||||
+ ', '.join(names[:8]) + ('...' if len(names) > 8 else ''),
|
||||
'utf8mb3/latin1 cannot store emoji or many non-Latin names, '
|
||||
'and a join between two different collations cannot use an '
|
||||
'index on that column.\n'
|
||||
+ '\n'.join(
|
||||
f'ALTER TABLE {n} CONVERT TO CHARACTER SET utf8mb4 '
|
||||
f'COLLATE utf8mb4_unicode_ci;' for n in names[:8]))
|
||||
else:
|
||||
ok('all tables are utf8mb4')
|
||||
|
||||
if len(collations) > 1:
|
||||
finding('low', 'schema',
|
||||
'Mixed collations across tables: '
|
||||
+ ', '.join(sorted(str(c) for c in collations))
|
||||
+ '. Joining two columns with different collations silently '
|
||||
'disables index use on the join.')
|
||||
|
||||
|
||||
def check_table_footprint(tables):
|
||||
header('Table footprint (largest first)')
|
||||
print(' ' + _c(C.DIM, 'table'.ljust(32) + 'rows~'.rjust(12)
|
||||
+ 'data'.rjust(12) + 'index'.rjust(12)))
|
||||
for t in tables[:15]:
|
||||
print(f' {t["name"][:32].ljust(32)}'
|
||||
f'{str(t["rows"]).rjust(12)}'
|
||||
f'{human_bytes(t["data"]).rjust(12)}'
|
||||
f'{human_bytes(t["index"]).rjust(12)}')
|
||||
for t in tables:
|
||||
if t['index'] > t['data'] * 2 and t['data'] > 10 * 1024 * 1024:
|
||||
finding('low', 'indexes',
|
||||
f'{t["name"]}: index bytes ({human_bytes(t["index"])}) are '
|
||||
f'more than twice the data ({human_bytes(t["data"])}) -- '
|
||||
f'likely over-indexed, which slows every write.')
|
||||
|
||||
|
||||
def check_missing_indexes(conn, schema, existing):
|
||||
header('Missing indexes (recommended set)')
|
||||
missing = []
|
||||
for table, name, cols, prio, why in RECOMMENDED_INDEXES:
|
||||
if not table_exists(conn, schema, table):
|
||||
continue
|
||||
col_list = [c.strip() for c in cols.split(',')]
|
||||
if any(not column_exists(conn, schema, table, c) for c in col_list):
|
||||
continue # column absent in this schema version -- skip quietly
|
||||
|
||||
tbl_idx = existing.get(table, {})
|
||||
# Covered when some existing index STARTS with exactly these columns in
|
||||
# this order: a longer index with the right prefix serves the same
|
||||
# queries, so adding a duplicate would only cost write time.
|
||||
covered = any(idx['cols'][:len(col_list)] == col_list
|
||||
for idx in tbl_idx.values())
|
||||
if covered:
|
||||
continue
|
||||
|
||||
missing.append((table, name, cols, prio, why))
|
||||
finding(prio, 'indexes',
|
||||
f'{table}.{name} ({cols}) is missing',
|
||||
f'{why}\nCREATE INDEX {name} ON {table} ({cols});')
|
||||
|
||||
if not missing:
|
||||
ok('every recommended index is present')
|
||||
return missing
|
||||
|
||||
|
||||
def check_redundant_indexes(existing, fk_names, fk_cols):
|
||||
header('Redundant indexes')
|
||||
found = False
|
||||
for table, idxs in sorted(existing.items()):
|
||||
names = sorted(idxs.keys())
|
||||
for a in names:
|
||||
if a == 'PRIMARY':
|
||||
continue
|
||||
for b in names:
|
||||
if a == b or b == 'PRIMARY':
|
||||
continue
|
||||
ca, cb = idxs[a]['cols'], idxs[b]['cols']
|
||||
# a is redundant when b starts with all of a's columns and b is
|
||||
# strictly longer.
|
||||
if len(ca) >= len(cb) or cb[:len(ca)] != ca:
|
||||
continue
|
||||
if idxs[a]['unique']:
|
||||
continue # a unique index is a constraint, not a hint
|
||||
|
||||
protected = (a in fk_names.get(table, set())
|
||||
or (len(ca) == 1
|
||||
and ca[0] in fk_cols.get(table, set())))
|
||||
if protected:
|
||||
finding('info', 'indexes',
|
||||
f'{table}.{a} ({", ".join(ca)}) is a prefix of '
|
||||
f'{b} ({", ".join(cb)}) but backs a FOREIGN KEY -- '
|
||||
f'leave it alone (dropping it fails, errno 150).')
|
||||
else:
|
||||
found = True
|
||||
finding('low', 'indexes',
|
||||
f'{table}.{a} ({", ".join(ca)}) is fully covered by '
|
||||
f'{b} ({", ".join(cb)}) -- it costs write time and '
|
||||
f'buffer-pool space for nothing.',
|
||||
'-- confirm against your slow log first, then:\n'
|
||||
f'DROP INDEX {a} ON {table};')
|
||||
break
|
||||
if not found:
|
||||
ok('no droppable duplicate indexes')
|
||||
|
||||
|
||||
def check_unindexed_fks(conn, schema, existing):
|
||||
header('Foreign keys without a usable index')
|
||||
rows = conn.execute(text("""
|
||||
SELECT TABLE_NAME, COLUMN_NAME
|
||||
FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE
|
||||
WHERE TABLE_SCHEMA = :s AND REFERENCED_TABLE_NAME IS NOT NULL
|
||||
"""), {'s': schema}).fetchall()
|
||||
bad = []
|
||||
for tbl, col in rows:
|
||||
idxs = existing.get(tbl, {})
|
||||
if not any(i['cols'] and i['cols'][0] == col for i in idxs.values()):
|
||||
bad.append((tbl, col))
|
||||
if bad:
|
||||
for tbl, col in bad:
|
||||
finding('high', 'indexes',
|
||||
f'{tbl}.{col} is a foreign key with no leading index -- '
|
||||
f'every DELETE on the parent table scans this one.',
|
||||
f'CREATE INDEX ix_{tbl}_{col} ON {tbl} ({col});')
|
||||
else:
|
||||
ok('every foreign key column has a leading index')
|
||||
|
||||
|
||||
def check_explain(conn):
|
||||
header('EXPLAIN on representative hot queries')
|
||||
for label, sql, params in EXPLAIN_QUERIES:
|
||||
try:
|
||||
rows = conn.execute(text('EXPLAIN ' + sql), params).mappings().all()
|
||||
except Exception as exc:
|
||||
print(f' {_c(C.DIM, "skip ")} {label}: '
|
||||
f'{str(exc).splitlines()[0][:90]}')
|
||||
continue
|
||||
|
||||
problems = []
|
||||
for r in rows:
|
||||
typ = (r.get('type') or '').upper()
|
||||
extra = r.get('Extra') or ''
|
||||
tbl = r.get('table') or '?'
|
||||
nrows = r.get('rows') or 0
|
||||
if typ == 'ALL':
|
||||
problems.append(f'full scan of {tbl} (~{nrows} rows)')
|
||||
if 'Using filesort' in extra:
|
||||
problems.append(f'filesort on {tbl}')
|
||||
if 'Using temporary' in extra:
|
||||
problems.append(f'temp table on {tbl}')
|
||||
|
||||
if problems:
|
||||
finding('medium', 'query-plan',
|
||||
f'{label}: ' + '; '.join(sorted(set(problems))), sql)
|
||||
else:
|
||||
ok(label)
|
||||
|
||||
|
||||
# -- Mutating actions (opt-in only) ------------------------------------------
|
||||
|
||||
def apply_indexes(conn, schema, missing):
|
||||
header('Applying missing indexes')
|
||||
if not missing:
|
||||
ok('nothing to apply')
|
||||
return
|
||||
for table, name, cols, prio, why in missing:
|
||||
# Re-check rather than trusting the earlier pass: CREATE INDEX IF NOT
|
||||
# EXISTS is unavailable before MySQL 8.0.12 (CLAUDE.md rule 16).
|
||||
if index_exists(conn, schema, table, name):
|
||||
print(f' {_c(C.DIM, "skip ")} {name} already exists')
|
||||
continue
|
||||
sql = f'CREATE INDEX {name} ON {table} ({cols})'
|
||||
print(f' {_c(C.BOLD, "run ")} {sql}')
|
||||
conn.execute(text(sql))
|
||||
print(f' {_c(C.OK, "done ")} {name}')
|
||||
|
||||
|
||||
def run_analyze(conn, tables):
|
||||
header('ANALYZE TABLE (refresh optimizer statistics)')
|
||||
for t in tables:
|
||||
conn.execute(text(f'ANALYZE TABLE `{t["name"]}`'))
|
||||
print(f' {_c(C.OK, "done ")} {t["name"]}')
|
||||
|
||||
|
||||
def run_optimize(conn, tables):
|
||||
header('OPTIMIZE TABLE (rebuild -- locks each table)')
|
||||
for t in tables:
|
||||
before = t['data'] + t['index']
|
||||
conn.execute(text(f'OPTIMIZE TABLE `{t["name"]}`'))
|
||||
print(f' {_c(C.OK, "done ")} {t["name"]} (was {human_bytes(before)})')
|
||||
|
||||
|
||||
# -- Alembic migration emitter -----------------------------------------------
|
||||
|
||||
MIGRATION_TEMPLATE = '''"""Performance indexes -- generated by scripts/db_health.py
|
||||
|
||||
Adds the indexes the health check found missing. Every statement is guarded by
|
||||
an INFORMATION_SCHEMA lookup, so this migration is safe to re-run (CLAUDE.md
|
||||
rule 16: CREATE INDEX IF NOT EXISTS needs MySQL >= 8.0.12).
|
||||
|
||||
Generated: {generated}
|
||||
|
||||
Revision ID: {revision}
|
||||
Revises: {down_revision}
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# Revision ids must be <= 32 chars -- alembic_version.version_num is VARCHAR(32).
|
||||
revision = '{revision}'
|
||||
down_revision = '{down_revision}'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
INDEXES = [
|
||||
{index_rows}
|
||||
]
|
||||
|
||||
|
||||
def _index_exists(conn, table, name):
|
||||
return conn.execute(sa.text("""
|
||||
SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :t AND INDEX_NAME = :i
|
||||
"""), {{'t': table, 'i': name}}).scalar() > 0
|
||||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
for table, name, cols in INDEXES:
|
||||
if not _index_exists(conn, table, name):
|
||||
op.execute(sa.text(f'CREATE INDEX {{name}} ON {{table}} ({{cols}})'))
|
||||
|
||||
|
||||
def downgrade():
|
||||
conn = op.get_bind()
|
||||
for table, name, cols in INDEXES:
|
||||
if _index_exists(conn, table, name):
|
||||
op.execute(sa.text(f'DROP INDEX {{name}} ON {{table}}'))
|
||||
'''
|
||||
|
||||
|
||||
def emit_migration(path, missing, revision, down_revision):
|
||||
rows = '\n'.join(
|
||||
f' ({table!r}, {name!r}, {cols!r}),'
|
||||
for table, name, cols, prio, why in missing
|
||||
) or ' # nothing was missing at generation time'
|
||||
body = MIGRATION_TEMPLATE.format(
|
||||
generated=datetime.now().strftime('%Y-%m-%d %H:%M'),
|
||||
revision=revision,
|
||||
down_revision=down_revision,
|
||||
index_rows=rows,
|
||||
)
|
||||
with open(path, 'w', encoding='utf-8') as fh:
|
||||
fh.write(body)
|
||||
print(f'\n {_c(C.OK, "wrote")} {path}')
|
||||
print(_c(C.DIM, ' Review it, confirm down_revision is the real head '
|
||||
'(flask db heads), then run flask db upgrade.'))
|
||||
|
||||
|
||||
def current_head():
|
||||
"""Best-effort read of the current Alembic head from the versions dir."""
|
||||
vdir = os.path.join(os.path.dirname(__file__), '..', 'migrations', 'versions')
|
||||
revs, downs = set(), set()
|
||||
try:
|
||||
for fn in os.listdir(vdir):
|
||||
if not fn.endswith('.py'):
|
||||
continue
|
||||
with open(os.path.join(vdir, fn), encoding='utf-8') as fh:
|
||||
src = fh.read()
|
||||
m = re.search(r"^revision\s*=\s*['\"]([^'\"]+)", src, re.M)
|
||||
d = re.search(r"^down_revision\s*=\s*['\"]([^'\"]+)", src, re.M)
|
||||
if m:
|
||||
revs.add(m.group(1))
|
||||
if d:
|
||||
downs.add(d.group(1))
|
||||
except OSError:
|
||||
return '<REPLACE_WITH_CURRENT_HEAD>'
|
||||
heads = revs - downs
|
||||
return sorted(heads)[0] if len(heads) == 1 else '<REPLACE_WITH_CURRENT_HEAD>'
|
||||
|
||||
|
||||
# -- Main --------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(
|
||||
description='MySQL health check and index optimizer for JQC. '
|
||||
'Read-only unless an --apply/--analyze/--optimize flag is '
|
||||
'given.')
|
||||
p.add_argument('--json', metavar='PATH', help='write the full report as JSON')
|
||||
p.add_argument('--no-explain', action='store_true',
|
||||
help='skip the EXPLAIN pass')
|
||||
p.add_argument('--apply-indexes', action='store_true',
|
||||
help='CREATE the missing recommended indexes (idempotent)')
|
||||
p.add_argument('--analyze', action='store_true',
|
||||
help='ANALYZE TABLE on every table (safe, non-locking)')
|
||||
p.add_argument('--optimize', action='store_true',
|
||||
help='OPTIMIZE TABLE on every table -- LOCKS each table; '
|
||||
'needs --yes')
|
||||
p.add_argument('--emit-migration', metavar='PATH',
|
||||
help='write an Alembic migration for the missing indexes')
|
||||
p.add_argument('--revision', default='phaseNN_perf_indexes',
|
||||
help='revision id for --emit-migration (max 32 chars)')
|
||||
p.add_argument('--yes', action='store_true',
|
||||
help='confirm locking operations')
|
||||
p.add_argument('--fail-on', choices=['none', 'low', 'medium', 'high'],
|
||||
default='none',
|
||||
help='exit 1 when a finding of this severity or above exists')
|
||||
args = p.parse_args()
|
||||
|
||||
if args.optimize and not args.yes:
|
||||
print('--optimize rebuilds every table and holds a lock for the '
|
||||
'duration. Re-run with --yes inside a maintenance window.')
|
||||
return 2
|
||||
if len(args.revision) > 32:
|
||||
print('--revision must be <= 32 characters (alembic_version.version_num '
|
||||
'is VARCHAR(32)).')
|
||||
return 2
|
||||
|
||||
app = create_app(os.getenv('FLASK_ENV') or 'production')
|
||||
|
||||
with app.app_context():
|
||||
engine_opts = check_engine_options(app)
|
||||
|
||||
try:
|
||||
conn = db.engine.connect()
|
||||
except Exception as exc:
|
||||
print(_c(C.ERR, f'\nCould not connect to the database: {exc}'))
|
||||
return 2
|
||||
|
||||
with conn:
|
||||
if not is_mysql(conn):
|
||||
print(_c(C.ERR,
|
||||
f'\nThis script targets MySQL/MariaDB; the configured '
|
||||
f'database is "{conn.dialect.name}". Nothing to do.'))
|
||||
return 2
|
||||
|
||||
schema = db_name(conn)
|
||||
print(_c(C.BOLD, f'\nJQC database health check -- schema "{schema}" '
|
||||
f'@ {datetime.now():%Y-%m-%d %H:%M}'))
|
||||
|
||||
tables = fetch_tables(conn, schema)
|
||||
existing = fetch_indexes(conn, schema)
|
||||
fk_names, fk_cols = fetch_fk_protection(conn, schema)
|
||||
|
||||
server = check_server(conn, engine_opts, tables)
|
||||
check_schema_hygiene(conn, schema, tables)
|
||||
check_table_footprint(tables)
|
||||
missing = check_missing_indexes(conn, schema, existing)
|
||||
check_redundant_indexes(existing, fk_names, fk_cols)
|
||||
check_unindexed_fks(conn, schema, existing)
|
||||
if not args.no_explain:
|
||||
check_explain(conn)
|
||||
|
||||
# -- opt-in mutations ---------------------------------------------
|
||||
if args.apply_indexes:
|
||||
with conn.begin():
|
||||
apply_indexes(conn, schema, missing)
|
||||
if args.analyze:
|
||||
run_analyze(conn, tables)
|
||||
if args.optimize:
|
||||
run_optimize(conn, tables)
|
||||
|
||||
if args.emit_migration:
|
||||
emit_migration(args.emit_migration, missing,
|
||||
args.revision, current_head())
|
||||
|
||||
# -- summary ---------------------------------------------------------
|
||||
header('Summary')
|
||||
counts = {'high': 0, 'medium': 0, 'low': 0, 'info': 0}
|
||||
for f in FINDINGS:
|
||||
counts[f['severity']] += 1
|
||||
print(f' {_c(C.ERR, "high")} {counts["high"]} '
|
||||
f'{_c(C.WARN, "medium")} {counts["medium"]} '
|
||||
f'{_c(C.DIM, "low")} {counts["low"]} '
|
||||
f'{_c(C.DIM, "info")} {counts["info"]}')
|
||||
if not (args.apply_indexes or args.analyze or args.optimize):
|
||||
print(_c(C.DIM, ' (read-only run -- nothing was modified)'))
|
||||
|
||||
if args.json:
|
||||
report = {
|
||||
'generated_at': datetime.now().isoformat(timespec='seconds'),
|
||||
'schema': schema,
|
||||
'server': server,
|
||||
'engine_options': {k: str(v) for k, v in (engine_opts or {}).items()},
|
||||
'tables': tables,
|
||||
'missing_indexes': [
|
||||
{'table': t, 'name': n, 'columns': c,
|
||||
'priority': pr, 'why': w}
|
||||
for t, n, c, pr, w in missing
|
||||
],
|
||||
'findings': FINDINGS,
|
||||
'counts': counts,
|
||||
}
|
||||
with open(args.json, 'w', encoding='utf-8') as fh:
|
||||
json.dump(report, fh, indent=2, default=str)
|
||||
print(f' report written to {args.json}')
|
||||
|
||||
if args.fail_on != 'none':
|
||||
threshold = SEVERITY_ORDER[args.fail_on]
|
||||
if any(SEVERITY_ORDER[f['severity']] >= threshold for f in FINDINGS):
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,485 @@
|
||||
"""
|
||||
Tests for issue linking (duplicate / related).
|
||||
|
||||
Two things are worth testing here. The first is the link semantics: one stored
|
||||
row read from both sides, no self-links, no double-linking in either direction.
|
||||
|
||||
The second, and the reason this file is long, is SCOPE. A link is a pointer to
|
||||
another issue's id, description and facility, so it is a potential way to read
|
||||
an issue you have no access to. Three surfaces have to hold that line
|
||||
independently -- the panel that renders links, the search that finds candidates,
|
||||
and the POST that creates them -- and only the POST is a real boundary.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from app import db as _db
|
||||
from app.models.issue import Issue, IssueLink
|
||||
from app.models.project import Project, CustomerAssignment
|
||||
from app.models.inspector_assignment import InspectorAssignment
|
||||
from app.utils.time_utils import now_eastern
|
||||
|
||||
|
||||
# ── Fixtures ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture
|
||||
def make_issue(db):
|
||||
def _make(facility=None, area=None, severity='high', status='open', **kw):
|
||||
issue = Issue(
|
||||
facility_id=facility.id if facility else None,
|
||||
area_id=area.id if area else None,
|
||||
severity=severity,
|
||||
description=kw.pop('description', 'a leak under the sink'),
|
||||
status=status,
|
||||
reported_at=now_eastern(),
|
||||
)
|
||||
for k, v in kw.items():
|
||||
setattr(issue, k, v)
|
||||
db.session.add(issue)
|
||||
db.session.commit()
|
||||
return issue
|
||||
return _make
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_project(db):
|
||||
def _make(name='Contract A'):
|
||||
proj = Project(name=name, active=True)
|
||||
db.session.add(proj)
|
||||
db.session.commit()
|
||||
return proj
|
||||
return _make
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def link_url():
|
||||
def _make(issue):
|
||||
return f'/issues/{issue.id}/links'
|
||||
return _make
|
||||
|
||||
|
||||
# ── Model semantics ──────────────────────────────────────────────────────────
|
||||
|
||||
def test_a_link_reads_differently_from_each_end(app, db, make_issue, make_facility):
|
||||
"""One stored row; 'duplicate' is directional and says so on both issues."""
|
||||
facility = make_facility()
|
||||
dupe = make_issue(facility=facility)
|
||||
orig = make_issue(facility=facility)
|
||||
|
||||
db.session.add(IssueLink(issue_id=dupe.id, linked_issue_id=orig.id,
|
||||
link_type='duplicate'))
|
||||
db.session.commit()
|
||||
|
||||
link = IssueLink.query.one()
|
||||
assert link.label_for(dupe.id) == 'Duplicate of'
|
||||
assert link.label_for(orig.id) == 'Duplicated by'
|
||||
assert link.other_issue(dupe.id).id == orig.id
|
||||
assert link.other_issue(orig.id).id == dupe.id
|
||||
|
||||
|
||||
def test_related_reads_the_same_from_both_ends(app, db, make_issue, make_facility):
|
||||
facility = make_facility()
|
||||
a, b = make_issue(facility=facility), make_issue(facility=facility)
|
||||
db.session.add(IssueLink(issue_id=a.id, linked_issue_id=b.id,
|
||||
link_type='related'))
|
||||
db.session.commit()
|
||||
|
||||
link = IssueLink.query.one()
|
||||
assert link.label_for(a.id) == 'Related to'
|
||||
assert link.label_for(b.id) == 'Related to'
|
||||
|
||||
|
||||
def test_one_row_appears_on_both_issues(app, db, make_issue, make_facility):
|
||||
"""all_links() merges the two storage directions into one list."""
|
||||
facility = make_facility()
|
||||
a, b = make_issue(facility=facility), make_issue(facility=facility)
|
||||
db.session.add(IssueLink(issue_id=a.id, linked_issue_id=b.id,
|
||||
link_type='related'))
|
||||
db.session.commit()
|
||||
|
||||
assert len(a.all_links()) == 1
|
||||
assert len(b.all_links()) == 1
|
||||
assert IssueLink.query.count() == 1
|
||||
|
||||
|
||||
def test_exists_between_is_direction_agnostic(app, db, make_issue, make_facility):
|
||||
"""The stored UniqueConstraint only covers one direction -- this covers both."""
|
||||
facility = make_facility()
|
||||
a, b = make_issue(facility=facility), make_issue(facility=facility)
|
||||
db.session.add(IssueLink(issue_id=a.id, linked_issue_id=b.id,
|
||||
link_type='related'))
|
||||
db.session.commit()
|
||||
|
||||
assert IssueLink.exists_between(a.id, b.id)
|
||||
assert IssueLink.exists_between(b.id, a.id)
|
||||
assert not IssueLink.exists_between(a.id, 99999)
|
||||
|
||||
|
||||
def test_deleting_an_issue_removes_its_links_from_both_sides(
|
||||
app, db, make_issue, make_facility):
|
||||
"""A surviving link would render a dead row on the other issue's page."""
|
||||
facility = make_facility()
|
||||
a, b, c = (make_issue(facility=facility) for _ in range(3))
|
||||
db.session.add(IssueLink(issue_id=a.id, linked_issue_id=b.id, link_type='related'))
|
||||
db.session.add(IssueLink(issue_id=c.id, linked_issue_id=a.id, link_type='duplicate'))
|
||||
db.session.commit()
|
||||
assert IssueLink.query.count() == 2
|
||||
|
||||
db.session.delete(a) # the path both the single and bulk delete use
|
||||
db.session.commit()
|
||||
|
||||
assert IssueLink.query.count() == 0
|
||||
assert b.all_links() == []
|
||||
assert c.all_links() == []
|
||||
|
||||
|
||||
# ── Creating links through the route ─────────────────────────────────────────
|
||||
|
||||
def test_admin_can_link_two_issues(client, login, make_user, make_facility,
|
||||
make_issue, link_url):
|
||||
facility = make_facility()
|
||||
a, b = make_issue(facility=facility), make_issue(facility=facility)
|
||||
login(make_user(role='admin'))
|
||||
|
||||
res = client.post(link_url(a),
|
||||
data={'link_type': 'duplicate', 'linked_issue_id': str(b.id)},
|
||||
follow_redirects=True)
|
||||
assert res.status_code == 200
|
||||
link = IssueLink.query.one()
|
||||
assert (link.issue_id, link.linked_issue_id) == (a.id, b.id)
|
||||
assert link.link_type == 'duplicate'
|
||||
|
||||
|
||||
def test_a_leading_hash_is_accepted(client, login, make_user, make_facility,
|
||||
make_issue, link_url):
|
||||
"""People type '#412' because that is how the id is shown everywhere."""
|
||||
facility = make_facility()
|
||||
a, b = make_issue(facility=facility), make_issue(facility=facility)
|
||||
login(make_user(role='admin'))
|
||||
|
||||
client.post(link_url(a),
|
||||
data={'link_type': 'related', 'linked_issue_id': f'#{b.id}'},
|
||||
follow_redirects=True)
|
||||
assert IssueLink.query.count() == 1
|
||||
|
||||
|
||||
def test_linking_does_not_touch_either_issue(client, login, make_user,
|
||||
make_facility, make_issue, link_url):
|
||||
"""The chosen design: a link is navigation, not a workflow action."""
|
||||
facility = make_facility()
|
||||
a = make_issue(facility=facility, status='open')
|
||||
b = make_issue(facility=facility, status='open')
|
||||
login(make_user(role='admin'))
|
||||
|
||||
client.post(link_url(a),
|
||||
data={'link_type': 'duplicate', 'linked_issue_id': str(b.id)},
|
||||
follow_redirects=True)
|
||||
|
||||
_db.session.refresh(a)
|
||||
_db.session.refresh(b)
|
||||
assert a.status == 'open' and a.resolved_at is None and a.assigned_to is None
|
||||
assert b.status == 'open' and b.resolved_at is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize('payload', [
|
||||
{'link_type': 'related', 'linked_issue_id': 'abc'},
|
||||
{'link_type': 'related', 'linked_issue_id': ''},
|
||||
{'link_type': 'nonsense', 'linked_issue_id': '1'},
|
||||
{'link_type': '', 'linked_issue_id': '1'},
|
||||
{},
|
||||
])
|
||||
def test_malformed_link_requests_are_rejected_without_error(
|
||||
client, login, make_user, make_facility, make_issue, link_url, payload):
|
||||
facility = make_facility()
|
||||
a = make_issue(facility=facility)
|
||||
make_issue(facility=facility)
|
||||
login(make_user(role='admin'))
|
||||
|
||||
res = client.post(link_url(a), data=payload, follow_redirects=True)
|
||||
assert res.status_code == 200
|
||||
assert IssueLink.query.count() == 0
|
||||
|
||||
|
||||
def test_an_issue_cannot_be_linked_to_itself(client, login, make_user,
|
||||
make_facility, make_issue, link_url):
|
||||
facility = make_facility()
|
||||
a = make_issue(facility=facility)
|
||||
login(make_user(role='admin'))
|
||||
|
||||
client.post(link_url(a),
|
||||
data={'link_type': 'related', 'linked_issue_id': str(a.id)},
|
||||
follow_redirects=True)
|
||||
assert IssueLink.query.count() == 0
|
||||
|
||||
|
||||
def test_the_same_pair_cannot_be_linked_twice_in_either_direction(
|
||||
client, login, make_user, make_facility, make_issue, link_url):
|
||||
facility = make_facility()
|
||||
a, b = make_issue(facility=facility), make_issue(facility=facility)
|
||||
login(make_user(role='admin'))
|
||||
|
||||
client.post(link_url(a), data={'link_type': 'related',
|
||||
'linked_issue_id': str(b.id)},
|
||||
follow_redirects=True)
|
||||
client.post(link_url(a), data={'link_type': 'duplicate',
|
||||
'linked_issue_id': str(b.id)},
|
||||
follow_redirects=True)
|
||||
client.post(link_url(b), data={'link_type': 'duplicate',
|
||||
'linked_issue_id': str(a.id)},
|
||||
follow_redirects=True)
|
||||
|
||||
assert IssueLink.query.count() == 1
|
||||
|
||||
|
||||
def test_unlinking_removes_the_row_and_works_from_either_end(
|
||||
client, login, make_user, make_facility, make_issue, db):
|
||||
facility = make_facility()
|
||||
a, b = make_issue(facility=facility), make_issue(facility=facility)
|
||||
db.session.add(IssueLink(issue_id=a.id, linked_issue_id=b.id,
|
||||
link_type='related'))
|
||||
db.session.commit()
|
||||
link_id = IssueLink.query.one().id
|
||||
|
||||
login(make_user(role='admin'))
|
||||
# from the far end, which is the row's linked_issue_id
|
||||
client.post(f'/issues/{b.id}/links/{link_id}/delete', follow_redirects=True)
|
||||
assert IssueLink.query.count() == 0
|
||||
|
||||
|
||||
def test_cannot_delete_a_link_between_two_other_issues(
|
||||
client, login, make_user, make_facility, make_issue, db):
|
||||
"""The link id is posted by the client, so it must be checked against
|
||||
the issue in the URL -- otherwise any link is deletable from anywhere."""
|
||||
facility = make_facility()
|
||||
a, b, unrelated = (make_issue(facility=facility) for _ in range(3))
|
||||
db.session.add(IssueLink(issue_id=a.id, linked_issue_id=b.id,
|
||||
link_type='related'))
|
||||
db.session.commit()
|
||||
link_id = IssueLink.query.one().id
|
||||
|
||||
login(make_user(role='admin'))
|
||||
client.post(f'/issues/{unrelated.id}/links/{link_id}/delete',
|
||||
follow_redirects=True)
|
||||
assert IssueLink.query.count() == 1
|
||||
|
||||
|
||||
# ── Permission ───────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.parametrize('role', ['admin', 'director', 'auditor'])
|
||||
def test_issue_managers_may_link(client, login, make_user, make_facility,
|
||||
make_issue, link_url, role):
|
||||
facility = make_facility()
|
||||
a, b = make_issue(facility=facility), make_issue(facility=facility)
|
||||
login(make_user(role=role))
|
||||
client.post(link_url(a), data={'link_type': 'related',
|
||||
'linked_issue_id': str(b.id)},
|
||||
follow_redirects=True)
|
||||
assert IssueLink.query.count() == 1
|
||||
|
||||
|
||||
def test_the_assignee_may_link_their_own_issue(client, login, make_user, db,
|
||||
make_facility, make_issue,
|
||||
make_project, link_url):
|
||||
project = make_project()
|
||||
facility = make_facility(project=project)
|
||||
inspector = make_user(role='inspector')
|
||||
db.session.add(InspectorAssignment(user_id=inspector.id, project_id=project.id))
|
||||
db.session.commit()
|
||||
|
||||
a = make_issue(facility=facility, assigned_to=inspector.id)
|
||||
b = make_issue(facility=facility)
|
||||
|
||||
login(inspector)
|
||||
client.post(link_url(a), data={'link_type': 'related',
|
||||
'linked_issue_id': str(b.id)},
|
||||
follow_redirects=True)
|
||||
assert IssueLink.query.count() == 1
|
||||
|
||||
|
||||
def test_a_project_manager_may_not_link(client, login, make_user,
|
||||
make_facility, make_issue, link_url):
|
||||
"""Matches the page's existing can_edit set. Widening this is a decision,
|
||||
not an accident -- if it changes, issues/view.html must change too."""
|
||||
facility = make_facility()
|
||||
a, b = make_issue(facility=facility), make_issue(facility=facility)
|
||||
login(make_user(role='project_manager'))
|
||||
|
||||
res = client.post(link_url(a), data={'link_type': 'related',
|
||||
'linked_issue_id': str(b.id)})
|
||||
assert res.status_code == 403
|
||||
assert IssueLink.query.count() == 0
|
||||
|
||||
|
||||
def test_a_customer_may_not_link_even_on_their_own_facility(
|
||||
client, login, make_user, db, make_facility, make_issue,
|
||||
make_project, link_url):
|
||||
project = make_project()
|
||||
facility = make_facility(project=project)
|
||||
customer = make_user(role='customer')
|
||||
db.session.add(CustomerAssignment(user_id=customer.id, project_id=project.id))
|
||||
db.session.commit()
|
||||
|
||||
a, b = make_issue(facility=facility), make_issue(facility=facility)
|
||||
|
||||
login(customer)
|
||||
res = client.post(link_url(a), data={'link_type': 'related',
|
||||
'linked_issue_id': str(b.id)})
|
||||
assert res.status_code == 403
|
||||
assert IssueLink.query.count() == 0
|
||||
|
||||
|
||||
# ── Scope: the part that actually matters ────────────────────────────────────
|
||||
|
||||
def test_cannot_link_to_an_issue_outside_your_scope(
|
||||
client, login, make_user, db, make_facility, make_issue,
|
||||
make_project, link_url):
|
||||
"""An inspector must not be able to attach another contract's issue."""
|
||||
mine = make_project('Mine')
|
||||
theirs = make_project('Theirs')
|
||||
my_facility = make_facility(project=mine)
|
||||
their_facility = make_facility(project=theirs)
|
||||
|
||||
inspector = make_user(role='inspector')
|
||||
db.session.add(InspectorAssignment(user_id=inspector.id, project_id=mine.id))
|
||||
db.session.commit()
|
||||
|
||||
a = make_issue(facility=my_facility, assigned_to=inspector.id)
|
||||
out_of_scope = make_issue(facility=their_facility,
|
||||
description='another contract')
|
||||
|
||||
login(inspector)
|
||||
res = client.post(link_url(a),
|
||||
data={'link_type': 'related',
|
||||
'linked_issue_id': str(out_of_scope.id)},
|
||||
follow_redirects=True)
|
||||
|
||||
assert IssueLink.query.count() == 0
|
||||
# And the refusal must not confirm the issue exists.
|
||||
body = res.get_data(as_text=True)
|
||||
assert 'another contract' not in body
|
||||
|
||||
|
||||
def test_the_panel_hides_a_link_whose_far_end_is_out_of_scope(
|
||||
client, login, make_user, db, make_facility, make_issue, make_project):
|
||||
"""An admin can link across contracts. A customer at one end must still not
|
||||
read the issue at the other."""
|
||||
mine = make_project('Mine')
|
||||
theirs = make_project('Theirs')
|
||||
my_facility = make_facility(project=mine)
|
||||
their_facility = make_facility(project=theirs)
|
||||
|
||||
customer = make_user(role='customer')
|
||||
db.session.add(CustomerAssignment(user_id=customer.id, project_id=mine.id))
|
||||
db.session.commit()
|
||||
|
||||
visible = make_issue(facility=my_facility, description='my own issue')
|
||||
hidden = make_issue(facility=their_facility,
|
||||
description='SECRET other customer issue')
|
||||
db.session.add(IssueLink(issue_id=visible.id, linked_issue_id=hidden.id,
|
||||
link_type='related'))
|
||||
db.session.commit()
|
||||
|
||||
login(customer)
|
||||
body = client.get(f'/issues/{visible.id}').get_data(as_text=True)
|
||||
|
||||
assert 'my own issue' in body
|
||||
assert 'SECRET other customer issue' not in body
|
||||
assert f'/issues/{hidden.id}' not in body
|
||||
|
||||
|
||||
def test_link_search_is_scoped(client, login, make_user, db, make_facility,
|
||||
make_issue, make_project):
|
||||
mine = make_project('Mine')
|
||||
theirs = make_project('Theirs')
|
||||
my_facility = make_facility(project=mine)
|
||||
their_facility = make_facility(project=theirs)
|
||||
|
||||
inspector = make_user(role='inspector')
|
||||
db.session.add(InspectorAssignment(user_id=inspector.id, project_id=mine.id))
|
||||
db.session.commit()
|
||||
|
||||
a = make_issue(facility=my_facility, description='mine mine mine')
|
||||
findable = make_issue(facility=my_facility, description='findable mine')
|
||||
hidden = make_issue(facility=their_facility, description='findable theirs')
|
||||
|
||||
login(inspector)
|
||||
data = client.get(f'/issues/{a.id}/link-search?q=findable').get_json()
|
||||
ids = {r['id'] for r in data['results']}
|
||||
|
||||
assert findable.id in ids
|
||||
assert hidden.id not in ids
|
||||
|
||||
|
||||
def test_link_search_excludes_self_and_already_linked(
|
||||
client, login, make_user, db, make_facility, make_issue):
|
||||
facility = make_facility()
|
||||
a = make_issue(facility=facility, description='widget one')
|
||||
already = make_issue(facility=facility, description='widget two')
|
||||
free = make_issue(facility=facility, description='widget three')
|
||||
db.session.add(IssueLink(issue_id=a.id, linked_issue_id=already.id,
|
||||
link_type='related'))
|
||||
db.session.commit()
|
||||
|
||||
login(make_user(role='admin'))
|
||||
data = client.get(f'/issues/{a.id}/link-search?q=widget').get_json()
|
||||
ids = {r['id'] for r in data['results']}
|
||||
|
||||
assert ids == {free.id}
|
||||
|
||||
|
||||
def test_link_search_finds_by_issue_number(client, login, make_user,
|
||||
make_facility, make_issue):
|
||||
facility = make_facility()
|
||||
a = make_issue(facility=facility)
|
||||
target = make_issue(facility=facility, description='nothing in common')
|
||||
|
||||
login(make_user(role='admin'))
|
||||
data = client.get(f'/issues/{a.id}/link-search?q={target.id}').get_json()
|
||||
assert target.id in {r['id'] for r in data['results']}
|
||||
|
||||
|
||||
def test_link_search_returns_nothing_for_an_unscoped_inspector(
|
||||
client, login, make_user, make_facility, make_issue):
|
||||
"""Strict scoping: no assignments means no candidates, not all of them."""
|
||||
facility = make_facility()
|
||||
a = make_issue(facility=facility, description='findable')
|
||||
make_issue(facility=facility, description='findable too')
|
||||
|
||||
inspector = make_user(role='inspector')
|
||||
login(inspector)
|
||||
# No InspectorAssignment -> cannot even open the issue.
|
||||
assert client.get(f'/issues/{a.id}/link-search?q=findable').status_code == 403
|
||||
|
||||
|
||||
# ── Rendering ────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_the_panel_renders_both_directions(client, login, make_user, db,
|
||||
make_facility, make_issue):
|
||||
facility = make_facility()
|
||||
subject = make_issue(facility=facility, description='the one being viewed')
|
||||
original = make_issue(facility=facility, description='the original')
|
||||
other = make_issue(facility=facility, description='a related thing')
|
||||
|
||||
db.session.add(IssueLink(issue_id=subject.id, linked_issue_id=original.id,
|
||||
link_type='duplicate'))
|
||||
db.session.add(IssueLink(issue_id=other.id, linked_issue_id=subject.id,
|
||||
link_type='duplicate'))
|
||||
db.session.commit()
|
||||
|
||||
login(make_user(role='admin'))
|
||||
body = client.get(f'/issues/{subject.id}').get_data(as_text=True)
|
||||
|
||||
assert 'Duplicate of' in body # subject -> original
|
||||
assert 'Duplicated by' in body # other -> subject
|
||||
assert f'/issues/{original.id}' in body
|
||||
assert f'/issues/{other.id}' in body
|
||||
|
||||
|
||||
def test_the_panel_renders_with_no_links(client, login, make_user,
|
||||
make_facility, make_issue):
|
||||
facility = make_facility()
|
||||
issue = make_issue(facility=facility)
|
||||
login(make_user(role='admin'))
|
||||
|
||||
body = client.get(f'/issues/{issue.id}').get_data(as_text=True)
|
||||
assert 'Linked Issues' in body
|
||||
assert 'No linked issues' in body
|
||||
@@ -0,0 +1,277 @@
|
||||
"""
|
||||
Smoke coverage for the pages whose queries were rewritten during the database
|
||||
optimization pass: the dashboard, the issues list, and the reports overview.
|
||||
|
||||
These routes had no request-level tests, so a query rewrite that produced
|
||||
invalid SQL or a broken scope would only have surfaced in production. Each test
|
||||
here drives the real route through the test client for a role that exercises a
|
||||
DIFFERENT branch of the scoping code:
|
||||
|
||||
admin -- unscoped branch
|
||||
inspector -- InspectorAssignment / get_inspector_scope() branch
|
||||
customer -- CustomerAssignment / get_customer_scope() branch
|
||||
|
||||
The assertions are deliberately about behaviour that must hold (status code,
|
||||
scope isolation), not about markup.
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from app import db as _db
|
||||
from app.models.issue import Issue
|
||||
from app.models.project import Project, CustomerAssignment
|
||||
from app.models.inspector_assignment import InspectorAssignment
|
||||
from app.utils.time_utils import now_eastern
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_issue(db):
|
||||
def _make(facility=None, area=None, severity='high', status='open',
|
||||
hours_ago=1, **kw):
|
||||
issue = Issue(
|
||||
facility_id=facility.id if facility else None,
|
||||
area_id=area.id if area else None,
|
||||
severity=severity,
|
||||
description=kw.pop('description', 'test issue'),
|
||||
status=status,
|
||||
reported_at=now_eastern() - timedelta(hours=hours_ago),
|
||||
)
|
||||
for k, v in kw.items():
|
||||
setattr(issue, k, v)
|
||||
db.session.add(issue)
|
||||
db.session.commit()
|
||||
return issue
|
||||
return _make
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project(db):
|
||||
proj = Project(name='Contract A', active=True)
|
||||
db.session.add(proj)
|
||||
db.session.commit()
|
||||
return proj
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def other_project(db):
|
||||
proj = Project(name='Contract B', active=True)
|
||||
db.session.add(proj)
|
||||
db.session.commit()
|
||||
return proj
|
||||
|
||||
|
||||
# ── Dashboard ────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.parametrize('role', ['admin', 'director', 'project_manager', 'auditor'])
|
||||
def test_dashboard_renders_for_staff_roles(client, login, make_user,
|
||||
make_facility, make_issue,
|
||||
project, role):
|
||||
"""The card queries (severity / handler / SLA splits) build and run."""
|
||||
facility = make_facility(project=project)
|
||||
make_issue(facility=facility, severity='critical', hours_ago=100)
|
||||
make_issue(facility=facility, severity='low', status='in_progress')
|
||||
make_issue(facility=facility, status='resolved',
|
||||
resolved_at=now_eastern())
|
||||
|
||||
login(make_user(role=role))
|
||||
assert client.get('/').status_code == 200
|
||||
|
||||
|
||||
def test_dashboard_renders_for_scoped_inspector(client, login, make_user, db,
|
||||
make_facility, make_area,
|
||||
make_issue, project):
|
||||
inspector = make_user(role='inspector')
|
||||
db.session.add(InspectorAssignment(user_id=inspector.id,
|
||||
project_id=project.id))
|
||||
db.session.commit()
|
||||
|
||||
facility = make_facility(project=project)
|
||||
area = make_area(facility)
|
||||
make_issue(area=area, severity='high', hours_ago=50) # reached via area
|
||||
make_issue(facility=facility, severity='medium') # reached directly
|
||||
|
||||
login(inspector)
|
||||
assert client.get('/').status_code == 200
|
||||
|
||||
|
||||
def test_dashboard_renders_for_inspector_with_no_assignments(client, login,
|
||||
make_user):
|
||||
"""Strict scoping: no assignments must render an empty dashboard, not 500."""
|
||||
login(make_user(role='inspector'))
|
||||
assert client.get('/').status_code == 200
|
||||
|
||||
|
||||
def test_dashboard_renders_for_customer(client, login, make_user, db,
|
||||
make_facility, make_issue, project):
|
||||
customer = make_user(role='customer')
|
||||
facility = make_facility(project=project)
|
||||
db.session.add(CustomerAssignment(user_id=customer.id,
|
||||
project_id=project.id))
|
||||
db.session.commit()
|
||||
make_issue(facility=facility)
|
||||
|
||||
login(customer)
|
||||
assert client.get('/').status_code == 200
|
||||
|
||||
|
||||
# ── Issues list ──────────────────────────────────────────────────────────────
|
||||
|
||||
def test_issues_list_renders(client, login, make_user, make_facility,
|
||||
make_issue, project):
|
||||
facility = make_facility(project=project)
|
||||
make_issue(facility=facility)
|
||||
login(make_user(role='admin'))
|
||||
assert client.get('/issues/').status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.parametrize('query', [
|
||||
'?facility_id=abc', # non-numeric -- used to raise ValueError -> 500
|
||||
'?facility_id=',
|
||||
'?facility_id=999999', # numeric but nonexistent
|
||||
'?contract_id=abc',
|
||||
'?issue_id=abc',
|
||||
'?severity=high&status=open',
|
||||
'?handler_type=vendor',
|
||||
'?unassigned=1',
|
||||
'?sla=breached',
|
||||
'?date_from=notadate&date_to=alsonot',
|
||||
])
|
||||
def test_issues_list_survives_malformed_filters(client, login, make_user,
|
||||
make_facility, make_issue,
|
||||
project, query):
|
||||
"""A hand-edited or stale query string must never 500 the list."""
|
||||
facility = make_facility(project=project)
|
||||
make_issue(facility=facility)
|
||||
login(make_user(role='admin'))
|
||||
assert client.get('/issues/' + query).status_code == 200
|
||||
|
||||
|
||||
def test_issues_list_scopes_a_customer_to_their_own_facilities(
|
||||
client, login, make_user, db, make_facility, make_issue,
|
||||
project, other_project):
|
||||
"""The scope filter still isolates customers after the query rewrite."""
|
||||
customer = make_user(role='customer')
|
||||
mine = make_facility(project=project, name='Mine')
|
||||
theirs = make_facility(project=other_project, name='Theirs')
|
||||
db.session.add(CustomerAssignment(user_id=customer.id,
|
||||
project_id=project.id))
|
||||
db.session.commit()
|
||||
|
||||
make_issue(facility=mine, description='visible to me')
|
||||
make_issue(facility=theirs, description='other customer only')
|
||||
|
||||
login(customer)
|
||||
body = client.get('/issues/').get_data(as_text=True)
|
||||
assert 'visible to me' in body
|
||||
assert 'other customer only' not in body
|
||||
|
||||
|
||||
# ── Issue detail scope gate ──────────────────────────────────────────────────
|
||||
# issues.view() had two inline scope blocks; they were collapsed into
|
||||
# _issue_readable_by() so the linked-issues panel could reuse the same rule.
|
||||
# These pin the behaviour that gate must keep.
|
||||
|
||||
def test_issue_view_allows_an_inspector_inside_their_contract(
|
||||
client, login, make_user, db, make_facility, make_issue, project):
|
||||
inspector = make_user(role='inspector')
|
||||
db.session.add(InspectorAssignment(user_id=inspector.id,
|
||||
project_id=project.id))
|
||||
db.session.commit()
|
||||
issue = make_issue(facility=make_facility(project=project))
|
||||
|
||||
login(inspector)
|
||||
assert client.get(f'/issues/{issue.id}').status_code == 200
|
||||
|
||||
|
||||
def test_issue_view_denies_an_inspector_outside_their_contract(
|
||||
client, login, make_user, db, make_facility, make_issue,
|
||||
project, other_project):
|
||||
inspector = make_user(role='inspector')
|
||||
db.session.add(InspectorAssignment(user_id=inspector.id,
|
||||
project_id=project.id))
|
||||
db.session.commit()
|
||||
issue = make_issue(facility=make_facility(project=other_project),
|
||||
description='not yours')
|
||||
|
||||
login(inspector)
|
||||
res = client.get(f'/issues/{issue.id}', follow_redirects=True)
|
||||
assert 'not yours' not in res.get_data(as_text=True)
|
||||
|
||||
|
||||
def test_issue_view_denies_an_inspector_with_no_assignments(
|
||||
client, login, make_user, make_facility, make_issue, project):
|
||||
"""Strict scoping: no assignments means no access, not full access."""
|
||||
issue = make_issue(facility=make_facility(project=project),
|
||||
description='strictly scoped')
|
||||
login(make_user(role='inspector'))
|
||||
res = client.get(f'/issues/{issue.id}', follow_redirects=True)
|
||||
assert 'strictly scoped' not in res.get_data(as_text=True)
|
||||
|
||||
|
||||
def test_issue_view_denies_a_customer_outside_their_contract(
|
||||
client, login, make_user, db, make_facility, make_issue,
|
||||
project, other_project):
|
||||
customer = make_user(role='customer')
|
||||
db.session.add(CustomerAssignment(user_id=customer.id,
|
||||
project_id=project.id))
|
||||
db.session.commit()
|
||||
issue = make_issue(facility=make_facility(project=other_project),
|
||||
description='other customer only')
|
||||
|
||||
login(customer)
|
||||
res = client.get(f'/issues/{issue.id}', follow_redirects=True)
|
||||
assert 'other customer only' not in res.get_data(as_text=True)
|
||||
|
||||
|
||||
def test_issue_view_allows_a_customer_inside_their_contract(
|
||||
client, login, make_user, db, make_facility, make_issue, project):
|
||||
customer = make_user(role='customer')
|
||||
db.session.add(CustomerAssignment(user_id=customer.id,
|
||||
project_id=project.id))
|
||||
db.session.commit()
|
||||
issue = make_issue(facility=make_facility(project=project),
|
||||
description='mine to read')
|
||||
|
||||
login(customer)
|
||||
res = client.get(f'/issues/{issue.id}')
|
||||
assert res.status_code == 200
|
||||
assert 'mine to read' in res.get_data(as_text=True)
|
||||
|
||||
|
||||
# ── Reports overview ─────────────────────────────────────────────────────────
|
||||
|
||||
def test_reports_overview_renders_for_admin(client, login, make_user,
|
||||
make_facility, make_template,
|
||||
make_inspection, project):
|
||||
admin = make_user(role='admin')
|
||||
inspector = make_user(role='inspector')
|
||||
facility = make_facility(project=project)
|
||||
make_inspection(facility, inspector, make_template())
|
||||
|
||||
login(admin)
|
||||
assert client.get('/reports/').status_code == 200
|
||||
|
||||
|
||||
def test_reports_overview_inspector_scope_uses_subquery(
|
||||
client, login, make_user, db, make_facility, make_template,
|
||||
make_inspection, make_issue, project):
|
||||
"""_scope_issue() now filters via a subquery instead of an id list."""
|
||||
inspector = make_user(role='inspector')
|
||||
db.session.add(InspectorAssignment(user_id=inspector.id,
|
||||
project_id=project.id))
|
||||
db.session.commit()
|
||||
|
||||
facility = make_facility(project=project)
|
||||
insp = make_inspection(facility, inspector, make_template())
|
||||
make_issue(facility=facility, inspection_id=insp.id)
|
||||
|
||||
login(inspector)
|
||||
assert client.get('/reports/').status_code == 200
|
||||
|
||||
|
||||
def test_reports_overview_inspector_with_no_inspections(client, login,
|
||||
make_user):
|
||||
"""IN (empty subquery) must match nothing rather than error."""
|
||||
login(make_user(role='inspector'))
|
||||
assert client.get('/reports/').status_code == 200
|
||||
@@ -0,0 +1,165 @@
|
||||
"""
|
||||
Equivalence test for the SQL prefilter in send_sla_alerts().
|
||||
|
||||
The cron used to read EVERY open issue and decide in Python which ones deserved
|
||||
a notification. It now narrows to candidates in SQL first.
|
||||
|
||||
The contract is a CONSERVATIVE SUPERSET, not equality:
|
||||
|
||||
* Nothing the old loop would have notified may be missed. This is the safety
|
||||
property -- a miss is a silently unsent SLA alert.
|
||||
* The SQL may return extra rows, because it deliberately does not replicate
|
||||
the "already notified at_risk and still only at_risk" skip (that would mean
|
||||
writing the per-severity deadline arithmetic a second time, in SQL). The
|
||||
Python loop still applies that skip, so no extra notification is ever sent
|
||||
-- only a handful of extra rows are read.
|
||||
|
||||
The reference predicate below is the old loop's skip logic, written out.
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from app import db
|
||||
from app.models.issue import Issue
|
||||
from app.utils.sla import SLA_HOURS, AT_RISK_THRESHOLD, sla_status
|
||||
from app.utils.time_utils import now_eastern
|
||||
|
||||
|
||||
def _candidate_query():
|
||||
"""The prefilter exactly as send_sla_alerts() builds it."""
|
||||
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()
|
||||
]
|
||||
return Issue.query.filter(
|
||||
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),
|
||||
)
|
||||
|
||||
|
||||
def _old_loop_would_act(issue):
|
||||
"""The pre-change Python logic: which rows survived to send a notification."""
|
||||
if issue.status not in ('open', 'in_progress', 'pending_verification'):
|
||||
return False
|
||||
if issue.reported_at is None:
|
||||
return False # would have raised TypeError -- see the NULL test
|
||||
status = sla_status(issue)
|
||||
if status not in ('at_risk', 'breached'):
|
||||
return False
|
||||
already = issue.sla_notified
|
||||
if already == 'breached':
|
||||
return False
|
||||
if already == 'at_risk' and status == 'at_risk':
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _add(severity, hours_ago, status='open', sla_notified=None):
|
||||
issue = Issue(
|
||||
severity=severity,
|
||||
description='x',
|
||||
status=status,
|
||||
sla_notified=sla_notified,
|
||||
reported_at=now_eastern() - timedelta(hours=hours_ago),
|
||||
)
|
||||
db.session.add(issue)
|
||||
return issue
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def population(app):
|
||||
"""One issue per interesting combination of severity / age / state."""
|
||||
rows = []
|
||||
for severity, window in SLA_HOURS.items():
|
||||
at_risk_at = window * AT_RISK_THRESHOLD
|
||||
rows += [
|
||||
_add(severity, 0), # fresh -> ok
|
||||
_add(severity, at_risk_at * 0.5), # halfway -> ok
|
||||
_add(severity, at_risk_at + 1), # at risk
|
||||
_add(severity, window + 1), # breached
|
||||
# already-notified variants
|
||||
_add(severity, at_risk_at + 1, sla_notified='at_risk'),
|
||||
_add(severity, window + 1, sla_notified='at_risk'),
|
||||
_add(severity, window + 1, sla_notified='breached'),
|
||||
# non-actionable / other statuses
|
||||
_add(severity, window + 1, status='resolved'),
|
||||
_add(severity, window + 1, status='in_progress'),
|
||||
_add(severity, window + 1, status='pending_verification'),
|
||||
]
|
||||
db.session.commit()
|
||||
return rows
|
||||
|
||||
|
||||
def test_prefilter_never_misses_an_actionable_issue(population):
|
||||
"""Safety property: every row the old loop notified is still selected."""
|
||||
expected = {i.id for i in population if _old_loop_would_act(i)}
|
||||
actual = {i.id for i in _candidate_query().all()}
|
||||
|
||||
assert expected, 'fixture built no actionable issues -- test is vacuous'
|
||||
assert expected <= actual, (
|
||||
'the prefilter drops issues the old loop would have alerted on: '
|
||||
f'{sorted(expected - actual)}'
|
||||
)
|
||||
|
||||
|
||||
def test_prefilter_extras_are_all_skipped_by_the_loop(population):
|
||||
"""The extra rows the SQL lets through produce no extra notifications.
|
||||
|
||||
Each must be a row the Python loop independently skips, so the set of alerts
|
||||
actually sent is unchanged.
|
||||
"""
|
||||
by_id = {i.id: i for i in population}
|
||||
expected = {i.id for i in population if _old_loop_would_act(i)}
|
||||
extras = {i.id for i in _candidate_query().all()} - expected
|
||||
|
||||
for iid in extras:
|
||||
issue = by_id[iid]
|
||||
assert not _old_loop_would_act(issue)
|
||||
# and the only reason it is allowed through is the at_risk bookkeeping
|
||||
assert issue.sla_notified == 'at_risk' and sla_status(issue) == 'at_risk', (
|
||||
f'issue {iid} is an unexplained extra: status={issue.status} '
|
||||
f'severity={issue.severity} sla_notified={issue.sla_notified} '
|
||||
f'sla_status={sla_status(issue)}'
|
||||
)
|
||||
|
||||
|
||||
def test_prefilter_excludes_the_bulk_of_open_issues(population):
|
||||
"""The point of the change: most open issues are never read."""
|
||||
total_open = Issue.query.filter(
|
||||
Issue.status.in_(['open', 'in_progress', 'pending_verification'])
|
||||
).count()
|
||||
candidates = _candidate_query().count()
|
||||
assert candidates < total_open
|
||||
|
||||
|
||||
def test_null_reported_at_is_excluded_not_crashed(app):
|
||||
"""reported_at is nullable and sla_status() raises TypeError on NULL.
|
||||
|
||||
Before the prefilter one such row aborted the whole cron run; it must now be
|
||||
excluded in SQL instead.
|
||||
"""
|
||||
issue = Issue(severity='high', description='x', status='open')
|
||||
db.session.add(issue)
|
||||
db.session.commit()
|
||||
|
||||
# The column carries default=now_eastern, so an ORM insert can never leave
|
||||
# it NULL -- the row has to be forced, which is exactly how a legacy or
|
||||
# raw-SQL-inserted row would look.
|
||||
db.session.execute(
|
||||
db.text('UPDATE issues SET reported_at = NULL WHERE id = :i'),
|
||||
{'i': issue.id},
|
||||
)
|
||||
db.session.commit()
|
||||
db.session.expire(issue)
|
||||
|
||||
assert issue.reported_at is None
|
||||
assert issue.id not in {i.id for i in _candidate_query().all()}
|
||||
with pytest.raises(TypeError):
|
||||
sla_status(issue) # confirms the crash the filter is avoiding
|
||||
Reference in New Issue
Block a user