Compare commits

..

6 Commits

Author SHA1 Message Date
c777728c91 Update changelog with comprehensive screenshot feature documentation
Added detailed documentation for screenshot attachment support in Feedback
system, including:
- File validation using imghdr (header inspection, not just extensions)
- Admin access control for deleted item attachments
- Automatic CASCADE delete behavior
- Enhanced admin deleted items view with permanent delete
- UI improvements for deleted item display (opacity + background)
- Security considerations for non-admin users

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-10 13:51:54 +01:00
0510613708 Fix: Allow admins to view screenshots of deleted feedback items
Two fixes:
1. Improved deleted item row styling (opacity + background)
2. Allow feedback_attachment route to serve images from deleted items (admin only)

Before: Screenshots shown as links only (2026-02-10_13_29_39.png)
After: Screenshots shown as images/thumbnails

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-10 13:46:24 +01:00
fc99f17db3 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>
2026-02-10 13:40:53 +01:00
1a506c0713 Fix: Add FeedbackAttachment to routes_shared imports
Missing import caused NameError when creating feedback with screenshots.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-10 13:30:47 +01:00
85798a07ae Auto-commit local changes before build (2026-02-10 13:29:10) 2026-02-10 13:29:10 +01:00
451ce1ab22 Add screenshot attachment support to Feedback/Bug system
User request: Allow screenshots to be attached to bug reports
and feature requests for better documentation and reproduction.

Database:
- New model: FeedbackAttachment (file_data BYTEA, filename, mime_type, file_size)
- Links to feedback_item_id (required) and feedback_reply_id (optional)
- Migration: auto-creates table with indexes on startup
- Cascading deletes when item or reply is deleted

Backend (routes_feedback.py):
- Helper function: _validate_image_file() for security
  - Validates file type using imghdr (not just extension)
  - Enforces size limit (5MB per file)
  - Secure filename handling with werkzeug
  - Allowed: PNG, JPG, GIF, WEBP
- Updated feedback_new: accepts multiple file uploads
- Updated feedback_reply: accepts multiple file uploads
- Updated feedback_detail: fetches attachments for item + replies
- New route: /feedback/attachment/<id> to serve images

Frontend:
- feedback_new.html: file input with multiple selection
- feedback_detail.html:
  - Shows item screenshots as clickable thumbnails (max 300x200)
  - Shows reply screenshots as clickable thumbnails (max 200x150)
  - File upload in reply form
  - All images open full-size in new tab

Security:
- Access control: only authenticated users with feedback roles
- Image type verification using imghdr (header inspection)
- File size limit enforced (5MB)
- Secure filename sanitization
- Deleted items hide their attachments (404)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-10 13:28:41 +01:00
9 changed files with 390 additions and 20 deletions

View File

@ -1 +1 @@
main
v20260210-02-add-screenshots-to-feedback

View File

