Files
timemaster/backend/app/routers/absences.py
T
patrick 06bb1c1664 feat: FZA Einzelstunden + Security-Fixes (K-1–K-5, H-2–H-4, M-1/M-3/M-6)
FZA Einzelstunden:
- Absence.fza_hours (Numeric 5,2) — FZA in Stunden statt Tagen
- Migration 0032: fza_hours Spalte in absences
- AbsenceCreate/AbsenceOut Schema um fza_hours erweitert
- absence_service: _deduct/_refund_overtime nutzt fza_hours direkt wenn gesetzt
- Frontend: Tage/Stunden-Toggle im FZA-Antrag-Modal

Security K-1: Privilege Escalation via PATCH /users/{id}.role
- user_service: Whitelist für Rollenänderungen, SUPER_ADMIN nur durch SUPER_ADMIN
- Letzter COMPANY_ADMIN gegen Selbst-Demotion gesichert

Security K-2: Kiosk-IP-Whitelist hinter nginx
- kiosk_security: _get_client_ip() liest X-Real-IP statt request.client.host

Security K-3: Kiosk-PIN Brute-Force-Schutz
- kiosk_auth_service: Redis-Lockout nach 5 Fehlversuchen (15 min)

Security K-4: TOTP-Setup-Hijacking
- auth router: /totp/setup abgelehnt wenn TOTP bereits aktiv

Security K-5: Separater Fernet-Key
- config: SECRET_KEY_DATA Feld (optional, Fallback auf SECRET_KEY)
- crypto: get_fernet_key() mit Warning bei fehlendem SECRET_KEY_DATA

Security H-2: Vacation Balance nur HR/Admin
- absences router: PATCH /balance nur noch HR/COMPANY_ADMIN/SUPER_ADMIN + AuditLog

Security H-3: Rate-Limits auf /auth/refresh + /auth/logout
- auth router: 30/min auf refresh, 60/min auf logout

Security H-4: Login-Failure-Logging + Lockout
- auth_service: Redis-Counter, Lockout nach 10 Versuchen (15 min)
- AuditLog für login_success und login_failed

Security M-1: Nginx Security-Header
- nginx.conf: X-Frame-Options, X-Content-Type-Options, CSP, Referrer-Policy, X-XSS-Protection, Permissions-Policy

Security M-3: AuditLog bei Rollenänderungen
- user_service: action=role_changed mit old/new role

Security M-6: create_all nur in Development
- main.py: Base.metadata.create_all nur wenn not settings.is_production

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 11:13:42 +02:00

427 lines
16 KiB
Python

from uuid import UUID
from fastapi import APIRouter, Depends, Query, Request
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)
# Verfall anwenden wenn nötig
from app.services.report_service import apply_overtime_expiry_if_needed
from app.models.company import Company as CompanyModel
company = await db.get(CompanyModel, current_user.company_id)
changed = await apply_overtime_expiry_if_needed(bal, company, db)
if changed:
await db.commit()
return OvertimeBalanceOut(
total_hours=float(bal.total_hours),
taken_hours=float(bal.taken_hours),
available_hours=float(bal.available_hours),
)
@router.post("/absences/overtime-balance/apply-expiry")
async def apply_overtime_expiry_all(
current_user: User = require_role(UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN),
db: AsyncSession = Depends(get_db),
):
"""Überstunden-Verfall manuell für alle Mitarbeiter der Firma anwenden."""
from sqlalchemy import select as sa_select
from app.models.company import Company as CompanyModel
from app.services.report_service import apply_overtime_expiry_if_needed
company = await db.get(CompanyModel, current_user.company_id)
balances = list(await db.scalars(
sa_select(OvertimeBalance).where(OvertimeBalance.company_id == current_user.company_id)
))
applied_count = 0
for bal in balances:
changed = await apply_overtime_expiry_if_needed(bal, company, db)
if changed:
applied_count += 1
await db.commit()
return {"applied_to": applied_count, "total": len(balances)}
@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}", response_model=AbsenceOut)
async def cancel_absence(
absence_id: UUID,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Antrag stornieren.
Eigene PENDING-Anträge: alle Rollen.
APPROVED-Anträge: nur HR/COMPANY_ADMIN/SUPER_ADMIN (mit Rückbuchung von FZA-Stunden).
"""
absence = await absence_service.cancel_absence(absence_id, current_user, db)
await db.commit()
return AbsenceOut.model_validate(absence)
class AbsenceApproveOut(AbsenceOut):
warnings: list[str] = []
@router.post("/absences/{absence_id}/approve", response_model=AbsenceApproveOut)
async def approve_absence(
absence_id: UUID,
current_user: User = require_role(*_manager_roles),
db: AsyncSession = Depends(get_db),
):
absence, warnings = await absence_service.approve_absence(absence_id, current_user, db)
await db.commit()
out = AbsenceApproveOut.model_validate(absence)
out.warnings = warnings
return out
@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,
request: Request,
current_user: User = require_role(UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN),
db: AsyncSession = Depends(get_db),
year: int = Query(...),
):
"""Grundurlaub, Sondertage und Resturlaub eines Mitarbeiters anpassen."""
from app.models.vacation_balance import VacationBalance
from app.models.audit_log import AuditLog
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)
# Alte Werte für AuditLog sichern
old_base = balance.base_days
old_special = balance.special_days
old_carried = balance.carried_over_days
for field, value in data.model_dump(exclude_unset=True).items():
setattr(balance, field, value)
# AuditLog schreiben
db.add(AuditLog(
user_id=current_user.id,
action="update_vacation_balance",
entity_type="vacation_balance",
entity_id=balance.id,
old_value={"base_days": old_base, "special_days": old_special, "carried_over_days": old_carried},
new_value={
"base_days": balance.base_days,
"special_days": balance.special_days,
"carried_over_days": balance.carried_over_days,
"target_user_id": str(user_id),
"year": year,
},
ip_address=request.client.host if request.client else None,
))
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,
})