Files
siftlode/backend/app/routes/notifications.py
T
peter e74986cc9a fix(playlists): a racing move is not a 500; ownership through owned_or_404 everywhere
move_item checked "already in the target?" and then re-parented without a
lock, so a second tab's add or the scheduler's YouTube merge committing the
same video in between hit uq_playlist_video and answered a raw 500. The
re-parent now runs in a savepoint and a conflict falls back to the
already-present outcome. The target is touched only when the row landed in
it (an untouched list was promoted in the rail's "recently changed" sort),
and _touch stamps the database clock like the mixin's own onupdate.

The canonical-patterns table names owned_or_404 against ad-hoc ownership
helpers; six were left (_own_playlist, _own_playlist_or_404, _own_view,
_own_tag, notifications._owned, downloads._own_job), two of them
hand-rolled with a bare English 404. All call sites now use owned_or_404,
and the download/link/notification 404s are structured like the rest. The
contract test reads every "what is gone" the routes name, and the YouTube
refusal reasons, and requires both languages to name each.
2026-09-19 07:01:18 +02:00

125 lines
3.8 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, 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.notifications import trim_read
from app.utils import iso
from app.routes._common import owned_or_404
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}
@router.post("/{notification_id}/read")
def mark_read(
notification_id: int,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
n = owned_or_404(db, Notification, notification_id, user, what="notification")
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_or_404(db, Notification, notification_id, user, what="notification")
# 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}