Coverage for src/lexigram/admin/resources/list_renderer.py: 58%

241 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-21 15:04 +0800

1from __future__ import annotations 

2 

3"""List view rendering for admin resources.""" 

4 

5from typing import Any 

6 

7from starlette.responses import HTMLResponse 

8 

9from lexigram.admin.config import AdminConfig 

10from lexigram.admin.data.query import QuerySpec 

11from lexigram.admin.engine.renderer import AdminRenderer 

12from lexigram.admin.exceptions import DataError 

13from lexigram.admin.observability.admin_metrics import AdminMetrics, OperationTimer 

14from lexigram.admin.resources.config import TableConfiguration 

15from lexigram.admin.state.context import wants_fragment 

16from lexigram.admin.ui.organisms.data_table import DataTable 

17from lexigram.di.decorators import inject 

18from lexigram.logging import get_logger 

19from lexigram.ui import Column as OrgColumn 

20from lexigram.ui import TableState, TextColumn, Zones, render_to_string 

21 

22logger = get_logger(__name__) 

23 

24 

25@inject 

26class ListRenderer: 

27 """Handles rendering of list views for admin resources.""" 

28 

29 def __init__( 

30 self, 

31 config: AdminConfig, 

32 resource_name: str, 

33 renderer: AdminRenderer, 

34 metrics: AdminMetrics | None = None, 

35 ): 

36 self._config = config 

37 self.resource_name = resource_name 

38 self._renderer = renderer 

39 self._metrics = metrics or AdminMetrics(None) 

40 self._cache_integration: Any = None 

41 self._search_integration: Any = None 

42 self._resilience_integration: Any = None 

43 

44 async def render( 

45 self, 

46 request, 

47 resource, 

48 user=None, 

49 ) -> HTMLResponse: 

50 """Render list view with DataTable component.""" 

51 # Get resource configuration 

52 table_config = ( 

53 resource.get_table_config() 

54 if resource and hasattr(resource, "get_table_config") 

55 else None 

56 ) 

57 label = ( 

58 (table_config.resource_name if table_config else self.resource_name) 

59 .replace("_", " ") 

60 .title() 

61 ) 

62 resource_prefix = f"{self._config.prefix}/{self.resource_name}" 

63 

64 # Resolve Columns Early for Search 

65 source_columns = [] 

66 if table_config and table_config.columns: 

67 source_columns = table_config.columns 

68 elif resource and hasattr(resource, "columns"): 

69 # Check if columns is a property/method 

70 source_columns = ( 

71 resource.columns 

72 if not callable(resource.columns) 

73 else resource.columns() 

74 ) 

75 

76 # Parse request params using TableState 

77 state = TableState.from_request( 

78 request, 

79 defaults={ 

80 "sort_by": table_config.default_sort_by if table_config else None, 

81 "sort_order": table_config.default_sort_order 

82 if table_config 

83 else "asc", 

84 "view": table_config.default_view if table_config else "tabular", 

85 "layout": table_config.default_layout if table_config else "stack", 

86 "per_page": table_config.per_page if table_config else 20, 

87 } 

88 if table_config 

89 else {}, 

90 ) 

91 

92 # Map list_view sort params to TableState params 

93 if request.query_params.get("sort"): 

94 state.sort_by = request.query_params.get("sort") 

95 if request.query_params.get("dir"): 

96 state.sort_order = request.query_params.get("dir") 

97 

98 # Fetch data from service 

99 items, total = await self._fetch_data(request, resource, state, source_columns) 

100 

101 # Build columns 

102 columns = self._build_columns(source_columns, items) 

103 

104 # Prepare Filters 

105 filter_options = self._get_filter_options(table_config, resource) 

106 

107 # Prepare Actions 

108 row_actions = self._get_row_actions(table_config, resource, resource_prefix) 

109 

110 header_actions = self._get_header_actions(table_config, resource) 

111 

112 # Prepare Bulk Actions 

113 bulk_actions_list = self._get_bulk_actions(table_config, resource) 

