Coverage for models.py: 26%

404 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-22 07:15 +0000

1from __future__ import annotations 

2 

3import logging 

4import uuid 

5import warnings 

6from keyword import iskeyword, issoftkeyword 

7from typing import Any, Iterable, TypedDict, get_args 

8 

9import pghistory 

10import pgtrigger 

11from django.conf import settings 

12from django.contrib.contenttypes.models import ContentType 

13from django.contrib.postgres.aggregates import ArrayAgg 

14from django.core.exceptions import ValidationError 

15from django.db import models, transaction 

16from django.db.models import Q 

17from django.db.models.aggregates import Max 

18from django.db.models.functions import Greatest 

19from rich.console import Console 

20 

21from formkit_ninja import formkit_schema, triggers 

22 

23console = Console() 

24log = console.log 

25 

26logger = logging.getLogger() 

27 

28 

29def check_valid_django_id(key: str): 

30 if not key: 

31 raise ValidationError("Name cannot be empty") 

32 if key[0].isdigit(): 

33 raise ValidationError(f"{key} is not valid, it cannot start with a digit") 

34 if not key.isidentifier() or iskeyword(key) or issoftkeyword(key): 

35 raise ValidationError(f"{key} cannot be used as a keyword. Should be a valid python identifier") 

36 if key[-1] == "_": 

37 raise ValidationError(f"{key} is not valid, it cannot end with an underscore") 

38 

39 

40class UuidIdModel(models.Model): 

41 """ 

42 Consistently use fields which will 

43 help with syncing data: 

44 - UUID field is the ID 

45 - Created field 

46 - Last Modified field 

47 - updated_by (optional) 

48 - created_by (optional) 

49 """ 

50 

51 class Meta: 

52 abstract = True 

53 

54 id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False, unique=True) 

55 created = models.DateTimeField(auto_now_add=True, blank=True, null=True) 

56 updated = models.DateTimeField(auto_now=True, blank=True, null=True) 

57 created_by = models.ForeignKey( 

58 settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="+", blank=True, null=True 

59 ) 

60 updated_by = models.ForeignKey( 

61 settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="+", blank=True, null=True 

62 ) 

63 

64 

65class OptionDict(TypedDict): 

66 value: str 

67 label: str 

68 

69 

70class OptionGroup(models.Model): 

71 """ 

72 This intended to be a "collection" of choices 

73 For instance all the values in a single PNDS zTable 

74 Also intended to allow users to add / modify their __own__ 'Options' 

75 for idb and formkit to recognize 

76 """ 

77 

78 group = models.CharField(max_length=1024, primary_key=True, help_text="The label to use for these options") 

79 content_type = models.ForeignKey( 

80 ContentType, 

81 on_delete=models.PROTECT, 

82 null=True, 

83 blank=True, 

84 help_text=( 

85 "This is an optional reference to the original source object " 

86 "for this set of options (typically a table from which we copy options)" 

87 ), 

88 ) 

89 

90 # If the object is a "Content Type" we expect it to have a similar layout to this 

91 

92 def save(self, *args, **kwargs): 

93 # Prior to save ensure that content_type, if present, fits suitable schema 

94 if self.content_type: 

95 klass = self.content_type.model_class() 

96 try: 

97 if klass._meta.get_field("value") is None or not hasattr(klass, "label_set"): 

98 raise ValueError(f"Expected {klass} to have a 'value' field and a 'label_set' attribute") 

99 except Exception as E: 

100 raise ValueError(f"Expected {klass} to have a 'value' field and a 'label_set' attribute") from E 

101 return super().save(*args, **kwargs) 

102 

103 def __str__(self): 

104 return f"{self.group}" 

105 

106 @classmethod 

107 def copy_table( 

108 cls, model: type[models.Model], field: str, language: str | None = "en", group_name: str | None = None 

109 ): 

110 """ 

111 Copy an existing table of options into this OptionGroup 

112 """ 

113 

114 with transaction.atomic(): 

115 group_obj, group_created = cls.objects.get_or_create( 

116 group=group_name, content_type=ContentType.objects.get_for_model(model) 

117 ) 

118 log(group_obj) 

119 

120 from typing import Any, cast 

121 

122 for obj in cast(Any, model).objects.values("pk", field): 

123 option, option_created = Option.objects.get_or_create( 

124 object_id=obj["pk"], 

125 group=group_obj, 

126 value=obj["pk"], 

127 ) 

