Initial commit – TimeMaster Zeiterfassung & HR-Tool

Stand: agent-06 (Audit-Log), agent-05 (Krankmeldung), agent-07 Phase 1 (Personalnummer),
Busylight-Pull-Integration, TOTP/2FA, Abwesenheiten, Zeiterfassung, Kiosk-Grundgerüst.
Migrations 0001–0023 deployed auf 192.168.1.137 + .164.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-05-23 20:03:27 +02:00
commit 1fedd683e0
178 changed files with 29896 additions and 0 deletions
View File
+210
View File
@@ -0,0 +1,210 @@
import uuid
from datetime import date, datetime
from pydantic import BaseModel, Field
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
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
def model_post_init(self, __context) -> None:
if self.end_date < self.start_date:
raise ValueError("end_date must be >= start_date")
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
+24
View File
@@ -0,0 +1,24 @@
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel
class AuditLogEntry(BaseModel):
id: UUID
user_id: UUID | None
user_name: str | None
action: str
entity_type: str | None
entity_id: UUID | None
old_value: dict | None
new_value: dict | None
ip_address: str | None
created_at: datetime
model_config = {"from_attributes": True}
class AuditLogListResponse(BaseModel):
total: int
items: list[AuditLogEntry]
+81
View File
@@ -0,0 +1,81 @@
from pydantic import BaseModel, EmailStr, Field, model_validator
class RegisterRequest(BaseModel):
company_name: str = Field(min_length=2, max_length=255)
first_name: str = Field(min_length=1, max_length=100)
last_name: str = Field(min_length=1, max_length=100)
email: EmailStr
password: str = Field(min_length=8, max_length=128)
@model_validator(mode="after")
def password_strength(self):
pw = self.password
if not any(c.isupper() for c in pw):
raise ValueError("Password must contain at least one uppercase letter")
if not any(c.isdigit() for c in pw):
raise ValueError("Password must contain at least one digit")
return self
class LoginRequest(BaseModel):
email: EmailStr
password: str
class RefreshRequest(BaseModel):
refresh_token: str
class PasswordResetRequest(BaseModel):
email: EmailStr
class PasswordResetConfirm(BaseModel):
token: str
new_password: str = Field(min_length=8, max_length=128)
@model_validator(mode="after")
def password_strength(self):
pw = self.new_password
if not any(c.isupper() for c in pw):
raise ValueError("Password must contain at least one uppercase letter")
if not any(c.isdigit() for c in pw):
raise ValueError("Password must contain at least one digit")
return self
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
totp_required: bool = False
partial_token: str | None = None
class TotpSetupResponse(BaseModel):
secret: str # base32 secret for manual entry
otpauth_uri: str # otpauth://totp/... für QR-Code
class TotpConfirmRequest(BaseModel):
code: str = Field(min_length=6, max_length=6)
class TotpLoginRequest(BaseModel):
partial_token: str
code: str = Field(min_length=6, max_length=6)
class TotpDisableRequest(BaseModel):
password: str
code: str = Field(min_length=6, max_length=6)
class AccessTokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
class MessageResponse(BaseModel):
message: str
+29
View File
@@ -0,0 +1,29 @@
from datetime import date, datetime
from pydantic import BaseModel, Field
class BusylightTokenStatus(BaseModel):
configured: bool
created_at: datetime | None = None
class BusylightTokenRotated(BaseModel):
token: str = Field(..., description="Klartext-Token, wird nur einmal angezeigt.")
created_at: datetime
class BusylightAbsenceItem(BaseModel):
type: str
category: str
class BusylightUserItem(BaseModel):
personnel_number: str
full_name: str
absences_today: list[BusylightAbsenceItem]
class BusylightUsersResponse(BaseModel):
date: date
users: list[BusylightUserItem]
+59
View File
@@ -0,0 +1,59 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, Field
class CaldavCompanyConfigOut(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
company_id: uuid.UUID
enabled: bool
principal_url: str
calendar_url: str | None
username: str
calendar_display_name: str
verify_ssl: bool
name_template: str
last_error: str | None
updated_at: datetime
class CaldavCompanyConfigSave(BaseModel):
enabled: bool = False
principal_url: str = Field(min_length=1)
calendar_url: str | None = None
username: str = Field(min_length=1, max_length=255)
password: str | None = None # leer = unverändert
calendar_display_name: str = ""
verify_ssl: bool = True
name_template: str = "$vorname $nachname $typ"
class CaldavUserConfigOut(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
user_id: uuid.UUID
enabled: bool
principal_url: str
calendar_url: str | None
username: str
calendar_display_name: str
verify_ssl: bool
last_error: str | None
updated_at: datetime
class CaldavUserConfigSave(BaseModel):
enabled: bool = False
principal_url: str = Field(min_length=1)
calendar_url: str | None = None
username: str = Field(min_length=1, max_length=255)
password: str | None = None
calendar_display_name: str = ""
verify_ssl: bool = True
class ResyncResult(BaseModel):
synced: int
failed: int
total: int
+51
View File
@@ -0,0 +1,51 @@
import uuid
from typing import Literal
from pydantic import BaseModel, Field
PersonnelNumberModeT = Literal["manual", "auto"]
class CompanyOut(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
name: str
slug: str
plan: str
logo_url: str | None
country: str
state: str | None
settings: dict
personnel_number_required: bool = False
personnel_number_mode: PersonnelNumberModeT = "manual"
personnel_number_next: int = 1
class CompanyUpdate(BaseModel):
name: str | None = Field(None, min_length=2, max_length=255)
state: str | None = Field(None, max_length=10)
settings: dict | None = None
personnel_number_required: bool | None = None
personnel_number_mode: PersonnelNumberModeT | None = None
personnel_number_next: int | None = Field(None, ge=1)
class DepartmentOut(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
company_id: uuid.UUID
name: str
manager_id: uuid.UUID | None
class DepartmentCreate(BaseModel):
name: str = Field(min_length=1, max_length=255)
manager_id: uuid.UUID | None = None
class DepartmentUpdate(BaseModel):
name: str | None = Field(None, min_length=1, max_length=255)
manager_id: uuid.UUID | None = None
+48
View File
@@ -0,0 +1,48 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, Field, field_validator
class KioskDeviceCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
location: str | None = Field(None, max_length=255)
@field_validator("name")
@classmethod
def name_not_blank(cls, v: str) -> str:
if not v.strip():
raise ValueError("Name darf nicht nur aus Leerzeichen bestehen.")
return v.strip()
class KioskDeviceUpdate(BaseModel):
name: str | None = Field(None, min_length=1, max_length=255)
location: str | None = Field(None, max_length=255)
@field_validator("name")
@classmethod
def name_not_blank(cls, v: str | None) -> str | None:
if v is not None:
if not v.strip():
raise ValueError("Name darf nicht nur aus Leerzeichen bestehen.")
return v.strip()
return v
is_active: bool | None = None
class KioskDeviceOut(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
company_id: uuid.UUID
name: str
location: str | None
is_active: bool
last_seen_at: datetime | None
created_at: datetime
class KioskDeviceCreated(KioskDeviceOut):
"""Wird nur einmalig bei Erstellung zurückgegeben enthält den Klartext-Token."""
token: str
+93
View File
@@ -0,0 +1,93 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, Field
from app.models.user import UserRole
class LdapConfigCreate(BaseModel):
host: str = Field(min_length=1, max_length=255)
port: int = Field(default=389, ge=1, le=65535)
use_ssl: bool = False
use_tls: bool = False
tls_verify: bool = False
bind_dn: str = Field(min_length=1)
bind_password: str = Field(min_length=1)
base_dn: str = Field(min_length=1)
user_search_filter: str = Field(default="(objectClass=person)", min_length=1, max_length=512)
attr_email: str = Field(default="mail", min_length=1, max_length=100)
attr_firstname: str = Field(default="givenName", min_length=1, max_length=100)
attr_lastname: str = Field(default="sn", min_length=1, max_length=100)
attr_username: str = Field(default="sAMAccountName", min_length=1, max_length=100)
attr_department: str | None = Field(default=None, max_length=100)
attr_personnel_number: str | None = Field(default=None, max_length=100)
enabled: bool = False
class LdapConfigUpdate(BaseModel):
host: str | None = Field(default=None, min_length=1, max_length=255)
port: int | None = Field(default=None, ge=1, le=65535)
use_ssl: bool | None = None
use_tls: bool | None = None
tls_verify: bool | None = None
bind_dn: str | None = None
bind_password: str | None = None
base_dn: str | None = None
user_search_filter: str | None = Field(default=None, max_length=512)
attr_email: str | None = Field(default=None, max_length=100)
attr_firstname: str | None = Field(default=None, max_length=100)
attr_lastname: str | None = Field(default=None, max_length=100)
attr_username: str | None = Field(default=None, max_length=100)
attr_department: str | None = Field(default=None, max_length=100)
attr_personnel_number: str | None = Field(default=None, max_length=100)
enabled: bool | None = None
class LdapConfigOut(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
company_id: uuid.UUID
enabled: bool
host: str
port: int
use_ssl: bool
use_tls: bool
tls_verify: bool
bind_dn: str
base_dn: str
user_search_filter: str
attr_email: str
attr_firstname: str
attr_lastname: str
attr_username: str
attr_department: str | None
attr_personnel_number: str | None = None
last_sync_at: datetime | None
created_at: datetime
updated_at: datetime
class LdapTestResult(BaseModel):
success: bool
message: str
class LdapSyncRequest(BaseModel):
default_role: UserRole = UserRole.EMPLOYEE
class LdapSyncResult(BaseModel):
created: int
updated: int
deactivated: int
errors: list[str]
class LdapUserPreview(BaseModel):
dn: str
email: str
first_name: str
last_name: str
department: str | None = None
+48
View File
@@ -0,0 +1,48 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, Field
class ProjectOut(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
company_id: uuid.UUID
name: str
description: str | None
color: str
budget_hours: float | None
is_active: bool
created_at: datetime
updated_at: datetime
class ProjectCreate(BaseModel):
name: str = Field(min_length=1, max_length=100)
description: str | None = None
color: str = Field("#3B82F6", pattern=r"^#[0-9A-Fa-f]{6}$")
budget_hours: float | None = Field(None, ge=0.1, le=99999)
class ProjectUpdate(BaseModel):
name: str | None = Field(None, min_length=1, max_length=100)
description: str | None = None
color: str | None = Field(None, pattern=r"^#[0-9A-Fa-f]{6}$")
budget_hours: float | None = Field(None, ge=0.1, le=99999)
is_active: bool | None = None
class ProjectListResponse(BaseModel):
total: int
items: list[ProjectOut]
class ProjectTimeReport(BaseModel):
project_id: uuid.UUID
project_name: str
project_color: str
total_hours: float
entry_count: int
budget_hours: float | None
budget_used_pct: float | None # None wenn kein Budget
+190
View File
@@ -0,0 +1,190 @@
import uuid
from datetime import date, time
from pydantic import BaseModel
# ── Employee Dashboard ─────────────────────────────────────────────────────────
class EmployeeDashboard(BaseModel):
today_open: bool
today_start: time | None
today_hours_so_far: float | None
week_hours_worked: float
week_hours_expected: float
week_overtime: float
vacation_remaining_days: int | None
vacation_used_days: int
vacation_entitled_days: int
pending_absences: int
overtime_balance_hours: float | None # verfügbares Überstundenguthaben
schedule_name: str | None # zugewiesener Arbeitsplan
# ── Team Dashboard ─────────────────────────────────────────────────────────────
class TeamMemberStatus(BaseModel):
user_id: uuid.UUID
user_name: str
department: str | None
status: str # "present" | "on_leave" | "absent"
absence_type: str | None
time_in: time | None
hours_today: float | None
class TeamDashboard(BaseModel):
present_count: int
on_leave_count: int
absent_count: int
pending_time_approvals: int
pending_absence_approvals: int
members: list[TeamMemberStatus]
# ── Company Dashboard ──────────────────────────────────────────────────────────
class UpcomingAbsence(BaseModel):
user_id: uuid.UUID
user_name: str
absence_type: str
start_date: date
end_date: date
working_days: float
class CompanyDashboard(BaseModel):
total_employees: int
active_today: int
attendance_rate: float
month_hours_worked: float
month_hours_expected: float
month_overtime: float
pending_time_approvals: int
pending_absence_approvals: int
upcoming_absences: list[UpcomingAbsence]
# ── Time Report ────────────────────────────────────────────────────────────────
class HoursBreakdown(BaseModel):
"""Stunden-Aufteilung nach §3b EStG für den Steuerberater."""
normal_hours: float # MoFr, 0620 Uhr, kein Feiertag
night_25_hours: float # 2024 + 0406 Uhr (25% Zuschlag)
night_40_hours: float # 0004 Uhr (40% Zuschlag)
sunday_hours: float # Sonntag 0024 Uhr (50% Zuschlag)
holiday_125_hours: float # gesetzl. Feiertag (125% Zuschlag)
holiday_150_hours: float # besondere Feiertage 25.12, 26.12, 01.05 etc. (150%)
holiday_name: str | None # Name des Feiertags falls zutreffend
class TimeReportRow(BaseModel):
date: date
user_id: uuid.UUID
user_name: str
personnel_number: str | None = None
department: str | None
start_time: time
end_time: time | None
break_minutes: int
worked_hours: float | None
status: str
source: str
note: str | None
breakdown: HoursBreakdown | None = None # None wenn kein Bundesland konfiguriert
class TimeReport(BaseModel):
date_from: date
date_to: date
total_rows: int
total_hours: float
rows: list[TimeReportRow]
# ── Absence Report ─────────────────────────────────────────────────────────────
class AbsenceReportRow(BaseModel):
user_id: uuid.UUID
user_name: str
personnel_number: str | None = None
department: str | None
absence_type: str
start_date: date
end_date: date
working_days: float
status: str
note: str | None
class AbsenceReport(BaseModel):
date_from: date
date_to: date
total_rows: int
total_days: float
rows: list[AbsenceReportRow]
# ── Overtime Report ────────────────────────────────────────────────────────────
class OvertimeReportRow(BaseModel):
user_id: uuid.UUID
user_name: str
personnel_number: str | None = None
department: str | None
hours_worked: float
hours_expected: float
overtime_hours: float
class OvertimeReport(BaseModel):
date_from: date
date_to: date
total_employees: int
total_overtime: float
rows: list[OvertimeReportRow]
# ── Overtime Detail Report (Option A: Inline-Expand) ──────────────────────────
class DayEntry(BaseModel):
"""Einzelner Zeiteintrag innerhalb eines Tages (mehrere möglich)."""
start_time: time
end_time: time
break_minutes: int
hours_worked: float
status: str
arbzg_warnings: list[str] = []
breakdown: HoursBreakdown | None = None
class OvertimeDay(BaseModel):
date: date
weekday: str # "Mo", "Di", …
hours_worked: float # Summe aller Einträge des Tages
hours_expected: float
overtime: float
entries: list[DayEntry] = [] # leer = kein Eintrag an dem Tag
class OvertimeWeek(BaseModel):
week_nr: int
week_start: date
week_end: date
hours_worked: float
hours_expected: float
overtime: float
days: list[OvertimeDay]
class OvertimeReportRowDetailed(OvertimeReportRow):
weeks: list[OvertimeWeek] = []
arbzg_violation_days: int = 0 # Tage > 10h
special_hours_total: HoursBreakdown | None = None
class OvertimeReportDetailed(BaseModel):
date_from: date
date_to: date
total_employees: int
total_overtime: float
rows: list[OvertimeReportRowDetailed]
+34
View File
@@ -0,0 +1,34 @@
import uuid
from pydantic import BaseModel, Field
class SmtpConfigOut(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
company_id: uuid.UUID
host: str
port: int
use_tls: bool
use_starttls: bool
username: str | None
from_email: str
from_name: str
is_enabled: bool
# password_encrypted wird nie zurückgegeben
class SmtpConfigSave(BaseModel):
host: str = Field(min_length=1, max_length=255)
port: int = Field(default=587, ge=1, le=65535)
use_tls: bool = False
use_starttls: bool = True
username: str | None = Field(None, max_length=255)
password: str | None = None # Klartext wird serverseitig verschlüsselt
from_email: str = Field(min_length=5, max_length=255)
from_name: str = Field(default="TimeMaster", min_length=1, max_length=255)
is_enabled: bool = True
class SmtpTestRequest(BaseModel):
to: str = Field(min_length=5, max_length=255)
+111
View File
@@ -0,0 +1,111 @@
import uuid
from datetime import date, datetime, time
from decimal import Decimal
from pydantic import BaseModel, Field
from app.models.time_entry import EntrySource, EntryStatus
class TimeEntryOut(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
user_id: uuid.UUID
date: date
start_time: time
end_time: time | None
break_minutes: int
break_start: time | None
project_id: uuid.UUID | None
note: str | None
status: EntryStatus
source: EntrySource
approved_by: uuid.UUID | None
correction_note: str | None
worked_hours: float | None
created_at: datetime
updated_at: datetime
class TimeEntryWithWarnings(BaseModel):
entry: TimeEntryOut
warnings: list[str] = []
class StampInRequest(BaseModel):
source: EntrySource = EntrySource.WEB
project_id: uuid.UUID | None = None
note: str | None = None
class StampOutRequest(BaseModel):
note: str | None = None
class ManualEntryCreate(BaseModel):
user_id: uuid.UUID | None = None # MANAGER/HR können für andere setzen
date: date
start_time: time
end_time: time
break_minutes: int = Field(0, ge=0, le=600)
project_id: uuid.UUID | None = None
note: str | None = None
source: EntrySource = EntrySource.MANUAL
class TimeEntryUpdate(BaseModel):
start_time: time | None = None
end_time: time | None = None
break_minutes: int | None = Field(None, ge=0, le=600)
project_id: uuid.UUID | None = None
note: str | None = None
correction_note: str | None = None
class RejectRequest(BaseModel):
rejection_note: str | None = None
class TimeEntryListResponse(BaseModel):
total: int
items: list[TimeEntryOut]
class WorkScheduleOut(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
company_id: uuid.UUID
name: str
mon_h: Decimal
tue_h: Decimal
wed_h: Decimal
thu_h: Decimal
fri_h: Decimal
sat_h: Decimal
sun_h: Decimal
valid_from: date
class WorkScheduleCreate(BaseModel):
name: str = Field(min_length=1, max_length=255)
mon_h: Decimal = Field(Decimal("8.00"), ge=0, le=24)
tue_h: Decimal = Field(Decimal("8.00"), ge=0, le=24)
wed_h: Decimal = Field(Decimal("8.00"), ge=0, le=24)
thu_h: Decimal = Field(Decimal("8.00"), ge=0, le=24)
fri_h: Decimal = Field(Decimal("8.00"), ge=0, le=24)
sat_h: Decimal = Field(Decimal("0.00"), ge=0, le=24)
sun_h: Decimal = Field(Decimal("0.00"), ge=0, le=24)
valid_from: date
class BalanceResponse(BaseModel):
user_id: uuid.UUID
period_start: date
period_end: date
total_hours_worked: float
expected_hours: float
overtime_hours: float
approved_entries: int
pending_entries: int
+112
View File
@@ -0,0 +1,112 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, EmailStr, Field, model_validator
from app.models.user import AuthProvider, UserRole
PERSONNEL_NUMBER_PATTERN = r"^[0-9]+$"
class UserOut(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
company_id: uuid.UUID | None
department_id: uuid.UUID | None
email: str
first_name: str
last_name: str
full_name: str
role: UserRole
auth_provider: AuthProvider
is_active: bool
last_login: datetime | None
created_at: datetime
kuerzel: str | None = None
personnel_number: str | None = None
can_manual_time_entry: bool = False
class UserUpdate(BaseModel):
first_name: str | None = Field(None, min_length=1, max_length=100)
last_name: str | None = Field(None, min_length=1, max_length=100)
department_id: uuid.UUID | None = None
role: UserRole | None = None
work_schedule_id: uuid.UUID | None = None
kuerzel: str | None = Field(None, max_length=20)
personnel_number: str | None = Field(None, max_length=50, pattern=PERSONNEL_NUMBER_PATTERN)
can_manual_time_entry: bool | None = None
is_active: bool | None = None
class InviteRequest(BaseModel):
email: EmailStr
first_name: str = Field(min_length=1, max_length=100)
last_name: str = Field(min_length=1, max_length=100)
role: UserRole = UserRole.EMPLOYEE
department_id: uuid.UUID | None = None
personnel_number: str | None = Field(None, max_length=50, pattern=PERSONNEL_NUMBER_PATTERN)
# Wenn gesetzt → User wird sofort aktiv (kein Invite-E-Mail nötig)
initial_password: str | None = Field(None, min_length=8, max_length=128)
@model_validator(mode="after")
def password_strength(self):
pw = self.initial_password
if pw is None:
return self
if not any(c.isupper() for c in pw):
raise ValueError("initial_password must contain at least one uppercase letter")
if not any(c.isdigit() for c in pw):
raise ValueError("initial_password must contain at least one digit")
return self
class InviteAccept(BaseModel):
token: str
password: str = Field(min_length=8, max_length=128)
@model_validator(mode="after")
def password_strength(self):
pw = self.password
if not any(c.isupper() for c in pw):
raise ValueError("Password must contain at least one uppercase letter")
if not any(c.isdigit() for c in pw):
raise ValueError("Password must contain at least one digit")
return self
class UserListResponse(BaseModel):
total: int
items: list[UserOut]
class SetKioskPinRequest(BaseModel):
pin: str = Field(min_length=4, max_length=6, pattern=r"^\d+$")
class NextPersonnelNumberResponse(BaseModel):
next: str
class UserImportRowError(BaseModel):
row: int
email: str | None = None
message: str
class UserImportRowResult(BaseModel):
row: int
email: str
personnel_number: str | None = None
action: str # "created" | "reactivated" | "skipped" | "error"
message: str | None = None
class UserImportResult(BaseModel):
total_rows: int
created: int
reactivated: int
errors: int
items: list[UserImportRowResult]