114 

115 # Prepare DataTable 

116 dt = DataTable( 

117 columns=columns, 

118 data=items, 

119 state=state, 

120 config=TableConfiguration( 

121 columns=columns, 

122 resource_name=self.resource_name, 

123 resource_prefix=resource_prefix, 

124 actions=row_actions, 

125 header_actions=header_actions, 

126 bulk_actions=bulk_actions_list, 

127 filter_options=filter_options, 

128 default_sort_by=state.sort_by, 

129 default_sort_order=state.sort_order, 

130 default_layout=table_config.default_layout if table_config else "stack", 

131 default_view=table_config.default_view if table_config else "tabular", 

132 group_by=state.group_by 

133 or (table_config.group_by if table_config else None), 

134 empty_state_title=( 

135 table_config.empty_state_title if table_config else None 

136 ), 

137 empty_state_message=( 

138 table_config.empty_state_message if table_config else None 

139 ), 

140 empty_state_icon=( 

141 table_config.empty_state_icon if table_config else None 

142 ), 

143 search_fields=getattr(resource, "search_fields", None), 

144 ), 

145 total=total, 

146 user=user, 

147 loading=False, 

148 ) 

149 

150 is_htmx = wants_fragment(request) 

151 if is_htmx: 

152 hx_target = request.headers.get("HX-Target", "") 

153 

154 # Only emit OOB control fragments for data-zone requests (search, 

155 # filter, paginate) where the primary swap targets #table-data and 

156 # toolbar elements outside the data zone need updating. Skip OOB 

157 # for full-zone swaps (#lexigram-table) and sidebar nav 

158 # (#main-content) since the primary swap replaces the entire 

159 # subtree, making OOB redundant. 

160 if hx_target == Zones.DATA.id: 

161 dt.props["htmx_request"] = True 

162 

163 content = render_to_string(dt) 

164 resp_headers = {} 

165 

166 # Synchronization: Force the browser URL to match the clean server-side state. 

167 # This removes empty params (search=&foo=) that HTMX sends via hx-include. 

168 # We only do this if push was not explicitly disabled in the request. 

169 if request.headers.get("HX-Push-Url") != "false": 

170 resp_headers["HX-Push-Url"] = state.to_url(resource_prefix) 

171 

172 return HTMLResponse(content, headers=resp_headers) 

173 

174 # Direct navigation — return full page via AdminRenderer (Jinja2 + nav population). 

175 return self._renderer.render_page( 

176 dt, 

177 request=request, 

178 title=label, 

179 breadcrumbs=[ 

180 {"label": "Dashboard", "url": self._config.prefix}, 

181 {"label": label, "url": resource_prefix}, 

182 ], 

183 ) 

184 

185 async def _fetch_data( 

186 self, request, resource, state: TableState, source_columns 

187 ) -> Any: 

188 """Fetch data from the resource service.""" 

189 timer = OperationTimer() 

190 items: list[Any] = [] 

191 total = 0 

192 status = "success" 

193 failed = False 

194 

195 if not resource: 

196 self._metrics.record_operation( 

197 "list", 

198 resource=self.resource_name, 

199 status=status, 

200 duration_seconds=timer.elapsed(), 

201 ) 

202 return items, total 

203 

204 # Use resource search_fields when available, otherwise derive from columns 

205 resource_search_fields: list[str] | None = getattr( 

206 resource, "search_fields", None 

207 ) 

208 if resource_search_fields: 

209 search_fields = list(resource_search_fields) 

210 else: 

211 search_fields = [] 

212 for col in source_columns: 

213 if hasattr(col, "is_searchable") and col.is_searchable(): 

214 search_fields.append(col.name) 

215 

216 # Extract active filters from TableState 

217 filters: dict[str, Any] = dict(state.filters) if state.filters else {} 

218 

219 try: 

220 # Check if this resource should be cached 

221 cache_spec = self._resolve_cache_spec(resource) 

222 

223 # Check if search integration should be used 

224 search_spec = self._resolve_search_spec(resource) 

