Coverage for formkit_ninja / admin.py: 52.40%

439 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-02-20 04:40 +0000

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 SeparatedSubmission, 

21 SeparatedSubmissionImport, 

22 Submission, 

23 SubmissionFile, 

24) 

25 

26logger = logging.getLogger(__name__) 

27 

28 

29# Define fields in JSON with a tuple of fields 

30# The key of the dict provided is a JSON field on the model 

31JsonFieldDefn = dict[str, tuple[str | tuple[str, str], ...]] 

32 

33 

34class ItemAdmin(admin.ModelAdmin): 

35 list_display = ("name",) 

36 

37 

38class JSONMappingMixin: 

39 """ 

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

41 """ 

42 

43 _json_fields: JsonFieldDefn = {} 

44 

45 def get_json_fields(self) -> JsonFieldDefn: 

46 return self._json_fields 

47 

48 def _extract_field_value(self, values: dict, json_field: str): 

49 if "__" in json_field: 

50 nested_field_name, nested_key = json_field.split("__", 1) 

51 nested = values.get(nested_field_name) 

52 if isinstance(nested, dict): 

53 return nested.get(nested_key) 

54 return None 

55 return values.get(json_field) 

56 

57 def _populate_form_fields(self, instance): 

58 for field, keys in self.get_json_fields().items(): 

59 values = getattr(instance, field, {}) or {} 

60 for key in keys: 

61 form_field, json_field = key if isinstance(key, tuple) else (key, key) 

62 if f := self.fields.get(form_field): 

63 val = self._extract_field_value(values, json_field) 

64 if val is None: 

65 # Fallback: check if the json_field corresponds to a model attribute 

66 # using the same promotion logic as in models.py 

67 mapping = { 

68 "addLabel": "add_label", 

69 "upControl": "up_control", 

70 "downControl": "down_control", 

71 "sectionsSchema": "sections_schema", 

72 } 

73 attr_name = mapping.get(json_field, json_field) 

74 if hasattr(instance, attr_name): 

75 val = getattr(instance, attr_name) 

76 f.initial = val 

77 

78 def _build_json_data(self, keys: tuple, existing_data: dict) -> dict: 

79 data = existing_data.copy() if isinstance(existing_data, dict) else {} 

80 for key in keys: 

81 form_field, json_field = key if isinstance(key, tuple) else (key, key) 

82 if form_field not in self.cleaned_data: # type: ignore[attr-defined] 

83 continue 

84 

85 val = self.cleaned_data[form_field] # type: ignore[attr-defined] 

86 if "__" in json_field: 

87 nested_field_name, nested_key = json_field.split("__", 1) 

88 if not isinstance(data.get(nested_field_name), dict): 

89 data[nested_field_name] = {} 

90 data[nested_field_name][nested_key] = val 

91 else: 

92 data[json_field] = val 

93 return data 

94 

95 def save_json_fields(self, instance): 

96 for field, keys in self.get_json_fields().items(): 

97 existing = getattr(instance, field, {}) or {} 

98 new_data = self._build_json_data(keys, existing) 

99 

100 # Extract unrecognized fields from existing data and preserve in additional_props 

101 if field == "node" and isinstance(existing, dict): 

102 # Get all recognized fields (from form fields and their JSON mappings) 

103 recognized_fields = set() 

104 for key in keys: 

105 if isinstance(key, tuple): 

106 # (form_field, json_field) tuple 

107 recognized_fields.add(key[1]) 

108 else: 

109 # Just json_field 

110 recognized_fields.add(key) 

111 

112 # Also add special handled keys 

113 special_keys = { 

114 "$formkit", 

115 "$el", 

116 "if", 

117 "for", 

118 "then", 

119 "else", 

120 "children", 

121 "node_type", 

122 "formkit", 

123 "id", 

124 } 

125 recognized_fields.update(special_keys) 

126 

127 # Extract unrecognized fields 

128 unrecognized_fields = {k: v for k, v in existing.items() if k not in recognized_fields and v is not None} 

129 

130 # Store unrecognized fields in additional_props 

131 if unrecognized_fields: 

