feat(sdk): Multi-Tenancy, Versionierung, Change-Requests, Dokumentengenerierung (Phase 1-6)
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 32s
CI / test-python-backend-compliance (push) Successful in 30s
CI / test-python-document-crawler (push) Successful in 21s
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 32s
CI / test-python-backend-compliance (push) Successful in 30s
CI / test-python-document-crawler (push) Successful in 21s
CI / test-python-dsms-gateway (push) Successful in 18s
6-Phasen-Implementation fuer cloud-faehiges, mandantenfaehiges Compliance SDK:
Phase 1: Multi-Tenancy Fix
- Shared tenant_utils.py Dependency (UUID-Validierung, kein "default" mehr)
- VVT tenant_id Column + tenant-scoped Queries
- DSFA/Vendor DEFAULT_TENANT_ID von "default" auf UUID migriert
- Migration 035
Phase 2: Stammdaten-Erweiterung
- Company Profile um JSONB-Felder erweitert (processing_systems, ai_systems, technical_contacts)
- Regulierungs-Flags (NIS2, AI Act, ISO 27001)
- GET /template-context Endpoint
- Migration 036
Phase 3: Dokument-Versionierung
- 5 Versions-Tabellen (DSFA, VVT, TOM, Loeschfristen, Obligations)
- Shared versioning_utils.py Helper
- /{id}/versions Endpoints auf allen 5 Dokumenttypen
- Migration 037
Phase 4: Change-Request System
- Zentrale CR-Inbox mit CRUD + Accept/Reject/Edit Workflow
- Regelbasierte CR-Engine (VVT DPIA → DSFA CR, Datenkategorien → Loeschfristen CR)
- Audit-Trail
- Migration 038
Phase 5: Dokumentengenerierung
- 5 Template-Generatoren (DSFA, VVT, TOM, Loeschfristen, Obligations)
- Preview + Apply Endpoints (erzeugt CRs, keine direkten Dokumente)
Phase 6: Frontend-Integration
- Change-Request Inbox Page mit Stats, Filtern, Modals
- VersionHistory Timeline-Komponente
- SDKSidebar CR-Badge (60s Polling)
- Company Profile: 2 neue Wizard-Steps + "Dokumente generieren" CTA
Docs: 5 neue MkDocs-Seiten, CLAUDE.md aktualisiert
Tests: 97 neue Tests (alle bestanden)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -33,6 +33,7 @@ from .schemas import (
|
||||
VVTActivityCreate, VVTActivityUpdate, VVTActivityResponse,
|
||||
VVTStatsResponse, VVTAuditLogEntry,
|
||||
)
|
||||
from .tenant_utils import get_tenant_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/vvt", tags=["compliance-vvt"])
|
||||
@@ -40,6 +41,7 @@ router = APIRouter(prefix="/vvt", tags=["compliance-vvt"])
|
||||
|
||||
def _log_audit(
|
||||
db: Session,
|
||||
tenant_id: str,
|
||||
action: str,
|
||||
entity_type: str,
|
||||
entity_id=None,
|
||||
@@ -48,6 +50,7 @@ def _log_audit(
|
||||
new_values=None,
|
||||
):
|
||||
entry = VVTAuditLogDB(
|
||||
tenant_id=tenant_id,
|
||||
action=action,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
@@ -63,9 +66,17 @@ def _log_audit(
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/organization", response_model=Optional[VVTOrganizationResponse])
|
||||
async def get_organization(db: Session = Depends(get_db)):
|
||||
"""Load the VVT organization header (single record)."""
|
||||
org = db.query(VVTOrganizationDB).order_by(VVTOrganizationDB.created_at).first()
|
||||
async def get_organization(
|
||||
tid: str = Depends(get_tenant_id),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Load the VVT organization header for the given tenant."""
|
||||
org = (
|
||||
db.query(VVTOrganizationDB)
|
||||
.filter(VVTOrganizationDB.tenant_id == tid)
|
||||
.order_by(VVTOrganizationDB.created_at)
|
||||
.first()
|
||||
)
|
||||
if not org:
|
||||
return None
|
||||
return VVTOrganizationResponse(
|
||||
@@ -88,15 +99,22 @@ async def get_organization(db: Session = Depends(get_db)):
|
||||
@router.put("/organization", response_model=VVTOrganizationResponse)
|
||||
async def upsert_organization(
|
||||
request: VVTOrganizationUpdate,
|
||||
tid: str = Depends(get_tenant_id),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Create or update the VVT organization header."""
|
||||
org = db.query(VVTOrganizationDB).order_by(VVTOrganizationDB.created_at).first()
|
||||
org = (
|
||||
db.query(VVTOrganizationDB)
|
||||
.filter(VVTOrganizationDB.tenant_id == tid)
|
||||
.order_by(VVTOrganizationDB.created_at)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not org:
|
||||
data = request.dict(exclude_none=True)
|
||||
if 'organization_name' not in data:
|
||||
data['organization_name'] = 'Meine Organisation'
|
||||
data['tenant_id'] = tid
|
||||
org = VVTOrganizationDB(**data)
|
||||
db.add(org)
|
||||
else:
|
||||
@@ -168,10 +186,11 @@ async def list_activities(
|
||||
business_function: Optional[str] = Query(None),
|
||||
search: Optional[str] = Query(None),
|
||||
review_overdue: Optional[bool] = Query(None),
|
||||
tid: str = Depends(get_tenant_id),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""List all processing activities with optional filters."""
|
||||
query = db.query(VVTActivityDB)
|
||||
query = db.query(VVTActivityDB).filter(VVTActivityDB.tenant_id == tid)
|
||||
|
||||
if status:
|
||||
query = query.filter(VVTActivityDB.status == status)
|
||||
@@ -199,12 +218,14 @@ async def list_activities(
|
||||
async def create_activity(
|
||||
request: VVTActivityCreate,
|
||||
http_request: Request,
|
||||
tid: str = Depends(get_tenant_id),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Create a new processing activity."""
|
||||
# Check for duplicate vvt_id
|
||||
# Check for duplicate vvt_id within tenant
|
||||
existing = db.query(VVTActivityDB).filter(
|
||||
VVTActivityDB.vvt_id == request.vvt_id
|
||||
VVTActivityDB.tenant_id == tid,
|
||||
VVTActivityDB.vvt_id == request.vvt_id,
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
@@ -213,6 +234,7 @@ async def create_activity(
|
||||
)
|
||||
|
||||
data = request.dict()
|
||||
data['tenant_id'] = tid
|
||||
# Set created_by from X-User-ID header if not provided in body
|
||||
if not data.get('created_by'):
|
||||
data['created_by'] = http_request.headers.get('X-User-ID', 'system')
|
||||
@@ -223,6 +245,7 @@ async def create_activity(
|
||||
|
||||
_log_audit(
|
||||
db,
|
||||
tenant_id=tid,
|
||||
action="CREATE",
|
||||
entity_type="activity",
|
||||
entity_id=act.id,
|
||||
@@ -235,9 +258,16 @@ async def create_activity(
|
||||
|
||||
|
||||
@router.get("/activities/{activity_id}", response_model=VVTActivityResponse)
|
||||
async def get_activity(activity_id: str, db: Session = Depends(get_db)):
|
||||
async def get_activity(
|
||||
activity_id: str,
|
||||
tid: str = Depends(get_tenant_id),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Get a single processing activity by ID."""
|
||||
act = db.query(VVTActivityDB).filter(VVTActivityDB.id == activity_id).first()
|
||||
act = db.query(VVTActivityDB).filter(
|
||||
VVTActivityDB.id == activity_id,
|
||||
VVTActivityDB.tenant_id == tid,
|
||||
).first()
|
||||
if not act:
|
||||
raise HTTPException(status_code=404, detail=f"Activity {activity_id} not found")
|
||||
return _activity_to_response(act)
|
||||
@@ -247,10 +277,14 @@ async def get_activity(activity_id: str, db: Session = Depends(get_db)):
|
||||
async def update_activity(
|
||||
activity_id: str,
|
||||
request: VVTActivityUpdate,
|
||||
tid: str = Depends(get_tenant_id),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Update a processing activity."""
|
||||
act = db.query(VVTActivityDB).filter(VVTActivityDB.id == activity_id).first()
|
||||
act = db.query(VVTActivityDB).filter(
|
||||
VVTActivityDB.id == activity_id,
|
||||
VVTActivityDB.tenant_id == tid,
|
||||
).first()
|
||||
if not act:
|
||||
raise HTTPException(status_code=404, detail=f"Activity {activity_id} not found")
|
||||
|
||||
@@ -262,6 +296,7 @@ async def update_activity(
|
||||
|
||||
_log_audit(
|
||||
db,
|
||||
tenant_id=tid,
|
||||
action="UPDATE",
|
||||
entity_type="activity",
|
||||
entity_id=act.id,
|
||||
@@ -275,14 +310,22 @@ async def update_activity(
|
||||
|
||||
|
||||
@router.delete("/activities/{activity_id}")
|
||||
async def delete_activity(activity_id: str, db: Session = Depends(get_db)):
|
||||
async def delete_activity(
|
||||
activity_id: str,
|
||||
tid: str = Depends(get_tenant_id),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Delete a processing activity."""
|
||||
act = db.query(VVTActivityDB).filter(VVTActivityDB.id == activity_id).first()
|
||||
act = db.query(VVTActivityDB).filter(
|
||||
VVTActivityDB.id == activity_id,
|
||||
VVTActivityDB.tenant_id == tid,
|
||||
).first()
|
||||
if not act:
|
||||
raise HTTPException(status_code=404, detail=f"Activity {activity_id} not found")
|
||||
|
||||
_log_audit(
|
||||
db,
|
||||
tenant_id=tid,
|
||||
action="DELETE",
|
||||
entity_type="activity",
|
||||
entity_id=act.id,
|
||||
@@ -302,11 +345,13 @@ async def delete_activity(activity_id: str, db: Session = Depends(get_db)):
|
||||
async def get_audit_log(
|
||||
limit: int = Query(50, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
tid: str = Depends(get_tenant_id),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Get the VVT audit trail."""
|
||||
entries = (
|
||||
db.query(VVTAuditLogDB)
|
||||
.filter(VVTAuditLogDB.tenant_id == tid)
|
||||
.order_by(VVTAuditLogDB.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
@@ -334,14 +379,26 @@ async def get_audit_log(
|
||||
@router.get("/export")
|
||||
async def export_activities(
|
||||
format: str = Query("json", pattern="^(json|csv)$"),
|
||||
tid: str = Depends(get_tenant_id),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Export all activities as JSON or CSV (semicolon-separated, DE locale)."""
|
||||
org = db.query(VVTOrganizationDB).order_by(VVTOrganizationDB.created_at).first()
|
||||
activities = db.query(VVTActivityDB).order_by(VVTActivityDB.created_at).all()
|
||||
org = (
|
||||
db.query(VVTOrganizationDB)
|
||||
.filter(VVTOrganizationDB.tenant_id == tid)
|
||||
.order_by(VVTOrganizationDB.created_at)
|
||||
.first()
|
||||
)
|
||||
activities = (
|
||||
db.query(VVTActivityDB)
|
||||
.filter(VVTActivityDB.tenant_id == tid)
|
||||
.order_by(VVTActivityDB.created_at)
|
||||
.all()
|
||||
)
|
||||
|
||||
_log_audit(
|
||||
db,
|
||||
tenant_id=tid,
|
||||
action="EXPORT",
|
||||
entity_type="all_activities",
|
||||
new_values={"count": len(activities), "format": format},
|
||||
@@ -432,9 +489,12 @@ def _export_csv(activities: list) -> StreamingResponse:
|
||||
|
||||
|
||||
@router.get("/stats", response_model=VVTStatsResponse)
|
||||
async def get_stats(db: Session = Depends(get_db)):
|
||||
async def get_stats(
|
||||
tid: str = Depends(get_tenant_id),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Get VVT statistics summary."""
|
||||
activities = db.query(VVTActivityDB).all()
|
||||
activities = db.query(VVTActivityDB).filter(VVTActivityDB.tenant_id == tid).all()
|
||||
|
||||
by_status: dict = {}
|
||||
by_bf: dict = {}
|
||||
@@ -459,3 +519,33 @@ async def get_stats(db: Session = Depends(get_db)):
|
||||
approved_count=by_status.get('APPROVED', 0),
|
||||
overdue_review_count=overdue_count,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Versioning
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/activities/{activity_id}/versions")
|
||||
async def list_activity_versions(
|
||||
activity_id: str,
|
||||
tid: str = Depends(get_tenant_id),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""List all versions for a VVT activity."""
|
||||
from .versioning_utils import list_versions
|
||||
return list_versions(db, "vvt_activity", activity_id, tid)
|
||||
|
||||
|
||||
@router.get("/activities/{activity_id}/versions/{version_number}")
|
||||
async def get_activity_version(
|
||||
activity_id: str,
|
||||
version_number: int,
|
||||
tid: str = Depends(get_tenant_id),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Get a specific VVT activity version with full snapshot."""
|
||||
from .versioning_utils import get_version
|
||||
v = get_version(db, "vvt_activity", activity_id, version_number, tid)
|
||||
if not v:
|
||||
raise HTTPException(status_code=404, detail=f"Version {version_number} not found")
|
||||
return v
|
||||
|
||||
Reference in New Issue
Block a user