06/09 Update a customer can comment on it issue

This commit is contained in:
2026-06-09 15:35:39 -04:00
parent cdcb675a24
commit 58d4b351b4
4 changed files with 138 additions and 15 deletions
+7 -6
View File
@@ -5,12 +5,13 @@ from app.utils.time_utils import now_eastern
class IssueComment(db.Model): class IssueComment(db.Model):
__tablename__ = 'issue_comments' __tablename__ = 'issue_comments'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
issue_id = db.Column(db.Integer, db.ForeignKey('issues.id'), nullable=False) issue_id = db.Column(db.Integer, db.ForeignKey('issues.id'), nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
status_at_time = db.Column(db.String(20)) # snapshot of issue status when comment was made status_at_time = db.Column(db.String(20)) # snapshot of issue status when comment was made
body = db.Column(db.Text, nullable=False) body = db.Column(db.Text, nullable=False)
created_at = db.Column(db.DateTime, default=now_eastern, nullable=False) created_at = db.Column(db.DateTime, default=now_eastern, nullable=False)
is_customer_visible = db.Column(db.Boolean, nullable=False, default=False)
# Relationships # Relationships
author = db.relationship('User', foreign_keys=[user_id]) author = db.relationship('User', foreign_keys=[user_id])
+33 -6
View File
@@ -227,7 +227,28 @@ def view(issue_id):
flash('Access denied.', 'danger') flash('Access denied.', 'danger')
return redirect(url_for('issues.index')) return redirect(url_for('issues.index'))
if request.method == 'POST': if request.method == 'POST':
abort(403) # Customers may only add a comment, and only on issues they follow or reported
can_comment = (issue.is_followed_by(current_user) or issue.reported_by == current_user.id)
if not can_comment:
abort(403)
comment_body = request.form.get('update_notes', '').strip()
if not comment_body:
flash('Comment cannot be empty.', 'warning')
return redirect(url_for('issues.view', issue_id=issue_id))
comment = IssueComment(
issue_id=issue.id,
user_id=current_user.id,
status_at_time=issue.status,
body=comment_body,
is_customer_visible=True, # customer comments are always visible to all
)
db.session.add(comment)
db.session.commit()
log_action(ACTION_UPDATE, 'Issue', issue.id,
f'#{issue.id}',
'customer comment added')
flash('Comment posted.', 'success')
return redirect(url_for('issues.view', issue_id=issue_id))
form = IssueUpdateForm(obj=issue) form = IssueUpdateForm(obj=issue)
staff = User.query.filter(User.role.in_(['admin', 'director', 'inspector'])).order_by(User.username).all() staff = User.query.filter(User.role.in_(['admin', 'director', 'inspector'])).order_by(User.username).all()
@@ -270,10 +291,11 @@ def view(issue_id):
comment_body = form.update_notes.data.strip() if form.update_notes.data else '' comment_body = form.update_notes.data.strip() if form.update_notes.data else ''
if comment_body: if comment_body:
comment = IssueComment( comment = IssueComment(
issue_id = issue.id, issue_id = issue.id,
user_id = current_user.id, user_id = current_user.id,
status_at_time = issue.status, status_at_time = issue.status,
body = comment_body, body = comment_body,
is_customer_visible = 'is_customer_visible' in request.form,
) )
db.session.add(comment) db.session.add(comment)
@@ -418,7 +440,12 @@ def view(issue_id):
return redirect(url_for('issues.view', issue_id=issue_id)) return redirect(url_for('issues.view', issue_id=issue_id))
is_following = issue.is_followed_by(current_user) is_following = issue.is_followed_by(current_user)
comments = issue.comments.order_by(IssueComment.created_at.asc()).all() if current_user.role == 'customer':
comments = (issue.comments
.filter_by(is_customer_visible=True)
.order_by(IssueComment.created_at.asc()).all())
else:
comments = issue.comments.order_by(IssueComment.created_at.asc()).all()
return render_template('issues/view.html', return render_template('issues/view.html',
issue=issue, issue=issue,
form=form, form=form,
+57 -3
View File
@@ -183,6 +183,20 @@
{% else %} {% else %}
<span class="badge bg-secondary" style="font-size:.65rem;">{{ c.author.role|replace('_',' ')|title }}</span> <span class="badge bg-secondary" style="font-size:.65rem;">{{ c.author.role|replace('_',' ')|title }}</span>
{% endif %} {% endif %}
{# Visibility indicator — staff only #}
{% if current_user.role != 'customer' %}
{% if c.is_customer_visible %}
<span class="badge bg-success bg-opacity-10 text-success border border-success"
style="font-size:.6rem;" title="Customer can see this comment">
<i class="bi bi-eye me-1"></i>Customer visible
</span>
{% else %}
<span class="badge bg-secondary bg-opacity-10 text-secondary border border-secondary"
style="font-size:.6rem;" title="Hidden from customer">
<i class="bi bi-eye-slash me-1"></i>Staff only
</span>
{% endif %}
{% endif %}
</div> </div>
<div class="d-flex align-items-center gap-2"> <div class="d-flex align-items-center gap-2">
<span class="badge bg-{{ 'success' if c.status_at_time == 'resolved' else 'info text-dark' if c.status_at_time == 'pending_verification' else 'warning text-dark' if c.status_at_time == 'in_progress' else 'danger' }}" <span class="badge bg-{{ 'success' if c.status_at_time == 'resolved' else 'info text-dark' if c.status_at_time == 'pending_verification' else 'warning text-dark' if c.status_at_time == 'in_progress' else 'danger' }}"
@@ -198,27 +212,67 @@
{% endfor %} {% endfor %}
</div> </div>
<hr class="mx-3 my-0"> <hr class="mx-3 my-0">
{% elif current_user.role == 'customer' %}
<div class="px-3 pt-3 pb-0">
<p class="text-muted small"><i class="bi bi-chat-left me-1"></i>No comments yet.</p>
</div>
<hr class="mx-3 my-0">
{% endif %} {% endif %}
{# Add Comment form — submits to the same view POST endpoint #} {# ── Add Comment form ─────────────────────────────────────────────── #}
{% set can_customer_comment = current_user.role == 'customer' and (is_following or issue.reported_by == current_user.id) %}
{% if can_edit %} {% if can_edit %}
{# Staff comment form with visibility checkbox #}
<div class="card-body"> <div class="card-body">
<p class="fw-semibold small mb-2">Add Comment</p> <p class="fw-semibold small mb-2">Add Comment</p>
<form method="post"> <form method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{# Keep current status and assigned_to so the route doesn't change them #}
<input type="hidden" name="status" value="{{ issue.status }}"> <input type="hidden" name="status" value="{{ issue.status }}">
<input type="hidden" name="assigned_to" value="{{ issue.assigned_to or 0 }}"> <input type="hidden" name="assigned_to" value="{{ issue.assigned_to or 0 }}">
<div class="mb-2"> <div class="mb-2">
<textarea name="update_notes" class="form-control" rows="3" <textarea name="update_notes" class="form-control" rows="3"
placeholder="Write a comment…" required></textarea> placeholder="Write a comment…" required></textarea>
</div> </div>
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2">
<div class="form-check form-check-inline mb-0">
<input class="form-check-input" type="checkbox"
name="is_customer_visible" id="is_customer_visible" value="1">
<label class="form-check-label small text-muted" for="is_customer_visible">
<i class="bi bi-eye me-1"></i>Share with customer
</label>
</div>
<button type="submit" class="btn btn-primary btn-sm">
<i class="bi bi-send me-1"></i>Post Comment
</button>
</div>
</form>
</div>
{% elif can_customer_comment %}
{# Customer comment form — visible to all by design #}
<div class="card-body">
<p class="fw-semibold small mb-2">Add Comment</p>
<form method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-2">
<textarea name="update_notes" class="form-control" rows="3"
placeholder="Write a comment…" required></textarea>
</div>
<button type="submit" class="btn btn-primary btn-sm"> <button type="submit" class="btn btn-primary btn-sm">
<i class="bi bi-send me-1"></i>Post Comment <i class="bi bi-send me-1"></i>Post Comment
</button> </button>
</form> </form>
</div> </div>
{% elif current_user.role != 'customer' %}
{% elif current_user.role == 'customer' %}
<div class="card-body py-2">
<p class="text-muted small mb-0">
<i class="bi bi-bell me-1"></i>Follow this issue to add comments.
</p>
</div>
{% else %}
<div class="card-body py-2"> <div class="card-body py-2">
<p class="text-muted small mb-0"> <p class="text-muted small mb-0">
<i class="bi bi-lock me-1"></i>Only assigned staff can add comments. <i class="bi bi-lock me-1"></i>Only assigned staff can add comments.
@@ -0,0 +1,41 @@
"""phase22 — add is_customer_visible to issue_comments
Staff comments default to hidden from customers (is_customer_visible=FALSE).
Staff can tick a checkbox to share a comment with the customer.
Customer comments are always visible (is_customer_visible=TRUE, set at write time).
"""
import sqlalchemy as sa
from alembic import op
revision = 'phase22_comment_visibility'
down_revision = 'phase21_performance_indexes'
branch_labels = None
depends_on = None
def _column_exists(bind, table: str, column: str) -> bool:
result = bind.execute(sa.text(
"SELECT COUNT(*) FROM information_schema.columns "
"WHERE table_schema = DATABASE() "
" AND table_name = :table "
" AND column_name = :column"
), {'table': table, 'column': column})
return result.scalar() > 0
def upgrade():
bind = op.get_bind()
if not _column_exists(bind, 'issue_comments', 'is_customer_visible'):
op.execute(sa.text(
'ALTER TABLE issue_comments '
'ADD COLUMN is_customer_visible BOOLEAN NOT NULL DEFAULT FALSE'
))
def downgrade():
bind = op.get_bind()
if _column_exists(bind, 'issue_comments', 'is_customer_visible'):
op.execute(sa.text(
'ALTER TABLE issue_comments DROP COLUMN is_customer_visible'
))