- 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>
348 lines
11 KiB
Python
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
|