Files
IT_Ticket_System/app/services/notification_service.py
T

420 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import logging
from flask import current_app, render_template_string
from flask_mail import Message
from app import db, mail, socketio
from app.models import Notification, NotificationType, User, UserRole
logger = logging.getLogger(__name__)
# ─── Email Templates ──────────────────────────────────────────────────────────
_NEW_TICKET_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:#1a1a2e;padding:24px 32px;">
<h1 style="color:#e94560;margin:0;font-size:22px;">🎫 New IT Ticket Created</h1>
</div>
<div style="padding:32px;">
<p style="color:#555;margin-top:0;">A new support ticket has been submitted.</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;">Category</td><td style="padding:8px;">{{ category }}</td></tr>
<tr style="background:#f9f9f9;"><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><td style="padding:8px;color:#888;">Submitted by</td><td style="padding:8px;">{{ submitted_by }}</td></tr>
</table>
<div style="background:#f9f9f9;border-left:4px solid #e94560;padding:16px;margin:16px 0;border-radius:0 4px 4px 0;">
<p style="margin:0;color:#333;">{{ description }}</p>
</div>
<a href="{{ ticket_url }}" style="display:inline-block;background:#e94560;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;margin-top:8px;">View 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 notification.
</div>
</div>
</body></html>
"""
_STATUS_UPDATE_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:#1a1a2e;padding:24px 32px;">
<h1 style="color:#e94560;margin:0;font-size:22px;">🔄 Ticket Update</h1>
</div>
<div style="padding:32px;">
<p style="color:#555;margin-top:0;">Your ticket <strong>{{ ticket_number }}</strong> has been updated.</p>
<p><strong>{{ title }}</strong></p>
<p style="color:#555;">{{ message }}</p>
<a href="{{ ticket_url }}" style="display:inline-block;background:#e94560;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;margin-top:8px;">View 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 notification.
</div>
</div>
</body></html>
"""
def _priority_badge_color(priority):
return {'low': '#28a745', 'medium': '#ffc107', 'high': '#dc3545', 'critical': '#343a40'}.get(priority, '#6c757d')
# ─── In-App Notification ──────────────────────────────────────────────────────
def create_notification(user_id, notif_type, title, message, ticket_id=None, link=None):
"""Persist an in-app notification and push via WebSocket."""
try:
notif = Notification(
user_id = user_id,
ticket_id= ticket_id,
type = notif_type,
title = title,
message = message,
link = link,
)
db.session.add(notif)
db.session.commit()
logger.info(f'[NOTIFICATION CREATE] user_id={user_id} type={notif_type} ticket_id={ticket_id}')
# Real-time push — schedule via socketio.start_background_task so the
# emit runs inside eventlet's green-thread pool, not inline in the WSGI
# request context. Inline emits during a polling→WebSocket upgrade can
# race with the upgrade handshake and disconnect the client.
payload = {
'id' : notif.id,
'type' : notif_type,
'title' : title,
'message' : message,
'link' : link,
'created_at': notif.created_at.isoformat(),
}
def _emit():
socketio.emit(
'new_notification',
payload,
to=f'user_{user_id}', # 'to' is the modern alias for 'room'
namespace='/', # explicit default namespace — avoids
) # ambiguity under reverse-proxy setups
socketio.start_background_task(_emit)
except Exception as exc:
db.session.rollback()
logger.error(f'[NOTIFICATION ERROR] Failed to create notification: {exc}')
# ─── Email Notification ───────────────────────────────────────────────────────
def send_email(subject, recipients, html_body):
"""
Send an email in a background thread so it never blocks the request.
Flask-Mail opens an SMTP connection synchronously. Running it in the main
request thread means a slow or failing SMTP server delays the entire
response — including the WebSocket notification push that follows.
Using a daemon thread isolates SMTP failures from the request lifecycle.
"""
from threading import Thread
from flask import current_app
app = current_app._get_current_object() # real app, not the proxy
def _send():
with app.app_context():
try:
msg = Message(subject=subject, recipients=recipients, html=html_body)
mail.send(msg)
logger.info(f'[EMAIL SENT] subject="{subject}" to={recipients}')
except Exception as exc:
logger.error(f'[EMAIL ERROR] Failed to send "{subject}" to {recipients}: {exc}')
t = Thread(target=_send, daemon=True)
t.start()
# ─── Ticket Event Helpers ─────────────────────────────────────────────────────
def notify_new_ticket(ticket):
"""Notify IT staff (email + in-app) and confirm receipt to the creator."""
base_url = current_app.config.get('APP_BASE_URL', '')
ticket_url = f"{base_url}/tickets/{ticket.id}"
# Email IT staff
html = render_template_string(_NEW_TICKET_EMAIL,
ticket_number = ticket.ticket_number,
title = ticket.title,
description = ticket.description[:500],
category = ticket.category.replace('_', ' ').title(),
priority = ticket.priority.upper(),
priority_color= _priority_badge_color(ticket.priority),
submitted_by = ticket.creator.full_name,
ticket_url = ticket_url,
)
it_email = current_app.config.get('IT_DEPT_EMAIL')
send_email(f'[New Ticket] {ticket.ticket_number} {ticket.title}', [it_email], html)
# In-app: all IT staff
it_users = User.query.filter(
User.role.in_([UserRole.IT_STAFF, UserRole.ADMIN]),
User.is_active == True,
).all()
for staff in it_users:
create_notification(
user_id = staff.id,
notif_type= NotificationType.TICKET_CREATED,
title = f'New Ticket: {ticket.ticket_number}',
message = f'{ticket.creator.full_name} submitted: {ticket.title}',
ticket_id = ticket.id,
link = f'/tickets/{ticket.id}',
)
# Confirm to creator
create_notification(
user_id = ticket.created_by_id,
notif_type= NotificationType.TICKET_CREATED,
title = f'Ticket {ticket.ticket_number} Created',
message = 'Your ticket has been received. Our IT team will review it shortly.',
ticket_id = ticket.id,
link = f'/tickets/{ticket.id}',
)
def notify_status_change(ticket, old_status, changed_by):
"""Notify ticket creator of status change."""
base_url = current_app.config.get('APP_BASE_URL', '')
ticket_url = f"{base_url}/tickets/{ticket.id}"
msg_text = f'Status changed from {old_status.replace("_"," ").title()} to {ticket.status.replace("_"," ").title()} by {changed_by.full_name}.'
html = render_template_string(_STATUS_UPDATE_EMAIL,
ticket_number = ticket.ticket_number,
title = ticket.title,
message = msg_text,
ticket_url = ticket_url,
)
if ticket.creator.email_notif:
send_email(
f'[Ticket Update] {ticket.ticket_number} Status Changed',
[ticket.creator.email],
html,
)
create_notification(
user_id = ticket.created_by_id,
notif_type= NotificationType.STATUS_CHANGED,
title = f'Ticket {ticket.ticket_number} Updated',
message = msg_text,
ticket_id = ticket.id,
link = f'/tickets/{ticket.id}',
)
logger.info(f'[TICKET STATUS] ticket_id={ticket.id} {old_status} -> {ticket.status} by user_id={changed_by.id}')
def notify_comment_added(comment):
"""Notify relevant parties when a comment is added."""
ticket = comment.ticket
base_url = current_app.config.get('APP_BASE_URL', '')
ticket_url = f"{base_url}/tickets/{ticket.id}"
# ── Real-time push to everyone viewing this ticket ────────────────────────
# Emit to the ticket room so all users currently on the ticket detail page
# receive the new comment immediately without needing to reload.
payload = {
'id' : comment.id,
'author_name' : comment.author.full_name,
'author_init' : comment.author.full_name[0].upper(),
'author_avatar': comment.author.avatar_url or '',
'is_it_staff' : comment.author.is_it_staff,
'is_internal': comment.is_internal,
'body' : comment.body,
'created_at' : comment.created_at.strftime('%b %d, %Y %H:%M'),
'author_id' : comment.author_id,
'can_delete' : True, # the author always can; recipient-side JS checks role too
'attachments': [
{
'id' : a.id,
'filename' : a.filename,
'mime_type': a.mime_type or '',
'is_image' : (a.mime_type or '').startswith('image/'),
}
for a in comment.attachments.all()
],
}
def _emit_comment():
socketio.emit(
'new_comment',
payload,
to = f'ticket_{ticket.id}',
namespace = '/',
)
socketio.start_background_task(_emit_comment)
# ─────────────────────────────────────────────────────────────────────────
notified = set()
def _notify(user_id, is_internal=False):
if user_id in notified:
return
notified.add(user_id)
create_notification(
user_id = user_id,
notif_type= NotificationType.COMMENT_ADDED,
title = f'New Comment on {ticket.ticket_number}',
message = f'{comment.author.full_name} commented: {comment.body[:100]}',
ticket_id = ticket.id,
link = f'/tickets/{ticket.id}#comment-{comment.id}',
)
user = db.session.get(User, user_id)
if user and user.email_notif and not is_internal:
html = render_template_string(_STATUS_UPDATE_EMAIL,
ticket_number = ticket.ticket_number,
title = ticket.title,
message = f'{comment.author.full_name} added a comment: {comment.body[:300]}',
ticket_url = ticket_url,
)
send_email(
f'[Ticket Comment] {ticket.ticket_number}',
[user.email],
html,
)
# Notify creator (skip if internal-only comment)
if not comment.is_internal:
_notify(ticket.created_by_id)
# Notify assignee
if ticket.assigned_to_id and ticket.assigned_to_id != comment.author_id:
_notify(ticket.assigned_to_id, is_internal=comment.is_internal)
logger.info(f'[COMMENT ADD] comment_id={comment.id} ticket_id={ticket.id} author_id={comment.author_id} internal={comment.is_internal}')
def notify_assignment(ticket, assigned_by):
"""Notify newly assigned IT staff member."""
if not ticket.assigned_to_id:
return
base_url = current_app.config.get('APP_BASE_URL', '')
ticket_url = f"{base_url}/tickets/{ticket.id}"
create_notification(
user_id = ticket.assigned_to_id,
notif_type= NotificationType.TICKET_ASSIGNED,
title = f'Ticket Assigned: {ticket.ticket_number}',
message = f'You have been assigned ticket "{ticket.title}" by {assigned_by.full_name}.',
ticket_id = ticket.id,
link = f'/tickets/{ticket.id}',
)
if ticket.assignee and ticket.assignee.email_notif:
html = render_template_string(_STATUS_UPDATE_EMAIL,
ticket_number = ticket.ticket_number,
title = ticket.title,
message = f'This ticket has been assigned to you by {assigned_by.full_name}.',
ticket_url = ticket_url,
)
send_email(
f'[Ticket Assigned] {ticket.ticket_number}',
[ticket.assignee.email],
html,
)
logger.info(f'[TICKET ASSIGN] ticket_id={ticket.id} assigned_to={ticket.assigned_to_id} by={assigned_by.id}')
def send_satisfaction_survey(ticket):
"""Send a satisfaction survey email when a ticket is resolved.
Creates a TicketSatisfaction row with a unique survey token, then
emails the ticket creator a link to rate their experience (1-5 stars).
The link is token-authenticated so the employee does not need to be
logged in to respond.
URL construction
----------------
All other notification functions in this module use APP_BASE_URL from
config to build absolute URLs — NOT url_for(..., _external=True).
This function follows the same pattern. Using url_for inside an f-string
that is evaluated before the background thread starts causes a
RuntimeError ("Working outside of request context") which silently
swallows the entire function before the Thread is ever created.
Called from update_ticket() after commit, when status → Resolved.
"""
from app.models import TicketSatisfaction
from app import mail
from threading import Thread
from flask_mail import Message
creator = ticket.creator
if not creator or not creator.email_notif:
return
survey = TicketSatisfaction.create_for_ticket(ticket)
if not survey:
return # already sent for this ticket
db.session.commit()
logger.info(f'[SURVEY CREATED] ticket_id={ticket.id} token={survey.survey_token[:8]}…')
# Build absolute URLs using APP_BASE_URL — identical to every other
# notification function in this file. url_for(_external=True) requires
# an active request context which is not guaranteed here.
base_url = current_app.config.get('APP_BASE_URL', '').rstrip('/')
survey_url = f"{base_url}/survey/{survey.survey_token}"
ticket_url = f"{base_url}/tickets/{ticket.id}"
stars_html = ''.join(
f'<a href="{survey_url}?rating={i}" '
f'style="display:inline-block;margin:0 6px;font-size:38px;'
f'text-decoration:none;color:#f59e0b;" title="{i} star">&#9733;</a>'
for i in range(1, 6)
)
html = f"""
<html><body style="font-family:Arial,sans-serif;background:#f4f4f4;padding:20px;">
<div style="max-width:560px;margin:0 auto;background:#fff;border-radius:10px;
overflow:hidden;box-shadow:0 2px 12px rgba(0,0,0,.08);">
<div style="background:#1e293b;padding:24px 32px;">
<h1 style="color:#fff;margin:0;font-size:20px;">&#11088; How did we do?</h1>
</div>
<div style="padding:32px;">
<p style="color:#334155;margin-top:0;">Hi {creator.full_name},</p>
<p style="color:#334155;">
Your ticket <strong>{ticket.ticket_number}</strong> &mdash;
<em>{ticket.title}</em> &mdash; has been marked as resolved.
We'd love to hear how we did!
</p>
<p style="color:#334155;font-weight:600;margin-bottom:6px;">
Click a star to rate your experience:
</p>
<p style="text-align:center;margin:20px 0;line-height:1;">
{stars_html}
</p>
<p style="text-align:center;">
<a href="{survey_url}" style="color:#2563eb;font-size:13px;">
Or leave a detailed comment
</a>
</p>
<p style="color:#94a3b8;font-size:12px;margin-top:24px;">
If you feel the issue is not fully resolved, you can
<a href="{ticket_url}" style="color:#2563eb;">re-open your ticket</a>
at any time.
</p>
</div>
<div style="background:#f8fafc;padding:16px 32px;text-align:center;
color:#94a3b8;font-size:11px;border-top:1px solid #e2e8f0;">
TechDesk IT Helpdesk &bull; This is an automated message.
</div>
</div>
</body></html>"""
msg = Message(
subject = f'[TechDesk] How did we do? — {ticket.ticket_number}',
recipients = [creator.email],
html = html,
)
def _send():
with current_app.app_context():
try:
mail.send(msg)
logger.info(f'[SURVEY EMAIL SENT] ticket_id={ticket.id} user_id={creator.id}')
except Exception as exc:
logger.error(f'[SURVEY EMAIL FAILED] ticket_id={ticket.id} {exc}')
Thread(target=_send, daemon=True).start()