Coverage for src/lexigram/admin/forms/components.py: 0%

195 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-24 23:39 +0800

1"""Unified Form Components. 

2Includes FormBase, FormBuilder, and FormSchemaGenerator. 

3""" 

4 

5from __future__ import annotations 

6 

7import dataclasses 

8from dataclasses import dataclass, field 

9from datetime import date, datetime 

10from enum import Enum 

11import types as _builtin_types 

12from typing import ( 

13 TYPE_CHECKING, 

14 Any, 

15 ClassVar, 

16 Union, 

17 get_args, 

18 get_origin, 

19) 

20 

21if TYPE_CHECKING: 

22 from pydantic.fields import FieldInfo 

23 

24 from lexigram.admin.forms.builder import Form 

25 

26from lexigram.admin.exceptions import AdminValidationError 

27from lexigram.admin.schema import ( 

28 BelongsToField, 

29 BooleanField, 

30 DateField, 

31 DateTimeField, 

32 EnumField, 

33 FloatField, 

34 HasManyField, 

35 IntegerField, 

36 JsonField, 

37 MorphField, 

38 MultiSelectField, 

39 SchemaField, 

40 TextField, 

41) 

42from lexigram.contracts.exceptions import FieldError 

43from lexigram.result import Err, Ok, Result 

44from lexigram.ui import Component, el 

45 

46 

47@dataclass 

48class FormSchema: 

49 """Definition of a complete form structure.""" 

50 

51 fields: list[SchemaField] = field(default_factory=list) 

52 title: str | None = None 

53 description: str | None = None 

54 resource_name: str | None = None 

55 metadata: dict[str, Any] = field(default_factory=dict) 

56 layout: Any | None = None 

57 

58 def get_field(self, name: str) -> SchemaField | None: 

59 for f in self.fields: 

60 if f.name == name: 

61 return f 

62 return None 

63 

64 async def filter_for_user( 

65 self, 

66 user: Any, 

67 resource_name: str, 

68 permission_service: Any | None = None, 

69 ) -> FormSchema: 

70 """Return a copy of this schema with RBAC rules applied. 

71 

72 Args: 

73 user: Current user for permission checks. 

74 resource_name: Resource the form is rendered for. 

75 permission_service: PermissionService used for field-level 

76 checks. When None, the schema is returned unchanged. 

77 

78 Returns: 

79 New FormSchema with non-viewable fields removed and 

80 non-editable fields marked readonly. 

81 """ 

82 if permission_service is None: 

83 return self 

84 fields: list[SchemaField] = [] 

85 for f in self.fields: 

86 if not await permission_service.can_view_field(user, resource_name, f.name): 

87 continue 

88 schema_field = f 

89 if not await permission_service.can_edit_field(user, resource_name, f.name): 

90 schema_field = dataclasses.replace(f, readonly=True) 

91 fields.append(schema_field) 

92 return dataclasses.replace(self, fields=fields) 

93 

94 

95class FormMeta(type): 

96 """Metaclass to collect SchemaField instances from class attributes.""" 

97 

98 def __new__(mcs, name, bases, namespace) -> Any: 

99 fields: dict[str, SchemaField] = {} 

100 for base in bases: 

101 if hasattr(base, "_declared_fields"): 

102 fields.update(base._declared_fields) 

103 for key, value in list(namespace.items()): 

104 if isinstance(value, SchemaField): 

105 fields[key] = dataclasses.replace(value, name=key) 

106 namespace["_declared_fields"] = fields 

107 return super().__new__(mcs, name, bases, namespace) 

108 

109 

110class FormBase(Component, metaclass=FormMeta): 

111 """Base form class with lifecycle and rendering support.""" 

112 

113 _declared_fields: ClassVar[dict[str, SchemaField]] 

114 

115 def __init__( 

116 self, 

117 data: dict | None = None, 

118 initial: dict | None = None, 

119 action: str | None = None, 

120 method: str = "POST", 

121 hx_post: str | None = None, 

122 hx_target: str | None = None, 

123 **props: Any, 

124 ) -> None: 

