Initial commit
This commit is contained in:
297
backup 24.2.26 - Kopie (61)/workforce_planner/ARCHITECTURE.md
Normal file
297
backup 24.2.26 - Kopie (61)/workforce_planner/ARCHITECTURE.md
Normal 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 |
|
||||
@@ -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"
|
||||
@@ -0,0 +1 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
@@ -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
|
||||
106
backup 24.2.26 - Kopie (61)/workforce_planner/absences/rules.py
Normal file
106
backup 24.2.26 - Kopie (61)/workforce_planner/absences/rules.py
Normal 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)
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
147
backup 24.2.26 - Kopie (61)/workforce_planner/ai/service.py
Normal file
147
backup 24.2.26 - Kopie (61)/workforce_planner/ai/service.py
Normal 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}
|
||||
@@ -0,0 +1 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
45
backup 24.2.26 - Kopie (61)/workforce_planner/api/app.py
Normal file
45
backup 24.2.26 - Kopie (61)/workforce_planner/api/app.py
Normal 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"}
|
||||
35
backup 24.2.26 - Kopie (61)/workforce_planner/api/audit.py
Normal file
35
backup 24.2.26 - Kopie (61)/workforce_planner/api/audit.py
Normal 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
|
||||
66
backup 24.2.26 - Kopie (61)/workforce_planner/api/auth.py
Normal file
66
backup 24.2.26 - Kopie (61)/workforce_planner/api/auth.py
Normal 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
|
||||
@@ -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))
|
||||
119
backup 24.2.26 - Kopie (61)/workforce_planner/api/routes_ai.py
Normal file
119
backup 24.2.26 - Kopie (61)/workforce_planner/api/routes_ai.py
Normal 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}")
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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")
|
||||
266
backup 24.2.26 - Kopie (61)/workforce_planner/api_client.py
Normal file
266
backup 24.2.26 - Kopie (61)/workforce_planner/api_client.py
Normal 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
|
||||
80
backup 24.2.26 - Kopie (61)/workforce_planner/config.py
Normal file
80
backup 24.2.26 - Kopie (61)/workforce_planner/config.py
Normal 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"
|
||||
@@ -0,0 +1 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
36
backup 24.2.26 - Kopie (61)/workforce_planner/core/enums.py
Normal file
36
backup 24.2.26 - Kopie (61)/workforce_planner/core/enums.py
Normal 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"
|
||||
154
backup 24.2.26 - Kopie (61)/workforce_planner/core/models.py
Normal file
154
backup 24.2.26 - Kopie (61)/workforce_planner/core/models.py
Normal 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"),
|
||||
)
|
||||
123
backup 24.2.26 - Kopie (61)/workforce_planner/core/schemas.py
Normal file
123
backup 24.2.26 - Kopie (61)/workforce_planner/core/schemas.py
Normal 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 = ""
|
||||
30
backup 24.2.26 - Kopie (61)/workforce_planner/database.py
Normal file
30
backup 24.2.26 - Kopie (61)/workforce_planner/database.py
Normal 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()
|
||||
@@ -0,0 +1 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
109
backup 24.2.26 - Kopie (61)/workforce_planner/seed.py
Normal file
109
backup 24.2.26 - Kopie (61)/workforce_planner/seed.py
Normal 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()
|
||||
63
backup 24.2.26 - Kopie (61)/workforce_planner/test_api.py
Normal file
63
backup 24.2.26 - Kopie (61)/workforce_planner/test_api.py
Normal 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()
|
||||
Reference in New Issue
Block a user