Initial commit

This commit is contained in:
2026-03-25 13:42:48 +01:00
commit d6b31e2ef7
2802 changed files with 515196 additions and 0 deletions

View File

@@ -0,0 +1,297 @@
# Workforce Planner Zielarchitektur
## 1. Architekturdiagramm
```
┌─────────────────────────────────────────────────────────────────┐
│ CLIENTS │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ Desktop App │ │ Web App │ │ MedWork Plugin │ │
│ │ (tkinter) │ │(React/Vue/…) │ │ (Integration) │ │
│ └──────┬───────┘ └──────┬───────┘ └────────┬─────────┘ │
│ │ │ │ │
│ └──────────────────┼─────────────────────┘ │
│ │ │
│ HTTPS / REST API │
└────────────────────────────┼────────────────────────────────────┘
┌────────────────────────────┼────────────────────────────────────┐
│ API GATEWAY │
│ │
│ ┌─────────────────────────┴────────────────────────────────┐ │
│ │ FastAPI Server │ │
│ │ │ │
│ │ ┌─────────┐ ┌────────────┐ ┌──────────┐ ┌────────┐ │ │
│ │ │ Auth │ │ CORS │ │ Audit │ │ Rate │ │ │
│ │ │Middleware│ │ Middleware │ │Middleware │ │Limiter │ │ │
│ │ └─────────┘ └────────────┘ └──────────┘ └────────┘ │ │
│ │ │ │
│ │ ┌────────────────────────────────────────────────────┐ │ │
│ │ │ API Routes │ │ │
│ │ │ /employees /absences /balance /practices │ │ │
│ │ │ /auth /reports /health /audit │ │ │
│ │ └────────────────────────┬───────────────────────────┘ │ │
│ └───────────────────────────┼───────────────────────────────┘ │
└──────────────────────────────┼──────────────────────────────────┘
┌──────────────────────────────┼──────────────────────────────────┐
│ SERVICE LAYER │
│ (Businesslogik) │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ │
│ │ Employee │ │ Absence │ │ Approval │ │
│ │ Service │ │ Service │ │ Workflow │ │
│ └──────┬───────┘ └──────┬───────┘ └──────────┬───────────┘ │
│ │ │ │ │
│ │ ┌────────────┴──────────┐ │ │
│ │ │ Business Rules │ │ │
│ │ │ • Überschneidung │ │ │
│ │ │ • Kontingentprüfung │ │ │
│ │ │ • Mindestbesetzung │ │ │
│ │ └───────────────────────┘ │ │
│ │ │ │
└─────────┼────────────────────────────────────────┼──────────────┘
│ │
┌─────────┼────────────────────────────────────────┼──────────────┐
│ │ REPOSITORY LAYER │ │
│ │ (Datenzugriff) │ │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ │
│ │ Employee │ │ Absence │ │ Balance │ │
│ │ Repository │ │ Repository │ │ Repository │ │
│ └──────┬───────┘ └──────┬───────┘ └──────────┬───────────┘ │
└─────────┼─────────────────┼─────────────────────┼──────────────┘
│ │ │
└─────────────────┼──────────────────────┘
┌──────┴──────┐
│ SQLAlchemy │
│ ORM │
└──────┬──────┘
┌────────────┴────────────┐
│ │
┌──────┴──────┐ ┌───────┴──────┐
│ SQLite │ │ PostgreSQL │
│ (Dev) │ │ (Produktion)│
└─────────────┘ └──────────────┘
```
## 2. Komponentenbeschreibung
### Client Layer
| Komponente | Beschreibung |
|---|---|
| **Desktop App** | Bestehende tkinter-App. Wird umgebaut: statt lokaler JSON-Dateien ruft sie die REST API auf. Gleiche Benutzeranmeldung wie Web. |
| **Web App** | Modernes Web-Frontend (React/Vue/Svelte). Nutzt dieselbe API. Responsive für Tablet/Desktop. |
| **MedWork Plugin** | Adapter der MedWork-Daten (Patienten, Termine) mit dem Arbeitsplan verknüpft. Eigener API-Endpunkt. |
### API Layer
| Komponente | Beschreibung |
|---|---|
| **FastAPI Server** | Zentraler Einstiegspunkt. Stellt REST-Endpunkte bereit. Auto-generierte OpenAPI-Doku. |
| **Auth Middleware** | JWT-basierte Authentifizierung. Gleiche Tokens für Desktop + Web. |
| **Audit Middleware** | Protokolliert jede schreibende Aktion (wer, was, wann, alte/neue Werte). |
| **CORS Middleware** | Erlaubt Cross-Origin-Zugriffe für Web-Client. |
### Service Layer (Businesslogik)
| Komponente | Beschreibung |
|---|---|
| **EmployeeService** | Mitarbeiter anlegen/bearbeiten/deaktivieren. E-Mail-Duplikat-Check. |
| **AbsenceService** | Abwesenheiten erstellen mit Regelprüfung. Kontingent berechnen. |
| **Business Rules** | `check_no_overlap` keine überlappenden Einträge. `check_balance` genug Ferientage? `check_min_staffing` Mindestbesetzung gewährleistet? |
| **Approval Workflow** | Pending → Approved/Rejected Workflow mit Genehmiger-Zuweisung. |
### Repository Layer
| Komponente | Beschreibung |
|---|---|
| **EmployeeRepository** | CRUD für Mitarbeiter. Einziger Code der direkt SQL ausführt. |
| **AbsenceRepository** | CRUD + Spezialabfragen (Überschneidungen, Abwesende pro Tag). |
| **BalanceRepository** | Ferientage-Kontingent pro Mitarbeiter/Jahr. |
### Data Layer
| Komponente | Beschreibung |
|---|---|
| **SQLAlchemy ORM** | Abstrahiert Datenbank. Identischer Code für SQLite und PostgreSQL. |
| **Practice (Mandant)** | Multi-Tenant: jede Praxis hat eigene Mitarbeiter/Abwesenheiten. |
| **AuditLog** | Unveränderliches Änderungsprotokoll mit JSON-Diff. |
## 3. Technologie-Stack
| Schicht | Technologie | Begründung |
|---|---|---|
| **Backend** | Python 3.11+ | Bestehende Codebasis, grosses Ökosystem |
| **API Framework** | FastAPI | Async, Auto-Docs, Pydantic-Integration, modern |
| **ORM** | SQLAlchemy 2.0 | Industriestandard, DB-agnostisch |
| **Validierung** | Pydantic v2 | Schemas für API + Clients, schnell |
| **Auth** | JWT (python-jose) | Stateless, Desktop + Web kompatibel |
| **Passwörter** | passlib + bcrypt | Industriestandard |
| **DB (Dev)** | SQLite | Kein Setup nötig, lokales Testen |
| **DB (Prod)** | PostgreSQL 16 | ACID, JSON-Support, skalierbar |
| **Desktop** | tkinter (→ später Qt) | Bestehend, HTTP-Client wird ergänzt |
| **Web** | React oder Vue.js | SPA, nutzt dieselbe API |
| **Deployment** | Docker + Docker Compose | Einfach, reproduzierbar |
| **Reverse Proxy** | nginx / Caddy | HTTPS, Load Balancing |
## 4. Datenfluss
### Abwesenheit eintragen (von jedem Client)
```
Client (Desktop/Web)
POST /api/v1/absences
{ employee_id, category, start_date, end_date, reason }
Auth Middleware ──── JWT prüfen ──── 401 wenn ungültig
AbsenceService.create_absence()
├─→ EmployeeRepository.get_by_id() → Mitarbeiter existiert?
├─→ rules.check_no_overlap() → Überschneidung?
├─→ rules.check_balance() → Genug Ferientage?
├─→ rules.check_min_staffing() → Mindestbesetzung OK?
├─→ AbsenceRepository.create() → DB INSERT
├─→ AuditLog.log_action() → Protokollierung
├─→ db.commit()
Response: 201 Created
{ id, employee_id, category, status, business_days, ... }
Client aktualisiert Kalenderansicht
```
### Login-Flow (Desktop + Web identisch)
```
Client
POST /api/v1/auth/login
{ email, password }
verify_password(plain, hashed)
├─→ Falsch: 401 Unauthorized
├─→ Richtig: create_access_token(employee_id, role)
Response: { access_token, token_type, employee }
Client speichert Token → sendet bei jedem Request als
Authorization: Bearer <token>
```
### Multi-Tenant Datenfluss
```
Praxis A (ID: praxis_a) Praxis B (ID: praxis_b)
│ │
▼ ▼
Mitarbeiter mit Mitarbeiter mit
practice_id = praxis_a practice_id = praxis_b
│ │
▼ ▼
Abwesenheiten nur Abwesenheiten nur
für Praxis A sichtbar für Praxis B sichtbar
Admin-Benutzer mit role=ADMIN kann praxisübergreifend zugreifen.
```
## 5. Deployment-Konzept
### Entwicklung (lokal)
```
┌─────────────────────────────────────────┐
│ Entwickler-PC │
│ │
│ uvicorn workforce_planner.api.app:app │
│ │ │
│ └──→ SQLite (workforce_planner.db)│
│ │
│ Desktop App (tkinter) → localhost:8000 │
└─────────────────────────────────────────┘
```
### Produktion (Docker)
```
┌──────────────────────────────────────────────────────┐
│ Server / VPS / Cloud │
│ │
│ ┌────────────┐ │
│ │ nginx │ ◄── HTTPS (Let's Encrypt) │
│ │ :443/:80 │ │
│ └─────┬──────┘ │
│ │ │
│ ┌─────┴──────┐ ┌──────────────────────────────┐ │
│ │ FastAPI │ │ PostgreSQL │ │
│ │ :8000 ├──►│ :5432 │ │
│ │ (2 Worker) │ │ Volume: /data/postgres │ │
│ └────────────┘ └──────────────────────────────┘ │
│ │
│ docker-compose.yml orchestriert alles │
└──────────────────────────────────────────────────────┘
Desktop Clients ──────► server.praxis.ch:443/api/v1/
Web Clients ──────► server.praxis.ch:443/
```
### docker-compose.yml (Ziel)
```yaml
services:
api:
build: .
ports: ["8000:8000"]
environment:
WP_DATABASE_URL: postgresql://wp:secret@db:5432/workforce
WP_SECRET_KEY: ${SECRET_KEY}
depends_on: [db]
db:
image: postgres:16-alpine
volumes: [pgdata:/var/lib/postgresql/data]
environment:
POSTGRES_DB: workforce
POSTGRES_USER: wp
POSTGRES_PASSWORD: secret
nginx:
image: nginx:alpine
ports: ["443:443", "80:80"]
volumes: [./nginx.conf:/etc/nginx/conf.d/default.conf]
depends_on: [api]
volumes:
pgdata:
```
## Aktueller Stand
| Komponente | Status |
|---|---|
| Datenmodelle (Employee, Absence, BalanceAccount, Practice, AuditLog) | ✅ Fertig |
| Enums (AbsenceCategory, EmployeeRole, AbsenceStatus) | ✅ Fertig |
| Pydantic Schemas (Create/Update/Read) | ✅ Fertig |
| Database Setup (SQLite/PostgreSQL) | ✅ Fertig |
| Repository Layer (Employee, Absence, Balance) | ✅ Fertig |
| Service Layer + Business Rules | ✅ Fertig |
| FastAPI Routen (Employees, Absences, Balance) | ✅ Fertig |
| JWT Auth + Rollen | ✅ Fertig |
| Audit Logging | ✅ Fertig |
| Multi-Tenant (Practice) | ✅ Modell fertig |
| Desktop-Client Umstellung auf API | ⬜ Nächster Schritt |
| Web-Frontend | ⬜ Geplant |
| MedWork-Integration | ⬜ Geplant |
| Docker Deployment | ⬜ Geplant |

