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 = """

🎫 New IT Ticket Created

A new support ticket has been submitted.

Ticket #{{ ticket_number }}
Title{{ title }}
Category{{ category }}
Priority{{ priority }}
Submitted by{{ submitted_by }}

{{ description }}

View Ticket
IT Helpdesk System • This is an automated notification.
""" _STATUS_UPDATE_EMAIL = """

🔄 Ticket Update

Your ticket {{ ticket_number }} has been updated.

{{ title }}

{{ message }}

View Ticket
IT Helpdesk System • This is an automated notification.
""" 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. Every step is wrapped in explicit error handling and logged so failures are visible in the application log rather than swallowed silently. """ from app.models import TicketSatisfaction from app import mail from threading import Thread from flask_mail import Message logger.info( f'[SURVEY] send_satisfaction_survey called: ' f'ticket_id={ticket.id} number={ticket.ticket_number}' ) # Guard: survey feature must be enabled in system settings from app.models import SystemSetting if not SystemSetting.get_bool('survey_enabled', default=True): logger.info('[SURVEY] Satisfaction survey is disabled in settings — skipping') return # Guard: creator must exist and have email notifications enabled creator = ticket.creator if not creator: logger.warning(f'[SURVEY] No creator found for ticket_id={ticket.id} — skipping') return if not creator.email_notif: logger.info( f'[SURVEY] Creator user_id={creator.id} has email_notif=False — skipping' ) return logger.info( f'[SURVEY] Creator OK: user_id={creator.id} ' f'email={creator.email} email_notif={creator.email_notif}' ) # Create the TicketSatisfaction row try: survey = TicketSatisfaction.create_for_ticket(ticket) except Exception as exc: logger.error( f'[SURVEY] create_for_ticket raised an exception for ' f'ticket_id={ticket.id}: {exc}', exc_info=True ) return if not survey: logger.info( f'[SURVEY] Survey already exists for ticket_id={ticket.id} — skipping' ) return try: db.session.commit() except Exception as exc: logger.error( f'[SURVEY] db.session.commit() failed for ticket_id={ticket.id}: {exc}', exc_info=True ) db.session.rollback() return logger.info( f'[SURVEY CREATED] ticket_id={ticket.id} ' f'survey_id={survey.id} token={survey.survey_token[:8]}...' ) # Build absolute URLs using APP_BASE_URL — no url_for() which requires # an active request context not guaranteed in all calling paths base_url = current_app.config.get('APP_BASE_URL', '').rstrip('/') if not base_url: logger.warning( '[SURVEY] APP_BASE_URL is not set — survey links will be broken. ' 'Set APP_BASE_URL in your .env (e.g. https://tickets.ltservicesinc.com)' ) survey_url = f"{base_url}/survey/{survey.survey_token}" ticket_url = f"{base_url}/tickets/{ticket.id}" logger.info(f'[SURVEY] survey_url={survey_url}') stars_html = ''.join( f'' for i in range(1, 6) ) html = ( '' '
' '
' '

⭐ How did we do?

' '
' '
' f'

Hi {creator.full_name},

' f'

Your ticket {ticket.ticket_number}' f' has been marked as resolved. We'd love to hear how we did!

' '

' 'How satisfied were you with the resolution?

' f'

{stars_html}

' f'

' f'' 'Or leave a written comment

' f'

If the issue is not resolved, ' f're-open your ticket.

' '
' '
' 'TechDesk IT Helpdesk • This is an automated message.' '
' ) # Capture locals needed inside the thread before spawning. # Mirrors the pattern in send_email() exactly: # - current_app._get_current_object() resolves the proxy to the real # Flask app object so it is safe to reference inside a daemon thread # that has no active context of its own yet. # - The Message is built INSIDE the thread, within app.app_context(), # not before — flask_mail reads app config at construction time. app = current_app._get_current_object() ticket_num = ticket.ticket_number ticket_id = ticket.id creator_name = creator.full_name creator_email= creator.email logger.info( f'[SURVEY] Spawning email thread: recipient={creator_email} ' f'ticket_id={ticket_id}' ) def _send(): with app.app_context(): try: msg = Message( subject = f'[TechDesk] How did we do? — {ticket_num}', recipients = [creator_email], html = html, ) mail.send(msg) logger.info( f'[SURVEY EMAIL SENT] ticket_id={ticket_id} ' f'recipient={creator_email}' ) except Exception as exc: logger.error( f'[SURVEY EMAIL FAILED] ticket_id={ticket_id} ' f'recipient={creator_email}: {exc}', exc_info=True ) t = Thread(target=_send, daemon=True) t.start() logger.info(f'[SURVEY] Email thread started for ticket_id={ticket_id}')