""" 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': 120, # 5 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 recognized. """ 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 unrecognized 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 unrecognized 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, notify_by_matrix import logging logger = logging.getLogger(__name__) # yield_per streams rows in batches of 100 rather than loading all open # issues into memory at once. At current scale this is a no-op difference, # but it prevents a memory spike if the issue count grows large. open_issues = Issue.query.filter( Issue.status.in_(['open', 'in_progress', 'pending_verification']) ).yield_per(100) 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 # Compose message hrs = sla_hours_remaining(issue) deadline = sla_deadline(issue) facility_name = issue.resolved_facility.name if issue.resolved_facility else "\u2014" if status == 'breached': title = f'🚨 SLA Breached — Issue #{issue.id} ({issue.severity.title()})' body = ( f'Issue #{issue.id} at {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 {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}' # Always notify the assignee and followers (implicit, not matrix-controlled) implicit_notified = set() if issue.assigned_to and issue.assigned_user: notify( recipient = issue.assigned_user, title = title, body = body, link = link, issue_id = issue.id, event_type = 'sla_alert', send_email = True, ) implicit_notified.add(issue.assigned_user.id) 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 issue.sla_notified = status logger.info( 'SLA ALERT SENT | issue_id=%s | status=%s', issue.id, status, ) if total_sent: db.session.commit() return total_sent # ── Score trend alert dispatcher ────────────────────────────────────────────── # Default drop threshold in percentage points that triggers an alert. SCORE_DROP_THRESHOLD = 5.0 def send_score_alerts(threshold=SCORE_DROP_THRESHOLD): """ Compare each active facility's avg inspection score for the last 30 days against the prior 30-day period. When the score has dropped by more than *threshold* points, dispatch an in-app + email alert via notify_by_matrix and record the alert in facility_score_alerts for deduplication. A facility is skipped if it already received an alert within the last 24 hours (prevents repeat storms on persistent low scores). Returns the number of alert notifications dispatched. """ from datetime import timedelta from flask import current_app, url_for from sqlalchemy import func from app import db from app.models.facility import Facility from app.models.inspection import Inspection from app.models.score_alert import FacilityScoreAlert from app.utils.notifications import notify_by_matrix import logging logger = logging.getLogger(__name__) now = now_eastern() cur_start = now - timedelta(days=30) pri_start = now - timedelta(days=60) pri_end = cur_start # Current-period avg score per facility cur_rows = db.session.query( Facility.id, Facility.name, func.avg(Inspection.overall_score).label('avg'), ).join(Inspection, Facility.id == Inspection.facility_id)\ .filter( Facility.active == True, Inspection.inspection_date >= cur_start, Inspection.inspection_date <= now, Inspection.status == 'completed', Inspection.overall_score.isnot(None), ).group_by(Facility.id, Facility.name).all() # Prior-period avg score per facility pri_rows = db.session.query( Facility.id, func.avg(Inspection.overall_score).label('avg'), ).join(Inspection, Facility.id == Inspection.facility_id)\ .filter( Facility.active == True, Inspection.inspection_date >= pri_start, Inspection.inspection_date <= pri_end, Inspection.status == 'completed', Inspection.overall_score.isnot(None), ).group_by(Facility.id).all() prior_map = {r.id: float(r.avg) for r in pri_rows} # Facilities that already received an alert in the last 24 hours cutoff = now - timedelta(hours=24) recent_alerts = db.session.query(FacilityScoreAlert.facility_id)\ .filter(FacilityScoreAlert.sent_at >= cutoff).all() already_alerted = {r.facility_id for r in recent_alerts} total_sent = 0 for row in cur_rows: fid = row.id cur_avg = float(row.avg) pri_avg = prior_map.get(fid) if pri_avg is None: continue # no prior period data — nothing to compare delta = cur_avg - pri_avg # negative = score dropped if delta >= -threshold: continue # drop is within acceptable range if fid in already_alerted: logger.debug('SCORE ALERT SKIPPED (already alerted) | facility_id=%s', fid) continue title = f'📉 Score Drop Alert — {row.name}' body = ( f'{row.name} avg score has dropped {abs(delta):.1f} points ' f'(from {pri_avg:.1f}% to {cur_avg:.1f}%) over the last 30 days ' f'vs. the prior 30-day period.' ) try: link = url_for('reports.facility_scorecard', facility_id=fid) except RuntimeError: link = f'/reports/facility/{fid}/scorecard' notify_by_matrix( event_type = 'score_alert', title = title, body = body, link = link, ) total_sent += 1 db.session.add(FacilityScoreAlert( facility_id = fid, sent_at = now, current_avg = round(cur_avg, 2), prior_avg = round(pri_avg, 2), delta = round(delta, 2), )) logger.info( 'SCORE ALERT SENT | facility_id=%s | facility=%s | cur=%.1f | prior=%.1f | delta=%.1f', fid, row.name, cur_avg, pri_avg, delta, ) if total_sent: db.session.commit() return total_sent