View File

@@ -0,0 +1,7 @@
# -*- coding: utf-8 -*-
"""
workforce_planner Modulares Abwesenheits- & Arbeitsplanungspaket.
Kann als Backend für Desktop (tkinter) und Web (FastAPI) dienen.
"""
__version__ = "0.1.0"

View File

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

View File

@@ -0,0 +1,141 @@
# -*- coding: utf-8 -*-
"""Repository für Absence- und BalanceAccount-CRUD."""
import datetime
from typing import Optional
from sqlalchemy.orm import Session
from sqlalchemy import and_, func
from ..core.models import Absence, BalanceAccount, Employee
from ..core.enums import AbsenceStatus, AbsenceCategory, ABSENCE_META
from ..core.schemas import AbsenceCreate, AbsenceUpdate
class AbsenceRepository:
def __init__(self, db: Session):
self.db = db
def get_by_id(self, absence_id: str) -> Optional[Absence]:
return self.db.query(Absence).filter(Absence.id == absence_id).first()
def list_for_employee(
self, employee_id: str, year: Optional[int] = None
) -> list[Absence]:
q = self.db.query(Absence).filter(Absence.employee_id == employee_id)
if year:
jan1 = datetime.date(year, 1, 1)
dec31 = datetime.date(year, 12, 31)
q = q.filter(Absence.end_date >= jan1, Absence.start_date <= dec31)
return q.order_by(Absence.start_date).all()
def list_for_period(
self, start: datetime.date, end: datetime.date, status: Optional[AbsenceStatus] = None
) -> list[Absence]:
q = self.db.query(Absence).filter(
Absence.end_date >= start,
Absence.start_date <= end,
)
if status:
q = q.filter(Absence.status == status)
return q.order_by(Absence.start_date).all()
def list_all(self, year: Optional[int] = None) -> list[Absence]:
q = self.db.query(Absence)
if year:
jan1 = datetime.date(year, 1, 1)
dec31 = datetime.date(year, 12, 31)
q = q.filter(Absence.end_date >= jan1, Absence.start_date <= dec31)
return q.order_by(Absence.start_date).all()
def find_overlapping(
self, employee_id: str, start: datetime.date, end: datetime.date,
exclude_id: Optional[str] = None,
) -> list[Absence]:
q = self.db.query(Absence).filter(
Absence.employee_id == employee_id,
Absence.end_date >= start,
Absence.start_date <= end,
Absence.status != AbsenceStatus.CANCELLED,
)
if exclude_id:
q = q.filter(Absence.id != exclude_id)
return q.all()
def count_absent_on_date(self, date: datetime.date) -> int:
return self.db.query(Absence).filter(
Absence.start_date <= date,
Absence.end_date >= date,
Absence.status.in_([AbsenceStatus.APPROVED, AbsenceStatus.PENDING]),
).count()
def create(self, data: AbsenceCreate, business_days: float) -> Absence:
absence = Absence(
**data.model_dump(),
business_days=business_days,
)
self.db.add(absence)
self.db.flush()
return absence
def update(self, absence_id: str, data: AbsenceUpdate) -> Optional[Absence]:
absence = self.get_by_id(absence_id)
if not absence:
return None
for field, value in data.model_dump(exclude_unset=True).items():
setattr(absence, field, value)
self.db.flush()
return absence
def delete(self, absence_id: str) -> bool:
absence = self.get_by_id(absence_id)
if not absence:
return False
self.db.delete(absence)
self.db.flush()
return True
def used_days(self, employee_id: str, year: int) -> float:
"""Verbrauchte Ferientage (nur Kategorien mit deducts_balance)."""
deducting = [
cat for cat, meta in ABSENCE_META.items() if meta["deducts_balance"]
]
rows = (
self.db.query(Absence)
.filter(
Absence.employee_id == employee_id,
Absence.category.in_(deducting),
Absence.status != AbsenceStatus.CANCELLED,
Absence.end_date >= datetime.date(year, 1, 1),
Absence.start_date <= datetime.date(year, 12, 31),
)
.all()
)
return sum(r.business_days for r in rows)
class BalanceRepository:
def __init__(self, db: Session):
self.db = db
def get_or_create(self, employee_id: str, year: int) -> BalanceAccount:
ba = (
self.db.query(BalanceAccount)
.filter(BalanceAccount.employee_id == employee_id, BalanceAccount.year == year)
.first()
)
if not ba:
emp = self.db.query(Employee).filter(Employee.id == employee_id).first()
quota = emp.vacation_days_per_year if emp else 25
ba = BalanceAccount(employee_id=employee_id, year=year, yearly_quota=quota)
self.db.add(ba)
self.db.flush()
return ba
def update(self, employee_id: str, year: int, **kwargs) -> BalanceAccount:
ba = self.get_or_create(employee_id, year)
for k, v in kwargs.items():
if hasattr(ba, k) and v is not None:
setattr(ba, k, v)
self.db.flush()
return ba

