First commit
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
"""
|
||||
app/api/comments.py
|
||||
-------------------
|
||||
Mobile API endpoints for issue comments.
|
||||
|
||||
GET /api/v1/issues/<id>/comments
|
||||
Returns all comments for an issue, ordered oldest-first.
|
||||
Inspectors may only access issues within their contracted facilities.
|
||||
|
||||
POST /api/v1/issues/<id>/comments
|
||||
Adds a comment to an issue.
|
||||
Inspectors may only comment on issues within their contracted facilities.
|
||||
Fires notify_by_matrix('issue_comment') so the relevant staff are notified.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from flask import Blueprint, request, g
|
||||
from app import db
|
||||
from app.models.issue import Issue, IssueComment
|
||||
from app.api.errors import api_ok, api_error
|
||||
from app.api.decorators import jwt_required
|
||||
from app.utils.audit import log_action, ACTION_CREATE
|
||||
from app.utils.notifications import notify_by_matrix
|
||||
from app.utils.scope import get_inspector_scope
|
||||
from app.utils.time_utils import now_eastern
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
bp = Blueprint('api_comments', __name__)
|
||||
|
||||
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'}
|
||||
|
||||
|
||||
def _comment_payload(comment: IssueComment) -> dict:
|
||||
"""Serialise an IssueComment to the dict returned in API responses."""
|
||||
return {
|
||||
'id': comment.id,
|
||||
'issue_id': comment.issue_id,
|
||||
'author_name': comment.author.display_name if comment.author else 'Unknown',
|
||||
'author_role': comment.author.role if comment.author else '',
|
||||
'status_at_time': comment.status_at_time or '',
|
||||
'body': comment.body,
|
||||
'created_at': comment.created_at.isoformat() if comment.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _check_issue_access(issue: Issue, user) -> bool:
|
||||
"""Return True if user may read/write this issue. False = 403."""
|
||||
if user.role == 'inspector':
|
||||
fids = get_inspector_scope(user)
|
||||
facility = issue.resolved_facility
|
||||
if not fids or not facility or facility.id not in fids:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
# ── GET comments ─────────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/issues/<int:issue_id>/comments', methods=['GET'])
|
||||
@jwt_required
|
||||
def list_comments(issue_id):
|
||||
"""
|
||||
Return all comments for the given issue, oldest-first.
|
||||
|
||||
Response 200
|
||||
------------
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"issue_id": 42,
|
||||
"comments": [
|
||||
{
|
||||
"id": 1,
|
||||
"issue_id": 42,
|
||||
"author_name": "Jane Smith",
|
||||
"author_role": "director",
|
||||
"status_at_time": "in_progress",
|
||||
"body": "Cleaning crew has been notified.",
|
||||
"created_at": "2026-05-10T09:15:00"
|
||||
}
|
||||
],
|
||||
"count": 1
|
||||
}
|
||||
}
|
||||
"""
|
||||
user = g.api_user
|
||||
if user.role not in _ALLOWED_ROLES:
|
||||
return api_error('Access denied', 403)
|
||||
|
||||
issue = db.session.get(Issue, issue_id)
|
||||
if issue is None:
|
||||
return api_error('Issue not found', 404)
|
||||
|
||||
if not _check_issue_access(issue, user):
|
||||
return api_error('Access denied', 403)
|
||||
|
||||
comments = (
|
||||
issue.comments
|
||||
.order_by(IssueComment.created_at.asc())
|
||||
.all()
|
||||
)
|
||||
payload = [_comment_payload(c) for c in comments]
|
||||
|
||||
logger.info('API COMMENTS | list | issue_id=%d | count=%d | user=%s',
|
||||
issue_id, len(payload), user.username)
|
||||
|
||||
return api_ok({'issue_id': issue_id, 'comments': payload, 'count': len(payload)})
|
||||
|
||||
|
||||
# ── POST comment ──────────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/issues/<int:issue_id>/comments', methods=['POST'])
|
||||
@jwt_required
|
||||
def add_comment(issue_id):
|
||||
"""
|
||||
Add a comment to an issue.
|
||||
|
||||
Request JSON
|
||||
------------
|
||||
{ "body": "The spill has been cleaned up." }
|
||||
|
||||
Response 200
|
||||
------------
|
||||
{ "ok": true, "data": { "comment_id": 7 } }
|
||||
"""
|
||||
user = g.api_user
|
||||
if user.role not in _ALLOWED_ROLES:
|
||||
return api_error('Access denied', 403)
|
||||
|
||||
issue = db.session.get(Issue, issue_id)
|
||||
if issue is None:
|
||||
return api_error('Issue not found', 404)
|
||||
|
||||
if not _check_issue_access(issue, user):
|
||||
return api_error('Access denied', 403)
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
body = (data.get('body') or '').strip()
|
||||
if not body:
|
||||
return api_error('body is required', 400)
|
||||
|
||||
comment = IssueComment(
|
||||
issue_id = issue_id,
|
||||
user_id = user.id,
|
||||
status_at_time = issue.status,
|
||||
body = body,
|
||||
)
|
||||
db.session.add(comment)
|
||||
db.session.flush() # get comment.id
|
||||
|
||||
# Notify via matrix — same event type as web-originated comments
|
||||
facility = issue.resolved_facility
|
||||
area_name = issue.area.name if issue.area else (facility.name if facility else '—')
|
||||
facility_id = facility.id if facility else None
|
||||
|
||||
try:
|
||||
from flask import url_for
|
||||
issue_link = url_for('issues.view', issue_id=issue.id, _external=False)
|
||||
except RuntimeError:
|
||||
issue_link = f'/issues/{issue.id}'
|
||||
|
||||
if facility_id:
|
||||
notify_by_matrix(
|
||||
event_type = 'issue_comment',
|
||||
title = f'New Comment on Issue #{issue.id}',
|
||||
body = (
|
||||
f'{user.display_name} commented on Issue #{issue.id} '
|
||||
f'at {area_name}: '
|
||||
f'"{body[:120]}{"…" if len(body) > 120 else ""}"'
|
||||
),
|
||||
link = issue_link,
|
||||
issue_id = issue.id,
|
||||
facility_id = facility_id,
|
||||
exclude_user_ids = {user.id},
|
||||
)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
log_action(ACTION_CREATE, 'IssueComment', comment.id,
|
||||
f'comment on Issue #{issue_id}',
|
||||
f'source=mobile; author={user.username}; issue_status={issue.status}')
|
||||
|
||||
logger.info('API COMMENTS | created | comment_id=%d | issue_id=%d | user=%s',
|
||||
comment.id, issue_id, user.username)
|
||||
|
||||
return api_ok({'comment_id': comment.id})
|
||||
Reference in New Issue
Block a user