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(), } # Emit strategy depends on whether we have a live request context: # # - Inside a web request → use start_background_task() so the emit # runs in eventlet's green-thread pool and does not block the WSGI # response or race with a polling->WebSocket upgrade handshake. # # - Outside a request context (e.g. APScheduler background job) -> # call socketio.emit() directly. start_background_task() causes # Flask-SocketIO to invoke the on_connect handler internally, which # references current_user -- but there is no session to resolve it # from, so Flask-Login returns None and raises AttributeError. # A direct emit bypasses that handler entirely and is safe from a # background thread that already has an app context active. from flask import has_request_context if has_request_context(): def _emit(): socketio.emit( 'new_notification', payload, to=f'user_{user_id}', namespace='/', ) socketio.start_background_task(_emit) else: socketio.emit( 'new_notification', payload, to=f'user_{user_id}', namespace='/', ) 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 caller. 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 background thread isolates SMTP failures from the request lifecycle. Thread daemon mode ------------------ When called from a web request context, the thread is spawned as a daemon (daemon=True) so it does not prevent the server process from exiting cleanly. When called from an APScheduler background job (no active request context), the thread is spawned as a non-daemon (daemon=False). Daemon threads are killed as soon as the parent thread exits — since the APScheduler job thread finishes quickly, a daemon email thread is terminated before SMTP delivery completes, causing silent email loss. A non-daemon thread survives until the SMTP handshake finishes regardless of the job thread's lifetime. """ from threading import Thread from flask import current_app, has_request_context app = current_app._get_current_object() # real app, not the proxy # Determine daemon mode based on whether we are inside a live HTTP request. # APScheduler jobs and other background callers have no request context. is_daemon = has_request_context() 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=is_daemon) t.start() # ─── Ticket Event Helpers ───────────────────────────────────────────────────── def notify_new_ticket(ticket): """Notify IT staff (email + in-app) and confirm receipt to the creator. Email fan-out strategy: - The department alias (IT_DEPT_EMAIL) always receives one consolidated email. - Each active IT staff / admin user who has email_notif=True also receives an individual email so that personal notification preferences are respected. - In-app notifications are created for every active IT staff / admin user regardless of their email_notif setting. """ base_url = current_app.config.get('APP_BASE_URL', '') ticket_url = f"{base_url}/tickets/{ticket.id}" # Build the shared HTML body once — reused for both the dept alias and # individual staff emails to avoid rendering the template multiple times. 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, ) subject = f'[New Ticket] {ticket.ticket_number} – {ticket.title}' # --- Department alias email (existing behaviour, preserved) --------------- it_email = current_app.config.get('IT_DEPT_EMAIL') if it_email: send_email(subject, [it_email], html) logger.info(f'[NEW TICKET NOTIFY] Dept alias email sent to {it_email} for ticket_id={ticket.id}') else: logger.warning('[NEW TICKET NOTIFY] IT_DEPT_EMAIL is not configured — dept alias email skipped') # --- Per-staff individual emails + in-app notifications ------------------ it_users = User.query.filter( User.role.in_([UserRole.IT_STAFF, UserRole.ADMIN]), User.is_active == True, ).all() email_sent_count = 0 for staff in it_users: # In-app notification — sent regardless of email preference 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}', ) # Individual email — only if the staff member has opted in if staff.email_notif: send_email(subject, [staff.email], html) email_sent_count += 1 logger.info( f'[NEW TICKET NOTIFY] Individual email sent to staff user_id={staff.id} ' f'email={staff.email} for ticket_id={ticket.id}' ) logger.info( f'[NEW TICKET NOTIFY] ticket_id={ticket.id} number={ticket.ticket_number} — ' f'in-app notifications sent to {len(it_users)} IT staff; ' f'individual emails sent to {email_sent_count} of {len(it_users)} staff (email_notif=True)' ) # --- Confirm receipt to ticket 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 ) # Mirror the daemon-mode logic from send_email(): non-daemon when called # outside a request context (e.g. from the SLA APScheduler job) so the # thread is not killed before SMTP delivery completes. from flask import has_request_context t = Thread(target=_send, daemon=has_request_context()) t.start() # ─── Watcher Notifications ─────────────────────────────────────────────────── def notify_watchers(ticket, event_title, event_message, exclude_user_id=None): """Send in-app notifications to all watchers of a ticket. Watchers are notified of status changes, new comments, and assignments. The user who triggered the event (exclude_user_id) is skipped so they don't receive a notification about their own action. """ from app.models import TicketWatcher, NotificationType watchers = TicketWatcher.query.filter_by(ticket_id=ticket.id).all() base_url = current_app.config.get('APP_BASE_URL', '') link = f'{base_url}/tickets/{ticket.id}' for w in watchers: if w.user_id == exclude_user_id: continue create_notification( user_id = w.user_id, notif_type = NotificationType.TICKET_UPDATE, title = event_title, message = event_message, ticket_id = ticket.id, link = link, ) # ─── Weekly IT Digest ───────────────────────────────────────────────────────── def send_weekly_digest(app): """Send a weekly summary email to all active IT staff / admin users. Summarises the past 7 days: new tickets, resolved tickets, still-open tickets with priority breakdown, and any SLA-overdue tickets. Intended to be called once a week by an APScheduler job. """ from datetime import datetime, timedelta from app.models import Ticket, TicketStatus, TicketPriority with app.app_context(): from app.services.license_service import feature_enabled if not feature_enabled('digest'): logger.debug('[DIGEST] Skipped — digest feature not enabled on current license tier.') return try: now = datetime.utcnow() week_ago = now - timedelta(days=7) base_url = app.config.get('APP_BASE_URL', '').rstrip('/') new_tickets = Ticket.query.filter(Ticket.created_at >= week_ago).all() resolved = Ticket.query.filter( Ticket.resolved_at >= week_ago ).all() open_tickets = Ticket.query.filter( Ticket.status.notin_([TicketStatus.RESOLVED, TicketStatus.CLOSED]) ).order_by(Ticket.created_at.asc()).all() overdue = [t for t in open_tickets if t.due_date and t.due_date < now] # Priority breakdown for open tickets prio_counts = {} for t in open_tickets: prio_counts[t.priority] = prio_counts.get(t.priority, 0) + 1 # Build ticket rows HTML def _ticket_rows(tickets, limit=10): rows = '' for t in tickets[:limit]: prio_color = _priority_badge_color(t.priority) url = f'{base_url}/tickets/{t.id}' rows += ( f'' f'' f'{t.ticket_number}' f'{t.title[:60]}' f'{t.priority.upper()}' f'' f'{t.assignee.full_name if t.assignee else "—"}' f'' ) if len(tickets) > limit: rows += ( f'' f'… and {len(tickets) - limit} more' ) return rows or 'None' dashboard_url = f'{base_url}/admin/' html = ( '' '
' '
' f'

