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 = """
""" _STATUS_UPDATE_EMAIL = """ """ 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}')