View File

@@ -0,0 +1,106 @@
# -*- coding: utf-8 -*-
"""Business Rules Validierungsregeln für Abwesenheiten.
Jede Regel ist eine Funktion die (db, AbsenceCreate) nimmt und
bei Verstoss eine RuleViolation raised.
"""
import datetime
from sqlalchemy.orm import Session
from ..core.schemas import AbsenceCreate
from ..core.enums import ABSENCE_META
from ..employees.repository import EmployeeRepository
from .repository import AbsenceRepository, BalanceRepository
from ..config import MIN_STAFF_COUNT
class RuleViolation(Exception):
"""Wird geworfen wenn eine Business Rule verletzt ist."""
def __init__(self, rule: str, message: str):
self.rule = rule
self.message = message
super().__init__(f"[{rule}] {message}")
def _business_days(start: datetime.date, end: datetime.date) -> float:
count = 0
d = start
while d <= end:
if d.weekday() < 5:
count += 1
d += datetime.timedelta(days=1)
return count
def check_no_overlap(db: Session, data: AbsenceCreate):
"""Keine überlappenden Abwesenheiten für denselben Mitarbeiter."""
repo = AbsenceRepository(db)
overlaps = repo.find_overlapping(data.employee_id, data.start_date, data.end_date)
if overlaps:
first = overlaps[0]
raise RuleViolation(
"NO_OVERLAP",
f"Überschneidung mit bestehender Abwesenheit "
f"({first.start_date} {first.end_date}, {first.category.value})"
)
def check_balance(db: Session, data: AbsenceCreate):
"""Ferientage-Kontingent prüfen (nur für Kategorien mit deducts_balance)."""
meta = ABSENCE_META.get(data.category, {})
if not meta.get("deducts_balance"):
return
year = data.start_date.year
abs_repo = AbsenceRepository(db)
bal_repo = BalanceRepository(db)
balance = bal_repo.get_or_create(data.employee_id, year)
used = abs_repo.used_days(data.employee_id, year)
needed = _business_days(data.start_date, data.end_date)
remaining = balance.total_available - used
if needed > remaining:
raise RuleViolation(
"INSUFFICIENT_BALANCE",
f"Nicht genügend Ferientage. Benötigt: {needed:.0f}, "
f"Verfügbar: {remaining:.0f} (Anspruch: {balance.total_available:.0f}, "
f"Genommen: {used:.0f})"
)
def check_min_staffing(db: Session, data: AbsenceCreate):
"""Mindestbesetzung prüfen nicht alle gleichzeitig abwesend."""
emp_repo = EmployeeRepository(db)
abs_repo = AbsenceRepository(db)
total_active = emp_repo.count_active()
if total_active <= MIN_STAFF_COUNT:
return
d = data.start_date
while d <= data.end_date:
if d.weekday() < 5:
absent_count = abs_repo.count_absent_on_date(d)
present = total_active - absent_count - 1
if present < MIN_STAFF_COUNT:
raise RuleViolation(
"MIN_STAFFING",
f"Am {d.strftime('%d.%m.%Y')} wären nur noch {present} "
f"Mitarbeiter anwesend (Minimum: {MIN_STAFF_COUNT})"
)
d += datetime.timedelta(days=1)
ALL_RULES = [check_no_overlap, check_balance, check_min_staffing]
def validate_absence(db: Session, data: AbsenceCreate, skip_rules: list[str] | None = None):
"""Alle Regeln durchlaufen. Wirft RuleViolation beim ersten Verstoss."""
skip = set(skip_rules or [])
for rule_fn in ALL_RULES:
if rule_fn.__name__ in skip:
continue
rule_fn(db, data)

View File

@@ -0,0 +1,127 @@
# -*- coding: utf-8 -*-
"""Service Layer Orchestriert Business-Logik für Abwesenheiten."""
import datetime
from typing import Optional
from sqlalchemy.orm import Session
from ..core.models import Absence
from ..core.enums import AbsenceStatus, ABSENCE_META
from ..core.schemas import (
AbsenceCreate, AbsenceUpdate, AbsenceRead,
BalanceRead, BalanceAdjust, ApprovalDecision,
)
from .repository import AbsenceRepository, BalanceRepository
from .rules import validate_absence, _business_days, RuleViolation
from ..employees.repository import EmployeeRepository
class AbsenceService:
"""Zentrale Geschäftslogik wird von API UND Desktop-Client genutzt."""
def __init__(self, db: Session):
self.db = db
self.abs_repo = AbsenceRepository(db)
self.bal_repo = BalanceRepository(db)
self.emp_repo = EmployeeRepository(db)
def create_absence(self, data: AbsenceCreate) -> Absence:
emp = self.emp_repo.get_by_id(data.employee_id)
if not emp:
raise ValueError(f"Mitarbeiter {data.employee_id} nicht gefunden")
validate_absence(self.db, data)
bd = _business_days(data.start_date, data.end_date)
meta = ABSENCE_META.get(data.category, {})
absence = self.abs_repo.create(data, business_days=bd)
if not meta.get("requires_approval"):
absence.status = AbsenceStatus.APPROVED
self.db.commit()
return absence
def update_absence(self, absence_id: str, data: AbsenceUpdate) -> Optional[Absence]:
absence = self.abs_repo.get_by_id(absence_id)
if not absence:
return None
if data.start_date or data.end_date:
start = data.start_date or absence.start_date
end = data.end_date or absence.end_date
check_data = AbsenceCreate(
employee_id=absence.employee_id,
category=data.category or absence.category,
start_date=start,
end_date=end,
reason=data.reason or absence.reason,
)
validate_absence(self.db, check_data, skip_rules=[])
absence.business_days = _business_days(start, end)
result = self.abs_repo.update(absence_id, data)
self.db.commit()
return result
def delete_absence(self, absence_id: str) -> bool:
ok = self.abs_repo.delete(absence_id)
if ok:
self.db.commit()
return ok
def approve(self, decision: ApprovalDecision) -> Absence:
absence = self.abs_repo.get_by_id(decision.absence_id)
if not absence:
raise ValueError("Abwesenheit nicht gefunden")
if absence.status != AbsenceStatus.PENDING:
raise ValueError(f"Status ist bereits {absence.status.value}")
absence.approver_id = decision.approver_id
absence.approved_at = datetime.datetime.utcnow()
absence.status = AbsenceStatus.APPROVED if decision.approved else AbsenceStatus.REJECTED
self.db.commit()
return absence
def get_absences_for_employee(self, employee_id: str, year: Optional[int] = None) -> list[Absence]:
return self.abs_repo.list_for_employee(employee_id, year)
def get_absences_for_period(
self, start: datetime.date, end: datetime.date
) -> list[Absence]:
return self.abs_repo.list_for_period(start, end)
def get_all_absences(self, year: Optional[int] = None) -> list[Absence]:
return self.abs_repo.list_all(year)
def get_balance(self, employee_id: str, year: int) -> BalanceRead:
emp = self.emp_repo.get_by_id(employee_id)
if not emp:
raise ValueError(f"Mitarbeiter {employee_id} nicht gefunden")
ba = self.bal_repo.get_or_create(employee_id, year)
used = self.abs_repo.used_days(employee_id, year)
return BalanceRead(
employee_id=employee_id,
employee_name=emp.name,
year=year,
yearly_quota=ba.yearly_quota,
carry_over=ba.carry_over,
manual_adjustment=ba.manual_adjustment,
total_available=ba.total_available,
used_days=used,
remaining=ba.total_available - used,
)
def adjust_balance(self, employee_id: str, year: int, data: BalanceAdjust) -> BalanceRead:
self.bal_repo.update(
employee_id, year,
carry_over=data.carry_over,
manual_adjustment=data.manual_adjustment,
yearly_quota=data.yearly_quota,
)
self.db.commit()
return self.get_balance(employee_id, year)

