Coverage for src/lexigram/admin/actions/audited.py: 96%
45 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 14:56 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 14:56 +0800
1"""AuditedAction — framework base class that wraps execute() with audit logging.
3Subclasses extend both AuditedAction and a concrete Action subclass
4(RowAction, BulkAction, HeaderAction). The abstract method is
5execute_audited(); the inherited execute() is sealed here.
7Writes are made through ctx.audit_writer. If audit_writer is None,
8execution proceeds and a warning is logged (best-effort at framework level).
9"""
11from __future__ import annotations
13from abc import abstractmethod
14from typing import Any, Generic, TypeVar
16from lexigram.admin.actions.base import Action
17from lexigram.admin.actions.exceptions import ActionError
18from lexigram.admin.actions.types import ActionContext
19from lexigram.contracts.admin.audit_entry import AuditEntry, AuditOutcome
20from lexigram.logging import get_logger
21from lexigram.result import Result
23logger = get_logger(__name__)
25_RT = TypeVar("_RT")
26_OT = TypeVar("_OT")
29class AuditedAction(Action[_RT, _OT], Generic[_RT, _OT]):
30 """Action base that wraps execute() with before/after audit capture.
32 Subclass together with a concrete Action specialisation:
34 class DeleteUser(AuditedAction[User, None], RowAction):
35 name = "delete_user"
36 resource_type = "users"
38 async def execute_audited(self, record, ctx):
39 ...
41 Attributes:
42 resource_type: The resource category recorded in the audit entry.
43 Must be set by each subclass.
44 """
46 resource_type: str = ""
48 @abstractmethod
49 async def execute_audited(
50 self, record_or_records: _RT, ctx: ActionContext
51 ) -> Result[_OT, ActionError]: ...
53 def capture_before(self, record: _RT) -> dict[str, Any] | None:
54 return None
56 def capture_after(self, record: _RT, outcome: _OT) -> dict[str, Any] | None:
57 return None
59 def resource_id_of(self, record: _RT) -> str:
60 if record is None:
61 return ""
62 if isinstance(record, dict):
63 return str(record.get("id", ""))
64 return str(getattr(record, "id", record))
66 async def execute(
67 self, record_or_records: _RT, ctx: ActionContext
68 ) -> Result[_OT, ActionError]:
69 """Sealed execute() — wraps execute_audited() with audit writes."""
70 before_snapshot = self.capture_before(record_or_records)
72 result = await self.execute_audited(record_or_records, ctx)
74 outcome_value: AuditOutcome
75 after_snapshot: dict[str, Any] = {}
77 if result.is_ok():
78 outcome_value = AuditOutcome.SUCCESS
79 raw_after = self.capture_after(record_or_records, result.unwrap())
80 if raw_after is not None:
81 after_snapshot = raw_after
82 else:
83 outcome_value = AuditOutcome.ERRORED
85 admin_user_id = ""
86 if ctx.user is not None:
87 admin_user_id = str(
88 getattr(ctx.user, "user_id", None)
89 or getattr(ctx.user, "id", None)
90 or ""
91 )
93 resource_id = ctx.record_id or self.resource_id_of(record_or_records)
95 entry = AuditEntry(
96 admin_user_id=admin_user_id,
97 action=self.name,
98 resource_type=self.resource_type or ctx.resource_name,
99 resource_id=resource_id or None,
100 outcome=outcome_value,
101 before=before_snapshot or {},
102 after=after_snapshot,
103 correlation_id=ctx.correlation_id,
104 request_id=ctx.request_id,
105 request_ip=ctx.request_ip,
106 metadata=dict(ctx.metadata),
107 )
109 writer = ctx.audit_writer
110 if writer is None:
111 logger.warning(
112 "admin.audited_action_no_writer",
113 action=self.name,
114 resource_type=self.resource_type,
115 )
116 else:
117 await writer.write(entry)
119 return result
122__all__ = ["AuditedAction"]