Coverage for src/lexigram/admin/resources/field_renderer.py: 78%

184 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-21 14:56 +0800

1from __future__ import annotations 

2 

3"""Field rendering for admin resources.""" 

4 

5from typing import Any, Protocol 

6 

7from starlette.responses import HTMLResponse 

8 

9from lexigram.admin.config import AdminConfig 

10from lexigram.admin.schema import ( 

11 BelongsToField, 

12 BooleanField, 

13 ColorField, 

14 DateField, 

15 DateTimeField, 

16 EmailField, 

17 HasManyField, 

18 JsonField, 

19 MorphField, 

20 MultiSelectField, 

21 NumberField, 

22 PasswordField, 

23 SchemaField, 

24 SelectField, 

25 TagsField, 

26 TextAreaField, 

27 TextField, 

28) 

29from lexigram.di.decorators import inject 

30from lexigram.logging import get_logger 

31from lexigram.serialization import dumps_str 

32from lexigram.ui import ( 

33 BelongsTo, 

34 DateInput, 

35 MultiSelect, 

36 NumberInput, 

37 Select, 

38 Switch, 

39 TextArea, 

40 TextInput, 

41 el, 

42 render_to_string, 

43) 

44 

45logger = get_logger(__name__) 

46 

47 

48class FieldRendererProtocol(Protocol): 

49 """Protocol for field renderers.""" 

50 

51 def can_render(self, field_schema: SchemaField) -> bool: ... 

52 

53 def render_field( 

54 self, 

55 field_schema: SchemaField, 

56 value: Any, 

57 common_args: dict[str, Any], 

58 ) -> Any: ... 

59 

60 

61def _atom_args( 

62 common_args: dict[str, Any], value: Any, extra: dict[str, Any] | None = None 

63) -> dict[str, Any]: 

64 """Build atom kwargs from shared inline-editing args. 

65 

66 Args: 

67 common_args: Shared args (name, label, required, disabled, hx_*, ...). 

68 value: Current field value. 

69 extra: Additional atom-specific kwargs. 

70 

71 Returns: 

72 Kwargs acceptable by lexigram.ui input atoms. 

73 """ 

74 args: dict[str, Any] = { 

75 "name": common_args["name"], 

76 "value": value if value is not None else "", 

77 "label": common_args.get("label"), 

78 "required": common_args.get("required", False), 

79 "disabled": common_args.get("disabled", False), 

80 } 

81 for key in ("placeholder", "error"): 

82 if common_args.get(key): 

83 args[key] = common_args[key] 

84 args.update({k: v for k, v in common_args.items() if k.startswith("hx_")}) 

85 if extra: 

86 args.update(extra) 

87 return args 

88 

89 

90class TextAreaFieldRenderer: 

91 """Renderer for multi-line text fields.""" 

92 

93 def can_render(self, field_schema: SchemaField) -> bool: 

94 return isinstance(field_schema, TextAreaField) 

95 

96 def render_field( 

97 self, 

98 field_schema: SchemaField, 

99 value: Any, 

100 common_args: dict[str, Any], 

101 ) -> Any: 

102 return TextArea(**_atom_args(common_args, value)) 

103 

104 

105class ListFieldRenderer: 

106 """Renderer for tag-style list fields (comma-joined text area).""" 

107 

108 def can_render(self, field_schema: SchemaField) -> bool: 

109 return isinstance(field_schema, TagsField) 

110 

111 def render_field( 

112 self, 

113 field_schema: SchemaField, 

114 value: Any, 

115 common_args: dict[str, Any], 

116 ) -> Any: 

117 display_value = ( 

118 ", ".join(str(v) for v in value) 

119 if isinstance(value, list) 

120 else str(value or "") 

121 ) 

122 return TextArea(**_atom_args(common_args, display_value)) 

123 

124 

125class JsonFieldRenderer: 

126 """Renderer for JSON fields.""" 

127 

128 def can_render(self, field_schema: SchemaField) -> bool: 

129 return isinstance(field_schema, JsonField) 

130 

131 def render_field( 

132 self, 

133 field_schema: SchemaField, 

134 value: Any, 

135 common_args: dict[str, Any], 

136 ) -> Any: 

137 display_value = dumps_str(value) if value is not None else "" 

138 args = _atom_args(common_args, display_value, extra={"rows": 8}) 

139 return TextArea(**args) 

140 

141 

142class NumberFieldRenderer: 

143 """Renderer for numeric fields.""" 

144 

145 def can_render(self, field_schema: SchemaField) -> bool: 

146 return isinstance(field_schema, NumberField) 

147 

148 def render_field( 

149 self, 

150 field_schema: SchemaField, 

151 value: Any, 

152 common_args: dict[str, Any], 

153 ) -> Any: 

