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

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

19 

20logger = get_logger(__name__) 

21 

22 

23@inject 

24class FormRenderer(WizardRendererMixin): 

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

26 

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 ) 

41 

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

51 

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

53 is_htmx = wants_fragment(request) 

54 

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 

61 

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 

72 

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

85 

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 ) 

111 

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 ) 

125 

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

136 

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

138 is_htmx = wants_fragment(request) 

139 

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 

146 

147 # Fetch existing item data for edit 

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

149 

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 

162 

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

175 

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 ) 

201 

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 ) 

219 

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 

252 

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) 

258 

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. 

270 

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 

277 

278 Returns: 

279 Form component ready for rendering 

280 """ 

281 initial_data = initial_data or {} 

282 

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" 

288 

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 

295 

296 if form_class: 

297 # Use the declared form class 

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

299 

300 # Generate form from Pydantic model 

301 if resource and resource.model: 

302 try: 

303 from dataclasses import replace as dc_replace 

304 

305 from lexigram.admin.forms.components import FormSchemaGenerator 

306 from lexigram.admin.schema import BelongsToField 

307 

308 generator = FormSchemaGenerator() 

309 schema = generator.from_pydantic(resource.model) 

310 

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 

343 

344 logging.getLogger(__name__).debug( 

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

346 self.resource_name, 

347 field_schema.name, 

348 ) 

349 

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 

357 

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

373 

374 field_value = initial_data.get( 

375 field_schema.name, 

376 field_schema.default, 

377 ) 

378 

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

388 

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

390 

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 

401 

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

409 

410 return el( 

411 "p", 

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

413 class_="text-muted-foreground", 

414 ) 

415 

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

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

418 

419 Args: 

420 field_schema: The schema field definition 

421 value: Initial value for the field 

422 

423 Returns: 

424 Field component instance 

425 """ 

426 from lexigram.admin.resources.field_renderer import _field_renderer_registry 

427 

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 } 

437 

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) 

441 

442 

443__all__ = ["FormRenderer"]