Lab guiado: Plantillas Jinja2 — separar HTML de la lógica¶
- Unidad: UT4 — Interfaz web con Flask
- Sesión: Fase 4 — Plantillas Jinja2.
- Agrupamiento: Individual o en parejas conductor/navegante (igual que los labs anteriores).
- Recursos:
- Ficheros de trabajo:
expendedora/presentation/app.pyexpendedora/presentation/templates/(carpeta nueva)
Punto de partida¶
Parte del zip que entregaste al final del lab a3. Antes de empezar:
- Activa el entorno virtual del proyecto.
- Sitúate en la carpeta padre de
expendedora/. - Arranca el servidor web:
python -m expendedora.presentation.app. - Comprueba que las rutas de a3 siguen funcionando (
/productos,/producto/A1,/ayuda). Si alguna falla, vuelve a a3 antes de seguir.
Alcance: solo páginas de lectura
En este lab convertiremos a plantillas únicamente las páginas de lectura (/, /productos, /producto/<codigo>, /buscar/<texto>, /saldo, /ayuda) y las páginas de error (404 y 500). Las rutas que realizan acciones (/insertar, /comprar, /cancelar, /agregar, /eliminar, /reponer, /seleccionar) seguirán devolviendo texto plano. No las tocamos aquí porque en el lab a5 llegarán los formularios HTML con POST, y entonces se integrarán en plantillas con <form> de forma natural. Convertirlas ahora sería trabajo desechable.
Trabajo en parejas
Si decides trabajar en pareja, sigue el procedimiento conductor/navegante de los labs anteriores: conductor al teclado, navegante lee y detecta errores, se rota al final de cada paso.
Antes de empezar: repasemos los conceptos
El problema que resuelven las plantillas
Hasta ahora hemos devuelto HTML directamente desde el route, mezclándolo con strings de Python. A medida que el HTML crece, el código se vuelve ilegible y costoso de mantener. Las plantillas separan la estructura HTML (en ficheros .html) de los datos (que el route inyecta).
Jinja2
Es el motor de plantillas que viene incluido con Flask. Permite escribir HTML normal con tres añadidos: {{ variable }} para inyectar valores, {% for %} y {% if %} para iterar y condicionar, y {% extends %} / {% block %} para herencia entre plantillas.
render_template
Es la función de Flask que carga una plantilla, la rellena con los datos que le pases y devuelve el HTML resultante. Por defecto busca los ficheros en presentation/templates/.
Tuplas vs diccionarios en plantillas
servicio.listar_productos() y servicio.obtener_producto() devuelven tuplas. En una plantilla, escribir {{ producto[0] }}, {{ producto[1] }} es ilegible. Convertimos las tuplas a diccionarios en el route para que la plantilla pueda escribir {{ producto.codigo }}, {{ producto.nombre }}.
Paso 1 — Crear la carpeta de plantillas y la plantilla base presentation/templates/¶
Conceptos
Flask busca las plantillas en una carpeta llamada templates/ que cuelga del paquete donde vive app.py. Como app.py está en presentation/, las plantillas van en presentation/templates/.
Objetivo¶
Crear la carpeta templates/ y la plantilla base base.html que servirá de esqueleto común para todas las páginas.
Tarea¶
Crea la carpeta expendedora/presentation/templates/ y, dentro, el fichero base.html:
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<title>{% block titulo %}Expendedora{% endblock %}</title>
</head>
<body>
<header>
<h1><a href="{{ url_for('inicio') }}">Expendedora</a></h1>
<nav>
<a href="{{ url_for('listar_productos') }}">Productos</a> ·
<a href="{{ url_for('saldo') }}">Saldo</a> ·
<a href="{{ url_for('ayuda') }}">Ayuda</a>
</nav>
<hr>
</header>
<main>
{% block contenido %}{% endblock %}
</main>
</body>
</html>
Qué hace {% block %} — y la diferencia entre vacío y con valor por defecto
{% block nombre %} ... {% endblock %} define un hueco con nombre en la plantilla base. Las plantillas hijas rellenarán esos huecos con contenido propio. base.html se queda con la estructura común (cabecera, navegación, pie); cada página solo escribe lo que cambia.
Fíjate en que en base.html hay dos formas de declarar un bloque:
- Con valor por defecto:
{% block titulo %}Expendedora{% endblock %}. Si la plantilla hija no sobrescribe este bloque, el<title>final seráExpendedora. Sirve para un valor razonable que valga para cualquier página. - Vacío:
{% block contenido %}{% endblock %}. Si la hija no lo rellena, ese hueco queda en blanco en el HTML final. Se usa cuando cada página debe aportar su propio contenido obligatoriamente.
Pregunta¶
¿Por qué usamos
url_for('inicio')en lugar de escribir directamente"/"?
Respuesta
Por la misma razón que en el lab a2: si mañana cambias la URL del route inicio a /home, todos los enlaces hechos con url_for se actualizan solos. Los strings hardcodeados se romperían.
Reflexión¶
Respuestas
base.htmlno se renderiza solo: sirve de andamiaje a otras plantillas que la heredan.- El bloque
titulopermite que cada página tenga su propio<title>sin duplicar la cabecera.
Paso 2 — Plantilla del listado de productos productos.html¶
Conceptos
{% for x in lista %}...{% endfor %} itera sobre una lista. Es como el for de Python pero en la plantilla. Dentro del bucle puedes acceder a las propiedades de cada elemento con {{ x.atributo }}.
Objetivo¶
Renderizar la lista de productos como tabla HTML.
Tarea¶
Crea expendedora/presentation/templates/productos.html:
{% extends "base.html" %}
{% block titulo %}Productos — Expendedora{% endblock %}
{% block contenido %}
<h2>Lista de productos</h2>
{% if productos %}
<table>
<thead>
<tr>
<th>Código</th>
<th>Nombre</th>
<th>Precio</th>
<th>Stock</th>
<th></th>
</tr>
</thead>
<tbody>
{% for p in productos %}
<tr>
<td><code>{{ p.codigo }}</code></td>
<td>{{ p.nombre }}</td>
<td>{{ "%.2f"|format(p.precio_final)|replace(".", ",") }} EUR</td>
<td>{{ p.cantidad }}</td>
<td><a href="{{ url_for('ver_producto', codigo=p.codigo) }}">Detalle</a></td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No hay productos.</p>
{% endif %}
{% endblock %}
Todo lo que escribas fuera de un {% block %} se ignora
En una plantilla que empieza con {% extends "base.html" %}, solo el contenido dentro de los {% block %} entra en el HTML final. Si escribes un <p> o un <h1> fuera de los bloques (por ejemplo, justo después del extends), Jinja2 lo ignora silenciosamente: no salta error, simplemente no aparece en la página. Es la fricción número uno al modificar plantillas hijas — si "falta" algo, mira primero si lo has puesto fuera de un bloque.
Ahora abre presentation/app.py e importa render_template arriba del fichero (junto a Flask, redirect, url_for):
Modifica el route /productos para usar la plantilla. Antes de pasar la lista a la plantilla, convierte cada tupla a diccionario para que el HTML quede legible:
Qué hace dict(zip(keys, t))
Es un patrón nuevo, así que vamos a desmontarlo paso a paso. zip empareja dos secuencias en orden, formando pares:
dict() convierte esa lista de pares en un diccionario:
La comprensión de lista [dict(zip(keys, t)) for t in servicio.listar_productos()] aplica esa conversión a cada tupla de la lista que devuelve el servicio. Resultado: una lista de diccionarios con claves nombradas, lista para ser pasada a la plantilla.
¿Por qué convertir tupla → dict?
El servicio devuelve tuplas porque viene del dominio, donde la estructura es estable y compacta. Pero en una plantilla HTML preferimos {{ p.nombre }} sobre {{ p[1] }}: lo primero se lee, lo segundo se cuenta con los dedos. La conversión vive en el route porque es adaptación a la presentación, no lógica de dominio.
Pregunta¶
El filtro
"%.2f"|format(p.precio_final)|replace(".", ",")formatea el número con 2 decimales y cambia el punto por la coma. ¿Dónde habríamos hecho ese formateo antes de tener plantillas?
Respuesta
En el route, con un f-string como f"{precio_final:.2f}" y un .replace(".", ",") encima. La diferencia es que ahora el formateo vive donde se ve el valor, no donde se calcula. Eso encaja con la separación: el route obtiene los datos crudos, la plantilla los presenta — incluyendo el separador decimal local (punto en programación, coma en español).
Comprobación¶
Visita http://localhost:5000/productos. Debes ver una tabla con cabecera, una fila por producto y un enlace "Detalle" en cada fila. Si pulsas un enlace, la URL cambia a /producto/<codigo> (todavía devuelve la versión antigua sin plantilla — la convertimos en el siguiente paso).
Mira el HTML que ha generado Jinja2: en el navegador, sobre la página /productos, pulsa Ctrl+U (o clic derecho → Ver código fuente de la página). Verás algo parecido a:
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<title>Productos — Expendedora</title>
</head>
<body>
<header>
<h1><a href="/">Expendedora</a></h1>
<nav>
<a href="/productos">Productos</a> ·
<a href="/saldo">Saldo</a> ·
<a href="/ayuda">Ayuda</a>
</nav>
<hr>
</header>
<main>
<h2>Lista de productos</h2>
<table>
<thead>...</thead>
<tbody>
<tr>
<td><code>A1</code></td>
<td>Agua</td>
<td>1,00 EUR</td>
<td>10</td>
<td><a href="/producto/A1">Detalle</a></td>
</tr>
<tr>
<td><code>B1</code></td>
...
</tr>
...
</tbody>
</table>
</main>
</body>
</html>
Comprueba tres cosas:
- La cabecera de
base.html(<!doctype>,<head>,<header>con la navegación) aparece tal cual antes del<main>. La plantilla hija no ha tenido que escribirla — la ha heredado. - Las marcas
{% for %},{% endfor %},{% if %}y{% endif %}han desaparecido. Solo eran instrucciones para Jinja2. - La línea con
{% for %}se ha multiplicado: hay un<tr>por producto, con los valores ya sustituidos. Y los precios aparecen con coma decimal española.
Reflexión¶
Respuestas
{% for %}recorre la lista que el route inyecta como variableproductos.- La plantilla no sabe de dónde vienen los datos: SQLite, memoria, JSON. Solo recibe una lista.
- El filtro
|formatrompe la intuición: a la izquierda del|no va el valor sino la plantilla con hueco (%.2f), y el valor entra como argumento del filtro (mismo patrón que"{:.2f}".format(precio)en Python). El|replace(".", ",")siguiente sí encaja con el modelo normal de filtro: opera sobre el resultado que produjoformat. Los filtros encadenados se aplican izquierda → derecha: la salida de uno es la entrada del siguiente.
Paso 3 — Plantilla del detalle de producto producto.html¶
Conceptos
{% if condicion %}...{% else %}...{% endif %} permite mostrar contenido distinto según el valor de una variable. Aquí lo usaremos para mostrar la información del descuento solo si lo hay.
Objetivo¶
Renderizar el detalle de un producto como ficha y mostrar también enlaces a las acciones disponibles.
Tarea¶
Crea expendedora/presentation/templates/producto.html:
{% extends "base.html" %}
{% block titulo %}{{ producto.codigo }} — Expendedora{% endblock %}
{% block contenido %}
<h2>{{ producto.nombre }} <small>({{ producto.codigo }})</small></h2>
<dl>
<dt>Precio</dt>
<dd>
{{ "%.2f"|format(producto.precio_final)|replace(".", ",") }} EUR
{% if producto.descuento and producto.descuento > 0 %}
<small>(antes {{ "%.2f"|format(producto.precio_base)|replace(".", ",") }}, −{{ producto.descuento }}%)</small>
{% endif %}
</dd>
<dt>Stock</dt>
<dd>{{ producto.cantidad }} uds.</dd>
</dl>
<h3>Acciones</h3>
<ul>
<li><a href="{{ url_for('seleccionar', codigo=producto.codigo) }}">Seleccionar este producto</a></li>
<li><a href="{{ url_for('reponer', codigo=producto.codigo, unidades=5) }}">Reponer 5 unidades</a></li>
</ul>
{% endblock %}
Modifica el route /producto/<codigo> en app.py:
Pregunta¶
¿Por qué la conversión
tupla → dictse repite igual en/productosy en/producto/<codigo>? ¿Sería razonable extraerla a una función?
Respuesta
Sí, sería razonable: un helper producto_a_dict(tupla) o una constante con los keys evitaría duplicar la lista de campos. En este lab lo dejamos repetido porque solo son dos sitios y queremos que el patrón se vea con claridad. Refactorizar es la siguiente conversación, no esta.
Comprobación¶
http://localhost:5000/producto/A1→ ficha con nombre, precio, stock y enlaces.http://localhost:5000/producto/D1→ ficha con la línea de descuento ((antes ..., −20%)).http://localhost:5000/producto/A1→ al pulsar "Seleccionar este producto" vas a la propia página vía redirect.http://localhost:5000/producto/ZZ→ sigue devolviendoError: ...con 404 (el manejador del lab a3 todavía no usa plantilla).
Reflexión¶
Respuestas
- La plantilla muestra la línea de descuento solo si existe gracias al
{% if %}. - El campo
descuentopuede serNonepara productos sin descuento; por eso evaluamosproducto.descuento and producto.descuento > 0. - Cada plantilla hija (
productos.html,producto.html) reutiliza la cabecera y navegación debase.htmlsin copiar código.
Paso 4 — Plantillas para la búsqueda y el saldo buscar.html, saldo.html¶
Conceptos
Conviene unificar ahora todas las páginas de lectura en plantillas. Quedarán como hermanas de productos.html y producto.html, todas extendiendo de base.html.
Objetivo¶
Convertir las rutas /buscar/<texto> y /saldo para que devuelvan plantillas.
Tarea¶
Crea expendedora/presentation/templates/buscar.html:
{% extends "base.html" %}
{% block titulo %}Búsqueda — Expendedora{% endblock %}
{% block contenido %}
<h2>Búsqueda: <em>{{ texto }}</em></h2>
{% if productos %}
<ul>
{% for p in productos %}
<li>
<a href="{{ url_for('ver_producto', codigo=p.codigo) }}">{{ p.codigo }}</a> —
{{ p.nombre }} — {{ "%.2f"|format(p.precio_final)|replace(".", ",") }} EUR ({{ p.cantidad }} uds.)
</li>
{% endfor %}
</ul>
{% else %}
<p>No hay coincidencias para <em>{{ texto }}</em>.</p>
{% endif %}
{% endblock %}
Y expendedora/presentation/templates/saldo.html:
{% extends "base.html" %}
{% block titulo %}Saldo — Expendedora{% endblock %}
{% block contenido %}
<h2>Saldo actual</h2>
<p><strong>{{ "%.2f"|format(saldo)|replace(".", ",") }} EUR</strong></p>
{% endblock %}
Cuidado: el route se llama saldo() y la variable que pasaremos a la plantilla también
En el route que vamos a escribir a continuación, escribiremos render_template('saldo.html', saldo=servicio.saldo()). Hay tres "saldos" en juego: la función del route (def saldo()), el método del servicio (servicio.saldo()) y la variable que llega a la plantilla (saldo=...). Para Jinja2 no hay choque (la plantilla solo conoce la variable, no la función). Es coherente: el alumno entiende que "el saldo" es el dato y se llama igual en todas las capas. Pero conviene saberlo: si en otro proyecto pasas una variable con el mismo nombre que la función del route, dentro de la plantilla {{ saldo }} es la variable, nunca la función.
Modifica los routes correspondientes en app.py:
Comprobación¶
/buscar/agua→ lista con un resultado y enlace al detalle. Los precios aparecen con coma decimal./buscar/xxxxxx→ mensaje "No hay coincidencias"./saldo→ muestra el saldo actual con dos decimales y coma:0,00 EURal arrancar el servidor.
Reflexión¶
Respuestas
buscar.htmlrecibe dos variables del route:texto(para mostrarlo en la cabecera) yproductos(la lista).- La plantilla
saldo.htmlno necesita lógica condicional: el servicio siempre devuelve un número.
Paso 5 — Inicio y ayuda con plantillas inicio.html, ayuda.html¶
Conceptos
El route /ayuda que creaste en a3 ya hacía introspección sobre app.url_map. Ahora solo cambia dónde se construye el HTML: antes en el route, ahora en una plantilla.
Objetivo¶
Convertir las dos páginas que aún devuelven HTML inline (/ y /ayuda) para que también usen plantillas.
Tarea¶
Crea expendedora/presentation/templates/inicio.html:
{% extends "base.html" %}
{% block titulo %}Expendedora — Inicio{% endblock %}
{% block contenido %}
<h2>Bienvenida</h2>
<p>Interfaz web de la máquina expendedora. Usa la barra de navegación de arriba o ve directamente a la <a href="{{ url_for('ayuda') }}">página de ayuda</a> para ver todas las rutas disponibles.</p>
{% endblock %}
Y expendedora/presentation/templates/ayuda.html:
{% extends "base.html" %}
{% block titulo %}Ayuda — Expendedora{% endblock %}
{% block contenido %}
<h2>Rutas disponibles</h2>
<ul>
{% for regla in reglas %}
<li><code>{{ regla.rule }}</code> — {{ regla.endpoint }}</li>
{% endfor %}
</ul>
{% endblock %}
Modifica los routes:
Pasar objetos directamente a la plantilla
Las reglas son objetos de Werkzeug, no diccionarios. Jinja2 puede acceder a sus atributos (regla.rule, regla.endpoint) igual que con un diccionario. No hace falta convertir cuando los atributos ya son los que queremos usar.
Comprobación¶
/→ página de bienvenida con la cabecera común./ayuda→ tabla de rutas, cada una con su patrón y su endpoint.- En todas las páginas la barra de navegación (Productos · Saldo · Ayuda) aparece arriba.
Truco para depurar plantillas: ver código fuente del navegador
Cuando una página no se ve como esperas, antes de tocar el CSS o sospechar del navegador, abre Ctrl+U y mira el HTML que Jinja2 ha generado. Si en el HTML fuente ya falta lo que esperabas, el problema está en la plantilla (un bloque mal cerrado, un extends olvidado, contenido fuera de un {% block %}). Si el HTML fuente está bien y aun así no se ve, el problema es de CSS o de caché del navegador (Ctrl+F5 para forzar recarga). Esta distinción te ahorra muchísimo tiempo de depuración.
Reflexión¶
Respuestas
- El route ahora se reduce a obtener datos y delegar el HTML a la plantilla.
base.htmlda coherencia visual: cualquier cambio en la cabecera (logo, menú nuevo) toca un solo fichero.
Paso 6 — Mensajes de error con plantilla error.html¶
Conceptos
Los manejadores @app.errorhandler(404) y @app.errorhandler(500) que creaste en a3 todavía devuelven HTML inline. Vamos a unificarlos también con una plantilla, así toda la app tiene la misma apariencia.
Objetivo¶
Convertir las páginas de error 404 y 500 para que usen una plantilla común.
Tarea¶
Crea expendedora/presentation/templates/error.html:
{% extends "base.html" %}
{% block titulo %}{{ codigo }} — Error{% endblock %}
{% block contenido %}
<h2>{{ codigo }} — {{ titulo }}</h2>
<p>{{ mensaje }}</p>
<p><a href="{{ url_for('inicio') }}">Volver al inicio</a></p>
{% endblock %}
Modifica los manejadores en app.py:
Pregunta¶
Después de este paso, ¿qué partes de
app.pysiguen devolviendo HTML construido a mano?
Respuesta
Todas las rutas que realizan acciones: /insertar, /comprar, /cancelar, /reponer, /seleccionar, /agregar, /agregar_descuento, /eliminar. Algunas terminan con redirect(...) y otras con un mensaje corto del estilo "Compra realizada. Cambio: ...". También los return str(e), 4xx de los try/except. Son fragmentos pequeños que decidimos dejar inline porque no aportan valor moverlos a plantillas: el patrón actúa→redirige los reduce a una línea o son textos transitorios. Cuando lleguen los formularios POST en el lab a5, varios desaparecerán al integrarse en flujos.
Comprobación¶
http://localhost:5000/foo(URL inexistente) → página con cabecera común, "404 — No encontrado" y enlace al inicio.- Provoca un 500 (puedes añadir temporalmente una ruta
/explotarcon1/0y borrarla después): debe mostrar la página de error con el mismo aspecto.
Lo que dejamos para el lab a5
Al cerrar este paso, todas las páginas que muestran información (lectura + errores) ya van por plantillas. Las páginas que modifican estado (insertar, comprar, agregar, eliminar...) siguen sirviendo texto plano porque su forma natural de uso no es escribir la URL completa con datos en la barra del navegador, sino rellenar un formulario HTML y enviarlo con POST. En el lab a5 introduciremos <form method="post">, validación de campos y el patrón Post/Redirect/Get. Las rutas de acción pasarán entonces a aceptar POST y a renderizarse como formularios dentro de plantillas — todo en un movimiento coherente, en lugar de hacerlo a medias ahora.
Reflexión¶
Respuestas
- Un solo
error.htmlcubre 404 y 500 porque solo cambian el código, el título y el mensaje. - El usuario ve la misma cabecera y navegación tanto en una página normal como en una de error: la coherencia visual se mantiene incluso cuando algo falla.
Paso 7 — Actualizar la documentación del proyecto¶
Objetivo¶
Dejar CHANGELOG.md y README.md reflejando el cambio a plantillas.
Tarea¶
CHANGELOG.md¶
Añade al inicio del fichero:
## [0.8.0] - (Fase 08: plantillas Jinja2 en la capa web)
### Added
- `presentation/templates/`: nueva carpeta de plantillas Jinja2.
- `presentation/templates/base.html`: plantilla base con cabecera, navegación y bloques.
- `presentation/templates/productos.html`, `producto.html`, `buscar.html`, `saldo.html`, `inicio.html`, `ayuda.html`, `error.html`.
### Changed
- `presentation/app.py`: los routes de lectura (`/`, `/productos`, `/producto/<codigo>`, `/buscar/<texto>`, `/saldo`, `/ayuda`) y los manejadores `404`/`500` usan ahora `render_template` en lugar de devolver HTML inline.
- Conversión `tupla → dict` aplicada en los routes que pasan datos del servicio a la plantilla (mejora la legibilidad de las plantillas).
- Precios y saldo formateados con coma decimal española mediante el filtro `|replace(".", ",")` encadenado tras `|format`.
README.md¶
Añade una nota corta al final de la sección Uso (interfaz web):
Las páginas usan plantillas Jinja2 ubicadas en `presentation/templates/`. La plantilla `base.html` define la estructura común; el resto la heredan con `{% extends %}`.
Comprobación¶
Arranca el servidor y haz una pasada visual por todas las rutas: la cabecera "Expendedora" con la navegación debe aparecer en todas. Si una página la pierde, has olvidado heredar de base.html.
Reflexión¶
Respuestas
- Las plantillas introducen una carpeta nueva y un patrón nuevo (
render_template), pero no rompen ninguna ruta existente: las URLs son las mismas. - El
CHANGELOG.mdregistra el salto a0.8.0(cambio menor: funcionalidad añadida, sin romper la API).
Checklist de entrega¶
- Carpeta
presentation/templates/creada conbase.html,productos.html,producto.html,buscar.html,saldo.html,inicio.html,ayuda.htmlyerror.html. -
presentation/app.pyimportarender_templatey todos los routes de lectura + los@app.errorhandlerlo usan. - Los routes que pasan datos del servicio convierten tupla → dict antes de renderizar.
- La cabecera con navegación aparece en todas las páginas (incluidas las de error).
- El detalle de producto muestra la línea de descuento solo si el producto tiene descuento.
-
domain/einfrastructure/intactos. -
presentation/menu.pysigue funcionando sin cambios. -
CHANGELOG.mdyREADME.mdactualizados. - Entregado un zip con todo el proyecto — será la base del lab a5.
Problemas comunes¶
jinja2.exceptions.TemplateNotFound: productos.html
Flask no encuentra la plantilla. Comprueba que la carpeta se llama exactamente templates/ (en plural, en minúsculas) y cuelga del paquete presentation/. La estructura debe ser expendedora/presentation/templates/productos.html.
UndefinedError: 'producto' is undefined
La plantilla usa una variable que el route no le pasó. Revisa la llamada render_template('plantilla.html', ...): cada variable que la plantilla usa con {{ ... }} tiene que estar como argumento aquí.
El detalle muestra 0,00 EUR aunque el precio sea correcto
Has accedido a la clave equivocada del diccionario. Recuerda que el campo se llama precio_final (con descuento aplicado), no precio ni precio_base.
Los enlaces de la cabecera no funcionan en todas las páginas
Probablemente la página no extiende de base.html. Comprueba que la primera línea de la plantilla hija es {% extends "base.html" %}.
El precio aparece con punto (1.50 EUR) en lugar de coma (1,50 EUR)
Has olvidado encadenar |replace(".", ",") después de |format. El patrón completo es {{ "%.2f"|format(precio)|replace(".", ",") }}. Comprueba todas las plantillas que muestren importes.