Files
HartOMat/backend/app/api/routers/notifications.py
T
Hartmut f19a6ccde8 feat(F-G-H-I): STL cache, invoices, import validation, notification settings
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>
2026-03-06 18:05:01 +01:00

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)