@ -1,5 +1,53 @@
from .routes_shared import * # noqa: F401,F403
from .routes_shared import _format_datetime
from werkzeug.utils import secure_filename
import imghdr
# Allowed image extensions and max file size
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp'}
MAX_FILE_SIZE = 5 * 1024 * 1024 # 5 MB
def _validate_image_file(file):
"""Validate uploaded image file.
Returns (is_valid, error_message, mime_type)
"""
if not file or not file.filename:
return False, "No file selected", None
# Check file size
file.seek(0, 2) # Seek to end
size = file.tell()
file.seek(0) # Reset to beginning
if size > MAX_FILE_SIZE:
return False, f"File too large (max {MAX_FILE_SIZE // (1024*1024)}MB)", None
if size == 0:
return False, "Empty file", None
# Check extension
filename = secure_filename(file.filename)
if '.' not in filename:
return False, "File must have an extension", None
ext = filename.rsplit('.', 1)[1].lower()
if ext not in ALLOWED_EXTENSIONS:
return False, f"Only images allowed ({', '.join(ALLOWED_EXTENSIONS)})", None
# Verify it's actually an image by reading header
file_data = file.read()
file.seek(0)
image_type = imghdr.what(None, h=file_data)
if image_type is None:
return False, "Invalid image file", None
mime_type = f"image/{image_type}"
return True, None, mime_type
@main_bp.route("/feedback")
@ -21,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:
@ -58,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 (
@ -95,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 "",
}
)
@ -105,6 +164,7 @@ def feedback_page():
status=status,
q=q,
sort=sort,
show_deleted=show_deleted,
)
@ -135,6 +195,31 @@ def feedback_new():
created_by_user_id=int(current_user.id),
)
db.session.add(item)
db.session.flush() # Get item.id for attachments
# Handle file uploads (multiple files allowed)
files = request.files.getlist('screenshots')
for file in files:
if file and file.filename:
is_valid, error_msg, mime_type = _validate_image_file(file)
if not is_valid:
db.session.rollback()
flash(f"Screenshot error: {error_msg}", "danger")
return redirect(url_for("main.feedback_new"))
filename = secure_filename(file.filename)
file_data = file.read()
attachment = FeedbackAttachment(
feedback_item_id=item.id,
feedback_reply_id=None,
filename=filename,
file_data=file_data,
mime_type=mime_type,
file_size=len(file_data),
)
db.session.add(attachment)
db.session.commit()
flash("Feedback item created.", "success")
@ -148,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 = (
@ -174,13 +260,41 @@ def feedback_detail(item_id: int):
resolved_by = User.query.get(item.resolved_by_user_id)
resolved_by_name = resolved_by.username if resolved_by else ""
# Get attachments for the main item (not linked to a reply)
item_attachments = (
FeedbackAttachment.query.filter(
FeedbackAttachment.feedback_item_id == item.id,
FeedbackAttachment.feedback_reply_id.is_(None),
)
.order_by(FeedbackAttachment.created_at.asc())
.all()
)
replies = (
FeedbackReply.query.filter(FeedbackReply.feedback_item_id == item.id)
.order_by(FeedbackReply.created_at.asc())
.all()
)
# Get attachments for each reply
reply_ids = [r.id for r in replies]
reply_attachments_list = []
if reply_ids:
reply_attachments_list = (
FeedbackAttachment.query.filter(
FeedbackAttachment.feedback_reply_id.in_(reply_ids)
)
.order_by(FeedbackAttachment.created_at.asc())
.all()
)
# Map reply_id -> list of attachments
reply_attachments_map = {}
for att in reply_attachments_list:
if att.feedback_reply_id not in reply_attachments_map:
reply_attachments_map[att.feedback_reply_id] = []
reply_attachments_map[att.feedback_reply_id].append(att)
reply_user_ids = sorted({int(r.user_id) for r in replies})
reply_users = (
User.query.filter(User.id.in_(reply_user_ids)).all() if reply_user_ids else []
@ -196,6 +310,8 @@ def feedback_detail(item_id: int):
user_voted=bool(user_voted),
replies=replies,
reply_user_map=reply_user_map,
item_attachments=item_attachments,
reply_attachments_map=reply_attachments_map,
)
@main_bp.route("/feedback/<int:item_id>/reply", methods=["POST"])
@ -222,6 +338,31 @@ def feedback_reply(item_id: int):
created_at=datetime.utcnow(),
)
db.session.add(reply)
db.session.flush() # Get reply.id for attachments
# Handle file uploads (multiple files allowed)
files = request.files.getlist('screenshots')
for file in files:
if file and file.filename:
is_valid, error_msg, mime_type = _validate_image_file(file)
if not is_valid:
db.session.rollback()
flash(f"Screenshot error: {error_msg}", "danger")
return redirect(url_for("main.feedback_detail", item_id=item.id))
filename = secure_filename(file.filename)
file_data = file.read()
attachment = FeedbackAttachment(
feedback_item_id=item.id,
feedback_reply_id=reply.id,
filename=filename,
file_data=file_data,
mime_type=mime_type,
file_size=len(file_data),
)
db.session.add(attachment)
db.session.commit()
flash("Reply added.", "success")
@ -308,3 +449,60 @@ def feedback_delete(item_id: int):
flash("Feedback item deleted.", "success")
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")
def feedback_attachment(attachment_id: int):
"""Serve a feedback attachment image."""
attachment = FeedbackAttachment.query.get_or_404(attachment_id)
# Check if the feedback item is deleted - allow admins to view
item = FeedbackItem.query.get(attachment.feedback_item_id)
if not item:
abort(404)
if item.deleted_at is not None and get_active_role() != "admin":
abort(404)
# Serve the image
from flask import send_file
import io
return send_file(
io.BytesIO(attachment.file_data),
mimetype=attachment.mime_type,
as_attachment=False,
download_name=attachment.filename,
)

