Aug 17 - Update customer roles management
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
> **Audience:** AI assistants and developers working on this codebase.
|
||||
> **Purpose:** Authoritative reference for architecture, conventions, gotchas, and decisions.
|
||||
> **Last reviewed:** July 2026 (Phase 19 complete + mobile API gap-fill Phases A–E + customer UI refinements + Phase 22 comment visibility + Phase 23 support chat/tickets + inspector performance Excel export + inspection list filters + customer issue logging + AI chatbot + dashboard grouped sections + issues/inspections PDF export + date/ID filters + Reports expansion Phases R1–R4 + Phase 24 issue_created notify defaults + Phase 25 inspection GPS + Phase 26 issue vendor fields + Phase 27 facility score alerts + Phase 28 inspection-notify fix + Phase 29 admin broadcasts + Phases 30–32 device registry consolidation + ProxyFix reverse-proxy fix + Phase 33 per-contract notification recipients + grouped Admin nav dropdown + forgot-password case-insensitive lookup & email normalization + transactional email sender/branding fix + Phase 34 facility QR public pages & report-a-problem + Phase 35 issue handler_type (our staff / facility / vendor) + Phase 36 scheduled inspections + Phase 37 support chat persistence + Phase 38 support knowledge base + Phase 39 per-area QR public pages + Phase 40 auditor role + Phase 41 issue internal handler name + Phase 42 internal handler contact)
|
||||
> **Last reviewed:** July 2026 (Phase 19 complete + mobile API gap-fill Phases A–E + customer UI refinements + Phase 22 comment visibility + Phase 23 support chat/tickets + inspector performance Excel export + inspection list filters + customer issue logging + AI chatbot + dashboard grouped sections + issues/inspections PDF export + date/ID filters + Reports expansion Phases R1–R4 + Phase 24 issue_created notify defaults + Phase 25 inspection GPS + Phase 26 issue vendor fields + Phase 27 facility score alerts + Phase 28 inspection-notify fix + Phase 29 admin broadcasts + Phases 30–32 device registry consolidation + ProxyFix reverse-proxy fix + Phase 33 per-contract notification recipients + grouped Admin nav dropdown + forgot-password case-insensitive lookup & email normalization + transactional email sender/branding fix + Phase 34 facility QR public pages & report-a-problem + Phase 35 issue handler_type (our staff / facility / vendor) + Phase 36 scheduled inspections + Phase 37 support chat persistence + Phase 38 support knowledge base + Phase 39 per-area QR public pages + Phase 40 auditor role + Phase 41 issue internal handler name + Phase 42 internal handler contact + Phase 51 customer roles: Customer Director / Customer Inspector, both owned by Customer Management, role switching, per-account notification overrides, enrollment form narrowed to the two customer seats)
|
||||
|
||||
---
|
||||
|
||||
@@ -199,13 +199,30 @@ users: id, username (unique, indexed), full_name, email (unique, indexed),
|
||||
|
||||
**Role ENUM:** `admin`, `director`, `inspector`, `project_manager`, `customer`, `auditor`, `external_inspector`
|
||||
|
||||
**`external_inspector` (Phase 49):** An inspector employed by **the customer or a third party** rather than by us. It has **exactly the same capabilities as `inspector`** and is scoped the **same way** — through `InspectorAssignment` rows resolved by `get_inspector_scope()`, i.e. an admin grants it the customer's contracts on the existing **Assign Contracts** page (`/auth/users/<id>/assign-contracts`, now gated on `user.is_inspector`). Strict scoping applies unchanged: no assignments = sees nothing.
|
||||
### The two customer-side roles (Phase 51)
|
||||
|
||||
The two roles are distinguished by **display only**. `User.INSPECTOR_ROLES = ('inspector', 'external_inspector')` and the `User.is_inspector` property are the single definition — **every** capability/scoping check tests `is_inspector`, never `role == 'inspector'` (rule 87). `User.is_external_inspector` and `User.role_label` (backed by the `ROLE_LABELS` map) drive the "External" badges: users list, dashboard **Inspector Activity**, **Inspector Performance** report (HTML + the Excel export, where the name cell is suffixed `(External)` rather than gaining a column so the index-based cell styling stays correct), and every assignee dropdown (`(External)` suffix — issues create/update, issue-list quick-assign, inspection flag-issue).
|
||||
Both roles below belong to the **customer**, not to us. They are the two seats the enrollment form offers, and both are created, invited, assigned, switched and disabled in **Customer Management** (`/customers`) — User Management excludes them entirely.
|
||||
|
||||
**Invited, not provisioned (Aug 2026).** An external inspector works outside the business, so an admin never sets their password. Creating one at `/auth/users/new` follows the customer invitation flow instead: the account is stored with `password_set=False` and a random placeholder hash, a 72-hour `set_password_token` is minted, and `customers._send_invite_email()` (reused unchanged — its copy already fits any invited account) sends a link to **`/customers/set-password/<token>`**, where they choose their own **username and password**. `login()` refuses `password_set=False` until they finish. `POST /auth/users/<id>/resend-invite` (admin-only) mints a fresh token and re-sends — without it a bounced or expired invitation would leave the account permanently unusable. The users list shows an **"Invite pending"** badge and the resend button while `password_set` is false. Every other role is unaffected: they are still created with an admin-set password, and `create_user()` now **rejects a blank password** for them rather than storing the hash of an empty string.
|
||||
| Stored ENUM value | Display label | Scoped by | Capabilities |
|
||||
|---|---|---|---|
|
||||
| `customer` | **Customer Director** | `CustomerAssignment` (contract **or** single facility) | The portal, unchanged — read-mostly, own-facility issues/comments/follow-up requests |
|
||||
| `external_inspector` | **Customer Inspector** | `InspectorAssignment` (whole contracts only) | Identical to the internal `inspector`, limited to their contracts |
|
||||
|
||||
Assignable (rule 80 set becomes `director`/`inspector`/`external_inspector`/`auditor`, plus `project_manager` on the inspection flag-issue dropdown), included in Inspector Performance and Inspector Activity, and has **mobile-API access** — `external_inspector` is in the `_ALLOWED_ROLES` of every `app/api/*` module and falls into the inspector branch of every scoping check there. It gets its **own Notification Matrix column** (`external_inspector`), whose defaults mirror the Inspector column (see §11).
|
||||
**This is a LABEL-only rename** — the same posture as rule 19 ("Project" → "Contract"). The ENUM values are unchanged, so phase51 needed **no user migration** and moved none of the ~63 `external_inspector` call sites or the many `role == 'customer'` checks. `User.ROLE_LABELS` is the one place the names live.
|
||||
|
||||
**`CUSTOMER_ROLES` is not interchangeable with `role == 'customer'` — see rule 89.** `User.CUSTOMER_ROLES = ('customer', 'external_inspector')` and `User.is_customer_account` answer an *account-management* question ("is this managed under /customers?"). Every *capability* check — the portal gates, `@customer_required`, `get_customer_scope()`, support chat, the customer branch in each API module, `notify_customers_for_facility()` — must keep testing `role == 'customer'` exactly, because a Customer Inspector is an **inspector** there.
|
||||
|
||||
**Switching between them** — `POST /customers/<id>/switch-role`, admin-only. The two roles read different scoping tables, so the switch **mirrors the contracts across** (a bare role flip would leave the account correctly labelled and seeing nothing). Rows for the role being left are **kept**, not deleted. Two consequences worth knowing:
|
||||
- Director → Inspector **widens** any facility-level narrowing to the whole contract — inspectors have no per-facility row. The confirm dialog and the flash both say so.
|
||||
- Inspector → Director is **lossless on a round trip**: the reverse mirror skips contracts the account can already reach *by any* `CustomerAssignment` row, so it cannot stack a contract-wide grant on top of the original facility-level one.
|
||||
|
||||
API access changes in both directions (`external_inspector` has mobile-API access, `customer` is 403 everywhere), so the switch **revokes all `api_refresh_tokens` and deletes `api_device_tokens`** for the account — otherwise an issued JWT would keep working until expiry and a signed-in iPad would keep syncing.
|
||||
|
||||
**`external_inspector` (Phase 49) details, still current:** it has **exactly the same capabilities as `inspector`** and is scoped the **same way** — `InspectorAssignment` rows resolved by `get_inspector_scope()`. Strict scoping applies unchanged: no assignments = sees nothing. `User.INSPECTOR_ROLES = ('inspector', 'external_inspector')` and the `User.is_inspector` property are the single definition — **every** capability/scoping check tests `is_inspector`, never `role == 'inspector'` (rule 87). `User.is_external_inspector` and `User.role_label` drive the badges: dashboard **Inspector Activity**, **Inspector Performance** report (HTML badge reads "Customer"; the Excel export suffixes the name cell `(Customer)` rather than gaining a column, so the index-based cell styling stays correct), and every assignee dropdown (`(Customer)` suffix — issues create/update, issue-list quick-assign, inspection flag-issue). Phase 51 changed those strings from "External"; the *attribute* names did not move (rule 84).
|
||||
|
||||
**Invited, never provisioned.** Neither customer role is given a password we chose. `customers.create()` stores the account with `password_set=False` and a random placeholder hash, mints a 72-hour `set_password_token`, and `_send_invite_email()` sends a link to **`/customers/set-password/<token>`** where they choose their own **username and password**. `login()` refuses `password_set=False` until they finish. `POST /customers/<id>/resend-invite` mints a fresh token and re-sends — without it a bounced or expired invitation leaves the account permanently unusable. Phase 51 moved this branch out of `auth.create_user()`, which now **requires** a password for every role it still offers (all of them ours).
|
||||
|
||||
Assignable (rule 80 set: `director`/`inspector`/`external_inspector`/`auditor`, plus `project_manager` on the inspection flag-issue dropdown), included in Inspector Performance and Inspector Activity, and has **mobile-API access** — `external_inspector` is in the `_ALLOWED_ROLES` of every `app/api/*` module and falls into the inspector branch of every scoping check there. It gets its **own Notification Matrix column** (`external_inspector`, labelled "Customer Inspector"), whose defaults mirror the Inspector column (see §11), and both customer roles additionally support **per-account overrides** (§11).
|
||||
|
||||
**`auditor` (Phase 40):** A staff role with the **same access as `project_manager`** (it is included in `@project_manager_required` and everywhere `project_manager` is checked) **plus full issue-management powers** — create, assign, quick-assign, handler/vendor triage, request-verification, and verify/bulk-verify/verification-queue (via the new `@issue_manager_required` decorator). **Auditor does NOT get issue deletion** (that stays admin/director via `@supervisor_required`), nor any other admin/director-only area PM lacks (users, audit trail, notification matrix, customers, templates). Auditors are **assignable** as an issue/inspection assignee; **admin was removed** from the assignable set at the same time (assignee dropdowns are now `director`/`inspector`/`auditor`, plus `project_manager` on the inspection flag-issue dropdown). The issue-update route defensively keeps any pre-existing out-of-set assignee (e.g. a legacy admin assignment) in the dropdown so saving never silently unassigns. Auditor **has mobile-API access** — it is included in the `_ALLOWED_ROLES` set of every `app/api/*` module (comments, inspections, issues, photos, scheduled, stats, templates), so the iPad app accepts auditor logins. In every API endpoint that scopes by role, auditor falls into the non-inspector/non-customer (privileged) branch — org-wide data, same as admin/director/PM.
|
||||
|
||||
@@ -377,6 +394,28 @@ notification_matrix: id, event_type, role_key, enabled, custom_emails (JSON)
|
||||
UniqueConstraint(event_type, role_key)
|
||||
```
|
||||
|
||||
### UserNotificationMatrix (Phase 51)
|
||||
|
||||
```
|
||||
user_notification_matrix: id, user_id (FK→users CASCADE, indexed),
|
||||
event_type VARCHAR(50), enabled BOOL
|
||||
UniqueConstraint(user_id, event_type)
|
||||
```
|
||||
|
||||
**Per-account override of the global matrix**, for the two customer-side roles only. Each customer organisation states on its enrollment form which notifications each of its people wants, and the global matrix's grain (whole roles) cannot express that.
|
||||
|
||||
| Row state | Meaning |
|
||||
|---|---|
|
||||
| `enabled=True` | send even if the global column for this role is OFF |
|
||||
| `enabled=False` | do not send even if the global column is ON |
|
||||
| **no row** | **inherit** — follow the global column, including later changes to it |
|
||||
|
||||
Inherit is the default and the safe state, so the table shipped empty and changed routing for nobody. Setting a row back to inherit **deletes** it rather than snapshotting the current global value — that is what keeps an account that never expressed an opinion tracking the global matrix.
|
||||
|
||||
Helpers in `app/models/user_notification_matrix.py`: `overrides_for_user(user_id)` → `{event: bool}` (the editor), `overrides_for_event(event_type)` → `{user_id: bool}` (one query per dispatch, fails soft to `{}`), `set_overrides(user_id, {event: True|False|None})` (does **not** commit — caller owns the transaction, same contract as `notify()`).
|
||||
|
||||
Edited admin-side on the account's Customer Management page as a tri-state (Inherit / On / Off) with the global column's current value shown under "Inherit". Staff roles are unaffected — they use the global matrix alone; `NotificationPreference` remains a different question (how to deliver, not whether to route).
|
||||
|
||||
### AuditLog
|
||||
|
||||
```
|
||||
@@ -531,7 +570,7 @@ Management (`/scheduled-inspections/new|edit|delete`) is `@project_manager_requi
|
||||
| `facilities` | `/facilities` | CRUD + area management + QR code: `/<id>/qr` printable page, `/<id>/qr.png` image, `POST /<id>/qr/regenerate` (invalidates old printed code), `/qr/print-all[?contract_id=]` bulk sheet. **Per-area QR (Phase 39):** `/areas/<id>/qr`, `/areas/<id>/qr.png`, `POST /areas/<id>/qr/regenerate` — mirror the facility QR routes; scope enforced by `_area_for_qr_or_403()` via the area's parent facility. **Customers may use all QR actions (including regenerate) for their own assigned facilities**; inspectors/PM/admin/director for any. Scope enforced by `_facility_for_qr_or_403()` (customers) / `get_customer_scope` (print-all). Regenerate is limited to admin/director + scoped customer (PM/inspector excluded). **QR print/export page:** `GET /qr/print-all` is a selectable sheet with filters `?contract_id=` / `?facility_id=` / `?include_areas=1` (contract narrows the facility dropdown; areas render each facility's per-area QR cards). Each card is a `<label>` wrapping a checkbox; **Print Selected** (JS toggles `body.print-selected-only` so `@media print` hides unticked cards) and **Export Selected to PDF** (`POST /qr/export-pdf`, repeated `facility_ids`/`area_ids`, scope re-checked per id via the `_*_for_qr_or_403()` helpers, streams `generate_qr_codes_pdf()` output; logs `ACTION_EXPORT`). Inspectors 403. QR PNG bytes for the PDF come from `_qr_png_bytes(url)`. |
|
||||
| `public` | `/f` | **No login.** `GET /<token>` occupant facility summary + `POST /<token>/report` occupant issue report; `GET /area/<token>` per-area summary + `POST /area/<token>/report` (Phase 39, files with `area_id` set). Report form accepts **up to 5 photos** (`_save_report_photos()` → `photo_path` + `mobile_photo_paths`). All report POSTs rate-limited `5/hour`, honeypot-guarded. Resolves ACTIVE facility (area's parent must be active) by `public_token` or 404. |
|
||||
| `projects` | `/projects` | CRUD + customer assignment management + notification-recipient add/remove (`/<id>/notify-recipients/add`, `/notify-recipients/<rid>/remove` — admin only) |
|
||||
| `customers` | `/customers` | list, invite, set-password, manage, import CSV |
|
||||
| `customers` | `/customers` | **Owns BOTH customer roles (Phase 51).** `GET /` list (both roles, role badge + per-role scope column), `GET/POST /new` invite (role select: Customer Director / Customer Inspector — same invitation flow for both), `/set-password/<token>`, `GET /<id>` manage, `/<id>/edit`, `POST /<id>/assignments/add` + `/assignments/<aid>/remove` (**director only** — `CustomerAssignment`), `POST /<id>/contracts` (**inspector only** — replaces the whole `InspectorAssignment` set, rule 59 semantics), `POST /<id>/notifications` (per-account matrix overrides), `POST /<id>/switch-role` (**admin only** — mirrors contracts across, revokes tokens/devices), `POST /<id>/toggle-active`, `POST /<id>/resend-invite`, import CSV |
|
||||
| `inspections` | `/inspections` | list, start, execute, view, PDF export, flag-issue, save-draft (AJAX), flag-followup, reinspect, upload-photo (AJAX) |
|
||||
| `templates` | `/templates` | list, create, edit, delete, form editor, preview |
|
||||
| `issues` | `/issues` | list, view, create, update, verify, comment, follow/unfollow, verification queue, bulk-verify, delete, quick-assign. **verify / bulk-verify / verification-queue are `@issue_manager_required` (admin/director/auditor); delete stays `@supervisor_required` (admin/director).** |
|
||||
@@ -799,6 +838,20 @@ Customers can only *request*. `clear_followup` remains admin/director, `reinspec
|
||||
|
||||
**Dispatch** goes through `notify_by_matrix(EVENT_FOLLOWUP_REQUESTED, ...)` — the new `followup_requested` matrix event (admin/director/PM on by default). The inspection's own inspector is notified directly by the route and passed in `exclude_user_ids` so they aren't double-notified; the requester is excluded too. Routing via the matrix (rather than hardcoding managers) is what makes per-contract recipients fire — rule 73. Without it a customer request would reach only the inspector and nobody would own scheduling the re-inspection.
|
||||
|
||||
### Per-Account Overrides for Customer Roles (Phase 51)
|
||||
|
||||
`notify_by_matrix()` consults `UserNotificationMatrix` (§5) for the two customer-side role columns. One query per dispatch (`overrides_for_event`), then `users = [u for u in users if overrides.get(u.id, enabled)]` — an account with no row falls back to the global column, which is what makes both directions work.
|
||||
|
||||
**The skip-early guard had to change, and this is the subtle part.** The role loop used to `continue` on `if not enabled` *before* loading the pool, so a per-account opt-IN against a globally-OFF column would have saved fine, displayed as on, and never sent — a silent failure. A customer column is now skipped only when it is off **and** nobody has opted in:
|
||||
|
||||
```python
|
||||
is_customer_col = role_key in User.CUSTOMER_ROLES
|
||||
if not enabled and not (is_customer_col and any(overrides.values())):
|
||||
continue
|
||||
```
|
||||
|
||||
**`notify_customers_for_facility()` needs the filter passed in.** It re-derives recipients from `CustomerAssignment` rows itself, so the facility-scoped `customer` branch would bypass every override applied a few lines above. `notify_by_matrix()` therefore passes `allowed_user_ids={u.id for u in users}`; direct callers omit it (`None` = no filtering) and behave as before. Its `user.role != 'customer'` check stays an **equality** test — a Customer Inspector is routed by the inspector column, not this one.
|
||||
|
||||
### Per-Contract Additional Recipients (Phase 33)
|
||||
|
||||
`notify_by_matrix()` is the single dispatch point for all broadcast events. After routing to the global matrix roles + global custom emails, it calls `_notify_contract_recipients()`, which:
|
||||
@@ -912,7 +965,22 @@ phase1_projects_roles → phase6_features → phase7_mobile_api → phase8_notif
|
||||
→ phase47_sched_acknowledged
|
||||
→ phase48_user_ui_theme
|
||||
→ phase49_external_inspector
|
||||
→ phase50_default_modern ← HEAD
|
||||
→ phase50_default_modern
|
||||
→ phase51_user_notif_matrix ← HEAD
|
||||
|
||||
#### phase51 — per-account notification overrides
|
||||
|
||||
Revision id `phase51_user_notif_matrix` (file `phase51_user_notification_matrix.py`, down_revision `phase50_default_modern`). Creates `user_notification_matrix` — see §5 `UserNotificationMatrix` and §11.
|
||||
|
||||
**No backfill, deliberately.** An empty table means every account inherits the global matrix, which is exactly today's behaviour, so this migration cannot change who gets notified. Backfilling from the current global columns would freeze every account at today's routing and silently break future changes to those columns. Table-existence check — safe to re-run.
|
||||
|
||||
**The rest of phase51 needs no migration.** Customer Director / Customer Inspector is a **label-only** rename over the existing `customer` and `external_inspector` ENUM values (§5), so no ENUM change and no user row is touched. `downgrade()` drops the table, discarding every override and returning all accounts to global routing.
|
||||
|
||||
**Deploy order:**
|
||||
```bash
|
||||
flask db upgrade
|
||||
sudo systemctl restart gunicorn
|
||||
```
|
||||
|
||||
#### phase50 — modern design becomes the default
|
||||
|
||||
@@ -1509,6 +1577,8 @@ timeout = 30
|
||||
| 85 | **`next_due_date` is mutable state, `end_date` is a fixed boundary — never conflate them** | `fulfill()` rewrites `next_due_date` after every completed inspection; `end_date` is set by the manager and never touched by the app. The old single label "Start / Due Date" said both at once, which is what users reported as confusing. The label now follows context — `form.next_due_date.label.text` is set to "Start Date" in `create()` and "Next Due Date" in `edit()`. Do not rename the `next_due_date` column to match a label: it is indexed, it is the API payload key the iPad decodes, and the reminder cron filters on it. |
|
||||
| 87 | **Never write `role == 'inspector'` — use `user.is_inspector` (`User.INSPECTOR_ROLES`)** | phase49 added `external_inspector`, which must behave as an inspector everywhere. An equality check silently drops it into the *privileged* branch of every `if inspector: scope … else: org-wide` block — i.e. a third-party inspector would see **every contract in the system**. This is a fail-OPEN mistake: nothing errors, the data just leaks. The sweep converted ~44 Python sites and 7 template sites; the only surviving `== 'inspector'` literals are the matrix docstring, the `MATRIX_DEFAULTS` mirror comprehension, and the default-checked box in `admin/broadcast.html`. Query-level checks use `User.role.in_(User.INSPECTOR_ROLES)` (never `filter_by(role='inspector')`). A **new** `app/api/*` blueprint's `_ALLOWED_ROLES` must include `external_inspector`, same as rule 79 requires for `auditor`. |
|
||||
| 88 | **`app/enrollment/` writes no DB row and has exactly ONE read — keep the vertical slice sealed** | The enrollment form describes accounts that do NOT exist yet (no contract, facility or user to key a row against), so it stores flat JSON in `ENROLLMENT_DIR` and owns its own templates. The single permitted model access is `mailer._admin_recipients()` reading active `admin` users to address the new-enrollment alert — function-local, read-only, and guarded so a DB failure cannot break a submission. Adding a model/migration for enrollment, or letting the public POST **create** Users, would couple an unauthenticated endpoint to the account system — the exact thing the separation buys. If enrollment must ever provision accounts, do it as a separate admin-triggered action that reads a stored submission. Submission ids are filesystem paths: validate against `_ID_RE` before every open (path traversal). See §24. |
|
||||
| 89 | **`User.CUSTOMER_ROLES` is for ACCOUNT MANAGEMENT; `role == 'customer'` is for CAPABILITY — never swap them** | The inverse of rule 87, and it fails in both directions. Widening a capability check to `CUSTOMER_ROLES` hands a third-party Customer Inspector the customer portal (fail-OPEN, nothing errors). Narrowing an account-management check back to `'customer'` strands every Customer Inspector in a page that no longer lists or edits them (fail-closed, but invisible until someone looks for a missing account). `CUSTOMER_ROLES` / `is_customer_account` appear ONLY in: the `/customers` list query, its route guards, and the `auth.list_users` exclusion. Everything else — portal gates, `@customer_required`, `get_customer_scope()`, support chat, `notify_customers_for_facility()`, the customer branch of every `app/api/*` scope check — keeps the equality test, because a Customer Inspector is an **inspector** there (rule 87 already routes it correctly). |
|
||||
| 90 | **A per-account notification opt-IN must survive a globally-OFF column** | `notify_by_matrix()` skips a role column early when the matrix says off. For the two customer columns that early `continue` has to also ask whether anyone opted in (`any(overrides.values())`), or the override saves, displays as on, and never sends — a silent failure with no error anywhere. Equally, `notify_customers_for_facility()` re-queries recipients from assignment rows, so `notify_by_matrix()` must hand it `allowed_user_ids` or the facility-scoped path bypasses every override. Both halves are needed; either one alone leaves a hole. See §11. |
|
||||
| 81 | **Photo timestamp/geo overlay is burned at UPLOAD, never on `PATCH /issues/<id>/photos`** | That PATCH receives only path strings — the bytes are already in storage and the payload carries no capture metadata. Burning there would need a read-modify-write per key plus an overwrite-in-place primitive (`storage.save()` mints a NEW uuid key, and §22 requires key == DB path), and would risk a **double burn** since the endpoint is deliberately idempotent/retry-safe (rule 45). Stamp in `POST /photos/upload`, where the raw bytes + EXIF are in hand and each call writes exactly one already-stamped object. Stamping failures must always fall back to storing the ORIGINAL bytes — never lose a photo to a stamping bug. See §23. |
|
||||
|
||||
---
|
||||
@@ -1681,7 +1751,11 @@ The printed form had six fixed seats (Admin/Director + Inspector 1–5) and a st
|
||||
|
||||
The header collects Project Name, **Request by**, **Requester email** (required — the confirmation goes there) and Date Requested. The printed sheet's blank *"for office use"* block is **not rendered on the web form** — a customer cannot fill it in; those fields still exist and are filled by staff on the admin detail page.
|
||||
|
||||
1. **Step 1 — the people.** Free-form rows, each with a **role dropdown** (`schema.ROLES`: Admin / Director / Auditor / Inspector / External Inspector), name, job title, email. Starts with one row defaulted to `DEFAULT_FIRST_ROLE`; **"Add another person"** appends more, capped at `MAX_PEOPLE` (25). The last row cannot be removed.
|
||||
1. **Step 1 — the people.** Free-form rows, each with a **role dropdown**, name, job title, email. Starts with one row defaulted to `DEFAULT_FIRST_ROLE` (`director`); **"Add another person"** appends more, capped at `MAX_PEOPLE` (25). The last row cannot be removed.
|
||||
|
||||
**`schema.ROLES` offers exactly two roles — Director and Inspector (Phase 51).** Enrollment describes *customer-side* people only; our own staff (admin, auditor, internal inspector) are created in User Management and were never really enrollable. `schema.APP_ROLE_FOR` maps the seat to the app role an admin creates later — `director` → `customer` (Customer Director), `inspector` → `external_inspector` (Customer Inspector). It is a plain string map: the package still imports nothing from `app.models` (rule 88).
|
||||
|
||||
**`ADMIN_ROLES` keeps `admin` and `auditor` even though neither is selectable** — legacy tolerance. Submissions taken before Phase 51 stored those roles, and dropping them from the set would silently re-render their admin-only task cells (ref 10) as `n/a` in the admin detail view and the CSV export. Selectable roles shrink; the ability to read back what was already recorded does not. (`routes.py` already coerces an unrecognised posted role to `DEFAULT_FIRST_ROLE`, so the narrower dropdown needs no parser change.)
|
||||
2. **Step 2 — the task matrix**, with **one column per person from Step 1**, rebuilt in the browser whenever a name, role or row changes. Existing ticks survive a rebuild (preserved by field name).
|
||||
3. **Step 3 — mobile app**, likewise one column per person.
|
||||
|
||||
@@ -1691,6 +1765,8 @@ A **"Recommendation selection"** button applies `schema.recommendation_map()` pe
|
||||
|
||||
**Admin-only tasks are enforced server-side.** `task_applies()` gates ref 10 (Search/Export Reports) to `ADMIN_ROLES`; the POST parser only reads cells the person's role offers, so a crafted POST cannot record an admin-only task against an inspector — verified.
|
||||
|
||||
**Rows 7, 9 and 10 record an expectation; they do not switch anything on** (`schema.ROLE_IMPLIED_TASKS`, rendered as a footnote under Step 2). Both customer roles already carry all three today — comment on issues they follow or filed, log an issue at their own facility, search/export reports within their scope — so a per-person flag would be a **deny**-check, meaning new gates on routes that have none, i.e. a fail-open surface for no gain (the rule 87 failure class, self-inflicted). They stay in the form and the CSV because they are a useful record of what the customer expected, and they drive the Recommendation preset. **Rows 1–6 and 8 are the ones that map to notification events** and can be tuned per account in Customer Management (§5 `UserNotificationMatrix`) — seeding those overrides from a submission's ticks is a deliberate follow-up, not built: it needs a person↔account match by email that nothing in the system does yet, and rule 88 forbids the public form reaching into accounts, so it can only ever be an admin-triggered, confirm-before-save action.
|
||||
|
||||
### `schema.py` is the source of truth
|
||||
|
||||
`ROLES`, `ADMIN_ROLES`, `TASKS` (10 rows; ref 10 is `admin_only`), `RECOMMENDATION`, `OFFICE_FIELDS`, `STATUSES`. The public template renders from it *and hands it to the page as JSON* (`ROLES`, `TASKS`, `ADMIN_ROLES`, `recommendation_map()`), the POST parser iterates it, and the admin views re-render stored answers through it — so adding a task row or a role is a one-line edit with no template, JS or parser change.
|
||||
|
||||
@@ -17,25 +17,41 @@ 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 = [
|
||||
('admin', 'Admin'),
|
||||
('director', 'Director'),
|
||||
('auditor', 'Auditor'),
|
||||
('inspector', 'Inspector'),
|
||||
('external_inspector', 'External 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_ROLES = {'admin', 'director', 'auditor'}
|
||||
#
|
||||
#: '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 one
|
||||
#: Role pre-selected for the first row — the form starts with the customer's
|
||||
#: administrative contact, as on the printed sheet.
|
||||
DEFAULT_FIRST_ROLE = 'admin'
|
||||
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.
|
||||
@@ -64,6 +80,20 @@ TASKS = [
|
||||
|
||||
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`."""
|
||||
|
||||
@@ -170,6 +170,17 @@
|
||||
|
||||
<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
|
||||
|
||||
@@ -6,5 +6,6 @@ from app.models.issue import Issue
|
||||
from app.models.project import Project, CustomerAssignment
|
||||
from app.models.api_token import RefreshToken, DeviceToken
|
||||
from app.models.notification_matrix import NotificationMatrix
|
||||
from app.models.user_notification_matrix import UserNotificationMatrix
|
||||
from app.models.notification_recipient import ContractNotificationRecipient
|
||||
from app.models.scheduled_inspection import ScheduledInspection
|
||||
@@ -44,14 +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', 'External Inspector'),
|
||||
('external_inspector', 'Customer Inspector'),
|
||||
('project_manager', 'Project Manager'),
|
||||
('auditor', 'Auditor'),
|
||||
('customer', 'Customer'),
|
||||
('customer', 'Customer Director'),
|
||||
('custom', 'Custom Recipients'),
|
||||
]
|
||||
|
||||
|
||||
+52
-6
@@ -3,17 +3,22 @@ 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. 'external_inspector' would otherwise title
|
||||
# case to "External Inspector" anyway, but the map keeps every label in one
|
||||
# place for templates that show a role name.
|
||||
# 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': 'External Inspector',
|
||||
'customer': 'Customer',
|
||||
'external_inspector': 'Customer Inspector',
|
||||
'customer': 'Customer Director',
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +43,27 @@ class User(UserMixin, db.Model):
|
||||
# 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)
|
||||
@@ -105,9 +131,29 @@ class User(UserMixin, db.Model):
|
||||
|
||||
@property
|
||||
def is_external_inspector(self):
|
||||
"""True only for third-party / customer-employed inspectors."""
|
||||
"""True only for third-party / customer-employed inspectors.
|
||||
|
||||
Display name: "Customer Inspector". The attribute keeps its phase49
|
||||
name so the ~60 existing call sites stay put (rule 84 — the rename is
|
||||
a label, never an identifier).
|
||||
"""
|
||||
return self.role == 'external_inspector'
|
||||
|
||||
@property
|
||||
def is_customer_account(self):
|
||||
"""True for BOTH customer-side roles — an account-management question.
|
||||
|
||||
Answers "is this account managed under /customers?", NOT "does this
|
||||
account get the customer portal". For the latter keep testing
|
||||
`role == 'customer'`. See the CUSTOMER_ROLES note above.
|
||||
"""
|
||||
return self.role in self.CUSTOMER_ROLES
|
||||
|
||||
@property
|
||||
def is_customer_director(self):
|
||||
"""True for the portal-side customer role ('customer')."""
|
||||
return self.role == 'customer'
|
||||
|
||||
@property
|
||||
def role_label(self):
|
||||
"""Human-readable role name, used in staff-facing lists."""
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"""
|
||||
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 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
|
||||
+48
-38
@@ -243,10 +243,13 @@ def request_my_data_deletion():
|
||||
@login_required
|
||||
@admin_required
|
||||
def list_users():
|
||||
# Exclude customer accounts — those are managed exclusively via /customers
|
||||
# Exclude customer-side accounts — Customer Director AND Customer Inspector
|
||||
# are both managed exclusively via /customers (phase51). Using
|
||||
# User.CUSTOMER_ROLES rather than != 'customer' is what moves the customer
|
||||
# inspectors off this page.
|
||||
users = (
|
||||
User.query
|
||||
.filter(User.role != 'customer')
|
||||
.filter(~User.role.in_(User.CUSTOMER_ROLES))
|
||||
.order_by(User.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
@@ -270,6 +273,25 @@ def list_users():
|
||||
inspector_contract_counts=inspector_contract_counts)
|
||||
|
||||
|
||||
def _redirect_if_customer_account(user):
|
||||
"""Send customer-side accounts back to Customer Management.
|
||||
|
||||
phase51 moved Customer Director + Customer Inspector wholly under
|
||||
/customers. These accounts are no longer listed here, but the /auth/users
|
||||
URLs are still reachable by hand — and editing one through UserForm would
|
||||
fail anyway ('external_inspector' is no longer an offered role choice, so
|
||||
SelectField would reject the existing value). Redirect instead of 404 so an
|
||||
old bookmark lands on the page that now owns the account.
|
||||
|
||||
Returns a response to return, or None to continue.
|
||||
"""
|
||||
if user is not None and user.is_customer_account:
|
||||
flash(f'{user.display_name} is a {user.role_label} account and is '
|
||||
f'managed in Customer Management.', 'info')
|
||||
return redirect(url_for('customers.manage', customer_id=user.id))
|
||||
return None
|
||||
|
||||
|
||||
@bp.route('/users/new', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
@@ -283,20 +305,17 @@ def create_user():
|
||||
if form.validate_on_submit():
|
||||
role = 'inspector' if director_editing else form.role.data
|
||||
|
||||
# phase51 — an external inspector works for the customer or a third
|
||||
# party, so we never set a password on their behalf. They are invited
|
||||
# exactly like a customer: created with password_set=False (which the
|
||||
# login route refuses until they finish), given a one-time token, and
|
||||
# emailed a link to choose their own username and password.
|
||||
invite = (role == 'external_inspector')
|
||||
|
||||
# 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
|
||||
# unless the account is being invited to choose their own.
|
||||
if not invite and not form.password.data:
|
||||
flash('Please set a password, or choose the External Inspector role '
|
||||
'to send an invitation instead.', 'danger')
|
||||
# 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)
|
||||
@@ -306,38 +325,18 @@ def create_user():
|
||||
full_name=(form.full_name.data or '').strip() or None,
|
||||
email=form.email.data.strip().lower(),
|
||||
role=role,
|
||||
password_set=not invite,
|
||||
password_set=True,
|
||||
)
|
||||
if invite:
|
||||
# A random unguessable placeholder — password_set=False already
|
||||
# blocks login, but never leave an account holding a known or
|
||||
# empty-string hash.
|
||||
user.set_password(secrets.token_hex(32))
|
||||
else:
|
||||
user.set_password(form.password.data)
|
||||
db.session.add(user)
|
||||
db.session.flush() # need user.id before minting the token
|
||||
|
||||
token = user.generate_set_password_token(expires_hours=72) if invite else None
|
||||
db.session.commit()
|
||||
|
||||
logger.info('AUTH | user_create | admin_id=%s admin=%s new_user=%s role=%s invite=%s',
|
||||
logger.info('AUTH | user_create | admin_id=%s admin=%s new_user=%s role=%s',
|
||||
current_user.id, current_user.username, user.username,
|
||||
user.role, invite)
|
||||
user.role)
|
||||
log_action(ACTION_CREATE, 'User', user.id, user.username,
|
||||
f'role={user.role}; email={user.email}; invite_sent={invite}')
|
||||
f'role={user.role}; email={user.email}')
|
||||
|
||||
if invite:
|
||||
# Reuses the customer invitation email — the copy ("an account has
|
||||
# been created for you… set your password") is already correct for
|
||||
# any invited account. Imported inside the function to keep the
|
||||
# auth ↔ customers import graph acyclic.
|
||||
from app.routes.customers import _send_invite_email
|
||||
_send_invite_email(user, token, base_url=request.host_url)
|
||||
flash(f'External inspector {user.display_name} created. An invitation '
|
||||
f'email has been sent to {user.email} with a link to set their '
|
||||
f'username and password.', 'success')
|
||||
else:
|
||||
flash(f'User {user.username} created successfully.', 'success')
|
||||
return redirect(url_for('auth.list_users'))
|
||||
|
||||
@@ -352,6 +351,9 @@ def edit_user(user_id):
|
||||
user = db.session.get(User, user_id)
|
||||
if user is None:
|
||||
abort(404)
|
||||
moved = _redirect_if_customer_account(user)
|
||||
if moved:
|
||||
return moved
|
||||
form = UserForm(user=user, obj=user)
|
||||
|
||||
# Directors may not change another user's role — that privilege is admin-only.
|
||||
@@ -396,6 +398,9 @@ def resend_invite(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
|
||||
if user.password_set:
|
||||
flash(f'{user.display_name} has already completed their account setup.',
|
||||
'info')
|
||||
@@ -424,6 +429,11 @@ def assign_inspector_contracts(user_id):
|
||||
user = db.session.get(User, user_id)
|
||||
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
|
||||
|
||||
@@ -27,7 +27,7 @@ BROADCAST_ROLES = ['inspector', 'external_inspector', 'project_manager',
|
||||
|
||||
ROLE_LABELS = {
|
||||
'inspector': 'Inspectors',
|
||||
'external_inspector': 'External Inspectors',
|
||||
'external_inspector': 'Customer Inspectors',
|
||||
'project_manager': 'Project Managers',
|
||||
'director': 'Directors',
|
||||
'admin': 'Admins',
|
||||
|
||||
+384
-52
@@ -3,13 +3,26 @@ app/routes/customers.py
|
||||
-----------------------
|
||||
Customer Management — admin-only consolidated view.
|
||||
|
||||
Owns BOTH customer-side roles (phase51 — see User.CUSTOMER_ROLES):
|
||||
|
||||
Customer Director role='customer' portal access, read-mostly,
|
||||
scoped by CustomerAssignment
|
||||
Customer Inspector role='external_inspector' full inspector capabilities,
|
||||
scoped by InspectorAssignment
|
||||
|
||||
They are two seats of the same customer organisation, so they are listed,
|
||||
invited, edited, assigned, switched and disabled here rather than in User
|
||||
Management — which now excludes both.
|
||||
|
||||
Provides a single screen to:
|
||||
- List all customer-role users with their assignment summary
|
||||
- Create a new customer account
|
||||
- Edit an existing customer (username / email / password / active)
|
||||
- Manage assignments for a customer (add / remove)
|
||||
- Quick-disable / enable a customer account
|
||||
- View a customer's scoped facility access at a glance
|
||||
- List all customer-side users with their assignment summary
|
||||
- Invite a new customer account in either role
|
||||
- Edit an existing account (username / email / password / active)
|
||||
- Manage assignments (contracts/facilities for a director, contracts for an
|
||||
inspector)
|
||||
- Switch an account between the two roles
|
||||
- Quick-disable / enable an account
|
||||
- View the account's scoped facility access at a glance
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -18,6 +31,7 @@ from flask_login import login_required, current_user
|
||||
from app import db
|
||||
from app.models.user import User
|
||||
from app.models.project import Project, CustomerAssignment
|
||||
from app.models.inspector_assignment import InspectorAssignment
|
||||
from app.models.facility import Facility
|
||||
from app.utils.forms import CustomerUserForm, CustomerAssignmentForm, CustomerInviteForm, SetPasswordForm
|
||||
from app.utils.decorators import admin_required, supervisor_required, safe_redirect_url
|
||||
@@ -29,16 +43,46 @@ logger = logging.getLogger(__name__)
|
||||
bp = Blueprint('customers', __name__, url_prefix='/customers')
|
||||
|
||||
|
||||
def _get_customer_or_redirect(customer_id):
|
||||
"""Load a customer-side account, or return a redirect response.
|
||||
|
||||
Returns (account, None) on success and (None, response) when the id is not
|
||||
a customer-side account. Every route here used to test
|
||||
`customer.role != 'customer'`, which would now reject the Customer
|
||||
Inspectors this page owns — the check is CUSTOMER_ROLES, once, here.
|
||||
"""
|
||||
account = db.session.get(User, customer_id)
|
||||
if account is None:
|
||||
abort(404)
|
||||
if not account.is_customer_account:
|
||||
flash('This page is only for customer accounts.', 'warning')
|
||||
return None, redirect(url_for('customers.index'))
|
||||
return account, None
|
||||
|
||||
|
||||
def _inspector_scope_ids(user, project_facilities_map):
|
||||
"""Facility IDs a Customer Inspector reaches, from its contract rows.
|
||||
|
||||
Mirrors get_inspector_scope() but reuses the caller's already-loaded
|
||||
project → facilities map so the list view stays free of N+1 queries
|
||||
(rule 13).
|
||||
"""
|
||||
ids = set()
|
||||
for a in InspectorAssignment.query.filter_by(user_id=user.id).all():
|
||||
ids.update(project_facilities_map.get(a.project_id, []))
|
||||
return ids
|
||||
|
||||
|
||||
# ── List ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/')
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def index():
|
||||
"""Consolidated customer management dashboard."""
|
||||
"""Consolidated customer management dashboard — both customer roles."""
|
||||
customers = (
|
||||
User.query
|
||||
.filter_by(role='customer')
|
||||
.filter(User.role.in_(User.CUSTOMER_ROLES))
|
||||
.order_by(User.username)
|
||||
.all()
|
||||
)
|
||||
@@ -57,10 +101,27 @@ def index():
|
||||
for a in all_assignments:
|
||||
assignment_map[a.user_id].append(a)
|
||||
|
||||
# ── Bulk query for the inspector-side assignments ─────────────────────
|
||||
# Customer Inspectors are scoped by InspectorAssignment, not
|
||||
# CustomerAssignment — the two roles read different tables for the same
|
||||
# question ("which facilities does this account see?").
|
||||
all_inspector_assignments = (
|
||||
InspectorAssignment.query
|
||||
.filter(InspectorAssignment.user_id.in_(customer_ids))
|
||||
.all()
|
||||
) if customer_ids else []
|
||||
|
||||
inspector_assignment_map = {c.id: [] for c in customers}
|
||||
for a in all_inspector_assignments:
|
||||
inspector_assignment_map[a.user_id].append(a)
|
||||
|
||||
# ── Single bulk query for all active facilities in assigned projects ──
|
||||
# Resolves facility scope for every customer without repeated DB round-trips.
|
||||
from collections import defaultdict
|
||||
assigned_project_ids = {a.project_id for a in all_assignments}
|
||||
assigned_project_ids = (
|
||||
{a.project_id for a in all_assignments}
|
||||
| {a.project_id for a in all_inspector_assignments}
|
||||
)
|
||||
|
||||
project_facilities_map = defaultdict(list) # project_id → [facility_id, ...]
|
||||
if assigned_project_ids:
|
||||
@@ -78,6 +139,12 @@ def index():
|
||||
scope_map = {} # user_id → sorted list[int] facility IDs
|
||||
for customer in customers:
|
||||
ids = set()
|
||||
if customer.is_inspector:
|
||||
# Customer Inspector — contract-level rows only, no facility-level
|
||||
# narrowing exists for inspectors (rule 57: no rows = sees nothing).
|
||||
for a in inspector_assignment_map[customer.id]:
|
||||
ids.update(project_facilities_map.get(a.project_id, []))
|
||||
else:
|
||||
for a in assignment_map[customer.id]:
|
||||
if a.facility_id:
|
||||
ids.add(a.facility_id)
|
||||
@@ -103,6 +170,7 @@ def index():
|
||||
'customers/index.html',
|
||||
customers = customers,
|
||||
assignment_map = assignment_map,
|
||||
inspector_assignment_map = inspector_assignment_map,
|
||||
scope_map = scope_map,
|
||||
projects = projects,
|
||||
expired_invitations = expired_invitations,
|
||||
@@ -115,12 +183,16 @@ def index():
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def create():
|
||||
"""Create a customer account via email invitation.
|
||||
"""Create a customer-side account via email invitation.
|
||||
|
||||
Admin enters Full Name and Email only. A temporary username is
|
||||
auto-generated from the email address. A one-time set-password link
|
||||
is emailed; the customer chooses their own username and password when
|
||||
they click it. The account is activated on completion.
|
||||
Admin enters Full Name, Email and the role (Customer Director or Customer
|
||||
Inspector). A temporary username is auto-generated from the email address.
|
||||
A one-time set-password link is emailed; the invitee chooses their own
|
||||
username and password when they click it. The account is activated on
|
||||
completion.
|
||||
|
||||
Both roles take this identical path — an account that belongs to the
|
||||
customer is never given a password we chose.
|
||||
"""
|
||||
form = CustomerInviteForm()
|
||||
|
||||
@@ -129,6 +201,11 @@ def create():
|
||||
|
||||
full_name = form.full_name.data.strip()
|
||||
email = form.email.data.strip().lower()
|
||||
role = form.role.data
|
||||
# Defence in depth: never let a crafted POST mint a staff role through
|
||||
# the customer invitation form, which sets no password.
|
||||
if role not in User.CUSTOMER_ROLES:
|
||||
role = 'customer'
|
||||
|
||||
# Auto-generate a temporary username from the email local part.
|
||||
# The customer replaces this with their preferred username when
|
||||
@@ -146,7 +223,7 @@ def create():
|
||||
username = username,
|
||||
full_name = full_name,
|
||||
email = email,
|
||||
role = 'customer',
|
||||
role = role,
|
||||
active = True,
|
||||
password_set = False,
|
||||
)
|
||||
@@ -157,15 +234,15 @@ def create():
|
||||
token = user.generate_set_password_token(expires_hours=72)
|
||||
db.session.commit()
|
||||
|
||||
logger.info('CUSTOMERS | invite | admin=%s new_customer=%s email=%s',
|
||||
current_user.username, user.username, user.email)
|
||||
logger.info('CUSTOMERS | invite | admin=%s new_customer=%s role=%s email=%s',
|
||||
current_user.username, user.username, user.role, user.email)
|
||||
log_action(ACTION_CREATE, 'User', user.id, user.username,
|
||||
f'role=customer; email={user.email}; invite_sent=True')
|
||||
f'role={user.role}; email={user.email}; invite_sent=True')
|
||||
|
||||
_send_invite_email(user, token, base_url=request.host_url)
|
||||
|
||||
flash(
|
||||
f'Customer account created for {full_name}. '
|
||||
f'{user.role_label} account created for {full_name}. '
|
||||
f'An invitation email has been sent to {email} with a link to set their username and password.',
|
||||
'success'
|
||||
)
|
||||
@@ -258,12 +335,9 @@ def _send_invite_email(user, token, base_url=None):
|
||||
@supervisor_required
|
||||
def resend_invite(customer_id):
|
||||
"""Generate a fresh token and resend the set-password invitation email."""
|
||||
customer = db.session.get(User, customer_id)
|
||||
if customer is None:
|
||||
abort(404)
|
||||
if customer.role != 'customer':
|
||||
flash('This action is only for customer accounts.', 'warning')
|
||||
return redirect(url_for('customers.index'))
|
||||
customer, moved = _get_customer_or_redirect(customer_id)
|
||||
if moved:
|
||||
return moved
|
||||
|
||||
token = customer.generate_set_password_token(expires_hours=72)
|
||||
customer.password_set = False
|
||||
@@ -320,12 +394,9 @@ def set_password(token):
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def edit(customer_id):
|
||||
customer = db.session.get(User, customer_id)
|
||||
if customer is None:
|
||||
abort(404)
|
||||
if customer.role != 'customer':
|
||||
flash('This page is only for customer accounts.', 'warning')
|
||||
return redirect(url_for('customers.index'))
|
||||
customer, moved = _get_customer_or_redirect(customer_id)
|
||||
if moved:
|
||||
return moved
|
||||
|
||||
form = CustomerUserForm(user=customer, obj=customer)
|
||||
|
||||
@@ -353,16 +424,26 @@ def edit(customer_id):
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def manage(customer_id):
|
||||
"""Single-customer detail page: profile + all assignments."""
|
||||
customer = db.session.get(User, customer_id)
|
||||
if customer is None:
|
||||
abort(404)
|
||||
if customer.role != 'customer':
|
||||
flash('This page is only for customer accounts.', 'warning')
|
||||
return redirect(url_for('customers.index'))
|
||||
"""Single-account detail page: profile + assignments + notification matrix.
|
||||
|
||||
assignments = CustomerAssignment.query.filter_by(user_id=customer_id).all()
|
||||
The assignment editor differs by role. A Customer Director gets the
|
||||
contract/facility assignment list (CustomerAssignment); a Customer
|
||||
Inspector gets the contract checkbox set (InspectorAssignment) that used to
|
||||
live on /auth/users/<id>/assign-contracts.
|
||||
"""
|
||||
customer, moved = _get_customer_or_redirect(customer_id)
|
||||
if moved:
|
||||
return moved
|
||||
|
||||
# Resolve the account's facility scope through the SAME helper the app uses
|
||||
# at request time, so this page can never disagree with what the account
|
||||
# actually sees.
|
||||
if customer.is_inspector:
|
||||
from app.utils.scope import get_inspector_scope
|
||||
facility_ids = get_inspector_scope(customer) or []
|
||||
else:
|
||||
facility_ids = get_customer_scope(customer) or []
|
||||
|
||||
facilities = (
|
||||
Facility.query
|
||||
.filter(Facility.id.in_(facility_ids), Facility.active == True)
|
||||
@@ -370,19 +451,51 @@ def manage(customer_id):
|
||||
.all()
|
||||
) if facility_ids else []
|
||||
|
||||
projects = Project.query.filter_by(active=True).order_by(Project.name).all()
|
||||
|
||||
assignments = []
|
||||
assigned_pids = set()
|
||||
if customer.is_inspector:
|
||||
assigned_pids = {
|
||||
a.project_id
|
||||
for a in InspectorAssignment.query.filter_by(user_id=customer_id).all()
|
||||
}
|
||||
else:
|
||||
assignments = CustomerAssignment.query.filter_by(user_id=customer_id).all()
|
||||
|
||||
# Assignment form (populated here so it can be rendered inline)
|
||||
aform = CustomerAssignmentForm()
|
||||
projects = Project.query.filter_by(active=True).order_by(Project.name).all()
|
||||
aform.user_id.choices = [(customer.id, customer.username)]
|
||||
aform.facility_id.choices = [(0, '— All facilities in contract —')]
|
||||
|
||||
# ── Per-account notification matrix ───────────────────────────────────
|
||||
# For each event: what the global matrix would do for this account's role,
|
||||
# and whether the account overrides it. The template renders a tri-state
|
||||
# (Inherit / On / Off) so "inherit" stays visibly distinct from "explicitly
|
||||
# set to the same value the global happens to have today".
|
||||
from app.models.notification_matrix import MATRIX_EVENTS, is_enabled
|
||||
from app.models.user_notification_matrix import overrides_for_user
|
||||
|
||||
overrides = overrides_for_user(customer.id)
|
||||
matrix_rows = [
|
||||
{
|
||||
'event': event_key,
|
||||
'label': label,
|
||||
'global': is_enabled(event_key, customer.role),
|
||||
'override': overrides.get(event_key), # True / False / None
|
||||
}
|
||||
for event_key, label in MATRIX_EVENTS.items()
|
||||
]
|
||||
|
||||
return render_template(
|
||||
'customers/manage.html',
|
||||
customer = customer,
|
||||
assignments = assignments,
|
||||
assigned_pids = assigned_pids,
|
||||
facilities = facilities,
|
||||
aform = aform,
|
||||
projects = projects,
|
||||
matrix_rows = matrix_rows,
|
||||
)
|
||||
|
||||
|
||||
@@ -392,12 +505,16 @@ def manage(customer_id):
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def add_assignment(customer_id):
|
||||
customer = db.session.get(User, customer_id)
|
||||
if customer is None:
|
||||
abort(404)
|
||||
if customer.role != 'customer':
|
||||
flash('Assignments are only for customer accounts.', 'warning')
|
||||
return redirect(url_for('customers.index'))
|
||||
customer, moved = _get_customer_or_redirect(customer_id)
|
||||
if moved:
|
||||
return moved
|
||||
if customer.is_inspector:
|
||||
# A Customer Inspector is scoped by InspectorAssignment — writing a
|
||||
# CustomerAssignment row for them would grant nothing while looking
|
||||
# like it had.
|
||||
flash('Customer Inspectors are assigned whole contracts — use the '
|
||||
'contract list on this page.', 'warning')
|
||||
return redirect(url_for('customers.manage', customer_id=customer_id))
|
||||
|
||||
project_id = request.form.get('project_id', type=int)
|
||||
facility_id = request.form.get('facility_id', type=int) or None
|
||||
@@ -466,18 +583,233 @@ def remove_assignment(assignment_id):
|
||||
return redirect(url_for('customers.manage', customer_id=customer_id))
|
||||
|
||||
|
||||
# ── Contract assignments for a Customer Inspector ────────────────────────────
|
||||
|
||||
@bp.route('/<int:customer_id>/contracts', methods=['POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def assign_contracts(customer_id):
|
||||
"""Replace a Customer Inspector's whole InspectorAssignment set.
|
||||
|
||||
Same replace-the-entire-set semantics as auth.assign_inspector_contracts
|
||||
(rule 59) — the form posts the complete checked list, rows not in the POST
|
||||
body are deleted. Callers must always send the full desired set, never a
|
||||
diff.
|
||||
"""
|
||||
customer, moved = _get_customer_or_redirect(customer_id)
|
||||
if moved:
|
||||
return moved
|
||||
if not customer.is_inspector:
|
||||
flash('Contract assignment is for Customer Inspector accounts. '
|
||||
'Customer Directors are assigned per contract or facility below.',
|
||||
'warning')
|
||||
return redirect(url_for('customers.manage', customer_id=customer_id))
|
||||
|
||||
from app.utils.time_utils import now_eastern
|
||||
|
||||
selected_ids = set(request.form.getlist('project_ids', type=int))
|
||||
existing = InspectorAssignment.query.filter_by(user_id=customer_id).all()
|
||||
existing_pids = {a.project_id for a in existing}
|
||||
|
||||
for a in existing:
|
||||
if a.project_id not in selected_ids:
|
||||
db.session.delete(a)
|
||||
for pid in selected_ids:
|
||||
if pid not in existing_pids:
|
||||
db.session.add(InspectorAssignment(
|
||||
user_id = customer_id,
|
||||
project_id = pid,
|
||||
created_at = now_eastern(),
|
||||
))
|
||||
|
||||
db.session.commit()
|
||||
|
||||
logger.info('CUSTOMERS | assign_contracts | admin=%s customer=%s projects=%s',
|
||||
current_user.username, customer.username, sorted(selected_ids))
|
||||
log_action(ACTION_UPDATE, 'User', customer.id, customer.username,
|
||||
f'inspector_assignments={sorted(selected_ids)}')
|
||||
|
||||
if not selected_ids:
|
||||
# Rule 57 is strict, and silently is exactly how it bites.
|
||||
flash(f'{customer.display_name} now has no contracts assigned and will '
|
||||
f'see nothing until at least one is granted.', 'warning')
|
||||
else:
|
||||
flash(f'Contract assignments updated for {customer.display_name}.', 'success')
|
||||
return redirect(url_for('customers.manage', customer_id=customer_id))
|
||||
|
||||
|
||||
# ── Per-account notification matrix ──────────────────────────────────────────
|
||||
|
||||
@bp.route('/<int:customer_id>/notifications', methods=['POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def save_notifications(customer_id):
|
||||
"""Save this account's per-event notification overrides.
|
||||
|
||||
Each event posts one of 'inherit' / 'on' / 'off'. 'inherit' DELETES the row
|
||||
rather than storing the global column's current value — so an account that
|
||||
never expressed an opinion keeps following the global matrix when it
|
||||
changes later.
|
||||
"""
|
||||
customer, moved = _get_customer_or_redirect(customer_id)
|
||||
if moved:
|
||||
return moved
|
||||
|
||||
from app.models.notification_matrix import MATRIX_EVENTS
|
||||
from app.models.user_notification_matrix import set_overrides
|
||||
|
||||
tri = {'inherit': None, 'on': True, 'off': False}
|
||||
values = {}
|
||||
for event_key in MATRIX_EVENTS:
|
||||
# Only events this form actually posted; an unknown or missing value
|
||||
# is treated as inherit rather than guessed at.
|
||||
choice = request.form.get(f'event_{event_key}')
|
||||
if choice is not None:
|
||||
values[event_key] = tri.get(choice)
|
||||
|
||||
changed = set_overrides(customer.id, values)
|
||||
db.session.commit()
|
||||
|
||||
logger.info('CUSTOMERS | notif_matrix | admin=%s customer=%s changed=%s',
|
||||
current_user.username, customer.username, changed)
|
||||
log_action(ACTION_UPDATE, 'User', customer.id, customer.username,
|
||||
f'notification overrides updated ({changed} change(s))')
|
||||
|
||||
flash(f'Notification settings saved for {customer.display_name}.'
|
||||
if changed else 'No notification changes to save.',
|
||||
'success' if changed else 'info')
|
||||
return redirect(url_for('customers.manage', customer_id=customer.id))
|
||||
|
||||
|
||||
# ── Switch between the two customer roles ────────────────────────────────────
|
||||
|
||||
@bp.route('/<int:customer_id>/switch-role', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def switch_role(customer_id):
|
||||
"""Flip an account between Customer Director and Customer Inspector.
|
||||
|
||||
The two roles read DIFFERENT scoping tables, so flipping the column alone
|
||||
would leave the account correctly labelled and seeing nothing (rule 57 is
|
||||
strict for inspectors, and a director with no CustomerAssignment rows is
|
||||
equally blind). The contracts are therefore mirrored across: every contract
|
||||
the account could reach before, it can reach after.
|
||||
|
||||
Facility-level narrowing does NOT survive a switch to inspector — there is
|
||||
no per-facility row for inspectors, so a director scoped to one building in
|
||||
a contract becomes an inspector on that whole contract. The confirm dialog
|
||||
says so; the flash repeats it. Switching BACK is lossless though: the
|
||||
original facility-level rows were never deleted, and the reverse mirror
|
||||
skips contracts the account can already reach, so it does not pile a
|
||||
contract-wide grant on top of them.
|
||||
|
||||
API access changes in both directions ('external_inspector' has mobile API
|
||||
access, 'customer' is 403 everywhere), so the account's refresh tokens and
|
||||
device registrations are revoked — an issued JWT would otherwise keep
|
||||
working until it expired, and a signed-in iPad would keep syncing.
|
||||
"""
|
||||
customer, moved = _get_customer_or_redirect(customer_id)
|
||||
if moved:
|
||||
return moved
|
||||
|
||||
from app.utils.time_utils import now_eastern
|
||||
from app.models.api_token import RefreshToken, DeviceToken
|
||||
|
||||
old_role = customer.role
|
||||
new_role = 'external_inspector' if old_role == 'customer' else 'customer'
|
||||
|
||||
widened = False
|
||||
|
||||
if new_role == 'external_inspector':
|
||||
# Director → Inspector: CustomerAssignment (contract or facility) →
|
||||
# InspectorAssignment (contract only).
|
||||
existing_pids = {
|
||||
a.project_id
|
||||
for a in InspectorAssignment.query.filter_by(user_id=customer.id).all()
|
||||
}
|
||||
for a in CustomerAssignment.query.filter_by(user_id=customer.id).all():
|
||||
if a.facility_id:
|
||||
widened = True
|
||||
if a.project_id not in existing_pids:
|
||||
db.session.add(InspectorAssignment(
|
||||
user_id = customer.id,
|
||||
project_id = a.project_id,
|
||||
created_at = now_eastern(),
|
||||
))
|
||||
existing_pids.add(a.project_id)
|
||||
else:
|
||||
# Inspector → Director: contract-level CustomerAssignment rows
|
||||
# (facility_id NULL = all facilities in the contract).
|
||||
#
|
||||
# `existing_pids` counts ANY row for the contract, facility-level ones
|
||||
# included — NOT just the contract-wide ones. That is what makes a
|
||||
# round trip lossless: an account narrowed to one facility, switched to
|
||||
# inspector (which can only hold whole contracts) and switched back
|
||||
# would otherwise gain a contract-wide row on top of its original
|
||||
# facility row and come back with the whole contract. Skipping
|
||||
# contracts the account can already reach as a director leaves the
|
||||
# original narrowing intact, while contracts granted during the
|
||||
# inspector spell still carry over.
|
||||
existing_pids = {
|
||||
a.project_id
|
||||
for a in CustomerAssignment.query.filter_by(user_id=customer.id).all()
|
||||
}
|
||||
for a in InspectorAssignment.query.filter_by(user_id=customer.id).all():
|
||||
if a.project_id not in existing_pids:
|
||||
db.session.add(CustomerAssignment(
|
||||
user_id = customer.id,
|
||||
project_id = a.project_id,
|
||||
facility_id = None,
|
||||
))
|
||||
existing_pids.add(a.project_id)
|
||||
|
||||
# The stale rows for the role being left are kept on purpose: switching
|
||||
# back restores the account's original scope, including any facility-level
|
||||
# narrowing that the inspector side cannot express. They are inert while
|
||||
# the other role is active — each scope helper reads only its own table.
|
||||
|
||||
customer.role = new_role
|
||||
|
||||
revoked = (
|
||||
RefreshToken.query
|
||||
.filter_by(user_id=customer.id, revoked=False)
|
||||
.update({'revoked': True}, synchronize_session=False)
|
||||
)
|
||||
devices = (
|
||||
DeviceToken.query
|
||||
.filter_by(user_id=customer.id)
|
||||
.delete(synchronize_session=False)
|
||||
)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
logger.info('CUSTOMERS | switch_role | admin=%s customer=%s %s -> %s '
|
||||
'tokens_revoked=%s devices_cleared=%s',
|
||||
current_user.username, customer.username, old_role, new_role,
|
||||
revoked, devices)
|
||||
log_action(ACTION_UPDATE, 'User', customer.id, customer.username,
|
||||
f'role switched {old_role} -> {new_role}; '
|
||||
f'refresh_tokens_revoked={revoked}; devices_cleared={devices}')
|
||||
|
||||
msg = (f'{customer.display_name} is now a {customer.role_label}. '
|
||||
f'Their contracts were carried across; any signed-in device must log in again.')
|
||||
if widened:
|
||||
msg += (' Note: facility-level limits do not exist for inspectors, so '
|
||||
'this account now covers every facility in those contracts — '
|
||||
'review the contract list below.')
|
||||
flash(msg, 'warning' if widened else 'success')
|
||||
return redirect(url_for('customers.manage', customer_id=customer.id))
|
||||
|
||||
|
||||
# ── Toggle active ─────────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/<int:customer_id>/toggle-active', methods=['POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def toggle_active(customer_id):
|
||||
customer = db.session.get(User, customer_id)
|
||||
if customer is None:
|
||||
abort(404)
|
||||
if customer.role != 'customer':
|
||||
flash('This action is only for customer accounts.', 'warning')
|
||||
return redirect(url_for('customers.index'))
|
||||
customer, moved = _get_customer_or_redirect(customer_id)
|
||||
if moved:
|
||||
return moved
|
||||
|
||||
customer.active = not customer.active
|
||||
db.session.commit()
|
||||
|
||||
@@ -941,7 +941,7 @@ def flag_issue(inspection_id):
|
||||
|
||||
form.facility_id.choices = [(inspection.facility_id, inspection.facility.name)]
|
||||
form.assigned_to.choices = [(0, '— Unassigned —')] + [
|
||||
(u.id, u.username + (' (External)' if u.is_external_inspector else ''))
|
||||
(u.id, u.username + (' (Customer)' if u.is_external_inspector else ''))
|
||||
for u in staff
|
||||
]
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@ def _assignee_label(user):
|
||||
glance that the work is going outside the company. Display only; the
|
||||
stored value is still the user id.
|
||||
"""
|
||||
return (f'{user.display_name} (External)'
|
||||
return (f'{user.display_name} (Customer)'
|
||||
if user.is_external_inspector else user.display_name)
|
||||
|
||||
|
||||
|
||||
@@ -943,7 +943,7 @@ def export_inspector_performance():
|
||||
# phase49 — external inspectors share this table with our own crew;
|
||||
# suffixed rather than given a column so the index-based styling
|
||||
# below (score = col 5, vs_avg = col 6, …) stays correct.
|
||||
s['display_name'] + (' (External)' if s.get('external') else ''),
|
||||
s['display_name'] + (' (Customer)' if s.get('external') else ''),
|
||||
s['total'],
|
||||
s['completed'],
|
||||
s['completion_rate'],
|
||||
|
||||
@@ -79,19 +79,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# phase51 — an External Inspector is invited by email and chooses
|
||||
their own username and password, so the admin never sets one.
|
||||
The JS at the foot of this page swaps these two blocks when the
|
||||
role changes; the server decides independently of the JS. #}
|
||||
<div id="inviteNotice" class="alert alert-info d-none">
|
||||
<i class="bi bi-envelope me-1"></i>
|
||||
<strong>This account will be invited by email.</strong>
|
||||
External inspectors work outside the business, so we do not set
|
||||
a password for them. On save, an invitation is sent to the email
|
||||
address above with a link to choose their own username and
|
||||
password. The link is valid for 72 hours.
|
||||
</div>
|
||||
|
||||
{# 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") }}
|
||||
@@ -141,26 +132,4 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
var roleSel = document.getElementById('role');
|
||||
var pwBlock = document.getElementById('passwordFields');
|
||||
var notice = document.getElementById('inviteNotice');
|
||||
if (!roleSel || !pwBlock || !notice) return; // director view has no role select
|
||||
|
||||
function sync() {
|
||||
var invited = roleSel.value === 'external_inspector';
|
||||
pwBlock.classList.toggle('d-none', invited);
|
||||
notice.classList.toggle('d-none', !invited);
|
||||
// Clear anything already typed so an invited account can never be created
|
||||
// with an admin-chosen password sitting in the POST body.
|
||||
if (invited) {
|
||||
pwBlock.querySelectorAll('input').forEach(function (i) { i.value = ''; });
|
||||
}
|
||||
}
|
||||
roleSel.addEventListener('change', sync);
|
||||
sync();
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -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,78 @@
|
||||
</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() }}">
|
||||
|
||||
<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>
|
||||
<td class="text-center">
|
||||
<input class="form-check-input" type="radio"
|
||||
name="event_{{ row.event }}" value="inherit"
|
||||
{% if row.override is none %}checked{% endif %}>
|
||||
<div class="text-muted" style="font-size:.62rem;">
|
||||
{{ 'on' if row.global else 'off' }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<input class="form-check-input" type="radio"
|
||||
name="event_{{ row.event }}" value="on"
|
||||
{% if row.override is true %}checked{% endif %}>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<input class="form-check-input" type="radio"
|
||||
name="event_{{ row.event }}" value="off"
|
||||
{% if row.override is false %}checked{% endif %}>
|
||||
</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 +377,31 @@
|
||||
(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); });
|
||||
|
||||
// ── 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;
|
||||
|
||||
@@ -579,7 +579,7 @@
|
||||
<option value="0">— Unassigned —</option>
|
||||
{% set staff = staff_for_flag_issue %}
|
||||
{% if staff %}{% for u in staff %}
|
||||
<option value="{{ u.id }}">{{ u.display_name }}{{ ' (External)' if u.is_external_inspector }}</option>
|
||||
<option value="{{ u.id }}">{{ u.display_name }}{{ ' (Customer)' if u.is_external_inspector }}</option>
|
||||
{% endfor %}{% endif %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -177,7 +177,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 }}{{ ' (External)' if u.is_external_inspector }}</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>
|
||||
|
||||
@@ -236,7 +236,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 }}{{ ' (External)' if u.is_external_inspector }}</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>
|
||||
|
||||
@@ -116,7 +116,7 @@
|
||||
<td class="fw-semibold">
|
||||
{{ s.display_name }}
|
||||
{% if s.external %}
|
||||
<span class="badge bg-dark ms-1" title="Customer / third-party inspector">External</span>
|
||||
<span class="badge bg-dark ms-1" title="Customer / third-party inspector">Customer</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-center">{{ s.total }}</td>
|
||||
@@ -193,7 +193,7 @@
|
||||
<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">External</span>
|
||||
<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')) }}"
|
||||
|
||||
+17
-9
@@ -54,13 +54,13 @@ class UserForm(FlaskForm):
|
||||
('admin', 'Administrator'),
|
||||
('director', 'Director'),
|
||||
('inspector', 'Inspector'),
|
||||
# phase49 — an inspector employed by the customer or a third party.
|
||||
# Same capabilities as 'inspector'; scoped to the contracts assigned on
|
||||
# the Assign Contracts page (see User.INSPECTOR_ROLES).
|
||||
('external_inspector', 'External 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
|
||||
@@ -272,14 +272,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():
|
||||
|
||||
@@ -337,6 +337,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.
|
||||
@@ -357,6 +358,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
|
||||
@@ -394,8 +401,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:
|
||||
@@ -557,11 +576,20 @@ 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',
|
||||
@@ -580,7 +608,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)
|
||||
@@ -607,6 +642,15 @@ def notify_by_matrix(
|
||||
'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
|
||||
@@ -618,6 +662,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
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""phase51 — per-account notification overrides
|
||||
|
||||
Creates `user_notification_matrix`, the per-account layer over the global
|
||||
NotificationMatrix. One row = one account's explicit answer for one event
|
||||
(enabled True/False); NO row means "inherit the global column".
|
||||
|
||||
See app/models/user_notification_matrix.py for the semantics.
|
||||
|
||||
**No backfill, deliberately.** An empty table means every account inherits,
|
||||
which is exactly today's behaviour — so this migration cannot change who gets
|
||||
notified. Overrides are created only when an admin sets one on the account's
|
||||
page in Customer Management. Backfilling from the current global matrix would
|
||||
freeze every account at today's routing and quietly break future changes to the
|
||||
global columns.
|
||||
|
||||
The rest of phase51 (Customer Director / Customer Inspector) is a LABEL-only
|
||||
rename over the existing 'customer' and 'external_inspector' ENUM values, so
|
||||
there is no ENUM change and no user row is touched here.
|
||||
|
||||
Table-existence check — safe to re-run.
|
||||
"""
|
||||
|
||||
revision = 'phase51_user_notif_matrix'
|
||||
down_revision = 'phase50_default_modern'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
def _has_table(conn, name):
|
||||
return conn.execute(sa.text(
|
||||
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES "
|
||||
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :t"
|
||||
), {'t': name}).scalar() > 0
|
||||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
if _has_table(conn, 'user_notification_matrix'):
|
||||
return
|
||||
|
||||
op.create_table(
|
||||
'user_notification_matrix',
|
||||
sa.Column('id', sa.Integer, primary_key=True),
|
||||
sa.Column('user_id', sa.Integer,
|
||||
sa.ForeignKey('users.id', ondelete='CASCADE'),
|
||||
nullable=False, index=True),
|
||||
sa.Column('event_type', sa.String(50), nullable=False),
|
||||
sa.Column('enabled', sa.Boolean, nullable=False,
|
||||
server_default=sa.text('1')),
|
||||
sa.UniqueConstraint('user_id', 'event_type',
|
||||
name='uq_user_notif_matrix_user_event'),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
conn = op.get_bind()
|
||||
if _has_table(conn, 'user_notification_matrix'):
|
||||
# Every row here is an explicit admin decision; dropping the table
|
||||
# discards them and returns all accounts to global-matrix routing.
|
||||
op.drop_table('user_notification_matrix')
|
||||
Reference in New Issue
Block a user