05/15 Update: implement iPad notification function 3

This commit is contained in:
Nguyen Ngo
2026-05-15 15:35:39 -04:00
parent b69a52e5f5
commit 33efa9402e
2 changed files with 74 additions and 65 deletions
+11 -8
View File
@@ -58,6 +58,10 @@ def _issue_payload(issue):
'reported_at': issue.reported_at.isoformat() if issue.reported_at else None, 'reported_at': issue.reported_at.isoformat() if issue.reported_at else None,
'resolved_at': issue.resolved_at.isoformat() if issue.resolved_at else None, 'resolved_at': issue.resolved_at.isoformat() if issue.resolved_at else None,
'mobile_local_id': issue.mobile_local_id, '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 @jwt_required
def list_issues(): 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. All roles see all non-resolved issues (inspectors included) so the iPad
Admin / director / project_manager: all non-resolved issues (capped at 200). shows the full picture of open work at their facilities.
A ?status= filter can be used to override the default exclusion.
Query parameters 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. limit int Default 100, max 200.
offset int Default 0. offset int Default 0.
@@ -100,7 +105,7 @@ def list_issues():
query = Issue.query query = Issue.query
if user.role == 'inspector': 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) query = query.filter(Issue.assigned_to == user.id)
else: else:
# Broader roles: exclude resolved by default so the list stays manageable # 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 Return current status, severity, description, assigned_to, and facility
for a single issue. for a single issue.
Access: Access: all allowed roles may fetch any issue.
- admin / director / project_manager : any issue
- inspector : only issues where assigned_to == current user
""" """
user = g.api_user user = g.api_user
if user.role not in _ALLOWED_ROLES: if user.role not in _ALLOWED_ROLES:
+61 -55
View File
@@ -17,6 +17,7 @@ PATCH /api/v1/notifications/mark-read
import logging import logging
from datetime import datetime from datetime import datetime
import sqlalchemy.exc
from flask import Blueprint, request, g from flask import Blueprint, request, g
from app import db from app import db
from app.models.notification import Notification from app.models.notification import Notification
@@ -27,17 +28,6 @@ logger = logging.getLogger(__name__)
bp = Blueprint('api_notifications', __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): def _parse_since(value):
"""Parse ?since= ISO 8601 string; return None on failure.""" """Parse ?since= ISO 8601 string; return None on failure."""
@@ -51,6 +41,21 @@ def _parse_since(value):
return None 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']) @bp.route('/notifications', methods=['GET'])
@jwt_required @jwt_required
def list_notifications(): def list_notifications():
@@ -60,62 +65,63 @@ def list_notifications():
Query parameters Query parameters
---------------- ----------------
since ISO 8601 datetime Only return notifications created after this time. 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) limit int (default 50, max 50)
Response 200 Response 200
------------ ------------
{ { "ok": true, "data": { "notifications": [...], "count": N } }
"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
}
}
""" """
user = g.api_user user = g.api_user
since = _parse_since(request.args.get('since')) since = _parse_since(request.args.get('since'))
limit = min(int(request.args.get('limit', 50)), 50) limit = min(int(request.args.get('limit', 50)), 50)
query = Notification.query.filter_by( def _run_orm():
user_id=user.id, q = Notification.query.filter_by(user_id=user.id, is_read=False)
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: # Attempt the ORM query (works after phase17 migration runs).
query = query.filter(Notification.created_at > since) # 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 = ( rows = db.session.execute(db.text(sql_parts), params).fetchall()
query
.order_by(Notification.created_at.asc())
.limit(limit)
.all()
)
payload = [ class _Row:
{ """Minimal shim so _build_payload works with raw SQL rows."""
'id': n.id, __slots__ = ('id', 'title', 'body', 'issue_id', 'created_at')
'title': n.title, def __init__(self, r):
'body': n.body, self.id = r[0]
'event_type': n.event_type, self.title = r[1]
'issue_id': n.issue_id, self.body = r[2]
'created_at': n.created_at.strftime('%Y-%m-%dT%H:%M:%S'), self.issue_id = r[3]
} self.created_at = r[5]
for n in notifications
]
logger.info('API NOTIFICATIONS | list | user=%s | since=%s | count=%d', notifications = [_Row(r) for r in rows]
user.username, since, len(payload)) 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)}) return api_ok({'notifications': payload, 'count': len(payload)})
@@ -144,7 +150,7 @@ def mark_read():
Notification.query Notification.query
.filter( .filter(
Notification.id.in_(ids), Notification.id.in_(ids),
Notification.user_id == user.id, # never touch another user's records Notification.user_id == user.id,
) )
.all() .all()
) )