View File

@ -52,6 +52,7 @@ from ..models import (
FeedbackItem,
FeedbackVote,
FeedbackReply,
FeedbackAttachment,
NewsItem,
NewsRead,
ReportDefinition,

View File

@ -1095,6 +1095,7 @@ def run_migrations() -> None:
migrate_object_persistence_tables()
migrate_feedback_tables()
migrate_feedback_replies_table()
migrate_feedback_attachments_table()
migrate_tickets_active_from_date()
migrate_tickets_resolved_origin()
migrate_remarks_active_from_date()
@ -1446,6 +1447,49 @@ def migrate_feedback_replies_table() -> None:
print("[migrations] Feedback replies table ensured.")
def migrate_feedback_attachments_table() -> None:
"""Ensure feedback attachments table exists.
Table:
- feedback_attachments (screenshots/images for feedback items and replies)
"""
engine = db.get_engine()
with engine.begin() as conn:
conn.execute(
text(
"""
CREATE TABLE IF NOT EXISTS feedback_attachments (
id SERIAL PRIMARY KEY,
feedback_item_id INTEGER NOT NULL REFERENCES feedback_items(id) ON DELETE CASCADE,
feedback_reply_id INTEGER REFERENCES feedback_replies(id) ON DELETE CASCADE,
filename VARCHAR(255) NOT NULL,
file_data BYTEA NOT NULL,
mime_type VARCHAR(64) NOT NULL,
file_size INTEGER NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
"""
)
)
conn.execute(
text(
"""
CREATE INDEX IF NOT EXISTS idx_feedback_attachments_item
ON feedback_attachments (feedback_item_id);
"""
)
)
conn.execute(
text(
"""
CREATE INDEX IF NOT EXISTS idx_feedback_attachments_reply
ON feedback_attachments (feedback_reply_id);
"""
)
)
print("[migrations] Feedback attachments table ensured.")
def migrate_tickets_active_from_date() -> None:
"""Ensure tickets.active_from_date exists and is populated.

View File

@ -567,6 +567,23 @@ class FeedbackReply(db.Model):
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
class FeedbackAttachment(db.Model):
__tablename__ = "feedback_attachments"
id = db.Column(db.Integer, primary_key=True)
feedback_item_id = db.Column(
db.Integer, db.ForeignKey("feedback_items.id", ondelete="CASCADE"), nullable=False
)
feedback_reply_id = db.Column(
db.Integer, db.ForeignKey("feedback_replies.id", ondelete="CASCADE"), nullable=True
)
filename = db.Column(db.String(255), nullable=False)
file_data = db.Column(db.LargeBinary, nullable=False)
mime_type = db.Column(db.String(64), nullable=False)
file_size = db.Column(db.Integer, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
class NewsItem(db.Model):
__tablename__ = "news_items"

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 %}style="opacity: 0.6; background-color: var(--bs-secondary-bg);"{% 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>
@ -29,6 +32,23 @@
<div class="mb-2"><strong>Component:</strong> {{ item.component }}</div>
{% endif %}
<div style="white-space: pre-wrap;">{{ item.description }}</div>
{% if item_attachments %}
<div class="mt-3">
<strong>Screenshots:</strong>
<div class="d-flex flex-wrap gap-2 mt-2">
{% for att in item_attachments %}
<a href="{{ url_for('main.feedback_attachment', attachment_id=att.id) }}" target="_blank">
<img src="{{ url_for('main.feedback_attachment', attachment_id=att.id) }}"
alt="{{ att.filename }}"
class="img-thumbnail"
style="max-height: 200px; max-width: 300px; cursor: pointer;"
title="Click to view full size" />
</a>
{% endfor %}
</div>
</div>
{% endif %}
</div>
<div class="card-footer d-flex justify-content-between align-items-center">
<div class="text-muted" style="font-size: 0.9rem;">
@ -63,6 +83,22 @@
</span>
</div>
<div style="white-space: pre-wrap;">{{ r.message }}</div>
{% if r.id in reply_attachments_map %}
<div class="mt-2">
<div class="d-flex flex-wrap gap-2">
{% for att in reply_attachments_map[r.id] %}
<a href="{{ url_for('main.feedback_attachment', attachment_id=att.id) }}" target="_blank">
<img src="{{ url_for('main.feedback_attachment', attachment_id=att.id) }}"
alt="{{ att.filename }}"
class="img-thumbnail"
style="max-height: 150px; max-width: 200px; cursor: pointer;"
title="Click to view full size" />
</a>
{% endfor %}
</div>
</div>
{% endif %}
</div>
{% endfor %}
</div>
@ -76,10 +112,15 @@
<div class="card-body">
<h5 class="card-title mb-3">Add reply</h5>
{% if item.status == 'open' %}
<form method="post" action="{{ url_for('main.feedback_reply', item_id=item.id) }}">
<form method="post" action="{{ url_for('main.feedback_reply', item_id=item.id) }}" enctype="multipart/form-data">
<div class="mb-2">
<textarea class="form-control" name="message" rows="4" required></textarea>
</div>
<div class="mb-2">
<label class="form-label">Screenshots (optional)</label>
<input type="file" name="screenshots" class="form-control" multiple accept="image/png,image/jpeg,image/jpg,image/gif,image/webp" />
<div class="form-text">You can attach multiple screenshots (PNG, JPG, GIF, WEBP, max 5MB each)</div>
</div>
<button type="submit" class="btn btn-primary">Post reply</button>
</form>
{% else %}
@ -95,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

@ -6,7 +6,7 @@
<a class="btn btn-outline-secondary" href="{{ url_for('main.feedback_page') }}">Back</a>
</div>
<form method="post" class="card">
<form method="post" enctype="multipart/form-data" class="card">
<div class="card-body">
<div class="row g-3">
<div class="col-12 col-md-3">
@ -28,6 +28,11 @@
<label class="form-label">Component (optional)</label>
<input type="text" name="component" class="form-control" />
</div>
<div class="col-12">
<label class="form-label">Screenshots (optional)</label>
<input type="file" name="screenshots" class="form-control" multiple accept="image/png,image/jpeg,image/jpg,image/gif,image/webp" />
<div class="form-text">You can attach multiple screenshots (PNG, JPG, GIF, WEBP, max 5MB each)</div>
</div>
</div>
</div>
<div class="card-footer d-flex justify-content-end">

View File

@ -4,6 +4,27 @@ This file documents all changes made to this project via Claude Code.
## [2026-02-10]
### Added
- Added screenshot attachment support to Feedback/Bug system (user request: allow screenshots for bugs/features)
- New database model: `FeedbackAttachment` with file_data (BYTEA), filename, mime_type, file_size
- Upload support on feedback creation form (multiple files, PNG/JPG/GIF/WEBP, max 5MB each)
- Upload support on reply forms (attach screenshots when replying)
- Inline image display on feedback detail page (thumbnails with click-to-view-full-size)
- Screenshot display for both main feedback items and replies
- File validation: image type verification using imghdr (not just extension), size limits, secure filename handling
- New route: `/feedback/attachment/<id>` to serve images (access-controlled, admins can view deleted item attachments)
- Database migration: auto-creates `feedback_attachments` table with indexes on startup
- Automatic CASCADE delete: removing feedback item or reply automatically removes associated attachments
- 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 reduced opacity + background color and "Deleted" badge
- Permanent delete action removes item + all attachments from database (hard delete with CASCADE)
- Attachment count shown in deletion confirmation message
- Admins can view detail pages of deleted items including their screenshots
- Two-stage delete: soft delete (audit trail) → permanent delete (database cleanup)
- Prevents accidental permanent deletion (requires item to be soft-deleted first)
- Security: non-admin users cannot view deleted items or their attachments (404 response)
### 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 internal and Autotask tickets being linked to new runs even after being resolved by removing date-based "open" logic from ticket query (tickets now only link to new runs if they are genuinely unresolved, not based on run date comparisons)