Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Failing after 30s
CI / test-python-backend-compliance (push) Successful in 30s
CI / test-python-document-crawler (push) Successful in 21s
CI / test-python-dsms-gateway (push) Successful in 17s
- Ruff: 144 auto-fixes (unused imports, == None → is None), F821/F811/F841 manuell - CVEs: python-multipart>=0.0.22, weasyprint>=68.0, pillow>=12.1.1, npm audit fix (0 vulns) - TS: 5 tote Drafting-Engine-Dateien entfernt, allowed-facts/sanitizer/StepHeader/context fixes - Tests: +104 (ISMS 58, Evidence 18, VVT 14, Generation 14) → 1449 passed - Refactoring: collect_ci_evidence (F→A), row_to_response (E→A), extract_requirements (E→A) - Dead Code: pca-platform, 7 Go-Handler, dsr_api.py, duplicate Schemas entfernt Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
610 lines
20 KiB
Python
610 lines
20 KiB
Python
"""
|
|
FastAPI routes for TOM — Technisch-Organisatorische Massnahmen (Art. 32 DSGVO).
|
|
|
|
Endpoints:
|
|
GET /tom/state — Load TOM generator state for tenant
|
|
POST /tom/state — Save state (with version check)
|
|
DELETE /tom/state — Reset/clear state for tenant
|
|
GET /tom/measures — List measures (filter: category, status, tenant_id)
|
|
POST /tom/measures — Create single measure
|
|
PUT /tom/measures/{id} — Update measure
|
|
POST /tom/measures/bulk — Bulk upsert (for deriveTOMs sync)
|
|
GET /tom/stats — Statistics
|
|
GET /tom/export — Export as CSV or JSON
|
|
"""
|
|
|
|
import csv
|
|
import io
|
|
import json
|
|
import logging
|
|
from datetime import datetime, timezone
|
|
from typing import Optional, List, Any, Dict
|
|
from uuid import UUID
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from fastapi.responses import StreamingResponse
|
|
from pydantic import BaseModel
|
|
from sqlalchemy import func
|
|
from sqlalchemy.orm import Session
|
|
|
|
from classroom_engine.database import get_db
|
|
|
|
from ..db.tom_models import TOMStateDB, TOMMeasureDB
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/tom", tags=["tom"])
|
|
|
|
DEFAULT_TENANT_ID = "9282a473-5c95-4b3a-bf78-0ecc0ec71d3e"
|
|
|
|
|
|
# =============================================================================
|
|
# Pydantic Schemas (kept close to routes like loeschfristen pattern)
|
|
# =============================================================================
|
|
|
|
class TOMStateBody(BaseModel):
|
|
tenant_id: Optional[str] = None
|
|
tenantId: Optional[str] = None # Accept camelCase from frontend
|
|
state: Dict[str, Any]
|
|
version: Optional[int] = None
|
|
|
|
def get_tenant_id(self) -> str:
|
|
return self.tenant_id or self.tenantId or DEFAULT_TENANT_ID
|
|
|
|
|
|
class TOMMeasureCreate(BaseModel):
|
|
control_id: str
|
|
name: str
|
|
description: Optional[str] = None
|
|
category: str
|
|
type: str
|
|
applicability: str = "REQUIRED"
|
|
applicability_reason: Optional[str] = None
|
|
implementation_status: str = "NOT_IMPLEMENTED"
|
|
responsible_person: Optional[str] = None
|
|
responsible_department: Optional[str] = None
|
|
implementation_date: Optional[str] = None
|
|
review_date: Optional[str] = None
|
|
review_frequency: Optional[str] = None
|
|
priority: Optional[str] = None
|
|
complexity: Optional[str] = None
|
|
linked_evidence: Optional[List[Any]] = None
|
|
evidence_gaps: Optional[List[Any]] = None
|
|
related_controls: Optional[Dict[str, Any]] = None
|
|
verified_at: Optional[str] = None
|
|
verified_by: Optional[str] = None
|
|
effectiveness_rating: Optional[str] = None
|
|
|
|
|
|
class TOMMeasureUpdate(BaseModel):
|
|
name: Optional[str] = None
|
|
description: Optional[str] = None
|
|
category: Optional[str] = None
|
|
type: Optional[str] = None
|
|
applicability: Optional[str] = None
|
|
applicability_reason: Optional[str] = None
|
|
implementation_status: Optional[str] = None
|
|
responsible_person: Optional[str] = None
|
|
responsible_department: Optional[str] = None
|
|
implementation_date: Optional[str] = None
|
|
review_date: Optional[str] = None
|
|
review_frequency: Optional[str] = None
|
|
priority: Optional[str] = None
|
|
complexity: Optional[str] = None
|
|
linked_evidence: Optional[List[Any]] = None
|
|
evidence_gaps: Optional[List[Any]] = None
|
|
related_controls: Optional[Dict[str, Any]] = None
|
|
verified_at: Optional[str] = None
|
|
verified_by: Optional[str] = None
|
|
effectiveness_rating: Optional[str] = None
|
|
|
|
|
|
class TOMMeasureBulkItem(BaseModel):
|
|
control_id: str
|
|
name: str
|
|
description: Optional[str] = None
|
|
category: str
|
|
type: str
|
|
applicability: str = "REQUIRED"
|
|
applicability_reason: Optional[str] = None
|
|
implementation_status: str = "NOT_IMPLEMENTED"
|
|
responsible_person: Optional[str] = None
|
|
responsible_department: Optional[str] = None
|
|
implementation_date: Optional[str] = None
|
|
review_date: Optional[str] = None
|
|
review_frequency: Optional[str] = None
|
|
priority: Optional[str] = None
|
|
complexity: Optional[str] = None
|
|
linked_evidence: Optional[List[Any]] = None
|
|
evidence_gaps: Optional[List[Any]] = None
|
|
related_controls: Optional[Dict[str, Any]] = None
|
|
|
|
|
|
class TOMMeasureBulkBody(BaseModel):
|
|
tenant_id: Optional[str] = None
|
|
measures: List[TOMMeasureBulkItem]
|
|
|
|
|
|
# =============================================================================
|
|
# Helper: parse optional datetime strings
|
|
# =============================================================================
|
|
|
|
def _parse_dt(val: Optional[str]) -> Optional[datetime]:
|
|
if not val:
|
|
return None
|
|
try:
|
|
return datetime.fromisoformat(val.replace("Z", "+00:00"))
|
|
except (ValueError, AttributeError):
|
|
return None
|
|
|
|
|
|
def _measure_to_dict(m: TOMMeasureDB) -> dict:
|
|
return {
|
|
"id": str(m.id),
|
|
"tenant_id": m.tenant_id,
|
|
"control_id": m.control_id,
|
|
"name": m.name,
|
|
"description": m.description,
|
|
"category": m.category,
|
|
"type": m.type,
|
|
"applicability": m.applicability,
|
|
"applicability_reason": m.applicability_reason,
|
|
"implementation_status": m.implementation_status,
|
|
"responsible_person": m.responsible_person,
|
|
"responsible_department": m.responsible_department,
|
|
"implementation_date": m.implementation_date.isoformat() if m.implementation_date else None,
|
|
"review_date": m.review_date.isoformat() if m.review_date else None,
|
|
"review_frequency": m.review_frequency,
|
|
"priority": m.priority,
|
|
"complexity": m.complexity,
|
|
"linked_evidence": m.linked_evidence or [],
|
|
"evidence_gaps": m.evidence_gaps or [],
|
|
"related_controls": m.related_controls or {},
|
|
"verified_at": m.verified_at.isoformat() if m.verified_at else None,
|
|
"verified_by": m.verified_by,
|
|
"effectiveness_rating": m.effectiveness_rating,
|
|
"created_by": m.created_by,
|
|
"created_at": m.created_at.isoformat() if m.created_at else None,
|
|
"updated_at": m.updated_at.isoformat() if m.updated_at else None,
|
|
}
|
|
|
|
|
|
# =============================================================================
|
|
# STATE ENDPOINTS
|
|
# =============================================================================
|
|
|
|
@router.get("/state")
|
|
async def get_tom_state(
|
|
tenant_id: Optional[str] = Query(None, alias="tenant_id"),
|
|
tenantId: Optional[str] = Query(None),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Load TOM generator state for a tenant."""
|
|
tid = tenant_id or tenantId or DEFAULT_TENANT_ID
|
|
|
|
row = db.query(TOMStateDB).filter(TOMStateDB.tenant_id == tid).first()
|
|
|
|
if not row:
|
|
return {
|
|
"success": True,
|
|
"data": {
|
|
"tenantId": tid,
|
|
"state": {},
|
|
"version": 0,
|
|
"isNew": True,
|
|
},
|
|
}
|
|
|
|
return {
|
|
"success": True,
|
|
"data": {
|
|
"tenantId": tid,
|
|
"state": row.state,
|
|
"version": row.version,
|
|
"lastModified": row.updated_at.isoformat() if row.updated_at else None,
|
|
},
|
|
}
|
|
|
|
|
|
@router.post("/state")
|
|
async def save_tom_state(body: TOMStateBody, db: Session = Depends(get_db)):
|
|
"""Save TOM generator state with optimistic locking (version check)."""
|
|
tid = body.get_tenant_id()
|
|
|
|
existing = db.query(TOMStateDB).filter(TOMStateDB.tenant_id == tid).first()
|
|
|
|
# Version conflict check
|
|
if body.version is not None and existing and existing.version != body.version:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail={
|
|
"success": False,
|
|
"error": "Version conflict. State was modified by another request.",
|
|
"code": "VERSION_CONFLICT",
|
|
},
|
|
)
|
|
|
|
now = datetime.now(timezone.utc)
|
|
|
|
if existing:
|
|
existing.state = body.state
|
|
existing.version = existing.version + 1
|
|
existing.updated_at = now
|
|
else:
|
|
existing = TOMStateDB(
|
|
tenant_id=tid,
|
|
state=body.state,
|
|
version=1,
|
|
created_at=now,
|
|
updated_at=now,
|
|
)
|
|
db.add(existing)
|
|
|
|
db.commit()
|
|
db.refresh(existing)
|
|
|
|
return {
|
|
"success": True,
|
|
"data": {
|
|
"tenantId": tid,
|
|
"state": existing.state,
|
|
"version": existing.version,
|
|
"lastModified": existing.updated_at.isoformat() if existing.updated_at else None,
|
|
},
|
|
}
|
|
|
|
|
|
@router.delete("/state")
|
|
async def delete_tom_state(
|
|
tenant_id: Optional[str] = Query(None, alias="tenant_id"),
|
|
tenantId: Optional[str] = Query(None),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Clear TOM generator state for a tenant."""
|
|
tid = tenant_id or tenantId
|
|
if not tid:
|
|
raise HTTPException(status_code=400, detail="tenant_id is required")
|
|
|
|
row = db.query(TOMStateDB).filter(TOMStateDB.tenant_id == tid).first()
|
|
deleted = False
|
|
if row:
|
|
db.delete(row)
|
|
db.commit()
|
|
deleted = True
|
|
|
|
return {
|
|
"success": True,
|
|
"tenantId": tid,
|
|
"deleted": deleted,
|
|
"deletedAt": datetime.now(timezone.utc).isoformat(),
|
|
}
|
|
|
|
|
|
# =============================================================================
|
|
# MEASURES ENDPOINTS
|
|
# =============================================================================
|
|
|
|
@router.get("/measures")
|
|
async def list_measures(
|
|
tenant_id: Optional[str] = Query(None),
|
|
category: Optional[str] = Query(None),
|
|
implementation_status: Optional[str] = Query(None),
|
|
priority: Optional[str] = Query(None),
|
|
search: Optional[str] = Query(None),
|
|
limit: int = Query(100, ge=1, le=500),
|
|
offset: int = Query(0, ge=0),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""List TOM measures with optional filters."""
|
|
tid = tenant_id or DEFAULT_TENANT_ID
|
|
q = db.query(TOMMeasureDB).filter(TOMMeasureDB.tenant_id == tid)
|
|
|
|
if category:
|
|
q = q.filter(TOMMeasureDB.category == category)
|
|
if implementation_status:
|
|
q = q.filter(TOMMeasureDB.implementation_status == implementation_status)
|
|
if priority:
|
|
q = q.filter(TOMMeasureDB.priority == priority)
|
|
if search:
|
|
pattern = f"%{search}%"
|
|
q = q.filter(
|
|
(TOMMeasureDB.name.ilike(pattern))
|
|
| (TOMMeasureDB.description.ilike(pattern))
|
|
| (TOMMeasureDB.control_id.ilike(pattern))
|
|
)
|
|
|
|
total = q.count()
|
|
rows = q.order_by(TOMMeasureDB.control_id).offset(offset).limit(limit).all()
|
|
|
|
return {
|
|
"measures": [_measure_to_dict(r) for r in rows],
|
|
"total": total,
|
|
"limit": limit,
|
|
"offset": offset,
|
|
}
|
|
|
|
|
|
@router.post("/measures", status_code=201)
|
|
async def create_measure(
|
|
body: TOMMeasureCreate,
|
|
tenant_id: Optional[str] = Query(None),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Create a single TOM measure."""
|
|
tid = tenant_id or DEFAULT_TENANT_ID
|
|
|
|
# Check for duplicate control_id
|
|
existing = (
|
|
db.query(TOMMeasureDB)
|
|
.filter(TOMMeasureDB.tenant_id == tid, TOMMeasureDB.control_id == body.control_id)
|
|
.first()
|
|
)
|
|
if existing:
|
|
raise HTTPException(status_code=409, detail=f"Measure with control_id '{body.control_id}' already exists")
|
|
|
|
now = datetime.now(timezone.utc)
|
|
measure = TOMMeasureDB(
|
|
tenant_id=tid,
|
|
control_id=body.control_id,
|
|
name=body.name,
|
|
description=body.description,
|
|
category=body.category,
|
|
type=body.type,
|
|
applicability=body.applicability,
|
|
applicability_reason=body.applicability_reason,
|
|
implementation_status=body.implementation_status,
|
|
responsible_person=body.responsible_person,
|
|
responsible_department=body.responsible_department,
|
|
implementation_date=_parse_dt(body.implementation_date),
|
|
review_date=_parse_dt(body.review_date),
|
|
review_frequency=body.review_frequency,
|
|
priority=body.priority,
|
|
complexity=body.complexity,
|
|
linked_evidence=body.linked_evidence or [],
|
|
evidence_gaps=body.evidence_gaps or [],
|
|
related_controls=body.related_controls or {},
|
|
verified_at=_parse_dt(body.verified_at),
|
|
verified_by=body.verified_by,
|
|
effectiveness_rating=body.effectiveness_rating,
|
|
created_at=now,
|
|
updated_at=now,
|
|
)
|
|
db.add(measure)
|
|
db.commit()
|
|
db.refresh(measure)
|
|
|
|
return _measure_to_dict(measure)
|
|
|
|
|
|
@router.put("/measures/{measure_id}")
|
|
async def update_measure(
|
|
measure_id: UUID,
|
|
body: TOMMeasureUpdate,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Update a TOM measure."""
|
|
row = db.query(TOMMeasureDB).filter(TOMMeasureDB.id == measure_id).first()
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="Measure not found")
|
|
|
|
update_data = body.model_dump(exclude_unset=True)
|
|
for key, val in update_data.items():
|
|
if key in ("implementation_date", "review_date", "verified_at"):
|
|
val = _parse_dt(val)
|
|
setattr(row, key, val)
|
|
|
|
row.updated_at = datetime.now(timezone.utc)
|
|
db.commit()
|
|
db.refresh(row)
|
|
|
|
return _measure_to_dict(row)
|
|
|
|
|
|
@router.post("/measures/bulk")
|
|
async def bulk_upsert_measures(
|
|
body: TOMMeasureBulkBody,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Bulk upsert measures — used by deriveTOMs sync from frontend."""
|
|
tid = body.tenant_id or DEFAULT_TENANT_ID
|
|
now = datetime.now(timezone.utc)
|
|
|
|
created = 0
|
|
updated = 0
|
|
|
|
for item in body.measures:
|
|
existing = (
|
|
db.query(TOMMeasureDB)
|
|
.filter(TOMMeasureDB.tenant_id == tid, TOMMeasureDB.control_id == item.control_id)
|
|
.first()
|
|
)
|
|
|
|
if existing:
|
|
existing.name = item.name
|
|
existing.description = item.description
|
|
existing.category = item.category
|
|
existing.type = item.type
|
|
existing.applicability = item.applicability
|
|
existing.applicability_reason = item.applicability_reason
|
|
existing.implementation_status = item.implementation_status
|
|
existing.responsible_person = item.responsible_person
|
|
existing.responsible_department = item.responsible_department
|
|
existing.implementation_date = _parse_dt(item.implementation_date)
|
|
existing.review_date = _parse_dt(item.review_date)
|
|
existing.review_frequency = item.review_frequency
|
|
existing.priority = item.priority
|
|
existing.complexity = item.complexity
|
|
existing.linked_evidence = item.linked_evidence or []
|
|
existing.evidence_gaps = item.evidence_gaps or []
|
|
existing.related_controls = item.related_controls or {}
|
|
existing.updated_at = now
|
|
updated += 1
|
|
else:
|
|
measure = TOMMeasureDB(
|
|
tenant_id=tid,
|
|
control_id=item.control_id,
|
|
name=item.name,
|
|
description=item.description,
|
|
category=item.category,
|
|
type=item.type,
|
|
applicability=item.applicability,
|
|
applicability_reason=item.applicability_reason,
|
|
implementation_status=item.implementation_status,
|
|
responsible_person=item.responsible_person,
|
|
responsible_department=item.responsible_department,
|
|
implementation_date=_parse_dt(item.implementation_date),
|
|
review_date=_parse_dt(item.review_date),
|
|
review_frequency=item.review_frequency,
|
|
priority=item.priority,
|
|
complexity=item.complexity,
|
|
linked_evidence=item.linked_evidence or [],
|
|
evidence_gaps=item.evidence_gaps or [],
|
|
related_controls=item.related_controls or {},
|
|
created_at=now,
|
|
updated_at=now,
|
|
)
|
|
db.add(measure)
|
|
created += 1
|
|
|
|
db.commit()
|
|
|
|
return {
|
|
"success": True,
|
|
"tenant_id": tid,
|
|
"created": created,
|
|
"updated": updated,
|
|
"total": created + updated,
|
|
}
|
|
|
|
|
|
# =============================================================================
|
|
# STATS & EXPORT
|
|
# =============================================================================
|
|
|
|
@router.get("/stats")
|
|
async def get_tom_stats(
|
|
tenant_id: Optional[str] = Query(None),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Return TOM statistics for a tenant."""
|
|
tid = tenant_id or DEFAULT_TENANT_ID
|
|
|
|
base_q = db.query(TOMMeasureDB).filter(TOMMeasureDB.tenant_id == tid)
|
|
total = base_q.count()
|
|
|
|
# By status
|
|
status_rows = (
|
|
db.query(TOMMeasureDB.implementation_status, func.count(TOMMeasureDB.id))
|
|
.filter(TOMMeasureDB.tenant_id == tid)
|
|
.group_by(TOMMeasureDB.implementation_status)
|
|
.all()
|
|
)
|
|
by_status = {row[0]: row[1] for row in status_rows}
|
|
|
|
# By category
|
|
cat_rows = (
|
|
db.query(TOMMeasureDB.category, func.count(TOMMeasureDB.id))
|
|
.filter(TOMMeasureDB.tenant_id == tid)
|
|
.group_by(TOMMeasureDB.category)
|
|
.all()
|
|
)
|
|
by_category = {row[0]: row[1] for row in cat_rows}
|
|
|
|
# Overdue reviews
|
|
now = datetime.now(timezone.utc)
|
|
overdue = (
|
|
base_q.filter(
|
|
TOMMeasureDB.review_date.isnot(None),
|
|
TOMMeasureDB.review_date < now,
|
|
)
|
|
.count()
|
|
)
|
|
|
|
return {
|
|
"total": total,
|
|
"by_status": by_status,
|
|
"by_category": by_category,
|
|
"overdue_review_count": overdue,
|
|
"implemented": by_status.get("IMPLEMENTED", 0),
|
|
"partial": by_status.get("PARTIAL", 0),
|
|
"not_implemented": by_status.get("NOT_IMPLEMENTED", 0),
|
|
}
|
|
|
|
|
|
@router.get("/export")
|
|
async def export_measures(
|
|
tenant_id: Optional[str] = Query(None),
|
|
format: str = Query("csv"),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Export TOM measures as CSV (semicolon-separated) or JSON."""
|
|
tid = tenant_id or DEFAULT_TENANT_ID
|
|
|
|
rows = (
|
|
db.query(TOMMeasureDB)
|
|
.filter(TOMMeasureDB.tenant_id == tid)
|
|
.order_by(TOMMeasureDB.control_id)
|
|
.all()
|
|
)
|
|
measures = [_measure_to_dict(r) for r in rows]
|
|
|
|
if format == "json":
|
|
return StreamingResponse(
|
|
io.BytesIO(json.dumps(measures, ensure_ascii=False, indent=2).encode("utf-8")),
|
|
media_type="application/json",
|
|
headers={"Content-Disposition": "attachment; filename=tom_export.json"},
|
|
)
|
|
|
|
# CSV (semicolon, like VVT)
|
|
output = io.StringIO()
|
|
fieldnames = [
|
|
"control_id", "name", "description", "category", "type",
|
|
"applicability", "implementation_status", "responsible_person",
|
|
"responsible_department", "implementation_date", "review_date",
|
|
"review_frequency", "priority", "complexity", "effectiveness_rating",
|
|
]
|
|
writer = csv.DictWriter(output, fieldnames=fieldnames, delimiter=";", extrasaction="ignore")
|
|
writer.writeheader()
|
|
for m in measures:
|
|
writer.writerow(m)
|
|
|
|
output.seek(0)
|
|
return StreamingResponse(
|
|
io.BytesIO(output.getvalue().encode("utf-8")),
|
|
media_type="text/csv; charset=utf-8",
|
|
headers={"Content-Disposition": "attachment; filename=tom_export.csv"},
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# Versioning
|
|
# =============================================================================
|
|
|
|
@router.get("/measures/{measure_id}/versions")
|
|
async def list_measure_versions(
|
|
measure_id: str,
|
|
tenant_id: Optional[str] = Query(None, alias="tenant_id"),
|
|
tenantId: Optional[str] = Query(None, alias="tenantId"),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""List all versions for a TOM measure."""
|
|
from .versioning_utils import list_versions
|
|
tid = tenant_id or tenantId or DEFAULT_TENANT_ID
|
|
return list_versions(db, "tom", measure_id, tid)
|
|
|
|
|
|
@router.get("/measures/{measure_id}/versions/{version_number}")
|
|
async def get_measure_version(
|
|
measure_id: str,
|
|
version_number: int,
|
|
tenant_id: Optional[str] = Query(None, alias="tenant_id"),
|
|
tenantId: Optional[str] = Query(None, alias="tenantId"),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Get a specific TOM measure version with full snapshot."""
|
|
from .versioning_utils import get_version
|
|
tid = tenant_id or tenantId or DEFAULT_TENANT_ID
|
|
v = get_version(db, "tom", measure_id, version_number, tid)
|
|
if not v:
|
|
raise HTTPException(status_code=404, detail=f"Version {version_number} not found")
|
|
return v
|