720 lines
33 KiB
Python
720 lines
33 KiB
Python
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 • 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 • 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(),
|
||
}
|
||
# 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'<a href="{survey_url}?rating={i}" '
|
||
f'style="display:inline-block;margin:0 4px;font-size:40px;'
|
||
f'text-decoration:none;color:#f59e0b;" title="{i} star">★</a>'
|
||
for i in range(1, 6)
|
||
)
|
||
|
||
html = (
|
||
'<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;">⭐ How did we do?</h1>'
|
||
'</div>'
|
||
'<div style="padding:32px;">'
|
||
f'<p style="color:#334155;margin-top:0;">Hi {creator.full_name},</p>'
|
||
f'<p style="color:#334155;">Your ticket <strong>{ticket.ticket_number}</strong>'
|
||
f' has been marked as resolved. We'd love to hear how we did!</p>'
|
||
'<p style="color:#334155;font-weight:600;margin-bottom:6px;">'
|
||
'How satisfied were you with the resolution?</p>'
|
||
f'<p style="text-align:center;margin:24px 0;line-height:1;">{stars_html}</p>'
|
||
f'<p style="text-align:center;margin-bottom:16px;">'
|
||
f'<a href="{survey_url}" style="color:#2563eb;font-size:13px;">'
|
||
'Or leave a written comment</a></p>'
|
||
f'<p style="color:#94a3b8;font-size:12px;">If the issue is not resolved, '
|
||
f'<a href="{ticket_url}" style="color:#2563eb;">re-open your ticket</a>.</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 • This is an automated message.'
|
||
'</div></div></body></html>'
|
||
)
|
||
|
||
# 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'<tr>'
|
||
f'<td style="padding:6px 8px;font-family:monospace;font-size:12px;color:#0891b2;">'
|
||
f'<a href="{url}" style="color:#0891b2;text-decoration:none;">{t.ticket_number}</a></td>'
|
||
f'<td style="padding:6px 8px;font-size:13px;">{t.title[:60]}</td>'
|
||
f'<td style="padding:6px 8px;"><span style="background:{prio_color};color:#fff;'
|
||
f'padding:1px 7px;border-radius:4px;font-size:11px;">{t.priority.upper()}</span></td>'
|
||
f'<td style="padding:6px 8px;font-size:12px;color:#64748b;">'
|
||
f'{t.assignee.full_name if t.assignee else "—"}</td>'
|
||
f'</tr>'
|
||
)
|
||
if len(tickets) > limit:
|
||
rows += (
|
||
f'<tr><td colspan="4" style="padding:6px 8px;font-size:12px;color:#64748b;text-align:center;">'
|
||
f'… and {len(tickets) - limit} more</td></tr>'
|
||
)
|
||
return rows or '<tr><td colspan="4" style="padding:6px 8px;color:#94a3b8;font-size:13px;">None</td></tr>'
|
||
|
||
dashboard_url = f'{base_url}/admin/'
|
||
|
||
html = (
|
||
'<html><body style="font-family:Arial,sans-serif;background:#f0f4f8;padding:20px;">'
|
||
'<div style="max-width:680px;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;">'
|
||
f'<h1 style="color:#fff;margin:0;font-size:20px;">📊 Weekly IT Summary</h1>'
|
||
f'<p style="color:#94a3b8;margin:6px 0 0;font-size:13px;">'
|
||
f'{week_ago.strftime("%b %d")} – {now.strftime("%b %d, %Y")}</p>'
|
||
'</div>'
|
||
'<div style="padding:28px 32px;">'
|
||
|
||
# KPI row
|
||
'<div style="display:flex;gap:16px;margin-bottom:24px;">'
|
||
f'<div style="flex:1;background:#f0fdf4;border-radius:8px;padding:16px;text-align:center;">'
|
||
f'<div style="font-size:28px;font-weight:700;color:#059669;">{len(new_tickets)}</div>'
|
||
f'<div style="font-size:12px;color:#64748b;">New this week</div></div>'
|
||
f'<div style="flex:1;background:#eff6ff;border-radius:8px;padding:16px;text-align:center;">'
|
||
f'<div style="font-size:28px;font-weight:700;color:#2563eb;">{len(resolved)}</div>'
|
||
f'<div style="font-size:12px;color:#64748b;">Resolved this week</div></div>'
|
||
f'<div style="flex:1;background:#fef2f2;border-radius:8px;padding:16px;text-align:center;">'
|
||
f'<div style="font-size:28px;font-weight:700;color:#dc2626;">{len(open_tickets)}</div>'
|
||
f'<div style="font-size:12px;color:#64748b;">Still open</div></div>'
|
||
f'<div style="flex:1;background:#fffbeb;border-radius:8px;padding:16px;text-align:center;">'
|
||
f'<div style="font-size:28px;font-weight:700;color:#d97706;">{len(overdue)}</div>'
|
||
f'<div style="font-size:12px;color:#64748b;">Overdue</div></div>'
|
||
'</div>'
|
||
|
||
# Open tickets table
|
||
'<h3 style="font-size:14px;font-weight:700;color:#1e293b;margin:0 0 10px;">Open Tickets</h3>'
|
||
'<table style="width:100%;border-collapse:collapse;margin-bottom:24px;">'
|
||
'<thead><tr style="background:#f8fafc;">'
|
||
'<th style="padding:6px 8px;text-align:left;font-size:11px;color:#64748b;">TICKET</th>'
|
||
'<th style="padding:6px 8px;text-align:left;font-size:11px;color:#64748b;">TITLE</th>'
|
||
'<th style="padding:6px 8px;text-align:left;font-size:11px;color:#64748b;">PRIORITY</th>'
|
||
'<th style="padding:6px 8px;text-align:left;font-size:11px;color:#64748b;">ASSIGNED</th>'
|
||
'</tr></thead>'
|
||
f'<tbody>{_ticket_rows(open_tickets)}</tbody>'
|
||
'</table>'
|
||
|
||
# Overdue section (only if any)
|
||
+ (
|
||
'<h3 style="font-size:14px;font-weight:700;color:#dc2626;margin:0 0 10px;">'
|
||
'⚠ Overdue Tickets</h3>'
|
||
'<table style="width:100%;border-collapse:collapse;margin-bottom:24px;">'
|
||
'<thead><tr style="background:#fef2f2;">'
|
||
'<th style="padding:6px 8px;text-align:left;font-size:11px;color:#64748b;">TICKET</th>'
|
||
'<th style="padding:6px 8px;text-align:left;font-size:11px;color:#64748b;">TITLE</th>'
|
||
'<th style="padding:6px 8px;text-align:left;font-size:11px;color:#64748b;">PRIORITY</th>'
|
||
'<th style="padding:6px 8px;text-align:left;font-size:11px;color:#64748b;">ASSIGNED</th>'
|
||
'</tr></thead>'
|
||
f'<tbody>{_ticket_rows(overdue)}</tbody>'
|
||
'</table>'
|
||
if overdue else ''
|
||
) +
|
||
|
||
f'<a href="{dashboard_url}" style="display:inline-block;background:#2563eb;color:#fff;'
|
||
f'padding:11px 22px;border-radius:6px;text-decoration:none;font-size:13px;">Open Dashboard</a>'
|
||
'</div>'
|
||
'<div style="background:#f8fafc;padding:14px 32px;text-align:center;'
|
||
'color:#94a3b8;font-size:11px;border-top:1px solid #e2e8f0;">'
|
||
'TechDesk IT Helpdesk • Weekly digest • Automated message'
|
||
'</div></div></body></html>'
|
||
)
|
||
|
||
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}') |