codeforms 0.2.0__py3-none-any.whl → 0.2.2__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.
codeforms/forms.py CHANGED
@@ -1,9 +1,11 @@
1
+ import json
2
+ import re
3
+ from typing import Any, Type
4
+
1
5
  from codeforms.fields import *
2
6
  from codeforms.fields import FieldGroup, FormStep
3
7
  from codeforms.i18n import t
4
- import json
5
- import re
6
- from typing import Any
8
+
7
9
 
8
10
  class Form(BaseModel):
9
11
  id: UUID = Field(default_factory=uuid4)
@@ -14,8 +16,8 @@ class Form(BaseModel):
14
16
  schema_version: Optional[int] = None # Para compatibilidad entre versiones (RISK-5)
15
17
  attributes: Dict[str, str] = Field(default_factory=dict)
16
18
  action: Optional[str] = None
17
-
18
- @model_validator(mode='before')
19
+
20
+ @model_validator(mode="before")
19
21
  @classmethod
20
22
  def convert_fields_to_content(cls, data: Any) -> Any:
21
23
  """
@@ -25,20 +27,21 @@ class Form(BaseModel):
25
27
  """
26
28
  if isinstance(data, dict):
27
29
  # Si tiene 'fields' pero no 'content', convertir
28
- if 'fields' in data and 'content' not in data:
30
+ if "fields" in data and "content" not in data:
29
31
  data = data.copy()
30
- data['content'] = data.pop('fields')
32
+ data["content"] = data.pop("fields")
31
33
  # Si tiene ambos, 'content' tiene prioridad
32
- elif 'fields' in data and 'content' in data:
34
+ elif "fields" in data and "content" in data:
33
35
  data = data.copy()
34
- data.pop('fields') # Remover 'fields' redundante
36
+ data.pop("fields") # Remover 'fields' redundante
35
37
 
36
38
  # Resolver cada item del contenido usando el registry
37
- if 'content' in data:
39
+ if "content" in data:
38
40
  from codeforms.registry import resolve_content_item
41
+
39
42
  data = data.copy() if data is not data else data
