feat: Package 4 Rechtliche Texte — DB-Persistenz fuer Legal Documents, Einwilligungen und Cookie Banner
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Successful in 46s
CI / test-python-backend-compliance (push) Successful in 32s
CI / test-python-document-crawler (push) Successful in 22s
CI / test-python-dsms-gateway (push) Successful in 17s

- Migration 007: compliance_legal_documents, _versions, _approvals (Approval-Workflow)
- Migration 008: compliance_einwilligungen_catalog, _company, _cookies, _consents
- Backend: legal_document_routes.py (11 Endpoints + draft→review→approved→published Workflow)
- Backend: einwilligungen_routes.py (10 Endpoints inkl. Stats, Pagination, Revoke)
- Frontend: /api/admin/consent/[[...path]] Catch-All-Proxy fuer Legal Documents
- Frontend: catalog/consent/cookie-banner routes von In-Memory auf DB-Proxy umgestellt
- Frontend: einwilligungen/page.tsx + cookie-banner/page.tsx laden jetzt via API (kein Mock)
- Tests: 44/44 pass (test_legal_document_routes.py + test_einwilligungen_routes.py)
- Deploy-Scripts: apply_legal_docs_migration.sh + apply_einwilligungen_migration.sh

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-03-03 08:25:13 +01:00
parent 799668e472
commit 113ecdfa77
17 changed files with 2501 additions and 664 deletions

View File

