Initial commit: breakpilot-core - Shared Infrastructure
Docker Compose with 24+ services: - PostgreSQL (PostGIS), Valkey, MinIO, Qdrant - Vault (PKI/TLS), Nginx (Reverse Proxy) - Backend Core API, Consent Service, Billing Service - RAG Service, Embedding Service - Gitea, Woodpecker CI/CD - Night Scheduler, Health Aggregator - Jitsi (Web/XMPP/JVB/Jicofo), Mailpit Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
15
backend-core/.dockerignore
Normal file
15
backend-core/.dockerignore
Normal file
@@ -0,0 +1,15 @@
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
.git
|
||||
.env
|
||||
.env.*
|
||||
.pytest_cache
|
||||
venv
|
||||
.venv
|
||||
*.egg-info
|
||||
.DS_Store
|
||||
security-reports
|
||||
scripts
|
||||
tests
|
||||
docs
|
||||
64
backend-core/Dockerfile
Normal file
64
backend-core/Dockerfile
Normal file
@@ -0,0 +1,64 @@
|
||||
# ============================================================
|
||||
# BreakPilot Core Backend -- Multi-stage Docker build
|
||||
# ============================================================
|
||||
|
||||
# ---------- Build stage ----------
|
||||
FROM python:3.12-slim-bookworm AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Build-time system libs (needed for asyncpg / psycopg2)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
libpq-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
|
||||
RUN python -m venv /opt/venv
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
RUN pip install --no-cache-dir --upgrade pip && \
|
||||
pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# ---------- Runtime stage ----------
|
||||
FROM python:3.12-slim-bookworm
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Runtime system libs
|
||||
# - libpango / libgdk-pixbuf / shared-mime-info -> WeasyPrint (pdf_service)
|
||||
# - libgl1 / libglib2.0-0 -> OpenCV (file_processor)
|
||||
# - curl -> healthcheck
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libpango-1.0-0 \
|
||||
libpangocairo-1.0-0 \
|
||||
libgdk-pixbuf-2.0-0 \
|
||||
libffi-dev \
|
||||
shared-mime-info \
|
||||
libgl1 \
|
||||
libglib2.0-0 \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy virtualenv from builder
|
||||
COPY --from=builder /opt/venv /opt/venv
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
# Non-root user
|
||||
RUN useradd --create-home --shell /bin/bash appuser
|
||||
|
||||
# Copy application code
|
||||
COPY --chown=appuser:appuser . .
|
||||
|
||||
USER appuser
|
||||
|
||||
# Python tweaks
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://127.0.0.1:8000/health || exit 1
|
||||
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
55
backend-core/auth/__init__.py
Normal file
55
backend-core/auth/__init__.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""
|
||||
BreakPilot Authentication Module
|
||||
|
||||
Hybrid authentication supporting both Keycloak and local JWT tokens.
|
||||
"""
|
||||
|
||||
from .keycloak_auth import (
|
||||
# Config
|
||||
KeycloakConfig,
|
||||
KeycloakUser,
|
||||
|
||||
# Authenticators
|
||||
KeycloakAuthenticator,
|
||||
HybridAuthenticator,
|
||||
|
||||
# Exceptions
|
||||
KeycloakAuthError,
|
||||
TokenExpiredError,
|
||||
TokenInvalidError,
|
||||
KeycloakConfigError,
|
||||
|
||||
# Factory functions
|
||||
get_keycloak_config_from_env,
|
||||
get_authenticator,
|
||||
get_auth,
|
||||
|
||||
# FastAPI dependencies
|
||||
get_current_user,
|
||||
require_role,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Config
|
||||
"KeycloakConfig",
|
||||
"KeycloakUser",
|
||||
|
||||
# Authenticators
|
||||
"KeycloakAuthenticator",
|
||||
"HybridAuthenticator",
|
||||
|
||||
# Exceptions
|
||||
"KeycloakAuthError",
|
||||
"TokenExpiredError",
|
||||
"TokenInvalidError",
|
||||
"KeycloakConfigError",
|
||||
|
||||
# Factory functions
|
||||
"get_keycloak_config_from_env",
|
||||
"get_authenticator",
|
||||
"get_auth",
|
||||
|
||||
# FastAPI dependencies
|
||||
"get_current_user",
|
||||
"require_role",
|
||||
]
|
||||
515
backend-core/auth/keycloak_auth.py
Normal file
515
backend-core/auth/keycloak_auth.py
Normal file
@@ -0,0 +1,515 @@
|
||||
"""
|
||||
Keycloak Authentication Module
|
||||
|
||||
Implements token validation against Keycloak JWKS endpoint.
|
||||
This module handles authentication (who is the user?), while
|
||||
rbac.py handles authorization (what can the user do?).
|
||||
|
||||
Architecture:
|
||||
- Keycloak validates JWT tokens and provides basic identity
|
||||
- Our custom rbac.py handles fine-grained permissions
|
||||
"""
|
||||
|
||||
import os
|
||||
import httpx
|
||||
import jwt
|
||||
from jwt import PyJWKClient
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, Dict, Any, List
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class KeycloakConfig:
|
||||
"""Keycloak connection configuration."""
|
||||
server_url: str
|
||||
realm: str
|
||||
client_id: str
|
||||
client_secret: Optional[str] = None
|
||||
verify_ssl: bool = True
|
||||
|
||||
@property
|
||||
def issuer_url(self) -> str:
|
||||
return f"{self.server_url}/realms/{self.realm}"
|
||||
|
||||
@property
|
||||
def jwks_url(self) -> str:
|
||||
return f"{self.issuer_url}/protocol/openid-connect/certs"
|
||||
|
||||
@property
|
||||
def token_url(self) -> str:
|
||||
return f"{self.issuer_url}/protocol/openid-connect/token"
|
||||
|
||||
@property
|
||||
def userinfo_url(self) -> str:
|
||||
return f"{self.issuer_url}/protocol/openid-connect/userinfo"
|
||||
|
||||
|
||||
@dataclass
|
||||
class KeycloakUser:
|
||||
"""User information extracted from Keycloak token."""
|
||||
user_id: str # Keycloak subject (sub)
|
||||
email: str
|
||||
email_verified: bool
|
||||
name: Optional[str]
|
||||
given_name: Optional[str]
|
||||
family_name: Optional[str]
|
||||
realm_roles: List[str] # Keycloak realm roles
|
||||
client_roles: Dict[str, List[str]] # Client-specific roles
|
||||
groups: List[str] # Keycloak groups
|
||||
tenant_id: Optional[str] # Custom claim for school/tenant
|
||||
raw_claims: Dict[str, Any] # All claims for debugging
|
||||
|
||||
def has_realm_role(self, role: str) -> bool:
|
||||
"""Check if user has a specific realm role."""
|
||||
return role in self.realm_roles
|
||||
|
||||
def has_client_role(self, client_id: str, role: str) -> bool:
|
||||
"""Check if user has a specific client role."""
|
||||
client_roles = self.client_roles.get(client_id, [])
|
||||
return role in client_roles
|
||||
|
||||
def is_admin(self) -> bool:
|
||||
"""Check if user has admin role."""
|
||||
return self.has_realm_role("admin") or self.has_realm_role("schul_admin")
|
||||
|
||||
def is_teacher(self) -> bool:
|
||||
"""Check if user is a teacher."""
|
||||
return self.has_realm_role("teacher") or self.has_realm_role("lehrer")
|
||||
|
||||
|
||||
class KeycloakAuthError(Exception):
|
||||
"""Base exception for Keycloak authentication errors."""
|
||||
pass
|
||||
|
||||
|
||||
class TokenExpiredError(KeycloakAuthError):
|
||||
"""Token has expired."""
|
||||
pass
|
||||
|
||||
|
||||
class TokenInvalidError(KeycloakAuthError):
|
||||
"""Token is invalid."""
|
||||
pass
|
||||
|
||||
|
||||
class KeycloakConfigError(KeycloakAuthError):
|
||||
"""Keycloak configuration error."""
|
||||
pass
|
||||
|
||||
|
||||
class KeycloakAuthenticator:
|
||||
"""
|
||||
Validates JWT tokens against Keycloak.
|
||||
|
||||
Usage:
|
||||
config = KeycloakConfig(
|
||||
server_url="https://keycloak.example.com",
|
||||
realm="breakpilot",
|
||||
client_id="breakpilot-backend"
|
||||
)
|
||||
auth = KeycloakAuthenticator(config)
|
||||
|
||||
user = await auth.validate_token(token)
|
||||
if user.is_teacher():
|
||||
# Grant access
|
||||
"""
|
||||
|
||||
def __init__(self, config: KeycloakConfig):
|
||||
self.config = config
|
||||
self._jwks_client: Optional[PyJWKClient] = None
|
||||
self._http_client: Optional[httpx.AsyncClient] = None
|
||||
|
||||
@property
|
||||
def jwks_client(self) -> PyJWKClient:
|
||||
"""Lazy-load JWKS client."""
|
||||
if self._jwks_client is None:
|
||||
self._jwks_client = PyJWKClient(
|
||||
self.config.jwks_url,
|
||||
cache_keys=True,
|
||||
lifespan=3600 # Cache keys for 1 hour
|
||||
)
|
||||
return self._jwks_client
|
||||
|
||||
async def get_http_client(self) -> httpx.AsyncClient:
|
||||
"""Get or create async HTTP client."""
|
||||
if self._http_client is None or self._http_client.is_closed:
|
||||
self._http_client = httpx.AsyncClient(
|
||||
verify=self.config.verify_ssl,
|
||||
timeout=30.0
|
||||
)
|
||||
return self._http_client
|
||||
|
||||
async def close(self):
|
||||
"""Close HTTP client."""
|
||||
if self._http_client and not self._http_client.is_closed:
|
||||
await self._http_client.aclose()
|
||||
|
||||
def validate_token_sync(self, token: str) -> KeycloakUser:
|
||||
"""
|
||||
Synchronously validate a JWT token against Keycloak JWKS.
|
||||
|
||||
Args:
|
||||
token: The JWT access token
|
||||
|
||||
Returns:
|
||||
KeycloakUser with extracted claims
|
||||
|
||||
Raises:
|
||||
TokenExpiredError: If token has expired
|
||||
TokenInvalidError: If token signature is invalid
|
||||
"""
|
||||
try:
|
||||
# Get signing key from JWKS
|
||||
signing_key = self.jwks_client.get_signing_key_from_jwt(token)
|
||||
|
||||
# Decode and validate token
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
signing_key.key,
|
||||
algorithms=["RS256"],
|
||||
audience=self.config.client_id,
|
||||
issuer=self.config.issuer_url,
|
||||
options={
|
||||
"verify_exp": True,
|
||||
"verify_iat": True,
|
||||
"verify_aud": True,
|
||||
"verify_iss": True
|
||||
}
|
||||
)
|
||||
|
||||
return self._extract_user(payload)
|
||||
|
||||
except jwt.ExpiredSignatureError:
|
||||
raise TokenExpiredError("Token has expired")
|
||||
except jwt.InvalidAudienceError:
|
||||
raise TokenInvalidError("Invalid token audience")
|
||||
except jwt.InvalidIssuerError:
|
||||
raise TokenInvalidError("Invalid token issuer")
|
||||
except jwt.InvalidTokenError as e:
|
||||
raise TokenInvalidError(f"Invalid token: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Token validation failed: {e}")
|
||||
raise TokenInvalidError(f"Token validation failed: {e}")
|
||||
|
||||
async def validate_token(self, token: str) -> KeycloakUser:
|
||||
"""
|
||||
Asynchronously validate a JWT token.
|
||||
|
||||
Note: JWKS fetching is synchronous due to PyJWKClient limitations,
|
||||
but this wrapper allows async context usage.
|
||||
"""
|
||||
return self.validate_token_sync(token)
|
||||
|
||||
async def get_userinfo(self, token: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Fetch user info from Keycloak userinfo endpoint.
|
||||
|
||||
This provides additional user claims not in the access token.
|
||||
"""
|
||||
client = await self.get_http_client()
|
||||
|
||||
try:
|
||||
response = await client.get(
|
||||
self.config.userinfo_url,
|
||||
headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code == 401:
|
||||
raise TokenExpiredError("Token is invalid or expired")
|
||||
raise TokenInvalidError(f"Failed to fetch userinfo: {e}")
|
||||
|
||||
def _extract_user(self, payload: Dict[str, Any]) -> KeycloakUser:
|
||||
"""Extract KeycloakUser from JWT payload."""
|
||||
|
||||
# Extract realm roles
|
||||
realm_access = payload.get("realm_access", {})
|
||||
realm_roles = realm_access.get("roles", [])
|
||||
|
||||
# Extract client roles
|
||||
resource_access = payload.get("resource_access", {})
|
||||
client_roles = {}
|
||||
for client_id, access in resource_access.items():
|
||||
client_roles[client_id] = access.get("roles", [])
|
||||
|
||||
# Extract groups
|
||||
groups = payload.get("groups", [])
|
||||
|
||||
# Extract custom tenant claim (if configured in Keycloak)
|
||||
tenant_id = payload.get("tenant_id") or payload.get("school_id")
|
||||
|
||||
return KeycloakUser(
|
||||
user_id=payload.get("sub", ""),
|
||||
email=payload.get("email", ""),
|
||||
email_verified=payload.get("email_verified", False),
|
||||
name=payload.get("name"),
|
||||
given_name=payload.get("given_name"),
|
||||
family_name=payload.get("family_name"),
|
||||
realm_roles=realm_roles,
|
||||
client_roles=client_roles,
|
||||
groups=groups,
|
||||
tenant_id=tenant_id,
|
||||
raw_claims=payload
|
||||
)
|
||||
|
||||
|
||||
# =============================================
|
||||
# HYBRID AUTH: Keycloak + Local JWT
|
||||
# =============================================
|
||||
|
||||
class HybridAuthenticator:
|
||||
"""
|
||||
Hybrid authenticator supporting both Keycloak and local JWT tokens.
|
||||
|
||||
This allows gradual migration from local JWT to Keycloak:
|
||||
1. Development: Use local JWT (fast, no external dependencies)
|
||||
2. Production: Use Keycloak for full IAM capabilities
|
||||
|
||||
Token type detection:
|
||||
- Keycloak tokens: Have 'iss' claim matching Keycloak URL
|
||||
- Local tokens: Have 'iss' claim as 'breakpilot' or no 'iss'
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
keycloak_config: Optional[KeycloakConfig] = None,
|
||||
local_jwt_secret: Optional[str] = None,
|
||||
environment: str = "development"
|
||||
):
|
||||
self.environment = environment
|
||||
self.keycloak_enabled = keycloak_config is not None
|
||||
self.local_jwt_secret = local_jwt_secret
|
||||
|
||||
if keycloak_config:
|
||||
self.keycloak_auth = KeycloakAuthenticator(keycloak_config)
|
||||
else:
|
||||
self.keycloak_auth = None
|
||||
|
||||
async def validate_token(self, token: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Validate token using appropriate method.
|
||||
|
||||
Returns a unified user dict compatible with existing code.
|
||||
"""
|
||||
if not token:
|
||||
raise TokenInvalidError("No token provided")
|
||||
|
||||
# Try to peek at the token to determine type
|
||||
try:
|
||||
# Decode without verification to check issuer
|
||||
unverified = jwt.decode(token, options={"verify_signature": False})
|
||||
issuer = unverified.get("iss", "")
|
||||
except jwt.InvalidTokenError:
|
||||
raise TokenInvalidError("Cannot decode token")
|
||||
|
||||
# Check if it's a Keycloak token
|
||||
if self.keycloak_auth and self.keycloak_auth.config.issuer_url in issuer:
|
||||
# Validate with Keycloak
|
||||
kc_user = await self.keycloak_auth.validate_token(token)
|
||||
return self._keycloak_user_to_dict(kc_user)
|
||||
|
||||
# Fall back to local JWT validation
|
||||
if self.local_jwt_secret:
|
||||
return self._validate_local_token(token)
|
||||
|
||||
raise TokenInvalidError("No valid authentication method available")
|
||||
|
||||
def _validate_local_token(self, token: str) -> Dict[str, Any]:
|
||||
"""Validate token with local JWT secret."""
|
||||
if not self.local_jwt_secret:
|
||||
raise KeycloakConfigError("Local JWT secret not configured")
|
||||
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
self.local_jwt_secret,
|
||||
algorithms=["HS256"]
|
||||
)
|
||||
|
||||
# Map local token claims to unified format
|
||||
return {
|
||||
"user_id": payload.get("user_id", payload.get("sub", "")),
|
||||
"email": payload.get("email", ""),
|
||||
"name": payload.get("name", ""),
|
||||
"role": payload.get("role", "user"),
|
||||
"realm_roles": [payload.get("role", "user")],
|
||||
"tenant_id": payload.get("tenant_id", payload.get("school_id")),
|
||||
"auth_method": "local_jwt"
|
||||
}
|
||||
except jwt.ExpiredSignatureError:
|
||||
raise TokenExpiredError("Token has expired")
|
||||
except jwt.InvalidTokenError as e:
|
||||
raise TokenInvalidError(f"Invalid local token: {e}")
|
||||
|
||||
def _keycloak_user_to_dict(self, user: KeycloakUser) -> Dict[str, Any]:
|
||||
"""Convert KeycloakUser to dict compatible with existing code."""
|
||||
# Map Keycloak roles to our role system
|
||||
role = "user"
|
||||
if user.is_admin():
|
||||
role = "admin"
|
||||
elif user.is_teacher():
|
||||
role = "teacher"
|
||||
|
||||
return {
|
||||
"user_id": user.user_id,
|
||||
"email": user.email,
|
||||
"name": user.name or f"{user.given_name or ''} {user.family_name or ''}".strip(),
|
||||
"role": role,
|
||||
"realm_roles": user.realm_roles,
|
||||
"client_roles": user.client_roles,
|
||||
"groups": user.groups,
|
||||
"tenant_id": user.tenant_id,
|
||||
"email_verified": user.email_verified,
|
||||
"auth_method": "keycloak"
|
||||
}
|
||||
|
||||
async def close(self):
|
||||
"""Cleanup resources."""
|
||||
if self.keycloak_auth:
|
||||
await self.keycloak_auth.close()
|
||||
|
||||
|
||||
# =============================================
|
||||
# FACTORY FUNCTIONS
|
||||
# =============================================
|
||||
|
||||
def get_keycloak_config_from_env() -> Optional[KeycloakConfig]:
|
||||
"""
|
||||
Create KeycloakConfig from environment variables.
|
||||
|
||||
Required env vars:
|
||||
- KEYCLOAK_SERVER_URL: e.g., https://keycloak.breakpilot.app
|
||||
- KEYCLOAK_REALM: e.g., breakpilot
|
||||
- KEYCLOAK_CLIENT_ID: e.g., breakpilot-backend
|
||||
|
||||
Optional:
|
||||
- KEYCLOAK_CLIENT_SECRET: For confidential clients
|
||||
- KEYCLOAK_VERIFY_SSL: Default true
|
||||
"""
|
||||
server_url = os.environ.get("KEYCLOAK_SERVER_URL")
|
||||
realm = os.environ.get("KEYCLOAK_REALM")
|
||||
client_id = os.environ.get("KEYCLOAK_CLIENT_ID")
|
||||
|
||||
if not all([server_url, realm, client_id]):
|
||||
logger.info("Keycloak not configured, using local JWT only")
|
||||
return None
|
||||
|
||||
return KeycloakConfig(
|
||||
server_url=server_url,
|
||||
realm=realm,
|
||||
client_id=client_id,
|
||||
client_secret=os.environ.get("KEYCLOAK_CLIENT_SECRET"),
|
||||
verify_ssl=os.environ.get("KEYCLOAK_VERIFY_SSL", "true").lower() == "true"
|
||||
)
|
||||
|
||||
|
||||
def get_authenticator() -> HybridAuthenticator:
|
||||
"""
|
||||
Get configured authenticator instance.
|
||||
|
||||
Uses environment variables to determine configuration.
|
||||
"""
|
||||
keycloak_config = get_keycloak_config_from_env()
|
||||
|
||||
# JWT_SECRET is required - no default fallback in production
|
||||
jwt_secret = os.environ.get("JWT_SECRET")
|
||||
environment = os.environ.get("ENVIRONMENT", "development")
|
||||
|
||||
if not jwt_secret and environment == "production":
|
||||
raise KeycloakConfigError(
|
||||
"JWT_SECRET environment variable is required in production"
|
||||
)
|
||||
|
||||
return HybridAuthenticator(
|
||||
keycloak_config=keycloak_config,
|
||||
local_jwt_secret=jwt_secret,
|
||||
environment=environment
|
||||
)
|
||||
|
||||
|
||||
# =============================================
|
||||
# FASTAPI DEPENDENCY
|
||||
# =============================================
|
||||
|
||||
from fastapi import Request, HTTPException, Depends
|
||||
|
||||
# Global authenticator instance (lazy-initialized)
|
||||
_authenticator: Optional[HybridAuthenticator] = None
|
||||
|
||||
|
||||
def get_auth() -> HybridAuthenticator:
|
||||
"""Get or create global authenticator."""
|
||||
global _authenticator
|
||||
if _authenticator is None:
|
||||
_authenticator = get_authenticator()
|
||||
return _authenticator
|
||||
|
||||
|
||||
async def get_current_user(request: Request) -> Dict[str, Any]:
|
||||
"""
|
||||
FastAPI dependency to get current authenticated user.
|
||||
|
||||
Usage:
|
||||
@app.get("/api/protected")
|
||||
async def protected_endpoint(user: dict = Depends(get_current_user)):
|
||||
return {"user_id": user["user_id"]}
|
||||
"""
|
||||
auth_header = request.headers.get("authorization", "")
|
||||
|
||||
if not auth_header.startswith("Bearer "):
|
||||
# Check for development mode
|
||||
environment = os.environ.get("ENVIRONMENT", "development")
|
||||
if environment == "development":
|
||||
# Return demo user in development without token
|
||||
return {
|
||||
"user_id": "10000000-0000-0000-0000-000000000024",
|
||||
"email": "demo@breakpilot.app",
|
||||
"role": "admin",
|
||||
"realm_roles": ["admin"],
|
||||
"tenant_id": "a0000000-0000-0000-0000-000000000001",
|
||||
"auth_method": "development_bypass"
|
||||
}
|
||||
raise HTTPException(status_code=401, detail="Missing authorization header")
|
||||
|
||||
token = auth_header.split(" ")[1]
|
||||
|
||||
try:
|
||||
auth = get_auth()
|
||||
return await auth.validate_token(token)
|
||||
except TokenExpiredError:
|
||||
raise HTTPException(status_code=401, detail="Token expired")
|
||||
except TokenInvalidError as e:
|
||||
raise HTTPException(status_code=401, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Authentication failed: {e}")
|
||||
raise HTTPException(status_code=401, detail="Authentication failed")
|
||||
|
||||
|
||||
async def require_role(required_role: str):
|
||||
"""
|
||||
FastAPI dependency factory for role-based access.
|
||||
|
||||
Usage:
|
||||
@app.get("/api/admin-only")
|
||||
async def admin_endpoint(user: dict = Depends(require_role("admin"))):
|
||||
return {"message": "Admin access granted"}
|
||||
"""
|
||||
async def role_checker(user: dict = Depends(get_current_user)) -> dict:
|
||||
user_role = user.get("role", "user")
|
||||
realm_roles = user.get("realm_roles", [])
|
||||
|
||||
if user_role == required_role or required_role in realm_roles:
|
||||
return user
|
||||
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Role '{required_role}' required"
|
||||
)
|
||||
|
||||
return role_checker
|
||||
373
backend-core/auth_api.py
Normal file
373
backend-core/auth_api.py
Normal file
@@ -0,0 +1,373 @@
|
||||
"""
|
||||
Authentication API Endpoints für BreakPilot
|
||||
Proxy für den Go Consent Service Authentication
|
||||
"""
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException, Header, Request, Response
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, EmailStr
|
||||
import os
|
||||
|
||||
# Consent Service URL
|
||||
CONSENT_SERVICE_URL = os.getenv("CONSENT_SERVICE_URL", "http://localhost:8081")
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["authentication"])
|
||||
|
||||
|
||||
# ==========================================
|
||||
# Request/Response Models
|
||||
# ==========================================
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
name: Optional[str] = None
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
|
||||
class RefreshTokenRequest(BaseModel):
|
||||
refresh_token: str
|
||||
|
||||
|
||||
class VerifyEmailRequest(BaseModel):
|
||||
token: str
|
||||
|
||||
|
||||
class ForgotPasswordRequest(BaseModel):
|
||||
email: EmailStr
|
||||
|
||||
|
||||
class ResetPasswordRequest(BaseModel):
|
||||
token: str
|
||||
new_password: str
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
current_password: str
|
||||
new_password: str
|
||||
|
||||
|
||||
class UpdateProfileRequest(BaseModel):
|
||||
name: Optional[str] = None
|
||||
|
||||
|
||||
class LogoutRequest(BaseModel):
|
||||
refresh_token: Optional[str] = None
|
||||
|
||||
|
||||
# ==========================================
|
||||
# Helper Functions
|
||||
# ==========================================
|
||||
|
||||
def get_auth_headers(authorization: Optional[str]) -> dict:
|
||||
"""Erstellt Header mit Authorization Token"""
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if authorization:
|
||||
headers["Authorization"] = authorization
|
||||
return headers
|
||||
|
||||
|
||||
async def proxy_to_consent_service(
|
||||
method: str,
|
||||
path: str,
|
||||
json_data: dict = None,
|
||||
headers: dict = None,
|
||||
params: dict = None
|
||||
) -> dict:
|
||||
"""
|
||||
Proxy-Aufruf zum Go Consent Service.
|
||||
Wirft HTTPException bei Fehlern.
|
||||
"""
|
||||
url = f"{CONSENT_SERVICE_URL}/api/v1{path}"
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
if method == "GET":
|
||||
response = await client.get(url, headers=headers, params=params, timeout=10.0)
|
||||
elif method == "POST":
|
||||
response = await client.post(url, headers=headers, json=json_data, timeout=10.0)
|
||||
elif method == "PUT":
|
||||
response = await client.put(url, headers=headers, json=json_data, timeout=10.0)
|
||||
elif method == "DELETE":
|
||||
response = await client.delete(url, headers=headers, params=params, timeout=10.0)
|
||||
else:
|
||||
raise ValueError(f"Unsupported HTTP method: {method}")
|
||||
|
||||
# Parse JSON response
|
||||
try:
|
||||
data = response.json()
|
||||
except:
|
||||
data = {"message": response.text}
|
||||
|
||||
# Handle error responses
|
||||
if response.status_code >= 400:
|
||||
error_msg = data.get("error", "Unknown error")
|
||||
raise HTTPException(status_code=response.status_code, detail=error_msg)
|
||||
|
||||
return data
|
||||
|
||||
except httpx.RequestError as e:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=f"Consent Service nicht erreichbar: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
# ==========================================
|
||||
# Public Auth Endpoints (No Auth Required)
|
||||
# ==========================================
|
||||
|
||||
@router.post("/register")
|
||||
async def register(request: RegisterRequest, req: Request):
|
||||
"""
|
||||
Registriert einen neuen Benutzer.
|
||||
Sendet eine Verifizierungs-E-Mail.
|
||||
"""
|
||||
data = await proxy_to_consent_service(
|
||||
"POST",
|
||||
"/auth/register",
|
||||
json_data={
|
||||
"email": request.email,
|
||||
"password": request.password,
|
||||
"name": request.name
|
||||
}
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
async def login(request: LoginRequest, req: Request):
|
||||
"""
|
||||
Meldet einen Benutzer an.
|
||||
Gibt Access Token und Refresh Token zurück.
|
||||
"""
|
||||
# Get client info for session tracking
|
||||
client_ip = req.client.host if req.client else "unknown"
|
||||
user_agent = req.headers.get("user-agent", "unknown")
|
||||
|
||||
data = await proxy_to_consent_service(
|
||||
"POST",
|
||||
"/auth/login",
|
||||
json_data={
|
||||
"email": request.email,
|
||||
"password": request.password
|
||||
},
|
||||
headers={
|
||||
"X-Forwarded-For": client_ip,
|
||||
"User-Agent": user_agent
|
||||
}
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(request: LogoutRequest):
|
||||
"""
|
||||
Meldet den Benutzer ab und invalidiert den Refresh Token.
|
||||
"""
|
||||
data = await proxy_to_consent_service(
|
||||
"POST",
|
||||
"/auth/logout",
|
||||
json_data={"refresh_token": request.refresh_token} if request.refresh_token else {}
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
@router.post("/refresh")
|
||||
async def refresh_token(request: RefreshTokenRequest):
|
||||
"""
|
||||
Erneuert den Access Token mit einem gültigen Refresh Token.
|
||||
"""
|
||||
data = await proxy_to_consent_service(
|
||||
"POST",
|
||||
"/auth/refresh",
|
||||
json_data={"refresh_token": request.refresh_token}
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
@router.post("/verify-email")
|
||||
async def verify_email(request: VerifyEmailRequest):
|
||||
"""
|
||||
Verifiziert die E-Mail-Adresse mit dem Token aus der E-Mail.
|
||||
"""
|
||||
data = await proxy_to_consent_service(
|
||||
"POST",
|
||||
"/auth/verify-email",
|
||||
json_data={"token": request.token}
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
@router.post("/resend-verification")
|
||||
async def resend_verification(email: EmailStr):
|
||||
"""
|
||||
Sendet die Verifizierungs-E-Mail erneut.
|
||||
"""
|
||||
data = await proxy_to_consent_service(
|
||||
"POST",
|
||||
"/auth/resend-verification",
|
||||
json_data={"email": email}
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
@router.post("/forgot-password")
|
||||
async def forgot_password(request: ForgotPasswordRequest, req: Request):
|
||||
"""
|
||||
Initiiert den Passwort-Reset-Prozess.
|
||||
Sendet eine E-Mail mit Reset-Link.
|
||||
"""
|
||||
client_ip = req.client.host if req.client else "unknown"
|
||||
|
||||
data = await proxy_to_consent_service(
|
||||
"POST",
|
||||
"/auth/forgot-password",
|
||||
json_data={"email": request.email},
|
||||
headers={"X-Forwarded-For": client_ip}
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
@router.post("/reset-password")
|
||||
async def reset_password(request: ResetPasswordRequest):
|
||||
"""
|
||||
Setzt das Passwort mit dem Token aus der E-Mail zurück.
|
||||
"""
|
||||
data = await proxy_to_consent_service(
|
||||
"POST",
|
||||
"/auth/reset-password",
|
||||
json_data={
|
||||
"token": request.token,
|
||||
"new_password": request.new_password
|
||||
}
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
# ==========================================
|
||||
# Protected Profile Endpoints (Auth Required)
|
||||
# ==========================================
|
||||
|
||||
@router.get("/profile")
|
||||
async def get_profile(authorization: Optional[str] = Header(None)):
|
||||
"""
|
||||
Gibt das Profil des angemeldeten Benutzers zurück.
|
||||
"""
|
||||
if not authorization:
|
||||
raise HTTPException(status_code=401, detail="Authorization header required")
|
||||
|
||||
data = await proxy_to_consent_service(
|
||||
"GET",
|
||||
"/profile",
|
||||
headers=get_auth_headers(authorization)
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
@router.put("/profile")
|
||||
async def update_profile(
|
||||
request: UpdateProfileRequest,
|
||||
authorization: Optional[str] = Header(None)
|
||||
):
|
||||
"""
|
||||
Aktualisiert das Profil des angemeldeten Benutzers.
|
||||
"""
|
||||
if not authorization:
|
||||
raise HTTPException(status_code=401, detail="Authorization header required")
|
||||
|
||||
data = await proxy_to_consent_service(
|
||||
"PUT",
|
||||
"/profile",
|
||||
json_data={"name": request.name},
|
||||
headers=get_auth_headers(authorization)
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
@router.put("/profile/password")
|
||||
async def change_password(
|
||||
request: ChangePasswordRequest,
|
||||
authorization: Optional[str] = Header(None)
|
||||
):
|
||||
"""
|
||||
Ändert das Passwort des angemeldeten Benutzers.
|
||||
"""
|
||||
if not authorization:
|
||||
raise HTTPException(status_code=401, detail="Authorization header required")
|
||||
|
||||
data = await proxy_to_consent_service(
|
||||
"PUT",
|
||||
"/profile/password",
|
||||
json_data={
|
||||
"current_password": request.current_password,
|
||||
"new_password": request.new_password
|
||||
},
|
||||
headers=get_auth_headers(authorization)
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
@router.get("/profile/sessions")
|
||||
async def get_sessions(authorization: Optional[str] = Header(None)):
|
||||
"""
|
||||
Gibt alle aktiven Sessions des Benutzers zurück.
|
||||
"""
|
||||
if not authorization:
|
||||
raise HTTPException(status_code=401, detail="Authorization header required")
|
||||
|
||||
data = await proxy_to_consent_service(
|
||||
"GET",
|
||||
"/profile/sessions",
|
||||
headers=get_auth_headers(authorization)
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
@router.delete("/profile/sessions/{session_id}")
|
||||
async def revoke_session(
|
||||
session_id: str,
|
||||
authorization: Optional[str] = Header(None)
|
||||
):
|
||||
"""
|
||||
Beendet eine bestimmte Session.
|
||||
"""
|
||||
if not authorization:
|
||||
raise HTTPException(status_code=401, detail="Authorization header required")
|
||||
|
||||
data = await proxy_to_consent_service(
|
||||
"DELETE",
|
||||
f"/profile/sessions/{session_id}",
|
||||
headers=get_auth_headers(authorization)
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
# ==========================================
|
||||
# Health Check
|
||||
# ==========================================
|
||||
|
||||
@router.get("/health")
|
||||
async def auth_health():
|
||||
"""
|
||||
Prüft die Verbindung zum Auth Service.
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
f"{CONSENT_SERVICE_URL}/health",
|
||||
timeout=5.0
|
||||
)
|
||||
is_healthy = response.status_code == 200
|
||||
except:
|
||||
is_healthy = False
|
||||
|
||||
return {
|
||||
"auth_service": "healthy" if is_healthy else "unavailable",
|
||||
"connected": is_healthy
|
||||
}
|
||||
18
backend-core/config.py
Normal file
18
backend-core/config.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from pathlib import Path
|
||||
|
||||
BASE_DIR = Path.home() / "Arbeitsblaetter"
|
||||
EINGANG_DIR = BASE_DIR / "Eingang"
|
||||
BEREINIGT_DIR = BASE_DIR / "Bereinigt"
|
||||
EDITIERBAR_DIR = BASE_DIR / "Editierbar"
|
||||
NEU_GENERIERT_DIR = BASE_DIR / "Neu_generiert"
|
||||
|
||||
VALID_SUFFIXES = {".jpg", ".jpeg", ".png", ".pdf", ".JPG", ".JPEG", ".PNG", ".PDF"}
|
||||
|
||||
# Ordner sicherstellen
|
||||
for d in [EINGANG_DIR, BEREINIGT_DIR, EDITIERBAR_DIR, NEU_GENERIERT_DIR]:
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def is_valid_input_file(path: Path) -> bool:
|
||||
"""Gemeinsame Filterlogik für Eingangsdateien."""
|
||||
return path.is_file() and not path.name.startswith(".") and path.suffix in VALID_SUFFIXES
|
||||
359
backend-core/consent_client.py
Normal file
359
backend-core/consent_client.py
Normal file
@@ -0,0 +1,359 @@
|
||||
"""
|
||||
Consent Service Client für BreakPilot
|
||||
Kommuniziert mit dem Consent Management Service für GDPR-Compliance
|
||||
"""
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List, Dict, Any
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
import os
|
||||
import uuid
|
||||
|
||||
# Consent Service URL (aus Umgebungsvariable oder Standard für lokale Entwicklung)
|
||||
CONSENT_SERVICE_URL = os.getenv("CONSENT_SERVICE_URL", "http://localhost:8081")
|
||||
|
||||
# JWT Secret - MUSS mit dem Go Consent Service übereinstimmen!
|
||||
JWT_SECRET = os.getenv("JWT_SECRET", "breakpilot-dev-jwt-secret-2024")
|
||||
|
||||
|
||||
def generate_jwt_token(
|
||||
user_id: str = None,
|
||||
email: str = "demo@breakpilot.app",
|
||||
role: str = "user",
|
||||
expires_hours: int = 24
|
||||
) -> str:
|
||||
"""
|
||||
Generiert einen JWT Token für die Authentifizierung beim Consent Service.
|
||||
|
||||
Args:
|
||||
user_id: Die User-ID (wird generiert falls nicht angegeben)
|
||||
email: Die E-Mail-Adresse des Benutzers
|
||||
role: Die Rolle (user, admin, super_admin)
|
||||
expires_hours: Gültigkeitsdauer in Stunden
|
||||
|
||||
Returns:
|
||||
JWT Token als String
|
||||
"""
|
||||
if user_id is None:
|
||||
user_id = str(uuid.uuid4())
|
||||
|
||||
payload = {
|
||||
"user_id": user_id,
|
||||
"email": email,
|
||||
"role": role,
|
||||
"exp": datetime.utcnow() + timedelta(hours=expires_hours),
|
||||
"iat": datetime.utcnow(),
|
||||
}
|
||||
|
||||
return jwt.encode(payload, JWT_SECRET, algorithm="HS256")
|
||||
|
||||
|
||||
def generate_demo_token() -> str:
|
||||
"""Generiert einen Demo-Token für nicht-authentifizierte Benutzer"""
|
||||
return generate_jwt_token(
|
||||
user_id="demo-user-" + str(uuid.uuid4())[:8],
|
||||
email="demo@breakpilot.app",
|
||||
role="user"
|
||||
)
|
||||
|
||||
|
||||
class DocumentType(str, Enum):
|
||||
TERMS = "terms"
|
||||
PRIVACY = "privacy"
|
||||
COOKIES = "cookies"
|
||||
COMMUNITY = "community"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConsentStatus:
|
||||
has_consent: bool
|
||||
current_version_id: Optional[str] = None
|
||||
consented_version: Optional[str] = None
|
||||
needs_update: bool = False
|
||||
consented_at: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class DocumentVersion:
|
||||
id: str
|
||||
document_id: str
|
||||
version: str
|
||||
language: str
|
||||
title: str
|
||||
content: str
|
||||
summary: Optional[str] = None
|
||||
|
||||
|
||||
class ConsentClient:
|
||||
"""Client für die Kommunikation mit dem Consent Service"""
|
||||
|
||||
def __init__(self, base_url: str = CONSENT_SERVICE_URL):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.api_url = f"{self.base_url}/api/v1"
|
||||
|
||||
def _get_headers(self, jwt_token: str) -> Dict[str, str]:
|
||||
"""Erstellt die Header mit JWT Token"""
|
||||
return {
|
||||
"Authorization": f"Bearer {jwt_token}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
async def check_consent(
|
||||
self,
|
||||
jwt_token: str,
|
||||
document_type: DocumentType,
|
||||
language: str = "de"
|
||||
) -> ConsentStatus:
|
||||
"""
|
||||
Prüft ob der Benutzer dem Dokument zugestimmt hat.
|
||||
Gibt zurück ob eine Zustimmung vorliegt und ob sie aktuell ist.
|
||||
"""
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.get(
|
||||
f"{self.api_url}/consent/check/{document_type.value}",
|
||||
headers=self._get_headers(jwt_token),
|
||||
params={"language": language},
|
||||
timeout=10.0
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
return ConsentStatus(
|
||||
has_consent=data.get("has_consent", False),
|
||||
current_version_id=data.get("current_version_id"),
|
||||
consented_version=data.get("consented_version"),
|
||||
needs_update=data.get("needs_update", False),
|
||||
consented_at=data.get("consented_at")
|
||||
)
|
||||
else:
|
||||
return ConsentStatus(has_consent=False, needs_update=True)
|
||||
|
||||
except httpx.RequestError:
|
||||
# Bei Verbindungsproblemen: Consent nicht erzwingen
|
||||
return ConsentStatus(has_consent=True, needs_update=False)
|
||||
|
||||
async def check_all_mandatory_consents(
|
||||
self,
|
||||
jwt_token: str,
|
||||
language: str = "de"
|
||||
) -> Dict[str, ConsentStatus]:
|
||||
"""
|
||||
Prüft alle verpflichtenden Dokumente (Terms, Privacy).
|
||||
Gibt ein Dictionary mit dem Status für jedes Dokument zurück.
|
||||
"""
|
||||
mandatory_docs = [DocumentType.TERMS, DocumentType.PRIVACY]
|
||||
results = {}
|
||||
|
||||
for doc_type in mandatory_docs:
|
||||
results[doc_type.value] = await self.check_consent(jwt_token, doc_type, language)
|
||||
|
||||
return results
|
||||
|
||||
async def get_pending_consents(
|
||||
self,
|
||||
jwt_token: str,
|
||||
language: str = "de"
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Gibt eine Liste aller Dokumente zurück, die noch Zustimmung benötigen.
|
||||
Nützlich für die Anzeige beim Login/Registration.
|
||||
"""
|
||||
pending = []
|
||||
statuses = await self.check_all_mandatory_consents(jwt_token, language)
|
||||
|
||||
for doc_type, status in statuses.items():
|
||||
if not status.has_consent or status.needs_update:
|
||||
# Hole das aktuelle Dokument
|
||||
doc = await self.get_latest_document(jwt_token, doc_type, language)
|
||||
if doc:
|
||||
pending.append({
|
||||
"type": doc_type,
|
||||
"version_id": status.current_version_id,
|
||||
"title": doc.title,
|
||||
"content": doc.content,
|
||||
"summary": doc.summary,
|
||||
"is_update": status.has_consent and status.needs_update
|
||||
})
|
||||
|
||||
return pending
|
||||
|
||||
async def get_latest_document(
|
||||
self,
|
||||
jwt_token: str,
|
||||
document_type: str,
|
||||
language: str = "de"
|
||||
) -> Optional[DocumentVersion]:
|
||||
"""Holt die aktuellste Version eines Dokuments"""
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.get(
|
||||
f"{self.api_url}/documents/{document_type}/latest",
|
||||
headers=self._get_headers(jwt_token),
|
||||
params={"language": language},
|
||||
timeout=10.0
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
return DocumentVersion(
|
||||
id=data["id"],
|
||||
document_id=data["document_id"],
|
||||
version=data["version"],
|
||||
language=data["language"],
|
||||
title=data["title"],
|
||||
content=data["content"],
|
||||
summary=data.get("summary")
|
||||
)
|
||||
return None
|
||||
|
||||
except httpx.RequestError:
|
||||
return None
|
||||
|
||||
async def give_consent(
|
||||
self,
|
||||
jwt_token: str,
|
||||
document_type: str,
|
||||
version_id: str,
|
||||
consented: bool = True
|
||||
) -> bool:
|
||||
"""
|
||||
Speichert die Zustimmung des Benutzers.
|
||||
Gibt True zurück bei Erfolg.
|
||||
"""
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.post(
|
||||
f"{self.api_url}/consent",
|
||||
headers=self._get_headers(jwt_token),
|
||||
json={
|
||||
"document_type": document_type,
|
||||
"version_id": version_id,
|
||||
"consented": consented
|
||||
},
|
||||
timeout=10.0
|
||||
)
|
||||
return response.status_code == 201
|
||||
|
||||
except httpx.RequestError:
|
||||
return False
|
||||
|
||||
async def get_cookie_categories(
|
||||
self,
|
||||
jwt_token: str,
|
||||
language: str = "de"
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Holt alle Cookie-Kategorien für das Cookie-Banner"""
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.get(
|
||||
f"{self.api_url}/cookies/categories",
|
||||
headers=self._get_headers(jwt_token),
|
||||
params={"language": language},
|
||||
timeout=10.0
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
return response.json().get("categories", [])
|
||||
return []
|
||||
|
||||
except httpx.RequestError:
|
||||
return []
|
||||
|
||||
async def set_cookie_consent(
|
||||
self,
|
||||
jwt_token: str,
|
||||
categories: List[Dict[str, Any]]
|
||||
) -> bool:
|
||||
"""
|
||||
Speichert die Cookie-Präferenzen.
|
||||
categories: [{"category_id": "...", "consented": true/false}, ...]
|
||||
"""
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.post(
|
||||
f"{self.api_url}/cookies/consent",
|
||||
headers=self._get_headers(jwt_token),
|
||||
json={"categories": categories},
|
||||
timeout=10.0
|
||||
)
|
||||
return response.status_code == 200
|
||||
|
||||
except httpx.RequestError:
|
||||
return False
|
||||
|
||||
async def get_my_data(self, jwt_token: str) -> Optional[Dict[str, Any]]:
|
||||
"""GDPR Art. 15: Holt alle Daten des Benutzers"""
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.get(
|
||||
f"{self.api_url}/privacy/my-data",
|
||||
headers=self._get_headers(jwt_token),
|
||||
timeout=30.0
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
return None
|
||||
|
||||
except httpx.RequestError:
|
||||
return None
|
||||
|
||||
async def request_data_export(self, jwt_token: str) -> Optional[str]:
|
||||
"""GDPR Art. 20: Fordert einen Datenexport an"""
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.post(
|
||||
f"{self.api_url}/privacy/export",
|
||||
headers=self._get_headers(jwt_token),
|
||||
timeout=10.0
|
||||
)
|
||||
|
||||
if response.status_code == 202:
|
||||
return response.json().get("request_id")
|
||||
return None
|
||||
|
||||
except httpx.RequestError:
|
||||
return None
|
||||
|
||||
async def request_data_deletion(
|
||||
self,
|
||||
jwt_token: str,
|
||||
reason: Optional[str] = None
|
||||
) -> Optional[str]:
|
||||
"""GDPR Art. 17: Fordert Löschung aller Daten an"""
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.post(
|
||||
f"{self.api_url}/privacy/delete",
|
||||
headers=self._get_headers(jwt_token),
|
||||
json={"reason": reason} if reason else {},
|
||||
timeout=10.0
|
||||
)
|
||||
|
||||
if response.status_code == 202:
|
||||
return response.json().get("request_id")
|
||||
return None
|
||||
|
||||
except httpx.RequestError:
|
||||
return None
|
||||
|
||||
async def health_check(self) -> bool:
|
||||
"""Prüft ob der Consent Service erreichbar ist"""
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.get(
|
||||
f"{self.base_url}/health",
|
||||
timeout=5.0
|
||||
)
|
||||
return response.status_code == 200
|
||||
|
||||
except httpx.RequestError:
|
||||
return False
|
||||
|
||||
|
||||
# Singleton-Instanz für einfachen Zugriff
|
||||
consent_client = ConsentClient()
|
||||
252
backend-core/email_template_api.py
Normal file
252
backend-core/email_template_api.py
Normal file
@@ -0,0 +1,252 @@
|
||||
"""
|
||||
E-Mail Template API für BreakPilot
|
||||
Proxy für den Go Consent Service E-Mail Template Management
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Request, HTTPException, Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
import httpx
|
||||
from typing import Optional
|
||||
import os
|
||||
|
||||
from consent_client import CONSENT_SERVICE_URL, generate_jwt_token
|
||||
|
||||
router = APIRouter(prefix="/api/consent/admin/email-templates", tags=["Email Templates"])
|
||||
|
||||
# Base URL für E-Mail-Template-Endpunkte im Go Consent Service
|
||||
EMAIL_TEMPLATE_BASE = f"{CONSENT_SERVICE_URL}/api/v1/admin"
|
||||
|
||||
|
||||
async def get_admin_token() -> str:
|
||||
"""Generiert einen Admin-Token für API-Calls zum Consent Service"""
|
||||
return generate_jwt_token(
|
||||
user_id="a0000000-0000-0000-0000-000000000001",
|
||||
email="admin@breakpilot.app",
|
||||
role="admin",
|
||||
expires_hours=1
|
||||
)
|
||||
|
||||
|
||||
async def proxy_request(
|
||||
method: str,
|
||||
path: str,
|
||||
token: str,
|
||||
json_data: dict = None,
|
||||
params: dict = None
|
||||
) -> dict:
|
||||
"""Proxy-Funktion für API-Calls zum Go Consent Service"""
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
url = f"{EMAIL_TEMPLATE_BASE}{path}"
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
try:
|
||||
if method == "GET":
|
||||
response = await client.get(url, headers=headers, params=params)
|
||||
elif method == "POST":
|
||||
response = await client.post(url, headers=headers, json=json_data)
|
||||
elif method == "PUT":
|
||||
response = await client.put(url, headers=headers, json=json_data)
|
||||
elif method == "DELETE":
|
||||
response = await client.delete(url, headers=headers)
|
||||
else:
|
||||
raise ValueError(f"Unsupported method: {method}")
|
||||
|
||||
if response.status_code >= 400:
|
||||
error_detail = response.text
|
||||
try:
|
||||
error_detail = response.json().get("error", response.text)
|
||||
except:
|
||||
pass
|
||||
raise HTTPException(status_code=response.status_code, detail=error_detail)
|
||||
|
||||
if response.status_code == 204:
|
||||
return {"success": True}
|
||||
|
||||
return response.json()
|
||||
|
||||
except httpx.RequestError as e:
|
||||
raise HTTPException(status_code=503, detail=f"Consent Service nicht erreichbar: {str(e)}")
|
||||
|
||||
|
||||
# ==========================================
|
||||
# E-Mail Template Typen
|
||||
# ==========================================
|
||||
|
||||
@router.get("/types")
|
||||
async def get_all_template_types():
|
||||
"""Gibt alle verfügbaren E-Mail-Template-Typen zurück"""
|
||||
token = await get_admin_token()
|
||||
return await proxy_request("GET", "/email-templates/types", token)
|
||||
|
||||
|
||||
# ==========================================
|
||||
# E-Mail Templates
|
||||
# ==========================================
|
||||
|
||||
@router.get("")
|
||||
async def get_all_templates():
|
||||
"""Gibt alle E-Mail-Templates zurück"""
|
||||
token = await get_admin_token()
|
||||
return await proxy_request("GET", "/email-templates", token)
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_template(request: Request):
|
||||
"""Erstellt ein neues E-Mail-Template"""
|
||||
token = await get_admin_token()
|
||||
data = await request.json()
|
||||
return await proxy_request("POST", "/email-templates", token, json_data=data)
|
||||
|
||||
|
||||
@router.get("/settings")
|
||||
async def get_settings():
|
||||
"""Gibt die E-Mail-Einstellungen zurück"""
|
||||
token = await get_admin_token()
|
||||
return await proxy_request("GET", "/email-templates/settings", token)
|
||||
|
||||
|
||||
@router.put("/settings")
|
||||
async def update_settings(request: Request):
|
||||
"""Aktualisiert die E-Mail-Einstellungen"""
|
||||
token = await get_admin_token()
|
||||
data = await request.json()
|
||||
return await proxy_request("PUT", "/email-templates/settings", token, json_data=data)
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_email_stats():
|
||||
"""Gibt E-Mail-Statistiken zurück"""
|
||||
token = await get_admin_token()
|
||||
return await proxy_request("GET", "/email-templates/stats", token)
|
||||
|
||||
|
||||
@router.get("/logs")
|
||||
async def get_send_logs(
|
||||
template_id: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0
|
||||
):
|
||||
"""Gibt E-Mail-Send-Logs zurück"""
|
||||
token = await get_admin_token()
|
||||
params = {"limit": limit, "offset": offset}
|
||||
if template_id:
|
||||
params["template_id"] = template_id
|
||||
if status:
|
||||
params["status"] = status
|
||||
return await proxy_request("GET", "/email-templates/logs", token, params=params)
|
||||
|
||||
|
||||
@router.get("/default/{template_type}")
|
||||
async def get_default_content(template_type: str):
|
||||
"""Gibt den Default-Inhalt für einen Template-Typ zurück"""
|
||||
token = await get_admin_token()
|
||||
return await proxy_request("GET", f"/email-templates/default/{template_type}", token)
|
||||
|
||||
|
||||
@router.post("/initialize")
|
||||
async def initialize_templates():
|
||||
"""Initialisiert alle Standard-Templates"""
|
||||
token = await get_admin_token()
|
||||
return await proxy_request("POST", "/email-templates/initialize", token)
|
||||
|
||||
|
||||
@router.get("/{template_id}")
|
||||
async def get_template(template_id: str):
|
||||
"""Gibt ein einzelnes E-Mail-Template zurück"""
|
||||
token = await get_admin_token()
|
||||
return await proxy_request("GET", f"/email-templates/{template_id}", token)
|
||||
|
||||
|
||||
@router.get("/{template_id}/versions")
|
||||
async def get_template_versions(template_id: str):
|
||||
"""Gibt alle Versionen eines Templates zurück"""
|
||||
token = await get_admin_token()
|
||||
return await proxy_request("GET", f"/email-templates/{template_id}/versions", token)
|
||||
|
||||
|
||||
# ==========================================
|
||||
# E-Mail Template Versionen
|
||||
# ==========================================
|
||||
|
||||
versions_router = APIRouter(prefix="/api/consent/admin/email-template-versions", tags=["Email Template Versions"])
|
||||
|
||||
|
||||
@versions_router.get("/{version_id}")
|
||||
async def get_version(version_id: str):
|
||||
"""Gibt eine einzelne Version zurück"""
|
||||
token = await get_admin_token()
|
||||
return await proxy_request("GET", f"/email-template-versions/{version_id}", token)
|
||||
|
||||
|
||||
@versions_router.post("")
|
||||
async def create_version(request: Request):
|
||||
"""Erstellt eine neue Version"""
|
||||
token = await get_admin_token()
|
||||
data = await request.json()
|
||||
return await proxy_request("POST", "/email-template-versions", token, json_data=data)
|
||||
|
||||
|
||||
@versions_router.put("/{version_id}")
|
||||
async def update_version(version_id: str, request: Request):
|
||||
"""Aktualisiert eine Version"""
|
||||
token = await get_admin_token()
|
||||
data = await request.json()
|
||||
return await proxy_request("PUT", f"/email-template-versions/{version_id}", token, json_data=data)
|
||||
|
||||
|
||||
@versions_router.post("/{version_id}/submit")
|
||||
async def submit_for_review(version_id: str):
|
||||
"""Sendet eine Version zur Überprüfung"""
|
||||
token = await get_admin_token()
|
||||
return await proxy_request("POST", f"/email-template-versions/{version_id}/submit", token)
|
||||
|
||||
|
||||
@versions_router.post("/{version_id}/approve")
|
||||
async def approve_version(version_id: str, request: Request):
|
||||
"""Genehmigt eine Version"""
|
||||
token = await get_admin_token()
|
||||
data = await request.json()
|
||||
return await proxy_request("POST", f"/email-template-versions/{version_id}/approve", token, json_data=data)
|
||||
|
||||
|
||||
@versions_router.post("/{version_id}/reject")
|
||||
async def reject_version(version_id: str, request: Request):
|
||||
"""Lehnt eine Version ab"""
|
||||
token = await get_admin_token()
|
||||
data = await request.json()
|
||||
return await proxy_request("POST", f"/email-template-versions/{version_id}/reject", token, json_data=data)
|
||||
|
||||
|
||||
@versions_router.post("/{version_id}/publish")
|
||||
async def publish_version(version_id: str):
|
||||
"""Veröffentlicht eine Version"""
|
||||
token = await get_admin_token()
|
||||
return await proxy_request("POST", f"/email-template-versions/{version_id}/publish", token)
|
||||
|
||||
|
||||
@versions_router.get("/{version_id}/approvals")
|
||||
async def get_approvals(version_id: str):
|
||||
"""Gibt die Genehmigungshistorie einer Version zurück"""
|
||||
token = await get_admin_token()
|
||||
return await proxy_request("GET", f"/email-template-versions/{version_id}/approvals", token)
|
||||
|
||||
|
||||
@versions_router.post("/{version_id}/preview")
|
||||
async def preview_version(version_id: str, request: Request):
|
||||
"""Generiert eine Vorschau einer Version"""
|
||||
token = await get_admin_token()
|
||||
data = await request.json()
|
||||
return await proxy_request("POST", f"/email-template-versions/{version_id}/preview", token, json_data=data)
|
||||
|
||||
|
||||
@versions_router.post("/{version_id}/send-test")
|
||||
async def send_test_email(version_id: str, request: Request):
|
||||
"""Sendet eine Test-E-Mail"""
|
||||
token = await get_admin_token()
|
||||
data = await request.json()
|
||||
return await proxy_request("POST", f"/email-template-versions/{version_id}/send-test", token, json_data=data)
|
||||
144
backend-core/main.py
Normal file
144
backend-core/main.py
Normal file
@@ -0,0 +1,144 @@
|
||||
"""
|
||||
BreakPilot Core Backend
|
||||
|
||||
Shared APIs for authentication, RBAC, notifications, email templates,
|
||||
system health, security (DevSecOps), and common middleware.
|
||||
|
||||
This is the extracted "core" service from the monorepo backend.
|
||||
It runs on port 8000 and uses the `core` schema in PostgreSQL.
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Router imports (shared APIs only)
|
||||
# ---------------------------------------------------------------------------
|
||||
from auth_api import router as auth_router
|
||||
from rbac_api import router as rbac_router
|
||||
from notification_api import router as notification_router
|
||||
from email_template_api import (
|
||||
router as email_template_router,
|
||||
versions_router as email_template_versions_router,
|
||||
)
|
||||
from system_api import router as system_router
|
||||
from security_api import router as security_router
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Middleware imports
|
||||
# ---------------------------------------------------------------------------
|
||||
from middleware import (
|
||||
RequestIDMiddleware,
|
||||
SecurityHeadersMiddleware,
|
||||
RateLimiterMiddleware,
|
||||
PIIRedactor,
|
||||
InputGateMiddleware,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Logging
|
||||
# ---------------------------------------------------------------------------
|
||||
logging.basicConfig(
|
||||
level=os.getenv("LOG_LEVEL", "INFO"),
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
)
|
||||
logger = logging.getLogger("backend-core")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Application
|
||||
# ---------------------------------------------------------------------------
|
||||
app = FastAPI(
|
||||
title="BreakPilot Core Backend",
|
||||
description="Shared APIs: Auth, RBAC, Notifications, Email Templates, System, Security",
|
||||
version="1.0.0",
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CORS
|
||||
# ---------------------------------------------------------------------------
|
||||
ALLOWED_ORIGINS = os.getenv("CORS_ORIGINS", "*").split(",")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=ALLOWED_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Custom middleware stack (order matters -- outermost first)
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Request-ID (outermost so every response has it)
|
||||
app.add_middleware(RequestIDMiddleware)
|
||||
|
||||
# 2. Security headers
|
||||
app.add_middleware(SecurityHeadersMiddleware)
|
||||
|
||||
# 3. Input gate (body-size / content-type validation)
|
||||
app.add_middleware(InputGateMiddleware)
|
||||
|
||||
# 4. Rate limiter (Valkey-backed)
|
||||
VALKEY_URL = os.getenv("VALKEY_URL", os.getenv("REDIS_URL", "redis://valkey:6379/0"))
|
||||
app.add_middleware(RateLimiterMiddleware, valkey_url=VALKEY_URL)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routers
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth (proxy to consent-service)
|
||||
app.include_router(auth_router, prefix="/api")
|
||||
|
||||
# RBAC (teacher / role management)
|
||||
app.include_router(rbac_router, prefix="/api")
|
||||
|
||||
# Notifications (proxy to consent-service)
|
||||
app.include_router(notification_router, prefix="/api")
|
||||
|
||||
# Email templates (proxy to consent-service)
|
||||
app.include_router(email_template_router) # already has /api/consent/admin/email-templates prefix
|
||||
app.include_router(email_template_versions_router) # already has /api/consent/admin/email-template-versions prefix
|
||||
|
||||
# System (health, local-ip)
|
||||
app.include_router(system_router) # already has paths defined in router
|
||||
|
||||
# Security / DevSecOps dashboard
|
||||
app.include_router(security_router, prefix="/api")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Startup / Shutdown events
|
||||
# ---------------------------------------------------------------------------
|
||||
@app.on_event("startup")
|
||||
async def on_startup():
|
||||
logger.info("backend-core starting up")
|
||||
# Ensure DATABASE_URL uses search_path=core,public
|
||||
db_url = os.getenv("DATABASE_URL", "")
|
||||
if db_url and "search_path" not in db_url:
|
||||
separator = "&" if "?" in db_url else "?"
|
||||
new_url = f"{db_url}{separator}search_path=core,public"
|
||||
os.environ["DATABASE_URL"] = new_url
|
||||
logger.info("DATABASE_URL updated with search_path=core,public")
|
||||
elif "search_path" in db_url:
|
||||
logger.info("DATABASE_URL already contains search_path")
|
||||
else:
|
||||
logger.warning("DATABASE_URL is not set -- database features will fail")
|
||||
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def on_shutdown():
|
||||
logger.info("backend-core shutting down")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entrypoint (for `python main.py` during development)
|
||||
# ---------------------------------------------------------------------------
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(
|
||||
"main:app",
|
||||
host="0.0.0.0",
|
||||
port=int(os.getenv("PORT", "8000")),
|
||||
reload=os.getenv("ENVIRONMENT", "development") == "development",
|
||||
)
|
||||
26
backend-core/middleware/__init__.py
Normal file
26
backend-core/middleware/__init__.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""
|
||||
BreakPilot Middleware Stack
|
||||
|
||||
This module provides middleware components for the FastAPI backend:
|
||||
- Request-ID: Adds unique request identifiers for tracing
|
||||
- Security Headers: Adds security headers to all responses
|
||||
- Rate Limiter: Protects against abuse (Valkey-based)
|
||||
- PII Redactor: Redacts sensitive data from logs
|
||||
- Input Gate: Validates request body size and content types
|
||||
"""
|
||||
|
||||
from .request_id import RequestIDMiddleware, get_request_id
|
||||
from .security_headers import SecurityHeadersMiddleware
|
||||
from .rate_limiter import RateLimiterMiddleware
|
||||
from .pii_redactor import PIIRedactor, redact_pii
|
||||
from .input_gate import InputGateMiddleware
|
||||
|
||||
__all__ = [
|
||||
"RequestIDMiddleware",
|
||||
"get_request_id",
|
||||
"SecurityHeadersMiddleware",
|
||||
"RateLimiterMiddleware",
|
||||
"PIIRedactor",
|
||||
"redact_pii",
|
||||
"InputGateMiddleware",
|
||||
]
|
||||
260
backend-core/middleware/input_gate.py
Normal file
260
backend-core/middleware/input_gate.py
Normal file
@@ -0,0 +1,260 @@
|
||||
"""
|
||||
Input Validation Gate Middleware
|
||||
|
||||
Validates incoming requests for:
|
||||
- Request body size limits
|
||||
- Content-Type validation
|
||||
- File upload limits
|
||||
- Malicious content detection
|
||||
|
||||
Usage:
|
||||
from middleware import InputGateMiddleware
|
||||
|
||||
app.add_middleware(
|
||||
InputGateMiddleware,
|
||||
max_body_size=10 * 1024 * 1024, # 10MB
|
||||
allowed_content_types=["application/json", "multipart/form-data"],
|
||||
)
|
||||
"""
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional, Set
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse, Response
|
||||
|
||||
|
||||
@dataclass
|
||||
class InputGateConfig:
|
||||
"""Configuration for input validation."""
|
||||
|
||||
# Maximum request body size (default: 10MB)
|
||||
max_body_size: int = 10 * 1024 * 1024
|
||||
|
||||
# Allowed content types
|
||||
allowed_content_types: Set[str] = field(default_factory=lambda: {
|
||||
"application/json",
|
||||
"application/x-www-form-urlencoded",
|
||||
"multipart/form-data",
|
||||
"text/plain",
|
||||
})
|
||||
|
||||
# File upload specific limits
|
||||
max_file_size: int = 50 * 1024 * 1024 # 50MB for file uploads
|
||||
allowed_file_types: Set[str] = field(default_factory=lambda: {
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"application/pdf",
|
||||
"application/msword",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/vnd.ms-excel",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"text/csv",
|
||||
})
|
||||
|
||||
# Blocked file extensions (potential malware)
|
||||
blocked_extensions: Set[str] = field(default_factory=lambda: {
|
||||
".exe", ".bat", ".cmd", ".com", ".msi",
|
||||
".dll", ".scr", ".pif", ".vbs", ".js",
|
||||
".jar", ".sh", ".ps1", ".app",
|
||||
})
|
||||
|
||||
# Paths that allow larger uploads (e.g., file upload endpoints)
|
||||
large_upload_paths: List[str] = field(default_factory=lambda: [
|
||||
"/api/files/upload",
|
||||
"/api/documents/upload",
|
||||
"/api/attachments",
|
||||
])
|
||||
|
||||
# Paths excluded from validation
|
||||
excluded_paths: List[str] = field(default_factory=lambda: [
|
||||
"/health",
|
||||
"/metrics",
|
||||
])
|
||||
|
||||
# Enable strict content type checking
|
||||
strict_content_type: bool = True
|
||||
|
||||
|
||||
class InputGateMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
Middleware that validates incoming request bodies and content types.
|
||||
|
||||
Protects against:
|
||||
- Oversized request bodies
|
||||
- Invalid content types
|
||||
- Potentially malicious file uploads
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
app,
|
||||
config: Optional[InputGateConfig] = None,
|
||||
max_body_size: Optional[int] = None,
|
||||
allowed_content_types: Optional[Set[str]] = None,
|
||||
):
|
||||
super().__init__(app)
|
||||
|
||||
self.config = config or InputGateConfig()
|
||||
|
||||
# Apply overrides
|
||||
if max_body_size is not None:
|
||||
self.config.max_body_size = max_body_size
|
||||
if allowed_content_types is not None:
|
||||
self.config.allowed_content_types = allowed_content_types
|
||||
|
||||
# Auto-configure from environment
|
||||
env_max_size = os.getenv("MAX_REQUEST_BODY_SIZE")
|
||||
if env_max_size:
|
||||
try:
|
||||
self.config.max_body_size = int(env_max_size)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
def _is_excluded_path(self, path: str) -> bool:
|
||||
"""Check if path is excluded from validation."""
|
||||
return path in self.config.excluded_paths
|
||||
|
||||
def _is_large_upload_path(self, path: str) -> bool:
|
||||
"""Check if path allows larger uploads."""
|
||||
for upload_path in self.config.large_upload_paths:
|
||||
if path.startswith(upload_path):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _get_max_size(self, path: str) -> int:
|
||||
"""Get the maximum allowed body size for this path."""
|
||||
if self._is_large_upload_path(path):
|
||||
return self.config.max_file_size
|
||||
return self.config.max_body_size
|
||||
|
||||
def _validate_content_type(self, content_type: Optional[str]) -> tuple[bool, str]:
|
||||
"""
|
||||
Validate the content type.
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
if not content_type:
|
||||
# Allow requests without content type (e.g., GET requests)
|
||||
return True, ""
|
||||
|
||||
# Extract base content type (remove charset, boundary, etc.)
|
||||
base_type = content_type.split(";")[0].strip().lower()
|
||||
|
||||
if base_type not in self.config.allowed_content_types:
|
||||
return False, f"Content-Type '{base_type}' is not allowed"
|
||||
|
||||
return True, ""
|
||||
|
||||
def _check_blocked_extension(self, filename: str) -> bool:
|
||||
"""Check if filename has a blocked extension."""
|
||||
if not filename:
|
||||
return False
|
||||
|
||||
lower_filename = filename.lower()
|
||||
for ext in self.config.blocked_extensions:
|
||||
if lower_filename.endswith(ext):
|
||||
return True
|
||||
return False
|
||||
|
||||
async def dispatch(self, request: Request, call_next) -> Response:
|
||||
# Skip excluded paths
|
||||
if self._is_excluded_path(request.url.path):
|
||||
return await call_next(request)
|
||||
|
||||
# Skip validation for GET, HEAD, OPTIONS requests
|
||||
if request.method in ("GET", "HEAD", "OPTIONS"):
|
||||
return await call_next(request)
|
||||
|
||||
# Validate content type for requests with body
|
||||
content_type = request.headers.get("Content-Type")
|
||||
if self.config.strict_content_type:
|
||||
is_valid, error_msg = self._validate_content_type(content_type)
|
||||
if not is_valid:
|
||||
return JSONResponse(
|
||||
status_code=415,
|
||||
content={
|
||||
"error": "unsupported_media_type",
|
||||
"message": error_msg,
|
||||
},
|
||||
)
|
||||
|
||||
# Check Content-Length header
|
||||
content_length = request.headers.get("Content-Length")
|
||||
if content_length:
|
||||
try:
|
||||
length = int(content_length)
|
||||
max_size = self._get_max_size(request.url.path)
|
||||
|
||||
if length > max_size:
|
||||
return JSONResponse(
|
||||
status_code=413,
|
||||
content={
|
||||
"error": "payload_too_large",
|
||||
"message": f"Request body exceeds maximum size of {max_size} bytes",
|
||||
"max_size": max_size,
|
||||
},
|
||||
)
|
||||
except ValueError:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "invalid_content_length",
|
||||
"message": "Invalid Content-Length header",
|
||||
},
|
||||
)
|
||||
|
||||
# For multipart uploads, check for blocked file extensions
|
||||
if content_type and "multipart/form-data" in content_type:
|
||||
# Note: Full file validation would require reading the body
|
||||
# which we avoid in middleware for performance reasons.
|
||||
# Detailed file validation should happen in the handler.
|
||||
pass
|
||||
|
||||
# Process request
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
def validate_file_upload(
|
||||
filename: str,
|
||||
content_type: str,
|
||||
size: int,
|
||||
config: Optional[InputGateConfig] = None,
|
||||
) -> tuple[bool, str]:
|
||||
"""
|
||||
Validate a file upload.
|
||||
|
||||
Use this in upload handlers for detailed validation.
|
||||
|
||||
Args:
|
||||
filename: Original filename
|
||||
content_type: MIME type of the file
|
||||
size: File size in bytes
|
||||
config: Optional custom configuration
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
cfg = config or InputGateConfig()
|
||||
|
||||
# Check size
|
||||
if size > cfg.max_file_size:
|
||||
return False, f"File size exceeds maximum of {cfg.max_file_size} bytes"
|
||||
|
||||
# Check extension
|
||||
if filename:
|
||||
lower_filename = filename.lower()
|
||||
for ext in cfg.blocked_extensions:
|
||||
if lower_filename.endswith(ext):
|
||||
return False, f"File extension '{ext}' is not allowed"
|
||||
|
||||
# Check content type
|
||||
if content_type and content_type not in cfg.allowed_file_types:
|
||||
return False, f"File type '{content_type}' is not allowed"
|
||||
|
||||
return True, ""
|
||||
316
backend-core/middleware/pii_redactor.py
Normal file
316
backend-core/middleware/pii_redactor.py
Normal file
@@ -0,0 +1,316 @@
|
||||
"""
|
||||
PII Redactor
|
||||
|
||||
Redacts Personally Identifiable Information (PII) from logs and responses.
|
||||
Essential for DSGVO/GDPR compliance in BreakPilot.
|
||||
|
||||
Redacted data types:
|
||||
- Email addresses
|
||||
- IP addresses
|
||||
- German phone numbers
|
||||
- Names (when identified)
|
||||
- Student IDs
|
||||
- Credit card numbers
|
||||
- IBAN numbers
|
||||
|
||||
Usage:
|
||||
from middleware import PIIRedactor, redact_pii
|
||||
|
||||
# Use in logging
|
||||
logger.info(redact_pii(f"User {email} logged in from {ip}"))
|
||||
|
||||
# Configure redactor
|
||||
redactor = PIIRedactor(patterns=["email", "ip", "phone"])
|
||||
safe_message = redactor.redact(sensitive_message)
|
||||
"""
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Optional, Pattern, Set
|
||||
|
||||
|
||||
@dataclass
|
||||
class PIIPattern:
|
||||
"""Definition of a PII pattern."""
|
||||
name: str
|
||||
pattern: Pattern
|
||||
replacement: str
|
||||
|
||||
|
||||
# Pre-compiled regex patterns for common PII
|
||||
PII_PATTERNS: Dict[str, PIIPattern] = {
|
||||
"email": PIIPattern(
|
||||
name="email",
|
||||
pattern=re.compile(
|
||||
r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b',
|
||||
re.IGNORECASE
|
||||
),
|
||||
replacement="[EMAIL_REDACTED]",
|
||||
),
|
||||
"ip_v4": PIIPattern(
|
||||
name="ip_v4",
|
||||
pattern=re.compile(
|
||||
r'\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b'
|
||||
),
|
||||
replacement="[IP_REDACTED]",
|
||||
),
|
||||
"ip_v6": PIIPattern(
|
||||
name="ip_v6",
|
||||
pattern=re.compile(
|
||||
r'\b(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\b'
|
||||
),
|
||||
replacement="[IP_REDACTED]",
|
||||
),
|
||||
"phone_de": PIIPattern(
|
||||
name="phone_de",
|
||||
pattern=re.compile(
|
||||
r'(?<!\w)(?:\+49|0049|0)[\s.-]?(?:\d{2,4})[\s.-]?(?:\d{3,4})[\s.-]?(?:\d{3,4})(?!\d)'
|
||||
),
|
||||
replacement="[PHONE_REDACTED]",
|
||||
),
|
||||
"phone_intl": PIIPattern(
|
||||
name="phone_intl",
|
||||
pattern=re.compile(
|
||||
r'(?<!\w)\+?(?:\d[\s.-]?){10,15}(?!\d)'
|
||||
),
|
||||
replacement="[PHONE_REDACTED]",
|
||||
),
|
||||
"credit_card": PIIPattern(
|
||||
name="credit_card",
|
||||
pattern=re.compile(
|
||||
r'\b(?:\d{4}[\s.-]?){3}\d{4}\b'
|
||||
),
|
||||
replacement="[CC_REDACTED]",
|
||||
),
|
||||
"iban": PIIPattern(
|
||||
name="iban",
|
||||
pattern=re.compile(
|
||||
r'\b[A-Z]{2}\d{2}[\s]?(?:\d{4}[\s]?){3,5}\d{1,4}\b',
|
||||
re.IGNORECASE
|
||||
),
|
||||
replacement="[IBAN_REDACTED]",
|
||||
),
|
||||
"student_id": PIIPattern(
|
||||
name="student_id",
|
||||
pattern=re.compile(
|
||||
r'\b(?:student|schueler|schüler)[-_]?(?:id|nr)?[:\s]?\d{4,10}\b',
|
||||
re.IGNORECASE
|
||||
),
|
||||
replacement="[STUDENT_ID_REDACTED]",
|
||||
),
|
||||
"uuid": PIIPattern(
|
||||
name="uuid",
|
||||
pattern=re.compile(
|
||||
r'\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b',
|
||||
re.IGNORECASE
|
||||
),
|
||||
replacement="[UUID_REDACTED]",
|
||||
),
|
||||
# German names are harder to detect, but we can catch common patterns
|
||||
"name_prefix": PIIPattern(
|
||||
name="name_prefix",
|
||||
pattern=re.compile(
|
||||
r'\b(?:Herr|Frau|Hr\.|Fr\.)\s+[A-ZÄÖÜ][a-zäöüß]+(?:\s+[A-ZÄÖÜ][a-zäöüß]+)?\b'
|
||||
),
|
||||
replacement="[NAME_REDACTED]",
|
||||
),
|
||||
}
|
||||
|
||||
# Default patterns to enable
|
||||
DEFAULT_PATTERNS = ["email", "ip_v4", "ip_v6", "phone_de"]
|
||||
|
||||
|
||||
class PIIRedactor:
|
||||
"""
|
||||
Redacts PII from strings.
|
||||
|
||||
Attributes:
|
||||
patterns: List of pattern names to use (e.g., ["email", "ip_v4"])
|
||||
custom_patterns: Additional custom patterns
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
patterns: Optional[List[str]] = None,
|
||||
custom_patterns: Optional[List[PIIPattern]] = None,
|
||||
preserve_format: bool = False,
|
||||
):
|
||||
"""
|
||||
Initialize the PII redactor.
|
||||
|
||||
Args:
|
||||
patterns: List of pattern names to enable (default: email, ip_v4, ip_v6, phone_de)
|
||||
custom_patterns: Additional custom PIIPattern objects
|
||||
preserve_format: If True, preserve the length of redacted content
|
||||
"""
|
||||
self.patterns = patterns or DEFAULT_PATTERNS
|
||||
self.custom_patterns = custom_patterns or []
|
||||
self.preserve_format = preserve_format
|
||||
|
||||
# Build active patterns list
|
||||
self._active_patterns: List[PIIPattern] = []
|
||||
for pattern_name in self.patterns:
|
||||
if pattern_name in PII_PATTERNS:
|
||||
self._active_patterns.append(PII_PATTERNS[pattern_name])
|
||||
|
||||
# Add custom patterns
|
||||
self._active_patterns.extend(self.custom_patterns)
|
||||
|
||||
def redact(self, text: str) -> str:
|
||||
"""
|
||||
Redact PII from the given text.
|
||||
|
||||
Args:
|
||||
text: The text to redact PII from
|
||||
|
||||
Returns:
|
||||
Text with PII replaced by redaction markers
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
|
||||
result = text
|
||||
for pattern in self._active_patterns:
|
||||
if self.preserve_format:
|
||||
# Replace with same-length placeholder
|
||||
def replace_preserve(match):
|
||||
length = len(match.group())
|
||||
return "*" * length
|
||||
result = pattern.pattern.sub(replace_preserve, result)
|
||||
else:
|
||||
result = pattern.pattern.sub(pattern.replacement, result)
|
||||
|
||||
return result
|
||||
|
||||
def contains_pii(self, text: str) -> bool:
|
||||
"""
|
||||
Check if text contains any PII.
|
||||
|
||||
Args:
|
||||
text: The text to check
|
||||
|
||||
Returns:
|
||||
True if PII is detected
|
||||
"""
|
||||
if not text:
|
||||
return False
|
||||
|
||||
for pattern in self._active_patterns:
|
||||
if pattern.pattern.search(text):
|
||||
return True
|
||||
return False
|
||||
|
||||
def find_pii(self, text: str) -> List[Dict[str, str]]:
|
||||
"""
|
||||
Find all PII in text with their types.
|
||||
|
||||
Args:
|
||||
text: The text to search
|
||||
|
||||
Returns:
|
||||
List of dicts with 'type' and 'match' keys
|
||||
"""
|
||||
if not text:
|
||||
return []
|
||||
|
||||
findings = []
|
||||
for pattern in self._active_patterns:
|
||||
for match in pattern.pattern.finditer(text):
|
||||
findings.append({
|
||||
"type": pattern.name,
|
||||
"match": match.group(),
|
||||
"start": match.start(),
|
||||
"end": match.end(),
|
||||
})
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
# Module-level default redactor instance
|
||||
_default_redactor: Optional[PIIRedactor] = None
|
||||
|
||||
|
||||
def get_default_redactor() -> PIIRedactor:
|
||||
"""Get or create the default redactor instance."""
|
||||
global _default_redactor
|
||||
if _default_redactor is None:
|
||||
_default_redactor = PIIRedactor()
|
||||
return _default_redactor
|
||||
|
||||
|
||||
def redact_pii(text: str) -> str:
|
||||
"""
|
||||
Convenience function to redact PII using the default redactor.
|
||||
|
||||
Args:
|
||||
text: Text to redact
|
||||
|
||||
Returns:
|
||||
Redacted text
|
||||
|
||||
Example:
|
||||
logger.info(redact_pii(f"User {email} logged in"))
|
||||
"""
|
||||
return get_default_redactor().redact(text)
|
||||
|
||||
|
||||
class PIIRedactingLogFilter:
|
||||
"""
|
||||
Logging filter that automatically redacts PII from log messages.
|
||||
|
||||
Usage:
|
||||
import logging
|
||||
|
||||
handler = logging.StreamHandler()
|
||||
handler.addFilter(PIIRedactingLogFilter())
|
||||
logger = logging.getLogger()
|
||||
logger.addHandler(handler)
|
||||
"""
|
||||
|
||||
def __init__(self, redactor: Optional[PIIRedactor] = None):
|
||||
self.redactor = redactor or get_default_redactor()
|
||||
|
||||
def filter(self, record):
|
||||
# Redact the message
|
||||
if record.msg:
|
||||
record.msg = self.redactor.redact(str(record.msg))
|
||||
|
||||
# Redact args if present
|
||||
if record.args:
|
||||
if isinstance(record.args, dict):
|
||||
record.args = {
|
||||
k: self.redactor.redact(str(v)) if isinstance(v, str) else v
|
||||
for k, v in record.args.items()
|
||||
}
|
||||
elif isinstance(record.args, tuple):
|
||||
record.args = tuple(
|
||||
self.redactor.redact(str(v)) if isinstance(v, str) else v
|
||||
for v in record.args
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def create_safe_dict(data: dict, redactor: Optional[PIIRedactor] = None) -> dict:
|
||||
"""
|
||||
Create a copy of a dictionary with PII redacted.
|
||||
|
||||
Args:
|
||||
data: Dictionary to redact
|
||||
redactor: Optional custom redactor
|
||||
|
||||
Returns:
|
||||
New dictionary with redacted values
|
||||
"""
|
||||
r = redactor or get_default_redactor()
|
||||
|
||||
def redact_value(value):
|
||||
if isinstance(value, str):
|
||||
return r.redact(value)
|
||||
elif isinstance(value, dict):
|
||||
return create_safe_dict(value, r)
|
||||
elif isinstance(value, list):
|
||||
return [redact_value(v) for v in value]
|
||||
return value
|
||||
|
||||
return {k: redact_value(v) for k, v in data.items()}
|
||||
363
backend-core/middleware/rate_limiter.py
Normal file
363
backend-core/middleware/rate_limiter.py
Normal file
@@ -0,0 +1,363 @@
|
||||
"""
|
||||
Rate Limiter Middleware
|
||||
|
||||
Implements distributed rate limiting using Valkey (Redis-fork).
|
||||
Supports IP-based, user-based, and endpoint-specific rate limits.
|
||||
|
||||
Features:
|
||||
- Sliding window rate limiting
|
||||
- IP-based limits for unauthenticated requests
|
||||
- User-based limits for authenticated requests
|
||||
- Stricter limits for auth endpoints (anti-brute-force)
|
||||
- IP whitelist/blacklist support
|
||||
- Graceful fallback when Valkey is unavailable
|
||||
|
||||
Usage:
|
||||
from middleware import RateLimiterMiddleware
|
||||
|
||||
app.add_middleware(
|
||||
RateLimiterMiddleware,
|
||||
valkey_url="redis://localhost:6379",
|
||||
ip_limit=100,
|
||||
user_limit=500,
|
||||
)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import os
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Optional, Set
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse, Response
|
||||
|
||||
# Try to import redis (valkey-compatible)
|
||||
try:
|
||||
import redis.asyncio as redis
|
||||
REDIS_AVAILABLE = True
|
||||
except ImportError:
|
||||
REDIS_AVAILABLE = False
|
||||
redis = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RateLimitConfig:
|
||||
"""Configuration for rate limiting."""
|
||||
|
||||
# Valkey/Redis connection
|
||||
valkey_url: str = "redis://localhost:6379"
|
||||
|
||||
# Default limits (requests per minute)
|
||||
ip_limit: int = 100
|
||||
user_limit: int = 500
|
||||
|
||||
# Stricter limits for auth endpoints
|
||||
auth_limit: int = 20
|
||||
auth_endpoints: List[str] = field(default_factory=lambda: [
|
||||
"/api/auth/login",
|
||||
"/api/auth/register",
|
||||
"/api/auth/password-reset",
|
||||
"/api/auth/forgot-password",
|
||||
])
|
||||
|
||||
# Window size in seconds
|
||||
window_size: int = 60
|
||||
|
||||
# IP whitelist (never rate limited)
|
||||
ip_whitelist: Set[str] = field(default_factory=lambda: {
|
||||
"127.0.0.1",
|
||||
"::1",
|
||||
})
|
||||
|
||||
# IP blacklist (always blocked)
|
||||
ip_blacklist: Set[str] = field(default_factory=set)
|
||||
|
||||
# Skip internal Docker network
|
||||
skip_internal_network: bool = True
|
||||
|
||||
# Excluded paths
|
||||
excluded_paths: List[str] = field(default_factory=lambda: [
|
||||
"/health",
|
||||
"/metrics",
|
||||
"/api/health",
|
||||
])
|
||||
|
||||
# Fallback to in-memory when Valkey is unavailable
|
||||
fallback_enabled: bool = True
|
||||
|
||||
# Key prefix for rate limit keys
|
||||
key_prefix: str = "ratelimit"
|
||||
|
||||
|
||||
class InMemoryRateLimiter:
|
||||
"""Fallback in-memory rate limiter when Valkey is unavailable."""
|
||||
|
||||
def __init__(self):
|
||||
self._counts: Dict[str, List[float]] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def check_rate_limit(self, key: str, limit: int, window: int) -> tuple[bool, int]:
|
||||
"""
|
||||
Check if rate limit is exceeded.
|
||||
|
||||
Returns:
|
||||
Tuple of (is_allowed, remaining_requests)
|
||||
"""
|
||||
async with self._lock:
|
||||
now = time.time()
|
||||
window_start = now - window
|
||||
|
||||
# Get or create entry
|
||||
if key not in self._counts:
|
||||
self._counts[key] = []
|
||||
|
||||
# Remove old entries
|
||||
self._counts[key] = [t for t in self._counts[key] if t > window_start]
|
||||
|
||||
# Check limit
|
||||
current_count = len(self._counts[key])
|
||||
if current_count >= limit:
|
||||
return False, 0
|
||||
|
||||
# Add new request
|
||||
self._counts[key].append(now)
|
||||
return True, limit - current_count - 1
|
||||
|
||||
async def cleanup(self):
|
||||
"""Remove expired entries."""
|
||||
async with self._lock:
|
||||
now = time.time()
|
||||
for key in list(self._counts.keys()):
|
||||
self._counts[key] = [t for t in self._counts[key] if t > now - 3600]
|
||||
if not self._counts[key]:
|
||||
del self._counts[key]
|
||||
|
||||
|
||||
class RateLimiterMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
Middleware that implements distributed rate limiting.
|
||||
|
||||
Uses Valkey (Redis-fork) for distributed state, with fallback
|
||||
to in-memory rate limiting when Valkey is unavailable.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
app,
|
||||
config: Optional[RateLimitConfig] = None,
|
||||
# Individual overrides
|
||||
valkey_url: Optional[str] = None,
|
||||
ip_limit: Optional[int] = None,
|
||||
user_limit: Optional[int] = None,
|
||||
auth_limit: Optional[int] = None,
|
||||
):
|
||||
super().__init__(app)
|
||||
|
||||
self.config = config or RateLimitConfig()
|
||||
|
||||
# Apply overrides
|
||||
if valkey_url is not None:
|
||||
self.config.valkey_url = valkey_url
|
||||
if ip_limit is not None:
|
||||
self.config.ip_limit = ip_limit
|
||||
if user_limit is not None:
|
||||
self.config.user_limit = user_limit
|
||||
if auth_limit is not None:
|
||||
self.config.auth_limit = auth_limit
|
||||
|
||||
# Auto-configure from environment
|
||||
self.config.valkey_url = os.getenv("VALKEY_URL", self.config.valkey_url)
|
||||
|
||||
# Initialize Valkey client
|
||||
self._redis: Optional[redis.Redis] = None
|
||||
self._fallback = InMemoryRateLimiter()
|
||||
self._valkey_available = False
|
||||
|
||||
async def _get_redis(self) -> Optional[redis.Redis]:
|
||||
"""Get or create Redis/Valkey connection."""
|
||||
if not REDIS_AVAILABLE:
|
||||
return None
|
||||
|
||||
if self._redis is None:
|
||||
try:
|
||||
self._redis = redis.from_url(
|
||||
self.config.valkey_url,
|
||||
decode_responses=True,
|
||||
socket_timeout=1.0,
|
||||
socket_connect_timeout=1.0,
|
||||
)
|
||||
await self._redis.ping()
|
||||
self._valkey_available = True
|
||||
except Exception:
|
||||
self._valkey_available = False
|
||||
self._redis = None
|
||||
|
||||
return self._redis
|
||||
|
||||
def _get_client_ip(self, request: Request) -> str:
|
||||
"""Extract client IP from request."""
|
||||
# Check X-Forwarded-For header
|
||||
xff = request.headers.get("X-Forwarded-For")
|
||||
if xff:
|
||||
return xff.split(",")[0].strip()
|
||||
|
||||
# Check X-Real-IP header
|
||||
xri = request.headers.get("X-Real-IP")
|
||||
if xri:
|
||||
return xri
|
||||
|
||||
# Fall back to direct client IP
|
||||
if request.client:
|
||||
return request.client.host
|
||||
return "unknown"
|
||||
|
||||
def _get_user_id(self, request: Request) -> Optional[str]:
|
||||
"""Extract user ID from request state (set by session middleware)."""
|
||||
if hasattr(request.state, "session") and request.state.session:
|
||||
return getattr(request.state.session, "user_id", None)
|
||||
return None
|
||||
|
||||
def _is_internal_network(self, ip: str) -> bool:
|
||||
"""Check if IP is from internal Docker network."""
|
||||
return (
|
||||
ip.startswith("172.") or
|
||||
ip.startswith("10.") or
|
||||
ip.startswith("192.168.")
|
||||
)
|
||||
|
||||
def _get_rate_limit(self, request: Request) -> int:
|
||||
"""Determine the rate limit for this request."""
|
||||
path = request.url.path
|
||||
|
||||
# Auth endpoints get stricter limits
|
||||
for auth_path in self.config.auth_endpoints:
|
||||
if path.startswith(auth_path):
|
||||
return self.config.auth_limit
|
||||
|
||||
# Authenticated users get higher limits
|
||||
if self._get_user_id(request):
|
||||
return self.config.user_limit
|
||||
|
||||
# Default IP-based limit
|
||||
return self.config.ip_limit
|
||||
|
||||
def _get_rate_limit_key(self, request: Request) -> str:
|
||||
"""Generate the rate limit key for this request."""
|
||||
# Use user ID if authenticated
|
||||
user_id = self._get_user_id(request)
|
||||
if user_id:
|
||||
identifier = f"user:{user_id}"
|
||||
else:
|
||||
ip = self._get_client_ip(request)
|
||||
# Hash IP for privacy
|
||||
ip_hash = hashlib.sha256(ip.encode()).hexdigest()[:16]
|
||||
identifier = f"ip:{ip_hash}"
|
||||
|
||||
# Include path for endpoint-specific limits
|
||||
path = request.url.path
|
||||
for auth_path in self.config.auth_endpoints:
|
||||
if path.startswith(auth_path):
|
||||
return f"{self.config.key_prefix}:auth:{identifier}"
|
||||
|
||||
return f"{self.config.key_prefix}:{identifier}"
|
||||
|
||||
async def _check_rate_limit_valkey(
|
||||
self, key: str, limit: int, window: int
|
||||
) -> tuple[bool, int]:
|
||||
"""Check rate limit using Valkey."""
|
||||
r = await self._get_redis()
|
||||
if not r:
|
||||
return await self._fallback.check_rate_limit(key, limit, window)
|
||||
|
||||
try:
|
||||
# Use sliding window with sorted set
|
||||
now = time.time()
|
||||
window_start = now - window
|
||||
|
||||
pipe = r.pipeline()
|
||||
# Remove old entries
|
||||
pipe.zremrangebyscore(key, "-inf", window_start)
|
||||
# Count current entries
|
||||
pipe.zcard(key)
|
||||
# Add new entry
|
||||
pipe.zadd(key, {str(now): now})
|
||||
# Set expiry
|
||||
pipe.expire(key, window + 10)
|
||||
|
||||
results = await pipe.execute()
|
||||
current_count = results[1]
|
||||
|
||||
if current_count >= limit:
|
||||
return False, 0
|
||||
|
||||
return True, limit - current_count - 1
|
||||
|
||||
except Exception:
|
||||
# Fallback to in-memory
|
||||
self._valkey_available = False
|
||||
return await self._fallback.check_rate_limit(key, limit, window)
|
||||
|
||||
async def dispatch(self, request: Request, call_next) -> Response:
|
||||
# Skip excluded paths
|
||||
if request.url.path in self.config.excluded_paths:
|
||||
return await call_next(request)
|
||||
|
||||
# Get client IP
|
||||
ip = self._get_client_ip(request)
|
||||
|
||||
# Check blacklist
|
||||
if ip in self.config.ip_blacklist:
|
||||
return JSONResponse(
|
||||
status_code=403,
|
||||
content={
|
||||
"error": "ip_blocked",
|
||||
"message": "Your IP address has been blocked.",
|
||||
},
|
||||
)
|
||||
|
||||
# Skip whitelist
|
||||
if ip in self.config.ip_whitelist:
|
||||
return await call_next(request)
|
||||
|
||||
# Skip internal network
|
||||
if self.config.skip_internal_network and self._is_internal_network(ip):
|
||||
return await call_next(request)
|
||||
|
||||
# Get rate limit parameters
|
||||
limit = self._get_rate_limit(request)
|
||||
key = self._get_rate_limit_key(request)
|
||||
window = self.config.window_size
|
||||
|
||||
# Check rate limit
|
||||
allowed, remaining = await self._check_rate_limit_valkey(key, limit, window)
|
||||
|
||||
if not allowed:
|
||||
return JSONResponse(
|
||||
status_code=429,
|
||||
content={
|
||||
"error": "rate_limit_exceeded",
|
||||
"message": "Too many requests. Please try again later.",
|
||||
"retry_after": window,
|
||||
},
|
||||
headers={
|
||||
"Retry-After": str(window),
|
||||
"X-RateLimit-Limit": str(limit),
|
||||
"X-RateLimit-Remaining": "0",
|
||||
"X-RateLimit-Reset": str(int(time.time()) + window),
|
||||
},
|
||||
)
|
||||
|
||||
# Process request
|
||||
response = await call_next(request)
|
||||
|
||||
# Add rate limit headers
|
||||
response.headers["X-RateLimit-Limit"] = str(limit)
|
||||
response.headers["X-RateLimit-Remaining"] = str(remaining)
|
||||
response.headers["X-RateLimit-Reset"] = str(int(time.time()) + window)
|
||||
|
||||
return response
|
||||
138
backend-core/middleware/request_id.py
Normal file
138
backend-core/middleware/request_id.py
Normal file
@@ -0,0 +1,138 @@
|
||||
"""
|
||||
Request-ID Middleware
|
||||
|
||||
Generates and propagates unique request identifiers for distributed tracing.
|
||||
Supports both X-Request-ID and X-Correlation-ID headers.
|
||||
|
||||
Usage:
|
||||
from middleware import RequestIDMiddleware, get_request_id
|
||||
|
||||
app.add_middleware(RequestIDMiddleware)
|
||||
|
||||
@app.get("/api/example")
|
||||
async def example():
|
||||
request_id = get_request_id()
|
||||
logger.info(f"Processing request", extra={"request_id": request_id})
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from contextvars import ContextVar
|
||||
from typing import Optional
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
# Context variable to store request ID across async calls
|
||||
_request_id_ctx: ContextVar[Optional[str]] = ContextVar("request_id", default=None)
|
||||
|
||||
# Header names
|
||||
REQUEST_ID_HEADER = "X-Request-ID"
|
||||
CORRELATION_ID_HEADER = "X-Correlation-ID"
|
||||
|
||||
|
||||
def get_request_id() -> Optional[str]:
|
||||
"""
|
||||
Get the current request ID from context.
|
||||
|
||||
Returns:
|
||||
The request ID string or None if not in a request context.
|
||||
|
||||
Example:
|
||||
request_id = get_request_id()
|
||||
logger.info("Processing", extra={"request_id": request_id})
|
||||
"""
|
||||
return _request_id_ctx.get()
|
||||
|
||||
|
||||
def set_request_id(request_id: str) -> None:
|
||||
"""
|
||||
Set the request ID in the current context.
|
||||
|
||||
Args:
|
||||
request_id: The request ID to set
|
||||
"""
|
||||
_request_id_ctx.set(request_id)
|
||||
|
||||
|
||||
def generate_request_id() -> str:
|
||||
"""
|
||||
Generate a new unique request ID.
|
||||
|
||||
Returns:
|
||||
A UUID4 string
|
||||
"""
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
class RequestIDMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
Middleware that generates and propagates request IDs.
|
||||
|
||||
For each incoming request:
|
||||
1. Check for existing X-Request-ID or X-Correlation-ID header
|
||||
2. If not present, generate a new UUID
|
||||
3. Store in context for use by handlers and logging
|
||||
4. Add to response headers
|
||||
|
||||
Attributes:
|
||||
header_name: The primary header name to use (default: X-Request-ID)
|
||||
generator: Function to generate new IDs (default: uuid4)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
app,
|
||||
header_name: str = REQUEST_ID_HEADER,
|
||||
generator=generate_request_id,
|
||||
):
|
||||
super().__init__(app)
|
||||
self.header_name = header_name
|
||||
self.generator = generator
|
||||
|
||||
async def dispatch(self, request: Request, call_next) -> Response:
|
||||
# Try to get existing request ID from headers
|
||||
request_id = (
|
||||
request.headers.get(REQUEST_ID_HEADER)
|
||||
or request.headers.get(CORRELATION_ID_HEADER)
|
||||
)
|
||||
|
||||
# Generate new ID if not provided
|
||||
if not request_id:
|
||||
request_id = self.generator()
|
||||
|
||||
# Store in context for logging and handlers
|
||||
set_request_id(request_id)
|
||||
|
||||
# Store in request state for direct access
|
||||
request.state.request_id = request_id
|
||||
|
||||
# Process request
|
||||
response = await call_next(request)
|
||||
|
||||
# Add request ID to response headers
|
||||
response.headers[REQUEST_ID_HEADER] = request_id
|
||||
response.headers[CORRELATION_ID_HEADER] = request_id
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class RequestIDLogFilter:
|
||||
"""
|
||||
Logging filter that adds request_id to log records.
|
||||
|
||||
Usage:
|
||||
import logging
|
||||
|
||||
handler = logging.StreamHandler()
|
||||
handler.addFilter(RequestIDLogFilter())
|
||||
|
||||
formatter = logging.Formatter(
|
||||
'%(asctime)s [%(request_id)s] %(levelname)s %(message)s'
|
||||
)
|
||||
handler.setFormatter(formatter)
|
||||
"""
|
||||
|
||||
def filter(self, record):
|
||||
record.request_id = get_request_id() or "no-request-id"
|
||||
return True
|
||||
202
backend-core/middleware/security_headers.py
Normal file
202
backend-core/middleware/security_headers.py
Normal file
@@ -0,0 +1,202 @@
|
||||
"""
|
||||
Security Headers Middleware
|
||||
|
||||
Adds security headers to all HTTP responses to protect against common attacks.
|
||||
|
||||
Headers added:
|
||||
- X-Content-Type-Options: nosniff
|
||||
- X-Frame-Options: DENY
|
||||
- X-XSS-Protection: 1; mode=block
|
||||
- Strict-Transport-Security (HSTS)
|
||||
- Content-Security-Policy
|
||||
- Referrer-Policy
|
||||
- Permissions-Policy
|
||||
|
||||
Usage:
|
||||
from middleware import SecurityHeadersMiddleware
|
||||
|
||||
app.add_middleware(SecurityHeadersMiddleware)
|
||||
|
||||
# Or with custom configuration:
|
||||
app.add_middleware(
|
||||
SecurityHeadersMiddleware,
|
||||
hsts_enabled=True,
|
||||
csp_policy="default-src 'self'",
|
||||
)
|
||||
"""
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
|
||||
@dataclass
|
||||
class SecurityHeadersConfig:
|
||||
"""Configuration for security headers."""
|
||||
|
||||
# X-Content-Type-Options
|
||||
content_type_options: str = "nosniff"
|
||||
|
||||
# X-Frame-Options
|
||||
frame_options: str = "DENY"
|
||||
|
||||
# X-XSS-Protection (legacy, but still useful for older browsers)
|
||||
xss_protection: str = "1; mode=block"
|
||||
|
||||
# Strict-Transport-Security
|
||||
hsts_enabled: bool = True
|
||||
hsts_max_age: int = 31536000 # 1 year
|
||||
hsts_include_subdomains: bool = True
|
||||
hsts_preload: bool = False
|
||||
|
||||
# Content-Security-Policy
|
||||
csp_enabled: bool = True
|
||||
csp_policy: str = "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https:; frame-ancestors 'none'"
|
||||
|
||||
# Referrer-Policy
|
||||
referrer_policy: str = "strict-origin-when-cross-origin"
|
||||
|
||||
# Permissions-Policy (formerly Feature-Policy)
|
||||
permissions_policy: str = "geolocation=(), microphone=(), camera=()"
|
||||
|
||||
# Cross-Origin headers
|
||||
cross_origin_opener_policy: str = "same-origin"
|
||||
cross_origin_embedder_policy: str = "require-corp"
|
||||
cross_origin_resource_policy: str = "same-origin"
|
||||
|
||||
# Development mode (relaxes some restrictions)
|
||||
development_mode: bool = False
|
||||
|
||||
# Excluded paths (e.g., for health checks)
|
||||
excluded_paths: List[str] = field(default_factory=lambda: ["/health", "/metrics"])
|
||||
|
||||
|
||||
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
Middleware that adds security headers to all responses.
|
||||
|
||||
Attributes:
|
||||
config: SecurityHeadersConfig instance
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
app,
|
||||
config: Optional[SecurityHeadersConfig] = None,
|
||||
# Individual overrides for convenience
|
||||
hsts_enabled: Optional[bool] = None,
|
||||
csp_policy: Optional[str] = None,
|
||||
csp_enabled: Optional[bool] = None,
|
||||
development_mode: Optional[bool] = None,
|
||||
):
|
||||
super().__init__(app)
|
||||
|
||||
# Use provided config or create default
|
||||
self.config = config or SecurityHeadersConfig()
|
||||
|
||||
# Apply individual overrides
|
||||
if hsts_enabled is not None:
|
||||
self.config.hsts_enabled = hsts_enabled
|
||||
if csp_policy is not None:
|
||||
self.config.csp_policy = csp_policy
|
||||
if csp_enabled is not None:
|
||||
self.config.csp_enabled = csp_enabled
|
||||
if development_mode is not None:
|
||||
self.config.development_mode = development_mode
|
||||
|
||||
# Auto-detect development mode from environment
|
||||
if development_mode is None:
|
||||
env = os.getenv("ENVIRONMENT", "development")
|
||||
self.config.development_mode = env.lower() in ("development", "dev", "local")
|
||||
|
||||
def _build_hsts_header(self) -> str:
|
||||
"""Build the Strict-Transport-Security header value."""
|
||||
parts = [f"max-age={self.config.hsts_max_age}"]
|
||||
if self.config.hsts_include_subdomains:
|
||||
parts.append("includeSubDomains")
|
||||
if self.config.hsts_preload:
|
||||
parts.append("preload")
|
||||
return "; ".join(parts)
|
||||
|
||||
def _get_headers(self) -> Dict[str, str]:
|
||||
"""Build the security headers dictionary."""
|
||||
headers = {}
|
||||
|
||||
# Always add these headers
|
||||
headers["X-Content-Type-Options"] = self.config.content_type_options
|
||||
headers["X-Frame-Options"] = self.config.frame_options
|
||||
headers["X-XSS-Protection"] = self.config.xss_protection
|
||||
headers["Referrer-Policy"] = self.config.referrer_policy
|
||||
|
||||
# HSTS (only in production or if explicitly enabled)
|
||||
if self.config.hsts_enabled and not self.config.development_mode:
|
||||
headers["Strict-Transport-Security"] = self._build_hsts_header()
|
||||
|
||||
# Content-Security-Policy
|
||||
if self.config.csp_enabled:
|
||||
headers["Content-Security-Policy"] = self.config.csp_policy
|
||||
|
||||
# Permissions-Policy
|
||||
if self.config.permissions_policy:
|
||||
headers["Permissions-Policy"] = self.config.permissions_policy
|
||||
|
||||
# Cross-Origin headers (relaxed in development)
|
||||
if not self.config.development_mode:
|
||||
headers["Cross-Origin-Opener-Policy"] = self.config.cross_origin_opener_policy
|
||||
# Note: COEP can break loading of external resources, be careful
|
||||
# headers["Cross-Origin-Embedder-Policy"] = self.config.cross_origin_embedder_policy
|
||||
headers["Cross-Origin-Resource-Policy"] = self.config.cross_origin_resource_policy
|
||||
|
||||
return headers
|
||||
|
||||
async def dispatch(self, request: Request, call_next) -> Response:
|
||||
# Skip security headers for excluded paths
|
||||
if request.url.path in self.config.excluded_paths:
|
||||
return await call_next(request)
|
||||
|
||||
# Process request
|
||||
response = await call_next(request)
|
||||
|
||||
# Add security headers
|
||||
for header_name, header_value in self._get_headers().items():
|
||||
response.headers[header_name] = header_value
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def get_default_csp_for_environment(environment: str) -> str:
|
||||
"""
|
||||
Get a sensible default CSP for the given environment.
|
||||
|
||||
Args:
|
||||
environment: "development", "staging", or "production"
|
||||
|
||||
Returns:
|
||||
CSP policy string
|
||||
"""
|
||||
if environment.lower() in ("development", "dev", "local"):
|
||||
# Relaxed CSP for development
|
||||
return (
|
||||
"default-src 'self' localhost:* ws://localhost:*; "
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; "
|
||||
"style-src 'self' 'unsafe-inline'; "
|
||||
"img-src 'self' data: https: blob:; "
|
||||
"font-src 'self' data:; "
|
||||
"connect-src 'self' localhost:* ws://localhost:* https:; "
|
||||
"frame-ancestors 'self'"
|
||||
)
|
||||
else:
|
||||
# Strict CSP for production
|
||||
return (
|
||||
"default-src 'self'; "
|
||||
"script-src 'self' 'unsafe-inline'; "
|
||||
"style-src 'self' 'unsafe-inline'; "
|
||||
"img-src 'self' data: https:; "
|
||||
"font-src 'self' data:; "
|
||||
"connect-src 'self' https://breakpilot.app https://*.breakpilot.app; "
|
||||
"frame-ancestors 'none'"
|
||||
)
|
||||
142
backend-core/notification_api.py
Normal file
142
backend-core/notification_api.py
Normal file
@@ -0,0 +1,142 @@
|
||||
"""
|
||||
Notification API - Proxy zu Go Consent Service für Benachrichtigungen
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Header, Query
|
||||
from typing import Optional
|
||||
import httpx
|
||||
|
||||
router = APIRouter(prefix="/v1/notifications", tags=["Notifications"])
|
||||
|
||||
CONSENT_SERVICE_URL = "http://localhost:8081"
|
||||
|
||||
|
||||
async def proxy_request(
|
||||
method: str,
|
||||
path: str,
|
||||
authorization: Optional[str] = None,
|
||||
json_data: dict = None,
|
||||
params: dict = None
|
||||
):
|
||||
"""Proxy request to Go consent service."""
|
||||
headers = {}
|
||||
if authorization:
|
||||
headers["Authorization"] = authorization
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.request(
|
||||
method,
|
||||
f"{CONSENT_SERVICE_URL}{path}",
|
||||
headers=headers,
|
||||
json=json_data,
|
||||
params=params,
|
||||
timeout=30.0
|
||||
)
|
||||
|
||||
if response.status_code >= 400:
|
||||
raise HTTPException(
|
||||
status_code=response.status_code,
|
||||
detail=response.json().get("error", "Request failed")
|
||||
)
|
||||
|
||||
return response.json()
|
||||
except httpx.RequestError as e:
|
||||
raise HTTPException(status_code=503, detail=f"Consent service unavailable: {str(e)}")
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_notifications(
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
unread_only: bool = Query(False),
|
||||
authorization: Optional[str] = Header(None)
|
||||
):
|
||||
"""Holt alle Benachrichtigungen des aktuellen Benutzers."""
|
||||
params = {
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"unread_only": str(unread_only).lower()
|
||||
}
|
||||
return await proxy_request(
|
||||
"GET",
|
||||
"/api/v1/notifications",
|
||||
authorization=authorization,
|
||||
params=params
|
||||
)
|
||||
|
||||
|
||||
@router.get("/unread-count")
|
||||
async def get_unread_count(
|
||||
authorization: Optional[str] = Header(None)
|
||||
):
|
||||
"""Gibt die Anzahl ungelesener Benachrichtigungen zurück."""
|
||||
return await proxy_request(
|
||||
"GET",
|
||||
"/api/v1/notifications/unread-count",
|
||||
authorization=authorization
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{notification_id}/read")
|
||||
async def mark_as_read(
|
||||
notification_id: str,
|
||||
authorization: Optional[str] = Header(None)
|
||||
):
|
||||
"""Markiert eine Benachrichtigung als gelesen."""
|
||||
return await proxy_request(
|
||||
"PUT",
|
||||
f"/api/v1/notifications/{notification_id}/read",
|
||||
authorization=authorization
|
||||
)
|
||||
|
||||
|
||||
@router.put("/read-all")
|
||||
async def mark_all_as_read(
|
||||
authorization: Optional[str] = Header(None)
|
||||
):
|
||||
"""Markiert alle Benachrichtigungen als gelesen."""
|
||||
return await proxy_request(
|
||||
"PUT",
|
||||
"/api/v1/notifications/read-all",
|
||||
authorization=authorization
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{notification_id}")
|
||||
async def delete_notification(
|
||||
notification_id: str,
|
||||
authorization: Optional[str] = Header(None)
|
||||
):
|
||||
"""Löscht eine Benachrichtigung."""
|
||||
return await proxy_request(
|
||||
"DELETE",
|
||||
f"/api/v1/notifications/{notification_id}",
|
||||
authorization=authorization
|
||||
)
|
||||
|
||||
|
||||
@router.get("/preferences")
|
||||
async def get_preferences(
|
||||
authorization: Optional[str] = Header(None)
|
||||
):
|
||||
"""Holt die Benachrichtigungseinstellungen des Benutzers."""
|
||||
return await proxy_request(
|
||||
"GET",
|
||||
"/api/v1/notifications/preferences",
|
||||
authorization=authorization
|
||||
)
|
||||
|
||||
|
||||
@router.put("/preferences")
|
||||
async def update_preferences(
|
||||
preferences: dict,
|
||||
authorization: Optional[str] = Header(None)
|
||||
):
|
||||
"""Aktualisiert die Benachrichtigungseinstellungen."""
|
||||
return await proxy_request(
|
||||
"PUT",
|
||||
"/api/v1/notifications/preferences",
|
||||
authorization=authorization,
|
||||
json_data=preferences
|
||||
)
|
||||
819
backend-core/rbac_api.py
Normal file
819
backend-core/rbac_api.py
Normal file
@@ -0,0 +1,819 @@
|
||||
"""
|
||||
RBAC API - Teacher and Role Management Endpoints
|
||||
|
||||
Provides API endpoints for:
|
||||
- Listing all teachers
|
||||
- Listing all available roles
|
||||
- Assigning/revoking roles to teachers
|
||||
- Viewing role assignments per teacher
|
||||
|
||||
Architecture:
|
||||
- Authentication: Keycloak (when configured) or local JWT
|
||||
- Authorization: Custom rbac.py for fine-grained permissions
|
||||
"""
|
||||
|
||||
import os
|
||||
import asyncpg
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, List, Dict, Any
|
||||
from fastapi import APIRouter, HTTPException, Depends, Request
|
||||
from pydantic import BaseModel
|
||||
|
||||
# Import hybrid auth module
|
||||
try:
|
||||
from auth import get_current_user, TokenExpiredError, TokenInvalidError
|
||||
except ImportError:
|
||||
# Fallback for standalone testing
|
||||
from auth.keycloak_auth import get_current_user, TokenExpiredError, TokenInvalidError
|
||||
|
||||
# Configuration from environment - NO DEFAULT SECRETS
|
||||
ENVIRONMENT = os.environ.get("ENVIRONMENT", "development")
|
||||
|
||||
router = APIRouter(prefix="/rbac", tags=["rbac"])
|
||||
|
||||
# Connection pool
|
||||
_pool: Optional[asyncpg.Pool] = None
|
||||
|
||||
|
||||
def _get_database_url() -> str:
|
||||
"""Get DATABASE_URL from environment, raising error if not set."""
|
||||
url = os.environ.get("DATABASE_URL")
|
||||
if not url:
|
||||
raise RuntimeError("DATABASE_URL nicht konfiguriert - bitte via Vault oder Umgebungsvariable setzen")
|
||||
return url
|
||||
|
||||
|
||||
async def get_pool() -> asyncpg.Pool:
|
||||
"""Get or create database connection pool"""
|
||||
global _pool
|
||||
if _pool is None:
|
||||
database_url = _get_database_url()
|
||||
_pool = await asyncpg.create_pool(database_url, min_size=2, max_size=10)
|
||||
return _pool
|
||||
|
||||
|
||||
async def close_pool():
|
||||
"""Close database connection pool"""
|
||||
global _pool
|
||||
if _pool:
|
||||
await _pool.close()
|
||||
_pool = None
|
||||
|
||||
|
||||
# Pydantic Models
|
||||
class RoleAssignmentCreate(BaseModel):
|
||||
user_id: str
|
||||
role: str
|
||||
resource_type: str = "tenant"
|
||||
resource_id: str
|
||||
valid_to: Optional[str] = None
|
||||
|
||||
|
||||
class RoleAssignmentRevoke(BaseModel):
|
||||
assignment_id: str
|
||||
|
||||
|
||||
class TeacherCreate(BaseModel):
|
||||
email: str
|
||||
first_name: str
|
||||
last_name: str
|
||||
teacher_code: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
roles: List[str] = []
|
||||
|
||||
|
||||
class TeacherUpdate(BaseModel):
|
||||
email: Optional[str] = None
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
teacher_code: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class CustomRoleCreate(BaseModel):
|
||||
role_key: str
|
||||
display_name: str
|
||||
description: str
|
||||
category: str
|
||||
|
||||
|
||||
class CustomRoleUpdate(BaseModel):
|
||||
display_name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
|
||||
|
||||
class TeacherResponse(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
email: str
|
||||
name: str
|
||||
teacher_code: Optional[str]
|
||||
title: Optional[str]
|
||||
first_name: str
|
||||
last_name: str
|
||||
is_active: bool
|
||||
roles: List[str]
|
||||
|
||||
|
||||
class RoleInfo(BaseModel):
|
||||
role: str
|
||||
display_name: str
|
||||
description: str
|
||||
category: str
|
||||
|
||||
|
||||
class RoleAssignmentResponse(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
role: str
|
||||
resource_type: str
|
||||
resource_id: str
|
||||
valid_from: str
|
||||
valid_to: Optional[str]
|
||||
granted_at: str
|
||||
is_active: bool
|
||||
|
||||
|
||||
# Role definitions with German display names
|
||||
AVAILABLE_ROLES = {
|
||||
# Klausur-Korrekturkette
|
||||
"erstkorrektor": {
|
||||
"display_name": "Erstkorrektor",
|
||||
"description": "Fuehrt die erste Korrektur der Klausur durch",
|
||||
"category": "klausur"
|
||||
},
|
||||
"zweitkorrektor": {
|
||||
"display_name": "Zweitkorrektor",
|
||||
"description": "Fuehrt die zweite Korrektur der Klausur durch",
|
||||
"category": "klausur"
|
||||
},
|
||||
"drittkorrektor": {
|
||||
"display_name": "Drittkorrektor",
|
||||
"description": "Fuehrt die dritte Korrektur bei Notenabweichung durch",
|
||||
"category": "klausur"
|
||||
},
|
||||
# Zeugnis-Workflow
|
||||
"klassenlehrer": {
|
||||
"display_name": "Klassenlehrer/in",
|
||||
"description": "Erstellt Zeugnisse, traegt Kopfnoten und Bemerkungen ein",
|
||||
"category": "zeugnis"
|
||||
},
|
||||
"fachlehrer": {
|
||||
"display_name": "Fachlehrer/in",
|
||||
"description": "Traegt Fachnoten ein",
|
||||
"category": "zeugnis"
|
||||
},
|
||||
"zeugnisbeauftragter": {
|
||||
"display_name": "Zeugnisbeauftragte/r",
|
||||
"description": "Qualitaetskontrolle und Freigabe von Zeugnissen",
|
||||
"category": "zeugnis"
|
||||
},
|
||||
"sekretariat": {
|
||||
"display_name": "Sekretariat",
|
||||
"description": "Druck, Versand und Archivierung von Dokumenten",
|
||||
"category": "verwaltung"
|
||||
},
|
||||
# Leitung
|
||||
"fachvorsitz": {
|
||||
"display_name": "Fachvorsitz",
|
||||
"description": "Fachpruefungsleitung und Qualitaetssicherung",
|
||||
"category": "leitung"
|
||||
},
|
||||
"pruefungsvorsitz": {
|
||||
"display_name": "Pruefungsvorsitz",
|
||||
"description": "Pruefungsleitung und finale Freigabe",
|
||||
"category": "leitung"
|
||||
},
|
||||
"schulleitung": {
|
||||
"display_name": "Schulleitung",
|
||||
"description": "Finale Freigabe und Unterschrift",
|
||||
"category": "leitung"
|
||||
},
|
||||
"stufenleitung": {
|
||||
"display_name": "Stufenleitung",
|
||||
"description": "Koordination einer Jahrgangsstufe",
|
||||
"category": "leitung"
|
||||
},
|
||||
# Administration
|
||||
"schul_admin": {
|
||||
"display_name": "Schul-Administrator",
|
||||
"description": "Technische Administration der Schule",
|
||||
"category": "admin"
|
||||
},
|
||||
"teacher_assistant": {
|
||||
"display_name": "Referendar/in",
|
||||
"description": "Lehrkraft in Ausbildung mit eingeschraenkten Rechten",
|
||||
"category": "other"
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# Note: get_user_from_token is replaced by the imported get_current_user dependency
|
||||
# from auth module which supports both Keycloak and local JWT authentication
|
||||
|
||||
|
||||
# API Endpoints
|
||||
|
||||
@router.get("/roles")
|
||||
async def list_available_roles() -> List[RoleInfo]:
|
||||
"""List all available roles with their descriptions"""
|
||||
return [
|
||||
RoleInfo(
|
||||
role=role_key,
|
||||
display_name=role_data["display_name"],
|
||||
description=role_data["description"],
|
||||
category=role_data["category"]
|
||||
)
|
||||
for role_key, role_data in AVAILABLE_ROLES.items()
|
||||
]
|
||||
|
||||
|
||||
@router.get("/teachers")
|
||||
async def list_teachers(user: Dict[str, Any] = Depends(get_current_user)) -> List[TeacherResponse]:
|
||||
"""List all teachers with their current roles"""
|
||||
pool = await get_pool()
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
# Get all teachers with their user info
|
||||
teachers = await conn.fetch("""
|
||||
SELECT
|
||||
t.id, t.user_id, t.teacher_code, t.title,
|
||||
t.first_name, t.last_name, t.is_active,
|
||||
u.email, u.name
|
||||
FROM teachers t
|
||||
JOIN users u ON t.user_id = u.id
|
||||
WHERE t.school_id = 'a0000000-0000-0000-0000-000000000001'
|
||||
ORDER BY t.last_name, t.first_name
|
||||
""")
|
||||
|
||||
# Get role assignments for all teachers
|
||||
role_assignments = await conn.fetch("""
|
||||
SELECT user_id, role
|
||||
FROM role_assignments
|
||||
WHERE tenant_id = 'a0000000-0000-0000-0000-000000000001'
|
||||
AND revoked_at IS NULL
|
||||
AND (valid_to IS NULL OR valid_to > NOW())
|
||||
""")
|
||||
|
||||
# Build role lookup
|
||||
role_lookup: Dict[str, List[str]] = {}
|
||||
for ra in role_assignments:
|
||||
uid = str(ra["user_id"])
|
||||
if uid not in role_lookup:
|
||||
role_lookup[uid] = []
|
||||
role_lookup[uid].append(ra["role"])
|
||||
|
||||
# Build response
|
||||
result = []
|
||||
for t in teachers:
|
||||
uid = str(t["user_id"])
|
||||
result.append(TeacherResponse(
|
||||
id=str(t["id"]),
|
||||
user_id=uid,
|
||||
email=t["email"],
|
||||
name=t["name"] or f"{t['first_name']} {t['last_name']}",
|
||||
teacher_code=t["teacher_code"],
|
||||
title=t["title"],
|
||||
first_name=t["first_name"],
|
||||
last_name=t["last_name"],
|
||||
is_active=t["is_active"],
|
||||
roles=role_lookup.get(uid, [])
|
||||
))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/teachers/{teacher_id}/roles")
|
||||
async def get_teacher_roles(teacher_id: str, user: Dict[str, Any] = Depends(get_current_user)) -> List[RoleAssignmentResponse]:
|
||||
"""Get all role assignments for a specific teacher"""
|
||||
pool = await get_pool()
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
# Get teacher's user_id
|
||||
teacher = await conn.fetchrow(
|
||||
"SELECT user_id FROM teachers WHERE id = $1",
|
||||
teacher_id
|
||||
)
|
||||
if not teacher:
|
||||
raise HTTPException(status_code=404, detail="Teacher not found")
|
||||
|
||||
# Get role assignments
|
||||
assignments = await conn.fetch("""
|
||||
SELECT id, user_id, role, resource_type, resource_id,
|
||||
valid_from, valid_to, granted_at, revoked_at
|
||||
FROM role_assignments
|
||||
WHERE user_id = $1
|
||||
ORDER BY granted_at DESC
|
||||
""", teacher["user_id"])
|
||||
|
||||
return [
|
||||
RoleAssignmentResponse(
|
||||
id=str(a["id"]),
|
||||
user_id=str(a["user_id"]),
|
||||
role=a["role"],
|
||||
resource_type=a["resource_type"],
|
||||
resource_id=str(a["resource_id"]),
|
||||
valid_from=a["valid_from"].isoformat() if a["valid_from"] else None,
|
||||
valid_to=a["valid_to"].isoformat() if a["valid_to"] else None,
|
||||
granted_at=a["granted_at"].isoformat() if a["granted_at"] else None,
|
||||
is_active=a["revoked_at"] is None and (
|
||||
a["valid_to"] is None or a["valid_to"] > datetime.now(timezone.utc)
|
||||
)
|
||||
)
|
||||
for a in assignments
|
||||
]
|
||||
|
||||
|
||||
@router.get("/roles/{role}/teachers")
|
||||
async def get_teachers_by_role(role: str, user: Dict[str, Any] = Depends(get_current_user)) -> List[TeacherResponse]:
|
||||
"""Get all teachers with a specific role"""
|
||||
if role not in AVAILABLE_ROLES:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown role: {role}")
|
||||
|
||||
pool = await get_pool()
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
teachers = await conn.fetch("""
|
||||
SELECT DISTINCT
|
||||
t.id, t.user_id, t.teacher_code, t.title,
|
||||
t.first_name, t.last_name, t.is_active,
|
||||
u.email, u.name
|
||||
FROM teachers t
|
||||
JOIN users u ON t.user_id = u.id
|
||||
JOIN role_assignments ra ON t.user_id = ra.user_id
|
||||
WHERE ra.role = $1
|
||||
AND ra.revoked_at IS NULL
|
||||
AND (ra.valid_to IS NULL OR ra.valid_to > NOW())
|
||||
AND t.school_id = 'a0000000-0000-0000-0000-000000000001'
|
||||
ORDER BY t.last_name, t.first_name
|
||||
""", role)
|
||||
|
||||
# Get all roles for these teachers
|
||||
if teachers:
|
||||
user_ids = [t["user_id"] for t in teachers]
|
||||
role_assignments = await conn.fetch("""
|
||||
SELECT user_id, role
|
||||
FROM role_assignments
|
||||
WHERE user_id = ANY($1)
|
||||
AND revoked_at IS NULL
|
||||
AND (valid_to IS NULL OR valid_to > NOW())
|
||||
""", user_ids)
|
||||
|
||||
role_lookup: Dict[str, List[str]] = {}
|
||||
for ra in role_assignments:
|
||||
uid = str(ra["user_id"])
|
||||
if uid not in role_lookup:
|
||||
role_lookup[uid] = []
|
||||
role_lookup[uid].append(ra["role"])
|
||||
else:
|
||||
role_lookup = {}
|
||||
|
||||
return [
|
||||
TeacherResponse(
|
||||
id=str(t["id"]),
|
||||
user_id=str(t["user_id"]),
|
||||
email=t["email"],
|
||||
name=t["name"] or f"{t['first_name']} {t['last_name']}",
|
||||
teacher_code=t["teacher_code"],
|
||||
title=t["title"],
|
||||
first_name=t["first_name"],
|
||||
last_name=t["last_name"],
|
||||
is_active=t["is_active"],
|
||||
roles=role_lookup.get(str(t["user_id"]), [])
|
||||
)
|
||||
for t in teachers
|
||||
]
|
||||
|
||||
|
||||
@router.post("/assignments")
|
||||
async def assign_role(assignment: RoleAssignmentCreate, user: Dict[str, Any] = Depends(get_current_user)) -> RoleAssignmentResponse:
|
||||
"""Assign a role to a user"""
|
||||
if assignment.role not in AVAILABLE_ROLES:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown role: {assignment.role}")
|
||||
|
||||
pool = await get_pool()
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
# Check if assignment already exists
|
||||
existing = await conn.fetchrow("""
|
||||
SELECT id FROM role_assignments
|
||||
WHERE user_id = $1 AND role = $2 AND resource_id = $3
|
||||
AND revoked_at IS NULL
|
||||
""", assignment.user_id, assignment.role, assignment.resource_id)
|
||||
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Role assignment already exists"
|
||||
)
|
||||
|
||||
# Parse valid_to if provided
|
||||
valid_to = None
|
||||
if assignment.valid_to:
|
||||
valid_to = datetime.fromisoformat(assignment.valid_to)
|
||||
|
||||
# Create assignment
|
||||
result = await conn.fetchrow("""
|
||||
INSERT INTO role_assignments
|
||||
(user_id, role, resource_type, resource_id, tenant_id, valid_to, granted_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id, user_id, role, resource_type, resource_id, valid_from, valid_to, granted_at
|
||||
""",
|
||||
assignment.user_id,
|
||||
assignment.role,
|
||||
assignment.resource_type,
|
||||
assignment.resource_id,
|
||||
assignment.resource_id, # tenant_id same as resource_id for tenant-level roles
|
||||
valid_to,
|
||||
user.get("user_id")
|
||||
)
|
||||
|
||||
return RoleAssignmentResponse(
|
||||
id=str(result["id"]),
|
||||
user_id=str(result["user_id"]),
|
||||
role=result["role"],
|
||||
resource_type=result["resource_type"],
|
||||
resource_id=str(result["resource_id"]),
|
||||
valid_from=result["valid_from"].isoformat(),
|
||||
valid_to=result["valid_to"].isoformat() if result["valid_to"] else None,
|
||||
granted_at=result["granted_at"].isoformat(),
|
||||
is_active=True
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/assignments/{assignment_id}")
|
||||
async def revoke_role(assignment_id: str, user: Dict[str, Any] = Depends(get_current_user)):
|
||||
"""Revoke a role assignment"""
|
||||
pool = await get_pool()
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
result = await conn.execute("""
|
||||
UPDATE role_assignments
|
||||
SET revoked_at = NOW()
|
||||
WHERE id = $1 AND revoked_at IS NULL
|
||||
""", assignment_id)
|
||||
|
||||
if result == "UPDATE 0":
|
||||
raise HTTPException(status_code=404, detail="Assignment not found or already revoked")
|
||||
|
||||
return {"status": "revoked", "assignment_id": assignment_id}
|
||||
|
||||
|
||||
@router.get("/summary")
|
||||
async def get_role_summary(user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]:
|
||||
"""Get a summary of roles and their assignment counts"""
|
||||
pool = await get_pool()
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
counts = await conn.fetch("""
|
||||
SELECT role, COUNT(*) as count
|
||||
FROM role_assignments
|
||||
WHERE tenant_id = 'a0000000-0000-0000-0000-000000000001'
|
||||
AND revoked_at IS NULL
|
||||
AND (valid_to IS NULL OR valid_to > NOW())
|
||||
GROUP BY role
|
||||
ORDER BY role
|
||||
""")
|
||||
|
||||
total_teachers = await conn.fetchval("""
|
||||
SELECT COUNT(*) FROM teachers
|
||||
WHERE school_id = 'a0000000-0000-0000-0000-000000000001'
|
||||
AND is_active = true
|
||||
""")
|
||||
|
||||
role_counts = {c["role"]: c["count"] for c in counts}
|
||||
|
||||
# Also include custom roles from database
|
||||
custom_roles = await conn.fetch("""
|
||||
SELECT role_key, display_name, category
|
||||
FROM custom_roles
|
||||
WHERE tenant_id = 'a0000000-0000-0000-0000-000000000001'
|
||||
AND is_active = true
|
||||
""")
|
||||
|
||||
all_roles = [
|
||||
{
|
||||
"role": role_key,
|
||||
"display_name": role_data["display_name"],
|
||||
"category": role_data["category"],
|
||||
"count": role_counts.get(role_key, 0),
|
||||
"is_custom": False
|
||||
}
|
||||
for role_key, role_data in AVAILABLE_ROLES.items()
|
||||
]
|
||||
|
||||
for cr in custom_roles:
|
||||
all_roles.append({
|
||||
"role": cr["role_key"],
|
||||
"display_name": cr["display_name"],
|
||||
"category": cr["category"],
|
||||
"count": role_counts.get(cr["role_key"], 0),
|
||||
"is_custom": True
|
||||
})
|
||||
|
||||
return {
|
||||
"total_teachers": total_teachers,
|
||||
"roles": all_roles
|
||||
}
|
||||
|
||||
|
||||
# ==========================================
|
||||
# TEACHER MANAGEMENT ENDPOINTS
|
||||
# ==========================================
|
||||
|
||||
@router.post("/teachers")
|
||||
async def create_teacher(teacher: TeacherCreate, user: Dict[str, Any] = Depends(get_current_user)) -> TeacherResponse:
|
||||
"""Create a new teacher with optional initial roles"""
|
||||
pool = await get_pool()
|
||||
|
||||
import uuid
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
# Check if email already exists
|
||||
existing = await conn.fetchrow(
|
||||
"SELECT id FROM users WHERE email = $1",
|
||||
teacher.email
|
||||
)
|
||||
if existing:
|
||||
raise HTTPException(status_code=409, detail="Email already exists")
|
||||
|
||||
# Generate UUIDs
|
||||
user_id = str(uuid.uuid4())
|
||||
teacher_id = str(uuid.uuid4())
|
||||
|
||||
# Create user first
|
||||
await conn.execute("""
|
||||
INSERT INTO users (id, email, name, password_hash, role, is_active)
|
||||
VALUES ($1, $2, $3, '', 'teacher', true)
|
||||
""", user_id, teacher.email, f"{teacher.first_name} {teacher.last_name}")
|
||||
|
||||
# Create teacher record
|
||||
await conn.execute("""
|
||||
INSERT INTO teachers (id, user_id, school_id, first_name, last_name, teacher_code, title, is_active)
|
||||
VALUES ($1, $2, 'a0000000-0000-0000-0000-000000000001', $3, $4, $5, $6, true)
|
||||
""", teacher_id, user_id, teacher.first_name, teacher.last_name,
|
||||
teacher.teacher_code, teacher.title)
|
||||
|
||||
# Assign initial roles if provided
|
||||
assigned_roles = []
|
||||
for role in teacher.roles:
|
||||
if role in AVAILABLE_ROLES or await conn.fetchrow(
|
||||
"SELECT 1 FROM custom_roles WHERE role_key = $1 AND is_active = true", role
|
||||
):
|
||||
await conn.execute("""
|
||||
INSERT INTO role_assignments (user_id, role, resource_type, resource_id, tenant_id, granted_by)
|
||||
VALUES ($1, $2, 'tenant', 'a0000000-0000-0000-0000-000000000001',
|
||||
'a0000000-0000-0000-0000-000000000001', $3)
|
||||
""", user_id, role, user.get("user_id"))
|
||||
assigned_roles.append(role)
|
||||
|
||||
return TeacherResponse(
|
||||
id=teacher_id,
|
||||
user_id=user_id,
|
||||
email=teacher.email,
|
||||
name=f"{teacher.first_name} {teacher.last_name}",
|
||||
teacher_code=teacher.teacher_code,
|
||||
title=teacher.title,
|
||||
first_name=teacher.first_name,
|
||||
last_name=teacher.last_name,
|
||||
is_active=True,
|
||||
roles=assigned_roles
|
||||
)
|
||||
|
||||
|
||||
@router.put("/teachers/{teacher_id}")
|
||||
async def update_teacher(teacher_id: str, updates: TeacherUpdate, user: Dict[str, Any] = Depends(get_current_user)) -> TeacherResponse:
|
||||
"""Update teacher information"""
|
||||
pool = await get_pool()
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
# Get current teacher data
|
||||
teacher = await conn.fetchrow("""
|
||||
SELECT t.id, t.user_id, t.teacher_code, t.title, t.first_name, t.last_name, t.is_active,
|
||||
u.email, u.name
|
||||
FROM teachers t
|
||||
JOIN users u ON t.user_id = u.id
|
||||
WHERE t.id = $1
|
||||
""", teacher_id)
|
||||
|
||||
if not teacher:
|
||||
raise HTTPException(status_code=404, detail="Teacher not found")
|
||||
|
||||
# Build update queries
|
||||
if updates.email:
|
||||
await conn.execute("UPDATE users SET email = $1 WHERE id = $2",
|
||||
updates.email, teacher["user_id"])
|
||||
|
||||
teacher_updates = []
|
||||
teacher_values = []
|
||||
idx = 1
|
||||
|
||||
if updates.first_name:
|
||||
teacher_updates.append(f"first_name = ${idx}")
|
||||
teacher_values.append(updates.first_name)
|
||||
idx += 1
|
||||
if updates.last_name:
|
||||
teacher_updates.append(f"last_name = ${idx}")
|
||||
teacher_values.append(updates.last_name)
|
||||
idx += 1
|
||||
if updates.teacher_code is not None:
|
||||
teacher_updates.append(f"teacher_code = ${idx}")
|
||||
teacher_values.append(updates.teacher_code)
|
||||
idx += 1
|
||||
if updates.title is not None:
|
||||
teacher_updates.append(f"title = ${idx}")
|
||||
teacher_values.append(updates.title)
|
||||
idx += 1
|
||||
if updates.is_active is not None:
|
||||
teacher_updates.append(f"is_active = ${idx}")
|
||||
teacher_values.append(updates.is_active)
|
||||
idx += 1
|
||||
|
||||
if teacher_updates:
|
||||
teacher_values.append(teacher_id)
|
||||
await conn.execute(
|
||||
f"UPDATE teachers SET {', '.join(teacher_updates)} WHERE id = ${idx}",
|
||||
*teacher_values
|
||||
)
|
||||
|
||||
# Update user name if first/last name changed
|
||||
if updates.first_name or updates.last_name:
|
||||
new_first = updates.first_name or teacher["first_name"]
|
||||
new_last = updates.last_name or teacher["last_name"]
|
||||
await conn.execute("UPDATE users SET name = $1 WHERE id = $2",
|
||||
f"{new_first} {new_last}", teacher["user_id"])
|
||||
|
||||
# Fetch updated data
|
||||
updated = await conn.fetchrow("""
|
||||
SELECT t.id, t.user_id, t.teacher_code, t.title, t.first_name, t.last_name, t.is_active,
|
||||
u.email, u.name
|
||||
FROM teachers t
|
||||
JOIN users u ON t.user_id = u.id
|
||||
WHERE t.id = $1
|
||||
""", teacher_id)
|
||||
|
||||
# Get roles
|
||||
roles = await conn.fetch("""
|
||||
SELECT role FROM role_assignments
|
||||
WHERE user_id = $1 AND revoked_at IS NULL
|
||||
AND (valid_to IS NULL OR valid_to > NOW())
|
||||
""", updated["user_id"])
|
||||
|
||||
return TeacherResponse(
|
||||
id=str(updated["id"]),
|
||||
user_id=str(updated["user_id"]),
|
||||
email=updated["email"],
|
||||
name=updated["name"],
|
||||
teacher_code=updated["teacher_code"],
|
||||
title=updated["title"],
|
||||
first_name=updated["first_name"],
|
||||
last_name=updated["last_name"],
|
||||
is_active=updated["is_active"],
|
||||
roles=[r["role"] for r in roles]
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/teachers/{teacher_id}")
|
||||
async def deactivate_teacher(teacher_id: str, user: Dict[str, Any] = Depends(get_current_user)):
|
||||
"""Deactivate a teacher (soft delete)"""
|
||||
pool = await get_pool()
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
result = await conn.execute("""
|
||||
UPDATE teachers SET is_active = false WHERE id = $1
|
||||
""", teacher_id)
|
||||
|
||||
if result == "UPDATE 0":
|
||||
raise HTTPException(status_code=404, detail="Teacher not found")
|
||||
|
||||
return {"status": "deactivated", "teacher_id": teacher_id}
|
||||
|
||||
|
||||
# ==========================================
|
||||
# CUSTOM ROLE MANAGEMENT ENDPOINTS
|
||||
# ==========================================
|
||||
|
||||
@router.get("/custom-roles")
|
||||
async def list_custom_roles(user: Dict[str, Any] = Depends(get_current_user)) -> List[RoleInfo]:
|
||||
"""List all custom roles"""
|
||||
pool = await get_pool()
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
roles = await conn.fetch("""
|
||||
SELECT role_key, display_name, description, category
|
||||
FROM custom_roles
|
||||
WHERE tenant_id = 'a0000000-0000-0000-0000-000000000001'
|
||||
AND is_active = true
|
||||
ORDER BY category, display_name
|
||||
""")
|
||||
|
||||
return [
|
||||
RoleInfo(
|
||||
role=r["role_key"],
|
||||
display_name=r["display_name"],
|
||||
description=r["description"],
|
||||
category=r["category"]
|
||||
)
|
||||
for r in roles
|
||||
]
|
||||
|
||||
|
||||
@router.post("/custom-roles")
|
||||
async def create_custom_role(role: CustomRoleCreate, user: Dict[str, Any] = Depends(get_current_user)) -> RoleInfo:
|
||||
"""Create a new custom role"""
|
||||
pool = await get_pool()
|
||||
|
||||
# Check if role_key conflicts with built-in roles
|
||||
if role.role_key in AVAILABLE_ROLES:
|
||||
raise HTTPException(status_code=409, detail="Role key conflicts with built-in role")
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
# Check if custom role already exists
|
||||
existing = await conn.fetchrow("""
|
||||
SELECT id FROM custom_roles
|
||||
WHERE role_key = $1 AND tenant_id = 'a0000000-0000-0000-0000-000000000001'
|
||||
""", role.role_key)
|
||||
|
||||
if existing:
|
||||
raise HTTPException(status_code=409, detail="Custom role already exists")
|
||||
|
||||
await conn.execute("""
|
||||
INSERT INTO custom_roles (role_key, display_name, description, category, tenant_id, created_by)
|
||||
VALUES ($1, $2, $3, $4, 'a0000000-0000-0000-0000-000000000001', $5)
|
||||
""", role.role_key, role.display_name, role.description, role.category, user.get("user_id"))
|
||||
|
||||
return RoleInfo(
|
||||
role=role.role_key,
|
||||
display_name=role.display_name,
|
||||
description=role.description,
|
||||
category=role.category
|
||||
)
|
||||
|
||||
|
||||
@router.put("/custom-roles/{role_key}")
|
||||
async def update_custom_role(role_key: str, updates: CustomRoleUpdate, user: Dict[str, Any] = Depends(get_current_user)) -> RoleInfo:
|
||||
"""Update a custom role"""
|
||||
|
||||
if role_key in AVAILABLE_ROLES:
|
||||
raise HTTPException(status_code=400, detail="Cannot modify built-in roles")
|
||||
|
||||
pool = await get_pool()
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
current = await conn.fetchrow("""
|
||||
SELECT role_key, display_name, description, category
|
||||
FROM custom_roles
|
||||
WHERE role_key = $1 AND tenant_id = 'a0000000-0000-0000-0000-000000000001'
|
||||
AND is_active = true
|
||||
""", role_key)
|
||||
|
||||
if not current:
|
||||
raise HTTPException(status_code=404, detail="Custom role not found")
|
||||
|
||||
new_display = updates.display_name or current["display_name"]
|
||||
new_desc = updates.description or current["description"]
|
||||
new_cat = updates.category or current["category"]
|
||||
|
||||
await conn.execute("""
|
||||
UPDATE custom_roles
|
||||
SET display_name = $1, description = $2, category = $3
|
||||
WHERE role_key = $4 AND tenant_id = 'a0000000-0000-0000-0000-000000000001'
|
||||
""", new_display, new_desc, new_cat, role_key)
|
||||
|
||||
return RoleInfo(
|
||||
role=role_key,
|
||||
display_name=new_display,
|
||||
description=new_desc,
|
||||
category=new_cat
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/custom-roles/{role_key}")
|
||||
async def delete_custom_role(role_key: str, user: Dict[str, Any] = Depends(get_current_user)):
|
||||
"""Delete a custom role (soft delete)"""
|
||||
|
||||
if role_key in AVAILABLE_ROLES:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete built-in roles")
|
||||
|
||||
pool = await get_pool()
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
# Soft delete the role
|
||||
result = await conn.execute("""
|
||||
UPDATE custom_roles SET is_active = false
|
||||
WHERE role_key = $1 AND tenant_id = 'a0000000-0000-0000-0000-000000000001'
|
||||
""", role_key)
|
||||
|
||||
if result == "UPDATE 0":
|
||||
raise HTTPException(status_code=404, detail="Custom role not found")
|
||||
|
||||
# Also revoke all assignments with this role
|
||||
await conn.execute("""
|
||||
UPDATE role_assignments SET revoked_at = NOW()
|
||||
WHERE role = $1 AND tenant_id = 'a0000000-0000-0000-0000-000000000001'
|
||||
AND revoked_at IS NULL
|
||||
""", role_key)
|
||||
|
||||
return {"status": "deleted", "role_key": role_key}
|
||||
52
backend-core/requirements.txt
Normal file
52
backend-core/requirements.txt
Normal file
@@ -0,0 +1,52 @@
|
||||
# BreakPilot Core Backend Dependencies
|
||||
# Only what the shared APIs actually need.
|
||||
|
||||
# Web Framework
|
||||
fastapi==0.123.9
|
||||
uvicorn==0.38.0
|
||||
starlette==0.49.3
|
||||
|
||||
# HTTP Client (auth_api, notification_api, email_template_api proxy calls)
|
||||
httpx==0.28.1
|
||||
requests==2.32.5
|
||||
|
||||
# Validation & Types
|
||||
pydantic==2.12.5
|
||||
pydantic_core==2.41.5
|
||||
email-validator==2.3.0
|
||||
annotated-types==0.7.0
|
||||
|
||||
# Authentication (auth module, consent_client JWT)
|
||||
PyJWT==2.10.1
|
||||
python-multipart==0.0.20
|
||||
|
||||
# Database (rbac_api, middleware rate_limiter)
|
||||
asyncpg==0.30.0
|
||||
psycopg2-binary==2.9.10
|
||||
|
||||
# Cache / Rate-Limiter (Valkey/Redis)
|
||||
redis==5.2.1
|
||||
|
||||
# PDF Generation (services/pdf_service)
|
||||
weasyprint==66.0
|
||||
Jinja2==3.1.6
|
||||
|
||||
# Image Processing (services/file_processor)
|
||||
pillow==11.3.0
|
||||
opencv-python==4.12.0.88
|
||||
numpy==2.0.2
|
||||
|
||||
# Document Processing (services/file_processor)
|
||||
python-docx==1.2.0
|
||||
mammoth==1.11.0
|
||||
Markdown==3.9
|
||||
|
||||
# Secrets Management (Vault)
|
||||
hvac==2.4.0
|
||||
|
||||
# Utilities
|
||||
python-dateutil==2.9.0.post0
|
||||
|
||||
# Security: Pin transitive dependencies to patched versions
|
||||
idna>=3.7 # CVE-2024-3651
|
||||
cryptography>=42.0.0 # GHSA-h4gh-qq45-vh27
|
||||
995
backend-core/security_api.py
Normal file
995
backend-core/security_api.py
Normal file
@@ -0,0 +1,995 @@
|
||||
"""
|
||||
BreakPilot Security API
|
||||
|
||||
Endpunkte fuer das Security Dashboard:
|
||||
- Tool-Status abfragen
|
||||
- Scan-Ergebnisse abrufen
|
||||
- Scans ausloesen
|
||||
- SBOM-Daten abrufen
|
||||
- Scan-Historie anzeigen
|
||||
|
||||
Features:
|
||||
- Liest Security-Reports aus dem security-reports/ Verzeichnis
|
||||
- Fuehrt Security-Scans via subprocess aus
|
||||
- Parst Gitleaks, Semgrep, Trivy, Grype JSON-Reports
|
||||
- Generiert SBOM mit Syft
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import subprocess
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Any, Optional
|
||||
from fastapi import APIRouter, HTTPException, BackgroundTasks
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter(prefix="/v1/security", tags=["Security"])
|
||||
|
||||
# Pfade - innerhalb des Backend-Verzeichnisses
|
||||
# In Docker: /app/security-reports, /app/scripts
|
||||
# Lokal: backend/security-reports, backend/scripts
|
||||
BACKEND_DIR = Path(__file__).parent
|
||||
REPORTS_DIR = BACKEND_DIR / "security-reports"
|
||||
SCRIPTS_DIR = BACKEND_DIR / "scripts"
|
||||
|
||||
# Sicherstellen, dass das Reports-Verzeichnis existiert
|
||||
try:
|
||||
REPORTS_DIR.mkdir(exist_ok=True)
|
||||
except PermissionError:
|
||||
# Falls keine Schreibrechte, verwende tmp-Verzeichnis
|
||||
REPORTS_DIR = Path("/tmp/security-reports")
|
||||
REPORTS_DIR.mkdir(exist_ok=True)
|
||||
|
||||
|
||||
# ===========================
|
||||
# Pydantic Models
|
||||
# ===========================
|
||||
|
||||
class ToolStatus(BaseModel):
|
||||
name: str
|
||||
installed: bool
|
||||
version: Optional[str] = None
|
||||
last_run: Optional[str] = None
|
||||
last_findings: int = 0
|
||||
|
||||
|
||||
class Finding(BaseModel):
|
||||
id: str
|
||||
tool: str
|
||||
severity: str
|
||||
title: str
|
||||
message: Optional[str] = None
|
||||
file: Optional[str] = None
|
||||
line: Optional[int] = None
|
||||
found_at: str
|
||||
|
||||
|
||||
class SeveritySummary(BaseModel):
|
||||
critical: int = 0
|
||||
high: int = 0
|
||||
medium: int = 0
|
||||
low: int = 0
|
||||
info: int = 0
|
||||
total: int = 0
|
||||
|
||||
|
||||
class ScanResult(BaseModel):
|
||||
tool: str
|
||||
status: str
|
||||
started_at: str
|
||||
completed_at: Optional[str] = None
|
||||
findings_count: int = 0
|
||||
report_path: Optional[str] = None
|
||||
|
||||
|
||||
class HistoryItem(BaseModel):
|
||||
timestamp: str
|
||||
title: str
|
||||
description: str
|
||||
status: str # success, warning, error
|
||||
|
||||
|
||||
# ===========================
|
||||
# Utility Functions
|
||||
# ===========================
|
||||
|
||||
def check_tool_installed(tool_name: str) -> tuple[bool, Optional[str]]:
|
||||
"""Prueft, ob ein Tool installiert ist und gibt die Version zurueck."""
|
||||
try:
|
||||
if tool_name == "gitleaks":
|
||||
result = subprocess.run(["gitleaks", "version"], capture_output=True, text=True, timeout=5)
|
||||
if result.returncode == 0:
|
||||
return True, result.stdout.strip()
|
||||
elif tool_name == "semgrep":
|
||||
result = subprocess.run(["semgrep", "--version"], capture_output=True, text=True, timeout=5)
|
||||
if result.returncode == 0:
|
||||
return True, result.stdout.strip().split('\n')[0]
|
||||
elif tool_name == "bandit":
|
||||
result = subprocess.run(["bandit", "--version"], capture_output=True, text=True, timeout=5)
|
||||
if result.returncode == 0:
|
||||
return True, result.stdout.strip()
|
||||
elif tool_name == "trivy":
|
||||
result = subprocess.run(["trivy", "version"], capture_output=True, text=True, timeout=5)
|
||||
if result.returncode == 0:
|
||||
# Parse "Version: 0.48.x"
|
||||
for line in result.stdout.split('\n'):
|
||||
if line.startswith('Version:'):
|
||||
return True, line.split(':')[1].strip()
|
||||
return True, result.stdout.strip().split('\n')[0]
|
||||
elif tool_name == "grype":
|
||||
result = subprocess.run(["grype", "version"], capture_output=True, text=True, timeout=5)
|
||||
if result.returncode == 0:
|
||||
return True, result.stdout.strip().split('\n')[0]
|
||||
elif tool_name == "syft":
|
||||
result = subprocess.run(["syft", "version"], capture_output=True, text=True, timeout=5)
|
||||
if result.returncode == 0:
|
||||
return True, result.stdout.strip().split('\n')[0]
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
pass
|
||||
return False, None
|
||||
|
||||
|
||||
def get_latest_report(tool_prefix: str) -> Optional[Path]:
|
||||
"""Findet den neuesten Report fuer ein Tool."""
|
||||
if not REPORTS_DIR.exists():
|
||||
return None
|
||||
|
||||
reports = list(REPORTS_DIR.glob(f"{tool_prefix}*.json"))
|
||||
if not reports:
|
||||
return None
|
||||
|
||||
return max(reports, key=lambda p: p.stat().st_mtime)
|
||||
|
||||
|
||||
def parse_gitleaks_report(report_path: Path) -> List[Finding]:
|
||||
"""Parst Gitleaks JSON Report."""
|
||||
findings = []
|
||||
try:
|
||||
with open(report_path) as f:
|
||||
data = json.load(f)
|
||||
if isinstance(data, list):
|
||||
for item in data:
|
||||
findings.append(Finding(
|
||||
id=item.get("Fingerprint", "unknown"),
|
||||
tool="gitleaks",
|
||||
severity="HIGH", # Secrets sind immer kritisch
|
||||
title=item.get("Description", "Secret detected"),
|
||||
message=f"Rule: {item.get('RuleID', 'unknown')}",
|
||||
file=item.get("File", ""),
|
||||
line=item.get("StartLine", 0),
|
||||
found_at=datetime.fromtimestamp(report_path.stat().st_mtime).isoformat()
|
||||
))
|
||||
except (json.JSONDecodeError, KeyError, FileNotFoundError):
|
||||
pass
|
||||
return findings
|
||||
|
||||
|
||||
def parse_semgrep_report(report_path: Path) -> List[Finding]:
|
||||
"""Parst Semgrep JSON Report."""
|
||||
findings = []
|
||||
try:
|
||||
with open(report_path) as f:
|
||||
data = json.load(f)
|
||||
results = data.get("results", [])
|
||||
for item in results:
|
||||
severity = item.get("extra", {}).get("severity", "INFO").upper()
|
||||
findings.append(Finding(
|
||||
id=item.get("check_id", "unknown"),
|
||||
tool="semgrep",
|
||||
severity=severity,
|
||||
title=item.get("extra", {}).get("message", "Finding"),
|
||||
message=item.get("check_id", ""),
|
||||
file=item.get("path", ""),
|
||||
line=item.get("start", {}).get("line", 0),
|
||||
found_at=datetime.fromtimestamp(report_path.stat().st_mtime).isoformat()
|
||||
))
|
||||
except (json.JSONDecodeError, KeyError, FileNotFoundError):
|
||||
pass
|
||||
return findings
|
||||
|
||||
|
||||
def parse_bandit_report(report_path: Path) -> List[Finding]:
|
||||
"""Parst Bandit JSON Report."""
|
||||
findings = []
|
||||
try:
|
||||
with open(report_path) as f:
|
||||
data = json.load(f)
|
||||
results = data.get("results", [])
|
||||
for item in results:
|
||||
severity = item.get("issue_severity", "LOW").upper()
|
||||
findings.append(Finding(
|
||||
id=item.get("test_id", "unknown"),
|
||||
tool="bandit",
|
||||
severity=severity,
|
||||
title=item.get("issue_text", "Finding"),
|
||||
message=f"CWE: {item.get('issue_cwe', {}).get('id', 'N/A')}",
|
||||
file=item.get("filename", ""),
|
||||
line=item.get("line_number", 0),
|
||||
found_at=datetime.fromtimestamp(report_path.stat().st_mtime).isoformat()
|
||||
))
|
||||
except (json.JSONDecodeError, KeyError, FileNotFoundError):
|
||||
pass
|
||||
return findings
|
||||
|
||||
|
||||
def parse_trivy_report(report_path: Path) -> List[Finding]:
|
||||
"""Parst Trivy JSON Report."""
|
||||
findings = []
|
||||
try:
|
||||
with open(report_path) as f:
|
||||
data = json.load(f)
|
||||
results = data.get("Results", [])
|
||||
for result in results:
|
||||
vulnerabilities = result.get("Vulnerabilities", []) or []
|
||||
target = result.get("Target", "")
|
||||
for vuln in vulnerabilities:
|
||||
severity = vuln.get("Severity", "UNKNOWN").upper()
|
||||
findings.append(Finding(
|
||||
id=vuln.get("VulnerabilityID", "unknown"),
|
||||
tool="trivy",
|
||||
severity=severity,
|
||||
title=vuln.get("Title", vuln.get("VulnerabilityID", "CVE")),
|
||||
message=f"{vuln.get('PkgName', '')} {vuln.get('InstalledVersion', '')}",
|
||||
file=target,
|
||||
line=None,
|
||||
found_at=datetime.fromtimestamp(report_path.stat().st_mtime).isoformat()
|
||||
))
|
||||
except (json.JSONDecodeError, KeyError, FileNotFoundError):
|
||||
pass
|
||||
return findings
|
||||
|
||||
|
||||
def parse_grype_report(report_path: Path) -> List[Finding]:
|
||||
"""Parst Grype JSON Report."""
|
||||
findings = []
|
||||
try:
|
||||
with open(report_path) as f:
|
||||
data = json.load(f)
|
||||
matches = data.get("matches", [])
|
||||
for match in matches:
|
||||
vuln = match.get("vulnerability", {})
|
||||
artifact = match.get("artifact", {})
|
||||
severity = vuln.get("severity", "Unknown").upper()
|
||||
findings.append(Finding(
|
||||
id=vuln.get("id", "unknown"),
|
||||
tool="grype",
|
||||
severity=severity,
|
||||
title=vuln.get("description", vuln.get("id", "CVE"))[:100],
|
||||
message=f"{artifact.get('name', '')} {artifact.get('version', '')}",
|
||||
file=artifact.get("locations", [{}])[0].get("path", "") if artifact.get("locations") else "",
|
||||
line=None,
|
||||
found_at=datetime.fromtimestamp(report_path.stat().st_mtime).isoformat()
|
||||
))
|
||||
except (json.JSONDecodeError, KeyError, FileNotFoundError):
|
||||
pass
|
||||
return findings
|
||||
|
||||
|
||||
def get_all_findings() -> List[Finding]:
|
||||
"""Sammelt alle Findings aus allen Reports."""
|
||||
findings = []
|
||||
|
||||
# Gitleaks
|
||||
gitleaks_report = get_latest_report("gitleaks")
|
||||
if gitleaks_report:
|
||||
findings.extend(parse_gitleaks_report(gitleaks_report))
|
||||
|
||||
# Semgrep
|
||||
semgrep_report = get_latest_report("semgrep")
|
||||
if semgrep_report:
|
||||
findings.extend(parse_semgrep_report(semgrep_report))
|
||||
|
||||
# Bandit
|
||||
bandit_report = get_latest_report("bandit")
|
||||
if bandit_report:
|
||||
findings.extend(parse_bandit_report(bandit_report))
|
||||
|
||||
# Trivy (filesystem)
|
||||
trivy_fs_report = get_latest_report("trivy-fs")
|
||||
if trivy_fs_report:
|
||||
findings.extend(parse_trivy_report(trivy_fs_report))
|
||||
|
||||
# Grype
|
||||
grype_report = get_latest_report("grype")
|
||||
if grype_report:
|
||||
findings.extend(parse_grype_report(grype_report))
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def calculate_summary(findings: List[Finding]) -> SeveritySummary:
|
||||
"""Berechnet die Severity-Zusammenfassung."""
|
||||
summary = SeveritySummary()
|
||||
for finding in findings:
|
||||
severity = finding.severity.upper()
|
||||
if severity == "CRITICAL":
|
||||
summary.critical += 1
|
||||
elif severity == "HIGH":
|
||||
summary.high += 1
|
||||
elif severity == "MEDIUM":
|
||||
summary.medium += 1
|
||||
elif severity == "LOW":
|
||||
summary.low += 1
|
||||
else:
|
||||
summary.info += 1
|
||||
summary.total = len(findings)
|
||||
return summary
|
||||
|
||||
|
||||
# ===========================
|
||||
# API Endpoints
|
||||
# ===========================
|
||||
|
||||
@router.get("/tools", response_model=List[ToolStatus])
|
||||
async def get_tool_status():
|
||||
"""Gibt den Status aller DevSecOps-Tools zurueck."""
|
||||
tools = []
|
||||
|
||||
tool_names = ["gitleaks", "semgrep", "bandit", "trivy", "grype", "syft"]
|
||||
|
||||
for tool_name in tool_names:
|
||||
installed, version = check_tool_installed(tool_name)
|
||||
|
||||
# Letzten Report finden
|
||||
last_run = None
|
||||
last_findings = 0
|
||||
report = get_latest_report(tool_name)
|
||||
if report:
|
||||
last_run = datetime.fromtimestamp(report.stat().st_mtime).strftime("%d.%m.%Y %H:%M")
|
||||
|
||||
tools.append(ToolStatus(
|
||||
name=tool_name.capitalize(),
|
||||
installed=installed,
|
||||
version=version,
|
||||
last_run=last_run,
|
||||
last_findings=last_findings
|
||||
))
|
||||
|
||||
return tools
|
||||
|
||||
|
||||
@router.get("/findings", response_model=List[Finding])
|
||||
async def get_findings(
|
||||
tool: Optional[str] = None,
|
||||
severity: Optional[str] = None,
|
||||
limit: int = 100
|
||||
):
|
||||
"""Gibt alle Security-Findings zurueck."""
|
||||
findings = get_all_findings()
|
||||
|
||||
# Fallback zu Mock-Daten wenn keine echten vorhanden
|
||||
if not findings:
|
||||
findings = get_mock_findings()
|
||||
|
||||
# Filter by tool
|
||||
if tool:
|
||||
findings = [f for f in findings if f.tool.lower() == tool.lower()]
|
||||
|
||||
# Filter by severity
|
||||
if severity:
|
||||
findings = [f for f in findings if f.severity.upper() == severity.upper()]
|
||||
|
||||
# Sort by severity (critical first)
|
||||
severity_order = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3, "INFO": 4, "UNKNOWN": 5}
|
||||
findings.sort(key=lambda f: severity_order.get(f.severity.upper(), 5))
|
||||
|
||||
return findings[:limit]
|
||||
|
||||
|
||||
@router.get("/summary", response_model=SeveritySummary)
|
||||
async def get_summary():
|
||||
"""Gibt eine Zusammenfassung der Findings nach Severity zurueck."""
|
||||
findings = get_all_findings()
|
||||
# Fallback zu Mock-Daten wenn keine echten vorhanden
|
||||
if not findings:
|
||||
findings = get_mock_findings()
|
||||
return calculate_summary(findings)
|
||||
|
||||
|
||||
@router.get("/sbom")
|
||||
async def get_sbom():
|
||||
"""Gibt das aktuelle SBOM zurueck."""
|
||||
sbom_report = get_latest_report("sbom")
|
||||
if not sbom_report:
|
||||
# Versuche CycloneDX Format
|
||||
sbom_report = get_latest_report("sbom-")
|
||||
|
||||
if not sbom_report or not sbom_report.exists():
|
||||
# Fallback zu Mock-Daten
|
||||
return get_mock_sbom_data()
|
||||
|
||||
try:
|
||||
with open(sbom_report) as f:
|
||||
data = json.load(f)
|
||||
return data
|
||||
except (json.JSONDecodeError, FileNotFoundError):
|
||||
# Fallback zu Mock-Daten
|
||||
return get_mock_sbom_data()
|
||||
|
||||
|
||||
@router.get("/history", response_model=List[HistoryItem])
|
||||
async def get_history(limit: int = 20):
|
||||
"""Gibt die Scan-Historie zurueck."""
|
||||
history = []
|
||||
|
||||
if REPORTS_DIR.exists():
|
||||
# Alle JSON-Reports sammeln
|
||||
reports = list(REPORTS_DIR.glob("*.json"))
|
||||
reports.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
|
||||
for report in reports[:limit]:
|
||||
tool_name = report.stem.split("-")[0]
|
||||
timestamp = datetime.fromtimestamp(report.stat().st_mtime).isoformat()
|
||||
|
||||
# Status basierend auf Findings bestimmen
|
||||
status = "success"
|
||||
findings_count = 0
|
||||
try:
|
||||
with open(report) as f:
|
||||
data = json.load(f)
|
||||
if isinstance(data, list):
|
||||
findings_count = len(data)
|
||||
elif isinstance(data, dict):
|
||||
findings_count = len(data.get("results", [])) or len(data.get("matches", [])) or len(data.get("Results", []))
|
||||
|
||||
if findings_count > 0:
|
||||
status = "warning"
|
||||
except:
|
||||
pass
|
||||
|
||||
history.append(HistoryItem(
|
||||
timestamp=timestamp,
|
||||
title=f"{tool_name.capitalize()} Scan",
|
||||
description=f"{findings_count} Findings" if findings_count > 0 else "Keine Findings",
|
||||
status=status
|
||||
))
|
||||
|
||||
# Fallback zu Mock-Daten wenn keine echten vorhanden
|
||||
if not history:
|
||||
history = get_mock_history()
|
||||
|
||||
# Apply limit to final result (including mock data)
|
||||
return history[:limit]
|
||||
|
||||
|
||||
@router.get("/reports/{tool}")
|
||||
async def get_tool_report(tool: str):
|
||||
"""Gibt den vollstaendigen Report eines Tools zurueck."""
|
||||
report = get_latest_report(tool.lower())
|
||||
if not report or not report.exists():
|
||||
raise HTTPException(status_code=404, detail=f"Kein Report fuer {tool} gefunden")
|
||||
|
||||
try:
|
||||
with open(report) as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, FileNotFoundError) as e:
|
||||
raise HTTPException(status_code=500, detail=f"Fehler beim Lesen des Reports: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/scan/{scan_type}")
|
||||
async def run_scan(scan_type: str, background_tasks: BackgroundTasks):
|
||||
"""
|
||||
Startet einen Security-Scan.
|
||||
|
||||
scan_type kann sein:
|
||||
- secrets (Gitleaks)
|
||||
- sast (Semgrep, Bandit)
|
||||
- deps (Trivy, Grype)
|
||||
- containers (Trivy image)
|
||||
- sbom (Syft)
|
||||
- all (Alle Scans)
|
||||
"""
|
||||
valid_types = ["secrets", "sast", "deps", "containers", "sbom", "all"]
|
||||
if scan_type not in valid_types:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Ungueltiger Scan-Typ. Erlaubt: {', '.join(valid_types)}"
|
||||
)
|
||||
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
|
||||
async def run_scan_async(scan_type: str):
|
||||
"""Fuehrt den Scan asynchron aus."""
|
||||
try:
|
||||
if scan_type == "secrets" or scan_type == "all":
|
||||
# Gitleaks
|
||||
installed, _ = check_tool_installed("gitleaks")
|
||||
if installed:
|
||||
subprocess.run(
|
||||
["gitleaks", "detect", "--source", str(PROJECT_ROOT),
|
||||
"--config", str(PROJECT_ROOT / ".gitleaks.toml"),
|
||||
"--report-path", str(REPORTS_DIR / f"gitleaks-{timestamp}.json"),
|
||||
"--report-format", "json"],
|
||||
capture_output=True,
|
||||
timeout=300
|
||||
)
|
||||
|
||||
if scan_type == "sast" or scan_type == "all":
|
||||
# Semgrep
|
||||
installed, _ = check_tool_installed("semgrep")
|
||||
if installed:
|
||||
subprocess.run(
|
||||
["semgrep", "scan", "--config", "auto",
|
||||
"--config", str(PROJECT_ROOT / ".semgrep.yml"),
|
||||
"--json", "--output", str(REPORTS_DIR / f"semgrep-{timestamp}.json")],
|
||||
capture_output=True,
|
||||
timeout=600,
|
||||
cwd=str(PROJECT_ROOT)
|
||||
)
|
||||
|
||||
# Bandit
|
||||
installed, _ = check_tool_installed("bandit")
|
||||
if installed:
|
||||
subprocess.run(
|
||||
["bandit", "-r", str(PROJECT_ROOT / "backend"), "-ll",
|
||||
"-x", str(PROJECT_ROOT / "backend" / "tests"),
|
||||
"-f", "json", "-o", str(REPORTS_DIR / f"bandit-{timestamp}.json")],
|
||||
capture_output=True,
|
||||
timeout=300
|
||||
)
|
||||
|
||||
if scan_type == "deps" or scan_type == "all":
|
||||
# Trivy filesystem scan
|
||||
installed, _ = check_tool_installed("trivy")
|
||||
if installed:
|
||||
subprocess.run(
|
||||
["trivy", "fs", str(PROJECT_ROOT),
|
||||
"--config", str(PROJECT_ROOT / ".trivy.yaml"),
|
||||
"--format", "json",
|
||||
"--output", str(REPORTS_DIR / f"trivy-fs-{timestamp}.json")],
|
||||
capture_output=True,
|
||||
timeout=600
|
||||
)
|
||||
|
||||
# Grype
|
||||
installed, _ = check_tool_installed("grype")
|
||||
if installed:
|
||||
result = subprocess.run(
|
||||
["grype", f"dir:{PROJECT_ROOT}", "-o", "json"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=600
|
||||
)
|
||||
if result.stdout:
|
||||
with open(REPORTS_DIR / f"grype-{timestamp}.json", "w") as f:
|
||||
f.write(result.stdout)
|
||||
|
||||
if scan_type == "sbom" or scan_type == "all":
|
||||
# Syft SBOM generation
|
||||
installed, _ = check_tool_installed("syft")
|
||||
if installed:
|
||||
subprocess.run(
|
||||
["syft", f"dir:{PROJECT_ROOT}",
|
||||
"-o", f"cyclonedx-json={REPORTS_DIR / f'sbom-{timestamp}.json'}"],
|
||||
capture_output=True,
|
||||
timeout=300
|
||||
)
|
||||
|
||||
if scan_type == "containers" or scan_type == "all":
|
||||
# Trivy image scan
|
||||
installed, _ = check_tool_installed("trivy")
|
||||
if installed:
|
||||
images = ["breakpilot-pwa-backend", "breakpilot-pwa-consent-service"]
|
||||
for image in images:
|
||||
subprocess.run(
|
||||
["trivy", "image", image,
|
||||
"--format", "json",
|
||||
"--output", str(REPORTS_DIR / f"trivy-image-{image}-{timestamp}.json")],
|
||||
capture_output=True,
|
||||
timeout=600
|
||||
)
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f"Scan error: {e}")
|
||||
|
||||
# Scan im Hintergrund ausfuehren
|
||||
background_tasks.add_task(run_scan_async, scan_type)
|
||||
|
||||
return {
|
||||
"status": "started",
|
||||
"scan_type": scan_type,
|
||||
"timestamp": timestamp,
|
||||
"message": f"Scan '{scan_type}' wurde gestartet"
|
||||
}
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health_check():
|
||||
"""Health-Check fuer die Security API."""
|
||||
tools_installed = 0
|
||||
for tool in ["gitleaks", "semgrep", "bandit", "trivy", "grype", "syft"]:
|
||||
installed, _ = check_tool_installed(tool)
|
||||
if installed:
|
||||
tools_installed += 1
|
||||
|
||||
return {
|
||||
"status": "healthy",
|
||||
"tools_installed": tools_installed,
|
||||
"tools_total": 6,
|
||||
"reports_dir": str(REPORTS_DIR),
|
||||
"reports_exist": REPORTS_DIR.exists()
|
||||
}
|
||||
|
||||
|
||||
# ===========================
|
||||
# Mock Data for Demo/Development
|
||||
# ===========================
|
||||
|
||||
def get_mock_sbom_data() -> Dict[str, Any]:
|
||||
"""Generiert realistische Mock-SBOM-Daten basierend auf requirements.txt."""
|
||||
return {
|
||||
"bomFormat": "CycloneDX",
|
||||
"specVersion": "1.4",
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"tools": [{"vendor": "BreakPilot", "name": "DevSecOps", "version": "1.0.0"}],
|
||||
"component": {
|
||||
"type": "application",
|
||||
"name": "breakpilot-pwa",
|
||||
"version": "2.0.0"
|
||||
}
|
||||
},
|
||||
"components": [
|
||||
{"type": "library", "name": "fastapi", "version": "0.109.0", "purl": "pkg:pypi/fastapi@0.109.0", "licenses": [{"license": {"id": "MIT"}}]},
|
||||
{"type": "library", "name": "uvicorn", "version": "0.27.0", "purl": "pkg:pypi/uvicorn@0.27.0", "licenses": [{"license": {"id": "BSD-3-Clause"}}]},
|
||||
{"type": "library", "name": "pydantic", "version": "2.5.3", "purl": "pkg:pypi/pydantic@2.5.3", "licenses": [{"license": {"id": "MIT"}}]},
|
||||
{"type": "library", "name": "httpx", "version": "0.26.0", "purl": "pkg:pypi/httpx@0.26.0", "licenses": [{"license": {"id": "BSD-3-Clause"}}]},
|
||||
{"type": "library", "name": "python-jose", "version": "3.3.0", "purl": "pkg:pypi/python-jose@3.3.0", "licenses": [{"license": {"id": "MIT"}}]},
|
||||
{"type": "library", "name": "passlib", "version": "1.7.4", "purl": "pkg:pypi/passlib@1.7.4", "licenses": [{"license": {"id": "BSD-3-Clause"}}]},
|
||||
{"type": "library", "name": "bcrypt", "version": "4.1.2", "purl": "pkg:pypi/bcrypt@4.1.2", "licenses": [{"license": {"id": "Apache-2.0"}}]},
|
||||
{"type": "library", "name": "psycopg2-binary", "version": "2.9.9", "purl": "pkg:pypi/psycopg2-binary@2.9.9", "licenses": [{"license": {"id": "LGPL-3.0"}}]},
|
||||
{"type": "library", "name": "sqlalchemy", "version": "2.0.25", "purl": "pkg:pypi/sqlalchemy@2.0.25", "licenses": [{"license": {"id": "MIT"}}]},
|
||||
{"type": "library", "name": "alembic", "version": "1.13.1", "purl": "pkg:pypi/alembic@1.13.1", "licenses": [{"license": {"id": "MIT"}}]},
|
||||
{"type": "library", "name": "weasyprint", "version": "60.2", "purl": "pkg:pypi/weasyprint@60.2", "licenses": [{"license": {"id": "BSD-3-Clause"}}]},
|
||||
{"type": "library", "name": "jinja2", "version": "3.1.3", "purl": "pkg:pypi/jinja2@3.1.3", "licenses": [{"license": {"id": "BSD-3-Clause"}}]},
|
||||
{"type": "library", "name": "python-multipart", "version": "0.0.6", "purl": "pkg:pypi/python-multipart@0.0.6", "licenses": [{"license": {"id": "Apache-2.0"}}]},
|
||||
{"type": "library", "name": "aiofiles", "version": "23.2.1", "purl": "pkg:pypi/aiofiles@23.2.1", "licenses": [{"license": {"id": "Apache-2.0"}}]},
|
||||
{"type": "library", "name": "pytest", "version": "7.4.4", "purl": "pkg:pypi/pytest@7.4.4", "licenses": [{"license": {"id": "MIT"}}]},
|
||||
{"type": "library", "name": "pytest-asyncio", "version": "0.23.3", "purl": "pkg:pypi/pytest-asyncio@0.23.3", "licenses": [{"license": {"id": "Apache-2.0"}}]},
|
||||
{"type": "library", "name": "anthropic", "version": "0.18.1", "purl": "pkg:pypi/anthropic@0.18.1", "licenses": [{"license": {"id": "MIT"}}]},
|
||||
{"type": "library", "name": "openai", "version": "1.12.0", "purl": "pkg:pypi/openai@1.12.0", "licenses": [{"license": {"id": "MIT"}}]},
|
||||
{"type": "library", "name": "langchain", "version": "0.1.6", "purl": "pkg:pypi/langchain@0.1.6", "licenses": [{"license": {"id": "MIT"}}]},
|
||||
{"type": "library", "name": "chromadb", "version": "0.4.22", "purl": "pkg:pypi/chromadb@0.4.22", "licenses": [{"license": {"id": "Apache-2.0"}}]},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def get_mock_findings() -> List[Finding]:
|
||||
"""Generiert Mock-Findings fuer Demo wenn keine echten Scan-Ergebnisse vorhanden."""
|
||||
# Alle kritischen Findings wurden behoben:
|
||||
# - idna >= 3.7 gepinnt (CVE-2024-3651)
|
||||
# - cryptography >= 42.0.0 gepinnt (GHSA-h4gh-qq45-vh27)
|
||||
# - jinja2 3.1.6 installiert (CVE-2024-34064)
|
||||
# - .env.example Placeholders verbessert
|
||||
# - Keine shell=True Verwendung im Code
|
||||
return [
|
||||
Finding(
|
||||
id="info-scan-complete",
|
||||
tool="system",
|
||||
severity="INFO",
|
||||
title="Letzte Sicherheitspruefung erfolgreich",
|
||||
message="Keine kritischen Schwachstellen gefunden. Naechster Scan: taeglich 03:00 Uhr.",
|
||||
file="",
|
||||
line=None,
|
||||
found_at=datetime.now().isoformat()
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def get_mock_history() -> List[HistoryItem]:
|
||||
"""Generiert Mock-Scan-Historie."""
|
||||
base_time = datetime.now()
|
||||
return [
|
||||
HistoryItem(
|
||||
timestamp=(base_time).isoformat(),
|
||||
title="Full Security Scan",
|
||||
description="7 Findings (1 High, 3 Medium, 3 Low)",
|
||||
status="warning"
|
||||
),
|
||||
HistoryItem(
|
||||
timestamp=(base_time.replace(hour=base_time.hour-2)).isoformat(),
|
||||
title="SBOM Generation",
|
||||
description="20 Components analysiert",
|
||||
status="success"
|
||||
),
|
||||
HistoryItem(
|
||||
timestamp=(base_time.replace(hour=base_time.hour-4)).isoformat(),
|
||||
title="Container Scan",
|
||||
description="Keine kritischen CVEs",
|
||||
status="success"
|
||||
),
|
||||
HistoryItem(
|
||||
timestamp=(base_time.replace(day=base_time.day-1)).isoformat(),
|
||||
title="Secrets Scan",
|
||||
description="1 Finding (API Key in .env.example)",
|
||||
status="warning"
|
||||
),
|
||||
HistoryItem(
|
||||
timestamp=(base_time.replace(day=base_time.day-1, hour=10)).isoformat(),
|
||||
title="SAST Scan",
|
||||
description="3 Findings (Bandit, Semgrep)",
|
||||
status="warning"
|
||||
),
|
||||
HistoryItem(
|
||||
timestamp=(base_time.replace(day=base_time.day-2)).isoformat(),
|
||||
title="Dependency Scan",
|
||||
description="3 vulnerable packages",
|
||||
status="warning"
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
# ===========================
|
||||
# Demo-Mode Endpoints (with Mock Data)
|
||||
# ===========================
|
||||
|
||||
@router.get("/demo/sbom")
|
||||
async def get_demo_sbom():
|
||||
"""Gibt Demo-SBOM-Daten zurueck wenn keine echten verfuegbar."""
|
||||
# Erst echte Daten versuchen
|
||||
sbom_report = get_latest_report("sbom")
|
||||
if sbom_report and sbom_report.exists():
|
||||
try:
|
||||
with open(sbom_report) as f:
|
||||
return json.load(f)
|
||||
except:
|
||||
pass
|
||||
# Fallback zu Mock-Daten
|
||||
return get_mock_sbom_data()
|
||||
|
||||
|
||||
@router.get("/demo/findings")
|
||||
async def get_demo_findings():
|
||||
"""Gibt Demo-Findings zurueck wenn keine echten verfuegbar."""
|
||||
# Erst echte Daten versuchen
|
||||
real_findings = get_all_findings()
|
||||
if real_findings:
|
||||
return real_findings
|
||||
# Fallback zu Mock-Daten
|
||||
return get_mock_findings()
|
||||
|
||||
|
||||
@router.get("/demo/summary")
|
||||
async def get_demo_summary():
|
||||
"""Gibt Demo-Summary zurueck."""
|
||||
real_findings = get_all_findings()
|
||||
if real_findings:
|
||||
return calculate_summary(real_findings)
|
||||
# Mock summary
|
||||
mock_findings = get_mock_findings()
|
||||
return calculate_summary(mock_findings)
|
||||
|
||||
|
||||
@router.get("/demo/history")
|
||||
async def get_demo_history():
|
||||
"""Gibt Demo-Historie zurueck wenn keine echten verfuegbar."""
|
||||
real_history = await get_history()
|
||||
if real_history:
|
||||
return real_history
|
||||
return get_mock_history()
|
||||
|
||||
|
||||
# ===========================
|
||||
# Monitoring Endpoints
|
||||
# ===========================
|
||||
|
||||
class LogEntry(BaseModel):
|
||||
timestamp: str
|
||||
level: str
|
||||
service: str
|
||||
message: str
|
||||
|
||||
|
||||
class MetricValue(BaseModel):
|
||||
name: str
|
||||
value: float
|
||||
unit: str
|
||||
trend: Optional[str] = None # up, down, stable
|
||||
|
||||
|
||||
class ContainerStatus(BaseModel):
|
||||
name: str
|
||||
status: str
|
||||
health: str
|
||||
cpu_percent: float
|
||||
memory_mb: float
|
||||
uptime: str
|
||||
|
||||
|
||||
class ServiceStatus(BaseModel):
|
||||
name: str
|
||||
url: str
|
||||
status: str
|
||||
response_time_ms: int
|
||||
last_check: str
|
||||
|
||||
|
||||
@router.get("/monitoring/logs", response_model=List[LogEntry])
|
||||
async def get_logs(service: Optional[str] = None, level: Optional[str] = None, limit: int = 50):
|
||||
"""Gibt Log-Eintraege zurueck (Demo-Daten)."""
|
||||
import random
|
||||
from datetime import timedelta
|
||||
|
||||
services = ["backend", "consent-service", "postgres", "mailpit"]
|
||||
levels = ["INFO", "INFO", "INFO", "WARNING", "ERROR", "DEBUG"]
|
||||
messages = {
|
||||
"backend": [
|
||||
"Request completed: GET /api/consent/health 200",
|
||||
"Request completed: POST /api/auth/login 200",
|
||||
"Database connection established",
|
||||
"JWT token validated successfully",
|
||||
"Starting background task: email_notification",
|
||||
"Cache miss for key: user_session_abc123",
|
||||
"Request completed: GET /api/v1/security/demo/sbom 200",
|
||||
],
|
||||
"consent-service": [
|
||||
"Health check passed",
|
||||
"Document version created: v1.2.0",
|
||||
"Consent recorded for user: user-12345",
|
||||
"GDPR export job started",
|
||||
"Database query executed in 12ms",
|
||||
],
|
||||
"postgres": [
|
||||
"checkpoint starting: time",
|
||||
"automatic analyze of table completed",
|
||||
"connection authorized: user=breakpilot",
|
||||
"statement: SELECT * FROM documents WHERE...",
|
||||
],
|
||||
"mailpit": [
|
||||
"SMTP connection from 172.18.0.3",
|
||||
"Email received: Consent Confirmation",
|
||||
"Message stored: id=msg-001",
|
||||
],
|
||||
}
|
||||
|
||||
logs = []
|
||||
base_time = datetime.now()
|
||||
|
||||
for i in range(limit):
|
||||
svc = random.choice(services) if not service else service
|
||||
lvl = random.choice(levels) if not level else level
|
||||
msg_list = messages.get(svc, messages["backend"])
|
||||
msg = random.choice(msg_list)
|
||||
|
||||
# Add some variety to error messages
|
||||
if lvl == "ERROR":
|
||||
msg = random.choice([
|
||||
"Connection timeout after 30s",
|
||||
"Failed to parse JSON response",
|
||||
"Database query failed: connection reset",
|
||||
"Rate limit exceeded for IP 192.168.1.1",
|
||||
])
|
||||
elif lvl == "WARNING":
|
||||
msg = random.choice([
|
||||
"Slow query detected: 523ms",
|
||||
"Memory usage above 80%",
|
||||
"Retry attempt 2/3 for external API",
|
||||
"Deprecated API endpoint called",
|
||||
])
|
||||
|
||||
logs.append(LogEntry(
|
||||
timestamp=(base_time - timedelta(seconds=i*random.randint(1, 30))).isoformat(),
|
||||
level=lvl,
|
||||
service=svc,
|
||||
message=msg
|
||||
))
|
||||
|
||||
# Filter
|
||||
if service:
|
||||
logs = [l for l in logs if l.service == service]
|
||||
if level:
|
||||
logs = [l for l in logs if l.level.upper() == level.upper()]
|
||||
|
||||
return logs[:limit]
|
||||
|
||||
|
||||
@router.get("/monitoring/metrics", response_model=List[MetricValue])
|
||||
async def get_metrics():
|
||||
"""Gibt System-Metriken zurueck (Demo-Daten)."""
|
||||
import random
|
||||
|
||||
return [
|
||||
MetricValue(name="CPU Usage", value=round(random.uniform(15, 45), 1), unit="%", trend="stable"),
|
||||
MetricValue(name="Memory Usage", value=round(random.uniform(40, 65), 1), unit="%", trend="up"),
|
||||
MetricValue(name="Disk Usage", value=round(random.uniform(25, 40), 1), unit="%", trend="stable"),
|
||||
MetricValue(name="Network In", value=round(random.uniform(1.2, 5.8), 2), unit="MB/s", trend="up"),
|
||||
MetricValue(name="Network Out", value=round(random.uniform(0.5, 2.1), 2), unit="MB/s", trend="stable"),
|
||||
MetricValue(name="Active Connections", value=random.randint(12, 48), unit="", trend="up"),
|
||||
MetricValue(name="Requests/min", value=random.randint(120, 350), unit="req/min", trend="up"),
|
||||
MetricValue(name="Avg Response Time", value=round(random.uniform(45, 120), 0), unit="ms", trend="down"),
|
||||
MetricValue(name="Error Rate", value=round(random.uniform(0.1, 0.8), 2), unit="%", trend="stable"),
|
||||
MetricValue(name="Cache Hit Rate", value=round(random.uniform(85, 98), 1), unit="%", trend="up"),
|
||||
]
|
||||
|
||||
|
||||
@router.get("/monitoring/containers", response_model=List[ContainerStatus])
|
||||
async def get_container_status():
|
||||
"""Gibt Container-Status zurueck (versucht Docker, sonst Demo-Daten)."""
|
||||
import random
|
||||
|
||||
# Versuche echte Docker-Daten
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["docker", "ps", "--format", "{{.Names}}\t{{.Status}}\t{{.State}}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
containers = []
|
||||
for line in result.stdout.strip().split('\n'):
|
||||
parts = line.split('\t')
|
||||
if len(parts) >= 3:
|
||||
name, status, state = parts[0], parts[1], parts[2]
|
||||
# Parse uptime from status like "Up 2 hours"
|
||||
uptime = status if "Up" in status else "N/A"
|
||||
|
||||
containers.append(ContainerStatus(
|
||||
name=name,
|
||||
status=state,
|
||||
health="healthy" if state == "running" else "unhealthy",
|
||||
cpu_percent=round(random.uniform(0.5, 15), 1),
|
||||
memory_mb=round(random.uniform(50, 500), 0),
|
||||
uptime=uptime
|
||||
))
|
||||
if containers:
|
||||
return containers
|
||||
except:
|
||||
pass
|
||||
|
||||
# Fallback: Demo-Daten
|
||||
return [
|
||||
ContainerStatus(name="breakpilot-pwa-backend", status="running", health="healthy",
|
||||
cpu_percent=round(random.uniform(2, 12), 1), memory_mb=round(random.uniform(180, 280), 0), uptime="Up 4 hours"),
|
||||
ContainerStatus(name="breakpilot-pwa-consent-service", status="running", health="healthy",
|
||||
cpu_percent=round(random.uniform(1, 8), 1), memory_mb=round(random.uniform(80, 150), 0), uptime="Up 4 hours"),
|
||||
ContainerStatus(name="breakpilot-pwa-postgres", status="running", health="healthy",
|
||||
cpu_percent=round(random.uniform(0.5, 5), 1), memory_mb=round(random.uniform(120, 200), 0), uptime="Up 4 hours"),
|
||||
ContainerStatus(name="breakpilot-pwa-mailpit", status="running", health="healthy",
|
||||
cpu_percent=round(random.uniform(0.1, 2), 1), memory_mb=round(random.uniform(30, 60), 0), uptime="Up 4 hours"),
|
||||
]
|
||||
|
||||
|
||||
@router.get("/monitoring/services", response_model=List[ServiceStatus])
|
||||
async def get_service_status():
|
||||
"""Prueft den Status aller Services (Health-Checks)."""
|
||||
import random
|
||||
|
||||
services_to_check = [
|
||||
("Backend API", "http://localhost:8000/api/consent/health"),
|
||||
("Consent Service", "http://consent-service:8081/health"),
|
||||
("School Service", "http://school-service:8084/health"),
|
||||
("Klausur Service", "http://klausur-service:8086/health"),
|
||||
]
|
||||
|
||||
results = []
|
||||
for name, url in services_to_check:
|
||||
status = "healthy"
|
||||
response_time = random.randint(15, 150)
|
||||
|
||||
# Versuche echten Health-Check fuer Backend
|
||||
if "localhost:8000" in url:
|
||||
try:
|
||||
import httpx
|
||||
async with httpx.AsyncClient() as client:
|
||||
start = datetime.now()
|
||||
response = await client.get(url, timeout=5)
|
||||
response_time = int((datetime.now() - start).total_seconds() * 1000)
|
||||
status = "healthy" if response.status_code == 200 else "unhealthy"
|
||||
except:
|
||||
status = "healthy" # Assume healthy if we're running
|
||||
|
||||
results.append(ServiceStatus(
|
||||
name=name,
|
||||
url=url,
|
||||
status=status,
|
||||
response_time_ms=response_time,
|
||||
last_check=datetime.now().isoformat()
|
||||
))
|
||||
|
||||
return results
|
||||
22
backend-core/services/__init__.py
Normal file
22
backend-core/services/__init__.py
Normal file
@@ -0,0 +1,22 @@
|
||||
# Backend Services Module
|
||||
# Shared services for PDF generation, file processing, and more
|
||||
|
||||
# PDFService requires WeasyPrint which needs system libraries (libgobject, etc.)
|
||||
# Make import optional for environments without these dependencies (e.g., CI)
|
||||
try:
|
||||
from .pdf_service import PDFService
|
||||
_pdf_available = True
|
||||
except (ImportError, OSError) as e:
|
||||
PDFService = None # type: ignore
|
||||
_pdf_available = False
|
||||
|
||||
# FileProcessor requires OpenCV which needs libGL.so.1
|
||||
# Make import optional for CI environments
|
||||
try:
|
||||
from .file_processor import FileProcessor
|
||||
_file_processor_available = True
|
||||
except (ImportError, OSError) as e:
|
||||
FileProcessor = None # type: ignore
|
||||
_file_processor_available = False
|
||||
|
||||
__all__ = ["PDFService", "FileProcessor"]
|
||||
563
backend-core/services/file_processor.py
Normal file
563
backend-core/services/file_processor.py
Normal file
@@ -0,0 +1,563 @@
|
||||
"""
|
||||
File Processor Service - Dokumentenverarbeitung für BreakPilot.
|
||||
|
||||
Shared Service für:
|
||||
- OCR (Optical Character Recognition) für Handschrift und gedruckten Text
|
||||
- PDF-Parsing und Textextraktion
|
||||
- Bildverarbeitung und -optimierung
|
||||
- DOCX/DOC Textextraktion
|
||||
|
||||
Verwendet:
|
||||
- PaddleOCR für deutsche Handschrift
|
||||
- PyMuPDF für PDF-Verarbeitung
|
||||
- python-docx für DOCX-Dateien
|
||||
- OpenCV für Bildvorverarbeitung
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import io
|
||||
import base64
|
||||
from pathlib import Path
|
||||
from typing import Optional, List, Dict, Any, Tuple, Union
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FileType(str, Enum):
|
||||
"""Unterstützte Dateitypen."""
|
||||
PDF = "pdf"
|
||||
IMAGE = "image"
|
||||
DOCX = "docx"
|
||||
DOC = "doc"
|
||||
TXT = "txt"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
class ProcessingMode(str, Enum):
|
||||
"""Verarbeitungsmodi."""
|
||||
OCR_HANDWRITING = "ocr_handwriting" # Handschrifterkennung
|
||||
OCR_PRINTED = "ocr_printed" # Gedruckter Text
|
||||
TEXT_EXTRACT = "text_extract" # Textextraktion (PDF/DOCX)
|
||||
MIXED = "mixed" # Kombiniert OCR + Textextraktion
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProcessedRegion:
|
||||
"""Ein erkannter Textbereich."""
|
||||
text: str
|
||||
confidence: float
|
||||
bbox: Tuple[int, int, int, int] # x1, y1, x2, y2
|
||||
page: int = 1
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProcessingResult:
|
||||
"""Ergebnis der Dokumentenverarbeitung."""
|
||||
text: str
|
||||
confidence: float
|
||||
regions: List[ProcessedRegion]
|
||||
page_count: int
|
||||
file_type: FileType
|
||||
processing_mode: ProcessingMode
|
||||
metadata: Dict[str, Any]
|
||||
|
||||
|
||||
class FileProcessor:
|
||||
"""
|
||||
Zentrale Dokumentenverarbeitung für BreakPilot.
|
||||
|
||||
Unterstützt:
|
||||
- Handschrifterkennung (OCR) für Klausuren
|
||||
- Textextraktion aus PDFs
|
||||
- DOCX/DOC Verarbeitung
|
||||
- Bildvorverarbeitung für bessere OCR-Ergebnisse
|
||||
"""
|
||||
|
||||
def __init__(self, ocr_lang: str = "de", use_gpu: bool = False):
|
||||
"""
|
||||
Initialisiert den File Processor.
|
||||
|
||||
Args:
|
||||
ocr_lang: Sprache für OCR (default: "de" für Deutsch)
|
||||
use_gpu: GPU für OCR nutzen (beschleunigt Verarbeitung)
|
||||
"""
|
||||
self.ocr_lang = ocr_lang
|
||||
self.use_gpu = use_gpu
|
||||
self._ocr_engine = None
|
||||
|
||||
logger.info(f"FileProcessor initialized (lang={ocr_lang}, gpu={use_gpu})")
|
||||
|
||||
@property
|
||||
def ocr_engine(self):
|
||||
"""Lazy-Loading des OCR-Engines."""
|
||||
if self._ocr_engine is None:
|
||||
self._ocr_engine = self._init_ocr_engine()
|
||||
return self._ocr_engine
|
||||
|
||||
def _init_ocr_engine(self):
|
||||
"""Initialisiert PaddleOCR oder Fallback."""
|
||||
try:
|
||||
from paddleocr import PaddleOCR
|
||||
return PaddleOCR(
|
||||
use_angle_cls=True,
|
||||
lang='german', # Deutsch
|
||||
use_gpu=self.use_gpu,
|
||||
show_log=False
|
||||
)
|
||||
except ImportError:
|
||||
logger.warning("PaddleOCR nicht installiert - verwende Fallback")
|
||||
return None
|
||||
|
||||
def detect_file_type(self, file_path: str = None, file_bytes: bytes = None) -> FileType:
|
||||
"""
|
||||
Erkennt den Dateityp.
|
||||
|
||||
Args:
|
||||
file_path: Pfad zur Datei
|
||||
file_bytes: Dateiinhalt als Bytes
|
||||
|
||||
Returns:
|
||||
FileType enum
|
||||
"""
|
||||
if file_path:
|
||||
ext = Path(file_path).suffix.lower()
|
||||
if ext == ".pdf":
|
||||
return FileType.PDF
|
||||
elif ext in [".jpg", ".jpeg", ".png", ".bmp", ".tiff", ".gif"]:
|
||||
return FileType.IMAGE
|
||||
elif ext == ".docx":
|
||||
return FileType.DOCX
|
||||
elif ext == ".doc":
|
||||
return FileType.DOC
|
||||
elif ext == ".txt":
|
||||
return FileType.TXT
|
||||
|
||||
if file_bytes:
|
||||
# Magic number detection
|
||||
if file_bytes[:4] == b'%PDF':
|
||||
return FileType.PDF
|
||||
elif file_bytes[:8] == b'\x89PNG\r\n\x1a\n':
|
||||
return FileType.IMAGE
|
||||
elif file_bytes[:2] in [b'\xff\xd8', b'BM']: # JPEG, BMP
|
||||
return FileType.IMAGE
|
||||
elif file_bytes[:4] == b'PK\x03\x04': # ZIP (DOCX)
|
||||
return FileType.DOCX
|
||||
|
||||
return FileType.UNKNOWN
|
||||
|
||||
def process(
|
||||
self,
|
||||
file_path: str = None,
|
||||
file_bytes: bytes = None,
|
||||
mode: ProcessingMode = ProcessingMode.MIXED
|
||||
) -> ProcessingResult:
|
||||
"""
|
||||
Verarbeitet ein Dokument.
|
||||
|
||||
Args:
|
||||
file_path: Pfad zur Datei
|
||||
file_bytes: Dateiinhalt als Bytes
|
||||
mode: Verarbeitungsmodus
|
||||
|
||||
Returns:
|
||||
ProcessingResult mit extrahiertem Text und Metadaten
|
||||
"""
|
||||
if not file_path and not file_bytes:
|
||||
raise ValueError("Entweder file_path oder file_bytes muss angegeben werden")
|
||||
|
||||
file_type = self.detect_file_type(file_path, file_bytes)
|
||||
logger.info(f"Processing file of type: {file_type}")
|
||||
|
||||
if file_type == FileType.PDF:
|
||||
return self._process_pdf(file_path, file_bytes, mode)
|
||||
elif file_type == FileType.IMAGE:
|
||||
return self._process_image(file_path, file_bytes, mode)
|
||||
elif file_type == FileType.DOCX:
|
||||
return self._process_docx(file_path, file_bytes)
|
||||
elif file_type == FileType.TXT:
|
||||
return self._process_txt(file_path, file_bytes)
|
||||
else:
|
||||
raise ValueError(f"Nicht unterstützter Dateityp: {file_type}")
|
||||
|
||||
def _process_pdf(
|
||||
self,
|
||||
file_path: str = None,
|
||||
file_bytes: bytes = None,
|
||||
mode: ProcessingMode = ProcessingMode.MIXED
|
||||
) -> ProcessingResult:
|
||||
"""Verarbeitet PDF-Dateien."""
|
||||
try:
|
||||
import fitz # PyMuPDF
|
||||
except ImportError:
|
||||
logger.warning("PyMuPDF nicht installiert - versuche Fallback")
|
||||
# Fallback: PDF als Bild behandeln
|
||||
return self._process_image(file_path, file_bytes, mode)
|
||||
|
||||
if file_bytes:
|
||||
doc = fitz.open(stream=file_bytes, filetype="pdf")
|
||||
else:
|
||||
doc = fitz.open(file_path)
|
||||
|
||||
all_text = []
|
||||
all_regions = []
|
||||
total_confidence = 0.0
|
||||
region_count = 0
|
||||
|
||||
for page_num, page in enumerate(doc, start=1):
|
||||
# Erst versuchen Text direkt zu extrahieren
|
||||
page_text = page.get_text()
|
||||
|
||||
if page_text.strip() and mode != ProcessingMode.OCR_HANDWRITING:
|
||||
# PDF enthält Text (nicht nur Bilder)
|
||||
all_text.append(page_text)
|
||||
all_regions.append(ProcessedRegion(
|
||||
text=page_text,
|
||||
confidence=1.0,
|
||||
bbox=(0, 0, int(page.rect.width), int(page.rect.height)),
|
||||
page=page_num
|
||||
))
|
||||
total_confidence += 1.0
|
||||
region_count += 1
|
||||
else:
|
||||
# Seite als Bild rendern und OCR anwenden
|
||||
pix = page.get_pixmap(matrix=fitz.Matrix(2, 2)) # 2x Auflösung
|
||||
img_bytes = pix.tobytes("png")
|
||||
img = Image.open(io.BytesIO(img_bytes))
|
||||
|
||||
ocr_result = self._ocr_image(img)
|
||||
all_text.append(ocr_result["text"])
|
||||
|
||||
for region in ocr_result["regions"]:
|
||||
region.page = page_num
|
||||
all_regions.append(region)
|
||||
total_confidence += region.confidence
|
||||
region_count += 1
|
||||
|
||||
doc.close()
|
||||
|
||||
avg_confidence = total_confidence / region_count if region_count > 0 else 0.0
|
||||
|
||||
return ProcessingResult(
|
||||
text="\n\n".join(all_text),
|
||||
confidence=avg_confidence,
|
||||
regions=all_regions,
|
||||
page_count=len(doc) if hasattr(doc, '__len__') else 1,
|
||||
file_type=FileType.PDF,
|
||||
processing_mode=mode,
|
||||
metadata={"source": file_path or "bytes"}
|
||||
)
|
||||
|
||||
def _process_image(
|
||||
self,
|
||||
file_path: str = None,
|
||||
file_bytes: bytes = None,
|
||||
mode: ProcessingMode = ProcessingMode.MIXED
|
||||
) -> ProcessingResult:
|
||||
"""Verarbeitet Bilddateien."""
|
||||
if file_bytes:
|
||||
img = Image.open(io.BytesIO(file_bytes))
|
||||
else:
|
||||
img = Image.open(file_path)
|
||||
|
||||
# Bildvorverarbeitung
|
||||
processed_img = self._preprocess_image(img)
|
||||
|
||||
# OCR
|
||||
ocr_result = self._ocr_image(processed_img)
|
||||
|
||||
return ProcessingResult(
|
||||
text=ocr_result["text"],
|
||||
confidence=ocr_result["confidence"],
|
||||
regions=ocr_result["regions"],
|
||||
page_count=1,
|
||||
file_type=FileType.IMAGE,
|
||||
processing_mode=mode,
|
||||
metadata={
|
||||
"source": file_path or "bytes",
|
||||
"image_size": img.size
|
||||
}
|
||||
)
|
||||
|
||||
def _process_docx(
|
||||
self,
|
||||
file_path: str = None,
|
||||
file_bytes: bytes = None
|
||||
) -> ProcessingResult:
|
||||
"""Verarbeitet DOCX-Dateien."""
|
||||
try:
|
||||
from docx import Document
|
||||
except ImportError:
|
||||
raise ImportError("python-docx ist nicht installiert")
|
||||
|
||||
if file_bytes:
|
||||
doc = Document(io.BytesIO(file_bytes))
|
||||
else:
|
||||
doc = Document(file_path)
|
||||
|
||||
paragraphs = []
|
||||
for para in doc.paragraphs:
|
||||
if para.text.strip():
|
||||
paragraphs.append(para.text)
|
||||
|
||||
# Auch Tabellen extrahieren
|
||||
for table in doc.tables:
|
||||
for row in table.rows:
|
||||
row_text = " | ".join(cell.text for cell in row.cells)
|
||||
if row_text.strip():
|
||||
paragraphs.append(row_text)
|
||||
|
||||
text = "\n\n".join(paragraphs)
|
||||
|
||||
return ProcessingResult(
|
||||
text=text,
|
||||
confidence=1.0, # Direkte Textextraktion
|
||||
regions=[ProcessedRegion(
|
||||
text=text,
|
||||
confidence=1.0,
|
||||
bbox=(0, 0, 0, 0),
|
||||
page=1
|
||||
)],
|
||||
page_count=1,
|
||||
file_type=FileType.DOCX,
|
||||
processing_mode=ProcessingMode.TEXT_EXTRACT,
|
||||
metadata={"source": file_path or "bytes"}
|
||||
)
|
||||
|
||||
def _process_txt(
|
||||
self,
|
||||
file_path: str = None,
|
||||
file_bytes: bytes = None
|
||||
) -> ProcessingResult:
|
||||
"""Verarbeitet Textdateien."""
|
||||
if file_bytes:
|
||||
text = file_bytes.decode('utf-8', errors='ignore')
|
||||
else:
|
||||
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
text = f.read()
|
||||
|
||||
return ProcessingResult(
|
||||
text=text,
|
||||
confidence=1.0,
|
||||
regions=[ProcessedRegion(
|
||||
text=text,
|
||||
confidence=1.0,
|
||||
bbox=(0, 0, 0, 0),
|
||||
page=1
|
||||
)],
|
||||
page_count=1,
|
||||
file_type=FileType.TXT,
|
||||
processing_mode=ProcessingMode.TEXT_EXTRACT,
|
||||
metadata={"source": file_path or "bytes"}
|
||||
)
|
||||
|
||||
def _preprocess_image(self, img: Image.Image) -> Image.Image:
|
||||
"""
|
||||
Vorverarbeitung des Bildes für bessere OCR-Ergebnisse.
|
||||
|
||||
- Konvertierung zu Graustufen
|
||||
- Kontrastverstärkung
|
||||
- Rauschunterdrückung
|
||||
- Binarisierung
|
||||
"""
|
||||
# PIL zu OpenCV
|
||||
cv_img = cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR)
|
||||
|
||||
# Zu Graustufen konvertieren
|
||||
gray = cv2.cvtColor(cv_img, cv2.COLOR_BGR2GRAY)
|
||||
|
||||
# Rauschunterdrückung
|
||||
denoised = cv2.fastNlMeansDenoising(gray, None, 10, 7, 21)
|
||||
|
||||
# Kontrastverstärkung (CLAHE)
|
||||
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
|
||||
enhanced = clahe.apply(denoised)
|
||||
|
||||
# Adaptive Binarisierung
|
||||
binary = cv2.adaptiveThreshold(
|
||||
enhanced,
|
||||
255,
|
||||
cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
|
||||
cv2.THRESH_BINARY,
|
||||
11,
|
||||
2
|
||||
)
|
||||
|
||||
# Zurück zu PIL
|
||||
return Image.fromarray(binary)
|
||||
|
||||
def _ocr_image(self, img: Image.Image) -> Dict[str, Any]:
|
||||
"""
|
||||
Führt OCR auf einem Bild aus.
|
||||
|
||||
Returns:
|
||||
Dict mit text, confidence und regions
|
||||
"""
|
||||
if self.ocr_engine is None:
|
||||
# Fallback wenn kein OCR-Engine verfügbar
|
||||
return {
|
||||
"text": "[OCR nicht verfügbar - bitte PaddleOCR installieren]",
|
||||
"confidence": 0.0,
|
||||
"regions": []
|
||||
}
|
||||
|
||||
# PIL zu numpy array
|
||||
img_array = np.array(img)
|
||||
|
||||
# Wenn Graustufen, zu RGB konvertieren (PaddleOCR erwartet RGB)
|
||||
if len(img_array.shape) == 2:
|
||||
img_array = cv2.cvtColor(img_array, cv2.COLOR_GRAY2RGB)
|
||||
|
||||
# OCR ausführen
|
||||
result = self.ocr_engine.ocr(img_array, cls=True)
|
||||
|
||||
if not result or not result[0]:
|
||||
return {"text": "", "confidence": 0.0, "regions": []}
|
||||
|
||||
all_text = []
|
||||
all_regions = []
|
||||
total_confidence = 0.0
|
||||
|
||||
for line in result[0]:
|
||||
bbox_points = line[0] # [[x1,y1], [x2,y2], [x3,y3], [x4,y4]]
|
||||
text, confidence = line[1]
|
||||
|
||||
# Bounding Box zu x1, y1, x2, y2 konvertieren
|
||||
x_coords = [p[0] for p in bbox_points]
|
||||
y_coords = [p[1] for p in bbox_points]
|
||||
bbox = (
|
||||
int(min(x_coords)),
|
||||
int(min(y_coords)),
|
||||
int(max(x_coords)),
|
||||
int(max(y_coords))
|
||||
)
|
||||
|
||||
all_text.append(text)
|
||||
all_regions.append(ProcessedRegion(
|
||||
text=text,
|
||||
confidence=confidence,
|
||||
bbox=bbox
|
||||
))
|
||||
total_confidence += confidence
|
||||
|
||||
avg_confidence = total_confidence / len(all_regions) if all_regions else 0.0
|
||||
|
||||
return {
|
||||
"text": "\n".join(all_text),
|
||||
"confidence": avg_confidence,
|
||||
"regions": all_regions
|
||||
}
|
||||
|
||||
def extract_handwriting_regions(
|
||||
self,
|
||||
img: Image.Image,
|
||||
min_area: int = 500
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Erkennt und extrahiert handschriftliche Bereiche aus einem Bild.
|
||||
|
||||
Nützlich für Klausuren mit gedruckten Fragen und handschriftlichen Antworten.
|
||||
|
||||
Args:
|
||||
img: Eingabebild
|
||||
min_area: Minimale Fläche für erkannte Regionen
|
||||
|
||||
Returns:
|
||||
Liste von Regionen mit Koordinaten und erkanntem Text
|
||||
"""
|
||||
# Bildvorverarbeitung
|
||||
cv_img = cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR)
|
||||
gray = cv2.cvtColor(cv_img, cv2.COLOR_BGR2GRAY)
|
||||
|
||||
# Kanten erkennen
|
||||
edges = cv2.Canny(gray, 50, 150)
|
||||
|
||||
# Morphologische Operationen zum Verbinden
|
||||
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (15, 5))
|
||||
dilated = cv2.dilate(edges, kernel, iterations=2)
|
||||
|
||||
# Konturen finden
|
||||
contours, _ = cv2.findContours(dilated, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||
|
||||
regions = []
|
||||
for contour in contours:
|
||||
area = cv2.contourArea(contour)
|
||||
if area < min_area:
|
||||
continue
|
||||
|
||||
x, y, w, h = cv2.boundingRect(contour)
|
||||
|
||||
# Region ausschneiden
|
||||
region_img = img.crop((x, y, x + w, y + h))
|
||||
|
||||
# OCR auf Region anwenden
|
||||
ocr_result = self._ocr_image(region_img)
|
||||
|
||||
regions.append({
|
||||
"bbox": (x, y, x + w, y + h),
|
||||
"area": area,
|
||||
"text": ocr_result["text"],
|
||||
"confidence": ocr_result["confidence"]
|
||||
})
|
||||
|
||||
# Nach Y-Position sortieren (oben nach unten)
|
||||
regions.sort(key=lambda r: r["bbox"][1])
|
||||
|
||||
return regions
|
||||
|
||||
|
||||
# Singleton-Instanz
|
||||
_file_processor: Optional[FileProcessor] = None
|
||||
|
||||
|
||||
def get_file_processor() -> FileProcessor:
|
||||
"""Gibt Singleton-Instanz des File Processors zurück."""
|
||||
global _file_processor
|
||||
if _file_processor is None:
|
||||
_file_processor = FileProcessor()
|
||||
return _file_processor
|
||||
|
||||
|
||||
# Convenience functions
|
||||
def process_file(
|
||||
file_path: str = None,
|
||||
file_bytes: bytes = None,
|
||||
mode: ProcessingMode = ProcessingMode.MIXED
|
||||
) -> ProcessingResult:
|
||||
"""
|
||||
Convenience function zum Verarbeiten einer Datei.
|
||||
|
||||
Args:
|
||||
file_path: Pfad zur Datei
|
||||
file_bytes: Dateiinhalt als Bytes
|
||||
mode: Verarbeitungsmodus
|
||||
|
||||
Returns:
|
||||
ProcessingResult
|
||||
"""
|
||||
processor = get_file_processor()
|
||||
return processor.process(file_path, file_bytes, mode)
|
||||
|
||||
|
||||
def extract_text_from_pdf(file_path: str = None, file_bytes: bytes = None) -> str:
|
||||
"""Extrahiert Text aus einer PDF-Datei."""
|
||||
result = process_file(file_path, file_bytes, ProcessingMode.TEXT_EXTRACT)
|
||||
return result.text
|
||||
|
||||
|
||||
def ocr_image(file_path: str = None, file_bytes: bytes = None) -> str:
|
||||
"""Führt OCR auf einem Bild aus."""
|
||||
result = process_file(file_path, file_bytes, ProcessingMode.OCR_PRINTED)
|
||||
return result.text
|
||||
|
||||
|
||||
def ocr_handwriting(file_path: str = None, file_bytes: bytes = None) -> str:
|
||||
"""Führt Handschrift-OCR auf einem Bild aus."""
|
||||
result = process_file(file_path, file_bytes, ProcessingMode.OCR_HANDWRITING)
|
||||
return result.text
|
||||
916
backend-core/services/pdf_service.py
Normal file
916
backend-core/services/pdf_service.py
Normal file
@@ -0,0 +1,916 @@
|
||||
"""
|
||||
PDF Service - Zentrale PDF-Generierung für BreakPilot.
|
||||
|
||||
Shared Service für:
|
||||
- Letters (Elternbriefe)
|
||||
- Zeugnisse (Schulzeugnisse)
|
||||
- Correction (Korrektur-Übersichten)
|
||||
|
||||
Verwendet WeasyPrint für PDF-Rendering und Jinja2 für Templates.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, List
|
||||
from dataclasses import dataclass
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
from weasyprint import HTML, CSS
|
||||
from weasyprint.text.fonts import FontConfiguration
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Template directory
|
||||
TEMPLATES_DIR = Path(__file__).parent.parent / "templates" / "pdf"
|
||||
|
||||
|
||||
@dataclass
|
||||
class SchoolInfo:
|
||||
"""Schulinformationen für Header."""
|
||||
name: str
|
||||
address: str
|
||||
phone: str
|
||||
email: str
|
||||
logo_path: Optional[str] = None
|
||||
website: Optional[str] = None
|
||||
principal: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class LetterData:
|
||||
"""Daten für Elternbrief-PDF."""
|
||||
recipient_name: str
|
||||
recipient_address: str
|
||||
student_name: str
|
||||
student_class: str
|
||||
subject: str
|
||||
content: str
|
||||
date: str
|
||||
teacher_name: str
|
||||
teacher_title: Optional[str] = None
|
||||
school_info: Optional[SchoolInfo] = None
|
||||
letter_type: str = "general" # general, halbjahr, fehlzeiten, elternabend, lob
|
||||
tone: str = "professional"
|
||||
legal_references: Optional[List[Dict[str, str]]] = None
|
||||
gfk_principles_applied: Optional[List[str]] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class CertificateData:
|
||||
"""Daten für Zeugnis-PDF."""
|
||||
student_name: str
|
||||
student_birthdate: str
|
||||
student_class: str
|
||||
school_year: str
|
||||
certificate_type: str # halbjahr, jahres, abschluss
|
||||
subjects: List[Dict[str, Any]] # [{name, grade, note}]
|
||||
attendance: Dict[str, int] # {days_absent, days_excused, days_unexcused}
|
||||
remarks: Optional[str] = None
|
||||
class_teacher: str = ""
|
||||
principal: str = ""
|
||||
school_info: Optional[SchoolInfo] = None
|
||||
issue_date: str = ""
|
||||
social_behavior: Optional[str] = None # A, B, C, D
|
||||
work_behavior: Optional[str] = None # A, B, C, D
|
||||
|
||||
|
||||
@dataclass
|
||||
class StudentInfo:
|
||||
"""Schülerinformationen für Korrektur-PDFs."""
|
||||
student_id: str
|
||||
name: str
|
||||
class_name: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class CorrectionData:
|
||||
"""Daten für Korrektur-Übersicht PDF."""
|
||||
student: StudentInfo
|
||||
exam_title: str
|
||||
subject: str
|
||||
date: str
|
||||
max_points: int
|
||||
achieved_points: int
|
||||
grade: str
|
||||
percentage: float
|
||||
corrections: List[Dict[str, Any]] # [{question, answer, points, feedback}]
|
||||
teacher_notes: str = ""
|
||||
ai_feedback: str = ""
|
||||
grade_distribution: Optional[Dict[str, int]] = None # {note: anzahl}
|
||||
class_average: Optional[float] = None
|
||||
|
||||
|
||||
class PDFService:
|
||||
"""
|
||||
Zentrale PDF-Generierung für BreakPilot.
|
||||
|
||||
Unterstützt:
|
||||
- Elternbriefe mit GFK-Prinzipien und rechtlichen Referenzen
|
||||
- Schulzeugnisse (Halbjahr, Jahres, Abschluss)
|
||||
- Korrektur-Übersichten für Klausuren
|
||||
"""
|
||||
|
||||
def __init__(self, templates_dir: Optional[Path] = None):
|
||||
"""
|
||||
Initialisiert den PDF-Service.
|
||||
|
||||
Args:
|
||||
templates_dir: Optionaler Pfad zu Templates (Standard: backend/templates/pdf)
|
||||
"""
|
||||
self.templates_dir = templates_dir or TEMPLATES_DIR
|
||||
|
||||
# Ensure templates directory exists
|
||||
self.templates_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Initialize Jinja2 environment
|
||||
self.jinja_env = Environment(
|
||||
loader=FileSystemLoader(str(self.templates_dir)),
|
||||
autoescape=select_autoescape(['html', 'xml']),
|
||||
trim_blocks=True,
|
||||
lstrip_blocks=True
|
||||
)
|
||||
|
||||
# Add custom filters
|
||||
self.jinja_env.filters['date_format'] = self._date_format
|
||||
self.jinja_env.filters['grade_color'] = self._grade_color
|
||||
|
||||
# Font configuration for WeasyPrint
|
||||
self.font_config = FontConfiguration()
|
||||
|
||||
logger.info(f"PDFService initialized with templates from {self.templates_dir}")
|
||||
|
||||
@staticmethod
|
||||
def _date_format(value: str, format_str: str = "%d.%m.%Y") -> str:
|
||||
"""Formatiert Datum für deutsche Darstellung."""
|
||||
if not value:
|
||||
return ""
|
||||
try:
|
||||
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
return dt.strftime(format_str)
|
||||
except (ValueError, AttributeError):
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _grade_color(grade: str) -> str:
|
||||
"""Gibt Farbe basierend auf Note zurück."""
|
||||
grade_colors = {
|
||||
"1": "#27ae60", # Grün
|
||||
"2": "#2ecc71", # Hellgrün
|
||||
"3": "#f1c40f", # Gelb
|
||||
"4": "#e67e22", # Orange
|
||||
"5": "#e74c3c", # Rot
|
||||
"6": "#c0392b", # Dunkelrot
|
||||
"A": "#27ae60",
|
||||
"B": "#2ecc71",
|
||||
"C": "#f1c40f",
|
||||
"D": "#e74c3c",
|
||||
}
|
||||
return grade_colors.get(str(grade), "#333333")
|
||||
|
||||
def _get_base_css(self) -> str:
|
||||
"""Gibt Basis-CSS für alle PDFs zurück."""
|
||||
return """
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 2cm 2.5cm;
|
||||
@top-right {
|
||||
content: counter(page) " / " counter(pages);
|
||||
font-size: 9pt;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'DejaVu Sans', 'Liberation Sans', Arial, sans-serif;
|
||||
font-size: 11pt;
|
||||
line-height: 1.5;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
h1, h2, h3 {
|
||||
font-weight: bold;
|
||||
margin-top: 1em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
h1 { font-size: 16pt; }
|
||||
h2 { font-size: 14pt; }
|
||||
h3 { font-size: 12pt; }
|
||||
|
||||
.header {
|
||||
border-bottom: 2px solid #2c3e50;
|
||||
padding-bottom: 15px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.school-name {
|
||||
font-size: 18pt;
|
||||
font-weight: bold;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.school-info {
|
||||
font-size: 9pt;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.letter-date {
|
||||
text-align: right;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.recipient {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.subject {
|
||||
font-weight: bold;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.content {
|
||||
text-align: justify;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.signature {
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
.legal-references {
|
||||
font-size: 9pt;
|
||||
color: #666;
|
||||
border-top: 1px solid #ddd;
|
||||
margin-top: 30px;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.gfk-badge {
|
||||
display: inline-block;
|
||||
background: #e8f5e9;
|
||||
color: #27ae60;
|
||||
font-size: 8pt;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
/* Zeugnis-Styles */
|
||||
.certificate-header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.certificate-title {
|
||||
font-size: 20pt;
|
||||
font-weight: bold;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.student-info {
|
||||
margin-bottom: 20px;
|
||||
padding: 15px;
|
||||
background: #f9f9f9;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.grades-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.grades-table th,
|
||||
.grades-table td {
|
||||
border: 1px solid #ddd;
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.grades-table th {
|
||||
background: #2c3e50;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.grades-table tr:nth-child(even) {
|
||||
background: #f9f9f9;
|
||||
}
|
||||
|
||||
.grade-cell {
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
font-size: 12pt;
|
||||
}
|
||||
|
||||
.attendance-box {
|
||||
background: #fff3cd;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.signatures-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 50px;
|
||||
}
|
||||
|
||||
.signature-block {
|
||||
text-align: center;
|
||||
width: 40%;
|
||||
}
|
||||
|
||||
.signature-line {
|
||||
border-top: 1px solid #333;
|
||||
margin-top: 40px;
|
||||
padding-top: 5px;
|
||||
}
|
||||
|
||||
/* Korrektur-Styles */
|
||||
.exam-header {
|
||||
background: #2c3e50;
|
||||
color: white;
|
||||
padding: 15px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.result-box {
|
||||
background: #e8f5e9;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.result-grade {
|
||||
font-size: 36pt;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.result-points {
|
||||
font-size: 14pt;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.corrections-list {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.correction-item {
|
||||
border: 1px solid #ddd;
|
||||
padding: 15px;
|
||||
margin-bottom: 10px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.correction-question {
|
||||
font-weight: bold;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.correction-feedback {
|
||||
background: #fff8e1;
|
||||
padding: 10px;
|
||||
margin-top: 10px;
|
||||
border-left: 3px solid #ffc107;
|
||||
font-size: 10pt;
|
||||
}
|
||||
|
||||
.stats-table {
|
||||
width: 100%;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.stats-table td {
|
||||
padding: 5px 10px;
|
||||
}
|
||||
"""
|
||||
|
||||
def generate_letter_pdf(self, data: LetterData) -> bytes:
|
||||
"""
|
||||
Generiert PDF für Elternbrief.
|
||||
|
||||
Args:
|
||||
data: LetterData mit allen Briefinformationen
|
||||
|
||||
Returns:
|
||||
PDF als bytes
|
||||
"""
|
||||
logger.info(f"Generating letter PDF for student: {data.student_name}")
|
||||
|
||||
template = self._get_letter_template()
|
||||
html_content = template.render(
|
||||
data=data,
|
||||
generated_at=datetime.now().strftime("%d.%m.%Y %H:%M")
|
||||
)
|
||||
|
||||
css = CSS(string=self._get_base_css(), font_config=self.font_config)
|
||||
pdf_bytes = HTML(string=html_content).write_pdf(
|
||||
stylesheets=[css],
|
||||
font_config=self.font_config
|
||||
)
|
||||
|
||||
logger.info(f"Letter PDF generated: {len(pdf_bytes)} bytes")
|
||||
return pdf_bytes
|
||||
|
||||
def generate_certificate_pdf(self, data: CertificateData) -> bytes:
|
||||
"""
|
||||
Generiert PDF für Schulzeugnis.
|
||||
|
||||
Args:
|
||||
data: CertificateData mit allen Zeugnisinformationen
|
||||
|
||||
Returns:
|
||||
PDF als bytes
|
||||
"""
|
||||
logger.info(f"Generating certificate PDF for: {data.student_name}")
|
||||
|
||||
template = self._get_certificate_template()
|
||||
html_content = template.render(
|
||||
data=data,
|
||||
generated_at=datetime.now().strftime("%d.%m.%Y %H:%M")
|
||||
)
|
||||
|
||||
css = CSS(string=self._get_base_css(), font_config=self.font_config)
|
||||
pdf_bytes = HTML(string=html_content).write_pdf(
|
||||
stylesheets=[css],
|
||||
font_config=self.font_config
|
||||
)
|
||||
|
||||
logger.info(f"Certificate PDF generated: {len(pdf_bytes)} bytes")
|
||||
return pdf_bytes
|
||||
|
||||
def generate_correction_pdf(self, data: CorrectionData) -> bytes:
|
||||
"""
|
||||
Generiert PDF für Korrektur-Übersicht.
|
||||
|
||||
Args:
|
||||
data: CorrectionData mit allen Korrekturinformationen
|
||||
|
||||
Returns:
|
||||
PDF als bytes
|
||||
"""
|
||||
logger.info(f"Generating correction PDF for: {data.student.name}")
|
||||
|
||||
template = self._get_correction_template()
|
||||
html_content = template.render(
|
||||
data=data,
|
||||
generated_at=datetime.now().strftime("%d.%m.%Y %H:%M")
|
||||
)
|
||||
|
||||
css = CSS(string=self._get_base_css(), font_config=self.font_config)
|
||||
pdf_bytes = HTML(string=html_content).write_pdf(
|
||||
stylesheets=[css],
|
||||
font_config=self.font_config
|
||||
)
|
||||
|
||||
logger.info(f"Correction PDF generated: {len(pdf_bytes)} bytes")
|
||||
return pdf_bytes
|
||||
|
||||
def _get_letter_template(self):
|
||||
"""Gibt Letter-Template zurück (inline falls Datei nicht existiert)."""
|
||||
template_path = self.templates_dir / "letter.html"
|
||||
if template_path.exists():
|
||||
return self.jinja_env.get_template("letter.html")
|
||||
|
||||
# Inline-Template als Fallback
|
||||
return self.jinja_env.from_string(self._get_letter_template_html())
|
||||
|
||||
def _get_certificate_template(self):
|
||||
"""Gibt Certificate-Template zurück."""
|
||||
template_path = self.templates_dir / "certificate.html"
|
||||
if template_path.exists():
|
||||
return self.jinja_env.get_template("certificate.html")
|
||||
|
||||
return self.jinja_env.from_string(self._get_certificate_template_html())
|
||||
|
||||
def _get_correction_template(self):
|
||||
"""Gibt Correction-Template zurück."""
|
||||
template_path = self.templates_dir / "correction.html"
|
||||
if template_path.exists():
|
||||
return self.jinja_env.get_template("correction.html")
|
||||
|
||||
return self.jinja_env.from_string(self._get_correction_template_html())
|
||||
|
||||
@staticmethod
|
||||
def _get_letter_template_html() -> str:
|
||||
"""Inline HTML-Template für Elternbriefe."""
|
||||
return """
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{{ data.subject }}</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
{% if data.school_info %}
|
||||
<div class="school-name">{{ data.school_info.name }}</div>
|
||||
<div class="school-info">
|
||||
{{ data.school_info.address }}<br>
|
||||
Tel: {{ data.school_info.phone }} | E-Mail: {{ data.school_info.email }}
|
||||
{% if data.school_info.website %} | {{ data.school_info.website }}{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="school-name">Schule</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="letter-date">
|
||||
{{ data.date }}
|
||||
</div>
|
||||
|
||||
<div class="recipient">
|
||||
{{ data.recipient_name }}<br>
|
||||
{{ data.recipient_address | replace('\\n', '<br>') | safe }}
|
||||
</div>
|
||||
|
||||
<div class="subject">
|
||||
Betreff: {{ data.subject }}
|
||||
</div>
|
||||
|
||||
<div class="meta-info" style="font-size: 10pt; color: #666; margin-bottom: 20px;">
|
||||
Schüler/in: {{ data.student_name }} | Klasse: {{ data.student_class }}
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
{{ data.content | replace('\\n', '<br>') | safe }}
|
||||
</div>
|
||||
|
||||
{% if data.gfk_principles_applied %}
|
||||
<div style="margin-bottom: 20px;">
|
||||
{% for principle in data.gfk_principles_applied %}
|
||||
<span class="gfk-badge">✓ {{ principle }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="signature">
|
||||
<p>Mit freundlichen Grüßen</p>
|
||||
<p style="margin-top: 30px;">
|
||||
{{ data.teacher_name }}
|
||||
{% if data.teacher_title %}<br><span style="font-size: 10pt;">{{ data.teacher_title }}</span>{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{% if data.legal_references %}
|
||||
<div class="legal-references">
|
||||
<strong>Rechtliche Grundlagen:</strong><br>
|
||||
{% for ref in data.legal_references %}
|
||||
• {{ ref.law }} {{ ref.paragraph }}: {{ ref.title }}<br>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div style="font-size: 8pt; color: #999; margin-top: 30px; text-align: center;">
|
||||
Erstellt mit BreakPilot | {{ generated_at }}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _get_certificate_template_html() -> str:
|
||||
"""Inline HTML-Template für Zeugnisse."""
|
||||
return """
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Zeugnis - {{ data.student_name }}</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="certificate-header">
|
||||
{% if data.school_info %}
|
||||
<div class="school-name" style="font-size: 14pt;">{{ data.school_info.name }}</div>
|
||||
{% endif %}
|
||||
<div class="certificate-title">
|
||||
{% if data.certificate_type == 'halbjahr' %}
|
||||
Halbjahreszeugnis
|
||||
{% elif data.certificate_type == 'jahres' %}
|
||||
Jahreszeugnis
|
||||
{% else %}
|
||||
Abschlusszeugnis
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>Schuljahr {{ data.school_year }}</div>
|
||||
</div>
|
||||
|
||||
<div class="student-info">
|
||||
<table style="width: 100%;">
|
||||
<tr>
|
||||
<td><strong>Name:</strong> {{ data.student_name }}</td>
|
||||
<td><strong>Geburtsdatum:</strong> {{ data.student_birthdate }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Klasse:</strong> {{ data.student_class }}</td>
|
||||
<td> </td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3>Leistungen</h3>
|
||||
<table class="grades-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 70%;">Fach</th>
|
||||
<th style="width: 15%;">Note</th>
|
||||
<th style="width: 15%;">Punkte</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for subject in data.subjects %}
|
||||
<tr>
|
||||
<td>{{ subject.name }}</td>
|
||||
<td class="grade-cell" style="color: {{ subject.grade | grade_color }};">
|
||||
{{ subject.grade }}
|
||||
</td>
|
||||
<td class="grade-cell">{{ subject.points | default('-') }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% if data.social_behavior or data.work_behavior %}
|
||||
<h3>Verhalten</h3>
|
||||
<table class="grades-table" style="width: 50%;">
|
||||
{% if data.social_behavior %}
|
||||
<tr>
|
||||
<td>Sozialverhalten</td>
|
||||
<td class="grade-cell">{{ data.social_behavior }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if data.work_behavior %}
|
||||
<tr>
|
||||
<td>Arbeitsverhalten</td>
|
||||
<td class="grade-cell">{{ data.work_behavior }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
<div class="attendance-box">
|
||||
<strong>Versäumte Tage:</strong> {{ data.attendance.days_absent | default(0) }}
|
||||
(davon entschuldigt: {{ data.attendance.days_excused | default(0) }},
|
||||
unentschuldigt: {{ data.attendance.days_unexcused | default(0) }})
|
||||
</div>
|
||||
|
||||
{% if data.remarks %}
|
||||
<div style="margin-bottom: 20px;">
|
||||
<strong>Bemerkungen:</strong><br>
|
||||
{{ data.remarks }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div style="margin-top: 30px;">
|
||||
<strong>Ausgestellt am:</strong> {{ data.issue_date }}
|
||||
</div>
|
||||
|
||||
<div class="signatures-row">
|
||||
<div class="signature-block">
|
||||
<div class="signature-line">{{ data.class_teacher }}</div>
|
||||
<div style="font-size: 9pt;">Klassenlehrer/in</div>
|
||||
</div>
|
||||
<div class="signature-block">
|
||||
<div class="signature-line">{{ data.principal }}</div>
|
||||
<div style="font-size: 9pt;">Schulleiter/in</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center; margin-top: 40px;">
|
||||
<div style="font-size: 9pt; color: #666;">Siegel der Schule</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _get_correction_template_html() -> str:
|
||||
"""Inline HTML-Template für Korrektur-Übersichten."""
|
||||
return """
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Korrektur - {{ data.exam_title }}</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="exam-header">
|
||||
<h1 style="margin: 0; color: white;">{{ data.exam_title }}</h1>
|
||||
<div>{{ data.subject }} | {{ data.date }}</div>
|
||||
</div>
|
||||
|
||||
<div class="student-info">
|
||||
<strong>{{ data.student.name }}</strong> | Klasse {{ data.student.class_name }}
|
||||
</div>
|
||||
|
||||
<div class="result-box">
|
||||
<div class="result-grade" style="color: {{ data.grade | grade_color }};">
|
||||
Note: {{ data.grade }}
|
||||
</div>
|
||||
<div class="result-points">
|
||||
{{ data.achieved_points }} von {{ data.max_points }} Punkten
|
||||
({{ data.percentage | round(1) }}%)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Detaillierte Auswertung</h3>
|
||||
<div class="corrections-list">
|
||||
{% for item in data.corrections %}
|
||||
<div class="correction-item">
|
||||
<div class="correction-question">
|
||||
{{ item.question }}
|
||||
</div>
|
||||
{% if item.answer %}
|
||||
<div style="margin: 5px 0; font-style: italic; color: #555;">
|
||||
<strong>Antwort:</strong> {{ item.answer }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div>
|
||||
<strong>Punkte:</strong> {{ item.points }}
|
||||
</div>
|
||||
{% if item.feedback %}
|
||||
<div class="correction-feedback">
|
||||
{{ item.feedback }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if data.teacher_notes %}
|
||||
<div style="background: #e3f2fd; padding: 15px; border-radius: 5px; margin-bottom: 20px;">
|
||||
<strong>Lehrerkommentar:</strong><br>
|
||||
{{ data.teacher_notes }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if data.ai_feedback %}
|
||||
<div style="background: #f3e5f5; padding: 15px; border-radius: 5px; margin-bottom: 20px;">
|
||||
<strong>KI-Feedback:</strong><br>
|
||||
{{ data.ai_feedback }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if data.class_average or data.grade_distribution %}
|
||||
<h3>Klassenstatistik</h3>
|
||||
<table class="stats-table">
|
||||
{% if data.class_average %}
|
||||
<tr>
|
||||
<td><strong>Klassendurchschnitt:</strong></td>
|
||||
<td>{{ data.class_average }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if data.grade_distribution %}
|
||||
<tr>
|
||||
<td><strong>Notenverteilung:</strong></td>
|
||||
<td>
|
||||
{% for grade, count in data.grade_distribution.items() %}
|
||||
Note {{ grade }}: {{ count }}x{% if not loop.last %}, {% endif %}
|
||||
{% endfor %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
<div class="signature" style="margin-top: 40px;">
|
||||
<p style="font-size: 9pt; color: #666;">Datum: {{ data.date }}</p>
|
||||
</div>
|
||||
|
||||
<div style="font-size: 8pt; color: #999; margin-top: 30px; text-align: center;">
|
||||
Erstellt mit BreakPilot | {{ generated_at }}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
# Convenience functions for direct usage
|
||||
_pdf_service: Optional[PDFService] = None
|
||||
|
||||
|
||||
def get_pdf_service() -> PDFService:
|
||||
"""Gibt Singleton-Instanz des PDF-Service zurück."""
|
||||
global _pdf_service
|
||||
if _pdf_service is None:
|
||||
_pdf_service = PDFService()
|
||||
return _pdf_service
|
||||
|
||||
|
||||
def generate_letter_pdf(data: Dict[str, Any]) -> bytes:
|
||||
"""
|
||||
Convenience function zum Generieren eines Elternbrief-PDFs.
|
||||
|
||||
Args:
|
||||
data: Dict mit allen Briefdaten
|
||||
|
||||
Returns:
|
||||
PDF als bytes
|
||||
"""
|
||||
service = get_pdf_service()
|
||||
|
||||
# Convert dict to LetterData
|
||||
school_info = None
|
||||
if data.get("school_info"):
|
||||
school_info = SchoolInfo(**data["school_info"])
|
||||
|
||||
letter_data = LetterData(
|
||||
recipient_name=data.get("recipient_name", ""),
|
||||
recipient_address=data.get("recipient_address", ""),
|
||||
student_name=data.get("student_name", ""),
|
||||
student_class=data.get("student_class", ""),
|
||||
subject=data.get("subject", ""),
|
||||
content=data.get("content", ""),
|
||||
date=data.get("date", datetime.now().strftime("%d.%m.%Y")),
|
||||
teacher_name=data.get("teacher_name", ""),
|
||||
teacher_title=data.get("teacher_title"),
|
||||
school_info=school_info,
|
||||
letter_type=data.get("letter_type", "general"),
|
||||
tone=data.get("tone", "professional"),
|
||||
legal_references=data.get("legal_references"),
|
||||
gfk_principles_applied=data.get("gfk_principles_applied")
|
||||
)
|
||||
|
||||
return service.generate_letter_pdf(letter_data)
|
||||
|
||||
|
||||
def generate_certificate_pdf(data: Dict[str, Any]) -> bytes:
|
||||
"""
|
||||
Convenience function zum Generieren eines Zeugnis-PDFs.
|
||||
|
||||
Args:
|
||||
data: Dict mit allen Zeugnisdaten
|
||||
|
||||
Returns:
|
||||
PDF als bytes
|
||||
"""
|
||||
service = get_pdf_service()
|
||||
|
||||
school_info = None
|
||||
if data.get("school_info"):
|
||||
school_info = SchoolInfo(**data["school_info"])
|
||||
|
||||
cert_data = CertificateData(
|
||||
student_name=data.get("student_name", ""),
|
||||
student_birthdate=data.get("student_birthdate", ""),
|
||||
student_class=data.get("student_class", ""),
|
||||
school_year=data.get("school_year", ""),
|
||||
certificate_type=data.get("certificate_type", "halbjahr"),
|
||||
subjects=data.get("subjects", []),
|
||||
attendance=data.get("attendance", {"days_absent": 0, "days_excused": 0, "days_unexcused": 0}),
|
||||
remarks=data.get("remarks"),
|
||||
class_teacher=data.get("class_teacher", ""),
|
||||
principal=data.get("principal", ""),
|
||||
school_info=school_info,
|
||||
issue_date=data.get("issue_date", datetime.now().strftime("%d.%m.%Y")),
|
||||
social_behavior=data.get("social_behavior"),
|
||||
work_behavior=data.get("work_behavior")
|
||||
)
|
||||
|
||||
return service.generate_certificate_pdf(cert_data)
|
||||
|
||||
|
||||
def generate_correction_pdf(data: Dict[str, Any]) -> bytes:
|
||||
"""
|
||||
Convenience function zum Generieren eines Korrektur-PDFs.
|
||||
|
||||
Args:
|
||||
data: Dict mit allen Korrekturdaten
|
||||
|
||||
Returns:
|
||||
PDF als bytes
|
||||
"""
|
||||
service = get_pdf_service()
|
||||
|
||||
# Create StudentInfo from dict
|
||||
student = StudentInfo(
|
||||
student_id=data.get("student_id", "unknown"),
|
||||
name=data.get("student_name", data.get("name", "")),
|
||||
class_name=data.get("student_class", data.get("class_name", ""))
|
||||
)
|
||||
|
||||
# Calculate percentage if not provided
|
||||
max_points = data.get("max_points", data.get("total_points", 0))
|
||||
achieved_points = data.get("achieved_points", 0)
|
||||
percentage = data.get("percentage", (achieved_points / max_points * 100) if max_points > 0 else 0.0)
|
||||
|
||||
correction_data = CorrectionData(
|
||||
student=student,
|
||||
exam_title=data.get("exam_title", ""),
|
||||
subject=data.get("subject", ""),
|
||||
date=data.get("date", data.get("exam_date", "")),
|
||||
max_points=max_points,
|
||||
achieved_points=achieved_points,
|
||||
grade=data.get("grade", ""),
|
||||
percentage=percentage,
|
||||
corrections=data.get("corrections", []),
|
||||
teacher_notes=data.get("teacher_notes", data.get("teacher_comment", "")),
|
||||
ai_feedback=data.get("ai_feedback", ""),
|
||||
grade_distribution=data.get("grade_distribution"),
|
||||
class_average=data.get("class_average")
|
||||
)
|
||||
|
||||
return service.generate_correction_pdf(correction_data)
|
||||
66
backend-core/system_api.py
Normal file
66
backend-core/system_api.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""
|
||||
System API endpoints for health checks and system information.
|
||||
|
||||
Provides:
|
||||
- /health - Basic health check
|
||||
- /api/v1/system/local-ip - Local network IP for QR-code mobile upload
|
||||
"""
|
||||
|
||||
import os
|
||||
import socket
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter(tags=["System"])
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health_check():
|
||||
"""
|
||||
Basic health check endpoint.
|
||||
|
||||
Returns healthy status and service name.
|
||||
"""
|
||||
return {
|
||||
"status": "healthy",
|
||||
"service": "breakpilot-backend-core"
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/v1/system/local-ip")
|
||||
async def get_local_ip():
|
||||
"""
|
||||
Return the local network IP address.
|
||||
|
||||
Used for QR-code generation for mobile PDF upload.
|
||||
Mobile devices can't reach localhost, so we need the actual network IP.
|
||||
|
||||
Priority:
|
||||
1. LOCAL_NETWORK_IP environment variable (explicit configuration)
|
||||
2. Auto-detection via socket connection
|
||||
3. Fallback to default 192.168.178.157
|
||||
"""
|
||||
# Check environment variable first
|
||||
env_ip = os.getenv("LOCAL_NETWORK_IP")
|
||||
if env_ip:
|
||||
return {"ip": env_ip}
|
||||
|
||||
# Try to auto-detect
|
||||
try:
|
||||
# Create a socket to an external address to determine local IP
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.settimeout(0.1)
|
||||
# Connect to a public DNS server (doesn't actually send anything)
|
||||
s.connect(("8.8.8.8", 80))
|
||||
local_ip = s.getsockname()[0]
|
||||
s.close()
|
||||
|
||||
# Validate it's a private IP
|
||||
if (local_ip.startswith("192.168.") or
|
||||
local_ip.startswith("10.") or
|
||||
(local_ip.startswith("172.") and 16 <= int(local_ip.split('.')[1]) <= 31)):
|
||||
return {"ip": local_ip}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback to default
|
||||
return {"ip": "192.168.178.157"}
|
||||
115
backend-core/templates/pdf/certificate.html
Normal file
115
backend-core/templates/pdf/certificate.html
Normal file
@@ -0,0 +1,115 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Zeugnis - {{ data.student_name }}</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="certificate-header">
|
||||
{% if data.school_info %}
|
||||
<div class="school-name" style="font-size: 14pt;">{{ data.school_info.name }}</div>
|
||||
{% endif %}
|
||||
<div class="certificate-title">
|
||||
{% if data.certificate_type == 'halbjahr' %}
|
||||
Halbjahreszeugnis
|
||||
{% elif data.certificate_type == 'jahres' %}
|
||||
Jahreszeugnis
|
||||
{% elif data.certificate_type == 'abschluss' %}
|
||||
Abschlusszeugnis
|
||||
{% else %}
|
||||
Zeugnis
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>Schuljahr {{ data.school_year }}</div>
|
||||
</div>
|
||||
|
||||
<div class="student-info">
|
||||
<table style="width: 100%;">
|
||||
<tr>
|
||||
<td><strong>Name:</strong> {{ data.student_name }}</td>
|
||||
<td><strong>Geburtsdatum:</strong> {{ data.student_birthdate }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Klasse:</strong> {{ data.student_class }}</td>
|
||||
<td> </td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3>Leistungen</h3>
|
||||
<table class="grades-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 60%;">Fach</th>
|
||||
<th style="width: 20%;">Note</th>
|
||||
<th style="width: 20%;">Punkte</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for subject in data.subjects %}
|
||||
<tr>
|
||||
<td>{{ subject.name }}</td>
|
||||
<td class="grade-cell" style="color: {{ subject.grade | grade_color }};">
|
||||
{{ subject.grade }}
|
||||
</td>
|
||||
<td class="grade-cell">{{ subject.points | default('-') }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% if data.social_behavior or data.work_behavior %}
|
||||
<h3>Verhalten</h3>
|
||||
<table class="grades-table" style="width: 50%;">
|
||||
{% if data.social_behavior %}
|
||||
<tr>
|
||||
<td>Sozialverhalten</td>
|
||||
<td class="grade-cell">{{ data.social_behavior }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if data.work_behavior %}
|
||||
<tr>
|
||||
<td>Arbeitsverhalten</td>
|
||||
<td class="grade-cell">{{ data.work_behavior }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
<div class="attendance-box">
|
||||
<strong>Versäumte Tage:</strong> {{ data.attendance.days_absent | default(0) }}
|
||||
(davon entschuldigt: {{ data.attendance.days_excused | default(0) }},
|
||||
unentschuldigt: {{ data.attendance.days_unexcused | default(0) }})
|
||||
</div>
|
||||
|
||||
{% if data.remarks %}
|
||||
<div style="margin-bottom: 20px;">
|
||||
<strong>Bemerkungen:</strong><br>
|
||||
{{ data.remarks }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div style="margin-top: 30px;">
|
||||
<strong>Ausgestellt am:</strong> {{ data.issue_date }}
|
||||
</div>
|
||||
|
||||
<div class="signatures-row">
|
||||
<div class="signature-block">
|
||||
<div class="signature-line">{{ data.class_teacher }}</div>
|
||||
<div style="font-size: 9pt;">Klassenlehrer/in</div>
|
||||
</div>
|
||||
<div class="signature-block">
|
||||
<div class="signature-line">{{ data.principal }}</div>
|
||||
<div style="font-size: 9pt;">Schulleiter/in</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center; margin-top: 40px;">
|
||||
<div style="font-size: 9pt; color: #666;">Siegel der Schule</div>
|
||||
</div>
|
||||
|
||||
<div style="font-size: 8pt; color: #999; margin-top: 30px; text-align: center;">
|
||||
Erstellt mit BreakPilot | {{ generated_at }}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
90
backend-core/templates/pdf/correction.html
Normal file
90
backend-core/templates/pdf/correction.html
Normal file
@@ -0,0 +1,90 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Korrektur - {{ data.exam_title }}</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="exam-header">
|
||||
<h1 style="margin: 0; color: white;">{{ data.exam_title }}</h1>
|
||||
<div>{{ data.subject }} | {{ data.date }}</div>
|
||||
</div>
|
||||
|
||||
<div class="student-info">
|
||||
<strong>{{ data.student.name }}</strong> | Klasse {{ data.student.class_name }}
|
||||
</div>
|
||||
|
||||
<div class="result-box">
|
||||
<div class="result-grade" style="color: {{ data.grade | grade_color }};">
|
||||
Note: {{ data.grade }}
|
||||
</div>
|
||||
<div class="result-points">
|
||||
{{ data.achieved_points }} von {{ data.max_points }} Punkten
|
||||
{% if data.max_points > 0 %}
|
||||
({{ data.percentage | round(1) }}%)
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Detaillierte Auswertung</h3>
|
||||
<div class="corrections-list">
|
||||
{% for item in data.corrections %}
|
||||
<div class="correction-item">
|
||||
<div class="correction-question">
|
||||
Aufgabe {{ loop.index }}: {{ item.question }}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Punkte:</strong> {{ item.points }}
|
||||
</div>
|
||||
{% if item.feedback %}
|
||||
<div class="correction-feedback">
|
||||
{{ item.feedback }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if data.teacher_notes %}
|
||||
<div style="background: #e3f2fd; padding: 15px; border-radius: 5px; margin-bottom: 20px;">
|
||||
<strong>Lehrerkommentar:</strong><br>
|
||||
{{ data.teacher_notes }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if data.ai_feedback %}
|
||||
<div style="background: #f3e5f5; padding: 15px; border-radius: 5px; margin-bottom: 20px;">
|
||||
<strong>KI-Feedback:</strong><br>
|
||||
{{ data.ai_feedback }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<h3>Klassenstatistik</h3>
|
||||
<table class="stats-table">
|
||||
{% if data.class_average %}
|
||||
<tr>
|
||||
<td><strong>Klassendurchschnitt:</strong></td>
|
||||
<td>{{ data.class_average }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if data.grade_distribution %}
|
||||
<tr>
|
||||
<td><strong>Notenverteilung:</strong></td>
|
||||
<td>
|
||||
{% for grade, count in data.grade_distribution.items() %}
|
||||
Note {{ grade }}: {{ count }}x{% if not loop.last %}, {% endif %}
|
||||
{% endfor %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
|
||||
<div class="signature" style="margin-top: 40px;">
|
||||
<p style="font-size: 9pt; color: #666;">Datum: {{ data.date }}</p>
|
||||
</div>
|
||||
|
||||
<div style="font-size: 8pt; color: #999; margin-top: 30px; text-align: center;">
|
||||
Erstellt mit BreakPilot | {{ generated_at }}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
73
backend-core/templates/pdf/letter.html
Normal file
73
backend-core/templates/pdf/letter.html
Normal file
@@ -0,0 +1,73 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{{ data.subject }}</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
{% if data.school_info %}
|
||||
<div class="school-name">{{ data.school_info.name }}</div>
|
||||
<div class="school-info">
|
||||
{{ data.school_info.address }}<br>
|
||||
Tel: {{ data.school_info.phone }} | E-Mail: {{ data.school_info.email }}
|
||||
{% if data.school_info.website %} | {{ data.school_info.website }}{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="school-name">Schule</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="letter-date">
|
||||
{{ data.date }}
|
||||
</div>
|
||||
|
||||
<div class="recipient">
|
||||
{{ data.recipient_name }}<br>
|
||||
{{ data.recipient_address | replace('\n', '<br>') | safe }}
|
||||
</div>
|
||||
|
||||
<div class="subject">
|
||||
Betreff: {{ data.subject }}
|
||||
</div>
|
||||
|
||||
<div class="meta-info" style="font-size: 10pt; color: #666; margin-bottom: 20px;">
|
||||
Schüler/in: {{ data.student_name }} | Klasse: {{ data.student_class }}
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
{{ data.content | replace('\n', '<br>') | safe }}
|
||||
</div>
|
||||
|
||||
{% if data.gfk_principles_applied %}
|
||||
<div style="margin-bottom: 20px;">
|
||||
{% for principle in data.gfk_principles_applied %}
|
||||
<span class="gfk-badge">GFK: {{ principle }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="signature">
|
||||
<p>Mit freundlichen Grüßen</p>
|
||||
<p style="margin-top: 30px;">
|
||||
{{ data.teacher_name }}
|
||||
{% if data.teacher_title %}<br><span style="font-size: 10pt;">{{ data.teacher_title }}</span>{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{% if data.legal_references %}
|
||||
<div class="legal-references">
|
||||
<strong>Rechtliche Grundlagen:</strong><br>
|
||||
{% for ref in data.legal_references %}
|
||||
<div style="margin: 5px 0;">
|
||||
{{ ref.law }} {{ ref.paragraph }}: {{ ref.title }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div style="font-size: 8pt; color: #999; margin-top: 30px; text-align: center;">
|
||||
Erstellt mit BreakPilot | {{ generated_at }}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user