225 

226 # Check if resilience integration should be used 

227 resilient_spec = self._resolve_resilient_spec(resource) 

228 

229 # Search path: when a search query is active and a search spec exists, 

230 # route through the search integration instead of the LIKE-based query 

231 if state.search and search_spec and self._search_integration: 

232 index = search_spec.index_name or self.resource_name 

233 result = await self._search_integration.query( 

234 index=index, 

235 query_str=state.search, 

236 limit=state.per_page, 

237 offset=(state.page - 1) * state.per_page, 

238 ) 

239 if hasattr(result, "rows"): 

240 items = list(result.rows) 

241 total = result.row_count 

242 elif isinstance(result, dict): 

243 items = list(result.get("results", [])) 

244 total = result.get("total", len(items)) 

245 else: 

246 items, total = [], 0 

247 

248 # If search returned results, return them. 

249 # Otherwise fall through to fetch_list when fallback_to_like is enabled 

250 # so that the SearchQueryDataSourceWrapper can try a LIKE query. 

251 if items or not self._search_integration.fallback_to_like: 

252 status = "success" 

253 self._metrics.record_operation( 

254 "list", 

255 resource=self.resource_name, 

256 status=status, 

257 duration_seconds=timer.elapsed(), 

258 ) 

259 return items, total 

260 

261 logger.info( 

262 "search_integration_fallback", 

263 resource=self.resource_name, 

264 query=state.search, 

265 ) 

266 

267 # Primary path: resource.fetch_list() — handles all service patterns 

268 if hasattr(resource, "fetch_list"): 

269 fetch_kwargs = { 

270 "limit": state.per_page, 

271 "offset": (state.page - 1) * state.per_page, 

272 "filters": filters, 

273 "search": state.search or None, 

274 "search_fields": search_fields or None, 

275 "sort_by": state.sort_by or None, 

276 "sort_order": state.sort_order or "asc", 

277 "include_deleted": state.include_deleted, 

278 } 

279 

280 # Build the fetch callable, optionally wrapped with resilience 

281 if resilient_spec and self._resilience_integration: 

282 

283 async def _resilient_fetch() -> tuple[list[Any], int]: 

284 return await self._resilience_integration.execute( 

285 resource.fetch_list, 

286 **fetch_kwargs, 

287 ) 

288 

289 fetcher = _resilient_fetch 

290 else: 

291 

292 async def _fetch() -> tuple[list[Any], int]: 

293 return await resource.fetch_list(**fetch_kwargs) 

294 

295 fetcher = _fetch 

296 

297 if cache_spec and self._cache_integration: 

298 cache_key = self._cache_integration.cache_key( 

299 self.resource_name, 

300 str(state.page), 

301 state.sort_by or "", 

302 ) 

303 items, total = await self._cache_integration.get_or_compute( 

304 cache_key, 

305 fetcher, 

306 cache_spec.ttl_seconds, 

307 ) 

308 else: 

309 items, total = await fetcher() 

310 

311 logger.info( 

312 "search.fetch_list_result", 

313 resource=self.resource_name, 

314 search=state.search, 

315 search_fields=search_fields, 

316 item_count=len(items), 

317 total=total, 

318 ) 

319 

320 # Legacy fallback: direct service.find_many(query) 

321 elif hasattr(resource, "service") and resource.service: 

322 service = resource.service 

323 if hasattr(service, "find_many"): 

324 qs = QuerySpec() 

325 if state.search and search_fields: 

326 qs = qs.with_search(state.search, search_fields) 

327 if state.page and state.per_page: 

328 qs = qs.with_page(state.page).with_per_page(state.per_page) 

329 if state.sort_by: 

330 qs = qs.with_order_by(state.sort_by, state.sort_order) 

331 if state.include_deleted: 

332 qs = qs.with_deleted(True) 

333 for field, value in filters.items(): 

334 if isinstance(value, list): 

335 qs = qs.with_where_in(field, list(value)) 

336 else: 

