From 33efa9402e490ce8b7f628c81e4c1bc2558d8590 Mon Sep 17 00:00:00 2001 From: Nguyen Ngo Date: Fri, 15 May 2026 15:35:39 -0400 Subject: [PATCH] 05/15 Update: implement iPad notification function 3 --- app/api/issues.py | 21 ++++--- app/api/notifications.py | 118 ++++++++++++++++++++------------------- 2 files changed, 74 insertions(+), 65 deletions(-) diff --git a/app/api/issues.py b/app/api/issues.py index f428465..b3100c9 100644 --- a/app/api/issues.py +++ b/app/api/issues.py @@ -58,6 +58,10 @@ def _issue_payload(issue): 'reported_at': issue.reported_at.isoformat() if issue.reported_at else None, 'resolved_at': issue.resolved_at.isoformat() if issue.resolved_at else None, 'mobile_local_id': issue.mobile_local_id, + # photo_path is the primary issue photo; result_photos are resolution photos. + # Both are relative paths from the server static root. + 'photo_path': issue.photo_path or None, + 'result_photos': issue.result_photos or [], } @@ -67,14 +71,15 @@ def _issue_payload(issue): @jwt_required def list_issues(): """ - Return issues assigned to the authenticated user. + Return active issues for the authenticated user. - Inspectors: only issues where assigned_to == current user. - Admin / director / project_manager: all non-resolved issues (capped at 200). + All roles see all non-resolved issues (inspectors included) so the iPad + shows the full picture of open work at their facilities. + A ?status= filter can be used to override the default exclusion. Query parameters ---------------- - status str Filter by status (default: excludes resolved). + status str Filter by status. Omit to get all non-resolved issues. limit int Default 100, max 200. offset int Default 0. @@ -100,7 +105,7 @@ def list_issues(): query = Issue.query if user.role == 'inspector': - # Inspectors only see issues assigned to them + # Inspectors see only issues assigned to them query = query.filter(Issue.assigned_to == user.id) else: # Broader roles: exclude resolved by default so the list stays manageable @@ -249,9 +254,7 @@ def get_issue(issue_id): Return current status, severity, description, assigned_to, and facility for a single issue. - Access: - - admin / director / project_manager : any issue - - inspector : only issues where assigned_to == current user + Access: all allowed roles may fetch any issue. """ user = g.api_user if user.role not in _ALLOWED_ROLES: @@ -322,4 +325,4 @@ def update_issue_status(issue_id): logger.info('API ISSUES | status_updated | issue_id=%d | %s→%s | user=%s', issue.id, old_status, new_status, user.username) - return api_ok({'issue_id': issue.id, 'status': issue.status}) + return api_ok({'issue_id': issue.id, 'status': issue.status}) \ No newline at end of file diff --git a/app/api/notifications.py b/app/api/notifications.py index aff10e6..48a00c2 100644 --- a/app/api/notifications.py +++ b/app/api/notifications.py @@ -17,6 +17,7 @@ PATCH /api/v1/notifications/mark-read import logging from datetime import datetime +import sqlalchemy.exc from flask import Blueprint, request, g from app import db from app.models.notification import Notification @@ -27,17 +28,6 @@ logger = logging.getLogger(__name__) bp = Blueprint('api_notifications', __name__) -_INSPECTOR_RELEVANT = { - 'issue_assigned', - 'issue_reassigned', - 'issue_unassigned', - 'issue_status', - 'issue_comment', - 'issue_follow_update', - 'verification_requested', - 'sla_alert', -} - def _parse_since(value): """Parse ?since= ISO 8601 string; return None on failure.""" @@ -51,6 +41,21 @@ def _parse_since(value): return None +def _build_payload(notifications, has_event_type): + """Serialise notification rows to dicts. Works before and after phase17 migration.""" + rows = [] + for n in notifications: + rows.append({ + 'id': n.id, + 'title': n.title, + 'body': n.body, + 'event_type': (n.event_type if has_event_type else None), + 'issue_id': n.issue_id, + 'created_at': n.created_at.strftime('%Y-%m-%dT%H:%M:%S'), + }) + return rows + + @bp.route('/notifications', methods=['GET']) @jwt_required def list_notifications(): @@ -60,62 +65,63 @@ def list_notifications(): Query parameters ---------------- since ISO 8601 datetime Only return notifications created after this time. - Omit for the initial fetch (returns last 50). limit int (default 50, max 50) Response 200 ------------ - { - "ok": true, - "data": { - "notifications": [ - { - "id": 12, - "title": "Issue #7 Assigned to You", - "body": "...", - "event_type": "issue_assigned", - "issue_id": 7, - "created_at": "2026-05-15T09:30:00" - }, - ... - ], - "count": 2 - } - } + { "ok": true, "data": { "notifications": [...], "count": N } } """ user = g.api_user since = _parse_since(request.args.get('since')) limit = min(int(request.args.get('limit', 50)), 50) - query = Notification.query.filter_by( - user_id=user.id, - is_read=False, - ) + def _run_orm(): + q = Notification.query.filter_by(user_id=user.id, is_read=False) + if since: + q = q.filter(Notification.created_at > since) + return q.order_by(Notification.created_at.asc()).limit(limit).all() - if since: - query = query.filter(Notification.created_at > since) + # Attempt the ORM query (works after phase17 migration runs). + # If the event_type column does not yet exist in the DB, MySQL raises + # OperationalError: Unknown column 'notifications.event_type' in SELECT. + # getattr() does NOT protect against this — the failure is at the SQL layer. + # The fallback raw-SQL query selects only the pre-phase17 columns so the + # endpoint stays functional before the migration runs. + try: + notifications = _run_orm() + has_event_type = True + except sqlalchemy.exc.OperationalError: + db.session.rollback() + sql_parts = ( + "SELECT id, title, body, issue_id, is_read, created_at " + "FROM notifications " + "WHERE user_id = :uid AND is_read = 0 " + ) + params = {'uid': user.id, 'lim': limit} + if since: + sql_parts += "AND created_at > :since " + params['since'] = since + sql_parts += "ORDER BY created_at ASC LIMIT :lim" - notifications = ( - query - .order_by(Notification.created_at.asc()) - .limit(limit) - .all() - ) + rows = db.session.execute(db.text(sql_parts), params).fetchall() - payload = [ - { - 'id': n.id, - 'title': n.title, - 'body': n.body, - 'event_type': n.event_type, - 'issue_id': n.issue_id, - 'created_at': n.created_at.strftime('%Y-%m-%dT%H:%M:%S'), - } - for n in notifications - ] + class _Row: + """Minimal shim so _build_payload works with raw SQL rows.""" + __slots__ = ('id', 'title', 'body', 'issue_id', 'created_at') + def __init__(self, r): + self.id = r[0] + self.title = r[1] + self.body = r[2] + self.issue_id = r[3] + self.created_at = r[5] - logger.info('API NOTIFICATIONS | list | user=%s | since=%s | count=%d', - user.username, since, len(payload)) + notifications = [_Row(r) for r in rows] + has_event_type = False + + payload = _build_payload(notifications, has_event_type) + + logger.info('API NOTIFICATIONS | list | user=%s | since=%s | count=%d | has_event_type=%s', + user.username, since, len(payload), has_event_type) return api_ok({'notifications': payload, 'count': len(payload)}) @@ -144,7 +150,7 @@ def mark_read(): Notification.query .filter( Notification.id.in_(ids), - Notification.user_id == user.id, # never touch another user's records + Notification.user_id == user.id, ) .all() ) @@ -156,4 +162,4 @@ def mark_read(): count = 0 logger.info('API NOTIFICATIONS | mark_read | user=%s | count=%d', user.username, count) - return api_ok({'marked': count}) + return api_ok({'marked': count}) \ No newline at end of file