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.
125 lines
3.8 KiB
Python
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}
|