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
+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