Initial commit
This commit is contained in:
@@ -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")
|
||||
Reference in New Issue
Block a user