Upgrade code
This commit is contained in:
+108
-7
@@ -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]
|
||||
|
||||
|
||||
+3
-2
@@ -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,
|
||||
|
||||
+45
-2
@@ -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/<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)
|
||||
|
||||
+53
-20
@@ -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/<int:ticket_id>', 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/<int:comment_id>/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/<int:att_id>')
|
||||
@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/<int:article_id>')
|
||||
@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
|
||||
|
||||
Reference in New Issue
Block a user