This commit is contained in:
2026-03-25 22:03:39 +01:00
parent a0073b4fb1
commit faf4ca10c9
5603 changed files with 1030866 additions and 79 deletions

View File

@@ -0,0 +1 @@
# -*- coding: utf-8 -*-

View File

@@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
"""Zentrale Enums für das workforce_planner Paket."""
from enum import Enum
class AbsenceStatus(str, Enum):
PENDING = "pending"
APPROVED = "approved"
REJECTED = "rejected"
CANCELLED = "cancelled"
class AbsenceCategory(str, Enum):
URLAUB = "urlaub"
UNBEZAHLT = "unbezahlt"
KRANK = "krank"
BUERO = "buero"
WEITERBILDUNG = "weiterbildung"
SONSTIGES = "sonstiges"
ABSENCE_META = {
AbsenceCategory.URLAUB: {"label": "Urlaub", "color": "#7EC8E3", "deducts_balance": True, "requires_approval": True},
AbsenceCategory.UNBEZAHLT: {"label": "Unbezahlter Urlaub","color": "#E8C87E", "deducts_balance": False, "requires_approval": True},
AbsenceCategory.KRANK: {"label": "Krank", "color": "#F5A3A3", "deducts_balance": False, "requires_approval": False},
AbsenceCategory.BUERO: {"label": "Bürozeit", "color": "#A8D5BA", "deducts_balance": False, "requires_approval": False},
AbsenceCategory.WEITERBILDUNG: {"label": "Weiterbildung", "color": "#B8C8F0", "deducts_balance": False, "requires_approval": True},
AbsenceCategory.SONSTIGES: {"label": "Sonstiges", "color": "#D4C5F9", "deducts_balance": False, "requires_approval": False},
}
class EmployeeRole(str, Enum):
ADMIN = "admin"
MANAGER = "manager"
EMPLOYEE = "employee"

View File

@@ -0,0 +1,154 @@
# -*- coding: utf-8 -*-
"""SQLAlchemy-Datenbankmodelle für workforce_planner."""
import datetime
import uuid
from sqlalchemy import (
Column, String, Integer, Float, Date, DateTime, Boolean,
ForeignKey, Text, Enum as SAEnum, UniqueConstraint, CheckConstraint,
Index, JSON,
)
from sqlalchemy.orm import relationship, DeclarativeBase
from .enums import AbsenceCategory, AbsenceStatus, EmployeeRole
def _new_id() -> str:
return uuid.uuid4().hex[:12]
class Base(DeclarativeBase):
pass
# ═══════════════════════════════════════════════════════════════
# MULTI-TENANT: Praxis / Organisation
# ═══════════════════════════════════════════════════════════════
class Practice(Base):
"""Eine Praxis / Organisation Mandant im Multi-Tenant-Modell."""
__tablename__ = "practices"
id = Column(String(32), primary_key=True, default=_new_id)
name = Column(String(200), nullable=False)
address = Column(Text, default="")
phone = Column(String(40), default="")
email = Column(String(200), nullable=True)
settings = Column(JSON, default=dict)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.datetime.utcnow)
employees = relationship("Employee", back_populates="practice")
def __repr__(self):
return f"<Practice {self.name!r}>"
# ═══════════════════════════════════════════════════════════════
# EMPLOYEE
# ═══════════════════════════════════════════════════════════════
class Employee(Base):
__tablename__ = "employees"
id = Column(String(32), primary_key=True, default=_new_id)
practice_id = Column(String(32), ForeignKey("practices.id"), nullable=True)
name = Column(String(120), nullable=False)
email = Column(String(200), unique=True, nullable=True)
password_hash = Column(String(256), nullable=True)
department = Column(String(80), nullable=True)
role = Column(SAEnum(EmployeeRole), default=EmployeeRole.EMPLOYEE, nullable=False)
workload_percent = Column(Integer, default=100)
vacation_days_per_year = Column(Integer, default=25)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.datetime.utcnow)
last_login = Column(DateTime, nullable=True)
practice = relationship("Practice", back_populates="employees")
absences = relationship("Absence", back_populates="employee", cascade="all, delete-orphan",
foreign_keys="Absence.employee_id")
balance_accounts = relationship("BalanceAccount", back_populates="employee", cascade="all, delete-orphan")
def __repr__(self):
return f"<Employee {self.name!r}>"
class Absence(Base):
__tablename__ = "absences"
id = Column(String(32), primary_key=True, default=_new_id)
employee_id = Column(String(32), ForeignKey("employees.id", ondelete="CASCADE"), nullable=False)
category = Column(SAEnum(AbsenceCategory), nullable=False)
status = Column(SAEnum(AbsenceStatus), default=AbsenceStatus.PENDING, nullable=False)
start_date = Column(Date, nullable=False)
end_date = Column(Date, nullable=False)
reason = Column(Text, default="")
approver_id = Column(String(32), ForeignKey("employees.id"), nullable=True)
approved_at = Column(DateTime, nullable=True)
business_days = Column(Float, default=0)
created_at = Column(DateTime, default=datetime.datetime.utcnow)
employee = relationship("Employee", back_populates="absences", foreign_keys=[employee_id])
approver = relationship("Employee", foreign_keys=[approver_id])
__table_args__ = (
CheckConstraint("end_date >= start_date", name="ck_absence_dates"),
)
def __repr__(self):
return f"<Absence {self.employee_id} {self.category.value} {self.start_date}{self.end_date}>"
class BalanceAccount(Base):
"""Ferientage-Konto pro Mitarbeiter und Jahr."""
__tablename__ = "balance_accounts"
id = Column(String(32), primary_key=True, default=_new_id)
employee_id = Column(String(32), ForeignKey("employees.id", ondelete="CASCADE"), nullable=False)
year = Column(Integer, nullable=False)
yearly_quota = Column(Integer, default=25)
carry_over = Column(Float, default=0)
manual_adjustment = Column(Float, default=0)
employee = relationship("Employee", back_populates="balance_accounts")
__table_args__ = (
UniqueConstraint("employee_id", "year", name="uq_balance_emp_year"),
)
@property
def total_available(self) -> float:
return self.yearly_quota + self.carry_over + self.manual_adjustment
def __repr__(self):
return f"<BalanceAccount {self.employee_id} {self.year} quota={self.yearly_quota}>"
# ═══════════════════════════════════════════════════════════════
# AUDIT LOG
# ═══════════════════════════════════════════════════════════════
class AuditLog(Base):
"""Vollständiges Änderungsprotokoll wer hat was wann gemacht."""
__tablename__ = "audit_logs"
id = Column(String(32), primary_key=True, default=_new_id)
timestamp = Column(DateTime, default=datetime.datetime.utcnow, nullable=False)
user_id = Column(String(32), ForeignKey("employees.id"), nullable=True)
action = Column(String(50), nullable=False)
entity_type = Column(String(50), nullable=False)
entity_id = Column(String(32), nullable=True)
practice_id = Column(String(32), ForeignKey("practices.id"), nullable=True)
old_values = Column(JSON, nullable=True)
new_values = Column(JSON, nullable=True)
ip_address = Column(String(45), nullable=True)
user_agent = Column(String(300), nullable=True)
user = relationship("Employee", foreign_keys=[user_id])
__table_args__ = (
Index("ix_audit_entity", "entity_type", "entity_id"),
Index("ix_audit_user", "user_id"),
Index("ix_audit_time", "timestamp"),
)

