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

238 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-08-13 22:07 +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 search_fields=getattr(resource, "search_fields", None), 

133 ), 

134 total=total, 

135 user=user, 

136 loading=False, 

137 ) 

138 

139 is_htmx = wants_fragment(request) 

140 if is_htmx: 

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

142 

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

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

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

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

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

148 # subtree, making OOB redundant. 

149 if hx_target == Zones.DATA.id: 

150 dt.props["htmx_request"] = True 

151 

152 content = render_to_string(dt) 

153 resp_headers = {} 

154 

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

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

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

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

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

160 

161 return HTMLResponse(content, headers=resp_headers) 

162 

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

164 return self._renderer.render_page( 

165 dt, 

166 request=request, 

167 title=label, 

168 breadcrumbs=[ 

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

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

171 ], 

172 ) 

173 

174 async def _fetch_data( 

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

176 ) -> Any: 

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

178 timer = OperationTimer() 

179 items: list[Any] = [] 

180 total = 0 

181 status = "success" 

182 failed = False 

183 

184 if not resource: 

185 self._metrics.record_operation( 

186 "list", 

187 resource=self.resource_name, 

188 status=status, 

189 duration_seconds=timer.elapsed(), 

190 ) 

191 return items, total 

192 

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

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

195 resource, "search_fields", None 

196 ) 

197 if resource_search_fields: 

198 search_fields = list(resource_search_fields) 

199 else: 

200 search_fields = [] 

201 for col in source_columns: 

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

203 search_fields.append(col.name) 

204 

205 # Extract active filters from TableState 

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

207 

208 try: 

209 # Check if this resource should be cached 

210 cache_spec = self._resolve_cache_spec(resource) 

211 

212 # Check if search integration should be used 

213 search_spec = self._resolve_search_spec(resource) 

214 

215 # Check if resilience integration should be used 

216 resilient_spec = self._resolve_resilient_spec(resource) 

217 

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

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

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

221 index = search_spec.index_name or self.resource_name 

222 result = await self._search_integration.query( 

223 index=index, 

224 query_str=state.search, 

225 limit=state.per_page, 

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

227 ) 

228 if hasattr(result, "rows"): 

229 items = list(result.rows) 

230 total = result.row_count 

231 elif isinstance(result, dict): 

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

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

234 else: 

235 items, total = [], 0 

236 

237 # If search returned results, return them. 

238 # Otherwise fall through to fetch_list when fallback_to_like is enabled 

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

240 if items or not self._search_integration.fallback_to_like: 

241 status = "success" 

242 self._metrics.record_operation( 

243 "list", 

244 resource=self.resource_name, 

245 status=status, 

246 duration_seconds=timer.elapsed(), 

247 ) 

248 return items, total 

249 

250 logger.info( 

251 "search_integration_fallback", 

252 resource=self.resource_name, 

253 query=state.search, 

254 ) 

255 

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

257 if hasattr(resource, "fetch_list"): 

258 fetch_kwargs = { 

259 "limit": state.per_page, 

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

261 "filters": filters, 

262 "search": state.search or None, 

263 "search_fields": search_fields or None, 

264 "sort_by": state.sort_by or None, 

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

266 "include_deleted": state.include_deleted, 

267 } 

268 

269 # Build the fetch callable, optionally wrapped with resilience 

270 if resilient_spec and self._resilience_integration: 

271 

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

273 return await self._resilience_integration.execute( 

274 resource.fetch_list, 

275 **fetch_kwargs, 

276 ) 

277 

278 fetcher = _resilient_fetch 

279 else: 

280 

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

282 return await resource.fetch_list(**fetch_kwargs) 

283 

284 fetcher = _fetch 

285 

286 if cache_spec and self._cache_integration: 

287 cache_key = self._cache_integration.cache_key( 

288 self.resource_name, 

289 str(state.page), 

290 state.sort_by or "", 

291 ) 

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

293 cache_key, 

294 fetcher, 

295 cache_spec.ttl_seconds, 

296 ) 

297 else: 

298 items, total = await fetcher() 

299 

300 logger.info( 

301 "search.fetch_list_result", 

302 resource=self.resource_name, 

303 search=state.search, 

304 search_fields=search_fields, 

305 item_count=len(items), 

306 total=total, 

307 ) 

308 

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

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

311 service = resource.service 

312 if hasattr(service, "find_many"): 

313 qs = QuerySpec() 

314 if state.search and search_fields: 

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

316 if state.page and state.per_page: 

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

318 if state.sort_by: 

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

320 if state.include_deleted: 

321 qs = qs.with_deleted(True) 

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

323 if isinstance(value, list): 

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

325 else: 

326 qs = qs.with_where_eq(field, value) 

327 result = await service.find_many(qs) 

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

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

330 elif hasattr(service, "list"): 

331 items = await service.list( 

332 limit=state.per_page, 

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

334 ) 

335 total = len(items) 

