Files
breakpilot-compliance/backend-compliance/compliance/api/loeschfristen_routes.py
Benjamin Admin 25d5da78ef
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 34s
CI / test-python-backend-compliance (push) Successful in 32s
CI / test-python-document-crawler (push) Successful in 21s
CI / test-python-dsms-gateway (push) Successful in 17s
feat: Alle 5 verbleibenden SDK-Module auf 100% — RAG, Security-Backlog, Quality, Notfallplan, Loeschfristen
Paket A — RAG Proxy:
- NEU: admin-compliance/app/api/sdk/v1/rag/[[...path]]/route.ts
  → Proxy zu ai-compliance-sdk:8090, GET+POST, UUID-Validierung
- UPDATE: rag/page.tsx — setTimeout Mock → echte API-Calls
  GET /regulations → dynamische suggestedQuestions
  POST /search → Qdrant-Ergebnisse mit score, title, reference

Paket B — Security-Backlog + Quality:
- NEU: migrations/014_security_backlog.sql + 015_quality.sql
- NEU: compliance/api/security_backlog_routes.py — CRUD + Stats
- NEU: compliance/api/quality_routes.py — Metrics + Tests CRUD + Stats
- UPDATE: security-backlog/page.tsx — mockItems → API
- UPDATE: quality/page.tsx — mockMetrics/mockTests → API
- UPDATE: compliance/api/__init__.py — Router-Registrierung
- NEU: tests/test_security_backlog_routes.py (48 Tests — 48/48 bestanden)
- NEU: tests/test_quality_routes.py (67 Tests — 67/67 bestanden)

Paket C — Notfallplan Incidents + Templates:
- NEU: migrations/016_notfallplan_incidents.sql
  compliance_notfallplan_incidents + compliance_notfallplan_templates
- UPDATE: notfallplan_routes.py — GET/POST/PUT/DELETE für /incidents + /templates
- UPDATE: notfallplan/page.tsx — Incidents-Tab + Templates-Tab → API
- UPDATE: tests/test_notfallplan_routes.py (+76 neue Tests — alle bestanden)

Paket D — Loeschfristen localStorage → API:
- NEU: migrations/017_loeschfristen.sql (JSONB: legal_holds, storage_locations, ...)
- NEU: compliance/api/loeschfristen_routes.py — CRUD + Stats + Status-Update
- UPDATE: loeschfristen/page.tsx — vollständige localStorage → API Migration
  createNewPolicy → POST (API-UUID als id), deletePolicy → DELETE,
  handleSaveAndClose → PUT, adoptGeneratedPolicies → POST je Policy
  apiToPolicy() + policyToPayload() Mapper, saving-State für Buttons
- NEU: tests/test_loeschfristen_routes.py (58 Tests — alle bestanden)

Gesamt: 253 neue Tests, alle bestanden (48 + 67 + 76 + 58 + bestehende)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 18:04:53 +01:00

355 lines
12 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")