Compare commits
35
Commits
321965038d
...
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 |
@@ -34,6 +34,8 @@
|
||||
24. [Backup CLI](#24-backup-cli-controlbackuppy)
|
||||
25. [Health Dashboard](#25-health-dashboard-health-on-panel)
|
||||
26. [Coding Rules for AI Assistants](#26-coding-rules-for-ai-assistants)
|
||||
29. [Photo Object Storage (R2)](#29-photo-object-storage-r2)
|
||||
30. [Database Health Check](#30-database-health-check-scriptsdb_healthpy)
|
||||
|
||||
---
|
||||
|
||||
@@ -223,6 +225,8 @@ lt_janitorial_quality_control/
|
||||
| `REDIS_URL` | Optional. When set, Flask-Limiter uses Redis for shared rate-limit counters across Gunicorn workers. |
|
||||
| `GROQ_API_KEY` | Optional. When set, enables the AI chatbot at `/support/chat`. Absent → chat input disabled; customers see a "Submit to Support" fallback only. |
|
||||
| `GROQ_MODEL` | Optional. Groq model ID. Defaults to `llama-3.3-70b-versatile`. |
|
||||
| `DB_POOL_RECYCLE` | Optional, default `1800` (seconds). Retires a pooled connection on the **default bind** after this long. **Must stay below the server's `wait_timeout`** or MySQL closes the socket first and the next request gets `OperationalError 2006`. `scripts/db_health.py` cross-checks the two. Per-tenant engines use `TENANT_ENGINE_POOL_RECYCLE` instead. |
|
||||
| `DB_POOL_SIZE` / `DB_MAX_OVERFLOW` | Optional, default `5` / `5`. Per-**worker** pool on the default bind. In MT the real ceiling is `workers x [ (default pool) + TENANT_ENGINE_CACHE_MAX x (tenant pool) ]` — the library defaults (5+10) alone put a 9-worker box at 135 against a `max_connections` of 151, before any tenant engine is counted. |
|
||||
| `MULTI_TENANT_ENABLED` | `false` by default. Set `true` to activate Host→tenant routing. Requires all control-plane vars below. |
|
||||
| `CONTROL_DATABASE_URL` | Control-plane MySQL URI, e.g. `mysql+pymysql://jqc_control:pw@127.0.0.1/jqc_control`. Required when `MULTI_TENANT_ENABLED=true`. |
|
||||
| `CONTROL_FERNET_KEY` | Fernet key for encrypting tenant DB passwords. Generate once; store in `/etc/jqc/control.env`. |
|
||||
@@ -362,6 +366,28 @@ notifications: id, user_id, title, body, link, is_read, created_at, issue_id,
|
||||
notification_preferences: id, user_id, event_type, email_enabled, digest_mode, digest_frequency
|
||||
```
|
||||
|
||||
### IssueLink (phase57)
|
||||
|
||||
```
|
||||
issue_links: id, issue_id (FK→issues CASCADE, indexed),
|
||||
linked_issue_id (FK→issues CASCADE, indexed),
|
||||
link_type ENUM('duplicate','related') NOT NULL DEFAULT 'related',
|
||||
created_by (FK→users SET NULL), created_at
|
||||
UniqueConstraint(issue_id, linked_issue_id) — uq_issue_link
|
||||
```
|
||||
|
||||
**Connects a duplicate to its original, or two issues about the same thing**, so whoever picks one up can reach the other.
|
||||
|
||||
**One row is stored per pair and shown on BOTH issues.** The stored direction carries meaning for `duplicate` — `issue_id` is a duplicate *of* `linked_issue_id` — so the same row reads differently at each end: "Duplicate of #B" on one, "Duplicated by #A" on the other. `related` is symmetric and reads "Related to" from either side. `IssueLink.LABELS` is keyed `(link_type, is_source)` and is the only place that wording lives; `label_for(viewing_issue_id)` / `other_issue(viewing_issue_id)` resolve a row against whichever issue is on screen.
|
||||
|
||||
Storing one row rather than a mirrored pair keeps the direction unambiguous and makes unlinking a single delete. The cost: **uniqueness cannot be expressed by the UniqueConstraint alone.** `(A,B)` and `(B,A)` are distinct rows to MySQL but the same link to a person, so **`IssueLink.exists_between(a, b)` is the only correct duplicate check** — it looks both ways. The constraint catches the exact-duplicate row; `exists_between()` catches the reverse.
|
||||
|
||||
**A link is PURELY NAVIGATIONAL.** Marking a duplicate does **not** touch either issue's status, `resolved_at`, SLA, assignee or followers, and fires no notification. Closing the duplicate stays a separate, deliberate action. Do not add side effects here without saying so in the UI — the link control reads as navigation, and a status write from it would be invisible.
|
||||
|
||||
**Two FKs from one table to `issues`, so both relationships pin `foreign_keys`** — the same failure mode as phase56's third `inspections`→`users` FK (§17), which raises on first ORM *use*, not at import. `Issue.links_from` / `Issue.links_to` are the two storage directions; **`Issue.all_links()` merges them** into the single list a person actually sees. Both relationships cascade `all, delete-orphan` (and both FKs are `ON DELETE CASCADE`), so deleting an issue takes its links from *either* end — a surviving link would render a dead row on the other issue's page.
|
||||
|
||||
**Nothing here is tenant-aware, deliberately.** The table lives in the tenant DB and routes through `RoutingSession`, so a link can only reach an issue in the same tenant. Scope *within* a tenant is the caller's job — **see rule 110.**
|
||||
|
||||
### IssueComment
|
||||
|
||||
```
|
||||
@@ -428,6 +454,36 @@ notification_matrix: id, event_type, role_key, enabled, custom_emails (JSON)
|
||||
UniqueConstraint(event_type, role_key)
|
||||
```
|
||||
|
||||
### UserNotificationMatrix (phase54)
|
||||
|
||||
```
|
||||
user_notification_matrix: id, user_id (FK→users CASCADE, indexed),
|
||||
event_type VARCHAR(50), enabled BOOL
|
||||
UniqueConstraint(user_id, event_type)
|
||||
```
|
||||
|
||||
Per-account override of the global matrix, for the two customer-side roles
|
||||
only. `enabled=True` = send even if the global column is OFF; `enabled=False` =
|
||||
never send even if it is ON; **no row = inherit**. Setting a row back to
|
||||
inherit DELETES it, which is what keeps an account that never expressed an
|
||||
opinion tracking the global matrix. Helpers: `overrides_for_user()`,
|
||||
`override_for()`, `overrides_for_event()`, `set_overrides()` (none commit).
|
||||
See §27b and rules 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)
|
||||
|
||||
```
|
||||
@@ -534,7 +590,7 @@ The `DeviceRegistration` model and the duplicate `api_devices` blueprint were **
|
||||
| `customers` | `/customers` | list, invite, set-password, manage, import CSV |
|
||||
| `inspections` | `/inspections` | list, start, execute, view, PDF export, flag-issue, save-draft (AJAX), flag-followup, reinspect, upload-photo (AJAX) |
|
||||
| `templates` | `/templates` | list, create, edit, delete, form editor, preview |
|
||||
| `issues` | `/issues` | list, view, create, update, verify, comment, follow/unfollow, verification queue, bulk-verify, delete, quick-assign |
|
||||
| `issues` | `/issues` | list, view, create, update, verify, comment, follow/unfollow, verification queue, bulk-verify, delete, quick-assign, **issue links** (`POST /<id>/links` add, `POST /<id>/links/<link_id>/delete` remove, `GET /<id>/link-search` scoped JSON picker — phase57) |
|
||||
| `notifications` | `/notifications` | list, mark-read, preferences, send-digest (cron), check-sla (cron), cleanup-tokens (cron), trial-reminders (cron), dunning-reminders (cron) |
|
||||
| `audit` | `/audit` | list (admin only), view, purge |
|
||||
| `reports` | `/reports` | index, facility report, scorecard, CSV/PDF/Excel export, issues-aging, sla-compliance, followup-closure, facility summary PDF |
|
||||
@@ -812,7 +868,7 @@ limiter = Limiter(
|
||||
|
||||
## 17. Alembic Migration Chain
|
||||
|
||||
**Current HEAD:** `phase40_support_chat_kb` (38 migrations total).
|
||||
**Current HEAD:** `phase57_issue_links`.
|
||||
|
||||
**Chain root:** `0003_add_user_active` — a guarded squashed baseline (MT-2) that recreates the full 25-table schema with INFORMATION_SCHEMA guards. The original baseline migrations (0001/0002/0003) were lost; this file restores the chain root so Alembic can build the revision map. `down_revision = None`.
|
||||
|
||||
@@ -846,9 +902,101 @@ limiter = Limiter(
|
||||
→ phase36_issue_work_orders
|
||||
→ phase37_contract_recipients
|
||||
→ phase38_facility_qr
|
||||
→ phase39_issue_handler_type → phase40_support_chat_kb ← HEAD
|
||||
→ phase39_issue_handler_type → phase40_support_chat_kb
|
||||
→ phase41_auditor_role → phase42_area_qr_token → phase43_schedule_plan_fields
|
||||
→ phase44_internal_handler → phase45_schedule_frequency_enum
|
||||
→ phase46_schedule_recurrence → phase47_schedule_end_date
|
||||
→ phase48_schedule_parent_inspection → phase49_followup_requested_by
|
||||
→ phase50_sched_acknowledged → phase51_external_inspector
|
||||
→ phase52_user_ui_theme → phase53_knowledge_sort_order
|
||||
→ phase54_user_notif_matrix → phase55_template_contracts
|
||||
→ phase56_followup_assignee → phase57_issue_links ← HEAD
|
||||
```
|
||||
|
||||
`phase54` / `phase55` port the ST August-2026 work (ST calls them phase51 /
|
||||
phase52; the ids differ because MT's chain was already past those numbers —
|
||||
match by NAME, not number, when comparing the two repos).
|
||||
|
||||
#### phase54 — per-account notification overrides
|
||||
|
||||
Creates `user_notification_matrix` (§5). **No backfill, deliberately** — an
|
||||
empty table means every account inherits the global matrix, i.e. exactly
|
||||
today's routing, so the migration cannot change who gets notified. Backfilling
|
||||
from the current global columns would freeze every account at today's routing
|
||||
and silently break later changes to those columns. Table-existence check —
|
||||
safe to re-run.
|
||||
|
||||
#### phase55 — restrict forms to specific contracts
|
||||
|
||||
Creates `template_contracts` (§5 `TemplateContract`). **No rows for a template
|
||||
means SHARED**, so every pre-existing form stays available everywhere and the
|
||||
migration cannot change behaviour on deploy. Table-existence check — safe to
|
||||
re-run.
|
||||
|
||||
#### phase56 — assign a follow-up to another inspector
|
||||
|
||||
Adds `inspections.follow_up_assigned_to` (FK → `users.id`, ON DELETE SET NULL)
|
||||
— see §27c. **No backfill:** NULL means the follow-up belongs to the
|
||||
inspection's own inspector, which is what every existing row already means, so
|
||||
deploying cannot change who owns anything.
|
||||
|
||||
**This is the THIRD FK from `inspections` to `users`** (`inspector_id`,
|
||||
`follow_up_requested_by`, and now this). Every relationship spanning the two
|
||||
tables must pin `foreign_keys` explicitly or the mapper is ambiguous — and it
|
||||
raises on first ORM *use*, not at import, so the app starts cleanly and then
|
||||
every request 500s. Column + constraint checks — safe to re-run.
|
||||
|
||||
ST calls this phase53; MT's chain was already past that number. Match by NAME.
|
||||
|
||||
#### phase57 — link related and duplicate issues
|
||||
|
||||
Creates `issue_links` (§5 `IssueLink`). **Purely additive** — nothing reads the
|
||||
table until a person creates a link, so an empty table is exactly today's
|
||||
behaviour and there is nothing to backfill.
|
||||
|
||||
Both issue FKs are `ON DELETE CASCADE`, so a direct SQL delete of an issue
|
||||
cannot leave a link pointing at a row that no longer exists (the ORM cascade on
|
||||
`links_from`/`links_to` covers the application path). The index names
|
||||
deliberately match what SQLAlchemy's `index=True` generates —
|
||||
`ix_issue_links_issue_id`, `ix_issue_links_linked_issue_id` — which matters more
|
||||
in MT than in ST: a tenant **bootstrapped** from the baseline and one
|
||||
**upgraded** through the chain must end up with the same schema, down to the
|
||||
index names.
|
||||
|
||||
Table-existence check — safe to re-run, as every migration from phase33 on must
|
||||
be. `downgrade()` drops the table, discarding every link; no issue is affected,
|
||||
since a link never held state belonging to one.
|
||||
|
||||
ST calls this phase54; MT's chain was already past that number. Match by NAME.
|
||||
|
||||
**Deploy order (tenant DBs):**
|
||||
```bash
|
||||
python -m control.tenant_migrate upgrade --tenant all
|
||||
sudo systemctl restart gunicorn
|
||||
```
|
||||
|
||||
**Deploy order (tenant DBs):**
|
||||
```bash
|
||||
python -m control.tenant_migrate upgrade --tenant all
|
||||
sudo systemctl restart gunicorn
|
||||
```
|
||||
|
||||
`phase41` → `phase52` are the single-tenant feature-parity track — see
|
||||
MULTI_TENANT_PLAN.md §12. Two notes on that tail:
|
||||
|
||||
* **`phase51_external_inspector`** widens the `users.role` ENUM. It must be
|
||||
deployed together with its code: MT tested `role == 'inspector'` literally in
|
||||
~80 places, and widening the ENUM alone drops external inspectors into the
|
||||
*unscoped* branch, which is a cross-tenant data leak rather than a cosmetic
|
||||
bug. Use `User.INSPECTOR_ROLES` / `user.is_inspector`, never a literal.
|
||||
* **ST's `phase50_default_modern` is deliberately NOT ported.** It overwrites
|
||||
every saved `ui_theme` preference, which in MT would run against every tenant
|
||||
DB. Set `DEFAULT_UI_THEME=modern` per tenant instead. See
|
||||
MULTI_TENANT_PLAN.md §12.3.
|
||||
|
||||
Migration revision IDs must be **≤ 32 characters** to fit
|
||||
`alembic_version.version_num VARCHAR(32)`.
|
||||
|
||||
### phase40_support_chat_kb
|
||||
|
||||
Creates three tables backing support chat persistence and the AI knowledge base:
|
||||
@@ -1418,6 +1566,20 @@ set -a; . /etc/jqc/control.env; set +a
|
||||
| 95 | **Chat history is loaded from DB — never pass client-sent history to Groq** | phase40. `POST /support/chat/message` loads prior turns from `SupportChatMessage` (newest-first, limit 40, reversed). The JSON body sends only `{ message, session_id }` — no history array. This prevents history tampering by clients and ensures accuracy across page reloads. |
|
||||
| 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. |
|
||||
|
||||
---
|
||||
@@ -1734,7 +1896,213 @@ Row highlights: yellow = suspended, red = trial expired.
|
||||
|
||||
---
|
||||
|
||||
## 26. Coding Rules for AI Assistants
|
||||
## 26. Web Portal Design (MT-16)
|
||||
|
||||
Two designs share one set of page templates.
|
||||
|
||||
* `base.html` is a **one-line dispatcher**: `{% extends jqc_layout %}`. Page
|
||||
templates keep `{% extends "base.html" %}` and need no edits.
|
||||
* `layouts/classic.html` is the original chrome, verbatim.
|
||||
`layouts/modern.html` is the sidebar shell.
|
||||
* `jqc_layout` comes from `inject_ui_theme()` in `app/__init__.py`, driven by
|
||||
`users.ui_theme` with config `DEFAULT_UI_THEME` as the fallback.
|
||||
* Per-page overrides live at `templates/modern/<same path>.html` and are indexed
|
||||
once at boot. Look for `UI themes | modern overrides indexed: N` in the log —
|
||||
`0` on a host that should have them means the directory did not deploy.
|
||||
|
||||
**Do not move the template swap into the Jinja loader.** It lives in
|
||||
`ThemedEnvironment.get_template()` so the template cache is keyed on the
|
||||
*rewritten* name. A loader-level swap caches under the original name, so a
|
||||
modern template can be served to a classic user — and in MT, where one worker
|
||||
serves many tenants, across tenants.
|
||||
|
||||
When adding a page: write it once as a normal template. Only add a
|
||||
`modern/` override if the layout genuinely differs; styling alone is handled by
|
||||
`static/css/theme_modern.css`, which is scoped to `body.jqc-modern`.
|
||||
|
||||
---
|
||||
|
||||
## 27. Roles (MT-15)
|
||||
|
||||
`users.role` ENUM: `admin`, `director`, `inspector`, `external_inspector`,
|
||||
`project_manager`, `customer`, `auditor`.
|
||||
|
||||
**Never test `role == 'inspector'`.** `external_inspector` (customer /
|
||||
third-party inspectors) has identical capabilities and identical
|
||||
`InspectorAssignment` scoping. Use:
|
||||
|
||||
* `user.is_inspector` — true for both inspector roles; use for every capability
|
||||
and scoping check
|
||||
* `User.INSPECTOR_ROLES` — for `User.role.in_(...)` queries
|
||||
* `user.is_external_inspector` — only where the two genuinely differ (display)
|
||||
* `user.role_label` / `ROLE_LABELS` — for any role name shown in the UI
|
||||
|
||||
A literal comparison sends external inspectors down the unscoped branch, where
|
||||
`get_inspector_scope()` returns `None` and every downstream query drops its
|
||||
facility filter. That is a cross-customer leak.
|
||||
|
||||
External inspectors are **invited**, never given a password: `password_set=False`
|
||||
plus an emailed 72-hour token, with `auth.resend_invite` for bounced invitations.
|
||||
|
||||
---
|
||||
|
||||
## 27b. Customer-side roles, per-account notifications, per-contract forms (Aug 2026 ST parity)
|
||||
|
||||
Ported from the single-tenant app (its phase51 + phase52 + the August fixes).
|
||||
Nothing here is tenant-aware in its own right — every table lives in the tenant
|
||||
DB and every query routes through `RoutingSession` as usual.
|
||||
|
||||
### The two customer-side roles
|
||||
|
||||
| Stored ENUM value | Display label | Scoped by | Capabilities |
|
||||
|---|---|---|---|
|
||||
| `customer` | **Customer Director** | `CustomerAssignment` | The portal, unchanged — plus planning scheduled inspections at their own facilities |
|
||||
| `external_inspector` | **Customer Inspector** | `InspectorAssignment` | Identical to the internal `inspector`, plus the customer support surface (AI chat + tickets) |
|
||||
|
||||
**LABEL-only rename** — the ENUM values are untouched, so no migration and no
|
||||
role check moved. `User.ROLE_LABELS` is the one place the names live.
|
||||
|
||||
`User.CUSTOMER_ROLES = ('customer', 'external_inspector')` and
|
||||
`User.is_customer_account` answer an **account-management** question ("is this
|
||||
managed under `/customers`?"). Every **capability** check — portal gates,
|
||||
`@customer_required`, `get_customer_scope()`, `notify_customers_for_facility()`,
|
||||
the customer branch of each `app/api/*` module — keeps testing
|
||||
`role == 'customer'` exactly (rule 99).
|
||||
|
||||
Both roles are now created, invited, assigned and switched in **Customer
|
||||
Management** (`/customers`); `auth.list_users` excludes them and the
|
||||
`/auth/users/...` URLs redirect to `customers.manage`. `UserForm` no longer
|
||||
offers `external_inspector`, and `auth.create_user` always requires a password
|
||||
— customer-side accounts are invited (they choose their own username and
|
||||
password) via `customers.create()`. `POST /customers/<id>/switch-role`
|
||||
(admin only) mirrors contracts across the two scoping tables and revokes the
|
||||
account's refresh tokens + device rows, because API access differs between them.
|
||||
|
||||
### `UserNotificationMatrix` (per-account overrides)
|
||||
|
||||
Row `enabled=True` = send even if the global column is OFF; `enabled=False` =
|
||||
never send even if it is ON; **no row = inherit**. Inherit is the default, so
|
||||
the table shipped empty and changed routing for nobody, and setting a row back
|
||||
to inherit DELETES it. Helpers live in
|
||||
`app/models/user_notification_matrix.py`; edited on the account's Customer
|
||||
Management page as a tri-state. Enforced in **`notify()` as well as**
|
||||
`notify_by_matrix()` — follower fan-out and direct assignee notifications reach
|
||||
`notify()` straight, so gating only the matrix left rows that read as Off while
|
||||
notifications kept arriving. See rules 100 and 101.
|
||||
|
||||
### `TemplateContract` (forms per contract)
|
||||
|
||||
`InspectionTemplate.available_query(project_id)` is the single definition of
|
||||
"which forms may this contract use" — pickers, the POST validation behind them,
|
||||
the schedule form and `GET /api/v1/templates` all call it. **No rows = shared**
|
||||
(rule 102). Managed in three places: the template list's Edit modal
|
||||
(`POST /templates/<id>/rename`, carrying a hidden `contracts_present=1`
|
||||
marker), Create Template, and the full form editor. `duplicate_template()`
|
||||
copies the restrictions.
|
||||
|
||||
### Other ported behaviour
|
||||
|
||||
* **Bulk actions** on the issues and inspections lists (`POST /issues/bulk`,
|
||||
`POST /inspections/bulk`) with shared partials in `templates/partials/`.
|
||||
Toolbar form sits OUTSIDE the table; row checkboxes join it with the HTML5
|
||||
`form=` attribute (rule 103). Deletes remove DB rows first, storage keys
|
||||
second (rule 104).
|
||||
* **List filter preservation** — `current_url()` (Jinja global) +
|
||||
`return_url(fallback)` (`utils/decorators`) round-trip the full list URL as
|
||||
`next`, so an edit or delete returns to the filtered page.
|
||||
`safe_redirect_url` still guards every hop.
|
||||
* **Flag-issue assignee scoping** — `_assignable_staff_for(inspection, actor)`
|
||||
in `routes/inspections.py` is the single source for both the offcanvas
|
||||
dropdown and `form.assigned_to.choices` (the actual POST validation). A
|
||||
failed flag-issue POST now returns **400**, because the offcanvas JS branches
|
||||
on `res.ok` (rule 105).
|
||||
* **Customer Directors plan inspections** — `schedule_manager_required` in
|
||||
`routes/inspection_schedules.py` = the manager set plus `role == 'customer'`.
|
||||
Because that blueprint builds its form by hand (no WTForms SelectField),
|
||||
narrowing the choice lists is NOT the validation: `_scope_errors()`
|
||||
re-checks facility, inspector and form-vs-contract on every POST, and
|
||||
`_schedule_in_scope()` guards edit/delete. Start remains the assignee's.
|
||||
* **Support chat serves both customer roles** — `_is_customer_side()` opens the
|
||||
door, then `_support_facilities()` branches per role (CustomerAssignment vs
|
||||
InspectorAssignment) and `_system_prompt_for()` appends
|
||||
`_INSPECTOR_ADDENDUM` for a Customer Inspector. The curated knowledge base is
|
||||
now spliced in **before** the `Rules:` heading (`_STYLE_MARKER`) — appended
|
||||
after it, the prompt's own "ground answers in everything above" put it out of
|
||||
scope, which is why KB entries looked ignored. `/support/admin/knowledge/preview`
|
||||
shows the exact prompt. `GROQ_MODEL` defaults to `_DEFAULT_GROQ_MODEL`
|
||||
(`openai/gpt-oss-120b`); Groq retires models without notice, and the error
|
||||
handler names the model and says to set `GROQ_MODEL` (rule 106).
|
||||
* **`COMMENTS_VISIBLE_TO_ALL`** (config, default true) lifts the phase22 read
|
||||
filter so customers see every comment. `is_customer_visible` is still
|
||||
written, so flipping it back restores the old behaviour with nothing to
|
||||
repair. `issues/view.html` gates the internal chrome on
|
||||
`viewer_is_our_staff` — an explicit allowlist of OUR roles, which fails
|
||||
closed and deliberately excludes `external_inspector` (rule 107).
|
||||
|
||||
---
|
||||
|
||||
## 27c. Follow-up assignment + inspector read access (Aug 2026 ST parity)
|
||||
|
||||
Ported from ST (its phase53 + the two fixes shipped beside it).
|
||||
|
||||
### Assigning a follow-up (phase56)
|
||||
|
||||
A follow-up used to belong implicitly to whoever performed the original
|
||||
inspection. `inspections.follow_up_assigned_to` lets a director — or a
|
||||
**Customer Director**, for their own facilities — hand the re-inspection to
|
||||
someone else. `Inspection.follow_up_owner` (assignee *or* inspector) is the
|
||||
single definition of ownership, so the web display, the notification and the
|
||||
mobile API filter cannot disagree.
|
||||
|
||||
**The assignee takes over**: only the owner is notified, and only the owner
|
||||
sees it. In `GET /api/v1/inspections?follow_up_required=true` the two arms are
|
||||
mutually exclusive — without `is_(None)` on the second arm the original
|
||||
inspector keeps seeing a follow-up handed to someone else and two people turn
|
||||
up to do it. The generic "inspectors see only their own inspections" filter is
|
||||
**deferred** when follow-ups are requested, because an assigned follow-up lives
|
||||
on an inspection somebody else performed.
|
||||
|
||||
The picker (`_followup_assignees_for()`) is contract-scoped for the same reason
|
||||
the flag-issue list is (rule 105), offers only the two INSPECTOR roles, and the
|
||||
POST re-validates against it. A facility with no contract offers nobody —
|
||||
fail-closed, the follow-up stays with the original inspector.
|
||||
|
||||
**MT-only gap closed on the way:** MT's `GET /api/v1/inspections` had no
|
||||
`follow_up_required` filter at all, so the iPad's Follow-up Requests screen
|
||||
received the inspector's entire history. The filter now matches the web's
|
||||
definition of "follow-up" — flagged, completed, and not yet answered by a
|
||||
linked re-inspection (`~follow_ups.any()`).
|
||||
|
||||
### Inspector READ access follows the list, not authorship
|
||||
|
||||
`index()` scopes an inspector by FACILITY (rule 58), but `view()` and
|
||||
`export_pdf()` scoped by authorship — so the list offered rows that answered
|
||||
"Access denied" on click, and the follow-up assignee could not open the parent
|
||||
inspection they had just been asked to re-inspect. Both reads now use
|
||||
`_inspector_may_read()` (facility scope). **Writes stay owner-only**: `execute`,
|
||||
`save_draft_ajax`, `upload_photo_ajax` and `flag_issue` keep the authorship
|
||||
check. `reinspect()` belongs to the follow-up's owner; the buttons render only
|
||||
for `is_own_inspection or owns_follow_up`, so the page never shows a control
|
||||
that fails on click.
|
||||
|
||||
### One rule, two expressions, three callers
|
||||
|
||||
Ownership has to be stated twice — once for a loaded row, once in SQL — so both
|
||||
live together in `models/inspection.py`:
|
||||
|
||||
* `follow_up_owner` — the property (assignee, else inspector)
|
||||
* `follow_up_owned_by(user_id)` — the query predicate
|
||||
|
||||
Every query that scopes follow-ups calls the predicate: the mobile list filter,
|
||||
the web dashboard card, and the iPad stats KPI. They each used to write their
|
||||
own version and three tested AUTHORSHIP, so an assignee saw the work in their
|
||||
list while both dashboards read 0 — the stats KPI sitting directly above the
|
||||
Follow-up Requests list it disagreed with. Fixed in ST at the same time.
|
||||
Pinned by `tests/test_followup_ownership.py`.
|
||||
|
||||
---
|
||||
|
||||
## 28. Coding Rules for AI Assistants
|
||||
|
||||
These rules apply to every change made to this codebase, without exception.
|
||||
|
||||
@@ -1865,4 +2233,131 @@ curl -sI -H "Host: ztest.jqc.app" http://127.0.0.1:8000/ | head -2
|
||||
# 5. Wildcard TLS cert (DNS-01, certbot)
|
||||
# 6. Add to /etc/jqc/control.env: MULTI_TENANT_ENABLED=true
|
||||
# 7. sudo systemctl daemon-reload && sudo systemctl restart jqc
|
||||
```
|
||||
```
|
||||
---
|
||||
|
||||
## 29. Photo Object Storage (R2)
|
||||
|
||||
**Goal:** photo **files** live in Cloudflare R2 (S3-compatible, $0 egress), not on
|
||||
the server's local disk. The DB is not the bottleneck — rows are tiny, PDFs are
|
||||
streamed via `BytesIO` and never written to disk. Only photos accumulate, and in
|
||||
a multi-tenant deployment they accumulate from every tenant onto one volume.
|
||||
|
||||
**No schema change, ever.** The DB stores an unprefixed relative path
|
||||
(`uploads/issue_photos/abc.jpg`) and continues to. That string is the storage
|
||||
**key**; `app/utils/storage.py` maps it to a backend.
|
||||
|
||||
### Key mapping (the rule that matters)
|
||||
|
||||
| Layer | Value |
|
||||
|---|---|
|
||||
| DB (`issues.photo_path`, `result_photos[]`, `mobile_photo_paths[]`, `inspections.form_data`, `inspection_results.photo_path`) | `uploads/issue_photos/abc.jpg` |
|
||||
| Local backend, on disk | `app/static/uploads/issue_photos/abc.jpg` |
|
||||
| S3 backend, object key | `t<tenant_id>/uploads/issue_photos/abc.jpg` |
|
||||
|
||||
The `t<tenant_id>/` prefix is applied **only** inside `S3Backend._object_key()`,
|
||||
from `g.tenant`. It never enters the DB, a template, or an API payload — so the
|
||||
tenant DB stays portable and every caller stays tenant-agnostic. The **local**
|
||||
backend deliberately does not prefix: prefixing would relocate every existing
|
||||
file, and the local layout must remain byte-identical to what predates the seam.
|
||||
Local mode therefore shares one uploads directory across tenants — an isolation
|
||||
weakness inherited from before multi-tenancy, and the reason to move to `s3`.
|
||||
|
||||
`S3Backend.save()` **raises** when `MULTI_TENANT_ENABLED` is true and no tenant
|
||||
is bound, rather than writing an unprefixed key that a second tenant could later
|
||||
collide with. Reads are more forgiving: `read()` / `exists()` / `delete()` try
|
||||
the prefixed key and then the bare key, so objects written before the prefix
|
||||
existed stay reachable. Pinned by `tests/test_storage_backend.py`.
|
||||
|
||||
### Config (all env, per deployment)
|
||||
|
||||
`STORAGE_BACKEND=local|s3` (default `local`), plus `R2_ENDPOINT_URL`,
|
||||
`R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_BUCKET`, `R2_PRESIGN_TTL`
|
||||
(default 86400), `R2_MEDIA_FALLBACK` (default false). `boto3` is imported lazily,
|
||||
so `local` deployments never touch it — but it **is** in `requirements.txt`,
|
||||
because `STORAGE_BACKEND=s3` fails at first upload without it.
|
||||
|
||||
`STORAGE_BACKEND` is process-wide, not per-tenant: one bucket, one backend, all
|
||||
tenants, isolated by prefix. A per-tenant backend would need the resolver to
|
||||
carry a storage selector and `get_backend()` to cache per tenant instead of per
|
||||
app — do not half-build it.
|
||||
|
||||
**CSP:** `set_security_headers` in `app/__init__.py` derives the R2 host from
|
||||
`R2_ENDPOINT_URL` and appends it to `img-src` automatically. Presigned images are
|
||||
blocked by the browser without this. A custom R2 domain must be added too.
|
||||
|
||||
### Operator scripts
|
||||
|
||||
- `scripts/audit_photos.py` — read-only. Per tenant, collects every key its DB
|
||||
references and reconciles against disk. Records the baseline that must still
|
||||
resolve after cutover, and flags any key claimed by more than one tenant.
|
||||
- `scripts/migrate_photos_to_r2.py` — copy-only, idempotent, resumable,
|
||||
MD5+size verified. Uploads each tenant's referenced files to `t<id>/…`.
|
||||
Exit 0 only when everything verifies. Orphans (referenced by no tenant) are
|
||||
**not** uploaded — no prefix could legitimately claim them.
|
||||
|
||||
Both establish file ownership from tenant DB references, because the shared
|
||||
local directory carries none. Both need `CONTROL_DATABASE_URL` +
|
||||
`CONTROL_FERNET_KEY`; neither writes to any database.
|
||||
|
||||
**Rollback is one env var:** `STORAGE_BACKEND=local` + restart. The sync never
|
||||
deletes local files, so the old tree is intact indefinitely.
|
||||
|
||||
---
|
||||
|
||||
## 30. Database Health Check (`scripts/db_health.py`)
|
||||
|
||||
A standalone operations tool for the MySQL side. It imports the app factory for
|
||||
config and nothing else — no request layer, no uploads tree — and the plain
|
||||
invocation is **strictly read-only** (INFORMATION_SCHEMA / SHOW / EXPLAIN only).
|
||||
|
||||
```bash
|
||||
set -a; . /etc/jqc/control.env; set +a # needed for any --tenant but 'default'
|
||||
|
||||
python scripts/db_health.py # default bind, read-only
|
||||
python scripts/db_health.py --tenant all # every tenant schema
|
||||
python scripts/db_health.py --tenant acme --json /tmp/db.json
|
||||
python scripts/db_health.py --tenant all --analyze # refresh optimizer stats (safe)
|
||||
python scripts/db_health.py --tenant acme --optimize --yes # rebuild (LOCKS)
|
||||
python scripts/db_health.py --tenant all \
|
||||
--emit-migration migrations/versions/phase58_perf_indexes.py \
|
||||
--revision phase58_perf_indexes
|
||||
```
|
||||
|
||||
**Every tenant has its own database built from the same chain, so a schema check
|
||||
is per tenant, not per deployment.** `--tenant` takes `default` (the
|
||||
`SQLALCHEMY_DATABASE_URI` bind — the whole story in single-tenant mode, and the
|
||||
fallback bind in MT), a slug, or `all`. Tenant modes resolve through the control
|
||||
plane exactly as `control/backup.py` does, and open each tenant with a
|
||||
`NullPool` engine: a one-shot CLI must not open a full pool per tenant against
|
||||
the very `max_connections` it is checking.
|
||||
|
||||
The **server-level** checks (`max_connections`, `wait_timeout`,
|
||||
`innodb_buffer_pool_size`, slow-query log, STRICT mode) describe the MySQL
|
||||
instance, not a schema, so they run **once** against the first database opened.
|
||||
Everything schema-shaped — hygiene, footprint, missing/redundant indexes,
|
||||
unindexed FKs, EXPLAIN — runs per tenant, and each finding is tagged with the
|
||||
tenant it came from.
|
||||
|
||||
**It never DROPs anything.** Redundant indexes are reported with the SQL to run
|
||||
by hand, because "unused" is a judgement the tool should not make for you. It
|
||||
also refuses to offer an **FK-backed** index as a drop candidate — dropping one
|
||||
fails with errno 150.
|
||||
|
||||
**Prefer `--emit-migration` over `--apply-indexes` in MT.** An index applied by
|
||||
hand to one tenant leaves every other tenant's schema different from it; a
|
||||
migration reaches all of them through the normal
|
||||
`python -m control.tenant_migrate upgrade --tenant all`. `--emit-migration`
|
||||
writes ONE re-runnable migration for the union of what every inspected tenant is
|
||||
missing (the chain is shared — emitting one per tenant would produce conflicting
|
||||
revisions), with INFORMATION_SCHEMA guards per rule 16. It guesses
|
||||
`down_revision` from the versions directory — confirm against
|
||||
`python -m control.tenant_migrate heads` before committing.
|
||||
|
||||
`RECOMMENDED_INDEXES` in the script is the **single place** the index wish-list
|
||||
lives, and every entry names the query that justifies it. An index nothing runs
|
||||
is pure write-amplification, so keep speculative entries out — and when a new
|
||||
hot query lands, add its index there rather than to an ad-hoc migration, so the
|
||||
checker keeps agreeing with the schema.
|
||||
|
||||
The connection-ceiling check is MT-aware — see **rule 111**.
|
||||
|
||||
+215
-1
@@ -3,6 +3,8 @@
|
||||
> **Audience:** AI assistants and developers extending JQC into a multi-tenant SaaS.
|
||||
> **Companion to:** `CLAUDE.md` (single-tenant architecture reference).
|
||||
> **Status:** MT-0 through MT-8 complete and deployed (MT-8 billing is flag-gated behind `BILLING_ENABLED`, default off). MT-9 (iOS multi-tenant) is fully pending — both the server-side discovery endpoints and the iOS client are unbuilt.
|
||||
>
|
||||
> **Feature parity with the single-tenant tree (MT-10 → MT-17): complete.** MT forked from ST before ST kept shipping, and that gap has now been closed phase by phase — see §12. The only deliberate divergence is ST's `phase50_default_modern`, which MT does not adopt (§12.3). Tenant migration head: `phase52_user_ui_theme`.
|
||||
|
||||
---
|
||||
|
||||
@@ -225,6 +227,61 @@ Stripe per-plan subscription, fully implemented in `app/billing/` (`routes.py`,
|
||||
**MT-9 — iOS multi-tenant. 🔲 PENDING (server + client both unbuilt).**
|
||||
Planned server side: `GET /api/v1/discover?subdomain=acme` and `GET /api/v1/tenant` public endpoints — to be exempt from tenant middleware via `MULTI_TENANT_EXEMPT_PATHS`. **Neither endpoint exists in the code yet** — no `api_discovery` blueprint is registered. iOS side: also pending (web-first priority).
|
||||
|
||||
**MT-20 — R2 photo storage cutover. ✅ CODE DONE / 🔲 CUTOVER PENDING.**
|
||||
No schema change, no migration. The data-plane code was already fully on the
|
||||
storage seam (`storage.save()` / `media_url()` / `materialize_to_dir()` /
|
||||
`delete()` at every inspection and issue photo site) — MT served local purely
|
||||
because `STORAGE_BACKEND` had never been flipped, and it **could not** be:
|
||||
|
||||
- `boto3` was missing from `requirements.txt`, so `S3Backend.__init__`'s lazy
|
||||
import would have raised `ModuleNotFoundError` on the first upload after the
|
||||
flip — at request time, not at boot. Now pinned (`boto3>=1.34`, matching ST).
|
||||
- there was no cutover tooling, and ST's could not be reused (below).
|
||||
|
||||
**Why ST's sync script does not port.** The local backend deliberately does not
|
||||
prefix keys, so one shared `app/static/uploads/` holds every tenant's photos and
|
||||
a file on disk carries no ownership marker. ST's script walks the disk and
|
||||
uploads everything, which on MT writes objects with no tenant prefix — keys
|
||||
`S3Backend._object_key()` will never read. Ownership must instead be derived
|
||||
from each tenant DB's references, then written under `t<tenant_id>/`.
|
||||
|
||||
| Layer | Value |
|
||||
|---|---|
|
||||
| DB | `uploads/issue_photos/abc.jpg` |
|
||||
| Disk (local backend) | `app/static/uploads/issue_photos/abc.jpg` |
|
||||
| R2 object (s3 backend) | `t3/uploads/issue_photos/abc.jpg` |
|
||||
|
||||
Delivered:
|
||||
- `scripts/audit_photos.py` — read-only. Per tenant, collects every key its DB
|
||||
references (5 sources: `issues.photo_path`, `.mobile_photo_paths[]`,
|
||||
`.result_photos[]`, `inspections.form_data`, `inspection_results.photo_path`)
|
||||
and reconciles against disk. Emits the baseline count that must still resolve
|
||||
after cutover, plus orphans and any key claimed by more than one tenant.
|
||||
- `scripts/migrate_photos_to_r2.py` — copy-only, idempotent, resumable,
|
||||
MD5+size verified, tenant-prefixed. Exit 0 only on full verification.
|
||||
Orphans are **not** uploaded: no prefix could legitimately claim them.
|
||||
- `tests/test_storage_backend.py` — pins the prefix arithmetic, the bare key
|
||||
returned to the DB, the refusal to write unprefixed with no tenant bound, and
|
||||
the legacy-unprefixed read/delete fallback.
|
||||
|
||||
Both scripts read the control DB then each tenant DB via raw SQL (no Flask app
|
||||
context — the ORM's default bind is the wrong database for every tenant), and
|
||||
the sync imports `collect_referenced` from the audit script so the two key sets
|
||||
can never diverge. Neither writes to any database.
|
||||
|
||||
`STORAGE_BACKEND` is process-wide, so cutover is all-tenants-at-once; isolation
|
||||
comes from the prefix, not from separate backends. Per-tenant backend selection
|
||||
would require the resolver to carry a storage selector and `get_backend()` to
|
||||
cache per tenant rather than per app — do not half-build it.
|
||||
|
||||
Rollback is one env var (`STORAGE_BACKEND=local` + restart); the sync never
|
||||
deletes local files.
|
||||
|
||||
**Open, deliberately not done in MT-20:** `routes/tenant_settings.py::_save_logo`
|
||||
still writes tenant logos directly to `static/uploads/logos/` with `os.path.join`,
|
||||
bypassing the seam. After cutover it is the only remaining local-disk writer, so
|
||||
logos would sit outside whatever backs up R2.
|
||||
|
||||
---
|
||||
|
||||
## 8. Tenant-zero (LT Services) migration
|
||||
@@ -328,4 +385,161 @@ python -m control.tenant_migrate bootstrap --tenant acme # fresh DB only
|
||||
# Superadmin panel
|
||||
sudo systemctl status jqc-panel
|
||||
sudo systemctl restart jqc-panel
|
||||
```
|
||||
```
|
||||
---
|
||||
|
||||
## 12. Feature parity with the single-tenant tree (MT-10 → MT-17)
|
||||
|
||||
MT forked from the single-tenant codebase (`LT_Janitorial_Quality_Control`, "ST")
|
||||
before ST continued shipping features. This section records how that gap was
|
||||
closed. It is **complete** as of MT-17.
|
||||
|
||||
### 12.1 Why the two trees look more different than they are
|
||||
|
||||
A file-by-file comparison of the two trees overstates the gap. Several ST files
|
||||
have no MT counterpart *by name* while the feature is fully present under MT's
|
||||
own naming. These are **not** gaps and must not be "fixed":
|
||||
|
||||
| ST | MT equivalent |
|
||||
|---|---|
|
||||
| `models/scheduled_inspection.py` | `models/inspection_schedule.py` |
|
||||
| `routes/scheduled_inspections.py` | `routes/inspection_schedules.py` |
|
||||
| `routes/public.py` | `routes/facility_qr.py` |
|
||||
| `models/notification_recipient.py` | `models/project_recipient.py` |
|
||||
| `ContractNotificationRecipient` / `get_event_types()` | `ProjectNotificationRecipient` / `get_events()` |
|
||||
| `app/add_form_schema.py` | `scripts/add_form_schema.py` |
|
||||
| `support/admin_conversation_detail.html` | `support/conversation_detail.html` |
|
||||
|
||||
There is also one place where **MT is ahead of ST**: `utils/forms.py`
|
||||
`strong_password()` (length + complexity + common-password blocklist) versus
|
||||
ST's bare `Length(min=6)`. Porting ST's version would be a downgrade.
|
||||
|
||||
### 12.2 Phase log
|
||||
|
||||
Migrations `phase41` → `phase56` in `migrations/versions/` are the parity track:
|
||||
auditor role, area QR tokens, schedule plan fields, internal handler, frequency
|
||||
ENUM widening, recurrence, end date, parent inspection, follow-up attribution,
|
||||
schedule acknowledgement, and then:
|
||||
|
||||
**Aug 2026 tail — ST parity, ported after MT-17.** ST's chain numbers these
|
||||
differently (its phase51/52/53); MT's was already past those numbers, so match
|
||||
by NAME:
|
||||
|
||||
| MT | ST | What |
|
||||
|---|---|---|
|
||||
| `phase54_user_notif_matrix` | phase51 | Per-account notification overrides for the two customer-side roles. Empty table = everyone inherits the global matrix, so it shipped changing nothing. See CLAUDE.md §27b. |
|
||||
| `phase55_template_contracts` | phase52 | Forms restricted to specific contracts. **No rows = shared**, which is why it needed no backfill. |
|
||||
| `phase56_followup_assignee` | phase53 | `inspections.follow_up_assigned_to` — hand a re-inspection to another inspector. NULL = the inspection's own inspector, as before. See CLAUDE.md §27c. |
|
||||
|
||||
Shipped alongside them, without migrations: customer-role management under
|
||||
`/customers`, bulk actions on the issues/inspections lists, list-filter
|
||||
preservation, contract-scoped flag-issue assignees, Customer Directors planning
|
||||
their own inspections, and the support surface serving both customer roles.
|
||||
|
||||
**MT-15 — External Inspector role. ✅ DONE** (`phase51_external_inspector`)
|
||||
Adds `external_inspector` to the `users.role` ENUM: an inspector employed by the
|
||||
customer or a third party, with identical capabilities to `inspector` and scoped
|
||||
the same way through `InspectorAssignment`.
|
||||
|
||||
The ENUM widening and the code **must ship together**. MT had ~80 sites testing
|
||||
`role == 'inspector'` with a literal comparison; widening the ENUM alone would
|
||||
make every one of them evaluate False for the new role and fall through to the
|
||||
*unscoped* branch — `get_inspector_scope()` returns `None`, downstream queries
|
||||
drop their facility filter, and an inspector employed by one customer sees every
|
||||
other customer's contracts. `User.INSPECTOR_ROLES` (exposed as the
|
||||
`is_inspector` property) is now the single definition, and
|
||||
`test_external_inspector_scope_is_not_unrestricted` fails loudly if anyone
|
||||
reverts a membership test to a literal.
|
||||
|
||||
Also in this phase: external inspectors are **invited**, never given a password
|
||||
(`password_set=False` + emailed 72-hour token, reusing the customer invite mail),
|
||||
with a new `auth.resend_invite` route so a bounced invitation cannot brick an
|
||||
account permanently. Creating any other role with a blank password is now
|
||||
rejected — it previously stored the hash of the empty string.
|
||||
|
||||
**MT-16 — Modern web portal design. ✅ DONE** (`phase52_user_ui_theme`)
|
||||
Ports ST's `phase48`. `base.html` became a one-line dispatcher
|
||||
(`{% extends jqc_layout %}`); the old chrome moved verbatim to
|
||||
`layouts/classic.html`; `layouts/modern.html` is the sidebar shell. All existing
|
||||
page templates needed **zero edits** — Jinja resolves `{% block %}` overrides
|
||||
through the whole inheritance chain.
|
||||
|
||||
`ThemedEnvironment.get_template()` swaps `x.html` → `modern/x.html` for modern
|
||||
users. **The swap is in `get_template()`, not the loader, on purpose:** Jinja's
|
||||
template cache is keyed on the name `get_template()` receives, so a cached
|
||||
modern template can never be served to a classic user — and in MT, where one
|
||||
Gunicorn worker serves many tenants, a loader-level swap would leak across
|
||||
tenants too.
|
||||
|
||||
MT-specific adaptations that ST's files required: tenant branding (ST hardcodes
|
||||
its own company name), `inspection_schedules` for ST's `scheduled_inspections`,
|
||||
`facilities.qr_print_all` for ST's `facility_qr_print_all`, the billing banner,
|
||||
`role_label` for MT-15's new role, and a rewritten tenant-neutral About page.
|
||||
`_quota_warning.html` is deliberately **not** in the modern layout — it is a
|
||||
per-form include, not chrome, and would render twice on four pages.
|
||||
|
||||
**MT-17 — Enrollment intake form. ✅ DONE** (no migration)
|
||||
Ports ST's `app/enrollment/` — a public, login-free intake form plus an
|
||||
admin-only inbox, kept deliberately outside the schema (flat JSON, no model, no
|
||||
migration, deletable package).
|
||||
|
||||
**Tenant isolation was the change ST's version required.** ST keeps every
|
||||
submission in one flat directory; in MT that directory is shared by every tenant
|
||||
on the host, so `/enrollment/admin` would list other organisations' submissions.
|
||||
Submissions are now filed under `<ENROLLMENT_DIR>/t<tenant_id>/`, mirroring
|
||||
`storage.tenant_key_prefix()`. When multi-tenancy is on and no tenant is bound,
|
||||
`storage.enrollment_dir()` **raises `TenantUnresolved` rather than falling back
|
||||
to the root** — a fallback would be a silent cross-tenant leak; an exception is
|
||||
loud and safe.
|
||||
|
||||
Branding was the second change: ST hardcodes its company name in four places and
|
||||
a personal Gmail address as the customer-facing "corrections" contact. Both now
|
||||
resolve from `TenantSettings`, with a test that greps the package so they cannot
|
||||
silently return.
|
||||
|
||||
### 12.3 Deliberate divergence: ST `phase50_default_modern` is NOT ported
|
||||
|
||||
ST's `phase50` flips the `ui_theme` column default to `modern` **and** runs:
|
||||
|
||||
```sql
|
||||
UPDATE users SET ui_theme = 'modern' WHERE ui_theme = 'classic';
|
||||
```
|
||||
|
||||
That overwrites every saved preference. It was defensible for a single-tenant
|
||||
deployment deciding for its own staff after its own A/B test.
|
||||
|
||||
**It is not portable to MT.** The same statement runs against *every tenant
|
||||
database*, flipping the entire UI for tenants who never saw the test and never
|
||||
asked. MT therefore ships the `phase48` semantics only: default `classic`, **no
|
||||
backfill of any kind**.
|
||||
|
||||
The effective default for accounts that never chose is config
|
||||
`DEFAULT_UI_THEME` (`app/__init__.py::resolve_ui_theme`), which reads the
|
||||
environment and itself defaults to `classic`. A stored `users.ui_theme` always
|
||||
wins. **To put a tenant on the modern design, set `DEFAULT_UI_THEME=modern` in
|
||||
that tenant's process environment** — a config change, reversible, with no
|
||||
preferences destroyed. `test_new_user_defaults_to_classic` pins this so a future
|
||||
port of `phase50` has to be a deliberate act.
|
||||
|
||||
### 12.4 Open decisions
|
||||
|
||||
- **Seat quota.** `tenancy/quota.py::count_active_users()` counts all active
|
||||
users regardless of role, so external inspectors consume a seat against
|
||||
`max_users`. Intentional (they are real accounts), but tenants near their cap
|
||||
will hit `@quota_soft_check('users')` when inviting third parties. Excluding
|
||||
them is a billing-policy decision, not a bug fix.
|
||||
- **`enrollment/schema.py::CORRECTIONS_EMAIL`** is now an empty last-resort
|
||||
default. Decide whether to drop the constant and its two config fallbacks in
|
||||
favour of requiring `TenantSettings.support_email`.
|
||||
|
||||
### 12.5 Deferred, with reasons
|
||||
|
||||
- **`_handler_split` dashboard cards.** ST's classic dashboard shows handler
|
||||
breakdowns for *opened today* and *unassigned* as well as open issues. MT
|
||||
supplies `handler_breakdown` (open issues) and both MT dashboards render it;
|
||||
the other two would require converting `.count()` queries to `.all()` and
|
||||
fetching full rows for a cosmetic card, which regresses large tenants. If
|
||||
wanted, do it as a SQL `GROUP BY handler_type` rather than ST's Python-side
|
||||
count over fetched rows.
|
||||
- **PDF / audit hardening.**
|
||||
- **MT-9 iOS client** (see §7).
|
||||
|
||||
+137
-2
@@ -18,8 +18,25 @@ login_manager = LoginManager()
|
||||
migrate = Migrate()
|
||||
mail = Mail()
|
||||
csrf = CSRFProtect() # initialized here; .init_app() called in create_app()
|
||||
def _rate_limit_key():
|
||||
"""MT-21: scope rate-limit buckets per tenant as well as per client IP.
|
||||
|
||||
With a bare remote-address key, two tenants behind the same NAT egress
|
||||
share every route's counter, so one tenant's traffic can lock another out
|
||||
of (for example) /auth/login. Falls back to the plain address in
|
||||
single-tenant mode and on tenant-exempt paths, leaving today's buckets
|
||||
unchanged there.
|
||||
"""
|
||||
from flask import g, has_request_context
|
||||
addr = get_remote_address()
|
||||
if not has_request_context():
|
||||
return addr
|
||||
tenant = getattr(g, 'tenant', None)
|
||||
return f't{tenant.id}|{addr}' if tenant is not None else addr
|
||||
|
||||
|
||||
limiter = Limiter(
|
||||
key_func = get_remote_address,
|
||||
key_func = _rate_limit_key,
|
||||
default_limits = [], # no global limit — applied per-route only
|
||||
# Use Redis when REDIS_URL is set in the environment (production multi-worker).
|
||||
# Falls back to in-process memory for local development (single-worker only;
|
||||
@@ -28,15 +45,57 @@ limiter = Limiter(
|
||||
)
|
||||
|
||||
|
||||
# ── Web portal design: per-request template overrides (MT-16) ────────────────
|
||||
# A user on the 'modern' design gets templates/modern/<name>.html in place of
|
||||
# templates/<name>.html whenever that override exists; otherwise the normal
|
||||
# template is used and only the layout shell + CSS differ.
|
||||
#
|
||||
# The rewrite happens in get_template() (not in the loader) so Jinja's template
|
||||
# cache is keyed on the REWRITTEN name — a cached modern template can never be
|
||||
# served to a classic user, or vice versa. A loader-level swap would have that
|
||||
# bug, and in MT it would leak across tenants sharing a worker process.
|
||||
from flask.templating import Environment as _FlaskJinjaEnvironment
|
||||
|
||||
|
||||
class ThemedEnvironment(_FlaskJinjaEnvironment):
|
||||
"""Jinja environment that redirects template names to modern/<name>."""
|
||||
|
||||
# Populated once in create_app() by scanning templates/modern/.
|
||||
jqc_modern_templates: set = set()
|
||||
|
||||
def get_template(self, name, parent=None, globals=None):
|
||||
if (isinstance(name, str)
|
||||
and self.jqc_modern_templates
|
||||
and not name.startswith('modern/')):
|
||||
candidate = 'modern/' + name
|
||||
if candidate in self.jqc_modern_templates:
|
||||
from flask import g, has_request_context
|
||||
if has_request_context() and getattr(g, 'jqc_theme', 'classic') == 'modern':
|
||||
name = candidate
|
||||
return super().get_template(name, parent, globals)
|
||||
|
||||
|
||||
def create_app(config_name='default'):
|
||||
app = Flask(__name__)
|
||||
# Must be assigned BEFORE app.jinja_env is first touched (it is a cached
|
||||
# property), so the themed subclass is the one actually instantiated.
|
||||
app.jinja_environment = ThemedEnvironment
|
||||
app.config.from_object(config[config_name])
|
||||
|
||||
# Unwrap X-Forwarded-For / X-Forwarded-Proto set by Nginx so Flask sees
|
||||
# the real client IP (needed for rate limiting and fail2ban logging) and
|
||||
# the real scheme (needed for HTTPS URL generation in emails).
|
||||
#
|
||||
# MT-24: x_host is deliberately 0. With x_host=1, `request.host` was taken
|
||||
# from the X-Forwarded-Host header — and nginx forwards unrecognised client
|
||||
# headers upstream, so any client could supply that header and choose which
|
||||
# tenant database the request bound to. Nginx already sets `Host $host`
|
||||
# from the real SNI/Host, so HTTP_HOST is the trustworthy source and
|
||||
# X-Forwarded-Host adds nothing but an attacker-controlled input.
|
||||
# The nginx configs also pin X-Forwarded-Host explicitly (defence in depth);
|
||||
# neither layer alone is relied upon. See deploy/nginx/README.md.
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
|
||||
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=0)
|
||||
|
||||
db.init_app(app)
|
||||
login_manager.init_app(app)
|
||||
@@ -127,6 +186,74 @@ def create_app(config_name='default'):
|
||||
from app.utils import storage as _storage
|
||||
app.jinja_env.globals['media_url'] = _storage.media_url
|
||||
|
||||
# Current page URL including its query string — what list pages hand to
|
||||
# their actions as `next` so filters survive an edit/delete round trip
|
||||
# (see utils/decorators.return_url). full_path always appends '?', which
|
||||
# is harmless but makes for ugly links, so strip a bare trailing one.
|
||||
def _current_url():
|
||||
from flask import request
|
||||
return request.full_path.rstrip('?') if request else ''
|
||||
app.jinja_env.globals['current_url'] = _current_url
|
||||
|
||||
# ── Web portal design wiring (MT-16) ──────────────────────────────────
|
||||
# Index the modern/ override templates once at boot, so get_template()
|
||||
# never has to touch the filesystem per request.
|
||||
_modern_root = os.path.join(app.template_folder or 'templates', 'modern')
|
||||
if not os.path.isabs(_modern_root):
|
||||
_modern_root = os.path.join(app.root_path, _modern_root)
|
||||
_modern_set = set()
|
||||
if os.path.isdir(_modern_root):
|
||||
for _dirpath, _dirnames, _filenames in os.walk(_modern_root):
|
||||
for _fn in _filenames:
|
||||
if _fn.endswith('.html'):
|
||||
_rel = os.path.relpath(os.path.join(_dirpath, _fn), _modern_root)
|
||||
_modern_set.add('modern/' + _rel.replace(os.sep, '/'))
|
||||
ThemedEnvironment.jqc_modern_templates = _modern_set
|
||||
app.logger.info('UI themes | modern overrides indexed: %s', len(_modern_set))
|
||||
|
||||
from flask import g, request as _request
|
||||
|
||||
@app.before_request
|
||||
def resolve_ui_theme():
|
||||
"""Stash the active design on `g` for ThemedEnvironment.get_template()."""
|
||||
# The mobile API renders no templates and authenticates by JWT — skip it
|
||||
# so this never touches the Flask-Login session loader on API traffic.
|
||||
if _request.path.startswith('/api/'):
|
||||
# The API renders no templates; 'classic' here only means "never
|
||||
# rewrite a template name" (see ThemedEnvironment.get_template).
|
||||
g.jqc_theme = 'classic'
|
||||
return
|
||||
from flask_login import current_user as _cu
|
||||
# MT-16 — the fallback is configurable per deployment. It defaults to
|
||||
# 'classic' so an existing tenant's users see no change until they opt
|
||||
# in; a stored users.ui_theme always wins over the default.
|
||||
default = app.config.get('DEFAULT_UI_THEME', 'classic')
|
||||
theme = default
|
||||
try:
|
||||
if _cu.is_authenticated:
|
||||
theme = _cu.ui_theme or default
|
||||
except Exception: # DB column missing (migration not yet run)
|
||||
theme = default
|
||||
g.jqc_theme = theme if theme in ('classic', 'modern') else default
|
||||
|
||||
@app.context_processor
|
||||
def inject_ui_theme():
|
||||
"""Give base.html the shell to extend."""
|
||||
from app.utils.time_utils import now_eastern
|
||||
theme = getattr(g, 'jqc_theme',
|
||||
app.config.get('DEFAULT_UI_THEME', 'classic'))
|
||||
_now = now_eastern()
|
||||
return {
|
||||
'jqc_theme': theme,
|
||||
'jqc_layout': 'layouts/modern.html' if theme == 'modern'
|
||||
else 'layouts/classic.html',
|
||||
# Long-form date shown in the modern dashboard header. The day is
|
||||
# interpolated rather than formatted with '%-d' — that flag is a
|
||||
# glibc extension and raises ValueError on Windows, which would
|
||||
# 500 every page (this context processor runs on both themes).
|
||||
'now_display': f'{_now.strftime("%A, %B")} {_now.day}, {_now.year}',
|
||||
}
|
||||
|
||||
# ── Inject unread notification count into every template context ──────
|
||||
# This powers the red badge on the navbar bell icon without requiring
|
||||
# individual routes to pass the count manually.
|
||||
@@ -221,6 +348,7 @@ def create_app(config_name='default'):
|
||||
from app.routes import tenant_settings # MT-7 — tenant self-service
|
||||
from app.routes import signup # MT-8+ — public self-service signup
|
||||
from app.routes import landing # Public apex marketing/landing page
|
||||
from app.routes import ui # MT-16 — design switch + new pages
|
||||
from app.billing import bp as billing_bp # MT-8 — Stripe billing
|
||||
|
||||
app.register_blueprint(auth.bp)
|
||||
@@ -244,6 +372,13 @@ def create_app(config_name='default'):
|
||||
app.register_blueprint(tenant_settings.bp)
|
||||
app.register_blueprint(signup.bp)
|
||||
app.register_blueprint(landing.bp)
|
||||
app.register_blueprint(ui.bp)
|
||||
|
||||
# ── Enrollment form (self-contained — see app/enrollment/__init__.py) ────
|
||||
# Registered last and via its own helper so the package stays deletable:
|
||||
# removing app/enrollment/ and these two lines removes the feature entirely.
|
||||
from app.enrollment import register_enrollment
|
||||
register_enrollment(app)
|
||||
# Billing blueprint is CSRF-exempt: /billing/webhook receives raw POST from
|
||||
# Stripe and cannot carry a CSRF token. Subscribe/portal are GET redirects
|
||||
# which Flask-WTF does not protect anyway (CSRF only applies to unsafe methods).
|
||||
|
||||
@@ -32,6 +32,7 @@ import logging
|
||||
|
||||
from flask import Blueprint, request, g
|
||||
from app import db, limiter
|
||||
from app.tenancy.gates import feature_required
|
||||
from app.models.user import User
|
||||
from app.models.api_token import RefreshToken, DeviceToken
|
||||
from app.api.errors import api_ok, api_error
|
||||
@@ -61,6 +62,13 @@ def _user_payload(user: User) -> dict:
|
||||
|
||||
@bp.route('/auth/login', methods=['POST'])
|
||||
@limiter.limit('10 per minute; 3 per second')
|
||||
# The plan gate belongs HERE, not only on the write endpoints. It used to sit
|
||||
# on POST /inspections and POST /issues alone, so a tenant without mobile API
|
||||
# access could sign in, sync reference data and let an inspector complete a
|
||||
# whole inspection on site — and only then get a 403, with the work already
|
||||
# done and no way to submit it. Refusing at the door is the honest answer.
|
||||
# Inert in single-tenant mode and for any plan that allows the mobile API.
|
||||
@feature_required('mobile_api')
|
||||
def login():
|
||||
"""
|
||||
Authenticate with username + password.
|
||||
@@ -70,10 +78,22 @@ def login():
|
||||
{
|
||||
"username": "john",
|
||||
"password": "secret",
|
||||
"mfa_code": "123456", // required IF the account has 2FA on
|
||||
"device_id": "A1B2C3D4...", // UIDevice.identifierForVendor (optional)
|
||||
"device_name": "John's iPhone" // (optional)
|
||||
}
|
||||
|
||||
Response 401 — second factor needed
|
||||
-----------------------------------
|
||||
{
|
||||
"ok": false,
|
||||
"error": "A verification code is required for this account.",
|
||||
"mfa_required": true
|
||||
}
|
||||
|
||||
The password was correct; the client should prompt for the 6-digit code
|
||||
(or a recovery code) and POST again with `mfa_code`.
|
||||
|
||||
Response 200
|
||||
------------
|
||||
{
|
||||
@@ -106,6 +126,41 @@ def login():
|
||||
if not user.active:
|
||||
return api_error('Account is disabled. Please contact an administrator.', 401)
|
||||
|
||||
# ── Two-factor (phase35 parity) ───────────────────────────────────────
|
||||
# The web login defers identity to /auth/mfa when an account has TOTP
|
||||
# enabled. This endpoint did not, so anyone who turned MFA on could skip it
|
||||
# entirely by signing in through the app — the factor was decorative for
|
||||
# exactly the accounts that chose to enable it.
|
||||
#
|
||||
# Accepts either a TOTP code or a single-use recovery code, the same two
|
||||
# the web challenge accepts. A missing code is answered with
|
||||
# `mfa_required: true` so a client can prompt for it rather than treating
|
||||
# this as a wrong password.
|
||||
if user.mfa_enabled and user.mfa_secret:
|
||||
from app.utils.mfa import verify_totp, check_and_consume_recovery
|
||||
|
||||
code = (data.get('mfa_code') or '').strip()
|
||||
if not code:
|
||||
logger.info('API login | mfa_required | username=%s', user.username)
|
||||
return api_error('A verification code is required for this account.',
|
||||
401, extra={'mfa_required': True})
|
||||
|
||||
if not verify_totp(user.mfa_secret, code):
|
||||
matched, remaining = check_and_consume_recovery(
|
||||
user.mfa_recovery_codes, code)
|
||||
if not matched:
|
||||
logger.warning('API login | mfa_failed | username=%s | ip=%s',
|
||||
user.username, request.remote_addr)
|
||||
return api_error('That verification code is not valid.',
|
||||
401, extra={'mfa_required': True})
|
||||
# Recovery codes are single-use — persist the shortened list before
|
||||
# any token is issued, so a crash cannot hand out a login while
|
||||
# leaving the code usable again.
|
||||
user.mfa_recovery_codes = remaining
|
||||
db.session.commit()
|
||||
logger.warning('API login | recovery_code_used | username=%s | '
|
||||
'remaining=%d', user.username, len(remaining))
|
||||
|
||||
device_id = (data.get('device_id') or '')[:64] or None
|
||||
device_name = (data.get('device_name') or '')[:100] or None
|
||||
|
||||
@@ -154,6 +209,11 @@ def login():
|
||||
|
||||
@bp.route('/auth/refresh', methods=['POST'])
|
||||
@limiter.limit('30 per minute; 5 per second')
|
||||
# Gated too: without it a device that signed in before the plan changed would
|
||||
# keep rotating tokens forever and never notice it had lost access.
|
||||
# logout stays open on purpose — a blocked device must still be able to
|
||||
# surrender its refresh token and clean up.
|
||||
@feature_required('mobile_api')
|
||||
def refresh():
|
||||
"""
|
||||
Exchange a valid refresh token for a new access token.
|
||||
|
||||
+3
-2
@@ -29,7 +29,8 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
bp = Blueprint('api_comments', __name__)
|
||||
|
||||
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager', 'auditor'}
|
||||
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'external_inspector',
|
||||
'project_manager', 'auditor'}
|
||||
|
||||
|
||||
def _comment_payload(comment: IssueComment) -> dict:
|
||||
@@ -47,7 +48,7 @@ def _comment_payload(comment: IssueComment) -> dict:
|
||||
|
||||
def _check_issue_access(issue: Issue, user) -> bool:
|
||||
"""Return True if user may read/write this issue. False = 403."""
|
||||
if user.role == 'inspector':
|
||||
if user.is_inspector:
|
||||
fids = get_inspector_scope(user)
|
||||
facility = issue.resolved_facility
|
||||
if not fids or not facility or facility.id not in fids:
|
||||
|
||||
@@ -62,6 +62,20 @@ def jwt_required(f):
|
||||
if payload is None:
|
||||
return api_error('Access token is invalid or expired', 401)
|
||||
|
||||
# MT-21: a token signed for another tenant verifies fine here (shared
|
||||
# SECRET_KEY), so check the tenant claim before 'sub' is resolved
|
||||
# against the bound database. No-op in single-tenant mode.
|
||||
from app.tenancy.session_binding import current_tenant_id
|
||||
tenant_id = current_tenant_id()
|
||||
if tenant_id is not None:
|
||||
token_tid = payload.get('tid')
|
||||
if token_tid != tenant_id:
|
||||
logger.warning(
|
||||
'API tenant mismatch | token_tid=%s resolved=%s endpoint=%s',
|
||||
token_tid, tenant_id, request.endpoint,
|
||||
)
|
||||
return api_error('Access token is not valid for this workspace', 401)
|
||||
|
||||
user_id = int(payload.get('sub', 0))
|
||||
user = db.session.get(User, user_id)
|
||||
|
||||
|
||||
+15
-4
@@ -31,13 +31,24 @@ def api_ok(data=None, status=200):
|
||||
}), status
|
||||
|
||||
|
||||
def api_error(message: str, status: int = 400):
|
||||
"""Return an error JSON response."""
|
||||
return jsonify({
|
||||
def api_error(message: str, status: int = 400, extra: dict | None = None):
|
||||
"""Return an error JSON response.
|
||||
|
||||
`extra` merges additional top-level keys into the envelope — for flags a
|
||||
client must branch on rather than parse out of the message, e.g.
|
||||
`mfa_required` on a login that needs a second factor. Reserved keys
|
||||
(ok/data/error) always win, so a caller cannot accidentally rewrite the
|
||||
envelope's shape.
|
||||
"""
|
||||
payload = {
|
||||
'ok': False,
|
||||
'data': None,
|
||||
'error': message,
|
||||
}), status
|
||||
}
|
||||
if extra:
|
||||
for k, v in extra.items():
|
||||
payload.setdefault(k, v)
|
||||
return jsonify(payload), status
|
||||
|
||||
|
||||
# ── Registered error handlers (attached to the api blueprint) ─────────────────
|
||||
|
||||
+260
-11
@@ -35,7 +35,8 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
bp = Blueprint('api_inspections', __name__)
|
||||
|
||||
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager', 'auditor'}
|
||||
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'external_inspector',
|
||||
'project_manager', 'auditor'}
|
||||
|
||||
|
||||
def _merge_form_data(existing: dict, incoming: dict) -> dict:
|
||||
@@ -96,6 +97,84 @@ def _parse_datetime(value):
|
||||
return None
|
||||
|
||||
|
||||
def _schedule_id_from(data):
|
||||
"""Read the schedule link from a request body, accepting either key.
|
||||
|
||||
The iPad sends `scheduled_inspection_id` (the single-tenant column name it
|
||||
was built against); MT's column is `inspection_schedule_id`. Both are
|
||||
accepted so shipped iPad builds keep working and a future build can migrate
|
||||
to the MT name without a flag day. MT's own name wins if both are present.
|
||||
"""
|
||||
for key in ('inspection_schedule_id', 'scheduled_inspection_id'):
|
||||
if data.get(key):
|
||||
return data[key]
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_schedule(schedule_id, user):
|
||||
"""Resolve a client-supplied schedule id to an InspectionSchedule, or None.
|
||||
|
||||
The iPad sends this when the inspector taps Start on a scheduled row;
|
||||
without it the inspection lands unlinked and the schedule is never fulfilled
|
||||
(no link on the detail page, and the schedule stays due forever).
|
||||
|
||||
NON-BLOCKING BY DESIGN. A bad link drops the link and logs a warning — it
|
||||
never fails the submission. The app is offline-first, so a schedule can
|
||||
legitimately be deleted or reassigned while a completed inspection sits in
|
||||
the outbox for days; erroring here would retry-fail that inspection and
|
||||
strand the inspector's work (and its photos) permanently. A missed fulfil is
|
||||
recoverable from the web UI; a stranded submission is not.
|
||||
|
||||
The ownership check still matters: accepting a foreign link would let one
|
||||
inspector fulfil another's schedule. So the link is refused — but the
|
||||
inspection itself is still accepted.
|
||||
"""
|
||||
from app.models.inspection_schedule import InspectionSchedule
|
||||
|
||||
sched = db.session.get(InspectionSchedule, schedule_id)
|
||||
if sched is None:
|
||||
logger.warning('API INSPECTIONS | unknown schedule id=%s from user=%s '
|
||||
'— submitting unlinked', schedule_id, user.username)
|
||||
return None
|
||||
if user.is_inspector and sched.inspector_id != user.id:
|
||||
logger.warning('API INSPECTIONS | schedule id=%s not assigned to user=%s '
|
||||
'— submitting unlinked', schedule_id, user.username)
|
||||
return None
|
||||
return sched
|
||||
|
||||
|
||||
def _fulfill_schedule(inspection):
|
||||
"""Roll the originating schedule forward. Caller commits.
|
||||
|
||||
Mirrors routes/inspections.py exactly, including passing `_compute_next_run`
|
||||
as `next_run_fn`. As of phase46 that argument is accepted and ignored: the
|
||||
cadence maths moved onto `InspectionSchedule.advance_due_date()`, which owns
|
||||
the recurrence columns and the end-date boundary. Before phase46 omitting it
|
||||
silently left `next_run_at` untouched and the schedule stayed permanently
|
||||
due; the call is kept as-is so this file needs no behavioural change. The
|
||||
deferred import mirrors the web route and avoids a module-load cycle between
|
||||
the api and routes packages.
|
||||
"""
|
||||
if not inspection.inspection_schedule_id:
|
||||
return
|
||||
from app.models.inspection_schedule import InspectionSchedule
|
||||
from app.routes.inspection_schedules import _compute_next_run
|
||||
|
||||
sched = db.session.get(InspectionSchedule, inspection.inspection_schedule_id)
|
||||
if sched is None:
|
||||
return
|
||||
sched.fulfill(next_run_fn=_compute_next_run)
|
||||
logger.info('API INSPECTIONS | schedule fulfilled | schedule=%s | inspection=%s '
|
||||
'| next_due=%s', sched.id, inspection.id, sched.next_run_at)
|
||||
|
||||
|
||||
def _media(key):
|
||||
"""Absolute display URL for a storage key (presigned on R2, absolute-static
|
||||
on local). '' for falsy keys. Used for iPad image rendering."""
|
||||
from app.utils import storage
|
||||
return storage.media_url(key, external=True) if key else ''
|
||||
|
||||
|
||||
def _inspection_payload(inspection):
|
||||
"""Serialize an Inspection to the dict returned in API responses."""
|
||||
# Extract form responses from the notes JSON blob.
|
||||
@@ -137,12 +216,32 @@ def _inspection_payload(inspection):
|
||||
if inspection.completed_at else None,
|
||||
'mobile_local_id': inspection.mobile_local_id,
|
||||
'form_data': form_data,
|
||||
# Absolute display URLs for image form fields (presigned on R2,
|
||||
# absolute-static on local): {field_id: url}. The iPad prefers this
|
||||
# over building ServerConfig + /static/ + value.
|
||||
'form_media': {
|
||||
fid: _media(v)
|
||||
for fid, v in (form_data or {}).items()
|
||||
if isinstance(v, str) and v.startswith('uploads/')
|
||||
},
|
||||
'form_schema': form_schema,
|
||||
'inspector_notes': inspector_notes,
|
||||
# ── Follow-up / re-inspection fields ──────────────────────────────
|
||||
'follow_up_required': inspection.follow_up_required,
|
||||
# phase56 — who is to perform the follow-up. NULL means the
|
||||
# inspection's own inspector, which is what it always meant.
|
||||
'follow_up_assigned_to': inspection.follow_up_assigned_to,
|
||||
'follow_up_assigned_to_name': (inspection.follow_up_assignee.display_name
|
||||
if inspection.follow_up_assignee else None),
|
||||
'follow_up_note': inspection.follow_up_note,
|
||||
'parent_inspection_id': inspection.parent_inspection_id,
|
||||
# ── Originating schedule (MT-14) ──────────────────────────────────
|
||||
# Emitted under BOTH names: `inspection_schedule_id` is MT's column,
|
||||
# `scheduled_inspection_id` is the name shipped iPad builds decode.
|
||||
# They always carry the same value. Drop the legacy alias once every
|
||||
# deployed client has moved to the MT name.
|
||||
'inspection_schedule_id': inspection.inspection_schedule_id,
|
||||
'scheduled_inspection_id': inspection.inspection_schedule_id,
|
||||
}
|
||||
|
||||
|
||||
@@ -163,6 +262,9 @@ def list_inspections():
|
||||
offset int default 0
|
||||
facility_id int filter by facility
|
||||
status str filter by status (completed, in_progress, flagged)
|
||||
follow_up_required
|
||||
bool 'true'/'1' — only inspections awaiting a re-inspection,
|
||||
scoped to the caller's own follow-ups (see below)
|
||||
from_date str ISO date (YYYY-MM-DD) — include inspections on/after this date
|
||||
to_date str ISO date (YYYY-MM-DD) — include inspections on/before this date
|
||||
|
||||
@@ -183,13 +285,20 @@ def list_inspections():
|
||||
if user.role not in _ALLOWED_ROLES:
|
||||
return api_error('Access denied', 403)
|
||||
|
||||
limit = min(int(request.args.get('limit', 50)), 200)
|
||||
offset = max(int(request.args.get('offset', 0)), 0)
|
||||
limit = min(request.args.get('limit', 50, type=int) or 50, 200)
|
||||
offset = max(request.args.get('offset', 0, type=int) or 0, 0)
|
||||
|
||||
query = Inspection.query
|
||||
|
||||
# Inspectors only see their own inspections
|
||||
if user.role == 'inspector':
|
||||
# Inspectors only see their own inspections.
|
||||
#
|
||||
# EXCEPT when asking for follow-up requests: a follow-up can now be handed
|
||||
# to a different inspector (phase56), and that request lives on an
|
||||
# inspection somebody ELSE performed. Applying this filter first would hide
|
||||
# exactly the rows the assignee needs, so it is deferred to the follow-up
|
||||
# block below, which applies ownership instead of authorship.
|
||||
wants_follow_ups = request.args.get('follow_up_required', '').lower() in ('true', '1')
|
||||
if user.is_inspector and not wants_follow_ups:
|
||||
query = query.filter(Inspection.inspector_id == user.id)
|
||||
|
||||
# Optional filters
|
||||
@@ -201,6 +310,38 @@ def list_inspections():
|
||||
if status:
|
||||
query = query.filter(Inspection.status == status)
|
||||
|
||||
if wants_follow_ups:
|
||||
# MT had no follow_up_required filter at all, so the iPad's Follow-up
|
||||
# Requests screen — which calls ?follow_up_required=true — received the
|
||||
# inspector's ENTIRE history and presented it as outstanding requests.
|
||||
#
|
||||
# "Follow-up" must mean exactly what it means everywhere on the web
|
||||
# (inspections.index / reports status_filter == 'follow_up'): flagged,
|
||||
# completed, and not yet answered by a linked re-inspection.
|
||||
#
|
||||
# The ~follow_ups.any() clause is the one that matters. The web execute
|
||||
# route never clears follow_up_required on the parent — it only stops
|
||||
# listing it once a child exists — so filtering on the flag alone would
|
||||
# return follow-ups that were already satisfied on the web, forever.
|
||||
# On the iPad those rows are undismissable: pull_follow_up_requests()
|
||||
# keeps receiving them and update(from:) resets fulfilledLocally, so the
|
||||
# FOLLOW-UP REQUESTED card would never clear. (The mobile POST path does
|
||||
# clear the parent flag, so only web-completed re-inspections stick.)
|
||||
query = query.filter(
|
||||
Inspection.follow_up_required.is_(True),
|
||||
Inspection.status == 'completed',
|
||||
).filter(~Inspection.follow_ups.any())
|
||||
|
||||
# Ownership, not authorship (phase56). Mirrors
|
||||
# Inspection.follow_up_owner: an assigned follow-up belongs to the
|
||||
# assignee ALONE, an unassigned one to the inspection's own inspector.
|
||||
#
|
||||
# The two arms are mutually exclusive on purpose. Without the second
|
||||
# arm's `is_(None)` an inspector would keep seeing a follow-up that had
|
||||
# been handed to someone else, and two people would turn up to do it.
|
||||
if user.is_inspector:
|
||||
query = query.filter(Inspection.follow_up_owned_by(user.id))
|
||||
|
||||
from_date_str = request.args.get('from_date')
|
||||
if from_date_str:
|
||||
try:
|
||||
@@ -264,9 +405,16 @@ def create_inspection():
|
||||
"overall_score": 87.5,
|
||||
"inspection_date": "2026-05-01T14:30:00",
|
||||
"completed_at": "2026-05-01T15:00:00",
|
||||
"mobile_local_id": "uuid-string"
|
||||
"mobile_local_id": "uuid-string",
|
||||
"inspection_schedule_id": 12
|
||||
}
|
||||
|
||||
`inspection_schedule_id` links the inspection to the schedule it fulfils
|
||||
(sent when the inspector taps Start on a scheduled row).
|
||||
`scheduled_inspection_id` is accepted as an alias for shipped iPad builds.
|
||||
An unresolvable or foreign id is dropped with a warning — it never fails the
|
||||
submission. The schedule is rolled forward only when status is "completed".
|
||||
|
||||
Response 200
|
||||
------------
|
||||
{ "ok": true, "data": { "inspection_id": 42, "duplicate": false } }
|
||||
@@ -357,6 +505,29 @@ def create_inspection():
|
||||
submit_latitude = None
|
||||
submit_longitude = None
|
||||
|
||||
# ── Originating schedule (MT-14) ──────────────────────────────────────
|
||||
# Sent when the inspector taps Start on a scheduled row. Resolution is
|
||||
# non-blocking: an unresolvable or foreign id drops the link and logs, but
|
||||
# the inspection is still accepted (see _resolve_schedule).
|
||||
inspection_schedule_id = None
|
||||
_sched_id = _schedule_id_from(data)
|
||||
if _sched_id:
|
||||
_sched = _resolve_schedule(_sched_id, user)
|
||||
inspection_schedule_id = _sched.id if _sched else None
|
||||
|
||||
# phase48 — inherit the follow-up link from the schedule when the client
|
||||
# did not send one. A schedule created by "Schedule Follow-up" knows
|
||||
# which inspection it answers, so the link must not depend on the client
|
||||
# remembering to pass it: an older build, or a draft resumed after the
|
||||
# cached row was refreshed, would otherwise submit a plain inspection
|
||||
# and leave the parent flagged forever. Never overrides an explicit
|
||||
# parent_inspection_id.
|
||||
if not parent_inspection_id and _sched is not None and _sched.parent_inspection_id:
|
||||
parent_inspection_id = _sched.parent_inspection_id
|
||||
logger.info('API INSPECTIONS | parent inherited from schedule | '
|
||||
'schedule=%s | parent=%s | user=%s',
|
||||
_sched.id, parent_inspection_id, user.username)
|
||||
|
||||
inspection = Inspection(
|
||||
template_id = template_id,
|
||||
facility_id = facility_id,
|
||||
@@ -371,11 +542,19 @@ def create_inspection():
|
||||
parent_inspection_id = parent_inspection_id,
|
||||
submit_latitude = submit_latitude,
|
||||
submit_longitude = submit_longitude,
|
||||
inspection_schedule_id = inspection_schedule_id,
|
||||
)
|
||||
|
||||
db.session.add(inspection)
|
||||
db.session.flush()
|
||||
|
||||
# ── Fulfil the originating schedule ───────────────────────────────────
|
||||
# Staged into the same atomic commit as the inspection, mirroring the web
|
||||
# route. Only on completion: an in_progress submission has not satisfied
|
||||
# the occurrence, so rolling the schedule forward there would skip a cycle.
|
||||
if status == 'completed':
|
||||
_fulfill_schedule(inspection)
|
||||
|
||||
# ── Auto-clear follow-up flag on parent ───────────────────────────────
|
||||
# When a completed re-inspection arrives that links to a parent, clear
|
||||
# follow_up_required on the parent automatically. This mirrors the web
|
||||
@@ -465,8 +644,13 @@ def update_inspection(inspection_id):
|
||||
"form_data": { ... },
|
||||
"notes": "...",
|
||||
"overall_score": 91.0,
|
||||
"completed_at": "2026-05-01T15:30:00"
|
||||
"completed_at": "2026-05-01T15:30:00",
|
||||
"inspection_schedule_id": 12
|
||||
}
|
||||
|
||||
`inspection_schedule_id` links the inspection to the schedule it fulfils.
|
||||
`scheduled_inspection_id` is accepted as an alias for shipped iPad builds.
|
||||
The schedule is rolled forward only on the draft → completed transition.
|
||||
"""
|
||||
user = g.api_user
|
||||
|
||||
@@ -477,7 +661,7 @@ def update_inspection(inspection_id):
|
||||
if inspection is None:
|
||||
return api_error('Inspection not found', 404)
|
||||
|
||||
if user.role == 'inspector' and inspection.inspector_id != user.id:
|
||||
if user.is_inspector and inspection.inspector_id != user.id:
|
||||
return api_error('Access denied', 403)
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
@@ -500,6 +684,31 @@ def update_inspection(inspection_id):
|
||||
|
||||
prev_status = inspection.status
|
||||
|
||||
# Allow the link to be set/corrected on PATCH too — the iPad may create the
|
||||
# inspection as a draft first and only attach the schedule on submit.
|
||||
# Same non-blocking semantics as create: a bad id leaves the link untouched.
|
||||
_sched_id = _schedule_id_from(data)
|
||||
if _sched_id:
|
||||
_sched = _resolve_schedule(_sched_id, user)
|
||||
if _sched is not None:
|
||||
inspection.inspection_schedule_id = _sched.id
|
||||
# phase48 parity with the POST path: a schedule created by
|
||||
# "Schedule Follow-up" knows which inspection it answers, so a draft
|
||||
# that only gets its schedule attached here still becomes a properly
|
||||
# linked re-inspection. Never overrides an explicit parent.
|
||||
if not inspection.parent_inspection_id and _sched.parent_inspection_id:
|
||||
inspection.parent_inspection_id = _sched.parent_inspection_id
|
||||
logger.info('API INSPECTIONS | parent inherited from schedule on '
|
||||
'PATCH | schedule=%s | parent=%s | user=%s',
|
||||
_sched.id, _sched.parent_inspection_id, user.username)
|
||||
|
||||
# An explicitly supplied parent still wins, and can be set on the draft
|
||||
# before submit — mirrors the POST handler's field list.
|
||||
if 'parent_inspection_id' in data:
|
||||
_pid = data.get('parent_inspection_id')
|
||||
if isinstance(_pid, int) and db.session.get(Inspection, _pid) is not None:
|
||||
inspection.parent_inspection_id = _pid
|
||||
|
||||
if 'status' in data:
|
||||
inspection.status = data['status']
|
||||
|
||||
@@ -515,12 +724,52 @@ def update_inspection(inspection_id):
|
||||
elif data.get('status') == 'completed' and not inspection.completed_at:
|
||||
inspection.completed_at = now_eastern()
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# Notify when a draft transitions to completed — mirrors the POST handler.
|
||||
# Computed BEFORE the commit so the schedule fulfil can be staged into the
|
||||
# same transaction; reused after the commit for the notification below.
|
||||
transitioning_to_complete = (
|
||||
data.get('status') == 'completed' and prev_status != 'completed'
|
||||
)
|
||||
# Fulfil on the draft → completed transition ONLY, so a later PATCH on an
|
||||
# already-completed inspection cannot roll the schedule forward twice.
|
||||
if transitioning_to_complete:
|
||||
_fulfill_schedule(inspection)
|
||||
|
||||
# ── Auto-clear follow-up flag on parent ───────────────────────────────
|
||||
# Mirrors the POST handler. This was previously MISSING here, so an iPad
|
||||
# that created a follow-up as a draft and submitted it via PATCH left the
|
||||
# parent flagged forever — the re-inspection happened, but the parent still
|
||||
# showed "Follow-up Inspection Required" and stayed in every manager's
|
||||
# outstanding list. phase48 made that a normal path, since a schedule-started
|
||||
# follow-up is a draft first.
|
||||
#
|
||||
# Same commit-ordering rule as the POST handler: log_action() must fire AFTER
|
||||
# db.session.commit(), because audit.py commits internally and would
|
||||
# otherwise persist the parent's flag change before this inspection's own
|
||||
# changes are committed — a partial state if the main commit then failed.
|
||||
_parent_log_args = None
|
||||
if transitioning_to_complete and inspection.parent_inspection_id:
|
||||
parent_insp = db.session.get(Inspection, inspection.parent_inspection_id)
|
||||
if parent_insp and parent_insp.follow_up_required:
|
||||
parent_insp.follow_up_required = False
|
||||
logger.info(
|
||||
'API INSPECTIONS | follow_up cleared on PATCH | parent_id=%s | '
|
||||
'by_inspection_id=%s | user=%s',
|
||||
parent_insp.id, inspection.id, user.username,
|
||||
)
|
||||
# Snapshot label strings now — ORM objects may be expired after commit.
|
||||
_parent_log_args = (
|
||||
parent_insp.id,
|
||||
f'{parent_insp.template.name} @ {parent_insp.facility.name}',
|
||||
f'follow_up_required=False (cleared by re-inspection '
|
||||
f'#{inspection.id} via mobile API)',
|
||||
)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
if _parent_log_args:
|
||||
log_action(ACTION_UPDATE, 'Inspection', *_parent_log_args)
|
||||
|
||||
# Notify when a draft transitions to completed — mirrors the POST handler.
|
||||
if transitioning_to_complete:
|
||||
score_val = inspection.overall_score
|
||||
score_display = f'{score_val:.1f}%' if score_val is not None else 'N/A'
|
||||
|
||||
+34
-11
@@ -42,7 +42,8 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
bp = Blueprint('api_issues', __name__)
|
||||
|
||||
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager', 'auditor'}
|
||||
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'external_inspector',
|
||||
'project_manager', 'auditor'}
|
||||
_VALID_SEVERITY = {'low', 'medium', 'high', 'critical'}
|
||||
_VALID_STATUSES = {'open', 'in_progress', 'resolved', 'pending_verification'}
|
||||
_VALID_HANDLERS = {'internal', 'facility', 'vendor'}
|
||||
@@ -52,6 +53,13 @@ _UUID_RE = re.compile(
|
||||
)
|
||||
|
||||
|
||||
def _photo_urls(keys):
|
||||
"""Map storage keys to absolute display URLs (presigned on R2, absolute-static
|
||||
on local). Falsy keys are skipped. Used for iPad photo rendering."""
|
||||
from app.utils import storage
|
||||
return [storage.media_url(k, external=True) for k in keys if k]
|
||||
|
||||
|
||||
def _issue_payload(issue):
|
||||
"""Serialise an Issue to the dict returned in list/detail responses."""
|
||||
facility = issue.resolved_facility
|
||||
@@ -72,6 +80,14 @@ def _issue_payload(issue):
|
||||
'photo_path': issue.photo_path or None,
|
||||
'mobile_photo_paths': issue.mobile_photo_paths or [],
|
||||
'result_photos': issue.result_photos or [],
|
||||
# Absolute display URLs (presigned on R2, absolute-static on local) for
|
||||
# the iPad, which loads photos off-origin. Relative keys above stay as
|
||||
# keys. photo_urls order mirrors the iPad's evidence merge:
|
||||
# [photo_path] + mobile_photo_paths.
|
||||
'photo_urls': _photo_urls(
|
||||
([issue.photo_path] if issue.photo_path else [])
|
||||
+ (issue.mobile_photo_paths or [])),
|
||||
'result_photo_urls': _photo_urls(issue.result_photos or []),
|
||||
# Resolution details — set by web staff after fixing the issue.
|
||||
'result_notes': issue.result_notes or None,
|
||||
# Verification fields — set after a director/admin confirms fix.
|
||||
@@ -95,6 +111,10 @@ def _issue_payload(issue):
|
||||
'vendor_name': issue.vendor_name or None,
|
||||
'vendor_contact': issue.vendor_contact or None,
|
||||
'vendor_notes': issue.vendor_notes or None,
|
||||
# Janitorial staff handler — used when handler_type == 'internal'.
|
||||
# Distinct from assigned_to: the crew member may not be a system user.
|
||||
'internal_handler_name': issue.internal_handler_name or None,
|
||||
'internal_handler_contact': issue.internal_handler_contact or None,
|
||||
}
|
||||
|
||||
|
||||
@@ -132,12 +152,12 @@ def list_issues():
|
||||
if user.role not in _ALLOWED_ROLES:
|
||||
return api_error('Access denied', 403)
|
||||
|
||||
limit = min(int(request.args.get('limit', 100)), 200)
|
||||
offset = max(int(request.args.get('offset', 0)), 0)
|
||||
limit = min(request.args.get('limit', 100, type=int) or 100, 200)
|
||||
offset = max(request.args.get('offset', 0, type=int) or 0, 0)
|
||||
|
||||
query = Issue.query
|
||||
|
||||
if user.role == 'inspector':
|
||||
if user.is_inspector:
|
||||
fids = get_inspector_scope(user)
|
||||
if not fids:
|
||||
return api_ok({'issues': [], 'total': 0, 'limit': limit, 'offset': offset})
|
||||
@@ -231,7 +251,7 @@ def create_issue():
|
||||
if facility is None:
|
||||
return api_error('Facility not found', 404)
|
||||
|
||||
if user.role == 'inspector':
|
||||
if user.is_inspector:
|
||||
fids = get_inspector_scope(user)
|
||||
if not fids or facility_id not in fids:
|
||||
return api_error('Access denied — facility is not in your assigned contracts', 403)
|
||||
@@ -320,7 +340,7 @@ def get_issue(issue_id):
|
||||
if issue is None:
|
||||
return api_error('Issue not found', 404)
|
||||
|
||||
if user.role == 'inspector':
|
||||
if user.is_inspector:
|
||||
fids = get_inspector_scope(user)
|
||||
facility = issue.resolved_facility
|
||||
if not fids or not facility or facility.id not in fids:
|
||||
@@ -353,7 +373,7 @@ def update_issue_status(issue_id):
|
||||
if issue is None:
|
||||
return api_error('Issue not found', 404)
|
||||
|
||||
if user.role == 'inspector':
|
||||
if user.is_inspector:
|
||||
fids = get_inspector_scope(user)
|
||||
facility = issue.resolved_facility
|
||||
if not fids or not facility or facility.id not in fids:
|
||||
@@ -418,7 +438,7 @@ def update_issue_photos(issue_id):
|
||||
if issue is None:
|
||||
return api_error('Issue not found', 404)
|
||||
|
||||
if user.role == 'inspector':
|
||||
if user.is_inspector:
|
||||
fids = get_inspector_scope(user)
|
||||
facility = issue.resolved_facility
|
||||
if not fids or not facility or facility.id not in fids:
|
||||
@@ -479,7 +499,7 @@ def update_issue_result_photos(issue_id):
|
||||
if issue is None:
|
||||
return api_error('Issue not found', 404)
|
||||
|
||||
if user.role == 'inspector':
|
||||
if user.is_inspector:
|
||||
fids = get_inspector_scope(user)
|
||||
facility = issue.resolved_facility
|
||||
if not fids or not facility or facility.id not in fids:
|
||||
@@ -535,7 +555,9 @@ def update_issue_handler(issue_id):
|
||||
"facility_handler_notes": "...", // optional
|
||||
"vendor_name": "...", // optional (vendor handler)
|
||||
"vendor_contact": "...", // optional
|
||||
"vendor_notes": "..." // optional
|
||||
"vendor_notes": "...", // optional
|
||||
"internal_handler_name": "...", // optional (janitorial staff handler)
|
||||
"internal_handler_contact": "..." // optional
|
||||
}
|
||||
|
||||
Only keys present in the body are updated; empty strings clear a field.
|
||||
@@ -554,7 +576,7 @@ def update_issue_handler(issue_id):
|
||||
if issue is None:
|
||||
return api_error('Issue not found', 404)
|
||||
|
||||
if user.role == 'inspector':
|
||||
if user.is_inspector:
|
||||
fids = get_inspector_scope(user)
|
||||
facility = issue.resolved_facility
|
||||
if not fids or not facility or facility.id not in fids:
|
||||
@@ -576,6 +598,7 @@ def update_issue_handler(issue_id):
|
||||
_text_fields = (
|
||||
'facility_handler_name', 'facility_handler_contact', 'facility_handler_notes',
|
||||
'vendor_name', 'vendor_contact', 'vendor_notes',
|
||||
'internal_handler_name', 'internal_handler_contact',
|
||||
)
|
||||
for field in _text_fields:
|
||||
if field in data:
|
||||
|
||||
@@ -10,6 +10,7 @@ claims needed to identify the caller:
|
||||
{
|
||||
"sub": "42", # user.id as string
|
||||
"role": "inspector", # user.role
|
||||
"tid": 3, # issuing tenant id (multi-tenant mode only)
|
||||
"iat": 1710000000, # issued-at (UTC epoch)
|
||||
"exp": 1710003600, # expiry (UTC epoch, 60 min later)
|
||||
}
|
||||
@@ -33,6 +34,12 @@ def _secret():
|
||||
return current_app.config['SECRET_KEY']
|
||||
|
||||
|
||||
def _current_tenant_id():
|
||||
"""Resolved tenant id, or None in single-tenant / unbound contexts."""
|
||||
from app.tenancy.session_binding import current_tenant_id
|
||||
return current_tenant_id()
|
||||
|
||||
|
||||
def generate_access_token(user, lifetime_minutes: int = ACCESS_TOKEN_LIFETIME_MINUTES) -> str:
|
||||
"""
|
||||
Create and sign a new access token for the given user.
|
||||
@@ -54,6 +61,14 @@ def generate_access_token(user, lifetime_minutes: int = ACCESS_TOKEN_LIFETIME_MI
|
||||
'iat': now,
|
||||
'exp': now + timedelta(minutes=lifetime_minutes),
|
||||
}
|
||||
# MT-21: bind the token to the issuing tenant. Every tenant is signed with
|
||||
# the same SECRET_KEY, so without this claim a token minted at one tenant
|
||||
# host verifies at another and 'sub' resolves against whichever database
|
||||
# the middleware bound. Omitted in single-tenant mode so token shape is
|
||||
# unchanged there.
|
||||
tid = _current_tenant_id()
|
||||
if tid is not None:
|
||||
payload['tid'] = tid
|
||||
return jwt.encode(payload, _secret(), algorithm='HS256')
|
||||
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ def list_notifications():
|
||||
"""
|
||||
user = g.api_user
|
||||
since = _parse_since(request.args.get('since'))
|
||||
limit = min(int(request.args.get('limit', 50)), 50)
|
||||
limit = min(request.args.get('limit', 50, type=int) or 50, 50)
|
||||
|
||||
def _run_orm():
|
||||
q = Notification.query.filter_by(user_id=user.id, is_read=False)
|
||||
|
||||
+69
-7
@@ -25,7 +25,8 @@ logger = logging.getLogger(__name__)
|
||||
bp = Blueprint('api_photos', __name__)
|
||||
|
||||
_ALLOWED_EXTENSIONS = {'jpg', 'jpeg', 'png', 'gif'}
|
||||
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager', 'auditor'}
|
||||
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'external_inspector',
|
||||
'project_manager', 'auditor'}
|
||||
|
||||
|
||||
def _allowed_file(filename: str) -> bool:
|
||||
@@ -47,31 +48,58 @@ def upload_photo():
|
||||
---------------------
|
||||
file — binary image data (jpg / png / gif)
|
||||
entity_type — "inspection" | "issue" | "issue_result" (controls subfolder)
|
||||
captured_at — OPTIONAL ISO-8601 capture time (e.g. 2026-07-20T09:14:22-04:00)
|
||||
latitude — OPTIONAL decimal degrees at capture
|
||||
longitude — OPTIONAL decimal degrees at capture
|
||||
|
||||
A capture-time + geo overlay is burned into the image before it is stored
|
||||
(see app/utils/photo_stamp.py). Metadata is taken from the client fields
|
||||
above, falling back to the image's EXIF, then to server receipt time.
|
||||
Sending captured_at/latitude/longitude is strongly preferred for an
|
||||
offline-first client: a photo taken at 09:14 but synced at 16:00 would
|
||||
otherwise be stamped with the sync time.
|
||||
|
||||
Response 200
|
||||
------------
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"server_path": "uploads/inspection_photos/abc123.jpg"
|
||||
"server_path": "uploads/inspection_photos/abc123.jpg",
|
||||
"stamped": true,
|
||||
"captured_at": "2026-07-20T09:14:22",
|
||||
"capture_source": "client"
|
||||
}
|
||||
}
|
||||
|
||||
The three stamp keys are additive — an iPad build that predates them decodes
|
||||
explicit CodingKeys and ignores what it doesn't know.
|
||||
"""
|
||||
user = g.api_user
|
||||
|
||||
if user.role not in _ALLOWED_ROLES:
|
||||
return api_error('Access denied', 403)
|
||||
|
||||
# Every rejection below is logged at WARNING with the user. Only SUCCESSES
|
||||
# were logged before, so when an inspector's photos failed repeatedly there
|
||||
# was nothing server-side to explain why — and a photo that exhausts its
|
||||
# upload attempts costs the inspection its evidence (see the iPad's
|
||||
# PendingPhoto.lastUploadError for the device half of this).
|
||||
if 'file' not in request.files:
|
||||
logger.warning('API PHOTOS | rejected | reason=no_file_part | user=%s',
|
||||
user.username)
|
||||
return api_error('No file provided', 400)
|
||||
|
||||
file_obj = request.files['file']
|
||||
entity_type = request.form.get('entity_type', 'inspection')
|
||||
|
||||
if not file_obj or not file_obj.filename:
|
||||
logger.warning('API PHOTOS | rejected | reason=empty_file | user=%s',
|
||||
user.username)
|
||||
return api_error('Empty file', 400)
|
||||
|
||||
if not _allowed_file(file_obj.filename):
|
||||
logger.warning('API PHOTOS | rejected | reason=bad_extension | file=%r | user=%s',
|
||||
file_obj.filename, user.username)
|
||||
return api_error(
|
||||
f'File type not allowed. Accepted: {", ".join(sorted(_ALLOWED_EXTENSIONS))}',
|
||||
400
|
||||
@@ -85,12 +113,46 @@ def upload_photo():
|
||||
else:
|
||||
subfolder = 'inspection_photos'
|
||||
|
||||
# Burn the capture-time + geo overlay before the bytes are ever stored, so
|
||||
# exactly one (already-stamped) object is written and nothing has to be
|
||||
# read back out of R2. Any stamping failure returns the original bytes.
|
||||
meta = {'stamped': False, 'captured_at': None, 'source': None}
|
||||
if current_app.config.get('PHOTO_STAMP_ENABLED', True):
|
||||
from app.utils.photo_stamp import stamp_file_storage
|
||||
file_obj, meta = stamp_file_storage(
|
||||
file_obj,
|
||||
captured_at = request.form.get('captured_at'),
|
||||
latitude = request.form.get('latitude'),
|
||||
longitude = request.form.get('longitude'),
|
||||
)
|
||||
|
||||
# Write via the active storage backend (local disk or R2). Key format
|
||||
# 'uploads/<subfolder>/<uuid>.<ext>' is unchanged across backends.
|
||||
# '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
|
||||
server_path = storage.save(file_obj, subfolder)
|
||||
try:
|
||||
server_path = storage.save(file_obj, subfolder)
|
||||
except Exception as exc:
|
||||
# A storage failure is the most likely cause of a REPEATED upload
|
||||
# failure (disk full, R2 credentials/quota). Name it explicitly —
|
||||
# otherwise it surfaces only as a generic 500 with no link to the
|
||||
# inspector who is losing evidence photos.
|
||||
logger.error('API PHOTOS | STORAGE WRITE FAILED | user=%s | entity_type=%s | '
|
||||
'subfolder=%s | error=%s', user.username, entity_type, subfolder, exc)
|
||||
return api_error('Could not store the photo. Please retry.', 500)
|
||||
|
||||
logger.info('API PHOTOS | uploaded | entity_type=%s | path=%s | user=%s',
|
||||
entity_type, server_path, user.username)
|
||||
logger.info(
|
||||
'API PHOTOS | uploaded | entity_type=%s | path=%s | user=%s | '
|
||||
'stamped=%s | capture_source=%s',
|
||||
entity_type, server_path, user.username,
|
||||
meta.get('stamped'), meta.get('source'),
|
||||
)
|
||||
|
||||
return api_ok({'server_path': server_path})
|
||||
captured_at = meta.get('captured_at')
|
||||
return api_ok({
|
||||
'server_path': server_path,
|
||||
'stamped': bool(meta.get('stamped')),
|
||||
'captured_at': captured_at.isoformat() if captured_at else None,
|
||||
'capture_source': meta.get('source'),
|
||||
})
|
||||
+172
-4
@@ -24,17 +24,24 @@ 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', 'project_manager', 'auditor'}
|
||||
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'external_inspector',
|
||||
'project_manager', 'auditor'}
|
||||
|
||||
|
||||
def _scheduled_payload(s):
|
||||
@@ -56,8 +63,27 @@ def _scheduled_payload(s):
|
||||
'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,
|
||||
}
|
||||
|
||||
@@ -92,8 +118,8 @@ def list_scheduled():
|
||||
return api_error('Access denied', 403)
|
||||
|
||||
try:
|
||||
limit = min(int(request.args.get('limit', 100)), 200)
|
||||
offset = max(int(request.args.get('offset', 0)), 0)
|
||||
limit = min(request.args.get('limit', 100, type=int) or 100, 200)
|
||||
offset = max(request.args.get('offset', 0, type=int) or 0, 0)
|
||||
except (TypeError, ValueError):
|
||||
return api_error('limit and offset must be integers', 400)
|
||||
|
||||
@@ -102,7 +128,7 @@ def list_scheduled():
|
||||
InspectionSchedule.mode == 'plan',
|
||||
)
|
||||
|
||||
if user.role == 'inspector':
|
||||
if user.is_inspector:
|
||||
# Inspectors only see schedules assigned directly to them.
|
||||
query = query.filter(InspectionSchedule.inspector_id == user.id)
|
||||
|
||||
@@ -122,3 +148,145 @@ def list_scheduled():
|
||||
|
||||
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__)
|
||||
|
||||
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager', 'auditor'}
|
||||
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'external_inspector',
|
||||
'project_manager', 'auditor'}
|
||||
|
||||
|
||||
@bp.route('/stats/dashboard', methods=['GET'])
|
||||
@@ -74,7 +75,7 @@ def dashboard_stats():
|
||||
today_end = today_start + timedelta(days=1)
|
||||
thirty_days_ago = now - timedelta(days=30)
|
||||
|
||||
is_inspector = user.role == 'inspector'
|
||||
is_inspector = user.is_inspector
|
||||
fids = get_inspector_scope(user) if is_inspector else None # None = no scoping
|
||||
|
||||
# ── Today's inspections ───────────────────────────────────────────────
|
||||
@@ -110,7 +111,14 @@ def dashboard_stats():
|
||||
)
|
||||
)
|
||||
|
||||
open_issues_all = open_q.all()
|
||||
# Counts and buckets only — never a hydrated Issue. For an admin this is
|
||||
# every open issue in the system, fetched on every iPad dashboard refresh;
|
||||
# the full entity would drag the description TEXT and the JSON photo
|
||||
# columns along with it. A Row exposes the same attribute names, so
|
||||
# sla_status() below works unchanged.
|
||||
open_issues_all = open_q.with_entities(
|
||||
Issue.id, Issue.severity, Issue.status, Issue.reported_at
|
||||
).all()
|
||||
open_issues = len(open_issues_all)
|
||||
|
||||
# ── Severity breakdown (derived from the same open_issues_all list) ───
|
||||
@@ -151,9 +159,14 @@ def dashboard_stats():
|
||||
if not fids:
|
||||
followup_q = followup_q.filter(False)
|
||||
else:
|
||||
# OWNERSHIP, not authorship: a follow-up handed to this inspector
|
||||
# belongs to them even though somebody else performed the original.
|
||||
# This tile sits directly above the Follow-up Requests list, which
|
||||
# filters the same way — counting authorship here made the two
|
||||
# disagree on the same screen.
|
||||
followup_q = followup_q.filter(
|
||||
Inspection.facility_id.in_(fids),
|
||||
Inspection.inspector_id == user.id,
|
||||
Inspection.follow_up_owned_by(user.id),
|
||||
)
|
||||
pending_followups = followup_q.count()
|
||||
|
||||
|
||||
+90
-10
@@ -16,18 +16,86 @@ GET /api/v1/templates/<template_id>
|
||||
|
||||
import logging
|
||||
|
||||
from flask import Blueprint, g
|
||||
from flask import Blueprint, g, request
|
||||
from app import db
|
||||
from app.models.inspection import InspectionTemplate
|
||||
from app.models.facility import Facility
|
||||
from app.api.errors import api_ok, api_error
|
||||
from app.api.decorators import jwt_required
|
||||
from app.utils.scope import get_inspector_scope
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
bp = Blueprint('api_templates', __name__)
|
||||
|
||||
# Customer role cannot access template data — inspectors and above only
|
||||
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager', 'auditor'}
|
||||
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'external_inspector',
|
||||
'project_manager', 'auditor'}
|
||||
|
||||
|
||||
def _visible_project_ids(user):
|
||||
"""Contract ids whose forms this user may see, or None for "no limit".
|
||||
|
||||
An inspector (ours or a customer's) is limited to the contracts they are
|
||||
assigned; every other allowed role sees all. Derived from the facilities
|
||||
get_inspector_scope() returns, so the API can never disagree with the web.
|
||||
"""
|
||||
if not user.is_inspector:
|
||||
return None
|
||||
fids = get_inspector_scope(user) or []
|
||||
if not fids:
|
||||
return []
|
||||
return sorted({
|
||||
f.project_id
|
||||
for f in Facility.query.filter(Facility.id.in_(fids)).all()
|
||||
if f.project_id
|
||||
})
|
||||
|
||||
|
||||
def _visible_templates(user, project_id=None):
|
||||
"""Forms this user may use, optionally narrowed to one contract.
|
||||
|
||||
phase52 — a form attached to specific contracts must not reach an
|
||||
inspector working for a different customer. Shared forms (no contract
|
||||
links) stay visible to everyone, which is what keeps existing installs
|
||||
behaving exactly as before.
|
||||
|
||||
With `project_id`: exactly the web picker's list for that contract.
|
||||
Without: the union across every contract the user can reach — the iPad
|
||||
caches templates up-front and picks the facility later, so it needs the
|
||||
whole set it might legitimately use.
|
||||
"""
|
||||
pids = _visible_project_ids(user)
|
||||
|
||||
if project_id is not None:
|
||||
# The caller names a contract. Their OWN scope still applies — without
|
||||
# this, passing another customer's facility_id would list that
|
||||
# customer's form names back to an inspector who has no business
|
||||
# seeing them. Empty list, not an error: the endpoint must not confirm
|
||||
# whether that contract exists either.
|
||||
if pids is not None and project_id not in pids:
|
||||
logger.warning('API TEMPLATES | out-of-scope project filter | '
|
||||
'user=%s | project_id=%s', user.username, project_id)
|
||||
return []
|
||||
return InspectionTemplate.available_query(project_id).all()
|
||||
|
||||
if pids is None:
|
||||
return (InspectionTemplate.query
|
||||
.filter_by(active=True)
|
||||
.order_by(InspectionTemplate.name)
|
||||
.all())
|
||||
|
||||
seen, out = set(), []
|
||||
# Always include the shared forms, even when the user has no contracts —
|
||||
# otherwise an unassigned inspector would see nothing at all rather than
|
||||
# the standard forms.
|
||||
for pid in list(pids) + [None]:
|
||||
for t in InspectionTemplate.available_query(pid).all():
|
||||
if t.id not in seen:
|
||||
seen.add(t.id)
|
||||
out.append(t)
|
||||
out.sort(key=lambda t: (t.name or '').lower())
|
||||
return out
|
||||
|
||||
|
||||
def _template_summary_payload(template: InspectionTemplate) -> dict:
|
||||
@@ -87,17 +155,21 @@ def list_templates():
|
||||
user.username, user.role)
|
||||
return api_error('Access denied', 403)
|
||||
|
||||
templates = (
|
||||
InspectionTemplate.query
|
||||
.filter_by(active=True)
|
||||
.order_by(InspectionTemplate.name)
|
||||
.all()
|
||||
)
|
||||
# Optional ?project_id= narrows to one contract (matches the web picker);
|
||||
# ?facility_id= is accepted as a convenience and resolved to its contract.
|
||||
project_id = request.args.get('project_id', type=int)
|
||||
if project_id is None:
|
||||
facility_id = request.args.get('facility_id', type=int)
|
||||
if facility_id is not None:
|
||||
facility = db.session.get(Facility, facility_id)
|
||||
project_id = facility.project_id if facility else None
|
||||
|
||||
templates = _visible_templates(user, project_id)
|
||||
|
||||
payload = [_template_summary_payload(t) for t in templates]
|
||||
|
||||
logger.info('API TEMPLATES | list | user=%s | count=%d',
|
||||
user.username, len(payload))
|
||||
logger.info('API TEMPLATES | list | user=%s | project_id=%s | count=%d',
|
||||
user.username, project_id, len(payload))
|
||||
|
||||
return api_ok({'templates': payload, 'count': len(payload)})
|
||||
|
||||
@@ -143,6 +215,14 @@ def get_template(template_id):
|
||||
if template is None:
|
||||
return api_error('Template not found', 404)
|
||||
|
||||
# phase52 — a restricted form must not be fetchable by an inspector on a
|
||||
# different customer's contracts. 404 rather than 403: whether another
|
||||
# customer's form exists is itself not this user's business.
|
||||
if template.id not in {t.id for t in _visible_templates(user)}:
|
||||
logger.warning('API TEMPLATES | out-of-contract fetch blocked | '
|
||||
'user=%s | template_id=%s', user.username, template_id)
|
||||
return api_error('Template not found', 404)
|
||||
|
||||
logger.info('API TEMPLATES | detail | user=%s | template_id=%d | name=%s',
|
||||
user.username, template_id, template.name)
|
||||
|
||||
|
||||
@@ -0,0 +1,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.facility import Facility, Area
|
||||
from app.models.inspection import (InspectionTemplate, ChecklistItem,
|
||||
Inspection, InspectionResult)
|
||||
Inspection, InspectionResult,
|
||||
TemplateContract)
|
||||
from app.models.issue import Issue
|
||||
from app.models.project import Project, CustomerAssignment
|
||||
from app.models.project_recipient import ProjectNotificationRecipient
|
||||
from app.models.api_token import RefreshToken, DeviceToken
|
||||
from app.models.notification_matrix import NotificationMatrix
|
||||
from app.models.user_notification_matrix import UserNotificationMatrix
|
||||
from app.models.inspection_schedule import InspectionSchedule
|
||||
from app.models.work_order import IssueWorkOrder
|
||||
@@ -3,6 +3,43 @@ from app.utils.time_utils import now_eastern
|
||||
import json
|
||||
|
||||
|
||||
class TemplateContract(db.Model):
|
||||
"""Restricts a form to specific contracts (phase52).
|
||||
|
||||
A customer's bespoke form must not be visible to — or startable against —
|
||||
another customer's facilities. One row = "this template is available on
|
||||
this contract".
|
||||
|
||||
**No rows means the template is SHARED** (available on every contract), not
|
||||
"available nowhere". That is what makes the feature additive: every
|
||||
template that existed before phase52 has no rows, so nothing changed on
|
||||
deploy, and a form becomes customer-specific only when an admin attaches it
|
||||
to at least one contract. The empty-set-means-all convention is the whole
|
||||
migration story — do not "fix" it to mean the opposite.
|
||||
"""
|
||||
|
||||
__tablename__ = 'template_contracts'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
template_id = db.Column(db.Integer,
|
||||
db.ForeignKey('inspection_templates.id', ondelete='CASCADE'),
|
||||
nullable=False, index=True)
|
||||
project_id = db.Column(db.Integer,
|
||||
db.ForeignKey('projects.id', ondelete='CASCADE'),
|
||||
nullable=False, index=True)
|
||||
created_at = db.Column(db.DateTime, default=now_eastern, nullable=False)
|
||||
|
||||
project = db.relationship('Project', backref='template_contracts')
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('template_id', 'project_id',
|
||||
name='uq_template_contract'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<TemplateContract template={self.template_id} project={self.project_id}>'
|
||||
|
||||
|
||||
class InspectionTemplate(db.Model):
|
||||
__tablename__ = 'inspection_templates'
|
||||
|
||||
@@ -18,6 +55,82 @@ class InspectionTemplate(db.Model):
|
||||
checklist_items = db.relationship('ChecklistItem', backref='template', lazy='dynamic', cascade='all, delete-orphan')
|
||||
inspections = db.relationship('Inspection', backref='template', lazy='dynamic')
|
||||
|
||||
# phase52 — contract restrictions. Deleting a template removes its links.
|
||||
contract_links = db.relationship('TemplateContract', backref='template',
|
||||
lazy='dynamic',
|
||||
cascade='all, delete-orphan')
|
||||
|
||||
# ── Contract availability (phase52) ──────────────────────────────────
|
||||
|
||||
@property
|
||||
def contract_ids(self):
|
||||
"""Project ids this form is restricted to; empty = shared with all."""
|
||||
return sorted(l.project_id for l in self.contract_links.all())
|
||||
|
||||
@property
|
||||
def is_shared(self):
|
||||
"""True when the form carries no restriction and is available anywhere."""
|
||||
return self.contract_links.count() == 0
|
||||
|
||||
def available_for_project(self, project_id):
|
||||
"""Can this form be used on `project_id`?
|
||||
|
||||
Shared forms are usable anywhere, including on a facility that has no
|
||||
contract at all. A restricted form needs an explicit link, so a
|
||||
facility with no contract (project_id None) can only ever use shared
|
||||
forms — fail-closed, which is the right side to err on.
|
||||
"""
|
||||
if self.is_shared:
|
||||
return True
|
||||
if project_id is None:
|
||||
return False
|
||||
return project_id in set(self.contract_ids)
|
||||
|
||||
@staticmethod
|
||||
def available_query(project_id, active_only=True):
|
||||
"""Query of templates usable on `project_id` (shared + linked).
|
||||
|
||||
The single definition of "which forms may this contract use". Every
|
||||
picker, the POST validation behind it, and the mobile API all go
|
||||
through here so they cannot disagree — a picker that offers more than
|
||||
the validator accepts silently drops work (see rule 93 for the same
|
||||
failure in the assignee dropdown).
|
||||
"""
|
||||
q = InspectionTemplate.query
|
||||
if active_only:
|
||||
q = q.filter(InspectionTemplate.active == True)
|
||||
|
||||
shared = ~InspectionTemplate.contract_links.any()
|
||||
if project_id is None:
|
||||
# No contract to match against — only unrestricted forms apply.
|
||||
return q.filter(shared).order_by(InspectionTemplate.name)
|
||||
|
||||
linked = InspectionTemplate.contract_links.any(
|
||||
TemplateContract.project_id == project_id
|
||||
)
|
||||
return q.filter(db.or_(shared, linked)).order_by(InspectionTemplate.name)
|
||||
|
||||
def set_contracts(self, project_ids):
|
||||
"""Replace this form's contract restrictions.
|
||||
|
||||
Pass an empty list to make the form shared again. Does NOT commit —
|
||||
the caller owns the transaction. Returns True if anything changed.
|
||||
"""
|
||||
wanted = {int(p) for p in project_ids or []}
|
||||
existing = {l.project_id: l for l in self.contract_links.all()}
|
||||
|
||||
changed = False
|
||||
for pid, link in existing.items():
|
||||
if pid not in wanted:
|
||||
db.session.delete(link)
|
||||
changed = True
|
||||
for pid in wanted:
|
||||
if pid not in existing:
|
||||
db.session.add(TemplateContract(template_id=self.id, project_id=pid))
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
|
||||
def get_form_schema(self):
|
||||
if self.form_schema is None:
|
||||
return []
|
||||
@@ -81,11 +194,91 @@ class Inspection(db.Model):
|
||||
)
|
||||
follow_up_required = db.Column(db.Boolean, nullable=False, default=False)
|
||||
follow_up_note = db.Column(db.Text, nullable=True)
|
||||
# phase49 — WHO asked for the follow-up and when. `follow_up_required` alone
|
||||
# cannot distinguish a client request from an internal one, and staff need to
|
||||
# know who is waiting. Set by flag_followup(), nulled by clear_followup().
|
||||
# NULL on every pre-phase49 row, which the UI renders as an unattributed
|
||||
# follow-up exactly as before.
|
||||
follow_up_requested_by = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('users.id', ondelete='SET NULL',
|
||||
name='fk_inspections_follow_up_requested_by'),
|
||||
nullable=True,
|
||||
)
|
||||
follow_up_requested_at = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
# phase56 — who is to PERFORM the follow-up re-inspection.
|
||||
#
|
||||
# NULL keeps the original behaviour: the follow-up belongs to the
|
||||
# inspection's own inspector. When set, that person owns it instead — they
|
||||
# are the one notified, and the one it appears for on the iPad. Lets a
|
||||
# director (or a Customer Director) hand a re-inspection to someone other
|
||||
# than whoever did the original.
|
||||
#
|
||||
# This is the THIRD FK from inspections to users: every relationship
|
||||
# spanning the two must pin foreign_keys explicitly, or the mapper is
|
||||
# ambiguous and blows up on first ORM USE rather than at import — the app
|
||||
# starts cleanly and then every request 500s.
|
||||
follow_up_assigned_to = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('users.id', ondelete='SET NULL',
|
||||
name='fk_inspections_followup_assignee'),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
results = db.relationship('InspectionResult', backref='inspection', lazy='dynamic', cascade='all, delete-orphan')
|
||||
issues = db.relationship('Issue', backref='inspection', lazy='dynamic', cascade='all, delete-orphan')
|
||||
follow_ups = db.relationship('Inspection', backref=db.backref('parent', remote_side='Inspection.id'),
|
||||
lazy='dynamic', foreign_keys='Inspection.parent_inspection_id')
|
||||
# phase49. Explicit foreign_keys is required: inspector_id also points at
|
||||
# users.id, so SQLAlchemy cannot infer which column this relationship uses.
|
||||
follow_up_requester = db.relationship('User',
|
||||
foreign_keys=[follow_up_requested_by])
|
||||
follow_up_assignee = db.relationship('User',
|
||||
foreign_keys=[follow_up_assigned_to])
|
||||
|
||||
@property
|
||||
def follow_up_owner(self):
|
||||
"""Who is expected to carry out the follow-up.
|
||||
|
||||
The explicit assignee when one is set, otherwise the inspection's own
|
||||
inspector — the single definition of ownership, so the web display, the
|
||||
notification and the mobile API filter cannot disagree about who owns a
|
||||
follow-up.
|
||||
"""
|
||||
return self.follow_up_assignee or self.inspector
|
||||
|
||||
@staticmethod
|
||||
def follow_up_owned_by(user_id):
|
||||
"""SQL predicate: *user_id* owns this inspection's follow-up.
|
||||
|
||||
The query-side mirror of `follow_up_owner` above. Ownership has to be
|
||||
expressed twice — once for a loaded row, once in SQL — so both live
|
||||
here, together, and every caller uses one of them.
|
||||
|
||||
The two arms are mutually exclusive on purpose. Drop the `is_(None)`
|
||||
from the second and an inspector keeps matching a follow-up that was
|
||||
handed to someone else: two people turn up for the same re-inspection.
|
||||
|
||||
Callers: the mobile list filter, the web dashboard card, and the iPad
|
||||
stats KPI. They previously each wrote their own version, and three of
|
||||
them tested AUTHORSHIP — so an assignee saw the work in their list but
|
||||
a 0 on both dashboards.
|
||||
"""
|
||||
return db.or_(
|
||||
Inspection.follow_up_assigned_to == user_id,
|
||||
db.and_(
|
||||
Inspection.follow_up_assigned_to.is_(None),
|
||||
Inspection.inspector_id == user_id,
|
||||
),
|
||||
)
|
||||
|
||||
# The schedule this inspection was started from / materialised by, so the
|
||||
# detail view can show the cadence and who set it up. Explicit foreign_keys
|
||||
# again: inspection_schedules.parent_inspection_id points back here (phase48),
|
||||
# so neither side's join is inferable.
|
||||
inspection_schedule = db.relationship(
|
||||
'InspectionSchedule', foreign_keys=[inspection_schedule_id])
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Inspection {self.id} - {self.inspection_date}>'
|
||||
|
||||
@@ -27,12 +27,93 @@ phase43 adds the single-tenant "plan" semantics alongside that:
|
||||
deactivates a one-time schedule or rolls a recurring one forward.
|
||||
|
||||
`next_run_at` is the due datetime for both modes.
|
||||
|
||||
phase45/46/47 port the single-tenant recurrence + end-date model
|
||||
(ST phase43 + phase44) onto this table without renaming anything:
|
||||
|
||||
phase45 frequency ENUM gains 'once', 'bi-annually' and 'annually'
|
||||
phase46 weekdays / month_mode / day_of_month / nth_week / nth_weekday
|
||||
phase47 end_date
|
||||
|
||||
`next_run_at` keeps its name, its DATETIME type and its index — it remains the
|
||||
due datetime, and is ST's `next_due_date` by another name. All recurrence maths
|
||||
happens on its DATE part; the TIME part is preserved across roll-forwards
|
||||
(defaulting to 06:00, the hour `_compute_next_run()` has always used) so the
|
||||
auto-mode cron keeps firing at the same time of day.
|
||||
|
||||
Two dates, deliberately distinct:
|
||||
next_run_at — mutable state. The next occurrence. Rewritten by fulfill()
|
||||
after every completed inspection.
|
||||
end_date — fixed boundary. The last date an occurrence may fall on, set by
|
||||
the manager and never rewritten. NULL = repeat indefinitely.
|
||||
"""
|
||||
|
||||
import calendar
|
||||
from datetime import date, datetime, time, timedelta
|
||||
|
||||
from app import db
|
||||
from app.utils.time_utils import now_eastern
|
||||
|
||||
|
||||
# Every frequency this table accepts (phase45). 'bi-annually' is every 6 months.
|
||||
FREQUENCY_CHOICES = ('once', 'daily', 'weekly', 'monthly',
|
||||
'quarterly', 'bi-annually', 'annually')
|
||||
|
||||
# Recurring frequencies that advance by whole calendar months.
|
||||
_MONTH_STEPS = {
|
||||
'monthly': 1,
|
||||
'quarterly': 3,
|
||||
'bi-annually': 6,
|
||||
'annually': 12,
|
||||
}
|
||||
|
||||
# Monthly recurrence styles (phase46). Stored as VARCHAR, not ENUM, so adding a
|
||||
# style later needs no 3-step MySQL ENUM migration.
|
||||
MONTH_MODE_DAY = 'day_of_month' # "the 15th of every month"
|
||||
MONTH_MODE_NTH = 'nth_weekday' # "the 2nd Tuesday of every month"
|
||||
|
||||
# Python weekday numbering: Monday=0 … Sunday=6 (matches date.weekday()).
|
||||
WEEKDAY_NAMES = ('Monday', 'Tuesday', 'Wednesday', 'Thursday',
|
||||
'Friday', 'Saturday', 'Sunday')
|
||||
WEEKDAY_ABBREV = ('Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun')
|
||||
|
||||
# nth_week: 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):
|
||||
__tablename__ = 'inspection_schedules'
|
||||
|
||||
@@ -56,9 +137,11 @@ class InspectionSchedule(db.Model):
|
||||
nullable=False
|
||||
)
|
||||
|
||||
# daily | weekly | monthly | quarterly — mirrors InspectionTemplate.frequency
|
||||
# once | daily | weekly | monthly | quarterly | bi-annually | annually
|
||||
# (phase45 — 'once', 'bi-annually' and 'annually' added; the original four
|
||||
# values are unchanged, so no existing row is affected.)
|
||||
frequency = db.Column(
|
||||
db.Enum('daily', 'weekly', 'monthly', 'quarterly'),
|
||||
db.Enum(*FREQUENCY_CHOICES),
|
||||
nullable=False, default='weekly'
|
||||
)
|
||||
active = db.Column(db.Boolean, nullable=False, default=True)
|
||||
@@ -68,6 +151,41 @@ class InspectionSchedule(db.Model):
|
||||
mode = db.Column(db.Enum('auto', 'plan'), nullable=False, default='auto')
|
||||
notes = db.Column(db.Text, nullable=True)
|
||||
|
||||
# ── Follow-up link (phase48) ─────────────────────────────────────────────
|
||||
# Set when this schedule was created as a follow-up of a specific completed
|
||||
# inspection ("Schedule Follow-up" in the iPad's history detail — the
|
||||
# deferred twin of "Re-inspect Now"). The inspection eventually started from
|
||||
# this schedule inherits it as its own parent_inspection_id, so the run
|
||||
# lands as a true linked re-inspection: pre-filled from the parent, and
|
||||
# clearing the parent's follow_up_required on submit. NULL = an ordinary
|
||||
# schedule, which is what every pre-phase48 row is.
|
||||
parent_inspection_id = db.Column(
|
||||
db.Integer,
|
||||
# use_alter + an explicit name: inspections and inspection_schedules now
|
||||
# reference each other, so metadata-driven CREATE/DROP cannot topologically
|
||||
# sort them. The name matches the constraint phase48 creates, so the ORM's
|
||||
# view of the schema and the migration's agree.
|
||||
db.ForeignKey('inspections.id', ondelete='SET NULL',
|
||||
name='fk_inspection_schedules_parent_inspection',
|
||||
use_alter=True),
|
||||
nullable=True, index=True,
|
||||
)
|
||||
|
||||
# ── Receipt acknowledgement (phase50) ────────────────────────────────────
|
||||
# Stamped when the assigned inspector confirms they received the request.
|
||||
# NULL = awaiting confirmation.
|
||||
#
|
||||
# Per ASSIGNMENT, not per occurrence: fulfill() and advance_due_date()
|
||||
# deliberately leave this alone as the schedule rolls forward, so an
|
||||
# inspector who confirmed "yes, this weekly round is mine" is not asked
|
||||
# again every week. The edit route resets it to NULL on reassignment to a
|
||||
# DIFFERENT inspector, who has confirmed nothing.
|
||||
#
|
||||
# The acknowledger is always `inspector` — the only person the routes let
|
||||
# confirm — so no separate acknowledged_by column is needed. Only meaningful
|
||||
# for mode='plan'; an auto schedule has no request to receive.
|
||||
acknowledged_at = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
created_by = db.Column(
|
||||
db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'),
|
||||
nullable=True
|
||||
@@ -76,6 +194,30 @@ class InspectionSchedule(db.Model):
|
||||
last_run_at = db.Column(db.DateTime, nullable=True) # last successful materialisation
|
||||
next_run_at = db.Column(db.DateTime, nullable=True) # when the next inspection is due
|
||||
|
||||
# ── Recurrence detail (phase46) ──────────────────────────────────────────
|
||||
# weekly : CSV of Python weekday ints, e.g. '0,2,4' = Mon/Wed/Fri.
|
||||
# NULL/empty falls back to the legacy "every 7 days" behaviour.
|
||||
# monthly and the other month-stepping frequencies:
|
||||
# month_mode picks which pair of columns applies —
|
||||
# MONTH_MODE_DAY → day_of_month; MONTH_MODE_NTH → nth_week + nth_weekday.
|
||||
# NULL falls back to the legacy "same day next period" behaviour.
|
||||
# Every pre-phase46 row keeps NULLs here and therefore keeps its exact
|
||||
# current cadence.
|
||||
weekdays = db.Column(db.String(20), nullable=True)
|
||||
month_mode = db.Column(db.String(20), nullable=True)
|
||||
day_of_month = db.Column(db.SmallInteger, nullable=True)
|
||||
nth_week = db.Column(db.SmallInteger, nullable=True)
|
||||
nth_weekday = db.Column(db.SmallInteger, nullable=True)
|
||||
|
||||
# ── End-date boundary (phase47) ──────────────────────────────────────────
|
||||
# Fixed boundary set by the manager, never rewritten by the app — unlike
|
||||
# next_run_at, which fulfill() advances after every completed inspection.
|
||||
# NULL = repeat indefinitely, which is what every pre-phase47 row is. Only
|
||||
# meaningful for recurring schedules; the create/edit routes force it to
|
||||
# NULL when frequency == 'once'. Applies to BOTH modes: without it an auto
|
||||
# schedule would keep materialising inspections past its boundary forever.
|
||||
end_date = db.Column(db.Date, nullable=True)
|
||||
|
||||
# phase43 — plan mode bookkeeping
|
||||
last_completed_at = db.Column(db.DateTime, nullable=True)
|
||||
# Per-occurrence reminder de-dup flags; reset when a recurring schedule rolls forward.
|
||||
@@ -89,12 +231,31 @@ class InspectionSchedule(db.Model):
|
||||
area = db.relationship('Area', foreign_keys=[area_id])
|
||||
inspector = db.relationship('User', foreign_keys=[inspector_id])
|
||||
creator = db.relationship('User', foreign_keys=[created_by])
|
||||
# phase48. Explicit foreign_keys is required, not optional: inspections and
|
||||
# inspection_schedules now reference each other (Inspection
|
||||
# .inspection_schedule_id points here, parent_inspection_id points back), so
|
||||
# SQLAlchemy cannot infer the join for either side.
|
||||
parent_inspection = db.relationship('Inspection',
|
||||
foreign_keys=[parent_inspection_id])
|
||||
|
||||
@property
|
||||
def is_follow_up(self):
|
||||
"""True when this schedule was created to follow up an inspection."""
|
||||
return self.parent_inspection_id is not None
|
||||
|
||||
@property
|
||||
def is_acknowledged(self):
|
||||
"""True once the assigned inspector has confirmed receipt (phase50)."""
|
||||
return self.acknowledged_at is not None
|
||||
|
||||
FREQUENCY_LABELS = {
|
||||
'daily': 'Daily',
|
||||
'weekly': 'Weekly',
|
||||
'monthly': 'Monthly',
|
||||
'quarterly': 'Quarterly',
|
||||
'once': 'One-time',
|
||||
'daily': 'Daily',
|
||||
'weekly': 'Weekly',
|
||||
'monthly': 'Monthly',
|
||||
'quarterly': 'Quarterly',
|
||||
'bi-annually': 'Every 6 months',
|
||||
'annually': 'Annually',
|
||||
}
|
||||
|
||||
@property
|
||||
@@ -113,18 +274,223 @@ class InspectionSchedule(db.Model):
|
||||
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.
|
||||
|
||||
Recurring schedules roll their due date forward past today and reset the
|
||||
reminder flags; MT has no 'once' frequency, so a schedule stays active.
|
||||
`next_run_fn(frequency, from_dt)` computes the next due datetime — the
|
||||
route passes `_compute_next_run` so the cadence math lives in one place.
|
||||
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
|
||||
if next_run_fn is not None:
|
||||
self.next_run_at = next_run_fn(self.frequency, now)
|
||||
|
||||
self.advance_due_date(now)
|
||||
if not self.active:
|
||||
# 'once', or the roll-forward crossed the end date. Either way this
|
||||
# was the last occurrence — leave the reminder flags set so nothing
|
||||
# re-fires against a closed schedule.
|
||||
return
|
||||
|
||||
self.advance_notified = False
|
||||
self.due_notified = False
|
||||
self.overdue_notified = False
|
||||
|
||||
+165
-9
@@ -43,6 +43,122 @@ class IssueFollower(db.Model):
|
||||
return f'<IssueFollower issue={self.issue_id} user={self.user_id}>'
|
||||
|
||||
|
||||
|
||||
# ── Issue Link ────────────────────────────────────────────────────────────────
|
||||
# Connects two issues so staff can jump between a duplicate and the original, or
|
||||
# between issues that are simply about the same thing.
|
||||
|
||||
class IssueLink(db.Model):
|
||||
"""One directed link between two issues, displayed on BOTH of them.
|
||||
|
||||
Only one row is stored per pair. The stored direction carries meaning for
|
||||
'duplicate' — issue_id is a duplicate OF linked_issue_id — so the two issues
|
||||
read the same row differently:
|
||||
|
||||
on issue_id -> "Duplicate of #B"
|
||||
on linked_issue_id -> "Duplicated by #A"
|
||||
|
||||
'related' is symmetric and reads "Related to" from either side.
|
||||
|
||||
Storing one row rather than a mirrored pair is what keeps the direction
|
||||
unambiguous and makes unlinking a single delete. The cost is that uniqueness
|
||||
cannot be expressed by the UniqueConstraint alone: (A,B) and (B,A) are
|
||||
distinct rows to the database but the same link to a person, so the
|
||||
duplicate check has to look in both directions. exists_between() is that
|
||||
check, and it is the only thing callers should use.
|
||||
|
||||
A link is PURELY NAVIGATIONAL. Marking a duplicate does not touch either
|
||||
issue's status, SLA, assignee or followers — closing the duplicate stays a
|
||||
deliberate, separate action.
|
||||
|
||||
Multi-tenant: nothing here is tenant-aware, and deliberately so. The table
|
||||
lives in the tenant database and every query routes through RoutingSession,
|
||||
so a link can only ever reach an issue in the same tenant. Scope WITHIN a
|
||||
tenant is the caller's job — see _readable_links() in routes/issues.py.
|
||||
"""
|
||||
__tablename__ = 'issue_links'
|
||||
|
||||
TYPE_DUPLICATE = 'duplicate'
|
||||
TYPE_RELATED = 'related'
|
||||
|
||||
# How each link type reads from the two sides, keyed by (type, is_source).
|
||||
LABELS = {
|
||||
('duplicate', True): 'Duplicate of',
|
||||
('duplicate', False): 'Duplicated by',
|
||||
('related', True): 'Related to',
|
||||
('related', False): 'Related to',
|
||||
}
|
||||
|
||||
# Offered in the "Link an issue" picker. The value is what gets stored; the
|
||||
# phrasing is from the point of view of the issue being viewed.
|
||||
TYPE_CHOICES = [
|
||||
('duplicate', 'Duplicate of'),
|
||||
('related', 'Related to'),
|
||||
]
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
issue_id = db.Column(db.Integer,
|
||||
db.ForeignKey('issues.id', ondelete='CASCADE'),
|
||||
nullable=False, index=True)
|
||||
linked_issue_id = db.Column(db.Integer,
|
||||
db.ForeignKey('issues.id', ondelete='CASCADE'),
|
||||
nullable=False, index=True)
|
||||
link_type = db.Column(db.Enum('duplicate', 'related'),
|
||||
nullable=False, default='related')
|
||||
created_by = db.Column(db.Integer,
|
||||
db.ForeignKey('users.id', ondelete='SET NULL'),
|
||||
nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=now_eastern, nullable=False)
|
||||
|
||||
__table_args__ = (
|
||||
# Catches the exact-duplicate row at the database level. The REVERSE
|
||||
# direction is caught by exists_between() — see the class docstring.
|
||||
db.UniqueConstraint('issue_id', 'linked_issue_id', name='uq_issue_link'),
|
||||
)
|
||||
|
||||
# BOTH relationships must pin foreign_keys: two FKs from this table to
|
||||
# issues leave the join condition ambiguous otherwise, and the mapper raises
|
||||
# on first ORM USE rather than at import — the app starts cleanly and then
|
||||
# every request 500s (the phase56 lesson, CLAUDE.md §17).
|
||||
issue = db.relationship('Issue', foreign_keys=[issue_id],
|
||||
back_populates='links_from')
|
||||
linked_issue = db.relationship('Issue', foreign_keys=[linked_issue_id],
|
||||
back_populates='links_to')
|
||||
creator = db.relationship('User', foreign_keys=[created_by])
|
||||
|
||||
def label_for(self, viewing_issue_id):
|
||||
"""How this link reads on the issue currently being viewed."""
|
||||
return self.LABELS[(self.link_type, self.issue_id == viewing_issue_id)]
|
||||
|
||||
def other_issue(self, viewing_issue_id):
|
||||
"""The issue at the far end of this link from the one being viewed."""
|
||||
return (self.linked_issue if self.issue_id == viewing_issue_id
|
||||
else self.issue)
|
||||
|
||||
@staticmethod
|
||||
def exists_between(issue_id, other_id):
|
||||
"""True when the two issues are already linked, in EITHER direction.
|
||||
|
||||
The UniqueConstraint only covers the stored direction, so this is what
|
||||
stops #A being linked to #B and then #B linked back to #A as a second,
|
||||
contradictory row.
|
||||
"""
|
||||
return db.session.query(
|
||||
IssueLink.query.filter(
|
||||
db.or_(
|
||||
db.and_(IssueLink.issue_id == issue_id,
|
||||
IssueLink.linked_issue_id == other_id),
|
||||
db.and_(IssueLink.issue_id == other_id,
|
||||
IssueLink.linked_issue_id == issue_id),
|
||||
)
|
||||
).exists()
|
||||
).scalar()
|
||||
|
||||
def __repr__(self):
|
||||
return (f'<IssueLink {self.issue_id} {self.link_type} '
|
||||
f'{self.linked_issue_id}>')
|
||||
|
||||
|
||||
class Issue(db.Model):
|
||||
__tablename__ = 'issues'
|
||||
|
||||
@@ -85,18 +201,24 @@ class Issue(db.Model):
|
||||
vendor_notes = db.Column(db.Text, nullable=True)
|
||||
|
||||
# Handler type — who is responsible for resolving the issue (phase39).
|
||||
# NULL and 'internal' both mean janitorial staff (the default); 'facility'
|
||||
# unlocks the facility_handler_* sub-fields; 'vendor' points to vendor_*.
|
||||
handler_type = db.Column(db.Enum('internal', 'facility', 'vendor'), nullable=True)
|
||||
# 'internal' means janitorial staff (the default); 'facility' unlocks the
|
||||
# facility_handler_* sub-fields; 'vendor' points to vendor_*.
|
||||
# NOT NULL DEFAULT 'internal' since phase44 — previously nullable, with NULL
|
||||
# treated as a synonym for 'internal'. Existing NULLs were backfilled by that
|
||||
# migration, so the two representations are now one.
|
||||
handler_type = db.Column(
|
||||
db.Enum('internal', 'facility', 'vendor'),
|
||||
nullable=False, default='internal',
|
||||
)
|
||||
facility_handler_name = db.Column(db.String(100), nullable=True)
|
||||
facility_handler_contact = db.Column(db.String(200), nullable=True)
|
||||
facility_handler_notes = db.Column(db.Text, nullable=True)
|
||||
|
||||
HANDLER_LABELS = {
|
||||
'internal': 'Janitorial Staff',
|
||||
'facility': 'Facility Staff',
|
||||
'vendor': 'External Vendor',
|
||||
}
|
||||
# Free-text name of the janitorial staff member who will handle the issue,
|
||||
# used when handler_type == 'internal'. Distinct from assigned_to (the JQC
|
||||
# User who owns follow-up): the actual crew member may not be a system user.
|
||||
# (phase44)
|
||||
internal_handler_name = db.Column(db.String(100), nullable=True)
|
||||
internal_handler_contact = db.Column(db.String(200), nullable=True) # phone or email
|
||||
|
||||
# Relationships
|
||||
# NOTE: Issue.area is provided by the backref on Area.issues (facility.py).
|
||||
@@ -112,10 +234,37 @@ class Issue(db.Model):
|
||||
followers = db.relationship('IssueFollower', back_populates='issue',
|
||||
cascade='all, delete-orphan', lazy='dynamic')
|
||||
|
||||
# An issue link is stored once and shown on both issues, so each issue has
|
||||
# rows pointing OUT of it and rows pointing AT it. Deleting an issue must
|
||||
# take its links with it from BOTH sides, or the surviving issue keeps a row
|
||||
# referencing one that no longer exists.
|
||||
links_from = db.relationship('IssueLink', back_populates='issue',
|
||||
foreign_keys='IssueLink.issue_id',
|
||||
cascade='all, delete-orphan', lazy='dynamic')
|
||||
links_to = db.relationship('IssueLink', back_populates='linked_issue',
|
||||
foreign_keys='IssueLink.linked_issue_id',
|
||||
cascade='all, delete-orphan', lazy='dynamic')
|
||||
|
||||
def is_followed_by(self, user):
|
||||
"""Return True if the given user is currently following this issue."""
|
||||
return self.followers.filter_by(user_id=user.id).first() is not None
|
||||
|
||||
def all_links(self):
|
||||
"""Every link touching this issue, from both directions, newest first.
|
||||
|
||||
The two relationships are a storage detail — a link is one thing to the
|
||||
person reading it, so callers get a single list and ask each row how it
|
||||
reads via label_for() / other_issue().
|
||||
|
||||
Nothing here filters by permission. The caller MUST drop links whose far
|
||||
end the viewer cannot access, or a link becomes a way to read an issue
|
||||
at a facility they hold no assignment to. See _readable_links() in
|
||||
routes/issues.py.
|
||||
"""
|
||||
links = list(self.links_from) + list(self.links_to)
|
||||
links.sort(key=lambda link: link.created_at, reverse=True)
|
||||
return links
|
||||
|
||||
# Display labels for handler_type. The web templates hardcode these inline;
|
||||
# this mapping exists so the mobile API can return a human-readable label
|
||||
# without the client duplicating the strings. (phase43)
|
||||
@@ -124,6 +273,13 @@ class Issue(db.Model):
|
||||
'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):
|
||||
|
||||
@@ -37,6 +37,12 @@ EVENT_INSPECTION_SCHEDULED = 'inspection_scheduled'
|
||||
# order via the tokenized public link (phase36).
|
||||
EVENT_WORK_ORDER = 'work_order_update'
|
||||
|
||||
# Fired when a follow-up re-inspection is requested — by a manager, or (phase49)
|
||||
# by a customer against their own facility. Routed through notify_by_matrix so
|
||||
# recipients stay admin-configurable; the inspection's own inspector is notified
|
||||
# directly by the route rather than through the matrix.
|
||||
EVENT_FOLLOWUP_REQUESTED = 'followup_requested'
|
||||
|
||||
ALL_EVENT_TYPES = {
|
||||
EVENT_ISSUE_ASSIGNED: 'Issue assigned to me',
|
||||
EVENT_ISSUE_STATUS: 'Issue status changed',
|
||||
@@ -48,6 +54,7 @@ ALL_EVENT_TYPES = {
|
||||
EVENT_ADMIN_BROADCAST: 'Admin broadcast (system announcements)',
|
||||
EVENT_INSPECTION_SCHEDULED: 'Scheduled inspection due (assigned to me)',
|
||||
EVENT_WORK_ORDER: 'Contractor updated a work order',
|
||||
EVENT_FOLLOWUP_REQUESTED: 'Follow-up re-inspection requested',
|
||||
# Customer-facing — only relevant for customer role accounts
|
||||
EVENT_CUSTOMER_INSPECTION_DONE: 'Inspection completed at my facility (portal)',
|
||||
EVENT_CUSTOMER_ISSUE_UPDATED: 'Issue created or updated at my facility (portal)',
|
||||
|
||||
@@ -10,6 +10,14 @@ role_key values
|
||||
admin — all users with role='admin'
|
||||
director — all users with role='director'
|
||||
inspector — all users with role='inspector'
|
||||
EXCEPTION: for event 'inspection_completed', the inspector
|
||||
column notifies ONLY the inspection's own inspector
|
||||
(the submitter), not the whole inspector pool. Scoping is
|
||||
applied in notify_by_matrix() via the inspection_id.
|
||||
external_inspector — all users with role='external_inspector' (customer /
|
||||
third-party inspectors). Separate column so third parties
|
||||
can be routed differently from the tenant's own crew; the
|
||||
'inspection_completed' scoping above applies here too.
|
||||
project_manager — all users with role='project_manager'
|
||||
customer — all customer-portal users assigned to the relevant facility
|
||||
assignee — the specific user the issue/inspection is assigned to
|
||||
@@ -27,6 +35,7 @@ issue_flagged : admin ✓ director ✓ inspector ✗ pm ✗ cust
|
||||
issue_created : admin ✗ director ✗ inspector ✗ pm ✗ customer ✓ (assignee implicit)
|
||||
issue_updated_customer : admin ✗ director ✗ inspector ✗ pm ✗ customer ✓
|
||||
verification_requested : admin ✓ director ✓ inspector ✗ pm ✗ customer ✗
|
||||
followup_requested : admin ✓ director ✓ inspector ✗ pm ✓ customer ✗ (inspection's own inspector implicit)
|
||||
sla_alert : admin ✓ director ✗ inspector ✗ pm ✗ customer ✗ (assignee + followers implicit)
|
||||
score_alert : admin ✓ director ✓ inspector ✗ pm ✗ customer ✗ (facility score drop cron)
|
||||
"""
|
||||
@@ -35,13 +44,17 @@ import json
|
||||
from app import db
|
||||
|
||||
# Role keys available in the matrix UI
|
||||
# NOTE: the two customer-side labels are a display rename only (phase51) — the
|
||||
# role_key values stored in notification_matrix.role_key are unchanged, so no
|
||||
# data migration was needed. See User.CUSTOMER_ROLES.
|
||||
MATRIX_ROLES = [
|
||||
('admin', 'Admin'),
|
||||
('director', 'Director'),
|
||||
('inspector', 'Inspector'),
|
||||
('external_inspector', 'Customer Inspector'),
|
||||
('project_manager', 'Project Manager'),
|
||||
('auditor', 'Auditor'),
|
||||
('customer', 'Customer'),
|
||||
('customer', 'Customer Director'),
|
||||
('custom', 'Custom Recipients'),
|
||||
]
|
||||
|
||||
@@ -59,6 +72,7 @@ MATRIX_EVENTS = {
|
||||
'issue_created': 'Issue created (standalone)',
|
||||
'issue_updated_customer': 'Issue updated (customer)',
|
||||
'verification_requested': 'Verification requested',
|
||||
'followup_requested': 'Follow-up requested (incl. by customer)',
|
||||
'sla_alert': 'SLA at-risk / breached',
|
||||
'score_alert': 'Facility score trend alert (significant drop)',
|
||||
}
|
||||
@@ -143,6 +157,16 @@ MATRIX_DEFAULTS = {
|
||||
('verification_requested', 'project_manager'): False,
|
||||
('verification_requested', 'customer'): False,
|
||||
('verification_requested', 'custom'): False,
|
||||
# followup_requested (phase49) — a customer (or manager) asks for a
|
||||
# re-inspection. On for the roles who action it; the inspection's own
|
||||
# inspector is notified directly by the route, so the inspector column stays
|
||||
# off to avoid alerting the whole inspector pool.
|
||||
('followup_requested', 'admin'): True,
|
||||
('followup_requested', 'director'): True,
|
||||
('followup_requested', 'inspector'): False,
|
||||
('followup_requested', 'project_manager'): True,
|
||||
('followup_requested', 'customer'): False,
|
||||
('followup_requested', 'custom'): False,
|
||||
# sla_alert (assignee + followers always notified implicitly)
|
||||
('sla_alert', 'admin'): True,
|
||||
('sla_alert', 'director'): False,
|
||||
@@ -159,6 +183,18 @@ MATRIX_DEFAULTS = {
|
||||
('score_alert', 'custom'): False,
|
||||
}
|
||||
|
||||
# MT-15 — the External Inspector column defaults to whatever the internal
|
||||
# Inspector column defaults to, for every event. Mirroring rather than listing
|
||||
# 14 more literals means a future event added for 'inspector' automatically
|
||||
# gets a matching external default instead of silently falling back to the
|
||||
# is_enabled() fallback. Admins can diverge the two columns in the UI at any
|
||||
# time; this only seeds rows that do not exist yet.
|
||||
MATRIX_DEFAULTS.update({
|
||||
(_event, 'external_inspector'): _enabled
|
||||
for (_event, _role), _enabled in list(MATRIX_DEFAULTS.items())
|
||||
if _role == 'inspector'
|
||||
})
|
||||
|
||||
|
||||
class NotificationMatrix(db.Model):
|
||||
"""Admin-controlled per-event notification routing."""
|
||||
|
||||
@@ -18,6 +18,29 @@ class SupportChatSession(db.Model):
|
||||
order_by='SupportChatMessage.created_at',
|
||||
)
|
||||
|
||||
@property
|
||||
def message_count(self):
|
||||
"""Number of turns in this session.
|
||||
|
||||
`messages` is a plain list relationship here (ST's is lazy='dynamic'),
|
||||
so this is len() rather than .count(). It is already loaded whenever the
|
||||
session is, so this costs no extra query.
|
||||
"""
|
||||
return len(self.messages)
|
||||
|
||||
@property
|
||||
def preview(self):
|
||||
"""First user message, for list views.
|
||||
|
||||
Scans the loaded list instead of ST's .filter_by(role='user').first(),
|
||||
for the same reason. `messages` is ordered by created_at, so the first
|
||||
match is the opening question.
|
||||
"""
|
||||
for m in self.messages:
|
||||
if m.role == 'user':
|
||||
return m.content
|
||||
return '(no messages)'
|
||||
|
||||
def __repr__(self):
|
||||
return f'<SupportChatSession {self.id}>'
|
||||
|
||||
@@ -46,6 +69,11 @@ class SupportKnowledge(db.Model):
|
||||
title = db.Column(db.String(200), nullable=False)
|
||||
body = db.Column(db.Text, nullable=False)
|
||||
active = db.Column(db.Boolean, default=True, nullable=False)
|
||||
# MT-19 — admin-controlled ordering. Entries are injected into the support
|
||||
# chat's system prompt in this order, so a low sort_order is how an admin
|
||||
# promotes the guidance the assistant should reach for first. Ties break on
|
||||
# id, keeping the order stable.
|
||||
sort_order = db.Column(db.Integer, nullable=False, default=0)
|
||||
created_by = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=now_eastern, nullable=False)
|
||||
updated_at = db.Column(db.DateTime, default=now_eastern, nullable=False)
|
||||
|
||||
+133
-3
@@ -3,14 +3,75 @@ from flask_login import UserMixin
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from app.utils.time_utils import now_eastern
|
||||
|
||||
# Display labels for the role ENUM — the single place a role's user-facing name
|
||||
# is defined.
|
||||
#
|
||||
# The two customer-side roles are a LABEL-ONLY rename (same idea as rule 19,
|
||||
# "Project" -> "Contract"): the stored ENUM values are still 'customer' and
|
||||
# 'external_inspector', so no migration and no role check anywhere had to move.
|
||||
# 'customer' -> "Customer Director" (portal access, CustomerAssignment scope)
|
||||
# 'external_inspector' -> "Customer Inspector" (inspector powers, InspectorAssignment scope)
|
||||
ROLE_LABELS = {
|
||||
'admin': 'Admin',
|
||||
'director': 'Director',
|
||||
'project_manager': 'Project Manager',
|
||||
'auditor': 'Auditor',
|
||||
'inspector': 'Inspector',
|
||||
'external_inspector': 'Customer Inspector',
|
||||
'customer': 'Customer Director',
|
||||
}
|
||||
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
from app import db
|
||||
return db.session.get(User, int(user_id))
|
||||
from app.tenancy.session_binding import parse_user_id
|
||||
# MT-21: the identity string is tenant-tagged in multi-tenant mode. A tag
|
||||
# naming another tenant (a session or remember-me cookie replayed onto this
|
||||
# host) resolves to None here rather than loading the same-numbered user out
|
||||
# of whichever database happens to be bound.
|
||||
uid = parse_user_id(user_id)
|
||||
if uid is None:
|
||||
return None
|
||||
return db.session.get(User, uid)
|
||||
|
||||
class User(UserMixin, db.Model):
|
||||
__tablename__ = 'users'
|
||||
|
||||
# ── Inspector roles (MT-15) ───────────────────────────────────────────────
|
||||
# 'external_inspector' is an inspector employed by the customer or a third
|
||||
# party rather than by the tenant. It has exactly the same capabilities as
|
||||
# the internal 'inspector' role and is scoped the same way — through
|
||||
# InspectorAssignment rows, via get_inspector_scope().
|
||||
#
|
||||
# Every place that used to test `role == 'inspector'` must test membership
|
||||
# of this tuple instead, or external inspectors silently fall into the
|
||||
# privileged (org-wide) branch and see every contract. Use the
|
||||
# `is_inspector` property below — it is an ordinary attribute, so it reads
|
||||
# the same way in Python and in Jinja (`current_user.is_inspector`).
|
||||
INSPECTOR_ROLES = ('inspector', 'external_inspector')
|
||||
|
||||
# ── Customer-side roles (phase51) ────────────────────────────────
|
||||
# Accounts that belong to the CUSTOMER, not to us. Both are created,
|
||||
# invited, assigned and switched from Customer Management (/customers) —
|
||||
# they never appear in User Management.
|
||||
# 'customer' = Customer Director — portal access, read-mostly,
|
||||
# scoped by CustomerAssignment.
|
||||
# 'external_inspector' = Customer Inspector — full inspector capabilities,
|
||||
# scoped by InspectorAssignment (see INSPECTOR_ROLES).
|
||||
#
|
||||
# CAUTION — this tuple is NOT interchangeable with `role == 'customer'`.
|
||||
# A Customer Inspector is an INSPECTOR everywhere it matters: portal
|
||||
# read-only gates, @customer_required, get_customer_scope(), support chat
|
||||
# and the customer branch of every API scope check must keep testing
|
||||
# `role == 'customer'` exactly. Use CUSTOMER_ROLES / is_customer_account
|
||||
# ONLY for account-management surfaces (who is listed, invited, edited,
|
||||
# assigned or switched under /customers). Widening a capability check to
|
||||
# this tuple hands a third-party inspector the customer portal; narrowing
|
||||
# an account-management check to 'customer' strands the inspectors in a
|
||||
# page that no longer manages them.
|
||||
CUSTOMER_ROLES = ('customer', 'external_inspector')
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(100), unique=True, nullable=False, index=True)
|
||||
full_name = db.Column(db.String(150), nullable=True)
|
||||
@@ -19,12 +80,26 @@ class User(UserMixin, db.Model):
|
||||
role = db.Column(
|
||||
# Phase 11 migration complete — 'supervisor' removed from both the DB
|
||||
# ENUM and this Python-side declaration. Director is the canonical role.
|
||||
db.Enum('admin', 'director', 'inspector', 'project_manager', 'customer', 'auditor'),
|
||||
# MT-15 — 'external_inspector' added: a customer / third-party
|
||||
# inspector with identical capabilities to 'inspector'.
|
||||
db.Enum('admin', 'director', 'inspector', 'project_manager', 'customer',
|
||||
'auditor', 'external_inspector'),
|
||||
nullable=False
|
||||
)
|
||||
created_at = db.Column(db.DateTime, default=now_eastern)
|
||||
active = db.Column(db.Boolean, default=True, nullable=False)
|
||||
|
||||
# ── Web portal design preference (MT-16) ──────────────────────────────
|
||||
# 'classic' = the original top-navbar design. 'modern' = the sidebar design.
|
||||
# Drives base.html's layout dispatch via the inject_ui_theme() context
|
||||
# processor. Persisted per user so the choice survives logout.
|
||||
#
|
||||
# Defaults to 'classic' so existing tenants see no change on deploy; the
|
||||
# effective fallback for accounts that never choose is config
|
||||
# DEFAULT_UI_THEME, which a tenant can be provisioned with as 'modern'.
|
||||
ui_theme = db.Column(db.String(16), nullable=False,
|
||||
server_default='classic', default='classic')
|
||||
|
||||
# ── Customer password-setup workflow ──────────────────────────────────
|
||||
# password_set: False for newly created customer accounts until they
|
||||
# complete the set-password flow via emailed link.
|
||||
@@ -43,7 +118,13 @@ class User(UserMixin, db.Model):
|
||||
mfa_recovery_codes = db.Column(db.JSON, nullable=True)
|
||||
|
||||
# Relationships
|
||||
inspections = db.relationship('Inspection', backref='inspector', lazy='dynamic')
|
||||
# phase49: inspections now has TWO foreign keys to users.id — inspector_id
|
||||
# and follow_up_requested_by — so the join is otherwise ambiguous and every
|
||||
# mapper configuration fails with AmbiguousForeignKeysError. This
|
||||
# relationship means "inspections I performed": inspector_id only.
|
||||
inspections = db.relationship('Inspection', backref='inspector',
|
||||
lazy='dynamic',
|
||||
foreign_keys='Inspection.inspector_id')
|
||||
|
||||
# ── Flask-Login integration ────────────────────────────────────────────
|
||||
# Override UserMixin.is_active so that disabled accounts are rejected
|
||||
@@ -52,12 +133,61 @@ class User(UserMixin, db.Model):
|
||||
def is_active(self):
|
||||
return self.active
|
||||
|
||||
# MT-21: Flask-Login derives BOTH the session '_user_id' and the
|
||||
# remember-me cookie payload from get_id(), and feeds both back through
|
||||
# load_user(). Tagging the tenant here is therefore the single seam that
|
||||
# binds every persisted identity to the tenant that issued it. Returns a
|
||||
# bare id (today's format) whenever no tenant is bound.
|
||||
def get_id(self):
|
||||
from app.tenancy.session_binding import tag_user_id
|
||||
return tag_user_id(self.id)
|
||||
|
||||
def set_password(self, password):
|
||||
self.password_hash = generate_password_hash(password)
|
||||
|
||||
def check_password(self, password):
|
||||
return check_password_hash(self.password_hash, password)
|
||||
|
||||
@property
|
||||
def is_inspector(self):
|
||||
"""True for both the internal and the external inspector role.
|
||||
|
||||
Prefer this over `role == 'inspector'` for capability and scoping
|
||||
checks. Use an explicit `role == 'external_inspector'` test only where
|
||||
the two genuinely differ (currently: display labelling only).
|
||||
"""
|
||||
return self.role in self.INSPECTOR_ROLES
|
||||
|
||||
@property
|
||||
def is_external_inspector(self):
|
||||
"""True only for third-party / customer-employed inspectors.
|
||||
|
||||
Display name: "Customer Inspector". The attribute keeps its MT-15
|
||||
name so the existing call sites stay put (the rename is a label,
|
||||
never an identifier).
|
||||
"""
|
||||
return self.role == 'external_inspector'
|
||||
|
||||
@property
|
||||
def is_customer_account(self):
|
||||
"""True for BOTH customer-side roles — an account-management question.
|
||||
|
||||
Answers "is this account managed under /customers?", NOT "does this
|
||||
account get the customer portal". For the latter keep testing
|
||||
`role == 'customer'`. See the CUSTOMER_ROLES note above.
|
||||
"""
|
||||
return self.role in self.CUSTOMER_ROLES
|
||||
|
||||
@property
|
||||
def is_customer_director(self):
|
||||
"""True for the portal-side customer role ('customer')."""
|
||||
return self.role == 'customer'
|
||||
|
||||
@property
|
||||
def role_label(self):
|
||||
"""Human-readable role name, used in staff-facing lists."""
|
||||
return ROLE_LABELS.get(self.role, (self.role or '').replace('_', ' ').title())
|
||||
|
||||
@property
|
||||
def display_name(self):
|
||||
"""Return full name if set, otherwise fall back to username."""
|
||||
|
||||
@@ -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 urllib.parse import urlparse
|
||||
from flask_login import login_user, logout_user, login_required, current_user
|
||||
from app import db, limiter
|
||||
from app.models.user import User
|
||||
@@ -9,6 +8,7 @@ from app.utils import mfa
|
||||
import logging
|
||||
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE, ACTION_LOGIN, ACTION_LOGOUT
|
||||
from app.tenancy.gates import quota_soft_check
|
||||
from app.tenancy.session_binding import bind_session_tenant, SESSION_TENANT_KEY
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -41,6 +41,7 @@ def login():
|
||||
# second-factor step. Password is verified; identity is NOT yet
|
||||
# established until the code is confirmed at /auth/mfa.
|
||||
if user.mfa_enabled and user.mfa_secret:
|
||||
bind_session_tenant() # MT-21: tag before identity enters the session
|
||||
session['mfa_pending_user_id'] = user.id
|
||||
session['mfa_pending_remember'] = bool(form.remember_me.data)
|
||||
session['mfa_pending_next'] = safe_redirect_url(request.args.get('next'))
|
||||
@@ -48,6 +49,7 @@ def login():
|
||||
return redirect(url_for('auth.mfa_challenge'))
|
||||
|
||||
login_user(user, remember=form.remember_me.data)
|
||||
bind_session_tenant() # MT-21
|
||||
# Use validated next URL — never redirect blindly to request.args['next']
|
||||
next_page = safe_redirect_url(request.args.get('next'))
|
||||
log_action(ACTION_LOGIN, 'User', user.id, user.username)
|
||||
@@ -107,6 +109,7 @@ def mfa_challenge():
|
||||
next_page = session.pop('mfa_pending_next', None)
|
||||
session.pop('mfa_pending_user_id', None)
|
||||
login_user(user, remember=remember)
|
||||
bind_session_tenant() # MT-21
|
||||
log_action(ACTION_LOGIN, 'User', user.id, user.username, f'2fa via {via}')
|
||||
if via == 'recovery':
|
||||
remaining_n = len(user.mfa_recovery_codes or [])
|
||||
@@ -199,7 +202,7 @@ def profile():
|
||||
|
||||
if form.validate_on_submit():
|
||||
current_user.full_name = form.full_name.data.strip() or None
|
||||
current_user.email = form.email.data
|
||||
current_user.email = form.email.data.strip().lower()
|
||||
|
||||
if form.new_password.data:
|
||||
current_user.set_password(form.new_password.data)
|
||||
@@ -242,14 +245,163 @@ def profile():
|
||||
)
|
||||
|
||||
|
||||
# ── Self-service data export (GDPR Art. 15/20, CCPA right-to-know) ────────────
|
||||
|
||||
@bp.route('/my-data/export')
|
||||
@login_required
|
||||
def export_my_data():
|
||||
"""Download a JSON snapshot of everything this account's own records hold:
|
||||
profile fields, inspections performed, issues reported/assigned/commented
|
||||
on, and the audit log entries recorded against this user id.
|
||||
|
||||
Read-only, and scoped to the caller. Records that merely *reference* this
|
||||
user are included only as the user's own row — related entities are NOT
|
||||
expanded, so an issue this user commented on contributes the comment, not
|
||||
the facility details or the other participants. That keeps a subject-access
|
||||
request from becoming a data leak about everyone else.
|
||||
|
||||
Multi-tenant note: this runs against the caller's own tenant DB via the
|
||||
normal request routing, so it can only ever see that tenant's data.
|
||||
"""
|
||||
from flask import Response
|
||||
import json
|
||||
from app.models.inspection import Inspection
|
||||
from app.models.issue import Issue, IssueComment
|
||||
from app.models.audit import AuditLog
|
||||
from app.utils.time_utils import now_eastern
|
||||
|
||||
user = current_user
|
||||
|
||||
payload = {
|
||||
'exported_at': now_eastern().isoformat(),
|
||||
'profile': {
|
||||
'id': user.id,
|
||||
'username': user.username,
|
||||
'full_name': user.full_name,
|
||||
'email': user.email,
|
||||
'role': user.role,
|
||||
'created_at': user.created_at.isoformat() if user.created_at else None,
|
||||
'active': user.active,
|
||||
},
|
||||
'inspections_performed': [
|
||||
{'id': i.id, 'facility_id': i.facility_id,
|
||||
'inspection_date': i.inspection_date.isoformat() if i.inspection_date else None,
|
||||
'overall_score': i.overall_score, 'status': i.status}
|
||||
for i in Inspection.query.filter_by(inspector_id=user.id).all()
|
||||
],
|
||||
'issues_reported': [
|
||||
{'id': iss.id, 'facility_id': iss.facility_id, 'description': iss.description,
|
||||
'status': iss.status, 'severity': iss.severity,
|
||||
'reported_at': iss.reported_at.isoformat() if iss.reported_at else None}
|
||||
for iss in Issue.query.filter_by(reported_by=user.id).all()
|
||||
],
|
||||
'issues_assigned': [
|
||||
{'id': iss.id, 'facility_id': iss.facility_id, 'description': iss.description,
|
||||
'status': iss.status, 'severity': iss.severity}
|
||||
for iss in Issue.query.filter_by(assigned_to=user.id).all()
|
||||
],
|
||||
'issue_comments_authored': [
|
||||
{'id': c.id, 'issue_id': c.issue_id, 'body': c.body,
|
||||
'created_at': c.created_at.isoformat() if c.created_at else None}
|
||||
for c in IssueComment.query.filter_by(user_id=user.id).all()
|
||||
],
|
||||
'audit_log_entries': [
|
||||
{'id': a.id, 'action': a.action, 'entity_type': a.entity_type,
|
||||
'entity_id': a.entity_id, 'entity_label': a.entity_label,
|
||||
'created_at': a.created_at.isoformat() if a.created_at else None}
|
||||
for a in AuditLog.query.filter_by(user_id=user.id).all()
|
||||
],
|
||||
}
|
||||
|
||||
log_action(ACTION_UPDATE, 'User', user.id, user.username, 'self-service data export')
|
||||
logger.info('AUTH | export_my_data | user_id=%s username=%s', user.id, user.username)
|
||||
|
||||
body = json.dumps(payload, indent=2, default=str)
|
||||
return Response(
|
||||
body,
|
||||
mimetype='application/json',
|
||||
headers={'Content-Disposition': f'attachment; filename=jqc_my_data_{user.id}.json'},
|
||||
)
|
||||
|
||||
|
||||
# ── Self-service erasure request (GDPR Art. 17, CCPA right-to-delete) ─────────
|
||||
|
||||
@bp.route('/my-data/delete-request', methods=['POST'])
|
||||
@login_required
|
||||
def request_my_data_deletion():
|
||||
"""Erase this account's PII on request.
|
||||
|
||||
Two outcomes, chosen automatically:
|
||||
|
||||
* No records that a hard delete would orphan (same guard rails as the admin
|
||||
delete_user route) → the account is deleted outright.
|
||||
* Otherwise — the common case, since staff usually have inspection or issue
|
||||
history that must be kept for business and audit continuity — the account
|
||||
is ANONYMIZED in place: name/email/username replaced with a
|
||||
non-identifying placeholder, the password hash invalidated so nobody can
|
||||
ever log in as it again, and the account deactivated.
|
||||
|
||||
Historical records reference the user *id*, not the PII, so they survive the
|
||||
anonymization unchanged and the audit trail stays intact. This is the
|
||||
balance the regulations expect: erase the identity, keep the ledger.
|
||||
"""
|
||||
import secrets
|
||||
from app.models.issue import Issue as _Issue, IssueComment as _IssueComment
|
||||
from app.models.inspection import InspectionTemplate as _InspectionTemplate
|
||||
|
||||
user = current_user
|
||||
|
||||
blocking = (
|
||||
user.inspections.count() > 0
|
||||
or _Issue.query.filter_by(assigned_to=user.id).count() > 0
|
||||
or _IssueComment.query.filter_by(user_id=user.id).count() > 0
|
||||
or _InspectionTemplate.query.filter_by(created_by=user.id).count() > 0
|
||||
)
|
||||
|
||||
username = user.username
|
||||
user_id = user.id
|
||||
|
||||
if not blocking:
|
||||
db.session.delete(user)
|
||||
db.session.commit()
|
||||
logout_user()
|
||||
logger.info('AUTH | self_delete | user_id=%s username=%s', user_id, username)
|
||||
log_action(ACTION_DELETE, 'User', user_id, username,
|
||||
'self-service account deletion')
|
||||
flash('Your account and data have been permanently deleted.', 'success')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
placeholder = f'deleted_user_{user_id}'
|
||||
user.full_name = None
|
||||
user.email = f'{placeholder}@deleted.local'
|
||||
user.username = placeholder
|
||||
# Random hash nobody holds — the account can never be logged into again.
|
||||
user.set_password(secrets.token_hex(32))
|
||||
user.active = False
|
||||
db.session.commit()
|
||||
logger.info('AUTH | self_anonymize | user_id=%s '
|
||||
'(had blocking records, hard delete not possible)', user_id)
|
||||
log_action(ACTION_UPDATE, 'User', user_id, placeholder,
|
||||
'self-service erasure request — anonymized '
|
||||
'(blocking records retained for audit/business continuity)')
|
||||
logout_user()
|
||||
flash('Your personal information has been removed and your account '
|
||||
'deactivated. Historical records tied to your account id are retained '
|
||||
'for audit continuity but no longer identify you.', 'success')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
|
||||
@bp.route('/users')
|
||||
@login_required
|
||||
@admin_required
|
||||
def list_users():
|
||||
# Exclude customer accounts — those are managed exclusively via /customers
|
||||
# Exclude customer-side accounts — Customer Director AND Customer Inspector
|
||||
# are both managed exclusively via /customers (phase51). Using
|
||||
# User.CUSTOMER_ROLES rather than != 'customer' is what moves the customer
|
||||
# inspectors off this page.
|
||||
users = (
|
||||
User.query
|
||||
.filter(User.role != 'customer')
|
||||
.filter(~User.role.in_(User.CUSTOMER_ROLES))
|
||||
.order_by(User.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
@@ -273,6 +425,25 @@ def list_users():
|
||||
inspector_contract_counts=inspector_contract_counts)
|
||||
|
||||
|
||||
def _redirect_if_customer_account(user):
|
||||
"""Send customer-side accounts back to Customer Management.
|
||||
|
||||
phase51 moved Customer Director + Customer Inspector wholly under
|
||||
/customers. These accounts are no longer listed here, but the /auth/users
|
||||
URLs are still reachable by hand — and editing one through UserForm would
|
||||
fail anyway ('external_inspector' is no longer an offered role choice, so
|
||||
SelectField would reject the existing value). Redirect instead of 404 so an
|
||||
old bookmark lands on the page that now owns the account.
|
||||
|
||||
Returns a response to return, or None to continue.
|
||||
"""
|
||||
if user is not None and user.is_customer_account:
|
||||
flash(f'{user.display_name} is a {user.role_label} account and is '
|
||||
f'managed in Customer Management.', 'info')
|
||||
return redirect(url_for('customers.manage', customer_id=user.id))
|
||||
return None
|
||||
|
||||
|
||||
@bp.route('/users/new', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
@@ -286,19 +457,39 @@ def create_user():
|
||||
|
||||
if form.validate_on_submit():
|
||||
role = 'inspector' if director_editing else form.role.data
|
||||
|
||||
# phase51 — the invitation branch that used to live here moved to
|
||||
# Customer Management along with the Customer Inspector role. Every
|
||||
# role this form still offers is OUR OWN staff, created with an
|
||||
# admin-set password. Customer-side accounts are invited (they choose
|
||||
# their own username and password) via customers.create().
|
||||
#
|
||||
# UserForm.password is Optional() because the same form is used for
|
||||
# EDIT, where blank means "keep current". On CREATE a blank password
|
||||
# would otherwise store the hash of an empty string, so require one.
|
||||
if not form.password.data:
|
||||
flash('Please set a password for the new user.', 'danger')
|
||||
return render_template('auth/user_form.html', form=form, user=None,
|
||||
title='Create User',
|
||||
director_editing=director_editing)
|
||||
|
||||
user = User(
|
||||
username=form.username.data,
|
||||
full_name=form.full_name.data.strip() or None,
|
||||
email=form.email.data,
|
||||
role=role
|
||||
email=form.email.data.strip().lower(),
|
||||
role=role,
|
||||
password_set=True,
|
||||
)
|
||||
user.set_password(form.password.data)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
logger.info('AUTH | user_create | admin_id=%s admin=%s new_user=%s role=%s',
|
||||
current_user.id, current_user.username, user.username, user.role)
|
||||
current_user.id, current_user.username, user.username,
|
||||
user.role)
|
||||
log_action(ACTION_CREATE, 'User', user.id, user.username,
|
||||
f'role={user.role}; email={user.email}')
|
||||
|
||||
flash(f'User {user.username} created successfully.', 'success')
|
||||
return redirect(url_for('auth.list_users'))
|
||||
|
||||
@@ -313,6 +504,9 @@ def edit_user(user_id):
|
||||
user = db.session.get(User, user_id)
|
||||
if user is None:
|
||||
abort(404)
|
||||
moved = _redirect_if_customer_account(user)
|
||||
if moved:
|
||||
return moved
|
||||
form = UserForm(user=user, obj=user)
|
||||
|
||||
# Directors may not change another user's role — that privilege is admin-only.
|
||||
@@ -323,7 +517,7 @@ def edit_user(user_id):
|
||||
if form.validate_on_submit():
|
||||
user.username = form.username.data
|
||||
user.full_name = form.full_name.data.strip() or None
|
||||
user.email = form.email.data
|
||||
user.email = form.email.data.strip().lower()
|
||||
|
||||
if not director_editing:
|
||||
user.role = form.role.data
|
||||
@@ -343,13 +537,58 @@ def edit_user(user_id):
|
||||
title='Edit User', director_editing=director_editing)
|
||||
|
||||
|
||||
@bp.route('/users/<int:user_id>/resend-invite', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def resend_invite(user_id):
|
||||
"""Re-send the set-password invitation for an account still awaiting setup.
|
||||
|
||||
Without this an invitation that bounces, is deleted or expires leaves the
|
||||
account permanently unusable — password_set=False blocks login and only a
|
||||
valid token can clear it. Mirrors customers.resend_invite for staff-side
|
||||
accounts (currently only external inspectors are ever invited this way).
|
||||
"""
|
||||
user = db.session.get(User, user_id)
|
||||
if user is None:
|
||||
abort(404)
|
||||
moved = _redirect_if_customer_account(user)
|
||||
if moved:
|
||||
return moved
|
||||
if user.password_set:
|
||||
flash(f'{user.display_name} has already completed their account setup.',
|
||||
'info')
|
||||
return redirect(url_for('auth.list_users'))
|
||||
|
||||
# A fresh token invalidates the previous link.
|
||||
token = user.generate_set_password_token(expires_hours=72)
|
||||
db.session.commit()
|
||||
|
||||
logger.info('AUTH | resend_invite | admin=%s user=%s',
|
||||
current_user.username, user.username)
|
||||
log_action(ACTION_UPDATE, 'User', user.id, user.username,
|
||||
'invitation email resent')
|
||||
|
||||
from app.routes.customers import _send_invite_email
|
||||
_send_invite_email(user, token, base_url=request.host_url)
|
||||
|
||||
flash(f'Invitation resent to {user.email}.', 'success')
|
||||
return redirect(url_for('auth.list_users'))
|
||||
|
||||
|
||||
@bp.route('/users/<int:user_id>/assign-contracts', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def assign_inspector_contracts(user_id):
|
||||
user = db.session.get(User, user_id)
|
||||
if user is None or user.role != 'inspector':
|
||||
# MT-15: external inspectors are scoped by the same InspectorAssignment
|
||||
# rows, so this page must accept them too.
|
||||
if user is None or not user.is_inspector:
|
||||
abort(404)
|
||||
# A Customer Inspector is scoped by exactly these rows, but the page that
|
||||
# owns them is now customers.manage — one editor per account, not two.
|
||||
moved = _redirect_if_customer_account(user)
|
||||
if moved:
|
||||
return moved
|
||||
|
||||
from app.models.project import Project
|
||||
from app.models.inspector_assignment import InspectorAssignment
|
||||
@@ -536,7 +775,6 @@ def _send_password_reset_email(user, token, base_url=None):
|
||||
from flask import current_app, render_template_string, url_for as _url_for
|
||||
from flask_mail import Message
|
||||
from app import mail
|
||||
from urllib.parse import urlparse
|
||||
import threading
|
||||
|
||||
if not current_app.config.get('MAIL_SERVER'):
|
||||
@@ -545,8 +783,17 @@ def _send_password_reset_email(user, token, base_url=None):
|
||||
|
||||
effective_base = (base_url or current_app.config.get('APP_BASE_URL', '')).rstrip('/')
|
||||
reset_link = f'{effective_base}{_url_for("auth.reset_password", token=token)}'
|
||||
host = urlparse(effective_base).netloc or 'janitorialqc.local'
|
||||
sender = f'noreply@{host}'
|
||||
|
||||
# Branded From as a (display_name, address) tuple, exactly as
|
||||
# customers._send_invite_email does. The display NAME tracks the tenant; the
|
||||
# ADDRESS is branded only for DNS-authorized domains and otherwise stays the
|
||||
# authenticated SMTP identity so the mail still delivers. The previous
|
||||
# `noreply@{host}` sent from whatever host the browser was on, which is not
|
||||
# an authorized sender for that domain — the mail server accepted it and it
|
||||
# was then dropped downstream by SPF/DMARC. See app/utils/mail_utils.py and
|
||||
# CLAUDE.md rule 64.
|
||||
from app.utils.mail_utils import branded_sender
|
||||
sender = branded_sender(effective_base)
|
||||
|
||||
html_body = render_template_string("""<!DOCTYPE html>
|
||||
<html>
|
||||
@@ -612,12 +859,26 @@ def forgot_password():
|
||||
|
||||
form = ForgotPasswordForm()
|
||||
if form.validate_on_submit():
|
||||
user = User.query.filter_by(email=form.email.data.strip().lower()).first()
|
||||
# Explicit case-insensitive lookup. This is NOT fixing a live bug: no
|
||||
# table here declares a COLLATE, so `users.email` inherits the utf8mb4
|
||||
# default (utf8mb4_general_ci / utf8mb4_0900_ai_ci), both of which are
|
||||
# case-insensitive — a bare `== lower(input)` already matched a
|
||||
# mixed-case stored address. The point is to stop depending on that
|
||||
# server default: under a binary/_bin collation the bare comparison
|
||||
# would silently find nothing and still show the success message below.
|
||||
email_input = form.email.data.strip().lower()
|
||||
user = User.query.filter(
|
||||
db.func.lower(User.email) == email_input
|
||||
).first()
|
||||
if user and user.active:
|
||||
token = user.generate_set_password_token(expires_hours=1)
|
||||
db.session.commit()
|
||||
_send_password_reset_email(user, token, base_url=request.host_url)
|
||||
logger.info('AUTH | forgot_password | user=%s | email=%s', user.username, user.email)
|
||||
logger.info('AUTH | forgot_password | reset link dispatched | user=%s | email=%s',
|
||||
user.username, user.email)
|
||||
else:
|
||||
# No leak to the user (generic message below), but log for diagnosis.
|
||||
logger.info('AUTH | forgot_password | no active account for email=%s', email_input)
|
||||
# Always show the same message — never reveal whether the email exists
|
||||
flash(
|
||||
'If an account with that email address exists, a password reset link '
|
||||
@@ -691,6 +952,23 @@ def impersonate_entry():
|
||||
flask_session['impersonating_tenant_id'] = tenant_id
|
||||
flask_session['impersonating_superadmin_id'] = superadmin_id
|
||||
|
||||
# ── MT-21: re-tag the session for the impersonated tenant ────────────────
|
||||
# Identity in the session is tenant-tagged (User.get_id), and from the next
|
||||
# request onward the middleware binds the impersonated tenant's database.
|
||||
# Without re-tagging, load_user() would correctly reject the tag issued by
|
||||
# the host tenant and the superadmin would land on a login page.
|
||||
#
|
||||
# This preserves the pre-existing impersonation semantics EXACTLY: the
|
||||
# numeric user id carries over, so the superadmin is loaded as the
|
||||
# same-numbered user in the target tenant's database. That behaviour is
|
||||
# arbitrary and worth revisiting (see MULTI_TENANT_PLAN.md open items) —
|
||||
# but changing it is a separate decision, not a security fix.
|
||||
flask_session[SESSION_TENANT_KEY] = tenant_id
|
||||
raw_uid = flask_session.get('_user_id')
|
||||
if raw_uid is not None:
|
||||
numeric_uid = str(raw_uid).rpartition(':')[2]
|
||||
flask_session['_user_id'] = f'{tenant_id}:{numeric_uid}'
|
||||
|
||||
logger.info('AUTH | impersonate_start | sa=%s tenant=%s', superadmin_id, tenant_id)
|
||||
|
||||
import os
|
||||
@@ -714,6 +992,11 @@ def impersonate_end():
|
||||
import os
|
||||
flask_session.pop('impersonating_tenant_id', None)
|
||||
flask_session.pop('impersonating_superadmin_id', None)
|
||||
# MT-21: the session identity is still tagged for the impersonated tenant.
|
||||
# Drop it rather than carrying it back to the superadmin's own host, where
|
||||
# the middleware would clear it on tenant mismatch anyway.
|
||||
logout_user()
|
||||
flask_session.clear()
|
||||
panel_url = f"https://admin.{os.environ.get('TENANT_BASE_DOMAIN', 'jqc.app')}"
|
||||
logger.info('AUTH | impersonate_end | redirecting to panel')
|
||||
return redirect(panel_url)
|
||||
@@ -22,10 +22,12 @@ logger = logging.getLogger(__name__)
|
||||
bp = Blueprint('broadcast', __name__, url_prefix='/admin/broadcast')
|
||||
|
||||
# All roles that can hold an active iOS session
|
||||
BROADCAST_ROLES = ['inspector', 'project_manager', 'director', 'admin']
|
||||
BROADCAST_ROLES = ['inspector', 'external_inspector', 'project_manager',
|
||||
'director', 'admin']
|
||||
|
||||
ROLE_LABELS = {
|
||||
'inspector': 'Inspectors',
|
||||
'inspector': 'Inspectors',
|
||||
'external_inspector': 'Customer Inspectors',
|
||||
'project_manager': 'Project Managers',
|
||||
'director': 'Directors',
|
||||
'admin': 'Admins',
|
||||
|
||||
+402
-70
@@ -3,13 +3,26 @@ app/routes/customers.py
|
||||
-----------------------
|
||||
Customer Management — admin-only consolidated view.
|
||||
|
||||
Owns BOTH customer-side roles (phase51 — see User.CUSTOMER_ROLES):
|
||||
|
||||
Customer Director role='customer' portal access, read-mostly,
|
||||
scoped by CustomerAssignment
|
||||
Customer Inspector role='external_inspector' full inspector capabilities,
|
||||
scoped by InspectorAssignment
|
||||
|
||||
They are two seats of the same customer organisation, so they are listed,
|
||||
invited, edited, assigned, switched and disabled here rather than in User
|
||||
Management — which now excludes both.
|
||||
|
||||
Provides a single screen to:
|
||||
- List all customer-role users with their assignment summary
|
||||
- Create a new customer account
|
||||
- Edit an existing customer (username / email / password / active)
|
||||
- Manage assignments for a customer (add / remove)
|
||||
- Quick-disable / enable a customer account
|
||||
- View a customer's scoped facility access at a glance
|
||||
- List all customer-side users with their assignment summary
|
||||
- Invite a new customer account in either role
|
||||
- Edit an existing account (username / email / password / active)
|
||||
- Manage assignments (contracts/facilities for a director, contracts for an
|
||||
inspector)
|
||||
- Switch an account between the two roles
|
||||
- Quick-disable / enable an account
|
||||
- View the account's scoped facility access at a glance
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -18,6 +31,7 @@ from flask_login import login_required, current_user
|
||||
from app import db
|
||||
from app.models.user import User
|
||||
from app.models.project import Project, CustomerAssignment
|
||||
from app.models.inspector_assignment import InspectorAssignment
|
||||
from app.models.facility import Facility
|
||||
from app.utils.forms import CustomerUserForm, CustomerAssignmentForm, CustomerInviteForm, SetPasswordForm
|
||||
from app.utils.decorators import admin_required, supervisor_required, safe_redirect_url
|
||||
@@ -29,16 +43,46 @@ logger = logging.getLogger(__name__)
|
||||
bp = Blueprint('customers', __name__, url_prefix='/customers')
|
||||
|
||||
|
||||
def _get_customer_or_redirect(customer_id):
|
||||
"""Load a customer-side account, or return a redirect response.
|
||||
|
||||
Returns (account, None) on success and (None, response) when the id is not
|
||||
a customer-side account. Every route here used to test
|
||||
`customer.role != 'customer'`, which would now reject the Customer
|
||||
Inspectors this page owns — the check is CUSTOMER_ROLES, once, here.
|
||||
"""
|
||||
account = db.session.get(User, customer_id)
|
||||
if account is None:
|
||||
abort(404)
|
||||
if not account.is_customer_account:
|
||||
flash('This page is only for customer accounts.', 'warning')
|
||||
return None, redirect(url_for('customers.index'))
|
||||
return account, None
|
||||
|
||||
|
||||
def _inspector_scope_ids(user, project_facilities_map):
|
||||
"""Facility IDs a Customer Inspector reaches, from its contract rows.
|
||||
|
||||
Mirrors get_inspector_scope() but reuses the caller's already-loaded
|
||||
project → facilities map so the list view stays free of N+1 queries
|
||||
(rule 13).
|
||||
"""
|
||||
ids = set()
|
||||
for a in InspectorAssignment.query.filter_by(user_id=user.id).all():
|
||||
ids.update(project_facilities_map.get(a.project_id, []))
|
||||
return ids
|
||||
|
||||
|
||||
# ── List ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/')
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def index():
|
||||
"""Consolidated customer management dashboard."""
|
||||
"""Consolidated customer management dashboard — both customer roles."""
|
||||
customers = (
|
||||
User.query
|
||||
.filter_by(role='customer')
|
||||
.filter(User.role.in_(User.CUSTOMER_ROLES))
|
||||
.order_by(User.username)
|
||||
.all()
|
||||
)
|
||||
@@ -57,10 +101,27 @@ def index():
|
||||
for a in all_assignments:
|
||||
assignment_map[a.user_id].append(a)
|
||||
|
||||
# ── Bulk query for the inspector-side assignments ─────────────────────
|
||||
# Customer Inspectors are scoped by InspectorAssignment, not
|
||||
# CustomerAssignment — the two roles read different tables for the same
|
||||
# question ("which facilities does this account see?").
|
||||
all_inspector_assignments = (
|
||||
InspectorAssignment.query
|
||||
.filter(InspectorAssignment.user_id.in_(customer_ids))
|
||||
.all()
|
||||
) if customer_ids else []
|
||||
|
||||
inspector_assignment_map = {c.id: [] for c in customers}
|
||||
for a in all_inspector_assignments:
|
||||
inspector_assignment_map[a.user_id].append(a)
|
||||
|
||||
# ── Single bulk query for all active facilities in assigned projects ──
|
||||
# Resolves facility scope for every customer without repeated DB round-trips.
|
||||
from collections import defaultdict
|
||||
assigned_project_ids = {a.project_id for a in all_assignments}
|
||||
assigned_project_ids = (
|
||||
{a.project_id for a in all_assignments}
|
||||
| {a.project_id for a in all_inspector_assignments}
|
||||
)
|
||||
|
||||
project_facilities_map = defaultdict(list) # project_id → [facility_id, ...]
|
||||
if assigned_project_ids:
|
||||
@@ -78,11 +139,17 @@ def index():
|
||||
scope_map = {} # user_id → sorted list[int] facility IDs
|
||||
for customer in customers:
|
||||
ids = set()
|
||||
for a in assignment_map[customer.id]:
|
||||
if a.facility_id:
|
||||
ids.add(a.facility_id)
|
||||
else:
|
||||
if customer.is_inspector:
|
||||
# Customer Inspector — contract-level rows only, no facility-level
|
||||
# narrowing exists for inspectors (rule 57: no rows = sees nothing).
|
||||
for a in inspector_assignment_map[customer.id]:
|
||||
ids.update(project_facilities_map.get(a.project_id, []))
|
||||
else:
|
||||
for a in assignment_map[customer.id]:
|
||||
if a.facility_id:
|
||||
ids.add(a.facility_id)
|
||||
else:
|
||||
ids.update(project_facilities_map.get(a.project_id, []))
|
||||
scope_map[customer.id] = sorted(ids)
|
||||
|
||||
# All active projects for the assignment modal
|
||||
@@ -101,11 +168,12 @@ def index():
|
||||
|
||||
return render_template(
|
||||
'customers/index.html',
|
||||
customers = customers,
|
||||
assignment_map = assignment_map,
|
||||
scope_map = scope_map,
|
||||
projects = projects,
|
||||
expired_invitations = expired_invitations,
|
||||
customers = customers,
|
||||
assignment_map = assignment_map,
|
||||
inspector_assignment_map = inspector_assignment_map,
|
||||
scope_map = scope_map,
|
||||
projects = projects,
|
||||
expired_invitations = expired_invitations,
|
||||
)
|
||||
|
||||
|
||||
@@ -115,12 +183,16 @@ def index():
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def create():
|
||||
"""Create a customer account via email invitation.
|
||||
"""Create a customer-side account via email invitation.
|
||||
|
||||
Admin enters Full Name and Email only. A temporary username is
|
||||
auto-generated from the email address. A one-time set-password link
|
||||
is emailed; the customer chooses their own username and password when
|
||||
they click it. The account is activated on completion.
|
||||
Admin enters Full Name, Email and the role (Customer Director or Customer
|
||||
Inspector). A temporary username is auto-generated from the email address.
|
||||
A one-time set-password link is emailed; the invitee chooses their own
|
||||
username and password when they click it. The account is activated on
|
||||
completion.
|
||||
|
||||
Both roles take this identical path — an account that belongs to the
|
||||
customer is never given a password we chose.
|
||||
"""
|
||||
form = CustomerInviteForm()
|
||||
|
||||
@@ -129,6 +201,11 @@ def create():
|
||||
|
||||
full_name = form.full_name.data.strip()
|
||||
email = form.email.data.strip().lower()
|
||||
role = form.role.data
|
||||
# Defence in depth: never let a crafted POST mint a staff role through
|
||||
# the customer invitation form, which sets no password.
|
||||
if role not in User.CUSTOMER_ROLES:
|
||||
role = 'customer'
|
||||
|
||||
# Auto-generate a temporary username from the email local part.
|
||||
# The customer replaces this with their preferred username when
|
||||
@@ -146,7 +223,7 @@ def create():
|
||||
username = username,
|
||||
full_name = full_name,
|
||||
email = email,
|
||||
role = 'customer',
|
||||
role = role,
|
||||
active = True,
|
||||
password_set = False,
|
||||
)
|
||||
@@ -157,15 +234,15 @@ def create():
|
||||
token = user.generate_set_password_token(expires_hours=72)
|
||||
db.session.commit()
|
||||
|
||||
logger.info('CUSTOMERS | invite | admin=%s new_customer=%s email=%s',
|
||||
current_user.username, user.username, user.email)
|
||||
logger.info('CUSTOMERS | invite | admin=%s new_customer=%s role=%s email=%s',
|
||||
current_user.username, user.username, user.role, user.email)
|
||||
log_action(ACTION_CREATE, 'User', user.id, user.username,
|
||||
f'role=customer; email={user.email}; invite_sent=True')
|
||||
f'role={user.role}; email={user.email}; invite_sent=True')
|
||||
|
||||
_send_invite_email(user, token, base_url=request.host_url)
|
||||
|
||||
flash(
|
||||
f'Customer account created for {full_name}. '
|
||||
f'{user.role_label} account created for {full_name}. '
|
||||
f'An invitation email has been sent to {email} with a link to set their username and password.',
|
||||
'success'
|
||||
)
|
||||
@@ -259,12 +336,9 @@ def _send_invite_email(user, token, base_url=None):
|
||||
@supervisor_required
|
||||
def resend_invite(customer_id):
|
||||
"""Generate a fresh token and resend the set-password invitation email."""
|
||||
customer = db.session.get(User, customer_id)
|
||||
if customer is None:
|
||||
abort(404)
|
||||
if customer.role != 'customer':
|
||||
flash('This action is only for customer accounts.', 'warning')
|
||||
return redirect(url_for('customers.index'))
|
||||
customer, moved = _get_customer_or_redirect(customer_id)
|
||||
if moved:
|
||||
return moved
|
||||
|
||||
token = customer.generate_set_password_token(expires_hours=72)
|
||||
customer.password_set = False
|
||||
@@ -321,19 +395,16 @@ def set_password(token):
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def edit(customer_id):
|
||||
customer = db.session.get(User, customer_id)
|
||||
if customer is None:
|
||||
abort(404)
|
||||
if customer.role != 'customer':
|
||||
flash('This page is only for customer accounts.', 'warning')
|
||||
return redirect(url_for('customers.index'))
|
||||
customer, moved = _get_customer_or_redirect(customer_id)
|
||||
if moved:
|
||||
return moved
|
||||
|
||||
form = CustomerUserForm(user=customer, obj=customer)
|
||||
|
||||
if form.validate_on_submit():
|
||||
customer.username = form.username.data
|
||||
customer.full_name = form.full_name.data.strip() or None
|
||||
customer.email = form.email.data
|
||||
customer.email = form.email.data.strip().lower()
|
||||
if form.password.data:
|
||||
customer.set_password(form.password.data)
|
||||
db.session.commit()
|
||||
@@ -354,36 +425,78 @@ def edit(customer_id):
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def manage(customer_id):
|
||||
"""Single-customer detail page: profile + all assignments."""
|
||||
customer = db.session.get(User, customer_id)
|
||||
if customer is None:
|
||||
abort(404)
|
||||
if customer.role != 'customer':
|
||||
flash('This page is only for customer accounts.', 'warning')
|
||||
return redirect(url_for('customers.index'))
|
||||
"""Single-account detail page: profile + assignments + notification matrix.
|
||||
|
||||
assignments = CustomerAssignment.query.filter_by(user_id=customer_id).all()
|
||||
facility_ids = get_customer_scope(customer) or []
|
||||
facilities = (
|
||||
The assignment editor differs by role. A Customer Director gets the
|
||||
contract/facility assignment list (CustomerAssignment); a Customer
|
||||
Inspector gets the contract checkbox set (InspectorAssignment) that used to
|
||||
live on /auth/users/<id>/assign-contracts.
|
||||
"""
|
||||
customer, moved = _get_customer_or_redirect(customer_id)
|
||||
if moved:
|
||||
return moved
|
||||
|
||||
# Resolve the account's facility scope through the SAME helper the app uses
|
||||
# at request time, so this page can never disagree with what the account
|
||||
# actually sees.
|
||||
if customer.is_inspector:
|
||||
from app.utils.scope import get_inspector_scope
|
||||
facility_ids = get_inspector_scope(customer) or []
|
||||
else:
|
||||
facility_ids = get_customer_scope(customer) or []
|
||||
|
||||
facilities = (
|
||||
Facility.query
|
||||
.filter(Facility.id.in_(facility_ids), Facility.active == True)
|
||||
.order_by(Facility.name)
|
||||
.all()
|
||||
) if facility_ids else []
|
||||
|
||||
# Assignment form (populated here so it can be rendered inline)
|
||||
aform = CustomerAssignmentForm()
|
||||
projects = Project.query.filter_by(active=True).order_by(Project.name).all()
|
||||
|
||||
assignments = []
|
||||
assigned_pids = set()
|
||||
if customer.is_inspector:
|
||||
assigned_pids = {
|
||||
a.project_id
|
||||
for a in InspectorAssignment.query.filter_by(user_id=customer_id).all()
|
||||
}
|
||||
else:
|
||||
assignments = CustomerAssignment.query.filter_by(user_id=customer_id).all()
|
||||
|
||||
# Assignment form (populated here so it can be rendered inline)
|
||||
aform = CustomerAssignmentForm()
|
||||
aform.user_id.choices = [(customer.id, customer.username)]
|
||||
aform.facility_id.choices = [(0, '— All facilities in contract —')]
|
||||
|
||||
# ── Per-account notification matrix ───────────────────────────────────
|
||||
# For each event: what the global matrix would do for this account's role,
|
||||
# and whether the account overrides it. The template renders a tri-state
|
||||
# (Inherit / On / Off) so "inherit" stays visibly distinct from "explicitly
|
||||
# set to the same value the global happens to have today".
|
||||
from app.models.notification_matrix import MATRIX_EVENTS, is_enabled
|
||||
from app.models.user_notification_matrix import overrides_for_user
|
||||
|
||||
overrides = overrides_for_user(customer.id)
|
||||
matrix_rows = [
|
||||
{
|
||||
'event': event_key,
|
||||
'label': label,
|
||||
'global': is_enabled(event_key, customer.role),
|
||||
'override': overrides.get(event_key), # True / False / None
|
||||
}
|
||||
for event_key, label in MATRIX_EVENTS.items()
|
||||
]
|
||||
|
||||
return render_template(
|
||||
'customers/manage.html',
|
||||
customer = customer,
|
||||
assignments = assignments,
|
||||
facilities = facilities,
|
||||
aform = aform,
|
||||
projects = projects,
|
||||
customer = customer,
|
||||
assignments = assignments,
|
||||
assigned_pids = assigned_pids,
|
||||
facilities = facilities,
|
||||
aform = aform,
|
||||
projects = projects,
|
||||
matrix_rows = matrix_rows,
|
||||
)
|
||||
|
||||
|
||||
@@ -393,12 +506,16 @@ def manage(customer_id):
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def add_assignment(customer_id):
|
||||
customer = db.session.get(User, customer_id)
|
||||
if customer is None:
|
||||
abort(404)
|
||||
if customer.role != 'customer':
|
||||
flash('Assignments are only for customer accounts.', 'warning')
|
||||
return redirect(url_for('customers.index'))
|
||||
customer, moved = _get_customer_or_redirect(customer_id)
|
||||
if moved:
|
||||
return moved
|
||||
if customer.is_inspector:
|
||||
# A Customer Inspector is scoped by InspectorAssignment — writing a
|
||||
# CustomerAssignment row for them would grant nothing while looking
|
||||
# like it had.
|
||||
flash('Customer Inspectors are assigned whole contracts — use the '
|
||||
'contract list on this page.', 'warning')
|
||||
return redirect(url_for('customers.manage', customer_id=customer_id))
|
||||
|
||||
project_id = request.form.get('project_id', type=int)
|
||||
facility_id = request.form.get('facility_id', type=int) or None
|
||||
@@ -467,18 +584,233 @@ def remove_assignment(assignment_id):
|
||||
return redirect(url_for('customers.manage', customer_id=customer_id))
|
||||
|
||||
|
||||
# ── Contract assignments for a Customer Inspector ────────────────────────────
|
||||
|
||||
@bp.route('/<int:customer_id>/contracts', methods=['POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def assign_contracts(customer_id):
|
||||
"""Replace a Customer Inspector's whole InspectorAssignment set.
|
||||
|
||||
Same replace-the-entire-set semantics as auth.assign_inspector_contracts
|
||||
(rule 59) — the form posts the complete checked list, rows not in the POST
|
||||
body are deleted. Callers must always send the full desired set, never a
|
||||
diff.
|
||||
"""
|
||||
customer, moved = _get_customer_or_redirect(customer_id)
|
||||
if moved:
|
||||
return moved
|
||||
if not customer.is_inspector:
|
||||
flash('Contract assignment is for Customer Inspector accounts. '
|
||||
'Customer Directors are assigned per contract or facility below.',
|
||||
'warning')
|
||||
return redirect(url_for('customers.manage', customer_id=customer_id))
|
||||
|
||||
from app.utils.time_utils import now_eastern
|
||||
|
||||
selected_ids = set(request.form.getlist('project_ids', type=int))
|
||||
existing = InspectorAssignment.query.filter_by(user_id=customer_id).all()
|
||||
existing_pids = {a.project_id for a in existing}
|
||||
|
||||
for a in existing:
|
||||
if a.project_id not in selected_ids:
|
||||
db.session.delete(a)
|
||||
for pid in selected_ids:
|
||||
if pid not in existing_pids:
|
||||
db.session.add(InspectorAssignment(
|
||||
user_id = customer_id,
|
||||
project_id = pid,
|
||||
created_at = now_eastern(),
|
||||
))
|
||||
|
||||
db.session.commit()
|
||||
|
||||
logger.info('CUSTOMERS | assign_contracts | admin=%s customer=%s projects=%s',
|
||||
current_user.username, customer.username, sorted(selected_ids))
|
||||
log_action(ACTION_UPDATE, 'User', customer.id, customer.username,
|
||||
f'inspector_assignments={sorted(selected_ids)}')
|
||||
|
||||
if not selected_ids:
|
||||
# Rule 57 is strict, and silently is exactly how it bites.
|
||||
flash(f'{customer.display_name} now has no contracts assigned and will '
|
||||
f'see nothing until at least one is granted.', 'warning')
|
||||
else:
|
||||
flash(f'Contract assignments updated for {customer.display_name}.', 'success')
|
||||
return redirect(url_for('customers.manage', customer_id=customer_id))
|
||||
|
||||
|
||||
# ── Per-account notification matrix ──────────────────────────────────────────
|
||||
|
||||
@bp.route('/<int:customer_id>/notifications', methods=['POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def save_notifications(customer_id):
|
||||
"""Save this account's per-event notification overrides.
|
||||
|
||||
Each event posts one of 'inherit' / 'on' / 'off'. 'inherit' DELETES the row
|
||||
rather than storing the global column's current value — so an account that
|
||||
never expressed an opinion keeps following the global matrix when it
|
||||
changes later.
|
||||
"""
|
||||
customer, moved = _get_customer_or_redirect(customer_id)
|
||||
if moved:
|
||||
return moved
|
||||
|
||||
from app.models.notification_matrix import MATRIX_EVENTS
|
||||
from app.models.user_notification_matrix import set_overrides
|
||||
|
||||
tri = {'inherit': None, 'on': True, 'off': False}
|
||||
values = {}
|
||||
for event_key in MATRIX_EVENTS:
|
||||
# Only events this form actually posted; an unknown or missing value
|
||||
# is treated as inherit rather than guessed at.
|
||||
choice = request.form.get(f'event_{event_key}')
|
||||
if choice is not None:
|
||||
values[event_key] = tri.get(choice)
|
||||
|
||||
changed = set_overrides(customer.id, values)
|
||||
db.session.commit()
|
||||
|
||||
logger.info('CUSTOMERS | notif_matrix | admin=%s customer=%s changed=%s',
|
||||
current_user.username, customer.username, changed)
|
||||
log_action(ACTION_UPDATE, 'User', customer.id, customer.username,
|
||||
f'notification overrides updated ({changed} change(s))')
|
||||
|
||||
flash(f'Notification settings saved for {customer.display_name}.'
|
||||
if changed else 'No notification changes to save.',
|
||||
'success' if changed else 'info')
|
||||
return redirect(url_for('customers.manage', customer_id=customer.id))
|
||||
|
||||
|
||||
# ── Switch between the two customer roles ────────────────────────────────────
|
||||
|
||||
@bp.route('/<int:customer_id>/switch-role', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def switch_role(customer_id):
|
||||
"""Flip an account between Customer Director and Customer Inspector.
|
||||
|
||||
The two roles read DIFFERENT scoping tables, so flipping the column alone
|
||||
would leave the account correctly labelled and seeing nothing (rule 57 is
|
||||
strict for inspectors, and a director with no CustomerAssignment rows is
|
||||
equally blind). The contracts are therefore mirrored across: every contract
|
||||
the account could reach before, it can reach after.
|
||||
|
||||
Facility-level narrowing does NOT survive a switch to inspector — there is
|
||||
no per-facility row for inspectors, so a director scoped to one building in
|
||||
a contract becomes an inspector on that whole contract. The confirm dialog
|
||||
says so; the flash repeats it. Switching BACK is lossless though: the
|
||||
original facility-level rows were never deleted, and the reverse mirror
|
||||
skips contracts the account can already reach, so it does not pile a
|
||||
contract-wide grant on top of them.
|
||||
|
||||
API access changes in both directions ('external_inspector' has mobile API
|
||||
access, 'customer' is 403 everywhere), so the account's refresh tokens and
|
||||
device registrations are revoked — an issued JWT would otherwise keep
|
||||
working until it expired, and a signed-in iPad would keep syncing.
|
||||
"""
|
||||
customer, moved = _get_customer_or_redirect(customer_id)
|
||||
if moved:
|
||||
return moved
|
||||
|
||||
from app.utils.time_utils import now_eastern
|
||||
from app.models.api_token import RefreshToken, DeviceToken
|
||||
|
||||
old_role = customer.role
|
||||
new_role = 'external_inspector' if old_role == 'customer' else 'customer'
|
||||
|
||||
widened = False
|
||||
|
||||
if new_role == 'external_inspector':
|
||||
# Director → Inspector: CustomerAssignment (contract or facility) →
|
||||
# InspectorAssignment (contract only).
|
||||
existing_pids = {
|
||||
a.project_id
|
||||
for a in InspectorAssignment.query.filter_by(user_id=customer.id).all()
|
||||
}
|
||||
for a in CustomerAssignment.query.filter_by(user_id=customer.id).all():
|
||||
if a.facility_id:
|
||||
widened = True
|
||||
if a.project_id not in existing_pids:
|
||||
db.session.add(InspectorAssignment(
|
||||
user_id = customer.id,
|
||||
project_id = a.project_id,
|
||||
created_at = now_eastern(),
|
||||
))
|
||||
existing_pids.add(a.project_id)
|
||||
else:
|
||||
# Inspector → Director: contract-level CustomerAssignment rows
|
||||
# (facility_id NULL = all facilities in the contract).
|
||||
#
|
||||
# `existing_pids` counts ANY row for the contract, facility-level ones
|
||||
# included — NOT just the contract-wide ones. That is what makes a
|
||||
# round trip lossless: an account narrowed to one facility, switched to
|
||||
# inspector (which can only hold whole contracts) and switched back
|
||||
# would otherwise gain a contract-wide row on top of its original
|
||||
# facility row and come back with the whole contract. Skipping
|
||||
# contracts the account can already reach as a director leaves the
|
||||
# original narrowing intact, while contracts granted during the
|
||||
# inspector spell still carry over.
|
||||
existing_pids = {
|
||||
a.project_id
|
||||
for a in CustomerAssignment.query.filter_by(user_id=customer.id).all()
|
||||
}
|
||||
for a in InspectorAssignment.query.filter_by(user_id=customer.id).all():
|
||||
if a.project_id not in existing_pids:
|
||||
db.session.add(CustomerAssignment(
|
||||
user_id = customer.id,
|
||||
project_id = a.project_id,
|
||||
facility_id = None,
|
||||
))
|
||||
existing_pids.add(a.project_id)
|
||||
|
||||
# The stale rows for the role being left are kept on purpose: switching
|
||||
# back restores the account's original scope, including any facility-level
|
||||
# narrowing that the inspector side cannot express. They are inert while
|
||||
# the other role is active — each scope helper reads only its own table.
|
||||
|
||||
customer.role = new_role
|
||||
|
||||
revoked = (
|
||||
RefreshToken.query
|
||||
.filter_by(user_id=customer.id, revoked=False)
|
||||
.update({'revoked': True}, synchronize_session=False)
|
||||
)
|
||||
devices = (
|
||||
DeviceToken.query
|
||||
.filter_by(user_id=customer.id)
|
||||
.delete(synchronize_session=False)
|
||||
)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
logger.info('CUSTOMERS | switch_role | admin=%s customer=%s %s -> %s '
|
||||
'tokens_revoked=%s devices_cleared=%s',
|
||||
current_user.username, customer.username, old_role, new_role,
|
||||
revoked, devices)
|
||||
log_action(ACTION_UPDATE, 'User', customer.id, customer.username,
|
||||
f'role switched {old_role} -> {new_role}; '
|
||||
f'refresh_tokens_revoked={revoked}; devices_cleared={devices}')
|
||||
|
||||
msg = (f'{customer.display_name} is now a {customer.role_label}. '
|
||||
f'Their contracts were carried across; any signed-in device must log in again.')
|
||||
if widened:
|
||||
msg += (' Note: facility-level limits do not exist for inspectors, so '
|
||||
'this account now covers every facility in those contracts — '
|
||||
'review the contract list below.')
|
||||
flash(msg, 'warning' if widened else 'success')
|
||||
return redirect(url_for('customers.manage', customer_id=customer.id))
|
||||
|
||||
|
||||
# ── Toggle active ─────────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/<int:customer_id>/toggle-active', methods=['POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def toggle_active(customer_id):
|
||||
customer = db.session.get(User, customer_id)
|
||||
if customer is None:
|
||||
abort(404)
|
||||
if customer.role != 'customer':
|
||||
flash('This action is only for customer accounts.', 'warning')
|
||||
return redirect(url_for('customers.index'))
|
||||
customer, moved = _get_customer_or_redirect(customer_id)
|
||||
if moved:
|
||||
return moved
|
||||
|
||||
customer.active = not customer.active
|
||||
db.session.commit()
|
||||
|
||||
+79
-5
@@ -17,6 +17,16 @@ bp = Blueprint('dashboard', __name__)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Columns the dashboard actually reads off an issue row. The cards below need
|
||||
# counts and buckets, never a hydrated Issue — loading the full entity pulls the
|
||||
# description TEXT and three JSON photo columns for every open issue in scope,
|
||||
# on every dashboard load, and registers each one in the identity map.
|
||||
# A Row exposes the same attribute names, so the severity/handler tallies and
|
||||
# sla_status() work against these unchanged.
|
||||
_ISSUE_CARD_COLS = (Issue.id, Issue.severity, Issue.status,
|
||||
Issue.reported_at, Issue.handler_type)
|
||||
|
||||
|
||||
@bp.route('/')
|
||||
@bp.route('/dashboard')
|
||||
@login_required
|
||||
@@ -24,8 +34,12 @@ def index():
|
||||
now = now_eastern()
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
today_end = today_start + timedelta(days=1)
|
||||
# MT-16 — Monday 00:00 of the current week, for the modern dashboard's
|
||||
# "submitted this week" tile. Derived from today_start so it inherits
|
||||
# now_eastern() rather than mixing in a second clock.
|
||||
week_start = today_start - timedelta(days=today_start.weekday())
|
||||
|
||||
is_inspector = current_user.role == 'inspector'
|
||||
is_inspector = current_user.is_inspector
|
||||
is_privileged = current_user.role in ['admin', 'director']
|
||||
is_customer = current_user.role == 'customer'
|
||||
is_project_manager = current_user.role == 'project_manager'
|
||||
@@ -62,6 +76,14 @@ def index():
|
||||
Inspection.inspection_date < today_end,
|
||||
).count()
|
||||
|
||||
# MT-16 — fully completed & submitted so far this week (Monday → now).
|
||||
# Reuses base_q, so it inherits the same role scoping as every other tile.
|
||||
submitted_this_week = base_q.filter(
|
||||
Inspection.status == 'completed',
|
||||
Inspection.inspection_date >= week_start,
|
||||
Inspection.inspection_date < today_end,
|
||||
).count()
|
||||
|
||||
# ── Open issues (inspector: all issues in contracted facilities) ───────
|
||||
open_issues_q = Issue.query.filter(Issue.status.in_(['open', 'in_progress']))
|
||||
if is_inspector:
|
||||
@@ -88,7 +110,7 @@ def index():
|
||||
))
|
||||
|
||||
# Single query — derive count from the list to avoid hitting the DB twice
|
||||
open_issues_all = open_issues_q.all()
|
||||
open_issues_all = open_issues_q.with_entities(*_ISSUE_CARD_COLS).all()
|
||||
open_issues = len(open_issues_all)
|
||||
severity_breakdown = {
|
||||
'critical': sum(1 for i in open_issues_all if i.severity == 'critical'),
|
||||
@@ -163,9 +185,13 @@ def index():
|
||||
if not inspector_facility_ids:
|
||||
followup_q = followup_q.filter(False)
|
||||
else:
|
||||
# OWNERSHIP, not authorship — see Inspection.follow_up_owned_by().
|
||||
# An assigned follow-up lives on an inspection somebody else
|
||||
# performed, so testing inspector_id made the card read 0 for the
|
||||
# very person who had been asked to do the work.
|
||||
followup_q = followup_q.filter(
|
||||
Inspection.facility_id.in_(inspector_facility_ids),
|
||||
Inspection.inspector_id == current_user.id,
|
||||
Inspection.follow_up_owned_by(current_user.id),
|
||||
)
|
||||
elif is_customer:
|
||||
if customer_facility_ids:
|
||||
@@ -210,7 +236,7 @@ def index():
|
||||
elif is_customer and not customer_facility_ids:
|
||||
all_open_issues = []
|
||||
else:
|
||||
all_open_issues = sla_q.all()
|
||||
all_open_issues = sla_q.with_entities(*_ISSUE_CARD_COLS).all()
|
||||
sla_breached = sum(1 for i in all_open_issues if sla_status(i) == 'breached')
|
||||
sla_at_risk = sum(1 for i in all_open_issues if sla_status(i) == 'at_risk')
|
||||
|
||||
@@ -282,6 +308,26 @@ def index():
|
||||
stale_q = stale_q.filter(False)
|
||||
stale_in_progress = stale_q.count()
|
||||
|
||||
# ── In-progress inspections, all ages (MT-16) ─────────────────────────────
|
||||
# stale_in_progress above counts only those older than 24h. The modern
|
||||
# dashboard shows the full in-progress count as its own tile, so this is a
|
||||
# separate query with the SAME role scoping rather than a reuse of stale_q.
|
||||
inprog_q = Inspection.query.filter(Inspection.status == 'in_progress')
|
||||
if is_inspector:
|
||||
if not inspector_facility_ids:
|
||||
inprog_q = inprog_q.filter(False)
|
||||
else:
|
||||
inprog_q = inprog_q.filter(
|
||||
Inspection.facility_id.in_(inspector_facility_ids),
|
||||
Inspection.inspector_id == current_user.id,
|
||||
)
|
||||
elif is_customer:
|
||||
if customer_facility_ids:
|
||||
inprog_q = inprog_q.filter(Inspection.facility_id.in_(customer_facility_ids))
|
||||
else:
|
||||
inprog_q = inprog_q.filter(False)
|
||||
in_progress_total = inprog_q.count()
|
||||
|
||||
# ── Unassigned open issues ────────────────────────────────────────────────
|
||||
from app.models.facility import Area as _AreaU
|
||||
unassigned_q = Issue.query.outerjoin(_AreaU, Issue.area_id == _AreaU.id).filter(
|
||||
@@ -305,7 +351,7 @@ def index():
|
||||
if is_privileged or is_project_manager or is_auditor:
|
||||
active_inspectors = (
|
||||
User.query
|
||||
.filter_by(role='inspector', active=True)
|
||||
.filter(User.role.in_(User.INSPECTOR_ROLES), User.active == True)
|
||||
.order_by(User.full_name, User.username)
|
||||
.all()
|
||||
)
|
||||
@@ -349,6 +395,11 @@ def index():
|
||||
# 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()
|
||||
@@ -365,11 +416,34 @@ def index():
|
||||
s for s in _all_sched
|
||||
if s.next_run_at and _today <= s.next_run_at.date() <= _today + timedelta(days=7)
|
||||
][:8]
|
||||
sched_total = len(_all_sched) # active plan-mode schedules
|
||||
# {schedule_id: inspection_id} for plans with an inspection already in
|
||||
# progress. MT's FK is Inspection.inspection_schedule_id (ST calls it
|
||||
# scheduled_inspection_id). Ordered ascending so that when a plan somehow
|
||||
# has more than one open inspection, the dict keeps the LOWEST id — the
|
||||
# original, not a later duplicate.
|
||||
_sched_ids = [s.id for s in sched_upcoming if s.id]
|
||||
if _sched_ids:
|
||||
_open_rows = (
|
||||
Inspection.query
|
||||
.filter(Inspection.inspection_schedule_id.in_(_sched_ids),
|
||||
Inspection.status == 'in_progress')
|
||||
.order_by(Inspection.id.desc())
|
||||
.all()
|
||||
)
|
||||
sched_open_inspections = {
|
||||
r.inspection_schedule_id: r.id for r in _open_rows
|
||||
}
|
||||
|
||||
return render_template(
|
||||
'dashboard.html',
|
||||
sched_upcoming = sched_upcoming,
|
||||
sched_overdue_count = sched_overdue_count,
|
||||
sched_total = sched_total,
|
||||
sched_open_inspections = sched_open_inspections,
|
||||
in_progress_total = in_progress_total,
|
||||
submitted_this_week = submitted_this_week,
|
||||
week_start_str = week_start.strftime('%Y-%m-%d'),
|
||||
today_inspections = today_inspections,
|
||||
completed_today = completed_today,
|
||||
open_issues = open_issues,
|
||||
|
||||
@@ -23,7 +23,7 @@ def list_facilities():
|
||||
facilities = Facility.query.filter(
|
||||
Facility.id.in_(cids), Facility.active == True
|
||||
).order_by(Facility.name).all()
|
||||
elif current_user.role == 'inspector':
|
||||
elif current_user.is_inspector:
|
||||
fids = get_inspector_scope(current_user) or []
|
||||
facilities = Facility.query.filter(
|
||||
Facility.id.in_(fids), Facility.active == True
|
||||
@@ -262,7 +262,7 @@ def _facility_for_qr_or_403(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.role == 'inspector':
|
||||
if current_user.is_inspector:
|
||||
abort(403)
|
||||
if current_user.role == 'customer':
|
||||
cids = get_customer_scope(current_user) or []
|
||||
@@ -329,7 +329,7 @@ def qr_sheet():
|
||||
"""Bulk print sheet — one labeled QR card per active facility."""
|
||||
from app.utils.qr import qr_svg
|
||||
|
||||
if current_user.role == 'inspector':
|
||||
if current_user.is_inspector:
|
||||
abort(403)
|
||||
|
||||
if current_user.role == 'customer':
|
||||
@@ -395,7 +395,7 @@ def _area_for_qr_or_403(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.role == 'inspector':
|
||||
if current_user.is_inspector:
|
||||
abort(403)
|
||||
if current_user.role == 'customer':
|
||||
cids = get_customer_scope(current_user) or []
|
||||
@@ -483,7 +483,7 @@ def qr_print_all():
|
||||
Inspectors have no QR management (403); customers are scoped to their
|
||||
assigned facilities; managers see all active facilities.
|
||||
"""
|
||||
if current_user.role == 'inspector':
|
||||
if current_user.is_inspector:
|
||||
abort(403)
|
||||
|
||||
contract_id = request.args.get('contract_id', type=int)
|
||||
@@ -552,7 +552,7 @@ def qr_export_pdf():
|
||||
Scope is enforced per-id via the same helpers as the QR pages, so a
|
||||
customer can never export a code outside their assigned facilities.
|
||||
"""
|
||||
if current_user.role == 'inspector':
|
||||
if current_user.is_inspector:
|
||||
abort(403)
|
||||
|
||||
facility_ids = request.form.getlist('facility_ids', type=int)
|
||||
|
||||
@@ -125,7 +125,7 @@ def _can_view_full(facility):
|
||||
return False
|
||||
if current_user.role in ('admin', 'director', 'project_manager'):
|
||||
return True
|
||||
if current_user.role == 'inspector':
|
||||
if current_user.is_inspector:
|
||||
return facility.id in (get_inspector_scope(current_user) or [])
|
||||
if current_user.role == 'customer':
|
||||
return facility.id in (get_customer_scope(current_user) or [])
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+673
-65
@@ -15,12 +15,13 @@ from app.models.project import Project
|
||||
from app.models.issue import Issue
|
||||
from app.models.user import User
|
||||
from app.utils.forms import StartInspectionForm, IssueForm
|
||||
from app.utils.decorators import supervisor_required
|
||||
from app.utils.decorators import supervisor_required, return_url
|
||||
from app.utils.pdf_export import generate_inspection_pdf, generate_inspections_list_pdf
|
||||
from app.utils.notifications import notify, notify_customers_for_facility, notify_by_matrix
|
||||
from app.models.notification import (
|
||||
EVENT_INSPECTION_DONE, EVENT_ISSUE_ASSIGNED,
|
||||
EVENT_CUSTOMER_INSPECTION_DONE, EVENT_CUSTOMER_ISSUE_UPDATED,
|
||||
EVENT_FOLLOWUP_REQUESTED,
|
||||
)
|
||||
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE, ACTION_EXPORT
|
||||
from app.tenancy.gates import quota_soft_check
|
||||
@@ -215,7 +216,7 @@ def index():
|
||||
joinedload(Inspection.area),
|
||||
).order_by(Inspection.inspection_date.desc())
|
||||
|
||||
if current_user.role == 'inspector':
|
||||
if current_user.is_inspector:
|
||||
fids = get_inspector_scope(current_user)
|
||||
if not fids:
|
||||
q = q.filter(False)
|
||||
@@ -253,7 +254,8 @@ def index():
|
||||
q = q.filter(Inspection.status == status_filter)
|
||||
if contract_filter.isdigit():
|
||||
_contract_fids = [
|
||||
f.id for f in Facility.query.filter_by(project_id=int(contract_filter)).all()
|
||||
fid for (fid,) in db.session.query(Facility.id)
|
||||
.filter(Facility.project_id == int(contract_filter)).all()
|
||||
]
|
||||
q = q.filter(Inspection.facility_id.in_(_contract_fids)) if _contract_fids else q.filter(False)
|
||||
if facility_filter.isdigit():
|
||||
@@ -284,12 +286,12 @@ def index():
|
||||
q = q.filter(Inspection.overall_score <= float(score_max_filter))
|
||||
except ValueError:
|
||||
score_max_filter = ''
|
||||
if inspector_filter.isdigit() and current_user.role != 'inspector':
|
||||
if inspector_filter.isdigit() and not current_user.is_inspector:
|
||||
q = q.filter(Inspection.inspector_id == int(inspector_filter))
|
||||
|
||||
inspections = q.paginate(page=page, per_page=20, error_out=False)
|
||||
|
||||
if current_user.role == 'inspector':
|
||||
if current_user.is_inspector:
|
||||
fids = get_inspector_scope(current_user) or []
|
||||
_fq = Facility.query.filter(Facility.id.in_(fids), Facility.active == True)
|
||||
elif current_user.role == 'customer':
|
||||
@@ -316,9 +318,9 @@ def index():
|
||||
projects = Project.query.filter_by(active=True).order_by(Project.name).all()
|
||||
|
||||
# Inspector dropdown — shown to all roles except inspector (they only see their own)
|
||||
if current_user.role != 'inspector':
|
||||
if not current_user.is_inspector:
|
||||
inspectors = (User.query
|
||||
.filter(User.role == 'inspector', User.active == True)
|
||||
.filter(User.role.in_(User.INSPECTOR_ROLES), User.active == True)
|
||||
.order_by(User.full_name, User.username).all())
|
||||
else:
|
||||
inspectors = []
|
||||
@@ -349,11 +351,10 @@ def index():
|
||||
def start():
|
||||
form = StartInspectionForm()
|
||||
|
||||
templates = InspectionTemplate.query.filter_by(active=True).order_by(InspectionTemplate.name).all()
|
||||
projects = Project.query.filter_by(active=True).order_by(Project.name).all()
|
||||
|
||||
# Scope projects to inspector's assigned contracts
|
||||
if current_user.role == 'inspector':
|
||||
if current_user.is_inspector:
|
||||
from app.models.inspector_assignment import InspectorAssignment
|
||||
assigned_pids = {
|
||||
a.project_id for a in
|
||||
@@ -361,7 +362,6 @@ def start():
|
||||
}
|
||||
projects = [p for p in projects if p.id in assigned_pids]
|
||||
|
||||
form.template_id.choices = [(t.id, t.name) for t in templates]
|
||||
form.project_id.choices = [(p.id, p.name) for p in projects]
|
||||
|
||||
# Seed facility choices: use submitted project_id, session value, or first project
|
||||
@@ -375,6 +375,13 @@ def start():
|
||||
else:
|
||||
selected_project_id = projects[0].id if projects else None
|
||||
|
||||
# phase52 — forms are offered per CONTRACT: shared forms plus any attached
|
||||
# to the selected contract. This is also the POST validation (SelectField
|
||||
# validates against its choices), so a crafted template_id for another
|
||||
# customer's form is rejected here, not merely hidden in the UI.
|
||||
templates = InspectionTemplate.available_query(selected_project_id).all()
|
||||
form.template_id.choices = [(t.id, t.name) for t in templates]
|
||||
|
||||
if selected_project_id:
|
||||
facilities = Facility.query.filter_by(active=True, project_id=selected_project_id).order_by(Facility.name).all()
|
||||
else:
|
||||
@@ -407,9 +414,22 @@ def start():
|
||||
if template is None:
|
||||
abort(404)
|
||||
|
||||
# Belt-and-braces: the choices above already reject a form that is not
|
||||
# available on this contract, but that guard lives in how the list was
|
||||
# built. Re-assert it against the FACILITY actually chosen, so a future
|
||||
# change to the choice-building cannot quietly open a cross-customer
|
||||
# hole here.
|
||||
_fac = db.session.get(Facility, form.facility_id.data)
|
||||
if not template.available_for_project(_fac.project_id if _fac else None):
|
||||
logger_msg = ('INSPECTION START BLOCKED | template=%s not available for '
|
||||
'facility=%s | user=%s')
|
||||
current_app.logger.warning(logger_msg, template.id,
|
||||
form.facility_id.data, current_user.username)
|
||||
abort(403)
|
||||
|
||||
# Inspector facility scope check — prevent crafted POST from selecting
|
||||
# a facility outside their assigned contracts.
|
||||
if current_user.role == 'inspector':
|
||||
if current_user.is_inspector:
|
||||
fids = get_inspector_scope(current_user)
|
||||
if not fids or form.facility_id.data not in fids:
|
||||
abort(403)
|
||||
@@ -457,13 +477,65 @@ def areas_for_facility(facility_id):
|
||||
@bp.route('/facilities_for_project/<int:project_id>')
|
||||
@login_required
|
||||
def facilities_for_project(project_id):
|
||||
facilities = (Facility.query
|
||||
.filter_by(active=True, project_id=project_id)
|
||||
.order_by(Facility.name)
|
||||
.all())
|
||||
"""Active facilities on one contract, for the Contract -> Facility cascade.
|
||||
|
||||
**Scoped to the caller.** Every page that renders a facility dropdown
|
||||
already limits it to what the viewer may see; this endpoint refills that
|
||||
same dropdown, so without the same scope it would happily list another
|
||||
customer's building names to anyone who guessed a contract id — the leak
|
||||
rule 96 describes on the mobile API. Empty list rather than 403, so it does
|
||||
not confirm whether the contract exists either.
|
||||
"""
|
||||
q = Facility.query.filter_by(active=True, project_id=project_id)
|
||||
if current_user.is_inspector:
|
||||
fids = get_inspector_scope(current_user) or []
|
||||
q = q.filter(Facility.id.in_(fids)) if fids else q.filter(False)
|
||||
elif current_user.role == 'customer':
|
||||
fids = get_customer_scope(current_user) or []
|
||||
q = q.filter(Facility.id.in_(fids)) if fids else q.filter(False)
|
||||
facilities = q.order_by(Facility.name).all()
|
||||
return jsonify([{'id': f.id, 'name': f.name} for f in facilities])
|
||||
|
||||
|
||||
# ── AJAX: forms available on a given contract (phase52) ──────────────────────
|
||||
|
||||
@bp.route('/templates_for_project/<int:project_id>')
|
||||
@login_required
|
||||
def templates_for_project(project_id):
|
||||
"""Forms usable on this contract — shared ones plus any attached to it.
|
||||
|
||||
Powers the Contract -> Form cascade on the start-inspection page, the same
|
||||
way facilities_for_project powers Contract -> Facility.
|
||||
|
||||
**Scoped to the caller's own contracts.** The POST validation in start() is
|
||||
what stops a form being *used* across contracts, but this endpoint would
|
||||
otherwise happily list one customer's bespoke form NAMES to another
|
||||
customer's inspector who simply asked for a contract id — the same leak
|
||||
that rule 96 covers on the mobile API. Empty list rather than 403, so it
|
||||
does not confirm whether the contract exists either.
|
||||
"""
|
||||
if current_user.is_inspector:
|
||||
fids = get_inspector_scope(current_user) or []
|
||||
allowed = {
|
||||
f.project_id
|
||||
for f in Facility.query.filter(Facility.id.in_(fids)).all()
|
||||
} if fids else set()
|
||||
if project_id not in allowed:
|
||||
logger_msg = ('TEMPLATES_FOR_PROJECT | out-of-scope request | '
|
||||
'user=%s | project_id=%s')
|
||||
current_app.logger.warning(logger_msg, current_user.username, project_id)
|
||||
return jsonify([])
|
||||
elif current_user.role == 'customer':
|
||||
# Customers never start inspections; nothing here is theirs to see.
|
||||
return jsonify([])
|
||||
|
||||
templates = InspectionTemplate.available_query(project_id).all()
|
||||
return jsonify([
|
||||
{'id': t.id, 'name': t.name, 'shared': t.is_shared}
|
||||
for t in templates
|
||||
])
|
||||
|
||||
|
||||
# ── Execute ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/<int:inspection_id>/execute', methods=['GET', 'POST'])
|
||||
@@ -473,12 +545,12 @@ def execute(inspection_id):
|
||||
if inspection is None:
|
||||
abort(404)
|
||||
|
||||
if current_user.role == 'inspector' and inspection.inspector_id != current_user.id:
|
||||
if current_user.is_inspector and inspection.inspector_id != current_user.id:
|
||||
flash('Access denied.', 'danger')
|
||||
return redirect(url_for('inspections.index'))
|
||||
|
||||
if inspection.status == 'completed':
|
||||
return redirect(url_for('inspections.view', inspection_id=inspection_id))
|
||||
return redirect(_view_url(inspection_id))
|
||||
|
||||
template = inspection.template
|
||||
form_fields = template.get_form_schema()
|
||||
@@ -600,7 +672,7 @@ def execute(inspection_id):
|
||||
f'status=completed; score={score}')
|
||||
|
||||
flash('Inspection submitted successfully!', 'success')
|
||||
return redirect(url_for('inspections.view', inspection_id=inspection_id))
|
||||
return redirect(_view_url(inspection_id))
|
||||
|
||||
else:
|
||||
_save_draft(inspection, responses)
|
||||
@@ -608,10 +680,10 @@ def execute(inspection_id):
|
||||
flash('Draft saved. You can continue filling in the form later.', 'success')
|
||||
return redirect(url_for('inspections.execute', inspection_id=inspection_id))
|
||||
|
||||
staff_for_flag_issue = User.query.filter(
|
||||
User.role.in_(['director', 'inspector', 'project_manager', 'auditor']),
|
||||
User.active == True,
|
||||
).order_by(User.full_name, User.username).all()
|
||||
# Scoped to this inspection's contract — see _assignable_staff_for().
|
||||
# Must match flag_issue()'s choices exactly or the offcanvas silently
|
||||
# fails to save (rule 60).
|
||||
staff_for_flag_issue = _assignable_staff_for(inspection, current_user)
|
||||
|
||||
return render_template('inspections/execute.html',
|
||||
inspection=inspection,
|
||||
@@ -649,7 +721,7 @@ def save_draft_ajax(inspection_id):
|
||||
if inspection is None:
|
||||
abort(404)
|
||||
|
||||
if current_user.role == 'inspector' and inspection.inspector_id != current_user.id:
|
||||
if current_user.is_inspector and inspection.inspector_id != current_user.id:
|
||||
return jsonify({'ok': False, 'error': 'Access denied'}), 403
|
||||
|
||||
if inspection.status == 'completed':
|
||||
@@ -688,7 +760,7 @@ def upload_photo_ajax(inspection_id):
|
||||
if inspection is None:
|
||||
return jsonify({'ok': False, 'error': 'Not found'}), 404
|
||||
|
||||
if current_user.role == 'inspector' and inspection.inspector_id != current_user.id:
|
||||
if current_user.is_inspector and inspection.inspector_id != current_user.id:
|
||||
return jsonify({'ok': False, 'error': 'Access denied'}), 403
|
||||
|
||||
if inspection.status == 'completed':
|
||||
@@ -715,7 +787,11 @@ def view(inspection_id):
|
||||
if inspection is None:
|
||||
abort(404)
|
||||
|
||||
if current_user.role == 'inspector' and inspection.inspector_id != current_user.id:
|
||||
# Read access matches the LIST (rule 58) — an inspector may open anything
|
||||
# at their contracted facilities, not only what they performed. Editing
|
||||
# someone else's inspection is still refused (execute / save-draft /
|
||||
# upload-photo / flag-issue keep the authorship check).
|
||||
if current_user.is_inspector and not _inspector_may_read(inspection, current_user):
|
||||
flash('Access denied.', 'danger')
|
||||
return redirect(url_for('inspections.index'))
|
||||
if current_user.role == 'customer':
|
||||
@@ -912,7 +988,23 @@ def view(inspection_id):
|
||||
'unchanged': sum(1 for r in rows if r['delta'] == 0),
|
||||
}
|
||||
|
||||
followup_assignees = _followup_assignees_for(inspection, current_user)
|
||||
# An inspector viewing SOMEBODY ELSE's inspection gets a read-only page.
|
||||
# Without this the buttons would all render and then fail on click — the
|
||||
# same list-says-yes / page-says-no mismatch this change removes.
|
||||
is_own_inspection = (not current_user.is_inspector
|
||||
or inspection.inspector_id == current_user.id)
|
||||
# Whoever is expected to carry out the follow-up (phase56) may start the
|
||||
# re-inspection even though the original inspection is not theirs.
|
||||
owns_follow_up = bool(
|
||||
inspection.follow_up_required
|
||||
and inspection.follow_up_owner
|
||||
and inspection.follow_up_owner.id == current_user.id
|
||||
)
|
||||
return render_template('inspections/view.html',
|
||||
followup_assignees=followup_assignees,
|
||||
is_own_inspection=is_own_inspection,
|
||||
owns_follow_up=owns_follow_up,
|
||||
inspection=inspection,
|
||||
form_fields=form_fields,
|
||||
form_data=form_data,
|
||||
@@ -922,6 +1014,95 @@ def view(inspection_id):
|
||||
|
||||
# ── Flag issue during inspection ──────────────────────────────────────────────
|
||||
|
||||
#: Internal roles that are NOT contract-scoped — they work across the whole
|
||||
#: organisation, so they are offered regardless of which contract the
|
||||
#: inspection belongs to. Only ever shown to our own people.
|
||||
_ORG_WIDE_ASSIGNEE_ROLES = ('director', 'project_manager', 'auditor')
|
||||
|
||||
|
||||
def _assignable_staff_for(inspection, actor):
|
||||
"""Users `actor` may assign an issue to, for THIS inspection.
|
||||
|
||||
The candidate list is scoped by the inspection's CONTRACT, not taken
|
||||
org-wide. Two distinct problems this fixes:
|
||||
|
||||
1. **Cross-customer leak.** A Customer Inspector could assign an issue to
|
||||
anyone in the system — including another client's Customer Inspector.
|
||||
The assignee is notified by email and in-app with the facility name and
|
||||
issue description, so this handed one customer's data to another. It is
|
||||
a leak whoever flags the issue, so the contract scope is applied to the
|
||||
two inspector roles for EVERY actor, not just customer ones.
|
||||
|
||||
2. An external account should not see our internal org chart at all. For a
|
||||
customer-side actor the list is their co-workers on shared contracts —
|
||||
inspectors assigned to this inspection's contract — and nothing else.
|
||||
|
||||
Rules applied:
|
||||
* inspector / external_inspector -> only those holding an
|
||||
InspectorAssignment on this inspection's contract (the same rows
|
||||
get_inspector_scope() reads, so the list can never disagree with what
|
||||
the assignee can actually open).
|
||||
* director / project_manager / auditor -> org-wide, but offered ONLY to
|
||||
our own staff. These roles carry no InspectorAssignment rows, so
|
||||
contract-scoping them would remove them entirely and break the normal
|
||||
"escalate to the contract manager" flow.
|
||||
* inactive accounts are never offered.
|
||||
|
||||
A facility with no contract yields no contract-scoped candidates; that is
|
||||
fail-closed and correct — an external actor then gets an empty list and can
|
||||
only leave the issue unassigned.
|
||||
|
||||
Used by BOTH the offcanvas dropdown in execute() and the choices that
|
||||
validate the POST in flag_issue(). They MUST stay identical: a value the UI
|
||||
offers but the choices reject fails `validate_on_submit()`, and the
|
||||
offcanvas JS treats the resulting 200 as success — the issue is silently
|
||||
never saved (rule 60's failure mode, which is exactly what the two
|
||||
hand-maintained lists were already doing to project_manager and auditor).
|
||||
"""
|
||||
from app.models.inspector_assignment import InspectorAssignment
|
||||
|
||||
# `is_customer_account` is used here to WITHHOLD internal staff from an
|
||||
# external account — the narrowing direction, which rule 89 permits. It
|
||||
# must never be used to grant a customer-side account anything.
|
||||
actor_is_external = bool(actor) and actor.is_customer_account
|
||||
|
||||
project_id = inspection.facility.project_id if inspection.facility else None
|
||||
|
||||
candidates = []
|
||||
if project_id:
|
||||
candidates = (
|
||||
User.query
|
||||
.join(InspectorAssignment, InspectorAssignment.user_id == User.id)
|
||||
.filter(
|
||||
InspectorAssignment.project_id == project_id,
|
||||
User.role.in_(User.INSPECTOR_ROLES),
|
||||
User.active == True,
|
||||
)
|
||||
.order_by(User.full_name, User.username)
|
||||
.all()
|
||||
)
|
||||
|
||||
if not actor_is_external:
|
||||
candidates += (
|
||||
User.query
|
||||
.filter(
|
||||
User.role.in_(_ORG_WIDE_ASSIGNEE_ROLES),
|
||||
User.active == True,
|
||||
)
|
||||
.order_by(User.full_name, User.username)
|
||||
.all()
|
||||
)
|
||||
|
||||
# The join can repeat a user across assignment rows; dedupe by id, keeping
|
||||
# a stable display order.
|
||||
seen, out = set(), []
|
||||
for u in candidates:
|
||||
if u.id not in seen:
|
||||
seen.add(u.id)
|
||||
out.append(u)
|
||||
out.sort(key=lambda u: (u.display_name or '').lower())
|
||||
return out
|
||||
|
||||
@bp.route('/<int:inspection_id>/flag-issue', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def flag_issue(inspection_id):
|
||||
@@ -929,15 +1110,23 @@ def flag_issue(inspection_id):
|
||||
if inspection is None:
|
||||
abort(404)
|
||||
|
||||
if current_user.role == 'inspector' and inspection.inspector_id != current_user.id:
|
||||
if current_user.is_inspector and inspection.inspector_id != current_user.id:
|
||||
flash('Access denied.', 'danger')
|
||||
return redirect(url_for('inspections.index'))
|
||||
|
||||
form = IssueForm()
|
||||
staff = User.query.filter(User.role.in_(['director', 'inspector'])).order_by(User.username).all()
|
||||
# SAME list the offcanvas rendered — this is what actually validates the
|
||||
# POST, so it is also the security boundary: a crafted assigned_to for
|
||||
# someone outside this contract fails validation rather than being stored.
|
||||
staff = _assignable_staff_for(inspection, current_user)
|
||||
|
||||
form.facility_id.choices = [(inspection.facility_id, inspection.facility.name)]
|
||||
form.assigned_to.choices = [(0, '— Unassigned —')] + [(u.id, u.username) for u in staff]
|
||||
# Suffix customer-employed inspectors so whoever is triaging can see the
|
||||
# work is going outside the company. Display only.
|
||||
form.assigned_to.choices = [(0, '— Unassigned —')] + [
|
||||
(u.id, u.display_name + (' (Customer)' if u.is_external_inspector else ''))
|
||||
for u in staff
|
||||
]
|
||||
|
||||
if form.validate_on_submit():
|
||||
photo_path = _save_photo(form.photo.data, subfolder='issue_photos')
|
||||
@@ -1002,6 +1191,21 @@ def flag_issue(inspection_id):
|
||||
flash('Issue logged successfully.', 'success')
|
||||
return redirect(url_for('inspections.execute', inspection_id=inspection_id))
|
||||
|
||||
# A failed POST must NOT come back 200. The flag-issue offcanvas treats
|
||||
# `res.ok` as success and reloads the page, so a 200 here means the issue
|
||||
# is silently discarded with the user believing it was logged — the exact
|
||||
# failure rule 60 describes. Returning 400 routes it to the JS error branch
|
||||
# so the reason is shown and the form stays open with its input intact.
|
||||
if request.method == 'POST':
|
||||
if form.assigned_to.errors:
|
||||
# Most likely an assignee outside this inspection's contract:
|
||||
# either a stale page rendered before the assignment changed, or a
|
||||
# crafted id. Say something actionable rather than "invalid choice".
|
||||
flash('That person cannot be assigned to an issue on this contract. '
|
||||
'Reopen the panel to refresh the list.', 'danger')
|
||||
return render_template('inspections/flag_issue.html',
|
||||
form=form, inspection=inspection), 400
|
||||
|
||||
return render_template('inspections/flag_issue.html',
|
||||
form=form, inspection=inspection)
|
||||
|
||||
@@ -1019,7 +1223,7 @@ def export_list_pdf():
|
||||
joinedload(Inspection.area),
|
||||
).order_by(Inspection.inspection_date.desc())
|
||||
|
||||
if current_user.role == 'inspector':
|
||||
if current_user.is_inspector:
|
||||
fids = get_inspector_scope(current_user)
|
||||
if not fids:
|
||||
q = q.filter(False)
|
||||
@@ -1056,7 +1260,8 @@ def export_list_pdf():
|
||||
q = q.filter(Inspection.status == status_filter)
|
||||
if contract_filter.isdigit():
|
||||
_contract_fids = [
|
||||
f.id for f in Facility.query.filter_by(project_id=int(contract_filter)).all()
|
||||
fid for (fid,) in db.session.query(Facility.id)
|
||||
.filter(Facility.project_id == int(contract_filter)).all()
|
||||
]
|
||||
q = q.filter(Inspection.facility_id.in_(_contract_fids)) if _contract_fids else q.filter(False)
|
||||
if facility_filter.isdigit():
|
||||
@@ -1082,7 +1287,7 @@ def export_list_pdf():
|
||||
q = q.filter(Inspection.overall_score <= float(score_max_filter))
|
||||
except ValueError:
|
||||
pass
|
||||
if inspector_filter.isdigit() and current_user.role != 'inspector':
|
||||
if inspector_filter.isdigit() and not current_user.is_inspector:
|
||||
q = q.filter(Inspection.inspector_id == int(inspector_filter))
|
||||
|
||||
inspections = q.all()
|
||||
@@ -1112,7 +1317,7 @@ def export_list_pdf():
|
||||
filter_parts.append(f'Min score: {score_min_filter}%')
|
||||
if score_max_filter:
|
||||
filter_parts.append(f'Max score: {score_max_filter}%')
|
||||
if inspector_filter.isdigit() and current_user.role != 'inspector':
|
||||
if inspector_filter.isdigit() and not current_user.is_inspector:
|
||||
u = db.session.get(User, int(inspector_filter))
|
||||
if u:
|
||||
filter_parts.append(f'Inspector: {u.display_name}')
|
||||
@@ -1142,7 +1347,11 @@ def export_pdf(inspection_id):
|
||||
if inspection is None:
|
||||
abort(404)
|
||||
|
||||
if current_user.role == 'inspector' and inspection.inspector_id != current_user.id:
|
||||
# Read access matches the LIST (rule 58) — an inspector may open anything
|
||||
# at their contracted facilities, not only what they performed. Editing
|
||||
# someone else's inspection is still refused (execute / save-draft /
|
||||
# upload-photo / flag-issue keep the authorship check).
|
||||
if current_user.is_inspector and not _inspector_may_read(inspection, current_user):
|
||||
flash('Access denied.', 'danger')
|
||||
return redirect(url_for('inspections.index'))
|
||||
if current_user.role == 'customer':
|
||||
@@ -1214,31 +1423,397 @@ def export_pdf(inspection_id):
|
||||
|
||||
# ── Flag / clear follow-up required ──────────────────────────────────────────
|
||||
|
||||
def _view_url(inspection_id):
|
||||
"""inspections.view URL that carries the list `next` through.
|
||||
|
||||
Actions posted from the detail page redirect back to that same page;
|
||||
re-attaching `next` is what keeps its Back button (and the next action)
|
||||
pointed at the filtered list the user arrived from.
|
||||
"""
|
||||
nxt = request.form.get('next') or request.args.get('next')
|
||||
if nxt:
|
||||
return url_for('inspections.view', inspection_id=inspection_id, next=nxt)
|
||||
return url_for('inspections.view', inspection_id=inspection_id)
|
||||
|
||||
|
||||
def _inspector_may_read(inspection, user):
|
||||
"""May this inspector OPEN someone else's inspection?
|
||||
|
||||
Yes, when it happened at a facility on one of their contracts — the same
|
||||
scope `index()` uses (rule 58: an inspector's scope covers all data in their
|
||||
contracted facilities, not just their own work).
|
||||
|
||||
This used to test authorship instead, and the two disagreed: the list
|
||||
showed every inspection at the inspector's facilities, then clicking one
|
||||
said "Access denied". It also blocked the phase56 follow-up assignee from
|
||||
opening the parent inspection they had just been asked to re-inspect —
|
||||
the button they needed was on a page they could not reach.
|
||||
|
||||
READ only. Editing someone else's inspection is still refused: execute,
|
||||
save-draft, upload-photo and flag-issue all keep the authorship check.
|
||||
"""
|
||||
fids = get_inspector_scope(user)
|
||||
if fids is None: # not an inspector — no scoping applies here
|
||||
return True
|
||||
return bool(fids) and inspection.facility_id in fids
|
||||
|
||||
|
||||
def _followup_assignees_for(inspection, actor):
|
||||
"""Inspectors who may be handed this inspection's follow-up.
|
||||
|
||||
Contract-scoped, for the same reason the flag-issue list is (rule 93): a
|
||||
Customer Director must never see — let alone assign work to — another
|
||||
client's inspector, and one of our own directors picking the wrong name
|
||||
would leak this facility to an outsider.
|
||||
|
||||
Only the two INSPECTOR roles are offered: a follow-up is an inspection, and
|
||||
directors/PMs/auditors hold no InspectorAssignment, so they cannot be
|
||||
scoped to a contract and could not open the re-inspection anyway.
|
||||
|
||||
A facility with no contract yields nobody — fail-closed, leaving the
|
||||
follow-up with the original inspector.
|
||||
"""
|
||||
from app.models.inspector_assignment import InspectorAssignment
|
||||
|
||||
project_id = inspection.facility.project_id if inspection.facility else None
|
||||
if not project_id:
|
||||
return []
|
||||
|
||||
users = (
|
||||
User.query
|
||||
.join(InspectorAssignment, InspectorAssignment.user_id == User.id)
|
||||
.filter(
|
||||
InspectorAssignment.project_id == project_id,
|
||||
User.role.in_(User.INSPECTOR_ROLES),
|
||||
User.active == True,
|
||||
)
|
||||
.order_by(User.full_name, User.username)
|
||||
.all()
|
||||
)
|
||||
seen, out = set(), []
|
||||
for u in users: # the join repeats across assignments
|
||||
if u.id not in seen:
|
||||
seen.add(u.id)
|
||||
out.append(u)
|
||||
return out
|
||||
|
||||
|
||||
def _collect_inspection_photos(inspection):
|
||||
"""Relative storage keys owned by an inspection, for cleanup after delete.
|
||||
|
||||
Two sources: image field values inside the submitted form data (stored as
|
||||
`uploads/...` strings in the notes JSON), and the primary photo of each
|
||||
issue flagged during the inspection. Shared by the single and bulk delete
|
||||
paths so they cannot drift — a miss here leaves orphaned files in storage
|
||||
forever, and it is invisible.
|
||||
"""
|
||||
paths = []
|
||||
if inspection.notes:
|
||||
try:
|
||||
notes_data = json.loads(inspection.notes)
|
||||
form_data = notes_data.get('_form_data', {}) if isinstance(notes_data, dict) else {}
|
||||
for val in form_data.values():
|
||||
if isinstance(val, str) and val.startswith('uploads/'):
|
||||
paths.append(val)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
for issue in inspection.issues.all():
|
||||
if issue.photo_path:
|
||||
paths.append(issue.photo_path)
|
||||
return paths
|
||||
|
||||
|
||||
# ── Bulk actions from the inspections list ───────────────────────────────────
|
||||
|
||||
@bp.route('/bulk', methods=['POST'])
|
||||
@login_required
|
||||
def bulk_action():
|
||||
"""Apply one action to every ticked inspection on the list page.
|
||||
|
||||
Partial-failure policy: act on every eligible row, skip the rest, and
|
||||
report exact counts. Permission is checked per ACTION (all are
|
||||
admin/director level except the PDF export, which anyone who can see the
|
||||
list may run); `skipped` therefore means "this row was not in a state the
|
||||
action applies to".
|
||||
"""
|
||||
back = return_url(url_for('inspections.index'))
|
||||
action = request.form.get('action', '')
|
||||
ids = request.form.getlist('inspection_ids', type=int)
|
||||
|
||||
if not ids:
|
||||
flash('No inspections selected.', 'warning')
|
||||
return redirect(back)
|
||||
|
||||
supervisor = current_user.role in ('admin', 'director')
|
||||
allowed = {
|
||||
'export': True, # read-only, already scoped below
|
||||
'delete': supervisor,
|
||||
'flag_followup': supervisor,
|
||||
'clear_followup': supervisor,
|
||||
}
|
||||
if action not in allowed:
|
||||
flash('Unknown bulk action.', 'danger')
|
||||
return redirect(back)
|
||||
if not allowed[action]:
|
||||
flash('You do not have permission for that bulk action.', 'danger')
|
||||
return redirect(back)
|
||||
|
||||
q = Inspection.query.options(
|
||||
joinedload(Inspection.facility),
|
||||
joinedload(Inspection.template),
|
||||
joinedload(Inspection.inspector),
|
||||
).filter(Inspection.id.in_(ids))
|
||||
|
||||
# Re-apply the viewer's facility scope to the SELECTED ids. The list page
|
||||
# only ever shows in-scope rows, but the id list arrives in the POST body
|
||||
# and must not be trusted — a crafted request could otherwise name any
|
||||
# inspection in the system.
|
||||
if current_user.is_inspector:
|
||||
fids = get_inspector_scope(current_user) or []
|
||||
q = q.filter(Inspection.facility_id.in_(fids)) if fids else q.filter(False)
|
||||
elif current_user.role == 'customer':
|
||||
fids = get_customer_scope(current_user) or []
|
||||
q = q.filter(Inspection.facility_id.in_(fids)) if fids else q.filter(False)
|
||||
|
||||
inspections = q.order_by(Inspection.inspection_date.desc()).all()
|
||||
out_of_scope = len(ids) - len(inspections)
|
||||
changed = 0
|
||||
skipped = out_of_scope
|
||||
|
||||
# ── Export selected to PDF ───────────────────────────────────────────
|
||||
if action == 'export':
|
||||
if not inspections:
|
||||
flash('None of the selected inspections are available to you.', 'warning')
|
||||
return redirect(back)
|
||||
from flask import Response
|
||||
pdf = generate_inspections_list_pdf(
|
||||
inspections,
|
||||
f'Selected inspections ({len(inspections)})',
|
||||
)
|
||||
log_action(ACTION_EXPORT, 'Inspection', None, 'bulk PDF export',
|
||||
f'ids={[i.id for i in inspections]}')
|
||||
return Response(
|
||||
pdf,
|
||||
mimetype='application/pdf',
|
||||
headers={'Content-Disposition':
|
||||
'attachment; filename="selected_inspections.pdf"'},
|
||||
)
|
||||
|
||||
# ── Delete ───────────────────────────────────────────────────────────
|
||||
if action == 'delete':
|
||||
from app.utils import storage
|
||||
photo_paths = []
|
||||
# Snapshot (id, label) BEFORE deleting: the objects are expired after
|
||||
# the commit, and the audit pass must run after it. log_action()
|
||||
# commits internally (rule 41), so auditing inside this loop would
|
||||
# commit the deletes one at a time — and a mid-loop failure would
|
||||
# leave rows gone with the photo cleanup below never reached.
|
||||
deleted = []
|
||||
for insp in inspections:
|
||||
photo_paths.extend(_collect_inspection_photos(insp))
|
||||
deleted.append((
|
||||
insp.id,
|
||||
f'{insp.template.name if insp.template else "—"} @ '
|
||||
f'{insp.facility.name if insp.facility else "—"}',
|
||||
))
|
||||
db.session.delete(insp)
|
||||
changed += 1
|
||||
db.session.commit()
|
||||
for insp_id, label in deleted:
|
||||
log_action(ACTION_DELETE, 'Inspection', insp_id, label,
|
||||
f'bulk deleted by {current_user.username}')
|
||||
# Files only after the rows are gone — an orphaned file is recoverable,
|
||||
# a deleted file belonging to a surviving row is not.
|
||||
for rel_path in photo_paths:
|
||||
storage.delete(rel_path)
|
||||
_flash_bulk(changed, skipped, 'permanently deleted')
|
||||
|
||||
# ── Request follow-up ────────────────────────────────────────────────
|
||||
elif action == 'flag_followup':
|
||||
note = request.form.get('follow_up_note', '').strip() or None
|
||||
# Only the rows this run actually flagged. Re-deriving it afterwards
|
||||
# from `follow_up_requested_by == current_user.id` would also match
|
||||
# inspections this same user flagged on an EARLIER run and that were
|
||||
# skipped here as already-flagged — re-notifying their inspectors.
|
||||
flagged = []
|
||||
for insp in inspections:
|
||||
# Same two guards as the single-inspection route: nothing to follow
|
||||
# up on before submission, and a repeat request must not overwrite
|
||||
# the pending one's note or attribution.
|
||||
if insp.status != 'completed' or insp.follow_up_required:
|
||||
skipped += 1
|
||||
continue
|
||||
insp.follow_up_required = True
|
||||
insp.follow_up_note = note
|
||||
insp.follow_up_requested_by = current_user.id
|
||||
insp.follow_up_requested_at = now_eastern()
|
||||
flagged.append(insp)
|
||||
changed += 1
|
||||
db.session.commit()
|
||||
|
||||
for insp in flagged:
|
||||
body = (f'{current_user.display_name} has requested a follow-up '
|
||||
f're-inspection of "{insp.template.name if insp.template else "—"}" '
|
||||
f'at {insp.facility.name if insp.facility else "—"}.'
|
||||
+ (f' Note: {note}' if note else ''))
|
||||
inspector = db.session.get(User, insp.inspector_id)
|
||||
if inspector and inspector.id != current_user.id:
|
||||
notify(
|
||||
recipient = inspector,
|
||||
title = f'Follow-Up Required: Inspection #{insp.id}',
|
||||
body = body,
|
||||
link = url_for('inspections.view', inspection_id=insp.id),
|
||||
inspection_id = insp.id,
|
||||
event_type = EVENT_INSPECTION_DONE,
|
||||
send_email = True,
|
||||
)
|
||||
# Through the matrix, not straight to managers — rule 73, so
|
||||
# per-contract recipients fire here exactly as they do for a
|
||||
# single request.
|
||||
notify_by_matrix(
|
||||
event_type = EVENT_FOLLOWUP_REQUESTED,
|
||||
title = f'Follow-Up Requested: Inspection #{insp.id}',
|
||||
body = body,
|
||||
link = url_for('inspections.view', inspection_id=insp.id),
|
||||
inspection_id = insp.id,
|
||||
facility_id = insp.facility_id,
|
||||
exclude_user_ids = {current_user.id,
|
||||
inspector.id if inspector else None} - {None},
|
||||
)
|
||||
db.session.commit() # notify() does not commit — rule 70
|
||||
# Audited after the commit (rule 41) — log_action commits internally.
|
||||
for insp in flagged:
|
||||
log_action(ACTION_UPDATE, 'Inspection', insp.id,
|
||||
f'{insp.template.name if insp.template else "—"}',
|
||||
f'bulk follow_up_required=True by {current_user.username}')
|
||||
_flash_bulk(changed, skipped, 'flagged for follow-up',
|
||||
skip_reason='not submitted, or already flagged')
|
||||
|
||||
# ── Clear follow-up ──────────────────────────────────────────────────
|
||||
elif action == 'clear_followup':
|
||||
cleared = []
|
||||
for insp in inspections:
|
||||
if not insp.follow_up_required:
|
||||
skipped += 1
|
||||
continue
|
||||
insp.follow_up_required = False
|
||||
insp.follow_up_note = None
|
||||
insp.follow_up_requested_by = None
|
||||
insp.follow_up_requested_at = None
|
||||
insp.follow_up_assigned_to = None
|
||||
cleared.append(insp)
|
||||
changed += 1
|
||||
db.session.commit()
|
||||
for insp in cleared: # after the commit — rule 41
|
||||
log_action(ACTION_UPDATE, 'Inspection', insp.id,
|
||||
f'{insp.template.name if insp.template else "—"}',
|
||||
f'bulk follow_up cleared by {current_user.username}')
|
||||
_flash_bulk(changed, skipped, 'cleared of the follow-up flag',
|
||||
skip_reason='not flagged')
|
||||
|
||||
current_app.logger.info(
|
||||
'INSPECTIONS | bulk | action=%s user=%s selected=%s changed=%s skipped=%s',
|
||||
action, current_user.username, len(ids), changed, skipped,
|
||||
)
|
||||
return redirect(back)
|
||||
|
||||
|
||||
def _flash_bulk(changed, skipped, verb, skip_reason='no change needed'):
|
||||
"""One consistent result message for every bulk action."""
|
||||
if not changed and not skipped:
|
||||
flash('Nothing to do.', 'info')
|
||||
return
|
||||
parts = [f'{changed} inspection{"s" if changed != 1 else ""} {verb}']
|
||||
if skipped:
|
||||
parts.append(f'{skipped} skipped ({skip_reason})')
|
||||
flash('. '.join(parts) + '.', 'success' if changed else 'warning')
|
||||
|
||||
|
||||
@bp.route('/<int:inspection_id>/flag-followup', methods=['POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def flag_followup(inspection_id):
|
||||
"""Mark an inspection as requiring a follow-up re-inspection."""
|
||||
"""Mark an inspection as requiring a follow-up re-inspection.
|
||||
|
||||
phase49: no longer @supervisor_required. Open to admin/director AND to
|
||||
customers for their own facilities — a client unhappy with a result can ask
|
||||
for a re-inspection directly rather than going through support. Every other
|
||||
role is refused, so inspectors and auditors are no worse off than before.
|
||||
|
||||
Customers may only *request*: they cannot clear the flag (clear_followup is
|
||||
still @supervisor_required) nor run the re-inspection itself.
|
||||
"""
|
||||
inspection = db.session.get(Inspection, inspection_id)
|
||||
if inspection is None:
|
||||
abort(404)
|
||||
|
||||
is_customer = current_user.role == 'customer'
|
||||
if is_customer:
|
||||
# Same facility scope as view() — a customer must not be able to reach
|
||||
# another client's inspection with a crafted POST.
|
||||
if inspection.facility_id not in (get_customer_scope(current_user) or []):
|
||||
abort(403)
|
||||
# Nothing to follow up on until the inspection has been submitted.
|
||||
if inspection.status != 'completed':
|
||||
flash('You can only request a follow-up on a completed inspection.', 'warning')
|
||||
return redirect(_view_url(inspection_id))
|
||||
# Don't let a repeat request overwrite the note/attribution of a pending
|
||||
# one — the flag is already raised and staff are already on it.
|
||||
if inspection.follow_up_required:
|
||||
flash('A follow-up has already been requested for this inspection.', 'info')
|
||||
return redirect(_view_url(inspection_id))
|
||||
elif current_user.role not in ('admin', 'director'):
|
||||
abort(403)
|
||||
|
||||
note = request.form.get('follow_up_note', '').strip() or None
|
||||
|
||||
inspection.follow_up_required = True
|
||||
inspection.follow_up_note = note
|
||||
# ── Assignee (phase56) ────────────────────────────────────────────────
|
||||
# Optional. Blank keeps the original behaviour: the follow-up belongs to
|
||||
# the inspection's own inspector. Validated against the contract-scoped
|
||||
# list rather than trusted, so a crafted id cannot hand work to another
|
||||
# customer's inspector (and tell them this facility's name in the email).
|
||||
assignee_id = request.form.get('follow_up_assigned_to', type=int) or None
|
||||
if assignee_id:
|
||||
allowed = {u.id for u in _followup_assignees_for(inspection, current_user)}
|
||||
if assignee_id not in allowed:
|
||||
current_app.logger.warning(
|
||||
'FOLLOW-UP | out-of-contract assignee blocked | inspection=%s | '
|
||||
'assignee=%s | by=%s',
|
||||
inspection_id, assignee_id, current_user.username)
|
||||
flash('That inspector is not assigned to this facility\'s contract.',
|
||||
'danger')
|
||||
return redirect(_view_url(inspection_id))
|
||||
|
||||
inspection.follow_up_required = True
|
||||
inspection.follow_up_note = note
|
||||
inspection.follow_up_requested_by = current_user.id
|
||||
inspection.follow_up_requested_at = now_eastern()
|
||||
inspection.follow_up_assigned_to = assignee_id
|
||||
db.session.commit()
|
||||
|
||||
# Notify the original inspector so they see it on the iPad
|
||||
inspector = db.session.get(User, inspection.inspector_id)
|
||||
note_suffix = f' Note: {note}' if note else ''
|
||||
who = (f'The customer ({current_user.display_name})' if is_customer
|
||||
else current_user.display_name)
|
||||
assigned_suffix = ''
|
||||
if inspection.follow_up_assignee:
|
||||
assigned_suffix = (f' It has been assigned to '
|
||||
f'{inspection.follow_up_assignee.display_name}.')
|
||||
body = (
|
||||
f'{who} has requested a follow-up re-inspection '
|
||||
f'of "{inspection.template.name}" at {inspection.facility.name}.'
|
||||
f'{assigned_suffix}{note_suffix}'
|
||||
)
|
||||
|
||||
# Notify whoever now OWNS the follow-up — the assignee when one was named,
|
||||
# otherwise the original inspector (Inspection.follow_up_owner). Notifying
|
||||
# the original inspector for work that has been handed to someone else is
|
||||
# noise, and worse, it implies they are expected to do it.
|
||||
inspector = inspection.follow_up_owner
|
||||
if inspector and inspector.id != current_user.id:
|
||||
note_suffix = f' Note: {note}' if note else ''
|
||||
notify(
|
||||
recipient = inspector,
|
||||
title = f'Follow-Up Required: Inspection #{inspection_id}',
|
||||
body = (
|
||||
f'{current_user.username} has requested a follow-up re-inspection '
|
||||
f'of "{inspection.template.name}" at {inspection.facility.name}.{note_suffix}'
|
||||
),
|
||||
body = body,
|
||||
link = url_for('inspections.view', inspection_id=inspection_id),
|
||||
inspection_id = inspection_id,
|
||||
event_type = EVENT_INSPECTION_DONE,
|
||||
@@ -1246,15 +1821,35 @@ def flag_followup(inspection_id):
|
||||
)
|
||||
db.session.commit()
|
||||
|
||||
# Route to the staff who action follow-ups. Going through notify_by_matrix
|
||||
# rather than notifying managers directly keeps recipients admin-configurable
|
||||
# and lets per-contract recipients fire too. This matters most for a customer
|
||||
# request: without it only the inspector would hear about it and nobody would
|
||||
# be accountable for scheduling the re-inspection.
|
||||
notify_by_matrix(
|
||||
event_type = EVENT_FOLLOWUP_REQUESTED,
|
||||
title = f'Follow-Up Requested: Inspection #{inspection_id}',
|
||||
body = body,
|
||||
link = url_for('inspections.view', inspection_id=inspection_id),
|
||||
inspection_id = inspection_id,
|
||||
facility_id = inspection.facility_id,
|
||||
exclude_user_ids = {current_user.id,
|
||||
inspector.id if inspector else None} - {None},
|
||||
)
|
||||
db.session.commit()
|
||||
|
||||
current_app.logger.info(
|
||||
'INSPECTION FOLLOW-UP FLAGGED | id=%s | by=%s | note=%r',
|
||||
inspection_id, current_user.username, note,
|
||||
'INSPECTION FOLLOW-UP FLAGGED | id=%s | by=%s (%s) | note=%r',
|
||||
inspection_id, current_user.username, current_user.role, note,
|
||||
)
|
||||
log_action(ACTION_UPDATE, 'Inspection', inspection_id,
|
||||
f'{inspection.template.name} @ {inspection.facility.name}',
|
||||
f'follow_up_required=True; note={note!r}')
|
||||
flash('Follow-up inspection required flag set.', 'warning')
|
||||
return redirect(url_for('inspections.view', inspection_id=inspection_id))
|
||||
f'follow_up_required=True; by_role={current_user.role}; note={note!r}')
|
||||
if is_customer:
|
||||
flash('Follow-up re-inspection requested. The team has been notified.', 'success')
|
||||
else:
|
||||
flash('Follow-up inspection required flag set.', 'warning')
|
||||
return redirect(_view_url(inspection_id))
|
||||
|
||||
|
||||
@bp.route('/<int:inspection_id>/clear-followup', methods=['POST'])
|
||||
@@ -1267,12 +1862,18 @@ def clear_followup(inspection_id):
|
||||
abort(404)
|
||||
inspection.follow_up_required = False
|
||||
inspection.follow_up_note = None
|
||||
# phase49 — clear the attribution with the flag. Leaving it behind would
|
||||
# make the next unattributed follow-up appear to have been requested by
|
||||
# whoever raised the previous one.
|
||||
inspection.follow_up_requested_by = None
|
||||
inspection.follow_up_requested_at = None
|
||||
inspection.follow_up_assigned_to = None
|
||||
db.session.commit()
|
||||
log_action(ACTION_UPDATE, 'Inspection', inspection_id,
|
||||
f'{inspection.template.name} @ {inspection.facility.name}',
|
||||
'follow_up_required=False (cleared)')
|
||||
flash('Follow-up flag cleared.', 'success')
|
||||
return redirect(url_for('inspections.view', inspection_id=inspection_id))
|
||||
return redirect(_view_url(inspection_id))
|
||||
|
||||
|
||||
# ── Start a re-inspection (linked to parent) ──────────────────────────────────
|
||||
@@ -1291,6 +1892,26 @@ def reinspect(inspection_id):
|
||||
flash('Access denied.', 'danger')
|
||||
return redirect(url_for('inspections.index'))
|
||||
|
||||
# An inspector may re-inspect their OWN work, or work they have been
|
||||
# handed the follow-up for (phase56). Anything else at a contracted
|
||||
# facility is readable but not theirs to redo — starting a re-inspection
|
||||
# of a colleague's inspection uninvited only creates confusion about who
|
||||
# is doing it.
|
||||
if current_user.is_inspector:
|
||||
if parent.follow_up_required and parent.follow_up_owner:
|
||||
# A live follow-up has exactly ONE owner (phase56). Even the
|
||||
# original inspector does not start it once it has been handed to
|
||||
# someone else — that is the whole point of assigning it, and two
|
||||
# people turning up is the failure being designed out.
|
||||
may = parent.follow_up_owner.id == current_user.id
|
||||
else:
|
||||
# No follow-up outstanding: re-inspecting your own work is fine,
|
||||
# someone else's is not yours to redo uninvited.
|
||||
may = parent.inspector_id == current_user.id
|
||||
if not may:
|
||||
flash('That re-inspection has been assigned to someone else.', 'warning')
|
||||
return redirect(_view_url(inspection_id))
|
||||
|
||||
session['reinspect_parent_id'] = parent.id
|
||||
session['reinspect_template_id'] = parent.template_id
|
||||
session['reinspect_facility_id'] = parent.facility_id
|
||||
@@ -1318,20 +1939,7 @@ def delete(inspection_id):
|
||||
template_name = inspection.template.name
|
||||
inspector_name = inspection.inspector.username
|
||||
|
||||
photo_paths = []
|
||||
if inspection.notes:
|
||||
try:
|
||||
notes_data = json.loads(inspection.notes)
|
||||
form_data = notes_data.get('_form_data', {}) if isinstance(notes_data, dict) else {}
|
||||
for val in form_data.values():
|
||||
if isinstance(val, str) and val.startswith('uploads/'):
|
||||
photo_paths.append(val)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
for issue in inspection.issues.all():
|
||||
if issue.photo_path:
|
||||
photo_paths.append(issue.photo_path)
|
||||
photo_paths = _collect_inspection_photos(inspection)
|
||||
|
||||
db.session.delete(inspection)
|
||||
db.session.commit()
|
||||
@@ -1358,4 +1966,4 @@ def delete(inspection_id):
|
||||
f'has been permanently deleted.',
|
||||
'success'
|
||||
)
|
||||
return redirect(url_for('inspections.index'))
|
||||
return redirect(return_url(url_for('inspections.index')))
|
||||
+577
-46
@@ -6,7 +6,7 @@ from flask import (Blueprint, render_template, redirect, url_for,
|
||||
flash, request, current_app, jsonify, abort, Response)
|
||||
from flask_login import login_required, current_user
|
||||
from app import db
|
||||
from app.models.issue import Issue, IssueComment, IssueFollower
|
||||
from app.models.issue import Issue, IssueComment, IssueFollower, IssueLink
|
||||
from app.models.facility import Facility, Area
|
||||
from app.models.user import User
|
||||
from app.models.notification import (
|
||||
@@ -16,7 +16,7 @@ from app.models.notification import (
|
||||
)
|
||||
from app.utils.forms import IssueForm, IssueUpdateForm
|
||||
from app.utils.decorators import (supervisor_required, project_manager_required,
|
||||
issue_manager_required)
|
||||
issue_manager_required, return_url)
|
||||
from app.utils.notifications import notify, notify_customers_for_facility, notify_by_matrix
|
||||
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE, ACTION_EXPORT
|
||||
from app.tenancy.gates import quota_soft_check
|
||||
@@ -71,6 +71,60 @@ class _SLAFilteredPage:
|
||||
return iter([1])
|
||||
|
||||
|
||||
# ── Issue read access ─────────────────────────────────────────────────────────
|
||||
# One definition of "may this person open this issue", used by the detail view,
|
||||
# by the linked-issues panel, and by the link picker's search. They must not
|
||||
# drift: the picker is what a person searches, but the panel is what actually
|
||||
# renders another issue's description, and the POST is the real boundary.
|
||||
#
|
||||
# All three are WITHIN one tenant. Cross-tenant isolation is not their job and
|
||||
# never can be — RoutingSession has already bound the session to g.tenant's
|
||||
# database, so an id from another tenant simply does not resolve here.
|
||||
|
||||
def _viewer_facility_scope(user):
|
||||
"""Facility ids this user is confined to, or None when unrestricted.
|
||||
|
||||
Returns a LIST (possibly empty) for the two scoped role groups and None for
|
||||
everyone else. Empty list and None mean opposite things — [] is "no access
|
||||
to anything", None is "no restriction" — so callers must test `is None`
|
||||
rather than truthiness (CLAUDE.md rule 57's failure mode).
|
||||
"""
|
||||
if user.is_inspector: # rule 87 — never role == 'inspector'
|
||||
return get_inspector_scope(user) or []
|
||||
if user.role == 'customer': # rule 99 — capability check, exact match
|
||||
return get_customer_scope(user) or []
|
||||
return None
|
||||
|
||||
|
||||
def _issue_in_scope(issue, scope_ids):
|
||||
"""Whether one issue falls inside an already-resolved facility scope.
|
||||
|
||||
Takes the scope rather than the user so a caller filtering a list of issues
|
||||
resolves it once instead of re-querying the assignment tables per row.
|
||||
"""
|
||||
if scope_ids is None:
|
||||
return True
|
||||
facility = issue.resolved_facility
|
||||
return facility is not None and facility.id in scope_ids
|
||||
|
||||
|
||||
def _issue_readable_by(issue, user):
|
||||
"""Single-issue convenience wrapper around the two helpers above."""
|
||||
return _issue_in_scope(issue, _viewer_facility_scope(user))
|
||||
|
||||
|
||||
def _assignee_label(user):
|
||||
"""Dropdown label for an assignee.
|
||||
|
||||
MT-15 — external (customer / third-party) inspectors are assignable just
|
||||
like the tenant's own crew, but are suffixed so whoever is triaging can see
|
||||
at a glance that the work is going outside the company. Display only; the
|
||||
stored value is still the user id.
|
||||
"""
|
||||
return (f'{user.display_name} (Customer)'
|
||||
if user.is_external_inspector else user.display_name)
|
||||
|
||||
|
||||
@bp.route('/export-list-pdf')
|
||||
@login_required
|
||||
def export_list_pdf():
|
||||
@@ -86,7 +140,7 @@ def export_list_pdf():
|
||||
.order_by(Issue.reported_at.desc())
|
||||
)
|
||||
|
||||
if current_user.role == 'inspector':
|
||||
if current_user.is_inspector:
|
||||
fids = get_inspector_scope(current_user)
|
||||
if not fids:
|
||||
q = q.filter(False)
|
||||
@@ -134,13 +188,14 @@ def export_list_pdf():
|
||||
date_to_filter = ''
|
||||
if contract_filter.isdigit():
|
||||
_contract_fids = [
|
||||
f.id for f in Facility.query.filter_by(project_id=int(contract_filter)).all()
|
||||
fid for (fid,) in db.session.query(Facility.id)
|
||||
.filter(Facility.project_id == int(contract_filter)).all()
|
||||
]
|
||||
q = q.filter(db.or_(
|
||||
Issue.facility_id.in_(_contract_fids),
|
||||
Area.facility_id.in_(_contract_fids),
|
||||
)) if _contract_fids else q.filter(False)
|
||||
if facility_filter:
|
||||
if facility_filter.isdigit():
|
||||
fid = int(facility_filter)
|
||||
q = q.filter(db.or_(Issue.facility_id == fid, Area.facility_id == fid))
|
||||
if reporter_filter.isdigit():
|
||||
@@ -169,7 +224,7 @@ def export_list_pdf():
|
||||
p = db.session.get(Project, int(contract_filter))
|
||||
if p:
|
||||
filter_parts.append(f'Contract: {p.name}')
|
||||
if facility_filter:
|
||||
if facility_filter.isdigit():
|
||||
f = db.session.get(Facility, int(facility_filter))
|
||||
if f:
|
||||
filter_parts.append(f'Facility: {f.name}')
|
||||
@@ -211,7 +266,7 @@ def index():
|
||||
.order_by(Issue.reported_at.desc())
|
||||
)
|
||||
|
||||
if current_user.role == 'inspector':
|
||||
if current_user.is_inspector:
|
||||
fids = get_inspector_scope(current_user)
|
||||
if not fids:
|
||||
q = q.filter(False)
|
||||
@@ -267,7 +322,8 @@ def index():
|
||||
date_to_filter = ''
|
||||
if contract_filter.isdigit():
|
||||
_contract_fids = [
|
||||
f.id for f in Facility.query.filter_by(project_id=int(contract_filter)).all()
|
||||
fid for (fid,) in db.session.query(Facility.id)
|
||||
.filter(Facility.project_id == int(contract_filter)).all()
|
||||
]
|
||||
q = q.filter(db.or_(
|
||||
Issue.facility_id.in_(_contract_fids),
|
||||
@@ -275,7 +331,7 @@ def index():
|
||||
)) if _contract_fids else q.filter(False)
|
||||
if reporter_filter.isdigit():
|
||||
q = q.filter(Issue.reported_by == int(reporter_filter))
|
||||
if facility_filter:
|
||||
if facility_filter.isdigit():
|
||||
fid = int(facility_filter)
|
||||
q = q.filter(
|
||||
db.or_(
|
||||
@@ -313,13 +369,14 @@ def index():
|
||||
# can render the following badge and inline unfollow button without an
|
||||
# additional query per row.
|
||||
followed_ids = {
|
||||
f.issue_id
|
||||
for f in IssueFollower.query.filter_by(user_id=current_user.id).all()
|
||||
iid for (iid,) in
|
||||
db.session.query(IssueFollower.issue_id)
|
||||
.filter(IssueFollower.user_id == current_user.id).all()
|
||||
}
|
||||
|
||||
# Facilities for the filter dropdown — scoped for inspectors/customers,
|
||||
# then narrowed to the selected contract when contract_filter is active.
|
||||
if current_user.role == 'inspector':
|
||||
if current_user.is_inspector:
|
||||
fids = get_inspector_scope(current_user) or []
|
||||
_fq = Facility.query.filter(Facility.id.in_(fids), Facility.active == True)
|
||||
elif current_user.role == 'customer':
|
||||
@@ -347,7 +404,8 @@ def index():
|
||||
|
||||
# Staff for quick-assign dropdown — same roles as the full issue form
|
||||
staff = User.query.filter(
|
||||
User.role.in_(['director', 'inspector', 'auditor']), User.active == True
|
||||
User.role.in_(['director', 'inspector', 'external_inspector', 'auditor']),
|
||||
User.active == True
|
||||
).order_by(User.username).all()
|
||||
|
||||
# Reporters dropdown — users who have actually filed at least one issue
|
||||
@@ -385,18 +443,14 @@ def view(issue_id):
|
||||
if issue is None:
|
||||
abort(404)
|
||||
|
||||
if current_user.role == 'inspector':
|
||||
fids = get_inspector_scope(current_user)
|
||||
facility = issue.resolved_facility
|
||||
if not fids or not facility or facility.id not in fids:
|
||||
flash('Access denied.', 'danger')
|
||||
return redirect(url_for('issues.index'))
|
||||
# Scope gate — see _issue_readable_by(). This was two inline blocks that the
|
||||
# linked-issues panel would have had to reproduce a third time; it is now
|
||||
# one definition so the panel cannot end up more permissive than the page.
|
||||
if not _issue_readable_by(issue, current_user):
|
||||
flash('Access denied.', 'danger')
|
||||
return redirect(url_for('issues.index'))
|
||||
|
||||
if current_user.role == 'customer':
|
||||
cids = get_customer_scope(current_user) or []
|
||||
facility = issue.resolved_facility
|
||||
if not facility or facility.id not in cids:
|
||||
flash('Access denied.', 'danger')
|
||||
return redirect(url_for('issues.index'))
|
||||
if request.method == 'POST':
|
||||
# Customers may only add a comment, and only on issues they follow or reported
|
||||
can_comment = (issue.is_followed_by(current_user) or issue.reported_by == current_user.id)
|
||||
@@ -405,7 +459,7 @@ def view(issue_id):
|
||||
comment_body = request.form.get('update_notes', '').strip()
|
||||
if not comment_body:
|
||||
flash('Comment cannot be empty.', 'warning')
|
||||
return redirect(url_for('issues.view', issue_id=issue_id))
|
||||
return redirect(_view_url(issue_id))
|
||||
comment = IssueComment(
|
||||
issue_id=issue.id,
|
||||
user_id=current_user.id,
|
||||
@@ -419,10 +473,10 @@ def view(issue_id):
|
||||
f'#{issue.id}',
|
||||
'customer comment added')
|
||||
flash('Comment posted.', 'success')
|
||||
return redirect(url_for('issues.view', issue_id=issue_id))
|
||||
return redirect(_view_url(issue_id))
|
||||
|
||||
form = IssueUpdateForm(obj=issue)
|
||||
staff = User.query.filter(User.role.in_(['director', 'inspector', 'auditor'])).order_by(User.username).all()
|
||||
staff = User.query.filter(User.role.in_(['director', 'inspector', 'external_inspector', 'auditor'])).order_by(User.username).all()
|
||||
# Preserve any pre-existing assignee who is no longer in the assignable set
|
||||
# (e.g. an admin assigned before admins were removed from the dropdown) so
|
||||
# saving the form doesn't silently unassign them.
|
||||
@@ -430,7 +484,9 @@ def view(issue_id):
|
||||
current_assignee = db.session.get(User, issue.assigned_to)
|
||||
if current_assignee:
|
||||
staff.append(current_assignee)
|
||||
form.assigned_to.choices = [(0, '— Unassigned —')] + [(u.id, u.display_name) for u in staff]
|
||||
form.assigned_to.choices = [(0, '— Unassigned —')] + [
|
||||
(u.id, _assignee_label(u)) for u in staff
|
||||
]
|
||||
form.status.data = form.status.data or issue.status
|
||||
|
||||
if form.validate_on_submit():
|
||||
@@ -462,8 +518,19 @@ def view(issue_id):
|
||||
issue.vendor_contact = form.vendor_contact.data.strip() or None
|
||||
issue.vendor_notes = form.vendor_notes.data.strip() or None
|
||||
|
||||
# Handler type (phase39)
|
||||
ht = form.handler_type.data or None
|
||||
# Handler type (phase39; NOT NULL since phase44)
|
||||
# Coerce empty/unknown to 'internal' explicitly. This is NOT
|
||||
# preventing a crash: handler_type carries a Python-side
|
||||
# default='internal', and SQLAlchemy applies a column default when
|
||||
# the attribute is None — so the previous `or None` would have been
|
||||
# silently rescued to 'internal' rather than raising. The point is to
|
||||
# not depend on that fairly obscure behaviour, and to state the
|
||||
# intended value at the point of assignment. The membership check
|
||||
# also backstops a crafted POST, though SelectField.pre_validate
|
||||
# already rejects out-of-choice values.
|
||||
ht = form.handler_type.data or 'internal'
|
||||
if ht not in ('internal', 'facility', 'vendor'):
|
||||
ht = 'internal'
|
||||
issue.handler_type = ht
|
||||
if ht == 'facility':
|
||||
issue.facility_handler_name = form.facility_handler_name.data.strip() or None
|
||||
@@ -474,6 +541,13 @@ def view(issue_id):
|
||||
issue.facility_handler_contact = None
|
||||
issue.facility_handler_notes = None
|
||||
|
||||
# Janitorial staff handler (phase44). Written unconditionally, the
|
||||
# same way vendor_* above is: the work-order dispatch route also
|
||||
# writes vendor_name, so clearing non-active handler fields here
|
||||
# would discard data set elsewhere.
|
||||
issue.internal_handler_name = (form.internal_handler_name.data or '').strip() or None
|
||||
issue.internal_handler_contact = (form.internal_handler_contact.data or '').strip() or None
|
||||
|
||||
from app.routes.inspections import _save_photo
|
||||
new_photos = []
|
||||
for file_obj in request.files.getlist('result_photos'):
|
||||
@@ -633,10 +707,16 @@ def view(issue_id):
|
||||
f'#{issue.id} in {issue.area.name if issue.area else issue.resolved_facility.name if issue.resolved_facility else '—'}',
|
||||
f'status={issue.status}; assigned_to={issue.assigned_to}')
|
||||
flash('Issue updated.', 'success')
|
||||
return redirect(url_for('issues.view', issue_id=issue_id))
|
||||
return redirect(_view_url(issue_id))
|
||||
|
||||
is_following = issue.is_followed_by(current_user)
|
||||
if current_user.role == 'customer':
|
||||
# TEMPORARY (Aug 2026) — COMMENTS_VISIBLE_TO_ALL lifts the phase22
|
||||
# restriction so customers see every comment on the issue, not only the
|
||||
# ones ticked "Share with customer". is_customer_visible is still recorded
|
||||
# on every comment, so setting the flag back to false restores the old
|
||||
# filtering with nothing to repair. See config.py.
|
||||
comments_open = current_app.config.get('COMMENTS_VISIBLE_TO_ALL', False)
|
||||
if current_user.role == 'customer' and not comments_open:
|
||||
comments = (issue.comments
|
||||
.filter_by(is_customer_visible=True)
|
||||
.order_by(IssueComment.created_at.asc()).all())
|
||||
@@ -646,7 +726,210 @@ def view(issue_id):
|
||||
issue=issue,
|
||||
form=form,
|
||||
comments=comments,
|
||||
is_following=is_following)
|
||||
comments_open=comments_open,
|
||||
is_following=is_following,
|
||||
# Already filtered to links whose far end this viewer
|
||||
# may open — see _readable_links().
|
||||
issue_links=_readable_links(issue, current_user),
|
||||
link_types=IssueLink.TYPE_CHOICES,
|
||||
can_manage_links=_can_manage_links(issue, current_user))
|
||||
|
||||
|
||||
# ── Issue links ───────────────────────────────────────────────────────────────
|
||||
# Connect a duplicate to its original, or two issues about the same thing, so
|
||||
# whoever picks one up can reach the other. Links are purely navigational: they
|
||||
# never touch status, SLA, assignee or followers on either issue.
|
||||
|
||||
def _can_manage_links(issue, user):
|
||||
"""Who may add or remove a link on this issue.
|
||||
|
||||
Deliberately the SAME set as the page's `can_edit` (the Update Issue panel):
|
||||
admin / director / auditor, or the person the issue is assigned to. Keeping
|
||||
the two identical means the panel's buttons and this gate cannot disagree —
|
||||
the alternative is a second, slightly different rule that nobody remembers.
|
||||
Widening it (to project_manager, or to the reporter) is a one-line change
|
||||
here, but change `can_edit` in issues/view.html at the same time.
|
||||
"""
|
||||
return (user.role in ('admin', 'director', 'auditor')
|
||||
or issue.assigned_to == user.id)
|
||||
|
||||
|
||||
def _readable_links(issue, user):
|
||||
"""Links on this issue whose FAR END the viewer may also open.
|
||||
|
||||
A link is a pointer to another issue's id, description and facility, so an
|
||||
unfiltered panel would let a customer read an issue at a facility they have
|
||||
no assignment to simply because one of our staff linked it. The scope is
|
||||
resolved once for the whole list rather than per row.
|
||||
|
||||
Returns a list of (link, other_issue, label) ready for the template.
|
||||
"""
|
||||
scope = _viewer_facility_scope(user)
|
||||
visible = []
|
||||
for link in issue.all_links():
|
||||
other = link.other_issue(issue.id)
|
||||
if other is None or not _issue_in_scope(other, scope):
|
||||
continue
|
||||
visible.append((link, other, link.label_for(issue.id)))
|
||||
return visible
|
||||
|
||||
|
||||
@bp.route('/<int:issue_id>/links', methods=['POST'])
|
||||
@login_required
|
||||
def add_link(issue_id):
|
||||
"""Link this issue to another one."""
|
||||
issue = db.session.get(Issue, issue_id)
|
||||
if issue is None:
|
||||
abort(404)
|
||||
if not _issue_readable_by(issue, current_user):
|
||||
abort(403)
|
||||
if not _can_manage_links(issue, current_user):
|
||||
abort(403)
|
||||
|
||||
link_type = request.form.get('link_type', '')
|
||||
if link_type not in (IssueLink.TYPE_DUPLICATE, IssueLink.TYPE_RELATED):
|
||||
flash('Choose how the two issues are related.', 'warning')
|
||||
return redirect(_view_url(issue_id))
|
||||
|
||||
raw_target = (request.form.get('linked_issue_id') or '').strip().lstrip('#')
|
||||
if not raw_target.isdigit():
|
||||
flash('Enter the number of the issue to link, e.g. 412.', 'warning')
|
||||
return redirect(_view_url(issue_id))
|
||||
target_id = int(raw_target)
|
||||
|
||||
if target_id == issue.id:
|
||||
flash('An issue cannot be linked to itself.', 'warning')
|
||||
return redirect(_view_url(issue_id))
|
||||
|
||||
target = db.session.get(Issue, target_id)
|
||||
# A 404 and a 403 are the same message here on purpose: whether an issue
|
||||
# outside your scope EXISTS is not something the link box should confirm.
|
||||
if target is None or not _issue_readable_by(target, current_user):
|
||||
flash(f'Issue #{target_id} was not found.', 'warning')
|
||||
return redirect(_view_url(issue_id))
|
||||
|
||||
if IssueLink.exists_between(issue.id, target.id):
|
||||
flash(f'Issue #{issue.id} and #{target.id} are already linked.', 'info')
|
||||
return redirect(_view_url(issue_id))
|
||||
|
||||
link = IssueLink(
|
||||
issue_id = issue.id,
|
||||
linked_issue_id = target.id,
|
||||
link_type = link_type,
|
||||
created_by = current_user.id,
|
||||
)
|
||||
db.session.add(link)
|
||||
db.session.commit()
|
||||
|
||||
log_action(ACTION_UPDATE, 'Issue', issue.id, f'#{issue.id}',
|
||||
f'linked to #{target.id} as {link_type}')
|
||||
current_app.logger.info(
|
||||
'ISSUE LINK | issue_id=%s | linked_issue_id=%s | type=%s | user=%s',
|
||||
issue.id, target.id, link_type, current_user.username,
|
||||
)
|
||||
flash(f'Issue #{issue.id} is now linked to #{target.id}.', 'success')
|
||||
return redirect(_view_url(issue_id))
|
||||
|
||||
|
||||
@bp.route('/<int:issue_id>/links/<int:link_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
def remove_link(issue_id, link_id):
|
||||
"""Remove a link. Either end of it may do this."""
|
||||
issue = db.session.get(Issue, issue_id)
|
||||
if issue is None:
|
||||
abort(404)
|
||||
if not _issue_readable_by(issue, current_user):
|
||||
abort(403)
|
||||
if not _can_manage_links(issue, current_user):
|
||||
abort(403)
|
||||
|
||||
link = db.session.get(IssueLink, link_id)
|
||||
# The link must actually touch THIS issue. Without the check, anyone able to
|
||||
# manage links on any one issue could delete a link between two others by
|
||||
# posting its id here.
|
||||
if link is None or issue.id not in (link.issue_id, link.linked_issue_id):
|
||||
flash('That link no longer exists.', 'info')
|
||||
return redirect(_view_url(issue_id))
|
||||
|
||||
other_id = link.linked_issue_id if link.issue_id == issue.id else link.issue_id
|
||||
db.session.delete(link)
|
||||
db.session.commit()
|
||||
|
||||
log_action(ACTION_UPDATE, 'Issue', issue.id, f'#{issue.id}',
|
||||
f'unlinked from #{other_id}')
|
||||
current_app.logger.info(
|
||||
'ISSUE UNLINK | issue_id=%s | linked_issue_id=%s | user=%s',
|
||||
issue.id, other_id, current_user.username,
|
||||
)
|
||||
flash(f'Removed the link to issue #{other_id}.', 'info')
|
||||
return redirect(_view_url(issue_id))
|
||||
|
||||
|
||||
@bp.route('/<int:issue_id>/link-search')
|
||||
@login_required
|
||||
def link_search(issue_id):
|
||||
"""JSON candidates for the link picker.
|
||||
|
||||
Scoped exactly like the issue list, so an inspector or customer can only
|
||||
find issues they could already open — searching must not become a way to
|
||||
enumerate another contract's issues. The results are a convenience; the POST
|
||||
in add_link() re-checks access and is the real boundary.
|
||||
"""
|
||||
issue = db.session.get(Issue, issue_id)
|
||||
if issue is None:
|
||||
abort(404)
|
||||
if not _issue_readable_by(issue, current_user):
|
||||
abort(403)
|
||||
|
||||
term = (request.args.get('q') or '').strip().lstrip('#')
|
||||
if len(term) < 1:
|
||||
return jsonify({'results': []})
|
||||
|
||||
q = (
|
||||
Issue.query
|
||||
.outerjoin(Area, Issue.area_id == Area.id)
|
||||
.options(joinedload(Issue.facility), contains_eager(Issue.area))
|
||||
.filter(Issue.id != issue.id)
|
||||
)
|
||||
|
||||
scope = _viewer_facility_scope(current_user)
|
||||
if scope is not None:
|
||||
if not scope:
|
||||
return jsonify({'results': []})
|
||||
q = q.filter(db.or_(
|
||||
Issue.facility_id.in_(scope),
|
||||
db.and_(Issue.area_id.isnot(None), Area.facility_id.in_(scope)),
|
||||
))
|
||||
|
||||
# Exclude issues already linked in either direction — offering them only
|
||||
# produces an "already linked" flash.
|
||||
linked_ids = {other.id for _l, other, _lbl in _readable_links(issue, current_user)}
|
||||
if linked_ids:
|
||||
q = q.filter(Issue.id.notin_(linked_ids))
|
||||
|
||||
if term.isdigit():
|
||||
# A number is almost always an issue number, so match the id first and
|
||||
# fall back to the description for things like "Room 204".
|
||||
q = q.filter(db.or_(Issue.id == int(term),
|
||||
Issue.description.ilike(f'%{term}%')))
|
||||
else:
|
||||
q = q.filter(Issue.description.ilike(f'%{term}%'))
|
||||
|
||||
matches = q.order_by(Issue.reported_at.desc()).limit(10).all()
|
||||
|
||||
return jsonify({'results': [
|
||||
{
|
||||
'id': i.id,
|
||||
'description': (i.description or '')[:110],
|
||||
'status': (i.status or '').replace('_', ' ').title(),
|
||||
'severity': (i.severity or '').title(),
|
||||
'location': (i.area.name if i.area
|
||||
else i.resolved_facility.name if i.resolved_facility
|
||||
else '—'),
|
||||
'reported_at': i.reported_at.strftime('%Y-%m-%d') if i.reported_at else '',
|
||||
}
|
||||
for i in matches
|
||||
]})
|
||||
|
||||
|
||||
# ── Follow ────────────────────────────────────────────────────────────────────
|
||||
@@ -668,7 +951,7 @@ def follow(issue_id):
|
||||
flash('You are now following this issue and will receive notifications for any updates.', 'success')
|
||||
else:
|
||||
flash('You are already following this issue.', 'info')
|
||||
return redirect(url_for('issues.view', issue_id=issue_id))
|
||||
return redirect(_view_url(issue_id))
|
||||
|
||||
|
||||
# ── Unfollow ──────────────────────────────────────────────────────────────────
|
||||
@@ -725,10 +1008,12 @@ def create():
|
||||
else:
|
||||
facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all()
|
||||
projects = Project.query.filter_by(active=True).order_by(Project.name).all()
|
||||
staff = User.query.filter(User.role.in_(['director', 'inspector', 'auditor'])).order_by(User.username).all()
|
||||
staff = User.query.filter(User.role.in_(['director', 'inspector', 'external_inspector', 'auditor'])).order_by(User.username).all()
|
||||
|
||||
form.facility_id.choices = [(f.id, f.name) for f in facilities]
|
||||
form.assigned_to.choices = [(0, '— Unassigned —')] + [(u.id, u.display_name) for u in staff]
|
||||
form.assigned_to.choices = [(0, '— Unassigned —')] + [
|
||||
(u.id, _assignee_label(u)) for u in staff
|
||||
]
|
||||
|
||||
# On POST validation error: identify which contract the submitted facility
|
||||
# belongs to so the contract selector can be restored on re-render.
|
||||
@@ -752,6 +1037,25 @@ def create():
|
||||
reported_at = now_eastern(),
|
||||
reported_by = current_user.id,
|
||||
)
|
||||
|
||||
# "Handled By" — staff only; customer-created issues stay internal.
|
||||
# (phase44) Previously the create form carried no handler fields at all,
|
||||
# so a handler chosen here was silently discarded and had to be re-entered
|
||||
# on the update form.
|
||||
if current_user.role != 'customer':
|
||||
handler = form.handler_type.data or 'internal'
|
||||
if handler not in ('internal', 'facility', 'vendor'):
|
||||
handler = 'internal'
|
||||
issue.handler_type = handler
|
||||
issue.facility_handler_name = (form.facility_handler_name.data or '').strip() or None
|
||||
issue.facility_handler_contact = (form.facility_handler_contact.data or '').strip() or None
|
||||
issue.facility_handler_notes = (form.facility_handler_notes.data or '').strip() or None
|
||||
issue.vendor_name = (form.vendor_name.data or '').strip() or None
|
||||
issue.vendor_contact = (form.vendor_contact.data or '').strip() or None
|
||||
issue.vendor_notes = (form.vendor_notes.data or '').strip() or None
|
||||
issue.internal_handler_name = (form.internal_handler_name.data or '').strip() or None
|
||||
issue.internal_handler_contact = (form.internal_handler_contact.data or '').strip() or None
|
||||
|
||||
db.session.add(issue)
|
||||
db.session.commit()
|
||||
current_app.logger.info(
|
||||
@@ -801,10 +1105,11 @@ def create():
|
||||
)
|
||||
db.session.commit()
|
||||
flash('Issue created.', 'success')
|
||||
return redirect(url_for('issues.index'))
|
||||
return redirect(return_url(url_for('issues.index')))
|
||||
|
||||
return render_template('issues/form.html', form=form, title='Log New Issue',
|
||||
projects=projects, selected_project_id=selected_project_id)
|
||||
projects=projects, selected_project_id=selected_project_id,
|
||||
issue_handler_descriptions=Issue.HANDLER_DESCRIPTIONS)
|
||||
|
||||
|
||||
# ── Supervisor verify resolved issue ─────────────────────────────────────────
|
||||
@@ -820,7 +1125,7 @@ def verify(issue_id):
|
||||
|
||||
if issue.status not in ('resolved', 'pending_verification'):
|
||||
flash('Only resolved or pending-verification issues can be verified.', 'warning')
|
||||
return redirect(url_for('issues.view', issue_id=issue_id))
|
||||
return redirect(_view_url(issue_id))
|
||||
|
||||
note = request.form.get('verification_note', '').strip() or None
|
||||
|
||||
@@ -840,7 +1145,21 @@ def verify(issue_id):
|
||||
f'#{issue_id} in {issue.area.name if issue.area else issue.resolved_facility.name if issue.resolved_facility else '—'}',
|
||||
f'verified_by={current_user.username}')
|
||||
flash(f'Issue #{issue_id} verified and closed.', 'success')
|
||||
return redirect(url_for('issues.view', issue_id=issue_id))
|
||||
return redirect(_view_url(issue_id))
|
||||
|
||||
|
||||
def _view_url(issue_id):
|
||||
"""issues.view URL that carries the list `next` through.
|
||||
|
||||
An update posted from the detail page redirects back to that same detail
|
||||
page; without re-attaching `next`, the Back button would lose the filters
|
||||
the user arrived with and the next action from this page would too. Only
|
||||
added when there is something to carry, so ordinary links stay clean.
|
||||
"""
|
||||
nxt = request.form.get('next') or request.args.get('next')
|
||||
if nxt:
|
||||
return url_for('issues.view', issue_id=issue_id, next=nxt)
|
||||
return url_for('issues.view', issue_id=issue_id)
|
||||
|
||||
|
||||
@bp.route('/bulk-verify', methods=['POST'])
|
||||
@@ -875,7 +1194,205 @@ def bulk_verify():
|
||||
f'bulk_verified_by={current_user.username}')
|
||||
|
||||
flash(f'{verified_count} issue{"s" if verified_count != 1 else ""} verified and closed.', 'success')
|
||||
return redirect(url_for('issues.verification_queue'))
|
||||
# Reachable from BOTH the verification queue and the issues list, so honour
|
||||
# the caller's `next` and fall back to the queue as before.
|
||||
return redirect(return_url(url_for('issues.verification_queue')))
|
||||
|
||||
|
||||
# ── Bulk actions from the issues list ────────────────────────────────────────
|
||||
|
||||
#: Statuses a bulk status change may set, and what an issue must already be in
|
||||
#: for the change to mean anything. Moving an issue to the state it is already
|
||||
#: in is a no-op, so it counts as skipped rather than changed.
|
||||
_BULK_STATUSES = ('open', 'in_progress', 'resolved', 'pending_verification')
|
||||
|
||||
|
||||
@bp.route('/bulk', methods=['POST'])
|
||||
@login_required
|
||||
def bulk_action():
|
||||
"""Apply one action to every ticked issue on the list page.
|
||||
|
||||
Partial-failure policy (matches bulk_verify): act on every eligible row,
|
||||
skip the rest, and report exact counts — never silently drop rows, and
|
||||
never let one ineligible row block the batch.
|
||||
|
||||
Permission is checked per ACTION here rather than per row: all four actions
|
||||
are manager-level, and the roles that hold them have org-wide issue access,
|
||||
so there is no per-row scope question to answer. `skipped` therefore only
|
||||
ever means "this row was not in a state the action applies to".
|
||||
"""
|
||||
back = return_url(url_for('issues.index'))
|
||||
action = request.form.get('action', '')
|
||||
ids = request.form.getlist('issue_ids', type=int)
|
||||
|
||||
if not ids:
|
||||
flash('No issues selected.', 'warning')
|
||||
return redirect(back)
|
||||
|
||||
manager = current_user.role in ('admin', 'director', 'auditor')
|
||||
deleter = current_user.role in ('admin', 'director')
|
||||
|
||||
allowed = {
|
||||
'assign': manager,
|
||||
'status': manager,
|
||||
'verify': manager,
|
||||
'delete': deleter,
|
||||
}
|
||||
if action not in allowed:
|
||||
flash('Unknown bulk action.', 'danger')
|
||||
return redirect(back)
|
||||
if not allowed[action]:
|
||||
flash('You do not have permission for that bulk action.', 'danger')
|
||||
return redirect(back)
|
||||
|
||||
issues = [i for i in (db.session.get(Issue, i_id) for i_id in ids) if i is not None]
|
||||
missing = len(ids) - len(issues)
|
||||
changed = 0
|
||||
skipped = missing
|
||||
|
||||
# ── Assign ───────────────────────────────────────────────────────────
|
||||
if action == 'assign':
|
||||
raw = request.form.get('assigned_to', '')
|
||||
user = None
|
||||
if raw and raw != '0':
|
||||
user = db.session.get(User, int(raw)) if raw.isdigit() else None
|
||||
if user is None:
|
||||
flash('That user no longer exists.', 'danger')
|
||||
return redirect(back)
|
||||
|
||||
# Track what actually moved. Re-deriving this after the commit by
|
||||
# testing `issue.assigned_to == user.id` would also match the issues
|
||||
# that were ALREADY assigned to that person — they were counted as
|
||||
# skipped, but would still be emailed "assigned to you" every time
|
||||
# anyone ran a bulk assign over them.
|
||||
newly_assigned = []
|
||||
for issue in issues:
|
||||
if issue.assigned_to == (user.id if user else None):
|
||||
skipped += 1
|
||||
continue
|
||||
issue.assigned_to = user.id if user else None
|
||||
newly_assigned.append(issue)
|
||||
changed += 1
|
||||
db.session.commit()
|
||||
|
||||
if user:
|
||||
for issue in newly_assigned:
|
||||
notify(
|
||||
recipient = user,
|
||||
title = f'Issue #{issue.id} assigned to you',
|
||||
body = (f'{issue.severity.title()}-severity issue at '
|
||||
f'{issue.resolved_facility.name if issue.resolved_facility else "—"}: '
|
||||
f'{issue.description[:120]}'),
|
||||
link = url_for('issues.view', issue_id=issue.id),
|
||||
issue_id = issue.id,
|
||||
event_type = EVENT_ISSUE_ASSIGNED,
|
||||
send_email = True,
|
||||
)
|
||||
db.session.commit() # notify() does not commit — rule 70
|
||||
|
||||
label = user.display_name if user else 'Unassigned'
|
||||
log_action(ACTION_UPDATE, 'Issue', None, f'bulk assign → {label}',
|
||||
f'ids={[i.id for i in issues]}; changed={changed}')
|
||||
_flash_bulk(changed, skipped, f'assigned to {label}')
|
||||
|
||||
# ── Status ───────────────────────────────────────────────────────────
|
||||
elif action == 'status':
|
||||
new_status = request.form.get('status', '')
|
||||
if new_status not in _BULK_STATUSES:
|
||||
flash('Please choose a status to set.', 'warning')
|
||||
return redirect(back)
|
||||
|
||||
# (issue, old_status) for the audit pass, which must run AFTER the
|
||||
# commit — log_action() commits internally (rule 41), so calling it
|
||||
# inside this loop would commit each row separately and lose the
|
||||
# batch's atomicity.
|
||||
moved = []
|
||||
for issue in issues:
|
||||
if issue.status == new_status:
|
||||
skipped += 1
|
||||
continue
|
||||
old = issue.status
|
||||
moved.append((issue, old))
|
||||
issue.status = new_status
|
||||
# Keep resolved_at consistent with the status, the same way the
|
||||
# single-issue update does — a resolved issue with no resolved_at
|
||||
# breaks the SLA compliance report and the aging buckets.
|
||||
if new_status == 'resolved' and not issue.resolved_at:
|
||||
issue.resolved_at = now_eastern()
|
||||
elif new_status in ('open', 'in_progress'):
|
||||
issue.resolved_at = None
|
||||
changed += 1
|
||||
db.session.commit()
|
||||
for issue, old in moved:
|
||||
log_action(ACTION_UPDATE, 'Issue', issue.id, f'#{issue.id}',
|
||||
f'bulk status {old} → {new_status} by {current_user.username}')
|
||||
_flash_bulk(changed, skipped,
|
||||
f'set to {new_status.replace("_", " ").title()}')
|
||||
|
||||
# ── Verify & close ───────────────────────────────────────────────────
|
||||
elif action == 'verify':
|
||||
verified = []
|
||||
for issue in issues:
|
||||
if issue.status not in ('resolved', 'pending_verification'):
|
||||
skipped += 1
|
||||
continue
|
||||
issue.status = 'resolved'
|
||||
issue.verified_by = current_user.id
|
||||
issue.verified_at = now_eastern()
|
||||
if not issue.resolved_at:
|
||||
issue.resolved_at = now_eastern()
|
||||
verified.append(issue)
|
||||
changed += 1
|
||||
db.session.commit()
|
||||
for issue in verified: # after the commit — rule 41
|
||||
log_action(ACTION_UPDATE, 'Issue', issue.id, f'#{issue.id}',
|
||||
f'bulk_verified_by={current_user.username}')
|
||||
_flash_bulk(changed, skipped, 'verified and closed',
|
||||
skip_reason='not awaiting verification')
|
||||
|
||||
# ── Delete ───────────────────────────────────────────────────────────
|
||||
elif action == 'delete':
|
||||
from app.utils import storage
|
||||
photo_paths = []
|
||||
# Snapshot the ids BEFORE deleting — the objects are expired after the
|
||||
# commit, and the audit pass has to run after it (rule 41: log_action
|
||||
# commits internally, so auditing inside this loop would commit the
|
||||
# deletes one at a time and, on a mid-loop failure, leave rows gone
|
||||
# with the photo cleanup below never reached).
|
||||
deleted_ids = []
|
||||
for issue in issues:
|
||||
if issue.photo_path:
|
||||
photo_paths.append(issue.photo_path)
|
||||
for lst in (issue.mobile_photo_paths, issue.result_photos):
|
||||
if lst:
|
||||
photo_paths.extend(lst)
|
||||
deleted_ids.append(issue.id)
|
||||
db.session.delete(issue)
|
||||
changed += 1
|
||||
db.session.commit()
|
||||
for issue_id in deleted_ids:
|
||||
log_action(ACTION_DELETE, 'Issue', issue_id, f'#{issue_id}',
|
||||
f'bulk deleted by {current_user.username}')
|
||||
# Files go only after the rows are safely gone — a failure here leaves
|
||||
# an orphaned file, which is recoverable; the reverse is not.
|
||||
for rel_path in photo_paths:
|
||||
storage.delete(rel_path)
|
||||
_flash_bulk(changed, skipped, 'permanently deleted')
|
||||
|
||||
logger.info('ISSUES | bulk | action=%s user=%s selected=%s changed=%s skipped=%s',
|
||||
action, current_user.username, len(ids), changed, skipped)
|
||||
return redirect(back)
|
||||
|
||||
|
||||
def _flash_bulk(changed, skipped, verb, skip_reason='no change needed'):
|
||||
"""One consistent result message for every bulk action."""
|
||||
if not changed and not skipped:
|
||||
flash('Nothing to do.', 'info')
|
||||
return
|
||||
parts = [f'{changed} issue{"s" if changed != 1 else ""} {verb}']
|
||||
if skipped:
|
||||
parts.append(f'{skipped} skipped ({skip_reason})')
|
||||
flash('. '.join(parts) + '.', 'success' if changed else 'warning')
|
||||
|
||||
|
||||
@bp.route('/<int:issue_id>/request-verification', methods=['POST'])
|
||||
@@ -897,11 +1414,11 @@ def request_verification(issue_id):
|
||||
)
|
||||
if not can_act:
|
||||
flash('Access denied.', 'danger')
|
||||
return redirect(url_for('issues.view', issue_id=issue_id))
|
||||
return redirect(_view_url(issue_id))
|
||||
|
||||
if issue.status not in ('in_progress',):
|
||||
flash('Issue must be in progress to request verification.', 'warning')
|
||||
return redirect(url_for('issues.view', issue_id=issue_id))
|
||||
return redirect(_view_url(issue_id))
|
||||
|
||||
issue.status = 'pending_verification'
|
||||
db.session.commit()
|
||||
@@ -929,7 +1446,7 @@ def request_verification(issue_id):
|
||||
)
|
||||
db.session.commit()
|
||||
flash('Issue marked as pending verification. Supervisors have been notified.', 'info')
|
||||
return redirect(url_for('issues.view', issue_id=issue_id))
|
||||
return redirect(_view_url(issue_id))
|
||||
|
||||
# ── Verification queue ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1022,7 +1539,7 @@ def delete(issue_id):
|
||||
f'facility={facility_name}; description={issue_desc}')
|
||||
|
||||
flash(f'Issue #{issue_id_snap} has been permanently deleted.', 'success')
|
||||
return redirect(url_for('issues.index'))
|
||||
return redirect(return_url(url_for('issues.index')))
|
||||
|
||||
|
||||
# ── Quick-assign (AJAX) ───────────────────────────────────────────────────────
|
||||
@@ -1091,7 +1608,7 @@ def export_pdf(issue_id):
|
||||
if issue is None:
|
||||
abort(404)
|
||||
|
||||
if current_user.role == 'inspector':
|
||||
if current_user.is_inspector:
|
||||
fids = get_inspector_scope(current_user)
|
||||
facility = issue.resolved_facility
|
||||
if not fids or not facility or facility.id not in fids:
|
||||
@@ -1114,6 +1631,20 @@ def export_pdf(issue_id):
|
||||
|
||||
|
||||
# ── Vendor work order dispatch (phase36) ─────────────────────────────────────
|
||||
#
|
||||
# NOT LINKED FROM THE UI (Aug 2026). The "Contractor Work Orders" card was
|
||||
# removed from the issue detail page, so nothing posts here any more. The
|
||||
# endpoint is kept deliberately rather than deleted:
|
||||
#
|
||||
# * it is the ONLY way to create a work order, so removing it would strand
|
||||
# the public contractor pages (/work-orders/<token>), the model, the email
|
||||
# template and the phase36 migration — a whole feature, not dead code;
|
||||
# * tests/test_work_orders.py drives the end-to-end flow through it.
|
||||
#
|
||||
# To bring the feature back, restore the card in templates/issues/view.html —
|
||||
# nothing here needs to change. To retire it for good, remove this route, the
|
||||
# work_orders blueprint, its templates, the model and those tests together,
|
||||
# and only once no tokenized links are still outstanding with contractors.
|
||||
|
||||
@bp.route('/<int:issue_id>/work-order', methods=['POST'])
|
||||
@login_required
|
||||
|
||||
@@ -308,6 +308,118 @@ def check_score_trends():
|
||||
return jsonify({'ok': True, 'alerts_sent': sent})
|
||||
|
||||
|
||||
# ── Photo retention purge (called by cron) ────────────────────────────────────
|
||||
|
||||
@bp.route('/purge-old-photos', methods=['POST'])
|
||||
@csrf.exempt
|
||||
def purge_old_photos():
|
||||
"""Delete photo FILES (not the issue records) for issues resolved longer
|
||||
ago than PHOTO_RETENTION_DAYS, addressing GDPR Art. 5(1)(e) storage
|
||||
limitation — evidence photos otherwise persist forever.
|
||||
|
||||
Disabled by default (no-op) unless PHOTO_RETENTION_DAYS is set in config/
|
||||
env — this is a data-minimization policy the operator opts into, not a
|
||||
forced deletion, since some deployments may have a longer required
|
||||
retention for their own contractual/audit reasons.
|
||||
|
||||
Only touches RESOLVED issues whose resolved_at predates the cutoff.
|
||||
Clears photo_path / mobile_photo_paths / result_photos to null/empty and
|
||||
deletes the underlying files via the storage abstraction (safe on both
|
||||
the local and R2 backends, and tenant-prefix aware — storage.delete()
|
||||
resolves the same key the write used).
|
||||
|
||||
PER-TENANT, deliberately. Unlike /trial-reminders and /dunning-reminders
|
||||
(which walk the control DB), this operates on `issues` in whichever tenant
|
||||
DB the request resolves to, and the retention window is that tenant's own
|
||||
policy. So it must be invoked once per tenant Host, exactly like
|
||||
/check-sla and /check-score-trends. A cross-tenant variant would have to
|
||||
read each tenant's own retention setting, which does not exist yet.
|
||||
|
||||
Recommended cron schedule — nightly is sufficient, per tenant host:
|
||||
|
||||
0 4 * * * curl -s -X POST https://lts.jqc.app/notifications/purge-old-photos \\
|
||||
-d "token=YOUR_DIGEST_SECRET"
|
||||
"""
|
||||
token = request.form.get('token') or request.args.get('token')
|
||||
expected = current_app.config.get('DIGEST_SECRET')
|
||||
|
||||
if not expected or token != expected:
|
||||
logger.warning('PHOTO PURGE REJECTED | bad or missing token')
|
||||
abort(403)
|
||||
|
||||
retention_days = current_app.config.get('PHOTO_RETENTION_DAYS')
|
||||
if not retention_days:
|
||||
return jsonify({'ok': True, 'skipped': 'PHOTO_RETENTION_DAYS not configured',
|
||||
'issues_purged': 0})
|
||||
|
||||
from datetime import timedelta
|
||||
from app.models.issue import Issue
|
||||
from app.utils.time_utils import now_eastern
|
||||
from app.utils.audit import log_action, ACTION_UPDATE
|
||||
from app.utils import storage
|
||||
|
||||
cutoff = now_eastern() - timedelta(days=int(retention_days))
|
||||
candidates = (
|
||||
Issue.query
|
||||
.filter(Issue.status == 'resolved')
|
||||
.filter(Issue.resolved_at.isnot(None))
|
||||
.filter(Issue.resolved_at < cutoff)
|
||||
.filter(
|
||||
db.or_(
|
||||
Issue.photo_path.isnot(None),
|
||||
Issue.mobile_photo_paths.isnot(None),
|
||||
Issue.result_photos.isnot(None),
|
||||
)
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
purged_count = 0
|
||||
for issue in candidates:
|
||||
keys = []
|
||||
if issue.photo_path:
|
||||
keys.append(issue.photo_path)
|
||||
keys.extend(issue.mobile_photo_paths or [])
|
||||
keys.extend(issue.result_photos or [])
|
||||
|
||||
# Skip rows that hold no actual photo keys. The or_() above cannot do
|
||||
# this on its own: db.JSON defaults to none_as_null=False, so a Python
|
||||
# None assigned to mobile_photo_paths / result_photos is persisted as
|
||||
# the JSON scalar `null` — which is NOT SQL NULL and therefore still
|
||||
# satisfies isnot(None). Without this guard the endpoint would (a) count
|
||||
# and rewrite every old resolved issue even when it has no photos, and
|
||||
# (b) never become idempotent: clearing the fields writes JSON `null`
|
||||
# again, so the next nightly run would re-select the very same rows
|
||||
# forever, churning UPDATEs and logging a purge that did nothing.
|
||||
if not keys:
|
||||
continue
|
||||
|
||||
for key in keys:
|
||||
try:
|
||||
storage.delete(key)
|
||||
except Exception as exc:
|
||||
logger.warning('PHOTO PURGE | failed to delete key=%s issue_id=%s: %s',
|
||||
key, issue.id, exc)
|
||||
issue.photo_path = None
|
||||
issue.mobile_photo_paths = None
|
||||
issue.result_photos = None
|
||||
purged_count += 1
|
||||
|
||||
db.session.commit()
|
||||
|
||||
if purged_count:
|
||||
log_action(
|
||||
ACTION_UPDATE, 'Issue', None,
|
||||
f'Photo retention purge — {purged_count} resolved issue(s)',
|
||||
f'cutoff={cutoff.strftime("%Y-%m-%d %H:%M:%S")}; retention_days={retention_days}',
|
||||
)
|
||||
|
||||
logger.info('PHOTO PURGE TRIGGERED | issues_purged=%s | retention_days=%s',
|
||||
purged_count, retention_days)
|
||||
return jsonify({'ok': True, 'issues_purged': purged_count,
|
||||
'retention_days': retention_days})
|
||||
|
||||
|
||||
# ── Trial-ending reminder (called by cron) ────────────────────────────────────
|
||||
|
||||
@bp.route('/trial-reminders', methods=['POST'])
|
||||
|
||||
+43
-31
@@ -57,7 +57,8 @@ def index():
|
||||
# Inspectors get a scoped view of their own inspections and related issues.
|
||||
# Customers get a facility-scoped view.
|
||||
# Internal management roles (director+) get the full unscoped view.
|
||||
if current_user.role not in ['admin', 'director', 'project_manager', 'inspector', 'customer']:
|
||||
if current_user.role not in ['admin', 'director', 'project_manager', 'inspector',
|
||||
'external_inspector', 'customer']:
|
||||
from flask import flash, redirect, url_for
|
||||
flash('Access denied.', 'danger')
|
||||
return redirect(url_for('dashboard.index'))
|
||||
@@ -66,30 +67,36 @@ def index():
|
||||
|
||||
# Resolve scoping for customers (facility list) and inspectors (inspector_id)
|
||||
customer_facility_ids = get_customer_scope(current_user) # None = unrestricted
|
||||
is_inspector = current_user.role == 'inspector'
|
||||
is_inspector = current_user.is_inspector
|
||||
|
||||
# Inspector filter — admin / director / project_manager only
|
||||
inspector_filter = None
|
||||
if current_user.role in ('admin', 'director', 'project_manager'):
|
||||
inspector_filter = request.args.get('inspector_id', type=int) or None
|
||||
|
||||
# Pre-compute inspection ID sets used by _scope_issue to avoid join conflicts.
|
||||
inspector_inspection_ids = [] # own inspections (inspector role)
|
||||
filter_inspection_ids = None # filtered inspector's inspections (admin/dir/PM)
|
||||
# Scope issues by the relevant inspector's inspections, as a SUBQUERY rather
|
||||
# than a materialised id list. The previous form pulled every inspection id
|
||||
# that inspector had ever performed into Python and sent them straight back
|
||||
# as a literal IN (1, 2, 3, ... N): the round trip is wasted, the statement
|
||||
# grows without bound with the inspector's history, and a long enough list
|
||||
# eventually trips max_allowed_packet. A subquery is also still a single
|
||||
# statement, so the "avoid join conflicts" reason for pre-computing holds.
|
||||
#
|
||||
# IN (empty subquery) already matches nothing, so the explicit empty-list
|
||||
# guards the old code needed are gone rather than merely moved.
|
||||
inspector_insp_subq = None
|
||||
if is_inspector:
|
||||
inspector_inspection_ids = [
|
||||
row[0] for row in
|
||||
inspector_insp_subq = (
|
||||
db.session.query(Inspection.id)
|
||||
.filter(Inspection.inspector_id == current_user.id)
|
||||
.all()
|
||||
]
|
||||
.scalar_subquery()
|
||||
)
|
||||
elif inspector_filter:
|
||||
filter_inspection_ids = [
|
||||
row[0] for row in
|
||||
inspector_insp_subq = (
|
||||
db.session.query(Inspection.id)
|
||||
.filter(Inspection.inspector_id == inspector_filter)
|
||||
.all()
|
||||
]
|
||||
.scalar_subquery()
|
||||
)
|
||||
|
||||
def _scope_insp(q):
|
||||
if is_inspector:
|
||||
@@ -103,14 +110,8 @@ def index():
|
||||
return q
|
||||
|
||||
def _scope_issue(q):
|
||||
if is_inspector:
|
||||
if not inspector_inspection_ids:
|
||||
return q.filter(False)
|
||||
return q.filter(Issue.inspection_id.in_(inspector_inspection_ids))
|
||||
if filter_inspection_ids is not None:
|
||||
if not filter_inspection_ids:
|
||||
return q.filter(False)
|
||||
return q.filter(Issue.inspection_id.in_(filter_inspection_ids))
|
||||
if inspector_insp_subq is not None:
|
||||
return q.filter(Issue.inspection_id.in_(inspector_insp_subq))
|
||||
if customer_facility_ids is not None:
|
||||
if not customer_facility_ids:
|
||||
return q.filter(False)
|
||||
@@ -262,7 +263,8 @@ def index():
|
||||
|
||||
inspectors = []
|
||||
if current_user.role in ('admin', 'director', 'project_manager'):
|
||||
inspectors = User.query.filter_by(role='inspector', active=True)\
|
||||
inspectors = User.query.filter(User.role.in_(User.INSPECTOR_ROLES),
|
||||
User.active == True)\
|
||||
.order_by(User.full_name, User.username).all()
|
||||
|
||||
facility_scores_list = [{
|
||||
@@ -307,7 +309,8 @@ def index():
|
||||
@bp.route('/facility/<int:facility_id>')
|
||||
@login_required
|
||||
def facility_report(facility_id):
|
||||
if current_user.role not in ['admin', 'director', 'project_manager', 'inspector', 'customer']:
|
||||
if current_user.role not in ['admin', 'director', 'project_manager', 'inspector',
|
||||
'external_inspector', 'customer']:
|
||||
from flask import flash, redirect, url_for
|
||||
flash('Access denied.', 'danger')
|
||||
return redirect(url_for('dashboard.index'))
|
||||
@@ -320,7 +323,7 @@ def facility_report(facility_id):
|
||||
from flask import flash, redirect, url_for
|
||||
flash('Access denied.', 'danger')
|
||||
return redirect(url_for('reports.index'))
|
||||
if current_user.role == 'inspector':
|
||||
if current_user.is_inspector:
|
||||
# Inspectors may only view the facility report for facilities where
|
||||
# they have personally conducted at least one inspection.
|
||||
has_access = Inspection.query.filter_by(
|
||||
@@ -369,7 +372,8 @@ def facility_report(facility_id):
|
||||
def facility_scorecard(facility_id):
|
||||
"""Comprehensive per-facility scorecard: score trend, SLA compliance,
|
||||
issue breakdown by severity, inspection frequency."""
|
||||
if current_user.role not in ['admin', 'director', 'project_manager', 'inspector', 'customer']:
|
||||
if current_user.role not in ['admin', 'director', 'project_manager', 'inspector',
|
||||
'external_inspector', 'customer']:
|
||||
from flask import flash, redirect, url_for
|
||||
flash('Access denied.', 'danger')
|
||||
return redirect(url_for('dashboard.index'))
|
||||
@@ -385,7 +389,7 @@ def facility_scorecard(facility_id):
|
||||
flash('Access denied.', 'danger')
|
||||
return redirect(url_for('reports.index'))
|
||||
|
||||
if current_user.role == 'inspector':
|
||||
if current_user.is_inspector:
|
||||
has_access = Inspection.query.filter_by(
|
||||
facility_id=facility_id,
|
||||
inspector_id=current_user.id,
|
||||
@@ -701,7 +705,8 @@ def _build_inspector_stats(start, end):
|
||||
all_ids = set(total_map.keys())
|
||||
active_inspectors = (
|
||||
User.query
|
||||
.filter(User.id.in_(all_ids), User.active == True, User.role == 'inspector')
|
||||
.filter(User.id.in_(all_ids), User.active == True,
|
||||
User.role.in_(User.INSPECTOR_ROLES))
|
||||
.order_by(User.full_name, User.username)
|
||||
.all()
|
||||
) if all_ids else []
|
||||
@@ -717,6 +722,10 @@ def _build_inspector_stats(start, end):
|
||||
inspector_stats.append({
|
||||
'id': u.id,
|
||||
'display_name': u.display_name,
|
||||
# MT-15 — customer / third-party inspectors appear in the same
|
||||
# table as the tenant's own crew, badged so the numbers can be read
|
||||
# in context. Consumed by the HTML table and the export.
|
||||
'external': u.is_external_inspector,
|
||||
'total': tot,
|
||||
'completed': comp,
|
||||
'completion_rate': round(comp / tot * 100) if tot else 0,
|
||||
@@ -759,7 +768,7 @@ def inspector_performance():
|
||||
|
||||
if selected_id:
|
||||
selected_inspector = db.session.get(User, selected_id)
|
||||
if selected_inspector and selected_inspector.role == 'inspector':
|
||||
if selected_inspector and selected_inspector.is_inspector:
|
||||
selected_kpis = next((s for s in inspector_stats if s['id'] == selected_id), None)
|
||||
|
||||
trend_rows = db.session.query(
|
||||
@@ -857,7 +866,7 @@ def export_inspector_performance():
|
||||
.filter(
|
||||
Inspection.inspection_date >= start,
|
||||
Inspection.inspection_date <= end,
|
||||
User.role == 'inspector',
|
||||
User.role.in_(User.INSPECTOR_ROLES),
|
||||
)
|
||||
if selected_id:
|
||||
detail_q = detail_q.filter(Inspection.inspector_id == selected_id)
|
||||
@@ -931,7 +940,10 @@ def export_inspector_performance():
|
||||
for row_idx, s in enumerate(inspector_stats, start=3):
|
||||
stripe = sub_fill if row_idx % 2 == 0 else None
|
||||
row_data = [
|
||||
s['display_name'],
|
||||
# Customer-employed inspectors share this table with our own crew;
|
||||
# suffixed rather than given a column so the index-based styling
|
||||
# below (score = col 5, vs_avg = col 6, …) stays correct.
|
||||
s['display_name'] + (' (Customer)' if s.get('external') else ''),
|
||||
s['total'],
|
||||
s['completed'],
|
||||
s['completion_rate'],
|
||||
@@ -1628,7 +1640,7 @@ def facility_summary_pdf(facility_id):
|
||||
cids = get_customer_scope(current_user) or []
|
||||
if facility_id not in cids:
|
||||
abort(403)
|
||||
elif current_user.role == 'inspector':
|
||||
elif current_user.is_inspector:
|
||||
has = Inspection.query.filter_by(facility_id=facility_id,
|
||||
inspector_id=current_user.id).first()
|
||||
if not has:
|
||||
|
||||
+266
-30
@@ -12,7 +12,7 @@ from app.models.support import (SupportTicket, SupportTicketReply,
|
||||
from app.models.user import User
|
||||
from app.models.facility import Facility
|
||||
from app.utils.decorators import supervisor_required
|
||||
from app.utils.scope import get_customer_scope
|
||||
from app.utils.scope import get_customer_scope, get_inspector_scope
|
||||
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE
|
||||
from app.utils.time_utils import now_eastern
|
||||
from app.utils.notifications import notify
|
||||
@@ -20,6 +20,38 @@ from app.utils.notifications import notify
|
||||
bp = Blueprint('support', __name__, url_prefix='/support')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── Outbound PII redaction ────────────────────────────────────────────────────
|
||||
# Groq is a THIRD PARTY. Customers routinely paste contact details (their own,
|
||||
# or a coworker's) into a support question, and none of that needs to leave the
|
||||
# app to get a helpful, generic answer. This scrubs a best-effort set of PII
|
||||
# patterns from the copy of the text sent to Groq ONLY — the original is still
|
||||
# stored verbatim in support_chat_messages, so the customer's own conversation
|
||||
# history reads normally in the app and staff see what was actually said.
|
||||
#
|
||||
# Best-effort by design: over-redacting a support question costs nothing, while
|
||||
# under-redacting leaks a real address. Order matters — the 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 ────────────────────────────────────────────────────────
|
||||
|
||||
_SYSTEM_PROMPT = """\
|
||||
@@ -37,6 +69,12 @@ Help customers with:
|
||||
|
||||
Rules:
|
||||
- Keep answers concise (3-5 sentences max) and friendly.
|
||||
- Ground answers in everything above, INCLUDING the "ADDITIONAL KNOWLEDGE" section when \
|
||||
one is present — that section is curated by the provider's team and is authoritative. \
|
||||
If it answers the question, use it.
|
||||
- When the knowledge above contains a link (URL), email address or exact wording, quote \
|
||||
it EXACTLY as written. Repeating something given to you here is not inventing — do it \
|
||||
freely. Never alter a URL, shorten it, or replace it with a description.
|
||||
- Never invent specific staff names, contract prices, schedules, or contact numbers.
|
||||
- If the customer has an access problem, billing question, or a concern you genuinely \
|
||||
cannot resolve through guidance, say so clearly and suggest they click \
|
||||
@@ -53,21 +91,161 @@ FAQS = [
|
||||
{'icon': 'bi-megaphone', 'text': 'How do I report a cleaning concern?'},
|
||||
{'icon': 'bi-alarm', 'text': 'What is SLA and how does it work?'},
|
||||
{'icon': 'bi-bell', 'text': 'How do I get notified on issue updates?'},
|
||||
{'icon': 'bi-phone', 'text': 'Can our own staff use the JQC app to conduct inspections?'},
|
||||
]
|
||||
|
||||
#: Extra chips shown to a Customer Inspector, whose questions are about doing
|
||||
#: the work rather than reading the results. Appended to FAQS, not replacing
|
||||
#: them — they still care about scores and issues.
|
||||
INSPECTOR_FAQS = [
|
||||
{'icon': 'bi-clipboard-plus', 'text': 'How do I start an inspection on the iPad?'},
|
||||
{'icon': 'bi-wifi-off', 'text': 'What happens if I lose signal during an inspection?'},
|
||||
{'icon': 'bi-flag', 'text': 'How do I flag an issue while inspecting?'},
|
||||
{'icon': 'bi-search', 'text': "Why can't I see a form for this facility?"},
|
||||
]
|
||||
|
||||
|
||||
#: Groq model used when GROQ_MODEL is unset. Verified available Aug 2026.
|
||||
#: Groq RETIRES models without notice, and when the configured one disappears
|
||||
#: every question fails with the generic "problem reaching the AI assistant"
|
||||
#: reply — invisible until a customer complains. That is exactly how
|
||||
#: llama-3.3-70b-versatile took the chat down. See the error handler in
|
||||
#: chat_message(): it names the model and says to set GROQ_MODEL, which fixes
|
||||
#: it with an env change and a restart — no deploy.
|
||||
_DEFAULT_GROQ_MODEL = 'openai/gpt-oss-120b'
|
||||
|
||||
|
||||
def _is_customer_side(user):
|
||||
"""True for both customer-side roles — Director and Customer Inspector.
|
||||
|
||||
The AI assistant and the ticket flow are for the CUSTOMER organisation, and
|
||||
a Customer Inspector is part of it: they work at the customer's facilities
|
||||
and have the same questions about scores, issues and the app. This is one
|
||||
of the few places where User.CUSTOMER_ROLES is the right test; every
|
||||
capability/scoping decision below still branches per role (see
|
||||
_support_facilities and _system_prompt_for) — the two roles get the same
|
||||
DOOR, not the same answers.
|
||||
"""
|
||||
return getattr(user, 'is_customer_account', False)
|
||||
|
||||
|
||||
def _support_facilities(user):
|
||||
"""The facilities this user may pick on a support ticket.
|
||||
|
||||
Directors are scoped by CustomerAssignment, Customer Inspectors by
|
||||
InspectorAssignment — reusing the customer helper for both would silently
|
||||
return nothing for an inspector (it returns None for any non-'customer'
|
||||
role) and the facility dropdown would come up empty.
|
||||
"""
|
||||
if getattr(user, 'is_inspector', False):
|
||||
fids = get_inspector_scope(user) or []
|
||||
else:
|
||||
fids = get_customer_scope(user) or []
|
||||
if not fids:
|
||||
return []
|
||||
return (Facility.query
|
||||
.filter(Facility.id.in_(fids), Facility.active == True)
|
||||
.order_by(Facility.name).all())
|
||||
|
||||
|
||||
#: Appended to the system prompt for a Customer Inspector. The base prompt is
|
||||
#: written for the read-mostly portal customer and explicitly tells the model
|
||||
#: NOT to describe staff actions; without this the assistant would deny a
|
||||
#: Customer Inspector the very things they are employed to do.
|
||||
_INSPECTOR_ADDENDUM = """
|
||||
|
||||
=== ABOUT THE PERSON YOU ARE TALKING TO: CUSTOMER INSPECTOR ===
|
||||
This user works FOR the customer but holds an inspecting role in JQC, limited to
|
||||
the contracts they have been assigned. This section OVERRIDES the "only describe
|
||||
what a customer can do" restriction above, for this user only.
|
||||
|
||||
Everything above about the portal still applies to their assigned facilities. IN
|
||||
ADDITION, they can:
|
||||
- Conduct inspections themselves — start one on the web (Inspections -> New
|
||||
Inspection) or in the JQC iPad app, fill in the checklist form, add photos, and
|
||||
submit it.
|
||||
- Use the iPad app OFFLINE: inspections and photos are stored on the device and
|
||||
sync automatically when back online.
|
||||
- Flag an issue during an inspection, and log new issues at their facilities.
|
||||
- Assign an issue to an inspector working on the SAME contract (their own
|
||||
colleagues, or the provider's inspectors) — never to anyone outside it.
|
||||
- Update an issue's status, add comments, and set "Handled By"
|
||||
(Janitorial Staff / Facility Staff / External Vendor) from the iPad.
|
||||
- Work from Scheduled Inspections assigned to them.
|
||||
|
||||
They CANNOT: verify or close out issues (the provider's admin/director does that),
|
||||
manage users, create or edit inspection forms, change the notification matrix, or
|
||||
see anything outside their assigned contracts. If they ask for one of those, say
|
||||
who to ask instead — their own Customer Director, or the provider's team via
|
||||
"Submit to Support".
|
||||
|
||||
Note on forms: the inspection forms they can choose from are the shared standard
|
||||
forms plus any built specifically for their contract. A form built for a different
|
||||
customer will never appear.
|
||||
"""
|
||||
|
||||
|
||||
#: The curated knowledge is spliced in immediately BEFORE this heading, not
|
||||
#: appended after it. The rules under it say "ground answers in everything
|
||||
#: above", so knowledge appended after them was, by the prompt's own
|
||||
#: instruction, out of scope — which is exactly why admin KB entries appeared
|
||||
#: to be ignored. Keep this marker in sync with the heading in _SYSTEM_PROMPT.
|
||||
_STYLE_MARKER = 'Rules:'
|
||||
|
||||
|
||||
def _system_prompt_for(user):
|
||||
"""Base prompt + curated knowledge, plus the addendum for this user's role.
|
||||
|
||||
Kept separate from _system_prompt_with_kb() so the curated knowledge base
|
||||
still lands at the same marker regardless of role.
|
||||
"""
|
||||
prompt = _system_prompt_with_kb()
|
||||
if getattr(user, 'is_external_inspector', False):
|
||||
prompt += _INSPECTOR_ADDENDUM
|
||||
return prompt
|
||||
|
||||
|
||||
def _system_prompt_with_kb():
|
||||
"""Return the Groq system prompt, appending active knowledge base entries."""
|
||||
"""Return the Groq system prompt with active knowledge entries spliced in.
|
||||
|
||||
Best-effort — a knowledge-base failure never breaks the chat.
|
||||
"""
|
||||
try:
|
||||
entries = SupportKnowledge.query.filter_by(active=True).order_by(SupportKnowledge.id).all()
|
||||
except Exception:
|
||||
entries = (SupportKnowledge.query.filter_by(active=True)
|
||||
.order_by(SupportKnowledge.sort_order.asc(),
|
||||
SupportKnowledge.id.asc()).all())
|
||||
if not entries:
|
||||
logger.info('SUPPORT | KB | no active entries — base prompt only')
|
||||
return _SYSTEM_PROMPT
|
||||
|
||||
parts = ['=== ADDITIONAL KNOWLEDGE (curated by the provider team; authoritative '
|
||||
'— prefer it over general guesses, and quote any link in it exactly) ===']
|
||||
total = 0
|
||||
used = 0
|
||||
for e in entries:
|
||||
block = f'\n\nTopic: {e.title}\n{(e.body or "").strip()}'
|
||||
if total + len(block) > _KB_MAX_CHARS:
|
||||
logger.warning('SUPPORT | KB | %d of %d entries dropped — %d char cap '
|
||||
'reached', len(entries) - used, len(entries), _KB_MAX_CHARS)
|
||||
break
|
||||
parts.append(block)
|
||||
total += len(block)
|
||||
used += 1
|
||||
kb_block = ''.join(parts)
|
||||
|
||||
idx = _SYSTEM_PROMPT.find(_STYLE_MARKER)
|
||||
if idx == -1: # marker renamed — fall back to append
|
||||
logger.warning('SUPPORT | KB | style marker not found; appending at end')
|
||||
prompt = f'{_SYSTEM_PROMPT}\n\n{kb_block}'
|
||||
else:
|
||||
prompt = f'{_SYSTEM_PROMPT[:idx]}{kb_block}\n\n{_SYSTEM_PROMPT[idx:]}'
|
||||
|
||||
logger.info('SUPPORT | KB | %d/%d entries injected (%d chars), prompt=%d chars',
|
||||
used, len(entries), total, len(prompt))
|
||||
return prompt
|
||||
except Exception as exc:
|
||||
logger.warning('SUPPORT | knowledge-base load failed: %s', exc)
|
||||
return _SYSTEM_PROMPT
|
||||
if not entries:
|
||||
return _SYSTEM_PROMPT
|
||||
kb_text = '\n\n'.join(f'[{e.title}]\n{e.body}' for e in entries)
|
||||
if len(kb_text) > _KB_MAX_CHARS:
|
||||
kb_text = kb_text[:_KB_MAX_CHARS] + '\n…(truncated)'
|
||||
return _SYSTEM_PROMPT + '\n\n# Additional Context\n' + kb_text
|
||||
|
||||
|
||||
# ── Customer chat page ────────────────────────────────────────────────────────
|
||||
@@ -75,13 +253,10 @@ def _system_prompt_with_kb():
|
||||
@bp.route('/chat')
|
||||
@login_required
|
||||
def chat():
|
||||
if current_user.role != 'customer':
|
||||
if not _is_customer_side(current_user):
|
||||
return redirect(url_for('support.admin_tickets'))
|
||||
|
||||
cids = get_customer_scope(current_user) or []
|
||||
facilities = (Facility.query
|
||||
.filter(Facility.id.in_(cids), Facility.active == True)
|
||||
.order_by(Facility.name).all()) if cids else []
|
||||
facilities = _support_facilities(current_user)
|
||||
|
||||
groq_ready = bool(os.environ.get('GROQ_API_KEY'))
|
||||
session_id = request.args.get('session_id', type=int)
|
||||
@@ -96,8 +271,9 @@ def chat():
|
||||
if chat_session:
|
||||
db_history = list(chat_session.messages)
|
||||
|
||||
faqs = (FAQS + INSPECTOR_FAQS) if current_user.is_external_inspector else FAQS
|
||||
return render_template('support/chat.html',
|
||||
faqs=FAQS,
|
||||
faqs=faqs,
|
||||
facilities=facilities,
|
||||
groq_ready=groq_ready,
|
||||
chat_session=chat_session,
|
||||
@@ -109,7 +285,7 @@ def chat():
|
||||
@bp.route('/chat/message', methods=['POST'])
|
||||
@login_required
|
||||
def chat_message():
|
||||
if current_user.role != 'customer':
|
||||
if not _is_customer_side(current_user):
|
||||
return jsonify({'error': 'Forbidden'}), 403
|
||||
|
||||
api_key = os.environ.get('GROQ_API_KEY')
|
||||
@@ -158,12 +334,14 @@ def chat_message():
|
||||
from groq import Groq
|
||||
client = Groq(api_key=api_key)
|
||||
|
||||
messages = [{'role': 'system', 'content': _system_prompt_with_kb()}]
|
||||
messages = [{'role': 'system', 'content': _system_prompt_for(current_user)}]
|
||||
# Redact before the text leaves the app for Groq. The unredacted
|
||||
# originals are persisted below, so nothing is lost in-app.
|
||||
for m in prior[-20:]:
|
||||
messages.append({'role': m.role, 'content': m.content})
|
||||
messages.append({'role': 'user', 'content': user_message})
|
||||
messages.append({'role': m.role, 'content': _redact_pii(m.content)})
|
||||
messages.append({'role': 'user', 'content': _redact_pii(user_message)})
|
||||
|
||||
model = os.environ.get('GROQ_MODEL', 'llama-3.3-70b-versatile')
|
||||
model = os.environ.get('GROQ_MODEL', _DEFAULT_GROQ_MODEL)
|
||||
completion = client.chat.completions.create(
|
||||
model=model,
|
||||
messages=messages,
|
||||
@@ -184,7 +362,18 @@ def chat_message():
|
||||
return jsonify({'reply': reply, 'session_id': chat_session.id})
|
||||
|
||||
except Exception as exc:
|
||||
logger.error('SUPPORT | Groq error: %s', exc)
|
||||
# Always name the model — a bare "Groq error" gives whoever reads the
|
||||
# log nothing to act on, and a retired model is the most likely cause
|
||||
# of a total outage here.
|
||||
_model = locals().get('model') or os.environ.get('GROQ_MODEL', _DEFAULT_GROQ_MODEL)
|
||||
if 'model_not_found' in str(exc) or 'does not exist' in str(exc):
|
||||
logger.error(
|
||||
'SUPPORT | Groq model %r is not available on this account — '
|
||||
'the assistant is DOWN for every user. Set GROQ_MODEL to a '
|
||||
'current model (see https://console.groq.com/docs/models). '
|
||||
'Underlying error: %s', _model, exc)
|
||||
else:
|
||||
logger.error('SUPPORT | Groq error (model=%r): %s', _model, exc)
|
||||
db.session.rollback()
|
||||
return jsonify({'reply': (
|
||||
"I ran into a problem reaching the AI assistant. "
|
||||
@@ -197,7 +386,7 @@ def chat_message():
|
||||
@bp.route('/my-conversations')
|
||||
@login_required
|
||||
def my_conversations():
|
||||
if current_user.role != 'customer':
|
||||
if not _is_customer_side(current_user):
|
||||
abort(403)
|
||||
sessions = (SupportChatSession.query
|
||||
.filter_by(customer_id=current_user.id)
|
||||
@@ -209,7 +398,7 @@ def my_conversations():
|
||||
@bp.route('/my-conversations/<int:session_id>')
|
||||
@login_required
|
||||
def my_conversation_detail(session_id):
|
||||
if current_user.role != 'customer':
|
||||
if not _is_customer_side(current_user):
|
||||
abort(403)
|
||||
chat_session = db.session.get(SupportChatSession, session_id)
|
||||
if chat_session is None or chat_session.customer_id != current_user.id:
|
||||
@@ -225,7 +414,7 @@ def my_conversation_detail(session_id):
|
||||
@bp.route('/tickets', methods=['POST'])
|
||||
@login_required
|
||||
def submit_ticket():
|
||||
if current_user.role != 'customer':
|
||||
if not _is_customer_side(current_user):
|
||||
abort(403)
|
||||
|
||||
subject = request.form.get('subject', '').strip()
|
||||
@@ -236,8 +425,9 @@ def submit_ticket():
|
||||
flash('Please fill in both subject and description.', 'warning')
|
||||
return redirect(url_for('support.chat'))
|
||||
|
||||
# Validate facility belongs to this customer
|
||||
cids = get_customer_scope(current_user) or []
|
||||
# Validate the facility belongs to this user — by whichever assignment
|
||||
# table their role is scoped through.
|
||||
cids = [f.id for f in _support_facilities(current_user)]
|
||||
if facility_id and facility_id not in cids:
|
||||
facility_id = None
|
||||
|
||||
@@ -267,7 +457,7 @@ def submit_ticket():
|
||||
@bp.route('/my-tickets')
|
||||
@login_required
|
||||
def my_tickets():
|
||||
if current_user.role != 'customer':
|
||||
if not _is_customer_side(current_user):
|
||||
abort(403)
|
||||
|
||||
tickets = (SupportTicket.query
|
||||
@@ -282,7 +472,7 @@ def my_tickets():
|
||||
@bp.route('/my-tickets/<int:ticket_id>', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def my_ticket_detail(ticket_id):
|
||||
if current_user.role != 'customer':
|
||||
if not _is_customer_side(current_user):
|
||||
abort(403)
|
||||
|
||||
ticket = db.session.get(SupportTicket, ticket_id)
|
||||
@@ -482,10 +672,53 @@ def admin_conversation_detail(session_id):
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def admin_knowledge():
|
||||
entries = SupportKnowledge.query.order_by(SupportKnowledge.created_at.desc()).all()
|
||||
# Same order the chat prompt uses, so the admin list shows the real
|
||||
# priority rather than a different one.
|
||||
entries = (SupportKnowledge.query
|
||||
.order_by(SupportKnowledge.sort_order.asc(),
|
||||
SupportKnowledge.id.asc()).all())
|
||||
return render_template('support/admin_knowledge.html', entries=entries)
|
||||
|
||||
|
||||
@bp.route('/admin/knowledge/preview')
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def admin_knowledge_preview():
|
||||
"""Show the exact system prompt the chatbot receives, knowledge included.
|
||||
|
||||
Added after admin entries appeared to be ignored: without this there is no
|
||||
way to tell "my entry never reached the prompt" from "the model saw it and
|
||||
chose not to use it". Read-only, builds nothing of its own — it calls the
|
||||
same _system_prompt_with_kb() the chat endpoint calls.
|
||||
"""
|
||||
prompt = _system_prompt_with_kb()
|
||||
active_count = SupportKnowledge.query.filter_by(active=True).count()
|
||||
total_count = SupportKnowledge.query.count()
|
||||
return render_template('support/admin_knowledge_preview.html',
|
||||
prompt=prompt,
|
||||
active_count=active_count,
|
||||
total_count=total_count,
|
||||
kb_included='=== ADDITIONAL KNOWLEDGE' in prompt,
|
||||
kb_cap=_KB_MAX_CHARS)
|
||||
|
||||
|
||||
def _parse_sort_order(raw, fallback=0):
|
||||
"""Coerce a submitted sort_order to a sane int.
|
||||
|
||||
The column is NOT NULL, so a blank or non-numeric field must not reach the
|
||||
DB. Clamped to 0..9999 to match the range ST validates, and falls back to
|
||||
the existing value on edit so a blank field means "leave it alone" rather
|
||||
than silently resetting the entry to the top.
|
||||
"""
|
||||
raw = (raw or '').strip()
|
||||
if not raw:
|
||||
return fallback
|
||||
try:
|
||||
return max(0, min(9999, int(raw)))
|
||||
except (TypeError, ValueError):
|
||||
return fallback
|
||||
|
||||
|
||||
@bp.route('/admin/knowledge/add', methods=['POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
@@ -500,6 +733,7 @@ def admin_knowledge_add():
|
||||
title = title,
|
||||
body = body,
|
||||
active = True,
|
||||
sort_order = _parse_sort_order(request.form.get('sort_order')),
|
||||
created_by = current_user.id,
|
||||
created_at = now_eastern(),
|
||||
updated_at = now_eastern(),
|
||||
@@ -527,6 +761,8 @@ def admin_knowledge_edit(entry_id):
|
||||
return redirect(url_for('support.admin_knowledge_edit', entry_id=entry_id))
|
||||
entry.title = title
|
||||
entry.body = body
|
||||
entry.sort_order = _parse_sort_order(request.form.get('sort_order'),
|
||||
entry.sort_order)
|
||||
entry.updated_at = now_eastern()
|
||||
db.session.commit()
|
||||
log_action(ACTION_UPDATE, 'SupportKnowledge', entry.id, title[:60], 'edited')
|
||||
|
||||
+64
-11
@@ -2,7 +2,8 @@ import logging
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify, abort
|
||||
from flask_login import login_required, current_user
|
||||
from app import db
|
||||
from app.models.inspection import InspectionTemplate, ChecklistItem
|
||||
from app.models.inspection import InspectionTemplate, ChecklistItem, TemplateContract
|
||||
from app.models.project import Project
|
||||
from app.utils.forms import InspectionTemplateForm, ChecklistItemForm
|
||||
from app.utils.decorators import supervisor_required
|
||||
import json
|
||||
@@ -13,6 +14,17 @@ bp = Blueprint('templates', __name__, url_prefix='/templates')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _populate_contract_choices(form):
|
||||
"""Contract options for the "Available on contracts" multi-select.
|
||||
|
||||
Selecting none leaves the form SHARED (usable on every contract) — that is
|
||||
the default and what every template did before phase52. See
|
||||
TemplateContract.
|
||||
"""
|
||||
contracts = Project.query.filter_by(active=True).order_by(Project.name).all()
|
||||
form.contract_ids.choices = [(p.id, p.name) for p in contracts]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Template CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -21,7 +33,12 @@ logger = logging.getLogger(__name__)
|
||||
@login_required
|
||||
def index():
|
||||
templates = InspectionTemplate.query.order_by(InspectionTemplate.name).all()
|
||||
return render_template('templates/list.html', templates=templates)
|
||||
# Contract options for the Edit Template modal's "Available on contracts"
|
||||
# picker (phase52) — this modal is the edit UI reached from the list, so it
|
||||
# needs the same control the full editor has.
|
||||
contracts = Project.query.filter_by(active=True).order_by(Project.name).all()
|
||||
return render_template('templates/list.html',
|
||||
templates=templates, contracts=contracts)
|
||||
|
||||
|
||||
@bp.route('/new', methods=['GET', 'POST'])
|
||||
@@ -29,6 +46,7 @@ def index():
|
||||
@supervisor_required
|
||||
def create_template():
|
||||
form = InspectionTemplateForm()
|
||||
_populate_contract_choices(form)
|
||||
|
||||
if form.validate_on_submit():
|
||||
template = InspectionTemplate(
|
||||
@@ -38,11 +56,15 @@ def create_template():
|
||||
created_by=current_user.id
|
||||
)
|
||||
db.session.add(template)
|
||||
db.session.flush() # need template.id before linking contracts
|
||||
template.set_contracts(form.contract_ids.data)
|
||||
db.session.commit()
|
||||
logger.info('TEMPLATES | create | user=%s | template_id=%s name=%r',
|
||||
current_user.username, template.id, template.name)
|
||||
logger.info('TEMPLATES | create | user=%s | template_id=%s name=%r contracts=%s',
|
||||
current_user.username, template.id, template.name,
|
||||
template.contract_ids or 'shared')
|
||||
log_action(ACTION_CREATE, 'Template', template.id, template.name,
|
||||
f'frequency={template.frequency}')
|
||||
f'frequency={template.frequency}; '
|
||||
f'contracts={template.contract_ids or "shared"}')
|
||||
|
||||
flash(f'Template "{template.name}" created successfully.', 'success')
|
||||
return redirect(url_for('templates.form_editor', template_id=template.id))
|
||||
@@ -72,16 +94,23 @@ def edit_template(template_id):
|
||||
if template is None:
|
||||
abort(404)
|
||||
form = InspectionTemplateForm(obj=template)
|
||||
_populate_contract_choices(form)
|
||||
if request.method == 'GET':
|
||||
# obj= cannot read the association rows; seed the multi-select from them.
|
||||
form.contract_ids.data = template.contract_ids
|
||||
|
||||
if form.validate_on_submit():
|
||||
template.name = form.name.data
|
||||
template.description = form.description.data
|
||||
template.frequency = form.frequency.data
|
||||
template.set_contracts(form.contract_ids.data)
|
||||
db.session.commit()
|
||||
logger.info('TEMPLATES | edit | user=%s | template_id=%s name=%r',
|
||||
current_user.username, template.id, template.name)
|
||||
logger.info('TEMPLATES | edit | user=%s | template_id=%s name=%r contracts=%s',
|
||||
current_user.username, template.id, template.name,
|
||||
template.contract_ids or 'shared')
|
||||
log_action(ACTION_UPDATE, 'Template', template.id, template.name,
|
||||
f'frequency={template.frequency}')
|
||||
f'frequency={template.frequency}; '
|
||||
f'contracts={template.contract_ids or "shared"}')
|
||||
flash(f'Template "{template.name}" updated successfully.', 'success')
|
||||
return redirect(url_for('templates.view_template', template_id=template.id))
|
||||
|
||||
@@ -120,11 +149,29 @@ def rename_template(template_id):
|
||||
template.description = request.form.get('description', '').strip() or None
|
||||
template.frequency = new_frequency
|
||||
|
||||
# phase52 — contract restrictions are edited from this modal too, since it
|
||||
# is the Edit Template dialog people actually reach from the list. The
|
||||
# hidden marker distinguishes "the form posted an empty selection" (make
|
||||
# the template shared) from "the form has no contracts field at all", which
|
||||
# must leave the existing restrictions untouched rather than silently
|
||||
# sharing the template with every customer.
|
||||
if request.form.get('contracts_present') == '1':
|
||||
valid_pids = {
|
||||
p.id for p in Project.query.filter_by(active=True).all()
|
||||
}
|
||||
posted = {
|
||||
pid for pid in request.form.getlist('contract_ids', type=int)
|
||||
if pid in valid_pids
|
||||
}
|
||||
template.set_contracts(posted)
|
||||
|
||||
db.session.commit()
|
||||
logger.info('TEMPLATES | rename | user=%s | template_id=%s name=%r',
|
||||
current_user.username, template.id, template.name)
|
||||
logger.info('TEMPLATES | rename | user=%s | template_id=%s name=%r contracts=%s',
|
||||
current_user.username, template.id, template.name,
|
||||
template.contract_ids or 'shared')
|
||||
log_action(ACTION_UPDATE, 'Template', template.id, template.name,
|
||||
f'frequency={template.frequency}; via=rename')
|
||||
f'frequency={template.frequency}; '
|
||||
f'contracts={template.contract_ids or "shared"}; via=rename')
|
||||
flash(f'Template "{template.name}" updated successfully.', 'success')
|
||||
return redirect(url_for('templates.index'))
|
||||
|
||||
@@ -188,6 +235,12 @@ def duplicate_template(template_id):
|
||||
db.session.add(new_tpl)
|
||||
db.session.flush() # get new_tpl.id before committing
|
||||
|
||||
# phase52 — carry the contract restrictions across. Duplicating a
|
||||
# customer's bespoke form must not produce a copy that is silently shared
|
||||
# with every other customer; copying a shared form still yields a shared
|
||||
# one (no links to copy).
|
||||
new_tpl.set_contracts(src.contract_ids)
|
||||
|
||||
# Duplicate all checklist items
|
||||
for item in src.checklist_items.order_by(ChecklistItem.display_order).all():
|
||||
new_item = ChecklistItem(
|
||||
|
||||
@@ -29,6 +29,7 @@ from app.models.tenant_settings import TenantSettings
|
||||
from app.utils.decorators import admin_required
|
||||
from app.utils.audit import log_action, ACTION_UPDATE, ACTION_CREATE, ACTION_DELETE
|
||||
from app.utils.time_utils import now_eastern
|
||||
from app.utils import storage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -43,7 +44,19 @@ _DOMAIN_RE = re.compile(
|
||||
# ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _save_logo(file_obj):
|
||||
"""Save uploaded logo to static/uploads/logos/; return relative URL or None."""
|
||||
"""Save an uploaded logo via the storage seam; return its key or None.
|
||||
|
||||
MT-22: this previously wrote straight to ``UPLOAD_FOLDER/logos/`` with
|
||||
``file_obj.save()`` — the last direct-to-disk writer in the app. On an
|
||||
R2-backed tenant the logo never reached the bucket, and on the local
|
||||
backend every tenant's logo landed in one shared directory.
|
||||
|
||||
Validation stays here by design: ``storage.py`` only moves bytes, and
|
||||
callers own extension / magic-byte checks (see its module docstring).
|
||||
|
||||
The returned key keeps the exact ``uploads/logos/<file>`` shape already
|
||||
stored in ``TenantSettings.logo_url``, so no migration and no data rewrite.
|
||||
"""
|
||||
if not file_obj or not file_obj.filename:
|
||||
return None
|
||||
allowed = {'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp'}
|
||||
@@ -63,27 +76,22 @@ def _save_logo(file_obj):
|
||||
}
|
||||
if not any(header.startswith(m) for m in magic):
|
||||
return None
|
||||
import secrets
|
||||
logos_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], 'logos')
|
||||
os.makedirs(logos_dir, exist_ok=True)
|
||||
filename = f'{secrets.token_hex(12)}.{ext}'
|
||||
file_obj.save(os.path.join(logos_dir, filename))
|
||||
return f'uploads/logos/{filename}'
|
||||
file_obj.seek(0)
|
||||
return storage.save(file_obj, 'logos')
|
||||
|
||||
|
||||
def _delete_logo(logo_url):
|
||||
"""Remove a logo file from disk. Silently ignores missing files.
|
||||
Safety guard: only deletes files inside the uploads/logos/ subfolder."""
|
||||
"""Remove a stored logo. Silently ignores missing objects.
|
||||
Safety guard: only deletes keys inside the uploads/logos/ subfolder."""
|
||||
if not logo_url:
|
||||
return # nothing stored — never issue a delete for 'uploads/logos/'
|
||||
try:
|
||||
# Use only the basename to avoid any path-traversal via the stored URL.
|
||||
# All logos are written into logos_dir by _save_logo(), so joining the
|
||||
# basename back to that directory is always the correct path.
|
||||
logos_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], 'logos')
|
||||
abs_logos = os.path.abspath(logos_dir)
|
||||
abs_path = os.path.join(abs_logos, os.path.basename(logo_url))
|
||||
if os.path.isfile(abs_path):
|
||||
os.remove(abs_path)
|
||||
logger.info('SETTINGS | logo_deleted | path=%s', abs_path)
|
||||
# All logos are written to 'uploads/logos/' by _save_logo(), so
|
||||
# rebuilding the key from the basename is always the correct target.
|
||||
key = f'uploads/logos/{os.path.basename(str(logo_url or ""))}'
|
||||
storage.delete(key)
|
||||
logger.info('SETTINGS | logo_deleted | key=%s', key)
|
||||
except Exception as exc:
|
||||
logger.warning('SETTINGS | logo_delete_failed | url=%s err=%s', logo_url, exc)
|
||||
|
||||
@@ -211,20 +219,32 @@ def plan():
|
||||
'allow_custom_domain': tenant.allow_custom_domain,
|
||||
}
|
||||
|
||||
# Live counts (tenant DB)
|
||||
# Live counts (tenant DB), one axis at a time.
|
||||
#
|
||||
# These used to be four calls inside a single dict literal in one
|
||||
# try/except. Python evaluates every value before assigning, so ONE
|
||||
# failing counter discarded the whole dict and the page rendered 0
|
||||
# for all four axes — including users and facilities, which were
|
||||
# fine. That is exactly how a wrong column name in the issues
|
||||
# counter presented as "no usage number ever updates".
|
||||
from app.tenancy.quota import (
|
||||
count_active_users, count_active_facilities,
|
||||
count_inspections_this_month, count_issues_this_month,
|
||||
)
|
||||
try:
|
||||
quota_usage = {
|
||||
'users': count_active_users(),
|
||||
'facilities': count_active_facilities(),
|
||||
'inspections': count_inspections_this_month(),
|
||||
'issues': count_issues_this_month(),
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.error('tenant_settings.plan: quota count failed: %s', exc)
|
||||
for axis, counter in (
|
||||
('users', count_active_users),
|
||||
('facilities', count_active_facilities),
|
||||
('inspections', count_inspections_this_month),
|
||||
('issues', count_issues_this_month),
|
||||
):
|
||||
try:
|
||||
quota_usage[axis] = counter()
|
||||
except Exception as exc:
|
||||
# None (not 0) so the page shows "—": an unknown count and
|
||||
# a genuine zero must not look the same.
|
||||
quota_usage[axis] = None
|
||||
logger.error('tenant_settings.plan: %s count failed: %s',
|
||||
axis, exc)
|
||||
|
||||
# MT-8: billing fields are already on g.tenant — no extra DB query needed.
|
||||
billing_enabled = current_app.config.get('BILLING_ENABLED', False)
|
||||
|
||||
@@ -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 */
|
||||
.btn,
|
||||
.nav-link,
|
||||
.dropdown-item,
|
||||
input[type="checkbox"],
|
||||
input[type="radio"],
|
||||
.form-check-input {
|
||||
.dropdown-item {
|
||||
min-height: 44px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Checkboxes and radios are deliberately NOT in the rule above.
|
||||
`display: inline-flex` on a native <input type="radio"> replaces its
|
||||
intrinsic box with a flex container: the control keeps painting its glyph
|
||||
at its natural ~16px size while the element claims a 44px-tall box, so the
|
||||
visible dot and the actual hit area stop coinciding. Taps land next to the
|
||||
control and nothing happens — which is exactly how the per-account
|
||||
notification matrix (Inherit / On / Off) came to look unclickable.
|
||||
|
||||
Grow the target without touching `display`: keep the native box, scale the
|
||||
glyph up, and give it margin so neighbouring options stay separable. Any
|
||||
control that needs a genuinely large tap area should wrap the input in a
|
||||
<label> that fills the cell (see customers/manage.html). */
|
||||
input[type="checkbox"],
|
||||
input[type="radio"],
|
||||
.form-check-input {
|
||||
transform: scale(1.35);
|
||||
margin: 6px;
|
||||
}
|
||||
|
||||
/* Slightly larger form controls for finger input */
|
||||
.form-control,
|
||||
.form-select {
|
||||
|
||||
@@ -0,0 +1,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>
|
||||
|
||||
<!-- ── 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>
|
||||
|
||||
<!-- ── Right column: edit form + recent inspections ──────────────── -->
|
||||
@@ -263,4 +284,38 @@
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Delete My Data Modal (phase51) ──────────────────────────────────── -->
|
||||
<div class="modal fade" id="deleteMyDataModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header bg-danger text-white">
|
||||
<h5 class="modal-title"><i class="bi bi-exclamation-triangle me-2"></i>Delete My Account & 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 %}
|
||||
@@ -80,7 +80,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
{# phase51 — the email-invitation branch that used to live here
|
||||
moved to Customer Management along with the Customer
|
||||
Inspector role. Every role this form still offers is our own
|
||||
staff, created with an admin-set password. #}
|
||||
<div class="row" id="passwordFields">
|
||||
<div class="col-md-6 mb-3">
|
||||
{{ form.password.label(class="form-label") }}
|
||||
{{ form.password(class="form-control", placeholder="Leave blank to keep current" if user else "") }}
|
||||
@@ -128,4 +132,5 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
@@ -37,12 +37,12 @@
|
||||
<td>{{ user.full_name or '—' }}</td>
|
||||
<td>{{ user.email }}</td>
|
||||
<td>
|
||||
<span class="badge bg-{% if user.role == 'admin' %}danger{% elif user.role == 'director' %}warning{% elif user.role == 'project_manager' %}primary{% elif user.role == 'auditor' %}secondary{% elif user.role == 'customer' %}success{% else %}info{% endif %}">
|
||||
{{ user.role.replace('_',' ')|title }}
|
||||
<span class="badge bg-{% if user.role == 'admin' %}danger{% elif user.role == 'director' %}warning{% elif user.role == 'project_manager' %}primary{% elif user.role == 'auditor' %}secondary{% elif user.role == 'customer' %}success{% elif user.role == 'external_inspector' %}dark{% else %}info{% endif %}">
|
||||
{{ user.role_label }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if user.role == 'inspector' %}
|
||||
{% if user.is_inspector %}
|
||||
{% set cnt = inspector_contract_counts.get(user.id, 0) %}
|
||||
{% if cnt > 0 %}
|
||||
<span class="badge bg-success">{{ cnt }} contract{{ 's' if cnt != 1 else '' }}</span>
|
||||
@@ -65,12 +65,23 @@
|
||||
<a href="{{ url_for('auth.edit_user', user_id=user.id) }}" class="btn btn-sm btn-outline-primary" title="Edit">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
{% if user.role == 'inspector' %}
|
||||
{% if user.is_inspector %}
|
||||
<a href="{{ url_for('auth.assign_inspector_contracts', user_id=user.id) }}"
|
||||
class="btn btn-sm btn-outline-secondary" title="Assign contracts">
|
||||
<i class="bi bi-briefcase"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if not user.password_set %}
|
||||
<form method="POST" action="{{ url_for('auth.resend_invite', user_id=user.id) }}"
|
||||
class="d-inline"
|
||||
onsubmit="return confirm('Resend the invitation email to {{ user.email }}? The previous link will stop working.');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-warning"
|
||||
title="Resend invitation email">
|
||||
<i class="bi bi-envelope-arrow-up"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if user.id != current_user.id %}
|
||||
<form method="POST" action="{{ url_for('auth.toggle_active', user_id=user.id) }}" class="d-inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
+12
-527
@@ -1,530 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<!-- iOS / iPadOS web app meta tags -->
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<title>{% block title %}Janitorial QC System{% endblock %}</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&display=swap">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/theme.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/ipad_responsive.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/mobile_phone.css') }}">
|
||||
{% if tenant_branding %}
|
||||
<style>
|
||||
:root {
|
||||
--bs-primary: {{ tenant_branding.primary_color or '#1a56db' }};
|
||||
--bs-primary-rgb: {{ tenant_branding.primary_color|hex_to_rgb if tenant_branding.primary_color else '26,86,219' }};
|
||||
--jqc-accent: {{ tenant_branding.accent_color or '#16a34a' }};
|
||||
}
|
||||
.bg-primary { background-color: {{ tenant_branding.primary_color or '#1a56db' }} !important; }
|
||||
.btn-primary { background-color: {{ tenant_branding.primary_color or '#1a56db' }} !important;
|
||||
border-color: {{ tenant_branding.primary_color or '#1a56db' }} !important; }
|
||||
</style>
|
||||
{% endif %}
|
||||
{% block extra_css %}{% endblock %}
|
||||
<style>
|
||||
/* ── Notification bell styles ── */
|
||||
.notif-bell-wrapper { position: relative; }
|
||||
.notif-badge {
|
||||
position: absolute;
|
||||
top: 2px; right: 2px;
|
||||
font-size: 0.6rem;
|
||||
min-width: 16px; height: 16px; line-height: 16px;
|
||||
padding: 0 4px; border-radius: 8px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.notif-dropdown {
|
||||
width: 380px;
|
||||
max-height: 520px;
|
||||
overflow-y: auto;
|
||||
padding: 0;
|
||||
}
|
||||
.notif-item {
|
||||
border-left: 3px solid transparent;
|
||||
transition: background 0.15s;
|
||||
cursor: pointer;
|
||||
}
|
||||
.notif-item.unread {
|
||||
border-left-color: #0d6efd;
|
||||
background-color: #f0f6ff;
|
||||
}
|
||||
.notif-item:hover { background-color: #e8f0fe; }
|
||||
.notif-title { font-size: 0.85rem; font-weight: 600; margin-bottom: 2px; }
|
||||
.notif-body { font-size: 0.78rem; color: #555; white-space: normal; }
|
||||
.notif-time { font-size: 0.7rem; color: #999; }
|
||||
.notif-empty { padding: 24px; text-align: center; color: #aaa; font-size: 0.85rem; }
|
||||
{# ────────────────────────────────────────────────────────────────────────────
|
||||
base.html — layout dispatcher (MT-16)
|
||||
|
||||
/* ── Active nav tab ── */
|
||||
.navbar-dark .navbar-nav .nav-link.active {
|
||||
background-color: rgba(255, 255, 255, 0.18);
|
||||
color: #ffffff !important;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
box-shadow: inset 0 -2px 0 rgba(255,255,255,0.6);
|
||||
}
|
||||
.navbar-dark .navbar-nav .nav-link:not(.active):hover {
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
border-radius: 6px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{% if current_user.is_authenticated %}
|
||||
<nav class="navbar navbar-expand-xxl navbar-dark bg-primary">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="{{ url_for('dashboard.index') }}">
|
||||
{% if tenant_branding and tenant_branding.logo_url %}
|
||||
<img src="{{ url_for('static', filename=tenant_branding.logo_url) }}"
|
||||
alt="{{ tenant_branding.display_name }}"
|
||||
style="max-height:32px; border-radius:4px; margin-right:.35rem;">
|
||||
{% else %}
|
||||
<i class="bi bi-clipboard-check"></i>
|
||||
{% endif %}
|
||||
{{ tenant_branding.display_name if tenant_branding else 'Janitorial QC' }}
|
||||
</a>
|
||||
<!-- ── Bell + toggler always visible on mobile/tablet ── -->
|
||||
<div class="d-flex align-items-center gap-2 ms-auto me-2 d-xxl-none">
|
||||
<!-- Notification bell (always visible) -->
|
||||
<div class="dropdown">
|
||||
<a class="nav-link position-relative notif-bell-wrapper text-white"
|
||||
href="#"
|
||||
id="notifDropdownMobile"
|
||||
role="button"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
title="Notifications">
|
||||
<i class="bi bi-bell fs-5"></i>
|
||||
{% if unread_notification_count > 0 %}
|
||||
<span class="badge bg-danger notif-badge" id="notif-count-badge-mobile">
|
||||
{{ unread_notification_count if unread_notification_count <= 99 else '99+' }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger notif-badge d-none" id="notif-count-badge-mobile"></span>
|
||||
{% endif %}
|
||||
</a>
|
||||
<div class="dropdown-menu dropdown-menu-end notif-dropdown shadow"
|
||||
id="notif-dropdown-menu-mobile">
|
||||
<div class="d-flex justify-content-between align-items-center px-3 py-2 border-bottom">
|
||||
<span class="fw-semibold" style="font-size:.9rem;">Notifications</span>
|
||||
<button class="btn btn-link btn-sm p-0 text-muted text-decoration-none mark-all-read-btn"
|
||||
style="font-size:.75rem;">Mark all as read</button>
|
||||
</div>
|
||||
<div class="notif-list-mobile">
|
||||
<div class="notif-empty">Loading…</div>
|
||||
</div>
|
||||
<div class="border-top d-flex justify-content-between px-3 py-2" style="font-size:.8rem;">
|
||||
<a href="{{ url_for('notifications.index') }}" class="text-decoration-none">
|
||||
<i class="bi bi-list-ul me-1"></i>View all
|
||||
</a>
|
||||
<a href="{{ url_for('notifications.preferences') }}" class="text-decoration-none text-muted">
|
||||
<i class="bi bi-gear me-1"></i>Preferences
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav me-auto">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('dashboard.') }}" href="{{ url_for('dashboard.index') }}">Dashboard</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint and (request.endpoint.startswith('reports.') or request.endpoint.startswith('scheduled_reports.')) }}" href="{{ url_for('reports.index') }}">Reports</a>
|
||||
</li>
|
||||
{% if current_user.role in ['admin', 'director', 'project_manager', 'auditor'] %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('projects.') }}" href="{{ url_for('projects.index') }}">Contracts</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('facilities.') }}" href="{{ url_for('facilities.list_facilities') }}">Facilities</a>
|
||||
</li>
|
||||
{% if current_user.role in ['admin', 'director'] %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('templates.') }}" href="{{ url_for('templates.index') }}">Templates</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('inspections.') }}" href="{{ url_for('inspections.index') }}">Inspections</a>
|
||||
</li>
|
||||
{% if current_user.role in ['admin', 'director', 'project_manager', 'auditor'] %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('inspection_schedules.') }}" href="{{ url_for('inspection_schedules.index') }}">Schedules</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('issues.') and request.endpoint != 'issues.verification_queue' }}" href="{{ url_for('issues.index') }}">Issues</a>
|
||||
</li>
|
||||
{% if current_user.role in ['admin', 'director', 'auditor'] %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link d-flex align-items-center gap-1 {{ 'active' if request.endpoint == 'issues.verification_queue' }}"
|
||||
href="{{ url_for('issues.verification_queue') }}">
|
||||
Verify
|
||||
{% if pending_verification_count and pending_verification_count > 0 %}
|
||||
<span class="badge bg-info text-dark"
|
||||
style="font-size:.65rem;line-height:1;">
|
||||
{{ pending_verification_count }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if current_user.role in ['admin', 'director'] %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('customers.') }}" href="{{ url_for('customers.index') }}">Customers</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if current_user.role in ['admin', 'director'] %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('support.') }}"
|
||||
href="{{ url_for('support.admin_tickets') }}">
|
||||
Support
|
||||
{% if open_support_tickets_count > 0 %}
|
||||
<span class="badge bg-danger ms-1">{{ open_support_tickets_count }}</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if current_user.role == 'customer' %}
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle {{ 'active' if request.endpoint and request.endpoint.startswith('support.') }}"
|
||||
href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<i class="bi bi-chat-dots me-1"></i>Support
|
||||
</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li>
|
||||
<a class="dropdown-item" href="{{ url_for('support.chat') }}">
|
||||
<i class="bi bi-chat-dots me-2"></i>Ask a Question
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{{ url_for('support.my_conversations') }}">
|
||||
<i class="bi bi-clock-history me-2"></i>My Conversations
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{{ url_for('support.my_tickets') }}">
|
||||
<i class="bi bi-inbox me-2"></i>My Requests
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if current_user.role == 'admin' %}
|
||||
{% set admin_active = request.endpoint and (
|
||||
(request.endpoint.startswith('auth.') and 'user' in request.endpoint)
|
||||
or request.endpoint.startswith('audit.')
|
||||
or request.endpoint == 'auth.notification_matrix'
|
||||
or request.endpoint.startswith('broadcast.')
|
||||
or request.endpoint.startswith('devices.')
|
||||
or request.endpoint.startswith('tenant_settings.')
|
||||
) %}
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle {{ 'active' if admin_active }}"
|
||||
href="#" id="adminMenu" role="button"
|
||||
data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<i class="bi bi-sliders me-1"></i>Admin
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="adminMenu">
|
||||
<li>
|
||||
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('auth.') and 'user' in request.endpoint }}"
|
||||
href="{{ url_for('auth.list_users') }}">
|
||||
<i class="bi bi-people me-2"></i>Users
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('audit.') }}"
|
||||
href="{{ url_for('audit.index') }}">
|
||||
<i class="bi bi-clipboard-data me-2"></i>Audit Trail
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item {{ 'active' if request.endpoint == 'auth.notification_matrix' }}"
|
||||
href="{{ url_for('auth.notification_matrix') }}">
|
||||
<i class="bi bi-grid-3x3-gap-fill me-2"></i>Notification Matrix
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('broadcast.') }}"
|
||||
href="{{ url_for('broadcast.index') }}">
|
||||
<i class="bi bi-megaphone-fill me-2"></i>Broadcast
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('devices.') }}"
|
||||
href="{{ url_for('devices.index') }}">
|
||||
<i class="bi bi-tablet me-2"></i>Devices
|
||||
</a>
|
||||
</li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li>
|
||||
<a class="dropdown-item {{ 'active' if request.endpoint and request.endpoint.startswith('tenant_settings.') }}"
|
||||
href="{{ url_for('tenant_settings.branding') }}">
|
||||
<i class="bi bi-gear me-2"></i>Workspace Settings
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
<ul class="navbar-nav align-items-center">
|
||||
This file used to hold the entire page chrome. That markup now lives in
|
||||
layouts/classic.html, unchanged.
|
||||
|
||||
<!-- ── Notification Bell (desktop lg+ only) ── -->
|
||||
<li class="nav-item dropdown me-2 d-none d-xxl-block">
|
||||
<a class="nav-link position-relative notif-bell-wrapper"
|
||||
href="#"
|
||||
id="notifDropdown"
|
||||
role="button"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
title="Notifications">
|
||||
<i class="bi bi-bell fs-5"></i>
|
||||
{% if unread_notification_count > 0 %}
|
||||
<span class="badge bg-danger notif-badge" id="notif-count-badge">
|
||||
{{ unread_notification_count if unread_notification_count <= 99 else '99+' }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger notif-badge d-none" id="notif-count-badge"></span>
|
||||
{% endif %}
|
||||
</a>
|
||||
<div class="dropdown-menu dropdown-menu-end notif-dropdown shadow"
|
||||
id="notif-dropdown-menu">
|
||||
<!-- Header -->
|
||||
<div class="d-flex justify-content-between align-items-center
|
||||
px-3 py-2 border-bottom">
|
||||
<span class="fw-semibold" style="font-size:.9rem;">Notifications</span>
|
||||
<button class="btn btn-link btn-sm p-0 text-muted text-decoration-none"
|
||||
id="mark-all-read-btn" style="font-size:.75rem;">
|
||||
Mark all as read
|
||||
</button>
|
||||
</div>
|
||||
<!-- Items -->
|
||||
<div id="notif-list">
|
||||
<div class="notif-empty">Loading…</div>
|
||||
</div>
|
||||
<!-- Footer -->
|
||||
<div class="border-top d-flex justify-content-between px-3 py-2"
|
||||
style="font-size:.8rem;">
|
||||
<a href="{{ url_for('notifications.index') }}"
|
||||
class="text-decoration-none">
|
||||
<i class="bi bi-list-ul me-1"></i>View all
|
||||
</a>
|
||||
<a href="{{ url_for('notifications.preferences') }}"
|
||||
class="text-decoration-none text-muted">
|
||||
<i class="bi bi-gear me-1"></i>Preferences
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<!-- ── End Notification Bell ── -->
|
||||
Every page template still says {% extends "base.html" %} and needed ZERO
|
||||
edits: Jinja resolves {% block %} overrides through the whole inheritance
|
||||
chain, so one extra link in that chain is invisible to them.
|
||||
|
||||
<!-- User menu -->
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown"
|
||||
role="button" data-bs-toggle="dropdown">
|
||||
<i class="bi bi-person-circle"></i> {{ current_user.username }}
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li>
|
||||
<a class="dropdown-item"
|
||||
href="{{ url_for('auth.profile') }}">
|
||||
<i class="bi bi-person-circle me-1"></i>My Profile
|
||||
</a>
|
||||
</li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li>
|
||||
<a class="dropdown-item"
|
||||
href="{{ url_for('notifications.preferences') }}">
|
||||
<i class="bi bi-bell-slash me-1"></i>Notification Preferences
|
||||
</a>
|
||||
</li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{{ url_for('auth.logout') }}">
|
||||
<i class="bi bi-box-arrow-right me-1"></i>Logout
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
<div class="container-fluid mt-4">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% include 'billing/_billing_banner.html' %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
{% block extra_js %}{% endblock %}
|
||||
|
||||
{% if current_user.is_authenticated %}
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const FEED_URL = '{{ url_for("notifications.feed") }}';
|
||||
const MARK_READ_BASE = '/notifications/';
|
||||
const MARK_ALL_URL = '{{ url_for("notifications.mark_all_read") }}';
|
||||
const CSRF_TOKEN = '{{ csrf_token() }}';
|
||||
const POLL_INTERVAL = 60000; // 60 seconds
|
||||
|
||||
// ── Element refs — desktop bell (lg+) and mobile/tablet bell (<lg) ──
|
||||
const badgeDesktop = document.getElementById('notif-count-badge');
|
||||
const badgeMobile = document.getElementById('notif-count-badge-mobile');
|
||||
const listDesktop = document.getElementById('notif-list');
|
||||
const listMobile = document.querySelector('.notif-list-mobile');
|
||||
|
||||
// ── Update both badge instances ────────────────────────────────────────
|
||||
function updateBadge(count) {
|
||||
[badgeDesktop, badgeMobile].forEach(function(badge) {
|
||||
if (!badge) return;
|
||||
if (count > 0) {
|
||||
badge.textContent = count > 99 ? '99+' : count;
|
||||
badge.classList.remove('d-none');
|
||||
} else {
|
||||
badge.textContent = '';
|
||||
badge.classList.add('d-none');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Render notification items into a given container ───────────────────
|
||||
function renderInto(container, notifications) {
|
||||
if (!container) return;
|
||||
if (!notifications.length) {
|
||||
container.innerHTML = '<div class="notif-empty">'
|
||||
+ '<i class="bi bi-check2-circle me-1"></i>You\'re all caught up!</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = notifications.map(function(n) {
|
||||
return '<div class="d-block text-decoration-none text-dark notif-item px-3 py-2 border-bottom '
|
||||
+ (n.is_read ? '' : 'unread') + '"'
|
||||
+ ' data-notif-id="' + n.id + '"'
|
||||
+ ' data-link="' + escapeAttr(n.link || '') + '">'
|
||||
+ '<div class="notif-title">' + escapeHtml(n.title) + '</div>'
|
||||
+ '<div class="notif-body">' + escapeHtml(n.body) + '</div>'
|
||||
+ '<div class="notif-time">' + escapeHtml(n.created_at) + '</div>'
|
||||
+ '</div>';
|
||||
}).join('');
|
||||
container.querySelectorAll('.notif-item').forEach(function(el) {
|
||||
el.addEventListener('click', function() {
|
||||
var id = this.dataset.notifId;
|
||||
var link = this.dataset.link;
|
||||
markRead(id, function() {
|
||||
el.classList.remove('unread');
|
||||
if (link) window.location.href = link;
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderNotifications(notifications) {
|
||||
renderInto(listDesktop, notifications);
|
||||
renderInto(listMobile, notifications);
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/&/g,'&').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>
|
||||
`jqc_layout` is supplied by the inject_ui_theme() context processor in
|
||||
app/__init__.py, driven by users.ui_theme ('classic' | 'modern') with
|
||||
config DEFAULT_UI_THEME as the fallback for accounts that never chose.
|
||||
──────────────────────────────────────────────────────────────────────────── #}
|
||||
{% extends jqc_layout %}
|
||||
|
||||
@@ -5,7 +5,11 @@
|
||||
<div class="row mb-4 align-items-center">
|
||||
<div class="col">
|
||||
<h2><i class="bi bi-person-badge"></i> Customer Management</h2>
|
||||
<p class="text-muted mb-0">Manage portal access for all customer accounts.</p>
|
||||
<p class="text-muted mb-0">
|
||||
Manage both customer-side roles — <strong>Customer Directors</strong>
|
||||
(portal access) and <strong>Customer Inspectors</strong> (perform
|
||||
inspections on their contracts).
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-auto d-flex gap-2">
|
||||
<a href="{{ url_for('customers.bulk_import') }}" class="btn btn-outline-success">
|
||||
@@ -55,6 +59,7 @@
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Full Name</th>
|
||||
<th>Role</th>
|
||||
<th>Email</th>
|
||||
<th>Status</th>
|
||||
<th>Assigned Contracts</th>
|
||||
@@ -65,7 +70,11 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for customer in customers %}
|
||||
{% set assignments = assignment_map[customer.id] %}
|
||||
{# Inspectors are scoped by InspectorAssignment, directors by
|
||||
CustomerAssignment — read the map that matches the role. #}
|
||||
{% set assignments = inspector_assignment_map[customer.id]
|
||||
if customer.is_inspector
|
||||
else assignment_map[customer.id] %}
|
||||
{% set facility_ids = scope_map[customer.id] %}
|
||||
<tr class="{{ 'table-secondary text-muted' if not customer.active else '' }}">
|
||||
<td>
|
||||
@@ -77,6 +86,11 @@
|
||||
</strong>
|
||||
</td>
|
||||
<td>{{ customer.full_name or '—' }}</td>
|
||||
<td>
|
||||
<span class="badge {{ 'bg-info text-dark' if customer.is_inspector else 'bg-primary' }}">
|
||||
{{ customer.role_label }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="small text-muted">{{ customer.email }}</td>
|
||||
<td>
|
||||
{% if customer.active %}
|
||||
@@ -136,6 +150,8 @@
|
||||
<div class="mt-3 text-muted small">
|
||||
{{ customers|length }} customer account{{ 's' if customers|length != 1 else '' }} total
|
||||
· {{ customers|selectattr('active')|list|length }} active
|
||||
· {{ customers|rejectattr('is_inspector')|list|length }} director{{ 's' if customers|rejectattr('is_inspector')|list|length != 1 else '' }}
|
||||
· {{ customers|selectattr('is_inspector')|list|length }} inspector{{ 's' if customers|selectattr('is_inspector')|list|length != 1 else '' }}
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<div class="mb-3">
|
||||
{{ form.email.label(class="form-label fw-semibold") }}
|
||||
{{ form.email(class="form-control" + (" is-invalid" if form.email.errors else ""),
|
||||
placeholder="jane@example.com") }}
|
||||
@@ -44,6 +44,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
{{ form.role.label(class="form-label fw-semibold") }}
|
||||
{{ form.role(class="form-select" + (" is-invalid" if form.role.errors else "")) }}
|
||||
{% for error in form.role.errors %}
|
||||
<div class="invalid-feedback">{{ error }}</div>
|
||||
{% endfor %}
|
||||
<div class="form-text">
|
||||
A <strong>Director</strong> gets portal access to their facilities'
|
||||
inspections, issues and reports. An <strong>Inspector</strong>
|
||||
performs inspections and manages issues on the contracts you assign
|
||||
them — the same tools as our own inspectors, limited to their
|
||||
contracts. You can switch an account between the two later.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-send me-1"></i>Create & Send Invitation
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
<div class="col">
|
||||
<h2>
|
||||
<i class="bi bi-person-badge"></i> {{ customer.display_name }}
|
||||
<span class="badge {{ 'bg-info text-dark' if customer.is_inspector else 'bg-primary' }} ms-2 fs-6">
|
||||
{{ customer.role_label }}
|
||||
</span>
|
||||
{% if not customer.active %}
|
||||
<span class="badge bg-secondary ms-2 fs-6">Disabled</span>
|
||||
{% else %}
|
||||
@@ -19,6 +22,20 @@
|
||||
class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-pencil"></i> Edit Account
|
||||
</a>
|
||||
|
||||
{# ── Switch role (admin only) ── #}
|
||||
{% if current_user.role == 'admin' %}
|
||||
{% set to_label = 'Customer Director' if customer.is_inspector else 'Customer Inspector' %}
|
||||
<form method="POST"
|
||||
action="{{ url_for('customers.switch_role', customer_id=customer.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-info"
|
||||
title="Change what this account can do"
|
||||
onclick="return confirm('Switch {{ customer.display_name }} from {{ customer.role_label }} to {{ to_label }}?\n\nTheir contracts are carried across.{% if not customer.is_inspector %}\n\nFacility-level limits do not exist for inspectors — an account limited to specific facilities will gain the whole contract.{% endif %}\n\nAny signed-in device will be logged out.')">
|
||||
<i class="bi bi-arrow-left-right me-1"></i> Switch to {{ to_label }}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<form method="POST"
|
||||
action="{{ url_for('customers.toggle_active', customer_id=customer.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
@@ -48,6 +65,19 @@
|
||||
<dl class="row mb-0 small">
|
||||
<dt class="col-5 text-muted">Full Name</dt>
|
||||
<dd class="col-7">{{ customer.full_name or '—' }}</dd>
|
||||
<dt class="col-5 text-muted">Role</dt>
|
||||
<dd class="col-7">
|
||||
<span class="badge {{ 'bg-info text-dark' if customer.is_inspector else 'bg-primary' }}">
|
||||
{{ customer.role_label }}
|
||||
</span>
|
||||
<div class="text-muted" style="font-size:.72rem;">
|
||||
{% if customer.is_inspector %}
|
||||
Performs inspections and manages issues on their assigned contracts.
|
||||
{% else %}
|
||||
Portal access to their facilities' inspections, issues and reports.
|
||||
{% endif %}
|
||||
</div>
|
||||
</dd>
|
||||
<dt class="col-5 text-muted">Username</dt>
|
||||
<dd class="col-7">{{ customer.username }}</dd>
|
||||
<dt class="col-5 text-muted">Email</dt>
|
||||
@@ -69,7 +99,7 @@
|
||||
<dt class="col-5 text-muted">Created</dt>
|
||||
<dd class="col-7">{{ customer.created_at.strftime('%Y-%m-%d') }}</dd>
|
||||
<dt class="col-5 text-muted">Assignments</dt>
|
||||
<dd class="col-7">{{ assignments|length }}</dd>
|
||||
<dd class="col-7">{{ assigned_pids|length if customer.is_inspector else assignments|length }}</dd>
|
||||
<dt class="col-5 text-muted">Facilities</dt>
|
||||
<dd class="col-7">{{ facilities|length }}</dd>
|
||||
</dl>
|
||||
@@ -120,6 +150,58 @@
|
||||
{# ── Right column: assignments ── #}
|
||||
<div class="col-md-8">
|
||||
|
||||
{% if customer.is_inspector %}
|
||||
{# ══ Customer Inspector — whole contracts, no facility-level narrowing ══
|
||||
Scoped by InspectorAssignment, the same rows an internal inspector uses.
|
||||
Posts the COMPLETE checked set; unchecked contracts are removed. ══ #}
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header bg-light fw-semibold d-flex justify-content-between align-items-center">
|
||||
<span><i class="bi bi-diagram-3 me-1"></i> Contract Assignments</span>
|
||||
<span class="badge bg-secondary rounded-pill" id="assignedCount">
|
||||
{{ assigned_pids|length }} assigned
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted small">
|
||||
A Customer Inspector sees only the contracts ticked here — with none
|
||||
ticked they see nothing at all. Inspectors are assigned whole
|
||||
contracts; there is no per-facility option for this role.
|
||||
</p>
|
||||
|
||||
<form method="POST"
|
||||
action="{{ url_for('customers.assign_contracts', customer_id=customer.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="mb-2">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="selectAll">Select all</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="deselectAll">Deselect all</button>
|
||||
</div>
|
||||
|
||||
{% if projects %}
|
||||
<div class="list-group list-group-flush mb-3"
|
||||
style="max-height:340px;overflow-y:auto;">
|
||||
{% for p in projects %}
|
||||
<label class="list-group-item d-flex align-items-center gap-2 py-2">
|
||||
<input class="form-check-input m-0 contract-check" type="checkbox"
|
||||
name="project_ids" value="{{ p.id }}"
|
||||
{% if p.id in assigned_pids %}checked{% endif %}>
|
||||
<span class="small">{{ p.name }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-sm">
|
||||
<i class="bi bi-check2 me-1"></i> Save Contract Assignments
|
||||
</button>
|
||||
{% else %}
|
||||
<p class="text-muted small mb-0">No active contracts exist yet.</p>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
{# ══ Customer Director — contract or single-facility assignments ══ #}
|
||||
|
||||
{# ── Current assignments table ── #}
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header bg-light fw-semibold d-flex justify-content-between align-items-center">
|
||||
@@ -213,6 +295,116 @@
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Per-account notification matrix ─────────────────────────────────
|
||||
Overrides the global Notification Matrix for THIS account only.
|
||||
"Inherit" is the default and means "follow the global column", so it
|
||||
keeps tracking future changes there — it is not a snapshot. #}
|
||||
<div class="card shadow-sm mt-4">
|
||||
<div class="card-header bg-light fw-semibold">
|
||||
<i class="bi bi-bell me-1"></i> Notifications for this account
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted small">
|
||||
Each customer's enrollment form says which notifications their people
|
||||
want, so these can differ per person. <strong>Inherit</strong> follows
|
||||
the global Notification Matrix for
|
||||
{{ customer.role_label }}s — including any later change to it. Choose
|
||||
On or Off only where this account should differ.
|
||||
</p>
|
||||
|
||||
<form method="POST"
|
||||
action="{{ url_for('customers.save_notifications', customer_id=customer.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<style>
|
||||
/* The whole cell is the control. Padding (not min-height on the
|
||||
input) gives the touch target, so the native radio keeps its
|
||||
own box — see the note in ipad_responsive.css. */
|
||||
.matrix-opt {
|
||||
display: block;
|
||||
padding: .55rem .25rem;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
}
|
||||
.matrix-opt:hover { background: rgba(13,110,253,.06); }
|
||||
.matrix-opt input { cursor: pointer; }
|
||||
.matrix-opt-sub {
|
||||
display: block;
|
||||
font-size: .62rem;
|
||||
color: #6c757d;
|
||||
margin-top: 2px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="d-flex flex-wrap align-items-center gap-2 mb-2">
|
||||
<span class="small text-muted">Set every row:</span>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" data-matrix-all="inherit">Inherit</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-success" data-matrix-all="on">On</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger" data-matrix-all="off">Off</button>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle mb-3">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Event</th>
|
||||
<th class="text-center" style="width:110px;">Inherit</th>
|
||||
<th class="text-center" style="width:70px;">On</th>
|
||||
<th class="text-center" style="width:70px;">Off</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in matrix_rows %}
|
||||
<tr>
|
||||
<td class="small">
|
||||
{{ row.label }}
|
||||
{% if row.override is not none %}
|
||||
<span class="badge bg-warning text-dark ms-1" style="font-size:.6rem;">custom</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
{# Each option is a <label> filling its whole cell, so the
|
||||
click target is the cell rather than the ~16px glyph. A
|
||||
bare <input> in a centred <td> was effectively unclickable
|
||||
at touch/narrow widths. #}
|
||||
<td class="p-0">
|
||||
<label class="matrix-opt" title="Follow the global matrix">
|
||||
<input class="form-check-input" type="radio"
|
||||
name="event_{{ row.event }}" value="inherit"
|
||||
{% if row.override is none %}checked{% endif %}>
|
||||
<span class="matrix-opt-sub">
|
||||
currently {{ 'on' if row.global else 'off' }}
|
||||
</span>
|
||||
</label>
|
||||
</td>
|
||||
<td class="p-0">
|
||||
<label class="matrix-opt" title="Always send this to this account">
|
||||
<input class="form-check-input" type="radio"
|
||||
name="event_{{ row.event }}" value="on"
|
||||
{% if row.override is true %}checked{% endif %}>
|
||||
</label>
|
||||
</td>
|
||||
<td class="p-0">
|
||||
<label class="matrix-opt" title="Never send this to this account">
|
||||
<input class="form-check-input" type="radio"
|
||||
name="event_{{ row.event }}" value="off"
|
||||
{% if row.override is false %}checked{% endif %}>
|
||||
</label>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-sm">
|
||||
<i class="bi bi-check2 me-1"></i> Save Notification Settings
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@@ -223,8 +415,41 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// ── Customer Inspector: contract checkbox helpers ──
|
||||
const checks = document.querySelectorAll('.contract-check');
|
||||
const countBadge = document.getElementById('assignedCount');
|
||||
|
||||
function refreshCount() {
|
||||
if (!countBadge) return;
|
||||
const n = document.querySelectorAll('.contract-check:checked').length;
|
||||
countBadge.textContent = n + ' assigned';
|
||||
}
|
||||
function setAll(state) {
|
||||
checks.forEach(function (c) { c.checked = state; });
|
||||
refreshCount();
|
||||
}
|
||||
const selectAll = document.getElementById('selectAll');
|
||||
const deselectAll = document.getElementById('deselectAll');
|
||||
if (selectAll) selectAll.addEventListener('click', function () { setAll(true); });
|
||||
if (deselectAll) deselectAll.addEventListener('click', function () { setAll(false); });
|
||||
checks.forEach(function (c) { c.addEventListener('change', refreshCount); });
|
||||
|
||||
// ── Notification matrix: set every row at once ──
|
||||
document.querySelectorAll('[data-matrix-all]').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
var want = btn.getAttribute('data-matrix-all');
|
||||
document.querySelectorAll('.matrix-opt input[type=radio]').forEach(function (r) {
|
||||
if (r.value === want) { r.checked = true; }
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Customer Director: contract → facility cascade ──
|
||||
// Both selects are absent on the inspector view, so bail out rather than
|
||||
// throwing on addEventListener of null (which would kill the handlers above).
|
||||
const projSelect = document.getElementById('proj-select');
|
||||
const facSelect = document.getElementById('fac-select');
|
||||
if (!projSelect || !facSelect) return;
|
||||
|
||||
projSelect.addEventListener('change', function () {
|
||||
const projectId = this.value;
|
||||
|
||||
@@ -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,51 +1,242 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ title }}{% endblock %}
|
||||
|
||||
{# Laid out to match the single-tenant scheduled-inspection form: one narrow
|
||||
card, Contract → Facility cascade at the top, then what/who, then when.
|
||||
|
||||
MT keeps three fields ST does not have — the schedule NAME (required by
|
||||
inspection_schedules.name), the AREA (a schedule may target one area) and
|
||||
the auto/plan MODE. They are placed next to the field they qualify rather
|
||||
than in a block of their own. #}
|
||||
|
||||
{# ── Sticky values ────────────────────────────────────────────────────────
|
||||
This form is hand-built (no WTForms), so a re-render after a validation
|
||||
error would otherwise come back blank and the user would retype everything.
|
||||
On POST every field reads back from request.form; otherwise from the saved
|
||||
schedule (edit) or its default (create). ST gets this free from WTForms —
|
||||
this is the equivalent. #}
|
||||
{% set posted = request.form if request.method == 'POST' else None %}
|
||||
{% set v_name = posted.get('name') if posted else (schedule.name if schedule else '') %}
|
||||
{% set v_facility = (posted.get('facility_id')|int(0)) if posted else (schedule.facility_id if schedule else 0) %}
|
||||
{% set v_area = (posted.get('area_id')|int(0)) if posted else (schedule.area_id if schedule and schedule.area_id else 0) %}
|
||||
{% set v_template = (posted.get('template_id')|int(0)) if posted else (schedule.template_id if schedule else 0) %}
|
||||
{% set v_inspector = (posted.get('inspector_id')|int(0)) if posted else (schedule.inspector_id if schedule else 0) %}
|
||||
{% set v_frequency = posted.get('frequency') if posted else (schedule.frequency if schedule else 'weekly') %}
|
||||
{% set v_due = posted.get('next_due_date') if posted else (schedule.due_date.isoformat() if schedule and schedule.due_date else '') %}
|
||||
{% set v_end = posted.get('end_date') if posted else (schedule.end_date.isoformat() if schedule and schedule.end_date else '') %}
|
||||
{% set v_mode = posted.get('mode') if posted else (schedule.mode if schedule else 'auto') %}
|
||||
{% set v_notes = posted.get('notes') if posted else (schedule.notes if schedule and schedule.notes else '') %}
|
||||
{% set v_month_mode = posted.get('month_mode') if posted else (schedule.month_mode if schedule else 'day_of_month') %}
|
||||
{% set v_dom = posted.get('day_of_month') if posted else (schedule.day_of_month if schedule and schedule.day_of_month else '') %}
|
||||
{% set v_nth_week = (posted.get('nth_week')|int(0)) if posted else (schedule.nth_week if schedule and schedule.nth_week else 0) %}
|
||||
{% set v_nth_weekday = (posted.get('nth_weekday')|int(-1)) if posted else (schedule.nth_weekday if schedule and schedule.nth_weekday is not none else -1) %}
|
||||
{% set v_weekdays = (posted.getlist('weekdays')|map('int')|list) if posted else (schedule.weekday_list if schedule else []) %}
|
||||
{% set v_active = (posted.get('active') is not none) if posted else (schedule.active if schedule else True) %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-8">
|
||||
<h2 class="mb-4"><i class="bi bi-calendar2-week"></i> {{ title }}</h2>
|
||||
|
||||
<form method="POST" class="card shadow-sm">
|
||||
<div class="col-lg-7">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-light"><h5 class="mb-0">{{ title }}</h5></div>
|
||||
<div class="card-body">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<form method="POST" novalidate>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Schedule name</label>
|
||||
<input type="text" name="name" class="form-control" required
|
||||
value="{{ schedule.name if schedule else '' }}"
|
||||
placeholder="e.g. Weekly restroom check — Main Office">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold" for="name">Schedule name</label>
|
||||
<input type="text" name="name" id="name" class="form-control" required
|
||||
value="{{ v_name }}"
|
||||
placeholder="e.g. Weekly restroom check — Main Office">
|
||||
<div class="form-text">Shown in the schedule list and in the inspector's reminder.</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Template</label>
|
||||
<select name="template_id" class="form-select" required>
|
||||
<option value="">— Choose a template —</option>
|
||||
{# Contract selector — UI only; narrows the facility list via AJAX.
|
||||
It carries no name attribute and is never submitted: the facility
|
||||
is what the route validates (rule 61). #}
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold" for="contract_select">Contract</label>
|
||||
<select id="contract_select" class="form-select">
|
||||
<option value="">— Select Contract —</option>
|
||||
{% for p in projects %}
|
||||
<option value="{{ p.id }}">{{ p.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label fw-semibold" for="facility_id">Facility</label>
|
||||
<select name="facility_id" id="facility_id" class="form-select" required>
|
||||
<option value="">— Select a Contract first —</option>
|
||||
{% for f in facilities %}
|
||||
<option value="{{ f.id }}"
|
||||
{{ 'selected' if v_facility == f.id }}>{{ f.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label fw-semibold" for="area_id">
|
||||
Area <span class="text-muted small fw-normal">(optional)</span>
|
||||
</label>
|
||||
<select name="area_id" id="area_id" class="form-select">
|
||||
<option value="">— Whole facility —</option>
|
||||
{# Refilled by JS from the chosen facility; this keeps the saved
|
||||
or just-submitted area selected until that call returns. #}
|
||||
{% if schedule and schedule.area %}
|
||||
<option value="{{ schedule.area.id }}"
|
||||
{{ 'selected' if v_area == schedule.area.id }}>{{ schedule.area.name }}</option>
|
||||
{% endif %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold" for="template_id">Inspection Form</label>
|
||||
<select name="template_id" id="template_id" class="form-select" required>
|
||||
<option value="">— Choose a form —</option>
|
||||
{% for t in templates %}
|
||||
<option value="{{ t.id }}"
|
||||
{{ 'selected' if schedule and schedule.template_id == t.id }}>{{ t.name }}</option>
|
||||
{{ 'selected' if v_template == t.id }}>{{ t.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="form-text">
|
||||
Shared forms plus any built for this contract. A form belonging to
|
||||
another contract is rejected on save, not merely hidden here.
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<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">Mode</label>
|
||||
<select name="mode" class="form-select">
|
||||
<option value="auto" {{ 'selected' if not schedule or schedule.mode != 'plan' }}>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold" for="inspector_id">Assign to inspector</label>
|
||||
<select name="inspector_id" id="inspector_id" class="form-select" required>
|
||||
<option value="">— Select a Contract first —</option>
|
||||
{% for u in inspectors %}
|
||||
<option value="{{ u.id }}"
|
||||
{{ 'selected' if v_inspector == u.id }}>
|
||||
{{ u.display_name }}{{ ' (Customer)' if u.is_external_inspector }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="form-text">
|
||||
Inspectors assigned to this contract — the person named here has
|
||||
to be able to open the inspection. Managers are never listed: a
|
||||
manager who will do the work holds a contract assignment like
|
||||
anyone else. If the contract has nobody assigned yet, every
|
||||
inspector is offered so the schedule is not blocked.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label fw-semibold" for="frequency">Frequency</label>
|
||||
<select name="frequency" id="frequency" class="form-select" required>
|
||||
{% for f in frequencies %}
|
||||
<option value="{{ f }}" {{ 'selected' if v_frequency == f }}>
|
||||
{{ frequency_labels.get(f, f|title) }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label fw-semibold" id="due_date_label" for="next_due_date">
|
||||
{{ 'Next Due Date' if schedule else 'Start Date' }}
|
||||
</label>
|
||||
<input type="date" name="next_due_date" id="next_due_date" class="form-control"
|
||||
value="{{ v_due }}">
|
||||
<div class="form-text">
|
||||
{% if schedule %}
|
||||
Snapped forward to the first matching day. Leave it unchanged
|
||||
and saving will not move it.
|
||||
{% else %}
|
||||
Snapped forward to the first matching day. Leave blank to start
|
||||
one full period from now.
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── End date ──
|
||||
Hidden for one-time schedules, which end by deactivating when
|
||||
completed. syncFrequency() toggles it; the route clears the column
|
||||
for 'once', so a stale DOM value cannot survive a frequency change. #}
|
||||
<div class="row" id="end_date_row" hidden>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label fw-semibold" for="end_date">
|
||||
End Date <span class="text-muted small fw-normal">(optional)</span>
|
||||
</label>
|
||||
<input type="date" name="end_date" id="end_date" class="form-control"
|
||||
value="{{ v_end }}">
|
||||
<div class="form-text">
|
||||
The last date this schedule may run — leave blank to repeat indefinitely.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Weekly: which days of the week ──────────────────────────── #}
|
||||
<div class="mb-3 p-3 rounded bg-light border" id="weekly_block" hidden>
|
||||
<label class="form-label fw-semibold d-block">Days of the Week</label>
|
||||
<div class="d-flex flex-wrap gap-3">
|
||||
{% for i, day in [(0,'Mon'),(1,'Tue'),(2,'Wed'),(3,'Thu'),(4,'Fri'),(5,'Sat'),(6,'Sun')] %}
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="weekdays"
|
||||
id="weekday_{{ i }}" value="{{ i }}" {{ 'checked' if i in v_weekdays }}>
|
||||
<label class="form-check-label" for="weekday_{{ i }}">{{ day }}</label>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="form-text mb-0">
|
||||
Pick every day the inspection recurs — e.g. Mon, Wed, Fri gives three
|
||||
inspections a week. The due date rolls to the next selected day each
|
||||
time one is submitted.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Monthly: day-of-month OR nth weekday ────────────────────── #}
|
||||
<div class="mb-3 p-3 rounded bg-light border" id="monthly_block" hidden>
|
||||
<label class="form-label fw-semibold d-block">Monthly rule</label>
|
||||
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="month_mode"
|
||||
id="month_mode_day" value="day_of_month"
|
||||
{% if v_month_mode != 'nth_weekday' %}checked{% endif %}>
|
||||
<label class="form-check-label" for="month_mode_day">On a day of the month</label>
|
||||
</div>
|
||||
<div class="ms-4 mb-2" id="dom_row">
|
||||
<div class="input-group input-group-sm" style="max-width:16rem;">
|
||||
<span class="input-group-text">Day</span>
|
||||
<input type="number" name="day_of_month" class="form-control"
|
||||
min="1" max="31" placeholder="15"
|
||||
value="{{ v_dom }}">
|
||||
</div>
|
||||
<div class="form-text mb-0">Months without that day use their last day.</div>
|
||||
</div>
|
||||
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="month_mode"
|
||||
id="month_mode_nth" value="nth_weekday"
|
||||
{% if v_month_mode == 'nth_weekday' %}checked{% endif %}>
|
||||
<label class="form-check-label" for="month_mode_nth">On a weekday of the month</label>
|
||||
</div>
|
||||
<div class="ms-4" id="nth_row">
|
||||
<div class="d-flex gap-2 flex-wrap" style="max-width:24rem;">
|
||||
<select name="nth_week" class="form-select form-select-sm" style="max-width:7rem;">
|
||||
{% for v, lbl in [(1,'1st'),(2,'2nd'),(3,'3rd'),(4,'4th'),(5,'5th'),(-1,'Last')] %}
|
||||
<option value="{{ v }}" {{ 'selected' if v_nth_week == v }}>{{ lbl }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<select name="nth_weekday" class="form-select form-select-sm" style="max-width:11rem;">
|
||||
{% for i, day in [(0,'Monday'),(1,'Tuesday'),(2,'Wednesday'),(3,'Thursday'),(4,'Friday'),(5,'Saturday'),(6,'Sunday')] %}
|
||||
<option value="{{ i }}" {{ 'selected' if v_nth_weekday == i }}>{{ day }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-text mb-0">e.g. the 2nd Tuesday of every month.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold" for="mode">Mode</label>
|
||||
<select name="mode" id="mode" class="form-select">
|
||||
<option value="auto" {{ 'selected' if v_mode != 'plan' }}>
|
||||
Auto — create the inspection automatically each period</option>
|
||||
<option value="plan" {{ 'selected' if schedule and schedule.mode == 'plan' }}>
|
||||
<option value="plan" {{ 'selected' if v_mode == 'plan' }}>
|
||||
Plan — inspector presses Start (with due/overdue reminders)</option>
|
||||
</select>
|
||||
<div class="form-text">
|
||||
@@ -54,108 +245,237 @@
|
||||
the day, and alerts managers once it's overdue.
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Notes for the inspector <span class="text-muted small">(optional)</span></label>
|
||||
<textarea name="notes" class="form-control" rows="2"
|
||||
placeholder="Anything the inspector should know before starting">{{ schedule.notes if schedule and schedule.notes else '' }}</textarea>
|
||||
<div class="form-text">Copied onto the inspection when it starts.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Facility</label>
|
||||
<select name="facility_id" id="facilitySelect" class="form-select" required>
|
||||
<option value="">— Choose a facility —</option>
|
||||
{% for f in facilities %}
|
||||
<option value="{{ f.id }}"
|
||||
{{ 'selected' if schedule and schedule.facility_id == f.id }}>{{ f.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div 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>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Area <span class="text-muted small">(optional)</span></label>
|
||||
<select name="area_id" id="areaSelect" class="form-select">
|
||||
<option value="">— Whole facility —</option>
|
||||
{% if schedule and schedule.area %}
|
||||
<option value="{{ schedule.area.id }}" selected>{{ schedule.area.name }}</option>
|
||||
{% endif %}
|
||||
</select>
|
||||
|
||||
{% 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>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Assign to inspector</label>
|
||||
<select name="inspector_id" class="form-select" required>
|
||||
<option value="">— Choose an inspector —</option>
|
||||
{% for u in inspectors %}
|
||||
<option value="{{ u.id }}"
|
||||
{{ 'selected' if schedule and schedule.inspector_id == u.id }}>
|
||||
{{ u.display_name }} ({{ u.role }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{% if schedule %}
|
||||
<div class="form-check form-switch mb-1">
|
||||
<input class="form-check-input" type="checkbox" name="active" id="activeSwitch"
|
||||
{{ 'checked' if schedule.active }}>
|
||||
<label class="form-check-label" for="activeSwitch">Active</label>
|
||||
</div>
|
||||
<p class="text-muted small">
|
||||
Saving recomputes the next {{ 'due date' if schedule.mode == 'plan' else 'run' }}
|
||||
from now, and resets this occurrence's reminders. Currently:
|
||||
{{ schedule.next_run_at.strftime('%Y-%m-%d %H:%M') if schedule.next_run_at else '—' }}
|
||||
{% if schedule.last_completed_at %}
|
||||
· last completed {{ schedule.last_completed_at.strftime('%Y-%m-%d %H:%M') }}
|
||||
{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="card-footer d-flex justify-content-between">
|
||||
<a href="{{ url_for('inspection_schedules.index') }}" class="btn btn-outline-secondary">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-check-lg"></i> Save Schedule
|
||||
</button>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-check-lg"></i> Save
|
||||
</button>
|
||||
<a href="{{ url_for('inspection_schedules.index') }}" class="btn btn-outline-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p class="text-muted small mt-2">
|
||||
Recurring schedules automatically roll their due date forward each time the
|
||||
inspection is completed. The assigned inspector is reminded the day before
|
||||
and on the due date; managers are alerted if it becomes overdue.
|
||||
{% if schedule %}
|
||||
<br>
|
||||
Current {{ 'due date' if schedule.mode == 'plan' else 'run' }}:
|
||||
{{ schedule.next_run_at.strftime('%Y-%m-%d %H:%M') if schedule.next_run_at else '—' }}
|
||||
{% if schedule.last_completed_at %}
|
||||
· last completed {{ schedule.last_completed_at.strftime('%Y-%m-%d %H:%M') }}
|
||||
{% endif %}
|
||||
{% if schedule.end_date %}· ends {{ schedule.end_date.strftime('%Y-%m-%d') }}{% endif %}.
|
||||
Reminders for this occurrence are only reset if the due date actually moves,
|
||||
so renaming or re-noting a schedule will not re-send them.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// Facility → Area cascade, reusing the existing inspections AJAX endpoint.
|
||||
// ── Contract → Facility → Area cascade ──────────────────────────────────────
|
||||
// The contract selector is UI-only (no name attribute): it never reaches the
|
||||
// server, it only narrows the facility list. facilities_for_project is scoped
|
||||
// to the caller, so a Customer Director asking for another contract's id gets
|
||||
// an empty list rather than that customer's building names.
|
||||
(function () {
|
||||
var facilitySelect = document.getElementById('facilitySelect');
|
||||
var areaSelect = document.getElementById('areaSelect');
|
||||
if (!facilitySelect || !areaSelect) return;
|
||||
'use strict';
|
||||
var contractSel = document.getElementById('contract_select');
|
||||
var facilitySel = document.getElementById('facility_id');
|
||||
var areaSel = document.getElementById('area_id');
|
||||
var inspectorSel = document.getElementById('inspector_id');
|
||||
if (!contractSel || !facilitySel) { return; }
|
||||
|
||||
var preselectedAreaId = {{ (schedule.area_id if schedule and schedule.area_id else 0) | tojson }};
|
||||
var FACILITIES_URL = '{{ url_for("inspections.facilities_for_project", project_id=0) }}'.replace('/0', '/');
|
||||
var INSPECTORS_URL = '{{ url_for("inspection_schedules.inspectors_for_contract", project_id=0) }}'.replace('/0', '/');
|
||||
var AREAS_URL = '{{ url_for("inspections.areas_for_facility", facility_id=0) }}'.replace('/0', '/');
|
||||
var preProjectId = {{ selected_project_id | tojson }};
|
||||
var preFacilityId = {{ v_facility | tojson }};
|
||||
var preAreaId = {{ v_area | tojson }};
|
||||
var preInspectorId = {{ v_inspector | tojson }};
|
||||
|
||||
function setPlaceholder() {
|
||||
facilitySel.innerHTML = '<option value="">— Select a Contract first —</option>';
|
||||
facilitySel.disabled = true;
|
||||
loadAreas('', false);
|
||||
if (inspectorSel) {
|
||||
inspectorSel.innerHTML = '<option value="">— Select a Contract first —</option>';
|
||||
inspectorSel.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Inspectors come from the CONTRACT, not the facility: assignment rows are
|
||||
// per contract. The server re-checks the chosen id against this same list.
|
||||
function loadInspectors(projectId, restoreInspectorId) {
|
||||
if (!inspectorSel) { return; }
|
||||
inspectorSel.disabled = true;
|
||||
inspectorSel.innerHTML = '<option value="">Loading…</option>';
|
||||
fetch(INSPECTORS_URL + projectId)
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
if (!data.length) {
|
||||
// Only reachable for a Customer Director: our own staff fall back to
|
||||
// the full inspector pool server-side. Say what is wrong rather than
|
||||
// leaving an empty dropdown.
|
||||
inspectorSel.innerHTML =
|
||||
'<option value="">— No inspectors on this contract —</option>';
|
||||
inspectorSel.disabled = false;
|
||||
return;
|
||||
}
|
||||
inspectorSel.innerHTML = '<option value="">— Choose an inspector —</option>';
|
||||
data.forEach(function (u) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = u.id;
|
||||
opt.textContent = u.name;
|
||||
if (restoreInspectorId && u.id === restoreInspectorId) { opt.selected = true; }
|
||||
inspectorSel.appendChild(opt);
|
||||
});
|
||||
inspectorSel.disabled = false;
|
||||
})
|
||||
.catch(function () {
|
||||
inspectorSel.innerHTML = '<option value="">Could not load inspectors</option>';
|
||||
});
|
||||
}
|
||||
|
||||
function loadAreas(facilityId, keepSelection) {
|
||||
areaSelect.innerHTML = '<option value="">— Whole facility —</option>';
|
||||
if (!facilityId) return;
|
||||
fetch('{{ url_for('inspections.areas_for_facility', facility_id=0) }}'.replace('/0', '/' + facilityId))
|
||||
if (!areaSel) { return; }
|
||||
areaSel.innerHTML = '<option value="">— Whole facility —</option>';
|
||||
if (!facilityId) { return; }
|
||||
fetch(AREAS_URL + facilityId)
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (areas) {
|
||||
areas.forEach(function (a) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = a.id;
|
||||
opt.textContent = a.name;
|
||||
if (keepSelection && a.id === preselectedAreaId) opt.selected = true;
|
||||
areaSelect.appendChild(opt);
|
||||
if (keepSelection && a.id === preAreaId) { opt.selected = true; }
|
||||
areaSel.appendChild(opt);
|
||||
});
|
||||
})
|
||||
.catch(function () { /* leave the whole-facility default in place */ });
|
||||
}
|
||||
|
||||
facilitySelect.addEventListener('change', function () {
|
||||
preselectedAreaId = 0;
|
||||
function loadFacilities(projectId, restoreFacilityId) {
|
||||
facilitySel.disabled = true;
|
||||
facilitySel.innerHTML = '<option value="">Loading…</option>';
|
||||
fetch(FACILITIES_URL + projectId)
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
facilitySel.innerHTML = '<option value="">— Select Facility —</option>';
|
||||
data.forEach(function (f) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = f.id;
|
||||
opt.textContent = f.name;
|
||||
if (restoreFacilityId && f.id === restoreFacilityId) { opt.selected = true; }
|
||||
facilitySel.appendChild(opt);
|
||||
});
|
||||
facilitySel.disabled = false;
|
||||
if (restoreFacilityId) { loadAreas(restoreFacilityId, true); }
|
||||
})
|
||||
.catch(function () {
|
||||
facilitySel.innerHTML = '<option value="">Could not load facilities</option>';
|
||||
});
|
||||
}
|
||||
|
||||
contractSel.addEventListener('change', function () {
|
||||
if (this.value) {
|
||||
loadFacilities(this.value, null);
|
||||
loadInspectors(this.value, null);
|
||||
} else {
|
||||
setPlaceholder();
|
||||
}
|
||||
});
|
||||
|
||||
facilitySel.addEventListener('change', function () {
|
||||
preAreaId = 0; // a new facility invalidates the saved area
|
||||
loadAreas(this.value, false);
|
||||
});
|
||||
|
||||
// On edit load, refresh the area list for the saved facility and keep the saved area.
|
||||
if (facilitySelect.value) loadAreas(facilitySelect.value, true);
|
||||
})();
|
||||
// Initial state: restore the contract, facility and area on edit / re-render.
|
||||
if (preProjectId) {
|
||||
contractSel.value = String(preProjectId);
|
||||
loadFacilities(preProjectId, preFacilityId);
|
||||
loadInspectors(preProjectId, preInspectorId);
|
||||
} else if (preFacilityId) {
|
||||
// Facility on no contract (or one the selector cannot name): keep the
|
||||
// server-rendered options and the current choice rather than clearing it.
|
||||
facilitySel.disabled = false;
|
||||
loadAreas(String(preFacilityId), true);
|
||||
if (inspectorSel) { inspectorSel.disabled = false; }
|
||||
} else {
|
||||
setPlaceholder();
|
||||
}
|
||||
}());
|
||||
|
||||
// ── Recurrence blocks follow the chosen frequency ───────────────────────────
|
||||
// Display only — the server re-validates and clears the unused blocks on save,
|
||||
// so stale values left in the DOM never take effect.
|
||||
(function () {
|
||||
'use strict';
|
||||
var freq = document.getElementById('frequency');
|
||||
var weekly = document.getElementById('weekly_block');
|
||||
var monthly = document.getElementById('monthly_block');
|
||||
var endRow = document.getElementById('end_date_row');
|
||||
var dueLabel = document.getElementById('due_date_label');
|
||||
if (!freq || !weekly || !monthly) { return; }
|
||||
|
||||
var domRadio = document.getElementById('month_mode_day');
|
||||
var nthRadio = document.getElementById('month_mode_nth');
|
||||
var domRow = document.getElementById('dom_row');
|
||||
var nthRow = document.getElementById('nth_row');
|
||||
var isEdit = {{ 'true' if schedule else 'false' }};
|
||||
// Every frequency that repeats on a month boundary uses the monthly rule.
|
||||
var MONTHLY = ['monthly', 'quarterly', 'bi-annually', 'annually'];
|
||||
|
||||
function syncMonthMode() {
|
||||
if (!domRow || !nthRow) { return; }
|
||||
var useNth = nthRadio && nthRadio.checked;
|
||||
domRow.style.opacity = useNth ? '.45' : '1';
|
||||
nthRow.style.opacity = useNth ? '1' : '.45';
|
||||
}
|
||||
|
||||
function syncFrequency() {
|
||||
weekly.hidden = freq.value !== 'weekly';
|
||||
monthly.hidden = MONTHLY.indexOf(freq.value) === -1;
|
||||
// End date is a recurring-only concept.
|
||||
if (endRow) { endRow.hidden = freq.value === 'once'; }
|
||||
if (dueLabel) {
|
||||
dueLabel.textContent = isEdit ? 'Next Due Date'
|
||||
: (freq.value === 'once' ? 'Date' : 'Start Date');
|
||||
}
|
||||
syncMonthMode();
|
||||
}
|
||||
|
||||
freq.addEventListener('change', syncFrequency);
|
||||
[domRadio, nthRadio].forEach(function (r) {
|
||||
if (r) { r.addEventListener('change', syncMonthMode); }
|
||||
});
|
||||
syncFrequency();
|
||||
}());
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,17 +1,31 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Inspection Schedules{% endblock %}
|
||||
{% block content %}
|
||||
{# Who may create/edit/delete a schedule — mirrors schedule_manager_required
|
||||
in routes/inspection_schedules.py. 'customer' is the Customer DIRECTOR, who
|
||||
plans work for their own facilities; a Customer Inspector ('external_inspector')
|
||||
performs schedules and is covered by is_inspector below. #}
|
||||
{% set can_manage_schedules = current_user.role in
|
||||
['admin','director','project_manager','auditor','customer'] %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="bi bi-calendar2-week"></i> Inspection Schedules</h2>
|
||||
{% if current_user.role != 'inspector' %}
|
||||
<a href="{{ url_for('inspection_schedules.create') }}" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle"></i> New Schedule
|
||||
</a>
|
||||
{% endif %}
|
||||
{# This page is reached from the Inspections list ("Scheduled") and has no
|
||||
entry of its own in the main nav, so without this button the only way back
|
||||
is the browser control. #}
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{{ url_for('inspections.index') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Inspections
|
||||
</a>
|
||||
{% if can_manage_schedules %}
|
||||
<a href="{{ url_for('inspection_schedules.create') }}" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle"></i> New Schedule
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-muted small mb-4">
|
||||
{% if current_user.role == 'inspector' %}
|
||||
{% if current_user.is_inspector %}
|
||||
Inspections scheduled for you. <strong>Auto</strong> schedules appear in your
|
||||
Inspections list on their own each period; <strong>Plan</strong> schedules wait
|
||||
for you to press Start.
|
||||
@@ -23,8 +37,27 @@
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
{# Pending / Completed tabs (phase51). The partition is on `active`, so it is
|
||||
exhaustive — no schedule can fall between the two tabs. #}
|
||||
<ul class="nav nav-tabs mb-0">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if tab == 'pending' }}"
|
||||
href="{{ url_for('inspection_schedules.index', tab='pending') }}">
|
||||
<i class="bi bi-hourglass-split"></i> Pending
|
||||
<span class="badge rounded-pill bg-{{ 'primary' if tab == 'pending' else 'secondary' }} ms-1">{{ pending_count }}</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if tab == 'completed' }}"
|
||||
href="{{ url_for('inspection_schedules.index', tab='completed') }}">
|
||||
<i class="bi bi-check2-circle"></i> Completed
|
||||
<span class="badge rounded-pill bg-{{ 'primary' if tab == 'completed' else 'secondary' }} ms-1">{{ completed_count }}</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{% if schedules %}
|
||||
<div class="card shadow-sm">
|
||||
<div class="card shadow-sm border-top-0 rounded-top-0">
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0 align-middle">
|
||||
@@ -32,20 +65,31 @@
|
||||
<tr>
|
||||
<th>Name</th><th>Template</th><th>Facility / Area</th>
|
||||
<th>Inspector</th><th>Frequency</th><th>Mode</th><th>Next Due</th>
|
||||
<th>Last Run</th><th>Status</th><th width="190"></th>
|
||||
<th>Ends</th><th>Last Run</th><th>Status</th><th width="190"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for s in schedules %}
|
||||
<tr class="{{ 'text-muted' if not s.active else '' }}">
|
||||
<td><strong>{{ s.name }}</strong></td>
|
||||
<td>
|
||||
<strong>{{ s.name }}</strong>
|
||||
{% if s.is_follow_up %}
|
||||
{# phase48 — a schedule planned as the deferred twin of
|
||||
"Re-inspect Now". Starting it produces a linked re-inspection. #}
|
||||
<a href="{{ url_for('inspections.view', inspection_id=s.parent_inspection_id) }}"
|
||||
class="badge bg-warning text-dark text-decoration-none ms-1"
|
||||
title="Follow-up of inspection #{{ s.parent_inspection_id }}">
|
||||
<i class="bi bi-arrow-repeat"></i> Follow-up #{{ s.parent_inspection_id }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ s.template.name if s.template else '—' }}</td>
|
||||
<td>
|
||||
{{ s.facility.name if s.facility else '—' }}
|
||||
{% if s.area %}<span class="text-muted small">/ {{ s.area.name }}</span>{% endif %}
|
||||
</td>
|
||||
<td>{{ s.inspector.display_name if s.inspector else '—' }}</td>
|
||||
<td><span class="badge bg-secondary">{{ s.frequency|title }}</span></td>
|
||||
<td><span class="badge bg-secondary">{{ s.recurrence_label }}</span></td>
|
||||
<td>
|
||||
{% if s.mode == 'plan' %}
|
||||
<span class="badge bg-info text-dark" title="Inspector presses Start">Plan</span>
|
||||
@@ -59,22 +103,63 @@
|
||||
<span class="badge bg-danger ms-1">Overdue</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="small text-muted">
|
||||
{% if s.frequency == 'once' %}—
|
||||
{% elif s.end_date %}{{ s.end_date.strftime('%Y-%m-%d') }}
|
||||
{% else %}No end{% endif %}
|
||||
</td>
|
||||
<td class="small text-muted">
|
||||
{{ s.last_run_at.strftime('%Y-%m-%d %H:%M') if s.last_run_at else 'Never' }}
|
||||
</td>
|
||||
<td>
|
||||
{# "Ended" separates a schedule that reached its end date from one a
|
||||
manager switched off — both are inactive, for different reasons. #}
|
||||
{% if s.active %}<span class="badge bg-success">Active</span>
|
||||
{% elif s.is_expired %}<span class="badge bg-dark">Ended</span>
|
||||
{% else %}<span class="badge bg-secondary">Paused</span>{% endif %}
|
||||
{# phase50 — receipt acknowledgement. Only meaningful for plan
|
||||
mode: an auto schedule materialises itself, so there is no
|
||||
request for anyone to receive. #}
|
||||
{% if s.mode == 'plan' and s.inspector_id %}
|
||||
{% if s.is_acknowledged %}
|
||||
<span class="badge bg-light text-success border border-success ms-1"
|
||||
title="Inspector confirmed receipt on {{ s.acknowledged_at.strftime('%b %d, %Y %I:%M %p') }}">
|
||||
<i class="bi bi-check-circle"></i> Confirmed
|
||||
</span>
|
||||
{% elif s.active %}
|
||||
<span class="badge bg-light text-warning border border-warning ms-1"
|
||||
title="The assigned inspector has not confirmed receipt yet">
|
||||
<i class="bi bi-hourglass-split"></i> Awaiting confirmation
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
{# Assignee-only, like Start: a manager must not be able to confirm
|
||||
on someone's behalf, since the record means "this person saw it". #}
|
||||
{% if s.active and s.mode == 'plan' and not s.is_acknowledged
|
||||
and s.inspector_id == current_user.id %}
|
||||
<form method="POST" class="d-inline"
|
||||
action="{{ url_for('inspection_schedules.acknowledge', schedule_id=s.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-success"
|
||||
title="Confirm you have received this request">
|
||||
<i class="bi bi-check-lg"></i> Confirm
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{# Start is for whoever must DO the work. A Customer Director can
|
||||
plan a schedule but never execute one — the route 403s them, so
|
||||
the button must not be offered either. #}
|
||||
{% if s.active and s.mode == 'plan'
|
||||
and (current_user.role != 'inspector' or s.inspector_id == current_user.id) %}
|
||||
and current_user.role != 'customer'
|
||||
and (not current_user.is_inspector or s.inspector_id == current_user.id) %}
|
||||
<a href="{{ url_for('inspection_schedules.start', schedule_id=s.id) }}"
|
||||
class="btn btn-sm btn-primary" title="Start this inspection now">
|
||||
<i class="bi bi-play-fill"></i> Start
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if current_user.role != 'inspector' %}
|
||||
{% if can_manage_schedules %}
|
||||
<a href="{{ url_for('inspection_schedules.edit', schedule_id=s.id) }}"
|
||||
class="btn btn-sm btn-outline-secondary" title="Edit">
|
||||
<i class="bi bi-pencil"></i>
|
||||
@@ -107,13 +192,29 @@
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card shadow-sm">
|
||||
<div class="card shadow-sm border-top-0 rounded-top-0">
|
||||
<div class="card-body text-center py-5 text-muted">
|
||||
{% if tab == 'completed' %}
|
||||
<i class="bi bi-check2-circle fs-1 d-block mb-3 opacity-25"></i>
|
||||
<p class="mb-0">No completed schedules yet.</p>
|
||||
{% elif completed_count %}
|
||||
{# Nothing pending but there IS history — offer the other tab rather than
|
||||
inviting them to create a duplicate of something already closed. #}
|
||||
<i class="bi bi-calendar-check fs-1 d-block mb-3 opacity-25"></i>
|
||||
<p class="mb-3">Nothing pending — all schedules are complete.</p>
|
||||
<a href="{{ url_for('inspection_schedules.index', tab='completed') }}"
|
||||
class="btn btn-outline-secondary">
|
||||
<i class="bi bi-check2-circle"></i> View Completed ({{ completed_count }})
|
||||
</a>
|
||||
{% else %}
|
||||
<i class="bi bi-calendar-x fs-1 d-block mb-3 opacity-25"></i>
|
||||
<p class="mb-3">No inspection schedules configured yet.</p>
|
||||
{% if can_manage_schedules %}
|
||||
<a href="{{ url_for('inspection_schedules.create') }}" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle"></i> Create First Schedule
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -564,7 +564,7 @@
|
||||
<option value="0">— Unassigned —</option>
|
||||
{% set staff = staff_for_flag_issue %}
|
||||
{% if staff %}{% for u in staff %}
|
||||
<option value="{{ u.id }}">{{ u.display_name }}</option>
|
||||
<option value="{{ u.id }}">{{ u.display_name }}{{ ' (Customer)' if u.is_external_inspector }}</option>
|
||||
{% endfor %}{% endif %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -3,16 +3,18 @@
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="bi bi-clipboard-data"></i> Inspections</h2>
|
||||
{% if current_user.role != 'customer' %}
|
||||
{# Customer Directors schedule inspections for their own facilities, so the
|
||||
Scheduled link is theirs too — but starting an ad-hoc inspection is not. #}
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{{ url_for('inspection_schedules.index') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-calendar-check"></i> Scheduled
|
||||
</a>
|
||||
{% if current_user.role != 'customer' %}
|
||||
<a href="{{ url_for('inspections.start') }}" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle"></i> New Inspection
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Filters #}
|
||||
@@ -106,10 +108,15 @@
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body p-0">
|
||||
{% if inspections.items %}
|
||||
{% include 'partials/bulk_inspections_toolbar.html' %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width:34px;">
|
||||
<input type="checkbox" class="form-check-input bulk-check-all"
|
||||
title="Select all on this page" aria-label="Select all">
|
||||
</th>
|
||||
<th>#</th><th>Date</th><th>Contract</th><th>Facility</th><th>Area</th>
|
||||
<th>Template</th><th>Inspector</th><th>Score</th>
|
||||
<th>Status</th><th></th>
|
||||
@@ -118,6 +125,11 @@
|
||||
<tbody>
|
||||
{% for ins in inspections.items %}
|
||||
<tr>
|
||||
<td>
|
||||
<input type="checkbox" class="form-check-input bulk-check"
|
||||
form="inspectionsBulkForm" name="inspection_ids" value="{{ ins.id }}"
|
||||
aria-label="Select inspection #{{ ins.id }}">
|
||||
</td>
|
||||
<td><small class="text-muted">#{{ ins.id }}</small></td>
|
||||
<td>{{ ins.inspection_date.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
<td><small>{{ ins.facility.project.name if ins.facility and ins.facility.project else '—' }}</small></td>
|
||||
@@ -152,9 +164,9 @@
|
||||
</td>
|
||||
<td class="text-nowrap">
|
||||
{% if ins.status == 'in_progress' or ins.status == 'flagged' %}
|
||||
<a href="{{ url_for('inspections.execute', inspection_id=ins.id) }}" class="btn btn-sm btn-outline-primary insp-list-link">Continue</a>
|
||||
<a href="{{ url_for('inspections.execute', inspection_id=ins.id, next=current_url()) }}" class="btn btn-sm btn-outline-primary insp-list-link">Continue</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('inspections.view', inspection_id=ins.id) }}" class="btn btn-sm btn-outline-secondary insp-list-link">View</a>
|
||||
<a href="{{ url_for('inspections.view', inspection_id=ins.id, next=current_url()) }}" class="btn btn-sm btn-outline-secondary insp-list-link">View</a>
|
||||
{% endif %}
|
||||
{% if current_user.role in ['admin', 'director'] %}
|
||||
<button type="button"
|
||||
@@ -216,6 +228,7 @@
|
||||
</button>
|
||||
<form id="deleteInspectionForm" method="POST" action="" class="d-inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="next" value="{{ current_url() }}">
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<i class="bi bi-trash3-fill"></i> Delete Permanently
|
||||
</button>
|
||||
@@ -228,6 +241,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
{% include 'partials/bulk_select_js.html' %}
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
@@ -14,8 +14,14 @@
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.template_id.label(class="form-label fw-semibold") }}
|
||||
{{ form.template_id(class="form-select" + (" is-invalid" if form.template_id.errors else "")) }}
|
||||
{{ form.template_id(class="form-select" + (" is-invalid" if form.template_id.errors else ""), id="templateSelect") }}
|
||||
{% for e in form.template_id.errors %}<div class="invalid-feedback">{{ e }}</div>{% endfor %}
|
||||
{# phase52 — the list shows shared forms plus the ones attached to
|
||||
the selected contract, refreshed by JS when the contract changes. #}
|
||||
<div class="form-text">Shows forms available on the selected contract.</div>
|
||||
<div id="templateEmpty" class="form-text text-danger d-none">
|
||||
No forms are available on this contract yet.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
@@ -55,6 +61,8 @@
|
||||
<script>
|
||||
(function () {
|
||||
const projectSel = document.getElementById('projectSelect');
|
||||
const templateSel = document.getElementById('templateSelect');
|
||||
const templateEmpty = document.getElementById('templateEmpty');
|
||||
const facilitySel = document.getElementById('facilitySelect');
|
||||
const spinner = document.getElementById('facilitySpinner');
|
||||
const emptyMsg = document.getElementById('facilityEmpty');
|
||||
@@ -62,6 +70,7 @@
|
||||
const areaSel = document.getElementById('areaSelect');
|
||||
|
||||
const FACILITIES_URL = `{{ url_for('inspections.facilities_for_project', project_id=0) }}`.replace('/0', '/');
|
||||
const TEMPLATES_URL = `{{ url_for('inspections.templates_for_project', project_id=0) }}`.replace('/0', '/');
|
||||
const AREAS_URL = `{{ url_for('inspections.areas_for_facility', facility_id=0) }}`.replace('/0', '/');
|
||||
|
||||
function loadAreas(facilityId, selectedAreaId) {
|
||||
@@ -93,6 +102,28 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Forms are per-contract (phase52): a customer's bespoke form must not be
|
||||
// offered on another customer's facilities. Keeps the currently selected
|
||||
// form if it is still valid on the new contract.
|
||||
function loadTemplates(projectId) {
|
||||
if (!projectId || !templateSel) return;
|
||||
const keep = templateSel.value;
|
||||
fetch(TEMPLATES_URL + projectId)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
templateSel.innerHTML = '';
|
||||
data.forEach(t => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = t.id;
|
||||
opt.textContent = t.name;
|
||||
if (String(t.id) === keep) opt.selected = true;
|
||||
templateSel.appendChild(opt);
|
||||
});
|
||||
templateEmpty.classList.toggle('d-none', data.length > 0);
|
||||
})
|
||||
.catch(() => {}); // leave the server-rendered list in place
|
||||
}
|
||||
|
||||
function loadFacilities(projectId, selectedFacilityId, selectedAreaId) {
|
||||
if (!projectId) return;
|
||||
spinner.classList.remove('d-none');
|
||||
@@ -129,6 +160,7 @@
|
||||
|
||||
projectSel.addEventListener('change', function () {
|
||||
loadFacilities(this.value, null, null);
|
||||
loadTemplates(this.value);
|
||||
});
|
||||
|
||||
facilitySel.addEventListener('change', function () {
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Inspection #{{ inspection.id }} — Results{% endblock %}
|
||||
|
||||
{# The filtered list URL this page was opened from. Defined at TOP LEVEL, not
|
||||
inside the content block: Jinja blocks do not share scope, and the follow-up
|
||||
modal below sits in the extra_js block — from there a content-scoped
|
||||
back_url is Undefined and posts next="", silently dropping the very filters
|
||||
this mechanism exists to preserve. #}
|
||||
{% set back_url = request.args.get('next') or url_for('inspections.index') %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
@@ -338,7 +345,10 @@
|
||||
|
||||
{# Action bar #}
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<a id="backToInspectionsBtn" href="{{ url_for('inspections.index') }}" class="btn btn-sm btn-outline-secondary">
|
||||
{# `next` carries the filtered list URL from the list page; the
|
||||
sessionStorage fallback below still covers links opened before
|
||||
this page started sending one. #}
|
||||
<a id="backToInspectionsBtn" href="{{ back_url }}" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Back to Inspections
|
||||
</a>
|
||||
<div class="d-flex gap-2">
|
||||
@@ -349,13 +359,28 @@
|
||||
<button onclick="window.print()" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-printer"></i> Print
|
||||
</button>
|
||||
{% if current_user.role not in ['customer'] %}
|
||||
{# Offered to managers, to the inspector who did this inspection, and to
|
||||
whoever the follow-up was assigned to (phase56). Not to any other
|
||||
inspector who can merely SEE it: reinspect() refuses them, and showing
|
||||
a button that fails on click is the mismatch this page just fixed. #}
|
||||
{% if current_user.role not in ['customer']
|
||||
and (is_own_inspection or owns_follow_up) %}
|
||||
<a href="{{ url_for('inspections.reinspect', inspection_id=inspection.id) }}"
|
||||
class="btn btn-sm btn-outline-primary"
|
||||
title="Start a follow-up re-inspection with the same template and facility">
|
||||
<i class="bi bi-arrow-repeat"></i> Re-inspect
|
||||
</a>
|
||||
{% endif %}
|
||||
{# phase49 — customers may REQUEST a follow-up on their own completed
|
||||
inspections; only admin/director can clear one. #}
|
||||
{% if current_user.role == 'customer' and inspection.status == 'completed'
|
||||
and not inspection.follow_up_required %}
|
||||
<button type="button" class="btn btn-sm btn-outline-warning"
|
||||
data-bs-toggle="modal" data-bs-target="#followupModal"
|
||||
title="Ask the team to re-inspect this facility">
|
||||
<i class="bi bi-flag"></i> Request Follow-up
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if current_user.role in ['admin','director'] %}
|
||||
{% if not inspection.follow_up_required %}
|
||||
<button type="button" class="btn btn-sm btn-outline-warning"
|
||||
@@ -368,6 +393,7 @@
|
||||
action="{{ url_for('inspections.clear_followup', inspection_id=inspection.id) }}"
|
||||
class="d-inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="next" value="{{ back_url }}">
|
||||
<button class="btn btn-sm btn-warning">
|
||||
<i class="bi bi-flag-fill"></i> Clear Follow-up
|
||||
</button>
|
||||
@@ -376,6 +402,7 @@
|
||||
<form method="post" action="{{ url_for('inspections.delete', inspection_id=inspection.id) }}"
|
||||
onsubmit="return confirm('Delete this inspection permanently?')">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="next" value="{{ back_url }}">
|
||||
<button class="btn btn-sm btn-outline-danger"><i class="bi bi-trash3"></i> Delete</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
@@ -388,13 +415,43 @@
|
||||
<i class="bi bi-flag-fill mt-1"></i>
|
||||
<div>
|
||||
<strong>Follow-up Inspection Required</strong>
|
||||
{# phase49 — who asked, and whether it was the client or our own staff. #}
|
||||
{% if inspection.follow_up_requester %}
|
||||
<span class="badge {{ 'bg-info text-dark' if inspection.follow_up_requester.role == 'customer' else 'bg-secondary' }} ms-1">
|
||||
{{ 'Requested by customer' if inspection.follow_up_requester.role == 'customer' else 'Requested by staff' }}:
|
||||
{{ inspection.follow_up_requester.display_name }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if inspection.follow_up_requested_at %}
|
||||
<span class="small text-muted ms-1">{{ inspection.follow_up_requested_at.strftime('%b %d, %Y %I:%M %p') }}</span>
|
||||
{% endif %}
|
||||
{# Who is expected to DO it — the assignee when one was named, otherwise
|
||||
the original inspector (Inspection.follow_up_owner). #}
|
||||
{% if inspection.follow_up_owner %}
|
||||
<div class="small mt-1">
|
||||
<i class="bi bi-person-check me-1"></i>Assigned to
|
||||
<strong>{{ inspection.follow_up_owner.display_name }}</strong>
|
||||
{% if not inspection.follow_up_assignee %}
|
||||
<span class="text-muted">(original inspector)</span>
|
||||
{% elif inspection.follow_up_owner.id == current_user.id %}
|
||||
<span class="badge bg-warning text-dark ms-1">You</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if inspection.follow_up_note %}<br><span class="small">{{ inspection.follow_up_note }}</span>{% endif %}
|
||||
{# Re-inspection is staff work — reinspect() already refuses customers —
|
||||
and among inspectors it belongs to the follow-up's OWNER. #}
|
||||
{% if current_user.role != 'customer'
|
||||
and (is_own_inspection or owns_follow_up) %}
|
||||
<div class="mt-2">
|
||||
<a href="{{ url_for('inspections.reinspect', inspection_id=inspection.id) }}"
|
||||
class="btn btn-sm btn-warning">
|
||||
<i class="bi bi-arrow-repeat me-1"></i>Start Re-inspection
|
||||
</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="small mt-1">The team has been notified and will schedule the re-inspection.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -475,6 +532,13 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
{# Parity with ST phase43: show that this run came from a schedule. Uses
|
||||
MT's own column/relationship names (inspection_schedule_id). #}
|
||||
{% if inspection.inspection_schedule_id %}
|
||||
<span class="badge bg-info text-dark fs-6" title="Created from a scheduled inspection">
|
||||
<i class="bi bi-calendar-check"></i> Scheduled{% if inspection.inspection_schedule %} · {{ inspection.inspection_schedule.recurrence_label }}{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
<span class="badge bg-{{ 'success' if inspection.status == 'completed' else 'danger' if inspection.status == 'flagged' else 'secondary' }} fs-6">
|
||||
{{ inspection.status|replace('_',' ')|title }}
|
||||
</span>
|
||||
@@ -508,6 +572,12 @@
|
||||
<span class="lbl">Frequency</span>
|
||||
<span class="val">{{ inspection.template.frequency|title }}</span>
|
||||
</div>
|
||||
{% if inspection.inspection_schedule and inspection.inspection_schedule.creator %}
|
||||
<div class="meta-item">
|
||||
<span class="lbl">Scheduled By</span>
|
||||
<span class="val">{{ inspection.inspection_schedule.creator.display_name }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# ── Submission GPS (admin / director only) ──────────────────────────── #}
|
||||
@@ -854,7 +924,11 @@
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
(function () {
|
||||
var backUrl = sessionStorage.getItem('insp_list_back_url');
|
||||
// A server-provided `next` is authoritative — it reflects the list this
|
||||
// page was actually opened from. Only fall back to sessionStorage when
|
||||
// there is none (e.g. a link created before `next` was threaded in).
|
||||
var hasNext = {{ 'true' if request.args.get('next') else 'false' }};
|
||||
var backUrl = hasNext ? null : sessionStorage.getItem('insp_list_back_url');
|
||||
if (backUrl) {
|
||||
var btn = document.getElementById('backToInspectionsBtn');
|
||||
if (btn) btn.href = backUrl;
|
||||
@@ -880,20 +954,65 @@ document.addEventListener('keydown', e => { if (e.key === 'Escape') closeMedia()
|
||||
<div class="modal-dialog">
|
||||
<form method="POST" action="{{ url_for('inspections.flag_followup', inspection_id=inspection.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="next" value="{{ back_url }}">
|
||||
<div class="modal-content">
|
||||
{% set is_cust = current_user.role == 'customer' %}
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="bi bi-flag me-2"></i>Flag Follow-up Required</h5>
|
||||
<h5 class="modal-title">
|
||||
<i class="bi bi-flag me-2"></i>{{ 'Request a Follow-up Inspection' if is_cust else 'Flag Follow-up Required' }}
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<label class="form-label fw-semibold">Reason / Notes <span class="text-muted small">(optional)</span></label>
|
||||
{% if is_cust %}
|
||||
<p class="small text-muted">
|
||||
Ask the team to re-inspect this facility. Your request is sent to the
|
||||
inspector and management right away.
|
||||
</p>
|
||||
{% endif %}
|
||||
<label class="form-label fw-semibold">
|
||||
{{ 'What still needs attention?' if is_cust else 'Reason / Notes' }}
|
||||
<span class="text-muted small">(optional)</span>
|
||||
</label>
|
||||
<textarea name="follow_up_note" class="form-control" rows="3"
|
||||
placeholder="Describe what needs to be addressed in the follow-up inspection…"></textarea>
|
||||
|
||||
{# ── Assign it (phase56) ────────────────────────────────────────
|
||||
Optional. Left blank, the follow-up stays with whoever performed
|
||||
the original inspection — the behaviour before this existed. The
|
||||
list is contract-scoped in _followup_assignees_for(), so a
|
||||
Customer Director only ever sees inspectors on their own
|
||||
contracts. #}
|
||||
{% if followup_assignees %}
|
||||
<div class="mt-3">
|
||||
<label class="form-label fw-semibold">
|
||||
Assign to
|
||||
<span class="text-muted small">(optional)</span>
|
||||
</label>
|
||||
<select name="follow_up_assigned_to" class="form-select">
|
||||
<option value="">
|
||||
— {{ inspection.inspector.display_name }} (original inspector) —
|
||||
</option>
|
||||
{% for u in followup_assignees %}
|
||||
{% if u.id != inspection.inspector_id %}
|
||||
<option value="{{ u.id }}">
|
||||
{{ u.display_name }}{{ ' (Customer)' if u.is_external_inspector }}
|
||||
</option>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="form-text">
|
||||
Choose someone else to carry out the re-inspection. They are
|
||||
notified and it appears in their list on the web and the iPad;
|
||||
the original inspector is not asked to do it.
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-warning">
|
||||
<i class="bi bi-flag me-1"></i>Flag Follow-up
|
||||
<i class="bi bi-flag me-1"></i>{{ 'Send Request' if is_cust else 'Flag Follow-up' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -46,6 +46,93 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Handled By (phase44) — staff only; customers stay internal ── #}
|
||||
{% if current_user.role != 'customer' %}
|
||||
<hr class="my-3">
|
||||
<p class="fw-semibold mb-2">
|
||||
<i class="bi bi-person-check me-1 text-secondary"></i>Handled By
|
||||
</p>
|
||||
<div class="mb-3">
|
||||
{{ form.handler_type(class="form-select", id="handlerTypeSelect") }}
|
||||
<div class="form-text" id="handlerTypeHelp"></div>
|
||||
</div>
|
||||
|
||||
<div id="internalHandlerFields">
|
||||
<div class="mb-2">
|
||||
{{ form.internal_handler_name.label(class="form-label small fw-semibold mb-1") }}
|
||||
{{ form.internal_handler_name(class="form-control form-control-sm",
|
||||
placeholder="Crew member handling this") }}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
{{ form.internal_handler_contact.label(class="form-label small fw-semibold mb-1") }}
|
||||
{{ form.internal_handler_contact(class="form-control form-control-sm",
|
||||
placeholder="Phone or email") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="facilityHandlerFields" style="display:none;">
|
||||
<div class="mb-2">
|
||||
{{ form.facility_handler_name.label(class="form-label small fw-semibold mb-1") }}
|
||||
{{ form.facility_handler_name(class="form-control form-control-sm",
|
||||
placeholder="Contact name at the facility") }}
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
{{ form.facility_handler_contact.label(class="form-label small fw-semibold mb-1") }}
|
||||
{{ form.facility_handler_contact(class="form-control form-control-sm",
|
||||
placeholder="Phone or email") }}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
{{ form.facility_handler_notes.label(class="form-label small fw-semibold mb-1") }}
|
||||
{{ form.facility_handler_notes(class="form-control form-control-sm", rows=2,
|
||||
placeholder="Notes about what they are handling…") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="vendorHandlerFields" style="display:none;">
|
||||
<div class="mb-2">
|
||||
{{ form.vendor_name.label(class="form-label small fw-semibold mb-1") }}
|
||||
{{ form.vendor_name(class="form-control form-control-sm",
|
||||
placeholder="Contractor or vendor name") }}
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
{{ form.vendor_contact.label(class="form-label small fw-semibold mb-1") }}
|
||||
{{ form.vendor_contact(class="form-control form-control-sm",
|
||||
placeholder="Phone or email") }}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
{{ form.vendor_notes.label(class="form-label small fw-semibold mb-1") }}
|
||||
{{ form.vendor_notes(class="form-control form-control-sm", rows=2,
|
||||
placeholder="Scope, quote reference, etc.") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
var sel = document.getElementById('handlerTypeSelect');
|
||||
var help = document.getElementById('handlerTypeHelp');
|
||||
var boxes = {
|
||||
internal: document.getElementById('internalHandlerFields'),
|
||||
facility: document.getElementById('facilityHandlerFields'),
|
||||
vendor: document.getElementById('vendorHandlerFields')
|
||||
};
|
||||
var notes = {
|
||||
internal: {{ (issue_handler_descriptions or {}).get('internal', '')|tojson }},
|
||||
facility: {{ (issue_handler_descriptions or {}).get('facility', '')|tojson }},
|
||||
vendor: {{ (issue_handler_descriptions or {}).get('vendor', '')|tojson }}
|
||||
};
|
||||
function sync(){
|
||||
var v = sel ? sel.value : 'internal';
|
||||
for (var k in boxes){
|
||||
if (boxes[k]) { boxes[k].style.display = (k === v) ? '' : 'none'; }
|
||||
}
|
||||
if (help) { help.textContent = notes[v] || ''; }
|
||||
}
|
||||
if (sel){ sel.addEventListener('change', sync); }
|
||||
sync();
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-danger">Log Issue</button>
|
||||
<a href="{{ url_for('issues.index') }}" class="btn btn-outline-secondary">Cancel</a>
|
||||
|
||||
@@ -115,10 +115,15 @@
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body p-0">
|
||||
{% if issues.items %}
|
||||
{% include 'partials/bulk_issues_toolbar.html' %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width:34px;">
|
||||
<input type="checkbox" class="form-check-input bulk-check-all"
|
||||
title="Select all on this page" aria-label="Select all">
|
||||
</th>
|
||||
<th>#</th>
|
||||
<th>Reported</th>
|
||||
<th>Severity</th>
|
||||
@@ -137,6 +142,11 @@
|
||||
{% set is_following = issue.id in followed_ids %}
|
||||
{% set sla = sla_status(issue) %}
|
||||
<tr class="{{ 'table-danger' if sla == 'breached' else 'table-warning' if sla == 'at_risk' else '' }}">
|
||||
<td>
|
||||
<input type="checkbox" class="form-check-input bulk-check"
|
||||
form="issuesBulkForm" name="issue_ids" value="{{ issue.id }}"
|
||||
aria-label="Select issue #{{ issue.id }}">
|
||||
</td>
|
||||
<td><small class="text-muted">#{{ issue.id }}</small></td>
|
||||
<td><small>{{ issue.reported_at.strftime('%Y-%m-%d %H:%M') }}</small></td>
|
||||
<td>
|
||||
@@ -181,7 +191,7 @@
|
||||
<select class="form-select form-select-sm quick-assign-select" style="min-width:110px;font-size:.78rem;">
|
||||
<option value="">— Unassigned —</option>
|
||||
{% for u in staff %}
|
||||
<option value="{{ u.id }}" {{ 'selected' if issue.assigned_to == u.id }}>{{ u.display_name }}</option>
|
||||
<option value="{{ u.id }}" {{ 'selected' if issue.assigned_to == u.id }}>{{ u.display_name }}{{ ' (Customer)' if u.is_external_inspector }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<span class="quick-assign-spinner spinner-border spinner-border-sm text-secondary d-none" role="status"></span>
|
||||
@@ -202,7 +212,7 @@
|
||||
class="d-inline"
|
||||
title="Unfollow this issue">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="next" value="{{ url_for('issues.index', page=issues.page, issue_id=issue_id_filter, severity=severity_filter, status=status_filter, contract_id=contract_filter, facility_id=facility_filter, date_from=date_from_filter, date_to=date_to_filter, reporter_id=reporter_filter) }}">
|
||||
<input type="hidden" name="next" value="{{ current_url() }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-primary p-0 px-1 me-1"
|
||||
title="Unfollow">
|
||||
<i class="bi bi-bell-slash" style="font-size:.75rem;"></i>
|
||||
@@ -210,7 +220,7 @@
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<a href="{{ url_for('issues.view', issue_id=issue.id) }}"
|
||||
<a href="{{ url_for('issues.view', issue_id=issue.id, next=current_url()) }}"
|
||||
class="btn btn-sm btn-outline-secondary">
|
||||
{% if current_user.role in ['admin','director','auditor'] or issue.assigned_to == current_user.id %}
|
||||
<i class="bi bi-pencil"></i> Edit
|
||||
@@ -223,6 +233,7 @@
|
||||
class="d-inline"
|
||||
onsubmit="return confirm('Permanently delete Issue #{{ issue.id }}? This cannot be undone.');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="next" value="{{ current_url() }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger"
|
||||
title="Delete Issue #{{ issue.id }}">
|
||||
<i class="bi bi-trash"></i>
|
||||
@@ -259,6 +270,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
{% include 'partials/bulk_select_js.html' %}
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
+353
-54
@@ -19,6 +19,30 @@
|
||||
|
||||
{% block content %}
|
||||
{% set can_edit = current_user.role in ['admin','director','auditor'] or issue.assigned_to == current_user.id %}
|
||||
{# The filtered list URL this page was opened from (phase: filter
|
||||
preservation). Threaded into every action so an update or delete
|
||||
returns to the same filtered page, and used by the Back button. #}
|
||||
{% set back_url = request.args.get('next') or url_for('issues.index') %}
|
||||
{# Is the viewer OUR staff? Drives the internal-only chrome on this page: the
|
||||
"comments are visible to everyone" warning and the per-comment
|
||||
"Customer visible" / "Staff only" badges. Both are instructions about how WE
|
||||
work and must never reach a customer account.
|
||||
|
||||
Written as an explicit ALLOWLIST of our own roles, deliberately:
|
||||
|
||||
* It FAILS CLOSED. The obvious form, `not current_user.is_customer_account`,
|
||||
fails OPEN — if the attribute is missing for any reason (a process still
|
||||
running an older models/user.py after a template-only reload, say) Jinja
|
||||
yields Undefined, `not Undefined` is true, and the internal text is shown
|
||||
to exactly the people it must be hidden from. An allowlist of literal role
|
||||
strings can only ever be true for a role we listed.
|
||||
* `external_inspector` is absent ON PURPOSE. This is NOT the rule-87 case:
|
||||
rule 87 is about capability/scoping, where a Customer Inspector must
|
||||
behave exactly like our own inspector. Here the question is "does this
|
||||
person work for us?", which is the one place the two genuinely differ.
|
||||
Do not "fix" this by adding external_inspector to the list. #}
|
||||
{% set viewer_is_our_staff = current_user.role in
|
||||
['admin', 'director', 'project_manager', 'auditor', 'inspector'] %}
|
||||
|
||||
<div class="row">
|
||||
{# ══════════════════════════════════ LEFT COLUMN ══════════════════════════════════ #}
|
||||
@@ -76,10 +100,11 @@
|
||||
<dt class="col-sm-3">Assigned To</dt>
|
||||
<dd class="col-sm-9">{{ issue.assigned_user.display_name if issue.assigned_user else '— Unassigned —' }}</dd>
|
||||
|
||||
{% if issue.handler_type and issue.handler_type != 'internal' %}
|
||||
{% set _ht = issue.handler_type or 'internal' %}
|
||||
{% if _ht != 'internal' or issue.internal_handler_name or issue.internal_handler_contact %}
|
||||
<dt class="col-sm-3">Handled By</dt>
|
||||
<dd class="col-sm-9">
|
||||
{% if issue.handler_type == 'facility' %}
|
||||
{% if _ht == 'facility' %}
|
||||
<span class="badge bg-secondary">
|
||||
<i class="bi bi-building me-1"></i>Facility Staff
|
||||
</span>
|
||||
@@ -92,8 +117,18 @@
|
||||
{% if issue.facility_handler_notes %}
|
||||
<div class="text-muted small mt-1" style="white-space:pre-wrap;">{{ issue.facility_handler_notes }}</div>
|
||||
{% endif %}
|
||||
{% elif issue.handler_type == 'vendor' %}
|
||||
{% elif _ht == 'vendor' %}
|
||||
<span class="badge bg-dark"><i class="bi bi-person-gear me-1"></i>External Vendor</span>
|
||||
{% else %}
|
||||
<span class="badge bg-light text-dark border">
|
||||
<i class="bi bi-people me-1"></i>Janitorial Staff
|
||||
</span>
|
||||
{% if issue.internal_handler_name %}
|
||||
<span class="ms-2 fw-semibold">{{ issue.internal_handler_name }}</span>
|
||||
{% endif %}
|
||||
{% if issue.internal_handler_contact %}
|
||||
<span class="text-muted ms-2">{{ issue.internal_handler_contact }}</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</dd>
|
||||
{% endif %}
|
||||
@@ -176,6 +211,7 @@
|
||||
<strong>Awaiting director verification.</strong>
|
||||
{% if current_user.role in ['admin','director','auditor'] %}
|
||||
<form method="POST" action="{{ url_for('issues.verify', issue_id=issue.id) }}" class="mt-2">
|
||||
<input type="hidden" name="next" value="{{ back_url }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="mb-2">
|
||||
<input type="text" name="verification_note" class="form-control form-control-sm"
|
||||
@@ -191,6 +227,84 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Linked issues ──────────────────────────────────────────────────────
|
||||
Duplicates and related issues, so whoever picks this one up can reach the
|
||||
others. `issue_links` arrives already filtered to links whose far end this
|
||||
viewer may open (_readable_links) — do NOT add links from the model
|
||||
directly here, or a customer sees an issue at a facility they have no
|
||||
assignment to. Links are navigational only: nothing here changes status,
|
||||
SLA, assignee or followers on either issue. #}
|
||||
<div class="card shadow-sm mb-4" id="linked-issues-section">
|
||||
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0">
|
||||
<i class="bi bi-link-45deg me-1"></i>Linked Issues
|
||||
<span class="badge bg-secondary rounded-pill ms-1">{{ issue_links|length }}</span>
|
||||
</h6>
|
||||
{% if can_manage_links %}
|
||||
<button type="button" class="btn btn-sm btn-outline-primary"
|
||||
data-bs-toggle="modal" data-bs-target="#linkIssueModal">
|
||||
<i class="bi bi-plus-lg me-1"></i>Link an Issue
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="card-body py-2">
|
||||
{% if issue_links %}
|
||||
<div class="list-group list-group-flush">
|
||||
{% for link, other, label in issue_links %}
|
||||
<div class="list-group-item px-0 py-2 d-flex align-items-start gap-2 flex-wrap">
|
||||
<span class="badge {{ 'bg-warning text-dark' if link.link_type == 'duplicate' else 'bg-info text-dark' }} mt-1"
|
||||
style="min-width:7.5rem;">{{ label }}</span>
|
||||
|
||||
<div class="flex-grow-1" style="min-width:14rem;">
|
||||
<a href="{{ url_for('issues.view', issue_id=other.id, next=back_url) }}"
|
||||
class="fw-semibold text-decoration-none">#{{ other.id }}</a>
|
||||
<span class="text-muted small ms-1">
|
||||
{{ other.area.name if other.area
|
||||
else other.resolved_facility.name if other.resolved_facility else '—' }}
|
||||
</span>
|
||||
<div class="small text-muted text-truncate" style="max-width:38rem;">
|
||||
{{ other.description }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-items-center gap-1 mt-1">
|
||||
<span class="badge bg-{{ 'danger' if other.severity in ['critical','high']
|
||||
else 'warning text-dark' if other.severity == 'medium'
|
||||
else 'secondary' }}">{{ other.severity|title }}</span>
|
||||
<span class="badge bg-{{ 'success' if other.status == 'resolved'
|
||||
else 'info text-dark' if other.status == 'pending_verification'
|
||||
else 'light text-dark' }}">
|
||||
{{ other.status|replace('_',' ')|title }}
|
||||
</span>
|
||||
{% if can_manage_links %}
|
||||
<form method="POST" class="mb-0 ms-1"
|
||||
action="{{ url_for('issues.remove_link', issue_id=issue.id, link_id=link.id) }}"
|
||||
onsubmit="return confirm('Remove the link between #{{ issue.id }} and #{{ other.id }}? Neither issue is changed or deleted.');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="next" value="{{ back_url }}">
|
||||
<button type="submit" class="btn btn-sm btn-link text-muted p-0 px-1"
|
||||
title="Remove this link">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted small mb-0 py-1">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
No linked issues.
|
||||
{% if can_manage_links %}
|
||||
Use <strong>Link an Issue</strong> to point at a duplicate or a related issue.
|
||||
{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Comments ───────────────────────────────────────────────────────── #}
|
||||
<div class="card shadow-sm mb-4" id="comments-section">
|
||||
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
||||
@@ -219,8 +333,12 @@
|
||||
{% else %}
|
||||
<span class="badge bg-secondary" style="font-size:.65rem;">{{ c.author.role|replace('_',' ')|title }}</span>
|
||||
{% endif %}
|
||||
{# Visibility indicator — staff only #}
|
||||
{% if current_user.role != 'customer' %}
|
||||
{# Visibility indicator — OUR staff only, and only while the
|
||||
per-comment flag still decides anything. While comments_open
|
||||
is set EVERY comment reaches the customer, so a "Staff only"
|
||||
badge would be a lie; it is suppressed rather than shown
|
||||
incorrectly. #}
|
||||
{% if viewer_is_our_staff and not comments_open %}
|
||||
{% if c.is_customer_visible %}
|
||||
<span class="badge bg-success bg-opacity-10 text-success border border-success"
|
||||
style="font-size:.6rem;" title="Customer can see this comment">
|
||||
@@ -263,6 +381,7 @@
|
||||
<div class="card-body">
|
||||
<p class="fw-semibold small mb-2">Add Comment</p>
|
||||
<form method="post">
|
||||
<input type="hidden" name="next" value="{{ back_url }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="status" value="{{ issue.status }}">
|
||||
<input type="hidden" name="assigned_to" value="{{ issue.assigned_to or 0 }}">
|
||||
@@ -270,6 +389,25 @@
|
||||
<textarea name="update_notes" class="form-control" rows="3"
|
||||
placeholder="Write a comment…" required></textarea>
|
||||
</div>
|
||||
{# While comments_open is set, every comment reaches the customer, so
|
||||
the "Share with customer" tick decides nothing. Saying so plainly
|
||||
matters: a staff member must not write something they believe is
|
||||
private. The checkbox is still posted and recorded, so turning the
|
||||
setting off restores its meaning immediately.
|
||||
|
||||
OUR STAFF ONLY. `can_edit` is also true for a Customer Inspector
|
||||
assigned to the issue, and this banner is an internal-process
|
||||
warning ("do not post internal-only notes") — showing it to a
|
||||
customer account exposes how we work and reads as nonsense to
|
||||
them, since nothing they write was ever private. #}
|
||||
{% if comments_open and viewer_is_our_staff %}
|
||||
<div class="alert alert-warning py-2 px-3 small mb-2">
|
||||
<i class="bi bi-eye me-1"></i>
|
||||
<strong>Comments are currently visible to everyone,</strong> including
|
||||
the customer, regardless of the tick below. Do not post internal-only
|
||||
notes here.
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2">
|
||||
<div class="form-check form-check-inline mb-0">
|
||||
<input class="form-check-input" type="checkbox"
|
||||
@@ -290,6 +428,7 @@
|
||||
<div class="card-body">
|
||||
<p class="fw-semibold small mb-2">Add Comment</p>
|
||||
<form method="post">
|
||||
<input type="hidden" name="next" value="{{ back_url }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="mb-2">
|
||||
<textarea name="update_notes" class="form-control" rows="3"
|
||||
@@ -358,6 +497,7 @@
|
||||
<div class="card-header bg-light"><h6 class="mb-0">Update Issue</h6></div>
|
||||
<div class="card-body">
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
<input type="hidden" name="next" value="{{ back_url }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="mb-3">
|
||||
{{ form.status.label(class="form-label fw-semibold") }}
|
||||
@@ -416,13 +556,30 @@
|
||||
placeholder="Notes about what they are handling…") }}
|
||||
</div>
|
||||
</div>
|
||||
<div id="internalHandlerFields"
|
||||
style="{{ '' if (issue.handler_type or 'internal') == 'internal' else 'display:none;' }}">
|
||||
<div class="mb-2">
|
||||
{{ form.internal_handler_name.label(class="form-label small fw-semibold mb-1") }}
|
||||
{{ form.internal_handler_name(class="form-control form-control-sm",
|
||||
placeholder="Crew member handling this",
|
||||
value=issue.internal_handler_name or '') }}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
{{ form.internal_handler_contact.label(class="form-label small fw-semibold mb-1") }}
|
||||
{{ form.internal_handler_contact(class="form-control form-control-sm",
|
||||
placeholder="Phone or email",
|
||||
value=issue.internal_handler_contact or '') }}
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function(){
|
||||
var sel = document.getElementById('handlerTypeSelect');
|
||||
var box = document.getElementById('facilityHandlerFields');
|
||||
if(sel && box){
|
||||
var inv = document.getElementById('internalHandlerFields');
|
||||
if(sel){
|
||||
sel.addEventListener('change', function(){
|
||||
box.style.display = (this.value === 'facility') ? '' : 'none';
|
||||
if(box){ box.style.display = (this.value === 'facility') ? '' : 'none'; }
|
||||
if(inv){ inv.style.display = (this.value === 'internal') ? '' : 'none'; }
|
||||
});
|
||||
}
|
||||
})();
|
||||
@@ -466,58 +623,12 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Vendor Work Orders (phase36) ───────────────────────────────────── #}
|
||||
{% if current_user.role in ['admin','director','project_manager','auditor'] %}
|
||||
<div class="card shadow-sm mt-3">
|
||||
<div class="card-header bg-light">
|
||||
<h6 class="mb-0"><i class="bi bi-send me-1"></i>Contractor Work Orders</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% set wos = issue.work_orders.all() %}
|
||||
{% if wos %}
|
||||
<ul class="list-unstyled small mb-3">
|
||||
{% for wo in wos %}
|
||||
{% set b = {'sent':'secondary','acknowledged':'info','completed':'success'}[wo.status] %}
|
||||
<li class="d-flex justify-content-between align-items-center border-bottom py-1">
|
||||
<span class="text-truncate me-2">{{ wo.vendor_name }}
|
||||
<span class="text-muted d-block" style="font-size:.75rem;">{{ wo.vendor_email }}</span>
|
||||
</span>
|
||||
<span class="badge bg-{{ b }}">{{ wo.status_label }}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
<form method="POST" action="{{ url_for('issues.dispatch_work_order', issue_id=issue.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="mb-2">
|
||||
<label class="form-label small fw-semibold mb-1">Contractor name</label>
|
||||
<input type="text" name="vendor_name" class="form-control form-control-sm"
|
||||
value="{{ issue.vendor_name or '' }}" placeholder="e.g. Ace Plumbing" required>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small fw-semibold mb-1">Contractor email</label>
|
||||
<input type="email" name="vendor_email" class="form-control form-control-sm"
|
||||
placeholder="name@contractor.com" required>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small fw-semibold mb-1">Message <span class="text-muted">(optional)</span></label>
|
||||
<textarea name="message" class="form-control form-control-sm" rows="2"
|
||||
placeholder="Any specific instructions…"></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-outline-primary btn-sm w-100">
|
||||
<i class="bi bi-envelope-paper me-1"></i> Send Work Order
|
||||
</button>
|
||||
<div class="form-text">Emails the contractor a private link to acknowledge & complete — no account needed.</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>{# /col-lg-4 #}
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-items-center gap-2 mt-2">
|
||||
<a href="{{ url_for('issues.index') }}" class="btn btn-outline-secondary btn-sm">
|
||||
<a href="{{ back_url }}" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-arrow-left"></i> Back to Issues
|
||||
</a>
|
||||
<a href="{{ url_for('issues.export_pdf', issue_id=issue.id) }}" class="btn btn-outline-primary btn-sm">
|
||||
@@ -531,6 +642,77 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# ── Link an issue ────────────────────────────────────────────────────────────
|
||||
Search is scoped server-side to issues this viewer could already open, so the
|
||||
picker can never be used to enumerate another contract's issues. The POST
|
||||
re-checks access — the search is only a convenience. #}
|
||||
{% if can_manage_links %}
|
||||
<div class="modal fade" id="linkIssueModal" tabindex="-1"
|
||||
aria-labelledby="linkIssueModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<form method="POST" action="{{ url_for('issues.add_link', issue_id=issue.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="next" value="{{ back_url }}">
|
||||
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="linkIssueModalLabel">
|
||||
<i class="bi bi-link-45deg me-1"></i>Link an issue to #{{ issue.id }}
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold" for="linkTypeSelect">
|
||||
How are they related?
|
||||
</label>
|
||||
<select name="link_type" id="linkTypeSelect" class="form-select form-select-sm">
|
||||
{% for value, label in link_types %}
|
||||
<option value="{{ value }}">
|
||||
#{{ issue.id }} is a <strong>{{ label|lower }}</strong> …
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="form-text">
|
||||
Linking is for navigation only — neither issue's status, SLA or
|
||||
assignee changes.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<label class="form-label small fw-semibold" for="linkIssueSearch">
|
||||
Which issue?
|
||||
</label>
|
||||
<input type="text" class="form-control form-control-sm" id="linkIssueSearch"
|
||||
autocomplete="off" placeholder="Issue number, or words from the description…">
|
||||
<input type="hidden" name="linked_issue_id" id="linkIssueId">
|
||||
</div>
|
||||
|
||||
{# Chosen issue, shown once picked so nobody submits a mistyped number #}
|
||||
<div id="linkIssueChosen" class="alert alert-primary py-2 small d-none mb-2">
|
||||
<span id="linkIssueChosenText"></span>
|
||||
<button type="button" class="btn btn-sm btn-link p-0 ms-2" id="linkIssueClear">change</button>
|
||||
</div>
|
||||
|
||||
<div id="linkIssueResults" class="list-group small" style="max-height:16rem; overflow-y:auto;"></div>
|
||||
<div id="linkIssueEmpty" class="text-muted small d-none py-2">
|
||||
No matching issue you can access.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary btn-sm" id="linkIssueSubmit" disabled>
|
||||
<i class="bi bi-link-45deg me-1"></i>Link Issue
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if current_user.role in ['admin', 'director'] %}
|
||||
<div class="modal fade" id="deleteIssueModal" tabindex="-1" aria-labelledby="deleteIssueModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
@@ -555,6 +737,7 @@
|
||||
<i class="bi bi-x-circle"></i> Cancel
|
||||
</button>
|
||||
<form method="POST" action="{{ url_for('issues.delete', issue_id=issue.id) }}" class="d-inline">
|
||||
<input type="hidden" name="next" value="{{ back_url }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<i class="bi bi-trash-fill"></i> Delete Permanently
|
||||
@@ -585,6 +768,122 @@
|
||||
section.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
}
|
||||
// ── Link-an-issue picker ────────────────────────────────────────────────
|
||||
// Type a number or some words, pick from the scoped results, submit. The
|
||||
// hidden linked_issue_id is only ever set by CHOOSING a result, so the
|
||||
// number posted is always one the server just confirmed this user can see.
|
||||
//
|
||||
// Every result field is written with textContent / createTextNode, never
|
||||
// innerHTML: `description` is text a person typed and would otherwise be
|
||||
// an XSS hole straight into the page of whoever opens the picker.
|
||||
var linkSearch = document.getElementById('linkIssueSearch');
|
||||
if (linkSearch) {
|
||||
var linkResults = document.getElementById('linkIssueResults');
|
||||
var linkEmpty = document.getElementById('linkIssueEmpty');
|
||||
var linkIdField = document.getElementById('linkIssueId');
|
||||
var linkChosen = document.getElementById('linkIssueChosen');
|
||||
var linkChosenText = document.getElementById('linkIssueChosenText');
|
||||
var linkClear = document.getElementById('linkIssueClear');
|
||||
var linkSubmit = document.getElementById('linkIssueSubmit');
|
||||
var searchTimer = null;
|
||||
var searchSeq = 0;
|
||||
|
||||
function clearChoice() {
|
||||
linkIdField.value = '';
|
||||
linkSubmit.disabled = true;
|
||||
linkChosen.classList.add('d-none');
|
||||
linkSearch.classList.remove('d-none');
|
||||
}
|
||||
|
||||
function choose(item) {
|
||||
linkIdField.value = item.id;
|
||||
linkSubmit.disabled = false;
|
||||
linkChosenText.textContent =
|
||||
'#' + item.id + ' — ' + item.location + ' — ' + item.description;
|
||||
linkChosen.classList.remove('d-none');
|
||||
linkSearch.classList.add('d-none');
|
||||
linkResults.innerHTML = '';
|
||||
linkEmpty.classList.add('d-none');
|
||||
}
|
||||
|
||||
function renderResults(items) {
|
||||
linkResults.innerHTML = '';
|
||||
linkEmpty.classList.toggle('d-none', items.length > 0);
|
||||
|
||||
items.forEach(function (item) {
|
||||
var row = document.createElement('button');
|
||||
row.type = 'button';
|
||||
row.className = 'list-group-item list-group-item-action py-2';
|
||||
|
||||
var head = document.createElement('div');
|
||||
head.className = 'd-flex justify-content-between gap-2';
|
||||
|
||||
var num = document.createElement('span');
|
||||
num.className = 'fw-semibold';
|
||||
num.textContent = '#' + item.id + ' · ' + item.location;
|
||||
|
||||
var meta = document.createElement('span');
|
||||
meta.className = 'text-muted';
|
||||
meta.textContent = item.severity + ' · ' + item.status +
|
||||
(item.reported_at ? ' · ' + item.reported_at : '');
|
||||
|
||||
head.appendChild(num);
|
||||
head.appendChild(meta);
|
||||
|
||||
var desc = document.createElement('div');
|
||||
desc.className = 'text-muted text-truncate';
|
||||
desc.textContent = item.description;
|
||||
|
||||
row.appendChild(head);
|
||||
row.appendChild(desc);
|
||||
row.addEventListener('click', function () { choose(item); });
|
||||
linkResults.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function runSearch() {
|
||||
var term = linkSearch.value.trim();
|
||||
if (!term) {
|
||||
linkResults.innerHTML = '';
|
||||
linkEmpty.classList.add('d-none');
|
||||
return;
|
||||
}
|
||||
// Responses can arrive out of order; only the newest one may render.
|
||||
var seq = ++searchSeq;
|
||||
fetch('{{ url_for("issues.link_search", issue_id=issue.id) }}?q=' +
|
||||
encodeURIComponent(term), { headers: { 'Accept': 'application/json' } })
|
||||
.then(function (res) { return res.ok ? res.json() : { results: [] }; })
|
||||
.then(function (data) {
|
||||
if (seq !== searchSeq) { return; }
|
||||
renderResults(data.results || []);
|
||||
})
|
||||
.catch(function () {
|
||||
if (seq !== searchSeq) { return; }
|
||||
renderResults([]);
|
||||
});
|
||||
}
|
||||
|
||||
linkSearch.addEventListener('input', function () {
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(runSearch, 250);
|
||||
});
|
||||
// The picker lives inside a form — Enter would submit it with no issue
|
||||
// chosen instead of searching.
|
||||
linkSearch.addEventListener('keydown', function (ev) {
|
||||
if (ev.key === 'Enter') {
|
||||
ev.preventDefault();
|
||||
clearTimeout(searchTimer);
|
||||
runSearch();
|
||||
}
|
||||
});
|
||||
linkClear.addEventListener('click', function () {
|
||||
clearChoice();
|
||||
linkSearch.value = '';
|
||||
linkSearch.focus();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -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>
|
||||
@@ -115,6 +115,9 @@
|
||||
data-inspector-id="{{ s.id }}">
|
||||
<td class="fw-semibold">
|
||||
{{ s.display_name }}
|
||||
{% if s.external %}
|
||||
<span class="badge bg-dark ms-1" title="Customer / third-party inspector">Customer</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-center">{{ s.total }}</td>
|
||||
<td class="text-center">{{ s.completed }}</td>
|
||||
@@ -189,6 +192,9 @@
|
||||
<div class="card-header bg-primary text-white d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0">
|
||||
<i class="bi bi-person-circle me-2"></i>{{ selected_inspector.display_name }}
|
||||
{% if selected_inspector.is_external_inspector %}
|
||||
<span class="badge bg-dark ms-1" title="Customer / third-party inspector">Customer</span>
|
||||
{% endif %}
|
||||
</h6>
|
||||
<a href="{{ url_for('reports.inspector_performance', start=start.strftime('%Y-%m-%d'), end=end.strftime('%Y-%m-%d')) }}"
|
||||
class="btn btn-sm btn-light text-primary">
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
<a href="{{ url_for('support.admin_tickets') }}" class="btn btn-outline-secondary btn-sm me-1">
|
||||
<i class="bi bi-inbox me-1"></i>Tickets
|
||||
</a>
|
||||
<a href="{{ url_for('support.admin_knowledge_preview') }}" class="btn btn-outline-primary btn-sm"
|
||||
title="See the exact prompt the chatbot receives, with your entries in it">
|
||||
<i class="bi bi-eye me-1"></i>What the AI Sees
|
||||
</a>
|
||||
<a href="{{ url_for('support.admin_conversations') }}" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-chat-dots me-1"></i>Conversations
|
||||
</a>
|
||||
@@ -34,6 +38,12 @@
|
||||
placeholder="Add facts, FAQs, or instructions the AI should know about this customer's account…"></textarea>
|
||||
<div class="form-text">Keep entries focused and factual. Combined active entries are capped at 6,000 characters.</div>
|
||||
</div>
|
||||
<div class="mb-3" style="max-width:200px;">
|
||||
<label class="form-label fw-semibold">Sort Order</label>
|
||||
<input type="number" name="sort_order" class="form-control"
|
||||
min="0" max="9999" step="1" value="0">
|
||||
<div class="form-text">Lower numbers are sent to the assistant first. Leave at 0 unless an entry should take priority.</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-sm">
|
||||
<i class="bi bi-plus me-1"></i>Add Entry
|
||||
</button>
|
||||
@@ -54,6 +64,7 @@
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div class="flex-grow-1 me-3">
|
||||
<div class="d-flex align-items-center gap-2 mb-1">
|
||||
<span class="badge bg-light text-dark border" title="Sort order">#{{ entry.sort_order }}</span>
|
||||
<span class="fw-semibold">{{ entry.title }}</span>
|
||||
{% if entry.active %}
|
||||
<span class="badge bg-success">Active</span>
|
||||
|
||||
@@ -23,6 +23,12 @@
|
||||
<textarea name="body" class="form-control" rows="8" required>{{ entry.body }}</textarea>
|
||||
<div class="form-text">Combined active entries are capped at 6,000 characters in the AI prompt.</div>
|
||||
</div>
|
||||
<div class="mb-3" style="max-width:200px;">
|
||||
<label class="form-label fw-semibold">Sort Order</label>
|
||||
<input type="number" name="sort_order" class="form-control"
|
||||
min="0" max="9999" step="1" value="{{ entry.sort_order }}">
|
||||
<div class="form-text">Lower numbers are sent to the assistant first. Leave at 0 unless an entry should take priority.</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-check-lg me-1"></i>Save Changes
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}What the AI Sees{% endblock %}
|
||||
|
||||
{# Read-only view of the assembled system prompt. Exists so an admin can tell
|
||||
"my knowledge entry never reached the prompt" apart from "the model saw it
|
||||
and chose not to use it" — the two have completely different fixes. #}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-center mb-3 gap-2">
|
||||
<h2 class="mb-0"><i class="bi bi-eye"></i> What the AI Sees</h2>
|
||||
<a href="{{ url_for('support.admin_knowledge') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Back to Knowledge Base
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-body text-center py-3">
|
||||
<div class="fs-4 fw-bold">{{ active_count }}</div>
|
||||
<div class="text-muted small">Active entries</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-body text-center py-3">
|
||||
<div class="fs-4 fw-bold">{{ total_count - active_count }}</div>
|
||||
<div class="text-muted small">Inactive (not sent)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-body text-center py-3">
|
||||
<div class="fs-4 fw-bold">{{ prompt | length }}</div>
|
||||
<div class="text-muted small">Prompt characters</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-body text-center py-3">
|
||||
{% if kb_included %}
|
||||
<div class="fs-4 fw-bold text-success"><i class="bi bi-check-circle"></i></div>
|
||||
<div class="text-muted small">Knowledge included</div>
|
||||
{% else %}
|
||||
<div class="fs-4 fw-bold text-danger"><i class="bi bi-x-circle"></i></div>
|
||||
<div class="text-muted small">Knowledge NOT included</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if not kb_included and total_count %}
|
||||
<div class="alert alert-warning">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||
You have {{ total_count }} knowledge entr{{ 'y' if total_count == 1 else 'ies' }}, but
|
||||
none reached the prompt. Check that at least one is marked <strong>Active</strong>.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="alert alert-info">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
This is the exact text sent to the AI ahead of every customer question. Entries are
|
||||
capped at {{ kb_cap }} characters in total — past that, later entries are dropped
|
||||
(lowest sort order is kept first). If something you wrote appears here but the AI still
|
||||
will not say it, the wording of the entry is the thing to change, not the setup.
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-light fw-semibold">Assembled system prompt</div>
|
||||
<div class="card-body p-0">
|
||||
<pre class="mb-0 p-3" style="white-space:pre-wrap; font-size:.8rem; max-height:70vh;
|
||||
overflow-y:auto; background:#f8fafc;">{{ prompt }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -160,6 +160,15 @@
|
||||
{{ form.frequency.label(class="form-label fw-semibold small") }}
|
||||
{{ form.frequency(class="form-select form-select-sm") }}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
{{ form.contract_ids.label(class="form-label fw-semibold small") }}
|
||||
{{ form.contract_ids(class="form-select form-select-sm", size=6) }}
|
||||
<div class="form-text small">
|
||||
Nothing selected = shared with every contract. Select contracts to
|
||||
restrict this form to them (hidden from all other customers).
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="d-grid gap-2">
|
||||
<button type="submit" class="btn btn-primary btn-sm">
|
||||
|
||||
@@ -27,6 +27,18 @@
|
||||
{{ form.frequency.label(class="form-label") }}
|
||||
{{ form.frequency(class="form-select") }}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
{{ form.contract_ids.label(class="form-label") }}
|
||||
{{ form.contract_ids(class="form-select", size=6) }}
|
||||
<div class="form-text">
|
||||
Leave <strong>nothing selected</strong> to share this form with
|
||||
every contract. Select one or more contracts to make it
|
||||
specific to them — it will then be hidden from every other
|
||||
customer's facilities, on the web and in the iPad app.
|
||||
Ctrl/Cmd-click to select several.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
|
||||
@@ -38,6 +38,24 @@
|
||||
<i class="bi bi-check2-square"></i> {{ template.checklist_items.count() }} items
|
||||
</small>
|
||||
</div>
|
||||
|
||||
{# phase52 — who may use this form. No links = shared with all. #}
|
||||
<div class="mt-2">
|
||||
{% if template.is_shared %}
|
||||
<span class="badge bg-light text-dark border"
|
||||
title="Available on every contract">
|
||||
<i class="bi bi-globe2"></i> Shared
|
||||
</span>
|
||||
{% else %}
|
||||
{% for link in template.contract_links %}
|
||||
<span class="badge bg-primary"
|
||||
title="Only available on this contract">
|
||||
<i class="bi bi-briefcase"></i>
|
||||
{{ link.project.name if link.project else 'contract #' ~ link.project_id }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-footer bg-transparent d-flex gap-2 align-items-center flex-wrap">
|
||||
@@ -56,6 +74,7 @@
|
||||
data-template-name="{{ template.name }}"
|
||||
data-template-description="{{ template.description or '' }}"
|
||||
data-template-frequency="{{ template.frequency or 'daily' }}"
|
||||
data-template-contracts="{{ template.contract_ids|join(',') }}"
|
||||
title="Edit template details">
|
||||
<i class="bi bi-pencil"></i> Edit
|
||||
</button>
|
||||
@@ -142,7 +161,7 @@
|
||||
placeholder="Optional description"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="mb-1">
|
||||
<div class="mb-3">
|
||||
<label for="editFrequency" class="form-label fw-semibold">Frequency</label>
|
||||
<select id="editFrequency" name="frequency" class="form-select">
|
||||
<option value="daily">Daily</option>
|
||||
@@ -151,6 +170,34 @@
|
||||
<option value="quarterly">Quarterly</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{# phase52 — which contracts may use this form. The hidden
|
||||
marker tells the route this modal really did include the
|
||||
field, so an empty selection means "share it" rather
|
||||
than "no field was posted, leave it alone". #}
|
||||
<div class="mb-1">
|
||||
<label for="editContracts" class="form-label fw-semibold">
|
||||
Available on contracts
|
||||
</label>
|
||||
<input type="hidden" name="contracts_present" value="1">
|
||||
<select id="editContracts" name="contract_ids"
|
||||
class="form-select" multiple size="6">
|
||||
{% for p in contracts %}
|
||||
<option value="{{ p.id }}">{{ p.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="form-text">
|
||||
Leave <strong>nothing selected</strong> to share this form with
|
||||
every contract (this is how all existing forms are set).
|
||||
Select one or more contracts to restrict it to them — it is
|
||||
then hidden from every other customer, on the web and in the
|
||||
iPad app. Ctrl/Cmd-click to select several.
|
||||
</div>
|
||||
<button type="button" id="clearContracts"
|
||||
class="btn btn-sm btn-link px-0 mt-1">
|
||||
Clear selection (make shared)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||||
@@ -228,6 +275,17 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
document.getElementById('renameInput').value = templateName;
|
||||
document.getElementById('editDescription').value = templateDesc;
|
||||
document.getElementById('editFrequency').value = templateFreq;
|
||||
|
||||
// Pre-tick the contracts this form is currently restricted to. An
|
||||
// empty attribute means it is shared, so nothing is selected.
|
||||
const contractSel = document.getElementById('editContracts');
|
||||
if (contractSel) {
|
||||
const current = (btn.getAttribute('data-template-contracts') || '')
|
||||
.split(',').filter(Boolean);
|
||||
Array.from(contractSel.options).forEach(function (o) {
|
||||
o.selected = current.indexOf(o.value) !== -1;
|
||||
});
|
||||
}
|
||||
document.getElementById('renameTemplateForm').action =
|
||||
'/templates/' + templateId + '/rename';
|
||||
|
||||
@@ -238,6 +296,14 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
});
|
||||
});
|
||||
|
||||
const clearBtn = document.getElementById('clearContracts');
|
||||
if (clearBtn) {
|
||||
clearBtn.addEventListener('click', function () {
|
||||
const sel = document.getElementById('editContracts');
|
||||
Array.from(sel.options).forEach(function (o) { o.selected = false; });
|
||||
});
|
||||
}
|
||||
|
||||
const deleteModal = document.getElementById('deleteModal');
|
||||
deleteModal.addEventListener('show.bs.modal', function (event) {
|
||||
const btn = event.relatedTarget;
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
<label class="form-label fw-semibold">Logo</label>
|
||||
{% if settings.logo_url %}
|
||||
<div class="mb-2">
|
||||
<img src="{{ url_for('static', filename=settings.logo_url) }}"
|
||||
<img src="{{ media_url(settings.logo_url) }}"
|
||||
alt="Current logo" style="max-height:48px; border-radius:6px;">
|
||||
<div class="form-check mt-1">
|
||||
<input class="form-check-input" type="checkbox" name="clear_logo" id="clear_logo">
|
||||
@@ -105,7 +105,7 @@
|
||||
<nav class="navbar navbar-dark px-3 py-2" id="preview-navbar"
|
||||
style="background-color: {{ settings.primary_color or '#1a56db' }}; border-radius:0 0 6px 6px;">
|
||||
{% if settings.logo_url %}
|
||||
<img src="{{ url_for('static', filename=settings.logo_url) }}"
|
||||
<img src="{{ media_url(settings.logo_url) }}"
|
||||
alt="logo" style="max-height:32px; margin-right:.5rem; border-radius:4px;">
|
||||
{% else %}
|
||||
<i class="bi bi-clipboard-check me-2"></i>
|
||||
|
||||
@@ -98,17 +98,22 @@
|
||||
('facilities', 'Active Facilities (total)', plan_info.max_facilities),
|
||||
] %}
|
||||
{% for key, label, limit in axes %}
|
||||
{% set current = quota_usage.get(key, 0) %}
|
||||
{% set pct = ((current / limit * 100) | int) if limit else 0 %}
|
||||
{% set over = limit and current >= limit %}
|
||||
{# None = the counter failed (logged server-side). Rendered as "—"
|
||||
rather than 0 so an unknown count is never mistaken for real usage. #}
|
||||
{% set current = quota_usage.get(key) %}
|
||||
{% set unknown = current is none %}
|
||||
{% set pct = ((current / limit * 100) | int) if (limit and not unknown) else 0 %}
|
||||
{% set over = limit and not unknown and current >= limit %}
|
||||
<div class="mb-3">
|
||||
<div class="d-flex justify-content-between mb-1" style="font-size:.85rem;">
|
||||
<span>{{ label }}</span>
|
||||
<span class="{{ 'text-danger fw-semibold' if over else 'text-muted' }}">
|
||||
{{ current }}{% if limit %} / {{ limit }}{% else %} / <em>unlimited</em>{% endif %}
|
||||
{% if unknown %}
|
||||
<span title="This count could not be read — see the server log.">—</span>
|
||||
{% else %}{{ current }}{% endif %}{% if limit %} / {{ limit }}{% else %} / <em>unlimited</em>{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
{% if limit %}
|
||||
{% if limit and not unknown %}
|
||||
<div class="progress" style="height:6px;">
|
||||
<div class="progress-bar {{ 'bg-danger' if over else ('bg-warning' if pct >= 80 else 'bg-success') }}"
|
||||
role="progressbar" style="width:{{ [pct,100]|min }}%"></div>
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}About{% endblock %}
|
||||
|
||||
{# About — linked from the modern sidebar.
|
||||
|
||||
MT note: ST's version of this page hardcodes its own company name and links
|
||||
the `enrollment` blueprint, neither of which is portable here. This version
|
||||
is tenant-neutral: the workspace name comes from tenant_branding (the same
|
||||
source the layouts use), and "add people" points at the Users admin page,
|
||||
which is how accounts are actually created in MT. #}
|
||||
|
||||
{% set workspace_name = tenant_branding.display_name if tenant_branding else 'this workspace' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="jqc-page-head center">
|
||||
<div class="jqc-page-title">About</div>
|
||||
<div class="jqc-page-sub text-center">
|
||||
Janitorial Quality Control{% if tenant_branding %} — {{ tenant_branding.display_name }}{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-12 col-lg-7">
|
||||
<div class="jqc-card h-100">
|
||||
<div class="jqc-card-title">
|
||||
<span class="jqc-tile-icon"><i class="bi bi-buildings"></i></span>{{ workspace_name }}
|
||||
</div>
|
||||
<p class="mb-3">
|
||||
Quality is verified in the field, not assumed. Every contract is backed by
|
||||
scheduled inspections, documented findings and tracked resolution, so what
|
||||
was checked, when, and by whom is always on the record.
|
||||
</p>
|
||||
<p class="mb-0 text-muted">
|
||||
JQC is the quality control platform behind that work. Inspectors work from an
|
||||
offline-capable iPad app; managers, contract staff and customers work from
|
||||
this web portal. Both share one record of every inspection, issue and photo.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-lg-5">
|
||||
<div class="jqc-card h-100">
|
||||
<div class="jqc-card-title">
|
||||
<span class="jqc-tile-icon"><i class="bi bi-shield-check"></i></span>Your data
|
||||
</div>
|
||||
<p class="mb-0 text-muted">
|
||||
{{ workspace_name }} has its own isolated database. Inspections, issues,
|
||||
photos and accounts belong to this workspace alone and are never shared
|
||||
with, or visible to, any other organisation using JQC.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if current_user.role == 'admin' %}
|
||||
<div class="col-12 col-lg-6">
|
||||
<a class="jqc-hub-card" href="{{ url_for('auth.list_users') }}">
|
||||
<div class="d-flex gap-3 align-items-start">
|
||||
<span class="jqc-tile-icon lg"><i class="bi bi-person-plus"></i></span>
|
||||
<div>
|
||||
<div class="jqc-hub-title">Add More People</div>
|
||||
<div class="jqc-hub-text">
|
||||
Create accounts for colleagues and set what each person can do.
|
||||
Customer Directors and Customer Inspectors are invited by email from
|
||||
Customer Management and choose their own username and password.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="jqc-hub-open">Open →</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{# The enrollment intake form is served by THIS app and is public (no login),
|
||||
so it is linked with url_for() rather than an absolute URL: the link then
|
||||
stays on whatever host the user is already on — which in MT is the tenant's
|
||||
own subdomain or custom domain — and cannot rot if that domain changes.
|
||||
Submissions are read at Admin -> Enrollment Forms. #}
|
||||
<div class="col-12 col-lg-6">
|
||||
<a class="jqc-hub-card" href="{{ url_for('enrollment.form') }}">
|
||||
<div class="d-flex gap-3 align-items-start">
|
||||
<span class="jqc-tile-icon lg"><i class="bi bi-clipboard-plus"></i></span>
|
||||
<div>
|
||||
<div class="jqc-hub-title">Enrollment Form</div>
|
||||
<div class="jqc-hub-text">
|
||||
Collect who needs access and what each person should be able to do.
|
||||
No login is required to fill it in, so the link can be forwarded to a
|
||||
customer; submissions arrive under Admin → Enrollment Forms.
|
||||
</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('enrollment.admin_list') }}">
|
||||
<div class="d-flex gap-3 align-items-start">
|
||||
<span class="jqc-tile-icon lg"><i class="bi bi-inboxes"></i></span>
|
||||
<div>
|
||||
<div class="jqc-hub-title">Enrollment Submissions</div>
|
||||
<div class="jqc-hub-text">
|
||||
Every enrollment form that has been submitted, with its office
|
||||
notes, status and CSV export.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="jqc-hub-open">Open →</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-lg-6">
|
||||
<a class="jqc-hub-card" href="{{ url_for('tenant_settings.branding') }}">
|
||||
<div class="d-flex gap-3 align-items-start">
|
||||
<span class="jqc-tile-icon lg"><i class="bi bi-palette"></i></span>
|
||||
<div>
|
||||
<div class="jqc-hub-title">Workspace Settings</div>
|
||||
<div class="jqc-hub-text">
|
||||
Set the workspace name, logo and colours used across the portal and
|
||||
in outbound email.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="jqc-hub-open">Open →</div>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="col-12">
|
||||
<div class="jqc-card">
|
||||
<div class="jqc-card-title">
|
||||
<span class="jqc-tile-icon"><i class="bi bi-envelope"></i></span>Contact & Support
|
||||
</div>
|
||||
<p class="mb-3">
|
||||
Questions about an inspection, an issue on a site, or access to the portal —
|
||||
start in the Support Center and it will be routed to the right person.
|
||||
</p>
|
||||
<a href="{{ url_for('ui.support_center') }}" class="btn btn-primary">
|
||||
<i class="bi bi-life-preserver me-1"></i>Go to Support
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,150 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Support{% endblock %}
|
||||
|
||||
{#
|
||||
Support Center — new page (design A/B test, slide 6 of JQC_design).
|
||||
Linked from the modern sidebar. Every card either opens an existing route or
|
||||
expands an inline how-to, so nothing here dead-ends.
|
||||
#}
|
||||
|
||||
{% block content %}
|
||||
<div class="jqc-page-head center">
|
||||
<div class="jqc-page-title">Support</div>
|
||||
<div class="jqc-page-sub text-center">JQC Features — find answers and how-to guides</div>
|
||||
</div>
|
||||
|
||||
{# ── Live support routes (role-aware) ──────────────────────────────────── #}
|
||||
<div class="row g-3 mb-4">
|
||||
{% if current_user.is_customer_account %}
|
||||
<div class="col-12 col-md-4">
|
||||
<a class="jqc-hub-card" href="{{ url_for('support.chat') }}">
|
||||
<div class="d-flex gap-3 align-items-center">
|
||||
<span class="jqc-tile-icon"><i class="bi bi-chat-dots"></i></span>
|
||||
<div class="jqc-hub-title" style="font-size:1.05rem;">Ask a Question</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<a class="jqc-hub-card" href="{{ url_for('support.my_conversations') }}">
|
||||
<div class="d-flex gap-3 align-items-center">
|
||||
<span class="jqc-tile-icon"><i class="bi bi-clock-history"></i></span>
|
||||
<div class="jqc-hub-title" style="font-size:1.05rem;">My Conversations</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<a class="jqc-hub-card" href="{{ url_for('support.my_tickets') }}">
|
||||
<div class="d-flex gap-3 align-items-center">
|
||||
<span class="jqc-tile-icon"><i class="bi bi-inbox"></i></span>
|
||||
<div class="jqc-hub-title" style="font-size:1.05rem;">My Requests</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
{% elif current_user.role in ['admin', 'director'] %}
|
||||
<div class="col-12 col-md-4">
|
||||
<a class="jqc-hub-card" href="{{ url_for('support.admin_tickets') }}">
|
||||
<div class="d-flex gap-3 align-items-center">
|
||||
<span class="jqc-tile-icon"><i class="bi bi-inbox"></i></span>
|
||||
<div class="jqc-hub-title" style="font-size:1.05rem;">Support Requests</div>
|
||||
</div>
|
||||
{% if open_support_tickets_count > 0 %}
|
||||
<div class="jqc-hub-text">{{ open_support_tickets_count }} open</div>
|
||||
{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<a class="jqc-hub-card" href="{{ url_for('support.admin_conversations') }}">
|
||||
<div class="d-flex gap-3 align-items-center">
|
||||
<span class="jqc-tile-icon"><i class="bi bi-chat-square-text"></i></span>
|
||||
<div class="jqc-hub-title" style="font-size:1.05rem;">Chat Conversations</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<a class="jqc-hub-card" href="{{ url_for('support.admin_knowledge') }}">
|
||||
<div class="d-flex gap-3 align-items-center">
|
||||
<span class="jqc-tile-icon"><i class="bi bi-journal-text"></i></span>
|
||||
<div class="jqc-hub-title" style="font-size:1.05rem;">Knowledge Base</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# ── How-to guides ─────────────────────────────────────────────────────── #}
|
||||
{% set guides = [
|
||||
('bi-clipboard-check', 'How to create a new inspection',
|
||||
'Inspections → New Inspection. Pick the contract, facility, area and template, then Start. The checklist opens straight away and saves as you go — you can leave and resume from Inspections → In Progress.'),
|
||||
('bi-search', 'How to follow up on an inspection',
|
||||
'Open the inspection and use Request Follow-up. It moves to the Follow-up list, notifies the inspector, and stays there until a re-inspection is submitted against it.'),
|
||||
('bi-calendar2-week', 'How to schedule an inspection',
|
||||
'Inspections → Schedule. Choose facility, template, inspector and how often it repeats. Recurring schedules roll their due date forward automatically once the inspection is submitted.'),
|
||||
('bi-exclamation-triangle', 'How to Flag For Attention',
|
||||
'While executing an inspection, use Flag Issue on any failing item. Set severity and who handles it (janitorial crew, facility staff or an outside vendor) — the SLA clock starts from that moment.'),
|
||||
('bi-qr-code', 'How QR code works',
|
||||
"Every facility and area has a QR code. Scanning it opens that location's public page — anyone on site can report a problem without an account, and the request lands in Issues."),
|
||||
('bi-file-earmark-text','Send a request without the app',
|
||||
'Point the on-site contact at the facility QR code, or forward them the public facility link. Their submission arrives as an unassigned issue for triage.'),
|
||||
('bi-search', 'How to search',
|
||||
'Use the search box in the top bar for an inspection number. For anything broader, each list page has filters for contract, facility, inspector, status, date range and score.'),
|
||||
('bi-chat-dots', 'How to comment',
|
||||
'Open any inspection or issue and use the comment box at the bottom. Comments are visible to staff; sharing one with the customer is an explicit choice on the comment itself.'),
|
||||
('bi-bar-chart', 'Create & print inspection reports',
|
||||
'Reports & Analytics → filter by date, contract, facility or inspector → Apply. Export to CSV, or use the PDF export on an individual inspection or facility scorecard.'),
|
||||
('bi-clock', 'What does SLA At Risk mean?',
|
||||
'The issue is approaching its resolution deadline for its severity but has not passed it yet. Treat it as the last window to close the issue on time.'),
|
||||
('bi-alarm', 'What does an SLA Alert mean?',
|
||||
'The issue has passed its resolution deadline for its severity. It stays flagged until resolved and shows on the dashboard SLA card.'),
|
||||
] %}
|
||||
|
||||
<div class="row g-3">
|
||||
{% for icon, title, body in guides %}
|
||||
<div class="col-12 col-md-6 col-xl-4">
|
||||
<div class="jqc-hub-card" role="button" data-bs-toggle="collapse"
|
||||
data-bs-target="#guide{{ loop.index }}" aria-expanded="false">
|
||||
<div class="d-flex gap-3 align-items-center">
|
||||
<span class="jqc-tile-icon"><i class="bi {{ icon }}"></i></span>
|
||||
<div class="jqc-hub-title" style="font-size:1.02rem;">{{ title }}</div>
|
||||
</div>
|
||||
<div class="collapse" id="guide{{ loop.index }}">
|
||||
<div class="jqc-hub-text mt-3">{{ body }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{# The AI chat is for customer-side accounts (support.chat redirects staff to the ticket
|
||||
queue, which is itself @supervisor_required). So the destination is chosen
|
||||
per role rather than pointed at support.chat for everyone — an inspector
|
||||
following that chain would land on the dashboard with an access-denied
|
||||
flash, and this page is meant never to dead-end. Roles with no support
|
||||
destination get no card; the how-to guides below are their support. #}
|
||||
{% if current_user.is_customer_account %}
|
||||
<div class="col-12 col-md-6 col-xl-4">
|
||||
<a class="jqc-hub-card dark" href="{{ url_for('support.chat') }}">
|
||||
<div class="d-flex gap-3 align-items-center">
|
||||
<span class="jqc-tile-icon"><i class="bi bi-life-preserver"></i></span>
|
||||
<div>
|
||||
<div class="jqc-hub-title" style="font-size:1.02rem;">AI Support</div>
|
||||
<div class="jqc-hub-text">Ask a question and get an answer straight away.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="jqc-hub-open">Open →</div>
|
||||
</a>
|
||||
</div>
|
||||
{% elif current_user.role in ['admin', 'director'] %}
|
||||
<div class="col-12 col-md-6 col-xl-4">
|
||||
<a class="jqc-hub-card dark" href="{{ url_for('support.admin_conversations') }}">
|
||||
<div class="d-flex gap-3 align-items-center">
|
||||
<span class="jqc-tile-icon"><i class="bi bi-life-preserver"></i></span>
|
||||
<div>
|
||||
<div class="jqc-hub-title" style="font-size:1.02rem;">AI Support</div>
|
||||
<div class="jqc-hub-text">Review the AI chat conversations customers have had.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="jqc-hub-open">Open →</div>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,71 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Design Vote Tally{% endblock %}
|
||||
|
||||
{# Admin-only: which design are active users currently keeping? #}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h2 class="mb-1"><i class="bi bi-bar-chart"></i> Design Vote Tally</h2>
|
||||
<div class="text-muted">Which web portal design each active user is currently using.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-12 col-md-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<div class="text-muted small">Classic design</div>
|
||||
<div class="fs-2 fw-bold">{{ tally.classic }}</div>
|
||||
<div class="text-muted small">
|
||||
{{ ((tally.classic / total * 100) | round(1)) if total else 0 }}% of {{ total }} active users
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<div class="text-muted small">New design</div>
|
||||
<div class="fs-2 fw-bold">{{ tally.modern }}</div>
|
||||
<div class="text-muted small">
|
||||
{{ ((tally.modern / total * 100) | round(1)) if total else 0 }}% of {{ total }} active users
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<div class="text-muted small">Total active users</div>
|
||||
<div class="fs-2 fw-bold">{{ total }}</div>
|
||||
<div class="text-muted small">Every account defaults to classic</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header fw-semibold"><i class="bi bi-people me-1"></i>Breakdown by role</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr><th>Role</th><th>Design</th><th class="text-end">Users</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for role, theme, count in by_role %}
|
||||
<tr>
|
||||
<td>{{ role_labels.get(role, role.replace('_',' ')|title) }}</td>
|
||||
<td>{{ 'New design' if theme == 'modern' else 'Classic design' }}</td>
|
||||
<td class="text-end fw-semibold">{{ count }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="3" class="text-center text-muted py-4">No active users.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -12,6 +12,10 @@ from app.tenancy.middleware import init_tenancy
|
||||
from app.tenancy.context import TenantContext
|
||||
from app.tenancy.gates import feature_required, quota_soft_check
|
||||
from app.tenancy.quota import check_quota, check_feature
|
||||
from app.tenancy.session_binding import (
|
||||
bind_session_tenant, enforce_session_tenant, current_tenant_id,
|
||||
tag_user_id, parse_user_id, SESSION_TENANT_KEY,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'RoutingSession',
|
||||
@@ -21,4 +25,11 @@ __all__ = [
|
||||
'quota_soft_check',
|
||||
'check_quota',
|
||||
'check_feature',
|
||||
# MT-21
|
||||
'bind_session_tenant',
|
||||
'enforce_session_tenant',
|
||||
'current_tenant_id',
|
||||
'tag_user_id',
|
||||
'parse_user_id',
|
||||
'SESSION_TENANT_KEY',
|
||||
]
|
||||
|
||||
@@ -5,40 +5,75 @@ Process-local cache of per-tenant SQLAlchemy engines, keyed by tenant id.
|
||||
|
||||
Each tenant has its own database, hence its own engine + connection pool.
|
||||
Engines are created lazily on first use and reused across requests. Total
|
||||
backend connections ≈ workers × cached-tenants × pool_size, so pool sizing is
|
||||
a real scaling lever (see MULTI_TENANT_PLAN.md §4); tune via config, or set a
|
||||
small pool / switch to NullPool when the tenant count grows large.
|
||||
backend connections ≈ workers × cached-tenants × (pool_size + max_overflow),
|
||||
so the cache is BOUNDED (MT-21): it holds at most
|
||||
``TENANT_ENGINE_CACHE_MAX`` engines and evicts the least-recently-used one
|
||||
beyond that, disposing it. Without the bound, a process that has served N
|
||||
tenants holds N pools forever, and the connection count grows without limit
|
||||
until MySQL's ``max_connections`` (default 151) rejects new sessions.
|
||||
|
||||
Worked example — 8 workers, pool_size 2 + max_overflow 3, cache cap 32:
|
||||
8 × 32 × 5 = 1280 worst case, vs. unbounded before this change.
|
||||
|
||||
Eviction disposes the engine, which closes its *idle* pooled connections.
|
||||
Connections already checked out by another thread stay valid and are closed
|
||||
when returned, so eviction is safe under concurrency; the evicted tenant
|
||||
simply rebuilds its engine on the next request.
|
||||
|
||||
`invalidate(tenant_id)` drops a cached engine (e.g. after credential rotation
|
||||
or tenant suspension); the next request rebuilds it.
|
||||
"""
|
||||
|
||||
import threading
|
||||
from collections import OrderedDict
|
||||
|
||||
from flask import current_app
|
||||
from sqlalchemy import create_engine
|
||||
|
||||
_engines = {}
|
||||
_engines = OrderedDict()
|
||||
_lock = threading.Lock()
|
||||
|
||||
# Fallback cap used when no application context is available (CLI/cron paths
|
||||
# that touch the cache outside a request). Mirrors the config default.
|
||||
_DEFAULT_CACHE_MAX = 32
|
||||
|
||||
|
||||
def _cache_max():
|
||||
try:
|
||||
return int(current_app.config.get('TENANT_ENGINE_CACHE_MAX', _DEFAULT_CACHE_MAX))
|
||||
except Exception:
|
||||
return _DEFAULT_CACHE_MAX
|
||||
|
||||
|
||||
def get_tenant_engine(tenant):
|
||||
"""Return (building if needed) the cached engine for a TenantContext."""
|
||||
engine = _engines.get(tenant.id)
|
||||
if engine is not None:
|
||||
return engine
|
||||
evicted = []
|
||||
with _lock:
|
||||
engine = _engines.get(tenant.id)
|
||||
if engine is None:
|
||||
engine = create_engine(
|
||||
tenant.db_uri,
|
||||
pool_pre_ping=True,
|
||||
pool_size=current_app.config.get('TENANT_ENGINE_POOL_SIZE', 5),
|
||||
max_overflow=current_app.config.get('TENANT_ENGINE_MAX_OVERFLOW', 5),
|
||||
pool_size=current_app.config.get('TENANT_ENGINE_POOL_SIZE', 2),
|
||||
max_overflow=current_app.config.get('TENANT_ENGINE_MAX_OVERFLOW', 3),
|
||||
pool_recycle=current_app.config.get('TENANT_ENGINE_POOL_RECYCLE', 1800),
|
||||
future=True,
|
||||
)
|
||||
_engines[tenant.id] = engine
|
||||
# Bound the cache: drop least-recently-used engines beyond the cap.
|
||||
cap = _cache_max()
|
||||
while cap > 0 and len(_engines) > cap:
|
||||
old_id, old_engine = _engines.popitem(last=False)
|
||||
evicted.append((old_id, old_engine))
|
||||
else:
|
||||
_engines.move_to_end(tenant.id)
|
||||
|
||||
# Dispose outside the lock — dispose() can block on socket teardown.
|
||||
for old_id, old_engine in evicted:
|
||||
try:
|
||||
old_engine.dispose()
|
||||
except Exception:
|
||||
pass
|
||||
return engine
|
||||
|
||||
|
||||
|
||||
@@ -27,7 +27,8 @@ g.tenant_engine — no teardown handler is needed here.
|
||||
|
||||
import logging
|
||||
|
||||
from flask import g, request, current_app, Response, session, redirect, url_for
|
||||
from flask import (g, request, current_app, Response, session, redirect,
|
||||
url_for, jsonify)
|
||||
|
||||
from app.tenancy.resolver import resolve_tenant
|
||||
from app.tenancy.engine_cache import get_tenant_engine
|
||||
@@ -52,6 +53,26 @@ _UNKNOWN_TENANT_PAGE = (
|
||||
)
|
||||
|
||||
|
||||
def _wants_json():
|
||||
"""True when this request is the mobile API (or explicitly asks for JSON).
|
||||
|
||||
Mirrors gates._is_api_request(). The tenancy and billing gates run BEFORE
|
||||
any route, so without this they answer an iPad with a 302 to an HTML page:
|
||||
URLSession follows it, the client decodes the login/billing markup as JSON
|
||||
and reports "the data couldn't be read". The inspector sees a parse error
|
||||
instead of "your subscription has expired", and nothing in the app can tell
|
||||
the two apart.
|
||||
"""
|
||||
return (request.path.startswith('/api/')
|
||||
or request.accept_mimetypes.best == 'application/json')
|
||||
|
||||
|
||||
def _json(payload, status):
|
||||
"""Small local responder — the API error helpers live in a blueprint that
|
||||
is not necessarily importable this early in the request."""
|
||||
return jsonify(payload), status
|
||||
|
||||
|
||||
def _is_exempt(path):
|
||||
if path.startswith('/static/'):
|
||||
return True
|
||||
@@ -139,6 +160,13 @@ def init_tenancy(app):
|
||||
)
|
||||
g.tenant = ctx
|
||||
g.tenant_engine = get_tenant_engine(ctx)
|
||||
# MT-21: impersonation deliberately rebinds the session
|
||||
# to the impersonated tenant, so stamp it rather than
|
||||
# clearing it. /auth/impersonate has already written the
|
||||
# same marker; this keeps it correct if the superadmin's
|
||||
# target changes mid-session.
|
||||
from app.tenancy.session_binding import bind_session_tenant
|
||||
bind_session_tenant()
|
||||
return # skip normal Host resolution
|
||||
except Exception:
|
||||
logger.warning('TENANCY | impersonation_failed | tenant_id=%s', imp_id)
|
||||
@@ -151,11 +179,25 @@ def init_tenancy(app):
|
||||
host = (request.host or '').split(':')[0].strip().lower()
|
||||
tenant = resolve_tenant(host)
|
||||
if tenant is None:
|
||||
if _wants_json():
|
||||
# An HTML "Workspace not found" page is unreadable to the iPad
|
||||
# — it decodes as a parse failure, which looks like a bug in
|
||||
# the app rather than a wrong/retired server address.
|
||||
return _json({'ok': False,
|
||||
'error': 'Workspace not found for this address.'}, 404)
|
||||
return Response(_UNKNOWN_TENANT_PAGE, status=404, mimetype='text/html')
|
||||
|
||||
g.tenant = tenant
|
||||
g.tenant_engine = get_tenant_engine(tenant)
|
||||
|
||||
# MT-21: a session cookie signed for a different tenant validates fine
|
||||
# here — same app, same SECRET_KEY — so drop it before any downstream
|
||||
# code reads identity out of it. Covers pre-auth session state
|
||||
# (mfa_pending_user_id); the tenant tag in User.get_id() covers the
|
||||
# authenticated session and the remember-me cookie.
|
||||
from app.tenancy.session_binding import enforce_session_tenant
|
||||
enforce_session_tenant()
|
||||
|
||||
@app.before_request
|
||||
def _billing_gate():
|
||||
"""MT-8: Enforce subscription status. Inert when BILLING_ENABLED=False."""
|
||||
@@ -190,6 +232,18 @@ def init_tenancy(app):
|
||||
# they can still see their plan page and the subscribe button.
|
||||
if not (request.path.startswith('/billing/')
|
||||
or request.path.startswith('/settings/')):
|
||||
if _wants_json():
|
||||
# 402, not a redirect: the caller is a program.
|
||||
# Distinct from 401 on purpose — the iPad retries a
|
||||
# 401 by refreshing its token, which would loop
|
||||
# forever against a billing block.
|
||||
return _json({
|
||||
'ok': False,
|
||||
'error': 'This workspace\'s trial has ended. '
|
||||
'An administrator needs to choose a plan '
|
||||
'before the app can sync again.',
|
||||
'billing_required': True,
|
||||
}, 402)
|
||||
return redirect(url_for('billing.subscribe'))
|
||||
else:
|
||||
days_left = (trial_ends_at - now).days
|
||||
@@ -202,4 +256,11 @@ def init_tenancy(app):
|
||||
return
|
||||
|
||||
# status == 'cancelled' — block and redirect to subscription page.
|
||||
if _wants_json():
|
||||
return _json({
|
||||
'ok': False,
|
||||
'error': 'This workspace is suspended. An administrator needs to '
|
||||
'reactivate the subscription before the app can sync again.',
|
||||
'billing_required': True,
|
||||
}, 402)
|
||||
return redirect(url_for('billing.suspended'))
|
||||
|
||||
+23
-5
@@ -9,7 +9,7 @@ no second control-DB round-trip is needed.
|
||||
|
||||
Quota axes:
|
||||
inspections → Inspection.inspection_date in current month, status='completed'
|
||||
issues → Issue.created_at in current month
|
||||
issues → Issue.reported_at in current month
|
||||
users → User.active == True (total, not monthly)
|
||||
facilities → Facility.active == True (total, not monthly)
|
||||
|
||||
@@ -18,10 +18,11 @@ all quota checks pass, single-tenant behaviour unchanged.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from flask import g, current_app
|
||||
|
||||
from app.utils.time_utils import now_eastern
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -30,7 +31,15 @@ def _mt_enabled():
|
||||
|
||||
|
||||
def _month_window():
|
||||
now = datetime.now()
|
||||
"""[start, end) of the current month, in the timezone the rows are stamped in.
|
||||
|
||||
now_eastern(), not datetime.now(): every timestamp in the tenant DB is
|
||||
written by now_eastern() (rule 2). On a UTC server the two differ by 4-5
|
||||
hours, so a plain now() puts the month boundary in the wrong place and the
|
||||
first hours of each month count the wrong rows — a discrepancy that only
|
||||
appears on the 1st and is gone before anyone investigates it.
|
||||
"""
|
||||
now = now_eastern()
|
||||
start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
if now.month == 12:
|
||||
end = now.replace(year=now.year + 1, month=1, day=1,
|
||||
@@ -52,11 +61,20 @@ def count_inspections_this_month():
|
||||
|
||||
|
||||
def count_issues_this_month():
|
||||
"""Issues filed this month.
|
||||
|
||||
`reported_at`, NOT `created_at` — the issues table has no created_at
|
||||
column. (IssueComment does, in the same module, which is how the wrong name
|
||||
got here.) Referencing a missing column raises AttributeError while the
|
||||
query is built, and every caller wraps this in a try/except, so the failure
|
||||
was invisible: the plan page silently showed 0 for EVERY axis and the
|
||||
issues quota was never evaluated at all.
|
||||
"""
|
||||
from app.models.issue import Issue
|
||||
start, end = _month_window()
|
||||
return (Issue.query
|
||||
.filter(Issue.created_at >= start,
|
||||
Issue.created_at < end)
|
||||
.filter(Issue.reported_at >= start,
|
||||
Issue.reported_at < end)
|
||||
.count())
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
"""
|
||||
app/tenancy/session_binding.py
|
||||
------------------------------
|
||||
MT-21: bind a browser session to the tenant that issued it.
|
||||
|
||||
Why this exists
|
||||
---------------
|
||||
Every tenant is served by the same Flask application with the same
|
||||
``SECRET_KEY``. Before this module, nothing in a signed cookie identified
|
||||
*which* tenant it was issued by, so a cookie minted on one tenant host
|
||||
validated perfectly on another:
|
||||
|
||||
1. A legitimate user of tenant A logs in at a.jqc.app.
|
||||
2. They copy their session cookie onto b.jqc.app (devtools / curl —
|
||||
host-only cookie scoping stops the *browser* replaying it, but not a
|
||||
person doing it by hand).
|
||||
3. The tenancy middleware resolves Host → tenant B and binds tenant B's
|
||||
database.
|
||||
4. Flask-Login calls load_user('7') and gets **tenant B's user #7**.
|
||||
|
||||
The signature was always valid, because it is the same key. The identity was
|
||||
never checked against the tenant. Result: authentication as an arbitrary user
|
||||
in any tenant whose hostname the attacker knows.
|
||||
|
||||
Two layers close this, and both live here:
|
||||
|
||||
``SESSION_TENANT_KEY``
|
||||
A ``_tenant_id`` marker written into the session at every point where the
|
||||
session starts carrying identity (login, MFA challenge hand-off,
|
||||
impersonation). ``enforce_session_tenant()`` clears the whole session when
|
||||
that marker disagrees with the resolved tenant, so pre-authentication
|
||||
session state (``mfa_pending_user_id`` and friends) cannot cross tenants
|
||||
either.
|
||||
|
||||
``tag_user_id`` / ``parse_user_id``
|
||||
Flask-Login derives BOTH the session ``_user_id`` and the "remember me"
|
||||
cookie payload from ``User.get_id()``, and feeds both back through
|
||||
``user_loader``. Tagging the id there — ``"<tenant_id>:<user_id>"`` — is
|
||||
therefore a single seam that covers both cookies. Clearing the session
|
||||
alone would not have been enough: a remember-me cookie repopulates the
|
||||
session immediately afterwards.
|
||||
|
||||
Single-tenant behaviour is unchanged. When ``MULTI_TENANT_ENABLED`` is False,
|
||||
or no tenant is bound (cron, CLI, exempt paths), ids stay bare integers and
|
||||
the enforcement is a no-op.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from flask import current_app, g, session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SESSION_TENANT_KEY = '_tenant_id'
|
||||
|
||||
|
||||
def current_tenant_id():
|
||||
"""Resolved tenant id for this request, or None in single-tenant mode."""
|
||||
if not current_app.config.get('MULTI_TENANT_ENABLED', False):
|
||||
return None
|
||||
tenant = getattr(g, 'tenant', None)
|
||||
return tenant.id if tenant is not None else None
|
||||
|
||||
|
||||
def bind_session_tenant():
|
||||
"""Stamp the current session with the tenant that issued it.
|
||||
|
||||
Called at every point that puts identity into the session. No-op in
|
||||
single-tenant mode so existing sessions keep working untouched.
|
||||
"""
|
||||
tid = current_tenant_id()
|
||||
if tid is not None:
|
||||
session[SESSION_TENANT_KEY] = tid
|
||||
|
||||
|
||||
def enforce_session_tenant():
|
||||
"""Clear the session if it was issued by a different tenant.
|
||||
|
||||
Returns True when the session was cleared. Runs after tenant resolution,
|
||||
from the tenancy middleware.
|
||||
"""
|
||||
tid = current_tenant_id()
|
||||
if tid is None:
|
||||
return False
|
||||
|
||||
bound = session.get(SESSION_TENANT_KEY)
|
||||
|
||||
if bound is None:
|
||||
# An untagged session carrying identity predates this binding, or was
|
||||
# lifted from somewhere else. Either way it cannot be trusted here.
|
||||
if '_user_id' in session or 'mfa_pending_user_id' in session:
|
||||
logger.warning('TENANCY | session_untagged_cleared | tenant=%s', tid)
|
||||
session.clear()
|
||||
return True
|
||||
return False
|
||||
|
||||
if bound != tid:
|
||||
logger.warning('TENANCY | session_tenant_mismatch | bound=%s resolved=%s',
|
||||
bound, tid)
|
||||
session.clear()
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def tag_user_id(user_id):
|
||||
"""Render a Flask-Login identity string, tenant-tagged when applicable.
|
||||
|
||||
Used by ``User.get_id()``. Feeds both the session ``_user_id`` and the
|
||||
remember-me cookie.
|
||||
"""
|
||||
tid = current_tenant_id()
|
||||
if tid is None:
|
||||
return str(user_id)
|
||||
return f'{tid}:{user_id}'
|
||||
|
||||
|
||||
def parse_user_id(raw):
|
||||
"""Inverse of :func:`tag_user_id`, with the tenant check applied.
|
||||
|
||||
Returns the integer user id, or None when the identity must be rejected —
|
||||
a foreign tenant tag, an untagged id arriving in multi-tenant mode, or
|
||||
anything unparseable. ``user_loader`` turns None into an anonymous user,
|
||||
which sends the caller back to the login page.
|
||||
"""
|
||||
if raw is None:
|
||||
return None
|
||||
raw = str(raw)
|
||||
tid = current_tenant_id()
|
||||
|
||||
if ':' in raw:
|
||||
tag, _, uid = raw.partition(':')
|
||||
if tid is None:
|
||||
# Tagged id replayed at a single-tenant / unbound context.
|
||||
return None
|
||||
try:
|
||||
if int(tag) != tid:
|
||||
logger.warning('TENANCY | user_id_tenant_mismatch | tag=%s resolved=%s',
|
||||
tag, tid)
|
||||
return None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
else:
|
||||
uid = raw
|
||||
if tid is not None:
|
||||
# Untagged id in multi-tenant mode: either a session predating
|
||||
# MT-21 or one lifted from another host. Force re-authentication.
|
||||
logger.warning('TENANCY | user_id_untagged_rejected | tenant=%s', tid)
|
||||
return None
|
||||
|
||||
try:
|
||||
return int(uid)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
@@ -28,6 +28,29 @@ def safe_redirect_url(url: str | None, fallback: str | None = None) -> str:
|
||||
return fallback
|
||||
return url
|
||||
|
||||
def return_url(fallback: str) -> str:
|
||||
"""Where to go back to after a list-page action, preserving its filters.
|
||||
|
||||
Reads the `next` value the page carried through the action — POST body
|
||||
first (forms), then query string (links) — and validates it with
|
||||
safe_redirect_url, so a crafted `next` can never redirect off-site.
|
||||
|
||||
The problem this solves: a delete or an edit launched from a filtered list
|
||||
used to redirect to the bare index, throwing away the filters the user had
|
||||
set. Every list-page action now round-trips the list URL instead.
|
||||
|
||||
`next` is deliberately the FULL list URL (page number and all), not a
|
||||
reconstructed set of arguments — that keeps this helper working when a new
|
||||
filter is added to either list page without anyone having to remember to
|
||||
thread it through here.
|
||||
"""
|
||||
from flask import request
|
||||
return safe_redirect_url(
|
||||
request.form.get('next') or request.args.get('next'),
|
||||
fallback=fallback,
|
||||
)
|
||||
|
||||
|
||||
def admin_required(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
|
||||
+45
-8
@@ -2,7 +2,7 @@ from flask_wtf import FlaskForm
|
||||
from flask_wtf.file import FileField, FileAllowed, MultipleFileField
|
||||
from wtforms import (StringField, PasswordField, SelectField, TextAreaField,
|
||||
DecimalField, BooleanField, IntegerField, HiddenField,
|
||||
RadioField)
|
||||
RadioField, SelectMultipleField)
|
||||
from wtforms.validators import (DataRequired, Email, Length, EqualTo,
|
||||
Optional, NumberRange, ValidationError)
|
||||
import re as _re
|
||||
@@ -93,7 +93,11 @@ class UserForm(FlaskForm):
|
||||
('inspector', 'Inspector'),
|
||||
('project_manager', 'Project Manager'),
|
||||
('auditor', 'Auditor'),
|
||||
# 'customer' is intentionally excluded — customer accounts are managed via /customers
|
||||
# Both customer-side roles are intentionally excluded — 'customer'
|
||||
# (Customer Director) and 'external_inspector' (Customer Inspector) are
|
||||
# created, edited and switched exclusively in Customer Management
|
||||
# (/customers). phase51 removed 'external_inspector' from here; see
|
||||
# User.CUSTOMER_ROLES.
|
||||
], validators=[Optional()])
|
||||
# NOTE: Optional() here because directors submit no role value (the field is
|
||||
# hidden in user_form.html for them). Role enforcement is handled in the
|
||||
@@ -160,6 +164,11 @@ class InspectionTemplateForm(FlaskForm):
|
||||
('daily','Daily'), ('weekly','Weekly'),
|
||||
('monthly','Monthly'), ('quarterly','Quarterly'),
|
||||
], validators=[DataRequired()])
|
||||
# phase52 — which contracts may use this form. Choices are populated in the
|
||||
# route. Selecting NONE leaves the form shared with every contract, which
|
||||
# is the default and what every pre-phase52 template does.
|
||||
contract_ids = SelectMultipleField('Available on contracts', coerce=int,
|
||||
validators=[Optional()])
|
||||
|
||||
|
||||
class ChecklistItemForm(FlaskForm):
|
||||
@@ -206,6 +215,23 @@ class IssueForm(FlaskForm):
|
||||
FileAllowed(['jpg','jpeg','png','gif'], 'Images only.')
|
||||
])
|
||||
assigned_to = SelectField('Assign To', coerce=int, validators=[Optional()])
|
||||
# Who handles the issue (phase44) — set at creation by staff. MT previously
|
||||
# exposed these only on the update form, so a handler chosen at creation had
|
||||
# to be re-entered afterwards.
|
||||
handler_type = SelectField('Handled By', choices=[
|
||||
('internal', 'Janitorial Staff'),
|
||||
('facility', 'Facility Staff'),
|
||||
('vendor', 'External Vendor'),
|
||||
], validators=[Optional()])
|
||||
facility_handler_name = StringField('Facility Contact Name', validators=[Optional(), Length(max=100)])
|
||||
facility_handler_contact = StringField('Facility Contact', validators=[Optional(), Length(max=200)])
|
||||
facility_handler_notes = TextAreaField('Facility Handling Notes', validators=[Optional(), Length(max=1000)])
|
||||
vendor_name = StringField('Contractor Name', validators=[Optional(), Length(max=100)])
|
||||
vendor_contact = StringField('Contractor Contact', validators=[Optional(), Length(max=200)])
|
||||
vendor_notes = TextAreaField('Contractor Notes', validators=[Optional(), Length(max=1000)])
|
||||
# Janitorial staff member's name + contact — used when handler_type == 'internal'
|
||||
internal_handler_name = StringField('Staff Name', validators=[Optional(), Length(max=100)])
|
||||
internal_handler_contact = StringField('Staff Contact', validators=[Optional(), Length(max=200)])
|
||||
|
||||
|
||||
class IssueUpdateForm(FlaskForm):
|
||||
@@ -224,9 +250,9 @@ class IssueUpdateForm(FlaskForm):
|
||||
vendor_name = StringField('Contractor Name', validators=[Optional(), Length(max=100)])
|
||||
vendor_contact = StringField('Contractor Contact', validators=[Optional(), Length(max=200)])
|
||||
vendor_notes = TextAreaField('Contractor Notes', validators=[Optional(), Length(max=1000)])
|
||||
# Handler type (phase39)
|
||||
# Handler type (phase39; empty option removed phase44 — handler_type is now
|
||||
# NOT NULL DEFAULT 'internal', so "unset" is not a representable state)
|
||||
handler_type = SelectField('Handled By', choices=[
|
||||
('', '— Select —'),
|
||||
('internal', 'Janitorial Staff'),
|
||||
('facility', 'Facility Staff'),
|
||||
('vendor', 'External Vendor'),
|
||||
@@ -234,6 +260,9 @@ class IssueUpdateForm(FlaskForm):
|
||||
facility_handler_name = StringField('Facility Contact Name', validators=[Optional(), Length(max=100)])
|
||||
facility_handler_contact = StringField('Facility Contact Phone/Email', validators=[Optional(), Length(max=200)])
|
||||
facility_handler_notes = TextAreaField('Facility Handler Notes', validators=[Optional(), Length(max=1000)])
|
||||
# Janitorial staff member's name + contact — used when handler_type == 'internal' (phase44)
|
||||
internal_handler_name = StringField('Staff Name', validators=[Optional(), Length(max=100)])
|
||||
internal_handler_contact = StringField('Staff Contact', validators=[Optional(), Length(max=200)])
|
||||
|
||||
# ── Projects ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -284,14 +313,22 @@ class CustomerUserForm(FlaskForm):
|
||||
raise ValidationError('Password is required for new accounts.')
|
||||
|
||||
class CustomerInviteForm(FlaskForm):
|
||||
"""Simplified form for creating a customer account via email invitation.
|
||||
"""Create a customer-side account via email invitation.
|
||||
|
||||
Admin enters Full Name and Email only. A username is auto-generated
|
||||
from the email address. The customer sets their own username and
|
||||
password via the emailed link.
|
||||
Admin enters Full Name, Email and which of the two customer roles the
|
||||
person holds. A username is auto-generated from the email address; the
|
||||
invitee sets their own username and password via the emailed link.
|
||||
|
||||
Both roles use the SAME invitation flow — neither is an account we set a
|
||||
password for. phase51 folded the Customer Inspector (stored as
|
||||
'external_inspector') in here from User Management.
|
||||
"""
|
||||
full_name = StringField('Full Name', validators=[DataRequired(), Length(max=150)])
|
||||
email = StringField('Email', validators=[DataRequired(), Email(), Length(max=255)])
|
||||
role = SelectField('Role', choices=[
|
||||
('customer', 'Customer Director — portal access for their facilities'),
|
||||
('external_inspector', 'Customer Inspector — performs inspections on their contracts'),
|
||||
], default='customer', validators=[DataRequired()])
|
||||
|
||||
def validate_email(self, field):
|
||||
if User.query.filter_by(email=field.data.strip().lower()).first():
|
||||
|
||||
+135
-8
@@ -44,13 +44,26 @@ _EMAIL_HTML_SINGLE = """\
|
||||
<body style="font-family:Arial,sans-serif;color:#333;max-width:600px;margin:auto;">
|
||||
<h2 style="color:#0d6efd;">{{ title }}</h2>
|
||||
<p>{{ body }}</p>
|
||||
{% if link %}
|
||||
{% if link or extra_action %}
|
||||
<p>
|
||||
{# extra_action (phase50) renders BEFORE "View Details" and in green: it is
|
||||
the one-click action the email is asking for (e.g. "Confirm receipt"),
|
||||
so it must be the primary button, not a footnote after the generic link. #}
|
||||
{% if extra_action %}
|
||||
<a href="{{ extra_action.url }}"
|
||||
style="background:#198754;color:#fff;padding:10px 20px;
|
||||
text-decoration:none;border-radius:4px;display:inline-block;
|
||||
margin-right:8px;">
|
||||
{{ extra_action.label }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if link %}
|
||||
<a href="{{ base_url }}{{ link }}"
|
||||
style="background:#0d6efd;color:#fff;padding:10px 20px;
|
||||
text-decoration:none;border-radius:4px;display:inline-block;">
|
||||
View Details
|
||||
</a>
|
||||
{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
<hr style="border:none;border-top:1px solid #eee;margin-top:32px;">
|
||||
@@ -68,6 +81,9 @@ _EMAIL_TEXT_SINGLE = """\
|
||||
{{ title }}
|
||||
|
||||
{{ body }}
|
||||
{% if extra_action %}
|
||||
{{ extra_action.label }}: {{ extra_action.url }}
|
||||
{% endif %}
|
||||
{% if link %}
|
||||
View: {{ base_url }}{{ link }}
|
||||
{% endif %}
|
||||
@@ -172,6 +188,7 @@ def notify(
|
||||
event_type: str = None,
|
||||
send_email: bool = True,
|
||||
respect_preferences: bool = True,
|
||||
extra_action: dict = None,
|
||||
):
|
||||
"""Create an in-app Notification record and optionally send an email.
|
||||
|
||||
@@ -189,7 +206,36 @@ def notify(
|
||||
respect_preferences : When True (default), per-user email preferences gate delivery.
|
||||
Set False for matrix-routed broadcasts — the matrix is the
|
||||
authority; individual opt-out should not override admin config.
|
||||
extra_action : Optional dict {'label': str, 'url': str} rendered as a second,
|
||||
primary button in the email (phase50). The URL must be
|
||||
ABSOLUTE — unlike `link`, it is not prefixed with base_url,
|
||||
because it typically points at a login-free tokenised route
|
||||
built with url_for(..., _external=True). EMAIL ONLY: the
|
||||
in-app Notification row is unchanged, so a recipient reading
|
||||
it in the bell menu simply follows `link` as before.
|
||||
"""
|
||||
# ── Per-account override (phase51) ───────────────────────────────────
|
||||
# A customer-side account's own notification matrix governs EVERY path
|
||||
# that reaches it, not just matrix broadcasts: follower fan-out
|
||||
# (_notify_followers) and direct assignee notifications both call notify()
|
||||
# straight, so without this the editor would offer rows — "Issue follow
|
||||
# update", "Issue assigned" — that appeared to be off while the
|
||||
# notifications kept arriving.
|
||||
#
|
||||
# Only an explicit `False` suppresses. No row means inherit, which is the
|
||||
# default for every account and leaves behaviour exactly as before. The
|
||||
# getattr fallback is deliberate: if the attribute is unavailable for any
|
||||
# reason we send, never silently drop.
|
||||
if event_type and getattr(recipient, 'is_customer_account', False):
|
||||
from app.models.user_notification_matrix import override_for
|
||||
if override_for(recipient.id, event_type) is False:
|
||||
logger.info(
|
||||
'NOTIFICATION SUPPRESSED | user=%s | event=%s | '
|
||||
'reason=per_account_override_off',
|
||||
recipient.username, event_type,
|
||||
)
|
||||
return
|
||||
|
||||
# Determine digest flag before creating the record.
|
||||
# Digest mode is only respected when individual preferences are in effect.
|
||||
hold_for_digest = (
|
||||
@@ -246,10 +292,10 @@ def notify(
|
||||
elif should_send:
|
||||
logger.info('EMAIL SEND | user=%s | event=%s | to=%s',
|
||||
recipient.username, event_type, recipient.email)
|
||||
_send_single_email(recipient, title, body, link)
|
||||
_send_single_email(recipient, title, body, link, extra_action)
|
||||
|
||||
|
||||
def _send_single_email(recipient, title, body, link):
|
||||
def _send_single_email(recipient, title, body, link, extra_action=None):
|
||||
"""Dispatch a single immediate notification email in a background thread.
|
||||
|
||||
Sending is offloaded to a daemon thread so SMTP latency never blocks the
|
||||
@@ -265,9 +311,11 @@ def _send_single_email(recipient, title, body, link):
|
||||
)
|
||||
html_body = render_template_string(
|
||||
_EMAIL_HTML_SINGLE, title=title, body=body, link=link, base_url=base_url,
|
||||
extra_action=extra_action,
|
||||
)
|
||||
text_body = render_template_string(
|
||||
_EMAIL_TEXT_SINGLE, title=title, body=body, link=link, base_url=base_url,
|
||||
extra_action=extra_action,
|
||||
)
|
||||
msg = Message(
|
||||
subject = f'[JQC] {title}',
|
||||
@@ -316,6 +364,7 @@ def notify_customers_for_facility(
|
||||
link: str = None,
|
||||
issue_id: int = None,
|
||||
inspection_id: int = None,
|
||||
allowed_user_ids: set = None,
|
||||
):
|
||||
"""Dispatch in-app + email notifications to all customer users assigned
|
||||
to the given facility.
|
||||
@@ -336,6 +385,12 @@ def notify_customers_for_facility(
|
||||
link : Relative URL for 'View Details'.
|
||||
issue_id : FK to issues.id (optional).
|
||||
inspection_id : FK to inspections.id (optional).
|
||||
allowed_user_ids :
|
||||
Optional whitelist. When notify_by_matrix() calls this it has already
|
||||
applied each account's per-event override (phase51), so it passes the
|
||||
surviving ids here — this function re-derives recipients from the
|
||||
assignment rows and would otherwise notify accounts that opted out.
|
||||
None (the default, used by direct callers) means no filtering.
|
||||
"""
|
||||
try:
|
||||
from app.models.project import CustomerAssignment
|
||||
@@ -373,8 +428,20 @@ def notify_customers_for_facility(
|
||||
)
|
||||
return
|
||||
|
||||
if allowed_user_ids is not None:
|
||||
notified_user_ids &= set(allowed_user_ids)
|
||||
if not notified_user_ids:
|
||||
logger.debug(
|
||||
'notify_customers_for_facility | facility_id=%s | all '
|
||||
'assigned customers filtered out by per-account overrides',
|
||||
facility_id,
|
||||
)
|
||||
return
|
||||
|
||||
for user_id in notified_user_ids:
|
||||
user = db.session.get(User, user_id)
|
||||
# role != 'customer' stays an EQUALITY check: a Customer Inspector
|
||||
# is not a portal customer and is routed by the inspector column.
|
||||
if not user or not user.active or user.role != 'customer':
|
||||
continue
|
||||
try:
|
||||
@@ -536,15 +603,25 @@ def notify_by_matrix(
|
||||
from app.models.notification_matrix import (
|
||||
is_enabled, get_custom_emails_for, MATRIX_ROLES,
|
||||
)
|
||||
from app.models.user_notification_matrix import overrides_for_event
|
||||
from app.models.user import User
|
||||
|
||||
exclude = set(exclude_user_ids or [])
|
||||
notified = set() # deduplicate across roles
|
||||
|
||||
# ── Per-account overrides (phase51) ───────────────────────────────────
|
||||
# {user_id: bool} for this event, one query. Applies to the two
|
||||
# customer-side role columns only; staff roles use the global matrix alone.
|
||||
# An account with no entry inherits the global column, which is why this
|
||||
# feature is a no-op until an admin actually sets something.
|
||||
overrides = overrides_for_event(event_type)
|
||||
customer_keys = User.CUSTOMER_ROLES # ('customer', 'external_inspector')
|
||||
|
||||
role_to_db = {
|
||||
'admin': 'admin',
|
||||
'director': 'director',
|
||||
'inspector': 'inspector',
|
||||
'inspector': 'inspector',
|
||||
'external_inspector': 'external_inspector',
|
||||
'project_manager': 'project_manager',
|
||||
'auditor': 'auditor',
|
||||
'customer': 'customer',
|
||||
@@ -558,7 +635,14 @@ def notify_by_matrix(
|
||||
enabled = is_enabled(event_type, role_key)
|
||||
logger.info('MATRIX NOTIFY | event=%s | role=%s | enabled=%s',
|
||||
event_type, role_key, enabled)
|
||||
if not enabled:
|
||||
|
||||
# A customer-side column must NOT be skipped just because the global
|
||||
# switch is off — an account that opted IN individually still has to be
|
||||
# reached. Only skip when the column is off AND nobody opted in.
|
||||
# (Getting this wrong is silent: the per-account "on" would save fine,
|
||||
# show as on, and never send.)
|
||||
is_customer_col = role_key in customer_keys
|
||||
if not enabled and not (is_customer_col and any(overrides.values())):
|
||||
continue
|
||||
|
||||
db_role = role_to_db.get(role_key)
|
||||
@@ -569,6 +653,34 @@ def notify_by_matrix(
|
||||
logger.info('MATRIX NOTIFY | event=%s | role=%s | users_found=%s',
|
||||
event_type, role_key, [u.username for u in users])
|
||||
|
||||
# Scope the inspector role for "inspection_completed" to the inspection's
|
||||
# OWN inspector — the person who did the work — not the whole inspector
|
||||
# pool. Without this, switching the Inspector column on for this event
|
||||
# notifies EVERY active inspector on EVERY submitted inspection, which on
|
||||
# a tenant with a dozen inspectors is a mail storm and trains people to
|
||||
# ignore notifications. Falls back to notifying nobody when the
|
||||
# inspection cannot be resolved, rather than notifying everybody.
|
||||
if (role_key in ('inspector', 'external_inspector')
|
||||
and event_type == 'inspection_completed'):
|
||||
target_id = None
|
||||
if inspection_id:
|
||||
from app.models.inspection import Inspection
|
||||
insp = db.session.get(Inspection, inspection_id)
|
||||
target_id = insp.inspector_id if insp else None
|
||||
users = [u for u in users if u.id == target_id] if target_id else []
|
||||
logger.info('MATRIX NOTIFY | event=%s | role=%s scoped to '
|
||||
'submitting inspector_id=%s',
|
||||
event_type, role_key, target_id)
|
||||
|
||||
# Apply the per-account overrides to the customer-side columns. An
|
||||
# account with no override falls back to `enabled`, i.e. the global
|
||||
# column — so this line is what makes both directions work: opt-in
|
||||
# against an off column, and opt-out of an on one.
|
||||
if is_customer_col:
|
||||
users = [u for u in users if overrides.get(u.id, enabled)]
|
||||
logger.info('MATRIX NOTIFY | event=%s | role=%s | after overrides=%s',
|
||||
event_type, role_key, [u.username for u in users])
|
||||
|
||||
# Scope customer role to facility if provided
|
||||
if role_key == 'customer' and facility_id:
|
||||
from app.utils.notifications import notify_customers_for_facility
|
||||
@@ -580,6 +692,9 @@ def notify_by_matrix(
|
||||
link = link,
|
||||
issue_id = issue_id,
|
||||
inspection_id = inspection_id,
|
||||
# Without this the facility-scoped path would re-query customers
|
||||
# itself and bypass every override applied just above.
|
||||
allowed_user_ids = {u.id for u in users},
|
||||
)
|
||||
continue # notify_customers_for_facility handles dedup internally
|
||||
|
||||
@@ -599,10 +714,22 @@ def notify_by_matrix(
|
||||
)
|
||||
notified.add(user.id)
|
||||
|
||||
# ── Custom email recipients ───────────────────────────────────────────
|
||||
# ── Custom email recipients (global, per-event) ───────────────────────
|
||||
# Deduplicated on the normalised address. The matrix stores this list as
|
||||
# free text, so the same person can appear twice with different casing or
|
||||
# stray whitespace ("Ops@x.com" and "ops@x.com "), which previously produced
|
||||
# two identical emails. `sent_emails` is built AS WE SEND, so it reflects
|
||||
# what actually went out — blank entries are skipped rather than being
|
||||
# counted as sent — and is then handed to the per-contract pass below so a
|
||||
# recipient listed both globally and on the contract is contacted once.
|
||||
custom_emails = get_custom_emails_for(event_type)
|
||||
sent_emails = set()
|
||||
for email in custom_emails:
|
||||
norm = (email or '').strip().lower()
|
||||
if not norm or norm in sent_emails:
|
||||
continue
|
||||
_send_custom_email(email, title, body, link)
|
||||
sent_emails.add(norm)
|
||||
|
||||
# ── Per-contract additional recipients (phase37) ──────────────────────
|
||||
_notify_project_recipients(
|
||||
@@ -615,12 +742,12 @@ def notify_by_matrix(
|
||||
facility_id = facility_id,
|
||||
exclude_user_ids = exclude,
|
||||
already_notified = notified,
|
||||
already_emailed = {e.strip().lower() for e in custom_emails},
|
||||
already_emailed = sent_emails,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'MATRIX NOTIFY | event=%s | notified=%s | custom_emails=%s',
|
||||
event_type, len(notified), len(custom_emails),
|
||||
event_type, len(notified), len(sent_emails),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
"""
|
||||
app/utils/photo_stamp.py
|
||||
------------------------
|
||||
Burn a capture-time + geolocation overlay into uploaded evidence photos.
|
||||
|
||||
Applied at UPLOAD time (``app/api/photos.py``) rather than on
|
||||
``PATCH /issues/<id>/photos``. At upload the raw bytes and any camera EXIF are
|
||||
in hand, so nothing has to be read back out of R2, and each upload writes
|
||||
exactly one already-stamped object. That also keeps stamping clear of the
|
||||
retry/double-burn hazard the PATCH endpoint would have — it is deliberately
|
||||
idempotent and re-runnable (CLAUDE.md rule 45), so burning there could stack a
|
||||
second bar onto an already-stamped image.
|
||||
|
||||
Metadata resolution order
|
||||
-------------------------
|
||||
1. Client-supplied ``captured_at`` / ``latitude`` / ``longitude`` — most
|
||||
reliable for an offline-first app: the iPad knows when and where the shot
|
||||
was taken even if it syncs hours later.
|
||||
2. The image's own EXIF ``DateTimeOriginal`` / ``GPSInfo``.
|
||||
3. Server receipt time (last resort; no geo).
|
||||
|
||||
FAILURE POLICY
|
||||
--------------
|
||||
Stamping must never cost us the photo. Every failure path falls back to
|
||||
storing the original bytes unmodified — an unstamped photo beats a lost one.
|
||||
|
||||
MULTI-TENANT NOTES (MT-12)
|
||||
--------------------------
|
||||
Ported from the single-tenant tree. This module is tenant-agnostic by
|
||||
construction and needs no tenancy awareness:
|
||||
|
||||
- It only transforms bytes. It never touches the DB, ``g.tenant``, or a
|
||||
storage key, so there is nothing here that could leak across tenants.
|
||||
- ``stamp_file_storage()`` returns a FileStorage carrying the ORIGINAL
|
||||
``filename`` and ``content_type``, so ``storage.save()`` derives exactly the
|
||||
same ``uploads/<subfolder>/<uuid>.<ext>`` key it would have derived for the
|
||||
unstamped upload. The per-tenant ``t<id>/`` prefix is applied further down,
|
||||
inside ``S3Backend._object_key`` — stamping happens strictly upstream of
|
||||
that and cannot interfere with it.
|
||||
- Because the bytes are stamped BEFORE ``storage.save()``, exactly one
|
||||
already-stamped object is written per upload and nothing is ever read back
|
||||
out of R2 to be re-encoded.
|
||||
|
||||
Scope matches the single-tenant tree deliberately: only the mobile API upload
|
||||
path (``app/api/photos.py``) stamps. Web uploads through
|
||||
``routes/inspections._save_photo`` are NOT stamped, there or here — a desk user
|
||||
attaching a file has no capture-time or GPS to burn in, and the server receipt
|
||||
time would be misleading.
|
||||
"""
|
||||
|
||||
import io
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from app.utils.time_utils import EASTERN, now_eastern
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Only these are stamped. GIF (possibly animated) and anything exotic passes
|
||||
# through untouched rather than risking a broken re-encode.
|
||||
_STAMPABLE_FORMATS = {'JPEG', 'PNG'}
|
||||
|
||||
# Candidate TrueType fonts, in preference order. Pillow does not reliably ship
|
||||
# a TTF, and the bitmap default is unreadably small on a 4000px photo, so we
|
||||
# probe the usual Linux locations and degrade gracefully.
|
||||
_FONT_CANDIDATES = (
|
||||
'/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',
|
||||
'/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',
|
||||
'/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf',
|
||||
'/usr/share/fonts/truetype/freefont/FreeSansBold.ttf',
|
||||
'C:/Windows/Fonts/arialbd.ttf',
|
||||
'C:/Windows/Fonts/arial.ttf',
|
||||
)
|
||||
|
||||
_EXIF_DATETIME_ORIGINAL = 36867 # 0x9003
|
||||
_EXIF_DATETIME_DIGITIZED = 36868 # 0x9004
|
||||
_EXIF_DATETIME = 306 # 0x0132
|
||||
_EXIF_GPS_IFD = 34853 # 0x8825
|
||||
|
||||
|
||||
# ── Font ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _load_font(size):
|
||||
"""Return a TrueType font at *size*, or Pillow's bitmap default."""
|
||||
from PIL import ImageFont
|
||||
for path in _FONT_CANDIDATES:
|
||||
try:
|
||||
return ImageFont.truetype(path, size)
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
# Pillow >= 9.2 can scale the default font.
|
||||
return ImageFont.load_default(size=size)
|
||||
except Exception:
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
# ── Metadata extraction ───────────────────────────────────────────────────────
|
||||
|
||||
def _parse_client_datetime(value):
|
||||
"""Parse a client ISO-8601 timestamp into naive Eastern, or None.
|
||||
|
||||
Accepts offsets and a trailing 'Z'. An offset-aware value is converted to
|
||||
Eastern; a naive value is taken as already-Eastern wall time.
|
||||
"""
|
||||
if not value:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
return None
|
||||
if text.endswith(('Z', 'z')):
|
||||
text = text[:-1] + '+00:00'
|
||||
try:
|
||||
dt = datetime.fromisoformat(text)
|
||||
except ValueError:
|
||||
return None
|
||||
if dt.tzinfo is not None:
|
||||
dt = dt.astimezone(EASTERN).replace(tzinfo=None)
|
||||
return dt
|
||||
|
||||
|
||||
def _parse_exif_datetime(raw):
|
||||
"""Parse an EXIF 'YYYY:MM:DD HH:MM:SS' string into a naive datetime."""
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return datetime.strptime(str(raw).strip(), '%Y:%m:%d %H:%M:%S')
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _exif_datetime(exif):
|
||||
"""Best available capture time from EXIF, or None."""
|
||||
if not exif:
|
||||
return None
|
||||
for tag in (_EXIF_DATETIME_ORIGINAL, _EXIF_DATETIME_DIGITIZED, _EXIF_DATETIME):
|
||||
dt = _parse_exif_datetime(exif.get(tag))
|
||||
if dt:
|
||||
return dt
|
||||
return None
|
||||
|
||||
|
||||
def _dms_to_decimal(dms, ref):
|
||||
"""Convert EXIF degrees/minutes/seconds rationals to signed decimal."""
|
||||
try:
|
||||
deg, minutes, seconds = (float(x) for x in dms)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
value = deg + minutes / 60.0 + seconds / 3600.0
|
||||
if str(ref).upper().strip() in ('S', 'W'):
|
||||
value = -value
|
||||
return value
|
||||
|
||||
|
||||
def _exif_gps(exif):
|
||||
"""Return (lat, lng) decimal degrees from EXIF GPSInfo, or (None, None)."""
|
||||
if not exif:
|
||||
return (None, None)
|
||||
try:
|
||||
gps = exif.get_ifd(_EXIF_GPS_IFD)
|
||||
except Exception:
|
||||
gps = None
|
||||
if not gps:
|
||||
return (None, None)
|
||||
# 1/2 = LatitudeRef/Latitude, 3/4 = LongitudeRef/Longitude
|
||||
lat = _dms_to_decimal(gps.get(2), gps.get(1)) if gps.get(2) and gps.get(1) else None
|
||||
lng = _dms_to_decimal(gps.get(4), gps.get(3)) if gps.get(4) and gps.get(3) else None
|
||||
return (lat, lng)
|
||||
|
||||
|
||||
def _coerce_coord(value):
|
||||
"""Parse a coordinate to float, rejecting out-of-range/garbage values."""
|
||||
if value is None or value == '':
|
||||
return None
|
||||
try:
|
||||
num = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if num != num or abs(num) > 180: # NaN or impossible
|
||||
return None
|
||||
return num
|
||||
|
||||
|
||||
def resolve_metadata(exif, captured_at=None, latitude=None, longitude=None):
|
||||
"""Resolve (capture_dt, lat, lng, source) from client fields then EXIF.
|
||||
|
||||
``source`` is one of 'client', 'exif', or 'server' and describes where the
|
||||
*timestamp* came from — useful for logging and for judging trust later.
|
||||
"""
|
||||
dt = _parse_client_datetime(captured_at)
|
||||
source = 'client' if dt else None
|
||||
|
||||
lat = _coerce_coord(latitude)
|
||||
lng = _coerce_coord(longitude)
|
||||
|
||||
if dt is None:
|
||||
dt = _exif_datetime(exif)
|
||||
source = 'exif' if dt else None
|
||||
|
||||
if lat is None or lng is None:
|
||||
ex_lat, ex_lng = _exif_gps(exif)
|
||||
lat = lat if lat is not None else ex_lat
|
||||
lng = lng if lng is not None else ex_lng
|
||||
|
||||
if dt is None:
|
||||
dt = now_eastern()
|
||||
source = 'server'
|
||||
|
||||
return dt, lat, lng, source
|
||||
|
||||
|
||||
# ── Overlay rendering ─────────────────────────────────────────────────────────
|
||||
|
||||
def _tz_abbrev(dt):
|
||||
"""EDT/EST label for a naive Eastern datetime."""
|
||||
try:
|
||||
return EASTERN.localize(dt).strftime('%Z')
|
||||
except Exception:
|
||||
return 'ET'
|
||||
|
||||
|
||||
def _overlay_lines(dt, lat, lng):
|
||||
"""Text lines for the overlay bar."""
|
||||
lines = [f"{dt.strftime('%Y-%m-%d %H:%M:%S')} {_tz_abbrev(dt)}"]
|
||||
if lat is not None and lng is not None:
|
||||
lines.append(f'{lat:.5f}, {lng:.5f}')
|
||||
return lines
|
||||
|
||||
|
||||
def _draw_overlay(img, lines):
|
||||
"""Draw a translucent bar with *lines* across the bottom of *img*."""
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
if img.mode not in ('RGB', 'RGBA'):
|
||||
img = img.convert('RGB')
|
||||
|
||||
width, height = img.size
|
||||
# Scale everything off the short edge so portrait and landscape match.
|
||||
base = min(width, height)
|
||||
font_size = max(11, int(base * 0.020))
|
||||
pad = max(6, int(base * 0.012))
|
||||
font = _load_font(font_size)
|
||||
|
||||
measure = ImageDraw.Draw(img)
|
||||
|
||||
# Measure the block.
|
||||
heights, widths = [], []
|
||||
for line in lines:
|
||||
box = measure.textbbox((0, 0), line, font=font)
|
||||
widths.append(box[2] - box[0])
|
||||
heights.append(box[3] - box[1])
|
||||
line_gap = max(2, int(font_size * 0.25))
|
||||
text_h = sum(heights) + line_gap * (len(lines) - 1)
|
||||
bar_h = text_h + pad * 2
|
||||
|
||||
# Draw the whole overlay on a transparent layer so both the bar AND the
|
||||
# text carry alpha, then composite once. Keeps the photo readable through
|
||||
# the stamp instead of masking it behind a solid strip.
|
||||
layer = Image.new('RGBA', (width, bar_h), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(layer)
|
||||
draw.rectangle((0, 0, width, bar_h), fill=(0, 0, 0, 80))
|
||||
|
||||
y = pad
|
||||
for line, h in zip(lines, heights):
|
||||
# Faint dark outline still keeps the text legible over a bright photo.
|
||||
for dx, dy in ((-1, 0), (1, 0), (0, -1), (0, 1)):
|
||||
draw.text((pad + dx, y + dy), line, font=font, fill=(0, 0, 0, 90))
|
||||
draw.text((pad, y), line, font=font, fill=(255, 255, 255, 165))
|
||||
y += h + line_gap
|
||||
|
||||
if img.mode == 'RGBA':
|
||||
img.alpha_composite(layer, (0, height - bar_h))
|
||||
else:
|
||||
img.paste(Image.alpha_composite(
|
||||
img.crop((0, height - bar_h, width, height)).convert('RGBA'), layer
|
||||
).convert('RGB'), (0, height - bar_h))
|
||||
|
||||
return img
|
||||
|
||||
|
||||
# ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
def stamp_image_bytes(data, captured_at=None, latitude=None, longitude=None):
|
||||
"""Burn the capture-time/geo overlay into *data*.
|
||||
|
||||
Returns ``(out_bytes, meta)``. On any failure — unsupported format, corrupt
|
||||
image, missing Pillow — returns the ORIGINAL bytes with ``meta['stamped']``
|
||||
False rather than raising, so an upload is never lost to a stamping bug.
|
||||
"""
|
||||
meta = {'stamped': False, 'captured_at': None, 'latitude': None,
|
||||
'longitude': None, 'source': None}
|
||||
try:
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
img = Image.open(io.BytesIO(data))
|
||||
fmt = (img.format or '').upper()
|
||||
|
||||
try:
|
||||
exif = img.getexif()
|
||||
except Exception:
|
||||
exif = None
|
||||
|
||||
dt, lat, lng, source = resolve_metadata(exif, captured_at, latitude, longitude)
|
||||
meta.update({'captured_at': dt, 'latitude': lat,
|
||||
'longitude': lng, 'source': source})
|
||||
|
||||
if fmt not in _STAMPABLE_FORMATS:
|
||||
logger.info('PHOTO STAMP | skipped unsupported format=%s', fmt or '?')
|
||||
return data, meta
|
||||
|
||||
# Honour the camera's EXIF orientation BEFORE drawing, otherwise the
|
||||
# bar lands on a rotated edge and the re-encode (which drops EXIF)
|
||||
# would leave the photo visibly rotated versus the original.
|
||||
img = ImageOps.exif_transpose(img)
|
||||
|
||||
img = _draw_overlay(img, _overlay_lines(dt, lat, lng))
|
||||
|
||||
out = io.BytesIO()
|
||||
if fmt == 'JPEG':
|
||||
if img.mode != 'RGB':
|
||||
img = img.convert('RGB')
|
||||
img.save(out, format='JPEG', quality=88, optimize=True)
|
||||
else:
|
||||
img.save(out, format='PNG', optimize=True)
|
||||
|
||||
meta['stamped'] = True
|
||||
return out.getvalue(), meta
|
||||
|
||||
except Exception as exc: # never lose a photo
|
||||
logger.warning('PHOTO STAMP | failed, storing original: %s', exc)
|
||||
return data, meta
|
||||
|
||||
|
||||
def stamp_file_storage(file_obj, captured_at=None, latitude=None, longitude=None):
|
||||
"""Return a FileStorage of the stamped image, plus the resolved metadata.
|
||||
|
||||
The result is a drop-in replacement for the incoming upload: it keeps the
|
||||
original ``filename``/``content_type``, so ``storage.save()`` derives the
|
||||
same key and works unchanged on both the local and s3 backends.
|
||||
"""
|
||||
from werkzeug.datastructures import FileStorage
|
||||
|
||||
file_obj.stream.seek(0)
|
||||
original = file_obj.stream.read()
|
||||
|
||||
out_bytes, meta = stamp_image_bytes(
|
||||
original, captured_at=captured_at, latitude=latitude, longitude=longitude
|
||||
)
|
||||
if not meta['stamped']:
|
||||
file_obj.stream.seek(0) # hand back the untouched upload
|
||||
return file_obj, meta
|
||||
|
||||
return FileStorage(
|
||||
stream=io.BytesIO(out_bytes),
|
||||
filename=file_obj.filename,
|
||||
content_type=file_obj.content_type,
|
||||
), meta
|
||||
+30
-15
@@ -8,6 +8,8 @@ Facility-scoping utilities for the Janitorial QC portal.
|
||||
|
||||
get_inspector_scope(user) -> list[int] | None
|
||||
Facility IDs an inspector may access via InspectorAssignment rows.
|
||||
Applies to BOTH 'inspector' (internal) and 'external_inspector'
|
||||
(customer / third-party) — see User.INSPECTOR_ROLES.
|
||||
Returns [] (empty list) when the inspector has no contract assignments,
|
||||
meaning they see nothing (strict mode).
|
||||
|
||||
@@ -16,6 +18,7 @@ that no facility-level scoping is required (full access applies).
|
||||
"""
|
||||
|
||||
import logging
|
||||
from app import db
|
||||
from app.models.project import CustomerAssignment
|
||||
from app.models.facility import Facility
|
||||
|
||||
@@ -41,30 +44,34 @@ def get_customer_scope(user) -> list[int] | None:
|
||||
if user.role != 'customer':
|
||||
return None # no scoping needed for internal staff
|
||||
|
||||
assignments = CustomerAssignment.query.filter_by(user_id=user.id).all()
|
||||
# Select only the two columns needed. The previous .all() built full
|
||||
# CustomerAssignment ORM objects (and their identity-map entries) purely to
|
||||
# read two integers off each one; this function runs on nearly every
|
||||
# request for a customer, sometimes more than once.
|
||||
assignments = db.session.query(
|
||||
CustomerAssignment.project_id,
|
||||
CustomerAssignment.facility_id,
|
||||
).filter(CustomerAssignment.user_id == user.id).all()
|
||||
|
||||
if not assignments:
|
||||
return []
|
||||
|
||||
# Separate direct facility assignments from project-level assignments
|
||||
direct_facility_ids = {a.facility_id for a in assignments if a.facility_id}
|
||||
project_ids = {a.project_id for a in assignments if not a.facility_id}
|
||||
direct_facility_ids = {fac_id for _, fac_id in assignments if fac_id}
|
||||
project_ids = {proj_id for proj_id, fac_id in assignments if not fac_id}
|
||||
|
||||
facility_ids = set(direct_facility_ids)
|
||||
|
||||
# Single bulk query for all project-scoped facilities — replaces the
|
||||
# previous per-assignment Facility.query loop (N+1 pattern).
|
||||
# previous per-assignment Facility.query loop (N+1 pattern). Only the id
|
||||
# column is read; nothing here needs a hydrated Facility.
|
||||
if project_ids:
|
||||
project_facilities = (
|
||||
Facility.query
|
||||
.filter(
|
||||
facility_ids.update(
|
||||
fid for (fid,) in db.session.query(Facility.id).filter(
|
||||
Facility.project_id.in_(project_ids),
|
||||
Facility.active == True,
|
||||
)
|
||||
.all()
|
||||
).all()
|
||||
)
|
||||
for f in project_facilities:
|
||||
facility_ids.add(f.id)
|
||||
|
||||
logger.debug(
|
||||
'SCOPE | customer_scope | user_id=%s username=%s facility_ids=%s',
|
||||
@@ -90,21 +97,29 @@ def get_inspector_scope(user) -> list[int] | None:
|
||||
None
|
||||
Returned for non-inspector roles, indicating unrestricted access.
|
||||
"""
|
||||
if user.role != 'inspector':
|
||||
# MT-15: covers BOTH 'inspector' and 'external_inspector'. An external
|
||||
# (customer / third-party) inspector is scoped by exactly the same
|
||||
# InspectorAssignment rows — the contracts an admin grants them.
|
||||
from app.models.user import User
|
||||
|
||||
if user.role not in User.INSPECTOR_ROLES:
|
||||
return None
|
||||
|
||||
from app.models.inspector_assignment import InspectorAssignment
|
||||
|
||||
# Column-only selects — see the note in get_customer_scope(). This runs on
|
||||
# every scoped request for both inspector roles.
|
||||
project_ids = [
|
||||
a.project_id
|
||||
for a in InspectorAssignment.query.filter_by(user_id=user.id).all()
|
||||
pid for (pid,) in
|
||||
db.session.query(InspectorAssignment.project_id)
|
||||
.filter(InspectorAssignment.user_id == user.id).all()
|
||||
]
|
||||
|
||||
if not project_ids:
|
||||
return [] # strict: no assignments = no access
|
||||
|
||||
facility_ids = [
|
||||
f.id for f in Facility.query.filter(
|
||||
fid for (fid,) in db.session.query(Facility.id).filter(
|
||||
Facility.project_id.in_(project_ids),
|
||||
Facility.active == True,
|
||||
).all()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user