Coverage for src / lexigram / admin / dashboard / route_integrator.py: 14%

268 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-08-13 22:14 +0800

1from __future__ import annotations 

2 

3from collections.abc import Sequence 

4import inspect 

5import types 

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

7 

8from starlette.requests import Request as StarletteRequest 

9from starlette.responses import HTMLResponse 

10 

11from lexigram.admin.navigation.clusters import ( 

12 CLUSTER_GROUP, 

13 CLUSTER_ICON, 

14 CLUSTER_LABEL, 

15 CLUSTER_URL, 

16 cluster_child_href, 

17) 

18from lexigram.admin.state.context import wants_fragment 

19from lexigram.contracts.admin.types import ( 

20 ManagementPageDefinition, 

21 SettingsPanelDefinition, 

22) 

23from lexigram.contracts.exceptions import UnresolvableDependencyError 

24from lexigram.logging import get_logger 

25 

26if TYPE_CHECKING: 

27 from lexigram.admin.core.routing import AdminRouter 

28 from lexigram.admin.dashboard.naming_policy import NamingPolicy 

29 from lexigram.contracts.admin.contributor import BaseAdminContributor 

30 from lexigram.contracts.core.di import ContainerResolverProtocol 

31 

32logger = get_logger(__name__) 

33 

34 

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

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

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

38 origin = get_origin(tp) 

39 if origin is types.UnionType: 

40 args = get_args(tp) 

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

42 if len(non_none) == 1: 

43 return non_none[0] 

44 return tp 

45 

46 

47_DEFAULT_PRIMARY_COLOR = "#6b7280" 

48 

49 

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

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

52 

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

54 available. 

55 """ 

56 try: 

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

58 

59 registry = await container.resolve( 

60 ConfigRegistry, 

61 bypass_visibility=True, 

62 ) 

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

64 color = values.get("primary_color") 

65 if color: 

66 return str(color) 

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

68 logger.exception("admin.theme_overrides_failed") 

69 return _DEFAULT_PRIMARY_COLOR 

70 

71 

72class AdminPageHandler: 

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

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

75 

76 Starlette treats class endpoints as ASGI apps — it calls 

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

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

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

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

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

82 container at request time. 

83 """ 

84 

85 def __init__( 

86 self, 

87 page_cls: type, 

88 container: ContainerResolverProtocol, 

89 ) -> None: 

90 self._page_cls = page_cls 

91 self._container = container 

92 

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

94 request = StarletteRequest(scope, receive, send) 

95 try: 

96 instance = await self._resolve_page() 

97 response = await instance.handle(request) 

98 except Exception: 

99 logger.exception( 

100 "admin_page_handler_error", 

101 page=self._page_cls.__name__, 

102 ) 

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

104 

105 try: 

106 is_htmx = wants_fragment(request) 

107 except KeyError: 

108 is_htmx = False 

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

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

111 

112 await response(scope, receive, send) 

113 

114 async def _wrap_in_shell( 

115 self, 

116 request: StarletteRequest, 

117 response: HTMLResponse, 

118 ) -> HTMLResponse: 

119 from pathlib import Path 

120 

121 from starlette.templating import Jinja2Templates 

122 

123 from lexigram.admin.engine.renderer import resolve_admin_nav 

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

125 from lexigram.ui import raw, render_to_string 

126 

127 content = ( 

128 response.body.decode() 

129 if isinstance(response.body, bytes) 

130 else str(response.body) 

131 ) 

132 

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

134 

135 user = ( 

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

137 ) 

138 nav_items, system_menu_items, secondary_nav = resolve_admin_nav(request) 

139 if secondary_nav: 

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

141 

142 content = render_to_string( 

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

144 ) 

145 

146 theme_css = "" 

147 try: 

148 from lexigram.admin.theme.service import AdminThemeService 

149 

150 service = AdminThemeService( 

151 primary_color=await _resolve_primary_color(self._container) 

152 ) 

153 theme_css = service.generate_theme_css() 

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

155 pass 

156 

157 user_menu_items: list[dict[str, str]] = [ 

158 { 

159 "label": CLUSTER_LABEL, 

160 "href": CLUSTER_URL, 

161 "icon": CLUSTER_ICON, 

162 }, 

163 { 

164 "label": "Settings", 

165 "href": "/admin/settings", 

166 "icon": "settings", 

167 }, 

168 ] 