132 if instance.additional_props is None: 

133 instance.additional_props = {} 

134 # Merge with existing additional_props (don't overwrite if already set) 

135 for key, value in unrecognized_fields.items(): 

136 if key not in instance.additional_props: 

137 instance.additional_props[key] = value 

138 

139 setattr(instance, field, new_data) 

140 

141 def clean(self) -> dict[str, Any]: 

142 cleaned_data = super().clean() # type: ignore[misc] 

143 # Find any field mapped to "name" in JSON and validate it 

144 for field, keys in self.get_json_fields().items(): 

145 for key in keys: 

146 form_field, json_field = key if isinstance(key, tuple) else (key, key) 

147 if json_field == "name" and form_field in cleaned_data: 

148 val = cleaned_data[form_field] 

149 if val: 

150 try: 

151 models.check_valid_django_id(val) 

152 except django.core.exceptions.ValidationError as e: 

153 self.add_error(form_field, e) # type: ignore[attr-defined] 

154 return cleaned_data 

155 

156 

157class FormKitBaseForm(JSONMappingMixin, forms.ModelForm): 

158 """ 

159 Base form for all FormKit-related nodes. 

160 """ 

161 

162 class Meta: 

163 model = models.FormKitSchemaNode 

164 fields = ( 

165 "label", 

166 "description", 

167 "is_active", 

168 "protected", 

169 "django_field_type", 

170 "django_field_args", 

171 "django_field_positional_args", 

172 "pydantic_field_type", 

173 "extra_imports", 

174 "validators", 

175 ) 

176 

177 # Code Generation Overrides 

178 django_field_type = forms.CharField(required=False) 

179 django_field_args = forms.JSONField(required=False, widget=forms.Textarea(attrs={"rows": 4})) 

180 django_field_positional_args = forms.JSONField(required=False, widget=forms.Textarea(attrs={"rows": 4})) 

181 pydantic_field_type = forms.CharField(required=False) 

182 extra_imports = forms.JSONField(required=False, widget=forms.Textarea(attrs={"rows": 4})) 

183 validators = forms.JSONField(required=False, widget=forms.Textarea(attrs={"rows": 4})) 

184 

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

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

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

188 self._populate_form_fields(instance) 

189 

190 def save(self, commit: bool = True) -> models.FormKitSchemaNode: 

191 instance = super().save(commit=False) 

192 self.save_json_fields(instance) # type: ignore[arg-type] 

193 if commit: 

194 instance.save() 

195 return instance 

196 

197 

198class NewFormKitForm(forms.ModelForm): 

199 class Meta: 

200 model = models.FormKitSchemaNode 

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

202 

203 

204class OptionForm(forms.ModelForm): 

205 class Meta: 

206 model = models.Option 

207 exclude = () 

208 

209 

210class FormComponentsForm(forms.ModelForm): 

211 class Meta: 

212 model = models.FormComponents 

213 exclude = () 

214 

215 

216class FormKitSchemaComponentInline(admin.TabularInline): 

217 model = models.FormComponents 

218 readonly_fields = ( 

219 "node", 

220 "created_by", 

221 "updated_by", 

222 ) 

223 ordering = ("order",) 

224 extra = 0 

225 

226 

227class FormKitNodeGroupForm(FormKitBaseForm): 

228 class Meta: 

229 model = models.FormKitSchemaNode 

230 fields = ( 

231 "label", 

232 "description", 

233 "additional_props", 

234 "is_active", 

235 "protected", 

236 "django_field_type", 

237 "django_field_args", 

238 "django_field_positional_args", 

239 "pydantic_field_type", 

240 "extra_imports", 

241 "validators", 

242 ) 

243 

244 _json_fields = { 

245 "node": ("name", ("formkit", "$formkit"), "if_condition", ("html_id", "id")), 

246 } 

247 html_id = forms.CharField(required=False) 

248 name = forms.CharField(required=True) 

249 formkit = forms.ChoiceField(required=False, initial="group", choices=models.FormKitSchemaNode.FORMKIT_CHOICES, disabled=True) 

250 if_condition = forms.CharField(widget=forms.TextInput, required=False) 

