Coverage for formkit_ninja / parser / type_convert.py: 24.05%

599 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-03-03 09:21 +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 == "..": 69 ↛ 70line 69 didn't jump to line 70 because the condition on line 69 was never true

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: 97 ↛ 99line 97 didn't jump to line 99 because the condition on line 97 was never true

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: 163 ↛ 164line 163 didn't jump to line 164 because the condition on line 163 was never true

164 raise TypeError 

165 if not name.isidentifier() or iskeyword(name): 165 ↛ 166line 165 didn't jump to line 166 because the condition on line 165 was never true

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: 179 ↛ 182line 179 didn't jump to line 182 because the condition on line 179 was always true

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): 221 ↛ 220line 221 didn't jump to line 220 because the condition on line 221 was always true

222 yield self / n 

223 

224 return tuple(_get()) 

225 

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: 234 ↛ 235line 234 didn't jump to line 235 because the condition on line 234 was never true

235 continue 

236 if attrib.django_type == "OneToOneField" and len(self.parent_abstract_bases) > 0: 236 ↛ 237line 236 didn't jump to line 237 because the condition on line 236 was never true

237 continue 

238 if getattr(attrib.node, "list_filter", False): 

239 result.append(attrib) 

240 for group in self.groups: 240 ↛ 241line 240 didn't jump to line 241 because the loop on line 240 never started

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) 

246 

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 

260 

261 @property 

262 def children(self): 

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

264 

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

266 for n in self.children: 

267 if isinstance(n, type_): 267 ↛ 268line 267 didn't jump to line 268 because the condition on line 267 was never true

268 yield self / n 

269 

270 @property 

271 def repeaters(self): 

272 return tuple(self.filter_children(RepeaterNode)) 

273 

274 @property 

275 def groups(self): 

276 return tuple(self.filter_children(GroupNode)) 

277 

278 @property 

279 def node(self): 

280 return self.nodes[-1] 

281 

282 @property 

283 def parent(self): 

284 if len(self.nodes) > 1: 284 ↛ 285line 284 didn't jump to line 285 because the condition on line 284 was never true

285 return self.nodes[-2] 

286 else: 

287 return None 

288 

289 @property 

290 def is_child(self): 

291 return self.parent is not None 

292 

293 @property 

294 def depth(self): 

295 return len(self.nodes) 

296 

297 @property 

298 def tail(self): 

299 return NodePath(self.node) 

300 

301 def __str__(self): 

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

303 

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 

310 

311 @property 

312 def pydantic_attrib_name(self): 

313 base = self.django_attrib_name 

314 return base 

315 

316 @property 

317 def parent_class_name(self): 

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

319 

320 @property 

321 def is_abstract_base(self) -> bool: 

322 """ 

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

324 

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 

330 

331 # In the admin preview/general case, nested groups are abstract 

332 if self.is_child: 332 ↛ 334line 332 didn't jump to line 334 because the condition on line 332 was never true

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 

337 

338 if not self._config or not getattr(self._config, "merge_top_level_groups", False): 338 ↛ 341line 338 didn't jump to line 341 because the condition on line 338 was always true

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) 

342 

343 @property 

344 def abstract_class_name(self) -> str: 

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

346 return f"{self.classname}Abstract" 

347 

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" 

359 

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

364 

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

371 

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

373 

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): 379 ↛ 380line 379 didn't jump to line 380 because the condition on line 379 was never true

380 return [] 

381 

382 bases = [] 

383 for group in self.groups: 383 ↛ 384line 383 didn't jump to line 384 because the loop on line 383 never started

384 if group.is_abstract_base: 

385 bases.append(group.abstract_class_name) 

386 

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): 388 ↛ 389line 388 didn't jump to line 389 because the condition on line 388 was never true

389 for base in self._child_abstract_bases: 

390 if base not in bases: 

391 bases.append(base) 

392 return bases 

393 

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 

402 

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) 

408 

409 # Fallback to original logic for backward compatibility 

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

411 return "str" 

412 

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" 

418 

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" 

441 

442 @property 

443 def pydantic_type(self): 

444 return self.to_pydantic_type() 

445 

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" 

459 

460 @property 

461 def postgres_type(self): 

462 return self.to_postgres_type() 

463 

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: 470 ↛ 471line 470 didn't jump to line 471 because the condition on line 470 was never true

471 return self.node.django_field_type 

472 

473 # 2. Handle group nodes 

474 if self.is_group: 474 ↛ 475line 474 didn't jump to line 475 because the condition on line 474 was never true

475 return "OneToOneField" 

476 

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"): 480 ↛ 484line 480 didn't jump to line 484 because the condition on line 480 was always true

481 return converter.to_django_type(node) 

482 

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" 

500 

501 @property 

502 def django_type(self): 

503 return self.to_django_type() 

504 

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. 

510 

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

516 

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

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

519 

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

551 

552 # Get extra args from extension point 

553 extra_args = self.get_django_args_extra() 

554 

555 # Start with base args 

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

557 arg_order: list[str] = [] 

558 

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 

567 

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) 

584 

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) 

588 

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

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

591 

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) 

603 

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

613 

614 return ", ".join(result_parts) 

615 

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. 

620 

621 Args: 

622 args_dict: Dict of keyword field arguments 

623 positional_args: List of positional field arguments 

624 

625 Returns: 

626 Comma-separated string of arguments 

627 """ 

