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/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 Optional, List, Literal, Union, Dict, Any, Type, Set
4
+ from typing import Any, Dict, List, Literal, Optional, Set, Union
5
5
  from uuid import UUID, uuid4
6
- from pydantic import BaseModel, Field, EmailStr, ConfigDict, field_validator, model_validator
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
- field: str # nombre del campo del que depende
36
- operator: str = "equals" # equals, not_equals, in, not_in, gt, lt, is_empty, is_not_empty
37
- value: Any = None # valor a comparar
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
- depends_on: str # nombre del campo padre
49
- options_map: Dict[str, List[SelectOption]] # valor_padre opciones disponibles
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('attributes', mode='before')
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 = 'html', **kwargs) -> 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('default_value')
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('default_value')
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('default_value')
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('min_selected')
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('multiple', False):
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('max_selected')
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('multiple', False):
174
+ if not info.data.get("multiple", False):
161
175
  raise ValueError(t("select.max_selected_requires_multiple"))
162
- min_selected = info.data.get('min_selected')
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('pattern')
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('default_value')
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('default_value')
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(('http://', 'https://')):
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 # Si el grupo está colapsado por defecto
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='before')
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 'fields' in data:
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['fields'] = [resolve_content_item(item) for item in data['fields']]
346
+ data["fields"] = [resolve_content_item(item) for item in data["fields"]]
302
347
  return data
303
348
 
304
- @model_validator(mode='after')
305
- def validate_field_names_in_group(self) -> 'FieldGroup':
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 = 'html', **kwargs) -> 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('attributes', mode='before')
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='before')
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 'content' in data:
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['content'] = [resolve_content_item(item) for item in data['content']]
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 = 'html', **kwargs) -> 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)