Coverage for src/lexigram/admin/dashboard/page_handlers.py: 0%

228 statements  

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

1from __future__ import annotations 

2 

3import inspect 

4import re 

5import types 

6from typing import TYPE_CHECKING, Any, cast, get_args, get_origin, get_type_hints 

7 

8from markupsafe import Markup 

9from starlette.requests import Request as StarletteRequest 

10from starlette.responses import HTMLResponse 

11 

12from lexigram.admin.navigation.clusters import ( 

13 CLUSTER_LABEL, 

14 CLUSTER_URL, 

15 cluster_items, 

16 is_cluster_path, 

17) 

18from lexigram.admin.state.context import wants_fragment 

19from lexigram.contracts.admin.page_content import PageContent 

20from lexigram.contracts.admin.widget_content import EmptyContent 

21from lexigram.contracts.exceptions import UnresolvableDependencyError 

22from lexigram.logging import get_logger 

23 

24if TYPE_CHECKING: 

25 from lexigram.contracts.core.di import ContainerResolverProtocol 

26 

27logger = get_logger(__name__) 

28 

29 

30def _strip_optional(tp: Any) -> Any: 

31 """If *tp* is ``Optional[X]`` (``Union[X, None]`` or ``X | None``), 

32 return ``X``. Otherwise return *tp* unchanged.""" 

33 origin = get_origin(tp) 

34 if origin is types.UnionType: 

35 args = get_args(tp) 

36 non_none = [a for a in args if a is not type(None)] 

37 if len(non_none) == 1: 

38 return non_none[0] 

39 return tp 

40 

41 

42_DEFAULT_PRIMARY_COLOR = "#6b7280" 

43 

44_CLUSTER_HEADER_DESCRIPTION = ( 

45 "Monitor and manage the services powering your application: web, data, " 

46 "and runtime areas." 

47) 

48 

49 

50def _cluster_header_html() -> str: 

51 """Render the cluster center top-level title + description block. 

52 

53 Mirrors the settings center header: a ``mb-2`` wrapper with the 

54 section title and a muted one-line description, rendered above the 

55 whole center layout (secondary sidebar and page content). 

56 """ 

57 from lexigram.ui import el, render_to_string 

58 

59 return render_to_string( 

60 el( 

61 "div", 

62 el("h1", CLUSTER_LABEL, class_="text-2xl font-bold text-foreground"), 

63 el( 

64 "p", 

65 _CLUSTER_HEADER_DESCRIPTION, 

66 class_="text-muted-foreground mt-1", 

67 ), 

68 class_="mb-2", 

69 ) 

70 ) 

71 

72 

73#: First header block rendered by management pages (h1 + description + 

74#: divider). Cluster pages have their own top-level header injected by the 

75#: shell wrapper, so this inline header is dropped. 

76_PAGE_HEADER_RE = re.compile( 

77 r"<h1[^>]*>.*?</h1>\s*<p[^>]*>.*?</p>\s*(?:<hr[^>]*/?>)?", 

78 re.S, 

79) 

80 

81 

82async def _resolve_primary_color(container: Any) -> str: 

83 """Resolve the saved branding primary color, best-effort. 

84 

85 Falls back to the framework default when no registry/db store is 

86 available. 

87 """ 

88 try: 

89 from lexigram.admin.settings.panel.registry import ConfigRegistry 

90 

91 registry = await container.resolve( 

92 ConfigRegistry, 

93 bypass_visibility=True, 

94 ) 

95 values = await registry.get_values("admin.branding", "db") 

96 color = values.get("primary_color") 

97 if color: 

98 return str(color) 

99 except Exception: # noqa: BLE001 — non-fatal 

100 logger.exception("admin.theme_overrides_failed") 

101 return _DEFAULT_PRIMARY_COLOR 

102 

103 

104class AdminPageHandler: 

