refactor(backend/api): extract VVTService (Step 4 — file 5 of 18)
compliance/api/vvt_routes.py (550 LOC) -> 225 LOC thin routes + 475-line VVTService. Covers the organization header, processing activities CRUD, audit log, JSON/CSV export, stats, and version lookups for the Art. 30 DSGVO Verzeichnis. Single-service split: organization + activities + audit + stats all revolve around the same tenant's VVT document, and the existing test suite (tests/test_vvt_routes.py — 768 LOC, tests/test_vvt_tenant_isolation.py — 205 LOC) exercises them together. Module-level helpers (_activity_to_response, _log_audit, _export_csv) stay module-level in compliance.services.vvt_service and are re-exported from compliance.api.vvt_routes so the two test files keep importing from the old path. Pydantic schemas already live in compliance.schemas.vvt from Step 3 — no new schema file needed this round. mypy.ini flips compliance.api.vvt_routes from ignore_errors=True to False. Two SQLAlchemy Column[str] vs str dict-index errors fixed with explicit str() casts on status/business_function in the stats loop. Verified: - 242/242 pytest (173 core + 69 VVT integration) pass - OpenAPI 360/484 unchanged - mypy compliance/ -> Success on 128 source files - vvt_routes.py 550 -> 225 LOC - vvt_service.py 475 LOC (under 500 hard cap) - Hard-cap violations: 14 -> 13 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,60 +4,52 @@ FastAPI routes for VVT — Verzeichnis von Verarbeitungstaetigkeiten (Art. 30 DS
|
||||
Endpoints:
|
||||
GET /vvt/organization — Load organization header
|
||||
PUT /vvt/organization — Save organization header
|
||||
GET /vvt/activities — List activities (filter: status, business_function)
|
||||
GET /vvt/activities — List activities
|
||||
POST /vvt/activities — Create new activity
|
||||
GET /vvt/activities/{id} — Get single activity
|
||||
PUT /vvt/activities/{id} — Update activity
|
||||
DELETE /vvt/activities/{id} — Delete activity
|
||||
GET /vvt/audit-log — Audit trail (limit, offset)
|
||||
GET /vvt/export — JSON export of all activities
|
||||
GET /vvt/audit-log — Audit trail
|
||||
GET /vvt/export — JSON or CSV export
|
||||
GET /vvt/stats — Statistics
|
||||
GET /vvt/activities/{id}/versions — List activity versions
|
||||
GET /vvt/activities/{id}/versions/{n} — Get specific version
|
||||
|
||||
Phase 1 Step 4 refactor: handlers delegate to VVTService.
|
||||
"""
|
||||
|
||||
import csv
|
||||
import io
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, List
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from classroom_engine.database import get_db
|
||||
|
||||
from ..db.vvt_models import VVTOrganizationDB, VVTActivityDB, VVTAuditLogDB
|
||||
from .schemas import (
|
||||
VVTOrganizationUpdate, VVTOrganizationResponse,
|
||||
VVTActivityCreate, VVTActivityUpdate, VVTActivityResponse,
|
||||
VVTStatsResponse, VVTAuditLogEntry,
|
||||
from compliance.api._http_errors import translate_domain_errors
|
||||
from compliance.api.tenant_utils import get_tenant_id
|
||||
from compliance.schemas.vvt import (
|
||||
VVTActivityCreate,
|
||||
VVTActivityResponse,
|
||||
VVTActivityUpdate,
|
||||
VVTAuditLogEntry,
|
||||
VVTOrganizationResponse,
|
||||
VVTOrganizationUpdate,
|
||||
VVTStatsResponse,
|
||||
)
|
||||
from compliance.services.vvt_service import (
|
||||
VVTService,
|
||||
_activity_to_response, # re-exported for legacy test imports
|
||||
_export_csv, # re-exported for legacy test imports
|
||||
_log_audit, # re-exported for legacy test imports
|
||||
)
|
||||
from .tenant_utils import get_tenant_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/vvt", tags=["compliance-vvt"])
|
||||
|
||||
|
||||
def _log_audit(
|
||||
db: Session,
|
||||
tenant_id: str,
|
||||
action: str,
|
||||
entity_type: str,
|
||||
entity_id=None,
|
||||
changed_by: str = "system",
|
||||
old_values=None,
|
||||
new_values=None,
|
||||
):
|
||||
entry = VVTAuditLogDB(
|
||||
tenant_id=tenant_id,
|
||||
action=action,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
changed_by=changed_by,
|
||||
old_values=old_values,
|
||||
new_values=new_values,
|
||||
)
|
||||
db.add(entry)
|
||||
def get_vvt_service(db: Session = Depends(get_db)) -> VVTService:
|
||||
return VVTService(db)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -67,118 +59,28 @@ def _log_audit(
|
||||
@router.get("/organization", response_model=Optional[VVTOrganizationResponse])
|
||||
async def get_organization(
|
||||
tid: str = Depends(get_tenant_id),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
service: VVTService = Depends(get_vvt_service),
|
||||
) -> Optional[VVTOrganizationResponse]:
|
||||
"""Load the VVT organization header for the given tenant."""
|
||||
org = (
|
||||
db.query(VVTOrganizationDB)
|
||||
.filter(VVTOrganizationDB.tenant_id == tid)
|
||||
.order_by(VVTOrganizationDB.created_at)
|
||||
.first()
|
||||
)
|
||||
if not org:
|
||||
return None
|
||||
return VVTOrganizationResponse(
|
||||
id=str(org.id),
|
||||
organization_name=org.organization_name,
|
||||
industry=org.industry,
|
||||
locations=org.locations or [],
|
||||
employee_count=org.employee_count,
|
||||
dpo_name=org.dpo_name,
|
||||
dpo_contact=org.dpo_contact,
|
||||
vvt_version=org.vvt_version or '1.0',
|
||||
last_review_date=org.last_review_date,
|
||||
next_review_date=org.next_review_date,
|
||||
review_interval=org.review_interval or 'annual',
|
||||
created_at=org.created_at,
|
||||
updated_at=org.updated_at,
|
||||
)
|
||||
with translate_domain_errors():
|
||||
return service.get_organization(tid)
|
||||
|
||||
|
||||
@router.put("/organization", response_model=VVTOrganizationResponse)
|
||||
async def upsert_organization(
|
||||
request: VVTOrganizationUpdate,
|
||||
tid: str = Depends(get_tenant_id),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
service: VVTService = Depends(get_vvt_service),
|
||||
) -> VVTOrganizationResponse:
|
||||
"""Create or update the VVT organization header."""
|
||||
org = (
|
||||
db.query(VVTOrganizationDB)
|
||||
.filter(VVTOrganizationDB.tenant_id == tid)
|
||||
.order_by(VVTOrganizationDB.created_at)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not org:
|
||||
data = request.dict(exclude_none=True)
|
||||
if 'organization_name' not in data:
|
||||
data['organization_name'] = 'Meine Organisation'
|
||||
data['tenant_id'] = tid
|
||||
org = VVTOrganizationDB(**data)
|
||||
db.add(org)
|
||||
else:
|
||||
for field, value in request.dict(exclude_none=True).items():
|
||||
setattr(org, field, value)
|
||||
org.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
db.commit()
|
||||
db.refresh(org)
|
||||
|
||||
return VVTOrganizationResponse(
|
||||
id=str(org.id),
|
||||
organization_name=org.organization_name,
|
||||
industry=org.industry,
|
||||
locations=org.locations or [],
|
||||
employee_count=org.employee_count,
|
||||
dpo_name=org.dpo_name,
|
||||
dpo_contact=org.dpo_contact,
|
||||
vvt_version=org.vvt_version or '1.0',
|
||||
last_review_date=org.last_review_date,
|
||||
next_review_date=org.next_review_date,
|
||||
review_interval=org.review_interval or 'annual',
|
||||
created_at=org.created_at,
|
||||
updated_at=org.updated_at,
|
||||
)
|
||||
with translate_domain_errors():
|
||||
return service.upsert_organization(tid, request)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Activities
|
||||
# ============================================================================
|
||||
|
||||
def _activity_to_response(act: VVTActivityDB) -> VVTActivityResponse:
|
||||
return VVTActivityResponse(
|
||||
id=str(act.id),
|
||||
vvt_id=act.vvt_id,
|
||||
name=act.name,
|
||||
description=act.description,
|
||||
purposes=act.purposes or [],
|
||||
legal_bases=act.legal_bases or [],
|
||||
data_subject_categories=act.data_subject_categories or [],
|
||||
personal_data_categories=act.personal_data_categories or [],
|
||||
recipient_categories=act.recipient_categories or [],
|
||||
third_country_transfers=act.third_country_transfers or [],
|
||||
retention_period=act.retention_period or {},
|
||||
tom_description=act.tom_description,
|
||||
business_function=act.business_function,
|
||||
systems=act.systems or [],
|
||||
deployment_model=act.deployment_model,
|
||||
data_sources=act.data_sources or [],
|
||||
data_flows=act.data_flows or [],
|
||||
protection_level=act.protection_level or 'MEDIUM',
|
||||
dpia_required=act.dpia_required or False,
|
||||
structured_toms=act.structured_toms or {},
|
||||
status=act.status or 'DRAFT',
|
||||
responsible=act.responsible,
|
||||
owner=act.owner,
|
||||
last_reviewed_at=act.last_reviewed_at,
|
||||
next_review_at=act.next_review_at,
|
||||
created_by=act.created_by,
|
||||
dsfa_id=str(act.dsfa_id) if act.dsfa_id else None,
|
||||
created_at=act.created_at,
|
||||
updated_at=act.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/activities", response_model=List[VVTActivityResponse])
|
||||
async def list_activities(
|
||||
status: Optional[str] = Query(None),
|
||||
@@ -186,31 +88,13 @@ async def list_activities(
|
||||
search: Optional[str] = Query(None),
|
||||
review_overdue: Optional[bool] = Query(None),
|
||||
tid: str = Depends(get_tenant_id),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
service: VVTService = Depends(get_vvt_service),
|
||||
) -> List[VVTActivityResponse]:
|
||||
"""List all processing activities with optional filters."""
|
||||
query = db.query(VVTActivityDB).filter(VVTActivityDB.tenant_id == tid)
|
||||
|
||||
if status:
|
||||
query = query.filter(VVTActivityDB.status == status)
|
||||
if business_function:
|
||||
query = query.filter(VVTActivityDB.business_function == business_function)
|
||||
if review_overdue:
|
||||
now = datetime.now(timezone.utc)
|
||||
query = query.filter(
|
||||
VVTActivityDB.next_review_at.isnot(None),
|
||||
VVTActivityDB.next_review_at < now,
|
||||
with translate_domain_errors():
|
||||
return service.list_activities(
|
||||
tid, status, business_function, search, review_overdue
|
||||
)
|
||||
if search:
|
||||
term = f"%{search}%"
|
||||
query = query.filter(
|
||||
(VVTActivityDB.name.ilike(term)) |
|
||||
(VVTActivityDB.description.ilike(term)) |
|
||||
(VVTActivityDB.vvt_id.ilike(term))
|
||||
)
|
||||
|
||||
activities = query.order_by(VVTActivityDB.created_at.desc()).all()
|
||||
return [_activity_to_response(a) for a in activities]
|
||||
|
||||
|
||||
@router.post("/activities", response_model=VVTActivityResponse, status_code=201)
|
||||
@@ -218,58 +102,24 @@ async def create_activity(
|
||||
request: VVTActivityCreate,
|
||||
http_request: Request,
|
||||
tid: str = Depends(get_tenant_id),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
service: VVTService = Depends(get_vvt_service),
|
||||
) -> VVTActivityResponse:
|
||||
"""Create a new processing activity."""
|
||||
# Check for duplicate vvt_id within tenant
|
||||
existing = db.query(VVTActivityDB).filter(
|
||||
VVTActivityDB.tenant_id == tid,
|
||||
VVTActivityDB.vvt_id == request.vvt_id,
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Activity with VVT-ID '{request.vvt_id}' already exists"
|
||||
with translate_domain_errors():
|
||||
return service.create_activity(
|
||||
tid, request, http_request.headers.get("X-User-ID")
|
||||
)
|
||||
|
||||
data = request.dict()
|
||||
data['tenant_id'] = tid
|
||||
# Set created_by from X-User-ID header if not provided in body
|
||||
if not data.get('created_by'):
|
||||
data['created_by'] = http_request.headers.get('X-User-ID', 'system')
|
||||
|
||||
act = VVTActivityDB(**data)
|
||||
db.add(act)
|
||||
db.flush() # get ID before audit log
|
||||
|
||||
_log_audit(
|
||||
db,
|
||||
tenant_id=tid,
|
||||
action="CREATE",
|
||||
entity_type="activity",
|
||||
entity_id=act.id,
|
||||
new_values={"vvt_id": act.vvt_id, "name": act.name, "status": act.status},
|
||||
)
|
||||
|
||||
db.commit()
|
||||
db.refresh(act)
|
||||
return _activity_to_response(act)
|
||||
|
||||
|
||||
@router.get("/activities/{activity_id}", response_model=VVTActivityResponse)
|
||||
async def get_activity(
|
||||
activity_id: str,
|
||||
tid: str = Depends(get_tenant_id),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
service: VVTService = Depends(get_vvt_service),
|
||||
) -> VVTActivityResponse:
|
||||
"""Get a single processing activity by ID."""
|
||||
act = db.query(VVTActivityDB).filter(
|
||||
VVTActivityDB.id == activity_id,
|
||||
VVTActivityDB.tenant_id == tid,
|
||||
).first()
|
||||
if not act:
|
||||
raise HTTPException(status_code=404, detail=f"Activity {activity_id} not found")
|
||||
return _activity_to_response(act)
|
||||
with translate_domain_errors():
|
||||
return service.get_activity(tid, activity_id)
|
||||
|
||||
|
||||
@router.put("/activities/{activity_id}", response_model=VVTActivityResponse)
|
||||
@@ -277,63 +127,22 @@ async def update_activity(
|
||||
activity_id: str,
|
||||
request: VVTActivityUpdate,
|
||||
tid: str = Depends(get_tenant_id),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
service: VVTService = Depends(get_vvt_service),
|
||||
) -> VVTActivityResponse:
|
||||
"""Update a processing activity."""
|
||||
act = db.query(VVTActivityDB).filter(
|
||||
VVTActivityDB.id == activity_id,
|
||||
VVTActivityDB.tenant_id == tid,
|
||||
).first()
|
||||
if not act:
|
||||
raise HTTPException(status_code=404, detail=f"Activity {activity_id} not found")
|
||||
|
||||
old_values = {"name": act.name, "status": act.status}
|
||||
updates = request.dict(exclude_none=True)
|
||||
for field, value in updates.items():
|
||||
setattr(act, field, value)
|
||||
act.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
_log_audit(
|
||||
db,
|
||||
tenant_id=tid,
|
||||
action="UPDATE",
|
||||
entity_type="activity",
|
||||
entity_id=act.id,
|
||||
old_values=old_values,
|
||||
new_values=updates,
|
||||
)
|
||||
|
||||
db.commit()
|
||||
db.refresh(act)
|
||||
return _activity_to_response(act)
|
||||
with translate_domain_errors():
|
||||
return service.update_activity(tid, activity_id, request)
|
||||
|
||||
|
||||
@router.delete("/activities/{activity_id}")
|
||||
async def delete_activity(
|
||||
activity_id: str,
|
||||
tid: str = Depends(get_tenant_id),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
service: VVTService = Depends(get_vvt_service),
|
||||
) -> dict[str, Any]:
|
||||
"""Delete a processing activity."""
|
||||
act = db.query(VVTActivityDB).filter(
|
||||
VVTActivityDB.id == activity_id,
|
||||
VVTActivityDB.tenant_id == tid,
|
||||
).first()
|
||||
if not act:
|
||||
raise HTTPException(status_code=404, detail=f"Activity {activity_id} not found")
|
||||
|
||||
_log_audit(
|
||||
db,
|
||||
tenant_id=tid,
|
||||
action="DELETE",
|
||||
entity_type="activity",
|
||||
entity_id=act.id,
|
||||
old_values={"vvt_id": act.vvt_id, "name": act.name},
|
||||
)
|
||||
|
||||
db.delete(act)
|
||||
db.commit()
|
||||
return {"success": True, "message": f"Activity {activity_id} deleted"}
|
||||
with translate_domain_errors():
|
||||
return service.delete_activity(tid, activity_id)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -345,30 +154,11 @@ async def get_audit_log(
|
||||
limit: int = Query(50, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
tid: str = Depends(get_tenant_id),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
service: VVTService = Depends(get_vvt_service),
|
||||
) -> List[VVTAuditLogEntry]:
|
||||
"""Get the VVT audit trail."""
|
||||
entries = (
|
||||
db.query(VVTAuditLogDB)
|
||||
.filter(VVTAuditLogDB.tenant_id == tid)
|
||||
.order_by(VVTAuditLogDB.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
return [
|
||||
VVTAuditLogEntry(
|
||||
id=str(e.id),
|
||||
action=e.action,
|
||||
entity_type=e.entity_type,
|
||||
entity_id=str(e.entity_id) if e.entity_id else None,
|
||||
changed_by=e.changed_by,
|
||||
old_values=e.old_values,
|
||||
new_values=e.new_values,
|
||||
created_at=e.created_at,
|
||||
)
|
||||
for e in entries
|
||||
]
|
||||
with translate_domain_errors():
|
||||
return service.audit_log(tid, limit, offset)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -379,145 +169,21 @@ async def get_audit_log(
|
||||
async def export_activities(
|
||||
format: str = Query("json", pattern="^(json|csv)$"),
|
||||
tid: str = Depends(get_tenant_id),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
service: VVTService = Depends(get_vvt_service),
|
||||
) -> Any:
|
||||
"""Export all activities as JSON or CSV (semicolon-separated, DE locale)."""
|
||||
org = (
|
||||
db.query(VVTOrganizationDB)
|
||||
.filter(VVTOrganizationDB.tenant_id == tid)
|
||||
.order_by(VVTOrganizationDB.created_at)
|
||||
.first()
|
||||
)
|
||||
activities = (
|
||||
db.query(VVTActivityDB)
|
||||
.filter(VVTActivityDB.tenant_id == tid)
|
||||
.order_by(VVTActivityDB.created_at)
|
||||
.all()
|
||||
)
|
||||
|
||||
_log_audit(
|
||||
db,
|
||||
tenant_id=tid,
|
||||
action="EXPORT",
|
||||
entity_type="all_activities",
|
||||
new_values={"count": len(activities), "format": format},
|
||||
)
|
||||
db.commit()
|
||||
|
||||
if format == "csv":
|
||||
return _export_csv(activities)
|
||||
|
||||
return {
|
||||
"exported_at": datetime.now(timezone.utc).isoformat(),
|
||||
"organization": {
|
||||
"name": org.organization_name if org else "",
|
||||
"dpo_name": org.dpo_name if org else "",
|
||||
"dpo_contact": org.dpo_contact if org else "",
|
||||
"vvt_version": org.vvt_version if org else "1.0",
|
||||
} if org else None,
|
||||
"activities": [
|
||||
{
|
||||
"id": str(a.id),
|
||||
"vvt_id": a.vvt_id,
|
||||
"name": a.name,
|
||||
"description": a.description,
|
||||
"status": a.status,
|
||||
"purposes": a.purposes,
|
||||
"legal_bases": a.legal_bases,
|
||||
"data_subject_categories": a.data_subject_categories,
|
||||
"personal_data_categories": a.personal_data_categories,
|
||||
"recipient_categories": a.recipient_categories,
|
||||
"third_country_transfers": a.third_country_transfers,
|
||||
"retention_period": a.retention_period,
|
||||
"dpia_required": a.dpia_required,
|
||||
"protection_level": a.protection_level,
|
||||
"business_function": a.business_function,
|
||||
"responsible": a.responsible,
|
||||
"created_by": a.created_by,
|
||||
"dsfa_id": str(a.dsfa_id) if a.dsfa_id else None,
|
||||
"last_reviewed_at": a.last_reviewed_at.isoformat() if a.last_reviewed_at else None,
|
||||
"next_review_at": a.next_review_at.isoformat() if a.next_review_at else None,
|
||||
"created_at": a.created_at.isoformat(),
|
||||
"updated_at": a.updated_at.isoformat() if a.updated_at else None,
|
||||
}
|
||||
for a in activities
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _export_csv(activities: list) -> StreamingResponse:
|
||||
"""Generate semicolon-separated CSV with UTF-8 BOM for German Excel compatibility."""
|
||||
output = io.StringIO()
|
||||
# UTF-8 BOM for Excel
|
||||
output.write('\ufeff')
|
||||
|
||||
writer = csv.writer(output, delimiter=';', quoting=csv.QUOTE_MINIMAL)
|
||||
writer.writerow([
|
||||
'ID', 'VVT-ID', 'Name', 'Zweck', 'Rechtsgrundlage',
|
||||
'Datenkategorien', 'Betroffene', 'Empfaenger', 'Drittland',
|
||||
'Aufbewahrung', 'Status', 'Verantwortlich', 'Erstellt von',
|
||||
'Erstellt am',
|
||||
])
|
||||
|
||||
for a in activities:
|
||||
writer.writerow([
|
||||
str(a.id),
|
||||
a.vvt_id,
|
||||
a.name,
|
||||
'; '.join(a.purposes or []),
|
||||
'; '.join(a.legal_bases or []),
|
||||
'; '.join(a.personal_data_categories or []),
|
||||
'; '.join(a.data_subject_categories or []),
|
||||
'; '.join(a.recipient_categories or []),
|
||||
'Ja' if a.third_country_transfers else 'Nein',
|
||||
str(a.retention_period) if a.retention_period else '',
|
||||
a.status or 'DRAFT',
|
||||
a.responsible or '',
|
||||
a.created_by or 'system',
|
||||
a.created_at.strftime('%d.%m.%Y %H:%M') if a.created_at else '',
|
||||
])
|
||||
|
||||
output.seek(0)
|
||||
return StreamingResponse(
|
||||
iter([output.getvalue()]),
|
||||
media_type='text/csv; charset=utf-8',
|
||||
headers={
|
||||
'Content-Disposition': f'attachment; filename="vvt_export_{datetime.now(timezone.utc).strftime("%Y%m%d")}.csv"'
|
||||
},
|
||||
)
|
||||
with translate_domain_errors():
|
||||
return service.export(tid, format)
|
||||
|
||||
|
||||
@router.get("/stats", response_model=VVTStatsResponse)
|
||||
async def get_stats(
|
||||
tid: str = Depends(get_tenant_id),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
service: VVTService = Depends(get_vvt_service),
|
||||
) -> VVTStatsResponse:
|
||||
"""Get VVT statistics summary."""
|
||||
activities = db.query(VVTActivityDB).filter(VVTActivityDB.tenant_id == tid).all()
|
||||
|
||||
by_status: dict = {}
|
||||
by_bf: dict = {}
|
||||
now = datetime.now(timezone.utc)
|
||||
overdue_count = 0
|
||||
|
||||
for a in activities:
|
||||
status = a.status or 'DRAFT'
|
||||
bf = a.business_function or 'unknown'
|
||||
by_status[status] = by_status.get(status, 0) + 1
|
||||
by_bf[bf] = by_bf.get(bf, 0) + 1
|
||||
if a.next_review_at and a.next_review_at < now:
|
||||
overdue_count += 1
|
||||
|
||||
return VVTStatsResponse(
|
||||
total=len(activities),
|
||||
by_status=by_status,
|
||||
by_business_function=by_bf,
|
||||
dpia_required_count=sum(1 for a in activities if a.dpia_required),
|
||||
third_country_count=sum(1 for a in activities if a.third_country_transfers),
|
||||
draft_count=by_status.get('DRAFT', 0),
|
||||
approved_count=by_status.get('APPROVED', 0),
|
||||
overdue_review_count=overdue_count,
|
||||
)
|
||||
with translate_domain_errors():
|
||||
return service.stats(tid)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -528,11 +194,11 @@ async def get_stats(
|
||||
async def list_activity_versions(
|
||||
activity_id: str,
|
||||
tid: str = Depends(get_tenant_id),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
service: VVTService = Depends(get_vvt_service),
|
||||
) -> Any:
|
||||
"""List all versions for a VVT activity."""
|
||||
from .versioning_utils import list_versions
|
||||
return list_versions(db, "vvt_activity", activity_id, tid)
|
||||
with translate_domain_errors():
|
||||
return service.list_versions(tid, activity_id)
|
||||
|
||||
|
||||
@router.get("/activities/{activity_id}/versions/{version_number}")
|
||||
@@ -540,11 +206,20 @@ async def get_activity_version(
|
||||
activity_id: str,
|
||||
version_number: int,
|
||||
tid: str = Depends(get_tenant_id),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
service: VVTService = Depends(get_vvt_service),
|
||||
) -> Any:
|
||||
"""Get a specific VVT activity version with full snapshot."""
|
||||
from .versioning_utils import get_version
|
||||
v = get_version(db, "vvt_activity", activity_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 service.get_version(tid, activity_id, version_number)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Legacy re-exports for tests that import helpers directly.
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
__all__ = [
|
||||
"router",
|
||||
"_activity_to_response",
|
||||
"_log_audit",
|
||||
"_export_csv",
|
||||
]
|
||||
|
||||
475
backend-compliance/compliance/services/vvt_service.py
Normal file
475
backend-compliance/compliance/services/vvt_service.py
Normal file
@@ -0,0 +1,475 @@
|
||||
# mypy: disable-error-code="arg-type,assignment"
|
||||
# SQLAlchemy 1.x Column() descriptors are Column[T] statically, T at runtime.
|
||||
"""
|
||||
VVT service — Verzeichnis von Verarbeitungstaetigkeiten (Art. 30 DSGVO).
|
||||
|
||||
Phase 1 Step 4: extracted from ``compliance.api.vvt_routes``. Covers the
|
||||
organization header, processing activities CRUD, audit log, export
|
||||
(JSON + CSV), stats, and versioning lookups.
|
||||
|
||||
The module-level helpers ``_activity_to_response``, ``_log_audit``, and
|
||||
``_export_csv`` are also re-exported by ``compliance.api.vvt_routes`` so
|
||||
the existing test suite (tests/test_vvt_routes.py,
|
||||
tests/test_vvt_tenant_isolation.py) continues to import them from the
|
||||
same path.
|
||||
"""
|
||||
|
||||
import csv
|
||||
import io
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from compliance.db.vvt_models import (
|
||||
VVTActivityDB,
|
||||
VVTAuditLogDB,
|
||||
VVTOrganizationDB,
|
||||
)
|
||||
from compliance.domain import ConflictError, NotFoundError
|
||||
from compliance.schemas.vvt import (
|
||||
VVTActivityCreate,
|
||||
VVTActivityResponse,
|
||||
VVTActivityUpdate,
|
||||
VVTAuditLogEntry,
|
||||
VVTOrganizationResponse,
|
||||
VVTOrganizationUpdate,
|
||||
VVTStatsResponse,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Module-level helpers (legacy-exported via compliance.api.vvt_routes)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def _log_audit(
|
||||
db: Session,
|
||||
tenant_id: str,
|
||||
action: str,
|
||||
entity_type: str,
|
||||
entity_id: Any = None,
|
||||
changed_by: str = "system",
|
||||
old_values: Optional[dict[str, Any]] = None,
|
||||
new_values: Optional[dict[str, Any]] = None,
|
||||
) -> None:
|
||||
db.add(
|
||||
VVTAuditLogDB(
|
||||
tenant_id=tenant_id,
|
||||
action=action,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
changed_by=changed_by,
|
||||
old_values=old_values,
|
||||
new_values=new_values,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _activity_to_response(act: VVTActivityDB) -> VVTActivityResponse:
|
||||
return VVTActivityResponse(
|
||||
id=str(act.id),
|
||||
vvt_id=act.vvt_id,
|
||||
name=act.name,
|
||||
description=act.description,
|
||||
purposes=act.purposes or [],
|
||||
legal_bases=act.legal_bases or [],
|
||||
data_subject_categories=act.data_subject_categories or [],
|
||||
personal_data_categories=act.personal_data_categories or [],
|
||||
recipient_categories=act.recipient_categories or [],
|
||||
third_country_transfers=act.third_country_transfers or [],
|
||||
retention_period=act.retention_period or {},
|
||||
tom_description=act.tom_description,
|
||||
business_function=act.business_function,
|
||||
systems=act.systems or [],
|
||||
deployment_model=act.deployment_model,
|
||||
data_sources=act.data_sources or [],
|
||||
data_flows=act.data_flows or [],
|
||||
protection_level=act.protection_level or "MEDIUM",
|
||||
dpia_required=act.dpia_required or False,
|
||||
structured_toms=act.structured_toms or {},
|
||||
status=act.status or "DRAFT",
|
||||
responsible=act.responsible,
|
||||
owner=act.owner,
|
||||
last_reviewed_at=act.last_reviewed_at,
|
||||
next_review_at=act.next_review_at,
|
||||
created_by=act.created_by,
|
||||
dsfa_id=str(act.dsfa_id) if act.dsfa_id else None,
|
||||
created_at=act.created_at,
|
||||
updated_at=act.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _org_to_response(org: VVTOrganizationDB) -> VVTOrganizationResponse:
|
||||
return VVTOrganizationResponse(
|
||||
id=str(org.id),
|
||||
organization_name=org.organization_name,
|
||||
industry=org.industry,
|
||||
locations=org.locations or [],
|
||||
employee_count=org.employee_count,
|
||||
dpo_name=org.dpo_name,
|
||||
dpo_contact=org.dpo_contact,
|
||||
vvt_version=org.vvt_version or "1.0",
|
||||
last_review_date=org.last_review_date,
|
||||
next_review_date=org.next_review_date,
|
||||
review_interval=org.review_interval or "annual",
|
||||
created_at=org.created_at,
|
||||
updated_at=org.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _export_csv(activities: list[Any]) -> StreamingResponse:
|
||||
"""Generate semicolon-separated CSV with UTF-8 BOM for German Excel compatibility."""
|
||||
output = io.StringIO()
|
||||
output.write("\ufeff") # UTF-8 BOM for Excel
|
||||
writer = csv.writer(output, delimiter=";", quoting=csv.QUOTE_MINIMAL)
|
||||
writer.writerow([
|
||||
"ID", "VVT-ID", "Name", "Zweck", "Rechtsgrundlage",
|
||||
"Datenkategorien", "Betroffene", "Empfaenger", "Drittland",
|
||||
"Aufbewahrung", "Status", "Verantwortlich", "Erstellt von",
|
||||
"Erstellt am",
|
||||
])
|
||||
for a in activities:
|
||||
writer.writerow([
|
||||
str(a.id),
|
||||
a.vvt_id,
|
||||
a.name,
|
||||
"; ".join(a.purposes or []),
|
||||
"; ".join(a.legal_bases or []),
|
||||
"; ".join(a.personal_data_categories or []),
|
||||
"; ".join(a.data_subject_categories or []),
|
||||
"; ".join(a.recipient_categories or []),
|
||||
"Ja" if a.third_country_transfers else "Nein",
|
||||
str(a.retention_period) if a.retention_period else "",
|
||||
a.status or "DRAFT",
|
||||
a.responsible or "",
|
||||
a.created_by or "system",
|
||||
a.created_at.strftime("%d.%m.%Y %H:%M") if a.created_at else "",
|
||||
])
|
||||
|
||||
output.seek(0)
|
||||
return StreamingResponse(
|
||||
iter([output.getvalue()]),
|
||||
media_type="text/csv; charset=utf-8",
|
||||
headers={
|
||||
"Content-Disposition": (
|
||||
f'attachment; filename="vvt_export_'
|
||||
f'{datetime.now(timezone.utc).strftime("%Y%m%d")}.csv"'
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Service
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class VVTService:
|
||||
"""Business logic for VVT organization, activities, audit, export, stats."""
|
||||
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Organization header
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_organization(self, tid: str) -> Optional[VVTOrganizationResponse]:
|
||||
org = (
|
||||
self.db.query(VVTOrganizationDB)
|
||||
.filter(VVTOrganizationDB.tenant_id == tid)
|
||||
.order_by(VVTOrganizationDB.created_at)
|
||||
.first()
|
||||
)
|
||||
if not org:
|
||||
return None
|
||||
return _org_to_response(org)
|
||||
|
||||
def upsert_organization(
|
||||
self, tid: str, request: VVTOrganizationUpdate
|
||||
) -> VVTOrganizationResponse:
|
||||
org = (
|
||||
self.db.query(VVTOrganizationDB)
|
||||
.filter(VVTOrganizationDB.tenant_id == tid)
|
||||
.order_by(VVTOrganizationDB.created_at)
|
||||
.first()
|
||||
)
|
||||
if not org:
|
||||
data = request.dict(exclude_none=True)
|
||||
if "organization_name" not in data:
|
||||
data["organization_name"] = "Meine Organisation"
|
||||
data["tenant_id"] = tid
|
||||
org = VVTOrganizationDB(**data)
|
||||
self.db.add(org)
|
||||
else:
|
||||
for field, value in request.dict(exclude_none=True).items():
|
||||
setattr(org, field, value)
|
||||
org.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(org)
|
||||
return _org_to_response(org)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Activities
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def list_activities(
|
||||
self,
|
||||
tid: str,
|
||||
status: Optional[str],
|
||||
business_function: Optional[str],
|
||||
search: Optional[str],
|
||||
review_overdue: Optional[bool],
|
||||
) -> list[VVTActivityResponse]:
|
||||
q = self.db.query(VVTActivityDB).filter(VVTActivityDB.tenant_id == tid)
|
||||
if status:
|
||||
q = q.filter(VVTActivityDB.status == status)
|
||||
if business_function:
|
||||
q = q.filter(VVTActivityDB.business_function == business_function)
|
||||
if review_overdue:
|
||||
now = datetime.now(timezone.utc)
|
||||
q = q.filter(
|
||||
VVTActivityDB.next_review_at.isnot(None),
|
||||
VVTActivityDB.next_review_at < now,
|
||||
)
|
||||
if search:
|
||||
term = f"%{search}%"
|
||||
q = q.filter(
|
||||
(VVTActivityDB.name.ilike(term))
|
||||
| (VVTActivityDB.description.ilike(term))
|
||||
| (VVTActivityDB.vvt_id.ilike(term))
|
||||
)
|
||||
rows = q.order_by(VVTActivityDB.created_at.desc()).all()
|
||||
return [_activity_to_response(a) for a in rows]
|
||||
|
||||
def create_activity(
|
||||
self,
|
||||
tid: str,
|
||||
request: VVTActivityCreate,
|
||||
created_by_header: Optional[str],
|
||||
) -> VVTActivityResponse:
|
||||
existing = (
|
||||
self.db.query(VVTActivityDB)
|
||||
.filter(
|
||||
VVTActivityDB.tenant_id == tid,
|
||||
VVTActivityDB.vvt_id == request.vvt_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
raise ConflictError(
|
||||
f"Activity with VVT-ID '{request.vvt_id}' already exists"
|
||||
)
|
||||
|
||||
data = request.dict()
|
||||
data["tenant_id"] = tid
|
||||
if not data.get("created_by"):
|
||||
data["created_by"] = created_by_header or "system"
|
||||
|
||||
act = VVTActivityDB(**data)
|
||||
self.db.add(act)
|
||||
self.db.flush()
|
||||
|
||||
_log_audit(
|
||||
self.db,
|
||||
tenant_id=tid,
|
||||
action="CREATE",
|
||||
entity_type="activity",
|
||||
entity_id=act.id,
|
||||
new_values={"vvt_id": act.vvt_id, "name": act.name, "status": act.status},
|
||||
)
|
||||
self.db.commit()
|
||||
self.db.refresh(act)
|
||||
return _activity_to_response(act)
|
||||
|
||||
def _activity_or_raise(self, tid: str, activity_id: str) -> VVTActivityDB:
|
||||
act = (
|
||||
self.db.query(VVTActivityDB)
|
||||
.filter(
|
||||
VVTActivityDB.id == activity_id,
|
||||
VVTActivityDB.tenant_id == tid,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not act:
|
||||
raise NotFoundError(f"Activity {activity_id} not found")
|
||||
return act
|
||||
|
||||
def get_activity(self, tid: str, activity_id: str) -> VVTActivityResponse:
|
||||
return _activity_to_response(self._activity_or_raise(tid, activity_id))
|
||||
|
||||
def update_activity(
|
||||
self, tid: str, activity_id: str, request: VVTActivityUpdate
|
||||
) -> VVTActivityResponse:
|
||||
act = self._activity_or_raise(tid, activity_id)
|
||||
old_values: dict[str, Any] = {"name": act.name, "status": act.status}
|
||||
updates = request.dict(exclude_none=True)
|
||||
for field, value in updates.items():
|
||||
setattr(act, field, value)
|
||||
act.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
_log_audit(
|
||||
self.db,
|
||||
tenant_id=tid,
|
||||
action="UPDATE",
|
||||
entity_type="activity",
|
||||
entity_id=act.id,
|
||||
old_values=old_values,
|
||||
new_values=updates,
|
||||
)
|
||||
self.db.commit()
|
||||
self.db.refresh(act)
|
||||
return _activity_to_response(act)
|
||||
|
||||
def delete_activity(self, tid: str, activity_id: str) -> dict[str, Any]:
|
||||
act = self._activity_or_raise(tid, activity_id)
|
||||
_log_audit(
|
||||
self.db,
|
||||
tenant_id=tid,
|
||||
action="DELETE",
|
||||
entity_type="activity",
|
||||
entity_id=act.id,
|
||||
old_values={"vvt_id": act.vvt_id, "name": act.name},
|
||||
)
|
||||
self.db.delete(act)
|
||||
self.db.commit()
|
||||
return {"success": True, "message": f"Activity {activity_id} deleted"}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Audit log
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def audit_log(self, tid: str, limit: int, offset: int) -> list[VVTAuditLogEntry]:
|
||||
entries = (
|
||||
self.db.query(VVTAuditLogDB)
|
||||
.filter(VVTAuditLogDB.tenant_id == tid)
|
||||
.order_by(VVTAuditLogDB.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
return [
|
||||
VVTAuditLogEntry(
|
||||
id=str(e.id),
|
||||
action=e.action,
|
||||
entity_type=e.entity_type,
|
||||
entity_id=str(e.entity_id) if e.entity_id else None,
|
||||
changed_by=e.changed_by,
|
||||
old_values=e.old_values,
|
||||
new_values=e.new_values,
|
||||
created_at=e.created_at,
|
||||
)
|
||||
for e in entries
|
||||
]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Export + stats
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def export(self, tid: str, fmt: str) -> Any:
|
||||
org = (
|
||||
self.db.query(VVTOrganizationDB)
|
||||
.filter(VVTOrganizationDB.tenant_id == tid)
|
||||
.order_by(VVTOrganizationDB.created_at)
|
||||
.first()
|
||||
)
|
||||
activities = (
|
||||
self.db.query(VVTActivityDB)
|
||||
.filter(VVTActivityDB.tenant_id == tid)
|
||||
.order_by(VVTActivityDB.created_at)
|
||||
.all()
|
||||
)
|
||||
_log_audit(
|
||||
self.db,
|
||||
tenant_id=tid,
|
||||
action="EXPORT",
|
||||
entity_type="all_activities",
|
||||
new_values={"count": len(activities), "format": fmt},
|
||||
)
|
||||
self.db.commit()
|
||||
|
||||
if fmt == "csv":
|
||||
return _export_csv(activities)
|
||||
|
||||
return {
|
||||
"exported_at": datetime.now(timezone.utc).isoformat(),
|
||||
"organization": {
|
||||
"name": org.organization_name if org else "",
|
||||
"dpo_name": org.dpo_name if org else "",
|
||||
"dpo_contact": org.dpo_contact if org else "",
|
||||
"vvt_version": org.vvt_version if org else "1.0",
|
||||
} if org else None,
|
||||
"activities": [
|
||||
{
|
||||
"id": str(a.id),
|
||||
"vvt_id": a.vvt_id,
|
||||
"name": a.name,
|
||||
"description": a.description,
|
||||
"status": a.status,
|
||||
"purposes": a.purposes,
|
||||
"legal_bases": a.legal_bases,
|
||||
"data_subject_categories": a.data_subject_categories,
|
||||
"personal_data_categories": a.personal_data_categories,
|
||||
"recipient_categories": a.recipient_categories,
|
||||
"third_country_transfers": a.third_country_transfers,
|
||||
"retention_period": a.retention_period,
|
||||
"dpia_required": a.dpia_required,
|
||||
"protection_level": a.protection_level,
|
||||
"business_function": a.business_function,
|
||||
"responsible": a.responsible,
|
||||
"created_by": a.created_by,
|
||||
"dsfa_id": str(a.dsfa_id) if a.dsfa_id else None,
|
||||
"last_reviewed_at": a.last_reviewed_at.isoformat() if a.last_reviewed_at else None,
|
||||
"next_review_at": a.next_review_at.isoformat() if a.next_review_at else None,
|
||||
"created_at": a.created_at.isoformat(),
|
||||
"updated_at": a.updated_at.isoformat() if a.updated_at else None,
|
||||
}
|
||||
for a in activities
|
||||
],
|
||||
}
|
||||
|
||||
def stats(self, tid: str) -> VVTStatsResponse:
|
||||
activities = (
|
||||
self.db.query(VVTActivityDB).filter(VVTActivityDB.tenant_id == tid).all()
|
||||
)
|
||||
by_status: dict[str, int] = {}
|
||||
by_bf: dict[str, int] = {}
|
||||
now = datetime.now(timezone.utc)
|
||||
overdue_count = 0
|
||||
|
||||
for a in activities:
|
||||
st: str = str(a.status or "DRAFT")
|
||||
bf: str = str(a.business_function or "unknown")
|
||||
by_status[st] = by_status.get(st, 0) + 1
|
||||
by_bf[bf] = by_bf.get(bf, 0) + 1
|
||||
if a.next_review_at and a.next_review_at < now:
|
||||
overdue_count += 1
|
||||
|
||||
return VVTStatsResponse(
|
||||
total=len(activities),
|
||||
by_status=by_status,
|
||||
by_business_function=by_bf,
|
||||
dpia_required_count=sum(1 for a in activities if a.dpia_required),
|
||||
third_country_count=sum(1 for a in activities if a.third_country_transfers),
|
||||
draft_count=by_status.get("DRAFT", 0),
|
||||
approved_count=by_status.get("APPROVED", 0),
|
||||
overdue_review_count=overdue_count,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Versioning (delegates to shared versioning_utils)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def list_versions(self, tid: str, activity_id: str) -> Any:
|
||||
from compliance.api.versioning_utils import list_versions
|
||||
return list_versions(self.db, "vvt_activity", activity_id, tid)
|
||||
|
||||
def get_version(self, tid: str, activity_id: str, version_number: int) -> Any:
|
||||
from compliance.api.versioning_utils import get_version
|
||||
v = get_version(self.db, "vvt_activity", activity_id, version_number, tid)
|
||||
if not v:
|
||||
raise NotFoundError(f"Version {version_number} not found")
|
||||
return v
|
||||
@@ -79,5 +79,7 @@ ignore_errors = False
|
||||
ignore_errors = False
|
||||
[mypy-compliance.api.company_profile_routes]
|
||||
ignore_errors = False
|
||||
[mypy-compliance.api.vvt_routes]
|
||||
ignore_errors = False
|
||||
[mypy-compliance.api._http_errors]
|
||||
ignore_errors = False
|
||||
|
||||
@@ -44708,7 +44708,11 @@
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {}
|
||||
"schema": {
|
||||
"additionalProperties": true,
|
||||
"title": "Response Delete Activity Api Compliance Vvt Activities Activity Id Delete",
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
@@ -44940,7 +44944,9 @@
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {}
|
||||
"schema": {
|
||||
"title": "Response List Activity Versions Api Compliance Vvt Activities Activity Id Versions Get"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
@@ -45023,7 +45029,9 @@
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {}
|
||||
"schema": {
|
||||
"title": "Response Get Activity Version Api Compliance Vvt Activities Activity Id Versions Version Number Get"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
@@ -45193,7 +45201,9 @@
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {}
|
||||
"schema": {
|
||||
"title": "Response Export Activities Api Compliance Vvt Export Get"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
|
||||
Reference in New Issue
Block a user