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

71 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-24 23:39 +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 single landing entries; when the current path belongs to a cluster 

33 center, the secondary nav for that center is returned as the third 

34 element. 

35 

36 Duplicates are removed at three levels: 

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

38 group headers are skipped. 

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

40 builder item are skipped (handles URL mismatches between 

41 namespaced resource URLs and hardcoded contributor URLs). 

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

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

44 

45 Args: 

46 request: The current request (Starlette Request). 

47 

48 Returns: 

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

50 """ 

51 from lexigram.admin.navigation.manager import NavigationManager 

52 

53 return NavigationManager(request).resolve_nav() 

54 

55 

56@dataclass 

57class AdminRendererConfig: 

58 """Configuration for AdminRenderer.""" 

59 

60 # Site branding 

61 site_name: str = "Lexigram Admin" 

62 site_logo: str | None = None 

63 

64 # Layout options 

65 show_sidebar: bool = True 

66 show_breadcrumbs: bool = True 

67 

68 # Theme 

69 primary_color: str = "#6b7280" 

70 theme: str = "default" 

71 

72 # Custom CSS/JS 

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

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

75 

76 # Footer 

77 footer_text: str = "" 

78 

79 

80class AdminRenderer: 

81 """Renderer for admin pages. 

82 

83 Handles: 

84 - Wrapping content in admin layout 

85 - Breadcrumb navigation 

86 - Background context (user info, navigation) 

87 - HTMX partial rendering support 

88 

89 Usage: 

90 renderer = AdminRenderer(config) 

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

92 """ 

93 

94 def __init__( 

95 self, 

96 config: AdminRendererConfig | None = None, 

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

98 ): 

99 """Initialize renderer. 

100 

101 Args: 

102 config: Renderer configuration 

103 layout_builder: Custom layout builder function 

104 """ 

105 self.config = config or AdminRendererConfig() 

106 self._layout_builder = layout_builder 

107 

108 def render_page( 

109 self, 

110 content: str | Markup | Any, 

111 request: Request | None = None, 

112 title: str = "", 

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

114 **extra_context: Any, 

115 ) -> HTMLResponse: 

116 """Render an admin page with layout. 

117 

118 Args: 

119 content: Page content (HTML string or component) 

120 request: Current request (for user context) 

121 title: Page title 

122 breadcrumbs: Breadcrumb navigation items 

123 **extra_context: Additional context for layout 

124 

125 Returns: 

126 HTMLResponse with rendered page 

127 """ 

128 from lexigram.admin.navigation.manager import NavigationManager 

129 from lexigram.admin.state.context import AdminContextManager 

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

131 from lexigram.ui.core.base import render_to_string 

132 

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

134 

135 nav_items, system_menu_items, _ = resolve_admin_nav(request) 

136 

137 # Read flash messages from request context and consume them 

138 ctx = AdminContextManager.get_context() 

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

140 if ctx: 

141 flash_messages = list(ctx.flash_messages) 

142 ctx.flash_messages.clear() 

143 

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

145 theme_css = "" 

146 try: 

147 from lexigram.admin.theme.service import AdminThemeService 

148 

149 primary_color = ( 

150 extra_context.get("primary_color") 

151 or self.config.primary_color 

152 or "#6b7280" 

153 ) 

154 service = AdminThemeService(primary_color=primary_color) 

155 theme_css = service.generate_theme_css() 

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

157 pass 

158 

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

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

161 ) 

162 

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

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

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

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

167 current_tenant_id = extra_context.get("current_tenant_id") 

168 current_tenant_name = extra_context.get("current_tenant_name") or "" 

169 tenant_list = extra_context.get("tenant_list") or [] 

170 tenant_csrf_token = extra_context.get("tenant_csrf_token") 

171 impersonation_active = bool(extra_context.get("impersonation_active")) 

172 impersonation_target_id = str( 

173 extra_context.get("impersonation_target_id") or "" 

174 ) 

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

176 

177 shell = AdminShell( 

178 content=content, 

179 title=title, 

180 user=user, 

181 nav_items=nav_items, 

182 user_menu_items=user_menu_items, 

183 system_menu_items=system_menu_items, 

184 breadcrumbs=breadcrumbs, 

185 flash_messages=flash_messages, 

186 theme_css=theme_css, 

187 site_name=site_name, 

188 logo_url=logo_url, 

189 dark_mode=dark_mode, 

190 current_tenant_id=current_tenant_id, 

191 current_tenant_name=current_tenant_name, 

192 tenant_list=tenant_list, 

193 tenant_csrf_token=tenant_csrf_token, 

194 impersonation_active=impersonation_active, 

195 impersonation_target_id=impersonation_target_id, 

196 csrf_token=csrf_token or "", 

197 ) 

198 

199 # Prepare templates 

200 from pathlib import Path 

201 

202 from starlette.templating import Jinja2Templates 

203 

204 # Resolve templates directory relative to this file 

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

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

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

208 

209 shell_html = Markup(render_to_string(shell)) 

210 

211 # Render using template 

212 return templates.TemplateResponse( 

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

214 "admin_shell.html", 

215 context={ 

216 "content": shell_html, 

217 "title": title, 

218 "site_name": site_name, 

219 "favicon_url": favicon_url, 

220 "dark_mode": dark_mode, 

221 "csrf_token": csrf_token, 

222 }, 

223 ) 

224 

225 def render_partial( 

226 self, 

227 content: str | Markup | Any, 

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

229 ) -> HTMLResponse: 

230 """Render a partial for HTMX requests. 

231 

232 Args: 

233 content: Partial content 

234 headers: Optional HTMX response headers 

235 

236 Returns: 

237 HTMLResponse with partial content 

238 """ 

239 if hasattr(content, "__html__"): 

240 content_str = str(content.__html__()) 

241 else: 

242 content_str = str(content) 

243 

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