169 

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

171 try: 

172 from lexigram.admin.multitenancy.adapter import resolve_tenant_id 

173 from lexigram.admin.services.settings_service import ( 

174 resolve_admin_settings_service, 

175 ) 

176 

177 container = ( 

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

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

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

181 or self._container 

182 ) 

183 settings_service = await resolve_admin_settings_service(container) 

184 if settings_service is not None: 

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

186 overrides = await settings_service.get_all(tenant) 

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

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

189 f"admin.branding.{field}" 

190 ) 

191 if value: 

192 branding[field] = value 

193 if branding.get("primary_color"): 

194 from lexigram.admin.theme.service import AdminThemeService 

195 

196 theme_css = AdminThemeService( 

197 primary_color=branding["primary_color"] 

198 ).generate_theme_css() 

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

200 pass 

201 

202 shell = AdminShell( 

203 content=content, 

204 title=title, 

205 user=user, 

206 nav_items=nav_items, 

207 system_menu_items=system_menu_items, 

208 user_menu_items=user_menu_items, 

209 theme_css=theme_css, 

210 **{ 

211 k: v 

212 for k, v in branding.items() 

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

214 }, 

215 ) 

216 shell_html = render_to_string(shell) 

217 

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

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

220 return templates.TemplateResponse( 

221 request, 

222 "admin_shell.html", 

223 context={ 

224 "content": shell_html, 

225 "title": title, 

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

227 }, 

228 ) 

229 

230 async def _resolve_page(self) -> Any: 

231 """Resolve page instance from container. 

232 

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

234 parameter from the DI container, then constructs the instance 

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

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

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

238 discover parameter types. 

239 """ 

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

241 sig = inspect.signature(init_method) 

242 hints = get_type_hints(init_method) 

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

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

245 if name == "self": 

246 continue 

247 if param.kind in ( 

248 inspect.Parameter.VAR_POSITIONAL, 

249 inspect.Parameter.VAR_KEYWORD, 

250 ): 

251 continue 

252 param_type = hints.get(name) 

253 if param_type is not None: 

254 try: 

255 resolution_target = _strip_optional(param_type) 

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

257 continue 

258 except UnresolvableDependencyError: 

259 pass 

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

261 kwargs[name] = param.default 

262 elif param_type is not None: 

263 raise UnresolvableDependencyError( 

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

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

266 ) 

267 else: 

268 raise UnresolvableDependencyError( 

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

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

271 ) 

272 return self._page_cls(**kwargs) 

273 

274 

275async def _placeholder_page( 

276 request: Any, 

277 container: Any | None = None, 

278) -> HTMLResponse: 

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

280 

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

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

283 navigation returns the full admin layout. 

284 

285 Args: 

286 request: Starlette request. 

287 container: Optional resolver for theme settings. 

288 """ 

289 content = ( 

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

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

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

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

294 "</div></div>" 

295 ) 

296 

297 try: 

298 from lexigram.admin.engine.renderer import resolve_admin_nav 

299 

300 nav_items, system_menu_items, secondary_nav = resolve_admin_nav(request) 

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

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

303 

304 if secondary_nav: 

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

306 from lexigram.ui import raw, render_to_string 

307 

308 content = render_to_string( 

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

310 ) 

311 

312 is_htmx = wants_fragment(request) 

313 

314 if is_htmx: 

315 return HTMLResponse(content) 

316 

317 try: 

318 from pathlib import Path 

319 

320 from starlette.templating import Jinja2Templates 

321 

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

323 from lexigram.ui import render_to_string 

324 

325 user = ( 

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

327 ) 

328 

329 user_menu_items = [ 

330 { 

331 "label": CLUSTER_LABEL, 

332 "href": CLUSTER_URL, 

333 "icon": CLUSTER_ICON, 

334 }, 

335 { 

336 "label": "Settings", 

337 "href": "/admin/settings", 

338 "icon": "settings", 

339 }, 

340 ] 

341 

342 theme_css = "" 

343 try: 

344 from lexigram.admin.theme.service import AdminThemeService 

345 

346 service = AdminThemeService( 

347 primary_color=( 

348 await _resolve_primary_color(container) 

349 if container is not None 

350 else _DEFAULT_PRIMARY_COLOR 

351 ) 

352 ) 

353 theme_css = service.generate_theme_css() 

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

355 pass 

356 

357 shell = AdminShell( 

358 content=content, 

359 title="Under Construction", 

360 user=user, 

361 nav_items=nav_items, 

362 system_menu_items=system_menu_items, 

363 user_menu_items=user_menu_items, 

364 theme_css=theme_css, 

365 ) 

366 shell_html = render_to_string(shell) 

367 

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

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

370 return templates.TemplateResponse( 

371 request, 

372 "admin_shell.html", 

373 context={ 

374 "content": shell_html, 

375 "title": "Under Construction", 

376 "dark_mode": "", 

377 }, 

378 ) 

379 except Exception: 

380 return HTMLResponse(content) 

381 

382 

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

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

385 if not isinstance(handler, str): 

386 return handler 

387 try: 

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

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

390 resolved = getattr(mod, func_name, None) 

391 if resolved is None: 

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

393 return resolved 

394 except Exception: # noqa: BLE001 

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

396 return None 

397 

398 

399def _register_pages( 

400 router: AdminRouter, 

401 naming_policy: NamingPolicy, 

402 prefix: str, 

403 pages: list[ManagementPageDefinition], 

404 container: Any = None, 

405) -> None: 

406 """Register management page routes on the admin router. 

