Coverage for formkit_ninja/admin.py: 55%

1 from __future__ import annotations

2

3 import logging

4 import operator

5 from functools import reduce

6 from typing import Any

7

8 import django.core.exceptions

9 from django import forms

10 from django.contrib import admin

11 from django.http import HttpRequest

12

13 from formkit_ninja import formkit_schema, models

14

15 logger = 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

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

21

22

23 class ItemAdmin(admin.ModelAdmin):

24 list_display = ("name",)

25

26

27 class 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

105 class 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

123 class NewFormKitForm(forms.ModelForm):

124 class Meta:

125 model = models.FormKitSchemaNode

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

127

128

129 class OptionForm(forms.ModelForm):

130 class Meta:

131 model = models.Option

132 exclude = ()

133

134

135 class FormComponentsForm(forms.ModelForm):

136 class Meta:

137 model = models.FormComponents

138 exclude = ()

139

140

141 class 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

152 class 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

166 class 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

223 class 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

245 class FormKitTextNode(forms.ModelForm):

246 class Meta:

247 model = models.FormKitSchemaNode

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

249

250

251 class 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

264 class 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

275 class 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

283 class 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

296 class 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

309 class 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

319 class SchemaLabelInline(admin.TabularInline):

320 model = models.SchemaLabel

321 extra = 0

322

323

324 class SchemaDescriptionInline(admin.TabularInline):

325 model = models.SchemaDescription

326 extra = 0

327

328

329 class 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

336 NODE_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)

372 class 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)

442 class 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)

470 class FormComponentsAdmin(admin.ModelAdmin):

471 list_display = (

472 "label",

473 "schema",

474 "node",

475 "order",

476 )

477

478

479 class OptionLabelInline(admin.TabularInline):

480 model = models.OptionLabel

481 extra = 0

482

483

484 class 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)

492 class 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)

505 class OptionGroupAdmin(admin.ModelAdmin):

506 inlines = [OptionInline, NodeInline]

507

508

509@admin.register(models.OptionLabel)

510 class OptionLabelAdmin(admin.ModelAdmin):

511 list_display = (

512 "label",

513 "lang",

514 )

515 readonly_fields = ("option",)

516 search_fields = ("label",)