fh-pydantic-form 0.2.0__py3-none-any.whl → 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of fh-pydantic-form might be problematic. Click here for more details.

@@ -3,6 +3,7 @@ from datetime import date, time
3
3
  from enum import Enum
4
4
  from typing import (
5
5
  Any,
6
+ List,
6
7
  Optional,
7
8
  get_args,
8
9
  get_origin,
@@ -61,6 +62,8 @@ class BaseFieldRenderer:
61
62
  disabled: bool = False,
62
63
  label_color: Optional[str] = None,
63
64
  spacing: SpacingValue = SpacingTheme.NORMAL,
65
+ field_path: Optional[List[str]] = None,
66
+ form_name: Optional[str] = None,
64
67
  ):
65
68
  """
66
69
  Initialize the field renderer
@@ -73,17 +76,61 @@ class BaseFieldRenderer:
73
76
  disabled: Whether the field should be rendered as disabled
74
77
  label_color: Optional CSS color value for the field label
75
78
  spacing: Spacing theme to use for layout ("normal", "compact", or SpacingTheme enum)
79
+ field_path: Path segments from root to this field (for nested list support)
80
+ form_name: Explicit form name (used for nested list URLs)
76
81
  """
77
82
  self.field_name = f"{prefix}{field_name}" if prefix else field_name
78
83
  self.original_field_name = field_name
79
84
  self.field_info = field_info
80
85
  self.value = value
81
86
  self.prefix = prefix
87
+ self.field_path: List[str] = field_path or []
88
+ self.explicit_form_name: Optional[str] = form_name
82
89
  self.is_optional = _is_optional_type(field_info.annotation)
83
90
  self.disabled = disabled
84
91
  self.label_color = label_color
85
92
  self.spacing = _normalize_spacing(spacing)
86
93
 
94
+ def _is_inline_color(self, color: str) -> bool:
95
+ """
96
+ Determine if a color should be applied as an inline style or CSS class.
97
+
98
+ Args:
99
+ color: The color value to check
100
+
101
+ Returns:
102
+ True if the color should be applied as inline style, False if as CSS class
103
+ """
104
+ # Check if it's a hex color value (starts with #) or basic HTML color name
105
+ return color.startswith("#") or color in [
106
+ "red",
107
+ "blue",
108
+ "green",
109
+ "yellow",
110
+ "orange",
111
+ "purple",
112
+ "pink",
113
+ "cyan",
114
+ "magenta",
115
+ "brown",
116
+ "black",
117
+ "white",
118
+ "gray",
119
+ "grey",
120
+ ]
121
+
122
+ def _get_color_class(self, color: str) -> str:
123
+ """
124
+ Get the appropriate CSS class for a color.
125
+
126
+ Args:
127
+ color: The color name
128
+
129
+ Returns:
130
+ The CSS class string for the color
131
+ """
132
+ return f"text-{color}-600"
133
+
87
134
  def render_label(self) -> FT:
88
135
  """
89
136
  Render label for the field
@@ -118,13 +165,12 @@ class BaseFieldRenderer:
118
165
 
119
166
  # Apply color styling if specified
120
167
  if self.label_color:
121
- # Check if it's a CSS class (contains letters/hyphens) or a color value
122
- if self.label_color.replace("-", "").replace("#", "").isalnum():
123
- # Looks like a CSS class, add it to the class list
124
- cls_attr = f"block text-sm font-medium {self.label_color} {label_gap_class}".strip()
125
- else:
168
+ if self._is_inline_color(self.label_color):
126
169
  # Treat as color value
127
170
  label_attrs["style"] = f"color: {self.label_color};"
171
+ else:
172
+ # Treat as CSS class (includes Tailwind colors like emerald, amber, rose, teal, indigo, lime, violet, etc.)
173
+ cls_attr = f"block text-sm font-medium {self._get_color_class(self.label_color)} {label_gap_class}".strip()
128
174
 
129
175
  # Create and return the label - using standard fh.Label with appropriate styling
130
176
  return fh.Label(
@@ -147,49 +193,38 @@ class BaseFieldRenderer:
147
193
 
148
194
  def render(self) -> FT:
149
195
  """