251 

252 

253class FormKitNodeForm(FormKitBaseForm): 

254 class Meta: 

255 model = models.FormKitSchemaNode 

256 fields = ( 

257 "label", 

258 "description", 

259 "additional_props", 

260 "option_group", 

261 "is_active", 

262 "protected", 

263 "django_field_type", 

264 "django_field_args", 

265 "django_field_positional_args", 

266 "pydantic_field_type", 

267 "extra_imports", 

268 "validators", 

269 ) 

270 

271 _json_fields = { 

272 "node": ( 

273 ("formkit", "$formkit"), 

274 "name", 

275 "key", 

276 "if_condition", 

277 "options", 

278 ("node_label", "label"), 

279 "placeholder", 

280 "help", 

281 "validation", 

282 "validationLabel", 

283 "validationVisibility", 

284 "validationMessages", 

285 "prefixIcon", 

286 "min", 

287 "max", 

288 "step", 

289 ("html_id", "id"), 

290 ("onchange", "onChange"), 

291 ) 

292 } 

293 name = forms.CharField(required=True) 

294 formkit = forms.ChoiceField(required=False, choices=models.FormKitSchemaNode.FORMKIT_CHOICES) 

295 if_condition = forms.CharField(widget=forms.TextInput, required=False) 

296 key = forms.CharField(required=False) 

297 node_label = forms.CharField(required=False) 

298 placeholder = forms.CharField(required=False) 

299 help = forms.CharField(required=False) 

300 html_id = forms.CharField(required=False) 

301 onchange = forms.CharField(required=False) 

302 options = forms.CharField(required=False) 

303 validation = forms.CharField(required=False) 

304 validationLabel = forms.CharField(required=False) 

305 validationVisibility = forms.CharField(required=False) 

306 validationMessages = forms.JSONField(required=False) 

307 prefixIcon = forms.CharField(required=False) 

308 validationRules = forms.CharField(required=False, help_text="A function for validation passed into the schema: a key on `formSchemaData`") 

309 max = forms.IntegerField(required=False) 

310 min = forms.IntegerField(required=False) 

311 step = forms.IntegerField(required=False) 

312 

313 def get_fields(self, request, obj: models.FormKitSchemaNode): 

314 """ 

315 Customise the returned fields based on the type 

316 of formkit node 

317 """ 

318 return super().get_fields(request, obj) # type: ignore[misc] 

319 

320 

321class FormKitNodeRepeaterForm(FormKitNodeForm): 

322 def get_json_fields(self) -> JsonFieldDefn: 

323 return { 

324 "node": ( 

325 *(super()._json_fields["node"]), 

326 "addLabel", 

327 "upControl", 

328 "downControl", 

329 "itemsClass", 

330 "itemClass", 

331 ) 

332 } 

333 

334 addLabel = forms.CharField(required=False) 

335 upControl = forms.BooleanField(required=False) 

336 downControl = forms.BooleanField(required=False) 

337 itemsClass = forms.CharField(required=False) 

338 itemClass = forms.CharField(required=False) 

339 max = forms.IntegerField(required=False) 

340 min = forms.IntegerField(required=False) 

341 

342 

343class FormKitTextNode(FormKitBaseForm): 

344 class Meta(FormKitBaseForm.Meta): 

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

346 

347 

348class FormKitElementForm(FormKitBaseForm): 

349 class Meta(FormKitBaseForm.Meta): 

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

351 

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

353 

354 el = forms.ChoiceField(required=False, choices=models.FormKitSchemaNode.ELEMENT_TYPE_CHOICES) 

355 name = forms.CharField(required=False) 

356 attrs__class = forms.CharField(required=False) 

357 if_condition = forms.CharField(widget=forms.TextInput, required=False) 

358 

359 

360class FormKitConditionForm(FormKitBaseForm): 

361 class Meta(FormKitBaseForm.Meta): 

362 pass 

363 

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

365 if_condition = forms.CharField(widget=forms.TextInput, required=False) 

366 then_condition = forms.CharField(max_length=256, required=False) 

367 else_condition = forms.CharField(max_length=256, required=False) 

