Saltar a contenido

FORMULARIOS HTML Y MÉTODO POST EN FLASK

Estos apuntes asumen que ya conoces lo visto en los apuntes anteriores: rutas, excepciones, manejadores de error y plantillas Jinja2.

Ejemplo recurrente. Para ilustrar los conceptos a lo largo de la fase usaremos un caso de estudio: un formulario para que el usuario introduzca una cantidad numérica (por ejemplo, un importe en EUR) y la envíe a una ruta /insertar que la procese. Veremos cómo construir el HTML, cómo recoger los datos en el servidor, cómo validarlos y cómo manejar errores. Es el mismo ejemplo que verás implementado paso a paso en el lab.

HTTP: GET y POST

El problema con GET para escribir datos

Hasta ahora todas las rutas que hemos hecho eran GET. El navegador pide una URL y Flask responde. En los apuntes anteriores incluso hicimos rutas que pasaban datos en la propia URL, por ejemplo /recurso/<id> o /operacion/<param1>/<param2>/<param3>.

Eso funciona, pero tiene problemas serios cuando lo que hacemos modifica el estado del servidor:

  • La URL es visible en la barra del navegador, en el historial y en cualquier registro intermedio (proxy, log).
  • La URL es reproducible: si recargas con F5, vuelve a ejecutar la acción. Por ejemplo: si la URL inserta un importe, recargar la página suma el importe otra vez.
  • La URL tiene límite de tamaño (típicamente unos 2 KB) y restricciones de caracteres.
  • Cualquier enlace o buscador puede disparar la acción sin intención del usuario.

POST resuelve todo esto: los datos viajan en el cuerpo de la petición, no en la URL.

Comparativa GET vs POST

Aspecto GET POST
Dónde van los datos En la URL (?clave=valor) o en la propia ruta (/insertar/2.0) En el cuerpo de la petición HTTP
Visible en barra de direcciones No
Se cachea Sí (navegador, proxy) No por defecto
Recargar con F5 Repite el GET sin avisar El navegador pregunta antes de reenviar
Idempotente Sí (debería serlo) No
Para qué sirve Leer (mostrar, consultar) Escribir (crear, modificar, borrar)

Conceptos: idempotente

Una operación HTTP es idempotente cuando ejecutarla 1 vez o N veces deja el servidor en el mismo estado. Por ejemplo, un GET /saldo repetido 10 veces sigue devolviendo el mismo valor; en cambio, un POST /insertar con cantidad=2 ejecutado 10 veces sumaría 10 veces ese importe. Por eso pulsar F5 sobre un GET es seguro y sobre un POST el navegador pregunta antes de reenviar.

Conceptos: regla práctica

  • Si la URL solo lee algo y volver a llamarla no cambia nada → GET.
  • Si la URL escribe en la base de datos: por ejemplo, da de alta un recurso, modifica un campo, borra un elemento… → POST.

Por ejemplo, en nuestro proyecto las rutas que muestran datos (listados, fichas, búsquedas) se quedan en GET. Las que tocan estado (/insertar, /agregar, /eliminar/..., /comprar, /cancelar) van a POST.


El formulario HTML

Estructura mínima

Un formulario es un bloque <form> con campos <input> y un botón de envío. Cuando el usuario pulsa el botón, el navegador empaqueta los valores y los envía al servidor con el método y la URL que indique el formulario.

1
2
3
4
5
6
7
<form method="post" action="{{ url_for('insertar') }}">
    <label>
        Cantidad (EUR):
        <input type="text" name="cantidad" required>
    </label>
    <button type="submit">Insertar</button>
</form>
Atributo Qué hace
method="post" Verbo HTTP que usa el navegador al enviar. Por defecto sería get.
action="..." URL a la que se envían los datos. Usa url_for(...) en lugar de escribirla a mano.
name="cantidad" Clave con la que el campo llegará al servidor. Sin name, el campo no se envía.
required Validación del cliente: el navegador no deja enviar si el campo está vacío (el servidor también tendrá que comprobarlo).

Más adelante añadiremos un atributo value="..." a este <input> para conservar lo que el usuario tecleó cuando re-renderizamos el formulario tras un error. Por ahora, con esta versión basta.

