Add admin view for deleted feedback items + permanent delete

User request: Allow admins to view deleted items and permanently
delete them (hard delete) to clean up database and remove screenshots.

Features:
1. Admin-only "Show deleted" checkbox on feedback list
2. Deleted items shown with gray background + "Deleted" badge
3. Permanent delete button (only for soft-deleted items)
4. Hard delete removes item + all attachments from database
5. Admins can view detail pages of deleted items

Backend (routes_feedback.py):
- Added show_deleted parameter (admin only)
- Modified feedback_page query to optionally include deleted items
- Added deleted_at, deleted_by to query results
- Modified feedback_detail to allow admins to view deleted items
- New route: feedback_permanent_delete (hard delete)
  - Only works on already soft-deleted items (safety check)
  - Uses db.session.delete() - CASCADE removes attachments
  - Shows attachment count in confirmation message

Frontend:
- feedback.html:
  - "Show deleted items" checkbox (auto-submits form)
  - Deleted items: gray background (table-secondary)
  - Shows deleted timestamp
  - "Permanent Delete" button in Actions column
  - Confirmation dialog warns about permanent deletion
- feedback_detail.html:
  - "Deleted" badge in header
  - Actions sidebar shows warning + "Permanent Delete" button
  - Normal actions (resolve/delete) hidden for deleted items

Benefits:
- Audit trail preserved with soft delete
- Database can be cleaned up later by removing old deleted items
- Screenshots (BYTEA) don't accumulate forever
- Two-stage safety: soft delete → permanent delete

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Ivo Oskamp 2026-02-10 13:40:53 +01:00
parent 1a506c0713
commit fc99f17db3
4 changed files with 114 additions and 16 deletions

View File

