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

1from __future__ import annotations 

2 

3import logging 

4import operator 

5from functools import reduce 

6from typing import Any 

7 

8import django.core.exceptions 

9import pghistory.admin 

10from django import forms 

11from django.contrib import admin 

12from django.http import HttpRequest 

13 

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 

27 

28logger = logging.getLogger(__name__) 

29 

30 

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], ...]] 

34 

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") 

59 

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) 

68 

69 

70class ItemAdmin(admin.ModelAdmin): 

71 list_display = ("name",) 

72 

73 

74class JSONMappingMixin: 

75 """ 

76 Mixin to handle mapping between flat form fields and nested JSON fields. 

77 """ 

78 

79 _json_fields: JsonFieldDefn = {} 

80 

81 def get_json_fields(self) -> JsonFieldDefn: 

82 return self._json_fields 

83 

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) 

92 

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 

113 

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 

120 

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 

130 

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) 

135 

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) 

147 

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) 

162 

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} 

165 

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 

174 

175 setattr(instance, field, new_data) 

176 

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 

191 

192 

193class FormKitBaseForm(JSONMappingMixin, forms.ModelForm): 

194 """ 

195 Base form for all FormKit-related nodes. 

196 """ 

197 

198 class Meta: 

199 model = models.FormKitSchemaNode 

200 fields = COMMON_NODE_FIELDS + CODE_GEN_FIELDS + AUDIT_FIELDS 

201 

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) 

210 

211 def __init__(self, *args, **kwargs): 

212 super().__init__(*args, **kwargs) 

213 if instance := kwargs.get("instance"): 

214 self._populate_form_fields(instance) 

215 

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 

222 

223 

224class NewFormKitForm(forms.ModelForm): 

225 class Meta: 

226 model = models.FormKitSchemaNode 

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

228 

229 

230class OptionForm(forms.ModelForm): 

231 class Meta: 

232 model = models.Option 

233 exclude = () 

234 

235 

236class FormComponentsForm(forms.ModelForm): 

237 class Meta: 

238 model = models.FormComponents 

239 exclude = () 

240 

241 

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 

251 

252 

253class FormKitNodeGroupForm(FormKitBaseForm): 

254 class Meta: 

255 model = models.FormKitSchemaNode 

256 fields = COMMON_NODE_FIELDS + ("additional_props", "option_group") + CODE_GEN_FIELDS + AUDIT_FIELDS 

257 

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) 

265 

266 

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 

271 

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) 

313 

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] 

320 

321 

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.""" 

325 

326 def get_json_fields(self) -> JsonFieldDefn: 

327 return { 

328 "node": ( 

329 *(super().get_json_fields()["node"]), 

330 "itemsClass", 

331 "itemClass", 

332 ) 

333 } 

334 

335 itemsClass = forms.CharField(required=False) 

336 itemClass = forms.CharField(required=False) 

337 max = forms.IntegerField(required=False) 

338 min = forms.IntegerField(required=False) 

339 

340 

341class FormKitTextNode(FormKitBaseForm): 

342 class Meta(FormKitBaseForm.Meta): 

343 fields = FormKitBaseForm.Meta.fields + ("text_content",) # type: ignore[assignment] 

344 

345 

346class FormKitElementForm(FormKitBaseForm): 

347 class Meta(FormKitBaseForm.Meta): 

348 fields = FormKitBaseForm.Meta.fields + ("text_content",) # type: ignore[assignment] 

349 

350 _json_fields = {"node": (("el", "$el"), "name", "if_condition", "attrs__class")} 

351 

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) 

356 

357 

358class FormKitConditionForm(FormKitBaseForm): 

359 class Meta(FormKitBaseForm.Meta): 

360 pass 

361 

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) 

366 

367 

368class FormKitComponentForm(FormKitBaseForm): 

369 class Meta(FormKitBaseForm.Meta): 

370 pass 

371 

372 _json_fields = {"node": ("if_condition", "then_condition", "else_condition")} 

373 

374 

375class NodeChildrenInline(admin.TabularInline): 

376 """ 

377 Nested HTML elements 

378 """ 

379 

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 

386 

387 

388class NodeParentsInline(admin.TabularInline): 

389 """ 

390 Nested HTML elements 

391 """ 

392 

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 

399 

400 

401class NodeInline(admin.StackedInline): 

402 """ 

403 Nodes related to Option Groups 

