Coverage for formkit_ninja / parser / type_convert.py: 14.03%
583 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-02-20 04:40 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-02-20 04:40 +0000
1from __future__ import annotations
3import ast
4import warnings
5from keyword import iskeyword
6from typing import Generator, Iterable, Literal, cast
8from formkit_ninja import formkit_schema
9from formkit_ninja.formkit_schema import FormKitSchemaDOMNode, GroupNode, RepeaterNode
10from formkit_ninja.parser.converters import TypeConverterRegistry, default_registry
11from formkit_ninja.parser.node_factory import FormKitNodeFactory
13FormKitType = formkit_schema.FormKitType
16def make_valid_identifier(input_string: str):
17 """
18 Replace invalid characters with underscores
19 Remove trailing / leading digits
20 Remove trailing/leading underscores
21 Lowercase
22 """
23 try:
24 output = "".join(ch if ch.isalnum() else "_" for ch in input_string)
26 while output and output[0].isdigit():
27 output = output[1:]
29 while output[-1] == "_":
30 output = output[:-1]
32 while output[0] == "_":
33 output = output[1:]
34 except IndexError:
35 raise TypeError(f"The name {input_string} couldn't be used as an identifier")
37 return output.lower()
40class NodePath:
41 """
42 Mostly a wrapper around "tuple" to provide useful conventions
43 for naming
44 """
46 def __init__(
47 self,
48 *nodes: FormKitType,
49 type_converter_registry: TypeConverterRegistry | None = None,
50 config=None,
51 abstract_base_info: dict | None = None,
52 child_abstract_bases: list[str] | None = None,
53 ):
54 self.nodes = nodes
55 self._type_converter_registry = type_converter_registry or default_registry
56 self._config = config
57 self._abstract_base_info: dict[str, bool] = abstract_base_info or {}
58 self._child_abstract_bases: list[str] = child_abstract_bases or []
60 @classmethod
61 def from_obj(cls, obj: dict):
62 node = FormKitNodeFactory.from_dict(obj)
63 return cls(cast(FormKitType, node))
65 def __truediv__(self, node: Literal[".."] | FormKitType):
66 """
67 This overrides the builtin '/' operator, like "Path", to allow appending nodes
68 """
69 if node == "..":
70 return self.__class__(
71 *self.nodes[:-1],
72 type_converter_registry=self._type_converter_registry,
73 config=self._config,
74 abstract_base_info=self._abstract_base_info,
75 child_abstract_bases=self._child_abstract_bases,
76 )
77 return self.__class__(
78 *self.nodes,
79 cast(formkit_schema.FormKitType, node),
80 type_converter_registry=self._type_converter_registry,
81 config=self._config,
82 abstract_base_info=self._abstract_base_info,
83 child_abstract_bases=self._child_abstract_bases,
84 )
86 def suggest_model_name(self) -> str:
87 """
88 Single reference for table name and foreign key references
89 """
90 model_name = "".join(map(self.safe_node_name, self.nodes))
91 return model_name
93 def suggest_class_name(self):
94 # If this is a repeater, skip wrapping group nodes in the classname
95 # Example: TF_6_1_1 > projectoutput > repeaterProjectOutput
96 # Should become: Tf_6_1_1Repeaterprojectoutput (not Tf_6_1_1ProjectoutputRepeaterprojectoutput)
97 if self.is_repeater and len(self.nodes) > 1:
98 # Filter nodes: keep root node(s) and the repeater, skip intermediate groups
99 filtered_nodes = []
100 for i, node in enumerate(self.nodes):
101 # Always include the first node (root)
102 if i == 0:
103 filtered_nodes.append(node)
104 # Always include the last node (the repeater itself)
105 elif i == len(self.nodes) - 1:
106 filtered_nodes.append(node)
107 # Skip intermediate nodes that are groups
108 else:
109 # Check if this intermediate node is a group
110 # Create a temporary NodePath to check the node type
111 temp_path = self.__class__(*self.nodes[: i + 1])
112 if not temp_path.is_group:
113 # Not a group, include it
114 filtered_nodes.append(node)
115 # If it is a group, skip it
116 else:
117 filtered_nodes = self.nodes
118 # For non-repeaters, use all nodes as before
119 model_name = "".join(self.safe_node_name(node).capitalize() for node in filtered_nodes)
120 return model_name
122 def suggest_field_name(self):
123 """
124 Single reference for table name and foreign key references
125 """
126 return self.safe_node_name(self.node)
128 def suggest_link_class_name(self):
129 return f"{self.classname}Link"
131 # Some accessors for the functions above
133 @property
134 def modelname(self):
135 return self.suggest_model_name()
137 @property
138 def classname(self):
139 return self.suggest_class_name()
141 @property
142 def fieldname(self):
143 return self.suggest_field_name()
145 @property
146 def linkname(self):
147 return self.suggest_link_class_name()
149 @property
150 def classname_lower(self):
151 return self.classname.lower()
153 @property
154 def classname_schema(self):
155 return f"{self.classname}Schema"
157 @staticmethod
158 def safe_name(name: str, fix: bool = True) -> str:
159 """
160 Ensure that the "name" provided is a valid
161 python identifier, correct if necessary
162 """
163 if name is None:
164 raise TypeError
165 if not name.isidentifier() or iskeyword(name):
166 if fix:
167 warnings.warn(f"The name: '''{name}''' is not a valid identifier")
168 # Run again to check that it's not a keyword
169 return NodePath.safe_name(make_valid_identifier(name), fix=False)
170 else:
171 raise KeyError(f"The name: '''{name}''' is not a valid identifier")
172 return name
174 def safe_node_name(self, node: FormKitType) -> str:
175 """
176 Return either the "name" or "id" field
177 """
178 name = getattr(node, "name", None)
179 if name:
180 return self.safe_name(name)
182 node_id = getattr(node, "id", None)
183 if node_id:
184 return self.safe_name(str(node_id))
186 # Return a fallback rather than raising AttributeError
187 # to support incomplete/transient nodes during dev/tests
188 return "unknown"
190 @property
191 def is_repeater(self):
192 return isinstance(self.node, RepeaterNode)
194 @property
195 def is_group(self):
196 return isinstance(self.node, GroupNode)
198 @property
199 def is_el(self):
200 """Returns True if this is a $el (HTML element) node."""
201 return isinstance(self.node, FormKitSchemaDOMNode)
203 @property
204 def formkits(self) -> Iterable["NodePath"]:
205 """
206 Iterate over FormKit nodes, recursing through layout elements ($el)
207 to find nested inputs.
208 """
209 for n in self.children:
210 child_path = self / n
211 if hasattr(n, "formkit"):
212 yield child_path
213 elif child_path.is_el:
214 # Recurse into $el layout nodes to find nested FormKit inputs
215 yield from child_path.formkits
217 @property
218 def formkits_not_repeaters(self) -> Iterable["NodePath"]:
219 def _get() -> Generator["NodePath", None, None]:
220 for n in self.children:
221 if hasattr(n, "formkit") and not isinstance(n, RepeaterNode):
222 yield self / n
224 return tuple(_get())
226 @property
227 def flat_pydantic_fields(self) -> Iterable["NodePath"]:
228 """
229 Recursively collect all fields that should be part of this Pydantic model's flat structure.
230 Groups are merged, repeaters remain as separate field entries.
231 """
232 for child in self.formkits:
233 if child.is_group:
234 # Recurse into groups to merge their fields
235 yield from child.flat_pydantic_fields
236 else:
237 # Fields and Repeaters remain as fields in this model
238 yield child
240 @property
241 def children(self):
242 return getattr(self.node, "children", []) or []
244 def filter_children(self, type_) -> Iterable["NodePath"]:
245 for n in self.children:
246 if isinstance(n, type_):
247 yield self / n
249 @property
250 def repeaters(self):
251 return tuple(self.filter_children(RepeaterNode))
253 @property
254 def groups(self):
255 return tuple(self.filter_children(GroupNode))
257 @property
258 def node(self):
259 return self.nodes[-1]
261 @property
262 def parent(self):
263 if len(self.nodes) > 1:
264 return self.nodes[-2]
265 else:
266 return None
268 @property
269 def is_child(self):
270 return self.parent is not None
272 @property
273 def depth(self):
274 return len(self.nodes)
276 @property
277 def tail(self):
278 return NodePath(self.node)
280 def __str__(self):
281 return f"NodePath {len(self.nodes)}: {self.node}"
283 @property
284 def django_attrib_name(self):
285 """
286 If not a group, return the Django field attribute
287 """
288 return self.tail.modelname
290 @property
291 def pydantic_attrib_name(self):
292 base = self.django_attrib_name
293 return base
295 @property
296 def parent_class_name(self):
297 return (self / "..").classname
299 @property
300 def is_abstract_base(self) -> bool:
301 """
302 Returns True if this NodePath should be generated as an abstract base class.
304 In the admin preview (and for general nested groups), any nested group
305 is handled as an abstract base for its parent.
306 """
307 if not self.is_group:
308 return False
310 # In the admin preview/general case, nested groups are abstract
311 if self.is_child:
312 # If we're not merging, or no config provided, children are not abstract bases
313 if not self._config or not getattr(self._config, "merge_top_level_groups", False):
314 return False
315 return True
317 if not self._config or not getattr(self._config, "merge_top_level_groups", False):
318 return False
319 # Check if this NodePath classname is marked as abstract base
320 return (self._abstract_base_info or {}).get(self.classname, False)
322 @property
323 def abstract_class_name(self) -> str:
324 """Returns the abstract class name: f'{classname}Abstract'"""
325 return f"{self.classname}Abstract"
327 def get_node_path_string(self) -> str:
328 """Returns a string representation of the node path for docstrings."""
329 path_parts = []
330 for node in self.nodes:
331 if hasattr(node, "name") and node.name:
332 path_parts.append(node.name)
333 elif hasattr(node, "id") and node.id:
334 path_parts.append(node.id)
335 elif hasattr(node, "formkit"):
336 path_parts.append(f"${node.formkit}")
337 return " > ".join(path_parts) if path_parts else "root"
339 def get_node_info_docstring(self) -> str:
340 """Returns a docstring describing the node origin."""
341 node_type = "Repeater" if self.is_repeater else "Group" if self.is_group else "Field"
342 path = self.get_node_path_string()
344 # Get label if available (and different from name)
345 label_info = ""
346 if hasattr(self.node, "label") and self.node.label:
347 node_name = getattr(self.node, "name", "") or getattr(self.node, "id", "")
348 if self.node.label != node_name:
349 label_info = f' (label: "{self.node.label}")'
351 return f"Generated from FormKit {node_type} node: {path}{label_info}"
353 @property
354 def parent_abstract_bases(self) -> list[str]:
355 """
356 Returns list of abstract base class names that this class should inherit from.
357 """
358 if not (self.is_group or self.is_repeater):
359 return []
361 bases = []
362 for group in self.groups:
363 if group.is_abstract_base:
364 bases.append(group.abstract_class_name)
366 # Fallback to config-driven bases if merge_top_level_groups is enabled
367 if self._config and getattr(self._config, "merge_top_level_groups", False):
368 for base in self._child_abstract_bases:
369 if base not in bases:
370 bases.append(base)
371 return bases
373 def to_pydantic_type(self) -> str:
374 """
375 Usually, this should return a well known Python type as a string.
376 Prioritizes fields stored on the node instance if available.
377 """
378 # 1. Check for database-derived override on the node
379 if hasattr(self.node, "pydantic_field_type") and self.node.pydantic_field_type:
380 return self.node.pydantic_field_type
382 # 2. Fall back to registry/legacy logic
383 node = self.node
384 converter = self._type_converter_registry.get_converter(node)
385 if converter is not None:
386 return converter.to_pydantic_type(node)
388 # Fallback to original logic for backward compatibility
389 if not hasattr(node, "formkit"):
390 return "str"
392 if node.formkit == "number":
393 if node.step is not None:
394 # We don't actually **know** this but it's a good assumption
395 return "float"
396 return "int"
398 match node.formkit:
399 case "text":
400 return "str"
401 case "number":
402 return "float"
403 case "select" | "dropdown" | "radio" | "autocomplete":
404 return "str"
405 case "datepicker":
406 return "date" # Changed from "datetime" to generate DateField
407 case "tel":
408 return "int"
409 case "group":
410 return self.classname_schema
411 case "repeater":
412 return f"list[{self.classname_schema}]"
413 case "hidden":
414 return "str"
415 case "uuid":
416 return "UUID"
417 case "currency":
418 return "Decimal"
419 return "str"
421 @property
422 def pydantic_type(self):
423 return self.to_pydantic_type()
425 def to_postgres_type(self):
426 match self.to_pydantic_type():
427 case "bool":
428 return "boolean"
429 case "str":
430 return "text"
431 case "Decimal":
432 return "NUMERIC(15,2)"
433 case "int":
434 return "int"
435 case "float":
436 return "float"
437 return "text"
439 @property
440 def postgres_type(self):
441 return self.to_postgres_type()
443 def to_django_type(self) -> str:
444 """
445 Convert formkit type to equivalent django field type.
446 Prioritizes fields stored on the node instance if available.
447 """
448 # 1. Check for database-derived override on the node
449 if hasattr(self.node, "django_field_type") and self.node.django_field_type:
450 return self.node.django_field_type
452 # 2. Handle group nodes
453 if self.is_group:
454 return "OneToOneField"
456 # 3. Fall back to default converter/registry
457 node = self.node
458 converter = self._type_converter_registry.get_converter(node)
459 if converter is not None and hasattr(converter, "to_django_type"):
460 return converter.to_django_type(node)
462 # Fallback to match logic based on pydantic type
463 match self.to_pydantic_type():
464 case "bool":
465 return "BooleanField"
466 case "Decimal":
467 return "DecimalField"
468 case "int":
469 return "IntegerField"
470 case "float":
471 return "FloatField"
472 case "datetime":
473 return "DateTimeField"
474 case "date":
475 return "DateField"
476 case "UUID":
477 return "UUIDField"
478 return "TextField"
480 @property
481 def django_type(self):
482 return self.to_django_type()
484 def _get_django_args_dict(self) -> dict[str, str]:
485 """
486 Get Django field arguments as a dictionary.
487 Returns a dict where keys are argument names and values are argument values.
488 For model references (no "="), the key and value are the same.
490 Returns:
491 dict: Dictionary of Django field arguments, with order preserved via insertion order
492 """
493 if self.is_group:
494 return {self.classname: self.classname, "on_delete": "models.CASCADE"}
496 # Get base args as a dictionary based on pydantic type
497 base_args_dict: dict[str, str] = {}
499 # First, check if converter provides django args
500 node = self.node
501 converter = self._type_converter_registry.get_converter(node)
502 if converter is not None and hasattr(converter, "to_django_args"):
503 base_args_dict = converter.to_django_args(node)
504 else:
505 # Fallback to defaults based on pydantic type
506 match self.to_pydantic_type():
507 case "bool":
508 base_args_dict = {"null": "True", "blank": "True"}
509 case "str":
510 base_args_dict = {"null": "True", "blank": "True"}
511 case "Decimal":
512 base_args_dict = {
513 "max_digits": "20",
514 "decimal_places": "2",
515 "null": "True",
516 "blank": "True",
517 }
518 case "int":
519 base_args_dict = {"null": "True", "blank": "True"}
520 case "float":
521 base_args_dict = {"null": "True", "blank": "True"}
522 case "datetime":
523 base_args_dict = {"null": "True", "blank": "True"}
524 case "date":
525 base_args_dict = {"null": "True", "blank": "True"}
526 case "UUID":
527 base_args_dict = {"editable": "False", "null": "True", "blank": "True"}
528 case _:
529 base_args_dict = {"null": "True", "blank": "True"}
531 # Get extra args from extension point
532 extra_args = self.get_django_args_extra()
534 # Start with base args
535 args_dict: dict[str, str] = {}
536 arg_order: list[str] = []
538 # Helper to add an argument to the dict
539 def add_arg(key: str, value: str, is_extra: bool = False) -> None:
540 """Add an argument to args_dict, preserving order."""
541 # Extra args override base args
542 if key not in args_dict or is_extra:
543 if key not in arg_order:
544 arg_order.append(key)
545 args_dict[key] = value
547 # Parse extra args first (they come first in output and override base args)
548 if extra_args:
549 for arg in extra_args:
550 arg = arg.strip()
551 if not arg:
552 continue
553 # Split by "=" to get key and value
554 if "=" in arg:
555 key, value = arg.split("=", 1)
556 key = key.strip()
557 value = value.strip()
558 add_arg(key, value, is_extra=True)
559 else:
560 # Handle args without "=" (e.g., model references like '"pnds_data.zDistrict"')
561 # Use the full arg as both key and value
562 add_arg(arg, arg, is_extra=True)
564 # Add base args (they fill in missing args and won't override existing ones)
565 for key, value in base_args_dict.items():
566 add_arg(key, value, is_extra=False)
568 # Return ordered dict (Python 3.7+ dicts preserve insertion order)
569 return {key: args_dict[key] for key in arg_order}
571 def to_django_args(self) -> str:
572 """
573 Default arguments for the field.
574 Prioritizes fields stored on the node instance if available.
575 """
576 # 1. Check for database-derived override on the node
577 args = getattr(self.node, "django_field_args", {})
578 pos_args = getattr(self.node, "django_field_positional_args", [])
579 if args or pos_args:
580 # We need to convert the dict/list back to a string for the template
581 return self._django_args_dict_to_str(args, pos_args)
583 # 2. Use args dict from _get_django_args_dict which combines converter logic
584 # and subclass extension point (get_django_args_extra)
585 args_dict = self._get_django_args_dict()
586 result_parts = []
587 for key, value in args_dict.items():
588 if key == value: # No "=" needed (e.g., model references)
589 result_parts.append(key)
590 else:
591 result_parts.append(f"{key}={value}")
593 return ", ".join(result_parts)
595 @staticmethod
596 def _django_args_dict_to_str(args_dict: dict, positional_args: list | None = None) -> str:
597 """
598 Convert django_args dict and positional_args list to string format.
600 Args:
601 args_dict: Dict of keyword field arguments
602 positional_args: List of positional field arguments
604 Returns:
605 Comma-separated string of arguments
606 """
607 parts = []
609 # Handle positional arguments first
610 if positional_args:
611 for value in positional_args:
612 parts.append(str(value))
614 # Handle keyword arguments
615 for key, value in args_dict.items():
616 if isinstance(value, bool):
617 parts.append(f"{key}={str(value)}")
618 elif isinstance(value, (int, float)):
619 parts.append(f"{key}={value}")
620 elif isinstance(value, str):
621 # Handle model references (e.g., "app.Model" or models.CASCADE)
622 if value in {"True", "False", "None"}:
623 parts.append(f"{key}={value}")
624 elif value.startswith("models.") or ("." in value and not value.startswith('"')):
625 parts.append(f"{key}={value}")
626 else:
627 parts.append(f'{key}="{value}"')
628 else:
629 parts.append(f"{key}={value}")
631 return ", ".join(parts)
633 @property
634 def django_args(self):
635 return self.to_django_args()
637 @property
638 def extra_attribs(self):
639 """
640 Returns extra fields to be appended to this group or
641 repeater node in "models.py"
642 """
643 if self.is_abstract_base:
644 return []
645 return ['submission = models.OneToOneField("formkit_ninja.SeparatedSubmission", on_delete=models.CASCADE, primary_key=True, related_name="+")']
647 @property
648 def has_schema_content(self) -> bool:
649 """
650 Returns True if this NodePath would generate any content in a schema class.
651 Used to determine if a 'pass' statement is needed for empty classes.
652 """
653 # Check extra attributes
654 if self.extra_attribs_schema:
655 return True
656 # Check parent abstract bases (would add fields)
657 if self.parent_abstract_bases:
658 # Only return True if we actually find abstract base groups with fields
659 # Logic mirrors schema.jinja2 lines 27-39
660 for group in self.groups:
661 if group.is_abstract_base:
662 if any(True for _ in group.formkits_not_repeaters):
663 return True
664 # Check repeaters (would add list fields)
665 if self.repeaters:
666 return True
667 # Check if this is a repeater (would add ordinality)
668 if self.is_repeater:
669 return True
670 # Check if any formkits_not_repeaters would be outputtable
671 # A field is outputtable if:
672 # - not is_abstract_base AND
673 # - not (django_type == "OneToOneField" and parent_abstract_bases exists)
674 for f in self.formkits_not_repeaters:
675 if not f.is_abstract_base:
676 # Check if it would be filtered out (same logic as template)
677 if not (f.django_type == "OneToOneField" and len(self.parent_abstract_bases) > 0):
678 return True
679 return False
681 @property
682 def extra_attribs_schema(self):
683 """
684 Returns extra attributes to be appended to "schema_out.py"
685 For Partisipa this included a foreign key to "Submission"
686 """
687 return []
689 @property
690 def has_basemodel_content(self) -> bool:
691 """
692 Returns True if this NodePath would generate any content in a basemodel class.
693 """
694 # Check extra attributes
695 if self.extra_attribs_basemodel:
696 return True
697 # Check parent abstract bases
698 if self.parent_abstract_bases:
699 # Only return True if we actually find abstract base groups with fields
700 for group in self.groups:
701 if group.is_abstract_base:
702 if any(True for _ in group.formkits_not_repeaters):
703 return True
704 # Check repeaters
705 if self.repeaters:
706 return True
707 # Check formkits_not_repeaters
708 for f in self.formkits_not_repeaters:
709 if not f.is_abstract_base:
710 if not (f.django_type == "OneToOneField" and len(self.parent_abstract_bases) > 0):
711 return True
712 # Also check validators!
713 if f.validators:
714 return True
715 return False
717 @property
718 def extra_attribs_basemodel(self):
719 """
720 Returns extra attributes to be appended to "schema.py"
721 For Partisipa this included a foreign key to "Submission"
722 """
723 return []
725 @property
726 def pydantic_extra_attribs(self) -> list[str]:
727 """
728 Returns extra fields to be added to the Pydantic model.
729 For the root model, we often need submission ID and other metadata.
730 """
731 if self.is_child:
732 return []
734 # This matches the user example for Sf_6_2
735 attribs = [
736 'id: UUID = Field(alias="submission")',
737 f'form_type: Literal["{self.classname}"] = "{self.classname}"',
738 ]
740 # Specific metadata fields often needed for questionnaires
741 metadata = [
742 "district: int | None = None",
743 "administrative_post: int | None = None",
744 "suco: int | None = None",
745 "aldeia: int | None = None",
746 "date: date | None = None",
747 "month: int | None = None",
748 "year: int | None = None",
749 "project_name: int | None = None",
750 "project: int | None = None",
751 ]
753 # Avoid duplication if they are already in the FormKit schema
754 schema_names = {f.fieldname for f in self.flat_pydantic_fields}
755 for m in metadata:
756 name = m.split(":")[0].strip()
757 if name not in schema_names:
758 attribs.append(m)
760 return attribs
762 @property
763 def validators(self) -> list[str]:
764 """
765 Hook to allow extra processing for
766 fields like Partisipa's 'currency' field
768 This property calls get_validators() for extensibility.
769 Subclasses can override get_validators() to provide custom validators.
770 """
771 return self.get_validators()
773 def get_validators(self) -> list[str]:
774 """
775 Get all validators related to this specific node.
776 Prioritizes fields stored on the node instance if available.
777 """
778 # 1. Check for database-derived override on the node
779 if hasattr(self.node, "validators") and self.node.validators:
780 return self.node.validators
782 # 2. Fall back to converter
783 node = self.node
784 converter = self._type_converter_registry.get_converter(node)
785 if converter is not None and hasattr(converter, "validators"):
786 return converter.validators
788 # 3. Legacy logic (actually the original get_validators was mostly empty or delegating)
789 return []
791 @property
792 def filter_clause(self) -> str:
793 """
794 Extension point: Return filter clause class name for admin/API filtering.
796 Override this property in a NodePath subclass to provide custom filter clauses.
797 Used in generated admin and API code for filtering querysets.
799 Returns:
800 Filter clause class name (default: "SubStatusFilter")
801 """
802 return "SubStatusFilter"
804 def get_extra_imports(self) -> list[str]:
805 """
806 Return any extra imports required for this field.
807 Prioritizes fields stored on the node instance if available.
808 """
809 # 1. Check for database-derived override on the node
810 if hasattr(self.node, "extra_imports") and self.node.extra_imports:
811 return self.node.extra_imports
813 # 2. Fall back to converter
814 node = self.node
815 converter = self._type_converter_registry.get_converter(node)
816 if converter is not None and hasattr(converter, "extra_imports"):
817 return converter.extra_imports
819 return []
821 def get_custom_imports(self) -> list[str]:
822 """
823 Extension point: Return list of custom import statements for models.py.
825 Override this method in a NodePath subclass to provide additional imports
826 that should be included in the generated models.py file.
828 Returns:
829 List of import statement strings (default: empty list)
830 """
831 return []
833 def get_django_args_extra(self) -> list[str]:
834 """
835 Extension point: Return additional Django field arguments.
837 Override this method in a NodePath subclass to add custom arguments
838 (e.g., model references, custom decimal places, on_delete behavior)
839 without overriding the entire to_django_args() method.
841 Returns:
842 List of additional argument strings (e.g., ["pnds_data.zDistrict", "on_delete=models.CASCADE"])
843 """
844 return []
846 def has_option(self, pattern: str) -> bool:
847 """
848 Check if node has options attribute that starts with the given pattern.
850 Helper method for checking option patterns like '$ida(' or '$getoptions'.
852 Args:
853 pattern: The pattern to check for at the start of options string
855 Returns:
856 True if node has options attribute and it starts with pattern, False otherwise
857 """
858 if not hasattr(self.node, "options") or self.node.options is None:
859 return False
860 options_str = str(self.node.options)
861 return options_str.startswith(pattern)
863 def matches_name(self, names: set[str] | list[str]) -> bool:
864 """
865 Check if node name is in the provided set or list.
867 Helper method for checking if a node name matches any of a set of names.
869 Args:
870 names: Set or list of node names to check against
872 Returns:
873 True if node has name attribute and it's in the provided names, False otherwise
874 """
875 if not hasattr(self.node, "name") or self.node.name is None:
876 return False
877 return self.node.name in names
879 def get_option_value(self) -> str | None:
880 """
881 Get the options attribute value as a string.
883 Helper method for accessing the options value safely.
885 Returns:
886 String representation of options if it exists, None otherwise
887 """
888 if not hasattr(self.node, "options") or self.node.options is None:
889 return None
890 return str(self.node.options)
892 @property
893 def django_code(self) -> str:
894 """
895 Generate the Full Django Model field code line.
896 Includes field name, type, and arguments.
897 """
898 code = f"{self.django_attrib_name} = models.{self.django_type}({self.django_args})"
900 # Validate syntax
901 try:
902 ast.parse(code)
903 except SyntaxError as e:
904 msg = f"Generated Django code for node '{self.get_node_path_string()}' has syntax errors: {e.msg}\nCode: {code}"
905 raise SyntaxError(msg) from e
907 return code
909 return code
911 @property
912 def pydantic_code(self) -> str:
913 """
914 Generate the Pydantic field code line for schemas.
915 """
916 name = self.pydantic_attrib_name
917 type_hint = self.to_pydantic_type() # Call the method to get the type
919 # Suffix _id for ForeignKeys in output schemas (Django Ninja convention)
920 if self.django_type == "OneToOneField" or self.django_type == "ForeignKey":
921 if not self.is_group and not self.is_repeater:
922 name = f"{name}_id"
924 # FormKit schemas often use 'T | None = None' as a default pattern for optional fields
925 code = f"{name}: {type_hint} | None = None"
927 # Pydantic code can be a class attribute, let's wrap it in a class to validate
928 validation_code = f"class Model:\n {code}"
929 try:
930 ast.parse(validation_code)
931 except SyntaxError as e:
932 msg = f"Generated Pydantic code for node '{self.get_node_path_string()}' has syntax errors: {e.msg}\nCode: {code}"
933 raise SyntaxError(msg) from e
935 return code
937 @property
938 def django_model_code(self) -> str:
939 """
940 Generate the complete Django model code for this node (if it's a group or repeater).
942 Includes:
943 - Class definition (abstract for nested groups)
944 - ForeignKey relationships for repeaters
945 - Child field definitions
946 - Nested groups and repeaters as comments
947 """
948 # $el (layout) nodes and text nodes don't generate Django models/fields
949 if self.is_el:
950 return "# $el (HTML layout element) nodes do not generate Django fields"
951 if isinstance(self.node, str):
952 return "# Text nodes do not generate Django fields"
954 if not (self.is_group or self.is_repeater):
955 # For simple fields, just return the field definition
956 return self.django_code
958 lines = []
960 # Determine class name and inheritance
961 is_abstract = self.is_abstract_base
962 class_suffix = "Abstract" if is_abstract else ""
963 class_name = f"{self.classname}{class_suffix}"
965 if self.parent_abstract_bases:
966 lines.append(f"class {class_name}({', '.join(self.parent_abstract_bases)}, models.Model):")
967 else:
968 lines.append(f"class {class_name}(models.Model):")
970 lines.append(' """')
971 lines.append(f" {self.get_node_info_docstring()}")
972 lines.append(' """')
974 has_content = False
976 if self.is_repeater:
977 # Repeaters always have submission FK
978 lines.append(' submission = models.ForeignKey("SeparatedSubmission", on_delete=models.CASCADE, null=True)')
979 has_content = True
981 # Nested repeaters also have parent FK
982 if self.depth > 1:
983 try:
984 parent_name = (self / "..").classname
985 except Exception:
986 parent_name = "ParentModel"
987 node_name = getattr(self.node, "name", "repeater_field") or "repeater_field"
988 lines.append(f' parent = models.ForeignKey("{parent_name}", on_delete=models.CASCADE, related_name="{node_name}")')
990 # Ordinality for list ordering
991 lines.append(" ordinality = models.IntegerField()")
993 # Extra attributes (submission, project, etc.)
994 for extra in self.extra_attribs:
995 lines.append(f" {extra}")
996 has_content = True
998 # Add fields from children (non-repeater, non-group fields)
999 for child_path in self.formkits_not_repeaters:
1000 if not child_path.is_abstract_base and not child_path.is_group:
1001 lines.append(f" {child_path.django_code}")
1002 has_content = True
1004 # Show child groups (as OneToOneField or abstract reference)
1005 for group_path in self.groups:
1006 if group_path.is_abstract_base:
1007 lines.append(f" # Inherits fields from {group_path.classname}Abstract")
1008 else:
1009 lines.append(f" {group_path.fieldname} = models.OneToOneField({group_path.classname}, on_delete=models.CASCADE)")
1010 has_content = True
1012 # Show child repeaters (as related name reference)
1013 for repeater_path in self.repeaters:
1014 node_name = getattr(repeater_path.node, "name", "items") or "items"
1015 lines.append(f" # {node_name}: list[{repeater_path.classname}] via ForeignKey")
1016 has_content = True
1018 # Abstract class Meta
1019 if is_abstract:
1020 lines.append("")
1021 lines.append(" class Meta:")
1022 lines.append(" abstract = True")
1023 has_content = True
1025 # If no fields at all, add pass
1026 if not has_content:
1027 lines.append(" pass")
1029 return "\n".join(lines)
1031 @property
1032 def pydantic_model_code(self) -> str:
1033 """
1034 Generate the complete Pydantic model code for this node (if it's a group or repeater).
1035 """
1036 # $el (layout) nodes and text nodes don't generate Pydantic models/fields
1037 if self.is_el:
1038 return "# $el (HTML layout element) nodes do not generate Pydantic fields"
1039 if isinstance(self.node, str):
1040 return "# Text nodes do not generate Pydantic fields"
1042 if not (self.is_group or self.is_repeater):
1043 # For simple fields, just return the field definition
1044 return self.pydantic_code
1046 lines = []
1047 lines.append(f"class {self.classname_schema}(BaseModel):")
1048 lines.append(' """')
1049 lines.append(f" {self.get_node_info_docstring()}")
1050 lines.append(' """')
1052 has_content = False
1054 # Add fields from flat descendants (merges nested groups)
1055 for child_path in self.flat_pydantic_fields:
1056 lines.append(f" {child_path.pydantic_code}")
1057 has_content = True
1059 # Add extra attributes (metadata for root model)
1060 for extra in self.pydantic_extra_attribs:
1061 lines.append(f" {extra}")
1062 has_content = True
1064 if self.is_repeater:
1065 # Repeaters often include an ordinality/index in Pydantic too
1066 lines.append(" ordinality: int | None = None")
1067 has_content = True
1069 # Add validators
1070 for child_path in self.flat_pydantic_fields:
1071 for v in child_path.validators:
1072 lines.append(f" {v}")
1073 has_content = True
1075 if not has_content:
1076 lines.append(" pass")
1078 return "\n".join(lines)