Coverage for src/lexigram/admin/resources/base.py: 24%

226 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-24 23:31 +0800

1"""Base resource class for Admin Resources. 

2 

3.. stability:: stable 

4 

5Resources define the configuration for admin UI views including 

6columns, actions, filters, and permissions. 

7""" 

8 

9from __future__ import annotations 

10 

11import contextlib 

12import re 

13from typing import TYPE_CHECKING, Any 

14import warnings 

15 

16from lexigram.admin.data.data_source import IDataSource 

17from lexigram.admin.resources.archive_ops import ArchiveOperationsMixin 

18from lexigram.admin.resources.layouts import apply_layout_config 

19from lexigram.admin.resources.config import TableConfiguration 

20 

21if TYPE_CHECKING: 

22 from lexigram.admin.actions.base import HeaderAction 

23 from lexigram.admin.forms.components import FormBase 

24 from lexigram.admin.layout.layout_manager import LayoutManager 

25 from lexigram.admin.rbac.schema import ResourcePermissions 

26 from lexigram.admin.relations.manager_ext import RelationManager 

27 from lexigram.admin.ui.filters.base import Filter 

28 from lexigram.domain import DomainModel 

29 from lexigram.ui.actions import Action, BulkAction 

30 from lexigram.ui.columns import Column 

31 

32_VALID_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)?$") 

33 

34 

35def _validate_resource_name(name: str) -> None: 

36 """Validate a Resource name is a dotted slug. 

37 

38 Raises ``ValueError`` if the name doesn't match 

39 the slug pattern (lowercase alphanumeric with underscores, 

40 optionally dotted for namespaced resources). 

41 """ 

42 if not _VALID_NAME_RE.match(name): 

43 raise ValueError( 

44 f"Resource name {name!r} is not a valid slug. " 

45 f"Allowed: lowercase alphanumeric + underscores, " 

46 f"optionally dotted for namespaced resources. " 

47 f"Pattern: {_VALID_NAME_RE.pattern!r}" 

48 ) 

49 

50 

51class Resource(ArchiveOperationsMixin): 

52 """Base class for Admin Resources. 

53 

54 Resources define the configuration for list views (tables) and form views 

55 in the admin interface. Subclass this to create custom resources. 

56 

57 Example: 

58 >>> class UserResource(Resource): 

59 ... model = UserModel 

60 ... icon = "users" 

61 ... columns = [ 

62 ... TextColumn("name").sortable(), 

63 ... TextColumn("email").sortable(), 

64 ... ] 

65 ... actions = [EditAction(), DeleteAction()] 

66 """ 

67 

68 # Data Model 

69 model: type[DomainModel] | None = None 

70 

71 # Registration metadata 

72 name: str | None = None 

73 cluster: str | None = None 

74 """Cluster name for navigation grouping. Replaces ``group``.""" 

75 

76 # Backward-compat alias for cluster 

77 group: str | None = None 

78 """Deprecated: use ``cluster`` instead. Kept in sync via __init_subclass__.""" 

79 

80 # Permissions 

81 permissions: ResourcePermissions | None = None 

82 

83 # UI Configuration 

84 icon: str = "box" 

85 label: str | None = None 

86 visible_in_sidebar: bool = True 

87 

88 # Table Configuration 

89 columns: list[Column] = [] 

90 actions: list[Action] = [] 

91 action_layout: str = "horizontal" 

92 header_actions: list[HeaderAction] = [] 

93 bulk_actions: list[BulkAction] = [] 

94 filters: list[Filter] = [] 

95 

96 # New declarative field system — SchemaField instances 

97 # When set, columns and filters are derived automatically. 

98 fields: list[Any] = [] 

99 

100 page_size: int = 20 

101 default_sort: str | None = None 

102 

103 # Optional default grouping column (users can override via the toolbar) 

104 group_by: str | None = None 

105 

106 # Table empty-state copy overrides (None = framework defaults) 

107 empty_state_title: str | None = None 

108 empty_state_message: str | None = None 

109 empty_state_icon: str | None = None 

110 

111 # Form Configuration 

112 form_class: type[FormBase] | None = None 

113 # Form display mode: "page" (full page), "modal" (centered modal), "slider" (side panel) 

114 form_display_mode: str = "modal" # Options: "page", "modal", "slider" 

