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/fields.py
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
import re
|
|
2
2
|
from datetime import date, datetime
|
|
3
3
|
from enum import Enum
|
|
4
|
-
from typing import
|
|
4
|
+
from typing import Any, Dict, List, Literal, Optional, Set, Union
|
|
5
5
|
from uuid import UUID, uuid4
|
|
6
|
-
|
|
6
|
+
|
|
7
|
+
from pydantic import (
|
|
8
|
+
BaseModel,
|
|
9
|
+
EmailStr,
|
|
10
|
+
Field,
|
|
11
|
+
field_validator,
|
|
12
|
+
model_validator,
|
|
13
|
+
)
|
|
14
|
+
|
|
7
15
|
from codeforms.i18n import t
|
|
8
16
|
|
|
9
17
|
|
|
@@ -22,6 +30,7 @@ class FieldType(str, Enum):
|
|
|
22
30
|
HIDDEN = "hidden"
|
|
23
31
|
URL = "url"
|
|
24
32
|
LIST = "list"
|
|
33
|
+
OBJECT_LIST = "object-list"
|
|
25
34
|
|
|
26
35
|
|
|
27
36
|
class ValidationRule(BaseModel):
|
|
@@ -32,9 +41,12 @@ class ValidationRule(BaseModel):
|
|
|
32
41
|
|
|
33
42
|
class VisibilityRule(BaseModel):
|
|
34
43
|
"""Regla de visibilidad condicional para un campo."""
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
44
|
+
|
|
45
|
+
field: str # nombre del campo del que depende
|
|
46
|
+
operator: str = (
|
|
47
|
+
"equals" # equals, not_equals, in, not_in, gt, lt, is_empty, is_not_empty
|
|
48
|
+
)
|
|
49
|
+
value: Any = None # valor a comparar
|
|
38
50
|
|
|
39
51
|
|
|
40
52
|
class SelectOption(BaseModel):
|
|
@@ -45,8 +57,9 @@ class SelectOption(BaseModel):
|
|
|
45
57
|
|
|
46
58
|
class DependentOptionsConfig(BaseModel):
|
|
47
59
|
"""Configuración para opciones dependientes de otro campo."""
|
|
48
|
-
|
|
49
|
-
|
|
60
|
+
|
|
61
|
+
depends_on: str # nombre del campo padre
|
|
62
|
+
options_map: Dict[str, List[SelectOption]] # valor_padre → opciones disponibles
|
|
50
63
|
|
|
51
64
|
|
|
52
65
|
class FormFieldBase(BaseModel):
|
|
@@ -65,7 +78,7 @@ class FormFieldBase(BaseModel):
|
|
|
65
78
|
visible_when: Optional[List[VisibilityRule]] = None
|
|
66
79
|
dependent_options: Optional[DependentOptionsConfig] = None
|
|
67
80
|
|
|
68
|
-
@field_validator(
|
|
81
|
+
@field_validator("attributes", mode="before")
|
|
69
82
|
@classmethod
|
|
70
83
|
def coerce_attributes_to_strings(cls, v: Any) -> Dict[str, str]:
|
|
71
84
|
"""Coerce all attribute values to strings to handle cases like data_flags=1."""
|
|
@@ -79,7 +92,7 @@ class FormFieldBase(BaseModel):
|
|
|
79
92
|
"json_serializers": {
|
|
80
93
|
UUID: str,
|
|
81
94
|
datetime: lambda v: v.isoformat(),
|
|
82
|
-
date: lambda v: v.isoformat()
|
|
95
|
+
date: lambda v: v.isoformat(),
|
|
83
96
|
}
|
|
84
97
|
}
|
|
85
98
|
|
|
@@ -89,9 +102,10 @@ class FormFieldBase(BaseModel):
|
|
|
89
102
|
ft = self.field_type
|
|
90
103
|
return ft.value if isinstance(ft, FieldType) else ft
|
|
91
104
|
|
|
92
|
-
def export(self, output_format: str =
|
|
105
|
+
def export(self, output_format: str = "html", **kwargs) -> str:
|
|
93
106
|
"""Método genérico para exportar el campo en diferentes formatos"""
|
|
94
107
|
from codeforms.export import field_exporter
|
|
108
|
+
|
|
95
109
|
return field_exporter(self, output_format, kwargs=kwargs)
|
|
96
110
|
|
|
97
111
|
|
|
@@ -100,7 +114,7 @@ class CheckboxField(FormFieldBase):
|
|
|
100
114
|
checked: bool = False
|
|
101
115
|
value: str = "on" # Valor por defecto cuando está marcado
|
|
102
116
|
|
|
103
|
-
@field_validator(
|
|
117
|
+
@field_validator("default_value")
|
|
104
118
|
@classmethod
|
|
105
119
|
def validate_default_value(cls, v: Any) -> Any:
|
|
106
120
|
if v is not None and not isinstance(v, bool):
|
|
@@ -113,7 +127,7 @@ class CheckboxGroupField(FormFieldBase):
|
|
|
113
127
|
options: List[SelectOption]
|
|
114
128
|
inline: bool = False
|
|
115
129
|
|
|
116
|
-
@field_validator(
|
|
130
|
+
@field_validator("default_value")
|
|
117
131
|
@classmethod
|
|
118
132
|
def validate_default_value(cls, v: Any) -> Any:
|
|
119
133
|
if v is not None and not isinstance(v, list):
|
|
@@ -126,7 +140,7 @@ class RadioField(FormFieldBase):
|
|
|
126
140
|
options: List[SelectOption]
|
|
127
141
|
inline: bool = False
|
|
128
142
|
|
|
129
|
-
@field_validator(
|
|
143
|
+
@field_validator("default_value")
|
|
130
144
|
@classmethod
|
|
131
145
|
def validate_default_value(cls, v: Any) -> Any:
|
|
132
146
|
if v is not None and not isinstance(v, str):
|
|
@@ -141,25 +155,25 @@ class SelectField(FormFieldBase):
|
|
|
141
155
|
min_selected: Optional[int] = None # Mínimo de opciones a seleccionar
|
|
142
156
|
max_selected: Optional[int] = None # Máximo de opciones a seleccionar
|
|
143
157
|
|
|
144
|
-
@field_validator(
|
|
158
|
+
@field_validator("min_selected")
|
|
145
159
|
@classmethod
|
|
146
160
|
def validate_min_selected(cls, v: Optional[int], info: Any) -> Optional[int]:
|
|
147
161
|
if v is not None:
|
|
148
162
|
if v < 0:
|
|
149
163
|
raise ValueError(t("select.min_selected_negative"))
|
|
150
|
-
if not info.data.get(
|
|
164
|
+
if not info.data.get("multiple", False):
|
|
151
165
|
raise ValueError(t("select.min_selected_requires_multiple"))
|
|
152
166
|
return v
|
|
153
167
|
|
|
154
|
-
@field_validator(
|
|
168
|
+
@field_validator("max_selected")
|
|
155
169
|
@classmethod
|
|
156
170
|
def validate_max_selected(cls, v: Optional[int], info: Any) -> Optional[int]:
|
|
157
171
|
if v is not None:
|
|
158
172
|
if v < 1:
|
|
159
173
|
raise ValueError(t("select.max_selected_min_value"))
|
|
160
|
-
if not info.data.get(
|
|
174
|
+
if not info.data.get("multiple", False):
|
|
161
175
|
raise ValueError(t("select.max_selected_requires_multiple"))
|
|
162
|
-
min_selected = info.data.get(
|
|
176
|
+
min_selected = info.data.get("min_selected")
|
|
163
177
|
if min_selected is not None and v < min_selected:
|
|
164
178
|
raise ValueError(t("select.max_less_than_min"))
|
|
165
179
|
return v
|
|
@@ -175,7 +189,7 @@ class TextField(FormFieldBase):
|
|
|
175
189
|
maxlength: Optional[int] = None
|
|
176
190
|
pattern: Optional[str] = None
|
|
177
191
|
|
|
178
|
-
@field_validator(
|
|
192
|
+
@field_validator("pattern")
|
|
179
193
|
@classmethod
|
|
180
194
|
def validate_pattern(cls, v: Optional[str]) -> Optional[str]:
|
|
181
195
|
if v is not None:
|
|
@@ -206,7 +220,7 @@ class TextField(FormFieldBase):
|
|
|
206
220
|
class EmailField(FormFieldBase):
|
|
207
221
|
field_type: FieldType = FieldType.EMAIL
|
|
208
222
|
|
|
209
|
-
@field_validator(
|
|
223
|
+
@field_validator("default_value")
|
|
210
224
|
@classmethod
|
|
211
225
|
def validate_email(cls, v: Any) -> Any:
|
|
212
226
|
if v is not None:
|
|
@@ -240,22 +254,24 @@ class HiddenField(FormFieldBase):
|
|
|
240
254
|
|
|
241
255
|
class UrlField(FormFieldBase):
|
|
242
256
|
"""Campo para URLs"""
|
|
257
|
+
|
|
243
258
|
field_type: FieldType = FieldType.URL
|
|
244
259
|
minlength: Optional[int] = None
|
|
245
260
|
maxlength: Optional[int] = None
|
|
246
261
|
|
|
247
|
-
@field_validator(
|
|
262
|
+
@field_validator("default_value")
|
|
248
263
|
@classmethod
|
|
249
264
|
def validate_url(cls, v: Any) -> Any:
|
|
250
265
|
if v is not None and isinstance(v, str):
|
|
251
266
|
# Validación básica de URL
|
|
252
|
-
if not v.startswith((
|
|
267
|
+
if not v.startswith(("http://", "https://")):
|
|
253
268
|
raise ValueError(t("url.invalid_scheme"))
|
|
254
269
|
return v
|
|
255
270
|
|
|
256
271
|
|
|
257
272
|
class TextareaField(FormFieldBase):
|
|
258
273
|
"""Campo de texto multilínea"""
|
|
274
|
+
|
|
259
275
|
field_type: FieldType = FieldType.TEXTAREA
|
|
260
276
|
minlength: Optional[int] = None
|
|
261
277
|
maxlength: Optional[int] = None
|
|
@@ -264,15 +280,43 @@ class TextareaField(FormFieldBase):
|
|
|
264
280
|
|
|
265
281
|
|
|
266
282
|
class ListField(FormFieldBase):
|
|
267
|
-
"""Campo para listas de valores (ej: lista de participantes)"""
|
|
283
|
+
"""Campo para listas de valores primitivos (ej: lista de participantes)."""
|
|
284
|
+
|
|
268
285
|
field_type: FieldType = FieldType.LIST
|
|
269
286
|
min_items: Optional[int] = None
|
|
270
287
|
max_items: Optional[int] = None
|
|
271
288
|
item_type: str = "text" # Tipo de cada item en la lista
|
|
272
289
|
|
|
273
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
|
+
|
|
274
317
|
class FieldGroup(BaseModel):
|
|
275
318
|
"""Representa un grupo de campos en un formulario para organización en secciones"""
|
|
319
|
+
|
|
276
320
|
container_type: str = "group" # Discriminador explícito para distinguir de FormStep
|
|
277
321
|
id: UUID = Field(default_factory=uuid4)
|
|
278
322
|
title: str
|
|
@@ -281,37 +325,39 @@ class FieldGroup(BaseModel):
|
|
|
281
325
|
css_classes: Optional[str] = None
|
|
282
326
|
attributes: Dict[str, str] = Field(default_factory=dict)
|
|
283
327
|
collapsible: bool = False # Si el grupo puede colapsarse
|
|
284
|
-
collapsed: bool = False
|
|
328
|
+
collapsed: bool = False # Si el grupo está colapsado por defecto
|
|
285
329
|
|
|
286
330
|
model_config = {
|
|
287
331
|
"json_serializers": {
|
|
288
332
|
UUID: str,
|
|
289
333
|
datetime: lambda v: v.isoformat(),
|
|
290
|
-
date: lambda v: v.isoformat()
|
|
334
|
+
date: lambda v: v.isoformat(),
|
|
291
335
|
}
|
|
292
336
|
}
|
|
293
337
|
|
|
294
|
-
@model_validator(mode=
|
|
338
|
+
@model_validator(mode="before")
|
|
295
339
|
@classmethod
|
|
296
340
|
def resolve_group_fields(cls, data: Any) -> Any:
|
|
297
341
|
"""Resolve field dicts to instances using the registry."""
|
|
298
|
-
if isinstance(data, dict) and
|
|
342
|
+
if isinstance(data, dict) and "fields" in data:
|
|
299
343
|
from codeforms.registry import resolve_content_item
|
|
344
|
+
|
|
300
345
|
data = data.copy()
|
|
301
|
-
data[
|
|
346
|
+
data["fields"] = [resolve_content_item(item) for item in data["fields"]]
|
|
302
347
|
return data
|
|
303
348
|
|
|
304
|
-
@model_validator(mode=
|
|
305
|
-
def validate_field_names_in_group(self) ->
|
|
349
|
+
@model_validator(mode="after")
|
|
350
|
+
def validate_field_names_in_group(self) -> "FieldGroup":
|
|
306
351
|
"""Valida que los nombres de campos dentro del grupo sean únicos"""
|
|
307
352
|
names = [field.name for field in self.fields]
|
|
308
353
|
if len(names) != len(set(names)):
|
|
309
354
|
raise ValueError(t("form.unique_field_names_in_group", title=self.title))
|
|
310
355
|
return self
|
|
311
356
|
|
|
312
|
-
def export(self, output_format: str =
|
|
357
|
+
def export(self, output_format: str = "html", **kwargs) -> str:
|
|
313
358
|
"""Método para exportar el grupo de campos en diferentes formatos"""
|
|
314
359
|
from codeforms.export import group_exporter
|
|
360
|
+
|
|
315
361
|
return group_exporter(self, output_format, kwargs=kwargs)
|
|
316
362
|
|
|
317
363
|
|
|
@@ -321,6 +367,7 @@ class FormStep(BaseModel):
|
|
|
321
367
|
Agrupa campos y/o grupos de campos en una secuencia lógica.
|
|
322
368
|
Se diferencia de FieldGroup mediante el discriminador explícito type="step".
|
|
323
369
|
"""
|
|
370
|
+
|
|
324
371
|
type: Literal["step"] = "step" # Discriminador explícito (RISK-1)
|
|
325
372
|
id: UUID = Field(default_factory=uuid4)
|
|
326
373
|
title: str
|
|
@@ -335,11 +382,11 @@ class FormStep(BaseModel):
|
|
|
335
382
|
"json_serializers": {
|
|
336
383
|
UUID: str,
|
|
337
384
|
datetime: lambda v: v.isoformat(),
|
|
338
|
-
date: lambda v: v.isoformat()
|
|
385
|
+
date: lambda v: v.isoformat(),
|
|
339
386
|
}
|
|
340
387
|
}
|
|
341
388
|
|
|
342
|
-
@field_validator(
|
|
389
|
+
@field_validator("attributes", mode="before")
|
|
343
390
|
@classmethod
|
|
344
391
|
def coerce_attributes_to_strings(cls, v: Any) -> Dict[str, str]:
|
|
345
392
|
if v is None:
|
|
@@ -348,14 +395,15 @@ class FormStep(BaseModel):
|
|
|
348
395
|
return {str(k): str(val) for k, val in v.items()}
|
|
349
396
|
return v
|
|
350
397
|
|
|
351
|
-
@model_validator(mode=
|
|
398
|
+
@model_validator(mode="before")
|
|
352
399
|
@classmethod
|
|
353
400
|
def resolve_step_content(cls, data: Any) -> Any:
|
|
354
401
|
"""Resolver items del contenido del paso usando el registry."""
|
|
355
|
-
if isinstance(data, dict) and
|
|
402
|
+
if isinstance(data, dict) and "content" in data:
|
|
356
403
|
from codeforms.registry import resolve_content_item
|
|
404
|
+
|
|
357
405
|
data = data.copy()
|
|
358
|
-
data[
|
|
406
|
+
data["content"] = [resolve_content_item(item) for item in data["content"]]
|
|
359
407
|
return data
|
|
360
408
|
|
|
361
409
|
@property
|
|
@@ -370,8 +418,8 @@ class FormStep(BaseModel):
|
|
|
370
418
|
all_fields.append(item)
|
|
371
419
|
return all_fields
|
|
372
420
|
|
|
373
|
-
def export(self, output_format: str =
|
|
421
|
+
def export(self, output_format: str = "html", **kwargs) -> str:
|
|
374
422
|
"""Exportar el paso en diferentes formatos."""
|
|
375
423
|
from codeforms.export import step_exporter
|
|
376
|
-
return step_exporter(self, output_format, kwargs=kwargs)
|
|
377
424
|
|
|
425
|
+
return step_exporter(self, output_format, kwargs=kwargs)
|