Saltar al contenido

Capítulo 6 de 14

Formularios y reglas de negocio

Convertir entradas no fiables en valores válidos, conservar errores y separar una buena experiencia de una garantía de escritura.

#Tres momentos que no conviene mezclar

Un formulario tiene tres trabajos: mostrar campos, interpretar una entrada y explicar errores. Una operación de negocio decide si esa entrada puede cambiar el sistema ahora. La base comprueba sus restricciones al guardar. Una misma regla puede aparecer en más de una capa por razones distintas: buena respuesta al usuario y protección frente a carreras.

Un navegador puede marcar un campo como required o limitar un número, pero cualquier cliente puede enviar un POST manual. La validación del servidor es obligatoria. A la vez, un IntegrityError de la base no es una experiencia aceptable para una falta ortográfica en el título: intenta explicar antes los errores previsibles.

Formulario GET sin datos, POST ligado a entrada, validación con errores que vuelve a la misma página o servicio y commit que terminan en redirección y GET.Ampliar diagrama ↗

#ModelForm: elegir campos permitidos

En la web de artículos crea articulos/forms.py:

from django import forms
from .models import Articulo


class ArticuloForm(forms.ModelForm):
    class Meta:
        model = Articulo
        fields = ["categoria", "titulo", "slug", "contenido", "publicado"]
        widgets = {"contenido": forms.Textarea(attrs={"rows": 8})}

    def clean_titulo(self):
        titulo = self.cleaned_data["titulo"].strip()
        if len(titulo) < 8:
            raise forms.ValidationError("Escribe un título de al menos ocho caracteres.")
        return titulo

    def clean(self):
        datos = super().clean()
        if datos.get("publicado") and len(datos.get("contenido", "").strip()) < 50:
            self.add_error("contenido", "Un artículo publicado necesita al menos 50 caracteres.")
        return datos

ModelForm obtiene tipos y parte de las reglas del modelo; fields es una lista explícita de lo que el cliente puede modificar. No uses fields="__all__" por comodidad en una interfaz pública: añadir mañana propietario, aprobado o organizacion al modelo podría convertirlo sin querer en un campo editable.

widgets cambia cómo se presenta el campo, no su validación. clean_titulo recibe el valor ya convertido en cleaned_data, devuelve el valor normalizado o lanza un error. clean combina varios campos; super().clean() conserva el comportamiento heredado. Usa .get porque un campo inválido puede no aparecer en cleaned_data. add_error coloca un mensaje junto al campo concreto; una excepción general en clean se muestra como error del formulario completo.

#Formulario nuevo y formulario ligado

form_nuevo = ArticuloForm()
form_enviado = ArticuloForm({"titulo": "Corto"})
print(form_nuevo.is_bound)    # False
print(form_enviado.is_valid())  # False: faltan otros campos y el título es corto
print(form_enviado.errors)

Un formulario ligado conoce los datos que el cliente envió, incluso si están vacíos. is_valid() realiza conversión y validación, y solo entonces debes usar cleaned_data. Los datos originales permanecen disponibles para volver a dibujar los campos con errores. No redirijas inmediatamente cuando falla: perderías esa entrada y obligarías a escribirla otra vez.

#Una view de creación con límites claros

Vamos a permitir escribir solo a usuarios con el permiso de añadir artículos. Django ya incluye autenticación y permisos; el siguiente capítulo explica cómo configurar login y concederlos. Hasta entonces puedes utilizar una cuenta superusuaria local iniciando sesión en /admin/. Esta restricción evita publicar una view de escritura abierta mientras aprendes formularios.

Añade a articulos/views.py:

from django.contrib.auth.decorators import login_required, permission_required
from django.db import IntegrityError, transaction
from django.shortcuts import redirect, render
from django.views.decorators.http import require_http_methods
from .forms import ArticuloForm


@login_required
@permission_required("articulos.add_articulo", raise_exception=True)
@require_http_methods(["GET", "POST"])
def crear(request):
    form = ArticuloForm(request.POST if request.method == "POST" else None)
    if request.method == "POST" and form.is_valid():
        try:
            with transaction.atomic():
                articulo = form.save()
        except IntegrityError:
            # Este formulario solo añade una fila; devuelve conflicto sin afirmar qué constraint falló.
            form.add_error(None, "No se pudo guardar por un conflicto de datos. Revisa categoría y slug.")
        else:
            return redirect("articulos:listado")
    return render(request, "articulos/formulario.html", {"form": form})