337 qs = qs.with_where_eq(field, value) 

338 result = await service.find_many(qs) 

339 items = result.items if hasattr(result, "items") else result 

340 total = result.total if hasattr(result, "total") else len(items) 

341 elif hasattr(service, "list"): 

342 items = await service.list( 

343 limit=state.per_page, 

344 offset=(state.page - 1) * state.per_page, 

345 ) 

346 total = len(items) 

347 except DataError as e: 

348 logger.error( 

349 "Failed to list items for %s: %s", 

350 self.resource_name, 

351 e, 

352 exc_info=True, 

353 ) 

354 raise DataError( 

355 message=f"Failed to retrieve {self.resource_name} items", 

356 original_error=e, 

357 ) from None 

358 except Exception: # noqa: BLE001 

359 logger.exception("admin.resource_fetch_error", resource=self.resource_name) 

360 failed = True 

361 

362 if failed: 

363 status = "error" 

364 

365 logger.info( 

366 "search.fetch_data_result", 

367 resource=self.resource_name, 

368 search=state.search, 

369 item_count=len(items), 

370 total=total, 

371 status=status, 

372 failed=failed, 

373 ) 

374 

375 self._metrics.record_operation( 

376 "list", 

377 resource=self.resource_name, 

378 status=status, 

379 duration_seconds=timer.elapsed(), 

380 ) 

381 return items, total 

382 

383 def _resolve_cache_spec(self, resource: Any) -> Any: 

384 """Return a CacheableSpec if resource is cacheable, else None.""" 

385 if not resource: 

386 return None 

387 spec_fn = getattr(resource, "cache_spec", None) 

388 if not spec_fn: 

389 return None 

390 spec = spec_fn() 

391 if not spec: 

392 return None 

393 from lexigram.admin.integrations import get as get_integration 

394 

395 self._cache_integration = get_integration("CacheIntegration") 

396 return spec if self._cache_integration else None 

397 

398 def _resolve_search_spec(self, resource: Any) -> Any: 

399 """Return a SearchableSpec if resource is searchable, else None.""" 

400 if not resource: 

401 return None 

402 spec_fn = getattr(resource, "search_spec", None) 

403 if not spec_fn: 

404 return None 

405 spec = spec_fn() 

406 if not spec: 

407 return None 

408 from lexigram.admin.integrations import get as get_integration 

409 

410 self._search_integration = get_integration("SearchIntegration") 

411 return spec if self._search_integration else None 

412 

413 def _resolve_resilient_spec(self, resource: Any) -> Any: 

414 """Return a ResilientSpec if resource is resilient, else None.""" 

415 if not resource: 

416 return None 

417 spec_fn = getattr(resource, "resilient_spec", None) 

418 if not spec_fn: 

419 return None 

420 spec = spec_fn() 

421 if not spec: 

422 return None 

423 from lexigram.admin.integrations import get as get_integration 

424 

425 self._resilience_integration = get_integration("ResilienceIntegration") 

426 return spec if self._resilience_integration else None 

427 

428 def _build_columns(self, source_columns, items) -> Any: 

429 """Build column definitions for the data table.""" 

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

431 

432 columns = [] 

433 # Auto-generate columns if missing (post-fetch) 

434 if not source_columns and items: 

435 first_item = items[0] 

436 item_dict = ( 

437 first_item.model_dump() 

438 if hasattr(first_item, "model_dump") 

439 else ( 

440 dict(first_item) 

441 if not isinstance(first_item, (str, int, float)) 

442 else {"id": first_item} 

443 ) 

444 ) 

445 for key in list(item_dict.keys())[:6]: 

446 columns.append( 

447 TextColumn(name=key, label=key.replace("_", " ").title()), 

448 ) 

449 else: 

450 # Convert or preserve columns 

451 for col in source_columns: 

452 if isinstance(col, Filter): 

453 continue 

454 if hasattr(col, "render") or isinstance(col, OrgColumn): 

455 # Already a component 

456 columns.append(col) 

457 else: 