115 # Model fields excluded from generated forms (e.g. secrets, framework-managed 

116 # columns). Resources may extend the default to exclude their own fields. 

117 form_exclude_fields: tuple[str, ...] = ("id", "created_at", "updated_at") 

118 

119 # Resource Config (Optional fluent config) 

120 config: Any = None 

121 

122 def __init_subclass__(cls, **kwargs: Any) -> None: 

123 """Validate and auto-derive backward-compat attributes when using ``fields``.""" 

124 super().__init_subclass__(**kwargs) 

125 

126 own = cls.__dict__ 

127 

128 # Sync group <-> cluster for backward compatibility 

129 if "group" in own and "cluster" not in own: 

130 cls.cluster = own["group"] 

131 if "cluster" in own and "group" not in own: 

132 cls.group = own["cluster"] 

133 

134 # Validate name if explicitly set — must be a dotted slug 

135 if "name" in own and own["name"] is not None: 

136 _validate_resource_name(own["name"]) 

137 

138 has_fields = "fields" in own 

139 has_columns = "columns" in own 

140 has_filters = "filters" in own 

141 has_form_class = "form_class" in own 

142 

143 if has_fields: 

144 if has_columns or has_filters or has_form_class: 

145 warnings.warn( 

146 "Resource.fields is the new declarative path for schema " 

147 "configuration. When fields is set, columns, filters, and " 

148 "form_class should not be set — they will be derived from " 

149 "fields automatically.", 

150 DeprecationWarning, 

151 stacklevel=2, 

152 ) 

153 

154 # Derive columns from fields for backward compatibility 

155 if not has_columns: 

156 cls.columns = list(cls.fields) 

157 

158 # Derive filters from fields for backward compatibility 

159 if not has_filters: 

160 cls.filters = [f for f in cls.fields if getattr(f, "filterable", False)] 

161 

162 # Relation managers for inline related-record editing on the ViewPage 

163 relations: list[type[RelationManager]] = [] 

164 

165 # Search Configuration 

166 # Fields to include in global search queries. Empty list disables search for this resource. 

167 search_fields: list[str] = [] 

168 # Field used as the display title in search results (falls back to "id") 

169 search_title_field: str = "name" 

170 

171 # Optional integration knobs 

172 cacheable: bool | Any = False # True or CacheableSpec enables list caching 

173 searchable: bool | Any = False # True or SearchableSpec enables search index 

174 resilient: bool | Any = False # True or ResilientSpec enables retry/circuit 

175 

176 def cache_spec(self) -> Any: 

177 """Return a CacheableSpec or None based on the cacheable field.""" 

178 if self.cacheable is False: 

179 return None 

180 if self.cacheable is True: 

181 from lexigram.admin.integrations.cache import CacheableSpec 

182 

183 return CacheableSpec() 

184 return self.cacheable 

185 

186 def search_spec(self) -> Any: 

187 """Return a SearchableSpec or None based on the searchable field.""" 

188 if self.searchable is False: 

189 return None 

190 if self.searchable is True: 

191 from lexigram.contracts.search import SearchableSpec 

192 

193 return SearchableSpec( 

194 index_name=self.name, 

195 fields=tuple(self.search_fields), 

196 ) 

197 return self.searchable 

198 

199 def resilient_spec(self) -> Any: 

200 """Return a ResilientSpec or None based on the resilient field.""" 

201 if self.resilient is False: 

202 return None 

203 if self.resilient is True: 

204 from lexigram.admin.integrations.resilience import ResilientSpec 

205 

206 return ResilientSpec() 

207 return self.resilient 

208 

209 # Data source instance for search (set at runtime via set_data_source) 

210 _data_source: IDataSource | None = None 

211 

212 def set_data_source(self, data_source: IDataSource) -> None: 

213 """Attach a data source to this resource for search and list support. 

214 

215 Args: 

216 data_source: An IDataSource-compatible instance. 

217 

218 Raises: 

219 TypeError: If data_source does not satisfy IDataSource protocol. 

220 """ 

221 if not isinstance(data_source, IDataSource): 

222 raise TypeError( 

223 f"data_source must implement IDataSource, got {type(data_source).__name__}" 

224 ) 

225 self._data_source = data_source 

226 

227 async def search(self, query: str, *, limit: int = 5) -> list[dict[str, Any]]: 