628 parts = [] 

629 

630 # Handle positional arguments first 

631 if positional_args: 

632 for value in positional_args: 

633 parts.append(str(value)) 

634 

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

651 

652 return ", ".join(parts) 

653 

654 @property 

655 def django_args(self): 

656 return self.to_django_args() 

657 

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

667 

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 

701 

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

709 

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 

737 

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

745 

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

754 

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 ] 

760 

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 ] 

773 

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) 

780 

781 return attribs 

782 

783 @property 

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

785 """ 

786 Hook to allow extra processing for 

787 fields like Partisipa's 'currency' field 

788 

789 This property calls get_validators() for extensibility. 

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

791 """ 

792 return self.get_validators() 

793 

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 

802 

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 

808 

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

810 return [] 

811 

812 @property 

813 def filter_clause(self) -> str: 

814 """ 

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

816 

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

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

819 

820 Returns: 

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

822 """ 

823 return "SubStatusFilter" 

824 

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 

833 

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 

839 

840 return [] 

841 

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

843 """ 

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

845 

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

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

848 

849 Returns: 

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

851 """ 

852 return [] 

853 

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

855 """ 

856 Extension point: Return additional Django field arguments. 

857 

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. 

861 

862 Returns: 

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

864 """ 

865 return [] 

866 

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

868 """ 

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

870 

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

872 

873 Args: 

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

875 

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) 

883 

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

885 """ 

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

887 

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

889 

890 Args: 

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

892 

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 

899 

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

901 """ 

902 Get the options attribute value as a string. 

903 

904 Helper method for accessing the options value safely. 

905 

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) 

912 

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

920 

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 

927 

928 return code 

929 

930 return code 

931 

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 

939 

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" 

944 

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

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

947 

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 

955 

956 return code 

957 

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

962 

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" 

974 

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

976 # For simple fields, just return the field definition 

977 return self.django_code 

978 

979 lines = [] 

980 

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

985 

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

990 

991 lines.append(' """') 

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

993 lines.append(' """') 

994 

995 has_content = False 

996 

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 

1001 

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

1010 

1011 # Ordinality for list ordering 

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

1013 

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

1015 for extra in self.extra_attribs: 

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

1017 has_content = True 

1018 

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 

1024 

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 

1032 

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 

1038 

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 

1045 

1046 # If no fields at all, add pass 

1047 if not has_content: 

1048 lines.append(" pass") 

1049 

1050 return "\n".join(lines) 

1051 

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" 

1062 

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

1064 # For simple fields, just return the field definition 

1065 return self.pydantic_code 

1066 

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

1072 

1073 has_content = False 

1074 

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 

1079 

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 

1084 

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 

1089 

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 

1095 

1096 if not has_content: 

1097 lines.append(" pass") 

1098 

1099 return "\n".join(lines)