Coverage for src / lexigram / admin / resources / form_renderer.py: 0%

185 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-08-13 22:07 +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(secret=config.auth.session_secret) 

38 

39 async def render_create( 

40 self, 

41 request, 

42 resource, 

43 user=None, 

44 errors: dict[str, list[str]] | None = None, 

45 ) -> HTMLResponse: 

46 """Render create form using Modal/SlideOver components based on form_display_mode.""" 

47 label = self.resource_name.replace("_", " ").title() 

48 

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

50 is_htmx = wants_fragment(request) 

51 

52 # Get form display mode from resource configuration 

53 display_mode = "modal" # default 

54 if resource and hasattr(resource, "get_form_display_mode"): 

55 display_mode = resource.get_form_display_mode() 

56 elif resource and hasattr(resource, "form_display_mode"): 

57 display_mode = resource.form_display_mode 

58 

59 # Build form component 

60 await self._ensure_csrf_token(request) 

61 form_component = await self._build_form_component( 

62 resource, 

63 label, 

64 mode="create", 

65 user=user, 

66 errors=errors, 

67 ) 

68 form_component._request = request 

69 

70 # If HTMX request, always render as SlideOver 

71 if is_htmx: 

72 overlay = SlideOver( 

73 title=f"Create {label}", 

74 subtitle=f"Fill in the details to create a new {label.lower()} record.", 

75 trigger=None, 

76 render_trigger=False, 

77 is_open=True, 

78 size="xl", 

79 children=[form_component], 

80 ) 

81 return HTMLResponse(render_to_string(overlay)) 

82 

83 # Full page render 

84 content = el( 

85 "div", 

86 el( 

87 "div", 

88 el( 

89 "a", 

90 f"← Back to {label}", 

91 href=f"{self._config.prefix}/{self.resource_name}", 

92 class_="text-primary-600 hover:text-primary-900", 

93 ), 

94 el( 

95 "h1", 

96 f"Create {label}", 

97 class_="text-2xl font-bold text-foreground mt-2", 

98 ), 

99 class_="mb-6", 

100 ), 

101 el( 

102 "div", 

103 form_component, 

104 class_="bg-card shadow rounded-lg p-6", 

105 ), 

106 class_="resource-content", 

107 ) 

108 

109 return self._renderer.render_page( 

110 content, 

111 request=request, 

112 title=f"Create {label}", 

113 breadcrumbs=[ 

114 {"label": "Dashboard", "url": self._config.prefix}, 

115 {"label": label, "url": f"{self._config.prefix}/{self.resource_name}"}, 

116 { 

117 "label": "Create", 

118 "url": f"{self._config.prefix}/{self.resource_name}/create", 

119 }, 

120 ], 

121 ) 

122 

123 async def render_edit( 

124 self, 

125 request, 

126 resource, 

127 item_id: str, 

128 user=None, 

129 errors: dict[str, list[str]] | None = None, 

130 ) -> HTMLResponse: 

131 """Render edit form using Modal/SlideOver components based on form_display_mode.""" 

132 label = self.resource_name.replace("_", " ").title() 

133 

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

135 is_htmx = wants_fragment(request) 

136 

137 # Get form display mode from resource configuration 

138 display_mode = "slider" # default for edit 

139 if resource and hasattr(resource, "get_form_display_mode"): 

140 display_mode = resource.get_form_display_mode() 

141 elif resource and hasattr(resource, "form_display_mode"): 

142 display_mode = resource.form_display_mode 

143 

144 # Fetch existing item data for edit 

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

146 

147 # Build form component with initial data 

148 await self._ensure_csrf_token(request) 

149 form_component = await self._build_form_component( 

150 resource, 

151 label, 

152 mode="edit", 

153 initial_data=initial_data, 

154 record_id=item_id, 

155 user=user, 

156 errors=errors, 

157 ) 

158 form_component._request = request 

159 

160 # If HTMX request, always render as SlideOver 

161 if is_htmx: 

162 overlay = SlideOver( 

163 title=f"Edit {label}", 

164 subtitle=f"Editing record #{item_id}", 

165 trigger=None, 

166 render_trigger=False, 

167 is_open=True, 

168 size="xl", 

169 children=[form_component], 

170 ) 

