Coverage for formkit_ninja / parser / type_convert.py: 13.76%
599 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-03-06 04:12 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-03-06 04:12 +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 formkits_for_list_filter(self) -> tuple["NodePath", ...]:
228 """
229 FormKit nodes that should appear in ModelAdmin.list_filter.
230 Same inclusion rules as list_display, but only nodes with list_filter=True.
231 """
232 result: list["NodePath"] = []
233 for attrib in self.formkits_not_repeaters:
234 if attrib.is_abstract_base:
235 continue
236 if attrib.django_type == "OneToOneField" and len(self.parent_abstract_bases) > 0:
237 continue
238 if getattr(attrib.node, "list_filter", False):
239 result.append(attrib)
240 for group in self.groups:
241 if group.is_abstract_base:
242 for attrib in group.formkits_not_repeaters:
243 if getattr(attrib.node, "list_filter", False):
244 result.append(attrib)
245 return tuple(result)
247 @property
248 def flat_pydantic_fields(self) -> Iterable["NodePath"]:
249 """
250 Recursively collect all fields that should be part of this Pydantic model's flat structure.
251 Groups are merged, repeaters remain as separate field entries.
252 """
253 for child in self.formkits:
254 if child.is_group:
255 # Recurse into groups to merge their fields
256 yield from child.flat_pydantic_fields
257 else:
258 # Fields and Repeaters remain as fields in this model
259 yield child
261 @property
262 def children(self):
263 return getattr(self.node, "children", []) or []
265 def filter_children(self, type_) -> Iterable["NodePath"]:
266 for n in self.children:
267 if isinstance(n, type_):
268 yield self / n
270 @property
271 def repeaters(self):
272 return tuple(self.filter_children(RepeaterNode))
274 @property
275 def groups(self):
276 return tuple(self.filter_children(GroupNode))
278 @property
279 def node(self):
280 return self.nodes[-1]
282 @property
283 def parent(self):
284 if len(self.nodes) > 1:
285 return self.nodes[-2]
286 else:
287 return None
289 @property
290 def is_child(self):
291 return self.parent is not None
293 @property
294 def depth(self):
295 return len(self.nodes)
297 @property
298 def tail(self):
299 return NodePath(self.node)
301 def __str__(self):
302 return f"NodePath {len(self.nodes)}: {self.node}"
304 @property
305 def django_attrib_name(self):
306 """
307 If not a group, return the Django field attribute
308 """
309 return self.tail.modelname
311 @property
312 def pydantic_attrib_name(self):
313 base = self.django_attrib_name
314 return base
316 @property
317 def parent_class_name(self):
318 return (self / "..").classname
320 @property
321 def is_abstract_base(self) -> bool:
322 """
323 Returns True if this NodePath should be generated as an abstract base class.
325 In the admin preview (and for general nested groups), any nested group
326 is handled as an abstract base for its parent.
327 """
328 if not self.is_group:
329 return False
331 # In the admin preview/general case, nested groups are abstract
332 if self.is_child:
333 # If we're not merging, or no config provided, children are not abstract bases
334 if not self._config or not getattr(self._config, "merge_top_level_groups", False):
335 return False
336 return True
338 if not self._config or not getattr(self._config, "merge_top_level_groups", False):
339 return False
340 # Check if this NodePath classname is marked as abstract base
341 return (self._abstract_base_info or {}).get(self.classname, False)
343 @property
344 def abstract_class_name(self) -> str:
345 """Returns the abstract class name: f'{classname}Abstract'"""
346 return f"{self.classname}Abstract"
348 def get_node_path_string(self) -> str:
349 """Returns a string representation of the node path for docstrings."""
350 path_parts = []
351 for node in self.nodes:
352 if hasattr(node, "name") and node.name:
353 path_parts.append(node.name)
354 elif hasattr(node, "id") and node.id:
355 path_parts.append(node.id)
356 elif hasattr(node, "formkit"):
357 path_parts.append(f"${node.formkit}")
358 return " > ".join(path_parts) if path_parts else "root"
360 def get_node_info_docstring(self) -> str:
361 """Returns a docstring describing the node origin."""
362 node_type = "Repeater" if self.is_repeater else "Group" if self.is_group else "Field"
363 path = self.get_node_path_string()
365 # Get label if available (and different from name)
366 label_info = ""
367 if hasattr(self.node, "label") and self.node.label:
368 node_name = getattr(self.node, "name", "") or getattr(self.node, "id", "")
369 if self.node.label != node_name:
370 label_info = f' (label: "{self.node.label}")'
372 return f"Generated from FormKit {node_type} node: {path}{label_info}"
374 @property
375 def parent_abstract_bases(self) -> list[str]:
376 """
377 Returns list of abstract base class names that this class should inherit from.
378 """
379 if not (self.is_group or self.is_repeater):
380 return []
382 bases = []
383 for group in self.groups:
384 if group.is_abstract_base:
385 bases.append(group.abstract_class_name)
387 # Fallback to config-driven bases if merge_top_level_groups is enabled
388 if self._config and getattr(self._config, "merge_top_level_groups", False):
389 for base in self._child_abstract_bases:
390 if base not in bases:
391 bases.append(base)
392 return bases
394 def to_pydantic_type(self) -> str:
395 """
396 Usually, this should return a well known Python type as a string.
397 Prioritizes fields stored on the node instance if available.
398 """
399 # 1. Check for database-derived override on the node
400 if hasattr(self.node, "pydantic_field_type") and self.node.pydantic_field_type:
401 return self.node.pydantic_field_type
403 # 2. Fall back to registry/legacy logic
404 node = self.node
405 converter = self._type_converter_registry.get_converter(node)
406 if converter is not None:
407 return converter.to_pydantic_type(node)
409 # Fallback to original logic for backward compatibility
410 if not hasattr(node, "formkit"):
411 return "str"
413 if node.formkit == "number":
414 if node.step is not None:
415 # We don't actually **know** this but it's a good assumption
416 return "float"
417 return "int"
419 match node.formkit:
420 case "text":
421 return "str"
422 case "number":
423 return "float"
424 case "select" | "dropdown" | "radio" | "autocomplete":
425 return "str"
426 case "datepicker":
427 return "date" # Changed from "datetime" to generate DateField
428 case "tel":
429 return "int"
430 case "group":
431 return self.classname_schema
432 case "repeater":
433 return f"list[{self.classname_schema}]"
434 case "hidden":
435 return "str"
436 case "uuid":
437 return "UUID"
438 case "currency":
439 return "Decimal"
440 return "str"
442 @property
443 def pydantic_type(self):
444 return self.to_pydantic_type()
446 def to_postgres_type(self):
447 match self.to_pydantic_type():
448 case "bool":
449 return "boolean"
450 case "str":
451 return "text"
452 case "Decimal":
453 return "NUMERIC(15,2)"
454 case "int":
455 return "int"
456 case "float":
457 return "float"
458 return "text"
460 @property
461 def postgres_type(self):
462 return self.to_postgres_type()
464 def to_django_type(self) -> str:
465 """
466 Convert formkit type to equivalent django field type.
467 Prioritizes fields stored on the node instance if available.
468 """
469 # 1. Check for database-derived override on the node
470 if hasattr(self.node, "django_field_type") and self.node.django_field_type:
471 return self.node.django_field_type
473 # 2. Handle group nodes
474 if self.is_group:
475 return "OneToOneField"
477 # 3. Fall back to default converter/registry
478 node = self.node
479 converter = self._type_converter_registry.get_converter(node)
480 if converter is not None and hasattr(converter, "to_django_type"):
481 return converter.to_django_type(node)
483 # Fallback to match logic based on pydantic type
484 match self.to_pydantic_type():
485 case "bool":
486 return "BooleanField"
487 case "Decimal":
488 return "DecimalField"
489 case "int":
490 return "IntegerField"
491 case "float":
492 return "FloatField"
493 case "datetime":
494 return "DateTimeField"
495 case "date":
496 return "DateField"
497 case "UUID":
498 return "UUIDField"
499 return "TextField"
501 @property
502 def django_type(self):
503 return self.to_django_type()
505 def _get_django_args_dict(self) -> dict[str, str]:
506 """
507 Get Django field arguments as a dictionary.
508 Returns a dict where keys are argument names and values are argument values.
509 For model references (no "="), the key and value are the same.
511 Returns:
512 dict: Dictionary of Django field arguments, with order preserved via insertion order
513 """
514 if self.is_group:
515 return {self.classname: self.classname, "on_delete": "models.CASCADE"}
517 # Get base args as a dictionary based on pydantic type
518 base_args_dict: dict[str, str] = {}
520 # First, check if converter provides django args
521 node = self.node
522 converter = self._type_converter_registry.get_converter(node)
523 if converter is not None and hasattr(converter, "to_django_args"):
524 base_args_dict = converter.to_django_args(node)
525 else:
526 # Fallback to defaults based on pydantic type
527 match self.to_pydantic_type():
528 case "bool":
529 base_args_dict = {"null": "True", "blank": "True"}
530 case "str":
531 base_args_dict = {"null": "True", "blank": "True"}
532 case "Decimal":
533 base_args_dict = {
534 "max_digits": "20",
535 "decimal_places": "2",
536 "null": "True",
537 "blank": "True",
538 }
539 case "int":
540 base_args_dict = {"null": "True", "blank": "True"}
541 case "float":
542 base_args_dict = {"null": "True", "blank": "True"}
543 case "datetime":
544 base_args_dict = {"null": "True", "blank": "True"}
545 case "date":
546 base_args_dict = {"null": "True", "blank": "True"}
547 case "UUID":
548 base_args_dict = {"editable": "False", "null": "True", "blank": "True"}
549 case _:
550 base_args_dict = {"null": "True", "blank": "True"}
552 # Get extra args from extension point
553 extra_args = self.get_django_args_extra()
555 # Start with base args
556 args_dict: dict[str, str] = {}
557 arg_order: list[str] = []
559 # Helper to add an argument to the dict
560 def add_arg(key: str, value: str, is_extra: bool = False) -> None:
561 """Add an argument to args_dict, preserving order."""
562 # Extra args override base args
563 if key not in args_dict or is_extra:
564 if key not in arg_order:
565 arg_order.append(key)
566 args_dict[key] = value
568 # Parse extra args first (they come first in output and override base args)
569 if extra_args:
570 for arg in extra_args:
571 arg = arg.strip()
572 if not arg:
573 continue
574 # Split by "=" to get key and value
575 if "=" in arg:
576 key, value = arg.split("=", 1)
577 key = key.strip()
578 value = value.strip()
579 add_arg(key, value, is_extra=True)
580 else:
581 # Handle args without "=" (e.g., model references like '"pnds_data.zDistrict"')
582 # Use the full arg as both key and value
583 add_arg(arg, arg, is_extra=True)
585 # Add base args (they fill in missing args and won't override existing ones)
586 for key, value in base_args_dict.items():
587 add_arg(key, value, is_extra=False)
589 # Return ordered dict (Python 3.7+ dicts preserve insertion order)
590 return {key: args_dict[key] for key in arg_order}
592 def to_django_args(self) -> str:
593 """
594 Default arguments for the field.
595 Prioritizes fields stored on the node instance if available.
596 """
597 # 1. Check for database-derived override on the node
598 args = getattr(self.node, "django_field_args", {})
599 pos_args = getattr(self.node, "django_field_positional_args", [])
600 if args or pos_args:
601 # We need to convert the dict/list back to a string for the template
602 return self._django_args_dict_to_str(args, pos_args)
604 # 2. Use args dict from _get_django_args_dict which combines converter logic
605 # and subclass extension point (get_django_args_extra)
606 args_dict = self._get_django_args_dict()
607 result_parts = []
608 for key, value in args_dict.items():
609 if key == value: # No "=" needed (e.g., model references)
610 result_parts.append(key)
611 else:
612 result_parts.append(f"{key}={value}")
614 return ", ".join(result_parts)
616 @staticmethod
617 def _django_args_dict_to_str(args_dict: dict, positional_args: list | None = None) -> str:
618 """
619 Convert django_args dict and positional_args list to string format.
621 Args:
622 args_dict: Dict of keyword field arguments
623 positional_args: List of positional field arguments
625 Returns:
626 Comma-separated string of arguments
627 """
628 parts = []
630 # Handle positional arguments first
631 if positional_args:
632 for value in positional_args:
633 parts.append(str(value))
635 # Handle keyword arguments
636 for key, value in args_dict.items():
637 if isinstance(value, bool):
638 parts.append(f"{key}={str(value)}")
639 elif isinstance(value, (int, float)):
640 parts.append(f"{key}={value}")
641 elif isinstance(value, str):
642 # Handle model references (e.g., "app.Model" or models.CASCADE)
643 if value in {"True", "False", "None"}:
644 parts.append(f"{key}={value}")
645 elif value.startswith("models.") or ("." in value and not value.startswith('"')):
646 parts.append(f"{key}={value}")
647 else:
648 parts.append(f'{key}="{value}"')
649 else:
650 parts.append(f"{key}={value}")
652 return ", ".join(parts)
654 @property
655 def django_args(self):
656 return self.to_django_args()
658 @property
659 def extra_attribs(self):
660 """
661 Returns extra fields to be appended to this group or
662 repeater node in "models.py"
663 """
664 if self.is_abstract_base:
665 return []
666 return ['submission = models.OneToOneField("formkit_ninja.SeparatedSubmission", on_delete=models.CASCADE, primary_key=True, related_name="+")']
668 @property
669 def has_schema_content(self) -> bool:
670 """
671 Returns True if this NodePath would generate any content in a schema class.
672 Used to determine if a 'pass' statement is needed for empty classes.
673 """
674 # Check extra attributes
675 if self.extra_attribs_schema:
676 return True
677 # Check parent abstract bases (would add fields)
678 if self.parent_abstract_bases:
679 # Only return True if we actually find abstract base groups with fields
680 # Logic mirrors schema.jinja2 lines 27-39
681 for group in self.groups:
682 if group.is_abstract_base:
683 if any(True for _ in group.formkits_not_repeaters):
684 return True
685 # Check repeaters (would add list fields)
686 if self.repeaters:
687 return True
688 # Check if this is a repeater (would add ordinality)
689 if self.is_repeater:
690 return True
691 # Check if any formkits_not_repeaters would be outputtable
692 # A field is outputtable if:
693 # - not is_abstract_base AND
694 # - not (django_type == "OneToOneField" and parent_abstract_bases exists)
695 for f in self.formkits_not_repeaters:
696 if not f.is_abstract_base:
697 # Check if it would be filtered out (same logic as template)
698 if not (f.django_type == "OneToOneField" and len(self.parent_abstract_bases) > 0):
699 return True
700 return False
702 @property
703 def extra_attribs_schema(self):
704 """
705 Returns extra attributes to be appended to "schema_out.py"
706 For Partisipa this included a foreign key to "Submission"
707 """
708 return []
710 @property
711 def has_basemodel_content(self) -> bool:
712 """
713 Returns True if this NodePath would generate any content in a basemodel class.
714 """
715 # Check extra attributes
716 if self.extra_attribs_basemodel:
717 return True
718 # Check parent abstract bases
719 if self.parent_abstract_bases:
720 # Only return True if we actually find abstract base groups with fields
721 for group in self.groups:
722 if group.is_abstract_base:
723 if any(True for _ in group.formkits_not_repeaters):
724 return True
725 # Check repeaters
726 if self.repeaters:
727 return True
728 # Check formkits_not_repeaters
729 for f in self.formkits_not_repeaters:
730 if not f.is_abstract_base:
731 if not (f.django_type == "OneToOneField" and len(self.parent_abstract_bases) > 0):
732 return True
733 # Also check validators!
734 if f.validators:
735 return True
736 return False
738 @property
739 def extra_attribs_basemodel(self):
740 """
741 Returns extra attributes to be appended to "schema.py"
742 For Partisipa this included a foreign key to "Submission"
743 """
744 return []
746 @property
747 def pydantic_extra_attribs(self) -> list[str]:
748 """
749 Returns extra fields to be added to the Pydantic model.
750 For the root model, we often need submission ID and other metadata.
751 """
752 if self.is_child:
753 return []
755 # This matches the user example for Sf_6_2
756 attribs = [
757 'id: UUID = Field(alias="submission")',
758 f'form_type: Literal["{self.classname}"] = "{self.classname}"',
759 ]
761 # Specific metadata fields often needed for questionnaires
762 metadata = [
763 "district: int | None = None",
764 "administrative_post: int | None = None",
765 "suco: int | None = None",
766 "aldeia: int | None = None",
767 "date: date | None = None",
768 "month: int | None = None",
769 "year: int | None = None",
770 "project_name: int | None = None",
771 "project: int | None = None",
772 ]
774 # Avoid duplication if they are already in the FormKit schema
775 schema_names = {f.fieldname for f in self.flat_pydantic_fields}
776 for m in metadata:
777 name = m.split(":")[0].strip()
778 if name not in schema_names:
779 attribs.append(m)
781 return attribs
783 @property
784 def validators(self) -> list[str]:
785 """
786 Hook to allow extra processing for
787 fields like Partisipa's 'currency' field
789 This property calls get_validators() for extensibility.
790 Subclasses can override get_validators() to provide custom validators.
791 """
792 return self.get_validators()
794 def get_validators(self) -> list[str]:
795 """
796 Get all validators related to this specific node.
797 Prioritizes fields stored on the node instance if available.
798 """
799 # 1. Check for database-derived override on the node
800 if hasattr(self.node, "validators") and self.node.validators:
801 return self.node.validators
803 # 2. Fall back to converter
804 node = self.node
805 converter = self._type_converter_registry.get_converter(node)
806 if converter is not None and hasattr(converter, "validators"):
807 return converter.validators
809 # 3. Legacy logic (actually the original get_validators was mostly empty or delegating)
810 return []
812 @property
813 def filter_clause(self) -> str:
814 """
815 Extension point: Return filter clause class name for admin/API filtering.
817 Override this property in a NodePath subclass to provide custom filter clauses.
818 Used in generated admin and API code for filtering querysets.
820 Returns:
821 Filter clause class name (default: "SubStatusFilter")
822 """
823 return "SubStatusFilter"
825 def get_extra_imports(self) -> list[str]:
826 """
827 Return any extra imports required for this field.
828 Prioritizes fields stored on the node instance if available.
829 """
830 # 1. Check for database-derived override on the node
831 if hasattr(self.node, "extra_imports") and self.node.extra_imports:
832 return self.node.extra_imports
834 # 2. Fall back to converter
835 node = self.node
836 converter = self._type_converter_registry.get_converter(node)
837 if converter is not None and hasattr(converter, "extra_imports"):
838 return converter.extra_imports
840 return []
842 def get_custom_imports(self) -> list[str]:
843 """
844 Extension point: Return list of custom import statements for models.py.
846 Override this method in a NodePath subclass to provide additional imports
847 that should be included in the generated models.py file.
849 Returns:
850 List of import statement strings (default: empty list)
851 """
852 return []
854 def get_django_args_extra(self) -> list[str]:
855 """
856 Extension point: Return additional Django field arguments.
858 Override this method in a NodePath subclass to add custom arguments
859 (e.g., model references, custom decimal places, on_delete behavior)
860 without overriding the entire to_django_args() method.
862 Returns:
863 List of additional argument strings (e.g., ["pnds_data.zDistrict", "on_delete=models.CASCADE"])
864 """
865 return []
867 def has_option(self, pattern: str) -> bool:
868 """
869 Check if node has options attribute that starts with the given pattern.
871 Helper method for checking option patterns like '$ida(' or '$getoptions'.
873 Args:
874 pattern: The pattern to check for at the start of options string
876 Returns:
877 True if node has options attribute and it starts with pattern, False otherwise
878 """
879 if not hasattr(self.node, "options") or self.node.options is None:
880 return False
881 options_str = str(self.node.options)
882 return options_str.startswith(pattern)
884 def matches_name(self, names: set[str] | list[str]) -> bool:
885 """
886 Check if node name is in the provided set or list.
888 Helper method for checking if a node name matches any of a set of names.
890 Args:
891 names: Set or list of node names to check against
893 Returns:
894 True if node has name attribute and it's in the provided names, False otherwise
895 """
896 if not hasattr(self.node, "name") or self.node.name is None:
897 return False
898 return self.node.name in names
900 def get_option_value(self) -> str | None:
901 """
902 Get the options attribute value as a string.
904 Helper method for accessing the options value safely.
906 Returns:
907 String representation of options if it exists, None otherwise
908 """
909 if not hasattr(self.node, "options") or self.node.options is None:
910 return None
911 return str(self.node.options)
913 @property
914 def django_code(self) -> str:
915 """
916 Generate the Full Django Model field code line.
917 Includes field name, type, and arguments.
918 """
919 code = f"{self.django_attrib_name} = models.{self.django_type}({self.django_args})"
921 # Validate syntax
922 try:
923 ast.parse(code)
924 except SyntaxError as e:
925 msg = f"Generated Django code for node '{self.get_node_path_string()}' has syntax errors: {e.msg}\nCode: {code}"
926 raise SyntaxError(msg) from e
928 return code
930 return code
932 @property
933 def pydantic_code(self) -> str:
934 """
935 Generate the Pydantic field code line for schemas.
936 """
937 name = self.pydantic_attrib_name
938 type_hint = self.to_pydantic_type() # Call the method to get the type
940 # Suffix _id for ForeignKeys in output schemas (Django Ninja convention)
941 if self.django_type == "OneToOneField" or self.django_type == "ForeignKey":
942 if not self.is_group and not self.is_repeater:
943 name = f"{name}_id"
945 # FormKit schemas often use 'T | None = None' as a default pattern for optional fields
946 code = f"{name}: {type_hint} | None = None"
948 # Pydantic code can be a class attribute, let's wrap it in a class to validate
949 validation_code = f"class Model:\n {code}"
950 try:
951 ast.parse(validation_code)
952 except SyntaxError as e:
953 msg = f"Generated Pydantic code for node '{self.get_node_path_string()}' has syntax errors: {e.msg}\nCode: {code}"
954 raise SyntaxError(msg) from e
956 return code
958 @property
959 def django_model_code(self) -> str:
960 """
961 Generate the complete Django model code for this node (if it's a group or repeater).
963 Includes:
964 - Class definition (abstract for nested groups)
965 - ForeignKey relationships for repeaters
966 - Child field definitions
967 - Nested groups and repeaters as comments
968 """
969 # $el (layout) nodes and text nodes don't generate Django models/fields
970 if self.is_el:
971 return "# $el (HTML layout element) nodes do not generate Django fields"
972 if isinstance(self.node, str):
973 return "# Text nodes do not generate Django fields"
975 if not (self.is_group or self.is_repeater):
976 # For simple fields, just return the field definition
977 return self.django_code
979 lines = []
981 # Determine class name and inheritance
982 is_abstract = self.is_abstract_base
983 class_suffix = "Abstract" if is_abstract else ""
984 class_name = f"{self.classname}{class_suffix}"
986 if self.parent_abstract_bases:
987 lines.append(f"class {class_name}({', '.join(self.parent_abstract_bases)}, models.Model):")
988 else:
989 lines.append(f"class {class_name}(models.Model):")
991 lines.append(' """')
992 lines.append(f" {self.get_node_info_docstring()}")
993 lines.append(' """')
995 has_content = False
997 if self.is_repeater:
998 # Repeaters always have submission FK
999 lines.append(' submission = models.ForeignKey("SeparatedSubmission", on_delete=models.CASCADE, null=True)')
1000 has_content = True
1002 # Nested repeaters also have parent FK
1003 if self.depth > 1:
1004 try:
1005 parent_name = (self / "..").classname
1006 except Exception:
1007 parent_name = "ParentModel"
1008 node_name = getattr(self.node, "name", "repeater_field") or "repeater_field"
1009 lines.append(f' parent = models.ForeignKey("{parent_name}", on_delete=models.CASCADE, related_name="{node_name}")')
1011 # Ordinality for list ordering
1012 lines.append(" ordinality = models.IntegerField()")
1014 # Extra attributes (submission, project, etc.)
1015 for extra in self.extra_attribs:
1016 lines.append(f" {extra}")
1017 has_content = True
1019 # Add fields from children (non-repeater, non-group fields)
1020 for child_path in self.formkits_not_repeaters:
1021 if not child_path.is_abstract_base and not child_path.is_group:
1022 lines.append(f" {child_path.django_code}")
1023 has_content = True
1025 # Show child groups (as OneToOneField or abstract reference)
1026 for group_path in self.groups:
1027 if group_path.is_abstract_base:
1028 lines.append(f" # Inherits fields from {group_path.classname}Abstract")
1029 else:
1030 lines.append(f" {group_path.fieldname} = models.OneToOneField({group_path.classname}, on_delete=models.CASCADE)")
1031 has_content = True
1033 # Show child repeaters (as related name reference)
1034 for repeater_path in self.repeaters:
1035 node_name = getattr(repeater_path.node, "name", "items") or "items"
1036 lines.append(f" # {node_name}: list[{repeater_path.classname}] via ForeignKey")
1037 has_content = True
1039 # Abstract class Meta
1040 if is_abstract:
1041 lines.append("")
1042 lines.append(" class Meta:")
1043 lines.append(" abstract = True")
1044 has_content = True
1046 # If no fields at all, add pass
1047 if not has_content:
1048 lines.append(" pass")
1050 return "\n".join(lines)
1052 @property
1053 def pydantic_model_code(self) -> str:
1054 """
1055 Generate the complete Pydantic model code for this node (if it's a group or repeater).
1056 """
1057 # $el (layout) nodes and text nodes don't generate Pydantic models/fields
1058 if self.is_el:
1059 return "# $el (HTML layout element) nodes do not generate Pydantic fields"
1060 if isinstance(self.node, str):
1061 return "# Text nodes do not generate Pydantic fields"
1063 if not (self.is_group or self.is_repeater):
1064 # For simple fields, just return the field definition
1065 return self.pydantic_code
1067 lines = []
1068 lines.append(f"class {self.classname_schema}(BaseModel):")
1069 lines.append(' """')
1070 lines.append(f" {self.get_node_info_docstring()}")
1071 lines.append(' """')
1073 has_content = False
1075 # Add fields from flat descendants (merges nested groups)
1076 for child_path in self.flat_pydantic_fields:
1077 lines.append(f" {child_path.pydantic_code}")
1078 has_content = True
1080 # Add extra attributes (metadata for root model)
1081 for extra in self.pydantic_extra_attribs:
1082 lines.append(f" {extra}")
1083 has_content = True
1085 if self.is_repeater:
1086 # Repeaters often include an ordinality/index in Pydantic too
1087 lines.append(" ordinality: int | None = None")
1088 has_content = True
1090 # Add validators
1091 for child_path in self.flat_pydantic_fields:
1092 for v in child_path.validators:
1093 lines.append(f" {v}")
1094 has_content = True
1096 if not has_content:
1097 lines.append(" pass")
1099 return "\n".join(lines)