228 """Search this resource for items matching *query*. 

229 

230 Override in subclasses for custom search logic. The default 

231 implementation queries the attached data source using 

232 :attr:`search_fields`. 

233 

234 Args: 

235 query: Search term entered by the user. 

236 limit: Maximum number of results to return. 

237 

238 Returns: 

239 List of dicts with ``id``, ``title``, and ``subtitle`` keys. 

240 """ 

241 if not self.search_fields or self._data_source is None: 

242 return [] 

243 

244 from lexigram.admin.data.query import QuerySpec 

245 

246 qs = ( 

247 QuerySpec() 

248 .with_search(query, self.search_fields) 

249 .with_page(1) 

250 .with_per_page(limit) 

251 ) 

252 try: 

253 result = await self._data_source.find_many(qs) 

254 except (AttributeError, TypeError, ValueError, KeyError, RuntimeError): 

255 return [] 

256 

257 hits: list[dict[str, Any]] = [] 

258 for item in result.items: 

259 if isinstance(item, dict): 

260 item_id = item.get("id", "") 

261 title = ( 

262 item.get(self.search_title_field) 

263 or item.get("name") 

264 or item.get("title") 

265 or str(item_id) 

266 ) 

267 subtitle = item.get("email") or item.get("description") or "" 

268 else: 

269 item_id = getattr(item, "id", "") 

270 title = ( 

271 getattr(item, self.search_title_field, None) 

272 or getattr(item, "name", None) 

273 or str(item_id) 

274 ) 

275 subtitle = ( 

276 getattr(item, "email", "") or getattr(item, "description", "") or "" 

277 ) 

278 hits.append( 

279 {"id": str(item_id), "title": str(title), "subtitle": str(subtitle)} 

280 ) 

281 return hits 

282 

283 async def fetch_list( 

284 self, 

285 *, 

286 limit: int = 20, 

287 offset: int = 0, 

288 filters: dict[str, Any] | None = None, 

289 search: str | None = None, 

290 search_fields: list[str] | None = None, 

291 sort_by: str | None = None, 

292 sort_order: str = "asc", 

293 include_deleted: bool = False, 

294 ) -> tuple[list[Any], int]: 

295 """Fetch a paginated list of items via the attached IDataSource. 

296 

297 Builds a Query object from the pagination/search/filter/sort parameters 

298 and delegates to ``self._data_source.find_many(query)``. 

299 

300 Override this in resource subclasses for custom data access logic. 

301 

302 Returns: 

303 Tuple of (items, total_count). 

304 """ 

305 if self._data_source is None: 

306 return [], 0 

307 

308 from lexigram.admin.data.query import QuerySpec 

309 