171 return HTMLResponse(render_to_string(overlay)) 

172 

173 # Full page render 

174 content = el( 

175 "div", 

176 el( 

177 "div", 

178 el( 

179 "a", 

180 f"← Back to {label} #{item_id}", 

181 href=f"{self._config.prefix}/{self.resource_name}/{item_id}", 

182 class_="text-primary-600 hover:text-primary-900", 

183 ), 

184 el( 

185 "h1", 

186 f"Edit {label} #{item_id}", 

187 class_="text-2xl font-bold text-foreground mt-2", 

188 ), 

189 class_="mb-6", 

190 ), 

191 el( 

192 "div", 

193 form_component, 

194 class_="bg-card shadow rounded-lg p-6", 

195 ), 

196 class_="resource-content", 

197 ) 

198 

199 return self._renderer.render_page( 

200 content, 

201 request=request, 

202 title=f"Edit {label} #{item_id}", 

203 breadcrumbs=[ 

204 {"label": "Dashboard", "url": self._config.prefix}, 

205 {"label": label, "url": f"{self._config.prefix}/{self.resource_name}"}, 

206 { 

207 "label": f"#{item_id}", 

208 "url": f"{self._config.prefix}/{self.resource_name}/{item_id}", 

209 }, 

210 { 

211 "label": "Edit", 

212 "url": f"{self._config.prefix}/{self.resource_name}/{item_id}/edit", 

213 }, 

214 ], 

215 ) 

216 

217 async def render_wizard( 

218 self, 

219 request, 

220 resource, 

221 steps: list[dict], 

222 action_url: str, 

223 submit_label: str = "Submit", 

224 ) -> HTMLResponse: 

225 """Render a multi-step wizard form driven by Alpine.js. 

226 

227 Each step is defined by a dict with ``"title"`` and ``"fields"`` keys. 

228 Only the step whose index matches the Alpine ``currentStep`` variable 

229 is visible at any time. Previous / Next buttons advance or retreat 

230 through the steps, and the final step shows a Submit button that POSTs 

231 the whole form to ``action_url``. A step indicator line (e.g. 

232 "Step 2 of 4") is shown above the step body. 

233 

234 Args: 

235 request: Incoming HTTP request. 

236 resource: Admin resource instance (used to build field components). 

237 steps: Step definitions. Each item must be a dict with at minimum 

238 ``"title": str`` and ``"fields": list[str]`` keys. 

239 action_url: Form ``action`` / HTMX ``hx-post`` target URL. 

240 submit_label: Label for the submit button on the final step. 

241 

242 Returns: 

243 ``HTMLResponse`` with the wizard form fragment or full page. 

244 """ 

245 label = self.resource_name.replace("_", " ").title() 

246 total_steps = len(steps) 

247 

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

249 alpine_data = "{ currentStep: 0 }" 

250 

251 step_els: list[Any] = [] 

252 for idx, step_def in enumerate(steps): 

253 step_title = step_def.get("title", f"Step {idx + 1}") 

254 step_fields_names: list[str] = step_def.get("fields", []) 

255 

256 # Attempt to render each named field via the field registry. 

257 field_html_parts: list[Any] = [] 

258 if resource and resource.model: 

259 try: 

260 from lexigram.admin.forms.components import FormSchemaGenerator 

261 

262 generator = FormSchemaGenerator() 

263 schema = generator.from_pydantic(resource.model) 

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

265 

266 for fname in step_fields_names: 

267 field_schema = schema_map.get(fname) 

268 if field_schema is None: 

269 field_html_parts.append( 

270 el( 

271 "p", 

272 f"Unknown field: {fname}", 

273 class_="text-xs text-destructive", 

274 ) 

275 ) 

276 continue 

277 field_component = self._create_field_component( 

278 field_schema, field_schema.default 

279 ) 

280 if field_component: 

281 raw = field_component.render() 

282 field_html_parts.append( 

283 el("div", raw, class_="wizard-field mb-4") 

284 ) 

285 except AdminValidationError as exc: 

286 logger.debug( 

287 "render_wizard field generation failed resource=%s: %s", 

288 self.resource_name, 

289 exc, 

290 ) 