128 OptionLabel.objects.get_or_create(option=option, label=obj[field] or "", lang=language) 

129 

130 

131class OptionQuerySet(models.Manager): 

132 """ 

133 Prefetched "labels" for performance 

134 """ 

135 

136 def get_queryset(self): 

137 """ 

138 Added a prefetch_related to the queryset 

139 """ 

140 lang_codes = (n[0] for n in settings.LANGUAGES) 

141 

142 label_model = OptionLabel 

143 annotated_fields = { 

144 f"label_{lang}": label_model.objects.filter(lang=lang, option=models.OuterRef("pk")) for lang in lang_codes 

145 } 

146 annotated_fields_subquery = { 

147 field: models.Subquery(query.values("label")[:1], output_field=models.CharField()) 

148 for field, query in annotated_fields.items() 

149 } 

150 return super().get_queryset().annotate(**annotated_fields_subquery) 

151 

152 

153class Option(UuidIdModel): 

154 """ 

155 This is a key/value field representing one "option" for a FormKit property 

156 The translated values for this option are in the `Translatable` table 

157 """ 

158 

159 object_id = models.IntegerField( 

160 null=True, 

161 blank=True, 

162 help_text=( 

163 "This is a reference to the primary key of the original source object " 

164 "(typically a PNDS ztable ID) or a user-specified ID for a new group" 

165 ), 

166 ) 

167 last_updated = models.DateTimeField(auto_now=True) 

168 group = models.ForeignKey(OptionGroup, on_delete=models.CASCADE, null=True, blank=True) 

169 # is_active = models.BooleanField(default=True) 

170 order = models.IntegerField(null=True, blank=True) 

171 

172 class Meta: 

173 triggers = triggers.update_or_insert_group_trigger("group_id") 

174 constraints = [models.UniqueConstraint(fields=["group", "object_id"], name="unique_option_id")] 

175 ordering = ( 

176 "group", 

177 "order", 

178 ) 

179 

180 value = models.CharField(max_length=1024) 

181 order_with_respect_to = "group" 

182 

183 objects = OptionQuerySet() 

184 

185 @classmethod 

186 def from_pydantic( 

187 cls, 

188 options: list[str] | list[OptionDict], 

189 group: OptionGroup | None = None, 

190 ) -> Iterable["Option"]: 

191 """ 

192 Yields "Options" in the database based on the input given 

193 """ 

194 for option in options: 

195 if isinstance(option, str): 

196 opt = cls(value=option, group=group) 

197 # Capture the effects of triggers 

198 # Else we override with the 'default' value of 0 

199 opt.save() 

200 opt.refresh_from_db() 

201 OptionLabel.objects.create(option=opt, lang="en", label=option) 

202 elif isinstance(option, dict) and option.keys() == {"value", "label"}: 

203 opt = cls(value=option["value"], group=group) 

204 OptionLabel.objects.create(option=opt, lang="en", label=option["label"]) 

205 else: 

206 console.log(f"[red]Could not format the given object {option}") 

207 continue 

208 yield opt 

209 

210 def __str__(self): 

211 if self.group: 

212 return f"{self.group.group}::{self.value}" 

213 else: 

214 return f"No group: {self.value}" 

215 

216 

217class OptionLabel(models.Model): 

218 option = models.ForeignKey("Option", on_delete=models.CASCADE) 

219 label = models.CharField(max_length=1024) 

220 lang = models.CharField( 

221 max_length=4, default="en", choices=(("en", "English"), ("tet", "Tetum"), ("pt", "Portugese")) 

222 ) 

223 

224 def save(self, *args, **kwargs): 

225 """ 

226 When saved, save also my "option" so that its last_updated is set 

227 """ 

228 if self.option is not None: 

229 self.option.save() 

230 return super().save(*args, **kwargs) 

231 

232 class Meta: 

233 constraints = [models.UniqueConstraint(fields=["option", "lang"], name="unique_option_label")] 

234 

235 

236class FormComponents(UuidIdModel): 

237 """ 

238 A model relating "nodes" of a schema to a schema with model ordering 

239 """ 

240 

241 schema = models.ForeignKey("FormKitSchema", on_delete=models.CASCADE) 

242 # This is null=True so that a new FormComponent can be added from the admin inline 

243 node = models.ForeignKey("FormKitSchemaNode", on_delete=models.CASCADE, null=True, blank=True) 