El atributo name es obligatorio

El name= es la etiqueta con la que se envía el valor del campo: sin name, el campo no se envía y el dato se pierde. Más adelante veremos cómo se recoge en el servidor.

Validación en el cliente y en el servidor

La validación de un formulario puede ocurrir en dos sitios:

  • En el cliente (el navegador): rechaza el envío antes de que salga del equipo del usuario. Es rápida y mejora la experiencia, pero se puede saltar — basta con desactivar JavaScript, modificar el HTML desde las herramientas del navegador o enviar la petición desde un terminal con curl.
  • En el servidor (nuestro código Flask y el dominio): se ejecuta sí o sí cuando llega la petición. Es la que realmente protege los datos.

La regla

La validación del cliente es ayuda; la del servidor es defensa. Lo que pongamos en el HTML no nos exime de comprobarlo en Python. Más abajo, en Validación: misma excepción, distinto re-render, lo vemos en detalle.

Tipos de input habituales

Tipo Para qué Ejemplo
text Texto libre (también números si validamos en aplicación) <input type="text" name="codigo">
number Números (validación del navegador) <input type="number" name="cantidad">
password Texto oculto <input type="password" name="clave">
checkbox Casilla on/off <input type="checkbox" name="oferta">
select Desplegable con <option> dentro <select name="codigo">...</select>
submit Botón que envía el formulario <button type="submit">Enviar</button>

Por qué usamos text en vez de number para valores numéricos como precios y cantidades

Siguiendo la regla anterior, la validación de verdad la hace el servidor — la del cliente es solo ayuda. Y en el caso concreto de type="number" esa ayuda no es fiable: varía entre navegadores y depende del idioma del sistema (algunos aceptan coma, otros solo punto). Nos sale más limpio dejar que el campo llegue siempre como string y validar una sola vez en el servidor.


El route que muestra un formulario (GET)

En los apuntes de plantillas usamos render_template para servir plantillas que mostraban datos al usuario: la lista de productos, la ficha de un producto, los resultados de una búsqueda.

Una plantilla, sin embargo, no tiene por qué contener solo datos: puede contener también el HTML de un formulario que el usuario va a rellenar. Eso es lo que vamos a hacer ahora.

La plantilla insertar.html

Guardamos el HTML del formulario que vimos arriba en un fichero insertar.html, en la carpeta templates/, igual que cualquier plantilla de Fase 4:

{% extends "base.html" %}

{% block titulo %}Insertar dinero{% endblock %}

{% block contenido %}
<h2>Insertar dinero</h2>

<form method="post" action="{{ url_for('insertar') }}">
    <label>
        Cantidad (EUR):
        <input type="text" name="cantidad" required>
    </label>
    <button type="submit">Insertar</button>
</form>
{% endblock %}

Es una plantilla Jinja2 normal — hereda de base.html y usa url_for(...) para construir la URL del action. La novedad es que dentro del block contenido hay un <form> en vez de una tabla o una ficha.

El route que la sirve

El route que entrega esta plantilla cuando el usuario escribe http://localhost:5000/insertar en la barra del navegador es un GET normal:

1
2
3
@app.route('/insertar')
def insertar():
    return render_template('insertar.html')

Eso es todo lo que hace falta para que el usuario vea el formulario en pantalla. Cuando lo rellene y pulse Insertar, el navegador hará una petición POST a la misma URL /insertar con los datos en el cuerpo.

Este route todavía no acepta POST

Si arrancas la app con solo esta versión del route y pulsas Insertar, Flask responde 405 Method Not Allowed: el decorator @app.route('/insertar') admite solo GET por defecto. En la siguiente sección añadimos la rama POST para que el envío funcione.

Conceptos: la plantilla del formulario es 'tonta'

La plantilla insertar.html no sabe nada de qué pasa después. Solo describe el formulario que se muestra. Cómo procesamos lo que se envía lo vemos en la siguiente sección.

Plantillas con listas desplegables pobladas por el route

