Coverage for src / lexigram / admin / forms / builder.py: 25%

229 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-08-13 22:14 +0800

1"""Form builder with Pydantic integration. 

2 

3Provides a declarative API for building forms from Pydantic models 

4with automatic validation, HTMX integration, and customization. 

5""" 

6 

7from __future__ import annotations 

8 

9from dataclasses import dataclass 

10from dataclasses import field as dataclass_field 

11from datetime import date, datetime 

12from enum import Enum 

13from typing import ( 

14 TYPE_CHECKING, 

15 Any, 

16 Generic, 

17 TypeVar, 

18 get_args, 

19 get_origin, 

20 get_type_hints, 

21) 

22 

23from lexigram.admin.forms.fields import ( 

24 AbstractField, 

25 BooleanField, 

26 DateField, 

27 IntegerField, 

28 SelectField, 

29 TextAreaField, 

30 TextField, 

31) 

32from lexigram.domain import DomainModel 

33from lexigram.logging import get_logger 

34from lexigram.primitives.builder import AbstractBuilder 

35from lexigram.ui import el, render_to_string 

36 

37if TYPE_CHECKING: 

38 from collections.abc import Callable 

39 

40logger = get_logger(__name__) 

41 

42T = TypeVar("T", bound=DomainModel) 

43 

44 

45@dataclass 

46class FieldConfig: 

47 """Configuration for a form field.""" 

48 

49 name: str 

50 label: str | None = None 

51 help_text: str | None = None 

52 placeholder: str | None = None 

53 required: bool = True 

54 disabled: bool = False 

55 hidden: bool = False 

56 widget: str | None = None # Override default widget 

57 order: int = 100 

58 group: str | None = None 

59 col_span: int = 1 # For grid layouts 

60 validators: list[Callable] = dataclass_field(default_factory=list) 

61 props: dict[str, Any] = dataclass_field(default_factory=dict) 

62 

63 

64class FormBuilder(AbstractBuilder["Form[T]"], Generic[T]): 

65 """Build forms from Pydantic models. 

66 

67 Usage: 

68 @dataclass(init=False) 

69 class UserForm(DomainModel): 

70 name: str 

71 email: str 

72 age: int 

73 

74 form = (FormBuilder(UserForm) 

75 .field("name", label="Full Name", placeholder="Enter your name") 

76 .field("email", widget="email") 

77 .field("age", help_text="Must be 18 or older") 

78 .exclude("password") 

79 .build()) 

80 """ 

81 

82 def __init__(self, model: type[T]): 

83 super().__init__() 

84 self.model = model 

85 self._field_configs: dict[str, FieldConfig] = {} 

86 self._excluded: set[str] = set() 

87 self._groups: dict[str, list[str]] = {} 

88 self._layout: str = "vertical" # "vertical", "horizontal", "grid" 

89 self._columns: int = 1 

90 self._submit_label: str = "Submit" 

91 self._cancel_url: str | None = None 

92 

93 self._form_class: type | None = None 

94 

95 # Extract fields from model 

96 self._init_from_model() 

97 

98 def _init_from_model(self) -> None: 

99 """Initialize field configs from Pydantic model.""" 

100 hints = get_type_hints(self.model) 

101 model_fields = self.model.model_fields # type: ignore[attr-defined] 

102 

103 for name, field_info in model_fields.items(): 

104 hints.get(name, str) 

105 

106 # Determine if required 

107 required = field_info.is_required() 

108 

109 # Generate label from name 

110 label = name.replace("_", " ").title() 

111 

112 # Get help text from description 

113 help_text = field_info.description 

114 

115 # Determine default 

116 

117 self._field_configs[name] = FieldConfig( 

118 name=name, 

119 label=label, 

120 help_text=help_text, 

121 required=required, 

122 ) 

123 

124 def field( 

125 self, 

126 name: str, 

127 *, 

128 label: str | None = None, 

129 help_text: str | None = None, 

130 placeholder: str | None = None, 

131 required: bool | None = None, 

132 disabled: bool = False, 

133 hidden: bool = False, 

134 widget: str | None = None, 

135 order: int | None = None, 

136 group: str | None = None, 

137 col_span: int = 1, 

138 validators: list[Callable] | None = None, 

139 **props: Any, 

140 ) -> FormBuilder[T]: 

141 """Configure a field.""" 

142 if name not in self._field_configs: 

143 raise ValueError(f"Unknown field: {name}") 