View File

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

View File

@@ -0,0 +1,147 @@
# -*- coding: utf-8 -*-
"""
KI-Service zentrale Stelle für alle OpenAI-Aufrufe.
Kein Client hat direkten Zugriff auf den API-Key.
Desktop und Web schicken Requests hierher.
"""
import os
import tempfile
from typing import Optional
from openai import OpenAI
from dotenv import load_dotenv
load_dotenv()
_client: Optional[OpenAI] = None
TRANSCRIBE_MODEL = "whisper-1"
DEFAULT_SUMMARY_MODEL = "gpt-4o"
WHISPER_MEDICAL_PROMPT = (
"Medizinische Konsultation, Arzt-Patient-Gespräch, Schweizerdeutsch und Hochdeutsch. "
"Medizinische Fachbegriffe: Anamnese, Status, Befund, Diagnose, Therapie, Verlauf, "
"Medikation, Dosierung, Labor, Röntgen, MRI, CT, Sonographie, EKG, Spirometrie, "
"ICD-10, SOAP, Krankengeschichte, Kostengutsprache, Arztbrief."
)
DEFAULT_SYSTEM_PROMPT = (
"Du bist ein medizinischer Dokumentationsassistent. "
"Erstelle aus dem Transkript eine strukturierte Krankengeschichte im SOAP-Format. "
"Verwende medizinische Fachterminologie. Sprache: Deutsch."
)
def _get_client() -> OpenAI:
global _client
if _client is None:
api_key = os.getenv("OPENAI_API_KEY", "").strip()
if not api_key:
raise RuntimeError("OPENAI_API_KEY nicht gesetzt")
_client = OpenAI(api_key=api_key)
return _client
def transcribe_audio(wav_bytes: bytes, language: str = "de") -> dict:
"""WAV-Bytes transkribieren → {"text": "...", "tokens_estimated": int}"""
client = _get_client()
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
tmp.write(wav_bytes)
tmp_path = tmp.name
try:
with open(tmp_path, "rb") as f:
resp = client.audio.transcriptions.create(
model=TRANSCRIBE_MODEL,
file=f,
language=language,
prompt=WHISPER_MEDICAL_PROMPT,
)
text = getattr(resp, "text", "") or ""
if text.strip().startswith("Medizinische Dokumentation auf Deutsch"):
text = ""
tokens_est = len(text) // 4
return {"text": text, "tokens_estimated": tokens_est}
finally:
try:
os.unlink(tmp_path)
except OSError:
pass
def summarize_transcript(
transcript: str,
system_prompt: str = "",
model: str = DEFAULT_SUMMARY_MODEL,
) -> dict:
"""Transkript → Krankengeschichte (SOAP)."""
client = _get_client()
sys_prompt = system_prompt or DEFAULT_SYSTEM_PROMPT
resp = client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": sys_prompt},
{"role": "user", "content": f"TRANSKRIPT:\n{transcript}"},
],
)
content = resp.choices[0].message.content or ""
total_tokens = 0
if hasattr(resp, "usage") and resp.usage:
total_tokens = getattr(resp.usage, "total_tokens", 0)
return {"text": content, "tokens_used": total_tokens, "model": model}
def merge_kg(
existing_kg: str,
full_transcript: str,
system_prompt: str = "",
model: str = DEFAULT_SUMMARY_MODEL,
) -> dict:
"""Bestehende KG mit neuem Transkript zusammenführen."""
client = _get_client()
sys_prompt = system_prompt or DEFAULT_SYSTEM_PROMPT
user_text = (
f"BESTEHENDE KRANKENGESCHICHTE:\n{existing_kg}\n\n"
f"VOLLSTÄNDIGES TRANSKRIPT (bisher + Ergänzung):\n{full_transcript}\n\n"
"Aktualisiere die KG: neue Informationen aus dem Transkript in die "
"passenden Abschnitte einfügen, gleiche Überschriften beibehalten."
)
resp = client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": sys_prompt},
{"role": "user", "content": user_text},
],
)
content = resp.choices[0].message.content or ""
total_tokens = 0
if hasattr(resp, "usage") and resp.usage:
total_tokens = getattr(resp.usage, "total_tokens", 0)
return {"text": content, "tokens_used": total_tokens, "model": model}
def chat_completion(
messages: list[dict],
model: str = DEFAULT_SUMMARY_MODEL,
) -> dict:
"""Generischer Chat-Completion Aufruf."""
client = _get_client()
resp = client.chat.completions.create(model=model, messages=messages)
content = resp.choices[0].message.content or ""
total_tokens = 0
if hasattr(resp, "usage") and resp.usage:
total_tokens = getattr(resp.usage, "total_tokens", 0)
return {"text": content, "tokens_used": total_tokens, "model": model}

View File

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

View File

@@ -0,0 +1,45 @@
# -*- coding: utf-8 -*-
"""
FastAPI Hauptanwendung startet den API-Server.
uvicorn workforce_planner.api.app:app --reload
"""
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from ..database import init_db
from .routes_auth import router as auth_router
from .routes_employees import router as emp_router
from .routes_absences import router as abs_router, balance_router
from .routes_ai import router as ai_router
app = FastAPI(
title="Workforce Planner API",
description="Abwesenheits- & Arbeitsplanung Backend für Desktop + Web",
version="0.1.0",
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth_router, prefix="/api/v1")
app.include_router(emp_router, prefix="/api/v1")
app.include_router(abs_router, prefix="/api/v1")
app.include_router(balance_router, prefix="/api/v1")
app.include_router(ai_router, prefix="/api/v1")
@app.on_event("startup")
def _startup():
init_db()
@app.get("/api/v1/health")
def health():
return {"status": "ok", "service": "workforce_planner"}

View File

@@ -0,0 +1,35 @@
# -*- coding: utf-8 -*-
"""Audit-Logging zeichnet alle schreibenden Aktionen auf."""
from sqlalchemy.orm import Session
from ..core.models import AuditLog
def log_action(
db: Session,
*,
user_id: str | None,
action: str,
entity_type: str,
entity_id: str | None = None,
practice_id: str | None = None,
old_values: dict | None = None,
new_values: dict | None = None,
ip_address: str | None = None,
user_agent: str | None = None,
):
entry = AuditLog(
user_id=user_id,
action=action,
entity_type=entity_type,
entity_id=entity_id,
practice_id=practice_id,
old_values=old_values,
new_values=new_values,
ip_address=ip_address,
user_agent=user_agent,
)
db.add(entry)
db.flush()
return entry

View File

