Apr 2 2026: implement notification settings matrix
This commit is contained in:
@@ -4,4 +4,5 @@ from app.models.inspection import (InspectionTemplate, ChecklistItem,
|
|||||||
Inspection, InspectionResult)
|
Inspection, InspectionResult)
|
||||||
from app.models.issue import Issue
|
from app.models.issue import Issue
|
||||||
from app.models.project import Project, CustomerAssignment
|
from app.models.project import Project, CustomerAssignment
|
||||||
from app.models.api_token import RefreshToken, DeviceToken
|
from app.models.api_token import RefreshToken, DeviceToken
|
||||||
|
from app.models.notification_matrix import NotificationMatrix
|
||||||
@@ -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'<NotificationMatrix {self.event_type} / {self.role_key} enabled={self.enabled}>'
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
+53
-1
@@ -230,4 +230,56 @@ def toggle_active(user_id):
|
|||||||
f'account {action_label} by {current_user.username}',
|
f'account {action_label} by {current_user.username}',
|
||||||
)
|
)
|
||||||
flash(f'User {user.username} has been {action_label}.', 'success')
|
flash(f'User {user.username} has been {action_label}.', 'success')
|
||||||
return redirect(request.referrer or url_for('auth.list_users'))
|
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,
|
||||||
|
)
|
||||||
|
|||||||
+18
-32
@@ -15,7 +15,7 @@ from app.models.user import User
|
|||||||
from app.utils.forms import StartInspectionForm, IssueForm
|
from app.utils.forms import StartInspectionForm, IssueForm
|
||||||
from app.utils.decorators import supervisor_required
|
from app.utils.decorators import supervisor_required
|
||||||
from app.utils.pdf_export import generate_inspection_pdf
|
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 (
|
from app.models.notification import (
|
||||||
EVENT_INSPECTION_DONE, EVENT_ISSUE_ASSIGNED,
|
EVENT_INSPECTION_DONE, EVENT_ISSUE_ASSIGNED,
|
||||||
EVENT_CUSTOMER_INSPECTION_DONE, EVENT_CUSTOMER_ISSUE_UPDATED,
|
EVENT_CUSTOMER_INSPECTION_DONE, EVENT_CUSTOMER_ISSUE_UPDATED,
|
||||||
@@ -371,36 +371,21 @@ def execute(inspection_id):
|
|||||||
_save_responses(inspection, responses)
|
_save_responses(inspection, responses)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
supervisors = User.query.filter(User.role.in_(['admin', 'supervisor'])).all()
|
|
||||||
inspection_link = url_for('inspections.view', inspection_id=inspection.id)
|
inspection_link = url_for('inspections.view', inspection_id=inspection.id)
|
||||||
score_display = f'{score:.1f}%' if score is not None else 'N/A'
|
score_display = f'{score:.1f}%' if score is not None else 'N/A'
|
||||||
for supervisor in supervisors:
|
notify_by_matrix(
|
||||||
if supervisor.id != current_user.id:
|
event_type = 'inspection_completed',
|
||||||
notify(
|
title = f'Inspection #{inspection.id} Completed',
|
||||||
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}',
|
|
||||||
body = (
|
body = (
|
||||||
f'An inspection using the "{inspection.template.name}" template '
|
f'{current_user.username} completed an inspection at '
|
||||||
f'was completed at {inspection.facility.name}. '
|
f'{inspection.facility.name} using the '
|
||||||
|
f'"{inspection.template.name}" template. '
|
||||||
f'Overall score: {score_display}.'
|
f'Overall score: {score_display}.'
|
||||||
),
|
),
|
||||||
link = url_for('inspections.view', inspection_id=inspection.id),
|
link = inspection_link,
|
||||||
inspection_id = inspection.id,
|
inspection_id = inspection.id,
|
||||||
|
facility_id = inspection.facility_id,
|
||||||
|
exclude_user_ids = {current_user.id},
|
||||||
)
|
)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
log_action(ACTION_UPDATE, 'Inspection', inspection.id,
|
log_action(ACTION_UPDATE, 'Inspection', inspection.id,
|
||||||
@@ -736,19 +721,20 @@ def flag_issue(inspection_id):
|
|||||||
)
|
)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
notify_customers_for_facility(
|
notify_by_matrix(
|
||||||
facility_id = inspection.facility_id,
|
event_type = 'issue_flagged',
|
||||||
event_type = EVENT_CUSTOMER_ISSUE_UPDATED,
|
title = f'New Issue #{issue.id} at {inspection.facility.name}',
|
||||||
title = f'New Issue #{issue.id} at {inspection.facility.name}',
|
body = (
|
||||||
body = (
|
|
||||||
f'A new {issue.severity.title()}-severity issue has been logged '
|
f'A new {issue.severity.title()}-severity issue has been logged '
|
||||||
f'in {issue.area.name} at {inspection.facility.name} '
|
f'in {issue.area.name} at {inspection.facility.name} '
|
||||||
f'during inspection #{inspection_id}. '
|
f'during inspection #{inspection_id}. '
|
||||||
f'Description: {issue.description[:120]}'
|
f'Description: {issue.description[:120]}'
|
||||||
f'{"…" if len(issue.description) > 120 else ""}'
|
f'{"…" if len(issue.description) > 120 else ""}'
|
||||||
),
|
),
|
||||||
link = url_for('issues.view', issue_id=issue.id),
|
link = url_for('issues.view', issue_id=issue.id),
|
||||||
issue_id = issue.id,
|
issue_id = issue.id,
|
||||||
|
facility_id = inspection.facility_id,
|
||||||
|
exclude_user_ids = {current_user.id},
|
||||||
)
|
)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
|||||||
+35
-39
@@ -14,7 +14,7 @@ from app.models.notification import (
|
|||||||
)
|
)
|
||||||
from app.utils.forms import IssueForm, IssueUpdateForm
|
from app.utils.forms import IssueForm, IssueUpdateForm
|
||||||
from app.utils.decorators import supervisor_required
|
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.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE
|
||||||
from app.utils.scope import get_customer_scope
|
from app.utils.scope import get_customer_scope
|
||||||
from app.utils.sla import sla_status
|
from app.utils.sla import sla_status
|
||||||
@@ -268,21 +268,22 @@ def view(issue_id):
|
|||||||
exclude_user_ids = exclude_ids,
|
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
|
facility_id = issue.area.facility_id if issue.area else None
|
||||||
if facility_id:
|
if facility_id and changes:
|
||||||
changes_summary = '; '.join(changes) if changes else 'updated'
|
changes_summary = '; '.join(changes)
|
||||||
notify_customers_for_facility(
|
notify_by_matrix(
|
||||||
facility_id = facility_id,
|
event_type = 'issue_updated_customer',
|
||||||
event_type = EVENT_CUSTOMER_ISSUE_UPDATED,
|
title = f'Issue #{issue.id} Updated at {issue.area.facility.name}',
|
||||||
title = f'Issue #{issue.id} Updated at {issue.area.facility.name}',
|
body = (
|
||||||
body = (
|
|
||||||
f'Issue #{issue.id} ({issue.severity.title()} severity) '
|
f'Issue #{issue.id} ({issue.severity.title()} severity) '
|
||||||
f'in {issue.area.name} was updated: {changes_summary}. '
|
f'in {issue.area.name} was updated: {changes_summary}. '
|
||||||
f'Current status: {issue.status.replace("_", " ").title()}.'
|
f'Current status: {issue.status.replace("_", " ").title()}.'
|
||||||
),
|
),
|
||||||
link = url_for('issues.view', issue_id=issue.id),
|
link = url_for('issues.view', issue_id=issue.id),
|
||||||
issue_id = issue.id,
|
issue_id = issue.id,
|
||||||
|
facility_id = facility_id,
|
||||||
|
exclude_user_ids = {current_user.id},
|
||||||
)
|
)
|
||||||
db.session.commit() # Commit all notifications
|
db.session.commit() # Commit all notifications
|
||||||
log_action(ACTION_UPDATE, 'Issue', issue.id,
|
log_action(ACTION_UPDATE, 'Issue', issue.id,
|
||||||
@@ -398,21 +399,22 @@ def create():
|
|||||||
)
|
)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
# ── Notify customer portal users for this facility ──────────
|
# ── Notify via matrix (issue_created) ────────────────────────
|
||||||
area = db.session.get(Area, issue.area_id)
|
area = db.session.get(Area, issue.area_id)
|
||||||
if area:
|
if area:
|
||||||
notify_customers_for_facility(
|
notify_by_matrix(
|
||||||
facility_id = area.facility_id,
|
event_type = 'issue_created',
|
||||||
event_type = EVENT_CUSTOMER_ISSUE_UPDATED,
|
title = f'New Issue #{issue.id} at {area.facility.name}',
|
||||||
title = f'New Issue #{issue.id} at {area.facility.name}',
|
body = (
|
||||||
body = (
|
|
||||||
f'A new {issue.severity.title()}-severity issue has been logged '
|
f'A new {issue.severity.title()}-severity issue has been logged '
|
||||||
f'in {area.name} at {area.facility.name}. '
|
f'in {area.name} at {area.facility.name}. '
|
||||||
f'Description: {issue.description[:120]}'
|
f'Description: {issue.description[:120]}'
|
||||||
f'{"…" if len(issue.description) > 120 else ""}'
|
f'{"…" if len(issue.description) > 120 else ""}'
|
||||||
),
|
),
|
||||||
link = url_for('issues.view', issue_id=issue.id),
|
link = url_for('issues.view', issue_id=issue.id),
|
||||||
issue_id = issue.id,
|
issue_id = issue.id,
|
||||||
|
facility_id = area.facility_id,
|
||||||
|
exclude_user_ids = {current_user.id},
|
||||||
)
|
)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash('Issue created.', 'success')
|
flash('Issue created.', 'success')
|
||||||
@@ -489,25 +491,19 @@ def request_verification(issue_id):
|
|||||||
f'#{issue_id} in {issue.area.name}',
|
f'#{issue_id} in {issue.area.name}',
|
||||||
f'status=pending_verification; requested_by={current_user.username}')
|
f'status=pending_verification; requested_by={current_user.username}')
|
||||||
|
|
||||||
# Notify supervisors
|
# Notify via matrix (verification_requested)
|
||||||
from app.utils.notifications import notify
|
notify_by_matrix(
|
||||||
from app.models.notification import EVENT_ISSUE_STATUS
|
event_type = 'verification_requested',
|
||||||
supervisors = User.query.filter(User.role.in_(['admin', 'supervisor'])).all()
|
title = f'Issue #{issue_id} Awaiting Verification',
|
||||||
for sup in supervisors:
|
body = (
|
||||||
if sup.id != current_user.id:
|
f'{current_user.username} has marked Issue #{issue_id} '
|
||||||
notify(
|
f'({issue.severity.title()} severity) in {issue.area.name} '
|
||||||
recipient = sup,
|
f'as pending your verification.'
|
||||||
title = f'Issue #{issue_id} Awaiting Verification',
|
),
|
||||||
body = (
|
link = url_for('issues.view', issue_id=issue_id),
|
||||||
f'{current_user.username} has marked Issue #{issue_id} '
|
issue_id = issue_id,
|
||||||
f'({issue.severity.title()} severity) in {issue.area.name} '
|
exclude_user_ids = {current_user.id},
|
||||||
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,
|
|
||||||
)
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash('Issue marked as pending verification. Supervisors have been notified.', 'info')
|
flash('Issue marked as pending verification. Supervisors have been notified.', 'info')
|
||||||
return redirect(url_for('issues.view', issue_id=issue_id))
|
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}')
|
f'facility={facility_name}; description={issue_desc}')
|
||||||
|
|
||||||
flash(f'Issue #{issue_id_snap} has been permanently deleted.', 'success')
|
flash(f'Issue #{issue_id_snap} has been permanently deleted.', 'success')
|
||||||
return redirect(url_for('issues.index'))
|
return redirect(url_for('issues.index'))
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Notification Matrix{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<style>
|
||||||
|
.matrix-wrap { max-width: 1100px; margin: 0 auto; }
|
||||||
|
|
||||||
|
/* ── Matrix table ── */
|
||||||
|
.matrix-tbl { border-collapse: collapse; width: 100%; font-size: .82rem; }
|
||||||
|
.matrix-tbl th, .matrix-tbl td {
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
padding: .35rem .55rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.matrix-tbl thead th {
|
||||||
|
background: #1a1d23; color: #fff; font-weight: 600;
|
||||||
|
text-align: center; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.matrix-tbl thead th.event-col { text-align: left; }
|
||||||
|
.matrix-tbl thead th.group-hdr {
|
||||||
|
background: #2563eb; font-size: .75rem; letter-spacing: .04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.matrix-tbl tbody tr:nth-child(even) td { background: #f8fafc; }
|
||||||
|
.matrix-tbl tbody tr:hover td { background: #eff6ff; }
|
||||||
|
.matrix-tbl td.event-name { font-weight: 500; color: #1a1d23; white-space: nowrap; }
|
||||||
|
.matrix-tbl td.check-cell { text-align: center; }
|
||||||
|
|
||||||
|
/* Checkbox styling */
|
||||||
|
.matrix-check {
|
||||||
|
width: 1.1rem; height: 1.1rem; cursor: pointer;
|
||||||
|
accent-color: #2563eb;
|
||||||
|
}
|
||||||
|
/* Disabled row (event has no meaningful broadcast) */
|
||||||
|
.matrix-tbl tr.implicit td { color: #94a3b8; }
|
||||||
|
.matrix-tbl tr.implicit td.event-name { color: #64748b; font-style: italic; }
|
||||||
|
|
||||||
|
/* Custom emails cell */
|
||||||
|
.custom-cell { min-width: 180px; }
|
||||||
|
.custom-input {
|
||||||
|
width: 100%; font-size: .75rem;
|
||||||
|
border: 1px solid #e2e8f0; border-radius: 4px;
|
||||||
|
padding: .2rem .4rem; color: #374151;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
.custom-input:focus {
|
||||||
|
outline: none; border-color: #2563eb;
|
||||||
|
box-shadow: 0 0 0 2px rgba(37,99,235,.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Legend */
|
||||||
|
.legend { font-size: .78rem; color: #64748b; }
|
||||||
|
.legend span { display: inline-flex; align-items: center; gap: .3rem; margin-right: 1rem; }
|
||||||
|
|
||||||
|
/* Implicit badge */
|
||||||
|
.badge-implicit {
|
||||||
|
font-size: .62rem; background: #f1f5f9; color: #64748b;
|
||||||
|
border: 1px solid #e2e8f0; border-radius: 4px;
|
||||||
|
padding: 1px 5px; vertical-align: middle;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="matrix-wrap mt-3">
|
||||||
|
|
||||||
|
{# ── Page header ── #}
|
||||||
|
<div class="d-flex justify-content-between align-items-start mb-3">
|
||||||
|
<div>
|
||||||
|
<h4 class="mb-0"><i class="bi bi-grid-3x3-gap-fill text-primary me-2"></i>Notification Matrix</h4>
|
||||||
|
<p class="text-muted mb-0 mt-1" style="font-size:.85rem;">
|
||||||
|
Control which roles receive email notifications for each event.
|
||||||
|
<strong>Assignee</strong>, <strong>followers</strong>, and <strong>customer portal</strong>
|
||||||
|
recipients are handled automatically where marked.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<a href="{{ url_for('auth.list_users') }}" class="btn btn-outline-secondary btn-sm">
|
||||||
|
<i class="bi bi-arrow-left"></i> Back to Users
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Legend ── #}
|
||||||
|
<div class="legend mb-3 d-flex flex-wrap align-items-center">
|
||||||
|
<span><input type="checkbox" checked disabled class="matrix-check"> Enabled by default</span>
|
||||||
|
<span><input type="checkbox" disabled class="matrix-check"> Disabled by default</span>
|
||||||
|
<span><span class="badge-implicit">implicit</span> Always notified — not controlled here</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="POST" action="{{ url_for('auth.notification_matrix') }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
|
||||||
|
<div class="table-responsive shadow-sm rounded">
|
||||||
|
<table class="matrix-tbl">
|
||||||
|
<thead>
|
||||||
|
{# Row 1: spanning group headers #}
|
||||||
|
<tr>
|
||||||
|
<th class="event-col" rowspan="2" style="min-width:200px;">Event</th>
|
||||||
|
<th class="group-hdr" colspan="4">Internal Recipients</th>
|
||||||
|
<th class="group-hdr" colspan="1">Customer</th>
|
||||||
|
<th class="group-hdr" colspan="1">Custom Recipients</th>
|
||||||
|
</tr>
|
||||||
|
{# Row 2: individual role headers #}
|
||||||
|
<tr>
|
||||||
|
{% for role_key, role_label in matrix_roles if role_key != 'custom' and role_key != 'customer' %}
|
||||||
|
<th style="min-width:80px;">{{ role_label }}</th>
|
||||||
|
{% endfor %}
|
||||||
|
<th style="min-width:80px;">Customer</th>
|
||||||
|
<th style="min-width:200px;">Email addresses<br><span style="font-weight:400;font-size:.7rem;color:#94a3b8;">(comma-separated)</span></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for event_key, event_label in matrix_events.items() %}
|
||||||
|
{% set event_state = state.get(event_key, {}) %}
|
||||||
|
<tr>
|
||||||
|
<td class="event-name">
|
||||||
|
{{ 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') %}
|
||||||
|
<span class="badge-implicit ms-1">assignee</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if event_key == 'issue_follow_update' %}
|
||||||
|
<span class="badge-implicit ms-1">followers</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if event_key == 'sla_alert' %}
|
||||||
|
<span class="badge-implicit ms-1">assignee</span>
|
||||||
|
<span class="badge-implicit ms-1">followers</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{# 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 %}
|
||||||
|
<td class="check-cell">
|
||||||
|
<input type="checkbox"
|
||||||
|
class="matrix-check"
|
||||||
|
name="matrix_{{ event_key }}_{{ role_key }}"
|
||||||
|
value="1"
|
||||||
|
{% if checked %}checked{% endif %}>
|
||||||
|
</td>
|
||||||
|
{% 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 %}
|
||||||
|
<td class="check-cell">
|
||||||
|
<input type="checkbox"
|
||||||
|
class="matrix-check"
|
||||||
|
name="matrix_{{ event_key }}_customer"
|
||||||
|
value="1"
|
||||||
|
{% if cust_checked %}checked{% endif %}>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{# 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 %}
|
||||||
|
<td class="custom-cell">
|
||||||
|
<input type="text"
|
||||||
|
class="custom-input"
|
||||||
|
name="custom_{{ event_key }}"
|
||||||
|
value="{{ custom_val }}"
|
||||||
|
placeholder="e.g. ops@company.com, mgr@co.com">
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 d-flex gap-2 align-items-center">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="bi bi-check2-circle me-1"></i>Save Matrix
|
||||||
|
</button>
|
||||||
|
<a href="{{ url_for('auth.notification_matrix') }}" class="btn btn-outline-secondary">
|
||||||
|
Reset
|
||||||
|
</a>
|
||||||
|
<span class="text-muted ms-2" style="font-size:.8rem;">
|
||||||
|
<i class="bi bi-info-circle me-1"></i>
|
||||||
|
Changes take effect immediately for all subsequent notifications.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{# ── Notes card ── #}
|
||||||
|
<div class="card mt-4 border-0 bg-light">
|
||||||
|
<div class="card-body py-2 px-3">
|
||||||
|
<p class="mb-1" style="font-size:.8rem;">
|
||||||
|
<strong>Internal:</strong> Admin, Supervisor, Inspector, and Project Manager users
|
||||||
|
receive in-app notifications and emails based on their individual
|
||||||
|
<a href="{{ url_for('notifications.preferences') }}">preference settings</a>.
|
||||||
|
</p>
|
||||||
|
<p class="mb-1" style="font-size:.8rem;">
|
||||||
|
<strong>Customer:</strong> Customer-portal users are notified only for facilities
|
||||||
|
they are assigned to via their project/facility assignments.
|
||||||
|
</p>
|
||||||
|
<p class="mb-0" style="font-size:.8rem;">
|
||||||
|
<strong>Custom Recipients:</strong> 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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
+135
-64
@@ -52,6 +52,47 @@
|
|||||||
<a class="navbar-brand" href="{{ url_for('dashboard.index') }}">
|
<a class="navbar-brand" href="{{ url_for('dashboard.index') }}">
|
||||||
<i class="bi bi-clipboard-check"></i> Janitorial QC
|
<i class="bi bi-clipboard-check"></i> Janitorial QC
|
||||||
</a>
|
</a>
|
||||||
|
<!-- ── Bell + toggler always visible on mobile/tablet ── -->
|
||||||
|
<div class="d-flex align-items-center gap-2 ms-auto me-2 d-lg-none">
|
||||||
|
<!-- Notification bell (always visible) -->
|
||||||
|
<div class="dropdown">
|
||||||
|
<a class="nav-link position-relative notif-bell-wrapper text-white"
|
||||||
|
href="#"
|
||||||
|
id="notifDropdownMobile"
|
||||||
|
role="button"
|
||||||
|
data-bs-toggle="dropdown"
|
||||||
|
aria-expanded="false"
|
||||||
|
title="Notifications">
|
||||||
|
<i class="bi bi-bell fs-5"></i>
|
||||||
|
{% if unread_notification_count > 0 %}
|
||||||
|
<span class="badge bg-danger notif-badge" id="notif-count-badge-mobile">
|
||||||
|
{{ unread_notification_count if unread_notification_count <= 99 else '99+' }}
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-danger notif-badge d-none" id="notif-count-badge-mobile"></span>
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
<div class="dropdown-menu dropdown-menu-end notif-dropdown shadow"
|
||||||
|
id="notif-dropdown-menu-mobile">
|
||||||
|
<div class="d-flex justify-content-between align-items-center px-3 py-2 border-bottom">
|
||||||
|
<span class="fw-semibold" style="font-size:.9rem;">Notifications</span>
|
||||||
|
<button class="btn btn-link btn-sm p-0 text-muted text-decoration-none mark-all-read-btn"
|
||||||
|
style="font-size:.75rem;">Mark all as read</button>
|
||||||
|
</div>
|
||||||
|
<div class="notif-list-mobile">
|
||||||
|
<div class="notif-empty">Loading…</div>
|
||||||
|
</div>
|
||||||
|
<div class="border-top d-flex justify-content-between px-3 py-2" style="font-size:.8rem;">
|
||||||
|
<a href="{{ url_for('notifications.index') }}" class="text-decoration-none">
|
||||||
|
<i class="bi bi-list-ul me-1"></i>View all
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('notifications.preferences') }}" class="text-decoration-none text-muted">
|
||||||
|
<i class="bi bi-gear me-1"></i>Preferences
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||||
<span class="navbar-toggler-icon"></span>
|
<span class="navbar-toggler-icon"></span>
|
||||||
</button>
|
</button>
|
||||||
@@ -111,12 +152,17 @@
|
|||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="{{ url_for('audit.index') }}">Audit Trail</a>
|
<a class="nav-link" href="{{ url_for('audit.index') }}">Audit Trail</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ url_for('auth.notification_matrix') }}">
|
||||||
|
<i class="bi bi-grid-3x3-gap-fill"></i> Notif. Matrix
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
<ul class="navbar-nav align-items-center">
|
<ul class="navbar-nav align-items-center">
|
||||||
|
|
||||||
<!-- ── Notification Bell ── -->
|
<!-- ── Notification Bell (desktop lg+ only) ── -->
|
||||||
<li class="nav-item dropdown me-2">
|
<li class="nav-item dropdown me-2 d-none d-lg-block">
|
||||||
<a class="nav-link position-relative notif-bell-wrapper"
|
<a class="nav-link position-relative notif-bell-wrapper"
|
||||||
href="#"
|
href="#"
|
||||||
id="notifDropdown"
|
id="notifDropdown"
|
||||||
@@ -227,50 +273,61 @@
|
|||||||
const CSRF_TOKEN = '{{ csrf_token() }}';
|
const CSRF_TOKEN = '{{ csrf_token() }}';
|
||||||
const POLL_INTERVAL = 60000; // 60 seconds
|
const POLL_INTERVAL = 60000; // 60 seconds
|
||||||
|
|
||||||
const badge = document.getElementById('notif-count-badge');
|
// ── Element refs — desktop bell (lg+) and mobile/tablet bell (<lg) ──
|
||||||
const listEl = document.getElementById('notif-list');
|
const badgeDesktop = document.getElementById('notif-count-badge');
|
||||||
const markAllBtn = document.getElementById('mark-all-read-btn');
|
const badgeMobile = document.getElementById('notif-count-badge-mobile');
|
||||||
|
const listDesktop = document.getElementById('notif-list');
|
||||||
|
const listMobile = document.querySelector('.notif-list-mobile');
|
||||||
|
|
||||||
|
// ── Update both badge instances ────────────────────────────────────────
|
||||||
function updateBadge(count) {
|
function updateBadge(count) {
|
||||||
if (count > 0) {
|
[badgeDesktop, badgeMobile].forEach(function(badge) {
|
||||||
badge.textContent = count > 99 ? '99+' : count;
|
if (!badge) return;
|
||||||
badge.classList.remove('d-none');
|
if (count > 0) {
|
||||||
} else {
|
badge.textContent = count > 99 ? '99+' : count;
|
||||||
badge.textContent = '';
|
badge.classList.remove('d-none');
|
||||||
badge.classList.add('d-none');
|
} else {
|
||||||
}
|
badge.textContent = '';
|
||||||
|
badge.classList.add('d-none');
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderNotifications(notifications) {
|
// ── Render notification items into a given container ───────────────────
|
||||||
|
function renderInto(container, notifications) {
|
||||||
|
if (!container) return;
|
||||||
if (!notifications.length) {
|
if (!notifications.length) {
|
||||||
listEl.innerHTML = '<div class="notif-empty">'
|
container.innerHTML = '<div class="notif-empty">'
|
||||||
+ '<i class="bi bi-check2-circle me-1"></i>You\'re all caught up!</div>';
|
+ '<i class="bi bi-check2-circle me-1"></i>You\'re all caught up!</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
container.innerHTML = notifications.map(function(n) {
|
||||||
listEl.innerHTML = notifications.map(n => `
|
return '<div class="d-block text-decoration-none text-dark notif-item px-3 py-2 border-bottom '
|
||||||
<div class="d-block text-decoration-none text-dark notif-item px-3 py-2 border-bottom
|
+ (n.is_read ? '' : 'unread') + '"'
|
||||||
${n.is_read ? '' : 'unread'}"
|
+ ' data-notif-id="' + n.id + '"'
|
||||||
data-notif-id="${n.id}"
|
+ ' data-link="' + escapeAttr(n.link || '') + '">'
|
||||||
data-link="${escapeAttr(n.link || '')}">
|
+ '<div class="notif-title">' + escapeHtml(n.title) + '</div>'
|
||||||
<div class="notif-title">${escapeHtml(n.title)}</div>
|
+ '<div class="notif-body">' + escapeHtml(n.body) + '</div>'
|
||||||
<div class="notif-body">${escapeHtml(n.body)}</div>
|
+ '<div class="notif-time">' + escapeHtml(n.created_at) + '</div>'
|
||||||
<div class="notif-time">${escapeHtml(n.created_at)}</div>
|
+ '</div>';
|
||||||
</div>
|
}).join('');
|
||||||
`).join('');
|
container.querySelectorAll('.notif-item').forEach(function(el) {
|
||||||
|
el.addEventListener('click', function() {
|
||||||
listEl.querySelectorAll('.notif-item').forEach(el => {
|
var id = this.dataset.notifId;
|
||||||
el.addEventListener('click', function () {
|
var link = this.dataset.link;
|
||||||
const id = this.dataset.notifId;
|
markRead(id, function() {
|
||||||
const link = this.dataset.link;
|
el.classList.remove('unread');
|
||||||
markRead(id, () => {
|
|
||||||
this.classList.remove('unread');
|
|
||||||
if (link) window.location.href = link;
|
if (link) window.location.href = link;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderNotifications(notifications) {
|
||||||
|
renderInto(listDesktop, notifications);
|
||||||
|
renderInto(listMobile, notifications);
|
||||||
|
}
|
||||||
|
|
||||||
function escapeHtml(str) {
|
function escapeHtml(str) {
|
||||||
if (!str) return '';
|
if (!str) return '';
|
||||||
return str.replace(/&/g,'&').replace(/</g,'<')
|
return str.replace(/&/g,'&').replace(/</g,'<')
|
||||||
@@ -278,55 +335,69 @@
|
|||||||
}
|
}
|
||||||
function escapeAttr(str) { return escapeHtml(str); }
|
function escapeAttr(str) { return escapeHtml(str); }
|
||||||
|
|
||||||
|
// ── Fetch + update ─────────────────────────────────────────────────────
|
||||||
window.fetchNotifications = function fetchNotifications() {
|
window.fetchNotifications = function fetchNotifications() {
|
||||||
fetch(FEED_URL, { credentials: 'same-origin' })
|
fetch(FEED_URL, { credentials: 'same-origin' })
|
||||||
.then(r => r.json())
|
.then(function(r) { return r.json(); })
|
||||||
.then(data => {
|
.then(function(data) {
|
||||||
updateBadge(data.unread_count);
|
updateBadge(data.unread_count);
|
||||||
window._jqcNotifications = data.notifications;
|
window._jqcNotifications = data.notifications;
|
||||||
const dropdownEl = document.getElementById('notifDropdown');
|
var deskEl = document.getElementById('notifDropdown');
|
||||||
if (dropdownEl.getAttribute('aria-expanded') === 'true') {
|
var mobileEl = document.getElementById('notifDropdownMobile');
|
||||||
|
var deskOpen = deskEl && deskEl.getAttribute('aria-expanded') === 'true';
|
||||||
|
var mobileOpen = mobileEl && mobileEl.getAttribute('aria-expanded') === 'true';
|
||||||
|
if (deskOpen || mobileOpen) {
|
||||||
renderNotifications(data.notifications);
|
renderNotifications(data.notifications);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(function() {});
|
||||||
}
|
};
|
||||||
|
|
||||||
function markRead(id, callback) {
|
function markRead(id, callback) {
|
||||||
fetch(`${MARK_READ_BASE}${id}/mark-read`, {
|
fetch(MARK_READ_BASE + id + '/mark-read', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'X-CSRFToken': CSRF_TOKEN, 'Content-Type': 'application/json' },
|
headers: { 'X-CSRFToken': CSRF_TOKEN, 'Content-Type': 'application/json' },
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
})
|
})
|
||||||
.then(r => r.json())
|
.then(function(r) { return r.json(); })
|
||||||
.then(() => { if (callback) callback(); fetchNotifications(); })
|
.then(function() { if (callback) callback(); fetchNotifications(); })
|
||||||
.catch(() => { if (callback) callback(); });
|
.catch(function() { if (callback) callback(); });
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('notifDropdown').addEventListener('show.bs.dropdown', function () {
|
// ── Show dropdown → render cached data immediately ─────────────────────
|
||||||
if (window._jqcNotifications) {
|
['notifDropdown', 'notifDropdownMobile'].forEach(function(id) {
|
||||||
renderNotifications(window._jqcNotifications);
|
var el = document.getElementById(id);
|
||||||
} else {
|
if (!el) return;
|
||||||
fetchNotifications();
|
el.addEventListener('show.bs.dropdown', function() {
|
||||||
}
|
if (window._jqcNotifications) {
|
||||||
|
renderNotifications(window._jqcNotifications);
|
||||||
|
} else {
|
||||||
|
fetchNotifications();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
markAllBtn.addEventListener('click', function (e) {
|
// ── Mark all read — works from either bell ─────────────────────────────
|
||||||
e.stopPropagation();
|
document.querySelectorAll('#mark-all-read-btn, .mark-all-read-btn').forEach(function(btn) {
|
||||||
fetch(MARK_ALL_URL, {
|
btn.addEventListener('click', function(e) {
|
||||||
method: 'POST',
|
e.stopPropagation();
|
||||||
headers: { 'X-CSRFToken': CSRF_TOKEN },
|
fetch(MARK_ALL_URL, {
|
||||||
credentials: 'same-origin',
|
method: 'POST',
|
||||||
})
|
headers: { 'X-CSRFToken': CSRF_TOKEN },
|
||||||
.then(r => r.json())
|
credentials: 'same-origin',
|
||||||
.then(() => {
|
})
|
||||||
updateBadge(0);
|
.then(function(r) { return r.json(); })
|
||||||
listEl.querySelectorAll('.notif-item.unread').forEach(el => el.classList.remove('unread'));
|
.then(function() {
|
||||||
if (window._jqcNotifications) {
|
updateBadge(0);
|
||||||
window._jqcNotifications.forEach(n => n.is_read = true);
|
document.querySelectorAll('.notif-item.unread').forEach(function(el) {
|
||||||
}
|
el.classList.remove('unread');
|
||||||
})
|
});
|
||||||
.catch(() => {});
|
if (window._jqcNotifications) {
|
||||||
|
window._jqcNotifications.forEach(function(n) { n.is_read = true; });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function() {});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
fetchNotifications();
|
fetchNotifications();
|
||||||
|
|||||||
+141
-1
@@ -467,4 +467,144 @@ def send_pending_digests(frequency: str = 'daily'):
|
|||||||
user.email, frequency, exc,
|
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 import db
|
||||||
from app.models.issue import Issue
|
from app.models.issue import Issue
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.utils.notifications import notify
|
from app.utils.notifications import notify, notify_by_matrix
|
||||||
from app.models.notification import EVENT_SLA_ALERT
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -115,8 +114,6 @@ def send_sla_alerts():
|
|||||||
Issue.status.in_(['open', 'in_progress', 'pending_verification'])
|
Issue.status.in_(['open', 'in_progress', 'pending_verification'])
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
admins = User.query.filter_by(role='admin').all()
|
|
||||||
|
|
||||||
total_sent = 0
|
total_sent = 0
|
||||||
|
|
||||||
for issue in open_issues:
|
for issue in open_issues:
|
||||||
@@ -133,24 +130,8 @@ def send_sla_alerts():
|
|||||||
if already == 'at_risk' and status == 'at_risk':
|
if already == 'at_risk' and status == 'at_risk':
|
||||||
continue # at_risk already sent, not yet breached
|
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
|
# Compose message
|
||||||
hrs = sla_hours_remaining(issue)
|
hrs = sla_hours_remaining(issue)
|
||||||
deadline = sla_deadline(issue)
|
deadline = sla_deadline(issue)
|
||||||
|
|
||||||
if status == 'breached':
|
if status == 'breached':
|
||||||
@@ -178,23 +159,51 @@ def send_sla_alerts():
|
|||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
link = f'/issues/{issue.id}'
|
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(
|
notify(
|
||||||
recipient = user,
|
recipient = issue.assigned_user,
|
||||||
title = title,
|
title = title,
|
||||||
body = body,
|
body = body,
|
||||||
link = link,
|
link = link,
|
||||||
issue_id = issue.id,
|
issue_id = issue.id,
|
||||||
event_type = EVENT_SLA_ALERT,
|
event_type = 'sla_alert',
|
||||||
send_email = True,
|
send_email = True,
|
||||||
)
|
)
|
||||||
|
implicit_notified.add(issue.assigned_user.id)
|
||||||
total_sent += 1
|
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
|
# Mark this issue as notified at the current level
|
||||||
issue.sla_notified = status
|
issue.sla_notified = status
|
||||||
logger.info(
|
logger.info(
|
||||||
'SLA ALERT SENT | issue_id=%s | status=%s | recipients=%s',
|
'SLA ALERT SENT | issue_id=%s | status=%s',
|
||||||
issue.id, status, list(recipients.keys()),
|
issue.id, status,
|
||||||
)
|
)
|
||||||
|
|
||||||
if total_sent:
|
if total_sent:
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""Phase 8: Notification matrix — admin-controlled per-event recipient settings
|
||||||
|
|
||||||
|
Revision ID: phase8_notification_matrix
|
||||||
|
Revises: phase7_mobile_api
|
||||||
|
Create Date: 2026-04-02
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = 'phase8_notification_matrix'
|
||||||
|
down_revision = 'phase7_mobile_api'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
tables = inspector.get_table_names()
|
||||||
|
|
||||||
|
if 'notification_matrix' not in tables:
|
||||||
|
op.create_table(
|
||||||
|
'notification_matrix',
|
||||||
|
sa.Column('id', sa.Integer, primary_key=True),
|
||||||
|
sa.Column('event_type', sa.String(50), nullable=False),
|
||||||
|
sa.Column('role_key', sa.String(30), nullable=False),
|
||||||
|
# enabled: whether this role receives notifications for this event
|
||||||
|
sa.Column('enabled', sa.Boolean, nullable=False, server_default='1'),
|
||||||
|
# custom_emails: JSON list of extra email addresses (role_key='custom')
|
||||||
|
sa.Column('custom_emails', sa.Text, nullable=True),
|
||||||
|
sa.UniqueConstraint('event_type', 'role_key',
|
||||||
|
name='uq_notif_matrix_event_role'),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_table('notification_matrix')
|
||||||
Reference in New Issue
Block a user