105 """ASGI adapter that resolves a management page handler from the DI 

106 container at request time and delegates to its ``handle()`` method. 

107 

108 Starlette treats class endpoints as ASGI apps — it calls 

109 ``cls(scope, receive, send)`` which becomes ``__init__(scope, receive, 

110 send)``. Management page handlers use keyword-only constructor DI 

111 (``def __init__(self, *, repo: ..., ...)``), so direct registration 

112 always raises TypeError. This wrapper sidesteps that by storing the 

113 page **class** at route-build time and resolving an instance from the 

114 container at request time. 

115 """ 

116 

117 def __init__( 

118 self, 

119 page_cls: type, 

120 container: ContainerResolverProtocol, 

121 ) -> None: 

122 self._page_cls = page_cls 

123 self._container = container 

124 

125 async def __call__(self, scope: Any, receive: Any, send: Any) -> None: 

126 request = StarletteRequest(scope, receive, send) 

127 try: 

128 instance = await self._resolve_page() 

129 response = await instance.handle(request) 

130 if not isinstance(response, HTMLResponse): 

131 from lexigram.admin.dashboard.page_renderer import ( 

132 render_page_content, 

133 ) 

134 from lexigram.contracts.admin.page_content import PageContent 

135 

136 if isinstance(response, PageContent): 

137 response = render_page_content(response) 

138 else: 

139 logger.error( 

140 "admin_page_contract_violation", 

141 page=self._page_cls.__name__, 

142 result_type=type(response).__name__, 

143 ) 

144 response = await _placeholder_page(request, self._container) 

145 except Exception: 

146 logger.exception( 

147 "admin_page_handler_error", 

148 page=self._page_cls.__name__, 

149 ) 

150 response = await _placeholder_page(request, self._container) 

151 

152 try: 

153 is_htmx = wants_fragment(request) 

154 except KeyError: 

155 is_htmx = False 

156 if isinstance(response, HTMLResponse): 

157 response = await self._apply_cluster_header(request, response) 

158 if not is_htmx and isinstance(response, HTMLResponse): 

159 response = await self._wrap_in_shell(request, response) 

160 

161 await response(scope, receive, send) 

162 

163 async def _apply_cluster_header( 

164 self, 

165 request: StarletteRequest, 

166 response: HTMLResponse, 

167 ) -> HTMLResponse: 

168 """Drop the page's own inline title/description block. 

169 

170 Cluster pages receive a single top-level header rendered above 

171 the whole center layout (see ``_cluster_header_html``), so the 

172 page's inline header is removed. Only applies to pages living 

173 inside a cluster center (e.g. ``/admin/infrastructure/...``). 

174 """ 

175 state = getattr(request, "app", None) 

176 groups = ( 

177 getattr(state.state, "assembler_groups", None) 

178 if state and hasattr(state, "state") 

179 else None 

180 ) 

181 if not is_cluster_path(request.url.path, cluster_items(groups)): 

182 return response 

183 

184 content = ( 

185 response.body.decode() 

186 if isinstance(response.body, bytes) 

187 else str(response.body) 

188 ) 

189 return HTMLResponse(_PAGE_HEADER_RE.sub("", content, count=1)) 

190 

191 async def _wrap_in_shell( 

192 self, 

193 request: StarletteRequest, 

194 response: HTMLResponse, 

195 ) -> HTMLResponse: 

196 from pathlib import Path 

197 

198 from starlette.templating import Jinja2Templates 

199 

200 from lexigram.admin.engine.renderer import resolve_admin_nav 

201 from lexigram.admin.ui.templates.shell import AdminShell 

202 from lexigram.ui import raw, render_to_string 

203 

204 content = ( 

205 response.body.decode() 

206 if isinstance(response.body, bytes) 

207 else str(response.body) 

208 ) 

209 

210 title = self._page_cls.__name__.removesuffix("Page") 

211 

212 user = ( 

213 getattr(request.state, "user", None) if hasattr(request, "state") else None 

214 ) 

215 nav_items, system_menu_items, secondary_nav = resolve_admin_nav(request) 

216 state = getattr(request, "app", None) 