@@ -0,0 +1,66 @@
# -*- coding: utf-8 -*-
"""Einfache Token-basierte Authentifizierung (JWT)."""
import datetime
from typing import Optional
import bcrypt
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from jose import jwt, JWTError
from sqlalchemy.orm import Session
from ..config import SECRET_KEY, ACCESS_TOKEN_EXPIRE_MINUTES
from ..database import get_db
from ..core.models import Employee
from ..core.enums import EmployeeRole
_security = HTTPBearer(auto_error=False)
ALGORITHM = "HS256"
def hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
def verify_password(plain: str, hashed: str) -> bool:
return bcrypt.checkpw(plain.encode("utf-8"), hashed.encode("utf-8"))
def create_access_token(employee_id: str, role: str) -> str:
expire = datetime.datetime.utcnow() + datetime.timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
return jwt.encode(
{"sub": employee_id, "role": role, "exp": expire},
SECRET_KEY,
algorithm=ALGORITHM,
)
def get_current_user(
creds: Optional[HTTPAuthorizationCredentials] = Depends(_security),
db: Session = Depends(get_db),
) -> Employee:
if not creds:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Token fehlt")
try:
payload = jwt.decode(creds.credentials, SECRET_KEY, algorithms=[ALGORITHM])
emp_id = payload.get("sub")
if not emp_id:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Ungültiger Token")
except JWTError:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Ungültiger Token")
emp = db.query(Employee).filter(Employee.id == emp_id).first()
if not emp or not emp.is_active:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Benutzer nicht gefunden")
return emp
def require_role(*roles: EmployeeRole):
"""Dependency-Factory: erlaubt nur bestimmte Rollen."""
def _check(user: Employee = Depends(get_current_user)):
if user.role not in roles:
raise HTTPException(status.HTTP_403_FORBIDDEN, "Keine Berechtigung")
return user
return _check

View File

@@ -0,0 +1,128 @@
# -*- coding: utf-8 -*-
"""API-Routen für Abwesenheiten, Kontingente und Genehmigungen."""
import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from ..database import get_db
from ..core.schemas import (
AbsenceCreate, AbsenceUpdate, AbsenceRead,
BalanceRead, BalanceAdjust, ApprovalDecision,
)
from ..core.enums import EmployeeRole
from ..core.models import Employee
from ..absences.service import AbsenceService
from ..absences.rules import RuleViolation
from .auth import get_current_user, require_role
router = APIRouter(prefix="/absences", tags=["Abwesenheiten"])
@router.get("/", response_model=list[AbsenceRead])
def list_absences(
year: Optional[int] = None,
employee_id: Optional[str] = None,
start: Optional[datetime.date] = None,
end: Optional[datetime.date] = None,
db: Session = Depends(get_db),
_user: Employee = Depends(get_current_user),
):
svc = AbsenceService(db)
if employee_id:
return svc.get_absences_for_employee(employee_id, year)
if start and end:
return svc.get_absences_for_period(start, end)
return svc.get_all_absences(year)
@router.post("/", response_model=AbsenceRead, status_code=201)
def create_absence(
data: AbsenceCreate,
db: Session = Depends(get_db),
user: Employee = Depends(get_current_user),
):
svc = AbsenceService(db)
try:
return svc.create_absence(data)
except RuleViolation as e:
raise HTTPException(422, detail={"rule": e.rule, "message": e.message})
except ValueError as e:
raise HTTPException(400, str(e))
@router.patch("/{absence_id}", response_model=AbsenceRead)
def update_absence(
absence_id: str,
data: AbsenceUpdate,
db: Session = Depends(get_db),
_user: Employee = Depends(get_current_user),
):
svc = AbsenceService(db)
try:
result = svc.update_absence(absence_id, data)
except RuleViolation as e:
raise HTTPException(422, detail={"rule": e.rule, "message": e.message})
if not result:
raise HTTPException(404, "Abwesenheit nicht gefunden")
return result
@router.delete("/{absence_id}", status_code=204)
def delete_absence(
absence_id: str,
db: Session = Depends(get_db),
_user: Employee = Depends(get_current_user),
):
svc = AbsenceService(db)
if not svc.delete_absence(absence_id):
raise HTTPException(404, "Abwesenheit nicht gefunden")
@router.post("/approve", response_model=AbsenceRead)
def approve_absence(
decision: ApprovalDecision,
db: Session = Depends(get_db),
_user: Employee = Depends(require_role(EmployeeRole.ADMIN, EmployeeRole.MANAGER)),
):
svc = AbsenceService(db)
try:
return svc.approve(decision)
except ValueError as e:
raise HTTPException(400, str(e))
# ── Kontingent / Balance ────────────────────────────────────
balance_router = APIRouter(prefix="/balance", tags=["Ferientage-Kontingent"])
@balance_router.get("/{employee_id}/{year}", response_model=BalanceRead)
def get_balance(
employee_id: str,
year: int,
db: Session = Depends(get_db),
_user: Employee = Depends(get_current_user),
):
svc = AbsenceService(db)
try:
return svc.get_balance(employee_id, year)
except ValueError as e:
raise HTTPException(404, str(e))
@balance_router.patch("/{employee_id}/{year}", response_model=BalanceRead)
def adjust_balance(
employee_id: str,
year: int,
data: BalanceAdjust,
db: Session = Depends(get_db),
_user: Employee = Depends(require_role(EmployeeRole.ADMIN, EmployeeRole.MANAGER)),
):
svc = AbsenceService(db)
try:
return svc.adjust_balance(employee_id, year, data)
except ValueError as e:
raise HTTPException(404, str(e))

View File

@@ -0,0 +1,119 @@
# -*- coding: utf-8 -*-
"""API-Routen für KI-Services (Transkription, KG-Erstellung, Chat)."""
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
from pydantic import BaseModel
from ..core.models import Employee
from .auth import get_current_user
from ..ai import service as ai_service
router = APIRouter(prefix="/ai", tags=["KI-Service"])
# ── Transkription ──────────────────────────────
class TranscribeResponse(BaseModel):
text: str
tokens_estimated: int
@router.post("/transcribe", response_model=TranscribeResponse)
async def transcribe(
file: UploadFile = File(...),
language: str = Form("de"),
_user: Employee = Depends(get_current_user),
):
if not file.filename or not file.filename.lower().endswith(".wav"):
raise HTTPException(400, "Nur WAV-Dateien erlaubt")
wav_bytes = await file.read()
if len(wav_bytes) > 50 * 1024 * 1024:
raise HTTPException(413, "Datei zu gross (max 50 MB)")
try:
result = ai_service.transcribe_audio(wav_bytes, language)
return TranscribeResponse(**result)
except RuntimeError as e:
raise HTTPException(503, str(e))
except Exception as e:
raise HTTPException(500, f"Transkription fehlgeschlagen: {e}")
# ── KG-Erstellung ─────────────────────────────
class SummarizeRequest(BaseModel):
transcript: str
system_prompt: str = ""
model: str = "gpt-4o"
class SummarizeResponse(BaseModel):
text: str
tokens_used: int
model: str
@router.post("/summarize", response_model=SummarizeResponse)
def summarize(
data: SummarizeRequest,
_user: Employee = Depends(get_current_user),
):
try:
result = ai_service.summarize_transcript(
data.transcript, data.system_prompt, data.model
)
return SummarizeResponse(**result)
except RuntimeError as e:
raise HTTPException(503, str(e))
except Exception as e:
raise HTTPException(500, f"Zusammenfassung fehlgeschlagen: {e}")
# ── KG-Merge ──────────────────────────────────
class MergeRequest(BaseModel):
existing_kg: str
full_transcript: str
system_prompt: str = ""
model: str = "gpt-4o"
@router.post("/merge", response_model=SummarizeResponse)
def merge_kg(
data: MergeRequest,
_user: Employee = Depends(get_current_user),
):
try:
result = ai_service.merge_kg(
data.existing_kg, data.full_transcript,
data.system_prompt, data.model,
)
return SummarizeResponse(**result)
except RuntimeError as e:
raise HTTPException(503, str(e))
except Exception as e:
raise HTTPException(500, f"Merge fehlgeschlagen: {e}")
# ── Generischer Chat ──────────────────────────
class ChatRequest(BaseModel):
messages: list[dict]
model: str = "gpt-4o"
@router.post("/chat", response_model=SummarizeResponse)
def chat_completion(
data: ChatRequest,
_user: Employee = Depends(get_current_user),
):
try:
result = ai_service.chat_completion(data.messages, data.model)
return SummarizeResponse(**result)
except RuntimeError as e:
raise HTTPException(503, str(e))
except Exception as e:
raise HTTPException(500, f"Chat fehlgeschlagen: {e}")