368 

369 

370class FormKitComponentForm(FormKitBaseForm): 

371 class Meta(FormKitBaseForm.Meta): 

372 pass 

373 

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

375 

376 

377class NodeChildrenInline(admin.TabularInline): 

378 """ 

379 Nested HTML elements 

380 """ 

381 

382 model = models.NodeChildren 

383 fields = ("child", "order", "track_change") 

384 ordering = ("order",) 

385 readonly_fields = ("track_change",) 

386 fk_name = "parent" 

387 extra = 0 

388 

389 

390class NodeParentsInline(admin.TabularInline): 

391 """ 

392 Nested HTML elements 

393 """ 

394 

395 model = models.NodeChildren 

396 fields = ("parent", "order", "track_change") 

397 ordering = ("order",) 

398 readonly_fields = ("track_change", "parent") 

399 fk_name = "child" 

400 extra = 0 

401 

402 

403class NodeInline(admin.StackedInline): 

404 """ 

405 Nodes related to Option Groups 

406 """ 

407 

408 model = models.FormKitSchemaNode 

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

410 extra = 0 

411 

412 

413class SchemaLabelInline(admin.TabularInline): 

414 model = models.SchemaLabel 

415 extra = 0 

416 

417 

418class SchemaDescriptionInline(admin.TabularInline): 

419 model = models.SchemaDescription 

420 extra = 0 

421 

422 

423class FormKitSchemaForm(forms.ModelForm): 

424 class Meta: 

425 model = models.FormKitSchema 

426 exclude = ("name",) 

427 

428 

429# Registry to map pydantic node types to form classes and fieldsets 

430NODE_CONFIG: dict[type | str, dict[str, Any]] = { 

431 str: {"form": FormKitTextNode}, 

432 formkit_schema.GroupNode: {"form": FormKitNodeGroupForm}, 

433 formkit_schema.RepeaterNode: { 

434 "form": FormKitNodeRepeaterForm, 

435 "fieldsets": [ 

436 ( 

437 "Repeater field properties", 

438 {"fields": ("addLabel", "upControl", "downControl", "itemsClass", "itemClass")}, 

439 ) 

440 ], 

441 }, 

442 formkit_schema.FormKitSchemaDOMNode: {"form": FormKitElementForm}, 

443 formkit_schema.FormKitSchemaComponent: {"form": FormKitComponentForm}, 

444 formkit_schema.FormKitSchemaCondition: {"form": FormKitConditionForm}, 

445 formkit_schema.FormKitSchemaProps: {"form": FormKitNodeForm}, 

446} 

447 

448# Admin site registration continues below... 

449 

450 

451@admin.register(models.FormKitSchemaNode) 

452class FormKitSchemaNodeAdmin(admin.ModelAdmin): 

453 list_display = ( 

454 "label", 

455 "is_active", 

456 "id", 

457 "node_type", 

458 "option_group", 

459 "formkit_or_el_type", 

460 "key_is_valid", 

461 "track_change", 

462 "protected", 

463 "created", 

464 ) 

465 readonly_fields = ("django_code_preview", "pydantic_code_preview", "formkit_node_preview", "created", "updated") 

466 search_fields = ["label", "description", "id"] 

467 list_filter = ("is_active", "node_type", "protected", "option_group", "created", "updated") 

468 list_select_related = ("option_group",) 

469 list_per_page = 50 

470 date_hierarchy = "created" 

471 inlines = [NodeChildrenInline, NodeParentsInline] 

472 

473 def get_readonly_fields(self, request, obj=None): 

474 ro = super().get_readonly_fields(request, obj) 

475 return list(ro) + ["django_code_preview", "pydantic_code_preview", "formkit_node_preview"] 

476 

477 @admin.display(boolean=True) 

478 def key_is_valid(self, obj) -> bool: 

479 if not (obj and obj.node and isinstance(obj.node, dict) and "name" in obj.node): 

480 return True 

481 try: 

482 models.check_valid_django_id(obj.node.get("name")) 

483 except (TypeError, django.core.exceptions.ValidationError): 

484 return False 

485 return True 

