Coverage for formkit_ninja / admin.py: 55%

277 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-23 06:00 +0000

1from __future__ import annotations 

2 

3import logging 

4import operator 

5from functools import reduce 

6from typing import Any 

7 

8import django.core.exceptions 

9from django import forms 

10from django.contrib import admin 

11from django.http import HttpRequest 

12 

13from formkit_ninja import formkit_schema, models 

14 

15logger = logging.getLogger(__name__) 

16 

17 

18# Define fields in JSON with a tuple of fields 

19# The key of the dict provided is a JSON field on the model 

20JsonFieldDefn = dict[str, tuple[str | tuple[str, str], ...]] 

21 

22 

23class ItemAdmin(admin.ModelAdmin): 

24 list_display = ("name",) 

25 

26 

27class JSONMappingMixin: 

28 """ 

29 Mixin to handle mapping between flat form fields and nested JSON fields. 

30 """ 

31 

32 _json_fields: JsonFieldDefn = {} 

33 

34 def get_json_fields(self) -> JsonFieldDefn: 

35 return self._json_fields 

36 

37 def _extract_field_value(self, values: dict, json_field: str): 

38 if "__" in json_field: 

39 nested_field_name, nested_key = json_field.split("__", 1) 

40 nested = values.get(nested_field_name) 

41 if isinstance(nested, dict): 

42 return nested.get(nested_key) 

43 return None 

44 return values.get(json_field) 

45 

46 def _populate_form_fields(self, instance): 

47 for field, keys in self.get_json_fields().items(): 

48 values = getattr(instance, field, {}) or {} 

49 for key in keys: 

50 form_field, json_field = key if isinstance(key, tuple) else (key, key) 

51 if f := self.fields.get(form_field): 

52 val = self._extract_field_value(values, json_field) 

53 if val is None: 

54 # Fallback: check if the json_field corresponds to a model attribute 

55 # using the same promotion logic as in models.py 

56 mapping = { 

57 "addLabel": "add_label", 

58 "upControl": "up_control", 

59 "downControl": "down_control", 

60 "sectionsSchema": "sections_schema", 

61 } 

62 attr_name = mapping.get(json_field, json_field) 

63 if hasattr(instance, attr_name): 

64 val = getattr(instance, attr_name) 

65 f.initial = val 

66 

67 def _build_json_data(self, keys: tuple, existing_data: dict) -> dict: 

68 data = existing_data.copy() if isinstance(existing_data, dict) else {} 

69 for key in keys: 

70 form_field, json_field = key if isinstance(key, tuple) else (key, key) 

71 if form_field not in self.cleaned_data: # type: ignore[attr-defined] 

72 continue 

73 

74 val = self.cleaned_data[form_field] # type: ignore[attr-defined] 

75 if "__" in json_field: 

76 nested_field_name, nested_key = json_field.split("__", 1) 

77 if not isinstance(data.get(nested_field_name), dict): 

78 data[nested_field_name] = {} 

79 data[nested_field_name][nested_key] = val 

80 else: 

81 data[json_field] = val 

82 return data 

83 

84 def save_json_fields(self, instance): 

85 for field, keys in self.get_json_fields().items(): 

86 existing = getattr(instance, field, {}) or {} 

87 setattr(instance, field, self._build_json_data(keys, existing)) 

88 

89 def clean(self) -> dict[str, Any]: 

90 cleaned_data = super().clean() # type: ignore[misc] 

91 # Find any field mapped to "name" in JSON and validate it 

92 for field, keys in self.get_json_fields().items(): 

93 for key in keys: 

94 form_field, json_field = key if isinstance(key, tuple) else (key, key) 

95 if json_field == "name" and form_field in cleaned_data: 

96 val = cleaned_data[form_field] 

97 if val: 

98 try: 

99 models.check_valid_django_id(val) 

100 except django.core.exceptions.ValidationError as e: 

101 self.add_error(form_field, e) # type: ignore[attr-defined] 

102 return cleaned_data 

103 

104 

105class FormKitBaseForm(JSONMappingMixin, forms.ModelForm): 

106 """ 

107 Base form for all FormKit-related nodes. 

108 """ 

109 

110 def __init__(self, *args, **kwargs): 

111 super().__init__(*args, **kwargs) 

112 if instance := kwargs.get("instance"): 

113 self._populate_form_fields(instance) 

114 

115 def save(self, commit: bool = True) -> models.FormKitSchemaNode: 

116 instance = super().save(commit=False) 

117 self.save_json_fields(instance) # type: ignore[arg-type] 

118 if commit: 

119 instance.save() 

120 return instance 

121 

122 

