Coverage for formkit_ninja / admin.py: 53.87%
460 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-03-06 04:12 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-03-06 04:12 +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 Flag,
21 SeparatedSubmission,
22 SeparatedSubmissionImport,
23 Submission,
24 SubmissionFile,
25)
26from formkit_ninja.utils import short_uuid
28logger = logging.getLogger(__name__)
31# Define fields in JSON with a tuple of fields
32# The key of the dict provided is a JSON field on the model
33JsonFieldDefn = dict[str, tuple[str | tuple[str, str], ...]]
35# Composable field sets for FormKitSchemaNode admin forms.
36# Rule: promoted props (icon, title, readonly, etc.) are model fields only in forms;
37# _json_fields is for node-only keys (e.g. name, $formkit, placeholder). The model
38# syncs promoted columns to/from node on save and in get_node_values().
39COMMON_NODE_FIELDS = (
40 "label",
41 "description",
42 "icon",
43 "title",
44 "readonly",
45 "sections_schema",
46 "is_active",
47 "protected",
48)
49CODE_GEN_FIELDS = (
50 "django_field_type",
51 "django_field_args",
52 "django_field_positional_args",
53 "pydantic_field_type",
54 "extra_imports",
55 "validators",
56 "list_filter",
57)
58AUDIT_FIELDS = ("created_by", "updated_by")
60# Code generation fieldset shown at bottom of change form; also used to exclude from default fieldset
61CODE_GEN_FIELDSET_TITLE = "Code Generation (Source of Truth)"
62CODE_GEN_FIELDSET_FIELDS = CODE_GEN_FIELDS + (
63 "django_code_preview",
64 "pydantic_code_preview",
65 "formkit_node_preview",
66)
67CODE_GEN_GROUPED_FIELDS = frozenset(CODE_GEN_FIELDSET_FIELDS)
70class ItemAdmin(admin.ModelAdmin):
71 list_display = ("name",)
74class JSONMappingMixin:
75 """
76 Mixin to handle mapping between flat form fields and nested JSON fields.
77 """
79 _json_fields: JsonFieldDefn = {}
81 def get_json_fields(self) -> JsonFieldDefn:
82 return self._json_fields
84 def _extract_field_value(self, values: dict, json_field: str):
85 if "__" in json_field:
86 nested_field_name, nested_key = json_field.split("__", 1)
87 nested = values.get(nested_field_name)
88 if isinstance(nested, dict):
89 return nested.get(nested_key)
90 return None
91 return values.get(json_field)
93 def _populate_form_fields(self, instance):
94 for field, keys in self.get_json_fields().items():
95 values = getattr(instance, field, {}) or {}
96 for key in keys:
97 form_field, json_field = key if isinstance(key, tuple) else (key, key)
98 if f := self.fields.get(form_field):
99 val = self._extract_field_value(values, json_field)
100 if val is None:
101 # Fallback: check if the json_field corresponds to a model attribute
102 # using the same promotion logic as in models.py
103 mapping = {
104 "addLabel": "add_label",
105 "upControl": "up_control",
106 "downControl": "down_control",
107 "sectionsSchema": "sections_schema",
108 }
109 attr_name = mapping.get(json_field, json_field)
110 if hasattr(instance, attr_name):
111 val = getattr(instance, attr_name)
112 f.initial = val
114 def _build_json_data(self, keys: tuple, existing_data: dict) -> dict:
115 data = existing_data.copy() if isinstance(existing_data, dict) else {}
116 for key in keys:
117 form_field, json_field = key if isinstance(key, tuple) else (key, key)
118 if form_field not in self.cleaned_data: # type: ignore[attr-defined]
119 continue
121 val = self.cleaned_data[form_field] # type: ignore[attr-defined]
122 if "__" in json_field:
123 nested_field_name, nested_key = json_field.split("__", 1)
124 if not isinstance(data.get(nested_field_name), dict):
125 data[nested_field_name] = {}
126 data[nested_field_name][nested_key] = val
127 else:
128 data[json_field] = val
129 return data
131 def save_json_fields(self, instance):
132 for field, keys in self.get_json_fields().items():
133 existing = getattr(instance, field, {}) or {}
134 new_data = self._build_json_data(keys, existing)
136 # Extract unrecognized fields from existing data and preserve in additional_props
137 if field == "node" and isinstance(existing, dict):
138 # Get all recognized fields (from form fields and their JSON mappings)
139 recognized_fields = set()
140 for key in keys:
141 if isinstance(key, tuple):
142 # (form_field, json_field) tuple
143 recognized_fields.add(key[1])
144 else:
145 # Just json_field
146 recognized_fields.add(key)
148 # Also add special handled keys
149 special_keys = {
150 "$formkit",
151 "$el",
152 "if",
153 "for",
154 "then",
155 "else",
156 "children",
157 "node_type",
158 "formkit",
159 "id",
160 }
161 recognized_fields.update(special_keys)
163 # Extract unrecognized fields
164 unrecognized_fields = {k: v for k, v in existing.items() if k not in recognized_fields and v is not None}
166 # Store unrecognized fields in additional_props
167 if unrecognized_fields:
168 if instance.additional_props is None:
169 instance.additional_props = {}
170 # Merge with existing additional_props (don't overwrite if already set)
171 for key, value in unrecognized_fields.items():
172 if key not in instance.additional_props:
173 instance.additional_props[key] = value
175 setattr(instance, field, new_data)
177 def clean(self) -> dict[str, Any]:
178 cleaned_data = super().clean() # type: ignore[misc]
179 # Find any field mapped to "name" in JSON and validate it
180 for field, keys in self.get_json_fields().items():
181 for key in keys:
182 form_field, json_field = key if isinstance(key, tuple) else (key, key)
183 if json_field == "name" and form_field in cleaned_data:
184 val = cleaned_data[form_field]
185 if val:
186 try:
187 models.check_valid_django_id(val)
188 except django.core.exceptions.ValidationError as e:
189 self.add_error(form_field, e) # type: ignore[attr-defined]
190 return cleaned_data
193class FormKitBaseForm(JSONMappingMixin, forms.ModelForm):
194 """
195 Base form for all FormKit-related nodes.
196 """
198 class Meta:
199 model = models.FormKitSchemaNode
200 fields = COMMON_NODE_FIELDS + CODE_GEN_FIELDS + AUDIT_FIELDS
202 # Code Generation Overrides
203 django_field_type = forms.CharField(required=False)
204 django_field_args = forms.JSONField(required=False, widget=forms.Textarea(attrs={"rows": 4}))
205 django_field_positional_args = forms.JSONField(required=False, widget=forms.Textarea(attrs={"rows": 4}))
206 pydantic_field_type = forms.CharField(required=False)
207 extra_imports = forms.JSONField(required=False, widget=forms.Textarea(attrs={"rows": 4}))
208 validators = forms.JSONField(required=False, widget=forms.Textarea(attrs={"rows": 4}))
209 list_filter = forms.BooleanField(required=False)
211 def __init__(self, *args, **kwargs):
212 super().__init__(*args, **kwargs)
213 if instance := kwargs.get("instance"):
214 self._populate_form_fields(instance)
216 def save(self, commit: bool = True) -> models.FormKitSchemaNode:
217 instance = super().save(commit=False)
218 self.save_json_fields(instance) # type: ignore[arg-type]
219 if commit:
220 instance.save()
221 return instance
224class NewFormKitForm(forms.ModelForm):
225 class Meta:
226 model = models.FormKitSchemaNode
227 fields = ("label", "node_type", "description")
230class OptionForm(forms.ModelForm):
231 class Meta:
232 model = models.Option
233 exclude = ()
236class FormComponentsForm(forms.ModelForm):
237 class Meta:
238 model = models.FormComponents
239 exclude = ()
242class FormKitSchemaComponentInline(admin.TabularInline):
243 model = models.FormComponents
244 readonly_fields = (
245 "node",
246 "created_by",
247 "updated_by",
248 )
249 ordering = ("order",)
250 extra = 0
253class FormKitNodeGroupForm(FormKitBaseForm):
254 class Meta:
255 model = models.FormKitSchemaNode
256 fields = COMMON_NODE_FIELDS + ("additional_props", "option_group") + CODE_GEN_FIELDS + AUDIT_FIELDS
258 _json_fields = {
259 "node": ("name", ("formkit", "$formkit"), "if_condition", ("html_id", "id")),
260 }
261 html_id = forms.CharField(required=False)
262 name = forms.CharField(required=True)
263 formkit = forms.ChoiceField(required=False, initial="group", choices=models.FormKitSchemaNode.FORMKIT_CHOICES, disabled=True)
264 if_condition = forms.CharField(widget=forms.TextInput, required=False)
267class FormKitNodeForm(FormKitBaseForm):
268 class Meta:
269 model = models.FormKitSchemaNode
270 fields = COMMON_NODE_FIELDS + ("additional_props", "option_group", "add_label", "up_control", "down_control") + CODE_GEN_FIELDS + AUDIT_FIELDS
272 _json_fields = {
273 "node": (
274 ("formkit", "$formkit"),
275 "name",
276 "key",
277 "if_condition",
278 "options",
279 ("node_label", "label"),
280 "placeholder",
281 "help",
282 "validation",
283 "validationLabel",
284 "validationVisibility",
285 "validationMessages",
286 "prefixIcon",
287 "min",
288 "max",
289 "step",
290 ("html_id", "id"),
291 ("onchange", "onChange"),
292 )
293 }
294 name = forms.CharField(required=True)
295 formkit = forms.ChoiceField(required=False, choices=models.FormKitSchemaNode.FORMKIT_CHOICES)
296 if_condition = forms.CharField(widget=forms.TextInput, required=False)
297 key = forms.CharField(required=False)
298 node_label = forms.CharField(required=False)
299 placeholder = forms.CharField(required=False)
300 help = forms.CharField(required=False)
301 html_id = forms.CharField(required=False)
302 onchange = forms.CharField(required=False)
303 options = forms.CharField(required=False)
304 validation = forms.CharField(required=False)
305 validationLabel = forms.CharField(required=False)
306 validationVisibility = forms.CharField(required=False)
307 validationMessages = forms.JSONField(required=False)
308 prefixIcon = forms.CharField(required=False)
309 validationRules = forms.CharField(required=False, help_text="A function for validation passed into the schema: a key on `formSchemaData`")
310 max = forms.IntegerField(required=False)
311 min = forms.IntegerField(required=False)
312 step = forms.IntegerField(required=False)
314 def get_fields(self, request, obj: models.FormKitSchemaNode):
315 """
316 Customise the returned fields based on the type
317 of formkit node
318 """
319 return super().get_fields(request, obj) # type: ignore[misc]
322class FormKitNodeRepeaterForm(FormKitNodeForm):
323 """Repeater node form. add_label, up_control, down_control are model fields (parent);
324 itemsClass/itemClass are node-only and mapped here."""
326 def get_json_fields(self) -> JsonFieldDefn:
327 return {
328 "node": (
329 *(super().get_json_fields()["node"]),
330 "itemsClass",
331 "itemClass",
332 )
333 }
335 itemsClass = forms.CharField(required=False)
336 itemClass = forms.CharField(required=False)
337 max = forms.IntegerField(required=False)
338 min = forms.IntegerField(required=False)
341class FormKitTextNode(FormKitBaseForm):
342 class Meta(FormKitBaseForm.Meta):
343 fields = FormKitBaseForm.Meta.fields + ("text_content",) # type: ignore[assignment]
346class FormKitElementForm(FormKitBaseForm):
347 class Meta(FormKitBaseForm.Meta):
348 fields = FormKitBaseForm.Meta.fields + ("text_content",) # type: ignore[assignment]
350 _json_fields = {"node": (("el", "$el"), "name", "if_condition", "attrs__class")}
352 el = forms.ChoiceField(required=False, choices=models.FormKitSchemaNode.ELEMENT_TYPE_CHOICES)
353 name = forms.CharField(required=False)
354 attrs__class = forms.CharField(required=False)
355 if_condition = forms.CharField(widget=forms.TextInput, required=False)
358class FormKitConditionForm(FormKitBaseForm):
359 class Meta(FormKitBaseForm.Meta):
360 pass
362 _json_fields = {"node": ("if_condition", "then_condition", "else_condition")}
363 if_condition = forms.CharField(widget=forms.TextInput, required=False)
364 then_condition = forms.CharField(max_length=256, required=False)
365 else_condition = forms.CharField(max_length=256, required=False)
368class FormKitComponentForm(FormKitBaseForm):
369 class Meta(FormKitBaseForm.Meta):
370 pass
372 _json_fields = {"node": ("if_condition", "then_condition", "else_condition")}
375class NodeChildrenInline(admin.TabularInline):
376 """
377 Nested HTML elements
378 """
380 model = models.NodeChildren
381 fields = ("child", "order", "track_change")
382 ordering = ("order",)
383 readonly_fields = ("track_change",)
384 fk_name = "parent"
385 extra = 0
388class NodeParentsInline(admin.TabularInline):
389 """
390 Nested HTML elements
391 """
393 model = models.NodeChildren
394 fields = ("parent", "order", "track_change")
395 ordering = ("order",)
396 readonly_fields = ("track_change", "parent")
397 fk_name = "child"
398 extra = 0
401class NodeInline(admin.StackedInline):
402 """
403 Nodes related to Option Groups
404 """
406 model = models.FormKitSchemaNode
407 fields = ("label", "node_type", "description")
408 extra = 0
411class SchemaLabelInline(admin.TabularInline):
412 model = models.SchemaLabel
413 extra = 0
416class SchemaDescriptionInline(admin.TabularInline):
417 model = models.SchemaDescription
418 extra = 0
421class FormKitSchemaForm(forms.ModelForm):
422 class Meta:
423 model = models.FormKitSchema
424 exclude = ("name",)
427# Registry to map pydantic node types to form classes and fieldsets
428NODE_CONFIG: dict[type | str, dict[str, Any]] = {
429 str: {"form": FormKitTextNode},
430 formkit_schema.GroupNode: {"form": FormKitNodeGroupForm},
431 formkit_schema.RepeaterNode: {
432 "form": FormKitNodeRepeaterForm,
433 "fieldsets": [
434 (
435 "Repeater field properties",
436 {"fields": ("add_label", "up_control", "down_control", "itemsClass", "itemClass")},
437 )
438 ],
439 },
440 formkit_schema.FormKitSchemaDOMNode: {"form": FormKitElementForm},
441 formkit_schema.FormKitSchemaComponent: {"form": FormKitComponentForm},
442 formkit_schema.FormKitSchemaCondition: {"form": FormKitConditionForm},
443 formkit_schema.FormKitSchemaProps: {
444 "form": FormKitNodeForm,
445 "fieldsets": [
446 (
447 "Display & behaviour",
448 {"fields": ("icon", "title", "readonly", "sections_schema")},
449 ),
450 ],
451 },
452}
454# Admin site registration continues below...
457@admin.register(models.FormKitSchemaNode)
458class FormKitSchemaNodeAdmin(admin.ModelAdmin):
459 list_display = (
460 "label",
461 "title",
462 "is_active",
463 "short_id",
464 "node_type",
465 "option_group",
466 "formkit_or_el_type",
467 "key_is_valid",
468 "track_change",
469 "protected",
470 "created",
471 )
472 readonly_fields = (
473 "django_code_preview",
474 "pydantic_code_preview",
475 "formkit_node_preview",
476 "created",
477 "updated",
478 "created_by",
479 "updated_by",
480 )
481 search_fields = ["label", "description", "id"]
482 list_filter = ("is_active", "node_type", "protected", "option_group", "created", "updated")
483 list_select_related = ("option_group",)
484 list_per_page = 50
485 date_hierarchy = "created"
486 inlines = [NodeChildrenInline, NodeParentsInline]
488 def get_readonly_fields(self, request, obj=None):
489 ro = super().get_readonly_fields(request, obj)
490 return list(ro) + ["django_code_preview", "pydantic_code_preview", "formkit_node_preview"]
492 @admin.display(description="ID", ordering="id")
493 def short_id(self, obj: models.FormKitSchemaNode | None) -> str:
494 return short_uuid(obj.id) if obj else ""
496 @admin.display(boolean=True)
497 def key_is_valid(self, obj) -> bool:
498 if not (obj and obj.node and isinstance(obj.node, dict) and "name" in obj.node):
499 return True
500 try:
501 models.check_valid_django_id(obj.node.get("name"))
502 except (TypeError, django.core.exceptions.ValidationError):
503 return False
504 return True
506 def formkit_or_el_type(self, obj):
507 if obj and obj.node and obj.node_type in ("$formkit", "$el"):
508 return obj.node.get(obj.node_type)
510 def get_inlines(self, request, obj: models.FormKitSchemaNode | None):
511 return [NodeChildrenInline, NodeParentsInline] if obj else []
513 def get_fieldsets(self, request: HttpRequest, obj: models.FormKitSchemaNode | None = None):
514 if not obj:
515 return super().get_fieldsets(request, obj)
517 try:
518 node = obj.get_node()
519 except Exception:
520 return super().get_fieldsets(request, obj)
522 fieldsets: list[tuple[str | None, dict[str, Any]]] = []
523 for pydantic_type, config in NODE_CONFIG.items():
524 if isinstance(pydantic_type, type) and isinstance(node, pydantic_type):
525 if "fieldsets" in config:
526 fieldsets.extend(config["fieldsets"])
527 break
529 grouped_fields: set[str] = reduce(operator.or_, (set(opts["fields"]) for _, opts in fieldsets), set())
530 grouped_fields.update(CODE_GEN_GROUPED_FIELDS)
531 fieldsets.insert(0, (None, {"fields": [f for f in self.get_fields(request, obj) if f not in grouped_fields]}))
533 fieldsets.append(
534 (
535 CODE_GEN_FIELDSET_TITLE,
536 {
537 "fields": CODE_GEN_FIELDSET_FIELDS,
538 "description": "These values are the source of truth for code generation. If empty, they are auto-resolved on save from global configs.",
539 },
540 )
541 )
542 return fieldsets
544 @admin.display(description="Django Model Field Preview")
545 def django_code_preview(self, obj):
546 """Show what the Django model field code will look like."""
547 from django.utils.html import format_html
549 if not obj or not obj.pk:
550 return "(Save node to see preview)"
552 try:
553 from formkit_ninja.parser.type_convert import NodePath
555 # Ensure defaults are resolved for the preview
556 obj.resolve_code_generation_defaults()
558 nodes = obj.get_node_path(recursive=True)
560 path = NodePath(*nodes)
561 code = path.django_model_code
563 style = "background: #f8f9fa; padding: 10px; border-radius: 4px; border: 1px solid #dee2e6; color: #333; overflow: auto; max-height: 400px;"
564 return format_html(
565 '<pre style="{}">{}</pre>',
566 style,
567 code,
568 )
569 except Exception as e:
570 return format_html('<div style="color: red;">Error generating preview: {}</div>', str(e))
572 @admin.display(description="Pydantic Schema Preview")
573 def pydantic_code_preview(self, obj):
574 """Show what the Pydantic schema code will look like."""
575 from django.utils.html import format_html
577 if not obj or not obj.pk:
578 return "(Save node to see preview)"
580 try:
581 from formkit_ninja.parser.type_convert import NodePath
583 # Ensure defaults are resolved for the preview
584 obj.resolve_code_generation_defaults()
586 nodes = obj.get_node_path(recursive=True)
588 path = NodePath(*nodes)
589 code = path.pydantic_model_code
591 style = "background: #f8f9fa; padding: 10px; border-radius: 4px; border: 1px solid #dee2e6; color: #333; overflow: auto; max-height: 400px;"
592 return format_html(
593 '<pre style="{}">{}</pre>',
594 style,
595 code,
596 )
597 except Exception as e:
598 return format_html('<div style="color: red;">Error generating preview: {}</div>', str(e))
600 @admin.display(description="FormKit Node JSON Preview")
601 def formkit_node_preview(self, obj):
602 """Show the generated FormKit Node JSON."""
603 import json
605 from django.utils.html import format_html
607 if not obj or not obj.pk:
608 return "(Save node to see preview)"
610 try:
611 # Get the node via the Pydantic generator (recursive=True)
612 node = obj.get_node(recursive=True)
614 # If it's a Pydantic model, convert to dict
615 if hasattr(node, "dict"):
616 node_values = node.dict(exclude_none=True)
617 else:
618 # Could be a string (TextNode) or other primitive
619 node_values = node
621 # Format as pretty JSON
622 code = json.dumps(node_values, indent=2, ensure_ascii=False)
624 style = (
625 "background: #f1f3f5; padding: 10px; border-radius: 4px; "
626 "border: 1px solid #ced4da; color: #212529; overflow: auto; "
627 "max-height: 400px; font-family: monospace; font-size: 11px; "
628 "white-space: pre-wrap; word-break: break-all;"
629 )
630 return format_html(
631 '<pre style="{}">{}</pre>',
632 style,
633 code,
634 )
635 except Exception as e:
636 return format_html('<div style="color: red;">Error generating JSON preview: {}</div>', str(e))
638 def get_form(self, request: HttpRequest, obj: Any | None = None, change: bool = False, **kwargs: Any) -> type[forms.ModelForm[Any]]:
639 if not obj:
640 return NewFormKitForm
641 try:
642 node = obj.get_node()
643 for pydantic_type, config in NODE_CONFIG.items():
644 if isinstance(pydantic_type, type) and isinstance(node, pydantic_type):
645 return config["form"]
646 except Exception:
647 pass
648 return super().get_form(request, obj, **kwargs)
651@admin.register(models.FormKitSchema)
652class FormKitSchemaAdmin(admin.ModelAdmin):
653 form = FormKitSchemaForm
655 def get_inlines(self, request, obj: models.FormKitSchema | None):
656 """
657 For a "new object" do not show the Form Components
658 """
659 return (
660 [
661 SchemaLabelInline,
662 SchemaDescriptionInline,
663 FormKitSchemaComponentInline,
664 ]
665 if obj
666 else [
667 SchemaLabelInline,
668 SchemaDescriptionInline,
669 ]
670 )
672 inlines = [
673 SchemaLabelInline,
674 SchemaDescriptionInline,
675 FormKitSchemaComponentInline,
676 ]
679@admin.register(models.FormComponents)
680class FormComponentsAdmin(admin.ModelAdmin):
681 list_display = (
682 "label",
683 "schema",
684 "node",
685 "order",
686 )
689class OptionLabelInline(admin.TabularInline):
690 model = models.OptionLabel
691 extra = 0
694class OptionInline(admin.TabularInline):
695 model = models.Option
696 extra = 0
697 fields = ("group", "object_id", "value", "order")
698 readonly_fields = ("group", "object_id", "value")
701@admin.register(models.Option)
702class OptionAdmin(admin.ModelAdmin):
703 list_display = (
704 "object_id",
705 "value",
706 "order",
707 "group",
708 "last_updated",
709 )
710 inlines = [OptionLabelInline]
711 list_select_related = ("group",)
712 list_filter = ("group", "last_updated")
713 search_fields = ("value", "object_id", "group__group")
714 list_per_page = 50
715 date_hierarchy = "last_updated"
716 readonly_fields = ("group", "object_id", "value", "created_by", "updated_by")
719@admin.register(models.OptionGroup)
720class OptionGroupAdmin(admin.ModelAdmin):
721 list_display = ("group", "content_type", "option_count")
722 search_fields = ("group",)
723 list_filter = ("content_type",)
724 inlines = [OptionInline, NodeInline]
726 @admin.display(description="Options Count")
727 def option_count(self, obj):
728 """Display the number of options in this group."""
729 if obj.pk:
730 return obj.option_set.count()
731 return 0
734@admin.register(models.OptionLabel)
735class OptionLabelAdmin(admin.ModelAdmin):
736 list_display = (
737 "label",
738 "lang",
739 "option",
740 )
741 readonly_fields = ("option",)
742 search_fields = ("label", "option__value", "option__group__group")
743 list_filter = ("lang", "option__group")
744 list_select_related = ("option", "option__group")
745 list_per_page = 50
748# NOTE: SeparatedSubmission and Submission are imported at the top of the file
751@admin.register(Submission.pgh_event_model) # type: ignore[attr-defined]
752class SubmissionEventAdmin(pghistory.admin.EventModelAdmin):
753 """
754 Admin for Submission events.
755 """
757 pass
760@admin.register(SeparatedSubmission.pgh_event_model) # type: ignore[attr-defined]
761class SeparatedSubmissionEventAdmin(pghistory.admin.EventModelAdmin):
762 """
763 Admin for SeparatedSubmission events.
764 """
766 pass
769class SeparatedSubmissionForm(forms.ModelForm):
770 class Meta:
771 model = SeparatedSubmission
772 fields = "__all__"
773 widgets = {
774 "id": forms.HiddenInput(),
775 }
778class SeparatedSubmissionInline(admin.TabularInline):
779 """Inline for showing separated submissions within a Submission."""
781 model = SeparatedSubmission
782 form = SeparatedSubmissionForm
783 extra = 0
784 show_change_link = True
785 can_delete = False
786 readonly_fields = [f.name for f in SeparatedSubmission._meta.fields if f.name != "id"]
789@admin.register(Submission)
790class SubmissionAdmin(admin.ModelAdmin):
791 """Admin for Submission model."""
793 list_display = ("short_key", "user", "created", "status", "form_type", "is_verified", "is_active")
794 list_filter = ("is_active", "user", "status", "form_type", "created")
795 search_fields = ("key", "form_type", "user__username", "user__email")
796 list_select_related = ("user",)
797 list_per_page = 50
798 readonly_fields = ("key", "created", "updated")
799 inlines = [SeparatedSubmissionInline]
800 date_hierarchy = "created"
802 @admin.display(description="Key", ordering="key")
803 def short_key(self, obj: Submission | None) -> str:
804 return short_uuid(obj.key) if obj else ""
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 = ("short_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(description="ID", ordering="id")
825 def short_id(self, obj: SeparatedSubmission | None) -> str:
826 return short_uuid(obj.id) if obj else ""
828 @admin.display(boolean=True)
829 def is_verified(self, obj: SeparatedSubmission) -> bool:
830 """Returns whether the parent submission is verified."""
831 return obj.submission.status == Submission.Status.VERIFIED
834@admin.register(SubmissionFile)
835class SubmissionFileAdmin(admin.ModelAdmin):
836 list_display = ["submission", "file", "user", "date_uploaded", "deleted"]
837 list_filter = ("deleted", "date_uploaded", "user")
838 search_fields = ("submission", "file", "user__username", "user__email", "comment")
839 list_select_related = ("user",)
840 list_per_page = 50
841 date_hierarchy = "date_uploaded"
842 readonly_fields = ("submission", "file", "user", "date_uploaded", "comment", "deleted")
845@admin.register(SeparatedSubmissionImport)
846class SeparatedSubmissionImportAdmin(admin.ModelAdmin):
847 """Admin for SeparatedSubmissionImport model."""
849 list_display = ("id", "submission", "created", "success", "message_preview")
850 list_filter = ("success", "created")
851 readonly_fields = ("submission", "created", "success", "message")
852 list_select_related = ("submission", "submission__user")
853 list_per_page = 50
854 date_hierarchy = "created"
855 search_fields = ("message", "submission__form_type", "submission__id")
857 @admin.display(description="Message")
858 def message_preview(self, obj):
859 """Show truncated message preview."""
860 if obj.message:
861 max_length = 100
862 if len(obj.message) > max_length:
863 return f"{obj.message[:max_length]}..."
864 return obj.message
865 return "-"
868@admin.register(Flag)
869class FlagAdmin(admin.ModelAdmin):
870 list_display = ("separated_submission", "flag_type", "severity", "created", "resolved_at")
871 list_filter = ("flag_type", "severity", "resolved_at")
872 search_fields = ("flag_type", "message", "separated_submission_id")
873 readonly_fields = ("created",)
874 date_hierarchy = "created"
875 raw_id_fields = ("separated_submission",)