From bda4b3f5b36710bdb92a0a3fa70f0916882a04bc Mon Sep 17 00:00:00 2001 From: NguyenND Date: Thu, 2 Apr 2026 10:43:34 -0400 Subject: [PATCH] Apr 2 2026: implement notification settings matrix --- app/models/__init__.py | 3 +- app/models/notification_matrix.py | 219 ++++++++++++++++++ app/routes/auth.py | 54 ++++- app/routes/inspections.py | 50 ++-- app/routes/issues.py | 74 +++--- app/templates/auth/notification_matrix.html | 218 +++++++++++++++++ app/templates/base.html | 199 +++++++++++----- app/utils/notifications.py | 142 +++++++++++- app/utils/sla.py | 61 ++--- .../{ => versions}/phase1_projects_roles.py | 0 .../versions/phase8_notification_matrix.py | 37 +++ 11 files changed, 893 insertions(+), 164 deletions(-) create mode 100644 app/models/notification_matrix.py create mode 100644 app/templates/auth/notification_matrix.html rename migrations/{ => versions}/phase1_projects_roles.py (100%) create mode 100644 migrations/versions/phase8_notification_matrix.py diff --git a/app/models/__init__.py b/app/models/__init__.py index 8d827d5..dd90aff 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -4,4 +4,5 @@ from app.models.inspection import (InspectionTemplate, ChecklistItem, Inspection, InspectionResult) from app.models.issue import Issue from app.models.project import Project, CustomerAssignment -from app.models.api_token import RefreshToken, DeviceToken \ No newline at end of file +from app.models.api_token import RefreshToken, DeviceToken +from app.models.notification_matrix import NotificationMatrix \ No newline at end of file diff --git a/app/models/notification_matrix.py b/app/models/notification_matrix.py new file mode 100644 index 0000000..4431a3c --- /dev/null +++ b/app/models/notification_matrix.py @@ -0,0 +1,219 @@ +""" +app/models/notification_matrix.py +---------------------------------- +Admin-controlled notification matrix. + +One row per (event_type, role_key) pair. + +role_key values +--------------- +admin — all users with role='admin' +supervisor — all users with role='supervisor' +inspector — all users with role='inspector' +project_manager — all users with role='project_manager' +customer — all customer-portal users assigned to the relevant facility +assignee — the specific user the issue/inspection is assigned to + (implicit; always notified regardless of matrix) +custom — free-form extra email addresses stored in custom_emails JSON + +Default matrix (mirrors current hardcoded behaviour) +----------------------------------------------------- +inspection_completed : admin ✓ supervisor ✓ inspector ✗ pm ✗ customer ✓ +issue_assigned : admin ✗ supervisor ✗ inspector ✗ pm ✗ customer ✗ (assignee implicit) +issue_status : admin ✗ supervisor ✗ inspector ✗ pm ✗ customer ✗ (assignee implicit) +issue_comment : admin ✗ supervisor ✗ inspector ✗ pm ✗ customer ✗ (assignee implicit) +issue_follow_update : admin ✗ supervisor ✗ inspector ✗ pm ✗ customer ✗ (followers implicit) +issue_flagged : admin ✗ supervisor ✗ inspector ✗ pm ✗ customer ✓ (assignee implicit) +issue_created : admin ✗ supervisor ✗ inspector ✗ pm ✗ customer ✓ (assignee implicit) +issue_updated_customer : admin ✗ supervisor ✗ inspector ✗ pm ✗ customer ✓ +verification_requested : admin ✓ supervisor ✓ inspector ✗ pm ✗ customer ✗ +sla_alert : admin ✓ supervisor ✗ inspector ✗ pm ✗ customer ✗ (assignee + followers implicit) +""" + +import json +from app import db + +# Role keys available in the matrix UI +MATRIX_ROLES = [ + ('admin', 'Admin'), + ('supervisor', 'Supervisor'), + ('inspector', 'Inspector'), + ('project_manager', 'Project Manager'), + ('customer', 'Customer'), + ('custom', 'Custom Recipients'), +] + +# Events shown in the matrix — maps event_key → display label +# event_key is used as the DB event_type value +MATRIX_EVENTS = { + 'inspection_completed': 'Inspection completed', + 'issue_assigned': 'Issue assigned (new)', + 'issue_reassigned': 'Issue reassigned', + 'issue_unassigned': 'Issue unassigned', + 'issue_status': 'Issue status changed', + 'issue_comment': 'Issue comment added', + 'issue_follow_update': 'Issue follow update', + 'issue_flagged': 'Issue flagged (from inspection)', + 'issue_created': 'Issue created (standalone)', + 'issue_updated_customer': 'Issue updated (customer)', + 'verification_requested': 'Verification requested', + 'sla_alert': 'SLA at-risk / breached', +} + +# Default enabled state: (event_key, role_key) → True/False +# Mirrors the current hardcoded behaviour exactly. +MATRIX_DEFAULTS = { + # inspection_completed + ('inspection_completed', 'admin'): True, + ('inspection_completed', 'supervisor'): True, + ('inspection_completed', 'inspector'): False, + ('inspection_completed', 'project_manager'): False, + ('inspection_completed', 'customer'): True, + ('inspection_completed', 'custom'): False, + # issue_assigned (assignee is always notified implicitly) + ('issue_assigned', 'admin'): False, + ('issue_assigned', 'supervisor'): False, + ('issue_assigned', 'inspector'): False, + ('issue_assigned', 'project_manager'): False, + ('issue_assigned', 'customer'): False, + ('issue_assigned', 'custom'): False, + # issue_reassigned + ('issue_reassigned', 'admin'): False, + ('issue_reassigned', 'supervisor'): False, + ('issue_reassigned', 'inspector'): False, + ('issue_reassigned', 'project_manager'): False, + ('issue_reassigned', 'customer'): False, + ('issue_reassigned', 'custom'): False, + # issue_unassigned + ('issue_unassigned', 'admin'): False, + ('issue_unassigned', 'supervisor'): False, + ('issue_unassigned', 'inspector'): False, + ('issue_unassigned', 'project_manager'): False, + ('issue_unassigned', 'customer'): False, + ('issue_unassigned', 'custom'): False, + # issue_status + ('issue_status', 'admin'): False, + ('issue_status', 'supervisor'): False, + ('issue_status', 'inspector'): False, + ('issue_status', 'project_manager'): False, + ('issue_status', 'customer'): False, + ('issue_status', 'custom'): False, + # issue_comment + ('issue_comment', 'admin'): False, + ('issue_comment', 'supervisor'): False, + ('issue_comment', 'inspector'): False, + ('issue_comment', 'project_manager'): False, + ('issue_comment', 'customer'): False, + ('issue_comment', 'custom'): False, + # issue_follow_update (followers always notified implicitly) + ('issue_follow_update', 'admin'): False, + ('issue_follow_update', 'supervisor'): False, + ('issue_follow_update', 'inspector'): False, + ('issue_follow_update', 'project_manager'): False, + ('issue_follow_update', 'customer'): False, + ('issue_follow_update', 'custom'): False, + # issue_flagged (from inspection) + ('issue_flagged', 'admin'): False, + ('issue_flagged', 'supervisor'): False, + ('issue_flagged', 'inspector'): False, + ('issue_flagged', 'project_manager'): False, + ('issue_flagged', 'customer'): True, + ('issue_flagged', 'custom'): False, + # issue_created (standalone) + ('issue_created', 'admin'): False, + ('issue_created', 'supervisor'): False, + ('issue_created', 'inspector'): False, + ('issue_created', 'project_manager'): False, + ('issue_created', 'customer'): True, + ('issue_created', 'custom'): False, + # issue_updated_customer + ('issue_updated_customer', 'admin'): False, + ('issue_updated_customer', 'supervisor'): False, + ('issue_updated_customer', 'inspector'): False, + ('issue_updated_customer', 'project_manager'): False, + ('issue_updated_customer', 'customer'): True, + ('issue_updated_customer', 'custom'): False, + # verification_requested + ('verification_requested', 'admin'): True, + ('verification_requested', 'supervisor'): True, + ('verification_requested', 'inspector'): False, + ('verification_requested', 'project_manager'): False, + ('verification_requested', 'customer'): False, + ('verification_requested', 'custom'): False, + # sla_alert (assignee + followers always notified implicitly) + ('sla_alert', 'admin'): True, + ('sla_alert', 'supervisor'): False, + ('sla_alert', 'inspector'): False, + ('sla_alert', 'project_manager'): False, + ('sla_alert', 'customer'): False, + ('sla_alert', 'custom'): False, +} + + +class NotificationMatrix(db.Model): + """Admin-controlled per-event notification routing.""" + + __tablename__ = 'notification_matrix' + + id = db.Column(db.Integer, primary_key=True) + event_type = db.Column(db.String(50), nullable=False) + role_key = db.Column(db.String(30), nullable=False) + enabled = db.Column(db.Boolean, nullable=False, default=True) + custom_emails = db.Column(db.Text, nullable=True) # JSON list, only used when role_key='custom' + + __table_args__ = ( + db.UniqueConstraint('event_type', 'role_key', name='uq_notif_matrix_event_role'), + ) + + def get_custom_emails(self): + """Return custom_emails as a Python list.""" + if not self.custom_emails: + return [] + try: + result = json.loads(self.custom_emails) + return [e.strip() for e in result if isinstance(e, str) and e.strip()] + except (json.JSONDecodeError, TypeError): + return [] + + def __repr__(self): + return f'' + + +def get_matrix_row(event_type: str, role_key: str) -> NotificationMatrix: + """ + Return the matrix row for (event_type, role_key), creating it from + defaults if it doesn't exist yet. Safe to call without seeding. + """ + row = NotificationMatrix.query.filter_by( + event_type=event_type, role_key=role_key + ).first() + if row is None: + default = MATRIX_DEFAULTS.get((event_type, role_key), False) + row = NotificationMatrix( + event_type=event_type, + role_key=role_key, + enabled=default, + ) + db.session.add(row) + db.session.flush() + return row + + +def is_enabled(event_type: str, role_key: str) -> bool: + """Return True if the matrix enables notifications for this event/role pair.""" + row = NotificationMatrix.query.filter_by( + event_type=event_type, role_key=role_key + ).first() + if row is None: + return MATRIX_DEFAULTS.get((event_type, role_key), False) + return row.enabled + + +def get_custom_emails_for(event_type: str) -> list: + """Return the custom email list for this event type.""" + row = NotificationMatrix.query.filter_by( + event_type=event_type, role_key='custom' + ).first() + if row is None: + return [] + return row.get_custom_emails() diff --git a/app/routes/auth.py b/app/routes/auth.py index 24ee932..7754a25 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -230,4 +230,56 @@ def toggle_active(user_id): f'account {action_label} by {current_user.username}', ) flash(f'User {user.username} has been {action_label}.', 'success') - return redirect(request.referrer or url_for('auth.list_users')) \ No newline at end of file + return redirect(request.referrer or url_for('auth.list_users')) + +# ── Notification Matrix ─────────────────────────────────────────────────────── + +@bp.route('/notification-matrix', methods=['GET', 'POST']) +@login_required +@admin_required +def notification_matrix(): + """Admin-only notification matrix — controls who receives each event type.""" + import json as _json + from app.models.notification_matrix import ( + NotificationMatrix, MATRIX_EVENTS, MATRIX_ROLES, MATRIX_DEFAULTS, + ) + + if request.method == 'POST': + for event_key in MATRIX_EVENTS: + for role_key, _ in MATRIX_ROLES: + row = NotificationMatrix.query.filter_by( + event_type=event_key, role_key=role_key + ).first() + if row is None: + row = NotificationMatrix(event_type=event_key, role_key=role_key) + db.session.add(row) + + if role_key == 'custom': + raw = request.form.get(f'custom_{event_key}', '').strip() + # Parse comma-separated emails into a JSON list + emails = [e.strip() for e in raw.split(',') if e.strip()] + row.custom_emails = _json.dumps(emails) + row.enabled = bool(emails) + else: + row.enabled = bool(request.form.get(f'matrix_{event_key}_{role_key}')) + + db.session.commit() + log_action(ACTION_UPDATE, 'NotificationMatrix', None, + 'Notification Matrix', 'admin updated notification matrix') + logger.info('NOTIFICATION MATRIX UPDATED | by=%s', current_user.username) + flash('Notification matrix saved successfully.', 'success') + return redirect(url_for('auth.notification_matrix')) + + # Build current state dict: {event_key: {role_key: enabled/emails}} + all_rows = NotificationMatrix.query.all() + state = {} # event_key -> role_key -> row + for row in all_rows: + state.setdefault(row.event_type, {})[row.role_key] = row + + return render_template( + 'auth/notification_matrix.html', + matrix_events = MATRIX_EVENTS, + matrix_roles = MATRIX_ROLES, + defaults = MATRIX_DEFAULTS, + state = state, + ) diff --git a/app/routes/inspections.py b/app/routes/inspections.py index f9bee91..1e76ac3 100644 --- a/app/routes/inspections.py +++ b/app/routes/inspections.py @@ -15,7 +15,7 @@ from app.models.user import User from app.utils.forms import StartInspectionForm, IssueForm from app.utils.decorators import supervisor_required from app.utils.pdf_export import generate_inspection_pdf -from app.utils.notifications import notify, notify_customers_for_facility +from app.utils.notifications import notify, notify_customers_for_facility, notify_by_matrix from app.models.notification import ( EVENT_INSPECTION_DONE, EVENT_ISSUE_ASSIGNED, EVENT_CUSTOMER_INSPECTION_DONE, EVENT_CUSTOMER_ISSUE_UPDATED, @@ -371,36 +371,21 @@ def execute(inspection_id): _save_responses(inspection, responses) db.session.commit() - supervisors = User.query.filter(User.role.in_(['admin', 'supervisor'])).all() inspection_link = url_for('inspections.view', inspection_id=inspection.id) score_display = f'{score:.1f}%' if score is not None else 'N/A' - for supervisor in supervisors: - if supervisor.id != current_user.id: - notify( - recipient = supervisor, - title = f'Inspection #{inspection.id} Completed', - body = ( - f'{current_user.username} completed an inspection at ' - f'{inspection.facility.name} using the ' - f'"{inspection.template.name}" template. ' - f'Overall score: {score_display}.' - ), - link = inspection_link, - inspection_id = inspection.id, - event_type = EVENT_INSPECTION_DONE, - send_email = True, - ) - notify_customers_for_facility( - facility_id = inspection.facility_id, - event_type = EVENT_CUSTOMER_INSPECTION_DONE, - title = f'Inspection Completed at {inspection.facility.name}', + notify_by_matrix( + event_type = 'inspection_completed', + title = f'Inspection #{inspection.id} Completed', body = ( - f'An inspection using the "{inspection.template.name}" template ' - f'was completed at {inspection.facility.name}. ' + f'{current_user.username} completed an inspection at ' + f'{inspection.facility.name} using the ' + f'"{inspection.template.name}" template. ' f'Overall score: {score_display}.' ), - link = url_for('inspections.view', inspection_id=inspection.id), + link = inspection_link, inspection_id = inspection.id, + facility_id = inspection.facility_id, + exclude_user_ids = {current_user.id}, ) db.session.commit() log_action(ACTION_UPDATE, 'Inspection', inspection.id, @@ -736,19 +721,20 @@ def flag_issue(inspection_id): ) db.session.commit() - notify_customers_for_facility( - facility_id = inspection.facility_id, - event_type = EVENT_CUSTOMER_ISSUE_UPDATED, - title = f'New Issue #{issue.id} at {inspection.facility.name}', - body = ( + notify_by_matrix( + event_type = 'issue_flagged', + title = f'New Issue #{issue.id} at {inspection.facility.name}', + body = ( f'A new {issue.severity.title()}-severity issue has been logged ' f'in {issue.area.name} at {inspection.facility.name} ' f'during inspection #{inspection_id}. ' f'Description: {issue.description[:120]}' f'{"…" if len(issue.description) > 120 else ""}' ), - link = url_for('issues.view', issue_id=issue.id), - issue_id = issue.id, + link = url_for('issues.view', issue_id=issue.id), + issue_id = issue.id, + facility_id = inspection.facility_id, + exclude_user_ids = {current_user.id}, ) db.session.commit() diff --git a/app/routes/issues.py b/app/routes/issues.py index 342ae4c..2b07a12 100644 --- a/app/routes/issues.py +++ b/app/routes/issues.py @@ -14,7 +14,7 @@ from app.models.notification import ( ) from app.utils.forms import IssueForm, IssueUpdateForm from app.utils.decorators import supervisor_required -from app.utils.notifications import notify, notify_customers_for_facility +from app.utils.notifications import notify, notify_customers_for_facility, notify_by_matrix from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE from app.utils.scope import get_customer_scope from app.utils.sla import sla_status @@ -268,21 +268,22 @@ def view(issue_id): exclude_user_ids = exclude_ids, ) - # ── Notify customer portal users for this facility ────────── + # ── Notify via matrix (issue_updated_customer) ─────────────── facility_id = issue.area.facility_id if issue.area else None - if facility_id: - changes_summary = '; '.join(changes) if changes else 'updated' - notify_customers_for_facility( - facility_id = facility_id, - event_type = EVENT_CUSTOMER_ISSUE_UPDATED, - title = f'Issue #{issue.id} Updated at {issue.area.facility.name}', - body = ( + if facility_id and changes: + changes_summary = '; '.join(changes) + notify_by_matrix( + event_type = 'issue_updated_customer', + title = f'Issue #{issue.id} Updated at {issue.area.facility.name}', + body = ( f'Issue #{issue.id} ({issue.severity.title()} severity) ' f'in {issue.area.name} was updated: {changes_summary}. ' f'Current status: {issue.status.replace("_", " ").title()}.' ), - link = url_for('issues.view', issue_id=issue.id), - issue_id = issue.id, + link = url_for('issues.view', issue_id=issue.id), + issue_id = issue.id, + facility_id = facility_id, + exclude_user_ids = {current_user.id}, ) db.session.commit() # Commit all notifications log_action(ACTION_UPDATE, 'Issue', issue.id, @@ -398,21 +399,22 @@ def create(): ) db.session.commit() - # ── Notify customer portal users for this facility ────────── + # ── Notify via matrix (issue_created) ──────────────────────── area = db.session.get(Area, issue.area_id) if area: - notify_customers_for_facility( - facility_id = area.facility_id, - event_type = EVENT_CUSTOMER_ISSUE_UPDATED, - title = f'New Issue #{issue.id} at {area.facility.name}', - body = ( + notify_by_matrix( + event_type = 'issue_created', + title = f'New Issue #{issue.id} at {area.facility.name}', + body = ( f'A new {issue.severity.title()}-severity issue has been logged ' f'in {area.name} at {area.facility.name}. ' f'Description: {issue.description[:120]}' f'{"…" if len(issue.description) > 120 else ""}' ), - link = url_for('issues.view', issue_id=issue.id), - issue_id = issue.id, + link = url_for('issues.view', issue_id=issue.id), + issue_id = issue.id, + facility_id = area.facility_id, + exclude_user_ids = {current_user.id}, ) db.session.commit() flash('Issue created.', 'success') @@ -489,25 +491,19 @@ def request_verification(issue_id): f'#{issue_id} in {issue.area.name}', f'status=pending_verification; requested_by={current_user.username}') - # Notify supervisors - from app.utils.notifications import notify - from app.models.notification import EVENT_ISSUE_STATUS - supervisors = User.query.filter(User.role.in_(['admin', 'supervisor'])).all() - for sup in supervisors: - if sup.id != current_user.id: - notify( - recipient = sup, - title = f'Issue #{issue_id} Awaiting Verification', - body = ( - f'{current_user.username} has marked Issue #{issue_id} ' - f'({issue.severity.title()} severity) in {issue.area.name} ' - f'as pending your verification.' - ), - link = url_for('issues.view', issue_id=issue_id), - issue_id = issue_id, - event_type = EVENT_ISSUE_STATUS, - send_email = True, - ) + # Notify via matrix (verification_requested) + notify_by_matrix( + event_type = 'verification_requested', + title = f'Issue #{issue_id} Awaiting Verification', + body = ( + f'{current_user.username} has marked Issue #{issue_id} ' + f'({issue.severity.title()} severity) in {issue.area.name} ' + f'as pending your verification.' + ), + link = url_for('issues.view', issue_id=issue_id), + issue_id = issue_id, + exclude_user_ids = {current_user.id}, + ) db.session.commit() flash('Issue marked as pending verification. Supervisors have been notified.', 'info') return redirect(url_for('issues.view', issue_id=issue_id)) @@ -601,4 +597,4 @@ def delete(issue_id): f'facility={facility_name}; description={issue_desc}') flash(f'Issue #{issue_id_snap} has been permanently deleted.', 'success') - return redirect(url_for('issues.index')) \ No newline at end of file + return redirect(url_for('issues.index')) diff --git a/app/templates/auth/notification_matrix.html b/app/templates/auth/notification_matrix.html new file mode 100644 index 0000000..d78d2ec --- /dev/null +++ b/app/templates/auth/notification_matrix.html @@ -0,0 +1,218 @@ +{% extends "base.html" %} +{% block title %}Notification Matrix{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+ + {# ── Page header ── #} +
+
+

