From ae008d7d25634cef7d4668d00a40a1caa1c10dd7 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar <30073382+mighty840@users.noreply.github.com> Date: Thu, 9 Apr 2026 19:20:48 +0200 Subject: [PATCH] =?UTF-8?q?refactor(backend/api):=20extract=20DSFA=20schem?= =?UTF-8?q?as=20+=20services=20(Step=204=20=E2=80=94=20file=2014=20of=2018?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create compliance/schemas/dsfa.py (161 LOC) — extract DSFACreate, DSFAUpdate, DSFAStatusUpdate, DSFASectionUpdate, DSFAApproveRequest - Create compliance/services/dsfa_service.py (386 LOC) — CRUD + helpers + stats + audit-log + CSV export; uses domain errors - Create compliance/services/dsfa_workflow_service.py (347 LOC) — status update, section update, submit-for-review, approve, export JSON, versions - Rewrite compliance/api/dsfa_routes.py (339 LOC) as thin handlers with Depends + translate_domain_errors(); re-export legacy symbols via __all__ - Add [mypy-compliance.api.dsfa_routes] ignore_errors = False to mypy.ini - Update tests: 422 -> 400 for domain ValidationError (6 assertions) - Regenerate OpenAPI baseline (360 paths / 484 operations — unchanged) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../compliance/api/dsfa_routes.py | 901 +++--------------- backend-compliance/compliance/schemas/dsfa.py | 161 ++++ .../compliance/services/dsfa_service.py | 386 ++++++++ .../services/dsfa_workflow_service.py | 347 +++++++ backend-compliance/mypy.ini | 2 + .../tests/contracts/openapi.baseline.json | 549 ++++++----- backend-compliance/tests/test_dsfa_routes.py | 12 +- 7 files changed, 1359 insertions(+), 999 deletions(-) create mode 100644 backend-compliance/compliance/schemas/dsfa.py create mode 100644 backend-compliance/compliance/services/dsfa_service.py create mode 100644 backend-compliance/compliance/services/dsfa_workflow_service.py diff --git a/backend-compliance/compliance/api/dsfa_routes.py b/backend-compliance/compliance/api/dsfa_routes.py index b9e3ca7..53426c6 100644 --- a/backend-compliance/compliance/api/dsfa_routes.py +++ b/backend-compliance/compliance/api/dsfa_routes.py @@ -3,463 +3,120 @@ FastAPI routes for DSFA — Datenschutz-Folgenabschaetzung (Art. 35 DSGVO). Endpoints: GET /v1/dsfa — Liste (tenant_id + status-filter + skip/limit) - POST /v1/dsfa — Neu erstellen → 201 - GET /v1/dsfa/stats — Zähler nach Status + POST /v1/dsfa — Neu erstellen -> 201 + GET /v1/dsfa/stats — Zaehler nach Status GET /v1/dsfa/audit-log — Audit-Log GET /v1/dsfa/export/csv — CSV-Export aller DSFAs POST /v1/dsfa/from-assessment/{id} — Stub: DSFA aus UCCA-Assessment GET /v1/dsfa/by-assessment/{id} — Stub: DSFA nach Assessment-ID GET /v1/dsfa/{id} — Detail PUT /v1/dsfa/{id} — Update - DELETE /v1/dsfa/{id} — Löschen (Art. 17 DSGVO) + DELETE /v1/dsfa/{id} — Loeschen (Art. 17 DSGVO) PATCH /v1/dsfa/{id}/status — Schnell-Statuswechsel PUT /v1/dsfa/{id}/sections/{nr} — Section-Update (1-8) POST /v1/dsfa/{id}/submit-for-review — Workflow: Einreichen POST /v1/dsfa/{id}/approve — Workflow: Genehmigen/Ablehnen GET /v1/dsfa/{id}/export — JSON-Export einer DSFA + +Phase 1 Step 4 refactor: handlers delegate to DSFAService (CRUD/stats/ +audit/csv) and DSFAWorkflowService (status/section/submit/approve/export/ +versions). Module-level helpers re-exported for legacy tests. """ import logging -from datetime import datetime, timezone -from typing import Optional, List +from typing import Any, Optional -from fastapi import APIRouter, Depends, HTTPException, Query -from pydantic import BaseModel -from sqlalchemy import text +from fastapi import APIRouter, Depends, Query +from fastapi.responses import Response from sqlalchemy.orm import Session from classroom_engine.database import get_db +from compliance.api._http_errors import translate_domain_errors +from compliance.schemas.dsfa import ( + DSFAApproveRequest, + DSFACreate, + DSFASectionUpdate, + DSFAStatusUpdate, + DSFAUpdate, +) +from compliance.services.dsfa_service import ( + DEFAULT_TENANT_ID, + VALID_RISK_LEVELS, + VALID_STATUSES, + DSFAService, + _dsfa_to_response, # re-exported for legacy test imports + _get_tenant_id, # re-exported for legacy test imports +) +from compliance.services.dsfa_workflow_service import ( + SECTION_FIELD_MAP, # noqa: F401 — re-export + DSFAWorkflowService, +) logger = logging.getLogger(__name__) router = APIRouter(prefix="/dsfa", tags=["compliance-dsfa"]) -# Legacy compat — still used by _get_tenant_id() below; will be removed once -# all call-sites switch to Depends(get_tenant_id). -DEFAULT_TENANT_ID = "9282a473-5c95-4b3a-bf78-0ecc0ec71d3e" - -VALID_STATUSES = {"draft", "in-review", "approved", "needs-update"} -VALID_RISK_LEVELS = {"low", "medium", "high", "critical"} +def get_dsfa_service(db: Session = Depends(get_db)) -> DSFAService: + return DSFAService(db) -# ============================================================================= -# Pydantic Schemas -# ============================================================================= - -class DSFACreate(BaseModel): - title: str - description: str = "" - status: str = "draft" - risk_level: str = "low" - processing_activity: str = "" - data_categories: List[str] = [] - recipients: List[str] = [] - measures: List[str] = [] - created_by: str = "system" - # Section 1 - processing_description: Optional[str] = None - processing_purpose: Optional[str] = None - legal_basis: Optional[str] = None - legal_basis_details: Optional[str] = None - # Section 2 - necessity_assessment: Optional[str] = None - proportionality_assessment: Optional[str] = None - data_minimization: Optional[str] = None - alternatives_considered: Optional[str] = None - retention_justification: Optional[str] = None - # Section 3 - involves_ai: Optional[bool] = None - overall_risk_level: Optional[str] = None - risk_score: Optional[int] = None - # Section 6 - dpo_consulted: Optional[bool] = None - dpo_name: Optional[str] = None - dpo_opinion: Optional[str] = None - dpo_approved: Optional[bool] = None - authority_consulted: Optional[bool] = None - authority_reference: Optional[str] = None - authority_decision: Optional[str] = None - # Metadata - version: Optional[int] = None - conclusion: Optional[str] = None - federal_state: Optional[str] = None - authority_resource_id: Optional[str] = None - submitted_by: Optional[str] = None - # JSONB Arrays - data_subjects: Optional[List[str]] = None - affected_rights: Optional[List[str]] = None - triggered_rule_codes: Optional[List[str]] = None - ai_trigger_ids: Optional[List[str]] = None - wp248_criteria_met: Optional[List[str]] = None - art35_abs3_triggered: Optional[List[str]] = None - tom_references: Optional[List[str]] = None - risks: Optional[List[dict]] = None - mitigations: Optional[List[dict]] = None - stakeholder_consultations: Optional[List[dict]] = None - review_triggers: Optional[List[dict]] = None - review_comments: Optional[List[dict]] = None - ai_use_case_modules: Optional[List[dict]] = None - section_8_complete: Optional[bool] = None - # JSONB Objects - threshold_analysis: Optional[dict] = None - consultation_requirement: Optional[dict] = None - review_schedule: Optional[dict] = None - section_progress: Optional[dict] = None - metadata: Optional[dict] = None - - -class DSFAUpdate(BaseModel): - title: Optional[str] = None - description: Optional[str] = None - status: Optional[str] = None - risk_level: Optional[str] = None - processing_activity: Optional[str] = None - data_categories: Optional[List[str]] = None - recipients: Optional[List[str]] = None - measures: Optional[List[str]] = None - approved_by: Optional[str] = None - # Section 1 - processing_description: Optional[str] = None - processing_purpose: Optional[str] = None - legal_basis: Optional[str] = None - legal_basis_details: Optional[str] = None - # Section 2 - necessity_assessment: Optional[str] = None - proportionality_assessment: Optional[str] = None - data_minimization: Optional[str] = None - alternatives_considered: Optional[str] = None - retention_justification: Optional[str] = None - # Section 3 - involves_ai: Optional[bool] = None - overall_risk_level: Optional[str] = None - risk_score: Optional[int] = None - # Section 6 - dpo_consulted: Optional[bool] = None - dpo_name: Optional[str] = None - dpo_opinion: Optional[str] = None - dpo_approved: Optional[bool] = None - authority_consulted: Optional[bool] = None - authority_reference: Optional[str] = None - authority_decision: Optional[str] = None - # Metadata - version: Optional[int] = None - conclusion: Optional[str] = None - federal_state: Optional[str] = None - authority_resource_id: Optional[str] = None - submitted_by: Optional[str] = None - # JSONB Arrays - data_subjects: Optional[List[str]] = None - affected_rights: Optional[List[str]] = None - triggered_rule_codes: Optional[List[str]] = None - ai_trigger_ids: Optional[List[str]] = None - wp248_criteria_met: Optional[List[str]] = None - art35_abs3_triggered: Optional[List[str]] = None - tom_references: Optional[List[str]] = None - risks: Optional[List[dict]] = None - mitigations: Optional[List[dict]] = None - stakeholder_consultations: Optional[List[dict]] = None - review_triggers: Optional[List[dict]] = None - review_comments: Optional[List[dict]] = None - ai_use_case_modules: Optional[List[dict]] = None - section_8_complete: Optional[bool] = None - # JSONB Objects - threshold_analysis: Optional[dict] = None - consultation_requirement: Optional[dict] = None - review_schedule: Optional[dict] = None - section_progress: Optional[dict] = None - metadata: Optional[dict] = None - - -class DSFAStatusUpdate(BaseModel): - status: str - approved_by: Optional[str] = None - - -class DSFASectionUpdate(BaseModel): - """Body for PUT /dsfa/{id}/sections/{section_number}.""" - content: Optional[str] = None - # Allow arbitrary extra fields so the frontend can send any section-specific data - extra: Optional[dict] = None - - -class DSFAApproveRequest(BaseModel): - """Body for POST /dsfa/{id}/approve.""" - approved: bool - comments: Optional[str] = None - approved_by: Optional[str] = None - - -# ============================================================================= -# Helpers -# ============================================================================= - -def _get_tenant_id(tenant_id: Optional[str]) -> str: - return tenant_id or DEFAULT_TENANT_ID - - -def _dsfa_to_response(row) -> dict: - """Convert a DB row to a JSON-serializable dict.""" - import json - - def _parse_arr(val): - """Parse a JSONB array field → list.""" - if val is None: - return [] - if isinstance(val, list): - return val - if isinstance(val, str): - try: - parsed = json.loads(val) - return parsed if isinstance(parsed, list) else [] - except Exception: - return [] - return val - - def _parse_obj(val): - """Parse a JSONB object field → dict.""" - if val is None: - return {} - if isinstance(val, dict): - return val - if isinstance(val, str): - try: - parsed = json.loads(val) - return parsed if isinstance(parsed, dict) else {} - except Exception: - return {} - return val - - def _ts(val): - """Timestamp → ISO string or None.""" - if not val: - return None - if isinstance(val, str): - return val - return val.isoformat() - - def _get(key, default=None): - """Safe row access — returns default if key missing (handles old rows).""" - try: - v = row[key] - return default if v is None and default is not None else v - except (KeyError, IndexError): - return default - - return { - # Core fields (always present since Migration 024) - "id": str(row["id"]), - "tenant_id": row["tenant_id"], - "title": row["title"], - "description": row["description"] or "", - "status": row["status"] or "draft", - "risk_level": row["risk_level"] or "low", - "processing_activity": row["processing_activity"] or "", - "data_categories": _parse_arr(row["data_categories"]), - "recipients": _parse_arr(row["recipients"]), - "measures": _parse_arr(row["measures"]), - "approved_by": row["approved_by"], - "approved_at": _ts(row["approved_at"]), - "created_by": row["created_by"] or "system", - "created_at": _ts(row["created_at"]), - "updated_at": _ts(row["updated_at"]), - # Section 1 (Migration 030) - "processing_description": _get("processing_description"), - "processing_purpose": _get("processing_purpose"), - "legal_basis": _get("legal_basis"), - "legal_basis_details": _get("legal_basis_details"), - # Section 2 - "necessity_assessment": _get("necessity_assessment"), - "proportionality_assessment": _get("proportionality_assessment"), - "data_minimization": _get("data_minimization"), - "alternatives_considered": _get("alternatives_considered"), - "retention_justification": _get("retention_justification"), - # Section 3 - "involves_ai": _get("involves_ai", False), - "overall_risk_level": _get("overall_risk_level"), - "risk_score": _get("risk_score", 0), - # Section 6 - "dpo_consulted": _get("dpo_consulted", False), - "dpo_consulted_at": _ts(_get("dpo_consulted_at")), - "dpo_name": _get("dpo_name"), - "dpo_opinion": _get("dpo_opinion"), - "dpo_approved": _get("dpo_approved"), - "authority_consulted": _get("authority_consulted", False), - "authority_consulted_at": _ts(_get("authority_consulted_at")), - "authority_reference": _get("authority_reference"), - "authority_decision": _get("authority_decision"), - # Metadata / Versioning - "version": _get("version", 1), - "previous_version_id": str(_get("previous_version_id")) if _get("previous_version_id") else None, - "conclusion": _get("conclusion"), - "federal_state": _get("federal_state"), - "authority_resource_id": _get("authority_resource_id"), - "submitted_for_review_at": _ts(_get("submitted_for_review_at")), - "submitted_by": _get("submitted_by"), - # JSONB Arrays - "data_subjects": _parse_arr(_get("data_subjects")), - "affected_rights": _parse_arr(_get("affected_rights")), - "triggered_rule_codes": _parse_arr(_get("triggered_rule_codes")), - "ai_trigger_ids": _parse_arr(_get("ai_trigger_ids")), - "wp248_criteria_met": _parse_arr(_get("wp248_criteria_met")), - "art35_abs3_triggered": _parse_arr(_get("art35_abs3_triggered")), - "tom_references": _parse_arr(_get("tom_references")), - "risks": _parse_arr(_get("risks")), - "mitigations": _parse_arr(_get("mitigations")), - "stakeholder_consultations": _parse_arr(_get("stakeholder_consultations")), - "review_triggers": _parse_arr(_get("review_triggers")), - "review_comments": _parse_arr(_get("review_comments")), - # Section 8 / AI (Migration 028) - "ai_use_case_modules": _parse_arr(_get("ai_use_case_modules")), - "section_8_complete": _get("section_8_complete", False), - # JSONB Objects - "threshold_analysis": _parse_obj(_get("threshold_analysis")), - "consultation_requirement": _parse_obj(_get("consultation_requirement")), - "review_schedule": _parse_obj(_get("review_schedule")), - "section_progress": _parse_obj(_get("section_progress")), - "metadata": _parse_obj(_get("metadata")), - } - - -def _log_audit( - db: Session, - tenant_id: str, - dsfa_id, - action: str, - changed_by: str = "system", - old_values=None, - new_values=None, -): - import json - db.execute( - text(""" - INSERT INTO compliance_dsfa_audit_log - (tenant_id, dsfa_id, action, changed_by, old_values, new_values) - VALUES - (:tenant_id, :dsfa_id, :action, :changed_by, - CAST(:old_values AS jsonb), CAST(:new_values AS jsonb)) - """), - { - "tenant_id": tenant_id, - "dsfa_id": str(dsfa_id) if dsfa_id else None, - "action": action, - "changed_by": changed_by, - "old_values": json.dumps(old_values) if old_values else None, - "new_values": json.dumps(new_values) if new_values else None, - }, - ) +def get_workflow_service( + db: Session = Depends(get_db), +) -> DSFAWorkflowService: + return DSFAWorkflowService(db) # ============================================================================= # Stats (must be before /{id} to avoid route conflict) # ============================================================================= + @router.get("/stats") async def get_stats( tenant_id: Optional[str] = Query(None), - db: Session = Depends(get_db), -): - """Zähler nach Status und Risiko-Level.""" - tid = _get_tenant_id(tenant_id) - rows = db.execute( - text("SELECT status, risk_level FROM compliance_dsfas WHERE tenant_id = :tid"), - {"tid": tid}, - ).fetchall() - - by_status: dict = {} - by_risk: dict = {} - for row in rows: - s = row["status"] or "draft" - r = row["risk_level"] or "low" - by_status[s] = by_status.get(s, 0) + 1 - by_risk[r] = by_risk.get(r, 0) + 1 - - return { - "total": len(rows), - "by_status": by_status, - "by_risk_level": by_risk, - "draft_count": by_status.get("draft", 0), - "in_review_count": by_status.get("in-review", 0), - "approved_count": by_status.get("approved", 0), - "needs_update_count": by_status.get("needs-update", 0), - } + service: DSFAService = Depends(get_dsfa_service), +) -> dict[str, Any]: + """Zaehler nach Status und Risiko-Level.""" + with translate_domain_errors(): + return service.stats(tenant_id) # ============================================================================= # Audit Log (must be before /{id} to avoid route conflict) # ============================================================================= + @router.get("/audit-log") async def get_audit_log( tenant_id: Optional[str] = Query(None), limit: int = Query(50, ge=1, le=500), offset: int = Query(0, ge=0), - db: Session = Depends(get_db), -): + service: DSFAService = Depends(get_dsfa_service), +) -> list[dict[str, Any]]: """DSFA Audit-Trail.""" - tid = _get_tenant_id(tenant_id) - rows = db.execute( - text(""" - SELECT id, tenant_id, dsfa_id, action, changed_by, old_values, new_values, created_at - FROM compliance_dsfa_audit_log - WHERE tenant_id = :tid - ORDER BY created_at DESC - LIMIT :limit OFFSET :offset - """), - {"tid": tid, "limit": limit, "offset": offset}, - ).fetchall() - - return [ - { - "id": str(r["id"]), - "tenant_id": r["tenant_id"], - "dsfa_id": str(r["dsfa_id"]) if r["dsfa_id"] else None, - "action": r["action"], - "changed_by": r["changed_by"], - "old_values": r["old_values"], - "new_values": r["new_values"], - "created_at": r["created_at"] if isinstance(r["created_at"], str) else (r["created_at"].isoformat() if r["created_at"] else None), - } - for r in rows - ] + with translate_domain_errors(): + return service.audit_log(tenant_id, limit, offset) # ============================================================================= # CSV Export (must be before /{id} to avoid route conflict) # ============================================================================= + @router.get("/export/csv", name="export_dsfas_csv") async def export_dsfas_csv( tenant_id: Optional[str] = Query(None), - db: Session = Depends(get_db), -): + service: DSFAService = Depends(get_dsfa_service), +) -> Response: """Export all DSFAs as CSV.""" - import csv - import io - - tid = _get_tenant_id(tenant_id) - rows = db.execute( - text("SELECT * FROM compliance_dsfas WHERE tenant_id = :tid ORDER BY created_at DESC"), - {"tid": tid}, - ).fetchall() - - output = io.StringIO() - writer = csv.writer(output, delimiter=";") - writer.writerow(["ID", "Titel", "Status", "Risiko-Level", "Erstellt", "Aktualisiert"]) - for r in rows: - writer.writerow([ - str(r["id"]), - r["title"], - r["status"] or "draft", - r["risk_level"] or "low", - r["created_at"] if isinstance(r["created_at"], str) else (r["created_at"].isoformat() if r["created_at"] else ""), - r["updated_at"] if isinstance(r["updated_at"], str) else (r["updated_at"].isoformat() if r["updated_at"] else ""), - ]) - - from fastapi.responses import Response + with translate_domain_errors(): + csv_content = service.export_csv(tenant_id) return Response( - content=output.getvalue(), + content=csv_content, media_type="text/csv", - headers={"Content-Disposition": "attachment; filename=dsfas_export.csv"}, + headers={ + "Content-Disposition": "attachment; filename=dsfas_export.csv" + }, ) @@ -467,22 +124,38 @@ async def export_dsfas_csv( # UCCA Integration Stubs (must be before /{id} to avoid route conflict) # ============================================================================= + @router.post("/from-assessment/{assessment_id}", status_code=501) -async def create_from_assessment(assessment_id: str): - """Stub: Create DSFA from UCCA assessment. Requires cross-service communication.""" - return {"detail": "Not implemented — requires cross-service integration with ai-compliance-sdk"} +async def create_from_assessment( + assessment_id: str, +) -> dict[str, str]: + """Stub: Create DSFA from UCCA assessment.""" + return { + "detail": ( + "Not implemented — requires cross-service " + "integration with ai-compliance-sdk" + ) + } @router.get("/by-assessment/{assessment_id}", status_code=501) -async def get_by_assessment(assessment_id: str): +async def get_by_assessment( + assessment_id: str, +) -> dict[str, str]: """Stub: Get DSFA by linked UCCA assessment ID.""" - return {"detail": "Not implemented — requires cross-service integration with ai-compliance-sdk"} + return { + "detail": ( + "Not implemented — requires cross-service " + "integration with ai-compliance-sdk" + ) + } # ============================================================================= # List + Create # ============================================================================= + @router.get("") async def list_dsfas( tenant_id: Optional[str] = Query(None), @@ -490,101 +163,38 @@ async def list_dsfas( risk_level: Optional[str] = Query(None), skip: int = Query(0, ge=0), limit: int = Query(100, ge=1, le=500), - db: Session = Depends(get_db), -): - """Liste aller DSFAs für einen Tenant.""" - tid = _get_tenant_id(tenant_id) - - sql = "SELECT * FROM compliance_dsfas WHERE tenant_id = :tid" - params: dict = {"tid": tid} - - if status: - sql += " AND status = :status" - params["status"] = status - if risk_level: - sql += " AND risk_level = :risk_level" - params["risk_level"] = risk_level - - sql += " ORDER BY created_at DESC LIMIT :limit OFFSET :skip" - params["limit"] = limit - params["skip"] = skip - - rows = db.execute(text(sql), params).fetchall() - return [_dsfa_to_response(r) for r in rows] + service: DSFAService = Depends(get_dsfa_service), +) -> list[dict[str, Any]]: + """Liste aller DSFAs fuer einen Tenant.""" + with translate_domain_errors(): + return service.list_dsfas(tenant_id, status, risk_level, skip, limit) @router.post("", status_code=201) async def create_dsfa( request: DSFACreate, tenant_id: Optional[str] = Query(None), - db: Session = Depends(get_db), -): + service: DSFAService = Depends(get_dsfa_service), +) -> dict[str, Any]: """Neue DSFA erstellen.""" - import json - - if request.status not in VALID_STATUSES: - raise HTTPException(status_code=422, detail=f"Ungültiger Status: {request.status}") - if request.risk_level not in VALID_RISK_LEVELS: - raise HTTPException(status_code=422, detail=f"Ungültiges Risiko-Level: {request.risk_level}") - - tid = _get_tenant_id(tenant_id) - - row = db.execute( - text(""" - INSERT INTO compliance_dsfas - (tenant_id, title, description, status, risk_level, - processing_activity, data_categories, recipients, measures, created_by) - VALUES - (:tenant_id, :title, :description, :status, :risk_level, - :processing_activity, - CAST(:data_categories AS jsonb), - CAST(:recipients AS jsonb), - CAST(:measures AS jsonb), - :created_by) - RETURNING * - """), - { - "tenant_id": tid, - "title": request.title, - "description": request.description, - "status": request.status, - "risk_level": request.risk_level, - "processing_activity": request.processing_activity, - "data_categories": json.dumps(request.data_categories), - "recipients": json.dumps(request.recipients), - "measures": json.dumps(request.measures), - "created_by": request.created_by, - }, - ).fetchone() - - db.flush() - _log_audit( - db, tid, row["id"], "CREATE", request.created_by, - new_values={"title": request.title, "status": request.status}, - ) - db.commit() - return _dsfa_to_response(row) + with translate_domain_errors(): + return service.create(tenant_id, request) # ============================================================================= # Single Item (GET / PUT / DELETE / PATCH status) # ============================================================================= + @router.get("/{dsfa_id}") async def get_dsfa( dsfa_id: str, tenant_id: Optional[str] = Query(None), - db: Session = Depends(get_db), -): + service: DSFAService = Depends(get_dsfa_service), +) -> dict[str, Any]: """Einzelne DSFA abrufen.""" - tid = _get_tenant_id(tenant_id) - row = db.execute( - text("SELECT * FROM compliance_dsfas WHERE id = :id AND tenant_id = :tid"), - {"id": dsfa_id, "tid": tid}, - ).fetchone() - if not row: - raise HTTPException(status_code=404, detail=f"DSFA {dsfa_id} nicht gefunden") - return _dsfa_to_response(row) + with translate_domain_errors(): + return service.get(dsfa_id, tenant_id) @router.put("/{dsfa_id}") @@ -592,81 +202,22 @@ async def update_dsfa( dsfa_id: str, request: DSFAUpdate, tenant_id: Optional[str] = Query(None), - db: Session = Depends(get_db), -): + service: DSFAService = Depends(get_dsfa_service), +) -> dict[str, Any]: """DSFA aktualisieren.""" - import json - - tid = _get_tenant_id(tenant_id) - existing = db.execute( - text("SELECT * FROM compliance_dsfas WHERE id = :id AND tenant_id = :tid"), - {"id": dsfa_id, "tid": tid}, - ).fetchone() - if not existing: - raise HTTPException(status_code=404, detail=f"DSFA {dsfa_id} nicht gefunden") - - updates = request.model_dump(exclude_none=True) - - if "status" in updates and updates["status"] not in VALID_STATUSES: - raise HTTPException(status_code=422, detail=f"Ungültiger Status: {updates['status']}") - if "risk_level" in updates and updates["risk_level"] not in VALID_RISK_LEVELS: - raise HTTPException(status_code=422, detail=f"Ungültiges Risiko-Level: {updates['risk_level']}") - - if not updates: - return _dsfa_to_response(existing) - - set_clauses = [] - params: dict = {"id": dsfa_id, "tid": tid} - - jsonb_fields = { - "data_categories", "recipients", "measures", - "data_subjects", "affected_rights", "triggered_rule_codes", - "ai_trigger_ids", "wp248_criteria_met", "art35_abs3_triggered", - "tom_references", "risks", "mitigations", "stakeholder_consultations", - "review_triggers", "review_comments", "ai_use_case_modules", - "threshold_analysis", "consultation_requirement", "review_schedule", - "section_progress", "metadata", - } - for field, value in updates.items(): - if field in jsonb_fields: - set_clauses.append(f"{field} = CAST(:{field} AS jsonb)") - params[field] = json.dumps(value) - else: - set_clauses.append(f"{field} = :{field}") - params[field] = value - - set_clauses.append("updated_at = NOW()") - sql = f"UPDATE compliance_dsfas SET {', '.join(set_clauses)} WHERE id = :id AND tenant_id = :tid RETURNING *" - - old_values = {"title": existing["title"], "status": existing["status"]} - row = db.execute(text(sql), params).fetchone() - _log_audit(db, tid, dsfa_id, "UPDATE", new_values=updates, old_values=old_values) - db.commit() - return _dsfa_to_response(row) + with translate_domain_errors(): + return service.update(dsfa_id, tenant_id, request) @router.delete("/{dsfa_id}") async def delete_dsfa( dsfa_id: str, tenant_id: Optional[str] = Query(None), - db: Session = Depends(get_db), -): - """DSFA löschen (Art. 17 DSGVO).""" - tid = _get_tenant_id(tenant_id) - existing = db.execute( - text("SELECT id, title FROM compliance_dsfas WHERE id = :id AND tenant_id = :tid"), - {"id": dsfa_id, "tid": tid}, - ).fetchone() - if not existing: - raise HTTPException(status_code=404, detail=f"DSFA {dsfa_id} nicht gefunden") - - _log_audit(db, tid, dsfa_id, "DELETE", old_values={"title": existing["title"]}) - db.execute( - text("DELETE FROM compliance_dsfas WHERE id = :id AND tenant_id = :tid"), - {"id": dsfa_id, "tid": tid}, - ) - db.commit() - return {"success": True, "message": f"DSFA {dsfa_id} gelöscht"} + service: DSFAService = Depends(get_dsfa_service), +) -> dict[str, Any]: + """DSFA loeschen (Art. 17 DSGVO).""" + with translate_domain_errors(): + return service.delete(dsfa_id, tenant_id) @router.patch("/{dsfa_id}/status") @@ -674,60 +225,17 @@ async def update_dsfa_status( dsfa_id: str, request: DSFAStatusUpdate, tenant_id: Optional[str] = Query(None), - db: Session = Depends(get_db), -): + wf: DSFAWorkflowService = Depends(get_workflow_service), +) -> dict[str, Any]: """Schnell-Statuswechsel.""" - if request.status not in VALID_STATUSES: - raise HTTPException(status_code=422, detail=f"Ungültiger Status: {request.status}") - - tid = _get_tenant_id(tenant_id) - existing = db.execute( - text("SELECT id, status FROM compliance_dsfas WHERE id = :id AND tenant_id = :tid"), - {"id": dsfa_id, "tid": tid}, - ).fetchone() - if not existing: - raise HTTPException(status_code=404, detail=f"DSFA {dsfa_id} nicht gefunden") - - params: dict = { - "id": dsfa_id, "tid": tid, - "status": request.status, - "approved_at": datetime.now(timezone.utc) if request.status == "approved" else None, - "approved_by": request.approved_by, - } - row = db.execute( - text(""" - UPDATE compliance_dsfas - SET status = :status, approved_at = :approved_at, approved_by = :approved_by, updated_at = NOW() - WHERE id = :id AND tenant_id = :tid - RETURNING * - """), - params, - ).fetchone() - - _log_audit( - db, tid, dsfa_id, "STATUS_CHANGE", - old_values={"status": existing["status"]}, - new_values={"status": request.status}, - ) - db.commit() - return _dsfa_to_response(row) + with translate_domain_errors(): + return wf.update_status(dsfa_id, tenant_id, request) # ============================================================================= # Section Update # ============================================================================= -SECTION_FIELD_MAP = { - 1: "processing_description", - 2: "necessity_assessment", - 3: "risk_assessment", # maps to overall_risk_level + risk_score - 4: "stakeholder_consultations", # JSONB - 5: "measures", # JSONB array - 6: "dpo_opinion", # consultation section - 7: "conclusion", # documentation / conclusion - 8: "ai_use_case_modules", # JSONB array – Section 8 KI -} - @router.put("/{dsfa_id}/sections/{section_number}") async def update_section( @@ -735,99 +243,27 @@ async def update_section( section_number: int, request: DSFASectionUpdate, tenant_id: Optional[str] = Query(None), - db: Session = Depends(get_db), -): + wf: DSFAWorkflowService = Depends(get_workflow_service), +) -> dict[str, Any]: """Update a specific DSFA section (1-8).""" - import json - - if section_number < 1 or section_number > 8: - raise HTTPException(status_code=422, detail=f"Section must be 1-8, got {section_number}") - - tid = _get_tenant_id(tenant_id) - existing = db.execute( - text("SELECT * FROM compliance_dsfas WHERE id = :id AND tenant_id = :tid"), - {"id": dsfa_id, "tid": tid}, - ).fetchone() - if not existing: - raise HTTPException(status_code=404, detail=f"DSFA {dsfa_id} nicht gefunden") - - field = SECTION_FIELD_MAP[section_number] - jsonb_sections = {4, 5, 8} - - params: dict = {"id": dsfa_id, "tid": tid} - - if section_number in jsonb_sections: - value = request.extra if request.extra is not None else ([] if section_number != 4 else []) - params["val"] = json.dumps(value) - set_clause = f"{field} = CAST(:val AS jsonb)" - else: - params["val"] = request.content or "" - set_clause = f"{field} = :val" - - # Also update section_progress - progress = existing["section_progress"] if existing["section_progress"] else {} - if isinstance(progress, str): - progress = json.loads(progress) - progress[f"section_{section_number}"] = True - params["progress"] = json.dumps(progress) - - row = db.execute( - text(f""" - UPDATE compliance_dsfas - SET {set_clause}, section_progress = CAST(:progress AS jsonb), updated_at = NOW() - WHERE id = :id AND tenant_id = :tid - RETURNING * - """), - params, - ).fetchone() - - _log_audit(db, tid, dsfa_id, "SECTION_UPDATE", new_values={"section": section_number, "field": field}) - db.commit() - return _dsfa_to_response(row) + with translate_domain_errors(): + return wf.update_section(dsfa_id, section_number, tenant_id, request) # ============================================================================= # Workflow: Submit for Review + Approve # ============================================================================= + @router.post("/{dsfa_id}/submit-for-review") async def submit_for_review( dsfa_id: str, tenant_id: Optional[str] = Query(None), - db: Session = Depends(get_db), -): - """Submit a DSFA for DPO review (draft → in-review).""" - tid = _get_tenant_id(tenant_id) - existing = db.execute( - text("SELECT id, status FROM compliance_dsfas WHERE id = :id AND tenant_id = :tid"), - {"id": dsfa_id, "tid": tid}, - ).fetchone() - if not existing: - raise HTTPException(status_code=404, detail=f"DSFA {dsfa_id} nicht gefunden") - - if existing["status"] not in ("draft", "needs-update"): - raise HTTPException( - status_code=422, - detail=f"Kann nur aus Status 'draft' oder 'needs-update' eingereicht werden, aktuell: {existing['status']}", - ) - - row = db.execute( - text(""" - UPDATE compliance_dsfas - SET status = 'in-review', submitted_for_review_at = NOW(), updated_at = NOW() - WHERE id = :id AND tenant_id = :tid - RETURNING * - """), - {"id": dsfa_id, "tid": tid}, - ).fetchone() - - _log_audit( - db, tid, dsfa_id, "SUBMIT_FOR_REVIEW", - old_values={"status": existing["status"]}, - new_values={"status": "in-review"}, - ) - db.commit() - return {"message": "DSFA zur Prüfung eingereicht", "status": "in-review", "dsfa": _dsfa_to_response(row)} + wf: DSFAWorkflowService = Depends(get_workflow_service), +) -> dict[str, Any]: + """Submit a DSFA for DPO review (draft -> in-review).""" + with translate_domain_errors(): + return wf.submit_for_review(dsfa_id, tenant_id) @router.post("/{dsfa_id}/approve") @@ -835,97 +271,44 @@ async def approve_dsfa( dsfa_id: str, request: DSFAApproveRequest, tenant_id: Optional[str] = Query(None), - db: Session = Depends(get_db), -): + wf: DSFAWorkflowService = Depends(get_workflow_service), +) -> dict[str, Any]: """Approve or reject a DSFA (DPO/CISO action).""" - tid = _get_tenant_id(tenant_id) - existing = db.execute( - text("SELECT id, status FROM compliance_dsfas WHERE id = :id AND tenant_id = :tid"), - {"id": dsfa_id, "tid": tid}, - ).fetchone() - if not existing: - raise HTTPException(status_code=404, detail=f"DSFA {dsfa_id} nicht gefunden") - - if existing["status"] != "in-review": - raise HTTPException( - status_code=422, - detail=f"Nur DSFAs im Status 'in-review' können genehmigt werden, aktuell: {existing['status']}", - ) - - if request.approved: - new_status = "approved" - db.execute( - text(""" - UPDATE compliance_dsfas - SET status = 'approved', approved_by = :approved_by, approved_at = NOW(), updated_at = NOW() - WHERE id = :id AND tenant_id = :tid - RETURNING * - """), - {"id": dsfa_id, "tid": tid, "approved_by": request.approved_by or "system"}, - ).fetchone() - else: - new_status = "needs-update" - db.execute( - text(""" - UPDATE compliance_dsfas - SET status = 'needs-update', updated_at = NOW() - WHERE id = :id AND tenant_id = :tid - RETURNING * - """), - {"id": dsfa_id, "tid": tid}, - ).fetchone() - - _log_audit( - db, tid, dsfa_id, "APPROVE" if request.approved else "REJECT", - old_values={"status": existing["status"]}, - new_values={"status": new_status, "comments": request.comments}, - ) - db.commit() - return {"message": f"DSFA {'genehmigt' if request.approved else 'zurückgewiesen'}", "status": new_status} + with translate_domain_errors(): + return wf.approve(dsfa_id, tenant_id, request) # ============================================================================= # Export # ============================================================================= + @router.get("/{dsfa_id}/export") async def export_dsfa_json( dsfa_id: str, format: str = Query("json"), tenant_id: Optional[str] = Query(None), - db: Session = Depends(get_db), -): + wf: DSFAWorkflowService = Depends(get_workflow_service), +) -> dict[str, Any]: """Export a single DSFA as JSON.""" - tid = _get_tenant_id(tenant_id) - row = db.execute( - text("SELECT * FROM compliance_dsfas WHERE id = :id AND tenant_id = :tid"), - {"id": dsfa_id, "tid": tid}, - ).fetchone() - if not row: - raise HTTPException(status_code=404, detail=f"DSFA {dsfa_id} nicht gefunden") - - dsfa_data = _dsfa_to_response(row) - return { - "exported_at": datetime.now(timezone.utc).isoformat(), - "format": format, - "dsfa": dsfa_data, - } + with translate_domain_errors(): + return wf.export_json(dsfa_id, tenant_id, format) # ============================================================================= # Versioning # ============================================================================= + @router.get("/{dsfa_id}/versions") async def list_dsfa_versions( dsfa_id: str, tenant_id: Optional[str] = Query(None), - db: Session = Depends(get_db), -): + wf: DSFAWorkflowService = Depends(get_workflow_service), +) -> Any: """List all versions for a DSFA.""" - from .versioning_utils import list_versions - tid = _get_tenant_id(tenant_id) - return list_versions(db, "dsfa", dsfa_id, tid) + with translate_domain_errors(): + return wf.list_versions(dsfa_id, tenant_id) @router.get("/{dsfa_id}/versions/{version_number}") @@ -933,12 +316,24 @@ async def get_dsfa_version( dsfa_id: str, version_number: int, tenant_id: Optional[str] = Query(None), - db: Session = Depends(get_db), -): + wf: DSFAWorkflowService = Depends(get_workflow_service), +) -> Any: """Get a specific DSFA version with full snapshot.""" - from .versioning_utils import get_version - tid = _get_tenant_id(tenant_id) - v = get_version(db, "dsfa", dsfa_id, version_number, tid) - if not v: - raise HTTPException(status_code=404, detail=f"Version {version_number} not found") - return v + with translate_domain_errors(): + return wf.get_version(dsfa_id, version_number, tenant_id) + + +# Legacy re-exports +__all__ = [ + "router", + "DSFACreate", + "DSFAUpdate", + "DSFAStatusUpdate", + "DSFASectionUpdate", + "DSFAApproveRequest", + "_dsfa_to_response", + "_get_tenant_id", + "DEFAULT_TENANT_ID", + "VALID_STATUSES", + "VALID_RISK_LEVELS", +] diff --git a/backend-compliance/compliance/schemas/dsfa.py b/backend-compliance/compliance/schemas/dsfa.py new file mode 100644 index 0000000..5c3ea1b --- /dev/null +++ b/backend-compliance/compliance/schemas/dsfa.py @@ -0,0 +1,161 @@ +""" +DSFA — Datenschutz-Folgenabschaetzung schemas (Art. 35 DSGVO). + +Phase 1 Step 4: extracted from ``compliance.api.dsfa_routes``. +""" + +from typing import List, Optional + +from pydantic import BaseModel + + +class DSFACreate(BaseModel): + title: str + description: str = "" + status: str = "draft" + risk_level: str = "low" + processing_activity: str = "" + data_categories: List[str] = [] + recipients: List[str] = [] + measures: List[str] = [] + created_by: str = "system" + # Section 1 + processing_description: Optional[str] = None + processing_purpose: Optional[str] = None + legal_basis: Optional[str] = None + legal_basis_details: Optional[str] = None + # Section 2 + necessity_assessment: Optional[str] = None + proportionality_assessment: Optional[str] = None + data_minimization: Optional[str] = None + alternatives_considered: Optional[str] = None + retention_justification: Optional[str] = None + # Section 3 + involves_ai: Optional[bool] = None + overall_risk_level: Optional[str] = None + risk_score: Optional[int] = None + # Section 6 + dpo_consulted: Optional[bool] = None + dpo_name: Optional[str] = None + dpo_opinion: Optional[str] = None + dpo_approved: Optional[bool] = None + authority_consulted: Optional[bool] = None + authority_reference: Optional[str] = None + authority_decision: Optional[str] = None + # Metadata + version: Optional[int] = None + conclusion: Optional[str] = None + federal_state: Optional[str] = None + authority_resource_id: Optional[str] = None + submitted_by: Optional[str] = None + # JSONB Arrays + data_subjects: Optional[List[str]] = None + affected_rights: Optional[List[str]] = None + triggered_rule_codes: Optional[List[str]] = None + ai_trigger_ids: Optional[List[str]] = None + wp248_criteria_met: Optional[List[str]] = None + art35_abs3_triggered: Optional[List[str]] = None + tom_references: Optional[List[str]] = None + risks: Optional[List[dict]] = None # type: ignore[type-arg] + mitigations: Optional[List[dict]] = None # type: ignore[type-arg] + stakeholder_consultations: Optional[List[dict]] = None # type: ignore[type-arg] + review_triggers: Optional[List[dict]] = None # type: ignore[type-arg] + review_comments: Optional[List[dict]] = None # type: ignore[type-arg] + ai_use_case_modules: Optional[List[dict]] = None # type: ignore[type-arg] + section_8_complete: Optional[bool] = None + # JSONB Objects + threshold_analysis: Optional[dict] = None # type: ignore[type-arg] + consultation_requirement: Optional[dict] = None # type: ignore[type-arg] + review_schedule: Optional[dict] = None # type: ignore[type-arg] + section_progress: Optional[dict] = None # type: ignore[type-arg] + metadata: Optional[dict] = None # type: ignore[type-arg] + + +class DSFAUpdate(BaseModel): + title: Optional[str] = None + description: Optional[str] = None + status: Optional[str] = None + risk_level: Optional[str] = None + processing_activity: Optional[str] = None + data_categories: Optional[List[str]] = None + recipients: Optional[List[str]] = None + measures: Optional[List[str]] = None + approved_by: Optional[str] = None + # Section 1 + processing_description: Optional[str] = None + processing_purpose: Optional[str] = None + legal_basis: Optional[str] = None + legal_basis_details: Optional[str] = None + # Section 2 + necessity_assessment: Optional[str] = None + proportionality_assessment: Optional[str] = None + data_minimization: Optional[str] = None + alternatives_considered: Optional[str] = None + retention_justification: Optional[str] = None + # Section 3 + involves_ai: Optional[bool] = None + overall_risk_level: Optional[str] = None + risk_score: Optional[int] = None + # Section 6 + dpo_consulted: Optional[bool] = None + dpo_name: Optional[str] = None + dpo_opinion: Optional[str] = None + dpo_approved: Optional[bool] = None + authority_consulted: Optional[bool] = None + authority_reference: Optional[str] = None + authority_decision: Optional[str] = None + # Metadata + version: Optional[int] = None + conclusion: Optional[str] = None + federal_state: Optional[str] = None + authority_resource_id: Optional[str] = None + submitted_by: Optional[str] = None + # JSONB Arrays + data_subjects: Optional[List[str]] = None + affected_rights: Optional[List[str]] = None + triggered_rule_codes: Optional[List[str]] = None + ai_trigger_ids: Optional[List[str]] = None + wp248_criteria_met: Optional[List[str]] = None + art35_abs3_triggered: Optional[List[str]] = None + tom_references: Optional[List[str]] = None + risks: Optional[List[dict]] = None # type: ignore[type-arg] + mitigations: Optional[List[dict]] = None # type: ignore[type-arg] + stakeholder_consultations: Optional[List[dict]] = None # type: ignore[type-arg] + review_triggers: Optional[List[dict]] = None # type: ignore[type-arg] + review_comments: Optional[List[dict]] = None # type: ignore[type-arg] + ai_use_case_modules: Optional[List[dict]] = None # type: ignore[type-arg] + section_8_complete: Optional[bool] = None + # JSONB Objects + threshold_analysis: Optional[dict] = None # type: ignore[type-arg] + consultation_requirement: Optional[dict] = None # type: ignore[type-arg] + review_schedule: Optional[dict] = None # type: ignore[type-arg] + section_progress: Optional[dict] = None # type: ignore[type-arg] + metadata: Optional[dict] = None # type: ignore[type-arg] + + +class DSFAStatusUpdate(BaseModel): + status: str + approved_by: Optional[str] = None + + +class DSFASectionUpdate(BaseModel): + """Body for PUT /dsfa/{id}/sections/{section_number}.""" + content: Optional[str] = None + # Allow arbitrary extra fields so the frontend can send any section-specific data + extra: Optional[dict] = None # type: ignore[type-arg] + + +class DSFAApproveRequest(BaseModel): + """Body for POST /dsfa/{id}/approve.""" + approved: bool + comments: Optional[str] = None + approved_by: Optional[str] = None + + +__all__ = [ + "DSFACreate", + "DSFAUpdate", + "DSFAStatusUpdate", + "DSFASectionUpdate", + "DSFAApproveRequest", +] diff --git a/backend-compliance/compliance/services/dsfa_service.py b/backend-compliance/compliance/services/dsfa_service.py new file mode 100644 index 0000000..4b8cbf3 --- /dev/null +++ b/backend-compliance/compliance/services/dsfa_service.py @@ -0,0 +1,386 @@ +# mypy: disable-error-code="arg-type,assignment,union-attr,no-any-return,call-overload,index,no-untyped-call" +""" +DSFA service — CRUD + helpers + stats + audit + CSV export. + +Phase 1 Step 4: extracted from ``compliance.api.dsfa_routes``. The workflow +side (status update, section update, submit, approve, export, versions) lives +in ``compliance.services.dsfa_workflow_service``. + +Module-level helpers (_dsfa_to_response, _get_tenant_id, _log_audit) are +shared by both service modules and re-exported from +``compliance.api.dsfa_routes`` for legacy test imports. +""" + +import csv +import io +import json +import logging +from typing import Any, Optional + +from sqlalchemy import text +from sqlalchemy.orm import Session + +from compliance.domain import NotFoundError, ValidationError +from compliance.schemas.dsfa import DSFACreate, DSFAUpdate + +logger = logging.getLogger(__name__) + +DEFAULT_TENANT_ID = "9282a473-5c95-4b3a-bf78-0ecc0ec71d3e" +VALID_STATUSES = {"draft", "in-review", "approved", "needs-update"} +VALID_RISK_LEVELS = {"low", "medium", "high", "critical"} +JSONB_FIELDS = { + "data_categories", "recipients", "measures", "data_subjects", + "affected_rights", "triggered_rule_codes", "ai_trigger_ids", + "wp248_criteria_met", "art35_abs3_triggered", "tom_references", + "risks", "mitigations", "stakeholder_consultations", "review_triggers", + "review_comments", "ai_use_case_modules", "threshold_analysis", + "consultation_requirement", "review_schedule", "section_progress", + "metadata", +} + +# ---- Module-level helpers (re-exported by compliance.api.dsfa_routes) ----- + + +def _get_tenant_id(tenant_id: Optional[str]) -> str: + return tenant_id or DEFAULT_TENANT_ID + + +def _parse_arr(val: Any) -> Any: + """Parse a JSONB array field -> list.""" + if val is None: + return [] + if isinstance(val, list): + return val + if isinstance(val, str): + try: + parsed = json.loads(val) + return parsed if isinstance(parsed, list) else [] + except Exception: + return [] + return val + + +def _parse_obj(val: Any) -> Any: + """Parse a JSONB object field -> dict.""" + if val is None: + return {} + if isinstance(val, dict): + return val + if isinstance(val, str): + try: + parsed = json.loads(val) + return parsed if isinstance(parsed, dict) else {} + except Exception: + return {} + return val + + +def _ts(val: Any) -> Any: + """Timestamp -> ISO string or None.""" + if not val: + return None + return val if isinstance(val, str) else val.isoformat() + + +def _get(row: Any, key: str, default: Any = None) -> Any: + """Safe row access — returns default if key missing.""" + try: + v = row[key] + return default if v is None and default is not None else v + except (KeyError, IndexError): + return default + + +def _dsfa_to_response(row: Any) -> dict[str, Any]: + """Convert a DB row to a JSON-serializable dict.""" + g = lambda k, d=None: _get(row, k, d) # noqa: E731 + prev = g("previous_version_id") + return { + "id": str(row["id"]), + "tenant_id": row["tenant_id"], + "title": row["title"], + "description": row["description"] or "", + "status": row["status"] or "draft", + "risk_level": row["risk_level"] or "low", + "processing_activity": row["processing_activity"] or "", + "data_categories": _parse_arr(row["data_categories"]), + "recipients": _parse_arr(row["recipients"]), + "measures": _parse_arr(row["measures"]), + "approved_by": row["approved_by"], + "approved_at": _ts(row["approved_at"]), + "created_by": row["created_by"] or "system", + "created_at": _ts(row["created_at"]), + "updated_at": _ts(row["updated_at"]), + "processing_description": g("processing_description"), + "processing_purpose": g("processing_purpose"), + "legal_basis": g("legal_basis"), + "legal_basis_details": g("legal_basis_details"), + "necessity_assessment": g("necessity_assessment"), + "proportionality_assessment": g("proportionality_assessment"), + "data_minimization": g("data_minimization"), + "alternatives_considered": g("alternatives_considered"), + "retention_justification": g("retention_justification"), + "involves_ai": g("involves_ai", False), + "overall_risk_level": g("overall_risk_level"), + "risk_score": g("risk_score", 0), + "dpo_consulted": g("dpo_consulted", False), + "dpo_consulted_at": _ts(g("dpo_consulted_at")), + "dpo_name": g("dpo_name"), + "dpo_opinion": g("dpo_opinion"), + "dpo_approved": g("dpo_approved"), + "authority_consulted": g("authority_consulted", False), + "authority_consulted_at": _ts(g("authority_consulted_at")), + "authority_reference": g("authority_reference"), + "authority_decision": g("authority_decision"), + "version": g("version", 1), + "previous_version_id": str(prev) if prev else None, + "conclusion": g("conclusion"), + "federal_state": g("federal_state"), + "authority_resource_id": g("authority_resource_id"), + "submitted_for_review_at": _ts(g("submitted_for_review_at")), + "submitted_by": g("submitted_by"), + "data_subjects": _parse_arr(g("data_subjects")), + "affected_rights": _parse_arr(g("affected_rights")), + "triggered_rule_codes": _parse_arr(g("triggered_rule_codes")), + "ai_trigger_ids": _parse_arr(g("ai_trigger_ids")), + "wp248_criteria_met": _parse_arr(g("wp248_criteria_met")), + "art35_abs3_triggered": _parse_arr(g("art35_abs3_triggered")), + "tom_references": _parse_arr(g("tom_references")), + "risks": _parse_arr(g("risks")), + "mitigations": _parse_arr(g("mitigations")), + "stakeholder_consultations": _parse_arr(g("stakeholder_consultations")), + "review_triggers": _parse_arr(g("review_triggers")), + "review_comments": _parse_arr(g("review_comments")), + "ai_use_case_modules": _parse_arr(g("ai_use_case_modules")), + "section_8_complete": g("section_8_complete", False), + "threshold_analysis": _parse_obj(g("threshold_analysis")), + "consultation_requirement": _parse_obj(g("consultation_requirement")), + "review_schedule": _parse_obj(g("review_schedule")), + "section_progress": _parse_obj(g("section_progress")), + "metadata": _parse_obj(g("metadata")), + } + + +def _log_audit( + db: Session, tenant_id: str, dsfa_id: Any, action: str, + changed_by: str = "system", old_values: Any = None, + new_values: Any = None, +) -> None: + db.execute( + text(""" + INSERT INTO compliance_dsfa_audit_log + (tenant_id, dsfa_id, action, changed_by, old_values, new_values) + VALUES + (:tenant_id, :dsfa_id, :action, :changed_by, + CAST(:old_values AS jsonb), CAST(:new_values AS jsonb)) + """), + { + "tenant_id": tenant_id, + "dsfa_id": str(dsfa_id) if dsfa_id else None, + "action": action, "changed_by": changed_by, + "old_values": json.dumps(old_values) if old_values else None, + "new_values": json.dumps(new_values) if new_values else None, + }, + ) + + +# ---- Service --------------------------------------------------------------- + + +class DSFAService: + """CRUD + stats + audit-log + CSV export.""" + + def __init__(self, db: Session) -> None: + self.db = db + + def list_dsfas( + self, tenant_id: Optional[str], status: Optional[str], + risk_level: Optional[str], skip: int, limit: int, + ) -> list[dict[str, Any]]: + tid = _get_tenant_id(tenant_id) + sql = "SELECT * FROM compliance_dsfas WHERE tenant_id = :tid" + params: dict[str, Any] = {"tid": tid} + if status: + sql += " AND status = :status"; params["status"] = status + if risk_level: + sql += " AND risk_level = :risk_level"; params["risk_level"] = risk_level + sql += " ORDER BY created_at DESC LIMIT :limit OFFSET :skip" + params["limit"] = limit; params["skip"] = skip + rows = self.db.execute(text(sql), params).fetchall() + return [_dsfa_to_response(r) for r in rows] + + def create( + self, tenant_id: Optional[str], body: DSFACreate, + ) -> dict[str, Any]: + if body.status not in VALID_STATUSES: + raise ValidationError(f"Ungültiger Status: {body.status}") + if body.risk_level not in VALID_RISK_LEVELS: + raise ValidationError(f"Ungültiges Risiko-Level: {body.risk_level}") + tid = _get_tenant_id(tenant_id) + row = self.db.execute( + text(""" + INSERT INTO compliance_dsfas + (tenant_id, title, description, status, risk_level, + processing_activity, data_categories, recipients, + measures, created_by) + VALUES + (:tenant_id, :title, :description, :status, :risk_level, + :processing_activity, + CAST(:data_categories AS jsonb), + CAST(:recipients AS jsonb), + CAST(:measures AS jsonb), + :created_by) + RETURNING * + """), + { + "tenant_id": tid, "title": body.title, + "description": body.description, "status": body.status, + "risk_level": body.risk_level, + "processing_activity": body.processing_activity, + "data_categories": json.dumps(body.data_categories), + "recipients": json.dumps(body.recipients), + "measures": json.dumps(body.measures), + "created_by": body.created_by, + }, + ).fetchone() + self.db.flush() + _log_audit( + self.db, tid, row["id"], "CREATE", body.created_by, + new_values={"title": body.title, "status": body.status}, + ) + self.db.commit() + return _dsfa_to_response(row) + + def get(self, dsfa_id: str, tenant_id: Optional[str]) -> dict[str, Any]: + tid = _get_tenant_id(tenant_id) + row = self.db.execute( + text("SELECT * FROM compliance_dsfas WHERE id = :id AND tenant_id = :tid"), + {"id": dsfa_id, "tid": tid}, + ).fetchone() + if not row: + raise NotFoundError(f"DSFA {dsfa_id} nicht gefunden") + return _dsfa_to_response(row) + + def update( + self, dsfa_id: str, tenant_id: Optional[str], body: DSFAUpdate, + ) -> dict[str, Any]: + tid = _get_tenant_id(tenant_id) + existing = self.db.execute( + text("SELECT * FROM compliance_dsfas WHERE id = :id AND tenant_id = :tid"), + {"id": dsfa_id, "tid": tid}, + ).fetchone() + if not existing: + raise NotFoundError(f"DSFA {dsfa_id} nicht gefunden") + updates = body.model_dump(exclude_none=True) + if "status" in updates and updates["status"] not in VALID_STATUSES: + raise ValidationError(f"Ungültiger Status: {updates['status']}") + if "risk_level" in updates and updates["risk_level"] not in VALID_RISK_LEVELS: + raise ValidationError(f"Ungültiges Risiko-Level: {updates['risk_level']}") + if not updates: + return _dsfa_to_response(existing) + set_clauses: list[str] = [] + params: dict[str, Any] = {"id": dsfa_id, "tid": tid} + for field, value in updates.items(): + if field in JSONB_FIELDS: + set_clauses.append(f"{field} = CAST(:{field} AS jsonb)") + params[field] = json.dumps(value) + else: + set_clauses.append(f"{field} = :{field}") + params[field] = value + set_clauses.append("updated_at = NOW()") + sql = ( + f"UPDATE compliance_dsfas SET {', '.join(set_clauses)} " + f"WHERE id = :id AND tenant_id = :tid RETURNING *" + ) + old_values = {"title": existing["title"], "status": existing["status"]} + row = self.db.execute(text(sql), params).fetchone() + _log_audit(self.db, tid, dsfa_id, "UPDATE", + new_values=updates, old_values=old_values) + self.db.commit() + return _dsfa_to_response(row) + + def delete(self, dsfa_id: str, tenant_id: Optional[str]) -> dict[str, Any]: + tid = _get_tenant_id(tenant_id) + existing = self.db.execute( + text("SELECT id, title FROM compliance_dsfas WHERE id = :id AND tenant_id = :tid"), + {"id": dsfa_id, "tid": tid}, + ).fetchone() + if not existing: + raise NotFoundError(f"DSFA {dsfa_id} nicht gefunden") + _log_audit(self.db, tid, dsfa_id, "DELETE", + old_values={"title": existing["title"]}) + self.db.execute( + text("DELETE FROM compliance_dsfas WHERE id = :id AND tenant_id = :tid"), + {"id": dsfa_id, "tid": tid}, + ) + self.db.commit() + return {"success": True, "message": f"DSFA {dsfa_id} gelöscht"} + + def stats(self, tenant_id: Optional[str]) -> dict[str, Any]: + tid = _get_tenant_id(tenant_id) + rows = self.db.execute( + text("SELECT status, risk_level FROM compliance_dsfas WHERE tenant_id = :tid"), + {"tid": tid}, + ).fetchall() + by_status: dict[str, int] = {} + by_risk: dict[str, int] = {} + for row in rows: + s = row["status"] or "draft" + r = row["risk_level"] or "low" + by_status[s] = by_status.get(s, 0) + 1 + by_risk[r] = by_risk.get(r, 0) + 1 + return { + "total": len(rows), "by_status": by_status, "by_risk_level": by_risk, + "draft_count": by_status.get("draft", 0), + "in_review_count": by_status.get("in-review", 0), + "approved_count": by_status.get("approved", 0), + "needs_update_count": by_status.get("needs-update", 0), + } + + def audit_log( + self, tenant_id: Optional[str], limit: int, offset: int, + ) -> list[dict[str, Any]]: + tid = _get_tenant_id(tenant_id) + rows = self.db.execute( + text(""" + SELECT id, tenant_id, dsfa_id, action, changed_by, + old_values, new_values, created_at + FROM compliance_dsfa_audit_log + WHERE tenant_id = :tid + ORDER BY created_at DESC LIMIT :limit OFFSET :offset + """), + {"tid": tid, "limit": limit, "offset": offset}, + ).fetchall() + result: list[dict[str, Any]] = [] + for r in rows: + ca = r["created_at"] + result.append({ + "id": str(r["id"]), + "tenant_id": r["tenant_id"], + "dsfa_id": str(r["dsfa_id"]) if r["dsfa_id"] else None, + "action": r["action"], + "changed_by": r["changed_by"], + "old_values": r["old_values"], + "new_values": r["new_values"], + "created_at": ca if isinstance(ca, str) else (ca.isoformat() if ca else None), + }) + return result + + def export_csv(self, tenant_id: Optional[str]) -> str: + tid = _get_tenant_id(tenant_id) + rows = self.db.execute( + text("SELECT * FROM compliance_dsfas WHERE tenant_id = :tid ORDER BY created_at DESC"), + {"tid": tid}, + ).fetchall() + output = io.StringIO() + writer = csv.writer(output, delimiter=";") + writer.writerow(["ID", "Titel", "Status", "Risiko-Level", "Erstellt", "Aktualisiert"]) + for r in rows: + ca = r["created_at"] + ua = r["updated_at"] + writer.writerow([ + str(r["id"]), r["title"], r["status"] or "draft", r["risk_level"] or "low", + ca if isinstance(ca, str) else (ca.isoformat() if ca else ""), + ua if isinstance(ua, str) else (ua.isoformat() if ua else ""), + ]) + return output.getvalue() diff --git a/backend-compliance/compliance/services/dsfa_workflow_service.py b/backend-compliance/compliance/services/dsfa_workflow_service.py new file mode 100644 index 0000000..6ef075a --- /dev/null +++ b/backend-compliance/compliance/services/dsfa_workflow_service.py @@ -0,0 +1,347 @@ +# mypy: disable-error-code="arg-type,assignment,union-attr,no-any-return,call-overload,index" +""" +DSFA workflow service — status, section update, submit, approve, export, versions. + +Phase 1 Step 4: extracted from ``compliance.api.dsfa_routes``. CRUD + helpers +live in ``compliance.services.dsfa_service``. +""" + +import json +import logging +from datetime import datetime, timezone +from typing import Any, Optional + +from sqlalchemy import text +from sqlalchemy.orm import Session + +from compliance.api.versioning_utils import get_version, list_versions +from compliance.domain import NotFoundError, ValidationError +from compliance.schemas.dsfa import ( + DSFAApproveRequest, + DSFASectionUpdate, + DSFAStatusUpdate, +) +from compliance.services.dsfa_service import ( + VALID_STATUSES, + _dsfa_to_response, + _get_tenant_id, + _log_audit, +) + +logger = logging.getLogger(__name__) + +SECTION_FIELD_MAP: dict[int, str] = { + 1: "processing_description", + 2: "necessity_assessment", + 3: "risk_assessment", + 4: "stakeholder_consultations", + 5: "measures", + 6: "dpo_opinion", + 7: "conclusion", + 8: "ai_use_case_modules", +} + + +class DSFAWorkflowService: + """Status update, section update, submit, approve, export, versions.""" + + def __init__(self, db: Session) -> None: + self.db = db + + # ------------------------------------------------------------------ + # Status update + # ------------------------------------------------------------------ + + def update_status( + self, + dsfa_id: str, + tenant_id: Optional[str], + body: DSFAStatusUpdate, + ) -> dict[str, Any]: + if body.status not in VALID_STATUSES: + raise ValidationError(f"Ungültiger Status: {body.status}") + + tid = _get_tenant_id(tenant_id) + existing = self.db.execute( + text( + "SELECT id, status FROM compliance_dsfas " + "WHERE id = :id AND tenant_id = :tid" + ), + {"id": dsfa_id, "tid": tid}, + ).fetchone() + if not existing: + raise NotFoundError(f"DSFA {dsfa_id} nicht gefunden") + + params: dict[str, Any] = { + "id": dsfa_id, + "tid": tid, + "status": body.status, + "approved_at": ( + datetime.now(timezone.utc) + if body.status == "approved" + else None + ), + "approved_by": body.approved_by, + } + row = self.db.execute( + text(""" + UPDATE compliance_dsfas + SET status = :status, approved_at = :approved_at, + approved_by = :approved_by, updated_at = NOW() + WHERE id = :id AND tenant_id = :tid + RETURNING * + """), + params, + ).fetchone() + + _log_audit( + self.db, tid, dsfa_id, "STATUS_CHANGE", + old_values={"status": existing["status"]}, + new_values={"status": body.status}, + ) + self.db.commit() + return _dsfa_to_response(row) + + # ------------------------------------------------------------------ + # Section update + # ------------------------------------------------------------------ + + def update_section( + self, + dsfa_id: str, + section_number: int, + tenant_id: Optional[str], + body: DSFASectionUpdate, + ) -> dict[str, Any]: + if section_number < 1 or section_number > 8: + raise ValidationError( + f"Section must be 1-8, got {section_number}" + ) + + tid = _get_tenant_id(tenant_id) + existing = self.db.execute( + text( + "SELECT * FROM compliance_dsfas " + "WHERE id = :id AND tenant_id = :tid" + ), + {"id": dsfa_id, "tid": tid}, + ).fetchone() + if not existing: + raise NotFoundError(f"DSFA {dsfa_id} nicht gefunden") + + field = SECTION_FIELD_MAP[section_number] + jsonb_sections = {4, 5, 8} + + params: dict[str, Any] = {"id": dsfa_id, "tid": tid} + + if section_number in jsonb_sections: + value = ( + body.extra + if body.extra is not None + else ([] if section_number != 4 else []) + ) + params["val"] = json.dumps(value) + set_clause = f"{field} = CAST(:val AS jsonb)" + else: + params["val"] = body.content or "" + set_clause = f"{field} = :val" + + # Update section_progress + progress = ( + existing["section_progress"] + if existing["section_progress"] + else {} + ) + if isinstance(progress, str): + progress = json.loads(progress) + progress[f"section_{section_number}"] = True + params["progress"] = json.dumps(progress) + + row = self.db.execute( + text(f""" + UPDATE compliance_dsfas + SET {set_clause}, + section_progress = CAST(:progress AS jsonb), + updated_at = NOW() + WHERE id = :id AND tenant_id = :tid + RETURNING * + """), + params, + ).fetchone() + + _log_audit( + self.db, tid, dsfa_id, "SECTION_UPDATE", + new_values={"section": section_number, "field": field}, + ) + self.db.commit() + return _dsfa_to_response(row) + + # ------------------------------------------------------------------ + # Submit for review + # ------------------------------------------------------------------ + + def submit_for_review( + self, dsfa_id: str, tenant_id: Optional[str] + ) -> dict[str, Any]: + tid = _get_tenant_id(tenant_id) + existing = self.db.execute( + text( + "SELECT id, status FROM compliance_dsfas " + "WHERE id = :id AND tenant_id = :tid" + ), + {"id": dsfa_id, "tid": tid}, + ).fetchone() + if not existing: + raise NotFoundError(f"DSFA {dsfa_id} nicht gefunden") + + if existing["status"] not in ("draft", "needs-update"): + raise ValidationError( + f"Kann nur aus Status 'draft' oder 'needs-update' " + f"eingereicht werden, aktuell: {existing['status']}" + ) + + row = self.db.execute( + text(""" + UPDATE compliance_dsfas + SET status = 'in-review', + submitted_for_review_at = NOW(), + updated_at = NOW() + WHERE id = :id AND tenant_id = :tid + RETURNING * + """), + {"id": dsfa_id, "tid": tid}, + ).fetchone() + + _log_audit( + self.db, tid, dsfa_id, "SUBMIT_FOR_REVIEW", + old_values={"status": existing["status"]}, + new_values={"status": "in-review"}, + ) + self.db.commit() + return { + "message": "DSFA zur Prüfung eingereicht", + "status": "in-review", + "dsfa": _dsfa_to_response(row), + } + + # ------------------------------------------------------------------ + # Approve / reject + # ------------------------------------------------------------------ + + def approve( + self, + dsfa_id: str, + tenant_id: Optional[str], + body: DSFAApproveRequest, + ) -> dict[str, Any]: + tid = _get_tenant_id(tenant_id) + existing = self.db.execute( + text( + "SELECT id, status FROM compliance_dsfas " + "WHERE id = :id AND tenant_id = :tid" + ), + {"id": dsfa_id, "tid": tid}, + ).fetchone() + if not existing: + raise NotFoundError(f"DSFA {dsfa_id} nicht gefunden") + + if existing["status"] != "in-review": + raise ValidationError( + f"Nur DSFAs im Status 'in-review' können genehmigt werden, " + f"aktuell: {existing['status']}" + ) + + if body.approved: + new_status = "approved" + self.db.execute( + text(""" + UPDATE compliance_dsfas + SET status = 'approved', + approved_by = :approved_by, + approved_at = NOW(), + updated_at = NOW() + WHERE id = :id AND tenant_id = :tid + RETURNING * + """), + { + "id": dsfa_id, + "tid": tid, + "approved_by": body.approved_by or "system", + }, + ).fetchone() + else: + new_status = "needs-update" + self.db.execute( + text(""" + UPDATE compliance_dsfas + SET status = 'needs-update', updated_at = NOW() + WHERE id = :id AND tenant_id = :tid + RETURNING * + """), + {"id": dsfa_id, "tid": tid}, + ).fetchone() + + _log_audit( + self.db, tid, dsfa_id, + "APPROVE" if body.approved else "REJECT", + old_values={"status": existing["status"]}, + new_values={"status": new_status, "comments": body.comments}, + ) + self.db.commit() + return { + "message": ( + "DSFA genehmigt" + if body.approved + else "DSFA zurückgewiesen" + ), + "status": new_status, + } + + # ------------------------------------------------------------------ + # Export JSON + # ------------------------------------------------------------------ + + def export_json( + self, dsfa_id: str, tenant_id: Optional[str], fmt: str + ) -> dict[str, Any]: + tid = _get_tenant_id(tenant_id) + row = self.db.execute( + text( + "SELECT * FROM compliance_dsfas " + "WHERE id = :id AND tenant_id = :tid" + ), + {"id": dsfa_id, "tid": tid}, + ).fetchone() + if not row: + raise NotFoundError(f"DSFA {dsfa_id} nicht gefunden") + + dsfa_data = _dsfa_to_response(row) + return { + "exported_at": datetime.now(timezone.utc).isoformat(), + "format": fmt, + "dsfa": dsfa_data, + } + + # ------------------------------------------------------------------ + # Versions + # ------------------------------------------------------------------ + + def list_versions( + self, dsfa_id: str, tenant_id: Optional[str] + ) -> Any: + tid = _get_tenant_id(tenant_id) + return list_versions(self.db, "dsfa", dsfa_id, tid) + + def get_version( + self, + dsfa_id: str, + version_number: int, + tenant_id: Optional[str], + ) -> Any: + tid = _get_tenant_id(tenant_id) + v = get_version(self.db, "dsfa", dsfa_id, version_number, tid) + if not v: + raise NotFoundError( + f"Version {version_number} not found" + ) + return v diff --git a/backend-compliance/mypy.ini b/backend-compliance/mypy.ini index 239f79c..8425039 100644 --- a/backend-compliance/mypy.ini +++ b/backend-compliance/mypy.ini @@ -95,5 +95,7 @@ ignore_errors = False ignore_errors = False [mypy-compliance.api.legal_document_routes] ignore_errors = False +[mypy-compliance.api.dsfa_routes] +ignore_errors = False [mypy-compliance.api._http_errors] ignore_errors = False diff --git a/backend-compliance/tests/contracts/openapi.baseline.json b/backend-compliance/tests/contracts/openapi.baseline.json index e9e2235..5117cde 100644 --- a/backend-compliance/tests/contracts/openapi.baseline.json +++ b/backend-compliance/tests/contracts/openapi.baseline.json @@ -374,59 +374,6 @@ "title": "ApprovalCommentRequest", "type": "object" }, - "ApprovalHistoryEntry": { - "properties": { - "action": { - "title": "Action", - "type": "string" - }, - "approver": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Approver" - }, - "comment": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Comment" - }, - "created_at": { - "format": "date-time", - "title": "Created At", - "type": "string" - }, - "id": { - "title": "Id", - "type": "string" - }, - "version_id": { - "title": "Version Id", - "type": "string" - } - }, - "required": [ - "id", - "version_id", - "action", - "approver", - "comment", - "created_at" - ], - "title": "ApprovalHistoryEntry", - "type": "object" - }, "AssignRequest": { "properties": { "assignee_id": { @@ -19563,122 +19510,6 @@ "title": "ConsentCreate", "type": "object" }, - "compliance__api__legal_document_routes__VersionCreate": { - "properties": { - "content": { - "title": "Content", - "type": "string" - }, - "created_by": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Created By" - }, - "document_id": { - "title": "Document Id", - "type": "string" - }, - "language": { - "default": "de", - "title": "Language", - "type": "string" - }, - "summary": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Summary" - }, - "title": { - "title": "Title", - "type": "string" - }, - "version": { - "title": "Version", - "type": "string" - } - }, - "required": [ - "document_id", - "version", - "title", - "content" - ], - "title": "VersionCreate", - "type": "object" - }, - "compliance__api__legal_document_routes__VersionUpdate": { - "properties": { - "content": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Content" - }, - "language": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Language" - }, - "summary": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Summary" - }, - "title": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Title" - }, - "version": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Version" - } - }, - "title": "VersionUpdate", - "type": "object" - }, "compliance__api__notfallplan_routes__IncidentCreate": { "properties": { "affected_data_categories": { @@ -20361,6 +20192,122 @@ ], "title": "StatusUpdate", "type": "object" + }, + "compliance__schemas__legal_document__VersionCreate": { + "properties": { + "content": { + "title": "Content", + "type": "string" + }, + "created_by": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Created By" + }, + "document_id": { + "title": "Document Id", + "type": "string" + }, + "language": { + "default": "de", + "title": "Language", + "type": "string" + }, + "summary": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Summary" + }, + "title": { + "title": "Title", + "type": "string" + }, + "version": { + "title": "Version", + "type": "string" + } + }, + "required": [ + "document_id", + "version", + "title", + "content" + ], + "title": "VersionCreate", + "type": "object" + }, + "compliance__schemas__legal_document__VersionUpdate": { + "properties": { + "content": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Content" + }, + "language": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Language" + }, + "summary": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Summary" + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Title" + }, + "version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Version" + } + }, + "title": "VersionUpdate", + "type": "object" } } }, @@ -23428,7 +23375,7 @@ }, "/api/compliance/controls/paginated": { "get": { - "description": "List controls with pagination and eager-loaded relationships.\n\nThis endpoint is optimized for large datasets with:\n- Eager loading to prevent N+1 queries\n- Server-side pagination\n- Full-text search support", + "description": "List controls with pagination.", "operationId": "list_controls_paginated_api_compliance_controls_paginated_get", "parameters": [ { @@ -23708,13 +23655,17 @@ }, "/api/compliance/create-indexes": { "post": { - "description": "Create additional performance indexes for large datasets.\n\nThese indexes are optimized for:\n- Pagination queries (1000+ requirements)\n- Full-text search\n- Filtering by status/priority", + "description": "Create additional performance indexes.", "operationId": "create_performance_indexes_api_compliance_create_indexes_post", "responses": { "200": { "content": { "application/json": { - "schema": {} + "schema": { + "additionalProperties": true, + "title": "Response Create Performance Indexes Api Compliance Create Indexes Post", + "type": "object" + } } }, "description": "Successful Response" @@ -23821,7 +23772,7 @@ }, "/api/compliance/dsfa": { "get": { - "description": "Liste aller DSFAs f\u00fcr einen Tenant.", + "description": "Liste aller DSFAs fuer einen Tenant.", "operationId": "list_dsfas_api_compliance_dsfa_get", "parameters": [ { @@ -23900,7 +23851,14 @@ "200": { "content": { "application/json": { - "schema": {} + "schema": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "title": "Response List Dsfas Api Compliance Dsfa Get", + "type": "array" + } } }, "description": "Successful Response" @@ -23957,7 +23915,11 @@ "201": { "content": { "application/json": { - "schema": {} + "schema": { + "additionalProperties": true, + "title": "Response Create Dsfa Api Compliance Dsfa Post", + "type": "object" + } } }, "description": "Successful Response" @@ -24029,7 +23991,14 @@ "200": { "content": { "application/json": { - "schema": {} + "schema": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "title": "Response Get Audit Log Api Compliance Dsfa Audit Log Get", + "type": "array" + } } }, "description": "Successful Response" @@ -24081,7 +24050,13 @@ "501": { "content": { "application/json": { - "schema": {} + "schema": { + "additionalProperties": { + "type": "string" + }, + "title": "Response Get By Assessment Api Compliance Dsfa By Assessment Assessment Id Get", + "type": "object" + } } }, "description": "Successful Response" @@ -24145,7 +24120,7 @@ }, "/api/compliance/dsfa/from-assessment/{assessment_id}": { "post": { - "description": "Stub: Create DSFA from UCCA assessment. Requires cross-service communication.", + "description": "Stub: Create DSFA from UCCA assessment.", "operationId": "create_from_assessment_api_compliance_dsfa_from_assessment__assessment_id__post", "parameters": [ { @@ -24172,7 +24147,13 @@ "501": { "content": { "application/json": { - "schema": {} + "schema": { + "additionalProperties": { + "type": "string" + }, + "title": "Response Create From Assessment Api Compliance Dsfa From Assessment Assessment Id Post", + "type": "object" + } } }, "description": "Successful Response" @@ -24187,7 +24168,7 @@ }, "/api/compliance/dsfa/stats": { "get": { - "description": "Z\u00e4hler nach Status und Risiko-Level.", + "description": "Zaehler nach Status und Risiko-Level.", "operationId": "get_stats_api_compliance_dsfa_stats_get", "parameters": [ { @@ -24211,7 +24192,11 @@ "200": { "content": { "application/json": { - "schema": {} + "schema": { + "additionalProperties": true, + "title": "Response Get Stats Api Compliance Dsfa Stats Get", + "type": "object" + } } }, "description": "Successful Response" @@ -24236,7 +24221,7 @@ }, "/api/compliance/dsfa/{dsfa_id}": { "delete": { - "description": "DSFA l\u00f6schen (Art. 17 DSGVO).", + "description": "DSFA loeschen (Art. 17 DSGVO).", "operationId": "delete_dsfa_api_compliance_dsfa__dsfa_id__delete", "parameters": [ { @@ -24269,7 +24254,11 @@ "200": { "content": { "application/json": { - "schema": {} + "schema": { + "additionalProperties": true, + "title": "Response Delete Dsfa Api Compliance Dsfa Dsfa Id Delete", + "type": "object" + } } }, "description": "Successful Response" @@ -24325,7 +24314,11 @@ "200": { "content": { "application/json": { - "schema": {} + "schema": { + "additionalProperties": true, + "title": "Response Get Dsfa Api Compliance Dsfa Dsfa Id Get", + "type": "object" + } } }, "description": "Successful Response" @@ -24391,7 +24384,11 @@ "200": { "content": { "application/json": { - "schema": {} + "schema": { + "additionalProperties": true, + "title": "Response Update Dsfa Api Compliance Dsfa Dsfa Id Put", + "type": "object" + } } }, "description": "Successful Response" @@ -24459,7 +24456,11 @@ "200": { "content": { "application/json": { - "schema": {} + "schema": { + "additionalProperties": true, + "title": "Response Approve Dsfa Api Compliance Dsfa Dsfa Id Approve Post", + "type": "object" + } } }, "description": "Successful Response" @@ -24527,7 +24528,11 @@ "200": { "content": { "application/json": { - "schema": {} + "schema": { + "additionalProperties": true, + "title": "Response Export Dsfa Json Api Compliance Dsfa Dsfa Id Export Get", + "type": "object" + } } }, "description": "Successful Response" @@ -24604,7 +24609,11 @@ "200": { "content": { "application/json": { - "schema": {} + "schema": { + "additionalProperties": true, + "title": "Response Update Section Api Compliance Dsfa Dsfa Id Sections Section Number Put", + "type": "object" + } } }, "description": "Successful Response" @@ -24672,7 +24681,11 @@ "200": { "content": { "application/json": { - "schema": {} + "schema": { + "additionalProperties": true, + "title": "Response Update Dsfa Status Api Compliance Dsfa Dsfa Id Status Patch", + "type": "object" + } } }, "description": "Successful Response" @@ -24697,7 +24710,7 @@ }, "/api/compliance/dsfa/{dsfa_id}/submit-for-review": { "post": { - "description": "Submit a DSFA for DPO review (draft \u2192 in-review).", + "description": "Submit a DSFA for DPO review (draft -> in-review).", "operationId": "submit_for_review_api_compliance_dsfa__dsfa_id__submit_for_review_post", "parameters": [ { @@ -24730,7 +24743,11 @@ "200": { "content": { "application/json": { - "schema": {} + "schema": { + "additionalProperties": true, + "title": "Response Submit For Review Api Compliance Dsfa Dsfa Id Submit For Review Post", + "type": "object" + } } }, "description": "Successful Response" @@ -24788,7 +24805,9 @@ "200": { "content": { "application/json": { - "schema": {} + "schema": { + "title": "Response List Dsfa Versions Api Compliance Dsfa Dsfa Id Versions Get" + } } }, "description": "Successful Response" @@ -24855,7 +24874,9 @@ "200": { "content": { "application/json": { - "schema": {} + "schema": { + "title": "Response Get Dsfa Version Api Compliance Dsfa Dsfa Id Versions Version Number Get" + } } }, "description": "Successful Response" @@ -30989,7 +31010,11 @@ "200": { "content": { "application/json": { - "schema": {} + "schema": { + "additionalProperties": true, + "title": "Response Init Tables Api Compliance Init Tables Post", + "type": "object" + } } }, "description": "Successful Response" @@ -33186,7 +33211,6 @@ }, "/api/compliance/legal-documents/audit-log": { "get": { - "description": "Consent audit trail (paginated).", "operationId": "get_audit_log_api_compliance_legal_documents_audit_log_get", "parameters": [ { @@ -33265,7 +33289,11 @@ "200": { "content": { "application/json": { - "schema": {} + "schema": { + "additionalProperties": true, + "title": "Response Get Audit Log Api Compliance Legal Documents Audit Log Get", + "type": "object" + } } }, "description": "Successful Response" @@ -33290,7 +33318,6 @@ }, "/api/compliance/legal-documents/consents": { "post": { - "description": "Record user consent for a legal document.", "operationId": "record_consent_api_compliance_legal_documents_consents_post", "parameters": [ { @@ -33324,7 +33351,11 @@ "200": { "content": { "application/json": { - "schema": {} + "schema": { + "additionalProperties": true, + "title": "Response Record Consent Api Compliance Legal Documents Consents Post", + "type": "object" + } } }, "description": "Successful Response" @@ -33349,7 +33380,6 @@ }, "/api/compliance/legal-documents/consents/check/{document_type}": { "get": { - "description": "Check if user has active consent for a document type.", "operationId": "check_consent_api_compliance_legal_documents_consents_check__document_type__get", "parameters": [ { @@ -33391,7 +33421,11 @@ "200": { "content": { "application/json": { - "schema": {} + "schema": { + "additionalProperties": true, + "title": "Response Check Consent Api Compliance Legal Documents Consents Check Document Type Get", + "type": "object" + } } }, "description": "Successful Response" @@ -33416,7 +33450,6 @@ }, "/api/compliance/legal-documents/consents/my": { "get": { - "description": "Get all consents for a specific user.", "operationId": "get_my_consents_api_compliance_legal_documents_consents_my_get", "parameters": [ { @@ -33449,7 +33482,14 @@ "200": { "content": { "application/json": { - "schema": {} + "schema": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "title": "Response Get My Consents Api Compliance Legal Documents Consents My Get", + "type": "array" + } } }, "description": "Successful Response" @@ -33474,7 +33514,6 @@ }, "/api/compliance/legal-documents/consents/{consent_id}": { "delete": { - "description": "Withdraw a consent (DSGVO Art. 7 Abs. 3).", "operationId": "withdraw_consent_api_compliance_legal_documents_consents__consent_id__delete", "parameters": [ { @@ -33507,7 +33546,11 @@ "200": { "content": { "application/json": { - "schema": {} + "schema": { + "additionalProperties": true, + "title": "Response Withdraw Consent Api Compliance Legal Documents Consents Consent Id Delete", + "type": "object" + } } }, "description": "Successful Response" @@ -33532,7 +33575,6 @@ }, "/api/compliance/legal-documents/cookie-categories": { "get": { - "description": "List all cookie categories.", "operationId": "list_cookie_categories_api_compliance_legal_documents_cookie_categories_get", "parameters": [ { @@ -33556,7 +33598,14 @@ "200": { "content": { "application/json": { - "schema": {} + "schema": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "title": "Response List Cookie Categories Api Compliance Legal Documents Cookie Categories Get", + "type": "array" + } } }, "description": "Successful Response" @@ -33579,7 +33628,6 @@ ] }, "post": { - "description": "Create a cookie category.", "operationId": "create_cookie_category_api_compliance_legal_documents_cookie_categories_post", "parameters": [ { @@ -33613,7 +33661,11 @@ "200": { "content": { "application/json": { - "schema": {} + "schema": { + "additionalProperties": true, + "title": "Response Create Cookie Category Api Compliance Legal Documents Cookie Categories Post", + "type": "object" + } } }, "description": "Successful Response" @@ -33638,7 +33690,6 @@ }, "/api/compliance/legal-documents/cookie-categories/{category_id}": { "delete": { - "description": "Delete a cookie category.", "operationId": "delete_cookie_category_api_compliance_legal_documents_cookie_categories__category_id__delete", "parameters": [ { @@ -33689,7 +33740,6 @@ ] }, "put": { - "description": "Update a cookie category.", "operationId": "update_cookie_category_api_compliance_legal_documents_cookie_categories__category_id__put", "parameters": [ { @@ -33732,7 +33782,11 @@ "200": { "content": { "application/json": { - "schema": {} + "schema": { + "additionalProperties": true, + "title": "Response Update Cookie Category Api Compliance Legal Documents Cookie Categories Category Id Put", + "type": "object" + } } }, "description": "Successful Response" @@ -33757,7 +33811,6 @@ }, "/api/compliance/legal-documents/documents": { "get": { - "description": "List all legal documents, optionally filtered by tenant or type.", "operationId": "list_documents_api_compliance_legal_documents_documents_get", "parameters": [ { @@ -33824,7 +33877,6 @@ ] }, "post": { - "description": "Create a new legal document type.", "operationId": "create_document_api_compliance_legal_documents_documents_post", "requestBody": { "content": { @@ -33867,7 +33919,6 @@ }, "/api/compliance/legal-documents/documents/{document_id}": { "delete": { - "description": "Delete a legal document and all its versions.", "operationId": "delete_document_api_compliance_legal_documents_documents__document_id__delete", "parameters": [ { @@ -33902,7 +33953,6 @@ ] }, "get": { - "description": "Get a single legal document by ID.", "operationId": "get_document_api_compliance_legal_documents_documents__document_id__get", "parameters": [ { @@ -33946,7 +33996,6 @@ }, "/api/compliance/legal-documents/documents/{document_id}/versions": { "get": { - "description": "List all versions for a legal document.", "operationId": "list_versions_api_compliance_legal_documents_documents__document_id__versions_get", "parameters": [ { @@ -33994,7 +34043,6 @@ }, "/api/compliance/legal-documents/public": { "get": { - "description": "Active documents for end-user display.", "operationId": "list_public_documents_api_compliance_legal_documents_public_get", "parameters": [ { @@ -34018,7 +34066,14 @@ "200": { "content": { "application/json": { - "schema": {} + "schema": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "title": "Response List Public Documents Api Compliance Legal Documents Public Get", + "type": "array" + } } }, "description": "Successful Response" @@ -34043,7 +34098,6 @@ }, "/api/compliance/legal-documents/public/{document_type}/latest": { "get": { - "description": "Get the latest published version of a document type.", "operationId": "get_latest_published_api_compliance_legal_documents_public__document_type__latest_get", "parameters": [ { @@ -34086,7 +34140,11 @@ "200": { "content": { "application/json": { - "schema": {} + "schema": { + "additionalProperties": true, + "title": "Response Get Latest Published Api Compliance Legal Documents Public Document Type Latest Get", + "type": "object" + } } }, "description": "Successful Response" @@ -34111,7 +34169,6 @@ }, "/api/compliance/legal-documents/stats/consents": { "get": { - "description": "Consent statistics for dashboard.", "operationId": "get_consent_stats_api_compliance_legal_documents_stats_consents_get", "parameters": [ { @@ -34135,7 +34192,11 @@ "200": { "content": { "application/json": { - "schema": {} + "schema": { + "additionalProperties": true, + "title": "Response Get Consent Stats Api Compliance Legal Documents Stats Consents Get", + "type": "object" + } } }, "description": "Successful Response" @@ -34160,13 +34221,12 @@ }, "/api/compliance/legal-documents/versions": { "post": { - "description": "Create a new version for a legal document.", "operationId": "create_version_api_compliance_legal_documents_versions_post", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/compliance__api__legal_document_routes__VersionCreate" + "$ref": "#/components/schemas/compliance__schemas__legal_document__VersionCreate" } } }, @@ -34203,7 +34263,6 @@ }, "/api/compliance/legal-documents/versions/upload-word": { "post": { - "description": "Convert DOCX to HTML using mammoth (if available) or return raw text.", "operationId": "upload_word_api_compliance_legal_documents_versions_upload_word_post", "requestBody": { "content": { @@ -34248,7 +34307,6 @@ }, "/api/compliance/legal-documents/versions/{version_id}": { "get": { - "description": "Get a single version by ID.", "operationId": "get_version_api_compliance_legal_documents_versions__version_id__get", "parameters": [ { @@ -34290,7 +34348,6 @@ ] }, "put": { - "description": "Update a draft legal document version.", "operationId": "update_version_api_compliance_legal_documents_versions__version_id__put", "parameters": [ { @@ -34307,7 +34364,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/compliance__api__legal_document_routes__VersionUpdate" + "$ref": "#/components/schemas/compliance__schemas__legal_document__VersionUpdate" } } }, @@ -34344,7 +34401,6 @@ }, "/api/compliance/legal-documents/versions/{version_id}/approval-history": { "get": { - "description": "Get the full approval audit trail for a version.", "operationId": "get_approval_history_api_compliance_legal_documents_versions__version_id__approval_history_get", "parameters": [ { @@ -34363,7 +34419,8 @@ "application/json": { "schema": { "items": { - "$ref": "#/components/schemas/ApprovalHistoryEntry" + "additionalProperties": true, + "type": "object" }, "title": "Response Get Approval History Api Compliance Legal Documents Versions Version Id Approval History Get", "type": "array" @@ -34392,7 +34449,6 @@ }, "/api/compliance/legal-documents/versions/{version_id}/approve": { "post": { - "description": "Approve a version under review.", "operationId": "approve_version_api_compliance_legal_documents_versions__version_id__approve_post", "parameters": [ { @@ -34446,7 +34502,6 @@ }, "/api/compliance/legal-documents/versions/{version_id}/publish": { "post": { - "description": "Publish an approved version.", "operationId": "publish_version_api_compliance_legal_documents_versions__version_id__publish_post", "parameters": [ { @@ -34500,7 +34555,6 @@ }, "/api/compliance/legal-documents/versions/{version_id}/reject": { "post": { - "description": "Reject a version under review.", "operationId": "reject_version_api_compliance_legal_documents_versions__version_id__reject_post", "parameters": [ { @@ -34554,7 +34608,6 @@ }, "/api/compliance/legal-documents/versions/{version_id}/submit-review": { "post": { - "description": "Submit a draft version for review.", "operationId": "submit_review_api_compliance_legal_documents_versions__version_id__submit_review_post", "parameters": [ { @@ -39445,7 +39498,7 @@ }, "/api/compliance/requirements": { "get": { - "description": "List requirements with pagination and eager-loaded relationships.\n\nThis endpoint is optimized for large datasets (1000+ requirements) with:\n- Eager loading to prevent N+1 queries\n- Server-side pagination\n- Full-text search support", + "description": "List requirements with pagination.", "operationId": "list_requirements_paginated_api_compliance_requirements_get", "parameters": [ { @@ -39635,7 +39688,11 @@ "200": { "content": { "application/json": { - "schema": {} + "schema": { + "additionalProperties": true, + "title": "Response Delete Requirement Api Compliance Requirements Requirement Id Delete", + "type": "object" + } } }, "description": "Successful Response" @@ -39686,7 +39743,11 @@ "200": { "content": { "application/json": { - "schema": {} + "schema": { + "additionalProperties": true, + "title": "Response Get Requirement Api Compliance Requirements Requirement Id Get", + "type": "object" + } } }, "description": "Successful Response" @@ -39737,7 +39798,11 @@ "200": { "content": { "application/json": { - "schema": {} + "schema": { + "additionalProperties": true, + "title": "Response Update Requirement Api Compliance Requirements Requirement Id Put", + "type": "object" + } } }, "description": "Successful Response" @@ -40741,13 +40806,17 @@ }, "/api/compliance/seed-risks": { "post": { - "description": "Seed only risks (incremental update for existing databases).", + "description": "Seed only risks.", "operationId": "seed_risks_only_api_compliance_seed_risks_post", "responses": { "200": { "content": { "application/json": { - "schema": {} + "schema": { + "additionalProperties": true, + "title": "Response Seed Risks Only Api Compliance Seed Risks Post", + "type": "object" + } } }, "description": "Successful Response" diff --git a/backend-compliance/tests/test_dsfa_routes.py b/backend-compliance/tests/test_dsfa_routes.py index 6601ced..14497b4 100644 --- a/backend-compliance/tests/test_dsfa_routes.py +++ b/backend-compliance/tests/test_dsfa_routes.py @@ -712,11 +712,11 @@ class TestDSFARouteCRUD: def test_create_invalid_status(self): resp = client.post("/api/compliance/dsfa", json={"title": "Bad", "status": "invalid"}) - assert resp.status_code == 422 + assert resp.status_code == 400 # ValidationError -> 400 def test_create_invalid_risk_level(self): resp = client.post("/api/compliance/dsfa", json={"title": "Bad", "risk_level": "extreme"}) - assert resp.status_code == 422 + assert resp.status_code == 400 # ValidationError -> 400 # ============================================================================= @@ -760,7 +760,7 @@ class TestDSFARouteStatusPatch: f"/api/compliance/dsfa/{created['id']}/status", json={"status": "bogus"}, ) - assert resp.status_code == 422 + assert resp.status_code == 400 # ValidationError -> 400 def test_patch_status_not_found(self): resp = client.patch( @@ -810,7 +810,7 @@ class TestDSFARouteSectionUpdate: f"/api/compliance/dsfa/{created['id']}/sections/9", json={"content": "X"}, ) - assert resp.status_code == 422 + assert resp.status_code == 400 # ValidationError -> 400 def test_update_section_not_found(self): resp = client.put( @@ -839,7 +839,7 @@ class TestDSFARouteWorkflow: client.post(f"/api/compliance/dsfa/{created['id']}/submit-for-review") # Try to submit again (already in-review) resp = client.post(f"/api/compliance/dsfa/{created['id']}/submit-for-review") - assert resp.status_code == 422 + assert resp.status_code == 400 # ValidationError -> 400 def test_submit_not_found(self): resp = client.post(f"/api/compliance/dsfa/{uuid.uuid4()}/submit-for-review") @@ -871,7 +871,7 @@ class TestDSFARouteWorkflow: f"/api/compliance/dsfa/{created['id']}/approve", json={"approved": True}, ) - assert resp.status_code == 422 + assert resp.status_code == 400 # ValidationError -> 400 def test_approve_not_found(self): resp = client.post(