486 

487 def formkit_or_el_type(self, obj): 

488 if obj and obj.node and obj.node_type in ("$formkit", "$el"): 

489 return obj.node.get(obj.node_type) 

490 

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

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

493 

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

495 if not obj: 

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

497 

498 try: 

499 node = obj.get_node() 

500 except Exception: 

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

502 

503 fieldsets: list[tuple[str | None, dict[str, Any]]] = [] 

504 for pydantic_type, config in NODE_CONFIG.items(): 

505 if isinstance(pydantic_type, type) and isinstance(node, pydantic_type): 

506 if "fieldsets" in config: 

507 fieldsets.extend(config["fieldsets"]) 

508 break 

509 

510 grouped_fields: set[str] = reduce(operator.or_, (set(opts["fields"]) for _, opts in fieldsets), set()) 

511 # Also include code generation fields to avoid them being duplicated in the default fieldset 

512 code_gen_fields = { 

513 "django_field_type", 

514 "django_field_args", 

515 "django_field_positional_args", 

516 "pydantic_field_type", 

517 "extra_imports", 

518 "validators", 

519 "django_code_preview", 

520 "pydantic_code_preview", 

521 "formkit_node_preview", 

522 } 

523 grouped_fields.update(code_gen_fields) 

524 fieldsets.insert(0, (None, {"fields": [f for f in self.get_fields(request, obj) if f not in grouped_fields]})) 

525 

526 # Add Code Generation Source of Truth fieldset 

527 fieldsets.append( 

528 ( 

529 "Code Generation (Source of Truth)", 

530 { 

531 "fields": ( 

532 "django_field_type", 

533 "django_field_args", 

534 "django_field_positional_args", 

535 "pydantic_field_type", 

536 "extra_imports", 

537 "validators", 

538 "django_code_preview", 

539 "pydantic_code_preview", 

540 "formkit_node_preview", 

541 ), 

542 "description": ("These values are the source of truth for code generation. If empty, they are auto-resolved on save from global configs."), 

543 }, 

544 ) 

545 ) 

546 return fieldsets 

547 

548 @admin.display(description="Django Model Field Preview") 

549 def django_code_preview(self, obj): 

550 """Show what the Django model field code will look like.""" 

551 from django.utils.html import format_html 

552 

553 if not obj or not obj.pk: 

554 return "(Save node to see preview)" 

555 

556 try: 

557 from formkit_ninja.parser.type_convert import NodePath 

558 

559 # Ensure defaults are resolved for the preview 

560 obj.resolve_code_generation_defaults() 

561 

562 nodes = obj.get_node_path(recursive=True) 

563 

564 path = NodePath(*nodes) 

565 code = path.django_model_code 

566 

567 style = "background: #f8f9fa; padding: 10px; border-radius: 4px; border: 1px solid #dee2e6; color: #333; overflow: auto; max-height: 400px;" 

568 return format_html( 

569 '<pre style="{}">{}</pre>', 

570 style, 

571 code, 

572 ) 

573 except Exception as e: 

574 return format_html('<div style="color: red;">Error generating preview: {}</div>', str(e)) 

575 

576 @admin.display(description="Pydantic Schema Preview") 

577 def pydantic_code_preview(self, obj): 

578 """Show what the Pydantic schema code will look like.""" 

579 from django.utils.html import format_html 

580 

581 if not obj or not obj.pk: 

582 return "(Save node to see preview)" 

583 

584 try: 

585 from formkit_ninja.parser.type_convert import NodePath 

586 

587 # Ensure defaults are resolved for the preview 

588 obj.resolve_code_generation_defaults() 

589 

590 nodes = obj.get_node_path(recursive=True) 

591 

592 path = NodePath(*nodes) 

593 code = path.pydantic_model_code 

594 

595 style = "background: #f8f9fa; padding: 10px; border-radius: 4px; border: 1px solid #dee2e6; color: #333; overflow: auto; max-height: 400px;" 

596 return format_html( 

597 '<pre style="{}">{}</pre>', 

598 style, 

599 code, 

600 ) 

601 except Exception as e: 

