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
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 23:39 +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 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.
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).
45 Args:
46 request: The current request (Starlette Request).
48 Returns:
49 A tuple of (nav_items, system_menu_items, secondary_nav).
50 """
51 from lexigram.admin.navigation.manager import NavigationManager
53 return NavigationManager(request).resolve_nav()
56@dataclass
57class AdminRendererConfig:
58 """Configuration for AdminRenderer."""
60 # Site branding
61 site_name: str = "Lexigram Admin"
62 site_logo: str | None = None
64 # Layout options
65 show_sidebar: bool = True
66 show_breadcrumbs: bool = True
68 # Theme
69 primary_color: str = "#6b7280"
70 theme: str = "default"
72 # Custom CSS/JS
73 extra_css: list[str] = field(default_factory=list)
74 extra_js: list[str] = field(default_factory=list)
76 # Footer
77 footer_text: str = ""
80class AdminRenderer:
81 """Renderer for admin pages.
83 Handles:
84 - Wrapping content in admin layout
85 - Breadcrumb navigation
86 - Background context (user info, navigation)
87 - HTMX partial rendering support
89 Usage:
90 renderer = AdminRenderer(config)
91 response = renderer.render_page(content, request, title="Dashboard")
92 """
94 def __init__(
95 self,
96 config: AdminRendererConfig | None = None,
97 layout_builder: Callable[..., str | Markup] | None = None,
98 ):
99 """Initialize renderer.
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
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.
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
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
133 user = getattr(request.state, "user", None) if request else None
135 nav_items, system_menu_items, _ = resolve_admin_nav(request)
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()
144 # Generate theme CSS from config primary_color (overridable per request)
145 theme_css = ""
146 try:
147 from lexigram.admin.theme.service import AdminThemeService
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
159 user_menu_items: list[dict[str, str | None]] = (
160 NavigationManager(request).user_menu_items() if request is not None else []
161 )
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
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 )
199 # Prepare templates
200 from pathlib import Path
202 from starlette.templating import Jinja2Templates
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))
209 shell_html = Markup(render_to_string(shell))
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 )
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.
232 Args:
233 content: Partial content
234 headers: Optional HTMX response headers
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)
244 return HTMLResponse(content_str, headers=headers or {})