Files
timemaster/backend/app/routers/hours_payouts.py
T
patrick 654258f13e security: M-2 HttpOnly-Cookie + M-4 TrustedHost-Warning + M-5 TOTP-Lockout + M-7 zentraler get_client_ip()
M-2: Refresh-Token als HttpOnly SameSite=Strict Cookie
- auth.py: _set_refresh_cookie/_delete_refresh_cookie Helpers
- Alle Auth-Endpoints (login, totp/login, refresh, logout) nutzen Cookie
- schemas/auth.py: refresh_token in Request/Response optional
- AuthContext.tsx: kein refresh_token in localStorage
- api/client.ts: credentials:include, kein Token-Body beim Refresh

M-4: TrustedHostMiddleware Warning in Production
- main.py: Startup-Warning wenn is_production + kein ALLOWED_HOSTS

M-5: TOTP-Fehlversuche Redis-Lockout
- auth.py: _check/_record/_clear_totp_lockout; 5 Versuche → 15 min Sperre

M-7: Zentraler get_client_ip()-Helper
- core/dependencies.py: get_client_ip() mit X-Real-IP → X-Forwarded-For → client.host
- hours_payouts.py, absences.py, busylight.py: request.client.host ersetzt

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

190 lines
6.8 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.
"""Stunden-Auszahlung: HR/Admin bucht Überstunden-Stunden zur Lohn-Auszahlung aus."""
from decimal import Decimal
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.hours_payout import HoursPayout
from app.models.overtime_balance import OvertimeBalance
from app.models.user import User, UserRole
from app.schemas.hours_payout import HoursPayoutCreate, HoursPayoutListResponse, HoursPayoutOut
router = APIRouter(tags=["Stunden-Auszahlung"])
_hr_roles = (UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
_all_roles = (UserRole.EMPLOYEE, UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
def _build_out(payout: HoursPayout, user: User | None, creator: User | None) -> HoursPayoutOut:
out = HoursPayoutOut.model_validate(payout)
out.user_name = (
f"{user.first_name} {user.last_name}" if user else str(payout.user_id)
)
out.created_by_name = (
f"{creator.first_name} {creator.last_name}" if creator else str(payout.created_by)
)
return out
# ── GET /hr/payouts ───────────────────────────────────────────────────────────
@router.get("/hr/payouts", response_model=HoursPayoutListResponse)
async def list_payouts(
user_id: UUID | None = Query(None),
year: int | None = Query(None, ge=2000, le=2100),
month: int | None = Query(None, ge=1, le=12),
current_user: User = require_role(*_all_roles),
db: AsyncSession = Depends(get_db),
):
"""Alle Auszahlungen der eigenen Firma, optional gefiltert nach Mitarbeiter / Monat.
EMPLOYEE und MANAGER sehen ausschließlich ihre eigenen Auszahlungen.
"""
# Employees und Manager sehen nur ihre eigenen Daten Query-Param wird ignoriert
if current_user.role not in _hr_roles:
user_id = current_user.id
filters = [HoursPayout.company_id == current_user.company_id]
if user_id is not None:
filters.append(HoursPayout.user_id == user_id)
if year is not None:
filters.append(HoursPayout.period_year == year)
if month is not None:
filters.append(HoursPayout.period_month == month)
total_count = await db.scalar(
select(func.count()).select_from(HoursPayout).where(*filters)
)
rows = list(await db.scalars(
select(HoursPayout).where(*filters).order_by(HoursPayout.created_at.desc())
))
result: list[HoursPayoutOut] = []
for payout in rows:
user = await db.get(User, payout.user_id)
creator = await db.get(User, payout.created_by)
result.append(_build_out(payout, user, creator))
return HoursPayoutListResponse(payouts=result, total_count=total_count or 0)
# ── POST /hr/payouts ──────────────────────────────────────────────────────────
@router.post("/hr/payouts", response_model=HoursPayoutOut, status_code=201)
async def create_payout(
request: Request,
data: HoursPayoutCreate,
current_user: User = require_role(*_hr_roles),
db: AsyncSession = Depends(get_db),
):
"""Neue Auszahlung anlegen reduziert sofort den Überstunden-Saldo."""
# Ziel-User prüfen
target = await db.get(User, data.user_id)
if not target or target.company_id != current_user.company_id:
raise HTTPException(404, "Mitarbeiter nicht gefunden")
# OvertimeBalance laden oder anlegen
ob = await db.scalar(
select(OvertimeBalance).where(OvertimeBalance.user_id == data.user_id)
)
if ob is None:
ob = OvertimeBalance(
user_id=data.user_id,
company_id=current_user.company_id,
total_hours=Decimal("0"),
taken_hours=Decimal("0"),
)
db.add(ob)
await db.flush() # id erzeugen
# Warnung bei Überziehung (kein Hard-Block)
hours = Decimal(str(data.hours))
if ob.available_hours < hours:
# Wir blockieren nicht Auszahlung trotzdem buchen (wie FZA mit overdraft)
pass
# Saldo anpassen
ob.taken_hours += hours
# Auszahlungs-Datensatz anlegen
payout = HoursPayout(
company_id=current_user.company_id,
user_id=data.user_id,
hours=hours,
period_year=data.period_year,
period_month=data.period_month,
note=data.note,
created_by=current_user.id,
)
db.add(payout)
await db.flush() # payout.id erzeugen
# AuditLog
db.add(AuditLog(
company_id=current_user.company_id,
user_id=current_user.id,
action="hours_payout_created",
entity_type="hours_payout",
entity_id=payout.id,
new_value={
"user_id": str(data.user_id),
"hours": str(hours),
"period_year": data.period_year,
"period_month": data.period_month,
"note": data.note,
},
ip=get_client_ip(request),
))
await db.commit()
await db.refresh(payout)
creator = await db.get(User, payout.created_by)
return _build_out(payout, target, creator)
# ── DELETE /hr/payouts/{payout_id} ───────────────────────────────────────────
@router.delete("/hr/payouts/{payout_id}", status_code=204)
async def delete_payout(
payout_id: UUID,
request: Request,
current_user: User = require_role(*_hr_roles),
db: AsyncSession = Depends(get_db),
):
"""Auszahlung stornieren stellt die Stunden in den Überstunden-Saldo zurück."""
payout = await db.get(HoursPayout, payout_id)
if payout is None or payout.company_id != current_user.company_id:
raise HTTPException(404, "Auszahlung nicht gefunden")
# OvertimeBalance laden und Stunden zurückbuchen
ob = await db.scalar(
select(OvertimeBalance).where(OvertimeBalance.user_id == payout.user_id)
)
if ob is not None:
ob.taken_hours = max(Decimal("0"), ob.taken_hours - payout.hours)
# AuditLog
db.add(AuditLog(
company_id=current_user.company_id,
user_id=current_user.id,
action="hours_payout_deleted",
entity_type="hours_payout",
entity_id=payout.id,
old_value={
"user_id": str(payout.user_id),
"hours": str(payout.hours),
"period_year": payout.period_year,
"period_month": payout.period_month,
"note": payout.note,
},
ip=get_client_ip(request),
))
await db.delete(payout)
await db.commit()