Notification Matrix

+

+ Control which roles receive email notifications for each event. + Assignee, followers, and customer portal + recipients are handled automatically where marked. +

+
+ + Back to Users + +
+ + {# ── Legend ── #} +
+ Enabled by default + Disabled by default + implicit Always notified — not controlled here +
+ +
+ + +
+ + + {# Row 1: spanning group headers #} + + + + + + + {# Row 2: individual role headers #} + + {% for role_key, role_label in matrix_roles if role_key != 'custom' and role_key != 'customer' %} + + {% endfor %} + + + + + + {% for event_key, event_label in matrix_events.items() %} + {% set event_state = state.get(event_key, {}) %} + + + + {# Internal role checkboxes (admin, supervisor, inspector, project_manager) #} + {% for role_key, _ in matrix_roles if role_key not in ('custom', 'customer') %} + {% set row = event_state.get(role_key) %} + {% if row is not none %} + {% set checked = row.enabled %} + {% else %} + {% set checked = defaults.get((event_key, role_key), false) %} + {% endif %} + + {% endfor %} + + {# Customer checkbox #} + {% set cust_row = event_state.get('customer') %} + {% if cust_row is not none %} + {% set cust_checked = cust_row.enabled %} + {% else %} + {% set cust_checked = defaults.get((event_key, 'customer'), false) %} + {% endif %} + + + {# Custom emails text input #} + {% set custom_row = event_state.get('custom') %} + {% if custom_row is not none %} + {% set custom_val = custom_row.get_custom_emails() | join(', ') %} + {% else %} + {% set custom_val = '' %} + {% endif %} + + + {% endfor %} + +
EventInternal RecipientsCustomerCustom Recipients
{{ role_label }}CustomerEmail addresses
(comma-separated)
+ {{ event_label }} + {# Show implicit labels where assignee/followers are always notified #} + {% if event_key in ('issue_assigned', 'issue_reassigned', 'issue_unassigned', + 'issue_status', 'issue_comment') %} + assignee + {% endif %} + {% if event_key == 'issue_follow_update' %} + followers + {% endif %} + {% if event_key == 'sla_alert' %} + assignee + followers + {% endif %} + + + + + + +
+
+ +
+ + + Reset + + + + Changes take effect immediately for all subsequent notifications. + +
+
+ + {# ── Notes card ── #} +
+
+

+ Internal: Admin, Supervisor, Inspector, and Project Manager users + receive in-app notifications and emails based on their individual + preference settings. +

+

+ Customer: Customer-portal users are notified only for facilities + they are assigned to via their project/facility assignments. +

+

+ Custom Recipients: Additional email addresses (e.g. external managers) + receive a plain email. They do not get in-app notifications and are not affected + by individual user preference settings. +

+
+
+ +
+{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html index ead2b0a..6efee46 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -52,6 +52,47 @@ Janitorial QC + + @@ -111,12 +152,17 @@ + {% endif %}