"""Entity history service — record, query, restore, and undo entity snapshots.""" from __future__ import annotations import uuid from datetime import UTC, datetime from typing import Any from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.visibility import apply_visibility_filter, check_single_entity_access from app.models.entity_history import EntityHistory async def record_history( db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID | None, entity_type: str, entity_id: uuid.UUID, action: str, snapshot_before: dict[str, Any] | None = None, snapshot_after: dict[str, Any] | None = None, changes: dict[str, Any] | None = None, ) -> EntityHistory: """Create a history entry for a CRUD action.""" entry = EntityHistory( tenant_id=tenant_id, user_id=user_id, entity_type=entity_type, entity_id=entity_id, action=action, snapshot_before=snapshot_before, snapshot_after=snapshot_after, changes=changes, owner_id=user_id, ) db.add(entry) await db.flush() return entry async def get_entity_history( db: AsyncSession, tenant_id: uuid.UUID, entity_type: str, entity_id: uuid.UUID, limit: int = 50, user_id: uuid.UUID | None = None, is_system_admin: bool = False, ) -> list[EntityHistory]: """Get all history entries for an entity, newest first.""" q = ( select(EntityHistory) .where( EntityHistory.tenant_id == tenant_id, EntityHistory.entity_type == entity_type, EntityHistory.entity_id == entity_id, ) .order_by(EntityHistory.created_at.desc()) .limit(limit) ) if user_id and not is_system_admin: q = await apply_visibility_filter( db, q, "entity_history", EntityHistory, user_id, tenant_id, is_system_admin ) result = await db.execute(q) return list(result.scalars().all()) async def get_history_entry( db: AsyncSession, tenant_id: uuid.UUID, history_id: uuid.UUID, user_id: uuid.UUID | None = None, is_system_admin: bool = False, ) -> EntityHistory | None: """Get a specific history entry by ID.""" q = select(EntityHistory).where( EntityHistory.id == history_id, EntityHistory.tenant_id == tenant_id, ) result = await db.execute(q) entry = result.scalar_one_or_none() if entry is None: return None if user_id and not is_system_admin: has_access = await check_single_entity_access( db, "entity_history", entry.id, user_id, tenant_id, "read", is_system_admin ) if not has_access: raise PermissionError("No access") return entry async def restore_from_history( db: AsyncSession, tenant_id: uuid.UUID, history_id: uuid.UUID, user_id: uuid.UUID, is_system_admin: bool = False, ) -> dict[str, Any]: """Restore an entity to a previous snapshot state. For 'delete' actions: un-delete the entity (clear deleted_at). For 'update' actions: revert entity fields to snapshot_before. For 'create' actions: soft-delete the entity (undo creation). Uses the RestoreRegistry to find the entity configuration. Only explicitly registered entity types can be restored. Returns the restored data dict. """ from app.core.restore_registry import get_restore_registry entry = await get_history_entry(db, tenant_id, history_id, user_id, is_system_admin) if entry is None: raise ValueError("History entry not found") entity_type = entry.entity_type entity_id = entry.entity_id action = entry.action # Look up restore configuration from registry config = get_restore_registry().get(entity_type) if config is None: raise ValueError(f"Unsupported entity type for restore: {entity_type}") # Load entity from database model_class = config.model_class q = select(model_class).where( model_class.id == entity_id, model_class.tenant_id == tenant_id, ) result = await db.execute(q) entity = result.scalar_one_or_none() # Use special handler if registered (e.g. Mail with IMAP semantics) if config.special_handler is not None: context = { "user_id": user_id, "tenant_id": tenant_id, "is_system_admin": is_system_admin, "history_entry": entry, } snapshot = entry.snapshot_before if entry.snapshot_before else {} return await config.special_handler(db, entity, action, snapshot, context) # Generic restore logic if action == "delete": if entity is None: raise ValueError("Entity not found for restore") entity.deleted_at = None if hasattr(entity, "updated_by"): entity.updated_by = user_id await db.flush() return _serialize_entity(entity, entity_type) elif action == "update": if entity is None: raise ValueError("Entity not found for restore") if entry.snapshot_before is None: raise ValueError("No snapshot_before available for restore") excluded = config.all_excluded_fields for key, value in entry.snapshot_before.items(): if hasattr(entity, key) and key not in excluded: setattr(entity, key, value) if hasattr(entity, "updated_by"): entity.updated_by = user_id await db.flush() return _serialize_entity(entity, entity_type) elif action == "create": if entity is None: raise ValueError("Entity not found for restore") entity.deleted_at = datetime.now(UTC) if hasattr(entity, "updated_by"): entity.updated_by = user_id await db.flush() return _serialize_entity(entity, entity_type) raise ValueError(f"Unsupported action for restore: {action}") def _serialize_entity(entity: Any, entity_type: str) -> dict[str, Any]: """Serialize an entity to a dict for restore response. For contacts, uses the detailed serializer with contact_persons. For other entities, uses a generic column-based serializer. """ if entity_type == "contact": from app.services.contact_service import _serialize_contact_detail # This is called after flush, entity is still in session # but we need to reload with the relationship return _serialize_contact_detail(entity) # Generic serialization: extract column values result: dict[str, Any] = {} for column in entity.__table__.columns: val = getattr(entity, column.name, None) if val is not None: if hasattr(val, "isoformat"): result[column.name] = val.isoformat() elif hasattr(val, "hex"): result[column.name] = str(val) else: result[column.name] = val result["_entity_type"] = entity_type result["_restored"] = True return result async def undo_last_action( db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, entity_type: str, entity_id: uuid.UUID, is_system_admin: bool = False, ) -> dict[str, Any]: """Undo the most recent action for an entity. Returns the restored entity data. Raises ValueError if no history exists. """ q = ( select(EntityHistory) .where( EntityHistory.tenant_id == tenant_id, EntityHistory.entity_type == entity_type, EntityHistory.entity_id == entity_id, ) .order_by(EntityHistory.created_at.desc()) .limit(1) ) if not is_system_admin: q = await apply_visibility_filter( db, q, "entity_history", EntityHistory, user_id, tenant_id, is_system_admin ) result = await db.execute(q) entry = result.scalar_one_or_none() if entry is None: raise ValueError("No history found for this entity") return await restore_from_history(db, tenant_id, entry.id, user_id, is_system_admin) async def list_trash( db: AsyncSession, tenant_id: uuid.UUID, entity_type: str | None = None, limit: int = 50, offset: int = 0, user_id: uuid.UUID | None = None, is_system_admin: bool = False, ) -> dict[str, Any]: """List deleted entities from history (delete actions only). Returns paginated trash items with entity_type filter. """ q = ( select(EntityHistory) .where( EntityHistory.tenant_id == tenant_id, EntityHistory.action == "delete", ) .order_by(EntityHistory.created_at.desc()) ) if entity_type: q = q.where(EntityHistory.entity_type == entity_type) if user_id and not is_system_admin: q = await apply_visibility_filter( db, q, "entity_history", EntityHistory, user_id, tenant_id, is_system_admin ) # Count total from sqlalchemy import func as sa_func count_q = select(sa_func.count()).select_from(q.subquery()) total = (await db.execute(count_q)).scalar() or 0 # Paginate q = q.offset(offset).limit(limit) result = await db.execute(q) entries = result.scalars().all() items = [] for e in entries: items.append({ "history_id": str(e.id), "entity_type": e.entity_type, "entity_id": str(e.entity_id), "snapshot_before": e.snapshot_before, "deleted_at": e.created_at.isoformat() if e.created_at else None, "user_id": str(e.user_id) if e.user_id else None, }) return {"items": items, "total": total, "limit": limit, "offset": offset} async def bulk_restore( db: AsyncSession, tenant_id: uuid.UUID, history_ids: list[uuid.UUID], user_id: uuid.UUID, is_system_admin: bool = False, ) -> dict[str, Any]: """Restore multiple entities from history entries. Returns partial_success semantics: if some fail, others still succeed. """ results = [] succeeded = 0 failed = 0 for hid in history_ids: try: restored = await restore_from_history(db, tenant_id, hid, user_id, is_system_admin) succeeded += 1 results.append({ "history_id": str(hid), "success": True, "entity_type": restored.get("_entity_type") or restored.get("entity_type"), "entity_id": restored.get("id"), "error": None, }) except Exception as exc: failed += 1 results.append({ "history_id": str(hid), "success": False, "entity_type": None, "entity_id": None, "error": str(exc), }) return { "total": len(history_ids), "succeeded": succeeded, "failed": failed, "results": results, "partial_success": failed > 0 and succeeded > 0, } async def archive_old_history( db: AsyncSession, tenant_id: uuid.UUID, days: int = 90, ) -> int: """Archive (hard-delete) EntityHistory entries older than *days* days. GDPR compliance: after retention period, history snapshots are purged. Returns the number of archived entries. """ from datetime import timedelta from sqlalchemy import delete as sa_delete cutoff = datetime.now(UTC) - timedelta(days=days) stmt = sa_delete(EntityHistory).where( EntityHistory.tenant_id == tenant_id, EntityHistory.created_at < cutoff, ) result = await db.execute(stmt) await db.flush() return result.rowcount or 0