05/15 Update: implement iPad notification function
This commit is contained in:
+3
-1
@@ -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,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)
|
||||||
@@ -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})
|
||||||
Reference in New Issue
Block a user