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

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 

36class ItemAdmin(admin.ModelAdmin): 

37 list_display = ("name",) 

38 

39 

40class JSONMappingMixin: 

41 """ 

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

43 """ 

44 

45 _json_fields: JsonFieldDefn = {} 

46 

47 def get_json_fields(self) -> JsonFieldDefn: 

48 return self._json_fields 

49 

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) 

58 

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 

79 

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 

86 

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 

96 

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) 

101 

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) 

113 

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) 

128 

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} 

131 

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 

140 

141 setattr(instance, field, new_data) 

142 

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 

157 

158 

159class FormKitBaseForm(JSONMappingMixin, forms.ModelForm): 

160 """ 

161 Base form for all FormKit-related nodes. 

162 """ 

163 

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 ) 

179 

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) 

188 

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

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

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

192 self._populate_form_fields(instance) 

193 

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 

200 

201 

202class NewFormKitForm(forms.ModelForm): 

203 class Meta: 

204 model = models.FormKitSchemaNode 

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

206 

207 

208class OptionForm(forms.ModelForm): 

209 class Meta: 

210 model = models.Option 

211 exclude = () 

212 

213 

214class FormComponentsForm(forms.ModelForm): 

215 class Meta: 

216 model = models.FormComponents 

217 exclude = () 

218 

219 

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 

229 

230 

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 ) 

248 

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) 

256 

257 

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 ) 

276 

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) 

318 

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] 

325 

326 

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 } 

339 

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) 

347 

348 

349class FormKitTextNode(FormKitBaseForm): 

350 class Meta(FormKitBaseForm.Meta): 

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

352 

353 

354class FormKitElementForm(FormKitBaseForm): 

355 class Meta(FormKitBaseForm.Meta): 

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

357 

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

359 

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) 

364 

365 

366class FormKitConditionForm(FormKitBaseForm): 

367 class Meta(FormKitBaseForm.Meta): 

368 pass 

369 

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) 

374 

375 

376class FormKitComponentForm(FormKitBaseForm): 

377 class Meta(FormKitBaseForm.Meta): 

378 pass 

379 

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

381 

382 

383class NodeChildrenInline(admin.TabularInline): 

384 """ 

385 Nested HTML elements 

386 """ 

387 

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 

394 

395 

396class NodeParentsInline(admin.TabularInline): 

397 """ 

398 Nested HTML elements 

399 """ 

400 

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 

407 

408 

409class NodeInline(admin.StackedInline): 

410 """ 

411 Nodes related to Option Groups 

412 """ 

413 

414 model = models.FormKitSchemaNode 

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

416 extra = 0 

417 

418 

419class SchemaLabelInline(admin.TabularInline): 

420 model = models.SchemaLabel 

421 extra = 0 

422 

423 

424class SchemaDescriptionInline(admin.TabularInline): 

425 model = models.SchemaDescription 

426 extra = 0 

427 

428 

429class FormKitSchemaForm(forms.ModelForm): 

430 class Meta: 

431 model = models.FormKitSchema 

432 exclude = ("name",) 

433 

434 

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} 

453 

454# Admin site registration continues below... 

455 

456 

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] 

478 

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

482 

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

486 

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 

496 

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) 

500 

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

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

503 

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

505 if not obj: 

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

507 

508 try: 

509 node = obj.get_node() 

510 except Exception: 

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

512 

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 

519 

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

536 

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 

559 

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 

564 

565 if not obj or not obj.pk: 

566 return "(Save node to see preview)" 

567 

568 try: 

569 from formkit_ninja.parser.type_convert import NodePath 

570 

571 # Ensure defaults are resolved for the preview 

572 obj.resolve_code_generation_defaults() 

573 

574 nodes = obj.get_node_path(recursive=True) 

575 

576 path = NodePath(*nodes) 

577 code = path.django_model_code 

578 

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

587 

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 

592 

593 if not obj or not obj.pk: 

594 return "(Save node to see preview)" 

595 

596 try: 

597 from formkit_ninja.parser.type_convert import NodePath 

598 

599 # Ensure defaults are resolved for the preview 

600 obj.resolve_code_generation_defaults() 

601 

602 nodes = obj.get_node_path(recursive=True) 

603 

604 path = NodePath(*nodes) 

605 code = path.pydantic_model_code 

606 

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

615 

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 

620 

621 from django.utils.html import format_html 

622 

623 if not obj or not obj.pk: 

624 return "(Save node to see preview)" 

625 

626 try: 

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

628 node = obj.get_node(recursive=True) 

629 

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 

636 

637 # Format as pretty JSON 

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

639 

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

653 

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) 

665 

666 

667@admin.register(models.FormKitSchema) 

668class FormKitSchemaAdmin(admin.ModelAdmin): 

669 form = FormKitSchemaForm 

670 

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 ) 

687 

688 inlines = [ 

689 SchemaLabelInline, 

690 SchemaDescriptionInline, 

691 FormKitSchemaComponentInline, 

692 ] 

693 

694 

695@admin.register(models.FormComponents) 

696class FormComponentsAdmin(admin.ModelAdmin): 

697 list_display = ( 

698 "label", 

699 "schema", 

700 "node", 

701 "order", 

702 ) 

703 

704 

705class OptionLabelInline(admin.TabularInline): 

706 model = models.OptionLabel 

707 extra = 0 

708 

709 

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

715 

716 

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

733 

734 

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] 

741 

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 

748 

749 

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 

762 

763 

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

765 

766 

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

768class SubmissionEventAdmin(pghistory.admin.EventModelAdmin): 

769 """ 

770 Admin for Submission events. 

771 """ 

772 

773 pass 

774 

775 

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

777class SeparatedSubmissionEventAdmin(pghistory.admin.EventModelAdmin): 

778 """ 

779 Admin for SeparatedSubmission events. 

780 """ 

781 

782 pass 

783 

784 

785class SeparatedSubmissionForm(forms.ModelForm): 

786 class Meta: 

787 model = SeparatedSubmission 

788 fields = "__all__" 

789 widgets = { 

790 "id": forms.HiddenInput(), 

791 } 

792 

793 

794class SeparatedSubmissionInline(admin.TabularInline): 

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

796 

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

803 

804 

805@admin.register(Submission) 

806class SubmissionAdmin(admin.ModelAdmin): 

807 """Admin for Submission model.""" 

808 

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" 

817 

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

821 

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 

826 

827 

828@admin.register(SeparatedSubmission) 

829class SeparatedSubmissionAdmin(admin.ModelAdmin): 

830 """Admin for SeparatedSubmission model.""" 

831 

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" 

839 

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

843 

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 

848 

849 

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

859 

860 

861@admin.register(SeparatedSubmissionImport) 

862class SeparatedSubmissionImportAdmin(admin.ModelAdmin): 

863 """Admin for SeparatedSubmissionImport model.""" 

864 

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

872 

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

882 

883 

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