404 """ 

405 

406 model = models.FormKitSchemaNode 

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

408 extra = 0 

409 

410 

411class SchemaLabelInline(admin.TabularInline): 

412 model = models.SchemaLabel 

413 extra = 0 

414 

415 

416class SchemaDescriptionInline(admin.TabularInline): 

417 model = models.SchemaDescription 

418 extra = 0 

419 

420 

421class FormKitSchemaForm(forms.ModelForm): 

422 class Meta: 

423 model = models.FormKitSchema 

424 exclude = ("name",) 

425 

426 

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} 

453 

454# Admin site registration continues below... 

455 

456 

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] 

487 

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"] 

491 

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 "" 

495 

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 

505 

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) 

509 

510 def get_inlines(self, request, obj: models.FormKitSchemaNode | None): 

511 return [NodeChildrenInline, NodeParentsInline] if obj else [] 

512 

513 def get_fieldsets(self, request: HttpRequest, obj: models.FormKitSchemaNode | None = None): 

514 if not obj: 

515 return super().get_fieldsets(request, obj) 

516 

517 try: 

518 node = obj.get_node() 

519 except Exception: 

520 return super().get_fieldsets(request, obj) 

521 

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 

528 

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]})) 

532 

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 

543 

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 

548 

549 if not obj or not obj.pk: 

550 return "(Save node to see preview)" 

551 

552 try: 

553 from formkit_ninja.parser.type_convert import NodePath 

554 

555 # Ensure defaults are resolved for the preview 

556 obj.resolve_code_generation_defaults() 

557 

558 nodes = obj.get_node_path(recursive=True) 

559 

560 path = NodePath(*nodes) 

561 code = path.django_model_code 

562 

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

571 

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 

576 

577 if not obj or not obj.pk: 

578 return "(Save node to see preview)" 

579 

580 try: 

581 from formkit_ninja.parser.type_convert import NodePath 

582 

583 # Ensure defaults are resolved for the preview 

584 obj.resolve_code_generation_defaults() 

585 

586 nodes = obj.get_node_path(recursive=True) 

587 

588 path = NodePath(*nodes) 

589 code = path.pydantic_model_code 

590 

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

599 

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 

604 

605 from django.utils.html import format_html 

606 

607 if not obj or not obj.pk: 

608 return "(Save node to see preview)" 

609 

610 try: 

611 # Get the node via the Pydantic generator (recursive=True) 

612 node = obj.get_node(recursive=True) 

613 

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 

620 

621 # Format as pretty JSON 

622 code = json.dumps(node_values, indent=2, ensure_ascii=False) 

623 

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

637 

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) 

649 

650 

651@admin.register(models.FormKitSchema) 

652class FormKitSchemaAdmin(admin.ModelAdmin): 

653 form = FormKitSchemaForm 

654 

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 ) 

671 

672 inlines = [ 

673 SchemaLabelInline, 

674 SchemaDescriptionInline, 

675 FormKitSchemaComponentInline, 

676 ] 

677 

678 

679@admin.register(models.FormComponents) 

680class FormComponentsAdmin(admin.ModelAdmin): 

681 list_display = ( 

682 "label", 

683 "schema", 

684 "node", 

685 "order", 

686 ) 

687 

688 

689class OptionLabelInline(admin.TabularInline): 

690 model = models.OptionLabel 

691 extra = 0 

692 

693 

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") 

699 

700 

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") 

717 

718 

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] 

725 

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 

732 

733 

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 

746 

747 

748# NOTE: SeparatedSubmission and Submission are imported at the top of the file 

749 

750 

751@admin.register(Submission.pgh_event_model) # type: ignore[attr-defined] 

752class SubmissionEventAdmin(pghistory.admin.EventModelAdmin): 

753 """ 

754 Admin for Submission events. 

755 """ 

756 

757 pass 

758 

759 

760@admin.register(SeparatedSubmission.pgh_event_model) # type: ignore[attr-defined] 

761class SeparatedSubmissionEventAdmin(pghistory.admin.EventModelAdmin): 

762 """ 

763 Admin for SeparatedSubmission events. 

764 """ 

765 

766 pass 

767 

768 

769class SeparatedSubmissionForm(forms.ModelForm): 

770 class Meta: 

771 model = SeparatedSubmission 

772 fields = "__all__" 

773 widgets = { 

774 "id": forms.HiddenInput(), 

775 } 

776 

777 

778class SeparatedSubmissionInline(admin.TabularInline): 

779 """Inline for showing separated submissions within a Submission.""" 

780 

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"] 

787 

788 

789@admin.register(Submission) 

790class SubmissionAdmin(admin.ModelAdmin): 

791 """Admin for Submission model.""" 

792 

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" 

801 

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 "" 

805 

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 

810 

811 

812@admin.register(SeparatedSubmission) 

813class SeparatedSubmissionAdmin(admin.ModelAdmin): 

814 """Admin for SeparatedSubmission model.""" 

815 

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" 

823 

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 "" 

827 

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 

832 

833 

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") 

843 

844 

845@admin.register(SeparatedSubmissionImport) 

846class SeparatedSubmissionImportAdmin(admin.ModelAdmin): 

847 """Admin for SeparatedSubmissionImport model.""" 

848 

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") 

856 

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 "-" 

866 

867 

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",)