Coverage for src / lexigram / admin / engine / renderer.py: 18%

141 statements  

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

1"""Admin page renderer for lexigram-admin. 

2 

3Provides the AdminRenderer class that handles rendering admin pages 

4with proper layouts, navigation, and HTMX support. 

5""" 

6 

7from __future__ import annotations 

8 

9from dataclasses import dataclass, field 

10from typing import TYPE_CHECKING, Any 

11 

12from starlette.requests import Request 

13from starlette.responses import HTMLResponse 

14 

15if TYPE_CHECKING: 

16 from collections.abc import Callable 

17 

18try: 

19 from markupsafe import Markup 

20except ImportError: 

21 Markup = str # type: ignore[misc,assignment] 

22 

23 

24def resolve_admin_nav(request: Any) -> tuple[list, list, list | None]: 

25 """Resolve nav items, system menu items, and cluster secondary nav. 

26 

27 Merges NavItemBuilder resource items with NavigationAssembler contributor 

28 navigation items. Active-state detection is computed per-request based on 

29 the current URL path. 

30 

31 Cluster groups (e.g. infrastructure) are collapsed in the primary sidebar 

32 into a single landing entry; when the current path belongs to the cluster, 

33 the secondary nav for its center is returned as the third element. 

34 

35 Duplicates are removed at three levels: 

36 1. Group header dedup — assembler group headers that match builder 

37 group headers are skipped. 

38 2. Label dedup — assembler items with the same label+group as a 

39 builder item are skipped (handles URL mismatches between 

40 namespaced resource URLs and hardcoded contributor URLs). 

41 3. URL dedup — any remaining item with the same href as an already- 

42 included item is skipped (catches all same-URL duplicates). 

43 

44 Args: 

45 request: The current request (Starlette Request). 

46 

47 Returns: 

48 A tuple of (nav_items, system_menu_items, secondary_nav). 

49 """ 

50 nav_builder = None 

51 assembler_nav_items: list[dict] = [] 

52 assembler_groups: dict | None = None 

53 state = getattr(request, "app", None) if request else None 

54 if state and hasattr(state, "state"): 

55 nav_builder = getattr(state.state, "nav_builder", None) 

56 assembler_nav_items = getattr(state.state, "assembler_nav_items", None) or [] 

57 assembler_groups = getattr(state.state, "assembler_groups", None) or None 

58 

59 if nav_builder is None: 

60 return [], [], None 

61 

62 current_path: str | None = ( 

63 str(request.url.path) if request and hasattr(request, "url") else None 

64 ) 

65 

66 from lexigram.admin.navigation.clusters import ( 

67 build_secondary_nav, 

68 cluster_items, 

69 collapse_cluster_in_primary, 

70 is_cluster_path, 

71 ) 

72 

73 cluster_nav: list | None = None 

74 items = cluster_items(assembler_groups) 

75 if items: 

76 if is_cluster_path(current_path, items): 

77 cluster_nav = build_secondary_nav(items, current_path) 

78 assembler_nav_items = collapse_cluster_in_primary( 

79 assembler_nav_items, 

80 current_path, 

81 items, 

82 ) 

83 

84 # Build from NavItemBuilder with active-state detection 

85 builder_items = nav_builder.build_nav_items(current_path=current_path) 

86 system_menu_items = nav_builder.build_system_menu_items() 

87 

88 # Start with builder items, tracking what we've already seen for dedup 

89 merged = list(builder_items) 

90 seen_hrefs: set[str] = set() 

91 group_labels: dict[str, set[str]] = {} 

92 current_group = "" 

93 

94 for item in merged: 

95 if not isinstance(item, dict): 

96 continue 

97 if item.get("is_group"): 

98 current_group = item.get("label", "") or "" 

99 group_labels.setdefault(current_group, set()) 

100 else: 

101 href = (item.get("href", "") or "").strip() 

102 if href: 

103 seen_hrefs.add(href) 

