147 lines
5.5 KiB
Python
147 lines
5.5 KiB
Python
"""
|
|
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
|