fh-pydantic-form 0.1.3__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.
- fh_pydantic_form/__init__.py +38 -3
- fh_pydantic_form/defaults.py +160 -0
- fh_pydantic_form/field_renderers.py +565 -215
- fh_pydantic_form/form_parser.py +151 -43
- fh_pydantic_form/form_renderer.py +321 -161
- fh_pydantic_form/list_path.py +145 -0
- fh_pydantic_form/type_helpers.py +108 -1
- fh_pydantic_form/ui_style.py +134 -0
- fh_pydantic_form-0.2.1.dist-info/METADATA +675 -0
- fh_pydantic_form-0.2.1.dist-info/RECORD +14 -0
- fh_pydantic_form-0.1.3.dist-info/METADATA +0 -327
- fh_pydantic_form-0.1.3.dist-info/RECORD +0 -11
- {fh_pydantic_form-0.1.3.dist-info → fh_pydantic_form-0.2.1.dist-info}/WHEEL +0 -0
- {fh_pydantic_form-0.1.3.dist-info → fh_pydantic_form-0.2.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -9,6 +9,7 @@ from typing import (
|
|
|
9
9
|
Tuple,
|
|
10
10
|
Type,
|
|
11
11
|
TypeVar,
|
|
12
|
+
Union,
|
|
12
13
|
)
|
|
13
14
|
|
|
14
15
|
import fasthtml.common as fh
|
|
@@ -16,6 +17,7 @@ import monsterui.all as mui
|
|
|
16
17
|
from fastcore.xml import FT
|
|
17
18
|
from pydantic import BaseModel
|
|
18
19
|
|
|
20
|
+
from fh_pydantic_form.defaults import default_dict_for_model, default_for_annotation
|
|
19
21
|
from fh_pydantic_form.field_renderers import (
|
|
20
22
|
BaseFieldRenderer,
|
|
21
23
|
ListFieldRenderer,
|
|
@@ -26,7 +28,16 @@ from fh_pydantic_form.form_parser import (
|
|
|
26
28
|
_parse_list_fields,
|
|
27
29
|
_parse_non_list_fields,
|
|
28
30
|
)
|
|
31
|
+
from fh_pydantic_form.list_path import walk_path
|
|
29
32
|
from fh_pydantic_form.registry import FieldRendererRegistry
|
|
33
|
+
from fh_pydantic_form.type_helpers import _UNSET, get_default
|
|
34
|
+
from fh_pydantic_form.ui_style import (
|
|
35
|
+
COMPACT_EXTRA_CSS,
|
|
36
|
+
SpacingTheme,
|
|
37
|
+
SpacingValue,
|
|
38
|
+
_normalize_spacing,
|
|
39
|
+
spacing,
|
|
40
|
+
)
|
|
30
41
|
|
|
31
42
|
logger = logging.getLogger(__name__)
|
|
32
43
|
|
|
@@ -181,7 +192,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
181
192
|
|
|
182
193
|
class PydanticForm(Generic[ModelType]):
|
|
183
194
|
"""
|
|
184
|
-
Renders a form from a Pydantic model class
|
|
195
|
+
Renders a form from a Pydantic model class with robust schema drift handling
|
|
196
|
+
|
|
197
|
+
Accepts initial values as either BaseModel instances or dictionaries.
|
|
198
|
+
Gracefully handles missing fields and schema mismatches by rendering
|
|
199
|
+
available fields and skipping problematic ones.
|
|
185
200
|
|
|
186
201
|
This class handles:
|
|
187
202
|
- Finding appropriate renderers for each field
|
|
@@ -194,15 +209,68 @@ class PydanticForm(Generic[ModelType]):
|
|
|
194
209
|
- validating request data against the model
|
|
195
210
|
"""
|
|
196
211
|
|
|
212
|
+
# --- module-level flag (add near top of file) ---
|
|
213
|
+
|
|
214
|
+
def _compact_wrapper(self, inner: FT) -> FT:
|
|
215
|
+
"""
|
|
216
|
+
Wrap inner markup in a namespaced div.
|
|
217
|
+
Auto-inject the compact CSS the *first* time any compact form is rendered.
|
|
218
|
+
"""
|
|
219
|
+
wrapper_cls = "fhpf-wrapper w-full flex-1"
|
|
220
|
+
|
|
221
|
+
if self.spacing != SpacingTheme.COMPACT:
|
|
222
|
+
return fh.Div(inner, cls=wrapper_cls)
|
|
223
|
+
|
|
224
|
+
return fh.Div(
|
|
225
|
+
COMPACT_EXTRA_CSS,
|
|
226
|
+
fh.Div(inner, cls="fhpf-fields fhpf-compact"),
|
|
227
|
+
cls=wrapper_cls,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
def _clone_with_values(self, values: Dict[str, Any]) -> "PydanticForm":
|
|
231
|
+
"""
|
|
232
|
+
Create a copy of this renderer with the same configuration but different values.
|
|
233
|
+
|
|
234
|
+
This preserves all constructor arguments (label_colors, custom_renderers, etc.)
|
|
235
|
+
to avoid configuration drift during refresh operations.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
values: New values dictionary to use in the cloned renderer
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
A new PydanticForm instance with identical configuration but updated values
|
|
242
|
+
"""
|
|
243
|
+
# Get custom renderers if they were registered (not stored directly on instance)
|
|
244
|
+
# We'll rely on global registry state being preserved
|
|
245
|
+
|
|
246
|
+
clone = PydanticForm(
|
|
247
|
+
form_name=self.name,
|
|
248
|
+
model_class=self.model_class,
|
|
249
|
+
initial_values=None, # Will be set via values_dict below
|
|
250
|
+
custom_renderers=None, # Registry is global, no need to re-register
|
|
251
|
+
disabled=self.disabled,
|
|
252
|
+
disabled_fields=self.disabled_fields,
|
|
253
|
+
label_colors=self.label_colors,
|
|
254
|
+
exclude_fields=self.exclude_fields,
|
|
255
|
+
spacing=self.spacing,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
# Set the values directly
|
|
259
|
+
clone.values_dict = values
|
|
260
|
+
|
|
261
|
+
return clone
|
|
262
|
+
|
|
197
263
|
def __init__(
|
|
198
264
|
self,
|
|
199
265
|
form_name: str,
|
|
200
266
|
model_class: Type[ModelType],
|
|
201
|
-
initial_values: Optional[ModelType] = None,
|
|
267
|
+
initial_values: Optional[Union[ModelType, Dict[str, Any]]] = None,
|
|
202
268
|
custom_renderers: Optional[List[Tuple[Type, Type[BaseFieldRenderer]]]] = None,
|
|
203
269
|
disabled: bool = False,
|
|
204
270
|
disabled_fields: Optional[List[str]] = None,
|
|
205
271
|
label_colors: Optional[Dict[str, str]] = None,
|
|
272
|
+
exclude_fields: Optional[List[str]] = None,
|
|
273
|
+
spacing: SpacingValue = SpacingTheme.NORMAL,
|
|
206
274
|
):
|
|
207
275
|
"""
|
|
208
276
|
Initialize the form renderer
|
|
@@ -210,22 +278,56 @@ class PydanticForm(Generic[ModelType]):
|
|
|
210
278
|
Args:
|
|
211
279
|
form_name: Unique name for this form
|
|
212
280
|
model_class: The Pydantic model class to render
|
|
213
|
-
initial_values:
|
|
281
|
+
initial_values: Initial values as BaseModel instance or dict.
|
|
282
|
+
Missing fields will not be auto-filled with defaults.
|
|
283
|
+
Supports robust handling of schema drift.
|
|
214
284
|
custom_renderers: Optional list of tuples (field_type, renderer_cls) to register
|
|
215
285
|
disabled: Whether all form inputs should be disabled
|
|
216
286
|
disabled_fields: Optional list of top-level field names to disable specifically
|
|
217
287
|
label_colors: Optional dictionary mapping field names to label colors (CSS color values)
|
|
288
|
+
exclude_fields: Optional list of top-level field names to exclude from the form
|
|
289
|
+
spacing: Spacing theme to use for form layout ("normal", "compact", or SpacingTheme enum)
|
|
218
290
|
"""
|
|
219
291
|
self.name = form_name
|
|
220
292
|
self.model_class = model_class
|
|
221
|
-
|
|
222
|
-
self.
|
|
293
|
+
|
|
294
|
+
self.initial_values_dict: Dict[str, Any] = {}
|
|
295
|
+
|
|
296
|
+
# Store initial values as dict for robustness to schema drift
|
|
297
|
+
if initial_values is None:
|
|
298
|
+
self.initial_values_dict = {}
|
|
299
|
+
elif isinstance(initial_values, dict):
|
|
300
|
+
self.initial_values_dict = initial_values.copy()
|
|
301
|
+
elif hasattr(initial_values, "model_dump"):
|
|
302
|
+
self.initial_values_dict = initial_values.model_dump()
|
|
303
|
+
else:
|
|
304
|
+
# Fallback - attempt dict conversion
|
|
305
|
+
try:
|
|
306
|
+
temp_dict = dict(initial_values)
|
|
307
|
+
model_field_names = set(self.model_class.model_fields.keys())
|
|
308
|
+
# Only accept if all keys are in the model's field names
|
|
309
|
+
if not isinstance(temp_dict, dict) or not set(
|
|
310
|
+
temp_dict.keys()
|
|
311
|
+
).issubset(model_field_names):
|
|
312
|
+
raise ValueError("Converted to dict with keys not in model fields")
|
|
313
|
+
self.initial_values_dict = temp_dict
|
|
314
|
+
except (TypeError, ValueError):
|
|
315
|
+
logger.warning(
|
|
316
|
+
"Could not convert initial_values to dict, using empty dict"
|
|
317
|
+
)
|
|
318
|
+
self.initial_values_dict = {}
|
|
319
|
+
|
|
320
|
+
# Use copy for rendering to avoid mutations
|
|
321
|
+
self.values_dict: Dict[str, Any] = self.initial_values_dict.copy()
|
|
322
|
+
|
|
223
323
|
self.base_prefix = f"{form_name}_"
|
|
224
324
|
self.disabled = disabled
|
|
225
325
|
self.disabled_fields = (
|
|
226
326
|
disabled_fields or []
|
|
227
327
|
) # Store as list for easier checking
|
|
228
328
|
self.label_colors = label_colors or {} # Store label colors mapping
|
|
329
|
+
self.exclude_fields = exclude_fields or [] # Store excluded fields list
|
|
330
|
+
self.spacing = _normalize_spacing(spacing) # Store normalized spacing
|
|
229
331
|
|
|
230
332
|
# Register custom renderers with the global registry if provided
|
|
231
333
|
if custom_renderers:
|
|
@@ -247,11 +349,22 @@ class PydanticForm(Generic[ModelType]):
|
|
|
247
349
|
)
|
|
248
350
|
|
|
249
351
|
for field_name, field_info in self.model_class.model_fields.items():
|
|
250
|
-
#
|
|
352
|
+
# Skip excluded fields
|
|
353
|
+
if field_name in self.exclude_fields:
|
|
354
|
+
logger.debug(f"Skipping excluded field: {field_name}")
|
|
355
|
+
continue
|
|
356
|
+
|
|
357
|
+
# Only use what was explicitly provided in initial values
|
|
251
358
|
initial_value = (
|
|
252
359
|
self.values_dict.get(field_name) if self.values_dict else None
|
|
253
360
|
)
|
|
254
361
|
|
|
362
|
+
# Only use model defaults if field was not provided at all
|
|
363
|
+
# (not if it was provided as None/empty)
|
|
364
|
+
field_was_provided = (
|
|
365
|
+
field_name in self.values_dict if self.values_dict else False
|
|
366
|
+
)
|
|
367
|
+
|
|
255
368
|
# Log the initial value type and a summary for debugging
|
|
256
369
|
if initial_value is not None:
|
|
257
370
|
value_type = type(initial_value).__name__
|
|
@@ -259,26 +372,39 @@ class PydanticForm(Generic[ModelType]):
|
|
|
259
372
|
value_size = f"size={len(initial_value)}"
|
|
260
373
|
else:
|
|
261
374
|
value_size = ""
|
|
262
|
-
logger.debug(
|
|
375
|
+
logger.debug(
|
|
376
|
+
f"Field '{field_name}': {value_type} {value_size} (provided: {field_was_provided})"
|
|
377
|
+
)
|
|
263
378
|
else:
|
|
264
379
|
logger.debug(
|
|
265
|
-
f"Field '{field_name}': None (
|
|
380
|
+
f"Field '{field_name}': None (provided: {field_was_provided})"
|
|
266
381
|
)
|
|
267
382
|
|
|
268
|
-
#
|
|
269
|
-
if
|
|
383
|
+
# Only use defaults if field was not provided at all
|
|
384
|
+
if not field_was_provided:
|
|
385
|
+
# Field not provided - use model defaults
|
|
270
386
|
if field_info.default is not None:
|
|
271
387
|
initial_value = field_info.default
|
|
272
388
|
logger.debug(f" - Using default value for '{field_name}'")
|
|
273
389
|
elif getattr(field_info, "default_factory", None) is not None:
|
|
274
390
|
try:
|
|
275
|
-
|
|
276
|
-
|
|
391
|
+
default_factory = field_info.default_factory
|
|
392
|
+
if callable(default_factory):
|
|
393
|
+
initial_value = default_factory()
|
|
394
|
+
logger.debug(
|
|
395
|
+
f" - Using default_factory for '{field_name}'"
|
|
396
|
+
)
|
|
397
|
+
else:
|
|
398
|
+
initial_value = None
|
|
399
|
+
logger.warning(
|
|
400
|
+
f" - default_factory for '{field_name}' is not callable"
|
|
401
|
+
)
|
|
277
402
|
except Exception as e:
|
|
278
403
|
initial_value = None
|
|
279
404
|
logger.warning(
|
|
280
405
|
f" - Error in default_factory for '{field_name}': {e}"
|
|
281
406
|
)
|
|
407
|
+
# If field was provided (even as None), respect that value
|
|
282
408
|
|
|
283
409
|
# Get renderer from global registry
|
|
284
410
|
renderer_cls = registry.get_renderer(field_name, field_info)
|
|
@@ -307,21 +433,29 @@ class PydanticForm(Generic[ModelType]):
|
|
|
307
433
|
prefix=self.base_prefix,
|
|
308
434
|
disabled=is_field_disabled, # Pass the calculated disabled state
|
|
309
435
|
label_color=label_color, # Pass the label color if specified
|
|
436
|
+
spacing=self.spacing, # Pass the spacing
|
|
437
|
+
field_path=[field_name], # Set top-level field path
|
|
310
438
|
)
|
|
311
439
|
|
|
312
440
|
rendered_field = renderer.render()
|
|
313
441
|
form_inputs.append(rendered_field)
|
|
314
442
|
|
|
315
443
|
# Create container for inputs, ensuring items stretch to full width
|
|
316
|
-
inputs_container = mui.DivVStacked(
|
|
444
|
+
inputs_container = mui.DivVStacked(
|
|
445
|
+
*form_inputs,
|
|
446
|
+
cls=f"{spacing('stack_gap', self.spacing)} items-stretch",
|
|
447
|
+
)
|
|
317
448
|
|
|
318
449
|
# Define the ID for the wrapper div - this is what the HTMX request targets
|
|
319
450
|
form_content_wrapper_id = f"{self.name}-inputs-wrapper"
|
|
320
451
|
logger.debug(f"Creating form inputs wrapper with ID: {form_content_wrapper_id}")
|
|
321
452
|
|
|
322
|
-
#
|
|
323
|
-
|
|
324
|
-
|
|
453
|
+
# Create the wrapper div and apply compact styling if needed
|
|
454
|
+
wrapped = self._compact_wrapper(
|
|
455
|
+
fh.Div(inputs_container, id=form_content_wrapper_id)
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
return wrapped
|
|
325
459
|
|
|
326
460
|
# ---- Form Renderer Methods (continued) ----
|
|
327
461
|
|
|
@@ -351,23 +485,17 @@ class PydanticForm(Generic[ModelType]):
|
|
|
351
485
|
f"Error parsing form data for refresh on form '{self.name}': {e}",
|
|
352
486
|
exc_info=True,
|
|
353
487
|
)
|
|
354
|
-
# Fallback: Use original initial
|
|
488
|
+
# Fallback: Use original initial values dict if available, otherwise empty dict
|
|
355
489
|
parsed_data = (
|
|
356
|
-
self.
|
|
490
|
+
self.initial_values_dict.copy() if self.initial_values_dict else {}
|
|
357
491
|
)
|
|
358
492
|
alert_ft = mui.Alert(
|
|
359
493
|
f"Warning: Could not fully process current form values for refresh. Display might not be fully updated. Error: {str(e)}",
|
|
360
494
|
cls=mui.AlertT.warning + " mb-4", # Add margin bottom
|
|
361
495
|
)
|
|
362
496
|
|
|
363
|
-
# Create
|
|
364
|
-
temp_renderer =
|
|
365
|
-
form_name=self.name,
|
|
366
|
-
model_class=self.model_class,
|
|
367
|
-
# No initial_data needed here, we set values_dict below
|
|
368
|
-
)
|
|
369
|
-
# Set the values based on the parsed (or fallback) data
|
|
370
|
-
temp_renderer.values_dict = parsed_data
|
|
497
|
+
# Create temporary renderer with same configuration but updated values
|
|
498
|
+
temp_renderer = self._clone_with_values(parsed_data)
|
|
371
499
|
|
|
372
500
|
refreshed_inputs_component = temp_renderer.render_inputs()
|
|
373
501
|
|
|
@@ -395,27 +523,28 @@ class PydanticForm(Generic[ModelType]):
|
|
|
395
523
|
Returns:
|
|
396
524
|
HTML response with reset form inputs
|
|
397
525
|
"""
|
|
398
|
-
logger.info(
|
|
399
|
-
f"Resetting form '{self.name}' to initial values. Initial model: {self.initial_data_model}"
|
|
400
|
-
)
|
|
526
|
+
logger.info(f"Resetting form '{self.name}' to initial values")
|
|
401
527
|
|
|
402
|
-
# Create
|
|
528
|
+
# Create temporary renderer with original initial dict
|
|
403
529
|
temp_renderer = PydanticForm(
|
|
404
530
|
form_name=self.name,
|
|
405
531
|
model_class=self.model_class,
|
|
406
|
-
initial_values=self.
|
|
532
|
+
initial_values=self.initial_values_dict, # Use dict instead of BaseModel
|
|
533
|
+
custom_renderers=getattr(self, "custom_renderers", None),
|
|
534
|
+
disabled=self.disabled,
|
|
535
|
+
disabled_fields=self.disabled_fields,
|
|
536
|
+
label_colors=self.label_colors,
|
|
537
|
+
exclude_fields=self.exclude_fields,
|
|
538
|
+
spacing=self.spacing,
|
|
407
539
|
)
|
|
408
540
|
|
|
409
|
-
# Render inputs with the initial data
|
|
410
541
|
reset_inputs_component = temp_renderer.render_inputs()
|
|
411
542
|
|
|
412
543
|
if reset_inputs_component is None:
|
|
413
544
|
logger.error(f"Reset for form '{self.name}' failed to render inputs.")
|
|
414
545
|
return mui.Alert("Error resetting form.", cls=mui.AlertT.error)
|
|
415
546
|
|
|
416
|
-
logger.info(
|
|
417
|
-
f"Reset form '{self.name}' successful. Component: {reset_inputs_component}"
|
|
418
|
-
)
|
|
547
|
+
logger.info(f"Reset form '{self.name}' successful")
|
|
419
548
|
return reset_inputs_component
|
|
420
549
|
|
|
421
550
|
def parse(self, form_dict: Dict[str, Any]) -> Dict[str, Any]:
|
|
@@ -434,20 +563,112 @@ class PydanticForm(Generic[ModelType]):
|
|
|
434
563
|
|
|
435
564
|
list_field_defs = _identify_list_fields(self.model_class)
|
|
436
565
|
|
|
437
|
-
#
|
|
566
|
+
# Filter out excluded fields from list field definitions
|
|
567
|
+
filtered_list_field_defs = {
|
|
568
|
+
field_name: field_def
|
|
569
|
+
for field_name, field_def in list_field_defs.items()
|
|
570
|
+
if field_name not in self.exclude_fields
|
|
571
|
+
}
|
|
438
572
|
|
|
573
|
+
# Parse non-list fields first - pass the base_prefix and exclude_fields
|
|
439
574
|
result = _parse_non_list_fields(
|
|
440
|
-
form_dict,
|
|
575
|
+
form_dict,
|
|
576
|
+
self.model_class,
|
|
577
|
+
list_field_defs,
|
|
578
|
+
self.base_prefix,
|
|
579
|
+
self.exclude_fields,
|
|
441
580
|
)
|
|
442
581
|
|
|
443
582
|
# Parse list fields based on keys present in form_dict - pass the base_prefix
|
|
444
|
-
|
|
583
|
+
# Use filtered list field definitions to skip excluded list fields
|
|
584
|
+
list_results = _parse_list_fields(
|
|
585
|
+
form_dict, filtered_list_field_defs, self.base_prefix
|
|
586
|
+
)
|
|
445
587
|
|
|
446
588
|
# Merge list results into the main result
|
|
447
589
|
result.update(list_results)
|
|
448
590
|
|
|
591
|
+
# Inject defaults for excluded fields before returning
|
|
592
|
+
self._inject_default_values_for_excluded(result)
|
|
593
|
+
|
|
449
594
|
return result
|
|
450
595
|
|
|
596
|
+
def _inject_default_values_for_excluded(
|
|
597
|
+
self, data: Dict[str, Any]
|
|
598
|
+
) -> Dict[str, Any]:
|
|
599
|
+
"""
|
|
600
|
+
Ensures that every field listed in self.exclude_fields is present in data
|
|
601
|
+
if the model defines a default or default_factory, or if initial_values were provided.
|
|
602
|
+
|
|
603
|
+
Also ensures all model fields have appropriate defaults if missing.
|
|
604
|
+
|
|
605
|
+
Priority order:
|
|
606
|
+
1. initial_values (if provided during form creation)
|
|
607
|
+
2. model defaults/default_factory
|
|
608
|
+
|
|
609
|
+
Operates top-level only (exclude_fields spec is top-level names).
|
|
610
|
+
|
|
611
|
+
Args:
|
|
612
|
+
data: Dictionary to modify in-place
|
|
613
|
+
|
|
614
|
+
Returns:
|
|
615
|
+
The same dictionary instance for method chaining
|
|
616
|
+
"""
|
|
617
|
+
# Handle excluded fields first
|
|
618
|
+
for field_name in self.exclude_fields:
|
|
619
|
+
# Skip if already present (e.g., user provided initial_values)
|
|
620
|
+
if field_name in data:
|
|
621
|
+
continue
|
|
622
|
+
|
|
623
|
+
# First priority: check if initial_values_dict has this field
|
|
624
|
+
if field_name in self.initial_values_dict:
|
|
625
|
+
initial_val = self.initial_values_dict[field_name]
|
|
626
|
+
# If the initial value is a BaseModel, convert to dict for consistency
|
|
627
|
+
if hasattr(initial_val, "model_dump"):
|
|
628
|
+
initial_val = initial_val.model_dump()
|
|
629
|
+
data[field_name] = initial_val
|
|
630
|
+
logger.debug(
|
|
631
|
+
f"Injected initial value for excluded field '{field_name}'"
|
|
632
|
+
)
|
|
633
|
+
continue
|
|
634
|
+
|
|
635
|
+
# Second priority: use model defaults
|
|
636
|
+
field_info = self.model_class.model_fields.get(field_name)
|
|
637
|
+
if field_info is None:
|
|
638
|
+
logger.warning(f"exclude_fields contains unknown field '{field_name}'")
|
|
639
|
+
continue
|
|
640
|
+
|
|
641
|
+
default_val = get_default(field_info)
|
|
642
|
+
if default_val is not _UNSET:
|
|
643
|
+
# If the default is a BaseModel, convert to dict for consistency
|
|
644
|
+
if hasattr(default_val, "model_dump"):
|
|
645
|
+
default_val = default_val.model_dump()
|
|
646
|
+
data[field_name] = default_val
|
|
647
|
+
logger.debug(
|
|
648
|
+
f"Injected model default value for excluded field '{field_name}'"
|
|
649
|
+
)
|
|
650
|
+
else:
|
|
651
|
+
# No default → leave missing; validation will surface error
|
|
652
|
+
logger.debug(f"No default found for excluded field '{field_name}'")
|
|
653
|
+
|
|
654
|
+
# Also handle any other missing fields that should have defaults
|
|
655
|
+
for field_name, field_info in self.model_class.model_fields.items():
|
|
656
|
+
if field_name not in data:
|
|
657
|
+
# Try to inject defaults for missing fields
|
|
658
|
+
if field_name in self.initial_values_dict:
|
|
659
|
+
initial_val = self.initial_values_dict[field_name]
|
|
660
|
+
if hasattr(initial_val, "model_dump"):
|
|
661
|
+
initial_val = initial_val.model_dump()
|
|
662
|
+
data[field_name] = initial_val
|
|
663
|
+
else:
|
|
664
|
+
default_val = get_default(field_info)
|
|
665
|
+
if default_val is not _UNSET:
|
|
666
|
+
if hasattr(default_val, "model_dump"):
|
|
667
|
+
default_val = default_val.model_dump()
|
|
668
|
+
data[field_name] = default_val
|
|
669
|
+
|
|
670
|
+
return data
|
|
671
|
+
|
|
451
672
|
def register_routes(self, app):
|
|
452
673
|
"""
|
|
453
674
|
Register HTMX routes for list manipulation and form refresh
|
|
@@ -485,143 +706,76 @@ class PydanticForm(Generic[ModelType]):
|
|
|
485
706
|
f"Registered reset route for form '{self.name}' at {reset_route_path}"
|
|
486
707
|
)
|
|
487
708
|
|
|
488
|
-
|
|
489
|
-
|
|
709
|
+
# Try the route with a more explicit pattern
|
|
710
|
+
route_pattern = f"/form/{self.name}/list/{{action}}/{{list_path:path}}"
|
|
711
|
+
logger.debug(f"Registering list action route: {route_pattern}")
|
|
712
|
+
|
|
713
|
+
@app.route(route_pattern, methods=["POST", "DELETE"])
|
|
714
|
+
async def list_action(req, action: str, list_path: str):
|
|
490
715
|
"""
|
|
491
|
-
Handle
|
|
716
|
+
Handle list actions (add/delete) for nested lists in this specific form
|
|
492
717
|
|
|
493
718
|
Args:
|
|
494
719
|
req: The request object
|
|
495
|
-
|
|
720
|
+
action: Either "add" or "delete"
|
|
721
|
+
list_path: Path to the list field (e.g., "tags" or "main_address/tags" or "other_addresses/1/tags")
|
|
496
722
|
|
|
497
723
|
Returns:
|
|
498
|
-
A component for the new list item
|
|
724
|
+
A component for the new list item (add) or empty response (delete)
|
|
499
725
|
"""
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
if field_name in self.model_class.model_fields:
|
|
505
|
-
field_info = self.model_class.model_fields[field_name]
|
|
506
|
-
annotation = getattr(field_info, "annotation", None)
|
|
507
|
-
|
|
508
|
-
if (
|
|
509
|
-
annotation is not None
|
|
510
|
-
and hasattr(annotation, "__origin__")
|
|
511
|
-
and annotation.__origin__ is list
|
|
512
|
-
):
|
|
513
|
-
item_type = annotation.__args__[0]
|
|
514
|
-
|
|
515
|
-
if not item_type:
|
|
516
|
-
logger.error(
|
|
517
|
-
f"Cannot determine item type for list field {field_name}"
|
|
518
|
-
)
|
|
519
|
-
return mui.Alert(
|
|
520
|
-
f"Cannot determine item type for list field {field_name}",
|
|
521
|
-
cls=mui.AlertT.error,
|
|
522
|
-
)
|
|
523
|
-
|
|
524
|
-
# Create a default item
|
|
525
|
-
default_item = None # Initialize default_item
|
|
726
|
+
if action not in {"add", "delete"}:
|
|
727
|
+
return fh.Response(status_code=400, content="Unknown list action")
|
|
728
|
+
|
|
729
|
+
segments = list_path.split("/")
|
|
526
730
|
try:
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
# For Pydantic models, try to use model_construct for default values
|
|
530
|
-
if hasattr(item_type, "model_construct"):
|
|
531
|
-
try:
|
|
532
|
-
default_item = item_type.model_construct()
|
|
533
|
-
except Exception as e:
|
|
534
|
-
logger.error(
|
|
535
|
-
f"Error constructing model for {field_name}: {e}",
|
|
536
|
-
exc_info=True,
|
|
537
|
-
)
|
|
538
|
-
return fh.Li(
|
|
539
|
-
mui.Alert(
|
|
540
|
-
f"Error creating model instance: {str(e)}",
|
|
541
|
-
cls=mui.AlertT.error,
|
|
542
|
-
),
|
|
543
|
-
cls="mb-2",
|
|
544
|
-
)
|
|
545
|
-
# Handle simple types with appropriate defaults
|
|
546
|
-
elif item_type is str:
|
|
547
|
-
default_item = ""
|
|
548
|
-
elif item_type is int:
|
|
549
|
-
default_item = 0
|
|
550
|
-
elif item_type is float:
|
|
551
|
-
default_item = 0.0
|
|
552
|
-
elif item_type is bool:
|
|
553
|
-
default_item = False
|
|
554
|
-
else:
|
|
555
|
-
default_item = None # Other simple types or complex non-models
|
|
556
|
-
else:
|
|
557
|
-
# Case where item_type itself was None (should ideally be caught earlier)
|
|
558
|
-
default_item = None
|
|
559
|
-
logger.warning(
|
|
560
|
-
f"item_type was None when trying to create default for {field_name}"
|
|
561
|
-
)
|
|
562
|
-
except Exception as e:
|
|
563
|
-
logger.error(
|
|
564
|
-
f"Error creating default item for {field_name}: {e}", exc_info=True
|
|
565
|
-
)
|
|
566
|
-
return fh.Li(
|
|
567
|
-
mui.Alert(
|
|
568
|
-
f"Error creating default item: {str(e)}", cls=mui.AlertT.error
|
|
569
|
-
),
|
|
570
|
-
cls="mb-2",
|
|
731
|
+
list_field_info, html_parts, item_type = walk_path(
|
|
732
|
+
self.model_class, segments
|
|
571
733
|
)
|
|
734
|
+
except ValueError as exc:
|
|
735
|
+
logger.warning("Bad list path %s – %s", list_path, exc)
|
|
736
|
+
return mui.Alert(str(exc), cls=mui.AlertT.error)
|
|
572
737
|
|
|
573
|
-
|
|
574
|
-
placeholder_idx = f"new_{int(pytime.time() * 1000)}"
|
|
575
|
-
|
|
576
|
-
# Create a list renderer
|
|
577
|
-
list_renderer = ListFieldRenderer(
|
|
578
|
-
field_name=field_name,
|
|
579
|
-
field_info=field_info,
|
|
580
|
-
value=[], # Empty list, we only need to render one item
|
|
581
|
-
prefix=self.base_prefix, # Use the form's base prefix
|
|
582
|
-
)
|
|
583
|
-
|
|
584
|
-
# Ensure the item data passed to the renderer is a dict if it's a model instance
|
|
585
|
-
item_data_for_renderer = None
|
|
586
|
-
if isinstance(default_item, BaseModel):
|
|
587
|
-
item_data_for_renderer = default_item.model_dump()
|
|
738
|
+
if req.method == "DELETE":
|
|
588
739
|
logger.debug(
|
|
589
|
-
f"
|
|
740
|
+
f"Received DELETE request for {list_path} for form '{self.name}'"
|
|
590
741
|
)
|
|
591
|
-
|
|
592
|
-
item_data_for_renderer = default_item
|
|
593
|
-
logger.debug(
|
|
594
|
-
f"Add item: Passing simple type directly to renderer: {item_data_for_renderer}"
|
|
595
|
-
)
|
|
596
|
-
# else: item_data_for_renderer remains None if default_item was None
|
|
742
|
+
return fh.Response(status_code=200, content="")
|
|
597
743
|
|
|
598
|
-
#
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
item_type
|
|
603
|
-
is_open=True,
|
|
744
|
+
# === add (POST) ===
|
|
745
|
+
default_item = (
|
|
746
|
+
default_dict_for_model(item_type)
|
|
747
|
+
if hasattr(item_type, "model_fields")
|
|
748
|
+
else default_for_annotation(item_type)
|
|
604
749
|
)
|
|
605
750
|
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
751
|
+
# Build prefix **without** the list field itself to avoid duplication
|
|
752
|
+
parts_before_list = html_parts[:-1] # drop final segment
|
|
753
|
+
if parts_before_list:
|
|
754
|
+
html_prefix = f"{self.base_prefix}{'_'.join(parts_before_list)}_"
|
|
755
|
+
else:
|
|
756
|
+
html_prefix = self.base_prefix
|
|
757
|
+
|
|
758
|
+
# Create renderer for the list field
|
|
759
|
+
renderer = ListFieldRenderer(
|
|
760
|
+
field_name=segments[-1],
|
|
761
|
+
field_info=list_field_info,
|
|
762
|
+
value=[],
|
|
763
|
+
prefix=html_prefix,
|
|
764
|
+
spacing=self.spacing,
|
|
765
|
+
disabled=self.disabled,
|
|
766
|
+
field_path=segments, # Pass the full path segments
|
|
767
|
+
form_name=self.name, # Pass the explicit form name
|
|
768
|
+
)
|
|
612
769
|
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
field_name: The name of the list field
|
|
770
|
+
# Generate a unique placeholder index
|
|
771
|
+
placeholder_idx = f"new_{int(pytime.time() * 1000)}"
|
|
616
772
|
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
# Return empty string to delete the target element
|
|
621
|
-
logger.debug(
|
|
622
|
-
f"Received DELETE request for {field_name} for form '{self.name}'"
|
|
773
|
+
# Render the new item card, set is_open=True to make it expanded by default
|
|
774
|
+
new_card = renderer._render_item_card(
|
|
775
|
+
default_item, placeholder_idx, item_type, is_open=True
|
|
623
776
|
)
|
|
624
|
-
|
|
777
|
+
|
|
778
|
+
return new_card
|
|
625
779
|
|
|
626
780
|
def refresh_button(self, text: Optional[str] = None, **kwargs) -> FT:
|
|
627
781
|
"""
|
|
@@ -640,9 +794,6 @@ class PydanticForm(Generic[ModelType]):
|
|
|
640
794
|
# Define the target wrapper ID
|
|
641
795
|
form_content_wrapper_id = f"{self.name}-inputs-wrapper"
|
|
642
796
|
|
|
643
|
-
# Define the form ID to include
|
|
644
|
-
form_id = f"{self.name}-form"
|
|
645
|
-
|
|
646
797
|
# Define the target URL
|
|
647
798
|
refresh_url = f"/form/{self.name}/refresh"
|
|
648
799
|
|
|
@@ -653,7 +804,7 @@ class PydanticForm(Generic[ModelType]):
|
|
|
653
804
|
"hx_target": f"#{form_content_wrapper_id}", # Target the wrapper Div ID
|
|
654
805
|
"hx_swap": "innerHTML",
|
|
655
806
|
"hx_trigger": "click", # Explicit trigger on click
|
|
656
|
-
"hx_include":
|
|
807
|
+
"hx_include": "closest form", # Include all form fields from the enclosing form
|
|
657
808
|
"uk_tooltip": "Update the form display based on current values (e.g., list item titles)",
|
|
658
809
|
"cls": mui.ButtonT.secondary,
|
|
659
810
|
}
|
|
@@ -736,3 +887,12 @@ class PydanticForm(Generic[ModelType]):
|
|
|
736
887
|
logger.info(f"Request validation successful for form '{self.name}'")
|
|
737
888
|
|
|
738
889
|
return validated_model
|
|
890
|
+
|
|
891
|
+
def form_id(self) -> str:
|
|
892
|
+
"""
|
|
893
|
+
Get the standard form ID for this renderer.
|
|
894
|
+
|
|
895
|
+
Returns:
|
|
896
|
+
The form ID string that should be used for the HTML form element
|
|
897
|
+
"""
|
|
898
|
+
return f"{self.name}-form"
|