Code reviewed and issue fixed.

This commit is contained in:
2026-03-27 14:01:11 -04:00
parent d8828d487a
commit 359c801027
10 changed files with 788 additions and 113 deletions
+47 -9
View File
@@ -15,17 +15,16 @@ from app.services.notification_service import (
notify_comment_added, notify_assignment,
)
from app.services.log_service import log_action, log_ticket_history
from app.services.validation_service import validate_file
tickets_bp = Blueprint('tickets', __name__)
logger = logging.getLogger(__name__)
# Allowed extensions for ticket and comment attachments.
# validate_file() uses this set for both extension and magic-byte checks.
ALLOWED_EXT = {'png', 'jpg', 'jpeg', 'gif', 'pdf', 'doc', 'docx', 'txt', 'zip', 'log'}
def allowed_file(filename):
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXT
def save_attachment(file, ticket_id=None, comment_id=None, uploader_id=None):
filename = secure_filename(file.filename)
ext = filename.rsplit('.', 1)[1].lower() if '.' in filename else ''
@@ -114,7 +113,11 @@ def create_ticket():
# Handle file uploads
for f in request.files.getlist('attachments'):
if f and f.filename and allowed_file(f.filename):
if f and f.filename:
file_error = validate_file(f, ALLOWED_EXT)
if file_error:
logger.warning(f'[TICKET UPLOAD REJECTED] {file_error} filename="{f.filename}" user_id={current_user.id}')
continue
save_attachment(f, ticket_id=ticket.id, uploader_id=current_user.id)
log_action(current_user.id, 'ticket_create', 'ticket', ticket.id,
@@ -193,7 +196,11 @@ def ticket_detail(ticket_id):
db.session.flush()
for f in request.files.getlist('attachments'):
if f and f.filename and allowed_file(f.filename):
if f and f.filename:
file_error = validate_file(f, ALLOWED_EXT)
if file_error:
logger.warning(f'[COMMENT UPLOAD REJECTED] {file_error} filename="{f.filename}" user_id={current_user.id}')
continue
save_attachment(f, ticket_id=ticket.id,
comment_id=comment.id, uploader_id=current_user.id)
@@ -266,7 +273,7 @@ def update_ticket(ticket_id):
def _user_label(uid):
if uid is None:
return 'Unassigned'
u = User.query.get(uid)
u = db.session.get(User, uid)
return u.full_name if u else f'User #{uid}'
ticket.assigned_to_id = new_assigned
@@ -275,7 +282,7 @@ def update_ticket(ticket_id):
_user_label(new_assigned),
current_user.id)
changes.append(f'assigned_to: {old_assigned}{new_assigned}')
notify_assignment(ticket, current_user)
# notify_assignment is called AFTER commit below — see Fix #13.
ticket.internal_notes = internal_notes
ticket.resolution_notes = resolution
@@ -291,6 +298,13 @@ def update_ticket(ticket_id):
db.session.commit()
logger.info(f'[TICKET UPDATE] ticket_id={ticket.id} changes={changes} by user_id={current_user.id}')
# Both notification calls are placed after commit so that create_notification's
# independent commit never races against an uncommitted ticket state. If the
# parent commit above had failed, neither notification would be sent — which
# is the correct behaviour (no notification for a change that did not persist).
if new_assigned != old_assigned:
notify_assignment(ticket, current_user)
if new_status != old_status:
notify_status_change(ticket, old_status, current_user)
@@ -322,6 +336,18 @@ def delete_comment(comment_id):
@login_required
def download_attachment(att_id):
att = Attachment.query.get_or_404(att_id)
# Authorization: employees may only download attachments belonging to
# their own tickets. IT staff have unrestricted access across all tickets.
# att.ticket_id is the authoritative link — comment attachments also carry
# the parent ticket_id, so this check covers both ticket and comment files.
if not current_user.is_it_staff:
ticket = Ticket.query.get_or_404(att.ticket_id)
if ticket.created_by_id != current_user.id:
logger.warning(
f'[ATTACHMENT ACCESS DENIED] att_id={att_id} ticket_id={att.ticket_id} '
f'user_id={current_user.id}'
)
abort(403)
upload_dir = current_app.config['UPLOAD_FOLDER']
return send_from_directory(upload_dir, att.stored_name, as_attachment=True,
download_name=att.filename)
@@ -385,8 +411,20 @@ def knowledge_base():
@login_required
def kb_article(article_id):
article = KnowledgeBase.query.get_or_404(article_id)
article.view_count += 1
# Increment view_count atomically at the SQL level. A Python-level
# read-modify-write (article.view_count += 1) is not safe under concurrent
# requests: two simultaneous reads both see the same value and one
# increment is silently lost. The SQL expression KnowledgeBase.view_count + 1
# delegates the addition to the database, which serialises it correctly.
from sqlalchemy import update as sa_update
db.session.execute(
sa_update(KnowledgeBase)
.where(KnowledgeBase.id == article_id)
.values(view_count=KnowledgeBase.view_count + 1)
)
db.session.commit()
# Re-fetch so the template receives the post-increment value.
db.session.refresh(article)
return render_template('tickets/kb_article.html', article=article)