125 super().__init__(**props) 

126 self.data = data or {} 

127 self.initial = initial or {} 

128 self.action = action 

129 self.method = method 

130 self.hx_post = hx_post 

131 self.hx_target = hx_target 

132 self.fields: dict[str, SchemaField] = dict(self._declared_fields) 

133 self.values: dict[str, Any] = {} 

134 self.errors: dict[str, list[str]] = {} 

135 self._initialize_fields() 

136 if self.data: 

137 self.is_valid() 

138 

139 def _initialize_fields(self) -> None: 

140 for name, field_schema in self._declared_fields.items(): 

141 if name in self.data: 

142 self.values[name] = self.data[name] 

143 elif name in self.initial: 

144 self.values[name] = self.initial[name] 

145 else: 

146 self.values[name] = field_schema.default 

147 

148 def is_valid(self) -> bool: 

149 self.errors = {} 

150 is_valid = True 

151 for name, field_schema in self._declared_fields.items(): 

152 value = self.values.get(name) 

153 raw = value if value is None or isinstance(value, str) else str(value) 

154 result = field_schema.from_form(raw) 

155 if result.is_err(): 

156 error = result.unwrap_err() 

157 self.errors[name] = [str(error)] 

158 is_valid = False 

159 continue 

160 cleaned = result.unwrap() 

161 if field_schema.required and ( 

162 cleaned is None or (isinstance(cleaned, str) and not cleaned) 

163 ): 

164 self.errors[name] = ["This field is required."] 

165 is_valid = False 

166 else: 

167 self.values[name] = cleaned 

168 return is_valid 

169 

170 async def validate(self) -> Result[dict[str, Any], AdminValidationError]: 

171 """Validate form data, returning a Result. 

172 

173 Returns: 

174 Ok containing cleaned data dict on success, or Err containing 

175 AdminValidationError with per-field FieldError detail on failure. 

176 """ 

177 if self.is_valid(): 

178 return Ok(self.cleaned_data) 

179 field_errors = [ 

180 FieldError(field=name, message=msgs[0], code="invalid") 

181 for name, msgs in self.errors.items() 

182 if msgs 

183 ] 

184 return Err( 

185 AdminValidationError( 

186 message="Form validation failed", 

187 errors=field_errors, 

188 ) 

189 ) 

190 

191 @property 

192 def cleaned_data(self) -> dict: 

193 return dict(self.values) 

194 

195 def render(self) -> Any: 

196 from lexigram.ui import Button 

197 

198 layout = getattr(self, "layout", None) 

199 if layout: 

200 if hasattr(layout, "render"): 

201 form_body = layout.render(self) 

202 elif isinstance(layout, list): 

203 form_body = el( 

204 "div", 

205 *[ 

206 (n.render(self) if hasattr(n, "render") else str(n)) 

207 for n in layout 

208 ], 

209 class_="space-y-6", 

210 ) 

211 else: 

212 form_body = str(layout) 

213 else: 

214 form_content = [ 

215 field_schema.render_form(self.values.get(name)) 

216 for name, field_schema in self.fields.items() 

217 ] 

218 form_body = el("div", *form_content, class_="space-y-4") 

219 

220 actions = el( 

221 "div", 

222 Button("Submit", type="submit", color="primary"), 

223 class_="flex justify-end pt-4 border-t border-border mt-6", 

224 ) 

225 

226 attrs = { 

227 "method": self.method, 

228 "class": "bg-card p-6 rounded-lg shadow", 

229 } 

230 if self.action: 

231 attrs["action"] = self.action 

232 if self.hx_post: 

233 attrs["hx-post"] = self.hx_post 

234 if self.hx_target: 

235 attrs["hx-target"] = self.hx_target 

236 

237 return el("form", form_body, actions, **attrs) 

238 

239 

240class FormSchemaGenerator: 

241 """Generates FormSchema from various data model types.""" 

242 

