dd3e069466
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>
359 lines
13 KiB
Python
359 lines
13 KiB
Python
from uuid import UUID
|
|
|
|
from fastapi import APIRouter, Depends, 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.absence import AbsenceStatus
|
|
from app.models.user import User, UserRole
|
|
from app.models.overtime_balance import OvertimeBalance
|
|
from app.schemas.absence import (
|
|
AbsenceCreate,
|
|
AbsenceListResponse,
|
|
AbsenceOut,
|
|
AbsenceReject,
|
|
AbsenceUpdate,
|
|
AbsenceTypeCreate,
|
|
AbsenceTypeOut,
|
|
AbsenceTypeUpdate,
|
|
CalendarEntry,
|
|
CertificateMarkIn,
|
|
OvertimeBalanceOut,
|
|
PublicHolidayCreate,
|
|
PublicHolidayOut,
|
|
QuickSickIn,
|
|
SickStatsOut,
|
|
VacationBalanceOut,
|
|
VacationBalanceUpdate,
|
|
)
|
|
from app.services.absence_service import absence_service
|
|
from app.models.company import Company
|
|
from sqlalchemy import select
|
|
from datetime import date
|
|
|
|
router = APIRouter(tags=["Abwesenheiten"])
|
|
|
|
|
|
def _carryover_expiry(company: Company, year: int) -> tuple[date | None, bool]:
|
|
"""Verfallsdatum für Resturlaub berechnen.
|
|
Gibt (expires_at, is_expired) zurück. None wenn kein Verfall konfiguriert."""
|
|
s = company.settings or {}
|
|
month = s.get("carryover_expires_month")
|
|
day = s.get("carryover_expires_day")
|
|
if not month or not day:
|
|
return None, False
|
|
try:
|
|
expires_at = date(year, int(month), int(day))
|
|
return expires_at, date.today() > expires_at
|
|
except ValueError:
|
|
return None, False
|
|
|
|
_admin_roles = (UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
|
|
_manager_roles = (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
|
|
|
|
|
|
# ── Absence Types ─────────────────────────────────────────────────────────────
|
|
|
|
@router.get("/absence-types/", response_model=list[AbsenceTypeOut])
|
|
async def list_absence_types(
|
|
current_user: CurrentUser,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
types = await absence_service.list_types(current_user.company_id, db)
|
|
return [AbsenceTypeOut.model_validate(t) for t in types]
|
|
|
|
|
|
@router.post("/absence-types/", response_model=AbsenceTypeOut, status_code=201)
|
|
async def create_absence_type(
|
|
data: AbsenceTypeCreate,
|
|
current_user: User = require_role(*_admin_roles),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
at = await absence_service.create_type(current_user.company_id, data, db)
|
|
await db.commit()
|
|
return AbsenceTypeOut.model_validate(at)
|
|
|
|
|
|
@router.patch("/absence-types/{type_id}", response_model=AbsenceTypeOut)
|
|
async def update_absence_type(
|
|
type_id: UUID,
|
|
data: AbsenceTypeUpdate,
|
|
current_user: User = require_role(*_admin_roles),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
at = await absence_service.update_type(type_id, current_user.company_id, data, db)
|
|
await db.commit()
|
|
return AbsenceTypeOut.model_validate(at)
|
|
|
|
|
|
# ── Public Holidays ───────────────────────────────────────────────────────────
|
|
|
|
@router.get("/public-holidays/", response_model=list[PublicHolidayOut])
|
|
async def list_public_holidays(
|
|
current_user: CurrentUser,
|
|
year: int = Query(...),
|
|
country: str = Query("DE"),
|
|
state: str | None = Query(None),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
holidays = await absence_service.list_holidays(year, country, state, db)
|
|
return [PublicHolidayOut.model_validate(h) for h in holidays]
|
|
|
|
|
|
@router.post("/public-holidays/", response_model=PublicHolidayOut, status_code=201)
|
|
async def create_public_holiday(
|
|
data: PublicHolidayCreate,
|
|
current_user: User = require_role(*_admin_roles),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
holiday = await absence_service.create_holiday(data, db)
|
|
await db.commit()
|
|
return PublicHolidayOut.model_validate(holiday)
|
|
|
|
|
|
# ── Absences ──────────────────────────────────────────────────────────────────
|
|
|
|
@router.get("/absences/calendar", response_model=list[CalendarEntry])
|
|
async def get_calendar(
|
|
current_user: CurrentUser,
|
|
year: int = Query(...),
|
|
month: int | None = Query(None, ge=1, le=12),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Team-Kalender: alle Abwesenheiten im Zeitraum."""
|
|
entries = await absence_service.get_calendar(current_user.company_id, year, month, db)
|
|
return [CalendarEntry(**e) for e in entries]
|
|
|
|
|
|
@router.get("/absences/balance", response_model=VacationBalanceOut)
|
|
async def get_own_balance(
|
|
current_user: CurrentUser,
|
|
year: int = Query(...),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Eigenes Urlaubskonto."""
|
|
balance = await absence_service.get_balance(current_user.id, year, db)
|
|
pending = await absence_service.get_pending_days(current_user.id, year, db)
|
|
company = await db.get(Company, current_user.company_id)
|
|
expires_at, expired = _carryover_expiry(company, year) if company else (None, False)
|
|
return VacationBalanceOut.model_validate(balance).model_copy(update={
|
|
"pending_days": pending,
|
|
"carried_over_expires_at": expires_at,
|
|
"carried_over_expired": expired,
|
|
})
|
|
|
|
|
|
@router.get("/absences/balance/{user_id}", response_model=VacationBalanceOut)
|
|
async def get_balance_for_user(
|
|
user_id: UUID,
|
|
year: int = Query(...),
|
|
current_user: User = require_role(*_manager_roles),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Urlaubskonto eines Mitarbeiters (MANAGER/HR/ADMIN)."""
|
|
target_user = await db.get(User, user_id)
|
|
if target_user is None or target_user.company_id != current_user.company_id:
|
|
from fastapi import HTTPException
|
|
raise HTTPException(status_code=404, detail="Mitarbeiter nicht gefunden.")
|
|
balance = await absence_service.get_balance(user_id, year, db)
|
|
pending = await absence_service.get_pending_days(user_id, year, db)
|
|
company = await db.get(Company, current_user.company_id)
|
|
expires_at, expired = _carryover_expiry(company, year) if company else (None, False)
|
|
return VacationBalanceOut.model_validate(balance).model_copy(update={
|
|
"pending_days": pending,
|
|
"carried_over_expires_at": expires_at,
|
|
"carried_over_expired": expired,
|
|
})
|
|
|
|
|
|
@router.post("/absences/quick-sick", response_model=AbsenceOut, status_code=201)
|
|
async def quick_sick(
|
|
data: QuickSickIn,
|
|
current_user: CurrentUser,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Sofort-Krankmeldung (auto-approved). Nutzt den ersten aktiven SICK-Typ der Firma."""
|
|
absence, _ = await absence_service.quick_sick(
|
|
data.start_date, data.end_date, current_user, db
|
|
)
|
|
await db.commit()
|
|
return AbsenceOut.model_validate(absence)
|
|
|
|
|
|
@router.get("/absences/sick-stats", response_model=list[SickStatsOut])
|
|
async def get_sick_stats(
|
|
current_user: User = require_role(*_manager_roles),
|
|
user_id: UUID | None = Query(None),
|
|
ref_date: date | None = Query(None, description="Stichtag, default heute"),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Krankheitsstatistik (rolling 12 Monate ab ref_date) inkl. Bradford-Faktor."""
|
|
stats = await absence_service.get_sick_stats(
|
|
company_id=current_user.company_id,
|
|
current_user=current_user,
|
|
ref_date=ref_date or date.today(),
|
|
db=db,
|
|
user_id=user_id,
|
|
)
|
|
return [SickStatsOut(**s) for s in stats]
|
|
|
|
|
|
@router.get("/absences/overtime-balance", response_model=OvertimeBalanceOut)
|
|
async def get_overtime_balance(
|
|
current_user: CurrentUser,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Eigenes Überstunden-Konto."""
|
|
bal = await db.scalar(
|
|
select(OvertimeBalance).where(OvertimeBalance.user_id == current_user.id)
|
|
)
|
|
if bal is None:
|
|
return OvertimeBalanceOut(total_hours=0, taken_hours=0, available_hours=0)
|
|
return OvertimeBalanceOut(
|
|
total_hours=float(bal.total_hours),
|
|
taken_hours=float(bal.taken_hours),
|
|
available_hours=float(bal.available_hours),
|
|
)
|
|
|
|
|
|
@router.get("/absences/", response_model=AbsenceListResponse)
|
|
async def list_absences(
|
|
current_user: CurrentUser,
|
|
user_id: UUID | None = Query(None),
|
|
type_id: UUID | None = Query(None),
|
|
status: AbsenceStatus | None = Query(None),
|
|
year: int | None = Query(None),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
total, absences = await absence_service.list_absences(
|
|
current_user.company_id, current_user, db,
|
|
user_id=user_id, type_id=type_id, status=status, year=year,
|
|
)
|
|
return AbsenceListResponse(total=total, items=[AbsenceOut.model_validate(a) for a in absences])
|
|
|
|
|
|
@router.post("/absences/", response_model=AbsenceOut, status_code=201)
|
|
async def create_absence(
|
|
data: AbsenceCreate,
|
|
current_user: CurrentUser,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Abwesenheitsantrag stellen. HR/Admin kann for_user_id setzen um für andere anzulegen."""
|
|
acting_user = current_user
|
|
if data.for_user_id and data.for_user_id != current_user.id:
|
|
if current_user.role not in _manager_roles:
|
|
from fastapi import HTTPException
|
|
raise HTTPException(status_code=403, detail="Keine Berechtigung, Abwesenheiten für andere anzulegen.")
|
|
target = await db.get(User, data.for_user_id)
|
|
if not target or target.company_id != current_user.company_id:
|
|
from fastapi import HTTPException
|
|
raise HTTPException(status_code=404, detail="Mitarbeiter nicht gefunden.")
|
|
acting_user = target
|
|
absence, warnings = await absence_service.create_absence(data, acting_user, db)
|
|
await db.commit()
|
|
return AbsenceOut.model_validate(absence)
|
|
|
|
|
|
@router.patch("/absences/{absence_id}", response_model=AbsenceOut)
|
|
async def update_absence(
|
|
absence_id: UUID,
|
|
data: AbsenceUpdate,
|
|
current_user: CurrentUser,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Ausstehenden Antrag bearbeiten (Mitarbeiter: eigene; Manager: alle der Company)."""
|
|
absence = await absence_service.update_absence(absence_id, data, current_user, db)
|
|
await db.commit()
|
|
return AbsenceOut.model_validate(absence)
|
|
|
|
|
|
@router.get("/absences/{absence_id}", response_model=AbsenceOut)
|
|
async def get_absence(
|
|
absence_id: UUID,
|
|
current_user: CurrentUser,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
absence = await absence_service.get_by_id(absence_id, current_user, db)
|
|
return AbsenceOut.model_validate(absence)
|
|
|
|
|
|
@router.delete("/absences/{absence_id}", status_code=204)
|
|
async def cancel_absence(
|
|
absence_id: UUID,
|
|
current_user: CurrentUser,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Eigenen ausstehenden Antrag stornieren."""
|
|
await absence_service.cancel_absence(absence_id, current_user, db)
|
|
await db.commit()
|
|
|
|
|
|
@router.post("/absences/{absence_id}/approve", response_model=AbsenceOut)
|
|
async def approve_absence(
|
|
absence_id: UUID,
|
|
current_user: User = require_role(*_manager_roles),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
absence = await absence_service.approve_absence(absence_id, current_user, db)
|
|
await db.commit()
|
|
return AbsenceOut.model_validate(absence)
|
|
|
|
|
|
@router.post("/absences/{absence_id}/reject", response_model=AbsenceOut)
|
|
async def reject_absence(
|
|
absence_id: UUID,
|
|
data: AbsenceReject,
|
|
current_user: User = require_role(*_manager_roles),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
absence = await absence_service.reject_absence(absence_id, data, current_user, db)
|
|
await db.commit()
|
|
return AbsenceOut.model_validate(absence)
|
|
|
|
|
|
@router.patch("/absences/{absence_id}/certificate", response_model=AbsenceOut)
|
|
async def mark_certificate_received(
|
|
absence_id: UUID,
|
|
data: CertificateMarkIn,
|
|
current_user: User = require_role(UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""HR/Admin markiert die AU-Bescheinigung als eingegangen."""
|
|
absence = await absence_service.mark_certificate_received(
|
|
absence_id, data.received_at, current_user, db
|
|
)
|
|
await db.commit()
|
|
return AbsenceOut.model_validate(absence)
|
|
|
|
|
|
# ── Urlaubskonto bearbeiten (HR/Admin) ────────────────────────────────────────
|
|
|
|
@router.patch("/absences/balance/{user_id}", response_model=VacationBalanceOut)
|
|
async def update_balance(
|
|
user_id: UUID,
|
|
data: VacationBalanceUpdate,
|
|
current_user: User = require_role(*_manager_roles),
|
|
db: AsyncSession = Depends(get_db),
|
|
year: int = Query(...),
|
|
):
|
|
"""Grundurlaub, Sondertage und Resturlaub eines Mitarbeiters anpassen."""
|
|
from app.models.vacation_balance import VacationBalance
|
|
target = await db.get(User, user_id)
|
|
if target is None or target.company_id != current_user.company_id:
|
|
from fastapi import HTTPException
|
|
raise HTTPException(404, "Mitarbeiter nicht gefunden")
|
|
|
|
balance = await absence_service.get_balance(user_id, year, db)
|
|
for field, value in data.model_dump(exclude_unset=True).items():
|
|
setattr(balance, field, value)
|
|
await db.commit()
|
|
pending = await absence_service.get_pending_days(user_id, year, db)
|
|
company = await db.get(Company, current_user.company_id)
|
|
expires_at, expired = _carryover_expiry(company, year) if company else (None, False)
|
|
return VacationBalanceOut.model_validate(balance).model_copy(update={
|
|
"pending_days": pending,
|
|
"carried_over_expires_at": expires_at,
|
|
"carried_over_expired": expired,
|
|
})
|