Upgrade code
This commit is contained in:
+108
-7
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -221,6 +221,7 @@ def notify_comment_added(comment):
|
|||||||
'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(),
|
||||||
|
'author_avatar': comment.author.avatar_url or '',
|
||||||
'is_it_staff' : comment.author.is_it_staff,
|
'is_it_staff' : comment.author.is_it_staff,
|
||||||
'is_internal': comment.is_internal,
|
'is_internal': comment.is_internal,
|
||||||
'body' : comment.body,
|
'body' : comment.body,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user