f19a6ccde8
Phase F — STL Hash Cache:
- Migration 041: step_file_hash column on cad_files
- cache_service.py: SHA256 hash + MinIO-backed STL cache (check/store)
- render_step_thumbnail: compute+persist hash before render
- generate_stl_cache: check MinIO cache before cadquery conversion, store after
Phase G — Invoices:
- Migration 042: invoices + invoice_lines tables with RLS
- Invoice/InvoiceLine models + schemas
- billing service: generate_invoice_number (INV-YYYY-NNNN), create/list/get/delete/PDF
- WeasyPrint PDF generation; backend Dockerfile + pyproject.toml deps
- invoice_router with 6 endpoints; registered in main.py
- frontend: Billing.tsx page + api/billing.ts; route + nav link
Phase H — Import Sanity Check:
- Migration 043: import_validations table
- ImportValidation model + schemas
- run_sanity_check: material fuzzy-match (cutoff=0.8), STEP availability, duplicate detection
- validate_excel_import Celery task (queue: step_processing)
- uploads.py: create ImportValidation on /excel, fire task, expose GET /validations/{id}
- frontend: Upload.tsx polling ValidationDialog with Ampel status indicators
Phase I — Notification Settings:
- Migration 044: notification_configs table (user×event×channel toggles)
- NotificationConfig model + seeds (in_app=true, email=false)
- get/upsert/reset config endpoints on /notifications/config
- frontend: NotificationSettings.tsx page + api/notifications.ts extensions
Infrastructure:
- docker-compose.yml: add worker-thumbnail service (concurrency=1, Q=thumbnail_rendering)
- Fix Dockerfile: libgdk-pixbuf-2.0-0 (correct Debian bookworm package name)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
194 lines
6.0 KiB
Python
194 lines
6.0 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
|
|
|
|
|
|
@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}
|
|
|
|
|
|
# ── 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'")
|
|
return await upsert_notification_config(db, current_user.id, event_type, channel, body.enabled)
|
|
|
|
|
|
@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)
|