Aug 19 - Update code to catch up with ST
This commit is contained in:
@@ -429,6 +429,36 @@ notification_matrix: id, event_type, role_key, enabled, custom_emails (JSON)
|
||||
UniqueConstraint(event_type, role_key)
|
||||
```
|
||||
|
||||
### UserNotificationMatrix (phase54)
|
||||
|
||||
```
|
||||
user_notification_matrix: id, user_id (FK→users CASCADE, indexed),
|
||||
event_type VARCHAR(50), enabled BOOL
|
||||
UniqueConstraint(user_id, event_type)
|
||||
```
|
||||
|
||||
Per-account override of the global matrix, for the two customer-side roles
|
||||
only. `enabled=True` = send even if the global column is OFF; `enabled=False` =
|
||||
never send even if it is ON; **no row = inherit**. Setting a row back to
|
||||
inherit DELETES it, which is what keeps an account that never expressed an
|
||||
opinion tracking the global matrix. Helpers: `overrides_for_user()`,
|
||||
`override_for()`, `overrides_for_event()`, `set_overrides()` (none commit).
|
||||
See §27b and rules 100–101.
|
||||
|
||||
### TemplateContract (phase55)
|
||||
|
||||
```
|
||||
template_contracts: id, template_id (FK→inspection_templates CASCADE, indexed),
|
||||
project_id (FK→projects CASCADE, indexed), created_at
|
||||
UniqueConstraint(template_id, project_id)
|
||||
```
|
||||
|
||||
Restricts a form to specific contracts. **No rows means the form is SHARED**
|
||||
(available on every contract) — rule 102. `InspectionTemplate` helpers:
|
||||
`contract_ids`, `is_shared`, `available_for_project()`, `set_contracts()` (does
|
||||
not commit), and the static `available_query(project_id)` — the single
|
||||
definition of "which forms may this contract use".
|
||||
|
||||
### InspectionSchedule (phase34)
|
||||
|
||||
```
|
||||
@@ -813,7 +843,7 @@ limiter = Limiter(
|
||||
|
||||
## 17. Alembic Migration Chain
|
||||
|
||||
**Current HEAD:** `phase52_user_ui_theme` (51 migrations total).
|
||||
**Current HEAD:** `phase55_template_contracts`.
|
||||
|
||||
**Chain root:** `0003_add_user_active` — a guarded squashed baseline (MT-2) that recreates the full 25-table schema with INFORMATION_SCHEMA guards. The original baseline migrations (0001/0002/0003) were lost; this file restores the chain root so Alembic can build the revision map. `down_revision = None`.
|
||||
|
||||
@@ -853,7 +883,34 @@ limiter = Limiter(
|
||||
→ phase46_schedule_recurrence → phase47_schedule_end_date
|
||||
→ phase48_schedule_parent_inspection → phase49_followup_requested_by
|
||||
→ phase50_sched_acknowledged → phase51_external_inspector
|
||||
→ phase52_user_ui_theme ← HEAD
|
||||
→ phase52_user_ui_theme → phase53_knowledge_sort_order
|
||||
→ phase54_user_notif_matrix → phase55_template_contracts ← HEAD
|
||||
```
|
||||
|
||||
`phase54` / `phase55` port the ST August-2026 work (ST calls them phase51 /
|
||||
phase52; the ids differ because MT's chain was already past those numbers —
|
||||
match by NAME, not number, when comparing the two repos).
|
||||
|
||||
#### phase54 — per-account notification overrides
|
||||
|
||||
Creates `user_notification_matrix` (§5). **No backfill, deliberately** — an
|
||||
empty table means every account inherits the global matrix, i.e. exactly
|
||||
today's routing, so the migration cannot change who gets notified. Backfilling
|
||||
from the current global columns would freeze every account at today's routing
|
||||
and silently break later changes to those columns. Table-existence check —
|
||||
safe to re-run.
|
||||
|
||||
#### phase55 — restrict forms to specific contracts
|
||||
|
||||
Creates `template_contracts` (§5 `TemplateContract`). **No rows for a template
|
||||
means SHARED**, so every pre-existing form stays available everywhere and the
|
||||
migration cannot change behaviour on deploy. Table-existence check — safe to
|
||||
re-run.
|
||||
|
||||
**Deploy order for both (tenant DBs):**
|
||||
```bash
|
||||
python -m control.tenant_migrate upgrade --tenant all
|
||||
sudo systemctl restart gunicorn
|
||||
```
|
||||
|
||||
`phase41` → `phase52` are the single-tenant feature-parity track — see
|
||||
@@ -1441,6 +1498,15 @@ set -a; . /etc/jqc/control.env; set +a
|
||||
| 95 | **Chat history is loaded from DB — never pass client-sent history to Groq** | phase40. `POST /support/chat/message` loads prior turns from `SupportChatMessage` (newest-first, limit 40, reversed). The JSON body sends only `{ message, session_id }` — no history array. This prevents history tampering by clients and ensures accuracy across page reloads. |
|
||||
| 96 | **`db.session.flush()` to get session ID before first message insert** | When creating a new `SupportChatSession` in `chat_message()`, call `db.session.flush()` after `db.session.add(chat_session)` to get the autoincrement `id` before constructing `SupportChatMessage` rows. If Groq fails, `db.session.rollback()` undoes the flush — no orphaned empty session is left in the DB. |
|
||||
| 97 | **`send_billing_email()` derives the From address from `APP_BASE_URL` — same pattern as rule 64** | `urlparse(app.config['APP_BASE_URL']).netloc` is extracted before the background thread starts and passed as `sender=f'noreply@{netloc}'` to `Message()`. Falls back to `MAIL_DEFAULT_SENDER` when `APP_BASE_URL` is absent or yields an empty netloc (`sender=None` triggers Flask-Mail's default). Do not hardcode a sender string or duplicate the derivation logic — extend via `send_billing_email()` only. |
|
||||
| 99 | **`User.CUSTOMER_ROLES` is for ACCOUNT MANAGEMENT; `role == 'customer'` is for CAPABILITY — never swap them** | Widening a capability check to `CUSTOMER_ROLES` hands a third-party Customer Inspector the customer portal (fails OPEN, nothing errors). Narrowing an account-management check back to `'customer'` strands every Customer Inspector in a page that no longer lists or edits them. `CUSTOMER_ROLES` / `is_customer_account` appear ONLY in: the `/customers` list query and its guards, the `auth.list_users` exclusion, the customer-facing support surface (`_is_customer_side()`), and narrowing uses that WITHHOLD something from an external account (`_assignable_staff_for()`). |
|
||||
| 100 | **A per-account notification opt-IN must survive a globally-OFF column** | `notify_by_matrix()` skips a role column early when the matrix says off. For the two customer columns that `continue` must also ask `any(overrides.values())`, or the override saves, displays as on, and never sends. Equally, `notify_customers_for_facility()` re-queries recipients from assignment rows, so `notify_by_matrix()` must hand it `allowed_user_ids` or the facility-scoped path bypasses every override. Both halves are needed. |
|
||||
| 101 | **Per-account overrides are enforced in `notify()`, not only `notify_by_matrix()`** | Follower fan-out and direct assignee notifications call `notify()` straight. Gating only the matrix left the editor offering rows ("Issue assigned") that read as Off while the notifications kept arriving. Only an explicit `False` suppresses; the `getattr(recipient, 'is_customer_account', False)` test is deliberate so an unavailable attribute SENDS rather than silently dropping. |
|
||||
| 102 | **A template with NO `template_contracts` rows is SHARED, not hidden** | The empty set means "available on every contract" — that is what makes phase55 additive and why it needed no backfill. Reading it the other way hides every pre-existing form from every contract at once. The convention lives in exactly one place, `InspectionTemplate.available_query()`. A facility with no contract gets shared forms only (fail-closed). |
|
||||
| 103 | **A bulk-action form must live OUTSIDE the table; row checkboxes join it via the HTML5 `form=` attribute** | Wrapping the table nests the per-row delete/unfollow forms inside the bulk form, and browsers silently discard nested forms (rule 9) — the row buttons post nothing, with no console error and no server log. Applies to all four list templates (classic + modern). |
|
||||
| 104 | **Bulk deletes: DB rows first, storage files second** | Collect the keys, `db.session.delete()` every row, `commit()`, and only then `storage.delete()`. `_collect_inspection_photos()` is shared by the single and bulk inspection delete paths so the two cannot drift — a key missed there is an invisible permanent storage leak. |
|
||||
| 105 | **The flag-issue assignee list is contract-scoped, and BOTH call sites must use `_assignable_staff_for()`; a failed flag-issue POST must return non-2xx** | `execute()` renders the dropdown, `flag_issue()` builds the choices that validate the POST — the choices are the security boundary. An org-wide list let anyone assign another client's Customer Inspector, who was then emailed the facility name and issue description. And the offcanvas JS branches on `res.ok`, so a 200 re-render of an invalid form reads as success: the panel closes, the page reloads, and no issue exists. |
|
||||
| 106 | **Name the Groq model in the chat error log, and keep `_DEFAULT_GROQ_MODEL` current** | Groq retires models without notice; when the configured one disappears the API 404s and EVERY question returns the generic "problem reaching the AI assistant" reply, with nothing else broken — invisible until a customer complains. The fix needs no deploy, only `GROQ_MODEL`, which is exactly what the log line must say. |
|
||||
| 107 | **`viewer_is_our_staff` in `issues/view.html` is an explicit role ALLOWLIST, and `external_inspector` is absent on purpose** | `not current_user.is_customer_account` fails OPEN — a missing attribute yields Jinja `Undefined`, `not Undefined` is true, and the internal-process chrome renders for exactly the accounts it must be hidden from. This is not a rule-87 violation: rule 87 governs capability/scoping, where a Customer Inspector must behave like our inspector; this asks "does this person work for us?", the one place the two genuinely differ. |
|
||||
| 98 | **Reports R1 + R2 contract cascade is client-side only — facility_id is the sole DB filter** | The Contract dropdown in `reports/issues_aging.html` and `reports/sla_compliance.html` has no `name` attribute and is never submitted. It exists only to narrow the Facility `<select>` in the browser via `GET /inspections/facilities_for_project/<id>`. The routes receive and filter on `facility_id`; `contract_id` plays no role server-side. Do not add server-side `contract_id` filtering to these routes — it would duplicate what `facility_id` already provides. |
|
||||
|
||||
---
|
||||
@@ -1807,6 +1873,101 @@ plus an emailed 72-hour token, with `auth.resend_invite` for bounced invitations
|
||||
|
||||
---
|
||||
|
||||
## 27b. Customer-side roles, per-account notifications, per-contract forms (Aug 2026 ST parity)
|
||||
|
||||
Ported from the single-tenant app (its phase51 + phase52 + the August fixes).
|
||||
Nothing here is tenant-aware in its own right — every table lives in the tenant
|
||||
DB and every query routes through `RoutingSession` as usual.
|
||||
|
||||
### The two customer-side roles
|
||||
|
||||
| Stored ENUM value | Display label | Scoped by | Capabilities |
|
||||
|---|---|---|---|
|
||||
| `customer` | **Customer Director** | `CustomerAssignment` | The portal, unchanged — plus planning scheduled inspections at their own facilities |
|
||||
| `external_inspector` | **Customer Inspector** | `InspectorAssignment` | Identical to the internal `inspector`, plus the customer support surface (AI chat + tickets) |
|
||||
|
||||
**LABEL-only rename** — the ENUM values are untouched, so no migration and no
|
||||
role check moved. `User.ROLE_LABELS` is the one place the names live.
|
||||
|
||||
`User.CUSTOMER_ROLES = ('customer', 'external_inspector')` and
|
||||
`User.is_customer_account` answer an **account-management** question ("is this
|
||||
managed under `/customers`?"). Every **capability** check — portal gates,
|
||||
`@customer_required`, `get_customer_scope()`, `notify_customers_for_facility()`,
|
||||
the customer branch of each `app/api/*` module — keeps testing
|
||||
`role == 'customer'` exactly (rule 99).
|
||||
|
||||
Both roles are now created, invited, assigned and switched in **Customer
|
||||
Management** (`/customers`); `auth.list_users` excludes them and the
|
||||
`/auth/users/...` URLs redirect to `customers.manage`. `UserForm` no longer
|
||||
offers `external_inspector`, and `auth.create_user` always requires a password
|
||||
— customer-side accounts are invited (they choose their own username and
|
||||
password) via `customers.create()`. `POST /customers/<id>/switch-role`
|
||||
(admin only) mirrors contracts across the two scoping tables and revokes the
|
||||
account's refresh tokens + device rows, because API access differs between them.
|
||||
|
||||
### `UserNotificationMatrix` (per-account overrides)
|
||||
|
||||
Row `enabled=True` = send even if the global column is OFF; `enabled=False` =
|
||||
never send even if it is ON; **no row = inherit**. Inherit is the default, so
|
||||
the table shipped empty and changed routing for nobody, and setting a row back
|
||||
to inherit DELETES it. Helpers live in
|
||||
`app/models/user_notification_matrix.py`; edited on the account's Customer
|
||||
Management page as a tri-state. Enforced in **`notify()` as well as**
|
||||
`notify_by_matrix()` — follower fan-out and direct assignee notifications reach
|
||||
`notify()` straight, so gating only the matrix left rows that read as Off while
|
||||
notifications kept arriving. See rules 100 and 101.
|
||||
|
||||
### `TemplateContract` (forms per contract)
|
||||
|
||||
`InspectionTemplate.available_query(project_id)` is the single definition of
|
||||
"which forms may this contract use" — pickers, the POST validation behind them,
|
||||
the schedule form and `GET /api/v1/templates` all call it. **No rows = shared**
|
||||
(rule 102). Managed in three places: the template list's Edit modal
|
||||
(`POST /templates/<id>/rename`, carrying a hidden `contracts_present=1`
|
||||
marker), Create Template, and the full form editor. `duplicate_template()`
|
||||
copies the restrictions.
|
||||
|
||||
### Other ported behaviour
|
||||
|
||||
* **Bulk actions** on the issues and inspections lists (`POST /issues/bulk`,
|
||||
`POST /inspections/bulk`) with shared partials in `templates/partials/`.
|
||||
Toolbar form sits OUTSIDE the table; row checkboxes join it with the HTML5
|
||||
`form=` attribute (rule 103). Deletes remove DB rows first, storage keys
|
||||
second (rule 104).
|
||||
* **List filter preservation** — `current_url()` (Jinja global) +
|
||||
`return_url(fallback)` (`utils/decorators`) round-trip the full list URL as
|
||||
`next`, so an edit or delete returns to the filtered page.
|
||||
`safe_redirect_url` still guards every hop.
|
||||
* **Flag-issue assignee scoping** — `_assignable_staff_for(inspection, actor)`
|
||||
in `routes/inspections.py` is the single source for both the offcanvas
|
||||
dropdown and `form.assigned_to.choices` (the actual POST validation). A
|
||||
failed flag-issue POST now returns **400**, because the offcanvas JS branches
|
||||
on `res.ok` (rule 105).
|
||||
* **Customer Directors plan inspections** — `schedule_manager_required` in
|
||||
`routes/inspection_schedules.py` = the manager set plus `role == 'customer'`.
|
||||
Because that blueprint builds its form by hand (no WTForms SelectField),
|
||||
narrowing the choice lists is NOT the validation: `_scope_errors()`
|
||||
re-checks facility, inspector and form-vs-contract on every POST, and
|
||||
`_schedule_in_scope()` guards edit/delete. Start remains the assignee's.
|
||||
* **Support chat serves both customer roles** — `_is_customer_side()` opens the
|
||||
door, then `_support_facilities()` branches per role (CustomerAssignment vs
|
||||
InspectorAssignment) and `_system_prompt_for()` appends
|
||||
`_INSPECTOR_ADDENDUM` for a Customer Inspector. The curated knowledge base is
|
||||
now spliced in **before** the `Rules:` heading (`_STYLE_MARKER`) — appended
|
||||
after it, the prompt's own "ground answers in everything above" put it out of
|
||||
scope, which is why KB entries looked ignored. `/support/admin/knowledge/preview`
|
||||
shows the exact prompt. `GROQ_MODEL` defaults to `_DEFAULT_GROQ_MODEL`
|
||||
(`openai/gpt-oss-120b`); Groq retires models without notice, and the error
|
||||
handler names the model and says to set `GROQ_MODEL` (rule 106).
|
||||
* **`COMMENTS_VISIBLE_TO_ALL`** (config, default true) lifts the phase22 read
|
||||
filter so customers see every comment. `is_customer_visible` is still
|
||||
written, so flipping it back restores the old behaviour with nothing to
|
||||
repair. `issues/view.html` gates the internal chrome on
|
||||
`viewer_is_our_staff` — an explicit allowlist of OUR roles, which fails
|
||||
closed and deliberately excludes `external_inspector` (rule 107).
|
||||
|
||||
---
|
||||
|
||||
## 28. Coding Rules for AI Assistants
|
||||
|
||||
These rules apply to every change made to this codebase, without exception.
|
||||
|
||||
@@ -160,6 +160,15 @@ def create_app(config_name='default'):
|
||||
from app.utils import storage as _storage
|
||||
app.jinja_env.globals['media_url'] = _storage.media_url
|
||||
|
||||
# Current page URL including its query string — what list pages hand to
|
||||
# their actions as `next` so filters survive an edit/delete round trip
|
||||
# (see utils/decorators.return_url). full_path always appends '?', which
|
||||
# is harmless but makes for ugly links, so strip a bare trailing one.
|
||||
def _current_url():
|
||||
from flask import request
|
||||
return request.full_path.rstrip('?') if request else ''
|
||||
app.jinja_env.globals['current_url'] = _current_url
|
||||
|
||||
# ── Web portal design wiring (MT-16) ──────────────────────────────────
|
||||
# Index the modern/ override templates once at boot, so get_template()
|
||||
# never has to touch the filesystem per request.
|
||||
|
||||
+88
-9
@@ -16,11 +16,13 @@ GET /api/v1/templates/<template_id>
|
||||
|
||||
import logging
|
||||
|
||||
from flask import Blueprint, g
|
||||
from flask import Blueprint, g, request
|
||||
from app import db
|
||||
from app.models.inspection import InspectionTemplate
|
||||
from app.models.facility import Facility
|
||||
from app.api.errors import api_ok, api_error
|
||||
from app.api.decorators import jwt_required
|
||||
from app.utils.scope import get_inspector_scope
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -31,6 +33,71 @@ _ALLOWED_ROLES = {'admin', 'director', 'inspector', 'external_inspector',
|
||||
'project_manager', 'auditor'}
|
||||
|
||||
|
||||
def _visible_project_ids(user):
|
||||
"""Contract ids whose forms this user may see, or None for "no limit".
|
||||
|
||||
An inspector (ours or a customer's) is limited to the contracts they are
|
||||
assigned; every other allowed role sees all. Derived from the facilities
|
||||
get_inspector_scope() returns, so the API can never disagree with the web.
|
||||
"""
|
||||
if not user.is_inspector:
|
||||
return None
|
||||
fids = get_inspector_scope(user) or []
|
||||
if not fids:
|
||||
return []
|
||||
return sorted({
|
||||
f.project_id
|
||||
for f in Facility.query.filter(Facility.id.in_(fids)).all()
|
||||
if f.project_id
|
||||
})
|
||||
|
||||
|
||||
def _visible_templates(user, project_id=None):
|
||||
"""Forms this user may use, optionally narrowed to one contract.
|
||||
|
||||
phase52 — a form attached to specific contracts must not reach an
|
||||
inspector working for a different customer. Shared forms (no contract
|
||||
links) stay visible to everyone, which is what keeps existing installs
|
||||
behaving exactly as before.
|
||||
|
||||
With `project_id`: exactly the web picker's list for that contract.
|
||||
Without: the union across every contract the user can reach — the iPad
|
||||
caches templates up-front and picks the facility later, so it needs the
|
||||
whole set it might legitimately use.
|
||||
"""
|
||||
pids = _visible_project_ids(user)
|
||||
|
||||
if project_id is not None:
|
||||
# The caller names a contract. Their OWN scope still applies — without
|
||||
# this, passing another customer's facility_id would list that
|
||||
# customer's form names back to an inspector who has no business
|
||||
# seeing them. Empty list, not an error: the endpoint must not confirm
|
||||
# whether that contract exists either.
|
||||
if pids is not None and project_id not in pids:
|
||||
logger.warning('API TEMPLATES | out-of-scope project filter | '
|
||||
'user=%s | project_id=%s', user.username, project_id)
|
||||
return []
|
||||
return InspectionTemplate.available_query(project_id).all()
|
||||
|
||||
if pids is None:
|
||||
return (InspectionTemplate.query
|
||||
.filter_by(active=True)
|
||||
.order_by(InspectionTemplate.name)
|
||||
.all())
|
||||
|
||||
seen, out = set(), []
|
||||
# Always include the shared forms, even when the user has no contracts —
|
||||
# otherwise an unassigned inspector would see nothing at all rather than
|
||||
# the standard forms.
|
||||
for pid in list(pids) + [None]:
|
||||
for t in InspectionTemplate.available_query(pid).all():
|
||||
if t.id not in seen:
|
||||
seen.add(t.id)
|
||||
out.append(t)
|
||||
out.sort(key=lambda t: (t.name or '').lower())
|
||||
return out
|
||||
|
||||
|
||||
def _template_summary_payload(template: InspectionTemplate) -> dict:
|
||||
"""Serialize a template to the lightweight summary dict (no form_schema)."""
|
||||
return {
|
||||
@@ -88,17 +155,21 @@ def list_templates():
|
||||
user.username, user.role)
|
||||
return api_error('Access denied', 403)
|
||||
|
||||
templates = (
|
||||
InspectionTemplate.query
|
||||
.filter_by(active=True)
|
||||
.order_by(InspectionTemplate.name)
|
||||
.all()
|
||||
)
|
||||
# Optional ?project_id= narrows to one contract (matches the web picker);
|
||||
# ?facility_id= is accepted as a convenience and resolved to its contract.
|
||||
project_id = request.args.get('project_id', type=int)
|
||||
if project_id is None:
|
||||
facility_id = request.args.get('facility_id', type=int)
|
||||
if facility_id is not None:
|
||||
facility = db.session.get(Facility, facility_id)
|
||||
project_id = facility.project_id if facility else None
|
||||
|
||||
templates = _visible_templates(user, project_id)
|
||||
|
||||
payload = [_template_summary_payload(t) for t in templates]
|
||||
|
||||
logger.info('API TEMPLATES | list | user=%s | count=%d',
|
||||
user.username, len(payload))
|
||||
logger.info('API TEMPLATES | list | user=%s | project_id=%s | count=%d',
|
||||
user.username, project_id, len(payload))
|
||||
|
||||
return api_ok({'templates': payload, 'count': len(payload)})
|
||||
|
||||
@@ -144,6 +215,14 @@ def get_template(template_id):
|
||||
if template is None:
|
||||
return api_error('Template not found', 404)
|
||||
|
||||
# phase52 — a restricted form must not be fetchable by an inspector on a
|
||||
# different customer's contracts. 404 rather than 403: whether another
|
||||
# customer's form exists is itself not this user's business.
|
||||
if template.id not in {t.id for t in _visible_templates(user)}:
|
||||
logger.warning('API TEMPLATES | out-of-contract fetch blocked | '
|
||||
'user=%s | template_id=%s', user.username, template_id)
|
||||
return api_error('Template not found', 404)
|
||||
|
||||
logger.info('API TEMPLATES | detail | user=%s | template_id=%d | name=%s',
|
||||
user.username, template_id, template.name)
|
||||
|
||||
|
||||
@@ -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'),
|
||||
('director', 'Director'),
|
||||
('inspector', 'Inspector'),
|
||||
]
|
||||
|
||||
ROLE_LABELS = dict(ROLES)
|
||||
ROLE_KEYS = [k for k, _ in ROLES]
|
||||
|
||||
#: App role each enrolled seat becomes when an admin actually creates the
|
||||
#: account in Customer Management. A plain string map on purpose — the
|
||||
#: enrollment package must not import app.models (see __init__.py, rule 88).
|
||||
APP_ROLE_FOR = {
|
||||
'director': 'customer', # "Customer Director"
|
||||
'inspector': 'external_inspector', # "Customer Inspector"
|
||||
}
|
||||
|
||||
#: Roles that act on the administrative side of the printed form (the
|
||||
#: "Admin / Director" column). Everything else is an inspector seat. Drives
|
||||
#: both the recommendation preset and eligibility for admin-only tasks.
|
||||
ADMIN_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
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
from app.models.user import User
|
||||
from app.models.facility import Facility, Area
|
||||
from app.models.inspection import (InspectionTemplate, ChecklistItem,
|
||||
Inspection, InspectionResult)
|
||||
Inspection, InspectionResult,
|
||||
TemplateContract)
|
||||
from app.models.issue import Issue
|
||||
from app.models.project import Project, CustomerAssignment
|
||||
from app.models.project_recipient import ProjectNotificationRecipient
|
||||
from app.models.api_token import RefreshToken, DeviceToken
|
||||
from app.models.notification_matrix import NotificationMatrix
|
||||
from app.models.user_notification_matrix import UserNotificationMatrix
|
||||
from app.models.inspection_schedule import InspectionSchedule
|
||||
from app.models.work_order import IssueWorkOrder
|
||||
@@ -3,6 +3,43 @@ from app.utils.time_utils import now_eastern
|
||||
import json
|
||||
|
||||
|
||||
class TemplateContract(db.Model):
|
||||
"""Restricts a form to specific contracts (phase52).
|
||||
|
||||
A customer's bespoke form must not be visible to — or startable against —
|
||||
another customer's facilities. One row = "this template is available on
|
||||
this contract".
|
||||
|
||||
**No rows means the template is SHARED** (available on every contract), not
|
||||
"available nowhere". That is what makes the feature additive: every
|
||||
template that existed before phase52 has no rows, so nothing changed on
|
||||
deploy, and a form becomes customer-specific only when an admin attaches it
|
||||
to at least one contract. The empty-set-means-all convention is the whole
|
||||
migration story — do not "fix" it to mean the opposite.
|
||||
"""
|
||||
|
||||
__tablename__ = 'template_contracts'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
template_id = db.Column(db.Integer,
|
||||
db.ForeignKey('inspection_templates.id', ondelete='CASCADE'),
|
||||
nullable=False, index=True)
|
||||
project_id = db.Column(db.Integer,
|
||||
db.ForeignKey('projects.id', ondelete='CASCADE'),
|
||||
nullable=False, index=True)
|
||||
created_at = db.Column(db.DateTime, default=now_eastern, nullable=False)
|
||||
|
||||
project = db.relationship('Project', backref='template_contracts')
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('template_id', 'project_id',
|
||||
name='uq_template_contract'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<TemplateContract template={self.template_id} project={self.project_id}>'
|
||||
|
||||
|
||||
class InspectionTemplate(db.Model):
|
||||
__tablename__ = 'inspection_templates'
|
||||
|
||||
@@ -18,6 +55,82 @@ class InspectionTemplate(db.Model):
|
||||
checklist_items = db.relationship('ChecklistItem', backref='template', lazy='dynamic', cascade='all, delete-orphan')
|
||||
inspections = db.relationship('Inspection', backref='template', lazy='dynamic')
|
||||
|
||||
# phase52 — contract restrictions. Deleting a template removes its links.
|
||||
contract_links = db.relationship('TemplateContract', backref='template',
|
||||
lazy='dynamic',
|
||||
cascade='all, delete-orphan')
|
||||
|
||||
# ── Contract availability (phase52) ──────────────────────────────────
|
||||
|
||||
@property
|
||||
def contract_ids(self):
|
||||
"""Project ids this form is restricted to; empty = shared with all."""
|
||||
return sorted(l.project_id for l in self.contract_links.all())
|
||||
|
||||
@property
|
||||
def is_shared(self):
|
||||
"""True when the form carries no restriction and is available anywhere."""
|
||||
return self.contract_links.count() == 0
|
||||
|
||||
def available_for_project(self, project_id):
|
||||
"""Can this form be used on `project_id`?
|
||||
|
||||
Shared forms are usable anywhere, including on a facility that has no
|
||||
contract at all. A restricted form needs an explicit link, so a
|
||||
facility with no contract (project_id None) can only ever use shared
|
||||
forms — fail-closed, which is the right side to err on.
|
||||
"""
|
||||
if self.is_shared:
|
||||
return True
|
||||
if project_id is None:
|
||||
return False
|
||||
return project_id in set(self.contract_ids)
|
||||
|
||||
@staticmethod
|
||||
def available_query(project_id, active_only=True):
|
||||
"""Query of templates usable on `project_id` (shared + linked).
|
||||
|
||||
The single definition of "which forms may this contract use". Every
|
||||
picker, the POST validation behind it, and the mobile API all go
|
||||
through here so they cannot disagree — a picker that offers more than
|
||||
the validator accepts silently drops work (see rule 93 for the same
|
||||
failure in the assignee dropdown).
|
||||
"""
|
||||
q = InspectionTemplate.query
|
||||
if active_only:
|
||||
q = q.filter(InspectionTemplate.active == True)
|
||||
|
||||
shared = ~InspectionTemplate.contract_links.any()
|
||||
if project_id is None:
|
||||
# No contract to match against — only unrestricted forms apply.
|
||||
return q.filter(shared).order_by(InspectionTemplate.name)
|
||||
|
||||
linked = InspectionTemplate.contract_links.any(
|
||||
TemplateContract.project_id == project_id
|
||||
)
|
||||
return q.filter(db.or_(shared, linked)).order_by(InspectionTemplate.name)
|
||||
|
||||
def set_contracts(self, project_ids):
|
||||
"""Replace this form's contract restrictions.
|
||||
|
||||
Pass an empty list to make the form shared again. Does NOT commit —
|
||||
the caller owns the transaction. Returns True if anything changed.
|
||||
"""
|
||||
wanted = {int(p) for p in project_ids or []}
|
||||
existing = {l.project_id: l for l in self.contract_links.all()}
|
||||
|
||||
changed = False
|
||||
for pid, link in existing.items():
|
||||
if pid not in wanted:
|
||||
db.session.delete(link)
|
||||
changed = True
|
||||
for pid in wanted:
|
||||
if pid not in existing:
|
||||
db.session.add(TemplateContract(template_id=self.id, project_id=pid))
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
|
||||
def get_form_schema(self):
|
||||
if self.form_schema is None:
|
||||
return []
|
||||
|
||||
@@ -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)
|
||||
@@ -117,9 +143,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 MT-15
|
||||
name so the existing call sites stay put (the rename is a label,
|
||||
never an identifier).
|
||||
"""
|
||||
return self.role == 'external_inspector'
|
||||
|
||||
@property
|
||||
def is_customer_account(self):
|
||||
"""True for BOTH customer-side roles — an account-management question.
|
||||
|
||||
Answers "is this account managed under /customers?", NOT "does this
|
||||
account get the customer portal". For the latter keep testing
|
||||
`role == 'customer'`. See the CUSTOMER_ROLES note above.
|
||||
"""
|
||||
return self.role in self.CUSTOMER_ROLES
|
||||
|
||||
@property
|
||||
def is_customer_director(self):
|
||||
"""True for the portal-side customer role ('customer')."""
|
||||
return self.role == 'customer'
|
||||
|
||||
@property
|
||||
def role_label(self):
|
||||
"""Human-readable role name, used in staff-facing lists."""
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
"""
|
||||
app/models/user_notification_matrix.py
|
||||
--------------------------------------
|
||||
Per-account notification overrides (phase51).
|
||||
|
||||
The global NotificationMatrix routes an event to whole ROLES: "every Customer
|
||||
Director hears about issue_created". That is the wrong grain for customers —
|
||||
each customer organisation states on its enrollment form which notifications
|
||||
each of its people wants, and two directors on two contracts rarely want the
|
||||
same set.
|
||||
|
||||
This table is the per-account layer on top. One row = one account's explicit
|
||||
answer for one event:
|
||||
|
||||
enabled=True send it to this account even if the global column is OFF
|
||||
enabled=False do not send it to this account even if the global column is ON
|
||||
NO ROW inherit — whatever the global matrix column says
|
||||
|
||||
Inheritance is the default and the safe state: an account with no rows behaves
|
||||
exactly as it did before this table existed, so the feature ships without
|
||||
changing routing for anyone. Setting a row back to "inherit" DELETES it rather
|
||||
than storing a copy of the current global value, so a later change to the
|
||||
global matrix still reaches accounts that never expressed an opinion.
|
||||
|
||||
Scope: consulted for the two customer-side roles only (User.CUSTOMER_ROLES).
|
||||
Staff roles keep using the global matrix alone — an admin who wants fewer
|
||||
emails uses NotificationPreference, which is a different question (how to
|
||||
deliver, not whether to route).
|
||||
"""
|
||||
|
||||
from app import db
|
||||
|
||||
|
||||
class UserNotificationMatrix(db.Model):
|
||||
"""One account's override of the global matrix for one event."""
|
||||
|
||||
__tablename__ = 'user_notification_matrix'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer,
|
||||
db.ForeignKey('users.id', ondelete='CASCADE'),
|
||||
nullable=False, index=True)
|
||||
event_type = db.Column(db.String(50), nullable=False)
|
||||
enabled = db.Column(db.Boolean, nullable=False, default=True)
|
||||
|
||||
user = db.relationship('User', foreign_keys=[user_id],
|
||||
backref=db.backref('notification_overrides',
|
||||
lazy='dynamic',
|
||||
cascade='all, delete-orphan'))
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('user_id', 'event_type',
|
||||
name='uq_user_notif_matrix_user_event'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return (f'<UserNotificationMatrix user={self.user_id} '
|
||||
f'event={self.event_type} enabled={self.enabled}>')
|
||||
|
||||
|
||||
def overrides_for_user(user_id) -> dict:
|
||||
"""Return {event_type: bool} — every override this account has set."""
|
||||
return {
|
||||
row.event_type: row.enabled
|
||||
for row in UserNotificationMatrix.query.filter_by(user_id=user_id).all()
|
||||
}
|
||||
|
||||
|
||||
def override_for(user_id, event_type):
|
||||
"""One account's answer for one event: True, False, or None (inherit).
|
||||
|
||||
Used by notify() to enforce the override on EVERY delivery path, not just
|
||||
matrix broadcasts. Best-effort: any failure returns None (inherit), so a
|
||||
lookup problem can never silently swallow a notification.
|
||||
"""
|
||||
import logging
|
||||
if not user_id or not event_type:
|
||||
return None
|
||||
try:
|
||||
row = UserNotificationMatrix.query.filter_by(
|
||||
user_id=user_id, event_type=event_type).first()
|
||||
return row.enabled if row is not None else None
|
||||
except Exception as exc:
|
||||
logging.getLogger(__name__).error(
|
||||
'USER MATRIX | single override lookup failed | user=%s event=%s | %s',
|
||||
user_id, event_type, exc,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def overrides_for_event(event_type) -> dict:
|
||||
"""Return {user_id: bool} — every account's override for one event.
|
||||
|
||||
One query per dispatch rather than one per candidate recipient. The table
|
||||
holds only explicitly-set rows (inherit deletes), so it stays small.
|
||||
Best-effort: a failure here must never take down a notification dispatch,
|
||||
so callers get an empty dict (= everyone inherits) if the query fails.
|
||||
"""
|
||||
import logging
|
||||
try:
|
||||
return {
|
||||
row.user_id: row.enabled
|
||||
for row in UserNotificationMatrix.query.filter_by(
|
||||
event_type=event_type).all()
|
||||
}
|
||||
except Exception as exc:
|
||||
logging.getLogger(__name__).error(
|
||||
'USER MATRIX | override lookup failed | event=%s | error=%s',
|
||||
event_type, exc,
|
||||
)
|
||||
return {}
|
||||
|
||||
|
||||
def set_overrides(user_id, values: dict):
|
||||
"""Replace an account's overrides.
|
||||
|
||||
`values` maps event_type -> True / False / None, where None means inherit
|
||||
(the row is deleted). Events absent from `values` are left untouched, so a
|
||||
caller can update one event without resending the whole matrix.
|
||||
|
||||
Does NOT commit — the caller owns the transaction (same contract as
|
||||
notify()). Returns the number of rows added, updated or deleted.
|
||||
"""
|
||||
existing = {
|
||||
row.event_type: row
|
||||
for row in UserNotificationMatrix.query.filter_by(user_id=user_id).all()
|
||||
}
|
||||
changed = 0
|
||||
|
||||
for event_type, wanted in values.items():
|
||||
row = existing.get(event_type)
|
||||
if wanted is None:
|
||||
if row is not None:
|
||||
db.session.delete(row)
|
||||
changed += 1
|
||||
continue
|
||||
wanted = bool(wanted)
|
||||
if row is None:
|
||||
db.session.add(UserNotificationMatrix(
|
||||
user_id=user_id, event_type=event_type, enabled=wanted))
|
||||
changed += 1
|
||||
elif row.enabled != wanted:
|
||||
row.enabled = wanted
|
||||
changed += 1
|
||||
|
||||
return changed
|
||||
+50
-41
@@ -391,10 +391,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()
|
||||
)
|
||||
@@ -418,6 +421,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
|
||||
@@ -432,20 +454,17 @@ def create_user():
|
||||
if form.validate_on_submit():
|
||||
role = 'inspector' if director_editing else form.role.data
|
||||
|
||||
# MT-15 — 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 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)
|
||||
@@ -455,40 +474,19 @@ def create_user():
|
||||
full_name=form.full_name.data.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.
|
||||
import secrets
|
||||
user.set_password(secrets.token_hex(32))
|
||||
else:
|
||||
user.set_password(form.password.data)
|
||||
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'password.', 'success')
|
||||
else:
|
||||
flash(f'User {user.username} created successfully.', 'success')
|
||||
flash(f'User {user.username} created successfully.', 'success')
|
||||
return redirect(url_for('auth.list_users'))
|
||||
|
||||
return render_template('auth/user_form.html', form=form, title='Create User',
|
||||
@@ -502,6 +500,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.
|
||||
@@ -546,6 +547,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')
|
||||
@@ -576,6 +580,11 @@ def assign_inspector_contracts(user_id):
|
||||
# rows, so this page must accept them too.
|
||||
if user is None or not user.is_inspector:
|
||||
abort(404)
|
||||
# A Customer Inspector is scoped by exactly these rows, but the page that
|
||||
# owns them is now customers.manage — one editor per account, not two.
|
||||
moved = _redirect_if_customer_account(user)
|
||||
if moved:
|
||||
return moved
|
||||
|
||||
from app.models.project import Project
|
||||
from app.models.inspector_assignment import InspectorAssignment
|
||||
|
||||
@@ -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',
|
||||
|
||||
+401
-69
@@ -3,13 +3,26 @@ app/routes/customers.py
|
||||
-----------------------
|
||||
Customer Management — admin-only consolidated view.
|
||||
|
||||
Owns BOTH customer-side roles (phase51 — see User.CUSTOMER_ROLES):
|
||||
|
||||
Customer Director role='customer' portal access, read-mostly,
|
||||
scoped by CustomerAssignment
|
||||
Customer Inspector role='external_inspector' full inspector capabilities,
|
||||
scoped by InspectorAssignment
|
||||
|
||||
They are two seats of the same customer organisation, so they are listed,
|
||||
invited, edited, assigned, switched and disabled here rather than in User
|
||||
Management — which now excludes both.
|
||||
|
||||
Provides a single screen to:
|
||||
- List all customer-role users with their assignment summary
|
||||
- Create a new customer account
|
||||
- Edit an existing customer (username / email / password / active)
|
||||
- Manage assignments for a customer (add / remove)
|
||||
- Quick-disable / enable a customer account
|
||||
- View a customer's scoped facility access at a glance
|
||||
- List all customer-side users with their assignment summary
|
||||
- Invite a new customer account in either role
|
||||
- Edit an existing account (username / email / password / active)
|
||||
- Manage assignments (contracts/facilities for a director, contracts for an
|
||||
inspector)
|
||||
- Switch an account between the two roles
|
||||
- Quick-disable / enable an account
|
||||
- View the account's scoped facility access at a glance
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -18,6 +31,7 @@ from flask_login import login_required, current_user
|
||||
from app import db
|
||||
from app.models.user import User
|
||||
from app.models.project import Project, CustomerAssignment
|
||||
from app.models.inspector_assignment import InspectorAssignment
|
||||
from app.models.facility import Facility
|
||||
from app.utils.forms import CustomerUserForm, CustomerAssignmentForm, CustomerInviteForm, SetPasswordForm
|
||||
from app.utils.decorators import admin_required, supervisor_required, safe_redirect_url
|
||||
@@ -29,16 +43,46 @@ logger = logging.getLogger(__name__)
|
||||
bp = Blueprint('customers', __name__, url_prefix='/customers')
|
||||
|
||||
|
||||
def _get_customer_or_redirect(customer_id):
|
||||
"""Load a customer-side account, or return a redirect response.
|
||||
|
||||
Returns (account, None) on success and (None, response) when the id is not
|
||||
a customer-side account. Every route here used to test
|
||||
`customer.role != 'customer'`, which would now reject the Customer
|
||||
Inspectors this page owns — the check is CUSTOMER_ROLES, once, here.
|
||||
"""
|
||||
account = db.session.get(User, customer_id)
|
||||
if account is None:
|
||||
abort(404)
|
||||
if not account.is_customer_account:
|
||||
flash('This page is only for customer accounts.', 'warning')
|
||||
return None, redirect(url_for('customers.index'))
|
||||
return account, None
|
||||
|
||||
|
||||
def _inspector_scope_ids(user, project_facilities_map):
|
||||
"""Facility IDs a Customer Inspector reaches, from its contract rows.
|
||||
|
||||
Mirrors get_inspector_scope() but reuses the caller's already-loaded
|
||||
project → facilities map so the list view stays free of N+1 queries
|
||||
(rule 13).
|
||||
"""
|
||||
ids = set()
|
||||
for a in InspectorAssignment.query.filter_by(user_id=user.id).all():
|
||||
ids.update(project_facilities_map.get(a.project_id, []))
|
||||
return ids
|
||||
|
||||
|
||||
# ── List ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/')
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def index():
|
||||
"""Consolidated customer management dashboard."""
|
||||
"""Consolidated customer management dashboard — both customer roles."""
|
||||
customers = (
|
||||
User.query
|
||||
.filter_by(role='customer')
|
||||
.filter(User.role.in_(User.CUSTOMER_ROLES))
|
||||
.order_by(User.username)
|
||||
.all()
|
||||
)
|
||||
@@ -57,10 +101,27 @@ def index():
|
||||
for a in all_assignments:
|
||||
assignment_map[a.user_id].append(a)
|
||||
|
||||
# ── Bulk query for the inspector-side assignments ─────────────────────
|
||||
# Customer Inspectors are scoped by InspectorAssignment, not
|
||||
# CustomerAssignment — the two roles read different tables for the same
|
||||
# question ("which facilities does this account see?").
|
||||
all_inspector_assignments = (
|
||||
InspectorAssignment.query
|
||||
.filter(InspectorAssignment.user_id.in_(customer_ids))
|
||||
.all()
|
||||
) if customer_ids else []
|
||||
|
||||
inspector_assignment_map = {c.id: [] for c in customers}
|
||||
for a in all_inspector_assignments:
|
||||
inspector_assignment_map[a.user_id].append(a)
|
||||
|
||||
# ── Single bulk query for all active facilities in assigned projects ──
|
||||
# Resolves facility scope for every customer without repeated DB round-trips.
|
||||
from collections import defaultdict
|
||||
assigned_project_ids = {a.project_id for a in all_assignments}
|
||||
assigned_project_ids = (
|
||||
{a.project_id for a in all_assignments}
|
||||
| {a.project_id for a in all_inspector_assignments}
|
||||
)
|
||||
|
||||
project_facilities_map = defaultdict(list) # project_id → [facility_id, ...]
|
||||
if assigned_project_ids:
|
||||
@@ -78,11 +139,17 @@ def index():
|
||||
scope_map = {} # user_id → sorted list[int] facility IDs
|
||||
for customer in customers:
|
||||
ids = set()
|
||||
for a in assignment_map[customer.id]:
|
||||
if a.facility_id:
|
||||
ids.add(a.facility_id)
|
||||
else:
|
||||
if customer.is_inspector:
|
||||
# Customer Inspector — contract-level rows only, no facility-level
|
||||
# narrowing exists for inspectors (rule 57: no rows = sees nothing).
|
||||
for a in inspector_assignment_map[customer.id]:
|
||||
ids.update(project_facilities_map.get(a.project_id, []))
|
||||
else:
|
||||
for a in assignment_map[customer.id]:
|
||||
if a.facility_id:
|
||||
ids.add(a.facility_id)
|
||||
else:
|
||||
ids.update(project_facilities_map.get(a.project_id, []))
|
||||
scope_map[customer.id] = sorted(ids)
|
||||
|
||||
# All active projects for the assignment modal
|
||||
@@ -101,11 +168,12 @@ def index():
|
||||
|
||||
return render_template(
|
||||
'customers/index.html',
|
||||
customers = customers,
|
||||
assignment_map = assignment_map,
|
||||
scope_map = scope_map,
|
||||
projects = projects,
|
||||
expired_invitations = expired_invitations,
|
||||
customers = customers,
|
||||
assignment_map = assignment_map,
|
||||
inspector_assignment_map = inspector_assignment_map,
|
||||
scope_map = scope_map,
|
||||
projects = projects,
|
||||
expired_invitations = expired_invitations,
|
||||
)
|
||||
|
||||
|
||||
@@ -115,12 +183,16 @@ def index():
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def create():
|
||||
"""Create a customer account via email invitation.
|
||||
"""Create a customer-side account via email invitation.
|
||||
|
||||
Admin enters Full Name and Email only. A temporary username is
|
||||
auto-generated from the email address. A one-time set-password link
|
||||
is emailed; the customer chooses their own username and password when
|
||||
they click it. The account is activated on completion.
|
||||
Admin enters Full Name, Email and the role (Customer Director or Customer
|
||||
Inspector). A temporary username is auto-generated from the email address.
|
||||
A one-time set-password link is emailed; the invitee chooses their own
|
||||
username and password when they click it. The account is activated on
|
||||
completion.
|
||||
|
||||
Both roles take this identical path — an account that belongs to the
|
||||
customer is never given a password we chose.
|
||||
"""
|
||||
form = CustomerInviteForm()
|
||||
|
||||
@@ -129,6 +201,11 @@ def create():
|
||||
|
||||
full_name = form.full_name.data.strip()
|
||||
email = form.email.data.strip().lower()
|
||||
role = form.role.data
|
||||
# Defence in depth: never let a crafted POST mint a staff role through
|
||||
# the customer invitation form, which sets no password.
|
||||
if role not in User.CUSTOMER_ROLES:
|
||||
role = 'customer'
|
||||
|
||||
# Auto-generate a temporary username from the email local part.
|
||||
# The customer replaces this with their preferred username when
|
||||
@@ -146,7 +223,7 @@ def create():
|
||||
username = username,
|
||||
full_name = full_name,
|
||||
email = email,
|
||||
role = 'customer',
|
||||
role = role,
|
||||
active = True,
|
||||
password_set = False,
|
||||
)
|
||||
@@ -157,15 +234,15 @@ def create():
|
||||
token = user.generate_set_password_token(expires_hours=72)
|
||||
db.session.commit()
|
||||
|
||||
logger.info('CUSTOMERS | invite | admin=%s new_customer=%s email=%s',
|
||||
current_user.username, user.username, user.email)
|
||||
logger.info('CUSTOMERS | invite | admin=%s new_customer=%s role=%s email=%s',
|
||||
current_user.username, user.username, user.role, user.email)
|
||||
log_action(ACTION_CREATE, 'User', user.id, user.username,
|
||||
f'role=customer; email={user.email}; invite_sent=True')
|
||||
f'role={user.role}; email={user.email}; invite_sent=True')
|
||||
|
||||
_send_invite_email(user, token, base_url=request.host_url)
|
||||
|
||||
flash(
|
||||
f'Customer account created for {full_name}. '
|
||||
f'{user.role_label} account created for {full_name}. '
|
||||
f'An invitation email has been sent to {email} with a link to set their username and password.',
|
||||
'success'
|
||||
)
|
||||
@@ -259,12 +336,9 @@ def _send_invite_email(user, token, base_url=None):
|
||||
@supervisor_required
|
||||
def resend_invite(customer_id):
|
||||
"""Generate a fresh token and resend the set-password invitation email."""
|
||||
customer = db.session.get(User, customer_id)
|
||||
if customer is None:
|
||||
abort(404)
|
||||
if customer.role != 'customer':
|
||||
flash('This action is only for customer accounts.', 'warning')
|
||||
return redirect(url_for('customers.index'))
|
||||
customer, moved = _get_customer_or_redirect(customer_id)
|
||||
if moved:
|
||||
return moved
|
||||
|
||||
token = customer.generate_set_password_token(expires_hours=72)
|
||||
customer.password_set = False
|
||||
@@ -321,12 +395,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)
|
||||
|
||||
@@ -354,36 +425,78 @@ def edit(customer_id):
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def manage(customer_id):
|
||||
"""Single-customer detail page: profile + all assignments."""
|
||||
customer = db.session.get(User, customer_id)
|
||||
if customer is None:
|
||||
abort(404)
|
||||
if customer.role != 'customer':
|
||||
flash('This page is only for customer accounts.', 'warning')
|
||||
return redirect(url_for('customers.index'))
|
||||
"""Single-account detail page: profile + assignments + notification matrix.
|
||||
|
||||
assignments = CustomerAssignment.query.filter_by(user_id=customer_id).all()
|
||||
facility_ids = get_customer_scope(customer) or []
|
||||
facilities = (
|
||||
The assignment editor differs by role. A Customer Director gets the
|
||||
contract/facility assignment list (CustomerAssignment); a Customer
|
||||
Inspector gets the contract checkbox set (InspectorAssignment) that used to
|
||||
live on /auth/users/<id>/assign-contracts.
|
||||
"""
|
||||
customer, moved = _get_customer_or_redirect(customer_id)
|
||||
if moved:
|
||||
return moved
|
||||
|
||||
# Resolve the account's facility scope through the SAME helper the app uses
|
||||
# at request time, so this page can never disagree with what the account
|
||||
# actually sees.
|
||||
if customer.is_inspector:
|
||||
from app.utils.scope import get_inspector_scope
|
||||
facility_ids = get_inspector_scope(customer) or []
|
||||
else:
|
||||
facility_ids = get_customer_scope(customer) or []
|
||||
|
||||
facilities = (
|
||||
Facility.query
|
||||
.filter(Facility.id.in_(facility_ids), Facility.active == True)
|
||||
.order_by(Facility.name)
|
||||
.all()
|
||||
) if facility_ids else []
|
||||
|
||||
# Assignment form (populated here so it can be rendered inline)
|
||||
aform = CustomerAssignmentForm()
|
||||
projects = Project.query.filter_by(active=True).order_by(Project.name).all()
|
||||
|
||||
assignments = []
|
||||
assigned_pids = set()
|
||||
if customer.is_inspector:
|
||||
assigned_pids = {
|
||||
a.project_id
|
||||
for a in InspectorAssignment.query.filter_by(user_id=customer_id).all()
|
||||
}
|
||||
else:
|
||||
assignments = CustomerAssignment.query.filter_by(user_id=customer_id).all()
|
||||
|
||||
# Assignment form (populated here so it can be rendered inline)
|
||||
aform = CustomerAssignmentForm()
|
||||
aform.user_id.choices = [(customer.id, customer.username)]
|
||||
aform.facility_id.choices = [(0, '— All facilities in contract —')]
|
||||
|
||||
# ── Per-account notification matrix ───────────────────────────────────
|
||||
# For each event: what the global matrix would do for this account's role,
|
||||
# and whether the account overrides it. The template renders a tri-state
|
||||
# (Inherit / On / Off) so "inherit" stays visibly distinct from "explicitly
|
||||
# set to the same value the global happens to have today".
|
||||
from app.models.notification_matrix import MATRIX_EVENTS, is_enabled
|
||||
from app.models.user_notification_matrix import overrides_for_user
|
||||
|
||||
overrides = overrides_for_user(customer.id)
|
||||
matrix_rows = [
|
||||
{
|
||||
'event': event_key,
|
||||
'label': label,
|
||||
'global': is_enabled(event_key, customer.role),
|
||||
'override': overrides.get(event_key), # True / False / None
|
||||
}
|
||||
for event_key, label in MATRIX_EVENTS.items()
|
||||
]
|
||||
|
||||
return render_template(
|
||||
'customers/manage.html',
|
||||
customer = customer,
|
||||
assignments = assignments,
|
||||
facilities = facilities,
|
||||
aform = aform,
|
||||
projects = projects,
|
||||
customer = customer,
|
||||
assignments = assignments,
|
||||
assigned_pids = assigned_pids,
|
||||
facilities = facilities,
|
||||
aform = aform,
|
||||
projects = projects,
|
||||
matrix_rows = matrix_rows,
|
||||
)
|
||||
|
||||
|
||||
@@ -393,12 +506,16 @@ def manage(customer_id):
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def add_assignment(customer_id):
|
||||
customer = db.session.get(User, customer_id)
|
||||
if customer is None:
|
||||
abort(404)
|
||||
if customer.role != 'customer':
|
||||
flash('Assignments are only for customer accounts.', 'warning')
|
||||
return redirect(url_for('customers.index'))
|
||||
customer, moved = _get_customer_or_redirect(customer_id)
|
||||
if moved:
|
||||
return moved
|
||||
if customer.is_inspector:
|
||||
# A Customer Inspector is scoped by InspectorAssignment — writing a
|
||||
# CustomerAssignment row for them would grant nothing while looking
|
||||
# like it had.
|
||||
flash('Customer Inspectors are assigned whole contracts — use the '
|
||||
'contract list on this page.', 'warning')
|
||||
return redirect(url_for('customers.manage', customer_id=customer_id))
|
||||
|
||||
project_id = request.form.get('project_id', type=int)
|
||||
facility_id = request.form.get('facility_id', type=int) or None
|
||||
@@ -467,18 +584,233 @@ def remove_assignment(assignment_id):
|
||||
return redirect(url_for('customers.manage', customer_id=customer_id))
|
||||
|
||||
|
||||
# ── Contract assignments for a Customer Inspector ────────────────────────────
|
||||
|
||||
@bp.route('/<int:customer_id>/contracts', methods=['POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def assign_contracts(customer_id):
|
||||
"""Replace a Customer Inspector's whole InspectorAssignment set.
|
||||
|
||||
Same replace-the-entire-set semantics as auth.assign_inspector_contracts
|
||||
(rule 59) — the form posts the complete checked list, rows not in the POST
|
||||
body are deleted. Callers must always send the full desired set, never a
|
||||
diff.
|
||||
"""
|
||||
customer, moved = _get_customer_or_redirect(customer_id)
|
||||
if moved:
|
||||
return moved
|
||||
if not customer.is_inspector:
|
||||
flash('Contract assignment is for Customer Inspector accounts. '
|
||||
'Customer Directors are assigned per contract or facility below.',
|
||||
'warning')
|
||||
return redirect(url_for('customers.manage', customer_id=customer_id))
|
||||
|
||||
from app.utils.time_utils import now_eastern
|
||||
|
||||
selected_ids = set(request.form.getlist('project_ids', type=int))
|
||||
existing = InspectorAssignment.query.filter_by(user_id=customer_id).all()
|
||||
existing_pids = {a.project_id for a in existing}
|
||||
|
||||
for a in existing:
|
||||
if a.project_id not in selected_ids:
|
||||
db.session.delete(a)
|
||||
for pid in selected_ids:
|
||||
if pid not in existing_pids:
|
||||
db.session.add(InspectorAssignment(
|
||||
user_id = customer_id,
|
||||
project_id = pid,
|
||||
created_at = now_eastern(),
|
||||
))
|
||||
|
||||
db.session.commit()
|
||||
|
||||
logger.info('CUSTOMERS | assign_contracts | admin=%s customer=%s projects=%s',
|
||||
current_user.username, customer.username, sorted(selected_ids))
|
||||
log_action(ACTION_UPDATE, 'User', customer.id, customer.username,
|
||||
f'inspector_assignments={sorted(selected_ids)}')
|
||||
|
||||
if not selected_ids:
|
||||
# Rule 57 is strict, and silently is exactly how it bites.
|
||||
flash(f'{customer.display_name} now has no contracts assigned and will '
|
||||
f'see nothing until at least one is granted.', 'warning')
|
||||
else:
|
||||
flash(f'Contract assignments updated for {customer.display_name}.', 'success')
|
||||
return redirect(url_for('customers.manage', customer_id=customer_id))
|
||||
|
||||
|
||||
# ── Per-account notification matrix ──────────────────────────────────────────
|
||||
|
||||
@bp.route('/<int:customer_id>/notifications', methods=['POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def save_notifications(customer_id):
|
||||
"""Save this account's per-event notification overrides.
|
||||
|
||||
Each event posts one of 'inherit' / 'on' / 'off'. 'inherit' DELETES the row
|
||||
rather than storing the global column's current value — so an account that
|
||||
never expressed an opinion keeps following the global matrix when it
|
||||
changes later.
|
||||
"""
|
||||
customer, moved = _get_customer_or_redirect(customer_id)
|
||||
if moved:
|
||||
return moved
|
||||
|
||||
from app.models.notification_matrix import MATRIX_EVENTS
|
||||
from app.models.user_notification_matrix import set_overrides
|
||||
|
||||
tri = {'inherit': None, 'on': True, 'off': False}
|
||||
values = {}
|
||||
for event_key in MATRIX_EVENTS:
|
||||
# Only events this form actually posted; an unknown or missing value
|
||||
# is treated as inherit rather than guessed at.
|
||||
choice = request.form.get(f'event_{event_key}')
|
||||
if choice is not None:
|
||||
values[event_key] = tri.get(choice)
|
||||
|
||||
changed = set_overrides(customer.id, values)
|
||||
db.session.commit()
|
||||
|
||||
logger.info('CUSTOMERS | notif_matrix | admin=%s customer=%s changed=%s',
|
||||
current_user.username, customer.username, changed)
|
||||
log_action(ACTION_UPDATE, 'User', customer.id, customer.username,
|
||||
f'notification overrides updated ({changed} change(s))')
|
||||
|
||||
flash(f'Notification settings saved for {customer.display_name}.'
|
||||
if changed else 'No notification changes to save.',
|
||||
'success' if changed else 'info')
|
||||
return redirect(url_for('customers.manage', customer_id=customer.id))
|
||||
|
||||
|
||||
# ── Switch between the two customer roles ────────────────────────────────────
|
||||
|
||||
@bp.route('/<int:customer_id>/switch-role', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def switch_role(customer_id):
|
||||
"""Flip an account between Customer Director and Customer Inspector.
|
||||
|
||||
The two roles read DIFFERENT scoping tables, so flipping the column alone
|
||||
would leave the account correctly labelled and seeing nothing (rule 57 is
|
||||
strict for inspectors, and a director with no CustomerAssignment rows is
|
||||
equally blind). The contracts are therefore mirrored across: every contract
|
||||
the account could reach before, it can reach after.
|
||||
|
||||
Facility-level narrowing does NOT survive a switch to inspector — there is
|
||||
no per-facility row for inspectors, so a director scoped to one building in
|
||||
a contract becomes an inspector on that whole contract. The confirm dialog
|
||||
says so; the flash repeats it. Switching BACK is lossless though: the
|
||||
original facility-level rows were never deleted, and the reverse mirror
|
||||
skips contracts the account can already reach, so it does not pile a
|
||||
contract-wide grant on top of them.
|
||||
|
||||
API access changes in both directions ('external_inspector' has mobile API
|
||||
access, 'customer' is 403 everywhere), so the account's refresh tokens and
|
||||
device registrations are revoked — an issued JWT would otherwise keep
|
||||
working until it expired, and a signed-in iPad would keep syncing.
|
||||
"""
|
||||
customer, moved = _get_customer_or_redirect(customer_id)
|
||||
if moved:
|
||||
return moved
|
||||
|
||||
from app.utils.time_utils import now_eastern
|
||||
from app.models.api_token import RefreshToken, DeviceToken
|
||||
|
||||
old_role = customer.role
|
||||
new_role = 'external_inspector' if old_role == 'customer' else 'customer'
|
||||
|
||||
widened = False
|
||||
|
||||
if new_role == 'external_inspector':
|
||||
# Director → Inspector: CustomerAssignment (contract or facility) →
|
||||
# InspectorAssignment (contract only).
|
||||
existing_pids = {
|
||||
a.project_id
|
||||
for a in InspectorAssignment.query.filter_by(user_id=customer.id).all()
|
||||
}
|
||||
for a in CustomerAssignment.query.filter_by(user_id=customer.id).all():
|
||||
if a.facility_id:
|
||||
widened = True
|
||||
if a.project_id not in existing_pids:
|
||||
db.session.add(InspectorAssignment(
|
||||
user_id = customer.id,
|
||||
project_id = a.project_id,
|
||||
created_at = now_eastern(),
|
||||
))
|
||||
existing_pids.add(a.project_id)
|
||||
else:
|
||||
# Inspector → Director: contract-level CustomerAssignment rows
|
||||
# (facility_id NULL = all facilities in the contract).
|
||||
#
|
||||
# `existing_pids` counts ANY row for the contract, facility-level ones
|
||||
# included — NOT just the contract-wide ones. That is what makes a
|
||||
# round trip lossless: an account narrowed to one facility, switched to
|
||||
# inspector (which can only hold whole contracts) and switched back
|
||||
# would otherwise gain a contract-wide row on top of its original
|
||||
# facility row and come back with the whole contract. Skipping
|
||||
# contracts the account can already reach as a director leaves the
|
||||
# original narrowing intact, while contracts granted during the
|
||||
# inspector spell still carry over.
|
||||
existing_pids = {
|
||||
a.project_id
|
||||
for a in CustomerAssignment.query.filter_by(user_id=customer.id).all()
|
||||
}
|
||||
for a in InspectorAssignment.query.filter_by(user_id=customer.id).all():
|
||||
if a.project_id not in existing_pids:
|
||||
db.session.add(CustomerAssignment(
|
||||
user_id = customer.id,
|
||||
project_id = a.project_id,
|
||||
facility_id = None,
|
||||
))
|
||||
existing_pids.add(a.project_id)
|
||||
|
||||
# The stale rows for the role being left are kept on purpose: switching
|
||||
# back restores the account's original scope, including any facility-level
|
||||
# narrowing that the inspector side cannot express. They are inert while
|
||||
# the other role is active — each scope helper reads only its own table.
|
||||
|
||||
customer.role = new_role
|
||||
|
||||
revoked = (
|
||||
RefreshToken.query
|
||||
.filter_by(user_id=customer.id, revoked=False)
|
||||
.update({'revoked': True}, synchronize_session=False)
|
||||
)
|
||||
devices = (
|
||||
DeviceToken.query
|
||||
.filter_by(user_id=customer.id)
|
||||
.delete(synchronize_session=False)
|
||||
)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
logger.info('CUSTOMERS | switch_role | admin=%s customer=%s %s -> %s '
|
||||
'tokens_revoked=%s devices_cleared=%s',
|
||||
current_user.username, customer.username, old_role, new_role,
|
||||
revoked, devices)
|
||||
log_action(ACTION_UPDATE, 'User', customer.id, customer.username,
|
||||
f'role switched {old_role} -> {new_role}; '
|
||||
f'refresh_tokens_revoked={revoked}; devices_cleared={devices}')
|
||||
|
||||
msg = (f'{customer.display_name} is now a {customer.role_label}. '
|
||||
f'Their contracts were carried across; any signed-in device must log in again.')
|
||||
if widened:
|
||||
msg += (' Note: facility-level limits do not exist for inspectors, so '
|
||||
'this account now covers every facility in those contracts — '
|
||||
'review the contract list below.')
|
||||
flash(msg, 'warning' if widened else 'success')
|
||||
return redirect(url_for('customers.manage', customer_id=customer.id))
|
||||
|
||||
|
||||
# ── Toggle active ─────────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/<int:customer_id>/toggle-active', methods=['POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def toggle_active(customer_id):
|
||||
customer = db.session.get(User, customer_id)
|
||||
if customer is None:
|
||||
abort(404)
|
||||
if customer.role != 'customer':
|
||||
flash('This action is only for customer accounts.', 'warning')
|
||||
return redirect(url_for('customers.index'))
|
||||
customer, moved = _get_customer_or_redirect(customer_id)
|
||||
if moved:
|
||||
return moved
|
||||
|
||||
customer.active = not customer.active
|
||||
db.session.commit()
|
||||
|
||||
@@ -43,7 +43,9 @@ from app.models.inspection_schedule import (InspectionSchedule,
|
||||
from app.models.inspection import Inspection, InspectionTemplate
|
||||
from app.models.facility import Facility, Area
|
||||
from app.models.user import User
|
||||
from functools import wraps
|
||||
from app.utils.decorators import project_manager_required
|
||||
from app.utils.scope import get_customer_scope
|
||||
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE
|
||||
from app.utils.time_utils import now_eastern
|
||||
from app.utils.notifications import notify
|
||||
@@ -326,6 +328,142 @@ def _materialise(schedule: InspectionSchedule, when: datetime) -> Inspection:
|
||||
return inspection
|
||||
|
||||
|
||||
# ── Who may plan an inspection ───────────────────────────────────────────────
|
||||
|
||||
#: Our own staff who plan inspections. Customer Directors are added on top by
|
||||
#: schedule_manager_required — they plan work for their OWN facilities only.
|
||||
_STAFF_SCHEDULERS = ('admin', 'director', 'project_manager', 'auditor')
|
||||
|
||||
|
||||
def _is_customer_director(user):
|
||||
"""True only for the portal customer role.
|
||||
|
||||
Equality on purpose (rule 89): a Customer Inspector PERFORMS scheduled
|
||||
inspections, they do not plan them, and they are scoped by
|
||||
InspectorAssignment rather than CustomerAssignment. Widening this to
|
||||
User.CUSTOMER_ROLES would hand them a planning screen scoped by the wrong
|
||||
table — i.e. no facilities at all.
|
||||
"""
|
||||
return getattr(user, 'role', None) == 'customer'
|
||||
|
||||
|
||||
def schedule_manager_required(f):
|
||||
"""Who may create / edit / delete a scheduled inspection.
|
||||
|
||||
Our staff (_STAFF_SCHEDULERS) plus **Customer Directors**, who schedule
|
||||
inspections for the facilities they are assigned. Every choice list and
|
||||
every POST is narrowed to their own contracts — see _form_choices(),
|
||||
_scope_errors() and _schedule_in_scope().
|
||||
"""
|
||||
@wraps(f)
|
||||
def wrapper(*args, **kwargs):
|
||||
if not current_user.is_authenticated:
|
||||
abort(403)
|
||||
if current_user.role in _STAFF_SCHEDULERS or _is_customer_director(current_user):
|
||||
return f(*args, **kwargs)
|
||||
flash('You do not have permission to manage scheduled inspections.', 'danger')
|
||||
return redirect(url_for('dashboard.index'))
|
||||
return wrapper
|
||||
|
||||
|
||||
def _customer_facility_ids():
|
||||
"""Facility ids the current Customer Director may schedule against."""
|
||||
return set(get_customer_scope(current_user) or [])
|
||||
|
||||
|
||||
def _customer_project_ids():
|
||||
"""Contract ids behind those facilities.
|
||||
|
||||
Derived from the facilities rather than straight off CustomerAssignment, so
|
||||
a facility-level assignment resolves to its owning contract and the
|
||||
contract selector still lines up with the facilities on offer.
|
||||
"""
|
||||
fids = _customer_facility_ids()
|
||||
if not fids:
|
||||
return set()
|
||||
return {
|
||||
f.project_id
|
||||
for f in Facility.query.filter(Facility.id.in_(fids)).all()
|
||||
if f.project_id
|
||||
}
|
||||
|
||||
|
||||
def _schedule_in_scope(sched):
|
||||
"""May the current user act on this schedule?
|
||||
|
||||
Staff: any. Customer Director: only schedules at a facility they are
|
||||
assigned — checked on edit and delete so a hand-typed id cannot reach
|
||||
another customer's schedule.
|
||||
"""
|
||||
if not _is_customer_director(current_user):
|
||||
return True
|
||||
return sched.facility_id in _customer_facility_ids()
|
||||
|
||||
|
||||
def _scope_errors(template_id, facility_id, inspector_id):
|
||||
"""Validate a submitted schedule against the actor's scope and phase55.
|
||||
|
||||
This route builds its form by hand (no WTForms SelectField), so narrowing
|
||||
the choice lists is NOT the validation — a crafted POST would sail past it.
|
||||
Every id is therefore re-checked here:
|
||||
|
||||
* Customer Director — facility and inspector must belong to their own
|
||||
contracts, otherwise they could schedule work at, or assign it to,
|
||||
another customer.
|
||||
* Everyone — the chosen form must be available on the chosen facility's
|
||||
contract (phase55). Without this a manager could schedule one
|
||||
customer's bespoke form against another customer's facility, and the
|
||||
mismatch would only surface when the inspector opened it.
|
||||
"""
|
||||
errors = []
|
||||
facility = db.session.get(Facility, facility_id) if facility_id else None
|
||||
|
||||
if _is_customer_director(current_user):
|
||||
fids = _customer_facility_ids()
|
||||
if not facility_id or facility_id not in fids:
|
||||
logger.warning(
|
||||
'SCHED INSP | out-of-scope facility blocked | user=%s | facility_id=%s',
|
||||
current_user.username, facility_id)
|
||||
errors.append('That facility is not one of yours. '
|
||||
'Choose a facility from your contracts.')
|
||||
if inspector_id and inspector_id not in {u.id for u in _schedulable_inspectors()}:
|
||||
logger.warning(
|
||||
'SCHED INSP | out-of-scope inspector blocked | user=%s | user_id=%s',
|
||||
current_user.username, inspector_id)
|
||||
errors.append('That inspector does not work on your contracts.')
|
||||
|
||||
template = db.session.get(InspectionTemplate, template_id) if template_id else None
|
||||
if template is not None and facility is not None:
|
||||
if not template.available_for_project(facility.project_id):
|
||||
contract = facility.project.name if facility.project else "this facility's contract"
|
||||
errors.append(f'"{template.name}" is not available on {contract}. '
|
||||
f'Choose a form attached to that contract, or a shared form.')
|
||||
return errors
|
||||
|
||||
|
||||
def _schedulable_inspectors():
|
||||
"""Inspectors the current user may assign a schedule to.
|
||||
|
||||
A Customer Director sees only inspectors holding an InspectorAssignment on
|
||||
their own contracts — their own people and ours, never another client's
|
||||
Customer Inspector (rule 93). Staff see the whole active pool.
|
||||
"""
|
||||
q = User.query.filter(User.role.in_(User.INSPECTOR_ROLES), User.active == True)
|
||||
if _is_customer_director(current_user):
|
||||
from app.models.inspector_assignment import InspectorAssignment
|
||||
pids = _customer_project_ids()
|
||||
q = (q.join(InspectorAssignment, InspectorAssignment.user_id == User.id)
|
||||
.filter(InspectorAssignment.project_id.in_(pids))
|
||||
if pids else q.filter(False))
|
||||
seen, uniq = set(), []
|
||||
for u in q.order_by(User.username).all():
|
||||
# The join can repeat a user across assignment rows.
|
||||
if u.id not in seen:
|
||||
seen.add(u.id)
|
||||
uniq.append(u)
|
||||
return uniq
|
||||
|
||||
|
||||
# ── CRUD ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/')
|
||||
@@ -338,8 +476,9 @@ def index():
|
||||
customers are barred; managers see everything, exactly as before. All
|
||||
mutating routes below keep @project_manager_required.
|
||||
"""
|
||||
if current_user.role == 'customer':
|
||||
abort(403)
|
||||
# Customer Directors now plan inspections for their own facilities, so they
|
||||
# reach this list too — narrowed below. Customer Inspectors already saw it
|
||||
# (they are inspectors) and keep their own-assignments-only view.
|
||||
|
||||
# Two tabs (phase51): Pending = schedules still producing occurrences
|
||||
# (active); Completed = closed ones — fulfilled one-times, recurring
|
||||
@@ -359,6 +498,13 @@ def index():
|
||||
# Inspectors see only their own assignments; managers see everything.
|
||||
if current_user.is_inspector:
|
||||
base = base.filter(InspectionSchedule.inspector_id == current_user.id)
|
||||
elif _is_customer_director(current_user):
|
||||
# Only schedules at facilities they are assigned. An empty scope must
|
||||
# match nothing rather than everything — filter(False), not a skipped
|
||||
# filter (rule 57's failure mode).
|
||||
fids = _customer_facility_ids()
|
||||
base = (base.filter(InspectionSchedule.facility_id.in_(fids))
|
||||
if fids else base.filter(False))
|
||||
|
||||
# Counts are computed on the same scoped query, so the badges match what the
|
||||
# viewer can actually open.
|
||||
@@ -385,15 +531,42 @@ def index():
|
||||
|
||||
|
||||
def _form_choices():
|
||||
templates = InspectionTemplate.query.filter_by(active=True).order_by(InspectionTemplate.name).all()
|
||||
facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all()
|
||||
inspectors = _active_inspectors()
|
||||
"""Lists offered on the schedule form, narrowed to the actor's scope.
|
||||
|
||||
For a Customer Director every list is limited to their own contracts —
|
||||
including the FORM list, so another customer's bespoke form names never
|
||||
appear (phase55 / rule 96). The lists are a UI convenience only; the POST
|
||||
is re-validated by _scope_errors().
|
||||
"""
|
||||
customer_scoped = _is_customer_director(current_user)
|
||||
|
||||
fac_q = Facility.query.filter_by(active=True)
|
||||
if customer_scoped:
|
||||
fids = _customer_facility_ids()
|
||||
fac_q = fac_q.filter(Facility.id.in_(fids)) if fids else fac_q.filter(False)
|
||||
facilities = fac_q.order_by(Facility.name).all()
|
||||
|
||||
if customer_scoped:
|
||||
# Shared forms plus those attached to their contracts (phase55) — the
|
||||
# same union the mobile API builds.
|
||||
seen, templates = set(), []
|
||||
for pid in list(_customer_project_ids()) + [None]:
|
||||
for t in InspectionTemplate.available_query(pid).all():
|
||||
if t.id not in seen:
|
||||
seen.add(t.id)
|
||||
templates.append(t)
|
||||
templates.sort(key=lambda t: (t.name or '').lower())
|
||||
else:
|
||||
templates = (InspectionTemplate.query.filter_by(active=True)
|
||||
.order_by(InspectionTemplate.name).all())
|
||||
|
||||
inspectors = _schedulable_inspectors() if customer_scoped else _active_inspectors()
|
||||
return templates, facilities, inspectors
|
||||
|
||||
|
||||
@bp.route('/new', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@project_manager_required
|
||||
@schedule_manager_required
|
||||
def create():
|
||||
templates, facilities, inspectors = _form_choices()
|
||||
|
||||
@@ -421,6 +594,7 @@ def create():
|
||||
if mode not in _MODES:
|
||||
errors.append('Invalid mode.')
|
||||
errors.extend(_recurrence_errors(request.form, frequency))
|
||||
errors.extend(_scope_errors(template_id, facility_id, inspector_id))
|
||||
|
||||
if errors:
|
||||
for e in errors:
|
||||
@@ -486,11 +660,13 @@ def create():
|
||||
|
||||
@bp.route('/<int:schedule_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@project_manager_required
|
||||
@schedule_manager_required
|
||||
def edit(schedule_id):
|
||||
schedule = db.session.get(InspectionSchedule, schedule_id)
|
||||
if schedule is None:
|
||||
abort(404)
|
||||
if not _schedule_in_scope(schedule):
|
||||
abort(403)
|
||||
templates, facilities, inspectors = _form_choices()
|
||||
|
||||
if request.method == 'POST':
|
||||
@@ -504,6 +680,9 @@ def edit(schedule_id):
|
||||
if frequency not in _FREQUENCIES:
|
||||
frequency = schedule.frequency
|
||||
errors = _recurrence_errors(request.form, frequency)
|
||||
errors.extend(_scope_errors(template_id or schedule.template_id,
|
||||
facility_id or schedule.facility_id,
|
||||
inspector_id))
|
||||
if errors:
|
||||
db.session.rollback()
|
||||
for e in errors:
|
||||
@@ -593,11 +772,13 @@ def edit(schedule_id):
|
||||
|
||||
@bp.route('/<int:schedule_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
@project_manager_required
|
||||
@schedule_manager_required
|
||||
def delete(schedule_id):
|
||||
schedule = db.session.get(InspectionSchedule, schedule_id)
|
||||
if schedule is None:
|
||||
abort(404)
|
||||
if not _schedule_in_scope(schedule):
|
||||
abort(403)
|
||||
name = schedule.name
|
||||
sid = schedule.id
|
||||
db.session.delete(schedule)
|
||||
|
||||
+427
-35
@@ -15,7 +15,7 @@ from app.models.project import Project
|
||||
from app.models.issue import Issue
|
||||
from app.models.user import User
|
||||
from app.utils.forms import StartInspectionForm, IssueForm
|
||||
from app.utils.decorators import supervisor_required
|
||||
from app.utils.decorators import supervisor_required, return_url
|
||||
from app.utils.pdf_export import generate_inspection_pdf, generate_inspections_list_pdf
|
||||
from app.utils.notifications import notify, notify_customers_for_facility, notify_by_matrix
|
||||
from app.models.notification import (
|
||||
@@ -350,7 +350,6 @@ def index():
|
||||
def start():
|
||||
form = StartInspectionForm()
|
||||
|
||||
templates = InspectionTemplate.query.filter_by(active=True).order_by(InspectionTemplate.name).all()
|
||||
projects = Project.query.filter_by(active=True).order_by(Project.name).all()
|
||||
|
||||
# Scope projects to inspector's assigned contracts
|
||||
@@ -362,7 +361,6 @@ def start():
|
||||
}
|
||||
projects = [p for p in projects if p.id in assigned_pids]
|
||||
|
||||
form.template_id.choices = [(t.id, t.name) for t in templates]
|
||||
form.project_id.choices = [(p.id, p.name) for p in projects]
|
||||
|
||||
# Seed facility choices: use submitted project_id, session value, or first project
|
||||
@@ -376,6 +374,13 @@ def start():
|
||||
else:
|
||||
selected_project_id = projects[0].id if projects else None
|
||||
|
||||
# phase52 — forms are offered per CONTRACT: shared forms plus any attached
|
||||
# to the selected contract. This is also the POST validation (SelectField
|
||||
# validates against its choices), so a crafted template_id for another
|
||||
# customer's form is rejected here, not merely hidden in the UI.
|
||||
templates = InspectionTemplate.available_query(selected_project_id).all()
|
||||
form.template_id.choices = [(t.id, t.name) for t in templates]
|
||||
|
||||
if selected_project_id:
|
||||
facilities = Facility.query.filter_by(active=True, project_id=selected_project_id).order_by(Facility.name).all()
|
||||
else:
|
||||
@@ -408,6 +413,19 @@ def start():
|
||||
if template is None:
|
||||
abort(404)
|
||||
|
||||
# Belt-and-braces: the choices above already reject a form that is not
|
||||
# available on this contract, but that guard lives in how the list was
|
||||
# built. Re-assert it against the FACILITY actually chosen, so a future
|
||||
# change to the choice-building cannot quietly open a cross-customer
|
||||
# hole here.
|
||||
_fac = db.session.get(Facility, form.facility_id.data)
|
||||
if not template.available_for_project(_fac.project_id if _fac else None):
|
||||
logger_msg = ('INSPECTION START BLOCKED | template=%s not available for '
|
||||
'facility=%s | user=%s')
|
||||
current_app.logger.warning(logger_msg, template.id,
|
||||
form.facility_id.data, current_user.username)
|
||||
abort(403)
|
||||
|
||||
# Inspector facility scope check — prevent crafted POST from selecting
|
||||
# a facility outside their assigned contracts.
|
||||
if current_user.is_inspector:
|
||||
@@ -465,6 +483,45 @@ def facilities_for_project(project_id):
|
||||
return jsonify([{'id': f.id, 'name': f.name} for f in facilities])
|
||||
|
||||
|
||||
# ── AJAX: forms available on a given contract (phase52) ──────────────────────
|
||||
|
||||
@bp.route('/templates_for_project/<int:project_id>')
|
||||
@login_required
|
||||
def templates_for_project(project_id):
|
||||
"""Forms usable on this contract — shared ones plus any attached to it.
|
||||
|
||||
Powers the Contract -> Form cascade on the start-inspection page, the same
|
||||
way facilities_for_project powers Contract -> Facility.
|
||||
|
||||
**Scoped to the caller's own contracts.** The POST validation in start() is
|
||||
what stops a form being *used* across contracts, but this endpoint would
|
||||
otherwise happily list one customer's bespoke form NAMES to another
|
||||
customer's inspector who simply asked for a contract id — the same leak
|
||||
that rule 96 covers on the mobile API. Empty list rather than 403, so it
|
||||
does not confirm whether the contract exists either.
|
||||
"""
|
||||
if current_user.is_inspector:
|
||||
fids = get_inspector_scope(current_user) or []
|
||||
allowed = {
|
||||
f.project_id
|
||||
for f in Facility.query.filter(Facility.id.in_(fids)).all()
|
||||
} if fids else set()
|
||||
if project_id not in allowed:
|
||||
logger_msg = ('TEMPLATES_FOR_PROJECT | out-of-scope request | '
|
||||
'user=%s | project_id=%s')
|
||||
current_app.logger.warning(logger_msg, current_user.username, project_id)
|
||||
return jsonify([])
|
||||
elif current_user.role == 'customer':
|
||||
# Customers never start inspections; nothing here is theirs to see.
|
||||
return jsonify([])
|
||||
|
||||
templates = InspectionTemplate.available_query(project_id).all()
|
||||
return jsonify([
|
||||
{'id': t.id, 'name': t.name, 'shared': t.is_shared}
|
||||
for t in templates
|
||||
])
|
||||
|
||||
|
||||
# ── Execute ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/<int:inspection_id>/execute', methods=['GET', 'POST'])
|
||||
@@ -479,7 +536,7 @@ def execute(inspection_id):
|
||||
return redirect(url_for('inspections.index'))
|
||||
|
||||
if inspection.status == 'completed':
|
||||
return redirect(url_for('inspections.view', inspection_id=inspection_id))
|
||||
return redirect(_view_url(inspection_id))
|
||||
|
||||
template = inspection.template
|
||||
form_fields = template.get_form_schema()
|
||||
@@ -601,7 +658,7 @@ def execute(inspection_id):
|
||||
f'status=completed; score={score}')
|
||||
|
||||
flash('Inspection submitted successfully!', 'success')
|
||||
return redirect(url_for('inspections.view', inspection_id=inspection_id))
|
||||
return redirect(_view_url(inspection_id))
|
||||
|
||||
else:
|
||||
_save_draft(inspection, responses)
|
||||
@@ -609,11 +666,10 @@ def execute(inspection_id):
|
||||
flash('Draft saved. You can continue filling in the form later.', 'success')
|
||||
return redirect(url_for('inspections.execute', inspection_id=inspection_id))
|
||||
|
||||
staff_for_flag_issue = User.query.filter(
|
||||
User.role.in_(['director', 'inspector', 'external_inspector',
|
||||
'project_manager', 'auditor']),
|
||||
User.active == True,
|
||||
).order_by(User.full_name, User.username).all()
|
||||
# Scoped to this inspection's contract — see _assignable_staff_for().
|
||||
# Must match flag_issue()'s choices exactly or the offcanvas silently
|
||||
# fails to save (rule 60).
|
||||
staff_for_flag_issue = _assignable_staff_for(inspection, current_user)
|
||||
|
||||
return render_template('inspections/execute.html',
|
||||
inspection=inspection,
|
||||
@@ -924,6 +980,95 @@ def view(inspection_id):
|
||||
|
||||
# ── Flag issue during inspection ──────────────────────────────────────────────
|
||||
|
||||
#: Internal roles that are NOT contract-scoped — they work across the whole
|
||||
#: organisation, so they are offered regardless of which contract the
|
||||
#: inspection belongs to. Only ever shown to our own people.
|
||||
_ORG_WIDE_ASSIGNEE_ROLES = ('director', 'project_manager', 'auditor')
|
||||
|
||||
|
||||
def _assignable_staff_for(inspection, actor):
|
||||
"""Users `actor` may assign an issue to, for THIS inspection.
|
||||
|
||||
The candidate list is scoped by the inspection's CONTRACT, not taken
|
||||
org-wide. Two distinct problems this fixes:
|
||||
|
||||
1. **Cross-customer leak.** A Customer Inspector could assign an issue to
|
||||
anyone in the system — including another client's Customer Inspector.
|
||||
The assignee is notified by email and in-app with the facility name and
|
||||
issue description, so this handed one customer's data to another. It is
|
||||
a leak whoever flags the issue, so the contract scope is applied to the
|
||||
two inspector roles for EVERY actor, not just customer ones.
|
||||
|
||||
2. An external account should not see our internal org chart at all. For a
|
||||
customer-side actor the list is their co-workers on shared contracts —
|
||||
inspectors assigned to this inspection's contract — and nothing else.
|
||||
|
||||
Rules applied:
|
||||
* inspector / external_inspector -> only those holding an
|
||||
InspectorAssignment on this inspection's contract (the same rows
|
||||
get_inspector_scope() reads, so the list can never disagree with what
|
||||
the assignee can actually open).
|
||||
* director / project_manager / auditor -> org-wide, but offered ONLY to
|
||||
our own staff. These roles carry no InspectorAssignment rows, so
|
||||
contract-scoping them would remove them entirely and break the normal
|
||||
"escalate to the contract manager" flow.
|
||||
* inactive accounts are never offered.
|
||||
|
||||
A facility with no contract yields no contract-scoped candidates; that is
|
||||
fail-closed and correct — an external actor then gets an empty list and can
|
||||
only leave the issue unassigned.
|
||||
|
||||
Used by BOTH the offcanvas dropdown in execute() and the choices that
|
||||
validate the POST in flag_issue(). They MUST stay identical: a value the UI
|
||||
offers but the choices reject fails `validate_on_submit()`, and the
|
||||
offcanvas JS treats the resulting 200 as success — the issue is silently
|
||||
never saved (rule 60's failure mode, which is exactly what the two
|
||||
hand-maintained lists were already doing to project_manager and auditor).
|
||||
"""
|
||||
from app.models.inspector_assignment import InspectorAssignment
|
||||
|
||||
# `is_customer_account` is used here to WITHHOLD internal staff from an
|
||||
# external account — the narrowing direction, which rule 89 permits. It
|
||||
# must never be used to grant a customer-side account anything.
|
||||
actor_is_external = bool(actor) and actor.is_customer_account
|
||||
|
||||
project_id = inspection.facility.project_id if inspection.facility else None
|
||||
|
||||
candidates = []
|
||||
if project_id:
|
||||
candidates = (
|
||||
User.query
|
||||
.join(InspectorAssignment, InspectorAssignment.user_id == User.id)
|
||||
.filter(
|
||||
InspectorAssignment.project_id == project_id,
|
||||
User.role.in_(User.INSPECTOR_ROLES),
|
||||
User.active == True,
|
||||
)
|
||||
.order_by(User.full_name, User.username)
|
||||
.all()
|
||||
)
|
||||
|
||||
if not actor_is_external:
|
||||
candidates += (
|
||||
User.query
|
||||
.filter(
|
||||
User.role.in_(_ORG_WIDE_ASSIGNEE_ROLES),
|
||||
User.active == True,
|
||||
)
|
||||
.order_by(User.full_name, User.username)
|
||||
.all()
|
||||
)
|
||||
|
||||
# The join can repeat a user across assignment rows; dedupe by id, keeping
|
||||
# a stable display order.
|
||||
seen, out = set(), []
|
||||
for u in candidates:
|
||||
if u.id not in seen:
|
||||
seen.add(u.id)
|
||||
out.append(u)
|
||||
out.sort(key=lambda u: (u.display_name or '').lower())
|
||||
return out
|
||||
|
||||
@bp.route('/<int:inspection_id>/flag-issue', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def flag_issue(inspection_id):
|
||||
@@ -936,15 +1081,16 @@ def flag_issue(inspection_id):
|
||||
return redirect(url_for('inspections.index'))
|
||||
|
||||
form = IssueForm()
|
||||
staff = User.query.filter(
|
||||
User.role.in_(['director', 'inspector', 'external_inspector'])
|
||||
).order_by(User.username).all()
|
||||
# SAME list the offcanvas rendered — this is what actually validates the
|
||||
# POST, so it is also the security boundary: a crafted assigned_to for
|
||||
# someone outside this contract fails validation rather than being stored.
|
||||
staff = _assignable_staff_for(inspection, current_user)
|
||||
|
||||
form.facility_id.choices = [(inspection.facility_id, inspection.facility.name)]
|
||||
# MT-15: suffix external (customer / third-party) inspectors so whoever is
|
||||
# triaging can see the work is going outside the company. Display only.
|
||||
# Suffix customer-employed inspectors so whoever is triaging can see the
|
||||
# work is going outside the company. Display only.
|
||||
form.assigned_to.choices = [(0, '— Unassigned —')] + [
|
||||
(u.id, u.username + (' (External)' if u.is_external_inspector else ''))
|
||||
(u.id, u.display_name + (' (Customer)' if u.is_external_inspector else ''))
|
||||
for u in staff
|
||||
]
|
||||
|
||||
@@ -1011,6 +1157,21 @@ def flag_issue(inspection_id):
|
||||
flash('Issue logged successfully.', 'success')
|
||||
return redirect(url_for('inspections.execute', inspection_id=inspection_id))
|
||||
|
||||
# A failed POST must NOT come back 200. The flag-issue offcanvas treats
|
||||
# `res.ok` as success and reloads the page, so a 200 here means the issue
|
||||
# is silently discarded with the user believing it was logged — the exact
|
||||
# failure rule 60 describes. Returning 400 routes it to the JS error branch
|
||||
# so the reason is shown and the form stays open with its input intact.
|
||||
if request.method == 'POST':
|
||||
if form.assigned_to.errors:
|
||||
# Most likely an assignee outside this inspection's contract:
|
||||
# either a stale page rendered before the assignment changed, or a
|
||||
# crafted id. Say something actionable rather than "invalid choice".
|
||||
flash('That person cannot be assigned to an issue on this contract. '
|
||||
'Reopen the panel to refresh the list.', 'danger')
|
||||
return render_template('inspections/flag_issue.html',
|
||||
form=form, inspection=inspection), 400
|
||||
|
||||
return render_template('inspections/flag_issue.html',
|
||||
form=form, inspection=inspection)
|
||||
|
||||
@@ -1223,6 +1384,250 @@ def export_pdf(inspection_id):
|
||||
|
||||
# ── Flag / clear follow-up required ──────────────────────────────────────────
|
||||
|
||||
def _view_url(inspection_id):
|
||||
"""inspections.view URL that carries the list `next` through.
|
||||
|
||||
Actions posted from the detail page redirect back to that same page;
|
||||
re-attaching `next` is what keeps its Back button (and the next action)
|
||||
pointed at the filtered list the user arrived from.
|
||||
"""
|
||||
nxt = request.form.get('next') or request.args.get('next')
|
||||
if nxt:
|
||||
return url_for('inspections.view', inspection_id=inspection_id, next=nxt)
|
||||
return url_for('inspections.view', inspection_id=inspection_id)
|
||||
|
||||
|
||||
def _collect_inspection_photos(inspection):
|
||||
"""Relative storage keys owned by an inspection, for cleanup after delete.
|
||||
|
||||
Two sources: image field values inside the submitted form data (stored as
|
||||
`uploads/...` strings in the notes JSON), and the primary photo of each
|
||||
issue flagged during the inspection. Shared by the single and bulk delete
|
||||
paths so they cannot drift — a miss here leaves orphaned files in storage
|
||||
forever, and it is invisible.
|
||||
"""
|
||||
paths = []
|
||||
if inspection.notes:
|
||||
try:
|
||||
notes_data = json.loads(inspection.notes)
|
||||
form_data = notes_data.get('_form_data', {}) if isinstance(notes_data, dict) else {}
|
||||
for val in form_data.values():
|
||||
if isinstance(val, str) and val.startswith('uploads/'):
|
||||
paths.append(val)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
for issue in inspection.issues.all():
|
||||
if issue.photo_path:
|
||||
paths.append(issue.photo_path)
|
||||
return paths
|
||||
|
||||
|
||||
# ── Bulk actions from the inspections list ───────────────────────────────────
|
||||
|
||||
@bp.route('/bulk', methods=['POST'])
|
||||
@login_required
|
||||
def bulk_action():
|
||||
"""Apply one action to every ticked inspection on the list page.
|
||||
|
||||
Partial-failure policy: act on every eligible row, skip the rest, and
|
||||
report exact counts. Permission is checked per ACTION (all are
|
||||
admin/director level except the PDF export, which anyone who can see the
|
||||
list may run); `skipped` therefore means "this row was not in a state the
|
||||
action applies to".
|
||||
"""
|
||||
back = return_url(url_for('inspections.index'))
|
||||
action = request.form.get('action', '')
|
||||
ids = request.form.getlist('inspection_ids', type=int)
|
||||
|
||||
if not ids:
|
||||
flash('No inspections selected.', 'warning')
|
||||
return redirect(back)
|
||||
|
||||
supervisor = current_user.role in ('admin', 'director')
|
||||
allowed = {
|
||||
'export': True, # read-only, already scoped below
|
||||
'delete': supervisor,
|
||||
'flag_followup': supervisor,
|
||||
'clear_followup': supervisor,
|
||||
}
|
||||
if action not in allowed:
|
||||
flash('Unknown bulk action.', 'danger')
|
||||
return redirect(back)
|
||||
if not allowed[action]:
|
||||
flash('You do not have permission for that bulk action.', 'danger')
|
||||
return redirect(back)
|
||||
|
||||
q = Inspection.query.options(
|
||||
joinedload(Inspection.facility),
|
||||
joinedload(Inspection.template),
|
||||
joinedload(Inspection.inspector),
|
||||
).filter(Inspection.id.in_(ids))
|
||||
|
||||
# Re-apply the viewer's facility scope to the SELECTED ids. The list page
|
||||
# only ever shows in-scope rows, but the id list arrives in the POST body
|
||||
# and must not be trusted — a crafted request could otherwise name any
|
||||
# inspection in the system.
|
||||
if current_user.is_inspector:
|
||||
fids = get_inspector_scope(current_user) or []
|
||||
q = q.filter(Inspection.facility_id.in_(fids)) if fids else q.filter(False)
|
||||
elif current_user.role == 'customer':
|
||||
fids = get_customer_scope(current_user) or []
|
||||
q = q.filter(Inspection.facility_id.in_(fids)) if fids else q.filter(False)
|
||||
|
||||
inspections = q.order_by(Inspection.inspection_date.desc()).all()
|
||||
out_of_scope = len(ids) - len(inspections)
|
||||
changed = 0
|
||||
skipped = out_of_scope
|
||||
|
||||
# ── Export selected to PDF ───────────────────────────────────────────
|
||||
if action == 'export':
|
||||
if not inspections:
|
||||
flash('None of the selected inspections are available to you.', 'warning')
|
||||
return redirect(back)
|
||||
from flask import Response
|
||||
pdf = generate_inspections_list_pdf(
|
||||
inspections,
|
||||
f'Selected inspections ({len(inspections)})',
|
||||
)
|
||||
log_action(ACTION_EXPORT, 'Inspection', None, 'bulk PDF export',
|
||||
f'ids={[i.id for i in inspections]}')
|
||||
return Response(
|
||||
pdf,
|
||||
mimetype='application/pdf',
|
||||
headers={'Content-Disposition':
|
||||
'attachment; filename="selected_inspections.pdf"'},
|
||||
)
|
||||
|
||||
# ── Delete ───────────────────────────────────────────────────────────
|
||||
if action == 'delete':
|
||||
from app.utils import storage
|
||||
photo_paths = []
|
||||
# Snapshot (id, label) BEFORE deleting: the objects are expired after
|
||||
# the commit, and the audit pass must run after it. log_action()
|
||||
# commits internally (rule 41), so auditing inside this loop would
|
||||
# commit the deletes one at a time — and a mid-loop failure would
|
||||
# leave rows gone with the photo cleanup below never reached.
|
||||
deleted = []
|
||||
for insp in inspections:
|
||||
photo_paths.extend(_collect_inspection_photos(insp))
|
||||
deleted.append((
|
||||
insp.id,
|
||||
f'{insp.template.name if insp.template else "—"} @ '
|
||||
f'{insp.facility.name if insp.facility else "—"}',
|
||||
))
|
||||
db.session.delete(insp)
|
||||
changed += 1
|
||||
db.session.commit()
|
||||
for insp_id, label in deleted:
|
||||
log_action(ACTION_DELETE, 'Inspection', insp_id, label,
|
||||
f'bulk deleted by {current_user.username}')
|
||||
# Files only after the rows are gone — an orphaned file is recoverable,
|
||||
# a deleted file belonging to a surviving row is not.
|
||||
for rel_path in photo_paths:
|
||||
storage.delete(rel_path)
|
||||
_flash_bulk(changed, skipped, 'permanently deleted')
|
||||
|
||||
# ── Request follow-up ────────────────────────────────────────────────
|
||||
elif action == 'flag_followup':
|
||||
note = request.form.get('follow_up_note', '').strip() or None
|
||||
# Only the rows this run actually flagged. Re-deriving it afterwards
|
||||
# from `follow_up_requested_by == current_user.id` would also match
|
||||
# inspections this same user flagged on an EARLIER run and that were
|
||||
# skipped here as already-flagged — re-notifying their inspectors.
|
||||
flagged = []
|
||||
for insp in inspections:
|
||||
# Same two guards as the single-inspection route: nothing to follow
|
||||
# up on before submission, and a repeat request must not overwrite
|
||||
# the pending one's note or attribution.
|
||||
if insp.status != 'completed' or insp.follow_up_required:
|
||||
skipped += 1
|
||||
continue
|
||||
insp.follow_up_required = True
|
||||
insp.follow_up_note = note
|
||||
insp.follow_up_requested_by = current_user.id
|
||||
insp.follow_up_requested_at = now_eastern()
|
||||
flagged.append(insp)
|
||||
changed += 1
|
||||
db.session.commit()
|
||||
|
||||
for insp in flagged:
|
||||
body = (f'{current_user.display_name} has requested a follow-up '
|
||||
f're-inspection of "{insp.template.name if insp.template else "—"}" '
|
||||
f'at {insp.facility.name if insp.facility else "—"}.'
|
||||
+ (f' Note: {note}' if note else ''))
|
||||
inspector = db.session.get(User, insp.inspector_id)
|
||||
if inspector and inspector.id != current_user.id:
|
||||
notify(
|
||||
recipient = inspector,
|
||||
title = f'Follow-Up Required: Inspection #{insp.id}',
|
||||
body = body,
|
||||
link = url_for('inspections.view', inspection_id=insp.id),
|
||||
inspection_id = insp.id,
|
||||
event_type = EVENT_INSPECTION_DONE,
|
||||
send_email = True,
|
||||
)
|
||||
# Through the matrix, not straight to managers — rule 73, so
|
||||
# per-contract recipients fire here exactly as they do for a
|
||||
# single request.
|
||||
notify_by_matrix(
|
||||
event_type = EVENT_FOLLOWUP_REQUESTED,
|
||||
title = f'Follow-Up Requested: Inspection #{insp.id}',
|
||||
body = body,
|
||||
link = url_for('inspections.view', inspection_id=insp.id),
|
||||
inspection_id = insp.id,
|
||||
facility_id = insp.facility_id,
|
||||
exclude_user_ids = {current_user.id,
|
||||
inspector.id if inspector else None} - {None},
|
||||
)
|
||||
db.session.commit() # notify() does not commit — rule 70
|
||||
# Audited after the commit (rule 41) — log_action commits internally.
|
||||
for insp in flagged:
|
||||
log_action(ACTION_UPDATE, 'Inspection', insp.id,
|
||||
f'{insp.template.name if insp.template else "—"}',
|
||||
f'bulk follow_up_required=True by {current_user.username}')
|
||||
_flash_bulk(changed, skipped, 'flagged for follow-up',
|
||||
skip_reason='not submitted, or already flagged')
|
||||
|
||||
# ── Clear follow-up ──────────────────────────────────────────────────
|
||||
elif action == 'clear_followup':
|
||||
cleared = []
|
||||
for insp in inspections:
|
||||
if not insp.follow_up_required:
|
||||
skipped += 1
|
||||
continue
|
||||
insp.follow_up_required = False
|
||||
insp.follow_up_note = None
|
||||
insp.follow_up_requested_by = None
|
||||
insp.follow_up_requested_at = None
|
||||
cleared.append(insp)
|
||||
changed += 1
|
||||
db.session.commit()
|
||||
for insp in cleared: # after the commit — rule 41
|
||||
log_action(ACTION_UPDATE, 'Inspection', insp.id,
|
||||
f'{insp.template.name if insp.template else "—"}',
|
||||
f'bulk follow_up cleared by {current_user.username}')
|
||||
_flash_bulk(changed, skipped, 'cleared of the follow-up flag',
|
||||
skip_reason='not flagged')
|
||||
|
||||
current_app.logger.info(
|
||||
'INSPECTIONS | bulk | action=%s user=%s selected=%s changed=%s skipped=%s',
|
||||
action, current_user.username, len(ids), changed, skipped,
|
||||
)
|
||||
return redirect(back)
|
||||
|
||||
|
||||
def _flash_bulk(changed, skipped, verb, skip_reason='no change needed'):
|
||||
"""One consistent result message for every bulk action."""
|
||||
if not changed and not skipped:
|
||||
flash('Nothing to do.', 'info')
|
||||
return
|
||||
parts = [f'{changed} inspection{"s" if changed != 1 else ""} {verb}']
|
||||
if skipped:
|
||||
parts.append(f'{skipped} skipped ({skip_reason})')
|
||||
flash('. '.join(parts) + '.', 'success' if changed else 'warning')
|
||||
|
||||
|
||||
@bp.route('/<int:inspection_id>/flag-followup', methods=['POST'])
|
||||
@login_required
|
||||
def flag_followup(inspection_id):
|
||||
@@ -1249,12 +1654,12 @@ def flag_followup(inspection_id):
|
||||
# Nothing to follow up on until the inspection has been submitted.
|
||||
if inspection.status != 'completed':
|
||||
flash('You can only request a follow-up on a completed inspection.', 'warning')
|
||||
return redirect(url_for('inspections.view', inspection_id=inspection_id))
|
||||
return redirect(_view_url(inspection_id))
|
||||
# Don't let a repeat request overwrite the note/attribution of a pending
|
||||
# one — the flag is already raised and staff are already on it.
|
||||
if inspection.follow_up_required:
|
||||
flash('A follow-up has already been requested for this inspection.', 'info')
|
||||
return redirect(url_for('inspections.view', inspection_id=inspection_id))
|
||||
return redirect(_view_url(inspection_id))
|
||||
elif current_user.role not in ('admin', 'director'):
|
||||
abort(403)
|
||||
|
||||
@@ -1316,7 +1721,7 @@ def flag_followup(inspection_id):
|
||||
flash('Follow-up re-inspection requested. The team has been notified.', 'success')
|
||||
else:
|
||||
flash('Follow-up inspection required flag set.', 'warning')
|
||||
return redirect(url_for('inspections.view', inspection_id=inspection_id))
|
||||
return redirect(_view_url(inspection_id))
|
||||
|
||||
|
||||
@bp.route('/<int:inspection_id>/clear-followup', methods=['POST'])
|
||||
@@ -1339,7 +1744,7 @@ def clear_followup(inspection_id):
|
||||
f'{inspection.template.name} @ {inspection.facility.name}',
|
||||
'follow_up_required=False (cleared)')
|
||||
flash('Follow-up flag cleared.', 'success')
|
||||
return redirect(url_for('inspections.view', inspection_id=inspection_id))
|
||||
return redirect(_view_url(inspection_id))
|
||||
|
||||
|
||||
# ── Start a re-inspection (linked to parent) ──────────────────────────────────
|
||||
@@ -1385,20 +1790,7 @@ def delete(inspection_id):
|
||||
template_name = inspection.template.name
|
||||
inspector_name = inspection.inspector.username
|
||||
|
||||
photo_paths = []
|
||||
if inspection.notes:
|
||||
try:
|
||||
notes_data = json.loads(inspection.notes)
|
||||
form_data = notes_data.get('_form_data', {}) if isinstance(notes_data, dict) else {}
|
||||
for val in form_data.values():
|
||||
if isinstance(val, str) and val.startswith('uploads/'):
|
||||
photo_paths.append(val)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
for issue in inspection.issues.all():
|
||||
if issue.photo_path:
|
||||
photo_paths.append(issue.photo_path)
|
||||
photo_paths = _collect_inspection_photos(inspection)
|
||||
|
||||
db.session.delete(inspection)
|
||||
db.session.commit()
|
||||
@@ -1425,4 +1817,4 @@ def delete(inspection_id):
|
||||
f'has been permanently deleted.',
|
||||
'success'
|
||||
)
|
||||
return redirect(url_for('inspections.index'))
|
||||
return redirect(return_url(url_for('inspections.index')))
|
||||
+234
-15
@@ -16,7 +16,7 @@ from app.models.notification import (
|
||||
)
|
||||
from app.utils.forms import IssueForm, IssueUpdateForm
|
||||
from app.utils.decorators import (supervisor_required, project_manager_required,
|
||||
issue_manager_required)
|
||||
issue_manager_required, return_url)
|
||||
from app.utils.notifications import notify, notify_customers_for_facility, notify_by_matrix
|
||||
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE, ACTION_EXPORT
|
||||
from app.tenancy.gates import quota_soft_check
|
||||
@@ -79,7 +79,7 @@ def _assignee_label(user):
|
||||
at a glance that the work is going outside the company. Display only; the
|
||||
stored value is still the user id.
|
||||
"""
|
||||
return (f'{user.display_name} (External)'
|
||||
return (f'{user.display_name} (Customer)'
|
||||
if user.is_external_inspector else user.display_name)
|
||||
|
||||
|
||||
@@ -418,7 +418,7 @@ def view(issue_id):
|
||||
comment_body = request.form.get('update_notes', '').strip()
|
||||
if not comment_body:
|
||||
flash('Comment cannot be empty.', 'warning')
|
||||
return redirect(url_for('issues.view', issue_id=issue_id))
|
||||
return redirect(_view_url(issue_id))
|
||||
comment = IssueComment(
|
||||
issue_id=issue.id,
|
||||
user_id=current_user.id,
|
||||
@@ -432,7 +432,7 @@ def view(issue_id):
|
||||
f'#{issue.id}',
|
||||
'customer comment added')
|
||||
flash('Comment posted.', 'success')
|
||||
return redirect(url_for('issues.view', issue_id=issue_id))
|
||||
return redirect(_view_url(issue_id))
|
||||
|
||||
form = IssueUpdateForm(obj=issue)
|
||||
staff = User.query.filter(User.role.in_(['director', 'inspector', 'external_inspector', 'auditor'])).order_by(User.username).all()
|
||||
@@ -666,10 +666,16 @@ def view(issue_id):
|
||||
f'#{issue.id} in {issue.area.name if issue.area else issue.resolved_facility.name if issue.resolved_facility else '—'}',
|
||||
f'status={issue.status}; assigned_to={issue.assigned_to}')
|
||||
flash('Issue updated.', 'success')
|
||||
return redirect(url_for('issues.view', issue_id=issue_id))
|
||||
return redirect(_view_url(issue_id))
|
||||
|
||||
is_following = issue.is_followed_by(current_user)
|
||||
if current_user.role == 'customer':
|
||||
# TEMPORARY (Aug 2026) — COMMENTS_VISIBLE_TO_ALL lifts the phase22
|
||||
# restriction so customers see every comment on the issue, not only the
|
||||
# ones ticked "Share with customer". is_customer_visible is still recorded
|
||||
# on every comment, so setting the flag back to false restores the old
|
||||
# filtering with nothing to repair. See config.py.
|
||||
comments_open = current_app.config.get('COMMENTS_VISIBLE_TO_ALL', False)
|
||||
if current_user.role == 'customer' and not comments_open:
|
||||
comments = (issue.comments
|
||||
.filter_by(is_customer_visible=True)
|
||||
.order_by(IssueComment.created_at.asc()).all())
|
||||
@@ -679,6 +685,7 @@ def view(issue_id):
|
||||
issue=issue,
|
||||
form=form,
|
||||
comments=comments,
|
||||
comments_open=comments_open,
|
||||
is_following=is_following)
|
||||
|
||||
|
||||
@@ -701,7 +708,7 @@ def follow(issue_id):
|
||||
flash('You are now following this issue and will receive notifications for any updates.', 'success')
|
||||
else:
|
||||
flash('You are already following this issue.', 'info')
|
||||
return redirect(url_for('issues.view', issue_id=issue_id))
|
||||
return redirect(_view_url(issue_id))
|
||||
|
||||
|
||||
# ── Unfollow ──────────────────────────────────────────────────────────────────
|
||||
@@ -855,7 +862,7 @@ def create():
|
||||
)
|
||||
db.session.commit()
|
||||
flash('Issue created.', 'success')
|
||||
return redirect(url_for('issues.index'))
|
||||
return redirect(return_url(url_for('issues.index')))
|
||||
|
||||
return render_template('issues/form.html', form=form, title='Log New Issue',
|
||||
projects=projects, selected_project_id=selected_project_id,
|
||||
@@ -875,7 +882,7 @@ def verify(issue_id):
|
||||
|
||||
if issue.status not in ('resolved', 'pending_verification'):
|
||||
flash('Only resolved or pending-verification issues can be verified.', 'warning')
|
||||
return redirect(url_for('issues.view', issue_id=issue_id))
|
||||
return redirect(_view_url(issue_id))
|
||||
|
||||
note = request.form.get('verification_note', '').strip() or None
|
||||
|
||||
@@ -895,7 +902,21 @@ def verify(issue_id):
|
||||
f'#{issue_id} in {issue.area.name if issue.area else issue.resolved_facility.name if issue.resolved_facility else '—'}',
|
||||
f'verified_by={current_user.username}')
|
||||
flash(f'Issue #{issue_id} verified and closed.', 'success')
|
||||
return redirect(url_for('issues.view', issue_id=issue_id))
|
||||
return redirect(_view_url(issue_id))
|
||||
|
||||
|
||||
def _view_url(issue_id):
|
||||
"""issues.view URL that carries the list `next` through.
|
||||
|
||||
An update posted from the detail page redirects back to that same detail
|
||||
page; without re-attaching `next`, the Back button would lose the filters
|
||||
the user arrived with and the next action from this page would too. Only
|
||||
added when there is something to carry, so ordinary links stay clean.
|
||||
"""
|
||||
nxt = request.form.get('next') or request.args.get('next')
|
||||
if nxt:
|
||||
return url_for('issues.view', issue_id=issue_id, next=nxt)
|
||||
return url_for('issues.view', issue_id=issue_id)
|
||||
|
||||
|
||||
@bp.route('/bulk-verify', methods=['POST'])
|
||||
@@ -930,7 +951,205 @@ def bulk_verify():
|
||||
f'bulk_verified_by={current_user.username}')
|
||||
|
||||
flash(f'{verified_count} issue{"s" if verified_count != 1 else ""} verified and closed.', 'success')
|
||||
return redirect(url_for('issues.verification_queue'))
|
||||
# Reachable from BOTH the verification queue and the issues list, so honour
|
||||
# the caller's `next` and fall back to the queue as before.
|
||||
return redirect(return_url(url_for('issues.verification_queue')))
|
||||
|
||||
|
||||
# ── Bulk actions from the issues list ────────────────────────────────────────
|
||||
|
||||
#: Statuses a bulk status change may set, and what an issue must already be in
|
||||
#: for the change to mean anything. Moving an issue to the state it is already
|
||||
#: in is a no-op, so it counts as skipped rather than changed.
|
||||
_BULK_STATUSES = ('open', 'in_progress', 'resolved', 'pending_verification')
|
||||
|
||||
|
||||
@bp.route('/bulk', methods=['POST'])
|
||||
@login_required
|
||||
def bulk_action():
|
||||
"""Apply one action to every ticked issue on the list page.
|
||||
|
||||
Partial-failure policy (matches bulk_verify): act on every eligible row,
|
||||
skip the rest, and report exact counts — never silently drop rows, and
|
||||
never let one ineligible row block the batch.
|
||||
|
||||
Permission is checked per ACTION here rather than per row: all four actions
|
||||
are manager-level, and the roles that hold them have org-wide issue access,
|
||||
so there is no per-row scope question to answer. `skipped` therefore only
|
||||
ever means "this row was not in a state the action applies to".
|
||||
"""
|
||||
back = return_url(url_for('issues.index'))
|
||||
action = request.form.get('action', '')
|
||||
ids = request.form.getlist('issue_ids', type=int)
|
||||
|
||||
if not ids:
|
||||
flash('No issues selected.', 'warning')
|
||||
return redirect(back)
|
||||
|
||||
manager = current_user.role in ('admin', 'director', 'auditor')
|
||||
deleter = current_user.role in ('admin', 'director')
|
||||
|
||||
allowed = {
|
||||
'assign': manager,
|
||||
'status': manager,
|
||||
'verify': manager,
|
||||
'delete': deleter,
|
||||
}
|
||||
if action not in allowed:
|
||||
flash('Unknown bulk action.', 'danger')
|
||||
return redirect(back)
|
||||
if not allowed[action]:
|
||||
flash('You do not have permission for that bulk action.', 'danger')
|
||||
return redirect(back)
|
||||
|
||||
issues = [i for i in (db.session.get(Issue, i_id) for i_id in ids) if i is not None]
|
||||
missing = len(ids) - len(issues)
|
||||
changed = 0
|
||||
skipped = missing
|
||||
|
||||
# ── Assign ───────────────────────────────────────────────────────────
|
||||
if action == 'assign':
|
||||
raw = request.form.get('assigned_to', '')
|
||||
user = None
|
||||
if raw and raw != '0':
|
||||
user = db.session.get(User, int(raw)) if raw.isdigit() else None
|
||||
if user is None:
|
||||
flash('That user no longer exists.', 'danger')
|
||||
return redirect(back)
|
||||
|
||||
# Track what actually moved. Re-deriving this after the commit by
|
||||
# testing `issue.assigned_to == user.id` would also match the issues
|
||||
# that were ALREADY assigned to that person — they were counted as
|
||||
# skipped, but would still be emailed "assigned to you" every time
|
||||
# anyone ran a bulk assign over them.
|
||||
newly_assigned = []
|
||||
for issue in issues:
|
||||
if issue.assigned_to == (user.id if user else None):
|
||||
skipped += 1
|
||||
continue
|
||||
issue.assigned_to = user.id if user else None
|
||||
newly_assigned.append(issue)
|
||||
changed += 1
|
||||
db.session.commit()
|
||||
|
||||
if user:
|
||||
for issue in newly_assigned:
|
||||
notify(
|
||||
recipient = user,
|
||||
title = f'Issue #{issue.id} assigned to you',
|
||||
body = (f'{issue.severity.title()}-severity issue at '
|
||||
f'{issue.resolved_facility.name if issue.resolved_facility else "—"}: '
|
||||
f'{issue.description[:120]}'),
|
||||
link = url_for('issues.view', issue_id=issue.id),
|
||||
issue_id = issue.id,
|
||||
event_type = EVENT_ISSUE_ASSIGNED,
|
||||
send_email = True,
|
||||
)
|
||||
db.session.commit() # notify() does not commit — rule 70
|
||||
|
||||
label = user.display_name if user else 'Unassigned'
|
||||
log_action(ACTION_UPDATE, 'Issue', None, f'bulk assign → {label}',
|
||||
f'ids={[i.id for i in issues]}; changed={changed}')
|
||||
_flash_bulk(changed, skipped, f'assigned to {label}')
|
||||
|
||||
# ── Status ───────────────────────────────────────────────────────────
|
||||
elif action == 'status':
|
||||
new_status = request.form.get('status', '')
|
||||
if new_status not in _BULK_STATUSES:
|
||||
flash('Please choose a status to set.', 'warning')
|
||||
return redirect(back)
|
||||
|
||||
# (issue, old_status) for the audit pass, which must run AFTER the
|
||||
# commit — log_action() commits internally (rule 41), so calling it
|
||||
# inside this loop would commit each row separately and lose the
|
||||
# batch's atomicity.
|
||||
moved = []
|
||||
for issue in issues:
|
||||
if issue.status == new_status:
|
||||
skipped += 1
|
||||
continue
|
||||
old = issue.status
|
||||
moved.append((issue, old))
|
||||
issue.status = new_status
|
||||
# Keep resolved_at consistent with the status, the same way the
|
||||
# single-issue update does — a resolved issue with no resolved_at
|
||||
# breaks the SLA compliance report and the aging buckets.
|
||||
if new_status == 'resolved' and not issue.resolved_at:
|
||||
issue.resolved_at = now_eastern()
|
||||
elif new_status in ('open', 'in_progress'):
|
||||
issue.resolved_at = None
|
||||
changed += 1
|
||||
db.session.commit()
|
||||
for issue, old in moved:
|
||||
log_action(ACTION_UPDATE, 'Issue', issue.id, f'#{issue.id}',
|
||||
f'bulk status {old} → {new_status} by {current_user.username}')
|
||||
_flash_bulk(changed, skipped,
|
||||
f'set to {new_status.replace("_", " ").title()}')
|
||||
|
||||
# ── Verify & close ───────────────────────────────────────────────────
|
||||
elif action == 'verify':
|
||||
verified = []
|
||||
for issue in issues:
|
||||
if issue.status not in ('resolved', 'pending_verification'):
|
||||
skipped += 1
|
||||
continue
|
||||
issue.status = 'resolved'
|
||||
issue.verified_by = current_user.id
|
||||
issue.verified_at = now_eastern()
|
||||
if not issue.resolved_at:
|
||||
issue.resolved_at = now_eastern()
|
||||
verified.append(issue)
|
||||
changed += 1
|
||||
db.session.commit()
|
||||
for issue in verified: # after the commit — rule 41
|
||||
log_action(ACTION_UPDATE, 'Issue', issue.id, f'#{issue.id}',
|
||||
f'bulk_verified_by={current_user.username}')
|
||||
_flash_bulk(changed, skipped, 'verified and closed',
|
||||
skip_reason='not awaiting verification')
|
||||
|
||||
# ── Delete ───────────────────────────────────────────────────────────
|
||||
elif action == 'delete':
|
||||
from app.utils import storage
|
||||
photo_paths = []
|
||||
# Snapshot the ids BEFORE deleting — the objects are expired after the
|
||||
# commit, and the audit pass has to run after it (rule 41: log_action
|
||||
# commits internally, so auditing inside this loop would commit the
|
||||
# deletes one at a time and, on a mid-loop failure, leave rows gone
|
||||
# with the photo cleanup below never reached).
|
||||
deleted_ids = []
|
||||
for issue in issues:
|
||||
if issue.photo_path:
|
||||
photo_paths.append(issue.photo_path)
|
||||
for lst in (issue.mobile_photo_paths, issue.result_photos):
|
||||
if lst:
|
||||
photo_paths.extend(lst)
|
||||
deleted_ids.append(issue.id)
|
||||
db.session.delete(issue)
|
||||
changed += 1
|
||||
db.session.commit()
|
||||
for issue_id in deleted_ids:
|
||||
log_action(ACTION_DELETE, 'Issue', issue_id, f'#{issue_id}',
|
||||
f'bulk deleted by {current_user.username}')
|
||||
# Files go only after the rows are safely gone — a failure here leaves
|
||||
# an orphaned file, which is recoverable; the reverse is not.
|
||||
for rel_path in photo_paths:
|
||||
storage.delete(rel_path)
|
||||
_flash_bulk(changed, skipped, 'permanently deleted')
|
||||
|
||||
logger.info('ISSUES | bulk | action=%s user=%s selected=%s changed=%s skipped=%s',
|
||||
action, current_user.username, len(ids), changed, skipped)
|
||||
return redirect(back)
|
||||
|
||||
|
||||
def _flash_bulk(changed, skipped, verb, skip_reason='no change needed'):
|
||||
"""One consistent result message for every bulk action."""
|
||||
if not changed and not skipped:
|
||||
flash('Nothing to do.', 'info')
|
||||
return
|
||||
parts = [f'{changed} issue{"s" if changed != 1 else ""} {verb}']
|
||||
if skipped:
|
||||
parts.append(f'{skipped} skipped ({skip_reason})')
|
||||
flash('. '.join(parts) + '.', 'success' if changed else 'warning')
|
||||
|
||||
|
||||
@bp.route('/<int:issue_id>/request-verification', methods=['POST'])
|
||||
@@ -952,11 +1171,11 @@ def request_verification(issue_id):
|
||||
)
|
||||
if not can_act:
|
||||
flash('Access denied.', 'danger')
|
||||
return redirect(url_for('issues.view', issue_id=issue_id))
|
||||
return redirect(_view_url(issue_id))
|
||||
|
||||
if issue.status not in ('in_progress',):
|
||||
flash('Issue must be in progress to request verification.', 'warning')
|
||||
return redirect(url_for('issues.view', issue_id=issue_id))
|
||||
return redirect(_view_url(issue_id))
|
||||
|
||||
issue.status = 'pending_verification'
|
||||
db.session.commit()
|
||||
@@ -984,7 +1203,7 @@ def request_verification(issue_id):
|
||||
)
|
||||
db.session.commit()
|
||||
flash('Issue marked as pending verification. Supervisors have been notified.', 'info')
|
||||
return redirect(url_for('issues.view', issue_id=issue_id))
|
||||
return redirect(_view_url(issue_id))
|
||||
|
||||
# ── Verification queue ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1077,7 +1296,7 @@ def delete(issue_id):
|
||||
f'facility={facility_name}; description={issue_desc}')
|
||||
|
||||
flash(f'Issue #{issue_id_snap} has been permanently deleted.', 'success')
|
||||
return redirect(url_for('issues.index'))
|
||||
return redirect(return_url(url_for('issues.index')))
|
||||
|
||||
|
||||
# ── Quick-assign (AJAX) ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -940,7 +940,10 @@ def export_inspector_performance():
|
||||
for row_idx, s in enumerate(inspector_stats, start=3):
|
||||
stripe = sub_fill if row_idx % 2 == 0 else None
|
||||
row_data = [
|
||||
s['display_name'],
|
||||
# Customer-employed inspectors share this table with our own crew;
|
||||
# suffixed rather than given a column so the index-based styling
|
||||
# below (score = col 5, vs_avg = col 6, …) stays correct.
|
||||
s['display_name'] + (' (Customer)' if s.get('external') else ''),
|
||||
s['total'],
|
||||
s['completed'],
|
||||
s['completion_rate'],
|
||||
|
||||
+202
-26
@@ -12,7 +12,7 @@ from app.models.support import (SupportTicket, SupportTicketReply,
|
||||
from app.models.user import User
|
||||
from app.models.facility import Facility
|
||||
from app.utils.decorators import supervisor_required
|
||||
from app.utils.scope import get_customer_scope
|
||||
from app.utils.scope import get_customer_scope, get_inspector_scope
|
||||
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE
|
||||
from app.utils.time_utils import now_eastern
|
||||
from app.utils.notifications import notify
|
||||
@@ -69,6 +69,12 @@ Help customers with:
|
||||
|
||||
Rules:
|
||||
- Keep answers concise (3-5 sentences max) and friendly.
|
||||
- Ground answers in everything above, INCLUDING the "ADDITIONAL KNOWLEDGE" section when \
|
||||
one is present — that section is curated by the provider's team and is authoritative. \
|
||||
If it answers the question, use it.
|
||||
- When the knowledge above contains a link (URL), email address or exact wording, quote \
|
||||
it EXACTLY as written. Repeating something given to you here is not inventing — do it \
|
||||
freely. Never alter a URL, shorten it, or replace it with a description.
|
||||
- Never invent specific staff names, contract prices, schedules, or contact numbers.
|
||||
- If the customer has an access problem, billing question, or a concern you genuinely \
|
||||
cannot resolve through guidance, say so clearly and suggest they click \
|
||||
@@ -85,23 +91,161 @@ FAQS = [
|
||||
{'icon': 'bi-megaphone', 'text': 'How do I report a cleaning concern?'},
|
||||
{'icon': 'bi-alarm', 'text': 'What is SLA and how does it work?'},
|
||||
{'icon': 'bi-bell', 'text': 'How do I get notified on issue updates?'},
|
||||
{'icon': 'bi-phone', 'text': 'Can our own staff use the JQC app to conduct inspections?'},
|
||||
]
|
||||
|
||||
#: Extra chips shown to a Customer Inspector, whose questions are about doing
|
||||
#: the work rather than reading the results. Appended to FAQS, not replacing
|
||||
#: them — they still care about scores and issues.
|
||||
INSPECTOR_FAQS = [
|
||||
{'icon': 'bi-clipboard-plus', 'text': 'How do I start an inspection on the iPad?'},
|
||||
{'icon': 'bi-wifi-off', 'text': 'What happens if I lose signal during an inspection?'},
|
||||
{'icon': 'bi-flag', 'text': 'How do I flag an issue while inspecting?'},
|
||||
{'icon': 'bi-search', 'text': "Why can't I see a form for this facility?"},
|
||||
]
|
||||
|
||||
|
||||
#: Groq model used when GROQ_MODEL is unset. Verified available Aug 2026.
|
||||
#: Groq RETIRES models without notice, and when the configured one disappears
|
||||
#: every question fails with the generic "problem reaching the AI assistant"
|
||||
#: reply — invisible until a customer complains. That is exactly how
|
||||
#: llama-3.3-70b-versatile took the chat down. See the error handler in
|
||||
#: chat_message(): it names the model and says to set GROQ_MODEL, which fixes
|
||||
#: it with an env change and a restart — no deploy.
|
||||
_DEFAULT_GROQ_MODEL = 'openai/gpt-oss-120b'
|
||||
|
||||
|
||||
def _is_customer_side(user):
|
||||
"""True for both customer-side roles — Director and Customer Inspector.
|
||||
|
||||
The AI assistant and the ticket flow are for the CUSTOMER organisation, and
|
||||
a Customer Inspector is part of it: they work at the customer's facilities
|
||||
and have the same questions about scores, issues and the app. This is one
|
||||
of the few places where User.CUSTOMER_ROLES is the right test; every
|
||||
capability/scoping decision below still branches per role (see
|
||||
_support_facilities and _system_prompt_for) — the two roles get the same
|
||||
DOOR, not the same answers.
|
||||
"""
|
||||
return getattr(user, 'is_customer_account', False)
|
||||
|
||||
|
||||
def _support_facilities(user):
|
||||
"""The facilities this user may pick on a support ticket.
|
||||
|
||||
Directors are scoped by CustomerAssignment, Customer Inspectors by
|
||||
InspectorAssignment — reusing the customer helper for both would silently
|
||||
return nothing for an inspector (it returns None for any non-'customer'
|
||||
role) and the facility dropdown would come up empty.
|
||||
"""
|
||||
if getattr(user, 'is_inspector', False):
|
||||
fids = get_inspector_scope(user) or []
|
||||
else:
|
||||
fids = get_customer_scope(user) or []
|
||||
if not fids:
|
||||
return []
|
||||
return (Facility.query
|
||||
.filter(Facility.id.in_(fids), Facility.active == True)
|
||||
.order_by(Facility.name).all())
|
||||
|
||||
|
||||
#: Appended to the system prompt for a Customer Inspector. The base prompt is
|
||||
#: written for the read-mostly portal customer and explicitly tells the model
|
||||
#: NOT to describe staff actions; without this the assistant would deny a
|
||||
#: Customer Inspector the very things they are employed to do.
|
||||
_INSPECTOR_ADDENDUM = """
|
||||
|
||||
=== ABOUT THE PERSON YOU ARE TALKING TO: CUSTOMER INSPECTOR ===
|
||||
This user works FOR the customer but holds an inspecting role in JQC, limited to
|
||||
the contracts they have been assigned. This section OVERRIDES the "only describe
|
||||
what a customer can do" restriction above, for this user only.
|
||||
|
||||
Everything above about the portal still applies to their assigned facilities. IN
|
||||
ADDITION, they can:
|
||||
- Conduct inspections themselves — start one on the web (Inspections -> New
|
||||
Inspection) or in the JQC iPad app, fill in the checklist form, add photos, and
|
||||
submit it.
|
||||
- Use the iPad app OFFLINE: inspections and photos are stored on the device and
|
||||
sync automatically when back online.
|
||||
- Flag an issue during an inspection, and log new issues at their facilities.
|
||||
- Assign an issue to an inspector working on the SAME contract (their own
|
||||
colleagues, or the provider's inspectors) — never to anyone outside it.
|
||||
- Update an issue's status, add comments, and set "Handled By"
|
||||
(Janitorial Staff / Facility Staff / External Vendor) from the iPad.
|
||||
- Work from Scheduled Inspections assigned to them.
|
||||
|
||||
They CANNOT: verify or close out issues (the provider's admin/director does that),
|
||||
manage users, create or edit inspection forms, change the notification matrix, or
|
||||
see anything outside their assigned contracts. If they ask for one of those, say
|
||||
who to ask instead — their own Customer Director, or the provider's team via
|
||||
"Submit to Support".
|
||||
|
||||
Note on forms: the inspection forms they can choose from are the shared standard
|
||||
forms plus any built specifically for their contract. A form built for a different
|
||||
customer will never appear.
|
||||
"""
|
||||
|
||||
|
||||
#: The curated knowledge is spliced in immediately BEFORE this heading, not
|
||||
#: appended after it. The rules under it say "ground answers in everything
|
||||
#: above", so knowledge appended after them was, by the prompt's own
|
||||
#: instruction, out of scope — which is exactly why admin KB entries appeared
|
||||
#: to be ignored. Keep this marker in sync with the heading in _SYSTEM_PROMPT.
|
||||
_STYLE_MARKER = 'Rules:'
|
||||
|
||||
|
||||
def _system_prompt_for(user):
|
||||
"""Base prompt + curated knowledge, plus the addendum for this user's role.
|
||||
|
||||
Kept separate from _system_prompt_with_kb() so the curated knowledge base
|
||||
still lands at the same marker regardless of role.
|
||||
"""
|
||||
prompt = _system_prompt_with_kb()
|
||||
if getattr(user, 'is_external_inspector', False):
|
||||
prompt += _INSPECTOR_ADDENDUM
|
||||
return prompt
|
||||
|
||||
|
||||
def _system_prompt_with_kb():
|
||||
"""Return the Groq system prompt, appending active knowledge base entries."""
|
||||
"""Return the Groq system prompt with active knowledge entries spliced in.
|
||||
|
||||
Best-effort — a knowledge-base failure never breaks the chat.
|
||||
"""
|
||||
try:
|
||||
entries = (SupportKnowledge.query.filter_by(active=True)
|
||||
.order_by(SupportKnowledge.sort_order.asc(),
|
||||
SupportKnowledge.id.asc()).all())
|
||||
except Exception:
|
||||
if not entries:
|
||||
logger.info('SUPPORT | KB | no active entries — base prompt only')
|
||||
return _SYSTEM_PROMPT
|
||||
|
||||
parts = ['=== ADDITIONAL KNOWLEDGE (curated by the provider team; authoritative '
|
||||
'— prefer it over general guesses, and quote any link in it exactly) ===']
|
||||
total = 0
|
||||
used = 0
|
||||
for e in entries:
|
||||
block = f'\n\nTopic: {e.title}\n{(e.body or "").strip()}'
|
||||
if total + len(block) > _KB_MAX_CHARS:
|
||||
logger.warning('SUPPORT | KB | %d of %d entries dropped — %d char cap '
|
||||
'reached', len(entries) - used, len(entries), _KB_MAX_CHARS)
|
||||
break
|
||||
parts.append(block)
|
||||
total += len(block)
|
||||
used += 1
|
||||
kb_block = ''.join(parts)
|
||||
|
||||
idx = _SYSTEM_PROMPT.find(_STYLE_MARKER)
|
||||
if idx == -1: # marker renamed — fall back to append
|
||||
logger.warning('SUPPORT | KB | style marker not found; appending at end')
|
||||
prompt = f'{_SYSTEM_PROMPT}\n\n{kb_block}'
|
||||
else:
|
||||
prompt = f'{_SYSTEM_PROMPT[:idx]}{kb_block}\n\n{_SYSTEM_PROMPT[idx:]}'
|
||||
|
||||
logger.info('SUPPORT | KB | %d/%d entries injected (%d chars), prompt=%d chars',
|
||||
used, len(entries), total, len(prompt))
|
||||
return prompt
|
||||
except Exception as exc:
|
||||
logger.warning('SUPPORT | knowledge-base load failed: %s', exc)
|
||||
return _SYSTEM_PROMPT
|
||||
if not entries:
|
||||
return _SYSTEM_PROMPT
|
||||
kb_text = '\n\n'.join(f'[{e.title}]\n{e.body}' for e in entries)
|
||||
if len(kb_text) > _KB_MAX_CHARS:
|
||||
kb_text = kb_text[:_KB_MAX_CHARS] + '\n…(truncated)'
|
||||
return _SYSTEM_PROMPT + '\n\n# Additional Context\n' + kb_text
|
||||
|
||||
|
||||
# ── Customer chat page ────────────────────────────────────────────────────────
|
||||
@@ -109,13 +253,10 @@ def _system_prompt_with_kb():
|
||||
@bp.route('/chat')
|
||||
@login_required
|
||||
def chat():
|
||||
if current_user.role != 'customer':
|
||||
if not _is_customer_side(current_user):
|
||||
return redirect(url_for('support.admin_tickets'))
|
||||
|
||||
cids = get_customer_scope(current_user) or []
|
||||
facilities = (Facility.query
|
||||
.filter(Facility.id.in_(cids), Facility.active == True)
|
||||
.order_by(Facility.name).all()) if cids else []
|
||||
facilities = _support_facilities(current_user)
|
||||
|
||||
groq_ready = bool(os.environ.get('GROQ_API_KEY'))
|
||||
session_id = request.args.get('session_id', type=int)
|
||||
@@ -130,8 +271,9 @@ def chat():
|
||||
if chat_session:
|
||||
db_history = list(chat_session.messages)
|
||||
|
||||
faqs = (FAQS + INSPECTOR_FAQS) if current_user.is_external_inspector else FAQS
|
||||
return render_template('support/chat.html',
|
||||
faqs=FAQS,
|
||||
faqs=faqs,
|
||||
facilities=facilities,
|
||||
groq_ready=groq_ready,
|
||||
chat_session=chat_session,
|
||||
@@ -143,7 +285,7 @@ def chat():
|
||||
@bp.route('/chat/message', methods=['POST'])
|
||||
@login_required
|
||||
def chat_message():
|
||||
if current_user.role != 'customer':
|
||||
if not _is_customer_side(current_user):
|
||||
return jsonify({'error': 'Forbidden'}), 403
|
||||
|
||||
api_key = os.environ.get('GROQ_API_KEY')
|
||||
@@ -192,14 +334,14 @@ def chat_message():
|
||||
from groq import Groq
|
||||
client = Groq(api_key=api_key)
|
||||
|
||||
messages = [{'role': 'system', 'content': _system_prompt_with_kb()}]
|
||||
messages = [{'role': 'system', 'content': _system_prompt_for(current_user)}]
|
||||
# Redact before the text leaves the app for Groq. The unredacted
|
||||
# originals are persisted below, so nothing is lost in-app.
|
||||
for m in prior[-20:]:
|
||||
messages.append({'role': m.role, 'content': _redact_pii(m.content)})
|
||||
messages.append({'role': 'user', 'content': _redact_pii(user_message)})
|
||||
|
||||
model = os.environ.get('GROQ_MODEL', 'llama-3.3-70b-versatile')
|
||||
model = os.environ.get('GROQ_MODEL', _DEFAULT_GROQ_MODEL)
|
||||
completion = client.chat.completions.create(
|
||||
model=model,
|
||||
messages=messages,
|
||||
@@ -220,7 +362,18 @@ def chat_message():
|
||||
return jsonify({'reply': reply, 'session_id': chat_session.id})
|
||||
|
||||
except Exception as exc:
|
||||
logger.error('SUPPORT | Groq error: %s', exc)
|
||||
# Always name the model — a bare "Groq error" gives whoever reads the
|
||||
# log nothing to act on, and a retired model is the most likely cause
|
||||
# of a total outage here.
|
||||
_model = locals().get('model') or os.environ.get('GROQ_MODEL', _DEFAULT_GROQ_MODEL)
|
||||
if 'model_not_found' in str(exc) or 'does not exist' in str(exc):
|
||||
logger.error(
|
||||
'SUPPORT | Groq model %r is not available on this account — '
|
||||
'the assistant is DOWN for every user. Set GROQ_MODEL to a '
|
||||
'current model (see https://console.groq.com/docs/models). '
|
||||
'Underlying error: %s', _model, exc)
|
||||
else:
|
||||
logger.error('SUPPORT | Groq error (model=%r): %s', _model, exc)
|
||||
db.session.rollback()
|
||||
return jsonify({'reply': (
|
||||
"I ran into a problem reaching the AI assistant. "
|
||||
@@ -233,7 +386,7 @@ def chat_message():
|
||||
@bp.route('/my-conversations')
|
||||
@login_required
|
||||
def my_conversations():
|
||||
if current_user.role != 'customer':
|
||||
if not _is_customer_side(current_user):
|
||||
abort(403)
|
||||
sessions = (SupportChatSession.query
|
||||
.filter_by(customer_id=current_user.id)
|
||||
@@ -245,7 +398,7 @@ def my_conversations():
|
||||
@bp.route('/my-conversations/<int:session_id>')
|
||||
@login_required
|
||||
def my_conversation_detail(session_id):
|
||||
if current_user.role != 'customer':
|
||||
if not _is_customer_side(current_user):
|
||||
abort(403)
|
||||
chat_session = db.session.get(SupportChatSession, session_id)
|
||||
if chat_session is None or chat_session.customer_id != current_user.id:
|
||||
@@ -261,7 +414,7 @@ def my_conversation_detail(session_id):
|
||||
@bp.route('/tickets', methods=['POST'])
|
||||
@login_required
|
||||
def submit_ticket():
|
||||
if current_user.role != 'customer':
|
||||
if not _is_customer_side(current_user):
|
||||
abort(403)
|
||||
|
||||
subject = request.form.get('subject', '').strip()
|
||||
@@ -272,8 +425,9 @@ def submit_ticket():
|
||||
flash('Please fill in both subject and description.', 'warning')
|
||||
return redirect(url_for('support.chat'))
|
||||
|
||||
# Validate facility belongs to this customer
|
||||
cids = get_customer_scope(current_user) or []
|
||||
# Validate the facility belongs to this user — by whichever assignment
|
||||
# table their role is scoped through.
|
||||
cids = [f.id for f in _support_facilities(current_user)]
|
||||
if facility_id and facility_id not in cids:
|
||||
facility_id = None
|
||||
|
||||
@@ -303,7 +457,7 @@ def submit_ticket():
|
||||
@bp.route('/my-tickets')
|
||||
@login_required
|
||||
def my_tickets():
|
||||
if current_user.role != 'customer':
|
||||
if not _is_customer_side(current_user):
|
||||
abort(403)
|
||||
|
||||
tickets = (SupportTicket.query
|
||||
@@ -318,7 +472,7 @@ def my_tickets():
|
||||
@bp.route('/my-tickets/<int:ticket_id>', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def my_ticket_detail(ticket_id):
|
||||
if current_user.role != 'customer':
|
||||
if not _is_customer_side(current_user):
|
||||
abort(403)
|
||||
|
||||
ticket = db.session.get(SupportTicket, ticket_id)
|
||||
@@ -526,6 +680,28 @@ def admin_knowledge():
|
||||
return render_template('support/admin_knowledge.html', entries=entries)
|
||||
|
||||
|
||||
@bp.route('/admin/knowledge/preview')
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def admin_knowledge_preview():
|
||||
"""Show the exact system prompt the chatbot receives, knowledge included.
|
||||
|
||||
Added after admin entries appeared to be ignored: without this there is no
|
||||
way to tell "my entry never reached the prompt" from "the model saw it and
|
||||
chose not to use it". Read-only, builds nothing of its own — it calls the
|
||||
same _system_prompt_with_kb() the chat endpoint calls.
|
||||
"""
|
||||
prompt = _system_prompt_with_kb()
|
||||
active_count = SupportKnowledge.query.filter_by(active=True).count()
|
||||
total_count = SupportKnowledge.query.count()
|
||||
return render_template('support/admin_knowledge_preview.html',
|
||||
prompt=prompt,
|
||||
active_count=active_count,
|
||||
total_count=total_count,
|
||||
kb_included='=== ADDITIONAL KNOWLEDGE' in prompt,
|
||||
kb_cap=_KB_MAX_CHARS)
|
||||
|
||||
|
||||
def _parse_sort_order(raw, fallback=0):
|
||||
"""Coerce a submitted sort_order to a sane int.
|
||||
|
||||
|
||||
+64
-11
@@ -2,7 +2,8 @@ import logging
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify, abort
|
||||
from flask_login import login_required, current_user
|
||||
from app import db
|
||||
from app.models.inspection import InspectionTemplate, ChecklistItem
|
||||
from app.models.inspection import InspectionTemplate, ChecklistItem, TemplateContract
|
||||
from app.models.project import Project
|
||||
from app.utils.forms import InspectionTemplateForm, ChecklistItemForm
|
||||
from app.utils.decorators import supervisor_required
|
||||
import json
|
||||
@@ -13,6 +14,17 @@ bp = Blueprint('templates', __name__, url_prefix='/templates')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _populate_contract_choices(form):
|
||||
"""Contract options for the "Available on contracts" multi-select.
|
||||
|
||||
Selecting none leaves the form SHARED (usable on every contract) — that is
|
||||
the default and what every template did before phase52. See
|
||||
TemplateContract.
|
||||
"""
|
||||
contracts = Project.query.filter_by(active=True).order_by(Project.name).all()
|
||||
form.contract_ids.choices = [(p.id, p.name) for p in contracts]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Template CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -21,7 +33,12 @@ logger = logging.getLogger(__name__)
|
||||
@login_required
|
||||
def index():
|
||||
templates = InspectionTemplate.query.order_by(InspectionTemplate.name).all()
|
||||
return render_template('templates/list.html', templates=templates)
|
||||
# Contract options for the Edit Template modal's "Available on contracts"
|
||||
# picker (phase52) — this modal is the edit UI reached from the list, so it
|
||||
# needs the same control the full editor has.
|
||||
contracts = Project.query.filter_by(active=True).order_by(Project.name).all()
|
||||
return render_template('templates/list.html',
|
||||
templates=templates, contracts=contracts)
|
||||
|
||||
|
||||
@bp.route('/new', methods=['GET', 'POST'])
|
||||
@@ -29,6 +46,7 @@ def index():
|
||||
@supervisor_required
|
||||
def create_template():
|
||||
form = InspectionTemplateForm()
|
||||
_populate_contract_choices(form)
|
||||
|
||||
if form.validate_on_submit():
|
||||
template = InspectionTemplate(
|
||||
@@ -38,11 +56,15 @@ def create_template():
|
||||
created_by=current_user.id
|
||||
)
|
||||
db.session.add(template)
|
||||
db.session.flush() # need template.id before linking contracts
|
||||
template.set_contracts(form.contract_ids.data)
|
||||
db.session.commit()
|
||||
logger.info('TEMPLATES | create | user=%s | template_id=%s name=%r',
|
||||
current_user.username, template.id, template.name)
|
||||
logger.info('TEMPLATES | create | user=%s | template_id=%s name=%r contracts=%s',
|
||||
current_user.username, template.id, template.name,
|
||||
template.contract_ids or 'shared')
|
||||
log_action(ACTION_CREATE, 'Template', template.id, template.name,
|
||||
f'frequency={template.frequency}')
|
||||
f'frequency={template.frequency}; '
|
||||
f'contracts={template.contract_ids or "shared"}')
|
||||
|
||||
flash(f'Template "{template.name}" created successfully.', 'success')
|
||||
return redirect(url_for('templates.form_editor', template_id=template.id))
|
||||
@@ -72,16 +94,23 @@ def edit_template(template_id):
|
||||
if template is None:
|
||||
abort(404)
|
||||
form = InspectionTemplateForm(obj=template)
|
||||
_populate_contract_choices(form)
|
||||
if request.method == 'GET':
|
||||
# obj= cannot read the association rows; seed the multi-select from them.
|
||||
form.contract_ids.data = template.contract_ids
|
||||
|
||||
if form.validate_on_submit():
|
||||
template.name = form.name.data
|
||||
template.description = form.description.data
|
||||
template.frequency = form.frequency.data
|
||||
template.set_contracts(form.contract_ids.data)
|
||||
db.session.commit()
|
||||
logger.info('TEMPLATES | edit | user=%s | template_id=%s name=%r',
|
||||
current_user.username, template.id, template.name)
|
||||
logger.info('TEMPLATES | edit | user=%s | template_id=%s name=%r contracts=%s',
|
||||
current_user.username, template.id, template.name,
|
||||
template.contract_ids or 'shared')
|
||||
log_action(ACTION_UPDATE, 'Template', template.id, template.name,
|
||||
f'frequency={template.frequency}')
|
||||
f'frequency={template.frequency}; '
|
||||
f'contracts={template.contract_ids or "shared"}')
|
||||
flash(f'Template "{template.name}" updated successfully.', 'success')
|
||||
return redirect(url_for('templates.view_template', template_id=template.id))
|
||||
|
||||
@@ -120,11 +149,29 @@ def rename_template(template_id):
|
||||
template.description = request.form.get('description', '').strip() or None
|
||||
template.frequency = new_frequency
|
||||
|
||||
# phase52 — contract restrictions are edited from this modal too, since it
|
||||
# is the Edit Template dialog people actually reach from the list. The
|
||||
# hidden marker distinguishes "the form posted an empty selection" (make
|
||||
# the template shared) from "the form has no contracts field at all", which
|
||||
# must leave the existing restrictions untouched rather than silently
|
||||
# sharing the template with every customer.
|
||||
if request.form.get('contracts_present') == '1':
|
||||
valid_pids = {
|
||||
p.id for p in Project.query.filter_by(active=True).all()
|
||||
}
|
||||
posted = {
|
||||
pid for pid in request.form.getlist('contract_ids', type=int)
|
||||
if pid in valid_pids
|
||||
}
|
||||
template.set_contracts(posted)
|
||||
|
||||
db.session.commit()
|
||||
logger.info('TEMPLATES | rename | user=%s | template_id=%s name=%r',
|
||||
current_user.username, template.id, template.name)
|
||||
logger.info('TEMPLATES | rename | user=%s | template_id=%s name=%r contracts=%s',
|
||||
current_user.username, template.id, template.name,
|
||||
template.contract_ids or 'shared')
|
||||
log_action(ACTION_UPDATE, 'Template', template.id, template.name,
|
||||
f'frequency={template.frequency}; via=rename')
|
||||
f'frequency={template.frequency}; '
|
||||
f'contracts={template.contract_ids or "shared"}; via=rename')
|
||||
flash(f'Template "{template.name}" updated successfully.', 'success')
|
||||
return redirect(url_for('templates.index'))
|
||||
|
||||
@@ -188,6 +235,12 @@ def duplicate_template(template_id):
|
||||
db.session.add(new_tpl)
|
||||
db.session.flush() # get new_tpl.id before committing
|
||||
|
||||
# phase52 — carry the contract restrictions across. Duplicating a
|
||||
# customer's bespoke form must not produce a copy that is silently shared
|
||||
# with every other customer; copying a shared form still yields a shared
|
||||
# one (no links to copy).
|
||||
new_tpl.set_contracts(src.contract_ids)
|
||||
|
||||
# Duplicate all checklist items
|
||||
for item in src.checklist_items.order_by(ChecklistItem.display_order).all():
|
||||
new_item = ChecklistItem(
|
||||
|
||||
@@ -13,15 +13,31 @@
|
||||
/* Minimum 44px touch targets on interactive elements */
|
||||
.btn,
|
||||
.nav-link,
|
||||
.dropdown-item,
|
||||
input[type="checkbox"],
|
||||
input[type="radio"],
|
||||
.form-check-input {
|
||||
.dropdown-item {
|
||||
min-height: 44px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Checkboxes and radios are deliberately NOT in the rule above.
|
||||
`display: inline-flex` on a native <input type="radio"> replaces its
|
||||
intrinsic box with a flex container: the control keeps painting its glyph
|
||||
at its natural ~16px size while the element claims a 44px-tall box, so the
|
||||
visible dot and the actual hit area stop coinciding. Taps land next to the
|
||||
control and nothing happens — which is exactly how the per-account
|
||||
notification matrix (Inherit / On / Off) came to look unclickable.
|
||||
|
||||
Grow the target without touching `display`: keep the native box, scale the
|
||||
glyph up, and give it margin so neighbouring options stay separable. Any
|
||||
control that needs a genuinely large tap area should wrap the input in a
|
||||
<label> that fills the cell (see customers/manage.html). */
|
||||
input[type="checkbox"],
|
||||
input[type="radio"],
|
||||
.form-check-input {
|
||||
transform: scale(1.35);
|
||||
margin: 6px;
|
||||
}
|
||||
|
||||
/* Slightly larger form controls for finger input */
|
||||
.form-control,
|
||||
.form-select {
|
||||
|
||||
@@ -80,19 +80,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# MT-15 — an External Inspector is invited by email and chooses
|
||||
their own 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 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") }}
|
||||
@@ -142,26 +133,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,116 @@
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Per-account notification matrix ─────────────────────────────────
|
||||
Overrides the global Notification Matrix for THIS account only.
|
||||
"Inherit" is the default and means "follow the global column", so it
|
||||
keeps tracking future changes there — it is not a snapshot. #}
|
||||
<div class="card shadow-sm mt-4">
|
||||
<div class="card-header bg-light fw-semibold">
|
||||
<i class="bi bi-bell me-1"></i> Notifications for this account
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted small">
|
||||
Each customer's enrollment form says which notifications their people
|
||||
want, so these can differ per person. <strong>Inherit</strong> follows
|
||||
the global Notification Matrix for
|
||||
{{ customer.role_label }}s — including any later change to it. Choose
|
||||
On or Off only where this account should differ.
|
||||
</p>
|
||||
|
||||
<form method="POST"
|
||||
action="{{ url_for('customers.save_notifications', customer_id=customer.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<style>
|
||||
/* The whole cell is the control. Padding (not min-height on the
|
||||
input) gives the touch target, so the native radio keeps its
|
||||
own box — see the note in ipad_responsive.css. */
|
||||
.matrix-opt {
|
||||
display: block;
|
||||
padding: .55rem .25rem;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
}
|
||||
.matrix-opt:hover { background: rgba(13,110,253,.06); }
|
||||
.matrix-opt input { cursor: pointer; }
|
||||
.matrix-opt-sub {
|
||||
display: block;
|
||||
font-size: .62rem;
|
||||
color: #6c757d;
|
||||
margin-top: 2px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="d-flex flex-wrap align-items-center gap-2 mb-2">
|
||||
<span class="small text-muted">Set every row:</span>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" data-matrix-all="inherit">Inherit</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-success" data-matrix-all="on">On</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger" data-matrix-all="off">Off</button>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle mb-3">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Event</th>
|
||||
<th class="text-center" style="width:110px;">Inherit</th>
|
||||
<th class="text-center" style="width:70px;">On</th>
|
||||
<th class="text-center" style="width:70px;">Off</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in matrix_rows %}
|
||||
<tr>
|
||||
<td class="small">
|
||||
{{ row.label }}
|
||||
{% if row.override is not none %}
|
||||
<span class="badge bg-warning text-dark ms-1" style="font-size:.6rem;">custom</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
{# Each option is a <label> filling its whole cell, so the
|
||||
click target is the cell rather than the ~16px glyph. A
|
||||
bare <input> in a centred <td> was effectively unclickable
|
||||
at touch/narrow widths. #}
|
||||
<td class="p-0">
|
||||
<label class="matrix-opt" title="Follow the global matrix">
|
||||
<input class="form-check-input" type="radio"
|
||||
name="event_{{ row.event }}" value="inherit"
|
||||
{% if row.override is none %}checked{% endif %}>
|
||||
<span class="matrix-opt-sub">
|
||||
currently {{ 'on' if row.global else 'off' }}
|
||||
</span>
|
||||
</label>
|
||||
</td>
|
||||
<td class="p-0">
|
||||
<label class="matrix-opt" title="Always send this to this account">
|
||||
<input class="form-check-input" type="radio"
|
||||
name="event_{{ row.event }}" value="on"
|
||||
{% if row.override is true %}checked{% endif %}>
|
||||
</label>
|
||||
</td>
|
||||
<td class="p-0">
|
||||
<label class="matrix-opt" title="Never send this to this account">
|
||||
<input class="form-check-input" type="radio"
|
||||
name="event_{{ row.event }}" value="off"
|
||||
{% if row.override is false %}checked{% endif %}>
|
||||
</label>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-sm">
|
||||
<i class="bi bi-check2 me-1"></i> Save Notification Settings
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@@ -223,8 +415,41 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// ── Customer Inspector: contract checkbox helpers ──
|
||||
const checks = document.querySelectorAll('.contract-check');
|
||||
const countBadge = document.getElementById('assignedCount');
|
||||
|
||||
function refreshCount() {
|
||||
if (!countBadge) return;
|
||||
const n = document.querySelectorAll('.contract-check:checked').length;
|
||||
countBadge.textContent = n + ' assigned';
|
||||
}
|
||||
function setAll(state) {
|
||||
checks.forEach(function (c) { c.checked = state; });
|
||||
refreshCount();
|
||||
}
|
||||
const selectAll = document.getElementById('selectAll');
|
||||
const deselectAll = document.getElementById('deselectAll');
|
||||
if (selectAll) selectAll.addEventListener('click', function () { setAll(true); });
|
||||
if (deselectAll) deselectAll.addEventListener('click', function () { setAll(false); });
|
||||
checks.forEach(function (c) { c.addEventListener('change', refreshCount); });
|
||||
|
||||
// ── Notification matrix: set every row at once ──
|
||||
document.querySelectorAll('[data-matrix-all]').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
var want = btn.getAttribute('data-matrix-all');
|
||||
document.querySelectorAll('.matrix-opt input[type=radio]').forEach(function (r) {
|
||||
if (r.value === want) { r.checked = true; }
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Customer Director: contract → facility cascade ──
|
||||
// Both selects are absent on the inspector view, so bail out rather than
|
||||
// throwing on addEventListener of null (which would kill the handlers above).
|
||||
const projSelect = document.getElementById('proj-select');
|
||||
const facSelect = document.getElementById('fac-select');
|
||||
if (!projSelect || !facSelect) return;
|
||||
|
||||
projSelect.addEventListener('change', function () {
|
||||
const projectId = this.value;
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Inspection Schedules{% endblock %}
|
||||
{% block content %}
|
||||
{# Who may create/edit/delete a schedule — mirrors schedule_manager_required
|
||||
in routes/inspection_schedules.py. 'customer' is the Customer DIRECTOR, who
|
||||
plans work for their own facilities; a Customer Inspector ('external_inspector')
|
||||
performs schedules and is covered by is_inspector below. #}
|
||||
{% set can_manage_schedules = current_user.role in
|
||||
['admin','director','project_manager','auditor','customer'] %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="bi bi-calendar2-week"></i> Inspection Schedules</h2>
|
||||
{% if current_user.role != 'inspector' %}
|
||||
{% if can_manage_schedules %}
|
||||
<a href="{{ url_for('inspection_schedules.create') }}" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle"></i> New Schedule
|
||||
</a>
|
||||
@@ -11,7 +17,7 @@
|
||||
</div>
|
||||
|
||||
<p class="text-muted small mb-4">
|
||||
{% if current_user.role == 'inspector' %}
|
||||
{% if current_user.is_inspector %}
|
||||
Inspections scheduled for you. <strong>Auto</strong> schedules appear in your
|
||||
Inspections list on their own each period; <strong>Plan</strong> schedules wait
|
||||
for you to press Start.
|
||||
@@ -134,14 +140,18 @@
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{# Start is for whoever must DO the work. A Customer Director can
|
||||
plan a schedule but never execute one — the route 403s them, so
|
||||
the button must not be offered either. #}
|
||||
{% if s.active and s.mode == 'plan'
|
||||
and (current_user.role != 'inspector' or s.inspector_id == current_user.id) %}
|
||||
and current_user.role != 'customer'
|
||||
and (not current_user.is_inspector or s.inspector_id == current_user.id) %}
|
||||
<a href="{{ url_for('inspection_schedules.start', schedule_id=s.id) }}"
|
||||
class="btn btn-sm btn-primary" title="Start this inspection now">
|
||||
<i class="bi bi-play-fill"></i> Start
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if current_user.role != 'inspector' %}
|
||||
{% if can_manage_schedules %}
|
||||
<a href="{{ url_for('inspection_schedules.edit', schedule_id=s.id) }}"
|
||||
class="btn btn-sm btn-outline-secondary" title="Edit">
|
||||
<i class="bi bi-pencil"></i>
|
||||
@@ -191,7 +201,7 @@
|
||||
{% else %}
|
||||
<i class="bi bi-calendar-x fs-1 d-block mb-3 opacity-25"></i>
|
||||
<p class="mb-3">No inspection schedules configured yet.</p>
|
||||
{% if current_user.role != 'inspector' %}
|
||||
{% if can_manage_schedules %}
|
||||
<a href="{{ url_for('inspection_schedules.create') }}" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle"></i> Create First Schedule
|
||||
</a>
|
||||
|
||||
@@ -564,7 +564,7 @@
|
||||
<option value="0">— Unassigned —</option>
|
||||
{% set staff = staff_for_flag_issue %}
|
||||
{% if staff %}{% for u in staff %}
|
||||
<option value="{{ u.id }}">{{ u.display_name }}{{ ' (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>
|
||||
|
||||
@@ -3,16 +3,18 @@
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="bi bi-clipboard-data"></i> Inspections</h2>
|
||||
{% if current_user.role != 'customer' %}
|
||||
{# Customer Directors schedule inspections for their own facilities, so the
|
||||
Scheduled link is theirs too — but starting an ad-hoc inspection is not. #}
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{{ url_for('inspection_schedules.index') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-calendar-check"></i> Scheduled
|
||||
</a>
|
||||
{% if current_user.role != 'customer' %}
|
||||
<a href="{{ url_for('inspections.start') }}" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle"></i> New Inspection
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Filters #}
|
||||
@@ -106,10 +108,15 @@
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body p-0">
|
||||
{% if inspections.items %}
|
||||
{% include 'partials/bulk_inspections_toolbar.html' %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width:34px;">
|
||||
<input type="checkbox" class="form-check-input bulk-check-all"
|
||||
title="Select all on this page" aria-label="Select all">
|
||||
</th>
|
||||
<th>#</th><th>Date</th><th>Contract</th><th>Facility</th><th>Area</th>
|
||||
<th>Template</th><th>Inspector</th><th>Score</th>
|
||||
<th>Status</th><th></th>
|
||||
@@ -118,6 +125,11 @@
|
||||
<tbody>
|
||||
{% for ins in inspections.items %}
|
||||
<tr>
|
||||
<td>
|
||||
<input type="checkbox" class="form-check-input bulk-check"
|
||||
form="inspectionsBulkForm" name="inspection_ids" value="{{ ins.id }}"
|
||||
aria-label="Select inspection #{{ ins.id }}">
|
||||
</td>
|
||||
<td><small class="text-muted">#{{ ins.id }}</small></td>
|
||||
<td>{{ ins.inspection_date.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
<td><small>{{ ins.facility.project.name if ins.facility and ins.facility.project else '—' }}</small></td>
|
||||
@@ -152,9 +164,9 @@
|
||||
</td>
|
||||
<td class="text-nowrap">
|
||||
{% if ins.status == 'in_progress' or ins.status == 'flagged' %}
|
||||
<a href="{{ url_for('inspections.execute', inspection_id=ins.id) }}" class="btn btn-sm btn-outline-primary insp-list-link">Continue</a>
|
||||
<a href="{{ url_for('inspections.execute', inspection_id=ins.id, next=current_url()) }}" class="btn btn-sm btn-outline-primary insp-list-link">Continue</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('inspections.view', inspection_id=ins.id) }}" class="btn btn-sm btn-outline-secondary insp-list-link">View</a>
|
||||
<a href="{{ url_for('inspections.view', inspection_id=ins.id, next=current_url()) }}" class="btn btn-sm btn-outline-secondary insp-list-link">View</a>
|
||||
{% endif %}
|
||||
{% if current_user.role in ['admin', 'director'] %}
|
||||
<button type="button"
|
||||
@@ -216,6 +228,7 @@
|
||||
</button>
|
||||
<form id="deleteInspectionForm" method="POST" action="" class="d-inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="next" value="{{ current_url() }}">
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<i class="bi bi-trash3-fill"></i> Delete Permanently
|
||||
</button>
|
||||
@@ -228,6 +241,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
{% include 'partials/bulk_select_js.html' %}
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
@@ -14,8 +14,14 @@
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.template_id.label(class="form-label fw-semibold") }}
|
||||
{{ form.template_id(class="form-select" + (" is-invalid" if form.template_id.errors else "")) }}
|
||||
{{ form.template_id(class="form-select" + (" is-invalid" if form.template_id.errors else ""), id="templateSelect") }}
|
||||
{% for e in form.template_id.errors %}<div class="invalid-feedback">{{ e }}</div>{% endfor %}
|
||||
{# phase52 — the list shows shared forms plus the ones attached to
|
||||
the selected contract, refreshed by JS when the contract changes. #}
|
||||
<div class="form-text">Shows forms available on the selected contract.</div>
|
||||
<div id="templateEmpty" class="form-text text-danger d-none">
|
||||
No forms are available on this contract yet.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
@@ -55,6 +61,8 @@
|
||||
<script>
|
||||
(function () {
|
||||
const projectSel = document.getElementById('projectSelect');
|
||||
const templateSel = document.getElementById('templateSelect');
|
||||
const templateEmpty = document.getElementById('templateEmpty');
|
||||
const facilitySel = document.getElementById('facilitySelect');
|
||||
const spinner = document.getElementById('facilitySpinner');
|
||||
const emptyMsg = document.getElementById('facilityEmpty');
|
||||
@@ -62,6 +70,7 @@
|
||||
const areaSel = document.getElementById('areaSelect');
|
||||
|
||||
const FACILITIES_URL = `{{ url_for('inspections.facilities_for_project', project_id=0) }}`.replace('/0', '/');
|
||||
const TEMPLATES_URL = `{{ url_for('inspections.templates_for_project', project_id=0) }}`.replace('/0', '/');
|
||||
const AREAS_URL = `{{ url_for('inspections.areas_for_facility', facility_id=0) }}`.replace('/0', '/');
|
||||
|
||||
function loadAreas(facilityId, selectedAreaId) {
|
||||
@@ -93,6 +102,28 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Forms are per-contract (phase52): a customer's bespoke form must not be
|
||||
// offered on another customer's facilities. Keeps the currently selected
|
||||
// form if it is still valid on the new contract.
|
||||
function loadTemplates(projectId) {
|
||||
if (!projectId || !templateSel) return;
|
||||
const keep = templateSel.value;
|
||||
fetch(TEMPLATES_URL + projectId)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
templateSel.innerHTML = '';
|
||||
data.forEach(t => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = t.id;
|
||||
opt.textContent = t.name;
|
||||
if (String(t.id) === keep) opt.selected = true;
|
||||
templateSel.appendChild(opt);
|
||||
});
|
||||
templateEmpty.classList.toggle('d-none', data.length > 0);
|
||||
})
|
||||
.catch(() => {}); // leave the server-rendered list in place
|
||||
}
|
||||
|
||||
function loadFacilities(projectId, selectedFacilityId, selectedAreaId) {
|
||||
if (!projectId) return;
|
||||
spinner.classList.remove('d-none');
|
||||
@@ -129,6 +160,7 @@
|
||||
|
||||
projectSel.addEventListener('change', function () {
|
||||
loadFacilities(this.value, null, null);
|
||||
loadTemplates(this.value);
|
||||
});
|
||||
|
||||
facilitySel.addEventListener('change', function () {
|
||||
|
||||
@@ -338,7 +338,11 @@
|
||||
|
||||
{# Action bar #}
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<a id="backToInspectionsBtn" href="{{ url_for('inspections.index') }}" class="btn btn-sm btn-outline-secondary">
|
||||
{# `next` carries the filtered list URL from the list page; the
|
||||
sessionStorage fallback below still covers links opened before
|
||||
this page started sending one. #}
|
||||
{% set back_url = request.args.get('next') or url_for('inspections.index') %}
|
||||
<a id="backToInspectionsBtn" href="{{ back_url }}" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Back to Inspections
|
||||
</a>
|
||||
<div class="d-flex gap-2">
|
||||
@@ -378,6 +382,7 @@
|
||||
action="{{ url_for('inspections.clear_followup', inspection_id=inspection.id) }}"
|
||||
class="d-inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="next" value="{{ back_url }}">
|
||||
<button class="btn btn-sm btn-warning">
|
||||
<i class="bi bi-flag-fill"></i> Clear Follow-up
|
||||
</button>
|
||||
@@ -386,6 +391,7 @@
|
||||
<form method="post" action="{{ url_for('inspections.delete', inspection_id=inspection.id) }}"
|
||||
onsubmit="return confirm('Delete this inspection permanently?')">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="next" value="{{ back_url }}">
|
||||
<button class="btn btn-sm btn-outline-danger"><i class="bi bi-trash3"></i> Delete</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
@@ -892,7 +898,11 @@
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
(function () {
|
||||
var backUrl = sessionStorage.getItem('insp_list_back_url');
|
||||
// A server-provided `next` is authoritative — it reflects the list this
|
||||
// page was actually opened from. Only fall back to sessionStorage when
|
||||
// there is none (e.g. a link created before `next` was threaded in).
|
||||
var hasNext = {{ 'true' if request.args.get('next') else 'false' }};
|
||||
var backUrl = hasNext ? null : sessionStorage.getItem('insp_list_back_url');
|
||||
if (backUrl) {
|
||||
var btn = document.getElementById('backToInspectionsBtn');
|
||||
if (btn) btn.href = backUrl;
|
||||
@@ -918,6 +928,7 @@ document.addEventListener('keydown', e => { if (e.key === 'Escape') closeMedia()
|
||||
<div class="modal-dialog">
|
||||
<form method="POST" action="{{ url_for('inspections.flag_followup', inspection_id=inspection.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="next" value="{{ back_url }}">
|
||||
<div class="modal-content">
|
||||
{% set is_cust = current_user.role == 'customer' %}
|
||||
<div class="modal-header">
|
||||
|
||||
@@ -115,10 +115,15 @@
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body p-0">
|
||||
{% if issues.items %}
|
||||
{% include 'partials/bulk_issues_toolbar.html' %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width:34px;">
|
||||
<input type="checkbox" class="form-check-input bulk-check-all"
|
||||
title="Select all on this page" aria-label="Select all">
|
||||
</th>
|
||||
<th>#</th>
|
||||
<th>Reported</th>
|
||||
<th>Severity</th>
|
||||
@@ -137,6 +142,11 @@
|
||||
{% set is_following = issue.id in followed_ids %}
|
||||
{% set sla = sla_status(issue) %}
|
||||
<tr class="{{ 'table-danger' if sla == 'breached' else 'table-warning' if sla == 'at_risk' else '' }}">
|
||||
<td>
|
||||
<input type="checkbox" class="form-check-input bulk-check"
|
||||
form="issuesBulkForm" name="issue_ids" value="{{ issue.id }}"
|
||||
aria-label="Select issue #{{ issue.id }}">
|
||||
</td>
|
||||
<td><small class="text-muted">#{{ issue.id }}</small></td>
|
||||
<td><small>{{ issue.reported_at.strftime('%Y-%m-%d %H:%M') }}</small></td>
|
||||
<td>
|
||||
@@ -181,7 +191,7 @@
|
||||
<select class="form-select form-select-sm quick-assign-select" style="min-width:110px;font-size:.78rem;">
|
||||
<option value="">— Unassigned —</option>
|
||||
{% for u in staff %}
|
||||
<option value="{{ u.id }}" {{ 'selected' if issue.assigned_to == u.id }}>{{ u.display_name }}{{ ' (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>
|
||||
@@ -202,7 +212,7 @@
|
||||
class="d-inline"
|
||||
title="Unfollow this issue">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="next" value="{{ url_for('issues.index', page=issues.page, issue_id=issue_id_filter, severity=severity_filter, status=status_filter, contract_id=contract_filter, facility_id=facility_filter, date_from=date_from_filter, date_to=date_to_filter, reporter_id=reporter_filter) }}">
|
||||
<input type="hidden" name="next" value="{{ current_url() }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-primary p-0 px-1 me-1"
|
||||
title="Unfollow">
|
||||
<i class="bi bi-bell-slash" style="font-size:.75rem;"></i>
|
||||
@@ -210,7 +220,7 @@
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<a href="{{ url_for('issues.view', issue_id=issue.id) }}"
|
||||
<a href="{{ url_for('issues.view', issue_id=issue.id, next=current_url()) }}"
|
||||
class="btn btn-sm btn-outline-secondary">
|
||||
{% if current_user.role in ['admin','director','auditor'] or issue.assigned_to == current_user.id %}
|
||||
<i class="bi bi-pencil"></i> Edit
|
||||
@@ -223,6 +233,7 @@
|
||||
class="d-inline"
|
||||
onsubmit="return confirm('Permanently delete Issue #{{ issue.id }}? This cannot be undone.');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="next" value="{{ current_url() }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger"
|
||||
title="Delete Issue #{{ issue.id }}">
|
||||
<i class="bi bi-trash"></i>
|
||||
@@ -259,6 +270,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
{% include 'partials/bulk_select_js.html' %}
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
@@ -19,6 +19,30 @@
|
||||
|
||||
{% block content %}
|
||||
{% set can_edit = current_user.role in ['admin','director','auditor'] or issue.assigned_to == current_user.id %}
|
||||
{# The filtered list URL this page was opened from (phase: filter
|
||||
preservation). Threaded into every action so an update or delete
|
||||
returns to the same filtered page, and used by the Back button. #}
|
||||
{% set back_url = request.args.get('next') or url_for('issues.index') %}
|
||||
{# Is the viewer OUR staff? Drives the internal-only chrome on this page: the
|
||||
"comments are visible to everyone" warning and the per-comment
|
||||
"Customer visible" / "Staff only" badges. Both are instructions about how WE
|
||||
work and must never reach a customer account.
|
||||
|
||||
Written as an explicit ALLOWLIST of our own roles, deliberately:
|
||||
|
||||
* It FAILS CLOSED. The obvious form, `not current_user.is_customer_account`,
|
||||
fails OPEN — if the attribute is missing for any reason (a process still
|
||||
running an older models/user.py after a template-only reload, say) Jinja
|
||||
yields Undefined, `not Undefined` is true, and the internal text is shown
|
||||
to exactly the people it must be hidden from. An allowlist of literal role
|
||||
strings can only ever be true for a role we listed.
|
||||
* `external_inspector` is absent ON PURPOSE. This is NOT the rule-87 case:
|
||||
rule 87 is about capability/scoping, where a Customer Inspector must
|
||||
behave exactly like our own inspector. Here the question is "does this
|
||||
person work for us?", which is the one place the two genuinely differ.
|
||||
Do not "fix" this by adding external_inspector to the list. #}
|
||||
{% set viewer_is_our_staff = current_user.role in
|
||||
['admin', 'director', 'project_manager', 'auditor', 'inspector'] %}
|
||||
|
||||
<div class="row">
|
||||
{# ══════════════════════════════════ LEFT COLUMN ══════════════════════════════════ #}
|
||||
@@ -187,6 +211,7 @@
|
||||
<strong>Awaiting director verification.</strong>
|
||||
{% if current_user.role in ['admin','director','auditor'] %}
|
||||
<form method="POST" action="{{ url_for('issues.verify', issue_id=issue.id) }}" class="mt-2">
|
||||
<input type="hidden" name="next" value="{{ back_url }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="mb-2">
|
||||
<input type="text" name="verification_note" class="form-control form-control-sm"
|
||||
@@ -230,8 +255,12 @@
|
||||
{% else %}
|
||||
<span class="badge bg-secondary" style="font-size:.65rem;">{{ c.author.role|replace('_',' ')|title }}</span>
|
||||
{% endif %}
|
||||
{# Visibility indicator — staff only #}
|
||||
{% if current_user.role != 'customer' %}
|
||||
{# Visibility indicator — OUR staff only, and only while the
|
||||
per-comment flag still decides anything. While comments_open
|
||||
is set EVERY comment reaches the customer, so a "Staff only"
|
||||
badge would be a lie; it is suppressed rather than shown
|
||||
incorrectly. #}
|
||||
{% if viewer_is_our_staff and not comments_open %}
|
||||
{% if c.is_customer_visible %}
|
||||
<span class="badge bg-success bg-opacity-10 text-success border border-success"
|
||||
style="font-size:.6rem;" title="Customer can see this comment">
|
||||
@@ -274,6 +303,7 @@
|
||||
<div class="card-body">
|
||||
<p class="fw-semibold small mb-2">Add Comment</p>
|
||||
<form method="post">
|
||||
<input type="hidden" name="next" value="{{ back_url }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="status" value="{{ issue.status }}">
|
||||
<input type="hidden" name="assigned_to" value="{{ issue.assigned_to or 0 }}">
|
||||
@@ -281,6 +311,25 @@
|
||||
<textarea name="update_notes" class="form-control" rows="3"
|
||||
placeholder="Write a comment…" required></textarea>
|
||||
</div>
|
||||
{# While comments_open is set, every comment reaches the customer, so
|
||||
the "Share with customer" tick decides nothing. Saying so plainly
|
||||
matters: a staff member must not write something they believe is
|
||||
private. The checkbox is still posted and recorded, so turning the
|
||||
setting off restores its meaning immediately.
|
||||
|
||||
OUR STAFF ONLY. `can_edit` is also true for a Customer Inspector
|
||||
assigned to the issue, and this banner is an internal-process
|
||||
warning ("do not post internal-only notes") — showing it to a
|
||||
customer account exposes how we work and reads as nonsense to
|
||||
them, since nothing they write was ever private. #}
|
||||
{% if comments_open and viewer_is_our_staff %}
|
||||
<div class="alert alert-warning py-2 px-3 small mb-2">
|
||||
<i class="bi bi-eye me-1"></i>
|
||||
<strong>Comments are currently visible to everyone,</strong> including
|
||||
the customer, regardless of the tick below. Do not post internal-only
|
||||
notes here.
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2">
|
||||
<div class="form-check form-check-inline mb-0">
|
||||
<input class="form-check-input" type="checkbox"
|
||||
@@ -301,6 +350,7 @@
|
||||
<div class="card-body">
|
||||
<p class="fw-semibold small mb-2">Add Comment</p>
|
||||
<form method="post">
|
||||
<input type="hidden" name="next" value="{{ back_url }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="mb-2">
|
||||
<textarea name="update_notes" class="form-control" rows="3"
|
||||
@@ -369,6 +419,7 @@
|
||||
<div class="card-header bg-light"><h6 class="mb-0">Update Issue</h6></div>
|
||||
<div class="card-body">
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
<input type="hidden" name="next" value="{{ back_url }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="mb-3">
|
||||
{{ form.status.label(class="form-label fw-semibold") }}
|
||||
@@ -545,7 +596,7 @@
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-items-center gap-2 mt-2">
|
||||
<a href="{{ url_for('issues.index') }}" class="btn btn-outline-secondary btn-sm">
|
||||
<a href="{{ back_url }}" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-arrow-left"></i> Back to Issues
|
||||
</a>
|
||||
<a href="{{ url_for('issues.export_pdf', issue_id=issue.id) }}" class="btn btn-outline-primary btn-sm">
|
||||
@@ -583,6 +634,7 @@
|
||||
<i class="bi bi-x-circle"></i> Cancel
|
||||
</button>
|
||||
<form method="POST" action="{{ url_for('issues.delete', issue_id=issue.id) }}" class="d-inline">
|
||||
<input type="hidden" name="next" value="{{ back_url }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<i class="bi bi-trash-fill"></i> Delete Permanently
|
||||
|
||||
@@ -193,7 +193,11 @@
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if current_user.role == 'customer' %}
|
||||
{# Both customer-side roles get the Support menu — a Customer
|
||||
Inspector works at the customer's facilities and has the
|
||||
same questions. The assistant answers them for their own
|
||||
role (see _role addendum in routes/support.py). #}
|
||||
{% if current_user.is_customer_account %}
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle {{ 'active' if request.endpoint and request.endpoint.startswith('support.') }}"
|
||||
href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
|
||||
@@ -187,25 +187,11 @@
|
||||
<i class="bi bi-bell-slash me-1"></i>Notification Preferences
|
||||
</a>
|
||||
</li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li>
|
||||
{# ── Design switcher (modern → classic) ── #}
|
||||
<form method="POST" action="{{ url_for('ui.switch_theme') }}" class="px-1">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="theme" value="classic">
|
||||
<input type="hidden" name="next" value="{{ request.full_path }}">
|
||||
<button type="submit" class="dropdown-item">
|
||||
<i class="bi bi-arrow-counterclockwise me-1"></i>Back to Classic Design
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
{% if current_user.role == 'admin' %}
|
||||
<li>
|
||||
<a class="dropdown-item" href="{{ url_for('ui.theme_votes') }}">
|
||||
<i class="bi bi-bar-chart me-1"></i>Design Vote Tally
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{# The design A/B test is over — modern is THE design (Aug 2026).
|
||||
The switcher and the vote tally are gone from this menu.
|
||||
ui.switch_theme / ui.theme_votes still exist and still work
|
||||
if visited directly, so nothing is stranded mid-request;
|
||||
they are simply no longer offered. #}
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{{ url_for('auth.logout') }}">
|
||||
|
||||
@@ -25,16 +25,18 @@
|
||||
<div>
|
||||
<div class="jqc-page-title">Inspections</div>
|
||||
</div>
|
||||
{% if current_user.role != 'customer' %}
|
||||
{# Customer Directors schedule inspections for their own facilities, so the
|
||||
Scheduled link is theirs too — but starting an ad-hoc inspection is not. #}
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{{ url_for('inspection_schedules.index') }}" class="btn btn-outline-primary">
|
||||
<i class="bi bi-calendar-check"></i> Scheduled
|
||||
</a>
|
||||
{% if current_user.role != 'customer' %}
|
||||
<a href="{{ url_for('inspections.start') }}" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle"></i> New Inspection
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# ── Filters ──────────────────────────────────────────────────────────── #}
|
||||
@@ -162,10 +164,15 @@
|
||||
{% endif %}
|
||||
|
||||
{% if inspections.items %}
|
||||
{% include 'partials/bulk_inspections_toolbar.html' %}
|
||||
<div class="jqc-table-wrap table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:34px;">
|
||||
<input type="checkbox" class="form-check-input bulk-check-all"
|
||||
title="Select all on this page" aria-label="Select all">
|
||||
</th>
|
||||
<th>#</th><th>Date</th><th>Contract</th><th>Facility</th><th>Area</th>
|
||||
<th>Template</th><th>Inspector</th><th>Score</th>
|
||||
<th>Status</th><th></th>
|
||||
@@ -174,6 +181,11 @@
|
||||
<tbody>
|
||||
{% for ins in inspections.items %}
|
||||
<tr>
|
||||
<td>
|
||||
<input type="checkbox" class="form-check-input bulk-check"
|
||||
form="inspectionsBulkForm" name="inspection_ids" value="{{ ins.id }}"
|
||||
aria-label="Select inspection #{{ ins.id }}">
|
||||
</td>
|
||||
<td><small class="text-muted">#{{ ins.id }}</small></td>
|
||||
<td class="text-nowrap">{{ ins.inspection_date.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
<td><small>{{ ins.facility.project.name if ins.facility and ins.facility.project else '—' }}</small></td>
|
||||
@@ -215,9 +227,9 @@
|
||||
</td>
|
||||
<td class="text-nowrap">
|
||||
{% if ins.status == 'in_progress' or ins.status == 'flagged' %}
|
||||
<a href="{{ url_for('inspections.execute', inspection_id=ins.id) }}" class="btn btn-sm btn-outline-primary insp-list-link">Continue</a>
|
||||
<a href="{{ url_for('inspections.execute', inspection_id=ins.id, next=current_url()) }}" class="btn btn-sm btn-outline-primary insp-list-link">Continue</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('inspections.view', inspection_id=ins.id) }}" class="btn btn-sm btn-outline-secondary insp-list-link">View</a>
|
||||
<a href="{{ url_for('inspections.view', inspection_id=ins.id, next=current_url()) }}" class="btn btn-sm btn-outline-secondary insp-list-link">View</a>
|
||||
{% endif %}
|
||||
{% if current_user.role in ['admin', 'director'] %}
|
||||
<button type="button"
|
||||
@@ -287,6 +299,7 @@
|
||||
</button>
|
||||
<form id="deleteInspectionForm" method="POST" action="" class="d-inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="next" value="{{ current_url() }}">
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<i class="bi bi-trash3-fill"></i> Delete Permanently
|
||||
</button>
|
||||
@@ -299,6 +312,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
{% include 'partials/bulk_select_js.html' %}
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
@@ -166,10 +166,15 @@
|
||||
{% endif %}
|
||||
|
||||
{% if issues.items %}
|
||||
{% include 'partials/bulk_issues_toolbar.html' %}
|
||||
<div class="jqc-table-wrap table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:34px;">
|
||||
<input type="checkbox" class="form-check-input bulk-check-all"
|
||||
title="Select all on this page" aria-label="Select all">
|
||||
</th>
|
||||
<th>#</th>
|
||||
<th>Reported</th>
|
||||
<th>Severity</th>
|
||||
@@ -188,6 +193,11 @@
|
||||
{% set is_following = issue.id in followed_ids %}
|
||||
{% set sla = sla_status(issue) %}
|
||||
<tr class="{{ 'table-danger' if sla == 'breached' else 'table-warning' if sla == 'at_risk' else '' }}">
|
||||
<td>
|
||||
<input type="checkbox" class="form-check-input bulk-check"
|
||||
form="issuesBulkForm" name="issue_ids" value="{{ issue.id }}"
|
||||
aria-label="Select issue #{{ issue.id }}">
|
||||
</td>
|
||||
<td><small class="text-muted">#{{ issue.id }}</small></td>
|
||||
<td class="text-nowrap"><small>{{ issue.reported_at.strftime('%Y-%m-%d %H:%M') }}</small></td>
|
||||
<td>
|
||||
@@ -236,7 +246,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>
|
||||
@@ -262,7 +272,7 @@
|
||||
class="d-inline"
|
||||
title="Unfollow this issue">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="next" value="{{ url_for('issues.index', page=issues.page, issue_id=issue_id_filter, severity=severity_filter, status=status_filter, contract_id=contract_filter, facility_id=facility_filter, date_from=date_from_filter, date_to=date_to_filter, reporter_id=reporter_filter, handler_type=handler_filter, unassigned=unassigned_filter) }}">
|
||||
<input type="hidden" name="next" value="{{ current_url() }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-primary p-0 px-1 me-1"
|
||||
title="Unfollow">
|
||||
<i class="bi bi-bell-slash" style="font-size:.75rem;"></i>
|
||||
@@ -270,7 +280,7 @@
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<a href="{{ url_for('issues.view', issue_id=issue.id) }}"
|
||||
<a href="{{ url_for('issues.view', issue_id=issue.id, next=current_url()) }}"
|
||||
class="btn btn-sm btn-outline-secondary">
|
||||
{% if current_user.role in ['admin','director','auditor'] or issue.assigned_to == current_user.id %}
|
||||
<i class="bi bi-pencil"></i> Edit
|
||||
@@ -283,6 +293,7 @@
|
||||
class="d-inline"
|
||||
onsubmit="return confirm('Permanently delete Issue #{{ issue.id }}? This cannot be undone.');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="next" value="{{ current_url() }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger"
|
||||
title="Delete Issue #{{ issue.id }}">
|
||||
<i class="bi bi-trash"></i>
|
||||
@@ -324,6 +335,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
{% include 'partials/bulk_select_js.html' %}
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
{# ── Bulk-action toolbar for the inspections list ─────────────────────────────
|
||||
Included by BOTH inspections/list.html and modern/inspections/list.html —
|
||||
edit here, not in either copy.
|
||||
|
||||
Same structure as the issues toolbar: the form sits OUTSIDE the table and
|
||||
row checkboxes join it via the HTML5 `form` attribute, so the per-row
|
||||
delete form inside the table is never nested (rule 9).
|
||||
|
||||
Export is offered to anyone who can see the list — it is read-only and the
|
||||
route re-applies the viewer's facility scope to the submitted ids. The three
|
||||
mutating actions are admin/director only.
|
||||
#}
|
||||
{% set can_manage = current_user.role in ['admin', 'director'] %}
|
||||
<form method="POST" id="inspectionsBulkForm"
|
||||
action="{{ url_for('inspections.bulk_action') }}"
|
||||
class="border-bottom bg-light px-3 py-2">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="next" value="{{ current_url() }}">
|
||||
|
||||
<div class="d-flex flex-wrap align-items-center gap-2">
|
||||
<span class="small fw-semibold text-nowrap">
|
||||
<span class="bulk-count">0</span> selected
|
||||
</span>
|
||||
<span class="text-muted small d-none d-md-inline">|</span>
|
||||
|
||||
<button type="submit" name="action" value="export"
|
||||
class="btn btn-sm btn-outline-secondary text-nowrap" data-bulk-action>
|
||||
<i class="bi bi-file-earmark-pdf"></i> Export Selected
|
||||
</button>
|
||||
|
||||
{% if can_manage %}
|
||||
<div class="d-flex align-items-center gap-1">
|
||||
<input type="text" name="follow_up_note" class="form-control form-control-sm"
|
||||
style="min-width:180px;font-size:.8rem;"
|
||||
placeholder="Follow-up note (optional)"
|
||||
aria-label="Follow-up note applied to all selected">
|
||||
<button type="submit" name="action" value="flag_followup"
|
||||
class="btn btn-sm btn-outline-warning text-nowrap" data-bulk-action
|
||||
data-bulk-confirm="Request a follow-up on the selected inspections? Ones not yet submitted, or already flagged, are skipped.">
|
||||
<i class="bi bi-flag"></i> Request Follow-up
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button type="submit" name="action" value="clear_followup"
|
||||
class="btn btn-sm btn-outline-success text-nowrap" data-bulk-action
|
||||
data-bulk-confirm="Clear the follow-up flag on the selected inspections?">
|
||||
<i class="bi bi-flag-fill"></i> Clear Follow-up
|
||||
</button>
|
||||
|
||||
<button type="submit" name="action" value="delete"
|
||||
class="btn btn-sm btn-outline-danger text-nowrap ms-auto" data-bulk-action
|
||||
data-bulk-confirm="Permanently delete the selected inspections and their photos? This cannot be undone.">
|
||||
<i class="bi bi-trash"></i> Delete
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
@@ -0,0 +1,79 @@
|
||||
{# ── Bulk-action toolbar for the issues list ──────────────────────────────────
|
||||
Included by BOTH issues/list.html and modern/issues/list.html — edit here,
|
||||
not in either copy.
|
||||
|
||||
The form lives OUTSIDE the table on purpose. Row checkboxes join it with the
|
||||
HTML5 `form="issuesBulkForm"` attribute instead of being wrapped by it, so
|
||||
the per-row delete / unfollow forms inside the table are never nested inside
|
||||
this one (rule 9 — browsers silently discard nested forms, and the row
|
||||
actions would stop working with no error).
|
||||
|
||||
`next` carries the current filtered list URL so the action returns here
|
||||
rather than to the bare index.
|
||||
|
||||
Requires from the view: `staff` (assignable users).
|
||||
#}
|
||||
{% set can_manage = current_user.role in ['admin', 'director', 'auditor'] %}
|
||||
{% set can_delete = current_user.role in ['admin', 'director'] %}
|
||||
{% if can_manage or can_delete %}
|
||||
<form method="POST" id="issuesBulkForm"
|
||||
action="{{ url_for('issues.bulk_action') }}"
|
||||
class="border-bottom bg-light px-3 py-2">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="next" value="{{ current_url() }}">
|
||||
|
||||
<div class="d-flex flex-wrap align-items-center gap-2">
|
||||
<span class="small fw-semibold text-nowrap">
|
||||
<span class="bulk-count">0</span> selected
|
||||
</span>
|
||||
<span class="text-muted small d-none d-md-inline">|</span>
|
||||
|
||||
{% if can_manage %}
|
||||
<div class="d-flex align-items-center gap-1">
|
||||
<select name="assigned_to" class="form-select form-select-sm"
|
||||
style="min-width:150px;font-size:.8rem;" aria-label="Assign selected to">
|
||||
<option value="0">— Unassigned —</option>
|
||||
{% for u in staff %}
|
||||
<option value="{{ u.id }}">{{ u.display_name }}{{ ' (Customer)' if u.is_external_inspector }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit" name="action" value="assign"
|
||||
class="btn btn-sm btn-outline-primary text-nowrap" data-bulk-action
|
||||
data-bulk-confirm="Assign the selected issues to the chosen user?">
|
||||
<i class="bi bi-person-check"></i> Assign
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-items-center gap-1">
|
||||
<select name="status" class="form-select form-select-sm"
|
||||
style="min-width:150px;font-size:.8rem;" aria-label="Set status of selected">
|
||||
<option value="">— Set status… —</option>
|
||||
<option value="open">Open</option>
|
||||
<option value="in_progress">In Progress</option>
|
||||
<option value="pending_verification">Pending Verification</option>
|
||||
<option value="resolved">Resolved</option>
|
||||
</select>
|
||||
<button type="submit" name="action" value="status"
|
||||
class="btn btn-sm btn-outline-primary text-nowrap" data-bulk-action
|
||||
data-bulk-confirm="Change the status of the selected issues?">
|
||||
<i class="bi bi-arrow-repeat"></i> Apply
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button type="submit" name="action" value="verify"
|
||||
class="btn btn-sm btn-outline-success text-nowrap" data-bulk-action
|
||||
data-bulk-confirm="Verify and close the selected issues? Issues that are not awaiting verification are skipped.">
|
||||
<i class="bi bi-patch-check"></i> Verify & Close
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
{% if can_delete %}
|
||||
<button type="submit" name="action" value="delete"
|
||||
class="btn btn-sm btn-outline-danger text-nowrap ms-auto" data-bulk-action
|
||||
data-bulk-confirm="Permanently delete the selected issues and their photos? This cannot be undone.">
|
||||
<i class="bi bi-trash"></i> Delete
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,77 @@
|
||||
{# ── Shared row-selection behaviour for bulk-action list pages ────────────────
|
||||
Included by the issues and inspections list templates (classic + modern).
|
||||
Generic on purpose — it keys off classes/attributes, not page-specific ids,
|
||||
so both pages share one implementation:
|
||||
|
||||
.bulk-check one per row (name=issue_ids / inspection_ids)
|
||||
.bulk-check-all the header select-all box
|
||||
.bulk-count element whose text becomes the selected count
|
||||
[data-bulk-action] submit buttons, disabled while nothing is selected
|
||||
[data-bulk-confirm] optional confirm text, count substituted for {n}
|
||||
|
||||
Guarding the submit on a zero selection matters: the browser would happily
|
||||
POST an empty id list, and the route would flash "No issues selected" after
|
||||
a full page round trip.
|
||||
#}
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var boxes = Array.prototype.slice.call(document.querySelectorAll('.bulk-check'));
|
||||
var all = document.querySelector('.bulk-check-all');
|
||||
var counts = Array.prototype.slice.call(document.querySelectorAll('.bulk-count'));
|
||||
var btns = Array.prototype.slice.call(document.querySelectorAll('[data-bulk-action]'));
|
||||
if (!boxes.length) return;
|
||||
|
||||
function selected() {
|
||||
return boxes.filter(function (b) { return b.checked; });
|
||||
}
|
||||
|
||||
function sync() {
|
||||
var n = selected().length;
|
||||
counts.forEach(function (el) { el.textContent = n; });
|
||||
btns.forEach(function (b) { b.disabled = (n === 0); });
|
||||
if (all) {
|
||||
all.checked = (n > 0 && n === boxes.length);
|
||||
// Distinguishes "some" from "none"/"all" in the header box.
|
||||
all.indeterminate = (n > 0 && n < boxes.length);
|
||||
}
|
||||
}
|
||||
|
||||
boxes.forEach(function (b) { b.addEventListener('change', sync); });
|
||||
|
||||
if (all) {
|
||||
all.addEventListener('change', function () {
|
||||
boxes.forEach(function (b) { b.checked = all.checked; });
|
||||
sync();
|
||||
});
|
||||
}
|
||||
|
||||
// Shift-click selects the range from the last clicked box — the usual
|
||||
// convention, and the difference between ticking 3 boxes and 40.
|
||||
var lastIndex = null;
|
||||
boxes.forEach(function (b, i) {
|
||||
b.addEventListener('click', function (e) {
|
||||
if (e.shiftKey && lastIndex !== null) {
|
||||
var lo = Math.min(lastIndex, i), hi = Math.max(lastIndex, i);
|
||||
for (var j = lo; j <= hi; j++) { boxes[j].checked = b.checked; }
|
||||
sync();
|
||||
}
|
||||
lastIndex = i;
|
||||
});
|
||||
});
|
||||
|
||||
btns.forEach(function (btn) {
|
||||
btn.addEventListener('click', function (e) {
|
||||
var n = selected().length;
|
||||
if (n === 0) { e.preventDefault(); return; }
|
||||
var msg = btn.getAttribute('data-bulk-confirm');
|
||||
if (msg && !window.confirm(msg.replace('{n}', n) + '\n\n' + n + ' selected.')) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
sync();
|
||||
}());
|
||||
</script>
|
||||
@@ -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')) }}"
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
<a href="{{ url_for('support.admin_tickets') }}" class="btn btn-outline-secondary btn-sm me-1">
|
||||
<i class="bi bi-inbox me-1"></i>Tickets
|
||||
</a>
|
||||
<a href="{{ url_for('support.admin_knowledge_preview') }}" class="btn btn-outline-primary btn-sm"
|
||||
title="See the exact prompt the chatbot receives, with your entries in it">
|
||||
<i class="bi bi-eye me-1"></i>What the AI Sees
|
||||
</a>
|
||||
<a href="{{ url_for('support.admin_conversations') }}" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-chat-dots me-1"></i>Conversations
|
||||
</a>
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}What the AI Sees{% endblock %}
|
||||
|
||||
{# Read-only view of the assembled system prompt. Exists so an admin can tell
|
||||
"my knowledge entry never reached the prompt" apart from "the model saw it
|
||||
and chose not to use it" — the two have completely different fixes. #}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-center mb-3 gap-2">
|
||||
<h2 class="mb-0"><i class="bi bi-eye"></i> What the AI Sees</h2>
|
||||
<a href="{{ url_for('support.admin_knowledge') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Back to Knowledge Base
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-body text-center py-3">
|
||||
<div class="fs-4 fw-bold">{{ active_count }}</div>
|
||||
<div class="text-muted small">Active entries</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-body text-center py-3">
|
||||
<div class="fs-4 fw-bold">{{ total_count - active_count }}</div>
|
||||
<div class="text-muted small">Inactive (not sent)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-body text-center py-3">
|
||||
<div class="fs-4 fw-bold">{{ prompt | length }}</div>
|
||||
<div class="text-muted small">Prompt characters</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-body text-center py-3">
|
||||
{% if kb_included %}
|
||||
<div class="fs-4 fw-bold text-success"><i class="bi bi-check-circle"></i></div>
|
||||
<div class="text-muted small">Knowledge included</div>
|
||||
{% else %}
|
||||
<div class="fs-4 fw-bold text-danger"><i class="bi bi-x-circle"></i></div>
|
||||
<div class="text-muted small">Knowledge NOT included</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if not kb_included and total_count %}
|
||||
<div class="alert alert-warning">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||
You have {{ total_count }} knowledge entr{{ 'y' if total_count == 1 else 'ies' }}, but
|
||||
none reached the prompt. Check that at least one is marked <strong>Active</strong>.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="alert alert-info">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
This is the exact text sent to the AI ahead of every customer question. Entries are
|
||||
capped at {{ kb_cap }} characters in total — past that, later entries are dropped
|
||||
(lowest sort order is kept first). If something you wrote appears here but the AI still
|
||||
will not say it, the wording of the entry is the thing to change, not the setup.
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-light fw-semibold">Assembled system prompt</div>
|
||||
<div class="card-body p-0">
|
||||
<pre class="mb-0 p-3" style="white-space:pre-wrap; font-size:.8rem; max-height:70vh;
|
||||
overflow-y:auto; background:#f8fafc;">{{ prompt }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -160,6 +160,15 @@
|
||||
{{ form.frequency.label(class="form-label fw-semibold small") }}
|
||||
{{ form.frequency(class="form-select form-select-sm") }}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
{{ form.contract_ids.label(class="form-label fw-semibold small") }}
|
||||
{{ form.contract_ids(class="form-select form-select-sm", size=6) }}
|
||||
<div class="form-text small">
|
||||
Nothing selected = shared with every contract. Select contracts to
|
||||
restrict this form to them (hidden from all other customers).
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="d-grid gap-2">
|
||||
<button type="submit" class="btn btn-primary btn-sm">
|
||||
|
||||
@@ -27,6 +27,18 @@
|
||||
{{ form.frequency.label(class="form-label") }}
|
||||
{{ form.frequency(class="form-select") }}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
{{ form.contract_ids.label(class="form-label") }}
|
||||
{{ form.contract_ids(class="form-select", size=6) }}
|
||||
<div class="form-text">
|
||||
Leave <strong>nothing selected</strong> to share this form with
|
||||
every contract. Select one or more contracts to make it
|
||||
specific to them — it will then be hidden from every other
|
||||
customer's facilities, on the web and in the iPad app.
|
||||
Ctrl/Cmd-click to select several.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
|
||||
@@ -38,6 +38,24 @@
|
||||
<i class="bi bi-check2-square"></i> {{ template.checklist_items.count() }} items
|
||||
</small>
|
||||
</div>
|
||||
|
||||
{# phase52 — who may use this form. No links = shared with all. #}
|
||||
<div class="mt-2">
|
||||
{% if template.is_shared %}
|
||||
<span class="badge bg-light text-dark border"
|
||||
title="Available on every contract">
|
||||
<i class="bi bi-globe2"></i> Shared
|
||||
</span>
|
||||
{% else %}
|
||||
{% for link in template.contract_links %}
|
||||
<span class="badge bg-primary"
|
||||
title="Only available on this contract">
|
||||
<i class="bi bi-briefcase"></i>
|
||||
{{ link.project.name if link.project else 'contract #' ~ link.project_id }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-footer bg-transparent d-flex gap-2 align-items-center flex-wrap">
|
||||
@@ -56,6 +74,7 @@
|
||||
data-template-name="{{ template.name }}"
|
||||
data-template-description="{{ template.description or '' }}"
|
||||
data-template-frequency="{{ template.frequency or 'daily' }}"
|
||||
data-template-contracts="{{ template.contract_ids|join(',') }}"
|
||||
title="Edit template details">
|
||||
<i class="bi bi-pencil"></i> Edit
|
||||
</button>
|
||||
@@ -142,7 +161,7 @@
|
||||
placeholder="Optional description"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="mb-1">
|
||||
<div class="mb-3">
|
||||
<label for="editFrequency" class="form-label fw-semibold">Frequency</label>
|
||||
<select id="editFrequency" name="frequency" class="form-select">
|
||||
<option value="daily">Daily</option>
|
||||
@@ -151,6 +170,34 @@
|
||||
<option value="quarterly">Quarterly</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{# phase52 — which contracts may use this form. The hidden
|
||||
marker tells the route this modal really did include the
|
||||
field, so an empty selection means "share it" rather
|
||||
than "no field was posted, leave it alone". #}
|
||||
<div class="mb-1">
|
||||
<label for="editContracts" class="form-label fw-semibold">
|
||||
Available on contracts
|
||||
</label>
|
||||
<input type="hidden" name="contracts_present" value="1">
|
||||
<select id="editContracts" name="contract_ids"
|
||||
class="form-select" multiple size="6">
|
||||
{% for p in contracts %}
|
||||
<option value="{{ p.id }}">{{ p.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="form-text">
|
||||
Leave <strong>nothing selected</strong> to share this form with
|
||||
every contract (this is how all existing forms are set).
|
||||
Select one or more contracts to restrict it to them — it is
|
||||
then hidden from every other customer, on the web and in the
|
||||
iPad app. Ctrl/Cmd-click to select several.
|
||||
</div>
|
||||
<button type="button" id="clearContracts"
|
||||
class="btn btn-sm btn-link px-0 mt-1">
|
||||
Clear selection (make shared)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||||
@@ -228,6 +275,17 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
document.getElementById('renameInput').value = templateName;
|
||||
document.getElementById('editDescription').value = templateDesc;
|
||||
document.getElementById('editFrequency').value = templateFreq;
|
||||
|
||||
// Pre-tick the contracts this form is currently restricted to. An
|
||||
// empty attribute means it is shared, so nothing is selected.
|
||||
const contractSel = document.getElementById('editContracts');
|
||||
if (contractSel) {
|
||||
const current = (btn.getAttribute('data-template-contracts') || '')
|
||||
.split(',').filter(Boolean);
|
||||
Array.from(contractSel.options).forEach(function (o) {
|
||||
o.selected = current.indexOf(o.value) !== -1;
|
||||
});
|
||||
}
|
||||
document.getElementById('renameTemplateForm').action =
|
||||
'/templates/' + templateId + '/rename';
|
||||
|
||||
@@ -238,6 +296,14 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
});
|
||||
});
|
||||
|
||||
const clearBtn = document.getElementById('clearContracts');
|
||||
if (clearBtn) {
|
||||
clearBtn.addEventListener('click', function () {
|
||||
const sel = document.getElementById('editContracts');
|
||||
Array.from(sel.options).forEach(function (o) { o.selected = false; });
|
||||
});
|
||||
}
|
||||
|
||||
const deleteModal = document.getElementById('deleteModal');
|
||||
deleteModal.addEventListener('show.bs.modal', function (event) {
|
||||
const btn = event.relatedTarget;
|
||||
|
||||
@@ -60,7 +60,8 @@
|
||||
<div class="jqc-hub-title">Add More People</div>
|
||||
<div class="jqc-hub-text">
|
||||
Create accounts for colleagues and set what each person can do.
|
||||
External inspectors are invited by email and choose their own password.
|
||||
Customer Directors and Customer Inspectors are invited by email from
|
||||
Customer Management and choose their own username and password.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
{# ── Live support routes (role-aware) ──────────────────────────────────── #}
|
||||
<div class="row g-3 mb-4">
|
||||
{% if current_user.role == 'customer' %}
|
||||
{% if current_user.is_customer_account %}
|
||||
<div class="col-12 col-md-4">
|
||||
<a class="jqc-hub-card" href="{{ url_for('support.chat') }}">
|
||||
<div class="d-flex gap-3 align-items-center">
|
||||
@@ -113,13 +113,13 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{# The AI chat is customer-only (support.chat redirects staff to the ticket
|
||||
{# The AI chat is for customer-side accounts (support.chat redirects staff to the ticket
|
||||
queue, which is itself @supervisor_required). So the destination is chosen
|
||||
per role rather than pointed at support.chat for everyone — an inspector
|
||||
following that chain would land on the dashboard with an access-denied
|
||||
flash, and this page is meant never to dead-end. Roles with no support
|
||||
destination get no card; the how-to guides below are their support. #}
|
||||
{% if current_user.role == 'customer' %}
|
||||
{% if current_user.is_customer_account %}
|
||||
<div class="col-12 col-md-6 col-xl-4">
|
||||
<a class="jqc-hub-card dark" href="{{ url_for('support.chat') }}">
|
||||
<div class="d-flex gap-3 align-items-center">
|
||||
|
||||
@@ -28,6 +28,29 @@ def safe_redirect_url(url: str | None, fallback: str | None = None) -> str:
|
||||
return fallback
|
||||
return url
|
||||
|
||||
def return_url(fallback: str) -> str:
|
||||
"""Where to go back to after a list-page action, preserving its filters.
|
||||
|
||||
Reads the `next` value the page carried through the action — POST body
|
||||
first (forms), then query string (links) — and validates it with
|
||||
safe_redirect_url, so a crafted `next` can never redirect off-site.
|
||||
|
||||
The problem this solves: a delete or an edit launched from a filtered list
|
||||
used to redirect to the bare index, throwing away the filters the user had
|
||||
set. Every list-page action now round-trips the list URL instead.
|
||||
|
||||
`next` is deliberately the FULL list URL (page number and all), not a
|
||||
reconstructed set of arguments — that keeps this helper working when a new
|
||||
filter is added to either list page without anyone having to remember to
|
||||
thread it through here.
|
||||
"""
|
||||
from flask import request
|
||||
return safe_redirect_url(
|
||||
request.form.get('next') or request.args.get('next'),
|
||||
fallback=fallback,
|
||||
)
|
||||
|
||||
|
||||
def admin_required(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
|
||||
+23
-10
@@ -2,7 +2,7 @@ from flask_wtf import FlaskForm
|
||||
from flask_wtf.file import FileField, FileAllowed, MultipleFileField
|
||||
from wtforms import (StringField, PasswordField, SelectField, TextAreaField,
|
||||
DecimalField, BooleanField, IntegerField, HiddenField,
|
||||
RadioField)
|
||||
RadioField, SelectMultipleField)
|
||||
from wtforms.validators import (DataRequired, Email, Length, EqualTo,
|
||||
Optional, NumberRange, ValidationError)
|
||||
import re as _re
|
||||
@@ -91,13 +91,13 @@ class UserForm(FlaskForm):
|
||||
('admin', 'Administrator'),
|
||||
('director', 'Director'),
|
||||
('inspector', 'Inspector'),
|
||||
# MT-15 — 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
|
||||
@@ -164,6 +164,11 @@ class InspectionTemplateForm(FlaskForm):
|
||||
('daily','Daily'), ('weekly','Weekly'),
|
||||
('monthly','Monthly'), ('quarterly','Quarterly'),
|
||||
], validators=[DataRequired()])
|
||||
# phase52 — which contracts may use this form. Choices are populated in the
|
||||
# route. Selecting NONE leaves the form shared with every contract, which
|
||||
# is the default and what every pre-phase52 template does.
|
||||
contract_ids = SelectMultipleField('Available on contracts', coerce=int,
|
||||
validators=[Optional()])
|
||||
|
||||
|
||||
class ChecklistItemForm(FlaskForm):
|
||||
@@ -308,14 +313,22 @@ class CustomerUserForm(FlaskForm):
|
||||
raise ValidationError('Password is required for new accounts.')
|
||||
|
||||
class CustomerInviteForm(FlaskForm):
|
||||
"""Simplified form for creating a customer account via email invitation.
|
||||
"""Create a customer-side account via email invitation.
|
||||
|
||||
Admin enters Full Name and Email only. A username is auto-generated
|
||||
from the email address. The customer sets their own username and
|
||||
password via the emailed link.
|
||||
Admin enters Full Name, Email and which of the two customer roles the
|
||||
person holds. A username is auto-generated from the email address; the
|
||||
invitee sets their own username and password via the emailed link.
|
||||
|
||||
Both roles use the SAME invitation flow — neither is an account we set a
|
||||
password for. phase51 folded the Customer Inspector (stored as
|
||||
'external_inspector') in here from User Management.
|
||||
"""
|
||||
full_name = StringField('Full Name', validators=[DataRequired(), Length(max=150)])
|
||||
email = StringField('Email', validators=[DataRequired(), Email(), Length(max=255)])
|
||||
role = SelectField('Role', choices=[
|
||||
('customer', 'Customer Director — portal access for their facilities'),
|
||||
('external_inspector', 'Customer Inspector — performs inspections on their contracts'),
|
||||
], default='customer', validators=[DataRequired()])
|
||||
|
||||
def validate_email(self, field):
|
||||
if User.query.filter_by(email=field.data.strip().lower()).first():
|
||||
|
||||
@@ -214,6 +214,28 @@ def notify(
|
||||
in-app Notification row is unchanged, so a recipient reading
|
||||
it in the bell menu simply follows `link` as before.
|
||||
"""
|
||||
# ── Per-account override (phase51) ───────────────────────────────────
|
||||
# A customer-side account's own notification matrix governs EVERY path
|
||||
# that reaches it, not just matrix broadcasts: follower fan-out
|
||||
# (_notify_followers) and direct assignee notifications both call notify()
|
||||
# straight, so without this the editor would offer rows — "Issue follow
|
||||
# update", "Issue assigned" — that appeared to be off while the
|
||||
# notifications kept arriving.
|
||||
#
|
||||
# Only an explicit `False` suppresses. No row means inherit, which is the
|
||||
# default for every account and leaves behaviour exactly as before. The
|
||||
# getattr fallback is deliberate: if the attribute is unavailable for any
|
||||
# reason we send, never silently drop.
|
||||
if event_type and getattr(recipient, 'is_customer_account', False):
|
||||
from app.models.user_notification_matrix import override_for
|
||||
if override_for(recipient.id, event_type) is False:
|
||||
logger.info(
|
||||
'NOTIFICATION SUPPRESSED | user=%s | event=%s | '
|
||||
'reason=per_account_override_off',
|
||||
recipient.username, event_type,
|
||||
)
|
||||
return
|
||||
|
||||
# Determine digest flag before creating the record.
|
||||
# Digest mode is only respected when individual preferences are in effect.
|
||||
hold_for_digest = (
|
||||
@@ -342,6 +364,7 @@ def notify_customers_for_facility(
|
||||
link: str = None,
|
||||
issue_id: int = None,
|
||||
inspection_id: int = None,
|
||||
allowed_user_ids: set = None,
|
||||
):
|
||||
"""Dispatch in-app + email notifications to all customer users assigned
|
||||
to the given facility.
|
||||
@@ -362,6 +385,12 @@ def notify_customers_for_facility(
|
||||
link : Relative URL for 'View Details'.
|
||||
issue_id : FK to issues.id (optional).
|
||||
inspection_id : FK to inspections.id (optional).
|
||||
allowed_user_ids :
|
||||
Optional whitelist. When notify_by_matrix() calls this it has already
|
||||
applied each account's per-event override (phase51), so it passes the
|
||||
surviving ids here — this function re-derives recipients from the
|
||||
assignment rows and would otherwise notify accounts that opted out.
|
||||
None (the default, used by direct callers) means no filtering.
|
||||
"""
|
||||
try:
|
||||
from app.models.project import CustomerAssignment
|
||||
@@ -399,8 +428,20 @@ def notify_customers_for_facility(
|
||||
)
|
||||
return
|
||||
|
||||
if allowed_user_ids is not None:
|
||||
notified_user_ids &= set(allowed_user_ids)
|
||||
if not notified_user_ids:
|
||||
logger.debug(
|
||||
'notify_customers_for_facility | facility_id=%s | all '
|
||||
'assigned customers filtered out by per-account overrides',
|
||||
facility_id,
|
||||
)
|
||||
return
|
||||
|
||||
for user_id in notified_user_ids:
|
||||
user = db.session.get(User, user_id)
|
||||
# role != 'customer' stays an EQUALITY check: a Customer Inspector
|
||||
# is not a portal customer and is routed by the inspector column.
|
||||
if not user or not user.active or user.role != 'customer':
|
||||
continue
|
||||
try:
|
||||
@@ -562,11 +603,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',
|
||||
@@ -585,7 +635,14 @@ def notify_by_matrix(
|
||||
enabled = is_enabled(event_type, role_key)
|
||||
logger.info('MATRIX NOTIFY | event=%s | role=%s | enabled=%s',
|
||||
event_type, role_key, enabled)
|
||||
if not enabled:
|
||||
|
||||
# A customer-side column must NOT be skipped just because the global
|
||||
# switch is off — an account that opted IN individually still has to be
|
||||
# reached. Only skip when the column is off AND nobody opted in.
|
||||
# (Getting this wrong is silent: the per-account "on" would save fine,
|
||||
# show as on, and never send.)
|
||||
is_customer_col = role_key in customer_keys
|
||||
if not enabled and not (is_customer_col and any(overrides.values())):
|
||||
continue
|
||||
|
||||
db_role = role_to_db.get(role_key)
|
||||
@@ -615,6 +672,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
|
||||
@@ -626,6 +692,9 @@ def notify_by_matrix(
|
||||
link = link,
|
||||
issue_id = issue_id,
|
||||
inspection_id = inspection_id,
|
||||
# Without this the facility-scoped path would re-query customers
|
||||
# itself and bypass every override applied just above.
|
||||
allowed_user_ids = {u.id for u in users},
|
||||
)
|
||||
continue # notify_customers_for_facility handles dedup internally
|
||||
|
||||
|
||||
@@ -113,6 +113,19 @@ class Config:
|
||||
if os.environ.get('PHOTO_RETENTION_DAYS') else None
|
||||
)
|
||||
|
||||
# ── Issue comment visibility (TEMPORARY — Aug 2026) ──────────────────────
|
||||
# True = every comment on an issue is visible to everyone, customers
|
||||
# included; the per-comment is_customer_visible flag is ignored
|
||||
# when READING.
|
||||
# False = phase22 behaviour — customers see only comments explicitly shared
|
||||
# with them.
|
||||
#
|
||||
# The flag is still WRITTEN on every comment, so flipping this back to
|
||||
# 'false' restores the old behaviour exactly, with no data to repair.
|
||||
# Set COMMENTS_VISIBLE_TO_ALL=false in the environment to revert.
|
||||
COMMENTS_VISIBLE_TO_ALL = os.environ.get(
|
||||
'COMMENTS_VISIBLE_TO_ALL', 'true').lower() == 'true'
|
||||
|
||||
# ── Session / cookies ───────────────────────────────────────────────────
|
||||
PERMANENT_SESSION_LIFETIME = timedelta(hours=24)
|
||||
# Secure by default — subclasses must explicitly opt out for local dev.
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""phase54 — 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 = 'phase54_user_notif_matrix'
|
||||
down_revision = 'phase53_knowledge_sort_order'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
def _has_table(conn, name):
|
||||
return conn.execute(sa.text(
|
||||
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES "
|
||||
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :t"
|
||||
), {'t': name}).scalar() > 0
|
||||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
if _has_table(conn, 'user_notification_matrix'):
|
||||
return
|
||||
|
||||
op.create_table(
|
||||
'user_notification_matrix',
|
||||
sa.Column('id', sa.Integer, primary_key=True),
|
||||
sa.Column('user_id', sa.Integer,
|
||||
sa.ForeignKey('users.id', ondelete='CASCADE'),
|
||||
nullable=False, index=True),
|
||||
sa.Column('event_type', sa.String(50), nullable=False),
|
||||
sa.Column('enabled', sa.Boolean, nullable=False,
|
||||
server_default=sa.text('1')),
|
||||
sa.UniqueConstraint('user_id', 'event_type',
|
||||
name='uq_user_notif_matrix_user_event'),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
conn = op.get_bind()
|
||||
if _has_table(conn, 'user_notification_matrix'):
|
||||
# Every row here is an explicit admin decision; dropping the table
|
||||
# discards them and returns all accounts to global-matrix routing.
|
||||
op.drop_table('user_notification_matrix')
|
||||
@@ -0,0 +1,61 @@
|
||||
"""phase55 — restrict forms to specific contracts
|
||||
|
||||
Creates `template_contracts`: one row = "this inspection template is available
|
||||
on this contract". Backs per-customer forms — a customer's bespoke form must
|
||||
not be visible to, or startable against, another customer's facilities.
|
||||
|
||||
**No rows for a template means SHARED (available on every contract)**, not
|
||||
"available nowhere". That convention is why this migration needs no backfill
|
||||
and cannot change behaviour on deploy: every template that exists today has no
|
||||
rows and therefore stays available everywhere, exactly as before. A form only
|
||||
becomes customer-specific once an admin attaches it to at least one contract.
|
||||
|
||||
Inverting that default later would silently hide every shared form from every
|
||||
contract, so it is enforced in one place — InspectionTemplate.available_query()
|
||||
— which the pickers, their POST validation, and the mobile API all use.
|
||||
|
||||
Table-existence check — safe to re-run.
|
||||
"""
|
||||
|
||||
revision = 'phase55_template_contracts'
|
||||
down_revision = 'phase54_user_notif_matrix'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
def _has_table(conn, name):
|
||||
return conn.execute(sa.text(
|
||||
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES "
|
||||
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :t"
|
||||
), {'t': name}).scalar() > 0
|
||||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
if _has_table(conn, 'template_contracts'):
|
||||
return
|
||||
|
||||
op.create_table(
|
||||
'template_contracts',
|
||||
sa.Column('id', sa.Integer, primary_key=True),
|
||||
sa.Column('template_id', sa.Integer,
|
||||
sa.ForeignKey('inspection_templates.id', ondelete='CASCADE'),
|
||||
nullable=False, index=True),
|
||||
sa.Column('project_id', sa.Integer,
|
||||
sa.ForeignKey('projects.id', ondelete='CASCADE'),
|
||||
nullable=False, index=True),
|
||||
sa.Column('created_at', sa.DateTime, nullable=False,
|
||||
server_default=sa.func.now()),
|
||||
sa.UniqueConstraint('template_id', 'project_id', name='uq_template_contract'),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
conn = op.get_bind()
|
||||
if _has_table(conn, 'template_contracts'):
|
||||
# Dropping the table returns every form to shared — no form becomes
|
||||
# unusable, they just stop being restricted.
|
||||
op.drop_table('template_contracts')
|
||||
Reference in New Issue
Block a user