View File

@@ -0,0 +1,48 @@
# -*- coding: utf-8 -*-
"""Login-Endpoint liefert JWT Token für Desktop + Web Clients."""
from pydantic import BaseModel
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from ..database import get_db
from ..core.models import Employee
from .auth import verify_password, create_access_token
from ..core.schemas import EmployeeRead
import datetime
router = APIRouter(prefix="/auth", tags=["Authentifizierung"])
class LoginRequest(BaseModel):
email: str
password: str
class LoginResponse(BaseModel):
access_token: str
token_type: str = "bearer"
employee: EmployeeRead
@router.post("/login", response_model=LoginResponse)
def login(data: LoginRequest, db: Session = Depends(get_db)):
emp = db.query(Employee).filter(Employee.email == data.email).first()
if not emp or not emp.password_hash:
raise HTTPException(401, "E-Mail oder Passwort falsch")
if not verify_password(data.password, emp.password_hash):
raise HTTPException(401, "E-Mail oder Passwort falsch")
if not emp.is_active:
raise HTTPException(403, "Konto deaktiviert")
emp.last_login = datetime.datetime.utcnow()
db.commit()
token = create_access_token(emp.id, emp.role.value)
return LoginResponse(
access_token=token,
employee=EmployeeRead.model_validate(emp),
)

View File

@@ -0,0 +1,75 @@
# -*- coding: utf-8 -*-
"""API-Routen für Mitarbeiterverwaltung."""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from ..database import get_db
from ..core.schemas import EmployeeCreate, EmployeeUpdate, EmployeeRead
from ..core.enums import EmployeeRole
from ..employees.service import EmployeeService
from .auth import get_current_user, require_role
from ..core.models import Employee
router = APIRouter(prefix="/employees", tags=["Mitarbeiter"])
@router.get("/", response_model=list[EmployeeRead])
def list_employees(
active_only: bool = True,
db: Session = Depends(get_db),
_user: Employee = Depends(get_current_user),
):
svc = EmployeeService(db)
return svc.list_employees(active_only)
@router.get("/{employee_id}", response_model=EmployeeRead)
def get_employee(
employee_id: str,
db: Session = Depends(get_db),
_user: Employee = Depends(get_current_user),
):
svc = EmployeeService(db)
emp = svc.get_employee(employee_id)
if not emp:
raise HTTPException(404, "Mitarbeiter nicht gefunden")
return emp
@router.post("/", response_model=EmployeeRead, status_code=201)
def create_employee(
data: EmployeeCreate,
db: Session = Depends(get_db),
_user: Employee = Depends(require_role(EmployeeRole.ADMIN, EmployeeRole.MANAGER)),
):
svc = EmployeeService(db)
try:
return svc.create_employee(data)
except ValueError as e:
raise HTTPException(409, str(e))
@router.patch("/{employee_id}", response_model=EmployeeRead)
def update_employee(
employee_id: str,
data: EmployeeUpdate,
db: Session = Depends(get_db),
_user: Employee = Depends(require_role(EmployeeRole.ADMIN, EmployeeRole.MANAGER)),
):
svc = EmployeeService(db)
emp = svc.update_employee(employee_id, data)
if not emp:
raise HTTPException(404, "Mitarbeiter nicht gefunden")
return emp
@router.delete("/{employee_id}", status_code=204)
def delete_employee(
employee_id: str,
db: Session = Depends(get_db),
_user: Employee = Depends(require_role(EmployeeRole.ADMIN)),
):
svc = EmployeeService(db)
if not svc.delete_employee(employee_id):
raise HTTPException(404, "Mitarbeiter nicht gefunden")

View File

@@ -0,0 +1,266 @@
# -*- coding: utf-8 -*-
"""
API-Client für Desktop-Anwendungen.
Stellt alle Backend-Aufrufe als einfache Funktionen bereit.
Hybrid-Modus: versucht Backend, fällt auf lokalen OpenAI-Call zurück.
from workforce_planner.api_client import WorkforceClient
client = WorkforceClient("http://localhost:8000")
client.login("admin@praxis.ch", "admin123")
result = client.transcribe_audio("recording.wav")
"""
import os
import threading
from typing import Optional, Callable
import requests
from requests.exceptions import ConnectionError, Timeout, RequestException
_BACKEND_TIMEOUT_TRANSCRIBE = 20
_BACKEND_TIMEOUT_DEFAULT = 12
class WorkforceClient:
"""HTTP-Client für das workforce_planner Backend."""
def __init__(self, base_url: str = "http://localhost:8000"):
self.base_url = base_url.rstrip("/")
self.token: Optional[str] = None
self.employee: Optional[dict] = None
self._session = requests.Session()
@property
def _headers(self) -> dict:
h = {}
if self.token:
h["Authorization"] = f"Bearer {self.token}"
return h
@property
def is_authenticated(self) -> bool:
return self.token is not None
def is_backend_available(self) -> bool:
try:
r = self._session.get(
f"{self.base_url}/api/v1/health", timeout=3
)
return r.status_code == 200
except RequestException:
return False
# ─── Auth ───────────────────────────────────
def login(self, email: str, password: str) -> dict:
r = self._session.post(
f"{self.base_url}/api/v1/auth/login",
json={"email": email, "password": password},
timeout=_BACKEND_TIMEOUT_DEFAULT,
)
r.raise_for_status()
data = r.json()
self.token = data["access_token"]
self.employee = data["employee"]
return data
def logout(self):
self.token = None
self.employee = None
# ─── Employees ──────────────────────────────
def list_employees(self, active_only: bool = True) -> list[dict]:
r = self._session.get(
f"{self.base_url}/api/v1/employees/",
headers=self._headers,
params={"active_only": active_only},
timeout=_BACKEND_TIMEOUT_DEFAULT,
)
r.raise_for_status()
return r.json()
def create_employee(self, data: dict) -> dict:
r = self._session.post(
f"{self.base_url}/api/v1/employees/",
headers=self._headers,
json=data,
timeout=_BACKEND_TIMEOUT_DEFAULT,
)
r.raise_for_status()
return r.json()
def update_employee(self, employee_id: str, data: dict) -> dict:
r = self._session.patch(
f"{self.base_url}/api/v1/employees/{employee_id}",
headers=self._headers,
json=data,
timeout=_BACKEND_TIMEOUT_DEFAULT,
)
r.raise_for_status()
return r.json()
def delete_employee(self, employee_id: str):
r = self._session.delete(
f"{self.base_url}/api/v1/employees/{employee_id}",
headers=self._headers,
timeout=_BACKEND_TIMEOUT_DEFAULT,
)
r.raise_for_status()
# ─── Absences ───────────────────────────────
def list_absences(self, year: Optional[int] = None, employee_id: Optional[str] = None) -> list[dict]:
params = {}
if year:
params["year"] = year
if employee_id:
params["employee_id"] = employee_id
r = self._session.get(
f"{self.base_url}/api/v1/absences/",
headers=self._headers,
params=params,
timeout=_BACKEND_TIMEOUT_DEFAULT,
)
r.raise_for_status()
return r.json()
def create_absence(self, data: dict) -> dict:
r = self._session.post(
f"{self.base_url}/api/v1/absences/",
headers=self._headers,
json=data,
timeout=_BACKEND_TIMEOUT_DEFAULT,
)
if r.status_code == 422:
detail = r.json().get("detail", {})
raise ValueError(detail.get("message", str(detail)))
r.raise_for_status()
return r.json()
def delete_absence(self, absence_id: str):
r = self._session.delete(
f"{self.base_url}/api/v1/absences/{absence_id}",
headers=self._headers,
timeout=_BACKEND_TIMEOUT_DEFAULT,
)
r.raise_for_status()
def get_balance(self, employee_id: str, year: int) -> dict:
r = self._session.get(
f"{self.base_url}/api/v1/balance/{employee_id}/{year}",
headers=self._headers,
timeout=_BACKEND_TIMEOUT_DEFAULT,
)
r.raise_for_status()
return r.json()
# ─── KI-Service (Hybrid) ────────────────────
def transcribe_audio(self, wav_path: str, language: str = "de") -> dict:
"""
Sendet WAV an Backend. Bei Fehler: wirft Exception
(Fallback wird vom Caller gehandhabt).
"""
with open(wav_path, "rb") as f:
r = self._session.post(
f"{self.base_url}/api/v1/ai/transcribe",
headers=self._headers,
files={"file": (os.path.basename(wav_path), f, "audio/wav")},
data={"language": language},
timeout=_BACKEND_TIMEOUT_TRANSCRIBE,
)
r.raise_for_status()
return r.json()
def summarize(self, transcript: str, system_prompt: str = "", model: str = "gpt-4o") -> dict:
r = self._session.post(
f"{self.base_url}/api/v1/ai/summarize",
headers=self._headers,
json={"transcript": transcript, "system_prompt": system_prompt, "model": model},
timeout=30,
)
r.raise_for_status()
return r.json()
def merge_kg(self, existing_kg: str, full_transcript: str,
system_prompt: str = "", model: str = "gpt-4o") -> dict:
r = self._session.post(
f"{self.base_url}/api/v1/ai/merge",
headers=self._headers,
json={
"existing_kg": existing_kg,
"full_transcript": full_transcript,
"system_prompt": system_prompt,
"model": model,
},
timeout=30,
)
r.raise_for_status()
return r.json()
def chat(self, messages: list[dict], model: str = "gpt-4o") -> dict:
r = self._session.post(
f"{self.base_url}/api/v1/ai/chat",
headers=self._headers,
json={"messages": messages, "model": model},
timeout=30,
)
r.raise_for_status()
return r.json()
# ═══════════════════════════════════════════════════════════
# HYBRID-TRANSKRIPTION: Backend mit lokalem Fallback
# ═══════════════════════════════════════════════════════════
def hybrid_transcribe(
wav_path: str,
api_client: Optional[WorkforceClient],
local_transcribe_fn: Callable[[str], str],
on_success: Callable[[str, str], None],
on_error: Optional[Callable[[str], None]] = None,
):
"""
Hybrid-Transkription: versucht Backend, fällt auf lokal zurück.
Läuft in eigenem Thread → UI blockiert nie.
Args:
wav_path: Pfad zur WAV-Datei
api_client: WorkforceClient (oder None = immer lokal)
local_transcribe_fn: Bestehende lokale Transkription (self.transcribe_wav)
on_success: Callback(text, source) source = "backend" oder "local"
on_error: Callback(error_message) bei totalem Fehler
"""
def _worker():
source = "local"
text = ""
# ── Versuch 1: Backend ──
if api_client and api_client.is_authenticated:
try:
result = api_client.transcribe_audio(wav_path)
text = result.get("text", "")
source = "backend"
except (ConnectionError, Timeout):
pass
except RequestException:
pass
# ── Versuch 2: Lokal (Fallback) ──
if not text and source == "local":
try:
text = local_transcribe_fn(wav_path)
source = "local"
except Exception as exc:
if on_error:
on_error(f"Transkription fehlgeschlagen: {exc}")
return
on_success(text, source)
t = threading.Thread(target=_worker, daemon=True)
t.start()
return t