154 return NumberInput(**_atom_args(common_args, value)) 

155 

156 

157class BooleanFieldRenderer: 

158 """Renderer for boolean fields.""" 

159 

160 def can_render(self, field_schema: SchemaField) -> bool: 

161 return isinstance(field_schema, BooleanField) 

162 

163 def render_field( 

164 self, 

165 field_schema: SchemaField, 

166 value: Any, 

167 common_args: dict[str, Any], 

168 ) -> Any: 

169 hx_props = {k: v for k, v in common_args.items() if k.startswith("hx_")} 

170 return Switch( 

171 label=common_args.get("label") or "", 

172 name=common_args["name"], 

173 value=bool(value), 

174 **hx_props, 

175 ) 

176 

177 

178class MultiSelectFieldRenderer: 

179 """Renderer for multi-select fields.""" 

180 

181 def can_render(self, field_schema: SchemaField) -> bool: 

182 return isinstance(field_schema, MultiSelectField) 

183 

184 def render_field( 

185 self, 

186 field_schema: SchemaField, 

187 value: Any, 

188 common_args: dict[str, Any], 

189 ) -> Any: 

190 if not isinstance(field_schema, MultiSelectField): 

191 return None 

192 choices = field_schema.options or [] 

193 selected = value if isinstance(value, list) else [] 

194 return MultiSelect( 

195 **_atom_args(common_args, selected, extra={"choices": choices}) 

196 ) 

197 

198 

199class HasManyFieldRenderer: 

200 """Renderer for HAS_MANY relation fields.""" 

201 

202 def can_render(self, field_schema: SchemaField) -> bool: 

203 return isinstance(field_schema, HasManyField) 

204 

205 def render_field( 

206 self, 

207 field_schema: SchemaField, 

208 value: Any, 

209 common_args: dict[str, Any], 

210 ) -> Any: 

211 if not isinstance(field_schema, HasManyField): 

212 return None 

213 choices = field_schema.options or [] 

214 selected = value if isinstance(value, list) else [] 

215 return MultiSelect( 

216 **_atom_args(common_args, selected, extra={"choices": choices}) 

217 ) 

218 

219 

220class BelongsToFieldRenderer: 

221 """Renderer for BELONGS_TO relation fields.""" 

222 

223 def can_render(self, field_schema: SchemaField) -> bool: 

224 return isinstance(field_schema, BelongsToField) 

225 

226 def render_field( 

227 self, 

228 field_schema: SchemaField, 

229 value: Any, 

230 common_args: dict[str, Any], 

231 ) -> Any: 

232 if not isinstance(field_schema, BelongsToField): 

233 return None 

234 return BelongsTo( 

235 **_atom_args( 

236 common_args, 

237 value, 

238 extra={ 

239 "resource": field_schema.resource, 

240 "choices": field_schema.options or [], 

241 }, 

242 ) 

243 ) 

244 

245 

246class MorphFieldRenderer: 

247 """Renderer for polymorphic relation fields.""" 

248 

249 def can_render(self, field_schema: SchemaField) -> bool: 

250 return isinstance(field_schema, MorphField) 

251 

252 def render_field( 

253 self, 

254 field_schema: SchemaField, 

255 value: Any, 

256 common_args: dict[str, Any], 

257 ) -> Any: 

258 if not isinstance(field_schema, MorphField): 

259 return None 

260 args = _atom_args( 

261 common_args, value, extra={"choices": field_schema.options or []} 

262 ) 

263 return Select(**args) 

264 

265 

266class SelectFieldRenderer: 

267 """Renderer for select fields.""" 

268 

269 def can_render(self, field_schema: SchemaField) -> bool: 

270 return isinstance(field_schema, SelectField) 

271 

272 def render_field( 

273 self, 

274 field_schema: SchemaField, 

275 value: Any, 

276 common_args: dict[str, Any], 

277 ) -> Any: 

278 if not isinstance(field_schema, SelectField): 

279 return None 

280 args = _atom_args( 

281 common_args, value, extra={"choices": field_schema.options or []} 

282 ) 

283 return Select(**args) 

284 

285 

286class DateFieldRenderer: 

287 """Renderer for date fields.""" 

288 

289 def can_render(self, field_schema: SchemaField) -> bool: 

290 return isinstance(field_schema, DateField) 

291 

292 def render_field( 

293 self, 

294 field_schema: SchemaField, 

295 value: Any, 

296 common_args: dict[str, Any], 

297 ) -> Any: 

298 iso_value = value.isoformat() if value is not None else "" 

299 return DateInput(**_atom_args(common_args, iso_value)) 

300 

301 

302class DateTimeFieldRenderer: 

