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
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-13 22:14 +0800
1"""Admin page renderer for lexigram-admin.
3Provides the AdminRenderer class that handles rendering admin pages
4with proper layouts, navigation, and HTMX support.
5"""
7from __future__ import annotations
9from dataclasses import dataclass, field
10from typing import TYPE_CHECKING, Any
12from starlette.requests import Request
13from starlette.responses import HTMLResponse
15if TYPE_CHECKING:
16 from collections.abc import Callable
18try:
19 from markupsafe import Markup
20except ImportError:
21 Markup = str # type: ignore[misc,assignment]
24def resolve_admin_nav(request: Any) -> tuple[list, list, list | None]:
25 """Resolve nav items, system menu items, and cluster secondary nav.
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.
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.
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).
44 Args:
45 request: The current request (Starlette Request).
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
59 if nav_builder is None:
60 return [], [], None
62 current_path: str | None = (
63 str(request.url.path) if request and hasattr(request, "url") else None
64 )
66 from lexigram.admin.navigation.clusters import (
67 build_secondary_nav,
68 cluster_items,
69 collapse_cluster_in_primary,
70 is_cluster_path,
71 )
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 )
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()
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 = ""
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)
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)
127 merged = top_items + merged
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
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
145 href = (item.get("href", "") or "").strip()
146 label = (item.get("label", "") or "").strip()
148 if href and href in seen_hrefs:
149 continue
150 if label and label in group_labels.get(current_group, set()):
151 continue
153 item["active"] = (
154 current_path is not None
155 and href
156 and (current_path == href or current_path.startswith(href + "/"))
157 )
159 if href:
160 seen_hrefs.add(href)
161 if label:
162 group_labels.setdefault(current_group, set()).add(label)
163 merged.append(item)
165 return merged, system_menu_items, cluster_nav
168@dataclass
169class AdminRendererConfig:
170 """Configuration for AdminRenderer."""
172 # Site branding
173 site_name: str = "Lexigram Admin"
174 site_logo: str | None = None
176 # Layout options
177 show_sidebar: bool = True
178 show_breadcrumbs: bool = True
180 # Theme
181 primary_color: str = "#6b7280"
182 theme: str = "default"
184 # Custom CSS/JS
185 extra_css: list[str] = field(default_factory=list)
186 extra_js: list[str] = field(default_factory=list)
188 # Footer
189 footer_text: str = ""
192class AdminRenderer:
193 """Renderer for admin pages.
195 Handles:
196 - Wrapping content in admin layout
197 - Breadcrumb navigation
198 - Background context (user info, navigation)
199 - HTMX partial rendering support
201 Usage:
202 renderer = AdminRenderer(config)
203 response = renderer.render_page(content, request, title="Dashboard")
204 """
206 def __init__(
207 self,
208 config: AdminRendererConfig | None = None,
209 layout_builder: Callable[..., str | Markup] | None = None,
210 ):
211 """Initialize renderer.
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
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.
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
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
249 user = getattr(request.state, "user", None) if request else None
251 nav_items, system_menu_items, _ = resolve_admin_nav(request)
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()
260 # Generate theme CSS from config primary_color (overridable per request)
261 theme_css = ""
262 try:
263 from lexigram.admin.theme.service import AdminThemeService
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
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 ]
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 ""
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 )
308 # Prepare templates
309 from pathlib import Path
311 from starlette.templating import Jinja2Templates
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))
318 shell_html = render_to_string(shell)
320 # Pass CSRF token to template for hx-headers on body
321 csrf_token = getattr(request.state, "csrf_token", None) if request else None
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 )
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.
344 Args:
345 content: Partial content
346 headers: Optional HTMX response headers
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)
356 return HTMLResponse(content_str, headers=headers or {})