The owner-filter check missed an aliased() model, an import alias, .in_() and filter_by; four live inline filters on aliased VideoState go through owned_by now. Cross-unit writes are seen under any import name, through sa.update and via __table__. Core modules may commit only where CORE_COMMITS names the reason; trim_read and audit.gc flush now and their callers commit. The pin target, the debug-session header and a link's target fetch through owned_row. The docs claim what the fence holds, and say what is beyond it.
122 lines
3.6 KiB
Python
122 lines
3.6 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."""
|
|
|
|
from fastapi import APIRouter, Depends, Query
|
|
from sqlalchemy import func, select, update
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.userscope import owned_by
|
|
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 = [owned_by(Notification.user_id, user)]
|
|
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(
|
|
owned_by(Notification.user_id, user),
|
|
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
|
|
trim_read(db, user.id)
|
|
db.commit()
|
|
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(owned_by(Notification.user_id, user), Notification.read.is_(False))
|
|
.values(read=True)
|
|
)
|
|
trim_read(db, user.id)
|
|
db.commit()
|
|
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(owned_by(Notification.user_id, user), Notification.dismissed.is_(False))
|
|
.values(dismissed=True, read=True)
|
|
)
|
|
db.commit()
|
|
return {"ok": True}
|