Aug 19 - Update code to catch up with ST

This commit is contained in:
2026-08-19 14:05:18 -04:00
parent c9984e7ae6
commit 12141c2f75
52 changed files with 3321 additions and 342 deletions
+163 -2
View File
@@ -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 100101.
### 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.
+9
View File
@@ -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
View File
@@ -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)
+38 -8
View File
@@ -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
+3 -1
View File
@@ -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
+113
View File
@@ -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 []
+5 -2
View File
@@ -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
View File
@@ -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."""
+146
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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()
+189 -8
View File
@@ -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
View File
@@ -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
View File
@@ -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) ───────────────────────────────────────────────────────
+4 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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(
+20 -4
View File
@@ -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 {
+4 -35
View File
@@ -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 %}
+18 -2
View File
@@ -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 %}
+16 -1
View File
@@ -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 &amp; Send Invitation
+226 -1
View File
@@ -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;
+15 -5
View File
@@ -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>
+1 -1
View File
@@ -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>
+18 -4
View File
@@ -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';
+33 -1
View File
@@ -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 () {
+13 -2
View File
@@ -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">
+15 -3
View File
@@ -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';
+55 -3
View File
@@ -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
+5 -1
View File
@@ -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">
+5 -19
View File
@@ -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') }}">
+18 -4
View File
@@ -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';
+15 -3
View File
@@ -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 &amp; 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 %}
+9
View File
@@ -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">
+12
View File
@@ -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">
+67 -1
View File
@@ -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;
+2 -1
View File
@@ -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>
+3 -3
View File
@@ -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">
+23
View File
@@ -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
View File
@@ -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():
+70 -1
View File
@@ -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
+13
View File
@@ -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')