150
- Render the complete field (label + input) with spacing in a collapsible accordion
196
+ Render the complete field (label + input) with spacing
197
+
198
+ For compact spacing: renders label and input side-by-side
199
+ For normal spacing: renders label above input (traditional)
151
200
 
152
201
  Returns:
153
- A FastHTML component (mui.Accordion) containing the complete field
202
+ A FastHTML component containing the complete field
154
203
  """
155
- # 1. Get the full label component (fh.Label)
204
+ # 1. Get the label component
156
205
  label_component = self.render_label()
157
206
 
158
- # Apply color styling directly to the Label component if needed
159
- if self.label_color and isinstance(label_component, fh.FT):
160
- if "style" in label_component.attrs:
161
- label_component.attrs["style"] += f" color: {self.label_color};"
162
- else:
163
- label_component.attrs["style"] = f"color: {self.label_color};"
164
-
165
- # 2. Render the input field that will be the accordion content
207
+ # 2. Render the input field
166
208
  input_component = self.render_input()
167
209
 
168
- # 3. Define unique IDs for potential targeting
169
- item_id = f"{self.field_name}_item"
170
- accordion_id = f"{self.field_name}_accordion"
171
-
172
- # 4. Create the AccordionItem with the full label component as title
173
- accordion_item = mui.AccordionItem(
174
- label_component, # Use the entire label component including the "for" attribute
175
- input_component, # Content component (the input field)
176
- open=True, # Open by default
177
- li_kwargs={"id": item_id}, # Pass the specific ID for the <li>
178
- cls=spacing(
179
- "outer_margin_sm", self.spacing
180
- ), # Add spacing between accordion items
181
- )
182
-
183
- # 5. Wrap the single AccordionItem in an Accordion container
184
- accordion_container = mui.Accordion(
185
- accordion_item, # The single item to include
186
- id=accordion_id, # ID for the accordion container (ul)
187
- multiple=True, # Allow multiple open (though only one exists)
188
- collapsible=True, # Allow toggling
189
- cls=f"{spacing('accordion_divider', self.spacing)} {spacing('accordion_content', self.spacing)}".strip(),
190
- )
191
-
192
- return accordion_container
210
+ # 3. Choose layout based on spacing theme
211
+ if self.spacing == SpacingTheme.COMPACT:
212
+ # Horizontal layout for compact mode
213
+ return fh.Div(
214
+ fh.Div(
215
+ label_component,
216
+ input_component,
217
+ cls=f"flex {spacing('horizontal_gap', self.spacing)} {spacing('label_align', self.spacing)}",
218
+ ),
219
+ cls=spacing("outer_margin", self.spacing),
220
+ )
221
+ else:
222
+ # Vertical layout for normal mode (existing behavior)
223
+ return fh.Div(
224
+ label_component,
225
+ input_component,
226
+ cls=spacing("outer_margin", self.spacing),
227
+ )
193
228
 
194
229
 
195
230
  # ---- Specific Field Renderers ----
@@ -301,6 +336,29 @@ class BooleanFieldRenderer(BaseFieldRenderer):
301
336
 
302
337
  return mui.CheckboxX(**checkbox_attrs)
303
338
 
339
+ def render(self) -> FT:
340
+ """
341
+ Render the complete field (label + input) with spacing, placing the checkbox next to the label.
342
+
343
+ Returns:
344
+ A FastHTML component containing the complete field
345
+ """
346
+ # Get the label component
347
+ label_component = self.render_label()
348
+
349
+ # Get the checkbox component
350
+ checkbox_component = self.render_input()
351
+
352
+ # Create a flex container to place label and checkbox side by side
353
+ return fh.Div(
354
+ fh.Div(
355
+ label_component,
356
+ checkbox_component,
357
+ cls="flex items-center gap-2", # Use flexbox to align items horizontally with a small gap
358
+ ),
359
+ cls=spacing("outer_margin", self.spacing),
360
+ )
361
+
304
362
 
305
363
  class DateFieldRenderer(BaseFieldRenderer):
306
364
  """Renderer for date fields"""
@@ -559,26 +617,36 @@ class BaseModelFieldRenderer(BaseFieldRenderer):
559
617
  Returns:
560
618
  A FastHTML component (mui.Accordion) containing the accordion structure.
561
619
  """