144 

145 config = self._field_configs[name] 

146 

147 if label is not None: 

148 config.label = label 

149 if help_text is not None: 

150 config.help_text = help_text 

151 if placeholder is not None: 

152 config.placeholder = placeholder 

153 if required is not None: 

154 config.required = required 

155 if disabled: 

156 config.disabled = disabled 

157 if hidden: 

158 config.hidden = hidden 

159 if widget is not None: 

160 config.widget = widget 

161 if order is not None: 

162 config.order = order 

163 if group is not None: 

164 config.group = group 

165 if group not in self._groups: 

166 self._groups[group] = [] 

167 if name not in self._groups[group]: 

168 self._groups[group].append(name) 

169 if validators: 

170 config.validators.extend(validators) 

171 

172 config.col_span = col_span 

173 config.props.update(props) 

174 

175 return self 

176 

177 def exclude(self, *fields: str) -> FormBuilder[T]: 

178 """Exclude fields from the form.""" 

179 self._excluded.update(fields) 

180 return self 

181 

182 def include_only(self, *fields: str) -> FormBuilder[T]: 

183 """Include only specified fields.""" 

184 all_fields = set(self._field_configs.keys()) 

185 self._excluded = all_fields - set(fields) 

186 return self 

187 

188 def group( 

189 self, 

190 name: str, 

191 *fields: str, 

192 label: str | None = None, 

193 ) -> FormBuilder[T]: 

194 """Group fields together.""" 

195 self._groups[name] = list(fields) 

196 for field_name in fields: 

197 if field_name in self._field_configs: 

198 self._field_configs[field_name].group = name 

199 return self 

200 

201 def layout(self, layout: str, columns: int = 1) -> FormBuilder[T]: 

202 """Set form layout.""" 

203 self._layout = layout 

204 self._columns = columns 

205 return self 

206 

207 def submit_label(self, label: str) -> FormBuilder[T]: 

208 """Set submit button label.""" 

209 self._submit_label = label 

210 return self 

211 

212 def cancel_url(self, url: str) -> FormBuilder[T]: 

213 """Set cancel button URL.""" 

214 self._cancel_url = url 

215 return self 

216 

217 @classmethod 

218 def create(cls, form_class: type | None = None) -> FormBuilder: 

219 """Create a fluent FormBuilder without a Pydantic model. 

220 

221 Supports the create()/text()/build() API for lightweight dynamic forms. 

222 """ 

223 builder: FormBuilder = cls.__new__(cls) 

224 super(FormBuilder, builder).__init__() 

225 builder.model = None # type: ignore[assignment] 

226 builder._field_configs = {} 

227 builder._excluded = set() 

228 builder._groups = {} 

229 builder._layout = "vertical" 

230 builder._columns = 1 

231 builder._submit_label = "Submit" 

232 builder._cancel_url = None 

233 builder._form_class = form_class 

234 return builder 

235 

236 def text(self, name: str, **kwargs: Any) -> FormBuilder[T]: 

237 """Add a text field to the form (fluent API).""" 

238 label: str = kwargs.get("label") or name.replace("_", " ").title() 

239 self._field_configs[name] = FieldConfig( 

240 name=name, 

241 label=label, 

242 help_text=kwargs.get("help_text"), 

243 placeholder=kwargs.get("placeholder"), 

244 required=kwargs.get("required", True), 

245 ) 

246 return self 

247 

248 def build(self) -> Form[T]: 

249 """Build the form.""" 

250 fields: list[AbstractField] = [] 

251 hints = get_type_hints(self.model) if self.model is not None else {} 

252 

253 # Sort by order 

254 sorted_configs = sorted(self._field_configs.items(), key=lambda x: x[1].order) 

255 

256 for name, config in sorted_configs: 

257 if name in self._excluded or config.hidden: 

258 continue 

259 

260 field_type = hints.get(name, str) 

261 field = self._create_field(name, field_type, config) 

262 fields.append(field) 

263 

264 return Form( 

265 model=self.model, 

266 fields=fields, 

267 groups=self._groups, 

268 layout=self._layout, 

269 columns=self._columns, 

270 submit_label=self._submit_label, 

271 cancel_url=self._cancel_url, 

272 ) 

273 

274 def _create_field( 

275 self, 

276 name: str, 

277 field_type: type, 

278 config: FieldConfig, 

279 ) -> AbstractField: 

280 """Create a Field instance from type and config.""" 