244 label = models.CharField(max_length=1024, help_text="Used as a human-readable label", null=True, blank=True) 

245 order = models.IntegerField(null=True, blank=True) 

246 order_with_respect_to = "schema" 

247 

248 class Meta: 

249 triggers = triggers.update_or_insert_group_trigger("schema_id") 

250 ordering = ("schema", "order") 

251 

252 def __str__(self): 

253 return f"{self.node}[{self.order}]: {self.schema}" 

254 

255 

256class NodeChildrenManager(models.Manager): 

257 """ 

258 Adds aggregation and filtering for client side data 

259 of NodeChildren relations 

260 """ 

261 

262 def aggregate_changes_table(self, latest_change: int | None = None): 

263 values = ( 

264 self.get_queryset() 

265 .values("parent_id") 

266 .annotate( 

267 children=ArrayAgg("child", ordering="order"), 

268 ) 

269 .annotate(Max("child__track_change")) 

270 .annotate(latest_change=Greatest("child__track_change__max", "parent__track_change")) 

271 ) 

272 if latest_change: 

273 values = values.filter(Q(latest_change__gt=latest_change) | Q(parent__latest_change__gt=latest_change)) 

274 return values.values_list("parent_id", "latest_change", "children", named=True) 

275 

276 

277class NodeChildren(models.Model): 

278 """ 

279 This is an ordered m2m model representing 

280 the "children" of an HTML element 

281 """ 

282 

283 parent = models.ForeignKey( 

284 "FormKitSchemaNode", 

285 on_delete=models.CASCADE, 

286 related_name="parent", 

287 ) 

288 child = models.ForeignKey("FormKitSchemaNode", on_delete=models.CASCADE) 

289 order = models.IntegerField(null=True, blank=True) 

290 track_change = models.BigIntegerField(null=True, blank=True) 

291 order_with_respect_to = "parent" 

292 

293 class Meta: 

294 triggers = [ 

295 *triggers.update_or_insert_group_trigger("parent_id"), 

296 triggers.bump_sequence_value(sequence_name=triggers.NODE_CHILDREN_CHANGE_ID), 

297 ] 

298 ordering = ( 

299 "parent_id", 

300 "order", 

301 ) 

302 

303 objects = NodeChildrenManager() 

304 

305 

306class NodeQS(models.QuerySet): 

307 def from_change(self, track_change: int = -1): 

308 return self.filter(track_change__gt=track_change) 

309 

310 def to_response( 

311 self, ignore_errors: bool = True, options: bool = True 

312 ) -> Iterable[tuple[uuid.UUID, int | None, formkit_schema.Node | str | None, bool]]: 

313 """ 

314 Return a set of FormKit nodes 

315 """ 

316 node: FormKitSchemaNode 

317 for node in self.all(): 

318 try: 

319 if node.is_active: 

320 yield node.id, node.track_change, node.get_node(recursive=False, options=options), node.protected 

321 else: 

322 yield node.id, node.track_change, None, node.protected 

323 except Exception as E: 

324 if not ignore_errors: 

325 raise 

326 warnings.warn(f"An unparseable FormKit node was hit at {node.pk}") 

327 warnings.warn(f"{E}") 

328 

329 

330@pghistory.track() 

331@pgtrigger.register( 

332 pgtrigger.Protect( 

333 # If the node is protected, delete is not allowed 

334 name="protect_node_deletes_and_updates", 

335 operation=pgtrigger.Delete, 

336 condition=pgtrigger.Q(old__protected=True), 

337 ), 

338 pgtrigger.Protect( 

339 # If both new and old values are "protected", updates are not allowed 

340 name="protect_node_updates", 

341 operation=pgtrigger.Update, 

342 condition=pgtrigger.Q(old__protected=True) & pgtrigger.Q(new__protected=True), 

343 ), 

344 pgtrigger.SoftDelete(name="soft_delete", field="is_active"), 

345 triggers.bump_sequence_value("track_change", triggers.NODE_CHANGE_ID), 

346) 

347class FormKitSchemaNode(UuidIdModel): 

348 """ 

349 This represents a single "Node" in a FormKit schema. 

350 There are several different types of node which may be defined: 

351 FormKitSchemaDOMNode 

352 | FormKitSchemaComponent 

353 | FormKitSchemaTextNode 

354 | FormKitSchemaCondition 

355 | FormKitSchemaFormKit 

356 """ 