562
- # 1. Get the label content (the inner Span with text/tooltip)
563
- label_component = self.render_label()
564
- if isinstance(label_component, fh.FT) and label_component.children:
565
- label_content = label_component.children[0]
566
- # Extract label style if present
567
- label_style = label_component.attrs.get("style", "")
568
- # Apply label style directly to the span if needed
569
- if label_style:
570
- # Check if label_content is already a Span, otherwise wrap it
571
- if isinstance(label_content, fh.Span):
572
- label_content.attrs["style"] = label_style
573
- else:
574
- # This case is less likely if render_label returns Label(Span(...))
575
- label_content = fh.Span(label_content, style=label_style)
620
+
621
+ # Extract the label text and apply color styling
622
+ label_text = self.original_field_name.replace("_", " ").title()
623
+
624
+ # Create the title component with proper color styling
625
+ if self.label_color:
626
+ if self._is_inline_color(self.label_color):
627
+ # Color value - apply as inline style
628
+ title_component = fh.Span(
629
+ label_text,
630
+ style=f"color: {self.label_color};",
631
+ cls="text-sm font-medium",
632
+ )
633
+ else:
634
+ # CSS class - apply as Tailwind class (includes emerald, amber, rose, teal, indigo, lime, violet, etc.)
635
+ title_component = fh.Span(
636
+ label_text,
637
+ cls=f"text-sm font-medium {self._get_color_class(self.label_color)}",
638
+ )
576
639
  else:
577
- # Fallback if structure is different (should not happen ideally)
578
- label_content = self.original_field_name.replace("_", " ").title()
579
- label_style = f"color: {self.label_color};" if self.label_color else ""
580
- if label_style:
581
- label_content = fh.Span(label_content, style=label_style)
640
+ # No color specified - use default styling
641
+ title_component = fh.Span(
642
+ label_text, cls="text-sm font-medium text-gray-700"
643
+ )
644
+
645
+ # Add tooltip if description is available
646
+ description = getattr(self.field_info, "description", None)
647
+ if description:
648
+ title_component.attrs["uk-tooltip"] = description
649
+ title_component.attrs["title"] = description
582
650
 
583
651
  # 2. Render the nested input fields that will be the accordion content
584
652
  input_component = self.render_input()
@@ -588,13 +656,8 @@ class BaseModelFieldRenderer(BaseFieldRenderer):
588
656
  accordion_id = f"{self.field_name}_accordion"
589
657
 
590
658
  # 4. Create the AccordionItem using the MonsterUI component
591
- # - Pass label_content as the title.
592
- # - Pass input_component as the content.
593
- # - Set 'open=True' to be expanded by default.
594
- # - Pass item_id via li_kwargs.
595
- # - Add 'mb-4' class for bottom margin spacing.
596
659
  accordion_item = mui.AccordionItem(
597
- label_content, # Title component (already potentially styled Span)
660
+ title_component, # Title component with proper color styling
598
661
  input_component, # Content component (the Card with nested fields)
599
662
  open=True, # Open by default
600
663
  li_kwargs={"id": item_id}, # Pass the specific ID for the <li>
@@ -695,6 +758,8 @@ class BaseModelFieldRenderer(BaseFieldRenderer):
695
758
  prefix=nested_prefix,
696
759
  disabled=self.disabled, # Propagate disabled state to nested fields
697
760
  spacing=self.spacing, # Propagate spacing to nested fields
761
+ field_path=self.field_path + [nested_field_name], # Propagate path
762
+ form_name=self.explicit_form_name, # Propagate form name
698
763
  )
699
764
 
700
765
  nested_inputs.append(renderer.render())
@@ -729,6 +794,54 @@ class BaseModelFieldRenderer(BaseFieldRenderer):
729
794
  class ListFieldRenderer(BaseFieldRenderer):
730
795
  """Renderer for list fields containing any type"""
731
796
 