217 groups = ( 

218 getattr(state.state, "assembler_groups", None) 

219 if state and hasattr(state, "state") 

220 else None 

221 ) 

222 is_cluster = is_cluster_path(request.url.path, cluster_items(groups)) 

223 if secondary_nav: 

224 from lexigram.admin.ui.organisms.secondary_nav import ClusterLayout 

225 

226 content = render_to_string( 

227 ClusterLayout(items=secondary_nav, content=raw(content)) 

228 ) 

229 if is_cluster: 

230 content = _cluster_header_html() + content 

231 

232 breadcrumbs: list[dict[str, str]] | None = None 

233 if secondary_nav and is_cluster: 

234 breadcrumbs = [ 

235 {"label": "Home", "url": "/admin/"}, 

236 {"label": CLUSTER_LABEL, "url": CLUSTER_URL}, 

237 ] 

238 path = request.url.path 

239 for item in secondary_nav: 

240 item_href = item.get("href", "") 

241 if path == item_href: 

242 breadcrumbs.append({"label": item.get("label", ""), "url": ""}) 

243 title = item.get("label", title) 

244 break 

245 child = next( 

246 (c for c in item.get("children", []) if path == c.get("href", "")), 

247 None, 

248 ) 

249 if child is not None: 

250 breadcrumbs.append( 

251 {"label": item.get("label", ""), "url": item_href} 

252 ) 

253 breadcrumbs.append({"label": child.get("label", ""), "url": ""}) 

254 title = child.get("label", title) 

255 break 

256 

257 theme_css = "" 

258 try: 

259 from lexigram.admin.theme.service import AdminThemeService 

260 

261 service = AdminThemeService( 

262 primary_color=await _resolve_primary_color(self._container) 

263 ) 

264 theme_css = service.generate_theme_css() 

265 except Exception: # noqa: BLE001, S110 — non-fatal 

266 pass 

267 

268 from lexigram.admin.navigation.manager import NavigationManager 

269 

270 user_menu_items: list[dict[str, str | None]] = ( 

271 NavigationManager(request).user_menu_items() if request is not None else [] 

272 ) 

273 

274 branding: dict[str, str] = {} 

275 try: 

276 from lexigram.admin.multitenancy.adapter import resolve_tenant_id 

277 from lexigram.admin.services.settings_service import ( 

278 resolve_admin_settings_service, 

279 ) 

280 

281 container = ( 

282 getattr(request.state, "root_container", None) 

283 or getattr(request.state, "container", None) 

284 or getattr(request.app.state, "container", None) 

285 or self._container 

286 ) 

287 settings_service = await resolve_admin_settings_service(container) 

288 if settings_service is not None: 

289 tenant = await resolve_tenant_id(request, default="default") 

290 overrides = await settings_service.get_all(tenant) 

291 for field in ("primary_color", "site_name", "logo_url", "dark_mode"): 

292 value = overrides.get(field) or overrides.get( 

293 f"admin.branding.{field}" 

294 ) 

295 if value: 

296 branding[field] = value 

297 if branding.get("primary_color"): 

298 from lexigram.admin.theme.service import AdminThemeService 

299 

300 theme_css = AdminThemeService( 

301 primary_color=branding["primary_color"] 

302 ).generate_theme_css() 

303 except Exception: # noqa: BLE001, S110 — non-fatal 

304 pass 

305 

306 shell = AdminShell( 

307 content=content, 

308 title=title, 

309 user=user, 

310 nav_items=nav_items, 

311 system_menu_items=system_menu_items, 

312 user_menu_items=user_menu_items, 

313 breadcrumbs=breadcrumbs, 

314 theme_css=theme_css, 

315 **cast( 

316 "Any", 

317 { 

318 k: v 

319 for k, v in branding.items() 

320 if k in ("dark_mode", "site_name", "logo_url") 

321 }, 

322 ), 

323 ) 

324 shell_html = render_to_string(shell) 

325 

