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

View File

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

View File

@@ -0,0 +1,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")