123class NewFormKitForm(forms.ModelForm): 

124 class Meta: 

125 model = models.FormKitSchemaNode 

126 fields = ("label", "node_type", "description") 

127 

128 

129class OptionForm(forms.ModelForm): 

130 class Meta: 

131 model = models.Option 

132 exclude = () 

133 

134 

135class FormComponentsForm(forms.ModelForm): 

136 class Meta: 

137 model = models.FormComponents 

138 exclude = () 

139 

140 

141class FormKitSchemaComponentInline(admin.TabularInline): 

142 model = models.FormComponents 

143 readonly_fields = ( 

144 "node", 

145 "created_by", 

146 "updated_by", 

147 ) 

148 ordering = ("order",) 

149 extra = 0 

150 

151 

152class FormKitNodeGroupForm(FormKitBaseForm): 

153 class Meta: 

154 model = models.FormKitSchemaNode 

155 fields = ("label", "description", "additional_props", "is_active", "protected") 

156 

157 _json_fields = { 

158 "node": ("name", ("formkit", "$formkit"), "if_condition", ("html_id", "id")), 

159 } 

160 html_id = forms.CharField(required=False) 

161 name = forms.CharField(required=True) 

162 formkit = forms.ChoiceField(required=False, choices=models.FormKitSchemaNode.FORMKIT_CHOICES, disabled=True) 

163 if_condition = forms.CharField(widget=forms.TextInput, required=False) 

164 

165 

166class FormKitNodeForm(FormKitBaseForm): 

167 class Meta: 

168 model = models.FormKitSchemaNode 

169 fields = ("label", "description", "additional_props", "option_group", "is_active", "protected") 

170 

171 _json_fields = { 

172 "node": ( 

173 ("formkit", "$formkit"), 

174 "name", 

175 "key", 

176 "if_condition", 

177 "options", 

178 ("node_label", "label"), 

179 "placeholder", 

180 "help", 

181 "validation", 

182 "validationLabel", 

183 "validationVisibility", 

184 "validationMessages", 

185 "prefixIcon", 

186 "min", 

187 "max", 

188 "step", 

189 ("html_id", "id"), 

190 ("onchange", "onChange"), 

191 ) 

192 } 

193 name = forms.CharField(required=True) 

194 formkit = forms.ChoiceField(required=False, choices=models.FormKitSchemaNode.FORMKIT_CHOICES) 

195 if_condition = forms.CharField(widget=forms.TextInput, required=False) 

196 key = forms.CharField(required=False) 

197 node_label = forms.CharField(required=False) 

198 placeholder = forms.CharField(required=False) 

199 help = forms.CharField(required=False) 

200 html_id = forms.CharField(required=False) 

201 onchange = forms.CharField(required=False) 

202 options = forms.CharField(required=False) 

203 validation = forms.CharField(required=False) 

204 validationLabel = forms.CharField(required=False) 

205 validationVisibility = forms.CharField(required=False) 

206 validationMessages = forms.JSONField(required=False) 

207 prefixIcon = forms.CharField(required=False) 

208 validationRules = forms.CharField( 

209 required=False, help_text="A function for validation passed into the schema: a key on `formSchemaData`" 

210 ) 

211 max = forms.IntegerField(required=False) 

212 min = forms.IntegerField(required=False) 

213 step = forms.IntegerField(required=False) 

214 

215 def get_fields(self, request, obj: models.FormKitSchemaNode): 

216 """ 

217 Customise the returned fields based on the type 

218 of formkit node 

219 """ 

220 return super().get_fields(request, obj) # type: ignore[misc] 

221 

222 

223class FormKitNodeRepeaterForm(FormKitNodeForm): 

224 def get_json_fields(self) -> JsonFieldDefn: 

225 return { 

226 "node": ( 

227 *(super()._json_fields["node"]), 

228 "addLabel", 

229 "upControl", 

230 "downControl", 

231 "itemsClass", 

232 "itemClass", 

233 ) 

234 } 

235 

236 addLabel = forms.CharField(required=False) 

237 upControl = forms.BooleanField(required=False) 

238 downControl = forms.BooleanField(required=False) 

239 itemsClass = forms.CharField(required=False) 

240 itemClass = forms.CharField(required=False) 

241 max = forms.IntegerField(required=False) 

242 min = forms.IntegerField(required=False) 

243 

244 

245class FormKitTextNode(forms.ModelForm): 

246 class Meta: 

247 model = models.FormKitSchemaNode 

248 fields = ("label", "description", "text_content", "is_active", "protected") 

249 

250 

251class FormKitElementForm(FormKitBaseForm): 

252 class Meta: 

