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:
@@ -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
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 # Mo–Fr, 06–20 Uhr, kein Feiertag
|
||||
night_25_hours: float # 20–24 + 04–06 Uhr (25% Zuschlag)
|
||||
night_40_hours: float # 00–04 Uhr (40% Zuschlag)
|
||||
sunday_hours: float # Sonntag 00–24 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]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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]
|
||||
Reference in New Issue
Block a user