Saltar al contenido
🐍 Curso de Python
/

Módulo 4 · Lección 28 de 45

Widgets y cómo se dibuja un formulario

La pieza que decide el HTML, el selector de fechas que sale vacío, y el desplegable que aparece en inglés.

⏱ 15 min de lectura

Un campo de formulario son dos cosas pegadas que conviene separar: qué se acepta y cómo se pregunta. Lo primero es el campo y ya se ha visto entero. Lo segundo es el widget, y hasta ahora ha ido saliendo el que tocaba por defecto.

En cuanto hay un diseño, eso deja de bastar. Y hay dos casos donde no es cosmética sino un fallo de verdad: un selector de fecha que aparece vacío al editar, y un desplegable que sale en inglés en un sitio en español. Los dos están medidos aquí.

Qué sale por defecto#

Salida en pantalla
texto      <input type="text" name="texto" required id="id_texto">
largo      <textarea name="largo" cols="40" rows="3" required id="id_largo"></textarea>
clave      <input type="password" name="clave" required id="id_clave">
oculto     <input type="hidden" name="oculto" id="id_oculto">
fecha      <input type="date" name="fecha" required id="id_fecha">
numero     <input type="number" name="numero" min="0" max="120" step="1" required id="id_numero">
eleccion   <select name="eleccion"><option value="a">Uno</option>...</select>
radio      <div id="id_radio"><div><label for="id_radio_0"><input type="radio" ...
varias     <div id="id_varias"><div><label for="id_varias_0"><input type="checkbox" ...
si         <input type="checkbox" name="si" id="id_si">

El id se genera con el prefijo id_ y el name es el nombre del campo, y eso es lo que permite que la etiqueta apunte al control y que peticion.POST tenga las claves que tiene.

Los que hay que conocer, porque son los que se cambian:

CampoWidget por defectoAlternativa habitual
CharFieldTextInputTextarea, PasswordInput, HiddenInput
ChoiceFieldSelectRadioSelect
MultipleChoiceFieldSelectMultipleCheckboxSelectMultiple
DateFieldDateInputDateInput(attrs={'type': 'date'})
BooleanFieldCheckboxInput—
FileFieldClearableFileInputlección 29

Cambiarlo es un argumento:

python
mensaje = forms.CharField(widget=forms.Textarea(attrs={'rows': 3}))
clave = forms.CharField(widget=forms.PasswordInput)

Con paréntesis cuando se le pasa algo y sin ellos cuando no. Las dos formas valen.

Atributos#

attrs es un diccionario que va tal cual al HTML:

python
busca = forms.CharField(widget=forms.TextInput(attrs={
    'type': 'search',
    'placeholder': 'Buscar…',
    'autofocus': True,
}))
Salida en pantalla
<input type="search" name="busca" placeholder="Buscar…" required id="id_busca">

Sirve para cualquier atributo, incluidos los de accesibilidad (aria-describedby), los de comportamiento (autocomplete, inputmode) y los que usan las bibliotecas de JavaScript (data-...). Y fíjate en que se puede cambiar el type de un TextInput, que es la forma corta de conseguir search, tel, url o color sin widget propio.

Clases para todos los campos#

Poner class widget a widget es insufrible y es justo lo que pide cualquier marco de estilos. La forma correcta es el __init__:

python
class ContactoForm(forms.ModelForm):
        class Meta:
                model = Contacto
        fields = ['nombre', 'correo', 'telefono']

    def __init__(self, *args, **kwargs):
                super().__init__(*args, **kwargs)
        for campo in self.fields.values():
                        campo.widget.attrs.setdefault('class', 'form-control')
Salida en pantalla
<input type="text" name="nombre" class="form-control" required id="id_nombre">

Dos detalles que hacen que esto funcione bien. El super() va primero, porque self.fields no existe hasta que el padre lo construye. Y setdefault en lugar de asignación directa: así un campo que ya tenía su clase la conserva, en vez de perderla.

Ese __init__ es además el sitio de las otras dos cosas que hacen falta a menudo:

python
def __init__(self, *args, **kwargs):
        self.usuario = kwargs.pop('usuario')          # un argumento propio
    super().__init__(*args, **kwargs)
    self.fields['grupo'].queryset = Grupo.objects.filter(dueno=self.usuario)

El kwargs.pop antes del super(), porque el padre no sabe qué es usuario y protestaría. Y limitar el queryset de un campo de relación es lo que resuelve el problema de los desplegables enormes de la lección 26, y también un agujero de seguridad, porque un desplegable sin filtrar enseña los grupos de todo el mundo.

Marcar obligatorios y errores#

Sin tocar ninguna plantilla:

python
class ContactoForm(forms.ModelForm):
        required_css_class = 'obligatorio'
    error_css_class = 'con-error'
Salida en pantalla
<div class="obligatorio con-error">
  <label for="id_a" class="obligatorio">A:</label>
  <ul class="errorlist" id="id_a_error"><li>Este campo es obligatorio.</li></ul>
  <input type="text" name="a" required ...>

Django añade esas clases al contenedor y a la etiqueta de cada campo según corresponda. Con dos reglas de CSS ya tienes el asterisco de los obligatorios y el borde rojo de los que fallan, y el HTML de la plantilla no cambia.

El selector de fecha que sale vacío#

Esto no es cosmética. Un formulario de edición con una fecha ya guardada:

Salida en pantalla
SinType      <input type="text" name="fecha" value="22/09/2026" ...>
ConTypeMal   <input type="date" name="fecha" value="22/09/2026" ...>
ConTypeBien  <input type="date" name="fecha" value="2026-09-22" ...>

El del medio es el que se escribe siempre y el navegador lo enseña vacío. Un <input type="date"> solo entiende el valor en AAAA-MM-DD; con cualquier otra cosa, se calla y no pinta nada.

El efecto en producción es de los caros: alguien edita un registro, no ve la fecha, la rellena a ojo o la deja en blanco, y se pierde el dato original. Y no hay ningún error en ninguna parte.

El arreglo es decirle al widget en qué formato tiene que escribir:

python
fecha = forms.DateField(widget=forms.DateInput(attrs={'type': 'date'}, format='%Y-%m-%d'))

O ponerlo para todo el proyecto:

agenda/settings.py
FORM_RENDERER = 'django.forms.renderers.DjangoTemplates'
DATE_INPUT_FORMATS = ['%Y-%m-%d', '%d/%m/%Y']