From 53eaf1c76d4a6406fe8a4095944be8e1a5615d15 Mon Sep 17 00:00:00 2001 From: NguyenND Date: Mon, 30 Mar 2026 14:04:58 -0400 Subject: [PATCH] Upgrade code --- app/routes/admin.py | 115 +++++++++++++++++++++++++-- app/routes/api.py | 5 +- app/routes/auth.py | 47 ++++++++++- app/routes/tickets.py | 73 ++++++++++++----- app/services/notification_service.py | 7 +- app/services/validation_service.py | 49 ++++++++++++ app/templates/admin/tickets.html | 21 +++-- app/templates/auth/profile.html | 17 +++- app/templates/base.html | 21 ++++- app/templates/tickets/detail.html | 90 ++++++++++++++++++--- app/templates/tickets/list.html | 2 +- requirements.txt | 1 + 12 files changed, 390 insertions(+), 58 deletions(-) diff --git a/app/routes/admin.py b/app/routes/admin.py index 01cb9db..d1b18f1 100644 --- a/app/routes/admin.py +++ b/app/routes/admin.py @@ -1,9 +1,11 @@ import logging import os import uuid +import csv +import io from datetime import datetime, timedelta from functools import wraps -from flask import Blueprint, render_template, redirect, url_for, flash, request, abort, jsonify, current_app, send_from_directory +from flask import Blueprint, render_template, redirect, url_for, flash, request, abort, jsonify, current_app, send_from_directory, Response from flask_login import login_required, current_user from werkzeug.utils import secure_filename import bleach @@ -340,7 +342,7 @@ def create_user(): @login_required @admin_required def edit_user(user_id): - user = User.query.get_or_404(user_id) + user = db.session.get(User, user_id) or abort(404) if request.method == 'POST': old_role = user.role user.full_name = request.form.get('full_name', user.full_name).strip() @@ -370,7 +372,7 @@ def edit_user(user_id): @login_required @admin_required def delete_user(user_id): - user = User.query.get_or_404(user_id) + user = db.session.get(User, user_id) or abort(404) if user.id == current_user.id: flash('You cannot delete your own account.', 'danger') return redirect(url_for('admin.users')) @@ -392,6 +394,7 @@ def all_tickets(): status = request.args.get('status', '') priority = request.args.get('priority', '') assigned = request.args.get('assigned', '') + search = request.args.get('q', '').strip() q = Ticket.query if status: q = q.filter_by(status=status) @@ -399,9 +402,30 @@ def all_tickets(): if assigned == 'me': q = q.filter_by(assigned_to_id=current_user.id) elif assigned == 'unassigned': q = q.filter_by(assigned_to_id=None) + if search: + from app.models import Comment + submitter_alias = db.aliased(User) + assignee_alias = db.aliased(User) + q = ( + q + .outerjoin(submitter_alias, submitter_alias.id == Ticket.created_by_id) + .outerjoin(assignee_alias, assignee_alias.id == Ticket.assigned_to_id) + .outerjoin(Comment, Comment.ticket_id == Ticket.id) + .filter( + Ticket.title.ilike(f'%{search}%') | + Ticket.ticket_number.ilike(f'%{search}%') | + Ticket.description.ilike(f'%{search}%') | + submitter_alias.full_name.ilike(f'%{search}%') | + assignee_alias.full_name.ilike(f'%{search}%') | + Comment.body.ilike(f'%{search}%') + ) + .distinct() + ) + tickets = q.order_by(Ticket.created_at.desc()).paginate(page=page, per_page=25) return render_template('admin/tickets.html', tickets=tickets, - status=status, priority=priority, assigned=assigned) + status=status, priority=priority, assigned=assigned, + search=search) # ─── Knowledge Base Management ──────────────────────────────────────────────── @@ -623,7 +647,7 @@ def kb_new(): @login_required @it_required def kb_edit(article_id): - article = KnowledgeBase.query.get_or_404(article_id) + article = db.session.get(KnowledgeBase, article_id) or abort(404) if request.method == 'POST': try: article.title = request.form.get('title', article.title).strip() @@ -658,7 +682,7 @@ def kb_edit(article_id): @it_required def kb_toggle_publish(article_id): """Quick publish/unpublish toggle — callable from the article list.""" - article = KnowledgeBase.query.get_or_404(article_id) + article = db.session.get(KnowledgeBase, article_id) or abort(404) article.is_published = not article.is_published state = 'published' if article.is_published else 'unpublished' log_action(current_user.id, f'kb_{state}', 'knowledge_base', article.id, @@ -673,7 +697,7 @@ def kb_toggle_publish(article_id): @login_required @it_required def kb_delete(article_id): - article = KnowledgeBase.query.get_or_404(article_id) + article = db.session.get(KnowledgeBase, article_id) or abort(404) upload_dir = current_app.config['UPLOAD_FOLDER'] # Remove physical files before the cascade deletes the KBAttachment rows. @@ -740,6 +764,83 @@ def activity_logs(): ) +@admin_bp.route('/tickets/export') +@login_required +@admin_required +def export_tickets(): + """Stream a CSV of tickets matching the current filter params.""" + status = request.args.get('status', '') + priority = request.args.get('priority', '') + assigned = request.args.get('assigned', '') + search = request.args.get('q', '').strip() + + q = Ticket.query + if status: q = q.filter_by(status=status) + if priority: q = q.filter_by(priority=priority) + if assigned == 'me': q = q.filter_by(assigned_to_id=current_user.id) + elif assigned == 'unassigned': q = q.filter_by(assigned_to_id=None) + + if search: + submitter_alias = db.aliased(User) + assignee_alias = db.aliased(User) + q = ( + q + .outerjoin(submitter_alias, submitter_alias.id == Ticket.created_by_id) + .outerjoin(assignee_alias, assignee_alias.id == Ticket.assigned_to_id) + .outerjoin(Comment, Comment.ticket_id == Ticket.id) + .filter( + Ticket.title.ilike(f'%{search}%') | + Ticket.ticket_number.ilike(f'%{search}%') | + Ticket.description.ilike(f'%{search}%') | + submitter_alias.full_name.ilike(f'%{search}%') | + assignee_alias.full_name.ilike(f'%{search}%') | + Comment.body.ilike(f'%{search}%') + ) + .distinct() + ) + + tickets = q.order_by(Ticket.created_at.desc()).all() + + log_action(current_user.id, 'ticket_export', 'ticket', None, + f'status={status} priority={priority} assigned={assigned} q={search} count={len(tickets)}') + logger.info(f'[ADMIN EXPORT] tickets count={len(tickets)} by admin_id={current_user.id}') + + # Build the entire CSV in memory within the request context so the + # SQLAlchemy session remains active for all relationship accesses + # (creator, assignee). Streaming generators execute outside the + # request context and cause DetachedInstanceError on lazy-loaded attrs. + buf = io.StringIO() + writer = csv.writer(buf) + writer.writerow([ + 'Ticket #', 'Title', 'Category', 'Status', 'Priority', + 'Submitted By', 'Assigned To', 'Created', 'Updated', + 'Resolved', 'Due Date', 'AI Generated', 'Resolution Notes', + ]) + for t in tickets: + writer.writerow([ + t.ticket_number, + t.title, + t.category.replace('_', ' ').title(), + t.status.replace('_', ' ').title(), + t.priority.title(), + t.creator.full_name, + t.assignee.full_name if t.assignee else '', + t.created_at.strftime('%Y-%m-%d %H:%M'), + t.updated_at.strftime('%Y-%m-%d %H:%M') if t.updated_at else '', + t.resolved_at.strftime('%Y-%m-%d %H:%M') if t.resolved_at else '', + t.due_date.strftime('%Y-%m-%d') if t.due_date else '', + 'Yes' if t.ai_generated else 'No', + t.resolution_notes or '', + ]) + + filename = f"tickets_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}.csv" + return Response( + buf.getvalue(), + mimetype = 'text/csv', + headers = {'Content-Disposition': f'attachment; filename={filename}'}, + ) + + def _roles(): return [UserRole.EMPLOYEE, UserRole.IT_STAFF, UserRole.ADMIN] diff --git a/app/routes/api.py b/app/routes/api.py index 9e5573f..dba9c17 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -1,5 +1,5 @@ import logging -from flask import Blueprint, jsonify, request +from flask import Blueprint, jsonify, request, abort from flask_login import login_required, current_user from flask_socketio import emit, join_room, leave_room from app import db, socketio @@ -66,7 +66,7 @@ def mark_all_read(): def get_comments(ticket_id): """Return all visible comments for a ticket as JSON.""" from app.models import Ticket, Comment, UserRole - ticket = Ticket.query.get_or_404(ticket_id) + ticket = db.session.get(Ticket, ticket_id) or abort(404) # Employees may only see their own tickets if not current_user.is_it_staff and ticket.created_by_id != current_user.id: return jsonify({'error': 'Forbidden'}), 403 @@ -81,6 +81,7 @@ def get_comments(ticket_id): 'id' : c.id, 'author_name': c.author.full_name, 'author_init': c.author.full_name[0].upper(), + 'author_avatar': c.author.avatar_url or '', 'is_it_staff': c.author.is_it_staff, 'is_internal': c.is_internal, 'body' : c.body, diff --git a/app/routes/auth.py b/app/routes/auth.py index b62452d..dcb5da2 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -1,16 +1,22 @@ import logging +import os +import uuid from datetime import datetime from urllib.parse import urlparse, urljoin -from flask import Blueprint, render_template, redirect, url_for, flash, request +from flask import Blueprint, render_template, redirect, url_for, flash, request, current_app, send_from_directory from flask_login import login_user, logout_user, login_required, current_user +from werkzeug.utils import secure_filename from app import db, limiter from app.models import User, UserRole from app.services.log_service import log_action -from app.services.validation_service import validate_password +from app.services.validation_service import validate_password, validate_file auth_bp = Blueprint('auth', __name__, url_prefix='/auth') logger = logging.getLogger(__name__) +AVATAR_ALLOWED_EXT = {'png', 'jpg', 'jpeg', 'gif', 'webp'} +AVATAR_MAX_BYTES = 5 * 1024 * 1024 # 5 MB + def _is_safe_url(target): """Return True only when *target* points back to this same host. @@ -138,6 +144,31 @@ def profile(): current_user.email_notif= email_notif current_user.web_notif = web_notif + # ── Avatar upload ───────────────────────────────────────────────────── + avatar_file = request.files.get('avatar') + if avatar_file and avatar_file.filename: + file_error = validate_file(avatar_file, AVATAR_ALLOWED_EXT) + if file_error: + flash(f'Avatar not saved: {file_error}', 'danger') + else: + avatar_file.stream.seek(0, 2) + avatar_size = avatar_file.stream.tell() + avatar_file.stream.seek(0) + if avatar_size > AVATAR_MAX_BYTES: + flash('Avatar image must be under 5 MB.', 'danger') + else: + ext = secure_filename(avatar_file.filename).rsplit('.', 1)[-1].lower() + stored_name = f"avatar_{current_user.id}_{uuid.uuid4().hex}.{ext}" + upload_dir = current_app.config['UPLOAD_FOLDER'] + # Delete old avatar file from disk if present + if current_user.avatar_url: + old_file = os.path.join(upload_dir, os.path.basename(current_user.avatar_url)) + if os.path.exists(old_file): + os.remove(old_file) + avatar_file.save(os.path.join(upload_dir, stored_name)) + current_user.avatar_url = stored_name + logger.info(f'[AUTH AVATAR UPLOAD] user_id={current_user.id} file={stored_name}') + if new_pw: pw_error = validate_password(new_pw, confirm_pw) if pw_error: @@ -152,3 +183,15 @@ def profile(): flash('Profile updated successfully.', 'success') return render_template('auth/profile.html') + + +@auth_bp.route('/avatar/') +@login_required +def serve_avatar(filename): + """Serve a user avatar image stored in the upload folder.""" + # Prevent path traversal — stored_name never contains slashes + if '/' in filename or '\\' in filename or '..' in filename: + from flask import abort + abort(400) + upload_dir = current_app.config['UPLOAD_FOLDER'] + return send_from_directory(upload_dir, filename, as_attachment=False) diff --git a/app/routes/tickets.py b/app/routes/tickets.py index 91bdddf..4a0bb25 100644 --- a/app/routes/tickets.py +++ b/app/routes/tickets.py @@ -15,7 +15,7 @@ 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 +from app.services.validation_service import validate_file, render_comment_body tickets_bp = Blueprint('tickets', __name__) logger = logging.getLogger(__name__) @@ -154,10 +154,22 @@ def ticket_list(): if category: query = query.filter_by(category=category) if search: - query = query.filter( - Ticket.title.ilike(f'%{search}%') | - Ticket.ticket_number.ilike(f'%{search}%') | - Ticket.description.ilike(f'%{search}%') + # Extend search to cover comments and assignee name via outer joins. + # distinct() prevents duplicate ticket rows when multiple comments match. + from app.models import Comment + assignee_alias = db.aliased(User) + query = ( + query + .outerjoin(Comment, Comment.ticket_id == Ticket.id) + .outerjoin(assignee_alias, assignee_alias.id == Ticket.assigned_to_id) + .filter( + Ticket.title.ilike(f'%{search}%') | + Ticket.ticket_number.ilike(f'%{search}%') | + Ticket.description.ilike(f'%{search}%') | + Comment.body.ilike(f'%{search}%') | + assignee_alias.full_name.ilike(f'%{search}%') + ) + .distinct() ) tickets = query.order_by(Ticket.created_at.desc()).paginate(page=page, per_page=20) @@ -173,7 +185,7 @@ def ticket_list(): @tickets_bp.route('/tickets/', methods=['GET', 'POST']) @login_required def ticket_detail(ticket_id): - ticket = Ticket.query.get_or_404(ticket_id) + ticket = db.session.get(Ticket, ticket_id) or abort(404) # Employees can only view their own tickets if not current_user.is_it_staff and ticket.created_by_id != current_user.id: @@ -189,20 +201,41 @@ def ticket_detail(ticket_id): comment = Comment( ticket_id = ticket.id, author_id = current_user.id, - body = body, + body = render_comment_body(body), is_internal= is_internal, ) db.session.add(comment) db.session.flush() - for f in request.files.getlist('attachments'): - 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) + # ── Attachment limits (mirrors client-side constants in detail.html) + MAX_COMMENT_FILES = 5 + MAX_COMMENT_BYTES = 25 * 1024 * 1024 # 25 MB total per comment + + uploaded_files = [f for f in request.files.getlist('attachments') if f and f.filename] + if len(uploaded_files) > MAX_COMMENT_FILES: + db.session.rollback() + logger.warning(f'[COMMENT UPLOAD REJECTED] Too many files ({len(uploaded_files)}) ticket_id={ticket.id} user_id={current_user.id}') + flash(f'Too many attachments — maximum {MAX_COMMENT_FILES} files per comment.', 'danger') + return redirect(url_for('tickets.ticket_detail', ticket_id=ticket.id)) + + total_bytes = 0 + for f in uploaded_files: + f.stream.seek(0, 2) + total_bytes += f.stream.tell() + f.stream.seek(0) + if total_bytes > MAX_COMMENT_BYTES: + db.session.rollback() + logger.warning(f'[COMMENT UPLOAD REJECTED] Total size {total_bytes} exceeds limit ticket_id={ticket.id} user_id={current_user.id}') + flash(f'Total attachment size exceeds the 25 MB limit per comment.', 'danger') + return redirect(url_for('tickets.ticket_detail', ticket_id=ticket.id)) + + for f in uploaded_files: + 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) log_action(current_user.id, 'comment_create', 'comment', comment.id, f'ticket_id={ticket.id} internal={is_internal}') @@ -239,7 +272,7 @@ def update_ticket(ticket_id): if not current_user.is_it_staff: abort(403) - ticket = Ticket.query.get_or_404(ticket_id) + ticket = db.session.get(Ticket, ticket_id) or abort(404) old_status = ticket.status old_priority = ticket.priority old_assigned = ticket.assigned_to_id @@ -317,7 +350,7 @@ def update_ticket(ticket_id): @tickets_bp.route('/comments//delete', methods=['POST']) @login_required def delete_comment(comment_id): - comment = Comment.query.get_or_404(comment_id) + comment = db.session.get(Comment, comment_id) or abort(404) if not current_user.is_it_staff and comment.author_id != current_user.id: abort(403) ticket_id = comment.ticket_id @@ -335,13 +368,13 @@ def delete_comment(comment_id): @tickets_bp.route('/attachments/') @login_required def download_attachment(att_id): - att = Attachment.query.get_or_404(att_id) + att = db.session.get(Attachment, att_id) or abort(404) # 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) + ticket = db.session.get(Ticket, att.ticket_id) or abort(404) if ticket.created_by_id != current_user.id: logger.warning( f'[ATTACHMENT ACCESS DENIED] att_id={att_id} ticket_id={att.ticket_id} ' @@ -416,7 +449,7 @@ def knowledge_base(): @tickets_bp.route('/kb/') @login_required def kb_article(article_id): - article = KnowledgeBase.query.get_or_404(article_id) + article = db.session.get(KnowledgeBase, article_id) or abort(404) # 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 diff --git a/app/services/notification_service.py b/app/services/notification_service.py index 97e854e..8895e81 100644 --- a/app/services/notification_service.py +++ b/app/services/notification_service.py @@ -219,9 +219,10 @@ def notify_comment_added(comment): # receive the new comment immediately without needing to reload. payload = { 'id' : comment.id, - 'author_name': comment.author.full_name, - 'author_init': comment.author.full_name[0].upper(), - 'is_it_staff': comment.author.is_it_staff, + 'author_name' : comment.author.full_name, + 'author_init' : comment.author.full_name[0].upper(), + 'author_avatar': comment.author.avatar_url or '', + 'is_it_staff' : comment.author.is_it_staff, 'is_internal': comment.is_internal, 'body' : comment.body, 'created_at' : comment.created_at.strftime('%b %d, %Y %H:%M'), diff --git a/app/services/validation_service.py b/app/services/validation_service.py index cf27b28..0f796a3 100644 --- a/app/services/validation_service.py +++ b/app/services/validation_service.py @@ -6,6 +6,7 @@ copy-pasting validation code into auth.py, admin.py, and future modules. """ import logging +import mistune logger = logging.getLogger(__name__) @@ -177,3 +178,51 @@ def _group_by_alternative( else: groups.append([(offset, magic)]) return groups if groups else [[]] + + +# ─── Comment Body Markdown Rendering ───────────────────────────────────────── +# Comments support a safe subset of Markdown (bold, italic, code, lists, +# blockquotes, links). We render to HTML at write time and sanitize with a +# strict allowlist so stored bodies are always safe to render with | safe. +# +# Intentionally excluded from comments (present in KB allowlist): +# img, table, div, h1-h6, figure — keep comment rendering lightweight. + +import bleach as _bleach + +_COMMENT_ALLOWED_TAGS = { + 'p', 'br', + 'strong', 'em', 'u', 's', 'code', 'pre', + 'ul', 'ol', 'li', + 'blockquote', + 'a', + 'hr', +} +_COMMENT_ALLOWED_ATTRS = { + 'a': ['href', 'title', 'rel'], +} + +_md = mistune.create_markdown(escape=True) + + +def render_comment_body(raw_text: str) -> str: + """Convert plain-text Markdown comment to sanitized HTML. + + Renders Markdown to HTML with mistune, then strips any tags/attributes + not in the comment allowlist via bleach. The result is safe to render + with Jinja2's ``| safe`` filter without further escaping. + + Parameters + ---------- + raw_text : str – the raw plain-text comment body submitted by the user + """ + if not raw_text: + return '' + html = _md(raw_text) + cleaned = _bleach.clean( + html, + tags = _COMMENT_ALLOWED_TAGS, + attributes = _COMMENT_ALLOWED_ATTRS, + strip = True, + ) + return cleaned diff --git a/app/templates/admin/tickets.html b/app/templates/admin/tickets.html index a0e9509..0906e5e 100644 --- a/app/templates/admin/tickets.html +++ b/app/templates/admin/tickets.html @@ -8,6 +8,11 @@
+ + +
+
-
+
-
+
- +
+ {% if current_user.avatar_url %} + Avatar + {% else %}
{{ current_user.full_name[0].upper() }}
+ {% endif %}
{{ current_user.email }}
{{ current_user.role.replace('_',' ').upper() }}
+
+ + +
diff --git a/app/templates/base.html b/app/templates/base.html index 568f96e..a535941 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -254,7 +254,16 @@ .comment-card.internal{border-left:3px solid var(--warning);background:var(--warning-bg);} .comment-author{font-size:13px;font-weight:600;color:var(--text);} .comment-time{font-size:11px;color:var(--muted);font-family:'Space Mono',monospace;} - .comment-body{font-size:14px;margin-top:8px;white-space:pre-wrap;color:var(--text2);} + .comment-body{font-size:14px;margin-top:8px;color:var(--text2);line-height:1.65;} + .comment-body p{margin:0 0 8px;} + .comment-body p:last-child{margin-bottom:0;} + .comment-body ul,.comment-body ol{margin:0 0 8px;padding-left:20px;} + .comment-body pre{background:var(--surface);border:1px solid var(--border);border-radius:6px;padding:10px 14px;font-size:12px;overflow-x:auto;margin:0 0 8px;} + .comment-body code{background:var(--surface);border:1px solid var(--border);border-radius:4px;padding:1px 5px;font-size:12px;font-family:'Space Mono',monospace;} + .comment-body pre code{background:none;border:none;padding:0;} + .comment-body blockquote{border-left:3px solid var(--border);margin:0 0 8px;padding:4px 12px;color:var(--muted);} + .comment-body a{color:var(--accent3);} + .comment-body hr{border:none;border-top:1px solid var(--border);margin:10px 0;} .history-item{font-size:12px;color:var(--muted);padding:6px 0;border-bottom:1px solid var(--border);} /* ── Responsive ── */ @@ -332,7 +341,15 @@