Coverage for src/lexigram/admin/resources/form_renderer.py: 41%
188 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 14:56 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 14:56 +0800
1from __future__ import annotations
3"""Form rendering for admin resources."""
5from typing import Any
7from starlette.responses import HTMLResponse
9from lexigram.admin.auth.services.csrf_service import AdminCsrfService
10from lexigram.admin.config import AdminConfig
11from lexigram.admin.engine.renderer import AdminRenderer
12from lexigram.admin.exceptions import AdminValidationError
13from lexigram.admin.rbac.service import PermissionService
14from lexigram.admin.state.context import wants_fragment
15from lexigram.di.decorators import inject
16from lexigram.logging import get_logger
17from lexigram.ui import Form, SlideOver, el, render_to_string
19logger = get_logger(__name__)
22@inject
23class FormRenderer:
24 """Handles rendering of create/edit forms for admin resources."""
26 def __init__(
27 self,
28 config: AdminConfig,
29 resource_name: str,
30 renderer: AdminRenderer,
31 permission_service: PermissionService | None = None,
32 ):
33 self._config = config
34 self.resource_name = resource_name
35 self._renderer = renderer
36 self._permission_service = permission_service
37 self._csrf_service = AdminCsrfService(
38 secret=config.auth.session_secret.get_secret_value()
39 )
41 async def render_create(
42 self,
43 request,
44 resource,
45 user=None,
46 errors: dict[str, list[str]] | None = None,
47 ) -> HTMLResponse:
48 """Render create form using Modal/SlideOver components based on form_display_mode."""
49 label = self.resource_name.replace("_", " ").title()
51 # Check if HTMX request (for modal/slider loading)
52 is_htmx = wants_fragment(request)
54 # Get form display mode from resource configuration
55 display_mode = "modal" # default
56 if resource and hasattr(resource, "get_form_display_mode"):
57 display_mode = resource.get_form_display_mode()
58 elif resource and hasattr(resource, "form_display_mode"):
59 display_mode = resource.form_display_mode
61 # Build form component
62 await self._ensure_csrf_token(request)
63 form_component = await self._build_form_component(
64 resource,
65 label,
66 mode="create",
67 user=user,
68 errors=errors,
69 )
70 form_component._request = request
72 # If HTMX request, always render as SlideOver
73 if is_htmx:
74 overlay = SlideOver(
75 title=f"Create {label}",
76 subtitle=f"Fill in the details to create a new {label.lower()} record.",
77 trigger=None,
78 render_trigger=False,
79 is_open=True,
80 size="xl",
81 children=[form_component],
82 )
83 return HTMLResponse(render_to_string(overlay))
85 # Full page render
86 content = el(
87 "div",
88 el(
89 "div",
90 el(
91 "a",
92 f"← Back to {label}",
93 href=f"{self._config.prefix}/{self.resource_name}",
94 class_="text-primary-600 hover:text-primary-900",
95 ),
96 el(
97 "h1",
98 f"Create {label}",
99 class_="text-2xl font-bold text-foreground mt-2",
100 ),
101 class_="mb-6",
102 ),
103 el(
104 "div",
105 form_component,
106 class_="bg-card shadow rounded-lg p-6",
107 ),
108 class_="resource-content",
109 )
111 return self._renderer.render_page(
112 content,
113 request=request,
114 title=f"Create {label}",
115 breadcrumbs=[
116 {"label": "Dashboard", "url": self._config.prefix},
117 {"label": label, "url": f"{self._config.prefix}/{self.resource_name}"},
118 {
119 "label": "Create",
120 "url": f"{self._config.prefix}/{self.resource_name}/create",
121 },
122 ],
123 )
125 async def render_edit(
126 self,
127 request,
128 resource,
129 item_id: str,
130 user=None,
131 errors: dict[str, list[str]] | None = None,
132 ) -> HTMLResponse:
133 """Render edit form using Modal/SlideOver components based on form_display_mode."""
134 label = self.resource_name.replace("_", " ").title()
136 # Check if HTMX request (for modal/slide-over loading)
137 is_htmx = wants_fragment(request)
139 # Get form display mode from resource configuration
140 display_mode = "slider" # default for edit
141 if resource and hasattr(resource, "get_form_display_mode"):
142 display_mode = resource.get_form_display_mode()
143 elif resource and hasattr(resource, "form_display_mode"):
144 display_mode = resource.form_display_mode
146 # Fetch existing item data for edit
147 initial_data = await self._fetch_item_data(resource, item_id)
149 # Build form component with initial data
150 await self._ensure_csrf_token(request)
151 form_component = await self._build_form_component(
152 resource,
153 label,
154 mode="edit",
155 initial_data=initial_data,
156 record_id=item_id,
157 user=user,
158 errors=errors,
159 )
160 form_component._request = request
162 # If HTMX request, always render as SlideOver
163 if is_htmx:
164 overlay = SlideOver(
165 title=f"Edit {label}",
166 subtitle=f"Editing record #{item_id}",
167 trigger=None,
168 render_trigger=False,
169 is_open=True,
170 size="xl",
171 children=[form_component],
172 )
173 return HTMLResponse(render_to_string(overlay))
175 # Full page render
176 content = el(
177 "div",
178 el(
179 "div",
180 el(
181 "a",
182 f"← Back to {label} #{item_id}",
183 href=f"{self._config.prefix}/{self.resource_name}/{item_id}",
184 class_="text-primary-600 hover:text-primary-900",
185 ),
186 el(
187 "h1",
188 f"Edit {label} #{item_id}",
189 class_="text-2xl font-bold text-foreground mt-2",
190 ),
191 class_="mb-6",
192 ),
193 el(
194 "div",
195 form_component,
196 class_="bg-card shadow rounded-lg p-6",
197 ),
198 class_="resource-content",
199 )
201 return self._renderer.render_page(
202 content,
203 request=request,
204 title=f"Edit {label} #{item_id}",
205 breadcrumbs=[
206 {"label": "Dashboard", "url": self._config.prefix},
207 {"label": label, "url": f"{self._config.prefix}/{self.resource_name}"},
208 {
209 "label": f"#{item_id}",
210 "url": f"{self._config.prefix}/{self.resource_name}/{item_id}",
211 },
212 {
213 "label": "Edit",
214 "url": f"{self._config.prefix}/{self.resource_name}/{item_id}/edit",
215 },
216 ],
217 )
219 async def render_wizard(
220 self,
221 request,
222 resource,
223 steps: list[dict],
224 action_url: str,
225 submit_label: str = "Submit",
226 ) -> HTMLResponse:
227 """Render a multi-step wizard form driven by Alpine.js.
229 Each step is defined by a dict with ``"title"`` and ``"fields"`` keys.
230 Only the step whose index matches the Alpine ``currentStep`` variable
231 is visible at any time. Previous / Next buttons advance or retreat
232 through the steps, and the final step shows a Submit button that POSTs
233 the whole form to ``action_url``. A step indicator line (e.g.
234 "Step 2 of 4") is shown above the step body.
236 Args:
237 request: Incoming HTTP request.
238 resource: Admin resource instance (used to build field components).
239 steps: Step definitions. Each item must be a dict with at minimum
240 ``"title": str`` and ``"fields": list[str]`` keys.
241 action_url: Form ``action`` / HTMX ``hx-post`` target URL.
242 submit_label: Label for the submit button on the final step.
244 Returns:
245 ``HTMLResponse`` with the wizard form fragment or full page.
246 """
247 label = self.resource_name.replace("_", " ").title()
248 total_steps = len(steps)
250 # Build Alpine.js data initialiser - currentStep is 0-indexed.
251 alpine_data = "{ currentStep: 0 }"
253 step_els: list[Any] = []
254 for idx, step_def in enumerate(steps):
255 step_title = step_def.get("title", f"Step {idx + 1}")
256 step_fields_names: list[str] = step_def.get("fields", [])
258 # Attempt to render each named field via the field registry.
259 field_html_parts: list[Any] = []
260 if resource and resource.model:
261 try:
262 from lexigram.admin.forms.components import FormSchemaGenerator
264 generator = FormSchemaGenerator()
265 schema = generator.from_pydantic(resource.model)
266 schema_map = {f.name: f for f in schema.fields}
268 for fname in step_fields_names:
269 field_schema = schema_map.get(fname)
270 if field_schema is None:
271 field_html_parts.append(
272 el(
273 "p",
274 f"Unknown field: {fname}",
275 class_="text-xs text-destructive",
276 )
277 )
278 continue
279 field_component = self._create_field_component(
280 field_schema, field_schema.default
281 )
282 if field_component:
283 raw = field_component.render()
284 field_html_parts.append(
285 el("div", raw, class_="wizard-field mb-4")
286 )
287 except AdminValidationError as exc:
288 logger.debug(
289 "render_wizard field generation failed resource=%s: %s",
290 self.resource_name,
291 exc,
292 )
293 field_html_parts.append(
294 el(
295 "p",
296 f"Error building fields: {exc}",
297 class_="text-destructive text-sm",
298 )
299 )
300 else:
301 for fname in step_fields_names:
302 field_html_parts.append(
303 el(
304 "div",
305 el(
306 "input",
307 type="text",
308 name=fname,
309 placeholder=fname.replace("_", " ").title(),
310 class_=(
311 "block w-full rounded-md border border-border "
312 "dark:border-border bg-muted "
313 "text-foreground px-3 py-2 text-sm "
314 "focus:outline-none focus:ring-2 focus:ring-primary-500"
315 ),
316 ),
317 class_="wizard-field mb-4",
318 )
319 )
321 # Navigation buttons
322 nav_buttons: list[Any] = []
323 if idx > 0:
324 nav_buttons.append(
325 el(
326 "button",
327 "← Previous",
328 type="button",
329 class_=(
330 "px-4 py-2 text-sm font-medium text-foreground "
331 "border border-border rounded-lg "
332 "hover:bg-muted dark:hover:bg-muted transition-colors"
333 ),
334 **{"@click": "currentStep--"},
335 )
336 )
338 if idx < total_steps - 1:
339 nav_buttons.append(
340 el(
341 "button",
342 "Next →",
343 type="button",
344 class_=(
345 "px-4 py-2 text-sm font-medium text-white bg-primary-600 "
346 "hover:bg-primary-700 rounded-lg focus:outline-none "
347 "focus:ring-2 focus:ring-primary-500 transition-colors"
348 ),
349 **{"@click": "currentStep++"},
350 )
351 )
352 else:
353 nav_buttons.append(
354 el(
355 "button",
356 submit_label,
357 type="submit",
358 class_=(
359 "px-4 py-2 text-sm font-medium text-white bg-success "
360 "hover:bg-success/90 rounded-lg focus:outline-none "
361 "focus:ring-2 focus:ring-ring transition-colors"
362 ),
363 )
364 )
366 step_indicator = el(
367 "p",
368 f"Step {idx + 1} of {total_steps}",
369 class_="text-xs text-muted-foreground mb-1",
370 )
371 step_heading = el(
372 "h3",
373 step_title,
374 class_="text-base font-semibold text-foreground mb-4",
375 )
376 step_els.append(
377 el(
378 "div",
379 step_indicator,
380 step_heading,
381 *field_html_parts,
382 el(
383 "div",
384 *nav_buttons,
385 class_="flex items-center justify-between mt-6 gap-3",
386 ),
387 class_="wizard-step",
388 **{"x-show": f"currentStep === {idx}"},
389 )
390 )
392 # Progress bar / step dots
393 step_dots: list[Any] = [
394 el(
395 "span",
396 str(i + 1),
397 class_=(
398 f"wizard-dot inline-flex items-center justify-center w-7 h-7 "
399 f"rounded-full text-xs font-semibold transition-colors "
400 f"{'bg-primary-600 text-white' if i == 0 else 'bg-muted text-muted-foreground dark:text-muted-foreground'}"
401 ),
402 **{
403 ":class": (
404 f"currentStep === {i} "
405 f"? 'bg-primary-600 text-white' "
406 f": currentStep > {i} "
407 f"? 'bg-success text-success-foreground' "
408 f": 'bg-muted text-muted-foreground dark:text-muted-foreground'"
409 )
410 },
411 )
412 for i in range(total_steps)
413 ]
414 progress_bar = el(
415 "div",
416 *step_dots,
417 class_="wizard-progress flex items-center gap-2 mb-6",
418 )
420 form_el = el(
421 "form",
422 progress_bar,
423 *step_els,
424 action=action_url,
425 method="post",
426 class_="wizard-form",
427 **{
428 "x-data": alpine_data,
429 "hx-post": action_url,
430 "hx-target": "#main-content",
431 "hx-swap": "innerHTML",
432 },
433 )
435 is_htmx = wants_fragment(request)
436 if is_htmx:
437 return HTMLResponse(render_to_string(form_el))
439 content = el(
440 "div",
441 el(
442 "div",
443 el(
444 "a",
445 f"← Back to {label}",
446 href=f"{self._config.prefix}/{self.resource_name}",
447 class_="text-primary-600 hover:text-primary-900",
448 ),
449 el(
450 "h1",
451 f"Create {label}",
452 class_="text-2xl font-bold text-foreground mt-2",
453 ),
454 class_="mb-6",
455 ),
456 el(
457 "div",
458 form_el,
459 class_="bg-card shadow rounded-lg p-6",
460 ),
461 class_="resource-content",
462 )
464 return self._renderer.render_page(
465 content,
466 request=request,
467 title=f"Create {label}",
468 breadcrumbs=[
469 {"label": "Dashboard", "url": self._config.prefix},
470 {"label": label, "url": f"{self._config.prefix}/{self.resource_name}"},
471 {
472 "label": "Create",
473 "url": f"{self._config.prefix}/{self.resource_name}/create",
474 },
475 ],
476 )
478 async def _fetch_item_data(self, resource, item_id: str) -> dict:
479 """Fetch existing item data for edit mode."""
480 initial_data: dict[str, Any] = {}
481 try:
482 if (
483 hasattr(resource, "service")
484 and resource.service
485 and hasattr(resource.service, "get")
486 ):
487 item = await resource.service.get(item_id)
488 if item:
489 initial_data = (
490 item.model_dump() if hasattr(item, "model_dump") else dict(item)
491 )
492 elif (
493 hasattr(resource, "_data_source")
494 and resource._data_source
495 and hasattr(resource._data_source, "find_one")
496 ):
497 item = await resource._data_source.find_one(item_id)
498 if item:
499 initial_data = (
500 item.model_dump() if hasattr(item, "model_dump") else dict(item)
501 )
502 except (AttributeError, TypeError, ValueError, RuntimeError) as e:
503 logger.debug(
504 "Failed to get item %s/%s for edit: %s",
505 self.resource_name,
506 item_id,
507 e,
508 )
509 return initial_data
511 async def _ensure_csrf_token(self, request) -> None:
512 if getattr(getattr(request, "state", None), "csrf_token", None):
513 return
514 session_id = request.session.get("admin_user_id", "")
515 request.state.csrf_token = self._csrf_service.generate_token(session_id)
517 async def _build_form_component(
518 self,
519 resource,
520 label: str,
521 mode: str = "create",
522 initial_data: dict | None = None,
523 record_id: str | None = None,
524 user=None,
525 errors: dict[str, list[str]] | None = None,
526 ) -> Any:
527 """Build a Form component from resource model or form_class.
529 Args:
530 resource: The resource class
531 label: Human-readable label for the resource
532 mode: 'create' or 'edit'
533 initial_data: Initial values for edit mode
534 record_id: Record ID for edit mode
536 Returns:
537 Form component ready for rendering
538 """
539 initial_data = initial_data or {}
541 # Determine form action URL
542 if mode == "edit" and record_id:
543 action_url = f"{self._config.prefix}/{self.resource_name}/{record_id}/edit"
544 else:
545 action_url = f"{self._config.prefix}/{self.resource_name}/create"
547 # Try to use resource's form_class first
548 form_class = None
549 if resource and hasattr(resource, "get_form_class"):
550 form_class = resource.get_form_class()
551 elif resource and hasattr(resource, "form_class"):
552 form_class = resource.form_class
554 if form_class:
555 # Use the declared form class
556 return form_class(initial=initial_data, action=action_url)
558 # Generate form from Pydantic model
559 if resource and resource.model:
560 try:
561 from dataclasses import replace as dc_replace
563 from lexigram.admin.forms.components import FormSchemaGenerator
564 from lexigram.admin.schema import BelongsToField
566 generator = FormSchemaGenerator()
567 schema = generator.from_pydantic(resource.model)
569 # Populate relation field options from the related resource's data source
570 for idx, field_schema in enumerate(schema.fields):
571 if isinstance(field_schema, BelongsToField):
572 related_resource_name = field_schema.resource
573 if related_resource_name and hasattr(
574 resource, "_admin_registry"
575 ):
576 try:
577 related_resource_cls = resource._admin_registry.get(
578 related_resource_name
579 )
580 if related_resource_cls:
581 related_instance = related_resource_cls()
582 if (
583 hasattr(related_instance, "_data_source")
584 and related_instance._data_source
585 ):
586 ds = related_instance._data_source
587 if hasattr(ds, "list_all"):
588 records = await ds.list_all()
589 options = [
590 (
591 str(getattr(r, "id", r)),
592 str(r),
593 )
594 for r in records
595 ]
596 schema.fields[idx] = dc_replace(
597 field_schema, options=options
598 )
599 except Exception:
600 import logging
602 logging.getLogger(__name__).debug(
603 "Failed to load options for %s.%s",
604 self.resource_name,
605 field_schema.name,
606 )
608 # Build field components from schema
609 field_components = []
610 exclude_names = set(getattr(resource, "form_exclude_fields", ()) or ())
611 for field_schema in schema.fields:
612 # Skip framework-managed / excluded fields
613 if field_schema.name in exclude_names:
614 continue
616 # --- Field-level RBAC enforcement ---
617 if user:
618 if self._permission_service is not None:
619 _perm_svc = self._permission_service
620 # Hide field if user lacks view permission
621 if not await _perm_svc.can_view_field(
622 user, self.resource_name, field_schema.name
623 ):
624 continue
625 # Mark field non-editable if user lacks edit permission
626 if mode == "edit" and not await _perm_svc.can_edit_field(
627 user, self.resource_name, field_schema.name
628 ):
629 field_schema = dc_replace(field_schema, readonly=True)
630 # --- end RBAC ---
632 field_value = initial_data.get(
633 field_schema.name,
634 field_schema.default,
635 )
637 # Create the appropriate field component based on type
638 field_component = self._create_field_component(
639 field_schema,
640 field_value,
641 )
642 if field_component:
643 if errors and field_schema.name in errors:
644 field_component.error = errors[field_schema.name][0]
645 field_components.append(field_component.render())
647 submit_label = "Update" if mode == "edit" else "Create"
649 # Create Form component with fields
650 form = Form(
651 action_url=action_url,
652 method="post",
653 submit_label=submit_label,
654 hx_target="#main-content",
655 hx_swap="innerHTML",
656 )
657 form.children = field_components
658 return form
660 except AdminValidationError as e:
661 logger.debug(
662 "Failed to generate form for %s: %s",
663 self.resource_name,
664 e,
665 )
666 return el("p", f"Error generating form: {e}", class_="text-destructive")
668 return el(
669 "p",
670 "No form configuration available for this resource.",
671 class_="text-muted-foreground",
672 )
674 def _create_field_component(self, field_schema, value) -> Any:
675 """Create a field component from a SchemaField.
677 Args:
678 field_schema: The schema field definition
679 value: Initial value for the field
681 Returns:
682 Field component instance
683 """
684 from lexigram.admin.resources.field_renderer import _field_renderer_registry
686 common_args = {
687 "label": field_schema.label,
688 "required": field_schema.required,
689 "readonly": field_schema.readonly,
690 "help_text": field_schema.help_text,
691 "default": field_schema.default,
692 "placeholder": field_schema.placeholder,
693 "name": field_schema.name,
694 }
696 # Use registry to get the appropriate renderer and field instance
697 renderer = _field_renderer_registry.get_renderer(field_schema)
698 return renderer.render_field(field_schema, value, common_args)
701__all__ = ["FormRenderer"]