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:
parent
1a506c0713
commit
fc99f17db3
@ -69,7 +69,14 @@ def feedback_page():
|
|||||||
if sort not in ("votes", "newest", "updated"):
|
if sort not in ("votes", "newest", "updated"):
|
||||||
sort = "votes"
|
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)}
|
params = {"user_id": int(current_user.id)}
|
||||||
|
|
||||||
if item_type:
|
if item_type:
|
||||||
@ -106,6 +113,8 @@ def feedback_page():
|
|||||||
fi.status,
|
fi.status,
|
||||||
fi.created_at,
|
fi.created_at,
|
||||||
fi.updated_at,
|
fi.updated_at,
|
||||||
|
fi.deleted_at,
|
||||||
|
fi.deleted_by_user_id,
|
||||||
u.username AS created_by,
|
u.username AS created_by,
|
||||||
COALESCE(v.vote_count, 0) AS vote_count,
|
COALESCE(v.vote_count, 0) AS vote_count,
|
||||||
EXISTS (
|
EXISTS (
|
||||||
@ -143,6 +152,8 @@ def feedback_page():
|
|||||||
"created_by": r["created_by"] or "-",
|
"created_by": r["created_by"] or "-",
|
||||||
"vote_count": int(r["vote_count"] or 0),
|
"vote_count": int(r["vote_count"] or 0),
|
||||||
"user_voted": bool(r["user_voted"]),
|
"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,
|
status=status,
|
||||||
q=q,
|
q=q,
|
||||||
sort=sort,
|
sort=sort,
|
||||||
|
show_deleted=show_deleted,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -221,7 +233,8 @@ def feedback_new():
|
|||||||
@roles_required("admin", "operator", "reporter", "viewer")
|
@roles_required("admin", "operator", "reporter", "viewer")
|
||||||
def feedback_detail(item_id: int):
|
def feedback_detail(item_id: int):
|
||||||
item = FeedbackItem.query.get_or_404(item_id)
|
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)
|
abort(404)
|
||||||
|
|
||||||
vote_count = (
|
vote_count = (
|
||||||
@ -438,6 +451,37 @@ def feedback_delete(item_id: int):
|
|||||||
return redirect(url_for("main.feedback_page"))
|
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>")
|
@main_bp.route("/feedback/attachment/<int:attachment_id>")
|
||||||
@login_required
|
@login_required
|
||||||
@roles_required("admin", "operator", "reporter", "viewer")
|
@roles_required("admin", "operator", "reporter", "viewer")
|
||||||
|
|||||||
@ -34,6 +34,16 @@
|
|||||||
<div class="col-6 col-md-3">
|
<div class="col-6 col-md-3">
|
||||||
<button class="btn btn-outline-secondary" type="submit">Apply</button>
|
<button class="btn btn-outline-secondary" type="submit">Apply</button>
|
||||||
</div>
|
</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>
|
</form>
|
||||||
|
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
@ -46,6 +56,9 @@
|
|||||||
<th style="width: 160px;">Component</th>
|
<th style="width: 160px;">Component</th>
|
||||||
<th style="width: 120px;">Status</th>
|
<th style="width: 120px;">Status</th>
|
||||||
<th style="width: 170px;">Created</th>
|
<th style="width: 170px;">Created</th>
|
||||||
|
{% if active_role == 'admin' and show_deleted %}
|
||||||
|
<th style="width: 140px;">Actions</th>
|
||||||
|
{% endif %}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@ -56,20 +69,30 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% for i in items %}
|
{% for i in items %}
|
||||||
<tr>
|
<tr {% if i.is_deleted %}class="table-secondary"{% endif %}>
|
||||||
<td>
|
<td>
|
||||||
|
{% if not i.is_deleted %}
|
||||||
<form method="post" action="{{ url_for('main.feedback_vote', item_id=i.id) }}">
|
<form method="post" action="{{ url_for('main.feedback_vote', item_id=i.id) }}">
|
||||||
<input type="hidden" name="ref" value="list" />
|
<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 %}">
|
<button type="submit" class="btn btn-sm {% if i.user_voted %}btn-success{% else %}btn-outline-secondary{% endif %}">
|
||||||
+ {{ i.vote_count }}
|
+ {{ i.vote_count }}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">+ {{ i.vote_count }}</span>
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<a href="{{ url_for('main.feedback_detail', item_id=i.id) }}">{{ i.title }}</a>
|
<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 %}
|
{% if i.created_by %}
|
||||||
<div class="text-muted" style="font-size: 0.85rem;">by {{ i.created_by }}</div>
|
<div class="text-muted" style="font-size: 0.85rem;">by {{ i.created_by }}</div>
|
||||||
{% endif %}
|
{% 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>
|
||||||
<td>
|
<td>
|
||||||
{% if i.item_type == 'bug' %}
|
{% if i.item_type == 'bug' %}
|
||||||
@ -90,6 +113,15 @@
|
|||||||
<div>{{ i.created_at|local_datetime }}</div>
|
<div>{{ i.created_at|local_datetime }}</div>
|
||||||
<div class="text-muted" style="font-size: 0.85rem;">Updated {{ i.updated_at|local_datetime }}</div>
|
<div class="text-muted" style="font-size: 0.85rem;">Updated {{ i.updated_at|local_datetime }}</div>
|
||||||
</td>
|
</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>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@ -15,6 +15,9 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<span class="badge text-bg-warning">Open</span>
|
<span class="badge text-bg-warning">Open</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if item.deleted_at %}
|
||||||
|
<span class="badge text-bg-dark">Deleted</span>
|
||||||
|
{% endif %}
|
||||||
<span class="ms-2">by {{ created_by_name }}</span>
|
<span class="ms-2">by {{ created_by_name }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -133,21 +136,32 @@
|
|||||||
<h2 class="h6">Actions</h2>
|
<h2 class="h6">Actions</h2>
|
||||||
|
|
||||||
{% if active_role == 'admin' %}
|
{% if active_role == 'admin' %}
|
||||||
{% if item.status == 'resolved' %}
|
{% if item.deleted_at %}
|
||||||
<form method="post" action="{{ url_for('main.feedback_resolve', item_id=item.id) }}" class="mb-2">
|
{# Item is deleted - show permanent delete option #}
|
||||||
<input type="hidden" name="action" value="reopen" />
|
<div class="alert alert-warning mb-2" style="font-size: 0.9rem;">
|
||||||
<button type="submit" class="btn btn-outline-secondary w-100">Reopen</button>
|
This item is deleted.
|
||||||
</form>
|
</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 %}
|
{% else %}
|
||||||
<form method="post" action="{{ url_for('main.feedback_resolve', item_id=item.id) }}" class="mb-2">
|
{# Item is not deleted - show normal actions #}
|
||||||
<input type="hidden" name="action" value="resolve" />
|
{% if item.status == 'resolved' %}
|
||||||
<button type="submit" class="btn btn-success w-100">Mark as resolved</button>
|
<form method="post" action="{{ url_for('main.feedback_resolve', item_id=item.id) }}" class="mb-2">
|
||||||
</form>
|
<input type="hidden" name="action" value="reopen" />
|
||||||
{% endif %}
|
<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?');">
|
<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>
|
<button type="submit" class="btn btn-danger w-100">Delete</button>
|
||||||
</form>
|
</form>
|
||||||
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="text-muted">Only administrators can resolve or delete items.</div>
|
<div class="text-muted">Only administrators can resolve or delete items.</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@ -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
|
- File validation: image type verification, size limits, secure filename handling
|
||||||
- New route: `/feedback/attachment/<id>` to serve images (access-controlled)
|
- New route: `/feedback/attachment/<id>` to serve images (access-controlled)
|
||||||
- Database migration: auto-creates `feedback_attachments` table with indexes on startup
|
- 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
|
||||||
- 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)
|
- 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)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user