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/__init__.py +25 -22
- codeforms/export.py +354 -101
- codeforms/fields.py +86 -38
- codeforms/forms.py +343 -318
- codeforms/i18n.py +1 -30
- codeforms/registry.py +43 -18
- codeforms-0.2.2.dist-info/METADATA +675 -0
- codeforms-0.2.2.dist-info/RECORD +10 -0
- {codeforms-0.2.0.dist-info → codeforms-0.2.2.dist-info}/WHEEL +1 -1
- codeforms-0.2.0.dist-info/METADATA +0 -325
- codeforms-0.2.0.dist-info/RECORD +0 -10
- {codeforms-0.2.0.dist-info → codeforms-0.2.2.dist-info}/licenses/LICENSE +0 -0
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
|
-
|
|
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=
|
|
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
|
|
30
|
+
if "fields" in data and "content" not in data:
|
|
29
31
|
data = data.copy()
|
|
30
|
-
data[
|
|
32
|
+
data["content"] = data.pop("fields")
|
|
31
33
|
# Si tiene ambos, 'content' tiene prioridad
|
|
32
|
-
elif
|
|
34
|
+
elif "fields" in data and "content" in data:
|
|
33
35
|
data = data.copy()
|
|
34
|
-
data.pop(
|
|
36
|
+
data.pop("fields") # Remover 'fields' redundante
|
|
35
37
|
|
|
36
38
|
# Resolver cada item del contenido usando el registry
|
|
37
|
-
if
|
|
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[
|
|
41
|
-
resolve_content_item(item) for item in data[
|
|
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)
|
|
55
|
-
|
|
56
|
-
|
|
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) ->
|
|
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=
|
|
82
|
-
def validate_field_names(self) ->
|
|
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
|
-
|
|
101
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
135
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
146
|
-
|
|
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")
|
|
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 =
|
|
221
|
+
def export(self, output_format: str = "html", **kwargs) -> dict:
|
|
159
222
|
from codeforms.export import ExportFormat
|
|
160
|
-
|
|
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 ==
|
|
166
|
-
export_result[
|
|
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=
|
|
242
|
+
return self.export(output_format="json").get("output")
|
|
178
243
|
|
|
179
|
-
def set_default_values(self, data: Dict[str, Any]) ->
|
|
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(
|
|
195
|
-
|
|
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(
|
|
214
|
-
|
|
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(
|
|
222
|
-
|
|
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")
|
|
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
|
-
|
|
350
|
+
"DynamicFormData",
|
|
279
351
|
(BaseModel,),
|
|
280
352
|
{
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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(
|
|
306
|
-
|
|
307
|
-
|
|
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(
|
|
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(
|
|
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
|
|
459
|
+
if field_type in (List[str], List[Any], List[Dict[str, Any]]):
|
|
376
460
|
default_value = []
|
|
377
|
-
fields[field.name] = Field(
|
|
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
|
-
|
|
473
|
+
"__annotations__": annotations,
|
|
388
474
|
**fields,
|
|
389
475
|
**validations,
|
|
390
|
-
|
|
391
|
-
arbitrary_types_allowed=True,
|
|
392
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
645
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
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,
|
|
691
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
705
|
-
|
|
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,
|
|
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,
|
|
718
|
-
|
|
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,
|
|
749
|
+
if hasattr(field, "min_value") and field.min_value is not None:
|
|
726
750
|
if num_value < field.min_value:
|
|
727
|
-
return None,
|
|
728
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
763
|
+
if hasattr(field, "minlength") and field.minlength is not None:
|
|
745
764
|
if len(field_value) < field.minlength:
|
|
746
|
-
return None,
|
|
747
|
-
|
|
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,
|
|
752
|
-
|
|
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,
|
|
772
|
+
return field_value, []
|
|
758
773
|
|
|
759
774
|
|
|
760
|
-
def validate_form_data_dynamic(
|
|
761
|
-
|
|
762
|
-
|
|
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": [
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
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,
|
|
831
|
+
value, field_errors = _validate_field_value(field, field_value, data)
|
|
808
832
|
|
|
809
|
-
if
|
|
810
|
-
errors.
|
|
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")
|
|
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
|
-
|