codeforms 0.1.0__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 ADDED
@@ -0,0 +1,30 @@
1
+ from codeforms.fields import (
2
+ FieldType,
3
+ ValidationRule,
4
+ FormFieldBase,
5
+ SelectOption,
6
+ CheckboxField,
7
+ CheckboxGroupField,
8
+ RadioField,
9
+ SelectField,
10
+ TextField,
11
+ EmailField,
12
+ NumberField,
13
+ DateField,
14
+ FileField,
15
+ HiddenField,
16
+ UrlField,
17
+ TextareaField,
18
+ ListField,
19
+ FieldGroup,
20
+ )
21
+ from codeforms.forms import Form, FormDataValidator, validate_form_data
22
+ from codeforms.export import ExportFormat
23
+ from codeforms.i18n import (
24
+ t,
25
+ set_locale,
26
+ get_locale,
27
+ get_available_locales,
28
+ register_locale,
29
+ get_messages,
30
+ )
codeforms/export.py ADDED
@@ -0,0 +1,331 @@
1
+ from enum import Enum
2
+ from codeforms.forms import Form
3
+ from codeforms.fields import FormFieldBase
4
+ from codeforms.i18n import t
5
+
6
+
7
+ class ExportFormat(Enum):
8
+ HTML = 'html'
9
+ BOOTSTRAP4 = 'html_bootstrap4'
10
+ BOOTSTRAP5 = 'html_bootstrap5'
11
+
12
+
13
+ def generate_validation_code(form, output_format: str) -> str:
14
+ """Genera el código de validación en Javascript"""
15
+ if output_format == 'html':
16
+ validation_code = f"""
17
+ <script>
18
+ function validate_{form.name}(form) {{
19
+ let errors = [];
20
+ let validated_data = {{}};
21
+
22
+ {js_generate_field_validations(form)}
23
+
24
+ if (errors.length > 0) {{
25
+ alert('{t("export.fix_errors")}\\n' + errors.join('\\n'));
26
+ return false;
27
+ }}
28
+ return true;
29
+ }}
30
+
31
+ document.getElementById('{form.name}').onsubmit = function(e) {{
32
+ return validate_{form.name}(this);
33
+ }};
34
+ </script>
35
+ """
36
+ return validation_code
37
+ return ""
38
+
39
+
40
+ def js_generate_field_validations(form) -> str:
41
+ """Genera el código JavaScript para validar cada campo"""
42
+ validations = []
43
+
44
+ # Usar form.fields que devuelve la lista plana de todos los campos
45
+ for field in form.fields:
46
+ field_validation = f"""
47
+ // Validación para {field.name}
48
+ let {field.name} = form.elements['{field.name}'].value;
49
+ """
50
+
51
+ if field.required:
52
+ field_validation += f"""
53
+ if (!{field.name}) {{
54
+ errors.push('{t("export.field_required", label=field.label)}');
55
+ }}
56
+ """
57
+
58
+ for rule in field.validation_rules:
59
+ if rule.type == "min":
60
+ field_validation += f"""
61
+ if ({field.name} && {field.name} < {rule.value}) {{
62
+ errors.push('{rule.message}');
63
+ }}
64
+ """
65
+ elif rule.type == "max":
66
+ field_validation += f"""
67
+ if ({field.name} && {field.name} > {rule.value}) {{
68
+ errors.push('{rule.message}');
69
+ }}
70
+ """
71
+ elif rule.type == "regex":
72
+ field_validation += f"""
73
+ if ({field.name} && !new RegExp('{rule.value}').test({field.name})) {{
74
+ errors.push('{rule.message}');
75
+ }}
76
+ """
77
+ elif rule.type == "minlength":
78
+ field_validation += f"""
79
+ if ({field.name} && {field.name}.length < {rule.value}) {{
80
+ errors.push('{rule.message}');
81
+ }}
82
+ """
83
+ elif rule.type == "maxlength":
84
+ field_validation += f"""
85
+ if ({field.name} && {field.name}.length > {rule.value}) {{
86
+ errors.push('{rule.message}');
87
+ }}
88
+ """
89
+
90
+ field_validation += f"""
91
+ if (!errors.length) {{
92
+ validated_data['{field.name}'] = {field.name};
93
+ }}
94
+ """
95
+
96
+ validations.append(field_validation)
97
+
98
+ return "\n".join(validations)
99
+
100
+ def field_exporter(field: FormFieldBase, output_format: str, **kwargs) -> str:
101
+ """Exporta un campo individual al formato especificado"""
102
+ if output_format == 'html':
103
+ return field_to_html(field, kwargs=kwargs)
104
+ return ""
105
+
106
+
107
+ def group_exporter(group, output_format: str, **kwargs) -> str:
108
+ """Exporta un grupo de campos al formato especificado"""
109
+ if output_format == 'html':
110
+ return group_to_html(group, kwargs=kwargs)
111
+ return ""
112
+
113
+
114
+ def group_to_html(group, **kwargs) -> str:
115
+ """Genera la representación HTML del grupo de campos usando fieldset y legend"""
116
+ output_format = kwargs.get('output_format', ExportFormat.HTML.value)
117
+ is_bootstrap = output_format in [ExportFormat.BOOTSTRAP4.value, ExportFormat.BOOTSTRAP5.value]
118
+
119
+ # Clases CSS para el fieldset
120
+ fieldset_class = f"mb-4 {group.css_classes or ''}".strip() if is_bootstrap else group.css_classes or ""
121
+ legend_class = "h5 mb-3" if is_bootstrap else ""
122
+
123
+ # Atributos del fieldset
124
+ fieldset_attrs = {
125
+ "id": f"group_{group.id}",
126
+ "class": fieldset_class
127
+ }
128
+
129
+ # Agregar atributos personalizados del grupo
130
+ fieldset_attrs.update(group.attributes)
131
+
132
+ # Generar string de atributos
133
+ attrs_str = " ".join(f'{k}="{v}"' for k, v in fieldset_attrs.items() if v)
134
+
135
+ # Generar HTML de los campos dentro del grupo
136
+ fields_html = "\n".join(field_to_html(field, **kwargs) for field in group.fields)
137
+
138
+ # Generar descripción si existe
139
+ description_html = ""
140
+ if group.description:
141
+ desc_class = "text-muted small mb-3" if is_bootstrap else "group-description"
142
+ description_html = f'<p class="{desc_class}">{group.description}</p>'
143
+
144
+ # Construir el HTML del fieldset
145
+ html = f'<fieldset {attrs_str}>'
146
+ html += f'<legend class="{legend_class}">{group.title}</legend>'
147
+ html += description_html
148
+ html += fields_html
149
+ html += '</fieldset>'
150
+
151
+ return html
152
+
153
+ def form_to_html(form: Form, **kwargs) -> str:
154
+ """Genera el HTML completo del formulario"""
155
+ output_format = kwargs.get('output_format', ExportFormat.HTML.value)
156
+ form_class = "needs-validation" if output_format in [ExportFormat.BOOTSTRAP4.value, ExportFormat.BOOTSTRAP5.value] else ""
157
+
158
+ attributes = {
159
+ "id": kwargs.get('id') or str(form.id),
160
+ "name": form.name,
161
+ "class": f"{form_class} {form.css_classes or ''}".strip(),
162
+ "enctype": kwargs.get('enctype') or "application/x-www-form-urlencoded"
163
+ }
164
+
165
+ attrs_str = " ".join(f'{k}="{v}"' for k, v in attributes.items() if v)
166
+
167
+ # Generar HTML para cada elemento del contenido (campos o grupos)
168
+ content_html_parts = []
169
+ for item in form.content:
170
+ if hasattr(item, 'fields') and hasattr(item, 'title'): # Es un FieldGroup
171
+ content_html_parts.append(group_to_html(item, **kwargs))
172
+ else: # Es un campo individual
173
+ content_html_parts.append(field_to_html(item, **kwargs))
174
+
175
+ content_html = "\n".join(content_html_parts)
176
+
177
+ if kwargs.get('submit'):
178
+ submit_class = "btn btn-primary" if output_format in [ExportFormat.BOOTSTRAP4.value, ExportFormat.BOOTSTRAP5.value] else ""
179
+ submit_html = f'<button type="submit" class="{submit_class}">{t("export.submit")}</button>'
180
+ else:
181
+ submit_html = ""
182
+
183
+ html = f"<form {attrs_str}>"
184
+ html += f"\t{content_html}"
185
+ html += f"\t{submit_html}"
186
+ html += f"</form>"
187
+ return html
188
+
189
+
190
+ def field_to_html(field: FormFieldBase, **kwargs) -> str:
191
+ """Genera la representación HTML del campo"""
192
+ output_format = kwargs.get('output_format', ExportFormat.HTML.value)
193
+ is_bootstrap = output_format in [ExportFormat.BOOTSTRAP4.value, ExportFormat.BOOTSTRAP5.value]
194
+
195
+ # Clases base para Bootstrap 4/5
196
+ if is_bootstrap:
197
+ base_input_class = "form-control"
198
+ form_group_class = "mb-3" if output_format == ExportFormat.BOOTSTRAP5.value else "form-group"
199
+ help_text_class = "form-text" # Bootstrap 5 removed text-muted
200
+ else:
201
+ base_input_class = ""
202
+ form_group_class = "form-field"
203
+ help_text_class = "help-text"
204
+
205
+ skip_label = ['hidden']
206
+
207
+ label_html = ''
208
+ if field.field_type.value not in skip_label:
209
+ label_class = "form-label" if is_bootstrap else ""
210
+ label_html = f'<label class="{label_class}" for="{field.id}">{field.label}</label>'
211
+
212
+ help_html = f'<small class="{help_text_class}">{field.help_text}</small>' if field.help_text else ""
213
+
214
+ # Manejar campos SELECT de manera especial
215
+ if field.field_type.value == 'select':
216
+ select_attrs = {
217
+ "id": str(field.id),
218
+ "name": field.name,
219
+ "class": f"{base_input_class} {field.css_classes or ''}".strip(),
220
+ }
221
+
222
+ if field.required:
223
+ select_attrs["required"] = "required"
224
+
225
+ if hasattr(field, 'multiple') and field.multiple:
226
+ select_attrs["multiple"] = "multiple"
227
+
228
+ # Agregar atributos personalizados
229
+ select_attrs.update(field.attributes)
230
+
231
+ attrs_str = " ".join(f'{k}="{v}"' for k, v in select_attrs.items() if v)
232
+
233
+ # Generar opciones
234
+ options_html = ""
235
+ if hasattr(field, 'options'):
236
+ for option in field.options:
237
+ selected = 'selected="selected"' if option.selected else ""
238
+ options_html += f'<option value="{option.value}" {selected}>{option.label}</option>'
239
+
240
+ input_html = f'<select {attrs_str}>{options_html}</select>'
241
+
242
+ # Manejar campos RADIO de manera especial
243
+ elif field.field_type.value == 'radio':
244
+ radio_html_parts = []
245
+ if hasattr(field, 'options'):
246
+ for option in field.options:
247
+ radio_attrs = {
248
+ "id": f"{field.id}_{option.value}",
249
+ "name": field.name,
250
+ "type": "radio",
251
+ "value": option.value,
252
+ "class": field.css_classes or ""
253
+ }
254
+
255
+ if option.selected:
256
+ radio_attrs["checked"] = "checked"
257
+
258
+ if field.required:
259
+ radio_attrs["required"] = "required"
260
+
261
+ attrs_str = " ".join(f'{k}="{v}"' for k, v in radio_attrs.items() if v)
262
+ radio_label = f'<label for="{field.id}_{option.value}">{option.label}</label>'
263
+ radio_html_parts.append(f'<input {attrs_str}>{radio_label}')
264
+
265
+ input_html = '<div class="radio-group">' + ''.join(radio_html_parts) + '</div>'
266
+
267
+ # Manejar campos CHECKBOX con opciones múltiples
268
+ elif field.field_type.value == 'checkbox' and hasattr(field, 'options'):
269
+ checkbox_html_parts = []
270
+ for option in field.options:
271
+ checkbox_attrs = {
272
+ "id": f"{field.id}_{option.value}",
273
+ "name": field.name,
274
+ "type": "checkbox",
275
+ "value": option.value,
276
+ "class": field.css_classes or ""
277
+ }
278
+
279
+ if option.selected:
280
+ checkbox_attrs["checked"] = "checked"
281
+
282
+ if field.required:
283
+ checkbox_attrs["required"] = "required"
284
+
285
+ attrs_str = " ".join(f'{k}="{v}"' for k, v in checkbox_attrs.items() if v)
286
+ checkbox_label = f'<label for="{field.id}_{option.value}">{option.label}</label>'
287
+ checkbox_html_parts.append(f'<input {attrs_str}>{checkbox_label}')
288
+
289
+ input_html = '<div class="checkbox-group">' + ''.join(checkbox_html_parts) + '</div>'
290
+
291
+ # Manejar campos normales (input)
292
+ else:
293
+ attributes = {
294
+ "id": str(field.id),
295
+ "name": field.name,
296
+ "type": field.field_type.value,
297
+ "class": f"{base_input_class} {field.css_classes or ''}".strip(),
298
+ "placeholder": field.placeholder or "",
299
+ }
300
+
301
+ if hasattr(field, 'value'):
302
+ attributes["value"] = getattr(field, 'value')
303
+
304
+ if field.required:
305
+ attributes["required"] = "required"
306
+
307
+ if field.default_value is not None:
308
+ attributes["value"] = str(field.default_value)
309
+
310
+ # Para checkbox simple, manejar el atributo checked
311
+ if field.field_type.value == 'checkbox' and hasattr(field, 'checked') and field.checked:
312
+ attributes["checked"] = "checked"
313
+
314
+ # Agregar atributos personalizados
315
+ attributes.update(field.attributes)
316
+
317
+ # Convertir atributos a string
318
+ attrs_str = " ".join(f'{k}="{v}"' for k, v in attributes.items() if v)
319
+ input_html = f'<input {attrs_str}>'
320
+
321
+ return f"""<div class="{form_group_class}">{label_html}{input_html}{help_html}</div>"""
322
+
323
+ def exporter(form: Form, output_format: str, **kwargs) -> dict:
324
+ export_result = {'format': output_format}
325
+ if output_format in [format.value for format in ExportFormat]:
326
+ actual_kwargs = kwargs.get('kwargs', kwargs)
327
+ actual_kwargs['output_format'] = output_format
328
+ export_result['output'] = form_to_html(form, **actual_kwargs)
329
+ export_result['javascript_validation_code'] = generate_validation_code(form, output_format)
330
+
331
+ return export_result
codeforms/fields.py ADDED
@@ -0,0 +1,298 @@
1
+ import re
2
+ from datetime import date, datetime
3
+ from enum import Enum
4
+ from typing import Optional, List, Union, Dict, Any, Type, Set
5
+ from uuid import UUID, uuid4
6
+ from pydantic import BaseModel, Field, EmailStr, ConfigDict, field_validator, model_validator
7
+ from codeforms.i18n import t
8
+
9
+
10
+ class FieldType(str, Enum):
11
+ TEXT = "text"
12
+ PASSWORD = "password"
13
+ EMAIL = "email"
14
+ NUMBER = "number"
15
+ DATE = "date"
16
+ DATETIME = "datetime-local"
17
+ CHECKBOX = "checkbox"
18
+ RADIO = "radio"
19
+ SELECT = "select"
20
+ TEXTAREA = "textarea"
21
+ FILE = "file"
22
+ HIDDEN = "hidden"
23
+ URL = "url"
24
+ LIST = "list"
25
+
26
+
27
+ class ValidationRule(BaseModel):
28
+ type: str # required, min, max, regex, etc.
29
+ value: Any
30
+ message: str
31
+
32
+
33
+ class FormFieldBase(BaseModel):
34
+ id: UUID = Field(default_factory=uuid4)
35
+ name: str
36
+ label: Union[str, None]
37
+ field_type: FieldType
38
+ required: bool = False
39
+ placeholder: Optional[str] = None
40
+ default_value: Optional[Any] = None
41
+ help_text: Optional[str] = None
42
+ validation_rules: List[ValidationRule] = Field(default_factory=list)
43
+ css_classes: Optional[str] = None
44
+ readonly: bool = False
45
+ attributes: Dict[str, str] = Field(default_factory=dict)
46
+
47
+ @field_validator('attributes', mode='before')
48
+ @classmethod
49
+ def coerce_attributes_to_strings(cls, v: Any) -> Dict[str, str]:
50
+ """Coerce all attribute values to strings to handle cases like data_flags=1."""
51
+ if v is None:
52
+ return {}
53
+ if isinstance(v, dict):
54
+ return {str(k): str(val) for k, val in v.items()}
55
+ return v
56
+
57
+ model_config = {
58
+ "json_serializers": {
59
+ UUID: str,
60
+ datetime: lambda v: v.isoformat(),
61
+ date: lambda v: v.isoformat()
62
+ }
63
+ }
64
+
65
+ def export(self, output_format: str = 'html', **kwargs) -> str:
66
+ """Método genérico para exportar el campo en diferentes formatos"""
67
+ from codeforms.export import field_exporter
68
+ return field_exporter(self, output_format, kwargs=kwargs)
69
+
70
+
71
+ class SelectOption(BaseModel):
72
+ value: str
73
+ label: str
74
+ selected: bool = False
75
+
76
+
77
+ class CheckboxField(FormFieldBase):
78
+ field_type: FieldType = FieldType.CHECKBOX
79
+ checked: bool = False
80
+ value: str = "on" # Valor por defecto cuando está marcado
81
+
82
+ @field_validator('default_value')
83
+ @classmethod
84
+ def validate_default_value(cls, v: Any) -> Any:
85
+ if v is not None and not isinstance(v, bool):
86
+ raise ValueError(t("checkbox.default_must_be_boolean"))
87
+ return v
88
+
89
+
90
+ class CheckboxGroupField(FormFieldBase):
91
+ field_type: FieldType = FieldType.CHECKBOX
92
+ options: List[SelectOption]
93
+ inline: bool = False
94
+
95
+ @field_validator('default_value')
96
+ @classmethod
97
+ def validate_default_value(cls, v: Any) -> Any:
98
+ if v is not None and not isinstance(v, list):
99
+ raise ValueError(t("checkbox_group.default_must_be_list"))
100
+ return v
101
+
102
+
103
+ class RadioField(FormFieldBase):
104
+ field_type: FieldType = FieldType.RADIO
105
+ options: List[SelectOption]
106
+ inline: bool = False
107
+
108
+ @field_validator('default_value')
109
+ @classmethod
110
+ def validate_default_value(cls, v: Any) -> Any:
111
+ if v is not None and not isinstance(v, str):
112
+ raise ValueError(t("radio.default_must_be_string"))
113
+ return v
114
+
115
+
116
+ class SelectField(FormFieldBase):
117
+ field_type: FieldType = FieldType.SELECT
118
+ options: List[SelectOption]
119
+ multiple: bool = False
120
+ min_selected: Optional[int] = None # Mínimo de opciones a seleccionar
121
+ max_selected: Optional[int] = None # Máximo de opciones a seleccionar
122
+
123
+ @field_validator('min_selected')
124
+ @classmethod
125
+ def validate_min_selected(cls, v: Optional[int], info: Any) -> Optional[int]:
126
+ if v is not None:
127
+ if v < 0:
128
+ raise ValueError(t("select.min_selected_negative"))
129
+ if not info.data.get('multiple', False):
130
+ raise ValueError(t("select.min_selected_requires_multiple"))
131
+ return v
132
+
133
+ @field_validator('max_selected')
134
+ @classmethod
135
+ def validate_max_selected(cls, v: Optional[int], info: Any) -> Optional[int]:
136
+ if v is not None:
137
+ if v < 1:
138
+ raise ValueError(t("select.max_selected_min_value"))
139
+ if not info.data.get('multiple', False):
140
+ raise ValueError(t("select.max_selected_requires_multiple"))
141
+ min_selected = info.data.get('min_selected')
142
+ if min_selected is not None and v < min_selected:
143
+ raise ValueError(t("select.max_less_than_min"))
144
+ return v
145
+
146
+ def get_valid_values(self) -> Set[str]:
147
+ """Retorna un conjunto de valores válidos para este campo"""
148
+ return {option.value for option in self.options}
149
+
150
+
151
+ class TextField(FormFieldBase):
152
+ field_type: FieldType = FieldType.TEXT
153
+ minlength: Optional[int] = None
154
+ maxlength: Optional[int] = None
155
+ pattern: Optional[str] = None
156
+
157
+ @field_validator('pattern')
158
+ @classmethod
159
+ def validate_pattern(cls, v: Optional[str]) -> Optional[str]:
160
+ if v is not None:
161
+ try:
162
+ re.compile(v)
163
+ except re.error:
164
+ raise ValueError(t("text.invalid_regex"))
165
+ return v
166
+
167
+ def validate_value(self, value: str) -> tuple[bool, Optional[str]]:
168
+ if value is None:
169
+ if self.required:
170
+ return False, t("field.required")
171
+ return True, None
172
+
173
+ if self.minlength and len(value) < self.minlength:
174
+ return False, t("text.minlength", min=self.minlength)
175
+
176
+ if self.maxlength and len(value) > self.maxlength:
177
+ return False, t("text.maxlength", max=self.maxlength)
178
+
179
+ if self.pattern and not re.match(self.pattern, value):
180
+ return False, t("text.pattern_mismatch")
181
+
182
+ return True, None
183
+
184
+
185
+ class EmailField(FormFieldBase):
186
+ field_type: FieldType = FieldType.EMAIL
187
+
188
+ @field_validator('default_value')
189
+ @classmethod
190
+ def validate_email(cls, v: Any) -> Any:
191
+ if v is not None:
192
+ EmailStr.validate(v)
193
+ return v
194
+
195
+
196
+ class NumberField(FormFieldBase):
197
+ field_type: FieldType = FieldType.NUMBER
198
+ min_value: Optional[float] = None
199
+ max_value: Optional[float] = None
200
+ step: Optional[float] = None
201
+
202
+
203
+ class DateField(FormFieldBase):
204
+ field_type: FieldType = FieldType.DATE
205
+ min_date: Optional[date] = None
206
+ max_date: Optional[date] = None
207
+
208
+
209
+ class FileField(FormFieldBase):
210
+ field_type: FieldType = FieldType.FILE
211
+ accept: Optional[str] = None
212
+ multiple: bool = False
213
+
214
+
215
+ class HiddenField(FormFieldBase):
216
+ field_type: FieldType = FieldType.HIDDEN
217
+ value: Union[str, int, float, bool] = ""
218
+
219
+
220
+ class UrlField(FormFieldBase):
221
+ """Campo para URLs"""
222
+ field_type: FieldType = FieldType.URL
223
+ minlength: Optional[int] = None
224
+ maxlength: Optional[int] = None
225
+
226
+ @field_validator('default_value')
227
+ @classmethod
228
+ def validate_url(cls, v: Any) -> Any:
229
+ if v is not None and isinstance(v, str):
230
+ # Validación básica de URL
231
+ if not v.startswith(('http://', 'https://')):
232
+ raise ValueError(t("url.invalid_scheme"))
233
+ return v
234
+
235
+
236
+ class TextareaField(FormFieldBase):
237
+ """Campo de texto multilínea"""
238
+ field_type: FieldType = FieldType.TEXTAREA
239
+ minlength: Optional[int] = None
240
+ maxlength: Optional[int] = None
241
+ rows: Optional[int] = 3
242
+ cols: Optional[int] = None
243
+
244
+
245
+ class ListField(FormFieldBase):
246
+ """Campo para listas de valores (ej: lista de participantes)"""
247
+ field_type: FieldType = FieldType.LIST
248
+ min_items: Optional[int] = None
249
+ max_items: Optional[int] = None
250
+ item_type: str = "text" # Tipo de cada item en la lista
251
+
252
+
253
+ class FieldGroup(BaseModel):
254
+ """Representa un grupo de campos en un formulario para organización en secciones"""
255
+ id: UUID = Field(default_factory=uuid4)
256
+ title: str
257
+ description: Optional[str] = None
258
+ fields: List[Union[
259
+ 'TextField',
260
+ 'EmailField',
261
+ 'NumberField',
262
+ 'DateField',
263
+ 'SelectField',
264
+ 'RadioField',
265
+ 'CheckboxField',
266
+ 'CheckboxGroupField',
267
+ 'FileField',
268
+ 'HiddenField',
269
+ 'UrlField',
270
+ 'TextareaField',
271
+ 'ListField'
272
+ ]]
273
+ css_classes: Optional[str] = None
274
+ attributes: Dict[str, str] = Field(default_factory=dict)
275
+ collapsible: bool = False # Si el grupo puede colapsarse
276
+ collapsed: bool = False # Si el grupo está colapsado por defecto
277
+
278
+ model_config = {
279
+ "json_serializers": {
280
+ UUID: str,
281
+ datetime: lambda v: v.isoformat(),
282
+ date: lambda v: v.isoformat()
283
+ }
284
+ }
285
+
286
+ @model_validator(mode='after')
287
+ def validate_field_names_in_group(self) -> 'FieldGroup':
288
+ """Valida que los nombres de campos dentro del grupo sean únicos"""
289
+ names = [field.name for field in self.fields]
290
+ if len(names) != len(set(names)):
291
+ raise ValueError(t("form.unique_field_names_in_group", title=self.title))
292
+ return self
293
+
294
+ def export(self, output_format: str = 'html', **kwargs) -> str:
295
+ """Método para exportar el grupo de campos en diferentes formatos"""
296
+ from codeforms.export import group_exporter
297
+ return group_exporter(self, output_format, kwargs=kwargs)
298
+