Mata tus formularios hardcodeados: cómo la generación de formularios basada en JSON cambió mi forma de construir aplicaciones Django
¿Y si cada formulario de tu aplicación Django (crear, editar, actualización masiva, modal, en línea) se definiera como un diccionario de Python en lugar de una clase? Dos sistemas en producción me enseñaron que los formularios son datos, no código, y la diferencia es transformadora.
El dolor de los formularios codificados a mano
Todo desarrollador de Django conoce el ritual. Defines un modelo. Luego escribes un ModelForm. Luego ajustas los widgets. Luego añades un layout crispy. Luego el cliente dice "añade un campo" y tocas cuatro archivos. Luego dice "haz que ese campo sea condicional" y añades JavaScript. Luego dice "el mismo formulario, pero en un modal" y duplicas una plantilla.
Multiplica esto por 20 modelos y estarás manteniendo un pequeño ejército de clases de formulario, cada una ligeramente distinta, cada una codificada a mano, cada una desincronizándose silenciosamente del modelo que representa.
Choqué con este muro en dos proyectos muy distintos - un sistema industrial de control de calidad de fabricación y un back office de trading financiero - y llegué a la misma solución en ambas ocasiones: dejar de escribir clases de formulario y empezar a escribir configuraciones de formulario.
La idea central: los formularios son datos
Un campo de formulario de Django es en realidad solo un conjunto de propiedades: un nombre, un tipo, un widget, una etiqueta, si es obligatorio, qué opciones tiene. No hay nada en esa descripción que requiera la definición de una clase de Python. Son datos, y los datos pertenecen a una estructura de datos.
La idea es sencilla: define los campos como diccionarios, almacénalos en un esquema y deja que una única clase de formulario genérica se construya a sí misma a partir de ese esquema en tiempo de ejecución.
# En lugar de esto:
class InspectionForm(forms.Form):
diameter = forms.FloatField(
label="Inner diameter",
widget=forms.NumberInput(attrs={"class": "form-control"}),
)
status = forms.ChoiceField(
choices=[("pass", "Pass"), ("fail", "Fail")],
widget=forms.Select(attrs={"class": "form-select"}),
)
# Escribe esto:
INSPECTION_SCHEMA = {
"fields": [
{
"name": "diameter",
"type": "FloatField",
"widget": "NumberInput",
"label": "Inner diameter",
"widget_attrs": {"class": "form-control"},
},
{
"name": "status",
"type": "ChoiceField",
"widget": "Select",
"label": "Status",
"choices": [("pass", "Pass"), ("fail", "Fail")],
"widget_attrs": {"class": "form-select"},
},
]
}
El motor DynamicForm
Todo el sistema se apoya en una única clase. Mapea identificadores de cadena a clases de campo y clases de widget de Django, y luego itera sobre el esquema para construirse a sí misma:
class DynamicForm(forms.Form):
FIELD_MAP = {
"CharField": forms.CharField,
"ChoiceField": forms.ChoiceField,
"FloatField": forms.FloatField,
"IntegerField": forms.IntegerField,
"BooleanField": forms.BooleanField,
"DateField": forms.DateField,
"DecimalField": forms.DecimalField,
"EmailField": forms.EmailField,
# ... cada tipo de campo de Django
}
WIDGET_MAP = {
"TextInput": forms.TextInput,
"Select": forms.Select,
"NumberInput": forms.NumberInput,
"Textarea": forms.Textarea,
"CheckboxInput": forms.CheckboxInput,
"DateInput": forms.DateInput,
# ... cada tipo de widget de Django
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for field_cfg in self.scheme["fields"]:
field_class = self.FIELD_MAP[field_cfg["type"]]
widget_class = self.WIDGET_MAP[field_cfg["widget"]]
self.fields[field_cfg["name"]] = field_class(
label=field_cfg["label"],
widget=widget_class(
attrs=field_cfg.get("widget_attrs", {})
),
required=field_cfg.get("required", True),
choices=field_cfg.get("choices", []),
)
Ese es todo el motor. Cada tipo de campo de Django, cada tipo de widget, mapeados mediante búsqueda por cadena. Un nuevo formulario es un nuevo diccionario, no una nueva clase.
Caso práctico 1: control de calidad en la fabricación
El primer proyecto fue un sistema de datos de producción para un fabricante industrial. Los inspectores de calidad siguen Procedimientos Operativos Estándar (SOP) que requieren medir dimensiones específicas, verificar tratamientos de superficie y registrar resultados de aprobado/reprobado para cada etapa de producción.
El desafío: más de 20 formularios de inspección diferentes, cada uno con campos únicos específicos del tipo de pieza de trabajo y del paso del SOP. La inspección de un torno vertical de placa terminal mide cosas diferentes que la inspección de fresado de una placa bipolar.
Con formularios codificados a mano, eso son más de 20 clases de formulario, cada una con sus propias definiciones de campo, su propia validación, sus propias peculiaridades de plantilla. Con el enfoque de blueprints, son más de 20 diccionarios y una sola clase de formulario.
# Cada inspección de QC es simplemente una definición de esquema
QC_ENDPLATE_LATHE_SCHEMA = {
"fields": [
{
"name": "inner_diameter",
"widget": "TextInput",
"label": _("Diámetro interior: 1846mm"),
},
{
"name": "outer_diameter",
"widget": "TextInput",
"label": _("Diámetro exterior: 1876-1876.1mm"),
},
{
"name": "surface_qualified",
"type": "ChoiceField",
"widget": "Select",
"label": _("Calificación de superficie"),
"choices": [
("qualified", _("Calificado")),
("unqualified", _("No calificado")),
],
},
]
}
# La clase del formulario es una sola línea
class EndPlateLathQCForm(DynamicForm, QCValidationMixin):
scheme = QC_ENDPLATE_LATHE_SCHEMA
Los datos recopilados se almacenan como JSON en un JSONField, conservando tanto las etiquetas de los campos como sus valores. Esto significa que el registro de inspección es autodescriptivo: puedes leerlo sin saber qué esquema lo generó.
# Los datos se almacenan como JSON autodescriptivo
{
"inner_diameter": {
"label": "Diámetro interior: 1846mm",
"value": "1846.02"
},
"surface_qualified": {
"label": "Calificación de superficie",
"value": "qualified"
}
}
Caso práctico 2: back office de trading financiero
El segundo proyecto llevó el patrón más lejos. Un back office de trading de criptomonedas necesitaba formularios para cuentas, comisiones, perfiles de riesgo, estado de cumplimiento, planes de tarifas; cada uno con campos distintos, permisos distintos, métodos de envío distintos (POST para crear, PATCH para actualizaciones masivas) y contextos de renderizado distintos (página completa, modal, en línea).
Aquí el blueprint creció más allá de las definiciones de campos hasta convertirse en una especificación completa del formulario:
ACCOUNT_CREATE_BLUEPRINT: FormBlueprint = {
"name": "account_create",
"title": "Create Account",
"column_count": 2,
"modal_size": "xl",
"method": "POST",
"api_config": "account:accounts_v1",
"success_message": "Account created successfully",
"fields": [
{
"name": "currency",
"type": "ChoiceField",
"label": "Currency",
"permission": "ACCOUNT_CURRENCY_UPDATE",
"api_choices": "common:unified_search",
"api_choices_args": ["assets-v1"],
"api_choices_autocomplete": True,
"row_class": "col-md-6",
},
{
"name": "commission_rate",
"type": "FloatField",
"label": "Commission Rate (%)",
"permission": "ACCOUNT_COMMISSION_UPDATE",
"widget_attrs": {"step": "0.01", "min": "0"},
"row_class": "col-md-6",
},
],
}
Fíjate en lo que está codificado en ese único diccionario: el método HTTP, el endpoint de la API, el tamaño del modal, qué campos requieren qué permisos, de dónde cargar las opciones de los desplegables, el layout de rejilla de Bootstrap y el mensaje de éxito. Eso es toda una experiencia de formulario en datos puros.
Visibilidad de campos según permisos
La plataforma financiera añadió una función potente: control de permisos por campo. Cada campo del blueprint puede declarar una cadena de permiso. El renderizador de formularios comprueba los permisos del usuario actual y se degrada de forma elegante:
class FormRenderer:
def _apply_field_permissions(self, fields, user):
user_perms = get_user_section_permissions(user)
for field in fields:
required_perm = field.get("permission")
if required_perm and required_perm not in user_perms:
field.setdefault("widget_attrs", {})
field["widget_attrs"]["disabled"] = True
field["widget_attrs"]["readonly"] = True
Los usuarios sin el permiso ACCOUNT_COMMISSION_UPDATE ven el campo de la tasa de comisión, pero es de solo lectura y aparece atenuado. Sin plantilla aparte, sin bloques {% if perms.x %}, sin lógica de renderizado condicional. El blueprint declara la regla, el renderizador la aplica.
Opciones cargadas por API y autocompletado
Las listas de opciones estáticas están bien para desplegables de "Cualificado/No cualificado", pero un selector de moneda necesita extraer datos de una API en vivo. El blueprint gestiona esto con dos propiedades:
api_choices: un nombre de URL de Django que devuelve las opcionesapi_choices_autocomplete: habilita Select2 con búsqueda del lado del servidor
El motor de formularios resuelve el nombre de la URL, obtiene las opciones en el momento del renderizado y conecta el autocompletado de Select2 si está configurado. El autor del blueprint nunca escribe JavaScript: simplemente declara dónde viven los datos.
Por qué esto es mejor que los formularios codificados a mano
Después de construir dos aplicaciones grandes con este patrón, puedo articular con exactitud lo que te aporta:
- Añadir un formulario es añadir datos, no código. Una nueva inspección de QC o un nuevo tipo de cuenta es un nuevo diccionario. Ninguna clase nueva, ninguna plantilla nueva, ninguna URL nueva. El sistema lo descubre y lo renderiza.
- Cambiar un formulario es cambiar un solo sitio. ¿La etiqueta de un campo está mal? Cambia el diccionario. ¿Necesitas una nueva opción en el desplegable? Actualiza la lista de opciones. ¿Necesitas hacer un campo opcional? Establece
"required": False. Un archivo, un cambio, hecho. - Los formularios se vuelven portátiles. Un blueprint es solo un diccionario. Puedes serializarlo a JSON, almacenarlo en una base de datos, cargarlo desde un archivo de configuración o enviarlo a través de una API. Al motor de formularios no le importa de dónde vino el esquema.
- La consistencia es automática. Cada formulario pasa por la misma canalización de renderizado. Los atributos de los widgets, las clases CSS, el comportamiento del diseño: todo está estandarizado por el motor, no copiado y pegado entre clases de formulario.
- Almacenamiento de datos autodescriptivo. Cuando los datos del formulario se guardan como JSON con etiquetas y valores, el registro almacenado es legible por humanos sin necesidad de consultar el esquema. Los informes y las exportaciones se vuelven triviales.
- Los mixins de validación se componen con limpieza. El formulario dinámico sigue siendo un formulario de Django. Puedes añadir mixins para validaciones específicas del dominio (comprobaciones de existencia de piezas de trabajo, validación de permisos) sin tocar el esquema.
Cuándo recurrir a este patrón
Los formularios dinámicos no siempre son la elección correcta. Para un formulario de inicio de sesión con dos campos, un ModelForm es más simple y más claro. Pero el patrón brilla cuando:
- Tienes muchos formularios similares con distintos conjuntos de campos (inspecciones de control de calidad, pasos de incorporación, páginas de encuestas)
- Los formularios necesitan ser configurables sin cambios de código - por administradores, por clientes o por sistemas externos
- Necesitas control de campos basado en permisos a lo largo de muchos formularios sin duplicar la lógica de plantillas
- Los datos del formulario necesitan almacenarse como JSON autodescriptivo en lugar de en columnas fijas de la base de datos
- Estás construyendo una plataforma con múltiples inquilinos que cada uno necesita formularios ligeramente distintos
El coste inicial es una clase DynamicForm (unas 50 líneas) y la disciplina de definir esquemas en lugar de clases de formulario. La recompensa es un sistema en el que añadir el formulario número 21 cuesta el mismo esfuerzo que añadir el segundo.
Un formulario codificado a mano es una instantánea de los requisitos de hoy. Un esquema de formulario es un contrato que evoluciona. Escribe el contrato.