Aug 17 - Update customer roles management
This commit is contained in:
@@ -6,5 +6,6 @@ from app.models.issue import Issue
|
||||
from app.models.project import Project, CustomerAssignment
|
||||
from app.models.api_token import RefreshToken, DeviceToken
|
||||
from app.models.notification_matrix import NotificationMatrix
|
||||
from app.models.user_notification_matrix import UserNotificationMatrix
|
||||
from app.models.notification_recipient import ContractNotificationRecipient
|
||||
from app.models.scheduled_inspection import ScheduledInspection
|
||||
@@ -44,14 +44,17 @@ import json
|
||||
from app import db
|
||||
|
||||
# Role keys available in the matrix UI
|
||||
# NOTE: the two customer-side labels are a display rename only (phase51) — the
|
||||
# role_key values stored in notification_matrix.role_key are unchanged, so no
|
||||
# data migration was needed. See User.CUSTOMER_ROLES.
|
||||
MATRIX_ROLES = [
|
||||
('admin', 'Admin'),
|
||||
('director', 'Director'),
|
||||
('inspector', 'Inspector'),
|
||||
('external_inspector', 'External Inspector'),
|
||||
('external_inspector', 'Customer Inspector'),
|
||||
('project_manager', 'Project Manager'),
|
||||
('auditor', 'Auditor'),
|
||||
('customer', 'Customer'),
|
||||
('customer', 'Customer Director'),
|
||||
('custom', 'Custom Recipients'),
|
||||
]
|
||||
|
||||
|
||||
+52
-6
@@ -3,17 +3,22 @@ from flask_login import UserMixin
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from app.utils.time_utils import now_eastern
|
||||
|
||||
# Display labels for the role ENUM. 'external_inspector' would otherwise title
|
||||
# case to "External Inspector" anyway, but the map keeps every label in one
|
||||
# place for templates that show a role name.
|
||||
# Display labels for the role ENUM — the single place a role's user-facing name
|
||||
# is defined.
|
||||
#
|
||||
# The two customer-side roles are a LABEL-ONLY rename (same idea as rule 19,
|
||||
# "Project" -> "Contract"): the stored ENUM values are still 'customer' and
|
||||
# 'external_inspector', so no migration and no role check anywhere had to move.
|
||||
# 'customer' -> "Customer Director" (portal access, CustomerAssignment scope)
|
||||
# 'external_inspector' -> "Customer Inspector" (inspector powers, InspectorAssignment scope)
|
||||
ROLE_LABELS = {
|
||||
'admin': 'Admin',
|
||||
'director': 'Director',
|
||||
'project_manager': 'Project Manager',
|
||||
'auditor': 'Auditor',
|
||||
'inspector': 'Inspector',
|
||||
'external_inspector': 'External Inspector',
|
||||
'customer': 'Customer',
|
||||
'external_inspector': 'Customer Inspector',
|
||||
'customer': 'Customer Director',
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +43,27 @@ class User(UserMixin, db.Model):
|
||||
# the same way in Python and in Jinja (`current_user.is_inspector`).
|
||||
INSPECTOR_ROLES = ('inspector', 'external_inspector')
|
||||
|
||||
# ── Customer-side roles (phase51) ────────────────────────────────────────
|
||||
# Accounts that belong to the CUSTOMER, not to us. Both are created,
|
||||
# invited, assigned and switched from Customer Management (/customers) —
|
||||
# they never appear in User Management.
|
||||
# 'customer' = Customer Director — portal access, read-mostly,
|
||||
# scoped by CustomerAssignment.
|
||||
# 'external_inspector' = Customer Inspector — full inspector capabilities,
|
||||
# scoped by InspectorAssignment (see INSPECTOR_ROLES).
|
||||
#
|
||||
# CAUTION — this tuple is NOT interchangeable with `role == 'customer'`.
|
||||
# A Customer Inspector is an INSPECTOR everywhere it matters: portal
|
||||
# read-only gates, @customer_required, get_customer_scope(), support chat
|
||||
# and the customer branch of every API scope check must keep testing
|
||||
# `role == 'customer'` exactly. Use CUSTOMER_ROLES / is_customer_account
|
||||
# ONLY for account-management surfaces (who is listed, invited, edited,
|
||||
# assigned or switched under /customers). Widening a capability check to
|
||||
# this tuple hands a third-party inspector the customer portal; narrowing
|
||||
# an account-management check to 'customer' strands the inspectors in a
|
||||
# page that no longer manages them.
|
||||
CUSTOMER_ROLES = ('customer', 'external_inspector')
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(100), unique=True, nullable=False, index=True)
|
||||
full_name = db.Column(db.String(150), nullable=True)
|
||||
@@ -105,9 +131,29 @@ class User(UserMixin, db.Model):
|
||||
|
||||
@property
|
||||
def is_external_inspector(self):
|
||||
"""True only for third-party / customer-employed inspectors."""
|
||||
"""True only for third-party / customer-employed inspectors.
|
||||
|
||||
Display name: "Customer Inspector". The attribute keeps its phase49
|
||||
name so the ~60 existing call sites stay put (rule 84 — the rename is
|
||||
a label, never an identifier).
|
||||
"""
|
||||
return self.role == 'external_inspector'
|
||||
|
||||
@property
|
||||
def is_customer_account(self):
|
||||
"""True for BOTH customer-side roles — an account-management question.
|
||||
|
||||
Answers "is this account managed under /customers?", NOT "does this
|
||||
account get the customer portal". For the latter keep testing
|
||||
`role == 'customer'`. See the CUSTOMER_ROLES note above.
|
||||
"""
|
||||
return self.role in self.CUSTOMER_ROLES
|
||||
|
||||
@property
|
||||
def is_customer_director(self):
|
||||
"""True for the portal-side customer role ('customer')."""
|
||||
return self.role == 'customer'
|
||||
|
||||
@property
|
||||
def role_label(self):
|
||||
"""Human-readable role name, used in staff-facing lists."""
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"""
|
||||
app/models/user_notification_matrix.py
|
||||
--------------------------------------
|
||||
Per-account notification overrides (phase51).
|
||||
|
||||
The global NotificationMatrix routes an event to whole ROLES: "every Customer
|
||||
Director hears about issue_created". That is the wrong grain for customers —
|
||||
each customer organisation states on its enrollment form which notifications
|
||||
each of its people wants, and two directors on two contracts rarely want the
|
||||
same set.
|
||||
|
||||
This table is the per-account layer on top. One row = one account's explicit
|
||||
answer for one event:
|
||||
|
||||
enabled=True send it to this account even if the global column is OFF
|
||||
enabled=False do not send it to this account even if the global column is ON
|
||||
NO ROW inherit — whatever the global matrix column says
|
||||
|
||||
Inheritance is the default and the safe state: an account with no rows behaves
|
||||
exactly as it did before this table existed, so the feature ships without
|
||||
changing routing for anyone. Setting a row back to "inherit" DELETES it rather
|
||||
than storing a copy of the current global value, so a later change to the
|
||||
global matrix still reaches accounts that never expressed an opinion.
|
||||
|
||||
Scope: consulted for the two customer-side roles only (User.CUSTOMER_ROLES).
|
||||
Staff roles keep using the global matrix alone — an admin who wants fewer
|
||||
emails uses NotificationPreference, which is a different question (how to
|
||||
deliver, not whether to route).
|
||||
"""
|
||||
|
||||
from app import db
|
||||
|
||||
|
||||
class UserNotificationMatrix(db.Model):
|
||||
"""One account's override of the global matrix for one event."""
|
||||
|
||||
__tablename__ = 'user_notification_matrix'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer,
|
||||
db.ForeignKey('users.id', ondelete='CASCADE'),
|
||||
nullable=False, index=True)
|
||||
event_type = db.Column(db.String(50), nullable=False)
|
||||
enabled = db.Column(db.Boolean, nullable=False, default=True)
|
||||
|
||||
user = db.relationship('User', foreign_keys=[user_id],
|
||||
backref=db.backref('notification_overrides',
|
||||
lazy='dynamic',
|
||||
cascade='all, delete-orphan'))
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('user_id', 'event_type',
|
||||
name='uq_user_notif_matrix_user_event'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return (f'<UserNotificationMatrix user={self.user_id} '
|
||||
f'event={self.event_type} enabled={self.enabled}>')
|
||||
|
||||
|
||||
def overrides_for_user(user_id) -> dict:
|
||||
"""Return {event_type: bool} — every override this account has set."""
|
||||
return {
|
||||
row.event_type: row.enabled
|
||||
for row in UserNotificationMatrix.query.filter_by(user_id=user_id).all()
|
||||
}
|
||||
|
||||
|
||||
def overrides_for_event(event_type) -> dict:
|
||||
"""Return {user_id: bool} — every account's override for one event.
|
||||
|
||||
One query per dispatch rather than one per candidate recipient. The table
|
||||
holds only explicitly-set rows (inherit deletes), so it stays small.
|
||||
Best-effort: a failure here must never take down a notification dispatch,
|
||||
so callers get an empty dict (= everyone inherits) if the query fails.
|
||||
"""
|
||||
import logging
|
||||
try:
|
||||
return {
|
||||
row.user_id: row.enabled
|
||||
for row in UserNotificationMatrix.query.filter_by(
|
||||
event_type=event_type).all()
|
||||
}
|
||||
except Exception as exc:
|
||||
logging.getLogger(__name__).error(
|
||||
'USER MATRIX | override lookup failed | event=%s | error=%s',
|
||||
event_type, exc,
|
||||
)
|
||||
return {}
|
||||
|
||||
|
||||
def set_overrides(user_id, values: dict):
|
||||
"""Replace an account's overrides.
|
||||
|
||||
`values` maps event_type -> True / False / None, where None means inherit
|
||||
(the row is deleted). Events absent from `values` are left untouched, so a
|
||||
caller can update one event without resending the whole matrix.
|
||||
|
||||
Does NOT commit — the caller owns the transaction (same contract as
|
||||
notify()). Returns the number of rows added, updated or deleted.
|
||||
"""
|
||||
existing = {
|
||||
row.event_type: row
|
||||
for row in UserNotificationMatrix.query.filter_by(user_id=user_id).all()
|
||||
}
|
||||
changed = 0
|
||||
|
||||
for event_type, wanted in values.items():
|
||||
row = existing.get(event_type)
|
||||
if wanted is None:
|
||||
if row is not None:
|
||||
db.session.delete(row)
|
||||
changed += 1
|
||||
continue
|
||||
wanted = bool(wanted)
|
||||
if row is None:
|
||||
db.session.add(UserNotificationMatrix(
|
||||
user_id=user_id, event_type=event_type, enabled=wanted))
|
||||
changed += 1
|
||||
elif row.enabled != wanted:
|
||||
row.enabled = wanted
|
||||
changed += 1
|
||||
|
||||
return changed
|
||||
Reference in New Issue
Block a user