04/06 remediate some issues

This commit is contained in:
2026-04-06 16:31:50 -04:00
parent d7293f2747
commit 3b17705911
8 changed files with 211 additions and 28 deletions
+53 -5
View File
@@ -1,4 +1,5 @@
import logging
import re
from flask import current_app, render_template_string
from flask_mail import Message
from app import db, mail, socketio
@@ -7,6 +8,17 @@ 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 = """
@@ -63,7 +75,22 @@ 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."""
"""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.
"""
try:
notif = Notification(
user_id = user_id,
@@ -74,7 +101,9 @@ def create_notification(user_id, notif_type, title, message, ticket_id=None, lin
link = link,
)
db.session.add(notif)
db.session.commit()
# Flush to obtain notif.id for the WebSocket payload without committing.
# The caller is responsible for the final db.session.commit().
db.session.flush()
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
@@ -99,7 +128,13 @@ def create_notification(user_id, notif_type, title, message, ticket_id=None, lin
socketio.start_background_task(_emit)
except Exception as exc:
db.session.rollback()
# 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
logger.error(f'[NOTIFICATION ERROR] Failed to create notification: {exc}')
@@ -177,6 +212,10 @@ 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):
@@ -205,6 +244,7 @@ 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}')
@@ -254,11 +294,15 @@ 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: {comment.body[:100]}',
message = f'{comment.author.full_name} commented: {plain_body[:100]}',
ticket_id = ticket.id,
link = f'/tickets/{ticket.id}#comment-{comment.id}',
)
@@ -267,7 +311,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: {comment.body[:300]}',
message = f'{comment.author.full_name} added a comment: {plain_body[:300]}',
ticket_url = ticket_url,
)
send_email(
@@ -284,6 +328,9 @@ 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}')
@@ -302,6 +349,7 @@ def notify_assignment(ticket, assigned_by):
ticket_id = ticket.id,
link = f'/tickets/{ticket.id}',
)
db.session.commit()
if ticket.assignee and ticket.assignee.email_notif:
html = render_template_string(_STATUS_UPDATE_EMAIL,
ticket_number = ticket.ticket_number,