Lab guiado: Formularios HTML, método POST y patrón Post/Redirect/Get¶
- Unidad: UT4 — Interfaz web con Flask
- Sesión: Fase 5 — Formularios y entrada de datos del usuario.
- Agrupamiento: Individual o en parejas conductor/navegante (igual que los labs anteriores).
- Recursos:
- Ficheros de trabajo:
expendedora/presentation/app.pyexpendedora/presentation/templates/(4 plantillas nuevas)
Punto de partida¶
Partimos del zip que entregaste al final del lab a4. Antes de empezar:
- Activa el entorno virtual del proyecto.
- Sitúate en la carpeta padre de
expendedora/. - Arranca el servidor:
python -m expendedora.presentation.app. - Comprueba que las rutas de a4 funcionan con sus plantillas:
/productos(tabla),/producto/A1(ficha),/buscar/agua,/saldo,/ayuda.
Mapa rápido a los apuntes
Si necesitas refrescar algún concepto antes de empezar, los apuntes de Fase 5 cubren:
- HTTP: GET y POST — diferencias, idempotencia, regla práctica.
- El formulario HTML — atributos, validación cliente vs servidor, tipos de input.
- El route que muestra un formulario (GET) —
render_templatecon un<form>. - Acceder a los datos del formulario en Flask —
request.form, routes con dos verbos. - El patrón Post/Redirect/Get (PRG) — por qué redirigir tras éxito.
- Validación: misma excepción, distinto re-render —
try/excepty plantilla con{% if error %}. - Operaciones destructivas y POST — patrón GET-confirma + POST-actúa.
Paso 1 — Formulario para insertar dinero app.py, insertar.html¶
Conceptos
Empezamos por el caso más simple: un formulario con un solo campo. El route responde a GET (mostrar formulario vacío) y a POST (procesar datos). Tras un POST con éxito, redirigimos a /saldo.
Objetivo¶
Sustituir el route /insertar/<float:cantidad> por un formulario web que pida la cantidad al usuario.
Tarea¶
Relación con los apuntes
En los apuntes hemos visto la plantilla en dos pasos: primero la versión mínima (<form> + <input> sin más), y luego la versión ampliada con {% if error %} y value="{{ cantidad or '' }}" para mostrar el mensaje de error y conservar lo tecleado. Aquí la creamos ya en su versión final, que combina ambas partes. El patrón es un único <p> rojo arriba con la excepción capturada, no mensajes por campo.
Crea expendedora/presentation/templates/insertar.html:
{% extends "base.html" %}
{% block titulo %}Insertar dinero — Expendedora{% endblock %}
{% block contenido %}
<h2>Insertar dinero</h2>
{% if error %}
<p style="color: red"><strong>Error:</strong> {{ error }}</p>
{% endif %}
<form method="post" action="{{ url_for('insertar') }}">
<label>
Cantidad (EUR):
<input type="text" name="cantidad" value="{{ cantidad or '' }}" required>
</label>
<button type="submit">Insertar</button>
</form>
<p><a href="{{ url_for('saldo') }}">Ver saldo actual</a></p>
{% endblock %}
Usamos
type="text"(notype="number") por las razones que vimos en los apuntes — la validación detype="number"no es fiable entre navegadores y preferimos validar siempre en el servidor.
Modifica app.py. Sustituye el route antiguo /insertar/<float:cantidad> por uno nuevo que acepta GET y POST:
Este es el patrón PRG que vimos en los apuntes: tras un POST con éxito, redirigir en vez de devolver HTML evita que F5 reejecute la acción.
Pregunta¶
Si en el
tryel dominio lanzaValueError, devolvemos código400y volvemos a renderizar el formulario. ¿Por qué pasamoscantidad=request.form.get('cantidad', '')a la plantilla?
Respuesta
Para que el usuario vea lo que tecleó ya rellenado en el campo y no tenga que volver a escribirlo. Si la cantidad era 1,5 (con coma) y le decimos "formato incorrecto, usa punto", queremos que el campo siga mostrando 1,5 para que cambie solo la coma. Vaciar el campo después de un error es una forma rápida de frustrar al usuario.
Comprobación¶
GET /insertar→ formulario vacío.- POST con cantidad
1.5→ redirige a/saldo, que muestra1.50 EUR. - POST con cantidad
abc→ 400, vuelves al formulario con el campo relleno y el error en rojo. - POST con cantidad
-1→ 400 con mensaje "La cantidad debe ser positiva" (lo lanza el dominio).
Reflexión¶
Respuestas
- El mismo route maneja dos verbos:
GETmuestra,POSTprocesa. - La validación de tipo (
float(...)) puede fallar conValueErrorpor formato; la validación de negocio (cantidad ≤ 0) también lanzaValueError. Las capturamos juntas porque al usuario le da igual el origen: ambas se traducen en mensaje + 400. - El antiguo route
/insertar/<float:cantidad>desaparece. La URL queda más limpia y el usuario no escribe nada en la barra de direcciones.
Paso 2 — Formulario para seleccionar producto app.py, seleccionar.html¶
Conceptos
Un campo <select> muestra una lista desplegable. Lo rellenaremos dinámicamente con los productos disponibles, pasándole desde el route la lista.
Objetivo¶
Sustituir el route /seleccionar/<codigo> por un formulario con desplegable.
Tarea¶
Crea expendedora/presentation/templates/seleccionar.html:
{% extends "base.html" %}
{% block titulo %}Seleccionar producto — Expendedora{% endblock %}
{% block contenido %}
<h2>Seleccionar producto</h2>
{% if error %}
<p style="color: red"><strong>Error:</strong> {{ error }}</p>
{% endif %}
<form method="post" action="{{ url_for('seleccionar') }}">
<label>
Producto:
<select name="codigo" required>
<option value="">— Elige un producto —</option>
{% for p in productos %}
<option value="{{ p.codigo }}">{{ p.codigo }} — {{ p.nombre }} ({{ p.cantidad }} uds.)</option>
{% endfor %}
</select>
</label>
<button type="submit">Seleccionar</button>
</form>
{% endblock %}
Sustituye el route /seleccionar/<codigo> en app.py:
Helper interno con _ delante
_render_seleccionar es una función privada del módulo (la convención _ indica que no es parte de la API pública). Evita repetir la lista de productos + el render_template en dos sitios.
Comprobación¶
GET /seleccionar→ desplegable con los productos.- POST con A1 → redirige a
/producto/A1. - POST manualmente con
codigo=ZZ(DevTools) → 404 con mensaje "No existe…" en el formulario.
Reflexión¶
Respuestas
- El desplegable filtra automáticamente: el usuario no puede teclear un código que no existe desde la interfaz. Pero un atacante con
curlo DevTools sí puede mandar cualquier código. Por eso eltry/exceptsigue siendo necesario. - Aplica el mismo patrón "actúa → redirige" del paso anterior.
Paso 3 — Comprar y cancelar como acciones POST app.py, comprar.html¶
Conceptos
Comprar y cancelar son acciones que modifican estado (descuentan stock, devuelven dinero). Hasta ahora estaban en GET, lo cual permitía cosas peligrosas como recargar la página y comprar otra vez. Las pasamos a POST, aunque no necesiten datos del usuario: serán formularios con un solo botón.
Objetivo¶
Convertir /comprar y /cancelar en acciones POST con confirmación.
Tarea¶
Crea expendedora/presentation/templates/comprar.html:
{% extends "base.html" %}
{% block titulo %}Comprar — Expendedora{% endblock %}
{% block contenido %}
<h2>Confirmar compra</h2>
{% if error %}
<p style="color: red"><strong>Error:</strong> {{ error }}</p>
{% endif %}
<p>Saldo actual: <strong>{{ "%.2f"|format(saldo) }} EUR</strong></p>
<form method="post" action="{{ url_for('comprar') }}">
<button type="submit">Confirmar compra</button>
</form>
<form method="post" action="{{ url_for('cancelar') }}">
<button type="submit">Cancelar y devolver saldo</button>
</form>
{% endblock %}
Modifica los routes:
Crea también una plantilla auxiliar expendedora/presentation/templates/mensaje.html para mostrar el resultado de las acciones que no redirigen:
{% extends "base.html" %}
{% block titulo %}{{ titulo }} — Expendedora{% endblock %}
{% block contenido %}
<h2>{{ titulo }}</h2>
<p>{{ mensaje }}</p>
<p><a href="{{ url_for('inicio') }}">Volver al inicio</a></p>
{% endblock %}
¿Por qué comprar no redirige tras éxito?
Porque el resultado (el cambio devuelto) es información puntual que no tiene una URL natural a la que redirigir. Si redirigiéramos a /saldo perderíamos la información del cambio. En el lab a6 veremos cómo unificar esto con un sistema de mensajes flash; por ahora, mostrar el resultado en su propia plantilla es legítimo.
Pregunta¶
El route
/cancelarsolo acepta POST (no GET). Si abreshttp://localhost:5000/cancelardirectamente en el navegador, ¿qué pasa? ¿Por qué es bueno que pase eso?
Respuesta
Flask devuelve 405 Method Not Allowed. Es bueno porque previene cancelaciones accidentales: nadie puede cancelar una operación tecleando una URL en la barra del navegador. La única forma de llegar a /cancelar es pulsando el botón del formulario.
Comprobación — flujo completo¶
GET /seleccionar→ desplegable, eliges A1, envías.- Vas a
/insertar, metes2.0, envías →/saldomuestra 2.00 EUR. - Vas a
/comprar, pulsas "Confirmar compra" → mensaje con el cambio. - Vuelves a
/comprarsin haber seleccionado → 400 con mensaje "No hay producto seleccionado".
Reflexión¶
Respuestas
- Los formularios POST sin campos siguen siendo formularios: la presencia del
<form method="post">es lo que distingue una acción reversible de una no reversible. - El nuevo flujo es exactamente el mismo que en consola: seleccionar → insertar → comprar. Solo cambia el medio.
Paso 4 — Formulario de alta de producto app.py, agregar.html¶
Conceptos
Es el caso más completo: un formulario con varios campos, validación múltiple (varias clases de error posibles: formato de tipo, regla de negocio, conflicto de estado) y un único mensaje de error arriba del formulario, como en los pasos anteriores. Mostraremos cómo manejar varios campos con request.form y cómo conservar lo tecleado tras un error.
Objetivo¶
Sustituir los routes /agregar/<codigo>/<nombre>/<float:precio>/<int:cantidad> y /agregar_descuento/... por un único formulario con la opción de aplicar descuento.
Tarea¶
Crea expendedora/presentation/templates/agregar.html:
{% extends "base.html" %}
{% block titulo %}Agregar producto — Expendedora{% endblock %}
{% block contenido %}
<h2>Agregar producto</h2>
{% if error %}
<p style="color: red"><strong>Error:</strong> {{ error }}</p>
{% endif %}
<form method="post" action="{{ url_for('agregar') }}">
<p><label>Código: <input type="text" name="codigo" value="{{ datos.codigo or '' }}" required></label></p>
<p><label>Nombre: <input type="text" name="nombre" value="{{ datos.nombre or '' }}" required></label></p>
<p><label>Precio (EUR): <input type="text" name="precio" value="{{ datos.precio or '' }}" required></label></p>
<p><label>Cantidad (stock): <input type="text" name="cantidad" value="{{ datos.cantidad or '' }}" required></label></p>
<p><label>Descuento (%): <input type="text" name="descuento" value="{{ datos.descuento or '' }}" placeholder="0 si no hay"></label></p>
<button type="submit">Agregar</button>
</form>
{% endblock %}
Sustituye los dos routes antiguos en app.py por uno único:
Diccionario datos
Construir un dict con los campos al inicio nos permite, en caso de error, devolverlo a la plantilla y conservar lo tecleado. Es el mismo patrón que en /insertar, pero con varios campos en lugar de uno.
Validación: dónde vive cada cosa
float(...)yint(...)lanzanValueErrorsi el formato es incorrecto. Lo capturamos.- El dominio lanza
ValueErrorsi precio ≤ 0, cantidad < 0, código vacío, etc. Mismo tipo de excepción, mismo manejo. - El dominio lanza
ProductoYaExisteErrorsi el código ya existe. Esto es conflicto de estado, no formato → código409, no400.
Comprobación¶
- POST con campos válidos (
X1/Zumo/1.5/10, sin descuento) → redirige a/producto/X1. - POST con descuento
20añadido → redirige a/producto/X2(o el código que pongas) con la línea de descuento en la ficha. - POST repitiendo
X1→ 409 con mensaje "Ya existe un producto…" y formulario relleno. - POST con precio
0→ 400 con mensaje "El precio debe ser mayor que 0" y formulario relleno. - POST con precio
abc→ 400 con mensaje "could not convert string to float: 'abc'" (mensaje de Python; lo aceptamos por ahora).
Reflexión¶
Respuestas
- Un único formulario gestiona ambos casos (con y sin descuento) decidiendo en el route a qué método del servicio llamar.
- La plantilla no cambia entre éxito y error: el mismo
agregar.htmlse rellena con datos vacíos (GET) o con datos tecleados + error (POST fallido).
Paso 5 — Eliminar con confirmación POST app.py, eliminar.html¶
Conceptos
Las operaciones destructivas nunca deben ir en GET. Una URL /eliminar/A1 accesible por GET podría borrarse por accidente al hacer clic en un enlace, al ser indexada por un buscador o al previsualizarse en un chat. La pasamos a POST con confirmación explícita.
Objetivo¶
Sustituir el route /eliminar/<codigo> por una página de confirmación con formulario POST.
Tarea¶
Crea expendedora/presentation/templates/eliminar.html:
{% extends "base.html" %}
{% block titulo %}Eliminar producto — Expendedora{% endblock %}
{% block contenido %}
<h2>Eliminar producto: {{ producto.codigo }}</h2>
<p>Vas a eliminar <strong>{{ producto.nombre }}</strong>. Esta acción no se puede deshacer.</p>
<form method="post" action="{{ url_for('eliminar', codigo=producto.codigo) }}">
<button type="submit">Sí, eliminar</button>
</form>
<p><a href="{{ url_for('ver_producto', codigo=producto.codigo) }}">No, volver</a></p>
{% endblock %}
Modifica el route en app.py:
Pregunta¶
¿Por qué la página de confirmación necesita hacer un GET previo (cargar
producto) antes del POST?
Respuesta
Porque queremos mostrar al usuario qué está a punto de eliminar. Una pantalla de confirmación que solo dice "¿Eliminar?" sin nombrar el producto es peor que no tener confirmación: el usuario aprueba sin saber qué aprueba. Cargamos el producto en el GET para mostrarlo en la página, y el POST solo dispara la eliminación.
Comprobación¶
GET /eliminar/A1→ página de confirmación con el nombre.- POST → redirige a
/productos, ya sin A1. GET /eliminar/ZZ→ 404 con mensaje "No existe…".
Reflexión¶
Respuestas
- El patrón GET-confirma + POST-actúa es estándar para borrados.
- El mismo route gestiona los dos verbos (GET y POST) sobre la misma URL, igual que en
/insertary/agregar.
Paso 6 — Actualizar la documentación¶
Tarea¶
CHANGELOG.md¶
Añade al inicio:
## [0.9.0] - (Fase 09: formularios POST con validación)
### Added
- `presentation/templates/insertar.html`, `seleccionar.html`, `comprar.html`, `agregar.html`, `eliminar.html`, `mensaje.html`: formularios HTML.
### Changed
- `presentation/app.py`: las rutas `/insertar`, `/seleccionar`, `/comprar`, `/cancelar`, `/agregar`, `/eliminar/<codigo>` aceptan ahora POST y aplican el patrón Post/Redirect/Get tras éxito.
- `presentation/app.py`: las rutas `/agregar/<codigo>/...` y `/agregar_descuento/<codigo>/...` con parámetros en URL han desaparecido — sustituidas por el único formulario `/agregar`.
- `presentation/app.py`: `/cancelar` ya no acepta GET (devuelve 405 si se accede directamente).
### Removed
- Routes con datos en URL para escritura (`/insertar/<float>`, `/seleccionar/<codigo>`, `/agregar_descuento/...`): los datos ahora viajan por el cuerpo POST.
README.md¶
En la sección Uso (interfaz web) sustituye los bloques de "Transacción" y "Administración" por:
**Transacción (formularios POST):**
- `/seleccionar` (GET formulario / POST acción) — selecciona un producto del desplegable.
- `/insertar` (GET formulario / POST acción) — inserta dinero al saldo.
- `/comprar` (GET formulario / POST acción) — confirma la compra del producto seleccionado.
- `/cancelar` (solo POST) — cancela la operación y devuelve el saldo.
**Administración (formularios POST):**
- `/agregar` (GET formulario / POST acción) — alta de producto (con descuento opcional).
- `/eliminar/<codigo>` (GET confirma / POST acción) — baja de producto.
- `/reponer/<codigo>/<int:unidades>` — reposición de stock (sigue como GET por simplicidad).
Comprobación¶
Recorre todas las rutas de la web desde / y confirma que cada acción de escritura pasa por un formulario. Comprueba también que las rutas viejas con parámetros en la URL (/insertar/2.0, /agregar/X1/...) devuelven 404 — ya no existen.
Checklist de entrega¶
- 6 plantillas nuevas creadas (
insertar.html,seleccionar.html,comprar.html,agregar.html,eliminar.html,mensaje.html). - Routes que aceptan POST aplican Post/Redirect/Get tras éxito.
- Errores se vuelven a renderizar en el formulario con los datos tecleados.
-
/agregar/<codigo>/<nombre>/...antiguo eliminado (sustituido por/agregarcon formulario). -
/eliminar/<codigo>con confirmación GET + acción POST. -
/cancelarsolo acepta POST (devuelve 405 con GET). -
domain/einfrastructure/intactos. -
presentation/menu.pysigue funcionando sin cambios. -
CHANGELOG.mdyREADME.mdactualizados. - Entregado un zip con todo el proyecto — será la base del lab a6.
Problemas comunes¶
KeyError: 'cantidad' al enviar el formulario
El nombre del campo en el HTML (name="cantidad") no coincide con la clave en request.form['cantidad']. Comprueba que coinciden literalmente.
Tras enviar el formulario, el navegador pregunta ¿Reenviar formulario?
Te falta el redirect(...) tras el POST con éxito. Sin PRG, recargar repetiría la acción.
405 Method Not Allowed al enviar el formulario
El route no declara que acepta POST. Revisa que tienes methods=['GET', 'POST'] en @app.route(...).
El campo se vacía al volver tras un error
Al renderizar la plantilla tras el except, no estás pasando los datos tecleados. Revisa que pasas datos=request.form (o el dict que construyas) al render_template.