281 # Handle widget override 

282 if config.widget: 

283 return self._create_field_by_widget(name, config) 

284 

285 # Handle Optional types 

286 origin = get_origin(field_type) 

287 if origin is type(None) or str(origin) == "typing.Union": 

288 args = get_args(field_type) 

289 # Get the non-None type 

290 for arg in args: 

291 if arg is not type(None): 

292 field_type = arg 

293 config.required = False 

294 break 

295 

296 # Map Python types to Field classes 

297 if field_type is str: 

298 return TextField( 

299 name=name, 

300 label=config.label, 

301 help_text=config.help_text, 

302 placeholder=config.placeholder, 

303 required=config.required, 

304 disabled=config.disabled, 

305 validators=config.validators, 

306 **config.props, 

307 ) 

308 if field_type is int: 

309 return IntegerField( 

310 name=name, 

311 label=config.label, 

312 help_text=config.help_text, 

313 placeholder=config.placeholder, 

314 required=config.required, 

315 disabled=config.disabled, 

316 validators=config.validators, 

317 **config.props, 

318 ) 

319 if field_type is bool: 

320 return BooleanField( 

321 name=name, 

322 label=config.label, 

323 help_text=config.help_text, 

324 required=config.required, 

325 disabled=config.disabled, 

326 validators=config.validators, 

327 **config.props, 

328 ) 

329 if field_type in (date, datetime): 

330 return DateField( 

331 name=name, 

332 label=config.label, 

333 help_text=config.help_text, 

334 required=config.required, 

335 disabled=config.disabled, 

336 validators=config.validators, 

337 **config.props, 

338 ) 

339 if issubclass(field_type, Enum) if isinstance(field_type, type) else False: 

340 options = [(e.value, e.name.replace("_", " ").title()) for e in field_type] # type: ignore[attr-defined] 

341 return SelectField( 

342 name=name, 

343 label=config.label, 

344 help_text=config.help_text, 

345 options=options, 

346 required=config.required, 

347 disabled=config.disabled, 

348 validators=config.validators, 

349 **config.props, 

350 ) 

351 # Default to text 

352 return TextField( 

353 name=name, 

354 label=config.label, 

355 help_text=config.help_text, 

356 placeholder=config.placeholder, 

357 required=config.required, 

358 disabled=config.disabled, 

359 validators=config.validators, 

360 **config.props, 

361 ) 

362 

363 def _create_field_by_widget(self, name: str, config: FieldConfig) -> AbstractField: 

364 """Create field by widget name.""" 

365 widget = config.widget 

366 

367 if widget == "textarea": 

368 return TextAreaField( 

369 name=name, 

370 label=config.label, 

371 help_text=config.help_text, 

372 placeholder=config.placeholder, 

373 required=config.required, 

374 disabled=config.disabled, 

375 **config.props, 

376 ) 

377 if widget == "email": 

378 return TextField( 

379 name=name, 

380 label=config.label, 

381 help_text=config.help_text, 

382 placeholder=config.placeholder, 

383 required=config.required, 

384 disabled=config.disabled, 

385 type="email", 

386 **config.props, 

387 ) 

388 if widget == "password": 

389 return TextField( 

390 name=name, 

391 label=config.label, 

392 help_text=config.help_text, 

393 placeholder=config.placeholder, 

394 required=config.required, 

395 disabled=config.disabled, 

396 type="password", 

397 **config.props, 

398 ) 

399 if widget == "switch": 

400 return BooleanField( 

401 name=name, 

402 label=config.label, 

403 help_text=config.help_text, 

404 required=config.required, 

405 disabled=config.disabled, 

406 widget="switch", 

407 **config.props, 

408 ) 

409 return TextField( 

410 name=name, 

411 label=config.label, 

412 help_text=config.help_text, 

413 placeholder=config.placeholder, 

414 required=config.required, 

415 disabled=config.disabled, 

416 **config.props, 

417 ) 

418 

419 

420@dataclass 

421class FormResult(Generic[T]): 

422 """Result of form validation.""" 

423 

424 success: bool 

425 data: T | None = None 

426 errors: dict[str, list[str]] = dataclass_field(default_factory=dict) 

427 

428 @property 

429 def is_valid(self) -> bool: 

430 return self.success and not self.errors 

431 

432 

433class Form(Generic[T]): 

434 """A built form ready for rendering and validation.""" 

435 

