Compare commits
6 Commits
main
...
v20260210-
| Author | SHA1 | Date | |
|---|---|---|---|
| c777728c91 | |||
| 0510613708 | |||
| fc99f17db3 | |||
| 1a506c0713 | |||
| 85798a07ae | |||
| 451ce1ab22 |
@ -1 +1 @@
|
||||
main
|
||||
v20260210-02-add-screenshots-to-feedback
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -52,6 +52,7 @@ from ..models import (
|
||||
FeedbackItem,
|
||||
FeedbackVote,
|
||||
FeedbackReply,
|
||||
FeedbackAttachment,
|
||||
NewsItem,
|
||||
NewsRead,
|
||||
ReportDefinition,
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 %}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user