All checks were successful
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) Successful in 32s
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 18s
6-Phasen-Implementation fuer cloud-faehiges, mandantenfaehiges Compliance SDK:
Phase 1: Multi-Tenancy Fix
- Shared tenant_utils.py Dependency (UUID-Validierung, kein "default" mehr)
- VVT tenant_id Column + tenant-scoped Queries
- DSFA/Vendor DEFAULT_TENANT_ID von "default" auf UUID migriert
- Migration 035
Phase 2: Stammdaten-Erweiterung
- Company Profile um JSONB-Felder erweitert (processing_systems, ai_systems, technical_contacts)
- Regulierungs-Flags (NIS2, AI Act, ISO 27001)
- GET /template-context Endpoint
- Migration 036
Phase 3: Dokument-Versionierung
- 5 Versions-Tabellen (DSFA, VVT, TOM, Loeschfristen, Obligations)
- Shared versioning_utils.py Helper
- /{id}/versions Endpoints auf allen 5 Dokumenttypen
- Migration 037
Phase 4: Change-Request System
- Zentrale CR-Inbox mit CRUD + Accept/Reject/Edit Workflow
- Regelbasierte CR-Engine (VVT DPIA → DSFA CR, Datenkategorien → Loeschfristen CR)
- Audit-Trail
- Migration 038
Phase 5: Dokumentengenerierung
- 5 Template-Generatoren (DSFA, VVT, TOM, Loeschfristen, Obligations)
- Preview + Apply Endpoints (erzeugt CRs, keine direkten Dokumente)
Phase 6: Frontend-Integration
- Change-Request Inbox Page mit Stats, Filtern, Modals
- VersionHistory Timeline-Komponente
- SDKSidebar CR-Badge (60s Polling)
- Company Profile: 2 neue Wizard-Steps + "Dokumente generieren" CTA
Docs: 5 neue MkDocs-Seiten, CLAUDE.md aktualisiert
Tests: 97 neue Tests (alle bestanden)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
387 lines
13 KiB
Python
387 lines
13 KiB
Python
"""
|
|
FastAPI routes for Loeschfristen (Retention Policies).
|
|
|
|
Endpoints:
|
|
GET /loeschfristen — list (filter: status, retention_driver, search; limit/offset)
|
|
GET /loeschfristen/stats — total, active, draft, review_needed, archived, legal_holds_count
|
|
POST /loeschfristen — create
|
|
GET /loeschfristen/{id} — get single
|
|
PUT /loeschfristen/{id} — full update
|
|
PUT /loeschfristen/{id}/status — quick status update
|
|
DELETE /loeschfristen/{id} — delete (204)
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
from datetime import datetime
|
|
from typing import Optional, List, Any, Dict
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Header
|
|
from pydantic import BaseModel
|
|
from sqlalchemy import text
|
|
from sqlalchemy.orm import Session
|
|
from uuid import UUID
|
|
|
|
from classroom_engine.database import get_db
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/loeschfristen", tags=["loeschfristen"])
|
|
|
|
DEFAULT_TENANT_ID = "9282a473-5c95-4b3a-bf78-0ecc0ec71d3e"
|
|
|
|
|
|
# =============================================================================
|
|
# Pydantic Schemas
|
|
# =============================================================================
|
|
|
|
class LoeschfristCreate(BaseModel):
|
|
policy_id: Optional[str] = None
|
|
data_object_name: str
|
|
description: Optional[str] = None
|
|
affected_groups: Optional[List[Any]] = None
|
|
data_categories: Optional[List[Any]] = None
|
|
primary_purpose: Optional[str] = None
|
|
deletion_trigger: str = "PURPOSE_END"
|
|
retention_driver: Optional[str] = None
|
|
retention_driver_detail: Optional[str] = None
|
|
retention_duration: Optional[int] = None
|
|
retention_unit: Optional[str] = None
|
|
retention_description: Optional[str] = None
|
|
start_event: Optional[str] = None
|
|
has_active_legal_hold: bool = False
|
|
legal_holds: Optional[List[Any]] = None
|
|
storage_locations: Optional[List[Any]] = None
|
|
deletion_method: Optional[str] = None
|
|
deletion_method_detail: Optional[str] = None
|
|
responsible_role: Optional[str] = None
|
|
responsible_person: Optional[str] = None
|
|
release_process: Optional[str] = None
|
|
linked_vvt_activity_ids: Optional[List[Any]] = None
|
|
status: str = "DRAFT"
|
|
last_review_date: Optional[datetime] = None
|
|
next_review_date: Optional[datetime] = None
|
|
review_interval: Optional[str] = None
|
|
tags: Optional[List[Any]] = None
|
|
|
|
|
|
class LoeschfristUpdate(BaseModel):
|
|
policy_id: Optional[str] = None
|
|
data_object_name: Optional[str] = None
|
|
description: Optional[str] = None
|
|
affected_groups: Optional[List[Any]] = None
|
|
data_categories: Optional[List[Any]] = None
|
|
primary_purpose: Optional[str] = None
|
|
deletion_trigger: Optional[str] = None
|
|
retention_driver: Optional[str] = None
|
|
retention_driver_detail: Optional[str] = None
|
|
retention_duration: Optional[int] = None
|
|
retention_unit: Optional[str] = None
|
|
retention_description: Optional[str] = None
|
|
start_event: Optional[str] = None
|
|
has_active_legal_hold: Optional[bool] = None
|
|
legal_holds: Optional[List[Any]] = None
|
|
storage_locations: Optional[List[Any]] = None
|
|
deletion_method: Optional[str] = None
|
|
deletion_method_detail: Optional[str] = None
|
|
responsible_role: Optional[str] = None
|
|
responsible_person: Optional[str] = None
|
|
release_process: Optional[str] = None
|
|
linked_vvt_activity_ids: Optional[List[Any]] = None
|
|
status: Optional[str] = None
|
|
last_review_date: Optional[datetime] = None
|
|
next_review_date: Optional[datetime] = None
|
|
review_interval: Optional[str] = None
|
|
tags: Optional[List[Any]] = None
|
|
|
|
|
|
class StatusUpdate(BaseModel):
|
|
status: str
|
|
|
|
|
|
# JSONB fields that need CAST
|
|
JSONB_FIELDS = {
|
|
"affected_groups", "data_categories", "legal_holds",
|
|
"storage_locations", "linked_vvt_activity_ids", "tags"
|
|
}
|
|
|
|
|
|
def _row_to_dict(row) -> Dict[str, Any]:
|
|
result = dict(row._mapping)
|
|
for key, val in result.items():
|
|
if isinstance(val, datetime):
|
|
result[key] = val.isoformat()
|
|
elif hasattr(val, '__str__') and not isinstance(val, (str, int, float, bool, list, dict, type(None))):
|
|
result[key] = str(val)
|
|
return result
|
|
|
|
|
|
def _get_tenant_id(x_tenant_id: Optional[str] = Header(None)) -> str:
|
|
if x_tenant_id:
|
|
try:
|
|
UUID(x_tenant_id)
|
|
return x_tenant_id
|
|
except ValueError:
|
|
pass
|
|
return DEFAULT_TENANT_ID
|
|
|
|
|
|
# =============================================================================
|
|
# Routes
|
|
# =============================================================================
|
|
|
|
@router.get("")
|
|
async def list_loeschfristen(
|
|
status: Optional[str] = Query(None),
|
|
retention_driver: Optional[str] = Query(None),
|
|
search: Optional[str] = Query(None),
|
|
limit: int = Query(500, ge=1, le=1000),
|
|
offset: int = Query(0, ge=0),
|
|
db: Session = Depends(get_db),
|
|
x_tenant_id: Optional[str] = Header(None),
|
|
):
|
|
"""List Loeschfristen with optional filters."""
|
|
tenant_id = _get_tenant_id(x_tenant_id)
|
|
|
|
where_clauses = ["tenant_id = :tenant_id"]
|
|
params: Dict[str, Any] = {"tenant_id": tenant_id, "limit": limit, "offset": offset}
|
|
|
|
if status:
|
|
where_clauses.append("status = :status")
|
|
params["status"] = status
|
|
if retention_driver:
|
|
where_clauses.append("retention_driver = :retention_driver")
|
|
params["retention_driver"] = retention_driver
|
|
if search:
|
|
where_clauses.append("(data_object_name ILIKE :search OR description ILIKE :search OR policy_id ILIKE :search)")
|
|
params["search"] = f"%{search}%"
|
|
|
|
where_sql = " AND ".join(where_clauses)
|
|
|
|
total_row = db.execute(
|
|
text(f"SELECT COUNT(*) FROM compliance_loeschfristen WHERE {where_sql}"),
|
|
params,
|
|
).fetchone()
|
|
total = total_row[0] if total_row else 0
|
|
|
|
rows = db.execute(
|
|
text(f"""
|
|
SELECT * FROM compliance_loeschfristen
|
|
WHERE {where_sql}
|
|
ORDER BY
|
|
CASE status
|
|
WHEN 'ACTIVE' THEN 0
|
|
WHEN 'REVIEW_NEEDED' THEN 1
|
|
WHEN 'DRAFT' THEN 2
|
|
ELSE 3
|
|
END,
|
|
created_at DESC
|
|
LIMIT :limit OFFSET :offset
|
|
"""),
|
|
params,
|
|
).fetchall()
|
|
|
|
return {
|
|
"policies": [_row_to_dict(r) for r in rows],
|
|
"total": total,
|
|
}
|
|
|
|
|
|
@router.get("/stats")
|
|
async def get_loeschfristen_stats(
|
|
db: Session = Depends(get_db),
|
|
x_tenant_id: Optional[str] = Header(None),
|
|
):
|
|
"""Return Loeschfristen statistics."""
|
|
tenant_id = _get_tenant_id(x_tenant_id)
|
|
|
|
row = db.execute(text("""
|
|
SELECT
|
|
COUNT(*) AS total,
|
|
COUNT(*) FILTER (WHERE status = 'ACTIVE') AS active,
|
|
COUNT(*) FILTER (WHERE status = 'DRAFT') AS draft,
|
|
COUNT(*) FILTER (WHERE status = 'REVIEW_NEEDED') AS review_needed,
|
|
COUNT(*) FILTER (WHERE status = 'ARCHIVED') AS archived,
|
|
COUNT(*) FILTER (WHERE has_active_legal_hold = TRUE) AS legal_holds_count,
|
|
COUNT(*) FILTER (
|
|
WHERE next_review_date IS NOT NULL
|
|
AND next_review_date < NOW()
|
|
AND status NOT IN ('ARCHIVED')
|
|
) AS overdue_reviews
|
|
FROM compliance_loeschfristen
|
|
WHERE tenant_id = :tenant_id
|
|
"""), {"tenant_id": tenant_id}).fetchone()
|
|
|
|
if row:
|
|
d = dict(row._mapping)
|
|
return {k: int(v or 0) for k, v in d.items()}
|
|
return {"total": 0, "active": 0, "draft": 0, "review_needed": 0,
|
|
"archived": 0, "legal_holds_count": 0, "overdue_reviews": 0}
|
|
|
|
|
|
@router.post("", status_code=201)
|
|
async def create_loeschfrist(
|
|
payload: LoeschfristCreate,
|
|
db: Session = Depends(get_db),
|
|
x_tenant_id: Optional[str] = Header(None),
|
|
):
|
|
"""Create a new Loeschfrist policy."""
|
|
tenant_id = _get_tenant_id(x_tenant_id)
|
|
|
|
data = payload.model_dump()
|
|
|
|
# Build INSERT with JSONB casts
|
|
columns = ["tenant_id"] + list(data.keys())
|
|
value_parts = [":tenant_id"]
|
|
params: Dict[str, Any] = {"tenant_id": tenant_id}
|
|
|
|
for k, v in data.items():
|
|
if k in JSONB_FIELDS:
|
|
value_parts.append(f"CAST(:{k} AS jsonb)")
|
|
params[k] = json.dumps(v if v is not None else [])
|
|
else:
|
|
value_parts.append(f":{k}")
|
|
params[k] = v
|
|
|
|
cols_sql = ", ".join(columns)
|
|
vals_sql = ", ".join(value_parts)
|
|
|
|
row = db.execute(
|
|
text(f"INSERT INTO compliance_loeschfristen ({cols_sql}) VALUES ({vals_sql}) RETURNING *"),
|
|
params,
|
|
).fetchone()
|
|
db.commit()
|
|
return _row_to_dict(row)
|
|
|
|
|
|
@router.get("/{policy_id}")
|
|
async def get_loeschfrist(
|
|
policy_id: str,
|
|
db: Session = Depends(get_db),
|
|
x_tenant_id: Optional[str] = Header(None),
|
|
):
|
|
tenant_id = _get_tenant_id(x_tenant_id)
|
|
row = db.execute(
|
|
text("SELECT * FROM compliance_loeschfristen WHERE id = :id AND tenant_id = :tenant_id"),
|
|
{"id": policy_id, "tenant_id": tenant_id},
|
|
).fetchone()
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="Loeschfrist not found")
|
|
return _row_to_dict(row)
|
|
|
|
|
|
@router.put("/{policy_id}")
|
|
async def update_loeschfrist(
|
|
policy_id: str,
|
|
payload: LoeschfristUpdate,
|
|
db: Session = Depends(get_db),
|
|
x_tenant_id: Optional[str] = Header(None),
|
|
):
|
|
"""Full update of a Loeschfrist policy."""
|
|
tenant_id = _get_tenant_id(x_tenant_id)
|
|
|
|
updates: Dict[str, Any] = {"id": policy_id, "tenant_id": tenant_id, "updated_at": datetime.utcnow()}
|
|
set_clauses = ["updated_at = :updated_at"]
|
|
|
|
for field, value in payload.model_dump(exclude_unset=True).items():
|
|
if field in JSONB_FIELDS:
|
|
updates[field] = json.dumps(value if value is not None else [])
|
|
set_clauses.append(f"{field} = CAST(:{field} AS jsonb)")
|
|
else:
|
|
updates[field] = value
|
|
set_clauses.append(f"{field} = :{field}")
|
|
|
|
if len(set_clauses) == 1:
|
|
raise HTTPException(status_code=400, detail="No fields to update")
|
|
|
|
row = db.execute(
|
|
text(f"""
|
|
UPDATE compliance_loeschfristen
|
|
SET {', '.join(set_clauses)}
|
|
WHERE id = :id AND tenant_id = :tenant_id
|
|
RETURNING *
|
|
"""),
|
|
updates,
|
|
).fetchone()
|
|
db.commit()
|
|
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="Loeschfrist not found")
|
|
return _row_to_dict(row)
|
|
|
|
|
|
@router.put("/{policy_id}/status")
|
|
async def update_loeschfrist_status(
|
|
policy_id: str,
|
|
payload: StatusUpdate,
|
|
db: Session = Depends(get_db),
|
|
x_tenant_id: Optional[str] = Header(None),
|
|
):
|
|
"""Quick status update."""
|
|
tenant_id = _get_tenant_id(x_tenant_id)
|
|
valid = {"DRAFT", "ACTIVE", "REVIEW_NEEDED", "ARCHIVED"}
|
|
if payload.status not in valid:
|
|
raise HTTPException(status_code=400, detail=f"Invalid status. Must be one of: {', '.join(valid)}")
|
|
|
|
row = db.execute(
|
|
text("""
|
|
UPDATE compliance_loeschfristen
|
|
SET status = :status, updated_at = :now
|
|
WHERE id = :id AND tenant_id = :tenant_id
|
|
RETURNING *
|
|
"""),
|
|
{"status": payload.status, "now": datetime.utcnow(), "id": policy_id, "tenant_id": tenant_id},
|
|
).fetchone()
|
|
db.commit()
|
|
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="Loeschfrist not found")
|
|
return _row_to_dict(row)
|
|
|
|
|
|
@router.delete("/{policy_id}", status_code=204)
|
|
async def delete_loeschfrist(
|
|
policy_id: str,
|
|
db: Session = Depends(get_db),
|
|
x_tenant_id: Optional[str] = Header(None),
|
|
):
|
|
tenant_id = _get_tenant_id(x_tenant_id)
|
|
result = db.execute(
|
|
text("DELETE FROM compliance_loeschfristen WHERE id = :id AND tenant_id = :tenant_id"),
|
|
{"id": policy_id, "tenant_id": tenant_id},
|
|
)
|
|
db.commit()
|
|
if result.rowcount == 0:
|
|
raise HTTPException(status_code=404, detail="Loeschfrist not found")
|
|
|
|
|
|
# =============================================================================
|
|
# Versioning
|
|
# =============================================================================
|
|
|
|
@router.get("/{policy_id}/versions")
|
|
async def list_loeschfristen_versions(
|
|
policy_id: str,
|
|
db: Session = Depends(get_db),
|
|
x_tenant_id: Optional[str] = Header(None),
|
|
):
|
|
"""List all versions for a Loeschfrist."""
|
|
from .versioning_utils import list_versions
|
|
tenant_id = _get_tenant_id(x_tenant_id)
|
|
return list_versions(db, "loeschfristen", policy_id, tenant_id)
|
|
|
|
|
|
@router.get("/{policy_id}/versions/{version_number}")
|
|
async def get_loeschfristen_version(
|
|
policy_id: str,
|
|
version_number: int,
|
|
db: Session = Depends(get_db),
|
|
x_tenant_id: Optional[str] = Header(None),
|
|
):
|
|
"""Get a specific Loeschfristen version with full snapshot."""
|
|
from .versioning_utils import get_version
|
|
tenant_id = _get_tenant_id(x_tenant_id)
|
|
v = get_version(db, "loeschfristen", policy_id, version_number, tenant_id)
|
|
if not v:
|
|
raise HTTPException(status_code=404, detail=f"Version {version_number} not found")
|
|
return v
|