04/06 adding some additional functions
This commit is contained in:
@@ -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}'
|
||||
)
|
||||
|
||||
@@ -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 & Action Ticket
|
||||
</a>
|
||||
</div>
|
||||
<div style="background:#f4f4f4;padding:16px 32px;text-align:center;
|
||||
color:#999;font-size:12px;">
|
||||
IT Helpdesk System • 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)),
|
||||
)
|
||||
Reference in New Issue
Block a user