326 templates_dir = Path(__file__).resolve().parent.parent / "views" / "templates" 

327 templates = Jinja2Templates(directory=str(templates_dir)) 

328 return templates.TemplateResponse( 

329 request, 

330 "admin_shell.html", 

331 context={ 

332 "content": Markup(shell_html), # noqa: S704 — framework-composed trusted HTML 

333 "title": title, 

334 "dark_mode": branding.get("dark_mode", ""), 

335 }, 

336 ) 

337 

338 async def _resolve_page(self) -> Any: 

339 """Resolve page instance from container. 

340 

341 Uses ``container.call(cls.__init__)`` to resolve each constructor 

342 parameter from the DI container, then constructs the instance 

343 manually. This is necessary because ``get_type_hints(cls)`` 

344 returns an empty dict for classes with ``from __future__ import 

345 annotations`` (PEP 563), so ``container.call(cls)`` cannot 

346 discover parameter types. 

347 """ 

348 init_method = self._page_cls.__init__ # type: ignore[misc] 

349 sig = inspect.signature(init_method) 

350 hints = get_type_hints(init_method) 

351 kwargs: dict[str, Any] = {} 

352 for name, param in sig.parameters.items(): 

353 if name == "self": 

354 continue 

355 if param.kind in ( 

356 inspect.Parameter.VAR_POSITIONAL, 

357 inspect.Parameter.VAR_KEYWORD, 

358 ): 

359 continue 

360 param_type = hints.get(name) 

361 if param_type is not None: 

362 try: 

363 resolution_target = _strip_optional(param_type) 

364 kwargs[name] = await self._container.resolve(resolution_target) 

365 continue 

366 except UnresolvableDependencyError: 

367 pass 

368 if param.default is not inspect.Parameter.empty: 

369 kwargs[name] = param.default 

370 elif param_type is not None: 

371 raise UnresolvableDependencyError( 

372 f"Cannot resolve parameter {name!r} for " 

373 f"{self._page_cls.__name__}: type {param_type} not registered.", 

374 ) 

375 else: 

376 raise UnresolvableDependencyError( 

377 f"Cannot resolve parameter {name!r} for " 

378 f"{self._page_cls.__name__}: no type hint and no default.", 

379 ) 

380 return self._page_cls(**kwargs) 

381 

382 

383async def _placeholder_page( 

384 request: Any, 

385 container: Any | None = None, 

386) -> HTMLResponse: 

387 """Placeholder for admin pages without an implemented handler. 

388 

389 For HTMX requests returns only the content fragment (no shell) so 

390 the sidebar/topbar from the existing page stays intact. For direct 

391 navigation returns the full admin layout. 

392 

393 Args: 

394 request: Starlette request. 

395 container: Optional resolver for theme settings. 

396 """ 

397 content = ( 

398 '<div class="flex items-center justify-center h-64">' 

399 '<div class="text-center">' 

400 '<h2 class="text-xl font-semibold text-muted-foreground">Under Construction</h2>' 

401 '<p class="text-muted-foreground mt-2">This page has not been implemented yet.</p>' 

402 "</div></div>" 

403 ) 

404 

405 try: 

406 from lexigram.admin.engine.renderer import resolve_admin_nav 

407 

408 nav_items, system_menu_items, secondary_nav = resolve_admin_nav(request) 

409 except Exception: # noqa: BLE001 — non-fatal 

410 nav_items, system_menu_items, secondary_nav = [], [], None 

411 

412 if secondary_nav: 

413 from lexigram.admin.ui.organisms.secondary_nav import ClusterLayout 

414 from lexigram.ui import raw, render_to_string 

415 

416 content = render_to_string( 

417 ClusterLayout(items=secondary_nav, content=raw(content)) 

418 ) 

419 

420 is_htmx = wants_fragment(request) 

421 

422 if is_htmx: 

423 return HTMLResponse(content) 

424 

425 try: 

426 from pathlib import Path 

427 

428 from starlette.templating import Jinja2Templates 

429 

