Files
breakpilot-compliance/backend-compliance/compliance/services/dsfa_workflow_service.py
Sharang Parnerkar ae008d7d25 refactor(backend/api): extract DSFA schemas + services (Step 4 — file 14 of 18)
- 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) <noreply@anthropic.com>
2026-04-09 19:20:48 +02:00

348 lines
11 KiB
Python

# 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