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
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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user