# 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