View File

@@ -0,0 +1,80 @@
# -*- coding: utf-8 -*-
"""Zentrale Konfiguration über Umgebungsvariablen oder .env steuerbar."""
import os
import sys
import secrets
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
BASE_DIR = Path(__file__).resolve().parent.parent
DATABASE_URL = os.getenv(
"WP_DATABASE_URL",
f"sqlite:///{BASE_DIR / 'workforce_planner.db'}"
)
_WEAK_PATTERNS = ("dev", "test", "password", "secret", "changeme", "default", "example", "123")
def _reject_weak(pattern: str):
print(
f"FEHLER: AZA_SECRET_KEY enthält triviales Muster ('{pattern}').\n"
"Verwenden Sie einen kryptografisch sicheren Key.",
file=sys.stderr,
)
sys.exit(1)
def _load_secret_key() -> str:
"""Lädt AZA_SECRET_KEY aus ENV. Fail-Start bei fehlendem oder schwachem Key.
Im DEV-Modus (AZA_ENV=dev) wird ein temporärer Key auto-generiert."""
key = os.getenv("AZA_SECRET_KEY", "").strip()
env_mode = os.getenv("AZA_ENV", "").strip().lower()
if not key:
if env_mode == "dev":
key = secrets.token_hex(64)
print(
"WARNUNG: AZA_SECRET_KEY nicht gesetzt. "
"Auto-generierter Key (nur gültig für diese Session, AZA_ENV=dev).",
file=sys.stderr,
)
return key
print(
"FEHLER: AZA_SECRET_KEY ist nicht gesetzt.\n"
"Setzen Sie die Umgebungsvariable mit mindestens 32 Zeichen.\n"
"Beispiel: AZA_SECRET_KEY=$(python -c \"import secrets; print(secrets.token_hex(64))\")\n"
"Für Entwicklung: AZA_ENV=dev erlaubt auto-generierten Key.",
file=sys.stderr,
)
sys.exit(1)
if len(key) < 32:
print(
f"FEHLER: AZA_SECRET_KEY ist zu kurz ({len(key)} Zeichen, Minimum: 32).\n"
"Generieren Sie einen sicheren Key:\n"
" python -c \"import secrets; print(secrets.token_hex(64))\"",
file=sys.stderr,
)
sys.exit(1)
key_lower = key.lower()
for pattern in _WEAK_PATTERNS:
if key_lower == pattern:
_reject_weak(pattern)
if key_lower.startswith(pattern) and (
len(key) < 40 or not key[len(pattern):len(pattern)+1].isalnum()
):
_reject_weak(pattern)
return key
SECRET_KEY = _load_secret_key()
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("WP_TOKEN_EXPIRE", "480"))
MIN_STAFF_COUNT = int(os.getenv("WP_MIN_STAFF", "2"))
DEBUG = os.getenv("WP_DEBUG", "0") == "1"

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 = ""

View File

@@ -0,0 +1,30 @@
# -*- coding: utf-8 -*-
"""Database Engine & Session SQLite (lokal) oder PostgreSQL (Produktion)."""
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, Session
from .config import DATABASE_URL
from .core.models import Base
engine = create_engine(
DATABASE_URL,
echo=False,
connect_args={"check_same_thread": False} if "sqlite" in DATABASE_URL else {},
)
SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False)
def init_db():
"""Erstellt alle Tabellen falls sie noch nicht existieren."""
Base.metadata.create_all(bind=engine)
def get_db() -> Session:
"""Dependency für FastAPI liefert eine DB-Session pro Request."""
db = SessionLocal()
try:
yield db
finally:
db.close()

View File

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

View File

@@ -0,0 +1,60 @@
# -*- coding: utf-8 -*-
"""Repository für Employee-CRUD einziger Ort der direkt mit der DB spricht."""
from typing import Optional
from sqlalchemy.orm import Session
from ..core.models import Employee
from ..core.schemas import EmployeeCreate, EmployeeUpdate
class EmployeeRepository:
def __init__(self, db: Session):
self.db = db
def get_by_id(self, employee_id: str) -> Optional[Employee]:
return self.db.query(Employee).filter(Employee.id == employee_id).first()
def get_by_email(self, email: str) -> Optional[Employee]:
return self.db.query(Employee).filter(Employee.email == email).first()
def list_all(self, active_only: bool = True) -> list[Employee]:
q = self.db.query(Employee)
if active_only:
q = q.filter(Employee.is_active == True)
return q.order_by(Employee.name).all()
def list_by_department(self, department: str) -> list[Employee]:
return (
self.db.query(Employee)
.filter(Employee.department == department, Employee.is_active == True)
.order_by(Employee.name)
.all()
)
def create(self, data: EmployeeCreate) -> Employee:
emp = Employee(**data.model_dump())
self.db.add(emp)
self.db.flush()
return emp
def update(self, employee_id: str, data: EmployeeUpdate) -> Optional[Employee]:
emp = self.get_by_id(employee_id)
if not emp:
return None
for field, value in data.model_dump(exclude_unset=True).items():
setattr(emp, field, value)
self.db.flush()
return emp
def delete(self, employee_id: str) -> bool:
emp = self.get_by_id(employee_id)
if not emp:
return False
self.db.delete(emp)
self.db.flush()
return True
def count_active(self) -> int:
return self.db.query(Employee).filter(Employee.is_active == True).count()