253 model = models.FormKitSchemaNode 

254 fields = ("label", "description", "text_content", "is_active", "protected") 

255 

256 _json_fields = {"node": (("el", "$el"), "name", "if_condition", "attrs__class")} 

257 

258 el = forms.ChoiceField(required=False, choices=models.FormKitSchemaNode.ELEMENT_TYPE_CHOICES) 

259 name = forms.CharField(required=False) 

260 attrs__class = forms.CharField(required=False) 

261 if_condition = forms.CharField(widget=forms.TextInput, required=False) 

262 

263 

264class FormKitConditionForm(FormKitBaseForm): 

265 class Meta: 

266 model = models.FormKitSchemaNode 

267 fields = ("label", "description") 

268 

269 _json_fields = {"node": ("if_condition", "then_condition", "else_condition")} 

270 if_condition = forms.CharField(widget=forms.TextInput, required=False) 

271 then_condition = forms.CharField(max_length=256, required=False) 

272 else_condition = forms.CharField(max_length=256, required=False) 

273 

274 

275class FormKitComponentForm(FormKitBaseForm): 

276 class Meta: 

277 model = models.FormKitSchemaNode 

278 fields = ("label", "description", "is_active", "protected") 

279 

280 _json_fields = {"node": ("if_condition", "then_condition", "else_condition")} 

281 

282 

283class NodeChildrenInline(admin.TabularInline): 

284 """ 

285 Nested HTML elements 

286 """ 

287 

288 model = models.NodeChildren 

289 fields = ("child", "order", "track_change") 

290 ordering = ("order",) 

291 readonly_fields = ("track_change",) 

292 fk_name = "parent" 

293 extra = 0 

294 

295 

296class NodeParentsInline(admin.TabularInline): 

297 """ 

298 Nested HTML elements 

299 """ 

300 

301 model = models.NodeChildren 

302 fields = ("parent", "order", "track_change") 

303 ordering = ("order",) 

304 readonly_fields = ("track_change", "parent") 

305 fk_name = "child" 

306 extra = 0 

307 

308 

309class NodeInline(admin.StackedInline): 

310 """ 

311 Nodes related to Option Groups 

312 """ 

313 

314 model = models.FormKitSchemaNode 

315 fields = ("label", "node_type", "description") 

316 extra = 0 

317 

318 

319class SchemaLabelInline(admin.TabularInline): 

320 model = models.SchemaLabel 

321 extra = 0 

322 

323 

324class SchemaDescriptionInline(admin.TabularInline): 

325 model = models.SchemaDescription 

326 extra = 0 

327 

328 

329class FormKitSchemaForm(forms.ModelForm): 

330 class Meta: 

331 model = models.FormKitSchema 

332 exclude = ("name",) 

333 

334 

335# Registry to map pydantic node types to form classes and fieldsets 

336NODE_CONFIG: dict[type | str, dict[str, Any]] = { 

337 str: {"form": FormKitTextNode}, 

338 formkit_schema.GroupNode: {"form": FormKitNodeGroupForm}, 

339 formkit_schema.RepeaterNode: { 

340 "form": FormKitNodeRepeaterForm, 

341 "fieldsets": [ 

342 ( 

343 "Repeater field properties", 

344 {"fields": ("addLabel", "upControl", "downControl", "itemsClass", "itemClass")}, 

345 ) 

346 ], 

347 }, 

348 formkit_schema.FormKitSchemaDOMNode: {"form": FormKitElementForm}, 

349 formkit_schema.FormKitSchemaComponent: {"form": FormKitComponentForm}, 

350 formkit_schema.FormKitSchemaCondition: {"form": FormKitConditionForm}, 

351 formkit_schema.FormKitSchemaProps: { 

352 "form": FormKitNodeForm, 

353 "fieldsets": [ 

354 ( 

355 "Field Validation", 

356 { 

357 "fields": ( 

358 "validation", 

359 "validationLabel", 

360 "validationVisibility", 

361 "validationMessages", 

362 "validationRules", 

363 ) 

364 }, 

365 ) 

366 ], 

367 }, 

368} 

369 

370 

371@admin.register(models.FormKitSchemaNode) 

372class FormKitSchemaNodeAdmin(admin.ModelAdmin): 

373 list_display = ( 

374 "label", 

375 "is_active", 

376 "id", 

377 "node_type", 

378 "option_group", 

379 "formkit_or_el_type", 

380 "track_change", 

381 "key_is_valid", 

382 "protected", 

383 ) 

384 list_filter = ("node_type", "is_active", "protected") 

385 readonly_fields = ("track_change",) 

386 search_fields = ["label", "description", "node", "node__el"] 

