Code reviewed and issue fixed.
This commit is contained in:
+47
-9
@@ -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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user