Round-1 review finding #4 named only audit.py/plex.py, but the same misplaced `from app.utils import iso` sat in channels/downloads/feed/notifications too — the round missed them (ruff enforces no import order here, so the gate was silent). Move them to alphabetical position for consistency. No behaviour change.
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.notifications import trim_read
|
|
from app.utils import iso
|
|
|
|
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}
|