compliance/api/dsr_routes.py (1176 LOC) -> 369 LOC thin routes +
469-line DsrService + 487-line DsrWorkflowService + 101-line schemas.
Two-service split for Data Subject Request (DSGVO Art. 15-22):
- dsr_service.py: CRUD, list, stats, export, audit log
- dsr_workflow_service.py: identity verification, processing,
portability, escalation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
370 lines
10 KiB
Python
370 lines
10 KiB
Python
"""
|
|
DSR (Data Subject Request) Routes — Betroffenenanfragen nach DSGVO Art. 15-21.
|
|
|
|
Phase 1 Step 4 refactor: thin handlers delegate to DSRService (CRUD/stats/
|
|
export/deadlines) and DSRWorkflowService (status/identity/assign/complete/
|
|
reject/communications/exception-checks/templates).
|
|
"""
|
|
|
|
from typing import Optional
|
|
|
|
from fastapi import APIRouter, Depends, Query, Header
|
|
from fastapi.responses import StreamingResponse
|
|
from sqlalchemy.orm import Session
|
|
|
|
from classroom_engine.database import get_db
|
|
from compliance.api._http_errors import translate_domain_errors
|
|
from compliance.schemas.dsr import (
|
|
AssignRequest,
|
|
CompleteDSR,
|
|
CreateTemplateVersion,
|
|
DSRCreate,
|
|
DSRUpdate,
|
|
ExtendDeadline,
|
|
RejectDSR,
|
|
SendCommunication,
|
|
StatusChange,
|
|
UpdateExceptionCheck,
|
|
VerifyIdentity,
|
|
)
|
|
from compliance.services.dsr_service import (
|
|
ART17_EXCEPTIONS,
|
|
DEFAULT_TENANT,
|
|
DEADLINE_DAYS,
|
|
DSRService,
|
|
VALID_PRIORITIES,
|
|
VALID_REQUEST_TYPES,
|
|
VALID_SOURCES,
|
|
VALID_STATUSES,
|
|
_dsr_to_dict,
|
|
_generate_request_number,
|
|
_get_dsr_or_404,
|
|
_record_history,
|
|
)
|
|
from compliance.services.dsr_workflow_service import DSRWorkflowService
|
|
|
|
router = APIRouter(prefix="/dsr", tags=["compliance-dsr"])
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# DI helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _get_tenant(x_tenant_id: Optional[str] = Header(None, alias='X-Tenant-ID')) -> str:
|
|
return x_tenant_id or DEFAULT_TENANT
|
|
|
|
|
|
def _dsr_svc(db: Session = Depends(get_db)) -> DSRService:
|
|
return DSRService(db)
|
|
|
|
|
|
def _wf_svc(db: Session = Depends(get_db)) -> DSRWorkflowService:
|
|
return DSRWorkflowService(db)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CRUD
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@router.post("")
|
|
async def create_dsr(
|
|
body: DSRCreate,
|
|
tenant_id: str = Depends(_get_tenant),
|
|
svc: DSRService = Depends(_dsr_svc),
|
|
):
|
|
with translate_domain_errors():
|
|
return svc.create(body, tenant_id)
|
|
|
|
|
|
@router.get("")
|
|
async def list_dsrs(
|
|
status: Optional[str] = Query(None),
|
|
request_type: Optional[str] = Query(None),
|
|
assigned_to: Optional[str] = Query(None),
|
|
priority: Optional[str] = Query(None),
|
|
overdue_only: bool = Query(False),
|
|
search: Optional[str] = Query(None),
|
|
from_date: Optional[str] = Query(None),
|
|
to_date: Optional[str] = Query(None),
|
|
limit: int = Query(20, ge=1, le=100),
|
|
offset: int = Query(0, ge=0),
|
|
tenant_id: str = Depends(_get_tenant),
|
|
svc: DSRService = Depends(_dsr_svc),
|
|
):
|
|
with translate_domain_errors():
|
|
return svc.list(
|
|
tenant_id, status=status, request_type=request_type,
|
|
assigned_to=assigned_to, priority=priority,
|
|
overdue_only=overdue_only, search=search,
|
|
from_date=from_date, to_date=to_date,
|
|
limit=limit, offset=offset,
|
|
)
|
|
|
|
|
|
@router.get("/stats")
|
|
async def get_dsr_stats(
|
|
tenant_id: str = Depends(_get_tenant),
|
|
svc: DSRService = Depends(_dsr_svc),
|
|
):
|
|
with translate_domain_errors():
|
|
return svc.stats(tenant_id)
|
|
|
|
|
|
@router.get("/export")
|
|
async def export_dsrs(
|
|
format: str = Query("csv", pattern="^(csv|json)$"),
|
|
tenant_id: str = Depends(_get_tenant),
|
|
svc: DSRService = Depends(_dsr_svc),
|
|
):
|
|
with translate_domain_errors():
|
|
result = svc.export(tenant_id, fmt=format)
|
|
if format == "json":
|
|
return result
|
|
return StreamingResponse(
|
|
result,
|
|
media_type="text/csv; charset=utf-8",
|
|
headers={"Content-Disposition": "attachment; filename=dsr_export.csv"},
|
|
)
|
|
|
|
|
|
@router.post("/deadlines/process")
|
|
async def process_deadlines(
|
|
tenant_id: str = Depends(_get_tenant),
|
|
svc: DSRService = Depends(_dsr_svc),
|
|
):
|
|
with translate_domain_errors():
|
|
return svc.process_deadlines(tenant_id)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Templates (static paths before /{dsr_id})
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@router.get("/templates")
|
|
async def get_templates(
|
|
tenant_id: str = Depends(_get_tenant),
|
|
svc: DSRWorkflowService = Depends(_wf_svc),
|
|
):
|
|
with translate_domain_errors():
|
|
return svc.get_templates(tenant_id)
|
|
|
|
|
|
@router.get("/templates/published")
|
|
async def get_published_templates(
|
|
request_type: Optional[str] = Query(None),
|
|
language: str = Query("de"),
|
|
tenant_id: str = Depends(_get_tenant),
|
|
svc: DSRWorkflowService = Depends(_wf_svc),
|
|
):
|
|
with translate_domain_errors():
|
|
return svc.get_published_templates(
|
|
tenant_id, request_type=request_type, language=language,
|
|
)
|
|
|
|
|
|
@router.get("/templates/{template_id}/versions")
|
|
async def get_template_versions(
|
|
template_id: str,
|
|
tenant_id: str = Depends(_get_tenant),
|
|
svc: DSRWorkflowService = Depends(_wf_svc),
|
|
):
|
|
with translate_domain_errors():
|
|
return svc.get_template_versions(template_id, tenant_id)
|
|
|
|
|
|
@router.post("/templates/{template_id}/versions")
|
|
async def create_template_version(
|
|
template_id: str,
|
|
body: CreateTemplateVersion,
|
|
tenant_id: str = Depends(_get_tenant),
|
|
svc: DSRWorkflowService = Depends(_wf_svc),
|
|
):
|
|
with translate_domain_errors():
|
|
return svc.create_template_version(template_id, body, tenant_id)
|
|
|
|
|
|
@router.put("/template-versions/{version_id}/publish")
|
|
async def publish_template_version(
|
|
version_id: str,
|
|
tenant_id: str = Depends(_get_tenant),
|
|
svc: DSRWorkflowService = Depends(_wf_svc),
|
|
):
|
|
with translate_domain_errors():
|
|
return svc.publish_template_version(version_id, tenant_id)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Single DSR (parameterized — after static paths)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@router.get("/{dsr_id}")
|
|
async def get_dsr(
|
|
dsr_id: str,
|
|
tenant_id: str = Depends(_get_tenant),
|
|
svc: DSRService = Depends(_dsr_svc),
|
|
):
|
|
with translate_domain_errors():
|
|
return svc.get(dsr_id, tenant_id)
|
|
|
|
|
|
@router.put("/{dsr_id}")
|
|
async def update_dsr(
|
|
dsr_id: str,
|
|
body: DSRUpdate,
|
|
tenant_id: str = Depends(_get_tenant),
|
|
svc: DSRService = Depends(_dsr_svc),
|
|
):
|
|
with translate_domain_errors():
|
|
return svc.update(dsr_id, body, tenant_id)
|
|
|
|
|
|
@router.delete("/{dsr_id}")
|
|
async def delete_dsr(
|
|
dsr_id: str,
|
|
tenant_id: str = Depends(_get_tenant),
|
|
svc: DSRService = Depends(_dsr_svc),
|
|
):
|
|
with translate_domain_errors():
|
|
return svc.delete(dsr_id, tenant_id)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Workflow actions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@router.post("/{dsr_id}/status")
|
|
async def change_status(
|
|
dsr_id: str,
|
|
body: StatusChange,
|
|
tenant_id: str = Depends(_get_tenant),
|
|
svc: DSRWorkflowService = Depends(_wf_svc),
|
|
):
|
|
with translate_domain_errors():
|
|
return svc.change_status(dsr_id, body, tenant_id)
|
|
|
|
|
|
@router.post("/{dsr_id}/verify-identity")
|
|
async def verify_identity(
|
|
dsr_id: str,
|
|
body: VerifyIdentity,
|
|
tenant_id: str = Depends(_get_tenant),
|
|
svc: DSRWorkflowService = Depends(_wf_svc),
|
|
):
|
|
with translate_domain_errors():
|
|
return svc.verify_identity(dsr_id, body, tenant_id)
|
|
|
|
|
|
@router.post("/{dsr_id}/assign")
|
|
async def assign_dsr(
|
|
dsr_id: str,
|
|
body: AssignRequest,
|
|
tenant_id: str = Depends(_get_tenant),
|
|
svc: DSRWorkflowService = Depends(_wf_svc),
|
|
):
|
|
with translate_domain_errors():
|
|
return svc.assign(dsr_id, body, tenant_id)
|
|
|
|
|
|
@router.post("/{dsr_id}/extend")
|
|
async def extend_deadline(
|
|
dsr_id: str,
|
|
body: ExtendDeadline,
|
|
tenant_id: str = Depends(_get_tenant),
|
|
svc: DSRWorkflowService = Depends(_wf_svc),
|
|
):
|
|
with translate_domain_errors():
|
|
return svc.extend_deadline(dsr_id, body, tenant_id)
|
|
|
|
|
|
@router.post("/{dsr_id}/complete")
|
|
async def complete_dsr(
|
|
dsr_id: str,
|
|
body: CompleteDSR,
|
|
tenant_id: str = Depends(_get_tenant),
|
|
svc: DSRWorkflowService = Depends(_wf_svc),
|
|
):
|
|
with translate_domain_errors():
|
|
return svc.complete(dsr_id, body, tenant_id)
|
|
|
|
|
|
@router.post("/{dsr_id}/reject")
|
|
async def reject_dsr(
|
|
dsr_id: str,
|
|
body: RejectDSR,
|
|
tenant_id: str = Depends(_get_tenant),
|
|
svc: DSRWorkflowService = Depends(_wf_svc),
|
|
):
|
|
with translate_domain_errors():
|
|
return svc.reject(dsr_id, body, tenant_id)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# History & Communications
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@router.get("/{dsr_id}/history")
|
|
async def get_history(
|
|
dsr_id: str,
|
|
tenant_id: str = Depends(_get_tenant),
|
|
svc: DSRWorkflowService = Depends(_wf_svc),
|
|
):
|
|
with translate_domain_errors():
|
|
return svc.get_history(dsr_id, tenant_id)
|
|
|
|
|
|
@router.get("/{dsr_id}/communications")
|
|
async def get_communications(
|
|
dsr_id: str,
|
|
tenant_id: str = Depends(_get_tenant),
|
|
svc: DSRWorkflowService = Depends(_wf_svc),
|
|
):
|
|
with translate_domain_errors():
|
|
return svc.get_communications(dsr_id, tenant_id)
|
|
|
|
|
|
@router.post("/{dsr_id}/communicate")
|
|
async def send_communication(
|
|
dsr_id: str,
|
|
body: SendCommunication,
|
|
tenant_id: str = Depends(_get_tenant),
|
|
svc: DSRWorkflowService = Depends(_wf_svc),
|
|
):
|
|
with translate_domain_errors():
|
|
return svc.send_communication(dsr_id, body, tenant_id)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Exception Checks (Art. 17)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@router.get("/{dsr_id}/exception-checks")
|
|
async def get_exception_checks(
|
|
dsr_id: str,
|
|
tenant_id: str = Depends(_get_tenant),
|
|
svc: DSRWorkflowService = Depends(_wf_svc),
|
|
):
|
|
with translate_domain_errors():
|
|
return svc.get_exception_checks(dsr_id, tenant_id)
|
|
|
|
|
|
@router.post("/{dsr_id}/exception-checks/init")
|
|
async def init_exception_checks(
|
|
dsr_id: str,
|
|
tenant_id: str = Depends(_get_tenant),
|
|
svc: DSRWorkflowService = Depends(_wf_svc),
|
|
):
|
|
with translate_domain_errors():
|
|
return svc.init_exception_checks(dsr_id, tenant_id)
|
|
|
|
|
|
@router.put("/{dsr_id}/exception-checks/{check_id}")
|
|
async def update_exception_check(
|
|
dsr_id: str,
|
|
check_id: str,
|
|
body: UpdateExceptionCheck,
|
|
tenant_id: str = Depends(_get_tenant),
|
|
svc: DSRWorkflowService = Depends(_wf_svc),
|
|
):
|
|
with translate_domain_errors():
|
|
return svc.update_exception_check(dsr_id, check_id, body, tenant_id)
|