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 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]