Upgrade code

This commit is contained in:
2026-03-30 14:04:58 -04:00
parent bf29ccb287
commit 53eaf1c76d
12 changed files with 390 additions and 58 deletions
+108 -7
View File
@@ -1,9 +1,11 @@
import logging import logging
import os import os
import uuid import uuid
import csv
import io
from datetime import datetime, timedelta from datetime import datetime, timedelta
from functools import wraps 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 flask_login import login_required, current_user
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
import bleach import bleach
@@ -340,7 +342,7 @@ def create_user():
@login_required @login_required
@admin_required @admin_required
def edit_user(user_id): 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': if request.method == 'POST':
old_role = user.role old_role = user.role
user.full_name = request.form.get('full_name', user.full_name).strip() user.full_name = request.form.get('full_name', user.full_name).strip()
@@ -370,7 +372,7 @@ def edit_user(user_id):
@login_required @login_required
@admin_required @admin_required
def delete_user(user_id): 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: if user.id == current_user.id:
flash('You cannot delete your own account.', 'danger') flash('You cannot delete your own account.', 'danger')
return redirect(url_for('admin.users')) return redirect(url_for('admin.users'))
@@ -392,6 +394,7 @@ def all_tickets():
status = request.args.get('status', '') status = request.args.get('status', '')
priority = request.args.get('priority', '') priority = request.args.get('priority', '')
assigned = request.args.get('assigned', '') assigned = request.args.get('assigned', '')
search = request.args.get('q', '').strip()
q = Ticket.query q = Ticket.query
if status: q = q.filter_by(status=status) 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) if assigned == 'me': q = q.filter_by(assigned_to_id=current_user.id)
elif assigned == 'unassigned': q = q.filter_by(assigned_to_id=None) 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) tickets = q.order_by(Ticket.created_at.desc()).paginate(page=page, per_page=25)
return render_template('admin/tickets.html', tickets=tickets, 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 ──────────────────────────────────────────────── # ─── Knowledge Base Management ────────────────────────────────────────────────
@@ -623,7 +647,7 @@ def kb_new():
@login_required @login_required
@it_required @it_required
def kb_edit(article_id): 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': if request.method == 'POST':
try: try:
article.title = request.form.get('title', article.title).strip() article.title = request.form.get('title', article.title).strip()
@@ -658,7 +682,7 @@ def kb_edit(article_id):
@it_required @it_required
def kb_toggle_publish(article_id): def kb_toggle_publish(article_id):
"""Quick publish/unpublish toggle — callable from the article list.""" """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 article.is_published = not article.is_published
state = 'published' if article.is_published else 'unpublished' state = 'published' if article.is_published else 'unpublished'
log_action(current_user.id, f'kb_{state}', 'knowledge_base', article.id, log_action(current_user.id, f'kb_{state}', 'knowledge_base', article.id,
@@ -673,7 +697,7 @@ def kb_toggle_publish(article_id):
@login_required @login_required
@it_required @it_required
def kb_delete(article_id): 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'] upload_dir = current_app.config['UPLOAD_FOLDER']
# Remove physical files before the cascade deletes the KBAttachment rows. # 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(): def _roles():
return [UserRole.EMPLOYEE, UserRole.IT_STAFF, UserRole.ADMIN] return [UserRole.EMPLOYEE, UserRole.IT_STAFF, UserRole.ADMIN]
+3 -2
View File
@@ -1,5 +1,5 @@
import logging 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_login import login_required, current_user
from flask_socketio import emit, join_room, leave_room from flask_socketio import emit, join_room, leave_room
from app import db, socketio from app import db, socketio
@@ -66,7 +66,7 @@ def mark_all_read():
def get_comments(ticket_id): def get_comments(ticket_id):
"""Return all visible comments for a ticket as JSON.""" """Return all visible comments for a ticket as JSON."""
from app.models import Ticket, Comment, UserRole 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 # Employees may only see their own tickets
if not current_user.is_it_staff and ticket.created_by_id != current_user.id: if not current_user.is_it_staff and ticket.created_by_id != current_user.id:
return jsonify({'error': 'Forbidden'}), 403 return jsonify({'error': 'Forbidden'}), 403
@@ -81,6 +81,7 @@ def get_comments(ticket_id):
'id' : c.id, 'id' : c.id,
'author_name': c.author.full_name, 'author_name': c.author.full_name,
'author_init': c.author.full_name[0].upper(), 'author_init': c.author.full_name[0].upper(),
'author_avatar': c.author.avatar_url or '',
'is_it_staff': c.author.is_it_staff, 'is_it_staff': c.author.is_it_staff,
'is_internal': c.is_internal, 'is_internal': c.is_internal,
'body' : c.body, 'body' : c.body,
+45 -2
View File
@@ -1,16 +1,22 @@
import logging import logging
import os
import uuid
from datetime import datetime from datetime import datetime
from urllib.parse import urlparse, urljoin 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 flask_login import login_user, logout_user, login_required, current_user
from werkzeug.utils import secure_filename
from app import db, limiter from app import db, limiter
from app.models import User, UserRole from app.models import User, UserRole
from app.services.log_service import log_action 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') auth_bp = Blueprint('auth', __name__, url_prefix='/auth')
logger = logging.getLogger(__name__) 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): def _is_safe_url(target):
"""Return True only when *target* points back to this same host. """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.email_notif= email_notif
current_user.web_notif = web_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: if new_pw:
pw_error = validate_password(new_pw, confirm_pw) pw_error = validate_password(new_pw, confirm_pw)
if pw_error: if pw_error:
@@ -152,3 +183,15 @@ def profile():
flash('Profile updated successfully.', 'success') flash('Profile updated successfully.', 'success')
return render_template('auth/profile.html') return render_template('auth/profile.html')
@auth_bp.route('/avatar/<string:filename>')
@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)
+45 -12
View File
@@ -15,7 +15,7 @@ from app.services.notification_service import (
notify_comment_added, notify_assignment, notify_comment_added, notify_assignment,
) )
from app.services.log_service import log_action, log_ticket_history 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__) tickets_bp = Blueprint('tickets', __name__)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -154,10 +154,22 @@ def ticket_list():
if category: if category:
query = query.filter_by(category=category) query = query.filter_by(category=category)
if search: if search:
query = query.filter( # 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.title.ilike(f'%{search}%') |
Ticket.ticket_number.ilike(f'%{search}%') | Ticket.ticket_number.ilike(f'%{search}%') |
Ticket.description.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) 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/<int:ticket_id>', methods=['GET', 'POST']) @tickets_bp.route('/tickets/<int:ticket_id>', methods=['GET', 'POST'])
@login_required @login_required
def ticket_detail(ticket_id): 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 # Employees can only view their own tickets
if not current_user.is_it_staff and ticket.created_by_id != current_user.id: if not current_user.is_it_staff and ticket.created_by_id != current_user.id:
@@ -189,14 +201,35 @@ def ticket_detail(ticket_id):
comment = Comment( comment = Comment(
ticket_id = ticket.id, ticket_id = ticket.id,
author_id = current_user.id, author_id = current_user.id,
body = body, body = render_comment_body(body),
is_internal= is_internal, is_internal= is_internal,
) )
db.session.add(comment) db.session.add(comment)
db.session.flush() db.session.flush()
for f in request.files.getlist('attachments'): # ── Attachment limits (mirrors client-side constants in detail.html)
if f and f.filename: 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) file_error = validate_file(f, ALLOWED_EXT)
if file_error: if file_error:
logger.warning(f'[COMMENT UPLOAD REJECTED] {file_error} filename="{f.filename}" user_id={current_user.id}') logger.warning(f'[COMMENT UPLOAD REJECTED] {file_error} filename="{f.filename}" user_id={current_user.id}')
@@ -239,7 +272,7 @@ def update_ticket(ticket_id):
if not current_user.is_it_staff: if not current_user.is_it_staff:
abort(403) 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_status = ticket.status
old_priority = ticket.priority old_priority = ticket.priority
old_assigned = ticket.assigned_to_id old_assigned = ticket.assigned_to_id
@@ -317,7 +350,7 @@ def update_ticket(ticket_id):
@tickets_bp.route('/comments/<int:comment_id>/delete', methods=['POST']) @tickets_bp.route('/comments/<int:comment_id>/delete', methods=['POST'])
@login_required @login_required
def delete_comment(comment_id): 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: if not current_user.is_it_staff and comment.author_id != current_user.id:
abort(403) abort(403)
ticket_id = comment.ticket_id ticket_id = comment.ticket_id
@@ -335,13 +368,13 @@ def delete_comment(comment_id):
@tickets_bp.route('/attachments/<int:att_id>') @tickets_bp.route('/attachments/<int:att_id>')
@login_required @login_required
def download_attachment(att_id): 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 # Authorization: employees may only download attachments belonging to
# their own tickets. IT staff have unrestricted access across all tickets. # their own tickets. IT staff have unrestricted access across all tickets.
# att.ticket_id is the authoritative link — comment attachments also carry # att.ticket_id is the authoritative link — comment attachments also carry
# the parent ticket_id, so this check covers both ticket and comment files. # the parent ticket_id, so this check covers both ticket and comment files.
if not current_user.is_it_staff: 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: if ticket.created_by_id != current_user.id:
logger.warning( logger.warning(
f'[ATTACHMENT ACCESS DENIED] att_id={att_id} ticket_id={att.ticket_id} ' f'[ATTACHMENT ACCESS DENIED] att_id={att_id} ticket_id={att.ticket_id} '
@@ -416,7 +449,7 @@ def knowledge_base():
@tickets_bp.route('/kb/<int:article_id>') @tickets_bp.route('/kb/<int:article_id>')
@login_required @login_required
def kb_article(article_id): 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 # Increment view_count atomically at the SQL level. A Python-level
# read-modify-write (article.view_count += 1) is not safe under concurrent # read-modify-write (article.view_count += 1) is not safe under concurrent
# requests: two simultaneous reads both see the same value and one # requests: two simultaneous reads both see the same value and one
+4 -3
View File
@@ -219,9 +219,10 @@ def notify_comment_added(comment):
# receive the new comment immediately without needing to reload. # receive the new comment immediately without needing to reload.
payload = { payload = {
'id' : comment.id, 'id' : comment.id,
'author_name': comment.author.full_name, 'author_name' : comment.author.full_name,
'author_init': comment.author.full_name[0].upper(), 'author_init' : comment.author.full_name[0].upper(),
'is_it_staff': comment.author.is_it_staff, 'author_avatar': comment.author.avatar_url or '',
'is_it_staff' : comment.author.is_it_staff,
'is_internal': comment.is_internal, 'is_internal': comment.is_internal,
'body' : comment.body, 'body' : comment.body,
'created_at' : comment.created_at.strftime('%b %d, %Y %H:%M'), 'created_at' : comment.created_at.strftime('%b %d, %Y %H:%M'),
+49
View File
@@ -6,6 +6,7 @@ copy-pasting validation code into auth.py, admin.py, and future modules.
""" """
import logging import logging
import mistune
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -177,3 +178,51 @@ def _group_by_alternative(
else: else:
groups.append([(offset, magic)]) groups.append([(offset, magic)])
return groups if groups else [[]] 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
+15 -6
View File
@@ -8,6 +8,11 @@
<div class="card-body"> <div class="card-body">
<form method="GET" class="row g-2 align-items-end"> <form method="GET" class="row g-2 align-items-end">
<div class="col-md-3"> <div class="col-md-3">
<label class="form-label">Search</label>
<input type="text" class="form-control" name="q" value="{{ search }}"
placeholder="Title, ticket #, submitter, assignee, comments…"/>
</div>
<div class="col-md-2">
<label class="form-label">Status</label> <label class="form-label">Status</label>
<select class="form-select" name="status"> <select class="form-select" name="status">
<option value="">All Statuses</option> <option value="">All Statuses</option>
@@ -16,7 +21,7 @@
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
<div class="col-md-3"> <div class="col-md-2">
<label class="form-label">Priority</label> <label class="form-label">Priority</label>
<select class="form-select" name="priority"> <select class="form-select" name="priority">
<option value="">All Priorities</option> <option value="">All Priorities</option>
@@ -25,7 +30,7 @@
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
<div class="col-md-3"> <div class="col-md-2">
<label class="form-label">Assignment</label> <label class="form-label">Assignment</label>
<select class="form-select" name="assigned"> <select class="form-select" name="assigned">
<option value="">All</option> <option value="">All</option>
@@ -35,7 +40,11 @@
</div> </div>
<div class="col-md-3 d-flex gap-2"> <div class="col-md-3 d-flex gap-2">
<button type="submit" class="btn btn-primary flex-fill"><i class="bi bi-search me-1"></i>Filter</button> <button type="submit" class="btn btn-primary flex-fill"><i class="bi bi-search me-1"></i>Filter</button>
<a href="{{ url_for('admin.all_tickets') }}" class="btn btn-secondary"><i class="bi bi-x-lg"></i></a> <a href="{{ url_for('admin.all_tickets') }}" class="btn btn-secondary" title="Clear filters"><i class="bi bi-x-lg"></i></a>
<a href="{{ url_for('admin.export_tickets', status=status, priority=priority, assigned=assigned, q=search) }}"
class="btn btn-secondary" title="Export current results to CSV">
<i class="bi bi-download me-1"></i>CSV
</a>
</div> </div>
</form> </form>
</div> </div>
@@ -83,19 +92,19 @@
<div class="d-flex justify-content-center py-3"> <div class="d-flex justify-content-center py-3">
<nav><ul class="pagination mb-0"> <nav><ul class="pagination mb-0">
{% if tickets.has_prev %} {% if tickets.has_prev %}
<li class="page-item"><a class="page-link" href="{{ url_for('admin.all_tickets', page=tickets.prev_num, status=status, priority=priority, assigned=assigned) }}"></a></li> <li class="page-item"><a class="page-link" href="{{ url_for('admin.all_tickets', page=tickets.prev_num, status=status, priority=priority, assigned=assigned, q=search) }}"></a></li>
{% endif %} {% endif %}
{% for p in tickets.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %} {% for p in tickets.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
{% if p %} {% if p %}
<li class="page-item {% if p==tickets.page %}active{% endif %}"> <li class="page-item {% if p==tickets.page %}active{% endif %}">
<a class="page-link" href="{{ url_for('admin.all_tickets', page=p, status=status, priority=priority, assigned=assigned) }}">{{ p }}</a> <a class="page-link" href="{{ url_for('admin.all_tickets', page=p, status=status, priority=priority, assigned=assigned, q=search) }}">{{ p }}</a>
</li> </li>
{% else %} {% else %}
<li class="page-item disabled"><span class="page-link"></span></li> <li class="page-item disabled"><span class="page-link"></span></li>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% if tickets.has_next %} {% if tickets.has_next %}
<li class="page-item"><a class="page-link" href="{{ url_for('admin.all_tickets', page=tickets.next_num, status=status, priority=priority, assigned=assigned) }}"></a></li> <li class="page-item"><a class="page-link" href="{{ url_for('admin.all_tickets', page=tickets.next_num, status=status, priority=priority, assigned=assigned, q=search) }}"></a></li>
{% endif %} {% endif %}
</ul></nav> </ul></nav>
</div> </div>
+15 -2
View File
@@ -8,17 +8,30 @@
<div class="card"> <div class="card">
<div class="card-header"><i class="bi bi-person-circle me-2"></i>Account Settings</div> <div class="card-header"><i class="bi bi-person-circle me-2"></i>Account Settings</div>
<div class="card-body"> <div class="card-body">
<form method="POST"> <form method="POST" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<!-- Avatar placeholder --> <!-- Avatar -->
<div class="text-center mb-4"> <div class="text-center mb-4">
{% if current_user.avatar_url %}
<img src="{{ url_for('auth.serve_avatar', filename=current_user.avatar_url) }}"
alt="Avatar"
style="width:80px;height:80px;border-radius:50%;object-fit:cover;border:2px solid var(--border);margin:0 auto 10px;display:block;"/>
{% else %}
<div style="width:80px;height:80px;border-radius:50%;background:var(--accent2);display:flex;align-items:center;justify-content:center;font-size:32px;font-weight:700;color:#fff;margin:0 auto 10px;"> <div style="width:80px;height:80px;border-radius:50%;background:var(--accent2);display:flex;align-items:center;justify-content:center;font-size:32px;font-weight:700;color:#fff;margin:0 auto 10px;">
{{ current_user.full_name[0].upper() }} {{ current_user.full_name[0].upper() }}
</div> </div>
{% endif %}
<div style="font-size:12px;color:var(--muted);">{{ current_user.email }}</div> <div style="font-size:12px;color:var(--muted);">{{ current_user.email }}</div>
<div style="font-size:11px;background:rgba(0,180,216,.1);color:var(--accent3);display:inline-block;padding:2px 10px;border-radius:12px;margin-top:4px;"> <div style="font-size:11px;background:rgba(0,180,216,.1);color:var(--accent3);display:inline-block;padding:2px 10px;border-radius:12px;margin-top:4px;">
{{ current_user.role.replace('_',' ').upper() }} {{ current_user.role.replace('_',' ').upper() }}
</div> </div>
<div class="mt-3">
<label class="form-label" style="font-size:12px;">Profile Picture
<span style="color:var(--muted);font-weight:400;">(PNG, JPG, GIF, WebP — max 5 MB)</span>
</label>
<input type="file" class="form-control form-control-sm" name="avatar"
accept=".png,.jpg,.jpeg,.gif,.webp" style="max-width:300px;margin:0 auto;"/>
</div>
</div> </div>
<div class="row g-3"> <div class="row g-3">
+19 -2
View File
@@ -254,7 +254,16 @@
.comment-card.internal{border-left:3px solid var(--warning);background:var(--warning-bg);} .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-author{font-size:13px;font-weight:600;color:var(--text);}
.comment-time{font-size:11px;color:var(--muted);font-family:'Space Mono',monospace;} .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);} .history-item{font-size:12px;color:var(--muted);padding:6px 0;border-bottom:1px solid var(--border);}
/* ── Responsive ── */ /* ── Responsive ── */
@@ -332,7 +341,15 @@
<div class="sidebar-footer"> <div class="sidebar-footer">
<div class="user-card"> <div class="user-card">
<div class="avatar">{{ current_user.full_name[0].upper() }}</div> <div class="avatar">
{% if current_user.avatar_url %}
<img src="{{ url_for('auth.serve_avatar', filename=current_user.avatar_url) }}"
alt="{{ current_user.full_name[0].upper() }}"
style="width:32px;height:32px;border-radius:50%;object-fit:cover;display:block;"/>
{% else %}
{{ current_user.full_name[0].upper() }}
{% endif %}
</div>
<div> <div>
<div class="user-name">{{ current_user.full_name }}</div> <div class="user-name">{{ current_user.full_name }}</div>
<div class="user-role">{{ current_user.role.replace('_',' ') }}</div> <div class="user-role">{{ current_user.role.replace('_',' ') }}</div>
+70 -6
View File
@@ -38,16 +38,27 @@
{% if ticket_atts %} {% if ticket_atts %}
<div class="card mb-4"> <div class="card mb-4">
<div class="card-header"><i class="bi bi-paperclip me-2"></i>Attachments</div> <div class="card-header"><i class="bi bi-paperclip me-2"></i>Attachments</div>
<div class="card-body d-flex flex-wrap gap-2"> <div class="card-body">
<div class="d-flex flex-wrap gap-2 align-items-start">
{% for att in ticket_atts %} {% for att in ticket_atts %}
{% if att.mime_type and att.mime_type.startswith('image/') %}
<a href="{{ url_for('tickets.download_attachment', att_id=att.id) }}"
target="_blank" class="comment-img-link" title="{{ att.filename }}">
<img src="{{ url_for('tickets.download_attachment', att_id=att.id) }}"
alt="{{ att.filename }}"
class="comment-img-thumb"/>
</a>
{% else %}
<a href="{{ url_for('tickets.download_attachment', att_id=att.id) }}" <a href="{{ url_for('tickets.download_attachment', att_id=att.id) }}"
class="btn btn-secondary btn-sm"> class="btn btn-secondary btn-sm">
<i class="bi bi-download me-1"></i>{{ att.filename }} <i class="bi bi-download me-1"></i>{{ att.filename }}
<span style="font-size:10px;color:var(--muted);">({{ (att.file_size/1024)|int }}KB)</span> <span style="font-size:10px;color:var(--muted);">({{ (att.file_size/1024)|int }}KB)</span>
</a> </a>
{% endif %}
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
</div>
{% endif %} {% endif %}
<!-- Resolution notes (IT only, or if resolved) --> <!-- Resolution notes (IT only, or if resolved) -->
@@ -77,8 +88,14 @@
<div class="comment-card {% if comment.is_internal %}internal{% endif %}" id="comment-{{ comment.id }}"> <div class="comment-card {% if comment.is_internal %}internal{% endif %}" id="comment-{{ comment.id }}">
<div class="d-flex align-items-center justify-content-between mb-2"> <div class="d-flex align-items-center justify-content-between mb-2">
<div class="d-flex align-items-center gap-2"> <div class="d-flex align-items-center gap-2">
<div style="width:28px;height:28px;border-radius:50%;background:var(--accent2);display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;"> <div style="width:28px;height:28px;border-radius:50%;background:var(--accent2);display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;overflow:hidden;flex-shrink:0;">
{% if comment.author.avatar_url %}
<img src="{{ url_for('auth.serve_avatar', filename=comment.author.avatar_url) }}"
alt="{{ comment.author.full_name[0].upper() }}"
style="width:28px;height:28px;object-fit:cover;display:block;"/>
{% else %}
{{ comment.author.full_name[0].upper() }} {{ comment.author.full_name[0].upper() }}
{% endif %}
</div> </div>
<span class="comment-author">{{ comment.author.full_name }}</span> <span class="comment-author">{{ comment.author.full_name }}</span>
{% if comment.author.is_it_staff %} {% if comment.author.is_it_staff %}
@@ -102,7 +119,7 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
<div class="comment-body">{{ comment.body }}</div> <div class="comment-body">{{ comment.body | safe }}</div>
{% set c_atts = comment.attachments.all() %} {% set c_atts = comment.attachments.all() %}
{% if c_atts %} {% if c_atts %}
<div class="mt-2"> <div class="mt-2">
@@ -141,7 +158,7 @@
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="mb-3"> <div class="mb-3">
<textarea id="comment-body" class="form-control" name="body" rows="4" required <textarea id="comment-body" class="form-control" name="body" rows="4" required
placeholder="Add your update, follow-up, or response here… (you can also paste images directly)"></textarea> placeholder="Add your update… Markdown supported: **bold**, _italic_, `code`, - lists, > quotes (you can also paste images directly)"></textarea>
</div> </div>
<!-- Image preview strip — populated by paste or file picker --> <!-- Image preview strip — populated by paste or file picker -->
<div id="img-preview-strip" style="display:none;flex-wrap:wrap;gap:8px;margin-bottom:12px;"></div> <div id="img-preview-strip" style="display:none;flex-wrap:wrap;gap:8px;margin-bottom:12px;"></div>
@@ -200,11 +217,41 @@ if (typeof socket !== 'undefined') {
// on submit, since a paste event cannot modify a real <input type=file>. // on submit, since a paste event cannot modify a real <input type=file>.
let pastedFiles = []; let pastedFiles = [];
// ── Attachment limits (enforced client-side here AND server-side in tickets.py)
const MAX_ATTACHMENT_FILES = 5;
const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024; // 25 MB total per comment
function getAttachmentError(allFiles) {
if (allFiles.length > MAX_ATTACHMENT_FILES) {
return `Too many files — maximum ${MAX_ATTACHMENT_FILES} attachments per comment.`;
}
const totalBytes = allFiles.reduce((sum, f) => sum + f.size, 0);
if (totalBytes > MAX_ATTACHMENT_BYTES) {
const mb = (MAX_ATTACHMENT_BYTES / (1024 * 1024)).toFixed(0);
return `Total attachment size exceeds the ${mb} MB limit per comment.`;
}
return null;
}
function rebuildPreviewStrip() { function rebuildPreviewStrip() {
// Collect all files: pasted images + files chosen via the picker // Collect all files: pasted images + files chosen via the picker
const pickerFiles = Array.from(document.getElementById('attachment-input').files || []); const pickerFiles = Array.from(document.getElementById('attachment-input').files || []);
const allFiles = [...pastedFiles, ...pickerFiles]; const allFiles = [...pastedFiles, ...pickerFiles];
const strip = document.getElementById('img-preview-strip'); const strip = document.getElementById('img-preview-strip');
const err = document.getElementById('comment-error');
// Show/clear the limit error alongside the preview strip
const limitErr = getAttachmentError(allFiles);
if (limitErr) {
err.textContent = limitErr;
err.style.display = 'block';
} else {
// Only clear the error if it was an attachment limit message
if (err.textContent && err.textContent.includes('attachment')) {
err.style.display = 'none';
err.textContent = '';
}
}
if (allFiles.length === 0) { strip.style.display = 'none'; strip.innerHTML = ''; return; } if (allFiles.length === 0) { strip.style.display = 'none'; strip.innerHTML = ''; return; }
@@ -282,6 +329,18 @@ document.getElementById('comment-form').addEventListener('submit', async functio
const fd = new FormData(this); const fd = new FormData(this);
pastedFiles.forEach(f => fd.append('attachments', f, f.name)); pastedFiles.forEach(f => fd.append('attachments', f, f.name));
// Client-side attachment limit check — mirrors server-side guard in tickets.py
const pickerFiles = Array.from(document.getElementById('attachment-input').files || []);
const allFiles = [...pastedFiles, ...pickerFiles];
const limitErr = getAttachmentError(allFiles);
if (limitErr) {
err.textContent = limitErr;
err.style.display = 'block';
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-send me-2"></i>Post Comment';
return;
}
try { try {
const resp = await fetch(window.location.pathname, { const resp = await fetch(window.location.pathname, {
method : 'POST', method : 'POST',
@@ -408,11 +467,16 @@ function buildCommentEl(c) {
</a>`; </a>`;
}).join(''); }).join('');
const avatarInner = c.author_avatar
? `<img src="/auth/avatar/${c.author_avatar}" alt="${c.author_init}"
style="width:28px;height:28px;object-fit:cover;display:block;border-radius:50%;"/>`
: c.author_init;
wrap.innerHTML = ` wrap.innerHTML = `
<div class="d-flex align-items-center justify-content-between mb-2"> <div class="d-flex align-items-center justify-content-between mb-2">
<div class="d-flex align-items-center gap-2"> <div class="d-flex align-items-center gap-2">
<div style="width:28px;height:28px;border-radius:50%;background:var(--accent2);display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;"> <div style="width:28px;height:28px;border-radius:50%;background:var(--accent2);display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;overflow:hidden;flex-shrink:0;">
${c.author_init} ${avatarInner}
</div> </div>
<span class="comment-author">${c.author_name}</span> <span class="comment-author">${c.author_name}</span>
${itBadge}${intBadge} ${itBadge}${intBadge}
+1 -1
View File
@@ -9,7 +9,7 @@
<form method="GET" class="row g-2 align-items-end"> <form method="GET" class="row g-2 align-items-end">
<div class="col-md-4"> <div class="col-md-4">
<label class="form-label">Search</label> <label class="form-label">Search</label>
<input type="text" class="form-control" name="q" value="{{ search }}" placeholder="Search by title, ticket #, description…"/> <input type="text" class="form-control" name="q" value="{{ search }}" placeholder="Search title, ticket #, description, comments, assignee…"/>
</div> </div>
<div class="col-md-2"> <div class="col-md-2">
<label class="form-label">Status</label> <label class="form-label">Status</label>
+1
View File
@@ -18,4 +18,5 @@ requests==2.32.3
APScheduler==3.10.4 APScheduler==3.10.4
marshmallow==3.21.3 marshmallow==3.21.3
bleach==6.1.0 bleach==6.1.0
mistune==3.2.0
Flask-Limiter==3.5.0 Flask-Limiter==3.5.0