Files
siftlode/backend/app/routes/notifications.py
T
peter c5533b0ac5 refactor(api): shared serializer + ownership + write-scope helpers (R6 S1a)
Establish the R6 API-contract infra before the S1b payload:dict migration:
- iso(dt) in utils.py replaces the ~16 hand-rolled `x.isoformat() if x else None`
  serializer idioms across the routes.
- get_or_404 / owned_or_404 in routes/_common.py — one 404 + existence-leak policy;
  plex's _own_playlist_or_404 and saved_views' _own_view now delegate to it.
- require_write_scope dependency in auth.py collapses the inline has_write_scope→403
  checks (channels subscribe/unsubscribe, playlists push/push-plan) that had drifted
  into three different messages into one; the conditional delete_playlist guard stays
  inline (its message was already canonical).

No response-shape change (iso == isoformat for a present value). Gate: ruff clean,
pytest 163 green (+ test_utils for iso).
2026-07-26 04:17:08 +02:00

138 lines
3.9 KiB
Python

"""Per-user notification inbox (phase 1): the durable, server-backed center that coexists
with the client-side transient bell. Read-only listing plus read/dismiss/clear mutations.
All endpoints use `current_user` (not `require_human`): notifications and their read/dismiss
state are personal UI state with no quota or YouTube involvement, so the shared demo account
manages its own inbox normally."""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, select, update
from sqlalchemy.orm import Session
from app.auth import current_user
from app.db import get_db
from app.models import Notification, User
from app.utils import iso
from app.notifications import trim_read
router = APIRouter(prefix="/api/me/notifications", tags=["notifications"])
def _serialize(n: Notification) -> dict:
return {
"id": n.id,
"type": n.type,
"title": n.title,
"body": n.body,
"data": n.data,
"read": n.read,
"dismissed": n.dismissed,
"created_at": iso(n.created_at),
}
@router.get("")
def list_notifications(
include_dismissed: bool = False,
limit: int = Query(default=100, le=500),
offset: int = 0,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
where = [Notification.user_id == user.id]
if not include_dismissed:
where.append(Notification.dismissed.is_(False))
total = db.scalar(
select(func.count()).select_from(Notification).where(*where)
) or 0
rows = (
db.execute(
select(Notification)
.where(*where)
.order_by(Notification.created_at.desc())
.offset(offset)
.limit(limit)
)
.scalars()
.all()
)
return {"items": [_serialize(n) for n in rows], "total": total}
@router.get("/unread_count")
def unread_count(
user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict:
"""Cheap poll target for the nav badge (uses the (user_id, read) index)."""
count = db.scalar(
select(func.count())
.select_from(Notification)
.where(
Notification.user_id == user.id,
Notification.read.is_(False),
Notification.dismissed.is_(False),
)
)
return {"count": count or 0}
def _owned(db: Session, notification_id: int, user: User) -> Notification:
n = db.get(Notification, notification_id)
if n is None or n.user_id != user.id:
raise HTTPException(status_code=404, detail="Notification not found")
return n
@router.post("/{notification_id}/read")
def mark_read(
notification_id: int,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
n = _owned(db, notification_id, user)
n.read = True
db.commit()
trim_read(db, user.id)
return {"ok": True}
@router.post("/read_all")
def mark_all_read(
user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict:
db.execute(
update(Notification)
.where(Notification.user_id == user.id, Notification.read.is_(False))
.values(read=True)
)
db.commit()
trim_read(db, user.id)
return {"ok": True}
@router.post("/{notification_id}/dismiss")
def dismiss(
notification_id: int,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
n = _owned(db, notification_id, user)
# Dismiss implies read (it no longer counts toward the unread badge).
n.dismissed = True
n.read = True
db.commit()
return {"ok": True}
@router.post("/clear")
def clear_all(
user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict:
"""Dismiss every (still-visible) notification for this user."""
db.execute(
update(Notification)
.where(Notification.user_id == user.id, Notification.dismissed.is_(False))
.values(dismissed=True, read=True)
)
db.commit()
return {"ok": True}