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

151 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-08-11 02:23 +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.di.decorators import inject 

11from lexigram.logging import get_logger 

12from lexigram.ui import el, render_to_string 

13 

14logger = get_logger(__name__) 

15 

16 

17class FieldRendererProtocol(Protocol): 

18 """Protocol for field renderers.""" 

19 

20 def can_render(self, field_type: Any, field_schema: Any) -> bool: ... 

21 

22 def render_field( 

23 self, 

24 field_schema: Any, 

25 value: Any, 

26 common_args: dict[str, Any], 

27 ) -> Any: ... 

28 

29 

30class TextAreaFieldRenderer: 

31 """Renderer for TEXTAREA fields.""" 

32 

33 def can_render(self, field_type: Any, field_schema: Any) -> bool: 

34 from lexigram.admin.forms.fields import FieldType 

35 

36 return field_type == FieldType.TEXTAREA 

37 

38 def render_field( 

39 self, 

40 field_schema: Any, 

41 value: Any, 

42 common_args: dict[str, Any], 

43 ) -> Any: 

44 from lexigram.admin.forms.fields import TextAreaField 

45 

46 return TextAreaField(**common_args) 

47 

48 

49class NumberFieldRenderer: 

50 """Renderer for NUMBER fields.""" 

51 

52 def can_render(self, field_type: Any, field_schema: Any) -> bool: 

53 from lexigram.admin.forms.fields import FieldType 

54 

55 return field_type == FieldType.NUMBER 

56 

57 def render_field( 

58 self, 

59 field_schema: Any, 

60 value: Any, 

61 common_args: dict[str, Any], 

62 ) -> Any: 

63 from lexigram.admin.forms.fields import IntegerField 

64 

65 return IntegerField(**common_args) 

66 

67 

68class CheckboxFieldRenderer: 

69 """Renderer for CHECKBOX fields.""" 

70 

71 def can_render(self, field_type: Any, field_schema: Any) -> bool: 

72 from lexigram.admin.forms.fields import FieldType 

73 

74 return field_type == FieldType.CHECKBOX 

75 

76 def render_field( 

77 self, 

78 field_schema: Any, 

79 value: Any, 

80 common_args: dict[str, Any], 

81 ) -> Any: 

82 from lexigram.admin.forms.fields import BooleanField 

83 

84 return BooleanField(**common_args) 

85 

86 

87class SelectFieldRenderer: 

88 """Renderer for SELECT fields.""" 

89 

90 def can_render(self, field_type: Any, field_schema: Any) -> bool: 

91 from lexigram.admin.forms.fields import FieldType 

92 

93 return field_type == FieldType.SELECT and field_schema.options 

94 

95 def render_field( 

96 self, 

97 field_schema: Any, 

98 value: Any, 

99 common_args: dict[str, Any], 

100 ) -> Any: 

101 from lexigram.admin.forms.fields import SelectField 

102 

103 options = [ 

104 ( 

105 opt.get("value", opt.get("id", "")), 

106 opt.get("label", opt.get("name", "")), 

107 ) 

108 for opt in field_schema.options 

109 ] 

110 return SelectField(options=options, **common_args) 

111 

112 

113class DateFieldRenderer: 

114 """Renderer for DATE/DATETIME fields.""" 

115 

116 def can_render(self, field_type: Any, field_schema: Any) -> bool: 

117 from lexigram.admin.forms.fields import FieldType 

118 

119 return field_type in (FieldType.DATE, FieldType.DATETIME) 

120 

121 def render_field( 

122 self, 

123 field_schema: Any, 

124 value: Any, 

125 common_args: dict[str, Any], 

126 ) -> Any: 

127 from lexigram.admin.forms.fields import DateField 

128 

129 return DateField(**common_args) 

130 

131 

132class EmailFieldRenderer: 

133 """Renderer for EMAIL fields.""" 

134 

135 def can_render(self, field_type: Any, field_schema: Any) -> bool: 

136 from lexigram.admin.forms.fields import FieldType 

137 

138 return field_type == FieldType.EMAIL 

139 

140 def render_field( 

141 self, 

142 field_schema: Any, 

143 value: Any, 

144 common_args: dict[str, Any], 

145 ) -> Any: 

