From 05ecf5a624f6e4b91d9ff8c17db10ffce34af68f Mon Sep 17 00:00:00 2001 From: NguyenND Date: Thu, 9 Apr 2026 13:10:56 -0400 Subject: [PATCH] 04/09: fixed searching ticket, and deleting/bulk deleting tickets --- app/routes/admin.py | 81 ++++++++++++++++++- app/routes/tickets.py | 2 +- app/templates/admin/tickets.html | 135 ++++++++++++++++++++++++++++++- 3 files changed, 213 insertions(+), 5 deletions(-) diff --git a/app/routes/admin.py b/app/routes/admin.py index 93e9192..bd9c6ef 100644 --- a/app/routes/admin.py +++ b/app/routes/admin.py @@ -408,7 +408,7 @@ def all_tickets(): from sqlalchemy import func submitter_alias = db.aliased(User) assignee_alias = db.aliased(User) - stripped_body = func.regexp_replace(Comment.body, r'<[^>]+>', '', 'g') + stripped_body = func.regexp_replace(Comment.body, r'<[^>]+>', '') q = ( q .outerjoin(submitter_alias, submitter_alias.id == Ticket.created_by_id) @@ -431,6 +431,44 @@ def all_tickets(): search=search) +@admin_bp.route('/tickets//delete', methods=['POST']) +@login_required +@admin_required +def delete_ticket(ticket_id): + """Permanently delete a single ticket and all its related data. + + Cascades handled by SQLAlchemy relationships (cascade='all, delete-orphan'): + comments, attachments (rows), notifications, history, satisfaction, links. + Physical attachment files on disk are deleted explicitly before the commit. + """ + ticket = db.session.get(Ticket, ticket_id) or abort(404) + + ticket_number = ticket.ticket_number + ticket_title = ticket.title + + # Delete physical attachment files from disk before removing DB rows + upload_dir = current_app.config.get('UPLOAD_FOLDER', '') + for attachment in ticket.attachments.all(): + if attachment.stored_name and upload_dir: + filepath = os.path.join(upload_dir, attachment.stored_name) + if os.path.isfile(filepath): + try: + os.remove(filepath) + except OSError as exc: + logger.warning(f'[TICKET DELETE] Could not remove file ' + f'{filepath}: {exc}') + + log_action(current_user.id, 'admin_ticket_delete', 'ticket', ticket.id, + f'ticket_number={ticket_number} title={ticket_title!r}') + db.session.delete(ticket) + db.session.commit() + + logger.info(f'[TICKET DELETE] ticket_number={ticket_number} ' + f'ticket_id={ticket_id} by admin_id={current_user.id}') + flash(f'Ticket {ticket_number} has been permanently deleted.', 'success') + return redirect(url_for('admin.all_tickets')) + + # ─── Knowledge Base Management ──────────────────────────────────────────────── @admin_bp.route('/kb') @@ -793,7 +831,7 @@ def export_tickets(): from sqlalchemy import func submitter_alias = db.aliased(User) assignee_alias = db.aliased(User) - stripped_body = func.regexp_replace(Comment.body, r'<[^>]+>', '', 'g') + stripped_body = func.regexp_replace(Comment.body, r'<[^>]+>', '') q = ( q .outerjoin(submitter_alias, submitter_alias.id == Ticket.created_by_id) @@ -955,16 +993,53 @@ def bulk_ticket_action(): flash('No tickets selected.', 'warning') return redirect(url_for('admin.all_tickets')) - valid_actions = ('resolve', 'close', 'assign_me', 'unassign') + valid_actions = ('resolve', 'close', 'assign_me', 'unassign', 'delete') if action not in valid_actions: flash('Invalid action.', 'danger') return redirect(url_for('admin.all_tickets')) + # Bulk delete is admin-only + if action == 'delete' and not current_user.is_admin: + flash('Only administrators may delete tickets.', 'danger') + return redirect(url_for('admin.all_tickets')) + tickets = Ticket.query.filter(Ticket.id.in_(ticket_ids)).all() now = datetime.utcnow() count = 0 notif_tickets = [] # collect for post-commit notifications + # ── Bulk delete: handle separately so we can early-return cleanly ───────── + if action == 'delete': + upload_dir = current_app.config.get('UPLOAD_FOLDER', '') + for ticket in tickets: + for attachment in ticket.attachments.all(): + if attachment.stored_name and upload_dir: + filepath = os.path.join(upload_dir, attachment.stored_name) + if os.path.isfile(filepath): + try: + os.remove(filepath) + except OSError as exc: + logger.warning(f'[BULK DELETE] Could not remove file ' + f'{filepath}: {exc}') + log_action(current_user.id, 'admin_ticket_delete', 'ticket', + ticket.id, + f'bulk=true ticket_number={ticket.ticket_number} title={ticket.title!r}') + db.session.delete(ticket) + count += 1 + + db.session.commit() + logger.info(f'[BULK DELETE] deleted={count} ticket_ids={ticket_ids} ' + f'by admin_id={current_user.id}') + flash(f'{count} ticket{"s" if count != 1 else ""} permanently deleted.', 'success') + return redirect(url_for('admin.all_tickets', + status = request.form.get('status', ''), + priority = request.form.get('priority', ''), + assigned = request.form.get('assigned', ''), + q = request.form.get('q', ''), + page = request.form.get('page', 1), + )) + # ───────────────────────────────────────────────────────────────────────── + for ticket in tickets: old_status = ticket.status old_assigned = ticket.assigned_to_id diff --git a/app/routes/tickets.py b/app/routes/tickets.py index 3043999..d20e1c3 100644 --- a/app/routes/tickets.py +++ b/app/routes/tickets.py @@ -330,7 +330,7 @@ def ticket_list(): from app.models import Comment from sqlalchemy import func assignee_alias = db.aliased(User) - stripped_body = func.regexp_replace(Comment.body, r'<[^>]+>', '', 'g') + stripped_body = func.regexp_replace(Comment.body, r'<[^>]+>', '') query = ( query .outerjoin(Comment, Comment.ticket_id == Ticket.id) diff --git a/app/templates/admin/tickets.html b/app/templates/admin/tickets.html index 092c3f7..3b7f33b 100644 --- a/app/templates/admin/tickets.html +++ b/app/templates/admin/tickets.html @@ -95,7 +95,22 @@ {{ t.creator.full_name }} {{ t.assignee.full_name if t.assignee else '—' }} {{ t.created_at | localtime("%b %d") }} - + +
+ + {% if current_user.is_admin %} + + {% endif %} +
+ {% endfor %} @@ -130,6 +145,37 @@ + +{% if current_user.is_admin %} + + + + +{% endif %} +
Unassign + {% if current_user.is_admin %} + + {% endif %}