243 def __init__(self, resource_registry: dict[str, type] | None = None) -> None: 

244 self.resource_registry = resource_registry 

245 

246 def from_pydantic(self, model: type) -> FormSchema: 

247 """Generate a FormSchema from a model class. 

248 

249 Supports both Pydantic v2 ``BaseModel`` subclasses (via 

250 ``model_fields``) and ``DomainModel`` / stdlib dataclasses (via 

251 ``__dataclass_fields__``). 

252 """ 

253 fields = [] 

254 title = getattr(model, "__name__", "Form") 

255 

256 # Pydantic v2 BaseModel 

257 if hasattr(model, "model_fields") and isinstance(model.model_fields, dict): 

258 for name, field_info in model.model_fields.items(): 

259 fields.append(self._parse_pydantic_field(name, field_info)) 

260 elif hasattr(model, "__dataclass_fields__"): 

261 # DomainModel / stdlib dataclass 

262 import dataclasses 

263 import typing 

264 

265 type_hints = typing.get_type_hints(model) 

266 for dc_field in dataclasses.fields(model): 

267 name = dc_field.name 

268 annotation = type_hints.get(name) 

269 meta: dict = dict(dc_field.metadata) if dc_field.metadata else {} 

270 fields.append( 

271 self._parse_dataclass_field(name, annotation, dc_field, meta) 

272 ) 

273 else: 

274 raise TypeError( 

275 f"Unsupported model type: {model!r}. " 

276 "Expected a Pydantic BaseModel or dataclass-backed DomainModel." 

277 ) 

278 

279 return FormSchema(fields=fields, title=title) 

280 

281 def _parse_pydantic_field(self, name: str, field_info: FieldInfo) -> SchemaField: 

282 from pydantic_core import PydanticUndefined 

283 

284 label = ( 

285 str(field_info.title) 

286 if field_info.title 

287 else name.replace("_", " ").title() 

288 ) 

289 is_required = ( 

290 field_info.is_required() if hasattr(field_info, "is_required") else True 

291 ) 

292 if ( 

293 field_info.default is not PydanticUndefined 

294 or field_info.default_factory is not None 

295 ): 

296 is_required = False 

297 default = ( 

298 field_info.default if field_info.default is not PydanticUndefined else None 

299 ) 

300 return self._build_field( 

301 name, 

302 field_info.annotation, 

303 label=label, 

304 required=is_required, 

305 help_text=field_info.description, 

306 default=default, 

307 ) 

308 

309 def _parse_dataclass_field( 

310 self, 

311 name: str, 

312 annotation: Any, 

313 dc_field: Any, 

314 meta: dict, 

315 ) -> SchemaField: 

316 """Parse a stdlib dataclass field into a ``SchemaField``.""" 

317 import dataclasses 

318 

319 is_required = ( 

320 dc_field.default is dataclasses.MISSING 

321 and dc_field.default_factory is dataclasses.MISSING 

322 ) 

323 default = ( 

324 None 

325 if is_required 

326 else ( 

327 dc_field.default 

328 if dc_field.default is not dataclasses.MISSING 

329 else None 

330 ) 

331 ) 

332 return self._build_field( 

333 name, 

334 annotation, 

335 label=meta.get("title") or name.replace("_", " ").title(), 

336 required=is_required, 

337 help_text=meta.get("description"), 

338 default=default, 

339 ) 

340 

341 def _build_field( 

342 self, 

343 name: str, 

344 annotation: Any, 

345 *, 

346 label: str | None = None, 

347 required: bool = False, 

348 help_text: str | None = None, 

349 default: Any = None, 

350 ) -> SchemaField: 

351 """Map a model annotation to a ``SchemaField`` instance.""" 

352 

353 def is_model(t: Any) -> bool: 

354 return isinstance(t, type) and ( 

355 hasattr(t, "model_fields") or hasattr(t, "__dataclass_fields__") 

356 ) 

357 

358 # Detect belongs-to FK: field ends with _id 