357 

358 objects = NodeQS.as_manager() 

359 

360 NODE_TYPE_CHOICES = ( 

361 ("$cmp", "Component"), # Not yet implemented 

362 ("text", "Text"), 

363 ("condition", "Condition"), # Not yet implemented 

364 ("$formkit", "FormKit"), 

365 ("$el", "Element"), 

366 ("raw", "Raw JSON"), # Not yet implemented 

367 ) 

368 FORMKIT_CHOICES = [(t, t) for t in get_args(formkit_schema.FORMKIT_TYPE)] 

369 

370 ELEMENT_TYPE_CHOICES = [("p", "p"), ("h1", "h1"), ("h2", "h2"), ("span", "span")] 

371 node_type = models.CharField(max_length=256, choices=NODE_TYPE_CHOICES, blank=True, help_text="") 

372 description = models.CharField( 

373 max_length=4000, 

374 null=True, 

375 blank=True, 

376 help_text="Decribe the type of data / reason for this component", 

377 ) 

378 label = models.CharField(max_length=1024, help_text="Used as a human-readable label", null=True, blank=True) 

379 option_group = models.ForeignKey(OptionGroup, null=True, blank=True, on_delete=models.PROTECT) 

380 children = models.ManyToManyField("self", through=NodeChildren, symmetrical=False, blank=True) 

381 is_active = models.BooleanField(default=True) 

382 protected = models.BooleanField(default=False) 

383 

384 node = models.JSONField( 

385 null=True, 

386 blank=True, 

387 help_text="A JSON representation of select parts of the FormKit schema", 

388 ) 

389 

390 additional_props = models.JSONField( 

391 null=True, 

392 blank=True, 

393 help_text="User space for additional, less used props", 

394 ) 

395 icon = models.CharField(max_length=256, null=True, blank=True) 

396 title = models.CharField(max_length=1024, null=True, blank=True) 

397 readonly = models.BooleanField(default=False) 

398 sections_schema = models.JSONField(null=True, blank=True, help_text="Schema for the sections") 

399 min = models.CharField(max_length=256, null=True, blank=True) 

400 max = models.CharField(max_length=256, null=True, blank=True) 

401 step = models.CharField(max_length=256, null=True, blank=True) 

402 add_label = models.CharField(max_length=1024, null=True, blank=True) 

403 up_control = models.BooleanField(default=True) 

404 down_control = models.BooleanField(default=True) 

405 

406 text_content = models.TextField( 

407 null=True, blank=True, help_text="Content for a text element, for children of an $el type component" 

408 ) 

409 track_change = models.BigIntegerField(null=True, blank=True) 

410 

411 def __str__(self): 

412 return f"Node: {self.label}" if self.label else f"{self.node_type} {self.id}" 

413 

414 def save(self, *args, **kwargs): 

415 """ 

416 On save validate the 'node' field matches the 'FormKitNode' 

417 """ 

418 # rename `formkit` to `$formkit` 

419 if isinstance(self.node, dict) and "formkit" in self.node: 

420 self.node.update({"$formkit": self.node.pop("formkit")}) 

421 # We're also going to verify that the 'key' is a valid identifier 

422 # Keep in mind that the `key` may be used as part of a model so 

423 # should be valid Django fieldname too 

424 if isinstance(self.node, dict) and self.node_type in ("$formkit", "$el"): 

425 if key := self.node.get("name"): 

426 check_valid_django_id(key) 

427 

428 # Auto-promote common props from both 'additional_props' and 'node' 

429 for source in (self.additional_props, self.node): 

430 if not isinstance(source, dict): 

431 continue 

432 for field in ( 

433 "icon", 

434 "title", 

435 "readonly", 

436 "sectionsSchema", 

437 "min", 

438 "max", 

439 "step", 

440 "addLabel", 

441 "upControl", 

442 "downControl", 

443 ): 

444 if field in source: 

445 if field == "sectionsSchema": 

446 target_field = "sections_schema" 

447 elif field == "addLabel": 

448 target_field = "add_label" 

449 elif field == "upControl": 

450 target_field = "up_control" 

451 elif field == "downControl": 

452 target_field = "down_control" 

453 else: 

454 target_field = field 

455 

456 val = source.get(field) 

457 if field in ("min", "max", "step") and val is not None: 

458 val = str(val) 

459 setattr(self, target_field, val) 

460 

