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).
138 lines
3.9 KiB
Python
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}
|