@@ -10,6 +10,8 @@ from .scraper_routes import router as scraper_router
from .module_routes import router as module_router
from .isms_routes import router as isms_router
from .vvt_routes import router as vvt_router
from .legal_document_routes import router as legal_document_router
from .einwilligungen_routes import router as einwilligungen_router
# Include sub-routers
router.include_router(audit_router)
@@ -21,6 +23,8 @@ router.include_router(scraper_router)
router.include_router(module_router)
router.include_router(isms_router)
router.include_router(vvt_router)
router.include_router(legal_document_router)
router.include_router(einwilligungen_router)
__all__ = [
"router",
@@ -33,4 +37,6 @@ __all__ = [
"module_router",
"isms_router",
"vvt_router",
"legal_document_router",
"einwilligungen_router",
]

View File

@@ -0,0 +1,390 @@
"""
FastAPI routes for Einwilligungen — Consent-Tracking, Cookie-Banner und Datenpunktkatalog.
Endpoints:
GET /einwilligungen/catalog — Katalog laden
PUT /einwilligungen/catalog — Katalog speichern (Upsert by tenant_id)
GET /einwilligungen/company — Firmeninfo laden
PUT /einwilligungen/company — Firmeninfo speichern (Upsert)
GET /einwilligungen/cookies — Cookie-Banner-Config laden
PUT /einwilligungen/cookies — Cookie-Banner-Config speichern (Upsert)
GET /einwilligungen/consents — Consent-Liste (Pagination + Filter)
POST /einwilligungen/consents — Consent erfassen
PUT /einwilligungen/consents/{id}/revoke — Consent widerrufen
GET /einwilligungen/consents/stats — Statistiken
"""
import logging
from datetime import datetime
from typing import Optional, List, Any, Dict
from fastapi import APIRouter, Depends, HTTPException, Query, Header
from pydantic import BaseModel
from sqlalchemy.orm import Session
from classroom_engine.database import get_db
from ..db.einwilligungen_models import (
EinwilligungenCatalogDB,
EinwilligungenCompanyDB,
EinwilligungenCookiesDB,
EinwilligungenConsentDB,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/einwilligungen", tags=["einwilligungen"])
# ============================================================================
# Pydantic Schemas
# ============================================================================
class CatalogUpsert(BaseModel):
selected_data_point_ids: List[str] = []
custom_data_points: List[Dict[str, Any]] = []
class CompanyUpsert(BaseModel):
data: Dict[str, Any] = {}
class CookiesUpsert(BaseModel):
categories: List[Dict[str, Any]] = []
config: Dict[str, Any] = {}
class ConsentCreate(BaseModel):
user_id: str
data_point_id: str
granted: bool
consent_version: str = '1.0'
source: Optional[str] = None
ip_address: Optional[str] = None
user_agent: Optional[str] = None
# ============================================================================
# Helpers
# ============================================================================
def _get_tenant(x_tenant_id: Optional[str] = Header(None, alias='X-Tenant-ID')) -> str:
if not x_tenant_id:
raise HTTPException(status_code=400, detail="X-Tenant-ID header required")
return x_tenant_id
# ============================================================================
# Catalog
# ============================================================================
@router.get("/catalog")
async def get_catalog(
tenant_id: str = Depends(_get_tenant),
db: Session = Depends(get_db),
):
"""Load the data point catalog for a tenant."""
record = db.query(EinwilligungenCatalogDB).filter(
EinwilligungenCatalogDB.tenant_id == tenant_id
).first()
if not record:
return {
"tenant_id": tenant_id,
"selected_data_point_ids": [],
"custom_data_points": [],
"updated_at": None,
}
return {
"tenant_id": tenant_id,
"selected_data_point_ids": record.selected_data_point_ids or [],
"custom_data_points": record.custom_data_points or [],
"updated_at": record.updated_at,
}
@router.put("/catalog")
async def upsert_catalog(
request: CatalogUpsert,
tenant_id: str = Depends(_get_tenant),
db: Session = Depends(get_db),
):
"""Create or update the data point catalog for a tenant."""
record = db.query(EinwilligungenCatalogDB).filter(
EinwilligungenCatalogDB.tenant_id == tenant_id
).first()
if record:
record.selected_data_point_ids = request.selected_data_point_ids
record.custom_data_points = request.custom_data_points
record.updated_at = datetime.utcnow()
else:
record = EinwilligungenCatalogDB(
tenant_id=tenant_id,
selected_data_point_ids=request.selected_data_point_ids,
custom_data_points=request.custom_data_points,
)
db.add(record)
db.commit()
db.refresh(record)
return {
"success": True,
"tenant_id": tenant_id,
"selected_data_point_ids": record.selected_data_point_ids,
"custom_data_points": record.custom_data_points,
"updated_at": record.updated_at,
}
# ============================================================================
# Company Info
# ============================================================================
@router.get("/company")
async def get_company(
tenant_id: str = Depends(_get_tenant),
db: Session = Depends(get_db),
):
"""Load company information for DSI generation."""
record = db.query(EinwilligungenCompanyDB).filter(
EinwilligungenCompanyDB.tenant_id == tenant_id
).first()
if not record:
return {"tenant_id": tenant_id, "data": {}, "updated_at": None}
return {"tenant_id": tenant_id, "data": record.data or {}, "updated_at": record.updated_at}
@router.put("/company")
async def upsert_company(
request: CompanyUpsert,
tenant_id: str = Depends(_get_tenant),
db: Session = Depends(get_db),
):
"""Create or update company information for a tenant."""
record = db.query(EinwilligungenCompanyDB).filter(
EinwilligungenCompanyDB.tenant_id == tenant_id
).first()
if record:
record.data = request.data
record.updated_at = datetime.utcnow()
else:
record = EinwilligungenCompanyDB(tenant_id=tenant_id, data=request.data)
db.add(record)
db.commit()
db.refresh(record)
return {"success": True, "tenant_id": tenant_id, "data": record.data, "updated_at": record.updated_at}
# ============================================================================
# Cookie Banner Config
# ============================================================================
@router.get("/cookies")
async def get_cookies(
tenant_id: str = Depends(_get_tenant),
db: Session = Depends(get_db),
):
"""Load cookie banner configuration for a tenant."""
record = db.query(EinwilligungenCookiesDB).filter(
EinwilligungenCookiesDB.tenant_id == tenant_id
).first()
if not record:
return {"tenant_id": tenant_id, "categories": [], "config": {}, "updated_at": None}
return {
"tenant_id": tenant_id,
"categories": record.categories or [],
"config": record.config or {},
"updated_at": record.updated_at,
}
@router.put("/cookies")
async def upsert_cookies(
request: CookiesUpsert,
tenant_id: str = Depends(_get_tenant),
db: Session = Depends(get_db),
):
"""Create or update cookie banner configuration for a tenant."""
record = db.query(EinwilligungenCookiesDB).filter(
EinwilligungenCookiesDB.tenant_id == tenant_id
).first()
if record:
record.categories = request.categories
record.config = request.config
record.updated_at = datetime.utcnow()
else:
record = EinwilligungenCookiesDB(
tenant_id=tenant_id,
categories=request.categories,
config=request.config,
)
db.add(record)
db.commit()
db.refresh(record)
return {
"success": True,
"tenant_id": tenant_id,
"categories": record.categories,
"config": record.config,
"updated_at": record.updated_at,
}
# ============================================================================
# Consents
# ============================================================================
@router.get("/consents/stats")
async def get_consent_stats(
tenant_id: str = Depends(_get_tenant),
db: Session = Depends(get_db),
):
"""Get consent statistics for a tenant."""
all_consents = db.query(EinwilligungenConsentDB).filter(
EinwilligungenConsentDB.tenant_id == tenant_id
).all()
total = len(all_consents)
active = sum(1 for c in all_consents if c.granted and not c.revoked_at)
revoked = sum(1 for c in all_consents if c.revoked_at)
# Unique users
unique_users = len(set(c.user_id for c in all_consents))
users_with_active = len(set(c.user_id for c in all_consents if c.granted and not c.revoked_at))
conversion_rate = round((users_with_active / unique_users * 100), 1) if unique_users > 0 else 0.0
# By data point
by_data_point: Dict[str, Dict] = {}
for c in all_consents:
dp = c.data_point_id
if dp not in by_data_point:
by_data_point[dp] = {"total": 0, "active": 0, "revoked": 0}
by_data_point[dp]["total"] += 1
if c.granted and not c.revoked_at:
by_data_point[dp]["active"] += 1
if c.revoked_at:
by_data_point[dp]["revoked"] += 1
return {
"total_consents": total,
"active_consents": active,
"revoked_consents": revoked,
"unique_users": unique_users,
"conversion_rate": conversion_rate,
"by_data_point": by_data_point,
}
@router.get("/consents")
async def list_consents(
tenant_id: str = Depends(_get_tenant),
user_id: Optional[str] = Query(None),
data_point_id: Optional[str] = Query(None),
granted: Optional[bool] = Query(None),
limit: int = Query(50, ge=1, le=500),
offset: int = Query(0, ge=0),
db: Session = Depends(get_db),
):
"""List consent records with optional filters and pagination."""
query = db.query(EinwilligungenConsentDB).filter(
EinwilligungenConsentDB.tenant_id == tenant_id
)
if user_id:
query = query.filter(EinwilligungenConsentDB.user_id == user_id)
if data_point_id:
query = query.filter(EinwilligungenConsentDB.data_point_id == data_point_id)
if granted is not None:
query = query.filter(EinwilligungenConsentDB.granted == granted)
total = query.count()
consents = query.order_by(EinwilligungenConsentDB.created_at.desc()).offset(offset).limit(limit).all()
return {
"total": total,
"offset": offset,
"limit": limit,
"consents": [
{
"id": str(c.id),
"tenant_id": c.tenant_id,
"user_id": c.user_id,
"data_point_id": c.data_point_id,
"granted": c.granted,
"granted_at": c.granted_at,
"revoked_at": c.revoked_at,
"consent_version": c.consent_version,
"source": c.source,
"created_at": c.created_at,
}
for c in consents
],
}
@router.post("/consents", status_code=201)
async def create_consent(
request: ConsentCreate,
tenant_id: str = Depends(_get_tenant),
db: Session = Depends(get_db),
):
"""Record a new consent entry."""
consent = EinwilligungenConsentDB(
tenant_id=tenant_id,
user_id=request.user_id,
data_point_id=request.data_point_id,
granted=request.granted,
granted_at=datetime.utcnow(),
consent_version=request.consent_version,
source=request.source,
ip_address=request.ip_address,
user_agent=request.user_agent,
)
db.add(consent)
db.commit()
db.refresh(consent)
return {
"success": True,
"id": str(consent.id),
"user_id": consent.user_id,
"data_point_id": consent.data_point_id,
"granted": consent.granted,
"granted_at": consent.granted_at,
}
@router.put("/consents/{consent_id}/revoke")
async def revoke_consent(
consent_id: str,
tenant_id: str = Depends(_get_tenant),
db: Session = Depends(get_db),
):
"""Revoke an active consent."""
consent = db.query(EinwilligungenConsentDB).filter(
EinwilligungenConsentDB.id == consent_id,
EinwilligungenConsentDB.tenant_id == tenant_id,
).first()
if not consent:
raise HTTPException(status_code=404, detail=f"Consent {consent_id} not found")
if consent.revoked_at:
raise HTTPException(status_code=400, detail="Consent is already revoked")
consent.revoked_at = datetime.utcnow()
db.commit()
db.refresh(consent)
return {
"success": True,
"id": str(consent.id),
"revoked_at": consent.revoked_at,
}

View File

@@ -0,0 +1,406 @@
"""
FastAPI routes for Legal Documents — Rechtliche Texte mit Versionierung und Approval-Workflow.
Endpoints:
GET /legal-documents/documents — Liste aller Dokumente
POST /legal-documents/documents — Dokument erstellen
GET /legal-documents/documents/{id}/versions — Versionen eines Dokuments
POST /legal-documents/versions — Neue Version erstellen
PUT /legal-documents/versions/{id} — Version aktualisieren
POST /legal-documents/versions/upload-word — DOCX → HTML
POST /legal-documents/versions/{id}/submit-review — Status: draft → review
POST /legal-documents/versions/{id}/approve — Status: review → approved
POST /legal-documents/versions/{id}/reject — Status: review → rejected
POST /legal-documents/versions/{id}/publish — Status: approved → published
GET /legal-documents/versions/{id}/approval-history — Approval-Audit-Trail
"""
import logging
from datetime import datetime
from typing import Optional, List, Any, Dict
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File
from pydantic import BaseModel
from sqlalchemy.orm import Session
from classroom_engine.database import get_db
from ..db.legal_document_models import (
LegalDocumentDB,
LegalDocumentVersionDB,
LegalDocumentApprovalDB,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/legal-documents", tags=["legal-documents"])
# ============================================================================
# Pydantic Schemas
# ============================================================================
class DocumentCreate(BaseModel):
type: str
name: str
description: Optional[str] = None
mandatory: bool = False
tenant_id: Optional[str] = None
class DocumentResponse(BaseModel):
id: str
tenant_id: Optional[str]
type: str
name: str
description: Optional[str]
mandatory: bool
created_at: datetime
updated_at: Optional[datetime]
class VersionCreate(BaseModel):
document_id: str
version: str
language: str = 'de'
title: str
content: str
summary: Optional[str] = None
created_by: Optional[str] = None
class VersionUpdate(BaseModel):
title: Optional[str] = None
content: Optional[str] = None
summary: Optional[str] = None
version: Optional[str] = None
language: Optional[str] = None
class VersionResponse(BaseModel):
id: str
document_id: str
version: str
language: str
title: str
content: str
summary: Optional[str]
status: str
created_by: Optional[str]
approved_by: Optional[str]
approved_at: Optional[datetime]
rejection_reason: Optional[str]
created_at: datetime
updated_at: Optional[datetime]
class ApprovalHistoryEntry(BaseModel):
id: str
version_id: str
action: str
approver: Optional[str]
comment: Optional[str]
created_at: datetime
class ActionRequest(BaseModel):
approver: Optional[str] = None
comment: Optional[str] = None
# ============================================================================
# Helpers
# ============================================================================
def _doc_to_response(doc: LegalDocumentDB) -> DocumentResponse:
return DocumentResponse(
id=str(doc.id),
tenant_id=doc.tenant_id,
type=doc.type,
name=doc.name,
description=doc.description,
mandatory=doc.mandatory or False,
created_at=doc.created_at,
updated_at=doc.updated_at,
)
def _version_to_response(v: LegalDocumentVersionDB) -> VersionResponse:
return VersionResponse(
id=str(v.id),
document_id=str(v.document_id),
version=v.version,
language=v.language or 'de',
title=v.title,
content=v.content,
summary=v.summary,
status=v.status or 'draft',
created_by=v.created_by,
approved_by=v.approved_by,
approved_at=v.approved_at,
rejection_reason=v.rejection_reason,
created_at=v.created_at,
updated_at=v.updated_at,
)
def _log_approval(
db: Session,
version_id: Any,
action: str,
approver: Optional[str] = None,
comment: Optional[str] = None,
) -> LegalDocumentApprovalDB:
entry = LegalDocumentApprovalDB(
version_id=version_id,
action=action,
approver=approver,
comment=comment,
)
db.add(entry)
return entry
# ============================================================================
# Documents
# ============================================================================
@router.get("/documents", response_model=Dict[str, Any])
async def list_documents(
tenant_id: Optional[str] = Query(None),
type: Optional[str] = Query(None),
db: Session = Depends(get_db),
):
"""List all legal documents, optionally filtered by tenant or type."""
query = db.query(LegalDocumentDB)
if tenant_id:
query = query.filter(LegalDocumentDB.tenant_id == tenant_id)
if type:
query = query.filter(LegalDocumentDB.type == type)
docs = query.order_by(LegalDocumentDB.created_at.desc()).all()
return {"documents": [_doc_to_response(d).dict() for d in docs]}
@router.post("/documents", response_model=DocumentResponse, status_code=201)
async def create_document(
request: DocumentCreate,
db: Session = Depends(get_db),
):
"""Create a new legal document type."""
doc = LegalDocumentDB(
tenant_id=request.tenant_id,
type=request.type,
name=request.name,
description=request.description,
mandatory=request.mandatory,
)
db.add(doc)
db.commit()
db.refresh(doc)
return _doc_to_response(doc)
@router.get("/documents/{document_id}/versions", response_model=List[VersionResponse])
async def list_versions(document_id: str, db: Session = Depends(get_db)):
"""List all versions for a legal document."""
doc = db.query(LegalDocumentDB).filter(LegalDocumentDB.id == document_id).first()
if not doc:
raise HTTPException(status_code=404, detail=f"Document {document_id} not found")
versions = (
db.query(LegalDocumentVersionDB)
.filter(LegalDocumentVersionDB.document_id == document_id)
.order_by(LegalDocumentVersionDB.created_at.desc())
.all()
)
return [_version_to_response(v) for v in versions]
# ============================================================================
# Versions
# ============================================================================
@router.post("/versions", response_model=VersionResponse, status_code=201)
async def create_version(
request: VersionCreate,
db: Session = Depends(get_db),
):
"""Create a new version for a legal document."""
doc = db.query(LegalDocumentDB).filter(LegalDocumentDB.id == request.document_id).first()
if not doc:
raise HTTPException(status_code=404, detail=f"Document {request.document_id} not found")
version = LegalDocumentVersionDB(
document_id=request.document_id,
version=request.version,
language=request.language,
title=request.title,
content=request.content,
summary=request.summary,
created_by=request.created_by,
status='draft',
)
db.add(version)
db.flush()
_log_approval(db, version.id, action='created', approver=request.created_by)
db.commit()
db.refresh(version)
return _version_to_response(version)
@router.put("/versions/{version_id}", response_model=VersionResponse)
async def update_version(
version_id: str,
request: VersionUpdate,
db: Session = Depends(get_db),
):
"""Update a draft legal document version."""
version = db.query(LegalDocumentVersionDB).filter(LegalDocumentVersionDB.id == version_id).first()
if not version:
raise HTTPException(status_code=404, detail=f"Version {version_id} not found")
if version.status not in ('draft', 'rejected'):
raise HTTPException(status_code=400, detail=f"Only draft/rejected versions can be edited (current: {version.status})")
for field, value in request.dict(exclude_none=True).items():
setattr(version, field, value)
version.updated_at = datetime.utcnow()
db.commit()
db.refresh(version)
return _version_to_response(version)
@router.post("/versions/upload-word", response_model=Dict[str, Any])
async def upload_word(file: UploadFile = File(...)):
"""Convert DOCX to HTML using mammoth (if available) or return raw text."""
if not file.filename or not file.filename.lower().endswith('.docx'):
raise HTTPException(status_code=400, detail="Only .docx files are supported")
content_bytes = await file.read()
html_content = ""
try:
import mammoth # type: ignore
import io
result = mammoth.convert_to_html(io.BytesIO(content_bytes))
html_content = result.value
except ImportError:
# Fallback: return placeholder if mammoth not installed
html_content = f"<p>[DOCX-Import: {file.filename}]</p><p>Bitte installieren Sie 'mammoth' fuer DOCX-Konvertierung.</p>"
return {"html": html_content, "filename": file.filename}
# ============================================================================
# Approval Workflow Actions
# ============================================================================
def _transition(
db: Session,
version_id: str,
from_statuses: List[str],
to_status: str,
action: str,
approver: Optional[str],
comment: Optional[str],
extra_updates: Optional[Dict] = None,
) -> VersionResponse:
version = db.query(LegalDocumentVersionDB).filter(LegalDocumentVersionDB.id == version_id).first()
if not version:
raise HTTPException(status_code=404, detail=f"Version {version_id} not found")
if version.status not in from_statuses:
raise HTTPException(
status_code=400,
detail=f"Cannot perform '{action}' on version with status '{version.status}' (expected: {from_statuses})"
)
version.status = to_status
version.updated_at = datetime.utcnow()
if extra_updates:
for k, v in extra_updates.items():
setattr(version, k, v)
_log_approval(db, version.id, action=action, approver=approver, comment=comment)
db.commit()
db.refresh(version)
return _version_to_response(version)
@router.post("/versions/{version_id}/submit-review", response_model=VersionResponse)
async def submit_review(
version_id: str,
request: ActionRequest,
db: Session = Depends(get_db),
):
"""Submit a draft version for review."""
return _transition(db, version_id, ['draft', 'rejected'], 'review', 'submitted', request.approver, request.comment)
@router.post("/versions/{version_id}/approve", response_model=VersionResponse)
async def approve_version(
version_id: str,
request: ActionRequest,
db: Session = Depends(get_db),
):
"""Approve a version under review."""
return _transition(
db, version_id, ['review'], 'approved', 'approved',
request.approver, request.comment,
extra_updates={'approved_by': request.approver, 'approved_at': datetime.utcnow()}
)
@router.post("/versions/{version_id}/reject", response_model=VersionResponse)
async def reject_version(
version_id: str,
request: ActionRequest,
db: Session = Depends(get_db),
):
"""Reject a version under review."""
return _transition(
db, version_id, ['review'], 'rejected', 'rejected',
request.approver, request.comment,
extra_updates={'rejection_reason': request.comment}
)
@router.post("/versions/{version_id}/publish", response_model=VersionResponse)
async def publish_version(
version_id: str,
request: ActionRequest,
db: Session = Depends(get_db),
):
"""Publish an approved version."""
return _transition(db, version_id, ['approved'], 'published', 'published', request.approver, request.comment)
# ============================================================================
# Approval History
# ============================================================================
@router.get("/versions/{version_id}/approval-history", response_model=List[ApprovalHistoryEntry])
async def get_approval_history(version_id: str, db: Session = Depends(get_db)):
"""Get the full approval audit trail for a version."""
version = db.query(LegalDocumentVersionDB).filter(LegalDocumentVersionDB.id == version_id).first()
if not version:
raise HTTPException(status_code=404, detail=f"Version {version_id} not found")
entries = (
db.query(LegalDocumentApprovalDB)
.filter(LegalDocumentApprovalDB.version_id == version_id)
.order_by(LegalDocumentApprovalDB.created_at.asc())
.all()
)
return [
ApprovalHistoryEntry(
id=str(e.id),
version_id=str(e.version_id),
action=e.action,
approver=e.approver,
comment=e.comment,
created_at=e.created_at,
)
for e in entries
]