codeforms 0.2.1__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 +2 -0
- codeforms/export.py +29 -0
- codeforms/fields.py +28 -1
- codeforms/forms.py +171 -266
- codeforms/registry.py +2 -0
- {codeforms-0.2.1.dist-info → codeforms-0.2.2.dist-info}/METADATA +93 -1
- codeforms-0.2.2.dist-info/RECORD +10 -0
- {codeforms-0.2.1.dist-info → codeforms-0.2.2.dist-info}/WHEEL +1 -1
- codeforms-0.2.1.dist-info/RECORD +0 -10
- {codeforms-0.2.1.dist-info → codeforms-0.2.2.dist-info}/licenses/LICENSE +0 -0
codeforms/__init__.py
CHANGED
|
@@ -12,6 +12,7 @@ from codeforms.fields import (
|
|
|
12
12
|
FormStep,
|
|
13
13
|
HiddenField,
|
|
14
14
|
ListField,
|
|
15
|
+
ObjectListField,
|
|
15
16
|
NumberField,
|
|
16
17
|
RadioField,
|
|
17
18
|
SelectField,
|
|
@@ -63,6 +64,7 @@ __all__ = [
|
|
|
63
64
|
"UrlField",
|
|
64
65
|
"TextareaField",
|
|
65
66
|
"ListField",
|
|
67
|
+
"ObjectListField",
|
|
66
68
|
"FieldGroup",
|
|
67
69
|
"FormStep",
|
|
68
70
|
# Form
|
codeforms/export.py
CHANGED
|
@@ -12,6 +12,7 @@ from codeforms.fields import (
|
|
|
12
12
|
FormStep,
|
|
13
13
|
HiddenField,
|
|
14
14
|
ListField,
|
|
15
|
+
ObjectListField,
|
|
15
16
|
NumberField,
|
|
16
17
|
RadioField,
|
|
17
18
|
SelectField,
|
|
@@ -485,6 +486,26 @@ _LIST_ITEM_TYPE_MAP: Dict[str, str] = {
|
|
|
485
486
|
}
|
|
486
487
|
|
|
487
488
|
|
|
489
|
+
def _object_list_item_schema(field: ObjectListField) -> Dict[str, Any]:
|
|
490
|
+
properties: Dict[str, Any] = {}
|
|
491
|
+
required = []
|
|
492
|
+
|
|
493
|
+
for subfield in field.fields:
|
|
494
|
+
properties[subfield.name] = _field_to_json_schema_property(subfield)
|
|
495
|
+
if subfield.required:
|
|
496
|
+
required.append(subfield.name)
|
|
497
|
+
|
|
498
|
+
item_schema: Dict[str, Any] = {
|
|
499
|
+
"type": "object",
|
|
500
|
+
"properties": properties,
|
|
501
|
+
"additionalProperties": False,
|
|
502
|
+
}
|
|
503
|
+
if required:
|
|
504
|
+
item_schema["required"] = required
|
|
505
|
+
|
|
506
|
+
return item_schema
|
|
507
|
+
|
|
508
|
+
|
|
488
509
|
def _field_to_json_schema_property(field: FormFieldBase) -> Dict[str, Any]:
|
|
489
510
|
"""Convert a single form field to a JSON Schema property definition."""
|
|
490
511
|
prop: Dict[str, Any] = {}
|
|
@@ -570,6 +591,14 @@ def _field_to_json_schema_property(field: FormFieldBase) -> Dict[str, Any]:
|
|
|
570
591
|
if field.max_items is not None:
|
|
571
592
|
prop["maxItems"] = field.max_items
|
|
572
593
|
|
|
594
|
+
elif isinstance(field, ObjectListField):
|
|
595
|
+
prop["type"] = "array"
|
|
596
|
+
prop["items"] = _object_list_item_schema(field)
|
|
597
|
+
if field.min_items is not None:
|
|
598
|
+
prop["minItems"] = field.min_items
|
|
599
|
+
if field.max_items is not None:
|
|
600
|
+
prop["maxItems"] = field.max_items
|
|
601
|
+
|
|
573
602
|
elif isinstance(field, TextField):
|
|
574
603
|
prop["type"] = "string"
|
|
575
604
|
if field.minlength is not None:
|
codeforms/fields.py
CHANGED
|
@@ -30,6 +30,7 @@ class FieldType(str, Enum):
|
|
|
30
30
|
HIDDEN = "hidden"
|
|
31
31
|
URL = "url"
|
|
32
32
|
LIST = "list"
|
|
33
|
+
OBJECT_LIST = "object-list"
|
|
33
34
|
|
|
34
35
|
|
|
35
36
|
class ValidationRule(BaseModel):
|
|
@@ -279,7 +280,7 @@ class TextareaField(FormFieldBase):
|
|
|
279
280
|
|
|
280
281
|
|
|
281
282
|
class ListField(FormFieldBase):
|
|
282
|
-
"""Campo para listas de valores (ej: lista de participantes)"""
|
|
283
|
+
"""Campo para listas de valores primitivos (ej: lista de participantes)."""
|
|
283
284
|
|
|
284
285
|
field_type: FieldType = FieldType.LIST
|
|
285
286
|
min_items: Optional[int] = None
|
|
@@ -287,6 +288,32 @@ class ListField(FormFieldBase):
|
|
|
287
288
|
item_type: str = "text" # Tipo de cada item en la lista
|
|
288
289
|
|
|
289
290
|
|
|
291
|
+
class ObjectListField(FormFieldBase):
|
|
292
|
+
"""Campo para listas de objetos homogéneos validados por subcampos."""
|
|
293
|
+
|
|
294
|
+
field_type: FieldType = FieldType.OBJECT_LIST
|
|
295
|
+
min_items: Optional[int] = None
|
|
296
|
+
max_items: Optional[int] = None
|
|
297
|
+
fields: List[Any] = Field(default_factory=list)
|
|
298
|
+
|
|
299
|
+
@model_validator(mode="before")
|
|
300
|
+
@classmethod
|
|
301
|
+
def resolve_object_fields(cls, data: Any) -> Any:
|
|
302
|
+
if isinstance(data, dict) and "fields" in data:
|
|
303
|
+
from codeforms.registry import resolve_content_item
|
|
304
|
+
|
|
305
|
+
data = data.copy()
|
|
306
|
+
data["fields"] = [resolve_content_item(item) for item in data["fields"]]
|
|
307
|
+
return data
|
|
308
|
+
|
|
309
|
+
@model_validator(mode="after")
|
|
310
|
+
def validate_object_fields(self) -> "ObjectListField":
|
|
311
|
+
names = [field.name for field in self.fields]
|
|
312
|
+
if len(names) != len(set(names)):
|
|
313
|
+
raise ValueError(t("form.unique_field_names_in_group", title=self.label or self.name))
|
|
314
|
+
return self
|
|
315
|
+
|
|
316
|
+
|
|
290
317
|
class FieldGroup(BaseModel):
|
|
291
318
|
"""Representa un grupo de campos en un formulario para organización en secciones"""
|
|
292
319
|
|
codeforms/forms.py
CHANGED
|
@@ -191,8 +191,22 @@ class Form(BaseModel):
|
|
|
191
191
|
"message": t("checkbox_group.invalid_options"),
|
|
192
192
|
}
|
|
193
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
|
|
194
208
|
|
|
195
|
-
if not errors:
|
|
209
|
+
if not errors and field.name not in validated_data:
|
|
196
210
|
validated_data[field.name] = field_value
|
|
197
211
|
|
|
198
212
|
return {
|
|
@@ -324,6 +338,10 @@ class FormDataModel(BaseModel):
|
|
|
324
338
|
fields[field.name] = (float, ... if field.required else None)
|
|
325
339
|
elif isinstance(field, DateField):
|
|
326
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 [])
|
|
327
345
|
else:
|
|
328
346
|
fields[field.name] = (str, ... if field.required else None)
|
|
329
347
|
|
|
@@ -426,6 +444,10 @@ class FormDataValidator:
|
|
|
426
444
|
field_type = float
|
|
427
445
|
elif isinstance(field, DateField):
|
|
428
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]]
|
|
429
451
|
else:
|
|
430
452
|
field_type = str
|
|
431
453
|
|
|
@@ -434,7 +456,7 @@ class FormDataValidator:
|
|
|
434
456
|
fields[field.name] = Field(..., description=field.help_text)
|
|
435
457
|
else:
|
|
436
458
|
default_value = None
|
|
437
|
-
if field_type
|
|
459
|
+
if field_type in (List[str], List[Any], List[Dict[str, Any]]):
|
|
438
460
|
default_value = []
|
|
439
461
|
fields[field.name] = Field(
|
|
440
462
|
default=default_value, description=field.help_text
|
|
@@ -460,223 +482,126 @@ class FormDataValidator:
|
|
|
460
482
|
return model
|
|
461
483
|
|
|
462
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
|
+
|
|
463
591
|
def validate_form_data(form: Form, data: Dict[str, Any]) -> Dict[str, Any]:
|
|
464
592
|
try:
|
|
465
593
|
validated_data = {}
|
|
466
594
|
for field in form.fields:
|
|
467
595
|
field_value = data.get(field.name)
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
if field_value is None:
|
|
471
|
-
field_value = field.default_value
|
|
472
|
-
|
|
473
|
-
# Validar campo requerido después de considerar el valor por defecto
|
|
474
|
-
if field.required and field_value is None:
|
|
596
|
+
value, errors = _validate_field_value(field, field_value, data)
|
|
597
|
+
if errors:
|
|
475
598
|
return {
|
|
476
599
|
"success": False,
|
|
477
|
-
"errors":
|
|
478
|
-
{
|
|
479
|
-
"field": field.name,
|
|
480
|
-
"message": t("field.required_named", name=field.name),
|
|
481
|
-
}
|
|
482
|
-
],
|
|
600
|
+
"errors": errors,
|
|
483
601
|
"message": t("form.data_validation_error"),
|
|
484
602
|
}
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
if field_value is not None:
|
|
488
|
-
# Select validation (debe ir antes de otros tipos)
|
|
489
|
-
if field.field_type == FieldType.SELECT:
|
|
490
|
-
valid_options = [opt.value for opt in field.options]
|
|
491
|
-
if field.multiple:
|
|
492
|
-
# Asegurar que field_value sea una lista
|
|
493
|
-
if isinstance(field_value, str):
|
|
494
|
-
field_value = [field_value]
|
|
495
|
-
if not isinstance(field_value, list):
|
|
496
|
-
return {
|
|
497
|
-
"success": False,
|
|
498
|
-
"errors": [
|
|
499
|
-
{
|
|
500
|
-
"field": field.name,
|
|
501
|
-
"message": t("select.value_must_be_list"),
|
|
502
|
-
}
|
|
503
|
-
],
|
|
504
|
-
"message": t("form.data_validation_error"),
|
|
505
|
-
}
|
|
506
|
-
# Validar cada valor en la lista
|
|
507
|
-
invalid_values = [
|
|
508
|
-
v for v in field_value if v not in valid_options
|
|
509
|
-
]
|
|
510
|
-
if invalid_values:
|
|
511
|
-
return {
|
|
512
|
-
"success": False,
|
|
513
|
-
"errors": [
|
|
514
|
-
{
|
|
515
|
-
"field": field.name,
|
|
516
|
-
"message": t(
|
|
517
|
-
"select.invalid_values",
|
|
518
|
-
values=str(invalid_values),
|
|
519
|
-
),
|
|
520
|
-
}
|
|
521
|
-
],
|
|
522
|
-
"message": t("form.data_validation_error"),
|
|
523
|
-
}
|
|
524
|
-
else:
|
|
525
|
-
# Validar valor único
|
|
526
|
-
if field_value not in valid_options:
|
|
527
|
-
return {
|
|
528
|
-
"success": False,
|
|
529
|
-
"errors": [
|
|
530
|
-
{
|
|
531
|
-
"field": field.name,
|
|
532
|
-
"message": t(
|
|
533
|
-
"select.invalid_option_value",
|
|
534
|
-
value=field_value,
|
|
535
|
-
valid=str(valid_options),
|
|
536
|
-
),
|
|
537
|
-
}
|
|
538
|
-
],
|
|
539
|
-
"message": t("form.data_validation_error"),
|
|
540
|
-
}
|
|
541
|
-
validated_data[field.name] = field_value
|
|
542
|
-
|
|
543
|
-
# Email validation
|
|
544
|
-
elif field.field_type == FieldType.EMAIL:
|
|
545
|
-
try:
|
|
546
|
-
if not re.match(r"[^@]+@[^@]+\.[^@]+", field_value):
|
|
547
|
-
raise ValueError("Invalid email format")
|
|
548
|
-
validated_data[field.name] = field_value
|
|
549
|
-
except ValueError:
|
|
550
|
-
return {
|
|
551
|
-
"success": False,
|
|
552
|
-
"errors": [
|
|
553
|
-
{"field": field.name, "message": t("email.invalid")}
|
|
554
|
-
],
|
|
555
|
-
"message": t("form.data_validation_error"),
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
# Checkbox group validation
|
|
559
|
-
elif field.field_type == FieldType.CHECKBOX and hasattr(
|
|
560
|
-
field, "options"
|
|
561
|
-
):
|
|
562
|
-
valid_options = [opt.value for opt in field.options]
|
|
563
|
-
|
|
564
|
-
# Ensure field_value is a list
|
|
565
|
-
if isinstance(field_value, str):
|
|
566
|
-
field_value = [field_value]
|
|
567
|
-
|
|
568
|
-
if not isinstance(field_value, list):
|
|
569
|
-
return {
|
|
570
|
-
"success": False,
|
|
571
|
-
"errors": [
|
|
572
|
-
{
|
|
573
|
-
"field": field.name,
|
|
574
|
-
"message": t("select.value_must_be_list"),
|
|
575
|
-
}
|
|
576
|
-
],
|
|
577
|
-
"message": t("form.data_validation_error"),
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
# Check if all values are valid options
|
|
581
|
-
invalid_values = [v for v in field_value if v not in valid_options]
|
|
582
|
-
if invalid_values:
|
|
583
|
-
return {
|
|
584
|
-
"success": False,
|
|
585
|
-
"errors": [
|
|
586
|
-
{
|
|
587
|
-
"field": field.name,
|
|
588
|
-
"message": t(
|
|
589
|
-
"select.invalid_values",
|
|
590
|
-
values=str(invalid_values),
|
|
591
|
-
),
|
|
592
|
-
}
|
|
593
|
-
],
|
|
594
|
-
"message": t("form.data_validation_error"),
|
|
595
|
-
}
|
|
596
|
-
validated_data[field.name] = field_value
|
|
597
|
-
|
|
598
|
-
# Single checkbox validation
|
|
599
|
-
elif field.field_type == FieldType.CHECKBOX and not hasattr(
|
|
600
|
-
field, "options"
|
|
601
|
-
):
|
|
602
|
-
# Convertir a booleano si es necesario
|
|
603
|
-
validated_data[field.name] = bool(field_value)
|
|
604
|
-
|
|
605
|
-
# Radio validation
|
|
606
|
-
elif field.field_type == FieldType.RADIO:
|
|
607
|
-
valid_options = [opt.value for opt in field.options]
|
|
608
|
-
if field_value not in valid_options:
|
|
609
|
-
return {
|
|
610
|
-
"success": False,
|
|
611
|
-
"errors": [
|
|
612
|
-
{
|
|
613
|
-
"field": field.name,
|
|
614
|
-
"message": t("radio.invalid_option"),
|
|
615
|
-
}
|
|
616
|
-
],
|
|
617
|
-
"message": t("form.data_validation_error"),
|
|
618
|
-
}
|
|
619
|
-
validated_data[field.name] = field_value
|
|
620
|
-
|
|
621
|
-
# Number validation
|
|
622
|
-
elif field.field_type == FieldType.NUMBER:
|
|
623
|
-
try:
|
|
624
|
-
num_value = float(field_value)
|
|
625
|
-
if hasattr(field, "min_value") and field.min_value is not None:
|
|
626
|
-
if num_value < field.min_value:
|
|
627
|
-
raise ValueError(
|
|
628
|
-
t("number.min_value", min=field.min_value)
|
|
629
|
-
)
|
|
630
|
-
if hasattr(field, "max_value") and field.max_value is not None:
|
|
631
|
-
if num_value > field.max_value:
|
|
632
|
-
raise ValueError(
|
|
633
|
-
t("number.max_value", max=field.max_value)
|
|
634
|
-
)
|
|
635
|
-
validated_data[field.name] = num_value
|
|
636
|
-
except ValueError as e:
|
|
637
|
-
return {
|
|
638
|
-
"success": False,
|
|
639
|
-
"errors": [{"field": field.name, "message": str(e)}],
|
|
640
|
-
"message": t("form.data_validation_error"),
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
# Text validation
|
|
644
|
-
elif field.field_type == FieldType.TEXT:
|
|
645
|
-
if not isinstance(field_value, str):
|
|
646
|
-
field_value = str(field_value)
|
|
647
|
-
if hasattr(field, "minlength") and field.minlength is not None:
|
|
648
|
-
if len(field_value) < field.minlength:
|
|
649
|
-
return {
|
|
650
|
-
"success": False,
|
|
651
|
-
"errors": [
|
|
652
|
-
{
|
|
653
|
-
"field": field.name,
|
|
654
|
-
"message": t(
|
|
655
|
-
"text.minlength", min=field.minlength
|
|
656
|
-
),
|
|
657
|
-
}
|
|
658
|
-
],
|
|
659
|
-
"message": t("form.data_validation_error"),
|
|
660
|
-
}
|
|
661
|
-
if hasattr(field, "maxlength") and field.maxlength is not None:
|
|
662
|
-
if len(field_value) > field.maxlength:
|
|
663
|
-
return {
|
|
664
|
-
"success": False,
|
|
665
|
-
"errors": [
|
|
666
|
-
{
|
|
667
|
-
"field": field.name,
|
|
668
|
-
"message": t(
|
|
669
|
-
"text.maxlength", max=field.maxlength
|
|
670
|
-
),
|
|
671
|
-
}
|
|
672
|
-
],
|
|
673
|
-
"message": t("form.data_validation_error"),
|
|
674
|
-
}
|
|
675
|
-
validated_data[field.name] = field_value
|
|
676
|
-
|
|
677
|
-
# Default validation for other types
|
|
678
|
-
else:
|
|
679
|
-
validated_data[field.name] = field_value
|
|
603
|
+
if value is not None:
|
|
604
|
+
validated_data[field.name] = value
|
|
680
605
|
|
|
681
606
|
return {
|
|
682
607
|
"success": True,
|
|
@@ -740,26 +665,25 @@ def evaluate_visibility(field: FormFieldBase, data: Dict[str, Any]) -> bool:
|
|
|
740
665
|
|
|
741
666
|
|
|
742
667
|
def _validate_field_value(
|
|
743
|
-
field: FormFieldBase, field_value: Any, data: Dict[str, Any]
|
|
744
|
-
) -> tuple:
|
|
668
|
+
field: FormFieldBase, field_value: Any, data: Dict[str, Any], field_path: Optional[str] = None
|
|
669
|
+
) -> tuple[Any, List[dict]]:
|
|
745
670
|
"""Valida un campo individual y retorna (validated_value, error_dict_or_None).
|
|
746
671
|
|
|
747
672
|
Lógica de validación compartida entre validate_form_data y
|
|
748
673
|
validate_form_data_dynamic. No modifica la función legacy.
|
|
749
674
|
"""
|
|
675
|
+
field_name = field_path or field.name
|
|
676
|
+
|
|
750
677
|
# Si no hay valor, usar el valor por defecto
|
|
751
678
|
if field_value is None:
|
|
752
679
|
field_value = field.default_value
|
|
753
680
|
|
|
754
681
|
# Validar campo requerido
|
|
755
682
|
if field.required and field_value is None:
|
|
756
|
-
return None,
|
|
757
|
-
"field": field.name,
|
|
758
|
-
"message": t("field.required_named", name=field.name),
|
|
759
|
-
}
|
|
683
|
+
return None, [_make_error(field_name, t("field.required_named", name=field.name))]
|
|
760
684
|
|
|
761
685
|
if field_value is None:
|
|
762
|
-
return field_value,
|
|
686
|
+
return field_value, []
|
|
763
687
|
|
|
764
688
|
# Select validation
|
|
765
689
|
if field.field_type == FieldType.SELECT:
|
|
@@ -768,33 +692,32 @@ def _validate_field_value(
|
|
|
768
692
|
if isinstance(field_value, str):
|
|
769
693
|
field_value = [field_value]
|
|
770
694
|
if not isinstance(field_value, list):
|
|
771
|
-
return None,
|
|
772
|
-
"field": field.name,
|
|
773
|
-
"message": t("select.value_must_be_list"),
|
|
774
|
-
}
|
|
695
|
+
return None, [_make_error(field_name, t("select.value_must_be_list"))]
|
|
775
696
|
invalid_values = [v for v in field_value if v not in valid_options]
|
|
776
697
|
if invalid_values:
|
|
777
|
-
return None,
|
|
778
|
-
"field": field.name,
|
|
779
|
-
"message": t("select.invalid_values", values=str(invalid_values)),
|
|
780
|
-
}
|
|
698
|
+
return None, [_make_error(field_name, t("select.invalid_values", values=str(invalid_values)))]
|
|
781
699
|
else:
|
|
782
700
|
if field_value not in valid_options:
|
|
783
|
-
return None,
|
|
784
|
-
"field": field.name,
|
|
785
|
-
"message": t(
|
|
701
|
+
return None, [_make_error(field_name, t(
|
|
786
702
|
"select.invalid_option_value",
|
|
787
703
|
value=field_value,
|
|
788
704
|
valid=str(valid_options),
|
|
789
|
-
)
|
|
790
|
-
|
|
791
|
-
|
|
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)
|
|
792
715
|
|
|
793
716
|
# Email validation
|
|
794
717
|
if field.field_type == FieldType.EMAIL:
|
|
795
718
|
if not re.match(r"[^@]+@[^@]+\.[^@]+", str(field_value)):
|
|
796
|
-
return None,
|
|
797
|
-
return field_value,
|
|
719
|
+
return None, [_make_error(field_name, t("email.invalid"))]
|
|
720
|
+
return field_value, []
|
|
798
721
|
|
|
799
722
|
# Checkbox group validation
|
|
800
723
|
if field.field_type == FieldType.CHECKBOX and hasattr(field, "options"):
|
|
@@ -802,17 +725,11 @@ def _validate_field_value(
|
|
|
802
725
|
if isinstance(field_value, str):
|
|
803
726
|
field_value = [field_value]
|
|
804
727
|
if not isinstance(field_value, list):
|
|
805
|
-
return None,
|
|
806
|
-
"field": field.name,
|
|
807
|
-
"message": t("select.value_must_be_list"),
|
|
808
|
-
}
|
|
728
|
+
return None, [_make_error(field_name, t("select.value_must_be_list"))]
|
|
809
729
|
invalid_values = [v for v in field_value if v not in valid_options]
|
|
810
730
|
if invalid_values:
|
|
811
|
-
return None,
|
|
812
|
-
|
|
813
|
-
"message": t("select.invalid_values", values=str(invalid_values)),
|
|
814
|
-
}
|
|
815
|
-
return field_value, None
|
|
731
|
+
return None, [_make_error(field_name, t("select.invalid_values", values=str(invalid_values)))]
|
|
732
|
+
return field_value, []
|
|
816
733
|
|
|
817
734
|
# Single checkbox validation
|
|
818
735
|
if field.field_type == FieldType.CHECKBOX and not hasattr(field, "options"):
|
|
@@ -822,8 +739,8 @@ def _validate_field_value(
|
|
|
822
739
|
if field.field_type == FieldType.RADIO:
|
|
823
740
|
valid_options = [opt.value for opt in field.options]
|
|
824
741
|
if field_value not in valid_options:
|
|
825
|
-
return None,
|
|
826
|
-
return field_value,
|
|
742
|
+
return None, [_make_error(field_name, t("radio.invalid_option"))]
|
|
743
|
+
return field_value, []
|
|
827
744
|
|
|
828
745
|
# Number validation
|
|
829
746
|
if field.field_type == FieldType.NUMBER:
|
|
@@ -831,19 +748,13 @@ def _validate_field_value(
|
|
|
831
748
|
num_value = float(field_value)
|
|
832
749
|
if hasattr(field, "min_value") and field.min_value is not None:
|
|
833
750
|
if num_value < field.min_value:
|
|
834
|
-
return None,
|
|
835
|
-
"field": field.name,
|
|
836
|
-
"message": t("number.min_value", min=field.min_value),
|
|
837
|
-
}
|
|
751
|
+
return None, [_make_error(field_name, t("number.min_value", min=field.min_value))]
|
|
838
752
|
if hasattr(field, "max_value") and field.max_value is not None:
|
|
839
753
|
if num_value > field.max_value:
|
|
840
|
-
return None,
|
|
841
|
-
"field": field.name,
|
|
842
|
-
"message": t("number.max_value", max=field.max_value),
|
|
843
|
-
}
|
|
754
|
+
return None, [_make_error(field_name, t("number.max_value", max=field.max_value))]
|
|
844
755
|
return num_value, None
|
|
845
756
|
except (ValueError, TypeError):
|
|
846
|
-
return None,
|
|
757
|
+
return None, [_make_error(field_name, t("number.invalid"))]
|
|
847
758
|
|
|
848
759
|
# Text validation
|
|
849
760
|
if field.field_type == FieldType.TEXT:
|
|
@@ -851,20 +762,14 @@ def _validate_field_value(
|
|
|
851
762
|
field_value = str(field_value)
|
|
852
763
|
if hasattr(field, "minlength") and field.minlength is not None:
|
|
853
764
|
if len(field_value) < field.minlength:
|
|
854
|
-
return None,
|
|
855
|
-
"field": field.name,
|
|
856
|
-
"message": t("text.minlength", min=field.minlength),
|
|
857
|
-
}
|
|
765
|
+
return None, [_make_error(field_name, t("text.minlength", min=field.minlength))]
|
|
858
766
|
if hasattr(field, "maxlength") and field.maxlength is not None:
|
|
859
767
|
if len(field_value) > field.maxlength:
|
|
860
|
-
return None,
|
|
861
|
-
|
|
862
|
-
"message": t("text.maxlength", max=field.maxlength),
|
|
863
|
-
}
|
|
864
|
-
return field_value, None
|
|
768
|
+
return None, [_make_error(field_name, t("text.maxlength", max=field.maxlength))]
|
|
769
|
+
return field_value, []
|
|
865
770
|
|
|
866
771
|
# Default: pasar el valor sin validación adicional
|
|
867
|
-
return field_value,
|
|
772
|
+
return field_value, []
|
|
868
773
|
|
|
869
774
|
|
|
870
775
|
def validate_form_data_dynamic(
|
|
@@ -923,10 +828,10 @@ def validate_form_data_dynamic(
|
|
|
923
828
|
continue # Campo oculto, no validar
|
|
924
829
|
|
|
925
830
|
field_value = data.get(field.name)
|
|
926
|
-
value,
|
|
831
|
+
value, field_errors = _validate_field_value(field, field_value, data)
|
|
927
832
|
|
|
928
|
-
if
|
|
929
|
-
errors.
|
|
833
|
+
if field_errors:
|
|
834
|
+
errors.extend(field_errors)
|
|
930
835
|
elif value is not None:
|
|
931
836
|
validated_data[field.name] = value
|
|
932
837
|
|
codeforms/registry.py
CHANGED
|
@@ -69,6 +69,7 @@ def _init_builtin_types() -> None:
|
|
|
69
69
|
FileField,
|
|
70
70
|
HiddenField,
|
|
71
71
|
ListField,
|
|
72
|
+
ObjectListField,
|
|
72
73
|
NumberField,
|
|
73
74
|
RadioField,
|
|
74
75
|
SelectField,
|
|
@@ -91,6 +92,7 @@ def _init_builtin_types() -> None:
|
|
|
91
92
|
UrlField,
|
|
92
93
|
TextareaField,
|
|
93
94
|
ListField,
|
|
95
|
+
ObjectListField,
|
|
94
96
|
]:
|
|
95
97
|
_register_class(cls)
|
|
96
98
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codeforms
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Python library for creating, validating, and rendering web forms using Pydantic
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -100,6 +100,69 @@ All fields inherit from `FormFieldBase` and share these common attributes:
|
|
|
100
100
|
- `accept`: Accepted file types (e.g. `"image/*,.pdf"`).
|
|
101
101
|
- `multiple`: Allow multiple file uploads.
|
|
102
102
|
- **`HiddenField`** — Hidden field (`<input type="hidden">`).
|
|
103
|
+
- **`ListField`** — Array of primitive values.
|
|
104
|
+
- `item_type`: Primitive type for each item (`text`, `number`, `email`, `url`, `date`).
|
|
105
|
+
- `min_items`, `max_items`: List size limits.
|
|
106
|
+
- **`ObjectListField`** — Array of homogeneous objects validated against nested subfields.
|
|
107
|
+
- `fields`: List of subfields that define each object shape.
|
|
108
|
+
- `min_items`, `max_items`: List size limits.
|
|
109
|
+
|
|
110
|
+
### `ObjectListField`
|
|
111
|
+
|
|
112
|
+
Use `ObjectListField` when a form needs a repeatable list of structured rows, such as parallel approvers, attendees with roles, or line items.
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
from codeforms import Form, ObjectListField, TextField, CheckboxField
|
|
116
|
+
|
|
117
|
+
form = Form(
|
|
118
|
+
name="parallel_approvers",
|
|
119
|
+
fields=[
|
|
120
|
+
ObjectListField(
|
|
121
|
+
name="parallel_approvals",
|
|
122
|
+
label="Aprobadores",
|
|
123
|
+
required=True,
|
|
124
|
+
min_items=1,
|
|
125
|
+
max_items=5,
|
|
126
|
+
fields=[
|
|
127
|
+
TextField(name="approver_email", label="Email", required=True),
|
|
128
|
+
TextField(name="label", label="Etiqueta", required=True),
|
|
129
|
+
CheckboxField(name="required", label="Obligatorio"),
|
|
130
|
+
],
|
|
131
|
+
)
|
|
132
|
+
],
|
|
133
|
+
)
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Expected submitted value:
|
|
137
|
+
|
|
138
|
+
```json
|
|
139
|
+
{
|
|
140
|
+
"parallel_approvals": [
|
|
141
|
+
{
|
|
142
|
+
"approver_email": "ana@empresa.com",
|
|
143
|
+
"label": "Compras",
|
|
144
|
+
"required": true
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
"approver_email": "luis@empresa.com",
|
|
148
|
+
"label": "Finanzas"
|
|
149
|
+
}
|
|
150
|
+
]
|
|
151
|
+
}
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Validation behavior:
|
|
155
|
+
|
|
156
|
+
- The top-level field must be a JSON array.
|
|
157
|
+
- Each item must be a JSON object.
|
|
158
|
+
- Unknown keys inside items are rejected.
|
|
159
|
+
- Required nested subfields are enforced.
|
|
160
|
+
- Validation errors include nested paths like `parallel_approvals[0].label`.
|
|
161
|
+
|
|
162
|
+
Current limitation:
|
|
163
|
+
|
|
164
|
+
- `ObjectListField` is fully supported in backend validation and JSON Schema export.
|
|
165
|
+
- Rich repeatable HTML UI generation is not implemented yet. If you need an interactive editor, prefer consuming the exported `json_schema` from your frontend.
|
|
103
166
|
|
|
104
167
|
## Data Validation
|
|
105
168
|
|
|
@@ -284,9 +347,38 @@ Output:
|
|
|
284
347
|
| `UrlField` | `string` (`format: "uri"`) | `minLength`, `maxLength` |
|
|
285
348
|
| `TextareaField` | `string` | `minLength`, `maxLength` |
|
|
286
349
|
| `ListField` | `array` | `minItems`, `maxItems` |
|
|
350
|
+
| `ObjectListField` | `array` of `object` | nested `properties`, nested `required`, `minItems`, `maxItems` |
|
|
287
351
|
|
|
288
352
|
Field annotations like `label`, `help_text`, `default_value`, and `readonly` map to the JSON Schema keywords `title`, `description`, `default`, and `readOnly` respectively.
|
|
289
353
|
|
|
354
|
+
Example `ObjectListField` schema:
|
|
355
|
+
|
|
356
|
+
```json
|
|
357
|
+
{
|
|
358
|
+
"type": "array",
|
|
359
|
+
"minItems": 1,
|
|
360
|
+
"items": {
|
|
361
|
+
"type": "object",
|
|
362
|
+
"properties": {
|
|
363
|
+
"approver_email": {
|
|
364
|
+
"type": "string",
|
|
365
|
+
"title": "Email"
|
|
366
|
+
},
|
|
367
|
+
"label": {
|
|
368
|
+
"type": "string",
|
|
369
|
+
"title": "Etiqueta"
|
|
370
|
+
},
|
|
371
|
+
"required": {
|
|
372
|
+
"type": "boolean",
|
|
373
|
+
"title": "Obligatorio"
|
|
374
|
+
}
|
|
375
|
+
},
|
|
376
|
+
"required": ["approver_email", "label"],
|
|
377
|
+
"additionalProperties": false
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
```
|
|
381
|
+
|
|
290
382
|
Fields inside `FieldGroup` and `FormStep` containers are flattened into the top-level `properties` automatically.
|
|
291
383
|
|
|
292
384
|
## Internationalization (i18n)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
codeforms/__init__.py,sha256=fwftisOnA1dFLFoEDKMPpYBYlVBI7vFwxY-ThP21ERU,1735
|
|
2
|
+
codeforms/export.py,sha256=PBfKHtsaXrQsIalQKE-MR6m_-LUKpbrAlc958DbpEAE,21265
|
|
3
|
+
codeforms/fields.py,sha256=dt4sGL1XKhCiCGUrgHzwiymjP9c4CptsY9QFKjZKDSM,13803
|
|
4
|
+
codeforms/forms.py,sha256=qWHF5TjmYuJU_Iw4eQ94_QTI-rbd8RTi20MbiH4jt_8,33692
|
|
5
|
+
codeforms/i18n.py,sha256=VK0n3d8UaD-q31ggrlIiJ4NSjagoYLoz200YFCXKuKU,10392
|
|
6
|
+
codeforms/registry.py,sha256=COYMIK0dtOdUwvNKBLhmUJEHC2ErSuJE8PU-jwZVt7U,6670
|
|
7
|
+
codeforms-0.2.2.dist-info/METADATA,sha256=KjHK2PpzKmqG9voez5We5vDknH9CO3KsQ9mamX8Krzs,20284
|
|
8
|
+
codeforms-0.2.2.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
9
|
+
codeforms-0.2.2.dist-info/licenses/LICENSE,sha256=U9D6cQ9DZJQrT03MMDJcq-aOuoB_Bn8HJlzcTTN7PfM,1074
|
|
10
|
+
codeforms-0.2.2.dist-info/RECORD,,
|
codeforms-0.2.1.dist-info/RECORD
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
codeforms/__init__.py,sha256=ozi9bHDT22B7kkh6fjCOm7Y6suJwWaHFWKR8S6kZlW0,1691
|
|
2
|
-
codeforms/export.py,sha256=QuzllibvfFYv8nuG0WfUld4uzL-mnxZ51-CRYRYxhTE,20398
|
|
3
|
-
codeforms/fields.py,sha256=LcDKiDX4lJ2OLT02eTYj-BSNKXq-CE6IE_Yb8MEDs1I,12793
|
|
4
|
-
codeforms/forms.py,sha256=VM80L5fQyHJxwiCslFqlcsD2UvPDGVE_bnw_23XP-gY,38442
|
|
5
|
-
codeforms/i18n.py,sha256=VK0n3d8UaD-q31ggrlIiJ4NSjagoYLoz200YFCXKuKU,10392
|
|
6
|
-
codeforms/registry.py,sha256=Qwd-9-yXhzqJiG7loDPgFrCmLjZ_zOU4_vAfTV7zdzc,6620
|
|
7
|
-
codeforms-0.2.1.dist-info/METADATA,sha256=2HG7D1OdTSFihxfDe2FftDAIyXXNJPsTymvpeOdwFH8,17746
|
|
8
|
-
codeforms-0.2.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
9
|
-
codeforms-0.2.1.dist-info/licenses/LICENSE,sha256=U9D6cQ9DZJQrT03MMDJcq-aOuoB_Bn8HJlzcTTN7PfM,1074
|
|
10
|
-
codeforms-0.2.1.dist-info/RECORD,,
|
|
File without changes
|