436 def __init__( 

437 self, 

438 model: type[T], 

439 fields: list[AbstractField], 

440 groups: dict[str, list[str]], 

441 layout: str, 

442 columns: int, 

443 submit_label: str, 

444 cancel_url: str | None, 

445 ): 

446 self.model = model 

447 self.fields = {f.name: f for f in fields} 

448 self.field_list = fields 

449 self.groups = groups 

450 self.layout = layout 

451 self.columns = columns 

452 self.submit_label = submit_label 

453 self.cancel_url = cancel_url 

454 

455 def bind(self, data: dict[str, Any]) -> Form[T]: 

456 """Bind data to form fields.""" 

457 for name, field in self.fields.items(): 

458 if name in data: 

459 field.value = data[name] 

460 field.is_bound = True 

461 return self 

462 

463 async def validate(self, data: dict[str, Any]) -> FormResult[T]: 

464 """Validate form data against model.""" 

465 errors: dict[str, list[str]] = {} 

466 

467 # Run field-level validation first 

468 for name, field in self.fields.items(): 

469 value = data.get(name) 

470 try: 

471 field.validate(value) 

472 # Run async validation if present 

473 if field.async_validator: 

474 await field.run_async_validation(value) 

475 except ValueError as e: 

476 errors.setdefault(name, []).append(str(e)) 

477 field.errors = [str(e)] 

478 

479 # If field validation passed, try Pydantic model validation 

480 if not errors: 

481 try: 

482 instance = self.model.model_validate(data) 

483 return FormResult(success=True, data=instance) 

484 except (ValueError, TypeError, AttributeError) as e: 

485 if hasattr(e, "errors"): 

486 for error in e.errors(): 

487 field_name = ( 

488 str(error["loc"][0]) if error["loc"] else "__root__" 

489 ) 

490 message = error["msg"] 

491 errors.setdefault(field_name, []).append(message) 

492 if field_name in self.fields: 

493 self.fields[field_name].errors = [message] 

494 else: 

495 errors.setdefault("__root__", []).append(str(e)) 

496 

497 return FormResult(success=False, errors=errors) 

498 

499 def render_html(self, action: str, method: str = "POST") -> str: 

500 """Render form as HTML.""" 

501 field_els = [ 

502 el("div", field.render(), class_="form-field") for field in self.field_list 

503 ] 

504 btns: list[Any] = [ 

505 el("button", self.submit_label, type="submit", class_="btn btn-primary") 

506 ] 

507 if self.cancel_url: 

508 btns.append( 

509 el("a", "Cancel", href=self.cancel_url, class_="btn btn-secondary") 

510 ) 

511 return render_to_string( 

512 el( 

513 "form", 

514 el( 

515 "div", 

516 *field_els, 

517 class_="form-fields", 

518 style=f"display:grid;grid-template-columns:repeat({self.columns},1fr);gap:1rem", 

519 ), 

520 el("div", *btns, class_="form-actions", style="margin-top:1.5rem"), 

521 action=action, 

522 method=method, 

523 class_=f"admin-form layout-{self.layout}", 

524 ) 

525 ) 

526 

527 def render_htmx( 

528 self, 

529 action: str, 

530 target: str = "#form-result", 

531 swap: str = "innerHTML", 

532 ) -> str: 

533 """Render form with HTMX attributes.""" 

534 field_els = [ 

535 el("div", field.render(), class_="form-field") for field in self.field_list 

536 ] 

537 btns: list[Any] = [ 

538 el("button", self.submit_label, type="submit", class_="btn btn-primary") 

539 ] 

540 if self.cancel_url: 

541 btns.append( 

542 el("a", "Cancel", href=self.cancel_url, class_="btn btn-secondary") 

543 ) 

544 spinner_id = target.lstrip("#") + "-spinner" 

545 return render_to_string( 

546 el( 

547 "form", 

548 el("div", "Saving...", id=spinner_id, class_="htmx-indicator"), 

549 el( 

550 "div", 

551 *field_els, 

552 class_="form-fields", 

553 style=f"display:grid;grid-template-columns:repeat({self.columns},1fr);gap:1rem", 

554 ), 

555 el("div", *btns, class_="form-actions", style="margin-top:1.5rem"), 

556 **{ 

557 "hx-post": action, 

558 "hx-target": target, 

559 "hx-swap": swap, 

560 "hx-indicator": "#form-spinner", 

561 "class": f"admin-form layout-{self.layout}", 

562 }, 

563 ) 

564 )