Coverage for formkit_ninja/models.py: 26%

1 from __future__ import annotations

2

3 import logging

4 import uuid

5 import warnings

6 from keyword import iskeyword, issoftkeyword

7 from typing import Any, Iterable, TypedDict, get_args

8

9 import pghistory

10 import pgtrigger

11 from django.conf import settings

12 from django.contrib.contenttypes.models import ContentType

13 from django.contrib.postgres.aggregates import ArrayAgg

14 from django.core.exceptions import ValidationError

15 from django.db import models, transaction

16 from django.db.models import Q

17 from django.db.models.aggregates import Max

18 from django.db.models.functions import Greatest

19 from rich.console import Console

20

21 from formkit_ninja import formkit_schema, triggers

22

23 console = Console()

24 log = console.log

25

26 logger = logging.getLogger()

27

28

29 def 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

40 class 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

65 class OptionDict(TypedDict):

66 value: str

67 label: str

68

69

70 class 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

131 class 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

153 class 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

217 class 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

236 class 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

256 class 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

277 class 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

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

347 class 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

719 class 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

728 class 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

789 class 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

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