407 

408 Routes are registered relative to the admin app mount point 

409 (the prefix is stripped before registration — the admin router 

410 already lives under the admin prefix due to ``AdminRouter.mount()``). 

411 ``registered_internal_paths`` is updated externally so that 

412 ``_ensure_nav_route`` does not create duplicate placeholders. 

413 """ 

414 # prefix is intentionally unused — routes live inside the mounted 

415 # admin app and must be relative to its mount point. 

416 for page in pages: 

417 handler = _resolve_handler(page.handler) 

418 if handler is None: 

419 continue 

420 if inspect.isclass(handler) and container is not None: 

421 handler = AdminPageHandler(handler, container) 

422 path = page.route_path 

423 if not path.startswith("/"): 

424 path = f"/{path}" 

425 ns_name = naming_policy.namespaced(page.contributor, page.name) 

426 naming_policy.register("page", ns_name) 

427 router.add_route(path=path, method="GET", handler=handler, name=ns_name) 

428 

429 

430def _register_settings( 

431 router: AdminRouter, 

432 naming_policy: NamingPolicy, 

433 prefix: str, 

434 panels: list[SettingsPanelDefinition], 

435 container: Any = None, 

436) -> None: 

437 """Register settings panel routes on the admin router. 

438 

439 Routes are registered relative to the admin app mount point 

440 (the prefix is stripped before registration — see ``_register_pages``). 

441 """ 

442 for panel in panels: 

443 handler = _resolve_handler(panel.handler) 

444 if handler is None: 

445 continue 

446 if inspect.isclass(handler) and container is not None: 

447 handler = AdminPageHandler(handler, container) 

448 path = panel.route_path 

449 if not path.startswith("/"): 

450 path = f"/{path}" 

451 ns_name = naming_policy.namespaced(panel.contributor, panel.name) 

452 naming_policy.register("panel", ns_name) 

453 router.add_route(path=path, method="GET", handler=handler, name=ns_name) 

454 

455 

456class RouteIntegrator: 

457 """Collects ``AdminRouteSpec``, ``ManagementPageDefinition``, and 

458 ``SettingsPanelDefinition`` from contributors and registers them 

459 on the router.""" 

460 

461 def __init__( 

462 self, 

463 *, 

464 router: AdminRouter, 

465 naming_policy: NamingPolicy, 

466 route_prefix: str = "", 

467 container: Any = None, 

468 ) -> None: 

469 self._router = router 

470 self._naming = naming_policy 

471 self._prefix = route_prefix 

472 self._container = container 

473 

474 def register(self, contributors: Sequence[BaseAdminContributor]) -> None: 

475 """Register each contributor's routes, management pages, and 

476 settings panels on the admin router. Nav-item URLs that don't 

477 have a corresponding handler automatically get a placeholder 

