Apr 2 2026: implement notification settings matrix
This commit is contained in:
+141
-1
@@ -467,4 +467,144 @@ def send_pending_digests(frequency: str = 'daily'):
|
||||
user.email, frequency, exc,
|
||||
)
|
||||
|
||||
return sent_count
|
||||
return sent_count
|
||||
|
||||
# ── Matrix-driven broadcast helpers ───────────────────────────────────────────
|
||||
|
||||
def notify_by_matrix(
|
||||
event_type: str,
|
||||
title: str,
|
||||
body: str,
|
||||
link: str = None,
|
||||
issue_id: int = None,
|
||||
inspection_id: int = None,
|
||||
facility_id: int = None,
|
||||
exclude_user_ids: set = None,
|
||||
):
|
||||
"""
|
||||
Dispatch in-app + email notifications for a broadcast event according
|
||||
to the admin-configured notification matrix.
|
||||
|
||||
For each enabled role in the matrix, all active users with that role
|
||||
are notified (optionally scoped to facility via CustomerAssignment for
|
||||
the 'customer' role). Custom email addresses are sent a plain email
|
||||
without creating an in-app Notification record.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
event_type : One of the MATRIX_EVENTS keys from notification_matrix.
|
||||
title : Short notification headline.
|
||||
body : Full notification body.
|
||||
link : Relative URL for 'View Details'.
|
||||
issue_id : FK to issues.id (optional).
|
||||
inspection_id : FK to inspections.id (optional).
|
||||
facility_id : Used to scope 'customer' role to assigned facility.
|
||||
exclude_user_ids : Set of user IDs to skip (e.g. the actor themselves).
|
||||
"""
|
||||
from app.models.notification_matrix import (
|
||||
is_enabled, get_custom_emails_for, MATRIX_ROLES,
|
||||
)
|
||||
from app.models.user import User
|
||||
|
||||
exclude = set(exclude_user_ids or [])
|
||||
notified = set() # deduplicate across roles
|
||||
|
||||
role_to_db = {
|
||||
'admin': 'admin',
|
||||
'supervisor': 'supervisor',
|
||||
'inspector': 'inspector',
|
||||
'project_manager': 'project_manager',
|
||||
'customer': 'customer',
|
||||
}
|
||||
|
||||
for role_key, _ in MATRIX_ROLES:
|
||||
if role_key == 'custom':
|
||||
continue # handled separately below
|
||||
if not is_enabled(event_type, role_key):
|
||||
continue
|
||||
|
||||
db_role = role_to_db.get(role_key)
|
||||
if not db_role:
|
||||
continue
|
||||
|
||||
users = User.query.filter_by(role=db_role, active=True).all()
|
||||
|
||||
# Scope customer role to facility if provided
|
||||
if role_key == 'customer' and facility_id:
|
||||
from app.utils.notifications import notify_customers_for_facility
|
||||
notify_customers_for_facility(
|
||||
facility_id = facility_id,
|
||||
event_type = event_type,
|
||||
title = title,
|
||||
body = body,
|
||||
link = link,
|
||||
issue_id = issue_id,
|
||||
inspection_id = inspection_id,
|
||||
)
|
||||
continue # notify_customers_for_facility handles dedup internally
|
||||
|
||||
for user in users:
|
||||
if user.id in exclude or user.id in notified:
|
||||
continue
|
||||
notify(
|
||||
recipient = user,
|
||||
title = title,
|
||||
body = body,
|
||||
link = link,
|
||||
issue_id = issue_id,
|
||||
inspection_id = inspection_id,
|
||||
event_type = event_type,
|
||||
send_email = True,
|
||||
)
|
||||
notified.add(user.id)
|
||||
|
||||
# ── Custom email recipients ───────────────────────────────────────────
|
||||
custom_emails = get_custom_emails_for(event_type)
|
||||
for email in custom_emails:
|
||||
_send_custom_email(email, title, body, link)
|
||||
|
||||
logger.info(
|
||||
'MATRIX NOTIFY | event=%s | notified=%s | custom_emails=%s',
|
||||
event_type, len(notified), len(custom_emails),
|
||||
)
|
||||
|
||||
|
||||
def _send_custom_email(to_email: str, title: str, body: str, link: str = None):
|
||||
"""Send a plain email to a custom (non-user) address. Best-effort."""
|
||||
try:
|
||||
base_url = current_app.config.get('APP_BASE_URL', '').rstrip('/')
|
||||
sender = current_app.config.get(
|
||||
'MAIL_DEFAULT_SENDER',
|
||||
current_app.config.get('MAIL_USERNAME', 'noreply@janitorialqc.local'),
|
||||
)
|
||||
html_body = render_template_string(
|
||||
_EMAIL_HTML_SINGLE, title=title, body=body,
|
||||
link=link, base_url=base_url,
|
||||
)
|
||||
text_body = render_template_string(
|
||||
_EMAIL_TEXT_SINGLE, title=title, body=body,
|
||||
link=link, base_url=base_url,
|
||||
)
|
||||
msg = Message(
|
||||
subject = f'[JQC] {title}',
|
||||
sender = sender,
|
||||
recipients = [to_email],
|
||||
body = text_body,
|
||||
html = html_body,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error('CUSTOM EMAIL BUILD FAILED | to=%s | error=%s', to_email, exc)
|
||||
return
|
||||
|
||||
app = current_app._get_current_object()
|
||||
|
||||
def _send():
|
||||
with app.app_context():
|
||||
try:
|
||||
mail.send(msg)
|
||||
logger.info('CUSTOM EMAIL SENT | to=%s', to_email)
|
||||
except Exception as exc:
|
||||
logger.error('CUSTOM EMAIL FAILED | to=%s | error=%s', to_email, exc)
|
||||
|
||||
import threading
|
||||
threading.Thread(target=_send, daemon=True).start()
|
||||
|
||||
+35
-26
@@ -105,8 +105,7 @@ def send_sla_alerts():
|
||||
from app import db
|
||||
from app.models.issue import Issue
|
||||
from app.models.user import User
|
||||
from app.utils.notifications import notify
|
||||
from app.models.notification import EVENT_SLA_ALERT
|
||||
from app.utils.notifications import notify, notify_by_matrix
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -115,8 +114,6 @@ def send_sla_alerts():
|
||||
Issue.status.in_(['open', 'in_progress', 'pending_verification'])
|
||||
).all()
|
||||
|
||||
admins = User.query.filter_by(role='admin').all()
|
||||
|
||||
total_sent = 0
|
||||
|
||||
for issue in open_issues:
|
||||
@@ -133,24 +130,8 @@ def send_sla_alerts():
|
||||
if already == 'at_risk' and status == 'at_risk':
|
||||
continue # at_risk already sent, not yet breached
|
||||
|
||||
# Build recipient set — deduplicated by user.id
|
||||
recipients = {}
|
||||
|
||||
for admin in admins:
|
||||
recipients[admin.id] = admin
|
||||
|
||||
if issue.assigned_to and issue.assigned_user:
|
||||
recipients[issue.assigned_user.id] = issue.assigned_user
|
||||
|
||||
for follower_link in issue.followers.all():
|
||||
user = follower_link.user
|
||||
recipients[user.id] = user
|
||||
|
||||
if not recipients:
|
||||
continue
|
||||
|
||||
# Compose message
|
||||
hrs = sla_hours_remaining(issue)
|
||||
hrs = sla_hours_remaining(issue)
|
||||
deadline = sla_deadline(issue)
|
||||
|
||||
if status == 'breached':
|
||||
@@ -178,23 +159,51 @@ def send_sla_alerts():
|
||||
except RuntimeError:
|
||||
link = f'/issues/{issue.id}'
|
||||
|
||||
for user in recipients.values():
|
||||
# Always notify the assignee and followers (implicit, not matrix-controlled)
|
||||
implicit_notified = set()
|
||||
if issue.assigned_to and issue.assigned_user:
|
||||
notify(
|
||||
recipient = user,
|
||||
recipient = issue.assigned_user,
|
||||
title = title,
|
||||
body = body,
|
||||
link = link,
|
||||
issue_id = issue.id,
|
||||
event_type = EVENT_SLA_ALERT,
|
||||
event_type = 'sla_alert',
|
||||
send_email = True,
|
||||
)
|
||||
implicit_notified.add(issue.assigned_user.id)
|
||||
total_sent += 1
|
||||
|
||||
for follower_link in issue.followers.all():
|
||||
if follower_link.user_id not in implicit_notified:
|
||||
notify(
|
||||
recipient = follower_link.user,
|
||||
title = title,
|
||||
body = body,
|
||||
link = link,
|
||||
issue_id = issue.id,
|
||||
event_type = 'sla_alert',
|
||||
send_email = True,
|
||||
)
|
||||
implicit_notified.add(follower_link.user_id)
|
||||
total_sent += 1
|
||||
|
||||
# Matrix-controlled broadcast (admin, supervisor, etc.)
|
||||
notify_by_matrix(
|
||||
event_type = 'sla_alert',
|
||||
title = title,
|
||||
body = body,
|
||||
link = link,
|
||||
issue_id = issue.id,
|
||||
exclude_user_ids = implicit_notified,
|
||||
)
|
||||
total_sent += 1 # approximate — matrix count not returned
|
||||
|
||||
# Mark this issue as notified at the current level
|
||||
issue.sla_notified = status
|
||||
logger.info(
|
||||
'SLA ALERT SENT | issue_id=%s | status=%s | recipients=%s',
|
||||
issue.id, status, list(recipients.keys()),
|
||||
'SLA ALERT SENT | issue_id=%s | status=%s',
|
||||
issue.id, status,
|
||||
)
|
||||
|
||||
if total_sent:
|
||||
|
||||
Reference in New Issue
Block a user