Coverage for formkit_ninja / admin.py: 53.52%
458 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-03-03 09:21 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-03-03 09:21 +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], ...]]
36class ItemAdmin(admin.ModelAdmin):
37 list_display = ("name",)
40class JSONMappingMixin:
41 """
42 Mixin to handle mapping between flat form fields and nested JSON fields.
43 """
45 _json_fields: JsonFieldDefn = {}
47 def get_json_fields(self) -> JsonFieldDefn:
48 return self._json_fields
50 def _extract_field_value(self, values: dict, json_field: str):
51 if "__" in json_field:
52 nested_field_name, nested_key = json_field.split("__", 1)
53 nested = values.get(nested_field_name)
54 if isinstance(nested, dict):
55 return nested.get(nested_key)
56 return None
57 return values.get(json_field)
59 def _populate_form_fields(self, instance):
60 for field, keys in self.get_json_fields().items():
61 values = getattr(instance, field, {}) or {}
62 for key in keys:
63 form_field, json_field = key if isinstance(key, tuple) else (key, key)
64 if f := self.fields.get(form_field):
65 val = self._extract_field_value(values, json_field)
66 if val is None:
67 # Fallback: check if the json_field corresponds to a model attribute
68 # using the same promotion logic as in models.py
69 mapping = {
70 "addLabel": "add_label",
71 "upControl": "up_control",
72 "downControl": "down_control",
73 "sectionsSchema": "sections_schema",
74 }
75 attr_name = mapping.get(json_field, json_field)
76 if hasattr(instance, attr_name):
77 val = getattr(instance, attr_name)
78 f.initial = val
80 def _build_json_data(self, keys: tuple, existing_data: dict) -> dict:
81 data = existing_data.copy() if isinstance(existing_data, dict) else {}
82 for key in keys:
83 form_field, json_field = key if isinstance(key, tuple) else (key, key)
84 if form_field not in self.cleaned_data: # type: ignore[attr-defined]
85 continue
87 val = self.cleaned_data[form_field] # type: ignore[attr-defined]
88 if "__" in json_field:
89 nested_field_name, nested_key = json_field.split("__", 1)
90 if not isinstance(data.get(nested_field_name), dict):
91 data[nested_field_name] = {}
92 data[nested_field_name][nested_key] = val
93 else:
94 data[json_field] = val
95 return data
97 def save_json_fields(self, instance):
98 for field, keys in self.get_json_fields().items():
99 existing = getattr(instance, field, {}) or {}
100 new_data = self._build_json_data(keys, existing)
102 # Extract unrecognized fields from existing data and preserve in additional_props
103 if field == "node" and isinstance(existing, dict):
104 # Get all recognized fields (from form fields and their JSON mappings)
105 recognized_fields = set()
106 for key in keys:
107 if isinstance(key, tuple):
108 # (form_field, json_field) tuple
109 recognized_fields.add(key[1])
110 else:
111 # Just json_field
112 recognized_fields.add(key)
114 # Also add special handled keys
115 special_keys = {
116 "$formkit",
117 "$el",
118 "if",
119 "for",
120 "then",
121 "else",
122 "children",
123 "node_type",
124 "formkit",
125 "id",
126 }
127 recognized_fields.update(special_keys)
129 # Extract unrecognized fields
130 unrecognized_fields = {k: v for k, v in existing.items() if k not in recognized_fields and v is not None}
132 # Store unrecognized fields in additional_props
133 if unrecognized_fields:
134 if instance.additional_props is None:
135 instance.additional_props = {}
136 # Merge with existing additional_props (don't overwrite if already set)
137 for key, value in unrecognized_fields.items():
138 if key not in instance.additional_props:
139 instance.additional_props[key] = value
141 setattr(instance, field, new_data)
143 def clean(self) -> dict[str, Any]:
144 cleaned_data = super().clean() # type: ignore[misc]
145 # Find any field mapped to "name" in JSON and validate it
146 for field, keys in self.get_json_fields().items():
147 for key in keys:
148 form_field, json_field = key if isinstance(key, tuple) else (key, key)
149 if json_field == "name" and form_field in cleaned_data:
150 val = cleaned_data[form_field]
151 if val:
152 try:
153 models.check_valid_django_id(val)
154 except django.core.exceptions.ValidationError as e:
155 self.add_error(form_field, e) # type: ignore[attr-defined]
156 return cleaned_data
159class FormKitBaseForm(JSONMappingMixin, forms.ModelForm):
160 """
161 Base form for all FormKit-related nodes.
162 """
164 class Meta:
165 model = models.FormKitSchemaNode
166 fields = (
167 "label",
168 "description",
169 "is_active",
170 "protected",
171 "django_field_type",
172 "django_field_args",
173 "django_field_positional_args",
174 "pydantic_field_type",
175 "extra_imports",
176 "validators",
177 "list_filter",
178 )
180 # Code Generation Overrides
181 django_field_type = forms.CharField(required=False)
182 django_field_args = forms.JSONField(required=False, widget=forms.Textarea(attrs={"rows": 4}))
183 django_field_positional_args = forms.JSONField(required=False, widget=forms.Textarea(attrs={"rows": 4}))
184 pydantic_field_type = forms.CharField(required=False)
185 extra_imports = forms.JSONField(required=False, widget=forms.Textarea(attrs={"rows": 4}))
186 validators = forms.JSONField(required=False, widget=forms.Textarea(attrs={"rows": 4}))
187 list_filter = forms.BooleanField(required=False)
189 def __init__(self, *args, **kwargs):
190 super().__init__(*args, **kwargs)
191 if instance := kwargs.get("instance"):
192 self._populate_form_fields(instance)
194 def save(self, commit: bool = True) -> models.FormKitSchemaNode:
195 instance = super().save(commit=False)
196 self.save_json_fields(instance) # type: ignore[arg-type]
197 if commit:
198 instance.save()
199 return instance
202class NewFormKitForm(forms.ModelForm):
203 class Meta:
204 model = models.FormKitSchemaNode
205 fields = ("label", "node_type", "description")
208class OptionForm(forms.ModelForm):
209 class Meta:
210 model = models.Option
211 exclude = ()
214class FormComponentsForm(forms.ModelForm):
215 class Meta:
216 model = models.FormComponents
217 exclude = ()
220class FormKitSchemaComponentInline(admin.TabularInline):
221 model = models.FormComponents
222 readonly_fields = (
223 "node",
224 "created_by",
225 "updated_by",
226 )
227 ordering = ("order",)
228 extra = 0
231class FormKitNodeGroupForm(FormKitBaseForm):
232 class Meta:
233 model = models.FormKitSchemaNode
234 fields = (
235 "label",
236 "description",
237 "additional_props",
238 "is_active",
239 "protected",
240 "django_field_type",
241 "django_field_args",
242 "django_field_positional_args",
243 "pydantic_field_type",
244 "extra_imports",
245 "validators",
246 "list_filter",
247 )
249 _json_fields = {
250 "node": ("name", ("formkit", "$formkit"), "if_condition", ("html_id", "id")),
251 }
252 html_id = forms.CharField(required=False)
253 name = forms.CharField(required=True)
254 formkit = forms.ChoiceField(required=False, initial="group", choices=models.FormKitSchemaNode.FORMKIT_CHOICES, disabled=True)
255 if_condition = forms.CharField(widget=forms.TextInput, required=False)
258class FormKitNodeForm(FormKitBaseForm):
259 class Meta:
260 model = models.FormKitSchemaNode
261 fields = (
262 "label",
263 "description",
264 "additional_props",
265 "option_group",
266 "is_active",
267 "protected",
268 "django_field_type",
269 "django_field_args",
270 "django_field_positional_args",
271 "pydantic_field_type",
272 "extra_imports",
273 "validators",
274 "list_filter",
275 )
277 _json_fields = {
278 "node": (
279 ("formkit", "$formkit"),
280 "name",
281 "key",
282 "if_condition",
283 "options",
284 ("node_label", "label"),
285 "placeholder",
286 "help",
287 "validation",
288 "validationLabel",
289 "validationVisibility",
290 "validationMessages",
291 "prefixIcon",
292 "min",
293 "max",
294 "step",
295 ("html_id", "id"),
296 ("onchange", "onChange"),
297 )
298 }
299 name = forms.CharField(required=True)
300 formkit = forms.ChoiceField(required=False, choices=models.FormKitSchemaNode.FORMKIT_CHOICES)
301 if_condition = forms.CharField(widget=forms.TextInput, required=False)
302 key = forms.CharField(required=False)
303 node_label = forms.CharField(required=False)
304 placeholder = forms.CharField(required=False)
305 help = forms.CharField(required=False)
306 html_id = forms.CharField(required=False)
307 onchange = forms.CharField(required=False)
308 options = forms.CharField(required=False)
309 validation = forms.CharField(required=False)
310 validationLabel = forms.CharField(required=False)
311 validationVisibility = forms.CharField(required=False)
312 validationMessages = forms.JSONField(required=False)
313 prefixIcon = forms.CharField(required=False)
314 validationRules = forms.CharField(required=False, help_text="A function for validation passed into the schema: a key on `formSchemaData`")
315 max = forms.IntegerField(required=False)
316 min = forms.IntegerField(required=False)
317 step = forms.IntegerField(required=False)
319 def get_fields(self, request, obj: models.FormKitSchemaNode):
320 """
321 Customise the returned fields based on the type
322 of formkit node
323 """
324 return super().get_fields(request, obj) # type: ignore[misc]
327class FormKitNodeRepeaterForm(FormKitNodeForm):
328 def get_json_fields(self) -> JsonFieldDefn:
329 return {
330 "node": (
331 *(super()._json_fields["node"]),
332 "addLabel",
333 "upControl",
334 "downControl",
335 "itemsClass",
336 "itemClass",
337 )
338 }
340 addLabel = forms.CharField(required=False)
341 upControl = forms.BooleanField(required=False)
342 downControl = forms.BooleanField(required=False)
343 itemsClass = forms.CharField(required=False)
344 itemClass = forms.CharField(required=False)
345 max = forms.IntegerField(required=False)
346 min = forms.IntegerField(required=False)
349class FormKitTextNode(FormKitBaseForm):
350 class Meta(FormKitBaseForm.Meta):
351 fields = FormKitBaseForm.Meta.fields + ("text_content",) # type: ignore[assignment]
354class FormKitElementForm(FormKitBaseForm):
355 class Meta(FormKitBaseForm.Meta):
356 fields = FormKitBaseForm.Meta.fields + ("text_content",) # type: ignore[assignment]
358 _json_fields = {"node": (("el", "$el"), "name", "if_condition", "attrs__class")}
360 el = forms.ChoiceField(required=False, choices=models.FormKitSchemaNode.ELEMENT_TYPE_CHOICES)
361 name = forms.CharField(required=False)
362 attrs__class = forms.CharField(required=False)
363 if_condition = forms.CharField(widget=forms.TextInput, required=False)
366class FormKitConditionForm(FormKitBaseForm):
367 class Meta(FormKitBaseForm.Meta):
368 pass
370 _json_fields = {"node": ("if_condition", "then_condition", "else_condition")}
371 if_condition = forms.CharField(widget=forms.TextInput, required=False)
372 then_condition = forms.CharField(max_length=256, required=False)
373 else_condition = forms.CharField(max_length=256, required=False)
376class FormKitComponentForm(FormKitBaseForm):
377 class Meta(FormKitBaseForm.Meta):
378 pass
380 _json_fields = {"node": ("if_condition", "then_condition", "else_condition")}
383class NodeChildrenInline(admin.TabularInline):
384 """
385 Nested HTML elements
386 """
388 model = models.NodeChildren
389 fields = ("child", "order", "track_change")
390 ordering = ("order",)
391 readonly_fields = ("track_change",)
392 fk_name = "parent"
393 extra = 0
396class NodeParentsInline(admin.TabularInline):
397 """
398 Nested HTML elements
399 """
401 model = models.NodeChildren
402 fields = ("parent", "order", "track_change")
403 ordering = ("order",)
404 readonly_fields = ("track_change", "parent")
405 fk_name = "child"
406 extra = 0
409class NodeInline(admin.StackedInline):
410 """
411 Nodes related to Option Groups
412 """
414 model = models.FormKitSchemaNode
415 fields = ("label", "node_type", "description")
416 extra = 0
419class SchemaLabelInline(admin.TabularInline):
420 model = models.SchemaLabel
421 extra = 0
424class SchemaDescriptionInline(admin.TabularInline):
425 model = models.SchemaDescription
426 extra = 0
429class FormKitSchemaForm(forms.ModelForm):
430 class Meta:
431 model = models.FormKitSchema
432 exclude = ("name",)
435# Registry to map pydantic node types to form classes and fieldsets
436NODE_CONFIG: dict[type | str, dict[str, Any]] = {
437 str: {"form": FormKitTextNode},
438 formkit_schema.GroupNode: {"form": FormKitNodeGroupForm},
439 formkit_schema.RepeaterNode: {
440 "form": FormKitNodeRepeaterForm,
441 "fieldsets": [
442 (
443 "Repeater field properties",
444 {"fields": ("addLabel", "upControl", "downControl", "itemsClass", "itemClass")},
445 )
446 ],
447 },
448 formkit_schema.FormKitSchemaDOMNode: {"form": FormKitElementForm},
449 formkit_schema.FormKitSchemaComponent: {"form": FormKitComponentForm},
450 formkit_schema.FormKitSchemaCondition: {"form": FormKitConditionForm},
451 formkit_schema.FormKitSchemaProps: {"form": FormKitNodeForm},
452}
454# Admin site registration continues below...
457@admin.register(models.FormKitSchemaNode)
458class FormKitSchemaNodeAdmin(admin.ModelAdmin):
459 list_display = (
460 "label",
461 "is_active",
462 "short_id",
463 "node_type",
464 "option_group",
465 "formkit_or_el_type",
466 "key_is_valid",
467 "track_change",
468 "protected",
469 "created",
470 )
471 readonly_fields = ("django_code_preview", "pydantic_code_preview", "formkit_node_preview", "created", "updated")
472 search_fields = ["label", "description", "id"]
473 list_filter = ("is_active", "node_type", "protected", "option_group", "created", "updated")
474 list_select_related = ("option_group",)
475 list_per_page = 50
476 date_hierarchy = "created"
477 inlines = [NodeChildrenInline, NodeParentsInline]
479 def get_readonly_fields(self, request, obj=None):
480 ro = super().get_readonly_fields(request, obj)
481 return list(ro) + ["django_code_preview", "pydantic_code_preview", "formkit_node_preview"]
483 @admin.display(description="ID", ordering="id")
484 def short_id(self, obj: models.FormKitSchemaNode | None) -> str:
485 return short_uuid(obj.id) if obj else ""
487 @admin.display(boolean=True)
488 def key_is_valid(self, obj) -> bool:
489 if not (obj and obj.node and isinstance(obj.node, dict) and "name" in obj.node):
490 return True
491 try:
492 models.check_valid_django_id(obj.node.get("name"))
493 except (TypeError, django.core.exceptions.ValidationError):
494 return False
495 return True
497 def formkit_or_el_type(self, obj):
498 if obj and obj.node and obj.node_type in ("$formkit", "$el"):
499 return obj.node.get(obj.node_type)
501 def get_inlines(self, request, obj: models.FormKitSchemaNode | None):
502 return [NodeChildrenInline, NodeParentsInline] if obj else []
504 def get_fieldsets(self, request: HttpRequest, obj: models.FormKitSchemaNode | None = None):
505 if not obj:
506 return super().get_fieldsets(request, obj)
508 try:
509 node = obj.get_node()
510 except Exception:
511 return super().get_fieldsets(request, obj)
513 fieldsets: list[tuple[str | None, dict[str, Any]]] = []
514 for pydantic_type, config in NODE_CONFIG.items():
515 if isinstance(pydantic_type, type) and isinstance(node, pydantic_type):
516 if "fieldsets" in config:
517 fieldsets.extend(config["fieldsets"])
518 break
520 grouped_fields: set[str] = reduce(operator.or_, (set(opts["fields"]) for _, opts in fieldsets), set())
521 # Also include code generation fields to avoid them being duplicated in the default fieldset
522 code_gen_fields = {
523 "django_field_type",
524 "django_field_args",
525 "django_field_positional_args",
526 "pydantic_field_type",
527 "extra_imports",
528 "validators",
529 "list_filter",
530 "django_code_preview",
531 "pydantic_code_preview",
532 "formkit_node_preview",
533 }
534 grouped_fields.update(code_gen_fields)
535 fieldsets.insert(0, (None, {"fields": [f for f in self.get_fields(request, obj) if f not in grouped_fields]}))
537 # Add Code Generation Source of Truth fieldset
538 fieldsets.append(
539 (
540 "Code Generation (Source of Truth)",
541 {
542 "fields": (
543 "django_field_type",
544 "django_field_args",
545 "django_field_positional_args",
546 "pydantic_field_type",
547 "extra_imports",
548 "validators",
549 "list_filter",
550 "django_code_preview",
551 "pydantic_code_preview",
552 "formkit_node_preview",
553 ),
554 "description": ("These values are the source of truth for code generation. If empty, they are auto-resolved on save from global configs."),
555 },
556 )
557 )
558 return fieldsets
560 @admin.display(description="Django Model Field Preview")
561 def django_code_preview(self, obj):
562 """Show what the Django model field code will look like."""
563 from django.utils.html import format_html
565 if not obj or not obj.pk:
566 return "(Save node to see preview)"
568 try:
569 from formkit_ninja.parser.type_convert import NodePath
571 # Ensure defaults are resolved for the preview
572 obj.resolve_code_generation_defaults()
574 nodes = obj.get_node_path(recursive=True)
576 path = NodePath(*nodes)
577 code = path.django_model_code
579 style = "background: #f8f9fa; padding: 10px; border-radius: 4px; border: 1px solid #dee2e6; color: #333; overflow: auto; max-height: 400px;"
580 return format_html(
581 '<pre style="{}">{}</pre>',
582 style,
583 code,
584 )
585 except Exception as e:
586 return format_html('<div style="color: red;">Error generating preview: {}</div>', str(e))
588 @admin.display(description="Pydantic Schema Preview")
589 def pydantic_code_preview(self, obj):
590 """Show what the Pydantic schema code will look like."""
591 from django.utils.html import format_html
593 if not obj or not obj.pk:
594 return "(Save node to see preview)"
596 try:
597 from formkit_ninja.parser.type_convert import NodePath
599 # Ensure defaults are resolved for the preview
600 obj.resolve_code_generation_defaults()
602 nodes = obj.get_node_path(recursive=True)
604 path = NodePath(*nodes)
605 code = path.pydantic_model_code
607 style = "background: #f8f9fa; padding: 10px; border-radius: 4px; border: 1px solid #dee2e6; color: #333; overflow: auto; max-height: 400px;"
608 return format_html(
609 '<pre style="{}">{}</pre>',
610 style,
611 code,
612 )
613 except Exception as e:
614 return format_html('<div style="color: red;">Error generating preview: {}</div>', str(e))
616 @admin.display(description="FormKit Node JSON Preview")
617 def formkit_node_preview(self, obj):
618 """Show the generated FormKit Node JSON."""
619 import json
621 from django.utils.html import format_html
623 if not obj or not obj.pk:
624 return "(Save node to see preview)"
626 try:
627 # Get the node via the Pydantic generator (recursive=True)
628 node = obj.get_node(recursive=True)
630 # If it's a Pydantic model, convert to dict
631 if hasattr(node, "dict"):
632 node_values = node.dict(exclude_none=True)
633 else:
634 # Could be a string (TextNode) or other primitive
635 node_values = node
637 # Format as pretty JSON
638 code = json.dumps(node_values, indent=2, ensure_ascii=False)
640 style = (
641 "background: #f1f3f5; padding: 10px; border-radius: 4px; "
642 "border: 1px solid #ced4da; color: #212529; overflow: auto; "
643 "max-height: 400px; font-family: monospace; font-size: 11px; "
644 "white-space: pre-wrap; word-break: break-all;"
645 )
646 return format_html(
647 '<pre style="{}">{}</pre>',
648 style,
649 code,
650 )
651 except Exception as e:
652 return format_html('<div style="color: red;">Error generating JSON preview: {}</div>', str(e))
654 def get_form(self, request: HttpRequest, obj: Any | None = None, change: bool = False, **kwargs: Any) -> type[forms.ModelForm[Any]]:
655 if not obj:
656 return NewFormKitForm
657 try:
658 node = obj.get_node()
659 for pydantic_type, config in NODE_CONFIG.items():
660 if isinstance(pydantic_type, type) and isinstance(node, pydantic_type):
661 return config["form"]
662 except Exception:
663 pass
664 return super().get_form(request, obj, **kwargs)
667@admin.register(models.FormKitSchema)
668class FormKitSchemaAdmin(admin.ModelAdmin):
669 form = FormKitSchemaForm
671 def get_inlines(self, request, obj: models.FormKitSchema | None):
672 """
673 For a "new object" do not show the Form Components
674 """
675 return (
676 [
677 SchemaLabelInline,
678 SchemaDescriptionInline,
679 FormKitSchemaComponentInline,
680 ]
681 if obj
682 else [
683 SchemaLabelInline,
684 SchemaDescriptionInline,
685 ]
686 )
688 inlines = [
689 SchemaLabelInline,
690 SchemaDescriptionInline,
691 FormKitSchemaComponentInline,
692 ]
695@admin.register(models.FormComponents)
696class FormComponentsAdmin(admin.ModelAdmin):
697 list_display = (
698 "label",
699 "schema",
700 "node",
701 "order",
702 )
705class OptionLabelInline(admin.TabularInline):
706 model = models.OptionLabel
707 extra = 0
710class OptionInline(admin.TabularInline):
711 model = models.Option
712 extra = 0
713 fields = ("group", "object_id", "value", "order")
714 readonly_fields = ("group", "object_id", "value")
717@admin.register(models.Option)
718class OptionAdmin(admin.ModelAdmin):
719 list_display = (
720 "object_id",
721 "value",
722 "order",
723 "group",
724 "last_updated",
725 )
726 inlines = [OptionLabelInline]
727 list_select_related = ("group",)
728 list_filter = ("group", "last_updated")
729 search_fields = ("value", "object_id", "group__group")
730 list_per_page = 50
731 date_hierarchy = "last_updated"
732 readonly_fields = ("group", "object_id", "value", "created_by", "updated_by")
735@admin.register(models.OptionGroup)
736class OptionGroupAdmin(admin.ModelAdmin):
737 list_display = ("group", "content_type", "option_count")
738 search_fields = ("group",)
739 list_filter = ("content_type",)
740 inlines = [OptionInline, NodeInline]
742 @admin.display(description="Options Count")
743 def option_count(self, obj):
744 """Display the number of options in this group."""
745 if obj.pk:
746 return obj.option_set.count()
747 return 0
750@admin.register(models.OptionLabel)
751class OptionLabelAdmin(admin.ModelAdmin):
752 list_display = (
753 "label",
754 "lang",
755 "option",
756 )
757 readonly_fields = ("option",)
758 search_fields = ("label", "option__value", "option__group__group")
759 list_filter = ("lang", "option__group")
760 list_select_related = ("option", "option__group")
761 list_per_page = 50
764# NOTE: SeparatedSubmission and Submission are imported at the top of the file
767@admin.register(Submission.pgh_event_model) # type: ignore[attr-defined]
768class SubmissionEventAdmin(pghistory.admin.EventModelAdmin):
769 """
770 Admin for Submission events.
771 """
773 pass
776@admin.register(SeparatedSubmission.pgh_event_model) # type: ignore[attr-defined]
777class SeparatedSubmissionEventAdmin(pghistory.admin.EventModelAdmin):
778 """
779 Admin for SeparatedSubmission events.
780 """
782 pass
785class SeparatedSubmissionForm(forms.ModelForm):
786 class Meta:
787 model = SeparatedSubmission
788 fields = "__all__"
789 widgets = {
790 "id": forms.HiddenInput(),
791 }
794class SeparatedSubmissionInline(admin.TabularInline):
795 """Inline for showing separated submissions within a Submission."""
797 model = SeparatedSubmission
798 form = SeparatedSubmissionForm
799 extra = 0
800 show_change_link = True
801 can_delete = False
802 readonly_fields = [f.name for f in SeparatedSubmission._meta.fields if f.name != "id"]
805@admin.register(Submission)
806class SubmissionAdmin(admin.ModelAdmin):
807 """Admin for Submission model."""
809 list_display = ("short_key", "user", "created", "status", "form_type", "is_verified", "is_active")
810 list_filter = ("is_active", "user", "status", "form_type", "created")
811 search_fields = ("key", "form_type", "user__username", "user__email")
812 list_select_related = ("user",)
813 list_per_page = 50
814 readonly_fields = ("key", "created", "updated")
815 inlines = [SeparatedSubmissionInline]
816 date_hierarchy = "created"
818 @admin.display(description="Key", ordering="key")
819 def short_key(self, obj: Submission | None) -> str:
820 return short_uuid(obj.key) if obj else ""
822 @admin.display(boolean=True)
823 def is_verified(self, obj: Submission) -> bool:
824 """Returns whether this submission is verified."""
825 return obj.status == Submission.Status.VERIFIED
828@admin.register(SeparatedSubmission)
829class SeparatedSubmissionAdmin(admin.ModelAdmin):
830 """Admin for SeparatedSubmission model."""
832 list_display = ("short_id", "user", "created", "status", "form_type", "is_verified", "repeater_key", "repeater_order")
833 list_filter = ("user", "status", "form_type", "repeater_key", "created")
834 search_fields = ("id", "form_type", "user__username", "user__email", "repeater_key")
835 readonly_fields = [f.name for f in SeparatedSubmission._meta.fields]
836 list_select_related = ("submission", "user", "repeater_parent")
837 list_per_page = 50
838 date_hierarchy = "created"
840 @admin.display(description="ID", ordering="id")
841 def short_id(self, obj: SeparatedSubmission | None) -> str:
842 return short_uuid(obj.id) if obj else ""
844 @admin.display(boolean=True)
845 def is_verified(self, obj: SeparatedSubmission) -> bool:
846 """Returns whether the parent submission is verified."""
847 return obj.submission.status == Submission.Status.VERIFIED
850@admin.register(SubmissionFile)
851class SubmissionFileAdmin(admin.ModelAdmin):
852 list_display = ["submission", "file", "user", "date_uploaded", "deleted"]
853 list_filter = ("deleted", "date_uploaded", "user")
854 search_fields = ("submission", "file", "user__username", "user__email", "comment")
855 list_select_related = ("user",)
856 list_per_page = 50
857 date_hierarchy = "date_uploaded"
858 readonly_fields = ("submission", "file", "user", "date_uploaded", "comment", "deleted")
861@admin.register(SeparatedSubmissionImport)
862class SeparatedSubmissionImportAdmin(admin.ModelAdmin):
863 """Admin for SeparatedSubmissionImport model."""
865 list_display = ("id", "submission", "created", "success", "message_preview")
866 list_filter = ("success", "created")
867 readonly_fields = ("submission", "created", "success", "message")
868 list_select_related = ("submission", "submission__user")
869 list_per_page = 50
870 date_hierarchy = "created"
871 search_fields = ("message", "submission__form_type", "submission__id")
873 @admin.display(description="Message")
874 def message_preview(self, obj):
875 """Show truncated message preview."""
876 if obj.message:
877 max_length = 100
878 if len(obj.message) > max_length:
879 return f"{obj.message[:max_length]}..."
880 return obj.message
881 return "-"
884@admin.register(Flag)
885class FlagAdmin(admin.ModelAdmin):
886 list_display = ("separated_submission", "flag_type", "severity", "created", "resolved_at")
887 list_filter = ("flag_type", "severity", "resolved_at")
888 search_fields = ("flag_type", "message", "separated_submission_id")
889 readonly_fields = ("created",)
890 date_hierarchy = "created"
891 raw_id_fields = ("separated_submission",)