From 345d14fcfd9022079bcb119c8d54491d62912262 Mon Sep 17 00:00:00 2001 From: NguyenND Date: Wed, 4 Mar 2026 09:20:22 -0500 Subject: [PATCH] Mar 04 2026: implemented SLA notifications --- app/models/issue.py | 4 ++ app/models/notification.py | 2 + app/routes/issues.py | 5 +- app/routes/notifications.py | 28 +++++++- app/utils/sla.py | 123 ++++++++++++++++++++++++++++++++++++ 5 files changed, 159 insertions(+), 3 deletions(-) diff --git a/app/models/issue.py b/app/models/issue.py index d2eb94a..637a489 100644 --- a/app/models/issue.py +++ b/app/models/issue.py @@ -58,6 +58,10 @@ class Issue(db.Model): result_notes = db.Column(db.Text) result_photos = db.Column(db.JSON) # list of relative paths e.g. ["uploads/issue_photos/abc.jpg"] + # Tracks which SLA alert level has already been notified so cron runs + # don't fire duplicate notifications. Values: None / 'at_risk' / 'breached' + sla_notified = db.Column(db.String(10), nullable=True, default=None) + # Relationships assigned_user = db.relationship('User', foreign_keys=[assigned_to], backref='assigned_issues') comments = db.relationship('IssueComment', backref='issue', lazy='dynamic', diff --git a/app/models/notification.py b/app/models/notification.py index 9355da1..2216811 100644 --- a/app/models/notification.py +++ b/app/models/notification.py @@ -11,6 +11,7 @@ EVENT_ISSUE_STATUS = 'issue_status' EVENT_ISSUE_COMMENT = 'issue_comment' EVENT_ISSUE_FOLLOW = 'issue_follow_update' EVENT_INSPECTION_DONE = 'inspection_completed' +EVENT_SLA_ALERT = 'sla_alert' ALL_EVENT_TYPES = { EVENT_ISSUE_ASSIGNED: 'Issue assigned to me', @@ -18,6 +19,7 @@ ALL_EVENT_TYPES = { EVENT_ISSUE_COMMENT: 'New comment on issue', EVENT_ISSUE_FOLLOW: 'Updates on followed issues', EVENT_INSPECTION_DONE: 'Inspection completed', + EVENT_SLA_ALERT: 'SLA at-risk / breached alerts', } diff --git a/app/routes/issues.py b/app/routes/issues.py index e35f5fc..252bde9 100644 --- a/app/routes/issues.py +++ b/app/routes/issues.py @@ -109,9 +109,10 @@ def view(issue_id): issue.assigned_to = form.assigned_to.data or None if form.status.data == 'resolved' and not issue.resolved_at: - issue.resolved_at = now_eastern() + issue.resolved_at = now_eastern() + issue.sla_notified = None # clear so alerts fire again if re-opened elif form.status.data != 'resolved': - issue.resolved_at = None + issue.resolved_at = None issue.result_notes = form.result_notes.data or None diff --git a/app/routes/notifications.py b/app/routes/notifications.py index ef5c314..aba239a 100644 --- a/app/routes/notifications.py +++ b/app/routes/notifications.py @@ -193,4 +193,30 @@ def send_digest(): sent = send_pending_digests(frequency=frequency) logger.info('DIGEST TRIGGERED | frequency=%s | sent=%s', frequency, sent) - return jsonify({'ok': True, 'sent': sent, 'frequency': frequency}) \ No newline at end of file + return jsonify({'ok': True, 'sent': sent, 'frequency': frequency}) + +# ── SLA alert trigger (called by cron) ──────────────────────────────────────── + +@bp.route('/check-sla', methods=['POST']) +def check_sla(): + """Scan all open issues for SLA breaches and dispatch alerts. + + Protected by the same DIGEST_SECRET token used for digest delivery. + Recommended cron schedule — every 30 minutes is sufficient for most + deployments; adjust based on your shortest SLA threshold (critical = 4h): + + */30 * * * * curl -s -X POST https://yourdomain.com/notifications/check-sla \\ + -d "token=YOUR_DIGEST_SECRET" + """ + token = request.form.get('token') or request.args.get('token') + expected = current_app.config.get('DIGEST_SECRET') + + if not expected or token != expected: + logger.warning('SLA CHECK REJECTED | bad or missing token') + abort(403) + + from app.utils.sla import send_sla_alerts + sent = send_sla_alerts() + + logger.info('SLA CHECK TRIGGERED | notifications_sent=%s', sent) + return jsonify({'ok': True, 'notifications_sent': sent}) diff --git a/app/utils/sla.py b/app/utils/sla.py index c043d0f..89a668a 100644 --- a/app/utils/sla.py +++ b/app/utils/sla.py @@ -78,3 +78,126 @@ def sla_hours_remaining(issue): return None delta = deadline - now_eastern() return round(delta.total_seconds() / 3600, 1) + + +# ── SLA alert dispatcher ────────────────────────────────────────────────────── + +def send_sla_alerts(): + """ + Check all open/in-progress issues for SLA breaches or at-risk status and + dispatch in-app + email notifications to the appropriate recipients. + + Recipient rules: + - Admins always receive alerts + - If the issue is assigned, the assignee also receives an alert + - All followers of the issue also receive an alert + - Deduplication ensures each user gets at most one notification per call + + Deduplication across cron runs: + - Issue.sla_notified tracks the highest alert level already sent + ('at_risk' or 'breached'). A notification is only sent once per level. + - 'breached' supersedes 'at_risk': if a user was already notified + at-risk, they will receive a second notification when it breaches. + + Returns the number of notifications created. + """ + from flask import current_app, url_for + 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 + import logging + + logger = logging.getLogger(__name__) + + open_issues = Issue.query.filter( + Issue.status.in_(['open', 'in_progress']) + ).all() + + admins = User.query.filter_by(role='admin').all() + + total_sent = 0 + + for issue in open_issues: + status = sla_status(issue) + + # Only act on at_risk or breached + if status not in ('at_risk', 'breached'): + continue + + # Skip if this level (or higher) was already notified + already = issue.sla_notified + if already == 'breached': + continue # highest level already sent + 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) + deadline = sla_deadline(issue) + + if status == 'breached': + title = f'🚨 SLA Breached — Issue #{issue.id} ({issue.severity.title()})' + body = ( + f'Issue #{issue.id} at {issue.area.facility.name} ' + f'has breached its SLA deadline. ' + f'Severity: {issue.severity.title()}. ' + f'Deadline was {deadline.strftime("%Y-%m-%d %H:%M") if deadline else "N/A"}. ' + f'Current status: {issue.status.replace("_", " ").title()}.' + ) + else: # at_risk + title = f'⚠️ SLA At Risk — Issue #{issue.id} ({issue.severity.title()})' + body = ( + f'Issue #{issue.id} at {issue.area.facility.name} ' + f'is approaching its SLA deadline with approximately ' + f'{abs(hrs):.1f}h remaining. ' + f'Severity: {issue.severity.title()}. ' + f'Deadline: {deadline.strftime("%Y-%m-%d %H:%M") if deadline else "N/A"}. ' + f'Current status: {issue.status.replace("_", " ").title()}.' + ) + + try: + link = url_for('issues.view', issue_id=issue.id) + except RuntimeError: + link = f'/issues/{issue.id}' + + for user in recipients.values(): + notify( + recipient = user, + title = title, + body = body, + link = link, + issue_id = issue.id, + event_type = EVENT_SLA_ALERT, + send_email = True, + ) + total_sent += 1 + + # 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()), + ) + + if total_sent: + db.session.commit() + + return total_sent