04/06 adding some additional functions

This commit is contained in:
2026-04-06 18:02:16 -04:00
parent bc236a3d19
commit 299bf2ca05
12 changed files with 1142 additions and 9 deletions
+46 -4
View File
@@ -335,12 +335,18 @@ def notify_comment_added(comment):
def notify_assignment(ticket, assigned_by):
"""Notify newly assigned IT staff member."""
"""Notify newly assigned IT staff member AND the ticket creator (employee).
The employee who submitted the ticket (or on whose behalf it was filed)
receives a confirmation that their issue has been picked up, giving
them visibility without requiring them to poll the ticket page.
"""
if not ticket.assigned_to_id:
return
base_url = current_app.config.get('APP_BASE_URL', '')
base_url = current_app.config.get('APP_BASE_URL', '')
ticket_url = f"{base_url}/tickets/{ticket.id}"
# ── Notify the IT staff assignee ──────────────────────────────────────────
create_notification(
user_id = ticket.assigned_to_id,
notif_type= NotificationType.TICKET_ASSIGNED,
@@ -349,7 +355,6 @@ def notify_assignment(ticket, assigned_by):
ticket_id = ticket.id,
link = f'/tickets/{ticket.id}',
)
db.session.commit()
if ticket.assignee and ticket.assignee.email_notif:
html = render_template_string(_STATUS_UPDATE_EMAIL,
ticket_number = ticket.ticket_number,
@@ -362,4 +367,41 @@ def notify_assignment(ticket, assigned_by):
[ticket.assignee.email],
html,
)
logger.info(f'[TICKET ASSIGN] ticket_id={ticket.id} assigned_to={ticket.assigned_to_id} by={assigned_by.id}')
# ── Notify the ticket creator (employee) ──────────────────────────────────
# Only notify if the creator is not the assignee — avoids a redundant
# self-notification when IT staff file and assign their own tickets.
if ticket.created_by_id != ticket.assigned_to_id:
assignee_name = ticket.assignee.full_name if ticket.assignee else 'an IT staff member'
employee_msg = (
f'Your ticket "{ticket.title}" has been picked up by {assignee_name}. '
f'You will be notified as soon as there is an update.'
)
create_notification(
user_id = ticket.created_by_id,
notif_type= NotificationType.TICKET_ASSIGNED,
title = f'Ticket {ticket.ticket_number} — Now Being Worked On',
message = employee_msg,
ticket_id = ticket.id,
link = f'/tickets/{ticket.id}',
)
creator = db.session.get(User, ticket.created_by_id)
if creator and creator.email_notif:
html = render_template_string(_STATUS_UPDATE_EMAIL,
ticket_number = ticket.ticket_number,
title = ticket.title,
message = employee_msg,
ticket_url = ticket_url,
)
send_email(
f'[Ticket Update] {ticket.ticket_number} — Now Being Worked On',
[creator.email],
html,
)
db.session.commit()
logger.info(
f'[TICKET ASSIGN] ticket_id={ticket.id} '
f'assigned_to={ticket.assigned_to_id} by={assigned_by.id} '
f'employee_notified={ticket.created_by_id != ticket.assigned_to_id}'
)
+294
View File
@@ -0,0 +1,294 @@
"""
SLA Service — breach detection and automatic due-date enforcement.
Responsibilities
----------------
1. check_sla_breaches(app)
Called every 30 minutes by APScheduler. Finds all open/in-progress
tickets whose due_date has passed and whose SLA breach has not yet
been notified. Sends in-app notifications to the assignee (if any)
and to every active IT admin, then stamps the ticket so repeat
notifications are suppressed until the ticket is updated.
2. set_due_date(ticket)
Convenience helper called by the ticket-creation routes so due dates
are always derived from the same single source of truth.
Notification suppression
------------------------
A dedicated SystemSetting key sla_notified_tickets stores a
comma-separated list of ticket IDs that have already received a breach
notification. When a ticket is resolved or closed the ID is removed
from the list so the suppression does not persist across re-opens
(edge case: ticket re-opened after resolution — unlikely but handled).
Design notes
------------
- Runs inside the gunicorn worker process (no separate process needed).
- Uses app.app_context() so SQLAlchemy sessions are properly scoped.
- All DB writes commit independently from the main request cycle.
- Errors are logged but never raised — a scheduler failure must not
bring down the web process.
"""
import logging
from datetime import datetime, timedelta
logger = logging.getLogger(__name__)
# ── Priority → SLA hours mapping (fallback if SystemSetting is absent) ────────
_DEFAULT_SLA_HOURS = {
'critical': 4,
'high': 8,
'medium': 48,
'low': 120,
}
_SETTING_KEYS = {
'critical': 'sla_critical_hours',
'high': 'sla_high_hours',
'medium': 'sla_medium_hours',
'low': 'sla_low_hours',
}
def _get_sla_hours(priority: str, app) -> int:
"""Return SLA hours for *priority*, reading from SystemSetting first."""
from app.models import SystemSetting
key = _SETTING_KEYS.get(priority, 'sla_medium_hours')
config_key = f'SLA_{priority.upper()}_HOURS'
default = app.config.get(config_key, _DEFAULT_SLA_HOURS.get(priority, 48))
raw = SystemSetting.get(key)
try:
return int(raw) if raw is not None else int(default)
except (ValueError, TypeError):
return int(default)
def set_due_date(ticket, app):
"""Set ticket.due_date from SLA config if not already set.
Callers are responsible for committing after calling this function.
"""
if ticket.due_date:
return # already set — respect manual override
hours = _get_sla_hours(ticket.priority, app)
ticket.due_date = datetime.utcnow() + timedelta(hours=hours)
# ── SLA breach notification email template ────────────────────────────────────
_SLA_BREACH_EMAIL = """
<html><body style="font-family:Arial,sans-serif;background:#f4f4f4;padding:20px;">
<div style="max-width:600px;margin:0 auto;background:#fff;border-radius:8px;
overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,.1);">
<div style="background:#7f1d1d;padding:24px 32px;">
<h1 style="color:#fca5a5;margin:0;font-size:22px;">⏰ SLA Breach — Action Required</h1>
</div>
<div style="padding:32px;">
<p style="color:#555;margin-top:0;">
The following ticket has exceeded its SLA response target and requires immediate attention.
</p>
<table style="width:100%;border-collapse:collapse;margin:16px 0;">
<tr><td style="padding:8px;color:#888;width:140px;">Ticket #</td>
<td style="padding:8px;font-weight:bold;">{{ ticket_number }}</td></tr>
<tr style="background:#f9f9f9;">
<td style="padding:8px;color:#888;">Title</td>
<td style="padding:8px;">{{ title }}</td></tr>
<tr><td style="padding:8px;color:#888;">Priority</td>
<td style="padding:8px;">
<span style="background:{{ priority_color }};color:#fff;
padding:2px 8px;border-radius:4px;">{{ priority }}</span>
</td></tr>
<tr style="background:#f9f9f9;">
<td style="padding:8px;color:#888;">Status</td>
<td style="padding:8px;">{{ status }}</td></tr>
<tr><td style="padding:8px;color:#888;">Assigned To</td>
<td style="padding:8px;">{{ assigned_to }}</td></tr>
<tr style="background:#fef2f2;">
<td style="padding:8px;color:#888;">Due Date</td>
<td style="padding:8px;color:#dc2626;font-weight:bold;">{{ due_date }} (overdue)</td></tr>
</table>
<a href="{{ ticket_url }}"
style="display:inline-block;background:#dc2626;color:#fff;
padding:12px 24px;border-radius:6px;text-decoration:none;margin-top:8px;">
View &amp; Action Ticket
</a>
</div>
<div style="background:#f4f4f4;padding:16px 32px;text-align:center;
color:#999;font-size:12px;">
IT Helpdesk System &bull; This is an automated SLA alert.
</div>
</div>
</body></html>
"""
def _priority_color(priority: str) -> str:
return {
'low': '#28a745',
'medium': '#ffc107',
'high': '#dc3545',
'critical': '#7f1d1d',
}.get(priority, '#6c757d')
def check_sla_breaches(app):
"""Scheduled job: find overdue tickets and notify responsible parties.
Safe to call repeatedly — already-notified tickets are suppressed via
the sla_notified_tickets SystemSetting key. The suppression list is
cleared for a ticket when it transitions to resolved/closed (handled by
the update_ticket route clearing it on status change) or when the ticket
is re-opened, ensuring fresh notifications if the issue resurfaces.
"""
with app.app_context():
try:
_run_sla_check(app)
except Exception as exc:
logger.error(f'[SLA] Unhandled error in check_sla_breaches: {exc}', exc_info=True)
def _run_sla_check(app):
from app import db
from app.models import (
Ticket, TicketStatus, User, UserRole,
NotificationType, SystemSetting,
)
from app.services.notification_service import create_notification, send_email
from flask import render_template_string
now = datetime.utcnow()
# ── Load suppression list ──────────────────────────────────────────────────
raw = SystemSetting.get('sla_notified_tickets', '')
already_notified = set(int(x) for x in raw.split(',') if x.strip().isdigit())
# ── Query overdue open tickets ─────────────────────────────────────────────
overdue = Ticket.query.filter(
Ticket.status.in_([TicketStatus.OPEN, TicketStatus.IN_PROGRESS]),
Ticket.due_date.isnot(None),
Ticket.due_date < now,
).all()
if not overdue:
logger.info(f'[SLA] Check complete — no overdue tickets at {now.strftime("%Y-%m-%d %H:%M")}')
return
base_url = app.config.get('APP_BASE_URL', '')
it_dept_email = app.config.get('IT_DEPT_EMAIL', '')
newly_notified = []
for ticket in overdue:
if ticket.id in already_notified:
continue # already sent — skip
ticket_url = f'{base_url}/tickets/{ticket.id}'
overdue_mins = int((now - ticket.due_date).total_seconds() / 60)
overdue_label = (
f'{overdue_mins // 60}h {overdue_mins % 60}m'
if overdue_mins >= 60 else f'{overdue_mins}m'
)
logger.warning(
f'[SLA BREACH] ticket_id={ticket.id} number={ticket.ticket_number} '
f'priority={ticket.priority} overdue_by={overdue_label} '
f'due={ticket.due_date.strftime("%Y-%m-%d %H:%M")} '
f'assigned_to={ticket.assigned_to_id}'
)
# Build the email
html = render_template_string(
_SLA_BREACH_EMAIL,
ticket_number = ticket.ticket_number,
title = ticket.title,
priority = ticket.priority.upper(),
priority_color = _priority_color(ticket.priority),
status = ticket.status.replace('_', ' ').title(),
assigned_to = ticket.assignee.full_name if ticket.assignee else 'Unassigned',
due_date = ticket.due_date.strftime('%b %d, %Y %H:%M UTC'),
ticket_url = ticket_url,
)
subject = (
f'[SLA BREACH] {ticket.ticket_number}{ticket.priority.upper()} '
f'ticket overdue by {overdue_label}'
)
notif_title = f'⏰ SLA Breach: {ticket.ticket_number}'
notif_message = (
f'{ticket.priority.upper()} priority ticket "{ticket.title}" '
f'is overdue by {overdue_label}.'
)
notified_user_ids = set()
# Notify assignee (in-app + email)
if ticket.assigned_to_id:
create_notification(
user_id = ticket.assigned_to_id,
notif_type= NotificationType.TICKET_UPDATED,
title = notif_title,
message = notif_message,
ticket_id = ticket.id,
link = f'/tickets/{ticket.id}',
)
if ticket.assignee and ticket.assignee.email_notif:
send_email(subject, [ticket.assignee.email], html)
notified_user_ids.add(ticket.assigned_to_id)
# Notify all active IT admins (in-app + IT dept email)
admins = User.query.filter(
User.role == UserRole.ADMIN,
User.is_active == True,
).all()
for admin in admins:
if admin.id not in notified_user_ids:
create_notification(
user_id = admin.id,
notif_type= NotificationType.TICKET_UPDATED,
title = notif_title,
message = notif_message,
ticket_id = ticket.id,
link = f'/tickets/{ticket.id}',
)
notified_user_ids.add(admin.id)
# One email to the IT department inbox
if it_dept_email:
send_email(subject, [it_dept_email], html)
newly_notified.append(ticket.id)
db.session.commit()
# ── Update suppression list ────────────────────────────────────────────────
if newly_notified:
updated = already_notified | set(newly_notified)
SystemSetting.set(
'sla_notified_tickets',
','.join(str(i) for i in sorted(updated)),
'Comma-separated ticket IDs that have received SLA breach notifications',
)
db.session.commit()
logger.info(
f'[SLA] Notified {len(newly_notified)} breach(es): '
f'{[str(i) for i in newly_notified]}'
)
def clear_sla_notification(ticket_id: int):
"""Remove a ticket from the SLA suppression list.
Call this when a ticket is resolved, closed, or re-opened so that
subsequent breaches (if the ticket re-opens) trigger fresh alerts.
Callers are responsible for committing after calling this function.
"""
from app.models import SystemSetting
raw = SystemSetting.get('sla_notified_tickets', '')
current = set(int(x) for x in raw.split(',') if x.strip().isdigit())
current.discard(ticket_id)
SystemSetting.set(
'sla_notified_tickets',
','.join(str(i) for i in sorted(current)),
)