478 route so they never 404.""" 

479 registered_internal_paths: set[str] = set() 

480 

481 for c in contributors: 

482 # Routes 

483 for spec in c.get_routes(): 

484 ns_name = self._naming.namespaced(c.package_source, spec.name) 

485 self._naming.register("route", ns_name) 

486 registered_internal_paths.add(spec.path) 

487 self._router.add_route( 

488 path=spec.path, 

489 method=spec.method, 

490 handler=spec.handler, 

491 name=ns_name, 

492 ) 

493 

494 # Management pages 

495 pages = c.get_management_pages() 

496 if pages: 

497 for page in pages: 

498 internal = page.route_path 

499 if not internal.startswith("/"): 

500 internal = f"/{internal}" 

501 registered_internal_paths.add(internal) 

502 _register_pages( 

503 self._router, 

504 self._naming, 

505 self._prefix, 

506 pages, # type: ignore[arg-type] 

507 container=self._container, 

508 ) 

509 

510 # Settings panels 

511 panels = c.get_settings_panels() 

512 if panels: 

513 for panel in panels: 

514 internal = panel.route_path 

515 if not internal.startswith("/"): 

516 internal = f"/{internal}" 

517 registered_internal_paths.add(internal) 

518 _register_settings( 

519 self._router, 

520 self._naming, 

521 self._prefix, 

522 panels, # type: ignore[arg-type] 

523 container=self._container, 

524 ) 

525 

526 # Auto-register placeholder routes for nav items without handlers. 

527 for c in contributors: 

528 for item in c.get_navigation_items(): 

529 self._ensure_nav_route(item, registered_internal_paths) 

530 for child in item.children or (): 

531 self._ensure_nav_route(child, registered_internal_paths) 

532 

533 # Cluster areas are also reachable under the center namespace 

534 # (e.g. /admin/infrastructure/web), mirroring how settings 

535 # sub-pages nest below /admin/settings. Aliases share the source 

536 # route's handler — real page or placeholder alike. 

537 for c in contributors: 

538 for item in c.get_navigation_items(): 

539 self._register_cluster_alias(item) 

540 for child in item.children or (): 

541 self._register_cluster_alias(child) 

542 

543 def _ensure_nav_route( 

544 self, 

545 item: Any, 

546 registered_paths: set[str], 

547 ) -> None: 

548 """Register a placeholder route for *item* if its URL isn't covered.""" 

549 url = item.url 

550 if not url or url.startswith("http"): 

551 return 

552 

553 internal = url 

554 if self._prefix and internal.startswith(self._prefix): 

555 internal = internal[len(self._prefix) :] 

556 if not internal: 

557 internal = "/" 

558 

559 if internal in registered_paths or url in registered_paths: 

560 return 

561 

562 safe_label = item.label.lower().replace(" ", "_").replace("/", "_") 

563 self._router.add_route( 

564 path=internal, 

565 method="GET", 

566 handler=_placeholder_page, 

567 name=f"placeholder_{safe_label}", 

568 ) 

569 registered_paths.add(internal) 

570 

571 def _register_cluster_alias(self, item: Any) -> None: 

572 """Register a namespaced alias for a cluster group nav item. 

573 

574 Cluster areas live under the center namespace (``/admin/ 

575 infrastructure/web``) in addition to their contributor URL. When 

576 the source URL has a real handler, the alias reuses it; otherwise 

577 the alias falls back to the placeholder page. 

578 """ 

579 if getattr(item, "group", None) != CLUSTER_GROUP: 

580 return 

581 url = item.url 

582 if not url or url.startswith("http"): 

583 return 

584 namespaced = cluster_child_href(url) 

585 if not namespaced or namespaced == url: 

586 return 

587 

588 internal = url 

589 internal_ns = namespaced 

590 if self._prefix and internal.startswith(self._prefix): 

591 internal = internal[len(self._prefix) :] 

592 if self._prefix and internal_ns.startswith(self._prefix): 

593 internal_ns = internal_ns[len(self._prefix) :] 

594 if internal_ns == internal: 

595 return 

596 

597 safe_label = item.label.lower().replace(" ", "_").replace("/", "_") 

598 if not self._router.alias_route( 

599 internal, 

600 internal_ns, 

601 name=f"cluster_alias_{safe_label}", 

602 ): 

603 self._router.add_route( 

604 path=internal_ns, 

605 method="GET", 

606 handler=_placeholder_page, 

607 name=f"cluster_alias_{safe_label}", 

608 )