797
+ def _container_id(self) -> str:
798
+ """
799
+ Return a DOM-unique ID for the list's <ul> / <div> wrapper.
800
+
801
+ Format: <formname>_<hierarchy>_items_container
802
+ Example: main_form_compact_tags_items_container
803
+ """
804
+ base = "_".join(self.field_path) # tags or main_address_tags
805
+ if self._form_name: # already resolved in property
806
+ return f"{self._form_name}_{base}_items_container"
807
+ return f"{base}_items_container" # fallback (shouldn't happen)
808
+
809
+ @property
810
+ def _form_name(self) -> str:
811
+ """Get form name - prefer explicit form name if provided"""
812
+ if self.explicit_form_name:
813
+ return self.explicit_form_name
814
+
815
+ # Fallback to extracting from prefix (for backward compatibility)
816
+ # The prefix always starts with the form name followed by underscore
817
+ # e.g., "main_form_compact_" or "main_form_compact_main_address_tags_"
818
+ # We need to extract just "main_form_compact"
819
+ if self.prefix:
820
+ # For backward compatibility with existing non-nested lists
821
+ # Split by underscore and rebuild the form name by removing known field components
822
+ parts = self.prefix.rstrip("_").split("_")
823
+
824
+ # For a simple heuristic: form names typically have 2-3 parts (main_form_compact)
825
+ # Field paths are at the end, so we find where the form name ends
826
+ # This is imperfect but works for most cases
827
+ if len(parts) >= 3 and parts[1] == "form":
828
+ # Standard pattern: main_form_compact
829
+ form_name = "_".join(parts[:3])
830
+ elif len(parts) >= 2:
831
+ # Fallback: take first 2 parts
832
+ form_name = "_".join(parts[:2])
833
+ else:
834
+ # Single part
835
+ form_name = parts[0] if parts else ""
836
+
837
+ return form_name
838
+ return ""
839
+
840
+ @property
841
+ def _list_path(self) -> str:
842
+ """Get the hierarchical path for this list field"""
843
+ return "/".join(self.field_path)
844
+
732
845
  def render(self) -> FT:
733
846
  """
734
847
  Render the complete field (label + input) with spacing, adding a refresh icon for list fields.
@@ -738,13 +851,42 @@ class ListFieldRenderer(BaseFieldRenderer):
738
851
  A FastHTML component containing the complete field with refresh icon
739
852
  """
740
853
  # Extract form name from prefix (removing trailing underscore if present)
741
- form_name = self.prefix.rstrip("_") if self.prefix else None
854
+ # form_name = self.prefix.rstrip("_") if self.prefix else None
855
+ form_name = self._form_name or None
856
+
857
+ # Create the label text with proper color styling
858
+ label_text = self.original_field_name.replace("_", " ").title()
859
+
860
+ # Create the styled label span
861
+ if self.label_color:
862
+ if self._is_inline_color(self.label_color):
863
+ # Color value - apply as inline style
864
+ label_span = fh.Span(
865
+ label_text,
866
+ style=f"color: {self.label_color};",
867
+ cls=f"block text-sm font-medium {spacing('label_gap', self.spacing)}",
868
+ )
869
+ else:
870
+ # CSS class - apply as Tailwind class (includes emerald, amber, rose, teal, indigo, lime, violet, etc.)
871
+ label_span = fh.Span(
872
+ label_text,
873
+ cls=f"block text-sm font-medium {self._get_color_class(self.label_color)} {spacing('label_gap', self.spacing)}",
874
+ )
875
+ else:
876
+ # No color specified - use default styling
877
+ label_span = fh.Span(
878
+ label_text,
879
+ cls=f"block text-sm font-medium text-gray-700 {spacing('label_gap', self.spacing)}",
880
+ )
742
881
 
743
- # Get the original label
744
- original_label = self.render_label()
882
+ # Add tooltip if description is available
883
+ description = getattr(self.field_info, "description", None)
884
+ if description:
885
+ label_span.attrs["uk-tooltip"] = description
886
+ label_span.attrs["title"] = description
745
887
 
746
888
  # Construct the container ID that will be generated by render_input()
747
- container_id = f"{self.prefix}{self.original_field_name}_items_container"
889
+ container_id = self._container_id()
748
890
 
749
891
  # Only add refresh icon if we have a form name
750
892
  if form_name:
