Mar 04 2026: implemented SLA notifications
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -194,3 +194,29 @@ def send_digest():
|
||||
|
||||
logger.info('DIGEST TRIGGERED | frequency=%s | sent=%s', frequency, sent)
|
||||
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})
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user