feat: Consent-Service Module nach Compliance migriert (DSR, E-Mail-Templates, Legal Docs, 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 36s
CI / test-python-backend-compliance (push) Successful in 31s
CI / test-python-document-crawler (push) Successful in 23s
CI / test-python-dsms-gateway (push) Successful in 18s

5-Phasen-Migration: Go consent-service Proxies durch native Python/FastAPI ersetzt.

Phase 1 — DSR (Betroffenenrechte): 6 Tabellen, 30 Endpoints, Frontend-API umgestellt
Phase 2 — E-Mail-Templates: 5 Tabellen, 20 Endpoints, neues Frontend, SDK_STEPS erweitert
Phase 3 — Legal Documents Extension: User Consents, Audit Log, Cookie-Kategorien
Phase 4 — Banner Consent: Device-Consents, Site-Configs, Kategorien, Vendors
Phase 5 — Cleanup: DSR-Proxy aus main.py entfernt, Frontend-URLs aktualisiert

148 neue Tests (50 + 47 + 26 + 25), alle bestanden.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-03-05 00:36:24 +01:00
parent 2211cb9349
commit b7c1a5da1a
23 changed files with 7146 additions and 542 deletions

View File

@@ -1,27 +1,18 @@
"""
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
Extended with: Public endpoints, User Consents, Consent Audit Log, Cookie Categories.
"""
import uuid as uuid_mod
import logging
from datetime import datetime
from typing import Optional, List, Any, Dict
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File
from fastapi import APIRouter, Depends, HTTPException, Query, Header, UploadFile, File
from pydantic import BaseModel
from sqlalchemy.orm import Session
from sqlalchemy import func
from classroom_engine.database import get_db
from ..db.legal_document_models import (
@@ -29,10 +20,21 @@ from ..db.legal_document_models import (
LegalDocumentVersionDB,
LegalDocumentApprovalDB,
)
from ..db.legal_document_extend_models import (
UserConsentDB,
ConsentAuditLogDB,
CookieCategoryDB,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/legal-documents", tags=["legal-documents"])
DEFAULT_TENANT = "9282a473-5c95-4b3a-bf78-0ecc0ec71d3e"
def _get_tenant(x_tenant_id: Optional[str] = Header(None, alias='X-Tenant-ID')) -> str:
return x_tenant_id or DEFAULT_TENANT
# ============================================================================
# Pydantic Schemas
@@ -432,3 +434,500 @@ async def get_approval_history(version_id: str, db: Session = Depends(get_db)):
)
for e in entries
]
# ============================================================================
# Extended Schemas
# ============================================================================
class UserConsentCreate(BaseModel):
user_id: str
document_id: str
document_version_id: Optional[str] = None
document_type: str
consented: bool = True
ip_address: Optional[str] = None
user_agent: Optional[str] = None
class CookieCategoryCreate(BaseModel):
name_de: str
name_en: Optional[str] = None
description_de: Optional[str] = None
description_en: Optional[str] = None
is_required: bool = False
sort_order: int = 0
class CookieCategoryUpdate(BaseModel):
name_de: Optional[str] = None
name_en: Optional[str] = None
description_de: Optional[str] = None
description_en: Optional[str] = None
is_required: Optional[bool] = None
sort_order: Optional[int] = None
is_active: Optional[bool] = None
# ============================================================================
# Extended Helpers
# ============================================================================
def _log_consent_audit(
db: Session,
tenant_id,
action: str,
entity_type: str,
entity_id=None,
user_id: Optional[str] = None,
details: Optional[dict] = None,
ip_address: Optional[str] = None,
):
entry = ConsentAuditLogDB(
tenant_id=tenant_id,
action=action,
entity_type=entity_type,
entity_id=entity_id,
user_id=user_id,
details=details or {},
ip_address=ip_address,
)
db.add(entry)
return entry
def _consent_to_dict(c: UserConsentDB) -> dict:
return {
"id": str(c.id),
"tenant_id": str(c.tenant_id),
"user_id": c.user_id,
"document_id": str(c.document_id),
"document_version_id": str(c.document_version_id) if c.document_version_id else None,
"document_type": c.document_type,
"consented": c.consented,
"ip_address": c.ip_address,
"user_agent": c.user_agent,
"consented_at": c.consented_at.isoformat() if c.consented_at else None,
"withdrawn_at": c.withdrawn_at.isoformat() if c.withdrawn_at else None,
"created_at": c.created_at.isoformat() if c.created_at else None,
}
def _cookie_cat_to_dict(c: CookieCategoryDB) -> dict:
return {
"id": str(c.id),
"tenant_id": str(c.tenant_id),
"name_de": c.name_de,
"name_en": c.name_en,
"description_de": c.description_de,
"description_en": c.description_en,
"is_required": c.is_required,
"sort_order": c.sort_order,
"is_active": c.is_active,
"created_at": c.created_at.isoformat() if c.created_at else None,
"updated_at": c.updated_at.isoformat() if c.updated_at else None,
}
# ============================================================================
# Public Endpoints (for end users)
# ============================================================================
@router.get("/public")
async def list_public_documents(
tenant_id: str = Depends(_get_tenant),
db: Session = Depends(get_db),
):
"""Active documents for end-user display."""
docs = (
db.query(LegalDocumentDB)
.filter(LegalDocumentDB.tenant_id == tenant_id)
.order_by(LegalDocumentDB.created_at.desc())
.all()
)
result = []
for doc in docs:
# Find latest published version
published = (
db.query(LegalDocumentVersionDB)
.filter(
LegalDocumentVersionDB.document_id == doc.id,
LegalDocumentVersionDB.status == "published",
)
.order_by(LegalDocumentVersionDB.created_at.desc())
.first()
)
if published:
result.append({
"id": str(doc.id),
"type": doc.type,
"name": doc.name,
"version": published.version,
"title": published.title,
"content": published.content,
"language": published.language,
"published_at": published.approved_at.isoformat() if published.approved_at else None,
})
return result
@router.get("/public/{document_type}/latest")
async def get_latest_published(
document_type: str,
language: str = Query("de"),
tenant_id: str = Depends(_get_tenant),
db: Session = Depends(get_db),
):
"""Get the latest published version of a document type."""
doc = (
db.query(LegalDocumentDB)
.filter(
LegalDocumentDB.tenant_id == tenant_id,
LegalDocumentDB.type == document_type,
)
.first()
)
if not doc:
raise HTTPException(status_code=404, detail=f"No document of type '{document_type}' found")
version = (
db.query(LegalDocumentVersionDB)
.filter(
LegalDocumentVersionDB.document_id == doc.id,
LegalDocumentVersionDB.status == "published",
LegalDocumentVersionDB.language == language,
)
.order_by(LegalDocumentVersionDB.created_at.desc())
.first()
)
if not version:
raise HTTPException(status_code=404, detail=f"No published version for type '{document_type}' in language '{language}'")
return {
"document_id": str(doc.id),
"type": doc.type,
"name": doc.name,
"version_id": str(version.id),
"version": version.version,
"title": version.title,
"content": version.content,
"language": version.language,
}
# ============================================================================
# User Consents
# ============================================================================
@router.post("/consents")
async def record_consent(
body: UserConsentCreate,
tenant_id: str = Depends(_get_tenant),
db: Session = Depends(get_db),
):
"""Record user consent for a legal document."""
tid = uuid_mod.UUID(tenant_id)
doc_id = uuid_mod.UUID(body.document_id)
doc = db.query(LegalDocumentDB).filter(LegalDocumentDB.id == doc_id).first()
if not doc:
raise HTTPException(status_code=404, detail="Document not found")
consent = UserConsentDB(
tenant_id=tid,
user_id=body.user_id,
document_id=doc_id,
document_version_id=uuid_mod.UUID(body.document_version_id) if body.document_version_id else None,
document_type=body.document_type,
consented=body.consented,
ip_address=body.ip_address,
user_agent=body.user_agent,
)
db.add(consent)
db.flush()
_log_consent_audit(
db, tid, "consent_given", "user_consent",
entity_id=consent.id, user_id=body.user_id,
details={"document_type": body.document_type, "document_id": body.document_id},
ip_address=body.ip_address,
)
db.commit()
db.refresh(consent)
return _consent_to_dict(consent)
@router.get("/consents/my")
async def get_my_consents(
user_id: str = Query(...),
tenant_id: str = Depends(_get_tenant),
db: Session = Depends(get_db),
):
"""Get all consents for a specific user."""
tid = uuid_mod.UUID(tenant_id)
consents = (
db.query(UserConsentDB)
.filter(
UserConsentDB.tenant_id == tid,
UserConsentDB.user_id == user_id,
UserConsentDB.withdrawn_at == None,
)
.order_by(UserConsentDB.consented_at.desc())
.all()
)
return [_consent_to_dict(c) for c in consents]
@router.get("/consents/check/{document_type}")
async def check_consent(
document_type: str,
user_id: str = Query(...),
tenant_id: str = Depends(_get_tenant),
db: Session = Depends(get_db),
):
"""Check if user has active consent for a document type."""
tid = uuid_mod.UUID(tenant_id)
consent = (
db.query(UserConsentDB)
.filter(
UserConsentDB.tenant_id == tid,
UserConsentDB.user_id == user_id,
UserConsentDB.document_type == document_type,
UserConsentDB.consented == True,
UserConsentDB.withdrawn_at == None,
)
.order_by(UserConsentDB.consented_at.desc())
.first()
)
return {
"has_consent": consent is not None,
"consent": _consent_to_dict(consent) if consent else None,
}
@router.delete("/consents/{consent_id}")
async def withdraw_consent(
consent_id: str,
tenant_id: str = Depends(_get_tenant),
db: Session = Depends(get_db),
):
"""Withdraw a consent (DSGVO Art. 7 Abs. 3)."""
tid = uuid_mod.UUID(tenant_id)
try:
cid = uuid_mod.UUID(consent_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid consent ID")
consent = db.query(UserConsentDB).filter(
UserConsentDB.id == cid,
UserConsentDB.tenant_id == tid,
).first()
if not consent:
raise HTTPException(status_code=404, detail="Consent not found")
if consent.withdrawn_at:
raise HTTPException(status_code=400, detail="Consent already withdrawn")
consent.withdrawn_at = datetime.utcnow()
consent.consented = False
_log_consent_audit(
db, tid, "consent_withdrawn", "user_consent",
entity_id=cid, user_id=consent.user_id,
details={"document_type": consent.document_type},
)
db.commit()
db.refresh(consent)
return _consent_to_dict(consent)
# ============================================================================
# Consent Statistics
# ============================================================================
@router.get("/stats/consents")
async def get_consent_stats(
tenant_id: str = Depends(_get_tenant),
db: Session = Depends(get_db),
):
"""Consent statistics for dashboard."""
tid = uuid_mod.UUID(tenant_id)
base = db.query(UserConsentDB).filter(UserConsentDB.tenant_id == tid)
total = base.count()
active = base.filter(
UserConsentDB.consented == True,
UserConsentDB.withdrawn_at == None,
).count()
withdrawn = base.filter(UserConsentDB.withdrawn_at != None).count()
# By document type
by_type = {}
type_counts = (
db.query(UserConsentDB.document_type, func.count(UserConsentDB.id))
.filter(UserConsentDB.tenant_id == tid)
.group_by(UserConsentDB.document_type)
.all()
)
for dtype, count in type_counts:
by_type[dtype] = count
# Unique users
unique_users = (
db.query(func.count(func.distinct(UserConsentDB.user_id)))
.filter(UserConsentDB.tenant_id == tid)
.scalar()
) or 0
return {
"total": total,
"active": active,
"withdrawn": withdrawn,
"unique_users": unique_users,
"by_type": by_type,
}
# ============================================================================
# Audit Log
# ============================================================================
@router.get("/audit-log")
async def get_audit_log(
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
action: Optional[str] = Query(None),
entity_type: Optional[str] = Query(None),
tenant_id: str = Depends(_get_tenant),
db: Session = Depends(get_db),
):
"""Consent audit trail (paginated)."""
tid = uuid_mod.UUID(tenant_id)
query = db.query(ConsentAuditLogDB).filter(ConsentAuditLogDB.tenant_id == tid)
if action:
query = query.filter(ConsentAuditLogDB.action == action)
if entity_type:
query = query.filter(ConsentAuditLogDB.entity_type == entity_type)
total = query.count()
entries = query.order_by(ConsentAuditLogDB.created_at.desc()).offset(offset).limit(limit).all()
return {
"entries": [
{
"id": str(e.id),
"action": e.action,
"entity_type": e.entity_type,
"entity_id": str(e.entity_id) if e.entity_id else None,
"user_id": e.user_id,
"details": e.details or {},
"ip_address": e.ip_address,
"created_at": e.created_at.isoformat() if e.created_at else None,
}
for e in entries
],
"total": total,
"limit": limit,
"offset": offset,
}
# ============================================================================
# Cookie Categories CRUD
# ============================================================================
@router.get("/cookie-categories")
async def list_cookie_categories(
tenant_id: str = Depends(_get_tenant),
db: Session = Depends(get_db),
):
"""List all cookie categories."""
tid = uuid_mod.UUID(tenant_id)
cats = (
db.query(CookieCategoryDB)
.filter(CookieCategoryDB.tenant_id == tid)
.order_by(CookieCategoryDB.sort_order)
.all()
)
return [_cookie_cat_to_dict(c) for c in cats]
@router.post("/cookie-categories")
async def create_cookie_category(
body: CookieCategoryCreate,
tenant_id: str = Depends(_get_tenant),
db: Session = Depends(get_db),
):
"""Create a cookie category."""
tid = uuid_mod.UUID(tenant_id)
cat = CookieCategoryDB(
tenant_id=tid,
name_de=body.name_de,
name_en=body.name_en,
description_de=body.description_de,
description_en=body.description_en,
is_required=body.is_required,
sort_order=body.sort_order,
)
db.add(cat)
db.commit()
db.refresh(cat)
return _cookie_cat_to_dict(cat)
@router.put("/cookie-categories/{category_id}")
async def update_cookie_category(
category_id: str,
body: CookieCategoryUpdate,
tenant_id: str = Depends(_get_tenant),
db: Session = Depends(get_db),
):
"""Update a cookie category."""
tid = uuid_mod.UUID(tenant_id)
try:
cid = uuid_mod.UUID(category_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid category ID")
cat = db.query(CookieCategoryDB).filter(
CookieCategoryDB.id == cid,
CookieCategoryDB.tenant_id == tid,
).first()
if not cat:
raise HTTPException(status_code=404, detail="Cookie category not found")
for field in ["name_de", "name_en", "description_de", "description_en",
"is_required", "sort_order", "is_active"]:
val = getattr(body, field, None)
if val is not None:
setattr(cat, field, val)
cat.updated_at = datetime.utcnow()
db.commit()
db.refresh(cat)
return _cookie_cat_to_dict(cat)
@router.delete("/cookie-categories/{category_id}", status_code=204)
async def delete_cookie_category(
category_id: str,
tenant_id: str = Depends(_get_tenant),
db: Session = Depends(get_db),
):
"""Delete a cookie category."""
tid = uuid_mod.UUID(tenant_id)
try:
cid = uuid_mod.UUID(category_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid category ID")
cat = db.query(CookieCategoryDB).filter(
CookieCategoryDB.id == cid,
CookieCategoryDB.tenant_id == tid,
).first()
if not cat:
raise HTTPException(status_code=404, detail="Cookie category not found")
db.delete(cat)
db.commit()