A veces los valores válidos de un campo <select> no son fijos sino que vienen de la base de datos (por ejemplo, los productos disponibles, los usuarios registrados, las categorías activas). En esos casos el route pasa la lista a la plantilla con un kwarg, igual que pasaríamos cualquier otro dato:

1
2
3
4
@app.route('/seleccionar')
def seleccionar():
    productos = servicio.listar_productos()
    return render_template('seleccionar.html', productos=productos)

Y la plantilla rellena el <select> con un bucle {% for %}:

<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>

Es el mismo bucle {% for %} de Jinja2 que ya vimos en los apuntes de plantillas para listar elementos en una tabla. La diferencia es que ahora cada elemento es una <option> en vez de una fila de tabla.


Acceder a los datos del formulario en Flask

El objeto request.form

En la siguiente sub-sección añadiremos la rama POST al route /insertar. Antes, veamos cómo Flask deja accesibles los campos del formulario cuando llega un POST.

Flask construye un diccionario con todos los campos del formulario y lo deja accesible en el atributo request.form del objeto request que ya conocemos de los apuntes anteriores (allí lo usamos con before_request para registrar en el log request.method y request.path; ahora vamos a usar request.form).

Dentro del route, hay dos maneras de leer un campo concreto del formulario:

1
2
3
4
5
from flask import request

# Dentro de un route que recibe POST, para leer el campo "cantidad":
cantidad = request.form['cantidad']         # acceso directo
cantidad = request.form.get('cantidad', '')  # acceso con valor por defecto

Las dos formas son válidas; lo que cambia es qué ocurre cuando la clave no está presente:

Acceso Comportamiento Cuándo usarlo
request.form['clave'] Lanza KeyError si la clave no existe. Campos obligatorios: si no llegan, es un bug de la plantilla, no del usuario.
request.form.get('clave', valor_por_defecto) Devuelve el valor por defecto si no existe. Campos opcionales (un descuento que puede no venir) y para reconstruir el formulario tras un error sin asumir que todos los campos estaban rellenos.

Routes de formularios con dos verbos (tipos de peticiones)

Hasta ahora cada route aceptaba solo GET (era el valor por defecto de @app.route). Para gestionar formularios el mismo route tiene que mostrar el formulario (GET) y procesarlo (POST):

1
2
3
4
5
6
7
@app.route('/insertar', methods=['GET', 'POST'])
def insertar():
    if request.method == 'POST':
        # Procesar el formulario y, si todo va bien, redirigir.
        ...
    # GET: mostrar el formulario vacío.
    return render_template('insertar.html')

Fíjate en methods=['GET', 'POST']: lista los verbos permitidos. Si alguien intenta acceder con un verbo no listado (por ejemplo, un DELETE), Flask responde 405 Method Not Allowed automáticamente.

El patrón if request.method == 'POST'

Es el patrón estándar para routes con dos verbos. La rama del if procesa el POST; el return que está fuera del if (no necesita else) es el GET. Sale natural porque el flujo de POST suele acabar con un return antes de llegar a la rama del GET.


El patrón Post/Redirect/Get (PRG)

El problema sin PRG

Imagina la siguiente situación. El usuario escribe http://localhost:5000/insertar en la barra del navegador. Eso es un GET a /insertar: nuestro route entra por la rama del GET (el return render_template('insertar.html', ...) que está fuera del if) y devuelve esta página con el formulario:

Ahora el usuario teclea 2.0 en el campo "Cantidad" y pulsa Insertar. Esta vez el navegador hace un POST a /insertar con la cantidad en el cuerpo de la petición. La misma URL, pero por dentro del route entramos por la otra rama: la del if request.method == 'POST'. Procesamos la cantidad (insertar 2 EUR en el saldo) y, de la forma más directa, devolvemos la página saldo.html ya con el nuevo saldo. A primera vista parece razonable: el usuario hace una acción y ve el resultado.

1
2
3
4
5
6
7
8
# Mala práctica: NO hagas esto
@app.route('/insertar', methods=['GET', 'POST'])
def insertar():
    if request.method == 'POST':
        cantidad = float(request.form['cantidad'])
        servicio.insertar_dinero(cantidad)
        return render_template('saldo.html', saldo=servicio.saldo())  # mal
    return render_template('insertar.html')