291 field_html_parts.append( 

292 el( 

293 "p", 

294 f"Error building fields: {exc}", 

295 class_="text-destructive text-sm", 

296 ) 

297 ) 

298 else: 

299 for fname in step_fields_names: 

300 field_html_parts.append( 

301 el( 

302 "div", 

303 el( 

304 "input", 

305 type="text", 

306 name=fname, 

307 placeholder=fname.replace("_", " ").title(), 

308 class_=( 

309 "block w-full rounded-md border border-border " 

310 "dark:border-border bg-muted " 

311 "text-foreground px-3 py-2 text-sm " 

312 "focus:outline-none focus:ring-2 focus:ring-primary-500" 

313 ), 

314 ), 

315 class_="wizard-field mb-4", 

316 ) 

317 ) 

318 

319 # Navigation buttons 

320 nav_buttons: list[Any] = [] 

321 if idx > 0: 

322 nav_buttons.append( 

323 el( 

324 "button", 

325 "← Previous", 

326 type="button", 

327 class_=( 

328 "px-4 py-2 text-sm font-medium text-foreground " 

329 "border border-border rounded-lg " 

330 "hover:bg-muted dark:hover:bg-muted transition-colors" 

331 ), 

332 **{"@click": "currentStep--"}, 

333 ) 

334 ) 

335 

336 if idx < total_steps - 1: 

337 nav_buttons.append( 

338 el( 

339 "button", 

340 "Next →", 

341 type="button", 

342 class_=( 

343 "px-4 py-2 text-sm font-medium text-white bg-primary-600 " 

344 "hover:bg-primary-700 rounded-lg focus:outline-none " 

345 "focus:ring-2 focus:ring-primary-500 transition-colors" 

346 ), 

347 **{"@click": "currentStep++"}, 

348 ) 

349 ) 

350 else: 

351 nav_buttons.append( 

352 el( 

353 "button", 

354 submit_label, 

355 type="submit", 

356 class_=( 

357 "px-4 py-2 text-sm font-medium text-white bg-success " 

358 "hover:bg-success/90 rounded-lg focus:outline-none " 

359 "focus:ring-2 focus:ring-ring transition-colors" 

360 ), 

361 ) 

362 ) 

363 

364 step_indicator = el( 

365 "p", 

366 f"Step {idx + 1} of {total_steps}", 

367 class_="text-xs text-muted-foreground mb-1", 

368 ) 

369 step_heading = el( 

370 "h3", 

371 step_title, 

372 class_="text-base font-semibold text-foreground mb-4", 

373 ) 

374 step_els.append( 

375 el( 

376 "div", 

377 step_indicator, 

378 step_heading, 

379 *field_html_parts, 

380 el( 

381 "div", 

382 *nav_buttons, 

383 class_="flex items-center justify-between mt-6 gap-3", 

384 ), 

385 class_="wizard-step", 

386 **{"x-show": f"currentStep === {idx}"}, 

387 ) 

388 ) 

389 

390 # Progress bar / step dots 

391 step_dots: list[Any] = [ 

392 el( 

393 "span", 

394 str(i + 1), 

395 class_=( 

396 f"wizard-dot inline-flex items-center justify-center w-7 h-7 " 

397 f"rounded-full text-xs font-semibold transition-colors " 

398 f"{'bg-primary-600 text-white' if i == 0 else 'bg-muted text-muted-foreground dark:text-muted-foreground'}" 

399 ), 

400 **{ 

401 ":class": ( 

402 f"currentStep === {i} " 

403 f"? 'bg-primary-600 text-white' " 

404 f": currentStep > {i} " 

405 f"? 'bg-success text-success-foreground' " 

406 f": 'bg-muted text-muted-foreground dark:text-muted-foreground'" 

407 ) 

408 }, 

409 ) 

410 for i in range(total_steps) 

411 ] 

412 progress_bar = el( 

413 "div", 

414 *step_dots, 

415 class_="wizard-progress flex items-center gap-2 mb-6", 

416 ) 

417 

418 form_el = el( 

419 "form", 

420 progress_bar, 

421 *step_els, 

422 action=action_url, 

423 method="post", 

424 class_="wizard-form", 

425 **{ 

426 "x-data": alpine_data, 

427 "hx-post": action_url, 

428 "hx-target": "#main-content", 

429 "hx-swap": "innerHTML", 

430 }, 

431 ) 

