Coverage for src/lexigram/admin/resources/form_renderer.py: 0%
143 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"""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.resources.wizard_renderer import WizardRendererMixin
15from lexigram.admin.state.context import wants_fragment
16from lexigram.di.decorators import inject
17from lexigram.logging import get_logger
18from lexigram.ui import Form, SlideOver, el, render_to_string
20logger = get_logger(__name__)
23@inject
24class FormRenderer(WizardRendererMixin):
25 """Handles rendering of create/edit forms for admin resources."""
27 def __init__(
28 self,
29 config: AdminConfig,
30 resource_name: str,
31 renderer: AdminRenderer,
32 permission_service: PermissionService | None = None,
33 ):
34 self._config = config
35 self.resource_name = resource_name
36 self._renderer = renderer
37 self._permission_service = permission_service
38 self._csrf_service = AdminCsrfService(
39 secret=config.auth.session_secret.get_secret_value()
40 )
42 async def render_create(
43 self,
44 request,
45 resource,
46 user=None,
47 errors: dict[str, list[str]] | None = None,
48 ) -> HTMLResponse:
49 """Render create form using Modal/SlideOver components based on form_display_mode."""
50 label = self.resource_name.replace("_", " ").title()
52 # Check if HTMX request (for modal/slider loading)
53 is_htmx = wants_fragment(request)
55 # Get form display mode from resource configuration
56 display_mode = "modal" # default
57 if resource and hasattr(resource, "get_form_display_mode"):
58 display_mode = resource.get_form_display_mode()
59 elif resource and hasattr(resource, "form_display_mode"):
60 display_mode = resource.form_display_mode
62 # Build form component
63 await self._ensure_csrf_token(request)
64 form_component = await self._build_form_component(
65 resource,
66 label,
67 mode="create",
68 user=user,
69 errors=errors,
70 )
71 form_component._request = request
73 # If HTMX request, always render as SlideOver
74 if is_htmx:
75 overlay = SlideOver(
76 title=f"Create {label}",
77 subtitle=f"Fill in the details to create a new {label.lower()} record.",
78 trigger=None,
79 render_trigger=False,
80 is_open=True,
81 size="xl",
82 children=[form_component],
83 )
84 return HTMLResponse(render_to_string(overlay))
86 # Full page render
87 content = el(
88 "div",
89 el(
90 "div",
91 el(
92 "a",
93 f"← Back to {label}",
94 href=f"{self._config.prefix}/{self.resource_name}",
95 class_="text-primary-600 hover:text-primary-900",
96 ),
97 el(
98 "h1",
99 f"Create {label}",
100 class_="text-2xl font-bold text-foreground mt-2",
101 ),
102 class_="mb-6",
103 ),
104 el(
105 "div",
106 form_component,
107 class_="bg-card shadow rounded-lg p-6",
108 ),
109 class_="resource-content",
110 )
112 return self._renderer.render_page(
113 content,
114 request=request,
115 title=f"Create {label}",
116 breadcrumbs=[
117 {"label": "Dashboard", "url": self._config.prefix},
118 {"label": label, "url": f"{self._config.prefix}/{self.resource_name}"},
119 {
120 "label": "Create",
121 "url": f"{self._config.prefix}/{self.resource_name}/create",
122 },
123 ],
124 )
126 async def render_edit(
127 self,
128 request,
129 resource,
130 item_id: str,
131 user=None,
132 errors: dict[str, list[str]] | None = None,
133 ) -> HTMLResponse:
134 """Render edit form using Modal/SlideOver components based on form_display_mode."""
135 label = self.resource_name.replace("_", " ").title()
137 # Check if HTMX request (for modal/slide-over loading)
138 is_htmx = wants_fragment(request)
140 # Get form display mode from resource configuration
141 display_mode = "slider" # default for edit
142 if resource and hasattr(resource, "get_form_display_mode"):
143 display_mode = resource.get_form_display_mode()
144 elif resource and hasattr(resource, "form_display_mode"):
145 display_mode = resource.form_display_mode
147 # Fetch existing item data for edit
148 initial_data = await self._fetch_item_data(resource, item_id)
150 # Build form component with initial data
151 await self._ensure_csrf_token(request)
152 form_component = await self._build_form_component(
153 resource,
154 label,
155 mode="edit",
156 initial_data=initial_data,
157 record_id=item_id,
158 user=user,
159 errors=errors,
160 )
161 form_component._request = request
163 # If HTMX request, always render as SlideOver
164 if is_htmx:
165 overlay = SlideOver(
166 title=f"Edit {label}",
167 subtitle=f"Editing record #{item_id}",
168 trigger=None,
169 render_trigger=False,
170 is_open=True,
171 size="xl",
172 children=[form_component],
173 )
174 return HTMLResponse(render_to_string(overlay))
176 # Full page render
177 content = el(
178 "div",
179 el(
180 "div",
181 el(
182 "a",
183 f"← Back to {label} #{item_id}",
184 href=f"{self._config.prefix}/{self.resource_name}/{item_id}",
185 class_="text-primary-600 hover:text-primary-900",
186 ),
187 el(
188 "h1",
189 f"Edit {label} #{item_id}",
190 class_="text-2xl font-bold text-foreground mt-2",
191 ),
192 class_="mb-6",
193 ),
194 el(
195 "div",
196 form_component,
197 class_="bg-card shadow rounded-lg p-6",
198 ),
199 class_="resource-content",
200 )
202 return self._renderer.render_page(
203 content,
204 request=request,
205 title=f"Edit {label} #{item_id}",
206 breadcrumbs=[
207 {"label": "Dashboard", "url": self._config.prefix},
208 {"label": label, "url": f"{self._config.prefix}/{self.resource_name}"},
209 {
210 "label": f"#{item_id}",
211 "url": f"{self._config.prefix}/{self.resource_name}/{item_id}",
212 },
213 {
214 "label": "Edit",
215 "url": f"{self._config.prefix}/{self.resource_name}/{item_id}/edit",
216 },
217 ],
218 )
220 async def _fetch_item_data(self, resource, item_id: str) -> dict:
221 """Fetch existing item data for edit mode."""
222 initial_data: dict[str, Any] = {}
223 try:
224 if (
225 hasattr(resource, "service")
226 and resource.service
227 and hasattr(resource.service, "get")
228 ):
229 item = await resource.service.get(item_id)
230 if item:
231 initial_data = (
232 item.model_dump() if hasattr(item, "model_dump") else dict(item)
233 )
234 elif (
235 hasattr(resource, "_data_source")
236 and resource._data_source
237 and hasattr(resource._data_source, "find_one")
238 ):
239 item = await resource._data_source.find_one(item_id)
240 if item:
241 initial_data = (
242 item.model_dump() if hasattr(item, "model_dump") else dict(item)
243 )
244 except (AttributeError, TypeError, ValueError, RuntimeError) as e:
245 logger.debug(
246 "Failed to get item %s/%s for edit: %s",
247 self.resource_name,
248 item_id,
249 e,
250 )
251 return initial_data
253 async def _ensure_csrf_token(self, request) -> None:
254 if getattr(getattr(request, "state", None), "csrf_token", None):
255 return
256 session_id = request.session.get("admin_user_id", "")
257 request.state.csrf_token = self._csrf_service.generate_token(session_id)
259 async def _build_form_component(
260 self,
261 resource,
262 label: str,
263 mode: str = "create",
264 initial_data: dict | None = None,
265 record_id: str | None = None,
266 user=None,
267 errors: dict[str, list[str]] | None = None,
268 ) -> Any:
269 """Build a Form component from resource model or form_class.
271 Args:
272 resource: The resource class
273 label: Human-readable label for the resource
274 mode: 'create' or 'edit'
275 initial_data: Initial values for edit mode
276 record_id: Record ID for edit mode
278 Returns:
279 Form component ready for rendering
280 """
281 initial_data = initial_data or {}
283 # Determine form action URL
284 if mode == "edit" and record_id:
285 action_url = f"{self._config.prefix}/{self.resource_name}/{record_id}/edit"
286 else:
287 action_url = f"{self._config.prefix}/{self.resource_name}/create"
289 # Try to use resource's form_class first
290 form_class = None
291 if resource and hasattr(resource, "get_form_class"):
292 form_class = resource.get_form_class()
293 elif resource and hasattr(resource, "form_class"):
294 form_class = resource.form_class
296 if form_class:
297 # Use the declared form class
298 return form_class(initial=initial_data, action=action_url)
300 # Generate form from Pydantic model
301 if resource and resource.model:
302 try:
303 from dataclasses import replace as dc_replace
305 from lexigram.admin.forms.components import FormSchemaGenerator
306 from lexigram.admin.schema import BelongsToField
308 generator = FormSchemaGenerator()
309 schema = generator.from_pydantic(resource.model)
311 # Populate relation field options from the related resource's data source
312 for idx, field_schema in enumerate(schema.fields):
313 if isinstance(field_schema, BelongsToField):
314 related_resource_name = field_schema.resource
315 if related_resource_name and hasattr(
316 resource, "_admin_registry"
317 ):
318 try:
319 related_resource_cls = resource._admin_registry.get(
320 related_resource_name
321 )
322 if related_resource_cls:
323 related_instance = related_resource_cls()
324 if (
325 hasattr(related_instance, "_data_source")
326 and related_instance._data_source
327 ):
328 ds = related_instance._data_source
329 if hasattr(ds, "list_all"):
330 records = await ds.list_all()
331 options = [
332 (
333 str(getattr(r, "id", r)),
334 str(r),
335 )
336 for r in records
337 ]
338 schema.fields[idx] = dc_replace(
339 field_schema, options=options
340 )
341 except Exception:
342 import logging
344 logging.getLogger(__name__).debug(
345 "Failed to load options for %s.%s",
346 self.resource_name,
347 field_schema.name,
348 )
350 # Build field components from schema
351 field_components = []
352 exclude_names = set(getattr(resource, "form_exclude_fields", ()) or ())
353 for field_schema in schema.fields:
354 # Skip framework-managed / excluded fields
355 if field_schema.name in exclude_names:
356 continue
358 # --- Field-level RBAC enforcement ---
359 if user:
360 if self._permission_service is not None:
361 _perm_svc = self._permission_service
362 # Hide field if user lacks view permission
363 if not await _perm_svc.can_view_field(
364 user, self.resource_name, field_schema.name
365 ):
366 continue
367 # Mark field non-editable if user lacks edit permission
368 if mode == "edit" and not await _perm_svc.can_edit_field(
369 user, self.resource_name, field_schema.name
370 ):
371 field_schema = dc_replace(field_schema, readonly=True)
372 # --- end RBAC ---
374 field_value = initial_data.get(
375 field_schema.name,
376 field_schema.default,
377 )
379 # Create the appropriate field component based on type
380 field_component = self._create_field_component(
381 field_schema,
382 field_value,
383 )
384 if field_component:
385 if errors and field_schema.name in errors:
386 field_component.error = errors[field_schema.name][0]
387 field_components.append(field_component.render())
389 submit_label = "Update" if mode == "edit" else "Create"
391 # Create Form component with fields
392 form = Form(
393 action_url=action_url,
394 method="post",
395 submit_label=submit_label,
396 hx_target="#main-content",
397 hx_swap="innerHTML",
398 )
399 form.children = field_components
400 return form
402 except AdminValidationError as e:
403 logger.debug(
404 "Failed to generate form for %s: %s",
405 self.resource_name,
406 e,
407 )
408 return el("p", f"Error generating form: {e}", class_="text-destructive")
410 return el(
411 "p",
412 "No form configuration available for this resource.",
413 class_="text-muted-foreground",
414 )
416 def _create_field_component(self, field_schema, value) -> Any:
417 """Create a field component from a SchemaField.
419 Args:
420 field_schema: The schema field definition
421 value: Initial value for the field
423 Returns:
424 Field component instance
425 """
426 from lexigram.admin.resources.field_renderer import _field_renderer_registry
428 common_args = {
429 "label": field_schema.label,
430 "required": field_schema.required,
431 "readonly": field_schema.readonly,
432 "help_text": field_schema.help_text,
433 "default": field_schema.default,
434 "placeholder": field_schema.placeholder,
435 "name": field_schema.name,
436 }
438 # Use registry to get the appropriate renderer and field instance
439 renderer = _field_renderer_registry.get_renderer(field_schema)
440 return renderer.render_field(field_schema, value, common_args)
443__all__ = ["FormRenderer"]