Compare commits
45
Commits
aa749107c7
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9005b9b9c | ||
|
|
d291dfc513 | ||
|
|
2d68bad966 | ||
|
|
3bfd81c84c | ||
|
|
d04190ba09 | ||
|
|
cb7c244872 | ||
|
|
6371b13c13 | ||
|
|
54a4a44bae | ||
|
|
8b2582705d | ||
|
|
a4a8d84801 | ||
|
|
7efc9bb572 | ||
|
|
36d1e62151 | ||
|
|
14535ca422 | ||
|
|
291d566f17 | ||
|
|
88af636912 | ||
|
|
12141c2f75 | ||
|
|
c9984e7ae6 | ||
|
|
3c35835505 | ||
|
|
38ab66d021 | ||
|
|
f3eb4badef | ||
|
|
513f708ee9 | ||
|
|
6ca30c0dea | ||
|
|
97c1dec54d | ||
|
|
9bd6b364b7 | ||
|
|
be5484e1fb | ||
|
|
45ad924df8 | ||
|
|
0b20e16e1f | ||
|
|
f4c80cfcef | ||
|
|
73ed0157fc | ||
|
|
3c2489e289 | ||
|
|
ceb0b806af | ||
|
|
d9bf4be709 | ||
|
|
60a9af106a | ||
|
|
bd994e75e6 | ||
|
|
7c064b6ca1 | ||
|
|
321965038d | ||
|
|
c706489480 | ||
|
|
8d9730e3df | ||
|
|
d44d761706 | ||
|
|
253291d5a4 | ||
|
|
02b030b1d2 | ||
|
|
a60afc6c41 | ||
|
|
8ff38578ad | ||
|
|
99d966e6ad | ||
|
|
5bd30aeaa0 |
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> **Audience:** AI assistants and developers working on this codebase.
|
> **Audience:** AI assistants and developers working on this codebase.
|
||||||
> **Purpose:** Authoritative reference for architecture, conventions, gotchas, and decisions.
|
> **Purpose:** Authoritative reference for architecture, conventions, gotchas, and decisions.
|
||||||
> **Last reviewed:** July 2026 (doc-reconciliation pass — verified against code on disk. Adds previously-undocumented phase28 notify-fix, phase29 broadcasts, phase30–32 device registry; `broadcast` + `devices` + `api_devices` blueprints; Broadcast + DeviceRegistration models; corrected MT-8 billing status to DONE; resolved the device-registration collision (rule 84 — removed duplicate `api_devices` blueprint + `DeviceRegistration` model, consolidated on `DeviceToken`). Prior: Phase 19 + mobile API Phases A–E + Phase 22 comment visibility + Phase 23 support chat/tickets + inspector Excel export + inspection list filters + customer issue logging + AI chatbot + dashboard grouped sections + issues/inspections PDF export + Reports R1–R4 + Phase 24 notify defaults + Phase 25 GPS + Phase 26 vendor fields + Phase 27 score alerts + **MT-0 through MT-8 complete; self-service signup; trial enforcement; billing emails; invoice history; superadmin billing controls; per-tenant backup CLI; health dashboard; fail2ban; welcome email; dunning day-3/7/14; ProxyFix middleware; QR occupant issue reporting; issue handler type (phase39); support chat persistence + knowledge base (phase40); MT-9 iOS pending**)
|
> **Last reviewed:** July 2026 (doc-reconciliation pass — verified against code on disk. Adds previously-undocumented phase28 notify-fix, phase29 broadcasts, phase30–32 device registry; `broadcast` + `devices` + `api_devices` blueprints; Broadcast + DeviceRegistration models; corrected MT-8 billing status to DONE; resolved the device-registration collision (rule 84 — removed duplicate `api_devices` blueprint + `DeviceRegistration` model, consolidated on `DeviceToken`). Prior: Phase 19 + mobile API Phases A–E + Phase 22 comment visibility + Phase 23 support chat/tickets + inspector Excel export + inspection list filters + customer issue logging + AI chatbot + dashboard grouped sections + issues/inspections PDF export + Reports R1–R4 + Phase 24 notify defaults + Phase 25 GPS + Phase 26 vendor fields + Phase 27 score alerts + **MT-0 through MT-8 complete; self-service signup; trial enforcement; billing emails; invoice history; superadmin billing controls; per-tenant backup CLI; health dashboard; fail2ban; welcome email; dunning day-3/7/14; ProxyFix middleware; QR occupant issue reporting; issue handler type (phase39); support chat persistence + knowledge base (phase40); Reports R1+R2 contract cascade filter; billing emails branded From address; MT-9 iOS pending**)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -34,6 +34,8 @@
|
|||||||
24. [Backup CLI](#24-backup-cli-controlbackuppy)
|
24. [Backup CLI](#24-backup-cli-controlbackuppy)
|
||||||
25. [Health Dashboard](#25-health-dashboard-health-on-panel)
|
25. [Health Dashboard](#25-health-dashboard-health-on-panel)
|
||||||
26. [Coding Rules for AI Assistants](#26-coding-rules-for-ai-assistants)
|
26. [Coding Rules for AI Assistants](#26-coding-rules-for-ai-assistants)
|
||||||
|
29. [Photo Object Storage (R2)](#29-photo-object-storage-r2)
|
||||||
|
30. [Database Health Check](#30-database-health-check-scriptsdb_healthpy)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -116,7 +118,7 @@ lt_janitorial_quality_control/
|
|||||||
│ ├── models/
|
│ ├── models/
|
||||||
│ │ ├── inspection.py # Inspection — mobile_local_id column (Phase B)
|
│ │ ├── inspection.py # Inspection — mobile_local_id column (Phase B)
|
||||||
│ │ ├── issue.py # Issue — mobile_local_id (Phase B), reported_by (Phase 18), mobile_photo_paths (Phase 19)
|
│ │ ├── issue.py # Issue — mobile_local_id (Phase B), reported_by (Phase 18), mobile_photo_paths (Phase 19)
|
||||||
│ │ ├── support.py # SupportTicket, SupportTicketReply (Phase 23)
|
│ │ ├── support.py # SupportChatSession, SupportChatMessage, SupportKnowledge (phase40) + SupportTicket, SupportTicketReply (Phase 23)
|
||||||
│ │ ├── tenant_settings.py # MT-7: TenantSettings — per-tenant branding (one row per tenant DB)
|
│ │ ├── tenant_settings.py # MT-7: TenantSettings — per-tenant branding (one row per tenant DB)
|
||||||
│ │ └── ...
|
│ │ └── ...
|
||||||
│ ├── routes/
|
│ ├── routes/
|
||||||
@@ -223,6 +225,8 @@ lt_janitorial_quality_control/
|
|||||||
| `REDIS_URL` | Optional. When set, Flask-Limiter uses Redis for shared rate-limit counters across Gunicorn workers. |
|
| `REDIS_URL` | Optional. When set, Flask-Limiter uses Redis for shared rate-limit counters across Gunicorn workers. |
|
||||||
| `GROQ_API_KEY` | Optional. When set, enables the AI chatbot at `/support/chat`. Absent → chat input disabled; customers see a "Submit to Support" fallback only. |
|
| `GROQ_API_KEY` | Optional. When set, enables the AI chatbot at `/support/chat`. Absent → chat input disabled; customers see a "Submit to Support" fallback only. |
|
||||||
| `GROQ_MODEL` | Optional. Groq model ID. Defaults to `llama-3.3-70b-versatile`. |
|
| `GROQ_MODEL` | Optional. Groq model ID. Defaults to `llama-3.3-70b-versatile`. |
|
||||||
|
| `DB_POOL_RECYCLE` | Optional, default `1800` (seconds). Retires a pooled connection on the **default bind** after this long. **Must stay below the server's `wait_timeout`** or MySQL closes the socket first and the next request gets `OperationalError 2006`. `scripts/db_health.py` cross-checks the two. Per-tenant engines use `TENANT_ENGINE_POOL_RECYCLE` instead. |
|
||||||
|
| `DB_POOL_SIZE` / `DB_MAX_OVERFLOW` | Optional, default `5` / `5`. Per-**worker** pool on the default bind. In MT the real ceiling is `workers x [ (default pool) + TENANT_ENGINE_CACHE_MAX x (tenant pool) ]` — the library defaults (5+10) alone put a 9-worker box at 135 against a `max_connections` of 151, before any tenant engine is counted. |
|
||||||
| `MULTI_TENANT_ENABLED` | `false` by default. Set `true` to activate Host→tenant routing. Requires all control-plane vars below. |
|
| `MULTI_TENANT_ENABLED` | `false` by default. Set `true` to activate Host→tenant routing. Requires all control-plane vars below. |
|
||||||
| `CONTROL_DATABASE_URL` | Control-plane MySQL URI, e.g. `mysql+pymysql://jqc_control:pw@127.0.0.1/jqc_control`. Required when `MULTI_TENANT_ENABLED=true`. |
|
| `CONTROL_DATABASE_URL` | Control-plane MySQL URI, e.g. `mysql+pymysql://jqc_control:pw@127.0.0.1/jqc_control`. Required when `MULTI_TENANT_ENABLED=true`. |
|
||||||
| `CONTROL_FERNET_KEY` | Fernet key for encrypting tenant DB passwords. Generate once; store in `/etc/jqc/control.env`. |
|
| `CONTROL_FERNET_KEY` | Fernet key for encrypting tenant DB passwords. Generate once; store in `/etc/jqc/control.env`. |
|
||||||
@@ -362,6 +366,28 @@ notifications: id, user_id, title, body, link, is_read, created_at, issue_id,
|
|||||||
notification_preferences: id, user_id, event_type, email_enabled, digest_mode, digest_frequency
|
notification_preferences: id, user_id, event_type, email_enabled, digest_mode, digest_frequency
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### IssueLink (phase57)
|
||||||
|
|
||||||
|
```
|
||||||
|
issue_links: id, issue_id (FK→issues CASCADE, indexed),
|
||||||
|
linked_issue_id (FK→issues CASCADE, indexed),
|
||||||
|
link_type ENUM('duplicate','related') NOT NULL DEFAULT 'related',
|
||||||
|
created_by (FK→users SET NULL), created_at
|
||||||
|
UniqueConstraint(issue_id, linked_issue_id) — uq_issue_link
|
||||||
|
```
|
||||||
|
|
||||||
|
**Connects a duplicate to its original, or two issues about the same thing**, so whoever picks one up can reach the other.
|
||||||
|
|
||||||
|
**One row is stored per pair and shown on BOTH issues.** The stored direction carries meaning for `duplicate` — `issue_id` is a duplicate *of* `linked_issue_id` — so the same row reads differently at each end: "Duplicate of #B" on one, "Duplicated by #A" on the other. `related` is symmetric and reads "Related to" from either side. `IssueLink.LABELS` is keyed `(link_type, is_source)` and is the only place that wording lives; `label_for(viewing_issue_id)` / `other_issue(viewing_issue_id)` resolve a row against whichever issue is on screen.
|
||||||
|
|
||||||
|
Storing one row rather than a mirrored pair keeps the direction unambiguous and makes unlinking a single delete. The cost: **uniqueness cannot be expressed by the UniqueConstraint alone.** `(A,B)` and `(B,A)` are distinct rows to MySQL but the same link to a person, so **`IssueLink.exists_between(a, b)` is the only correct duplicate check** — it looks both ways. The constraint catches the exact-duplicate row; `exists_between()` catches the reverse.
|
||||||
|
|
||||||
|
**A link is PURELY NAVIGATIONAL.** Marking a duplicate does **not** touch either issue's status, `resolved_at`, SLA, assignee or followers, and fires no notification. Closing the duplicate stays a separate, deliberate action. Do not add side effects here without saying so in the UI — the link control reads as navigation, and a status write from it would be invisible.
|
||||||
|
|
||||||
|
**Two FKs from one table to `issues`, so both relationships pin `foreign_keys`** — the same failure mode as phase56's third `inspections`→`users` FK (§17), which raises on first ORM *use*, not at import. `Issue.links_from` / `Issue.links_to` are the two storage directions; **`Issue.all_links()` merges them** into the single list a person actually sees. Both relationships cascade `all, delete-orphan` (and both FKs are `ON DELETE CASCADE`), so deleting an issue takes its links from *either* end — a surviving link would render a dead row on the other issue's page.
|
||||||
|
|
||||||
|
**Nothing here is tenant-aware, deliberately.** The table lives in the tenant DB and routes through `RoutingSession`, so a link can only reach an issue in the same tenant. Scope *within* a tenant is the caller's job — **see rule 110.**
|
||||||
|
|
||||||
### IssueComment
|
### IssueComment
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -428,6 +454,36 @@ notification_matrix: id, event_type, role_key, enabled, custom_emails (JSON)
|
|||||||
UniqueConstraint(event_type, role_key)
|
UniqueConstraint(event_type, role_key)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### UserNotificationMatrix (phase54)
|
||||||
|
|
||||||
|
```
|
||||||
|
user_notification_matrix: id, user_id (FK→users CASCADE, indexed),
|
||||||
|
event_type VARCHAR(50), enabled BOOL
|
||||||
|
UniqueConstraint(user_id, event_type)
|
||||||
|
```
|
||||||
|
|
||||||
|
Per-account override of the global matrix, for the two customer-side roles
|
||||||
|
only. `enabled=True` = send even if the global column is OFF; `enabled=False` =
|
||||||
|
never send even if it is ON; **no row = inherit**. Setting a row back to
|
||||||
|
inherit DELETES it, which is what keeps an account that never expressed an
|
||||||
|
opinion tracking the global matrix. Helpers: `overrides_for_user()`,
|
||||||
|
`override_for()`, `overrides_for_event()`, `set_overrides()` (none commit).
|
||||||
|
See §27b and rules 100–101.
|
||||||
|
|
||||||
|
### TemplateContract (phase55)
|
||||||
|
|
||||||
|
```
|
||||||
|
template_contracts: id, template_id (FK→inspection_templates CASCADE, indexed),
|
||||||
|
project_id (FK→projects CASCADE, indexed), created_at
|
||||||
|
UniqueConstraint(template_id, project_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
Restricts a form to specific contracts. **No rows means the form is SHARED**
|
||||||
|
(available on every contract) — rule 102. `InspectionTemplate` helpers:
|
||||||
|
`contract_ids`, `is_shared`, `available_for_project()`, `set_contracts()` (does
|
||||||
|
not commit), and the static `available_query(project_id)` — the single
|
||||||
|
definition of "which forms may this contract use".
|
||||||
|
|
||||||
### InspectionSchedule (phase34)
|
### InspectionSchedule (phase34)
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -534,7 +590,7 @@ The `DeviceRegistration` model and the duplicate `api_devices` blueprint were **
|
|||||||
| `customers` | `/customers` | list, invite, set-password, manage, import CSV |
|
| `customers` | `/customers` | list, invite, set-password, manage, import CSV |
|
||||||
| `inspections` | `/inspections` | list, start, execute, view, PDF export, flag-issue, save-draft (AJAX), flag-followup, reinspect, upload-photo (AJAX) |
|
| `inspections` | `/inspections` | list, start, execute, view, PDF export, flag-issue, save-draft (AJAX), flag-followup, reinspect, upload-photo (AJAX) |
|
||||||
| `templates` | `/templates` | list, create, edit, delete, form editor, preview |
|
| `templates` | `/templates` | list, create, edit, delete, form editor, preview |
|
||||||
| `issues` | `/issues` | list, view, create, update, verify, comment, follow/unfollow, verification queue, bulk-verify, delete, quick-assign |
|
| `issues` | `/issues` | list, view, create, update, verify, comment, follow/unfollow, verification queue, bulk-verify, delete, quick-assign, **issue links** (`POST /<id>/links` add, `POST /<id>/links/<link_id>/delete` remove, `GET /<id>/link-search` scoped JSON picker — phase57) |
|
||||||
| `notifications` | `/notifications` | list, mark-read, preferences, send-digest (cron), check-sla (cron), cleanup-tokens (cron), trial-reminders (cron), dunning-reminders (cron) |
|
| `notifications` | `/notifications` | list, mark-read, preferences, send-digest (cron), check-sla (cron), cleanup-tokens (cron), trial-reminders (cron), dunning-reminders (cron) |
|
||||||
| `audit` | `/audit` | list (admin only), view, purge |
|
| `audit` | `/audit` | list (admin only), view, purge |
|
||||||
| `reports` | `/reports` | index, facility report, scorecard, CSV/PDF/Excel export, issues-aging, sla-compliance, followup-closure, facility summary PDF |
|
| `reports` | `/reports` | index, facility report, scorecard, CSV/PDF/Excel export, issues-aging, sla-compliance, followup-closure, facility summary PDF |
|
||||||
@@ -812,7 +868,7 @@ limiter = Limiter(
|
|||||||
|
|
||||||
## 17. Alembic Migration Chain
|
## 17. Alembic Migration Chain
|
||||||
|
|
||||||
**Current HEAD:** `phase40_support_chat_kb` (38 migrations total).
|
**Current HEAD:** `phase57_issue_links`.
|
||||||
|
|
||||||
**Chain root:** `0003_add_user_active` — a guarded squashed baseline (MT-2) that recreates the full 25-table schema with INFORMATION_SCHEMA guards. The original baseline migrations (0001/0002/0003) were lost; this file restores the chain root so Alembic can build the revision map. `down_revision = None`.
|
**Chain root:** `0003_add_user_active` — a guarded squashed baseline (MT-2) that recreates the full 25-table schema with INFORMATION_SCHEMA guards. The original baseline migrations (0001/0002/0003) were lost; this file restores the chain root so Alembic can build the revision map. `down_revision = None`.
|
||||||
|
|
||||||
@@ -846,9 +902,101 @@ limiter = Limiter(
|
|||||||
→ phase36_issue_work_orders
|
→ phase36_issue_work_orders
|
||||||
→ phase37_contract_recipients
|
→ phase37_contract_recipients
|
||||||
→ phase38_facility_qr
|
→ phase38_facility_qr
|
||||||
→ phase39_issue_handler_type → phase40_support_chat_kb ← HEAD
|
→ phase39_issue_handler_type → phase40_support_chat_kb
|
||||||
|
→ phase41_auditor_role → phase42_area_qr_token → phase43_schedule_plan_fields
|
||||||
|
→ phase44_internal_handler → phase45_schedule_frequency_enum
|
||||||
|
→ phase46_schedule_recurrence → phase47_schedule_end_date
|
||||||
|
→ phase48_schedule_parent_inspection → phase49_followup_requested_by
|
||||||
|
→ phase50_sched_acknowledged → phase51_external_inspector
|
||||||
|
→ phase52_user_ui_theme → phase53_knowledge_sort_order
|
||||||
|
→ phase54_user_notif_matrix → phase55_template_contracts
|
||||||
|
→ phase56_followup_assignee → phase57_issue_links ← HEAD
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`phase54` / `phase55` port the ST August-2026 work (ST calls them phase51 /
|
||||||
|
phase52; the ids differ because MT's chain was already past those numbers —
|
||||||
|
match by NAME, not number, when comparing the two repos).
|
||||||
|
|
||||||
|
#### phase54 — per-account notification overrides
|
||||||
|
|
||||||
|
Creates `user_notification_matrix` (§5). **No backfill, deliberately** — an
|
||||||
|
empty table means every account inherits the global matrix, i.e. exactly
|
||||||
|
today's routing, so the migration cannot change who gets notified. Backfilling
|
||||||
|
from the current global columns would freeze every account at today's routing
|
||||||
|
and silently break later changes to those columns. Table-existence check —
|
||||||
|
safe to re-run.
|
||||||
|
|
||||||
|
#### phase55 — restrict forms to specific contracts
|
||||||
|
|
||||||
|
Creates `template_contracts` (§5 `TemplateContract`). **No rows for a template
|
||||||
|
means SHARED**, so every pre-existing form stays available everywhere and the
|
||||||
|
migration cannot change behaviour on deploy. Table-existence check — safe to
|
||||||
|
re-run.
|
||||||
|
|
||||||
|
#### phase56 — assign a follow-up to another inspector
|
||||||
|
|
||||||
|
Adds `inspections.follow_up_assigned_to` (FK → `users.id`, ON DELETE SET NULL)
|
||||||
|
— see §27c. **No backfill:** NULL means the follow-up belongs to the
|
||||||
|
inspection's own inspector, which is what every existing row already means, so
|
||||||
|
deploying cannot change who owns anything.
|
||||||
|
|
||||||
|
**This is the THIRD FK from `inspections` to `users`** (`inspector_id`,
|
||||||
|
`follow_up_requested_by`, and now this). Every relationship spanning the two
|
||||||
|
tables must pin `foreign_keys` explicitly or the mapper is ambiguous — and it
|
||||||
|
raises on first ORM *use*, not at import, so the app starts cleanly and then
|
||||||
|
every request 500s. Column + constraint checks — safe to re-run.
|
||||||
|
|
||||||
|
ST calls this phase53; MT's chain was already past that number. Match by NAME.
|
||||||
|
|
||||||
|
#### phase57 — link related and duplicate issues
|
||||||
|
|
||||||
|
Creates `issue_links` (§5 `IssueLink`). **Purely additive** — nothing reads the
|
||||||
|
table until a person creates a link, so an empty table is exactly today's
|
||||||
|
behaviour and there is nothing to backfill.
|
||||||
|
|
||||||
|
Both issue FKs are `ON DELETE CASCADE`, so a direct SQL delete of an issue
|
||||||
|
cannot leave a link pointing at a row that no longer exists (the ORM cascade on
|
||||||
|
`links_from`/`links_to` covers the application path). The index names
|
||||||
|
deliberately match what SQLAlchemy's `index=True` generates —
|
||||||
|
`ix_issue_links_issue_id`, `ix_issue_links_linked_issue_id` — which matters more
|
||||||
|
in MT than in ST: a tenant **bootstrapped** from the baseline and one
|
||||||
|
**upgraded** through the chain must end up with the same schema, down to the
|
||||||
|
index names.
|
||||||
|
|
||||||
|
Table-existence check — safe to re-run, as every migration from phase33 on must
|
||||||
|
be. `downgrade()` drops the table, discarding every link; no issue is affected,
|
||||||
|
since a link never held state belonging to one.
|
||||||
|
|
||||||
|
ST calls this phase54; MT's chain was already past that number. Match by NAME.
|
||||||
|
|
||||||
|
**Deploy order (tenant DBs):**
|
||||||
|
```bash
|
||||||
|
python -m control.tenant_migrate upgrade --tenant all
|
||||||
|
sudo systemctl restart gunicorn
|
||||||
|
```
|
||||||
|
|
||||||
|
**Deploy order (tenant DBs):**
|
||||||
|
```bash
|
||||||
|
python -m control.tenant_migrate upgrade --tenant all
|
||||||
|
sudo systemctl restart gunicorn
|
||||||
|
```
|
||||||
|
|
||||||
|
`phase41` → `phase52` are the single-tenant feature-parity track — see
|
||||||
|
MULTI_TENANT_PLAN.md §12. Two notes on that tail:
|
||||||
|
|
||||||
|
* **`phase51_external_inspector`** widens the `users.role` ENUM. It must be
|
||||||
|
deployed together with its code: MT tested `role == 'inspector'` literally in
|
||||||
|
~80 places, and widening the ENUM alone drops external inspectors into the
|
||||||
|
*unscoped* branch, which is a cross-tenant data leak rather than a cosmetic
|
||||||
|
bug. Use `User.INSPECTOR_ROLES` / `user.is_inspector`, never a literal.
|
||||||
|
* **ST's `phase50_default_modern` is deliberately NOT ported.** It overwrites
|
||||||
|
every saved `ui_theme` preference, which in MT would run against every tenant
|
||||||
|
DB. Set `DEFAULT_UI_THEME=modern` per tenant instead. See
|
||||||
|
MULTI_TENANT_PLAN.md §12.3.
|
||||||
|
|
||||||
|
Migration revision IDs must be **≤ 32 characters** to fit
|
||||||
|
`alembic_version.version_num VARCHAR(32)`.
|
||||||
|
|
||||||
### phase40_support_chat_kb
|
### phase40_support_chat_kb
|
||||||
|
|
||||||
Creates three tables backing support chat persistence and the AI knowledge base:
|
Creates three tables backing support chat persistence and the AI knowledge base:
|
||||||
@@ -1106,11 +1254,13 @@ Always use `user.display_name` in templates — never `.username` for display pu
|
|||||||
|
|
||||||
The Contract selector is always a plain HTML `<select>` (never a WTForms field). On `change` it calls `GET /inspections/facilities_for_project/<project_id>` and replaces the Facility `<option>` list. When the Contract is cleared it restores the "All Facilities" placeholder. The filter bars auto-narrow the server-side facility dropdown on page load when `contract_id` is in the query string.
|
The Contract selector is always a plain HTML `<select>` (never a WTForms field). On `change` it calls `GET /inspections/facilities_for_project/<project_id>` and replaces the Facility `<option>` list. When the Contract is cleared it restores the "All Facilities" placeholder. The filter bars auto-narrow the server-side facility dropdown on page load when `contract_id` is in the query string.
|
||||||
|
|
||||||
Pages using this pattern: `issues/form.html` (create), `issues/list.html` (filter bar), `inspections/list.html` (filter bar).
|
Pages using this pattern: `issues/form.html` (create), `issues/list.html` (filter bar), `inspections/list.html` (filter bar), `reports/issues_aging.html` (filter bar), `reports/sla_compliance.html` (filter bar).
|
||||||
|
|
||||||
The issues list and inspections list both accept a `contract_id` query param that filters the DB query to facilities belonging to that contract (`facility.project_id == contract_id`) and narrows the facility dropdown in the rendered HTML.
|
The issues list and inspections list both accept a `contract_id` query param that filters the DB query to facilities belonging to that contract (`facility.project_id == contract_id`) and narrows the facility dropdown in the rendered HTML.
|
||||||
|
|
||||||
**Customer role — contract filter scoping:** In `inspections.index()` and `issues.index()`, the `projects` list passed to the template is scoped to contracts the customer is assigned to via `CustomerAssignment`. Non-customer roles still receive all active projects. This prevents customers from seeing contracts they have no assignment to in the Contract filter dropdown.
|
The **Reports R1 (Issues Aging) and R2 (SLA Compliance)** filter bars include a Contract cascade dropdown that is **client-side only** — selecting a contract calls `GET /inspections/facilities_for_project/<id>` to narrow the facility list in the browser; the actual DB filter still uses only `facility_id`. The route passes `projects` to the template (all active projects, scoped to the customer's assigned facilities when the role is `customer`). The cascade JS is guarded by `{% if projects %}` so it is omitted for empty lists (e.g. a customer with no facility assignments).
|
||||||
|
|
||||||
|
**Customer role — contract filter scoping:** In `inspections.index()`, `issues.index()`, `reports.issues_aging()`, and `reports.sla_compliance()`, the `projects` list passed to the template is scoped to contracts whose facilities overlap the customer's assigned facility set. Non-customer roles receive all active projects. This prevents customers from seeing contracts they have no assignment to in the Contract filter dropdown.
|
||||||
|
|
||||||
### Customer Dashboard — "Your Facilities" Panel
|
### Customer Dashboard — "Your Facilities" Panel
|
||||||
|
|
||||||
@@ -1196,6 +1346,8 @@ All report pages include `{% include 'reports/_subnav.html' %}` as the first ele
|
|||||||
|
|
||||||
Loads all non-resolved issues scoped by role, groups into five age buckets (`<24h`, `1–3 days`, `3–7 days`, `1–4 weeks`, `>4 weeks`). SLA status computed per-issue via `sla_status()`. Filters: severity, facility (both applied in Python after the main query to avoid double-outerjoin conflicts with customer scope).
|
Loads all non-resolved issues scoped by role, groups into five age buckets (`<24h`, `1–3 days`, `3–7 days`, `1–4 weeks`, `>4 weeks`). SLA status computed per-issue via `sla_status()`. Filters: severity, facility (both applied in Python after the main query to avoid double-outerjoin conflicts with customer scope).
|
||||||
|
|
||||||
|
**Contract cascade filter:** A client-side Contract `<select>` (no name attribute — not submitted) appears above the Facility dropdown. On change, JS calls `GET /inspections/facilities_for_project/<id>` to narrow the Facility list in-browser; clearing the contract restores all options. The route passes `projects` (all active, or scoped to customer facility set). The actual DB filter uses only `facility_id`.
|
||||||
|
|
||||||
Excel export: `GET /reports/export/issues-aging` — one sheet, color-coded severity and SLA columns.
|
Excel export: `GET /reports/export/issues-aging` — one sheet, color-coded severity and SLA columns.
|
||||||
|
|
||||||
Helper: `_load_open_issues_scoped(customer_facility_ids, severity_filter, facility_id_filter)` — extracted so both the HTML route and the Excel export share identical query logic.
|
Helper: `_load_open_issues_scoped(customer_facility_ids, severity_filter, facility_id_filter)` — extracted so both the HTML route and the Excel export share identical query logic.
|
||||||
@@ -1207,6 +1359,8 @@ Loads resolved issues in the date range, computes `within_sla()` per issue (comp
|
|||||||
- `by_severity` — dict with `total`, `met`, `pct`, `sla_hours` per severity tier
|
- `by_severity` — dict with `total`, `met`, `pct`, `sla_hours` per severity tier
|
||||||
- `by_facility` — list sorted by compliance % descending
|
- `by_facility` — list sorted by compliance % descending
|
||||||
|
|
||||||
|
**Contract cascade filter:** Same client-side Contract → Facility cascade as R1. Route passes `projects`; DB filter uses only `facility_id`.
|
||||||
|
|
||||||
Helper: `_sla_within(issue)` — used by both the HTML route and the Excel export.
|
Helper: `_sla_within(issue)` — used by both the HTML route and the Excel export.
|
||||||
|
|
||||||
Excel export: `GET /reports/export/sla-compliance` — 2 sheets: **By Severity** (with totals row) and **By Facility**.
|
Excel export: `GET /reports/export/sla-compliance` — 2 sheets: **By Severity** (with totals row) and **By Facility**.
|
||||||
@@ -1227,12 +1381,15 @@ A **PDF Summary** button was added to `reports/scorecard.html` alongside the exi
|
|||||||
|
|
||||||
### Support Chat — Customer UX
|
### Support Chat — Customer UX
|
||||||
|
|
||||||
`GET /support/chat` — customer only. Renders:
|
`GET /support/chat` — customer only. Accepts optional `?session_id=N` to reload a prior conversation.
|
||||||
- Greeting message with `current_user.display_name` (injected via `var userName = {{ current_user.display_name | tojson }}` — use `tojson` not inline interpolation to prevent XSS/quote breaks).
|
|
||||||
- FAQ quick-reply chips: text stored in `data-faq="..."` HTML attribute (HTML-escaped with `| e`), read in JS via `btn.dataset.faq`. **Never use `| tojson` in an `onclick=""` attribute** — it emits double-quoted JSON inside a double-quoted attribute, breaking HTML parsing and truncating the `<script>` tag.
|
Renders:
|
||||||
- Chat history kept client-side in `let history = []`, sent with each AJAX `POST /support/chat/message`. Server caps at last 20 turns.
|
- Greeting message with `current_user.display_name` (injected via `var userName = {{ current_user.display_name | tojson }}` — use `tojson` not inline interpolation to prevent XSS/quote breaks). Greeting is hidden when loading a prior session (`{% if not db_history %}`).
|
||||||
|
- FAQ quick-reply chips: text stored in `data-faq="..."` HTML attribute (HTML-escaped with `| e`), read in JS via `btn.dataset.faq`. Hidden when a prior session is loaded. **Never use `| tojson` in an `onclick=""` attribute** — it emits double-quoted JSON inside a double-quoted attribute, breaking HTML parsing and truncating the `<script>` tag.
|
||||||
|
- **Chat history is DB-backed (phase40).** Prior turns are rendered server-side on page load from `db_history` (list of `SupportChatMessage`). The JS variable `let session_id` is seeded from `chat_session.id` (null for new chats). AJAX sends only `{ message, session_id }` — **no history array** (rule 95). Server returns `{ reply, session_id }` and the JS stores/reuses `session_id` across subsequent messages.
|
||||||
- If `GROQ_API_KEY` is absent, input is disabled and a fallback "Submit to Support" link is shown.
|
- If `GROQ_API_KEY` is absent, input is disabled and a fallback "Submit to Support" link is shown.
|
||||||
- "Submit to Support" modal POSTs to `POST /support/tickets`; subject pre-filled from last user message in history.
|
- "Submit to Support" modal POSTs to `POST /support/tickets`; subject pre-filled from `last_user_msg` JS variable (last message typed, not scanned from history array).
|
||||||
|
- "History" button links to `support.my_conversations` (list of all past sessions). "New Chat" link starts a fresh session (`/support/chat` with no `session_id`).
|
||||||
|
|
||||||
### Inspection Execute Page — UX Patterns
|
### Inspection Execute Page — UX Patterns
|
||||||
|
|
||||||
@@ -1381,7 +1538,7 @@ set -a; . /etc/jqc/control.env; set +a
|
|||||||
| 60 | **`flag_issue` offcanvas form must include `<input type="hidden" name="facility_id">`** | `IssueForm.facility_id` has `DataRequired()`. The hand-written offcanvas form in `execute.html` is not rendered by WTForms, so it must explicitly send `facility_id`. Without it, `form.validate_on_submit()` silently returns `False`, the server responds `200 OK` with the `flag_issue.html` template, and the JS treats `res.ok` as success — no issue is ever saved. Fix: `<input type="hidden" name="facility_id" value="{{ inspection.facility_id }}">` inside `#flagIssueForm`. |
|
| 60 | **`flag_issue` offcanvas form must include `<input type="hidden" name="facility_id">`** | `IssueForm.facility_id` has `DataRequired()`. The hand-written offcanvas form in `execute.html` is not rendered by WTForms, so it must explicitly send `facility_id`. Without it, `form.validate_on_submit()` silently returns `False`, the server responds `200 OK` with the `flag_issue.html` template, and the JS treats `res.ok` as success — no issue is ever saved. Fix: `<input type="hidden" name="facility_id" value="{{ inspection.facility_id }}">` inside `#flagIssueForm`. |
|
||||||
| 61 | **Contract→Facility cascade UI pattern: contract selector is UI-only, not a WTForms field** | The "Log New Issue" form (`issues/form.html`) and both filter bars (`issues/list.html`, `inspections/list.html`) use a plain HTML `<select id="...contract...">` that triggers an AJAX call to `GET /inspections/facilities_for_project/<id>` on change, repopulating the facility dropdown. `IssueForm.facility_id.choices` is always set to ALL active facilities in the route so POST validation passes regardless of which contract was selected in the UI. On POST error re-render, the route derives `selected_project_id` from the submitted `facility_id`'s `project_id` and passes it to the template so JS can restore both selectors. |
|
| 61 | **Contract→Facility cascade UI pattern: contract selector is UI-only, not a WTForms field** | The "Log New Issue" form (`issues/form.html`) and both filter bars (`issues/list.html`, `inspections/list.html`) use a plain HTML `<select id="...contract...">` that triggers an AJAX call to `GET /inspections/facilities_for_project/<id>` on change, repopulating the facility dropdown. `IssueForm.facility_id.choices` is always set to ALL active facilities in the route so POST validation passes regardless of which contract was selected in the UI. On POST error re-render, the route derives `selected_project_id` from the submitted `facility_id`'s `project_id` and passes it to the template so JS can restore both selectors. |
|
||||||
| 62 | **`issue.resolved_facility.project` and `inspection.facility.project` give the contract** | `Project.facilities` declares `backref='project'`, so `facility.project` is a direct ORM attribute (not a dynamic query). Guard all template accesses: `ins.facility.project.name if ins.facility and ins.facility.project else '—'`. The contract name is displayed in the issues list, issues detail, and inspections list; the issues list also accepts a `contract_id` query param that pre-filters the facility dropdown server-side. |
|
| 62 | **`issue.resolved_facility.project` and `inspection.facility.project` give the contract** | `Project.facilities` declares `backref='project'`, so `facility.project` is a direct ORM attribute (not a dynamic query). Guard all template accesses: `ins.facility.project.name if ins.facility and ins.facility.project else '—'`. The contract name is displayed in the issues list, issues detail, and inspections list; the issues list also accepts a `contract_id` query param that pre-filters the facility dropdown server-side. |
|
||||||
| 63 | **Customer Contract filter scoped to assigned contracts only** | `inspections.index()` and `issues.index()` build the `projects` list differently for `customer` role: query `CustomerAssignment.query.filter_by(user_id=current_user.id)` to get assigned `project_id` values, then filter `Project` to that set. All other roles still receive all active projects. Pattern mirrors the existing inspector scoping in `inspections.start()`. |
|
| 63 | **Customer Contract filter scoped to assigned contracts only** | `inspections.index()`, `issues.index()`, `reports.issues_aging()`, and `reports.sla_compliance()` build the `projects` list differently for `customer` role. Inspections/issues use `CustomerAssignment` to get assigned `project_id` values. Reports use a join: `Project.query.join(Facility).filter(Facility.id.in_(customer_facility_ids))`. All other roles receive all active projects. This prevents customers from seeing contracts they have no assignment to in any Contract filter dropdown. |
|
||||||
| 64 | **Invitation email sender and link domain are derived from `request.host_url`** | `_send_invite_email(user, token, base_url=None)` in `customers.py` accepts an optional `base_url`. Both call sites (`invite` and `resend_invite`) pass `request.host_url`. Inside the function, `effective_base` is built from that value (falling back to `APP_BASE_URL`); `setup_link` uses `effective_base`; `sender` is `noreply@<netloc>` parsed from `effective_base`. The SMTP server and credentials are unchanged — only the `From` address and link URL vary per domain. |
|
| 64 | **Invitation email sender and link domain are derived from `request.host_url`** | `_send_invite_email(user, token, base_url=None)` in `customers.py` accepts an optional `base_url`. Both call sites (`invite` and `resend_invite`) pass `request.host_url`. Inside the function, `effective_base` is built from that value (falling back to `APP_BASE_URL`); `setup_link` uses `effective_base`; `sender` is `noreply@<netloc>` parsed from `effective_base`. The SMTP server and credentials are unchanged — only the `From` address and link URL vary per domain. |
|
||||||
| 65 | **Customer "Your Facilities" uses a card grid, not a table** | See §18 "Customer Dashboard — Your Facilities Panel". Never revert to a full-width table for this section. The show-more threshold is `VISIBLE = 9`; the search input threshold is `> 6`. Both thresholds live as JS/Jinja constants in `dashboard.html` and can be adjusted together if needed. |
|
| 65 | **Customer "Your Facilities" uses a card grid, not a table** | See §18 "Customer Dashboard — Your Facilities Panel". Never revert to a full-width table for this section. The show-more threshold is `VISIBLE = 9`; the search input threshold is `> 6`. Both thresholds live as JS/Jinja constants in `dashboard.html` and can be adjusted together if needed. |
|
||||||
| 66 | **FAQ chip text must use `data-faq` attribute, not `onclick` with `\| tojson`** | `\| tojson` emits `"text"` (double-quoted) inside `onclick="..."` (also double-quoted), breaking HTML parsing and silently truncating the `<script>` block. Use `data-faq="{{ text \| e }}"` and read via `btn.dataset.faq` in JS. |
|
| 66 | **FAQ chip text must use `data-faq` attribute, not `onclick` with `\| tojson`** | `\| tojson` emits `"text"` (double-quoted) inside `onclick="..."` (also double-quoted), breaking HTML parsing and silently truncating the `<script>` block. Use `data-faq="{{ text \| e }}"` and read via `btn.dataset.faq` in JS. |
|
||||||
@@ -1408,6 +1565,22 @@ set -a; . /etc/jqc/control.env; set +a
|
|||||||
| 94 | **`handler_type` NULL and `'internal'` are equivalent** | NULL means the column was not set (pre-phase39 row or unmodified new row); the application treats both as "Janitorial Staff". The dashboard `handler_breakdown['internal']` counter and the `?handler_type=internal` issues-list filter both use `db.or_(Issue.handler_type == 'internal', Issue.handler_type.is_(None))`. Never coerce NULL to 'internal' at the DB layer — the nullable default is intentional for backwards compatibility. |
|
| 94 | **`handler_type` NULL and `'internal'` are equivalent** | NULL means the column was not set (pre-phase39 row or unmodified new row); the application treats both as "Janitorial Staff". The dashboard `handler_breakdown['internal']` counter and the `?handler_type=internal` issues-list filter both use `db.or_(Issue.handler_type == 'internal', Issue.handler_type.is_(None))`. Never coerce NULL to 'internal' at the DB layer — the nullable default is intentional for backwards compatibility. |
|
||||||
| 95 | **Chat history is loaded from DB — never pass client-sent history to Groq** | phase40. `POST /support/chat/message` loads prior turns from `SupportChatMessage` (newest-first, limit 40, reversed). The JSON body sends only `{ message, session_id }` — no history array. This prevents history tampering by clients and ensures accuracy across page reloads. |
|
| 95 | **Chat history is loaded from DB — never pass client-sent history to Groq** | phase40. `POST /support/chat/message` loads prior turns from `SupportChatMessage` (newest-first, limit 40, reversed). The JSON body sends only `{ message, session_id }` — no history array. This prevents history tampering by clients and ensures accuracy across page reloads. |
|
||||||
| 96 | **`db.session.flush()` to get session ID before first message insert** | When creating a new `SupportChatSession` in `chat_message()`, call `db.session.flush()` after `db.session.add(chat_session)` to get the autoincrement `id` before constructing `SupportChatMessage` rows. If Groq fails, `db.session.rollback()` undoes the flush — no orphaned empty session is left in the DB. |
|
| 96 | **`db.session.flush()` to get session ID before first message insert** | When creating a new `SupportChatSession` in `chat_message()`, call `db.session.flush()` after `db.session.add(chat_session)` to get the autoincrement `id` before constructing `SupportChatMessage` rows. If Groq fails, `db.session.rollback()` undoes the flush — no orphaned empty session is left in the DB. |
|
||||||
|
| 97 | **`send_billing_email()` derives the From address from `APP_BASE_URL` — same pattern as rule 64** | `urlparse(app.config['APP_BASE_URL']).netloc` is extracted before the background thread starts and passed as `sender=f'noreply@{netloc}'` to `Message()`. Falls back to `MAIL_DEFAULT_SENDER` when `APP_BASE_URL` is absent or yields an empty netloc (`sender=None` triggers Flask-Mail's default). Do not hardcode a sender string or duplicate the derivation logic — extend via `send_billing_email()` only. |
|
||||||
|
| 99 | **`User.CUSTOMER_ROLES` is for ACCOUNT MANAGEMENT; `role == 'customer'` is for CAPABILITY — never swap them** | Widening a capability check to `CUSTOMER_ROLES` hands a third-party Customer Inspector the customer portal (fails OPEN, nothing errors). Narrowing an account-management check back to `'customer'` strands every Customer Inspector in a page that no longer lists or edits them. `CUSTOMER_ROLES` / `is_customer_account` appear ONLY in: the `/customers` list query and its guards, the `auth.list_users` exclusion, the customer-facing support surface (`_is_customer_side()`), and narrowing uses that WITHHOLD something from an external account (`_assignable_staff_for()`). |
|
||||||
|
| 100 | **A per-account notification opt-IN must survive a globally-OFF column** | `notify_by_matrix()` skips a role column early when the matrix says off. For the two customer columns that `continue` must also ask `any(overrides.values())`, or the override saves, displays as on, and never sends. Equally, `notify_customers_for_facility()` re-queries recipients from assignment rows, so `notify_by_matrix()` must hand it `allowed_user_ids` or the facility-scoped path bypasses every override. Both halves are needed. |
|
||||||
|
| 101 | **Per-account overrides are enforced in `notify()`, not only `notify_by_matrix()`** | Follower fan-out and direct assignee notifications call `notify()` straight. Gating only the matrix left the editor offering rows ("Issue assigned") that read as Off while the notifications kept arriving. Only an explicit `False` suppresses; the `getattr(recipient, 'is_customer_account', False)` test is deliberate so an unavailable attribute SENDS rather than silently dropping. |
|
||||||
|
| 102 | **A template with NO `template_contracts` rows is SHARED, not hidden** | The empty set means "available on every contract" — that is what makes phase55 additive and why it needed no backfill. Reading it the other way hides every pre-existing form from every contract at once. The convention lives in exactly one place, `InspectionTemplate.available_query()`. A facility with no contract gets shared forms only (fail-closed). |
|
||||||
|
| 103 | **A bulk-action form must live OUTSIDE the table; row checkboxes join it via the HTML5 `form=` attribute** | Wrapping the table nests the per-row delete/unfollow forms inside the bulk form, and browsers silently discard nested forms (rule 9) — the row buttons post nothing, with no console error and no server log. Applies to all four list templates (classic + modern). |
|
||||||
|
| 104 | **Bulk deletes: DB rows first, storage files second** | Collect the keys, `db.session.delete()` every row, `commit()`, and only then `storage.delete()`. `_collect_inspection_photos()` is shared by the single and bulk inspection delete paths so the two cannot drift — a key missed there is an invisible permanent storage leak. |
|
||||||
|
| 105 | **The flag-issue assignee list is contract-scoped, and BOTH call sites must use `_assignable_staff_for()`; a failed flag-issue POST must return non-2xx** | `execute()` renders the dropdown, `flag_issue()` builds the choices that validate the POST — the choices are the security boundary. An org-wide list let anyone assign another client's Customer Inspector, who was then emailed the facility name and issue description. And the offcanvas JS branches on `res.ok`, so a 200 re-render of an invalid form reads as success: the panel closes, the page reloads, and no issue exists. |
|
||||||
|
| 106 | **Name the Groq model in the chat error log, and keep `_DEFAULT_GROQ_MODEL` current** | Groq retires models without notice; when the configured one disappears the API 404s and EVERY question returns the generic "problem reaching the AI assistant" reply, with nothing else broken — invisible until a customer complains. The fix needs no deploy, only `GROQ_MODEL`, which is exactly what the log line must say. |
|
||||||
|
| 107 | **`viewer_is_our_staff` in `issues/view.html` is an explicit role ALLOWLIST, and `external_inspector` is absent on purpose** | `not current_user.is_customer_account` fails OPEN — a missing attribute yields Jinja `Undefined`, `not Undefined` is true, and the internal-process chrome renders for exactly the accounts it must be hidden from. This is not a rule-87 violation: rule 87 governs capability/scoping, where a Customer Inspector must behave like our inspector; this asks "does this person work for us?", the one place the two genuinely differ. |
|
||||||
|
| 108 | **A follow-up has exactly ONE owner: use `follow_up_owner` (row) / `follow_up_owned_by()` (query) — never re-derive it** | Assignee when set, original inspector otherwise. The API's two arms must be mutually exclusive (`follow_up_assigned_to == me` OR `assigned_to IS NULL AND inspector_id == me`); drop the `IS NULL` and two people turn up for the same re-inspection. The authorship filter must be DEFERRED when `follow_up_required=true` is requested, or the rows the assignee needs are hidden before the ownership test runs. |
|
||||||
|
| 109 | **Inspector READ access is facility scope; WRITE access is authorship** | `index()` lists by facility (rule 58), so `view()`/`export_pdf()` must too — scoping reads by authorship made the list offer rows that said "Access denied" on click, and locked the follow-up assignee out of the parent inspection. `execute`, `save_draft_ajax`, `upload_photo_ajax` and `flag_issue` keep the authorship check: readable is not editable. |
|
||||||
|
| 110 | **An issue link is a pointer to another issue — filter it by scope on ALL THREE surfaces** | A link exposes the far issue's id, description, facility and status, so an unfiltered panel lets a customer read an issue at a facility they hold no assignment to, simply because one of our staff linked it. Three surfaces have to hold the line and only one is a real boundary: `_readable_links()` filters what the panel RENDERS, `link_search()` scopes what the picker FINDS, and `add_link()` re-checks on POST — the search is a convenience and must never be trusted as the gate. All three resolve scope through `_viewer_facility_scope()` / `_issue_in_scope()`, the same pair `issues.view()` now uses, so the panel cannot end up more permissive than the page it sits on. `_issue_in_scope` takes a resolved scope rather than a user, so filtering a list costs one assignment query, not one per row. A link to an issue outside your scope reports "not found", never "access denied" — whether another customer's issue exists is not something the link box should confirm. Cross-TENANT isolation is a different layer and is already handled: `RoutingSession` has bound the session to `g.tenant`'s database, so an id from another tenant does not resolve at all. |
|
||||||
|
| 111 | **The MT connection ceiling is `workers x [ default pool + cache_cap x tenant pool ]` — never just the default pool** | `config.SQLALCHEMY_ENGINE_OPTIONS` governs only the default bind; in MT nearly every request runs on a per-tenant engine from `app/tenancy/engine_cache.py` with its own `TENANT_ENGINE_*` pool, and each worker caches up to `TENANT_ENGINE_CACHE_MAX` of them. Sizing against the default pool alone is how a box reaches "Too many connections" while the health check reports headroom. `scripts/db_health.py` does the full arithmetic; the cache cap is the multiplier worth lowering first. Both pools set `pool_pre_ping` — without it an idle overnight surfaces as `OperationalError 2006` on the next request. |
|
||||||
|
| 112 | **The SLA cron narrows to candidates in SQL; the prefilter is a conservative SUPERSET, never an equality** | `send_sla_alerts()` runs every 30 minutes **per tenant**, so reading the whole open-issue backlog to decide in Python multiplied by the tenant count. The three filters each mirror a `continue` in the loop, and one of them — `reported_at IS NOT NULL` — is a correctness fix, not a speed one: `sla_status()` raises `TypeError` on a NULL and one such row aborted that tenant's entire run. The prefilter deliberately does NOT replicate the "already notified at_risk and still only at_risk" skip (that would mean writing the per-severity deadline arithmetic a second time, in SQL); the loop still applies it, so extra rows are read but no extra notification is sent. Pinned by `tests/test_sla_candidate_query.py` — a change that makes the prefilter narrower than the loop is a silently unsent alert. |
|
||||||
|
| 98 | **Reports R1 + R2 contract cascade is client-side only — facility_id is the sole DB filter** | The Contract dropdown in `reports/issues_aging.html` and `reports/sla_compliance.html` has no `name` attribute and is never submitted. It exists only to narrow the Facility `<select>` in the browser via `GET /inspections/facilities_for_project/<id>`. The routes receive and filter on `facility_id`; `contract_id` plays no role server-side. Do not add server-side `contract_id` filtering to these routes — it would duplicate what `facility_id` already provides. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1616,7 +1789,9 @@ Stripe-backed subscription billing. Controlled by `BILLING_ENABLED` env var (def
|
|||||||
|
|
||||||
### Billing emails (`app/billing/emails.py`)
|
### Billing emails (`app/billing/emails.py`)
|
||||||
|
|
||||||
`send_billing_email(to_addr, event_type, context_dict)` sends multipart HTML + plain text.
|
`send_billing_email(to_addr, event_type, context_dict)` sends multipart HTML + plain text in a background thread.
|
||||||
|
|
||||||
|
**Sender derivation:** Before launching the thread, `urlparse(app.config['APP_BASE_URL']).netloc` is extracted and the From address is set to `noreply@<netloc>`. This mirrors rule 64 (invitation emails) so billing emails carry the correct tenant domain in the From header rather than a hardcoded address. Falls back to `MAIL_DEFAULT_SENDER` when `APP_BASE_URL` is unset or unparseable (`sender=None` passes `None` to `Message()`, triggering Flask-Mail's default).
|
||||||
|
|
||||||
| `event_type` | Trigger | Required context keys |
|
| `event_type` | Trigger | Required context keys |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -1721,7 +1896,213 @@ Row highlights: yellow = suspended, red = trial expired.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 26. Coding Rules for AI Assistants
|
## 26. Web Portal Design (MT-16)
|
||||||
|
|
||||||
|
Two designs share one set of page templates.
|
||||||
|
|
||||||
|
* `base.html` is a **one-line dispatcher**: `{% extends jqc_layout %}`. Page
|
||||||
|
templates keep `{% extends "base.html" %}` and need no edits.
|
||||||
|
* `layouts/classic.html` is the original chrome, verbatim.
|
||||||
|
`layouts/modern.html` is the sidebar shell.
|
||||||
|
* `jqc_layout` comes from `inject_ui_theme()` in `app/__init__.py`, driven by
|
||||||
|
`users.ui_theme` with config `DEFAULT_UI_THEME` as the fallback.
|
||||||
|
* Per-page overrides live at `templates/modern/<same path>.html` and are indexed
|
||||||
|
once at boot. Look for `UI themes | modern overrides indexed: N` in the log —
|
||||||
|
`0` on a host that should have them means the directory did not deploy.
|
||||||
|
|
||||||
|
**Do not move the template swap into the Jinja loader.** It lives in
|
||||||
|
`ThemedEnvironment.get_template()` so the template cache is keyed on the
|
||||||
|
*rewritten* name. A loader-level swap caches under the original name, so a
|
||||||
|
modern template can be served to a classic user — and in MT, where one worker
|
||||||
|
serves many tenants, across tenants.
|
||||||
|
|
||||||
|
When adding a page: write it once as a normal template. Only add a
|
||||||
|
`modern/` override if the layout genuinely differs; styling alone is handled by
|
||||||
|
`static/css/theme_modern.css`, which is scoped to `body.jqc-modern`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 27. Roles (MT-15)
|
||||||
|
|
||||||
|
`users.role` ENUM: `admin`, `director`, `inspector`, `external_inspector`,
|
||||||
|
`project_manager`, `customer`, `auditor`.
|
||||||
|
|
||||||
|
**Never test `role == 'inspector'`.** `external_inspector` (customer /
|
||||||
|
third-party inspectors) has identical capabilities and identical
|
||||||
|
`InspectorAssignment` scoping. Use:
|
||||||
|
|
||||||
|
* `user.is_inspector` — true for both inspector roles; use for every capability
|
||||||
|
and scoping check
|
||||||
|
* `User.INSPECTOR_ROLES` — for `User.role.in_(...)` queries
|
||||||
|
* `user.is_external_inspector` — only where the two genuinely differ (display)
|
||||||
|
* `user.role_label` / `ROLE_LABELS` — for any role name shown in the UI
|
||||||
|
|
||||||
|
A literal comparison sends external inspectors down the unscoped branch, where
|
||||||
|
`get_inspector_scope()` returns `None` and every downstream query drops its
|
||||||
|
facility filter. That is a cross-customer leak.
|
||||||
|
|
||||||
|
External inspectors are **invited**, never given a password: `password_set=False`
|
||||||
|
plus an emailed 72-hour token, with `auth.resend_invite` for bounced invitations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 27b. Customer-side roles, per-account notifications, per-contract forms (Aug 2026 ST parity)
|
||||||
|
|
||||||
|
Ported from the single-tenant app (its phase51 + phase52 + the August fixes).
|
||||||
|
Nothing here is tenant-aware in its own right — every table lives in the tenant
|
||||||
|
DB and every query routes through `RoutingSession` as usual.
|
||||||
|
|
||||||
|
### The two customer-side roles
|
||||||
|
|
||||||
|
| Stored ENUM value | Display label | Scoped by | Capabilities |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `customer` | **Customer Director** | `CustomerAssignment` | The portal, unchanged — plus planning scheduled inspections at their own facilities |
|
||||||
|
| `external_inspector` | **Customer Inspector** | `InspectorAssignment` | Identical to the internal `inspector`, plus the customer support surface (AI chat + tickets) |
|
||||||
|
|
||||||
|
**LABEL-only rename** — the ENUM values are untouched, so no migration and no
|
||||||
|
role check moved. `User.ROLE_LABELS` is the one place the names live.
|
||||||
|
|
||||||
|
`User.CUSTOMER_ROLES = ('customer', 'external_inspector')` and
|
||||||
|
`User.is_customer_account` answer an **account-management** question ("is this
|
||||||
|
managed under `/customers`?"). Every **capability** check — portal gates,
|
||||||
|
`@customer_required`, `get_customer_scope()`, `notify_customers_for_facility()`,
|
||||||
|
the customer branch of each `app/api/*` module — keeps testing
|
||||||
|
`role == 'customer'` exactly (rule 99).
|
||||||
|
|
||||||
|
Both roles are now created, invited, assigned and switched in **Customer
|
||||||
|
Management** (`/customers`); `auth.list_users` excludes them and the
|
||||||
|
`/auth/users/...` URLs redirect to `customers.manage`. `UserForm` no longer
|
||||||
|
offers `external_inspector`, and `auth.create_user` always requires a password
|
||||||
|
— customer-side accounts are invited (they choose their own username and
|
||||||
|
password) via `customers.create()`. `POST /customers/<id>/switch-role`
|
||||||
|
(admin only) mirrors contracts across the two scoping tables and revokes the
|
||||||
|
account's refresh tokens + device rows, because API access differs between them.
|
||||||
|
|
||||||
|
### `UserNotificationMatrix` (per-account overrides)
|
||||||
|
|
||||||
|
Row `enabled=True` = send even if the global column is OFF; `enabled=False` =
|
||||||
|
never send even if it is ON; **no row = inherit**. Inherit is the default, so
|
||||||
|
the table shipped empty and changed routing for nobody, and setting a row back
|
||||||
|
to inherit DELETES it. Helpers live in
|
||||||
|
`app/models/user_notification_matrix.py`; edited on the account's Customer
|
||||||
|
Management page as a tri-state. Enforced in **`notify()` as well as**
|
||||||
|
`notify_by_matrix()` — follower fan-out and direct assignee notifications reach
|
||||||
|
`notify()` straight, so gating only the matrix left rows that read as Off while
|
||||||
|
notifications kept arriving. See rules 100 and 101.
|
||||||
|
|
||||||
|
### `TemplateContract` (forms per contract)
|
||||||
|
|
||||||
|
`InspectionTemplate.available_query(project_id)` is the single definition of
|
||||||
|
"which forms may this contract use" — pickers, the POST validation behind them,
|
||||||
|
the schedule form and `GET /api/v1/templates` all call it. **No rows = shared**
|
||||||
|
(rule 102). Managed in three places: the template list's Edit modal
|
||||||
|
(`POST /templates/<id>/rename`, carrying a hidden `contracts_present=1`
|
||||||
|
marker), Create Template, and the full form editor. `duplicate_template()`
|
||||||
|
copies the restrictions.
|
||||||
|
|
||||||
|
### Other ported behaviour
|
||||||
|
|
||||||
|
* **Bulk actions** on the issues and inspections lists (`POST /issues/bulk`,
|
||||||
|
`POST /inspections/bulk`) with shared partials in `templates/partials/`.
|
||||||
|
Toolbar form sits OUTSIDE the table; row checkboxes join it with the HTML5
|
||||||
|
`form=` attribute (rule 103). Deletes remove DB rows first, storage keys
|
||||||
|
second (rule 104).
|
||||||
|
* **List filter preservation** — `current_url()` (Jinja global) +
|
||||||
|
`return_url(fallback)` (`utils/decorators`) round-trip the full list URL as
|
||||||
|
`next`, so an edit or delete returns to the filtered page.
|
||||||
|
`safe_redirect_url` still guards every hop.
|
||||||
|
* **Flag-issue assignee scoping** — `_assignable_staff_for(inspection, actor)`
|
||||||
|
in `routes/inspections.py` is the single source for both the offcanvas
|
||||||
|
dropdown and `form.assigned_to.choices` (the actual POST validation). A
|
||||||
|
failed flag-issue POST now returns **400**, because the offcanvas JS branches
|
||||||
|
on `res.ok` (rule 105).
|
||||||
|
* **Customer Directors plan inspections** — `schedule_manager_required` in
|
||||||
|
`routes/inspection_schedules.py` = the manager set plus `role == 'customer'`.
|
||||||
|
Because that blueprint builds its form by hand (no WTForms SelectField),
|
||||||
|
narrowing the choice lists is NOT the validation: `_scope_errors()`
|
||||||
|
re-checks facility, inspector and form-vs-contract on every POST, and
|
||||||
|
`_schedule_in_scope()` guards edit/delete. Start remains the assignee's.
|
||||||
|
* **Support chat serves both customer roles** — `_is_customer_side()` opens the
|
||||||
|
door, then `_support_facilities()` branches per role (CustomerAssignment vs
|
||||||
|
InspectorAssignment) and `_system_prompt_for()` appends
|
||||||
|
`_INSPECTOR_ADDENDUM` for a Customer Inspector. The curated knowledge base is
|
||||||
|
now spliced in **before** the `Rules:` heading (`_STYLE_MARKER`) — appended
|
||||||
|
after it, the prompt's own "ground answers in everything above" put it out of
|
||||||
|
scope, which is why KB entries looked ignored. `/support/admin/knowledge/preview`
|
||||||
|
shows the exact prompt. `GROQ_MODEL` defaults to `_DEFAULT_GROQ_MODEL`
|
||||||
|
(`openai/gpt-oss-120b`); Groq retires models without notice, and the error
|
||||||
|
handler names the model and says to set `GROQ_MODEL` (rule 106).
|
||||||
|
* **`COMMENTS_VISIBLE_TO_ALL`** (config, default true) lifts the phase22 read
|
||||||
|
filter so customers see every comment. `is_customer_visible` is still
|
||||||
|
written, so flipping it back restores the old behaviour with nothing to
|
||||||
|
repair. `issues/view.html` gates the internal chrome on
|
||||||
|
`viewer_is_our_staff` — an explicit allowlist of OUR roles, which fails
|
||||||
|
closed and deliberately excludes `external_inspector` (rule 107).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 27c. Follow-up assignment + inspector read access (Aug 2026 ST parity)
|
||||||
|
|
||||||
|
Ported from ST (its phase53 + the two fixes shipped beside it).
|
||||||
|
|
||||||
|
### Assigning a follow-up (phase56)
|
||||||
|
|
||||||
|
A follow-up used to belong implicitly to whoever performed the original
|
||||||
|
inspection. `inspections.follow_up_assigned_to` lets a director — or a
|
||||||
|
**Customer Director**, for their own facilities — hand the re-inspection to
|
||||||
|
someone else. `Inspection.follow_up_owner` (assignee *or* inspector) is the
|
||||||
|
single definition of ownership, so the web display, the notification and the
|
||||||
|
mobile API filter cannot disagree.
|
||||||
|
|
||||||
|
**The assignee takes over**: only the owner is notified, and only the owner
|
||||||
|
sees it. In `GET /api/v1/inspections?follow_up_required=true` the two arms are
|
||||||
|
mutually exclusive — without `is_(None)` on the second arm the original
|
||||||
|
inspector keeps seeing a follow-up handed to someone else and two people turn
|
||||||
|
up to do it. The generic "inspectors see only their own inspections" filter is
|
||||||
|
**deferred** when follow-ups are requested, because an assigned follow-up lives
|
||||||
|
on an inspection somebody else performed.
|
||||||
|
|
||||||
|
The picker (`_followup_assignees_for()`) is contract-scoped for the same reason
|
||||||
|
the flag-issue list is (rule 105), offers only the two INSPECTOR roles, and the
|
||||||
|
POST re-validates against it. A facility with no contract offers nobody —
|
||||||
|
fail-closed, the follow-up stays with the original inspector.
|
||||||
|
|
||||||
|
**MT-only gap closed on the way:** MT's `GET /api/v1/inspections` had no
|
||||||
|
`follow_up_required` filter at all, so the iPad's Follow-up Requests screen
|
||||||
|
received the inspector's entire history. The filter now matches the web's
|
||||||
|
definition of "follow-up" — flagged, completed, and not yet answered by a
|
||||||
|
linked re-inspection (`~follow_ups.any()`).
|
||||||
|
|
||||||
|
### Inspector READ access follows the list, not authorship
|
||||||
|
|
||||||
|
`index()` scopes an inspector by FACILITY (rule 58), but `view()` and
|
||||||
|
`export_pdf()` scoped by authorship — so the list offered rows that answered
|
||||||
|
"Access denied" on click, and the follow-up assignee could not open the parent
|
||||||
|
inspection they had just been asked to re-inspect. Both reads now use
|
||||||
|
`_inspector_may_read()` (facility scope). **Writes stay owner-only**: `execute`,
|
||||||
|
`save_draft_ajax`, `upload_photo_ajax` and `flag_issue` keep the authorship
|
||||||
|
check. `reinspect()` belongs to the follow-up's owner; the buttons render only
|
||||||
|
for `is_own_inspection or owns_follow_up`, so the page never shows a control
|
||||||
|
that fails on click.
|
||||||
|
|
||||||
|
### One rule, two expressions, three callers
|
||||||
|
|
||||||
|
Ownership has to be stated twice — once for a loaded row, once in SQL — so both
|
||||||
|
live together in `models/inspection.py`:
|
||||||
|
|
||||||
|
* `follow_up_owner` — the property (assignee, else inspector)
|
||||||
|
* `follow_up_owned_by(user_id)` — the query predicate
|
||||||
|
|
||||||
|
Every query that scopes follow-ups calls the predicate: the mobile list filter,
|
||||||
|
the web dashboard card, and the iPad stats KPI. They each used to write their
|
||||||
|
own version and three tested AUTHORSHIP, so an assignee saw the work in their
|
||||||
|
list while both dashboards read 0 — the stats KPI sitting directly above the
|
||||||
|
Follow-up Requests list it disagreed with. Fixed in ST at the same time.
|
||||||
|
Pinned by `tests/test_followup_ownership.py`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 28. Coding Rules for AI Assistants
|
||||||
|
|
||||||
These rules apply to every change made to this codebase, without exception.
|
These rules apply to every change made to this codebase, without exception.
|
||||||
|
|
||||||
@@ -1853,3 +2234,130 @@ curl -sI -H "Host: ztest.jqc.app" http://127.0.0.1:8000/ | head -2
|
|||||||
# 6. Add to /etc/jqc/control.env: MULTI_TENANT_ENABLED=true
|
# 6. Add to /etc/jqc/control.env: MULTI_TENANT_ENABLED=true
|
||||||
# 7. sudo systemctl daemon-reload && sudo systemctl restart jqc
|
# 7. sudo systemctl daemon-reload && sudo systemctl restart jqc
|
||||||
```
|
```
|
||||||
|
---
|
||||||
|
|
||||||
|
## 29. Photo Object Storage (R2)
|
||||||
|
|
||||||
|
**Goal:** photo **files** live in Cloudflare R2 (S3-compatible, $0 egress), not on
|
||||||
|
the server's local disk. The DB is not the bottleneck — rows are tiny, PDFs are
|
||||||
|
streamed via `BytesIO` and never written to disk. Only photos accumulate, and in
|
||||||
|
a multi-tenant deployment they accumulate from every tenant onto one volume.
|
||||||
|
|
||||||
|
**No schema change, ever.** The DB stores an unprefixed relative path
|
||||||
|
(`uploads/issue_photos/abc.jpg`) and continues to. That string is the storage
|
||||||
|
**key**; `app/utils/storage.py` maps it to a backend.
|
||||||
|
|
||||||
|
### Key mapping (the rule that matters)
|
||||||
|
|
||||||
|
| Layer | Value |
|
||||||
|
|---|---|
|
||||||
|
| DB (`issues.photo_path`, `result_photos[]`, `mobile_photo_paths[]`, `inspections.form_data`, `inspection_results.photo_path`) | `uploads/issue_photos/abc.jpg` |
|
||||||
|
| Local backend, on disk | `app/static/uploads/issue_photos/abc.jpg` |
|
||||||
|
| S3 backend, object key | `t<tenant_id>/uploads/issue_photos/abc.jpg` |
|
||||||
|
|
||||||
|
The `t<tenant_id>/` prefix is applied **only** inside `S3Backend._object_key()`,
|
||||||
|
from `g.tenant`. It never enters the DB, a template, or an API payload — so the
|
||||||
|
tenant DB stays portable and every caller stays tenant-agnostic. The **local**
|
||||||
|
backend deliberately does not prefix: prefixing would relocate every existing
|
||||||
|
file, and the local layout must remain byte-identical to what predates the seam.
|
||||||
|
Local mode therefore shares one uploads directory across tenants — an isolation
|
||||||
|
weakness inherited from before multi-tenancy, and the reason to move to `s3`.
|
||||||
|
|
||||||
|
`S3Backend.save()` **raises** when `MULTI_TENANT_ENABLED` is true and no tenant
|
||||||
|
is bound, rather than writing an unprefixed key that a second tenant could later
|
||||||
|
collide with. Reads are more forgiving: `read()` / `exists()` / `delete()` try
|
||||||
|
the prefixed key and then the bare key, so objects written before the prefix
|
||||||
|
existed stay reachable. Pinned by `tests/test_storage_backend.py`.
|
||||||
|
|
||||||
|
### Config (all env, per deployment)
|
||||||
|
|
||||||
|
`STORAGE_BACKEND=local|s3` (default `local`), plus `R2_ENDPOINT_URL`,
|
||||||
|
`R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_BUCKET`, `R2_PRESIGN_TTL`
|
||||||
|
(default 86400), `R2_MEDIA_FALLBACK` (default false). `boto3` is imported lazily,
|
||||||
|
so `local` deployments never touch it — but it **is** in `requirements.txt`,
|
||||||
|
because `STORAGE_BACKEND=s3` fails at first upload without it.
|
||||||
|
|
||||||
|
`STORAGE_BACKEND` is process-wide, not per-tenant: one bucket, one backend, all
|
||||||
|
tenants, isolated by prefix. A per-tenant backend would need the resolver to
|
||||||
|
carry a storage selector and `get_backend()` to cache per tenant instead of per
|
||||||
|
app — do not half-build it.
|
||||||
|
|
||||||
|
**CSP:** `set_security_headers` in `app/__init__.py` derives the R2 host from
|
||||||
|
`R2_ENDPOINT_URL` and appends it to `img-src` automatically. Presigned images are
|
||||||
|
blocked by the browser without this. A custom R2 domain must be added too.
|
||||||
|
|
||||||
|
### Operator scripts
|
||||||
|
|
||||||
|
- `scripts/audit_photos.py` — read-only. Per tenant, collects every key its DB
|
||||||
|
references and reconciles against disk. Records the baseline that must still
|
||||||
|
resolve after cutover, and flags any key claimed by more than one tenant.
|
||||||
|
- `scripts/migrate_photos_to_r2.py` — copy-only, idempotent, resumable,
|
||||||
|
MD5+size verified. Uploads each tenant's referenced files to `t<id>/…`.
|
||||||
|
Exit 0 only when everything verifies. Orphans (referenced by no tenant) are
|
||||||
|
**not** uploaded — no prefix could legitimately claim them.
|
||||||
|
|
||||||
|
Both establish file ownership from tenant DB references, because the shared
|
||||||
|
local directory carries none. Both need `CONTROL_DATABASE_URL` +
|
||||||
|
`CONTROL_FERNET_KEY`; neither writes to any database.
|
||||||
|
|
||||||
|
**Rollback is one env var:** `STORAGE_BACKEND=local` + restart. The sync never
|
||||||
|
deletes local files, so the old tree is intact indefinitely.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 30. Database Health Check (`scripts/db_health.py`)
|
||||||
|
|
||||||
|
A standalone operations tool for the MySQL side. It imports the app factory for
|
||||||
|
config and nothing else — no request layer, no uploads tree — and the plain
|
||||||
|
invocation is **strictly read-only** (INFORMATION_SCHEMA / SHOW / EXPLAIN only).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
set -a; . /etc/jqc/control.env; set +a # needed for any --tenant but 'default'
|
||||||
|
|
||||||
|
python scripts/db_health.py # default bind, read-only
|
||||||
|
python scripts/db_health.py --tenant all # every tenant schema
|
||||||
|
python scripts/db_health.py --tenant acme --json /tmp/db.json
|
||||||
|
python scripts/db_health.py --tenant all --analyze # refresh optimizer stats (safe)
|
||||||
|
python scripts/db_health.py --tenant acme --optimize --yes # rebuild (LOCKS)
|
||||||
|
python scripts/db_health.py --tenant all \
|
||||||
|
--emit-migration migrations/versions/phase58_perf_indexes.py \
|
||||||
|
--revision phase58_perf_indexes
|
||||||
|
```
|
||||||
|
|
||||||
|
**Every tenant has its own database built from the same chain, so a schema check
|
||||||
|
is per tenant, not per deployment.** `--tenant` takes `default` (the
|
||||||
|
`SQLALCHEMY_DATABASE_URI` bind — the whole story in single-tenant mode, and the
|
||||||
|
fallback bind in MT), a slug, or `all`. Tenant modes resolve through the control
|
||||||
|
plane exactly as `control/backup.py` does, and open each tenant with a
|
||||||
|
`NullPool` engine: a one-shot CLI must not open a full pool per tenant against
|
||||||
|
the very `max_connections` it is checking.
|
||||||
|
|
||||||
|
The **server-level** checks (`max_connections`, `wait_timeout`,
|
||||||
|
`innodb_buffer_pool_size`, slow-query log, STRICT mode) describe the MySQL
|
||||||
|
instance, not a schema, so they run **once** against the first database opened.
|
||||||
|
Everything schema-shaped — hygiene, footprint, missing/redundant indexes,
|
||||||
|
unindexed FKs, EXPLAIN — runs per tenant, and each finding is tagged with the
|
||||||
|
tenant it came from.
|
||||||
|
|
||||||
|
**It never DROPs anything.** Redundant indexes are reported with the SQL to run
|
||||||
|
by hand, because "unused" is a judgement the tool should not make for you. It
|
||||||
|
also refuses to offer an **FK-backed** index as a drop candidate — dropping one
|
||||||
|
fails with errno 150.
|
||||||
|
|
||||||
|
**Prefer `--emit-migration` over `--apply-indexes` in MT.** An index applied by
|
||||||
|
hand to one tenant leaves every other tenant's schema different from it; a
|
||||||
|
migration reaches all of them through the normal
|
||||||
|
`python -m control.tenant_migrate upgrade --tenant all`. `--emit-migration`
|
||||||
|
writes ONE re-runnable migration for the union of what every inspected tenant is
|
||||||
|
missing (the chain is shared — emitting one per tenant would produce conflicting
|
||||||
|
revisions), with INFORMATION_SCHEMA guards per rule 16. It guesses
|
||||||
|
`down_revision` from the versions directory — confirm against
|
||||||
|
`python -m control.tenant_migrate heads` before committing.
|
||||||
|
|
||||||
|
`RECOMMENDED_INDEXES` in the script is the **single place** the index wish-list
|
||||||
|
lives, and every entry names the query that justifies it. An index nothing runs
|
||||||
|
is pure write-amplification, so keep speculative entries out — and when a new
|
||||||
|
hot query lands, add its index there rather than to an ad-hoc migration, so the
|
||||||
|
checker keeps agreeing with the schema.
|
||||||
|
|
||||||
|
The connection-ceiling check is MT-aware — see **rule 111**.
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
> **Audience:** AI assistants and developers extending JQC into a multi-tenant SaaS.
|
> **Audience:** AI assistants and developers extending JQC into a multi-tenant SaaS.
|
||||||
> **Companion to:** `CLAUDE.md` (single-tenant architecture reference).
|
> **Companion to:** `CLAUDE.md` (single-tenant architecture reference).
|
||||||
> **Status:** MT-0 through MT-8 complete and deployed (MT-8 billing is flag-gated behind `BILLING_ENABLED`, default off). MT-9 (iOS multi-tenant) is fully pending — both the server-side discovery endpoints and the iOS client are unbuilt.
|
> **Status:** MT-0 through MT-8 complete and deployed (MT-8 billing is flag-gated behind `BILLING_ENABLED`, default off). MT-9 (iOS multi-tenant) is fully pending — both the server-side discovery endpoints and the iOS client are unbuilt.
|
||||||
|
>
|
||||||
|
> **Feature parity with the single-tenant tree (MT-10 → MT-17): complete.** MT forked from ST before ST kept shipping, and that gap has now been closed phase by phase — see §12. The only deliberate divergence is ST's `phase50_default_modern`, which MT does not adopt (§12.3). Tenant migration head: `phase52_user_ui_theme`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -225,6 +227,61 @@ Stripe per-plan subscription, fully implemented in `app/billing/` (`routes.py`,
|
|||||||
**MT-9 — iOS multi-tenant. 🔲 PENDING (server + client both unbuilt).**
|
**MT-9 — iOS multi-tenant. 🔲 PENDING (server + client both unbuilt).**
|
||||||
Planned server side: `GET /api/v1/discover?subdomain=acme` and `GET /api/v1/tenant` public endpoints — to be exempt from tenant middleware via `MULTI_TENANT_EXEMPT_PATHS`. **Neither endpoint exists in the code yet** — no `api_discovery` blueprint is registered. iOS side: also pending (web-first priority).
|
Planned server side: `GET /api/v1/discover?subdomain=acme` and `GET /api/v1/tenant` public endpoints — to be exempt from tenant middleware via `MULTI_TENANT_EXEMPT_PATHS`. **Neither endpoint exists in the code yet** — no `api_discovery` blueprint is registered. iOS side: also pending (web-first priority).
|
||||||
|
|
||||||
|
**MT-20 — R2 photo storage cutover. ✅ CODE DONE / 🔲 CUTOVER PENDING.**
|
||||||
|
No schema change, no migration. The data-plane code was already fully on the
|
||||||
|
storage seam (`storage.save()` / `media_url()` / `materialize_to_dir()` /
|
||||||
|
`delete()` at every inspection and issue photo site) — MT served local purely
|
||||||
|
because `STORAGE_BACKEND` had never been flipped, and it **could not** be:
|
||||||
|
|
||||||
|
- `boto3` was missing from `requirements.txt`, so `S3Backend.__init__`'s lazy
|
||||||
|
import would have raised `ModuleNotFoundError` on the first upload after the
|
||||||
|
flip — at request time, not at boot. Now pinned (`boto3>=1.34`, matching ST).
|
||||||
|
- there was no cutover tooling, and ST's could not be reused (below).
|
||||||
|
|
||||||
|
**Why ST's sync script does not port.** The local backend deliberately does not
|
||||||
|
prefix keys, so one shared `app/static/uploads/` holds every tenant's photos and
|
||||||
|
a file on disk carries no ownership marker. ST's script walks the disk and
|
||||||
|
uploads everything, which on MT writes objects with no tenant prefix — keys
|
||||||
|
`S3Backend._object_key()` will never read. Ownership must instead be derived
|
||||||
|
from each tenant DB's references, then written under `t<tenant_id>/`.
|
||||||
|
|
||||||
|
| Layer | Value |
|
||||||
|
|---|---|
|
||||||
|
| DB | `uploads/issue_photos/abc.jpg` |
|
||||||
|
| Disk (local backend) | `app/static/uploads/issue_photos/abc.jpg` |
|
||||||
|
| R2 object (s3 backend) | `t3/uploads/issue_photos/abc.jpg` |
|
||||||
|
|
||||||
|
Delivered:
|
||||||
|
- `scripts/audit_photos.py` — read-only. Per tenant, collects every key its DB
|
||||||
|
references (5 sources: `issues.photo_path`, `.mobile_photo_paths[]`,
|
||||||
|
`.result_photos[]`, `inspections.form_data`, `inspection_results.photo_path`)
|
||||||
|
and reconciles against disk. Emits the baseline count that must still resolve
|
||||||
|
after cutover, plus orphans and any key claimed by more than one tenant.
|
||||||
|
- `scripts/migrate_photos_to_r2.py` — copy-only, idempotent, resumable,
|
||||||
|
MD5+size verified, tenant-prefixed. Exit 0 only on full verification.
|
||||||
|
Orphans are **not** uploaded: no prefix could legitimately claim them.
|
||||||
|
- `tests/test_storage_backend.py` — pins the prefix arithmetic, the bare key
|
||||||
|
returned to the DB, the refusal to write unprefixed with no tenant bound, and
|
||||||
|
the legacy-unprefixed read/delete fallback.
|
||||||
|
|
||||||
|
Both scripts read the control DB then each tenant DB via raw SQL (no Flask app
|
||||||
|
context — the ORM's default bind is the wrong database for every tenant), and
|
||||||
|
the sync imports `collect_referenced` from the audit script so the two key sets
|
||||||
|
can never diverge. Neither writes to any database.
|
||||||
|
|
||||||
|
`STORAGE_BACKEND` is process-wide, so cutover is all-tenants-at-once; isolation
|
||||||
|
comes from the prefix, not from separate backends. Per-tenant backend selection
|
||||||
|
would require the resolver to carry a storage selector and `get_backend()` to
|
||||||
|
cache per tenant rather than per app — do not half-build it.
|
||||||
|
|
||||||
|
Rollback is one env var (`STORAGE_BACKEND=local` + restart); the sync never
|
||||||
|
deletes local files.
|
||||||
|
|
||||||
|
**Open, deliberately not done in MT-20:** `routes/tenant_settings.py::_save_logo`
|
||||||
|
still writes tenant logos directly to `static/uploads/logos/` with `os.path.join`,
|
||||||
|
bypassing the seam. After cutover it is the only remaining local-disk writer, so
|
||||||
|
logos would sit outside whatever backs up R2.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. Tenant-zero (LT Services) migration
|
## 8. Tenant-zero (LT Services) migration
|
||||||
@@ -329,3 +386,160 @@ python -m control.tenant_migrate bootstrap --tenant acme # fresh DB only
|
|||||||
sudo systemctl status jqc-panel
|
sudo systemctl status jqc-panel
|
||||||
sudo systemctl restart jqc-panel
|
sudo systemctl restart jqc-panel
|
||||||
```
|
```
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Feature parity with the single-tenant tree (MT-10 → MT-17)
|
||||||
|
|
||||||
|
MT forked from the single-tenant codebase (`LT_Janitorial_Quality_Control`, "ST")
|
||||||
|
before ST continued shipping features. This section records how that gap was
|
||||||
|
closed. It is **complete** as of MT-17.
|
||||||
|
|
||||||
|
### 12.1 Why the two trees look more different than they are
|
||||||
|
|
||||||
|
A file-by-file comparison of the two trees overstates the gap. Several ST files
|
||||||
|
have no MT counterpart *by name* while the feature is fully present under MT's
|
||||||
|
own naming. These are **not** gaps and must not be "fixed":
|
||||||
|
|
||||||
|
| ST | MT equivalent |
|
||||||
|
|---|---|
|
||||||
|
| `models/scheduled_inspection.py` | `models/inspection_schedule.py` |
|
||||||
|
| `routes/scheduled_inspections.py` | `routes/inspection_schedules.py` |
|
||||||
|
| `routes/public.py` | `routes/facility_qr.py` |
|
||||||
|
| `models/notification_recipient.py` | `models/project_recipient.py` |
|
||||||
|
| `ContractNotificationRecipient` / `get_event_types()` | `ProjectNotificationRecipient` / `get_events()` |
|
||||||
|
| `app/add_form_schema.py` | `scripts/add_form_schema.py` |
|
||||||
|
| `support/admin_conversation_detail.html` | `support/conversation_detail.html` |
|
||||||
|
|
||||||
|
There is also one place where **MT is ahead of ST**: `utils/forms.py`
|
||||||
|
`strong_password()` (length + complexity + common-password blocklist) versus
|
||||||
|
ST's bare `Length(min=6)`. Porting ST's version would be a downgrade.
|
||||||
|
|
||||||
|
### 12.2 Phase log
|
||||||
|
|
||||||
|
Migrations `phase41` → `phase56` in `migrations/versions/` are the parity track:
|
||||||
|
auditor role, area QR tokens, schedule plan fields, internal handler, frequency
|
||||||
|
ENUM widening, recurrence, end date, parent inspection, follow-up attribution,
|
||||||
|
schedule acknowledgement, and then:
|
||||||
|
|
||||||
|
**Aug 2026 tail — ST parity, ported after MT-17.** ST's chain numbers these
|
||||||
|
differently (its phase51/52/53); MT's was already past those numbers, so match
|
||||||
|
by NAME:
|
||||||
|
|
||||||
|
| MT | ST | What |
|
||||||
|
|---|---|---|
|
||||||
|
| `phase54_user_notif_matrix` | phase51 | Per-account notification overrides for the two customer-side roles. Empty table = everyone inherits the global matrix, so it shipped changing nothing. See CLAUDE.md §27b. |
|
||||||
|
| `phase55_template_contracts` | phase52 | Forms restricted to specific contracts. **No rows = shared**, which is why it needed no backfill. |
|
||||||
|
| `phase56_followup_assignee` | phase53 | `inspections.follow_up_assigned_to` — hand a re-inspection to another inspector. NULL = the inspection's own inspector, as before. See CLAUDE.md §27c. |
|
||||||
|
|
||||||
|
Shipped alongside them, without migrations: customer-role management under
|
||||||
|
`/customers`, bulk actions on the issues/inspections lists, list-filter
|
||||||
|
preservation, contract-scoped flag-issue assignees, Customer Directors planning
|
||||||
|
their own inspections, and the support surface serving both customer roles.
|
||||||
|
|
||||||
|
**MT-15 — External Inspector role. ✅ DONE** (`phase51_external_inspector`)
|
||||||
|
Adds `external_inspector` to the `users.role` ENUM: an inspector employed by the
|
||||||
|
customer or a third party, with identical capabilities to `inspector` and scoped
|
||||||
|
the same way through `InspectorAssignment`.
|
||||||
|
|
||||||
|
The ENUM widening and the code **must ship together**. MT had ~80 sites testing
|
||||||
|
`role == 'inspector'` with a literal comparison; widening the ENUM alone would
|
||||||
|
make every one of them evaluate False for the new role and fall through to the
|
||||||
|
*unscoped* branch — `get_inspector_scope()` returns `None`, downstream queries
|
||||||
|
drop their facility filter, and an inspector employed by one customer sees every
|
||||||
|
other customer's contracts. `User.INSPECTOR_ROLES` (exposed as the
|
||||||
|
`is_inspector` property) is now the single definition, and
|
||||||
|
`test_external_inspector_scope_is_not_unrestricted` fails loudly if anyone
|
||||||
|
reverts a membership test to a literal.
|
||||||
|
|
||||||
|
Also in this phase: external inspectors are **invited**, never given a password
|
||||||
|
(`password_set=False` + emailed 72-hour token, reusing the customer invite mail),
|
||||||
|
with a new `auth.resend_invite` route so a bounced invitation cannot brick an
|
||||||
|
account permanently. Creating any other role with a blank password is now
|
||||||
|
rejected — it previously stored the hash of the empty string.
|
||||||
|
|
||||||
|
**MT-16 — Modern web portal design. ✅ DONE** (`phase52_user_ui_theme`)
|
||||||
|
Ports ST's `phase48`. `base.html` became a one-line dispatcher
|
||||||
|
(`{% extends jqc_layout %}`); the old chrome moved verbatim to
|
||||||
|
`layouts/classic.html`; `layouts/modern.html` is the sidebar shell. All existing
|
||||||
|
page templates needed **zero edits** — Jinja resolves `{% block %}` overrides
|
||||||
|
through the whole inheritance chain.
|
||||||
|
|
||||||
|
`ThemedEnvironment.get_template()` swaps `x.html` → `modern/x.html` for modern
|
||||||
|
users. **The swap is in `get_template()`, not the loader, on purpose:** Jinja's
|
||||||
|
template cache is keyed on the name `get_template()` receives, so a cached
|
||||||
|
modern template can never be served to a classic user — and in MT, where one
|
||||||
|
Gunicorn worker serves many tenants, a loader-level swap would leak across
|
||||||
|
tenants too.
|
||||||
|
|
||||||
|
MT-specific adaptations that ST's files required: tenant branding (ST hardcodes
|
||||||
|
its own company name), `inspection_schedules` for ST's `scheduled_inspections`,
|
||||||
|
`facilities.qr_print_all` for ST's `facility_qr_print_all`, the billing banner,
|
||||||
|
`role_label` for MT-15's new role, and a rewritten tenant-neutral About page.
|
||||||
|
`_quota_warning.html` is deliberately **not** in the modern layout — it is a
|
||||||
|
per-form include, not chrome, and would render twice on four pages.
|
||||||
|
|
||||||
|
**MT-17 — Enrollment intake form. ✅ DONE** (no migration)
|
||||||
|
Ports ST's `app/enrollment/` — a public, login-free intake form plus an
|
||||||
|
admin-only inbox, kept deliberately outside the schema (flat JSON, no model, no
|
||||||
|
migration, deletable package).
|
||||||
|
|
||||||
|
**Tenant isolation was the change ST's version required.** ST keeps every
|
||||||
|
submission in one flat directory; in MT that directory is shared by every tenant
|
||||||
|
on the host, so `/enrollment/admin` would list other organisations' submissions.
|
||||||
|
Submissions are now filed under `<ENROLLMENT_DIR>/t<tenant_id>/`, mirroring
|
||||||
|
`storage.tenant_key_prefix()`. When multi-tenancy is on and no tenant is bound,
|
||||||
|
`storage.enrollment_dir()` **raises `TenantUnresolved` rather than falling back
|
||||||
|
to the root** — a fallback would be a silent cross-tenant leak; an exception is
|
||||||
|
loud and safe.
|
||||||
|
|
||||||
|
Branding was the second change: ST hardcodes its company name in four places and
|
||||||
|
a personal Gmail address as the customer-facing "corrections" contact. Both now
|
||||||
|
resolve from `TenantSettings`, with a test that greps the package so they cannot
|
||||||
|
silently return.
|
||||||
|
|
||||||
|
### 12.3 Deliberate divergence: ST `phase50_default_modern` is NOT ported
|
||||||
|
|
||||||
|
ST's `phase50` flips the `ui_theme` column default to `modern` **and** runs:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
UPDATE users SET ui_theme = 'modern' WHERE ui_theme = 'classic';
|
||||||
|
```
|
||||||
|
|
||||||
|
That overwrites every saved preference. It was defensible for a single-tenant
|
||||||
|
deployment deciding for its own staff after its own A/B test.
|
||||||
|
|
||||||
|
**It is not portable to MT.** The same statement runs against *every tenant
|
||||||
|
database*, flipping the entire UI for tenants who never saw the test and never
|
||||||
|
asked. MT therefore ships the `phase48` semantics only: default `classic`, **no
|
||||||
|
backfill of any kind**.
|
||||||
|
|
||||||
|
The effective default for accounts that never chose is config
|
||||||
|
`DEFAULT_UI_THEME` (`app/__init__.py::resolve_ui_theme`), which reads the
|
||||||
|
environment and itself defaults to `classic`. A stored `users.ui_theme` always
|
||||||
|
wins. **To put a tenant on the modern design, set `DEFAULT_UI_THEME=modern` in
|
||||||
|
that tenant's process environment** — a config change, reversible, with no
|
||||||
|
preferences destroyed. `test_new_user_defaults_to_classic` pins this so a future
|
||||||
|
port of `phase50` has to be a deliberate act.
|
||||||
|
|
||||||
|
### 12.4 Open decisions
|
||||||
|
|
||||||
|
- **Seat quota.** `tenancy/quota.py::count_active_users()` counts all active
|
||||||
|
users regardless of role, so external inspectors consume a seat against
|
||||||
|
`max_users`. Intentional (they are real accounts), but tenants near their cap
|
||||||
|
will hit `@quota_soft_check('users')` when inviting third parties. Excluding
|
||||||
|
them is a billing-policy decision, not a bug fix.
|
||||||
|
- **`enrollment/schema.py::CORRECTIONS_EMAIL`** is now an empty last-resort
|
||||||
|
default. Decide whether to drop the constant and its two config fallbacks in
|
||||||
|
favour of requiring `TenantSettings.support_email`.
|
||||||
|
|
||||||
|
### 12.5 Deferred, with reasons
|
||||||
|
|
||||||
|
- **`_handler_split` dashboard cards.** ST's classic dashboard shows handler
|
||||||
|
breakdowns for *opened today* and *unassigned* as well as open issues. MT
|
||||||
|
supplies `handler_breakdown` (open issues) and both MT dashboards render it;
|
||||||
|
the other two would require converting `.count()` queries to `.all()` and
|
||||||
|
fetching full rows for a cosmetic card, which regresses large tenants. If
|
||||||
|
wanted, do it as a SQL `GROUP BY handler_type` rather than ST's Python-side
|
||||||
|
count over fetched rows.
|
||||||
|
- **PDF / audit hardening.**
|
||||||
|
- **MT-9 iOS client** (see §7).
|
||||||
|
|||||||
+157
-3
@@ -18,8 +18,25 @@ login_manager = LoginManager()
|
|||||||
migrate = Migrate()
|
migrate = Migrate()
|
||||||
mail = Mail()
|
mail = Mail()
|
||||||
csrf = CSRFProtect() # initialized here; .init_app() called in create_app()
|
csrf = CSRFProtect() # initialized here; .init_app() called in create_app()
|
||||||
|
def _rate_limit_key():
|
||||||
|
"""MT-21: scope rate-limit buckets per tenant as well as per client IP.
|
||||||
|
|
||||||
|
With a bare remote-address key, two tenants behind the same NAT egress
|
||||||
|
share every route's counter, so one tenant's traffic can lock another out
|
||||||
|
of (for example) /auth/login. Falls back to the plain address in
|
||||||
|
single-tenant mode and on tenant-exempt paths, leaving today's buckets
|
||||||
|
unchanged there.
|
||||||
|
"""
|
||||||
|
from flask import g, has_request_context
|
||||||
|
addr = get_remote_address()
|
||||||
|
if not has_request_context():
|
||||||
|
return addr
|
||||||
|
tenant = getattr(g, 'tenant', None)
|
||||||
|
return f't{tenant.id}|{addr}' if tenant is not None else addr
|
||||||
|
|
||||||
|
|
||||||
limiter = Limiter(
|
limiter = Limiter(
|
||||||
key_func = get_remote_address,
|
key_func = _rate_limit_key,
|
||||||
default_limits = [], # no global limit — applied per-route only
|
default_limits = [], # no global limit — applied per-route only
|
||||||
# Use Redis when REDIS_URL is set in the environment (production multi-worker).
|
# Use Redis when REDIS_URL is set in the environment (production multi-worker).
|
||||||
# Falls back to in-process memory for local development (single-worker only;
|
# Falls back to in-process memory for local development (single-worker only;
|
||||||
@@ -28,15 +45,57 @@ limiter = Limiter(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Web portal design: per-request template overrides (MT-16) ────────────────
|
||||||
|
# A user on the 'modern' design gets templates/modern/<name>.html in place of
|
||||||
|
# templates/<name>.html whenever that override exists; otherwise the normal
|
||||||
|
# template is used and only the layout shell + CSS differ.
|
||||||
|
#
|
||||||
|
# The rewrite happens in get_template() (not in the loader) so Jinja's template
|
||||||
|
# cache is keyed on the REWRITTEN name — a cached modern template can never be
|
||||||
|
# served to a classic user, or vice versa. A loader-level swap would have that
|
||||||
|
# bug, and in MT it would leak across tenants sharing a worker process.
|
||||||
|
from flask.templating import Environment as _FlaskJinjaEnvironment
|
||||||
|
|
||||||
|
|
||||||
|
class ThemedEnvironment(_FlaskJinjaEnvironment):
|
||||||
|
"""Jinja environment that redirects template names to modern/<name>."""
|
||||||
|
|
||||||
|
# Populated once in create_app() by scanning templates/modern/.
|
||||||
|
jqc_modern_templates: set = set()
|
||||||
|
|
||||||
|
def get_template(self, name, parent=None, globals=None):
|
||||||
|
if (isinstance(name, str)
|
||||||
|
and self.jqc_modern_templates
|
||||||
|
and not name.startswith('modern/')):
|
||||||
|
candidate = 'modern/' + name
|
||||||
|
if candidate in self.jqc_modern_templates:
|
||||||
|
from flask import g, has_request_context
|
||||||
|
if has_request_context() and getattr(g, 'jqc_theme', 'classic') == 'modern':
|
||||||
|
name = candidate
|
||||||
|
return super().get_template(name, parent, globals)
|
||||||
|
|
||||||
|
|
||||||
def create_app(config_name='default'):
|
def create_app(config_name='default'):
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
# Must be assigned BEFORE app.jinja_env is first touched (it is a cached
|
||||||
|
# property), so the themed subclass is the one actually instantiated.
|
||||||
|
app.jinja_environment = ThemedEnvironment
|
||||||
app.config.from_object(config[config_name])
|
app.config.from_object(config[config_name])
|
||||||
|
|
||||||
# Unwrap X-Forwarded-For / X-Forwarded-Proto set by Nginx so Flask sees
|
# Unwrap X-Forwarded-For / X-Forwarded-Proto set by Nginx so Flask sees
|
||||||
# the real client IP (needed for rate limiting and fail2ban logging) and
|
# the real client IP (needed for rate limiting and fail2ban logging) and
|
||||||
# the real scheme (needed for HTTPS URL generation in emails).
|
# the real scheme (needed for HTTPS URL generation in emails).
|
||||||
|
#
|
||||||
|
# MT-24: x_host is deliberately 0. With x_host=1, `request.host` was taken
|
||||||
|
# from the X-Forwarded-Host header — and nginx forwards unrecognised client
|
||||||
|
# headers upstream, so any client could supply that header and choose which
|
||||||
|
# tenant database the request bound to. Nginx already sets `Host $host`
|
||||||
|
# from the real SNI/Host, so HTTP_HOST is the trustworthy source and
|
||||||
|
# X-Forwarded-Host adds nothing but an attacker-controlled input.
|
||||||
|
# The nginx configs also pin X-Forwarded-Host explicitly (defence in depth);
|
||||||
|
# neither layer alone is relied upon. See deploy/nginx/README.md.
|
||||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||||
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
|
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=0)
|
||||||
|
|
||||||
db.init_app(app)
|
db.init_app(app)
|
||||||
login_manager.init_app(app)
|
login_manager.init_app(app)
|
||||||
@@ -122,6 +181,79 @@ def create_app(config_name='default'):
|
|||||||
app.jinja_env.globals['sla_hours_remaining'] = sla_hours_remaining
|
app.jinja_env.globals['sla_hours_remaining'] = sla_hours_remaining
|
||||||
app.jinja_env.globals['SLA_HOURS'] = SLA_HOURS
|
app.jinja_env.globals['SLA_HOURS'] = SLA_HOURS
|
||||||
|
|
||||||
|
# Photo URL resolver — routes through the active storage backend so templates
|
||||||
|
# work unchanged when the backend flips from local to R2 (see utils/storage.py).
|
||||||
|
from app.utils import storage as _storage
|
||||||
|
app.jinja_env.globals['media_url'] = _storage.media_url
|
||||||
|
|
||||||
|
# Current page URL including its query string — what list pages hand to
|
||||||
|
# their actions as `next` so filters survive an edit/delete round trip
|
||||||
|
# (see utils/decorators.return_url). full_path always appends '?', which
|
||||||
|
# is harmless but makes for ugly links, so strip a bare trailing one.
|
||||||
|
def _current_url():
|
||||||
|
from flask import request
|
||||||
|
return request.full_path.rstrip('?') if request else ''
|
||||||
|
app.jinja_env.globals['current_url'] = _current_url
|
||||||
|
|
||||||
|
# ── Web portal design wiring (MT-16) ──────────────────────────────────
|
||||||
|
# Index the modern/ override templates once at boot, so get_template()
|
||||||
|
# never has to touch the filesystem per request.
|
||||||
|
_modern_root = os.path.join(app.template_folder or 'templates', 'modern')
|
||||||
|
if not os.path.isabs(_modern_root):
|
||||||
|
_modern_root = os.path.join(app.root_path, _modern_root)
|
||||||
|
_modern_set = set()
|
||||||
|
if os.path.isdir(_modern_root):
|
||||||
|
for _dirpath, _dirnames, _filenames in os.walk(_modern_root):
|
||||||
|
for _fn in _filenames:
|
||||||
|
if _fn.endswith('.html'):
|
||||||
|
_rel = os.path.relpath(os.path.join(_dirpath, _fn), _modern_root)
|
||||||
|
_modern_set.add('modern/' + _rel.replace(os.sep, '/'))
|
||||||
|
ThemedEnvironment.jqc_modern_templates = _modern_set
|
||||||
|
app.logger.info('UI themes | modern overrides indexed: %s', len(_modern_set))
|
||||||
|
|
||||||
|
from flask import g, request as _request
|
||||||
|
|
||||||
|
@app.before_request
|
||||||
|
def resolve_ui_theme():
|
||||||
|
"""Stash the active design on `g` for ThemedEnvironment.get_template()."""
|
||||||
|
# The mobile API renders no templates and authenticates by JWT — skip it
|
||||||
|
# so this never touches the Flask-Login session loader on API traffic.
|
||||||
|
if _request.path.startswith('/api/'):
|
||||||
|
# The API renders no templates; 'classic' here only means "never
|
||||||
|
# rewrite a template name" (see ThemedEnvironment.get_template).
|
||||||
|
g.jqc_theme = 'classic'
|
||||||
|
return
|
||||||
|
from flask_login import current_user as _cu
|
||||||
|
# MT-16 — the fallback is configurable per deployment. It defaults to
|
||||||
|
# 'classic' so an existing tenant's users see no change until they opt
|
||||||
|
# in; a stored users.ui_theme always wins over the default.
|
||||||
|
default = app.config.get('DEFAULT_UI_THEME', 'classic')
|
||||||
|
theme = default
|
||||||
|
try:
|
||||||
|
if _cu.is_authenticated:
|
||||||
|
theme = _cu.ui_theme or default
|
||||||
|
except Exception: # DB column missing (migration not yet run)
|
||||||
|
theme = default
|
||||||
|
g.jqc_theme = theme if theme in ('classic', 'modern') else default
|
||||||
|
|
||||||
|
@app.context_processor
|
||||||
|
def inject_ui_theme():
|
||||||
|
"""Give base.html the shell to extend."""
|
||||||
|
from app.utils.time_utils import now_eastern
|
||||||
|
theme = getattr(g, 'jqc_theme',
|
||||||
|
app.config.get('DEFAULT_UI_THEME', 'classic'))
|
||||||
|
_now = now_eastern()
|
||||||
|
return {
|
||||||
|
'jqc_theme': theme,
|
||||||
|
'jqc_layout': 'layouts/modern.html' if theme == 'modern'
|
||||||
|
else 'layouts/classic.html',
|
||||||
|
# Long-form date shown in the modern dashboard header. The day is
|
||||||
|
# interpolated rather than formatted with '%-d' — that flag is a
|
||||||
|
# glibc extension and raises ValueError on Windows, which would
|
||||||
|
# 500 every page (this context processor runs on both themes).
|
||||||
|
'now_display': f'{_now.strftime("%A, %B")} {_now.day}, {_now.year}',
|
||||||
|
}
|
||||||
|
|
||||||
# ── Inject unread notification count into every template context ──────
|
# ── Inject unread notification count into every template context ──────
|
||||||
# This powers the red badge on the navbar bell icon without requiring
|
# This powers the red badge on the navbar bell icon without requiring
|
||||||
# individual routes to pass the count manually.
|
# individual routes to pass the count manually.
|
||||||
@@ -216,6 +348,7 @@ def create_app(config_name='default'):
|
|||||||
from app.routes import tenant_settings # MT-7 — tenant self-service
|
from app.routes import tenant_settings # MT-7 — tenant self-service
|
||||||
from app.routes import signup # MT-8+ — public self-service signup
|
from app.routes import signup # MT-8+ — public self-service signup
|
||||||
from app.routes import landing # Public apex marketing/landing page
|
from app.routes import landing # Public apex marketing/landing page
|
||||||
|
from app.routes import ui # MT-16 — design switch + new pages
|
||||||
from app.billing import bp as billing_bp # MT-8 — Stripe billing
|
from app.billing import bp as billing_bp # MT-8 — Stripe billing
|
||||||
|
|
||||||
app.register_blueprint(auth.bp)
|
app.register_blueprint(auth.bp)
|
||||||
@@ -239,6 +372,13 @@ def create_app(config_name='default'):
|
|||||||
app.register_blueprint(tenant_settings.bp)
|
app.register_blueprint(tenant_settings.bp)
|
||||||
app.register_blueprint(signup.bp)
|
app.register_blueprint(signup.bp)
|
||||||
app.register_blueprint(landing.bp)
|
app.register_blueprint(landing.bp)
|
||||||
|
app.register_blueprint(ui.bp)
|
||||||
|
|
||||||
|
# ── Enrollment form (self-contained — see app/enrollment/__init__.py) ────
|
||||||
|
# Registered last and via its own helper so the package stays deletable:
|
||||||
|
# removing app/enrollment/ and these two lines removes the feature entirely.
|
||||||
|
from app.enrollment import register_enrollment
|
||||||
|
register_enrollment(app)
|
||||||
# Billing blueprint is CSRF-exempt: /billing/webhook receives raw POST from
|
# Billing blueprint is CSRF-exempt: /billing/webhook receives raw POST from
|
||||||
# Stripe and cannot carry a CSRF token. Subscribe/portal are GET redirects
|
# Stripe and cannot carry a CSRF token. Subscribe/portal are GET redirects
|
||||||
# which Flask-WTF does not protect anyway (CSRF only applies to unsafe methods).
|
# which Flask-WTF does not protect anyway (CSRF only applies to unsafe methods).
|
||||||
@@ -261,6 +401,7 @@ def create_app(config_name='default'):
|
|||||||
from app.api.notifications import bp as _api_notifications_bp
|
from app.api.notifications import bp as _api_notifications_bp
|
||||||
from app.api.stats import bp as _api_stats_bp
|
from app.api.stats import bp as _api_stats_bp
|
||||||
from app.api.comments import bp as _api_comments_bp
|
from app.api.comments import bp as _api_comments_bp
|
||||||
|
from app.api.scheduled import bp as _api_scheduled_bp
|
||||||
csrf.exempt(_api_auth_bp)
|
csrf.exempt(_api_auth_bp)
|
||||||
csrf.exempt(_api_facilities_bp)
|
csrf.exempt(_api_facilities_bp)
|
||||||
csrf.exempt(_api_templates_bp)
|
csrf.exempt(_api_templates_bp)
|
||||||
@@ -270,11 +411,24 @@ def create_app(config_name='default'):
|
|||||||
csrf.exempt(_api_notifications_bp)
|
csrf.exempt(_api_notifications_bp)
|
||||||
csrf.exempt(_api_stats_bp)
|
csrf.exempt(_api_stats_bp)
|
||||||
csrf.exempt(_api_comments_bp)
|
csrf.exempt(_api_comments_bp)
|
||||||
|
csrf.exempt(_api_scheduled_bp)
|
||||||
register_api(app)
|
register_api(app)
|
||||||
|
|
||||||
# ── Security response headers ─────────────────────────────────────────
|
# ── Security response headers ─────────────────────────────────────────
|
||||||
# Applied to every response. Blocks clickjacking, MIME sniffing, and
|
# Applied to every response. Blocks clickjacking, MIME sniffing, and
|
||||||
# obvious XSS vectors without breaking Bootstrap CDN / Google Fonts.
|
# obvious XSS vectors without breaking Bootstrap CDN / Google Fonts.
|
||||||
|
# Allow R2 presigned photo URLs in the CSP img-src when the s3 storage
|
||||||
|
# backend is configured. Derived from R2_ENDPOINT_URL (the presigned URL
|
||||||
|
# host is the same R2 account endpoint), so nothing is hardcoded and the
|
||||||
|
# local backend is unaffected.
|
||||||
|
_r2_img_src = ''
|
||||||
|
_r2_endpoint = app.config.get('R2_ENDPOINT_URL')
|
||||||
|
if _r2_endpoint:
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
_r2_host = urlparse(_r2_endpoint).netloc
|
||||||
|
if _r2_host:
|
||||||
|
_r2_img_src = f' https://{_r2_host}'
|
||||||
|
|
||||||
@app.after_request
|
@app.after_request
|
||||||
def set_security_headers(response):
|
def set_security_headers(response):
|
||||||
from flask import request as _request
|
from flask import request as _request
|
||||||
@@ -287,7 +441,7 @@ def create_app(config_name='default'):
|
|||||||
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
|
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
|
||||||
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com; "
|
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com; "
|
||||||
"font-src 'self' data: https://fonts.gstatic.com https://cdn.jsdelivr.net; "
|
"font-src 'self' data: https://fonts.gstatic.com https://cdn.jsdelivr.net; "
|
||||||
"img-src 'self' data: blob: https://maps.gstatic.com https://maps.googleapis.com; "
|
f"img-src 'self' data: blob: https://maps.gstatic.com https://maps.googleapis.com{_r2_img_src}; "
|
||||||
"connect-src 'self' https://cdn.jsdelivr.net; "
|
"connect-src 'self' https://cdn.jsdelivr.net; "
|
||||||
"frame-src https://maps.google.com https://www.google.com; "
|
"frame-src https://maps.google.com https://www.google.com; "
|
||||||
# Hardening directives that don't affect existing inline scripts/styles:
|
# Hardening directives that don't affect existing inline scripts/styles:
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ def register_api(app):
|
|||||||
from app.api.comments import bp as comments_bp
|
from app.api.comments import bp as comments_bp
|
||||||
api_bp.register_blueprint(comments_bp)
|
api_bp.register_blueprint(comments_bp)
|
||||||
|
|
||||||
|
# phase43: Planned inspection assignments (plan-mode schedules)
|
||||||
|
from app.api.scheduled import bp as scheduled_bp
|
||||||
|
api_bp.register_blueprint(scheduled_bp)
|
||||||
|
|
||||||
# NOTE: device registration lives on the auth blueprint
|
# NOTE: device registration lives on the auth blueprint
|
||||||
# (POST /api/v1/devices/register in app/api/auth.py) and writes to the
|
# (POST /api/v1/devices/register in app/api/auth.py) and writes to the
|
||||||
# canonical api_device_tokens table (model DeviceToken). A former duplicate
|
# canonical api_device_tokens table (model DeviceToken). A former duplicate
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import logging
|
|||||||
|
|
||||||
from flask import Blueprint, request, g
|
from flask import Blueprint, request, g
|
||||||
from app import db, limiter
|
from app import db, limiter
|
||||||
|
from app.tenancy.gates import feature_required
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.api_token import RefreshToken, DeviceToken
|
from app.models.api_token import RefreshToken, DeviceToken
|
||||||
from app.api.errors import api_ok, api_error
|
from app.api.errors import api_ok, api_error
|
||||||
@@ -61,6 +62,13 @@ def _user_payload(user: User) -> dict:
|
|||||||
|
|
||||||
@bp.route('/auth/login', methods=['POST'])
|
@bp.route('/auth/login', methods=['POST'])
|
||||||
@limiter.limit('10 per minute; 3 per second')
|
@limiter.limit('10 per minute; 3 per second')
|
||||||
|
# The plan gate belongs HERE, not only on the write endpoints. It used to sit
|
||||||
|
# on POST /inspections and POST /issues alone, so a tenant without mobile API
|
||||||
|
# access could sign in, sync reference data and let an inspector complete a
|
||||||
|
# whole inspection on site — and only then get a 403, with the work already
|
||||||
|
# done and no way to submit it. Refusing at the door is the honest answer.
|
||||||
|
# Inert in single-tenant mode and for any plan that allows the mobile API.
|
||||||
|
@feature_required('mobile_api')
|
||||||
def login():
|
def login():
|
||||||
"""
|
"""
|
||||||
Authenticate with username + password.
|
Authenticate with username + password.
|
||||||
@@ -70,10 +78,22 @@ def login():
|
|||||||
{
|
{
|
||||||
"username": "john",
|
"username": "john",
|
||||||
"password": "secret",
|
"password": "secret",
|
||||||
|
"mfa_code": "123456", // required IF the account has 2FA on
|
||||||
"device_id": "A1B2C3D4...", // UIDevice.identifierForVendor (optional)
|
"device_id": "A1B2C3D4...", // UIDevice.identifierForVendor (optional)
|
||||||
"device_name": "John's iPhone" // (optional)
|
"device_name": "John's iPhone" // (optional)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Response 401 — second factor needed
|
||||||
|
-----------------------------------
|
||||||
|
{
|
||||||
|
"ok": false,
|
||||||
|
"error": "A verification code is required for this account.",
|
||||||
|
"mfa_required": true
|
||||||
|
}
|
||||||
|
|
||||||
|
The password was correct; the client should prompt for the 6-digit code
|
||||||
|
(or a recovery code) and POST again with `mfa_code`.
|
||||||
|
|
||||||
Response 200
|
Response 200
|
||||||
------------
|
------------
|
||||||
{
|
{
|
||||||
@@ -106,6 +126,41 @@ def login():
|
|||||||
if not user.active:
|
if not user.active:
|
||||||
return api_error('Account is disabled. Please contact an administrator.', 401)
|
return api_error('Account is disabled. Please contact an administrator.', 401)
|
||||||
|
|
||||||
|
# ── Two-factor (phase35 parity) ───────────────────────────────────────
|
||||||
|
# The web login defers identity to /auth/mfa when an account has TOTP
|
||||||
|
# enabled. This endpoint did not, so anyone who turned MFA on could skip it
|
||||||
|
# entirely by signing in through the app — the factor was decorative for
|
||||||
|
# exactly the accounts that chose to enable it.
|
||||||
|
#
|
||||||
|
# Accepts either a TOTP code or a single-use recovery code, the same two
|
||||||
|
# the web challenge accepts. A missing code is answered with
|
||||||
|
# `mfa_required: true` so a client can prompt for it rather than treating
|
||||||
|
# this as a wrong password.
|
||||||
|
if user.mfa_enabled and user.mfa_secret:
|
||||||
|
from app.utils.mfa import verify_totp, check_and_consume_recovery
|
||||||
|
|
||||||
|
code = (data.get('mfa_code') or '').strip()
|
||||||
|
if not code:
|
||||||
|
logger.info('API login | mfa_required | username=%s', user.username)
|
||||||
|
return api_error('A verification code is required for this account.',
|
||||||
|
401, extra={'mfa_required': True})
|
||||||
|
|
||||||
|
if not verify_totp(user.mfa_secret, code):
|
||||||
|
matched, remaining = check_and_consume_recovery(
|
||||||
|
user.mfa_recovery_codes, code)
|
||||||
|
if not matched:
|
||||||
|
logger.warning('API login | mfa_failed | username=%s | ip=%s',
|
||||||
|
user.username, request.remote_addr)
|
||||||
|
return api_error('That verification code is not valid.',
|
||||||
|
401, extra={'mfa_required': True})
|
||||||
|
# Recovery codes are single-use — persist the shortened list before
|
||||||
|
# any token is issued, so a crash cannot hand out a login while
|
||||||
|
# leaving the code usable again.
|
||||||
|
user.mfa_recovery_codes = remaining
|
||||||
|
db.session.commit()
|
||||||
|
logger.warning('API login | recovery_code_used | username=%s | '
|
||||||
|
'remaining=%d', user.username, len(remaining))
|
||||||
|
|
||||||
device_id = (data.get('device_id') or '')[:64] or None
|
device_id = (data.get('device_id') or '')[:64] or None
|
||||||
device_name = (data.get('device_name') or '')[:100] or None
|
device_name = (data.get('device_name') or '')[:100] or None
|
||||||
|
|
||||||
@@ -154,6 +209,11 @@ def login():
|
|||||||
|
|
||||||
@bp.route('/auth/refresh', methods=['POST'])
|
@bp.route('/auth/refresh', methods=['POST'])
|
||||||
@limiter.limit('30 per minute; 5 per second')
|
@limiter.limit('30 per minute; 5 per second')
|
||||||
|
# Gated too: without it a device that signed in before the plan changed would
|
||||||
|
# keep rotating tokens forever and never notice it had lost access.
|
||||||
|
# logout stays open on purpose — a blocked device must still be able to
|
||||||
|
# surrender its refresh token and clean up.
|
||||||
|
@feature_required('mobile_api')
|
||||||
def refresh():
|
def refresh():
|
||||||
"""
|
"""
|
||||||
Exchange a valid refresh token for a new access token.
|
Exchange a valid refresh token for a new access token.
|
||||||
|
|||||||
+3
-2
@@ -29,7 +29,8 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
bp = Blueprint('api_comments', __name__)
|
bp = Blueprint('api_comments', __name__)
|
||||||
|
|
||||||
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'}
|
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'external_inspector',
|
||||||
|
'project_manager', 'auditor'}
|
||||||
|
|
||||||
|
|
||||||
def _comment_payload(comment: IssueComment) -> dict:
|
def _comment_payload(comment: IssueComment) -> dict:
|
||||||
@@ -47,7 +48,7 @@ def _comment_payload(comment: IssueComment) -> dict:
|
|||||||
|
|
||||||
def _check_issue_access(issue: Issue, user) -> bool:
|
def _check_issue_access(issue: Issue, user) -> bool:
|
||||||
"""Return True if user may read/write this issue. False = 403."""
|
"""Return True if user may read/write this issue. False = 403."""
|
||||||
if user.role == 'inspector':
|
if user.is_inspector:
|
||||||
fids = get_inspector_scope(user)
|
fids = get_inspector_scope(user)
|
||||||
facility = issue.resolved_facility
|
facility = issue.resolved_facility
|
||||||
if not fids or not facility or facility.id not in fids:
|
if not fids or not facility or facility.id not in fids:
|
||||||
|
|||||||
@@ -62,6 +62,20 @@ def jwt_required(f):
|
|||||||
if payload is None:
|
if payload is None:
|
||||||
return api_error('Access token is invalid or expired', 401)
|
return api_error('Access token is invalid or expired', 401)
|
||||||
|
|
||||||
|
# MT-21: a token signed for another tenant verifies fine here (shared
|
||||||
|
# SECRET_KEY), so check the tenant claim before 'sub' is resolved
|
||||||
|
# against the bound database. No-op in single-tenant mode.
|
||||||
|
from app.tenancy.session_binding import current_tenant_id
|
||||||
|
tenant_id = current_tenant_id()
|
||||||
|
if tenant_id is not None:
|
||||||
|
token_tid = payload.get('tid')
|
||||||
|
if token_tid != tenant_id:
|
||||||
|
logger.warning(
|
||||||
|
'API tenant mismatch | token_tid=%s resolved=%s endpoint=%s',
|
||||||
|
token_tid, tenant_id, request.endpoint,
|
||||||
|
)
|
||||||
|
return api_error('Access token is not valid for this workspace', 401)
|
||||||
|
|
||||||
user_id = int(payload.get('sub', 0))
|
user_id = int(payload.get('sub', 0))
|
||||||
user = db.session.get(User, user_id)
|
user = db.session.get(User, user_id)
|
||||||
|
|
||||||
|
|||||||
+15
-4
@@ -31,13 +31,24 @@ def api_ok(data=None, status=200):
|
|||||||
}), status
|
}), status
|
||||||
|
|
||||||
|
|
||||||
def api_error(message: str, status: int = 400):
|
def api_error(message: str, status: int = 400, extra: dict | None = None):
|
||||||
"""Return an error JSON response."""
|
"""Return an error JSON response.
|
||||||
return jsonify({
|
|
||||||
|
`extra` merges additional top-level keys into the envelope — for flags a
|
||||||
|
client must branch on rather than parse out of the message, e.g.
|
||||||
|
`mfa_required` on a login that needs a second factor. Reserved keys
|
||||||
|
(ok/data/error) always win, so a caller cannot accidentally rewrite the
|
||||||
|
envelope's shape.
|
||||||
|
"""
|
||||||
|
payload = {
|
||||||
'ok': False,
|
'ok': False,
|
||||||
'data': None,
|
'data': None,
|
||||||
'error': message,
|
'error': message,
|
||||||
}), status
|
}
|
||||||
|
if extra:
|
||||||
|
for k, v in extra.items():
|
||||||
|
payload.setdefault(k, v)
|
||||||
|
return jsonify(payload), status
|
||||||
|
|
||||||
|
|
||||||
# ── Registered error handlers (attached to the api blueprint) ─────────────────
|
# ── Registered error handlers (attached to the api blueprint) ─────────────────
|
||||||
|
|||||||
+260
-11
@@ -35,7 +35,8 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
bp = Blueprint('api_inspections', __name__)
|
bp = Blueprint('api_inspections', __name__)
|
||||||
|
|
||||||
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'}
|
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'external_inspector',
|
||||||
|
'project_manager', 'auditor'}
|
||||||
|
|
||||||
|
|
||||||
def _merge_form_data(existing: dict, incoming: dict) -> dict:
|
def _merge_form_data(existing: dict, incoming: dict) -> dict:
|
||||||
@@ -96,6 +97,84 @@ def _parse_datetime(value):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _schedule_id_from(data):
|
||||||
|
"""Read the schedule link from a request body, accepting either key.
|
||||||
|
|
||||||
|
The iPad sends `scheduled_inspection_id` (the single-tenant column name it
|
||||||
|
was built against); MT's column is `inspection_schedule_id`. Both are
|
||||||
|
accepted so shipped iPad builds keep working and a future build can migrate
|
||||||
|
to the MT name without a flag day. MT's own name wins if both are present.
|
||||||
|
"""
|
||||||
|
for key in ('inspection_schedule_id', 'scheduled_inspection_id'):
|
||||||
|
if data.get(key):
|
||||||
|
return data[key]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_schedule(schedule_id, user):
|
||||||
|
"""Resolve a client-supplied schedule id to an InspectionSchedule, or None.
|
||||||
|
|
||||||
|
The iPad sends this when the inspector taps Start on a scheduled row;
|
||||||
|
without it the inspection lands unlinked and the schedule is never fulfilled
|
||||||
|
(no link on the detail page, and the schedule stays due forever).
|
||||||
|
|
||||||
|
NON-BLOCKING BY DESIGN. A bad link drops the link and logs a warning — it
|
||||||
|
never fails the submission. The app is offline-first, so a schedule can
|
||||||
|
legitimately be deleted or reassigned while a completed inspection sits in
|
||||||
|
the outbox for days; erroring here would retry-fail that inspection and
|
||||||
|
strand the inspector's work (and its photos) permanently. A missed fulfil is
|
||||||
|
recoverable from the web UI; a stranded submission is not.
|
||||||
|
|
||||||
|
The ownership check still matters: accepting a foreign link would let one
|
||||||
|
inspector fulfil another's schedule. So the link is refused — but the
|
||||||
|
inspection itself is still accepted.
|
||||||
|
"""
|
||||||
|
from app.models.inspection_schedule import InspectionSchedule
|
||||||
|
|
||||||
|
sched = db.session.get(InspectionSchedule, schedule_id)
|
||||||
|
if sched is None:
|
||||||
|
logger.warning('API INSPECTIONS | unknown schedule id=%s from user=%s '
|
||||||
|
'— submitting unlinked', schedule_id, user.username)
|
||||||
|
return None
|
||||||
|
if user.is_inspector and sched.inspector_id != user.id:
|
||||||
|
logger.warning('API INSPECTIONS | schedule id=%s not assigned to user=%s '
|
||||||
|
'— submitting unlinked', schedule_id, user.username)
|
||||||
|
return None
|
||||||
|
return sched
|
||||||
|
|
||||||
|
|
||||||
|
def _fulfill_schedule(inspection):
|
||||||
|
"""Roll the originating schedule forward. Caller commits.
|
||||||
|
|
||||||
|
Mirrors routes/inspections.py exactly, including passing `_compute_next_run`
|
||||||
|
as `next_run_fn`. As of phase46 that argument is accepted and ignored: the
|
||||||
|
cadence maths moved onto `InspectionSchedule.advance_due_date()`, which owns
|
||||||
|
the recurrence columns and the end-date boundary. Before phase46 omitting it
|
||||||
|
silently left `next_run_at` untouched and the schedule stayed permanently
|
||||||
|
due; the call is kept as-is so this file needs no behavioural change. The
|
||||||
|
deferred import mirrors the web route and avoids a module-load cycle between
|
||||||
|
the api and routes packages.
|
||||||
|
"""
|
||||||
|
if not inspection.inspection_schedule_id:
|
||||||
|
return
|
||||||
|
from app.models.inspection_schedule import InspectionSchedule
|
||||||
|
from app.routes.inspection_schedules import _compute_next_run
|
||||||
|
|
||||||
|
sched = db.session.get(InspectionSchedule, inspection.inspection_schedule_id)
|
||||||
|
if sched is None:
|
||||||
|
return
|
||||||
|
sched.fulfill(next_run_fn=_compute_next_run)
|
||||||
|
logger.info('API INSPECTIONS | schedule fulfilled | schedule=%s | inspection=%s '
|
||||||
|
'| next_due=%s', sched.id, inspection.id, sched.next_run_at)
|
||||||
|
|
||||||
|
|
||||||
|
def _media(key):
|
||||||
|
"""Absolute display URL for a storage key (presigned on R2, absolute-static
|
||||||
|
on local). '' for falsy keys. Used for iPad image rendering."""
|
||||||
|
from app.utils import storage
|
||||||
|
return storage.media_url(key, external=True) if key else ''
|
||||||
|
|
||||||
|
|
||||||
def _inspection_payload(inspection):
|
def _inspection_payload(inspection):
|
||||||
"""Serialize an Inspection to the dict returned in API responses."""
|
"""Serialize an Inspection to the dict returned in API responses."""
|
||||||
# Extract form responses from the notes JSON blob.
|
# Extract form responses from the notes JSON blob.
|
||||||
@@ -137,12 +216,32 @@ def _inspection_payload(inspection):
|
|||||||
if inspection.completed_at else None,
|
if inspection.completed_at else None,
|
||||||
'mobile_local_id': inspection.mobile_local_id,
|
'mobile_local_id': inspection.mobile_local_id,
|
||||||
'form_data': form_data,
|
'form_data': form_data,
|
||||||
|
# Absolute display URLs for image form fields (presigned on R2,
|
||||||
|
# absolute-static on local): {field_id: url}. The iPad prefers this
|
||||||
|
# over building ServerConfig + /static/ + value.
|
||||||
|
'form_media': {
|
||||||
|
fid: _media(v)
|
||||||
|
for fid, v in (form_data or {}).items()
|
||||||
|
if isinstance(v, str) and v.startswith('uploads/')
|
||||||
|
},
|
||||||
'form_schema': form_schema,
|
'form_schema': form_schema,
|
||||||
'inspector_notes': inspector_notes,
|
'inspector_notes': inspector_notes,
|
||||||
# ── Follow-up / re-inspection fields ──────────────────────────────
|
# ── Follow-up / re-inspection fields ──────────────────────────────
|
||||||
'follow_up_required': inspection.follow_up_required,
|
'follow_up_required': inspection.follow_up_required,
|
||||||
|
# phase56 — who is to perform the follow-up. NULL means the
|
||||||
|
# inspection's own inspector, which is what it always meant.
|
||||||
|
'follow_up_assigned_to': inspection.follow_up_assigned_to,
|
||||||
|
'follow_up_assigned_to_name': (inspection.follow_up_assignee.display_name
|
||||||
|
if inspection.follow_up_assignee else None),
|
||||||
'follow_up_note': inspection.follow_up_note,
|
'follow_up_note': inspection.follow_up_note,
|
||||||
'parent_inspection_id': inspection.parent_inspection_id,
|
'parent_inspection_id': inspection.parent_inspection_id,
|
||||||
|
# ── Originating schedule (MT-14) ──────────────────────────────────
|
||||||
|
# Emitted under BOTH names: `inspection_schedule_id` is MT's column,
|
||||||
|
# `scheduled_inspection_id` is the name shipped iPad builds decode.
|
||||||
|
# They always carry the same value. Drop the legacy alias once every
|
||||||
|
# deployed client has moved to the MT name.
|
||||||
|
'inspection_schedule_id': inspection.inspection_schedule_id,
|
||||||
|
'scheduled_inspection_id': inspection.inspection_schedule_id,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -163,6 +262,9 @@ def list_inspections():
|
|||||||
offset int default 0
|
offset int default 0
|
||||||
facility_id int filter by facility
|
facility_id int filter by facility
|
||||||
status str filter by status (completed, in_progress, flagged)
|
status str filter by status (completed, in_progress, flagged)
|
||||||
|
follow_up_required
|
||||||
|
bool 'true'/'1' — only inspections awaiting a re-inspection,
|
||||||
|
scoped to the caller's own follow-ups (see below)
|
||||||
from_date str ISO date (YYYY-MM-DD) — include inspections on/after this date
|
from_date str ISO date (YYYY-MM-DD) — include inspections on/after this date
|
||||||
to_date str ISO date (YYYY-MM-DD) — include inspections on/before this date
|
to_date str ISO date (YYYY-MM-DD) — include inspections on/before this date
|
||||||
|
|
||||||
@@ -183,13 +285,20 @@ def list_inspections():
|
|||||||
if user.role not in _ALLOWED_ROLES:
|
if user.role not in _ALLOWED_ROLES:
|
||||||
return api_error('Access denied', 403)
|
return api_error('Access denied', 403)
|
||||||
|
|
||||||
limit = min(int(request.args.get('limit', 50)), 200)
|
limit = min(request.args.get('limit', 50, type=int) or 50, 200)
|
||||||
offset = max(int(request.args.get('offset', 0)), 0)
|
offset = max(request.args.get('offset', 0, type=int) or 0, 0)
|
||||||
|
|
||||||
query = Inspection.query
|
query = Inspection.query
|
||||||
|
|
||||||
# Inspectors only see their own inspections
|
# Inspectors only see their own inspections.
|
||||||
if user.role == 'inspector':
|
#
|
||||||
|
# EXCEPT when asking for follow-up requests: a follow-up can now be handed
|
||||||
|
# to a different inspector (phase56), and that request lives on an
|
||||||
|
# inspection somebody ELSE performed. Applying this filter first would hide
|
||||||
|
# exactly the rows the assignee needs, so it is deferred to the follow-up
|
||||||
|
# block below, which applies ownership instead of authorship.
|
||||||
|
wants_follow_ups = request.args.get('follow_up_required', '').lower() in ('true', '1')
|
||||||
|
if user.is_inspector and not wants_follow_ups:
|
||||||
query = query.filter(Inspection.inspector_id == user.id)
|
query = query.filter(Inspection.inspector_id == user.id)
|
||||||
|
|
||||||
# Optional filters
|
# Optional filters
|
||||||
@@ -201,6 +310,38 @@ def list_inspections():
|
|||||||
if status:
|
if status:
|
||||||
query = query.filter(Inspection.status == status)
|
query = query.filter(Inspection.status == status)
|
||||||
|
|
||||||
|
if wants_follow_ups:
|
||||||
|
# MT had no follow_up_required filter at all, so the iPad's Follow-up
|
||||||
|
# Requests screen — which calls ?follow_up_required=true — received the
|
||||||
|
# inspector's ENTIRE history and presented it as outstanding requests.
|
||||||
|
#
|
||||||
|
# "Follow-up" must mean exactly what it means everywhere on the web
|
||||||
|
# (inspections.index / reports status_filter == 'follow_up'): flagged,
|
||||||
|
# completed, and not yet answered by a linked re-inspection.
|
||||||
|
#
|
||||||
|
# The ~follow_ups.any() clause is the one that matters. The web execute
|
||||||
|
# route never clears follow_up_required on the parent — it only stops
|
||||||
|
# listing it once a child exists — so filtering on the flag alone would
|
||||||
|
# return follow-ups that were already satisfied on the web, forever.
|
||||||
|
# On the iPad those rows are undismissable: pull_follow_up_requests()
|
||||||
|
# keeps receiving them and update(from:) resets fulfilledLocally, so the
|
||||||
|
# FOLLOW-UP REQUESTED card would never clear. (The mobile POST path does
|
||||||
|
# clear the parent flag, so only web-completed re-inspections stick.)
|
||||||
|
query = query.filter(
|
||||||
|
Inspection.follow_up_required.is_(True),
|
||||||
|
Inspection.status == 'completed',
|
||||||
|
).filter(~Inspection.follow_ups.any())
|
||||||
|
|
||||||
|
# Ownership, not authorship (phase56). Mirrors
|
||||||
|
# Inspection.follow_up_owner: an assigned follow-up belongs to the
|
||||||
|
# assignee ALONE, an unassigned one to the inspection's own inspector.
|
||||||
|
#
|
||||||
|
# The two arms are mutually exclusive on purpose. Without the second
|
||||||
|
# arm's `is_(None)` an inspector would keep seeing a follow-up that had
|
||||||
|
# been handed to someone else, and two people would turn up to do it.
|
||||||
|
if user.is_inspector:
|
||||||
|
query = query.filter(Inspection.follow_up_owned_by(user.id))
|
||||||
|
|
||||||
from_date_str = request.args.get('from_date')
|
from_date_str = request.args.get('from_date')
|
||||||
if from_date_str:
|
if from_date_str:
|
||||||
try:
|
try:
|
||||||
@@ -264,9 +405,16 @@ def create_inspection():
|
|||||||
"overall_score": 87.5,
|
"overall_score": 87.5,
|
||||||
"inspection_date": "2026-05-01T14:30:00",
|
"inspection_date": "2026-05-01T14:30:00",
|
||||||
"completed_at": "2026-05-01T15:00:00",
|
"completed_at": "2026-05-01T15:00:00",
|
||||||
"mobile_local_id": "uuid-string"
|
"mobile_local_id": "uuid-string",
|
||||||
|
"inspection_schedule_id": 12
|
||||||
}
|
}
|
||||||
|
|
||||||
|
`inspection_schedule_id` links the inspection to the schedule it fulfils
|
||||||
|
(sent when the inspector taps Start on a scheduled row).
|
||||||
|
`scheduled_inspection_id` is accepted as an alias for shipped iPad builds.
|
||||||
|
An unresolvable or foreign id is dropped with a warning — it never fails the
|
||||||
|
submission. The schedule is rolled forward only when status is "completed".
|
||||||
|
|
||||||
Response 200
|
Response 200
|
||||||
------------
|
------------
|
||||||
{ "ok": true, "data": { "inspection_id": 42, "duplicate": false } }
|
{ "ok": true, "data": { "inspection_id": 42, "duplicate": false } }
|
||||||
@@ -357,6 +505,29 @@ def create_inspection():
|
|||||||
submit_latitude = None
|
submit_latitude = None
|
||||||
submit_longitude = None
|
submit_longitude = None
|
||||||
|
|
||||||
|
# ── Originating schedule (MT-14) ──────────────────────────────────────
|
||||||
|
# Sent when the inspector taps Start on a scheduled row. Resolution is
|
||||||
|
# non-blocking: an unresolvable or foreign id drops the link and logs, but
|
||||||
|
# the inspection is still accepted (see _resolve_schedule).
|
||||||
|
inspection_schedule_id = None
|
||||||
|
_sched_id = _schedule_id_from(data)
|
||||||
|
if _sched_id:
|
||||||
|
_sched = _resolve_schedule(_sched_id, user)
|
||||||
|
inspection_schedule_id = _sched.id if _sched else None
|
||||||
|
|
||||||
|
# phase48 — inherit the follow-up link from the schedule when the client
|
||||||
|
# did not send one. A schedule created by "Schedule Follow-up" knows
|
||||||
|
# which inspection it answers, so the link must not depend on the client
|
||||||
|
# remembering to pass it: an older build, or a draft resumed after the
|
||||||
|
# cached row was refreshed, would otherwise submit a plain inspection
|
||||||
|
# and leave the parent flagged forever. Never overrides an explicit
|
||||||
|
# parent_inspection_id.
|
||||||
|
if not parent_inspection_id and _sched is not None and _sched.parent_inspection_id:
|
||||||
|
parent_inspection_id = _sched.parent_inspection_id
|
||||||
|
logger.info('API INSPECTIONS | parent inherited from schedule | '
|
||||||
|
'schedule=%s | parent=%s | user=%s',
|
||||||
|
_sched.id, parent_inspection_id, user.username)
|
||||||
|
|
||||||
inspection = Inspection(
|
inspection = Inspection(
|
||||||
template_id = template_id,
|
template_id = template_id,
|
||||||
facility_id = facility_id,
|
facility_id = facility_id,
|
||||||
@@ -371,11 +542,19 @@ def create_inspection():
|
|||||||
parent_inspection_id = parent_inspection_id,
|
parent_inspection_id = parent_inspection_id,
|
||||||
submit_latitude = submit_latitude,
|
submit_latitude = submit_latitude,
|
||||||
submit_longitude = submit_longitude,
|
submit_longitude = submit_longitude,
|
||||||
|
inspection_schedule_id = inspection_schedule_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
db.session.add(inspection)
|
db.session.add(inspection)
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
|
|
||||||
|
# ── Fulfil the originating schedule ───────────────────────────────────
|
||||||
|
# Staged into the same atomic commit as the inspection, mirroring the web
|
||||||
|
# route. Only on completion: an in_progress submission has not satisfied
|
||||||
|
# the occurrence, so rolling the schedule forward there would skip a cycle.
|
||||||
|
if status == 'completed':
|
||||||
|
_fulfill_schedule(inspection)
|
||||||
|
|
||||||
# ── Auto-clear follow-up flag on parent ───────────────────────────────
|
# ── Auto-clear follow-up flag on parent ───────────────────────────────
|
||||||
# When a completed re-inspection arrives that links to a parent, clear
|
# When a completed re-inspection arrives that links to a parent, clear
|
||||||
# follow_up_required on the parent automatically. This mirrors the web
|
# follow_up_required on the parent automatically. This mirrors the web
|
||||||
@@ -465,8 +644,13 @@ def update_inspection(inspection_id):
|
|||||||
"form_data": { ... },
|
"form_data": { ... },
|
||||||
"notes": "...",
|
"notes": "...",
|
||||||
"overall_score": 91.0,
|
"overall_score": 91.0,
|
||||||
"completed_at": "2026-05-01T15:30:00"
|
"completed_at": "2026-05-01T15:30:00",
|
||||||
|
"inspection_schedule_id": 12
|
||||||
}
|
}
|
||||||
|
|
||||||
|
`inspection_schedule_id` links the inspection to the schedule it fulfils.
|
||||||
|
`scheduled_inspection_id` is accepted as an alias for shipped iPad builds.
|
||||||
|
The schedule is rolled forward only on the draft → completed transition.
|
||||||
"""
|
"""
|
||||||
user = g.api_user
|
user = g.api_user
|
||||||
|
|
||||||
@@ -477,7 +661,7 @@ def update_inspection(inspection_id):
|
|||||||
if inspection is None:
|
if inspection is None:
|
||||||
return api_error('Inspection not found', 404)
|
return api_error('Inspection not found', 404)
|
||||||
|
|
||||||
if user.role == 'inspector' and inspection.inspector_id != user.id:
|
if user.is_inspector and inspection.inspector_id != user.id:
|
||||||
return api_error('Access denied', 403)
|
return api_error('Access denied', 403)
|
||||||
|
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
@@ -500,6 +684,31 @@ def update_inspection(inspection_id):
|
|||||||
|
|
||||||
prev_status = inspection.status
|
prev_status = inspection.status
|
||||||
|
|
||||||
|
# Allow the link to be set/corrected on PATCH too — the iPad may create the
|
||||||
|
# inspection as a draft first and only attach the schedule on submit.
|
||||||
|
# Same non-blocking semantics as create: a bad id leaves the link untouched.
|
||||||
|
_sched_id = _schedule_id_from(data)
|
||||||
|
if _sched_id:
|
||||||
|
_sched = _resolve_schedule(_sched_id, user)
|
||||||
|
if _sched is not None:
|
||||||
|
inspection.inspection_schedule_id = _sched.id
|
||||||
|
# phase48 parity with the POST path: a schedule created by
|
||||||
|
# "Schedule Follow-up" knows which inspection it answers, so a draft
|
||||||
|
# that only gets its schedule attached here still becomes a properly
|
||||||
|
# linked re-inspection. Never overrides an explicit parent.
|
||||||
|
if not inspection.parent_inspection_id and _sched.parent_inspection_id:
|
||||||
|
inspection.parent_inspection_id = _sched.parent_inspection_id
|
||||||
|
logger.info('API INSPECTIONS | parent inherited from schedule on '
|
||||||
|
'PATCH | schedule=%s | parent=%s | user=%s',
|
||||||
|
_sched.id, _sched.parent_inspection_id, user.username)
|
||||||
|
|
||||||
|
# An explicitly supplied parent still wins, and can be set on the draft
|
||||||
|
# before submit — mirrors the POST handler's field list.
|
||||||
|
if 'parent_inspection_id' in data:
|
||||||
|
_pid = data.get('parent_inspection_id')
|
||||||
|
if isinstance(_pid, int) and db.session.get(Inspection, _pid) is not None:
|
||||||
|
inspection.parent_inspection_id = _pid
|
||||||
|
|
||||||
if 'status' in data:
|
if 'status' in data:
|
||||||
inspection.status = data['status']
|
inspection.status = data['status']
|
||||||
|
|
||||||
@@ -515,12 +724,52 @@ def update_inspection(inspection_id):
|
|||||||
elif data.get('status') == 'completed' and not inspection.completed_at:
|
elif data.get('status') == 'completed' and not inspection.completed_at:
|
||||||
inspection.completed_at = now_eastern()
|
inspection.completed_at = now_eastern()
|
||||||
|
|
||||||
db.session.commit()
|
# Computed BEFORE the commit so the schedule fulfil can be staged into the
|
||||||
|
# same transaction; reused after the commit for the notification below.
|
||||||
# Notify when a draft transitions to completed — mirrors the POST handler.
|
|
||||||
transitioning_to_complete = (
|
transitioning_to_complete = (
|
||||||
data.get('status') == 'completed' and prev_status != 'completed'
|
data.get('status') == 'completed' and prev_status != 'completed'
|
||||||
)
|
)
|
||||||
|
# Fulfil on the draft → completed transition ONLY, so a later PATCH on an
|
||||||
|
# already-completed inspection cannot roll the schedule forward twice.
|
||||||
|
if transitioning_to_complete:
|
||||||
|
_fulfill_schedule(inspection)
|
||||||
|
|
||||||
|
# ── Auto-clear follow-up flag on parent ───────────────────────────────
|
||||||
|
# Mirrors the POST handler. This was previously MISSING here, so an iPad
|
||||||
|
# that created a follow-up as a draft and submitted it via PATCH left the
|
||||||
|
# parent flagged forever — the re-inspection happened, but the parent still
|
||||||
|
# showed "Follow-up Inspection Required" and stayed in every manager's
|
||||||
|
# outstanding list. phase48 made that a normal path, since a schedule-started
|
||||||
|
# follow-up is a draft first.
|
||||||
|
#
|
||||||
|
# Same commit-ordering rule as the POST handler: log_action() must fire AFTER
|
||||||
|
# db.session.commit(), because audit.py commits internally and would
|
||||||
|
# otherwise persist the parent's flag change before this inspection's own
|
||||||
|
# changes are committed — a partial state if the main commit then failed.
|
||||||
|
_parent_log_args = None
|
||||||
|
if transitioning_to_complete and inspection.parent_inspection_id:
|
||||||
|
parent_insp = db.session.get(Inspection, inspection.parent_inspection_id)
|
||||||
|
if parent_insp and parent_insp.follow_up_required:
|
||||||
|
parent_insp.follow_up_required = False
|
||||||
|
logger.info(
|
||||||
|
'API INSPECTIONS | follow_up cleared on PATCH | parent_id=%s | '
|
||||||
|
'by_inspection_id=%s | user=%s',
|
||||||
|
parent_insp.id, inspection.id, user.username,
|
||||||
|
)
|
||||||
|
# Snapshot label strings now — ORM objects may be expired after commit.
|
||||||
|
_parent_log_args = (
|
||||||
|
parent_insp.id,
|
||||||
|
f'{parent_insp.template.name} @ {parent_insp.facility.name}',
|
||||||
|
f'follow_up_required=False (cleared by re-inspection '
|
||||||
|
f'#{inspection.id} via mobile API)',
|
||||||
|
)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
if _parent_log_args:
|
||||||
|
log_action(ACTION_UPDATE, 'Inspection', *_parent_log_args)
|
||||||
|
|
||||||
|
# Notify when a draft transitions to completed — mirrors the POST handler.
|
||||||
if transitioning_to_complete:
|
if transitioning_to_complete:
|
||||||
score_val = inspection.overall_score
|
score_val = inspection.overall_score
|
||||||
score_display = f'{score_val:.1f}%' if score_val is not None else 'N/A'
|
score_display = f'{score_val:.1f}%' if score_val is not None else 'N/A'
|
||||||
|
|||||||
+128
-9
@@ -42,15 +42,24 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
bp = Blueprint('api_issues', __name__)
|
bp = Blueprint('api_issues', __name__)
|
||||||
|
|
||||||
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'}
|
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'external_inspector',
|
||||||
|
'project_manager', 'auditor'}
|
||||||
_VALID_SEVERITY = {'low', 'medium', 'high', 'critical'}
|
_VALID_SEVERITY = {'low', 'medium', 'high', 'critical'}
|
||||||
_VALID_STATUSES = {'open', 'in_progress', 'resolved', 'pending_verification'}
|
_VALID_STATUSES = {'open', 'in_progress', 'resolved', 'pending_verification'}
|
||||||
|
_VALID_HANDLERS = {'internal', 'facility', 'vendor'}
|
||||||
_UUID_RE = re.compile(
|
_UUID_RE = re.compile(
|
||||||
r'^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$',
|
r'^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$',
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _photo_urls(keys):
|
||||||
|
"""Map storage keys to absolute display URLs (presigned on R2, absolute-static
|
||||||
|
on local). Falsy keys are skipped. Used for iPad photo rendering."""
|
||||||
|
from app.utils import storage
|
||||||
|
return [storage.media_url(k, external=True) for k in keys if k]
|
||||||
|
|
||||||
|
|
||||||
def _issue_payload(issue):
|
def _issue_payload(issue):
|
||||||
"""Serialise an Issue to the dict returned in list/detail responses."""
|
"""Serialise an Issue to the dict returned in list/detail responses."""
|
||||||
facility = issue.resolved_facility
|
facility = issue.resolved_facility
|
||||||
@@ -71,6 +80,14 @@ def _issue_payload(issue):
|
|||||||
'photo_path': issue.photo_path or None,
|
'photo_path': issue.photo_path or None,
|
||||||
'mobile_photo_paths': issue.mobile_photo_paths or [],
|
'mobile_photo_paths': issue.mobile_photo_paths or [],
|
||||||
'result_photos': issue.result_photos or [],
|
'result_photos': issue.result_photos or [],
|
||||||
|
# Absolute display URLs (presigned on R2, absolute-static on local) for
|
||||||
|
# the iPad, which loads photos off-origin. Relative keys above stay as
|
||||||
|
# keys. photo_urls order mirrors the iPad's evidence merge:
|
||||||
|
# [photo_path] + mobile_photo_paths.
|
||||||
|
'photo_urls': _photo_urls(
|
||||||
|
([issue.photo_path] if issue.photo_path else [])
|
||||||
|
+ (issue.mobile_photo_paths or [])),
|
||||||
|
'result_photo_urls': _photo_urls(issue.result_photos or []),
|
||||||
# Resolution details — set by web staff after fixing the issue.
|
# Resolution details — set by web staff after fixing the issue.
|
||||||
'result_notes': issue.result_notes or None,
|
'result_notes': issue.result_notes or None,
|
||||||
# Verification fields — set after a director/admin confirms fix.
|
# Verification fields — set after a director/admin confirms fix.
|
||||||
@@ -82,6 +99,22 @@ def _issue_payload(issue):
|
|||||||
'area_name': issue.area.name if issue.area else None,
|
'area_name': issue.area.name if issue.area else None,
|
||||||
# Assigned-to display name — set when a director assigns the issue to a user.
|
# Assigned-to display name — set when a director assigns the issue to a user.
|
||||||
'assigned_to_name': issue.assigned_user.display_name if issue.assigned_user else None,
|
'assigned_to_name': issue.assigned_user.display_name if issue.assigned_user else None,
|
||||||
|
# ── Handler ("Handled By", phase39) ───────────────────────────────
|
||||||
|
# handler_type categorises WHO resolves the issue:
|
||||||
|
# internal = our staff (assigned_to) facility = facility's own staff
|
||||||
|
# vendor = external contractor
|
||||||
|
'handler_type': issue.handler_type or 'internal',
|
||||||
|
'handler_label': issue.handler_label,
|
||||||
|
'facility_handler_name': issue.facility_handler_name or None,
|
||||||
|
'facility_handler_contact': issue.facility_handler_contact or None,
|
||||||
|
'facility_handler_notes': issue.facility_handler_notes or None,
|
||||||
|
'vendor_name': issue.vendor_name or None,
|
||||||
|
'vendor_contact': issue.vendor_contact or None,
|
||||||
|
'vendor_notes': issue.vendor_notes or None,
|
||||||
|
# Janitorial staff handler — used when handler_type == 'internal'.
|
||||||
|
# Distinct from assigned_to: the crew member may not be a system user.
|
||||||
|
'internal_handler_name': issue.internal_handler_name or None,
|
||||||
|
'internal_handler_contact': issue.internal_handler_contact or None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -119,12 +152,12 @@ def list_issues():
|
|||||||
if user.role not in _ALLOWED_ROLES:
|
if user.role not in _ALLOWED_ROLES:
|
||||||
return api_error('Access denied', 403)
|
return api_error('Access denied', 403)
|
||||||
|
|
||||||
limit = min(int(request.args.get('limit', 100)), 200)
|
limit = min(request.args.get('limit', 100, type=int) or 100, 200)
|
||||||
offset = max(int(request.args.get('offset', 0)), 0)
|
offset = max(request.args.get('offset', 0, type=int) or 0, 0)
|
||||||
|
|
||||||
query = Issue.query
|
query = Issue.query
|
||||||
|
|
||||||
if user.role == 'inspector':
|
if user.is_inspector:
|
||||||
fids = get_inspector_scope(user)
|
fids = get_inspector_scope(user)
|
||||||
if not fids:
|
if not fids:
|
||||||
return api_ok({'issues': [], 'total': 0, 'limit': limit, 'offset': offset})
|
return api_ok({'issues': [], 'total': 0, 'limit': limit, 'offset': offset})
|
||||||
@@ -218,7 +251,7 @@ def create_issue():
|
|||||||
if facility is None:
|
if facility is None:
|
||||||
return api_error('Facility not found', 404)
|
return api_error('Facility not found', 404)
|
||||||
|
|
||||||
if user.role == 'inspector':
|
if user.is_inspector:
|
||||||
fids = get_inspector_scope(user)
|
fids = get_inspector_scope(user)
|
||||||
if not fids or facility_id not in fids:
|
if not fids or facility_id not in fids:
|
||||||
return api_error('Access denied — facility is not in your assigned contracts', 403)
|
return api_error('Access denied — facility is not in your assigned contracts', 403)
|
||||||
@@ -307,7 +340,7 @@ def get_issue(issue_id):
|
|||||||
if issue is None:
|
if issue is None:
|
||||||
return api_error('Issue not found', 404)
|
return api_error('Issue not found', 404)
|
||||||
|
|
||||||
if user.role == 'inspector':
|
if user.is_inspector:
|
||||||
fids = get_inspector_scope(user)
|
fids = get_inspector_scope(user)
|
||||||
facility = issue.resolved_facility
|
facility = issue.resolved_facility
|
||||||
if not fids or not facility or facility.id not in fids:
|
if not fids or not facility or facility.id not in fids:
|
||||||
@@ -340,7 +373,7 @@ def update_issue_status(issue_id):
|
|||||||
if issue is None:
|
if issue is None:
|
||||||
return api_error('Issue not found', 404)
|
return api_error('Issue not found', 404)
|
||||||
|
|
||||||
if user.role == 'inspector':
|
if user.is_inspector:
|
||||||
fids = get_inspector_scope(user)
|
fids = get_inspector_scope(user)
|
||||||
facility = issue.resolved_facility
|
facility = issue.resolved_facility
|
||||||
if not fids or not facility or facility.id not in fids:
|
if not fids or not facility or facility.id not in fids:
|
||||||
@@ -405,7 +438,7 @@ def update_issue_photos(issue_id):
|
|||||||
if issue is None:
|
if issue is None:
|
||||||
return api_error('Issue not found', 404)
|
return api_error('Issue not found', 404)
|
||||||
|
|
||||||
if user.role == 'inspector':
|
if user.is_inspector:
|
||||||
fids = get_inspector_scope(user)
|
fids = get_inspector_scope(user)
|
||||||
facility = issue.resolved_facility
|
facility = issue.resolved_facility
|
||||||
if not fids or not facility or facility.id not in fids:
|
if not fids or not facility or facility.id not in fids:
|
||||||
@@ -466,7 +499,7 @@ def update_issue_result_photos(issue_id):
|
|||||||
if issue is None:
|
if issue is None:
|
||||||
return api_error('Issue not found', 404)
|
return api_error('Issue not found', 404)
|
||||||
|
|
||||||
if user.role == 'inspector':
|
if user.is_inspector:
|
||||||
fids = get_inspector_scope(user)
|
fids = get_inspector_scope(user)
|
||||||
facility = issue.resolved_facility
|
facility = issue.resolved_facility
|
||||||
if not fids or not facility or facility.id not in fids:
|
if not fids or not facility or facility.id not in fids:
|
||||||
@@ -497,3 +530,89 @@ def update_issue_result_photos(issue_id):
|
|||||||
issue.id, len(new_photos), user.username)
|
issue.id, len(new_photos), user.username)
|
||||||
|
|
||||||
return api_ok({'issue_id': issue.id, 'result_photos_count': len(merged)})
|
return api_ok({'issue_id': issue.id, 'result_photos_count': len(merged)})
|
||||||
|
|
||||||
|
|
||||||
|
# ── Update Issue Handler ("Handled By") ───────────────────────────────────────
|
||||||
|
|
||||||
|
@bp.route('/issues/<int:issue_id>/handler', methods=['PATCH'])
|
||||||
|
@jwt_required
|
||||||
|
def update_issue_handler(issue_id):
|
||||||
|
"""
|
||||||
|
Set who handles an issue ("Handled By") from the mobile app.
|
||||||
|
|
||||||
|
Unlike the web form (which limits handler edits to admin/director/PM/auditor),
|
||||||
|
the iPad allows the assigned inspector to set the handler from the field,
|
||||||
|
scoped to issues at their assigned facilities. This divergence is deliberate:
|
||||||
|
the inspector is the one standing in the building who knows whether the
|
||||||
|
facility's own staff or a vendor should take it.
|
||||||
|
|
||||||
|
Request JSON
|
||||||
|
------------
|
||||||
|
{
|
||||||
|
"handler_type": "internal" | "facility" | "vendor",
|
||||||
|
"facility_handler_name": "...", // optional (facility handler)
|
||||||
|
"facility_handler_contact": "...", // optional
|
||||||
|
"facility_handler_notes": "...", // optional
|
||||||
|
"vendor_name": "...", // optional (vendor handler)
|
||||||
|
"vendor_contact": "...", // optional
|
||||||
|
"vendor_notes": "...", // optional
|
||||||
|
"internal_handler_name": "...", // optional (janitorial staff handler)
|
||||||
|
"internal_handler_contact": "..." // optional
|
||||||
|
}
|
||||||
|
|
||||||
|
Only keys present in the body are updated; empty strings clear a field.
|
||||||
|
handler_type is required.
|
||||||
|
|
||||||
|
Access:
|
||||||
|
- admin / director / project_manager / auditor : any issue
|
||||||
|
- inspector : only issues at their assigned facilities
|
||||||
|
- customer : denied
|
||||||
|
"""
|
||||||
|
user = g.api_user
|
||||||
|
if user.role not in _ALLOWED_ROLES:
|
||||||
|
return api_error('Access denied', 403)
|
||||||
|
|
||||||
|
issue = db.session.get(Issue, issue_id)
|
||||||
|
if issue is None:
|
||||||
|
return api_error('Issue not found', 404)
|
||||||
|
|
||||||
|
if user.is_inspector:
|
||||||
|
fids = get_inspector_scope(user)
|
||||||
|
facility = issue.resolved_facility
|
||||||
|
if not fids or not facility or facility.id not in fids:
|
||||||
|
return api_error('Access denied', 403)
|
||||||
|
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
handler = (data.get('handler_type') or '').strip().lower()
|
||||||
|
|
||||||
|
if handler not in _VALID_HANDLERS:
|
||||||
|
return api_error(
|
||||||
|
f'handler_type must be one of: {", ".join(sorted(_VALID_HANDLERS))}', 400
|
||||||
|
)
|
||||||
|
|
||||||
|
old_handler = issue.handler_type or 'internal'
|
||||||
|
issue.handler_type = handler
|
||||||
|
|
||||||
|
# Update only the detail fields that were supplied. Empty string clears
|
||||||
|
# the field (stored as NULL); a missing key leaves the field untouched.
|
||||||
|
_text_fields = (
|
||||||
|
'facility_handler_name', 'facility_handler_contact', 'facility_handler_notes',
|
||||||
|
'vendor_name', 'vendor_contact', 'vendor_notes',
|
||||||
|
'internal_handler_name', 'internal_handler_contact',
|
||||||
|
)
|
||||||
|
for field in _text_fields:
|
||||||
|
if field in data:
|
||||||
|
val = (data.get(field) or '').strip()
|
||||||
|
setattr(issue, field, val or None)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
log_action(ACTION_UPDATE, 'Issue', issue.id,
|
||||||
|
f'handler {old_handler} → {handler}',
|
||||||
|
f'source=mobile; updated_by={user.username}')
|
||||||
|
|
||||||
|
logger.info('API ISSUES | handler_updated | issue_id=%d | %s→%s | user=%s',
|
||||||
|
issue.id, old_handler, handler, user.username)
|
||||||
|
|
||||||
|
return api_ok({'issue_id': issue.id, 'handler_type': issue.handler_type,
|
||||||
|
'handler_label': issue.handler_label})
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ claims needed to identify the caller:
|
|||||||
{
|
{
|
||||||
"sub": "42", # user.id as string
|
"sub": "42", # user.id as string
|
||||||
"role": "inspector", # user.role
|
"role": "inspector", # user.role
|
||||||
|
"tid": 3, # issuing tenant id (multi-tenant mode only)
|
||||||
"iat": 1710000000, # issued-at (UTC epoch)
|
"iat": 1710000000, # issued-at (UTC epoch)
|
||||||
"exp": 1710003600, # expiry (UTC epoch, 60 min later)
|
"exp": 1710003600, # expiry (UTC epoch, 60 min later)
|
||||||
}
|
}
|
||||||
@@ -33,6 +34,12 @@ def _secret():
|
|||||||
return current_app.config['SECRET_KEY']
|
return current_app.config['SECRET_KEY']
|
||||||
|
|
||||||
|
|
||||||
|
def _current_tenant_id():
|
||||||
|
"""Resolved tenant id, or None in single-tenant / unbound contexts."""
|
||||||
|
from app.tenancy.session_binding import current_tenant_id
|
||||||
|
return current_tenant_id()
|
||||||
|
|
||||||
|
|
||||||
def generate_access_token(user, lifetime_minutes: int = ACCESS_TOKEN_LIFETIME_MINUTES) -> str:
|
def generate_access_token(user, lifetime_minutes: int = ACCESS_TOKEN_LIFETIME_MINUTES) -> str:
|
||||||
"""
|
"""
|
||||||
Create and sign a new access token for the given user.
|
Create and sign a new access token for the given user.
|
||||||
@@ -54,6 +61,14 @@ def generate_access_token(user, lifetime_minutes: int = ACCESS_TOKEN_LIFETIME_MI
|
|||||||
'iat': now,
|
'iat': now,
|
||||||
'exp': now + timedelta(minutes=lifetime_minutes),
|
'exp': now + timedelta(minutes=lifetime_minutes),
|
||||||
}
|
}
|
||||||
|
# MT-21: bind the token to the issuing tenant. Every tenant is signed with
|
||||||
|
# the same SECRET_KEY, so without this claim a token minted at one tenant
|
||||||
|
# host verifies at another and 'sub' resolves against whichever database
|
||||||
|
# the middleware bound. Omitted in single-tenant mode so token shape is
|
||||||
|
# unchanged there.
|
||||||
|
tid = _current_tenant_id()
|
||||||
|
if tid is not None:
|
||||||
|
payload['tid'] = tid
|
||||||
return jwt.encode(payload, _secret(), algorithm='HS256')
|
return jwt.encode(payload, _secret(), algorithm='HS256')
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ def list_notifications():
|
|||||||
"""
|
"""
|
||||||
user = g.api_user
|
user = g.api_user
|
||||||
since = _parse_since(request.args.get('since'))
|
since = _parse_since(request.args.get('since'))
|
||||||
limit = min(int(request.args.get('limit', 50)), 50)
|
limit = min(request.args.get('limit', 50, type=int) or 50, 50)
|
||||||
|
|
||||||
def _run_orm():
|
def _run_orm():
|
||||||
q = Notification.query.filter_by(user_id=user.id, is_read=False)
|
q = Notification.query.filter_by(user_id=user.id, is_read=False)
|
||||||
|
|||||||
+70
-14
@@ -25,7 +25,8 @@ logger = logging.getLogger(__name__)
|
|||||||
bp = Blueprint('api_photos', __name__)
|
bp = Blueprint('api_photos', __name__)
|
||||||
|
|
||||||
_ALLOWED_EXTENSIONS = {'jpg', 'jpeg', 'png', 'gif'}
|
_ALLOWED_EXTENSIONS = {'jpg', 'jpeg', 'png', 'gif'}
|
||||||
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'}
|
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'external_inspector',
|
||||||
|
'project_manager', 'auditor'}
|
||||||
|
|
||||||
|
|
||||||
def _allowed_file(filename: str) -> bool:
|
def _allowed_file(filename: str) -> bool:
|
||||||
@@ -47,31 +48,58 @@ def upload_photo():
|
|||||||
---------------------
|
---------------------
|
||||||
file — binary image data (jpg / png / gif)
|
file — binary image data (jpg / png / gif)
|
||||||
entity_type — "inspection" | "issue" | "issue_result" (controls subfolder)
|
entity_type — "inspection" | "issue" | "issue_result" (controls subfolder)
|
||||||
|
captured_at — OPTIONAL ISO-8601 capture time (e.g. 2026-07-20T09:14:22-04:00)
|
||||||
|
latitude — OPTIONAL decimal degrees at capture
|
||||||
|
longitude — OPTIONAL decimal degrees at capture
|
||||||
|
|
||||||
|
A capture-time + geo overlay is burned into the image before it is stored
|
||||||
|
(see app/utils/photo_stamp.py). Metadata is taken from the client fields
|
||||||
|
above, falling back to the image's EXIF, then to server receipt time.
|
||||||
|
Sending captured_at/latitude/longitude is strongly preferred for an
|
||||||
|
offline-first client: a photo taken at 09:14 but synced at 16:00 would
|
||||||
|
otherwise be stamped with the sync time.
|
||||||
|
|
||||||
Response 200
|
Response 200
|
||||||
------------
|
------------
|
||||||
{
|
{
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"data": {
|
"data": {
|
||||||
"server_path": "uploads/inspection_photos/abc123.jpg"
|
"server_path": "uploads/inspection_photos/abc123.jpg",
|
||||||
|
"stamped": true,
|
||||||
|
"captured_at": "2026-07-20T09:14:22",
|
||||||
|
"capture_source": "client"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
The three stamp keys are additive — an iPad build that predates them decodes
|
||||||
|
explicit CodingKeys and ignores what it doesn't know.
|
||||||
"""
|
"""
|
||||||
user = g.api_user
|
user = g.api_user
|
||||||
|
|
||||||
if user.role not in _ALLOWED_ROLES:
|
if user.role not in _ALLOWED_ROLES:
|
||||||
return api_error('Access denied', 403)
|
return api_error('Access denied', 403)
|
||||||
|
|
||||||
|
# Every rejection below is logged at WARNING with the user. Only SUCCESSES
|
||||||
|
# were logged before, so when an inspector's photos failed repeatedly there
|
||||||
|
# was nothing server-side to explain why — and a photo that exhausts its
|
||||||
|
# upload attempts costs the inspection its evidence (see the iPad's
|
||||||
|
# PendingPhoto.lastUploadError for the device half of this).
|
||||||
if 'file' not in request.files:
|
if 'file' not in request.files:
|
||||||
|
logger.warning('API PHOTOS | rejected | reason=no_file_part | user=%s',
|
||||||
|
user.username)
|
||||||
return api_error('No file provided', 400)
|
return api_error('No file provided', 400)
|
||||||
|
|
||||||
file_obj = request.files['file']
|
file_obj = request.files['file']
|
||||||
entity_type = request.form.get('entity_type', 'inspection')
|
entity_type = request.form.get('entity_type', 'inspection')
|
||||||
|
|
||||||
if not file_obj or not file_obj.filename:
|
if not file_obj or not file_obj.filename:
|
||||||
|
logger.warning('API PHOTOS | rejected | reason=empty_file | user=%s',
|
||||||
|
user.username)
|
||||||
return api_error('Empty file', 400)
|
return api_error('Empty file', 400)
|
||||||
|
|
||||||
if not _allowed_file(file_obj.filename):
|
if not _allowed_file(file_obj.filename):
|
||||||
|
logger.warning('API PHOTOS | rejected | reason=bad_extension | file=%r | user=%s',
|
||||||
|
file_obj.filename, user.username)
|
||||||
return api_error(
|
return api_error(
|
||||||
f'File type not allowed. Accepted: {", ".join(sorted(_ALLOWED_EXTENSIONS))}',
|
f'File type not allowed. Accepted: {", ".join(sorted(_ALLOWED_EXTENSIONS))}',
|
||||||
400
|
400
|
||||||
@@ -85,18 +113,46 @@ def upload_photo():
|
|||||||
else:
|
else:
|
||||||
subfolder = 'inspection_photos'
|
subfolder = 'inspection_photos'
|
||||||
|
|
||||||
ext = file_obj.filename.rsplit('.', 1)[-1].lower()
|
# Burn the capture-time + geo overlay before the bytes are ever stored, so
|
||||||
filename = f'{uuid.uuid4().hex}.{ext}'
|
# exactly one (already-stamped) object is written and nothing has to be
|
||||||
|
# read back out of R2. Any stamping failure returns the original bytes.
|
||||||
|
meta = {'stamped': False, 'captured_at': None, 'source': None}
|
||||||
|
if current_app.config.get('PHOTO_STAMP_ENABLED', True):
|
||||||
|
from app.utils.photo_stamp import stamp_file_storage
|
||||||
|
file_obj, meta = stamp_file_storage(
|
||||||
|
file_obj,
|
||||||
|
captured_at = request.form.get('captured_at'),
|
||||||
|
latitude = request.form.get('latitude'),
|
||||||
|
longitude = request.form.get('longitude'),
|
||||||
|
)
|
||||||
|
|
||||||
dest_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], subfolder)
|
# Write via the active storage backend (local disk or R2). Key format
|
||||||
os.makedirs(dest_dir, exist_ok=True)
|
# 'uploads/<subfolder>/<uuid>.<ext>' is unchanged across backends. The
|
||||||
|
# stamped FileStorage keeps the original filename, so the derived key — and
|
||||||
|
# the tenant prefix applied inside S3Backend — are unaffected.
|
||||||
|
from app.utils import storage
|
||||||
|
try:
|
||||||
|
server_path = storage.save(file_obj, subfolder)
|
||||||
|
except Exception as exc:
|
||||||
|
# A storage failure is the most likely cause of a REPEATED upload
|
||||||
|
# failure (disk full, R2 credentials/quota). Name it explicitly —
|
||||||
|
# otherwise it surfaces only as a generic 500 with no link to the
|
||||||
|
# inspector who is losing evidence photos.
|
||||||
|
logger.error('API PHOTOS | STORAGE WRITE FAILED | user=%s | entity_type=%s | '
|
||||||
|
'subfolder=%s | error=%s', user.username, entity_type, subfolder, exc)
|
||||||
|
return api_error('Could not store the photo. Please retry.', 500)
|
||||||
|
|
||||||
dest_path = os.path.join(dest_dir, filename)
|
logger.info(
|
||||||
file_obj.save(dest_path)
|
'API PHOTOS | uploaded | entity_type=%s | path=%s | user=%s | '
|
||||||
|
'stamped=%s | capture_source=%s',
|
||||||
|
entity_type, server_path, user.username,
|
||||||
|
meta.get('stamped'), meta.get('source'),
|
||||||
|
)
|
||||||
|
|
||||||
server_path = f'uploads/{subfolder}/{filename}'
|
captured_at = meta.get('captured_at')
|
||||||
|
return api_ok({
|
||||||
logger.info('API PHOTOS | uploaded | entity_type=%s | path=%s | user=%s',
|
'server_path': server_path,
|
||||||
entity_type, server_path, user.username)
|
'stamped': bool(meta.get('stamped')),
|
||||||
|
'captured_at': captured_at.isoformat() if captured_at else None,
|
||||||
return api_ok({'server_path': server_path})
|
'capture_source': meta.get('source'),
|
||||||
|
})
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
"""
|
||||||
|
app/api/scheduled.py
|
||||||
|
--------------------
|
||||||
|
Mobile API endpoint for planned inspection assignments (phase43).
|
||||||
|
|
||||||
|
GET /api/v1/scheduled-inspections
|
||||||
|
Returns ACTIVE, PLAN-MODE schedules the caller is responsible for.
|
||||||
|
- inspector : only schedules where inspector_id == the caller
|
||||||
|
- admin / director / project_manager / auditor : all active plan schedules
|
||||||
|
Powers the "Scheduled" section on the iPad Dashboard and My Inspections
|
||||||
|
lists. The iPad taps "Start", which opens the normal new-inspection flow
|
||||||
|
with the facility + template preselected (client-side); the schedule
|
||||||
|
lifecycle (fulfil / roll-forward) continues to be driven by the web app.
|
||||||
|
|
||||||
|
Why plan-mode only
|
||||||
|
------------------
|
||||||
|
`mode='auto'` schedules materialise themselves into a real Inspection at
|
||||||
|
next_run_at, which the iPad already fetches via /api/v1/inspections. Returning
|
||||||
|
them here too would show the same work twice, and "Start" is meaningless for a
|
||||||
|
schedule that starts itself. This mirrors the web dashboard panel (phase43).
|
||||||
|
|
||||||
|
A plan-mode schedule is a PLAN, not an inspection — see
|
||||||
|
app/models/inspection_schedule.py for the full lifecycle.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from flask import Blueprint, request, g
|
||||||
|
from app import db
|
||||||
|
from app.models.inspection import Inspection
|
||||||
|
from app.models.inspection_schedule import InspectionSchedule
|
||||||
|
from app.api.errors import api_ok, api_error
|
||||||
|
from app.api.decorators import jwt_required
|
||||||
|
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE
|
||||||
|
from app.utils.scope import get_inspector_scope
|
||||||
|
from app.utils.time_utils import now_eastern
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
bp = Blueprint('api_scheduled', __name__)
|
||||||
|
|
||||||
|
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'external_inspector',
|
||||||
|
'project_manager', 'auditor'}
|
||||||
|
|
||||||
|
|
||||||
|
def _scheduled_payload(s):
|
||||||
|
"""Serialise an InspectionSchedule to the dict returned in list responses.
|
||||||
|
|
||||||
|
`next_due_date` is the date part of next_run_at — MT reuses next_run_at as
|
||||||
|
the due datetime for both modes (see phase43).
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
'id': s.id,
|
||||||
|
'name': s.name,
|
||||||
|
'facility_id': s.facility_id,
|
||||||
|
'facility_name': s.facility.name if s.facility else None,
|
||||||
|
'area_id': s.area_id,
|
||||||
|
'area_name': s.area.name if s.area else None,
|
||||||
|
'template_id': s.template_id,
|
||||||
|
'template_name': s.template.name if s.template else None,
|
||||||
|
'inspector_id': s.inspector_id,
|
||||||
|
'frequency': s.frequency,
|
||||||
|
'frequency_label': s.frequency_label,
|
||||||
|
'mode': s.mode,
|
||||||
|
# phase46 recurrence detail. `recurrence_label` is the display string
|
||||||
|
# ("Weekly · Mon, Wed, Fri"); the raw fields let the iPad render its own.
|
||||||
|
'recurrence_label': s.recurrence_label,
|
||||||
|
'weekdays': s.weekday_list,
|
||||||
|
'month_mode': s.month_mode,
|
||||||
|
'day_of_month': s.day_of_month,
|
||||||
|
'nth_week': s.nth_week,
|
||||||
|
'nth_weekday': s.nth_weekday,
|
||||||
|
'next_due_date': s.next_run_at.date().isoformat() if s.next_run_at else None,
|
||||||
|
# phase47. Additive: the iPad decodes explicit CodingKeys, so a build
|
||||||
|
# that predates this key ignores it rather than failing to decode.
|
||||||
|
'end_date': s.end_date.isoformat() if s.end_date else None,
|
||||||
|
'is_overdue': s.is_overdue(),
|
||||||
|
# phase48 — non-NULL when this schedule is a planned follow-up of a
|
||||||
|
# completed inspection. The iPad uses it to badge the row and to open
|
||||||
|
# the parent from the schedule detail.
|
||||||
|
'parent_inspection_id': s.parent_inspection_id,
|
||||||
|
# phase50 — receipt acknowledgement, per assignment rather than per
|
||||||
|
# occurrence. Lets the iPad badge unconfirmed assignments.
|
||||||
|
'is_acknowledged': s.is_acknowledged,
|
||||||
|
'acknowledged_at': s.acknowledged_at.isoformat() if s.acknowledged_at else None,
|
||||||
|
'notes': s.notes or None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── List Scheduled Inspections ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@bp.route('/scheduled-inspections', methods=['GET'])
|
||||||
|
@jwt_required
|
||||||
|
def list_scheduled():
|
||||||
|
"""
|
||||||
|
Return active plan-mode scheduled inspections for the authenticated user.
|
||||||
|
|
||||||
|
Query parameters
|
||||||
|
----------------
|
||||||
|
limit int Default 100, max 200.
|
||||||
|
offset int Default 0.
|
||||||
|
|
||||||
|
Response 200
|
||||||
|
------------
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"data": {
|
||||||
|
"scheduled": [...],
|
||||||
|
"total": 3,
|
||||||
|
"limit": 100,
|
||||||
|
"offset": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
user = g.api_user
|
||||||
|
if user.role not in _ALLOWED_ROLES:
|
||||||
|
return api_error('Access denied', 403)
|
||||||
|
|
||||||
|
try:
|
||||||
|
limit = min(request.args.get('limit', 100, type=int) or 100, 200)
|
||||||
|
offset = max(request.args.get('offset', 0, type=int) or 0, 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return api_error('limit and offset must be integers', 400)
|
||||||
|
|
||||||
|
query = InspectionSchedule.query.filter(
|
||||||
|
InspectionSchedule.active.is_(True),
|
||||||
|
InspectionSchedule.mode == 'plan',
|
||||||
|
)
|
||||||
|
|
||||||
|
if user.is_inspector:
|
||||||
|
# Inspectors only see schedules assigned directly to them.
|
||||||
|
query = query.filter(InspectionSchedule.inspector_id == user.id)
|
||||||
|
|
||||||
|
total = query.count()
|
||||||
|
rows = (
|
||||||
|
query
|
||||||
|
.order_by(InspectionSchedule.next_run_at.asc())
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = [_scheduled_payload(s) for s in rows]
|
||||||
|
|
||||||
|
logger.info('API SCHEDULED | list | user=%s | count=%d | total=%d',
|
||||||
|
user.username, len(payload), total)
|
||||||
|
|
||||||
|
return api_ok({'scheduled': payload, 'total': total,
|
||||||
|
'limit': limit, 'offset': offset})
|
||||||
|
|
||||||
|
|
||||||
|
# ── Create a scheduled follow-up (phase48) ────────────────────────────────────
|
||||||
|
|
||||||
|
@bp.route('/scheduled-inspections/follow-up', methods=['POST'])
|
||||||
|
@jwt_required
|
||||||
|
def create_follow_up():
|
||||||
|
"""
|
||||||
|
Plan a follow-up re-inspection of a completed inspection for a later date.
|
||||||
|
|
||||||
|
Backs "Schedule Follow-up" in the iPad's inspection history detail, the
|
||||||
|
deferred twin of "Re-inspect Now". Creates a one-time (`frequency='once'`),
|
||||||
|
plan-mode schedule carrying `parent_inspection_id`, so the inspection
|
||||||
|
eventually started from it is a true linked re-inspection.
|
||||||
|
|
||||||
|
Deliberately narrow: this is NOT a general schedule-creation endpoint. The
|
||||||
|
facility, area, template and assignee are all derived from the parent
|
||||||
|
inspection rather than taken from the client, so a follow-up can only ever
|
||||||
|
target the thing it is a follow-up of. Recurring schedules stay web-only
|
||||||
|
(`@project_manager_required`).
|
||||||
|
|
||||||
|
Mode is forced to 'plan', never 'auto': a follow-up is something a person
|
||||||
|
goes and does, and an auto schedule would drop an in-progress inspection
|
||||||
|
into the queue unannounced on the due date.
|
||||||
|
|
||||||
|
Request body
|
||||||
|
------------
|
||||||
|
parent_inspection_id int required — the completed inspection to follow up
|
||||||
|
due_date str required — ISO date (YYYY-MM-DD), today or later
|
||||||
|
notes str optional — what the follow-up should address
|
||||||
|
|
||||||
|
Response 200 (reused existing) / 201 (created)
|
||||||
|
---------------------------------------------
|
||||||
|
{ "ok": true, "data": { "scheduled": {...}, "created": true } }
|
||||||
|
"""
|
||||||
|
user = g.api_user
|
||||||
|
|
||||||
|
# Auditor is read-only everywhere else; keep it that way here.
|
||||||
|
if user.role not in {'admin', 'director', 'inspector', 'external_inspector',
|
||||||
|
'project_manager'}:
|
||||||
|
return api_error('Access denied', 403)
|
||||||
|
|
||||||
|
body = request.get_json(silent=True) or {}
|
||||||
|
|
||||||
|
parent_id = body.get('parent_inspection_id')
|
||||||
|
if not isinstance(parent_id, int):
|
||||||
|
return api_error('parent_inspection_id is required', 400)
|
||||||
|
|
||||||
|
parent = db.session.get(Inspection, parent_id)
|
||||||
|
if parent is None:
|
||||||
|
return api_error('Inspection not found', 404)
|
||||||
|
|
||||||
|
# An inspector may only schedule a follow-up of their own work, and only
|
||||||
|
# within their assigned contracts — the same two gates the rest of the
|
||||||
|
# mobile API applies. Managers are unrestricted, matching the web.
|
||||||
|
if user.is_inspector:
|
||||||
|
if parent.inspector_id != user.id:
|
||||||
|
return api_error('Access denied', 403)
|
||||||
|
fids = get_inspector_scope(user)
|
||||||
|
if not fids or parent.facility_id not in fids:
|
||||||
|
return api_error('Access denied', 403)
|
||||||
|
|
||||||
|
# A follow-up only makes sense once there is something to follow up on.
|
||||||
|
if parent.status != 'completed':
|
||||||
|
return api_error('Only a completed inspection can have a follow-up '
|
||||||
|
'scheduled', 400)
|
||||||
|
|
||||||
|
due_raw = (body.get('due_date') or '').strip()
|
||||||
|
try:
|
||||||
|
due_date = datetime.strptime(due_raw, '%Y-%m-%d').date()
|
||||||
|
except ValueError:
|
||||||
|
return api_error('due_date must be an ISO date (YYYY-MM-DD)', 400)
|
||||||
|
|
||||||
|
# Today is allowed — "later today" is a legitimate plan; yesterday is not.
|
||||||
|
if due_date < now_eastern().date():
|
||||||
|
return api_error('due_date cannot be in the past', 400)
|
||||||
|
|
||||||
|
notes = (body.get('notes') or '').strip() or None
|
||||||
|
|
||||||
|
# Idempotent: the iPad may retry a request whose response was lost, and a
|
||||||
|
# second identical schedule would put a duplicate row in the inspector's
|
||||||
|
# Scheduled list with no way to tell them apart. Reuse the existing active
|
||||||
|
# follow-up for this parent instead, updating the date they just picked.
|
||||||
|
existing = (InspectionSchedule.query
|
||||||
|
.filter_by(parent_inspection_id=parent.id, active=True)
|
||||||
|
.order_by(InspectionSchedule.id.desc())
|
||||||
|
.first())
|
||||||
|
if existing is not None:
|
||||||
|
existing.set_next_run_date(due_date)
|
||||||
|
if notes:
|
||||||
|
existing.notes = notes
|
||||||
|
# A moved due date is a new occurrence — the reminders already sent for
|
||||||
|
# the old one no longer apply.
|
||||||
|
existing.advance_notified = False
|
||||||
|
existing.due_notified = False
|
||||||
|
existing.overdue_notified = False
|
||||||
|
db.session.commit()
|
||||||
|
log_action(ACTION_UPDATE, 'InspectionSchedule', existing.id, existing.name,
|
||||||
|
f'follow-up rescheduled via mobile API by {user.username}; '
|
||||||
|
f'parent_inspection_id={parent.id}; due={due_date}')
|
||||||
|
logger.info('API SCHEDULED | follow-up updated | schedule=%s | '
|
||||||
|
'parent=%s | due=%s | user=%s',
|
||||||
|
existing.id, parent.id, due_date, user.username)
|
||||||
|
return api_ok({'scheduled': _scheduled_payload(existing),
|
||||||
|
'created': False})
|
||||||
|
|
||||||
|
fac_name = parent.facility.name if parent.facility else 'facility'
|
||||||
|
sched = InspectionSchedule(
|
||||||
|
# MT requires a name (ST's table does not). Build one rather than asking
|
||||||
|
# the client for it, so the row is identifiable in the web schedule list
|
||||||
|
# without the iPad needing to know MT's schema.
|
||||||
|
name = f'Follow-up: {fac_name} (inspection #{parent.id})',
|
||||||
|
facility_id = parent.facility_id,
|
||||||
|
area_id = parent.area_id,
|
||||||
|
template_id = parent.template_id,
|
||||||
|
# Assign to whoever performed the original — they are the one being
|
||||||
|
# asked to put it right. Falls back to the caller when the parent has
|
||||||
|
# no inspector (its account was deleted).
|
||||||
|
inspector_id = parent.inspector_id or user.id,
|
||||||
|
frequency = 'once',
|
||||||
|
mode = 'plan',
|
||||||
|
active = True,
|
||||||
|
notes = notes,
|
||||||
|
parent_inspection_id = parent.id,
|
||||||
|
created_by = user.id,
|
||||||
|
created_at = now_eastern(),
|
||||||
|
)
|
||||||
|
# set_next_run_date() rather than a raw next_run_at so the due date gets the
|
||||||
|
# schedule's standard time-of-day (06:00 for a row with no next_run_at yet).
|
||||||
|
sched.set_next_run_date(due_date)
|
||||||
|
db.session.add(sched)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
log_action(ACTION_CREATE, 'InspectionSchedule', sched.id, sched.name,
|
||||||
|
f'follow-up created via mobile API by {user.username}; '
|
||||||
|
f'parent_inspection_id={parent.id}; facility_id={parent.facility_id}; '
|
||||||
|
f'due={due_date}')
|
||||||
|
logger.info('API SCHEDULED | follow-up created | schedule=%s | parent=%s | '
|
||||||
|
'facility=%s | due=%s | user=%s',
|
||||||
|
sched.id, parent.id, parent.facility_id, due_date, user.username)
|
||||||
|
|
||||||
|
return api_ok({'scheduled': _scheduled_payload(sched), 'created': True}, 201)
|
||||||
+17
-4
@@ -40,7 +40,8 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
bp = Blueprint('api_stats', __name__)
|
bp = Blueprint('api_stats', __name__)
|
||||||
|
|
||||||
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'}
|
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'external_inspector',
|
||||||
|
'project_manager', 'auditor'}
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/stats/dashboard', methods=['GET'])
|
@bp.route('/stats/dashboard', methods=['GET'])
|
||||||
@@ -74,7 +75,7 @@ def dashboard_stats():
|
|||||||
today_end = today_start + timedelta(days=1)
|
today_end = today_start + timedelta(days=1)
|
||||||
thirty_days_ago = now - timedelta(days=30)
|
thirty_days_ago = now - timedelta(days=30)
|
||||||
|
|
||||||
is_inspector = user.role == 'inspector'
|
is_inspector = user.is_inspector
|
||||||
fids = get_inspector_scope(user) if is_inspector else None # None = no scoping
|
fids = get_inspector_scope(user) if is_inspector else None # None = no scoping
|
||||||
|
|
||||||
# ── Today's inspections ───────────────────────────────────────────────
|
# ── Today's inspections ───────────────────────────────────────────────
|
||||||
@@ -110,7 +111,14 @@ def dashboard_stats():
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
open_issues_all = open_q.all()
|
# Counts and buckets only — never a hydrated Issue. For an admin this is
|
||||||
|
# every open issue in the system, fetched on every iPad dashboard refresh;
|
||||||
|
# the full entity would drag the description TEXT and the JSON photo
|
||||||
|
# columns along with it. A Row exposes the same attribute names, so
|
||||||
|
# sla_status() below works unchanged.
|
||||||
|
open_issues_all = open_q.with_entities(
|
||||||
|
Issue.id, Issue.severity, Issue.status, Issue.reported_at
|
||||||
|
).all()
|
||||||
open_issues = len(open_issues_all)
|
open_issues = len(open_issues_all)
|
||||||
|
|
||||||
# ── Severity breakdown (derived from the same open_issues_all list) ───
|
# ── Severity breakdown (derived from the same open_issues_all list) ───
|
||||||
@@ -151,9 +159,14 @@ def dashboard_stats():
|
|||||||
if not fids:
|
if not fids:
|
||||||
followup_q = followup_q.filter(False)
|
followup_q = followup_q.filter(False)
|
||||||
else:
|
else:
|
||||||
|
# OWNERSHIP, not authorship: a follow-up handed to this inspector
|
||||||
|
# belongs to them even though somebody else performed the original.
|
||||||
|
# This tile sits directly above the Follow-up Requests list, which
|
||||||
|
# filters the same way — counting authorship here made the two
|
||||||
|
# disagree on the same screen.
|
||||||
followup_q = followup_q.filter(
|
followup_q = followup_q.filter(
|
||||||
Inspection.facility_id.in_(fids),
|
Inspection.facility_id.in_(fids),
|
||||||
Inspection.inspector_id == user.id,
|
Inspection.follow_up_owned_by(user.id),
|
||||||
)
|
)
|
||||||
pending_followups = followup_q.count()
|
pending_followups = followup_q.count()
|
||||||
|
|
||||||
|
|||||||
+90
-10
@@ -16,18 +16,86 @@ GET /api/v1/templates/<template_id>
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from flask import Blueprint, g
|
from flask import Blueprint, g, request
|
||||||
from app import db
|
from app import db
|
||||||
from app.models.inspection import InspectionTemplate
|
from app.models.inspection import InspectionTemplate
|
||||||
|
from app.models.facility import Facility
|
||||||
from app.api.errors import api_ok, api_error
|
from app.api.errors import api_ok, api_error
|
||||||
from app.api.decorators import jwt_required
|
from app.api.decorators import jwt_required
|
||||||
|
from app.utils.scope import get_inspector_scope
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
bp = Blueprint('api_templates', __name__)
|
bp = Blueprint('api_templates', __name__)
|
||||||
|
|
||||||
# Customer role cannot access template data — inspectors and above only
|
# Customer role cannot access template data — inspectors and above only
|
||||||
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'}
|
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'external_inspector',
|
||||||
|
'project_manager', 'auditor'}
|
||||||
|
|
||||||
|
|
||||||
|
def _visible_project_ids(user):
|
||||||
|
"""Contract ids whose forms this user may see, or None for "no limit".
|
||||||
|
|
||||||
|
An inspector (ours or a customer's) is limited to the contracts they are
|
||||||
|
assigned; every other allowed role sees all. Derived from the facilities
|
||||||
|
get_inspector_scope() returns, so the API can never disagree with the web.
|
||||||
|
"""
|
||||||
|
if not user.is_inspector:
|
||||||
|
return None
|
||||||
|
fids = get_inspector_scope(user) or []
|
||||||
|
if not fids:
|
||||||
|
return []
|
||||||
|
return sorted({
|
||||||
|
f.project_id
|
||||||
|
for f in Facility.query.filter(Facility.id.in_(fids)).all()
|
||||||
|
if f.project_id
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _visible_templates(user, project_id=None):
|
||||||
|
"""Forms this user may use, optionally narrowed to one contract.
|
||||||
|
|
||||||
|
phase52 — a form attached to specific contracts must not reach an
|
||||||
|
inspector working for a different customer. Shared forms (no contract
|
||||||
|
links) stay visible to everyone, which is what keeps existing installs
|
||||||
|
behaving exactly as before.
|
||||||
|
|
||||||
|
With `project_id`: exactly the web picker's list for that contract.
|
||||||
|
Without: the union across every contract the user can reach — the iPad
|
||||||
|
caches templates up-front and picks the facility later, so it needs the
|
||||||
|
whole set it might legitimately use.
|
||||||
|
"""
|
||||||
|
pids = _visible_project_ids(user)
|
||||||
|
|
||||||
|
if project_id is not None:
|
||||||
|
# The caller names a contract. Their OWN scope still applies — without
|
||||||
|
# this, passing another customer's facility_id would list that
|
||||||
|
# customer's form names back to an inspector who has no business
|
||||||
|
# seeing them. Empty list, not an error: the endpoint must not confirm
|
||||||
|
# whether that contract exists either.
|
||||||
|
if pids is not None and project_id not in pids:
|
||||||
|
logger.warning('API TEMPLATES | out-of-scope project filter | '
|
||||||
|
'user=%s | project_id=%s', user.username, project_id)
|
||||||
|
return []
|
||||||
|
return InspectionTemplate.available_query(project_id).all()
|
||||||
|
|
||||||
|
if pids is None:
|
||||||
|
return (InspectionTemplate.query
|
||||||
|
.filter_by(active=True)
|
||||||
|
.order_by(InspectionTemplate.name)
|
||||||
|
.all())
|
||||||
|
|
||||||
|
seen, out = set(), []
|
||||||
|
# Always include the shared forms, even when the user has no contracts —
|
||||||
|
# otherwise an unassigned inspector would see nothing at all rather than
|
||||||
|
# the standard forms.
|
||||||
|
for pid in list(pids) + [None]:
|
||||||
|
for t in InspectionTemplate.available_query(pid).all():
|
||||||
|
if t.id not in seen:
|
||||||
|
seen.add(t.id)
|
||||||
|
out.append(t)
|
||||||
|
out.sort(key=lambda t: (t.name or '').lower())
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _template_summary_payload(template: InspectionTemplate) -> dict:
|
def _template_summary_payload(template: InspectionTemplate) -> dict:
|
||||||
@@ -87,17 +155,21 @@ def list_templates():
|
|||||||
user.username, user.role)
|
user.username, user.role)
|
||||||
return api_error('Access denied', 403)
|
return api_error('Access denied', 403)
|
||||||
|
|
||||||
templates = (
|
# Optional ?project_id= narrows to one contract (matches the web picker);
|
||||||
InspectionTemplate.query
|
# ?facility_id= is accepted as a convenience and resolved to its contract.
|
||||||
.filter_by(active=True)
|
project_id = request.args.get('project_id', type=int)
|
||||||
.order_by(InspectionTemplate.name)
|
if project_id is None:
|
||||||
.all()
|
facility_id = request.args.get('facility_id', type=int)
|
||||||
)
|
if facility_id is not None:
|
||||||
|
facility = db.session.get(Facility, facility_id)
|
||||||
|
project_id = facility.project_id if facility else None
|
||||||
|
|
||||||
|
templates = _visible_templates(user, project_id)
|
||||||
|
|
||||||
payload = [_template_summary_payload(t) for t in templates]
|
payload = [_template_summary_payload(t) for t in templates]
|
||||||
|
|
||||||
logger.info('API TEMPLATES | list | user=%s | count=%d',
|
logger.info('API TEMPLATES | list | user=%s | project_id=%s | count=%d',
|
||||||
user.username, len(payload))
|
user.username, project_id, len(payload))
|
||||||
|
|
||||||
return api_ok({'templates': payload, 'count': len(payload)})
|
return api_ok({'templates': payload, 'count': len(payload)})
|
||||||
|
|
||||||
@@ -143,6 +215,14 @@ def get_template(template_id):
|
|||||||
if template is None:
|
if template is None:
|
||||||
return api_error('Template not found', 404)
|
return api_error('Template not found', 404)
|
||||||
|
|
||||||
|
# phase52 — a restricted form must not be fetchable by an inspector on a
|
||||||
|
# different customer's contracts. 404 rather than 403: whether another
|
||||||
|
# customer's form exists is itself not this user's business.
|
||||||
|
if template.id not in {t.id for t in _visible_templates(user)}:
|
||||||
|
logger.warning('API TEMPLATES | out-of-contract fetch blocked | '
|
||||||
|
'user=%s | template_id=%s', user.username, template_id)
|
||||||
|
return api_error('Template not found', 404)
|
||||||
|
|
||||||
logger.info('API TEMPLATES | detail | user=%s | template_id=%d | name=%s',
|
logger.info('API TEMPLATES | detail | user=%s | template_id=%d | name=%s',
|
||||||
user.username, template_id, template.name)
|
user.username, template_id, template.name)
|
||||||
|
|
||||||
|
|||||||
@@ -91,6 +91,17 @@ def send_billing_email(to_addr: str, event_type: str, context_dict: dict):
|
|||||||
|
|
||||||
app = current_app._get_current_object()
|
app = current_app._get_current_object()
|
||||||
|
|
||||||
|
# Branded From: the display NAME tracks the tenant, the ADDRESS stays the
|
||||||
|
# authenticated identity unless the domain is DNS-authorized. Resolved HERE,
|
||||||
|
# in the request context, because _send() runs on a background thread where
|
||||||
|
# g.tenant no longer exists. The previous `noreply@{netloc}` sent from the
|
||||||
|
# tenant's host regardless of SPF authorization. See app/utils/mail_utils.py.
|
||||||
|
try:
|
||||||
|
from app.utils.mail_utils import branded_sender
|
||||||
|
_sender = branded_sender(app.config.get('APP_BASE_URL', ''))
|
||||||
|
except Exception:
|
||||||
|
_sender = app.config.get('MAIL_DEFAULT_SENDER', '')
|
||||||
|
|
||||||
def _send():
|
def _send():
|
||||||
try:
|
try:
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
@@ -105,6 +116,7 @@ def send_billing_email(to_addr: str, event_type: str, context_dict: dict):
|
|||||||
msg = Message(
|
msg = Message(
|
||||||
subject=subject,
|
subject=subject,
|
||||||
recipients=[to_addr],
|
recipients=[to_addr],
|
||||||
|
sender=_sender or None,
|
||||||
body=plain_body,
|
body=plain_body,
|
||||||
html=html_body,
|
html=html_body,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""
|
||||||
|
app/enrollment
|
||||||
|
--------------
|
||||||
|
The JQC Enrollment Form — a self-contained onboarding intake, deliberately
|
||||||
|
held apart from the rest of the application.
|
||||||
|
|
||||||
|
/enrollment public form emailed to a prospective customer
|
||||||
|
/enrollment/admin admin-only inbox of submissions
|
||||||
|
|
||||||
|
Separation contract (please keep this true)
|
||||||
|
-------------------------------------------
|
||||||
|
1. NO app.models imports, and nothing here writes to the database. Enrollment
|
||||||
|
happens before any contract, facility or user exists, so there is nothing to
|
||||||
|
key a row against. Submissions are flat JSON files (see storage.py).
|
||||||
|
MT NOTE: "no database" does NOT mean "no tenant". Submissions are filed per
|
||||||
|
tenant on disk, and the admin views only ever list the calling tenant's own
|
||||||
|
directory — see storage.enrollment_dir().
|
||||||
|
2. NO migration, NO model, NO notification-matrix event, NO iPad/API surface.
|
||||||
|
Deleting this package would remove the two routes and nothing else.
|
||||||
|
3. Its own template folder (app/enrollment/templates/enrollment/) — enrollment
|
||||||
|
markup never mixes into app/templates.
|
||||||
|
4. The only shared code it uses is what it should not reinvent: the app factory,
|
||||||
|
Flask-WTF CSRF, the rate limiter, and @admin_required.
|
||||||
|
|
||||||
|
If this ever needs to CREATE the accounts it describes, do that as a separate,
|
||||||
|
explicit admin action that reads a stored submission — do not let the public
|
||||||
|
form reach into the app's models.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .routes import bp # noqa: F401 (re-exported for register_enrollment)
|
||||||
|
|
||||||
|
|
||||||
|
def register_enrollment(app):
|
||||||
|
"""Register the blueprint and make sure the storage directory exists."""
|
||||||
|
import os
|
||||||
|
|
||||||
|
app.config.setdefault(
|
||||||
|
'ENROLLMENT_DIR',
|
||||||
|
os.path.join(app.instance_path, 'enrollments'),
|
||||||
|
)
|
||||||
|
# Only the ROOT is created at boot. Per-tenant subdirectories are created
|
||||||
|
# lazily on first use by storage.enrollment_dir(), because the tenant is
|
||||||
|
# not known until a request is bound.
|
||||||
|
os.makedirs(app.config['ENROLLMENT_DIR'], exist_ok=True)
|
||||||
|
app.register_blueprint(bp)
|
||||||
|
app.logger.info('Enrollment | storage root: %s | per-tenant=%s',
|
||||||
|
app.config['ENROLLMENT_DIR'],
|
||||||
|
bool(app.config.get('MULTI_TENANT_ENABLED')))
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
"""
|
||||||
|
app/enrollment/mailer.py
|
||||||
|
------------------------
|
||||||
|
The enrollment confirmation email.
|
||||||
|
|
||||||
|
Sent to the requester after a submission is stored. One job, and it must never
|
||||||
|
be able to break that: the record is already safely on disk before this runs,
|
||||||
|
so every failure path here is logged and swallowed. A bounced confirmation must
|
||||||
|
not cost the customer their enrollment.
|
||||||
|
|
||||||
|
Sending happens on a background thread (rule 14 — never block the HTTP
|
||||||
|
response), and the From identity comes from branded_sender() so it stays an
|
||||||
|
SMTP-authorized address that actually delivers (rules 64 / 76).
|
||||||
|
|
||||||
|
This is the only part of app/enrollment that touches shared mail
|
||||||
|
infrastructure. It performs exactly ONE database read — resolving the active
|
||||||
|
admin accounts to notify — and no write. That read is a deliberate, narrowed
|
||||||
|
exception to the package's no-models rule (rule 88): the alternative, a
|
||||||
|
hand-maintained recipient list in config, drifts out of step with reality the
|
||||||
|
first time someone joins or leaves. Everything else here stays model-free.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from flask import current_app, render_template
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _tenant_branding():
|
||||||
|
"""(display_name, support_email) for the calling tenant.
|
||||||
|
|
||||||
|
MT: ST hardcodes its own company name and a personal corrections address in
|
||||||
|
this module and in schema.py. Sending either to another tenant's customers
|
||||||
|
would be wrong and confusing, so both are resolved from TenantSettings at
|
||||||
|
send time, falling back to neutral defaults.
|
||||||
|
|
||||||
|
Best-effort: any failure returns the defaults rather than blocking the
|
||||||
|
email, matching the rest of this module's never-raise contract.
|
||||||
|
"""
|
||||||
|
name, support = 'Janitorial QC', None
|
||||||
|
try:
|
||||||
|
from app.models.tenant_settings import TenantSettings
|
||||||
|
row = TenantSettings.query.first()
|
||||||
|
if row is not None:
|
||||||
|
name = row.display_name or name
|
||||||
|
support = row.support_email or None
|
||||||
|
except Exception:
|
||||||
|
logger.debug('ENROLLMENT | tenant branding unavailable, using defaults')
|
||||||
|
if not support:
|
||||||
|
support = (current_app.config.get('ENROLLMENT_CORRECTIONS_EMAIL')
|
||||||
|
or current_app.config.get('MAIL_DEFAULT_SENDER') or '')
|
||||||
|
return name, support
|
||||||
|
|
||||||
|
|
||||||
|
def _text_body(record, people, corrections_email):
|
||||||
|
"""Plain-text alternative — some recipients see only this."""
|
||||||
|
lines = [
|
||||||
|
f'Hi {record.get("request_by") or "there"},',
|
||||||
|
'',
|
||||||
|
'Thank you — we have received your JQC enrollment form.',
|
||||||
|
'',
|
||||||
|
f'Reference: {record.get("id")}',
|
||||||
|
f'Project: {record.get("project_name")}',
|
||||||
|
'',
|
||||||
|
f'People to be set up ({len(people)}):',
|
||||||
|
]
|
||||||
|
for i, person in enumerate(people, start=1):
|
||||||
|
lines.append(
|
||||||
|
f' {i}. {person["name"]} — {person["role_label"]} — {person["email"]}'
|
||||||
|
)
|
||||||
|
lines += [
|
||||||
|
'',
|
||||||
|
'Our team will create these accounts. Each person will receive their own '
|
||||||
|
'email invitation with sign-in instructions.',
|
||||||
|
'',
|
||||||
|
f'If anything above is wrong, simply send an email to '
|
||||||
|
f'{corrections_email}, and we will correct it.',
|
||||||
|
'',
|
||||||
|
_tenant_branding()[0],
|
||||||
|
]
|
||||||
|
return '\n'.join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _dispatch(msg, label, record):
|
||||||
|
"""Send one message on a background thread. Never raises.
|
||||||
|
|
||||||
|
Rule 14 — the HTTP response must not wait on SMTP. The submission is
|
||||||
|
already on disk by the time anything here runs, so a mail failure is
|
||||||
|
logged and dropped rather than surfaced to the customer.
|
||||||
|
"""
|
||||||
|
app = current_app._get_current_object()
|
||||||
|
|
||||||
|
def _send():
|
||||||
|
with app.app_context():
|
||||||
|
try:
|
||||||
|
from app import mail
|
||||||
|
mail.send(msg)
|
||||||
|
logger.info('ENROLLMENT %s SENT | to=%s | id=%s',
|
||||||
|
label, msg.recipients, record.get('id'))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error('ENROLLMENT %s FAILED | to=%s | id=%s | error=%s',
|
||||||
|
label, msg.recipients, record.get('id'), exc)
|
||||||
|
|
||||||
|
threading.Thread(target=_send, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
|
def _admin_recipients():
|
||||||
|
"""Addresses to alert when a new enrollment arrives.
|
||||||
|
|
||||||
|
Active `admin` accounts, plus any extra addresses in the optional
|
||||||
|
ENROLLMENT_NOTIFY_EMAILS config (comma-separated) for people who should be
|
||||||
|
told but do not hold a JQC login. Deduplicated case-insensitively.
|
||||||
|
|
||||||
|
The User import is function-local and read-only — see the module docstring.
|
||||||
|
"""
|
||||||
|
emails = []
|
||||||
|
try:
|
||||||
|
from app.models.user import User
|
||||||
|
rows = User.query.filter(User.role == 'admin',
|
||||||
|
User.active == True).all() # noqa: E712
|
||||||
|
emails += [u.email for u in rows if u.email]
|
||||||
|
except Exception:
|
||||||
|
# A DB problem must not stop the confirmation going out, nor the
|
||||||
|
# submission from succeeding.
|
||||||
|
logger.exception('ENROLLMENT | could not resolve admin recipients')
|
||||||
|
|
||||||
|
extra = current_app.config.get('ENROLLMENT_NOTIFY_EMAILS') or ''
|
||||||
|
emails += [e.strip() for e in extra.split(',') if e.strip()]
|
||||||
|
|
||||||
|
seen, out = set(), []
|
||||||
|
for e in emails:
|
||||||
|
low = e.lower()
|
||||||
|
if low not in seen:
|
||||||
|
seen.add(low)
|
||||||
|
out.append(e)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def send_admin_notification(record, base_url=None):
|
||||||
|
"""Alert JQC admins that a new enrollment form has arrived. Never raises."""
|
||||||
|
if not current_app.config.get('MAIL_SERVER'):
|
||||||
|
logger.warning('ENROLLMENT ADMIN EMAIL SKIPPED | no MAIL_SERVER | id=%s',
|
||||||
|
record.get('id'))
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
from flask_mail import Message
|
||||||
|
from app.utils.mail_utils import branded_sender
|
||||||
|
from . import schema
|
||||||
|
|
||||||
|
recipients = _admin_recipients()
|
||||||
|
if not recipients:
|
||||||
|
logger.warning('ENROLLMENT | no admin recipients for id=%s',
|
||||||
|
record.get('id'))
|
||||||
|
return
|
||||||
|
|
||||||
|
effective_base = (base_url
|
||||||
|
or current_app.config.get('APP_BASE_URL', '')).rstrip('/')
|
||||||
|
people = schema.people_of(record)
|
||||||
|
link = f'{effective_base}/enrollment/admin/{record.get("id")}'
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
'A new JQC enrollment form has been submitted.',
|
||||||
|
'',
|
||||||
|
f'Project: {record.get("project_name")}',
|
||||||
|
f'Requester: {record.get("request_by")} <{record.get("requester_email")}>',
|
||||||
|
f'Reference: {record.get("id")}',
|
||||||
|
f'People: {len(people)}',
|
||||||
|
'',
|
||||||
|
f'Open it here: {link}',
|
||||||
|
]
|
||||||
|
if record.get('notes'):
|
||||||
|
lines += ['', f'Customer notes: {record["notes"]}']
|
||||||
|
|
||||||
|
msg = Message(
|
||||||
|
subject = f'[JQC] New enrollment — {record.get("project_name")}',
|
||||||
|
sender = branded_sender(effective_base),
|
||||||
|
recipients = recipients,
|
||||||
|
body = '\n'.join(lines),
|
||||||
|
html = render_template('enrollment/email_admin_notice.html',
|
||||||
|
record=record, people=people,
|
||||||
|
schema=schema, link=link),
|
||||||
|
)
|
||||||
|
_dispatch(msg, 'ADMIN EMAIL', record)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
logger.exception('ENROLLMENT ADMIN EMAIL BUILD FAILED | id=%s',
|
||||||
|
record.get('id'))
|
||||||
|
|
||||||
|
|
||||||
|
def send_confirmation(record, base_url=None):
|
||||||
|
"""Email the requester a copy of what they submitted. Never raises."""
|
||||||
|
email = (record.get('requester_email') or '').strip()
|
||||||
|
if not email:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not current_app.config.get('MAIL_SERVER'):
|
||||||
|
logger.warning('ENROLLMENT EMAIL SKIPPED | no MAIL_SERVER | id=%s',
|
||||||
|
record.get('id'))
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
from flask_mail import Message
|
||||||
|
from app.utils.mail_utils import branded_sender
|
||||||
|
from . import schema
|
||||||
|
|
||||||
|
effective_base = (base_url
|
||||||
|
or current_app.config.get('APP_BASE_URL', '')).rstrip('/')
|
||||||
|
people = schema.people_of(record)
|
||||||
|
|
||||||
|
msg = Message(
|
||||||
|
subject = f'[JQC] Enrollment received — {record.get("project_name")}',
|
||||||
|
sender = branded_sender(effective_base),
|
||||||
|
recipients = [email],
|
||||||
|
body = _text_body(record, people, _tenant_branding()[1]),
|
||||||
|
html = render_template('enrollment/email_confirmation.html',
|
||||||
|
record=record, people=people,
|
||||||
|
schema=schema,
|
||||||
|
corrections_email=_tenant_branding()[1]),
|
||||||
|
)
|
||||||
|
|
||||||
|
_dispatch(msg, 'EMAIL', record)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
# Building the message failed (bad template, mail misconfigured, …).
|
||||||
|
# The submission is already saved — log it and move on.
|
||||||
|
logger.exception('ENROLLMENT EMAIL BUILD FAILED | id=%s', record.get('id'))
|
||||||
@@ -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,248 @@
|
|||||||
|
"""
|
||||||
|
app/enrollment/schema.py
|
||||||
|
------------------------
|
||||||
|
The JQC Enrollment Form, expressed as data.
|
||||||
|
|
||||||
|
This is the SINGLE source of truth for the form's shape. The public template
|
||||||
|
renders from it, the POST handler parses against it, and the admin detail view
|
||||||
|
re-renders a stored submission through it. Changing a task label or adding a
|
||||||
|
role is a one-line edit here — no template or parser change.
|
||||||
|
|
||||||
|
Deliberately free of any app model / DB import: the enrollment form describes
|
||||||
|
what a prospective customer *wants set up*, not anything that exists in the
|
||||||
|
system yet. Keep it that way (see app/enrollment/__init__.py). The ROLES below
|
||||||
|
happen to mirror the app's user roles, but they are a COPY on purpose — the
|
||||||
|
public form must not import the User model.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ── Roles a person can be enrolled as ────────────────────────────────────────
|
||||||
|
# key -> label, shown in the Step 1 role dropdown.
|
||||||
|
# phase51 — enrollment describes CUSTOMER-side people only, so the dropdown
|
||||||
|
# offers exactly the two customer roles. Our own staff roles (admin, auditor,
|
||||||
|
# internal inspector) are never enrolled through this form; they are created in
|
||||||
|
# User Management. The keys stay 'director'/'inspector' — they are the
|
||||||
|
# customer's words for the seat, mapped to app roles by APP_ROLE_FOR below.
|
||||||
|
ROLES = [
|
||||||
|
('director', 'Director'),
|
||||||
|
('inspector', 'Inspector'),
|
||||||
|
]
|
||||||
|
|
||||||
|
ROLE_LABELS = dict(ROLES)
|
||||||
|
ROLE_KEYS = [k for k, _ in ROLES]
|
||||||
|
|
||||||
|
#: App role each enrolled seat becomes when an admin actually creates the
|
||||||
|
#: account in Customer Management. A plain string map on purpose — the
|
||||||
|
#: enrollment package must not import app.models (see __init__.py, rule 88).
|
||||||
|
APP_ROLE_FOR = {
|
||||||
|
'director': 'customer', # "Customer Director"
|
||||||
|
'inspector': 'external_inspector', # "Customer Inspector"
|
||||||
|
}
|
||||||
|
|
||||||
|
#: Roles that act on the administrative side of the printed form (the
|
||||||
|
#: "Admin / Director" column). Everything else is an inspector seat. Drives
|
||||||
|
#: both the recommendation preset and eligibility for admin-only tasks.
|
||||||
|
#
|
||||||
|
#: 'admin' and 'auditor' are NOT selectable any more but stay in this set for
|
||||||
|
#: LEGACY tolerance: submissions taken before phase51 stored those roles, and
|
||||||
|
#: dropping them here would silently re-render their admin-only task cells
|
||||||
|
#: (ref 10) as "n/a" in the admin detail view and the CSV export. Selectable
|
||||||
|
#: roles shrink; the ability to read back what was already recorded does not.
|
||||||
|
ADMIN_ROLES = {'director', 'admin', 'auditor'}
|
||||||
|
|
||||||
|
#: Role pre-selected for the first row — the form starts with the customer's
|
||||||
|
#: administrative contact, as on the printed sheet.
|
||||||
|
DEFAULT_FIRST_ROLE = 'director'
|
||||||
|
|
||||||
|
#: Upper bound on people per submission. Generous for a real enrollment, but
|
||||||
|
#: bounded so a scripted POST cannot make us build an unbounded matrix.
|
||||||
|
MAX_PEOPLE = 25
|
||||||
|
|
||||||
|
|
||||||
|
def is_admin_role(role):
|
||||||
|
return role in ADMIN_ROLES
|
||||||
|
|
||||||
|
|
||||||
|
# ── Task rows ────────────────────────────────────────────────────────────────
|
||||||
|
# ref, label, scope. scope 'admin_only' means the cell is offered only to
|
||||||
|
# people in an ADMIN_ROLES role (ref 10 on the printed form).
|
||||||
|
TASKS = [
|
||||||
|
(1, 'Receive new inspection submitted notification', 'all'),
|
||||||
|
(2, 'Receive issue-related notification', 'all'),
|
||||||
|
(3, 'New issue created', 'all'),
|
||||||
|
(4, 'Issue status updated', 'all'),
|
||||||
|
(5, 'Issue comment added', 'all'),
|
||||||
|
(6, 'Request follow up / re-inspection', 'all'),
|
||||||
|
(7, 'Add Comments (issue detail page)', 'all'),
|
||||||
|
(8, 'Receive issue SLA (at-risk, breached)', 'all'),
|
||||||
|
(9, 'Log new issue', 'all'),
|
||||||
|
(10, 'Search/Export Reports (inspection/issue)', 'admin_only'),
|
||||||
|
]
|
||||||
|
|
||||||
|
TASK_LABELS = {ref: label for ref, label, _ in TASKS}
|
||||||
|
|
||||||
|
#: Task rows that describe a CAPABILITY the role already carries, rather than a
|
||||||
|
#: notification we route. Every customer role can already do all three today —
|
||||||
|
#: comment on issues they follow or filed, log an issue at their own facility,
|
||||||
|
#: and search/export reports within their scope — so a tick here records what
|
||||||
|
#: the customer expects, it does not switch anything on. The remaining rows
|
||||||
|
#: (1-6, 8) are the ones that map to notification events and can be tuned
|
||||||
|
#: per account in Customer Management.
|
||||||
|
#:
|
||||||
|
#: Rendered as a footnote on the public form so the distinction is visible
|
||||||
|
#: without turning these into per-user permission flags (which would mean
|
||||||
|
#: adding deny-checks to routes that have none today — a fail-open surface for
|
||||||
|
#: no real gain).
|
||||||
|
ROLE_IMPLIED_TASKS = {7, 9, 10}
|
||||||
|
|
||||||
|
|
||||||
|
def task_applies(scope, role):
|
||||||
|
"""True when a task row offers a checkbox to someone in `role`."""
|
||||||
|
return scope == 'all' or is_admin_role(role)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Step 3 ───────────────────────────────────────────────────────────────────
|
||||||
|
MOBILE_APP_LABEL = 'JQC Mobile App For Smart Device'
|
||||||
|
|
||||||
|
|
||||||
|
# ── Recommended defaults ─────────────────────────────────────────────────────
|
||||||
|
# ref -> (recommended for admin-side roles, recommended for inspector roles).
|
||||||
|
# None = the row offers that side no cell.
|
||||||
|
#
|
||||||
|
# The printed form showed this as a separate RECOMMENDATION table for the
|
||||||
|
# customer to copy by hand. It is now applied by the "Recommendation selection"
|
||||||
|
# button instead, so the table is no longer rendered — but this mapping is
|
||||||
|
# still the authority, and is handed to the page as JSON.
|
||||||
|
RECOMMENDATION = {
|
||||||
|
1: (False, True),
|
||||||
|
2: (False, True),
|
||||||
|
3: (True, True),
|
||||||
|
4: (False, True),
|
||||||
|
5: (True, True),
|
||||||
|
6: (True, True),
|
||||||
|
7: (True, True),
|
||||||
|
8: (False, True),
|
||||||
|
9: (True, True),
|
||||||
|
10: (True, None),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def recommendation_for(role):
|
||||||
|
"""Return {task_ref: bool} — the recommended preset for one role.
|
||||||
|
|
||||||
|
Rows that offer this role no cell are omitted rather than set False, so
|
||||||
|
the caller never ticks a checkbox that does not exist.
|
||||||
|
"""
|
||||||
|
admin_side = is_admin_role(role)
|
||||||
|
preset = {}
|
||||||
|
for ref, _label, scope in TASKS:
|
||||||
|
if not task_applies(scope, role):
|
||||||
|
continue
|
||||||
|
rec = RECOMMENDATION.get(ref, (False, False))
|
||||||
|
value = rec[0] if admin_side else rec[1]
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
preset[ref] = bool(value)
|
||||||
|
return preset
|
||||||
|
|
||||||
|
|
||||||
|
def recommendation_map():
|
||||||
|
"""{role_key: {task_ref: bool}} for every role — serialised to the page."""
|
||||||
|
return {role: recommendation_for(role) for role in ROLE_KEYS}
|
||||||
|
|
||||||
|
|
||||||
|
#: Where a customer should write if their submission needs correcting. The
|
||||||
|
#: confirmation email is sent FROM the unmonitored no-reply identity
|
||||||
|
#: (branded_sender), so "reply to this email" would go nowhere — point them
|
||||||
|
#: here instead. Used by both the text and HTML bodies of the confirmation.
|
||||||
|
# MT: retained only as a last-resort default. The address actually shown to a
|
||||||
|
# customer is resolved per tenant at send time from TenantSettings.support_email
|
||||||
|
# — see mailer._tenant_branding(). Never send one tenant's customers another
|
||||||
|
# tenant's (or a developer's personal) address.
|
||||||
|
CORRECTIONS_EMAIL = ''
|
||||||
|
|
||||||
|
|
||||||
|
NOTES = [
|
||||||
|
'Each user will receive instructions on how to sign up and install the app '
|
||||||
|
'on their smart device.',
|
||||||
|
'Along with the installation instructions, users will receive a quick guide '
|
||||||
|
'to navigate the web portal and app based on their credentials.',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Office-use fields ────────────────────────────────────────────────────────
|
||||||
|
# Filled in by the tenant AFTER receipt, on the admin detail page only. The
|
||||||
|
# printed sheet showed these to the customer as a blank "for office use" block;
|
||||||
|
# the web form does not render them at all — a customer cannot fill them in, so
|
||||||
|
# showing them was only noise.
|
||||||
|
OFFICE_FIELDS = [
|
||||||
|
('receive_date', 'Receive Date'),
|
||||||
|
('program_by', 'Program By'),
|
||||||
|
('date_email_invitation', 'Date email invitation'),
|
||||||
|
]
|
||||||
|
|
||||||
|
STATUSES = ['new', 'in_progress', 'completed']
|
||||||
|
|
||||||
|
STATUS_LABELS = {
|
||||||
|
'new': 'New',
|
||||||
|
'in_progress': 'In Progress',
|
||||||
|
'completed': 'Completed',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Legacy record support ────────────────────────────────────────────────────
|
||||||
|
# Submissions taken before the form moved to free-form people used six fixed
|
||||||
|
# seats. Stored files are never rewritten, so the admin views normalise on
|
||||||
|
# read instead — one shape to render, whichever format is on disk.
|
||||||
|
_LEGACY_SEAT_ROLES = {
|
||||||
|
'admin': 'admin',
|
||||||
|
'inspector_1': 'inspector',
|
||||||
|
'inspector_2': 'inspector',
|
||||||
|
'inspector_3': 'inspector',
|
||||||
|
'inspector_4': 'inspector',
|
||||||
|
'inspector_5': 'inspector',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def people_of(record):
|
||||||
|
"""Return a submission's people as a uniform list, old format or new.
|
||||||
|
|
||||||
|
Each entry: {key, role, role_label, name, job_title, email}.
|
||||||
|
"""
|
||||||
|
if record.get('people'):
|
||||||
|
out = []
|
||||||
|
for p in record['people']:
|
||||||
|
role = p.get('role', 'inspector')
|
||||||
|
out.append({
|
||||||
|
'key': p.get('key', ''),
|
||||||
|
'role': role,
|
||||||
|
'role_label': ROLE_LABELS.get(role, role.replace('_', ' ').title()),
|
||||||
|
'name': p.get('name', ''),
|
||||||
|
'job_title': p.get('job_title', ''),
|
||||||
|
'email': p.get('email', ''),
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
# Legacy: fixed seats under 'registrants'.
|
||||||
|
out = []
|
||||||
|
for reg in record.get('registrants', []):
|
||||||
|
if not (reg.get('name') or reg.get('email')):
|
||||||
|
continue
|
||||||
|
role = _LEGACY_SEAT_ROLES.get(reg.get('key'), 'inspector')
|
||||||
|
out.append({
|
||||||
|
'key': reg.get('key', ''),
|
||||||
|
'role': role,
|
||||||
|
'role_label': ROLE_LABELS.get(role, role.title()),
|
||||||
|
'name': reg.get('name', ''),
|
||||||
|
'job_title': reg.get('job_title', ''),
|
||||||
|
'email': reg.get('email', ''),
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def cell(record, ref, person_key):
|
||||||
|
"""True when `person_key` was ticked for task `ref` in this submission."""
|
||||||
|
return bool(record.get('matrix', {}).get(str(ref), {}).get(person_key))
|
||||||
|
|
||||||
|
|
||||||
|
def wants_mobile(record, person_key):
|
||||||
|
return bool(record.get('mobile_app', {}).get(person_key))
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
"""
|
||||||
|
app/enrollment/storage.py
|
||||||
|
-------------------------
|
||||||
|
Flat-file persistence for enrollment submissions — one JSON document per
|
||||||
|
submission, under the directory named by config ENROLLMENT_DIR, in a
|
||||||
|
per-tenant subdirectory (see enrollment_dir()).
|
||||||
|
|
||||||
|
Why files and not a table
|
||||||
|
-------------------------
|
||||||
|
Enrollment happens BEFORE anything exists in the system: there is no contract,
|
||||||
|
no facility and no user account to key a row against, and the volume is a
|
||||||
|
handful of documents a year. A directory of readable JSON keeps this feature
|
||||||
|
completely outside the schema — no model, no migration, nothing to keep in sync
|
||||||
|
with the rest of the app. It can be backed up with `cp` and read with `cat`.
|
||||||
|
|
||||||
|
File naming
|
||||||
|
-----------
|
||||||
|
<YYYYmmdd-HHMMSS>-<8 hex>.json
|
||||||
|
|
||||||
|
Time-ordered so a plain directory listing sorts chronologically, with random
|
||||||
|
suffix so two submissions in the same second cannot collide. The stem is the
|
||||||
|
submission's id and is the ONLY thing the admin URLs accept — see _safe_id().
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
import tempfile
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
#: Submission ids are generated by us and must round-trip through a URL and a
|
||||||
|
#: file path. Anything not matching is rejected before touching the filesystem,
|
||||||
|
#: so a crafted id can never escape the enrollment directory (path traversal).
|
||||||
|
_ID_RE = re.compile(r'^\d{8}-\d{6}-[0-9a-f]{8}$')
|
||||||
|
|
||||||
|
|
||||||
|
class TenantUnresolved(RuntimeError):
|
||||||
|
"""Raised when multi-tenancy is on but no tenant is bound to the request.
|
||||||
|
|
||||||
|
Deliberately fatal rather than falling back to the shared root directory:
|
||||||
|
a fallback would put one tenant's submissions where every other tenant's
|
||||||
|
admin can read them.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def enrollment_dir():
|
||||||
|
"""Absolute path of THIS TENANT's submission directory, created on first use.
|
||||||
|
|
||||||
|
Multi-tenant isolation (MT-17)
|
||||||
|
------------------------------
|
||||||
|
ST keeps every submission in one flat directory. In MT that directory is
|
||||||
|
shared by every tenant on the host, so /enrollment/admin would list other
|
||||||
|
organisations' submissions — names, emails and phone numbers of people at
|
||||||
|
another company. Submissions are therefore filed under a per-tenant
|
||||||
|
subdirectory:
|
||||||
|
|
||||||
|
<ENROLLMENT_DIR>/t<tenant_id>/<submission>.json
|
||||||
|
|
||||||
|
``t<id>`` mirrors ``storage.tenant_key_prefix()`` so the on-disk layout is
|
||||||
|
the same shape as the media object keys.
|
||||||
|
|
||||||
|
When MULTI_TENANT_ENABLED is false the root directory is used unchanged,
|
||||||
|
so a single-tenant deploy behaves exactly like ST.
|
||||||
|
|
||||||
|
When multi-tenancy IS enabled but no tenant is bound, this raises rather
|
||||||
|
than falling back to the root — see TenantUnresolved.
|
||||||
|
"""
|
||||||
|
root = current_app.config['ENROLLMENT_DIR']
|
||||||
|
|
||||||
|
if not current_app.config.get('MULTI_TENANT_ENABLED'):
|
||||||
|
os.makedirs(root, exist_ok=True)
|
||||||
|
return root
|
||||||
|
|
||||||
|
from flask import g
|
||||||
|
tenant = getattr(g, 'tenant', None)
|
||||||
|
if tenant is None:
|
||||||
|
logger.error('ENROLLMENT | no tenant bound — refusing to touch storage')
|
||||||
|
raise TenantUnresolved(
|
||||||
|
'enrollment storage requires a resolved tenant when '
|
||||||
|
'MULTI_TENANT_ENABLED is set'
|
||||||
|
)
|
||||||
|
|
||||||
|
path = os.path.join(root, f't{tenant.id}')
|
||||||
|
os.makedirs(path, exist_ok=True)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def new_id(when=None):
|
||||||
|
"""Mint a time-ordered, collision-safe submission id."""
|
||||||
|
when = when or datetime.now()
|
||||||
|
return f'{when:%Y%m%d-%H%M%S}-{secrets.token_hex(4)}'
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_id(submission_id):
|
||||||
|
"""Return the id if it is one of ours, else None.
|
||||||
|
|
||||||
|
Never interpolate an unvalidated id into a path — `../../etc/passwd` and
|
||||||
|
friends. Callers should 404 on None.
|
||||||
|
"""
|
||||||
|
if not submission_id or not _ID_RE.match(submission_id):
|
||||||
|
logger.warning('ENROLLMENT | rejected malformed id=%r', submission_id)
|
||||||
|
return None
|
||||||
|
return submission_id
|
||||||
|
|
||||||
|
|
||||||
|
def _path_for(submission_id):
|
||||||
|
sid = _safe_id(submission_id)
|
||||||
|
if sid is None:
|
||||||
|
return None
|
||||||
|
return os.path.join(enrollment_dir(), f'{sid}.json')
|
||||||
|
|
||||||
|
|
||||||
|
def save(record):
|
||||||
|
"""Write a submission atomically. Returns the id.
|
||||||
|
|
||||||
|
Written to a temp file in the same directory then os.replace()d, so a
|
||||||
|
crash mid-write can never leave a truncated JSON document that would break
|
||||||
|
the admin list for every other submission.
|
||||||
|
"""
|
||||||
|
sid = record['id']
|
||||||
|
path = _path_for(sid)
|
||||||
|
if path is None:
|
||||||
|
raise ValueError(f'refusing to save malformed id {sid!r}')
|
||||||
|
|
||||||
|
directory = os.path.dirname(path)
|
||||||
|
fd, tmp = tempfile.mkstemp(dir=directory, suffix='.tmp')
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, 'w', encoding='utf-8') as fh:
|
||||||
|
json.dump(record, fh, indent=2, ensure_ascii=False)
|
||||||
|
os.replace(tmp, path)
|
||||||
|
except Exception:
|
||||||
|
# Never leave the temp file behind on a failed write.
|
||||||
|
try:
|
||||||
|
os.unlink(tmp)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
|
||||||
|
logger.info('ENROLLMENT | saved | id=%s project=%r',
|
||||||
|
sid, record.get('project_name'))
|
||||||
|
return sid
|
||||||
|
|
||||||
|
|
||||||
|
def load(submission_id):
|
||||||
|
"""Return one submission dict, or None if unknown/unreadable."""
|
||||||
|
path = _path_for(submission_id)
|
||||||
|
if path is None or not os.path.isfile(path):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
with open(path, encoding='utf-8') as fh:
|
||||||
|
return json.load(fh)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
logger.exception('ENROLLMENT | unreadable submission id=%s', submission_id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def load_all():
|
||||||
|
"""Return every submission, newest first.
|
||||||
|
|
||||||
|
A single corrupt file is skipped with a log line rather than breaking the
|
||||||
|
whole admin list.
|
||||||
|
"""
|
||||||
|
directory = enrollment_dir()
|
||||||
|
records = []
|
||||||
|
for name in sorted(os.listdir(directory), reverse=True):
|
||||||
|
if not name.endswith('.json'):
|
||||||
|
continue
|
||||||
|
rec = load(name[:-len('.json')])
|
||||||
|
if rec is not None:
|
||||||
|
records.append(rec)
|
||||||
|
return records
|
||||||
|
|
||||||
|
|
||||||
|
def update_office(submission_id, office, status):
|
||||||
|
"""Merge the office-use block + status into a stored submission.
|
||||||
|
|
||||||
|
Returns the updated record, or None if the id is unknown. Only these
|
||||||
|
fields are writable after submission — the customer's own answers are
|
||||||
|
immutable, so the file stays an accurate record of what they asked for.
|
||||||
|
"""
|
||||||
|
rec = load(submission_id)
|
||||||
|
if rec is None:
|
||||||
|
return None
|
||||||
|
rec.setdefault('office', {}).update(office)
|
||||||
|
rec['status'] = status
|
||||||
|
rec['updated_at'] = datetime.now().isoformat(timespec='seconds')
|
||||||
|
save(rec)
|
||||||
|
return rec
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Enrollment — {{ record.project_name }}{% endblock %}
|
||||||
|
|
||||||
|
{# One submitted enrollment form, rendered through the same schema the public
|
||||||
|
page uses. The customer's answers are READ-ONLY here — only the office-use
|
||||||
|
block and the status are editable, so the file stays a faithful record of
|
||||||
|
what was actually requested. #}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex flex-wrap justify-content-between align-items-center mb-3 gap-2">
|
||||||
|
<div>
|
||||||
|
<h2 class="mb-0"><i class="bi bi-clipboard-check"></i> {{ record.project_name }}</h2>
|
||||||
|
<div class="text-muted small">
|
||||||
|
Reference {{ record.id }} · submitted {{ record.submitted_at | replace('T', ' ') }}
|
||||||
|
{% if record.updated_at %}
|
||||||
|
· updated {{ record.updated_at | replace('T', ' ') }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a href="{{ url_for('enrollment.admin_list') }}" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-arrow-left"></i> Back
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('enrollment.admin_download', submission_id=record.id) }}"
|
||||||
|
class="btn btn-outline-primary">
|
||||||
|
<i class="bi bi-filetype-json"></i> Download JSON
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
{# ── Request details ─────────────────────────────────────────────── #}
|
||||||
|
<div class="col-12 col-lg-6">
|
||||||
|
<div class="card shadow-sm h-100">
|
||||||
|
<div class="card-header fw-semibold">Request</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<dl class="row mb-0">
|
||||||
|
<dt class="col-5">Project Name</dt><dd class="col-7">{{ record.project_name or '—' }}</dd>
|
||||||
|
<dt class="col-5">Request by</dt><dd class="col-7">{{ record.request_by or '—' }}</dd>
|
||||||
|
<dt class="col-5">Requester email</dt>
|
||||||
|
<dd class="col-7">
|
||||||
|
{% if record.requester_email %}
|
||||||
|
<a href="mailto:{{ record.requester_email }}">{{ record.requester_email }}</a>
|
||||||
|
{% else %}<span class="text-muted">—</span>{% endif %}
|
||||||
|
</dd>
|
||||||
|
<dt class="col-5">Date Requested</dt><dd class="col-7">{{ record.date_requested or '—' }}</dd>
|
||||||
|
</dl>
|
||||||
|
{% if record.notes %}
|
||||||
|
<hr>
|
||||||
|
<div class="fw-semibold small text-muted mb-1">Customer notes</div>
|
||||||
|
<div style="white-space:pre-wrap;">{{ record.notes }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Office use — the only editable part ─────────────────────────── #}
|
||||||
|
<div class="col-12 col-lg-6">
|
||||||
|
<div class="card shadow-sm h-100">
|
||||||
|
<div class="card-header fw-semibold">For Office Use</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="POST">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
{% for key, label in schema.OFFICE_FIELDS %}
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label small mb-1">{{ label }}</label>
|
||||||
|
<input type="text" name="{{ key }}" class="form-control form-control-sm"
|
||||||
|
value="{{ record.office.get(key, '') }}">
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label small mb-1">Status</label>
|
||||||
|
<select name="status" class="form-select form-select-sm">
|
||||||
|
{% for s in schema.STATUSES %}
|
||||||
|
<option value="{{ s }}" {{ 'selected' if record.status == s }}>
|
||||||
|
{{ schema.STATUS_LABELS[s] }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-sm btn-primary">
|
||||||
|
<i class="bi bi-save"></i> Save
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── The people to set up ───────────────────────────────────────────── #}
|
||||||
|
{% set people = schema.people_of(record) %}
|
||||||
|
<div class="card shadow-sm mt-3">
|
||||||
|
<div class="card-header fw-semibold">
|
||||||
|
Users to Register
|
||||||
|
<span class="badge bg-secondary rounded-pill ms-1">{{ people | length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th style="width:50px;">No.</th>
|
||||||
|
<th>Role</th><th>Name</th><th>Job Title</th><th>Email</th>
|
||||||
|
<th class="text-center">Mobile App</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for person in people %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ loop.index }}</td>
|
||||||
|
<td><span class="badge bg-light text-dark border">{{ person.role_label }}</span></td>
|
||||||
|
<td class="fw-semibold">{{ person.name or '—' }}</td>
|
||||||
|
<td>{{ person.job_title or '—' }}</td>
|
||||||
|
<td>
|
||||||
|
{% if person.email %}
|
||||||
|
<a href="mailto:{{ person.email }}">{{ person.email }}</a>
|
||||||
|
{% else %}—{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
{% if schema.wants_mobile(record, person.key) %}
|
||||||
|
<i class="bi bi-phone-fill text-primary" title="Wants the mobile app"></i>
|
||||||
|
{% else %}<span class="text-muted">—</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── The requested task matrix — one column per person ──────────────── #}
|
||||||
|
<div class="card shadow-sm mt-3">
|
||||||
|
<div class="card-header fw-semibold">Requested Tasks & 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;">{{ tenant_branding.display_name if tenant_branding else 'Janitorial QC' }}</p>
|
||||||
|
|
||||||
|
<p>Hi {{ record.request_by or 'there' }},</p>
|
||||||
|
<p>Thank you — we have received your JQC enrollment form. Our team will set up
|
||||||
|
the accounts listed below.</p>
|
||||||
|
|
||||||
|
<table style="border-collapse:collapse;margin:18px 0;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:4px 14px 4px 0;color:#6b7280;">Reference</td>
|
||||||
|
<td style="padding:4px 0;font-weight:bold;">{{ record.id }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:4px 14px 4px 0;color:#6b7280;">Project</td>
|
||||||
|
<td style="padding:4px 0;font-weight:bold;">{{ record.project_name }}</td>
|
||||||
|
</tr>
|
||||||
|
{% if record.date_requested %}
|
||||||
|
<tr>
|
||||||
|
<td style="padding:4px 14px 4px 0;color:#6b7280;">Date requested</td>
|
||||||
|
<td style="padding:4px 0;">{{ record.date_requested }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3 style="font-size:1rem;margin:22px 0 8px;">
|
||||||
|
People to be set up ({{ people | length }})
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<table style="border-collapse:collapse;width:100%;font-size:.92rem;">
|
||||||
|
<thead>
|
||||||
|
<tr style="background:#dbeafe;">
|
||||||
|
<th style="border:1px solid #cbd5e1;padding:6px 9px;text-align:left;">Name</th>
|
||||||
|
<th style="border:1px solid #cbd5e1;padding:6px 9px;text-align:left;">Role</th>
|
||||||
|
<th style="border:1px solid #cbd5e1;padding:6px 9px;text-align:left;">Email</th>
|
||||||
|
<th style="border:1px solid #cbd5e1;padding:6px 9px;text-align:center;">Mobile App</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for person in people %}
|
||||||
|
<tr>
|
||||||
|
<td style="border:1px solid #cbd5e1;padding:6px 9px;">
|
||||||
|
{{ person.name }}
|
||||||
|
{% if person.job_title %}
|
||||||
|
<div style="color:#6b7280;font-size:.82rem;">{{ person.job_title }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td style="border:1px solid #cbd5e1;padding:6px 9px;">{{ person.role_label }}</td>
|
||||||
|
<td style="border:1px solid #cbd5e1;padding:6px 9px;">{{ person.email }}</td>
|
||||||
|
<td style="border:1px solid #cbd5e1;padding:6px 9px;text-align:center;">
|
||||||
|
{{ 'Yes' if schema.wants_mobile(record, person.key) else '—' }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p style="margin-top:22px;">
|
||||||
|
Each person will receive their own email invitation with sign-in
|
||||||
|
instructions, along with a quick guide to the web portal and the mobile app.
|
||||||
|
</p>
|
||||||
|
<p>If anything above is wrong, simply send an email to
|
||||||
|
<a href="mailto:{{ corrections_email }}">{{ corrections_email }}</a>,
|
||||||
|
and we will correct it.</p>
|
||||||
|
|
||||||
|
<hr style="border:none;border-top:1px solid #e5e7eb;margin:26px 0 12px;">
|
||||||
|
<p style="color:#9ca3af;font-size:.8rem;margin:0;">
|
||||||
|
You are receiving this because this address was given as the requester on a
|
||||||
|
JQC enrollment form. Reference {{ record.id }}.
|
||||||
|
</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,445 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="robots" content="noindex, nofollow">
|
||||||
|
<title>Enrollment Form — {{ tenant_branding.display_name if tenant_branding else 'Janitorial QC' }}</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
|
||||||
|
<style>
|
||||||
|
body { background:#f1f5f9; color:#1f2937; }
|
||||||
|
.sheet { max-width:1180px; margin:24px auto 60px; background:#fff;
|
||||||
|
border:1px solid #d7dee6; border-radius:10px; padding:32px 34px 40px; }
|
||||||
|
.form-title { color:#1a6fb5; font-weight:800; font-size:2rem; text-align:center; margin:0; }
|
||||||
|
.form-sub { text-align:center; color:#6b7280; margin-bottom:26px; }
|
||||||
|
.step-head { font-weight:700; margin:30px 0 10px; }
|
||||||
|
.step-head span { font-weight:400; }
|
||||||
|
table.grid { width:100%; border-collapse:collapse; }
|
||||||
|
table.grid th, table.grid td { border:1px solid #cbd5e1; padding:6px 9px; vertical-align:middle; }
|
||||||
|
table.grid thead th { background:#dbeafe; font-weight:700; text-align:center; font-size:.86rem; line-height:1.25; }
|
||||||
|
table.grid thead th.left { text-align:left; }
|
||||||
|
.ref-col { width:52px; text-align:center; }
|
||||||
|
.chk-col { min-width:104px; text-align:center; }
|
||||||
|
.chk-col input { width:18px; height:18px; }
|
||||||
|
.people-table thead th { background:#dcfce7; }
|
||||||
|
.hdr-table td { border:1px solid #cbd5e1; padding:6px 9px; }
|
||||||
|
.hdr-table .lbl { background:#f8fafc; font-weight:600; width:170px; white-space:nowrap; }
|
||||||
|
.hdr-table input { border:none; outline:none; width:100%; }
|
||||||
|
.hdr-table input:focus { background:#eff6ff; }
|
||||||
|
.cell-input { border:1px solid transparent; background:transparent; width:100%;
|
||||||
|
padding:2px 4px; border-radius:4px; }
|
||||||
|
.cell-input:focus { border-color:#1a6fb5; background:#fff; outline:none; }
|
||||||
|
.col-person { font-weight:700; font-size:.84rem; line-height:1.2; }
|
||||||
|
.col-role { font-weight:400; font-size:.76rem; color:#4b5563; display:block; margin-top:2px; }
|
||||||
|
.cell-na { color:#cbd5e1; }
|
||||||
|
.office-note { color:#6b7280; font-size:.82rem; }
|
||||||
|
.note-list { font-size:.92rem; }
|
||||||
|
.scroll-x { overflow-x:auto; }
|
||||||
|
.empty-hint { border:1px dashed #cbd5e1; border-radius:8px; padding:20px;
|
||||||
|
text-align:center; color:#6b7280; }
|
||||||
|
/* Honeypot — hidden from humans, visible to naive bots. Not type=hidden:
|
||||||
|
some bots skip those. */
|
||||||
|
.hp { position:absolute; left:-9999px; width:1px; height:1px; overflow:hidden; }
|
||||||
|
@media (max-width: 820px) {
|
||||||
|
.sheet { padding:18px 14px 30px; margin:10px; }
|
||||||
|
table.grid { font-size:.8rem; }
|
||||||
|
.chk-col { min-width:70px; }
|
||||||
|
}
|
||||||
|
@media print {
|
||||||
|
body { background:#fff; }
|
||||||
|
.sheet { border:none; margin:0; max-width:none; }
|
||||||
|
.no-print { display:none !important; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="sheet">
|
||||||
|
|
||||||
|
<h1 class="form-title">JQC Enrollment Form</h1>
|
||||||
|
<div class="form-sub">{{ tenant_branding.display_name if tenant_branding else 'Janitorial QC' }}</div>
|
||||||
|
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="alert alert-{{ category }} no-print">{{ message }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<noscript>
|
||||||
|
<div class="alert alert-warning no-print">
|
||||||
|
This form needs JavaScript enabled — the task table is built from the
|
||||||
|
people you add. Please enable JavaScript, or contact us and we will send
|
||||||
|
you a printable copy.
|
||||||
|
</div>
|
||||||
|
</noscript>
|
||||||
|
|
||||||
|
<form method="POST" action="{{ url_for('enrollment.submit') }}" id="enrollForm">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
|
||||||
|
{# Honeypot — must stay empty. #}
|
||||||
|
<div class="hp" aria-hidden="true">
|
||||||
|
<label>Website<input type="text" name="website" tabindex="-1" autocomplete="off"></label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Header ─────────────────────────────────────────────────────── #}
|
||||||
|
{# The printed sheet carried a blank "for office use" block here. It is not
|
||||||
|
rendered on the web form — a customer cannot fill it in. Those fields
|
||||||
|
still exist and are filled by staff on the admin detail page. #}
|
||||||
|
<div class="row g-3 mb-2">
|
||||||
|
<div class="col-12 col-lg-8">
|
||||||
|
<table class="hdr-table" style="width:100%;">
|
||||||
|
<tr>
|
||||||
|
<td class="lbl">Project Name</td>
|
||||||
|
<td><input type="text" name="project_name" required maxlength="200"
|
||||||
|
value="{{ submitted.project_name if submitted else '' }}"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="lbl">Request by:</td>
|
||||||
|
<td><input type="text" name="request_by" required maxlength="200"
|
||||||
|
placeholder="Your name"
|
||||||
|
value="{{ submitted.request_by if submitted else '' }}"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="lbl">Requester email:</td>
|
||||||
|
<td><input type="email" name="requester_email" required maxlength="200"
|
||||||
|
placeholder="you@company.com"
|
||||||
|
value="{{ submitted.requester_email if submitted else '' }}"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="lbl">Date Requested:</td>
|
||||||
|
<td><input type="date" name="date_requested"
|
||||||
|
value="{{ submitted.date_requested if submitted else '' }}"></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<div class="form-text mt-1">
|
||||||
|
We send your confirmation, with a copy of everything below, to the
|
||||||
|
requester email.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Step 1 — the people ────────────────────────────────────────── #}
|
||||||
|
<div class="step-head">
|
||||||
|
Step 1: <span>Please list everyone who needs access. Each person will
|
||||||
|
receive an email invitation at the address you provide.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="scroll-x">
|
||||||
|
<table class="grid people-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="ref-col">No.</th>
|
||||||
|
<th class="left" style="min-width:190px;">Role</th>
|
||||||
|
<th class="left" style="min-width:190px;">First and last name</th>
|
||||||
|
<th class="left" style="min-width:160px;">Job Title</th>
|
||||||
|
<th class="left" style="min-width:210px;">Email Address</th>
|
||||||
|
<th style="width:52px;"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="peopleBody"><!-- rows injected by JS --></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2 no-print">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-primary" id="addPersonBtn">
|
||||||
|
<i class="bi bi-plus-lg"></i> Add another person
|
||||||
|
</button>
|
||||||
|
<span class="text-muted small ms-2" id="peopleCount"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Step 2 — the task matrix, built from Step 1 ────────────────── #}
|
||||||
|
<div class="step-head">
|
||||||
|
Step 2: <span>Please check the task/function for each user, or apply our
|
||||||
|
recommended selection and adjust it.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-2 no-print">
|
||||||
|
<button type="button" class="btn btn-sm btn-primary" id="recommendBtn">
|
||||||
|
<i class="bi bi-magic"></i> Recommendation selection
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary ms-1" id="clearBtn">
|
||||||
|
Clear all
|
||||||
|
</button>
|
||||||
|
<div class="form-text">
|
||||||
|
Our recommendation keeps administrators and directors from receiving an
|
||||||
|
overwhelming number of email notifications. You can change any box afterwards.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="scroll-x" id="matrixWrap"><!-- table injected by JS --></div>
|
||||||
|
|
||||||
|
{# Rows that come WITH the role rather than being switched on per person.
|
||||||
|
Ticking them records what you expect; it does not change access. #}
|
||||||
|
<div class="form-text mt-2">
|
||||||
|
Rows
|
||||||
|
{% for ref in schema.ROLE_IMPLIED_TASKS | sort %}{% if not loop.first %}{{ ', ' if not loop.last else ' and ' }}{% endif %}{{ ref }}{% endfor %}
|
||||||
|
({% for ref in schema.ROLE_IMPLIED_TASKS | sort %}{{ schema.TASK_LABELS[ref] }}{{ '; ' if not loop.last }}{% endfor %})
|
||||||
|
are included with the user's role where their access allows it — tick them
|
||||||
|
to record what you expect. The remaining rows control which email and
|
||||||
|
in-app notifications each user receives.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Step 3 — mobile app ────────────────────────────────────────── #}
|
||||||
|
<div class="step-head">
|
||||||
|
Step 3: <span>Please check the box next to the user who will receive the
|
||||||
|
app for smart devices.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="scroll-x" id="mobileWrap"><!-- table injected by JS --></div>
|
||||||
|
|
||||||
|
{# ── Notes ──────────────────────────────────────────────────────── #}
|
||||||
|
<div class="step-head">Anything else we should know? <span>(optional)</span></div>
|
||||||
|
<textarea name="notes" class="form-control" rows="3" maxlength="2000"
|
||||||
|
placeholder="Special requirements, timing, additional users…">{{ submitted.notes if submitted else '' }}</textarea>
|
||||||
|
|
||||||
|
<div class="step-head">Note:</div>
|
||||||
|
<ol class="note-list">
|
||||||
|
{% for note in schema.NOTES %}<li>{{ note }}</li>{% endfor %}
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<div class="d-flex gap-2 mt-4 no-print">
|
||||||
|
<button type="submit" class="btn btn-primary px-4" id="submitBtn">
|
||||||
|
<i class="bi bi-send"></i> Submit Enrollment
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary" onclick="window.print()">
|
||||||
|
<i class="bi bi-printer"></i> Print
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// ── Data handed over from schema.py — the single source of truth ────────
|
||||||
|
var ROLES = {{ schema.ROLES | tojson }};
|
||||||
|
var TASKS = {{ schema.TASKS | tojson }};
|
||||||
|
var ADMIN_ROLES = {{ schema.ADMIN_ROLES | list | tojson }};
|
||||||
|
var RECOMMENDATION = {{ schema.recommendation_map() | tojson }};
|
||||||
|
var DEFAULT_ROLE = {{ schema.DEFAULT_FIRST_ROLE | tojson }};
|
||||||
|
var MOBILE_LABEL = {{ schema.MOBILE_APP_LABEL | tojson }};
|
||||||
|
var MAX_PEOPLE = {{ schema.MAX_PEOPLE | tojson }};
|
||||||
|
var SEED = {{ seed_people | tojson }};
|
||||||
|
|
||||||
|
var peopleBody = document.getElementById('peopleBody');
|
||||||
|
var matrixWrap = document.getElementById('matrixWrap');
|
||||||
|
var mobileWrap = document.getElementById('mobileWrap');
|
||||||
|
var countLabel = document.getElementById('peopleCount');
|
||||||
|
|
||||||
|
// Row indexes only ever increase, so removing a middle row can never make a
|
||||||
|
// new row reuse a departed row's field names. The server re-keys people by
|
||||||
|
// position on receipt, so gaps here are harmless.
|
||||||
|
var nextIndex = 0;
|
||||||
|
|
||||||
|
function isAdminRole(role) { return ADMIN_ROLES.indexOf(role) !== -1; }
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s == null ? '' : s)
|
||||||
|
.replace(/&/g,'&').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">{{ tenant_branding.display_name if tenant_branding else 'Janitorial QC' }}</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.facility import Facility, Area
|
from app.models.facility import Facility, Area
|
||||||
from app.models.inspection import (InspectionTemplate, ChecklistItem,
|
from app.models.inspection import (InspectionTemplate, ChecklistItem,
|
||||||
Inspection, InspectionResult)
|
Inspection, InspectionResult,
|
||||||
|
TemplateContract)
|
||||||
from app.models.issue import Issue
|
from app.models.issue import Issue
|
||||||
from app.models.project import Project, CustomerAssignment
|
from app.models.project import Project, CustomerAssignment
|
||||||
from app.models.project_recipient import ProjectNotificationRecipient
|
from app.models.project_recipient import ProjectNotificationRecipient
|
||||||
from app.models.api_token import RefreshToken, DeviceToken
|
from app.models.api_token import RefreshToken, DeviceToken
|
||||||
from app.models.notification_matrix import NotificationMatrix
|
from app.models.notification_matrix import NotificationMatrix
|
||||||
|
from app.models.user_notification_matrix import UserNotificationMatrix
|
||||||
from app.models.inspection_schedule import InspectionSchedule
|
from app.models.inspection_schedule import InspectionSchedule
|
||||||
from app.models.work_order import IssueWorkOrder
|
from app.models.work_order import IssueWorkOrder
|
||||||
@@ -42,6 +42,18 @@ class Area(db.Model):
|
|||||||
name = db.Column(db.String(255), nullable=False)
|
name = db.Column(db.String(255), nullable=False)
|
||||||
area_type = db.Column(db.String(50))
|
area_type = db.Column(db.String(50))
|
||||||
|
|
||||||
|
# phase42: unguessable token behind the public area scan page (/f/area/<token>).
|
||||||
|
# NULL until first requested — ensure_qr_token() generates it lazily, exactly
|
||||||
|
# like Facility.qr_token above.
|
||||||
|
qr_token = db.Column(db.String(64), unique=True, nullable=True)
|
||||||
|
|
||||||
|
def ensure_qr_token(self):
|
||||||
|
"""Generate the QR token on first use. Caller commits."""
|
||||||
|
if not self.qr_token:
|
||||||
|
import secrets
|
||||||
|
self.qr_token = secrets.token_urlsafe(32)
|
||||||
|
return self.qr_token
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
inspections = db.relationship('Inspection', backref='area', lazy='dynamic')
|
inspections = db.relationship('Inspection', backref='area', lazy='dynamic')
|
||||||
issues = db.relationship('Issue', backref='area', lazy='dynamic')
|
issues = db.relationship('Issue', backref='area', lazy='dynamic')
|
||||||
|
|||||||
@@ -3,6 +3,43 @@ from app.utils.time_utils import now_eastern
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateContract(db.Model):
|
||||||
|
"""Restricts a form to specific contracts (phase52).
|
||||||
|
|
||||||
|
A customer's bespoke form must not be visible to — or startable against —
|
||||||
|
another customer's facilities. One row = "this template is available on
|
||||||
|
this contract".
|
||||||
|
|
||||||
|
**No rows means the template is SHARED** (available on every contract), not
|
||||||
|
"available nowhere". That is what makes the feature additive: every
|
||||||
|
template that existed before phase52 has no rows, so nothing changed on
|
||||||
|
deploy, and a form becomes customer-specific only when an admin attaches it
|
||||||
|
to at least one contract. The empty-set-means-all convention is the whole
|
||||||
|
migration story — do not "fix" it to mean the opposite.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = 'template_contracts'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
template_id = db.Column(db.Integer,
|
||||||
|
db.ForeignKey('inspection_templates.id', ondelete='CASCADE'),
|
||||||
|
nullable=False, index=True)
|
||||||
|
project_id = db.Column(db.Integer,
|
||||||
|
db.ForeignKey('projects.id', ondelete='CASCADE'),
|
||||||
|
nullable=False, index=True)
|
||||||
|
created_at = db.Column(db.DateTime, default=now_eastern, nullable=False)
|
||||||
|
|
||||||
|
project = db.relationship('Project', backref='template_contracts')
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
db.UniqueConstraint('template_id', 'project_id',
|
||||||
|
name='uq_template_contract'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<TemplateContract template={self.template_id} project={self.project_id}>'
|
||||||
|
|
||||||
|
|
||||||
class InspectionTemplate(db.Model):
|
class InspectionTemplate(db.Model):
|
||||||
__tablename__ = 'inspection_templates'
|
__tablename__ = 'inspection_templates'
|
||||||
|
|
||||||
@@ -18,6 +55,82 @@ class InspectionTemplate(db.Model):
|
|||||||
checklist_items = db.relationship('ChecklistItem', backref='template', lazy='dynamic', cascade='all, delete-orphan')
|
checklist_items = db.relationship('ChecklistItem', backref='template', lazy='dynamic', cascade='all, delete-orphan')
|
||||||
inspections = db.relationship('Inspection', backref='template', lazy='dynamic')
|
inspections = db.relationship('Inspection', backref='template', lazy='dynamic')
|
||||||
|
|
||||||
|
# phase52 — contract restrictions. Deleting a template removes its links.
|
||||||
|
contract_links = db.relationship('TemplateContract', backref='template',
|
||||||
|
lazy='dynamic',
|
||||||
|
cascade='all, delete-orphan')
|
||||||
|
|
||||||
|
# ── Contract availability (phase52) ──────────────────────────────────
|
||||||
|
|
||||||
|
@property
|
||||||
|
def contract_ids(self):
|
||||||
|
"""Project ids this form is restricted to; empty = shared with all."""
|
||||||
|
return sorted(l.project_id for l in self.contract_links.all())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_shared(self):
|
||||||
|
"""True when the form carries no restriction and is available anywhere."""
|
||||||
|
return self.contract_links.count() == 0
|
||||||
|
|
||||||
|
def available_for_project(self, project_id):
|
||||||
|
"""Can this form be used on `project_id`?
|
||||||
|
|
||||||
|
Shared forms are usable anywhere, including on a facility that has no
|
||||||
|
contract at all. A restricted form needs an explicit link, so a
|
||||||
|
facility with no contract (project_id None) can only ever use shared
|
||||||
|
forms — fail-closed, which is the right side to err on.
|
||||||
|
"""
|
||||||
|
if self.is_shared:
|
||||||
|
return True
|
||||||
|
if project_id is None:
|
||||||
|
return False
|
||||||
|
return project_id in set(self.contract_ids)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def available_query(project_id, active_only=True):
|
||||||
|
"""Query of templates usable on `project_id` (shared + linked).
|
||||||
|
|
||||||
|
The single definition of "which forms may this contract use". Every
|
||||||
|
picker, the POST validation behind it, and the mobile API all go
|
||||||
|
through here so they cannot disagree — a picker that offers more than
|
||||||
|
the validator accepts silently drops work (see rule 93 for the same
|
||||||
|
failure in the assignee dropdown).
|
||||||
|
"""
|
||||||
|
q = InspectionTemplate.query
|
||||||
|
if active_only:
|
||||||
|
q = q.filter(InspectionTemplate.active == True)
|
||||||
|
|
||||||
|
shared = ~InspectionTemplate.contract_links.any()
|
||||||
|
if project_id is None:
|
||||||
|
# No contract to match against — only unrestricted forms apply.
|
||||||
|
return q.filter(shared).order_by(InspectionTemplate.name)
|
||||||
|
|
||||||
|
linked = InspectionTemplate.contract_links.any(
|
||||||
|
TemplateContract.project_id == project_id
|
||||||
|
)
|
||||||
|
return q.filter(db.or_(shared, linked)).order_by(InspectionTemplate.name)
|
||||||
|
|
||||||
|
def set_contracts(self, project_ids):
|
||||||
|
"""Replace this form's contract restrictions.
|
||||||
|
|
||||||
|
Pass an empty list to make the form shared again. Does NOT commit —
|
||||||
|
the caller owns the transaction. Returns True if anything changed.
|
||||||
|
"""
|
||||||
|
wanted = {int(p) for p in project_ids or []}
|
||||||
|
existing = {l.project_id: l for l in self.contract_links.all()}
|
||||||
|
|
||||||
|
changed = False
|
||||||
|
for pid, link in existing.items():
|
||||||
|
if pid not in wanted:
|
||||||
|
db.session.delete(link)
|
||||||
|
changed = True
|
||||||
|
for pid in wanted:
|
||||||
|
if pid not in existing:
|
||||||
|
db.session.add(TemplateContract(template_id=self.id, project_id=pid))
|
||||||
|
changed = True
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
def get_form_schema(self):
|
def get_form_schema(self):
|
||||||
if self.form_schema is None:
|
if self.form_schema is None:
|
||||||
return []
|
return []
|
||||||
@@ -57,6 +170,13 @@ class Inspection(db.Model):
|
|||||||
template_id = db.Column(db.Integer, db.ForeignKey('inspection_templates.id'), nullable=False)
|
template_id = db.Column(db.Integer, db.ForeignKey('inspection_templates.id'), nullable=False)
|
||||||
facility_id = db.Column(db.Integer, db.ForeignKey('facilities.id'), nullable=False)
|
facility_id = db.Column(db.Integer, db.ForeignKey('facilities.id'), nullable=False)
|
||||||
area_id = db.Column(db.Integer, db.ForeignKey('areas.id'))
|
area_id = db.Column(db.Integer, db.ForeignKey('areas.id'))
|
||||||
|
# phase43: set when this inspection was started from / materialised by a
|
||||||
|
# schedule. ON DELETE SET NULL — deleting a schedule never deletes history.
|
||||||
|
inspection_schedule_id = db.Column(
|
||||||
|
db.Integer,
|
||||||
|
db.ForeignKey('inspection_schedules.id', ondelete='SET NULL'),
|
||||||
|
nullable=True, index=True
|
||||||
|
)
|
||||||
inspector_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
inspector_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||||
inspection_date = db.Column(db.DateTime, nullable=False, default=now_eastern)
|
inspection_date = db.Column(db.DateTime, nullable=False, default=now_eastern)
|
||||||
overall_score = db.Column(db.Numeric(5, 2))
|
overall_score = db.Column(db.Numeric(5, 2))
|
||||||
@@ -74,11 +194,91 @@ class Inspection(db.Model):
|
|||||||
)
|
)
|
||||||
follow_up_required = db.Column(db.Boolean, nullable=False, default=False)
|
follow_up_required = db.Column(db.Boolean, nullable=False, default=False)
|
||||||
follow_up_note = db.Column(db.Text, nullable=True)
|
follow_up_note = db.Column(db.Text, nullable=True)
|
||||||
|
# phase49 — WHO asked for the follow-up and when. `follow_up_required` alone
|
||||||
|
# cannot distinguish a client request from an internal one, and staff need to
|
||||||
|
# know who is waiting. Set by flag_followup(), nulled by clear_followup().
|
||||||
|
# NULL on every pre-phase49 row, which the UI renders as an unattributed
|
||||||
|
# follow-up exactly as before.
|
||||||
|
follow_up_requested_by = db.Column(
|
||||||
|
db.Integer,
|
||||||
|
db.ForeignKey('users.id', ondelete='SET NULL',
|
||||||
|
name='fk_inspections_follow_up_requested_by'),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
follow_up_requested_at = db.Column(db.DateTime, nullable=True)
|
||||||
|
|
||||||
|
# phase56 — who is to PERFORM the follow-up re-inspection.
|
||||||
|
#
|
||||||
|
# NULL keeps the original behaviour: the follow-up belongs to the
|
||||||
|
# inspection's own inspector. When set, that person owns it instead — they
|
||||||
|
# are the one notified, and the one it appears for on the iPad. Lets a
|
||||||
|
# director (or a Customer Director) hand a re-inspection to someone other
|
||||||
|
# than whoever did the original.
|
||||||
|
#
|
||||||
|
# This is the THIRD FK from inspections to users: every relationship
|
||||||
|
# spanning the two must pin foreign_keys explicitly, or the mapper is
|
||||||
|
# ambiguous and blows up on first ORM USE rather than at import — the app
|
||||||
|
# starts cleanly and then every request 500s.
|
||||||
|
follow_up_assigned_to = db.Column(
|
||||||
|
db.Integer,
|
||||||
|
db.ForeignKey('users.id', ondelete='SET NULL',
|
||||||
|
name='fk_inspections_followup_assignee'),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
|
||||||
results = db.relationship('InspectionResult', backref='inspection', lazy='dynamic', cascade='all, delete-orphan')
|
results = db.relationship('InspectionResult', backref='inspection', lazy='dynamic', cascade='all, delete-orphan')
|
||||||
issues = db.relationship('Issue', backref='inspection', lazy='dynamic', cascade='all, delete-orphan')
|
issues = db.relationship('Issue', backref='inspection', lazy='dynamic', cascade='all, delete-orphan')
|
||||||
follow_ups = db.relationship('Inspection', backref=db.backref('parent', remote_side='Inspection.id'),
|
follow_ups = db.relationship('Inspection', backref=db.backref('parent', remote_side='Inspection.id'),
|
||||||
lazy='dynamic', foreign_keys='Inspection.parent_inspection_id')
|
lazy='dynamic', foreign_keys='Inspection.parent_inspection_id')
|
||||||
|
# phase49. Explicit foreign_keys is required: inspector_id also points at
|
||||||
|
# users.id, so SQLAlchemy cannot infer which column this relationship uses.
|
||||||
|
follow_up_requester = db.relationship('User',
|
||||||
|
foreign_keys=[follow_up_requested_by])
|
||||||
|
follow_up_assignee = db.relationship('User',
|
||||||
|
foreign_keys=[follow_up_assigned_to])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def follow_up_owner(self):
|
||||||
|
"""Who is expected to carry out the follow-up.
|
||||||
|
|
||||||
|
The explicit assignee when one is set, otherwise the inspection's own
|
||||||
|
inspector — the single definition of ownership, so the web display, the
|
||||||
|
notification and the mobile API filter cannot disagree about who owns a
|
||||||
|
follow-up.
|
||||||
|
"""
|
||||||
|
return self.follow_up_assignee or self.inspector
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def follow_up_owned_by(user_id):
|
||||||
|
"""SQL predicate: *user_id* owns this inspection's follow-up.
|
||||||
|
|
||||||
|
The query-side mirror of `follow_up_owner` above. Ownership has to be
|
||||||
|
expressed twice — once for a loaded row, once in SQL — so both live
|
||||||
|
here, together, and every caller uses one of them.
|
||||||
|
|
||||||
|
The two arms are mutually exclusive on purpose. Drop the `is_(None)`
|
||||||
|
from the second and an inspector keeps matching a follow-up that was
|
||||||
|
handed to someone else: two people turn up for the same re-inspection.
|
||||||
|
|
||||||
|
Callers: the mobile list filter, the web dashboard card, and the iPad
|
||||||
|
stats KPI. They previously each wrote their own version, and three of
|
||||||
|
them tested AUTHORSHIP — so an assignee saw the work in their list but
|
||||||
|
a 0 on both dashboards.
|
||||||
|
"""
|
||||||
|
return db.or_(
|
||||||
|
Inspection.follow_up_assigned_to == user_id,
|
||||||
|
db.and_(
|
||||||
|
Inspection.follow_up_assigned_to.is_(None),
|
||||||
|
Inspection.inspector_id == user_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# The schedule this inspection was started from / materialised by, so the
|
||||||
|
# detail view can show the cadence and who set it up. Explicit foreign_keys
|
||||||
|
# again: inspection_schedules.parent_inspection_id points back here (phase48),
|
||||||
|
# so neither side's join is inferable.
|
||||||
|
inspection_schedule = db.relationship(
|
||||||
|
'InspectionSchedule', foreign_keys=[inspection_schedule_id])
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f'<Inspection {self.id} - {self.inspection_date}>'
|
return f'<Inspection {self.id} - {self.inspection_date}>'
|
||||||
|
|||||||
@@ -13,12 +13,107 @@ their queue and fills it out through the normal execute flow.
|
|||||||
|
|
||||||
This is purely additive: no existing inspection behaviour changes. A schedule is
|
This is purely additive: no existing inspection behaviour changes. A schedule is
|
||||||
just an automated `inspections.start()`.
|
just an automated `inspections.start()`.
|
||||||
|
|
||||||
|
phase43 adds the single-tenant "plan" semantics alongside that:
|
||||||
|
|
||||||
|
mode='auto' (default, phase34 behaviour)
|
||||||
|
Cron materialises the Inspection at next_run_at and notifies the inspector.
|
||||||
|
|
||||||
|
mode='plan' (ST behaviour)
|
||||||
|
Nothing is materialised. The schedule is a commitment with a due date; the
|
||||||
|
assigned inspector clicks "Start", which creates the Inspection linked back
|
||||||
|
via Inspection.inspection_schedule_id. Reminders fire in advance / on the
|
||||||
|
due date / once overdue. Completing the inspection calls fulfill(), which
|
||||||
|
deactivates a one-time schedule or rolls a recurring one forward.
|
||||||
|
|
||||||
|
`next_run_at` is the due datetime for both modes.
|
||||||
|
|
||||||
|
phase45/46/47 port the single-tenant recurrence + end-date model
|
||||||
|
(ST phase43 + phase44) onto this table without renaming anything:
|
||||||
|
|
||||||
|
phase45 frequency ENUM gains 'once', 'bi-annually' and 'annually'
|
||||||
|
phase46 weekdays / month_mode / day_of_month / nth_week / nth_weekday
|
||||||
|
phase47 end_date
|
||||||
|
|
||||||
|
`next_run_at` keeps its name, its DATETIME type and its index — it remains the
|
||||||
|
due datetime, and is ST's `next_due_date` by another name. All recurrence maths
|
||||||
|
happens on its DATE part; the TIME part is preserved across roll-forwards
|
||||||
|
(defaulting to 06:00, the hour `_compute_next_run()` has always used) so the
|
||||||
|
auto-mode cron keeps firing at the same time of day.
|
||||||
|
|
||||||
|
Two dates, deliberately distinct:
|
||||||
|
next_run_at — mutable state. The next occurrence. Rewritten by fulfill()
|
||||||
|
after every completed inspection.
|
||||||
|
end_date — fixed boundary. The last date an occurrence may fall on, set by
|
||||||
|
the manager and never rewritten. NULL = repeat indefinitely.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import calendar
|
||||||
|
from datetime import date, datetime, time, timedelta
|
||||||
|
|
||||||
from app import db
|
from app import db
|
||||||
from app.utils.time_utils import now_eastern
|
from app.utils.time_utils import now_eastern
|
||||||
|
|
||||||
|
|
||||||
|
# Every frequency this table accepts (phase45). 'bi-annually' is every 6 months.
|
||||||
|
FREQUENCY_CHOICES = ('once', 'daily', 'weekly', 'monthly',
|
||||||
|
'quarterly', 'bi-annually', 'annually')
|
||||||
|
|
||||||
|
# Recurring frequencies that advance by whole calendar months.
|
||||||
|
_MONTH_STEPS = {
|
||||||
|
'monthly': 1,
|
||||||
|
'quarterly': 3,
|
||||||
|
'bi-annually': 6,
|
||||||
|
'annually': 12,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Monthly recurrence styles (phase46). Stored as VARCHAR, not ENUM, so adding a
|
||||||
|
# style later needs no 3-step MySQL ENUM migration.
|
||||||
|
MONTH_MODE_DAY = 'day_of_month' # "the 15th of every month"
|
||||||
|
MONTH_MODE_NTH = 'nth_weekday' # "the 2nd Tuesday of every month"
|
||||||
|
|
||||||
|
# Python weekday numbering: Monday=0 … Sunday=6 (matches date.weekday()).
|
||||||
|
WEEKDAY_NAMES = ('Monday', 'Tuesday', 'Wednesday', 'Thursday',
|
||||||
|
'Friday', 'Saturday', 'Sunday')
|
||||||
|
WEEKDAY_ABBREV = ('Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun')
|
||||||
|
|
||||||
|
# nth_week: 1–4 are literal, 5 means "5th (or last if the month is short)",
|
||||||
|
# -1 means "last" explicitly.
|
||||||
|
NTH_WEEK_LABELS = {1: '1st', 2: '2nd', 3: '3rd', 4: '4th', 5: '5th', -1: 'Last'}
|
||||||
|
|
||||||
|
# The hour auto-mode schedules have always been due at (see _compute_next_run()
|
||||||
|
# in app/routes/inspection_schedules.py). Used when a schedule has no
|
||||||
|
# next_run_at yet and therefore no time-of-day to preserve.
|
||||||
|
DEFAULT_RUN_HOUR = 6
|
||||||
|
|
||||||
|
|
||||||
|
def _last_day_of(year, month):
|
||||||
|
return calendar.monthrange(year, month)[1]
|
||||||
|
|
||||||
|
|
||||||
|
def _shift_month(year, month, n=1):
|
||||||
|
"""Return (year, month) shifted by *n* months."""
|
||||||
|
idx = year * 12 + (month - 1) + n
|
||||||
|
return idx // 12, idx % 12 + 1
|
||||||
|
|
||||||
|
|
||||||
|
def _nth_weekday_of(year, month, weekday, nth):
|
||||||
|
"""Date of the *nth* *weekday* in a month.
|
||||||
|
|
||||||
|
``nth == -1`` means the last one. A requested 5th occurrence that does not
|
||||||
|
exist falls back to the 4th, so every month yields a valid date.
|
||||||
|
"""
|
||||||
|
last = _last_day_of(year, month)
|
||||||
|
if nth == -1:
|
||||||
|
d = date(year, month, last)
|
||||||
|
return d - timedelta(days=(d.weekday() - weekday) % 7)
|
||||||
|
first = date(year, month, 1)
|
||||||
|
day = 1 + ((weekday - first.weekday()) % 7) + (nth - 1) * 7
|
||||||
|
while day > last:
|
||||||
|
day -= 7
|
||||||
|
return date(year, month, day)
|
||||||
|
|
||||||
|
|
||||||
class InspectionSchedule(db.Model):
|
class InspectionSchedule(db.Model):
|
||||||
__tablename__ = 'inspection_schedules'
|
__tablename__ = 'inspection_schedules'
|
||||||
|
|
||||||
@@ -42,13 +137,55 @@ class InspectionSchedule(db.Model):
|
|||||||
nullable=False
|
nullable=False
|
||||||
)
|
)
|
||||||
|
|
||||||
# daily | weekly | monthly | quarterly — mirrors InspectionTemplate.frequency
|
# once | daily | weekly | monthly | quarterly | bi-annually | annually
|
||||||
|
# (phase45 — 'once', 'bi-annually' and 'annually' added; the original four
|
||||||
|
# values are unchanged, so no existing row is affected.)
|
||||||
frequency = db.Column(
|
frequency = db.Column(
|
||||||
db.Enum('daily', 'weekly', 'monthly', 'quarterly'),
|
db.Enum(*FREQUENCY_CHOICES),
|
||||||
nullable=False, default='weekly'
|
nullable=False, default='weekly'
|
||||||
)
|
)
|
||||||
active = db.Column(db.Boolean, nullable=False, default=True)
|
active = db.Column(db.Boolean, nullable=False, default=True)
|
||||||
|
|
||||||
|
# phase43: 'auto' = cron materialises the inspection (phase34 behaviour,
|
||||||
|
# the default for every pre-existing row); 'plan' = the inspector starts it.
|
||||||
|
mode = db.Column(db.Enum('auto', 'plan'), nullable=False, default='auto')
|
||||||
|
notes = db.Column(db.Text, nullable=True)
|
||||||
|
|
||||||
|
# ── Follow-up link (phase48) ─────────────────────────────────────────────
|
||||||
|
# Set when this schedule was created as a follow-up of a specific completed
|
||||||
|
# inspection ("Schedule Follow-up" in the iPad's history detail — the
|
||||||
|
# deferred twin of "Re-inspect Now"). The inspection eventually started from
|
||||||
|
# this schedule inherits it as its own parent_inspection_id, so the run
|
||||||
|
# lands as a true linked re-inspection: pre-filled from the parent, and
|
||||||
|
# clearing the parent's follow_up_required on submit. NULL = an ordinary
|
||||||
|
# schedule, which is what every pre-phase48 row is.
|
||||||
|
parent_inspection_id = db.Column(
|
||||||
|
db.Integer,
|
||||||
|
# use_alter + an explicit name: inspections and inspection_schedules now
|
||||||
|
# reference each other, so metadata-driven CREATE/DROP cannot topologically
|
||||||
|
# sort them. The name matches the constraint phase48 creates, so the ORM's
|
||||||
|
# view of the schema and the migration's agree.
|
||||||
|
db.ForeignKey('inspections.id', ondelete='SET NULL',
|
||||||
|
name='fk_inspection_schedules_parent_inspection',
|
||||||
|
use_alter=True),
|
||||||
|
nullable=True, index=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Receipt acknowledgement (phase50) ────────────────────────────────────
|
||||||
|
# Stamped when the assigned inspector confirms they received the request.
|
||||||
|
# NULL = awaiting confirmation.
|
||||||
|
#
|
||||||
|
# Per ASSIGNMENT, not per occurrence: fulfill() and advance_due_date()
|
||||||
|
# deliberately leave this alone as the schedule rolls forward, so an
|
||||||
|
# inspector who confirmed "yes, this weekly round is mine" is not asked
|
||||||
|
# again every week. The edit route resets it to NULL on reassignment to a
|
||||||
|
# DIFFERENT inspector, who has confirmed nothing.
|
||||||
|
#
|
||||||
|
# The acknowledger is always `inspector` — the only person the routes let
|
||||||
|
# confirm — so no separate acknowledged_by column is needed. Only meaningful
|
||||||
|
# for mode='plan'; an auto schedule has no request to receive.
|
||||||
|
acknowledged_at = db.Column(db.DateTime, nullable=True)
|
||||||
|
|
||||||
created_by = db.Column(
|
created_by = db.Column(
|
||||||
db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'),
|
db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'),
|
||||||
nullable=True
|
nullable=True
|
||||||
@@ -57,12 +194,307 @@ class InspectionSchedule(db.Model):
|
|||||||
last_run_at = db.Column(db.DateTime, nullable=True) # last successful materialisation
|
last_run_at = db.Column(db.DateTime, nullable=True) # last successful materialisation
|
||||||
next_run_at = db.Column(db.DateTime, nullable=True) # when the next inspection is due
|
next_run_at = db.Column(db.DateTime, nullable=True) # when the next inspection is due
|
||||||
|
|
||||||
|
# ── Recurrence detail (phase46) ──────────────────────────────────────────
|
||||||
|
# weekly : CSV of Python weekday ints, e.g. '0,2,4' = Mon/Wed/Fri.
|
||||||
|
# NULL/empty falls back to the legacy "every 7 days" behaviour.
|
||||||
|
# monthly and the other month-stepping frequencies:
|
||||||
|
# month_mode picks which pair of columns applies —
|
||||||
|
# MONTH_MODE_DAY → day_of_month; MONTH_MODE_NTH → nth_week + nth_weekday.
|
||||||
|
# NULL falls back to the legacy "same day next period" behaviour.
|
||||||
|
# Every pre-phase46 row keeps NULLs here and therefore keeps its exact
|
||||||
|
# current cadence.
|
||||||
|
weekdays = db.Column(db.String(20), nullable=True)
|
||||||
|
month_mode = db.Column(db.String(20), nullable=True)
|
||||||
|
day_of_month = db.Column(db.SmallInteger, nullable=True)
|
||||||
|
nth_week = db.Column(db.SmallInteger, nullable=True)
|
||||||
|
nth_weekday = db.Column(db.SmallInteger, nullable=True)
|
||||||
|
|
||||||
|
# ── End-date boundary (phase47) ──────────────────────────────────────────
|
||||||
|
# Fixed boundary set by the manager, never rewritten by the app — unlike
|
||||||
|
# next_run_at, which fulfill() advances after every completed inspection.
|
||||||
|
# NULL = repeat indefinitely, which is what every pre-phase47 row is. Only
|
||||||
|
# meaningful for recurring schedules; the create/edit routes force it to
|
||||||
|
# NULL when frequency == 'once'. Applies to BOTH modes: without it an auto
|
||||||
|
# schedule would keep materialising inspections past its boundary forever.
|
||||||
|
end_date = db.Column(db.Date, nullable=True)
|
||||||
|
|
||||||
|
# phase43 — plan mode bookkeeping
|
||||||
|
last_completed_at = db.Column(db.DateTime, nullable=True)
|
||||||
|
# Per-occurrence reminder de-dup flags; reset when a recurring schedule rolls forward.
|
||||||
|
advance_notified = db.Column(db.Boolean, nullable=False, default=False)
|
||||||
|
due_notified = db.Column(db.Boolean, nullable=False, default=False)
|
||||||
|
overdue_notified = db.Column(db.Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
# Relationships — explicit foreign_keys because two columns point at users.id.
|
# Relationships — explicit foreign_keys because two columns point at users.id.
|
||||||
template = db.relationship('InspectionTemplate', foreign_keys=[template_id])
|
template = db.relationship('InspectionTemplate', foreign_keys=[template_id])
|
||||||
facility = db.relationship('Facility', foreign_keys=[facility_id])
|
facility = db.relationship('Facility', foreign_keys=[facility_id])
|
||||||
area = db.relationship('Area', foreign_keys=[area_id])
|
area = db.relationship('Area', foreign_keys=[area_id])
|
||||||
inspector = db.relationship('User', foreign_keys=[inspector_id])
|
inspector = db.relationship('User', foreign_keys=[inspector_id])
|
||||||
creator = db.relationship('User', foreign_keys=[created_by])
|
creator = db.relationship('User', foreign_keys=[created_by])
|
||||||
|
# phase48. Explicit foreign_keys is required, not optional: inspections and
|
||||||
|
# inspection_schedules now reference each other (Inspection
|
||||||
|
# .inspection_schedule_id points here, parent_inspection_id points back), so
|
||||||
|
# SQLAlchemy cannot infer the join for either side.
|
||||||
|
parent_inspection = db.relationship('Inspection',
|
||||||
|
foreign_keys=[parent_inspection_id])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_follow_up(self):
|
||||||
|
"""True when this schedule was created to follow up an inspection."""
|
||||||
|
return self.parent_inspection_id is not None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_acknowledged(self):
|
||||||
|
"""True once the assigned inspector has confirmed receipt (phase50)."""
|
||||||
|
return self.acknowledged_at is not None
|
||||||
|
|
||||||
|
FREQUENCY_LABELS = {
|
||||||
|
'once': 'One-time',
|
||||||
|
'daily': 'Daily',
|
||||||
|
'weekly': 'Weekly',
|
||||||
|
'monthly': 'Monthly',
|
||||||
|
'quarterly': 'Quarterly',
|
||||||
|
'bi-annually': 'Every 6 months',
|
||||||
|
'annually': 'Annually',
|
||||||
|
}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def frequency_label(self):
|
||||||
|
return self.FREQUENCY_LABELS.get(self.frequency, self.frequency)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def due_date(self):
|
||||||
|
"""The due date (date part of next_run_at), or None."""
|
||||||
|
return self.next_run_at.date() if self.next_run_at else None
|
||||||
|
|
||||||
|
def is_overdue(self, today=None):
|
||||||
|
"""True when an active schedule's due date has passed."""
|
||||||
|
if not self.active or self.next_run_at is None:
|
||||||
|
return False
|
||||||
|
today = today or now_eastern().date()
|
||||||
|
return self.next_run_at.date() < today
|
||||||
|
|
||||||
|
# ── Recurrence accessors (phase46) ───────────────────────────────────────
|
||||||
|
|
||||||
|
@property
|
||||||
|
def weekday_list(self):
|
||||||
|
"""Selected weekdays as a sorted list of ints (Mon=0). [] if unset."""
|
||||||
|
if not self.weekdays:
|
||||||
|
return []
|
||||||
|
out = set()
|
||||||
|
for part in str(self.weekdays).split(','):
|
||||||
|
part = part.strip()
|
||||||
|
if part.lstrip('-').isdigit() and 0 <= int(part) <= 6:
|
||||||
|
out.add(int(part))
|
||||||
|
return sorted(out)
|
||||||
|
|
||||||
|
def set_weekdays(self, values):
|
||||||
|
"""Store an iterable of weekday ints as the CSV column (None if empty)."""
|
||||||
|
clean = sorted({int(v) for v in (values or []) if 0 <= int(v) <= 6})
|
||||||
|
self.weekdays = ','.join(str(v) for v in clean) or None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def recurrence_label(self):
|
||||||
|
"""Human summary of the recurrence rule, e.g. 'Weekly · Mon, Wed, Fri'."""
|
||||||
|
base = self.frequency_label
|
||||||
|
if self.frequency == 'weekly':
|
||||||
|
days = self.weekday_list
|
||||||
|
if days:
|
||||||
|
return f"{base} · {', '.join(WEEKDAY_ABBREV[d] for d in days)}"
|
||||||
|
elif self.frequency in _MONTH_STEPS:
|
||||||
|
if self.month_mode == MONTH_MODE_NTH and self.nth_week and self.nth_weekday is not None:
|
||||||
|
nth = NTH_WEEK_LABELS.get(self.nth_week, str(self.nth_week))
|
||||||
|
return f'{base} · {nth} {WEEKDAY_NAMES[self.nth_weekday]}'
|
||||||
|
if self.day_of_month:
|
||||||
|
return f'{base} · day {self.day_of_month}'
|
||||||
|
return base
|
||||||
|
|
||||||
|
# ── Date arithmetic (phase46) ────────────────────────────────────────────
|
||||||
|
|
||||||
|
@property
|
||||||
|
def run_time(self):
|
||||||
|
"""Time-of-day this schedule is due at.
|
||||||
|
|
||||||
|
Preserved across roll-forwards so an auto schedule keeps materialising
|
||||||
|
at the hour it always has. Falls back to DEFAULT_RUN_HOUR for a schedule
|
||||||
|
that has no next_run_at yet.
|
||||||
|
"""
|
||||||
|
if self.next_run_at is not None:
|
||||||
|
return self.next_run_at.time()
|
||||||
|
return time(hour=DEFAULT_RUN_HOUR)
|
||||||
|
|
||||||
|
def set_next_run_date(self, d):
|
||||||
|
"""Set next_run_at to date *d* keeping the current time-of-day."""
|
||||||
|
if d is None:
|
||||||
|
self.next_run_at = None
|
||||||
|
else:
|
||||||
|
self.next_run_at = datetime.combine(d, self.run_time)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _add_interval(d, frequency):
|
||||||
|
"""Return date *d* advanced by one plain interval of *frequency*.
|
||||||
|
|
||||||
|
Fallback used when no day-of-week / day-of-month detail is configured
|
||||||
|
(every pre-phase46 row). Prefer :meth:`next_occurrence_after`.
|
||||||
|
"""
|
||||||
|
if frequency == 'daily':
|
||||||
|
return d + timedelta(days=1)
|
||||||
|
if frequency == 'weekly':
|
||||||
|
return d + timedelta(weeks=1)
|
||||||
|
step = _MONTH_STEPS.get(frequency)
|
||||||
|
if step:
|
||||||
|
year, month = _shift_month(d.year, d.month, step)
|
||||||
|
return date(year, month, min(d.day, _last_day_of(year, month)))
|
||||||
|
return d # 'once' has no next interval
|
||||||
|
|
||||||
|
def next_occurrence_after(self, d):
|
||||||
|
"""First occurrence strictly after date *d*, honouring the day rules."""
|
||||||
|
if self.frequency == 'weekly':
|
||||||
|
days = self.weekday_list
|
||||||
|
if days:
|
||||||
|
for step in range(1, 8):
|
||||||
|
cand = d + timedelta(days=step)
|
||||||
|
if cand.weekday() in days:
|
||||||
|
return cand
|
||||||
|
elif self.frequency in _MONTH_STEPS:
|
||||||
|
year, month = _shift_month(d.year, d.month, _MONTH_STEPS[self.frequency])
|
||||||
|
if self.month_mode == MONTH_MODE_NTH and self.nth_week and self.nth_weekday is not None:
|
||||||
|
return _nth_weekday_of(year, month, self.nth_weekday, self.nth_week)
|
||||||
|
if self.day_of_month:
|
||||||
|
return date(year, month, min(self.day_of_month, _last_day_of(year, month)))
|
||||||
|
return self._add_interval(d, self.frequency)
|
||||||
|
|
||||||
|
def align_due_date(self, d):
|
||||||
|
"""Snap *d* forward to the first date on/after it that fits the rule.
|
||||||
|
|
||||||
|
Lets a manager pick any start date and still get, say, Mon/Wed/Fri:
|
||||||
|
picking a Tuesday for a Mon/Wed/Fri schedule yields that Wednesday.
|
||||||
|
"""
|
||||||
|
if self.frequency == 'weekly':
|
||||||
|
days = self.weekday_list
|
||||||
|
if days:
|
||||||
|
for step in range(0, 7):
|
||||||
|
cand = d + timedelta(days=step)
|
||||||
|
if cand.weekday() in days:
|
||||||
|
return cand
|
||||||
|
elif self.frequency in _MONTH_STEPS:
|
||||||
|
if self.month_mode == MONTH_MODE_NTH and self.nth_week and self.nth_weekday is not None:
|
||||||
|
cand = _nth_weekday_of(d.year, d.month, self.nth_weekday, self.nth_week)
|
||||||
|
elif self.day_of_month:
|
||||||
|
cand = date(d.year, d.month,
|
||||||
|
min(self.day_of_month, _last_day_of(d.year, d.month)))
|
||||||
|
else:
|
||||||
|
return d
|
||||||
|
if cand < d:
|
||||||
|
return self.next_occurrence_after(cand)
|
||||||
|
return cand
|
||||||
|
return d
|
||||||
|
|
||||||
|
# ── End-date boundary (phase47) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
def is_within_end_date(self, d):
|
||||||
|
"""True if date *d* is on or before the end date (inclusive).
|
||||||
|
|
||||||
|
No end date means the schedule repeats indefinitely, so every date
|
||||||
|
qualifies.
|
||||||
|
"""
|
||||||
|
return self.end_date is None or d <= self.end_date
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_expired(self):
|
||||||
|
"""True once the end date has passed.
|
||||||
|
|
||||||
|
Independent of `active`: a schedule can be inactive because it expired
|
||||||
|
or because a manager paused it, and the list view distinguishes the two.
|
||||||
|
Compares against the *end date* rather than next_run_at, which may have
|
||||||
|
been advanced past the boundary by fulfill().
|
||||||
|
"""
|
||||||
|
if self.end_date is None:
|
||||||
|
return False
|
||||||
|
return self.end_date < now_eastern().date()
|
||||||
|
|
||||||
|
def expire_if_past_end_date(self, today=None):
|
||||||
|
"""Deactivate a schedule whose end date has passed. Caller commits.
|
||||||
|
|
||||||
|
Returns True if this call changed anything. Needed because a schedule
|
||||||
|
can reach its end date *without ever being completed* — fulfill() never
|
||||||
|
runs, so the boundary would otherwise be checked nowhere and the cron
|
||||||
|
would keep materialising (auto) or re-alerting as overdue (plan)
|
||||||
|
forever. Called from the /run cron endpoint.
|
||||||
|
"""
|
||||||
|
today = today or now_eastern().date()
|
||||||
|
if self.active and self.end_date is not None and self.end_date < today:
|
||||||
|
self.active = False
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ── Roll-forward ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def advance_due_date(self, now=None):
|
||||||
|
"""Move next_run_at to the first occurrence after today. Caller commits.
|
||||||
|
|
||||||
|
Shared by fulfill() (plan mode, the inspector submitted the inspection)
|
||||||
|
and the cron materialiser (auto mode, an occurrence was produced), so
|
||||||
|
both modes obey the same recurrence rules and the same end-date
|
||||||
|
boundary. A 'once' schedule deactivates and its due date is left where
|
||||||
|
it is. Returns the new due date, or None for 'once'.
|
||||||
|
|
||||||
|
Advances from the current due date rather than from now, so a Mon/Wed/Fri
|
||||||
|
schedule dealt with late stays on Mon/Wed/Fri.
|
||||||
|
"""
|
||||||
|
now = now or now_eastern()
|
||||||
|
if self.frequency == 'once':
|
||||||
|
self.active = False
|
||||||
|
return None
|
||||||
|
|
||||||
|
today = now.date()
|
||||||
|
base = self.due_date or today
|
||||||
|
nxt = self.next_occurrence_after(base)
|
||||||
|
guard = 0
|
||||||
|
# Guard stops a misconfigured row spinning; 400 covers a daily schedule
|
||||||
|
# left untouched for over a year.
|
||||||
|
while nxt <= today and guard < 400:
|
||||||
|
nxt = self.next_occurrence_after(nxt)
|
||||||
|
guard += 1
|
||||||
|
self.set_next_run_date(nxt)
|
||||||
|
|
||||||
|
# Past the manager's boundary: this was the last occurrence. next_run_at
|
||||||
|
# is left at the computed value rather than clamped, so the row still
|
||||||
|
# shows which occurrence it stopped before.
|
||||||
|
if not self.is_within_end_date(nxt):
|
||||||
|
self.active = False
|
||||||
|
return nxt
|
||||||
|
|
||||||
|
def fulfill(self, next_run_fn=None):
|
||||||
|
"""Mark this occurrence complete. Caller commits.
|
||||||
|
|
||||||
|
One-time schedules deactivate. Recurring ones roll their due date
|
||||||
|
forward past today, honouring the phase46 day rules, and reset the
|
||||||
|
reminder flags. A recurring schedule whose next occurrence would fall
|
||||||
|
past its end date deactivates instead (phase47).
|
||||||
|
|
||||||
|
`next_run_fn` is accepted and IGNORED, retained only so the existing
|
||||||
|
`fulfill(next_run_fn=_compute_next_run)` call sites in
|
||||||
|
routes/inspections.py and api/inspections.py keep working unchanged.
|
||||||
|
Before phase46 the cadence maths lived in the route and omitting this
|
||||||
|
argument silently left next_run_at untouched — leaving the schedule
|
||||||
|
perpetually due. The maths now lives here, on the object that owns the
|
||||||
|
recurrence columns, so that failure mode is unreachable.
|
||||||
|
"""
|
||||||
|
now = now_eastern()
|
||||||
|
self.last_completed_at = now
|
||||||
|
|
||||||
|
self.advance_due_date(now)
|
||||||
|
if not self.active:
|
||||||
|
# 'once', or the roll-forward crossed the end date. Either way this
|
||||||
|
# was the last occurrence — leave the reminder flags set so nothing
|
||||||
|
# re-fires against a closed schedule.
|
||||||
|
return
|
||||||
|
|
||||||
|
self.advance_notified = False
|
||||||
|
self.due_notified = False
|
||||||
|
self.overdue_notified = False
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f'<InspectionSchedule {self.id} {self.name!r} {self.frequency}>'
|
return (f'<InspectionSchedule {self.id} {self.name!r} '
|
||||||
|
f'{self.frequency} mode={self.mode}>')
|
||||||
|
|||||||
+179
-9
@@ -43,6 +43,122 @@ class IssueFollower(db.Model):
|
|||||||
return f'<IssueFollower issue={self.issue_id} user={self.user_id}>'
|
return f'<IssueFollower issue={self.issue_id} user={self.user_id}>'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ── Issue Link ────────────────────────────────────────────────────────────────
|
||||||
|
# Connects two issues so staff can jump between a duplicate and the original, or
|
||||||
|
# between issues that are simply about the same thing.
|
||||||
|
|
||||||
|
class IssueLink(db.Model):
|
||||||
|
"""One directed link between two issues, displayed on BOTH of them.
|
||||||
|
|
||||||
|
Only one row is stored per pair. The stored direction carries meaning for
|
||||||
|
'duplicate' — issue_id is a duplicate OF linked_issue_id — so the two issues
|
||||||
|
read the same row differently:
|
||||||
|
|
||||||
|
on issue_id -> "Duplicate of #B"
|
||||||
|
on linked_issue_id -> "Duplicated by #A"
|
||||||
|
|
||||||
|
'related' is symmetric and reads "Related to" from either side.
|
||||||
|
|
||||||
|
Storing one row rather than a mirrored pair is what keeps the direction
|
||||||
|
unambiguous and makes unlinking a single delete. The cost is that uniqueness
|
||||||
|
cannot be expressed by the UniqueConstraint alone: (A,B) and (B,A) are
|
||||||
|
distinct rows to the database but the same link to a person, so the
|
||||||
|
duplicate check has to look in both directions. exists_between() is that
|
||||||
|
check, and it is the only thing callers should use.
|
||||||
|
|
||||||
|
A link is PURELY NAVIGATIONAL. Marking a duplicate does not touch either
|
||||||
|
issue's status, SLA, assignee or followers — closing the duplicate stays a
|
||||||
|
deliberate, separate action.
|
||||||
|
|
||||||
|
Multi-tenant: nothing here is tenant-aware, and deliberately so. The table
|
||||||
|
lives in the tenant database and every query routes through RoutingSession,
|
||||||
|
so a link can only ever reach an issue in the same tenant. Scope WITHIN a
|
||||||
|
tenant is the caller's job — see _readable_links() in routes/issues.py.
|
||||||
|
"""
|
||||||
|
__tablename__ = 'issue_links'
|
||||||
|
|
||||||
|
TYPE_DUPLICATE = 'duplicate'
|
||||||
|
TYPE_RELATED = 'related'
|
||||||
|
|
||||||
|
# How each link type reads from the two sides, keyed by (type, is_source).
|
||||||
|
LABELS = {
|
||||||
|
('duplicate', True): 'Duplicate of',
|
||||||
|
('duplicate', False): 'Duplicated by',
|
||||||
|
('related', True): 'Related to',
|
||||||
|
('related', False): 'Related to',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Offered in the "Link an issue" picker. The value is what gets stored; the
|
||||||
|
# phrasing is from the point of view of the issue being viewed.
|
||||||
|
TYPE_CHOICES = [
|
||||||
|
('duplicate', 'Duplicate of'),
|
||||||
|
('related', 'Related to'),
|
||||||
|
]
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
issue_id = db.Column(db.Integer,
|
||||||
|
db.ForeignKey('issues.id', ondelete='CASCADE'),
|
||||||
|
nullable=False, index=True)
|
||||||
|
linked_issue_id = db.Column(db.Integer,
|
||||||
|
db.ForeignKey('issues.id', ondelete='CASCADE'),
|
||||||
|
nullable=False, index=True)
|
||||||
|
link_type = db.Column(db.Enum('duplicate', 'related'),
|
||||||
|
nullable=False, default='related')
|
||||||
|
created_by = db.Column(db.Integer,
|
||||||
|
db.ForeignKey('users.id', ondelete='SET NULL'),
|
||||||
|
nullable=True)
|
||||||
|
created_at = db.Column(db.DateTime, default=now_eastern, nullable=False)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
# Catches the exact-duplicate row at the database level. The REVERSE
|
||||||
|
# direction is caught by exists_between() — see the class docstring.
|
||||||
|
db.UniqueConstraint('issue_id', 'linked_issue_id', name='uq_issue_link'),
|
||||||
|
)
|
||||||
|
|
||||||
|
# BOTH relationships must pin foreign_keys: two FKs from this table to
|
||||||
|
# issues leave the join condition ambiguous otherwise, and the mapper raises
|
||||||
|
# on first ORM USE rather than at import — the app starts cleanly and then
|
||||||
|
# every request 500s (the phase56 lesson, CLAUDE.md §17).
|
||||||
|
issue = db.relationship('Issue', foreign_keys=[issue_id],
|
||||||
|
back_populates='links_from')
|
||||||
|
linked_issue = db.relationship('Issue', foreign_keys=[linked_issue_id],
|
||||||
|
back_populates='links_to')
|
||||||
|
creator = db.relationship('User', foreign_keys=[created_by])
|
||||||
|
|
||||||
|
def label_for(self, viewing_issue_id):
|
||||||
|
"""How this link reads on the issue currently being viewed."""
|
||||||
|
return self.LABELS[(self.link_type, self.issue_id == viewing_issue_id)]
|
||||||
|
|
||||||
|
def other_issue(self, viewing_issue_id):
|
||||||
|
"""The issue at the far end of this link from the one being viewed."""
|
||||||
|
return (self.linked_issue if self.issue_id == viewing_issue_id
|
||||||
|
else self.issue)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def exists_between(issue_id, other_id):
|
||||||
|
"""True when the two issues are already linked, in EITHER direction.
|
||||||
|
|
||||||
|
The UniqueConstraint only covers the stored direction, so this is what
|
||||||
|
stops #A being linked to #B and then #B linked back to #A as a second,
|
||||||
|
contradictory row.
|
||||||
|
"""
|
||||||
|
return db.session.query(
|
||||||
|
IssueLink.query.filter(
|
||||||
|
db.or_(
|
||||||
|
db.and_(IssueLink.issue_id == issue_id,
|
||||||
|
IssueLink.linked_issue_id == other_id),
|
||||||
|
db.and_(IssueLink.issue_id == other_id,
|
||||||
|
IssueLink.linked_issue_id == issue_id),
|
||||||
|
)
|
||||||
|
).exists()
|
||||||
|
).scalar()
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return (f'<IssueLink {self.issue_id} {self.link_type} '
|
||||||
|
f'{self.linked_issue_id}>')
|
||||||
|
|
||||||
|
|
||||||
class Issue(db.Model):
|
class Issue(db.Model):
|
||||||
__tablename__ = 'issues'
|
__tablename__ = 'issues'
|
||||||
|
|
||||||
@@ -85,18 +201,24 @@ class Issue(db.Model):
|
|||||||
vendor_notes = db.Column(db.Text, nullable=True)
|
vendor_notes = db.Column(db.Text, nullable=True)
|
||||||
|
|
||||||
# Handler type — who is responsible for resolving the issue (phase39).
|
# Handler type — who is responsible for resolving the issue (phase39).
|
||||||
# NULL and 'internal' both mean janitorial staff (the default); 'facility'
|
# 'internal' means janitorial staff (the default); 'facility' unlocks the
|
||||||
# unlocks the facility_handler_* sub-fields; 'vendor' points to vendor_*.
|
# facility_handler_* sub-fields; 'vendor' points to vendor_*.
|
||||||
handler_type = db.Column(db.Enum('internal', 'facility', 'vendor'), nullable=True)
|
# NOT NULL DEFAULT 'internal' since phase44 — previously nullable, with NULL
|
||||||
|
# treated as a synonym for 'internal'. Existing NULLs were backfilled by that
|
||||||
|
# migration, so the two representations are now one.
|
||||||
|
handler_type = db.Column(
|
||||||
|
db.Enum('internal', 'facility', 'vendor'),
|
||||||
|
nullable=False, default='internal',
|
||||||
|
)
|
||||||
facility_handler_name = db.Column(db.String(100), nullable=True)
|
facility_handler_name = db.Column(db.String(100), nullable=True)
|
||||||
facility_handler_contact = db.Column(db.String(200), nullable=True)
|
facility_handler_contact = db.Column(db.String(200), nullable=True)
|
||||||
facility_handler_notes = db.Column(db.Text, nullable=True)
|
facility_handler_notes = db.Column(db.Text, nullable=True)
|
||||||
|
# Free-text name of the janitorial staff member who will handle the issue,
|
||||||
HANDLER_LABELS = {
|
# used when handler_type == 'internal'. Distinct from assigned_to (the JQC
|
||||||
'internal': 'Janitorial Staff',
|
# User who owns follow-up): the actual crew member may not be a system user.
|
||||||
'facility': 'Facility Staff',
|
# (phase44)
|
||||||
'vendor': 'External Vendor',
|
internal_handler_name = db.Column(db.String(100), nullable=True)
|
||||||
}
|
internal_handler_contact = db.Column(db.String(200), nullable=True) # phone or email
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
# NOTE: Issue.area is provided by the backref on Area.issues (facility.py).
|
# NOTE: Issue.area is provided by the backref on Area.issues (facility.py).
|
||||||
@@ -112,10 +234,58 @@ class Issue(db.Model):
|
|||||||
followers = db.relationship('IssueFollower', back_populates='issue',
|
followers = db.relationship('IssueFollower', back_populates='issue',
|
||||||
cascade='all, delete-orphan', lazy='dynamic')
|
cascade='all, delete-orphan', lazy='dynamic')
|
||||||
|
|
||||||
|
# An issue link is stored once and shown on both issues, so each issue has
|
||||||
|
# rows pointing OUT of it and rows pointing AT it. Deleting an issue must
|
||||||
|
# take its links with it from BOTH sides, or the surviving issue keeps a row
|
||||||
|
# referencing one that no longer exists.
|
||||||
|
links_from = db.relationship('IssueLink', back_populates='issue',
|
||||||
|
foreign_keys='IssueLink.issue_id',
|
||||||
|
cascade='all, delete-orphan', lazy='dynamic')
|
||||||
|
links_to = db.relationship('IssueLink', back_populates='linked_issue',
|
||||||
|
foreign_keys='IssueLink.linked_issue_id',
|
||||||
|
cascade='all, delete-orphan', lazy='dynamic')
|
||||||
|
|
||||||
def is_followed_by(self, user):
|
def is_followed_by(self, user):
|
||||||
"""Return True if the given user is currently following this issue."""
|
"""Return True if the given user is currently following this issue."""
|
||||||
return self.followers.filter_by(user_id=user.id).first() is not None
|
return self.followers.filter_by(user_id=user.id).first() is not None
|
||||||
|
|
||||||
|
def all_links(self):
|
||||||
|
"""Every link touching this issue, from both directions, newest first.
|
||||||
|
|
||||||
|
The two relationships are a storage detail — a link is one thing to the
|
||||||
|
person reading it, so callers get a single list and ask each row how it
|
||||||
|
reads via label_for() / other_issue().
|
||||||
|
|
||||||
|
Nothing here filters by permission. The caller MUST drop links whose far
|
||||||
|
end the viewer cannot access, or a link becomes a way to read an issue
|
||||||
|
at a facility they hold no assignment to. See _readable_links() in
|
||||||
|
routes/issues.py.
|
||||||
|
"""
|
||||||
|
links = list(self.links_from) + list(self.links_to)
|
||||||
|
links.sort(key=lambda link: link.created_at, reverse=True)
|
||||||
|
return links
|
||||||
|
|
||||||
|
# Display labels for handler_type. The web templates hardcode these inline;
|
||||||
|
# this mapping exists so the mobile API can return a human-readable label
|
||||||
|
# without the client duplicating the strings. (phase43)
|
||||||
|
HANDLER_LABELS = {
|
||||||
|
'internal': 'Janitorial Staff',
|
||||||
|
'facility': 'Facility Staff',
|
||||||
|
'vendor': 'External Vendor',
|
||||||
|
}
|
||||||
|
# One-line explanation per handler type, shown under the radio options on the
|
||||||
|
# issue form so staff pick the right one. (phase44)
|
||||||
|
HANDLER_DESCRIPTIONS = {
|
||||||
|
'internal': 'Our janitorial crew handles it.',
|
||||||
|
'facility': "The facility's own on-site staff handle it.",
|
||||||
|
'vendor': 'An outside contractor handles it.',
|
||||||
|
}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def handler_label(self):
|
||||||
|
"""Human-readable label for handler_type; defaults to internal."""
|
||||||
|
return self.HANDLER_LABELS.get(self.handler_type or 'internal', 'Janitorial Staff')
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def resolved_facility(self):
|
def resolved_facility(self):
|
||||||
"""Returns the Facility for this issue regardless of which path was used to create it.
|
"""Returns the Facility for this issue regardless of which path was used to create it.
|
||||||
|
|||||||
@@ -37,6 +37,12 @@ EVENT_INSPECTION_SCHEDULED = 'inspection_scheduled'
|
|||||||
# order via the tokenized public link (phase36).
|
# order via the tokenized public link (phase36).
|
||||||
EVENT_WORK_ORDER = 'work_order_update'
|
EVENT_WORK_ORDER = 'work_order_update'
|
||||||
|
|
||||||
|
# Fired when a follow-up re-inspection is requested — by a manager, or (phase49)
|
||||||
|
# by a customer against their own facility. Routed through notify_by_matrix so
|
||||||
|
# recipients stay admin-configurable; the inspection's own inspector is notified
|
||||||
|
# directly by the route rather than through the matrix.
|
||||||
|
EVENT_FOLLOWUP_REQUESTED = 'followup_requested'
|
||||||
|
|
||||||
ALL_EVENT_TYPES = {
|
ALL_EVENT_TYPES = {
|
||||||
EVENT_ISSUE_ASSIGNED: 'Issue assigned to me',
|
EVENT_ISSUE_ASSIGNED: 'Issue assigned to me',
|
||||||
EVENT_ISSUE_STATUS: 'Issue status changed',
|
EVENT_ISSUE_STATUS: 'Issue status changed',
|
||||||
@@ -48,6 +54,7 @@ ALL_EVENT_TYPES = {
|
|||||||
EVENT_ADMIN_BROADCAST: 'Admin broadcast (system announcements)',
|
EVENT_ADMIN_BROADCAST: 'Admin broadcast (system announcements)',
|
||||||
EVENT_INSPECTION_SCHEDULED: 'Scheduled inspection due (assigned to me)',
|
EVENT_INSPECTION_SCHEDULED: 'Scheduled inspection due (assigned to me)',
|
||||||
EVENT_WORK_ORDER: 'Contractor updated a work order',
|
EVENT_WORK_ORDER: 'Contractor updated a work order',
|
||||||
|
EVENT_FOLLOWUP_REQUESTED: 'Follow-up re-inspection requested',
|
||||||
# Customer-facing — only relevant for customer role accounts
|
# Customer-facing — only relevant for customer role accounts
|
||||||
EVENT_CUSTOMER_INSPECTION_DONE: 'Inspection completed at my facility (portal)',
|
EVENT_CUSTOMER_INSPECTION_DONE: 'Inspection completed at my facility (portal)',
|
||||||
EVENT_CUSTOMER_ISSUE_UPDATED: 'Issue created or updated at my facility (portal)',
|
EVENT_CUSTOMER_ISSUE_UPDATED: 'Issue created or updated at my facility (portal)',
|
||||||
|
|||||||
@@ -10,6 +10,14 @@ role_key values
|
|||||||
admin — all users with role='admin'
|
admin — all users with role='admin'
|
||||||
director — all users with role='director'
|
director — all users with role='director'
|
||||||
inspector — all users with role='inspector'
|
inspector — all users with role='inspector'
|
||||||
|
EXCEPTION: for event 'inspection_completed', the inspector
|
||||||
|
column notifies ONLY the inspection's own inspector
|
||||||
|
(the submitter), not the whole inspector pool. Scoping is
|
||||||
|
applied in notify_by_matrix() via the inspection_id.
|
||||||
|
external_inspector — all users with role='external_inspector' (customer /
|
||||||
|
third-party inspectors). Separate column so third parties
|
||||||
|
can be routed differently from the tenant's own crew; the
|
||||||
|
'inspection_completed' scoping above applies here too.
|
||||||
project_manager — all users with role='project_manager'
|
project_manager — all users with role='project_manager'
|
||||||
customer — all customer-portal users assigned to the relevant facility
|
customer — all customer-portal users assigned to the relevant facility
|
||||||
assignee — the specific user the issue/inspection is assigned to
|
assignee — the specific user the issue/inspection is assigned to
|
||||||
@@ -27,6 +35,7 @@ issue_flagged : admin ✓ director ✓ inspector ✗ pm ✗ cust
|
|||||||
issue_created : admin ✗ director ✗ inspector ✗ pm ✗ customer ✓ (assignee implicit)
|
issue_created : admin ✗ director ✗ inspector ✗ pm ✗ customer ✓ (assignee implicit)
|
||||||
issue_updated_customer : admin ✗ director ✗ inspector ✗ pm ✗ customer ✓
|
issue_updated_customer : admin ✗ director ✗ inspector ✗ pm ✗ customer ✓
|
||||||
verification_requested : admin ✓ director ✓ inspector ✗ pm ✗ customer ✗
|
verification_requested : admin ✓ director ✓ inspector ✗ pm ✗ customer ✗
|
||||||
|
followup_requested : admin ✓ director ✓ inspector ✗ pm ✓ customer ✗ (inspection's own inspector implicit)
|
||||||
sla_alert : admin ✓ director ✗ inspector ✗ pm ✗ customer ✗ (assignee + followers implicit)
|
sla_alert : admin ✓ director ✗ inspector ✗ pm ✗ customer ✗ (assignee + followers implicit)
|
||||||
score_alert : admin ✓ director ✓ inspector ✗ pm ✗ customer ✗ (facility score drop cron)
|
score_alert : admin ✓ director ✓ inspector ✗ pm ✗ customer ✗ (facility score drop cron)
|
||||||
"""
|
"""
|
||||||
@@ -35,12 +44,17 @@ import json
|
|||||||
from app import db
|
from app import db
|
||||||
|
|
||||||
# Role keys available in the matrix UI
|
# Role keys available in the matrix UI
|
||||||
|
# NOTE: the two customer-side labels are a display rename only (phase51) — the
|
||||||
|
# role_key values stored in notification_matrix.role_key are unchanged, so no
|
||||||
|
# data migration was needed. See User.CUSTOMER_ROLES.
|
||||||
MATRIX_ROLES = [
|
MATRIX_ROLES = [
|
||||||
('admin', 'Admin'),
|
('admin', 'Admin'),
|
||||||
('director', 'Director'),
|
('director', 'Director'),
|
||||||
('inspector', 'Inspector'),
|
('inspector', 'Inspector'),
|
||||||
|
('external_inspector', 'Customer Inspector'),
|
||||||
('project_manager', 'Project Manager'),
|
('project_manager', 'Project Manager'),
|
||||||
('customer', 'Customer'),
|
('auditor', 'Auditor'),
|
||||||
|
('customer', 'Customer Director'),
|
||||||
('custom', 'Custom Recipients'),
|
('custom', 'Custom Recipients'),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -58,6 +72,7 @@ MATRIX_EVENTS = {
|
|||||||
'issue_created': 'Issue created (standalone)',
|
'issue_created': 'Issue created (standalone)',
|
||||||
'issue_updated_customer': 'Issue updated (customer)',
|
'issue_updated_customer': 'Issue updated (customer)',
|
||||||
'verification_requested': 'Verification requested',
|
'verification_requested': 'Verification requested',
|
||||||
|
'followup_requested': 'Follow-up requested (incl. by customer)',
|
||||||
'sla_alert': 'SLA at-risk / breached',
|
'sla_alert': 'SLA at-risk / breached',
|
||||||
'score_alert': 'Facility score trend alert (significant drop)',
|
'score_alert': 'Facility score trend alert (significant drop)',
|
||||||
}
|
}
|
||||||
@@ -142,6 +157,16 @@ MATRIX_DEFAULTS = {
|
|||||||
('verification_requested', 'project_manager'): False,
|
('verification_requested', 'project_manager'): False,
|
||||||
('verification_requested', 'customer'): False,
|
('verification_requested', 'customer'): False,
|
||||||
('verification_requested', 'custom'): False,
|
('verification_requested', 'custom'): False,
|
||||||
|
# followup_requested (phase49) — a customer (or manager) asks for a
|
||||||
|
# re-inspection. On for the roles who action it; the inspection's own
|
||||||
|
# inspector is notified directly by the route, so the inspector column stays
|
||||||
|
# off to avoid alerting the whole inspector pool.
|
||||||
|
('followup_requested', 'admin'): True,
|
||||||
|
('followup_requested', 'director'): True,
|
||||||
|
('followup_requested', 'inspector'): False,
|
||||||
|
('followup_requested', 'project_manager'): True,
|
||||||
|
('followup_requested', 'customer'): False,
|
||||||
|
('followup_requested', 'custom'): False,
|
||||||
# sla_alert (assignee + followers always notified implicitly)
|
# sla_alert (assignee + followers always notified implicitly)
|
||||||
('sla_alert', 'admin'): True,
|
('sla_alert', 'admin'): True,
|
||||||
('sla_alert', 'director'): False,
|
('sla_alert', 'director'): False,
|
||||||
@@ -158,6 +183,18 @@ MATRIX_DEFAULTS = {
|
|||||||
('score_alert', 'custom'): False,
|
('score_alert', 'custom'): False,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# MT-15 — the External Inspector column defaults to whatever the internal
|
||||||
|
# Inspector column defaults to, for every event. Mirroring rather than listing
|
||||||
|
# 14 more literals means a future event added for 'inspector' automatically
|
||||||
|
# gets a matching external default instead of silently falling back to the
|
||||||
|
# is_enabled() fallback. Admins can diverge the two columns in the UI at any
|
||||||
|
# time; this only seeds rows that do not exist yet.
|
||||||
|
MATRIX_DEFAULTS.update({
|
||||||
|
(_event, 'external_inspector'): _enabled
|
||||||
|
for (_event, _role), _enabled in list(MATRIX_DEFAULTS.items())
|
||||||
|
if _role == 'inspector'
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
class NotificationMatrix(db.Model):
|
class NotificationMatrix(db.Model):
|
||||||
"""Admin-controlled per-event notification routing."""
|
"""Admin-controlled per-event notification routing."""
|
||||||
|
|||||||
@@ -18,6 +18,29 @@ class SupportChatSession(db.Model):
|
|||||||
order_by='SupportChatMessage.created_at',
|
order_by='SupportChatMessage.created_at',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def message_count(self):
|
||||||
|
"""Number of turns in this session.
|
||||||
|
|
||||||
|
`messages` is a plain list relationship here (ST's is lazy='dynamic'),
|
||||||
|
so this is len() rather than .count(). It is already loaded whenever the
|
||||||
|
session is, so this costs no extra query.
|
||||||
|
"""
|
||||||
|
return len(self.messages)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def preview(self):
|
||||||
|
"""First user message, for list views.
|
||||||
|
|
||||||
|
Scans the loaded list instead of ST's .filter_by(role='user').first(),
|
||||||
|
for the same reason. `messages` is ordered by created_at, so the first
|
||||||
|
match is the opening question.
|
||||||
|
"""
|
||||||
|
for m in self.messages:
|
||||||
|
if m.role == 'user':
|
||||||
|
return m.content
|
||||||
|
return '(no messages)'
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f'<SupportChatSession {self.id}>'
|
return f'<SupportChatSession {self.id}>'
|
||||||
|
|
||||||
@@ -46,6 +69,11 @@ class SupportKnowledge(db.Model):
|
|||||||
title = db.Column(db.String(200), nullable=False)
|
title = db.Column(db.String(200), nullable=False)
|
||||||
body = db.Column(db.Text, nullable=False)
|
body = db.Column(db.Text, nullable=False)
|
||||||
active = db.Column(db.Boolean, default=True, nullable=False)
|
active = db.Column(db.Boolean, default=True, nullable=False)
|
||||||
|
# MT-19 — admin-controlled ordering. Entries are injected into the support
|
||||||
|
# chat's system prompt in this order, so a low sort_order is how an admin
|
||||||
|
# promotes the guidance the assistant should reach for first. Ties break on
|
||||||
|
# id, keeping the order stable.
|
||||||
|
sort_order = db.Column(db.Integer, nullable=False, default=0)
|
||||||
created_by = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
|
created_by = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
|
||||||
created_at = db.Column(db.DateTime, default=now_eastern, nullable=False)
|
created_at = db.Column(db.DateTime, default=now_eastern, nullable=False)
|
||||||
updated_at = db.Column(db.DateTime, default=now_eastern, nullable=False)
|
updated_at = db.Column(db.DateTime, default=now_eastern, nullable=False)
|
||||||
|
|||||||
+133
-3
@@ -3,14 +3,75 @@ from flask_login import UserMixin
|
|||||||
from werkzeug.security import generate_password_hash, check_password_hash
|
from werkzeug.security import generate_password_hash, check_password_hash
|
||||||
from app.utils.time_utils import now_eastern
|
from app.utils.time_utils import now_eastern
|
||||||
|
|
||||||
|
# Display labels for the role ENUM — the single place a role's user-facing name
|
||||||
|
# is defined.
|
||||||
|
#
|
||||||
|
# The two customer-side roles are a LABEL-ONLY rename (same idea as rule 19,
|
||||||
|
# "Project" -> "Contract"): the stored ENUM values are still 'customer' and
|
||||||
|
# 'external_inspector', so no migration and no role check anywhere had to move.
|
||||||
|
# 'customer' -> "Customer Director" (portal access, CustomerAssignment scope)
|
||||||
|
# 'external_inspector' -> "Customer Inspector" (inspector powers, InspectorAssignment scope)
|
||||||
|
ROLE_LABELS = {
|
||||||
|
'admin': 'Admin',
|
||||||
|
'director': 'Director',
|
||||||
|
'project_manager': 'Project Manager',
|
||||||
|
'auditor': 'Auditor',
|
||||||
|
'inspector': 'Inspector',
|
||||||
|
'external_inspector': 'Customer Inspector',
|
||||||
|
'customer': 'Customer Director',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@login_manager.user_loader
|
@login_manager.user_loader
|
||||||
def load_user(user_id):
|
def load_user(user_id):
|
||||||
from app import db
|
from app import db
|
||||||
return db.session.get(User, int(user_id))
|
from app.tenancy.session_binding import parse_user_id
|
||||||
|
# MT-21: the identity string is tenant-tagged in multi-tenant mode. A tag
|
||||||
|
# naming another tenant (a session or remember-me cookie replayed onto this
|
||||||
|
# host) resolves to None here rather than loading the same-numbered user out
|
||||||
|
# of whichever database happens to be bound.
|
||||||
|
uid = parse_user_id(user_id)
|
||||||
|
if uid is None:
|
||||||
|
return None
|
||||||
|
return db.session.get(User, uid)
|
||||||
|
|
||||||
class User(UserMixin, db.Model):
|
class User(UserMixin, db.Model):
|
||||||
__tablename__ = 'users'
|
__tablename__ = 'users'
|
||||||
|
|
||||||
|
# ── Inspector roles (MT-15) ───────────────────────────────────────────────
|
||||||
|
# 'external_inspector' is an inspector employed by the customer or a third
|
||||||
|
# party rather than by the tenant. It has exactly the same capabilities as
|
||||||
|
# the internal 'inspector' role and is scoped the same way — through
|
||||||
|
# InspectorAssignment rows, via get_inspector_scope().
|
||||||
|
#
|
||||||
|
# Every place that used to test `role == 'inspector'` must test membership
|
||||||
|
# of this tuple instead, or external inspectors silently fall into the
|
||||||
|
# privileged (org-wide) branch and see every contract. Use the
|
||||||
|
# `is_inspector` property below — it is an ordinary attribute, so it reads
|
||||||
|
# the same way in Python and in Jinja (`current_user.is_inspector`).
|
||||||
|
INSPECTOR_ROLES = ('inspector', 'external_inspector')
|
||||||
|
|
||||||
|
# ── Customer-side roles (phase51) ────────────────────────────────
|
||||||
|
# Accounts that belong to the CUSTOMER, not to us. Both are created,
|
||||||
|
# invited, assigned and switched from Customer Management (/customers) —
|
||||||
|
# they never appear in User Management.
|
||||||
|
# 'customer' = Customer Director — portal access, read-mostly,
|
||||||
|
# scoped by CustomerAssignment.
|
||||||
|
# 'external_inspector' = Customer Inspector — full inspector capabilities,
|
||||||
|
# scoped by InspectorAssignment (see INSPECTOR_ROLES).
|
||||||
|
#
|
||||||
|
# CAUTION — this tuple is NOT interchangeable with `role == 'customer'`.
|
||||||
|
# A Customer Inspector is an INSPECTOR everywhere it matters: portal
|
||||||
|
# read-only gates, @customer_required, get_customer_scope(), support chat
|
||||||
|
# and the customer branch of every API scope check must keep testing
|
||||||
|
# `role == 'customer'` exactly. Use CUSTOMER_ROLES / is_customer_account
|
||||||
|
# ONLY for account-management surfaces (who is listed, invited, edited,
|
||||||
|
# assigned or switched under /customers). Widening a capability check to
|
||||||
|
# this tuple hands a third-party inspector the customer portal; narrowing
|
||||||
|
# an account-management check to 'customer' strands the inspectors in a
|
||||||
|
# page that no longer manages them.
|
||||||
|
CUSTOMER_ROLES = ('customer', 'external_inspector')
|
||||||
|
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
username = db.Column(db.String(100), unique=True, nullable=False, index=True)
|
username = db.Column(db.String(100), unique=True, nullable=False, index=True)
|
||||||
full_name = db.Column(db.String(150), nullable=True)
|
full_name = db.Column(db.String(150), nullable=True)
|
||||||
@@ -19,12 +80,26 @@ class User(UserMixin, db.Model):
|
|||||||
role = db.Column(
|
role = db.Column(
|
||||||
# Phase 11 migration complete — 'supervisor' removed from both the DB
|
# Phase 11 migration complete — 'supervisor' removed from both the DB
|
||||||
# ENUM and this Python-side declaration. Director is the canonical role.
|
# ENUM and this Python-side declaration. Director is the canonical role.
|
||||||
db.Enum('admin', 'director', 'inspector', 'project_manager', 'customer'),
|
# MT-15 — 'external_inspector' added: a customer / third-party
|
||||||
|
# inspector with identical capabilities to 'inspector'.
|
||||||
|
db.Enum('admin', 'director', 'inspector', 'project_manager', 'customer',
|
||||||
|
'auditor', 'external_inspector'),
|
||||||
nullable=False
|
nullable=False
|
||||||
)
|
)
|
||||||
created_at = db.Column(db.DateTime, default=now_eastern)
|
created_at = db.Column(db.DateTime, default=now_eastern)
|
||||||
active = db.Column(db.Boolean, default=True, nullable=False)
|
active = db.Column(db.Boolean, default=True, nullable=False)
|
||||||
|
|
||||||
|
# ── Web portal design preference (MT-16) ──────────────────────────────
|
||||||
|
# 'classic' = the original top-navbar design. 'modern' = the sidebar design.
|
||||||
|
# Drives base.html's layout dispatch via the inject_ui_theme() context
|
||||||
|
# processor. Persisted per user so the choice survives logout.
|
||||||
|
#
|
||||||
|
# Defaults to 'classic' so existing tenants see no change on deploy; the
|
||||||
|
# effective fallback for accounts that never choose is config
|
||||||
|
# DEFAULT_UI_THEME, which a tenant can be provisioned with as 'modern'.
|
||||||
|
ui_theme = db.Column(db.String(16), nullable=False,
|
||||||
|
server_default='classic', default='classic')
|
||||||
|
|
||||||
# ── Customer password-setup workflow ──────────────────────────────────
|
# ── Customer password-setup workflow ──────────────────────────────────
|
||||||
# password_set: False for newly created customer accounts until they
|
# password_set: False for newly created customer accounts until they
|
||||||
# complete the set-password flow via emailed link.
|
# complete the set-password flow via emailed link.
|
||||||
@@ -43,7 +118,13 @@ class User(UserMixin, db.Model):
|
|||||||
mfa_recovery_codes = db.Column(db.JSON, nullable=True)
|
mfa_recovery_codes = db.Column(db.JSON, nullable=True)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
inspections = db.relationship('Inspection', backref='inspector', lazy='dynamic')
|
# phase49: inspections now has TWO foreign keys to users.id — inspector_id
|
||||||
|
# and follow_up_requested_by — so the join is otherwise ambiguous and every
|
||||||
|
# mapper configuration fails with AmbiguousForeignKeysError. This
|
||||||
|
# relationship means "inspections I performed": inspector_id only.
|
||||||
|
inspections = db.relationship('Inspection', backref='inspector',
|
||||||
|
lazy='dynamic',
|
||||||
|
foreign_keys='Inspection.inspector_id')
|
||||||
|
|
||||||
# ── Flask-Login integration ────────────────────────────────────────────
|
# ── Flask-Login integration ────────────────────────────────────────────
|
||||||
# Override UserMixin.is_active so that disabled accounts are rejected
|
# Override UserMixin.is_active so that disabled accounts are rejected
|
||||||
@@ -52,12 +133,61 @@ class User(UserMixin, db.Model):
|
|||||||
def is_active(self):
|
def is_active(self):
|
||||||
return self.active
|
return self.active
|
||||||
|
|
||||||
|
# MT-21: Flask-Login derives BOTH the session '_user_id' and the
|
||||||
|
# remember-me cookie payload from get_id(), and feeds both back through
|
||||||
|
# load_user(). Tagging the tenant here is therefore the single seam that
|
||||||
|
# binds every persisted identity to the tenant that issued it. Returns a
|
||||||
|
# bare id (today's format) whenever no tenant is bound.
|
||||||
|
def get_id(self):
|
||||||
|
from app.tenancy.session_binding import tag_user_id
|
||||||
|
return tag_user_id(self.id)
|
||||||
|
|
||||||
def set_password(self, password):
|
def set_password(self, password):
|
||||||
self.password_hash = generate_password_hash(password)
|
self.password_hash = generate_password_hash(password)
|
||||||
|
|
||||||
def check_password(self, password):
|
def check_password(self, password):
|
||||||
return check_password_hash(self.password_hash, password)
|
return check_password_hash(self.password_hash, password)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_inspector(self):
|
||||||
|
"""True for both the internal and the external inspector role.
|
||||||
|
|
||||||
|
Prefer this over `role == 'inspector'` for capability and scoping
|
||||||
|
checks. Use an explicit `role == 'external_inspector'` test only where
|
||||||
|
the two genuinely differ (currently: display labelling only).
|
||||||
|
"""
|
||||||
|
return self.role in self.INSPECTOR_ROLES
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_external_inspector(self):
|
||||||
|
"""True only for third-party / customer-employed inspectors.
|
||||||
|
|
||||||
|
Display name: "Customer Inspector". The attribute keeps its MT-15
|
||||||
|
name so the existing call sites stay put (the rename is a label,
|
||||||
|
never an identifier).
|
||||||
|
"""
|
||||||
|
return self.role == 'external_inspector'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_customer_account(self):
|
||||||
|
"""True for BOTH customer-side roles — an account-management question.
|
||||||
|
|
||||||
|
Answers "is this account managed under /customers?", NOT "does this
|
||||||
|
account get the customer portal". For the latter keep testing
|
||||||
|
`role == 'customer'`. See the CUSTOMER_ROLES note above.
|
||||||
|
"""
|
||||||
|
return self.role in self.CUSTOMER_ROLES
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_customer_director(self):
|
||||||
|
"""True for the portal-side customer role ('customer')."""
|
||||||
|
return self.role == 'customer'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def role_label(self):
|
||||||
|
"""Human-readable role name, used in staff-facing lists."""
|
||||||
|
return ROLE_LABELS.get(self.role, (self.role or '').replace('_', ' ').title())
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def display_name(self):
|
def display_name(self):
|
||||||
"""Return full name if set, otherwise fall back to username."""
|
"""Return full name if set, otherwise fall back to username."""
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
"""
|
||||||
|
app/models/user_notification_matrix.py
|
||||||
|
--------------------------------------
|
||||||
|
Per-account notification overrides (phase51).
|
||||||
|
|
||||||
|
The global NotificationMatrix routes an event to whole ROLES: "every Customer
|
||||||
|
Director hears about issue_created". That is the wrong grain for customers —
|
||||||
|
each customer organisation states on its enrollment form which notifications
|
||||||
|
each of its people wants, and two directors on two contracts rarely want the
|
||||||
|
same set.
|
||||||
|
|
||||||
|
This table is the per-account layer on top. One row = one account's explicit
|
||||||
|
answer for one event:
|
||||||
|
|
||||||
|
enabled=True send it to this account even if the global column is OFF
|
||||||
|
enabled=False do not send it to this account even if the global column is ON
|
||||||
|
NO ROW inherit — whatever the global matrix column says
|
||||||
|
|
||||||
|
Inheritance is the default and the safe state: an account with no rows behaves
|
||||||
|
exactly as it did before this table existed, so the feature ships without
|
||||||
|
changing routing for anyone. Setting a row back to "inherit" DELETES it rather
|
||||||
|
than storing a copy of the current global value, so a later change to the
|
||||||
|
global matrix still reaches accounts that never expressed an opinion.
|
||||||
|
|
||||||
|
Scope: consulted for the two customer-side roles only (User.CUSTOMER_ROLES).
|
||||||
|
Staff roles keep using the global matrix alone — an admin who wants fewer
|
||||||
|
emails uses NotificationPreference, which is a different question (how to
|
||||||
|
deliver, not whether to route).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
|
||||||
|
class UserNotificationMatrix(db.Model):
|
||||||
|
"""One account's override of the global matrix for one event."""
|
||||||
|
|
||||||
|
__tablename__ = 'user_notification_matrix'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
user_id = db.Column(db.Integer,
|
||||||
|
db.ForeignKey('users.id', ondelete='CASCADE'),
|
||||||
|
nullable=False, index=True)
|
||||||
|
event_type = db.Column(db.String(50), nullable=False)
|
||||||
|
enabled = db.Column(db.Boolean, nullable=False, default=True)
|
||||||
|
|
||||||
|
user = db.relationship('User', foreign_keys=[user_id],
|
||||||
|
backref=db.backref('notification_overrides',
|
||||||
|
lazy='dynamic',
|
||||||
|
cascade='all, delete-orphan'))
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
db.UniqueConstraint('user_id', 'event_type',
|
||||||
|
name='uq_user_notif_matrix_user_event'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return (f'<UserNotificationMatrix user={self.user_id} '
|
||||||
|
f'event={self.event_type} enabled={self.enabled}>')
|
||||||
|
|
||||||
|
|
||||||
|
def overrides_for_user(user_id) -> dict:
|
||||||
|
"""Return {event_type: bool} — every override this account has set."""
|
||||||
|
return {
|
||||||
|
row.event_type: row.enabled
|
||||||
|
for row in UserNotificationMatrix.query.filter_by(user_id=user_id).all()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def override_for(user_id, event_type):
|
||||||
|
"""One account's answer for one event: True, False, or None (inherit).
|
||||||
|
|
||||||
|
Used by notify() to enforce the override on EVERY delivery path, not just
|
||||||
|
matrix broadcasts. Best-effort: any failure returns None (inherit), so a
|
||||||
|
lookup problem can never silently swallow a notification.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
if not user_id or not event_type:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
row = UserNotificationMatrix.query.filter_by(
|
||||||
|
user_id=user_id, event_type=event_type).first()
|
||||||
|
return row.enabled if row is not None else None
|
||||||
|
except Exception as exc:
|
||||||
|
logging.getLogger(__name__).error(
|
||||||
|
'USER MATRIX | single override lookup failed | user=%s event=%s | %s',
|
||||||
|
user_id, event_type, exc,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def overrides_for_event(event_type) -> dict:
|
||||||
|
"""Return {user_id: bool} — every account's override for one event.
|
||||||
|
|
||||||
|
One query per dispatch rather than one per candidate recipient. The table
|
||||||
|
holds only explicitly-set rows (inherit deletes), so it stays small.
|
||||||
|
Best-effort: a failure here must never take down a notification dispatch,
|
||||||
|
so callers get an empty dict (= everyone inherits) if the query fails.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
try:
|
||||||
|
return {
|
||||||
|
row.user_id: row.enabled
|
||||||
|
for row in UserNotificationMatrix.query.filter_by(
|
||||||
|
event_type=event_type).all()
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
logging.getLogger(__name__).error(
|
||||||
|
'USER MATRIX | override lookup failed | event=%s | error=%s',
|
||||||
|
event_type, exc,
|
||||||
|
)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def set_overrides(user_id, values: dict):
|
||||||
|
"""Replace an account's overrides.
|
||||||
|
|
||||||
|
`values` maps event_type -> True / False / None, where None means inherit
|
||||||
|
(the row is deleted). Events absent from `values` are left untouched, so a
|
||||||
|
caller can update one event without resending the whole matrix.
|
||||||
|
|
||||||
|
Does NOT commit — the caller owns the transaction (same contract as
|
||||||
|
notify()). Returns the number of rows added, updated or deleted.
|
||||||
|
"""
|
||||||
|
existing = {
|
||||||
|
row.event_type: row
|
||||||
|
for row in UserNotificationMatrix.query.filter_by(user_id=user_id).all()
|
||||||
|
}
|
||||||
|
changed = 0
|
||||||
|
|
||||||
|
for event_type, wanted in values.items():
|
||||||
|
row = existing.get(event_type)
|
||||||
|
if wanted is None:
|
||||||
|
if row is not None:
|
||||||
|
db.session.delete(row)
|
||||||
|
changed += 1
|
||||||
|
continue
|
||||||
|
wanted = bool(wanted)
|
||||||
|
if row is None:
|
||||||
|
db.session.add(UserNotificationMatrix(
|
||||||
|
user_id=user_id, event_type=event_type, enabled=wanted))
|
||||||
|
changed += 1
|
||||||
|
elif row.enabled != wanted:
|
||||||
|
row.enabled = wanted
|
||||||
|
changed += 1
|
||||||
|
|
||||||
|
return changed
|
||||||
+297
-14
@@ -1,5 +1,4 @@
|
|||||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, abort, session
|
from flask import Blueprint, render_template, redirect, url_for, flash, request, abort, session
|
||||||
from urllib.parse import urlparse
|
|
||||||
from flask_login import login_user, logout_user, login_required, current_user
|
from flask_login import login_user, logout_user, login_required, current_user
|
||||||
from app import db, limiter
|
from app import db, limiter
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
@@ -9,6 +8,7 @@ from app.utils import mfa
|
|||||||
import logging
|
import logging
|
||||||
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE, ACTION_LOGIN, ACTION_LOGOUT
|
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE, ACTION_LOGIN, ACTION_LOGOUT
|
||||||
from app.tenancy.gates import quota_soft_check
|
from app.tenancy.gates import quota_soft_check
|
||||||
|
from app.tenancy.session_binding import bind_session_tenant, SESSION_TENANT_KEY
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -41,6 +41,7 @@ def login():
|
|||||||
# second-factor step. Password is verified; identity is NOT yet
|
# second-factor step. Password is verified; identity is NOT yet
|
||||||
# established until the code is confirmed at /auth/mfa.
|
# established until the code is confirmed at /auth/mfa.
|
||||||
if user.mfa_enabled and user.mfa_secret:
|
if user.mfa_enabled and user.mfa_secret:
|
||||||
|
bind_session_tenant() # MT-21: tag before identity enters the session
|
||||||
session['mfa_pending_user_id'] = user.id
|
session['mfa_pending_user_id'] = user.id
|
||||||
session['mfa_pending_remember'] = bool(form.remember_me.data)
|
session['mfa_pending_remember'] = bool(form.remember_me.data)
|
||||||
session['mfa_pending_next'] = safe_redirect_url(request.args.get('next'))
|
session['mfa_pending_next'] = safe_redirect_url(request.args.get('next'))
|
||||||
@@ -48,6 +49,7 @@ def login():
|
|||||||
return redirect(url_for('auth.mfa_challenge'))
|
return redirect(url_for('auth.mfa_challenge'))
|
||||||
|
|
||||||
login_user(user, remember=form.remember_me.data)
|
login_user(user, remember=form.remember_me.data)
|
||||||
|
bind_session_tenant() # MT-21
|
||||||
# Use validated next URL — never redirect blindly to request.args['next']
|
# Use validated next URL — never redirect blindly to request.args['next']
|
||||||
next_page = safe_redirect_url(request.args.get('next'))
|
next_page = safe_redirect_url(request.args.get('next'))
|
||||||
log_action(ACTION_LOGIN, 'User', user.id, user.username)
|
log_action(ACTION_LOGIN, 'User', user.id, user.username)
|
||||||
@@ -107,6 +109,7 @@ def mfa_challenge():
|
|||||||
next_page = session.pop('mfa_pending_next', None)
|
next_page = session.pop('mfa_pending_next', None)
|
||||||
session.pop('mfa_pending_user_id', None)
|
session.pop('mfa_pending_user_id', None)
|
||||||
login_user(user, remember=remember)
|
login_user(user, remember=remember)
|
||||||
|
bind_session_tenant() # MT-21
|
||||||
log_action(ACTION_LOGIN, 'User', user.id, user.username, f'2fa via {via}')
|
log_action(ACTION_LOGIN, 'User', user.id, user.username, f'2fa via {via}')
|
||||||
if via == 'recovery':
|
if via == 'recovery':
|
||||||
remaining_n = len(user.mfa_recovery_codes or [])
|
remaining_n = len(user.mfa_recovery_codes or [])
|
||||||
@@ -199,7 +202,7 @@ def profile():
|
|||||||
|
|
||||||
if form.validate_on_submit():
|
if form.validate_on_submit():
|
||||||
current_user.full_name = form.full_name.data.strip() or None
|
current_user.full_name = form.full_name.data.strip() or None
|
||||||
current_user.email = form.email.data
|
current_user.email = form.email.data.strip().lower()
|
||||||
|
|
||||||
if form.new_password.data:
|
if form.new_password.data:
|
||||||
current_user.set_password(form.new_password.data)
|
current_user.set_password(form.new_password.data)
|
||||||
@@ -242,14 +245,163 @@ def profile():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Self-service data export (GDPR Art. 15/20, CCPA right-to-know) ────────────
|
||||||
|
|
||||||
|
@bp.route('/my-data/export')
|
||||||
|
@login_required
|
||||||
|
def export_my_data():
|
||||||
|
"""Download a JSON snapshot of everything this account's own records hold:
|
||||||
|
profile fields, inspections performed, issues reported/assigned/commented
|
||||||
|
on, and the audit log entries recorded against this user id.
|
||||||
|
|
||||||
|
Read-only, and scoped to the caller. Records that merely *reference* this
|
||||||
|
user are included only as the user's own row — related entities are NOT
|
||||||
|
expanded, so an issue this user commented on contributes the comment, not
|
||||||
|
the facility details or the other participants. That keeps a subject-access
|
||||||
|
request from becoming a data leak about everyone else.
|
||||||
|
|
||||||
|
Multi-tenant note: this runs against the caller's own tenant DB via the
|
||||||
|
normal request routing, so it can only ever see that tenant's data.
|
||||||
|
"""
|
||||||
|
from flask import Response
|
||||||
|
import json
|
||||||
|
from app.models.inspection import Inspection
|
||||||
|
from app.models.issue import Issue, IssueComment
|
||||||
|
from app.models.audit import AuditLog
|
||||||
|
from app.utils.time_utils import now_eastern
|
||||||
|
|
||||||
|
user = current_user
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
'exported_at': now_eastern().isoformat(),
|
||||||
|
'profile': {
|
||||||
|
'id': user.id,
|
||||||
|
'username': user.username,
|
||||||
|
'full_name': user.full_name,
|
||||||
|
'email': user.email,
|
||||||
|
'role': user.role,
|
||||||
|
'created_at': user.created_at.isoformat() if user.created_at else None,
|
||||||
|
'active': user.active,
|
||||||
|
},
|
||||||
|
'inspections_performed': [
|
||||||
|
{'id': i.id, 'facility_id': i.facility_id,
|
||||||
|
'inspection_date': i.inspection_date.isoformat() if i.inspection_date else None,
|
||||||
|
'overall_score': i.overall_score, 'status': i.status}
|
||||||
|
for i in Inspection.query.filter_by(inspector_id=user.id).all()
|
||||||
|
],
|
||||||
|
'issues_reported': [
|
||||||
|
{'id': iss.id, 'facility_id': iss.facility_id, 'description': iss.description,
|
||||||
|
'status': iss.status, 'severity': iss.severity,
|
||||||
|
'reported_at': iss.reported_at.isoformat() if iss.reported_at else None}
|
||||||
|
for iss in Issue.query.filter_by(reported_by=user.id).all()
|
||||||
|
],
|
||||||
|
'issues_assigned': [
|
||||||
|
{'id': iss.id, 'facility_id': iss.facility_id, 'description': iss.description,
|
||||||
|
'status': iss.status, 'severity': iss.severity}
|
||||||
|
for iss in Issue.query.filter_by(assigned_to=user.id).all()
|
||||||
|
],
|
||||||
|
'issue_comments_authored': [
|
||||||
|
{'id': c.id, 'issue_id': c.issue_id, 'body': c.body,
|
||||||
|
'created_at': c.created_at.isoformat() if c.created_at else None}
|
||||||
|
for c in IssueComment.query.filter_by(user_id=user.id).all()
|
||||||
|
],
|
||||||
|
'audit_log_entries': [
|
||||||
|
{'id': a.id, 'action': a.action, 'entity_type': a.entity_type,
|
||||||
|
'entity_id': a.entity_id, 'entity_label': a.entity_label,
|
||||||
|
'created_at': a.created_at.isoformat() if a.created_at else None}
|
||||||
|
for a in AuditLog.query.filter_by(user_id=user.id).all()
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
log_action(ACTION_UPDATE, 'User', user.id, user.username, 'self-service data export')
|
||||||
|
logger.info('AUTH | export_my_data | user_id=%s username=%s', user.id, user.username)
|
||||||
|
|
||||||
|
body = json.dumps(payload, indent=2, default=str)
|
||||||
|
return Response(
|
||||||
|
body,
|
||||||
|
mimetype='application/json',
|
||||||
|
headers={'Content-Disposition': f'attachment; filename=jqc_my_data_{user.id}.json'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Self-service erasure request (GDPR Art. 17, CCPA right-to-delete) ─────────
|
||||||
|
|
||||||
|
@bp.route('/my-data/delete-request', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def request_my_data_deletion():
|
||||||
|
"""Erase this account's PII on request.
|
||||||
|
|
||||||
|
Two outcomes, chosen automatically:
|
||||||
|
|
||||||
|
* No records that a hard delete would orphan (same guard rails as the admin
|
||||||
|
delete_user route) → the account is deleted outright.
|
||||||
|
* Otherwise — the common case, since staff usually have inspection or issue
|
||||||
|
history that must be kept for business and audit continuity — the account
|
||||||
|
is ANONYMIZED in place: name/email/username replaced with a
|
||||||
|
non-identifying placeholder, the password hash invalidated so nobody can
|
||||||
|
ever log in as it again, and the account deactivated.
|
||||||
|
|
||||||
|
Historical records reference the user *id*, not the PII, so they survive the
|
||||||
|
anonymization unchanged and the audit trail stays intact. This is the
|
||||||
|
balance the regulations expect: erase the identity, keep the ledger.
|
||||||
|
"""
|
||||||
|
import secrets
|
||||||
|
from app.models.issue import Issue as _Issue, IssueComment as _IssueComment
|
||||||
|
from app.models.inspection import InspectionTemplate as _InspectionTemplate
|
||||||
|
|
||||||
|
user = current_user
|
||||||
|
|
||||||
|
blocking = (
|
||||||
|
user.inspections.count() > 0
|
||||||
|
or _Issue.query.filter_by(assigned_to=user.id).count() > 0
|
||||||
|
or _IssueComment.query.filter_by(user_id=user.id).count() > 0
|
||||||
|
or _InspectionTemplate.query.filter_by(created_by=user.id).count() > 0
|
||||||
|
)
|
||||||
|
|
||||||
|
username = user.username
|
||||||
|
user_id = user.id
|
||||||
|
|
||||||
|
if not blocking:
|
||||||
|
db.session.delete(user)
|
||||||
|
db.session.commit()
|
||||||
|
logout_user()
|
||||||
|
logger.info('AUTH | self_delete | user_id=%s username=%s', user_id, username)
|
||||||
|
log_action(ACTION_DELETE, 'User', user_id, username,
|
||||||
|
'self-service account deletion')
|
||||||
|
flash('Your account and data have been permanently deleted.', 'success')
|
||||||
|
return redirect(url_for('auth.login'))
|
||||||
|
|
||||||
|
placeholder = f'deleted_user_{user_id}'
|
||||||
|
user.full_name = None
|
||||||
|
user.email = f'{placeholder}@deleted.local'
|
||||||
|
user.username = placeholder
|
||||||
|
# Random hash nobody holds — the account can never be logged into again.
|
||||||
|
user.set_password(secrets.token_hex(32))
|
||||||
|
user.active = False
|
||||||
|
db.session.commit()
|
||||||
|
logger.info('AUTH | self_anonymize | user_id=%s '
|
||||||
|
'(had blocking records, hard delete not possible)', user_id)
|
||||||
|
log_action(ACTION_UPDATE, 'User', user_id, placeholder,
|
||||||
|
'self-service erasure request — anonymized '
|
||||||
|
'(blocking records retained for audit/business continuity)')
|
||||||
|
logout_user()
|
||||||
|
flash('Your personal information has been removed and your account '
|
||||||
|
'deactivated. Historical records tied to your account id are retained '
|
||||||
|
'for audit continuity but no longer identify you.', 'success')
|
||||||
|
return redirect(url_for('auth.login'))
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/users')
|
@bp.route('/users')
|
||||||
@login_required
|
@login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def list_users():
|
def list_users():
|
||||||
# Exclude customer accounts — those are managed exclusively via /customers
|
# Exclude customer-side accounts — Customer Director AND Customer Inspector
|
||||||
|
# are both managed exclusively via /customers (phase51). Using
|
||||||
|
# User.CUSTOMER_ROLES rather than != 'customer' is what moves the customer
|
||||||
|
# inspectors off this page.
|
||||||
users = (
|
users = (
|
||||||
User.query
|
User.query
|
||||||
.filter(User.role != 'customer')
|
.filter(~User.role.in_(User.CUSTOMER_ROLES))
|
||||||
.order_by(User.created_at.desc())
|
.order_by(User.created_at.desc())
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
@@ -273,6 +425,25 @@ def list_users():
|
|||||||
inspector_contract_counts=inspector_contract_counts)
|
inspector_contract_counts=inspector_contract_counts)
|
||||||
|
|
||||||
|
|
||||||
|
def _redirect_if_customer_account(user):
|
||||||
|
"""Send customer-side accounts back to Customer Management.
|
||||||
|
|
||||||
|
phase51 moved Customer Director + Customer Inspector wholly under
|
||||||
|
/customers. These accounts are no longer listed here, but the /auth/users
|
||||||
|
URLs are still reachable by hand — and editing one through UserForm would
|
||||||
|
fail anyway ('external_inspector' is no longer an offered role choice, so
|
||||||
|
SelectField would reject the existing value). Redirect instead of 404 so an
|
||||||
|
old bookmark lands on the page that now owns the account.
|
||||||
|
|
||||||
|
Returns a response to return, or None to continue.
|
||||||
|
"""
|
||||||
|
if user is not None and user.is_customer_account:
|
||||||
|
flash(f'{user.display_name} is a {user.role_label} account and is '
|
||||||
|
f'managed in Customer Management.', 'info')
|
||||||
|
return redirect(url_for('customers.manage', customer_id=user.id))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/users/new', methods=['GET', 'POST'])
|
@bp.route('/users/new', methods=['GET', 'POST'])
|
||||||
@login_required
|
@login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
@@ -286,19 +457,39 @@ def create_user():
|
|||||||
|
|
||||||
if form.validate_on_submit():
|
if form.validate_on_submit():
|
||||||
role = 'inspector' if director_editing else form.role.data
|
role = 'inspector' if director_editing else form.role.data
|
||||||
|
|
||||||
|
# phase51 — the invitation branch that used to live here moved to
|
||||||
|
# Customer Management along with the Customer Inspector role. Every
|
||||||
|
# role this form still offers is OUR OWN staff, created with an
|
||||||
|
# admin-set password. Customer-side accounts are invited (they choose
|
||||||
|
# their own username and password) via customers.create().
|
||||||
|
#
|
||||||
|
# UserForm.password is Optional() because the same form is used for
|
||||||
|
# EDIT, where blank means "keep current". On CREATE a blank password
|
||||||
|
# would otherwise store the hash of an empty string, so require one.
|
||||||
|
if not form.password.data:
|
||||||
|
flash('Please set a password for the new user.', 'danger')
|
||||||
|
return render_template('auth/user_form.html', form=form, user=None,
|
||||||
|
title='Create User',
|
||||||
|
director_editing=director_editing)
|
||||||
|
|
||||||
user = User(
|
user = User(
|
||||||
username=form.username.data,
|
username=form.username.data,
|
||||||
full_name=form.full_name.data.strip() or None,
|
full_name=form.full_name.data.strip() or None,
|
||||||
email=form.email.data,
|
email=form.email.data.strip().lower(),
|
||||||
role=role
|
role=role,
|
||||||
|
password_set=True,
|
||||||
)
|
)
|
||||||
user.set_password(form.password.data)
|
user.set_password(form.password.data)
|
||||||
db.session.add(user)
|
db.session.add(user)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
logger.info('AUTH | user_create | admin_id=%s admin=%s new_user=%s role=%s',
|
logger.info('AUTH | user_create | admin_id=%s admin=%s new_user=%s role=%s',
|
||||||
current_user.id, current_user.username, user.username, user.role)
|
current_user.id, current_user.username, user.username,
|
||||||
|
user.role)
|
||||||
log_action(ACTION_CREATE, 'User', user.id, user.username,
|
log_action(ACTION_CREATE, 'User', user.id, user.username,
|
||||||
f'role={user.role}; email={user.email}')
|
f'role={user.role}; email={user.email}')
|
||||||
|
|
||||||
flash(f'User {user.username} created successfully.', 'success')
|
flash(f'User {user.username} created successfully.', 'success')
|
||||||
return redirect(url_for('auth.list_users'))
|
return redirect(url_for('auth.list_users'))
|
||||||
|
|
||||||
@@ -313,6 +504,9 @@ def edit_user(user_id):
|
|||||||
user = db.session.get(User, user_id)
|
user = db.session.get(User, user_id)
|
||||||
if user is None:
|
if user is None:
|
||||||
abort(404)
|
abort(404)
|
||||||
|
moved = _redirect_if_customer_account(user)
|
||||||
|
if moved:
|
||||||
|
return moved
|
||||||
form = UserForm(user=user, obj=user)
|
form = UserForm(user=user, obj=user)
|
||||||
|
|
||||||
# Directors may not change another user's role — that privilege is admin-only.
|
# Directors may not change another user's role — that privilege is admin-only.
|
||||||
@@ -323,7 +517,7 @@ def edit_user(user_id):
|
|||||||
if form.validate_on_submit():
|
if form.validate_on_submit():
|
||||||
user.username = form.username.data
|
user.username = form.username.data
|
||||||
user.full_name = form.full_name.data.strip() or None
|
user.full_name = form.full_name.data.strip() or None
|
||||||
user.email = form.email.data
|
user.email = form.email.data.strip().lower()
|
||||||
|
|
||||||
if not director_editing:
|
if not director_editing:
|
||||||
user.role = form.role.data
|
user.role = form.role.data
|
||||||
@@ -343,13 +537,58 @@ def edit_user(user_id):
|
|||||||
title='Edit User', director_editing=director_editing)
|
title='Edit User', director_editing=director_editing)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/users/<int:user_id>/resend-invite', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def resend_invite(user_id):
|
||||||
|
"""Re-send the set-password invitation for an account still awaiting setup.
|
||||||
|
|
||||||
|
Without this an invitation that bounces, is deleted or expires leaves the
|
||||||
|
account permanently unusable — password_set=False blocks login and only a
|
||||||
|
valid token can clear it. Mirrors customers.resend_invite for staff-side
|
||||||
|
accounts (currently only external inspectors are ever invited this way).
|
||||||
|
"""
|
||||||
|
user = db.session.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
abort(404)
|
||||||
|
moved = _redirect_if_customer_account(user)
|
||||||
|
if moved:
|
||||||
|
return moved
|
||||||
|
if user.password_set:
|
||||||
|
flash(f'{user.display_name} has already completed their account setup.',
|
||||||
|
'info')
|
||||||
|
return redirect(url_for('auth.list_users'))
|
||||||
|
|
||||||
|
# A fresh token invalidates the previous link.
|
||||||
|
token = user.generate_set_password_token(expires_hours=72)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
logger.info('AUTH | resend_invite | admin=%s user=%s',
|
||||||
|
current_user.username, user.username)
|
||||||
|
log_action(ACTION_UPDATE, 'User', user.id, user.username,
|
||||||
|
'invitation email resent')
|
||||||
|
|
||||||
|
from app.routes.customers import _send_invite_email
|
||||||
|
_send_invite_email(user, token, base_url=request.host_url)
|
||||||
|
|
||||||
|
flash(f'Invitation resent to {user.email}.', 'success')
|
||||||
|
return redirect(url_for('auth.list_users'))
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/users/<int:user_id>/assign-contracts', methods=['GET', 'POST'])
|
@bp.route('/users/<int:user_id>/assign-contracts', methods=['GET', 'POST'])
|
||||||
@login_required
|
@login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def assign_inspector_contracts(user_id):
|
def assign_inspector_contracts(user_id):
|
||||||
user = db.session.get(User, user_id)
|
user = db.session.get(User, user_id)
|
||||||
if user is None or user.role != 'inspector':
|
# MT-15: external inspectors are scoped by the same InspectorAssignment
|
||||||
|
# rows, so this page must accept them too.
|
||||||
|
if user is None or not user.is_inspector:
|
||||||
abort(404)
|
abort(404)
|
||||||
|
# A Customer Inspector is scoped by exactly these rows, but the page that
|
||||||
|
# owns them is now customers.manage — one editor per account, not two.
|
||||||
|
moved = _redirect_if_customer_account(user)
|
||||||
|
if moved:
|
||||||
|
return moved
|
||||||
|
|
||||||
from app.models.project import Project
|
from app.models.project import Project
|
||||||
from app.models.inspector_assignment import InspectorAssignment
|
from app.models.inspector_assignment import InspectorAssignment
|
||||||
@@ -536,7 +775,6 @@ def _send_password_reset_email(user, token, base_url=None):
|
|||||||
from flask import current_app, render_template_string, url_for as _url_for
|
from flask import current_app, render_template_string, url_for as _url_for
|
||||||
from flask_mail import Message
|
from flask_mail import Message
|
||||||
from app import mail
|
from app import mail
|
||||||
from urllib.parse import urlparse
|
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
if not current_app.config.get('MAIL_SERVER'):
|
if not current_app.config.get('MAIL_SERVER'):
|
||||||
@@ -545,8 +783,17 @@ def _send_password_reset_email(user, token, base_url=None):
|
|||||||
|
|
||||||
effective_base = (base_url or current_app.config.get('APP_BASE_URL', '')).rstrip('/')
|
effective_base = (base_url or current_app.config.get('APP_BASE_URL', '')).rstrip('/')
|
||||||
reset_link = f'{effective_base}{_url_for("auth.reset_password", token=token)}'
|
reset_link = f'{effective_base}{_url_for("auth.reset_password", token=token)}'
|
||||||
host = urlparse(effective_base).netloc or 'janitorialqc.local'
|
|
||||||
sender = f'noreply@{host}'
|
# Branded From as a (display_name, address) tuple, exactly as
|
||||||
|
# customers._send_invite_email does. The display NAME tracks the tenant; the
|
||||||
|
# ADDRESS is branded only for DNS-authorized domains and otherwise stays the
|
||||||
|
# authenticated SMTP identity so the mail still delivers. The previous
|
||||||
|
# `noreply@{host}` sent from whatever host the browser was on, which is not
|
||||||
|
# an authorized sender for that domain — the mail server accepted it and it
|
||||||
|
# was then dropped downstream by SPF/DMARC. See app/utils/mail_utils.py and
|
||||||
|
# CLAUDE.md rule 64.
|
||||||
|
from app.utils.mail_utils import branded_sender
|
||||||
|
sender = branded_sender(effective_base)
|
||||||
|
|
||||||
html_body = render_template_string("""<!DOCTYPE html>
|
html_body = render_template_string("""<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
@@ -612,12 +859,26 @@ def forgot_password():
|
|||||||
|
|
||||||
form = ForgotPasswordForm()
|
form = ForgotPasswordForm()
|
||||||
if form.validate_on_submit():
|
if form.validate_on_submit():
|
||||||
user = User.query.filter_by(email=form.email.data.strip().lower()).first()
|
# Explicit case-insensitive lookup. This is NOT fixing a live bug: no
|
||||||
|
# table here declares a COLLATE, so `users.email` inherits the utf8mb4
|
||||||
|
# default (utf8mb4_general_ci / utf8mb4_0900_ai_ci), both of which are
|
||||||
|
# case-insensitive — a bare `== lower(input)` already matched a
|
||||||
|
# mixed-case stored address. The point is to stop depending on that
|
||||||
|
# server default: under a binary/_bin collation the bare comparison
|
||||||
|
# would silently find nothing and still show the success message below.
|
||||||
|
email_input = form.email.data.strip().lower()
|
||||||
|
user = User.query.filter(
|
||||||
|
db.func.lower(User.email) == email_input
|
||||||
|
).first()
|
||||||
if user and user.active:
|
if user and user.active:
|
||||||
token = user.generate_set_password_token(expires_hours=1)
|
token = user.generate_set_password_token(expires_hours=1)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
_send_password_reset_email(user, token, base_url=request.host_url)
|
_send_password_reset_email(user, token, base_url=request.host_url)
|
||||||
logger.info('AUTH | forgot_password | user=%s | email=%s', user.username, user.email)
|
logger.info('AUTH | forgot_password | reset link dispatched | user=%s | email=%s',
|
||||||
|
user.username, user.email)
|
||||||
|
else:
|
||||||
|
# No leak to the user (generic message below), but log for diagnosis.
|
||||||
|
logger.info('AUTH | forgot_password | no active account for email=%s', email_input)
|
||||||
# Always show the same message — never reveal whether the email exists
|
# Always show the same message — never reveal whether the email exists
|
||||||
flash(
|
flash(
|
||||||
'If an account with that email address exists, a password reset link '
|
'If an account with that email address exists, a password reset link '
|
||||||
@@ -691,6 +952,23 @@ def impersonate_entry():
|
|||||||
flask_session['impersonating_tenant_id'] = tenant_id
|
flask_session['impersonating_tenant_id'] = tenant_id
|
||||||
flask_session['impersonating_superadmin_id'] = superadmin_id
|
flask_session['impersonating_superadmin_id'] = superadmin_id
|
||||||
|
|
||||||
|
# ── MT-21: re-tag the session for the impersonated tenant ────────────────
|
||||||
|
# Identity in the session is tenant-tagged (User.get_id), and from the next
|
||||||
|
# request onward the middleware binds the impersonated tenant's database.
|
||||||
|
# Without re-tagging, load_user() would correctly reject the tag issued by
|
||||||
|
# the host tenant and the superadmin would land on a login page.
|
||||||
|
#
|
||||||
|
# This preserves the pre-existing impersonation semantics EXACTLY: the
|
||||||
|
# numeric user id carries over, so the superadmin is loaded as the
|
||||||
|
# same-numbered user in the target tenant's database. That behaviour is
|
||||||
|
# arbitrary and worth revisiting (see MULTI_TENANT_PLAN.md open items) —
|
||||||
|
# but changing it is a separate decision, not a security fix.
|
||||||
|
flask_session[SESSION_TENANT_KEY] = tenant_id
|
||||||
|
raw_uid = flask_session.get('_user_id')
|
||||||
|
if raw_uid is not None:
|
||||||
|
numeric_uid = str(raw_uid).rpartition(':')[2]
|
||||||
|
flask_session['_user_id'] = f'{tenant_id}:{numeric_uid}'
|
||||||
|
|
||||||
logger.info('AUTH | impersonate_start | sa=%s tenant=%s', superadmin_id, tenant_id)
|
logger.info('AUTH | impersonate_start | sa=%s tenant=%s', superadmin_id, tenant_id)
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@@ -714,6 +992,11 @@ def impersonate_end():
|
|||||||
import os
|
import os
|
||||||
flask_session.pop('impersonating_tenant_id', None)
|
flask_session.pop('impersonating_tenant_id', None)
|
||||||
flask_session.pop('impersonating_superadmin_id', None)
|
flask_session.pop('impersonating_superadmin_id', None)
|
||||||
|
# MT-21: the session identity is still tagged for the impersonated tenant.
|
||||||
|
# Drop it rather than carrying it back to the superadmin's own host, where
|
||||||
|
# the middleware would clear it on tenant mismatch anyway.
|
||||||
|
logout_user()
|
||||||
|
flask_session.clear()
|
||||||
panel_url = f"https://admin.{os.environ.get('TENANT_BASE_DOMAIN', 'jqc.app')}"
|
panel_url = f"https://admin.{os.environ.get('TENANT_BASE_DOMAIN', 'jqc.app')}"
|
||||||
logger.info('AUTH | impersonate_end | redirecting to panel')
|
logger.info('AUTH | impersonate_end | redirecting to panel')
|
||||||
return redirect(panel_url)
|
return redirect(panel_url)
|
||||||
@@ -22,10 +22,12 @@ logger = logging.getLogger(__name__)
|
|||||||
bp = Blueprint('broadcast', __name__, url_prefix='/admin/broadcast')
|
bp = Blueprint('broadcast', __name__, url_prefix='/admin/broadcast')
|
||||||
|
|
||||||
# All roles that can hold an active iOS session
|
# All roles that can hold an active iOS session
|
||||||
BROADCAST_ROLES = ['inspector', 'project_manager', 'director', 'admin']
|
BROADCAST_ROLES = ['inspector', 'external_inspector', 'project_manager',
|
||||||
|
'director', 'admin']
|
||||||
|
|
||||||
ROLE_LABELS = {
|
ROLE_LABELS = {
|
||||||
'inspector': 'Inspectors',
|
'inspector': 'Inspectors',
|
||||||
|
'external_inspector': 'Customer Inspectors',
|
||||||
'project_manager': 'Project Managers',
|
'project_manager': 'Project Managers',
|
||||||
'director': 'Directors',
|
'director': 'Directors',
|
||||||
'admin': 'Admins',
|
'admin': 'Admins',
|
||||||
|
|||||||
+393
-56
@@ -3,13 +3,26 @@ app/routes/customers.py
|
|||||||
-----------------------
|
-----------------------
|
||||||
Customer Management — admin-only consolidated view.
|
Customer Management — admin-only consolidated view.
|
||||||
|
|
||||||
|
Owns BOTH customer-side roles (phase51 — see User.CUSTOMER_ROLES):
|
||||||
|
|
||||||
|
Customer Director role='customer' portal access, read-mostly,
|
||||||
|
scoped by CustomerAssignment
|
||||||
|
Customer Inspector role='external_inspector' full inspector capabilities,
|
||||||
|
scoped by InspectorAssignment
|
||||||
|
|
||||||
|
They are two seats of the same customer organisation, so they are listed,
|
||||||
|
invited, edited, assigned, switched and disabled here rather than in User
|
||||||
|
Management — which now excludes both.
|
||||||
|
|
||||||
Provides a single screen to:
|
Provides a single screen to:
|
||||||
- List all customer-role users with their assignment summary
|
- List all customer-side users with their assignment summary
|
||||||
- Create a new customer account
|
- Invite a new customer account in either role
|
||||||
- Edit an existing customer (username / email / password / active)
|
- Edit an existing account (username / email / password / active)
|
||||||
- Manage assignments for a customer (add / remove)
|
- Manage assignments (contracts/facilities for a director, contracts for an
|
||||||
- Quick-disable / enable a customer account
|
inspector)
|
||||||
- View a customer's scoped facility access at a glance
|
- Switch an account between the two roles
|
||||||
|
- Quick-disable / enable an account
|
||||||
|
- View the account's scoped facility access at a glance
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -18,6 +31,7 @@ from flask_login import login_required, current_user
|
|||||||
from app import db
|
from app import db
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.project import Project, CustomerAssignment
|
from app.models.project import Project, CustomerAssignment
|
||||||
|
from app.models.inspector_assignment import InspectorAssignment
|
||||||
from app.models.facility import Facility
|
from app.models.facility import Facility
|
||||||
from app.utils.forms import CustomerUserForm, CustomerAssignmentForm, CustomerInviteForm, SetPasswordForm
|
from app.utils.forms import CustomerUserForm, CustomerAssignmentForm, CustomerInviteForm, SetPasswordForm
|
||||||
from app.utils.decorators import admin_required, supervisor_required, safe_redirect_url
|
from app.utils.decorators import admin_required, supervisor_required, safe_redirect_url
|
||||||
@@ -29,16 +43,46 @@ logger = logging.getLogger(__name__)
|
|||||||
bp = Blueprint('customers', __name__, url_prefix='/customers')
|
bp = Blueprint('customers', __name__, url_prefix='/customers')
|
||||||
|
|
||||||
|
|
||||||
|
def _get_customer_or_redirect(customer_id):
|
||||||
|
"""Load a customer-side account, or return a redirect response.
|
||||||
|
|
||||||
|
Returns (account, None) on success and (None, response) when the id is not
|
||||||
|
a customer-side account. Every route here used to test
|
||||||
|
`customer.role != 'customer'`, which would now reject the Customer
|
||||||
|
Inspectors this page owns — the check is CUSTOMER_ROLES, once, here.
|
||||||
|
"""
|
||||||
|
account = db.session.get(User, customer_id)
|
||||||
|
if account is None:
|
||||||
|
abort(404)
|
||||||
|
if not account.is_customer_account:
|
||||||
|
flash('This page is only for customer accounts.', 'warning')
|
||||||
|
return None, redirect(url_for('customers.index'))
|
||||||
|
return account, None
|
||||||
|
|
||||||
|
|
||||||
|
def _inspector_scope_ids(user, project_facilities_map):
|
||||||
|
"""Facility IDs a Customer Inspector reaches, from its contract rows.
|
||||||
|
|
||||||
|
Mirrors get_inspector_scope() but reuses the caller's already-loaded
|
||||||
|
project → facilities map so the list view stays free of N+1 queries
|
||||||
|
(rule 13).
|
||||||
|
"""
|
||||||
|
ids = set()
|
||||||
|
for a in InspectorAssignment.query.filter_by(user_id=user.id).all():
|
||||||
|
ids.update(project_facilities_map.get(a.project_id, []))
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
# ── List ──────────────────────────────────────────────────────────────────────
|
# ── List ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@bp.route('/')
|
@bp.route('/')
|
||||||
@login_required
|
@login_required
|
||||||
@supervisor_required
|
@supervisor_required
|
||||||
def index():
|
def index():
|
||||||
"""Consolidated customer management dashboard."""
|
"""Consolidated customer management dashboard — both customer roles."""
|
||||||
customers = (
|
customers = (
|
||||||
User.query
|
User.query
|
||||||
.filter_by(role='customer')
|
.filter(User.role.in_(User.CUSTOMER_ROLES))
|
||||||
.order_by(User.username)
|
.order_by(User.username)
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
@@ -57,10 +101,27 @@ def index():
|
|||||||
for a in all_assignments:
|
for a in all_assignments:
|
||||||
assignment_map[a.user_id].append(a)
|
assignment_map[a.user_id].append(a)
|
||||||
|
|
||||||
|
# ── Bulk query for the inspector-side assignments ─────────────────────
|
||||||
|
# Customer Inspectors are scoped by InspectorAssignment, not
|
||||||
|
# CustomerAssignment — the two roles read different tables for the same
|
||||||
|
# question ("which facilities does this account see?").
|
||||||
|
all_inspector_assignments = (
|
||||||
|
InspectorAssignment.query
|
||||||
|
.filter(InspectorAssignment.user_id.in_(customer_ids))
|
||||||
|
.all()
|
||||||
|
) if customer_ids else []
|
||||||
|
|
||||||
|
inspector_assignment_map = {c.id: [] for c in customers}
|
||||||
|
for a in all_inspector_assignments:
|
||||||
|
inspector_assignment_map[a.user_id].append(a)
|
||||||
|
|
||||||
# ── Single bulk query for all active facilities in assigned projects ──
|
# ── Single bulk query for all active facilities in assigned projects ──
|
||||||
# Resolves facility scope for every customer without repeated DB round-trips.
|
# Resolves facility scope for every customer without repeated DB round-trips.
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
assigned_project_ids = {a.project_id for a in all_assignments}
|
assigned_project_ids = (
|
||||||
|
{a.project_id for a in all_assignments}
|
||||||
|
| {a.project_id for a in all_inspector_assignments}
|
||||||
|
)
|
||||||
|
|
||||||
project_facilities_map = defaultdict(list) # project_id → [facility_id, ...]
|
project_facilities_map = defaultdict(list) # project_id → [facility_id, ...]
|
||||||
if assigned_project_ids:
|
if assigned_project_ids:
|
||||||
@@ -78,6 +139,12 @@ def index():
|
|||||||
scope_map = {} # user_id → sorted list[int] facility IDs
|
scope_map = {} # user_id → sorted list[int] facility IDs
|
||||||
for customer in customers:
|
for customer in customers:
|
||||||
ids = set()
|
ids = set()
|
||||||
|
if customer.is_inspector:
|
||||||
|
# Customer Inspector — contract-level rows only, no facility-level
|
||||||
|
# narrowing exists for inspectors (rule 57: no rows = sees nothing).
|
||||||
|
for a in inspector_assignment_map[customer.id]:
|
||||||
|
ids.update(project_facilities_map.get(a.project_id, []))
|
||||||
|
else:
|
||||||
for a in assignment_map[customer.id]:
|
for a in assignment_map[customer.id]:
|
||||||
if a.facility_id:
|
if a.facility_id:
|
||||||
ids.add(a.facility_id)
|
ids.add(a.facility_id)
|
||||||
@@ -103,6 +170,7 @@ def index():
|
|||||||
'customers/index.html',
|
'customers/index.html',
|
||||||
customers = customers,
|
customers = customers,
|
||||||
assignment_map = assignment_map,
|
assignment_map = assignment_map,
|
||||||
|
inspector_assignment_map = inspector_assignment_map,
|
||||||
scope_map = scope_map,
|
scope_map = scope_map,
|
||||||
projects = projects,
|
projects = projects,
|
||||||
expired_invitations = expired_invitations,
|
expired_invitations = expired_invitations,
|
||||||
@@ -115,12 +183,16 @@ def index():
|
|||||||
@login_required
|
@login_required
|
||||||
@supervisor_required
|
@supervisor_required
|
||||||
def create():
|
def create():
|
||||||
"""Create a customer account via email invitation.
|
"""Create a customer-side account via email invitation.
|
||||||
|
|
||||||
Admin enters Full Name and Email only. A temporary username is
|
Admin enters Full Name, Email and the role (Customer Director or Customer
|
||||||
auto-generated from the email address. A one-time set-password link
|
Inspector). A temporary username is auto-generated from the email address.
|
||||||
is emailed; the customer chooses their own username and password when
|
A one-time set-password link is emailed; the invitee chooses their own
|
||||||
they click it. The account is activated on completion.
|
username and password when they click it. The account is activated on
|
||||||
|
completion.
|
||||||
|
|
||||||
|
Both roles take this identical path — an account that belongs to the
|
||||||
|
customer is never given a password we chose.
|
||||||
"""
|
"""
|
||||||
form = CustomerInviteForm()
|
form = CustomerInviteForm()
|
||||||
|
|
||||||
@@ -129,6 +201,11 @@ def create():
|
|||||||
|
|
||||||
full_name = form.full_name.data.strip()
|
full_name = form.full_name.data.strip()
|
||||||
email = form.email.data.strip().lower()
|
email = form.email.data.strip().lower()
|
||||||
|
role = form.role.data
|
||||||
|
# Defence in depth: never let a crafted POST mint a staff role through
|
||||||
|
# the customer invitation form, which sets no password.
|
||||||
|
if role not in User.CUSTOMER_ROLES:
|
||||||
|
role = 'customer'
|
||||||
|
|
||||||
# Auto-generate a temporary username from the email local part.
|
# Auto-generate a temporary username from the email local part.
|
||||||
# The customer replaces this with their preferred username when
|
# The customer replaces this with their preferred username when
|
||||||
@@ -146,7 +223,7 @@ def create():
|
|||||||
username = username,
|
username = username,
|
||||||
full_name = full_name,
|
full_name = full_name,
|
||||||
email = email,
|
email = email,
|
||||||
role = 'customer',
|
role = role,
|
||||||
active = True,
|
active = True,
|
||||||
password_set = False,
|
password_set = False,
|
||||||
)
|
)
|
||||||
@@ -157,15 +234,15 @@ def create():
|
|||||||
token = user.generate_set_password_token(expires_hours=72)
|
token = user.generate_set_password_token(expires_hours=72)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
logger.info('CUSTOMERS | invite | admin=%s new_customer=%s email=%s',
|
logger.info('CUSTOMERS | invite | admin=%s new_customer=%s role=%s email=%s',
|
||||||
current_user.username, user.username, user.email)
|
current_user.username, user.username, user.role, user.email)
|
||||||
log_action(ACTION_CREATE, 'User', user.id, user.username,
|
log_action(ACTION_CREATE, 'User', user.id, user.username,
|
||||||
f'role=customer; email={user.email}; invite_sent=True')
|
f'role={user.role}; email={user.email}; invite_sent=True')
|
||||||
|
|
||||||
_send_invite_email(user, token, base_url=request.host_url)
|
_send_invite_email(user, token, base_url=request.host_url)
|
||||||
|
|
||||||
flash(
|
flash(
|
||||||
f'Customer account created for {full_name}. '
|
f'{user.role_label} account created for {full_name}. '
|
||||||
f'An invitation email has been sent to {email} with a link to set their username and password.',
|
f'An invitation email has been sent to {email} with a link to set their username and password.',
|
||||||
'success'
|
'success'
|
||||||
)
|
)
|
||||||
@@ -179,7 +256,6 @@ def _send_invite_email(user, token, base_url=None):
|
|||||||
from flask import current_app, render_template_string
|
from flask import current_app, render_template_string
|
||||||
from flask_mail import Message
|
from flask_mail import Message
|
||||||
from app import mail
|
from app import mail
|
||||||
from urllib.parse import urlparse
|
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
if not current_app.config.get('MAIL_SERVER'):
|
if not current_app.config.get('MAIL_SERVER'):
|
||||||
@@ -189,8 +265,14 @@ def _send_invite_email(user, token, base_url=None):
|
|||||||
effective_base = (base_url or current_app.config.get('APP_BASE_URL', '')).rstrip('/')
|
effective_base = (base_url or current_app.config.get('APP_BASE_URL', '')).rstrip('/')
|
||||||
setup_link = f'{effective_base}{url_for("customers.set_password", token=token)}'
|
setup_link = f'{effective_base}{url_for("customers.set_password", token=token)}'
|
||||||
|
|
||||||
host = urlparse(effective_base).netloc or 'janitorialqc.local'
|
# Branded From as a (display_name, address) tuple. The display NAME tracks
|
||||||
sender = f'noreply@{host}'
|
# the tenant (e.g. "Gov Services QC"); the ADDRESS is branded only for
|
||||||
|
# DNS-authorized domains and otherwise stays the authenticated identity so
|
||||||
|
# the mail always delivers. The previous `noreply@{host}` sent from whatever
|
||||||
|
# host the browser was on, which fails SPF/DMARC for any domain this mail
|
||||||
|
# server isn't authorized for. See app/utils/mail_utils.py and rule 64.
|
||||||
|
from app.utils.mail_utils import branded_sender
|
||||||
|
sender = branded_sender(effective_base)
|
||||||
|
|
||||||
html_body = render_template_string("""<!DOCTYPE html>
|
html_body = render_template_string("""<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
@@ -254,12 +336,9 @@ def _send_invite_email(user, token, base_url=None):
|
|||||||
@supervisor_required
|
@supervisor_required
|
||||||
def resend_invite(customer_id):
|
def resend_invite(customer_id):
|
||||||
"""Generate a fresh token and resend the set-password invitation email."""
|
"""Generate a fresh token and resend the set-password invitation email."""
|
||||||
customer = db.session.get(User, customer_id)
|
customer, moved = _get_customer_or_redirect(customer_id)
|
||||||
if customer is None:
|
if moved:
|
||||||
abort(404)
|
return moved
|
||||||
if customer.role != 'customer':
|
|
||||||
flash('This action is only for customer accounts.', 'warning')
|
|
||||||
return redirect(url_for('customers.index'))
|
|
||||||
|
|
||||||
token = customer.generate_set_password_token(expires_hours=72)
|
token = customer.generate_set_password_token(expires_hours=72)
|
||||||
customer.password_set = False
|
customer.password_set = False
|
||||||
@@ -316,19 +395,16 @@ def set_password(token):
|
|||||||
@login_required
|
@login_required
|
||||||
@supervisor_required
|
@supervisor_required
|
||||||
def edit(customer_id):
|
def edit(customer_id):
|
||||||
customer = db.session.get(User, customer_id)
|
customer, moved = _get_customer_or_redirect(customer_id)
|
||||||
if customer is None:
|
if moved:
|
||||||
abort(404)
|
return moved
|
||||||
if customer.role != 'customer':
|
|
||||||
flash('This page is only for customer accounts.', 'warning')
|
|
||||||
return redirect(url_for('customers.index'))
|
|
||||||
|
|
||||||
form = CustomerUserForm(user=customer, obj=customer)
|
form = CustomerUserForm(user=customer, obj=customer)
|
||||||
|
|
||||||
if form.validate_on_submit():
|
if form.validate_on_submit():
|
||||||
customer.username = form.username.data
|
customer.username = form.username.data
|
||||||
customer.full_name = form.full_name.data.strip() or None
|
customer.full_name = form.full_name.data.strip() or None
|
||||||
customer.email = form.email.data
|
customer.email = form.email.data.strip().lower()
|
||||||
if form.password.data:
|
if form.password.data:
|
||||||
customer.set_password(form.password.data)
|
customer.set_password(form.password.data)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
@@ -349,16 +425,26 @@ def edit(customer_id):
|
|||||||
@login_required
|
@login_required
|
||||||
@supervisor_required
|
@supervisor_required
|
||||||
def manage(customer_id):
|
def manage(customer_id):
|
||||||
"""Single-customer detail page: profile + all assignments."""
|
"""Single-account detail page: profile + assignments + notification matrix.
|
||||||
customer = db.session.get(User, customer_id)
|
|
||||||
if customer is None:
|
|
||||||
abort(404)
|
|
||||||
if customer.role != 'customer':
|
|
||||||
flash('This page is only for customer accounts.', 'warning')
|
|
||||||
return redirect(url_for('customers.index'))
|
|
||||||
|
|
||||||
assignments = CustomerAssignment.query.filter_by(user_id=customer_id).all()
|
The assignment editor differs by role. A Customer Director gets the
|
||||||
|
contract/facility assignment list (CustomerAssignment); a Customer
|
||||||
|
Inspector gets the contract checkbox set (InspectorAssignment) that used to
|
||||||
|
live on /auth/users/<id>/assign-contracts.
|
||||||
|
"""
|
||||||
|
customer, moved = _get_customer_or_redirect(customer_id)
|
||||||
|
if moved:
|
||||||
|
return moved
|
||||||
|
|
||||||
|
# Resolve the account's facility scope through the SAME helper the app uses
|
||||||
|
# at request time, so this page can never disagree with what the account
|
||||||
|
# actually sees.
|
||||||
|
if customer.is_inspector:
|
||||||
|
from app.utils.scope import get_inspector_scope
|
||||||
|
facility_ids = get_inspector_scope(customer) or []
|
||||||
|
else:
|
||||||
facility_ids = get_customer_scope(customer) or []
|
facility_ids = get_customer_scope(customer) or []
|
||||||
|
|
||||||
facilities = (
|
facilities = (
|
||||||
Facility.query
|
Facility.query
|
||||||
.filter(Facility.id.in_(facility_ids), Facility.active == True)
|
.filter(Facility.id.in_(facility_ids), Facility.active == True)
|
||||||
@@ -366,19 +452,51 @@ def manage(customer_id):
|
|||||||
.all()
|
.all()
|
||||||
) if facility_ids else []
|
) if facility_ids else []
|
||||||
|
|
||||||
|
projects = Project.query.filter_by(active=True).order_by(Project.name).all()
|
||||||
|
|
||||||
|
assignments = []
|
||||||
|
assigned_pids = set()
|
||||||
|
if customer.is_inspector:
|
||||||
|
assigned_pids = {
|
||||||
|
a.project_id
|
||||||
|
for a in InspectorAssignment.query.filter_by(user_id=customer_id).all()
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
assignments = CustomerAssignment.query.filter_by(user_id=customer_id).all()
|
||||||
|
|
||||||
# Assignment form (populated here so it can be rendered inline)
|
# Assignment form (populated here so it can be rendered inline)
|
||||||
aform = CustomerAssignmentForm()
|
aform = CustomerAssignmentForm()
|
||||||
projects = Project.query.filter_by(active=True).order_by(Project.name).all()
|
|
||||||
aform.user_id.choices = [(customer.id, customer.username)]
|
aform.user_id.choices = [(customer.id, customer.username)]
|
||||||
aform.facility_id.choices = [(0, '— All facilities in contract —')]
|
aform.facility_id.choices = [(0, '— All facilities in contract —')]
|
||||||
|
|
||||||
|
# ── Per-account notification matrix ───────────────────────────────────
|
||||||
|
# For each event: what the global matrix would do for this account's role,
|
||||||
|
# and whether the account overrides it. The template renders a tri-state
|
||||||
|
# (Inherit / On / Off) so "inherit" stays visibly distinct from "explicitly
|
||||||
|
# set to the same value the global happens to have today".
|
||||||
|
from app.models.notification_matrix import MATRIX_EVENTS, is_enabled
|
||||||
|
from app.models.user_notification_matrix import overrides_for_user
|
||||||
|
|
||||||
|
overrides = overrides_for_user(customer.id)
|
||||||
|
matrix_rows = [
|
||||||
|
{
|
||||||
|
'event': event_key,
|
||||||
|
'label': label,
|
||||||
|
'global': is_enabled(event_key, customer.role),
|
||||||
|
'override': overrides.get(event_key), # True / False / None
|
||||||
|
}
|
||||||
|
for event_key, label in MATRIX_EVENTS.items()
|
||||||
|
]
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
'customers/manage.html',
|
'customers/manage.html',
|
||||||
customer = customer,
|
customer = customer,
|
||||||
assignments = assignments,
|
assignments = assignments,
|
||||||
|
assigned_pids = assigned_pids,
|
||||||
facilities = facilities,
|
facilities = facilities,
|
||||||
aform = aform,
|
aform = aform,
|
||||||
projects = projects,
|
projects = projects,
|
||||||
|
matrix_rows = matrix_rows,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -388,12 +506,16 @@ def manage(customer_id):
|
|||||||
@login_required
|
@login_required
|
||||||
@supervisor_required
|
@supervisor_required
|
||||||
def add_assignment(customer_id):
|
def add_assignment(customer_id):
|
||||||
customer = db.session.get(User, customer_id)
|
customer, moved = _get_customer_or_redirect(customer_id)
|
||||||
if customer is None:
|
if moved:
|
||||||
abort(404)
|
return moved
|
||||||
if customer.role != 'customer':
|
if customer.is_inspector:
|
||||||
flash('Assignments are only for customer accounts.', 'warning')
|
# A Customer Inspector is scoped by InspectorAssignment — writing a
|
||||||
return redirect(url_for('customers.index'))
|
# CustomerAssignment row for them would grant nothing while looking
|
||||||
|
# like it had.
|
||||||
|
flash('Customer Inspectors are assigned whole contracts — use the '
|
||||||
|
'contract list on this page.', 'warning')
|
||||||
|
return redirect(url_for('customers.manage', customer_id=customer_id))
|
||||||
|
|
||||||
project_id = request.form.get('project_id', type=int)
|
project_id = request.form.get('project_id', type=int)
|
||||||
facility_id = request.form.get('facility_id', type=int) or None
|
facility_id = request.form.get('facility_id', type=int) or None
|
||||||
@@ -462,18 +584,233 @@ def remove_assignment(assignment_id):
|
|||||||
return redirect(url_for('customers.manage', customer_id=customer_id))
|
return redirect(url_for('customers.manage', customer_id=customer_id))
|
||||||
|
|
||||||
|
|
||||||
|
# ── Contract assignments for a Customer Inspector ────────────────────────────
|
||||||
|
|
||||||
|
@bp.route('/<int:customer_id>/contracts', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
@supervisor_required
|
||||||
|
def assign_contracts(customer_id):
|
||||||
|
"""Replace a Customer Inspector's whole InspectorAssignment set.
|
||||||
|
|
||||||
|
Same replace-the-entire-set semantics as auth.assign_inspector_contracts
|
||||||
|
(rule 59) — the form posts the complete checked list, rows not in the POST
|
||||||
|
body are deleted. Callers must always send the full desired set, never a
|
||||||
|
diff.
|
||||||
|
"""
|
||||||
|
customer, moved = _get_customer_or_redirect(customer_id)
|
||||||
|
if moved:
|
||||||
|
return moved
|
||||||
|
if not customer.is_inspector:
|
||||||
|
flash('Contract assignment is for Customer Inspector accounts. '
|
||||||
|
'Customer Directors are assigned per contract or facility below.',
|
||||||
|
'warning')
|
||||||
|
return redirect(url_for('customers.manage', customer_id=customer_id))
|
||||||
|
|
||||||
|
from app.utils.time_utils import now_eastern
|
||||||
|
|
||||||
|
selected_ids = set(request.form.getlist('project_ids', type=int))
|
||||||
|
existing = InspectorAssignment.query.filter_by(user_id=customer_id).all()
|
||||||
|
existing_pids = {a.project_id for a in existing}
|
||||||
|
|
||||||
|
for a in existing:
|
||||||
|
if a.project_id not in selected_ids:
|
||||||
|
db.session.delete(a)
|
||||||
|
for pid in selected_ids:
|
||||||
|
if pid not in existing_pids:
|
||||||
|
db.session.add(InspectorAssignment(
|
||||||
|
user_id = customer_id,
|
||||||
|
project_id = pid,
|
||||||
|
created_at = now_eastern(),
|
||||||
|
))
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
logger.info('CUSTOMERS | assign_contracts | admin=%s customer=%s projects=%s',
|
||||||
|
current_user.username, customer.username, sorted(selected_ids))
|
||||||
|
log_action(ACTION_UPDATE, 'User', customer.id, customer.username,
|
||||||
|
f'inspector_assignments={sorted(selected_ids)}')
|
||||||
|
|
||||||
|
if not selected_ids:
|
||||||
|
# Rule 57 is strict, and silently is exactly how it bites.
|
||||||
|
flash(f'{customer.display_name} now has no contracts assigned and will '
|
||||||
|
f'see nothing until at least one is granted.', 'warning')
|
||||||
|
else:
|
||||||
|
flash(f'Contract assignments updated for {customer.display_name}.', 'success')
|
||||||
|
return redirect(url_for('customers.manage', customer_id=customer_id))
|
||||||
|
|
||||||
|
|
||||||
|
# ── Per-account notification matrix ──────────────────────────────────────────
|
||||||
|
|
||||||
|
@bp.route('/<int:customer_id>/notifications', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
@supervisor_required
|
||||||
|
def save_notifications(customer_id):
|
||||||
|
"""Save this account's per-event notification overrides.
|
||||||
|
|
||||||
|
Each event posts one of 'inherit' / 'on' / 'off'. 'inherit' DELETES the row
|
||||||
|
rather than storing the global column's current value — so an account that
|
||||||
|
never expressed an opinion keeps following the global matrix when it
|
||||||
|
changes later.
|
||||||
|
"""
|
||||||
|
customer, moved = _get_customer_or_redirect(customer_id)
|
||||||
|
if moved:
|
||||||
|
return moved
|
||||||
|
|
||||||
|
from app.models.notification_matrix import MATRIX_EVENTS
|
||||||
|
from app.models.user_notification_matrix import set_overrides
|
||||||
|
|
||||||
|
tri = {'inherit': None, 'on': True, 'off': False}
|
||||||
|
values = {}
|
||||||
|
for event_key in MATRIX_EVENTS:
|
||||||
|
# Only events this form actually posted; an unknown or missing value
|
||||||
|
# is treated as inherit rather than guessed at.
|
||||||
|
choice = request.form.get(f'event_{event_key}')
|
||||||
|
if choice is not None:
|
||||||
|
values[event_key] = tri.get(choice)
|
||||||
|
|
||||||
|
changed = set_overrides(customer.id, values)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
logger.info('CUSTOMERS | notif_matrix | admin=%s customer=%s changed=%s',
|
||||||
|
current_user.username, customer.username, changed)
|
||||||
|
log_action(ACTION_UPDATE, 'User', customer.id, customer.username,
|
||||||
|
f'notification overrides updated ({changed} change(s))')
|
||||||
|
|
||||||
|
flash(f'Notification settings saved for {customer.display_name}.'
|
||||||
|
if changed else 'No notification changes to save.',
|
||||||
|
'success' if changed else 'info')
|
||||||
|
return redirect(url_for('customers.manage', customer_id=customer.id))
|
||||||
|
|
||||||
|
|
||||||
|
# ── Switch between the two customer roles ────────────────────────────────────
|
||||||
|
|
||||||
|
@bp.route('/<int:customer_id>/switch-role', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def switch_role(customer_id):
|
||||||
|
"""Flip an account between Customer Director and Customer Inspector.
|
||||||
|
|
||||||
|
The two roles read DIFFERENT scoping tables, so flipping the column alone
|
||||||
|
would leave the account correctly labelled and seeing nothing (rule 57 is
|
||||||
|
strict for inspectors, and a director with no CustomerAssignment rows is
|
||||||
|
equally blind). The contracts are therefore mirrored across: every contract
|
||||||
|
the account could reach before, it can reach after.
|
||||||
|
|
||||||
|
Facility-level narrowing does NOT survive a switch to inspector — there is
|
||||||
|
no per-facility row for inspectors, so a director scoped to one building in
|
||||||
|
a contract becomes an inspector on that whole contract. The confirm dialog
|
||||||
|
says so; the flash repeats it. Switching BACK is lossless though: the
|
||||||
|
original facility-level rows were never deleted, and the reverse mirror
|
||||||
|
skips contracts the account can already reach, so it does not pile a
|
||||||
|
contract-wide grant on top of them.
|
||||||
|
|
||||||
|
API access changes in both directions ('external_inspector' has mobile API
|
||||||
|
access, 'customer' is 403 everywhere), so the account's refresh tokens and
|
||||||
|
device registrations are revoked — an issued JWT would otherwise keep
|
||||||
|
working until it expired, and a signed-in iPad would keep syncing.
|
||||||
|
"""
|
||||||
|
customer, moved = _get_customer_or_redirect(customer_id)
|
||||||
|
if moved:
|
||||||
|
return moved
|
||||||
|
|
||||||
|
from app.utils.time_utils import now_eastern
|
||||||
|
from app.models.api_token import RefreshToken, DeviceToken
|
||||||
|
|
||||||
|
old_role = customer.role
|
||||||
|
new_role = 'external_inspector' if old_role == 'customer' else 'customer'
|
||||||
|
|
||||||
|
widened = False
|
||||||
|
|
||||||
|
if new_role == 'external_inspector':
|
||||||
|
# Director → Inspector: CustomerAssignment (contract or facility) →
|
||||||
|
# InspectorAssignment (contract only).
|
||||||
|
existing_pids = {
|
||||||
|
a.project_id
|
||||||
|
for a in InspectorAssignment.query.filter_by(user_id=customer.id).all()
|
||||||
|
}
|
||||||
|
for a in CustomerAssignment.query.filter_by(user_id=customer.id).all():
|
||||||
|
if a.facility_id:
|
||||||
|
widened = True
|
||||||
|
if a.project_id not in existing_pids:
|
||||||
|
db.session.add(InspectorAssignment(
|
||||||
|
user_id = customer.id,
|
||||||
|
project_id = a.project_id,
|
||||||
|
created_at = now_eastern(),
|
||||||
|
))
|
||||||
|
existing_pids.add(a.project_id)
|
||||||
|
else:
|
||||||
|
# Inspector → Director: contract-level CustomerAssignment rows
|
||||||
|
# (facility_id NULL = all facilities in the contract).
|
||||||
|
#
|
||||||
|
# `existing_pids` counts ANY row for the contract, facility-level ones
|
||||||
|
# included — NOT just the contract-wide ones. That is what makes a
|
||||||
|
# round trip lossless: an account narrowed to one facility, switched to
|
||||||
|
# inspector (which can only hold whole contracts) and switched back
|
||||||
|
# would otherwise gain a contract-wide row on top of its original
|
||||||
|
# facility row and come back with the whole contract. Skipping
|
||||||
|
# contracts the account can already reach as a director leaves the
|
||||||
|
# original narrowing intact, while contracts granted during the
|
||||||
|
# inspector spell still carry over.
|
||||||
|
existing_pids = {
|
||||||
|
a.project_id
|
||||||
|
for a in CustomerAssignment.query.filter_by(user_id=customer.id).all()
|
||||||
|
}
|
||||||
|
for a in InspectorAssignment.query.filter_by(user_id=customer.id).all():
|
||||||
|
if a.project_id not in existing_pids:
|
||||||
|
db.session.add(CustomerAssignment(
|
||||||
|
user_id = customer.id,
|
||||||
|
project_id = a.project_id,
|
||||||
|
facility_id = None,
|
||||||
|
))
|
||||||
|
existing_pids.add(a.project_id)
|
||||||
|
|
||||||
|
# The stale rows for the role being left are kept on purpose: switching
|
||||||
|
# back restores the account's original scope, including any facility-level
|
||||||
|
# narrowing that the inspector side cannot express. They are inert while
|
||||||
|
# the other role is active — each scope helper reads only its own table.
|
||||||
|
|
||||||
|
customer.role = new_role
|
||||||
|
|
||||||
|
revoked = (
|
||||||
|
RefreshToken.query
|
||||||
|
.filter_by(user_id=customer.id, revoked=False)
|
||||||
|
.update({'revoked': True}, synchronize_session=False)
|
||||||
|
)
|
||||||
|
devices = (
|
||||||
|
DeviceToken.query
|
||||||
|
.filter_by(user_id=customer.id)
|
||||||
|
.delete(synchronize_session=False)
|
||||||
|
)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
logger.info('CUSTOMERS | switch_role | admin=%s customer=%s %s -> %s '
|
||||||
|
'tokens_revoked=%s devices_cleared=%s',
|
||||||
|
current_user.username, customer.username, old_role, new_role,
|
||||||
|
revoked, devices)
|
||||||
|
log_action(ACTION_UPDATE, 'User', customer.id, customer.username,
|
||||||
|
f'role switched {old_role} -> {new_role}; '
|
||||||
|
f'refresh_tokens_revoked={revoked}; devices_cleared={devices}')
|
||||||
|
|
||||||
|
msg = (f'{customer.display_name} is now a {customer.role_label}. '
|
||||||
|
f'Their contracts were carried across; any signed-in device must log in again.')
|
||||||
|
if widened:
|
||||||
|
msg += (' Note: facility-level limits do not exist for inspectors, so '
|
||||||
|
'this account now covers every facility in those contracts — '
|
||||||
|
'review the contract list below.')
|
||||||
|
flash(msg, 'warning' if widened else 'success')
|
||||||
|
return redirect(url_for('customers.manage', customer_id=customer.id))
|
||||||
|
|
||||||
|
|
||||||
# ── Toggle active ─────────────────────────────────────────────────────────────
|
# ── Toggle active ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@bp.route('/<int:customer_id>/toggle-active', methods=['POST'])
|
@bp.route('/<int:customer_id>/toggle-active', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
@supervisor_required
|
@supervisor_required
|
||||||
def toggle_active(customer_id):
|
def toggle_active(customer_id):
|
||||||
customer = db.session.get(User, customer_id)
|
customer, moved = _get_customer_or_redirect(customer_id)
|
||||||
if customer is None:
|
if moved:
|
||||||
abort(404)
|
return moved
|
||||||
if customer.role != 'customer':
|
|
||||||
flash('This action is only for customer accounts.', 'warning')
|
|
||||||
return redirect(url_for('customers.index'))
|
|
||||||
|
|
||||||
customer.active = not customer.active
|
customer.active = not customer.active
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|||||||
+106
-7
@@ -17,6 +17,16 @@ bp = Blueprint('dashboard', __name__)
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# Columns the dashboard actually reads off an issue row. The cards below need
|
||||||
|
# counts and buckets, never a hydrated Issue — loading the full entity pulls the
|
||||||
|
# description TEXT and three JSON photo columns for every open issue in scope,
|
||||||
|
# on every dashboard load, and registers each one in the identity map.
|
||||||
|
# A Row exposes the same attribute names, so the severity/handler tallies and
|
||||||
|
# sla_status() work against these unchanged.
|
||||||
|
_ISSUE_CARD_COLS = (Issue.id, Issue.severity, Issue.status,
|
||||||
|
Issue.reported_at, Issue.handler_type)
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/')
|
@bp.route('/')
|
||||||
@bp.route('/dashboard')
|
@bp.route('/dashboard')
|
||||||
@login_required
|
@login_required
|
||||||
@@ -24,11 +34,16 @@ def index():
|
|||||||
now = now_eastern()
|
now = now_eastern()
|
||||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
today_end = today_start + timedelta(days=1)
|
today_end = today_start + timedelta(days=1)
|
||||||
|
# MT-16 — Monday 00:00 of the current week, for the modern dashboard's
|
||||||
|
# "submitted this week" tile. Derived from today_start so it inherits
|
||||||
|
# now_eastern() rather than mixing in a second clock.
|
||||||
|
week_start = today_start - timedelta(days=today_start.weekday())
|
||||||
|
|
||||||
is_inspector = current_user.role == 'inspector'
|
is_inspector = current_user.is_inspector
|
||||||
is_privileged = current_user.role in ['admin', 'director']
|
is_privileged = current_user.role in ['admin', 'director']
|
||||||
is_customer = current_user.role == 'customer'
|
is_customer = current_user.role == 'customer'
|
||||||
is_project_manager = current_user.role == 'project_manager'
|
is_project_manager = current_user.role == 'project_manager'
|
||||||
|
is_auditor = current_user.role == 'auditor'
|
||||||
|
|
||||||
# Resolve facility scope
|
# Resolve facility scope
|
||||||
customer_facility_ids = get_customer_scope(current_user) # None for non-customers
|
customer_facility_ids = get_customer_scope(current_user) # None for non-customers
|
||||||
@@ -61,6 +76,14 @@ def index():
|
|||||||
Inspection.inspection_date < today_end,
|
Inspection.inspection_date < today_end,
|
||||||
).count()
|
).count()
|
||||||
|
|
||||||
|
# MT-16 — fully completed & submitted so far this week (Monday → now).
|
||||||
|
# Reuses base_q, so it inherits the same role scoping as every other tile.
|
||||||
|
submitted_this_week = base_q.filter(
|
||||||
|
Inspection.status == 'completed',
|
||||||
|
Inspection.inspection_date >= week_start,
|
||||||
|
Inspection.inspection_date < today_end,
|
||||||
|
).count()
|
||||||
|
|
||||||
# ── Open issues (inspector: all issues in contracted facilities) ───────
|
# ── Open issues (inspector: all issues in contracted facilities) ───────
|
||||||
open_issues_q = Issue.query.filter(Issue.status.in_(['open', 'in_progress']))
|
open_issues_q = Issue.query.filter(Issue.status.in_(['open', 'in_progress']))
|
||||||
if is_inspector:
|
if is_inspector:
|
||||||
@@ -87,7 +110,7 @@ def index():
|
|||||||
))
|
))
|
||||||
|
|
||||||
# Single query — derive count from the list to avoid hitting the DB twice
|
# Single query — derive count from the list to avoid hitting the DB twice
|
||||||
open_issues_all = open_issues_q.all()
|
open_issues_all = open_issues_q.with_entities(*_ISSUE_CARD_COLS).all()
|
||||||
open_issues = len(open_issues_all)
|
open_issues = len(open_issues_all)
|
||||||
severity_breakdown = {
|
severity_breakdown = {
|
||||||
'critical': sum(1 for i in open_issues_all if i.severity == 'critical'),
|
'critical': sum(1 for i in open_issues_all if i.severity == 'critical'),
|
||||||
@@ -162,9 +185,13 @@ def index():
|
|||||||
if not inspector_facility_ids:
|
if not inspector_facility_ids:
|
||||||
followup_q = followup_q.filter(False)
|
followup_q = followup_q.filter(False)
|
||||||
else:
|
else:
|
||||||
|
# OWNERSHIP, not authorship — see Inspection.follow_up_owned_by().
|
||||||
|
# An assigned follow-up lives on an inspection somebody else
|
||||||
|
# performed, so testing inspector_id made the card read 0 for the
|
||||||
|
# very person who had been asked to do the work.
|
||||||
followup_q = followup_q.filter(
|
followup_q = followup_q.filter(
|
||||||
Inspection.facility_id.in_(inspector_facility_ids),
|
Inspection.facility_id.in_(inspector_facility_ids),
|
||||||
Inspection.inspector_id == current_user.id,
|
Inspection.follow_up_owned_by(current_user.id),
|
||||||
)
|
)
|
||||||
elif is_customer:
|
elif is_customer:
|
||||||
if customer_facility_ids:
|
if customer_facility_ids:
|
||||||
@@ -209,7 +236,7 @@ def index():
|
|||||||
elif is_customer and not customer_facility_ids:
|
elif is_customer and not customer_facility_ids:
|
||||||
all_open_issues = []
|
all_open_issues = []
|
||||||
else:
|
else:
|
||||||
all_open_issues = sla_q.all()
|
all_open_issues = sla_q.with_entities(*_ISSUE_CARD_COLS).all()
|
||||||
sla_breached = sum(1 for i in all_open_issues if sla_status(i) == 'breached')
|
sla_breached = sum(1 for i in all_open_issues if sla_status(i) == 'breached')
|
||||||
sla_at_risk = sum(1 for i in all_open_issues if sla_status(i) == 'at_risk')
|
sla_at_risk = sum(1 for i in all_open_issues if sla_status(i) == 'at_risk')
|
||||||
|
|
||||||
@@ -281,6 +308,26 @@ def index():
|
|||||||
stale_q = stale_q.filter(False)
|
stale_q = stale_q.filter(False)
|
||||||
stale_in_progress = stale_q.count()
|
stale_in_progress = stale_q.count()
|
||||||
|
|
||||||
|
# ── In-progress inspections, all ages (MT-16) ─────────────────────────────
|
||||||
|
# stale_in_progress above counts only those older than 24h. The modern
|
||||||
|
# dashboard shows the full in-progress count as its own tile, so this is a
|
||||||
|
# separate query with the SAME role scoping rather than a reuse of stale_q.
|
||||||
|
inprog_q = Inspection.query.filter(Inspection.status == 'in_progress')
|
||||||
|
if is_inspector:
|
||||||
|
if not inspector_facility_ids:
|
||||||
|
inprog_q = inprog_q.filter(False)
|
||||||
|
else:
|
||||||
|
inprog_q = inprog_q.filter(
|
||||||
|
Inspection.facility_id.in_(inspector_facility_ids),
|
||||||
|
Inspection.inspector_id == current_user.id,
|
||||||
|
)
|
||||||
|
elif is_customer:
|
||||||
|
if customer_facility_ids:
|
||||||
|
inprog_q = inprog_q.filter(Inspection.facility_id.in_(customer_facility_ids))
|
||||||
|
else:
|
||||||
|
inprog_q = inprog_q.filter(False)
|
||||||
|
in_progress_total = inprog_q.count()
|
||||||
|
|
||||||
# ── Unassigned open issues ────────────────────────────────────────────────
|
# ── Unassigned open issues ────────────────────────────────────────────────
|
||||||
from app.models.facility import Area as _AreaU
|
from app.models.facility import Area as _AreaU
|
||||||
unassigned_q = Issue.query.outerjoin(_AreaU, Issue.area_id == _AreaU.id).filter(
|
unassigned_q = Issue.query.outerjoin(_AreaU, Issue.area_id == _AreaU.id).filter(
|
||||||
@@ -299,12 +346,12 @@ def index():
|
|||||||
unassigned_q = unassigned_q.filter(False) # not relevant for customers
|
unassigned_q = unassigned_q.filter(False) # not relevant for customers
|
||||||
unassigned_open = unassigned_q.count()
|
unassigned_open = unassigned_q.count()
|
||||||
|
|
||||||
# ── Inspector activity today (admin / director / PM only) ─────────────────
|
# ── Inspector activity today (admin / director / PM / auditor only) ───────
|
||||||
inspector_activity = []
|
inspector_activity = []
|
||||||
if is_privileged or is_project_manager:
|
if is_privileged or is_project_manager or is_auditor:
|
||||||
active_inspectors = (
|
active_inspectors = (
|
||||||
User.query
|
User.query
|
||||||
.filter_by(role='inspector', active=True)
|
.filter(User.role.in_(User.INSPECTOR_ROLES), User.active == True)
|
||||||
.order_by(User.full_name, User.username)
|
.order_by(User.full_name, User.username)
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
@@ -343,8 +390,60 @@ def index():
|
|||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ── Scheduled inspections (phase43): upcoming / overdue ───────────────────
|
||||||
|
# Plan-mode only: 'auto' schedules materialise themselves into the
|
||||||
|
# inspections list, so surfacing them here would double-report the work.
|
||||||
|
sched_upcoming = []
|
||||||
|
sched_overdue_count = 0
|
||||||
|
# MT-16 — the modern dashboard additionally shows the total number of active
|
||||||
|
# plans ("On Schedules") and offers Continue instead of a duplicate Start
|
||||||
|
# where an inspection is already underway for that plan.
|
||||||
|
sched_total = 0
|
||||||
|
sched_open_inspections = {}
|
||||||
|
if not is_customer:
|
||||||
|
from app.models.inspection_schedule import InspectionSchedule
|
||||||
|
_today = now_eastern().date()
|
||||||
|
_sq = InspectionSchedule.query.filter(
|
||||||
|
InspectionSchedule.active.is_(True),
|
||||||
|
InspectionSchedule.mode == 'plan',
|
||||||
|
)
|
||||||
|
if is_inspector:
|
||||||
|
_sq = _sq.filter(InspectionSchedule.inspector_id == current_user.id)
|
||||||
|
_all_sched = _sq.order_by(InspectionSchedule.next_run_at.asc()).all()
|
||||||
|
sched_overdue_count = sum(1 for s in _all_sched if s.is_overdue(_today))
|
||||||
|
# Upcoming = due today through the next 7 days (overdue shown separately)
|
||||||
|
sched_upcoming = [
|
||||||
|
s for s in _all_sched
|
||||||
|
if s.next_run_at and _today <= s.next_run_at.date() <= _today + timedelta(days=7)
|
||||||
|
][:8]
|
||||||
|
sched_total = len(_all_sched) # active plan-mode schedules
|
||||||
|
# {schedule_id: inspection_id} for plans with an inspection already in
|
||||||
|
# progress. MT's FK is Inspection.inspection_schedule_id (ST calls it
|
||||||
|
# scheduled_inspection_id). Ordered ascending so that when a plan somehow
|
||||||
|
# has more than one open inspection, the dict keeps the LOWEST id — the
|
||||||
|
# original, not a later duplicate.
|
||||||
|
_sched_ids = [s.id for s in sched_upcoming if s.id]
|
||||||
|
if _sched_ids:
|
||||||
|
_open_rows = (
|
||||||
|
Inspection.query
|
||||||
|
.filter(Inspection.inspection_schedule_id.in_(_sched_ids),
|
||||||
|
Inspection.status == 'in_progress')
|
||||||
|
.order_by(Inspection.id.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
sched_open_inspections = {
|
||||||
|
r.inspection_schedule_id: r.id for r in _open_rows
|
||||||
|
}
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
'dashboard.html',
|
'dashboard.html',
|
||||||
|
sched_upcoming = sched_upcoming,
|
||||||
|
sched_overdue_count = sched_overdue_count,
|
||||||
|
sched_total = sched_total,
|
||||||
|
sched_open_inspections = sched_open_inspections,
|
||||||
|
in_progress_total = in_progress_total,
|
||||||
|
submitted_this_week = submitted_this_week,
|
||||||
|
week_start_str = week_start.strftime('%Y-%m-%d'),
|
||||||
today_inspections = today_inspections,
|
today_inspections = today_inspections,
|
||||||
completed_today = completed_today,
|
completed_today = completed_today,
|
||||||
open_issues = open_issues,
|
open_issues = open_issues,
|
||||||
|
|||||||
+302
-12
@@ -6,7 +6,8 @@ from app.models.facility import Facility, Area
|
|||||||
from app.models.project import Project
|
from app.models.project import Project
|
||||||
from app.utils.forms import FacilityForm, AreaForm
|
from app.utils.forms import FacilityForm, AreaForm
|
||||||
from app.utils.decorators import supervisor_required, admin_required, project_manager_required
|
from app.utils.decorators import supervisor_required, admin_required, project_manager_required
|
||||||
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE
|
from app.utils.audit import (log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE,
|
||||||
|
ACTION_EXPORT)
|
||||||
from app.utils.scope import get_customer_scope, get_inspector_scope
|
from app.utils.scope import get_customer_scope, get_inspector_scope
|
||||||
from app.tenancy.gates import quota_soft_check
|
from app.tenancy.gates import quota_soft_check
|
||||||
|
|
||||||
@@ -22,7 +23,7 @@ def list_facilities():
|
|||||||
facilities = Facility.query.filter(
|
facilities = Facility.query.filter(
|
||||||
Facility.id.in_(cids), Facility.active == True
|
Facility.id.in_(cids), Facility.active == True
|
||||||
).order_by(Facility.name).all()
|
).order_by(Facility.name).all()
|
||||||
elif current_user.role == 'inspector':
|
elif current_user.is_inspector:
|
||||||
fids = get_inspector_scope(current_user) or []
|
fids = get_inspector_scope(current_user) or []
|
||||||
facilities = Facility.query.filter(
|
facilities = Facility.query.filter(
|
||||||
Facility.id.in_(fids), Facility.active == True
|
Facility.id.in_(fids), Facility.active == True
|
||||||
@@ -244,20 +245,70 @@ def delete_area(area_id):
|
|||||||
|
|
||||||
def _qr_scan_url(facility):
|
def _qr_scan_url(facility):
|
||||||
"""Absolute public scan URL, built from the current host (rule 64 pattern)."""
|
"""Absolute public scan URL, built from the current host (rule 64 pattern)."""
|
||||||
|
facility.ensure_qr_token()
|
||||||
return request.host_url.rstrip('/') + url_for('facility_qr.scan',
|
return request.host_url.rstrip('/') + url_for('facility_qr.scan',
|
||||||
token=facility.qr_token)
|
token=facility.qr_token)
|
||||||
|
|
||||||
|
|
||||||
|
def _facility_for_qr_or_403(facility_id):
|
||||||
|
"""Load a facility for a QR action, enforcing customer facility scope.
|
||||||
|
|
||||||
|
Customers may only touch QR codes for facilities they are assigned to; all
|
||||||
|
other roles have unrestricted QR access. Replaces the previous
|
||||||
|
@project_manager_required gate so a customer can print (and rotate) the
|
||||||
|
codes posted in their own building.
|
||||||
|
"""
|
||||||
|
facility = db.session.get(Facility, facility_id)
|
||||||
|
if facility is None:
|
||||||
|
abort(404)
|
||||||
|
# QR management is not an inspector task (matches qr_print_all/qr_export_pdf).
|
||||||
|
if current_user.is_inspector:
|
||||||
|
abort(403)
|
||||||
|
if current_user.role == 'customer':
|
||||||
|
cids = get_customer_scope(current_user) or []
|
||||||
|
if facility.id not in cids:
|
||||||
|
abort(403)
|
||||||
|
return facility
|
||||||
|
|
||||||
|
|
||||||
|
def _qr_png_bytes(url):
|
||||||
|
"""Return PNG bytes for a QR code encoding *url*."""
|
||||||
|
import io as _io
|
||||||
|
import qrcode
|
||||||
|
img = qrcode.make(url, box_size=10, border=2)
|
||||||
|
buf = _io.BytesIO()
|
||||||
|
img.save(buf, format='PNG')
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/<int:facility_id>/qr.png')
|
||||||
|
@login_required
|
||||||
|
def facility_qr_png(facility_id):
|
||||||
|
"""Return the facility's QR code as a PNG image.
|
||||||
|
|
||||||
|
The printable card renders inline SVG; this PNG endpoint exists for the
|
||||||
|
print-all grid and is the same image the PDF export embeds.
|
||||||
|
"""
|
||||||
|
facility = _facility_for_qr_or_403(facility_id)
|
||||||
|
|
||||||
|
created = not facility.qr_token
|
||||||
|
url = _qr_scan_url(facility)
|
||||||
|
if created:
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
from flask import Response
|
||||||
|
return Response(_qr_png_bytes(url), mimetype='image/png', headers={
|
||||||
|
'Cache-Control': 'private, max-age=3600',
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/<int:facility_id>/qr')
|
@bp.route('/<int:facility_id>/qr')
|
||||||
@login_required
|
@login_required
|
||||||
@project_manager_required
|
|
||||||
def qr_card(facility_id):
|
def qr_card(facility_id):
|
||||||
"""Printable QR card for one facility. Generates the token on first use."""
|
"""Printable QR card for one facility. Generates the token on first use."""
|
||||||
from app.utils.qr import qr_svg
|
from app.utils.qr import qr_svg
|
||||||
|
|
||||||
facility = db.session.get(Facility, facility_id)
|
facility = _facility_for_qr_or_403(facility_id)
|
||||||
if facility is None:
|
|
||||||
abort(404)
|
|
||||||
|
|
||||||
if not facility.qr_token:
|
if not facility.qr_token:
|
||||||
facility.ensure_qr_token()
|
facility.ensure_qr_token()
|
||||||
@@ -274,11 +325,19 @@ def qr_card(facility_id):
|
|||||||
|
|
||||||
@bp.route('/qr-sheet')
|
@bp.route('/qr-sheet')
|
||||||
@login_required
|
@login_required
|
||||||
@project_manager_required
|
|
||||||
def qr_sheet():
|
def qr_sheet():
|
||||||
"""Bulk print sheet — one labeled QR card per active facility."""
|
"""Bulk print sheet — one labeled QR card per active facility."""
|
||||||
from app.utils.qr import qr_svg
|
from app.utils.qr import qr_svg
|
||||||
|
|
||||||
|
if current_user.is_inspector:
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
if current_user.role == 'customer':
|
||||||
|
cids = get_customer_scope(current_user) or []
|
||||||
|
facilities = (Facility.query
|
||||||
|
.filter(Facility.id.in_(cids), Facility.active == True)
|
||||||
|
.order_by(Facility.name).all()) if cids else []
|
||||||
|
else:
|
||||||
facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all()
|
facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all()
|
||||||
|
|
||||||
generated = 0
|
generated = 0
|
||||||
@@ -297,14 +356,17 @@ def qr_sheet():
|
|||||||
|
|
||||||
@bp.route('/<int:facility_id>/qr/regenerate', methods=['POST'])
|
@bp.route('/<int:facility_id>/qr/regenerate', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
@supervisor_required
|
|
||||||
def regenerate_qr(facility_id):
|
def regenerate_qr(facility_id):
|
||||||
"""Rotate the QR token — invalidates every previously printed poster."""
|
"""Rotate the QR token — invalidates every previously printed poster.
|
||||||
|
|
||||||
|
Allowed for admin/director, and for customers on their own assigned
|
||||||
|
facilities. Project managers, auditors and inspectors cannot regenerate.
|
||||||
|
"""
|
||||||
import secrets
|
import secrets
|
||||||
|
|
||||||
facility = db.session.get(Facility, facility_id)
|
facility = _facility_for_qr_or_403(facility_id)
|
||||||
if facility is None:
|
if current_user.role not in ('admin', 'director', 'customer'):
|
||||||
abort(404)
|
abort(403)
|
||||||
|
|
||||||
facility.qr_token = secrets.token_urlsafe(32)
|
facility.qr_token = secrets.token_urlsafe(32)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
@@ -315,3 +377,231 @@ def regenerate_qr(facility_id):
|
|||||||
flash('QR code regenerated. Previously printed posters no longer work — '
|
flash('QR code regenerated. Previously printed posters no longer work — '
|
||||||
'print and post the new code.', 'success')
|
'print and post the new code.', 'success')
|
||||||
return redirect(url_for('facilities.qr_card', facility_id=facility_id))
|
return redirect(url_for('facilities.qr_card', facility_id=facility_id))
|
||||||
|
|
||||||
|
# ── Public Area QR codes (phase42) ────────────────────────────────────────────
|
||||||
|
# Mirrors the facility QR routes above, but scoped to a single area. Customer
|
||||||
|
# scope is enforced via the area's parent facility.
|
||||||
|
|
||||||
|
def _area_qr_scan_url(area):
|
||||||
|
"""Absolute public scan URL for an area, built from the current host."""
|
||||||
|
area.ensure_qr_token()
|
||||||
|
return request.host_url.rstrip('/') + url_for('facility_qr.area_scan',
|
||||||
|
token=area.qr_token)
|
||||||
|
|
||||||
|
|
||||||
|
def _area_for_qr_or_403(area_id):
|
||||||
|
"""Load an area for a QR action, enforcing customer facility scope."""
|
||||||
|
area = db.session.get(Area, area_id)
|
||||||
|
if area is None:
|
||||||
|
abort(404)
|
||||||
|
# QR management is not an inspector task (matches qr_print_all/qr_export_pdf).
|
||||||
|
if current_user.is_inspector:
|
||||||
|
abort(403)
|
||||||
|
if current_user.role == 'customer':
|
||||||
|
cids = get_customer_scope(current_user) or []
|
||||||
|
if area.facility_id not in cids:
|
||||||
|
abort(403)
|
||||||
|
return area
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/areas/<int:area_id>/qr.png')
|
||||||
|
@login_required
|
||||||
|
def area_qr_png(area_id):
|
||||||
|
"""Return the area's QR code as a PNG image."""
|
||||||
|
area = _area_for_qr_or_403(area_id)
|
||||||
|
|
||||||
|
created = not area.qr_token
|
||||||
|
url = _area_qr_scan_url(area)
|
||||||
|
if created:
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
from flask import Response
|
||||||
|
return Response(_qr_png_bytes(url), mimetype='image/png', headers={
|
||||||
|
'Cache-Control': 'private, max-age=3600',
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/areas/<int:area_id>/qr')
|
||||||
|
@login_required
|
||||||
|
def area_qr_card(area_id):
|
||||||
|
"""Printable page: area name + facility + QR + public URL + instructions."""
|
||||||
|
from app.utils.qr import qr_svg
|
||||||
|
|
||||||
|
area = _area_for_qr_or_403(area_id)
|
||||||
|
|
||||||
|
created = not area.qr_token
|
||||||
|
scan_url = _area_qr_scan_url(area)
|
||||||
|
if created:
|
||||||
|
db.session.commit()
|
||||||
|
logger.info('FACILITIES | area_qr_token_created | user=%s | area_id=%s',
|
||||||
|
current_user.username, area_id)
|
||||||
|
|
||||||
|
return render_template('facilities/area_qr.html',
|
||||||
|
area=area,
|
||||||
|
facility=area.facility,
|
||||||
|
scan_url=scan_url,
|
||||||
|
svg=qr_svg(scan_url))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/areas/<int:area_id>/qr/regenerate', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def regenerate_area_qr(area_id):
|
||||||
|
"""Rotate an area's QR token — invalidates every previously printed poster.
|
||||||
|
|
||||||
|
Allowed for admin/director, and for customers on their own assigned
|
||||||
|
facilities. Project managers, auditors and inspectors cannot regenerate.
|
||||||
|
"""
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
area = _area_for_qr_or_403(area_id)
|
||||||
|
if current_user.role not in ('admin', 'director', 'customer'):
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
area.qr_token = secrets.token_urlsafe(32)
|
||||||
|
db.session.commit()
|
||||||
|
logger.info('FACILITIES | area_qr_token_regenerated | user=%s | area_id=%s',
|
||||||
|
current_user.username, area_id)
|
||||||
|
log_action(ACTION_UPDATE, 'Area', area.id, area.name,
|
||||||
|
'QR token regenerated — previously printed QR posters are now invalid')
|
||||||
|
flash('QR code regenerated. Previously printed posters no longer work — '
|
||||||
|
'print and post the new code.', 'warning')
|
||||||
|
return redirect(url_for('facilities.area_qr_card', area_id=area.id))
|
||||||
|
|
||||||
|
|
||||||
|
# ── Bulk QR print / export (phase42) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
@bp.route('/qr/print-all')
|
||||||
|
@login_required
|
||||||
|
def qr_print_all():
|
||||||
|
"""Printable / selectable sheet of the QR codes the user can see.
|
||||||
|
|
||||||
|
Query params (all optional):
|
||||||
|
?contract_id=<id> — limit to one contract; narrows the facility dropdown
|
||||||
|
?facility_id=<id> — limit to a single facility
|
||||||
|
?include_areas=1 — also render each facility's per-area QR codes
|
||||||
|
|
||||||
|
Inspectors have no QR management (403); customers are scoped to their
|
||||||
|
assigned facilities; managers see all active facilities.
|
||||||
|
"""
|
||||||
|
if current_user.is_inspector:
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
contract_id = request.args.get('contract_id', type=int)
|
||||||
|
facility_id = request.args.get('facility_id', type=int)
|
||||||
|
include_areas = request.args.get('include_areas') in ('1', 'true', 'on')
|
||||||
|
|
||||||
|
# Facilities in the viewer's scope.
|
||||||
|
if current_user.role == 'customer':
|
||||||
|
fids = get_customer_scope(current_user) or []
|
||||||
|
scoped = Facility.query.filter(Facility.id.in_(fids), Facility.active == True)
|
||||||
|
else:
|
||||||
|
scoped = Facility.query.filter(Facility.active == True)
|
||||||
|
scoped_facilities = scoped.order_by(Facility.name).all()
|
||||||
|
|
||||||
|
# Contract dropdown — only contracts present among the scoped facilities.
|
||||||
|
contract_ids = {f.project_id for f in scoped_facilities if f.project_id}
|
||||||
|
contracts = (Project.query
|
||||||
|
.filter(Project.id.in_(contract_ids))
|
||||||
|
.order_by(Project.name).all()) if contract_ids else []
|
||||||
|
|
||||||
|
# Facility dropdown — narrowed by the selected contract.
|
||||||
|
facility_options = [f for f in scoped_facilities
|
||||||
|
if not contract_id or f.project_id == contract_id]
|
||||||
|
|
||||||
|
# The rendered grid — apply the contract + facility filters.
|
||||||
|
grid_facilities = facility_options
|
||||||
|
if facility_id:
|
||||||
|
grid_facilities = [f for f in grid_facilities if f.id == facility_id]
|
||||||
|
|
||||||
|
# Ensure every rendered facility (and area, if requested) has a token so
|
||||||
|
# its qr.png renders; collect areas keyed by facility id.
|
||||||
|
changed = False
|
||||||
|
areas_by_facility = {}
|
||||||
|
for f in grid_facilities:
|
||||||
|
if not f.qr_token:
|
||||||
|
f.ensure_qr_token()
|
||||||
|
changed = True
|
||||||
|
if include_areas:
|
||||||
|
fa = f.areas.order_by(Area.name).all()
|
||||||
|
for a in fa:
|
||||||
|
if not a.qr_token:
|
||||||
|
a.ensure_qr_token()
|
||||||
|
changed = True
|
||||||
|
areas_by_facility[f.id] = fa
|
||||||
|
if changed:
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
selected_contract = db.session.get(Project, contract_id) if contract_id else None
|
||||||
|
return render_template('facilities/qr_print_all.html',
|
||||||
|
facilities=grid_facilities,
|
||||||
|
areas_by_facility=areas_by_facility,
|
||||||
|
include_areas=include_areas,
|
||||||
|
contracts=contracts,
|
||||||
|
facility_options=facility_options,
|
||||||
|
selected_contract=selected_contract,
|
||||||
|
selected_contract_id=contract_id,
|
||||||
|
selected_facility_id=facility_id)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/qr/export-pdf', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def qr_export_pdf():
|
||||||
|
"""Export the selected facility + area QR codes to a single PDF.
|
||||||
|
|
||||||
|
Selection arrives as repeated `facility_ids` / `area_ids` form fields.
|
||||||
|
Scope is enforced per-id via the same helpers as the QR pages, so a
|
||||||
|
customer can never export a code outside their assigned facilities.
|
||||||
|
"""
|
||||||
|
if current_user.is_inspector:
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
facility_ids = request.form.getlist('facility_ids', type=int)
|
||||||
|
area_ids = request.form.getlist('area_ids', type=int)
|
||||||
|
|
||||||
|
if not facility_ids and not area_ids:
|
||||||
|
flash('Select at least one QR code to export.', 'warning')
|
||||||
|
return redirect(request.referrer or url_for('facilities.qr_print_all'))
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for fid in facility_ids:
|
||||||
|
facility = _facility_for_qr_or_403(fid) # 403 if out of scope
|
||||||
|
url = _qr_scan_url(facility)
|
||||||
|
items.append({
|
||||||
|
'title': facility.name,
|
||||||
|
'subtitle': facility.project.name if facility.project else None,
|
||||||
|
'caption': 'Facility · Report a problem & view recent quality',
|
||||||
|
'png': _qr_png_bytes(url),
|
||||||
|
'_sort': ((facility.name or '').lower(), 0, ''),
|
||||||
|
})
|
||||||
|
for aid in area_ids:
|
||||||
|
area = _area_for_qr_or_403(aid) # 403 if out of scope
|
||||||
|
url = _area_qr_scan_url(area)
|
||||||
|
fac_name = area.facility.name if area.facility else ''
|
||||||
|
items.append({
|
||||||
|
'title': area.name,
|
||||||
|
'subtitle': fac_name or None,
|
||||||
|
'caption': 'Area · Report a problem & view recent quality',
|
||||||
|
'png': _qr_png_bytes(url),
|
||||||
|
'_sort': (fac_name.lower(), 1, (area.name or '').lower()),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Persist any tokens minted by ensure_qr_token() above.
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Group each facility with its own areas: facility card first, then areas.
|
||||||
|
items.sort(key=lambda x: x['_sort'])
|
||||||
|
|
||||||
|
from app.utils.pdf_export import generate_qr_codes_pdf
|
||||||
|
summary = f'{len(facility_ids)} facilit' + ('y' if len(facility_ids) == 1 else 'ies')
|
||||||
|
summary += f', {len(area_ids)} area' + ('' if len(area_ids) == 1 else 's')
|
||||||
|
pdf_bytes = generate_qr_codes_pdf(items, filter_summary=summary)
|
||||||
|
|
||||||
|
logger.info('FACILITIES | qr_export_pdf | user=%s | facilities=%s | areas=%s',
|
||||||
|
current_user.username, len(facility_ids), len(area_ids))
|
||||||
|
log_action(ACTION_EXPORT, 'Facility', 0, 'QR Codes',
|
||||||
|
f'exported {len(facility_ids)} facility + {len(area_ids)} area QR codes to PDF')
|
||||||
|
|
||||||
|
from flask import Response
|
||||||
|
return Response(pdf_bytes, mimetype='application/pdf', headers={
|
||||||
|
'Content-Disposition': 'attachment; filename="qr_codes.pdf"',
|
||||||
|
})
|
||||||
|
|||||||
+238
-39
@@ -5,7 +5,13 @@ Public facility QR scan page (phase38).
|
|||||||
|
|
||||||
`GET /f/<token>` — public, tokenized, NO login (same authorization model as
|
`GET /f/<token>` — public, tokenized, NO login (same authorization model as
|
||||||
vendor work orders, rule 89: the unguessable token IS the credential). Shows a
|
vendor work orders, rule 89: the unguessable token IS the credential). Shows a
|
||||||
read-only, counts-and-scores-only snapshot of one facility:
|
read-only, counts-and-scores-only snapshot of one facility.
|
||||||
|
|
||||||
|
`GET /f/area/<token>` (phase42) — the same page scoped to a single area, so a
|
||||||
|
code posted inside one restroom reports on that restroom. Metrics are scoped by
|
||||||
|
`Inspection.area_id` / `Issue.area_id`.
|
||||||
|
|
||||||
|
Both pages show:
|
||||||
|
|
||||||
* summary stats (90 days): completed inspections, average score,
|
* summary stats (90 days): completed inspections, average score,
|
||||||
resolved issues, last inspection date
|
resolved issues, last inspection date
|
||||||
@@ -25,7 +31,8 @@ In multi-tenant mode the printed URL is built from the tenant's own domain
|
|||||||
import logging
|
import logging
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
from flask import Blueprint, render_template, redirect, request, url_for, abort
|
from flask import (Blueprint, render_template, redirect, request, url_for,
|
||||||
|
abort, flash)
|
||||||
from flask_login import current_user
|
from flask_login import current_user
|
||||||
from sqlalchemy import func, or_
|
from sqlalchemy import func, or_
|
||||||
|
|
||||||
@@ -33,6 +40,8 @@ from app import db, limiter
|
|||||||
from app.models.facility import Facility, Area
|
from app.models.facility import Facility, Area
|
||||||
from app.models.inspection import Inspection
|
from app.models.inspection import Inspection
|
||||||
from app.models.issue import Issue
|
from app.models.issue import Issue
|
||||||
|
from app.utils.forms import PublicIssueReportForm
|
||||||
|
from app.utils.notifications import notify_by_matrix
|
||||||
from app.utils.sla import sla_status
|
from app.utils.sla import sla_status
|
||||||
from app.utils.scope import get_customer_scope, get_inspector_scope
|
from app.utils.scope import get_customer_scope, get_inspector_scope
|
||||||
from app.utils.time_utils import now_eastern
|
from app.utils.time_utils import now_eastern
|
||||||
@@ -43,6 +52,72 @@ bp = Blueprint('facility_qr', __name__, url_prefix='/f')
|
|||||||
|
|
||||||
SEVERITY_ORDER = ('critical', 'high', 'medium', 'low')
|
SEVERITY_ORDER = ('critical', 'high', 'medium', 'low')
|
||||||
|
|
||||||
|
#: Maximum number of photos an occupant may attach to a public report.
|
||||||
|
MAX_REPORT_PHOTOS = 5
|
||||||
|
|
||||||
|
|
||||||
|
def _save_report_photos(file_list):
|
||||||
|
"""Save up to MAX_REPORT_PHOTOS uploaded photos from a public report.
|
||||||
|
|
||||||
|
Returns (photo_path, extra_paths) where photo_path is the primary evidence
|
||||||
|
photo (or None) and extra_paths is a list of the remaining paths (or None).
|
||||||
|
Splitting this way mirrors the Issue photo model: the first photo lives in
|
||||||
|
`photo_path`, the rest in `mobile_photo_paths` so they all render together
|
||||||
|
under "Photo Evidence" on the web (rule 44 — never `result_photos`).
|
||||||
|
|
||||||
|
Writes go through `_save_photo`, which validates magic bytes and routes to
|
||||||
|
the active storage backend (see app/utils/storage.py).
|
||||||
|
"""
|
||||||
|
from app.routes.inspections import _save_photo
|
||||||
|
saved = []
|
||||||
|
for f in (file_list or [])[:MAX_REPORT_PHOTOS]:
|
||||||
|
path = _save_photo(f, subfolder='issue_photos')
|
||||||
|
if path:
|
||||||
|
saved.append(path)
|
||||||
|
photo_path = saved[0] if saved else None
|
||||||
|
extra_paths = saved[1:] if len(saved) > 1 else None
|
||||||
|
return photo_path, extra_paths
|
||||||
|
|
||||||
|
|
||||||
|
def _facility_by_token_or_404(token):
|
||||||
|
"""Resolve an ACTIVE facility from its QR token, else 404."""
|
||||||
|
if not token:
|
||||||
|
abort(404)
|
||||||
|
facility = Facility.query.filter_by(qr_token=token).first()
|
||||||
|
if facility is None or not facility.active:
|
||||||
|
abort(404)
|
||||||
|
return facility
|
||||||
|
|
||||||
|
|
||||||
|
def _area_by_token_or_404(token):
|
||||||
|
"""Resolve an area (and its ACTIVE facility) from the area's QR token."""
|
||||||
|
if not token:
|
||||||
|
abort(404)
|
||||||
|
area = Area.query.filter_by(qr_token=token).first()
|
||||||
|
if area is None:
|
||||||
|
abort(404)
|
||||||
|
facility = db.session.get(Facility, area.facility_id)
|
||||||
|
if facility is None or not facility.active:
|
||||||
|
abort(404)
|
||||||
|
return area, facility
|
||||||
|
|
||||||
|
|
||||||
|
def _build_report_description(form, prefix):
|
||||||
|
"""Fold optional reporter identity + location into the issue description.
|
||||||
|
|
||||||
|
The public reporter is not a User, so `reported_by` stays NULL and this is
|
||||||
|
the only place their name/contact is recorded.
|
||||||
|
"""
|
||||||
|
parts = [prefix]
|
||||||
|
if form.area_label.data:
|
||||||
|
parts.append(f'Location: {form.area_label.data.strip()}')
|
||||||
|
reporter_bits = [b for b in (form.reporter_name.data, form.reporter_contact.data) if b]
|
||||||
|
if reporter_bits:
|
||||||
|
parts.append('Reporter: ' + ' — '.join(b.strip() for b in reporter_bits))
|
||||||
|
parts.append('')
|
||||||
|
parts.append(form.description.data.strip())
|
||||||
|
return '\n'.join(parts)
|
||||||
|
|
||||||
|
|
||||||
def _can_view_full(facility):
|
def _can_view_full(facility):
|
||||||
"""True when the logged-in scanner's role scope covers this facility."""
|
"""True when the logged-in scanner's role scope covers this facility."""
|
||||||
@@ -50,34 +125,44 @@ def _can_view_full(facility):
|
|||||||
return False
|
return False
|
||||||
if current_user.role in ('admin', 'director', 'project_manager'):
|
if current_user.role in ('admin', 'director', 'project_manager'):
|
||||||
return True
|
return True
|
||||||
if current_user.role == 'inspector':
|
if current_user.is_inspector:
|
||||||
return facility.id in (get_inspector_scope(current_user) or [])
|
return facility.id in (get_inspector_scope(current_user) or [])
|
||||||
if current_user.role == 'customer':
|
if current_user.role == 'customer':
|
||||||
return facility.id in (get_customer_scope(current_user) or [])
|
return facility.id in (get_customer_scope(current_user) or [])
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/<token>')
|
def _build_snapshot(facility, area=None):
|
||||||
@limiter.limit('60 per hour')
|
"""Assemble the public snapshot for a facility, or for one area of it.
|
||||||
def scan(token):
|
|
||||||
facility = Facility.query.filter_by(qr_token=token).first()
|
|
||||||
if facility is None or not facility.active:
|
|
||||||
abort(404)
|
|
||||||
|
|
||||||
|
When `area` is given every metric is scoped to that area via
|
||||||
|
`Inspection.area_id` / `Issue.area_id`; otherwise the facility-wide math is
|
||||||
|
used, unchanged from phase38. Returns the template context (minus `token`).
|
||||||
|
"""
|
||||||
now = now_eastern()
|
now = now_eastern()
|
||||||
d30 = now - timedelta(days=30)
|
d30 = now - timedelta(days=30)
|
||||||
d60 = now - timedelta(days=60)
|
d60 = now - timedelta(days=60)
|
||||||
d90 = now - timedelta(days=90)
|
d90 = now - timedelta(days=90)
|
||||||
|
|
||||||
|
if area is not None:
|
||||||
|
insp_scope = (Inspection.area_id == area.id,)
|
||||||
|
issue_q = Issue.query.filter(Issue.area_id == area.id)
|
||||||
|
else:
|
||||||
|
insp_scope = (Inspection.facility_id == facility.id,)
|
||||||
|
issue_q = (Issue.query
|
||||||
|
.outerjoin(Area, Issue.area_id == Area.id)
|
||||||
|
.filter(or_(Issue.facility_id == facility.id,
|
||||||
|
Area.facility_id == facility.id)))
|
||||||
|
|
||||||
completed = Inspection.query.filter(
|
completed = Inspection.query.filter(
|
||||||
Inspection.facility_id == facility.id,
|
*insp_scope,
|
||||||
Inspection.status == 'completed',
|
Inspection.status == 'completed',
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── Summary stats (90 days) ───────────────────────────────────────────
|
# ── Summary stats (90 days) ───────────────────────────────────────────
|
||||||
total_90 = completed.filter(Inspection.inspection_date >= d90).count()
|
total_90 = completed.filter(Inspection.inspection_date >= d90).count()
|
||||||
avg_90 = db.session.query(func.avg(Inspection.overall_score)).filter(
|
avg_90 = db.session.query(func.avg(Inspection.overall_score)).filter(
|
||||||
Inspection.facility_id == facility.id,
|
*insp_scope,
|
||||||
Inspection.status == 'completed',
|
Inspection.status == 'completed',
|
||||||
Inspection.inspection_date >= d90,
|
Inspection.inspection_date >= d90,
|
||||||
Inspection.overall_score.isnot(None),
|
Inspection.overall_score.isnot(None),
|
||||||
@@ -91,7 +176,7 @@ def scan(token):
|
|||||||
# ── Score trend: last 30 days vs prior 30 (mirrors send_score_alerts) ─
|
# ── Score trend: last 30 days vs prior 30 (mirrors send_score_alerts) ─
|
||||||
def _avg_between(start, end):
|
def _avg_between(start, end):
|
||||||
return db.session.query(func.avg(Inspection.overall_score)).filter(
|
return db.session.query(func.avg(Inspection.overall_score)).filter(
|
||||||
Inspection.facility_id == facility.id,
|
*insp_scope,
|
||||||
Inspection.status == 'completed',
|
Inspection.status == 'completed',
|
||||||
Inspection.overall_score.isnot(None),
|
Inspection.overall_score.isnot(None),
|
||||||
Inspection.inspection_date >= start,
|
Inspection.inspection_date >= start,
|
||||||
@@ -104,11 +189,6 @@ def scan(token):
|
|||||||
if (avg_cur is not None and avg_prior is not None) else None
|
if (avg_cur is not None and avg_prior is not None) else None
|
||||||
|
|
||||||
# ── Open issues: counts by severity + SLA state (counts only) ─────────
|
# ── Open issues: counts by severity + SLA state (counts only) ─────────
|
||||||
issue_q = (Issue.query
|
|
||||||
.outerjoin(Area, Issue.area_id == Area.id)
|
|
||||||
.filter(or_(Issue.facility_id == facility.id,
|
|
||||||
Area.facility_id == facility.id)))
|
|
||||||
|
|
||||||
open_issues = issue_q.filter(
|
open_issues = issue_q.filter(
|
||||||
Issue.status.in_(('open', 'in_progress'))).all()
|
Issue.status.in_(('open', 'in_progress'))).all()
|
||||||
severity_counts = {s: 0 for s in SEVERITY_ORDER}
|
severity_counts = {s: 0 for s in SEVERITY_ORDER}
|
||||||
@@ -130,13 +210,9 @@ def scan(token):
|
|||||||
Issue.resolved_at >= d90,
|
Issue.resolved_at >= d90,
|
||||||
).count()
|
).count()
|
||||||
|
|
||||||
logger.info('FACILITY QR SCAN | facility_id=%s | authenticated=%s',
|
return dict(
|
||||||
facility.id, current_user.is_authenticated)
|
|
||||||
|
|
||||||
return render_template(
|
|
||||||
'facility_qr/view.html',
|
|
||||||
token = token,
|
|
||||||
facility = facility,
|
facility = facility,
|
||||||
|
area = area,
|
||||||
contract = facility.project,
|
contract = facility.project,
|
||||||
total_90 = total_90,
|
total_90 = total_90,
|
||||||
avg_90 = float(avg_90) if avg_90 is not None else None,
|
avg_90 = float(avg_90) if avg_90 is not None else None,
|
||||||
@@ -157,6 +233,39 @@ def scan(token):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/<token>')
|
||||||
|
@limiter.limit('60 per hour')
|
||||||
|
def scan(token):
|
||||||
|
facility = _facility_by_token_or_404(token)
|
||||||
|
|
||||||
|
logger.info('FACILITY QR SCAN | facility_id=%s | authenticated=%s',
|
||||||
|
facility.id, current_user.is_authenticated)
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'facility_qr/view.html',
|
||||||
|
token = token,
|
||||||
|
form = PublicIssueReportForm(),
|
||||||
|
**_build_snapshot(facility),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/area/<token>')
|
||||||
|
@limiter.limit('60 per hour')
|
||||||
|
def area_scan(token):
|
||||||
|
"""Public snapshot for a single area (phase42)."""
|
||||||
|
area, facility = _area_by_token_or_404(token)
|
||||||
|
|
||||||
|
logger.info('AREA QR SCAN | area_id=%s | facility_id=%s | authenticated=%s',
|
||||||
|
area.id, facility.id, current_user.is_authenticated)
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'facility_qr/area.html',
|
||||||
|
token = token,
|
||||||
|
form = PublicIssueReportForm(),
|
||||||
|
**_build_snapshot(facility, area=area),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/<token>/report', methods=['POST'])
|
@bp.route('/<token>/report', methods=['POST'])
|
||||||
@limiter.limit('5 per hour')
|
@limiter.limit('5 per hour')
|
||||||
def report(token):
|
def report(token):
|
||||||
@@ -165,42 +274,132 @@ def report(token):
|
|||||||
No login required — the unguessable QR token is the sole authorization.
|
No login required — the unguessable QR token is the sole authorization.
|
||||||
A honeypot field silently rejects bot submissions. Creates an Issue with
|
A honeypot field silently rejects bot submissions. Creates an Issue with
|
||||||
reported_by=None so staff know it came from a public form.
|
reported_by=None so staff know it came from a public form.
|
||||||
|
|
||||||
|
phase42: accepts up to 5 photos, an optional location label, and optional
|
||||||
|
reporter identity, on top of the description + severity taken previously.
|
||||||
"""
|
"""
|
||||||
facility = Facility.query.filter_by(qr_token=token).first()
|
facility = _facility_by_token_or_404(token)
|
||||||
if facility is None or not facility.active:
|
form = PublicIssueReportForm()
|
||||||
abort(404)
|
|
||||||
|
|
||||||
# Honeypot — bots fill this field, humans leave it blank
|
# Honeypot — bots fill this field, humans leave it blank
|
||||||
if request.form.get('website', '').strip():
|
if form.website.data:
|
||||||
logger.warning('FACILITY QR REPORT | honeypot triggered | facility_id=%s', facility.id)
|
logger.warning('FACILITY QR REPORT | honeypot triggered | facility_id=%s', facility.id)
|
||||||
return redirect(url_for('facility_qr.scan', token=token) + '?reported=1')
|
return redirect(url_for('facility_qr.scan', token=token) + '?reported=1')
|
||||||
|
|
||||||
description = request.form.get('description', '').strip()
|
if not form.validate_on_submit():
|
||||||
severity = request.form.get('severity', 'medium')
|
# Re-render with validation errors and the snapshot intact.
|
||||||
|
return render_template(
|
||||||
|
'facility_qr/view.html',
|
||||||
|
token = token,
|
||||||
|
form = form,
|
||||||
|
**_build_snapshot(facility),
|
||||||
|
), 400
|
||||||
|
|
||||||
if not description:
|
severity = form.severity.data or 'medium'
|
||||||
return redirect(url_for('facility_qr.scan', token=token))
|
|
||||||
if severity not in ('low', 'medium', 'high'):
|
if severity not in ('low', 'medium', 'high'):
|
||||||
severity = 'medium'
|
severity = 'medium'
|
||||||
|
|
||||||
|
photo_path, extra_photos = _save_report_photos(form.photos.data)
|
||||||
|
description = _build_report_description(form, '[Reported via facility QR code]')
|
||||||
|
|
||||||
issue = Issue(
|
issue = Issue(
|
||||||
facility_id = facility.id,
|
facility_id = facility.id,
|
||||||
|
area_id = None,
|
||||||
severity = severity,
|
severity = severity,
|
||||||
description = description,
|
description = description,
|
||||||
|
photo_path = photo_path,
|
||||||
|
mobile_photo_paths = extra_photos,
|
||||||
status = 'open',
|
status = 'open',
|
||||||
reported_by = None, # anonymous public submission
|
reported_by = None, # anonymous public submission
|
||||||
)
|
)
|
||||||
db.session.add(issue)
|
db.session.add(issue)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
logger.info('FACILITY QR REPORT | facility_id=%s issue_id=%s severity=%s',
|
_photo_count = (1 if photo_path else 0) + (len(extra_photos) if extra_photos else 0)
|
||||||
facility.id, issue.id, severity)
|
logger.info('FACILITY QR REPORT | facility_id=%s issue_id=%s severity=%s photos=%s',
|
||||||
|
facility.id, issue.id, severity, _photo_count)
|
||||||
|
|
||||||
# Notify staff via the notification matrix (same event as Issues → Create)
|
# Notify staff via the notification matrix (same event as Issues → Create).
|
||||||
try:
|
# NOTE: title/body are REQUIRED positional args. The phase38 call omitted
|
||||||
from app.utils.notifications import notify_by_matrix
|
# them, so every public QR report raised TypeError into the except below and
|
||||||
notify_by_matrix('issue_created', issue_id=issue.id, facility_id=facility.id)
|
# nobody was ever notified — see MT3_DEPLOY.md §1.
|
||||||
except Exception as exc:
|
_snippet = form.description.data.strip()
|
||||||
logger.error('FACILITY QR REPORT | notify_failed | err=%s', exc)
|
notify_by_matrix(
|
||||||
|
event_type = 'issue_created',
|
||||||
|
title = f'New Issue #{issue.id} at {facility.name} (QR report)',
|
||||||
|
body = (
|
||||||
|
f'A problem was reported at {facility.name} via the facility QR code. '
|
||||||
|
f'Description: {_snippet[:120]}{"…" if len(_snippet) > 120 else ""}'
|
||||||
|
),
|
||||||
|
link = url_for('issues.view', issue_id=issue.id),
|
||||||
|
issue_id = issue.id,
|
||||||
|
facility_id = facility.id,
|
||||||
|
)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
return redirect(url_for('facility_qr.scan', token=token) + '?reported=1')
|
return redirect(url_for('facility_qr.scan', token=token) + '?reported=1')
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/area/<token>/report', methods=['POST'])
|
||||||
|
@limiter.limit('5 per hour')
|
||||||
|
def area_report(token):
|
||||||
|
"""Public occupant issue report submitted from an area QR page (phase42).
|
||||||
|
|
||||||
|
The area is known from the token, so `area_id` is set directly — staff see
|
||||||
|
exactly which room the report came from without the occupant describing it.
|
||||||
|
"""
|
||||||
|
area, facility = _area_by_token_or_404(token)
|
||||||
|
form = PublicIssueReportForm()
|
||||||
|
|
||||||
|
if form.website.data:
|
||||||
|
logger.warning('AREA QR REPORT | honeypot triggered | area_id=%s', area.id)
|
||||||
|
return redirect(url_for('facility_qr.area_scan', token=token) + '?reported=1')
|
||||||
|
|
||||||
|
if not form.validate_on_submit():
|
||||||
|
return render_template(
|
||||||
|
'facility_qr/area.html',
|
||||||
|
token = token,
|
||||||
|
form = form,
|
||||||
|
**_build_snapshot(facility, area=area),
|
||||||
|
), 400
|
||||||
|
|
||||||
|
severity = form.severity.data or 'medium'
|
||||||
|
if severity not in ('low', 'medium', 'high'):
|
||||||
|
severity = 'medium'
|
||||||
|
|
||||||
|
photo_path, extra_photos = _save_report_photos(form.photos.data)
|
||||||
|
description = _build_report_description(
|
||||||
|
form, f'[Reported via area QR code — {area.name}]')
|
||||||
|
|
||||||
|
issue = Issue(
|
||||||
|
facility_id = facility.id,
|
||||||
|
area_id = area.id,
|
||||||
|
severity = severity,
|
||||||
|
description = description,
|
||||||
|
photo_path = photo_path,
|
||||||
|
mobile_photo_paths = extra_photos,
|
||||||
|
status = 'open',
|
||||||
|
reported_by = None, # anonymous public submission
|
||||||
|
)
|
||||||
|
db.session.add(issue)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
_photo_count = (1 if photo_path else 0) + (len(extra_photos) if extra_photos else 0)
|
||||||
|
logger.info('AREA QR REPORT | area_id=%s facility_id=%s issue_id=%s severity=%s photos=%s',
|
||||||
|
area.id, facility.id, issue.id, severity, _photo_count)
|
||||||
|
|
||||||
|
_snippet = form.description.data.strip()
|
||||||
|
notify_by_matrix(
|
||||||
|
event_type = 'issue_created',
|
||||||
|
title = f'New Issue #{issue.id} at {facility.name} — {area.name} (QR report)',
|
||||||
|
body = (
|
||||||
|
f'A problem was reported in {area.name} at {facility.name} via the area '
|
||||||
|
f'QR code. Description: {_snippet[:120]}{"…" if len(_snippet) > 120 else ""}'
|
||||||
|
),
|
||||||
|
link = url_for('issues.view', issue_id=issue.id),
|
||||||
|
issue_id = issue.id,
|
||||||
|
facility_id = facility.id,
|
||||||
|
)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return redirect(url_for('facility_qr.area_scan', token=token) + '?reported=1')
|
||||||
|
|||||||
+1009
-44
File diff suppressed because it is too large
Load Diff
+693
-75
File diff suppressed because it is too large
Load Diff
+594
-60
@@ -6,7 +6,7 @@ from flask import (Blueprint, render_template, redirect, url_for,
|
|||||||
flash, request, current_app, jsonify, abort, Response)
|
flash, request, current_app, jsonify, abort, Response)
|
||||||
from flask_login import login_required, current_user
|
from flask_login import login_required, current_user
|
||||||
from app import db
|
from app import db
|
||||||
from app.models.issue import Issue, IssueComment, IssueFollower
|
from app.models.issue import Issue, IssueComment, IssueFollower, IssueLink
|
||||||
from app.models.facility import Facility, Area
|
from app.models.facility import Facility, Area
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.notification import (
|
from app.models.notification import (
|
||||||
@@ -15,7 +15,8 @@ from app.models.notification import (
|
|||||||
EVENT_CUSTOMER_ISSUE_UPDATED,
|
EVENT_CUSTOMER_ISSUE_UPDATED,
|
||||||
)
|
)
|
||||||
from app.utils.forms import IssueForm, IssueUpdateForm
|
from app.utils.forms import IssueForm, IssueUpdateForm
|
||||||
from app.utils.decorators import supervisor_required, project_manager_required
|
from app.utils.decorators import (supervisor_required, project_manager_required,
|
||||||
|
issue_manager_required, return_url)
|
||||||
from app.utils.notifications import notify, notify_customers_for_facility, notify_by_matrix
|
from app.utils.notifications import notify, notify_customers_for_facility, notify_by_matrix
|
||||||
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE, ACTION_EXPORT
|
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE, ACTION_EXPORT
|
||||||
from app.tenancy.gates import quota_soft_check
|
from app.tenancy.gates import quota_soft_check
|
||||||
@@ -70,6 +71,60 @@ class _SLAFilteredPage:
|
|||||||
return iter([1])
|
return iter([1])
|
||||||
|
|
||||||
|
|
||||||
|
# ── Issue read access ─────────────────────────────────────────────────────────
|
||||||
|
# One definition of "may this person open this issue", used by the detail view,
|
||||||
|
# by the linked-issues panel, and by the link picker's search. They must not
|
||||||
|
# drift: the picker is what a person searches, but the panel is what actually
|
||||||
|
# renders another issue's description, and the POST is the real boundary.
|
||||||
|
#
|
||||||
|
# All three are WITHIN one tenant. Cross-tenant isolation is not their job and
|
||||||
|
# never can be — RoutingSession has already bound the session to g.tenant's
|
||||||
|
# database, so an id from another tenant simply does not resolve here.
|
||||||
|
|
||||||
|
def _viewer_facility_scope(user):
|
||||||
|
"""Facility ids this user is confined to, or None when unrestricted.
|
||||||
|
|
||||||
|
Returns a LIST (possibly empty) for the two scoped role groups and None for
|
||||||
|
everyone else. Empty list and None mean opposite things — [] is "no access
|
||||||
|
to anything", None is "no restriction" — so callers must test `is None`
|
||||||
|
rather than truthiness (CLAUDE.md rule 57's failure mode).
|
||||||
|
"""
|
||||||
|
if user.is_inspector: # rule 87 — never role == 'inspector'
|
||||||
|
return get_inspector_scope(user) or []
|
||||||
|
if user.role == 'customer': # rule 99 — capability check, exact match
|
||||||
|
return get_customer_scope(user) or []
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _issue_in_scope(issue, scope_ids):
|
||||||
|
"""Whether one issue falls inside an already-resolved facility scope.
|
||||||
|
|
||||||
|
Takes the scope rather than the user so a caller filtering a list of issues
|
||||||
|
resolves it once instead of re-querying the assignment tables per row.
|
||||||
|
"""
|
||||||
|
if scope_ids is None:
|
||||||
|
return True
|
||||||
|
facility = issue.resolved_facility
|
||||||
|
return facility is not None and facility.id in scope_ids
|
||||||
|
|
||||||
|
|
||||||
|
def _issue_readable_by(issue, user):
|
||||||
|
"""Single-issue convenience wrapper around the two helpers above."""
|
||||||
|
return _issue_in_scope(issue, _viewer_facility_scope(user))
|
||||||
|
|
||||||
|
|
||||||
|
def _assignee_label(user):
|
||||||
|
"""Dropdown label for an assignee.
|
||||||
|
|
||||||
|
MT-15 — external (customer / third-party) inspectors are assignable just
|
||||||
|
like the tenant's own crew, but are suffixed so whoever is triaging can see
|
||||||
|
at a glance that the work is going outside the company. Display only; the
|
||||||
|
stored value is still the user id.
|
||||||
|
"""
|
||||||
|
return (f'{user.display_name} (Customer)'
|
||||||
|
if user.is_external_inspector else user.display_name)
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/export-list-pdf')
|
@bp.route('/export-list-pdf')
|
||||||
@login_required
|
@login_required
|
||||||
def export_list_pdf():
|
def export_list_pdf():
|
||||||
@@ -85,7 +140,7 @@ def export_list_pdf():
|
|||||||
.order_by(Issue.reported_at.desc())
|
.order_by(Issue.reported_at.desc())
|
||||||
)
|
)
|
||||||
|
|
||||||
if current_user.role == 'inspector':
|
if current_user.is_inspector:
|
||||||
fids = get_inspector_scope(current_user)
|
fids = get_inspector_scope(current_user)
|
||||||
if not fids:
|
if not fids:
|
||||||
q = q.filter(False)
|
q = q.filter(False)
|
||||||
@@ -133,13 +188,14 @@ def export_list_pdf():
|
|||||||
date_to_filter = ''
|
date_to_filter = ''
|
||||||
if contract_filter.isdigit():
|
if contract_filter.isdigit():
|
||||||
_contract_fids = [
|
_contract_fids = [
|
||||||
f.id for f in Facility.query.filter_by(project_id=int(contract_filter)).all()
|
fid for (fid,) in db.session.query(Facility.id)
|
||||||
|
.filter(Facility.project_id == int(contract_filter)).all()
|
||||||
]
|
]
|
||||||
q = q.filter(db.or_(
|
q = q.filter(db.or_(
|
||||||
Issue.facility_id.in_(_contract_fids),
|
Issue.facility_id.in_(_contract_fids),
|
||||||
Area.facility_id.in_(_contract_fids),
|
Area.facility_id.in_(_contract_fids),
|
||||||
)) if _contract_fids else q.filter(False)
|
)) if _contract_fids else q.filter(False)
|
||||||
if facility_filter:
|
if facility_filter.isdigit():
|
||||||
fid = int(facility_filter)
|
fid = int(facility_filter)
|
||||||
q = q.filter(db.or_(Issue.facility_id == fid, Area.facility_id == fid))
|
q = q.filter(db.or_(Issue.facility_id == fid, Area.facility_id == fid))
|
||||||
if reporter_filter.isdigit():
|
if reporter_filter.isdigit():
|
||||||
@@ -168,7 +224,7 @@ def export_list_pdf():
|
|||||||
p = db.session.get(Project, int(contract_filter))
|
p = db.session.get(Project, int(contract_filter))
|
||||||
if p:
|
if p:
|
||||||
filter_parts.append(f'Contract: {p.name}')
|
filter_parts.append(f'Contract: {p.name}')
|
||||||
if facility_filter:
|
if facility_filter.isdigit():
|
||||||
f = db.session.get(Facility, int(facility_filter))
|
f = db.session.get(Facility, int(facility_filter))
|
||||||
if f:
|
if f:
|
||||||
filter_parts.append(f'Facility: {f.name}')
|
filter_parts.append(f'Facility: {f.name}')
|
||||||
@@ -210,7 +266,7 @@ def index():
|
|||||||
.order_by(Issue.reported_at.desc())
|
.order_by(Issue.reported_at.desc())
|
||||||
)
|
)
|
||||||
|
|
||||||
if current_user.role == 'inspector':
|
if current_user.is_inspector:
|
||||||
fids = get_inspector_scope(current_user)
|
fids = get_inspector_scope(current_user)
|
||||||
if not fids:
|
if not fids:
|
||||||
q = q.filter(False)
|
q = q.filter(False)
|
||||||
@@ -266,7 +322,8 @@ def index():
|
|||||||
date_to_filter = ''
|
date_to_filter = ''
|
||||||
if contract_filter.isdigit():
|
if contract_filter.isdigit():
|
||||||
_contract_fids = [
|
_contract_fids = [
|
||||||
f.id for f in Facility.query.filter_by(project_id=int(contract_filter)).all()
|
fid for (fid,) in db.session.query(Facility.id)
|
||||||
|
.filter(Facility.project_id == int(contract_filter)).all()
|
||||||
]
|
]
|
||||||
q = q.filter(db.or_(
|
q = q.filter(db.or_(
|
||||||
Issue.facility_id.in_(_contract_fids),
|
Issue.facility_id.in_(_contract_fids),
|
||||||
@@ -274,7 +331,7 @@ def index():
|
|||||||
)) if _contract_fids else q.filter(False)
|
)) if _contract_fids else q.filter(False)
|
||||||
if reporter_filter.isdigit():
|
if reporter_filter.isdigit():
|
||||||
q = q.filter(Issue.reported_by == int(reporter_filter))
|
q = q.filter(Issue.reported_by == int(reporter_filter))
|
||||||
if facility_filter:
|
if facility_filter.isdigit():
|
||||||
fid = int(facility_filter)
|
fid = int(facility_filter)
|
||||||
q = q.filter(
|
q = q.filter(
|
||||||
db.or_(
|
db.or_(
|
||||||
@@ -312,13 +369,14 @@ def index():
|
|||||||
# can render the following badge and inline unfollow button without an
|
# can render the following badge and inline unfollow button without an
|
||||||
# additional query per row.
|
# additional query per row.
|
||||||
followed_ids = {
|
followed_ids = {
|
||||||
f.issue_id
|
iid for (iid,) in
|
||||||
for f in IssueFollower.query.filter_by(user_id=current_user.id).all()
|
db.session.query(IssueFollower.issue_id)
|
||||||
|
.filter(IssueFollower.user_id == current_user.id).all()
|
||||||
}
|
}
|
||||||
|
|
||||||
# Facilities for the filter dropdown — scoped for inspectors/customers,
|
# Facilities for the filter dropdown — scoped for inspectors/customers,
|
||||||
# then narrowed to the selected contract when contract_filter is active.
|
# then narrowed to the selected contract when contract_filter is active.
|
||||||
if current_user.role == 'inspector':
|
if current_user.is_inspector:
|
||||||
fids = get_inspector_scope(current_user) or []
|
fids = get_inspector_scope(current_user) or []
|
||||||
_fq = Facility.query.filter(Facility.id.in_(fids), Facility.active == True)
|
_fq = Facility.query.filter(Facility.id.in_(fids), Facility.active == True)
|
||||||
elif current_user.role == 'customer':
|
elif current_user.role == 'customer':
|
||||||
@@ -346,7 +404,8 @@ def index():
|
|||||||
|
|
||||||
# Staff for quick-assign dropdown — same roles as the full issue form
|
# Staff for quick-assign dropdown — same roles as the full issue form
|
||||||
staff = User.query.filter(
|
staff = User.query.filter(
|
||||||
User.role.in_(['admin', 'director', 'inspector']), User.active == True
|
User.role.in_(['director', 'inspector', 'external_inspector', 'auditor']),
|
||||||
|
User.active == True
|
||||||
).order_by(User.username).all()
|
).order_by(User.username).all()
|
||||||
|
|
||||||
# Reporters dropdown — users who have actually filed at least one issue
|
# Reporters dropdown — users who have actually filed at least one issue
|
||||||
@@ -384,18 +443,14 @@ def view(issue_id):
|
|||||||
if issue is None:
|
if issue is None:
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
if current_user.role == 'inspector':
|
# Scope gate — see _issue_readable_by(). This was two inline blocks that the
|
||||||
fids = get_inspector_scope(current_user)
|
# linked-issues panel would have had to reproduce a third time; it is now
|
||||||
facility = issue.resolved_facility
|
# one definition so the panel cannot end up more permissive than the page.
|
||||||
if not fids or not facility or facility.id not in fids:
|
if not _issue_readable_by(issue, current_user):
|
||||||
flash('Access denied.', 'danger')
|
flash('Access denied.', 'danger')
|
||||||
return redirect(url_for('issues.index'))
|
return redirect(url_for('issues.index'))
|
||||||
|
|
||||||
if current_user.role == 'customer':
|
if current_user.role == 'customer':
|
||||||
cids = get_customer_scope(current_user) or []
|
|
||||||
facility = issue.resolved_facility
|
|
||||||
if not facility or facility.id not in cids:
|
|
||||||
flash('Access denied.', 'danger')
|
|
||||||
return redirect(url_for('issues.index'))
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
# Customers may only add a comment, and only on issues they follow or reported
|
# Customers may only add a comment, and only on issues they follow or reported
|
||||||
can_comment = (issue.is_followed_by(current_user) or issue.reported_by == current_user.id)
|
can_comment = (issue.is_followed_by(current_user) or issue.reported_by == current_user.id)
|
||||||
@@ -404,7 +459,7 @@ def view(issue_id):
|
|||||||
comment_body = request.form.get('update_notes', '').strip()
|
comment_body = request.form.get('update_notes', '').strip()
|
||||||
if not comment_body:
|
if not comment_body:
|
||||||
flash('Comment cannot be empty.', 'warning')
|
flash('Comment cannot be empty.', 'warning')
|
||||||
return redirect(url_for('issues.view', issue_id=issue_id))
|
return redirect(_view_url(issue_id))
|
||||||
comment = IssueComment(
|
comment = IssueComment(
|
||||||
issue_id=issue.id,
|
issue_id=issue.id,
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
@@ -418,11 +473,20 @@ def view(issue_id):
|
|||||||
f'#{issue.id}',
|
f'#{issue.id}',
|
||||||
'customer comment added')
|
'customer comment added')
|
||||||
flash('Comment posted.', 'success')
|
flash('Comment posted.', 'success')
|
||||||
return redirect(url_for('issues.view', issue_id=issue_id))
|
return redirect(_view_url(issue_id))
|
||||||
|
|
||||||
form = IssueUpdateForm(obj=issue)
|
form = IssueUpdateForm(obj=issue)
|
||||||
staff = User.query.filter(User.role.in_(['admin', 'director', 'inspector'])).order_by(User.username).all()
|
staff = User.query.filter(User.role.in_(['director', 'inspector', 'external_inspector', 'auditor'])).order_by(User.username).all()
|
||||||
form.assigned_to.choices = [(0, '— Unassigned —')] + [(u.id, u.display_name) for u in staff]
|
# Preserve any pre-existing assignee who is no longer in the assignable set
|
||||||
|
# (e.g. an admin assigned before admins were removed from the dropdown) so
|
||||||
|
# saving the form doesn't silently unassign them.
|
||||||
|
if issue.assigned_to and issue.assigned_to not in [u.id for u in staff]:
|
||||||
|
current_assignee = db.session.get(User, issue.assigned_to)
|
||||||
|
if current_assignee:
|
||||||
|
staff.append(current_assignee)
|
||||||
|
form.assigned_to.choices = [(0, '— Unassigned —')] + [
|
||||||
|
(u.id, _assignee_label(u)) for u in staff
|
||||||
|
]
|
||||||
form.status.data = form.status.data or issue.status
|
form.status.data = form.status.data or issue.status
|
||||||
|
|
||||||
if form.validate_on_submit():
|
if form.validate_on_submit():
|
||||||
@@ -431,7 +495,7 @@ def view(issue_id):
|
|||||||
|
|
||||||
issue.status = form.status.data
|
issue.status = form.status.data
|
||||||
|
|
||||||
if current_user.role in ['admin', 'director']:
|
if current_user.role in ['admin', 'director', 'auditor']:
|
||||||
issue.assigned_to = form.assigned_to.data or None
|
issue.assigned_to = form.assigned_to.data or None
|
||||||
|
|
||||||
if form.status.data == 'resolved' and not issue.resolved_at:
|
if form.status.data == 'resolved' and not issue.resolved_at:
|
||||||
@@ -449,13 +513,24 @@ def view(issue_id):
|
|||||||
issue.result_notes = form.result_notes.data or None
|
issue.result_notes = form.result_notes.data or None
|
||||||
|
|
||||||
# Vendor / contractor assignment — admin, director, project_manager only
|
# Vendor / contractor assignment — admin, director, project_manager only
|
||||||
if current_user.role in ('admin', 'director', 'project_manager'):
|
if current_user.role in ('admin', 'director', 'project_manager', 'auditor'):
|
||||||
issue.vendor_name = form.vendor_name.data.strip() or None
|
issue.vendor_name = form.vendor_name.data.strip() or None
|
||||||
issue.vendor_contact = form.vendor_contact.data.strip() or None
|
issue.vendor_contact = form.vendor_contact.data.strip() or None
|
||||||
issue.vendor_notes = form.vendor_notes.data.strip() or None
|
issue.vendor_notes = form.vendor_notes.data.strip() or None
|
||||||
|
|
||||||
# Handler type (phase39)
|
# Handler type (phase39; NOT NULL since phase44)
|
||||||
ht = form.handler_type.data or None
|
# Coerce empty/unknown to 'internal' explicitly. This is NOT
|
||||||
|
# preventing a crash: handler_type carries a Python-side
|
||||||
|
# default='internal', and SQLAlchemy applies a column default when
|
||||||
|
# the attribute is None — so the previous `or None` would have been
|
||||||
|
# silently rescued to 'internal' rather than raising. The point is to
|
||||||
|
# not depend on that fairly obscure behaviour, and to state the
|
||||||
|
# intended value at the point of assignment. The membership check
|
||||||
|
# also backstops a crafted POST, though SelectField.pre_validate
|
||||||
|
# already rejects out-of-choice values.
|
||||||
|
ht = form.handler_type.data or 'internal'
|
||||||
|
if ht not in ('internal', 'facility', 'vendor'):
|
||||||
|
ht = 'internal'
|
||||||
issue.handler_type = ht
|
issue.handler_type = ht
|
||||||
if ht == 'facility':
|
if ht == 'facility':
|
||||||
issue.facility_handler_name = form.facility_handler_name.data.strip() or None
|
issue.facility_handler_name = form.facility_handler_name.data.strip() or None
|
||||||
@@ -466,6 +541,13 @@ def view(issue_id):
|
|||||||
issue.facility_handler_contact = None
|
issue.facility_handler_contact = None
|
||||||
issue.facility_handler_notes = None
|
issue.facility_handler_notes = None
|
||||||
|
|
||||||
|
# Janitorial staff handler (phase44). Written unconditionally, the
|
||||||
|
# same way vendor_* above is: the work-order dispatch route also
|
||||||
|
# writes vendor_name, so clearing non-active handler fields here
|
||||||
|
# would discard data set elsewhere.
|
||||||
|
issue.internal_handler_name = (form.internal_handler_name.data or '').strip() or None
|
||||||
|
issue.internal_handler_contact = (form.internal_handler_contact.data or '').strip() or None
|
||||||
|
|
||||||
from app.routes.inspections import _save_photo
|
from app.routes.inspections import _save_photo
|
||||||
new_photos = []
|
new_photos = []
|
||||||
for file_obj in request.files.getlist('result_photos'):
|
for file_obj in request.files.getlist('result_photos'):
|
||||||
@@ -625,10 +707,16 @@ def view(issue_id):
|
|||||||
f'#{issue.id} in {issue.area.name if issue.area else issue.resolved_facility.name if issue.resolved_facility else '—'}',
|
f'#{issue.id} in {issue.area.name if issue.area else issue.resolved_facility.name if issue.resolved_facility else '—'}',
|
||||||
f'status={issue.status}; assigned_to={issue.assigned_to}')
|
f'status={issue.status}; assigned_to={issue.assigned_to}')
|
||||||
flash('Issue updated.', 'success')
|
flash('Issue updated.', 'success')
|
||||||
return redirect(url_for('issues.view', issue_id=issue_id))
|
return redirect(_view_url(issue_id))
|
||||||
|
|
||||||
is_following = issue.is_followed_by(current_user)
|
is_following = issue.is_followed_by(current_user)
|
||||||
if current_user.role == 'customer':
|
# TEMPORARY (Aug 2026) — COMMENTS_VISIBLE_TO_ALL lifts the phase22
|
||||||
|
# restriction so customers see every comment on the issue, not only the
|
||||||
|
# ones ticked "Share with customer". is_customer_visible is still recorded
|
||||||
|
# on every comment, so setting the flag back to false restores the old
|
||||||
|
# filtering with nothing to repair. See config.py.
|
||||||
|
comments_open = current_app.config.get('COMMENTS_VISIBLE_TO_ALL', False)
|
||||||
|
if current_user.role == 'customer' and not comments_open:
|
||||||
comments = (issue.comments
|
comments = (issue.comments
|
||||||
.filter_by(is_customer_visible=True)
|
.filter_by(is_customer_visible=True)
|
||||||
.order_by(IssueComment.created_at.asc()).all())
|
.order_by(IssueComment.created_at.asc()).all())
|
||||||
@@ -638,7 +726,210 @@ def view(issue_id):
|
|||||||
issue=issue,
|
issue=issue,
|
||||||
form=form,
|
form=form,
|
||||||
comments=comments,
|
comments=comments,
|
||||||
is_following=is_following)
|
comments_open=comments_open,
|
||||||
|
is_following=is_following,
|
||||||
|
# Already filtered to links whose far end this viewer
|
||||||
|
# may open — see _readable_links().
|
||||||
|
issue_links=_readable_links(issue, current_user),
|
||||||
|
link_types=IssueLink.TYPE_CHOICES,
|
||||||
|
can_manage_links=_can_manage_links(issue, current_user))
|
||||||
|
|
||||||
|
|
||||||
|
# ── Issue links ───────────────────────────────────────────────────────────────
|
||||||
|
# Connect a duplicate to its original, or two issues about the same thing, so
|
||||||
|
# whoever picks one up can reach the other. Links are purely navigational: they
|
||||||
|
# never touch status, SLA, assignee or followers on either issue.
|
||||||
|
|
||||||
|
def _can_manage_links(issue, user):
|
||||||
|
"""Who may add or remove a link on this issue.
|
||||||
|
|
||||||
|
Deliberately the SAME set as the page's `can_edit` (the Update Issue panel):
|
||||||
|
admin / director / auditor, or the person the issue is assigned to. Keeping
|
||||||
|
the two identical means the panel's buttons and this gate cannot disagree —
|
||||||
|
the alternative is a second, slightly different rule that nobody remembers.
|
||||||
|
Widening it (to project_manager, or to the reporter) is a one-line change
|
||||||
|
here, but change `can_edit` in issues/view.html at the same time.
|
||||||
|
"""
|
||||||
|
return (user.role in ('admin', 'director', 'auditor')
|
||||||
|
or issue.assigned_to == user.id)
|
||||||
|
|
||||||
|
|
||||||
|
def _readable_links(issue, user):
|
||||||
|
"""Links on this issue whose FAR END the viewer may also open.
|
||||||
|
|
||||||
|
A link is a pointer to another issue's id, description and facility, so an
|
||||||
|
unfiltered panel would let a customer read an issue at a facility they have
|
||||||
|
no assignment to simply because one of our staff linked it. The scope is
|
||||||
|
resolved once for the whole list rather than per row.
|
||||||
|
|
||||||
|
Returns a list of (link, other_issue, label) ready for the template.
|
||||||
|
"""
|
||||||
|
scope = _viewer_facility_scope(user)
|
||||||
|
visible = []
|
||||||
|
for link in issue.all_links():
|
||||||
|
other = link.other_issue(issue.id)
|
||||||
|
if other is None or not _issue_in_scope(other, scope):
|
||||||
|
continue
|
||||||
|
visible.append((link, other, link.label_for(issue.id)))
|
||||||
|
return visible
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/<int:issue_id>/links', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def add_link(issue_id):
|
||||||
|
"""Link this issue to another one."""
|
||||||
|
issue = db.session.get(Issue, issue_id)
|
||||||
|
if issue is None:
|
||||||
|
abort(404)
|
||||||
|
if not _issue_readable_by(issue, current_user):
|
||||||
|
abort(403)
|
||||||
|
if not _can_manage_links(issue, current_user):
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
link_type = request.form.get('link_type', '')
|
||||||
|
if link_type not in (IssueLink.TYPE_DUPLICATE, IssueLink.TYPE_RELATED):
|
||||||
|
flash('Choose how the two issues are related.', 'warning')
|
||||||
|
return redirect(_view_url(issue_id))
|
||||||
|
|
||||||
|
raw_target = (request.form.get('linked_issue_id') or '').strip().lstrip('#')
|
||||||
|
if not raw_target.isdigit():
|
||||||
|
flash('Enter the number of the issue to link, e.g. 412.', 'warning')
|
||||||
|
return redirect(_view_url(issue_id))
|
||||||
|
target_id = int(raw_target)
|
||||||
|
|
||||||
|
if target_id == issue.id:
|
||||||
|
flash('An issue cannot be linked to itself.', 'warning')
|
||||||
|
return redirect(_view_url(issue_id))
|
||||||
|
|
||||||
|
target = db.session.get(Issue, target_id)
|
||||||
|
# A 404 and a 403 are the same message here on purpose: whether an issue
|
||||||
|
# outside your scope EXISTS is not something the link box should confirm.
|
||||||
|
if target is None or not _issue_readable_by(target, current_user):
|
||||||
|
flash(f'Issue #{target_id} was not found.', 'warning')
|
||||||
|
return redirect(_view_url(issue_id))
|
||||||
|
|
||||||
|
if IssueLink.exists_between(issue.id, target.id):
|
||||||
|
flash(f'Issue #{issue.id} and #{target.id} are already linked.', 'info')
|
||||||
|
return redirect(_view_url(issue_id))
|
||||||
|
|
||||||
|
link = IssueLink(
|
||||||
|
issue_id = issue.id,
|
||||||
|
linked_issue_id = target.id,
|
||||||
|
link_type = link_type,
|
||||||
|
created_by = current_user.id,
|
||||||
|
)
|
||||||
|
db.session.add(link)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
log_action(ACTION_UPDATE, 'Issue', issue.id, f'#{issue.id}',
|
||||||
|
f'linked to #{target.id} as {link_type}')
|
||||||
|
current_app.logger.info(
|
||||||
|
'ISSUE LINK | issue_id=%s | linked_issue_id=%s | type=%s | user=%s',
|
||||||
|
issue.id, target.id, link_type, current_user.username,
|
||||||
|
)
|
||||||
|
flash(f'Issue #{issue.id} is now linked to #{target.id}.', 'success')
|
||||||
|
return redirect(_view_url(issue_id))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/<int:issue_id>/links/<int:link_id>/delete', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def remove_link(issue_id, link_id):
|
||||||
|
"""Remove a link. Either end of it may do this."""
|
||||||
|
issue = db.session.get(Issue, issue_id)
|
||||||
|
if issue is None:
|
||||||
|
abort(404)
|
||||||
|
if not _issue_readable_by(issue, current_user):
|
||||||
|
abort(403)
|
||||||
|
if not _can_manage_links(issue, current_user):
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
link = db.session.get(IssueLink, link_id)
|
||||||
|
# The link must actually touch THIS issue. Without the check, anyone able to
|
||||||
|
# manage links on any one issue could delete a link between two others by
|
||||||
|
# posting its id here.
|
||||||
|
if link is None or issue.id not in (link.issue_id, link.linked_issue_id):
|
||||||
|
flash('That link no longer exists.', 'info')
|
||||||
|
return redirect(_view_url(issue_id))
|
||||||
|
|
||||||
|
other_id = link.linked_issue_id if link.issue_id == issue.id else link.issue_id
|
||||||
|
db.session.delete(link)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
log_action(ACTION_UPDATE, 'Issue', issue.id, f'#{issue.id}',
|
||||||
|
f'unlinked from #{other_id}')
|
||||||
|
current_app.logger.info(
|
||||||
|
'ISSUE UNLINK | issue_id=%s | linked_issue_id=%s | user=%s',
|
||||||
|
issue.id, other_id, current_user.username,
|
||||||
|
)
|
||||||
|
flash(f'Removed the link to issue #{other_id}.', 'info')
|
||||||
|
return redirect(_view_url(issue_id))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/<int:issue_id>/link-search')
|
||||||
|
@login_required
|
||||||
|
def link_search(issue_id):
|
||||||
|
"""JSON candidates for the link picker.
|
||||||
|
|
||||||
|
Scoped exactly like the issue list, so an inspector or customer can only
|
||||||
|
find issues they could already open — searching must not become a way to
|
||||||
|
enumerate another contract's issues. The results are a convenience; the POST
|
||||||
|
in add_link() re-checks access and is the real boundary.
|
||||||
|
"""
|
||||||
|
issue = db.session.get(Issue, issue_id)
|
||||||
|
if issue is None:
|
||||||
|
abort(404)
|
||||||
|
if not _issue_readable_by(issue, current_user):
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
term = (request.args.get('q') or '').strip().lstrip('#')
|
||||||
|
if len(term) < 1:
|
||||||
|
return jsonify({'results': []})
|
||||||
|
|
||||||
|
q = (
|
||||||
|
Issue.query
|
||||||
|
.outerjoin(Area, Issue.area_id == Area.id)
|
||||||
|
.options(joinedload(Issue.facility), contains_eager(Issue.area))
|
||||||
|
.filter(Issue.id != issue.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
scope = _viewer_facility_scope(current_user)
|
||||||
|
if scope is not None:
|
||||||
|
if not scope:
|
||||||
|
return jsonify({'results': []})
|
||||||
|
q = q.filter(db.or_(
|
||||||
|
Issue.facility_id.in_(scope),
|
||||||
|
db.and_(Issue.area_id.isnot(None), Area.facility_id.in_(scope)),
|
||||||
|
))
|
||||||
|
|
||||||
|
# Exclude issues already linked in either direction — offering them only
|
||||||
|
# produces an "already linked" flash.
|
||||||
|
linked_ids = {other.id for _l, other, _lbl in _readable_links(issue, current_user)}
|
||||||
|
if linked_ids:
|
||||||
|
q = q.filter(Issue.id.notin_(linked_ids))
|
||||||
|
|
||||||
|
if term.isdigit():
|
||||||
|
# A number is almost always an issue number, so match the id first and
|
||||||
|
# fall back to the description for things like "Room 204".
|
||||||
|
q = q.filter(db.or_(Issue.id == int(term),
|
||||||
|
Issue.description.ilike(f'%{term}%')))
|
||||||
|
else:
|
||||||
|
q = q.filter(Issue.description.ilike(f'%{term}%'))
|
||||||
|
|
||||||
|
matches = q.order_by(Issue.reported_at.desc()).limit(10).all()
|
||||||
|
|
||||||
|
return jsonify({'results': [
|
||||||
|
{
|
||||||
|
'id': i.id,
|
||||||
|
'description': (i.description or '')[:110],
|
||||||
|
'status': (i.status or '').replace('_', ' ').title(),
|
||||||
|
'severity': (i.severity or '').title(),
|
||||||
|
'location': (i.area.name if i.area
|
||||||
|
else i.resolved_facility.name if i.resolved_facility
|
||||||
|
else '—'),
|
||||||
|
'reported_at': i.reported_at.strftime('%Y-%m-%d') if i.reported_at else '',
|
||||||
|
}
|
||||||
|
for i in matches
|
||||||
|
]})
|
||||||
|
|
||||||
|
|
||||||
# ── Follow ────────────────────────────────────────────────────────────────────
|
# ── Follow ────────────────────────────────────────────────────────────────────
|
||||||
@@ -660,7 +951,7 @@ def follow(issue_id):
|
|||||||
flash('You are now following this issue and will receive notifications for any updates.', 'success')
|
flash('You are now following this issue and will receive notifications for any updates.', 'success')
|
||||||
else:
|
else:
|
||||||
flash('You are already following this issue.', 'info')
|
flash('You are already following this issue.', 'info')
|
||||||
return redirect(url_for('issues.view', issue_id=issue_id))
|
return redirect(_view_url(issue_id))
|
||||||
|
|
||||||
|
|
||||||
# ── Unfollow ──────────────────────────────────────────────────────────────────
|
# ── Unfollow ──────────────────────────────────────────────────────────────────
|
||||||
@@ -695,7 +986,7 @@ def unfollow(issue_id):
|
|||||||
@login_required
|
@login_required
|
||||||
@quota_soft_check('issues')
|
@quota_soft_check('issues')
|
||||||
def create():
|
def create():
|
||||||
if current_user.role not in ('admin', 'director', 'customer'):
|
if current_user.role not in ('admin', 'director', 'customer', 'auditor'):
|
||||||
abort(403)
|
abort(403)
|
||||||
|
|
||||||
from app.models.project import Project, CustomerAssignment
|
from app.models.project import Project, CustomerAssignment
|
||||||
@@ -717,10 +1008,12 @@ def create():
|
|||||||
else:
|
else:
|
||||||
facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all()
|
facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all()
|
||||||
projects = Project.query.filter_by(active=True).order_by(Project.name).all()
|
projects = Project.query.filter_by(active=True).order_by(Project.name).all()
|
||||||
staff = User.query.filter(User.role.in_(['admin', 'director', 'inspector'])).order_by(User.username).all()
|
staff = User.query.filter(User.role.in_(['director', 'inspector', 'external_inspector', 'auditor'])).order_by(User.username).all()
|
||||||
|
|
||||||
form.facility_id.choices = [(f.id, f.name) for f in facilities]
|
form.facility_id.choices = [(f.id, f.name) for f in facilities]
|
||||||
form.assigned_to.choices = [(0, '— Unassigned —')] + [(u.id, u.display_name) for u in staff]
|
form.assigned_to.choices = [(0, '— Unassigned —')] + [
|
||||||
|
(u.id, _assignee_label(u)) for u in staff
|
||||||
|
]
|
||||||
|
|
||||||
# On POST validation error: identify which contract the submitted facility
|
# On POST validation error: identify which contract the submitted facility
|
||||||
# belongs to so the contract selector can be restored on re-render.
|
# belongs to so the contract selector can be restored on re-render.
|
||||||
@@ -744,6 +1037,25 @@ def create():
|
|||||||
reported_at = now_eastern(),
|
reported_at = now_eastern(),
|
||||||
reported_by = current_user.id,
|
reported_by = current_user.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# "Handled By" — staff only; customer-created issues stay internal.
|
||||||
|
# (phase44) Previously the create form carried no handler fields at all,
|
||||||
|
# so a handler chosen here was silently discarded and had to be re-entered
|
||||||
|
# on the update form.
|
||||||
|
if current_user.role != 'customer':
|
||||||
|
handler = form.handler_type.data or 'internal'
|
||||||
|
if handler not in ('internal', 'facility', 'vendor'):
|
||||||
|
handler = 'internal'
|
||||||
|
issue.handler_type = handler
|
||||||
|
issue.facility_handler_name = (form.facility_handler_name.data or '').strip() or None
|
||||||
|
issue.facility_handler_contact = (form.facility_handler_contact.data or '').strip() or None
|
||||||
|
issue.facility_handler_notes = (form.facility_handler_notes.data or '').strip() or None
|
||||||
|
issue.vendor_name = (form.vendor_name.data or '').strip() or None
|
||||||
|
issue.vendor_contact = (form.vendor_contact.data or '').strip() or None
|
||||||
|
issue.vendor_notes = (form.vendor_notes.data or '').strip() or None
|
||||||
|
issue.internal_handler_name = (form.internal_handler_name.data or '').strip() or None
|
||||||
|
issue.internal_handler_contact = (form.internal_handler_contact.data or '').strip() or None
|
||||||
|
|
||||||
db.session.add(issue)
|
db.session.add(issue)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
current_app.logger.info(
|
current_app.logger.info(
|
||||||
@@ -793,17 +1105,18 @@ def create():
|
|||||||
)
|
)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash('Issue created.', 'success')
|
flash('Issue created.', 'success')
|
||||||
return redirect(url_for('issues.index'))
|
return redirect(return_url(url_for('issues.index')))
|
||||||
|
|
||||||
return render_template('issues/form.html', form=form, title='Log New Issue',
|
return render_template('issues/form.html', form=form, title='Log New Issue',
|
||||||
projects=projects, selected_project_id=selected_project_id)
|
projects=projects, selected_project_id=selected_project_id,
|
||||||
|
issue_handler_descriptions=Issue.HANDLER_DESCRIPTIONS)
|
||||||
|
|
||||||
|
|
||||||
# ── Supervisor verify resolved issue ─────────────────────────────────────────
|
# ── Supervisor verify resolved issue ─────────────────────────────────────────
|
||||||
|
|
||||||
@bp.route('/<int:issue_id>/verify', methods=['POST'])
|
@bp.route('/<int:issue_id>/verify', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
@supervisor_required
|
@issue_manager_required
|
||||||
def verify(issue_id):
|
def verify(issue_id):
|
||||||
"""Supervisor sign-off: confirms resolution is satisfactory and closes the issue."""
|
"""Supervisor sign-off: confirms resolution is satisfactory and closes the issue."""
|
||||||
issue = db.session.get(Issue, issue_id)
|
issue = db.session.get(Issue, issue_id)
|
||||||
@@ -812,7 +1125,7 @@ def verify(issue_id):
|
|||||||
|
|
||||||
if issue.status not in ('resolved', 'pending_verification'):
|
if issue.status not in ('resolved', 'pending_verification'):
|
||||||
flash('Only resolved or pending-verification issues can be verified.', 'warning')
|
flash('Only resolved or pending-verification issues can be verified.', 'warning')
|
||||||
return redirect(url_for('issues.view', issue_id=issue_id))
|
return redirect(_view_url(issue_id))
|
||||||
|
|
||||||
note = request.form.get('verification_note', '').strip() or None
|
note = request.form.get('verification_note', '').strip() or None
|
||||||
|
|
||||||
@@ -832,12 +1145,26 @@ def verify(issue_id):
|
|||||||
f'#{issue_id} in {issue.area.name if issue.area else issue.resolved_facility.name if issue.resolved_facility else '—'}',
|
f'#{issue_id} in {issue.area.name if issue.area else issue.resolved_facility.name if issue.resolved_facility else '—'}',
|
||||||
f'verified_by={current_user.username}')
|
f'verified_by={current_user.username}')
|
||||||
flash(f'Issue #{issue_id} verified and closed.', 'success')
|
flash(f'Issue #{issue_id} verified and closed.', 'success')
|
||||||
return redirect(url_for('issues.view', issue_id=issue_id))
|
return redirect(_view_url(issue_id))
|
||||||
|
|
||||||
|
|
||||||
|
def _view_url(issue_id):
|
||||||
|
"""issues.view URL that carries the list `next` through.
|
||||||
|
|
||||||
|
An update posted from the detail page redirects back to that same detail
|
||||||
|
page; without re-attaching `next`, the Back button would lose the filters
|
||||||
|
the user arrived with and the next action from this page would too. Only
|
||||||
|
added when there is something to carry, so ordinary links stay clean.
|
||||||
|
"""
|
||||||
|
nxt = request.form.get('next') or request.args.get('next')
|
||||||
|
if nxt:
|
||||||
|
return url_for('issues.view', issue_id=issue_id, next=nxt)
|
||||||
|
return url_for('issues.view', issue_id=issue_id)
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/bulk-verify', methods=['POST'])
|
@bp.route('/bulk-verify', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
@supervisor_required
|
@issue_manager_required
|
||||||
def bulk_verify():
|
def bulk_verify():
|
||||||
"""Verify multiple pending-verification issues in a single action."""
|
"""Verify multiple pending-verification issues in a single action."""
|
||||||
issue_ids = request.form.getlist('issue_ids', type=int)
|
issue_ids = request.form.getlist('issue_ids', type=int)
|
||||||
@@ -867,7 +1194,205 @@ def bulk_verify():
|
|||||||
f'bulk_verified_by={current_user.username}')
|
f'bulk_verified_by={current_user.username}')
|
||||||
|
|
||||||
flash(f'{verified_count} issue{"s" if verified_count != 1 else ""} verified and closed.', 'success')
|
flash(f'{verified_count} issue{"s" if verified_count != 1 else ""} verified and closed.', 'success')
|
||||||
return redirect(url_for('issues.verification_queue'))
|
# Reachable from BOTH the verification queue and the issues list, so honour
|
||||||
|
# the caller's `next` and fall back to the queue as before.
|
||||||
|
return redirect(return_url(url_for('issues.verification_queue')))
|
||||||
|
|
||||||
|
|
||||||
|
# ── Bulk actions from the issues list ────────────────────────────────────────
|
||||||
|
|
||||||
|
#: Statuses a bulk status change may set, and what an issue must already be in
|
||||||
|
#: for the change to mean anything. Moving an issue to the state it is already
|
||||||
|
#: in is a no-op, so it counts as skipped rather than changed.
|
||||||
|
_BULK_STATUSES = ('open', 'in_progress', 'resolved', 'pending_verification')
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/bulk', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def bulk_action():
|
||||||
|
"""Apply one action to every ticked issue on the list page.
|
||||||
|
|
||||||
|
Partial-failure policy (matches bulk_verify): act on every eligible row,
|
||||||
|
skip the rest, and report exact counts — never silently drop rows, and
|
||||||
|
never let one ineligible row block the batch.
|
||||||
|
|
||||||
|
Permission is checked per ACTION here rather than per row: all four actions
|
||||||
|
are manager-level, and the roles that hold them have org-wide issue access,
|
||||||
|
so there is no per-row scope question to answer. `skipped` therefore only
|
||||||
|
ever means "this row was not in a state the action applies to".
|
||||||
|
"""
|
||||||
|
back = return_url(url_for('issues.index'))
|
||||||
|
action = request.form.get('action', '')
|
||||||
|
ids = request.form.getlist('issue_ids', type=int)
|
||||||
|
|
||||||
|
if not ids:
|
||||||
|
flash('No issues selected.', 'warning')
|
||||||
|
return redirect(back)
|
||||||
|
|
||||||
|
manager = current_user.role in ('admin', 'director', 'auditor')
|
||||||
|
deleter = current_user.role in ('admin', 'director')
|
||||||
|
|
||||||
|
allowed = {
|
||||||
|
'assign': manager,
|
||||||
|
'status': manager,
|
||||||
|
'verify': manager,
|
||||||
|
'delete': deleter,
|
||||||
|
}
|
||||||
|
if action not in allowed:
|
||||||
|
flash('Unknown bulk action.', 'danger')
|
||||||
|
return redirect(back)
|
||||||
|
if not allowed[action]:
|
||||||
|
flash('You do not have permission for that bulk action.', 'danger')
|
||||||
|
return redirect(back)
|
||||||
|
|
||||||
|
issues = [i for i in (db.session.get(Issue, i_id) for i_id in ids) if i is not None]
|
||||||
|
missing = len(ids) - len(issues)
|
||||||
|
changed = 0
|
||||||
|
skipped = missing
|
||||||
|
|
||||||
|
# ── Assign ───────────────────────────────────────────────────────────
|
||||||
|
if action == 'assign':
|
||||||
|
raw = request.form.get('assigned_to', '')
|
||||||
|
user = None
|
||||||
|
if raw and raw != '0':
|
||||||
|
user = db.session.get(User, int(raw)) if raw.isdigit() else None
|
||||||
|
if user is None:
|
||||||
|
flash('That user no longer exists.', 'danger')
|
||||||
|
return redirect(back)
|
||||||
|
|
||||||
|
# Track what actually moved. Re-deriving this after the commit by
|
||||||
|
# testing `issue.assigned_to == user.id` would also match the issues
|
||||||
|
# that were ALREADY assigned to that person — they were counted as
|
||||||
|
# skipped, but would still be emailed "assigned to you" every time
|
||||||
|
# anyone ran a bulk assign over them.
|
||||||
|
newly_assigned = []
|
||||||
|
for issue in issues:
|
||||||
|
if issue.assigned_to == (user.id if user else None):
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
issue.assigned_to = user.id if user else None
|
||||||
|
newly_assigned.append(issue)
|
||||||
|
changed += 1
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
if user:
|
||||||
|
for issue in newly_assigned:
|
||||||
|
notify(
|
||||||
|
recipient = user,
|
||||||
|
title = f'Issue #{issue.id} assigned to you',
|
||||||
|
body = (f'{issue.severity.title()}-severity issue at '
|
||||||
|
f'{issue.resolved_facility.name if issue.resolved_facility else "—"}: '
|
||||||
|
f'{issue.description[:120]}'),
|
||||||
|
link = url_for('issues.view', issue_id=issue.id),
|
||||||
|
issue_id = issue.id,
|
||||||
|
event_type = EVENT_ISSUE_ASSIGNED,
|
||||||
|
send_email = True,
|
||||||
|
)
|
||||||
|
db.session.commit() # notify() does not commit — rule 70
|
||||||
|
|
||||||
|
label = user.display_name if user else 'Unassigned'
|
||||||
|
log_action(ACTION_UPDATE, 'Issue', None, f'bulk assign → {label}',
|
||||||
|
f'ids={[i.id for i in issues]}; changed={changed}')
|
||||||
|
_flash_bulk(changed, skipped, f'assigned to {label}')
|
||||||
|
|
||||||
|
# ── Status ───────────────────────────────────────────────────────────
|
||||||
|
elif action == 'status':
|
||||||
|
new_status = request.form.get('status', '')
|
||||||
|
if new_status not in _BULK_STATUSES:
|
||||||
|
flash('Please choose a status to set.', 'warning')
|
||||||
|
return redirect(back)
|
||||||
|
|
||||||
|
# (issue, old_status) for the audit pass, which must run AFTER the
|
||||||
|
# commit — log_action() commits internally (rule 41), so calling it
|
||||||
|
# inside this loop would commit each row separately and lose the
|
||||||
|
# batch's atomicity.
|
||||||
|
moved = []
|
||||||
|
for issue in issues:
|
||||||
|
if issue.status == new_status:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
old = issue.status
|
||||||
|
moved.append((issue, old))
|
||||||
|
issue.status = new_status
|
||||||
|
# Keep resolved_at consistent with the status, the same way the
|
||||||
|
# single-issue update does — a resolved issue with no resolved_at
|
||||||
|
# breaks the SLA compliance report and the aging buckets.
|
||||||
|
if new_status == 'resolved' and not issue.resolved_at:
|
||||||
|
issue.resolved_at = now_eastern()
|
||||||
|
elif new_status in ('open', 'in_progress'):
|
||||||
|
issue.resolved_at = None
|
||||||
|
changed += 1
|
||||||
|
db.session.commit()
|
||||||
|
for issue, old in moved:
|
||||||
|
log_action(ACTION_UPDATE, 'Issue', issue.id, f'#{issue.id}',
|
||||||
|
f'bulk status {old} → {new_status} by {current_user.username}')
|
||||||
|
_flash_bulk(changed, skipped,
|
||||||
|
f'set to {new_status.replace("_", " ").title()}')
|
||||||
|
|
||||||
|
# ── Verify & close ───────────────────────────────────────────────────
|
||||||
|
elif action == 'verify':
|
||||||
|
verified = []
|
||||||
|
for issue in issues:
|
||||||
|
if issue.status not in ('resolved', 'pending_verification'):
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
issue.status = 'resolved'
|
||||||
|
issue.verified_by = current_user.id
|
||||||
|
issue.verified_at = now_eastern()
|
||||||
|
if not issue.resolved_at:
|
||||||
|
issue.resolved_at = now_eastern()
|
||||||
|
verified.append(issue)
|
||||||
|
changed += 1
|
||||||
|
db.session.commit()
|
||||||
|
for issue in verified: # after the commit — rule 41
|
||||||
|
log_action(ACTION_UPDATE, 'Issue', issue.id, f'#{issue.id}',
|
||||||
|
f'bulk_verified_by={current_user.username}')
|
||||||
|
_flash_bulk(changed, skipped, 'verified and closed',
|
||||||
|
skip_reason='not awaiting verification')
|
||||||
|
|
||||||
|
# ── Delete ───────────────────────────────────────────────────────────
|
||||||
|
elif action == 'delete':
|
||||||
|
from app.utils import storage
|
||||||
|
photo_paths = []
|
||||||
|
# Snapshot the ids BEFORE deleting — the objects are expired after the
|
||||||
|
# commit, and the audit pass has to run after it (rule 41: log_action
|
||||||
|
# commits internally, so auditing inside this loop would commit the
|
||||||
|
# deletes one at a time and, on a mid-loop failure, leave rows gone
|
||||||
|
# with the photo cleanup below never reached).
|
||||||
|
deleted_ids = []
|
||||||
|
for issue in issues:
|
||||||
|
if issue.photo_path:
|
||||||
|
photo_paths.append(issue.photo_path)
|
||||||
|
for lst in (issue.mobile_photo_paths, issue.result_photos):
|
||||||
|
if lst:
|
||||||
|
photo_paths.extend(lst)
|
||||||
|
deleted_ids.append(issue.id)
|
||||||
|
db.session.delete(issue)
|
||||||
|
changed += 1
|
||||||
|
db.session.commit()
|
||||||
|
for issue_id in deleted_ids:
|
||||||
|
log_action(ACTION_DELETE, 'Issue', issue_id, f'#{issue_id}',
|
||||||
|
f'bulk deleted by {current_user.username}')
|
||||||
|
# Files go only after the rows are safely gone — a failure here leaves
|
||||||
|
# an orphaned file, which is recoverable; the reverse is not.
|
||||||
|
for rel_path in photo_paths:
|
||||||
|
storage.delete(rel_path)
|
||||||
|
_flash_bulk(changed, skipped, 'permanently deleted')
|
||||||
|
|
||||||
|
logger.info('ISSUES | bulk | action=%s user=%s selected=%s changed=%s skipped=%s',
|
||||||
|
action, current_user.username, len(ids), changed, skipped)
|
||||||
|
return redirect(back)
|
||||||
|
|
||||||
|
|
||||||
|
def _flash_bulk(changed, skipped, verb, skip_reason='no change needed'):
|
||||||
|
"""One consistent result message for every bulk action."""
|
||||||
|
if not changed and not skipped:
|
||||||
|
flash('Nothing to do.', 'info')
|
||||||
|
return
|
||||||
|
parts = [f'{changed} issue{"s" if changed != 1 else ""} {verb}']
|
||||||
|
if skipped:
|
||||||
|
parts.append(f'{skipped} skipped ({skip_reason})')
|
||||||
|
flash('. '.join(parts) + '.', 'success' if changed else 'warning')
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/<int:issue_id>/request-verification', methods=['POST'])
|
@bp.route('/<int:issue_id>/request-verification', methods=['POST'])
|
||||||
@@ -884,16 +1409,16 @@ def request_verification(issue_id):
|
|||||||
|
|
||||||
# Only the assignee, director, or admin can request verification
|
# Only the assignee, director, or admin can request verification
|
||||||
can_act = (
|
can_act = (
|
||||||
current_user.role in ['admin', 'director']
|
current_user.role in ['admin', 'director', 'auditor']
|
||||||
or issue.assigned_to == current_user.id
|
or issue.assigned_to == current_user.id
|
||||||
)
|
)
|
||||||
if not can_act:
|
if not can_act:
|
||||||
flash('Access denied.', 'danger')
|
flash('Access denied.', 'danger')
|
||||||
return redirect(url_for('issues.view', issue_id=issue_id))
|
return redirect(_view_url(issue_id))
|
||||||
|
|
||||||
if issue.status not in ('in_progress',):
|
if issue.status not in ('in_progress',):
|
||||||
flash('Issue must be in progress to request verification.', 'warning')
|
flash('Issue must be in progress to request verification.', 'warning')
|
||||||
return redirect(url_for('issues.view', issue_id=issue_id))
|
return redirect(_view_url(issue_id))
|
||||||
|
|
||||||
issue.status = 'pending_verification'
|
issue.status = 'pending_verification'
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
@@ -921,13 +1446,13 @@ def request_verification(issue_id):
|
|||||||
)
|
)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash('Issue marked as pending verification. Supervisors have been notified.', 'info')
|
flash('Issue marked as pending verification. Supervisors have been notified.', 'info')
|
||||||
return redirect(url_for('issues.view', issue_id=issue_id))
|
return redirect(_view_url(issue_id))
|
||||||
|
|
||||||
# ── Verification queue ────────────────────────────────────────────────────────
|
# ── Verification queue ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@bp.route('/verification-queue')
|
@bp.route('/verification-queue')
|
||||||
@login_required
|
@login_required
|
||||||
@supervisor_required
|
@issue_manager_required
|
||||||
def verification_queue():
|
def verification_queue():
|
||||||
"""Supervisor queue of all issues awaiting verification, grouped by facility."""
|
"""Supervisor queue of all issues awaiting verification, grouped by facility."""
|
||||||
from app.models.facility import Facility, Area
|
from app.models.facility import Facility, Area
|
||||||
@@ -1000,15 +1525,10 @@ def delete(issue_id):
|
|||||||
db.session.delete(issue)
|
db.session.delete(issue)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
# Remove orphaned photo files — best-effort, never block on failure
|
# Remove orphaned photo files from the active storage backend — best-effort.
|
||||||
static_folder = current_app.root_path
|
from app.utils import storage
|
||||||
for rel_path in photo_paths:
|
for rel_path in photo_paths:
|
||||||
abs_path = os.path.normpath(os.path.join(static_folder, 'static', rel_path))
|
storage.delete(rel_path)
|
||||||
try:
|
|
||||||
if os.path.isfile(abs_path):
|
|
||||||
os.remove(abs_path)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
current_app.logger.info(
|
current_app.logger.info(
|
||||||
'ISSUE DELETED | id=%s | severity=%s | area=%s | facility=%s | deleted_by=%s',
|
'ISSUE DELETED | id=%s | severity=%s | area=%s | facility=%s | deleted_by=%s',
|
||||||
@@ -1019,7 +1539,7 @@ def delete(issue_id):
|
|||||||
f'facility={facility_name}; description={issue_desc}')
|
f'facility={facility_name}; description={issue_desc}')
|
||||||
|
|
||||||
flash(f'Issue #{issue_id_snap} has been permanently deleted.', 'success')
|
flash(f'Issue #{issue_id_snap} has been permanently deleted.', 'success')
|
||||||
return redirect(url_for('issues.index'))
|
return redirect(return_url(url_for('issues.index')))
|
||||||
|
|
||||||
|
|
||||||
# ── Quick-assign (AJAX) ───────────────────────────────────────────────────────
|
# ── Quick-assign (AJAX) ───────────────────────────────────────────────────────
|
||||||
@@ -1028,7 +1548,7 @@ def delete(issue_id):
|
|||||||
@login_required
|
@login_required
|
||||||
def quick_assign(issue_id):
|
def quick_assign(issue_id):
|
||||||
"""Inline assignee update from the issues list — returns JSON."""
|
"""Inline assignee update from the issues list — returns JSON."""
|
||||||
if current_user.role not in ('admin', 'director'):
|
if current_user.role not in ('admin', 'director', 'auditor'):
|
||||||
return jsonify({'ok': False, 'error': 'Permission denied'}), 403
|
return jsonify({'ok': False, 'error': 'Permission denied'}), 403
|
||||||
|
|
||||||
issue = db.session.get(Issue, issue_id)
|
issue = db.session.get(Issue, issue_id)
|
||||||
@@ -1088,7 +1608,7 @@ def export_pdf(issue_id):
|
|||||||
if issue is None:
|
if issue is None:
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
if current_user.role == 'inspector':
|
if current_user.is_inspector:
|
||||||
fids = get_inspector_scope(current_user)
|
fids = get_inspector_scope(current_user)
|
||||||
facility = issue.resolved_facility
|
facility = issue.resolved_facility
|
||||||
if not fids or not facility or facility.id not in fids:
|
if not fids or not facility or facility.id not in fids:
|
||||||
@@ -1111,6 +1631,20 @@ def export_pdf(issue_id):
|
|||||||
|
|
||||||
|
|
||||||
# ── Vendor work order dispatch (phase36) ─────────────────────────────────────
|
# ── Vendor work order dispatch (phase36) ─────────────────────────────────────
|
||||||
|
#
|
||||||
|
# NOT LINKED FROM THE UI (Aug 2026). The "Contractor Work Orders" card was
|
||||||
|
# removed from the issue detail page, so nothing posts here any more. The
|
||||||
|
# endpoint is kept deliberately rather than deleted:
|
||||||
|
#
|
||||||
|
# * it is the ONLY way to create a work order, so removing it would strand
|
||||||
|
# the public contractor pages (/work-orders/<token>), the model, the email
|
||||||
|
# template and the phase36 migration — a whole feature, not dead code;
|
||||||
|
# * tests/test_work_orders.py drives the end-to-end flow through it.
|
||||||
|
#
|
||||||
|
# To bring the feature back, restore the card in templates/issues/view.html —
|
||||||
|
# nothing here needs to change. To retire it for good, remove this route, the
|
||||||
|
# work_orders blueprint, its templates, the model and those tests together,
|
||||||
|
# and only once no tokenized links are still outstanding with contractors.
|
||||||
|
|
||||||
@bp.route('/<int:issue_id>/work-order', methods=['POST'])
|
@bp.route('/<int:issue_id>/work-order', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
|
|||||||
@@ -308,6 +308,118 @@ def check_score_trends():
|
|||||||
return jsonify({'ok': True, 'alerts_sent': sent})
|
return jsonify({'ok': True, 'alerts_sent': sent})
|
||||||
|
|
||||||
|
|
||||||
|
# ── Photo retention purge (called by cron) ────────────────────────────────────
|
||||||
|
|
||||||
|
@bp.route('/purge-old-photos', methods=['POST'])
|
||||||
|
@csrf.exempt
|
||||||
|
def purge_old_photos():
|
||||||
|
"""Delete photo FILES (not the issue records) for issues resolved longer
|
||||||
|
ago than PHOTO_RETENTION_DAYS, addressing GDPR Art. 5(1)(e) storage
|
||||||
|
limitation — evidence photos otherwise persist forever.
|
||||||
|
|
||||||
|
Disabled by default (no-op) unless PHOTO_RETENTION_DAYS is set in config/
|
||||||
|
env — this is a data-minimization policy the operator opts into, not a
|
||||||
|
forced deletion, since some deployments may have a longer required
|
||||||
|
retention for their own contractual/audit reasons.
|
||||||
|
|
||||||
|
Only touches RESOLVED issues whose resolved_at predates the cutoff.
|
||||||
|
Clears photo_path / mobile_photo_paths / result_photos to null/empty and
|
||||||
|
deletes the underlying files via the storage abstraction (safe on both
|
||||||
|
the local and R2 backends, and tenant-prefix aware — storage.delete()
|
||||||
|
resolves the same key the write used).
|
||||||
|
|
||||||
|
PER-TENANT, deliberately. Unlike /trial-reminders and /dunning-reminders
|
||||||
|
(which walk the control DB), this operates on `issues` in whichever tenant
|
||||||
|
DB the request resolves to, and the retention window is that tenant's own
|
||||||
|
policy. So it must be invoked once per tenant Host, exactly like
|
||||||
|
/check-sla and /check-score-trends. A cross-tenant variant would have to
|
||||||
|
read each tenant's own retention setting, which does not exist yet.
|
||||||
|
|
||||||
|
Recommended cron schedule — nightly is sufficient, per tenant host:
|
||||||
|
|
||||||
|
0 4 * * * curl -s -X POST https://lts.jqc.app/notifications/purge-old-photos \\
|
||||||
|
-d "token=YOUR_DIGEST_SECRET"
|
||||||
|
"""
|
||||||
|
token = request.form.get('token') or request.args.get('token')
|
||||||
|
expected = current_app.config.get('DIGEST_SECRET')
|
||||||
|
|
||||||
|
if not expected or token != expected:
|
||||||
|
logger.warning('PHOTO PURGE REJECTED | bad or missing token')
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
retention_days = current_app.config.get('PHOTO_RETENTION_DAYS')
|
||||||
|
if not retention_days:
|
||||||
|
return jsonify({'ok': True, 'skipped': 'PHOTO_RETENTION_DAYS not configured',
|
||||||
|
'issues_purged': 0})
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
from app.models.issue import Issue
|
||||||
|
from app.utils.time_utils import now_eastern
|
||||||
|
from app.utils.audit import log_action, ACTION_UPDATE
|
||||||
|
from app.utils import storage
|
||||||
|
|
||||||
|
cutoff = now_eastern() - timedelta(days=int(retention_days))
|
||||||
|
candidates = (
|
||||||
|
Issue.query
|
||||||
|
.filter(Issue.status == 'resolved')
|
||||||
|
.filter(Issue.resolved_at.isnot(None))
|
||||||
|
.filter(Issue.resolved_at < cutoff)
|
||||||
|
.filter(
|
||||||
|
db.or_(
|
||||||
|
Issue.photo_path.isnot(None),
|
||||||
|
Issue.mobile_photo_paths.isnot(None),
|
||||||
|
Issue.result_photos.isnot(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
purged_count = 0
|
||||||
|
for issue in candidates:
|
||||||
|
keys = []
|
||||||
|
if issue.photo_path:
|
||||||
|
keys.append(issue.photo_path)
|
||||||
|
keys.extend(issue.mobile_photo_paths or [])
|
||||||
|
keys.extend(issue.result_photos or [])
|
||||||
|
|
||||||
|
# Skip rows that hold no actual photo keys. The or_() above cannot do
|
||||||
|
# this on its own: db.JSON defaults to none_as_null=False, so a Python
|
||||||
|
# None assigned to mobile_photo_paths / result_photos is persisted as
|
||||||
|
# the JSON scalar `null` — which is NOT SQL NULL and therefore still
|
||||||
|
# satisfies isnot(None). Without this guard the endpoint would (a) count
|
||||||
|
# and rewrite every old resolved issue even when it has no photos, and
|
||||||
|
# (b) never become idempotent: clearing the fields writes JSON `null`
|
||||||
|
# again, so the next nightly run would re-select the very same rows
|
||||||
|
# forever, churning UPDATEs and logging a purge that did nothing.
|
||||||
|
if not keys:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for key in keys:
|
||||||
|
try:
|
||||||
|
storage.delete(key)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning('PHOTO PURGE | failed to delete key=%s issue_id=%s: %s',
|
||||||
|
key, issue.id, exc)
|
||||||
|
issue.photo_path = None
|
||||||
|
issue.mobile_photo_paths = None
|
||||||
|
issue.result_photos = None
|
||||||
|
purged_count += 1
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
if purged_count:
|
||||||
|
log_action(
|
||||||
|
ACTION_UPDATE, 'Issue', None,
|
||||||
|
f'Photo retention purge — {purged_count} resolved issue(s)',
|
||||||
|
f'cutoff={cutoff.strftime("%Y-%m-%d %H:%M:%S")}; retention_days={retention_days}',
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info('PHOTO PURGE TRIGGERED | issues_purged=%s | retention_days=%s',
|
||||||
|
purged_count, retention_days)
|
||||||
|
return jsonify({'ok': True, 'issues_purged': purged_count,
|
||||||
|
'retention_days': retention_days})
|
||||||
|
|
||||||
|
|
||||||
# ── Trial-ending reminder (called by cron) ────────────────────────────────────
|
# ── Trial-ending reminder (called by cron) ────────────────────────────────────
|
||||||
|
|
||||||
@bp.route('/trial-reminders', methods=['POST'])
|
@bp.route('/trial-reminders', methods=['POST'])
|
||||||
|
|||||||
+81
-34
@@ -12,6 +12,7 @@ from app import db
|
|||||||
from app.models.inspection import Inspection, InspectionTemplate
|
from app.models.inspection import Inspection, InspectionTemplate
|
||||||
from app.models.facility import Facility, Area
|
from app.models.facility import Facility, Area
|
||||||
from app.models.issue import Issue
|
from app.models.issue import Issue
|
||||||
|
from app.models.project import Project
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.utils.decorators import supervisor_required
|
from app.utils.decorators import supervisor_required
|
||||||
from app.utils.scope import get_customer_scope
|
from app.utils.scope import get_customer_scope
|
||||||
@@ -56,7 +57,8 @@ def index():
|
|||||||
# Inspectors get a scoped view of their own inspections and related issues.
|
# Inspectors get a scoped view of their own inspections and related issues.
|
||||||
# Customers get a facility-scoped view.
|
# Customers get a facility-scoped view.
|
||||||
# Internal management roles (director+) get the full unscoped view.
|
# Internal management roles (director+) get the full unscoped view.
|
||||||
if current_user.role not in ['admin', 'director', 'project_manager', 'inspector', 'customer']:
|
if current_user.role not in ['admin', 'director', 'project_manager', 'inspector',
|
||||||
|
'external_inspector', 'customer']:
|
||||||
from flask import flash, redirect, url_for
|
from flask import flash, redirect, url_for
|
||||||
flash('Access denied.', 'danger')
|
flash('Access denied.', 'danger')
|
||||||
return redirect(url_for('dashboard.index'))
|
return redirect(url_for('dashboard.index'))
|
||||||
@@ -65,30 +67,36 @@ def index():
|
|||||||
|
|
||||||
# Resolve scoping for customers (facility list) and inspectors (inspector_id)
|
# Resolve scoping for customers (facility list) and inspectors (inspector_id)
|
||||||
customer_facility_ids = get_customer_scope(current_user) # None = unrestricted
|
customer_facility_ids = get_customer_scope(current_user) # None = unrestricted
|
||||||
is_inspector = current_user.role == 'inspector'
|
is_inspector = current_user.is_inspector
|
||||||
|
|
||||||
# Inspector filter — admin / director / project_manager only
|
# Inspector filter — admin / director / project_manager only
|
||||||
inspector_filter = None
|
inspector_filter = None
|
||||||
if current_user.role in ('admin', 'director', 'project_manager'):
|
if current_user.role in ('admin', 'director', 'project_manager'):
|
||||||
inspector_filter = request.args.get('inspector_id', type=int) or None
|
inspector_filter = request.args.get('inspector_id', type=int) or None
|
||||||
|
|
||||||
# Pre-compute inspection ID sets used by _scope_issue to avoid join conflicts.
|
# Scope issues by the relevant inspector's inspections, as a SUBQUERY rather
|
||||||
inspector_inspection_ids = [] # own inspections (inspector role)
|
# than a materialised id list. The previous form pulled every inspection id
|
||||||
filter_inspection_ids = None # filtered inspector's inspections (admin/dir/PM)
|
# that inspector had ever performed into Python and sent them straight back
|
||||||
|
# as a literal IN (1, 2, 3, ... N): the round trip is wasted, the statement
|
||||||
|
# grows without bound with the inspector's history, and a long enough list
|
||||||
|
# eventually trips max_allowed_packet. A subquery is also still a single
|
||||||
|
# statement, so the "avoid join conflicts" reason for pre-computing holds.
|
||||||
|
#
|
||||||
|
# IN (empty subquery) already matches nothing, so the explicit empty-list
|
||||||
|
# guards the old code needed are gone rather than merely moved.
|
||||||
|
inspector_insp_subq = None
|
||||||
if is_inspector:
|
if is_inspector:
|
||||||
inspector_inspection_ids = [
|
inspector_insp_subq = (
|
||||||
row[0] for row in
|
|
||||||
db.session.query(Inspection.id)
|
db.session.query(Inspection.id)
|
||||||
.filter(Inspection.inspector_id == current_user.id)
|
.filter(Inspection.inspector_id == current_user.id)
|
||||||
.all()
|
.scalar_subquery()
|
||||||
]
|
)
|
||||||
elif inspector_filter:
|
elif inspector_filter:
|
||||||
filter_inspection_ids = [
|
inspector_insp_subq = (
|
||||||
row[0] for row in
|
|
||||||
db.session.query(Inspection.id)
|
db.session.query(Inspection.id)
|
||||||
.filter(Inspection.inspector_id == inspector_filter)
|
.filter(Inspection.inspector_id == inspector_filter)
|
||||||
.all()
|
.scalar_subquery()
|
||||||
]
|
)
|
||||||
|
|
||||||
def _scope_insp(q):
|
def _scope_insp(q):
|
||||||
if is_inspector:
|
if is_inspector:
|
||||||
@@ -102,14 +110,8 @@ def index():
|
|||||||
return q
|
return q
|
||||||
|
|
||||||
def _scope_issue(q):
|
def _scope_issue(q):
|
||||||
if is_inspector:
|
if inspector_insp_subq is not None:
|
||||||
if not inspector_inspection_ids:
|
return q.filter(Issue.inspection_id.in_(inspector_insp_subq))
|
||||||
return q.filter(False)
|
|
||||||
return q.filter(Issue.inspection_id.in_(inspector_inspection_ids))
|
|
||||||
if filter_inspection_ids is not None:
|
|
||||||
if not filter_inspection_ids:
|
|
||||||
return q.filter(False)
|
|
||||||
return q.filter(Issue.inspection_id.in_(filter_inspection_ids))
|
|
||||||
if customer_facility_ids is not None:
|
if customer_facility_ids is not None:
|
||||||
if not customer_facility_ids:
|
if not customer_facility_ids:
|
||||||
return q.filter(False)
|
return q.filter(False)
|
||||||
@@ -141,9 +143,11 @@ def index():
|
|||||||
)
|
)
|
||||||
avg_score = _scope_insp(avg_score).scalar()
|
avg_score = _scope_insp(avg_score).scalar()
|
||||||
|
|
||||||
# Scores by facility (for bar chart)
|
# Scores by facility (for bar chart) — includes project_id so the report
|
||||||
|
# can group/filter facilities by Contract client-side.
|
||||||
fac_score_q = db.session.query(
|
fac_score_q = db.session.query(
|
||||||
Facility.name,
|
Facility.name,
|
||||||
|
Facility.project_id,
|
||||||
func.avg(Inspection.overall_score).label('avg_score'),
|
func.avg(Inspection.overall_score).label('avg_score'),
|
||||||
func.count(Inspection.id).label('count'),
|
func.count(Inspection.id).label('count'),
|
||||||
).join(Inspection, Facility.id == Inspection.facility_id)\
|
).join(Inspection, Facility.id == Inspection.facility_id)\
|
||||||
@@ -159,9 +163,16 @@ def index():
|
|||||||
fac_score_q = fac_score_q.filter(
|
fac_score_q = fac_score_q.filter(
|
||||||
Facility.id.in_(customer_facility_ids) if customer_facility_ids else False
|
Facility.id.in_(customer_facility_ids) if customer_facility_ids else False
|
||||||
)
|
)
|
||||||
facility_scores = fac_score_q.group_by(Facility.id, Facility.name)\
|
facility_scores = fac_score_q.group_by(Facility.id, Facility.name, Facility.project_id)\
|
||||||
.order_by(func.avg(Inspection.overall_score).desc()).all()
|
.order_by(func.avg(Inspection.overall_score).desc()).all()
|
||||||
|
|
||||||
|
# Resolve contract names for the facilities present.
|
||||||
|
_proj_ids = {r.project_id for r in facility_scores if r.project_id}
|
||||||
|
_proj_names = (
|
||||||
|
{p.id: p.name for p in Project.query.filter(Project.id.in_(_proj_ids)).all()}
|
||||||
|
if _proj_ids else {}
|
||||||
|
)
|
||||||
|
|
||||||
# Prior-period facility scores for period-over-period delta badges
|
# Prior-period facility scores for period-over-period delta badges
|
||||||
period_len = end - start
|
period_len = end - start
|
||||||
prior_end = start
|
prior_end = start
|
||||||
@@ -252,15 +263,28 @@ def index():
|
|||||||
|
|
||||||
inspectors = []
|
inspectors = []
|
||||||
if current_user.role in ('admin', 'director', 'project_manager'):
|
if current_user.role in ('admin', 'director', 'project_manager'):
|
||||||
inspectors = User.query.filter_by(role='inspector', active=True)\
|
inspectors = User.query.filter(User.role.in_(User.INSPECTOR_ROLES),
|
||||||
|
User.active == True)\
|
||||||
.order_by(User.full_name, User.username).all()
|
.order_by(User.full_name, User.username).all()
|
||||||
|
|
||||||
facility_scores_list = [{'name': r.name, 'avg_score': round(float(r.avg_score), 2), 'count': r.count} for r in facility_scores]
|
facility_scores_list = [{
|
||||||
|
'name': r.name,
|
||||||
|
'avg_score': round(float(r.avg_score), 2),
|
||||||
|
'count': r.count,
|
||||||
|
'project_id': r.project_id or 0,
|
||||||
|
'contract': _proj_names.get(r.project_id, 'No Contract'),
|
||||||
|
} for r in facility_scores]
|
||||||
# Attach prior avg and delta to each facility score dict for the template table
|
# Attach prior avg and delta to each facility score dict for the template table
|
||||||
for row in facility_scores_list:
|
for row in facility_scores_list:
|
||||||
row['prior_avg'] = prior_scores_map.get(row['name'])
|
row['prior_avg'] = prior_scores_map.get(row['name'])
|
||||||
row['delta'] = facility_deltas.get(row['name'])
|
row['delta'] = facility_deltas.get(row['name'])
|
||||||
|
|
||||||
|
# Distinct contracts present, for the "Avg Score by Facility" contract filter.
|
||||||
|
score_contracts = sorted(
|
||||||
|
{(r['project_id'], r['contract']) for r in facility_scores_list},
|
||||||
|
key=lambda t: (t[1] or '').lower(),
|
||||||
|
)
|
||||||
|
|
||||||
return render_template('reports/index.html',
|
return render_template('reports/index.html',
|
||||||
start=start, end=end,
|
start=start, end=end,
|
||||||
total_inspections=total_inspections,
|
total_inspections=total_inspections,
|
||||||
@@ -268,6 +292,7 @@ def index():
|
|||||||
flagged=flagged,
|
flagged=flagged,
|
||||||
avg_score=round(float(avg_score), 2) if avg_score else None,
|
avg_score=round(float(avg_score), 2) if avg_score else None,
|
||||||
facility_scores=facility_scores_list,
|
facility_scores=facility_scores_list,
|
||||||
|
score_contracts=score_contracts,
|
||||||
daily_scores=[{'day': str(r.day), 'avg': round(float(r.avg), 2), 'count': r.count} for r in daily_scores],
|
daily_scores=[{'day': str(r.day), 'avg': round(float(r.avg), 2), 'count': r.count} for r in daily_scores],
|
||||||
issue_severity=[{'severity': r.severity, 'count': r.count} for r in issue_severity],
|
issue_severity=[{'severity': r.severity, 'count': r.count} for r in issue_severity],
|
||||||
issue_status=[{'status': r.status, 'count': r.count} for r in issue_status],
|
issue_status=[{'status': r.status, 'count': r.count} for r in issue_status],
|
||||||
@@ -284,7 +309,8 @@ def index():
|
|||||||
@bp.route('/facility/<int:facility_id>')
|
@bp.route('/facility/<int:facility_id>')
|
||||||
@login_required
|
@login_required
|
||||||
def facility_report(facility_id):
|
def facility_report(facility_id):
|
||||||
if current_user.role not in ['admin', 'director', 'project_manager', 'inspector', 'customer']:
|
if current_user.role not in ['admin', 'director', 'project_manager', 'inspector',
|
||||||
|
'external_inspector', 'customer']:
|
||||||
from flask import flash, redirect, url_for
|
from flask import flash, redirect, url_for
|
||||||
flash('Access denied.', 'danger')
|
flash('Access denied.', 'danger')
|
||||||
return redirect(url_for('dashboard.index'))
|
return redirect(url_for('dashboard.index'))
|
||||||
@@ -297,7 +323,7 @@ def facility_report(facility_id):
|
|||||||
from flask import flash, redirect, url_for
|
from flask import flash, redirect, url_for
|
||||||
flash('Access denied.', 'danger')
|
flash('Access denied.', 'danger')
|
||||||
return redirect(url_for('reports.index'))
|
return redirect(url_for('reports.index'))
|
||||||
if current_user.role == 'inspector':
|
if current_user.is_inspector:
|
||||||
# Inspectors may only view the facility report for facilities where
|
# Inspectors may only view the facility report for facilities where
|
||||||
# they have personally conducted at least one inspection.
|
# they have personally conducted at least one inspection.
|
||||||
has_access = Inspection.query.filter_by(
|
has_access = Inspection.query.filter_by(
|
||||||
@@ -346,7 +372,8 @@ def facility_report(facility_id):
|
|||||||
def facility_scorecard(facility_id):
|
def facility_scorecard(facility_id):
|
||||||
"""Comprehensive per-facility scorecard: score trend, SLA compliance,
|
"""Comprehensive per-facility scorecard: score trend, SLA compliance,
|
||||||
issue breakdown by severity, inspection frequency."""
|
issue breakdown by severity, inspection frequency."""
|
||||||
if current_user.role not in ['admin', 'director', 'project_manager', 'inspector', 'customer']:
|
if current_user.role not in ['admin', 'director', 'project_manager', 'inspector',
|
||||||
|
'external_inspector', 'customer']:
|
||||||
from flask import flash, redirect, url_for
|
from flask import flash, redirect, url_for
|
||||||
flash('Access denied.', 'danger')
|
flash('Access denied.', 'danger')
|
||||||
return redirect(url_for('dashboard.index'))
|
return redirect(url_for('dashboard.index'))
|
||||||
@@ -362,7 +389,7 @@ def facility_scorecard(facility_id):
|
|||||||
flash('Access denied.', 'danger')
|
flash('Access denied.', 'danger')
|
||||||
return redirect(url_for('reports.index'))
|
return redirect(url_for('reports.index'))
|
||||||
|
|
||||||
if current_user.role == 'inspector':
|
if current_user.is_inspector:
|
||||||
has_access = Inspection.query.filter_by(
|
has_access = Inspection.query.filter_by(
|
||||||
facility_id=facility_id,
|
facility_id=facility_id,
|
||||||
inspector_id=current_user.id,
|
inspector_id=current_user.id,
|
||||||
@@ -678,7 +705,8 @@ def _build_inspector_stats(start, end):
|
|||||||
all_ids = set(total_map.keys())
|
all_ids = set(total_map.keys())
|
||||||
active_inspectors = (
|
active_inspectors = (
|
||||||
User.query
|
User.query
|
||||||
.filter(User.id.in_(all_ids), User.active == True, User.role == 'inspector')
|
.filter(User.id.in_(all_ids), User.active == True,
|
||||||
|
User.role.in_(User.INSPECTOR_ROLES))
|
||||||
.order_by(User.full_name, User.username)
|
.order_by(User.full_name, User.username)
|
||||||
.all()
|
.all()
|
||||||
) if all_ids else []
|
) if all_ids else []
|
||||||
@@ -694,6 +722,10 @@ def _build_inspector_stats(start, end):
|
|||||||
inspector_stats.append({
|
inspector_stats.append({
|
||||||
'id': u.id,
|
'id': u.id,
|
||||||
'display_name': u.display_name,
|
'display_name': u.display_name,
|
||||||
|
# MT-15 — customer / third-party inspectors appear in the same
|
||||||
|
# table as the tenant's own crew, badged so the numbers can be read
|
||||||
|
# in context. Consumed by the HTML table and the export.
|
||||||
|
'external': u.is_external_inspector,
|
||||||
'total': tot,
|
'total': tot,
|
||||||
'completed': comp,
|
'completed': comp,
|
||||||
'completion_rate': round(comp / tot * 100) if tot else 0,
|
'completion_rate': round(comp / tot * 100) if tot else 0,
|
||||||
@@ -736,7 +768,7 @@ def inspector_performance():
|
|||||||
|
|
||||||
if selected_id:
|
if selected_id:
|
||||||
selected_inspector = db.session.get(User, selected_id)
|
selected_inspector = db.session.get(User, selected_id)
|
||||||
if selected_inspector and selected_inspector.role == 'inspector':
|
if selected_inspector and selected_inspector.is_inspector:
|
||||||
selected_kpis = next((s for s in inspector_stats if s['id'] == selected_id), None)
|
selected_kpis = next((s for s in inspector_stats if s['id'] == selected_id), None)
|
||||||
|
|
||||||
trend_rows = db.session.query(
|
trend_rows = db.session.query(
|
||||||
@@ -834,7 +866,7 @@ def export_inspector_performance():
|
|||||||
.filter(
|
.filter(
|
||||||
Inspection.inspection_date >= start,
|
Inspection.inspection_date >= start,
|
||||||
Inspection.inspection_date <= end,
|
Inspection.inspection_date <= end,
|
||||||
User.role == 'inspector',
|
User.role.in_(User.INSPECTOR_ROLES),
|
||||||
)
|
)
|
||||||
if selected_id:
|
if selected_id:
|
||||||
detail_q = detail_q.filter(Inspection.inspector_id == selected_id)
|
detail_q = detail_q.filter(Inspection.inspector_id == selected_id)
|
||||||
@@ -908,7 +940,10 @@ def export_inspector_performance():
|
|||||||
for row_idx, s in enumerate(inspector_stats, start=3):
|
for row_idx, s in enumerate(inspector_stats, start=3):
|
||||||
stripe = sub_fill if row_idx % 2 == 0 else None
|
stripe = sub_fill if row_idx % 2 == 0 else None
|
||||||
row_data = [
|
row_data = [
|
||||||
s['display_name'],
|
# Customer-employed inspectors share this table with our own crew;
|
||||||
|
# suffixed rather than given a column so the index-based styling
|
||||||
|
# below (score = col 5, vs_avg = col 6, …) stays correct.
|
||||||
|
s['display_name'] + (' (Customer)' if s.get('external') else ''),
|
||||||
s['total'],
|
s['total'],
|
||||||
s['completed'],
|
s['completed'],
|
||||||
s['completion_rate'],
|
s['completion_rate'],
|
||||||
@@ -1105,8 +1140,13 @@ def issues_aging():
|
|||||||
if customer_facility_ids is not None:
|
if customer_facility_ids is not None:
|
||||||
facilities = (Facility.query.filter(Facility.id.in_(customer_facility_ids), Facility.active == True)
|
facilities = (Facility.query.filter(Facility.id.in_(customer_facility_ids), Facility.active == True)
|
||||||
.order_by(Facility.name).all()) if customer_facility_ids else []
|
.order_by(Facility.name).all()) if customer_facility_ids else []
|
||||||
|
projects = (Project.query
|
||||||
|
.join(Facility, Project.id == Facility.project_id)
|
||||||
|
.filter(Facility.id.in_(customer_facility_ids), Project.active == True)
|
||||||
|
.distinct().order_by(Project.name).all()) if customer_facility_ids else []
|
||||||
else:
|
else:
|
||||||
facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all()
|
facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all()
|
||||||
|
projects = Project.query.filter_by(active=True).order_by(Project.name).all()
|
||||||
|
|
||||||
return render_template('reports/issues_aging.html',
|
return render_template('reports/issues_aging.html',
|
||||||
now=now,
|
now=now,
|
||||||
@@ -1118,6 +1158,7 @@ def issues_aging():
|
|||||||
severity_filter=severity_filter,
|
severity_filter=severity_filter,
|
||||||
facility_id_filter=facility_id_filter,
|
facility_id_filter=facility_id_filter,
|
||||||
facilities=facilities,
|
facilities=facilities,
|
||||||
|
projects=projects,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1282,8 +1323,13 @@ def sla_compliance():
|
|||||||
if customer_facility_ids is not None:
|
if customer_facility_ids is not None:
|
||||||
facilities = (Facility.query.filter(Facility.id.in_(customer_facility_ids), Facility.active == True)
|
facilities = (Facility.query.filter(Facility.id.in_(customer_facility_ids), Facility.active == True)
|
||||||
.order_by(Facility.name).all()) if customer_facility_ids else []
|
.order_by(Facility.name).all()) if customer_facility_ids else []
|
||||||
|
projects = (Project.query
|
||||||
|
.join(Facility, Project.id == Facility.project_id)
|
||||||
|
.filter(Facility.id.in_(customer_facility_ids), Project.active == True)
|
||||||
|
.distinct().order_by(Project.name).all()) if customer_facility_ids else []
|
||||||
else:
|
else:
|
||||||
facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all()
|
facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all()
|
||||||
|
projects = Project.query.filter_by(active=True).order_by(Project.name).all()
|
||||||
|
|
||||||
return render_template('reports/sla_compliance.html',
|
return render_template('reports/sla_compliance.html',
|
||||||
start=start, end=end,
|
start=start, end=end,
|
||||||
@@ -1292,6 +1338,7 @@ def sla_compliance():
|
|||||||
by_facility=by_facility,
|
by_facility=by_facility,
|
||||||
facility_id_filter=facility_id_filter,
|
facility_id_filter=facility_id_filter,
|
||||||
facilities=facilities,
|
facilities=facilities,
|
||||||
|
projects=projects,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1593,7 +1640,7 @@ def facility_summary_pdf(facility_id):
|
|||||||
cids = get_customer_scope(current_user) or []
|
cids = get_customer_scope(current_user) or []
|
||||||
if facility_id not in cids:
|
if facility_id not in cids:
|
||||||
abort(403)
|
abort(403)
|
||||||
elif current_user.role == 'inspector':
|
elif current_user.is_inspector:
|
||||||
has = Inspection.query.filter_by(facility_id=facility_id,
|
has = Inspection.query.filter_by(facility_id=facility_id,
|
||||||
inspector_id=current_user.id).first()
|
inspector_id=current_user.id).first()
|
||||||
if not has:
|
if not has:
|
||||||
|
|||||||
+265
-29
@@ -12,7 +12,7 @@ from app.models.support import (SupportTicket, SupportTicketReply,
|
|||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.facility import Facility
|
from app.models.facility import Facility
|
||||||
from app.utils.decorators import supervisor_required
|
from app.utils.decorators import supervisor_required
|
||||||
from app.utils.scope import get_customer_scope
|
from app.utils.scope import get_customer_scope, get_inspector_scope
|
||||||
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE
|
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE
|
||||||
from app.utils.time_utils import now_eastern
|
from app.utils.time_utils import now_eastern
|
||||||
from app.utils.notifications import notify
|
from app.utils.notifications import notify
|
||||||
@@ -20,6 +20,38 @@ from app.utils.notifications import notify
|
|||||||
bp = Blueprint('support', __name__, url_prefix='/support')
|
bp = Blueprint('support', __name__, url_prefix='/support')
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Outbound PII redaction ────────────────────────────────────────────────────
|
||||||
|
# Groq is a THIRD PARTY. Customers routinely paste contact details (their own,
|
||||||
|
# or a coworker's) into a support question, and none of that needs to leave the
|
||||||
|
# app to get a helpful, generic answer. This scrubs a best-effort set of PII
|
||||||
|
# patterns from the copy of the text sent to Groq ONLY — the original is still
|
||||||
|
# stored verbatim in support_chat_messages, so the customer's own conversation
|
||||||
|
# history reads normally in the app and staff see what was actually said.
|
||||||
|
#
|
||||||
|
# Best-effort by design: over-redacting a support question costs nothing, while
|
||||||
|
# under-redacting leaks a real address. Order matters — the 13–19 digit card
|
||||||
|
# pattern runs before the phone pattern so a card number is not partly consumed
|
||||||
|
# as a phone number first.
|
||||||
|
import re as _re
|
||||||
|
|
||||||
|
_PII_PATTERNS = [
|
||||||
|
(_re.compile(r'[\w.+-]+@[\w-]+\.[\w.-]+'), '[redacted-email]'),
|
||||||
|
(_re.compile(r'\b\d{3}-\d{2}-\d{4}\b'), '[redacted-ssn]'),
|
||||||
|
(_re.compile(r'\b(?:\d[ -]?){13,19}\b'), '[redacted-number]'),
|
||||||
|
(_re.compile(r'\b(?:\+?1[ .-]?)?\(?\d{3}\)?[ .-]?\d{3}[ .-]?\d{4}\b'), '[redacted-phone]'),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _redact_pii(text):
|
||||||
|
"""Best-effort scrub of email/phone/SSN/card-like sequences from outbound text."""
|
||||||
|
if not text:
|
||||||
|
return text
|
||||||
|
redacted = text
|
||||||
|
for pattern, placeholder in _PII_PATTERNS:
|
||||||
|
redacted = pattern.sub(placeholder, redacted)
|
||||||
|
return redacted
|
||||||
|
|
||||||
# ── Groq system prompt ────────────────────────────────────────────────────────
|
# ── Groq system prompt ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
_SYSTEM_PROMPT = """\
|
_SYSTEM_PROMPT = """\
|
||||||
@@ -37,6 +69,12 @@ Help customers with:
|
|||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
- Keep answers concise (3-5 sentences max) and friendly.
|
- Keep answers concise (3-5 sentences max) and friendly.
|
||||||
|
- Ground answers in everything above, INCLUDING the "ADDITIONAL KNOWLEDGE" section when \
|
||||||
|
one is present — that section is curated by the provider's team and is authoritative. \
|
||||||
|
If it answers the question, use it.
|
||||||
|
- When the knowledge above contains a link (URL), email address or exact wording, quote \
|
||||||
|
it EXACTLY as written. Repeating something given to you here is not inventing — do it \
|
||||||
|
freely. Never alter a URL, shorten it, or replace it with a description.
|
||||||
- Never invent specific staff names, contract prices, schedules, or contact numbers.
|
- Never invent specific staff names, contract prices, schedules, or contact numbers.
|
||||||
- If the customer has an access problem, billing question, or a concern you genuinely \
|
- If the customer has an access problem, billing question, or a concern you genuinely \
|
||||||
cannot resolve through guidance, say so clearly and suggest they click \
|
cannot resolve through guidance, say so clearly and suggest they click \
|
||||||
@@ -53,21 +91,161 @@ FAQS = [
|
|||||||
{'icon': 'bi-megaphone', 'text': 'How do I report a cleaning concern?'},
|
{'icon': 'bi-megaphone', 'text': 'How do I report a cleaning concern?'},
|
||||||
{'icon': 'bi-alarm', 'text': 'What is SLA and how does it work?'},
|
{'icon': 'bi-alarm', 'text': 'What is SLA and how does it work?'},
|
||||||
{'icon': 'bi-bell', 'text': 'How do I get notified on issue updates?'},
|
{'icon': 'bi-bell', 'text': 'How do I get notified on issue updates?'},
|
||||||
|
{'icon': 'bi-phone', 'text': 'Can our own staff use the JQC app to conduct inspections?'},
|
||||||
|
]
|
||||||
|
|
||||||
|
#: Extra chips shown to a Customer Inspector, whose questions are about doing
|
||||||
|
#: the work rather than reading the results. Appended to FAQS, not replacing
|
||||||
|
#: them — they still care about scores and issues.
|
||||||
|
INSPECTOR_FAQS = [
|
||||||
|
{'icon': 'bi-clipboard-plus', 'text': 'How do I start an inspection on the iPad?'},
|
||||||
|
{'icon': 'bi-wifi-off', 'text': 'What happens if I lose signal during an inspection?'},
|
||||||
|
{'icon': 'bi-flag', 'text': 'How do I flag an issue while inspecting?'},
|
||||||
|
{'icon': 'bi-search', 'text': "Why can't I see a form for this facility?"},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
#: Groq model used when GROQ_MODEL is unset. Verified available Aug 2026.
|
||||||
|
#: Groq RETIRES models without notice, and when the configured one disappears
|
||||||
|
#: every question fails with the generic "problem reaching the AI assistant"
|
||||||
|
#: reply — invisible until a customer complains. That is exactly how
|
||||||
|
#: llama-3.3-70b-versatile took the chat down. See the error handler in
|
||||||
|
#: chat_message(): it names the model and says to set GROQ_MODEL, which fixes
|
||||||
|
#: it with an env change and a restart — no deploy.
|
||||||
|
_DEFAULT_GROQ_MODEL = 'openai/gpt-oss-120b'
|
||||||
|
|
||||||
|
|
||||||
|
def _is_customer_side(user):
|
||||||
|
"""True for both customer-side roles — Director and Customer Inspector.
|
||||||
|
|
||||||
|
The AI assistant and the ticket flow are for the CUSTOMER organisation, and
|
||||||
|
a Customer Inspector is part of it: they work at the customer's facilities
|
||||||
|
and have the same questions about scores, issues and the app. This is one
|
||||||
|
of the few places where User.CUSTOMER_ROLES is the right test; every
|
||||||
|
capability/scoping decision below still branches per role (see
|
||||||
|
_support_facilities and _system_prompt_for) — the two roles get the same
|
||||||
|
DOOR, not the same answers.
|
||||||
|
"""
|
||||||
|
return getattr(user, 'is_customer_account', False)
|
||||||
|
|
||||||
|
|
||||||
|
def _support_facilities(user):
|
||||||
|
"""The facilities this user may pick on a support ticket.
|
||||||
|
|
||||||
|
Directors are scoped by CustomerAssignment, Customer Inspectors by
|
||||||
|
InspectorAssignment — reusing the customer helper for both would silently
|
||||||
|
return nothing for an inspector (it returns None for any non-'customer'
|
||||||
|
role) and the facility dropdown would come up empty.
|
||||||
|
"""
|
||||||
|
if getattr(user, 'is_inspector', False):
|
||||||
|
fids = get_inspector_scope(user) or []
|
||||||
|
else:
|
||||||
|
fids = get_customer_scope(user) or []
|
||||||
|
if not fids:
|
||||||
|
return []
|
||||||
|
return (Facility.query
|
||||||
|
.filter(Facility.id.in_(fids), Facility.active == True)
|
||||||
|
.order_by(Facility.name).all())
|
||||||
|
|
||||||
|
|
||||||
|
#: Appended to the system prompt for a Customer Inspector. The base prompt is
|
||||||
|
#: written for the read-mostly portal customer and explicitly tells the model
|
||||||
|
#: NOT to describe staff actions; without this the assistant would deny a
|
||||||
|
#: Customer Inspector the very things they are employed to do.
|
||||||
|
_INSPECTOR_ADDENDUM = """
|
||||||
|
|
||||||
|
=== ABOUT THE PERSON YOU ARE TALKING TO: CUSTOMER INSPECTOR ===
|
||||||
|
This user works FOR the customer but holds an inspecting role in JQC, limited to
|
||||||
|
the contracts they have been assigned. This section OVERRIDES the "only describe
|
||||||
|
what a customer can do" restriction above, for this user only.
|
||||||
|
|
||||||
|
Everything above about the portal still applies to their assigned facilities. IN
|
||||||
|
ADDITION, they can:
|
||||||
|
- Conduct inspections themselves — start one on the web (Inspections -> New
|
||||||
|
Inspection) or in the JQC iPad app, fill in the checklist form, add photos, and
|
||||||
|
submit it.
|
||||||
|
- Use the iPad app OFFLINE: inspections and photos are stored on the device and
|
||||||
|
sync automatically when back online.
|
||||||
|
- Flag an issue during an inspection, and log new issues at their facilities.
|
||||||
|
- Assign an issue to an inspector working on the SAME contract (their own
|
||||||
|
colleagues, or the provider's inspectors) — never to anyone outside it.
|
||||||
|
- Update an issue's status, add comments, and set "Handled By"
|
||||||
|
(Janitorial Staff / Facility Staff / External Vendor) from the iPad.
|
||||||
|
- Work from Scheduled Inspections assigned to them.
|
||||||
|
|
||||||
|
They CANNOT: verify or close out issues (the provider's admin/director does that),
|
||||||
|
manage users, create or edit inspection forms, change the notification matrix, or
|
||||||
|
see anything outside their assigned contracts. If they ask for one of those, say
|
||||||
|
who to ask instead — their own Customer Director, or the provider's team via
|
||||||
|
"Submit to Support".
|
||||||
|
|
||||||
|
Note on forms: the inspection forms they can choose from are the shared standard
|
||||||
|
forms plus any built specifically for their contract. A form built for a different
|
||||||
|
customer will never appear.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
#: The curated knowledge is spliced in immediately BEFORE this heading, not
|
||||||
|
#: appended after it. The rules under it say "ground answers in everything
|
||||||
|
#: above", so knowledge appended after them was, by the prompt's own
|
||||||
|
#: instruction, out of scope — which is exactly why admin KB entries appeared
|
||||||
|
#: to be ignored. Keep this marker in sync with the heading in _SYSTEM_PROMPT.
|
||||||
|
_STYLE_MARKER = 'Rules:'
|
||||||
|
|
||||||
|
|
||||||
|
def _system_prompt_for(user):
|
||||||
|
"""Base prompt + curated knowledge, plus the addendum for this user's role.
|
||||||
|
|
||||||
|
Kept separate from _system_prompt_with_kb() so the curated knowledge base
|
||||||
|
still lands at the same marker regardless of role.
|
||||||
|
"""
|
||||||
|
prompt = _system_prompt_with_kb()
|
||||||
|
if getattr(user, 'is_external_inspector', False):
|
||||||
|
prompt += _INSPECTOR_ADDENDUM
|
||||||
|
return prompt
|
||||||
|
|
||||||
|
|
||||||
def _system_prompt_with_kb():
|
def _system_prompt_with_kb():
|
||||||
"""Return the Groq system prompt, appending active knowledge base entries."""
|
"""Return the Groq system prompt with active knowledge entries spliced in.
|
||||||
|
|
||||||
|
Best-effort — a knowledge-base failure never breaks the chat.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
entries = SupportKnowledge.query.filter_by(active=True).order_by(SupportKnowledge.id).all()
|
entries = (SupportKnowledge.query.filter_by(active=True)
|
||||||
except Exception:
|
.order_by(SupportKnowledge.sort_order.asc(),
|
||||||
return _SYSTEM_PROMPT
|
SupportKnowledge.id.asc()).all())
|
||||||
if not entries:
|
if not entries:
|
||||||
|
logger.info('SUPPORT | KB | no active entries — base prompt only')
|
||||||
|
return _SYSTEM_PROMPT
|
||||||
|
|
||||||
|
parts = ['=== ADDITIONAL KNOWLEDGE (curated by the provider team; authoritative '
|
||||||
|
'— prefer it over general guesses, and quote any link in it exactly) ===']
|
||||||
|
total = 0
|
||||||
|
used = 0
|
||||||
|
for e in entries:
|
||||||
|
block = f'\n\nTopic: {e.title}\n{(e.body or "").strip()}'
|
||||||
|
if total + len(block) > _KB_MAX_CHARS:
|
||||||
|
logger.warning('SUPPORT | KB | %d of %d entries dropped — %d char cap '
|
||||||
|
'reached', len(entries) - used, len(entries), _KB_MAX_CHARS)
|
||||||
|
break
|
||||||
|
parts.append(block)
|
||||||
|
total += len(block)
|
||||||
|
used += 1
|
||||||
|
kb_block = ''.join(parts)
|
||||||
|
|
||||||
|
idx = _SYSTEM_PROMPT.find(_STYLE_MARKER)
|
||||||
|
if idx == -1: # marker renamed — fall back to append
|
||||||
|
logger.warning('SUPPORT | KB | style marker not found; appending at end')
|
||||||
|
prompt = f'{_SYSTEM_PROMPT}\n\n{kb_block}'
|
||||||
|
else:
|
||||||
|
prompt = f'{_SYSTEM_PROMPT[:idx]}{kb_block}\n\n{_SYSTEM_PROMPT[idx:]}'
|
||||||
|
|
||||||
|
logger.info('SUPPORT | KB | %d/%d entries injected (%d chars), prompt=%d chars',
|
||||||
|
used, len(entries), total, len(prompt))
|
||||||
|
return prompt
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning('SUPPORT | knowledge-base load failed: %s', exc)
|
||||||
return _SYSTEM_PROMPT
|
return _SYSTEM_PROMPT
|
||||||
kb_text = '\n\n'.join(f'[{e.title}]\n{e.body}' for e in entries)
|
|
||||||
if len(kb_text) > _KB_MAX_CHARS:
|
|
||||||
kb_text = kb_text[:_KB_MAX_CHARS] + '\n…(truncated)'
|
|
||||||
return _SYSTEM_PROMPT + '\n\n# Additional Context\n' + kb_text
|
|
||||||
|
|
||||||
|
|
||||||
# ── Customer chat page ────────────────────────────────────────────────────────
|
# ── Customer chat page ────────────────────────────────────────────────────────
|
||||||
@@ -75,13 +253,10 @@ def _system_prompt_with_kb():
|
|||||||
@bp.route('/chat')
|
@bp.route('/chat')
|
||||||
@login_required
|
@login_required
|
||||||
def chat():
|
def chat():
|
||||||
if current_user.role != 'customer':
|
if not _is_customer_side(current_user):
|
||||||
return redirect(url_for('support.admin_tickets'))
|
return redirect(url_for('support.admin_tickets'))
|
||||||
|
|
||||||
cids = get_customer_scope(current_user) or []
|
facilities = _support_facilities(current_user)
|
||||||
facilities = (Facility.query
|
|
||||||
.filter(Facility.id.in_(cids), Facility.active == True)
|
|
||||||
.order_by(Facility.name).all()) if cids else []
|
|
||||||
|
|
||||||
groq_ready = bool(os.environ.get('GROQ_API_KEY'))
|
groq_ready = bool(os.environ.get('GROQ_API_KEY'))
|
||||||
session_id = request.args.get('session_id', type=int)
|
session_id = request.args.get('session_id', type=int)
|
||||||
@@ -96,8 +271,9 @@ def chat():
|
|||||||
if chat_session:
|
if chat_session:
|
||||||
db_history = list(chat_session.messages)
|
db_history = list(chat_session.messages)
|
||||||
|
|
||||||
|
faqs = (FAQS + INSPECTOR_FAQS) if current_user.is_external_inspector else FAQS
|
||||||
return render_template('support/chat.html',
|
return render_template('support/chat.html',
|
||||||
faqs=FAQS,
|
faqs=faqs,
|
||||||
facilities=facilities,
|
facilities=facilities,
|
||||||
groq_ready=groq_ready,
|
groq_ready=groq_ready,
|
||||||
chat_session=chat_session,
|
chat_session=chat_session,
|
||||||
@@ -109,7 +285,7 @@ def chat():
|
|||||||
@bp.route('/chat/message', methods=['POST'])
|
@bp.route('/chat/message', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def chat_message():
|
def chat_message():
|
||||||
if current_user.role != 'customer':
|
if not _is_customer_side(current_user):
|
||||||
return jsonify({'error': 'Forbidden'}), 403
|
return jsonify({'error': 'Forbidden'}), 403
|
||||||
|
|
||||||
api_key = os.environ.get('GROQ_API_KEY')
|
api_key = os.environ.get('GROQ_API_KEY')
|
||||||
@@ -158,12 +334,14 @@ def chat_message():
|
|||||||
from groq import Groq
|
from groq import Groq
|
||||||
client = Groq(api_key=api_key)
|
client = Groq(api_key=api_key)
|
||||||
|
|
||||||
messages = [{'role': 'system', 'content': _system_prompt_with_kb()}]
|
messages = [{'role': 'system', 'content': _system_prompt_for(current_user)}]
|
||||||
|
# Redact before the text leaves the app for Groq. The unredacted
|
||||||
|
# originals are persisted below, so nothing is lost in-app.
|
||||||
for m in prior[-20:]:
|
for m in prior[-20:]:
|
||||||
messages.append({'role': m.role, 'content': m.content})
|
messages.append({'role': m.role, 'content': _redact_pii(m.content)})
|
||||||
messages.append({'role': 'user', 'content': user_message})
|
messages.append({'role': 'user', 'content': _redact_pii(user_message)})
|
||||||
|
|
||||||
model = os.environ.get('GROQ_MODEL', 'llama-3.3-70b-versatile')
|
model = os.environ.get('GROQ_MODEL', _DEFAULT_GROQ_MODEL)
|
||||||
completion = client.chat.completions.create(
|
completion = client.chat.completions.create(
|
||||||
model=model,
|
model=model,
|
||||||
messages=messages,
|
messages=messages,
|
||||||
@@ -184,7 +362,18 @@ def chat_message():
|
|||||||
return jsonify({'reply': reply, 'session_id': chat_session.id})
|
return jsonify({'reply': reply, 'session_id': chat_session.id})
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error('SUPPORT | Groq error: %s', exc)
|
# Always name the model — a bare "Groq error" gives whoever reads the
|
||||||
|
# log nothing to act on, and a retired model is the most likely cause
|
||||||
|
# of a total outage here.
|
||||||
|
_model = locals().get('model') or os.environ.get('GROQ_MODEL', _DEFAULT_GROQ_MODEL)
|
||||||
|
if 'model_not_found' in str(exc) or 'does not exist' in str(exc):
|
||||||
|
logger.error(
|
||||||
|
'SUPPORT | Groq model %r is not available on this account — '
|
||||||
|
'the assistant is DOWN for every user. Set GROQ_MODEL to a '
|
||||||
|
'current model (see https://console.groq.com/docs/models). '
|
||||||
|
'Underlying error: %s', _model, exc)
|
||||||
|
else:
|
||||||
|
logger.error('SUPPORT | Groq error (model=%r): %s', _model, exc)
|
||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
return jsonify({'reply': (
|
return jsonify({'reply': (
|
||||||
"I ran into a problem reaching the AI assistant. "
|
"I ran into a problem reaching the AI assistant. "
|
||||||
@@ -197,7 +386,7 @@ def chat_message():
|
|||||||
@bp.route('/my-conversations')
|
@bp.route('/my-conversations')
|
||||||
@login_required
|
@login_required
|
||||||
def my_conversations():
|
def my_conversations():
|
||||||
if current_user.role != 'customer':
|
if not _is_customer_side(current_user):
|
||||||
abort(403)
|
abort(403)
|
||||||
sessions = (SupportChatSession.query
|
sessions = (SupportChatSession.query
|
||||||
.filter_by(customer_id=current_user.id)
|
.filter_by(customer_id=current_user.id)
|
||||||
@@ -209,7 +398,7 @@ def my_conversations():
|
|||||||
@bp.route('/my-conversations/<int:session_id>')
|
@bp.route('/my-conversations/<int:session_id>')
|
||||||
@login_required
|
@login_required
|
||||||
def my_conversation_detail(session_id):
|
def my_conversation_detail(session_id):
|
||||||
if current_user.role != 'customer':
|
if not _is_customer_side(current_user):
|
||||||
abort(403)
|
abort(403)
|
||||||
chat_session = db.session.get(SupportChatSession, session_id)
|
chat_session = db.session.get(SupportChatSession, session_id)
|
||||||
if chat_session is None or chat_session.customer_id != current_user.id:
|
if chat_session is None or chat_session.customer_id != current_user.id:
|
||||||
@@ -225,7 +414,7 @@ def my_conversation_detail(session_id):
|
|||||||
@bp.route('/tickets', methods=['POST'])
|
@bp.route('/tickets', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def submit_ticket():
|
def submit_ticket():
|
||||||
if current_user.role != 'customer':
|
if not _is_customer_side(current_user):
|
||||||
abort(403)
|
abort(403)
|
||||||
|
|
||||||
subject = request.form.get('subject', '').strip()
|
subject = request.form.get('subject', '').strip()
|
||||||
@@ -236,8 +425,9 @@ def submit_ticket():
|
|||||||
flash('Please fill in both subject and description.', 'warning')
|
flash('Please fill in both subject and description.', 'warning')
|
||||||
return redirect(url_for('support.chat'))
|
return redirect(url_for('support.chat'))
|
||||||
|
|
||||||
# Validate facility belongs to this customer
|
# Validate the facility belongs to this user — by whichever assignment
|
||||||
cids = get_customer_scope(current_user) or []
|
# table their role is scoped through.
|
||||||
|
cids = [f.id for f in _support_facilities(current_user)]
|
||||||
if facility_id and facility_id not in cids:
|
if facility_id and facility_id not in cids:
|
||||||
facility_id = None
|
facility_id = None
|
||||||
|
|
||||||
@@ -267,7 +457,7 @@ def submit_ticket():
|
|||||||
@bp.route('/my-tickets')
|
@bp.route('/my-tickets')
|
||||||
@login_required
|
@login_required
|
||||||
def my_tickets():
|
def my_tickets():
|
||||||
if current_user.role != 'customer':
|
if not _is_customer_side(current_user):
|
||||||
abort(403)
|
abort(403)
|
||||||
|
|
||||||
tickets = (SupportTicket.query
|
tickets = (SupportTicket.query
|
||||||
@@ -282,7 +472,7 @@ def my_tickets():
|
|||||||
@bp.route('/my-tickets/<int:ticket_id>', methods=['GET', 'POST'])
|
@bp.route('/my-tickets/<int:ticket_id>', methods=['GET', 'POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def my_ticket_detail(ticket_id):
|
def my_ticket_detail(ticket_id):
|
||||||
if current_user.role != 'customer':
|
if not _is_customer_side(current_user):
|
||||||
abort(403)
|
abort(403)
|
||||||
|
|
||||||
ticket = db.session.get(SupportTicket, ticket_id)
|
ticket = db.session.get(SupportTicket, ticket_id)
|
||||||
@@ -482,10 +672,53 @@ def admin_conversation_detail(session_id):
|
|||||||
@login_required
|
@login_required
|
||||||
@supervisor_required
|
@supervisor_required
|
||||||
def admin_knowledge():
|
def admin_knowledge():
|
||||||
entries = SupportKnowledge.query.order_by(SupportKnowledge.created_at.desc()).all()
|
# Same order the chat prompt uses, so the admin list shows the real
|
||||||
|
# priority rather than a different one.
|
||||||
|
entries = (SupportKnowledge.query
|
||||||
|
.order_by(SupportKnowledge.sort_order.asc(),
|
||||||
|
SupportKnowledge.id.asc()).all())
|
||||||
return render_template('support/admin_knowledge.html', entries=entries)
|
return render_template('support/admin_knowledge.html', entries=entries)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/admin/knowledge/preview')
|
||||||
|
@login_required
|
||||||
|
@supervisor_required
|
||||||
|
def admin_knowledge_preview():
|
||||||
|
"""Show the exact system prompt the chatbot receives, knowledge included.
|
||||||
|
|
||||||
|
Added after admin entries appeared to be ignored: without this there is no
|
||||||
|
way to tell "my entry never reached the prompt" from "the model saw it and
|
||||||
|
chose not to use it". Read-only, builds nothing of its own — it calls the
|
||||||
|
same _system_prompt_with_kb() the chat endpoint calls.
|
||||||
|
"""
|
||||||
|
prompt = _system_prompt_with_kb()
|
||||||
|
active_count = SupportKnowledge.query.filter_by(active=True).count()
|
||||||
|
total_count = SupportKnowledge.query.count()
|
||||||
|
return render_template('support/admin_knowledge_preview.html',
|
||||||
|
prompt=prompt,
|
||||||
|
active_count=active_count,
|
||||||
|
total_count=total_count,
|
||||||
|
kb_included='=== ADDITIONAL KNOWLEDGE' in prompt,
|
||||||
|
kb_cap=_KB_MAX_CHARS)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_sort_order(raw, fallback=0):
|
||||||
|
"""Coerce a submitted sort_order to a sane int.
|
||||||
|
|
||||||
|
The column is NOT NULL, so a blank or non-numeric field must not reach the
|
||||||
|
DB. Clamped to 0..9999 to match the range ST validates, and falls back to
|
||||||
|
the existing value on edit so a blank field means "leave it alone" rather
|
||||||
|
than silently resetting the entry to the top.
|
||||||
|
"""
|
||||||
|
raw = (raw or '').strip()
|
||||||
|
if not raw:
|
||||||
|
return fallback
|
||||||
|
try:
|
||||||
|
return max(0, min(9999, int(raw)))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/admin/knowledge/add', methods=['POST'])
|
@bp.route('/admin/knowledge/add', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
@supervisor_required
|
@supervisor_required
|
||||||
@@ -500,6 +733,7 @@ def admin_knowledge_add():
|
|||||||
title = title,
|
title = title,
|
||||||
body = body,
|
body = body,
|
||||||
active = True,
|
active = True,
|
||||||
|
sort_order = _parse_sort_order(request.form.get('sort_order')),
|
||||||
created_by = current_user.id,
|
created_by = current_user.id,
|
||||||
created_at = now_eastern(),
|
created_at = now_eastern(),
|
||||||
updated_at = now_eastern(),
|
updated_at = now_eastern(),
|
||||||
@@ -527,6 +761,8 @@ def admin_knowledge_edit(entry_id):
|
|||||||
return redirect(url_for('support.admin_knowledge_edit', entry_id=entry_id))
|
return redirect(url_for('support.admin_knowledge_edit', entry_id=entry_id))
|
||||||
entry.title = title
|
entry.title = title
|
||||||
entry.body = body
|
entry.body = body
|
||||||
|
entry.sort_order = _parse_sort_order(request.form.get('sort_order'),
|
||||||
|
entry.sort_order)
|
||||||
entry.updated_at = now_eastern()
|
entry.updated_at = now_eastern()
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
log_action(ACTION_UPDATE, 'SupportKnowledge', entry.id, title[:60], 'edited')
|
log_action(ACTION_UPDATE, 'SupportKnowledge', entry.id, title[:60], 'edited')
|
||||||
|
|||||||
+64
-11
@@ -2,7 +2,8 @@ import logging
|
|||||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify, abort
|
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify, abort
|
||||||
from flask_login import login_required, current_user
|
from flask_login import login_required, current_user
|
||||||
from app import db
|
from app import db
|
||||||
from app.models.inspection import InspectionTemplate, ChecklistItem
|
from app.models.inspection import InspectionTemplate, ChecklistItem, TemplateContract
|
||||||
|
from app.models.project import Project
|
||||||
from app.utils.forms import InspectionTemplateForm, ChecklistItemForm
|
from app.utils.forms import InspectionTemplateForm, ChecklistItemForm
|
||||||
from app.utils.decorators import supervisor_required
|
from app.utils.decorators import supervisor_required
|
||||||
import json
|
import json
|
||||||
@@ -13,6 +14,17 @@ bp = Blueprint('templates', __name__, url_prefix='/templates')
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _populate_contract_choices(form):
|
||||||
|
"""Contract options for the "Available on contracts" multi-select.
|
||||||
|
|
||||||
|
Selecting none leaves the form SHARED (usable on every contract) — that is
|
||||||
|
the default and what every template did before phase52. See
|
||||||
|
TemplateContract.
|
||||||
|
"""
|
||||||
|
contracts = Project.query.filter_by(active=True).order_by(Project.name).all()
|
||||||
|
form.contract_ids.choices = [(p.id, p.name) for p in contracts]
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Template CRUD
|
# Template CRUD
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -21,7 +33,12 @@ logger = logging.getLogger(__name__)
|
|||||||
@login_required
|
@login_required
|
||||||
def index():
|
def index():
|
||||||
templates = InspectionTemplate.query.order_by(InspectionTemplate.name).all()
|
templates = InspectionTemplate.query.order_by(InspectionTemplate.name).all()
|
||||||
return render_template('templates/list.html', templates=templates)
|
# Contract options for the Edit Template modal's "Available on contracts"
|
||||||
|
# picker (phase52) — this modal is the edit UI reached from the list, so it
|
||||||
|
# needs the same control the full editor has.
|
||||||
|
contracts = Project.query.filter_by(active=True).order_by(Project.name).all()
|
||||||
|
return render_template('templates/list.html',
|
||||||
|
templates=templates, contracts=contracts)
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/new', methods=['GET', 'POST'])
|
@bp.route('/new', methods=['GET', 'POST'])
|
||||||
@@ -29,6 +46,7 @@ def index():
|
|||||||
@supervisor_required
|
@supervisor_required
|
||||||
def create_template():
|
def create_template():
|
||||||
form = InspectionTemplateForm()
|
form = InspectionTemplateForm()
|
||||||
|
_populate_contract_choices(form)
|
||||||
|
|
||||||
if form.validate_on_submit():
|
if form.validate_on_submit():
|
||||||
template = InspectionTemplate(
|
template = InspectionTemplate(
|
||||||
@@ -38,11 +56,15 @@ def create_template():
|
|||||||
created_by=current_user.id
|
created_by=current_user.id
|
||||||
)
|
)
|
||||||
db.session.add(template)
|
db.session.add(template)
|
||||||
|
db.session.flush() # need template.id before linking contracts
|
||||||
|
template.set_contracts(form.contract_ids.data)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
logger.info('TEMPLATES | create | user=%s | template_id=%s name=%r',
|
logger.info('TEMPLATES | create | user=%s | template_id=%s name=%r contracts=%s',
|
||||||
current_user.username, template.id, template.name)
|
current_user.username, template.id, template.name,
|
||||||
|
template.contract_ids or 'shared')
|
||||||
log_action(ACTION_CREATE, 'Template', template.id, template.name,
|
log_action(ACTION_CREATE, 'Template', template.id, template.name,
|
||||||
f'frequency={template.frequency}')
|
f'frequency={template.frequency}; '
|
||||||
|
f'contracts={template.contract_ids or "shared"}')
|
||||||
|
|
||||||
flash(f'Template "{template.name}" created successfully.', 'success')
|
flash(f'Template "{template.name}" created successfully.', 'success')
|
||||||
return redirect(url_for('templates.form_editor', template_id=template.id))
|
return redirect(url_for('templates.form_editor', template_id=template.id))
|
||||||
@@ -72,16 +94,23 @@ def edit_template(template_id):
|
|||||||
if template is None:
|
if template is None:
|
||||||
abort(404)
|
abort(404)
|
||||||
form = InspectionTemplateForm(obj=template)
|
form = InspectionTemplateForm(obj=template)
|
||||||
|
_populate_contract_choices(form)
|
||||||
|
if request.method == 'GET':
|
||||||
|
# obj= cannot read the association rows; seed the multi-select from them.
|
||||||
|
form.contract_ids.data = template.contract_ids
|
||||||
|
|
||||||
if form.validate_on_submit():
|
if form.validate_on_submit():
|
||||||
template.name = form.name.data
|
template.name = form.name.data
|
||||||
template.description = form.description.data
|
template.description = form.description.data
|
||||||
template.frequency = form.frequency.data
|
template.frequency = form.frequency.data
|
||||||
|
template.set_contracts(form.contract_ids.data)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
logger.info('TEMPLATES | edit | user=%s | template_id=%s name=%r',
|
logger.info('TEMPLATES | edit | user=%s | template_id=%s name=%r contracts=%s',
|
||||||
current_user.username, template.id, template.name)
|
current_user.username, template.id, template.name,
|
||||||
|
template.contract_ids or 'shared')
|
||||||
log_action(ACTION_UPDATE, 'Template', template.id, template.name,
|
log_action(ACTION_UPDATE, 'Template', template.id, template.name,
|
||||||
f'frequency={template.frequency}')
|
f'frequency={template.frequency}; '
|
||||||
|
f'contracts={template.contract_ids or "shared"}')
|
||||||
flash(f'Template "{template.name}" updated successfully.', 'success')
|
flash(f'Template "{template.name}" updated successfully.', 'success')
|
||||||
return redirect(url_for('templates.view_template', template_id=template.id))
|
return redirect(url_for('templates.view_template', template_id=template.id))
|
||||||
|
|
||||||
@@ -120,11 +149,29 @@ def rename_template(template_id):
|
|||||||
template.description = request.form.get('description', '').strip() or None
|
template.description = request.form.get('description', '').strip() or None
|
||||||
template.frequency = new_frequency
|
template.frequency = new_frequency
|
||||||
|
|
||||||
|
# phase52 — contract restrictions are edited from this modal too, since it
|
||||||
|
# is the Edit Template dialog people actually reach from the list. The
|
||||||
|
# hidden marker distinguishes "the form posted an empty selection" (make
|
||||||
|
# the template shared) from "the form has no contracts field at all", which
|
||||||
|
# must leave the existing restrictions untouched rather than silently
|
||||||
|
# sharing the template with every customer.
|
||||||
|
if request.form.get('contracts_present') == '1':
|
||||||
|
valid_pids = {
|
||||||
|
p.id for p in Project.query.filter_by(active=True).all()
|
||||||
|
}
|
||||||
|
posted = {
|
||||||
|
pid for pid in request.form.getlist('contract_ids', type=int)
|
||||||
|
if pid in valid_pids
|
||||||
|
}
|
||||||
|
template.set_contracts(posted)
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
logger.info('TEMPLATES | rename | user=%s | template_id=%s name=%r',
|
logger.info('TEMPLATES | rename | user=%s | template_id=%s name=%r contracts=%s',
|
||||||
current_user.username, template.id, template.name)
|
current_user.username, template.id, template.name,
|
||||||
|
template.contract_ids or 'shared')
|
||||||
log_action(ACTION_UPDATE, 'Template', template.id, template.name,
|
log_action(ACTION_UPDATE, 'Template', template.id, template.name,
|
||||||
f'frequency={template.frequency}; via=rename')
|
f'frequency={template.frequency}; '
|
||||||
|
f'contracts={template.contract_ids or "shared"}; via=rename')
|
||||||
flash(f'Template "{template.name}" updated successfully.', 'success')
|
flash(f'Template "{template.name}" updated successfully.', 'success')
|
||||||
return redirect(url_for('templates.index'))
|
return redirect(url_for('templates.index'))
|
||||||
|
|
||||||
@@ -188,6 +235,12 @@ def duplicate_template(template_id):
|
|||||||
db.session.add(new_tpl)
|
db.session.add(new_tpl)
|
||||||
db.session.flush() # get new_tpl.id before committing
|
db.session.flush() # get new_tpl.id before committing
|
||||||
|
|
||||||
|
# phase52 — carry the contract restrictions across. Duplicating a
|
||||||
|
# customer's bespoke form must not produce a copy that is silently shared
|
||||||
|
# with every other customer; copying a shared form still yields a shared
|
||||||
|
# one (no links to copy).
|
||||||
|
new_tpl.set_contracts(src.contract_ids)
|
||||||
|
|
||||||
# Duplicate all checklist items
|
# Duplicate all checklist items
|
||||||
for item in src.checklist_items.order_by(ChecklistItem.display_order).all():
|
for item in src.checklist_items.order_by(ChecklistItem.display_order).all():
|
||||||
new_item = ChecklistItem(
|
new_item = ChecklistItem(
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ from app.models.tenant_settings import TenantSettings
|
|||||||
from app.utils.decorators import admin_required
|
from app.utils.decorators import admin_required
|
||||||
from app.utils.audit import log_action, ACTION_UPDATE, ACTION_CREATE, ACTION_DELETE
|
from app.utils.audit import log_action, ACTION_UPDATE, ACTION_CREATE, ACTION_DELETE
|
||||||
from app.utils.time_utils import now_eastern
|
from app.utils.time_utils import now_eastern
|
||||||
|
from app.utils import storage
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -43,7 +44,19 @@ _DOMAIN_RE = re.compile(
|
|||||||
# ── helpers ───────────────────────────────────────────────────────────────────
|
# ── helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _save_logo(file_obj):
|
def _save_logo(file_obj):
|
||||||
"""Save uploaded logo to static/uploads/logos/; return relative URL or None."""
|
"""Save an uploaded logo via the storage seam; return its key or None.
|
||||||
|
|
||||||
|
MT-22: this previously wrote straight to ``UPLOAD_FOLDER/logos/`` with
|
||||||
|
``file_obj.save()`` — the last direct-to-disk writer in the app. On an
|
||||||
|
R2-backed tenant the logo never reached the bucket, and on the local
|
||||||
|
backend every tenant's logo landed in one shared directory.
|
||||||
|
|
||||||
|
Validation stays here by design: ``storage.py`` only moves bytes, and
|
||||||
|
callers own extension / magic-byte checks (see its module docstring).
|
||||||
|
|
||||||
|
The returned key keeps the exact ``uploads/logos/<file>`` shape already
|
||||||
|
stored in ``TenantSettings.logo_url``, so no migration and no data rewrite.
|
||||||
|
"""
|
||||||
if not file_obj or not file_obj.filename:
|
if not file_obj or not file_obj.filename:
|
||||||
return None
|
return None
|
||||||
allowed = {'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp'}
|
allowed = {'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp'}
|
||||||
@@ -63,27 +76,22 @@ def _save_logo(file_obj):
|
|||||||
}
|
}
|
||||||
if not any(header.startswith(m) for m in magic):
|
if not any(header.startswith(m) for m in magic):
|
||||||
return None
|
return None
|
||||||
import secrets
|
file_obj.seek(0)
|
||||||
logos_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], 'logos')
|
return storage.save(file_obj, 'logos')
|
||||||
os.makedirs(logos_dir, exist_ok=True)
|
|
||||||
filename = f'{secrets.token_hex(12)}.{ext}'
|
|
||||||
file_obj.save(os.path.join(logos_dir, filename))
|
|
||||||
return f'uploads/logos/{filename}'
|
|
||||||
|
|
||||||
|
|
||||||
def _delete_logo(logo_url):
|
def _delete_logo(logo_url):
|
||||||
"""Remove a logo file from disk. Silently ignores missing files.
|
"""Remove a stored logo. Silently ignores missing objects.
|
||||||
Safety guard: only deletes files inside the uploads/logos/ subfolder."""
|
Safety guard: only deletes keys inside the uploads/logos/ subfolder."""
|
||||||
|
if not logo_url:
|
||||||
|
return # nothing stored — never issue a delete for 'uploads/logos/'
|
||||||
try:
|
try:
|
||||||
# Use only the basename to avoid any path-traversal via the stored URL.
|
# Use only the basename to avoid any path-traversal via the stored URL.
|
||||||
# All logos are written into logos_dir by _save_logo(), so joining the
|
# All logos are written to 'uploads/logos/' by _save_logo(), so
|
||||||
# basename back to that directory is always the correct path.
|
# rebuilding the key from the basename is always the correct target.
|
||||||
logos_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], 'logos')
|
key = f'uploads/logos/{os.path.basename(str(logo_url or ""))}'
|
||||||
abs_logos = os.path.abspath(logos_dir)
|
storage.delete(key)
|
||||||
abs_path = os.path.join(abs_logos, os.path.basename(logo_url))
|
logger.info('SETTINGS | logo_deleted | key=%s', key)
|
||||||
if os.path.isfile(abs_path):
|
|
||||||
os.remove(abs_path)
|
|
||||||
logger.info('SETTINGS | logo_deleted | path=%s', abs_path)
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning('SETTINGS | logo_delete_failed | url=%s err=%s', logo_url, exc)
|
logger.warning('SETTINGS | logo_delete_failed | url=%s err=%s', logo_url, exc)
|
||||||
|
|
||||||
@@ -211,20 +219,32 @@ def plan():
|
|||||||
'allow_custom_domain': tenant.allow_custom_domain,
|
'allow_custom_domain': tenant.allow_custom_domain,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Live counts (tenant DB)
|
# Live counts (tenant DB), one axis at a time.
|
||||||
|
#
|
||||||
|
# These used to be four calls inside a single dict literal in one
|
||||||
|
# try/except. Python evaluates every value before assigning, so ONE
|
||||||
|
# failing counter discarded the whole dict and the page rendered 0
|
||||||
|
# for all four axes — including users and facilities, which were
|
||||||
|
# fine. That is exactly how a wrong column name in the issues
|
||||||
|
# counter presented as "no usage number ever updates".
|
||||||
from app.tenancy.quota import (
|
from app.tenancy.quota import (
|
||||||
count_active_users, count_active_facilities,
|
count_active_users, count_active_facilities,
|
||||||
count_inspections_this_month, count_issues_this_month,
|
count_inspections_this_month, count_issues_this_month,
|
||||||
)
|
)
|
||||||
|
for axis, counter in (
|
||||||
|
('users', count_active_users),
|
||||||
|
('facilities', count_active_facilities),
|
||||||
|
('inspections', count_inspections_this_month),
|
||||||
|
('issues', count_issues_this_month),
|
||||||
|
):
|
||||||
try:
|
try:
|
||||||
quota_usage = {
|
quota_usage[axis] = counter()
|
||||||
'users': count_active_users(),
|
|
||||||
'facilities': count_active_facilities(),
|
|
||||||
'inspections': count_inspections_this_month(),
|
|
||||||
'issues': count_issues_this_month(),
|
|
||||||
}
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error('tenant_settings.plan: quota count failed: %s', exc)
|
# None (not 0) so the page shows "—": an unknown count and
|
||||||
|
# a genuine zero must not look the same.
|
||||||
|
quota_usage[axis] = None
|
||||||
|
logger.error('tenant_settings.plan: %s count failed: %s',
|
||||||
|
axis, exc)
|
||||||
|
|
||||||
# MT-8: billing fields are already on g.tenant — no extra DB query needed.
|
# MT-8: billing fields are already on g.tenant — no extra DB query needed.
|
||||||
billing_enabled = current_app.config.get('BILLING_ENABLED', False)
|
billing_enabled = current_app.config.get('BILLING_ENABLED', False)
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
"""
|
||||||
|
app/routes/ui.py
|
||||||
|
----------------
|
||||||
|
Web portal design switch and the pages the modern sidebar links to (MT-16).
|
||||||
|
|
||||||
|
Routes
|
||||||
|
POST /ui/theme switch_theme() — flip users.ui_theme classic ↔ modern
|
||||||
|
GET /ui/about about() — About Us page
|
||||||
|
GET /ui/support-center support_center() — support hub of how-to cards
|
||||||
|
GET /ui/theme-votes theme_votes() — admin tally of design choices
|
||||||
|
|
||||||
|
Nothing here changes existing behaviour: the theme flag only selects which
|
||||||
|
layout shell base.html extends. Every page template is untouched.
|
||||||
|
|
||||||
|
Multi-tenant note
|
||||||
|
-----------------
|
||||||
|
`users` is a per-tenant table, so every query in this module is automatically
|
||||||
|
scoped to the caller's tenant by the routing session — the vote tally shows one
|
||||||
|
tenant's users, never the estate. There is deliberately no cross-tenant rollup
|
||||||
|
here; that belongs in the control plane if it is ever wanted.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from flask import (Blueprint, render_template, redirect, request,
|
||||||
|
url_for, flash, current_app)
|
||||||
|
from flask_login import login_required, current_user
|
||||||
|
from sqlalchemy import func
|
||||||
|
|
||||||
|
from app import db
|
||||||
|
from app.models.user import User, ROLE_LABELS
|
||||||
|
from app.utils.audit import log_action, ACTION_UPDATE
|
||||||
|
|
||||||
|
bp = Blueprint('ui', __name__, url_prefix='/ui')
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
VALID_THEMES = ('classic', 'modern')
|
||||||
|
|
||||||
|
|
||||||
|
def _default_theme():
|
||||||
|
"""Fallback design for accounts that have never chosen one."""
|
||||||
|
default = (current_app.config.get('DEFAULT_UI_THEME') or 'classic').lower()
|
||||||
|
return default if default in VALID_THEMES else 'classic'
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_next(target):
|
||||||
|
"""Only allow same-site relative redirects (open-redirect guard).
|
||||||
|
|
||||||
|
A protocol-relative URL ('//evil.com') is a valid redirect target to the
|
||||||
|
browser but points off-site, so the leading-slash test alone is not enough.
|
||||||
|
"""
|
||||||
|
if not target:
|
||||||
|
return url_for('dashboard.index')
|
||||||
|
if target.startswith('/') and not target.startswith('//'):
|
||||||
|
return target
|
||||||
|
return url_for('dashboard.index')
|
||||||
|
|
||||||
|
|
||||||
|
# ── Design switch ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@bp.route('/theme', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def switch_theme():
|
||||||
|
"""Persist the user's design choice, then return them to the same page."""
|
||||||
|
theme = (request.form.get('theme') or '').strip().lower()
|
||||||
|
if theme not in VALID_THEMES:
|
||||||
|
flash('Unknown design option.', 'warning')
|
||||||
|
return redirect(_safe_next(request.form.get('next')))
|
||||||
|
|
||||||
|
previous = current_user.ui_theme or _default_theme()
|
||||||
|
if previous != theme:
|
||||||
|
current_user.ui_theme = theme
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# log_action() commits internally — always AFTER the business commit.
|
||||||
|
log_action(
|
||||||
|
action = ACTION_UPDATE,
|
||||||
|
entity_type = 'User',
|
||||||
|
entity_id = current_user.id,
|
||||||
|
entity_label = current_user.username,
|
||||||
|
details = f'ui_theme={previous}→{theme}',
|
||||||
|
)
|
||||||
|
logger.info('UI | theme switch | user=%s | %s -> %s',
|
||||||
|
current_user.username, previous, theme)
|
||||||
|
flash('Now showing the {} design. You can switch back any time from '
|
||||||
|
'the account menu.'.format('new' if theme == 'modern' else 'classic'),
|
||||||
|
'info')
|
||||||
|
|
||||||
|
return redirect(_safe_next(request.form.get('next')))
|
||||||
|
|
||||||
|
|
||||||
|
# ── New pages (linked from the modern sidebar) ───────────────────────────────
|
||||||
|
|
||||||
|
@bp.route('/about')
|
||||||
|
@login_required
|
||||||
|
def about():
|
||||||
|
return render_template('ui/about.html')
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/support-center')
|
||||||
|
@login_required
|
||||||
|
def support_center():
|
||||||
|
return render_template('ui/support_center.html')
|
||||||
|
|
||||||
|
|
||||||
|
# ── Admin: which design are people actually keeping? ─────────────────────────
|
||||||
|
|
||||||
|
@bp.route('/theme-votes')
|
||||||
|
@login_required
|
||||||
|
def theme_votes():
|
||||||
|
if current_user.role != 'admin':
|
||||||
|
flash('You do not have permission to view the design vote tally.', 'danger')
|
||||||
|
return redirect(url_for('dashboard.index'))
|
||||||
|
|
||||||
|
default = _default_theme()
|
||||||
|
|
||||||
|
rows = (db.session.query(User.ui_theme, func.count(User.id))
|
||||||
|
.filter(User.active == True) # noqa: E712 — SQL boolean
|
||||||
|
.group_by(User.ui_theme)
|
||||||
|
.all())
|
||||||
|
tally = {t: 0 for t in VALID_THEMES}
|
||||||
|
for theme, count in rows:
|
||||||
|
key = theme if theme in VALID_THEMES else default
|
||||||
|
tally[key] = tally.get(key, 0) + count
|
||||||
|
total = sum(tally.values())
|
||||||
|
|
||||||
|
by_role = (db.session.query(User.role, User.ui_theme, func.count(User.id))
|
||||||
|
.filter(User.active == True) # noqa: E712
|
||||||
|
.group_by(User.role, User.ui_theme)
|
||||||
|
.order_by(User.role)
|
||||||
|
.all())
|
||||||
|
|
||||||
|
return render_template('ui/theme_votes.html',
|
||||||
|
tally=tally, total=total, by_role=by_role,
|
||||||
|
default_theme=default,
|
||||||
|
# by_role yields raw role strings from a group_by,
|
||||||
|
# so hand the template the same label source the
|
||||||
|
# User.role_label property uses (MT-15).
|
||||||
|
role_labels=ROLE_LABELS)
|
||||||
@@ -13,15 +13,31 @@
|
|||||||
/* Minimum 44px touch targets on interactive elements */
|
/* Minimum 44px touch targets on interactive elements */
|
||||||
.btn,
|
.btn,
|
||||||
.nav-link,
|
.nav-link,
|
||||||
.dropdown-item,
|
.dropdown-item {
|
||||||
input[type="checkbox"],
|
|
||||||
input[type="radio"],
|
|
||||||
.form-check-input {
|
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Checkboxes and radios are deliberately NOT in the rule above.
|
||||||
|
`display: inline-flex` on a native <input type="radio"> replaces its
|
||||||
|
intrinsic box with a flex container: the control keeps painting its glyph
|
||||||
|
at its natural ~16px size while the element claims a 44px-tall box, so the
|
||||||
|
visible dot and the actual hit area stop coinciding. Taps land next to the
|
||||||
|
control and nothing happens — which is exactly how the per-account
|
||||||
|
notification matrix (Inherit / On / Off) came to look unclickable.
|
||||||
|
|
||||||
|
Grow the target without touching `display`: keep the native box, scale the
|
||||||
|
glyph up, and give it margin so neighbouring options stay separable. Any
|
||||||
|
control that needs a genuinely large tap area should wrap the input in a
|
||||||
|
<label> that fills the cell (see customers/manage.html). */
|
||||||
|
input[type="checkbox"],
|
||||||
|
input[type="radio"],
|
||||||
|
.form-check-input {
|
||||||
|
transform: scale(1.35);
|
||||||
|
margin: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Slightly larger form controls for finger input */
|
/* Slightly larger form controls for finger input */
|
||||||
.form-control,
|
.form-control,
|
||||||
.form-select {
|
.form-select {
|
||||||
|
|||||||
@@ -0,0 +1,482 @@
|
|||||||
|
/* ════════════════════════════════════════════════════════════════════════
|
||||||
|
Janitorial QC — MODERN design skin (design A/B test — "modern")
|
||||||
|
────────────────────────────────────────────────────────────────────────
|
||||||
|
Loaded ONLY by templates/layouts/modern.html, and always AFTER
|
||||||
|
theme.css, so every token below overrides the classic one.
|
||||||
|
|
||||||
|
The classic design is completely untouched by this file.
|
||||||
|
|
||||||
|
Palette sampled from the JQC_design deck:
|
||||||
|
brand #155F82 deep teal-blue top bar / table headers
|
||||||
|
brand-700 #0F4A66 hover / pressed
|
||||||
|
brand-050 #DCEBF5 soft icon tiles, active rail rows
|
||||||
|
page #EAEEF1 page background
|
||||||
|
surface #FFFFFF cards
|
||||||
|
ink #1D2A32 headings
|
||||||
|
muted #6B7A85 secondary text
|
||||||
|
════════════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
/* ── 1. Tokens ───────────────────────────────────────────────────────────── */
|
||||||
|
body.jqc-modern {
|
||||||
|
--jqc-brand: #155F82;
|
||||||
|
--jqc-brand-700: #0F4A66;
|
||||||
|
--jqc-brand-600: #1B6E93;
|
||||||
|
--jqc-brand-050: #DCEBF5;
|
||||||
|
--jqc-brand-025: #E9F0F8;
|
||||||
|
--jqc-page: #EAEEF1;
|
||||||
|
--jqc-ink: #1D2A32;
|
||||||
|
--jqc-heading: #1D2A32;
|
||||||
|
--jqc-muted: #6B7A85;
|
||||||
|
--jqc-faint: #93A1AB;
|
||||||
|
--jqc-border: #E3E8EC;
|
||||||
|
--jqc-border-2: #CFD9E0;
|
||||||
|
--jqc-surface: #F5F8FA; /* subtle fill — hovers, muted rows (theme.css) */
|
||||||
|
--jqc-surface-2: #EEF3F6;
|
||||||
|
--jqc-card-bg: #FFFFFF; /* raised surfaces — cards, KPI tiles, sidebar */
|
||||||
|
--jqc-accent: #155F82;
|
||||||
|
--jqc-accent-700: #0F4A66;
|
||||||
|
--jqc-accent-50: #DCEBF5;
|
||||||
|
--jqc-shadow: 0 1px 2px rgba(21, 46, 62, .05), 0 6px 18px rgba(21, 46, 62, .06);
|
||||||
|
--jqc-shadow-md: 0 10px 30px rgba(21, 46, 62, .12);
|
||||||
|
|
||||||
|
--bs-primary: #155F82;
|
||||||
|
--bs-primary-rgb: 21, 95, 130;
|
||||||
|
--bs-link-color: #155F82;
|
||||||
|
--bs-link-color-rgb: 21, 95, 130;
|
||||||
|
--bs-link-hover-color: #0F4A66;
|
||||||
|
--bs-link-hover-color-rgb: 15, 74, 102;
|
||||||
|
--bs-body-bg: #EAEEF1;
|
||||||
|
--bs-body-color: #1D2A32;
|
||||||
|
--bs-border-color: #E3E8EC;
|
||||||
|
--bs-border-radius: .6rem;
|
||||||
|
--bs-border-radius-sm: .45rem;
|
||||||
|
--bs-border-radius-lg: 1rem;
|
||||||
|
--bs-border-radius-xl: 1.15rem;
|
||||||
|
|
||||||
|
--jqc-topbar-h: 72px;
|
||||||
|
--jqc-sidebar-w: 232px;
|
||||||
|
|
||||||
|
background-color: var(--jqc-page);
|
||||||
|
color: var(--jqc-ink);
|
||||||
|
font-family: 'DM Sans', system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 2. Top bar ──────────────────────────────────────────────────────────── */
|
||||||
|
.jqc-modern .jqc-topbar {
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0; right: 0;
|
||||||
|
height: var(--jqc-topbar-h);
|
||||||
|
z-index: 1035;
|
||||||
|
background: var(--jqc-brand);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 0 20px;
|
||||||
|
padding-top: env(safe-area-inset-top);
|
||||||
|
box-shadow: 0 1px 0 rgba(0, 0, 0, .10);
|
||||||
|
}
|
||||||
|
.jqc-modern .jqc-brand {
|
||||||
|
text-decoration: none;
|
||||||
|
color: #fff;
|
||||||
|
line-height: 1;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.jqc-modern .jqc-brand-mark {
|
||||||
|
display: block;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -.02em;
|
||||||
|
}
|
||||||
|
.jqc-modern .jqc-brand-sub {
|
||||||
|
display: block;
|
||||||
|
font-size: .68rem;
|
||||||
|
opacity: .82;
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
/* MT: a tenant with an uploaded logo renders it in place of the "JQC" wordmark.
|
||||||
|
Capped in height so a tall logo cannot stretch the top bar. */
|
||||||
|
.jqc-modern .jqc-brand-logo {
|
||||||
|
display: block;
|
||||||
|
max-height: 30px;
|
||||||
|
max-width: 150px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
.jqc-modern .jqc-search {
|
||||||
|
position: relative;
|
||||||
|
margin-left: auto;
|
||||||
|
width: min(420px, 42vw);
|
||||||
|
}
|
||||||
|
.jqc-modern .jqc-search i {
|
||||||
|
position: absolute;
|
||||||
|
left: 16px; top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: var(--jqc-muted);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.jqc-modern .jqc-search .form-control {
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
height: 42px;
|
||||||
|
padding-left: 44px;
|
||||||
|
background: #fff;
|
||||||
|
font-size: .92rem;
|
||||||
|
}
|
||||||
|
.jqc-modern .jqc-search .form-control:focus {
|
||||||
|
box-shadow: 0 0 0 .2rem rgba(255, 255, 255, .35);
|
||||||
|
}
|
||||||
|
.jqc-modern .jqc-topbar-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.jqc-modern .jqc-icon-btn {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px; height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: background-color .15s;
|
||||||
|
}
|
||||||
|
.jqc-modern .jqc-icon-btn:hover { background: rgba(255, 255, 255, .14); color: #fff; }
|
||||||
|
.jqc-modern .jqc-avatar {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 44px; height: 44px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--jqc-brand-700);
|
||||||
|
border: 2px solid rgba(255, 255, 255, .85);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: .9rem;
|
||||||
|
letter-spacing: .02em;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.jqc-modern .jqc-avatar:hover { background: #0b3b53; color: #fff; }
|
||||||
|
.jqc-modern .jqc-hamburger {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 4px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 3. Sidebar ──────────────────────────────────────────────────────────── */
|
||||||
|
.jqc-modern .jqc-sidebar {
|
||||||
|
position: fixed;
|
||||||
|
top: var(--jqc-topbar-h);
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
width: var(--jqc-sidebar-w);
|
||||||
|
background: var(--jqc-card-bg);
|
||||||
|
border-right: 1px solid var(--jqc-border);
|
||||||
|
overflow-y: auto;
|
||||||
|
z-index: 1030;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
.jqc-modern .jqc-nav { flex: 1 1 auto; }
|
||||||
|
.jqc-modern .jqc-nav-link {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 13px 18px 13px 22px;
|
||||||
|
color: #43525C;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: .95rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: background-color .15s, color .15s;
|
||||||
|
}
|
||||||
|
.jqc-modern .jqc-nav-link i { font-size: 1.15rem; width: 22px; text-align: center; }
|
||||||
|
.jqc-modern .jqc-nav-link span { flex: 1 1 auto; }
|
||||||
|
.jqc-modern .jqc-nav-link:hover { background: var(--jqc-brand-025); color: var(--jqc-brand); }
|
||||||
|
.jqc-modern .jqc-nav-link.active {
|
||||||
|
background: var(--jqc-brand-025);
|
||||||
|
color: var(--jqc-brand);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.jqc-modern .jqc-nav-link.active::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0; top: 0; bottom: 0;
|
||||||
|
width: 5px;
|
||||||
|
background: var(--jqc-brand);
|
||||||
|
}
|
||||||
|
.jqc-modern .jqc-nav-caret { font-size: .7rem !important; width: auto !important; opacity: .6; }
|
||||||
|
.jqc-modern .jqc-nav-badge {
|
||||||
|
background: #D9534F;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: .68rem;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 1px 7px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.jqc-modern .jqc-nav-sublink {
|
||||||
|
display: block;
|
||||||
|
padding: 9px 18px 9px 58px;
|
||||||
|
font-size: .88rem;
|
||||||
|
color: #5A6A75;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.jqc-modern .jqc-nav-sublink:hover { background: var(--jqc-brand-025); color: var(--jqc-brand); }
|
||||||
|
.jqc-modern .jqc-nav-sublink.active { color: var(--jqc-brand); font-weight: 700; }
|
||||||
|
/* .jqc-sidebar-foot / .jqc-switch-btn were dropped in phase50 along with the
|
||||||
|
sidebar design switcher — no template references them any more. */
|
||||||
|
.jqc-modern .jqc-sidebar-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(15, 34, 46, .45);
|
||||||
|
z-index: 1029;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.jqc-modern .jqc-sidebar-backdrop.show { display: block; }
|
||||||
|
|
||||||
|
/* ── 4. Main region ──────────────────────────────────────────────────────── */
|
||||||
|
.jqc-modern .jqc-main {
|
||||||
|
margin-left: var(--jqc-sidebar-w);
|
||||||
|
padding: calc(var(--jqc-topbar-h) + 22px) 10px 40px;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
.jqc-modern .jqc-main > .container-fluid { padding-inline: 14px; }
|
||||||
|
|
||||||
|
@media (max-width: 991.98px) {
|
||||||
|
.jqc-modern .jqc-sidebar {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
transition: transform .2s ease;
|
||||||
|
box-shadow: 0 0 24px rgba(15, 34, 46, .18);
|
||||||
|
}
|
||||||
|
.jqc-modern .jqc-sidebar.open { transform: translateX(0); }
|
||||||
|
.jqc-modern .jqc-main { margin-left: 0; }
|
||||||
|
.jqc-modern .jqc-search { width: auto; flex: 1 1 auto; }
|
||||||
|
.jqc-modern .jqc-brand-sub { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 5. Page heading block (used by the rebuilt modern pages) ────────────── */
|
||||||
|
.jqc-modern .jqc-page-head { margin-bottom: 20px; }
|
||||||
|
.jqc-modern .jqc-page-head h1,
|
||||||
|
.jqc-modern .jqc-page-title {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -.02em;
|
||||||
|
color: var(--jqc-ink);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
/* Opt-in centring — the default is left-aligned. `.center` on the head block
|
||||||
|
centres the title and its sub-line together. */
|
||||||
|
.jqc-modern .jqc-page-head.center,
|
||||||
|
.jqc-modern .jqc-page-title.center { text-align: center; }
|
||||||
|
.jqc-modern .jqc-page-sub {
|
||||||
|
color: var(--jqc-muted);
|
||||||
|
font-size: .95rem;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 6. Cards / surfaces (applies to every page, rebuilt or not) ─────────── */
|
||||||
|
.jqc-modern .card {
|
||||||
|
border: 1px solid var(--jqc-border);
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: var(--jqc-shadow);
|
||||||
|
}
|
||||||
|
.jqc-modern .card-header {
|
||||||
|
background: var(--jqc-card-bg);
|
||||||
|
border-bottom: 1px solid var(--jqc-border);
|
||||||
|
color: var(--jqc-ink);
|
||||||
|
font-weight: 700;
|
||||||
|
padding: .9rem 1.15rem;
|
||||||
|
}
|
||||||
|
.jqc-modern .card-header.bg-light,
|
||||||
|
.jqc-modern .card-header.bg-white { background: var(--jqc-card-bg) !important; }
|
||||||
|
.jqc-modern .card-body { padding: 1.15rem; }
|
||||||
|
|
||||||
|
.jqc-modern .jqc-card {
|
||||||
|
background: var(--jqc-card-bg);
|
||||||
|
border: 1px solid var(--jqc-border);
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: var(--jqc-shadow);
|
||||||
|
padding: 20px 22px;
|
||||||
|
margin-bottom: 22px;
|
||||||
|
}
|
||||||
|
.jqc-modern .jqc-card-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--jqc-ink);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Soft square icon tile — the deck's signature element */
|
||||||
|
.jqc-modern .jqc-tile-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 42px; height: 42px;
|
||||||
|
border-radius: 11px;
|
||||||
|
background: var(--jqc-brand-050);
|
||||||
|
color: var(--jqc-brand);
|
||||||
|
font-size: 1.15rem;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.jqc-modern .jqc-tile-icon.lg { width: 74px; height: 74px; border-radius: 18px; font-size: 2rem; }
|
||||||
|
|
||||||
|
/* KPI tiles */
|
||||||
|
.jqc-modern .jqc-kpi {
|
||||||
|
background: var(--jqc-card-bg);
|
||||||
|
border: 1px solid var(--jqc-border);
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: var(--jqc-shadow);
|
||||||
|
padding: 18px 20px;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
transition: box-shadow .15s, transform .15s;
|
||||||
|
}
|
||||||
|
a.jqc-kpi:hover { box-shadow: var(--jqc-shadow-md); transform: translateY(-1px); color: inherit; }
|
||||||
|
.jqc-modern .jqc-kpi-value {
|
||||||
|
font-size: 2.1rem;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.05;
|
||||||
|
color: var(--jqc-ink);
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.jqc-modern .jqc-kpi-label { font-size: .85rem; color: var(--jqc-muted); margin-top: 2px; }
|
||||||
|
|
||||||
|
/* Label / value rows inside summary cards */
|
||||||
|
.jqc-modern .jqc-stat-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 11px 2px;
|
||||||
|
border-bottom: 1px solid var(--jqc-border);
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--jqc-ink);
|
||||||
|
}
|
||||||
|
.jqc-modern .jqc-stat-row:last-child { border-bottom: none; }
|
||||||
|
.jqc-modern .jqc-stat-row:hover { color: var(--jqc-brand); }
|
||||||
|
.jqc-modern .jqc-stat-label { font-size: .95rem; display: flex; align-items: center; gap: 9px; }
|
||||||
|
.jqc-modern .jqc-stat-value { font-size: 1.05rem; font-weight: 800; white-space: nowrap; }
|
||||||
|
.jqc-modern .jqc-dot {
|
||||||
|
width: 9px; height: 9px; border-radius: 50%;
|
||||||
|
display: inline-block; flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hub cards (Facility / Support pages) */
|
||||||
|
.jqc-modern .jqc-hub-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--jqc-card-bg);
|
||||||
|
border: 1px solid var(--jqc-border);
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: var(--jqc-shadow);
|
||||||
|
padding: 24px 26px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
transition: box-shadow .15s, transform .15s;
|
||||||
|
}
|
||||||
|
.jqc-modern .jqc-hub-card:hover { box-shadow: var(--jqc-shadow-md); transform: translateY(-2px); color: inherit; }
|
||||||
|
.jqc-modern .jqc-hub-title { font-size: 1.3rem; font-weight: 800; color: var(--jqc-ink); }
|
||||||
|
.jqc-modern .jqc-hub-text { color: var(--jqc-muted); font-size: .93rem; margin-top: 6px; }
|
||||||
|
.jqc-modern .jqc-hub-open { color: var(--jqc-brand); font-weight: 700; font-size: .9rem; margin-top: auto; padding-top: 18px; }
|
||||||
|
.jqc-modern .jqc-hub-card.dark { background: var(--jqc-brand); border-color: var(--jqc-brand); }
|
||||||
|
.jqc-modern .jqc-hub-card.dark .jqc-hub-title,
|
||||||
|
.jqc-modern .jqc-hub-card.dark .jqc-hub-text { color: #fff; }
|
||||||
|
.jqc-modern .jqc-hub-card.dark .jqc-tile-icon { background: #fff; }
|
||||||
|
/* The default .jqc-hub-open is brand-coloured, which is invisible on the dark
|
||||||
|
(brand-filled) card — it needs its own colour. */
|
||||||
|
.jqc-modern .jqc-hub-card.dark .jqc-hub-open { color: #fff; }
|
||||||
|
|
||||||
|
/* ── 7. Tables — dark teal header, as in the deck ────────────────────────── */
|
||||||
|
.jqc-modern .table { --bs-table-border-color: var(--jqc-border); margin-bottom: 0; }
|
||||||
|
|
||||||
|
/* Recoloured via Bootstrap's own table CSS variables rather than !important, so
|
||||||
|
a page that deliberately wants a different header (table-dark, a tinted
|
||||||
|
report header) can still override it with a normal rule. */
|
||||||
|
.jqc-modern .table > thead > tr > th,
|
||||||
|
.jqc-modern .table thead.table-light > tr > th,
|
||||||
|
.jqc-modern .table > thead th {
|
||||||
|
--bs-table-bg: var(--jqc-brand);
|
||||||
|
--bs-table-color: #fff;
|
||||||
|
background-color: var(--jqc-brand);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--jqc-brand-700);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: .88rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* nowrap only where the column set is known-narrow (dashboard panels); wide
|
||||||
|
tables such as the issues list and audit trail must be free to wrap rather
|
||||||
|
than force a horizontal scroll on iPad portrait. */
|
||||||
|
.jqc-modern .jqc-card .table > thead th { white-space: nowrap; }
|
||||||
|
.jqc-modern .table > tbody > tr > td { vertical-align: middle; font-size: .92rem; }
|
||||||
|
.jqc-modern .table-hover > tbody > tr:hover > * { background-color: var(--jqc-brand-025); }
|
||||||
|
.jqc-modern .jqc-table-wrap {
|
||||||
|
border: 1px solid var(--jqc-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 8. Buttons, badges, forms ───────────────────────────────────────────── */
|
||||||
|
.jqc-modern .btn { border-radius: .6rem; font-weight: 600; }
|
||||||
|
.jqc-modern .btn-primary {
|
||||||
|
--bs-btn-bg: var(--jqc-brand); --bs-btn-border-color: var(--jqc-brand);
|
||||||
|
--bs-btn-hover-bg: var(--jqc-brand-700); --bs-btn-hover-border-color: var(--jqc-brand-700);
|
||||||
|
--bs-btn-active-bg: var(--jqc-brand-700); --bs-btn-active-border-color: var(--jqc-brand-700);
|
||||||
|
--bs-btn-disabled-bg: var(--jqc-brand); --bs-btn-disabled-border-color: var(--jqc-brand);
|
||||||
|
}
|
||||||
|
.jqc-modern .btn-outline-primary {
|
||||||
|
--bs-btn-color: var(--jqc-brand); --bs-btn-border-color: var(--jqc-brand);
|
||||||
|
--bs-btn-hover-bg: var(--jqc-brand); --bs-btn-hover-border-color: var(--jqc-brand);
|
||||||
|
--bs-btn-active-bg: var(--jqc-brand); --bs-btn-active-border-color: var(--jqc-brand);
|
||||||
|
}
|
||||||
|
.jqc-modern .bg-primary { background-color: var(--jqc-brand) !important; }
|
||||||
|
.jqc-modern .text-primary { color: var(--jqc-brand) !important; }
|
||||||
|
.jqc-modern .badge { border-radius: 999px; font-weight: 700; padding: .35em .7em; }
|
||||||
|
.jqc-modern .form-control,
|
||||||
|
.jqc-modern .form-select {
|
||||||
|
border-radius: .6rem;
|
||||||
|
border-color: var(--jqc-border-2);
|
||||||
|
}
|
||||||
|
.jqc-modern .form-control:focus,
|
||||||
|
.jqc-modern .form-select:focus {
|
||||||
|
border-color: var(--jqc-brand);
|
||||||
|
box-shadow: 0 0 0 .18rem rgba(21, 95, 130, .18);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Filter bar — the rounded pill row from the deck */
|
||||||
|
.jqc-modern .jqc-filter-bar {
|
||||||
|
background: var(--jqc-card-bg);
|
||||||
|
border: 1px solid var(--jqc-border);
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: var(--jqc-shadow);
|
||||||
|
padding: 14px 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.jqc-modern .jqc-filter-bar .form-control,
|
||||||
|
.jqc-modern .jqc-filter-bar .form-select { border-radius: 999px; padding-inline: 16px; }
|
||||||
|
|
||||||
|
/* ── 9. Alerts / misc ────────────────────────────────────────────────────── */
|
||||||
|
.jqc-modern .alert { border-radius: 12px; border: 1px solid var(--jqc-border); }
|
||||||
|
.jqc-modern .dropdown-menu { border-radius: 12px; border-color: var(--jqc-border); box-shadow: var(--jqc-shadow-md); }
|
||||||
|
.jqc-modern .nav-tabs .nav-link.active { color: var(--jqc-brand); }
|
||||||
|
.jqc-modern .progress-bar.bg-success { background-color: #2E7D4F !important; }
|
||||||
|
|
||||||
|
/* Print: drop the chrome entirely */
|
||||||
|
@media print {
|
||||||
|
.jqc-modern .jqc-topbar,
|
||||||
|
.jqc-modern .jqc-sidebar,
|
||||||
|
.jqc-modern .jqc-sidebar-backdrop { display: none !important; }
|
||||||
|
.jqc-modern .jqc-main { margin-left: 0; padding-top: 0; }
|
||||||
|
}
|
||||||
@@ -81,6 +81,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── My Data & Privacy (phase51) ───────────────────────────── -->
|
||||||
|
<div class="card shadow-sm mt-4">
|
||||||
|
<div class="card-header bg-light">
|
||||||
|
<h6 class="mb-0 fw-semibold"><i class="bi bi-shield-lock me-1"></i>My Data & Privacy</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted small mb-3">
|
||||||
|
Download a copy of the data tied to your account, or request its
|
||||||
|
erasure.
|
||||||
|
</p>
|
||||||
|
<a href="{{ url_for('auth.export_my_data') }}"
|
||||||
|
class="btn btn-outline-secondary btn-sm w-100 mb-2">
|
||||||
|
<i class="bi bi-download me-1"></i>Export My Data
|
||||||
|
</a>
|
||||||
|
<button type="button" class="btn btn-outline-danger btn-sm w-100"
|
||||||
|
data-bs-toggle="modal" data-bs-target="#deleteMyDataModal">
|
||||||
|
<i class="bi bi-trash me-1"></i>Delete My Account & Data
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Right column: edit form + recent inspections ──────────────── -->
|
<!-- ── Right column: edit form + recent inspections ──────────────── -->
|
||||||
@@ -263,4 +284,38 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Delete My Data Modal (phase51) ──────────────────────────────────── -->
|
||||||
|
<div class="modal fade" id="deleteMyDataModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header bg-danger text-white">
|
||||||
|
<h5 class="modal-title"><i class="bi bi-exclamation-triangle me-2"></i>Delete My Account & Data</h5>
|
||||||
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<form method="POST" action="{{ url_for('auth.request_my_data_deletion') }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="alert alert-warning mb-3">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill me-1"></i>
|
||||||
|
This action is <strong>permanent</strong> and logs you out immediately.
|
||||||
|
</div>
|
||||||
|
<p class="mb-0">
|
||||||
|
If you have no inspection or issue history tied to your account, it will be
|
||||||
|
<strong>permanently deleted</strong>. If you do have history (common for staff
|
||||||
|
accounts), your name, username, and email will be replaced with a
|
||||||
|
non-identifying placeholder and the account deactivated — historical records
|
||||||
|
stay intact for audit continuity but will no longer identify you.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-danger">
|
||||||
|
<i class="bi bi-trash me-1"></i>Confirm Deletion
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -80,7 +80,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
{# phase51 — the email-invitation branch that used to live here
|
||||||
|
moved to Customer Management along with the Customer
|
||||||
|
Inspector role. Every role this form still offers is our own
|
||||||
|
staff, created with an admin-set password. #}
|
||||||
|
<div class="row" id="passwordFields">
|
||||||
<div class="col-md-6 mb-3">
|
<div class="col-md-6 mb-3">
|
||||||
{{ form.password.label(class="form-label") }}
|
{{ form.password.label(class="form-label") }}
|
||||||
{{ form.password(class="form-control", placeholder="Leave blank to keep current" if user else "") }}
|
{{ form.password(class="form-control", placeholder="Leave blank to keep current" if user else "") }}
|
||||||
@@ -128,4 +132,5 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -37,12 +37,12 @@
|
|||||||
<td>{{ user.full_name or '—' }}</td>
|
<td>{{ user.full_name or '—' }}</td>
|
||||||
<td>{{ user.email }}</td>
|
<td>{{ user.email }}</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="badge bg-{% if user.role == 'admin' %}danger{% elif user.role == 'director' %}warning{% elif user.role == 'project_manager' %}primary{% elif user.role == 'customer' %}success{% else %}info{% endif %}">
|
<span class="badge bg-{% if user.role == 'admin' %}danger{% elif user.role == 'director' %}warning{% elif user.role == 'project_manager' %}primary{% elif user.role == 'auditor' %}secondary{% elif user.role == 'customer' %}success{% elif user.role == 'external_inspector' %}dark{% else %}info{% endif %}">
|
||||||
{{ user.role.replace('_',' ')|title }}
|
{{ user.role_label }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if user.role == 'inspector' %}
|
{% if user.is_inspector %}
|
||||||
{% set cnt = inspector_contract_counts.get(user.id, 0) %}
|
{% set cnt = inspector_contract_counts.get(user.id, 0) %}
|
||||||
{% if cnt > 0 %}
|
{% if cnt > 0 %}
|
||||||
<span class="badge bg-success">{{ cnt }} contract{{ 's' if cnt != 1 else '' }}</span>
|
<span class="badge bg-success">{{ cnt }} contract{{ 's' if cnt != 1 else '' }}</span>
|
||||||
@@ -65,12 +65,23 @@
|
|||||||
<a href="{{ url_for('auth.edit_user', user_id=user.id) }}" class="btn btn-sm btn-outline-primary" title="Edit">
|
<a href="{{ url_for('auth.edit_user', user_id=user.id) }}" class="btn btn-sm btn-outline-primary" title="Edit">
|
||||||
<i class="bi bi-pencil"></i>
|
<i class="bi bi-pencil"></i>
|
||||||
</a>
|
</a>
|
||||||
{% if user.role == 'inspector' %}
|
{% if user.is_inspector %}
|
||||||
<a href="{{ url_for('auth.assign_inspector_contracts', user_id=user.id) }}"
|
<a href="{{ url_for('auth.assign_inspector_contracts', user_id=user.id) }}"
|
||||||
class="btn btn-sm btn-outline-secondary" title="Assign contracts">
|
class="btn btn-sm btn-outline-secondary" title="Assign contracts">
|
||||||
<i class="bi bi-briefcase"></i>
|
<i class="bi bi-briefcase"></i>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if not user.password_set %}
|
||||||
|
<form method="POST" action="{{ url_for('auth.resend_invite', user_id=user.id) }}"
|
||||||
|
class="d-inline"
|
||||||
|
onsubmit="return confirm('Resend the invitation email to {{ user.email }}? The previous link will stop working.');">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-warning"
|
||||||
|
title="Resend invitation email">
|
||||||
|
<i class="bi bi-envelope-arrow-up"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
{% if user.id != current_user.id %}
|
{% if user.id != current_user.id %}
|
||||||
<form method="POST" action="{{ url_for('auth.toggle_active', user_id=user.id) }}" class="d-inline">
|
<form method="POST" action="{{ url_for('auth.toggle_active', user_id=user.id) }}" class="d-inline">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
|||||||
+12
-522
@@ -1,525 +1,15 @@
|
|||||||
<!DOCTYPE html>
|
{# ────────────────────────────────────────────────────────────────────────────
|
||||||
<html lang="en">
|
base.html — layout dispatcher (MT-16)
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
|
||||||
<!-- iOS / iPadOS web app meta tags -->
|
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
|
||||||
<meta name="mobile-web-app-capable" content="yes">
|
|
||||||
<title>{% block title %}Janitorial QC System{% endblock %}</title>
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
|
|
||||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&display=swap">
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/theme.css') }}">
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/ipad_responsive.css') }}">
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/mobile_phone.css') }}">
|
|
||||||
{% if tenant_branding %}
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--bs-primary: {{ tenant_branding.primary_color or '#1a56db' }};
|
|
||||||
--bs-primary-rgb: {{ tenant_branding.primary_color|hex_to_rgb if tenant_branding.primary_color else '26,86,219' }};
|
|
||||||
--jqc-accent: {{ tenant_branding.accent_color or '#16a34a' }};
|
|
||||||
}
|
|
||||||
.bg-primary { background-color: {{ tenant_branding.primary_color or '#1a56db' }} !important; }
|
|
||||||
.btn-primary { background-color: {{ tenant_branding.primary_color or '#1a56db' }} !important;
|
|
||||||
border-color: {{ tenant_branding.primary_color or '#1a56db' }} !important; }
|
|
||||||
</style>
|
|
||||||
{% endif %}
|
|
||||||
{% block extra_css %}{% endblock %}
|
|
||||||
<style>
|
|
||||||
/* ── Notification bell styles ── */
|
|
||||||
.notif-bell-wrapper { position: relative; }
|
|
||||||
.notif-badge {
|
|
||||||
position: absolute;
|
|
||||||
top: 2px; right: 2px;
|
|
||||||
font-size: 0.6rem;
|
|
||||||
min-width: 16px; height: 16px; line-height: 16px;
|
|
||||||
padding: 0 4px; border-radius: 8px;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
.notif-dropdown {
|
|
||||||
width: 380px;
|
|
||||||
max-height: 520px;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
.notif-item {
|
|
||||||
border-left: 3px solid transparent;
|
|
||||||
transition: background 0.15s;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.notif-item.unread {
|
|
||||||
border-left-color: #0d6efd;
|
|
||||||
background-color: #f0f6ff;
|
|
||||||
}
|
|
||||||
.notif-item:hover { background-color: #e8f0fe; }
|
|
||||||
.notif-title { font-size: 0.85rem; font-weight: 600; margin-bottom: 2px; }
|
|
||||||
.notif-body { font-size: 0.78rem; color: #555; white-space: normal; }
|
|
||||||
.notif-time { font-size: 0.7rem; color: #999; }
|
|
||||||
.notif-empty { padding: 24px; text-align: center; color: #aaa; font-size: 0.85rem; }
|
|
||||||
|
|
||||||
/* ── Active nav tab ── */
|
This file used to hold the entire page chrome. That markup now lives in
|
||||||
.navbar-dark .navbar-nav .nav-link.active {
|
layouts/classic.html, unchanged.
|
||||||
background-color: rgba(255, 255, 255, 0.18);
|
|
||||||
color: #ffffff !important;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-weight: 600;
|
|
||||||
box-shadow: inset 0 -2px 0 rgba(255,255,255,0.6);
|
|
||||||
}
|
|
||||||
.navbar-dark .navbar-nav .nav-link:not(.active):hover {
|
|
||||||
background-color: rgba(255, 255, 255, 0.08);
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
{% if current_user.is_authenticated %}
|
|
||||||
<nav class="navbar navbar-expand-xxl navbar-dark bg-primary">
|
|
||||||
<div class="container-fluid">
|
|
||||||
<a class="navbar-brand" href="{{ url_for('dashboard.index') }}">
|
|
||||||
{% if tenant_branding and tenant_branding.logo_url %}
|
|
||||||
<img src="{{ url_for('static', filename=tenant_branding.logo_url) }}"
|
|
||||||
alt="{{ tenant_branding.display_name }}"
|
|
||||||
style="max-height:32px; border-radius:4px; margin-right:.35rem;">
|
|
||||||
{% else %}
|
|
||||||
<i class="bi bi-clipboard-check"></i>
|
|
||||||
{% endif %}
|
|
||||||
{{ tenant_branding.display_name if tenant_branding else 'Janitorial QC' }}
|
|
||||||
</a>
|
|
||||||
<!-- ── Bell + toggler always visible on mobile/tablet ── -->
|
|
||||||
<div class="d-flex align-items-center gap-2 ms-auto me-2 d-xxl-none">
|
|
||||||
<!-- Notification bell (always visible) -->
|
|
||||||
<div class="dropdown">
|
|
||||||
<a class="nav-link position-relative notif-bell-wrapper text-white"
|
|
||||||
href="#"
|
|
||||||
id="notifDropdownMobile"
|
|
||||||
role="button"
|
|
||||||
data-bs-toggle="dropdown"
|
|
||||||
aria-expanded="false"
|
|
||||||
title="Notifications">
|
|
||||||
<i class="bi bi-bell fs-5"></i>
|
|
||||||
{% if unread_notification_count > 0 %}
|
|
||||||
<span class="badge bg-danger notif-badge" id="notif-count-badge-mobile">
|
|
||||||
{{ unread_notification_count if unread_notification_count <= 99 else '99+' }}
|
|
||||||
</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="badge bg-danger notif-badge d-none" id="notif-count-badge-mobile"></span>
|
|
||||||
{% endif %}
|
|
||||||
</a>
|
|
||||||
<div class="dropdown-menu dropdown-menu-end notif-dropdown shadow"
|
|
||||||
id="notif-dropdown-menu-mobile">
|
|
||||||
<div class="d-flex justify-content-between align-items-center px-3 py-2 border-bottom">
|
|
||||||
<span class="fw-semibold" style="font-size:.9rem;">Notifications</span>
|
|
||||||
<button class="btn btn-link btn-sm p-0 text-muted text-decoration-none mark-all-read-btn"
|
|
||||||
style="font-size:.75rem;">Mark all as read</button>
|
|
||||||
</div>
|
|
||||||
<div class="notif-list-mobile">
|
|
||||||
<div class="notif-empty">Loading…</div>
|
|
||||||
</div>
|
|
||||||
<div class="border-top d-flex justify-content-between px-3 py-2" style="font-size:.8rem;">
|
|
||||||
<a href="{{ url_for('notifications.index') }}" class="text-decoration-none">
|
|
||||||
<i class="bi bi-list-ul me-1"></i>View all
|
|
||||||
</a>
|
|
||||||
<a href="{{ url_for('notifications.preferences') }}" class="text-decoration-none text-muted">
|
|
||||||
<i class="bi bi-gear me-1"></i>Preferences
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
|
||||||
<span class="navbar-toggler-icon"></span>
|
|
||||||
</button>
|
|
||||||
<div class="collapse navbar-collapse" id="navbarNav">
|
|
||||||
<ul class="navbar-nav me-auto">
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('dashboard.') }}" href="{{ url_for('dashboard.index') }}">Dashboard</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link {{ 'active' if request.endpoint and (request.endpoint.startswith('reports.') or request.endpoint.startswith('scheduled_reports.')) }}" href="{{ url_for('reports.index') }}">Reports</a>
|
|
||||||
</li>
|
|
||||||
{% if current_user.role in ['admin', 'director', 'project_manager'] %}
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('projects.') }}" href="{{ url_for('projects.index') }}">Contracts</a>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('facilities.') }}" href="{{ url_for('facilities.list_facilities') }}">Facilities</a>
|
|
||||||
</li>
|
|
||||||
{% if current_user.role in ['admin', 'director'] %}
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('templates.') }}" href="{{ url_for('templates.index') }}">Templates</a>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('inspections.') }}" href="{{ url_for('inspections.index') }}">Inspections</a>
|
|
||||||
</li>
|
|
||||||
{% if current_user.role in ['admin', 'director', 'project_manager'] %}
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('inspection_schedules.') }}" href="{{ url_for('inspection_schedules.index') }}">Schedules</a>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('issues.') and request.endpoint != 'issues.verification_queue' }}" href="{{ url_for('issues.index') }}">Issues</a>
|
|
||||||
</li>
|
|
||||||
{% if current_user.role in ['admin', 'director'] %}
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link d-flex align-items-center gap-1 {{ 'active' if request.endpoint == 'issues.verification_queue' }}"
|
|
||||||
href="{{ url_for('issues.verification_queue') }}">
|
|
||||||
Verify
|
|
||||||
{% if pending_verification_count and pending_verification_count > 0 %}
|
|
||||||
<span class="badge bg-info text-dark"
|
|
||||||
style="font-size:.65rem;line-height:1;">
|
|
||||||
{{ pending_verification_count }}
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
{% if current_user.role in ['admin', 'director'] %}
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('customers.') }}" href="{{ url_for('customers.index') }}">Customers</a>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
{% if current_user.role in ['admin', 'director'] %}
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('support.') }}"
|
|
||||||
href="{{ url_for('support.admin_tickets') }}">
|
|
||||||
Support
|
|
||||||
{% if open_support_tickets_count > 0 %}
|
|
||||||
<span class="badge bg-danger ms-1">{{ open_support_tickets_count }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
{% if current_user.role == 'customer' %}
|
|
||||||
<li class="nav-item dropdown">
|
|
||||||
<a class="nav-link dropdown-toggle {{ 'active' if request.endpoint and request.endpoint.startswith('support.') }}"
|
|
||||||
href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
|
||||||
<i class="bi bi-chat-dots me-1"></i>Support
|
|
||||||
</a>
|
|
||||||
<ul class="dropdown-menu">
|
|
||||||
<li>
|
|
||||||
<a class="dropdown-item" href="{{ url_for('support.chat') }}">
|
|
||||||
<i class="bi bi-chat-dots me-2"></i>Ask a Question
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a class="dropdown-item" href="{{ url_for('support.my_tickets') }}">
|
|
||||||
<i class="bi bi-inbox me-2"></i>My Requests
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
{% if current_user.role == 'admin' %}
|
|
||||||
{% set admin_active = request.endpoint and (
|
|
||||||
(request.endpoint.startswith('auth.') and 'user' in request.endpoint)
|
|
||||||
or request.endpoint.startswith('audit.')
|
|
||||||
or request.endpoint == 'auth.notification_matrix'
|
|
||||||
or request.endpoint.startswith('broadcast.')
|
|
||||||
or request.endpoint.startswith('devices.')
|
|
||||||
or request.endpoint.startswith('tenant_settings.')
|
|
||||||
) %}
|
|
||||||
<li class="nav-item dropdown">
|
|
||||||
<a class="nav-link dropdown-toggle {{ 'active' if admin_active }}"
|
|
||||||
href="#" id="adminMenu" role="button"
|
|
||||||
data-bs-toggle="dropdown" aria-expanded="false">
|
|
||||||
<i class="bi bi-sliders me-1"></i>Admin
|
|
||||||
</a>
|
|
||||||
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="adminMenu">
|
|
||||||
<li>
|
|
||||||
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('auth.') and 'user' in request.endpoint }}"
|
|
||||||
href="{{ url_for('auth.list_users') }}">
|
|
||||||
<i class="bi bi-people me-2"></i>Users
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('audit.') }}"
|
|
||||||
href="{{ url_for('audit.index') }}">
|
|
||||||
<i class="bi bi-clipboard-data me-2"></i>Audit Trail
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a class="dropdown-item {{ 'active' if request.endpoint == 'auth.notification_matrix' }}"
|
|
||||||
href="{{ url_for('auth.notification_matrix') }}">
|
|
||||||
<i class="bi bi-grid-3x3-gap-fill me-2"></i>Notification Matrix
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('broadcast.') }}"
|
|
||||||
href="{{ url_for('broadcast.index') }}">
|
|
||||||
<i class="bi bi-megaphone-fill me-2"></i>Broadcast
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('devices.') }}"
|
|
||||||
href="{{ url_for('devices.index') }}">
|
|
||||||
<i class="bi bi-tablet me-2"></i>Devices
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li><hr class="dropdown-divider"></li>
|
|
||||||
<li>
|
|
||||||
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('tenant_settings.') }}"
|
|
||||||
href="{{ url_for('tenant_settings.branding') }}">
|
|
||||||
<i class="bi bi-gear me-2"></i>Workspace Settings
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
</ul>
|
|
||||||
<ul class="navbar-nav align-items-center">
|
|
||||||
|
|
||||||
<!-- ── Notification Bell (desktop lg+ only) ── -->
|
Every page template still says {% extends "base.html" %} and needed ZERO
|
||||||
<li class="nav-item dropdown me-2 d-none d-xxl-block">
|
edits: Jinja resolves {% block %} overrides through the whole inheritance
|
||||||
<a class="nav-link position-relative notif-bell-wrapper"
|
chain, so one extra link in that chain is invisible to them.
|
||||||
href="#"
|
|
||||||
id="notifDropdown"
|
|
||||||
role="button"
|
|
||||||
data-bs-toggle="dropdown"
|
|
||||||
aria-expanded="false"
|
|
||||||
title="Notifications">
|
|
||||||
<i class="bi bi-bell fs-5"></i>
|
|
||||||
{% if unread_notification_count > 0 %}
|
|
||||||
<span class="badge bg-danger notif-badge" id="notif-count-badge">
|
|
||||||
{{ unread_notification_count if unread_notification_count <= 99 else '99+' }}
|
|
||||||
</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="badge bg-danger notif-badge d-none" id="notif-count-badge"></span>
|
|
||||||
{% endif %}
|
|
||||||
</a>
|
|
||||||
<div class="dropdown-menu dropdown-menu-end notif-dropdown shadow"
|
|
||||||
id="notif-dropdown-menu">
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="d-flex justify-content-between align-items-center
|
|
||||||
px-3 py-2 border-bottom">
|
|
||||||
<span class="fw-semibold" style="font-size:.9rem;">Notifications</span>
|
|
||||||
<button class="btn btn-link btn-sm p-0 text-muted text-decoration-none"
|
|
||||||
id="mark-all-read-btn" style="font-size:.75rem;">
|
|
||||||
Mark all as read
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<!-- Items -->
|
|
||||||
<div id="notif-list">
|
|
||||||
<div class="notif-empty">Loading…</div>
|
|
||||||
</div>
|
|
||||||
<!-- Footer -->
|
|
||||||
<div class="border-top d-flex justify-content-between px-3 py-2"
|
|
||||||
style="font-size:.8rem;">
|
|
||||||
<a href="{{ url_for('notifications.index') }}"
|
|
||||||
class="text-decoration-none">
|
|
||||||
<i class="bi bi-list-ul me-1"></i>View all
|
|
||||||
</a>
|
|
||||||
<a href="{{ url_for('notifications.preferences') }}"
|
|
||||||
class="text-decoration-none text-muted">
|
|
||||||
<i class="bi bi-gear me-1"></i>Preferences
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
<!-- ── End Notification Bell ── -->
|
|
||||||
|
|
||||||
<!-- User menu -->
|
`jqc_layout` is supplied by the inject_ui_theme() context processor in
|
||||||
<li class="nav-item dropdown">
|
app/__init__.py, driven by users.ui_theme ('classic' | 'modern') with
|
||||||
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown"
|
config DEFAULT_UI_THEME as the fallback for accounts that never chose.
|
||||||
role="button" data-bs-toggle="dropdown">
|
──────────────────────────────────────────────────────────────────────────── #}
|
||||||
<i class="bi bi-person-circle"></i> {{ current_user.username }}
|
{% extends jqc_layout %}
|
||||||
</a>
|
|
||||||
<ul class="dropdown-menu dropdown-menu-end">
|
|
||||||
<li>
|
|
||||||
<a class="dropdown-item"
|
|
||||||
href="{{ url_for('auth.profile') }}">
|
|
||||||
<i class="bi bi-person-circle me-1"></i>My Profile
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li><hr class="dropdown-divider"></li>
|
|
||||||
<li>
|
|
||||||
<a class="dropdown-item"
|
|
||||||
href="{{ url_for('notifications.preferences') }}">
|
|
||||||
<i class="bi bi-bell-slash me-1"></i>Notification Preferences
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li><hr class="dropdown-divider"></li>
|
|
||||||
<li>
|
|
||||||
<a class="dropdown-item" href="{{ url_for('auth.logout') }}">
|
|
||||||
<i class="bi bi-box-arrow-right me-1"></i>Logout
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="container-fluid mt-4">
|
|
||||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
|
||||||
{% if messages %}
|
|
||||||
{% for category, message in messages %}
|
|
||||||
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
|
|
||||||
{{ message }}
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
{% endwith %}
|
|
||||||
|
|
||||||
{% include 'billing/_billing_banner.html' %}
|
|
||||||
|
|
||||||
{% block content %}{% endblock %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
|
||||||
{% block extra_js %}{% endblock %}
|
|
||||||
|
|
||||||
{% if current_user.is_authenticated %}
|
|
||||||
<script>
|
|
||||||
(function () {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const FEED_URL = '{{ url_for("notifications.feed") }}';
|
|
||||||
const MARK_READ_BASE = '/notifications/';
|
|
||||||
const MARK_ALL_URL = '{{ url_for("notifications.mark_all_read") }}';
|
|
||||||
const CSRF_TOKEN = '{{ csrf_token() }}';
|
|
||||||
const POLL_INTERVAL = 60000; // 60 seconds
|
|
||||||
|
|
||||||
// ── Element refs — desktop bell (lg+) and mobile/tablet bell (<lg) ──
|
|
||||||
const badgeDesktop = document.getElementById('notif-count-badge');
|
|
||||||
const badgeMobile = document.getElementById('notif-count-badge-mobile');
|
|
||||||
const listDesktop = document.getElementById('notif-list');
|
|
||||||
const listMobile = document.querySelector('.notif-list-mobile');
|
|
||||||
|
|
||||||
// ── Update both badge instances ────────────────────────────────────────
|
|
||||||
function updateBadge(count) {
|
|
||||||
[badgeDesktop, badgeMobile].forEach(function(badge) {
|
|
||||||
if (!badge) return;
|
|
||||||
if (count > 0) {
|
|
||||||
badge.textContent = count > 99 ? '99+' : count;
|
|
||||||
badge.classList.remove('d-none');
|
|
||||||
} else {
|
|
||||||
badge.textContent = '';
|
|
||||||
badge.classList.add('d-none');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Render notification items into a given container ───────────────────
|
|
||||||
function renderInto(container, notifications) {
|
|
||||||
if (!container) return;
|
|
||||||
if (!notifications.length) {
|
|
||||||
container.innerHTML = '<div class="notif-empty">'
|
|
||||||
+ '<i class="bi bi-check2-circle me-1"></i>You\'re all caught up!</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
container.innerHTML = notifications.map(function(n) {
|
|
||||||
return '<div class="d-block text-decoration-none text-dark notif-item px-3 py-2 border-bottom '
|
|
||||||
+ (n.is_read ? '' : 'unread') + '"'
|
|
||||||
+ ' data-notif-id="' + n.id + '"'
|
|
||||||
+ ' data-link="' + escapeAttr(n.link || '') + '">'
|
|
||||||
+ '<div class="notif-title">' + escapeHtml(n.title) + '</div>'
|
|
||||||
+ '<div class="notif-body">' + escapeHtml(n.body) + '</div>'
|
|
||||||
+ '<div class="notif-time">' + escapeHtml(n.created_at) + '</div>'
|
|
||||||
+ '</div>';
|
|
||||||
}).join('');
|
|
||||||
container.querySelectorAll('.notif-item').forEach(function(el) {
|
|
||||||
el.addEventListener('click', function() {
|
|
||||||
var id = this.dataset.notifId;
|
|
||||||
var link = this.dataset.link;
|
|
||||||
markRead(id, function() {
|
|
||||||
el.classList.remove('unread');
|
|
||||||
if (link) window.location.href = link;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderNotifications(notifications) {
|
|
||||||
renderInto(listDesktop, notifications);
|
|
||||||
renderInto(listMobile, notifications);
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(str) {
|
|
||||||
if (!str) return '';
|
|
||||||
return str.replace(/&/g,'&').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>
|
|
||||||
|
|||||||
@@ -5,7 +5,11 @@
|
|||||||
<div class="row mb-4 align-items-center">
|
<div class="row mb-4 align-items-center">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<h2><i class="bi bi-person-badge"></i> Customer Management</h2>
|
<h2><i class="bi bi-person-badge"></i> Customer Management</h2>
|
||||||
<p class="text-muted mb-0">Manage portal access for all customer accounts.</p>
|
<p class="text-muted mb-0">
|
||||||
|
Manage both customer-side roles — <strong>Customer Directors</strong>
|
||||||
|
(portal access) and <strong>Customer Inspectors</strong> (perform
|
||||||
|
inspections on their contracts).
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-auto d-flex gap-2">
|
<div class="col-auto d-flex gap-2">
|
||||||
<a href="{{ url_for('customers.bulk_import') }}" class="btn btn-outline-success">
|
<a href="{{ url_for('customers.bulk_import') }}" class="btn btn-outline-success">
|
||||||
@@ -55,6 +59,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th>Username</th>
|
<th>Username</th>
|
||||||
<th>Full Name</th>
|
<th>Full Name</th>
|
||||||
|
<th>Role</th>
|
||||||
<th>Email</th>
|
<th>Email</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Assigned Contracts</th>
|
<th>Assigned Contracts</th>
|
||||||
@@ -65,7 +70,11 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for customer in customers %}
|
{% for customer in customers %}
|
||||||
{% set assignments = assignment_map[customer.id] %}
|
{# Inspectors are scoped by InspectorAssignment, directors by
|
||||||
|
CustomerAssignment — read the map that matches the role. #}
|
||||||
|
{% set assignments = inspector_assignment_map[customer.id]
|
||||||
|
if customer.is_inspector
|
||||||
|
else assignment_map[customer.id] %}
|
||||||
{% set facility_ids = scope_map[customer.id] %}
|
{% set facility_ids = scope_map[customer.id] %}
|
||||||
<tr class="{{ 'table-secondary text-muted' if not customer.active else '' }}">
|
<tr class="{{ 'table-secondary text-muted' if not customer.active else '' }}">
|
||||||
<td>
|
<td>
|
||||||
@@ -77,6 +86,11 @@
|
|||||||
</strong>
|
</strong>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ customer.full_name or '—' }}</td>
|
<td>{{ customer.full_name or '—' }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge {{ 'bg-info text-dark' if customer.is_inspector else 'bg-primary' }}">
|
||||||
|
{{ customer.role_label }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
<td class="small text-muted">{{ customer.email }}</td>
|
<td class="small text-muted">{{ customer.email }}</td>
|
||||||
<td>
|
<td>
|
||||||
{% if customer.active %}
|
{% if customer.active %}
|
||||||
@@ -136,6 +150,8 @@
|
|||||||
<div class="mt-3 text-muted small">
|
<div class="mt-3 text-muted small">
|
||||||
{{ customers|length }} customer account{{ 's' if customers|length != 1 else '' }} total
|
{{ customers|length }} customer account{{ 's' if customers|length != 1 else '' }} total
|
||||||
· {{ customers|selectattr('active')|list|length }} active
|
· {{ customers|selectattr('active')|list|length }} active
|
||||||
|
· {{ customers|rejectattr('is_inspector')|list|length }} director{{ 's' if customers|rejectattr('is_inspector')|list|length != 1 else '' }}
|
||||||
|
· {{ customers|selectattr('is_inspector')|list|length }} inspector{{ 's' if customers|selectattr('is_inspector')|list|length != 1 else '' }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4">
|
<div class="mb-3">
|
||||||
{{ form.email.label(class="form-label fw-semibold") }}
|
{{ form.email.label(class="form-label fw-semibold") }}
|
||||||
{{ form.email(class="form-control" + (" is-invalid" if form.email.errors else ""),
|
{{ form.email(class="form-control" + (" is-invalid" if form.email.errors else ""),
|
||||||
placeholder="jane@example.com") }}
|
placeholder="jane@example.com") }}
|
||||||
@@ -44,6 +44,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
{{ form.role.label(class="form-label fw-semibold") }}
|
||||||
|
{{ form.role(class="form-select" + (" is-invalid" if form.role.errors else "")) }}
|
||||||
|
{% for error in form.role.errors %}
|
||||||
|
<div class="invalid-feedback">{{ error }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
<div class="form-text">
|
||||||
|
A <strong>Director</strong> gets portal access to their facilities'
|
||||||
|
inspections, issues and reports. An <strong>Inspector</strong>
|
||||||
|
performs inspections and manages issues on the contracts you assign
|
||||||
|
them — the same tools as our own inspectors, limited to their
|
||||||
|
contracts. You can switch an account between the two later.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
<button type="submit" class="btn btn-primary">
|
<button type="submit" class="btn btn-primary">
|
||||||
<i class="bi bi-send me-1"></i>Create & Send Invitation
|
<i class="bi bi-send me-1"></i>Create & Send Invitation
|
||||||
|
|||||||
@@ -6,6 +6,9 @@
|
|||||||
<div class="col">
|
<div class="col">
|
||||||
<h2>
|
<h2>
|
||||||
<i class="bi bi-person-badge"></i> {{ customer.display_name }}
|
<i class="bi bi-person-badge"></i> {{ customer.display_name }}
|
||||||
|
<span class="badge {{ 'bg-info text-dark' if customer.is_inspector else 'bg-primary' }} ms-2 fs-6">
|
||||||
|
{{ customer.role_label }}
|
||||||
|
</span>
|
||||||
{% if not customer.active %}
|
{% if not customer.active %}
|
||||||
<span class="badge bg-secondary ms-2 fs-6">Disabled</span>
|
<span class="badge bg-secondary ms-2 fs-6">Disabled</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -19,6 +22,20 @@
|
|||||||
class="btn btn-outline-secondary btn-sm">
|
class="btn btn-outline-secondary btn-sm">
|
||||||
<i class="bi bi-pencil"></i> Edit Account
|
<i class="bi bi-pencil"></i> Edit Account
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
{# ── Switch role (admin only) ── #}
|
||||||
|
{% if current_user.role == 'admin' %}
|
||||||
|
{% set to_label = 'Customer Director' if customer.is_inspector else 'Customer Inspector' %}
|
||||||
|
<form method="POST"
|
||||||
|
action="{{ url_for('customers.switch_role', customer_id=customer.id) }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-info"
|
||||||
|
title="Change what this account can do"
|
||||||
|
onclick="return confirm('Switch {{ customer.display_name }} from {{ customer.role_label }} to {{ to_label }}?\n\nTheir contracts are carried across.{% if not customer.is_inspector %}\n\nFacility-level limits do not exist for inspectors — an account limited to specific facilities will gain the whole contract.{% endif %}\n\nAny signed-in device will be logged out.')">
|
||||||
|
<i class="bi bi-arrow-left-right me-1"></i> Switch to {{ to_label }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
<form method="POST"
|
<form method="POST"
|
||||||
action="{{ url_for('customers.toggle_active', customer_id=customer.id) }}">
|
action="{{ url_for('customers.toggle_active', customer_id=customer.id) }}">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
@@ -48,6 +65,19 @@
|
|||||||
<dl class="row mb-0 small">
|
<dl class="row mb-0 small">
|
||||||
<dt class="col-5 text-muted">Full Name</dt>
|
<dt class="col-5 text-muted">Full Name</dt>
|
||||||
<dd class="col-7">{{ customer.full_name or '—' }}</dd>
|
<dd class="col-7">{{ customer.full_name or '—' }}</dd>
|
||||||
|
<dt class="col-5 text-muted">Role</dt>
|
||||||
|
<dd class="col-7">
|
||||||
|
<span class="badge {{ 'bg-info text-dark' if customer.is_inspector else 'bg-primary' }}">
|
||||||
|
{{ customer.role_label }}
|
||||||
|
</span>
|
||||||
|
<div class="text-muted" style="font-size:.72rem;">
|
||||||
|
{% if customer.is_inspector %}
|
||||||
|
Performs inspections and manages issues on their assigned contracts.
|
||||||
|
{% else %}
|
||||||
|
Portal access to their facilities' inspections, issues and reports.
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</dd>
|
||||||
<dt class="col-5 text-muted">Username</dt>
|
<dt class="col-5 text-muted">Username</dt>
|
||||||
<dd class="col-7">{{ customer.username }}</dd>
|
<dd class="col-7">{{ customer.username }}</dd>
|
||||||
<dt class="col-5 text-muted">Email</dt>
|
<dt class="col-5 text-muted">Email</dt>
|
||||||
@@ -69,7 +99,7 @@
|
|||||||
<dt class="col-5 text-muted">Created</dt>
|
<dt class="col-5 text-muted">Created</dt>
|
||||||
<dd class="col-7">{{ customer.created_at.strftime('%Y-%m-%d') }}</dd>
|
<dd class="col-7">{{ customer.created_at.strftime('%Y-%m-%d') }}</dd>
|
||||||
<dt class="col-5 text-muted">Assignments</dt>
|
<dt class="col-5 text-muted">Assignments</dt>
|
||||||
<dd class="col-7">{{ assignments|length }}</dd>
|
<dd class="col-7">{{ assigned_pids|length if customer.is_inspector else assignments|length }}</dd>
|
||||||
<dt class="col-5 text-muted">Facilities</dt>
|
<dt class="col-5 text-muted">Facilities</dt>
|
||||||
<dd class="col-7">{{ facilities|length }}</dd>
|
<dd class="col-7">{{ facilities|length }}</dd>
|
||||||
</dl>
|
</dl>
|
||||||
@@ -120,6 +150,58 @@
|
|||||||
{# ── Right column: assignments ── #}
|
{# ── Right column: assignments ── #}
|
||||||
<div class="col-md-8">
|
<div class="col-md-8">
|
||||||
|
|
||||||
|
{% if customer.is_inspector %}
|
||||||
|
{# ══ Customer Inspector — whole contracts, no facility-level narrowing ══
|
||||||
|
Scoped by InspectorAssignment, the same rows an internal inspector uses.
|
||||||
|
Posts the COMPLETE checked set; unchecked contracts are removed. ══ #}
|
||||||
|
<div class="card shadow-sm mb-4">
|
||||||
|
<div class="card-header bg-light fw-semibold d-flex justify-content-between align-items-center">
|
||||||
|
<span><i class="bi bi-diagram-3 me-1"></i> Contract Assignments</span>
|
||||||
|
<span class="badge bg-secondary rounded-pill" id="assignedCount">
|
||||||
|
{{ assigned_pids|length }} assigned
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted small">
|
||||||
|
A Customer Inspector sees only the contracts ticked here — with none
|
||||||
|
ticked they see nothing at all. Inspectors are assigned whole
|
||||||
|
contracts; there is no per-facility option for this role.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form method="POST"
|
||||||
|
action="{{ url_for('customers.assign_contracts', customer_id=customer.id) }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
|
||||||
|
<div class="mb-2">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary" id="selectAll">Select all</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary" id="deselectAll">Deselect all</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if projects %}
|
||||||
|
<div class="list-group list-group-flush mb-3"
|
||||||
|
style="max-height:340px;overflow-y:auto;">
|
||||||
|
{% for p in projects %}
|
||||||
|
<label class="list-group-item d-flex align-items-center gap-2 py-2">
|
||||||
|
<input class="form-check-input m-0 contract-check" type="checkbox"
|
||||||
|
name="project_ids" value="{{ p.id }}"
|
||||||
|
{% if p.id in assigned_pids %}checked{% endif %}>
|
||||||
|
<span class="small">{{ p.name }}</span>
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm">
|
||||||
|
<i class="bi bi-check2 me-1"></i> Save Contract Assignments
|
||||||
|
</button>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted small mb-0">No active contracts exist yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
{# ══ Customer Director — contract or single-facility assignments ══ #}
|
||||||
|
|
||||||
{# ── Current assignments table ── #}
|
{# ── Current assignments table ── #}
|
||||||
<div class="card shadow-sm mb-4">
|
<div class="card shadow-sm mb-4">
|
||||||
<div class="card-header bg-light fw-semibold d-flex justify-content-between align-items-center">
|
<div class="card-header bg-light fw-semibold d-flex justify-content-between align-items-center">
|
||||||
@@ -213,6 +295,116 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# ── Per-account notification matrix ─────────────────────────────────
|
||||||
|
Overrides the global Notification Matrix for THIS account only.
|
||||||
|
"Inherit" is the default and means "follow the global column", so it
|
||||||
|
keeps tracking future changes there — it is not a snapshot. #}
|
||||||
|
<div class="card shadow-sm mt-4">
|
||||||
|
<div class="card-header bg-light fw-semibold">
|
||||||
|
<i class="bi bi-bell me-1"></i> Notifications for this account
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted small">
|
||||||
|
Each customer's enrollment form says which notifications their people
|
||||||
|
want, so these can differ per person. <strong>Inherit</strong> follows
|
||||||
|
the global Notification Matrix for
|
||||||
|
{{ customer.role_label }}s — including any later change to it. Choose
|
||||||
|
On or Off only where this account should differ.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form method="POST"
|
||||||
|
action="{{ url_for('customers.save_notifications', customer_id=customer.id) }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* The whole cell is the control. Padding (not min-height on the
|
||||||
|
input) gives the touch target, so the native radio keeps its
|
||||||
|
own box — see the note in ipad_responsive.css. */
|
||||||
|
.matrix-opt {
|
||||||
|
display: block;
|
||||||
|
padding: .55rem .25rem;
|
||||||
|
margin: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.matrix-opt:hover { background: rgba(13,110,253,.06); }
|
||||||
|
.matrix-opt input { cursor: pointer; }
|
||||||
|
.matrix-opt-sub {
|
||||||
|
display: block;
|
||||||
|
font-size: .62rem;
|
||||||
|
color: #6c757d;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="d-flex flex-wrap align-items-center gap-2 mb-2">
|
||||||
|
<span class="small text-muted">Set every row:</span>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary" data-matrix-all="inherit">Inherit</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-success" data-matrix-all="on">On</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-danger" data-matrix-all="off">Off</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm align-middle mb-3">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Event</th>
|
||||||
|
<th class="text-center" style="width:110px;">Inherit</th>
|
||||||
|
<th class="text-center" style="width:70px;">On</th>
|
||||||
|
<th class="text-center" style="width:70px;">Off</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in matrix_rows %}
|
||||||
|
<tr>
|
||||||
|
<td class="small">
|
||||||
|
{{ row.label }}
|
||||||
|
{% if row.override is not none %}
|
||||||
|
<span class="badge bg-warning text-dark ms-1" style="font-size:.6rem;">custom</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
{# Each option is a <label> filling its whole cell, so the
|
||||||
|
click target is the cell rather than the ~16px glyph. A
|
||||||
|
bare <input> in a centred <td> was effectively unclickable
|
||||||
|
at touch/narrow widths. #}
|
||||||
|
<td class="p-0">
|
||||||
|
<label class="matrix-opt" title="Follow the global matrix">
|
||||||
|
<input class="form-check-input" type="radio"
|
||||||
|
name="event_{{ row.event }}" value="inherit"
|
||||||
|
{% if row.override is none %}checked{% endif %}>
|
||||||
|
<span class="matrix-opt-sub">
|
||||||
|
currently {{ 'on' if row.global else 'off' }}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</td>
|
||||||
|
<td class="p-0">
|
||||||
|
<label class="matrix-opt" title="Always send this to this account">
|
||||||
|
<input class="form-check-input" type="radio"
|
||||||
|
name="event_{{ row.event }}" value="on"
|
||||||
|
{% if row.override is true %}checked{% endif %}>
|
||||||
|
</label>
|
||||||
|
</td>
|
||||||
|
<td class="p-0">
|
||||||
|
<label class="matrix-opt" title="Never send this to this account">
|
||||||
|
<input class="form-check-input" type="radio"
|
||||||
|
name="event_{{ row.event }}" value="off"
|
||||||
|
{% if row.override is false %}checked{% endif %}>
|
||||||
|
</label>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm">
|
||||||
|
<i class="bi bi-check2 me-1"></i> Save Notification Settings
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -223,8 +415,41 @@
|
|||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
// ── Customer Inspector: contract checkbox helpers ──
|
||||||
|
const checks = document.querySelectorAll('.contract-check');
|
||||||
|
const countBadge = document.getElementById('assignedCount');
|
||||||
|
|
||||||
|
function refreshCount() {
|
||||||
|
if (!countBadge) return;
|
||||||
|
const n = document.querySelectorAll('.contract-check:checked').length;
|
||||||
|
countBadge.textContent = n + ' assigned';
|
||||||
|
}
|
||||||
|
function setAll(state) {
|
||||||
|
checks.forEach(function (c) { c.checked = state; });
|
||||||
|
refreshCount();
|
||||||
|
}
|
||||||
|
const selectAll = document.getElementById('selectAll');
|
||||||
|
const deselectAll = document.getElementById('deselectAll');
|
||||||
|
if (selectAll) selectAll.addEventListener('click', function () { setAll(true); });
|
||||||
|
if (deselectAll) deselectAll.addEventListener('click', function () { setAll(false); });
|
||||||
|
checks.forEach(function (c) { c.addEventListener('change', refreshCount); });
|
||||||
|
|
||||||
|
// ── Notification matrix: set every row at once ──
|
||||||
|
document.querySelectorAll('[data-matrix-all]').forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
var want = btn.getAttribute('data-matrix-all');
|
||||||
|
document.querySelectorAll('.matrix-opt input[type=radio]').forEach(function (r) {
|
||||||
|
if (r.value === want) { r.checked = true; }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Customer Director: contract → facility cascade ──
|
||||||
|
// Both selects are absent on the inspector view, so bail out rather than
|
||||||
|
// throwing on addEventListener of null (which would kill the handlers above).
|
||||||
const projSelect = document.getElementById('proj-select');
|
const projSelect = document.getElementById('proj-select');
|
||||||
const facSelect = document.getElementById('fac-select');
|
const facSelect = document.getElementById('fac-select');
|
||||||
|
if (!projSelect || !facSelect) return;
|
||||||
|
|
||||||
projSelect.addEventListener('change', function () {
|
projSelect.addEventListener('change', function () {
|
||||||
const projectId = this.value;
|
const projectId = this.value;
|
||||||
|
|||||||
@@ -5,12 +5,60 @@
|
|||||||
<div class="row mb-3 align-items-center">
|
<div class="row mb-3 align-items-center">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<h2 class="mb-0">Welcome, {{ current_user.display_name }}!</h2>
|
<h2 class="mb-0">Welcome, {{ current_user.display_name }}!</h2>
|
||||||
<span class="badge bg-{% if current_user.role == 'admin' %}danger{% elif current_user.role == 'director' %}warning{% elif current_user.role == 'project_manager' %}primary{% elif current_user.role == 'customer' %}success{% else %}info{% endif %} mt-1">
|
<span class="badge bg-{% if current_user.role == 'admin' %}danger{% elif current_user.role == 'director' %}warning{% elif current_user.role == 'project_manager' %}primary{% elif current_user.role == 'auditor' %}secondary{% elif current_user.role == 'customer' %}success{% else %}info{% endif %} mt-1">
|
||||||
{{ current_user.role.replace('_',' ')|title }}
|
{{ current_user.role.replace('_',' ')|title }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# ── Scheduled Inspections (phase43) — plan-mode, staff only ─────────────── #}
|
||||||
|
{% if current_user.role != 'customer' and (sched_upcoming or sched_overdue_count) %}
|
||||||
|
<div class="card shadow-sm mb-4 border-0" style="border-left:4px solid #6366f1 !important;">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<span class="fw-bold"><i class="bi bi-calendar-check text-primary"></i> Scheduled Inspections</span>
|
||||||
|
<a href="{{ url_for('inspection_schedules.index') }}" class="btn btn-sm btn-outline-primary">View all</a>
|
||||||
|
</div>
|
||||||
|
{% if sched_overdue_count %}
|
||||||
|
<div class="alert alert-danger py-2 mb-2">
|
||||||
|
<i class="bi bi-alarm-fill"></i>
|
||||||
|
<strong>{{ sched_overdue_count }}</strong> scheduled inspection{{ 's' if sched_overdue_count != 1 }}
|
||||||
|
{{ 'are' if sched_overdue_count != 1 else 'is' }} <strong>overdue</strong>.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if sched_upcoming %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-hover mb-0 align-middle">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr><th>Schedule</th><th>Facility</th><th>Template</th><th>Inspector</th><th>Due</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for s in sched_upcoming %}
|
||||||
|
<tr>
|
||||||
|
<td class="fw-semibold">{{ s.name }}</td>
|
||||||
|
<td>{{ s.facility.name if s.facility else '—' }}</td>
|
||||||
|
<td class="small">{{ s.template.name if s.template else '—' }}</td>
|
||||||
|
<td class="small">{{ s.inspector.display_name if s.inspector else '—' }}</td>
|
||||||
|
<td class="small">{{ s.next_run_at.strftime('%b %d') if s.next_run_at else '—' }}</td>
|
||||||
|
<td class="text-end">
|
||||||
|
{% if current_user.role in ['admin','director','project_manager','auditor']
|
||||||
|
or (current_user.role == 'inspector' and s.inspector_id == current_user.id) %}
|
||||||
|
<a href="{{ url_for('inspection_schedules.start', schedule_id=s.id) }}"
|
||||||
|
class="btn btn-sm btn-success py-0"><i class="bi bi-play-fill"></i> Start</a>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted small mb-0">No inspections due in the next 7 days.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{# ── Inspections section ─────────────────────────────────────────────────── #}
|
{# ── Inspections section ─────────────────────────────────────────────────── #}
|
||||||
<div class="d-flex align-items-center gap-2 mb-3">
|
<div class="d-flex align-items-center gap-2 mb-3">
|
||||||
<i class="bi bi-clipboard-data-fill text-primary"></i>
|
<i class="bi bi-clipboard-data-fill text-primary"></i>
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>QR Code — {{ area.name }}</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
|
||||||
|
<style>
|
||||||
|
body { background:#f1f5f9; }
|
||||||
|
.qr-card { max-width:420px; margin:2rem auto; background:#fff; border-radius:.75rem;
|
||||||
|
box-shadow:0 1px 3px rgba(0,0,0,.12); padding:2rem; text-align:center; }
|
||||||
|
.qr-box svg { width:260px; height:260px; }
|
||||||
|
.scan-url { word-break:break-all; font-size:.72rem; color:#94a3b8; }
|
||||||
|
@media print {
|
||||||
|
body { background:#fff; }
|
||||||
|
.no-print { display:none !important; }
|
||||||
|
.qr-card { box-shadow:none; margin:0 auto; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="text-center mt-3 no-print">
|
||||||
|
<a href="{{ url_for('facilities.view_facility', facility_id=facility.id) }}"
|
||||||
|
class="btn btn-outline-secondary btn-sm">
|
||||||
|
<i class="bi bi-arrow-left"></i> Back to Facility
|
||||||
|
</a>
|
||||||
|
<button onclick="window.print()" class="btn btn-primary btn-sm">
|
||||||
|
<i class="bi bi-printer"></i> Print
|
||||||
|
</button>
|
||||||
|
<a href="{{ url_for('facilities.qr_print_all', facility_id=facility.id, include_areas=1) }}"
|
||||||
|
class="btn btn-outline-primary btn-sm">
|
||||||
|
<i class="bi bi-grid-3x3-gap"></i> Print All Codes
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="alert alert-{{ 'success' if category == 'success' else 'warning' }} mx-auto mt-3 no-print" style="max-width:420px;">
|
||||||
|
{{ message }}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<div class="qr-card">
|
||||||
|
<div class="text-muted text-uppercase small" style="letter-spacing:.08em;">Area</div>
|
||||||
|
<h4 class="mb-0">{{ area.name }}</h4>
|
||||||
|
<div class="text-muted small">{{ facility.name }}</div>
|
||||||
|
{% if area.area_type %}<div class="text-muted small mb-2">{{ area.area_type }}</div>{% endif %}
|
||||||
|
|
||||||
|
<div class="qr-box my-3">{{ svg | safe }}</div>
|
||||||
|
|
||||||
|
<div class="fw-semibold mb-1">
|
||||||
|
<i class="bi bi-phone"></i> Scan to report a problem in this area
|
||||||
|
</div>
|
||||||
|
<div class="text-muted small mb-2">
|
||||||
|
Recent scores, open issues, and quality trend for {{ area.name }}.
|
||||||
|
</div>
|
||||||
|
<div class="scan-url">{{ scan_url }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-center text-muted small no-print">
|
||||||
|
Tip: post this inside the area itself (e.g. on the restroom door), not at the building entrance.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% if current_user.role in ['admin', 'director', 'customer'] %}
|
||||||
|
<div class="text-center mb-4 no-print">
|
||||||
|
<form method="POST" action="{{ url_for('facilities.regenerate_area_qr', area_id=area.id) }}"
|
||||||
|
onsubmit="return confirm('Regenerate this QR code? Every previously printed poster for {{ area.name }} will stop working.');">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button type="submit" class="btn btn-outline-danger btn-sm">
|
||||||
|
<i class="bi bi-arrow-repeat"></i> Regenerate QR Code
|
||||||
|
</button>
|
||||||
|
<div class="form-text">Use this if a printed poster leaked or was posted somewhere it shouldn't be.</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -8,9 +8,10 @@
|
|||||||
<h2><i class="bi bi-building"></i> Facilities</h2>
|
<h2><i class="bi bi-building"></i> Facilities</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6 text-end">
|
<div class="col-md-6 text-end">
|
||||||
{% if current_user.role in ['admin', 'director', 'project_manager'] %}
|
{% if current_user.role != 'inspector' %}
|
||||||
<a href="{{ url_for('facilities.qr_sheet') }}" class="btn btn-outline-secondary">
|
<a href="{{ url_for('facilities.qr_print_all') }}"
|
||||||
<i class="bi bi-qr-code"></i> Print QR Codes
|
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>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if current_user.role in ['admin', 'director'] %}
|
{% if current_user.role in ['admin', 'director'] %}
|
||||||
@@ -43,7 +44,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</button>
|
</button>
|
||||||
<span class="badge bg-secondary ms-2">{{ group.facilities|length }}</span>
|
<span class="badge bg-secondary ms-2">{{ group.facilities|length }}</span>
|
||||||
{% if group.project and current_user.role in ['admin', 'director', 'project_manager'] %}
|
{% if group.project and current_user.role in ['admin', 'director', 'project_manager', 'auditor'] %}
|
||||||
<a href="{{ url_for('projects.view', project_id=group.project.id) }}"
|
<a href="{{ url_for('projects.view', project_id=group.project.id) }}"
|
||||||
class="btn btn-sm btn-outline-secondary ms-2"
|
class="btn btn-sm btn-outline-secondary ms-2"
|
||||||
title="View Contract">
|
title="View Contract">
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Print QR Codes{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<style>
|
||||||
|
.qr-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; }
|
||||||
|
.qr-item { position: relative; break-inside: avoid; page-break-inside: avoid;
|
||||||
|
text-align: center; cursor: pointer; }
|
||||||
|
.qr-item img { width: 220px; height: 220px; max-width: 100%; }
|
||||||
|
.qr-item.qr-selected { outline: 3px solid #2563eb; outline-offset: -1px; }
|
||||||
|
.qr-check { position: absolute; top: 10px; left: 10px; }
|
||||||
|
.qr-check .form-check-input { width: 1.25rem; height: 1.25rem; }
|
||||||
|
.qr-kind { font-size: .68rem; letter-spacing: .08em; }
|
||||||
|
@media print {
|
||||||
|
.no-print { display: none !important; }
|
||||||
|
.navbar, nav, footer { display: none !important; }
|
||||||
|
.qr-item { border: 1px dashed #bbb !important; cursor: default; }
|
||||||
|
.qr-item.qr-selected { outline: none !important; }
|
||||||
|
.qr-grid { gap: 8px; }
|
||||||
|
/* When printing a selection, hide the unselected cards. */
|
||||||
|
body.print-selected-only .qr-item:not(.qr-selected) { display: none !important; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3 no-print">
|
||||||
|
<div>
|
||||||
|
<h2 class="h4 mb-0"><i class="bi bi-qr-code"></i> QR Codes</h2>
|
||||||
|
<div class="text-muted small">
|
||||||
|
{% if selected_contract %}Contract: {{ selected_contract.name }} — {% endif %}
|
||||||
|
{{ facilities|length }} facilit{{ 'y' if facilities|length == 1 else 'ies' }}
|
||||||
|
{% if include_areas %}(with areas){% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="{{ url_for('facilities.list_facilities') }}" class="btn btn-outline-secondary btn-sm">
|
||||||
|
<i class="bi bi-arrow-left"></i> Back
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Filter bar (GET reload) ─────────────────────────────────────────────── #}
|
||||||
|
<form method="get" id="filterForm" class="card card-body mb-3 no-print">
|
||||||
|
<div class="row g-2 align-items-end">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label small fw-semibold mb-1">Contract</label>
|
||||||
|
<select name="contract_id" class="form-select form-select-sm"
|
||||||
|
onchange="document.getElementById('facilitySelect').value=''; this.form.submit();">
|
||||||
|
<option value="">All Contracts</option>
|
||||||
|
{% for c in contracts %}
|
||||||
|
<option value="{{ c.id }}" {{ 'selected' if selected_contract_id == c.id }}>{{ c.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label small fw-semibold mb-1">Facility</label>
|
||||||
|
<select name="facility_id" id="facilitySelect" class="form-select form-select-sm">
|
||||||
|
<option value="">All Facilities</option>
|
||||||
|
{% for f in facility_options %}
|
||||||
|
<option value="{{ f.id }}" {{ 'selected' if selected_facility_id == f.id }}>{{ f.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" name="include_areas" value="1"
|
||||||
|
id="includeAreas" {{ 'checked' if include_areas }}>
|
||||||
|
<label class="form-check-label small" for="includeAreas">Include area QR codes</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm w-100">
|
||||||
|
<i class="bi bi-funnel"></i> Apply
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{# ── Selection toolbar + export form ─────────────────────────────────────── #}
|
||||||
|
<form method="post" action="{{ url_for('facilities.qr_export_pdf') }}" id="qrForm">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
|
||||||
|
<div class="d-flex align-items-center gap-2 mb-3 no-print flex-wrap">
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="selectAllQr(true)">Select All</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="selectAllQr(false)">Clear</button>
|
||||||
|
<span class="text-muted small" id="selCount">0 selected</span>
|
||||||
|
<div class="ms-auto d-flex gap-2">
|
||||||
|
<button type="button" class="btn btn-primary btn-sm" onclick="printSelected()">
|
||||||
|
<i class="bi bi-printer"></i> Print Selected
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn btn-danger btn-sm">
|
||||||
|
<i class="bi bi-file-earmark-pdf"></i> Export Selected to PDF
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if facilities %}
|
||||||
|
<div class="qr-grid">
|
||||||
|
{% for f in facilities %}
|
||||||
|
{# Facility QR card #}
|
||||||
|
<label class="qr-item card shadow-sm p-3 mb-0">
|
||||||
|
<div class="qr-check no-print">
|
||||||
|
<input type="checkbox" class="form-check-input qr-cb" name="facility_ids" value="{{ f.id }}">
|
||||||
|
</div>
|
||||||
|
<div class="text-muted text-uppercase qr-kind">Facility</div>
|
||||||
|
<div class="fw-bold">{{ f.name }}</div>
|
||||||
|
{% if f.project %}
|
||||||
|
<div class="text-muted small mb-1">{{ f.project.name }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<div>
|
||||||
|
<img src="{{ url_for('facilities.facility_qr_png', facility_id=f.id) }}"
|
||||||
|
alt="QR code for {{ f.name }}" loading="lazy">
|
||||||
|
</div>
|
||||||
|
<div class="small">Report a problem & view recent quality</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{% if include_areas %}
|
||||||
|
{% for a in areas_by_facility.get(f.id, []) %}
|
||||||
|
{# Area QR card #}
|
||||||
|
<label class="qr-item card shadow-sm p-3 mb-0">
|
||||||
|
<div class="qr-check no-print">
|
||||||
|
<input type="checkbox" class="form-check-input qr-cb" name="area_ids" value="{{ a.id }}">
|
||||||
|
</div>
|
||||||
|
<div class="text-muted text-uppercase qr-kind">Area</div>
|
||||||
|
<div class="fw-bold">{{ a.name }}</div>
|
||||||
|
<div class="text-muted small mb-1">{{ f.name }}</div>
|
||||||
|
<div>
|
||||||
|
<img src="{{ url_for('facilities.area_qr_png', area_id=a.id) }}"
|
||||||
|
alt="QR code for {{ a.name }}" loading="lazy">
|
||||||
|
</div>
|
||||||
|
<div class="small">Report a problem & view recent quality</div>
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-info">No facilities match the selected filters.</div>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="text-muted small mt-3 no-print">
|
||||||
|
Tip: tick the codes you want, then <strong>Print Selected</strong> or
|
||||||
|
<strong>Export Selected to PDF</strong>. With nothing ticked, Print Selected prints them all.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
function checkboxes() { return document.querySelectorAll('.qr-cb'); }
|
||||||
|
|
||||||
|
function updateCount() {
|
||||||
|
var n = document.querySelectorAll('.qr-cb:checked').length;
|
||||||
|
document.getElementById('selCount').textContent = n + ' selected';
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncCard(cb) {
|
||||||
|
var card = cb.closest('.qr-item');
|
||||||
|
if (card) { card.classList.toggle('qr-selected', cb.checked); }
|
||||||
|
}
|
||||||
|
|
||||||
|
window.selectAllQr = function (state) {
|
||||||
|
checkboxes().forEach(function (cb) { cb.checked = state; syncCard(cb); });
|
||||||
|
updateCount();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.printSelected = function () {
|
||||||
|
var anySelected = document.querySelectorAll('.qr-cb:checked').length > 0;
|
||||||
|
if (anySelected) { document.body.classList.add('print-selected-only'); }
|
||||||
|
window.print();
|
||||||
|
setTimeout(function () {
|
||||||
|
document.body.classList.remove('print-selected-only');
|
||||||
|
}, 500);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Toggling a checkbox inside its <label> card also fires on the label click.
|
||||||
|
checkboxes().forEach(function (cb) {
|
||||||
|
cb.addEventListener('change', function () { syncCard(cb); updateCount(); });
|
||||||
|
});
|
||||||
|
|
||||||
|
updateCount();
|
||||||
|
}());
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -11,17 +11,21 @@
|
|||||||
<a href="{{ url_for('facilities.list_facilities') }}" class="btn btn-outline-secondary">
|
<a href="{{ url_for('facilities.list_facilities') }}" class="btn btn-outline-secondary">
|
||||||
<i class="bi bi-arrow-left"></i> Back to Facilities
|
<i class="bi bi-arrow-left"></i> Back to Facilities
|
||||||
</a>
|
</a>
|
||||||
{% if current_user.role in ['admin', 'director', 'project_manager', 'customer'] %}
|
{% if current_user.role in ['admin', 'director', 'project_manager', 'customer', 'auditor'] %}
|
||||||
<a href="{{ url_for('reports.facility_report', facility_id=facility.id) }}"
|
<a href="{{ url_for('reports.facility_report', facility_id=facility.id) }}"
|
||||||
class="btn btn-outline-info">
|
class="btn btn-outline-info">
|
||||||
<i class="bi bi-graph-up-arrow"></i> Scorecard
|
<i class="bi bi-graph-up-arrow"></i> Scorecard
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if current_user.role in ['admin', 'director', 'project_manager'] %}
|
{% if current_user.role != 'inspector' %}
|
||||||
<a href="{{ url_for('facilities.qr_card', facility_id=facility.id) }}"
|
<a href="{{ url_for('facilities.qr_card', facility_id=facility.id) }}"
|
||||||
class="btn btn-outline-secondary">
|
class="btn btn-outline-secondary">
|
||||||
<i class="bi bi-qr-code"></i> QR Code
|
<i class="bi bi-qr-code"></i> QR Code
|
||||||
</a>
|
</a>
|
||||||
|
<a href="{{ url_for('facilities.qr_print_all', facility_id=facility.id, include_areas=1) }}"
|
||||||
|
class="btn btn-outline-secondary" title="Print or export this facility's codes, including every area">
|
||||||
|
<i class="bi bi-grid-3x3-gap"></i> All Codes
|
||||||
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if current_user.role in ['admin', 'director'] %}
|
{% if current_user.role in ['admin', 'director'] %}
|
||||||
<a href="{{ url_for('facilities.edit_facility', facility_id=facility.id) }}" class="btn btn-outline-primary">
|
<a href="{{ url_for('facilities.edit_facility', facility_id=facility.id) }}" class="btn btn-outline-primary">
|
||||||
@@ -135,6 +139,12 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>{{ area.inspections.count() }}</td>
|
<td>{{ area.inspections.count() }}</td>
|
||||||
<td>
|
<td>
|
||||||
|
{% if current_user.role != 'inspector' %}
|
||||||
|
<a href="{{ url_for('facilities.area_qr_card', area_id=area.id) }}"
|
||||||
|
class="btn btn-sm btn-outline-secondary" title="Printable QR code for this area">
|
||||||
|
<i class="bi bi-qr-code"></i>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
{% if current_user.role in ['admin', 'director'] %}
|
{% if current_user.role in ['admin', 'director'] %}
|
||||||
<a href="{{ url_for('facilities.edit_area', area_id=area.id) }}" class="btn btn-sm btn-outline-primary">
|
<a href="{{ url_for('facilities.edit_area', area_id=area.id) }}" class="btn btn-sm btn-outline-primary">
|
||||||
<i class="bi bi-pencil"></i>
|
<i class="bi bi-pencil"></i>
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
{# ── Public "report a problem" form (phase42) ───────────────────────────────
|
||||||
|
Shared by the facility scan page and the area scan page. Caller passes:
|
||||||
|
action — the POST endpoint URL
|
||||||
|
form — a PublicIssueReportForm instance
|
||||||
|
Photos: up to 5, first becomes the issue's primary photo. Honeypot field
|
||||||
|
`website` is off-screen: humans never see it, bots fill it.
|
||||||
|
#}
|
||||||
|
<div class="card shadow-sm mb-3">
|
||||||
|
<div class="card-header bg-white fw-semibold py-2">
|
||||||
|
<i class="bi bi-megaphone text-danger"></i> Report a Problem
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted small mb-3">
|
||||||
|
See something that needs attention? Let our team know and we'll take care of it.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% if form.errors %}
|
||||||
|
<div class="alert alert-danger py-2 small">
|
||||||
|
<strong>Please check the form:</strong>
|
||||||
|
<ul class="mb-0 ps-3">
|
||||||
|
{% for field, errs in form.errors.items() %}
|
||||||
|
{% for e in errs %}<li>{{ e }}</li>{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="POST" action="{{ action }}" enctype="multipart/form-data">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
{# Honeypot — invisible to humans, filled by bots #}
|
||||||
|
<div style="position:absolute;left:-9999px;top:-9999px;" aria-hidden="true">
|
||||||
|
{{ form.website(tabindex="-1", autocomplete="off") }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label small fw-semibold">
|
||||||
|
What did you observe? <span class="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
{{ form.description(class="form-control form-control-sm", rows=3,
|
||||||
|
maxlength=2000,
|
||||||
|
placeholder="Describe the issue (e.g. restroom out of paper towels, spill in lobby…)") }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label small fw-semibold">Urgency</label>
|
||||||
|
{{ form.severity(class="form-select form-select-sm") }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label small fw-semibold">{{ area_label_prompt or 'Where in the building?' }}</label>
|
||||||
|
{{ form.area_label(class="form-control form-control-sm",
|
||||||
|
placeholder="e.g. 2nd floor men's restroom") }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label small fw-semibold">Add photos (optional, up to 5)</label>
|
||||||
|
{{ form.photos(class="form-control form-control-sm", accept="image/*") }}
|
||||||
|
<div class="form-text small">A photo helps our team find and fix it faster.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-2 mb-3">
|
||||||
|
<div class="col-6">
|
||||||
|
<label class="form-label small fw-semibold">Your name (optional)</label>
|
||||||
|
{{ form.reporter_name(class="form-control form-control-sm") }}
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<label class="form-label small fw-semibold">Email or phone (optional)</label>
|
||||||
|
{{ form.reporter_contact(class="form-control form-control-sm") }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-danger btn-sm w-100">
|
||||||
|
<i class="bi bi-send me-1"></i> Submit Report
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="robots" content="noindex, nofollow">
|
||||||
|
<title>{{ area.name }} — {{ facility.name }}</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
|
||||||
|
<style>
|
||||||
|
body { background:#f1f5f9; color:#1f2937; }
|
||||||
|
.fq-wrap { max-width:640px; margin:2rem auto; padding:0 1rem; }
|
||||||
|
.stat-tile { background:#fff; border-radius:.5rem; padding:.9rem .5rem; text-align:center;
|
||||||
|
box-shadow:0 1px 2px rgba(0,0,0,.06); height:100%; }
|
||||||
|
.stat-tile .val { font-size:1.6rem; font-weight:700; line-height:1.2; }
|
||||||
|
.stat-tile .lbl { font-size:.72rem; color:#64748b; text-transform:uppercase;
|
||||||
|
letter-spacing:.03em; margin-top:.15rem; }
|
||||||
|
.sev-critical{background:#dc2626}.sev-high{background:#ea580c}
|
||||||
|
.sev-medium{background:#d97706}.sev-low{background:#64748b}
|
||||||
|
.trend-up { color:#15803d; }
|
||||||
|
.trend-down { color:#dc2626; }
|
||||||
|
.trend-flat { color:#64748b; }
|
||||||
|
@media (max-width:576px){ .fq-wrap{ margin:1rem auto; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="fq-wrap">
|
||||||
|
|
||||||
|
{% if request.args.get('reported') == '1' %}
|
||||||
|
<div class="alert alert-success d-flex align-items-center gap-2 mb-3" role="alert">
|
||||||
|
<i class="bi bi-check-circle-fill fs-5"></i>
|
||||||
|
<div><strong>Report submitted.</strong> Our team has been notified and will follow up.</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="d-flex align-items-center gap-2 mb-3">
|
||||||
|
<i class="bi bi-door-open fs-3 text-primary"></i>
|
||||||
|
<div>
|
||||||
|
<div class="fw-bold">{{ area.name }}</div>
|
||||||
|
<div class="text-muted small">
|
||||||
|
{{ facility.name }}
|
||||||
|
{% if area.area_type %} · {{ area.area_type }}{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-light border py-2 small mb-3">
|
||||||
|
<i class="bi bi-info-circle text-primary"></i>
|
||||||
|
Everything below is for <strong>{{ area.name }}</strong> only — not the whole building.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Summary stat tiles (90 days) ── #}
|
||||||
|
<div class="row g-2 mb-3">
|
||||||
|
<div class="col-3">
|
||||||
|
<div class="stat-tile">
|
||||||
|
<div class="val">{% if avg_90 is not none %}{{ '%.1f'|format(avg_90) }}%{% else %}—{% endif %}</div>
|
||||||
|
<div class="lbl">Avg Score<br>90 days</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-3">
|
||||||
|
<div class="stat-tile">
|
||||||
|
<div class="val">{{ total_90 }}</div>
|
||||||
|
<div class="lbl">Inspections<br>90 days</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-3">
|
||||||
|
<div class="stat-tile">
|
||||||
|
<div class="val">{{ open_total }}</div>
|
||||||
|
<div class="lbl">Open<br>Issues</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-3">
|
||||||
|
<div class="stat-tile">
|
||||||
|
<div class="val">{{ resolved_90 }}</div>
|
||||||
|
<div class="lbl">Resolved<br>90 days</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Score trend ── #}
|
||||||
|
<div class="card shadow-sm mb-3">
|
||||||
|
<div class="card-body py-2 d-flex align-items-center justify-content-between">
|
||||||
|
<div class="text-muted small text-uppercase">Score trend — 30 days vs prior 30</div>
|
||||||
|
{% if trend_delta is not none %}
|
||||||
|
{% if trend_delta > 0.5 %}
|
||||||
|
<div class="trend-up fw-semibold">
|
||||||
|
<i class="bi bi-arrow-up-right"></i> Improving
|
||||||
|
(+{{ '%.1f'|format(trend_delta) }} pts, {{ '%.1f'|format(avg_prior) }}% → {{ '%.1f'|format(avg_cur) }}%)
|
||||||
|
</div>
|
||||||
|
{% elif trend_delta < -0.5 %}
|
||||||
|
<div class="trend-down fw-semibold">
|
||||||
|
<i class="bi bi-arrow-down-right"></i> Declining
|
||||||
|
({{ '%.1f'|format(trend_delta) }} pts, {{ '%.1f'|format(avg_prior) }}% → {{ '%.1f'|format(avg_cur) }}%)
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="trend-flat fw-semibold">
|
||||||
|
<i class="bi bi-arrow-right"></i> Steady ({{ '%.1f'|format(avg_cur) }}%)
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<div class="text-muted small">Not enough data yet</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Open issues by severity + SLA state ── #}
|
||||||
|
<div class="card shadow-sm mb-3">
|
||||||
|
<div class="card-header bg-white fw-semibold py-2">
|
||||||
|
<i class="bi bi-exclamation-triangle"></i> Open Issues
|
||||||
|
</div>
|
||||||
|
<div class="card-body py-3">
|
||||||
|
{% if open_total or pending_verification %}
|
||||||
|
<div class="d-flex flex-wrap gap-2 mb-2">
|
||||||
|
{% for sev in severity_order %}
|
||||||
|
{% if severity_counts[sev] %}
|
||||||
|
<span class="badge sev-{{ sev }} text-white">
|
||||||
|
{{ severity_counts[sev] }} {{ sev }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% if pending_verification %}
|
||||||
|
<span class="badge bg-info text-dark">{{ pending_verification }} pending verification</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if sla_breached %}
|
||||||
|
<div class="small text-danger fw-semibold">
|
||||||
|
<i class="bi bi-alarm"></i> {{ sla_breached }} issue{{ 's' if sla_breached != 1 }} past the response-time target
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if sla_at_risk %}
|
||||||
|
<div class="small text-warning-emphasis fw-semibold">
|
||||||
|
<i class="bi bi-hourglass-split"></i> {{ sla_at_risk }} issue{{ 's' if sla_at_risk != 1 }} approaching the response-time target
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if not sla_breached and not sla_at_risk and open_total %}
|
||||||
|
<div class="small text-muted">All open issues are within response-time targets.</div>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<div class="text-muted small"><i class="bi bi-check-circle text-success"></i> No open issues in this area right now.</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Recent inspections ── #}
|
||||||
|
<div class="card shadow-sm mb-3">
|
||||||
|
<div class="card-header bg-white fw-semibold py-2">
|
||||||
|
<i class="bi bi-clipboard-check"></i> Recent Inspections
|
||||||
|
</div>
|
||||||
|
{% if recent %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm mb-0 align-middle">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th class="ps-3">Date</th>
|
||||||
|
<th>Checklist</th>
|
||||||
|
<th class="text-end pe-3">Score</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for ins in recent %}
|
||||||
|
<tr>
|
||||||
|
<td class="ps-3 text-nowrap">{{ ins.inspection_date.strftime('%b %d, %Y') }}</td>
|
||||||
|
<td class="text-muted small">{{ ins.template.name if ins.template else '—' }}</td>
|
||||||
|
<td class="text-end pe-3 fw-semibold">
|
||||||
|
{% if ins.overall_score is not none %}{{ '%.1f'|format(ins.overall_score) }}%{% else %}—{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="card-body py-3 text-muted small">No completed inspections for this area yet.</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if can_view_full %}
|
||||||
|
<a href="{{ url_for('facilities.view_facility', facility_id=facility.id) }}"
|
||||||
|
class="btn btn-primary w-100 mb-3">
|
||||||
|
<i class="bi bi-box-arrow-in-right me-1"></i> Open Full Facility View
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% with action = url_for('facility_qr.area_report', token=token),
|
||||||
|
area_label_prompt = 'Whereabouts in ' ~ area.name ~ '? (optional)' %}
|
||||||
|
{% include "facility_qr/_report_form.html" %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<p class="text-center text-muted small mt-2 mb-1">
|
||||||
|
{% if last_date %}Last inspected {{ last_date.strftime('%b %d, %Y') }} · {% endif %}
|
||||||
|
Snapshot generated {{ generated_at.strftime('%b %d, %Y %I:%M %p') }} ET
|
||||||
|
</p>
|
||||||
|
<p class="text-center text-muted small">Janitorial QC — area quality snapshot</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -177,41 +177,9 @@
|
|||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{# ── Report a problem ── #}
|
{% with action = url_for('facility_qr.report', token=token) %}
|
||||||
<div class="card shadow-sm mb-3">
|
{% include "facility_qr/_report_form.html" %}
|
||||||
<div class="card-header bg-white fw-semibold py-2">
|
{% endwith %}
|
||||||
<i class="bi bi-megaphone text-danger"></i> Report a Problem
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<p class="text-muted small mb-3">
|
|
||||||
See something that needs attention? Let our team know and we'll take care of it.
|
|
||||||
</p>
|
|
||||||
<form method="POST" action="{{ url_for('facility_qr.report', token=token) }}">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
|
||||||
{# Honeypot — invisible to humans, filled by bots #}
|
|
||||||
<div style="position:absolute;left:-9999px;top:-9999px;" aria-hidden="true">
|
|
||||||
<input type="text" name="website" tabindex="-1" autocomplete="off">
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label small fw-semibold">What did you observe? <span class="text-danger">*</span></label>
|
|
||||||
<textarea name="description" class="form-control form-control-sm" rows="3"
|
|
||||||
placeholder="Describe the issue (e.g. restroom out of paper towels, spill in lobby…)"
|
|
||||||
required maxlength="2000"></textarea>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label small fw-semibold">Urgency</label>
|
|
||||||
<select name="severity" class="form-select form-select-sm">
|
|
||||||
<option value="low">Low — not urgent</option>
|
|
||||||
<option value="medium" selected>Medium — needs attention soon</option>
|
|
||||||
<option value="high">High — urgent</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn btn-danger btn-sm w-100">
|
|
||||||
<i class="bi bi-send me-1"></i> Submit Report
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="text-center text-muted small mt-2 mb-1">
|
<p class="text-center text-muted small mt-2 mb-1">
|
||||||
{% if last_date %}Last inspected {{ last_date.strftime('%b %d, %Y') }} · {% endif %}
|
{% if last_date %}Last inspected {{ last_date.strftime('%b %d, %Y') }} · {% endif %}
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
{# ── "Confirm receipt" result page (phase50) ─────────────────────────────────
|
||||||
|
Landing page for the one-click link in a scheduled-inspection assignment or
|
||||||
|
reminder email. Standalone — NOT extending base.html — because the viewer is
|
||||||
|
typically not logged in and base.html's nav assumes current_user. Same shape
|
||||||
|
as the public QR scan pages (facility_qr/area.html).
|
||||||
|
|
||||||
|
`status` is always set; `schedule` only for the statuses that found one.
|
||||||
|
confirmed — newly acknowledged (the happy path)
|
||||||
|
already — acknowledged before; a re-click or an email prefetch
|
||||||
|
reassigned — the token's inspector is no longer the assignee
|
||||||
|
inactive — schedule paused or ended since the email went out
|
||||||
|
expired — token older than 30 days
|
||||||
|
invalid — bad signature / mangled link
|
||||||
|
missing — schedule deleted since the email went out
|
||||||
|
#}
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="robots" content="noindex, nofollow">
|
||||||
|
<title>Confirm Receipt — Scheduled Inspection</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
|
||||||
|
<style>
|
||||||
|
body { background:#f1f5f9; color:#1f2937; }
|
||||||
|
.cf-wrap { max-width:520px; margin:3rem auto; padding:0 1rem; }
|
||||||
|
.cf-card { background:#fff; border-radius:.75rem; padding:2rem 1.5rem; text-align:center;
|
||||||
|
box-shadow:0 1px 3px rgba(0,0,0,.08); }
|
||||||
|
.cf-icon { font-size:3.5rem; line-height:1; }
|
||||||
|
.cf-meta { background:#f8fafc; border-radius:.5rem; padding:.9rem 1rem; text-align:left;
|
||||||
|
font-size:.9rem; margin-top:1.25rem; }
|
||||||
|
.cf-meta .lbl { color:#64748b; font-size:.75rem; text-transform:uppercase;
|
||||||
|
letter-spacing:.03em; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="cf-wrap">
|
||||||
|
<div class="cf-card">
|
||||||
|
|
||||||
|
{% if status == 'confirmed' %}
|
||||||
|
<div class="cf-icon text-success"><i class="bi bi-check-circle-fill"></i></div>
|
||||||
|
<h1 class="h4 mt-3 mb-2">Receipt confirmed</h1>
|
||||||
|
<p class="text-muted mb-0">
|
||||||
|
Thanks — we've let the scheduler know you've seen this request.
|
||||||
|
Nothing else to do right now.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% elif status == 'already' %}
|
||||||
|
<div class="cf-icon text-success"><i class="bi bi-check-circle"></i></div>
|
||||||
|
<h1 class="h4 mt-3 mb-2">Already confirmed</h1>
|
||||||
|
<p class="text-muted mb-0">
|
||||||
|
You confirmed this one earlier. No need to do anything else.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% elif status == 'reassigned' %}
|
||||||
|
<div class="cf-icon text-warning"><i class="bi bi-person-x"></i></div>
|
||||||
|
<h1 class="h4 mt-3 mb-2">No longer assigned to you</h1>
|
||||||
|
<p class="text-muted mb-0">
|
||||||
|
This scheduled inspection has been reassigned to someone else since the
|
||||||
|
email was sent, so there's nothing for you to confirm.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% elif status == 'inactive' %}
|
||||||
|
<div class="cf-icon text-secondary"><i class="bi bi-pause-circle"></i></div>
|
||||||
|
<h1 class="h4 mt-3 mb-2">This schedule is no longer active</h1>
|
||||||
|
<p class="text-muted mb-0">
|
||||||
|
It has been paused or has reached its end date. No action is needed.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% elif status == 'expired' %}
|
||||||
|
<div class="cf-icon text-secondary"><i class="bi bi-hourglass-bottom"></i></div>
|
||||||
|
<h1 class="h4 mt-3 mb-2">This link has expired</h1>
|
||||||
|
<p class="text-muted mb-0">
|
||||||
|
Confirmation links are good for 30 days. Sign in to the schedules page to
|
||||||
|
confirm, or ask your supervisor to resend the assignment.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% elif status == 'missing' %}
|
||||||
|
<div class="cf-icon text-secondary"><i class="bi bi-question-circle"></i></div>
|
||||||
|
<h1 class="h4 mt-3 mb-2">Schedule not found</h1>
|
||||||
|
<p class="text-muted mb-0">
|
||||||
|
This scheduled inspection has since been removed. No action is needed.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<div class="cf-icon text-danger"><i class="bi bi-x-circle"></i></div>
|
||||||
|
<h1 class="h4 mt-3 mb-2">This link isn't valid</h1>
|
||||||
|
<p class="text-muted mb-0">
|
||||||
|
The link may have been copied incompletely. Try tapping it directly from
|
||||||
|
the email, or sign in to confirm from the schedules page.
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if schedule %}
|
||||||
|
<div class="cf-meta">
|
||||||
|
<div class="lbl">Inspection</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
{{ schedule.template.name if schedule.template else '—' }}
|
||||||
|
at {{ schedule.facility.name if schedule.facility else '—' }}
|
||||||
|
</div>
|
||||||
|
<div class="lbl">Schedule</div>
|
||||||
|
<div>
|
||||||
|
{{ schedule.recurrence_label }}{% if schedule.due_date %} · due
|
||||||
|
{{ schedule.due_date.strftime('%b %d, %Y') }}{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<p class="text-center text-muted small mt-3 mb-0">
|
||||||
|
Janitorial QC System — automated message. Do not reply to the email.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,134 +1,481 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}{{ title }}{% endblock %}
|
{% block title %}{{ title }}{% endblock %}
|
||||||
|
|
||||||
|
{# Laid out to match the single-tenant scheduled-inspection form: one narrow
|
||||||
|
card, Contract → Facility cascade at the top, then what/who, then when.
|
||||||
|
|
||||||
|
MT keeps three fields ST does not have — the schedule NAME (required by
|
||||||
|
inspection_schedules.name), the AREA (a schedule may target one area) and
|
||||||
|
the auto/plan MODE. They are placed next to the field they qualify rather
|
||||||
|
than in a block of their own. #}
|
||||||
|
|
||||||
|
{# ── Sticky values ────────────────────────────────────────────────────────
|
||||||
|
This form is hand-built (no WTForms), so a re-render after a validation
|
||||||
|
error would otherwise come back blank and the user would retype everything.
|
||||||
|
On POST every field reads back from request.form; otherwise from the saved
|
||||||
|
schedule (edit) or its default (create). ST gets this free from WTForms —
|
||||||
|
this is the equivalent. #}
|
||||||
|
{% set posted = request.form if request.method == 'POST' else None %}
|
||||||
|
{% set v_name = posted.get('name') if posted else (schedule.name if schedule else '') %}
|
||||||
|
{% set v_facility = (posted.get('facility_id')|int(0)) if posted else (schedule.facility_id if schedule else 0) %}
|
||||||
|
{% set v_area = (posted.get('area_id')|int(0)) if posted else (schedule.area_id if schedule and schedule.area_id else 0) %}
|
||||||
|
{% set v_template = (posted.get('template_id')|int(0)) if posted else (schedule.template_id if schedule else 0) %}
|
||||||
|
{% set v_inspector = (posted.get('inspector_id')|int(0)) if posted else (schedule.inspector_id if schedule else 0) %}
|
||||||
|
{% set v_frequency = posted.get('frequency') if posted else (schedule.frequency if schedule else 'weekly') %}
|
||||||
|
{% set v_due = posted.get('next_due_date') if posted else (schedule.due_date.isoformat() if schedule and schedule.due_date else '') %}
|
||||||
|
{% set v_end = posted.get('end_date') if posted else (schedule.end_date.isoformat() if schedule and schedule.end_date else '') %}
|
||||||
|
{% set v_mode = posted.get('mode') if posted else (schedule.mode if schedule else 'auto') %}
|
||||||
|
{% set v_notes = posted.get('notes') if posted else (schedule.notes if schedule and schedule.notes else '') %}
|
||||||
|
{% set v_month_mode = posted.get('month_mode') if posted else (schedule.month_mode if schedule else 'day_of_month') %}
|
||||||
|
{% set v_dom = posted.get('day_of_month') if posted else (schedule.day_of_month if schedule and schedule.day_of_month else '') %}
|
||||||
|
{% set v_nth_week = (posted.get('nth_week')|int(0)) if posted else (schedule.nth_week if schedule and schedule.nth_week else 0) %}
|
||||||
|
{% set v_nth_weekday = (posted.get('nth_weekday')|int(-1)) if posted else (schedule.nth_weekday if schedule and schedule.nth_weekday is not none else -1) %}
|
||||||
|
{% set v_weekdays = (posted.getlist('weekdays')|map('int')|list) if posted else (schedule.weekday_list if schedule else []) %}
|
||||||
|
{% set v_active = (posted.get('active') is not none) if posted else (schedule.active if schedule else True) %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
<div class="col-lg-8">
|
<div class="col-lg-7">
|
||||||
<h2 class="mb-4"><i class="bi bi-calendar2-week"></i> {{ title }}</h2>
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header bg-light"><h5 class="mb-0">{{ title }}</h5></div>
|
||||||
<form method="POST" class="card shadow-sm">
|
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
<form method="POST" novalidate>
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Schedule name</label>
|
<label class="form-label fw-semibold" for="name">Schedule name</label>
|
||||||
<input type="text" name="name" class="form-control" required
|
<input type="text" name="name" id="name" class="form-control" required
|
||||||
value="{{ schedule.name if schedule else '' }}"
|
value="{{ v_name }}"
|
||||||
placeholder="e.g. Weekly restroom check — Main Office">
|
placeholder="e.g. Weekly restroom check — Main Office">
|
||||||
|
<div class="form-text">Shown in the schedule list and in the inspector's reminder.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Contract selector — UI only; narrows the facility list via AJAX.
|
||||||
|
It carries no name attribute and is never submitted: the facility
|
||||||
|
is what the route validates (rule 61). #}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold" for="contract_select">Contract</label>
|
||||||
|
<select id="contract_select" class="form-select">
|
||||||
|
<option value="">— Select Contract —</option>
|
||||||
|
{% for p in projects %}
|
||||||
|
<option value="{{ p.id }}">{{ p.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-6 mb-3">
|
<div class="col-md-6 mb-3">
|
||||||
<label class="form-label">Template</label>
|
<label class="form-label fw-semibold" for="facility_id">Facility</label>
|
||||||
<select name="template_id" class="form-select" required>
|
<select name="facility_id" id="facility_id" class="form-select" required>
|
||||||
<option value="">— Choose a template —</option>
|
<option value="">— Select a Contract first —</option>
|
||||||
{% for t in templates %}
|
|
||||||
<option value="{{ t.id }}"
|
|
||||||
{{ 'selected' if schedule and schedule.template_id == t.id }}>{{ t.name }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6 mb-3">
|
|
||||||
<label class="form-label">Frequency</label>
|
|
||||||
<select name="frequency" class="form-select" required>
|
|
||||||
{% for f in frequencies %}
|
|
||||||
<option value="{{ f }}"
|
|
||||||
{{ 'selected' if (schedule and schedule.frequency == f) or (not schedule and f == 'weekly') }}>
|
|
||||||
{{ f|title }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6 mb-3">
|
|
||||||
<label class="form-label">Facility</label>
|
|
||||||
<select name="facility_id" id="facilitySelect" class="form-select" required>
|
|
||||||
<option value="">— Choose a facility —</option>
|
|
||||||
{% for f in facilities %}
|
{% for f in facilities %}
|
||||||
<option value="{{ f.id }}"
|
<option value="{{ f.id }}"
|
||||||
{{ 'selected' if schedule and schedule.facility_id == f.id }}>{{ f.name }}</option>
|
{{ 'selected' if v_facility == f.id }}>{{ f.name }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6 mb-3">
|
<div class="col-md-6 mb-3">
|
||||||
<label class="form-label">Area <span class="text-muted small">(optional)</span></label>
|
<label class="form-label fw-semibold" for="area_id">
|
||||||
<select name="area_id" id="areaSelect" class="form-select">
|
Area <span class="text-muted small fw-normal">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<select name="area_id" id="area_id" class="form-select">
|
||||||
<option value="">— Whole facility —</option>
|
<option value="">— Whole facility —</option>
|
||||||
|
{# Refilled by JS from the chosen facility; this keeps the saved
|
||||||
|
or just-submitted area selected until that call returns. #}
|
||||||
{% if schedule and schedule.area %}
|
{% if schedule and schedule.area %}
|
||||||
<option value="{{ schedule.area.id }}" selected>{{ schedule.area.name }}</option>
|
<option value="{{ schedule.area.id }}"
|
||||||
|
{{ 'selected' if v_area == schedule.area.id }}>{{ schedule.area.name }}</option>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Assign to inspector</label>
|
<label class="form-label fw-semibold" for="template_id">Inspection Form</label>
|
||||||
<select name="inspector_id" class="form-select" required>
|
<select name="template_id" id="template_id" class="form-select" required>
|
||||||
<option value="">— Choose an inspector —</option>
|
<option value="">— Choose a form —</option>
|
||||||
|
{% for t in templates %}
|
||||||
|
<option value="{{ t.id }}"
|
||||||
|
{{ 'selected' if v_template == t.id }}>{{ t.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<div class="form-text">
|
||||||
|
Shared forms plus any built for this contract. A form belonging to
|
||||||
|
another contract is rejected on save, not merely hidden here.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold" for="inspector_id">Assign to inspector</label>
|
||||||
|
<select name="inspector_id" id="inspector_id" class="form-select" required>
|
||||||
|
<option value="">— Select a Contract first —</option>
|
||||||
{% for u in inspectors %}
|
{% for u in inspectors %}
|
||||||
<option value="{{ u.id }}"
|
<option value="{{ u.id }}"
|
||||||
{{ 'selected' if schedule and schedule.inspector_id == u.id }}>
|
{{ 'selected' if v_inspector == u.id }}>
|
||||||
{{ u.display_name }} ({{ u.role }})</option>
|
{{ u.display_name }}{{ ' (Customer)' if u.is_external_inspector }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<div class="form-text">
|
||||||
|
Inspectors assigned to this contract — the person named here has
|
||||||
|
to be able to open the inspection. Managers are never listed: a
|
||||||
|
manager who will do the work holds a contract assignment like
|
||||||
|
anyone else. If the contract has nobody assigned yet, every
|
||||||
|
inspector is offered so the schedule is not blocked.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label fw-semibold" for="frequency">Frequency</label>
|
||||||
|
<select name="frequency" id="frequency" class="form-select" required>
|
||||||
|
{% for f in frequencies %}
|
||||||
|
<option value="{{ f }}" {{ 'selected' if v_frequency == f }}>
|
||||||
|
{{ frequency_labels.get(f, f|title) }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label fw-semibold" id="due_date_label" for="next_due_date">
|
||||||
|
{{ 'Next Due Date' if schedule else 'Start Date' }}
|
||||||
|
</label>
|
||||||
|
<input type="date" name="next_due_date" id="next_due_date" class="form-control"
|
||||||
|
value="{{ v_due }}">
|
||||||
|
<div class="form-text">
|
||||||
{% if schedule %}
|
{% if schedule %}
|
||||||
<div class="form-check form-switch mb-1">
|
Snapped forward to the first matching day. Leave it unchanged
|
||||||
<input class="form-check-input" type="checkbox" name="active" id="activeSwitch"
|
and saving will not move it.
|
||||||
{{ 'checked' if schedule.active }}>
|
{% else %}
|
||||||
<label class="form-check-label" for="activeSwitch">Active</label>
|
Snapped forward to the first matching day. Leave blank to start
|
||||||
</div>
|
one full period from now.
|
||||||
<p class="text-muted small">
|
|
||||||
Saving recomputes the next run from now. Next run:
|
|
||||||
{{ schedule.next_run_at.strftime('%Y-%m-%d %H:%M') if schedule.next_run_at else '—' }}
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card-footer d-flex justify-content-between">
|
{# ── End date ──
|
||||||
<a href="{{ url_for('inspection_schedules.index') }}" class="btn btn-outline-secondary">Cancel</a>
|
Hidden for one-time schedules, which end by deactivating when
|
||||||
|
completed. syncFrequency() toggles it; the route clears the column
|
||||||
|
for 'once', so a stale DOM value cannot survive a frequency change. #}
|
||||||
|
<div class="row" id="end_date_row" hidden>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label fw-semibold" for="end_date">
|
||||||
|
End Date <span class="text-muted small fw-normal">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<input type="date" name="end_date" id="end_date" class="form-control"
|
||||||
|
value="{{ v_end }}">
|
||||||
|
<div class="form-text">
|
||||||
|
The last date this schedule may run — leave blank to repeat indefinitely.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Weekly: which days of the week ──────────────────────────── #}
|
||||||
|
<div class="mb-3 p-3 rounded bg-light border" id="weekly_block" hidden>
|
||||||
|
<label class="form-label fw-semibold d-block">Days of the Week</label>
|
||||||
|
<div class="d-flex flex-wrap gap-3">
|
||||||
|
{% for i, day in [(0,'Mon'),(1,'Tue'),(2,'Wed'),(3,'Thu'),(4,'Fri'),(5,'Sat'),(6,'Sun')] %}
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" name="weekdays"
|
||||||
|
id="weekday_{{ i }}" value="{{ i }}" {{ 'checked' if i in v_weekdays }}>
|
||||||
|
<label class="form-check-label" for="weekday_{{ i }}">{{ day }}</label>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="form-text mb-0">
|
||||||
|
Pick every day the inspection recurs — e.g. Mon, Wed, Fri gives three
|
||||||
|
inspections a week. The due date rolls to the next selected day each
|
||||||
|
time one is submitted.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Monthly: day-of-month OR nth weekday ────────────────────── #}
|
||||||
|
<div class="mb-3 p-3 rounded bg-light border" id="monthly_block" hidden>
|
||||||
|
<label class="form-label fw-semibold d-block">Monthly rule</label>
|
||||||
|
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="radio" name="month_mode"
|
||||||
|
id="month_mode_day" value="day_of_month"
|
||||||
|
{% if v_month_mode != 'nth_weekday' %}checked{% endif %}>
|
||||||
|
<label class="form-check-label" for="month_mode_day">On a day of the month</label>
|
||||||
|
</div>
|
||||||
|
<div class="ms-4 mb-2" id="dom_row">
|
||||||
|
<div class="input-group input-group-sm" style="max-width:16rem;">
|
||||||
|
<span class="input-group-text">Day</span>
|
||||||
|
<input type="number" name="day_of_month" class="form-control"
|
||||||
|
min="1" max="31" placeholder="15"
|
||||||
|
value="{{ v_dom }}">
|
||||||
|
</div>
|
||||||
|
<div class="form-text mb-0">Months without that day use their last day.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="radio" name="month_mode"
|
||||||
|
id="month_mode_nth" value="nth_weekday"
|
||||||
|
{% if v_month_mode == 'nth_weekday' %}checked{% endif %}>
|
||||||
|
<label class="form-check-label" for="month_mode_nth">On a weekday of the month</label>
|
||||||
|
</div>
|
||||||
|
<div class="ms-4" id="nth_row">
|
||||||
|
<div class="d-flex gap-2 flex-wrap" style="max-width:24rem;">
|
||||||
|
<select name="nth_week" class="form-select form-select-sm" style="max-width:7rem;">
|
||||||
|
{% for v, lbl in [(1,'1st'),(2,'2nd'),(3,'3rd'),(4,'4th'),(5,'5th'),(-1,'Last')] %}
|
||||||
|
<option value="{{ v }}" {{ 'selected' if v_nth_week == v }}>{{ lbl }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<select name="nth_weekday" class="form-select form-select-sm" style="max-width:11rem;">
|
||||||
|
{% for i, day in [(0,'Monday'),(1,'Tuesday'),(2,'Wednesday'),(3,'Thursday'),(4,'Friday'),(5,'Saturday'),(6,'Sunday')] %}
|
||||||
|
<option value="{{ i }}" {{ 'selected' if v_nth_weekday == i }}>{{ day }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-text mb-0">e.g. the 2nd Tuesday of every month.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold" for="mode">Mode</label>
|
||||||
|
<select name="mode" id="mode" class="form-select">
|
||||||
|
<option value="auto" {{ 'selected' if v_mode != 'plan' }}>
|
||||||
|
Auto — create the inspection automatically each period</option>
|
||||||
|
<option value="plan" {{ 'selected' if v_mode == 'plan' }}>
|
||||||
|
Plan — inspector presses Start (with due/overdue reminders)</option>
|
||||||
|
</select>
|
||||||
|
<div class="form-text">
|
||||||
|
Auto drops an in-progress inspection into the inspector's queue on
|
||||||
|
schedule. Plan assigns a due date and reminds them the day before, on
|
||||||
|
the day, and alerts managers once it's overdue.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold" for="notes">
|
||||||
|
Instructions for the inspector <span class="text-muted small fw-normal">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<textarea name="notes" id="notes" class="form-control" rows="3"
|
||||||
|
placeholder="e.g. Front lobby carpet needs extra attention. Check loading dock after 3 PM — key is at the front desk.">{{ v_notes }}</textarea>
|
||||||
|
<div class="form-text">
|
||||||
|
<i class="bi bi-info-circle"></i>
|
||||||
|
Shown to the assigned inspector when they open this inspection, on the web and on the iPad.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if schedule %}
|
||||||
|
<div class="form-check mb-3">
|
||||||
|
<input class="form-check-input" type="checkbox" name="active" id="active"
|
||||||
|
{{ 'checked' if v_active }}>
|
||||||
|
<label class="form-check-label" for="active">Active</label>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="d-flex gap-2">
|
||||||
<button type="submit" class="btn btn-primary">
|
<button type="submit" class="btn btn-primary">
|
||||||
<i class="bi bi-check-lg"></i> Save Schedule
|
<i class="bi bi-check-lg"></i> Save
|
||||||
</button>
|
</button>
|
||||||
|
<a href="{{ url_for('inspection_schedules.index') }}" class="btn btn-outline-secondary">Cancel</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<p class="text-muted small mt-2">
|
||||||
// Facility → Area cascade, reusing the existing inspections AJAX endpoint.
|
Recurring schedules automatically roll their due date forward each time the
|
||||||
(function () {
|
inspection is completed. The assigned inspector is reminded the day before
|
||||||
var facilitySelect = document.getElementById('facilitySelect');
|
and on the due date; managers are alerted if it becomes overdue.
|
||||||
var areaSelect = document.getElementById('areaSelect');
|
{% if schedule %}
|
||||||
if (!facilitySelect || !areaSelect) return;
|
<br>
|
||||||
|
Current {{ 'due date' if schedule.mode == 'plan' else 'run' }}:
|
||||||
|
{{ schedule.next_run_at.strftime('%Y-%m-%d %H:%M') if schedule.next_run_at else '—' }}
|
||||||
|
{% if schedule.last_completed_at %}
|
||||||
|
· last completed {{ schedule.last_completed_at.strftime('%Y-%m-%d %H:%M') }}
|
||||||
|
{% endif %}
|
||||||
|
{% if schedule.end_date %}· ends {{ schedule.end_date.strftime('%Y-%m-%d') }}{% endif %}.
|
||||||
|
Reminders for this occurrence are only reset if the due date actually moves,
|
||||||
|
so renaming or re-noting a schedule will not re-send them.
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
var preselectedAreaId = {{ (schedule.area_id if schedule and schedule.area_id else 0) | tojson }};
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
// ── Contract → Facility → Area cascade ──────────────────────────────────────
|
||||||
|
// The contract selector is UI-only (no name attribute): it never reaches the
|
||||||
|
// server, it only narrows the facility list. facilities_for_project is scoped
|
||||||
|
// to the caller, so a Customer Director asking for another contract's id gets
|
||||||
|
// an empty list rather than that customer's building names.
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
var contractSel = document.getElementById('contract_select');
|
||||||
|
var facilitySel = document.getElementById('facility_id');
|
||||||
|
var areaSel = document.getElementById('area_id');
|
||||||
|
var inspectorSel = document.getElementById('inspector_id');
|
||||||
|
if (!contractSel || !facilitySel) { return; }
|
||||||
|
|
||||||
|
var FACILITIES_URL = '{{ url_for("inspections.facilities_for_project", project_id=0) }}'.replace('/0', '/');
|
||||||
|
var INSPECTORS_URL = '{{ url_for("inspection_schedules.inspectors_for_contract", project_id=0) }}'.replace('/0', '/');
|
||||||
|
var AREAS_URL = '{{ url_for("inspections.areas_for_facility", facility_id=0) }}'.replace('/0', '/');
|
||||||
|
var preProjectId = {{ selected_project_id | tojson }};
|
||||||
|
var preFacilityId = {{ v_facility | tojson }};
|
||||||
|
var preAreaId = {{ v_area | tojson }};
|
||||||
|
var preInspectorId = {{ v_inspector | tojson }};
|
||||||
|
|
||||||
|
function setPlaceholder() {
|
||||||
|
facilitySel.innerHTML = '<option value="">— Select a Contract first —</option>';
|
||||||
|
facilitySel.disabled = true;
|
||||||
|
loadAreas('', false);
|
||||||
|
if (inspectorSel) {
|
||||||
|
inspectorSel.innerHTML = '<option value="">— Select a Contract first —</option>';
|
||||||
|
inspectorSel.disabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inspectors come from the CONTRACT, not the facility: assignment rows are
|
||||||
|
// per contract. The server re-checks the chosen id against this same list.
|
||||||
|
function loadInspectors(projectId, restoreInspectorId) {
|
||||||
|
if (!inspectorSel) { return; }
|
||||||
|
inspectorSel.disabled = true;
|
||||||
|
inspectorSel.innerHTML = '<option value="">Loading…</option>';
|
||||||
|
fetch(INSPECTORS_URL + projectId)
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (data) {
|
||||||
|
if (!data.length) {
|
||||||
|
// Only reachable for a Customer Director: our own staff fall back to
|
||||||
|
// the full inspector pool server-side. Say what is wrong rather than
|
||||||
|
// leaving an empty dropdown.
|
||||||
|
inspectorSel.innerHTML =
|
||||||
|
'<option value="">— No inspectors on this contract —</option>';
|
||||||
|
inspectorSel.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
inspectorSel.innerHTML = '<option value="">— Choose an inspector —</option>';
|
||||||
|
data.forEach(function (u) {
|
||||||
|
var opt = document.createElement('option');
|
||||||
|
opt.value = u.id;
|
||||||
|
opt.textContent = u.name;
|
||||||
|
if (restoreInspectorId && u.id === restoreInspectorId) { opt.selected = true; }
|
||||||
|
inspectorSel.appendChild(opt);
|
||||||
|
});
|
||||||
|
inspectorSel.disabled = false;
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
inspectorSel.innerHTML = '<option value="">Could not load inspectors</option>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function loadAreas(facilityId, keepSelection) {
|
function loadAreas(facilityId, keepSelection) {
|
||||||
areaSelect.innerHTML = '<option value="">— Whole facility —</option>';
|
if (!areaSel) { return; }
|
||||||
if (!facilityId) return;
|
areaSel.innerHTML = '<option value="">— Whole facility —</option>';
|
||||||
fetch('{{ url_for('inspections.areas_for_facility', facility_id=0) }}'.replace('/0', '/' + facilityId))
|
if (!facilityId) { return; }
|
||||||
|
fetch(AREAS_URL + facilityId)
|
||||||
.then(function (r) { return r.json(); })
|
.then(function (r) { return r.json(); })
|
||||||
.then(function (areas) {
|
.then(function (areas) {
|
||||||
areas.forEach(function (a) {
|
areas.forEach(function (a) {
|
||||||
var opt = document.createElement('option');
|
var opt = document.createElement('option');
|
||||||
opt.value = a.id;
|
opt.value = a.id;
|
||||||
opt.textContent = a.name;
|
opt.textContent = a.name;
|
||||||
if (keepSelection && a.id === preselectedAreaId) opt.selected = true;
|
if (keepSelection && a.id === preAreaId) { opt.selected = true; }
|
||||||
areaSelect.appendChild(opt);
|
areaSel.appendChild(opt);
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(function () { /* leave the whole-facility default in place */ });
|
.catch(function () { /* leave the whole-facility default in place */ });
|
||||||
}
|
}
|
||||||
|
|
||||||
facilitySelect.addEventListener('change', function () {
|
function loadFacilities(projectId, restoreFacilityId) {
|
||||||
preselectedAreaId = 0;
|
facilitySel.disabled = true;
|
||||||
|
facilitySel.innerHTML = '<option value="">Loading…</option>';
|
||||||
|
fetch(FACILITIES_URL + projectId)
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (data) {
|
||||||
|
facilitySel.innerHTML = '<option value="">— Select Facility —</option>';
|
||||||
|
data.forEach(function (f) {
|
||||||
|
var opt = document.createElement('option');
|
||||||
|
opt.value = f.id;
|
||||||
|
opt.textContent = f.name;
|
||||||
|
if (restoreFacilityId && f.id === restoreFacilityId) { opt.selected = true; }
|
||||||
|
facilitySel.appendChild(opt);
|
||||||
|
});
|
||||||
|
facilitySel.disabled = false;
|
||||||
|
if (restoreFacilityId) { loadAreas(restoreFacilityId, true); }
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
facilitySel.innerHTML = '<option value="">Could not load facilities</option>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
contractSel.addEventListener('change', function () {
|
||||||
|
if (this.value) {
|
||||||
|
loadFacilities(this.value, null);
|
||||||
|
loadInspectors(this.value, null);
|
||||||
|
} else {
|
||||||
|
setPlaceholder();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
facilitySel.addEventListener('change', function () {
|
||||||
|
preAreaId = 0; // a new facility invalidates the saved area
|
||||||
loadAreas(this.value, false);
|
loadAreas(this.value, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
// On edit load, refresh the area list for the saved facility and keep the saved area.
|
// Initial state: restore the contract, facility and area on edit / re-render.
|
||||||
if (facilitySelect.value) loadAreas(facilitySelect.value, true);
|
if (preProjectId) {
|
||||||
})();
|
contractSel.value = String(preProjectId);
|
||||||
|
loadFacilities(preProjectId, preFacilityId);
|
||||||
|
loadInspectors(preProjectId, preInspectorId);
|
||||||
|
} else if (preFacilityId) {
|
||||||
|
// Facility on no contract (or one the selector cannot name): keep the
|
||||||
|
// server-rendered options and the current choice rather than clearing it.
|
||||||
|
facilitySel.disabled = false;
|
||||||
|
loadAreas(String(preFacilityId), true);
|
||||||
|
if (inspectorSel) { inspectorSel.disabled = false; }
|
||||||
|
} else {
|
||||||
|
setPlaceholder();
|
||||||
|
}
|
||||||
|
}());
|
||||||
|
|
||||||
|
// ── Recurrence blocks follow the chosen frequency ───────────────────────────
|
||||||
|
// Display only — the server re-validates and clears the unused blocks on save,
|
||||||
|
// so stale values left in the DOM never take effect.
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
var freq = document.getElementById('frequency');
|
||||||
|
var weekly = document.getElementById('weekly_block');
|
||||||
|
var monthly = document.getElementById('monthly_block');
|
||||||
|
var endRow = document.getElementById('end_date_row');
|
||||||
|
var dueLabel = document.getElementById('due_date_label');
|
||||||
|
if (!freq || !weekly || !monthly) { return; }
|
||||||
|
|
||||||
|
var domRadio = document.getElementById('month_mode_day');
|
||||||
|
var nthRadio = document.getElementById('month_mode_nth');
|
||||||
|
var domRow = document.getElementById('dom_row');
|
||||||
|
var nthRow = document.getElementById('nth_row');
|
||||||
|
var isEdit = {{ 'true' if schedule else 'false' }};
|
||||||
|
// Every frequency that repeats on a month boundary uses the monthly rule.
|
||||||
|
var MONTHLY = ['monthly', 'quarterly', 'bi-annually', 'annually'];
|
||||||
|
|
||||||
|
function syncMonthMode() {
|
||||||
|
if (!domRow || !nthRow) { return; }
|
||||||
|
var useNth = nthRadio && nthRadio.checked;
|
||||||
|
domRow.style.opacity = useNth ? '.45' : '1';
|
||||||
|
nthRow.style.opacity = useNth ? '1' : '.45';
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncFrequency() {
|
||||||
|
weekly.hidden = freq.value !== 'weekly';
|
||||||
|
monthly.hidden = MONTHLY.indexOf(freq.value) === -1;
|
||||||
|
// End date is a recurring-only concept.
|
||||||
|
if (endRow) { endRow.hidden = freq.value === 'once'; }
|
||||||
|
if (dueLabel) {
|
||||||
|
dueLabel.textContent = isEdit ? 'Next Due Date'
|
||||||
|
: (freq.value === 'once' ? 'Date' : 'Start Date');
|
||||||
|
}
|
||||||
|
syncMonthMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
freq.addEventListener('change', syncFrequency);
|
||||||
|
[domRadio, nthRadio].forEach(function (r) {
|
||||||
|
if (r) { r.addEventListener('change', syncMonthMode); }
|
||||||
|
});
|
||||||
|
syncFrequency();
|
||||||
|
}());
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,53 +1,165 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Inspection Schedules{% endblock %}
|
{% block title %}Inspection Schedules{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{# Who may create/edit/delete a schedule — mirrors schedule_manager_required
|
||||||
|
in routes/inspection_schedules.py. 'customer' is the Customer DIRECTOR, who
|
||||||
|
plans work for their own facilities; a Customer Inspector ('external_inspector')
|
||||||
|
performs schedules and is covered by is_inspector below. #}
|
||||||
|
{% set can_manage_schedules = current_user.role in
|
||||||
|
['admin','director','project_manager','auditor','customer'] %}
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
<h2><i class="bi bi-calendar2-week"></i> Inspection Schedules</h2>
|
<h2><i class="bi bi-calendar2-week"></i> Inspection Schedules</h2>
|
||||||
|
{# This page is reached from the Inspections list ("Scheduled") and has no
|
||||||
|
entry of its own in the main nav, so without this button the only way back
|
||||||
|
is the browser control. #}
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a href="{{ url_for('inspections.index') }}" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-arrow-left"></i> Inspections
|
||||||
|
</a>
|
||||||
|
{% if can_manage_schedules %}
|
||||||
<a href="{{ url_for('inspection_schedules.create') }}" class="btn btn-primary">
|
<a href="{{ url_for('inspection_schedules.create') }}" class="btn btn-primary">
|
||||||
<i class="bi bi-plus-circle"></i> New Schedule
|
<i class="bi bi-plus-circle"></i> New Schedule
|
||||||
</a>
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="text-muted small mb-4">
|
<p class="text-muted small mb-4">
|
||||||
Recurring schedules automatically create an in-progress inspection for the
|
{% if current_user.is_inspector %}
|
||||||
assigned inspector each period. The inspector is notified and opens it from
|
Inspections scheduled for you. <strong>Auto</strong> schedules appear in your
|
||||||
their Inspections list to complete it.
|
Inspections list on their own each period; <strong>Plan</strong> schedules wait
|
||||||
|
for you to press Start.
|
||||||
|
{% else %}
|
||||||
|
<strong>Auto</strong> schedules automatically create an in-progress inspection
|
||||||
|
for the assigned inspector each period. <strong>Plan</strong> schedules assign a
|
||||||
|
due date and let the inspector press Start when they begin — with reminders the
|
||||||
|
day before, on the day, and an alert to managers once overdue.
|
||||||
|
{% endif %}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{# Pending / Completed tabs (phase51). The partition is on `active`, so it is
|
||||||
|
exhaustive — no schedule can fall between the two tabs. #}
|
||||||
|
<ul class="nav nav-tabs mb-0">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {{ 'active' if tab == 'pending' }}"
|
||||||
|
href="{{ url_for('inspection_schedules.index', tab='pending') }}">
|
||||||
|
<i class="bi bi-hourglass-split"></i> Pending
|
||||||
|
<span class="badge rounded-pill bg-{{ 'primary' if tab == 'pending' else 'secondary' }} ms-1">{{ pending_count }}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {{ 'active' if tab == 'completed' }}"
|
||||||
|
href="{{ url_for('inspection_schedules.index', tab='completed') }}">
|
||||||
|
<i class="bi bi-check2-circle"></i> Completed
|
||||||
|
<span class="badge rounded-pill bg-{{ 'primary' if tab == 'completed' else 'secondary' }} ms-1">{{ completed_count }}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
{% if schedules %}
|
{% if schedules %}
|
||||||
<div class="card shadow-sm">
|
<div class="card shadow-sm border-top-0 rounded-top-0">
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-hover mb-0 align-middle">
|
<table class="table table-hover mb-0 align-middle">
|
||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
<tr>
|
<tr>
|
||||||
<th>Name</th><th>Template</th><th>Facility / Area</th>
|
<th>Name</th><th>Template</th><th>Facility / Area</th>
|
||||||
<th>Inspector</th><th>Frequency</th><th>Next Run</th>
|
<th>Inspector</th><th>Frequency</th><th>Mode</th><th>Next Due</th>
|
||||||
<th>Last Run</th><th>Status</th><th width="150"></th>
|
<th>Ends</th><th>Last Run</th><th>Status</th><th width="190"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for s in schedules %}
|
{% for s in schedules %}
|
||||||
<tr class="{{ 'text-muted' if not s.active else '' }}">
|
<tr class="{{ 'text-muted' if not s.active else '' }}">
|
||||||
<td><strong>{{ s.name }}</strong></td>
|
<td>
|
||||||
|
<strong>{{ s.name }}</strong>
|
||||||
|
{% if s.is_follow_up %}
|
||||||
|
{# phase48 — a schedule planned as the deferred twin of
|
||||||
|
"Re-inspect Now". Starting it produces a linked re-inspection. #}
|
||||||
|
<a href="{{ url_for('inspections.view', inspection_id=s.parent_inspection_id) }}"
|
||||||
|
class="badge bg-warning text-dark text-decoration-none ms-1"
|
||||||
|
title="Follow-up of inspection #{{ s.parent_inspection_id }}">
|
||||||
|
<i class="bi bi-arrow-repeat"></i> Follow-up #{{ s.parent_inspection_id }}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
<td>{{ s.template.name if s.template else '—' }}</td>
|
<td>{{ s.template.name if s.template else '—' }}</td>
|
||||||
<td>
|
<td>
|
||||||
{{ s.facility.name if s.facility else '—' }}
|
{{ s.facility.name if s.facility else '—' }}
|
||||||
{% if s.area %}<span class="text-muted small">/ {{ s.area.name }}</span>{% endif %}
|
{% if s.area %}<span class="text-muted small">/ {{ s.area.name }}</span>{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>{{ s.inspector.display_name if s.inspector else '—' }}</td>
|
<td>{{ s.inspector.display_name if s.inspector else '—' }}</td>
|
||||||
<td><span class="badge bg-secondary">{{ s.frequency|title }}</span></td>
|
<td><span class="badge bg-secondary">{{ s.recurrence_label }}</span></td>
|
||||||
|
<td>
|
||||||
|
{% if s.mode == 'plan' %}
|
||||||
|
<span class="badge bg-info text-dark" title="Inspector presses Start">Plan</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-light text-dark border" title="Cron creates the inspection">Auto</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
<td class="small {{ 'text-danger fw-semibold' if s.active and s.next_run_at and s.next_run_at <= now else 'text-muted' }}">
|
<td class="small {{ 'text-danger fw-semibold' if s.active and s.next_run_at and s.next_run_at <= now else 'text-muted' }}">
|
||||||
{{ s.next_run_at.strftime('%Y-%m-%d %H:%M') if s.next_run_at else '—' }}
|
{{ s.next_run_at.strftime('%Y-%m-%d %H:%M') if s.next_run_at else '—' }}
|
||||||
|
{% if s.is_overdue(today) %}
|
||||||
|
<span class="badge bg-danger ms-1">Overdue</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="small text-muted">
|
||||||
|
{% if s.frequency == 'once' %}—
|
||||||
|
{% elif s.end_date %}{{ s.end_date.strftime('%Y-%m-%d') }}
|
||||||
|
{% else %}No end{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="small text-muted">
|
<td class="small text-muted">
|
||||||
{{ s.last_run_at.strftime('%Y-%m-%d %H:%M') if s.last_run_at else 'Never' }}
|
{{ s.last_run_at.strftime('%Y-%m-%d %H:%M') if s.last_run_at else 'Never' }}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
|
{# "Ended" separates a schedule that reached its end date from one a
|
||||||
|
manager switched off — both are inactive, for different reasons. #}
|
||||||
{% if s.active %}<span class="badge bg-success">Active</span>
|
{% if s.active %}<span class="badge bg-success">Active</span>
|
||||||
|
{% elif s.is_expired %}<span class="badge bg-dark">Ended</span>
|
||||||
{% else %}<span class="badge bg-secondary">Paused</span>{% endif %}
|
{% else %}<span class="badge bg-secondary">Paused</span>{% endif %}
|
||||||
|
{# phase50 — receipt acknowledgement. Only meaningful for plan
|
||||||
|
mode: an auto schedule materialises itself, so there is no
|
||||||
|
request for anyone to receive. #}
|
||||||
|
{% if s.mode == 'plan' and s.inspector_id %}
|
||||||
|
{% if s.is_acknowledged %}
|
||||||
|
<span class="badge bg-light text-success border border-success ms-1"
|
||||||
|
title="Inspector confirmed receipt on {{ s.acknowledged_at.strftime('%b %d, %Y %I:%M %p') }}">
|
||||||
|
<i class="bi bi-check-circle"></i> Confirmed
|
||||||
|
</span>
|
||||||
|
{% elif s.active %}
|
||||||
|
<span class="badge bg-light text-warning border border-warning ms-1"
|
||||||
|
title="The assigned inspector has not confirmed receipt yet">
|
||||||
|
<i class="bi bi-hourglass-split"></i> Awaiting confirmation
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-end">
|
<td class="text-end">
|
||||||
|
{# Assignee-only, like Start: a manager must not be able to confirm
|
||||||
|
on someone's behalf, since the record means "this person saw it". #}
|
||||||
|
{% if s.active and s.mode == 'plan' and not s.is_acknowledged
|
||||||
|
and s.inspector_id == current_user.id %}
|
||||||
|
<form method="POST" class="d-inline"
|
||||||
|
action="{{ url_for('inspection_schedules.acknowledge', schedule_id=s.id) }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-success"
|
||||||
|
title="Confirm you have received this request">
|
||||||
|
<i class="bi bi-check-lg"></i> Confirm
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{# Start is for whoever must DO the work. A Customer Director can
|
||||||
|
plan a schedule but never execute one — the route 403s them, so
|
||||||
|
the button must not be offered either. #}
|
||||||
|
{% if s.active and s.mode == 'plan'
|
||||||
|
and current_user.role != 'customer'
|
||||||
|
and (not current_user.is_inspector or s.inspector_id == current_user.id) %}
|
||||||
|
<a href="{{ url_for('inspection_schedules.start', schedule_id=s.id) }}"
|
||||||
|
class="btn btn-sm btn-primary" title="Start this inspection now">
|
||||||
|
<i class="bi bi-play-fill"></i> Start
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if can_manage_schedules %}
|
||||||
<a href="{{ url_for('inspection_schedules.edit', schedule_id=s.id) }}"
|
<a href="{{ url_for('inspection_schedules.edit', schedule_id=s.id) }}"
|
||||||
class="btn btn-sm btn-outline-secondary" title="Edit">
|
class="btn btn-sm btn-outline-secondary" title="Edit">
|
||||||
<i class="bi bi-pencil"></i>
|
<i class="bi bi-pencil"></i>
|
||||||
@@ -70,6 +182,7 @@
|
|||||||
<i class="bi bi-trash3"></i>
|
<i class="bi bi-trash3"></i>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -79,13 +192,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="card shadow-sm">
|
<div class="card shadow-sm border-top-0 rounded-top-0">
|
||||||
<div class="card-body text-center py-5 text-muted">
|
<div class="card-body text-center py-5 text-muted">
|
||||||
|
{% if tab == 'completed' %}
|
||||||
|
<i class="bi bi-check2-circle fs-1 d-block mb-3 opacity-25"></i>
|
||||||
|
<p class="mb-0">No completed schedules yet.</p>
|
||||||
|
{% elif completed_count %}
|
||||||
|
{# Nothing pending but there IS history — offer the other tab rather than
|
||||||
|
inviting them to create a duplicate of something already closed. #}
|
||||||
|
<i class="bi bi-calendar-check fs-1 d-block mb-3 opacity-25"></i>
|
||||||
|
<p class="mb-3">Nothing pending — all schedules are complete.</p>
|
||||||
|
<a href="{{ url_for('inspection_schedules.index', tab='completed') }}"
|
||||||
|
class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-check2-circle"></i> View Completed ({{ completed_count }})
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
<i class="bi bi-calendar-x fs-1 d-block mb-3 opacity-25"></i>
|
<i class="bi bi-calendar-x fs-1 d-block mb-3 opacity-25"></i>
|
||||||
<p class="mb-3">No inspection schedules configured yet.</p>
|
<p class="mb-3">No inspection schedules configured yet.</p>
|
||||||
|
{% if can_manage_schedules %}
|
||||||
<a href="{{ url_for('inspection_schedules.create') }}" class="btn btn-primary">
|
<a href="{{ url_for('inspection_schedules.create') }}" class="btn btn-primary">
|
||||||
<i class="bi bi-plus-circle"></i> Create First Schedule
|
<i class="bi bi-plus-circle"></i> Create First Schedule
|
||||||
</a>
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -439,7 +439,7 @@
|
|||||||
</label>
|
</label>
|
||||||
{# Thumbnail shown after AJAX upload or when a saved path exists #}
|
{# Thumbnail shown after AJAX upload or when a saved path exists #}
|
||||||
{% if saved %}
|
{% if saved %}
|
||||||
<img src="{{ url_for('static', filename=saved) }}"
|
<img src="{{ media_url(saved) }}"
|
||||||
id="thumb_{{ fid }}"
|
id="thumb_{{ fid }}"
|
||||||
alt="Photo"
|
alt="Photo"
|
||||||
style="max-height:60px;max-width:100%;border-radius:4px;margin-top:.3rem;object-fit:cover;">
|
style="max-height:60px;max-width:100%;border-radius:4px;margin-top:.3rem;object-fit:cover;">
|
||||||
@@ -564,7 +564,7 @@
|
|||||||
<option value="0">— Unassigned —</option>
|
<option value="0">— Unassigned —</option>
|
||||||
{% set staff = staff_for_flag_issue %}
|
{% set staff = staff_for_flag_issue %}
|
||||||
{% if staff %}{% for u in staff %}
|
{% if staff %}{% for u in staff %}
|
||||||
<option value="{{ u.id }}">{{ u.display_name }}</option>
|
<option value="{{ u.id }}">{{ u.display_name }}{{ ' (Customer)' if u.is_external_inspector }}</option>
|
||||||
{% endfor %}{% endif %}
|
{% endfor %}{% endif %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,12 +3,19 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
<h2><i class="bi bi-clipboard-data"></i> Inspections</h2>
|
<h2><i class="bi bi-clipboard-data"></i> Inspections</h2>
|
||||||
|
{# Customer Directors schedule inspections for their own facilities, so the
|
||||||
|
Scheduled link is theirs too — but starting an ad-hoc inspection is not. #}
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a href="{{ url_for('inspection_schedules.index') }}" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-calendar-check"></i> Scheduled
|
||||||
|
</a>
|
||||||
{% if current_user.role != 'customer' %}
|
{% if current_user.role != 'customer' %}
|
||||||
<a href="{{ url_for('inspections.start') }}" class="btn btn-primary">
|
<a href="{{ url_for('inspections.start') }}" class="btn btn-primary">
|
||||||
<i class="bi bi-plus-circle"></i> New Inspection
|
<i class="bi bi-plus-circle"></i> New Inspection
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{# Filters #}
|
{# Filters #}
|
||||||
<div class="card shadow-sm mb-4">
|
<div class="card shadow-sm mb-4">
|
||||||
@@ -101,10 +108,15 @@
|
|||||||
<div class="card shadow-sm">
|
<div class="card shadow-sm">
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
{% if inspections.items %}
|
{% if inspections.items %}
|
||||||
|
{% include 'partials/bulk_inspections_toolbar.html' %}
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-hover mb-0">
|
<table class="table table-hover mb-0">
|
||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
<tr>
|
<tr>
|
||||||
|
<th style="width:34px;">
|
||||||
|
<input type="checkbox" class="form-check-input bulk-check-all"
|
||||||
|
title="Select all on this page" aria-label="Select all">
|
||||||
|
</th>
|
||||||
<th>#</th><th>Date</th><th>Contract</th><th>Facility</th><th>Area</th>
|
<th>#</th><th>Date</th><th>Contract</th><th>Facility</th><th>Area</th>
|
||||||
<th>Template</th><th>Inspector</th><th>Score</th>
|
<th>Template</th><th>Inspector</th><th>Score</th>
|
||||||
<th>Status</th><th></th>
|
<th>Status</th><th></th>
|
||||||
@@ -113,6 +125,11 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% for ins in inspections.items %}
|
{% for ins in inspections.items %}
|
||||||
<tr>
|
<tr>
|
||||||
|
<td>
|
||||||
|
<input type="checkbox" class="form-check-input bulk-check"
|
||||||
|
form="inspectionsBulkForm" name="inspection_ids" value="{{ ins.id }}"
|
||||||
|
aria-label="Select inspection #{{ ins.id }}">
|
||||||
|
</td>
|
||||||
<td><small class="text-muted">#{{ ins.id }}</small></td>
|
<td><small class="text-muted">#{{ ins.id }}</small></td>
|
||||||
<td>{{ ins.inspection_date.strftime('%Y-%m-%d %H:%M') }}</td>
|
<td>{{ ins.inspection_date.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||||
<td><small>{{ ins.facility.project.name if ins.facility and ins.facility.project else '—' }}</small></td>
|
<td><small>{{ ins.facility.project.name if ins.facility and ins.facility.project else '—' }}</small></td>
|
||||||
@@ -147,9 +164,9 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="text-nowrap">
|
<td class="text-nowrap">
|
||||||
{% if ins.status == 'in_progress' or ins.status == 'flagged' %}
|
{% if ins.status == 'in_progress' or ins.status == 'flagged' %}
|
||||||
<a href="{{ url_for('inspections.execute', inspection_id=ins.id) }}" class="btn btn-sm btn-outline-primary insp-list-link">Continue</a>
|
<a href="{{ url_for('inspections.execute', inspection_id=ins.id, next=current_url()) }}" class="btn btn-sm btn-outline-primary insp-list-link">Continue</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="{{ url_for('inspections.view', inspection_id=ins.id) }}" class="btn btn-sm btn-outline-secondary insp-list-link">View</a>
|
<a href="{{ url_for('inspections.view', inspection_id=ins.id, next=current_url()) }}" class="btn btn-sm btn-outline-secondary insp-list-link">View</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if current_user.role in ['admin', 'director'] %}
|
{% if current_user.role in ['admin', 'director'] %}
|
||||||
<button type="button"
|
<button type="button"
|
||||||
@@ -211,6 +228,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<form id="deleteInspectionForm" method="POST" action="" class="d-inline">
|
<form id="deleteInspectionForm" method="POST" action="" class="d-inline">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<input type="hidden" name="next" value="{{ current_url() }}">
|
||||||
<button type="submit" class="btn btn-danger">
|
<button type="submit" class="btn btn-danger">
|
||||||
<i class="bi bi-trash3-fill"></i> Delete Permanently
|
<i class="bi bi-trash3-fill"></i> Delete Permanently
|
||||||
</button>
|
</button>
|
||||||
@@ -223,6 +241,7 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
|
{% include 'partials/bulk_select_js.html' %}
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|||||||
@@ -14,8 +14,14 @@
|
|||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
{{ form.template_id.label(class="form-label fw-semibold") }}
|
{{ form.template_id.label(class="form-label fw-semibold") }}
|
||||||
{{ form.template_id(class="form-select" + (" is-invalid" if form.template_id.errors else "")) }}
|
{{ form.template_id(class="form-select" + (" is-invalid" if form.template_id.errors else ""), id="templateSelect") }}
|
||||||
{% for e in form.template_id.errors %}<div class="invalid-feedback">{{ e }}</div>{% endfor %}
|
{% for e in form.template_id.errors %}<div class="invalid-feedback">{{ e }}</div>{% endfor %}
|
||||||
|
{# phase52 — the list shows shared forms plus the ones attached to
|
||||||
|
the selected contract, refreshed by JS when the contract changes. #}
|
||||||
|
<div class="form-text">Shows forms available on the selected contract.</div>
|
||||||
|
<div id="templateEmpty" class="form-text text-danger d-none">
|
||||||
|
No forms are available on this contract yet.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@@ -55,6 +61,8 @@
|
|||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
const projectSel = document.getElementById('projectSelect');
|
const projectSel = document.getElementById('projectSelect');
|
||||||
|
const templateSel = document.getElementById('templateSelect');
|
||||||
|
const templateEmpty = document.getElementById('templateEmpty');
|
||||||
const facilitySel = document.getElementById('facilitySelect');
|
const facilitySel = document.getElementById('facilitySelect');
|
||||||
const spinner = document.getElementById('facilitySpinner');
|
const spinner = document.getElementById('facilitySpinner');
|
||||||
const emptyMsg = document.getElementById('facilityEmpty');
|
const emptyMsg = document.getElementById('facilityEmpty');
|
||||||
@@ -62,6 +70,7 @@
|
|||||||
const areaSel = document.getElementById('areaSelect');
|
const areaSel = document.getElementById('areaSelect');
|
||||||
|
|
||||||
const FACILITIES_URL = `{{ url_for('inspections.facilities_for_project', project_id=0) }}`.replace('/0', '/');
|
const FACILITIES_URL = `{{ url_for('inspections.facilities_for_project', project_id=0) }}`.replace('/0', '/');
|
||||||
|
const TEMPLATES_URL = `{{ url_for('inspections.templates_for_project', project_id=0) }}`.replace('/0', '/');
|
||||||
const AREAS_URL = `{{ url_for('inspections.areas_for_facility', facility_id=0) }}`.replace('/0', '/');
|
const AREAS_URL = `{{ url_for('inspections.areas_for_facility', facility_id=0) }}`.replace('/0', '/');
|
||||||
|
|
||||||
function loadAreas(facilityId, selectedAreaId) {
|
function loadAreas(facilityId, selectedAreaId) {
|
||||||
@@ -93,6 +102,28 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Forms are per-contract (phase52): a customer's bespoke form must not be
|
||||||
|
// offered on another customer's facilities. Keeps the currently selected
|
||||||
|
// form if it is still valid on the new contract.
|
||||||
|
function loadTemplates(projectId) {
|
||||||
|
if (!projectId || !templateSel) return;
|
||||||
|
const keep = templateSel.value;
|
||||||
|
fetch(TEMPLATES_URL + projectId)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
templateSel.innerHTML = '';
|
||||||
|
data.forEach(t => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = t.id;
|
||||||
|
opt.textContent = t.name;
|
||||||
|
if (String(t.id) === keep) opt.selected = true;
|
||||||
|
templateSel.appendChild(opt);
|
||||||
|
});
|
||||||
|
templateEmpty.classList.toggle('d-none', data.length > 0);
|
||||||
|
})
|
||||||
|
.catch(() => {}); // leave the server-rendered list in place
|
||||||
|
}
|
||||||
|
|
||||||
function loadFacilities(projectId, selectedFacilityId, selectedAreaId) {
|
function loadFacilities(projectId, selectedFacilityId, selectedAreaId) {
|
||||||
if (!projectId) return;
|
if (!projectId) return;
|
||||||
spinner.classList.remove('d-none');
|
spinner.classList.remove('d-none');
|
||||||
@@ -129,6 +160,7 @@
|
|||||||
|
|
||||||
projectSel.addEventListener('change', function () {
|
projectSel.addEventListener('change', function () {
|
||||||
loadFacilities(this.value, null, null);
|
loadFacilities(this.value, null, null);
|
||||||
|
loadTemplates(this.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
facilitySel.addEventListener('change', function () {
|
facilitySel.addEventListener('change', function () {
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Inspection #{{ inspection.id }} — Results{% endblock %}
|
{% block title %}Inspection #{{ inspection.id }} — Results{% endblock %}
|
||||||
|
|
||||||
|
{# The filtered list URL this page was opened from. Defined at TOP LEVEL, not
|
||||||
|
inside the content block: Jinja blocks do not share scope, and the follow-up
|
||||||
|
modal below sits in the extra_js block — from there a content-scoped
|
||||||
|
back_url is Undefined and posts next="", silently dropping the very filters
|
||||||
|
this mechanism exists to preserve. #}
|
||||||
|
{% set back_url = request.args.get('next') or url_for('inspections.index') %}
|
||||||
|
|
||||||
{% block extra_css %}
|
{% block extra_css %}
|
||||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
@@ -338,7 +345,10 @@
|
|||||||
|
|
||||||
{# Action bar #}
|
{# Action bar #}
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<a id="backToInspectionsBtn" href="{{ url_for('inspections.index') }}" class="btn btn-sm btn-outline-secondary">
|
{# `next` carries the filtered list URL from the list page; the
|
||||||
|
sessionStorage fallback below still covers links opened before
|
||||||
|
this page started sending one. #}
|
||||||
|
<a id="backToInspectionsBtn" href="{{ back_url }}" class="btn btn-sm btn-outline-secondary">
|
||||||
<i class="bi bi-arrow-left"></i> Back to Inspections
|
<i class="bi bi-arrow-left"></i> Back to Inspections
|
||||||
</a>
|
</a>
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
@@ -349,13 +359,28 @@
|
|||||||
<button onclick="window.print()" class="btn btn-sm btn-outline-secondary">
|
<button onclick="window.print()" class="btn btn-sm btn-outline-secondary">
|
||||||
<i class="bi bi-printer"></i> Print
|
<i class="bi bi-printer"></i> Print
|
||||||
</button>
|
</button>
|
||||||
{% if current_user.role not in ['customer'] %}
|
{# Offered to managers, to the inspector who did this inspection, and to
|
||||||
|
whoever the follow-up was assigned to (phase56). Not to any other
|
||||||
|
inspector who can merely SEE it: reinspect() refuses them, and showing
|
||||||
|
a button that fails on click is the mismatch this page just fixed. #}
|
||||||
|
{% if current_user.role not in ['customer']
|
||||||
|
and (is_own_inspection or owns_follow_up) %}
|
||||||
<a href="{{ url_for('inspections.reinspect', inspection_id=inspection.id) }}"
|
<a href="{{ url_for('inspections.reinspect', inspection_id=inspection.id) }}"
|
||||||
class="btn btn-sm btn-outline-primary"
|
class="btn btn-sm btn-outline-primary"
|
||||||
title="Start a follow-up re-inspection with the same template and facility">
|
title="Start a follow-up re-inspection with the same template and facility">
|
||||||
<i class="bi bi-arrow-repeat"></i> Re-inspect
|
<i class="bi bi-arrow-repeat"></i> Re-inspect
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{# phase49 — customers may REQUEST a follow-up on their own completed
|
||||||
|
inspections; only admin/director can clear one. #}
|
||||||
|
{% if current_user.role == 'customer' and inspection.status == 'completed'
|
||||||
|
and not inspection.follow_up_required %}
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-warning"
|
||||||
|
data-bs-toggle="modal" data-bs-target="#followupModal"
|
||||||
|
title="Ask the team to re-inspect this facility">
|
||||||
|
<i class="bi bi-flag"></i> Request Follow-up
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
{% if current_user.role in ['admin','director'] %}
|
{% if current_user.role in ['admin','director'] %}
|
||||||
{% if not inspection.follow_up_required %}
|
{% if not inspection.follow_up_required %}
|
||||||
<button type="button" class="btn btn-sm btn-outline-warning"
|
<button type="button" class="btn btn-sm btn-outline-warning"
|
||||||
@@ -368,6 +393,7 @@
|
|||||||
action="{{ url_for('inspections.clear_followup', inspection_id=inspection.id) }}"
|
action="{{ url_for('inspections.clear_followup', inspection_id=inspection.id) }}"
|
||||||
class="d-inline">
|
class="d-inline">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<input type="hidden" name="next" value="{{ back_url }}">
|
||||||
<button class="btn btn-sm btn-warning">
|
<button class="btn btn-sm btn-warning">
|
||||||
<i class="bi bi-flag-fill"></i> Clear Follow-up
|
<i class="bi bi-flag-fill"></i> Clear Follow-up
|
||||||
</button>
|
</button>
|
||||||
@@ -376,6 +402,7 @@
|
|||||||
<form method="post" action="{{ url_for('inspections.delete', inspection_id=inspection.id) }}"
|
<form method="post" action="{{ url_for('inspections.delete', inspection_id=inspection.id) }}"
|
||||||
onsubmit="return confirm('Delete this inspection permanently?')">
|
onsubmit="return confirm('Delete this inspection permanently?')">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<input type="hidden" name="next" value="{{ back_url }}">
|
||||||
<button class="btn btn-sm btn-outline-danger"><i class="bi bi-trash3"></i> Delete</button>
|
<button class="btn btn-sm btn-outline-danger"><i class="bi bi-trash3"></i> Delete</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -388,13 +415,43 @@
|
|||||||
<i class="bi bi-flag-fill mt-1"></i>
|
<i class="bi bi-flag-fill mt-1"></i>
|
||||||
<div>
|
<div>
|
||||||
<strong>Follow-up Inspection Required</strong>
|
<strong>Follow-up Inspection Required</strong>
|
||||||
|
{# phase49 — who asked, and whether it was the client or our own staff. #}
|
||||||
|
{% if inspection.follow_up_requester %}
|
||||||
|
<span class="badge {{ 'bg-info text-dark' if inspection.follow_up_requester.role == 'customer' else 'bg-secondary' }} ms-1">
|
||||||
|
{{ 'Requested by customer' if inspection.follow_up_requester.role == 'customer' else 'Requested by staff' }}:
|
||||||
|
{{ inspection.follow_up_requester.display_name }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if inspection.follow_up_requested_at %}
|
||||||
|
<span class="small text-muted ms-1">{{ inspection.follow_up_requested_at.strftime('%b %d, %Y %I:%M %p') }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{# Who is expected to DO it — the assignee when one was named, otherwise
|
||||||
|
the original inspector (Inspection.follow_up_owner). #}
|
||||||
|
{% if inspection.follow_up_owner %}
|
||||||
|
<div class="small mt-1">
|
||||||
|
<i class="bi bi-person-check me-1"></i>Assigned to
|
||||||
|
<strong>{{ inspection.follow_up_owner.display_name }}</strong>
|
||||||
|
{% if not inspection.follow_up_assignee %}
|
||||||
|
<span class="text-muted">(original inspector)</span>
|
||||||
|
{% elif inspection.follow_up_owner.id == current_user.id %}
|
||||||
|
<span class="badge bg-warning text-dark ms-1">You</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% if inspection.follow_up_note %}<br><span class="small">{{ inspection.follow_up_note }}</span>{% endif %}
|
{% if inspection.follow_up_note %}<br><span class="small">{{ inspection.follow_up_note }}</span>{% endif %}
|
||||||
|
{# Re-inspection is staff work — reinspect() already refuses customers —
|
||||||
|
and among inspectors it belongs to the follow-up's OWNER. #}
|
||||||
|
{% if current_user.role != 'customer'
|
||||||
|
and (is_own_inspection or owns_follow_up) %}
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<a href="{{ url_for('inspections.reinspect', inspection_id=inspection.id) }}"
|
<a href="{{ url_for('inspections.reinspect', inspection_id=inspection.id) }}"
|
||||||
class="btn btn-sm btn-warning">
|
class="btn btn-sm btn-warning">
|
||||||
<i class="bi bi-arrow-repeat me-1"></i>Start Re-inspection
|
<i class="bi bi-arrow-repeat me-1"></i>Start Re-inspection
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="small mt-1">The team has been notified and will schedule the re-inspection.</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -475,6 +532,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex align-items-center gap-2">
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
{# Parity with ST phase43: show that this run came from a schedule. Uses
|
||||||
|
MT's own column/relationship names (inspection_schedule_id). #}
|
||||||
|
{% if inspection.inspection_schedule_id %}
|
||||||
|
<span class="badge bg-info text-dark fs-6" title="Created from a scheduled inspection">
|
||||||
|
<i class="bi bi-calendar-check"></i> Scheduled{% if inspection.inspection_schedule %} · {{ inspection.inspection_schedule.recurrence_label }}{% endif %}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
<span class="badge bg-{{ 'success' if inspection.status == 'completed' else 'danger' if inspection.status == 'flagged' else 'secondary' }} fs-6">
|
<span class="badge bg-{{ 'success' if inspection.status == 'completed' else 'danger' if inspection.status == 'flagged' else 'secondary' }} fs-6">
|
||||||
{{ inspection.status|replace('_',' ')|title }}
|
{{ inspection.status|replace('_',' ')|title }}
|
||||||
</span>
|
</span>
|
||||||
@@ -508,6 +572,12 @@
|
|||||||
<span class="lbl">Frequency</span>
|
<span class="lbl">Frequency</span>
|
||||||
<span class="val">{{ inspection.template.frequency|title }}</span>
|
<span class="val">{{ inspection.template.frequency|title }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{% if inspection.inspection_schedule and inspection.inspection_schedule.creator %}
|
||||||
|
<div class="meta-item">
|
||||||
|
<span class="lbl">Scheduled By</span>
|
||||||
|
<span class="val">{{ inspection.inspection_schedule.creator.display_name }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# ── Submission GPS (admin / director only) ──────────────────────────── #}
|
{# ── Submission GPS (admin / director only) ──────────────────────────── #}
|
||||||
@@ -688,7 +758,7 @@
|
|||||||
<div style="display:flex;align-items:center;flex:1;min-height:0;">
|
<div style="display:flex;align-items:center;flex:1;min-height:0;">
|
||||||
{% if val %}
|
{% if val %}
|
||||||
<button type="button" class="btn-view-media"
|
<button type="button" class="btn-view-media"
|
||||||
onclick="openMedia('{{ url_for('static', filename=val) }}','{{ field.label | e }}')">
|
onclick="openMedia('{{ media_url(val) }}','{{ field.label | e }}')">
|
||||||
<i class="bi bi-image"></i> View Photo
|
<i class="bi bi-image"></i> View Photo
|
||||||
</button>
|
</button>
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -854,7 +924,11 @@
|
|||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
var backUrl = sessionStorage.getItem('insp_list_back_url');
|
// A server-provided `next` is authoritative — it reflects the list this
|
||||||
|
// page was actually opened from. Only fall back to sessionStorage when
|
||||||
|
// there is none (e.g. a link created before `next` was threaded in).
|
||||||
|
var hasNext = {{ 'true' if request.args.get('next') else 'false' }};
|
||||||
|
var backUrl = hasNext ? null : sessionStorage.getItem('insp_list_back_url');
|
||||||
if (backUrl) {
|
if (backUrl) {
|
||||||
var btn = document.getElementById('backToInspectionsBtn');
|
var btn = document.getElementById('backToInspectionsBtn');
|
||||||
if (btn) btn.href = backUrl;
|
if (btn) btn.href = backUrl;
|
||||||
@@ -880,20 +954,65 @@ document.addEventListener('keydown', e => { if (e.key === 'Escape') closeMedia()
|
|||||||
<div class="modal-dialog">
|
<div class="modal-dialog">
|
||||||
<form method="POST" action="{{ url_for('inspections.flag_followup', inspection_id=inspection.id) }}">
|
<form method="POST" action="{{ url_for('inspections.flag_followup', inspection_id=inspection.id) }}">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<input type="hidden" name="next" value="{{ back_url }}">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
|
{% set is_cust = current_user.role == 'customer' %}
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h5 class="modal-title"><i class="bi bi-flag me-2"></i>Flag Follow-up Required</h5>
|
<h5 class="modal-title">
|
||||||
|
<i class="bi bi-flag me-2"></i>{{ 'Request a Follow-up Inspection' if is_cust else 'Flag Follow-up Required' }}
|
||||||
|
</h5>
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<label class="form-label fw-semibold">Reason / Notes <span class="text-muted small">(optional)</span></label>
|
{% if is_cust %}
|
||||||
|
<p class="small text-muted">
|
||||||
|
Ask the team to re-inspect this facility. Your request is sent to the
|
||||||
|
inspector and management right away.
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
<label class="form-label fw-semibold">
|
||||||
|
{{ 'What still needs attention?' if is_cust else 'Reason / Notes' }}
|
||||||
|
<span class="text-muted small">(optional)</span>
|
||||||
|
</label>
|
||||||
<textarea name="follow_up_note" class="form-control" rows="3"
|
<textarea name="follow_up_note" class="form-control" rows="3"
|
||||||
placeholder="Describe what needs to be addressed in the follow-up inspection…"></textarea>
|
placeholder="Describe what needs to be addressed in the follow-up inspection…"></textarea>
|
||||||
|
|
||||||
|
{# ── Assign it (phase56) ────────────────────────────────────────
|
||||||
|
Optional. Left blank, the follow-up stays with whoever performed
|
||||||
|
the original inspection — the behaviour before this existed. The
|
||||||
|
list is contract-scoped in _followup_assignees_for(), so a
|
||||||
|
Customer Director only ever sees inspectors on their own
|
||||||
|
contracts. #}
|
||||||
|
{% if followup_assignees %}
|
||||||
|
<div class="mt-3">
|
||||||
|
<label class="form-label fw-semibold">
|
||||||
|
Assign to
|
||||||
|
<span class="text-muted small">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<select name="follow_up_assigned_to" class="form-select">
|
||||||
|
<option value="">
|
||||||
|
— {{ inspection.inspector.display_name }} (original inspector) —
|
||||||
|
</option>
|
||||||
|
{% for u in followup_assignees %}
|
||||||
|
{% if u.id != inspection.inspector_id %}
|
||||||
|
<option value="{{ u.id }}">
|
||||||
|
{{ u.display_name }}{{ ' (Customer)' if u.is_external_inspector }}
|
||||||
|
</option>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<div class="form-text">
|
||||||
|
Choose someone else to carry out the re-inspection. They are
|
||||||
|
notified and it appears in their list on the web and the iPad;
|
||||||
|
the original inspector is not asked to do it.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
<button type="submit" class="btn btn-warning">
|
<button type="submit" class="btn btn-warning">
|
||||||
<i class="bi bi-flag me-1"></i>Flag Follow-up
|
<i class="bi bi-flag me-1"></i>{{ 'Send Request' if is_cust else 'Flag Follow-up' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -46,6 +46,93 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{# ── Handled By (phase44) — staff only; customers stay internal ── #}
|
||||||
|
{% if current_user.role != 'customer' %}
|
||||||
|
<hr class="my-3">
|
||||||
|
<p class="fw-semibold mb-2">
|
||||||
|
<i class="bi bi-person-check me-1 text-secondary"></i>Handled By
|
||||||
|
</p>
|
||||||
|
<div class="mb-3">
|
||||||
|
{{ form.handler_type(class="form-select", id="handlerTypeSelect") }}
|
||||||
|
<div class="form-text" id="handlerTypeHelp"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="internalHandlerFields">
|
||||||
|
<div class="mb-2">
|
||||||
|
{{ form.internal_handler_name.label(class="form-label small fw-semibold mb-1") }}
|
||||||
|
{{ form.internal_handler_name(class="form-control form-control-sm",
|
||||||
|
placeholder="Crew member handling this") }}
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
{{ form.internal_handler_contact.label(class="form-label small fw-semibold mb-1") }}
|
||||||
|
{{ form.internal_handler_contact(class="form-control form-control-sm",
|
||||||
|
placeholder="Phone or email") }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="facilityHandlerFields" style="display:none;">
|
||||||
|
<div class="mb-2">
|
||||||
|
{{ form.facility_handler_name.label(class="form-label small fw-semibold mb-1") }}
|
||||||
|
{{ form.facility_handler_name(class="form-control form-control-sm",
|
||||||
|
placeholder="Contact name at the facility") }}
|
||||||
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
{{ form.facility_handler_contact.label(class="form-label small fw-semibold mb-1") }}
|
||||||
|
{{ form.facility_handler_contact(class="form-control form-control-sm",
|
||||||
|
placeholder="Phone or email") }}
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
{{ form.facility_handler_notes.label(class="form-label small fw-semibold mb-1") }}
|
||||||
|
{{ form.facility_handler_notes(class="form-control form-control-sm", rows=2,
|
||||||
|
placeholder="Notes about what they are handling…") }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="vendorHandlerFields" style="display:none;">
|
||||||
|
<div class="mb-2">
|
||||||
|
{{ form.vendor_name.label(class="form-label small fw-semibold mb-1") }}
|
||||||
|
{{ form.vendor_name(class="form-control form-control-sm",
|
||||||
|
placeholder="Contractor or vendor name") }}
|
||||||
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
{{ form.vendor_contact.label(class="form-label small fw-semibold mb-1") }}
|
||||||
|
{{ form.vendor_contact(class="form-control form-control-sm",
|
||||||
|
placeholder="Phone or email") }}
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
{{ form.vendor_notes.label(class="form-label small fw-semibold mb-1") }}
|
||||||
|
{{ form.vendor_notes(class="form-control form-control-sm", rows=2,
|
||||||
|
placeholder="Scope, quote reference, etc.") }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
var sel = document.getElementById('handlerTypeSelect');
|
||||||
|
var help = document.getElementById('handlerTypeHelp');
|
||||||
|
var boxes = {
|
||||||
|
internal: document.getElementById('internalHandlerFields'),
|
||||||
|
facility: document.getElementById('facilityHandlerFields'),
|
||||||
|
vendor: document.getElementById('vendorHandlerFields')
|
||||||
|
};
|
||||||
|
var notes = {
|
||||||
|
internal: {{ (issue_handler_descriptions or {}).get('internal', '')|tojson }},
|
||||||
|
facility: {{ (issue_handler_descriptions or {}).get('facility', '')|tojson }},
|
||||||
|
vendor: {{ (issue_handler_descriptions or {}).get('vendor', '')|tojson }}
|
||||||
|
};
|
||||||
|
function sync(){
|
||||||
|
var v = sel ? sel.value : 'internal';
|
||||||
|
for (var k in boxes){
|
||||||
|
if (boxes[k]) { boxes[k].style.display = (k === v) ? '' : 'none'; }
|
||||||
|
}
|
||||||
|
if (help) { help.textContent = notes[v] || ''; }
|
||||||
|
}
|
||||||
|
if (sel){ sel.addEventListener('change', sync); }
|
||||||
|
sync();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
<button type="submit" class="btn btn-danger">Log Issue</button>
|
<button type="submit" class="btn btn-danger">Log Issue</button>
|
||||||
<a href="{{ url_for('issues.index') }}" class="btn btn-outline-secondary">Cancel</a>
|
<a href="{{ url_for('issues.index') }}" class="btn btn-outline-secondary">Cancel</a>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
<h2><i class="bi bi-exclamation-triangle"></i> Issues</h2>
|
<h2><i class="bi bi-exclamation-triangle"></i> Issues</h2>
|
||||||
{% if current_user.role in ['admin','director','customer'] %}
|
{% if current_user.role in ['admin','director','customer','auditor'] %}
|
||||||
<a href="{{ url_for('issues.create') }}" class="btn btn-danger">
|
<a href="{{ url_for('issues.create') }}" class="btn btn-danger">
|
||||||
<i class="bi bi-plus-circle"></i> Log Issue
|
<i class="bi bi-plus-circle"></i> Log Issue
|
||||||
</a>
|
</a>
|
||||||
@@ -115,10 +115,15 @@
|
|||||||
<div class="card shadow-sm">
|
<div class="card shadow-sm">
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
{% if issues.items %}
|
{% if issues.items %}
|
||||||
|
{% include 'partials/bulk_issues_toolbar.html' %}
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-hover mb-0">
|
<table class="table table-hover mb-0">
|
||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
<tr>
|
<tr>
|
||||||
|
<th style="width:34px;">
|
||||||
|
<input type="checkbox" class="form-check-input bulk-check-all"
|
||||||
|
title="Select all on this page" aria-label="Select all">
|
||||||
|
</th>
|
||||||
<th>#</th>
|
<th>#</th>
|
||||||
<th>Reported</th>
|
<th>Reported</th>
|
||||||
<th>Severity</th>
|
<th>Severity</th>
|
||||||
@@ -137,6 +142,11 @@
|
|||||||
{% set is_following = issue.id in followed_ids %}
|
{% set is_following = issue.id in followed_ids %}
|
||||||
{% set sla = sla_status(issue) %}
|
{% set sla = sla_status(issue) %}
|
||||||
<tr class="{{ 'table-danger' if sla == 'breached' else 'table-warning' if sla == 'at_risk' else '' }}">
|
<tr class="{{ 'table-danger' if sla == 'breached' else 'table-warning' if sla == 'at_risk' else '' }}">
|
||||||
|
<td>
|
||||||
|
<input type="checkbox" class="form-check-input bulk-check"
|
||||||
|
form="issuesBulkForm" name="issue_ids" value="{{ issue.id }}"
|
||||||
|
aria-label="Select issue #{{ issue.id }}">
|
||||||
|
</td>
|
||||||
<td><small class="text-muted">#{{ issue.id }}</small></td>
|
<td><small class="text-muted">#{{ issue.id }}</small></td>
|
||||||
<td><small>{{ issue.reported_at.strftime('%Y-%m-%d %H:%M') }}</small></td>
|
<td><small>{{ issue.reported_at.strftime('%Y-%m-%d %H:%M') }}</small></td>
|
||||||
<td>
|
<td>
|
||||||
@@ -176,12 +186,12 @@
|
|||||||
{% else %}<span class="text-muted">—</span>{% endif %}
|
{% else %}<span class="text-muted">—</span>{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if current_user.role in ['admin', 'director'] and issue.status != 'resolved' %}
|
{% if current_user.role in ['admin', 'director', 'auditor'] and issue.status != 'resolved' %}
|
||||||
<div class="d-flex align-items-center gap-1 quick-assign-wrap" data-issue-id="{{ issue.id }}">
|
<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;">
|
<select class="form-select form-select-sm quick-assign-select" style="min-width:110px;font-size:.78rem;">
|
||||||
<option value="">— Unassigned —</option>
|
<option value="">— Unassigned —</option>
|
||||||
{% for u in staff %}
|
{% for u in staff %}
|
||||||
<option value="{{ u.id }}" {{ 'selected' if issue.assigned_to == u.id }}>{{ u.display_name }}</option>
|
<option value="{{ u.id }}" {{ 'selected' if issue.assigned_to == u.id }}>{{ u.display_name }}{{ ' (Customer)' if u.is_external_inspector }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<span class="quick-assign-spinner spinner-border spinner-border-sm text-secondary d-none" role="status"></span>
|
<span class="quick-assign-spinner spinner-border spinner-border-sm text-secondary d-none" role="status"></span>
|
||||||
@@ -202,7 +212,7 @@
|
|||||||
class="d-inline"
|
class="d-inline"
|
||||||
title="Unfollow this issue">
|
title="Unfollow this issue">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<input type="hidden" name="next" value="{{ url_for('issues.index', page=issues.page, issue_id=issue_id_filter, severity=severity_filter, status=status_filter, contract_id=contract_filter, facility_id=facility_filter, date_from=date_from_filter, date_to=date_to_filter, reporter_id=reporter_filter) }}">
|
<input type="hidden" name="next" value="{{ current_url() }}">
|
||||||
<button type="submit" class="btn btn-sm btn-outline-primary p-0 px-1 me-1"
|
<button type="submit" class="btn btn-sm btn-outline-primary p-0 px-1 me-1"
|
||||||
title="Unfollow">
|
title="Unfollow">
|
||||||
<i class="bi bi-bell-slash" style="font-size:.75rem;"></i>
|
<i class="bi bi-bell-slash" style="font-size:.75rem;"></i>
|
||||||
@@ -210,9 +220,9 @@
|
|||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<a href="{{ url_for('issues.view', issue_id=issue.id) }}"
|
<a href="{{ url_for('issues.view', issue_id=issue.id, next=current_url()) }}"
|
||||||
class="btn btn-sm btn-outline-secondary">
|
class="btn btn-sm btn-outline-secondary">
|
||||||
{% if current_user.role in ['admin','director'] or issue.assigned_to == current_user.id %}
|
{% if current_user.role in ['admin','director','auditor'] or issue.assigned_to == current_user.id %}
|
||||||
<i class="bi bi-pencil"></i> Edit
|
<i class="bi bi-pencil"></i> Edit
|
||||||
{% else %}
|
{% else %}
|
||||||
<i class="bi bi-eye"></i> View
|
<i class="bi bi-eye"></i> View
|
||||||
@@ -223,6 +233,7 @@
|
|||||||
class="d-inline"
|
class="d-inline"
|
||||||
onsubmit="return confirm('Permanently delete Issue #{{ issue.id }}? This cannot be undone.');">
|
onsubmit="return confirm('Permanently delete Issue #{{ issue.id }}? This cannot be undone.');">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<input type="hidden" name="next" value="{{ current_url() }}">
|
||||||
<button type="submit" class="btn btn-sm btn-outline-danger"
|
<button type="submit" class="btn btn-sm btn-outline-danger"
|
||||||
title="Delete Issue #{{ issue.id }}">
|
title="Delete Issue #{{ issue.id }}">
|
||||||
<i class="bi bi-trash"></i>
|
<i class="bi bi-trash"></i>
|
||||||
@@ -259,6 +270,7 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
|
{% include 'partials/bulk_select_js.html' %}
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
@@ -294,7 +306,7 @@
|
|||||||
}());
|
}());
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{% if current_user.role in ['admin', 'director'] %}
|
{% if current_user.role in ['admin', 'director', 'auditor'] %}
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|||||||
+363
-64
@@ -18,7 +18,31 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% set can_edit = current_user.role in ['admin','director'] or issue.assigned_to == current_user.id %}
|
{% set can_edit = current_user.role in ['admin','director','auditor'] or issue.assigned_to == current_user.id %}
|
||||||
|
{# The filtered list URL this page was opened from (phase: filter
|
||||||
|
preservation). Threaded into every action so an update or delete
|
||||||
|
returns to the same filtered page, and used by the Back button. #}
|
||||||
|
{% set back_url = request.args.get('next') or url_for('issues.index') %}
|
||||||
|
{# Is the viewer OUR staff? Drives the internal-only chrome on this page: the
|
||||||
|
"comments are visible to everyone" warning and the per-comment
|
||||||
|
"Customer visible" / "Staff only" badges. Both are instructions about how WE
|
||||||
|
work and must never reach a customer account.
|
||||||
|
|
||||||
|
Written as an explicit ALLOWLIST of our own roles, deliberately:
|
||||||
|
|
||||||
|
* It FAILS CLOSED. The obvious form, `not current_user.is_customer_account`,
|
||||||
|
fails OPEN — if the attribute is missing for any reason (a process still
|
||||||
|
running an older models/user.py after a template-only reload, say) Jinja
|
||||||
|
yields Undefined, `not Undefined` is true, and the internal text is shown
|
||||||
|
to exactly the people it must be hidden from. An allowlist of literal role
|
||||||
|
strings can only ever be true for a role we listed.
|
||||||
|
* `external_inspector` is absent ON PURPOSE. This is NOT the rule-87 case:
|
||||||
|
rule 87 is about capability/scoping, where a Customer Inspector must
|
||||||
|
behave exactly like our own inspector. Here the question is "does this
|
||||||
|
person work for us?", which is the one place the two genuinely differ.
|
||||||
|
Do not "fix" this by adding external_inspector to the list. #}
|
||||||
|
{% set viewer_is_our_staff = current_user.role in
|
||||||
|
['admin', 'director', 'project_manager', 'auditor', 'inspector'] %}
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
{# ══════════════════════════════════ LEFT COLUMN ══════════════════════════════════ #}
|
{# ══════════════════════════════════ LEFT COLUMN ══════════════════════════════════ #}
|
||||||
@@ -76,10 +100,11 @@
|
|||||||
<dt class="col-sm-3">Assigned To</dt>
|
<dt class="col-sm-3">Assigned To</dt>
|
||||||
<dd class="col-sm-9">{{ issue.assigned_user.display_name if issue.assigned_user else '— Unassigned —' }}</dd>
|
<dd class="col-sm-9">{{ issue.assigned_user.display_name if issue.assigned_user else '— Unassigned —' }}</dd>
|
||||||
|
|
||||||
{% if issue.handler_type and issue.handler_type != 'internal' %}
|
{% set _ht = issue.handler_type or 'internal' %}
|
||||||
|
{% if _ht != 'internal' or issue.internal_handler_name or issue.internal_handler_contact %}
|
||||||
<dt class="col-sm-3">Handled By</dt>
|
<dt class="col-sm-3">Handled By</dt>
|
||||||
<dd class="col-sm-9">
|
<dd class="col-sm-9">
|
||||||
{% if issue.handler_type == 'facility' %}
|
{% if _ht == 'facility' %}
|
||||||
<span class="badge bg-secondary">
|
<span class="badge bg-secondary">
|
||||||
<i class="bi bi-building me-1"></i>Facility Staff
|
<i class="bi bi-building me-1"></i>Facility Staff
|
||||||
</span>
|
</span>
|
||||||
@@ -92,8 +117,18 @@
|
|||||||
{% if issue.facility_handler_notes %}
|
{% if issue.facility_handler_notes %}
|
||||||
<div class="text-muted small mt-1" style="white-space:pre-wrap;">{{ issue.facility_handler_notes }}</div>
|
<div class="text-muted small mt-1" style="white-space:pre-wrap;">{{ issue.facility_handler_notes }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% elif issue.handler_type == 'vendor' %}
|
{% elif _ht == 'vendor' %}
|
||||||
<span class="badge bg-dark"><i class="bi bi-person-gear me-1"></i>External Vendor</span>
|
<span class="badge bg-dark"><i class="bi bi-person-gear me-1"></i>External Vendor</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-light text-dark border">
|
||||||
|
<i class="bi bi-people me-1"></i>Janitorial Staff
|
||||||
|
</span>
|
||||||
|
{% if issue.internal_handler_name %}
|
||||||
|
<span class="ms-2 fw-semibold">{{ issue.internal_handler_name }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if issue.internal_handler_contact %}
|
||||||
|
<span class="text-muted ms-2">{{ issue.internal_handler_contact }}</span>
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</dd>
|
</dd>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -127,14 +162,14 @@
|
|||||||
<h6>Photo Evidence</h6>
|
<h6>Photo Evidence</h6>
|
||||||
<div class="d-flex flex-wrap gap-2">
|
<div class="d-flex flex-wrap gap-2">
|
||||||
{% if issue.photo_path %}
|
{% if issue.photo_path %}
|
||||||
<a href="{{ url_for('static', filename=issue.photo_path) }}" target="_blank">
|
<a href="{{ media_url(issue.photo_path) }}" target="_blank">
|
||||||
<img src="{{ url_for('static', filename=issue.photo_path) }}"
|
<img src="{{ media_url(issue.photo_path) }}"
|
||||||
class="img-fluid rounded" style="max-height:300px; max-width:100%;">
|
class="img-fluid rounded" style="max-height:300px; max-width:100%;">
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% for photo in (issue.mobile_photo_paths or []) %}
|
{% for photo in (issue.mobile_photo_paths or []) %}
|
||||||
<a href="{{ url_for('static', filename=photo) }}" target="_blank">
|
<a href="{{ media_url(photo) }}" target="_blank">
|
||||||
<img src="{{ url_for('static', filename=photo) }}"
|
<img src="{{ media_url(photo) }}"
|
||||||
class="rounded border" style="max-height:300px; max-width:100%; object-fit:cover;">
|
class="rounded border" style="max-height:300px; max-width:100%; object-fit:cover;">
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -150,8 +185,8 @@
|
|||||||
{% if issue.result_photos %}
|
{% if issue.result_photos %}
|
||||||
<div class="d-flex flex-wrap gap-2 mt-2">
|
<div class="d-flex flex-wrap gap-2 mt-2">
|
||||||
{% for photo in issue.result_photos %}
|
{% for photo in issue.result_photos %}
|
||||||
<a href="{{ url_for('static', filename=photo) }}" target="_blank">
|
<a href="{{ media_url(photo) }}" target="_blank">
|
||||||
<img src="{{ url_for('static', filename=photo) }}"
|
<img src="{{ media_url(photo) }}"
|
||||||
class="rounded border" style="max-height:120px; max-width:160px; object-fit:cover;"
|
class="rounded border" style="max-height:120px; max-width:160px; object-fit:cover;"
|
||||||
alt="Result photo">
|
alt="Result photo">
|
||||||
</a>
|
</a>
|
||||||
@@ -174,8 +209,9 @@
|
|||||||
<div class="alert alert-info py-2 mb-0">
|
<div class="alert alert-info py-2 mb-0">
|
||||||
<i class="bi bi-hourglass-split me-1"></i>
|
<i class="bi bi-hourglass-split me-1"></i>
|
||||||
<strong>Awaiting director verification.</strong>
|
<strong>Awaiting director verification.</strong>
|
||||||
{% if current_user.role in ['admin','director'] %}
|
{% if current_user.role in ['admin','director','auditor'] %}
|
||||||
<form method="POST" action="{{ url_for('issues.verify', issue_id=issue.id) }}" class="mt-2">
|
<form method="POST" action="{{ url_for('issues.verify', issue_id=issue.id) }}" class="mt-2">
|
||||||
|
<input type="hidden" name="next" value="{{ back_url }}">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<input type="text" name="verification_note" class="form-control form-control-sm"
|
<input type="text" name="verification_note" class="form-control form-control-sm"
|
||||||
@@ -191,6 +227,84 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# ── Linked issues ──────────────────────────────────────────────────────
|
||||||
|
Duplicates and related issues, so whoever picks this one up can reach the
|
||||||
|
others. `issue_links` arrives already filtered to links whose far end this
|
||||||
|
viewer may open (_readable_links) — do NOT add links from the model
|
||||||
|
directly here, or a customer sees an issue at a facility they have no
|
||||||
|
assignment to. Links are navigational only: nothing here changes status,
|
||||||
|
SLA, assignee or followers on either issue. #}
|
||||||
|
<div class="card shadow-sm mb-4" id="linked-issues-section">
|
||||||
|
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
||||||
|
<h6 class="mb-0">
|
||||||
|
<i class="bi bi-link-45deg me-1"></i>Linked Issues
|
||||||
|
<span class="badge bg-secondary rounded-pill ms-1">{{ issue_links|length }}</span>
|
||||||
|
</h6>
|
||||||
|
{% if can_manage_links %}
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-primary"
|
||||||
|
data-bs-toggle="modal" data-bs-target="#linkIssueModal">
|
||||||
|
<i class="bi bi-plus-lg me-1"></i>Link an Issue
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body py-2">
|
||||||
|
{% if issue_links %}
|
||||||
|
<div class="list-group list-group-flush">
|
||||||
|
{% for link, other, label in issue_links %}
|
||||||
|
<div class="list-group-item px-0 py-2 d-flex align-items-start gap-2 flex-wrap">
|
||||||
|
<span class="badge {{ 'bg-warning text-dark' if link.link_type == 'duplicate' else 'bg-info text-dark' }} mt-1"
|
||||||
|
style="min-width:7.5rem;">{{ label }}</span>
|
||||||
|
|
||||||
|
<div class="flex-grow-1" style="min-width:14rem;">
|
||||||
|
<a href="{{ url_for('issues.view', issue_id=other.id, next=back_url) }}"
|
||||||
|
class="fw-semibold text-decoration-none">#{{ other.id }}</a>
|
||||||
|
<span class="text-muted small ms-1">
|
||||||
|
{{ other.area.name if other.area
|
||||||
|
else other.resolved_facility.name if other.resolved_facility else '—' }}
|
||||||
|
</span>
|
||||||
|
<div class="small text-muted text-truncate" style="max-width:38rem;">
|
||||||
|
{{ other.description }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex align-items-center gap-1 mt-1">
|
||||||
|
<span class="badge bg-{{ 'danger' if other.severity in ['critical','high']
|
||||||
|
else 'warning text-dark' if other.severity == 'medium'
|
||||||
|
else 'secondary' }}">{{ other.severity|title }}</span>
|
||||||
|
<span class="badge bg-{{ 'success' if other.status == 'resolved'
|
||||||
|
else 'info text-dark' if other.status == 'pending_verification'
|
||||||
|
else 'light text-dark' }}">
|
||||||
|
{{ other.status|replace('_',' ')|title }}
|
||||||
|
</span>
|
||||||
|
{% if can_manage_links %}
|
||||||
|
<form method="POST" class="mb-0 ms-1"
|
||||||
|
action="{{ url_for('issues.remove_link', issue_id=issue.id, link_id=link.id) }}"
|
||||||
|
onsubmit="return confirm('Remove the link between #{{ issue.id }} and #{{ other.id }}? Neither issue is changed or deleted.');">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<input type="hidden" name="next" value="{{ back_url }}">
|
||||||
|
<button type="submit" class="btn btn-sm btn-link text-muted p-0 px-1"
|
||||||
|
title="Remove this link">
|
||||||
|
<i class="bi bi-x-lg"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted small mb-0 py-1">
|
||||||
|
<i class="bi bi-info-circle me-1"></i>
|
||||||
|
No linked issues.
|
||||||
|
{% if can_manage_links %}
|
||||||
|
Use <strong>Link an Issue</strong> to point at a duplicate or a related issue.
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{# ── Comments ───────────────────────────────────────────────────────── #}
|
{# ── Comments ───────────────────────────────────────────────────────── #}
|
||||||
<div class="card shadow-sm mb-4" id="comments-section">
|
<div class="card shadow-sm mb-4" id="comments-section">
|
||||||
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
||||||
@@ -219,8 +333,12 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<span class="badge bg-secondary" style="font-size:.65rem;">{{ c.author.role|replace('_',' ')|title }}</span>
|
<span class="badge bg-secondary" style="font-size:.65rem;">{{ c.author.role|replace('_',' ')|title }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{# Visibility indicator — staff only #}
|
{# Visibility indicator — OUR staff only, and only while the
|
||||||
{% if current_user.role != 'customer' %}
|
per-comment flag still decides anything. While comments_open
|
||||||
|
is set EVERY comment reaches the customer, so a "Staff only"
|
||||||
|
badge would be a lie; it is suppressed rather than shown
|
||||||
|
incorrectly. #}
|
||||||
|
{% if viewer_is_our_staff and not comments_open %}
|
||||||
{% if c.is_customer_visible %}
|
{% if c.is_customer_visible %}
|
||||||
<span class="badge bg-success bg-opacity-10 text-success border border-success"
|
<span class="badge bg-success bg-opacity-10 text-success border border-success"
|
||||||
style="font-size:.6rem;" title="Customer can see this comment">
|
style="font-size:.6rem;" title="Customer can see this comment">
|
||||||
@@ -263,6 +381,7 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<p class="fw-semibold small mb-2">Add Comment</p>
|
<p class="fw-semibold small mb-2">Add Comment</p>
|
||||||
<form method="post">
|
<form method="post">
|
||||||
|
<input type="hidden" name="next" value="{{ back_url }}">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<input type="hidden" name="status" value="{{ issue.status }}">
|
<input type="hidden" name="status" value="{{ issue.status }}">
|
||||||
<input type="hidden" name="assigned_to" value="{{ issue.assigned_to or 0 }}">
|
<input type="hidden" name="assigned_to" value="{{ issue.assigned_to or 0 }}">
|
||||||
@@ -270,6 +389,25 @@
|
|||||||
<textarea name="update_notes" class="form-control" rows="3"
|
<textarea name="update_notes" class="form-control" rows="3"
|
||||||
placeholder="Write a comment…" required></textarea>
|
placeholder="Write a comment…" required></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
{# While comments_open is set, every comment reaches the customer, so
|
||||||
|
the "Share with customer" tick decides nothing. Saying so plainly
|
||||||
|
matters: a staff member must not write something they believe is
|
||||||
|
private. The checkbox is still posted and recorded, so turning the
|
||||||
|
setting off restores its meaning immediately.
|
||||||
|
|
||||||
|
OUR STAFF ONLY. `can_edit` is also true for a Customer Inspector
|
||||||
|
assigned to the issue, and this banner is an internal-process
|
||||||
|
warning ("do not post internal-only notes") — showing it to a
|
||||||
|
customer account exposes how we work and reads as nonsense to
|
||||||
|
them, since nothing they write was ever private. #}
|
||||||
|
{% if comments_open and viewer_is_our_staff %}
|
||||||
|
<div class="alert alert-warning py-2 px-3 small mb-2">
|
||||||
|
<i class="bi bi-eye me-1"></i>
|
||||||
|
<strong>Comments are currently visible to everyone,</strong> including
|
||||||
|
the customer, regardless of the tick below. Do not post internal-only
|
||||||
|
notes here.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2">
|
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2">
|
||||||
<div class="form-check form-check-inline mb-0">
|
<div class="form-check form-check-inline mb-0">
|
||||||
<input class="form-check-input" type="checkbox"
|
<input class="form-check-input" type="checkbox"
|
||||||
@@ -290,6 +428,7 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<p class="fw-semibold small mb-2">Add Comment</p>
|
<p class="fw-semibold small mb-2">Add Comment</p>
|
||||||
<form method="post">
|
<form method="post">
|
||||||
|
<input type="hidden" name="next" value="{{ back_url }}">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<textarea name="update_notes" class="form-control" rows="3"
|
<textarea name="update_notes" class="form-control" rows="3"
|
||||||
@@ -358,12 +497,13 @@
|
|||||||
<div class="card-header bg-light"><h6 class="mb-0">Update Issue</h6></div>
|
<div class="card-header bg-light"><h6 class="mb-0">Update Issue</h6></div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form method="post" enctype="multipart/form-data">
|
<form method="post" enctype="multipart/form-data">
|
||||||
|
<input type="hidden" name="next" value="{{ back_url }}">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
{{ form.status.label(class="form-label fw-semibold") }}
|
{{ form.status.label(class="form-label fw-semibold") }}
|
||||||
{{ form.status(class="form-select") }}
|
{{ form.status(class="form-select") }}
|
||||||
</div>
|
</div>
|
||||||
{% if current_user.role in ['admin','director'] %}
|
{% if current_user.role in ['admin','director','auditor'] %}
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
{{ form.assigned_to.label(class="form-label fw-semibold") }}
|
{{ form.assigned_to.label(class="form-label fw-semibold") }}
|
||||||
{{ form.assigned_to(class="form-select") }}
|
{{ form.assigned_to(class="form-select") }}
|
||||||
@@ -386,7 +526,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% if current_user.role in ['admin','director','project_manager'] %}
|
{% if current_user.role in ['admin','director','project_manager','auditor'] %}
|
||||||
<hr class="my-3">
|
<hr class="my-3">
|
||||||
<p class="fw-semibold small mb-2">
|
<p class="fw-semibold small mb-2">
|
||||||
<i class="bi bi-person-check me-1 text-secondary"></i>Handler / Ownership
|
<i class="bi bi-person-check me-1 text-secondary"></i>Handler / Ownership
|
||||||
@@ -416,13 +556,30 @@
|
|||||||
placeholder="Notes about what they are handling…") }}
|
placeholder="Notes about what they are handling…") }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="internalHandlerFields"
|
||||||
|
style="{{ '' if (issue.handler_type or 'internal') == 'internal' else 'display:none;' }}">
|
||||||
|
<div class="mb-2">
|
||||||
|
{{ form.internal_handler_name.label(class="form-label small fw-semibold mb-1") }}
|
||||||
|
{{ form.internal_handler_name(class="form-control form-control-sm",
|
||||||
|
placeholder="Crew member handling this",
|
||||||
|
value=issue.internal_handler_name or '') }}
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
{{ form.internal_handler_contact.label(class="form-label small fw-semibold mb-1") }}
|
||||||
|
{{ form.internal_handler_contact(class="form-control form-control-sm",
|
||||||
|
placeholder="Phone or email",
|
||||||
|
value=issue.internal_handler_contact or '') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<script>
|
<script>
|
||||||
(function(){
|
(function(){
|
||||||
var sel = document.getElementById('handlerTypeSelect');
|
var sel = document.getElementById('handlerTypeSelect');
|
||||||
var box = document.getElementById('facilityHandlerFields');
|
var box = document.getElementById('facilityHandlerFields');
|
||||||
if(sel && box){
|
var inv = document.getElementById('internalHandlerFields');
|
||||||
|
if(sel){
|
||||||
sel.addEventListener('change', function(){
|
sel.addEventListener('change', function(){
|
||||||
box.style.display = (this.value === 'facility') ? '' : 'none';
|
if(box){ box.style.display = (this.value === 'facility') ? '' : 'none'; }
|
||||||
|
if(inv){ inv.style.display = (this.value === 'internal') ? '' : 'none'; }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
@@ -466,58 +623,12 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{# ── Vendor Work Orders (phase36) ───────────────────────────────────── #}
|
|
||||||
{% if current_user.role in ['admin','director','project_manager'] %}
|
|
||||||
<div class="card shadow-sm mt-3">
|
|
||||||
<div class="card-header bg-light">
|
|
||||||
<h6 class="mb-0"><i class="bi bi-send me-1"></i>Contractor Work Orders</h6>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
{% set wos = issue.work_orders.all() %}
|
|
||||||
{% if wos %}
|
|
||||||
<ul class="list-unstyled small mb-3">
|
|
||||||
{% for wo in wos %}
|
|
||||||
{% set b = {'sent':'secondary','acknowledged':'info','completed':'success'}[wo.status] %}
|
|
||||||
<li class="d-flex justify-content-between align-items-center border-bottom py-1">
|
|
||||||
<span class="text-truncate me-2">{{ wo.vendor_name }}
|
|
||||||
<span class="text-muted d-block" style="font-size:.75rem;">{{ wo.vendor_email }}</span>
|
|
||||||
</span>
|
|
||||||
<span class="badge bg-{{ b }}">{{ wo.status_label }}</span>
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
{% endif %}
|
|
||||||
<form method="POST" action="{{ url_for('issues.dispatch_work_order', issue_id=issue.id) }}">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
|
||||||
<div class="mb-2">
|
|
||||||
<label class="form-label small fw-semibold mb-1">Contractor name</label>
|
|
||||||
<input type="text" name="vendor_name" class="form-control form-control-sm"
|
|
||||||
value="{{ issue.vendor_name or '' }}" placeholder="e.g. Ace Plumbing" required>
|
|
||||||
</div>
|
|
||||||
<div class="mb-2">
|
|
||||||
<label class="form-label small fw-semibold mb-1">Contractor email</label>
|
|
||||||
<input type="email" name="vendor_email" class="form-control form-control-sm"
|
|
||||||
placeholder="name@contractor.com" required>
|
|
||||||
</div>
|
|
||||||
<div class="mb-2">
|
|
||||||
<label class="form-label small fw-semibold mb-1">Message <span class="text-muted">(optional)</span></label>
|
|
||||||
<textarea name="message" class="form-control form-control-sm" rows="2"
|
|
||||||
placeholder="Any specific instructions…"></textarea>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn btn-outline-primary btn-sm w-100">
|
|
||||||
<i class="bi bi-envelope-paper me-1"></i> Send Work Order
|
|
||||||
</button>
|
|
||||||
<div class="form-text">Emails the contractor a private link to acknowledge & complete — no account needed.</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
</div>{# /col-lg-4 #}
|
</div>{# /col-lg-4 #}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="d-flex align-items-center gap-2 mt-2">
|
<div class="d-flex align-items-center gap-2 mt-2">
|
||||||
<a href="{{ url_for('issues.index') }}" class="btn btn-outline-secondary btn-sm">
|
<a href="{{ back_url }}" class="btn btn-outline-secondary btn-sm">
|
||||||
<i class="bi bi-arrow-left"></i> Back to Issues
|
<i class="bi bi-arrow-left"></i> Back to Issues
|
||||||
</a>
|
</a>
|
||||||
<a href="{{ url_for('issues.export_pdf', issue_id=issue.id) }}" class="btn btn-outline-primary btn-sm">
|
<a href="{{ url_for('issues.export_pdf', issue_id=issue.id) }}" class="btn btn-outline-primary btn-sm">
|
||||||
@@ -531,6 +642,77 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# ── Link an issue ────────────────────────────────────────────────────────────
|
||||||
|
Search is scoped server-side to issues this viewer could already open, so the
|
||||||
|
picker can never be used to enumerate another contract's issues. The POST
|
||||||
|
re-checks access — the search is only a convenience. #}
|
||||||
|
{% if can_manage_links %}
|
||||||
|
<div class="modal fade" id="linkIssueModal" tabindex="-1"
|
||||||
|
aria-labelledby="linkIssueModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content">
|
||||||
|
<form method="POST" action="{{ url_for('issues.add_link', issue_id=issue.id) }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<input type="hidden" name="next" value="{{ back_url }}">
|
||||||
|
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="linkIssueModalLabel">
|
||||||
|
<i class="bi bi-link-45deg me-1"></i>Link an issue to #{{ issue.id }}
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label small fw-semibold" for="linkTypeSelect">
|
||||||
|
How are they related?
|
||||||
|
</label>
|
||||||
|
<select name="link_type" id="linkTypeSelect" class="form-select form-select-sm">
|
||||||
|
{% for value, label in link_types %}
|
||||||
|
<option value="{{ value }}">
|
||||||
|
#{{ issue.id }} is a <strong>{{ label|lower }}</strong> …
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<div class="form-text">
|
||||||
|
Linking is for navigation only — neither issue's status, SLA or
|
||||||
|
assignee changes.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label small fw-semibold" for="linkIssueSearch">
|
||||||
|
Which issue?
|
||||||
|
</label>
|
||||||
|
<input type="text" class="form-control form-control-sm" id="linkIssueSearch"
|
||||||
|
autocomplete="off" placeholder="Issue number, or words from the description…">
|
||||||
|
<input type="hidden" name="linked_issue_id" id="linkIssueId">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Chosen issue, shown once picked so nobody submits a mistyped number #}
|
||||||
|
<div id="linkIssueChosen" class="alert alert-primary py-2 small d-none mb-2">
|
||||||
|
<span id="linkIssueChosenText"></span>
|
||||||
|
<button type="button" class="btn btn-sm btn-link p-0 ms-2" id="linkIssueClear">change</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="linkIssueResults" class="list-group small" style="max-height:16rem; overflow-y:auto;"></div>
|
||||||
|
<div id="linkIssueEmpty" class="text-muted small d-none py-2">
|
||||||
|
No matching issue you can access.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm" id="linkIssueSubmit" disabled>
|
||||||
|
<i class="bi bi-link-45deg me-1"></i>Link Issue
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if current_user.role in ['admin', 'director'] %}
|
{% if current_user.role in ['admin', 'director'] %}
|
||||||
<div class="modal fade" id="deleteIssueModal" tabindex="-1" aria-labelledby="deleteIssueModalLabel" aria-hidden="true">
|
<div class="modal fade" id="deleteIssueModal" tabindex="-1" aria-labelledby="deleteIssueModalLabel" aria-hidden="true">
|
||||||
<div class="modal-dialog modal-dialog-centered">
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
@@ -555,6 +737,7 @@
|
|||||||
<i class="bi bi-x-circle"></i> Cancel
|
<i class="bi bi-x-circle"></i> Cancel
|
||||||
</button>
|
</button>
|
||||||
<form method="POST" action="{{ url_for('issues.delete', issue_id=issue.id) }}" class="d-inline">
|
<form method="POST" action="{{ url_for('issues.delete', issue_id=issue.id) }}" class="d-inline">
|
||||||
|
<input type="hidden" name="next" value="{{ back_url }}">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<button type="submit" class="btn btn-danger">
|
<button type="submit" class="btn btn-danger">
|
||||||
<i class="bi bi-trash-fill"></i> Delete Permanently
|
<i class="bi bi-trash-fill"></i> Delete Permanently
|
||||||
@@ -585,6 +768,122 @@
|
|||||||
section.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
section.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// ── Link-an-issue picker ────────────────────────────────────────────────
|
||||||
|
// Type a number or some words, pick from the scoped results, submit. The
|
||||||
|
// hidden linked_issue_id is only ever set by CHOOSING a result, so the
|
||||||
|
// number posted is always one the server just confirmed this user can see.
|
||||||
|
//
|
||||||
|
// Every result field is written with textContent / createTextNode, never
|
||||||
|
// innerHTML: `description` is text a person typed and would otherwise be
|
||||||
|
// an XSS hole straight into the page of whoever opens the picker.
|
||||||
|
var linkSearch = document.getElementById('linkIssueSearch');
|
||||||
|
if (linkSearch) {
|
||||||
|
var linkResults = document.getElementById('linkIssueResults');
|
||||||
|
var linkEmpty = document.getElementById('linkIssueEmpty');
|
||||||
|
var linkIdField = document.getElementById('linkIssueId');
|
||||||
|
var linkChosen = document.getElementById('linkIssueChosen');
|
||||||
|
var linkChosenText = document.getElementById('linkIssueChosenText');
|
||||||
|
var linkClear = document.getElementById('linkIssueClear');
|
||||||
|
var linkSubmit = document.getElementById('linkIssueSubmit');
|
||||||
|
var searchTimer = null;
|
||||||
|
var searchSeq = 0;
|
||||||
|
|
||||||
|
function clearChoice() {
|
||||||
|
linkIdField.value = '';
|
||||||
|
linkSubmit.disabled = true;
|
||||||
|
linkChosen.classList.add('d-none');
|
||||||
|
linkSearch.classList.remove('d-none');
|
||||||
|
}
|
||||||
|
|
||||||
|
function choose(item) {
|
||||||
|
linkIdField.value = item.id;
|
||||||
|
linkSubmit.disabled = false;
|
||||||
|
linkChosenText.textContent =
|
||||||
|
'#' + item.id + ' — ' + item.location + ' — ' + item.description;
|
||||||
|
linkChosen.classList.remove('d-none');
|
||||||
|
linkSearch.classList.add('d-none');
|
||||||
|
linkResults.innerHTML = '';
|
||||||
|
linkEmpty.classList.add('d-none');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderResults(items) {
|
||||||
|
linkResults.innerHTML = '';
|
||||||
|
linkEmpty.classList.toggle('d-none', items.length > 0);
|
||||||
|
|
||||||
|
items.forEach(function (item) {
|
||||||
|
var row = document.createElement('button');
|
||||||
|
row.type = 'button';
|
||||||
|
row.className = 'list-group-item list-group-item-action py-2';
|
||||||
|
|
||||||
|
var head = document.createElement('div');
|
||||||
|
head.className = 'd-flex justify-content-between gap-2';
|
||||||
|
|
||||||
|
var num = document.createElement('span');
|
||||||
|
num.className = 'fw-semibold';
|
||||||
|
num.textContent = '#' + item.id + ' · ' + item.location;
|
||||||
|
|
||||||
|
var meta = document.createElement('span');
|
||||||
|
meta.className = 'text-muted';
|
||||||
|
meta.textContent = item.severity + ' · ' + item.status +
|
||||||
|
(item.reported_at ? ' · ' + item.reported_at : '');
|
||||||
|
|
||||||
|
head.appendChild(num);
|
||||||
|
head.appendChild(meta);
|
||||||
|
|
||||||
|
var desc = document.createElement('div');
|
||||||
|
desc.className = 'text-muted text-truncate';
|
||||||
|
desc.textContent = item.description;
|
||||||
|
|
||||||
|
row.appendChild(head);
|
||||||
|
row.appendChild(desc);
|
||||||
|
row.addEventListener('click', function () { choose(item); });
|
||||||
|
linkResults.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function runSearch() {
|
||||||
|
var term = linkSearch.value.trim();
|
||||||
|
if (!term) {
|
||||||
|
linkResults.innerHTML = '';
|
||||||
|
linkEmpty.classList.add('d-none');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Responses can arrive out of order; only the newest one may render.
|
||||||
|
var seq = ++searchSeq;
|
||||||
|
fetch('{{ url_for("issues.link_search", issue_id=issue.id) }}?q=' +
|
||||||
|
encodeURIComponent(term), { headers: { 'Accept': 'application/json' } })
|
||||||
|
.then(function (res) { return res.ok ? res.json() : { results: [] }; })
|
||||||
|
.then(function (data) {
|
||||||
|
if (seq !== searchSeq) { return; }
|
||||||
|
renderResults(data.results || []);
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
if (seq !== searchSeq) { return; }
|
||||||
|
renderResults([]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
linkSearch.addEventListener('input', function () {
|
||||||
|
clearTimeout(searchTimer);
|
||||||
|
searchTimer = setTimeout(runSearch, 250);
|
||||||
|
});
|
||||||
|
// The picker lives inside a form — Enter would submit it with no issue
|
||||||
|
// chosen instead of searching.
|
||||||
|
linkSearch.addEventListener('keydown', function (ev) {
|
||||||
|
if (ev.key === 'Enter') {
|
||||||
|
ev.preventDefault();
|
||||||
|
clearTimeout(searchTimer);
|
||||||
|
runSearch();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
linkClear.addEventListener('click', function () {
|
||||||
|
clearChoice();
|
||||||
|
linkSearch.value = '';
|
||||||
|
linkSearch.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,564 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||||
|
<!-- iOS / iPadOS web app meta tags -->
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
|
<title>{% block title %}Janitorial QC System{% endblock %}</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
|
||||||
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&display=swap">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/theme.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/ipad_responsive.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/mobile_phone.css') }}">
|
||||||
|
{% if tenant_branding %}
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bs-primary: {{ tenant_branding.primary_color or '#1a56db' }};
|
||||||
|
--bs-primary-rgb: {{ tenant_branding.primary_color|hex_to_rgb if tenant_branding.primary_color else '26,86,219' }};
|
||||||
|
--jqc-accent: {{ tenant_branding.accent_color or '#16a34a' }};
|
||||||
|
}
|
||||||
|
.bg-primary { background-color: {{ tenant_branding.primary_color or '#1a56db' }} !important; }
|
||||||
|
.btn-primary { background-color: {{ tenant_branding.primary_color or '#1a56db' }} !important;
|
||||||
|
border-color: {{ tenant_branding.primary_color or '#1a56db' }} !important; }
|
||||||
|
</style>
|
||||||
|
{% endif %}
|
||||||
|
{% block extra_css %}{% endblock %}
|
||||||
|
<style>
|
||||||
|
/* ── Notification bell styles ── */
|
||||||
|
.notif-bell-wrapper { position: relative; }
|
||||||
|
.notif-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px; right: 2px;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
min-width: 16px; height: 16px; line-height: 16px;
|
||||||
|
padding: 0 4px; border-radius: 8px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.notif-dropdown {
|
||||||
|
width: 380px;
|
||||||
|
max-height: 520px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.notif-item {
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
transition: background 0.15s;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.notif-item.unread {
|
||||||
|
border-left-color: #0d6efd;
|
||||||
|
background-color: #f0f6ff;
|
||||||
|
}
|
||||||
|
.notif-item:hover { background-color: #e8f0fe; }
|
||||||
|
.notif-title { font-size: 0.85rem; font-weight: 600; margin-bottom: 2px; }
|
||||||
|
.notif-body { font-size: 0.78rem; color: #555; white-space: normal; }
|
||||||
|
.notif-time { font-size: 0.7rem; color: #999; }
|
||||||
|
.notif-empty { padding: 24px; text-align: center; color: #aaa; font-size: 0.85rem; }
|
||||||
|
|
||||||
|
/* ── Active nav tab ── */
|
||||||
|
.navbar-dark .navbar-nav .nav-link.active {
|
||||||
|
background-color: rgba(255, 255, 255, 0.18);
|
||||||
|
color: #ffffff !important;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: inset 0 -2px 0 rgba(255,255,255,0.6);
|
||||||
|
}
|
||||||
|
.navbar-dark .navbar-nav .nav-link:not(.active):hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{% if current_user.is_authenticated %}
|
||||||
|
<nav class="navbar navbar-expand-xxl navbar-dark bg-primary">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<a class="navbar-brand" href="{{ url_for('dashboard.index') }}">
|
||||||
|
{% if tenant_branding and tenant_branding.logo_url %}
|
||||||
|
<img src="{{ media_url(tenant_branding.logo_url) }}"
|
||||||
|
alt="{{ tenant_branding.display_name }}"
|
||||||
|
style="max-height:32px; border-radius:4px; margin-right:.35rem;">
|
||||||
|
{% else %}
|
||||||
|
<i class="bi bi-clipboard-check"></i>
|
||||||
|
{% endif %}
|
||||||
|
{{ tenant_branding.display_name if tenant_branding else 'Janitorial QC' }}
|
||||||
|
</a>
|
||||||
|
<!-- ── Bell + toggler always visible on mobile/tablet ── -->
|
||||||
|
<div class="d-flex align-items-center gap-2 ms-auto me-2 d-xxl-none">
|
||||||
|
<!-- Notification bell (always visible) -->
|
||||||
|
<div class="dropdown">
|
||||||
|
<a class="nav-link position-relative notif-bell-wrapper text-white"
|
||||||
|
href="#"
|
||||||
|
id="notifDropdownMobile"
|
||||||
|
role="button"
|
||||||
|
data-bs-toggle="dropdown"
|
||||||
|
aria-expanded="false"
|
||||||
|
title="Notifications">
|
||||||
|
<i class="bi bi-bell fs-5"></i>
|
||||||
|
{% if unread_notification_count > 0 %}
|
||||||
|
<span class="badge bg-danger notif-badge" id="notif-count-badge-mobile">
|
||||||
|
{{ unread_notification_count if unread_notification_count <= 99 else '99+' }}
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-danger notif-badge d-none" id="notif-count-badge-mobile"></span>
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
<div class="dropdown-menu dropdown-menu-end notif-dropdown shadow"
|
||||||
|
id="notif-dropdown-menu-mobile">
|
||||||
|
<div class="d-flex justify-content-between align-items-center px-3 py-2 border-bottom">
|
||||||
|
<span class="fw-semibold" style="font-size:.9rem;">Notifications</span>
|
||||||
|
<button class="btn btn-link btn-sm p-0 text-muted text-decoration-none mark-all-read-btn"
|
||||||
|
style="font-size:.75rem;">Mark all as read</button>
|
||||||
|
</div>
|
||||||
|
<div class="notif-list-mobile">
|
||||||
|
<div class="notif-empty">Loading…</div>
|
||||||
|
</div>
|
||||||
|
<div class="border-top d-flex justify-content-between px-3 py-2" style="font-size:.8rem;">
|
||||||
|
<a href="{{ url_for('notifications.index') }}" class="text-decoration-none">
|
||||||
|
<i class="bi bi-list-ul me-1"></i>View all
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('notifications.preferences') }}" class="text-decoration-none text-muted">
|
||||||
|
<i class="bi bi-gear me-1"></i>Preferences
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
<div class="collapse navbar-collapse" id="navbarNav">
|
||||||
|
<ul class="navbar-nav me-auto">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('dashboard.') }}" href="{{ url_for('dashboard.index') }}">Dashboard</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {{ 'active' if request.endpoint and (request.endpoint.startswith('reports.') or request.endpoint.startswith('scheduled_reports.')) }}" href="{{ url_for('reports.index') }}">Reports</a>
|
||||||
|
</li>
|
||||||
|
{% if current_user.role in ['admin', 'director', 'project_manager', 'auditor'] %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('projects.') }}" href="{{ url_for('projects.index') }}">Contracts</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('facilities.') }}" href="{{ url_for('facilities.list_facilities') }}">Facilities</a>
|
||||||
|
</li>
|
||||||
|
{% if current_user.role in ['admin', 'director'] %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('templates.') }}" href="{{ url_for('templates.index') }}">Templates</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('inspections.') }}" href="{{ url_for('inspections.index') }}">Inspections</a>
|
||||||
|
</li>
|
||||||
|
{% if current_user.role in ['admin', 'director', 'project_manager', 'auditor'] %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('inspection_schedules.') }}" href="{{ url_for('inspection_schedules.index') }}">Schedules</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('issues.') and request.endpoint != 'issues.verification_queue' }}" href="{{ url_for('issues.index') }}">Issues</a>
|
||||||
|
</li>
|
||||||
|
{% if current_user.role in ['admin', 'director', 'auditor'] %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link d-flex align-items-center gap-1 {{ 'active' if request.endpoint == 'issues.verification_queue' }}"
|
||||||
|
href="{{ url_for('issues.verification_queue') }}">
|
||||||
|
Verify
|
||||||
|
{% if pending_verification_count and pending_verification_count > 0 %}
|
||||||
|
<span class="badge bg-info text-dark"
|
||||||
|
style="font-size:.65rem;line-height:1;">
|
||||||
|
{{ pending_verification_count }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% if current_user.role in ['admin', 'director'] %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('customers.') }}" href="{{ url_for('customers.index') }}">Customers</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% if current_user.role in ['admin', 'director'] %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('support.') }}"
|
||||||
|
href="{{ url_for('support.admin_tickets') }}">
|
||||||
|
Support
|
||||||
|
{% if open_support_tickets_count > 0 %}
|
||||||
|
<span class="badge bg-danger ms-1">{{ open_support_tickets_count }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{# Both customer-side roles get the Support menu — a Customer
|
||||||
|
Inspector works at the customer's facilities and has the
|
||||||
|
same questions. The assistant answers them for their own
|
||||||
|
role (see _role addendum in routes/support.py). #}
|
||||||
|
{% if current_user.is_customer_account %}
|
||||||
|
<li class="nav-item dropdown">
|
||||||
|
<a class="nav-link dropdown-toggle {{ 'active' if request.endpoint and request.endpoint.startswith('support.') }}"
|
||||||
|
href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
|
<i class="bi bi-chat-dots me-1"></i>Support
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item" href="{{ url_for('support.chat') }}">
|
||||||
|
<i class="bi bi-chat-dots me-2"></i>Ask a Question
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item" href="{{ url_for('support.my_conversations') }}">
|
||||||
|
<i class="bi bi-clock-history me-2"></i>My Conversations
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item" href="{{ url_for('support.my_tickets') }}">
|
||||||
|
<i class="bi bi-inbox me-2"></i>My Requests
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% if current_user.role == 'admin' %}
|
||||||
|
{% set admin_active = request.endpoint and (
|
||||||
|
(request.endpoint.startswith('auth.') and 'user' in request.endpoint)
|
||||||
|
or request.endpoint.startswith('audit.')
|
||||||
|
or request.endpoint == 'auth.notification_matrix'
|
||||||
|
or request.endpoint.startswith('broadcast.')
|
||||||
|
or request.endpoint.startswith('devices.')
|
||||||
|
or request.endpoint.startswith('enrollment.')
|
||||||
|
or request.endpoint.startswith('tenant_settings.')
|
||||||
|
) %}
|
||||||
|
<li class="nav-item dropdown">
|
||||||
|
<a class="nav-link dropdown-toggle {{ 'active' if admin_active }}"
|
||||||
|
href="#" id="adminMenu" role="button"
|
||||||
|
data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
|
<i class="bi bi-sliders me-1"></i>Admin
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="adminMenu">
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('auth.') and 'user' in request.endpoint }}"
|
||||||
|
href="{{ url_for('auth.list_users') }}">
|
||||||
|
<i class="bi bi-people me-2"></i>Users
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('audit.') }}"
|
||||||
|
href="{{ url_for('audit.index') }}">
|
||||||
|
<i class="bi bi-clipboard-data me-2"></i>Audit Trail
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item {{ 'active' if request.endpoint == 'auth.notification_matrix' }}"
|
||||||
|
href="{{ url_for('auth.notification_matrix') }}">
|
||||||
|
<i class="bi bi-grid-3x3-gap-fill me-2"></i>Notification Matrix
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('broadcast.') }}"
|
||||||
|
href="{{ url_for('broadcast.index') }}">
|
||||||
|
<i class="bi bi-megaphone-fill me-2"></i>Broadcast
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('devices.') }}"
|
||||||
|
href="{{ url_for('devices.index') }}">
|
||||||
|
<i class="bi bi-tablet me-2"></i>Devices
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
{# The enrollment intake form is public (no login);
|
||||||
|
its submissions are read here. Admin-only. #}
|
||||||
|
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('enrollment.') }}"
|
||||||
|
href="{{ url_for('enrollment.admin_list') }}">
|
||||||
|
<i class="bi bi-person-plus-fill me-2"></i>Enrollment Forms
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('tenant_settings.') }}"
|
||||||
|
href="{{ url_for('tenant_settings.branding') }}">
|
||||||
|
<i class="bi bi-gear me-2"></i>Workspace Settings
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
<ul class="navbar-nav align-items-center">
|
||||||
|
|
||||||
|
<!-- ── Notification Bell (desktop lg+ only) ── -->
|
||||||
|
<li class="nav-item dropdown me-2 d-none d-xxl-block">
|
||||||
|
<a class="nav-link position-relative notif-bell-wrapper"
|
||||||
|
href="#"
|
||||||
|
id="notifDropdown"
|
||||||
|
role="button"
|
||||||
|
data-bs-toggle="dropdown"
|
||||||
|
aria-expanded="false"
|
||||||
|
title="Notifications">
|
||||||
|
<i class="bi bi-bell fs-5"></i>
|
||||||
|
{% if unread_notification_count > 0 %}
|
||||||
|
<span class="badge bg-danger notif-badge" id="notif-count-badge">
|
||||||
|
{{ unread_notification_count if unread_notification_count <= 99 else '99+' }}
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-danger notif-badge d-none" id="notif-count-badge"></span>
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
<div class="dropdown-menu dropdown-menu-end notif-dropdown shadow"
|
||||||
|
id="notif-dropdown-menu">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="d-flex justify-content-between align-items-center
|
||||||
|
px-3 py-2 border-bottom">
|
||||||
|
<span class="fw-semibold" style="font-size:.9rem;">Notifications</span>
|
||||||
|
<button class="btn btn-link btn-sm p-0 text-muted text-decoration-none"
|
||||||
|
id="mark-all-read-btn" style="font-size:.75rem;">
|
||||||
|
Mark all as read
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- Items -->
|
||||||
|
<div id="notif-list">
|
||||||
|
<div class="notif-empty">Loading…</div>
|
||||||
|
</div>
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="border-top d-flex justify-content-between px-3 py-2"
|
||||||
|
style="font-size:.8rem;">
|
||||||
|
<a href="{{ url_for('notifications.index') }}"
|
||||||
|
class="text-decoration-none">
|
||||||
|
<i class="bi bi-list-ul me-1"></i>View all
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('notifications.preferences') }}"
|
||||||
|
class="text-decoration-none text-muted">
|
||||||
|
<i class="bi bi-gear me-1"></i>Preferences
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<!-- ── End Notification Bell ── -->
|
||||||
|
|
||||||
|
<!-- User menu -->
|
||||||
|
<li class="nav-item dropdown">
|
||||||
|
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown"
|
||||||
|
role="button" data-bs-toggle="dropdown">
|
||||||
|
<i class="bi bi-person-circle"></i> {{ current_user.username }}
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-end">
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item"
|
||||||
|
href="{{ url_for('auth.profile') }}">
|
||||||
|
<i class="bi bi-person-circle me-1"></i>My Profile
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item"
|
||||||
|
href="{{ url_for('notifications.preferences') }}">
|
||||||
|
<i class="bi bi-bell-slash me-1"></i>Notification Preferences
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
{# MT-16 — opt in to the sidebar design. POST so the
|
||||||
|
switch is not a GET side effect; `next` returns the
|
||||||
|
user to the page they were on. #}
|
||||||
|
<li>
|
||||||
|
<form method="POST" action="{{ url_for('ui.switch_theme') }}" class="px-0">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<input type="hidden" name="theme" value="modern">
|
||||||
|
<input type="hidden" name="next" value="{{ request.full_path }}">
|
||||||
|
<button type="submit" class="dropdown-item">
|
||||||
|
<i class="bi bi-stars me-1"></i>Try the New Design
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
{% if current_user.role == 'admin' %}
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item" href="{{ url_for('ui.theme_votes') }}">
|
||||||
|
<i class="bi bi-bar-chart me-1"></i>Design Vote Tally
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item" href="{{ url_for('auth.logout') }}">
|
||||||
|
<i class="bi bi-box-arrow-right me-1"></i>Logout
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="container-fluid mt-4">
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
|
||||||
|
{{ message }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
{% include 'billing/_billing_banner.html' %}
|
||||||
|
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
{% block extra_js %}{% endblock %}
|
||||||
|
|
||||||
|
{% if current_user.is_authenticated %}
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const FEED_URL = '{{ url_for("notifications.feed") }}';
|
||||||
|
const MARK_READ_BASE = '/notifications/';
|
||||||
|
const MARK_ALL_URL = '{{ url_for("notifications.mark_all_read") }}';
|
||||||
|
const CSRF_TOKEN = '{{ csrf_token() }}';
|
||||||
|
const POLL_INTERVAL = 60000; // 60 seconds
|
||||||
|
|
||||||
|
// ── Element refs — desktop bell (lg+) and mobile/tablet bell (<lg) ──
|
||||||
|
const badgeDesktop = document.getElementById('notif-count-badge');
|
||||||
|
const badgeMobile = document.getElementById('notif-count-badge-mobile');
|
||||||
|
const listDesktop = document.getElementById('notif-list');
|
||||||
|
const listMobile = document.querySelector('.notif-list-mobile');
|
||||||
|
|
||||||
|
// ── Update both badge instances ────────────────────────────────────────
|
||||||
|
function updateBadge(count) {
|
||||||
|
[badgeDesktop, badgeMobile].forEach(function(badge) {
|
||||||
|
if (!badge) return;
|
||||||
|
if (count > 0) {
|
||||||
|
badge.textContent = count > 99 ? '99+' : count;
|
||||||
|
badge.classList.remove('d-none');
|
||||||
|
} else {
|
||||||
|
badge.textContent = '';
|
||||||
|
badge.classList.add('d-none');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render notification items into a given container ───────────────────
|
||||||
|
function renderInto(container, notifications) {
|
||||||
|
if (!container) return;
|
||||||
|
if (!notifications.length) {
|
||||||
|
container.innerHTML = '<div class="notif-empty">'
|
||||||
|
+ '<i class="bi bi-check2-circle me-1"></i>You\'re all caught up!</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.innerHTML = notifications.map(function(n) {
|
||||||
|
return '<div class="d-block text-decoration-none text-dark notif-item px-3 py-2 border-bottom '
|
||||||
|
+ (n.is_read ? '' : 'unread') + '"'
|
||||||
|
+ ' data-notif-id="' + n.id + '"'
|
||||||
|
+ ' data-link="' + escapeAttr(n.link || '') + '">'
|
||||||
|
+ '<div class="notif-title">' + escapeHtml(n.title) + '</div>'
|
||||||
|
+ '<div class="notif-body">' + escapeHtml(n.body) + '</div>'
|
||||||
|
+ '<div class="notif-time">' + escapeHtml(n.created_at) + '</div>'
|
||||||
|
+ '</div>';
|
||||||
|
}).join('');
|
||||||
|
container.querySelectorAll('.notif-item').forEach(function(el) {
|
||||||
|
el.addEventListener('click', function() {
|
||||||
|
var id = this.dataset.notifId;
|
||||||
|
var link = this.dataset.link;
|
||||||
|
markRead(id, function() {
|
||||||
|
el.classList.remove('unread');
|
||||||
|
if (link) window.location.href = link;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNotifications(notifications) {
|
||||||
|
renderInto(listDesktop, notifications);
|
||||||
|
renderInto(listMobile, notifications);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
if (!str) return '';
|
||||||
|
return str.replace(/&/g,'&').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,503 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||||
|
<!-- iOS / iPadOS web app meta tags -->
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
|
<title>{% block title %}Janitorial QC System{% endblock %}</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
|
||||||
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700;800&display=swap">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/theme.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/ipad_responsive.css') }}">
|
||||||
|
{# theme_modern.css loads LAST so it wins over theme.css tokens #}
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/theme_modern.css') }}">
|
||||||
|
{# MT: per-tenant branding overrides, mirrored from layouts/classic.html.
|
||||||
|
Loaded AFTER theme_modern.css so a tenant's colours win over the modern
|
||||||
|
palette; the modern layout/structure is unaffected. #}
|
||||||
|
{% if tenant_branding %}
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bs-primary: {{ tenant_branding.primary_color or '#1a56db' }};
|
||||||
|
--bs-primary-rgb: {{ tenant_branding.primary_color|hex_to_rgb if tenant_branding.primary_color else '26,86,219' }};
|
||||||
|
--jqc-accent: {{ tenant_branding.accent_color or '#16a34a' }};
|
||||||
|
--jqc-brand: {{ tenant_branding.primary_color or '#1a56db' }};
|
||||||
|
}
|
||||||
|
.bg-primary { background-color: {{ tenant_branding.primary_color or '#1a56db' }} !important; }
|
||||||
|
.btn-primary { background-color: {{ tenant_branding.primary_color or '#1a56db' }} !important;
|
||||||
|
border-color: {{ tenant_branding.primary_color or '#1a56db' }} !important; }
|
||||||
|
</style>
|
||||||
|
{% endif %}
|
||||||
|
{% block extra_css %}{% endblock %}
|
||||||
|
<style>
|
||||||
|
/* ── Notification bell styles (shared with the classic layout) ── */
|
||||||
|
.notif-bell-wrapper { position: relative; }
|
||||||
|
.notif-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px; right: 2px;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
min-width: 16px; height: 16px; line-height: 16px;
|
||||||
|
padding: 0 4px; border-radius: 8px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.notif-dropdown {
|
||||||
|
width: 380px;
|
||||||
|
max-height: 520px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.notif-item {
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
transition: background 0.15s;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.notif-item.unread {
|
||||||
|
border-left-color: var(--jqc-brand);
|
||||||
|
background-color: #f0f6fa;
|
||||||
|
}
|
||||||
|
.notif-item:hover { background-color: #e9f0f8; }
|
||||||
|
.notif-title { font-size: 0.85rem; font-weight: 600; margin-bottom: 2px; }
|
||||||
|
.notif-body { font-size: 0.78rem; color: #555; white-space: normal; }
|
||||||
|
.notif-time { font-size: 0.7rem; color: #999; }
|
||||||
|
.notif-empty { padding: 24px; text-align: center; color: #aaa; font-size: 0.85rem; }
|
||||||
|
|
||||||
|
/* ── Shared list-page filter panel (modern tint) ── */
|
||||||
|
.filter-panel {
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid var(--jqc-border);
|
||||||
|
border-left: 4px solid var(--jqc-brand);
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
|
.filter-panel .filter-title {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: .82rem;
|
||||||
|
letter-spacing: .03em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--jqc-brand);
|
||||||
|
}
|
||||||
|
.filter-panel .form-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #3f4652;
|
||||||
|
}
|
||||||
|
.filter-panel .form-control,
|
||||||
|
.filter-panel .form-select {
|
||||||
|
border: 1.5px solid #cfd9e0;
|
||||||
|
background-color: #ffffff;
|
||||||
|
}
|
||||||
|
.filter-panel .form-control:focus,
|
||||||
|
.filter-panel .form-select:focus {
|
||||||
|
border-color: var(--jqc-brand);
|
||||||
|
box-shadow: 0 0 0 .18rem rgba(21, 95, 130, .20);
|
||||||
|
}
|
||||||
|
.filter-panel .form-control::placeholder { color: #9aa4b2; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="jqc-modern">
|
||||||
|
{% if current_user.is_authenticated %}
|
||||||
|
|
||||||
|
<!-- ══════════════════════════ TOP BAR ══════════════════════════ -->
|
||||||
|
<header class="jqc-topbar">
|
||||||
|
<button class="jqc-hamburger d-lg-none" type="button" id="jqcSidebarToggle" aria-label="Menu">
|
||||||
|
<i class="bi bi-list"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<a class="jqc-brand" href="{{ url_for('dashboard.index') }}">
|
||||||
|
{% if tenant_branding and tenant_branding.logo_url %}
|
||||||
|
<img src="{{ media_url(tenant_branding.logo_url) }}"
|
||||||
|
alt="{{ tenant_branding.display_name }}" class="jqc-brand-logo">
|
||||||
|
{% else %}
|
||||||
|
<span class="jqc-brand-mark">JQC</span>
|
||||||
|
{% endif %}
|
||||||
|
<span class="jqc-brand-sub">
|
||||||
|
{{ tenant_branding.display_name if tenant_branding else 'Janitorial QC' }}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{# Scoped to inspection ID only — the placeholder says so explicitly so
|
||||||
|
nobody types a facility name and assumes the search is broken. #}
|
||||||
|
<form class="jqc-search" method="GET" action="{{ url_for('inspections.index') }}" role="search">
|
||||||
|
<i class="bi bi-search"></i>
|
||||||
|
<input type="search" name="inspection_id" class="form-control" inputmode="numeric"
|
||||||
|
placeholder="Inspection # (e.g. 1423)" aria-label="Search by inspection number"
|
||||||
|
title="Search by inspection number">
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="jqc-topbar-actions">
|
||||||
|
<!-- ── Notification Bell ── -->
|
||||||
|
<div class="dropdown">
|
||||||
|
<a class="jqc-icon-btn position-relative notif-bell-wrapper"
|
||||||
|
href="#"
|
||||||
|
id="notifDropdown"
|
||||||
|
role="button"
|
||||||
|
data-bs-toggle="dropdown"
|
||||||
|
aria-expanded="false"
|
||||||
|
title="Notifications">
|
||||||
|
<i class="bi bi-bell"></i>
|
||||||
|
{% if unread_notification_count > 0 %}
|
||||||
|
<span class="badge bg-danger notif-badge" id="notif-count-badge">
|
||||||
|
{{ unread_notification_count if unread_notification_count <= 99 else '99+' }}
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-danger notif-badge d-none" id="notif-count-badge"></span>
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
<div class="dropdown-menu dropdown-menu-end notif-dropdown shadow" id="notif-dropdown-menu">
|
||||||
|
<div class="d-flex justify-content-between align-items-center px-3 py-2 border-bottom">
|
||||||
|
<span class="fw-semibold" style="font-size:.9rem;">Notifications</span>
|
||||||
|
<button class="btn btn-link btn-sm p-0 text-muted text-decoration-none"
|
||||||
|
id="mark-all-read-btn" style="font-size:.75rem;">
|
||||||
|
Mark all as read
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="notif-list">
|
||||||
|
<div class="notif-empty">Loading…</div>
|
||||||
|
</div>
|
||||||
|
<div class="border-top d-flex justify-content-between px-3 py-2" style="font-size:.8rem;">
|
||||||
|
<a href="{{ url_for('notifications.index') }}" class="text-decoration-none">
|
||||||
|
<i class="bi bi-list-ul me-1"></i>View all
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('notifications.preferences') }}" class="text-decoration-none text-muted">
|
||||||
|
<i class="bi bi-gear me-1"></i>Preferences
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── User avatar menu ── -->
|
||||||
|
<div class="dropdown">
|
||||||
|
<a class="jqc-avatar" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown"
|
||||||
|
title="{{ current_user.display_name }}">
|
||||||
|
{{ (current_user.display_name.split() | map('first') | join)[:2] | upper }}
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-end">
|
||||||
|
<li class="px-3 py-2 border-bottom">
|
||||||
|
<div class="fw-semibold" style="font-size:.9rem;">{{ current_user.display_name }}</div>
|
||||||
|
<div class="text-muted" style="font-size:.75rem;">{{ current_user.role_label }}</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item" href="{{ url_for('auth.profile') }}">
|
||||||
|
<i class="bi bi-person-circle me-1"></i>My Profile
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item" href="{{ url_for('notifications.preferences') }}">
|
||||||
|
<i class="bi bi-bell-slash me-1"></i>Notification Preferences
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{# The design A/B test is over — modern is THE design (Aug 2026).
|
||||||
|
The switcher and the vote tally are gone from this menu.
|
||||||
|
ui.switch_theme / ui.theme_votes still exist and still work
|
||||||
|
if visited directly, so nothing is stranded mid-request;
|
||||||
|
they are simply no longer offered. #}
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item" href="{{ url_for('auth.logout') }}">
|
||||||
|
<i class="bi bi-box-arrow-right me-1"></i>Logout
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- ══════════════════════════ SIDEBAR ══════════════════════════ -->
|
||||||
|
<aside class="jqc-sidebar" id="jqcSidebar">
|
||||||
|
<nav class="jqc-nav">
|
||||||
|
<a class="jqc-nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('dashboard.') }}"
|
||||||
|
href="{{ url_for('dashboard.index') }}">
|
||||||
|
<i class="bi bi-grid"></i><span>Dashboard</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a class="jqc-nav-link {{ 'active' if request.endpoint and (request.endpoint.startswith('inspections.') or request.endpoint.startswith('inspection_schedules.')) }}"
|
||||||
|
href="{{ url_for('inspections.index') }}">
|
||||||
|
<i class="bi bi-clipboard-check"></i><span>Inspections</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a class="jqc-nav-link {{ 'active' if request.endpoint and (request.endpoint.startswith('reports.') or request.endpoint.startswith('scheduled_reports.')) }}"
|
||||||
|
href="{{ url_for('reports.index') }}">
|
||||||
|
<i class="bi bi-bar-chart-fill"></i><span>Reports & Analytics</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a class="jqc-nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('issues.') and request.endpoint != 'issues.verification_queue' }}"
|
||||||
|
href="{{ url_for('issues.index') }}">
|
||||||
|
<i class="bi bi-exclamation-triangle"></i><span>Issues</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{% if current_user.role in ['admin', 'director', 'auditor'] %}
|
||||||
|
<a class="jqc-nav-link {{ 'active' if request.endpoint == 'issues.verification_queue' }}"
|
||||||
|
href="{{ url_for('issues.verification_queue') }}">
|
||||||
|
<i class="bi bi-patch-check"></i><span>Verify</span>
|
||||||
|
{% if pending_verification_count and pending_verification_count > 0 %}
|
||||||
|
<span class="jqc-nav-badge">{{ pending_verification_count }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if current_user.role in ['admin', 'director', 'project_manager', 'auditor'] %}
|
||||||
|
<a class="jqc-nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('projects.') }}"
|
||||||
|
href="{{ url_for('projects.index') }}">
|
||||||
|
<i class="bi bi-file-earmark-text"></i><span>Contract</span>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<a class="jqc-nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('facilities.') }}"
|
||||||
|
href="{{ url_for('facilities.list_facilities') }}">
|
||||||
|
<i class="bi bi-buildings"></i><span>Facility</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{% if current_user.role in ['admin', 'director'] %}
|
||||||
|
<a class="jqc-nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('templates.') }}"
|
||||||
|
href="{{ url_for('templates.index') }}">
|
||||||
|
<i class="bi bi-list-check"></i><span>Templates</span>
|
||||||
|
</a>
|
||||||
|
<a class="jqc-nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('customers.') }}"
|
||||||
|
href="{{ url_for('customers.index') }}">
|
||||||
|
<i class="bi bi-people"></i><span>Customer</span>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<a class="jqc-nav-link {{ 'active' if request.endpoint and (request.endpoint.startswith('support.') or request.endpoint == 'ui.support_center') }}"
|
||||||
|
href="{{ url_for('ui.support_center') }}">
|
||||||
|
<i class="bi bi-life-preserver"></i><span>Supports</span>
|
||||||
|
{% if open_support_tickets_count > 0 %}
|
||||||
|
<span class="jqc-nav-badge">{{ open_support_tickets_count }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{% if current_user.role == 'admin' %}
|
||||||
|
{% set admin_active = request.endpoint and (
|
||||||
|
request.endpoint.startswith('audit.')
|
||||||
|
or request.endpoint == 'auth.notification_matrix'
|
||||||
|
or request.endpoint.startswith('broadcast.')
|
||||||
|
or request.endpoint.startswith('devices.')
|
||||||
|
or request.endpoint.startswith('enrollment.')
|
||||||
|
or request.endpoint.startswith('tenant_settings.')
|
||||||
|
or (request.endpoint.startswith('auth.') and 'user' in request.endpoint)
|
||||||
|
) %}
|
||||||
|
<a class="jqc-nav-link {{ 'active' if admin_active }}" data-bs-toggle="collapse"
|
||||||
|
href="#jqcAdminMenu" role="button" aria-expanded="{{ 'true' if admin_active else 'false' }}">
|
||||||
|
<i class="bi bi-shield-lock"></i><span>Admin</span>
|
||||||
|
<i class="bi bi-chevron-down jqc-nav-caret"></i>
|
||||||
|
</a>
|
||||||
|
<div class="collapse {{ 'show' if admin_active }}" id="jqcAdminMenu">
|
||||||
|
<a class="jqc-nav-sublink {{ 'active' if request.endpoint and request.endpoint.startswith('auth.') and 'user' in request.endpoint }}"
|
||||||
|
href="{{ url_for('auth.list_users') }}">Users</a>
|
||||||
|
<a class="jqc-nav-sublink {{ 'active' if request.endpoint and request.endpoint.startswith('audit.') }}"
|
||||||
|
href="{{ url_for('audit.index') }}">Audit Trail</a>
|
||||||
|
<a class="jqc-nav-sublink {{ 'active' if request.endpoint == 'auth.notification_matrix' }}"
|
||||||
|
href="{{ url_for('auth.notification_matrix') }}">Notification Matrix</a>
|
||||||
|
<a class="jqc-nav-sublink {{ 'active' if request.endpoint and request.endpoint.startswith('broadcast.') }}"
|
||||||
|
href="{{ url_for('broadcast.index') }}">Broadcast</a>
|
||||||
|
<a class="jqc-nav-sublink {{ 'active' if request.endpoint and request.endpoint.startswith('devices.') }}"
|
||||||
|
href="{{ url_for('devices.index') }}">Devices</a>
|
||||||
|
{# The enrollment intake form is public (no login) and its
|
||||||
|
submissions are read here. Admin-only, same as ST. #}
|
||||||
|
<a class="jqc-nav-sublink {{ 'active' if request.endpoint and request.endpoint.startswith('enrollment.') }}"
|
||||||
|
href="{{ url_for('enrollment.admin_list') }}">Enrollment Forms</a>
|
||||||
|
<a class="jqc-nav-sublink {{ 'active' if request.endpoint and request.endpoint.startswith('tenant_settings.') }}"
|
||||||
|
href="{{ url_for('tenant_settings.branding') }}">Workspace Settings</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<a class="jqc-nav-link {{ 'active' if request.endpoint == 'ui.about' }}" href="{{ url_for('ui.about') }}">
|
||||||
|
<i class="bi bi-info-circle"></i><span>About Us</span>
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{# The design switcher was removed from the sidebar in phase50, when
|
||||||
|
modern became the default — it no longer belongs in the primary nav.
|
||||||
|
The same action still exists in the account menu (top right), so
|
||||||
|
anyone who needs the classic design can still get to it. #}
|
||||||
|
</aside>
|
||||||
|
<div class="jqc-sidebar-backdrop d-lg-none" id="jqcSidebarBackdrop"></div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- ══════════════════════════ MAIN ══════════════════════════ -->
|
||||||
|
<main class="{{ 'jqc-main' if current_user.is_authenticated else '' }}">
|
||||||
|
<div class="container-fluid {{ '' if current_user.is_authenticated else 'mt-4' }}">
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
|
||||||
|
{{ message }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
{# MT: the billing banner is layout chrome in layouts/classic.html and
|
||||||
|
must render here too — a user on the modern design must not miss a
|
||||||
|
suspension or dunning notice.
|
||||||
|
|
||||||
|
_quota_warning.html is deliberately NOT included here: it is a
|
||||||
|
per-form include (user_form, issue form, facility form,
|
||||||
|
inspection start), not layout chrome. Including it globally would
|
||||||
|
render it twice on exactly those pages. #}
|
||||||
|
{% include 'billing/_billing_banner.html' %}
|
||||||
|
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
{% block extra_js %}{% endblock %}
|
||||||
|
|
||||||
|
{% if current_user.is_authenticated %}
|
||||||
|
<script>
|
||||||
|
// ── Sidebar off-canvas toggle (mobile / tablet portrait) ──────────────
|
||||||
|
(function () {
|
||||||
|
var btn = document.getElementById('jqcSidebarToggle');
|
||||||
|
var sidebar = document.getElementById('jqcSidebar');
|
||||||
|
var backdrop = document.getElementById('jqcSidebarBackdrop');
|
||||||
|
if (!btn || !sidebar) return;
|
||||||
|
function close() {
|
||||||
|
sidebar.classList.remove('open');
|
||||||
|
if (backdrop) backdrop.classList.remove('show');
|
||||||
|
}
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
sidebar.classList.toggle('open');
|
||||||
|
if (backdrop) backdrop.classList.toggle('show');
|
||||||
|
});
|
||||||
|
if (backdrop) backdrop.addEventListener('click', close);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const FEED_URL = '{{ url_for("notifications.feed") }}';
|
||||||
|
const MARK_READ_BASE = '/notifications/';
|
||||||
|
const MARK_ALL_URL = '{{ url_for("notifications.mark_all_read") }}';
|
||||||
|
const CSRF_TOKEN = '{{ csrf_token() }}';
|
||||||
|
const POLL_INTERVAL = 60000; // 60 seconds
|
||||||
|
|
||||||
|
const badgeDesktop = document.getElementById('notif-count-badge');
|
||||||
|
const listDesktop = document.getElementById('notif-list');
|
||||||
|
|
||||||
|
function updateBadge(count) {
|
||||||
|
[badgeDesktop].forEach(function(badge) {
|
||||||
|
if (!badge) return;
|
||||||
|
if (count > 0) {
|
||||||
|
badge.textContent = count > 99 ? '99+' : count;
|
||||||
|
badge.classList.remove('d-none');
|
||||||
|
} else {
|
||||||
|
badge.textContent = '';
|
||||||
|
badge.classList.add('d-none');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderInto(container, notifications) {
|
||||||
|
if (!container) return;
|
||||||
|
if (!notifications.length) {
|
||||||
|
container.innerHTML = '<div class="notif-empty">'
|
||||||
|
+ '<i class="bi bi-check2-circle me-1"></i>You\'re all caught up!</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.innerHTML = notifications.map(function(n) {
|
||||||
|
return '<div class="d-block text-decoration-none text-dark notif-item px-3 py-2 border-bottom '
|
||||||
|
+ (n.is_read ? '' : 'unread') + '"'
|
||||||
|
+ ' data-notif-id="' + n.id + '"'
|
||||||
|
+ ' data-link="' + escapeAttr(n.link || '') + '">'
|
||||||
|
+ '<div class="notif-title">' + escapeHtml(n.title) + '</div>'
|
||||||
|
+ '<div class="notif-body">' + escapeHtml(n.body) + '</div>'
|
||||||
|
+ '<div class="notif-time">' + escapeHtml(n.created_at) + '</div>'
|
||||||
|
+ '</div>';
|
||||||
|
}).join('');
|
||||||
|
container.querySelectorAll('.notif-item').forEach(function(el) {
|
||||||
|
el.addEventListener('click', function() {
|
||||||
|
var id = this.dataset.notifId;
|
||||||
|
var link = this.dataset.link;
|
||||||
|
markRead(id, function() {
|
||||||
|
el.classList.remove('unread');
|
||||||
|
if (link) window.location.href = link;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNotifications(notifications) {
|
||||||
|
renderInto(listDesktop, notifications);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
if (!str) return '';
|
||||||
|
return str.replace(/&/g,'&').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,421 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Dashboard{% endblock %}
|
||||||
|
|
||||||
|
{#
|
||||||
|
MODERN dashboard (design A/B test — slide 1 of JQC_design).
|
||||||
|
|
||||||
|
Uses exactly the same context variables as templates/dashboard.html — the
|
||||||
|
dashboard.index route is untouched. Every tile links to the same filtered
|
||||||
|
list view the classic dashboard links to, so no navigation path is lost.
|
||||||
|
#}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
{# ── Header ───────────────────────────────────────────────────────────── #}
|
||||||
|
<div class="d-flex flex-wrap justify-content-between align-items-end mb-4 gap-2">
|
||||||
|
<div>
|
||||||
|
<div class="jqc-page-title">Welcome, {{ current_user.display_name }}</div>
|
||||||
|
<div class="jqc-page-sub">{{ current_user.role.replace('_',' ')|title }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-muted">{{ now_display }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Scheduled inspections ────────────────────────────────────────────── #}
|
||||||
|
{% if current_user.role != 'customer' %}
|
||||||
|
<div class="jqc-card">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<div class="jqc-card-title mb-0">
|
||||||
|
<span class="jqc-tile-icon"><i class="bi bi-calendar2-week"></i></span>Scheduled Inspection In Progress
|
||||||
|
</div>
|
||||||
|
<a href="{{ url_for('inspection_schedules.index') }}" class="btn btn-sm btn-outline-primary">View all</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if sched_overdue_count %}
|
||||||
|
<div class="alert alert-danger py-2">
|
||||||
|
<i class="bi bi-alarm-fill me-1"></i>
|
||||||
|
<strong>{{ sched_overdue_count }}</strong> scheduled inspection{{ 's' if sched_overdue_count != 1 }}
|
||||||
|
{{ 'are' if sched_overdue_count != 1 else 'is' }} <strong>overdue</strong>.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if sched_upcoming %}
|
||||||
|
<div class="jqc-table-wrap table-responsive">
|
||||||
|
<table class="table table-hover align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Facility</th><th>Inspection Template</th><th>Inspector</th>
|
||||||
|
<th>How Often</th><th>Next Due Date</th><th class="text-end"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for s in sched_upcoming %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ s.facility.name if s.facility else '—' }}</td>
|
||||||
|
<td class="small">{{ s.template.name if s.template else '—' }}</td>
|
||||||
|
<td class="small">{{ s.inspector.display_name if s.inspector else '—' }}</td>
|
||||||
|
<td class="small text-muted">{{ s.recurrence_label }}</td>
|
||||||
|
{# MT's column is next_run_at (ST calls it next_due_date) — via the
|
||||||
|
due_date property, guarded: an active schedule can carry a NULL
|
||||||
|
next_run_at, and .strftime() on Undefined/None is a 500. #}
|
||||||
|
<td class="small">{{ s.due_date.strftime('%b %d, %Y') if s.due_date else '—' }}</td>
|
||||||
|
<td class="text-end text-nowrap">
|
||||||
|
{% if s.inspector_id and s.inspector_id == current_user.id %}
|
||||||
|
{% if s.is_acknowledged %}
|
||||||
|
<span class="badge bg-success" title="You confirmed receipt"><i class="bi bi-check-circle"></i> Confirmed</span>
|
||||||
|
{% else %}
|
||||||
|
<form method="POST" class="d-inline" action="{{ url_for('inspection_schedules.acknowledge', schedule_id=s.id) }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-success py-0"
|
||||||
|
title="Confirm you received this request">
|
||||||
|
<i class="bi bi-check-lg"></i> Confirm</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% set open_id = sched_open_inspections.get(s.id) %}
|
||||||
|
{% if open_id %}
|
||||||
|
<a href="{{ url_for('inspections.execute', inspection_id=open_id) }}"
|
||||||
|
class="btn btn-sm btn-warning py-0" title="You already started this — resume it">
|
||||||
|
<i class="bi bi-pencil-square"></i> Continue</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="{{ url_for('inspection_schedules.start', schedule_id=s.id) }}"
|
||||||
|
class="btn btn-sm btn-success py-0"><i class="bi bi-play-fill"></i> Start</a>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-muted small"><i class="bi bi-info-circle me-1"></i>No inspections due in the next 7 days.</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# ── KPI row ──────────────────────────────────────────────────────────── #}
|
||||||
|
<div class="row row-cols-2 row-cols-lg-4 g-3 mb-4">
|
||||||
|
<div class="col">
|
||||||
|
<a class="jqc-kpi" href="{{ url_for('inspections.index', status='completed', date_from=today_str, date_to=today_str) }}">
|
||||||
|
<span class="jqc-tile-icon"><i class="bi bi-clipboard-check"></i></span>
|
||||||
|
<div class="jqc-kpi-value">{{ completed_today }}</div>
|
||||||
|
<div class="jqc-kpi-label">Submitted Today</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<a class="jqc-kpi" href="{{ url_for('inspections.index', status='completed', date_from=week_start_str, date_to=today_str) }}">
|
||||||
|
<span class="jqc-tile-icon"><i class="bi bi-calendar-week"></i></span>
|
||||||
|
<div class="jqc-kpi-value">{{ submitted_this_week }}</div>
|
||||||
|
<div class="jqc-kpi-label">Submitted This Week</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<a class="jqc-kpi" href="{{ url_for('issues.index', status='open') }}">
|
||||||
|
<span class="jqc-tile-icon"><i class="bi bi-exclamation-triangle"></i></span>
|
||||||
|
<div class="jqc-kpi-value">{{ open_issues }}</div>
|
||||||
|
<div class="jqc-kpi-label">Open Issues</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
{% if current_user.role != 'customer' %}
|
||||||
|
<a class="jqc-kpi" href="{{ url_for('inspection_schedules.index') }}">
|
||||||
|
<span class="jqc-tile-icon"><i class="bi bi-calendar2-check"></i></span>
|
||||||
|
<div class="jqc-kpi-value">{{ sched_total }}</div>
|
||||||
|
<div class="jqc-kpi-label">On Schedules</div>
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<a class="jqc-kpi" href="{{ url_for('facilities.list_facilities') }}">
|
||||||
|
<span class="jqc-tile-icon"><i class="bi bi-buildings"></i></span>
|
||||||
|
<div class="jqc-kpi-value">{{ customer_facilities|length if customer_facilities else 0 }}</div>
|
||||||
|
<div class="jqc-kpi-label">Your Facilities</div>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Three summary cards ──────────────────────────────────────────────── #}
|
||||||
|
<div class="row g-3 mb-2">
|
||||||
|
|
||||||
|
<!-- Inspection -->
|
||||||
|
<div class="col-12 col-lg-4">
|
||||||
|
<div class="jqc-card h-100">
|
||||||
|
<div class="jqc-card-title">
|
||||||
|
<span class="jqc-tile-icon"><i class="bi bi-clipboard-check"></i></span>Inspection
|
||||||
|
</div>
|
||||||
|
<a class="jqc-stat-row" href="{{ url_for('inspections.index', status='completed', date_from=today_str, date_to=today_str) }}">
|
||||||
|
<span class="jqc-stat-label">Submitted Today</span>
|
||||||
|
<span class="jqc-stat-value" style="color:var(--jqc-brand);">{{ completed_today }}</span>
|
||||||
|
</a>
|
||||||
|
<a class="jqc-stat-row" href="{{ url_for('inspections.index', status='completed', date_from=week_start_str, date_to=today_str) }}">
|
||||||
|
<span class="jqc-stat-label">Submitted This Week</span>
|
||||||
|
<span class="jqc-stat-value" style="color:var(--jqc-brand);">{{ submitted_this_week }}</span>
|
||||||
|
</a>
|
||||||
|
{% if current_user.role != 'customer' %}
|
||||||
|
<a class="jqc-stat-row" href="{{ url_for('inspections.index', status='in_progress') }}">
|
||||||
|
<span class="jqc-stat-label">
|
||||||
|
In Process
|
||||||
|
{% if stale_in_progress %}<span class="badge bg-warning text-dark">{{ stale_in_progress }} stale</span>{% endif %}
|
||||||
|
</span>
|
||||||
|
<span class="jqc-stat-value" style="color:#1B9AD1;">{{ in_progress_total }}</span>
|
||||||
|
</a>
|
||||||
|
<a class="jqc-stat-row" href="{{ url_for('inspections.index', status='follow_up') }}">
|
||||||
|
<span class="jqc-stat-label">Pending to follow up</span>
|
||||||
|
<span class="jqc-stat-value" style="color:#E0A800;">{{ pending_followups }}</span>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Open Issues -->
|
||||||
|
<div class="col-12 col-lg-4">
|
||||||
|
<div class="jqc-card h-100">
|
||||||
|
<div class="jqc-card-title">
|
||||||
|
<span class="jqc-tile-icon"><i class="bi bi-exclamation-triangle"></i></span>Open Issues
|
||||||
|
</div>
|
||||||
|
<a class="jqc-stat-row" href="{{ url_for('issues.index', status='open', handler_type='internal') }}">
|
||||||
|
<span class="jqc-stat-label"><span class="jqc-dot" style="background:#E8722C;"></span>Janitorial</span>
|
||||||
|
<span class="jqc-stat-value">{{ handler_breakdown.internal }}</span>
|
||||||
|
</a>
|
||||||
|
<a class="jqc-stat-row" href="{{ url_for('issues.index', status='open', handler_type='facility') }}">
|
||||||
|
<span class="jqc-stat-label"><span class="jqc-dot" style="background:#155F82;"></span>Facility Staff</span>
|
||||||
|
<span class="jqc-stat-value">{{ handler_breakdown.facility }}</span>
|
||||||
|
</a>
|
||||||
|
<a class="jqc-stat-row" href="{{ url_for('issues.index', status='open', handler_type='vendor') }}">
|
||||||
|
<span class="jqc-stat-label"><span class="jqc-dot" style="background:#25AEE4;"></span>Vendors</span>
|
||||||
|
<span class="jqc-stat-value">{{ handler_breakdown.vendor }}</span>
|
||||||
|
</a>
|
||||||
|
{% if current_user.role != 'customer' %}
|
||||||
|
<a class="jqc-stat-row" href="{{ url_for('issues.index', status='pending_verification') }}">
|
||||||
|
<span class="jqc-stat-label"><span class="jqc-dot" style="background:#E0A800;"></span>Pending Verification</span>
|
||||||
|
<span class="jqc-stat-value">{{ pending_verification }}</span>
|
||||||
|
</a>
|
||||||
|
<a class="jqc-stat-row" href="{{ url_for('issues.index', status='open', unassigned='1') }}">
|
||||||
|
<span class="jqc-stat-label"><span class="jqc-dot" style="background:#D9534F;"></span>Unassigned Issue</span>
|
||||||
|
<span class="jqc-stat-value">{{ unassigned_open }}</span>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SLA Issues -->
|
||||||
|
<div class="col-12 col-lg-4">
|
||||||
|
<div class="jqc-card h-100">
|
||||||
|
<div class="jqc-card-title">
|
||||||
|
<span class="jqc-tile-icon"><i class="bi bi-clock-history"></i></span>SLA Issues
|
||||||
|
</div>
|
||||||
|
<a class="jqc-stat-row" href="{{ url_for('issues.index', sla='breached') }}">
|
||||||
|
<span class="jqc-stat-label"><span class="jqc-dot" style="background:#D9534F;"></span>SLA Alert</span>
|
||||||
|
<span class="jqc-stat-value" style="color:#D9534F;">{{ sla_breached }}</span>
|
||||||
|
</a>
|
||||||
|
<a class="jqc-stat-row" href="{{ url_for('issues.index', sla='at_risk') }}">
|
||||||
|
<span class="jqc-stat-label"><span class="jqc-dot" style="background:#E0A800;"></span>SLA At Risk</span>
|
||||||
|
<span class="jqc-stat-value" style="color:#E0A800;">{{ sla_at_risk }}</span>
|
||||||
|
</a>
|
||||||
|
<a class="jqc-stat-row" href="{{ url_for('issues.index', date_from=today_str, date_to=today_str) }}">
|
||||||
|
<span class="jqc-stat-label"><span class="jqc-dot" style="background:#25AEE4;"></span>Issues Opened Today</span>
|
||||||
|
<span class="jqc-stat-value">{{ issues_opened_today }}</span>
|
||||||
|
</a>
|
||||||
|
<a class="jqc-stat-row" href="{{ url_for('issues.index', status='resolved', date_from=today_str, date_to=today_str) }}">
|
||||||
|
<span class="jqc-stat-label"><span class="jqc-dot" style="background:#2E7D4F;"></span>Resolved Today</span>
|
||||||
|
<span class="jqc-stat-value" style="color:#2E7D4F;">{{ resolved_today }}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Recent activity ──────────────────────────────────────────────────── #}
|
||||||
|
<div class="jqc-card">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<div class="jqc-card-title mb-0">
|
||||||
|
<span class="jqc-tile-icon"><i class="bi bi-clock-history"></i></span>Recent Activities
|
||||||
|
</div>
|
||||||
|
<a href="{{ url_for('inspections.index') }}" class="btn btn-sm btn-outline-primary">View all</a>
|
||||||
|
</div>
|
||||||
|
{% if recent_inspections %}
|
||||||
|
<div class="jqc-table-wrap table-responsive">
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Facility Name</th>
|
||||||
|
<th>Area</th>
|
||||||
|
{% if not current_user.is_inspector %}<th>Inspector</th>{% endif %}
|
||||||
|
<th>Score</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for insp in recent_inspections %}
|
||||||
|
{# The row stays click-anywhere for the mouse, but the date is a real
|
||||||
|
link so the row is keyboard-reachable and openable in a new tab.
|
||||||
|
The guard stops the row handler from double-firing on that link. #}
|
||||||
|
<tr style="cursor:pointer;"
|
||||||
|
onclick="if(!event.target.closest('a')) window.location='{{ url_for('inspections.view', inspection_id=insp.id) }}'">
|
||||||
|
<td>
|
||||||
|
<a href="{{ url_for('inspections.view', inspection_id=insp.id) }}"
|
||||||
|
class="text-decoration-none"><small>{{ insp.inspection_date.strftime('%b %d, %Y') }}</small></a>
|
||||||
|
</td>
|
||||||
|
<td>{{ insp.facility.name }}</td>
|
||||||
|
<td>{{ insp.area.name if insp.area else '—' }}</td>
|
||||||
|
{% if not current_user.is_inspector %}<td>{{ insp.inspector.display_name }}</td>{% endif %}
|
||||||
|
<td>
|
||||||
|
{% if insp.overall_score %}
|
||||||
|
<span class="badge bg-{% if insp.overall_score >= 90 %}success{% elif insp.overall_score >= 70 %}warning{% else %}danger{% endif %}">
|
||||||
|
{{ insp.overall_score }}%
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-{% if insp.status == 'completed' %}success{% elif insp.status == 'flagged' %}danger{% else %}secondary{% endif %}">
|
||||||
|
{{ 'Submitted' if insp.status == 'completed' else insp.status|title }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-4 text-muted">
|
||||||
|
<i class="bi bi-inbox fs-2 d-block mb-2"></i>No recent inspections.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── My open issues (inspector widget) ────────────────────────────────── #}
|
||||||
|
{% if my_issues %}
|
||||||
|
<div class="jqc-card">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<div class="jqc-card-title mb-0">
|
||||||
|
<span class="jqc-tile-icon"><i class="bi bi-person-check"></i></span>My Open Issues
|
||||||
|
</div>
|
||||||
|
<a href="{{ url_for('issues.index') }}" class="btn btn-sm btn-outline-primary">View all</a>
|
||||||
|
</div>
|
||||||
|
<div class="jqc-table-wrap table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:60px;">ID</th>
|
||||||
|
<th style="width:90px;">Severity</th>
|
||||||
|
<th>Facility / Description</th>
|
||||||
|
<th style="width:100px;">Status</th>
|
||||||
|
<th style="width:120px;">SLA</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for issue in my_issues %}
|
||||||
|
{% set sla = sla_status(issue) %}
|
||||||
|
<tr class="{{ 'table-danger' if sla == 'breached' else 'table-warning' if sla == 'at_risk' else '' }}">
|
||||||
|
<td>
|
||||||
|
<a href="{{ url_for('issues.view', issue_id=issue.id) }}" class="text-decoration-none fw-semibold">#{{ issue.id }}</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-{{ 'danger' if issue.severity in ['critical','high'] else 'warning text-dark' if issue.severity == 'medium' else 'secondary' }}">
|
||||||
|
{{ issue.severity|title }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div>{{ issue.resolved_facility.name if issue.resolved_facility else '—' }}</div>
|
||||||
|
<div class="text-muted small">{{ issue.description[:60] }}{% if issue.description|length > 60 %}…{% endif %}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-{{ 'warning text-dark' if issue.status == 'in_progress' else 'danger' }}">
|
||||||
|
{{ issue.status|replace('_',' ')|title }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if sla == 'breached' %}
|
||||||
|
<span class="badge bg-danger"><i class="bi bi-alarm me-1"></i>Breached</span>
|
||||||
|
{% elif sla == 'at_risk' %}
|
||||||
|
<span class="badge bg-warning text-dark"><i class="bi bi-hourglass-split me-1"></i>{{ sla_hours_remaining(issue)|abs|round(1) }}h left</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">OK</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# ── Customer portal: scoped facilities panel ─────────────────────────── #}
|
||||||
|
{% if current_user.role == 'customer' and customer_facilities %}
|
||||||
|
<div class="jqc-card">
|
||||||
|
<div class="jqc-card-title">
|
||||||
|
<span class="jqc-tile-icon"><i class="bi bi-building"></i></span>Your Facilities
|
||||||
|
<span class="badge bg-secondary rounded-pill ms-2">{{ customer_facilities|length }}</span>
|
||||||
|
</div>
|
||||||
|
{% if customer_facilities|length > 6 %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<input type="text" id="facilitySearch" class="form-control form-control-sm"
|
||||||
|
placeholder="Search facilities…" aria-label="Search facilities">
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="row g-3" id="facilityGrid">
|
||||||
|
{% for f in customer_facilities %}
|
||||||
|
<div class="col-12 col-sm-6 col-lg-4 facility-col">
|
||||||
|
<div class="border rounded-3 p-3 h-100 d-flex flex-column facility-card">
|
||||||
|
<div class="fw-semibold mb-1">{{ f.name }}</div>
|
||||||
|
<div class="text-muted" style="font-size:.82rem;">{{ f.address or '—' }}</div>
|
||||||
|
<div class="my-2">
|
||||||
|
<span class="badge bg-light text-dark border">{{ f.project.name if f.project else '—' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-auto pt-1">
|
||||||
|
<a href="{{ url_for('facilities.view_facility', facility_id=f.id) }}"
|
||||||
|
class="btn btn-sm btn-outline-primary"><i class="bi bi-eye"></i> View</a>
|
||||||
|
<a href="{{ url_for('reports.facility_report', facility_id=f.id) }}"
|
||||||
|
class="btn btn-sm btn-outline-secondary ms-1"><i class="bi bi-graph-up"></i> Report</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% if customer_facilities|length > 9 %}
|
||||||
|
<div id="facilityShowMore" class="text-center mt-3">
|
||||||
|
<button class="btn btn-sm btn-link text-muted" id="toggleFacilities">
|
||||||
|
Show all {{ customer_facilities|length }} facilities <i class="bi bi-chevron-down"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Same VISIBLE = 9 / search > 6 thresholds as the classic dashboard (rule 65). #}
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
{% if customer_facilities|length > 9 %}
|
||||||
|
var VISIBLE = 9;
|
||||||
|
var cols = document.querySelectorAll('#facilityGrid .facility-col');
|
||||||
|
var btn = document.getElementById('toggleFacilities');
|
||||||
|
var expanded = false;
|
||||||
|
|
||||||
|
cols.forEach(function (c, i) { if (i >= VISIBLE) c.style.display = 'none'; });
|
||||||
|
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
expanded = !expanded;
|
||||||
|
cols.forEach(function (c, i) {
|
||||||
|
if (i >= VISIBLE) c.style.display = expanded ? '' : 'none';
|
||||||
|
});
|
||||||
|
btn.innerHTML = expanded
|
||||||
|
? 'Show fewer <i class="bi bi-chevron-up"></i>'
|
||||||
|
: 'Show all {{ customer_facilities|length }} facilities <i class="bi bi-chevron-down"></i>';
|
||||||
|
});
|
||||||
|
{% endif %}
|
||||||
|
{% if customer_facilities|length > 6 %}
|
||||||
|
document.getElementById('facilitySearch').addEventListener('input', function () {
|
||||||
|
var q = this.value.toLowerCase();
|
||||||
|
document.querySelectorAll('#facilityGrid .facility-col').forEach(function (col) {
|
||||||
|
var match = col.querySelector('.facility-card').textContent.toLowerCase().includes(q);
|
||||||
|
col.style.display = match ? '' : 'none';
|
||||||
|
});
|
||||||
|
var more = document.getElementById('facilityShowMore');
|
||||||
|
if (more) more.style.display = this.value ? 'none' : '';
|
||||||
|
});
|
||||||
|
{% endif %}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Facilities{% endblock %}
|
||||||
|
|
||||||
|
{#
|
||||||
|
MODERN facilities page (design A/B test — slide 5 of JQC_design).
|
||||||
|
|
||||||
|
The four hub cards are new; everything below them is the original grouped
|
||||||
|
facility list, delete modal and JS, unchanged — no functionality removed.
|
||||||
|
#}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="jqc-page-head center">
|
||||||
|
<div class="jqc-page-title">Facilities</div>
|
||||||
|
<div class="jqc-page-sub text-center">Manage facility records, statistics and QR access</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-4 mb-4">
|
||||||
|
{% if not current_user.is_inspector %}
|
||||||
|
<div class="col-12 col-lg-6">
|
||||||
|
<a class="jqc-hub-card" href="{{ url_for('facilities.qr_print_all') }}">
|
||||||
|
<div class="d-flex gap-4 align-items-start">
|
||||||
|
<span class="jqc-tile-icon lg"><i class="bi bi-qr-code"></i></span>
|
||||||
|
<div>
|
||||||
|
<div class="jqc-hub-title">Print QR Code</div>
|
||||||
|
<div class="jqc-hub-text">Generate and print scannable QR codes for every facility entrance and asset.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="jqc-hub-open">Open →</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.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('inspection_schedules.index') }}" class="btn btn-outline-primary">
|
||||||
|
<i class="bi bi-calendar-check"></i> Scheduled
|
||||||
|
</a>
|
||||||
|
{% if current_user.role != 'customer' %}
|
||||||
|
<a href="{{ url_for('inspections.start') }}" class="btn btn-primary">
|
||||||
|
<i class="bi bi-plus-circle"></i> New Inspection
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Filters ──────────────────────────────────────────────────────────── #}
|
||||||
|
{# Layout: fields fill two rows on the left; the actions sit in a block on the
|
||||||
|
right that spans both rows — Filter full-height, Clear above Export PDF.
|
||||||
|
Below the md breakpoint the action block wraps underneath, full width. #}
|
||||||
|
<div class="jqc-filter-bar">
|
||||||
|
<form method="get">
|
||||||
|
<div class="d-flex flex-wrap gap-3 align-items-stretch">
|
||||||
|
|
||||||
|
{# ── Fields ──────────────────────────────────────────────────────── #}
|
||||||
|
<div class="flex-grow-1" style="min-width:min(100%, 620px);">
|
||||||
|
<div class="row g-2 align-items-end">
|
||||||
|
<div class="col-6 col-md-2">
|
||||||
|
<label class="form-label small mb-1">Inspection #</label>
|
||||||
|
<input type="number" name="inspection_id" class="form-control form-control-sm"
|
||||||
|
min="1" placeholder="ID" value="{{ inspection_id_filter }}">
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-3">
|
||||||
|
<label class="form-label small mb-1">Status</label>
|
||||||
|
<select name="status" class="form-select form-select-sm">
|
||||||
|
<option value="">All Statuses</option>
|
||||||
|
{% for s in ['in_progress','completed','flagged'] %}
|
||||||
|
<option value="{{ s }}" {% if status_filter == s %}selected{% endif %}>{{ 'Submitted' if s == 'completed' else s|replace('_',' ')|title }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
<option value="follow_up" {% if status_filter == 'follow_up' %}selected{% endif %}>Flagged Follow-up</option>
|
||||||
|
<option value="has_issues" {% if status_filter == 'has_issues' %}selected{% endif %}>Has Logged Issues</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-3">
|
||||||
|
<label class="form-label small mb-1">Contract</label>
|
||||||
|
<select name="contract_id" id="insp_filter_contract" class="form-select form-select-sm">
|
||||||
|
<option value="">All Contracts</option>
|
||||||
|
{% for p in projects %}
|
||||||
|
<option value="{{ p.id }}" {% if contract_filter == p.id|string %}selected{% endif %}>{{ p.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-4">
|
||||||
|
<label class="form-label small mb-1">Facility</label>
|
||||||
|
<select name="facility_id" id="insp_filter_facility" class="form-select form-select-sm">
|
||||||
|
<option value="">All Facilities</option>
|
||||||
|
{% for f in facilities %}
|
||||||
|
<option value="{{ f.id }}" {% if facility_filter == f.id|string %}selected{% endif %}>{{ f.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Second row. The score fields absorb the Inspector column's width when
|
||||||
|
the viewer is an inspector (they only ever see their own work, so the
|
||||||
|
dropdown is not rendered for them) — the row always totals 12. #}
|
||||||
|
<div class="row g-2 align-items-end mt-2">
|
||||||
|
{% if inspectors %}
|
||||||
|
<div class="col-12 col-md-4">
|
||||||
|
<label class="form-label small mb-1">Inspector</label>
|
||||||
|
<select name="inspector_id" class="form-select form-select-sm">
|
||||||
|
<option value="">All Inspectors</option>
|
||||||
|
{% for u in inspectors %}
|
||||||
|
<option value="{{ u.id }}" {% if inspector_filter == u.id|string %}selected{% endif %}>{{ u.display_name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="col-6 col-md-{{ 3 if inspectors else 4 }}">
|
||||||
|
<label class="form-label small mb-1">Date From</label>
|
||||||
|
<input type="date" name="date_from" class="form-control form-control-sm"
|
||||||
|
value="{{ date_from_filter }}">
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-{{ 3 if inspectors else 4 }}">
|
||||||
|
<label class="form-label small mb-1">Date To</label>
|
||||||
|
<input type="date" name="date_to" class="form-control form-control-sm"
|
||||||
|
value="{{ date_to_filter }}">
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-{{ 1 if inspectors else 2 }}">
|
||||||
|
<label class="form-label small mb-1">Min Score</label>
|
||||||
|
<input type="number" name="score_min" class="form-control form-control-sm"
|
||||||
|
min="0" max="100" placeholder="0" value="{{ score_min_filter }}">
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-{{ 1 if inspectors else 2 }}">
|
||||||
|
<label class="form-label small mb-1">Max Score</label>
|
||||||
|
<input type="number" name="score_max" class="form-control form-control-sm"
|
||||||
|
min="0" max="100" placeholder="100" value="{{ score_max_filter }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Actions — spans both field rows ─────────────────────────────── #}
|
||||||
|
{# mt-md-4 drops the block by roughly one label's height, so the buttons
|
||||||
|
line up with the first row's INPUTS rather than its labels — that is
|
||||||
|
what makes them shorter, since they still stretch to the bottom of the
|
||||||
|
second row. The margin is md-only; below that the block wraps
|
||||||
|
underneath the fields and needs its full width and natural height. #}
|
||||||
|
<div class="d-flex gap-2 align-items-stretch flex-grow-1 flex-md-grow-0 mt-md-4">
|
||||||
|
<button type="submit"
|
||||||
|
class="btn btn-sm btn-primary d-flex align-items-center justify-content-center flex-grow-1 flex-md-grow-0"
|
||||||
|
style="min-width:88px;">
|
||||||
|
<span><i class="bi bi-funnel"></i> Filter</span>
|
||||||
|
</button>
|
||||||
|
<div class="d-flex flex-column gap-2 flex-grow-1 flex-md-grow-0">
|
||||||
|
<a href="{{ url_for('inspections.index') }}"
|
||||||
|
class="btn btn-sm btn-outline-secondary d-flex align-items-center justify-content-center flex-grow-1"
|
||||||
|
style="min-width:104px;">Clear</a>
|
||||||
|
<a id="exportPdfBtn"
|
||||||
|
href="{{ url_for('inspections.export_list_pdf', **request.args) }}"
|
||||||
|
class="btn btn-sm btn-outline-danger d-flex align-items-center justify-content-center flex-grow-1 text-nowrap"
|
||||||
|
style="min-width:104px;">
|
||||||
|
<span><i class="bi bi-file-earmark-pdf"></i> Export PDF</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Results ──────────────────────────────────────────────────────────── #}
|
||||||
|
<div class="jqc-card">
|
||||||
|
{% if active_filters %}
|
||||||
|
<div class="text-muted small mb-2">
|
||||||
|
<i class="bi bi-funnel me-1"></i>
|
||||||
|
{{ inspections.total }} inspection{{ 's' if inspections.total != 1 }} match
|
||||||
|
{{ 'es' if inspections.total == 1 }} your filters
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if inspections.items %}
|
||||||
|
{% include 'partials/bulk_inspections_toolbar.html' %}
|
||||||
|
<div class="jqc-table-wrap table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:34px;">
|
||||||
|
<input type="checkbox" class="form-check-input bulk-check-all"
|
||||||
|
title="Select all on this page" aria-label="Select all">
|
||||||
|
</th>
|
||||||
|
<th>#</th><th>Date</th><th>Contract</th><th>Facility</th><th>Area</th>
|
||||||
|
<th>Template</th><th>Inspector</th><th>Score</th>
|
||||||
|
<th>Status</th><th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for ins in inspections.items %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<input type="checkbox" class="form-check-input bulk-check"
|
||||||
|
form="inspectionsBulkForm" name="inspection_ids" value="{{ ins.id }}"
|
||||||
|
aria-label="Select inspection #{{ ins.id }}">
|
||||||
|
</td>
|
||||||
|
<td><small class="text-muted">#{{ ins.id }}</small></td>
|
||||||
|
<td class="text-nowrap">{{ ins.inspection_date.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||||
|
<td><small>{{ ins.facility.project.name if ins.facility and ins.facility.project else '—' }}</small></td>
|
||||||
|
<td>{{ ins.facility.name }}</td>
|
||||||
|
<td>{% if ins.area %}{{ ins.area.name }}{% else %}<span class="text-muted">—</span>{% endif %}</td>
|
||||||
|
<td>
|
||||||
|
{{ ins.template.name }}
|
||||||
|
{% if ins.scheduled_inspection_id %}
|
||||||
|
<span class="badge bg-info text-dark ms-1" title="From a scheduled inspection">
|
||||||
|
<i class="bi bi-calendar-check"></i> Scheduled
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ ins.inspector.display_name }}</td>
|
||||||
|
<td>
|
||||||
|
{% if ins.overall_score %}
|
||||||
|
<span class="badge bg-{{ 'success' if ins.overall_score >= 90 else 'warning' if ins.overall_score >= 70 else 'danger' }}">
|
||||||
|
{{ ins.overall_score }}%
|
||||||
|
</span>
|
||||||
|
{% else %}<span class="text-muted">—</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-{{ 'success' if ins.status == 'completed' else 'danger' if ins.status == 'flagged' else 'secondary' }}">
|
||||||
|
{{ 'Submitted' if ins.status == 'completed' else ins.status|replace('_',' ')|title }}
|
||||||
|
</span>
|
||||||
|
{% if ins.status == 'in_progress' %}
|
||||||
|
{% set hours_open = ((now - ins.inspection_date).total_seconds() / 3600) %}
|
||||||
|
{% if hours_open > 24 %}
|
||||||
|
<span class="badge bg-warning text-dark ms-1" title="In progress for over 24 hours — may be stale">
|
||||||
|
<i class="bi bi-clock-history"></i> Stale
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% if ins.follow_up_required and not ins.follow_ups.count() %}
|
||||||
|
<span class="badge bg-danger ms-1" title="Follow-up re-inspection required">
|
||||||
|
<i class="bi bi-arrow-repeat"></i> Follow-up
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-nowrap">
|
||||||
|
{% if ins.status == 'in_progress' or ins.status == 'flagged' %}
|
||||||
|
<a href="{{ url_for('inspections.execute', inspection_id=ins.id, next=current_url()) }}" class="btn btn-sm btn-outline-primary insp-list-link">Continue</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="{{ url_for('inspections.view', inspection_id=ins.id, next=current_url()) }}" class="btn btn-sm btn-outline-secondary insp-list-link">View</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if current_user.role in ['admin', 'director'] %}
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-sm btn-outline-danger ms-1"
|
||||||
|
data-bs-toggle="modal"
|
||||||
|
data-bs-target="#deleteInspectionModal"
|
||||||
|
data-inspection-id="{{ ins.id }}"
|
||||||
|
data-inspection-label="{{ ins.template.name }} — {{ ins.facility.name }} ({{ ins.inspection_date.strftime('%Y-%m-%d') }})"
|
||||||
|
title="Delete inspection">
|
||||||
|
<i class="bi bi-trash3"></i>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Pagination #}
|
||||||
|
{% if inspections.pages > 1 %}
|
||||||
|
<div class="d-flex justify-content-center pt-3">
|
||||||
|
<nav><ul class="pagination pagination-sm mb-0">
|
||||||
|
{% for p in inspections.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
|
||||||
|
{% if p %}
|
||||||
|
<li class="page-item {{ 'active' if p == inspections.page }}">
|
||||||
|
<a class="page-link" href="{{ url_for('inspections.index', page=p, inspection_id=inspection_id_filter, status=status_filter, contract_id=contract_filter, facility_id=facility_filter, date_from=date_from_filter, date_to=date_to_filter, score_min=score_min_filter, score_max=score_max_filter, inspector_id=inspector_filter) }}">{{ p }}</a>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="page-item disabled"><span class="page-link">…</span></li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</ul></nav>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-5 text-muted">
|
||||||
|
<i class="bi bi-clipboard-x fs-2 d-block mb-2"></i>
|
||||||
|
No inspections found.
|
||||||
|
<div class="mt-2">
|
||||||
|
<a href="{{ url_for('inspections.index') }}" class="btn btn-sm btn-outline-secondary">Clear filters</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if current_user.role in ['admin', 'director'] %}
|
||||||
|
<!-- Delete Inspection Confirmation Modal -->
|
||||||
|
<div class="modal fade" id="deleteInspectionModal" tabindex="-1" aria-labelledby="deleteInspectionModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header bg-danger text-white">
|
||||||
|
<h5 class="modal-title" id="deleteInspectionModalLabel">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill"></i> Confirm Deletion
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p>You are about to permanently delete the following inspection:</p>
|
||||||
|
<p class="fw-bold" id="deleteInspectionLabel"></p>
|
||||||
|
<p class="text-muted mb-0">This will also remove all associated results, flagged issues, and uploaded photos. This action is <strong>irreversible</strong>.</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||||||
|
<i class="bi bi-x-circle"></i> Cancel
|
||||||
|
</button>
|
||||||
|
<form id="deleteInspectionForm" method="POST" action="" class="d-inline">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<input type="hidden" name="next" value="{{ current_url() }}">
|
||||||
|
<button type="submit" class="btn btn-danger">
|
||||||
|
<i class="bi bi-trash3-fill"></i> Delete Permanently
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
{% include 'partials/bulk_select_js.html' %}
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
// Save current filtered URL so view/execute pages can restore it on Back
|
||||||
|
var links = document.querySelectorAll('.insp-list-link');
|
||||||
|
links.forEach(function (a) {
|
||||||
|
a.addEventListener('click', function () {
|
||||||
|
sessionStorage.setItem('insp_list_back_url', window.location.href);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}());
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
var contractSel = document.getElementById('insp_filter_contract');
|
||||||
|
var facilitySel = document.getElementById('insp_filter_facility');
|
||||||
|
if (!contractSel || !facilitySel) return;
|
||||||
|
|
||||||
|
var FACILITIES_URL = '{{ url_for("inspections.facilities_for_project", project_id=0) }}'.replace('/0', '/');
|
||||||
|
|
||||||
|
contractSel.addEventListener('change', function () {
|
||||||
|
var projectId = this.value;
|
||||||
|
facilitySel.value = '';
|
||||||
|
if (!projectId) {
|
||||||
|
facilitySel.innerHTML = '<option value="">All Facilities</option>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
facilitySel.disabled = true;
|
||||||
|
facilitySel.innerHTML = '<option value="">Loading…</option>';
|
||||||
|
fetch(FACILITIES_URL + projectId)
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (data) {
|
||||||
|
var html = '<option value="">All Facilities</option>';
|
||||||
|
data.forEach(function (f) {
|
||||||
|
html += '<option value="' + f.id + '">' + f.name + '</option>';
|
||||||
|
});
|
||||||
|
facilitySel.innerHTML = html;
|
||||||
|
facilitySel.disabled = false;
|
||||||
|
})
|
||||||
|
.catch(function () { facilitySel.disabled = false; });
|
||||||
|
});
|
||||||
|
}());
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% if current_user.role in ['admin', 'director'] %}
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
const modal = document.getElementById('deleteInspectionModal');
|
||||||
|
modal.addEventListener('show.bs.modal', function (event) {
|
||||||
|
const btn = event.relatedTarget;
|
||||||
|
const id = btn.getAttribute('data-inspection-id');
|
||||||
|
const label = btn.getAttribute('data-inspection-label');
|
||||||
|
document.getElementById('deleteInspectionLabel').textContent = label;
|
||||||
|
document.getElementById('deleteInspectionForm').action = '/inspections/' + id + '/delete';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,420 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Issues{% endblock %}
|
||||||
|
|
||||||
|
{#
|
||||||
|
MODERN issues list (design A/B test).
|
||||||
|
|
||||||
|
Same context variables, query params, form field names and JS as
|
||||||
|
templates/issues/list.html — only the chrome differs. Every filter, column,
|
||||||
|
badge, the quick-assign control, follow/unfollow, delete and the pagination
|
||||||
|
links are carried over verbatim.
|
||||||
|
#}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
{# Any non-empty query param other than the page number means the user has
|
||||||
|
actually filtered — used to show the match count only when it is meaningful. #}
|
||||||
|
{% set active_filters = [] %}
|
||||||
|
{% for _k, _v in request.args.items() %}
|
||||||
|
{% if _k != 'page' and _v %}{% set _ = active_filters.append(_k) %}{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{# ── Header ───────────────────────────────────────────────────────────── #}
|
||||||
|
<div class="d-flex flex-wrap justify-content-between align-items-end mb-4 gap-2">
|
||||||
|
<div>
|
||||||
|
<div class="jqc-page-title">Issues</div>
|
||||||
|
</div>
|
||||||
|
{% if current_user.role in ['admin','director','customer','auditor'] %}
|
||||||
|
<a href="{{ url_for('issues.create') }}" class="btn btn-danger">
|
||||||
|
<i class="bi bi-plus-circle"></i> Log Issue
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Filters ──────────────────────────────────────────────────────────── #}
|
||||||
|
{# Same layout as the modern inspections list: fields fill two rows on the left,
|
||||||
|
actions in a block on the right spanning both — Filter full-height, Clear
|
||||||
|
above Export PDF. Contract and Facility stay adjacent because they cascade
|
||||||
|
(rule 61). Below md the action block wraps underneath, full width. #}
|
||||||
|
<div class="jqc-filter-bar">
|
||||||
|
<form method="get">
|
||||||
|
<div class="d-flex flex-wrap gap-3 align-items-stretch">
|
||||||
|
|
||||||
|
{# ── Fields ──────────────────────────────────────────────────────── #}
|
||||||
|
<div class="flex-grow-1" style="min-width:min(100%, 620px);">
|
||||||
|
<div class="row g-2 align-items-end">
|
||||||
|
<div class="col-6 col-md-2">
|
||||||
|
<label class="form-label small mb-1">Issue #</label>
|
||||||
|
<input type="number" name="issue_id" class="form-control form-control-sm"
|
||||||
|
min="1" placeholder="ID" value="{{ issue_id_filter }}">
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-2">
|
||||||
|
<label class="form-label small mb-1">Severity</label>
|
||||||
|
<select name="severity" class="form-select form-select-sm">
|
||||||
|
<option value="">All</option>
|
||||||
|
{% for s in ['critical','high','medium','low'] %}
|
||||||
|
<option value="{{ s }}" {{ 'selected' if severity_filter == s }}>{{ s|title }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-2">
|
||||||
|
<label class="form-label small mb-1">Status</label>
|
||||||
|
<select name="status" class="form-select form-select-sm">
|
||||||
|
<option value="">All</option>
|
||||||
|
{% for s in ['open','in_progress','pending_verification','resolved'] %}
|
||||||
|
<option value="{{ s }}" {{ 'selected' if status_filter == s }}>{{ s|replace('_',' ')|title }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-3">
|
||||||
|
<label class="form-label small mb-1">Contract</label>
|
||||||
|
<select name="contract_id" id="filter_contract_id" class="form-select form-select-sm">
|
||||||
|
<option value="">All Contracts</option>
|
||||||
|
{% for p in projects %}
|
||||||
|
<option value="{{ p.id }}" {{ 'selected' if contract_filter == p.id|string }}>{{ p.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-3">
|
||||||
|
<label class="form-label small mb-1">Facility</label>
|
||||||
|
<select name="facility_id" id="filter_facility_id" class="form-select form-select-sm">
|
||||||
|
<option value="">All Facilities</option>
|
||||||
|
{% for f in facilities %}
|
||||||
|
<option value="{{ f.id }}" {{ 'selected' if facility_filter == f.id|string }}>{{ f.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-2 align-items-end mt-2">
|
||||||
|
<div class="col-6 col-md-2">
|
||||||
|
<label class="form-label small mb-1">SLA</label>
|
||||||
|
<select name="sla" class="form-select form-select-sm">
|
||||||
|
<option value="">All</option>
|
||||||
|
<option value="breached" {{ 'selected' if sla_filter == 'breached' }}>Breached</option>
|
||||||
|
<option value="at_risk" {{ 'selected' if sla_filter == 'at_risk' }}>At Risk</option>
|
||||||
|
<option value="ok" {{ 'selected' if sla_filter == 'ok' }}>OK</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-3">
|
||||||
|
<label class="form-label small mb-1">Reported From</label>
|
||||||
|
<input type="date" name="date_from" class="form-control form-control-sm"
|
||||||
|
value="{{ date_from_filter }}">
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-3">
|
||||||
|
<label class="form-label small mb-1">Reported To</label>
|
||||||
|
<input type="date" name="date_to" class="form-control form-control-sm"
|
||||||
|
value="{{ date_to_filter }}">
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-2">
|
||||||
|
<label class="form-label small mb-1">Reporter</label>
|
||||||
|
<select name="reporter_id" class="form-select form-select-sm">
|
||||||
|
<option value="">All Reporters</option>
|
||||||
|
{% for u in reporters %}
|
||||||
|
<option value="{{ u.id }}" {{ 'selected' if reporter_filter == u.id|string }}>{{ u.display_name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-2">
|
||||||
|
<label class="form-label small mb-1">Handled By</label>
|
||||||
|
<select name="handler_type" class="form-select form-select-sm">
|
||||||
|
<option value="">All Handlers</option>
|
||||||
|
<option value="internal" {{ 'selected' if handler_type_filter == 'internal' }}>Janitorial Staff</option>
|
||||||
|
<option value="facility" {{ 'selected' if handler_type_filter == 'facility' }}>Facility Staff</option>
|
||||||
|
<option value="vendor" {{ 'selected' if handler_type_filter == 'vendor' }}>External Vendor</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Actions — spans both field rows ─────────────────────────────── #}
|
||||||
|
{# mt-md-4 drops the block by roughly one label's height, so the buttons
|
||||||
|
line up with the first row's INPUTS rather than its labels — that is
|
||||||
|
what makes them shorter, since they still stretch to the bottom of the
|
||||||
|
second row. The margin is md-only; below that the block wraps
|
||||||
|
underneath the fields and needs its full width and natural height. #}
|
||||||
|
<div class="d-flex gap-2 align-items-stretch flex-grow-1 flex-md-grow-0 mt-md-4">
|
||||||
|
<button type="submit"
|
||||||
|
class="btn btn-sm btn-primary d-flex align-items-center justify-content-center flex-grow-1 flex-md-grow-0"
|
||||||
|
style="min-width:88px;">
|
||||||
|
<span><i class="bi bi-funnel"></i> Filter</span>
|
||||||
|
</button>
|
||||||
|
<div class="d-flex flex-column gap-2 flex-grow-1 flex-md-grow-0">
|
||||||
|
<a href="{{ url_for('issues.index') }}"
|
||||||
|
class="btn btn-sm btn-outline-secondary d-flex align-items-center justify-content-center flex-grow-1"
|
||||||
|
style="min-width:104px;">Clear</a>
|
||||||
|
<a href="{{ url_for('issues.export_list_pdf', **request.args) }}"
|
||||||
|
class="btn btn-sm btn-outline-danger d-flex align-items-center justify-content-center flex-grow-1 text-nowrap"
|
||||||
|
style="min-width:104px;">
|
||||||
|
<span><i class="bi bi-file-earmark-pdf"></i> Export PDF</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Results ──────────────────────────────────────────────────────────── #}
|
||||||
|
<div class="jqc-card">
|
||||||
|
{% if active_filters %}
|
||||||
|
<div class="text-muted small mb-2">
|
||||||
|
<i class="bi bi-funnel me-1"></i>
|
||||||
|
{{ issues.total }} issue{{ 's' if issues.total != 1 }} match
|
||||||
|
{{ 'es' if issues.total == 1 }} your filters
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if issues.items %}
|
||||||
|
{% include 'partials/bulk_issues_toolbar.html' %}
|
||||||
|
<div class="jqc-table-wrap table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:34px;">
|
||||||
|
<input type="checkbox" class="form-check-input bulk-check-all"
|
||||||
|
title="Select all on this page" aria-label="Select all">
|
||||||
|
</th>
|
||||||
|
<th>#</th>
|
||||||
|
<th>Reported</th>
|
||||||
|
<th>Severity</th>
|
||||||
|
<th>Contract</th>
|
||||||
|
<th>Facility / Area</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>SLA</th>
|
||||||
|
<th>Reporter</th>
|
||||||
|
<th>Assigned</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for issue in issues.items %}
|
||||||
|
{% set is_following = issue.id in followed_ids %}
|
||||||
|
{% set sla = sla_status(issue) %}
|
||||||
|
<tr class="{{ 'table-danger' if sla == 'breached' else 'table-warning' if sla == 'at_risk' else '' }}">
|
||||||
|
<td>
|
||||||
|
<input type="checkbox" class="form-check-input bulk-check"
|
||||||
|
form="issuesBulkForm" name="issue_ids" value="{{ issue.id }}"
|
||||||
|
aria-label="Select issue #{{ issue.id }}">
|
||||||
|
</td>
|
||||||
|
<td><small class="text-muted">#{{ issue.id }}</small></td>
|
||||||
|
<td class="text-nowrap"><small>{{ issue.reported_at.strftime('%Y-%m-%d %H:%M') }}</small></td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-{{ 'danger' if issue.severity in ['critical','high'] else 'warning text-dark' if issue.severity == 'medium' else 'secondary' }}">
|
||||||
|
{{ issue.severity|title }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% set _c = issue.resolved_facility.project if issue.resolved_facility else none %}
|
||||||
|
<small>{{ _c.name if _c else '—' }}</small>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ issue.resolved_facility.name if issue.resolved_facility else '—' }}<br>
|
||||||
|
<small class="text-muted">{{ issue.area.name if issue.area else '—' }}</small>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span{% if issue.description|length > 60 %} title="{{ issue.description }}" style="cursor:help;"{% endif %}>
|
||||||
|
{{ issue.description[:60] }}{% if issue.description|length > 60 %}…{% endif %}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-{{ 'success' if issue.status == 'resolved' else 'info text-dark' if issue.status == 'pending_verification' else 'warning text-dark' if issue.status == 'in_progress' else 'danger' }}">
|
||||||
|
{{ issue.status|replace('_',' ')|title }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if sla == 'breached' %}
|
||||||
|
<span class="badge bg-danger" title="SLA deadline has passed"><i class="bi bi-alarm me-1"></i>Breached</span>
|
||||||
|
{% elif sla == 'at_risk' %}
|
||||||
|
{% set hrs = sla_hours_remaining(issue) %}
|
||||||
|
<span class="badge bg-warning text-dark" title="Over 75% of SLA window elapsed"><i class="bi bi-hourglass-split me-1"></i>{{ hrs|abs|round(1) }}h left</span>
|
||||||
|
{% elif sla == 'ok' %}
|
||||||
|
<span class="badge bg-secondary">OK</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted small">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if issue.reporter %}
|
||||||
|
<small>{{ issue.reporter.display_name }}</small>
|
||||||
|
{% else %}<span class="text-muted">—</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if current_user.role in ['admin', 'director', 'auditor'] and issue.status != 'resolved' %}
|
||||||
|
<div class="d-flex align-items-center gap-1 quick-assign-wrap" data-issue-id="{{ issue.id }}">
|
||||||
|
<select class="form-select form-select-sm quick-assign-select" style="min-width:110px;font-size:.78rem;">
|
||||||
|
<option value="">— Unassigned —</option>
|
||||||
|
{% for u in staff %}
|
||||||
|
<option value="{{ u.id }}" {{ 'selected' if issue.assigned_to == u.id }}>{{ u.display_name }}{{ ' (Customer)' if u.is_external_inspector }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<span class="quick-assign-spinner spinner-border spinner-border-sm text-secondary d-none" role="status"></span>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
{% if issue.assigned_user %}{{ issue.assigned_user.display_name }}
|
||||||
|
{% else %}<span class="text-muted">—</span>{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% if issue.handler_type == 'facility' %}
|
||||||
|
<div><span class="badge bg-info text-dark mt-1" title="Handled by facility staff"><i class="bi bi-building"></i> Facility</span></div>
|
||||||
|
{% elif issue.handler_type == 'vendor' %}
|
||||||
|
<div><span class="badge bg-warning text-dark mt-1" title="Handled by external vendor"><i class="bi bi-person-gear"></i> Vendor</span></div>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-nowrap">
|
||||||
|
{# Following badge + inline unfollow #}
|
||||||
|
{% if is_following %}
|
||||||
|
<span class="badge bg-primary me-1" title="You are following this issue">
|
||||||
|
<i class="bi bi-bell-fill"></i> Following
|
||||||
|
</span>
|
||||||
|
<form method="post"
|
||||||
|
action="{{ url_for('issues.unfollow', issue_id=issue.id) }}"
|
||||||
|
class="d-inline"
|
||||||
|
title="Unfollow this issue">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<input type="hidden" name="next" value="{{ current_url() }}">
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-primary p-0 px-1 me-1"
|
||||||
|
title="Unfollow">
|
||||||
|
<i class="bi bi-bell-slash" style="font-size:.75rem;"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<a href="{{ url_for('issues.view', issue_id=issue.id, next=current_url()) }}"
|
||||||
|
class="btn btn-sm btn-outline-secondary">
|
||||||
|
{% if current_user.role in ['admin','director','auditor'] or issue.assigned_to == current_user.id %}
|
||||||
|
<i class="bi bi-pencil"></i> Edit
|
||||||
|
{% else %}
|
||||||
|
<i class="bi bi-eye"></i> View
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
{% if current_user.role in ['admin', 'director'] %}
|
||||||
|
<form method="POST" action="{{ url_for('issues.delete', issue_id=issue.id) }}"
|
||||||
|
class="d-inline"
|
||||||
|
onsubmit="return confirm('Permanently delete Issue #{{ issue.id }}? This cannot be undone.');">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<input type="hidden" name="next" value="{{ current_url() }}">
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-danger"
|
||||||
|
title="Delete Issue #{{ issue.id }}">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if issues.pages > 1 %}
|
||||||
|
<div class="d-flex justify-content-center pt-3">
|
||||||
|
<nav><ul class="pagination pagination-sm mb-0">
|
||||||
|
{% for p in issues.iter_pages(left_edge=1,right_edge=1,left_current=2,right_current=2) %}
|
||||||
|
{% if p %}
|
||||||
|
<li class="page-item {{ 'active' if p == issues.page }}">
|
||||||
|
<a class="page-link"
|
||||||
|
href="{{ url_for('issues.index', page=p, issue_id=issue_id_filter, severity=severity_filter, status=status_filter, sla=sla_filter, contract_id=contract_filter, facility_id=facility_filter, date_from=date_from_filter, date_to=date_to_filter, reporter_id=reporter_filter, handler_type=handler_type_filter, unassigned=unassigned_filter) }}">{{ p }}</a>
|
||||||
|
</li>
|
||||||
|
{% else %}<li class="page-item disabled"><span class="page-link">…</span></li>{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</ul></nav>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-5 text-muted">
|
||||||
|
<i class="bi bi-check2-circle fs-2 d-block mb-2"></i>
|
||||||
|
No issues found.
|
||||||
|
<div class="mt-2">
|
||||||
|
<a href="{{ url_for('issues.index') }}" class="btn btn-sm btn-outline-secondary">Clear filters</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
{% include 'partials/bulk_select_js.html' %}
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
var contractSel = document.getElementById('filter_contract_id');
|
||||||
|
var facilitySel = document.getElementById('filter_facility_id');
|
||||||
|
if (!contractSel || !facilitySel) return;
|
||||||
|
|
||||||
|
var FACILITIES_URL = '{{ url_for("inspections.facilities_for_project", project_id=0) }}'.replace('/0', '/');
|
||||||
|
|
||||||
|
contractSel.addEventListener('change', function () {
|
||||||
|
var projectId = this.value;
|
||||||
|
facilitySel.value = ''; // reset facility selection
|
||||||
|
if (!projectId) {
|
||||||
|
// No contract selected — restore all-facilities placeholder and submit
|
||||||
|
// (server will return unfiltered facility list)
|
||||||
|
facilitySel.innerHTML = '<option value="">All Facilities</option>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
facilitySel.disabled = true;
|
||||||
|
facilitySel.innerHTML = '<option value="">Loading…</option>';
|
||||||
|
fetch(FACILITIES_URL + projectId)
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (data) {
|
||||||
|
var html = '<option value="">All Facilities</option>';
|
||||||
|
data.forEach(function (f) {
|
||||||
|
html += '<option value="' + f.id + '">' + f.name + '</option>';
|
||||||
|
});
|
||||||
|
facilitySel.innerHTML = html;
|
||||||
|
facilitySel.disabled = false;
|
||||||
|
})
|
||||||
|
.catch(function () { facilitySel.disabled = false; });
|
||||||
|
});
|
||||||
|
}());
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% if current_user.role in ['admin', 'director', 'auditor'] %}
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
document.querySelectorAll('.quick-assign-select').forEach(function (sel) {
|
||||||
|
sel.dataset.previous = sel.value;
|
||||||
|
|
||||||
|
sel.addEventListener('change', function () {
|
||||||
|
const wrap = sel.closest('.quick-assign-wrap');
|
||||||
|
const issueId = wrap.dataset.issueId;
|
||||||
|
const spinner = wrap.querySelector('.quick-assign-spinner');
|
||||||
|
const userId = sel.value || null;
|
||||||
|
|
||||||
|
sel.disabled = true;
|
||||||
|
spinner.classList.remove('d-none');
|
||||||
|
|
||||||
|
fetch('/issues/' + issueId + '/quick-assign', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRFToken': '{{ csrf_token() }}',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ user_id: userId ? parseInt(userId) : null }),
|
||||||
|
})
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (data) {
|
||||||
|
if (!data.ok) {
|
||||||
|
alert('Assignment failed: ' + (data.error || 'Unknown error'));
|
||||||
|
sel.value = sel.dataset.previous;
|
||||||
|
} else {
|
||||||
|
sel.dataset.previous = sel.value;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
alert('Network error — assignment not saved.');
|
||||||
|
sel.value = sel.dataset.previous;
|
||||||
|
})
|
||||||
|
.finally(function () {
|
||||||
|
sel.disabled = false;
|
||||||
|
spinner.classList.add('d-none');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}());
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
{# ── Bulk-action toolbar for the inspections list ─────────────────────────────
|
||||||
|
Included by BOTH inspections/list.html and modern/inspections/list.html —
|
||||||
|
edit here, not in either copy.
|
||||||
|
|
||||||
|
Same structure as the issues toolbar: the form sits OUTSIDE the table and
|
||||||
|
row checkboxes join it via the HTML5 `form` attribute, so the per-row
|
||||||
|
delete form inside the table is never nested (rule 9).
|
||||||
|
|
||||||
|
Export is offered to anyone who can see the list — it is read-only and the
|
||||||
|
route re-applies the viewer's facility scope to the submitted ids. The three
|
||||||
|
mutating actions are admin/director only.
|
||||||
|
#}
|
||||||
|
{% set can_manage = current_user.role in ['admin', 'director'] %}
|
||||||
|
<form method="POST" id="inspectionsBulkForm"
|
||||||
|
action="{{ url_for('inspections.bulk_action') }}"
|
||||||
|
class="border-bottom bg-light px-3 py-2">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<input type="hidden" name="next" value="{{ current_url() }}">
|
||||||
|
|
||||||
|
<div class="d-flex flex-wrap align-items-center gap-2">
|
||||||
|
<span class="small fw-semibold text-nowrap">
|
||||||
|
<span class="bulk-count">0</span> selected
|
||||||
|
</span>
|
||||||
|
<span class="text-muted small d-none d-md-inline">|</span>
|
||||||
|
|
||||||
|
<button type="submit" name="action" value="export"
|
||||||
|
class="btn btn-sm btn-outline-secondary text-nowrap" data-bulk-action>
|
||||||
|
<i class="bi bi-file-earmark-pdf"></i> Export Selected
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{% if can_manage %}
|
||||||
|
<div class="d-flex align-items-center gap-1">
|
||||||
|
<input type="text" name="follow_up_note" class="form-control form-control-sm"
|
||||||
|
style="min-width:180px;font-size:.8rem;"
|
||||||
|
placeholder="Follow-up note (optional)"
|
||||||
|
aria-label="Follow-up note applied to all selected">
|
||||||
|
<button type="submit" name="action" value="flag_followup"
|
||||||
|
class="btn btn-sm btn-outline-warning text-nowrap" data-bulk-action
|
||||||
|
data-bulk-confirm="Request a follow-up on the selected inspections? Ones not yet submitted, or already flagged, are skipped.">
|
||||||
|
<i class="bi bi-flag"></i> Request Follow-up
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" name="action" value="clear_followup"
|
||||||
|
class="btn btn-sm btn-outline-success text-nowrap" data-bulk-action
|
||||||
|
data-bulk-confirm="Clear the follow-up flag on the selected inspections?">
|
||||||
|
<i class="bi bi-flag-fill"></i> Clear Follow-up
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button type="submit" name="action" value="delete"
|
||||||
|
class="btn btn-sm btn-outline-danger text-nowrap ms-auto" data-bulk-action
|
||||||
|
data-bulk-confirm="Permanently delete the selected inspections and their photos? This cannot be undone.">
|
||||||
|
<i class="bi bi-trash"></i> Delete
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
{# ── Bulk-action toolbar for the issues list ──────────────────────────────────
|
||||||
|
Included by BOTH issues/list.html and modern/issues/list.html — edit here,
|
||||||
|
not in either copy.
|
||||||
|
|
||||||
|
The form lives OUTSIDE the table on purpose. Row checkboxes join it with the
|
||||||
|
HTML5 `form="issuesBulkForm"` attribute instead of being wrapped by it, so
|
||||||
|
the per-row delete / unfollow forms inside the table are never nested inside
|
||||||
|
this one (rule 9 — browsers silently discard nested forms, and the row
|
||||||
|
actions would stop working with no error).
|
||||||
|
|
||||||
|
`next` carries the current filtered list URL so the action returns here
|
||||||
|
rather than to the bare index.
|
||||||
|
|
||||||
|
Requires from the view: `staff` (assignable users).
|
||||||
|
#}
|
||||||
|
{% set can_manage = current_user.role in ['admin', 'director', 'auditor'] %}
|
||||||
|
{% set can_delete = current_user.role in ['admin', 'director'] %}
|
||||||
|
{% if can_manage or can_delete %}
|
||||||
|
<form method="POST" id="issuesBulkForm"
|
||||||
|
action="{{ url_for('issues.bulk_action') }}"
|
||||||
|
class="border-bottom bg-light px-3 py-2">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<input type="hidden" name="next" value="{{ current_url() }}">
|
||||||
|
|
||||||
|
<div class="d-flex flex-wrap align-items-center gap-2">
|
||||||
|
<span class="small fw-semibold text-nowrap">
|
||||||
|
<span class="bulk-count">0</span> selected
|
||||||
|
</span>
|
||||||
|
<span class="text-muted small d-none d-md-inline">|</span>
|
||||||
|
|
||||||
|
{% if can_manage %}
|
||||||
|
<div class="d-flex align-items-center gap-1">
|
||||||
|
<select name="assigned_to" class="form-select form-select-sm"
|
||||||
|
style="min-width:150px;font-size:.8rem;" aria-label="Assign selected to">
|
||||||
|
<option value="0">— Unassigned —</option>
|
||||||
|
{% for u in staff %}
|
||||||
|
<option value="{{ u.id }}">{{ u.display_name }}{{ ' (Customer)' if u.is_external_inspector }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<button type="submit" name="action" value="assign"
|
||||||
|
class="btn btn-sm btn-outline-primary text-nowrap" data-bulk-action
|
||||||
|
data-bulk-confirm="Assign the selected issues to the chosen user?">
|
||||||
|
<i class="bi bi-person-check"></i> Assign
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex align-items-center gap-1">
|
||||||
|
<select name="status" class="form-select form-select-sm"
|
||||||
|
style="min-width:150px;font-size:.8rem;" aria-label="Set status of selected">
|
||||||
|
<option value="">— Set status… —</option>
|
||||||
|
<option value="open">Open</option>
|
||||||
|
<option value="in_progress">In Progress</option>
|
||||||
|
<option value="pending_verification">Pending Verification</option>
|
||||||
|
<option value="resolved">Resolved</option>
|
||||||
|
</select>
|
||||||
|
<button type="submit" name="action" value="status"
|
||||||
|
class="btn btn-sm btn-outline-primary text-nowrap" data-bulk-action
|
||||||
|
data-bulk-confirm="Change the status of the selected issues?">
|
||||||
|
<i class="bi bi-arrow-repeat"></i> Apply
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" name="action" value="verify"
|
||||||
|
class="btn btn-sm btn-outline-success text-nowrap" data-bulk-action
|
||||||
|
data-bulk-confirm="Verify and close the selected issues? Issues that are not awaiting verification are skipped.">
|
||||||
|
<i class="bi bi-patch-check"></i> Verify & 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>
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
<i class="bi bi-shield-check me-1"></i>SLA Compliance
|
<i class="bi bi-shield-check me-1"></i>SLA Compliance
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{% if current_user.role in ['admin', 'director', 'project_manager'] %}
|
{% if current_user.role in ['admin', 'director', 'project_manager', 'auditor'] %}
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {{ 'active' if request.endpoint == 'reports.followup_closure' else '' }}"
|
<a class="nav-link {{ 'active' if request.endpoint == 'reports.followup_closure' else '' }}"
|
||||||
href="{{ url_for('reports.followup_closure') }}">
|
href="{{ url_for('reports.followup_closure') }}">
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if current_user.role in ['admin', 'director', 'project_manager'] %}
|
{% if current_user.role in ['admin', 'director', 'project_manager', 'auditor'] %}
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('scheduled_reports.') else '' }}"
|
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('scheduled_reports.') else '' }}"
|
||||||
href="{{ url_for('scheduled_reports.index') }}">
|
href="{{ url_for('scheduled_reports.index') }}">
|
||||||
|
|||||||
@@ -105,7 +105,17 @@
|
|||||||
<div class="row mb-4">
|
<div class="row mb-4">
|
||||||
<div class="col-lg-8 mb-3">
|
<div class="col-lg-8 mb-3">
|
||||||
<div class="card shadow-sm h-100">
|
<div class="card shadow-sm h-100">
|
||||||
<div class="card-header bg-light"><h6 class="mb-0"><i class="bi bi-building"></i> Avg Score by Facility</h6></div>
|
<div class="card-header bg-light d-flex justify-content-between align-items-center gap-2 flex-wrap">
|
||||||
|
<h6 class="mb-0"><i class="bi bi-building"></i> Avg Score by Facility</h6>
|
||||||
|
{% if score_contracts %}
|
||||||
|
<select id="scoreContractFilter" class="form-select form-select-sm" style="max-width:230px;">
|
||||||
|
<option value="">All Contracts</option>
|
||||||
|
{% for pid, cname in score_contracts %}
|
||||||
|
<option value="{{ pid }}">{{ cname }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
<div class="card-body"><div class="chart-container"><canvas id="facilityChart"></canvas></div></div>
|
<div class="card-body"><div class="chart-container"><canvas id="facilityChart"></canvas></div></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -129,6 +139,7 @@
|
|||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
<tr>
|
<tr>
|
||||||
<th>Facility</th>
|
<th>Facility</th>
|
||||||
|
<th>Contract</th>
|
||||||
<th class="text-end">Current Period</th>
|
<th class="text-end">Current Period</th>
|
||||||
<th class="text-end">Prior Period</th>
|
<th class="text-end">Prior Period</th>
|
||||||
<th class="text-end">Change</th>
|
<th class="text-end">Change</th>
|
||||||
@@ -137,8 +148,9 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for row in facility_scores %}
|
{% for row in facility_scores %}
|
||||||
<tr>
|
<tr class="facility-score-row" data-project-id="{{ row.project_id }}">
|
||||||
<td class="fw-semibold">{{ row.name }}</td>
|
<td class="fw-semibold">{{ row.name }}</td>
|
||||||
|
<td class="text-muted small">{{ row.contract }}</td>
|
||||||
<td class="text-end">
|
<td class="text-end">
|
||||||
<span class="badge bg-{{ 'success' if row.avg_score >= 90 else 'warning text-dark' if row.avg_score >= 70 else 'danger' }}">
|
<span class="badge bg-{{ 'success' if row.avg_score >= 90 else 'warning text-dark' if row.avg_score >= 70 else 'danger' }}">
|
||||||
{{ '%.1f'|format(row.avg_score|float) }}%
|
{{ '%.1f'|format(row.avg_score|float) }}%
|
||||||
@@ -266,16 +278,31 @@ new Chart(document.getElementById('trendChart'), {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Facility bar chart ────────────────────────────────────────────────────────
|
// ── Facility bar chart (filterable by contract) ───────────────────────────────
|
||||||
new Chart(document.getElementById('facilityChart'), {
|
const FACILITY_SCORES = {{ facility_scores | tojson }};
|
||||||
|
let facilityChartObj = null;
|
||||||
|
function _facColors(data) { return data.map(s => s >= 90 ? GREEN : s >= 70 ? AMBER : RED); }
|
||||||
|
function renderFacilityChart(pid) {
|
||||||
|
const rows = (!pid)
|
||||||
|
? FACILITY_SCORES
|
||||||
|
: FACILITY_SCORES.filter(r => String(r.project_id) === String(pid));
|
||||||
|
const labels = rows.map(r => r.name);
|
||||||
|
const data = rows.map(r => r.avg_score);
|
||||||
|
if (facilityChartObj) {
|
||||||
|
facilityChartObj.data.labels = labels;
|
||||||
|
facilityChartObj.data.datasets[0].data = data;
|
||||||
|
facilityChartObj.data.datasets[0].backgroundColor = _facColors(data);
|
||||||
|
facilityChartObj.update();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
facilityChartObj = new Chart(document.getElementById('facilityChart'), {
|
||||||
type: 'bar',
|
type: 'bar',
|
||||||
data: {
|
data: {
|
||||||
labels: {{ facility_scores | map(attribute='name') | list | tojson }},
|
labels: labels,
|
||||||
datasets: [{
|
datasets: [{
|
||||||
label: 'Avg Score (%)',
|
label: 'Avg Score (%)',
|
||||||
data: {{ facility_scores | map(attribute='avg_score') | list | tojson }},
|
data: data,
|
||||||
backgroundColor: {{ facility_scores | map(attribute='avg_score') | list | tojson }}
|
backgroundColor: _facColors(data),
|
||||||
.map(s => s >= 90 ? GREEN : s >= 70 ? AMBER : RED),
|
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
}]
|
}]
|
||||||
},
|
},
|
||||||
@@ -285,6 +312,25 @@ new Chart(document.getElementById('facilityChart'), {
|
|||||||
plugins: { legend: { display: false } }
|
plugins: { legend: { display: false } }
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterFacilityScoreTable(pid) {
|
||||||
|
document.querySelectorAll('.facility-score-row').forEach(function (tr) {
|
||||||
|
const rp = tr.getAttribute('data-project-id');
|
||||||
|
tr.style.display = (!pid || rp === String(pid)) ? '' : 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
renderFacilityChart('');
|
||||||
|
const sel = document.getElementById('scoreContractFilter');
|
||||||
|
if (sel) {
|
||||||
|
sel.addEventListener('change', function () {
|
||||||
|
renderFacilityChart(this.value);
|
||||||
|
filterFacilityScoreTable(this.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}());
|
||||||
|
|
||||||
// ── Severity doughnut ─────────────────────────────────────────────────────────
|
// ── Severity doughnut ─────────────────────────────────────────────────────────
|
||||||
const sevData = {{ issue_severity | tojson }};
|
const sevData = {{ issue_severity | tojson }};
|
||||||
|
|||||||
@@ -115,6 +115,9 @@
|
|||||||
data-inspector-id="{{ s.id }}">
|
data-inspector-id="{{ s.id }}">
|
||||||
<td class="fw-semibold">
|
<td class="fw-semibold">
|
||||||
{{ s.display_name }}
|
{{ s.display_name }}
|
||||||
|
{% if s.external %}
|
||||||
|
<span class="badge bg-dark ms-1" title="Customer / third-party inspector">Customer</span>
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-center">{{ s.total }}</td>
|
<td class="text-center">{{ s.total }}</td>
|
||||||
<td class="text-center">{{ s.completed }}</td>
|
<td class="text-center">{{ s.completed }}</td>
|
||||||
@@ -189,6 +192,9 @@
|
|||||||
<div class="card-header bg-primary text-white d-flex justify-content-between align-items-center">
|
<div class="card-header bg-primary text-white d-flex justify-content-between align-items-center">
|
||||||
<h6 class="mb-0">
|
<h6 class="mb-0">
|
||||||
<i class="bi bi-person-circle me-2"></i>{{ selected_inspector.display_name }}
|
<i class="bi bi-person-circle me-2"></i>{{ selected_inspector.display_name }}
|
||||||
|
{% if selected_inspector.is_external_inspector %}
|
||||||
|
<span class="badge bg-dark ms-1" title="Customer / third-party inspector">Customer</span>
|
||||||
|
{% endif %}
|
||||||
</h6>
|
</h6>
|
||||||
<a href="{{ url_for('reports.inspector_performance', start=start.strftime('%Y-%m-%d'), end=end.strftime('%Y-%m-%d')) }}"
|
<a href="{{ url_for('reports.inspector_performance', start=start.strftime('%Y-%m-%d'), end=end.strftime('%Y-%m-%d')) }}"
|
||||||
class="btn btn-sm btn-light text-primary">
|
class="btn btn-sm btn-light text-primary">
|
||||||
|
|||||||
@@ -29,9 +29,20 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
{% if projects %}
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label small mb-1">Contract</label>
|
||||||
|
<select id="aging_contract_sel" class="form-select form-select-sm">
|
||||||
|
<option value="">All Contracts</option>
|
||||||
|
{% for p in projects %}
|
||||||
|
<option value="{{ p.id }}">{{ p.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="col-md-3">
|
||||||
<label class="form-label small mb-1">Facility</label>
|
<label class="form-label small mb-1">Facility</label>
|
||||||
<select name="facility_id" class="form-select form-select-sm">
|
<select id="aging_facility_sel" name="facility_id" class="form-select form-select-sm">
|
||||||
<option value="">All Facilities</option>
|
<option value="">All Facilities</option>
|
||||||
{% for f in facilities %}
|
{% for f in facilities %}
|
||||||
<option value="{{ f.id }}" {{ 'selected' if facility_id_filter == f.id }}>{{ f.name }}</option>
|
<option value="{{ f.id }}" {{ 'selected' if facility_id_filter == f.id }}>{{ f.name }}</option>
|
||||||
@@ -46,6 +57,36 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if projects %}
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var contractSel = document.getElementById('aging_contract_sel');
|
||||||
|
var facSel = document.getElementById('aging_facility_sel');
|
||||||
|
if (!contractSel || !facSel) return;
|
||||||
|
var allOpts = Array.from(facSel.options).map(function (o) { return {v: o.value, t: o.text}; });
|
||||||
|
contractSel.addEventListener('change', function () {
|
||||||
|
var pid = this.value;
|
||||||
|
facSel.innerHTML = '<option value="">All Facilities</option>';
|
||||||
|
if (!pid) {
|
||||||
|
allOpts.slice(1).forEach(function (o) {
|
||||||
|
var opt = document.createElement('option'); opt.value = o.v; opt.text = o.t;
|
||||||
|
facSel.appendChild(opt);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fetch('/inspections/facilities_for_project/' + pid)
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (data) {
|
||||||
|
data.forEach(function (f) {
|
||||||
|
var opt = document.createElement('option'); opt.value = f.id; opt.text = f.name;
|
||||||
|
facSel.appendChild(opt);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}());
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{# ── KPI row ── #}
|
{# ── KPI row ── #}
|
||||||
<div class="row g-3 mb-4">
|
<div class="row g-3 mb-4">
|
||||||
<div class="col-6 col-md-4">
|
<div class="col-6 col-md-4">
|
||||||
|
|||||||
@@ -25,17 +25,28 @@
|
|||||||
<div class="card shadow-sm mb-4">
|
<div class="card shadow-sm mb-4">
|
||||||
<div class="card-body py-2">
|
<div class="card-body py-2">
|
||||||
<form method="get" class="row g-2 align-items-end">
|
<form method="get" class="row g-2 align-items-end">
|
||||||
<div class="col-md-3">
|
<div class="col-md-2">
|
||||||
<label class="form-label small mb-1">From</label>
|
<label class="form-label small mb-1">From</label>
|
||||||
<input type="date" name="start" class="form-control form-control-sm" value="{{ start.strftime('%Y-%m-%d') }}">
|
<input type="date" name="start" class="form-control form-control-sm" value="{{ start.strftime('%Y-%m-%d') }}">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-2">
|
||||||
<label class="form-label small mb-1">To</label>
|
<label class="form-label small mb-1">To</label>
|
||||||
<input type="date" name="end" class="form-control form-control-sm" value="{{ end.strftime('%Y-%m-%d') }}">
|
<input type="date" name="end" class="form-control form-control-sm" value="{{ end.strftime('%Y-%m-%d') }}">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
{% if projects %}
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label small mb-1">Contract</label>
|
||||||
|
<select id="sla_contract_sel" class="form-select form-select-sm">
|
||||||
|
<option value="">All Contracts</option>
|
||||||
|
{% for p in projects %}
|
||||||
|
<option value="{{ p.id }}">{{ p.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="col-md-3">
|
||||||
<label class="form-label small mb-1">Facility</label>
|
<label class="form-label small mb-1">Facility</label>
|
||||||
<select name="facility_id" class="form-select form-select-sm">
|
<select id="sla_facility_sel" name="facility_id" class="form-select form-select-sm">
|
||||||
<option value="">All Facilities</option>
|
<option value="">All Facilities</option>
|
||||||
{% for f in facilities %}
|
{% for f in facilities %}
|
||||||
<option value="{{ f.id }}" {{ 'selected' if facility_id_filter == f.id }}>{{ f.name }}</option>
|
<option value="{{ f.id }}" {{ 'selected' if facility_id_filter == f.id }}>{{ f.name }}</option>
|
||||||
@@ -50,6 +61,36 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if projects %}
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var contractSel = document.getElementById('sla_contract_sel');
|
||||||
|
var facSel = document.getElementById('sla_facility_sel');
|
||||||
|
if (!contractSel || !facSel) return;
|
||||||
|
var allOpts = Array.from(facSel.options).map(function (o) { return {v: o.value, t: o.text}; });
|
||||||
|
contractSel.addEventListener('change', function () {
|
||||||
|
var pid = this.value;
|
||||||
|
facSel.innerHTML = '<option value="">All Facilities</option>';
|
||||||
|
if (!pid) {
|
||||||
|
allOpts.slice(1).forEach(function (o) {
|
||||||
|
var opt = document.createElement('option'); opt.value = o.v; opt.text = o.t;
|
||||||
|
facSel.appendChild(opt);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fetch('/inspections/facilities_for_project/' + pid)
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (data) {
|
||||||
|
data.forEach(function (f) {
|
||||||
|
var opt = document.createElement('option'); opt.value = f.id; opt.text = f.name;
|
||||||
|
facSel.appendChild(opt);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}());
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if total == 0 %}
|
{% if total == 0 %}
|
||||||
<div class="alert alert-info">
|
<div class="alert alert-info">
|
||||||
<i class="bi bi-info-circle me-2"></i>No resolved issues found for this period and filter.
|
<i class="bi bi-info-circle me-2"></i>No resolved issues found for this period and filter.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
{% block title %}Support Knowledge Base{% endblock %}
|
{% block title %}Support Knowledge Base{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
|
||||||
<div>
|
<div>
|
||||||
<h4 class="mb-0"><i class="bi bi-book me-2 text-primary"></i>Support Knowledge Base</h4>
|
<h4 class="mb-0"><i class="bi bi-book me-2 text-primary"></i>Support Knowledge Base</h4>
|
||||||
<small class="text-muted">Active entries are injected into the AI chatbot system prompt</small>
|
<small class="text-muted">Active entries are injected into the AI chatbot system prompt</small>
|
||||||
@@ -11,6 +11,10 @@
|
|||||||
<a href="{{ url_for('support.admin_tickets') }}" class="btn btn-outline-secondary btn-sm me-1">
|
<a href="{{ url_for('support.admin_tickets') }}" class="btn btn-outline-secondary btn-sm me-1">
|
||||||
<i class="bi bi-inbox me-1"></i>Tickets
|
<i class="bi bi-inbox me-1"></i>Tickets
|
||||||
</a>
|
</a>
|
||||||
|
<a href="{{ url_for('support.admin_knowledge_preview') }}" class="btn btn-outline-primary btn-sm"
|
||||||
|
title="See the exact prompt the chatbot receives, with your entries in it">
|
||||||
|
<i class="bi bi-eye me-1"></i>What the AI Sees
|
||||||
|
</a>
|
||||||
<a href="{{ url_for('support.admin_conversations') }}" class="btn btn-outline-secondary btn-sm">
|
<a href="{{ url_for('support.admin_conversations') }}" class="btn btn-outline-secondary btn-sm">
|
||||||
<i class="bi bi-chat-dots me-1"></i>Conversations
|
<i class="bi bi-chat-dots me-1"></i>Conversations
|
||||||
</a>
|
</a>
|
||||||
@@ -34,6 +38,12 @@
|
|||||||
placeholder="Add facts, FAQs, or instructions the AI should know about this customer's account…"></textarea>
|
placeholder="Add facts, FAQs, or instructions the AI should know about this customer's account…"></textarea>
|
||||||
<div class="form-text">Keep entries focused and factual. Combined active entries are capped at 6,000 characters.</div>
|
<div class="form-text">Keep entries focused and factual. Combined active entries are capped at 6,000 characters.</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mb-3" style="max-width:200px;">
|
||||||
|
<label class="form-label fw-semibold">Sort Order</label>
|
||||||
|
<input type="number" name="sort_order" class="form-control"
|
||||||
|
min="0" max="9999" step="1" value="0">
|
||||||
|
<div class="form-text">Lower numbers are sent to the assistant first. Leave at 0 unless an entry should take priority.</div>
|
||||||
|
</div>
|
||||||
<button type="submit" class="btn btn-primary btn-sm">
|
<button type="submit" class="btn btn-primary btn-sm">
|
||||||
<i class="bi bi-plus me-1"></i>Add Entry
|
<i class="bi bi-plus me-1"></i>Add Entry
|
||||||
</button>
|
</button>
|
||||||
@@ -54,6 +64,7 @@
|
|||||||
<div class="d-flex justify-content-between align-items-start">
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
<div class="flex-grow-1 me-3">
|
<div class="flex-grow-1 me-3">
|
||||||
<div class="d-flex align-items-center gap-2 mb-1">
|
<div class="d-flex align-items-center gap-2 mb-1">
|
||||||
|
<span class="badge bg-light text-dark border" title="Sort order">#{{ entry.sort_order }}</span>
|
||||||
<span class="fw-semibold">{{ entry.title }}</span>
|
<span class="fw-semibold">{{ entry.title }}</span>
|
||||||
{% if entry.active %}
|
{% if entry.active %}
|
||||||
<span class="badge bg-success">Active</span>
|
<span class="badge bg-success">Active</span>
|
||||||
|
|||||||
@@ -23,6 +23,12 @@
|
|||||||
<textarea name="body" class="form-control" rows="8" required>{{ entry.body }}</textarea>
|
<textarea name="body" class="form-control" rows="8" required>{{ entry.body }}</textarea>
|
||||||
<div class="form-text">Combined active entries are capped at 6,000 characters in the AI prompt.</div>
|
<div class="form-text">Combined active entries are capped at 6,000 characters in the AI prompt.</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mb-3" style="max-width:200px;">
|
||||||
|
<label class="form-label fw-semibold">Sort Order</label>
|
||||||
|
<input type="number" name="sort_order" class="form-control"
|
||||||
|
min="0" max="9999" step="1" value="{{ entry.sort_order }}">
|
||||||
|
<div class="form-text">Lower numbers are sent to the assistant first. Leave at 0 unless an entry should take priority.</div>
|
||||||
|
</div>
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
<button type="submit" class="btn btn-primary">
|
<button type="submit" class="btn btn-primary">
|
||||||
<i class="bi bi-check-lg me-1"></i>Save Changes
|
<i class="bi bi-check-lg me-1"></i>Save Changes
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}What the AI Sees{% endblock %}
|
||||||
|
|
||||||
|
{# Read-only view of the assembled system prompt. Exists so an admin can tell
|
||||||
|
"my knowledge entry never reached the prompt" apart from "the model saw it
|
||||||
|
and chose not to use it" — the two have completely different fixes. #}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex flex-wrap justify-content-between align-items-center mb-3 gap-2">
|
||||||
|
<h2 class="mb-0"><i class="bi bi-eye"></i> What the AI Sees</h2>
|
||||||
|
<a href="{{ url_for('support.admin_knowledge') }}" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-arrow-left"></i> Back to Knowledge Base
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-6 col-md-3">
|
||||||
|
<div class="card shadow-sm h-100">
|
||||||
|
<div class="card-body text-center py-3">
|
||||||
|
<div class="fs-4 fw-bold">{{ active_count }}</div>
|
||||||
|
<div class="text-muted small">Active entries</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-3">
|
||||||
|
<div class="card shadow-sm h-100">
|
||||||
|
<div class="card-body text-center py-3">
|
||||||
|
<div class="fs-4 fw-bold">{{ total_count - active_count }}</div>
|
||||||
|
<div class="text-muted small">Inactive (not sent)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-3">
|
||||||
|
<div class="card shadow-sm h-100">
|
||||||
|
<div class="card-body text-center py-3">
|
||||||
|
<div class="fs-4 fw-bold">{{ prompt | length }}</div>
|
||||||
|
<div class="text-muted small">Prompt characters</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-3">
|
||||||
|
<div class="card shadow-sm h-100">
|
||||||
|
<div class="card-body text-center py-3">
|
||||||
|
{% if kb_included %}
|
||||||
|
<div class="fs-4 fw-bold text-success"><i class="bi bi-check-circle"></i></div>
|
||||||
|
<div class="text-muted small">Knowledge included</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="fs-4 fw-bold text-danger"><i class="bi bi-x-circle"></i></div>
|
||||||
|
<div class="text-muted small">Knowledge NOT included</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if not kb_included and total_count %}
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||||
|
You have {{ total_count }} knowledge entr{{ 'y' if total_count == 1 else 'ies' }}, but
|
||||||
|
none reached the prompt. Check that at least one is marked <strong>Active</strong>.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<i class="bi bi-info-circle me-1"></i>
|
||||||
|
This is the exact text sent to the AI ahead of every customer question. Entries are
|
||||||
|
capped at {{ kb_cap }} characters in total — past that, later entries are dropped
|
||||||
|
(lowest sort order is kept first). If something you wrote appears here but the AI still
|
||||||
|
will not say it, the wording of the entry is the thing to change, not the setup.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header bg-light fw-semibold">Assembled system prompt</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<pre class="mb-0 p-3" style="white-space:pre-wrap; font-size:.8rem; max-height:70vh;
|
||||||
|
overflow-y:auto; background:#f8fafc;">{{ prompt }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
<div class="col-lg-8">
|
<div class="col-lg-8">
|
||||||
|
|
||||||
{# ── Header ── #}
|
{# ── Header ── #}
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
|
||||||
<div>
|
<div>
|
||||||
<h4 class="mb-0"><i class="bi bi-chat-dots me-2 text-primary"></i>JQC Support Chat</h4>
|
<h4 class="mb-0"><i class="bi bi-chat-dots me-2 text-primary"></i>JQC Support Chat</h4>
|
||||||
<small class="text-muted">
|
<small class="text-muted">
|
||||||
|
|||||||
@@ -160,6 +160,15 @@
|
|||||||
{{ form.frequency.label(class="form-label fw-semibold small") }}
|
{{ form.frequency.label(class="form-label fw-semibold small") }}
|
||||||
{{ form.frequency(class="form-select form-select-sm") }}
|
{{ form.frequency(class="form-select form-select-sm") }}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
{{ form.contract_ids.label(class="form-label fw-semibold small") }}
|
||||||
|
{{ form.contract_ids(class="form-select form-select-sm", size=6) }}
|
||||||
|
<div class="form-text small">
|
||||||
|
Nothing selected = shared with every contract. Select contracts to
|
||||||
|
restrict this form to them (hidden from all other customers).
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div class="d-grid gap-2">
|
<div class="d-grid gap-2">
|
||||||
<button type="submit" class="btn btn-primary btn-sm">
|
<button type="submit" class="btn btn-primary btn-sm">
|
||||||
|
|||||||
@@ -27,6 +27,18 @@
|
|||||||
{{ form.frequency.label(class="form-label") }}
|
{{ form.frequency.label(class="form-label") }}
|
||||||
{{ form.frequency(class="form-select") }}
|
{{ form.frequency(class="form-select") }}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
{{ form.contract_ids.label(class="form-label") }}
|
||||||
|
{{ form.contract_ids(class="form-select", size=6) }}
|
||||||
|
<div class="form-text">
|
||||||
|
Leave <strong>nothing selected</strong> to share this form with
|
||||||
|
every contract. Select one or more contracts to make it
|
||||||
|
specific to them — it will then be hidden from every other
|
||||||
|
customer's facilities, on the web and in the iPad app.
|
||||||
|
Ctrl/Cmd-click to select several.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
<button type="submit" class="btn btn-primary">
|
<button type="submit" class="btn btn-primary">
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user