Files
leocrm/app/core/history_hooks.py
Agent Zero abbe7a18fc fix(audit): P0-P3 audit fixes — 838 ruff errors → 0, 30 F821 bugs fixed, 118 files changed
- P0: hooks.py 3-tuple fix, trigger_dispatcher Contract, contacts/plugin unregister_actions_by_owner
- P0: 5 test files — check_permission mocks removed, hardcoded DB credential → env var
- P1: attachment_service DmsFile via Contract helper, restore_registry/history_hooks dedup
- P1: mail/plugin restore unregister, mcp_client datetime.now(UTC), saved_views/filters patterns
- P1: ProtectedRoute fail-closed, 13 test assertion fixes (bcrypt, DB-URLs, SECRET_KEYs)
- P2: deprecated notifications → post_system_message (3 files), forgejo Base, report_generator lazy import
- P2: webhooks permissions, deps.py/roles.py plugin perms removed, import_export default
- P2: address/tags/entity_links patterns removed, worker.py Contract-Umgehungen fixed
- P2: 28 frontend TODOs (hardcoded constants, deprecated notification API)
- P3: dead code, duplicates, deprecated imports, private attr, __import__ inline
- P3: 8 frontend TODOs (LucideIcons, inline styles, XSS, i18n)
- ruff: 838 → 0 (612 auto-fix + 246 manual + 27 F821 regression fix)
- F821: 30 → 0 (AutomationDefinition, DmsFile, user_id, Path, Any, String)
- Contract-Umgehungen: 2 neue gefunden (worker.py:169, worker.py:280) und gefixt
2026-08-16 01:17:18 +02:00

146 lines
5.1 KiB
Python

"""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()