Merge pull request 'Auto-commit local changes before build (2026-01-06 11:47:15)' (#41) from v20260106-07-feedback-open-reply into main

Reviewed-on: #41
This commit is contained in:
Ivo Oskamp 2026-01-13 11:07:54 +01:00
commit a5ebe867bb
7 changed files with 147 additions and 1 deletions

View File

@ -1 +1 @@
v20260106-06-customers-delete-fk-cascade-fix
v20260106-07-feedback-open-reply

View File

@ -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/<int:item_id>/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/<int:item_id>/vote", methods=["POST"])
@login_required

View File

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

View File

@ -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.

View File

@ -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"

View File

@ -49,6 +49,46 @@
</div>
</div>
<div class="card mb-3">
<div class="card-body">
<h5 class="card-title mb-3">Replies</h5>
{% if replies %}
<div class="list-group list-group-flush">
{% for r in replies %}
<div class="list-group-item px-0">
<div class="d-flex justify-content-between align-items-start">
<strong>{{ reply_user_map.get(r.user_id, '') or ('User #' ~ r.user_id) }}</strong>
<span class="text-muted" style="font-size: 0.85rem;">
{{ r.created_at.strftime('%d-%m-%Y %H:%M:%S') if r.created_at else '' }}
</span>
</div>
<div style="white-space: pre-wrap;">{{ r.message }}</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-muted">No replies yet.</div>
{% endif %}
</div>
</div>
<div class="card mb-3">
<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) }}">
<div class="mb-2">
<textarea class="form-control" name="message" rows="4" required></textarea>
</div>
<button type="submit" class="btn btn-primary">Post reply</button>
</form>
{% else %}
<div class="text-muted">Replies can only be added while the item is open.</div>
{% endif %}
</div>
</div>
<div class="col-12 col-lg-4">
<div class="card">
<div class="card-body">

View File

@ -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