@@ -761,21 +903,23 @@ class ListFieldRenderer(BaseFieldRenderer):
761
903
  hx_post=f"/form/{form_name}/refresh",
762
904
  hx_target=f"#{form_name}-inputs-wrapper",
763
905
  hx_swap="innerHTML",
764
- hx_include=f"#{form_name}-form",
906
+ hx_include="closest form", # ← key change
765
907
  uk_tooltip="Refresh form display to update list summaries",
908
+ # Prevent 'toggleListItems' on the parent from firing
909
+ onclick="event.stopPropagation();",
766
910
  )
767
911
 
768
912
  # Combine label and icon
769
913
  label_with_icon = fh.Div(
770
- original_label,
914
+ label_span, # Use the properly styled label span
771
915
  refresh_icon_trigger,
772
916
  cls="flex items-center cursor-pointer", # Added cursor-pointer
773
917
  onclick=f"toggleListItems('{container_id}'); return false;", # Add click handler
774
918
  )
775
919
  else:
776
- # If no form name, just use the original label but still make it clickable
920
+ # If no form name, just use the styled label but still make it clickable
777
921
  label_with_icon = fh.Div(
778
- original_label,
922
+ label_span, # Use the properly styled label span
779
923
  cls="flex items-center cursor-pointer", # Added cursor-pointer
780
924
  onclick=f"toggleListItems('{container_id}'); return false;", # Add click handler
781
925
  uk_tooltip="Click to toggle all items open/closed",
@@ -839,8 +983,8 @@ class ListFieldRenderer(BaseFieldRenderer):
839
983
  )
840
984
  )
841
985
 
842
- # Container for list items with form-specific prefix in ID
843
- container_id = f"{self.prefix}{self.original_field_name}_items_container"
986
+ # Container for list items using hierarchical field path
987
+ container_id = self._container_id()
844
988
 
845
989
  # Use mui.Accordion component
846
990
  accordion = mui.Accordion(
@@ -854,13 +998,10 @@ class ListFieldRenderer(BaseFieldRenderer):
854
998
  # Empty state message if no items
855
999
  empty_state = ""
856
1000
  if not items:
857
- # Extract form name from prefix if available
858
- form_name = self.prefix.rstrip("_") if self.prefix else None
859
-
860
- # Check if it's a simple type or BaseModel
1001
+ # Use hierarchical path for URL
861
1002
  add_url = (
862
- f"/form/{form_name}/list/add/{self.original_field_name}"
863
- if form_name
1003
+ f"/form/{self._form_name}/list/add/{self._list_path}"
1004
+ if self._form_name
864
1005
  else f"/list/add/{self.field_name}"
865
1006
  )
866
1007
 
@@ -913,9 +1054,6 @@ class ListFieldRenderer(BaseFieldRenderer):
913
1054
  item_id = f"{self.field_name}_{idx}"
914
1055
  item_card_id = f"{item_id}_card"
915
1056
 
916
- # Extract form name from prefix if available
917
- form_name = self.prefix.rstrip("_") if self.prefix else None
918
-
919
1057
  # Check if it's a simple type or BaseModel
920
1058
  is_model = hasattr(item_type, "model_fields")
921
1059
 
@@ -1060,6 +1198,11 @@ class ListFieldRenderer(BaseFieldRenderer):
1060
1198
  prefix=name_prefix,
1061
1199
  disabled=self.disabled, # Propagate disabled state
1062
1200
  spacing=self.spacing, # Propagate spacing
1201
+ field_path=self.field_path
1202
+ + [
1203
+ str(idx),
1204
+ nested_field_name,
1205
+ ], # Propagate path with index
1063
1206
  )
1064
1207
 
1065
1208
  # Add rendered field to valid fields
@@ -1095,21 +1238,23 @@ class ListFieldRenderer(BaseFieldRenderer):
1095
1238
  prefix=self.prefix, # Correct: Provide the form prefix
1096
1239
  disabled=self.disabled, # Propagate disabled state
1097
1240
  spacing=self.spacing, # Propagate spacing
1241
+ field_path=self.field_path
1242
+ + [str(idx)], # Propagate path with index
1098
1243
  )
1099
1244
  input_element = simple_renderer.render_input()
1100
1245
  item_content_elements.append(fh.Div(input_element))
