Coverage for models.py: 26%
404 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-22 07:15 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-22 07:15 +0000
1from __future__ import annotations
3import logging
4import uuid
5import warnings
6from keyword import iskeyword, issoftkeyword
7from typing import Any, Iterable, TypedDict, get_args
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
21from formkit_ninja import formkit_schema, triggers
23console = Console()
24log = console.log
26logger = logging.getLogger()
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")
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 """
51 class Meta:
52 abstract = True
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 )
65class OptionDict(TypedDict):
66 value: str
67 label: str
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 """
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 )
90 # If the object is a "Content Type" we expect it to have a similar layout to this
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)
103 def __str__(self):
104 return f"{self.group}"
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 """
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)
120 from typing import Any, cast
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)
131class OptionQuerySet(models.Manager):
132 """
133 Prefetched "labels" for performance
134 """
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)
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)
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 """
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)
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 )
180 value = models.CharField(max_length=1024)
181 order_with_respect_to = "group"
183 objects = OptionQuerySet()
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
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}"
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 )
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)
232 class Meta:
233 constraints = [models.UniqueConstraint(fields=["option", "lang"], name="unique_option_label")]
236class FormComponents(UuidIdModel):
237 """
238 A model relating "nodes" of a schema to a schema with model ordering
239 """
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"
248 class Meta:
249 triggers = triggers.update_or_insert_group_trigger("schema_id")
250 ordering = ("schema", "order")
252 def __str__(self):
253 return f"{self.node}[{self.order}]: {self.schema}"
256class NodeChildrenManager(models.Manager):
257 """
258 Adds aggregation and filtering for client side data
259 of NodeChildren relations
260 """
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)
277class NodeChildren(models.Model):
278 """
279 This is an ordered m2m model representing
280 the "children" of an HTML element
281 """
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"
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 )
303 objects = NodeChildrenManager()
306class NodeQS(models.QuerySet):
307 def from_change(self, track_change: int = -1):
308 return self.filter(track_change__gt=track_change)
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}")
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 """
358 objects = NodeQS.as_manager()
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)]
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)
384 node = models.JSONField(
385 null=True,
386 blank=True,
387 help_text="A JSON representation of select parts of the FormKit schema",
388 )
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)
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)
411 def __str__(self):
412 return f"Node: {self.label}" if self.label else f"{self.node_type} {self.id}"
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)
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
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)
461 return super().save(*args, **kwargs)
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
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 ]
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}
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
541 if self.additional_props and len(self.additional_props) > 0:
542 values.update(self.additional_props)
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"})
550 return values
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]
569 formkit_node = formkit_schema.FormKitNode.parse_obj(node_content_dict, recursive=recursive)
570 return formkit_node.__root__
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)
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)
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
592 # Node types
593 if props := getattr(input_model, "additional_props", None):
594 instance.additional_props = props
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
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 = {}
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
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"
662 log(f"[green]Yielding: {instance}")
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
681 instance.save()
682 # Add the "options" if it is a 'text' type getter
683 options: formkit_schema.OptionsType = getattr(input_model, "options", None)
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()
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()
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)
706 yield instance
708 else:
709 raise TypeError(f"Expected FormKitNode or Iterable[FormKitNode], got {type(input_models)}")
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 )
719class SchemaManager(models.Manager):
720 """
721 Provides prefetching which we'll almost always want to have
722 """
724 def get_queryset(self):
725 return super().get_queryset().prefetch_related("nodes", "nodes__children")
728class FormKitSchema(UuidIdModel):
729 """
730 This represents a "FormKitSchema" which is an heterogenous
731 collection of items.
732 """
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()
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)
753 def to_pydantic(self):
754 values = list(self.get_schema_values())
755 return formkit_schema.FormKitSchema.parse_obj(values)
757 def __str__(self):
758 return f"{self.label}" or f"{str(self.id)[:8]}"
760 def save(self, *args, **kwargs):
761 super().save(*args, **kwargs)
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
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)
789class SchemaLabel(models.Model):
790 """
791 This intended to hold translations of Partisipa schema definitions.
792 The title.
793 """
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 )
802class SchemaDescription(models.Model):
803 """
804 This intended to hold translations of Partisipa schema definitions.
805 The description.
806 """
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 )