165 lines
5.2 KiB
Python
165 lines
5.2 KiB
Python
"""
|
|
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
|
|
|
|
import sqlalchemy.exc
|
|
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__)
|
|
|
|
|
|
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
|
|
|
|
|
|
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():
|
|
"""
|
|
Return unread notifications for the authenticated user.
|
|
|
|
Query parameters
|
|
----------------
|
|
since ISO 8601 datetime Only return notifications created after this time.
|
|
limit int (default 50, max 50)
|
|
|
|
Response 200
|
|
------------
|
|
{ "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)
|
|
|
|
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()
|
|
|
|
# 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"
|
|
|
|
rows = db.session.execute(db.text(sql_parts), params).fetchall()
|
|
|
|
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]
|
|
|
|
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)})
|
|
|
|
|
|
@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,
|
|
)
|
|
.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}) |