173 lines
5.4 KiB
Python
173 lines
5.4 KiB
Python
"""站内通知 API"""
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
import re
|
|
from typing import Any, Optional
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from sqlalchemy import and_, desc, func, select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from ..core.response import success_response
|
|
from ..core.database import get_db
|
|
from ..dependencies import get_current_user, get_tenant_id
|
|
from ..models import Notification, User
|
|
|
|
|
|
from ..utils.timezone import now_cn_naive
|
|
router = APIRouter(prefix="/notifications", tags=["notifications"])
|
|
|
|
|
|
PROJECT_TARGET_RE = re.compile(r"/projects/(\d+)")
|
|
|
|
|
|
def _extract_project_id(row: Notification) -> Optional[int]:
|
|
payload = row.payload if isinstance(row.payload, dict) else {}
|
|
payload_project_id = payload.get("project_id")
|
|
if payload_project_id is not None:
|
|
try:
|
|
return int(payload_project_id)
|
|
except (TypeError, ValueError):
|
|
pass
|
|
if row.entity_type == "project" and row.entity_id is not None:
|
|
return int(row.entity_id)
|
|
target_url = str(row.target_url or "").strip()
|
|
matched = PROJECT_TARGET_RE.search(target_url)
|
|
if matched:
|
|
try:
|
|
return int(matched.group(1))
|
|
except (TypeError, ValueError):
|
|
return None
|
|
return None
|
|
|
|
|
|
def _extract_project_name(row: Notification) -> Optional[str]:
|
|
payload = row.payload if isinstance(row.payload, dict) else {}
|
|
name = payload.get("project_name")
|
|
if isinstance(name, str) and name.strip():
|
|
return name.strip()
|
|
return None
|
|
|
|
|
|
def _serialize_notification(row: Notification) -> dict:
|
|
payload: Optional[dict[str, Any]] = row.payload if isinstance(row.payload, dict) else None
|
|
return {
|
|
"id": int(row.id),
|
|
"title": row.title,
|
|
"content": row.content,
|
|
"category": row.category,
|
|
"source": row.source,
|
|
"entity_type": row.entity_type,
|
|
"entity_id": int(row.entity_id) if row.entity_id is not None else None,
|
|
"target_url": row.target_url,
|
|
"kind": row.kind or "info",
|
|
"priority": row.priority,
|
|
"status": row.status or row.kind or "info",
|
|
"blocking_reason": row.blocking_reason,
|
|
"recommended_action": row.recommended_action,
|
|
"dedupe_key": row.dedupe_key,
|
|
"occurrence_count": int(row.occurrence_count or 1),
|
|
"payload": payload,
|
|
"project_id": _extract_project_id(row),
|
|
"project_name": _extract_project_name(row),
|
|
"is_read": bool(row.is_read),
|
|
"read_at": row.read_at.isoformat() if row.read_at else None,
|
|
"created_at": row.created_at.isoformat() if row.created_at else None,
|
|
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
|
}
|
|
|
|
|
|
@router.get("")
|
|
async def list_notifications(
|
|
unread_only: bool = Query(default=False),
|
|
limit: int = Query(default=20, ge=1, le=100),
|
|
current_user: User = Depends(get_current_user),
|
|
tenant_id: int = Depends(get_tenant_id),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
query = select(Notification).where(
|
|
and_(
|
|
Notification.tenant_id == tenant_id,
|
|
Notification.user_id == int(current_user.id),
|
|
)
|
|
)
|
|
if unread_only:
|
|
query = query.where(Notification.is_read.is_(False))
|
|
query = query.order_by(desc(Notification.updated_at), desc(Notification.created_at)).limit(limit)
|
|
|
|
rows = (await db.execute(query)).scalars().all()
|
|
unread_count = await db.scalar(
|
|
select(func.count(Notification.id)).where(
|
|
and_(
|
|
Notification.tenant_id == tenant_id,
|
|
Notification.user_id == int(current_user.id),
|
|
Notification.is_read.is_(False),
|
|
)
|
|
)
|
|
)
|
|
return success_response(
|
|
data={
|
|
"items": [_serialize_notification(row) for row in rows],
|
|
"unread_count": int(unread_count or 0),
|
|
}
|
|
)
|
|
|
|
|
|
@router.post("/{notification_id}/read")
|
|
async def mark_notification_read(
|
|
notification_id: int,
|
|
current_user: User = Depends(get_current_user),
|
|
tenant_id: int = Depends(get_tenant_id),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
row = (
|
|
await db.execute(
|
|
select(Notification).where(
|
|
and_(
|
|
Notification.id == notification_id,
|
|
Notification.tenant_id == tenant_id,
|
|
Notification.user_id == int(current_user.id),
|
|
)
|
|
)
|
|
)
|
|
).scalar_one_or_none()
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="通知不存在")
|
|
|
|
if not row.is_read:
|
|
row.is_read = True
|
|
row.read_at = now_cn_naive()
|
|
await db.commit()
|
|
|
|
return success_response(data=_serialize_notification(row))
|
|
|
|
|
|
@router.post("/read-all")
|
|
async def mark_all_notifications_read(
|
|
current_user: User = Depends(get_current_user),
|
|
tenant_id: int = Depends(get_tenant_id),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
rows = (
|
|
await db.execute(
|
|
select(Notification).where(
|
|
and_(
|
|
Notification.tenant_id == tenant_id,
|
|
Notification.user_id == int(current_user.id),
|
|
Notification.is_read.is_(False),
|
|
)
|
|
)
|
|
)
|
|
).scalars().all()
|
|
|
|
now = now_cn_naive()
|
|
for row in rows:
|
|
row.is_read = True
|
|
row.read_at = now
|
|
|
|
if rows:
|
|
await db.commit()
|
|
|
|
return success_response(data={"updated": len(rows)})
|