146 from lexigram.admin.forms.fields import TextField 

147 

148 return TextField(type="email", **common_args) 

149 

150 

151class PasswordFieldRenderer: 

152 """Renderer for PASSWORD fields.""" 

153 

154 def can_render(self, field_type: Any, field_schema: Any) -> bool: 

155 from lexigram.admin.forms.fields import FieldType 

156 

157 return field_type == FieldType.PASSWORD 

158 

159 def render_field( 

160 self, 

161 field_schema: Any, 

162 value: Any, 

163 common_args: dict[str, Any], 

164 ) -> Any: 

165 from lexigram.admin.forms.fields import TextField 

166 

167 return TextField(type="password", **common_args) 

168 

169 

170class ListFieldRenderer: 

171 """Renderer for LIST fields (e.g., list[str]).""" 

172 

173 def can_render(self, field_type: Any, field_schema: Any) -> bool: 

174 from lexigram.admin.forms.fields import FieldType 

175 

176 return field_type == FieldType.LIST 

177 

178 def render_field( 

179 self, 

180 field_schema: Any, 

181 value: Any, 

182 common_args: dict[str, Any], 

183 ) -> Any: 

184 from lexigram.admin.forms.fields import TextAreaField 

185 

186 display_value = ( 

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

188 if isinstance(value, list) 

189 else str(value or "") 

190 ) 

191 args = {k: v for k, v in common_args.items() if k != "placeholder"} 

192 return TextAreaField( 

193 **args, 

194 placeholder=common_args.get("placeholder") 

195 or "Enter comma-separated values", 

196 ) 

197 

198 

199class BelongsToFieldRenderer: 

200 """Renders a BELONGS_TO field as a SelectField with related record options.""" 

201 

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

203 from lexigram.admin.forms.fields import FieldType 

204 

205 return field_type == FieldType.BELONGS_TO 

206 

207 def render_field( 

208 self, 

209 field_schema: Any, 

210 value: Any, 

211 common_args: dict[str, Any], 

212 ) -> Any: 

213 from lexigram.admin.forms.fields import SelectField 

214 

215 options = field_schema.options or [] 

216 return SelectField( 

217 options=[(o.get("value"), o.get("label")) for o in options], 

218 **common_args, 

219 ) 

220 

221 

222class HasManyFieldRenderer: 

223 """Renders a HAS_MANY field as a MultiSelectField.""" 

224 

225 def can_render(self, field_type: Any, field_schema: Any) -> bool: 

226 from lexigram.admin.forms.fields import FieldType 

227 

228 return field_type == FieldType.HAS_MANY 

229 

230 def render_field( 

231 self, 

232 field_schema: Any, 

233 value: Any, 

234 common_args: dict[str, Any], 

235 ) -> Any: 

236 from lexigram.admin.forms.fields import SelectField 

237 

238 options = field_schema.options or [] 

239 return SelectField( 

240 options=[(o.get("value"), o.get("label")) for o in options], 

241 multiple=True, 

242 **common_args, 

243 ) 

244 

245 

246class DefaultFieldRenderer: 

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

248 

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

250 return True 

251 

252 def render_field( 

253 self, 

254 field_schema: Any, 

255 value: Any, 

256 common_args: dict[str, Any], 

257 ) -> Any: 

258 from lexigram.admin.forms.fields import TextField 

259 

260 return TextField(**common_args) 

261 

262 

263class FieldRendererRegistry: 

264 """Registry for field renderers.""" 

265 

266 def __init__(self) -> None: 

267 self._renderers: list[FieldRendererProtocol] = [] 

268 self._register_renderers() 

269 

270 def _register_renderers(self) -> None: 

271 self._renderers = [ 

272 TextAreaFieldRenderer(), 

273 NumberFieldRenderer(), 

274 CheckboxFieldRenderer(), 

275 SelectFieldRenderer(), 

276 DateFieldRenderer(), 

277 EmailFieldRenderer(), 

278 PasswordFieldRenderer(), 

279 ListFieldRenderer(), 

280 BelongsToFieldRenderer(), 

281 HasManyFieldRenderer(), 

282 DefaultFieldRenderer(), 

283 ] 

284 

