Coverage for src / lexigram / admin / services / action_registry.py: 70%
119 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-13 22:14 +0800
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-13 22:14 +0800
1"""Action registry: types, protocols, and ActionRegistry for admin actions."""
3from __future__ import annotations
5from abc import ABC, abstractmethod
6from dataclasses import dataclass, field
7from enum import Enum
8from typing import TYPE_CHECKING, Any, Protocol, TypeVar, runtime_checkable
10from lexigram.result import Ok, Result
12if TYPE_CHECKING:
13 from collections.abc import Callable
15 from lexigram.admin.exceptions import (
16 AdminValidationError,
17 )
19T = TypeVar("T")
22class ActionType(str, Enum):
23 """Types of admin actions."""
25 SINGLE = "single" # Operates on a single resource
26 BULK = "bulk" # Operates on multiple resources
27 GLOBAL = "global" # Not tied to resources
30class ActionExecutionMode(str, Enum):
31 """How an action should be executed."""
33 SYNC = "sync" # Execute immediately
34 ASYNC = "async" # Execute as background task
35 CONFIRM = "confirm" # Require user confirmation first
38@dataclass
39class ActionConfig:
40 """Configuration for an admin action."""
42 name: str
43 label: str
44 description: str = ""
45 icon: str | None = None
47 # Action type
48 action_type: ActionType = ActionType.SINGLE
49 execution_mode: ActionExecutionMode = ActionExecutionMode.SYNC
51 # Authorization
52 permission: str | None = None
54 # Confirmation
55 confirm_message: str | None = None
56 confirm_style: str = "warning" # info, warning, danger
58 # UI
59 button_variant: str = "secondary"
60 show_in_list: bool = True
61 show_in_detail: bool = True
63 # Bulk specific
64 min_selection: int = 1
65 max_selection: int | None = None
67 # Form for action parameters
68 has_form: bool = False
69 form_schema: dict[str, Any] | None = None
72@dataclass
73class ActionContext:
74 """Context for action execution."""
76 user: Any
77 resource_name: str
78 action_name: str
80 # Target(s)
81 record_id: Any | None = None
82 record_ids: list[Any] = field(default_factory=list)
84 # Form data
85 parameters: dict[str, Any] = field(default_factory=dict)
87 # Additional context
88 request_id: str | None = None
89 metadata: dict[str, Any] = field(default_factory=dict)
91 @property
92 def is_bulk(self) -> bool:
93 """Check if this is a bulk action."""
94 return bool(
95 len(self.record_ids) > 1 or (not self.record_id and self.record_ids)
96 )
98 @property
99 def target_count(self) -> int:
100 """Get the number of target records."""
101 if self.record_ids:
102 return len(self.record_ids)
103 return 1 if self.record_id else 0
106@dataclass
107class ActionResult:
108 """Result of a successful action execution."""
110 message: str
111 data: Any = None
113 # For bulk actions
114 successful_count: int = 0
115 failed_count: int = 0
116 failures: list[tuple[Any, str]] = field(default_factory=list) # (id, error_msg)
118 # For async actions
119 task_id: str | None = None
121 # Redirect
122 redirect_url: str | None = None
124 # Refresh target
125 refresh_target: str | None = None
127 @classmethod
128 def bulk(
129 cls,
130 message: str,
131 successful_count: int,
132 failed_count: int,
133 failures: list[tuple[Any, str]] | None = None,
134 ) -> ActionResult:
135 """Create a bulk action result."""
136 return cls(
137 message=message,
138 successful_count=successful_count,
139 failed_count=failed_count,
140 failures=failures or [],
141 )
143 @classmethod
144 def async_started(
145 cls,
146 task_id: str,
147 message: str = "Action started",
148 ) -> ActionResult:
149 """Create an async action started result."""
150 return cls(
151 message=message,
152 task_id=task_id,
153 )
156@runtime_checkable
157class ActionHandler(Protocol):
158 """Protocol for action handlers."""
160 async def execute(self, context: ActionContext) -> ActionResult: ...
163@runtime_checkable
164class ActionValidator(Protocol):
165 """Protocol for action validators."""
167 async def validate(
168 self,
169 context: ActionContext,
170 config: ActionConfig,
171 ) -> Result[None, AdminValidationError]: ...
174class AbstractActionHandler(ABC):
175 """Base class for action handlers."""
177 @abstractmethod
178 async def execute(self, context: ActionContext) -> ActionResult:
179 """Execute the action."""
180 ...
182 async def validate(
183 self,
184 context: ActionContext,
185 config: ActionConfig,
186 ) -> Result[None, AdminValidationError]:
187 """Validate action parameters. Override for custom validation."""
188 return Ok(None)
190 async def on_success(self, context: ActionContext, result: ActionResult) -> None:
191 """Called after successful execution. Override for post-processing."""
193 async def on_failure(self, context: ActionContext, error: Exception) -> None:
194 """Called after failed execution. Override for error handling."""
197class FunctionActionHandler(AbstractActionHandler):
198 """Action handler that wraps a function."""
200 def __init__(
201 self,
202 func: Callable[[ActionContext], Any],
203 is_async: bool = True,
204 ):
205 self.func = func
206 self.is_async = is_async
208 async def execute(self, context: ActionContext) -> ActionResult:
209 """Execute the wrapped function."""
210 if self.is_async:
211 result = await self.func(context)
212 else:
213 result = self.func(context)
215 if isinstance(result, ActionResult):
216 return result
218 return ActionResult(message="Action completed", data=result)
221class ActionRegistry:
222 """Registry for admin actions."""
224 def __init__(self) -> None:
225 self._actions: dict[str, dict[str, tuple[ActionConfig, ActionHandler]]] = {}
226 # resource_name -> action_name -> (config, handler)
228 self._global_actions: dict[str, tuple[ActionConfig, ActionHandler]] = {}
230 def register(
231 self,
232 resource_name: str,
233 config: ActionConfig,
234 handler: ActionHandler | Callable[[ActionContext], Any],
235 ) -> None:
236 """Register an action for a resource.
238 Args:
239 resource_name: Resource this action applies to
240 config: Action configuration
241 handler: Action handler or callable
242 """
243 if resource_name not in self._actions:
244 self._actions[resource_name] = {}
246 if callable(handler) and not isinstance(handler, ActionHandler):
247 handler = FunctionActionHandler(handler)
249 self._actions[resource_name][config.name] = (config, handler)
251 def register_global(
252 self,
253 config: ActionConfig,
254 handler: ActionHandler | Callable[[ActionContext], Any],
255 ) -> None:
256 """Register a global action (not tied to a resource).
258 Args:
259 config: Action configuration
260 handler: Action handler or callable
261 """
262 if callable(handler) and not isinstance(handler, ActionHandler):
263 handler = FunctionActionHandler(handler)
265 self._global_actions[config.name] = (config, handler)
267 def get(
268 self,
269 resource_name: str,
270 action_name: str,
271 ) -> tuple[ActionConfig, ActionHandler] | None:
272 """Get an action by resource and name."""
273 if resource_name in self._actions:
274 return self._actions[resource_name].get(action_name)
275 return None
277 def get_global(self, action_name: str) -> tuple[ActionConfig, ActionHandler] | None:
278 """Get a global action by name."""
279 return self._global_actions.get(action_name)
281 def get_actions_for_resource(
282 self,
283 resource_name: str,
284 action_type: ActionType | None = None,
285 ) -> list[ActionConfig]:
286 """Get all actions for a resource.
288 Args:
289 resource_name: Resource name
290 action_type: Optional filter by action type
292 Returns:
293 List of action configurations
294 """
295 if resource_name not in self._actions:
296 return []
298 configs = [x[0] for x in self._actions[resource_name].values()]
300 if action_type:
301 configs = list(filter(lambda c: c.action_type == action_type, configs))
303 return configs
305 def get_all_global_actions(self) -> list[ActionConfig]:
306 """Get all global actions."""
307 return [x[0] for x in self._global_actions.values()]
310__all__ = [
311 "AbstractActionHandler",
312 "ActionConfig",
313 "ActionContext",
314 "ActionExecutionMode",
315 "ActionHandler",
316 "ActionRegistry",
317 "ActionResult",
318 "ActionType",
319 "ActionValidator",
320 "FunctionActionHandler",
321]