461 return super().save(*args, **kwargs) 

462 

463 @property 

464 def node_options(self) -> str | list[dict] | None: 

465 """ 

466 Because "options" are translated and 

467 separately stored, this step is necessary to 

468 reinstate them 

469 """ 

470 if self.node and (opts := self.node.get("options")): 

471 return opts 

472 

473 if not self.option_group: 

474 return None 

475 options = self.option_group.option_set.all().prefetch_related("optionlabel_set") 

476 # options: Iterable[Option] = self.option_set.all().prefetch_related("optionlabel_set") 

477 # TODO: This is horribly slow 

478 return [ 

479 { 

480 "value": option.value, 

481 "label": f"{label_obj.label if (label_obj := option.optionlabel_set.first()) else ''}", 

482 } 

483 for option in options 

484 ] 

485 

486 def get_node_values(self, recursive: bool = True, options: bool = True) -> str | dict: 

487 """ 

488 Reify a 'dict' instance suitable for creating 

489 a FormKit Schema node from 

490 """ 

491 # Text element 

492 if not self.node: 

493 if self.text_content: 

494 return self.text_content 

495 return {} 

496 values = {**self.node} 

497 

498 # Options may come from a string in the node, or 

499 # may come from an m2m 

500 if options and self.node_options: 

501 values["options"] = self.node_options 

502 if recursive: 

503 children = [c.get_node_values() for c in self.children.order_by("nodechildren__order")] 

504 if children: 

505 values["children"] = children 

506 if self.icon: 

507 values["icon"] = self.icon 

508 if self.title: 

509 values["title"] = self.title 

510 if self.readonly: 

511 values["readonly"] = self.readonly 

512 if self.sections_schema: 

513 values["sectionsSchema"] = self.sections_schema 

514 if self.min: 

515 try: 

516 values["min"] = int(self.min) 

517 except ValueError: 

518 values["min"] = self.min 

519 if self.max: 

520 try: 

521 values["max"] = int(self.max) 

522 except ValueError: 

523 values["max"] = self.max 

524 if self.step: 

525 try: 

526 val = float(self.step) 

527 if val.is_integer(): 

528 values["step"] = int(val) 

529 else: 

530 values["step"] = str(val) # Keep as string if float to avoid precision issues 

531 values["step"] = self.step 

532 except ValueError: 

533 values["step"] = self.step 

534 if self.add_label: 

535 values["addLabel"] = self.add_label 

536 if not self.up_control: # Only write if false? Or always? Defaults are True. 

537 values["upControl"] = self.up_control 

538 if not self.down_control: 

539 values["downControl"] = self.down_control 

540 

541 if self.additional_props and len(self.additional_props) > 0: 

542 values.update(self.additional_props) 

543 

544 if values == {}: 

545 if self.node_type == "$el": 

546 values.update({"$el": "span"}) 

547 elif self.node_type == "$formkit": 

548 values.update({"$formkit": "text"}) 

549 

550 return values 

551 

552 def get_node(self, recursive=False, options=False, **kwargs) -> formkit_schema.Node | str: 

553 """ 

554 Return a "decorated" node instance 

555 with restored options and translated fields 

556 """ 

557 if self.text_content or self.node_type == "text": 

558 return self.text_content or "" 

559 if self.node == {} or self.node is None: 

560 if self.node_type == "$el": 

561 node_content_dict: dict[str, Any] = {"$el": "span"} 

562 elif self.node_type == "$formkit": 

563 node_content_dict = {"$formkit": "text"} 

564 else: 

565 node_content_dict = {} 

566 else: 

567 node_content_dict = self.get_node_values(**kwargs, recursive=recursive, options=options) # type: ignore[assignment] 

568 

569 formkit_node = formkit_schema.FormKitNode.parse_obj(node_content_dict, recursive=recursive) 

570 return formkit_node.__root__ 

571 

572 @classmethod 

573 def from_pydantic( # noqa: C901 

574 cls, input_models: formkit_schema.FormKitSchemaProps | Iterable[formkit_schema.FormKitSchemaProps] 

575 ) -> Iterable["FormKitSchemaNode"]: 

576 if isinstance(input_models, str): 

577 yield cls.objects.create(node_type="text", label=input_models, text_content=input_models) 

578 

579 elif isinstance(input_models, Iterable) and not isinstance(input_models, formkit_schema.FormKitSchemaProps): 

580 for n in input_models: 

