100 Días de Código en Python — Día 60: Formularios HTML y solicitudes POST en Flask
Nivel: Grado décimo
Área: Tecnología e Informática
Modalidad: Tutorial práctico
Fuente: Curso "100 Days of Code: The Complete Python Pro Bootcamp" (Angela Yu, App Brewery). Contenido del Día 60 (nivel avanzado).
19 min de lectura
15 secciones
Este tutorial adapta pedagógicamente el Día 60. Las secciones 📝 Ejercicio original del curso provienen de las lecciones 425 a 430; las soluciones oficiales están en los gists enlazados y los archivos de inicio y final en los recursos de las lecciones 429 y 430. Los retos 🟢🟡🟠🔴, las secciones sobre GET contra POST, validación y correo con tildes, el proyecto extra y la etapa 2 del proyecto integrador son creaciones de esta adaptación. Las correcciones al material original aparecen como 🛠️ Nota técnica.
Nota sobre las verificaciones: el 27 de septiembre de 2026, con Python 3.14, Flask 3.1.3 y un servidor de correo local (aiosmtpd), se comprobó con el cliente de pruebas de Flask: que un GET a una ruta solo POST responde 405, que falta un campo en request.form responde 400, el flujo GET/POST en una sola ruta con msg_sent, y que el envío de correo del estilo del Día 32 (sendmail con una cadena) falla con UnicodeEncodeError si el mensaje tiene tildes, mientras que EmailMessage + send_message funciona. En la plantilla Clean Blog 6.0.9 se comprobó que los campos del formulario de contacto no tienen atributo name y que el botón trae la clase disabled.
🏫 Para el aula: los correos se envían al servidor de correo local (python -m aiosmtpd -n -l localhost:8025, como en los Días 40 y 47). Así no se necesitan contraseñas reales y nada sale del computador.
El blog del Día 59 funciona completo… salvo el formulario de contacto. Para arreglarlo hay que aprender cómo se envía un formulario HTML y cómo recibir sus datos en Flask. El objetivo del día: que, al llenar el formulario, el dueño del sitio reciba un correo con el nombre, el correo, el teléfono y el mensaje del visitante.
Prerequisitos: formularios HTML (Días 41 y 42), el blog del Día 59, envío de correo con smtplib (Día 32), condicionales en Jinja (Día 57).
Todas tus rutas respondían a solicitudes para ver algo. Hoy, por primera vez, el navegador envía datos a tu servidor. Es el paso de un sitio "de solo lectura" a una aplicación que recibe información de las personas, con la responsabilidad que eso implica: validar lo que llega y cuidar esos datos.
type="password" oculta lo que se escribe (¡pero no lo cifra!).
<label for="..."> enlazado con el id del campo: al hacer clic en la etiqueta, el cursor va al campo, y los lectores de pantalla saben qué campo es. Es accesibilidad básica.
methods recibe una lista, así que una ruta puede atender varios métodos: @app.route("/contact", methods=["GET", "POST"]).
Flask tiene un objeto request (¡no confundir con el módulo requests!) que da acceso a lo que llegó en la solicitud.
💪 Reto difícil: con la documentación de Flask (The Request Object), obtén el nombre y la contraseña y devuélvelos en un <h1>.
El name del HTML es la clave en request.form. El id no viaja al servidor.
request.form se comporta como un diccionario.
Comportamientos verificados con Flask 3.1:
Situación
Respuesta de Flask
Visitar /login escribiendo la URL (GET) en una ruta con methods=["POST"]
405 Method Not Allowed
POST sin el campo password y el código usa request.form["password"]
400 Bad Request
POST sin el campo y el código usa request.form.get("password")
Sigue funcionando; el valor es None
🛠️ Nota técnica: el reto devuelve la contraseña en pantalla solo para practicar. En una aplicación real nunca se muestra ni se guarda una contraseña tal como llega (el Día 68 aprenderás a guardarla con hash). Y como en el Día 55, devolver datos del usuario dentro de una f-string con HTML permite inyectar código: usa escape o, mejor, una plantilla.
🟢 Reto 1 — Aplicación: un formulario con nombre y color favorito que devuelva una página con fondo de ese color (usa <input type="color">).
🟡 Reto 2 — Ampliación: cambia el formulario a method="get" y la ruta a methods=["GET"]; lee los datos con request.args. ¿Qué ves en la barra de direcciones?
🟠 Reto 3 — Desafío: con request.form.get(...), muestra un mensaje amable si algún campo llega vacío, en lugar de un 400.
🔴 Reto 4 — Pensamiento computacional: con las herramientas de desarrollo (pestaña Red), envía el formulario y encuentra la solicitud POST. ¿Dónde están los datos? ¿Viajan cifrados en http://127.0.0.1? ¿Qué cambiaría con https://?
(Sección creada para esta adaptación a partir de las lecciones 427 a 429.)
GET
POST
Para qué
Pedir o ver algo
Enviar datos para que el servidor haga algo
Dónde van los datos
En la URL: /buscar?q=cactus
En el cuerpo de la solicitud
Se puede guardar en marcadores o compartir
Sí
No
Queda en el historial del navegador
Sí, con los datos
La URL sí; los datos no
Ejemplos
Buscar, filtrar, ver una entrada
Iniciar sesión, enviar un mensaje, publicar
En Flask
request.args
request.form
Regla práctica: si la acción cambia algo (envía un correo, guarda un registro) o lleva datos privados, usa POST.
💡 POST no es "seguro" por sí solo: los datos no se ven en la URL, pero viajan legibles salvo que el sitio use HTTPS. Por eso los sitios reales con formularios usan siempre https://.
Descarga los archivos de inicio: son el blog del Día 59 con un contact.htmlsimplificado para que sea más fácil conectarlo con main.py.
Agrega a main.py una ruta "/form-entry" que reciba los datos.
Reto: que el servidor imprima en la terminal lo que el usuario escribió y devuelva un <h1> "Successfully sent your message".
Reto: combina /contact y /form-entry en una sola ruta/contact que haga algo distinto según el método (GET o POST). Pistas: cambia el action de contact.html y usa request.method.
Reto: en lugar de devolver un <h1> suelto, haz que el propio <h1> de contact.html diga "Successfully sent message". Pista: {% if %} de Jinja.
🛠️ Nota técnica (verificado): en el contact.htmloriginal de Clean Blog, los campos tienen id pero noname, el formulario depende de SB Forms (data-sb-form-api-token) y el botón tiene la clase disabled. Por eso el curso entrega uno simplificado. Si trabajas con el original, debes: quitar data-sb-form-api-token y el script sb-forms-latest.js, agregar action y method="post" al <form>, agregar name a cada campo y quitar disabled del botón.
El atributo required en el HTML impide enviar campos vacíos… desde el navegador. Pero cualquiera puede enviar un POST sin pasar por tu formulario (con requests.post, Día 37). La validación real se hace en el servidor:
Python
123
nombre = request.form.get("name","").strip()ifnot nombre:return render_template("contact.html", msg_sent=False, error="Escribe tu nombre")
El Día 61 verás una herramienta que hace esto de forma ordenada: WTForms.
📖 Un detalle profesional: POST/Redirect/GET (adaptación)#
Si después de enviar el formulario presionas F5, el navegador pregunta si quieres reenviar los datos: ¡se mandaría otro correo! La solución habitual es redirigir después de un POST exitoso:
Python
12345
from flask import redirect, url_for
if request.method =="POST":...return redirect(url_for("contact", enviado=1))
La recarga ya no repite el envío. (redirect lo usarás mucho desde el Día 63.)
Con lo aprendido en el Día 32, haz que el formulario de contacto envíe un correo al dueño del sitio con los datos del visitante. Puede que necesites repasar las lecciones de smtplib.
🛠️ Nota técnica (verificado con Python 3.14): si el visitante escribe una sola tilde o una ñ (por ejemplo, "María" o "¿Qué tal?"), sendmail falla:
Texto
1
UnicodeEncodeError: 'ascii' codec can't encode character '\xed' in position 32
porque convierte la cadena a ASCII. En español esto ocurre casi siempre. La solución es construir el correo con la clase EmailMessage de la biblioteca estándar, que codifica el asunto y el cuerpo en UTF-8 (verificado: el asunto "Nuevo mensaje de María" llega como =?utf-8?q?Mar=C3=ADa?= y se muestra bien):
Python
123456789101112131415161718
import os
import smtplib
from email.message import EmailMessage
SERVIDOR = os.environ.get("SMTP_SERVIDOR","localhost")
PUERTO =int(os.environ.get("SMTP_PUERTO","8025"))
DESTINATARIO = os.environ.get("CORREO_DUENO","dueno@blog.local")defsend_email(name, email, phone, message):
correo = EmailMessage()
correo["Subject"]=f"Nuevo mensaje de {name}"
correo["From"]="formulario@blog.local"
correo["To"]= DESTINATARIO
correo["Reply-To"]= email # al responder, le escribes al visitante
correo.set_content(f"Nombre: {name}\nCorreo: {email}\nTeléfono: {phone}\n\n{message}")with smtplib.SMTP(SERVIDOR, PUERTO)as conexion:
conexion.send_message(correo)
Con el servidor local de aiosmtpd no hacen falta starttls() ni login(). Con Gmail u otro proveedor real sí (Día 32), y la contraseña de aplicación va en un .env, nunca en el código.
Reply-To evita un error común: poner el correo del visitante en From. Muchos servidores rechazan correos cuyo From no les pertenece.
🟢 Reto 1: envía también una copia de confirmación al visitante ("Recibimos tu mensaje").
🟡 Reto 2: si el correo falla, muestra "No pudimos enviar tu mensaje, inténtalo más tarde" en lugar de un error 500.
🟠 Reto 3: guarda cada mensaje también en mensajes.csv (fecha, nombre, correo, mensaje), por si el correo falla.
🔴 Reto 4: evita el spam: agrega un campo oculto (honeypot) que las personas no ven; si llega con texto, es un robot y no se envía nada. Explica por qué funciona.
08
6. 🚀 Proyecto del día: el blog que recibe mensajes#
Encuentra cuatro problemas: requests en lugar de request (y sin importarlo de flask), la ruta no acepta POST, el método se compara en minúsculas y la variable se llama msg_send pero la plantilla usa msg_sent.
Formulario con categoría (<select>: Infraestructura, Convivencia, Académico, Otro), sugerencia (<textarea>) y una casilla "Enviar de forma anónima" (<input type="checkbox">). Si no es anónimo, pide nombre y curso.
Validación en el servidor: sugerencia de 20 a 500 caracteres; nombre obligatorio si no es anónimo.
Cada sugerencia se guarda en sugerencias.csv y se envía por correo (servidor local) al consejo.
Una página /estadisticas (GET) que muestre cuántas sugerencias hay por categoría.
Una ruta tiene methods=["POST"]. ¿Qué código de estado ves si escribes su URL en el navegador? ¿Y si envías el formulario sin uno de los campos que el código lee con corchetes?
Crea una calculadora de IMC: un formulario con peso y estatura que, con POST, muestre el resultado en la misma página ({% if %}), validando que ambos sean números positivos.
Ciclo de trabajo: formula el problema → inténtalo tú → consulta a la IA → lee y entiende → prueba → compara con la documentación oficial → modifica → explica con tus palabras.
Actividad 1 — El correo con tildes. Pide a una IA una función para enviar un correo con smtplib. Pruébala con "María" y "¿Qué tal?". Si falla, muéstrale el error y compara su corrección con la sección 5 y con la documentación de email.message.
Actividad 2 — Validación. Pide a una IA que valide el formulario de contacto. ¿Valida en el navegador, en el servidor o en ambos? Pruébalo enviando un POST con requests.post sin pasar por el formulario.
Actividad 3 — Comprobar una afirmación. Pregunta a una IA si POST es "más seguro" que GET. Contrasta su respuesta con la sección 3 y con la documentación de MDN sobre métodos HTTP.
13
11. 🛠️ Correcciones técnicas al material original#
Archivo
Afirmación o comportamiento del curso
Observación
Tratamiento
430
Enviar el correo como en el Día 32 (sendmail con una cadena)
Falla con UnicodeEncodeError si hay tildes o ñ (verificado)
Sección 5 con EmailMessage
427, 428
Devolver en pantalla el nombre y la contraseña
Aceptable como ejercicio; en producción no se muestran ni se guardan contraseñas, y el HTML con datos del usuario debe escaparse
Nota técnica de la sección 2
429
El contact.html del Día 59 no se conecta fácilmente
Los campos originales no tienen name, el formulario usa SB Forms y el botón es disabled (verificado)
El blog con el formulario de contacto funcionando y la captura del correo en la terminal de aiosmtpd (con un mensaje que tenga tildes).
La captura de la pestaña Red con la solicitud POST y sus datos (Reto 4 de la sección 2).
La tabla de pruebas del proyecto del día con sus resultados.
El buzón de sugerencias (proyecto extra).
La etapa 2 de la Plataforma del Club.
Cuaderno de errores: 405, 400, request contra requests, campos sin name, método en minúsculas, variable con otro nombre en la plantilla, reenvío al recargar, UnicodeEncodeError en el correo, servidor de correo apagado.