From b761f6577ead191a089dd3b1e0f710699d9b0866 Mon Sep 17 00:00:00 2001 From: Nguyen Ngo Date: Fri, 15 May 2026 14:54:47 -0400 Subject: [PATCH] 05/15 Update: implement iPad notification function --- app/__init__.py | 16 ++-- app/api/__init__.py | 5 ++ app/api/notifications.py | 159 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 173 insertions(+), 7 deletions(-) create mode 100644 app/api/notifications.py diff --git a/app/__init__.py b/app/__init__.py index 48f5ce3..1fae60d 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -147,25 +147,27 @@ def create_app(config_name='default'): app.register_blueprint(customers.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. # # Flask-WTF's _is_exempt() checks whether the *leaf* blueprint object # is in _exempt_blueprints. Exempting the parent api_bp does NOT cascade # to sub-blueprints. Each child blueprint must be exempted individually. from app.api import register_api, api_bp - from app.api.auth import bp as _api_auth_bp - from app.api.facilities import bp as _api_facilities_bp - from app.api.templates import bp as _api_templates_bp - from app.api.inspections import bp as _api_inspections_bp - from app.api.issues import bp as _api_issues_bp - from app.api.photos import bp as _api_photos_bp + from app.api.auth import bp as _api_auth_bp + from app.api.facilities import bp as _api_facilities_bp + from app.api.templates import bp as _api_templates_bp + from app.api.inspections import bp as _api_inspections_bp + from app.api.issues import bp as _api_issues_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_facilities_bp) csrf.exempt(_api_templates_bp) csrf.exempt(_api_inspections_bp) csrf.exempt(_api_issues_bp) csrf.exempt(_api_photos_bp) + csrf.exempt(_api_notifications_bp) register_api(app) # ── Error handler: 413 Request Entity Too Large ─────────────────────── diff --git a/app/api/__init__.py b/app/api/__init__.py index 03b474c..d8469c2 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -5,6 +5,7 @@ Registers the /api/v1 blueprint group. Phase A: /api/v1/facilities/*, /api/v1/templates/* Phase B: /api/v1/inspections/*, /api/v1/issues/*, /api/v1/photos/* +Phase C: /api/v1/notifications/* """ from flask import Blueprint @@ -37,4 +38,8 @@ def register_api(app): api_bp.register_blueprint(issues_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) \ No newline at end of file diff --git a/app/api/notifications.py b/app/api/notifications.py new file mode 100644 index 0000000..aff10e6 --- /dev/null +++ b/app/api/notifications.py @@ -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= 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})