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

1from __future__ import annotations 

2 

3import ast 

4import warnings 

5from keyword import iskeyword 

6from typing import Generator, Iterable, Literal, cast 

7 

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 

12 

13FormKitType = formkit_schema.FormKitType 

14 

15 

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) 

25 

26 while output and output[0].isdigit(): 

27 output = output[1:] 

28 

29 while output[-1] == "_": 

30 output = output[:-1] 

31 

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") 

36 

37 return output.lower() 

38 

39 

40class NodePath: 

41 """ 

42 Mostly a wrapper around "tuple" to provide useful conventions 

43 for naming 

44 """ 

45 

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 [] 

59 

60 @classmethod 

61 def from_obj(cls, obj: dict): 

62 node = FormKitNodeFactory.from_dict(obj) 

63 return cls(cast(FormKitType, node)) 

64 

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 ) 

85 

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 

92 

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 

121 

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) 

127 

128 def suggest_link_class_name(self): 

129 return f"{self.classname}Link" 

130 

131 # Some accessors for the functions above 

132 

133 @property 

134 def modelname(self): 

135 return self.suggest_model_name() 

136 

137 @property 

138 def classname(self): 

139 return self.suggest_class_name() 

140 

141 @property 

142 def fieldname(self): 

143 return self.suggest_field_name() 

144 

145 @property 

146 def linkname(self): 

147 return self.suggest_link_class_name() 

148 

149 @property 

150 def classname_lower(self): 

151 return self.classname.lower() 

152 

153 @property 

154 def classname_schema(self): 

155 return f"{self.classname}Schema" 

156 

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 

173 

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) 

181 

182 node_id = getattr(node, "id", None) 

183 if node_id: 

184 return self.safe_name(str(node_id)) 

185 

186 # Return a fallback rather than raising AttributeError 

187 # to support incomplete/transient nodes during dev/tests 

188 return "unknown" 

189 

190 @property 

191 def is_repeater(self): 

192 return isinstance(self.node, RepeaterNode) 

193 

194 @property 

195 def is_group(self): 

196 return isinstance(self.node, GroupNode) 

197 

198 @property 

199 def is_el(self): 

200 """Returns True if this is a $el (HTML element) node.""" 

201 return isinstance(self.node, FormKitSchemaDOMNode) 

202 

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 

216 

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 

223 

224 return tuple(_get()) 

225 

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 

239 

240 @property 

241 def children(self): 

242 return getattr(self.node, "children", []) or [] 

243 

244 def filter_children(self, type_) -> Iterable["NodePath"]: 

245 for n in self.children: 

246 if isinstance(n, type_): 

247 yield self / n 

248 

249 @property 

250 def repeaters(self): 

251 return tuple(self.filter_children(RepeaterNode)) 

252 

253 @property 

254 def groups(self): 

255 return tuple(self.filter_children(GroupNode)) 

256 

257 @property 

258 def node(self): 

259 return self.nodes[-1] 

260 

261 @property 

262 def parent(self): 

263 if len(self.nodes) > 1: 

264 return self.nodes[-2] 

265 else: 

266 return None 

267 

268 @property 

269 def is_child(self): 

270 return self.parent is not None 

271 

272 @property 

273 def depth(self): 

274 return len(self.nodes) 

275 

276 @property 

277 def tail(self): 

278 return NodePath(self.node) 

279 

280 def __str__(self): 

281 return f"NodePath {len(self.nodes)}: {self.node}" 

282 

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 

289 

290 @property 

291 def pydantic_attrib_name(self): 

292 base = self.django_attrib_name 

293 return base 

294 

295 @property 

296 def parent_class_name(self): 

297 return (self / "..").classname 

298 

299 @property 

300 def is_abstract_base(self) -> bool: 

301 """ 

302 Returns True if this NodePath should be generated as an abstract base class. 

303 

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 

309 

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 

316 

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) 

321 

322 @property 

323 def abstract_class_name(self) -> str: 

324 """Returns the abstract class name: f'{classname}Abstract'""" 

325 return f"{self.classname}Abstract" 

326 

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" 

338 

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() 

343 

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}")' 

350 

351 return f"Generated from FormKit {node_type} node: {path}{label_info}" 

352 

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 [] 

360 

361 bases = [] 

362 for group in self.groups: 

363 if group.is_abstract_base: 

364 bases.append(group.abstract_class_name) 

365 

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 

372 

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 

381 

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) 

387 

388 # Fallback to original logic for backward compatibility 

389 if not hasattr(node, "formkit"): 

390 return "str" 

391 

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" 

397 

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" 

420 

421 @property 

422 def pydantic_type(self): 

423 return self.to_pydantic_type() 

424 

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" 

438 

439 @property 

440 def postgres_type(self): 

441 return self.to_postgres_type() 

442 

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 

451 

452 # 2. Handle group nodes 

453 if self.is_group: 

454 return "OneToOneField" 

455 

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) 

461 

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" 

479 

480 @property 

481 def django_type(self): 

482 return self.to_django_type() 

483 

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. 

489 

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"} 

495 

496 # Get base args as a dictionary based on pydantic type 

497 base_args_dict: dict[str, str] = {} 

498 

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"} 

530 

531 # Get extra args from extension point 

532 extra_args = self.get_django_args_extra() 

533 

534 # Start with base args 

535 args_dict: dict[str, str] = {} 

536 arg_order: list[str] = [] 

537 

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 

546 

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) 

563 

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) 

567 

568 # Return ordered dict (Python 3.7+ dicts preserve insertion order) 

569 return {key: args_dict[key] for key in arg_order} 

570 

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) 

582 

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}") 

592 

593 return ", ".join(result_parts) 

594 

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. 

599 

600 Args: 

601 args_dict: Dict of keyword field arguments 

602 positional_args: List of positional field arguments 

603 

604 Returns: 

605 Comma-separated string of arguments 

606 """ 