El usuario ve /insertar con el saldo actualizado. Pero la URL sigue siendo /insertar y el navegador recuerda que la última petición fue un POST con cantidad=2.0. Si pulsa F5, el navegador pregunta "¿Reenviar formulario?" y, si dice que sí, vuelve a insertar 2 EUR. Lo mismo si añade la página a marcadores.

La solución: redirigir tras éxito

Tras procesar el POST, devuelve una redirección a otra ruta:

1
2
3
4
5
6
7
@app.route('/insertar', methods=['GET', 'POST'])
def insertar():
    if request.method == 'POST':
        cantidad = float(request.form['cantidad'])
        servicio.insertar_dinero(cantidad)
        return redirect(url_for('saldo'))   # <-- la clave
    return render_template('insertar.html')

Lo que pasa entonces:

  1. El navegador recibe 302 Found con Location: /saldo.
  2. Hace un GET a /saldo.
  3. Esa GET es la que aparece en la barra de direcciones.
  4. Si el usuario pulsa F5, recarga /saldo (un GET inocuo), no el POST original.

Ya usamos redirect(url_for(...)) en los apuntes anteriores. La diferencia es que ahora redirigir no es un detalle estético, sino la pieza que protege contra reenvíos accidentales.

Conceptos: PRG en una línea

Tras un POST con éxito, no devuelvas HTML: devuelve una redirección. El cliente termina viendo una URL idempotente que puede recargar, marcar o compartir sin efectos secundarios.

Situación Qué devolver
GET (mostrar formulario) render_template('form.html', ...)
POST con éxito redirect(url_for('destino'))
POST con error render_template('form.html', error=..., <datos del formulario>), código 400/404/409

Hay un caso excepcional: si el POST con éxito produce información puntual sin URL natural a la que redirigir (por ejemplo, un mensaje de confirmación con un dato calculado al vuelo), podemos renderizar una plantilla de mensaje en lugar de redirigir. En la fase 6 unificaremos este caso con un sistema de mensajes flash que sí es compatible con PRG.

Mala práctica: POST que devuelve HTML directamente

# Mal: tras éxito, recargar duplica la operación.
@app.route('/insertar', methods=['GET', 'POST'])
def insertar():
    if request.method == 'POST':
        servicio.insertar_dinero(float(request.form['cantidad']))
        return render_template('insertar.html', mensaje='Saldo añadido')
    return render_template('insertar.html')
El usuario que pulse F5 verá "¿Reenviar formulario?" y, si confirma, sumará el dinero otra vez.


Validación: misma excepción, distinto re-render

Hasta aquí el route funciona cuando el usuario teclea una cantidad válida: lee el campo, llama al servicio, redirige. Pero, ¿qué pasa si en un campo numérico teclea abc, deja un valor en -1 o intenta crear un recurso con una clave que ya existía?

{ width="300" }

En esos casos el servicio o el propio Python lanzarán una excepción, y si no la atrapamos, Flask responde con un 500 Internal Server Error: una pantalla genérica de error que no le dice nada al usuario y, además, le hace perder los datos que ya había tecleado.

{ width="300" }

Lo que queremos es lo contrario: detectar el error, volver a mostrar el mismo formulario con un mensaje explicativo y conservar lo que el usuario ya había escrito para que solo tenga que corregir el campo con error.

Un único mensaje, no marcas de error por cada campo

En esta fase mostraremos un único mensaje con la excepción capturada (un <p> rojo encima del formulario), no mensajes individuales bajo cada campo. Si el usuario teclea varios datos inválidos a la vez, el route los procesa secuencialmente y la primera excepción que se dispare es la que mostramos; el usuario la corrige, vuelve a enviar, y si queda otro error aparece el siguiente.

{ width="400" }

En esta sección vemos cómo hacerlo. Tres pasos:

  1. Identificar qué errores pueden ocurrir y qué excepción los representa (sub-sección Validación de tipo y validación de regla de negocio).
  2. Atraparlos con try/except dentro del route (sub-sección Patrón completo: insertar dinero).
  3. Re-renderizar la plantilla pasándole el str(e) de la excepción como kwarg error=... y los datos previos para conservar lo tecleado, en lugar de redirigir.