303 """Renderer for datetime fields.""" 

304 

305 def can_render(self, field_schema: SchemaField) -> bool: 

306 return isinstance(field_schema, DateTimeField) 

307 

308 def render_field( 

309 self, 

310 field_schema: SchemaField, 

311 value: Any, 

312 common_args: dict[str, Any], 

313 ) -> Any: 

314 iso_value = value.isoformat() if value is not None else "" 

315 return DateInput(**_atom_args(common_args, iso_value)) 

316 

317 

318class EmailFieldRenderer: 

319 """Renderer for email fields.""" 

320 

321 def can_render(self, field_schema: SchemaField) -> bool: 

322 return isinstance(field_schema, EmailField) 

323 

324 def render_field( 

325 self, 

326 field_schema: SchemaField, 

327 value: Any, 

328 common_args: dict[str, Any], 

329 ) -> Any: 

330 return TextInput( 

331 **_atom_args(common_args, value, extra={"input_type": "email"}) 

332 ) 

333 

334 

335class PasswordFieldRenderer: 

336 """Renderer for password fields.""" 

337 

338 def can_render(self, field_schema: SchemaField) -> bool: 

339 return isinstance(field_schema, PasswordField) 

340 

341 def render_field( 

342 self, 

343 field_schema: SchemaField, 

344 value: Any, 

345 common_args: dict[str, Any], 

346 ) -> Any: 

347 return TextInput( 

348 **_atom_args(common_args, value, extra={"input_type": "password"}) 

349 ) 

350 

351 

352class ColorFieldRenderer: 

353 """Renderer for color fields.""" 

354 

355 def can_render(self, field_schema: SchemaField) -> bool: 

356 return isinstance(field_schema, ColorField) 

357 

358 def render_field( 

359 self, 

360 field_schema: SchemaField, 

361 value: Any, 

362 common_args: dict[str, Any], 

363 ) -> Any: 

364 return TextInput( 

365 **_atom_args(common_args, value, extra={"input_type": "color"}) 

366 ) 

367 

368 

369class TextFieldRenderer: 

370 """Renderer for plain text fields.""" 

371 

372 def can_render(self, field_schema: SchemaField) -> bool: 

373 return isinstance(field_schema, TextField) 

374 

375 def render_field( 

376 self, 

377 field_schema: SchemaField, 

378 value: Any, 

379 common_args: dict[str, Any], 

380 ) -> Any: 

381 return TextInput(**_atom_args(common_args, value)) 

382 

383 

384class DefaultFieldRenderer: 

385 """Default renderer for unknown field types.""" 

386 

387 def can_render(self, field_schema: SchemaField) -> bool: 

388 return True 

389 

390 def render_field( 

391 self, 

392 field_schema: SchemaField, 

393 value: Any, 

394 common_args: dict[str, Any], 

395 ) -> Any: 

396 return TextInput(**_atom_args(common_args, value)) 

397 

398 

399class FieldRendererRegistry: 

400 """Registry for field renderers.""" 

401 

402 def __init__(self) -> None: 

403 self._renderers: list[FieldRendererProtocol] = [ 

404 TextAreaFieldRenderer(), 

405 ListFieldRenderer(), 

406 JsonFieldRenderer(), 

407 MultiSelectFieldRenderer(), 

408 HasManyFieldRenderer(), 

409 BelongsToFieldRenderer(), 

410 MorphFieldRenderer(), 

411 SelectFieldRenderer(), 

412 DateFieldRenderer(), 

413 DateTimeFieldRenderer(), 

414 EmailFieldRenderer(), 

415 PasswordFieldRenderer(), 

416 ColorFieldRenderer(), 

417 NumberFieldRenderer(), 

418 BooleanFieldRenderer(), 

419 TextFieldRenderer(), 

420 DefaultFieldRenderer(), 

421 ] 

422 

423 def get_renderer(self, field_schema: SchemaField) -> FieldRendererProtocol: 

424 for renderer in self._renderers: 

425 if renderer.can_render(field_schema): 

426 return renderer 

427 return DefaultFieldRenderer() 

428 

429 

430# Global registry instance 

431_field_renderer_registry = FieldRendererRegistry() 

432 

433 

434@inject 

435class FieldRenderer: 

436 """Handles rendering of individual field components for admin resources.""" 

437 

438 def __init__(self, config: AdminConfig, resource_name: str): 

439 self._config = config 

440 self.resource_name = resource_name 

441 

442 async def render_field( 

443 self, 

444 request, 

445 resource, 

446 field_name: str, 

447 item_id: str | None = None, 

448 user=None, 

449 ) -> HTMLResponse: 

450 """Render a single field for inline editing.""" 