607 parts = [] 

608 

609 # Handle positional arguments first 

610 if positional_args: 

611 for value in positional_args: 

612 parts.append(str(value)) 

613 

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}") 

630 

631 return ", ".join(parts) 

632 

633 @property 

634 def django_args(self): 

635 return self.to_django_args() 

636 

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="+")'] 

646 

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 

680 

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 [] 

688 

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 

716 

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 [] 

724 

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 [] 

733 

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 ] 

739 

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 ] 

752 

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) 

759 

760 return attribs 

761 

762 @property 

763 def validators(self) -> list[str]: 

764 """ 

765 Hook to allow extra processing for 

766 fields like Partisipa's 'currency' field 

767 

768 This property calls get_validators() for extensibility. 

769 Subclasses can override get_validators() to provide custom validators. 

770 """ 

771 return self.get_validators() 

772 

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 

781 

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 

787 

788 # 3. Legacy logic (actually the original get_validators was mostly empty or delegating) 

789 return [] 

790 

791 @property 

792 def filter_clause(self) -> str: 

793 """ 

794 Extension point: Return filter clause class name for admin/API filtering. 

795 

796 Override this property in a NodePath subclass to provide custom filter clauses. 

797 Used in generated admin and API code for filtering querysets. 

798 

799 Returns: 

800 Filter clause class name (default: "SubStatusFilter") 

801 """ 

802 return "SubStatusFilter" 

803 

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 

812 

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 

818 

819 return [] 

820 

821 def get_custom_imports(self) -> list[str]: 

822 """ 

823 Extension point: Return list of custom import statements for models.py. 

824 

825 Override this method in a NodePath subclass to provide additional imports 

826 that should be included in the generated models.py file. 

827 

828 Returns: 

829 List of import statement strings (default: empty list) 

830 """ 

831 return [] 

832 

833 def get_django_args_extra(self) -> list[str]: 

834 """ 

835 Extension point: Return additional Django field arguments. 

836 

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. 

840 

841 Returns: 

842 List of additional argument strings (e.g., ["pnds_data.zDistrict", "on_delete=models.CASCADE"]) 

843 """ 

844 return [] 

845 

846 def has_option(self, pattern: str) -> bool: 

847 """ 

848 Check if node has options attribute that starts with the given pattern. 

849 

850 Helper method for checking option patterns like '$ida(' or '$getoptions'. 

851 

852 Args: 

853 pattern: The pattern to check for at the start of options string 

854 

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) 

862 

863 def matches_name(self, names: set[str] | list[str]) -> bool: 

864 """ 

865 Check if node name is in the provided set or list. 

866 

867 Helper method for checking if a node name matches any of a set of names. 

868 

869 Args: 

870 names: Set or list of node names to check against 

871 

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 

878 

879 def get_option_value(self) -> str | None: 

880 """ 

881 Get the options attribute value as a string. 

882 

883 Helper method for accessing the options value safely. 

884 

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) 

891 

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})" 

899 

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 

906 

907 return code 

908 

909 return code 

910 

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 

918 

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" 

923 

924 # FormKit schemas often use 'T | None = None' as a default pattern for optional fields 

925 code = f"{name}: {type_hint} | None = None" 

926 

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 

934 

935 return code 

936 

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). 

941 

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" 

953 

954 if not (self.is_group or self.is_repeater): 

955 # For simple fields, just return the field definition 

956 return self.django_code 

957 

958 lines = [] 

959 

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}" 

964 

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):") 

969 

970 lines.append(' """') 

971 lines.append(f" {self.get_node_info_docstring()}") 

972 lines.append(' """') 

973 

974 has_content = False 

975 

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 

980 

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}")') 

989 

990 # Ordinality for list ordering 

991 lines.append(" ordinality = models.IntegerField()") 

992 

993 # Extra attributes (submission, project, etc.) 

994 for extra in self.extra_attribs: 

995 lines.append(f" {extra}") 

996 has_content = True 

997 

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 

1003 

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 

1011 

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 

1017 

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 

1024 

1025 # If no fields at all, add pass 

1026 if not has_content: 

1027 lines.append(" pass") 

1028 

1029 return "\n".join(lines) 

1030 

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" 

1041 

1042 if not (self.is_group or self.is_repeater): 

1043 # For simple fields, just return the field definition 

1044 return self.pydantic_code 

1045 

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(' """') 

1051 

1052 has_content = False 

1053 

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 

1058 

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 

1063 

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 

1068 

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 

1074 

1075 if not has_content: 

1076 lines.append(" pass") 

1077 

1078 return "\n".join(lines)