22c29d5655
- Per-tenant Azure AI config stored in tenants.tenant_config JSONB
- GET/PUT /api/tenants/{id}/ai-config + POST .../test connection
- api_key never returned to frontend (has_api_key: bool pattern)
- azure_ai.py resolves creds from tenant config when ai_enabled=True
- ai_tasks.py loads tenant config and passes it to validate_thumbnail
- Admin GPU Status section: probe button + status badge + last-checked time
- Notifications: _BELL_CHANNELS filter (notification+alert only in bell)
- Tenants.tsx: per-row Azure AI Config modal with URL auto-parse helper
- Remove duplicate in-memory /gpu-probe endpoints (kept DB-backed /probe/gpu)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
209 lines
6.8 KiB
Python
209 lines
6.8 KiB
Python
"""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
|
|
|
|
|
|
# Default channels shown in bell dropdown
|
|
_BELL_CHANNELS = ("notification", "alert")
|
|
|
|
|
|
@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),
|
|
channel: Optional[str] = Query(None, description="Filter by channel: notification, activity, alert. Defaults to notification+alert."),
|
|
user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
vis = _visibility_filter(user)
|
|
|
|
# Channel filter
|
|
if channel:
|
|
channel_filter = AuditLog.channel == channel
|
|
else:
|
|
channel_filter = AuditLog.channel.in_(_BELL_CHANNELS)
|
|
|
|
# Total count
|
|
total_q = select(func.count(AuditLog.id)).where(vis, channel_filter)
|
|
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 — only for bell channels, not activity)
|
|
bell_channel_filter = AuditLog.channel.in_(_BELL_CHANNELS)
|
|
unread_q = select(func.count(AuditLog.id)).where(vis, bell_channel_filter, AuditLog.read_at.is_(None))
|
|
unread_count = (await db.execute(unread_q)).scalar() or 0
|
|
|
|
# Items
|
|
items_q = (
|
|
select(AuditLog)
|
|
.where(vis, channel_filter)
|
|
.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)
|
|
bell_channel_filter = AuditLog.channel.in_(_BELL_CHANNELS)
|
|
q = select(func.count(AuditLog.id)).where(vis, bell_channel_filter, 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}
|
|
|
|
|
|
# ── Notification Config Endpoints ────────────────────────────────────────────
|
|
from app.domains.notifications.schemas import NotificationConfigOut, NotificationConfigUpdate
|
|
from app.domains.notifications.service import (
|
|
get_notification_configs, upsert_notification_config, reset_notification_configs
|
|
)
|
|
|
|
|
|
@router.get("/config", response_model=list[NotificationConfigOut])
|
|
async def get_my_notification_config(
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
return await get_notification_configs(db, current_user.id)
|
|
|
|
|
|
@router.put("/config/{event_type}/{channel}", response_model=NotificationConfigOut)
|
|
async def update_my_notification_config(
|
|
event_type: str,
|
|
channel: str,
|
|
body: NotificationConfigUpdate,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
if channel not in ("in_app", "email"):
|
|
raise HTTPException(status_code=400, detail="channel must be 'in_app' or 'email'")
|
|
if body.frequency is not None and body.frequency not in ("immediate", "daily", "never"):
|
|
raise HTTPException(status_code=400, detail="frequency must be 'immediate', 'daily', or 'never'")
|
|
return await upsert_notification_config(db, current_user.id, event_type, channel, body.enabled, body.frequency)
|
|
|
|
|
|
@router.post("/config/reset", response_model=list[NotificationConfigOut])
|
|
async def reset_my_notification_config(
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
return await reset_notification_configs(db, current_user.id)
|