1101
1246
 
1102
1247
  # --- Create action buttons with form-specific URLs ---
1103
- # Generate HTMX endpoints with form name if available
1248
+ # Generate HTMX endpoints using hierarchical paths
1104
1249
  delete_url = (
1105
- f"/form/{form_name}/list/delete/{self.original_field_name}"
1106
- if form_name
1250
+ f"/form/{self._form_name}/list/delete/{self._list_path}"
1251
+ if self._form_name
1107
1252
  else f"/list/delete/{self.field_name}"
1108
1253
  )
1109
1254
 
1110
1255
  add_url = (
1111
- f"/form/{form_name}/list/add/{self.original_field_name}"
1112
- if form_name
1256
+ f"/form/{self._form_name}/list/add/{self._list_path}"
1257
+ if self._form_name
1113
1258
  else f"/list/add/{self.field_name}"
1114
1259
  )
1115
1260
 
@@ -275,7 +275,9 @@ def _parse_nested_model_field(
275
275
  break
276
276
 
277
277
  if found_any_subfield:
278
- # Process each field in the nested model
278
+ # ------------------------------------------------------------------
279
+ # 1. Process each **non-list** field in the nested model
280
+ # ------------------------------------------------------------------
279
281
  for sub_field_name, sub_field_info in nested_model_class.model_fields.items():
280
282
  sub_key = f"{current_prefix}{sub_field_name}"
281
283
  annotation = getattr(sub_field_info, "annotation", None)
@@ -311,6 +313,22 @@ def _parse_nested_model_field(
311
313
  elif is_optional:
312
314
  nested_data[sub_field_name] = None
313
315
 
316
+ # ------------------------------------------------------------------
317
+ # 2. Handle **list fields** inside this nested model (e.g. Address.tags)
318
+ # Re-use the generic helpers so behaviour matches top-level lists.
319
+ # ------------------------------------------------------------------
320
+ nested_list_defs = _identify_list_fields(nested_model_class)
321
+ if nested_list_defs:
322
+ list_results = _parse_list_fields(
323
+ form_data,
324
+ nested_list_defs,
325
+ current_prefix, # ← prefix for this nested model
326
+ )
327
+ # Merge without clobbering keys already set in step 1
328
+ for lf_name, lf_val in list_results.items():
329
+ if lf_name not in nested_data:
330
+ nested_data[lf_name] = lf_val
331
+
314
332
  return nested_data
315
333
 
316
334
  # No data found for this nested model
@@ -331,13 +349,25 @@ def _parse_nested_model_field(
331
349
  default_value = None
332
350
  default_applied = False
333
351
 
334
- if hasattr(field_info, "default") and field_info.default is not None:
352
+ # Import PydanticUndefined to check for it specifically
353
+ try:
354
+ from pydantic_core import PydanticUndefined
355
+ except ImportError:
356
+ # Fallback for older pydantic versions
357
+ from pydantic.fields import PydanticUndefined
358
+
359
+ if (
360
+ hasattr(field_info, "default")
361
+ and field_info.default is not None
362
+ and field_info.default is not PydanticUndefined
363
+ ):
335
364
  default_value = field_info.default
336
365
  default_applied = True
337
366
  logger.debug(f"Nested field {field_name} using default value.")
338
367
  elif (
339
368
  hasattr(field_info, "default_factory")
340
369
  and field_info.default_factory is not None
370
+ and field_info.default_factory is not PydanticUndefined
341
371
  ):
342
372
  try:
343
373
  default_value = field_info.default_factory()
@@ -430,52 +460,23 @@ def _parse_list_fields(
430
460
 
431
461
  items = []
432
462
  for idx_str in ordered_indices:
463
+ # ------------------------------------------------------------------
464
+ # If this list stores *BaseModel* items, completely re-parse the item
465
+ # so that any inner lists (e.g. tags inside Address) become real lists
466
+ # instead of a bunch of 'tags_0', 'tags_new_xxx' flat entries.
467
+ # ------------------------------------------------------------------
468
+ if field_def["is_model_type"]:
469
+ item_prefix = f"{base_prefix}{field_name}_{idx_str}_"
470
+ parsed_item = _parse_model_list_item(form_data, item_type, item_prefix)
471
+ items.append(parsed_item)
472
+ continue
473
+
474
+ # ───────── simple (non-model) items – keep existing logic ──────────
433
475
  item_data = list_items_temp[field_name][idx_str]
434
476
 
435
- # Handle empty strings for optional fields inside models
436
- if isinstance(item_data, dict):
437
- # Check each subfield for optional type and empty string
438
- for subfield_name, subfield_value in list(item_data.items()):
439
- # Get the corresponding field_info from the item_type
440
- if (
441
- hasattr(item_type, "model_fields")
442
- and subfield_name in item_type.model_fields
443
- ):
444
- subfield_info = item_type.model_fields[subfield_name]
445
- # Convert empty strings to None for optional fields
446
- if subfield_value == "" and _is_optional_type(
447
- subfield_info.annotation
448
- ):
449
- item_data[subfield_name] = None
450
- # Convert 'on' to True for boolean fields
451
- elif subfield_value == "on":
452
- annotation = getattr(subfield_info, "annotation", None)
453
- base_type = _get_underlying_type_if_optional(annotation)
454
- if base_type is bool:
455
- item_data[subfield_name] = True
456
-
457
- # Handle missing boolean fields in model list items
458
- if field_def["is_model_type"] and hasattr(item_type, "model_fields"):
459
- # Iterate through all model fields to find missing boolean fields
460
- for (
461
- model_field_name,
462
- model_field_info,
463
- ) in item_type.model_fields.items():
464
- annotation = getattr(model_field_info, "annotation", None)
465
- base_type = _get_underlying_type_if_optional(annotation)
466
- is_bool_type = base_type is bool
467
-
468
- # If it's a boolean field and not in the item_data, set it to False
469
- if is_bool_type and model_field_name not in item_data:
470
- logger.info(
471
- f"Setting missing boolean '{model_field_name}' to False for item in list '{field_name}'"
472
- )
473
- item_data[model_field_name] = False
474
-
475
477
  # Convert string to int for integer-valued enums in simple lists
476
478
  if (
477
- not field_def["is_model_type"]
478
- and isinstance(item_type, type)
479
+ isinstance(item_type, type)
479
480
  and issubclass(item_type, Enum)
480
481
  and isinstance(item_data, str)
481
482
  ):
@@ -491,7 +492,6 @@ def _parse_list_fields(
491
492
  # Empty enum, leave item_data as-is
492
493
  pass
493
494
 
494
- # For model types, keep as dict for now
495
495
  items.append(item_data)
496
496
 
497
497
  if items: # Only add if items were found
@@ -515,6 +515,39 @@ def _parse_list_fields(
515
515
  return final_lists
516
516
 
517
517
 
518
+ def _parse_model_list_item(
519
+ form_data: Dict[str, Any],
520
+ item_type,
521
+ item_prefix: str,
522
+ ) -> Dict[str, Any]:
523
+ """
524
+ Fully parse a single BaseModel list item – including its own nested lists.
525
+
526
+ Re-uses the existing non-list and list helpers so we don't duplicate logic.
527
+
528
+ Args:
529
+ form_data: Dictionary containing form field data
530
+ item_type: The BaseModel class for this list item
531
+ item_prefix: Prefix for this specific list item (e.g., "main_form_compact_other_addresses_0_")
532
+
533
+ Returns:
534
+ Dictionary with fully parsed item data including nested lists
535
+ """
536
+ nested_list_defs = _identify_list_fields(item_type)
537
+ # 1. Parse scalars & nested models
538
+ result = _parse_non_list_fields(
539
+ form_data,
540
+ item_type,
541
+ nested_list_defs,
542
+ base_prefix=item_prefix,
543
+ )
544
+ # 2. Parse inner lists
545
+ result.update(
546
+ _parse_list_fields(form_data, nested_list_defs, base_prefix=item_prefix)
547
+ )
548
+ return result
549
+
550
+
518
551
  def _parse_list_item_key(
519
552
  key: str, list_field_defs: Dict[str, Dict[str, Any]], base_prefix: str = ""
520
553
  ) -> Optional[Tuple[str, str, Optional[str], bool]]: