06bb1c1664
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>
221 lines
7.2 KiB
Python
221 lines
7.2 KiB
Python
import uuid
|
|
from datetime import date, datetime
|
|
from decimal import Decimal
|
|
|
|
from pydantic import BaseModel, Field, model_validator
|
|
|
|
from app.models.absence import AbsenceStatus
|
|
from app.models.absence_type import AbsenceCategory
|
|
|
|
|
|
# ── AbsenceType ───────────────────────────────────────────────────────────────
|
|
|
|
class AbsenceTypeOut(BaseModel):
|
|
model_config = {"from_attributes": True}
|
|
|
|
id: uuid.UUID
|
|
company_id: uuid.UUID
|
|
name: str
|
|
color: str
|
|
category: AbsenceCategory
|
|
requires_approval: bool
|
|
deducts_vacation: bool
|
|
affects_overtime_balance: bool
|
|
requires_certificate: bool
|
|
certificate_after_days: int
|
|
is_paid: bool
|
|
max_days_per_year: int | None
|
|
is_active: bool
|
|
|
|
|
|
class AbsenceTypeCreate(BaseModel):
|
|
name: str = Field(min_length=1, max_length=255)
|
|
color: str = Field("#3B82F6", pattern=r"^#[0-9A-Fa-f]{6}$")
|
|
category: AbsenceCategory = AbsenceCategory.OTHER
|
|
requires_approval: bool = True
|
|
deducts_vacation: bool = False
|
|
affects_overtime_balance: bool = False
|
|
requires_certificate: bool = False
|
|
certificate_after_days: int = Field(3, ge=0, le=365)
|
|
is_paid: bool = True
|
|
max_days_per_year: int | None = Field(None, ge=1)
|
|
|
|
|
|
class AbsenceTypeUpdate(BaseModel):
|
|
name: str | None = Field(None, min_length=1, max_length=255)
|
|
color: str | None = Field(None, pattern=r"^#[0-9A-Fa-f]{6}$")
|
|
category: AbsenceCategory | None = None
|
|
requires_approval: bool | None = None
|
|
deducts_vacation: bool | None = None
|
|
affects_overtime_balance: bool | None = None
|
|
requires_certificate: bool | None = None
|
|
certificate_after_days: int | None = Field(None, ge=0, le=365)
|
|
is_paid: bool | None = None
|
|
max_days_per_year: int | None = Field(None, ge=1)
|
|
is_active: bool | None = None
|
|
|
|
|
|
# ── Absence ───────────────────────────────────────────────────────────────────
|
|
|
|
class AbsenceOut(BaseModel):
|
|
model_config = {"from_attributes": True}
|
|
|
|
id: uuid.UUID
|
|
user_id: uuid.UUID
|
|
type_id: uuid.UUID
|
|
start_date: date
|
|
end_date: date
|
|
half_day_start: bool
|
|
half_day_end: bool
|
|
working_days: float
|
|
fza_hours: Decimal | None = None
|
|
status: AbsenceStatus
|
|
approved_by: uuid.UUID | None
|
|
substitute_id: uuid.UUID | None
|
|
note: str | None
|
|
correction_note: str | None
|
|
rejection_reason: str | None
|
|
certificate_required_by: date | None = None
|
|
certificate_received_at: date | None = None
|
|
created_at: datetime
|
|
|
|
|
|
class AbsenceCreate(BaseModel):
|
|
type_id: uuid.UUID
|
|
start_date: date
|
|
end_date: date
|
|
half_day_start: bool = False
|
|
half_day_end: bool = False
|
|
substitute_id: uuid.UUID | None = None
|
|
note: str | None = None
|
|
for_user_id: uuid.UUID | None = None # HR/Admin: Abwesenheit für anderen Mitarbeiter anlegen
|
|
fza_hours: Decimal | None = Field(
|
|
None,
|
|
ge=Decimal("0.25"),
|
|
le=Decimal("24"),
|
|
description="FZA in Stunden (statt Tagen); nur bei eintägigem Zeitraum erlaubt.",
|
|
)
|
|
|
|
def model_post_init(self, __context) -> None:
|
|
if self.end_date < self.start_date:
|
|
raise ValueError("end_date must be >= start_date")
|
|
if self.fza_hours is not None and self.start_date != self.end_date:
|
|
raise ValueError("fza_hours ist nur erlaubt wenn start_date == end_date (eintägiger FZA).")
|
|
|
|
|
|
class AbsenceUpdate(BaseModel):
|
|
type_id: uuid.UUID | None = None
|
|
start_date: date | None = None
|
|
end_date: date | None = None
|
|
half_day_start: bool | None = None
|
|
half_day_end: bool | None = None
|
|
substitute_id: uuid.UUID | None = None
|
|
note: str | None = None
|
|
correction_note: str | None = None # Pflicht bei Änderung genehmigter Anträge (Mitarbeiter)
|
|
|
|
def model_post_init(self, __context) -> None:
|
|
if self.start_date and self.end_date and self.end_date < self.start_date:
|
|
raise ValueError("end_date must be >= start_date")
|
|
|
|
|
|
class AbsenceReject(BaseModel):
|
|
rejection_reason: str = Field(min_length=1)
|
|
|
|
|
|
class AbsenceListResponse(BaseModel):
|
|
total: int
|
|
items: list[AbsenceOut]
|
|
|
|
|
|
# ── Krankmeldung ──────────────────────────────────────────────────────────────
|
|
|
|
class QuickSickIn(BaseModel):
|
|
start_date: date
|
|
end_date: date
|
|
|
|
def model_post_init(self, __context) -> None:
|
|
if self.end_date < self.start_date:
|
|
raise ValueError("end_date must be >= start_date")
|
|
|
|
|
|
class CertificateMarkIn(BaseModel):
|
|
received_at: date | None = None # default = heute
|
|
|
|
|
|
class SickStatsOut(BaseModel):
|
|
user_id: uuid.UUID
|
|
user_name: str
|
|
personnel_number: str | None = None
|
|
episodes: int
|
|
total_days: float
|
|
bradford_factor: float
|
|
certificates_overdue: int
|
|
|
|
|
|
# ── VacationBalance ───────────────────────────────────────────────────────────
|
|
|
|
class VacationBalanceOut(BaseModel):
|
|
model_config = {"from_attributes": True}
|
|
|
|
id: uuid.UUID
|
|
user_id: uuid.UUID
|
|
year: int
|
|
entitled_days: int
|
|
special_days: int = 0
|
|
carried_over: int
|
|
used_days: int
|
|
total_days: int
|
|
remaining_days: int
|
|
pending_days: float = 0
|
|
# Resturlaub-Verfall (wird zur Laufzeit befüllt, nicht in DB)
|
|
carried_over_expires_at: date | None = None
|
|
carried_over_expired: bool = False
|
|
|
|
|
|
class VacationBalanceUpdate(BaseModel):
|
|
entitled_days: int | None = Field(None, ge=0, le=365)
|
|
special_days: int | None = Field(None, ge=0, le=365)
|
|
carried_over: int | None = Field(None, ge=0, le=365)
|
|
|
|
|
|
# ── PublicHoliday ─────────────────────────────────────────────────────────────
|
|
|
|
class PublicHolidayOut(BaseModel):
|
|
model_config = {"from_attributes": True}
|
|
|
|
id: uuid.UUID
|
|
country: str
|
|
state: str | None
|
|
date: date
|
|
name: str
|
|
year: int
|
|
|
|
|
|
class PublicHolidayCreate(BaseModel):
|
|
country: str = Field("DE", min_length=2, max_length=10)
|
|
state: str | None = Field(None, max_length=10)
|
|
date: date
|
|
name: str = Field(min_length=1, max_length=255)
|
|
|
|
|
|
# ── OvertimeBalance ───────────────────────────────────────────────────────────
|
|
|
|
class OvertimeBalanceOut(BaseModel):
|
|
total_hours: float
|
|
taken_hours: float
|
|
available_hours: float
|
|
|
|
|
|
# ── Calendar ──────────────────────────────────────────────────────────────────
|
|
|
|
class CalendarEntry(BaseModel):
|
|
user_id: uuid.UUID
|
|
user_name: str
|
|
absence_id: uuid.UUID
|
|
type_name: str
|
|
type_color: str
|
|
start_date: date
|
|
end_date: date
|
|
status: AbsenceStatus
|
|
working_days: float
|