feat(D): Phase D — Undo/Restore komplett implementiert
Check Cross-Plugin Imports / check (push) Has been cancelled
Check Cross-Plugin Imports / check (push) Has been cancelled
- D-GEN: RestoreRegistry mit RestoreConfig (model_class, restore_permission, excluded_fields, special_handler) - D-HOOK: history_hooks.py mit register_history_hooks() für after_create/update/delete - D-CORE: Company create+update record_history in companies.py - D-PLUG: Task/Calendar/DMS record_history in services/routes - D-SOFT: Alle registrierten Entitäten haben deleted_at + un-delete via Registry - D-MAIL: Mail special_handler (IMAP Trash-Move, Folder-Verify) + record_history in delete/move - D-TRASH: GET /entity-history/trash (filterbar, paginiert) + Frontend Trash.tsx - D-TOAST: UndoToast.tsx (5s Auto-Dismiss, useUndoToast Hook) - D-HIST-UI: HistoryPanel.tsx (Timeline, Diff-View, Restore-Button) - D-BULK: POST /entity-history/bulk-restore mit partial_success Semantik - D-RET: POST /entity-history/retention/archive (GDPR hard-delete >90 Tage) - D-TEST: 26 Tests in test_restore_registry.py, alle grün - D-DOC: test-strategy.md + security_kernel.md aktualisiert Backend: 10 Dateien, Frontend: 7 Dateien, Tests: 1 Datei, Docs: 3 Dateien 26/26 Tests passed, TSC 0 errors, App import 492 routes
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
"""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,
|
||||
) -> None:
|
||||
"""Register standard history-recording hooks for an entity type.
|
||||
|
||||
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)
|
||||
reg.register_action(after_update_hook, _on_update, priority=90)
|
||||
reg.register_action(after_delete_hook, _on_delete, priority=90)
|
||||
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 all built-in entity types.
|
||||
|
||||
Called during app startup after the hook registry is initialized.
|
||||
Plugin entities should register their own hooks in on_activate().
|
||||
"""
|
||||
reg = get_hook_registry()
|
||||
|
||||
# Contact (already has manual record_history calls in contact_service.py,
|
||||
# but registering hooks ensures consistency for any code path that fires
|
||||
# the hooks without calling record_history directly)
|
||||
register_history_hooks(
|
||||
reg, "contact",
|
||||
"contact.after_create",
|
||||
"contact.after_update",
|
||||
"contact.after_delete",
|
||||
)
|
||||
|
||||
# Task plugin
|
||||
register_history_hooks(
|
||||
reg, "task",
|
||||
"task.after_create",
|
||||
"task.after_update",
|
||||
"task.after_delete",
|
||||
)
|
||||
|
||||
# Calendar plugin — CalendarEntry
|
||||
register_history_hooks(
|
||||
reg, "calendar_entry",
|
||||
"calendar_entry.after_create",
|
||||
"calendar_entry.after_update",
|
||||
"calendar_entry.after_delete",
|
||||
)
|
||||
|
||||
# DMS plugin — File metadata
|
||||
register_history_hooks(
|
||||
reg, "dms_file",
|
||||
"dms_file.after_create",
|
||||
"dms_file.after_update",
|
||||
"dms_file.after_delete",
|
||||
)
|
||||
|
||||
# Mail plugin
|
||||
register_history_hooks(
|
||||
reg, "mail",
|
||||
"mail.after_create",
|
||||
"mail.after_update",
|
||||
"mail.after_delete",
|
||||
)
|
||||
|
||||
logger.info("Default history hooks registered for: contact, task, calendar_entry, dms_file, mail")
|
||||
|
||||
|
||||
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()
|
||||
Reference in New Issue
Block a user