@ -69,7 +69,14 @@ def feedback_page():
if sort not in ("votes", "newest", "updated"):
sort = "votes"
where = ["fi.deleted_at IS NULL"]
# Admin-only: show deleted items
show_deleted = False
if get_active_role() == "admin":
show_deleted = request.args.get("show_deleted", "0") in ("1", "true", "yes", "on")
where = []
if not show_deleted:
where.append("fi.deleted_at IS NULL")
params = {"user_id": int(current_user.id)}
if item_type:
@ -106,6 +113,8 @@ def feedback_page():
fi.status,
fi.created_at,
fi.updated_at,
fi.deleted_at,
fi.deleted_by_user_id,
u.username AS created_by,
COALESCE(v.vote_count, 0) AS vote_count,
EXISTS (
@ -143,6 +152,8 @@ def feedback_page():
"created_by": r["created_by"] or "-",
"vote_count": int(r["vote_count"] or 0),
"user_voted": bool(r["user_voted"]),
"is_deleted": bool(r["deleted_at"]),
"deleted_at": _format_datetime(r["deleted_at"]) if r["deleted_at"] else "",
}
)
@ -153,6 +164,7 @@ def feedback_page():
status=status,
q=q,
sort=sort,
show_deleted=show_deleted,
)
@ -221,7 +233,8 @@ def feedback_new():
@roles_required("admin", "operator", "reporter", "viewer")
def feedback_detail(item_id: int):
item = FeedbackItem.query.get_or_404(item_id)
if item.deleted_at is not None:
# Allow admins to view deleted items
if item.deleted_at is not None and get_active_role() != "admin":
abort(404)
vote_count = (
@ -438,6 +451,37 @@ def feedback_delete(item_id: int):
return redirect(url_for("main.feedback_page"))
@main_bp.route("/feedback/<int:item_id>/permanent-delete", methods=["POST"])
@login_required
@roles_required("admin")
def feedback_permanent_delete(item_id: int):
"""Permanently delete a feedback item and all its attachments from the database.
This is a hard delete - the item and all associated data will be removed permanently.
Only available for items that are already soft-deleted.
"""
item = FeedbackItem.query.get_or_404(item_id)
# Only allow permanent delete on already soft-deleted items
if item.deleted_at is None:
flash("Item must be deleted first before permanent deletion.", "warning")
return redirect(url_for("main.feedback_detail", item_id=item.id))
# Get attachment count for feedback message
attachment_count = FeedbackAttachment.query.filter_by(feedback_item_id=item.id).count()
# Hard delete - CASCADE will automatically delete:
# - feedback_votes
# - feedback_replies
# - feedback_attachments (via replies CASCADE)
# - feedback_attachments (direct, via item CASCADE)
db.session.delete(item)
db.session.commit()
flash(f"Feedback item permanently deleted ({attachment_count} screenshot(s) removed).", "success")
return redirect(url_for("main.feedback_page", show_deleted="1"))
@main_bp.route("/feedback/attachment/<int:attachment_id>")
@login_required
@roles_required("admin", "operator", "reporter", "viewer")

View File

@ -34,6 +34,16 @@
<div class="col-6 col-md-3">
<button class="btn btn-outline-secondary" type="submit">Apply</button>
</div>
{% if active_role == 'admin' %}
<div class="col-12">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="show_deleted" value="1" id="show_deleted" {% if show_deleted %}checked{% endif %} onchange="this.form.submit()">
<label class="form-check-label" for="show_deleted">
Show deleted items
</label>
</div>
</div>
{% endif %}
</form>
<div class="table-responsive">
@ -46,6 +56,9 @@
<th style="width: 160px;">Component</th>
<th style="width: 120px;">Status</th>
<th style="width: 170px;">Created</th>
{% if active_role == 'admin' and show_deleted %}
<th style="width: 140px;">Actions</th>
{% endif %}
</tr>
</thead>
<tbody>
@ -56,20 +69,30 @@
{% endif %}
{% for i in items %}
<tr>
<tr {% if i.is_deleted %}class="table-secondary"{% endif %}>
<td>
{% if not i.is_deleted %}
<form method="post" action="{{ url_for('main.feedback_vote', item_id=i.id) }}">
<input type="hidden" name="ref" value="list" />
<button type="submit" class="btn btn-sm {% if i.user_voted %}btn-success{% else %}btn-outline-secondary{% endif %}">
+ {{ i.vote_count }}
</button>
</form>
{% else %}
<span class="text-muted">+ {{ i.vote_count }}</span>
{% endif %}
</td>
<td>
<a href="{{ url_for('main.feedback_detail', item_id=i.id) }}">{{ i.title }}</a>
{% if i.is_deleted %}
<span class="badge text-bg-dark ms-2">Deleted</span>
{% endif %}
{% if i.created_by %}
<div class="text-muted" style="font-size: 0.85rem;">by {{ i.created_by }}</div>
{% endif %}
{% if i.is_deleted and i.deleted_at %}
<div class="text-muted" style="font-size: 0.85rem;">Deleted {{ i.deleted_at|local_datetime }}</div>
{% endif %}
</td>
<td>
{% if i.item_type == 'bug' %}
@ -90,6 +113,15 @@
<div>{{ i.created_at|local_datetime }}</div>
<div class="text-muted" style="font-size: 0.85rem;">Updated {{ i.updated_at|local_datetime }}</div>
</td>
{% if active_role == 'admin' and show_deleted %}
<td>
{% if i.is_deleted %}
<form method="post" action="{{ url_for('main.feedback_permanent_delete', item_id=i.id) }}" onsubmit="return confirm('Permanently delete this item and all screenshots? This cannot be undone!');">
<button type="submit" class="btn btn-sm btn-danger">Permanent Delete</button>
</form>
{% endif %}
</td>
{% endif %}
</tr>
{% endfor %}
</tbody>

View File

@ -15,6 +15,9 @@
{% else %}
<span class="badge text-bg-warning">Open</span>
{% endif %}
{% if item.deleted_at %}
<span class="badge text-bg-dark">Deleted</span>
{% endif %}
<span class="ms-2">by {{ created_by_name }}</span>
</div>
</div>
@ -133,21 +136,32 @@
<h2 class="h6">Actions</h2>
{% if active_role == 'admin' %}
{% if item.status == 'resolved' %}
<form method="post" action="{{ url_for('main.feedback_resolve', item_id=item.id) }}" class="mb-2">
<input type="hidden" name="action" value="reopen" />
<button type="submit" class="btn btn-outline-secondary w-100">Reopen</button>
</form>
{% if item.deleted_at %}
{# Item is deleted - show permanent delete option #}
<div class="alert alert-warning mb-2" style="font-size: 0.9rem;">
This item is deleted.
</div>
<form method="post" action="{{ url_for('main.feedback_permanent_delete', item_id=item.id) }}" onsubmit="return confirm('Permanently delete this item and all screenshots? This cannot be undone!');">
<button type="submit" class="btn btn-danger w-100">Permanent Delete</button>
</form>
{% else %}
<form method="post" action="{{ url_for('main.feedback_resolve', item_id=item.id) }}" class="mb-2">
<input type="hidden" name="action" value="resolve" />
<button type="submit" class="btn btn-success w-100">Mark as resolved</button>
</form>
{% endif %}
{# Item is not deleted - show normal actions #}
{% if item.status == 'resolved' %}
<form method="post" action="{{ url_for('main.feedback_resolve', item_id=item.id) }}" class="mb-2">
<input type="hidden" name="action" value="reopen" />
<button type="submit" class="btn btn-outline-secondary w-100">Reopen</button>
</form>
{% else %}
<form method="post" action="{{ url_for('main.feedback_resolve', item_id=item.id) }}" class="mb-2">
<input type="hidden" name="action" value="resolve" />
<button type="submit" class="btn btn-success w-100">Mark as resolved</button>
</form>
{% endif %}
<form method="post" action="{{ url_for('main.feedback_delete', item_id=item.id) }}" onsubmit="return confirm('Delete this item?');">
<button type="submit" class="btn btn-danger w-100">Delete</button>
</form>
<form method="post" action="{{ url_for('main.feedback_delete', item_id=item.id) }}" onsubmit="return confirm('Delete this item?');">
<button type="submit" class="btn btn-danger w-100">Delete</button>
</form>
{% endif %}
{% else %}
<div class="text-muted">Only administrators can resolve or delete items.</div>
{% endif %}

View File

@ -14,6 +14,14 @@ This file documents all changes made to this project via Claude Code.
- File validation: image type verification, size limits, secure filename handling
- New route: `/feedback/attachment/<id>` to serve images (access-controlled)
- Database migration: auto-creates `feedback_attachments` table with indexes on startup
- Added admin-only deleted items view and permanent delete functionality to Feedback system
- "Show deleted items" checkbox on feedback list page (admin only)
- Deleted items shown with gray background and "Deleted" badge
- Permanent delete action removes item + all attachments from database (hard delete)
- Attachment count shown in deletion confirmation message
- Admins can view detail pages of deleted items
- Two-stage delete: soft delete (audit trail) → permanent delete (database cleanup)
- Prevents accidental permanent deletion (requires item to be soft-deleted first)
### Fixed
- Fixed Autotask ticket not being automatically linked to new runs when internal ticket is resolved by implementing independent Autotask propagation strategy (now checks for most recent non-deleted and non-resolved Autotask ticket on job regardless of internal ticket status, ensuring PSA ticket reference persists across runs until explicitly resolved or deleted)