2026-08-13 23:08:29 +02:00
|
|
|
"""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,
|
2026-08-16 01:17:18 +02:00
|
|
|
owner_tag: str | None = None,
|
2026-08-13 23:08:29 +02:00
|
|
|
) -> None:
|
|
|
|
|
"""Register standard history-recording hooks for an entity type.
|
|
|
|
|
|
2026-08-16 01:17:18 +02:00
|
|
|
Args:
|
|
|
|
|
owner_tag: Plugin name that owns these hooks. Used for targeted
|
|
|
|
|
deregistration in on_deactivate() via unregister_actions_by_owner().
|
|
|
|
|
|
2026-08-13 23:08:29 +02:00
|
|
|
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,
|
|
|
|
|
)
|
|
|
|
|
|
2026-08-16 01:17:18 +02:00
|
|
|
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)
|
2026-08-13 23:08:29 +02:00
|
|
|
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:
|
2026-08-16 01:17:18 +02:00
|
|
|
"""Register history hooks for Core entity types only.
|
2026-08-13 23:08:29 +02:00
|
|
|
|
|
|
|
|
Called during app startup after the hook registry is initialized.
|
2026-08-16 01:17:18 +02:00
|
|
|
Plugin entities (task, calendar_entry, dms_file, mail) register
|
|
|
|
|
their own hooks in on_activate(). See P0-8 fix.
|
2026-08-13 23:08:29 +02:00
|
|
|
"""
|
2026-08-16 01:17:18 +02:00
|
|
|
# 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.
|
2026-08-13 23:08:29 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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()
|