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,
+27 -5
View File
@@ -24,16 +24,26 @@ def validate_password(password: str, confirm: str) -> str | None:
-----
- Password and confirmation must match.
- Minimum length: 8 characters.
- Must contain at least one uppercase letter (A-Z).
- Must contain at least one digit (0-9).
- Must contain at least one special character (!@#$%^&* etc.).
Parameters
----------
password : str the candidate password (plain text)
confirm : str the confirmation field value
"""
import re
if password != confirm:
return 'Passwords do not match.'
if len(password) < 8:
return 'Password must be at least 8 characters.'
if not re.search(r'[A-Z]', password):
return 'Password must contain at least one uppercase letter.'
if not re.search(r'\d', password):
return 'Password must contain at least one number.'
if not re.search(r'[!@#$%^&*()\-_=+\[\]{};:\'",.<>?/\\|`~]', password):
return 'Password must contain at least one special character.'
return None
@@ -163,10 +173,22 @@ def _group_by_alternative(
[[gif87_sig], [gif89_sig]] so the caller can treat each inner list as
a complete match candidate.
The rule: each entry with offset=0 starts a new alternative group.
Entries with offset>0 are appended to the current group (they are
additional constraints on the same file type, e.g. WEBP needs both
offset-0 'RIFF' and offset-8 'WEBP').
Grouping rule
-------------
Each entry with offset=0 starts a **new alternative** group.
Entries with offset>0 are appended to the **current** group — they
represent additional byte constraints that must ALL match alongside
the group's offset-0 anchor (e.g. WEBP requires both RIFF at offset 0
AND 'WEBP' at offset 8 within the same file).
⚠️ Constraint: no two entries in the same alternative group may share
offset=0. If a future signature needs two offset-0 checks as part of
ONE alternative (i.e. two different bytes that must both appear at the
start of the same file), this function would incorrectly split them
into separate alternatives. In that case, use a combined bytes object
covering the full header range instead of two separate entries, or
refactor _MAGIC to use a dedicated tuple type that carries an
'alternative_id' discriminator.
"""
groups: list[list[tuple[int, bytes]]] = []
for offset, magic in signatures:
@@ -225,4 +247,4 @@ def render_comment_body(raw_text: str) -> str:
attributes = _COMMENT_ALLOWED_ATTRS,
strip = True,
)
return cleaned
return cleaned