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:
+21
-4
@@ -117,9 +117,12 @@ async def create_company(
|
||||
)
|
||||
db.add(audit_entry)
|
||||
await db.flush()
|
||||
# Record history (D-CORE)
|
||||
snapshot = _serialize_company(company)
|
||||
await record_history(db, tenant_id, user_id, "contact", company.id, "create", snapshot_after=snapshot)
|
||||
from app.core.hooks import do_action
|
||||
await do_action("company.after_create", _serialize_company(company), db=db, tenant_id=tenant_id, user_id=user_id)
|
||||
return _serialize_company(company)
|
||||
await do_action("company.after_create", snapshot, db=db, tenant_id=tenant_id, user_id=user_id)
|
||||
return snapshot
|
||||
|
||||
|
||||
@router.get("/export")
|
||||
@@ -208,6 +211,8 @@ async def update_company(
|
||||
raise HTTPException(status_code=404, detail="Company not found")
|
||||
from app.core.hooks import do_action
|
||||
await do_action("company.before_update", body, db=db, tenant_id=tenant_id, user_id=user_id, company_id=company_id)
|
||||
# Capture snapshot before update (D-CORE)
|
||||
snapshot_before = _serialize_company(company)
|
||||
if "name" in body:
|
||||
company.name = body["name"]
|
||||
company.displayname = body["name"]
|
||||
@@ -222,13 +227,25 @@ async def update_company(
|
||||
company.custom = custom
|
||||
company.updated_by = user_id
|
||||
await db.flush()
|
||||
snapshot_after = _serialize_company(company)
|
||||
# Compute changes diff (D-CORE)
|
||||
changes: dict = {}
|
||||
for key, new_val in snapshot_after.items():
|
||||
old_val = snapshot_before.get(key)
|
||||
if old_val != new_val:
|
||||
changes[key] = {"old": old_val, "new": new_val}
|
||||
# Record history (D-CORE)
|
||||
await record_history(
|
||||
db, tenant_id, user_id, "contact", company.id, "update",
|
||||
snapshot_before=snapshot_before, snapshot_after=snapshot_after, changes=changes or None,
|
||||
)
|
||||
audit_entry = AuditLog(tenant_id=tenant_id, user_id=user_id, action="update",
|
||||
entity_type="contact", entity_id=company.id, changes=body)
|
||||
db.add(audit_entry)
|
||||
await db.flush()
|
||||
from app.core.hooks import do_action
|
||||
await do_action("company.after_update", _serialize_company(company), db=db, tenant_id=tenant_id, user_id=user_id, company_id=company_id)
|
||||
return _serialize_company(company)
|
||||
await do_action("company.after_update", snapshot_after, db=db, tenant_id=tenant_id, user_id=user_id, company_id=company_id)
|
||||
return snapshot_after
|
||||
|
||||
|
||||
@router.delete("/{company_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@@ -8,8 +8,15 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.core.restore_registry import get_restore_registry
|
||||
from app.deps import get_current_user, require_permission
|
||||
from app.schemas.entity_history import EntityHistoryListResponse, EntityHistoryResponse, RestoreRequest
|
||||
from app.schemas.entity_history import (
|
||||
BulkRestoreRequest,
|
||||
BulkRestoreResponse,
|
||||
EntityHistoryListResponse,
|
||||
EntityHistoryResponse,
|
||||
RestoreRequest,
|
||||
)
|
||||
from app.services import entity_history_service
|
||||
|
||||
router = APIRouter(prefix="/api/v1/entity-history", tags=["entity-history"])
|
||||
@@ -57,15 +64,35 @@ async def get_entity_history(
|
||||
async def restore_from_history(
|
||||
body: RestoreRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_permission("contacts:write")),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Restore an entity from a history entry."""
|
||||
"""Restore an entity from a history entry.
|
||||
|
||||
Permission is checked dynamically based on the entity type's
|
||||
RestoreConfig.restore_permission from the RestoreRegistry.
|
||||
"""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
try:
|
||||
hid = uuid.UUID(body.history_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid history_id") from None
|
||||
|
||||
# Look up the history entry to determine entity_type
|
||||
entry = await entity_history_service.get_history_entry(db, tenant_id, hid, user_id)
|
||||
if entry is None:
|
||||
raise HTTPException(status_code=404, detail="History entry not found")
|
||||
|
||||
# Check permission dynamically based on entity type
|
||||
config = get_restore_registry().get(entry.entity_type)
|
||||
if config is None:
|
||||
raise HTTPException(status_code=400, detail=f"Restore not supported for entity type: {entry.entity_type}")
|
||||
|
||||
required_perm = config.restore_permission
|
||||
user_permissions = current_user.get("permissions", set())
|
||||
if required_perm not in user_permissions and not current_user.get("is_system_admin", False):
|
||||
raise HTTPException(status_code=403, detail=f"Missing permission: {required_perm}")
|
||||
|
||||
try:
|
||||
return await entity_history_service.restore_from_history(db, tenant_id, hid, user_id)
|
||||
except ValueError as e:
|
||||
@@ -77,18 +104,112 @@ async def undo_last_action(
|
||||
entity_type: str,
|
||||
entity_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_permission("contacts:write")),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Undo the most recent action for an entity."""
|
||||
"""Undo the most recent action for an entity.
|
||||
|
||||
Permission is checked dynamically based on the entity type's
|
||||
RestoreConfig.restore_permission from the RestoreRegistry.
|
||||
"""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
try:
|
||||
eid = uuid.UUID(entity_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid entity_id") from None
|
||||
|
||||
# Check permission dynamically based on entity type
|
||||
config = get_restore_registry().get(entity_type)
|
||||
if config is None:
|
||||
raise HTTPException(status_code=400, detail=f"Undo not supported for entity type: {entity_type}")
|
||||
|
||||
required_perm = config.restore_permission
|
||||
user_permissions = current_user.get("permissions", set())
|
||||
if required_perm not in user_permissions and not current_user.get("is_system_admin", False):
|
||||
raise HTTPException(status_code=403, detail=f"Missing permission: {required_perm}")
|
||||
|
||||
try:
|
||||
return await entity_history_service.undo_last_action(
|
||||
db, tenant_id, user_id, entity_type, eid
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e)) from None
|
||||
|
||||
|
||||
@router.get("/trash")
|
||||
async def list_trash(
|
||||
entity_type: str | None = Query(None),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""List deleted entities from history (trash view)."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
is_system_admin = current_user.get("is_system_admin", False)
|
||||
return await entity_history_service.list_trash(
|
||||
db, tenant_id, entity_type=entity_type, limit=limit, offset=offset,
|
||||
user_id=user_id, is_system_admin=is_system_admin,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/bulk-restore", response_model=BulkRestoreResponse)
|
||||
async def bulk_restore(
|
||||
body: BulkRestoreRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Restore multiple entities from history entries.
|
||||
|
||||
Partial-failure semantics: if some fail, others still succeed.
|
||||
Returns per-item results with success/error details.
|
||||
"""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
is_system_admin = current_user.get("is_system_admin", False)
|
||||
|
||||
if not body.history_ids:
|
||||
raise HTTPException(status_code=400, detail="No history_ids provided")
|
||||
|
||||
try:
|
||||
ids = [uuid.UUID(hid) for hid in body.history_ids]
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid history_id in list") from None
|
||||
|
||||
result = await entity_history_service.bulk_restore(
|
||||
db, tenant_id, ids, user_id, is_system_admin
|
||||
)
|
||||
return BulkRestoreResponse(
|
||||
total=result["total"],
|
||||
succeeded=result["succeeded"],
|
||||
failed=result["failed"],
|
||||
results=[
|
||||
{
|
||||
"history_id": r["history_id"],
|
||||
"success": r["success"],
|
||||
"entity_type": r.get("entity_type"),
|
||||
"entity_id": r.get("entity_id"),
|
||||
"error": r.get("error"),
|
||||
}
|
||||
for r in result["results"]
|
||||
],
|
||||
partial_success=result["partial_success"],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/retention/archive", dependencies=[Depends(require_permission("system:admin"))])
|
||||
async def archive_old_history(
|
||||
days: int = Query(90, ge=1, le=3650),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Archive (hard-delete) EntityHistory entries older than *days* days.
|
||||
|
||||
GDPR compliance: after retention period, history snapshots are purged.
|
||||
Requires system:admin permission.
|
||||
"""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
count = await entity_history_service.archive_old_history(db, tenant_id, days=days)
|
||||
await db.commit()
|
||||
return {"archived": count, "retention_days": days}
|
||||
|
||||
Reference in New Issue
Block a user