581 yield from cls.from_pydantic(n) 

582 

583 elif isinstance(input_models, formkit_schema.FormKitSchemaProps): 

584 input_model = input_models 

585 instance = cls() 

586 log(f"[green]Creating {instance}") 

587 for label_field in ("name", "id", "key", "label"): 

588 if label := getattr(input_model, label_field, None): 

589 instance.label = label 

590 break 

591 

592 # Node types 

593 if props := getattr(input_model, "additional_props", None): 

594 instance.additional_props = props 

595 

596 if (icon := getattr(input_model, "icon", None)) is not None: 

597 instance.icon = icon 

598 if (title := getattr(input_model, "title", None)) is not None: 

599 instance.title = title 

600 if (readonly := getattr(input_model, "readonly", None)) is not None: 

601 instance.readonly = readonly 

602 if (sections_schema := getattr(input_model, "sectionsSchema", None)) is not None: 

603 instance.sections_schema = sections_schema 

604 if (min_val := getattr(input_model, "min", None)) is not None: 

605 instance.min = str(min_val) 

606 if (max_val := getattr(input_model, "max", None)) is not None: 

607 instance.max = str(max_val) 

608 if (step := getattr(input_model, "step", None)) is not None: 

609 instance.step = str(step) 

610 if (add_label := getattr(input_model, "addLabel", None)) is not None: 

611 instance.add_label = add_label 

612 if (up_control := getattr(input_model, "upControl", None)) is not None: 

613 instance.up_control = up_control 

614 if (down_control := getattr(input_model, "downControl", None)) is not None: 

615 instance.down_control = down_control 

616 

617 # Fields that are valid Pydantic fields but not promoted to columns must be saved in additional_props 

618 # otherwise they are lost. 

619 extra_fields = [ 

620 "max", 

621 "rows", 

622 "cols", 

623 "prefixIcon", 

624 "classes", 

625 "value", 

626 "suffixIcon", 

627 "validationRules", 

628 "maxLength", 

629 "itemClass", 

630 "itemsClass", 

631 "_minDateSource", 

632 "_maxDateSource", 

633 "disabledDays", 

634 ] 

635 # Ensure additional_props is a dict 

636 if instance.additional_props is None: 

637 instance.additional_props = {} 

638 elif not isinstance(instance.additional_props, dict): 

639 # Should not happen but safety first 

640 instance.additional_props = {} 

641 

642 for field in extra_fields: 

643 if (val := getattr(input_model, field, None)) is not None: 

644 # 'max' is now a model field, so don't put it in additional_props 

645 if field == "max": 

646 continue 

647 instance.additional_props[field] = val 

648 

649 try: 

650 node_type = getattr(input_model, "node_type") 

651 except Exception as E: 

652 raise E 

653 if node_type == "condition": 

654 instance.node_type = "condition" 

655 elif node_type == "formkit": 

656 instance.node_type = "$formkit" 

657 elif node_type == "element": 

658 instance.node_type = "$el" 

659 elif node_type == "component": 

660 instance.node_type = "$cmp" 

661 

662 log(f"[green]Yielding: {instance}") 

663 

664 # Must save the instance before adding "options" or "children" 

665 instance.node = input_model.dict( 

666 exclude={ 

667 "options", 

668 "children", 

669 "additional_props", 

670 "node_type", 

671 }, 

672 exclude_none=True, 

673 exclude_unset=True, 

674 ) 

675 # Where an alias is used ("el", ) restore it to the expected value 

676 # of a FormKit schema node 

677 for pydantic_key, db_key in (("el", "$el"), ("formkit", "$formkit")): 

678 if db_value := instance.node.pop(pydantic_key, None): 

679 instance.node[db_key] = db_value 

680 

681 instance.save() 

682 # Add the "options" if it is a 'text' type getter 

683 options: formkit_schema.OptionsType = getattr(input_model, "options", None) 

684 

685 if isinstance(options, str): 

686 # Maintain this as it is probably a `$get...` options call 

687 # to a Javascript function 

688 instance.node["options"] = options 

689 instance.save() 

690 

691 elif isinstance(options, Iterable): 

692 # Create a new "group" to assign these options to 

693 # Here we use a random UUID as the group name 

694 instance.option_group = OptionGroup.objects.create( 

695 group=f"Auto generated group for {str(instance)} {uuid.uuid4().hex[0:8]}" 

696 ) 