📊 Weekly IT Summary

' f'

' f'{week_ago.strftime("%b %d")} – {now.strftime("%b %d, %Y")}

' '
' '
' # KPI row '
' f'
' f'
{len(new_tickets)}
' f'
New this week
' f'
' f'
{len(resolved)}
' f'
Resolved this week
' f'
' f'
{len(open_tickets)}
' f'
Still open
' f'
' f'
{len(overdue)}
' f'
Overdue
' '
' # Open tickets table '

Open Tickets

' '' '' '' '' '' '' '' f'{_ticket_rows(open_tickets)}' '
TICKETTITLEPRIORITYASSIGNED
' # Overdue section (only if any) + ( '

' '⚠ Overdue Tickets

' '' '' '' '' '' '' '' f'{_ticket_rows(overdue)}' '
TICKETTITLEPRIORITYASSIGNED
' if overdue else '' ) + f'Open Dashboard' '
' '
' 'TechDesk IT Helpdesk • Weekly digest • Automated message' '
' ) staff = User.query.filter( User.role.in_([UserRole.IT_STAFF, UserRole.ADMIN]), User.is_active == True, User.email_notif == True, ).all() if not staff: logger.info('[DIGEST] No staff with email notifications enabled — skipping digest') return subject = f'[TechDesk] Weekly IT Summary — {now.strftime("%b %d, %Y")}' for member in staff: send_email(subject, [member.email], html) logger.info(f'[DIGEST] Sent weekly digest to {member.email}') except Exception as exc: logger.error(f'[DIGEST] Failed to send weekly digest: {exc}', exc_info=True) logger.info(f'[SURVEY] Email thread started for ticket_id={ticket_id}')