05/15 Update: implement iPad notification function

This commit is contained in:
Nguyen Ngo
2026-05-15 14:54:47 -04:00
parent 4c073e1904
commit b761f6577e
3 changed files with 173 additions and 7 deletions
+3 -1
View File
@@ -147,7 +147,7 @@ def create_app(config_name='default'):
app.register_blueprint(customers.bp) app.register_blueprint(customers.bp)
app.register_blueprint(scheduled_reports.bp) app.register_blueprint(scheduled_reports.bp)
# ── Mobile API (Phase 7 / Phase A / Phase B) ───────────────────────────── # ── Mobile API (Phase 7 / Phase A / Phase B / Phase C) ───────────────────
# The /api/v1 blueprint group uses JWT Bearer tokens — no CSRF cookies needed. # The /api/v1 blueprint group uses JWT Bearer tokens — no CSRF cookies needed.
# #
# Flask-WTF's _is_exempt() checks whether the *leaf* blueprint object # Flask-WTF's _is_exempt() checks whether the *leaf* blueprint object
@@ -160,12 +160,14 @@ def create_app(config_name='default'):
from app.api.inspections import bp as _api_inspections_bp from app.api.inspections import bp as _api_inspections_bp
from app.api.issues import bp as _api_issues_bp from app.api.issues import bp as _api_issues_bp
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
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)
csrf.exempt(_api_inspections_bp) csrf.exempt(_api_inspections_bp)
csrf.exempt(_api_issues_bp) csrf.exempt(_api_issues_bp)
csrf.exempt(_api_photos_bp) csrf.exempt(_api_photos_bp)
csrf.exempt(_api_notifications_bp)
register_api(app) register_api(app)
# ── Error handler: 413 Request Entity Too Large ─────────────────────── # ── Error handler: 413 Request Entity Too Large ───────────────────────
+5
View File
@@ -5,6 +5,7 @@ Registers the /api/v1 blueprint group.
Phase A: /api/v1/facilities/*, /api/v1/templates/* Phase A: /api/v1/facilities/*, /api/v1/templates/*
Phase B: /api/v1/inspections/*, /api/v1/issues/*, /api/v1/photos/* Phase B: /api/v1/inspections/*, /api/v1/issues/*, /api/v1/photos/*
Phase C: /api/v1/notifications/*
""" """
from flask import Blueprint from flask import Blueprint
@@ -37,4 +38,8 @@ def register_api(app):
api_bp.register_blueprint(issues_bp) api_bp.register_blueprint(issues_bp)
api_bp.register_blueprint(photos_bp) api_bp.register_blueprint(photos_bp)
# Phase C: Notification polling
from app.api.notifications import bp as notifications_bp
api_bp.register_blueprint(notifications_bp)
app.register_blueprint(api_bp) app.register_blueprint(api_bp)
+159
View File
@@ -0,0 +1,159 @@
"""
app/api/notifications.py
------------------------
Mobile API endpoint for polling in-app notifications.
GET /api/v1/notifications
Returns unread notifications for the authenticated user.
Accepts ?since=<ISO 8601> to fetch only notifications created after
that datetime — used by the iPad poller to avoid re-delivering already-
seen alerts. Returns a maximum of 50 notifications per call.
PATCH /api/v1/notifications/mark-read
Marks a list of notification IDs as read.
Request JSON: { "ids": [1, 2, 3] }
"""
import logging
from datetime import datetime
from flask import Blueprint, request, g
from app import db
from app.models.notification import Notification
from app.api.errors import api_ok, api_error
from app.api.decorators import jwt_required
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."""
if not value:
return None
for fmt in ('%Y-%m-%dT%H:%M:%S', '%Y-%m-%dT%H:%M:%S.%f', '%Y-%m-%d'):
try:
return datetime.strptime(value, fmt)
except (ValueError, TypeError):
pass
return None
@bp.route('/notifications', methods=['GET'])
@jwt_required
def list_notifications():
"""
Return unread notifications for the authenticated user.
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
}
}
"""
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,
)
if since:
query = query.filter(Notification.created_at > since)
notifications = (
query
.order_by(Notification.created_at.asc())
.limit(limit)
.all()
)
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
]
logger.info('API NOTIFICATIONS | list | user=%s | since=%s | count=%d',
user.username, since, len(payload))
return api_ok({'notifications': payload, 'count': len(payload)})
@bp.route('/notifications/mark-read', methods=['PATCH'])
@jwt_required
def mark_read():
"""
Mark a list of notification IDs as read.
Request JSON: { "ids": [1, 2, 3] }
Response 200
------------
{ "ok": true, "data": { "marked": 3 } }
"""
user = g.api_user
data = request.get_json(silent=True) or {}
ids = data.get('ids') or []
if not isinstance(ids, list):
return api_error('ids must be a list', 400)
if ids:
updated = (
Notification.query
.filter(
Notification.id.in_(ids),
Notification.user_id == user.id, # never touch another user's records
)
.all()
)
for n in updated:
n.is_read = True
db.session.commit()
count = len(updated)
else:
count = 0
logger.info('API NOTIFICATIONS | mark_read | user=%s | count=%d', user.username, count)
return api_ok({'marked': count})