""" 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 linked_vendor_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 linked_vendor_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", "linked_vendor_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