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

1from __future__ import annotations 

2 

3"""Form rendering for admin resources.""" 

4 

5from typing import Any 

6 

7from starlette.responses import HTMLResponse 

8 

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 

18 

19logger = get_logger(__name__) 

20 

21 

22@inject 

23class FormRenderer: 

24 """Handles rendering of create/edit forms for admin resources.""" 

25 

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 ) 

40 

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() 

50 

51 # Check if HTMX request (for modal/slider loading) 

52 is_htmx = wants_fragment(request) 

53 

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 

60 

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 

71 

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)) 

84 

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 ) 

110 

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 ) 

124 

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() 

135 

136 # Check if HTMX request (for modal/slide-over loading) 

137 is_htmx = wants_fragment(request) 

138 

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 

145 

146 # Fetch existing item data for edit 

147 initial_data = await self._fetch_item_data(resource, item_id) 

148 

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 

161 

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)) 

174 

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 ) 

200 

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 ) 

218 

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. 

228 

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. 

235 

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. 

243 

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) 

249 

250 # Build Alpine.js data initialiser - currentStep is 0-indexed. 

251 alpine_data = "{ currentStep: 0 }" 

252 

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", []) 

257 

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 

263 

264 generator = FormSchemaGenerator() 

265 schema = generator.from_pydantic(resource.model) 

266 schema_map = {f.name: f for f in schema.fields} 

267 

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 ) 

320 

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 ) 

337 

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 ) 

365 

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 ) 

391 

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 ) 

419 

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 ) 

434 

435 is_htmx = wants_fragment(request) 

436 if is_htmx: 

437 return HTMLResponse(render_to_string(form_el)) 

438 

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 ) 

463 

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 ) 

477 

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 

510 

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) 

516 

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. 

528 

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 

535 

536 Returns: 

537 Form component ready for rendering 

538 """ 

539 initial_data = initial_data or {} 

540 

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" 

546 

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 

553 

554 if form_class: 

555 # Use the declared form class 

556 return form_class(initial=initial_data, action=action_url) 

557 

558 # Generate form from Pydantic model 

559 if resource and resource.model: 

560 try: 

561 from dataclasses import replace as dc_replace 

562 

563 from lexigram.admin.forms.components import FormSchemaGenerator 

564 from lexigram.admin.schema import BelongsToField 

565 

566 generator = FormSchemaGenerator() 

567 schema = generator.from_pydantic(resource.model) 

568 

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 

601 

602 logging.getLogger(__name__).debug( 

603 "Failed to load options for %s.%s", 

604 self.resource_name, 

605 field_schema.name, 

606 ) 

607 

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 

615 

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 --- 

631 

632 field_value = initial_data.get( 

633 field_schema.name, 

634 field_schema.default, 

635 ) 

636 

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()) 

646 

647 submit_label = "Update" if mode == "edit" else "Create" 

648 

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 

659 

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") 

667 

668 return el( 

669 "p", 

670 "No form configuration available for this resource.", 

671 class_="text-muted-foreground", 

672 ) 

673 

674 def _create_field_component(self, field_schema, value) -> Any: 

675 """Create a field component from a SchemaField. 

676 

677 Args: 

678 field_schema: The schema field definition 

679 value: Initial value for the field 

680 

681 Returns: 

682 Field component instance 

683 """ 

684 from lexigram.admin.resources.field_renderer import _field_renderer_registry 

685 

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 } 

695 

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) 

699 

700 

701__all__ = ["FormRenderer"]