310 page = (offset // limit) + 1 if limit else 1 

311 qs = QuerySpec().with_page(page).with_per_page(limit) 

312 

313 if search and search_fields: 

314 qs = qs.with_search(search, search_fields) 

315 

316 if sort_by: 

317 qs = qs.with_order_by(sort_by, sort_order) 

318 

319 if include_deleted: 

320 qs = qs.with_deleted(True) 

321 

322 for field, value in (filters or {}).items(): 

323 if isinstance(value, list): 

324 qs = qs.with_where_in(field, value) 

325 else: 

326 qs = qs.with_where_eq(field, value) 

327 

328 result = await self._data_source.find_many(qs) 

329 items = list(result.items) 

330 total = result.total 

331 return items, total 

332 

333 async def before_create(self, data: dict) -> dict: 

334 """Hook called before a record is created. 

335 

336 Args: 

337 data: Record data to be created 

338 

339 Returns: 

340 Modified data 

341 """ 

342 return data 

343 

344 async def before_validate(self, data: dict) -> Any: 

345 """Validate and coerce form data against the resource model. 

346 

347 Base implementation coerces HTML form strings to proper Python types 

348 via _coerce_form_data, then validates against ``self.model``. 

349 Returns Ok(coerced_data) on success, Err(AdminValidationError) with 

350 per-field errors on failure. 

351 

352 Override in subclasses to add custom validation logic. 

353 """ 

354 from lexigram.admin.exceptions import AdminValidationError 

355 from lexigram.admin.resources.form_coercion import _coerce_form_data 

356 from lexigram.contracts.exceptions.domain import FieldError 

357 from lexigram.result import Err, Ok 

358 

359 coerced = _coerce_form_data(data, self.model) 

360 if self.model is None: 

361 return Ok(coerced) 

362 

363 if not hasattr(self.model, "model_validate"): 

364 return Ok(coerced) 

365 

366 try: 

367 self.model.model_validate(coerced) 

368 except (ValueError, TypeError) as exc: 

369 msg = str(exc) 

370 errors: list[FieldError] = [] 

371 

372 is_pydantic = ( 

373 type(exc).__name__ == "ValidationError" 

374 and "pydantic" in type(exc).__module__ 

375 ) 

376 if is_pydantic: 

377 for err in exc.errors(): # type: ignore[union-attr] 

378 field = str(err["loc"][0]) if err.get("loc") else None 

379 if field and field in coerced: 

380 errors.append(FieldError(field=field, message=err["msg"])) 

381 else: 

382 field = None 

383 if msg.startswith("Field '"): 

384 field = msg.split("'")[1] 

385 if field: 

386 errors.append(FieldError(field=field, message=msg)) 

387 

388 if errors: 

389 return Err( 

390 AdminValidationError( 

391 message="Form validation failed", 

392 errors=errors, 

393 ) 

394 ) 

395 return Ok(coerced) 

396 

397 return Ok(coerced) 

398 

399 async def after_create(self, record: Any) -> None: 

400 """Hook called after a record is created. 

401 

402 Args: 

403 record: Created record 

404 """ 

405 

406 async def before_update(self, item_id: Any, data: dict) -> dict: 

407 """Hook called before a record is updated. 

408 

409 Args: 

410 item_id: Record identifier 

411 data: Updated record data 

412 

413 Returns: 

414 Modified data 

415 """ 

416 return data 

417 

418 async def after_update(self, record: Any) -> None: 

419 """Hook called after a record is updated. 

420 

421 Args: 

422 record: Updated record 

423 """ 

424 

425 async def before_delete(self, item_id: Any) -> None: 

426 """Hook called before a record is deleted. 

427 

428 Args: 

429 item_id: Record identifier 

430 """ 

431 

432 async def after_delete(self, item_id: Any) -> None: 

433 """Hook called after a record is deleted. 

434 

435 Args: 

436 item_id: Record identifier 

437 """ 

438 

439 @classmethod 

440 def get_action_hooks(cls, action_name: str) -> list[Any]: 

441 """Get action lifecycle hooks for the named action. 

442 

443 Override in a resource subclass to attach ``ActionHookProtocol`` 

444 hooks to registry-based actions. Hooks are collected by 

445 ``ActionExecutor`` and run before/after the action body and on 

446 failure. 

447 

448 Args: 

449 action_name: Name of the action (e.g. ``"export"``) 

450 

451 Returns: 

452 List of action hooks for the action. 

453 """ 

454 return [] 

455 

456 @classmethod 

457 def get_table_config(cls) -> TableConfiguration: 

458 """Get the table configuration for this resource. 

459 

460 Returns: 

461 TableConfiguration with columns, actions, filters 

462 """ 

463 cfg = cls.config 

464 

465 # Resolve configuration with priority: Config Object > Class Attribute 

466 per_page = ( 

467 cls._get_config_value(cfg, "per_page", cls.page_size) 

468 if cfg 

469 else cls.page_size 

470 ) 

471 default_sort = ( 

472 cls._get_config_value( 

473 cfg, 

474 "default_sort_field", 

475 cls.default_sort, 

476 ) 

477 if cfg 

478 else cls.default_sort 

479 ) 

480 default_sort_order = ( 

481 cls._get_config_value(cfg, "default_sort_order", "asc") if cfg else "asc" 

482 ) 

483 action_layout = ( 

484 cls._get_config_value(cfg, "action_layout", cls.action_layout) 

485 if cfg 

486 else cls.action_layout 

487 ) 

488 

489 resource_name = cls.label or cls.__name__.replace("Resource", "") 

490 if cfg and cfg.display_name: 

491 resource_name = cfg.display_name 

492 

493 columns = list( 

494 cls._get_config_value(cfg, "columns", cls.columns) if cfg else cls.columns 

495 ) 

496 actions = list( 

497 cls._get_config_value(cfg, "actions", cls.actions) if cfg else cls.actions 

498 ) 

499 filters = list( 

500 cls._get_config_value(cfg, "filters_list", cls.filters) 

501 if cfg 

502 else cls.filters 

503 ) 

504 

505 # Check class attribute `layout_type` as fallback for legacy resources 

506 layout_fallback = getattr(cls, "layout_type", "stack") 

507 default_layout = ( 

508 cls._get_config_value(cfg, "layout", layout_fallback) 

509 if cfg 

510 else layout_fallback 

511 ) 

512 # Check class attribute `data_view` as fallback for legacy resources 

513 view_fallback = getattr(cls, "data_view", "tabular") 

514 default_view = ( 

515 cls._get_config_value(cfg, "view", view_fallback) if cfg else view_fallback 

516 ) 

517 

518 empty_state_title = ( 

519 cls._get_config_value(cfg, "empty_state_title", cls.empty_state_title) 

520 if cfg 

521 else cls.empty_state_title 

522 ) 

523 empty_state_message = ( 

524 cls._get_config_value(cfg, "empty_state_message", cls.empty_state_message) 

525 if cfg 

526 else cls.empty_state_message 

527 ) 

528 empty_state_icon = ( 

529 cls._get_config_value(cfg, "empty_state_icon", cls.empty_state_icon) 

530 if cfg 

531 else cls.empty_state_icon 

532 ) 

533 group_by = ( 

534 cls._get_config_value(cfg, "group_by", cls.group_by) 

535 if cfg 

536 else cls.group_by 

537 ) 

538 

539 return TableConfiguration( 

540 columns=columns, 

541 actions=actions, 

542 header_actions=list(cls.header_actions), 

543 bulk_actions=list(cls.bulk_actions), 

544 filter_options=filters, 

545 per_page=per_page, 

546 default_sort_by=default_sort, 

547 default_sort_order=default_sort_order, 

548 resource_name=resource_name, 

549 action_layout=action_layout, 

550 default_layout=default_layout, 

551 default_view=default_view, 

552 empty_state_title=empty_state_title, 

553 empty_state_message=empty_state_message, 

554 empty_state_icon=empty_state_icon, 

555 group_by=group_by, 

556 ) 

557 

558 @classmethod 

559 def get_form_class(cls) -> type[FormBase] | None: 

560 """Return the Form class to use for create/edit views. 

561 

562 Returns: 

563 Form class or None 

564 """ 

565 return cls.form_class 

566 

567 @classmethod 

568 def get_form_display_mode(cls) -> str: 

569 """Return the form display mode for create/edit views. 

570 

571 Returns: 

572 Display mode: "page", "modal", or "slider" 

573 """ 

574 cfg = cls.config 

575 return ( 

576 cls._get_config_value(cfg, "form_display_mode", cls.form_display_mode) 

577 if cfg 

578 else cls.form_display_mode 

579 ) 

580 

581 @classmethod 

582 def get_layout_manager(cls) -> LayoutManager: 

583 """Get layout manager with configured views. 

584 

585 Returns: 

586 LayoutManager instance 

587 """ 

588 from lexigram.admin.layout import LayoutManager 

589 

590 manager = LayoutManager() 

591 cfg = cls.config 

592 

593 if cfg and cfg.views_list: 

594 for view in cfg.views_list: 

595 if hasattr(view, "to_config"): 

596 layout_config = view.to_config() 

597 apply_layout_config(manager, layout_config) 

598 

599 # Set default view 

600 if cfg.view: 

601 with contextlib.suppress(ValueError): 

602 manager.set_default(cfg.view) 

603 

604 return manager 

605 

606 @staticmethod 

607 def _get_config_value(cfg: Any, attr: str, default: Any) -> Any: 

608 """Get configuration value with fallback to default or private attribute. 

609 

610 Args: 

611 cfg: Configuration object 

612 attr: Attribute name 

613 default: Default value if not found 

614 

615 Returns: 

616 Configuration value or default 

617 """ 

618 if cfg is None: 

619 return default 

620 

621 # Try public attribute/property 

622 val = getattr(cfg, attr, None) 

623 

624 # If it's the fluent method (callable) or missing, try the private attribute 

625 if val is None or callable(val): 

626 val = getattr(cfg, f"_{attr}", None) 

627 

628 return val if val is not None else default 

629 

630 

631__all__ = ["Resource"]