359 if name.endswith("_id"): 

360 return BelongsToField( 

361 name=name, 

362 label=label, 

363 help_text=help_text, 

364 required=required, 

365 default=default, 

366 resource=f"{name[:-3]}s", 

367 ) 

368 

369 origin = get_origin(annotation) 

370 args = get_args(annotation) 

371 

372 # Detect polymorphic: Optional[Union[TypeA, TypeB]] 

373 _union_types = (Union, _builtin_types.UnionType) 

374 if origin in _union_types: 

375 inner_types = [t for t in args if t is not type(None)] 

376 non_primitive = [t for t in inner_types if is_model(t)] 

377 if len(non_primitive) >= 2: 

378 return MorphField( 

379 name=name, 

380 label=label, 

381 help_text=help_text, 

382 required=required, 

383 default=default, 

384 resource=name, 

385 ) 

386 if len(inner_types) == 1: 

387 return self._build_field( 

388 name, 

389 inner_types[0], 

390 label=label, 

391 required=required, 

392 help_text=help_text, 

393 default=default, 

394 ) 

395 return TextField( 

396 name=name, 

397 label=label, 

398 help_text=help_text, 

399 required=required, 

400 default=default, 

401 ) 

402 

403 # Detect has-many: list of domain models 

404 if origin is list and args: 

405 if is_model(args[0]): 

406 return HasManyField( 

407 name=name, 

408 label=label, 

409 help_text=help_text, 

410 required=required, 

411 default=default, 

412 resource=f"{args[0].__name__.lower()}s", 

413 ) 

414 if args[0] is str: 

415 return MultiSelectField( 

416 name=name, 

417 label=label, 

418 help_text=help_text, 

419 required=required, 

420 default=default, 

421 ) 

422 return JsonField( 

423 name=name, 

424 label=label, 

425 help_text=help_text, 

426 required=required, 

427 default=default, 

428 ) 

429 

430 if isinstance(annotation, type) and issubclass(annotation, Enum): 

431 return EnumField( 

432 name=name, 

433 label=label, 

434 help_text=help_text, 

435 required=required, 

436 default=default, 

437 enum_cls=annotation, 

438 ) 

439 if annotation is str: 

440 return TextField( 

441 name=name, 

442 label=label, 

443 help_text=help_text, 

444 required=required, 

445 default=default, 

446 ) 

447 if annotation is int: 

448 return IntegerField( 

449 name=name, 

450 label=label, 

451 help_text=help_text, 

452 required=required, 

453 default=default, 

454 ) 

455 if annotation is float: 

456 return FloatField( 

457 name=name, 

458 label=label, 

459 help_text=help_text, 

460 required=required, 

461 default=default, 

462 ) 

463 if annotation is bool: 

464 return BooleanField( 

465 name=name, 

466 label=label, 

467 help_text=help_text, 

468 required=required, 

469 default=default, 

470 ) 

471 if annotation is date: 

472 return DateField( 

473 name=name, 

474 label=label, 

475 help_text=help_text, 

476 required=required, 

477 default=default, 

478 ) 

479 if annotation is datetime: 

480 return DateTimeField( 

481 name=name, 

482 label=label, 

483 help_text=help_text, 

484 required=required, 

485 default=default, 

486 ) 

487 # Nested model → structured JSON input 

488 if is_model(annotation): 

489 return JsonField( 

490 name=name, 

491 label=label, 

492 help_text=help_text, 

493 required=required, 

494 default=default, 

495 ) 

496 return TextField( 

497 name=name, 

498 label=label, 

499 help_text=help_text, 

500 required=required, 

501 default=default, 

502 ) 

503 

504 

505def build_form(**fields) -> Form[Any]: 

506 """Build a simple form dynamically from keyword field configs.""" 

507 from lexigram.admin.forms.builder import FormBuilder 

508 

509 builder = FormBuilder.create() 

510 for name, config in fields.items(): 

511 builder.text(name, label=config.get("label")) 

512 return builder.build()