04/07 updated password reset, ticket templates, and satisfaction survey
This commit is contained in:
@@ -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">★</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;">⭐ 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> —
|
||||
<em>{ticket.title}</em> — 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 • 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()
|
||||
Reference in New Issue
Block a user