104 label = (item.get("label", "") or "").strip() 

105 if label: 

106 group_labels.setdefault(current_group, set()).add(label) 

107 

108 # Collect top-level items (empty group) from assembler contributions 

109 # These are items emitted before any group header — inserted at the 

110 # very front of merged, before builder items. 

111 top_items: list[dict] = [] 

112 for item in assembler_nav_items: 

113 if not isinstance(item, dict): 

114 continue 

115 if item.get("is_group"): 

116 break 

117 href = (item.get("href", "") or "").strip() 

118 label = (item.get("label", "") or "").strip() 

119 if current_path is not None and href: 

120 item["active"] = current_path == href or current_path.startswith(href + "/") 

121 if href: 

122 seen_hrefs.add(href) 

123 if label: 

124 group_labels.setdefault("", set()).add(label) 

125 top_items.append(item) 

126 

127 merged = top_items + merged 

128 

129 # Merge remaining assembler contributions with full dedup 

130 current_group = "" 

131 for item in assembler_nav_items: 

132 if not isinstance(item, dict): 

133 merged.append(item) 

134 continue 

135 

136 if item.get("is_group"): 

137 group_label = (item.get("label", "") or "").strip() 

138 current_group = group_label 

139 if group_label in group_labels: 

140 continue 

141 group_labels.setdefault(current_group, set()) 

142 merged.append(item) 

143 continue 

144 

145 href = (item.get("href", "") or "").strip() 

146 label = (item.get("label", "") or "").strip() 

147 

148 if href and href in seen_hrefs: 

149 continue 

150 if label and label in group_labels.get(current_group, set()): 

151 continue 

152 

153 item["active"] = ( 

154 current_path is not None 

155 and href 

156 and (current_path == href or current_path.startswith(href + "/")) 

157 ) 

158 

159 if href: 

160 seen_hrefs.add(href) 

161 if label: 

162 group_labels.setdefault(current_group, set()).add(label) 

163 merged.append(item) 

164 

165 return merged, system_menu_items, cluster_nav 

166 

167 

168@dataclass 

169class AdminRendererConfig: 

170 """Configuration for AdminRenderer.""" 

171 

172 # Site branding 

173 site_name: str = "Lexigram Admin" 

174 site_logo: str | None = None 

175 

176 # Layout options 

177 show_sidebar: bool = True 

178 show_breadcrumbs: bool = True 

179 

180 # Theme 

181 primary_color: str = "#6b7280" 

182 theme: str = "default" 

183 

184 # Custom CSS/JS 

185 extra_css: list[str] = field(default_factory=list) 

186 extra_js: list[str] = field(default_factory=list) 

187 

188 # Footer 

189 footer_text: str = "" 

190 

191 

192class AdminRenderer: 

193 """Renderer for admin pages. 

194 

195 Handles: 

196 - Wrapping content in admin layout 

197 - Breadcrumb navigation 

198 - Background context (user info, navigation) 

199 - HTMX partial rendering support 

200 

201 Usage: 

202 renderer = AdminRenderer(config) 

203 response = renderer.render_page(content, request, title="Dashboard") 

204 """ 

205 

206 def __init__( 

207 self, 

208 config: AdminRendererConfig | None = None, 

209 layout_builder: Callable[..., str | Markup] | None = None, 

210 ): 

211 """Initialize renderer. 

212 

213 Args: 

214 config: Renderer configuration 

215 layout_builder: Custom layout builder function 

216 """ 

217 self.config = config or AdminRendererConfig() 

218 self._layout_builder = layout_builder 

219 

220 def render_page( 

221 self, 

222 content: str | Markup | Any, 

223 request: Request | None = None, 

224 title: str = "", 

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

226 **extra_context: Any, 

227 ) -> HTMLResponse: 