432 

433 is_htmx = wants_fragment(request) 

434 if is_htmx: 

435 return HTMLResponse(render_to_string(form_el)) 

436 

437 content = el( 

438 "div", 

439 el( 

440 "div", 

441 el( 

442 "a", 

443 f"← Back to {label}", 

444 href=f"{self._config.prefix}/{self.resource_name}", 

445 class_="text-primary-600 hover:text-primary-900", 

446 ), 

447 el( 

448 "h1", 

449 f"Create {label}", 

450 class_="text-2xl font-bold text-foreground mt-2", 

451 ), 

452 class_="mb-6", 

453 ), 

454 el( 

455 "div", 

456 form_el, 

457 class_="bg-card shadow rounded-lg p-6", 

458 ), 

459 class_="resource-content", 

460 ) 

461 

462 return self._renderer.render_page( 

463 content, 

464 request=request, 

465 title=f"Create {label}", 

466 breadcrumbs=[ 

467 {"label": "Dashboard", "url": self._config.prefix}, 

468 {"label": label, "url": f"{self._config.prefix}/{self.resource_name}"}, 

469 { 

470 "label": "Create", 

471 "url": f"{self._config.prefix}/{self.resource_name}/create", 

472 }, 

473 ], 

474 ) 

475 

476 async def _fetch_item_data(self, resource, item_id: str) -> dict: 

477 """Fetch existing item data for edit mode.""" 

478 initial_data: dict[str, Any] = {} 

479 try: 

480 if ( 

481 hasattr(resource, "service") 

482 and resource.service 

483 and hasattr(resource.service, "get") 

484 ): 

485 item = await resource.service.get(item_id) 

486 if item: 

487 initial_data = ( 

488 item.model_dump() if hasattr(item, "model_dump") else dict(item) 

489 ) 

490 elif ( 

491 hasattr(resource, "_data_source") 

492 and resource._data_source 

493 and hasattr(resource._data_source, "find_one") 

494 ): 

495 item = await resource._data_source.find_one(item_id) 

496 if item: 

497 initial_data = ( 

498 item.model_dump() if hasattr(item, "model_dump") else dict(item) 

499 ) 

500 except (AttributeError, TypeError, ValueError, RuntimeError) as e: 

501 logger.debug( 

502 "Failed to get item %s/%s for edit: %s", 

503 self.resource_name, 

504 item_id, 

505 e, 

506 ) 

507 return initial_data 

508 

509 async def _ensure_csrf_token(self, request) -> None: 

510 if getattr(getattr(request, "state", None), "csrf_token", None): 

511 return 

512 session_id = request.session.get("admin_user_id", "") 

513 request.state.csrf_token = self._csrf_service.generate_token(session_id) 

514 

515 async def _build_form_component( 

516 self, 

517 resource, 

518 label: str, 

519 mode: str = "create", 

520 initial_data: dict | None = None, 

521 record_id: str | None = None, 

522 user=None, 

523 errors: dict[str, list[str]] | None = None, 

524 ) -> Any: 

525 """Build a Form component from resource model or form_class. 

526 

527 Args: 

528 resource: The resource class 

529 label: Human-readable label for the resource 

530 mode: 'create' or 'edit' 

531 initial_data: Initial values for edit mode 

532 record_id: Record ID for edit mode 

533 

534 Returns: 

535 Form component ready for rendering 

536 """ 

537 initial_data = initial_data or {} 

538 

539 # Determine form action URL 

540 if mode == "edit" and record_id: 

541 action_url = f"{self._config.prefix}/{self.resource_name}/{record_id}/edit" 

542 else: 

543 action_url = f"{self._config.prefix}/{self.resource_name}/create" 

544 

545 # Try to use resource's form_class first 

546 form_class = None 

547 if resource and hasattr(resource, "get_form_class"): 

548 form_class = resource.get_form_class() 

549 elif resource and hasattr(resource, "form_class"): 

550 form_class = resource.form_class 

551 

552 if form_class: 

553 # Use the declared form class 

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

555 

556 # Generate form from Pydantic model 

