Módulo 4 · Lección 45 de 45
Proyecto: un tablero de incidencias listo para producción
El proyecto que llega hasta el servidor: usuario propio desde el minuto uno, avisos por correo que no tiran la operación, ajustes por entorno y veinte pruebas.
Último proyecto del módulo, y el que lo cierra de verdad. Un tablero de incidencias es una herramienta interna: no tiene parte pública, todo el mundo está identificado, y cada uno ve unas cosas distintas. Eso lo hace el ejemplo correcto para el bloque F, porque obliga a tomar desde el principio las decisiones que la bitácora podía dejar para después.
Y sobre todo, este proyecto llega al final. No acaba en runserver: acaba con check --deploy limpio y Gunicorn sirviéndolo.
Las tres decisiones del primer día#
Antes de escribir ningún modelo, tres cosas. Las tres se pueden cambiar después, y las tres cuestan diez veces más.
Modelo de usuario propio, que es la lección 34. La primera migración, antes que nada:
class Usuario(AbstractUser):
correo = models.EmailField('correo', unique=True)
equipo = models.CharField('equipo', max_length=60, blank=True)
recibe_avisos = models.BooleanField('recibe avisos por correo', default=True)
USERNAME_FIELD = 'correo'
REQUIRED_FIELDS = ['username']AUTH_USER_MODEL = 'cuentas.Usuario'Se entra con el correo, no con un nombre de usuario, que es lo que espera cualquiera en una herramienta de trabajo. Y recibe_avisos está ahí desde el principio porque un sistema que manda correos sin manera de apagarlos acaba en la carpeta de no deseado.
La lección 34 midió lo que pasa si esto se deja para después: un InconsistentMigrationHistory que no se arregla con ningún ajuste.
Ajustes partidos, que es la lección 40. Cuatro módulos desde el principio:
cfg/ajustes/
base.py lo común, con DEBUG = False
desarrollo.py DEBUG = True y los dominios locales
pruebas.py base de datos en memoria y hasheo rápido
produccion.py todo lo de verdad, leído del entornoFíjate en que base.py tiene DEBUG = False. Es al revés de lo que trae startproject, y es a propósito: el valor peligroso hay que pedirlo, no heredarlo. Un módulo de ajustes nuevo que se olvide de decirlo será seguro.
La dirección del panel, que es la deuda de la lección 30:
RUTA_PANEL = os.environ.get('RUTA_PANEL', 'panel-interno/')path(settings.RUTA_PANEL, admin.site.urls),Sale del entorno, así que en producción es algo que no se adivina y en desarrollo es cómoda.
Visibilidad, que aquí es el modelo#
La bitácora tenía contenido público. Aquí no hay nada público, y quién ve qué es una regla de negocio:
class IncidenciaQuerySet(models.QuerySet):
def visibles_para(self, usuario):
if usuario.is_staff:
return self
return self.filter(models.Q(autor=usuario) | models.Q(asignada_a=usuario))Cuatro líneas, un sitio. Y todas las vistas empiezan por ahí, incluida la que añade notas:
def _visible(peticion, pk):
return get_object_or_404(Incidencia.objetos.visibles_para(peticion.user), pk=pk)cada uno ve lo suyo ok
el staff lo ve todo ok
la ajena da 404 ok
la asignada a mí se ve ok
editar lo ajeno da 403 ok
anotar lo ajeno da 404 okLas dos últimas son las que distinguen esto de un tablero con un cartel. La de anotar es la que más se olvida: es fácil acordarse de proteger el detalle y dejar abierta la vista que escribe, y ahí el fallo es peor, porque no solo se ve algo ajeno, se modifica.
Los avisos por correo#
La parte más delicada del proyecto, y la que aplica lo que midió la lección 38. Está en su propio módulo, a propósito:
def avisar_asignacion(incidencia, base_url='https://tablero.ejemplo.es'):
"""Avisa a quien se le asigna una incidencia. Nunca lanza."""
destino = incidencia.asignada_a
if destino is None or not destino.recibe_avisos:
return 0
mensaje = EmailMultiAlternatives(
subject=f'[#{incidencia.pk}] Te han asignado: {incidencia.titulo}',
body=render_to_string('tablero/correo/asignacion.txt', contexto),
to=[destino.correo],
)
mensaje.attach_alternative(
render_to_string('tablero/correo/asignacion.html', contexto), 'text/html')
try:
return mensaje.send()
except Exception:
registro.exception('No se pudo avisar de la incidencia %s', incidencia.pk)
return 0Cinco decisiones, y cada una tiene su prueba.
En un módulo aparte, para poder probarlo y para poder moverlo a una cola sin tocar las vistas.
Las dos versiones, texto y HTML, desde plantillas. El día que se quiera cambiar el texto, no hay que abrir código Python.
Respeta la preferencia del destinatario.
Captura y registra. Ese except Exception sería un error en casi cualquier otro sitio, y aquí es lo correcto: es la diferencia entre "no te llegó el correo" y "no se pudo crear la incidencia". Y registro.exception guarda la traza entera, que es lo contrario del fail_silently que la lección 38 desaconsejaba.
Devuelve un número, para que la vista pueda decirlo:
if avisar_asignacion(self.object):
messages.info(self.request, f'Se ha avisado a {self.object.asignada_a}.')Y así se prueba que la captura funciona de verdad:
with override_settings(MAILERS={'default': {
'BACKEND': 'django.core.mail.backends.smtp.EmailBackend',
'OPTIONS': {'host': 'localhost', 'port': 1, 'timeout': 1}}}):
with self.assertLogs('tablero.avisos', level='ERROR'):
r = self.client.post(reverse('tablero:nueva'), {...})
self.assertEqual(r.status_code, 302)
self.assertTrue(Incidencia.objetos.filter(titulo='Correo roto').exists())Apunta el correo a un puerto donde no hay nada, comprueba que se registró el error y que la incidencia se creó igual. Esa prueba es la que impide que alguien quite el try seis meses después.
Los adjuntos#
La lección 29 dejó cuatro reglas, y las cuatro están aquí:
def ruta_adjunto(instancia, nombre):
limpio = Path(nombre).name # neutraliza ../../
sufijo = secrets.token_hex(4) # dos ficheros con el mismo nombre no chocan
return f'adjuntos/{timezone.now():%Y/%m}/{sufijo}-{limpio}'adjunto = models.FileField(upload_to=ruta_adjunto, blank=True,
validators=[FileExtensionValidator(
['png', 'jpg', 'jpeg', 'pdf', 'txt', 'log'])])Y el tamaño, en el formulario, porque no es cosa del modelo:
def clean_adjunto(self):
fichero = self.cleaned_data.get('adjunto')
if fichero and fichero.size > MAX_ADJUNTO:
raise forms.ValidationError('El adjunto no puede pasar de %(max)s MB.',
code='demasiado_grande', params={'max': 5})
return ficheroLa prueba del recorrido de rutas es la interesante, porque enseña algo que no estaba medido:
fichero = SimpleUploadedFile('../../evil informe.txt', b'contenido', 'text/plain')adjuntos/2026/09/a9869e67-evil_informe.txtLos ../../ se han ido por el Path(nombre).name, y el espacio se ha convertido en un guion bajo sin que nadie lo pida: eso lo hace el almacenamiento de Django por su cuenta. Es una limpieza más que agradecer, y un recordatorio de que el nombre guardado no es el que subió el usuario. Si quieres enseñar el original, hay que guardarlo aparte.
Las veinte pruebas#
Ran 20 tests in 0.095s
OKNoventa y cinco milisegundos para veinte pruebas que suben ficheros, mandan correos y comprueban permisos. Eso no es casualidad: es el módulo de ajustes de pruebas.
DATABASES['default'] = {'ENGINE': 'django.db.backends.sqlite3', 'NAME': ':memory:'}
PASSWORD_HASHERS = ['django.contrib.auth.hashers.MD5PasswordHasher']PBKDF2PasswordHasher 173.7 ms
MD5PasswordHasher 0.1 msMil setecientas veces. El hasheo bueno es lento a propósito, que es lo que la lección 34 explicaba: es lo que hace inviable probar contraseñas a la fuerza. Pero en las pruebas, donde se crean usuarios a docenas, esos ciento setenta milisegundos por usuario son la diferencia entre una suite que se lanza al guardar y una que no se lanza.
Y por eso este ajuste solo puede estar en el módulo de pruebas. En cualquier otro sitio sería un agujero enorme.
El reparto de las veinte: cuatro del modelo, seis de visibilidad, cuatro de los avisos, tres de adjuntos, dos de notas y una del número de consultas.
la lista con 22 incidencias -> 4 consultasLa sesión, el usuario, el recuento y los datos. Constante, con dos claves ajenas resueltas en la misma consulta por el con_todo().
Hasta el servidor#
CLAVE_SECRETA=... HOSTS_PERMITIDOS=tablero.ejemplo.es SMTP_HOST=smtp.ejemplo.es \
python manage.py check --deploy --settings=cfg.ajustes.produccionWARNINGS:
?: (security.W021) You have not set the SECURE_HSTS_PRELOAD setting to True.
System check identified 1 issue (0 silenced).Uno, y es el que la lección 42 recomienda no activar a la ligera. Los demás están cubiertos.
Y funcionando de verdad, con Gunicorn:
/ (anónimo) -> 301 https://...
panel en /admin/ -> 301, y detrás no hay nada
panel movido -> 301 https://.../panel-interno/Lo último, que es lo que explica un hallazgo que quedó suelto:
sin cabecera de proxy -> 301 a https
con cabecera de proxy -> 200Eso es SECURE_PROXY_SSL_HEADER:
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')Detrás de un proxy, Django ve una conexión sin cifrar aunque el usuario esté en HTTPS, porque el cifrado acaba en el proxy. Sin ese ajuste pasan dos cosas: la redirección a HTTPS entra en un bucle infinito, y los enlaces de recuperación de contraseña salen con http://, que es exactamente lo que se midió en la lección 38 y quedó apuntado como pendiente.
Ahora queda cerrado, y con el aviso que lo acompaña: ese ajuste solo se pone si hay de verdad un proxy delante que ponga esa cabecera. Si no, cualquiera puede mandarla y hacer creer a Django que su conexión es segura.