"""Hook-based history recording — standard hooks that call record_history(). Registers action hooks for entity lifecycle events: - entity.after_create → record_history(action='create', snapshot_after=...) - entity.after_update → record_history(action='update', snapshot_before=..., snapshot_after=..., changes=...) - entity.after_delete → record_history(action='delete', snapshot_before=...) Plugins can register their own entity types by calling: register_history_hooks(reg, 'task', 'task.after_create', 'task.after_update', 'task.after_delete') This is explicit, traceable, and testable — no SQLAlchemy event listeners. """ from __future__ import annotations import logging import uuid from typing import Any from sqlalchemy.ext.asyncio import AsyncSession from app.core.hooks import HookRegistry, get_hook_registry from app.services.entity_history_service import record_history logger = logging.getLogger(__name__) def register_history_hooks( reg: HookRegistry, entity_type: str, after_create_hook: str, after_update_hook: str, after_delete_hook: str, owner_tag: str | None = None, ) -> None: """Register standard history-recording hooks for an entity type. Args: owner_tag: Plugin name that owns these hooks. Used for targeted deregistration in on_deactivate() via unregister_actions_by_owner(). Each hook receives kwargs: db, tenant_id, user_id, and either: - after_create: snapshot_after (the created entity dict) - after_update: snapshot_before, snapshot_after, changes - after_delete: snapshot_before """ async def _on_create( snapshot_after: dict[str, Any], *, db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID | None = None, **kwargs: Any, ) -> None: entity_id = _extract_entity_id(snapshot_after) if entity_id is None: logger.warning("history_hooks: cannot extract entity_id from snapshot for %s", entity_type) return await record_history( db, tenant_id, user_id, entity_type, entity_id, action="create", snapshot_after=snapshot_after, ) async def _on_update( snapshot_after: dict[str, Any], *, db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID | None = None, snapshot_before: dict[str, Any] | None = None, changes: dict[str, Any] | None = None, **kwargs: Any, ) -> None: entity_id = _extract_entity_id(snapshot_after) or ( _extract_entity_id(snapshot_before) if snapshot_before else None ) if entity_id is None: logger.warning("history_hooks: cannot extract entity_id for %s update", entity_type) return await record_history( db, tenant_id, user_id, entity_type, entity_id, action="update", snapshot_before=snapshot_before, snapshot_after=snapshot_after, changes=changes, ) async def _on_delete( snapshot_before: dict[str, Any] | None = None, *, db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID | None = None, entity_id: uuid.UUID | None = None, **kwargs: Any, ) -> None: eid = entity_id or (_extract_entity_id(snapshot_before) if snapshot_before else None) if eid is None: logger.warning("history_hooks: cannot extract entity_id for %s delete", entity_type) return await record_history( db, tenant_id, user_id, entity_type, eid, action="delete", snapshot_before=snapshot_before, ) reg.register_action(after_create_hook, _on_create, priority=90, owner_tag=owner_tag) reg.register_action(after_update_hook, _on_update, priority=90, owner_tag=owner_tag) reg.register_action(after_delete_hook, _on_delete, priority=90, owner_tag=owner_tag) logger.debug("History hooks registered for: %s", entity_type) def _extract_entity_id(snapshot: dict[str, Any] | None) -> uuid.UUID | None: """Extract entity UUID from a snapshot dict.""" if snapshot is None: return None raw_id = snapshot.get("id") if raw_id is None: return None if isinstance(raw_id, uuid.UUID): return raw_id try: return uuid.UUID(str(raw_id)) except (ValueError, TypeError): return None def register_default_history_hooks() -> None: """Register history hooks for Core entity types only. Called during app startup after the hook registry is initialized. Plugin entities (task, calendar_entry, dms_file, mail) register their own hooks in on_activate(). See P0-8 fix. """ # Contact hooks are registered by ContactsPlugin.on_activate() with # owner_tag="contacts" — do not register them here to avoid double # registration. This function remains for future Core entities that # have no plugin. def reset_history_hooks_for_testing() -> None: """Clear all history hooks — for unit tests only.""" reg = get_hook_registry() # The hook registry's _reset_for_testing clears everything reg._reset_for_testing()