458 # Fallback for simple objects 

459 new_col = TextColumn( 

460 name=getattr(col, "name", str(col)), 

461 label=getattr( 

462 col, 

463 "label", 

464 getattr(col, "name", str(col)).replace("_", " ").title(), 

465 ), 

466 ).sortable(True) 

467 columns.append(new_col) 

468 

469 return columns 

470 

471 def _get_filter_options(self, table_config, resource) -> Any: 

472 """Get filter options from resource configuration.""" 

473 filter_options = [] 

474 if table_config and table_config.filter_options: 

475 filter_options = table_config.filter_options 

476 elif resource and hasattr(resource, "filter_options"): 

477 filter_options = ( 

478 resource.filter_options 

479 if not callable(resource.filter_options) 

480 else resource.filter_options() 

481 ) 

482 elif resource and hasattr(resource, "filters"): 

483 filter_options = ( 

484 resource.filters 

485 if not callable(resource.filters) 

486 else resource.filters() 

487 ) 

488 return filter_options 

489 

490 def _get_row_actions(self, table_config, resource, resource_prefix) -> Any: 

491 """Get row actions from resource configuration.""" 

492 row_actions = [] 

493 if table_config and table_config.actions: 

494 row_actions = table_config.actions 

495 elif resource and hasattr(resource, "actions"): 

496 actions = ( 

497 resource.actions 

498 if not callable(resource.actions) 

499 else resource.actions() 

500 ) 

501 row_actions = list(actions) 

502 

503 # Inject default URLs for standard actions if missing 

504 from lexigram.ui.actions.standard import EditAction, ViewAction 

505 

506 for action in row_actions: 

507 if ( 

508 isinstance(action, (EditAction, ViewAction)) 

509 and not action.get_url() 

510 and not action.get_hx_get() 

511 ): 

512 # Default logic: {prefix}/{id}/edit or {prefix}/{id} 

513 postfix = "/edit" if isinstance(action, EditAction) else "" 

514 

515 # Use hx_get for partial updates (SlideOver/Modal) 

516 action.hx(get=f"{resource_prefix.rstrip('/')}/{{id}}{postfix}") 

517 

518 return row_actions 

519 

520 def _get_header_actions(self, table_config, resource) -> Any: 

521 """Get header actions from resource configuration.""" 

522 header_actions = [] 

523 if ( 

524 table_config 

525 and hasattr(table_config, "header_actions") 

526 and table_config.header_actions 

527 ): 

528 header_actions = table_config.header_actions 

529 elif resource and hasattr(resource, "header_actions"): 

530 header_actions = ( 

531 resource.header_actions 

532 if not callable(resource.header_actions) 

533 else resource.header_actions() 

534 ) 

535 return header_actions 

536 

537 def _get_bulk_actions(self, table_config, resource) -> Any: 

538 """Get bulk actions from resource configuration.""" 

539 bulk_actions_list = [] 

540 source_bulk = [] 

541 if table_config and table_config.bulk_actions: 

542 source_bulk = table_config.bulk_actions 

543 elif resource and hasattr(resource, "bulk_actions"): 

544 source_bulk = ( 

545 resource.bulk_actions 

546 if not callable(resource.bulk_actions) 

547 else resource.bulk_actions() 

548 ) 

549 

550 from lexigram.ui.actions.standard import BulkAction as OrgBulkAction 

551 from lexigram.ui.actions.standard import DeleteBulkAction 

552 

553 for ba in source_bulk: 

554 if isinstance(ba, OrgBulkAction): 

555 bulk_actions_list.append(ba) 

556 elif ba == "delete_selected": 

557 bulk_actions_list.append(DeleteBulkAction(label="Delete Selected")) 

558 elif isinstance(ba, str): 

559 # Generic bulk action from string 

560 bulk_actions_list.append( 

561 OrgBulkAction(label=ba.replace("_", " ").title(), name=ba), 

562 ) 

563 

564 return bulk_actions_list 

565 

566 

567__all__ = ["ListRenderer"]