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 +30 -0
- codeforms/export.py +331 -0
- codeforms/fields.py +298 -0
- codeforms/forms.py +530 -0
- codeforms/i18n.py +264 -0
- codeforms-0.1.0.dist-info/METADATA +263 -0
- codeforms-0.1.0.dist-info/RECORD +9 -0
- codeforms-0.1.0.dist-info/WHEEL +4 -0
- codeforms-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|
+
|