Coverage for formkit_ninja / admin.py: 52.40%
439 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-02-20 04:40 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-02-20 04:40 +0000
1from __future__ import annotations
3import logging
4import operator
5from functools import reduce
6from typing import Any
8import django.core.exceptions
9import pghistory.admin
10from django import forms
11from django.contrib import admin
12from django.http import HttpRequest
14# Import admin modules to register them
15from formkit_ninja import (
16 formkit_schema,
17 models,
18)
19from formkit_ninja.form_submission.models import (
20 SeparatedSubmission,
21 SeparatedSubmissionImport,
22 Submission,
23 SubmissionFile,
24)
26logger = logging.getLogger(__name__)
29# Define fields in JSON with a tuple of fields
30# The key of the dict provided is a JSON field on the model
31JsonFieldDefn = dict[str, tuple[str | tuple[str, str], ...]]
34class ItemAdmin(admin.ModelAdmin):
35 list_display = ("name",)
38class JSONMappingMixin:
39 """
40 Mixin to handle mapping between flat form fields and nested JSON fields.
41 """
43 _json_fields: JsonFieldDefn = {}
45 def get_json_fields(self) -> JsonFieldDefn:
46 return self._json_fields
48 def _extract_field_value(self, values: dict, json_field: str):
49 if "__" in json_field:
50 nested_field_name, nested_key = json_field.split("__", 1)
51 nested = values.get(nested_field_name)
52 if isinstance(nested, dict):
53 return nested.get(nested_key)
54 return None
55 return values.get(json_field)
57 def _populate_form_fields(self, instance):
58 for field, keys in self.get_json_fields().items():
59 values = getattr(instance, field, {}) or {}
60 for key in keys:
61 form_field, json_field = key if isinstance(key, tuple) else (key, key)
62 if f := self.fields.get(form_field):
63 val = self._extract_field_value(values, json_field)
64 if val is None:
65 # Fallback: check if the json_field corresponds to a model attribute
66 # using the same promotion logic as in models.py
67 mapping = {
68 "addLabel": "add_label",
69 "upControl": "up_control",
70 "downControl": "down_control",
71 "sectionsSchema": "sections_schema",
72 }
73 attr_name = mapping.get(json_field, json_field)
74 if hasattr(instance, attr_name):
75 val = getattr(instance, attr_name)
76 f.initial = val
78 def _build_json_data(self, keys: tuple, existing_data: dict) -> dict:
79 data = existing_data.copy() if isinstance(existing_data, dict) else {}
80 for key in keys:
81 form_field, json_field = key if isinstance(key, tuple) else (key, key)
82 if form_field not in self.cleaned_data: # type: ignore[attr-defined]
83 continue
85 val = self.cleaned_data[form_field] # type: ignore[attr-defined]
86 if "__" in json_field:
87 nested_field_name, nested_key = json_field.split("__", 1)
88 if not isinstance(data.get(nested_field_name), dict):
89 data[nested_field_name] = {}
90 data[nested_field_name][nested_key] = val
91 else:
92 data[json_field] = val
93 return data
95 def save_json_fields(self, instance):
96 for field, keys in self.get_json_fields().items():
97 existing = getattr(instance, field, {}) or {}
98 new_data = self._build_json_data(keys, existing)
100 # Extract unrecognized fields from existing data and preserve in additional_props
101 if field == "node" and isinstance(existing, dict):
102 # Get all recognized fields (from form fields and their JSON mappings)
103 recognized_fields = set()
104 for key in keys:
105 if isinstance(key, tuple):
106 # (form_field, json_field) tuple
107 recognized_fields.add(key[1])
108 else:
109 # Just json_field
110 recognized_fields.add(key)
112 # Also add special handled keys
113 special_keys = {
114 "$formkit",
115 "$el",
116 "if",
117 "for",
118 "then",
119 "else",
120 "children",
121 "node_type",
122 "formkit",
123 "id",
124 }
125 recognized_fields.update(special_keys)
127 # Extract unrecognized fields
128 unrecognized_fields = {k: v for k, v in existing.items() if k not in recognized_fields and v is not None}
130 # Store unrecognized fields in additional_props
131 if unrecognized_fields:
132 if instance.additional_props is None:
133 instance.additional_props = {}
134 # Merge with existing additional_props (don't overwrite if already set)
135 for key, value in unrecognized_fields.items():
136 if key not in instance.additional_props:
137 instance.additional_props[key] = value
139 setattr(instance, field, new_data)
141 def clean(self) -> dict[str, Any]:
142 cleaned_data = super().clean() # type: ignore[misc]
143 # Find any field mapped to "name" in JSON and validate it
144 for field, keys in self.get_json_fields().items():
145 for key in keys:
146 form_field, json_field = key if isinstance(key, tuple) else (key, key)
147 if json_field == "name" and form_field in cleaned_data:
148 val = cleaned_data[form_field]
149 if val:
150 try:
151 models.check_valid_django_id(val)
152 except django.core.exceptions.ValidationError as e:
153 self.add_error(form_field, e) # type: ignore[attr-defined]
154 return cleaned_data
157class FormKitBaseForm(JSONMappingMixin, forms.ModelForm):
158 """
159 Base form for all FormKit-related nodes.
160 """
162 class Meta:
163 model = models.FormKitSchemaNode
164 fields = (
165 "label",
166 "description",
167 "is_active",
168 "protected",
169 "django_field_type",
170 "django_field_args",
171 "django_field_positional_args",
172 "pydantic_field_type",
173 "extra_imports",
174 "validators",
175 )
177 # Code Generation Overrides
178 django_field_type = forms.CharField(required=False)
179 django_field_args = forms.JSONField(required=False, widget=forms.Textarea(attrs={"rows": 4}))
180 django_field_positional_args = forms.JSONField(required=False, widget=forms.Textarea(attrs={"rows": 4}))
181 pydantic_field_type = forms.CharField(required=False)
182 extra_imports = forms.JSONField(required=False, widget=forms.Textarea(attrs={"rows": 4}))
183 validators = forms.JSONField(required=False, widget=forms.Textarea(attrs={"rows": 4}))
185 def __init__(self, *args, **kwargs):
186 super().__init__(*args, **kwargs)
187 if instance := kwargs.get("instance"):
188 self._populate_form_fields(instance)
190 def save(self, commit: bool = True) -> models.FormKitSchemaNode:
191 instance = super().save(commit=False)
192 self.save_json_fields(instance) # type: ignore[arg-type]
193 if commit:
194 instance.save()
195 return instance
198class NewFormKitForm(forms.ModelForm):
199 class Meta:
200 model = models.FormKitSchemaNode
201 fields = ("label", "node_type", "description")
204class OptionForm(forms.ModelForm):
205 class Meta:
206 model = models.Option
207 exclude = ()
210class FormComponentsForm(forms.ModelForm):
211 class Meta:
212 model = models.FormComponents
213 exclude = ()
216class FormKitSchemaComponentInline(admin.TabularInline):
217 model = models.FormComponents
218 readonly_fields = (
219 "node",
220 "created_by",
221 "updated_by",
222 )
223 ordering = ("order",)
224 extra = 0
227class FormKitNodeGroupForm(FormKitBaseForm):
228 class Meta:
229 model = models.FormKitSchemaNode
230 fields = (
231 "label",
232 "description",
233 "additional_props",
234 "is_active",
235 "protected",
236 "django_field_type",
237 "django_field_args",
238 "django_field_positional_args",
239 "pydantic_field_type",
240 "extra_imports",
241 "validators",
242 )
244 _json_fields = {
245 "node": ("name", ("formkit", "$formkit"), "if_condition", ("html_id", "id")),
246 }
247 html_id = forms.CharField(required=False)
248 name = forms.CharField(required=True)
249 formkit = forms.ChoiceField(required=False, initial="group", choices=models.FormKitSchemaNode.FORMKIT_CHOICES, disabled=True)
250 if_condition = forms.CharField(widget=forms.TextInput, required=False)
253class FormKitNodeForm(FormKitBaseForm):
254 class Meta:
255 model = models.FormKitSchemaNode
256 fields = (
257 "label",
258 "description",
259 "additional_props",
260 "option_group",
261 "is_active",
262 "protected",
263 "django_field_type",
264 "django_field_args",
265 "django_field_positional_args",
266 "pydantic_field_type",
267 "extra_imports",
268 "validators",
269 )
271 _json_fields = {
272 "node": (
273 ("formkit", "$formkit"),
274 "name",
275 "key",
276 "if_condition",
277 "options",
278 ("node_label", "label"),
279 "placeholder",
280 "help",
281 "validation",
282 "validationLabel",
283 "validationVisibility",
284 "validationMessages",
285 "prefixIcon",
286 "min",
287 "max",
288 "step",
289 ("html_id", "id"),
290 ("onchange", "onChange"),
291 )
292 }
293 name = forms.CharField(required=True)
294 formkit = forms.ChoiceField(required=False, choices=models.FormKitSchemaNode.FORMKIT_CHOICES)
295 if_condition = forms.CharField(widget=forms.TextInput, required=False)
296 key = forms.CharField(required=False)
297 node_label = forms.CharField(required=False)
298 placeholder = forms.CharField(required=False)
299 help = forms.CharField(required=False)
300 html_id = forms.CharField(required=False)
301 onchange = forms.CharField(required=False)
302 options = forms.CharField(required=False)
303 validation = forms.CharField(required=False)
304 validationLabel = forms.CharField(required=False)
305 validationVisibility = forms.CharField(required=False)
306 validationMessages = forms.JSONField(required=False)
307 prefixIcon = forms.CharField(required=False)
308 validationRules = forms.CharField(required=False, help_text="A function for validation passed into the schema: a key on `formSchemaData`")
309 max = forms.IntegerField(required=False)
310 min = forms.IntegerField(required=False)
311 step = forms.IntegerField(required=False)
313 def get_fields(self, request, obj: models.FormKitSchemaNode):
314 """
315 Customise the returned fields based on the type
316 of formkit node
317 """
318 return super().get_fields(request, obj) # type: ignore[misc]
321class FormKitNodeRepeaterForm(FormKitNodeForm):
322 def get_json_fields(self) -> JsonFieldDefn:
323 return {
324 "node": (
325 *(super()._json_fields["node"]),
326 "addLabel",
327 "upControl",
328 "downControl",
329 "itemsClass",
330 "itemClass",
331 )
332 }
334 addLabel = forms.CharField(required=False)
335 upControl = forms.BooleanField(required=False)
336 downControl = forms.BooleanField(required=False)
337 itemsClass = forms.CharField(required=False)
338 itemClass = forms.CharField(required=False)
339 max = forms.IntegerField(required=False)
340 min = forms.IntegerField(required=False)
343class FormKitTextNode(FormKitBaseForm):
344 class Meta(FormKitBaseForm.Meta):
345 fields = FormKitBaseForm.Meta.fields + ("text_content",) # type: ignore[assignment]
348class FormKitElementForm(FormKitBaseForm):
349 class Meta(FormKitBaseForm.Meta):
350 fields = FormKitBaseForm.Meta.fields + ("text_content",) # type: ignore[assignment]
352 _json_fields = {"node": (("el", "$el"), "name", "if_condition", "attrs__class")}
354 el = forms.ChoiceField(required=False, choices=models.FormKitSchemaNode.ELEMENT_TYPE_CHOICES)
355 name = forms.CharField(required=False)
356 attrs__class = forms.CharField(required=False)
357 if_condition = forms.CharField(widget=forms.TextInput, required=False)
360class FormKitConditionForm(FormKitBaseForm):
361 class Meta(FormKitBaseForm.Meta):
362 pass
364 _json_fields = {"node": ("if_condition", "then_condition", "else_condition")}
365 if_condition = forms.CharField(widget=forms.TextInput, required=False)
366 then_condition = forms.CharField(max_length=256, required=False)
367 else_condition = forms.CharField(max_length=256, required=False)
370class FormKitComponentForm(FormKitBaseForm):
371 class Meta(FormKitBaseForm.Meta):
372 pass
374 _json_fields = {"node": ("if_condition", "then_condition", "else_condition")}
377class NodeChildrenInline(admin.TabularInline):
378 """
379 Nested HTML elements
380 """
382 model = models.NodeChildren
383 fields = ("child", "order", "track_change")
384 ordering = ("order",)
385 readonly_fields = ("track_change",)
386 fk_name = "parent"
387 extra = 0
390class NodeParentsInline(admin.TabularInline):
391 """
392 Nested HTML elements
393 """
395 model = models.NodeChildren
396 fields = ("parent", "order", "track_change")
397 ordering = ("order",)
398 readonly_fields = ("track_change", "parent")
399 fk_name = "child"
400 extra = 0
403class NodeInline(admin.StackedInline):
404 """
405 Nodes related to Option Groups
406 """
408 model = models.FormKitSchemaNode
409 fields = ("label", "node_type", "description")
410 extra = 0
413class SchemaLabelInline(admin.TabularInline):
414 model = models.SchemaLabel
415 extra = 0
418class SchemaDescriptionInline(admin.TabularInline):
419 model = models.SchemaDescription
420 extra = 0
423class FormKitSchemaForm(forms.ModelForm):
424 class Meta:
425 model = models.FormKitSchema
426 exclude = ("name",)
429# Registry to map pydantic node types to form classes and fieldsets
430NODE_CONFIG: dict[type | str, dict[str, Any]] = {
431 str: {"form": FormKitTextNode},
432 formkit_schema.GroupNode: {"form": FormKitNodeGroupForm},
433 formkit_schema.RepeaterNode: {
434 "form": FormKitNodeRepeaterForm,
435 "fieldsets": [
436 (
437 "Repeater field properties",
438 {"fields": ("addLabel", "upControl", "downControl", "itemsClass", "itemClass")},
439 )
440 ],
441 },
442 formkit_schema.FormKitSchemaDOMNode: {"form": FormKitElementForm},
443 formkit_schema.FormKitSchemaComponent: {"form": FormKitComponentForm},
444 formkit_schema.FormKitSchemaCondition: {"form": FormKitConditionForm},
445 formkit_schema.FormKitSchemaProps: {"form": FormKitNodeForm},
446}
448# Admin site registration continues below...
451@admin.register(models.FormKitSchemaNode)
452class FormKitSchemaNodeAdmin(admin.ModelAdmin):
453 list_display = (
454 "label",
455 "is_active",
456 "id",
457 "node_type",
458 "option_group",
459 "formkit_or_el_type",
460 "key_is_valid",
461 "track_change",
462 "protected",
463 "created",
464 )
465 readonly_fields = ("django_code_preview", "pydantic_code_preview", "formkit_node_preview", "created", "updated")
466 search_fields = ["label", "description", "id"]
467 list_filter = ("is_active", "node_type", "protected", "option_group", "created", "updated")
468 list_select_related = ("option_group",)
469 list_per_page = 50
470 date_hierarchy = "created"
471 inlines = [NodeChildrenInline, NodeParentsInline]
473 def get_readonly_fields(self, request, obj=None):
474 ro = super().get_readonly_fields(request, obj)
475 return list(ro) + ["django_code_preview", "pydantic_code_preview", "formkit_node_preview"]
477 @admin.display(boolean=True)
478 def key_is_valid(self, obj) -> bool:
479 if not (obj and obj.node and isinstance(obj.node, dict) and "name" in obj.node):
480 return True
481 try:
482 models.check_valid_django_id(obj.node.get("name"))
483 except (TypeError, django.core.exceptions.ValidationError):
484 return False
485 return True
487 def formkit_or_el_type(self, obj):
488 if obj and obj.node and obj.node_type in ("$formkit", "$el"):
489 return obj.node.get(obj.node_type)
491 def get_inlines(self, request, obj: models.FormKitSchemaNode | None):
492 return [NodeChildrenInline, NodeParentsInline] if obj else []
494 def get_fieldsets(self, request: HttpRequest, obj: models.FormKitSchemaNode | None = None):
495 if not obj:
496 return super().get_fieldsets(request, obj)
498 try:
499 node = obj.get_node()
500 except Exception:
501 return super().get_fieldsets(request, obj)
503 fieldsets: list[tuple[str | None, dict[str, Any]]] = []
504 for pydantic_type, config in NODE_CONFIG.items():
505 if isinstance(pydantic_type, type) and isinstance(node, pydantic_type):
506 if "fieldsets" in config:
507 fieldsets.extend(config["fieldsets"])
508 break
510 grouped_fields: set[str] = reduce(operator.or_, (set(opts["fields"]) for _, opts in fieldsets), set())
511 # Also include code generation fields to avoid them being duplicated in the default fieldset
512 code_gen_fields = {
513 "django_field_type",
514 "django_field_args",
515 "django_field_positional_args",
516 "pydantic_field_type",
517 "extra_imports",
518 "validators",
519 "django_code_preview",
520 "pydantic_code_preview",
521 "formkit_node_preview",
522 }
523 grouped_fields.update(code_gen_fields)
524 fieldsets.insert(0, (None, {"fields": [f for f in self.get_fields(request, obj) if f not in grouped_fields]}))
526 # Add Code Generation Source of Truth fieldset
527 fieldsets.append(
528 (
529 "Code Generation (Source of Truth)",
530 {
531 "fields": (
532 "django_field_type",
533 "django_field_args",
534 "django_field_positional_args",
535 "pydantic_field_type",
536 "extra_imports",
537 "validators",
538 "django_code_preview",
539 "pydantic_code_preview",
540 "formkit_node_preview",
541 ),
542 "description": ("These values are the source of truth for code generation. If empty, they are auto-resolved on save from global configs."),
543 },
544 )
545 )
546 return fieldsets
548 @admin.display(description="Django Model Field Preview")
549 def django_code_preview(self, obj):
550 """Show what the Django model field code will look like."""
551 from django.utils.html import format_html
553 if not obj or not obj.pk:
554 return "(Save node to see preview)"
556 try:
557 from formkit_ninja.parser.type_convert import NodePath
559 # Ensure defaults are resolved for the preview
560 obj.resolve_code_generation_defaults()
562 nodes = obj.get_node_path(recursive=True)
564 path = NodePath(*nodes)
565 code = path.django_model_code
567 style = "background: #f8f9fa; padding: 10px; border-radius: 4px; border: 1px solid #dee2e6; color: #333; overflow: auto; max-height: 400px;"
568 return format_html(
569 '<pre style="{}">{}</pre>',
570 style,
571 code,
572 )
573 except Exception as e:
574 return format_html('<div style="color: red;">Error generating preview: {}</div>', str(e))
576 @admin.display(description="Pydantic Schema Preview")
577 def pydantic_code_preview(self, obj):
578 """Show what the Pydantic schema code will look like."""
579 from django.utils.html import format_html
581 if not obj or not obj.pk:
582 return "(Save node to see preview)"
584 try:
585 from formkit_ninja.parser.type_convert import NodePath
587 # Ensure defaults are resolved for the preview
588 obj.resolve_code_generation_defaults()
590 nodes = obj.get_node_path(recursive=True)
592 path = NodePath(*nodes)
593 code = path.pydantic_model_code
595 style = "background: #f8f9fa; padding: 10px; border-radius: 4px; border: 1px solid #dee2e6; color: #333; overflow: auto; max-height: 400px;"
596 return format_html(
597 '<pre style="{}">{}</pre>',
598 style,
599 code,
600 )
601 except Exception as e:
602 return format_html('<div style="color: red;">Error generating preview: {}</div>', str(e))
604 @admin.display(description="FormKit Node JSON Preview")
605 def formkit_node_preview(self, obj):
606 """Show the generated FormKit Node JSON."""
607 import json
609 from django.utils.html import format_html
611 if not obj or not obj.pk:
612 return "(Save node to see preview)"
614 try:
615 # Get the node via the Pydantic generator (recursive=True)
616 node = obj.get_node(recursive=True)
618 # If it's a Pydantic model, convert to dict
619 if hasattr(node, "dict"):
620 node_values = node.dict(exclude_none=True)
621 else:
622 # Could be a string (TextNode) or other primitive
623 node_values = node
625 # Format as pretty JSON
626 code = json.dumps(node_values, indent=2, ensure_ascii=False)
628 style = (
629 "background: #f1f3f5; padding: 10px; border-radius: 4px; "
630 "border: 1px solid #ced4da; color: #212529; overflow: auto; "
631 "max-height: 400px; font-family: monospace; font-size: 11px; "
632 "white-space: pre-wrap; word-break: break-all;"
633 )
634 return format_html(
635 '<pre style="{}">{}</pre>',
636 style,
637 code,
638 )
639 except Exception as e:
640 return format_html('<div style="color: red;">Error generating JSON preview: {}</div>', str(e))
642 def get_form(self, request: HttpRequest, obj: Any | None = None, change: bool = False, **kwargs: Any) -> type[forms.ModelForm[Any]]:
643 if not obj:
644 return NewFormKitForm
645 try:
646 node = obj.get_node()
647 for pydantic_type, config in NODE_CONFIG.items():
648 if isinstance(pydantic_type, type) and isinstance(node, pydantic_type):
649 return config["form"]
650 except Exception:
651 pass
652 return super().get_form(request, obj, **kwargs)
655@admin.register(models.FormKitSchema)
656class FormKitSchemaAdmin(admin.ModelAdmin):
657 form = FormKitSchemaForm
659 def get_inlines(self, request, obj: models.FormKitSchema | None):
660 """
661 For a "new object" do not show the Form Components
662 """
663 return (
664 [
665 SchemaLabelInline,
666 SchemaDescriptionInline,
667 FormKitSchemaComponentInline,
668 ]
669 if obj
670 else [
671 SchemaLabelInline,
672 SchemaDescriptionInline,
673 ]
674 )
676 inlines = [
677 SchemaLabelInline,
678 SchemaDescriptionInline,
679 FormKitSchemaComponentInline,
680 ]
683@admin.register(models.FormComponents)
684class FormComponentsAdmin(admin.ModelAdmin):
685 list_display = (
686 "label",
687 "schema",
688 "node",
689 "order",
690 )
693class OptionLabelInline(admin.TabularInline):
694 model = models.OptionLabel
695 extra = 0
698class OptionInline(admin.TabularInline):
699 model = models.Option
700 extra = 0
701 fields = ("group", "object_id", "value", "order")
702 readonly_fields = ("group", "object_id", "value")
705@admin.register(models.Option)
706class OptionAdmin(admin.ModelAdmin):
707 list_display = (
708 "object_id",
709 "value",
710 "order",
711 "group",
712 "last_updated",
713 )
714 inlines = [OptionLabelInline]
715 list_select_related = ("group",)
716 list_filter = ("group", "last_updated")
717 search_fields = ("value", "object_id", "group__group")
718 list_per_page = 50
719 date_hierarchy = "last_updated"
720 readonly_fields = ("group", "object_id", "value", "created_by", "updated_by")
723@admin.register(models.OptionGroup)
724class OptionGroupAdmin(admin.ModelAdmin):
725 list_display = ("group", "content_type", "option_count")
726 search_fields = ("group",)
727 list_filter = ("content_type",)
728 inlines = [OptionInline, NodeInline]
730 @admin.display(description="Options Count")
731 def option_count(self, obj):
732 """Display the number of options in this group."""
733 if obj.pk:
734 return obj.option_set.count()
735 return 0
738@admin.register(models.OptionLabel)
739class OptionLabelAdmin(admin.ModelAdmin):
740 list_display = (
741 "label",
742 "lang",
743 "option",
744 )
745 readonly_fields = ("option",)
746 search_fields = ("label", "option__value", "option__group__group")
747 list_filter = ("lang", "option__group")
748 list_select_related = ("option", "option__group")
749 list_per_page = 50
752# NOTE: SeparatedSubmission and Submission are imported at the top of the file
755@admin.register(Submission.pgh_event_model) # type: ignore[attr-defined]
756class SubmissionEventAdmin(pghistory.admin.EventModelAdmin):
757 """
758 Admin for Submission events.
759 """
761 pass
764@admin.register(SeparatedSubmission.pgh_event_model) # type: ignore[attr-defined]
765class SeparatedSubmissionEventAdmin(pghistory.admin.EventModelAdmin):
766 """
767 Admin for SeparatedSubmission events.
768 """
770 pass
773class SeparatedSubmissionForm(forms.ModelForm):
774 class Meta:
775 model = SeparatedSubmission
776 fields = "__all__"
777 widgets = {
778 "id": forms.HiddenInput(),
779 }
782class SeparatedSubmissionInline(admin.TabularInline):
783 """Inline for showing separated submissions within a Submission."""
785 model = SeparatedSubmission
786 form = SeparatedSubmissionForm
787 extra = 0
788 show_change_link = True
789 can_delete = False
790 readonly_fields = [f.name for f in SeparatedSubmission._meta.fields if f.name != "id"]
793@admin.register(Submission)
794class SubmissionAdmin(admin.ModelAdmin):
795 """Admin for Submission model."""
797 list_display = ("key", "user", "created", "status", "form_type", "is_verified", "is_active")
798 list_filter = ("is_active", "user", "status", "form_type", "created")
799 search_fields = ("key", "form_type", "user__username", "user__email")
800 list_select_related = ("user",)
801 list_per_page = 50
802 readonly_fields = ("key", "created", "updated")
803 inlines = [SeparatedSubmissionInline]
804 date_hierarchy = "created"
806 @admin.display(boolean=True)
807 def is_verified(self, obj: Submission) -> bool:
808 """Returns whether this submission is verified."""
809 return obj.status == Submission.Status.VERIFIED
812@admin.register(SeparatedSubmission)
813class SeparatedSubmissionAdmin(admin.ModelAdmin):
814 """Admin for SeparatedSubmission model."""
816 list_display = ("id", "user", "created", "status", "form_type", "is_verified", "repeater_key", "repeater_order")
817 list_filter = ("user", "status", "form_type", "repeater_key", "created")
818 search_fields = ("id", "form_type", "user__username", "user__email", "repeater_key")
819 readonly_fields = [f.name for f in SeparatedSubmission._meta.fields]
820 list_select_related = ("submission", "user", "repeater_parent")
821 list_per_page = 50
822 date_hierarchy = "created"
824 @admin.display(boolean=True)
825 def is_verified(self, obj: SeparatedSubmission) -> bool:
826 """Returns whether the parent submission is verified."""
827 return obj.submission.status == Submission.Status.VERIFIED
830@admin.register(SubmissionFile)
831class SubmissionFileAdmin(admin.ModelAdmin):
832 list_display = ["submission", "file", "user", "date_uploaded", "deleted"]
833 list_filter = ("deleted", "date_uploaded", "user")
834 search_fields = ("submission", "file", "user__username", "user__email", "comment")
835 list_select_related = ("user",)
836 list_per_page = 50
837 date_hierarchy = "date_uploaded"
838 readonly_fields = ("submission", "file", "user", "date_uploaded", "comment", "deleted")
841@admin.register(SeparatedSubmissionImport)
842class SeparatedSubmissionImportAdmin(admin.ModelAdmin):
843 """Admin for SeparatedSubmissionImport model."""
845 list_display = ("id", "submission", "created", "success", "message_preview")
846 list_filter = ("success", "created")
847 readonly_fields = ("submission", "created", "success", "message")
848 list_select_related = ("submission", "submission__user")
849 list_per_page = 50
850 date_hierarchy = "created"
851 search_fields = ("message", "submission__form_type", "submission__id")
853 @admin.display(description="Message")
854 def message_preview(self, obj):
855 """Show truncated message preview."""
856 if obj.message:
857 max_length = 100
858 if len(obj.message) > max_length:
859 return f"{obj.message[:max_length]}..."
860 return obj.message
861 return "-"