04/06 remediate some issues
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user