285 def get_renderer( 

286 self, 

287 field_type: Any, 

288 field_schema: Any, 

289 ) -> FieldRendererProtocol: 

290 for renderer in self._renderers: 

291 if renderer.can_render(field_type, field_schema): 

292 return renderer 

293 return DefaultFieldRenderer() 

294 

295 

296# Global registry instance 

297_field_renderer_registry = FieldRendererRegistry() 

298 

299 

300@inject 

301class FieldRenderer: 

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

303 

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

305 self._config = config 

306 self.resource_name = resource_name 

307 

308 async def render_field( 

309 self, 

310 request, 

311 resource, 

312 field_name: str, 

313 item_id: str | None = None, 

314 user=None, 

315 ) -> HTMLResponse: 

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

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

318 

319 # Fetch current item data 

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

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

322 try: 

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

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

325 if item: 

326 current_data = ( 

327 item.model_dump() 

328 if hasattr(item, "model_dump") 

329 else dict(item) 

330 ) 

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

332 logger.debug( 

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

334 self.resource_name, 

335 item_id, 

336 e, 

337 ) 

338 

339 # Get field value 

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

341 

342 # Build field component 

343 field_component = self._build_field_component( 

344 resource, 

345 field_name, 

346 field_value, 

347 item_id, 

348 ) 

349 

350 if not field_component: 

351 return HTMLResponse( 

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

353 status_code=404, 

354 ) 

355 

356 # Return just the field component for HTMX inline editing 

357 return HTMLResponse(render_to_string(field_component)) 

358 

359 def _build_field_component( 

360 self, 

361 resource, 

362 field_name: str, 

363 value, 

364 item_id: str | None = None, 

365 ) -> Any: 

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

367 

368 Args: 

369 resource: The resource class 

370 field_name: Name of the field to render 

371 value: Current value of the field 

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

373 

374 Returns: 

375 Field component ready for rendering 

376 """ 

377 # Try to get field schema from resource model 

378 if not resource or not resource.model: 

379 return None 

380 

381 try: 

382 from lexigram.admin.forms.components import FormSchemaGenerator 

383 

384 generator = FormSchemaGenerator() 

385 schema = generator.from_pydantic(resource.model) 

386 

387 # Find the field schema 

388 field_schema = None 

389 for fs in schema.fields: 

390 if fs.name == field_name: 

391 field_schema = fs 

392 break 

393 

394 if not field_schema: 

395 return None 

396 

397 # Skip auto-generated fields 

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

399 return None 

400 

401 # Create the appropriate field component based on type 

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

403 

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

405 logger.debug( 

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

407 self.resource_name, 

408 field_name, 

409 e, 

410 ) 

411 return None 

412 

413 def _create_field_component( 

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

415 ) -> Any: 

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

417 

418 Args: 

419 field_schema: The field schema definition 

420 value: Current value for the field 

421 item_id: ID of the item being edited 

422 

423 Returns: 

424 Field component instance configured for inline editing 

425 """ 

426 # Determine action URL for inline editing 

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

428 

429 common_args = { 

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

431 "required": field_schema.required, 

432 "disabled": not field_schema.editable, 

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

434 "default": field_schema.default, 

435 "placeholder": field_schema.placeholder, 

436 "name": field_schema.name, 

437 "hx_post": action_url, 

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

439 "hx_swap": "outerHTML", 

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

441 } 

442 

443 # Use registry to get the appropriate renderer 

444 renderer = _field_renderer_registry.get_renderer( 

445 field_schema.type, 

446 field_schema, 

447 ) 

448 field = renderer.render_field(field_schema, value, common_args) 

449 

450 # Bind the value to the field 

451 return field.bind(value) 

452 

453 

454__all__ = [ 

455 "BelongsToFieldRenderer", 

456 "CheckboxFieldRenderer", 

457 "DateFieldRenderer", 

458 "DefaultFieldRenderer", 

459 "EmailFieldRenderer", 

460 "FieldRenderer", 

461 "FieldRendererProtocol", 

462 "FieldRendererRegistry", 

463 "HasManyFieldRenderer", 

464 "NumberFieldRenderer", 

465 "PasswordFieldRenderer", 

466 "SelectFieldRenderer", 

467 # Renderer classes 

468 "TextAreaFieldRenderer", 

469]