"""TEMPLATE – Mustervorlage für einen neuen Router (Modul mit Workflow-Status). Abgeleitet vom aktuellsten sauberen Muster im Projekt: app/routers/hours_payouts.py. Kern-Regeln (aus CLAUDE.md + gelernte Fallstricke): - Company-Isolation: JEDE Query filtert auf company_id == current_user.company_id (zusätzlich zur DB-seitigen RLS – Python-Check ist die zweite Verteidigungslinie) - **RLS-Bypass + mid-request commit Falle**: Response-Objekt (_build_out) IMMER VOR db.commit() bauen. db.refresh() nur nach db.flush() (noch in Transaktion). Nach commit() ist app.bypass_rls/app.company_id verfallen -> Post-Commit-Reads sehen ggf. keine Zeilen mehr. - AuditLog bei JEDER schreibenden Aktion (create/approve/reject/cancel/delete) - HTTPException mit sprechendem detail, kein Hard-Delete - require_role() aus core.dependencies für Rollenprüfung Anleitung: 1. Nach app/routers/.py kopieren, XxxThing/xxx_things ersetzen 2. In app/main.py registrieren: app.include_router(xxx_things.router, prefix="/api/v1") 3. Model + Schema aus model_template.py / schema_template.py zuerst anlegen 4. Migration schreiben (Alembic) inkl. RLS-Policy + conftest.py nachziehen 5. Tests in backend/tests/test_xxx_things.py (siehe test_hours_payouts.py als Muster) """ from datetime import datetime from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Query, Request from sqlalchemy import select, func from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_db from app.core.dependencies import get_client_ip, require_role from app.models.audit_log import AuditLog from app.models.user import User, UserRole from app.models.xxx_thing import XxxThing, XxxThingStatus # TODO: Model anlegen from app.schemas.xxx_thing import ( # TODO: Schemas anlegen XxxThingCreate, XxxThingListResponse, XxxThingOut, XxxThingReject, ) router = APIRouter(tags=["Xxx-Thing"]) _hr_roles = (UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN) _all_roles = (UserRole.EMPLOYEE, UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN) async def _build_out(item: XxxThing, db: AsyncSession) -> XxxThingOut: """Response-Objekt VOR commit() bauen – siehe RLS-Bypass-Falle oben.""" out = XxxThingOut.model_validate(item) user = await db.get(User, item.user_id) out.user_name = f"{user.first_name} {user.last_name}" if user else str(item.user_id) return out # ── GET /xxx-things ──────────────────────────────────────────────────────── @router.get("/xxx-things", response_model=XxxThingListResponse) async def list_items( status: str | None = Query(None), current_user: User = require_role(*_all_roles), db: AsyncSession = Depends(get_db), ): """Liste der eigenen Firma. EMPLOYEE/MANAGER sehen nur eigene Einträge.""" filters = [XxxThing.company_id == current_user.company_id] if current_user.role not in _hr_roles: filters.append(XxxThing.user_id == current_user.id) if status is not None: filters.append(XxxThing.status == status) total_count = await db.scalar(select(func.count()).select_from(XxxThing).where(*filters)) rows = list(await db.scalars( select(XxxThing).where(*filters).order_by(XxxThing.created_at.desc()) )) result = [await _build_out(row, db) for row in rows] return XxxThingListResponse(items=result, total_count=total_count or 0) # ── POST /xxx-things ─────────────────────────────────────────────────────── @router.post("/xxx-things", response_model=XxxThingOut, status_code=201) async def create_item( request: Request, data: XxxThingCreate, current_user: User = require_role(*_all_roles), db: AsyncSession = Depends(get_db), ): item = XxxThing( company_id=current_user.company_id, user_id=data.user_id, note=data.note, status=XxxThingStatus.REQUESTED.value, created_by=current_user.id, ) db.add(item) await db.flush() await db.refresh(item) # server_default created_at – noch in-Transaktion db.add(AuditLog( company_id=current_user.company_id, user_id=current_user.id, action="xxx_thing_created", entity_type="xxx_thing", entity_id=item.id, new_value={"note": data.note}, ip=get_client_ip(request), )) out = await _build_out(item, db) await db.commit() return out # ── POST /xxx-things/{id}/approve ────────────────────────────────────────── @router.post("/xxx-things/{item_id}/approve", response_model=XxxThingOut) async def approve_item( item_id: UUID, request: Request, current_user: User = require_role(*_hr_roles), db: AsyncSession = Depends(get_db), ): item = await db.get(XxxThing, item_id) if item is None or item.company_id != current_user.company_id: raise HTTPException(404, "Eintrag nicht gefunden") if item.status != XxxThingStatus.REQUESTED.value: raise HTTPException(409, "Nur offene Anträge können genehmigt werden.") item.status = XxxThingStatus.APPROVED.value item.decided_by = current_user.id item.decided_at = datetime.utcnow() db.add(AuditLog( company_id=current_user.company_id, user_id=current_user.id, action="xxx_thing_approved", entity_type="xxx_thing", entity_id=item.id, ip=get_client_ip(request), )) out = await _build_out(item, db) await db.commit() return out # ── POST /xxx-things/{id}/reject ─────────────────────────────────────────── @router.post("/xxx-things/{item_id}/reject", response_model=XxxThingOut) async def reject_item( item_id: UUID, request: Request, data: XxxThingReject, current_user: User = require_role(*_hr_roles), db: AsyncSession = Depends(get_db), ): item = await db.get(XxxThing, item_id) if item is None or item.company_id != current_user.company_id: raise HTTPException(404, "Eintrag nicht gefunden") if item.status != XxxThingStatus.REQUESTED.value: raise HTTPException(409, "Nur offene Anträge können abgelehnt werden.") item.status = XxxThingStatus.REJECTED.value item.rejection_reason = data.rejection_reason item.decided_by = current_user.id item.decided_at = datetime.utcnow() db.add(AuditLog( company_id=current_user.company_id, user_id=current_user.id, action="xxx_thing_rejected", entity_type="xxx_thing", entity_id=item.id, new_value={"rejection_reason": data.rejection_reason}, ip=get_client_ip(request), )) out = await _build_out(item, db) await db.commit() return out