Saltar a contenido

Lab guiado: Formularios HTML, método POST y patrón Post/Redirect/Get


Punto de partida

Partimos del zip que entregaste al final del lab a4. Antes de empezar:

  1. Activa el entorno virtual del proyecto.
  2. Sitúate en la carpeta padre de expendedora/.
  3. Arranca el servidor: python -m expendedora.presentation.app.
  4. 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:


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" (no type="number") por las razones que vimos en los apuntes — la validación de type="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:

@app.route('/insertar', methods=['GET', 'POST'])
def insertar():
    if request.method == 'POST':
        try:
            cantidad = float(request.form['cantidad'])
            servicio.insertar_dinero(cantidad)
            return redirect(url_for('saldo'))
        except ValueError as e:
            return render_template(
                'insertar.html',
                error=str(e),
                cantidad=request.form.get('cantidad', '')
            ), 400
    return render_template('insertar.html', error=None, cantidad=None)

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 try el dominio lanza ValueError, devolvemos código 400 y volvemos a renderizar el formulario. ¿Por qué pasamos cantidad=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 muestra 1.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: GET muestra, POST procesa.
  • La validación de tipo (float(...)) puede fallar con ValueError por formato; la validación de negocio (cantidad ≤ 0) también lanza ValueError. 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:

@app.route('/seleccionar', methods=['GET', 'POST'])
def seleccionar():
    if request.method == 'POST':
        codigo = request.form['codigo']
        try:
            servicio.seleccionar(codigo)
            return redirect(url_for('ver_producto', codigo=codigo))
        except ProductoNoEncontradoError as e:
            return _render_seleccionar(error=str(e)), 404
    return _render_seleccionar(error=None)


def _render_seleccionar(error):
    keys = ['codigo', 'nombre', 'precio_base', 'precio_final', 'cantidad', 'descuento']
    productos = [dict(zip(keys, t)) for t in servicio.listar_productos()]
    return render_template('seleccionar.html', productos=productos, error=error)

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 curl o DevTools sí puede mandar cualquier código. Por eso el try/except sigue 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:

@app.route('/comprar', methods=['GET', 'POST'])
def comprar():
    if request.method == 'POST':
        try:
            cambio = servicio.comprar()
            importe = f"{cambio:.2f}".replace(".", ",")
            return render_template('mensaje.html',
                                   titulo='Compra realizada',
                                   mensaje=f"Cambio: {importe} EUR.")
        except ValueError as e:
            return render_template('comprar.html', saldo=servicio.saldo(), error=str(e)), 400
    return render_template('comprar.html', saldo=servicio.saldo(), error=None)


@app.route('/cancelar', methods=['POST'])
def cancelar():
    devuelto = servicio.cancelar()
    importe = f"{devuelto:.2f}".replace(".", ",")
    return render_template('mensaje.html',
                           titulo='Operación cancelada',
                           mensaje=f"Devuelto: {importe} EUR.")

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 /cancelar solo acepta POST (no GET). Si abres http://localhost:5000/cancelar directamente 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

  1. GET /seleccionar → desplegable, eliges A1, envías.
  2. Vas a /insertar, metes 2.0, envías → /saldo muestra 2.00 EUR.
  3. Vas a /comprar, pulsas "Confirmar compra" → mensaje con el cambio.
  4. Vuelves a /comprar sin 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:

@app.route('/agregar', methods=['GET', 'POST'])
def agregar():
    if request.method == 'POST':
        datos = {k: request.form.get(k, '') for k in ('codigo', 'nombre', 'precio', 'cantidad', 'descuento')}
        try:
            codigo = datos['codigo'].strip()
            nombre = datos['nombre'].strip()
            precio = float(datos['precio'])
            cantidad = int(datos['cantidad'])
            descuento_raw = datos['descuento'].strip()

            if descuento_raw and float(descuento_raw) > 0:
                porcentaje = float(descuento_raw)
                servicio.agregar_producto_con_descuento(codigo, nombre, precio, cantidad, porcentaje)
            else:
                servicio.agregar_producto(codigo, nombre, precio, cantidad)
            return redirect(url_for('ver_producto', codigo=codigo))
        except ProductoYaExisteError as e:
            return render_template('agregar.html', datos=datos, error=str(e)), 409
        except ValueError as e:
            return render_template('agregar.html', datos=datos, error=str(e)), 400
    return render_template('agregar.html', datos={}, error=None)

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(...) y int(...) lanzan ValueError si el formato es incorrecto. Lo capturamos.
  • El dominio lanza ValueError si precio ≤ 0, cantidad < 0, código vacío, etc. Mismo tipo de excepción, mismo manejo.
  • El dominio lanza ProductoYaExisteError si el código ya existe. Esto es conflicto de estado, no formato → código 409, no 400.

Comprobación

  • POST con campos válidos (X1 / Zumo / 1.5 / 10, sin descuento) → redirige a /producto/X1.
  • POST con descuento 20 añ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.html se 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:

@app.route('/eliminar/<codigo>', methods=['GET', 'POST'])
def eliminar(codigo):
    if request.method == 'POST':
        try:
            servicio.eliminar_producto(codigo)
            return redirect(url_for('listar_productos'))
        except ProductoNoEncontradoError as e:
            return f"Error: {e}", 404
    try:
        keys = ['codigo', 'nombre', 'precio_base', 'precio_final', 'cantidad', 'descuento']
        producto = dict(zip(keys, servicio.obtener_producto(codigo)))
        return render_template('eliminar.html', producto=producto)
    except ProductoNoEncontradoError as e:
        return f"Error: {e}", 404

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 /insertar y /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 /agregar con formulario).
  • /eliminar/<codigo> con confirmación GET + acción POST.
  • /cancelar solo acepta POST (devuelve 405 con GET).
  • domain/ e infrastructure/ intactos.
  • presentation/menu.py sigue funcionando sin cambios.
  • CHANGELOG.md y README.md actualizados.
  • 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.