04/07 updated password reset, ticket templates, and satisfaction survey

This commit is contained in:
2026-04-07 17:32:11 -04:00
parent 36945f5976
commit 60db5c3f9a
18 changed files with 1309 additions and 105 deletions
+107 -94
View File
@@ -1,5 +1,4 @@
import logging
import re
from flask import current_app, render_template_string
from flask_mail import Message
from app import db, mail, socketio
@@ -8,17 +7,6 @@ from app.models import Notification, NotificationType, User, UserRole
logger = logging.getLogger(__name__)
def _strip_tags(html: str) -> str:
"""Strip HTML tags from a stored comment body for use in notification text.
Comment bodies are persisted as sanitized HTML. When a snippet is included
in an in-app notification message or email, raw tags like <strong>, <p>
render as literal text in notification dropdowns and look unsightly.
This helper produces a clean plain-text preview for those contexts.
"""
return re.sub(r'<[^>]+>', '', html or '')
# ─── Email Templates ──────────────────────────────────────────────────────────
_NEW_TICKET_EMAIL = """
@@ -75,22 +63,7 @@ def _priority_badge_color(priority):
# ─── 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.
Transaction note
----------------
This function does NOT call db.session.commit(). It flushes the new
Notification row to surface constraint violations early, then defers
the final commit to the caller. This keeps all notifications for a
given event (e.g. notifying every IT staff member on a new ticket)
in a single atomic transaction rather than N separate commits, and
prevents partial notification state if an error occurs mid-loop.
Callers that use create_notification in a loop (notify_new_ticket,
notify_comment_added) must commit after the loop completes.
Callers that use it standalone (notify_status_change, notify_assignment)
must also commit after calling this function.
"""
"""Persist an in-app notification and push via WebSocket."""
try:
notif = Notification(
user_id = user_id,
@@ -101,9 +74,7 @@ def create_notification(user_id, notif_type, title, message, ticket_id=None, lin
link = link,
)
db.session.add(notif)
# Flush to obtain notif.id for the WebSocket payload without committing.
# The caller is responsible for the final db.session.commit().
db.session.flush()
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
@@ -128,13 +99,7 @@ def create_notification(user_id, notif_type, title, message, ticket_id=None, lin
socketio.start_background_task(_emit)
except Exception as exc:
# Expunge only the failed notification entry — do NOT roll back the
# full session, as that would undo the parent operation (e.g. a ticket
# update) that triggered this notification call.
try:
db.session.expunge(notif)
except Exception:
pass
db.session.rollback()
logger.error(f'[NOTIFICATION ERROR] Failed to create notification: {exc}')
@@ -212,10 +177,6 @@ def notify_new_ticket(ticket):
ticket_id = ticket.id,
link = f'/tickets/{ticket.id}',
)
# Commit all notifications in a single transaction.
# create_notification() flushes but does not commit — the loop above
# accumulates all Notification rows and this single commit persists them all.
db.session.commit()
def notify_status_change(ticket, old_status, changed_by):
@@ -244,7 +205,6 @@ def notify_status_change(ticket, old_status, changed_by):
ticket_id = ticket.id,
link = f'/tickets/{ticket.id}',
)
db.session.commit()
logger.info(f'[TICKET STATUS] ticket_id={ticket.id} {old_status} -> {ticket.status} by user_id={changed_by.id}')
@@ -294,15 +254,11 @@ def notify_comment_added(comment):
if user_id in notified:
return
notified.add(user_id)
# Strip HTML tags from the stored comment body before embedding in
# the notification message — raw tags render as literal text in the
# in-app notification dropdown and look unsightly.
plain_body = _strip_tags(comment.body)
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: {plain_body[:100]}',
message = f'{comment.author.full_name} commented: {comment.body[:100]}',
ticket_id = ticket.id,
link = f'/tickets/{ticket.id}#comment-{comment.id}',
)
@@ -311,7 +267,7 @@ def notify_comment_added(comment):
html = render_template_string(_STATUS_UPDATE_EMAIL,
ticket_number = ticket.ticket_number,
title = ticket.title,
message = f'{comment.author.full_name} added a comment: {plain_body[:300]}',
message = f'{comment.author.full_name} added a comment: {comment.body[:300]}',
ticket_url = ticket_url,
)
send_email(
@@ -328,25 +284,16 @@ def notify_comment_added(comment):
if ticket.assigned_to_id and ticket.assigned_to_id != comment.author_id:
_notify(ticket.assigned_to_id, is_internal=comment.is_internal)
# Commit all accumulated Notification rows in a single transaction.
# create_notification() flushes but does not commit.
db.session.commit()
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 AND the ticket creator (employee).
The employee who submitted the ticket (or on whose behalf it was filed)
receives a confirmation that their issue has been picked up, giving
them visibility without requiring them to poll the ticket page.
"""
"""Notify newly assigned IT staff member."""
if not ticket.assigned_to_id:
return
base_url = current_app.config.get('APP_BASE_URL', '')
base_url = current_app.config.get('APP_BASE_URL', '')
ticket_url = f"{base_url}/tickets/{ticket.id}"
# ── Notify the IT staff assignee ──────────────────────────────────────────
create_notification(
user_id = ticket.assigned_to_id,
notif_type= NotificationType.TICKET_ASSIGNED,
@@ -367,41 +314,107 @@ def notify_assignment(ticket, assigned_by):
[ticket.assignee.email],
html,
)
logger.info(f'[TICKET ASSIGN] ticket_id={ticket.id} assigned_to={ticket.assigned_to_id} by={assigned_by.id}')
# ── Notify the ticket creator (employee) ──────────────────────────────────
# Only notify if the creator is not the assignee — avoids a redundant
# self-notification when IT staff file and assign their own tickets.
if ticket.created_by_id != ticket.assigned_to_id:
assignee_name = ticket.assignee.full_name if ticket.assignee else 'an IT staff member'
employee_msg = (
f'Your ticket "{ticket.title}" has been picked up by {assignee_name}. '
f'You will be notified as soon as there is an update.'
)
create_notification(
user_id = ticket.created_by_id,
notif_type= NotificationType.TICKET_ASSIGNED,
title = f'Ticket {ticket.ticket_number} — Now Being Worked On',
message = employee_msg,
ticket_id = ticket.id,
link = f'/tickets/{ticket.id}',
)
creator = db.session.get(User, ticket.created_by_id)
if creator and creator.email_notif:
html = render_template_string(_STATUS_UPDATE_EMAIL,
ticket_number = ticket.ticket_number,
title = ticket.title,
message = employee_msg,
ticket_url = ticket_url,
)
send_email(
f'[Ticket Update] {ticket.ticket_number} — Now Being Worked On',
[creator.email],
html,
)
def send_satisfaction_survey(ticket):
"""Send a satisfaction survey email when a ticket is resolved.
Creates a TicketSatisfaction row with a unique survey token, then
emails the ticket creator a link to rate their experience (1-5 stars).
The link is token-authenticated so the employee does not need to be
logged in to respond.
URL construction
----------------
All other notification functions in this module use APP_BASE_URL from
config to build absolute URLs — NOT url_for(..., _external=True).
This function follows the same pattern. Using url_for inside an f-string
that is evaluated before the background thread starts causes a
RuntimeError ("Working outside of request context") which silently
swallows the entire function before the Thread is ever created.
Called from update_ticket() after commit, when status → Resolved.
"""
from app.models import TicketSatisfaction
from app import mail
from threading import Thread
from flask_mail import Message
creator = ticket.creator
if not creator or not creator.email_notif:
return
survey = TicketSatisfaction.create_for_ticket(ticket)
if not survey:
return # already sent for this ticket
db.session.commit()
logger.info(
f'[TICKET ASSIGN] ticket_id={ticket.id} '
f'assigned_to={ticket.assigned_to_id} by={assigned_by.id} '
f'employee_notified={ticket.created_by_id != ticket.assigned_to_id}'
logger.info(f'[SURVEY CREATED] ticket_id={ticket.id} token={survey.survey_token[:8]}')
# Build absolute URLs using APP_BASE_URL — identical to every other
# notification function in this file. url_for(_external=True) requires
# an active request context which is not guaranteed here.
base_url = current_app.config.get('APP_BASE_URL', '').rstrip('/')
survey_url = f"{base_url}/survey/{survey.survey_token}"
ticket_url = f"{base_url}/tickets/{ticket.id}"
stars_html = ''.join(
f'<a href="{survey_url}?rating={i}" '
f'style="display:inline-block;margin:0 6px;font-size:38px;'
f'text-decoration:none;color:#f59e0b;" title="{i} star">&#9733;</a>'
for i in range(1, 6)
)
html = f"""
<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;">
<p style="color:#334155;margin-top:0;">Hi {creator.full_name},</p>
<p style="color:#334155;">
Your ticket <strong>{ticket.ticket_number}</strong> &mdash;
<em>{ticket.title}</em> &mdash; has been marked as resolved.
We'd love to hear how we did!
</p>
<p style="color:#334155;font-weight:600;margin-bottom:6px;">
Click a star to rate your experience:
</p>
<p style="text-align:center;margin:20px 0;line-height:1;">
{stars_html}
</p>
<p style="text-align:center;">
<a href="{survey_url}" style="color:#2563eb;font-size:13px;">
Or leave a detailed comment
</a>
</p>
<p style="color:#94a3b8;font-size:12px;margin-top:24px;">
If you feel the issue is not fully resolved, you can
<a href="{ticket_url}" style="color:#2563eb;">re-open your ticket</a>
at any time.
</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>"""
msg = Message(
subject = f'[TechDesk] How did we do? — {ticket.ticket_number}',
recipients = [creator.email],
html = html,
)
def _send():
with current_app.app_context():
try:
mail.send(msg)
logger.info(f'[SURVEY EMAIL SENT] ticket_id={ticket.id} user_id={creator.id}')
except Exception as exc:
logger.error(f'[SURVEY EMAIL FAILED] ticket_id={ticket.id} {exc}')
Thread(target=_send, daemon=True).start()