View File

@@ -0,0 +1,53 @@
# -*- coding: utf-8 -*-
"""Service Layer für Mitarbeiterverwaltung."""
from typing import Optional
from sqlalchemy.orm import Session
from ..core.models import Employee
from ..core.schemas import EmployeeCreate, EmployeeUpdate, EmployeeRead
from .repository import EmployeeRepository
class EmployeeService:
def __init__(self, db: Session):
self.db = db
self.repo = EmployeeRepository(db)
def create_employee(self, data: EmployeeCreate) -> Employee:
if data.email:
existing = self.repo.get_by_email(data.email)
if existing:
raise ValueError(f"E-Mail {data.email} ist bereits vergeben")
emp = self.repo.create(data)
self.db.commit()
return emp
def update_employee(self, employee_id: str, data: EmployeeUpdate) -> Optional[Employee]:
if data.email:
existing = self.repo.get_by_email(data.email)
if existing and existing.id != employee_id:
raise ValueError(f"E-Mail {data.email} ist bereits vergeben")
emp = self.repo.update(employee_id, data)
if emp:
self.db.commit()
return emp
def delete_employee(self, employee_id: str) -> bool:
ok = self.repo.delete(employee_id)
if ok:
self.db.commit()
return ok
def deactivate_employee(self, employee_id: str) -> Optional[Employee]:
return self.update_employee(employee_id, EmployeeUpdate(is_active=False))
def get_employee(self, employee_id: str) -> Optional[Employee]:
return self.repo.get_by_id(employee_id)
def list_employees(self, active_only: bool = True) -> list[Employee]:
return self.repo.list_all(active_only)
def list_by_department(self, department: str) -> list[Employee]:
return self.repo.list_by_department(department)

View File

@@ -0,0 +1,9 @@
# workforce_planner Backend Dependencies
fastapi
uvicorn[standard]
sqlalchemy
pydantic[email]
python-dotenv
python-jose[cryptography]
passlib[bcrypt]
stripe>=8.0.0

View File

@@ -0,0 +1,109 @@
# -*- coding: utf-8 -*-
"""
Seed-Script: erstellt Datenbank-Tabellen und fügt Test-Mitarbeiter ein.
python -m workforce_planner.seed
"""
from .database import init_db, SessionLocal
from .core.models import Employee, Practice, BalanceAccount
from .core.enums import EmployeeRole
from .api.auth import hash_password
def seed():
print("Erstelle Tabellen ...")
init_db()
db = SessionLocal()
try:
if db.query(Practice).count() > 0:
print("Datenbank enthält bereits Daten überspringe Seed.")
return
praxis = Practice(
id="praxis_1",
name="Praxis Muster",
address="Musterstrasse 1, 8000 Zürich",
phone="+41 44 123 45 67",
)
db.add(praxis)
db.flush()
employees = [
Employee(
id="admin_1",
practice_id=praxis.id,
name="Dr. Admin",
email="admin@praxis.ch",
password_hash=hash_password("admin123"),
role=EmployeeRole.ADMIN,
vacation_days_per_year=25,
),
Employee(
id="manager_1",
practice_id=praxis.id,
name="Anna Meier",
email="anna@praxis.ch",
password_hash=hash_password("test123"),
role=EmployeeRole.MANAGER,
department="Leitung",
vacation_days_per_year=25,
),
Employee(
id="emp_1",
practice_id=praxis.id,
name="Stefan Müller",
email="stefan@praxis.ch",
password_hash=hash_password("test123"),
role=EmployeeRole.EMPLOYEE,
department="MPA",
vacation_days_per_year=25,
),
Employee(
id="emp_2",
practice_id=praxis.id,
name="Lisa Weber",
email="lisa@praxis.ch",
password_hash=hash_password("test123"),
role=EmployeeRole.EMPLOYEE,
department="MPA",
vacation_days_per_year=20,
),
Employee(
id="emp_3",
practice_id=praxis.id,
name="Marco Brunner",
email="marco@praxis.ch",
password_hash=hash_password("test123"),
role=EmployeeRole.EMPLOYEE,
department="Labor",
vacation_days_per_year=25,
),
]
for emp in employees:
db.add(emp)
db.flush()
import datetime
year = datetime.date.today().year
for emp in employees:
ba = BalanceAccount(
employee_id=emp.id,
year=year,
yearly_quota=emp.vacation_days_per_year,
)
db.add(ba)
db.commit()
print(f"Seed erfolgreich: 1 Praxis, {len(employees)} Mitarbeiter, Jahr {year}")
for emp in employees:
print(f" {emp.role.value:10s} {emp.name:20s} {emp.email} (Passwort: {'admin123' if emp.role == EmployeeRole.ADMIN else 'test123'})")
finally:
db.close()
if __name__ == "__main__":
seed()

View File

@@ -0,0 +1,63 @@
# -*- coding: utf-8 -*-
"""Schnelltest für die API-Endpunkte."""
import requests
BASE = "http://127.0.0.1:8000/api/v1"
def main():
print("=== Health ===")
r = requests.get(f"{BASE}/health")
print(f" {r.status_code}: {r.json()}")
print("\n=== Login (admin@praxis.ch) ===")
r = requests.post(f"{BASE}/auth/login", json={"email": "admin@praxis.ch", "password": "admin123"})
print(f" {r.status_code}")
data = r.json()
token = data["access_token"]
emp = data["employee"]
print(f" User: {emp['name']} ({emp['role']})")
print(f" Token: {token[:40]}...")
headers = {"Authorization": f"Bearer {token}"}
print("\n=== Mitarbeiter auflisten ===")
r = requests.get(f"{BASE}/employees/", headers=headers)
print(f" {r.status_code}: {len(r.json())} Mitarbeiter")
for e in r.json():
print(f" {e['name']:20s} {e['role']:10s} {e['vacation_days_per_year']} Tage/Jahr")
print("\n=== Abwesenheit erstellen (Stefan, 16-20 Mär Skiferien) ===")
r = requests.post(f"{BASE}/absences/", headers=headers, json={
"employee_id": "emp_1",
"category": "urlaub",
"start_date": "2026-03-16",
"end_date": "2026-03-20",
"reason": "Skiferien",
})
print(f" {r.status_code}")
ab = r.json()
if r.status_code == 201:
print(f" {ab['category']} {ab['start_date']} {ab['end_date']} ({ab['business_days']} Arbeitstage)")
print(f" Status: {ab['status']}")
else:
print(f" Fehler: {ab}")
print("\n=== Ferientage-Konto (Stefan, 2026) ===")
r = requests.get(f"{BASE}/balance/emp_1/2026", headers=headers)
bal = r.json()
print(f" {r.status_code}")
print(f" Anspruch: {bal['total_available']} | Genommen: {bal['used_days']} | Rest: {bal['remaining']}")
print("\n=== Alle Abwesenheiten 2026 ===")
r = requests.get(f"{BASE}/absences/?year=2026", headers=headers)
print(f" {r.status_code}: {len(r.json())} Einträge")
for a in r.json():
print(f" {a['employee_id']:12s} {a['category']:15s} {a['start_date']} {a['end_date']}")
print("\n=== FERTIG alle Tests bestanden ===")
if __name__ == "__main__":
main()