387 inlines = [NodeChildrenInline, NodeParentsInline] 

388 

389 @admin.display(boolean=True) 

390 def key_is_valid(self, obj) -> bool: 

391 if not (obj and obj.node and isinstance(obj.node, dict) and "name" in obj.node): 

392 return True 

393 try: 

394 models.check_valid_django_id(obj.node.get("name")) 

395 except (TypeError, django.core.exceptions.ValidationError): 

396 return False 

397 return True 

398 

399 def formkit_or_el_type(self, obj): 

400 if obj and obj.node and obj.node_type in ("$formkit", "$el"): 

401 return obj.node.get(obj.node_type) 

402 

403 def get_inlines(self, request, obj: models.FormKitSchemaNode | None): 

404 return [NodeChildrenInline, NodeParentsInline] if obj else [] 

405 

406 def get_fieldsets(self, request: HttpRequest, obj: models.FormKitSchemaNode | None = None): 

407 if not obj: 

408 return super().get_fieldsets(request, obj) 

409 

410 try: 

411 node = obj.get_node() 

412 except Exception: 

413 return super().get_fieldsets(request, obj) 

414 

415 fieldsets: list[tuple[str | None, dict[str, Any]]] = [] 

416 for pydantic_type, config in NODE_CONFIG.items(): 

417 if isinstance(pydantic_type, type) and isinstance(node, pydantic_type): 

418 if "fieldsets" in config: 

419 fieldsets.extend(config["fieldsets"]) 

420 break 

421 

422 grouped_fields: set[str] = reduce(operator.or_, (set(opts["fields"]) for _, opts in fieldsets), set()) 

423 fieldsets.insert(0, (None, {"fields": [f for f in self.get_fields(request, obj) if f not in grouped_fields]})) 

424 return fieldsets 

425 

426 def get_form( 

427 self, request: HttpRequest, obj: Any | None = None, change: bool = False, **kwargs: Any 

428 ) -> type[forms.ModelForm[Any]]: 

429 if not obj: 

430 return NewFormKitForm 

431 try: 

432 node = obj.get_node() 

433 for pydantic_type, config in NODE_CONFIG.items(): 

434 if isinstance(pydantic_type, type) and isinstance(node, pydantic_type): 

435 return config["form"] 

436 except Exception: 

437 pass 

438 return super().get_form(request, obj, **kwargs) 

439 

440 

441@admin.register(models.FormKitSchema) 

442class FormKitSchemaAdmin(admin.ModelAdmin): 

443 form = FormKitSchemaForm 

444 

445 def get_inlines(self, request, obj: models.FormKitSchema | None): 

446 """ 

447 For a "new object" do not show the Form Components 

448 """ 

449 return ( 

450 [ 

451 SchemaLabelInline, 

452 SchemaDescriptionInline, 

453 FormKitSchemaComponentInline, 

454 ] 

455 if obj 

456 else [ 

457 SchemaLabelInline, 

458 SchemaDescriptionInline, 

459 ] 

460 ) 

461 

462 inlines = [ 

463 SchemaLabelInline, 

464 SchemaDescriptionInline, 

465 FormKitSchemaComponentInline, 

466 ] 

467 

468 

469@admin.register(models.FormComponents) 

470class FormComponentsAdmin(admin.ModelAdmin): 

471 list_display = ( 

472 "label", 

473 "schema", 

474 "node", 

475 "order", 

476 ) 

477 

478 

479class OptionLabelInline(admin.TabularInline): 

480 model = models.OptionLabel 

481 extra = 0 

482 

483 

484class OptionInline(admin.TabularInline): 

485 model = models.Option 

486 extra = 0 

487 fields = ("group", "object_id", "value", "order") 

488 readonly_fields = ("group", "object_id", "value") 

489 

490 

491@admin.register(models.Option) 

492class OptionAdmin(admin.ModelAdmin): 

493 list_display = ( 

494 "object_id", 

495 "value", 

496 "order", 

497 "group", 

498 ) 

499 inlines = [OptionLabelInline] 

500 list_select_related = ("group",) 

501 readonly_fields = ("group", "object_id", "value", "created_by", "updated_by") 

502 

503 

504@admin.register(models.OptionGroup) 

505class OptionGroupAdmin(admin.ModelAdmin): 

506 inlines = [OptionInline, NodeInline] 

507 

508 

509@admin.register(models.OptionLabel) 

510class OptionLabelAdmin(admin.ModelAdmin): 

511 list_display = ( 

512 "label", 

513 "lang", 

514 ) 

515 readonly_fields = ("option",) 

516 search_fields = ("label",)