697 for option in Option.from_pydantic(options, group=instance.option_group): # type: ignore[arg-type] 

698 pass 

699 instance.save() 

700 

701 for c_n in getattr(input_model, "children", []) or []: 

702 child_node = next(iter(cls.from_pydantic(c_n))) 

703 console.log(f" {child_node}") 

704 instance.children.add(child_node) 

705 

706 yield instance 

707 

708 else: 

709 raise TypeError(f"Expected FormKitNode or Iterable[FormKitNode], got {type(input_models)}") 

710 

711 def to_pydantic(self, recursive=False, options=False, **kwargs): 

712 if self.text_content: 

713 return self.text_content 

714 return formkit_schema.FormKitNode.parse_obj( 

715 self.get_node_values(recursive=recursive, options=options, **kwargs) 

716 ) 

717 

718 

719class SchemaManager(models.Manager): 

720 """ 

721 Provides prefetching which we'll almost always want to have 

722 """ 

723 

724 def get_queryset(self): 

725 return super().get_queryset().prefetch_related("nodes", "nodes__children") 

726 

727 

728class FormKitSchema(UuidIdModel): 

729 """ 

730 This represents a "FormKitSchema" which is an heterogenous 

731 collection of items. 

732 """ 

733 

734 label = models.CharField( 

735 max_length=1024, 

736 null=True, 

737 blank=True, 

738 help_text="Used as a human-readable label", 

739 unique=True, 

740 default=uuid.uuid4, 

741 ) 

742 nodes = models.ManyToManyField(FormKitSchemaNode, through=FormComponents) 

743 objects = SchemaManager() 

744 

745 def get_schema_values(self, recursive=False, options=False, **kwargs): 

746 """ 

747 Return a list of "node" dicts 

748 """ 

749 nodes: Iterable[FormKitSchemaNode] = self.nodes.order_by("formcomponents__order") 

750 for node in nodes: 

751 yield node.get_node_values(recursive=recursive, options=options, **kwargs) 

752 

753 def to_pydantic(self): 

754 values = list(self.get_schema_values()) 

755 return formkit_schema.FormKitSchema.parse_obj(values) 

756 

757 def __str__(self): 

758 return f"{self.label}" or f"{str(self.id)[:8]}" 

759 

760 def save(self, *args, **kwargs): 

761 super().save(*args, **kwargs) 

762 

763 @classmethod 

764 def from_pydantic(cls, input_model: formkit_schema.FormKitSchema, label: str | None = None) -> "FormKitSchema": 

765 """ 

766 Converts a given Pydantic representation of a Schema 

767 to Django database fields 

768 """ 

769 instance = cls.objects.create(label=label) 

770 node: FormKitSchemaNode 

771 nodes: Iterable[FormKitSchemaNode] = FormKitSchemaNode.from_pydantic(input_model.__root__) # type: ignore 

772 for node in nodes: 

773 log(f"[yellow]Saving {node}") 

774 node.save() 

775 FormComponents.objects.create(schema=instance, node=node, label=str(f"{str(instance)} {str(node)}")) 

776 logger.info("Schema load from JSON done") 

777 return instance 

778 

779 @classmethod 

780 def from_json(cls, input_file: dict): 

781 """ 

782 Converts a given JSON string to a suitable 

783 Django representation 

784 """ 

785 schema_instance = formkit_schema.FormKitSchema.parse_obj(input_file) 

786 return cls.from_pydantic(schema_instance) 

787 

788 

789class SchemaLabel(models.Model): 

790 """ 

791 This intended to hold translations of Partisipa schema definitions. 

792 The title. 

793 """ 

794 

795 schema = models.ForeignKey("FormKitSchema", on_delete=models.CASCADE) 

796 label = models.CharField(max_length=1024) 

797 lang = models.CharField( 

798 max_length=4, default="en", choices=(("en", "English"), ("tet", "Tetum"), ("pt", "Portugese")) 

799 ) 

800 

801 

802class SchemaDescription(models.Model): 

803 """ 

804 This intended to hold translations of Partisipa schema definitions. 

805 The description. 

806 """ 

807 

808 schema = models.ForeignKey("FormKitSchema", on_delete=models.CASCADE) 

809 label = models.CharField(max_length=1024) 

810 lang = models.CharField( 

811 max_length=4, default="en", choices=(("en", "English"), ("tet", "Tetum"), ("pt", "Portugese")) 

812 )