Files
timemaster/backend/app/routers/time_entries.py
T
patrick dd3e069466 fix: router db.refresh() nach commit bricht RLS-Kontext
SET LOCAL Werte (bypass_rls, company_id) sind transaktions-gebunden.
Nach db.commit() ist der Kontext weg – ein nachfolgendes db.refresh()
läuft in einer neuen Transaktion ohne RLS-Kontext und liefert 0 Rows.

Da expire_on_commit=False gesetzt ist, sind alle Instanz-Attribute
nach dem Commit bereits im Speicher vorhanden. Die expliziten
db.refresh()-Aufrufe nach db.commit() in allen Routers sind daher
redundant und wurden entfernt.

test_rls.py: 6 neue Tests beweisen DB-seitige Mandanten-Isolation.
conftest.py: _apply_rls() wendet RLS-Policies auf Test-DB an.
migrations/0024: korrigiert auf op.execute(text()) API.
migrations/env.py: SET LOCAL außerhalb Transaktion entfernt.

Ergebnis: 8 failed (pre-existing), 126 passed – identisch zur Baseline vor RLS.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 22:34:48 +02:00

269 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from datetime import date
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.dependencies import CurrentUser, require_role
from app.models.time_entry import EntryStatus
from app.models.user import User, UserRole
from app.schemas.time_entry import (
BalanceResponse,
ManualEntryCreate,
RejectRequest,
StampInRequest,
StampOutRequest,
TimeEntryListResponse,
TimeEntryOut,
TimeEntryUpdate,
TimeEntryWithWarnings,
WorkScheduleCreate,
WorkScheduleOut,
)
from app.services.time_service import time_service
router = APIRouter(prefix="/time", tags=["Zeiterfassung"])
_manager_roles = (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
# ── Stempeluhr ────────────────────────────────────────────────────────────────
@router.post("/stamp-in", response_model=TimeEntryWithWarnings, status_code=201)
async def stamp_in(
data: StampInRequest,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Einstempeln startet einen neuen Zeiterfassungseintrag."""
entry, warnings = await time_service.stamp_in(current_user, data, db)
await db.commit()
return TimeEntryWithWarnings(entry=TimeEntryOut.model_validate(entry), warnings=warnings)
@router.post("/stamp-out", response_model=TimeEntryWithWarnings)
async def stamp_out(
data: StampOutRequest,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Ausstempeln schließt den offenen Zeiterfassungseintrag."""
entry, warnings = await time_service.stamp_out(current_user, data.note, db)
await db.commit()
return TimeEntryWithWarnings(entry=TimeEntryOut.model_validate(entry), warnings=warnings)
@router.post("/break-start", response_model=TimeEntryOut)
async def break_start(
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Pause beginnen."""
entry = await time_service.break_start(current_user, db)
await db.commit()
return TimeEntryOut.model_validate(entry)
@router.post("/break-end", response_model=TimeEntryOut)
async def break_end(
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Pause beenden."""
entry = await time_service.break_end(current_user, db)
await db.commit()
return TimeEntryOut.model_validate(entry)
# ── Heute ─────────────────────────────────────────────────────────────────────
@router.get("/today", response_model=list[TimeEntryOut])
async def get_today(
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Alle Einträge des heutigen Tages für den aktuellen Benutzer."""
entries = await time_service.get_today(current_user, db)
return [TimeEntryOut.model_validate(e) for e in entries]
# ── Einträge ──────────────────────────────────────────────────────────────────
@router.get("/entries", response_model=TimeEntryListResponse)
async def list_entries(
current_user: CurrentUser,
user_id: UUID | None = Query(None),
date_from: date | None = Query(None),
date_to: date | None = Query(None),
status: EntryStatus | None = Query(None),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
db: AsyncSession = Depends(get_db),
):
total, entries = await time_service.list_entries(
current_user.company_id, current_user, db,
user_id=user_id, date_from=date_from, date_to=date_to,
status=status, skip=skip, limit=limit,
)
return TimeEntryListResponse(total=total, items=[TimeEntryOut.model_validate(e) for e in entries])
@router.post("/entries", response_model=TimeEntryWithWarnings, status_code=201)
async def create_manual_entry(
data: ManualEntryCreate,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Manuellen Zeiterfassungseintrag anlegen."""
entry, warnings = await time_service.create_manual(data, current_user, db)
await db.commit()
return TimeEntryWithWarnings(entry=TimeEntryOut.model_validate(entry), warnings=warnings)
@router.patch("/entries/{entry_id}", response_model=TimeEntryOut)
async def update_entry(
entry_id: UUID,
data: TimeEntryUpdate,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Zeiterfassungseintrag korrigieren."""
entry = await time_service.update_entry(entry_id, data, current_user, db)
await db.commit()
return TimeEntryOut.model_validate(entry)
@router.post("/entries/{entry_id}/approve", response_model=TimeEntryOut)
async def approve_entry(
entry_id: UUID,
current_user: User = require_role(*_manager_roles),
db: AsyncSession = Depends(get_db),
):
"""Zeiterfassungseintrag genehmigen."""
entry = await time_service.approve_entry(entry_id, current_user, db)
await db.commit()
return TimeEntryOut.model_validate(entry)
@router.post("/entries/{entry_id}/reject", response_model=TimeEntryOut)
async def reject_entry(
entry_id: UUID,
data: RejectRequest,
current_user: User = require_role(*_manager_roles),
db: AsyncSession = Depends(get_db),
):
"""Zeiterfassungseintrag ablehnen."""
entry = await time_service.reject_entry(entry_id, current_user, data.rejection_note, db)
await db.commit()
return TimeEntryOut.model_validate(entry)
@router.delete("/entries/{entry_id}", status_code=204)
async def delete_entry(
entry_id: UUID,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Zeiteintrag löschen. Mitarbeiter: nur eigene offene/ausstehende Einträge. Manager: alle außer genehmigten (außer HR/Admin)."""
await time_service.delete_entry(entry_id, current_user, db)
await db.commit()
# ── Überstundenkonto ──────────────────────────────────────────────────────────
@router.get("/balance/me", response_model=BalanceResponse)
async def get_own_balance(
current_user: CurrentUser,
period_start: date | None = Query(None),
period_end: date | None = Query(None),
db: AsyncSession = Depends(get_db),
):
"""Eigenes Überstundenkonto."""
return await time_service.get_balance(current_user.id, current_user, db, period_start, period_end)
@router.get("/balance/{user_id}", response_model=BalanceResponse)
async def get_balance(
user_id: UUID,
current_user: CurrentUser,
period_start: date | None = Query(None),
period_end: date | None = Query(None),
db: AsyncSession = Depends(get_db),
):
"""Überstundenkonto für einen Benutzer."""
if user_id != current_user.id:
if current_user.role == UserRole.EMPLOYEE:
raise HTTPException(status_code=403, detail="Keine Berechtigung.")
target_user = await db.get(User, user_id)
if target_user is None or target_user.company_id != current_user.company_id:
raise HTTPException(status_code=404, detail="Mitarbeiter nicht gefunden.")
return await time_service.get_balance(user_id, current_user, db, period_start, period_end)
# ── Arbeitspläne ──────────────────────────────────────────────────────────────
@router.get("/schedules", response_model=list[WorkScheduleOut])
async def list_schedules(
current_user: User = require_role(*_manager_roles),
db: AsyncSession = Depends(get_db),
):
schedules = await time_service.list_work_schedules(current_user.company_id, db)
return [WorkScheduleOut.model_validate(s) for s in schedules]
@router.post("/schedules", response_model=WorkScheduleOut, status_code=201)
async def create_schedule(
data: WorkScheduleCreate,
current_user: User = require_role(UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN),
db: AsyncSession = Depends(get_db),
):
schedule = await time_service.create_work_schedule(current_user.company_id, data, db)
await db.commit()
return WorkScheduleOut.model_validate(schedule)
@router.patch("/schedules/{schedule_id}", response_model=WorkScheduleOut)
async def update_schedule(
schedule_id: UUID,
data: WorkScheduleCreate,
current_user: User = require_role(UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN),
db: AsyncSession = Depends(get_db),
):
from sqlalchemy import select as sa_select
from app.models.work_schedule import WorkSchedule
schedule = await db.scalar(
sa_select(WorkSchedule).where(
WorkSchedule.id == schedule_id,
WorkSchedule.company_id == current_user.company_id,
)
)
if not schedule:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Arbeitsplan nicht gefunden")
for field, value in data.model_dump().items():
setattr(schedule, field, value)
await db.commit()
return WorkScheduleOut.model_validate(schedule)
@router.delete("/schedules/{schedule_id}", status_code=204)
async def delete_schedule(
schedule_id: UUID,
current_user: User = require_role(UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN),
db: AsyncSession = Depends(get_db),
):
from sqlalchemy import select as sa_select
from app.models.work_schedule import WorkSchedule
schedule = await db.scalar(
sa_select(WorkSchedule).where(
WorkSchedule.id == schedule_id,
WorkSchedule.company_id == current_user.company_id,
)
)
if not schedule:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Arbeitsplan nicht gefunden")
await db.delete(schedule)
await db.commit()