View File

@@ -0,0 +1,123 @@
# -*- coding: utf-8 -*-
"""Pydantic Schemas Validierung & Serialisierung für API und Clients."""
from __future__ import annotations
import datetime
from typing import Optional
from pydantic import BaseModel, Field, EmailStr, model_validator
from .enums import AbsenceCategory, AbsenceStatus, EmployeeRole
# ───────────────────────── Employee ─────────────────────────
class EmployeeCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=120)
email: Optional[str] = None
department: Optional[str] = None
role: EmployeeRole = EmployeeRole.EMPLOYEE
workload_percent: int = Field(100, ge=10, le=100)
vacation_days_per_year: int = Field(25, ge=0, le=60)
class EmployeeUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=120)
email: Optional[str] = None
department: Optional[str] = None
role: Optional[EmployeeRole] = None
workload_percent: Optional[int] = Field(None, ge=10, le=100)
vacation_days_per_year: Optional[int] = Field(None, ge=0, le=60)
is_active: Optional[bool] = None
class EmployeeRead(BaseModel):
id: str
name: str
email: Optional[str]
department: Optional[str]
role: EmployeeRole
workload_percent: int
vacation_days_per_year: int
is_active: bool
created_at: datetime.datetime
model_config = {"from_attributes": True}
# ───────────────────────── Absence ──────────────────────────
class AbsenceCreate(BaseModel):
employee_id: str
category: AbsenceCategory
start_date: datetime.date
end_date: datetime.date
reason: str = ""
@model_validator(mode="after")
def _check_dates(self):
if self.end_date < self.start_date:
raise ValueError("end_date darf nicht vor start_date liegen")
return self
class AbsenceUpdate(BaseModel):
category: Optional[AbsenceCategory] = None
start_date: Optional[datetime.date] = None
end_date: Optional[datetime.date] = None
reason: Optional[str] = None
status: Optional[AbsenceStatus] = None
@model_validator(mode="after")
def _check_dates(self):
if self.start_date and self.end_date and self.end_date < self.start_date:
raise ValueError("end_date darf nicht vor start_date liegen")
return self
class AbsenceRead(BaseModel):
id: str
employee_id: str
category: AbsenceCategory
status: AbsenceStatus
start_date: datetime.date
end_date: datetime.date
reason: str
approver_id: Optional[str]
approved_at: Optional[datetime.datetime]
business_days: float
created_at: datetime.datetime
model_config = {"from_attributes": True}
# ───────────────────────── Balance ──────────────────────────
class BalanceRead(BaseModel):
employee_id: str
employee_name: str
year: int
yearly_quota: int
carry_over: float
manual_adjustment: float
total_available: float
used_days: float
remaining: float
model_config = {"from_attributes": True}
class BalanceAdjust(BaseModel):
carry_over: Optional[float] = None
manual_adjustment: Optional[float] = None
yearly_quota: Optional[int] = Field(None, ge=0, le=60)
# ───────────────────────── Approval ─────────────────────────
class ApprovalDecision(BaseModel):
absence_id: str
approved: bool
approver_id: str
comment: str = ""