Files
breakpilot-compliance/backend-compliance/compliance/api/loeschfristen_routes.py
Benjamin Admin 6509e64dd9
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
feat(sdk): API-Referenz Frontend + Backend-Konsolidierung (Shared Utilities, CRUD Factory)
- API-Referenz Seite (/sdk/api-docs) mit ~690 Endpoints, Suche, Filter, Modul-Index
- Shared db_utils.py (row_to_dict) + tenant_utils Integration in 6 Route-Dateien
- CRUD Factory (crud_factory.py) fuer zukuenftige Module
- Version-Route Auto-Registration in versioning_utils.py
- 1338 Tests bestanden, -232 Zeilen Duplikat-Code

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 17:07:43 +01:00

357 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
from pydantic import BaseModel
from sqlalchemy import text
from sqlalchemy.orm import Session
from classroom_engine.database import get_db
from .tenant_utils import get_tenant_id as _get_tenant_id
from .db_utils import row_to_dict as _row_to_dict
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/loeschfristen", tags=["loeschfristen"])
# =============================================================================
# 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"
}
# =============================================================================
# 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),
tenant_id: str = Depends(_get_tenant_id),
):
"""List Loeschfristen with optional filters."""
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),
tenant_id: str = Depends(_get_tenant_id),
):
"""Return Loeschfristen statistics."""
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),
tenant_id: str = Depends(_get_tenant_id),
):
"""Create a new Loeschfrist policy."""
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),
tenant_id: str = Depends(_get_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),
tenant_id: str = Depends(_get_tenant_id),
):
"""Full update of a Loeschfrist policy."""
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),
tenant_id: str = Depends(_get_tenant_id),
):
"""Quick status update."""
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),
tenant_id: str = Depends(_get_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),
tenant_id: str = Depends(_get_tenant_id),
):
"""List all versions for a Loeschfrist."""
from .versioning_utils import list_versions
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),
tenant_id: str = Depends(_get_tenant_id),
):
"""Get a specific Loeschfristen version with full snapshot."""
from .versioning_utils import get_version
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