Coverage for src/lexigram/admin/resources/detail_renderer.py: 0%
81 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 23:18 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 23:18 +0800
1from __future__ import annotations
3"""Detail view rendering for admin resources."""
5from typing import Any
7from starlette.responses import HTMLResponse
9from lexigram.admin.config import AdminConfig
10from lexigram.admin.engine.renderer import AdminRenderer
11from lexigram.admin.exceptions import DataError
12from lexigram.admin.observability.admin_metrics import AdminMetrics, OperationTimer
13from lexigram.admin.state.context import wants_fragment
14from lexigram.di.decorators import inject
15from lexigram.logging import get_logger
16from lexigram.ui import InfolistWidget, el, render_to_string
18logger = get_logger(__name__)
21@inject
22class DetailRenderer:
23 """Handles rendering of detail views for admin resources."""
25 def __init__(
26 self,
27 config: AdminConfig,
28 resource_name: str,
29 renderer: AdminRenderer,
30 metrics: AdminMetrics | None = None,
31 ):
32 self._config = config
33 self.resource_name = resource_name
34 self._renderer = renderer
35 self._metrics = metrics or AdminMetrics(None)
37 async def render_detail(
38 self,
39 request,
40 resource,
41 item_id: str,
42 user=None,
43 ) -> HTMLResponse:
44 """Render detail view."""
45 timer = OperationTimer()
47 label = self.resource_name.replace("_", " ").title()
49 item_html = await self._get_item_html(resource, item_id, label)
51 content = f"""
52 <div class="resource-header" style="margin-bottom: 1.5rem;">
53 <a href="{self._config.prefix}/{self.resource_name}" style="color: #6366f1;">← Back to {label}</a>
54 <h1>{label} #{item_id}</h1>
55 </div>
56 <div class="resource-content">{item_html}</div>
57 <div class="resource-actions" style="margin-top: 1rem;">
58 <a href="{self._config.prefix}/{self.resource_name}/{item_id}/edit" class="btn btn-secondary">Edit</a>
59 </div>
60 """
62 is_htmx = wants_fragment(request)
63 if is_htmx:
64 self._metrics.record_operation(
65 "detail",
66 resource=self.resource_name,
67 status="success",
68 duration_seconds=timer.elapsed(),
69 )
70 return HTMLResponse(content)
72 self._metrics.record_operation(
73 "detail",
74 resource=self.resource_name,
75 status="success",
76 duration_seconds=timer.elapsed(),
77 )
79 return self._renderer.render_page(
80 content,
81 request=request,
82 title=f"{label} #{item_id}",
83 breadcrumbs=[
84 {"label": "Dashboard", "url": self._config.prefix},
85 {"label": label, "url": f"{self._config.prefix}/{self.resource_name}"},
86 {
87 "label": f"#{item_id}",
88 "url": f"{self._config.prefix}/{self.resource_name}/{item_id}",
89 },
90 ],
91 )
93 async def render_inline_edit(
94 self,
95 request,
96 resource,
97 item_id: str,
98 user=None,
99 ) -> HTMLResponse:
100 """Render detail view with inline editable fields.
102 Each field is rendered as an Alpine.js-driven inline editor. In
103 *display* mode the current value is shown with a pencil icon that
104 appears on hover. Clicking the icon switches to *edit* mode which
105 shows an ``<input>`` alongside Save and Cancel buttons. Saving
106 issues an HTMX ``PATCH`` to
107 ``/{resource_name}/{item_id}/inline?field=<name>`` carrying the new
108 value.
110 Args:
111 request: Incoming HTTP request.
112 resource: Admin resource instance.
113 item_id: Primary-key value of the record to display.
114 user: Authenticated user (optional, for future RBAC use).
116 Returns:
117 ``HTMLResponse`` with the inline-edit fragment or full page.
118 """
119 label = self.resource_name.replace("_", " ").title()
120 item_dict: dict = {}
122 if resource and hasattr(resource, "service") and resource.service:
123 try:
124 if hasattr(resource.service, "get"):
125 item = await resource.service.get(item_id)
126 if item:
127 item_dict = (
128 item.model_dump()
129 if hasattr(item, "model_dump")
130 else dict(item)
131 )
132 except DataError as exc:
133 logger.debug(
134 "inline_edit fetch failed resource=%s id=%s: %s",
135 self.resource_name,
136 item_id,
137 exc,
138 )
139 raise DataError(
140 message=f"Failed to retrieve {self.resource_name} item {item_id}",
141 original_error=exc,
142 ) from None
144 patch_base = f"{self._config.prefix}/{self.resource_name}/{item_id}/inline"
146 field_rows: list[Any] = []
147 for field_name, field_value in item_dict.items():
148 alpine_key = f"editing_{field_name}"
149 row = el(
150 "tr",
151 el(
152 "td",
153 el("strong", field_name),
154 class_="py-2 pr-4 align-top text-sm font-medium text-muted-foreground w-1/4",
155 ),
156 el(
157 "td",
158 # Display mode
159 el(
160 "div",
161 el(
162 "span",
163 str(field_value),
164 class_="inline-edit-value",
165 ),
166 el(
167 "button",
168 "✎",
169 type="button",
170 title=f"Edit {field_name}",
171 class_=(
172 "inline-edit-pencil ml-2 text-primary-500 opacity-0 "
173 "group-hover:opacity-100 transition-opacity text-xs"
174 ),
175 **{"@click": f"{alpine_key} = true"},
176 ),
177 class_="group flex items-center",
178 **{"x-show": f"!{alpine_key}"},
179 ),
180 # Edit mode
181 el(
182 "div",
183 el(
184 "input",
185 type="text",
186 name=field_name,
187 value=str(field_value),
188 class_=(
189 "inline-edit-input border border-border "
190 "rounded px-2 py-1 text-sm w-full "
191 "bg-muted text-foreground "
192 "focus:outline-none focus:ring-2 focus:ring-primary-500"
193 ),
194 **{
195 ":name": f"'{field_name}'",
196 "x-ref": f"input_{field_name}",
197 },
198 ),
199 el(
200 "button",
201 "Save",
202 type="button",
203 class_=(
204 "ml-2 px-2 py-1 text-xs font-medium text-white "
205 "bg-primary-600 hover:bg-primary-700 rounded "
206 "focus:outline-none focus:ring-2 focus:ring-primary-500"
207 ),
208 **{
209 "hx-patch": patch_base,
210 "hx-include": f"[name='{field_name}']",
211 "hx-target": "closest tr",
212 "hx-swap": "outerHTML",
213 "@click": f"{alpine_key} = false",
214 },
215 ),
216 el(
217 "button",
218 "Cancel",
219 type="button",
220 class_=(
221 "ml-1 px-2 py-1 text-xs font-medium text-muted-foreground "
222 "dark:text-muted-foreground hover:text-foreground rounded border "
223 "border-border"
224 ),
225 **{"@click": f"{alpine_key} = false"},
226 ),
227 class_="flex items-center gap-1",
228 **{"x-show": alpine_key},
229 ),
230 class_="py-2 text-sm text-foreground",
231 ),
232 **{
233 "x-data": f"{{ {alpine_key}: false }}",
234 "class": "border-t border-border",
235 },
236 )
237 field_rows.append(row)
239 table_html = render_to_string(
240 el("table", *field_rows, class_="detail-inline-edit-table w-full")
241 )
243 content = f"""
244 <div class="resource-header" style="margin-bottom: 1.5rem;">
245 <a href="{self._config.prefix}/{self.resource_name}" style="color: #6366f1;">← Back to {label}</a>
246 <h1>{label} #{item_id} <span class="text-muted-foreground text-xs">(inline edit)</span></h1>
247 </div>
248 <div class="resource-content">{table_html}</div>
249 """
251 is_htmx = wants_fragment(request)
252 if is_htmx:
253 return HTMLResponse(content)
255 return self._renderer.render_page(
256 content,
257 request=request,
258 title=f"{label} #{item_id} — Inline Edit",
259 breadcrumbs=[
260 {"label": "Dashboard", "url": self._config.prefix},
261 {"label": label, "url": f"{self._config.prefix}/{self.resource_name}"},
262 {
263 "label": f"#{item_id}",
264 "url": f"{self._config.prefix}/{self.resource_name}/{item_id}",
265 },
266 {
267 "label": "Inline Edit",
268 "url": f"{self._config.prefix}/{self.resource_name}/{item_id}/inline-edit",
269 },
270 ],
271 )
273 async def _get_item_html(self, resource, item_id: str, label: str) -> str:
274 """Get HTML representation of the item."""
275 if resource and hasattr(resource, "service") and resource.service:
276 try:
277 if hasattr(resource.service, "get"):
278 item = await resource.service.get(item_id)
279 if item:
280 item_dict = (
281 item.model_dump()
282 if hasattr(item, "model_dump")
283 else dict(item)
284 )
285 return self._render_item_infolist(resource, item_dict)
286 return render_to_string(el("p", "Item not found"))
287 except DataError as e:
288 logger.debug(
289 "Failed to get item %s/%s: %s",
290 self.resource_name,
291 item_id,
292 e,
293 )
294 raise DataError(
295 message=f"Failed to retrieve {self.resource_name} item {item_id}",
296 original_error=e,
297 ) from None
299 return render_to_string(el("p", f"Item #{item_id}"))
301 def _render_item_infolist(self, resource, item_dict: dict) -> str:
302 """Render item fields as an infolist widget.
304 Prefers the resource's ``infolist()`` API when available
305 (``HasInfolist`` mixin); otherwise derives entries from the
306 resource model. Falls back to a plain key/value table when the
307 resource model cannot be introspected.
308 """
309 try:
310 if hasattr(resource, "infolist"):
311 entries = resource.infolist(item_dict)
312 else:
313 from lexigram.admin.forms.components import FormSchemaGenerator
315 schema = FormSchemaGenerator().from_pydantic(resource.model)
316 entries = [
317 field.render_infolist_entry(item_dict[field.name])
318 for field in schema.fields
319 if field.visible_in_view and field.name in item_dict
320 ]
321 return render_to_string(InfolistWidget(entries=entries, columns=2).render())
322 except (ImportError, AttributeError, TypeError, ValueError) as exc:
323 logger.debug(
324 "infolist render failed for %s: %s",
325 self.resource_name,
326 exc,
327 )
328 rows = [
329 el("tr", el("td", el("strong", k)), el("td", str(v)))
330 for k, v in item_dict.items()
331 ]
332 return render_to_string(el("table", *rows, class_="detail-table"))
335__all__ = ["DetailRenderer"]