El dominio sigue siendo el mismo

La novedad ahora no es dónde se valida (eso sigue en el dominio, igual que cuando construimos la interfaz de consola en apuntes anteriores). La novedad es que ahora, cuando atrapamos la excepción, en lugar de devolver un texto crudo return str(e), 400 volvemos a renderizar el formulario con los datos que el usuario tecleó y un mensaje de error. Es el mismo try/except que ya hicimos; cambia solo lo que ponemos en el except.

Validación de tipo y validación de negocio

Cuando llegan campos como string, hay dos clases de validación que pueden fallar:

Tipo Origen Ejemplo de situación Excepción típica
De tipo / formato float(...), int(...) El usuario teclea abc en un campo numérico, o 1,5 con coma decimal ValueError
De negocio El servicio o el dominio Por ejemplo, un importe que debe ser positivo y llega -1 ValueError
De estado: recurso ya existe El repositorio Por ejemplo, alta con una clave duplicada Excepción del dominio (ConflictoExistente o similar)
De estado: recurso no existe El repositorio Por ejemplo, intentar borrar lo que ya no está Excepción del dominio (RecursoNoEncontrado o similar)

Las dos primeras lanzan el mismo tipo de excepción (ValueError) porque Python usa ValueError para cualquier "valor inválido", sea de tipo o de regla. Al usuario le da igual si el problema es que escribió abc o si escribió -1 — en ambos casos se queda en el formulario con un mensaje. Por eso un único except ValueError cubre las dos.

Actualizar la plantilla para mostrar el error y conservar el valor

Antes de tocar el route, la plantilla insertar.html tiene que estar preparada para recibir dos cosas nuevas:

  • Un mensaje de error que dibujaremos encima del formulario (cuando exista).
  • El valor previo del campo cantidad, para que aparezca relleno con lo que el usuario tecleó.

Modificamos la plantilla de ejemplo insertar.html con un bloque {% if error %} y un atributo value="{{ cantidad or '' }}" en el <input>:

{% extends "base.html" %}