228 """Render an admin page with layout. 

229 

230 Args: 

231 content: Page content (HTML string or component) 

232 request: Current request (for user context) 

233 title: Page title 

234 breadcrumbs: Breadcrumb navigation items 

235 **extra_context: Additional context for layout 

236 

237 Returns: 

238 HTMLResponse with rendered page 

239 """ 

240 from lexigram.admin.navigation.clusters import ( 

241 CLUSTER_ICON, 

242 CLUSTER_LABEL, 

243 CLUSTER_URL, 

244 ) 

245 from lexigram.admin.state.context import AdminContextManager 

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

247 from lexigram.ui.core.base import render_to_string 

248 

249 user = getattr(request.state, "user", None) if request else None 

250 

251 nav_items, system_menu_items, _ = resolve_admin_nav(request) 

252 

253 # Read flash messages from request context and consume them 

254 ctx = AdminContextManager.get_context() 

255 flash_messages: list[dict[str, str]] = [] 

256 if ctx: 

257 flash_messages = list(ctx.flash_messages) 

258 ctx.flash_messages.clear() 

259 

260 # Generate theme CSS from config primary_color (overridable per request) 

261 theme_css = "" 

262 try: 

263 from lexigram.admin.theme.service import AdminThemeService 

264 

265 primary_color = ( 

266 extra_context.get("primary_color") 

267 or self.config.primary_color 

268 or "#6b7280" 

269 ) 

270 service = AdminThemeService(primary_color=primary_color) 

271 theme_css = service.generate_theme_css() 

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

273 pass 

274 

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

276 { 

277 "label": CLUSTER_LABEL, 

278 "href": CLUSTER_URL, 

279 "icon": CLUSTER_ICON, 

280 }, 

281 { 

282 "label": "Settings", 

283 "href": "/admin/settings", 

284 "icon": "settings", 

285 }, 

286 ] 

287 

288 site_name = extra_context.get("site_name") or self.config.site_name 

289 logo_url = extra_context.get("logo_url") or "" 

290 favicon_url = extra_context.get("favicon_url") or "" 

291 dark_mode = extra_context.get("dark_mode") or "" 

292 

293 shell = AdminShell( 

294 content=content, 

295 title=title, 

296 user=user, 

297 nav_items=nav_items, 

298 user_menu_items=user_menu_items, 

299 system_menu_items=system_menu_items, 

300 breadcrumbs=breadcrumbs, 

301 flash_messages=flash_messages, 

302 theme_css=theme_css, 

303 site_name=site_name, 

304 logo_url=logo_url, 

305 dark_mode=dark_mode, 

306 ) 

307 

308 # Prepare templates 

309 from pathlib import Path 

310 

311 from starlette.templating import Jinja2Templates 

312 

313 # Resolve templates directory relative to this file 

314 # lexigram/admin/engine/renderer.py -> lexigram/admin/views/templates 

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

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

317 

318 shell_html = render_to_string(shell) 

319 

320 # Pass CSRF token to template for hx-headers on body 

321 csrf_token = getattr(request.state, "csrf_token", None) if request else None 

322 

323 # Render using template 

324 return templates.TemplateResponse( 

325 request, # type: ignore[arg-type] 

326 "admin_shell.html", 

327 context={ 

328 "content": shell_html, 

329 "title": title, 

330 "site_name": site_name, 

331 "favicon_url": favicon_url, 

332 "dark_mode": dark_mode, 

333 "csrf_token": csrf_token, 

334 }, 

335 ) 

336 

337 def render_partial( 

338 self, 

339 content: str | Markup | Any, 

340 headers: dict[str, str] | None = None, 

341 ) -> HTMLResponse: 

342 """Render a partial for HTMX requests. 

343 

344 Args: 

345 content: Partial content 

346 headers: Optional HTMX response headers 

347 

348 Returns: 

349 HTMLResponse with partial content 

350 """ 

351 if hasattr(content, "__html__"): 

352 content_str = str(content.__html__()) 

353 else: 

354 content_str = str(content) 

355 

356 return HTMLResponse(content_str, headers=headers or {})