feat: initial commit
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
"""Notification center API — list, count, mark-read."""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, update, or_, and_
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.user import User
|
||||
from app.utils.auth import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/notifications", tags=["notifications"])
|
||||
|
||||
|
||||
class NotificationOut(BaseModel):
|
||||
id: str
|
||||
action: str
|
||||
entity_type: str | None = None
|
||||
entity_id: str | None = None
|
||||
details: dict | None = None
|
||||
timestamp: datetime
|
||||
read_at: datetime | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class NotificationListResponse(BaseModel):
|
||||
items: list[NotificationOut]
|
||||
unread_count: int
|
||||
total: int
|
||||
|
||||
|
||||
class UnreadCountResponse(BaseModel):
|
||||
unread_count: int
|
||||
|
||||
|
||||
class MarkReadRequest(BaseModel):
|
||||
notification_ids: list[str] | None = None
|
||||
|
||||
|
||||
def _visibility_filter(user: User):
|
||||
"""Rows visible to this user: targeted at them, or broadcast (null) if admin/PM."""
|
||||
targeted = AuditLog.target_user_id == user.id
|
||||
if user.role.value in ("admin", "project_manager"):
|
||||
broadcast = AuditLog.target_user_id.is_(None)
|
||||
return and_(AuditLog.notification == True, or_(targeted, broadcast)) # noqa: E712
|
||||
return and_(AuditLog.notification == True, targeted) # noqa: E712
|
||||
|
||||
|
||||
@router.get("", response_model=NotificationListResponse)
|
||||
async def list_notifications(
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
unread_only: bool = Query(False),
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
vis = _visibility_filter(user)
|
||||
|
||||
# Total count
|
||||
total_q = select(func.count(AuditLog.id)).where(vis)
|
||||
if unread_only:
|
||||
total_q = total_q.where(AuditLog.read_at.is_(None))
|
||||
total = (await db.execute(total_q)).scalar() or 0
|
||||
|
||||
# Unread count (always)
|
||||
unread_q = select(func.count(AuditLog.id)).where(vis, AuditLog.read_at.is_(None))
|
||||
unread_count = (await db.execute(unread_q)).scalar() or 0
|
||||
|
||||
# Items
|
||||
items_q = (
|
||||
select(AuditLog)
|
||||
.where(vis)
|
||||
.order_by(AuditLog.timestamp.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
if unread_only:
|
||||
items_q = items_q.where(AuditLog.read_at.is_(None))
|
||||
|
||||
rows = (await db.execute(items_q)).scalars().all()
|
||||
items = [
|
||||
NotificationOut(
|
||||
id=str(r.id),
|
||||
action=r.action,
|
||||
entity_type=r.entity_type,
|
||||
entity_id=r.entity_id,
|
||||
details=r.details,
|
||||
timestamp=r.timestamp,
|
||||
read_at=r.read_at,
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
|
||||
return NotificationListResponse(items=items, unread_count=unread_count, total=total)
|
||||
|
||||
|
||||
@router.get("/unread-count", response_model=UnreadCountResponse)
|
||||
async def unread_count(
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
vis = _visibility_filter(user)
|
||||
q = select(func.count(AuditLog.id)).where(vis, AuditLog.read_at.is_(None))
|
||||
count = (await db.execute(q)).scalar() or 0
|
||||
return UnreadCountResponse(unread_count=count)
|
||||
|
||||
|
||||
@router.post("/mark-read")
|
||||
async def mark_read(
|
||||
body: MarkReadRequest,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Mark notifications as read. If notification_ids is null, mark all as read."""
|
||||
vis = _visibility_filter(user)
|
||||
now = datetime.utcnow()
|
||||
|
||||
if body.notification_ids is None:
|
||||
# Mark all unread
|
||||
stmt = (
|
||||
update(AuditLog)
|
||||
.where(vis, AuditLog.read_at.is_(None))
|
||||
.values(read_at=now)
|
||||
)
|
||||
else:
|
||||
ids = [uuid.UUID(nid) for nid in body.notification_ids]
|
||||
stmt = (
|
||||
update(AuditLog)
|
||||
.where(vis, AuditLog.id.in_(ids), AuditLog.read_at.is_(None))
|
||||
.values(read_at=now)
|
||||
)
|
||||
|
||||
await db.execute(stmt)
|
||||
await db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/{notification_id}/mark-read")
|
||||
async def mark_one_read(
|
||||
notification_id: uuid.UUID,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
vis = _visibility_filter(user)
|
||||
now = datetime.utcnow()
|
||||
result = await db.execute(
|
||||
update(AuditLog)
|
||||
.where(vis, AuditLog.id == notification_id, AuditLog.read_at.is_(None))
|
||||
.values(read_at=now)
|
||||
)
|
||||
await db.commit()
|
||||
return {"ok": True}
|
||||
Reference in New Issue
Block a user