336 except DataError as e: 

337 logger.error( 

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

339 self.resource_name, 

340 e, 

341 exc_info=True, 

342 ) 

343 raise DataError( 

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

345 original_error=e, 

346 ) from None 

347 except Exception: # noqa: BLE001 

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

349 failed = True 

350 

351 if failed: 

352 status = "error" 

353 

354 logger.info( 

355 "search.fetch_data_result", 

356 resource=self.resource_name, 

357 search=state.search, 

358 item_count=len(items), 

359 total=total, 

360 status=status, 

361 failed=failed, 

362 ) 

363 

364 self._metrics.record_operation( 

365 "list", 

366 resource=self.resource_name, 

367 status=status, 

368 duration_seconds=timer.elapsed(), 

369 ) 

370 return items, total 

371 

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

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

374 if not resource: 

375 return None 

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

377 if not spec_fn: 

378 return None 

379 spec = spec_fn() 

380 if not spec: 

381 return None 

382 from lexigram.admin.integrations import get as get_integration 

383 

384 self._cache_integration = get_integration("CacheIntegration") 

385 return spec if self._cache_integration else None 

386 

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

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

389 if not resource: 

390 return None 

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

392 if not spec_fn: 

393 return None 

394 spec = spec_fn() 

395 if not spec: 

396 return None 

397 from lexigram.admin.integrations import get as get_integration 

398 

399 self._search_integration = get_integration("SearchIntegration") 

400 return spec if self._search_integration else None 

401 

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

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

404 if not resource: 

405 return None 

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

407 if not spec_fn: 

408 return None 

409 spec = spec_fn() 

410 if not spec: 

411 return None 

412 from lexigram.admin.integrations import get as get_integration 

413 

414 self._resilience_integration = get_integration("ResilienceIntegration") 

415 return spec if self._resilience_integration else None 

416 

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

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

419 columns = [] 

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

421 if not source_columns and items: 

422 first_item = items[0] 

423 item_dict = ( 

424 first_item.model_dump() 

425 if hasattr(first_item, "model_dump") 

426 else ( 

427 dict(first_item) 

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

429 else {"id": first_item} 

430 ) 

431 ) 

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

433 columns.append( 

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

435 ) 

436 else: 

437 # Convert or preserve columns 

438 for col in source_columns: 

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

440 # Already a component 

441 columns.append(col) 

442 else: 

443 # Fallback for simple objects 

444 new_col = TextColumn( 

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

446 label=getattr( 

447 col, 

448 "label", 

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

450 ), 

451 ).sortable(True) 

452 columns.append(new_col) 

453 

454 return columns 

455 

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

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

458 filter_options = [] 

459 if table_config and table_config.filter_options: 

460 filter_options = table_config.filter_options 

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

462 filter_options = ( 

463 resource.filter_options 

464 if not callable(resource.filter_options) 

465 else resource.filter_options() 

466 ) 

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

468 filter_options = ( 

469 resource.filters 

470 if not callable(resource.filters) 

471 else resource.filters() 

472 ) 

473 return filter_options 

474 

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

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

477 row_actions = [] 

478 if table_config and table_config.actions: 

479 row_actions = table_config.actions 

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

481 actions = ( 

482 resource.actions 

483 if not callable(resource.actions) 

484 else resource.actions() 

485 ) 

486 row_actions = list(actions) 

487 

488 # Inject default URLs for standard actions if missing 

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

490 

491 for action in row_actions: 

492 if ( 

493 isinstance(action, (EditAction, ViewAction)) 

494 and not action.get_url() 

495 and not action.get_hx_get() 

496 ): 

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

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

499 

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

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

502 

503 return row_actions 

504 

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

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

507 header_actions = [] 

508 if ( 

509 table_config 

510 and hasattr(table_config, "header_actions") 

511 and table_config.header_actions 

512 ): 

513 header_actions = table_config.header_actions 

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

515 header_actions = ( 

516 resource.header_actions 

517 if not callable(resource.header_actions) 

518 else resource.header_actions() 

519 ) 

520 return header_actions 

521 

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

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

524 bulk_actions_list = [] 

525 source_bulk = [] 

526 if table_config and table_config.bulk_actions: 

527 source_bulk = table_config.bulk_actions 

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

529 source_bulk = ( 

530 resource.bulk_actions 

531 if not callable(resource.bulk_actions) 

532 else resource.bulk_actions() 

533 ) 

534 

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

536 from lexigram.ui.actions.standard import DeleteBulkAction 

537 

538 for ba in source_bulk: 

539 if isinstance(ba, OrgBulkAction): 

540 bulk_actions_list.append(ba) 

541 elif ba == "delete_selected": 

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

543 elif isinstance(ba, str): 

544 # Generic bulk action from string 

545 bulk_actions_list.append( 

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

547 ) 

548 

549 return bulk_actions_list 

550 

551 

552__all__ = ["ListRenderer"]