A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.
This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).
Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
360 lines
12 KiB
Python
360 lines
12 KiB
Python
"""
|
|
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()
|