From cc0d969ebf48586f42ab201fda35bd99299c8ab3 Mon Sep 17 00:00:00 2001 From: Ivo Oskamp Date: Tue, 6 Jan 2026 11:47:15 +0100 Subject: [PATCH] Auto-commit local changes before build (2026-01-06 11:47:15) --- .last-branch | 2 +- .../src/backend/app/main/routes_feedback.py | 47 +++++++++++++++++++ .../src/backend/app/main/routes_shared.py | 1 + .../src/backend/app/migrations.py | 35 ++++++++++++++ .../backupchecks/src/backend/app/models.py | 14 ++++++ .../src/templates/main/feedback_detail.html | 40 ++++++++++++++++ docs/changelog.md | 9 ++++ 7 files changed, 147 insertions(+), 1 deletion(-) diff --git a/.last-branch b/.last-branch index 53aafa4..bcc333e 100644 --- a/.last-branch +++ b/.last-branch @@ -1 +1 @@ -v20260106-06-customers-delete-fk-cascade-fix +v20260106-07-feedback-open-reply diff --git a/containers/backupchecks/src/backend/app/main/routes_feedback.py b/containers/backupchecks/src/backend/app/main/routes_feedback.py index 4e3e322..2dbc85e 100644 --- a/containers/backupchecks/src/backend/app/main/routes_feedback.py +++ b/containers/backupchecks/src/backend/app/main/routes_feedback.py @@ -172,6 +172,19 @@ 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 "" + + replies = ( + FeedbackReply.query.filter(FeedbackReply.feedback_item_id == item.id) + .order_by(FeedbackReply.created_at.asc()) + .all() + ) + + 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 [] + ) + reply_user_map = {int(u.id): (u.username or "") for u in reply_users} + return render_template( "main/feedback_detail.html", item=item, @@ -179,8 +192,42 @@ def feedback_detail(item_id: int): resolved_by_name=resolved_by_name, vote_count=int(vote_count), user_voted=bool(user_voted), + replies=replies, + reply_user_map=reply_user_map, ) +@main_bp.route("/feedback//reply", methods=["POST"]) +@login_required +@roles_required("admin", "operator", "viewer") +def feedback_reply(item_id: int): + item = FeedbackItem.query.get_or_404(item_id) + if item.deleted_at is not None: + abort(404) + + if (item.status or "").strip().lower() != "open": + flash("Only open feedback items can be replied to.", "warning") + return redirect(url_for("main.feedback_detail", item_id=item.id)) + + message = (request.form.get("message") or "").strip() + if not message: + flash("Reply message is required.", "danger") + return redirect(url_for("main.feedback_detail", item_id=item.id)) + + reply = FeedbackReply( + feedback_item_id=int(item.id), + user_id=int(current_user.id), + message=message, + created_at=datetime.utcnow(), + ) + db.session.add(reply) + db.session.commit() + + flash("Reply added.", "success") + return redirect(url_for("main.feedback_detail", item_id=item.id)) + + + + @main_bp.route("/feedback//vote", methods=["POST"]) @login_required diff --git a/containers/backupchecks/src/backend/app/main/routes_shared.py b/containers/backupchecks/src/backend/app/main/routes_shared.py index 9f3dcf4..e73ee9e 100644 --- a/containers/backupchecks/src/backend/app/main/routes_shared.py +++ b/containers/backupchecks/src/backend/app/main/routes_shared.py @@ -50,6 +50,7 @@ from ..models import ( RemarkJobRun, FeedbackItem, FeedbackVote, + FeedbackReply, NewsItem, NewsRead, ReportDefinition, diff --git a/containers/backupchecks/src/backend/app/migrations.py b/containers/backupchecks/src/backend/app/migrations.py index 5980336..589d718 100644 --- a/containers/backupchecks/src/backend/app/migrations.py +++ b/containers/backupchecks/src/backend/app/migrations.py @@ -772,6 +772,7 @@ def run_migrations() -> None: migrate_mail_objects_table() migrate_object_persistence_tables() migrate_feedback_tables() + migrate_feedback_replies_table() migrate_tickets_active_from_date() migrate_remarks_active_from_date() migrate_overrides_match_columns() @@ -978,6 +979,40 @@ def migrate_job_runs_review_tracking() -> None: print("[migrations] migrate_job_runs_review_tracking completed.") + + +def migrate_feedback_replies_table() -> None: + """Ensure feedback reply table exists. + + Table: + - feedback_replies (messages on open feedback items) + """ + engine = db.get_engine() + with engine.begin() as conn: + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS feedback_replies ( + id SERIAL PRIMARY KEY, + feedback_item_id INTEGER NOT NULL REFERENCES feedback_items(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + message TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW() + ); + """ + ) + ) + conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_feedback_replies_item_created_at + ON feedback_replies (feedback_item_id, created_at); + """ + ) + ) + print("[migrations] Feedback replies table ensured.") + + def migrate_tickets_active_from_date() -> None: """Ensure tickets.active_from_date exists and is populated. diff --git a/containers/backupchecks/src/backend/app/models.py b/containers/backupchecks/src/backend/app/models.py index c508649..7b0f26a 100644 --- a/containers/backupchecks/src/backend/app/models.py +++ b/containers/backupchecks/src/backend/app/models.py @@ -494,6 +494,20 @@ class FeedbackVote(db.Model): ) + + +class FeedbackReply(db.Model): + __tablename__ = "feedback_replies" + + id = db.Column(db.Integer, primary_key=True) + feedback_item_id = db.Column( + db.Integer, db.ForeignKey("feedback_items.id", ondelete="CASCADE"), nullable=False + ) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + message = db.Column(db.Text, nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + + class NewsItem(db.Model): __tablename__ = "news_items" diff --git a/containers/backupchecks/src/templates/main/feedback_detail.html b/containers/backupchecks/src/templates/main/feedback_detail.html index 729e2d7..d6cebf7 100644 --- a/containers/backupchecks/src/templates/main/feedback_detail.html +++ b/containers/backupchecks/src/templates/main/feedback_detail.html @@ -49,6 +49,46 @@ +
+
+
Replies
+ {% if replies %} +
+ {% for r in replies %} +
+
+ {{ reply_user_map.get(r.user_id, '') or ('User #' ~ r.user_id) }} + + {{ r.created_at.strftime('%d-%m-%Y %H:%M:%S') if r.created_at else '' }} + +
+
{{ r.message }}
+
+ {% endfor %} +
+ {% else %} +
No replies yet.
+ {% endif %} +
+
+ +
+
+
Add reply
+ {% if item.status == 'open' %} +
+
+ +
+ +
+ {% else %} +
Replies can only be added while the item is open.
+ {% endif %} +
+
+ +
diff --git a/docs/changelog.md b/docs/changelog.md index 0ca8b57..c526080 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -46,6 +46,15 @@ - Migration: enforced ON DELETE CASCADE on customer_objects.customer_id by recreating the FK constraint if needed (idempotent). - Customer delete flow: unlinks jobs (sets jobs.customer_id to NULL) before deleting the customer, so historical job/run data remains intact. +--- + +## v20260106-07-feedback-open-reply + +- Added the ability for users to reply to Feedback items with status "Open". +- Implemented reply functionality directly within the Feedback detail view. +- Ensured replies are only allowed while the Feedback item remains in the Open state. +- Stored user replies linked to the original Feedback item for audit and history purposes. + ================================================================================================================================================ ## v0.1.16