Files
LT_Janitorial_Quality_Control/app/utils/sla.py
T
2026-03-17 16:39:15 -04:00

203 lines
6.6 KiB
Python

"""
sla.py
------
Issue SLA (Service Level Agreement) helpers.
SLA thresholds define the maximum number of hours an issue of a given
severity may remain unresolved before it is considered breached.
Statuses
--------
ok — within the allowed window
at_risk — past 75% of the allowed window but not yet breached
breached — past the deadline
None — issue is already resolved; SLA no longer applies
"""
from datetime import timedelta
from app.utils.time_utils import now_eastern
# ── Configurable thresholds (hours) ──────────────────────────────────────────
SLA_HOURS = {
'critical': 4,
'high': 24,
'medium': 72,
'low': 168, # 7 days
}
# Fraction of the window at which an issue becomes "at risk"
AT_RISK_THRESHOLD = 0.75
def sla_deadline(issue):
"""
Return the datetime by which the issue must be resolved,
or None if the severity is not recognised.
"""
hours = SLA_HOURS.get(issue.severity)
if hours is None:
return None
return issue.reported_at + timedelta(hours=hours)
def sla_status(issue):
"""
Return one of: 'ok', 'at_risk', 'breached', or None.
None is returned when the issue is already resolved — SLA no longer
applies. None is also returned for unrecognised severity values.
"""
if issue.status == 'resolved':
return None
hours = SLA_HOURS.get(issue.severity)
if hours is None:
return None
deadline = issue.reported_at + timedelta(hours=hours)
at_risk_at = issue.reported_at + timedelta(hours=hours * AT_RISK_THRESHOLD)
now = now_eastern()
if now >= deadline:
return 'breached'
if now >= at_risk_at:
return 'at_risk'
return 'ok'
def sla_hours_remaining(issue):
"""
Return the number of hours remaining before the SLA deadline.
Negative values indicate the deadline has already passed.
Returns None for resolved issues or unrecognised severities.
"""
if issue.status == 'resolved':
return None
deadline = sla_deadline(issue)
if deadline is None:
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', 'pending_verification'])
).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