Aug 19 - Update code to catch up with ST
This commit is contained in:
@@ -1,11 +1,13 @@
|
||||
from app.models.user import User
|
||||
from app.models.facility import Facility, Area
|
||||
from app.models.inspection import (InspectionTemplate, ChecklistItem,
|
||||
Inspection, InspectionResult)
|
||||
Inspection, InspectionResult,
|
||||
TemplateContract)
|
||||
from app.models.issue import Issue
|
||||
from app.models.project import Project, CustomerAssignment
|
||||
from app.models.project_recipient import ProjectNotificationRecipient
|
||||
from app.models.api_token import RefreshToken, DeviceToken
|
||||
from app.models.notification_matrix import NotificationMatrix
|
||||
from app.models.user_notification_matrix import UserNotificationMatrix
|
||||
from app.models.inspection_schedule import InspectionSchedule
|
||||
from app.models.work_order import IssueWorkOrder
|
||||
@@ -3,6 +3,43 @@ from app.utils.time_utils import now_eastern
|
||||
import json
|
||||
|
||||
|
||||
class TemplateContract(db.Model):
|
||||
"""Restricts a form to specific contracts (phase52).
|
||||
|
||||
A customer's bespoke form must not be visible to — or startable against —
|
||||
another customer's facilities. One row = "this template is available on
|
||||
this contract".
|
||||
|
||||
**No rows means the template is SHARED** (available on every contract), not
|
||||
"available nowhere". That is what makes the feature additive: every
|
||||
template that existed before phase52 has no rows, so nothing changed on
|
||||
deploy, and a form becomes customer-specific only when an admin attaches it
|
||||
to at least one contract. The empty-set-means-all convention is the whole
|
||||
migration story — do not "fix" it to mean the opposite.
|
||||
"""
|
||||
|
||||
__tablename__ = 'template_contracts'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
template_id = db.Column(db.Integer,
|
||||
db.ForeignKey('inspection_templates.id', ondelete='CASCADE'),
|
||||
nullable=False, index=True)
|
||||
project_id = db.Column(db.Integer,
|
||||
db.ForeignKey('projects.id', ondelete='CASCADE'),
|
||||
nullable=False, index=True)
|
||||
created_at = db.Column(db.DateTime, default=now_eastern, nullable=False)
|
||||
|
||||
project = db.relationship('Project', backref='template_contracts')
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('template_id', 'project_id',
|
||||
name='uq_template_contract'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<TemplateContract template={self.template_id} project={self.project_id}>'
|
||||
|
||||
|
||||
class InspectionTemplate(db.Model):
|
||||
__tablename__ = 'inspection_templates'
|
||||
|
||||
@@ -18,6 +55,82 @@ class InspectionTemplate(db.Model):
|
||||
checklist_items = db.relationship('ChecklistItem', backref='template', lazy='dynamic', cascade='all, delete-orphan')
|
||||
inspections = db.relationship('Inspection', backref='template', lazy='dynamic')
|
||||
|
||||
# phase52 — contract restrictions. Deleting a template removes its links.
|
||||
contract_links = db.relationship('TemplateContract', backref='template',
|
||||
lazy='dynamic',
|
||||
cascade='all, delete-orphan')
|
||||
|
||||
# ── Contract availability (phase52) ──────────────────────────────────
|
||||
|
||||
@property
|
||||
def contract_ids(self):
|
||||
"""Project ids this form is restricted to; empty = shared with all."""
|
||||
return sorted(l.project_id for l in self.contract_links.all())
|
||||
|
||||
@property
|
||||
def is_shared(self):
|
||||
"""True when the form carries no restriction and is available anywhere."""
|
||||
return self.contract_links.count() == 0
|
||||
|
||||
def available_for_project(self, project_id):
|
||||
"""Can this form be used on `project_id`?
|
||||
|
||||
Shared forms are usable anywhere, including on a facility that has no
|
||||
contract at all. A restricted form needs an explicit link, so a
|
||||
facility with no contract (project_id None) can only ever use shared
|
||||
forms — fail-closed, which is the right side to err on.
|
||||
"""
|
||||
if self.is_shared:
|
||||
return True
|
||||
if project_id is None:
|
||||
return False
|
||||
return project_id in set(self.contract_ids)
|
||||
|
||||
@staticmethod
|
||||
def available_query(project_id, active_only=True):
|
||||
"""Query of templates usable on `project_id` (shared + linked).
|
||||
|
||||
The single definition of "which forms may this contract use". Every
|
||||
picker, the POST validation behind it, and the mobile API all go
|
||||
through here so they cannot disagree — a picker that offers more than
|
||||
the validator accepts silently drops work (see rule 93 for the same
|
||||
failure in the assignee dropdown).
|
||||
"""
|
||||
q = InspectionTemplate.query
|
||||
if active_only:
|
||||
q = q.filter(InspectionTemplate.active == True)
|
||||
|
||||
shared = ~InspectionTemplate.contract_links.any()
|
||||
if project_id is None:
|
||||
# No contract to match against — only unrestricted forms apply.
|
||||
return q.filter(shared).order_by(InspectionTemplate.name)
|
||||
|
||||
linked = InspectionTemplate.contract_links.any(
|
||||
TemplateContract.project_id == project_id
|
||||
)
|
||||
return q.filter(db.or_(shared, linked)).order_by(InspectionTemplate.name)
|
||||
|
||||
def set_contracts(self, project_ids):
|
||||
"""Replace this form's contract restrictions.
|
||||
|
||||
Pass an empty list to make the form shared again. Does NOT commit —
|
||||
the caller owns the transaction. Returns True if anything changed.
|
||||
"""
|
||||
wanted = {int(p) for p in project_ids or []}
|
||||
existing = {l.project_id: l for l in self.contract_links.all()}
|
||||
|
||||
changed = False
|
||||
for pid, link in existing.items():
|
||||
if pid not in wanted:
|
||||
db.session.delete(link)
|
||||
changed = True
|
||||
for pid in wanted:
|
||||
if pid not in existing:
|
||||
db.session.add(TemplateContract(template_id=self.id, project_id=pid))
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
|
||||
def get_form_schema(self):
|
||||
if self.form_schema is None:
|
||||
return []
|
||||
|
||||
@@ -44,14 +44,17 @@ import json
|
||||
from app import db
|
||||
|
||||
# Role keys available in the matrix UI
|
||||
# NOTE: the two customer-side labels are a display rename only (phase51) — the
|
||||
# role_key values stored in notification_matrix.role_key are unchanged, so no
|
||||
# data migration was needed. See User.CUSTOMER_ROLES.
|
||||
MATRIX_ROLES = [
|
||||
('admin', 'Admin'),
|
||||
('director', 'Director'),
|
||||
('inspector', 'Inspector'),
|
||||
('external_inspector', 'External Inspector'),
|
||||
('external_inspector', 'Customer Inspector'),
|
||||
('project_manager', 'Project Manager'),
|
||||
('auditor', 'Auditor'),
|
||||
('customer', 'Customer'),
|
||||
('customer', 'Customer Director'),
|
||||
('custom', 'Custom Recipients'),
|
||||
]
|
||||
|
||||
|
||||
+52
-6
@@ -3,17 +3,22 @@ from flask_login import UserMixin
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from app.utils.time_utils import now_eastern
|
||||
|
||||
# Display labels for the role ENUM. 'external_inspector' would otherwise title
|
||||
# case to "External Inspector" anyway, but the map keeps every label in one
|
||||
# place for templates that show a role name.
|
||||
# Display labels for the role ENUM — the single place a role's user-facing name
|
||||
# is defined.
|
||||
#
|
||||
# The two customer-side roles are a LABEL-ONLY rename (same idea as rule 19,
|
||||
# "Project" -> "Contract"): the stored ENUM values are still 'customer' and
|
||||
# 'external_inspector', so no migration and no role check anywhere had to move.
|
||||
# 'customer' -> "Customer Director" (portal access, CustomerAssignment scope)
|
||||
# 'external_inspector' -> "Customer Inspector" (inspector powers, InspectorAssignment scope)
|
||||
ROLE_LABELS = {
|
||||
'admin': 'Admin',
|
||||
'director': 'Director',
|
||||
'project_manager': 'Project Manager',
|
||||
'auditor': 'Auditor',
|
||||
'inspector': 'Inspector',
|
||||
'external_inspector': 'External Inspector',
|
||||
'customer': 'Customer',
|
||||
'external_inspector': 'Customer Inspector',
|
||||
'customer': 'Customer Director',
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +43,27 @@ class User(UserMixin, db.Model):
|
||||
# the same way in Python and in Jinja (`current_user.is_inspector`).
|
||||
INSPECTOR_ROLES = ('inspector', 'external_inspector')
|
||||
|
||||
# ── Customer-side roles (phase51) ────────────────────────────────
|
||||
# Accounts that belong to the CUSTOMER, not to us. Both are created,
|
||||
# invited, assigned and switched from Customer Management (/customers) —
|
||||
# they never appear in User Management.
|
||||
# 'customer' = Customer Director — portal access, read-mostly,
|
||||
# scoped by CustomerAssignment.
|
||||
# 'external_inspector' = Customer Inspector — full inspector capabilities,
|
||||
# scoped by InspectorAssignment (see INSPECTOR_ROLES).
|
||||
#
|
||||
# CAUTION — this tuple is NOT interchangeable with `role == 'customer'`.
|
||||
# A Customer Inspector is an INSPECTOR everywhere it matters: portal
|
||||
# read-only gates, @customer_required, get_customer_scope(), support chat
|
||||
# and the customer branch of every API scope check must keep testing
|
||||
# `role == 'customer'` exactly. Use CUSTOMER_ROLES / is_customer_account
|
||||
# ONLY for account-management surfaces (who is listed, invited, edited,
|
||||
# assigned or switched under /customers). Widening a capability check to
|
||||
# this tuple hands a third-party inspector the customer portal; narrowing
|
||||
# an account-management check to 'customer' strands the inspectors in a
|
||||
# page that no longer manages them.
|
||||
CUSTOMER_ROLES = ('customer', 'external_inspector')
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(100), unique=True, nullable=False, index=True)
|
||||
full_name = db.Column(db.String(150), nullable=True)
|
||||
@@ -117,9 +143,29 @@ class User(UserMixin, db.Model):
|
||||
|
||||
@property
|
||||
def is_external_inspector(self):
|
||||
"""True only for third-party / customer-employed inspectors."""
|
||||
"""True only for third-party / customer-employed inspectors.
|
||||
|
||||
Display name: "Customer Inspector". The attribute keeps its MT-15
|
||||
name so the existing call sites stay put (the rename is a label,
|
||||
never an identifier).
|
||||
"""
|
||||
return self.role == 'external_inspector'
|
||||
|
||||
@property
|
||||
def is_customer_account(self):
|
||||
"""True for BOTH customer-side roles — an account-management question.
|
||||
|
||||
Answers "is this account managed under /customers?", NOT "does this
|
||||
account get the customer portal". For the latter keep testing
|
||||
`role == 'customer'`. See the CUSTOMER_ROLES note above.
|
||||
"""
|
||||
return self.role in self.CUSTOMER_ROLES
|
||||
|
||||
@property
|
||||
def is_customer_director(self):
|
||||
"""True for the portal-side customer role ('customer')."""
|
||||
return self.role == 'customer'
|
||||
|
||||
@property
|
||||
def role_label(self):
|
||||
"""Human-readable role name, used in staff-facing lists."""
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
"""
|
||||
app/models/user_notification_matrix.py
|
||||
--------------------------------------
|
||||
Per-account notification overrides (phase51).
|
||||
|
||||
The global NotificationMatrix routes an event to whole ROLES: "every Customer
|
||||
Director hears about issue_created". That is the wrong grain for customers —
|
||||
each customer organisation states on its enrollment form which notifications
|
||||
each of its people wants, and two directors on two contracts rarely want the
|
||||
same set.
|
||||
|
||||
This table is the per-account layer on top. One row = one account's explicit
|
||||
answer for one event:
|
||||
|
||||
enabled=True send it to this account even if the global column is OFF
|
||||
enabled=False do not send it to this account even if the global column is ON
|
||||
NO ROW inherit — whatever the global matrix column says
|
||||
|
||||
Inheritance is the default and the safe state: an account with no rows behaves
|
||||
exactly as it did before this table existed, so the feature ships without
|
||||
changing routing for anyone. Setting a row back to "inherit" DELETES it rather
|
||||
than storing a copy of the current global value, so a later change to the
|
||||
global matrix still reaches accounts that never expressed an opinion.
|
||||
|
||||
Scope: consulted for the two customer-side roles only (User.CUSTOMER_ROLES).
|
||||
Staff roles keep using the global matrix alone — an admin who wants fewer
|
||||
emails uses NotificationPreference, which is a different question (how to
|
||||
deliver, not whether to route).
|
||||
"""
|
||||
|
||||
from app import db
|
||||
|
||||
|
||||
class UserNotificationMatrix(db.Model):
|
||||
"""One account's override of the global matrix for one event."""
|
||||
|
||||
__tablename__ = 'user_notification_matrix'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer,
|
||||
db.ForeignKey('users.id', ondelete='CASCADE'),
|
||||
nullable=False, index=True)
|
||||
event_type = db.Column(db.String(50), nullable=False)
|
||||
enabled = db.Column(db.Boolean, nullable=False, default=True)
|
||||
|
||||
user = db.relationship('User', foreign_keys=[user_id],
|
||||
backref=db.backref('notification_overrides',
|
||||
lazy='dynamic',
|
||||
cascade='all, delete-orphan'))
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('user_id', 'event_type',
|
||||
name='uq_user_notif_matrix_user_event'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return (f'<UserNotificationMatrix user={self.user_id} '
|
||||
f'event={self.event_type} enabled={self.enabled}>')
|
||||
|
||||
|
||||
def overrides_for_user(user_id) -> dict:
|
||||
"""Return {event_type: bool} — every override this account has set."""
|
||||
return {
|
||||
row.event_type: row.enabled
|
||||
for row in UserNotificationMatrix.query.filter_by(user_id=user_id).all()
|
||||
}
|
||||
|
||||
|
||||
def override_for(user_id, event_type):
|
||||
"""One account's answer for one event: True, False, or None (inherit).
|
||||
|
||||
Used by notify() to enforce the override on EVERY delivery path, not just
|
||||
matrix broadcasts. Best-effort: any failure returns None (inherit), so a
|
||||
lookup problem can never silently swallow a notification.
|
||||
"""
|
||||
import logging
|
||||
if not user_id or not event_type:
|
||||
return None
|
||||
try:
|
||||
row = UserNotificationMatrix.query.filter_by(
|
||||
user_id=user_id, event_type=event_type).first()
|
||||
return row.enabled if row is not None else None
|
||||
except Exception as exc:
|
||||
logging.getLogger(__name__).error(
|
||||
'USER MATRIX | single override lookup failed | user=%s event=%s | %s',
|
||||
user_id, event_type, exc,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def overrides_for_event(event_type) -> dict:
|
||||
"""Return {user_id: bool} — every account's override for one event.
|
||||
|
||||
One query per dispatch rather than one per candidate recipient. The table
|
||||
holds only explicitly-set rows (inherit deletes), so it stays small.
|
||||
Best-effort: a failure here must never take down a notification dispatch,
|
||||
so callers get an empty dict (= everyone inherits) if the query fails.
|
||||
"""
|
||||
import logging
|
||||
try:
|
||||
return {
|
||||
row.user_id: row.enabled
|
||||
for row in UserNotificationMatrix.query.filter_by(
|
||||
event_type=event_type).all()
|
||||
}
|
||||
except Exception as exc:
|
||||
logging.getLogger(__name__).error(
|
||||
'USER MATRIX | override lookup failed | event=%s | error=%s',
|
||||
event_type, exc,
|
||||
)
|
||||
return {}
|
||||
|
||||
|
||||
def set_overrides(user_id, values: dict):
|
||||
"""Replace an account's overrides.
|
||||
|
||||
`values` maps event_type -> True / False / None, where None means inherit
|
||||
(the row is deleted). Events absent from `values` are left untouched, so a
|
||||
caller can update one event without resending the whole matrix.
|
||||
|
||||
Does NOT commit — the caller owns the transaction (same contract as
|
||||
notify()). Returns the number of rows added, updated or deleted.
|
||||
"""
|
||||
existing = {
|
||||
row.event_type: row
|
||||
for row in UserNotificationMatrix.query.filter_by(user_id=user_id).all()
|
||||
}
|
||||
changed = 0
|
||||
|
||||
for event_type, wanted in values.items():
|
||||
row = existing.get(event_type)
|
||||
if wanted is None:
|
||||
if row is not None:
|
||||
db.session.delete(row)
|
||||
changed += 1
|
||||
continue
|
||||
wanted = bool(wanted)
|
||||
if row is None:
|
||||
db.session.add(UserNotificationMatrix(
|
||||
user_id=user_id, event_type=event_type, enabled=wanted))
|
||||
changed += 1
|
||||
elif row.enabled != wanted:
|
||||
row.enabled = wanted
|
||||
changed += 1
|
||||
|
||||
return changed
|
||||
Reference in New Issue
Block a user