#El HTML se genera en el servidor
Un template es un documento con huecos y reglas sencillas de presentación. Django recibe un contexto —un diccionario con datos—, lo combina con la plantilla y produce HTML. El navegador recibe el resultado, no el Python de la view ni las instrucciones {% ... %}. Esto permite una web completa sin añadir un framework de JavaScript.
La view prepara qué se puede mostrar; el template decide cómo presentarlo. Un filtro de permisos no debe existir únicamente dentro de un {% if %}: si ya enviaste una fila privada al contexto, otra plantilla o un endpoint podría exponerla. Mantén la autorización y el tamaño de la consulta antes del renderizado.
#Una base compartida y una página concreta
En la práctica de artículos, crea articulos/templates/articulos. Django busca plantillas dentro de las aplicaciones instaladas cuando APP_DIRS=True, como en la configuración generada. El segundo nivel articulos/ da un espacio de nombres: evita colisiones con un listado.html de otra aplicación.
Crea articulos/templates/base.html:
{% load static %}
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block titulo %}Publicaciones{% endblock %}</title>
<link rel="stylesheet" href="{% static 'articulos/sitio.css' %}">
</head>
<body>
<header><a href="{% url 'articulos:listado' %}">Publicaciones</a></header>
<main>{% block contenido %}{% endblock %}</main>
</body>
</html>
block define zonas sustituibles; el contenido restante se comparte. url construye enlaces por nombre de ruta. static resuelve la URL de un recurso distribuido con el código. No concatenamos una ruta de disco del servidor dentro de un enlace HTML: el navegador necesita una URL que pueda solicitar.
Crea articulos/templates/articulos/listado.html, compatible con la view paginada del capítulo anterior:
{% extends 'base.html' %}
{% block titulo %}Artículos publicados{% endblock %}
{% block contenido %}
<h1>Artículos publicados</h1>
<ul>
{% for articulo in pagina %}
<li>
<a href="{% url 'articulos:detalle' articulo.pk %}">{{ articulo.titulo }}</a>
<span>{{ articulo.categoria.nombre }} · {{ articulo.creado|date:'d/m/Y' }}</span>
</li>
{% empty %}
<li>Todavía no hay artículos publicados.</li>
{% endfor %}
</ul>
<nav aria-label="Páginas de artículos">
{% if pagina.has_previous %}<a href="?pagina={{ pagina.previous_page_number }}">Anterior</a>{% endif %}
<span>Página {{ pagina.number }} de {{ pagina.paginator.num_pages }}</span>
{% if pagina.has_next %}<a href="?pagina={{ pagina.next_page_number }}">Siguiente</a>{% endif %}
</nav>
{% endblock %}
for recorre los resultados y empty describe el estado sin filas. El filtro date controla la presentación. Este template referencia una ruta detalle que todavía hay que añadir: un NoReverseMatch no significa que falte una tabla, sino que el nombre o argumentos de ruta no coinciden.
Añade esta función a articulos/views.py:
from django.shortcuts import get_object_or_404, render
from .models import Articulo
def detalle(request, pk):
articulo = get_object_or_404(
Articulo.objects.filter(publicado=True).select_related("categoria"), pk=pk
)
return render(request, "articulos/detalle.html", {"articulo": articulo})
Y path("<int:pk>/", views.detalle, name="detalle") a las rutas de la aplicación. El filtro publicado=True está también en el detalle: ocultar borradores en el listado no impediría abrir su URL directamente.
Crea articulos/templates/articulos/detalle.html:
{% extends 'base.html' %}
{% block titulo %}{{ articulo.titulo }}{% endblock %}
{% block contenido %}
<article>
<h1>{{ articulo.titulo }}</h1>
<p>Categoría: {{ articulo.categoria.nombre }}</p>
{{ articulo.contenido|linebreaks }}
</article>
{% endblock %}
linebreaks transforma saltos de línea en estructura HTML manteniendo el tratamiento de escape del texto de entrada. Para contenido enriquecido de usuarios se necesita una política explícita de HTML permitido y sanitización, no un |safe añadido para quitar un síntoma.
#Escape y contexto de salida
Escapar HTML significa representar caracteres como < y > de forma que el navegador los vea como texto, no como etiquetas ejecutables. Es una transformación de salida; no sustituye validar longitudes o permisos de entrada. Un título que contenga <script> debe verse escrito, nunca ejecutar un script.
Las variables de templates se escapan por defecto. Conserva los atributos HTML entre comillas: value="{{ dato }}". safe y mark_safe declaran que tú garantizas la seguridad del HTML; no son arreglos para datos no confiables. Tampoco insertes variables directamente en un bloque JavaScript: ese contexto necesita otras reglas. Si más adelante pasas datos a JavaScript, usa json_script y léelos como JSON.
En la shell puedes comprobar el mecanismo sin navegador:
from django.template import Context, Template
resultado = Template("<p>{{ texto }}</p>").render(Context({"texto": "<script>alert(1)</script>"}))
print(resultado)
Esperas etiquetas p reales y el texto escapado <script>. Esto demuestra el renderizado, no una política completa de seguridad de una aplicación. El proyecto final comprueba también que el dato guardado sale escapado por la view.
#Estáticos y uploads tienen vidas diferentes
Crea articulos/static/articulos/sitio.css:
body { max-width: 52rem; margin: auto; padding: 1.5rem; font: 1rem/1.6 system-ui, sans-serif; }
a { color: #126446; }
li { margin: 1rem 0; }
Los estáticos son recursos que publicas con una versión del código: CSS, JavaScript e iconos. collectstatic reúne los de las aplicaciones en STATIC_ROOT para que un servidor web los entregue. runserver los sirve durante desarrollo; Gunicorn no lo hace automáticamente.
Los uploads pertenecen a usuarios y cambian mientras el servicio funciona. No se guardan dentro de la imagen o carpeta de release reemplazable. Necesitan almacenamiento persistente, copias y una política de acceso. Un enlace público a un fichero privado puede saltarse todos tus permisos Django, aunque la página que lo contiene sí los compruebe.
| Archivo | Quién lo produce | Cómo se publica | Qué conservar al desplegar |
|---|---|---|---|
| CSS del sitio | Desarrollo | static y collectstatic |
Artefacto reproducible; versiones previas durante transición |
| Avatar público | Usuario | Almacenamiento aislado, formato validado | Archivo y asociación con el usuario |
| Contrato privado | Usuario | View autorizada o URL temporal de almacenamiento | Archivo, permisos y registro de acceso según necesidad |
#Una subida empieza antes de guardar el fichero
Para recibir un archivo, el formulario HTML necesita enctype="multipart/form-data"; la view debe pasar request.FILES junto con request.POST. Este ejemplo conceptual acepta solo un fichero pequeño con extensión .txt. No lo integra todavía en la aplicación de artículos:
from django import forms
from django.core.validators import FileExtensionValidator
class AdjuntoForm(forms.Form):
archivo = forms.FileField(validators=[FileExtensionValidator(["txt"])])
def clean_archivo(self):
archivo = self.cleaned_data["archivo"]
if archivo.size > 1024 * 1024:
raise forms.ValidationError("El archivo supera 1 MiB.")
return archivo
La extensión solo comprueba el nombre; la cabecera Content-Type también puede mentir. Para importar texto, lee con un límite de bytes, decodifica como UTF-8 y rechaza contenido que no se ajuste al formato que esperas. Para imágenes, decodifica realmente y limita dimensiones/píxeles; si introduces una biblioteca como Pillow, hay que instalarla, explicarla y mantenerla actualizada. No prometas «archivo seguro» por pasar un validador de extensión.
Los límites de formulario se ejecutan después de recibir datos. Un proxy y los handlers de carga necesitan límites propios para que una solicitud enorme no agote disco o memoria antes de la validación. FILE_UPLOAD_MAX_MEMORY_SIZE decide cuándo pasar de memoria a un temporal; no es un límite total del tamaño aceptado.
Al guardar, usa la API de almacenamiento de Django y nombres generados por el servidor; no construyas una ruta arbitraria a partir de archivo.name. Para archivos grandes, recorre chunks() en vez de leer todo en memoria. Una transacción SQL no revierte automáticamente un fichero escrito en almacenamiento externo: conserva estado de subida, confirma la asociación y limpia huérfanos con una operación controlada.
Para servir contenido no confiable, usa un origen separado sin las cookies de sesión de tu aplicación, sin ejecución de scripts del servidor y con tipos/cabeceras deliberados. Si es privado, autoriza antes de servirlo; una URL difícil de adivinar no sustituye un permiso.
Comprobación: abre listado y detalle, verifica que un borrador da 404 incluso conociendo su ID y que el CSS se solicita como otra petición. Antes de añadir uploads, escribe qué tamaño aceptas, quién puede leerlos y qué ocurre si la base confirma pero el almacenamiento falla.