{% block titulo %}Insertar dinero{% 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>
{% endblock %}

Dos detalles de Jinja2:

  • {% if error %}...{% endif %}: el <p> rojo solo se renderiza si la variable error tiene contenido. Cuando el route pasa error=None (caso GET o éxito), no se dibuja nada.
  • value="{{ cantidad or '' }}": si cantidad viene con valor, lo usamos. Si cantidad viene a None, Python evalúa el or y devuelve la parte derecha (la cadena vacía). El HTML resultante es entonces value="" y el campo aparece limpio.

Patrón completo: insertar dinero

Con la plantilla ya preparada, este es el route definitivo de /insertar incluyendo el patrón PRG y la validación:

@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'))                # éxito: PRG
        except ValueError as e:
            return render_template(
                'insertar.html',
                error=str(e),
                cantidad=request.form.get('cantidad', '')   # <-- conserva lo tecleado
            ), 400
    return render_template('insertar.html', error=None, cantidad=None)  # GET

Tres detalles importantes:

  • En éxito redirigimos (redirect), no renderizamos.
  • En error volvemos a renderizar la misma plantilla con error=... y cantidad=....
  • Pasamos request.form.get('cantidad', '') para que el campo aparezca relleno con lo que el usuario escribió.

Conceptos: el , 400 final del return

Cuando una vista de Flask devuelve algo, 400, Python lo interpreta como una tupla de dos elementos: (cuerpo, código_de_estado). Flask la desempaqueta y construye una respuesta con ese cuerpo y ese código HTTP. Por eso , 400 aparece colgando fuera del paréntesis de cierre del render_template: no forma parte de la llamada al render_template, está al mismo nivel. Es equivalente — y a veces se ve escrito así — a return (render_template(...), 400) con paréntesis explícitos rodeando la tupla.

Mala práctica: vaciar el formulario tras un error

Si el usuario tecleó un precio como 1,5 (con coma) y le dices "formato incorrecto", lo más frustrante es que al volver al formulario tenga que escribirlo todo otra vez. El mensaje de error tiene que llegar acompañado de los datos tecleados, no en lugar de ellos.

Varios except apilados: un route, varios códigos HTTP

El route de /insertar solo necesita capturar ValueError porque la única operación que puede fallar es la conversión del importe y la validación del dominio. Pero cuando un route trabaja con más operaciones, normalmente puede fallar de varias formas, cada una con su propio código HTTP.

El caso típico es el route de alta de un producto. Aquí pueden ocurrir dos cosas distintas:

Fallo Excepción Código HTTP
El precio es abc, o la cantidad es -1 ValueError 400
El código del producto ya existe ProductoYaExisteError 409

El patrón es un único try con varios except debajo, cada uno con su render_template y su código HTTP:

@app.route('/agregar', methods=['GET', 'POST'])
def agregar():
    if request.method == 'POST':
        datos = request.form
        try:
            precio = float(datos['precio'])
            servicio.agregar_producto(datos['codigo'], datos['nombre'], precio, ...)
            return redirect(url_for('listar_productos'))
        except ValueError as e:                               # formato o negocio
            return render_template('agregar.html', datos=datos, error=str(e)), 400
        except ProductoYaExisteError as e:                    # conflicto de estado
            return render_template('agregar.html', datos=datos, error=str(e)), 409
    return render_template('agregar.html', datos={}, error=None)

Tres detalles:

  • Mismo patrón de re-render en los dos except: cambia solo el código HTTP.
  • datos=request.form se pasa entero a la plantilla en lugar de campo a campo, porque ahora hay varios campos (codigo, nombre, precio...) que conservar.
  • La plantilla agregar.html lee cada campo con la misma idea que antes: value="{{ datos.codigo or '' }}", value="{{ datos.nombre or '' }}", etc.

Conceptos: ¿por qué códigos HTTP distintos?

Los códigos HTTP no son solo para humanos: los consume cualquier cliente automatizado (otro programa, una API, un script). Un 400 significa "los datos que enviaste están mal formados"; un 409 significa "los datos son válidos, pero entran en conflicto con el estado actual del servidor". Para el usuario que rellena el formulario el resultado es el mismo (vuelve a ver el formulario con un mensaje), pero el código es información útil para el cliente que reciba la respuesta.

El orden de los except no importa aquí

ValueError y ProductoYaExisteError no tienen relación de herencia entre sí, así que da igual cuál pongamos primero. Solo importaría el orden si capturáramos una excepción y sus subclases — en ese caso, la subclase debe ir antes (es la misma regla que en cualquier try/except de Python).


Operaciones destructivas y POST

Por qué borrar nunca puede ir en GET

Un enlace del tipo <a href="/eliminar/<id>">Eliminar</a> parece inofensivo, pero se puede activar sin intención del usuario:

  • Al hacer clic accidental en el enlace.
  • Al recargar la página tras una eliminación previa.
  • Al previsualizar el enlace en un chat o un mensaje (Slack, Telegram y muchos otros hacen un GET silencioso para mostrar la vista previa).
  • Al ser indexado por un buscador que rastree tu intranet.
  • Al ser visitado por precarga del navegador.

Todos esos casos lanzan un GET automático. Si el GET borra, el daño está hecho.

El patrón de confirmación

La solución estándar tiene dos pasos sobre la misma URL:

  1. GET /eliminar/<id> → muestra una página de confirmación que dice qué se va a borrar.
  2. POST /eliminar/<id> → ejecuta el borrado y redirige.

Una ruta de eliminar de ejemplo quedaría:

@app.route('/eliminar/<codigo>', methods=['GET', 'POST'])
def eliminar(codigo):
    if request.method == 'POST':
        try:
            servicio.eliminar_recurso(codigo)
            return redirect(url_for('listar_recursos'))
        except RecursoNoEncontrado as e:
            return f"Error: {e}", 404
    # GET: cargar el recurso y mostrar pantalla de confirmación
    try:
        recurso = servicio.obtener_recurso(codigo)
        return render_template('eliminar.html', recurso=recurso)
    except RecursoNoEncontrado as e:
        return f"Error: {e}", 404

La plantilla de confirmación contiene un formulario POST (no un enlace) con un botón "Sí, eliminar" y un enlace "No, volver" que sí es seguro porque solo navega:

<form method="post" action="{{ url_for('eliminar', codigo=recurso.codigo) }}">
    <button type="submit">Sí, eliminar</button>
</form>
<p><a href="{{ url_for('ver_recurso', codigo=recurso.codigo) }}">No, volver</a></p>

{ width="300" }

El <a> de "No, volver" es seguro porque solo navega a una página de lectura: no modifica nada. La regla, en una frase: los enlaces pueden navegar, no actuar.

Mala práctica: enlaces que borran

<!-- Mal: cualquier precarga o clic accidental ejecuta el borrado. -->
<a href="/eliminar/<id>">Eliminar</a>
Sustitúyelo siempre por un formulario POST con un botón. El método HTTP es la diferencia entre un enlace navegable y una acción destructiva.

Routes que solo aceptan POST

Algunas acciones que modifican estado no necesitan pantalla de confirmación previa: o bien el botón que las dispara ya está integrado en otra pantalla junto a la acción contraria, o el contexto en el que aparece el botón ya hace de confirmación suficiente. En esos casos el route solo debe aceptar POST, sin verbo GET.

Por ejemplo, una acción de cancelar cuyo botón vive en la misma pantalla que el de confirmar la operación:

1
2
3
4
@app.route('/cancelar', methods=['POST'])
def cancelar():
    # ... lógica de la acción ...
    return render_template('mensaje.html', ...)

Una pantalla que ofrezca al usuario dos opciones (aceptar y cancelar) tendría dos botones, cada uno en su propio <form> POST apuntando a la URL correspondiente. Por ejemplo:

{ width="300" }

Si alguien escribe http://localhost:5000/cancelar en la barra del navegador, Flask responde 405 Method Not Allowed. Es justo lo que queremos: la única forma de disparar la acción es pulsando el botón del formulario que apunta a esa URL con POST.


Resumen del patrón completo

Pieza Dónde vive Qué hace
<form method="post" action="..."> Plantilla Empaqueta los campos y los envía al servidor con POST.
name="..." en cada <input> Plantilla Define la clave con la que llega el campo a request.form.
{% if error %} con <p> rojo Plantilla Muestra el mensaje de error cuando el route re-renderiza tras una excepción.
value="{{ campo or '' }}" Plantilla Conserva lo que el usuario tecleó cuando el formulario se re-renderiza.
Validación cliente (required, type=...) Plantilla Ayuda al usuario, pero se puede saltar.
methods=['GET', 'POST'] Route Permite los dos verbos sobre la misma URL (formulario en GET, procesar en POST).
methods=['POST'] Route Solo procesar; sin pantalla previa. Acceder con GET responde 405.
if request.method == 'POST': Route Bifurca entre mostrar el formulario y procesarlo.
request.form['x'] o .get('x', '') Route Lee los campos enviados.
try/except con excepciones del servicio/dominio Route Defensa: validación que sí se ejecuta sí o sí.
return redirect(url_for(...)) Route Implementa Post/Redirect/Get tras éxito (POST → 302 → GET).
return render_template(..., error=...), código Route Re-renderiza el formulario con mensaje cuando hay excepción.
Códigos HTTP: 400 / 409 / 404 Route Identifican el tipo de error al cliente (datos inválidos / conflicto / no existe).
Patrón Post/Redirect/Get (PRG) Route Tras un POST con éxito, devolver redirect(...) en lugar de HTML: F5 recarga el GET de destino de la redirección, no repite la operación.
Operación destructiva con confirmación Route + Plantilla GET muestra "¿seguro?", POST ejecuta. Nunca destruir vía enlace.

En la fase 6 mejoraremos dos cosas: los mensajes flash (un sistema estándar para mostrar avisos "compra realizada" tras un redirect) y los mensajes de error de Python que ahora muestras tal cual (could not convert string to float...). Por ahora, lo que tienes es suficiente para una app web honesta: PRG, validación con re-render y protección frente a operaciones destructivas detrás de POST.