Registra path("nuevo/", views.crear, name="crear"). Pon LOGIN_URL = "/admin/login/" en ajustes para esta fase local; en el capítulo siguiente lo sustituiremos por el login de la web. La variable articulo ilustra el objeto devuelto por save, aunque la redirección no necesita su ID. No redirigimos al detalle porque podría ser un borrador y ese detalle, deliberadamente, devuelve 404.

La validación de ModelForm puede descubrir un slug ya usado, pero otro proceso puede insertarlo entre validar y guardar. La transacción permite salir de una escritura fallida antes de continuar. Este ejemplo convierte cualquier conflicto de integridad en un error general y no dice que todos sean duplicados. En una operación con varias restricciones, identifica la restricción esperada y deja que los otros errores se registren como fallos internos; el proyecto final lo hace con el diagnóstico de PostgreSQL.

#El token CSRF y la redirección posterior

Crea articulos/templates/articulos/formulario.html:

{% extends 'base.html' %}
{% block contenido %}
<h1>Nuevo artículo</h1>
<form method="post">
  {% csrf_token %}
  {{ form.as_p }}
  <button type="submit">Guardar</button>
</form>
{% endblock %}

La sesión identifica al usuario mediante una cookie que el navegador envía automáticamente. Esa comodidad permite que una página de otro origen intente provocar peticiones usando la sesión de la víctima. El token CSRF ayuda a comprobar que la petición de cambio procede de un contexto permitido por tu aplicación; el middleware verifica además origen/referente según el caso. No es un permiso de usuario ni una protección contra XSS.

{% csrf_token %} genera un campo oculto y Django verifica su valor al recibir POST. No añadas csrf_exempt para resolver un formulario que falla: comprueba token, cookies, esquema HTTPS y configuración del proxy. Un cliente API autenticado exclusivamente con un token en cabecera tiene otro modelo; mezclar cookies con excepciones de CSRF sin entenderlo deja una vía de ataque.

Cuando guardar funciona, la respuesta redirige a un GET. El patrón POST → redirect → GET evita que una recarga normal repita el envío, pero no garantiza idempotencia ante doble clic o pérdida de respuesta. La concurrencia enseña esa frontera.

#Editar sin permitir que el cliente elija la autoridad

Para editar, el formulario recibe instance=objeto; en GET muestra sus valores y en POST aplica cambios a esa fila. El orden es esencial: primero seleccionas una instancia autorizada y después la vinculas al formulario.

# Fragmento de una view de edición: requiere su permiso y ruta propios.
articulo = get_object_or_404(Articulo.objects.all(), pk=pk)
form = ArticuloForm(
    request.POST if request.method == "POST" else None,
    instance=articulo,
)

En una aplicación por organizaciones, .all() se reemplazaría por un QuerySet ya filtrado por la organización que el usuario puede administrar. No aceptes una propiedad sensible porque el formulario la envíe como hidden: los campos ocultos son editables por el cliente. Con commit=False, form.save() devuelve una instancia aún no guardada para que asignes desde el servidor campos como el autor; después debes guardarla y, si hay relaciones many-to-many, llamar a form.save_m2m() tras obtener una clave primaria.

Una edición concurrente introduce otro problema: dos personas leen el mismo título, ambas guardan y la última pisa a la primera. Puedes bloquear durante una operación breve, o añadir una versión y ejecutar un UPDATE condicionado por la versión que se leyó. No mantengas una transacción abierta mientras una persona rellena el formulario durante minutos.

#Dónde poner una regla cuando deja de ser local

Una regla de formato como «título de ocho caracteres» pertenece bien al formulario. Una regla como «aprobar consume presupuesto de esta organización» necesita datos actuales y una transacción; debe estar en una función de servicio que pueda llamarse desde web, tareas o comandos. El servicio puede devolver un resultado o lanzar una excepción de negocio; la view decide cómo mostrarla.

No escondas todo en señales post_save. Una señal puede ser útil para extensiones desacopladas, pero una escritura crítica que dispara más escrituras, correo y llamadas externas invisibles dificulta saber qué se confirmó. Una llamada explícita a reservar(...) muestra el límite de operación y hace posible probarlo sin HTTP.

Comprobación: envía un formulario con errores y verifica que conserva título/contenido; publica uno válido y recarga el listado sin crear otra fila. Intenta enviar un campo extra como creado: no debe convertirse en una asignación arbitraria al modelo.

    ↑ ↓ para recorrer · Intro para abrir · Ctrl K para buscar