557 if resource and resource.model: 

558 try: 

559 from lexigram.admin.forms.components import FormSchemaGenerator 

560 from lexigram.admin.forms.fields import FieldType 

561 

562 generator = FormSchemaGenerator() 

563 schema = generator.from_pydantic(resource.model) 

564 

565 # Populate relation field options from the related resource's data source 

566 for field_schema in schema.fields: 

567 if field_schema.type == FieldType.BELONGS_TO: 

568 related_resource_name = field_schema.related_resource 

569 if related_resource_name and hasattr( 

570 resource, "_admin_registry" 

571 ): 

572 try: 

573 related_resource_cls = resource._admin_registry.get( 

574 related_resource_name 

575 ) 

576 if related_resource_cls: 

577 related_instance = related_resource_cls() 

578 if ( 

579 hasattr(related_instance, "_data_source") 

580 and related_instance._data_source 

581 ): 

582 ds = related_instance._data_source 

583 if hasattr(ds, "list_all"): 

584 records = await ds.list_all() 

585 field_schema.options = [ 

586 { 

587 "value": str(getattr(r, "id", r)), 

588 "label": str(r), 

589 } 

590 for r in records 

591 ] 

592 except Exception: 

593 import logging 

594 

595 logging.getLogger(__name__).debug( 

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

597 self.resource_name, 

598 field_schema.name, 

599 ) 

600 

601 # Build field components from schema 

602 field_components = [] 

603 for field_schema in schema.fields: 

604 # Skip auto-generated fields 

605 if field_schema.name in ("id", "created_at", "updated_at"): 

606 continue 

607 

608 # --- Field-level RBAC enforcement --- 

609 if user: 

610 if self._permission_service is not None: 

611 _perm_svc = self._permission_service 

612 # Hide field if user lacks view permission 

613 if not _perm_svc.can_view_field( 

614 user, self.resource_name, field_schema.name 

615 ): 

616 continue 

617 # Mark field non-editable if user lacks edit permission 

618 if mode == "edit" and not _perm_svc.can_edit_field( 

619 user, self.resource_name, field_schema.name 

620 ): 

621 field_schema.editable = False 

622 # --- end RBAC --- 

623 

624 field_value = initial_data.get( 

625 field_schema.name, 

626 field_schema.default, 

627 ) 

628 

629 # Create the appropriate field component based on type 

630 field_component = self._create_field_component( 

631 field_schema, 

632 field_value, 

633 ) 

634 if field_component: 

635 if errors and field_schema.name in errors: 

636 field_component.errors = errors[field_schema.name] 

637 field_components.append(field_component.render()) 

638 

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

640 

641 # Create Form component with fields 

642 form = Form( 

643 action_url=action_url, 

644 method="post", 

645 submit_label=submit_label, 

646 hx_target="#main-content", 

647 hx_swap="innerHTML", 

648 ) 

649 form.children = field_components 

650 return form 

651 

652 except AdminValidationError as e: 

653 logger.debug( 

654 "Failed to generate form for %s: %s", 

655 self.resource_name, 

656 e, 

657 ) 

658 return el("p", f"Error generating form: {e}", class_="text-destructive") 

659 

660 return el( 

661 "p", 

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

663 class_="text-muted-foreground", 

664 ) 

665 

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

667 """Create a field component from a FieldSchema. 

668 

669 Args: 

670 field_schema: The field schema definition 

671 value: Initial value for the field 

672 

673 Returns: 

674 Field component instance 

675 """ 

676 from lexigram.admin.resources.field_renderer import _field_renderer_registry 

677 

678 common_args = { 

679 "label": field_schema.label, 

680 "required": field_schema.required, 

681 "disabled": not field_schema.editable, 

682 "help_text": field_schema.help_text, 

683 "default": field_schema.default, 

684 "placeholder": field_schema.placeholder, 

685 "name": field_schema.name, 

686 } 

687 

688 # Use registry to get the appropriate renderer and field instance 

689 renderer = _field_renderer_registry.get_renderer( 

690 field_schema.type, 

691 field_schema, 

692 ) 

693 return renderer.render_field(field_schema, value, common_args).bind(value) 

694 

695 

696__all__ = ["FormRenderer"]