04/09: fixed searching ticket, and deleting/bulk deleting tickets
This commit is contained in:
+78
-3
@@ -408,7 +408,7 @@ def all_tickets():
|
|||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
submitter_alias = db.aliased(User)
|
submitter_alias = db.aliased(User)
|
||||||
assignee_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 = (
|
||||||
q
|
q
|
||||||
.outerjoin(submitter_alias, submitter_alias.id == Ticket.created_by_id)
|
.outerjoin(submitter_alias, submitter_alias.id == Ticket.created_by_id)
|
||||||
@@ -431,6 +431,44 @@ def all_tickets():
|
|||||||
search=search)
|
search=search)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route('/tickets/<int:ticket_id>/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 ────────────────────────────────────────────────
|
# ─── Knowledge Base Management ────────────────────────────────────────────────
|
||||||
|
|
||||||
@admin_bp.route('/kb')
|
@admin_bp.route('/kb')
|
||||||
@@ -793,7 +831,7 @@ def export_tickets():
|
|||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
submitter_alias = db.aliased(User)
|
submitter_alias = db.aliased(User)
|
||||||
assignee_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 = (
|
||||||
q
|
q
|
||||||
.outerjoin(submitter_alias, submitter_alias.id == Ticket.created_by_id)
|
.outerjoin(submitter_alias, submitter_alias.id == Ticket.created_by_id)
|
||||||
@@ -955,16 +993,53 @@ def bulk_ticket_action():
|
|||||||
flash('No tickets selected.', 'warning')
|
flash('No tickets selected.', 'warning')
|
||||||
return redirect(url_for('admin.all_tickets'))
|
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:
|
if action not in valid_actions:
|
||||||
flash('Invalid action.', 'danger')
|
flash('Invalid action.', 'danger')
|
||||||
return redirect(url_for('admin.all_tickets'))
|
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()
|
tickets = Ticket.query.filter(Ticket.id.in_(ticket_ids)).all()
|
||||||
now = datetime.utcnow()
|
now = datetime.utcnow()
|
||||||
count = 0
|
count = 0
|
||||||
notif_tickets = [] # collect for post-commit notifications
|
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:
|
for ticket in tickets:
|
||||||
old_status = ticket.status
|
old_status = ticket.status
|
||||||
old_assigned = ticket.assigned_to_id
|
old_assigned = ticket.assigned_to_id
|
||||||
|
|||||||
@@ -330,7 +330,7 @@ def ticket_list():
|
|||||||
from app.models import Comment
|
from app.models import Comment
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
assignee_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'<[^>]+>', '')
|
||||||
query = (
|
query = (
|
||||||
query
|
query
|
||||||
.outerjoin(Comment, Comment.ticket_id == Ticket.id)
|
.outerjoin(Comment, Comment.ticket_id == Ticket.id)
|
||||||
|
|||||||
@@ -95,7 +95,22 @@
|
|||||||
<td style="font-size:13px;">{{ t.creator.full_name }}</td>
|
<td style="font-size:13px;">{{ t.creator.full_name }}</td>
|
||||||
<td style="font-size:13px;color:var(--muted);">{{ t.assignee.full_name if t.assignee else '—' }}</td>
|
<td style="font-size:13px;color:var(--muted);">{{ t.assignee.full_name if t.assignee else '—' }}</td>
|
||||||
<td style="font-size:11px;color:var(--muted);">{{ t.created_at | localtime("%b %d") }}</td>
|
<td style="font-size:11px;color:var(--muted);">{{ t.created_at | localtime("%b %d") }}</td>
|
||||||
<td><a href="{{ url_for('tickets.ticket_detail', ticket_id=t.id) }}" class="btn btn-secondary btn-sm"><i class="bi bi-eye"></i></a></td>
|
<td>
|
||||||
|
<div class="d-flex gap-1">
|
||||||
|
<a href="{{ url_for('tickets.ticket_detail', ticket_id=t.id) }}" class="btn btn-secondary btn-sm"><i class="bi bi-eye"></i></a>
|
||||||
|
{% if current_user.is_admin %}
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-sm delete-ticket-btn"
|
||||||
|
style="background:#fee2e2;color:#dc2626;border:1px solid #fca5a5;"
|
||||||
|
data-ticket-id="{{ t.id }}"
|
||||||
|
data-ticket-number="{{ t.ticket_number }}"
|
||||||
|
data-ticket-title="{{ t.title | e }}"
|
||||||
|
title="Delete ticket">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -130,6 +145,37 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Single-ticket delete form (hidden, submitted via JS) ────────────────── -->
|
||||||
|
{% if current_user.is_admin %}
|
||||||
|
<form id="single-delete-form" method="POST" action="" style="display:none;">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- ── Confirmation modal for delete actions ──────────────────────────────── -->
|
||||||
|
<div id="delete-modal-overlay"
|
||||||
|
style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.55);
|
||||||
|
z-index:2000;align-items:center;justify-content:center;">
|
||||||
|
<div style="background:var(--card-bg,#fff);border-radius:12px;padding:28px 32px;
|
||||||
|
max-width:480px;width:90%;box-shadow:0 16px 48px rgba(0,0,0,.25);">
|
||||||
|
<div style="display:flex;align-items:center;gap:12px;margin-bottom:16px;">
|
||||||
|
<span style="background:#fee2e2;border-radius:50%;width:40px;height:40px;
|
||||||
|
display:flex;align-items:center;justify-content:center;flex-shrink:0;">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill" style="color:#dc2626;font-size:18px;"></i>
|
||||||
|
</span>
|
||||||
|
<h5 id="modal-title" style="margin:0;font-size:16px;font-weight:700;">Delete Ticket</h5>
|
||||||
|
</div>
|
||||||
|
<p id="modal-body" style="font-size:14px;color:var(--muted,#64748b);margin-bottom:24px;line-height:1.6;"></p>
|
||||||
|
<div class="d-flex gap-2 justify-content-end">
|
||||||
|
<button type="button" id="modal-cancel-btn" class="btn btn-secondary btn-sm">Cancel</button>
|
||||||
|
<button type="button" id="modal-confirm-btn"
|
||||||
|
class="btn btn-sm" style="background:#dc2626;color:#fff;border:none;">
|
||||||
|
<i class="bi bi-trash me-1"></i>Delete Permanently
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<!-- Bulk action bar: fixed at bottom, hidden until checkboxes are ticked -->
|
<!-- Bulk action bar: fixed at bottom, hidden until checkboxes are ticked -->
|
||||||
<!-- NOTE: display is set entirely via JS — no inline display property here -->
|
<!-- NOTE: display is set entirely via JS — no inline display property here -->
|
||||||
<div id="bulk-action-bar"
|
<div id="bulk-action-bar"
|
||||||
@@ -163,6 +209,12 @@
|
|||||||
class="btn btn-sm" style="color:#fff;border:1px solid #6b7280;background:transparent;">
|
class="btn btn-sm" style="color:#fff;border:1px solid #6b7280;background:transparent;">
|
||||||
<i class="bi bi-person-dash me-1"></i>Unassign
|
<i class="bi bi-person-dash me-1"></i>Unassign
|
||||||
</button>
|
</button>
|
||||||
|
{% if current_user.is_admin %}
|
||||||
|
<button type="button" id="bulk-delete-btn"
|
||||||
|
class="btn btn-sm" style="background:#dc2626;color:#fff;border:none;">
|
||||||
|
<i class="bi bi-trash me-1"></i>Delete
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<button type="button" onclick="clearSelection()"
|
<button type="button" onclick="clearSelection()"
|
||||||
@@ -224,6 +276,87 @@
|
|||||||
updateBar();
|
updateBar();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Delete modal ──────────────────────────────────────────────────────────
|
||||||
|
const overlay = document.getElementById('delete-modal-overlay');
|
||||||
|
const modalTitle = document.getElementById('modal-title');
|
||||||
|
const modalBody = document.getElementById('modal-body');
|
||||||
|
const cancelBtn = document.getElementById('modal-cancel-btn');
|
||||||
|
const confirmBtn = document.getElementById('modal-confirm-btn');
|
||||||
|
|
||||||
|
if (!overlay) return; // non-admin: modal not rendered
|
||||||
|
|
||||||
|
let pendingAction = null;
|
||||||
|
|
||||||
|
function showModal(title, body, onConfirm) {
|
||||||
|
modalTitle.textContent = title;
|
||||||
|
modalBody.innerHTML = body;
|
||||||
|
pendingAction = onConfirm;
|
||||||
|
overlay.style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideModal() {
|
||||||
|
overlay.style.display = 'none';
|
||||||
|
pendingAction = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelBtn.addEventListener('click', hideModal);
|
||||||
|
overlay.addEventListener('click', function (e) {
|
||||||
|
if (e.target === overlay) hideModal();
|
||||||
|
});
|
||||||
|
confirmBtn.addEventListener('click', function () {
|
||||||
|
if (typeof pendingAction === 'function') pendingAction();
|
||||||
|
hideModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Single-ticket delete ──────────────────────────────────────────────────
|
||||||
|
document.querySelectorAll('.delete-ticket-btn').forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
const ticketId = this.dataset.ticketId;
|
||||||
|
const ticketNumber = this.dataset.ticketNumber;
|
||||||
|
const ticketTitle = this.dataset.ticketTitle;
|
||||||
|
|
||||||
|
showModal(
|
||||||
|
'Delete Ticket',
|
||||||
|
'This will <strong>permanently delete</strong> ticket ' +
|
||||||
|
'<code>' + ticketNumber + '</code> — <em>' + ticketTitle + '</em> ' +
|
||||||
|
'and all associated comments, attachments, and history.' +
|
||||||
|
'<br><br>This action <strong>cannot be undone</strong>.',
|
||||||
|
function () {
|
||||||
|
const form = document.getElementById('single-delete-form');
|
||||||
|
form.action = '/admin/tickets/' + ticketId + '/delete';
|
||||||
|
form.submit();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Bulk delete ───────────────────────────────────────────────────────────
|
||||||
|
const bulkDeleteBtn = document.getElementById('bulk-delete-btn');
|
||||||
|
if (bulkDeleteBtn) {
|
||||||
|
bulkDeleteBtn.addEventListener('click', function () {
|
||||||
|
const checked = getChecked();
|
||||||
|
if (checked.length === 0) return;
|
||||||
|
const n = checked.length;
|
||||||
|
|
||||||
|
showModal(
|
||||||
|
'Delete Selected Tickets',
|
||||||
|
'This will <strong>permanently delete ' + n + ' ticket' + (n !== 1 ? 's' : '') + '</strong> ' +
|
||||||
|
'and all associated comments, attachments, and history.' +
|
||||||
|
'<br><br>This action <strong>cannot be undone</strong>.',
|
||||||
|
function () {
|
||||||
|
const form = document.getElementById('bulk-form');
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'hidden';
|
||||||
|
input.name = 'action';
|
||||||
|
input.value = 'delete';
|
||||||
|
form.appendChild(input);
|
||||||
|
form.submit();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user