40
- data['content'] = [
41
- resolve_content_item(item) for item in data['content']
43
+ data["content"] = [
44
+ resolve_content_item(item) for item in data["content"]
42
45
  ]
43
46
  return data
44
47
 
@@ -51,9 +54,9 @@ class Form(BaseModel):
51
54
  """
52
55
  all_fields = []
53
56
  for item in self.content:
54
- if isinstance(item, FormStep): # Es un FormStep (wizard)
55
- all_fields.extend(item.fields)
56
- elif isinstance(item, FieldGroup): # Es un FieldGroup
57
+ if isinstance(item, FormStep) or isinstance(
58
+ item, FieldGroup
59
+ ): # Es un FormStep (wizard)
57
60
  all_fields.extend(item.fields)
58
61
  else: # Es un campo individual
59
62
  all_fields.append(item)
@@ -67,7 +70,7 @@ class Form(BaseModel):
67
70
  return Form.model_validate(form)
68
71
 
69
72
  @classmethod
70
- def create_from_fields(cls, name: str, fields: List, **kwargs) -> 'Form':
73
+ def create_from_fields(cls, name: str, fields: List, **kwargs) -> "Form":
71
74
  """
72
75
  Método de conveniencia para crear un formulario usando la estructura anterior
73
76
  donde se pasaba directamente una lista de campos.
@@ -75,11 +78,11 @@ class Form(BaseModel):
75
78
  """
76
79
  return cls(name=name, content=fields, **kwargs)
77
80
 
78
- def to_dict(self, exclude_none: bool=True) -> Dict[str, Any]:
81
+ def to_dict(self, exclude_none: bool = True) -> Dict[str, Any]:
79
82
  return json.loads(self.model_dump_json(exclude_none=exclude_none))
80
83
 
81
- @model_validator(mode='after')
82
- def validate_field_names(self) -> 'Form':
84
+ @model_validator(mode="after")
85
+ def validate_field_names(self) -> "Form":
83
86
  """Valida que todos los nombres de campos sean únicos en todo el formulario"""
84
87
  names = [field.name for field in self.fields]
85
88
  if len(names) != len(set(names)):
@@ -96,10 +99,12 @@ class Form(BaseModel):
96
99
 
97
100
  # Validar campo requerido
98
101
  if field.required and field_value is None:
99
- errors.append({
100
- "field": field.name,
101
- "message": t("field.required_named", name=field.name)
102
- })
102
+ errors.append(
103
+ {
104
+ "field": field.name,
105
+ "message": t("field.required_named", name=field.name),
106
+ }
107
+ )
103
108
  continue
104
109
 
105
110
  # Validar según el tipo de campo
@@ -114,69 +119,129 @@ class Form(BaseModel):
114
119
  try:
115
120
  num_value = float(field_value)
116
121
  if field.min_value is not None and num_value < field.min_value:
117
- errors.append({"field": field.name, "message": t("number.min_value", min=field.min_value)})
122
+ errors.append(
123
+ {
124
+ "field": field.name,
125
+ "message": t("number.min_value", min=field.min_value),
126
+ }
127
+ )
118
128
  if field.max_value is not None and num_value > field.max_value:
119
- errors.append({"field": field.name, "message": t("number.max_value", max=field.max_value)})
129
+ errors.append(
130
+ {
131
+ "field": field.name,
132
+ "message": t("number.max_value", max=field.max_value),
133
+ }
134
+ )
120
135
  except (ValueError, TypeError):
121
136
  errors.append({"field": field.name, "message": t("number.invalid")})
122
137
  elif isinstance(field, DateField):
123
138
  try:
124
139
  date_value = date.fromisoformat(field_value)
125
140
  if field.min_date is not None and date_value < field.min_date:
126
- errors.append({"field": field.name, "message": t("date.min_date", min=field.min_date)})
141
+ errors.append(
142
+ {
143
+ "field": field.name,
144
+ "message": t("date.min_date", min=field.min_date),
145
+ }
146
+ )
127
147
  if field.max_date is not None and date_value > field.max_date:
128
- errors.append({"field": field.name, "message": t("date.max_date", max=field.max_date)})
148
+ errors.append(
149
+ {
150
+ "field": field.name,
151
+ "message": t("date.max_date", max=field.max_date),
152
+ }
153
+ )
129
154
  except (ValueError, TypeError):
130
- errors.append({"field": field.name, "message": t("date.invalid_format")})
155
+ errors.append(
156
+ {"field": field.name, "message": t("date.invalid_format")}
157
+ )
131
158
  elif isinstance(field, SelectField):
132
159
  valid_options = [opt.value for opt in field.options]
133
160
  if field.multiple:
134
- if not isinstance(field_value, list) or not all(v in valid_options for v in field_value):
135
- errors.append({"field": field.name, "message": t("select.invalid_options")})
161
+ if not isinstance(field_value, list) or not all(
162
+ v in valid_options for v in field_value
163
+ ):
164
+ errors.append(
165
+ {
166
+ "field": field.name,
167
+ "message": t("select.invalid_options"),
168
+ }
169
+ )
136
170
  elif field_value not in valid_options:
137
- errors.append({"field": field.name, "message": t("select.invalid_option")})
171
+ errors.append(
172
+ {"field": field.name, "message": t("select.invalid_option")}
173
+ )
138
174
  elif isinstance(field, RadioField):
139
175
  if field_value not in [opt.value for opt in field.options]:
140
- errors.append({"field": field.name, "message": t("radio.invalid_option")})
176
+ errors.append(
177
+ {"field": field.name, "message": t("radio.invalid_option")}
178
+ )
141
179
  elif isinstance(field, CheckboxField):
142
180
  if not isinstance(field_value, bool):
143
- errors.append({"field": field.name, "message": t("checkbox.must_be_boolean")})
181
+ errors.append(
182
+ {"field": field.name, "message": t("checkbox.must_be_boolean")}
183
+ )
144
184
  elif isinstance(field, CheckboxGroupField):
145
- if not isinstance(field_value, list) or not all(v in [opt.value for opt in field.options] for v in field_value):
146
- errors.append({"field": field.name, "message": t("checkbox_group.invalid_options")})
185
+ if not isinstance(field_value, list) or not all(
186
+ v in [opt.value for opt in field.options] for v in field_value
187
+ ):
188
+ errors.append(
189
+ {
190
+ "field": field.name,
191
+ "message": t("checkbox_group.invalid_options"),
192
+ }
193
+ )
194
+ elif isinstance(field, ListField):
195
+ value, field_errors = _validate_list_field_value(field, field_value)
196
+ if field_errors:
197
+ errors.extend(field_errors)
198
+ else:
199
+ validated_data[field.name] = value
200
+ elif isinstance(field, ObjectListField):
201
+ value, field_errors = _validate_object_list_field_value(
202
+ field, field_value
203
+ )
204
+ if field_errors:
205
+ errors.extend(field_errors)
206
+ else:
207
+ validated_data[field.name] = value
147
208
 
148
- if not errors:
209
+ if not errors and field.name not in validated_data:
149
210
  validated_data[field.name] = field_value
150
211
 
151
212
  return {
152
213
  "success": len(errors) == 0,
153
214
  "data": validated_data if not errors else None,
154
215
  "errors": errors,
155
- "message": t("form.validation_success") if not errors else t("form.validation_error")
216
+ "message": t("form.validation_success")
217
+ if not errors
218
+ else t("form.validation_error"),
156
219
  }
157
220
 
158
- def export(self, output_format: str = 'html', **kwargs) -> dict:
221
+ def export(self, output_format: str = "html", **kwargs) -> dict:
159
222
  from codeforms.export import ExportFormat
160
- export_result = {'format': output_format}
223
+
224
+ export_result = {"format": output_format}
161
225
  if output_format in [format.value for format in ExportFormat]:
162
226
  from codeforms.export import exporter
227
+
163
228
  export_result = exporter(self, output_format=output_format, **kwargs)
164
229
 
165
- elif output_format == 'dict':
166
- export_result['output'] = self.to_dict()
230
+ elif output_format == "dict":
231
+ export_result["output"] = self.to_dict()
232
+
233
+ elif output_format == "json":
234
+ export_result["output"] = self.model_dump_json()
167
235
 
168
- elif output_format == 'json':
169
- export_result['output'] = self.model_dump_json()
170
-
171
236
  else:
172
237
  raise ValueError(f"Unsupported export format: {output_format}")
173
-
238
+
174
239
  return export_result
175
240
 
176
241
  def to_json(self) -> str:
177
- return self.export(output_format='json').get('output')
242
+ return self.export(output_format="json").get("output")
178
243
 
179
- def set_default_values(self, data: Dict[str, Any]) -> 'Form':
244
+ def set_default_values(self, data: Dict[str, Any]) -> "Form":
180
245
  """Establece los valores por defecto para los campos del formulario"""
181
246
  for field in self.fields:
182
247
  field.default_value = data.get(field.name)
@@ -191,8 +256,9 @@ class Form(BaseModel):
191
256
  """Retorna solo los campos visibles según las reglas visible_when."""
192
257
  return [f for f in self.fields if evaluate_visibility(f, data)]
193
258
 
194
- def validate_step(self, step_index: int, data: Dict[str, Any],
195
- respect_visibility: bool = True) -> Dict[str, Any]:
259
+ def validate_step(
260
+ self, step_index: int, data: Dict[str, Any], respect_visibility: bool = True
261
+ ) -> Dict[str, Any]:
196
262
  """Valida un paso específico del wizard.
197
263
 
198
264
  Args:
@@ -210,16 +276,16 @@ class Form(BaseModel):
210
276
  if not steps:
211
277
  raise ValueError(t("wizard.not_a_wizard_form"))
212
278
  if not (0 <= step_index < len(steps)):
213
- raise ValueError(t("wizard.invalid_step_index",
214
- index=step_index, max=len(steps) - 1))
279
+ raise ValueError(
280
+ t("wizard.invalid_step_index", index=step_index, max=len(steps) - 1)
281
+ )
215
282
  return validate_form_data_dynamic(
216
- self, data,
217
- respect_visibility=respect_visibility,
218
- current_step=step_index
283
+ self, data, respect_visibility=respect_visibility, current_step=step_index
219
284
  )
220
285
 
221
- def validate_all_steps(self, data: Dict[str, Any],
222
- respect_visibility: bool = True) -> Dict[str, Any]:
286
+ def validate_all_steps(
287
+ self, data: Dict[str, Any], respect_visibility: bool = True
288
+ ) -> Dict[str, Any]:
223
289
  """Valida todos los pasos del wizard secuencialmente.
224
290
 
225
291
  Returns:
@@ -247,7 +313,9 @@ class Form(BaseModel):
247
313
  "data": all_validated_data if success else None,
248
314
  "errors": all_errors if all_errors else [],
249
315
  "step_errors": step_errors if step_errors else None,
250
- "message": t("form.validation_success") if success else t("wizard.validation_failed")
316
+ "message": t("form.validation_success")
317
+ if success
318
+ else t("wizard.validation_failed"),
251
319
  }
252
320
 
253
321
 
@@ -270,19 +338,23 @@ class FormDataModel(BaseModel):
270
338
  fields[field.name] = (float, ... if field.required else None)
271
339
  elif isinstance(field, DateField):
272
340
  fields[field.name] = (date, ... if field.required else None)
341
+ elif isinstance(field, ListField):
342
+ fields[field.name] = (List[Any], ... if field.required else [])
343
+ elif isinstance(field, ObjectListField):
344
+ fields[field.name] = (List[Dict[str, Any]], ... if field.required else [])
273
345
  else:
274
346
  fields[field.name] = (str, ... if field.required else None)
275
347
 
276
348
  # Crear el modelo dinámicamente
277
349
  self.__class__ = type(
278
- 'DynamicFormData',
350
+ "DynamicFormData",
279
351
  (BaseModel,),
280
352
  {
281
- '__annotations__': fields,
282
- 'model_config': {
283
- 'extra': 'forbid' # No permitir campos extra
284
- }
285
- }
353
+ "__annotations__": fields,
354
+ "model_config": {
355
+ "extra": "forbid" # No permitir campos extra
356
+ },
357
+ },
286
358
  )
287
359
  super().__init__()
288
360
 
@@ -302,9 +374,11 @@ class FormDataValidator:
302
374
  field_type = List[str]
303
375
 
304
376
  # Crear validador para la lista de valores
305
- def create_validator(valid_values=valid_values,
306
- min_selected=field.min_selected,
307
- max_selected=field.max_selected):
377
+ def create_validator(
378
+ valid_values=valid_values,
379
+ min_selected=field.min_selected,
380
+ max_selected=field.max_selected,
381
+ ):
308
382
  def validate_select_values(v: List[str]) -> List[str]:
309
383
  if not v and field.required:
310
384
  raise ValueError(t("field.required"))
@@ -313,7 +387,10 @@ class FormDataValidator:
313
387
  invalid_values = set(v) - valid_values
314
388
  if invalid_values:
315
389
  raise ValueError(
316
- t("select.invalid_values", values=', '.join(invalid_values))
390
+ t(
391
+ "select.invalid_values",
392
+ values=", ".join(invalid_values),
393
+ )
317
394
  )
318
395
 
319
396
  # Validar cantidad mínima de selecciones
@@ -345,7 +422,10 @@ class FormDataValidator:
345
422
  raise ValueError(t("field.required"))
346
423
  if v not in valid_values:
347
424
  raise ValueError(
348
- t("select.invalid_value_must_be_one_of", valid=', '.join(valid_values))
425
+ t(
426
+ "select.invalid_value_must_be_one_of",
427
+ valid=", ".join(valid_values),
428
+ )
349
429
  )
350
430
  return v
351
431
 
@@ -364,6 +444,10 @@ class FormDataValidator:
364
444
  field_type = float
365
445
  elif isinstance(field, DateField):
366
446
  field_type = date
447
+ elif isinstance(field, ListField):
448
+ field_type = List[Any]
449
+ elif isinstance(field, ObjectListField):
450
+ field_type = List[Dict[str, Any]]
367
451
  else:
368
452
  field_type = str
369
453
 
@@ -372,9 +456,11 @@ class FormDataValidator:
372
456
  fields[field.name] = Field(..., description=field.help_text)
373
457
  else:
374
458
  default_value = None
375
- if field_type == List[str]:
459
+ if field_type in (List[str], List[Any], List[Dict[str, Any]]):
376
460
  default_value = []
377
- fields[field.name] = Field(default=default_value, description=field.help_text)
461
+ fields[field.name] = Field(
462
+ default=default_value, description=field.help_text
463
+ )
378
464
 
379
465
  annotations[field.name] = field_type
380
466
 
@@ -384,213 +470,150 @@ class FormDataValidator:
384
470
  model_name,
385
471
  (BaseModel,),
386
472
  {
387
- '__annotations__': annotations,
473
+ "__annotations__": annotations,
388
474
  **fields,
389
475
  **validations,
390
- 'model_config': ConfigDict(
391
- arbitrary_types_allowed=True,
392
- extra='forbid'
393
- )
394
- }
476
+ "model_config": ConfigDict(
477
+ arbitrary_types_allowed=True, extra="forbid"
478
+ ),
479
+ },
395
480
  )
396
481
 
397
482
  return model
398
483
 
399
484
 
485
+ def _make_error(field_name: str, message: str) -> dict:
486
+ return {"field": field_name, "message": message}
487
+
488
+
489
+ def _validate_primitive_list_item(item_type: str, value: Any) -> tuple[Any, Optional[str]]:
490
+ if item_type == "number":
491
+ try:
492
+ return float(value), None
493
+ except (TypeError, ValueError):
494
+ return None, t("number.invalid")
495
+
496
+ if item_type == "email":
497
+ if not re.match(r"[^@]+@[^@]+\.[^@]+", str(value)):
498
+ return None, t("email.invalid")
499
+ return str(value), None
500
+
501
+ if item_type == "url":
502
+ if not isinstance(value, str) or not value.startswith(("http://", "https://")):
503
+ return None, t("url.invalid_scheme")
504
+ return value, None
505
+
506
+ if item_type == "date":
507
+ try:
508
+ return date.fromisoformat(str(value)).isoformat(), None
509
+ except (TypeError, ValueError):
510
+ return None, t("date.invalid_format")
511
+
512
+ if not isinstance(value, str):
513
+ value = str(value)
514
+ return value, None
515
+
516
+
517
+ def _validate_list_field_value(field: ListField, field_value: Any, field_path: Optional[str] = None) -> tuple[Any, List[dict]]:
518
+ field_name = field_path or field.name
519
+ if isinstance(field_value, str):
520
+ field_value = [field_value]
521
+
522
+ if not isinstance(field_value, list):
523
+ return None, [_make_error(field_name, t("select.value_must_be_list"))]
524
+
525
+ if field.min_items is not None and len(field_value) < field.min_items:
526
+ return None, [_make_error(field_name, f"Expected at least {field.min_items} items")]
527
+
528
+ if field.max_items is not None and len(field_value) > field.max_items:
529
+ return None, [_make_error(field_name, f"Expected at most {field.max_items} items")]
530
+
531
+ validated_items = []
532
+ errors = []
533
+ for index, item in enumerate(field_value):
534
+ validated_item, error_message = _validate_primitive_list_item(field.item_type, item)
535
+ if error_message:
536
+ errors.append(_make_error(f"{field_name}[{index}]", error_message))
537
+ else:
538
+ validated_items.append(validated_item)
539
+
540
+ return (validated_items if not errors else None), errors
541
+
542
+
543
+ def _validate_object_list_field_value(field: ObjectListField, field_value: Any, field_path: Optional[str] = None) -> tuple[Any, List[dict]]:
544
+ field_name = field_path or field.name
545
+ if not isinstance(field_value, list):
546
+ return None, [_make_error(field_name, t("select.value_must_be_list"))]
547
+
548
+ if field.min_items is not None and len(field_value) < field.min_items:
549
+ return None, [_make_error(field_name, f"Expected at least {field.min_items} items")]
550
+
551
+ if field.max_items is not None and len(field_value) > field.max_items:
552
+ return None, [_make_error(field_name, f"Expected at most {field.max_items} items")]
553
+
554
+ allowed_fields = {subfield.name for subfield in field.fields}
555
+ validated_items = []
556
+ errors = []
557
+
558
+ for index, item in enumerate(field_value):
559
+ item_path = f"{field_name}[{index}]"
560
+ if not isinstance(item, dict):
561
+ errors.append(_make_error(item_path, "Each item must be an object"))
562
+ continue
563
+
564
+ extra_fields = sorted(set(item.keys()) - allowed_fields)
565
+ if extra_fields:
566
+ errors.append(_make_error(item_path, f"Unknown fields: {', '.join(extra_fields)}"))
567
+
568
+ validated_item = {}
569
+ item_errors = []
570
+ for subfield in field.fields:
571
+ subvalue = item.get(subfield.name)
572
+ validated_value, suberrors = _validate_field_value(
573
+ subfield,
574
+ subvalue,
575
+ item,
576
+ field_path=f"{item_path}.{subfield.name}",
577
+ )
578
+ if suberrors:
579
+ item_errors.extend(suberrors)
580
+ elif validated_value is not None:
581
+ validated_item[subfield.name] = validated_value
582
+
583
+ if item_errors:
584
+ errors.extend(item_errors)
585
+ else:
586
+ validated_items.append(validated_item)
587
+
588
+ return (validated_items if not errors else None), errors
589
+
590
+
400
591
  def validate_form_data(form: Form, data: Dict[str, Any]) -> Dict[str, Any]:
401
592
  try:
402
593
  validated_data = {}
403
594
  for field in form.fields:
404
595
  field_value = data.get(field.name)
405
-
406
- # Si no hay valor, usar el valor por defecto
407
- if field_value is None:
408
- field_value = field.default_value
409
-
410
- # Validar campo requerido después de considerar el valor por defecto
411
- if field.required and field_value is None:
596
+ value, errors = _validate_field_value(field, field_value, data)
597
+ if errors:
412
598
  return {
413
599
  "success": False,
414
- "errors": [{
415
- "field": field.name,
416
- "message": t("field.required_named", name=field.name)
417
- }],
418
- "message": t("form.data_validation_error")
600
+ "errors": errors,
601
+ "message": t("form.data_validation_error"),
419
602
  }
420
-
421
- # Procesar valores específicos por tipo
422
- if field_value is not None:
423
- # Select validation (debe ir antes de otros tipos)
424
- if field.field_type == FieldType.SELECT:
425
- valid_options = [opt.value for opt in field.options]
426
- if field.multiple:
427
- # Asegurar que field_value sea una lista
428
- if isinstance(field_value, str):
429
- field_value = [field_value]
430
- if not isinstance(field_value, list):
431
- return {
432
- "success": False,
433
- "errors": [{
434
- "field": field.name,
435
- "message": t("select.value_must_be_list")
436
- }],
437
- "message": t("form.data_validation_error")
438
- }
439
- # Validar cada valor en la lista
440
- invalid_values = [v for v in field_value if v not in valid_options]
441
- if invalid_values:
442
- return {
443
- "success": False,
444
- "errors": [{
445
- "field": field.name,
446
- "message": t("select.invalid_values", values=str(invalid_values))
447
- }],
448
- "message": t("form.data_validation_error")
449
- }
450
- else:
451
- # Validar valor único
452
- if field_value not in valid_options:
453
- return {
454
- "success": False,
455
- "errors": [{
456
- "field": field.name,
457
- "message": t("select.invalid_option_value", value=field_value, valid=str(valid_options))
458
- }],
459
- "message": t("form.data_validation_error")
460
- }
461
- validated_data[field.name] = field_value
462
-
463
- # Email validation
464
- elif field.field_type == FieldType.EMAIL:
465
- try:
466
- if not re.match(r"[^@]+@[^@]+\.[^@]+", field_value):
467
- raise ValueError("Invalid email format")
468
- validated_data[field.name] = field_value
469
- except ValueError:
470
- return {
471
- "success": False,
472
- "errors": [{
473
- "field": field.name,
474
- "message": t("email.invalid")
475
- }],
476
- "message": t("form.data_validation_error")
477
- }
478
-
479
- # Checkbox group validation
480
- elif field.field_type == FieldType.CHECKBOX and hasattr(field, 'options'):
481
- valid_options = [opt.value for opt in field.options]
482
-
483
- # Ensure field_value is a list
484
- if isinstance(field_value, str):
485
- field_value = [field_value]
486
-
487
- if not isinstance(field_value, list):
488
- return {
489
- "success": False,
490
- "errors": [{
491
- "field": field.name,
492
- "message": t("select.value_must_be_list")
493
- }],
494
- "message": t("form.data_validation_error")
495
- }
496
-
497
- # Check if all values are valid options
498
- invalid_values = [v for v in field_value if v not in valid_options]
499
- if invalid_values:
500
- return {
501
- "success": False,
502
- "errors": [{
503
- "field": field.name,
504
- "message": t("select.invalid_values", values=str(invalid_values))
505
- }],
506
- "message": t("form.data_validation_error")
507
- }
508
- validated_data[field.name] = field_value
509
-
510
- # Single checkbox validation
511
- elif field.field_type == FieldType.CHECKBOX and not hasattr(field, 'options'):
512
- # Convertir a booleano si es necesario
513
- validated_data[field.name] = bool(field_value)
514
-
515
- # Radio validation
516
- elif field.field_type == FieldType.RADIO:
517
- valid_options = [opt.value for opt in field.options]
518
- if field_value not in valid_options:
519
- return {
520
- "success": False,
521
- "errors": [{
522
- "field": field.name,
523
- "message": t("radio.invalid_option")
524
- }],
525
- "message": t("form.data_validation_error")
526
- }
527
- validated_data[field.name] = field_value
528
-
529
- # Number validation
530
- elif field.field_type == FieldType.NUMBER:
531
- try:
532
- num_value = float(field_value)
533
- if hasattr(field, 'min_value') and field.min_value is not None:
534
- if num_value < field.min_value:
535
- raise ValueError(t("number.min_value", min=field.min_value))
536
- if hasattr(field, 'max_value') and field.max_value is not None:
537
- if num_value > field.max_value:
538
- raise ValueError(t("number.max_value", max=field.max_value))
539
- validated_data[field.name] = num_value
540
- except ValueError as e:
541
- return {
542
- "success": False,
543
- "errors": [{
544
- "field": field.name,
545
- "message": str(e)
546
- }],
547
- "message": t("form.data_validation_error")
548
- }
549
-
550
- # Text validation
551
- elif field.field_type == FieldType.TEXT:
552
- if not isinstance(field_value, str):
553
- field_value = str(field_value)
554
- if hasattr(field, 'minlength') and field.minlength is not None:
555
- if len(field_value) < field.minlength:
556
- return {
557
- "success": False,
558
- "errors": [{
559
- "field": field.name,
560
- "message": t("text.minlength", min=field.minlength)
561
- }],
562
- "message": t("form.data_validation_error")
563
- }
564
- if hasattr(field, 'maxlength') and field.maxlength is not None:
565
- if len(field_value) > field.maxlength:
566
- return {
567
- "success": False,
568
- "errors": [{
569
- "field": field.name,
570
- "message": t("text.maxlength", max=field.maxlength)
571
- }],
572
- "message": t("form.data_validation_error")
573
- }
574
- validated_data[field.name] = field_value
575
-
576
- # Default validation for other types
577
- else:
578
- validated_data[field.name] = field_value
603
+ if value is not None:
604
+ validated_data[field.name] = value
579
605
 
580
606
  return {
581
607
  "success": True,
582
608
  "data": validated_data,
583
- "message": t("form.validation_success")
609
+ "message": t("form.validation_success"),
584
610
  }
585
611
 
586
612
  except Exception as e:
587
613
  return {
588
614
  "success": False,
589
- "errors": [{
590
- "field": "unknown",
591
- "message": str(e)
592
- }],
593
- "message": t("form.data_validation_error")
615
+ "errors": [{"field": "unknown", "message": str(e)}],
616
+ "message": t("form.data_validation_error"),
594
617
  }
595
618
 
596
619
 
@@ -641,26 +664,26 @@ def evaluate_visibility(field: FormFieldBase, data: Dict[str, Any]) -> bool:
641
664
  return True
642
665
 
643
666
 
644
- def _validate_field_value(field: FormFieldBase, field_value: Any,
645
- data: Dict[str, Any]) -> tuple:
667
+ def _validate_field_value(
668
+ field: FormFieldBase, field_value: Any, data: Dict[str, Any], field_path: Optional[str] = None
669
+ ) -> tuple[Any, List[dict]]:
646
670
  """Valida un campo individual y retorna (validated_value, error_dict_or_None).
647
671
 
648
672
  Lógica de validación compartida entre validate_form_data y
649
673
  validate_form_data_dynamic. No modifica la función legacy.
650
674
  """
675
+ field_name = field_path or field.name
676
+
651
677
  # Si no hay valor, usar el valor por defecto
652
678
  if field_value is None:
653
679
  field_value = field.default_value
654
680
 
655
681
  # Validar campo requerido
656
682
  if field.required and field_value is None:
657
- return None, {
658
- "field": field.name,
659
- "message": t("field.required_named", name=field.name)
660
- }
683
+ return None, [_make_error(field_name, t("field.required_named", name=field.name))]
661
684
 
662
685
  if field_value is None:
663
- return field_value, None
686
+ return field_value, []
664
687
 
665
688
  # Select validation
666
689
  if field.field_type == FieldType.SELECT:
@@ -669,97 +692,92 @@ def _validate_field_value(field: FormFieldBase, field_value: Any,
669
692
  if isinstance(field_value, str):
670
693
  field_value = [field_value]
671
694
  if not isinstance(field_value, list):
672
- return None, {"field": field.name,
673
- "message": t("select.value_must_be_list")}
695
+ return None, [_make_error(field_name, t("select.value_must_be_list"))]
674
696
  invalid_values = [v for v in field_value if v not in valid_options]
675
697
  if invalid_values:
676
- return None, {"field": field.name,
677
- "message": t("select.invalid_values",
678
- values=str(invalid_values))}
698
+ return None, [_make_error(field_name, t("select.invalid_values", values=str(invalid_values)))]
679
699
  else:
680
700
  if field_value not in valid_options:
681
- return None, {"field": field.name,
682
- "message": t("select.invalid_option_value",
683
- value=field_value,
684
- valid=str(valid_options))}
685
- return field_value, None
701
+ return None, [_make_error(field_name, t(
702
+ "select.invalid_option_value",
703
+ value=field_value,
704
+ valid=str(valid_options),
705
+ ))]
706
+ return field_value, []
707
+
708
+ # Primitive list validation
709
+ if field.field_type == FieldType.LIST:
710
+ return _validate_list_field_value(field, field_value, field_name)
711
+
712
+ # Object list validation
713
+ if field.field_type == FieldType.OBJECT_LIST:
714
+ return _validate_object_list_field_value(field, field_value, field_name)
686
715
 
687
716
  # Email validation
688
717
  if field.field_type == FieldType.EMAIL:
689
718
  if not re.match(r"[^@]+@[^@]+\.[^@]+", str(field_value)):
690
- return None, {"field": field.name,
691
- "message": t("email.invalid")}
692
- return field_value, None
719
+ return None, [_make_error(field_name, t("email.invalid"))]
720
+ return field_value, []
693
721
 
694
722
  # Checkbox group validation
695
- if field.field_type == FieldType.CHECKBOX and hasattr(field, 'options'):
723
+ if field.field_type == FieldType.CHECKBOX and hasattr(field, "options"):
696
724
  valid_options = [opt.value for opt in field.options]
697
725
  if isinstance(field_value, str):
698
726
  field_value = [field_value]
699
727
  if not isinstance(field_value, list):
700
- return None, {"field": field.name,
701
- "message": t("select.value_must_be_list")}
728
+ return None, [_make_error(field_name, t("select.value_must_be_list"))]
702
729
  invalid_values = [v for v in field_value if v not in valid_options]
703
730
  if invalid_values:
704
- return None, {"field": field.name,
705
- "message": t("select.invalid_values",
706
- values=str(invalid_values))}
707
- return field_value, None
731
+ return None, [_make_error(field_name, t("select.invalid_values", values=str(invalid_values)))]
732
+ return field_value, []
708
733
 
709
734
  # Single checkbox validation
710
- if field.field_type == FieldType.CHECKBOX and not hasattr(field, 'options'):
735
+ if field.field_type == FieldType.CHECKBOX and not hasattr(field, "options"):
711
736
  return bool(field_value), None
712
737
 
713
738
  # Radio validation
714
739
  if field.field_type == FieldType.RADIO:
715
740
  valid_options = [opt.value for opt in field.options]
716
741
  if field_value not in valid_options:
717
- return None, {"field": field.name,
718
- "message": t("radio.invalid_option")}
719
- return field_value, None
742
+ return None, [_make_error(field_name, t("radio.invalid_option"))]
743
+ return field_value, []
720
744
 
721
745
  # Number validation
722
746
  if field.field_type == FieldType.NUMBER:
723
747
  try:
724
748
  num_value = float(field_value)
725
- if hasattr(field, 'min_value') and field.min_value is not None:
749
+ if hasattr(field, "min_value") and field.min_value is not None:
726
750
  if num_value < field.min_value:
727
- return None, {"field": field.name,
728
- "message": t("number.min_value",
729
- min=field.min_value)}
730
- if hasattr(field, 'max_value') and field.max_value is not None:
751
+ return None, [_make_error(field_name, t("number.min_value", min=field.min_value))]
752
+ if hasattr(field, "max_value") and field.max_value is not None:
731
753
  if num_value > field.max_value:
732
- return None, {"field": field.name,
733
- "message": t("number.max_value",
734
- max=field.max_value)}
754
+ return None, [_make_error(field_name, t("number.max_value", max=field.max_value))]
735
755
  return num_value, None
736
756
  except (ValueError, TypeError):
737
- return None, {"field": field.name,
738
- "message": t("number.invalid")}
757
+ return None, [_make_error(field_name, t("number.invalid"))]
739
758
 
740
759
  # Text validation
741
760
  if field.field_type == FieldType.TEXT:
742
761
  if not isinstance(field_value, str):
743
762
  field_value = str(field_value)
744
- if hasattr(field, 'minlength') and field.minlength is not None:
763
+ if hasattr(field, "minlength") and field.minlength is not None:
745
764
  if len(field_value) < field.minlength:
746
- return None, {"field": field.name,
747
- "message": t("text.minlength",
748
- min=field.minlength)}
749
- if hasattr(field, 'maxlength') and field.maxlength is not None:
765
+ return None, [_make_error(field_name, t("text.minlength", min=field.minlength))]
766
+ if hasattr(field, "maxlength") and field.maxlength is not None:
750
767
  if len(field_value) > field.maxlength:
751
- return None, {"field": field.name,
752
- "message": t("text.maxlength",
753
- max=field.maxlength)}
754
- return field_value, None
768
+ return None, [_make_error(field_name, t("text.maxlength", max=field.maxlength))]
769
+ return field_value, []
755
770
 
756
771
  # Default: pasar el valor sin validación adicional
757
- return field_value, None
772
+ return field_value, []
758
773
 
759
774
 
760
- def validate_form_data_dynamic(form: 'Form', data: Dict[str, Any],
761
- respect_visibility: bool = True,
762
- current_step: Optional[int] = None) -> Dict[str, Any]:
775
+ def validate_form_data_dynamic(
776
+ form: "Form",
777
+ data: Dict[str, Any],
778
+ respect_visibility: bool = True,
779
+ current_step: Optional[int] = None,
780
+ ) -> Dict[str, Any]:
763
781
  """Validación dinámica con soporte para visible_when y steps.
764
782
 
765
783
  A diferencia de validate_form_data(), esta función:
@@ -789,11 +807,17 @@ def validate_form_data_dynamic(form: 'Form', data: Dict[str, Any],
789
807
  else:
790
808
  return {
791
809
  "success": False,
792
- "errors": [{"field": "unknown",
793
- "message": t("wizard.invalid_step_index",
794
- index=current_step,
795
- max=len(steps) - 1)}],
796
- "message": t("form.data_validation_error")
810
+ "errors": [
811
+ {
812
+ "field": "unknown",
813
+ "message": t(
814
+ "wizard.invalid_step_index",
815
+ index=current_step,
816
+ max=len(steps) - 1,
817
+ ),
818
+ }
819
+ ],
820
+ "message": t("form.data_validation_error"),
797
821
  }
798
822
  else:
799
823
  fields_to_validate = form.fields
@@ -804,10 +828,10 @@ def validate_form_data_dynamic(form: 'Form', data: Dict[str, Any],
804
828
  continue # Campo oculto, no validar
805
829
 
806
830
  field_value = data.get(field.name)
807
- value, error = _validate_field_value(field, field_value, data)
831
+ value, field_errors = _validate_field_value(field, field_value, data)
808
832
 
809
- if error:
810
- errors.append(error)
833
+ if field_errors:
834
+ errors.extend(field_errors)
811
835
  elif value is not None:
812
836
  validated_data[field.name] = value
813
837
 
@@ -816,13 +840,14 @@ def validate_form_data_dynamic(form: 'Form', data: Dict[str, Any],
816
840
  "success": success,
817
841
  "data": validated_data if success else None,
818
842
  "errors": errors if errors else [],
819
- "message": t("form.validation_success") if success else t("form.data_validation_error")
843
+ "message": t("form.validation_success")
844
+ if success
845
+ else t("form.data_validation_error"),
820
846
  }
821
847
 
822
848
  except Exception as e:
823
849
  return {
824
850
  "success": False,
825
851
  "errors": [{"field": "unknown", "message": str(e)}],
826
- "message": t("form.data_validation_error")
852
+ "message": t("form.data_validation_error"),
827
853
  }
828
-