Neuer PDF-Export GET /reports/datev-monthly/export: ein Blatt pro Mitarbeiter/Monat im Layout der DATEV-Stundenaufzeichnungs-Vorlage (Beginn/Pause/Ende/Dauer + K/U/UU/F/SA/SU-Kürzel + Bemerkungen + Summe + Unterschriftsfelder). Führt time_entries, Absences und Feiertage pro Kalendertag zusammen. Außerdem: Mustervorlagen für neue Backend-Module (Model/Schema/Router/ Migration/Test) und eine Frontend-Page-Vorlage, abgeleitet vom hours_payouts-Modul als aktuellstem sauberen Muster. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CviFgc739S89xS97pvjszj
169 lines
6.8 KiB
Python
169 lines
6.8 KiB
Python
"""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/<name>.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
|