Files
IT_Ticket_System/app/services/notification_service.py
T
2026-05-22 16:31:19 -04:00

720 lines
33 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(),
}
# 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">&#9733;</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;">&#11088; 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&#39;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 &bull; 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;">&#128202; 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;">'
'&#9888; 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 &bull; Weekly digest &bull; 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}')