430 from lexigram.admin.ui.templates.shell import AdminShell 

431 from lexigram.ui import render_to_string 

432 

433 user = ( 

434 getattr(request.state, "user", None) if hasattr(request, "state") else None 

435 ) 

436 

437 from lexigram.admin.navigation.manager import NavigationManager 

438 

439 user_menu_items = ( 

440 NavigationManager(request).user_menu_items(include_plugins=False) 

441 if request is not None 

442 else [] 

443 ) 

444 

445 theme_css = "" 

446 try: 

447 from lexigram.admin.theme.service import AdminThemeService 

448 

449 service = AdminThemeService( 

450 primary_color=( 

451 await _resolve_primary_color(container) 

452 if container is not None 

453 else _DEFAULT_PRIMARY_COLOR 

454 ) 

455 ) 

456 theme_css = service.generate_theme_css() 

457 except Exception: # noqa: BLE001, S110 — non-fatal 

458 pass 

459 

460 shell = AdminShell( 

461 content=content, 

462 title="Under Construction", 

463 user=user, 

464 nav_items=nav_items, 

465 system_menu_items=system_menu_items, 

466 user_menu_items=user_menu_items, 

467 theme_css=theme_css, 

468 ) 

469 shell_html = render_to_string(shell) 

470 

471 templates_dir = Path(__file__).resolve().parent.parent / "views" / "templates" 

472 templates = Jinja2Templates(directory=str(templates_dir)) 

473 return templates.TemplateResponse( 

474 request, 

475 "admin_shell.html", 

476 context={ 

477 "content": shell_html, 

478 "title": "Under Construction", 

479 "dark_mode": "", 

480 }, 

481 ) 

482 except Exception: 

483 return HTMLResponse(content) 

484 

485 

486def _resolve_handler(handler: Any) -> Any: 

487 """Resolve a string dotted-path handler to the actual callable.""" 

488 if not isinstance(handler, str): 

489 return handler 

490 try: 

491 module_path, _, func_name = handler.partition(":") 

492 mod = __import__(module_path, fromlist=[func_name]) 

493 resolved = getattr(mod, func_name, None) 

494 if resolved is None: 

495 logger.warning("handler_import_failed", handler=handler) 

496 return resolved 

497 except Exception: # noqa: BLE001 

498 logger.warning("handler_import_failed", handler=handler, exc_info=True) 

499 return None 

500 

501 

502class StructuredPageHandler: 

503 """Wrap management page handlers so only ``PageContent`` reaches the browser. 

504 

505 Starlette treats class endpoints as ASGI apps (``__call__(scope, receive, 

506 send)``), so this wrapper builds a ``StarletteRequest`` from the ASGI scope 

507 before delegating to the page handler. 

508 

509 Any other return (str, HTMLResponse, template, ...) is a contract 

510 violation: it is logged and replaced with an error page. 

511 """ 

512 

513 def __init__(self, handler: Any) -> None: 

514 self._handler = handler 

515 

516 async def __call__(self, scope: Any, receive: Any, send: Any) -> None: 

517 from lexigram.admin.dashboard.page_renderer import render_page_content 

518 

519 request = StarletteRequest(scope, receive, send) 

520 handler = self._handler 

521 callable_handler = handler.handle if hasattr(handler, "handle") else handler 

522 result = await callable_handler(request) 

523 if isinstance(result, PageContent): 

524 response = render_page_content(result) 

525 else: 

526 logger.error( 

527 "page_contract_violation", 

528 handler=type(self._handler).__name__, 

529 result_type=type(result).__name__, 

530 ) 

531 response = render_page_content( 

532 PageContent( 

533 title="Page Contract Violation", 

534 body=EmptyContent( 

535 title="Invalid Page Content", 

536 message=( 

537 "The page handler returned raw HTML. " 

538 "Convert it to PageContent." 

539 ), 

540 icon="alert-triangle", 

541 ), 

542 ) 

543 ) 

544 await response(scope, receive, send)