Coverage for admin.py: 55%
277 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-22 07:15 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-22 07:15 +0000
1from __future__ import annotations
3import logging
4import operator
5from functools import reduce
6from typing import Any
8import django.core.exceptions
9from django import forms
10from django.contrib import admin
11from django.http import HttpRequest
13from formkit_ninja import formkit_schema, models
15logger = logging.getLogger(__name__)
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], ...]]
23class ItemAdmin(admin.ModelAdmin):
24 list_display = ("name",)
27class JSONMappingMixin:
28 """
29 Mixin to handle mapping between flat form fields and nested JSON fields.
30 """
32 _json_fields: JsonFieldDefn = {}
34 def get_json_fields(self) -> JsonFieldDefn:
35 return self._json_fields
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)
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
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
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
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))
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
105class FormKitBaseForm(JSONMappingMixin, forms.ModelForm):
106 """
107 Base form for all FormKit-related nodes.
108 """
110 def __init__(self, *args, **kwargs):
111 super().__init__(*args, **kwargs)
112 if instance := kwargs.get("instance"):
113 self._populate_form_fields(instance)
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
123class NewFormKitForm(forms.ModelForm):
124 class Meta:
125 model = models.FormKitSchemaNode
126 fields = ("label", "node_type", "description")
129class OptionForm(forms.ModelForm):
130 class Meta:
131 model = models.Option
132 exclude = ()
135class FormComponentsForm(forms.ModelForm):
136 class Meta:
137 model = models.FormComponents
138 exclude = ()
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
152class FormKitNodeGroupForm(FormKitBaseForm):
153 class Meta:
154 model = models.FormKitSchemaNode
155 fields = ("label", "description", "additional_props", "is_active", "protected")
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)
166class FormKitNodeForm(FormKitBaseForm):
167 class Meta:
168 model = models.FormKitSchemaNode
169 fields = ("label", "description", "additional_props", "option_group", "is_active", "protected")
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)
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]
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 }
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)
245class FormKitTextNode(forms.ModelForm):
246 class Meta:
247 model = models.FormKitSchemaNode
248 fields = ("label", "description", "text_content", "is_active", "protected")
251class FormKitElementForm(FormKitBaseForm):
252 class Meta:
253 model = models.FormKitSchemaNode
254 fields = ("label", "description", "text_content", "is_active", "protected")
256 _json_fields = {"node": (("el", "$el"), "name", "if_condition", "attrs__class")}
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)
264class FormKitConditionForm(FormKitBaseForm):
265 class Meta:
266 model = models.FormKitSchemaNode
267 fields = ("label", "description")
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)
275class FormKitComponentForm(FormKitBaseForm):
276 class Meta:
277 model = models.FormKitSchemaNode
278 fields = ("label", "description", "is_active", "protected")
280 _json_fields = {"node": ("if_condition", "then_condition", "else_condition")}
283class NodeChildrenInline(admin.TabularInline):
284 """
285 Nested HTML elements
286 """
288 model = models.NodeChildren
289 fields = ("child", "order", "track_change")
290 ordering = ("order",)
291 readonly_fields = ("track_change", "child")
292 fk_name = "parent"
293 extra = 0
296class NodeParentsInline(admin.TabularInline):
297 """
298 Nested HTML elements
299 """
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
309class NodeInline(admin.StackedInline):
310 """
311 Nodes related to Option Groups
312 """
314 model = models.FormKitSchemaNode
315 fields = ("label", "node_type", "description")
316 extra = 0
319class SchemaLabelInline(admin.TabularInline):
320 model = models.SchemaLabel
321 extra = 0
324class SchemaDescriptionInline(admin.TabularInline):
325 model = models.SchemaDescription
326 extra = 0
329class FormKitSchemaForm(forms.ModelForm):
330 class Meta:
331 model = models.FormKitSchema
332 exclude = ("name",)
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}
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]
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
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)
403 def get_inlines(self, request, obj: models.FormKitSchemaNode | None):
404 return [NodeChildrenInline, NodeParentsInline] if obj else []
406 def get_fieldsets(self, request: HttpRequest, obj: models.FormKitSchemaNode | None = None):
407 if not obj:
408 return super().get_fieldsets(request, obj)
410 try:
411 node = obj.get_node()
412 except Exception:
413 return super().get_fieldsets(request, obj)
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
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
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)
441@admin.register(models.FormKitSchema)
442class FormKitSchemaAdmin(admin.ModelAdmin):
443 form = FormKitSchemaForm
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 )
462 inlines = [
463 SchemaLabelInline,
464 SchemaDescriptionInline,
465 FormKitSchemaComponentInline,
466 ]
469@admin.register(models.FormComponents)
470class FormComponentsAdmin(admin.ModelAdmin):
471 list_display = (
472 "label",
473 "schema",
474 "node",
475 "order",
476 )
479class OptionLabelInline(admin.TabularInline):
480 model = models.OptionLabel
481 extra = 0
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")
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")
504@admin.register(models.OptionGroup)
505class OptionGroupAdmin(admin.ModelAdmin):
506 inlines = [OptionInline, NodeInline]
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",)