05/27 Sync with iPad app (Phase D)

This commit is contained in:
Nguyen Ngo
2026-05-27 16:40:46 -04:00
parent cef7252014
commit 1095c8fcde
3 changed files with 193 additions and 0 deletions
+2
View File
@@ -162,6 +162,7 @@ def create_app(config_name='default'):
from app.api.photos import bp as _api_photos_bp from app.api.photos import bp as _api_photos_bp
from app.api.notifications import bp as _api_notifications_bp from app.api.notifications import bp as _api_notifications_bp
from app.api.stats import bp as _api_stats_bp from app.api.stats import bp as _api_stats_bp
from app.api.comments import bp as _api_comments_bp
csrf.exempt(_api_auth_bp) csrf.exempt(_api_auth_bp)
csrf.exempt(_api_facilities_bp) csrf.exempt(_api_facilities_bp)
csrf.exempt(_api_templates_bp) csrf.exempt(_api_templates_bp)
@@ -170,6 +171,7 @@ def create_app(config_name='default'):
csrf.exempt(_api_photos_bp) csrf.exempt(_api_photos_bp)
csrf.exempt(_api_notifications_bp) csrf.exempt(_api_notifications_bp)
csrf.exempt(_api_stats_bp) csrf.exempt(_api_stats_bp)
csrf.exempt(_api_comments_bp)
register_api(app) register_api(app)
# ── Security response headers ───────────────────────────────────────── # ── Security response headers ─────────────────────────────────────────
+4
View File
@@ -46,4 +46,8 @@ def register_api(app):
from app.api.stats import bp as stats_bp from app.api.stats import bp as stats_bp
api_bp.register_blueprint(stats_bp) api_bp.register_blueprint(stats_bp)
# Phase D: Issue comments
from app.api.comments import bp as comments_bp
api_bp.register_blueprint(comments_bp)
app.register_blueprint(api_bp) app.register_blueprint(api_bp)
+187
View File
@@ -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})