602 return format_html('<div style="color: red;">Error generating preview: {}</div>', str(e)) 

603 

604 @admin.display(description="FormKit Node JSON Preview") 

605 def formkit_node_preview(self, obj): 

606 """Show the generated FormKit Node JSON.""" 

607 import json 

608 

609 from django.utils.html import format_html 

610 

611 if not obj or not obj.pk: 

612 return "(Save node to see preview)" 

613 

614 try: 

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

616 node = obj.get_node(recursive=True) 

617 

618 # If it's a Pydantic model, convert to dict 

619 if hasattr(node, "dict"): 

620 node_values = node.dict(exclude_none=True) 

621 else: 

622 # Could be a string (TextNode) or other primitive 

623 node_values = node 

624 

625 # Format as pretty JSON 

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

627 

628 style = ( 

629 "background: #f1f3f5; padding: 10px; border-radius: 4px; " 

630 "border: 1px solid #ced4da; color: #212529; overflow: auto; " 

631 "max-height: 400px; font-family: monospace; font-size: 11px; " 

632 "white-space: pre-wrap; word-break: break-all;" 

633 ) 

634 return format_html( 

635 '<pre style="{}">{}</pre>', 

636 style, 

637 code, 

638 ) 

639 except Exception as e: 

640 return format_html('<div style="color: red;">Error generating JSON preview: {}</div>', str(e)) 

641 

642 def get_form(self, request: HttpRequest, obj: Any | None = None, change: bool = False, **kwargs: Any) -> type[forms.ModelForm[Any]]: 

643 if not obj: 

644 return NewFormKitForm 

645 try: 

646 node = obj.get_node() 

647 for pydantic_type, config in NODE_CONFIG.items(): 

648 if isinstance(pydantic_type, type) and isinstance(node, pydantic_type): 

649 return config["form"] 

650 except Exception: 

651 pass 

652 return super().get_form(request, obj, **kwargs) 

653 

654 

655@admin.register(models.FormKitSchema) 

656class FormKitSchemaAdmin(admin.ModelAdmin): 

657 form = FormKitSchemaForm 

658 

659 def get_inlines(self, request, obj: models.FormKitSchema | None): 

660 """ 

661 For a "new object" do not show the Form Components 

662 """ 

663 return ( 

664 [ 

665 SchemaLabelInline, 

666 SchemaDescriptionInline, 

667 FormKitSchemaComponentInline, 

668 ] 

669 if obj 

670 else [ 

671 SchemaLabelInline, 

672 SchemaDescriptionInline, 

673 ] 

674 ) 

675 

676 inlines = [ 

677 SchemaLabelInline, 

678 SchemaDescriptionInline, 

679 FormKitSchemaComponentInline, 

680 ] 

681 

682 

683@admin.register(models.FormComponents) 

684class FormComponentsAdmin(admin.ModelAdmin): 

685 list_display = ( 

686 "label", 

687 "schema", 

688 "node", 

689 "order", 

690 ) 

691 

692 

693class OptionLabelInline(admin.TabularInline): 

694 model = models.OptionLabel 

695 extra = 0 

696 

697 

698class OptionInline(admin.TabularInline): 

699 model = models.Option 

700 extra = 0 

701 fields = ("group", "object_id", "value", "order") 

702 readonly_fields = ("group", "object_id", "value") 

703 

704 

705@admin.register(models.Option) 

706class OptionAdmin(admin.ModelAdmin): 

707 list_display = ( 

708 "object_id", 

709 "value", 

710 "order", 

711 "group", 

712 "last_updated", 

713 ) 

714 inlines = [OptionLabelInline] 

715 list_select_related = ("group",) 

716 list_filter = ("group", "last_updated") 

717 search_fields = ("value", "object_id", "group__group") 

718 list_per_page = 50 

719 date_hierarchy = "last_updated" 

720 readonly_fields = ("group", "object_id", "value", "created_by", "updated_by") 

721 

722 

723@admin.register(models.OptionGroup) 

724class OptionGroupAdmin(admin.ModelAdmin): 

725 list_display = ("group", "content_type", "option_count") 

726 search_fields = ("group",) 

