Files
leocrm/app/routes/entity_history.py
T

216 lines
7.7 KiB
Python
Raw Normal View History

2026-07-23 08:42:26 +02:00
"""Entity history routes — query, restore, and undo entity snapshots."""
from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query
2026-07-23 08:42:26 +02:00
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
from app.core.restore_registry import get_restore_registry
2026-07-23 08:42:26 +02:00
from app.deps import get_current_user, require_permission
from app.schemas.entity_history import (
BulkRestoreRequest,
BulkRestoreResponse,
EntityHistoryListResponse,
EntityHistoryResponse,
RestoreRequest,
)
2026-07-23 08:42:26 +02:00
from app.services import entity_history_service
router = APIRouter(prefix="/api/v1/entity-history", tags=["entity-history"])
def _entry_to_dict(e) -> dict:
"""Serialize an EntityHistory ORM object to dict."""
return {
"id": str(e.id),
"entity_type": e.entity_type,
"entity_id": str(e.entity_id),
"action": e.action,
"snapshot_before": e.snapshot_before,
"snapshot_after": e.snapshot_after,
"changes": e.changes,
"user_id": str(e.user_id) if e.user_id else None,
"created_at": e.created_at.isoformat() if e.created_at else None,
}
@router.get("/{entity_type}/{entity_id}", response_model=EntityHistoryListResponse)
async def get_entity_history(
entity_type: str,
entity_id: str,
limit: int = Query(50, ge=1, le=200),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Get history entries for an entity, newest first."""
tenant_id = uuid.UUID(current_user["tenant_id"])
try:
eid = uuid.UUID(entity_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid entity_id") from None
entries = await entity_history_service.get_entity_history(
db, tenant_id, entity_type, eid, limit=limit
)
return EntityHistoryListResponse(
items=[EntityHistoryResponse(**_entry_to_dict(e)) for e in entries],
total=len(entries),
)
@router.post("/restore")
async def restore_from_history(
body: RestoreRequest,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
2026-07-23 08:42:26 +02:00
):
"""Restore an entity from a history entry.
Permission is checked dynamically based on the entity type's
RestoreConfig.restore_permission from the RestoreRegistry.
"""
2026-07-23 08:42:26 +02:00
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
try:
hid = uuid.UUID(body.history_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid history_id") from None
# Look up the history entry to determine entity_type
entry = await entity_history_service.get_history_entry(db, tenant_id, hid, user_id)
if entry is None:
raise HTTPException(status_code=404, detail="History entry not found")
# Check permission dynamically based on entity type
config = get_restore_registry().get(entry.entity_type)
if config is None:
raise HTTPException(status_code=400, detail=f"Restore not supported for entity type: {entry.entity_type}")
required_perm = config.restore_permission
user_permissions = current_user.get("permissions", set())
if required_perm not in user_permissions and not current_user.get("is_system_admin", False):
raise HTTPException(status_code=403, detail=f"Missing permission: {required_perm}")
2026-07-23 08:42:26 +02:00
try:
return await entity_history_service.restore_from_history(db, tenant_id, hid, user_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e)) from None
@router.post("/undo/{entity_type}/{entity_id}")
async def undo_last_action(
entity_type: str,
entity_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
2026-07-23 08:42:26 +02:00
):
"""Undo the most recent action for an entity.
Permission is checked dynamically based on the entity type's
RestoreConfig.restore_permission from the RestoreRegistry.
"""
2026-07-23 08:42:26 +02:00
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
try:
eid = uuid.UUID(entity_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid entity_id") from None
# Check permission dynamically based on entity type
config = get_restore_registry().get(entity_type)
if config is None:
raise HTTPException(status_code=400, detail=f"Undo not supported for entity type: {entity_type}")
required_perm = config.restore_permission
user_permissions = current_user.get("permissions", set())
if required_perm not in user_permissions and not current_user.get("is_system_admin", False):
raise HTTPException(status_code=403, detail=f"Missing permission: {required_perm}")
2026-07-23 08:42:26 +02:00
try:
return await entity_history_service.undo_last_action(
db, tenant_id, user_id, entity_type, eid
)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e)) from None
@router.get("/trash")
async def list_trash(
entity_type: str | None = Query(None),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""List deleted entities from history (trash view)."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
is_system_admin = current_user.get("is_system_admin", False)
return await entity_history_service.list_trash(
db, tenant_id, entity_type=entity_type, limit=limit, offset=offset,
user_id=user_id, is_system_admin=is_system_admin,
)
@router.post("/bulk-restore", response_model=BulkRestoreResponse)
async def bulk_restore(
body: BulkRestoreRequest,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Restore multiple entities from history entries.
Partial-failure semantics: if some fail, others still succeed.
Returns per-item results with success/error details.
"""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
is_system_admin = current_user.get("is_system_admin", False)
if not body.history_ids:
raise HTTPException(status_code=400, detail="No history_ids provided")
try:
ids = [uuid.UUID(hid) for hid in body.history_ids]
except ValueError:
raise HTTPException(status_code=400, detail="Invalid history_id in list") from None
result = await entity_history_service.bulk_restore(
db, tenant_id, ids, user_id, is_system_admin
)
return BulkRestoreResponse(
total=result["total"],
succeeded=result["succeeded"],
failed=result["failed"],
results=[
{
"history_id": r["history_id"],
"success": r["success"],
"entity_type": r.get("entity_type"),
"entity_id": r.get("entity_id"),
"error": r.get("error"),
}
for r in result["results"]
],
partial_success=result["partial_success"],
)
@router.post("/retention/archive", dependencies=[Depends(require_permission("system:admin"))])
async def archive_old_history(
days: int = Query(90, ge=1, le=3650),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Archive (hard-delete) EntityHistory entries older than *days* days.
GDPR compliance: after retention period, history snapshots are purged.
Requires system:admin permission.
"""
tenant_id = uuid.UUID(current_user["tenant_id"])
count = await entity_history_service.archive_old_history(db, tenant_id, days=days)
await db.commit()
return {"archived": count, "retention_days": days}