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, status
|
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
|
|
|
|
from app.core.db import get_db
|
2026-08-13 23:08:29 +02:00
|
|
|
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
|
2026-08-13 23:08:29 +02:00
|
|
|
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),
|
2026-08-13 23:08:29 +02:00
|
|
|
current_user: dict = Depends(get_current_user),
|
2026-07-23 08:42:26 +02:00
|
|
|
):
|
2026-08-13 23:08:29 +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
|
2026-08-13 23:08:29 +02:00
|
|
|
|
|
|
|
|
# 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),
|
2026-08-13 23:08:29 +02:00
|
|
|
current_user: dict = Depends(get_current_user),
|
2026-07-23 08:42:26 +02:00
|
|
|
):
|
2026-08-13 23:08:29 +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
|
2026-08-13 23:08:29 +02:00
|
|
|
|
|
|
|
|
# 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
|
2026-08-13 23:08:29 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@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}
|