727 list_filter = ("content_type",) 

728 inlines = [OptionInline, NodeInline] 

729 

730 @admin.display(description="Options Count") 

731 def option_count(self, obj): 

732 """Display the number of options in this group.""" 

733 if obj.pk: 

734 return obj.option_set.count() 

735 return 0 

736 

737 

738@admin.register(models.OptionLabel) 

739class OptionLabelAdmin(admin.ModelAdmin): 

740 list_display = ( 

741 "label", 

742 "lang", 

743 "option", 

744 ) 

745 readonly_fields = ("option",) 

746 search_fields = ("label", "option__value", "option__group__group") 

747 list_filter = ("lang", "option__group") 

748 list_select_related = ("option", "option__group") 

749 list_per_page = 50 

750 

751 

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

753 

754 

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

756class SubmissionEventAdmin(pghistory.admin.EventModelAdmin): 

757 """ 

758 Admin for Submission events. 

759 """ 

760 

761 pass 

762 

763 

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

765class SeparatedSubmissionEventAdmin(pghistory.admin.EventModelAdmin): 

766 """ 

767 Admin for SeparatedSubmission events. 

768 """ 

769 

770 pass 

771 

772 

773class SeparatedSubmissionForm(forms.ModelForm): 

774 class Meta: 

775 model = SeparatedSubmission 

776 fields = "__all__" 

777 widgets = { 

778 "id": forms.HiddenInput(), 

779 } 

780 

781 

782class SeparatedSubmissionInline(admin.TabularInline): 

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

784 

785 model = SeparatedSubmission 

786 form = SeparatedSubmissionForm 

787 extra = 0 

788 show_change_link = True 

789 can_delete = False 

790 readonly_fields = [f.name for f in SeparatedSubmission._meta.fields if f.name != "id"] 

791 

792 

793@admin.register(Submission) 

794class SubmissionAdmin(admin.ModelAdmin): 

795 """Admin for Submission model.""" 

796 

797 list_display = ("key", "user", "created", "status", "form_type", "is_verified", "is_active") 

798 list_filter = ("is_active", "user", "status", "form_type", "created") 

799 search_fields = ("key", "form_type", "user__username", "user__email") 

800 list_select_related = ("user",) 

801 list_per_page = 50 

802 readonly_fields = ("key", "created", "updated") 

803 inlines = [SeparatedSubmissionInline] 

804 date_hierarchy = "created" 

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 = ("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(boolean=True) 

825 def is_verified(self, obj: SeparatedSubmission) -> bool: 

826 """Returns whether the parent submission is verified.""" 

827 return obj.submission.status == Submission.Status.VERIFIED 

828 

829 

830@admin.register(SubmissionFile) 

831class SubmissionFileAdmin(admin.ModelAdmin): 

832 list_display = ["submission", "file", "user", "date_uploaded", "deleted"] 

833 list_filter = ("deleted", "date_uploaded", "user") 

834 search_fields = ("submission", "file", "user__username", "user__email", "comment") 

835 list_select_related = ("user",) 

836 list_per_page = 50 

837 date_hierarchy = "date_uploaded" 

838 readonly_fields = ("submission", "file", "user", "date_uploaded", "comment", "deleted") 

839 

840 

841@admin.register(SeparatedSubmissionImport) 

842class SeparatedSubmissionImportAdmin(admin.ModelAdmin): 

843 """Admin for SeparatedSubmissionImport model.""" 

844 

845 list_display = ("id", "submission", "created", "success", "message_preview") 

846 list_filter = ("success", "created") 

847 readonly_fields = ("submission", "created", "success", "message") 

848 list_select_related = ("submission", "submission__user") 

849 list_per_page = 50 

850 date_hierarchy = "created" 

851 search_fields = ("message", "submission__form_type", "submission__id") 

852 

853 @admin.display(description="Message") 

854 def message_preview(self, obj): 

855 """Show truncated message preview.""" 

856 if obj.message: 

857 max_length = 100 

858 if len(obj.message) > max_length: 

859 return f"{obj.message[:max_length]}..." 

860 return obj.message 

861 return "-"