451 self.resource_name.replace("_", " ").title() 

452 

453 # Fetch current item data 

454 current_data: dict[str, Any] = {} 

455 if item_id and resource and hasattr(resource, "service") and resource.service: 

456 try: 

457 if hasattr(resource.service, "get"): 

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

459 if item: 

460 current_data = ( 

461 item.model_dump() 

462 if hasattr(item, "model_dump") 

463 else dict(item) 

464 ) 

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

466 logger.debug( 

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

468 self.resource_name, 

469 item_id, 

470 e, 

471 ) 

472 

473 # Get field value 

474 field_value = current_data.get(field_name, "") 

475 

476 # Build field component 

477 field_component = self._build_field_component( 

478 resource, 

479 field_name, 

480 field_value, 

481 item_id, 

482 ) 

483 

484 if not field_component: 

485 return HTMLResponse( 

486 el("span", "Field not found", class_="text-destructive"), 

487 status_code=404, 

488 ) 

489 

490 # Return just the field component for HTMX inline editing 

491 return HTMLResponse(render_to_string(field_component)) 

492 

493 def _build_field_component( 

494 self, 

495 resource, 

496 field_name: str, 

497 value, 

498 item_id: str | None = None, 

499 ) -> Any: 

500 """Build a field component for inline editing. 

501 

502 Args: 

503 resource: The resource class 

504 field_name: Name of the field to render 

505 value: Current value of the field 

506 item_id: ID of the item being edited (for inline editing) 

507 

508 Returns: 

509 Field component ready for rendering 

510 """ 

511 # Try to get field schema from resource model 

512 if not resource or not resource.model: 

513 return None 

514 

515 try: 

516 from lexigram.admin.forms.components import FormSchemaGenerator 

517 

518 generator = FormSchemaGenerator() 

519 schema = generator.from_pydantic(resource.model) 

520 

521 # Find the field schema 

522 field_schema = None 

523 for fs in schema.fields: 

524 if fs.name == field_name: 

525 field_schema = fs 

526 break 

527 

528 if not field_schema: 

529 return None 

530 

531 # Skip auto-generated fields 

532 if field_name in ("id", "created_at", "updated_at"): 

533 return None 

534 

535 # Create the appropriate field component based on type 

536 return self._create_field_component(field_schema, value, item_id) 

537 

538 except (ImportError, AttributeError, TypeError, ValueError) as e: 

539 logger.debug( 

540 "Failed to build field component for %s.%s: %s", 

541 self.resource_name, 

542 field_name, 

543 e, 

544 ) 

545 return None 

546 

547 def _create_field_component( 

548 self, field_schema, value, item_id: str | None = None 

549 ) -> Any: 

550 """Create a field component from a FieldSchema for inline editing. 

551 

552 Args: 

553 field_schema: The field schema definition 

554 value: Current value for the field 

555 item_id: ID of the item being edited 

556 

557 Returns: 

558 Field component instance configured for inline editing 

559 """ 

560 # Determine action URL for inline editing 

561 action_url = f"{self._config.prefix}/{self.resource_name}/{item_id}/field/{field_schema.name}" 

562 

563 common_args = { 

564 "label": None, # No label for inline editing 

565 "required": field_schema.required, 

566 "readonly": field_schema.readonly, 

567 "help_text": None, # No help text for inline editing 

568 "default": field_schema.default, 

569 "placeholder": field_schema.placeholder, 

570 "name": field_schema.name, 

571 "hx_post": action_url, 

572 "hx_target": "closest td", # Replace the table cell 

573 "hx_swap": "outerHTML", 

574 "hx_trigger": "change", # Submit on change 

575 } 

576 

577 # Use registry to get the appropriate renderer 

578 renderer = _field_renderer_registry.get_renderer(field_schema) 

579 return renderer.render_field(field_schema, value, common_args) 

580 

581 

582__all__ = [ 

583 "BelongsToFieldRenderer", 

584 "BooleanFieldRenderer", 

585 "ColorFieldRenderer", 

586 "DateFieldRenderer", 

587 "DateTimeFieldRenderer", 

588 "DefaultFieldRenderer", 

589 "EmailFieldRenderer", 

590 "FieldRenderer", 

591 "FieldRendererProtocol", 

592 "FieldRendererRegistry", 

593 "HasManyFieldRenderer", 

594 "JsonFieldRenderer", 

595 "ListFieldRenderer", 

596 "MorphFieldRenderer", 

597 "MultiSelectFieldRenderer", 

598 "NumberFieldRenderer", 

599 "PasswordFieldRenderer", 

600 "SelectFieldRenderer", 

601 "TextAreaFieldRenderer", 

602 "TextFieldRenderer", 

603]