📝 Formularios en Flask con WTForms + Bootstrap

📝 Formularios en Flask: WTForms, validación y estilos con Bootstrap

Ya sabes crear rutas, manejar datos con JSON, hacer un CRUD completo, dominar Jinja2 con herencia de plantillas y dar estilo a tus aplicaciones con Bootstrap. Ahora toca dar el siguiente paso: crear formularios web que funcionen y sean seguros de verdad.

🔄 ¿Qué añadimos a nuestro proyecto?
  • forms.py – donde definiremos la clase SoporteForm.
  • Flask-WTF y email-validator – nuevas dependencias.
  • ✅ Ruta /soporte en el controlador (con GET y POST).
  • ✅ Plantilla soporte.html – el formulario con Bootstrap 5.
  • ✅ Validación automática (DataRequired, Email, Length).
  • ✅ Protección CSRF mediante token.
  • ✅ Migración de toda la aplicación a Bootstrap 5.

En este post vas a aprender a:Formularios en Python Flask - WTForms

  • ✅ Entender la diferencia entre GET y POST en formularios.
  • ✅ Crear formularios robustos con WTForms y conectarlos con Flask mediante Flask-WTF.
  • ✅ Validar datos automáticamente (DataRequired, Email, Length).
  • ✅ Proteger tus formularios contra ataques CSRF.
  • ✅ Renderizar formularios con Bootstrap 5 y estilos personalizados.
  • ✅ Procesar los datos en el controlador y mostrar mensajes de éxito/error con flash().
  • Manejar errores con try/except para dar feedback al usuario.

Al terminar, tendrás un formulario de soporte profesional, validado y con mensajes de éxito/error.


🧠 ¿Qué son los formularios en Flask?

meme python pensarEn Flask, un formulario no es más que un conjunto de campos HTML que el usuario rellena y envía al servidor. Pero hay dos conceptos clave que debes tener claros, ya que trabajamos con dos solicitudes distintas dependiendo de lo que el navegador del usuario necesita; una es cargar el sitio web donde está el formulario (cuando se carga cualquier sitio web, entre los elementos aparece el formulario) –eso es una petición GET– y la otra es cuando el usuario envía los datos que completó en ese sitio web con un formulario –eso es una petición POST. Las rutas que necesitan recibir datos pueden aceptar peticiones POST. En nuestro caso utilizamos POST porque el usuario está enviando los datos de un formulario. Recordemos:

SOLICITUDES GET vs POST

  • GET: Se usa para solicitar datos del servidor. Los datos se envían en la URL (visible). Es el método por defecto al cargar una página.
  • POST: Se usa para enviar datos al servidor (formularios, archivos, etc.). Los datos viajan en el cuerpo de la petición, no son visibles en la URL.

Diagrama GET - POST - Formularios en flask con wtforms

🔐 Importante: POST no significa cifrado

Que los datos viajen en el cuerpo de una petición POST y no aparezcan en la URL no significa que estén cifrados.

La protección de los datos durante el transporte depende de utilizar HTTPS.

POST ≠ cifrado. HTTPS = protege la comunicación en tránsito.

Cuando un usuario carga un formulario por primera vez, suele ser una petición GET. Cuando lo envía (hace clic en «Enviar»), es una petición POST. Por eso, en Flask, nuestras rutas de formularios suelen admitir ambos métodos:

@app.route('/soporte', methods=['GET', 'POST'])
def soporte():
    # Si es GET, mostramos el formulario vacío.
    # Si es POST, procesamos los datos.
    pass
🧠 Qué debes recordar

GET normalmente sirve para pedir o mostrar recursos.

POST normalmente se utiliza para enviar datos al servidor.

En nuestro formulario: GET = mostrar el formulario. POST = enviar el formulario.

 


🚦 ¿Qué sucederá cuando pulsen «Enviar»?

Antes de meternos en código, vamos a entender el viaje de los datos desde que el usuario pulsa «Enviar» hasta que Flask responde. Esto te ayudará a comprender cada pieza del rompecabezas.

Diagrama-que-sucede-cuando-enviamos-un-formulario-con-flask-wtforms

Este es el recorrido paso a paso:

  1. El navegador envía una petición GET para cargar el formulario.
  2. El usuario o visitante completa los datos y pulsa ENVIAR.
  3. El navegador envía una petición POST.
  4. Flask (nuestro controlador) recibe la petición y crea una instancia objeto form a partir de la clase SoporteForm.
  5. Flask-WTF comprueba el Token CSRF (seguridad de los datos).
  6. WTForms aplica los validadores (DataRequired, Email, Length).
  7. Si hay errores, se vuelve a mostrar el formulario con los mensajes de error.
  8. Si todo es válido, se procesan los datos (en este caso, los mostramos por consola).
  9. Se genera un mensaje flash de éxito.
  10. Se hace un redirect a la misma página (para evitar reenvíos).
  11. El navegador carga la página mediante GET y muestra el mensaje.

flask-wtforms

¿Qué es WTForms y por qué usarlo con Flask?

Puedes crear formularios con HTML puro y procesarlos manualmente (como hicimos en los primeros posts de la serie). Pero a medida que tu aplicación crece, necesitas:

  • Validación automática de datos (que el email sea válido, que un campo no esté vacío, etc.).
  • Protección CSRF (Cross-Site Request Forgery) para evitar ataques.
  • Reutilización de formularios en múltiples rutas.
  • Mantenibilidad (tener toda la definición del formulario en un solo lugar).

WTForms es una librería de Python que se encarga de la definición, validación y renderizado de formularios.

Flask-WTF es la extensión que conecta WTForms con Flask, añadiendo integración con sesiones, CSRF y protección de formularios.

Juntos, te ahorran escribir código repetitivo y te protegen de errores comunes de seguridad.

💡 ¿Qué es CSRF?
hacking pythonCSRF (Cross-Site Request Forgery) es un ataque donde un sitio malicioso engaña al navegador del usuario para que realice acciones no deseadas en otro sitio donde está autenticado.

Flask-WTF genera un token CSRF asociado a la sesión y lo firma mediante una clave secreta. Cuando llega una petición protegida, el servidor comprueba que el token sea válido y corresponda con el que espera para esa sesión.

Un Token es una cadena de datos o un fragmento de código único que sirve como credencial digital de acceso, unidad de procesamiento de texto o representación de un valor.

Siempre que usamos librerías debemos tener en cuenta las vulnerabilidades que nos evitan y la facilidad de no tener que programar un formulario desde cero; en este caso nos sostenemos en un estándar de la librería que, recordemos, «ningún sistema es 100% seguro«, pero los desarrolladores actualizan las librerías en base a datos de la comunidad y reporte de bugs, por lo que si mantenemos nuestra versión actualizada en nuestro entorno virtual, nos aseguramos de que estamos cubiertos ante los riesgos principales existentes y reportados.


🛠️ Instalación y configuración de WTForms

Recuerda que debes tener activado tu entorno virtual:

# Linux/Mac
source venv/bin/activate

# o en Windows:
venv\Scripts\activate

Ahora vamos a instalar Flask-WTF y email-validator (necesario para el validador Email()):

pip install Flask-WTF email-validator

instalar WTForms y forms validator

Esto instalará WTForms junto con sus dependencias y la librería necesaria para validar direcciones de correo.

💡 ¿Por qué instalamos email-validator?

El validador Email() de WTForms necesita la librería email-validator para comprobar que la dirección introducida tenga un formato de correo válido (con @ y dominio). Sin ella, el validador no funcionará y obtendrás un error.

Ahora, en tu archivo principal (normalmente controlador.py o app.py), necesitas configurar una clave secreta (SECRET_KEY). Esa clave se usa para firmar el Token CSRF y otros mecanismos de seguridad. (tranqui, te explico bien luego).

from flask import Flask

app = Flask(__name__)
app.config['SECRET_KEY'] = 'una-clave-muy-secreta-y-dificil-de-adivinar'
🔑 Idea clave

La SECRET_KEY no es la contraseña de tu usuario. Es una clave interna de la aplicación que Flask utiliza para funciones de seguridad como firmar la sesión y, en Flask-WTF, el Token CSRF.

En desarrollo, puedes usar una clave fija. En producción (es decir, tu aplicación subida a un servidor y funcionando), nunca la pongas en el código; usa variables de entorno (lo veremos en el próximo post).

Y nunca publiques tu clave secreta en GitHub ni en ningún lado; obviamente.

 


📝 Creando nuestro primer formulario con WTForms

 

Vamos a crear un formulario de soporte para nuestra aplicación del directorio telefónico. Tendrá tres campos:

  • Nombre (obligatorio)
  • Email (obligatorio y con formato de email válido)
  • Mensaje (obligatorio, con una longitud mínima y máxima) – no queremos que nos envíen 200 mil caracteres en un solo mensaje.

 

Necesitamos, y es conveniente, crear un archivo aparte para definir las «clases» de nuestro formulario y no hacerlo directamente en el controlador, porque nuestro controlador se encarga de las rutas, no lo olvides.

Es mejor siempre separar: el controlador son rutas y procesamiento; todo lo demás lo debemos importar desde otro archivo para mantener un orden y programar aplicaciones modulares y escalables. No quieres luego tener un controlador de 50 mil líneas de código con formularios y chuchería mezclada en un guiso.

🔄 ¿Qué pasa con nuestro modelo?

En este capítulo no modificamos models.py. Nuestro modelo Persona continúa exactamente igual que en los capítulos anteriores. Solo añadimos:

  • forms.py
  • la ruta /soporte
  • templates/soporte.html
  • la actualización a Bootstrap 5 en todas las plantillas

Paso 1: En la raíz del proyecto crea el archivo forms.py

creando forms.py en nuestro proyecto

En la raíz de tu proyecto (junto a controlador.py, models.py), crea un archivo llamado forms.py. Aquí definiremos todos nuestros formularios.

from flask_wtf import FlaskForm
from wtforms import StringField, TextAreaField, SubmitField
from wtforms.validators import DataRequired, Email, Length

class SoporteForm(FlaskForm):
    nombre = StringField('Nombre', validators=[DataRequired()])
    email = StringField('Email', validators=[DataRequired(), Email()])
    mensaje = TextAreaField('Mensaje', validators=[
        DataRequired(),
        Length(min=10, max=500, message='El mensaje debe tener entre 10 y 500 caracteres.')
    ])
    submit = SubmitField('Enviar')

Desglose:

  • FlaskForm es la clase base que nos da Flask-WTF (que a su vez hereda de la clase Form de WTForms).
  • StringField y TextAreaField son los tipos de campo. StringField para texto corto, TextAreaField para texto largo.
  • validators es una lista de reglas que se aplican al campo.
  • DataRequired() significa que el campo no puede estar vacío.
  • Email() valida que el texto tenga formato de email (con @ y dominio).
  • Length(min, max) limita la cantidad de caracteres.
  • SubmitField es el botón de envío.
🧠 Qué estamos haciendo realmente

No estamos creando el formulario directamente dentro de controlador.py.

Primero creamos un «molde» llamado SoporteForm.

En ese molde definimos:

✅ qué campos tendrá el formulario
✅ qué tipo de datos recibirá cada campo
✅ qué reglas debe cumplir cada dato
✅ qué botón tendrá el formulario

Después, desde controlador.py, creamos una instancia de ese molde:

form = SoporteForm()

A partir de esa instancia, Flask-WTF podrá mostrar el formulario, recibir los datos y validarlos. Revisa el siguiente paso.

🧩 Más campos de WTForms: personaliza tus formularios

WTForms dispone de una gama amplia de campos, y Flask-WTF añade además integración específica para archivos mediante FileField. Aquí tienes un resumen de los campos más comunes que vas a necesitar:

Campo Para qué sirve Ejemplo
StringField Texto corto Nombre, usuario
TextAreaField Texto largo Comentario, mensaje
PasswordField Contraseñas Contraseña
BooleanField Verdadero / Falso (checkbox) Aceptar términos
SubmitField Botón de envío Enviar
SelectField Seleccionar una opción País
SelectMultipleField Seleccionar varias opciones Intereses
RadioField Elegir una opción entre varias Sexo / modalidad
IntegerField Número entero Edad
FloatField Número decimal Precio
DateField Fecha Fecha de nacimiento
FileField Archivo Foto / documento

✅ ¿Cómo creo un checkbox con WTForms?

El BooleanField representa un valor verdadero/falso y normalmente se muestra como una casilla de verificación. Es útil para opciones como «Acepto los términos», «Quiero recibir novedades», etc.

Para hacerlo obligatorio, usa el validador DataRequired():

from wtforms import BooleanField
from wtforms.validators import DataRequired

acepto = BooleanField(
    'Acepto los términos y condiciones',
    validators=[DataRequired()]
)

💡 Ojo: DataRequired() para un BooleanField exige que el checkbox esté marcado. Esto funciona bien para casos como «Acepto términos», pero para otros usos (ej. «Quiero recibir novedades» que no es obligatorio) puedes omitir el validador.

✅ ¿Cómo creo un Select (desplegable) con WTForms?

El SelectField te permite mostrar un menú desplegable con opciones predefinidas:

from wtforms import SelectField

pais = SelectField(
    'País',
    choices=[
        ('ar', 'Argentina'),
        ('es', 'España'),
        ('mx', 'México')
    ]
)

Cada tupla en choices tiene el formato (valor_a_guardar, texto_a_mostrar).

📚 Aprende a consultar la documentación
meme tabla
Si no lees la documentación al menos una sola vez. ¡HAY TABLA!

No necesitas memorizar todos los campos de WTForms.

Cuando necesites crear un formulario diferente, consulta la documentación oficial y busca el tipo de campo que necesitas.

👉 Documentación oficial de WTForms

Aunque esté principalmente en inglés, no necesitas traducir cada palabra. Busca el nombre del campo que necesitas (StringField, BooleanField, etc.) y revisa su ejemplo de uso.

Aprender a consultar documentación es una habilidad tan importante como aprender a escribir código.

Paso 2: Importar el formulario en el controlador

Ahora sí, nos enfocamos en que nuestro controlador integre este nuevo «módulo» formulario. En tu controlador.py, importa el formulario y úsalo en la nueva ruta para Soporte. Antes de ver el código completo, fíjate en la ruta /soporte:

# controlador.py (fragmento de la ruta soporte)
@app.route('/soporte', methods=['GET', 'POST'])
def soporte():
    form = SoporteForm()   # Creamos una instancia del formulario

    if form.validate_on_submit():
        # Si todo es válido, obtenemos los datos
        nombre = form.nombre.data
        email = form.email.data
        mensaje = form.mensaje.data

        print(f"Mensaje de {nombre} ({email}): {mensaje}")

        flash('¡Mensaje enviado con éxito!', 'success')
        return redirect(url_for('soporte'))

    # Si es GET o hay errores, mostramos el formulario con los errores
    return render_template('soporte.html', form=form)

Esto es lo que ocurre:

  • SoporteForm() crea una instancia del formulario. Si es GET, se muestra vacío; si es POST, se rellena con los datos enviados.
  • validate_on_submit() comprueba si la petición es POST y si todos los validadores y el CSRF pasan. Si es True, procesamos los datos; si es False, saltamos al render_template que mostrará el formulario con los errores (gracias a form.errors).
  • flash() guarda un mensaje que se mostrará en la siguiente petición (gracias a get_flashed_messages() en base.html).
  • redirect(url_for('soporte')) redirige a la misma ruta mediante GET, evitando que el usuario reenvíe el formulario al recargar (patrón Post/Redirect/Get).

🧠 ¿Qué hace realmente validate_on_submit()?

Esta línea es el corazón del procesamiento del formulario. Básicamente pregunta:

¿El usuario ha enviado el formulario y todos los controles de seguridad y validación han pasado correctamente?

diagrama-flujo-validación-formulario-flask

Su comportamiento es:

  • Si la petición es GET, devuelve False (no valida).
  • Si la petición es POST y todos los campos pasan las validaciones, devuelve True.
  • Si los validadores del formulario no pasan, validate_on_submit() devuelve False y puedes consultar form.errors. Además, Flask-WTF comprueba la protección CSRF; si el token falta o no es válido, la petición puede ser rechazada.

Este condicional es clave, ya que define cómo va a procesar la «petición» del usuario y si no está bien construido podrías tener errores tanto al cargar la página donde está como al enviar datos. Y si no existiera además estaríamos permitiendo procesar solicitudes POST sin condiciones claras.

Nota: validate_on_submit() es un atajo que combina request.method == 'POST' y form.validate(). Es el método recomendado para el patrón habitual de Flask-WTF.

🧠 ¿Qué contiene form.errors?

form.errors reúne los errores encontrados durante la validación de los campos. Por ejemplo:

email
  → formato de correo no válido

mensaje
  → longitud incorrecta

El flujo es:

validators
   ↓
form.errors
   ↓
soporte.html
   ↓
is-invalid
   ↓
invalid-feedback

Esto conecta la validación del servidor con el renderizado visual en Bootstrap 5.


🎨 Actualizando tu proyecto a Bootstrap 5

 

Hasta ahora hemos estado usando Bootstrap 4 en nuestros ejemplos. Sin embargo, a partir de este post vamos a actualizar a Bootstrap 5. ¿Por qué?

  • ✅ Bootstrap 5 es la versión actual y mantenida.
  • ✅ Incluye mejoras de rendimiento, accesibilidad y nuevas utilidades.
  • ✅ Es la versión que usarás en proyectos modernos.
  • ✅ Te enseña a actualizar dependencias, una habilidad clave en el desarrollo real.

Esta migración no es el tema principal del capítulo. La hacemos ahora para que el proyecto quede en una única versión de Bootstrap antes de continuar con los siguientes capítulos.

Los cambios principales son:

Bootstrap 4 Bootstrap 5 ¿Qué cambia?
ml-auto ms-auto Margin-left → margin-start (para soporte RTL)
mr-auto me-auto Margin-right → margin-end
data-toggle data-bs-toggle Prefijo para atributos de datos
data-target data-bs-target Prefijo para atributos de datos
data-dismiss data-bs-dismiss Prefijo para atributos de datos
form-row row g-3 Sistema de grid para formularios
btn-block d-grid + w-100 o d-grid en contenedor Botones de ancho completo
thead-dark table-dark Cabecera de tabla oscura
jQuery + Popper.js Bundle único (bootstrap.bundle.min.js) Se eliminan dependencias externas

🔗 El puente con el capítulo anterior: de # a url_for(‘soporte’)

En el post anterior, cuando mejoramos el header.html, dejamos el enlace a «Soporte» con un # porque la página todavía no existía:

<li class="nav-item">
    <a class="nav-link" href="#">Soporte</a>
</li>

Ahora que hemos creado la ruta /soporte y la plantilla soporte.html, actualizamos ese enlace para que apunte a la nueva página:

<li class="nav-item">
    <a class="nav-link" href="{{ url_for('soporte') }}">Soporte</a>
</li>

Este pequeño cambio cierra un ciclo: prometimos una página de soporte en el capítulo anterior, y ahora la entregamos.

📐 Mejora del layout: envolver el navbar en un container

Además de adaptar las clases a Bootstrap 5, aprovecharemos para envolver el contenido de nuestro navbar en un container, de modo que mantenga la misma alineación horizontal que el resto de la aplicación. En el post anterior el navbar estaba así:

<nav class="navbar navbar-expand-lg navbar-light bg-light">
    <a class="navbar-brand" href="{{ url_for('inicio') }}">
        <img src="{{ url_for('static', filename='img/logo.png') }}" alt="Logo" height="40">
        Directorio Online
    </a>
    ...
</nav>

Ahora lo mejoramos añadiendo un div class="container" dentro del navbar:

<nav class="navbar navbar-expand-lg navbar-light bg-light">
    <div class="container">
        <a class="navbar-brand" href="{{ url_for('inicio') }}">
            <img src="{{ url_for('static', filename='img/logo.png') }}" alt="Logo" height="40" class="d-inline-block align-top">
            Directorio Online
        </a>
        <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
            <span class="navbar-toggler-icon"></span>
        </button>
        <div class="collapse navbar-collapse" id="navbarNav">
            <ul class="navbar-nav ms-auto">
                ...
            </ul>
        </div>
    </div>
</nav>

Esto garantiza que el contenido del navbar esté alineado con el resto de la página (que ya usa container en base.html), dando un aspecto más profesional y consistente.

📌 Importante: trabajaremos con Bootstrap 5

A partir de este punto de la serie utilizaremos Bootstrap 5.

Por eso verás atributos como data-bs-toggle, data-bs-dismiss y clases como ms-auto (en lugar de ml-auto de Bootstrap 4).

No mezcles sintaxis de Bootstrap 4 con Bootstrap 5. Debes modificar en todos los archivos (base.html, header.html, index.html, galeria.html, etc.) los scripts y clases donde incluiste Bootstrap 4, para reemplazarlos por Bootstrap 5.

Nuestro base.html queda así:

<!DOCTYPE html>
<html lang="es">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{% block title %}Directorio Telefónico{% endblock %}</title>
    <!-- CAMBIO: Bootstrap 4 → Bootstrap 5 (CDN actualizado) -->
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css">
    <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body>
    {% include 'header.html' %}

    <main class="container mt-4">
        {% with messages = get_flashed_messages(with_categories=true) %}
            {% if messages %}
                {% for category, message in messages %}
                    <div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
                        {{ message }}
                        <!-- CAMBIO: data-dismiss → data-bs-dismiss -->
                        <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
                    </div>
                {% endfor %}
            {% endif %}
        {% endwith %}

        {% block content %}{% endblock %}
    </main>

    {% include 'footer.html' %}

    <!-- CAMBIO: scripts de Bootstrap 5 (se elimina jQuery y Popper separado, se usa el bundle) -->
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

Resumen de cambios en base.html:

  • ✅ CDN de CSS actualizado a Bootstrap 5.3.8.
  • ✅ Atributo data-dismissdata-bs-dismiss.
  • ✅ Scripts: se elimina jQuery y Popper.js separados, se usa el bundle único bootstrap.bundle.min.js.
  • ✅ Ahora el container y las clases funcionan con Bootstrap 5.

También debes actualizar el resto de las plantillas que usan clases de Bootstrap 4. Aquí tienes la tabla de archivos afectados y al final del post encuentras el código completo para copiar y pegar si lo deseas:

Archivo Estado Motivo
base.html Modificar Bootstrap 5 + flash + bundle
header.html Modificar Navbar: data-bs-*, ms-auto, container
footer.html Revisar Clases Bootstrap 5
index.html Modificar thead-dark → table-dark, btn-block → d-grid
galeria.html Revisar Compatibilidad Bootstrap 5
editar_contacto.html Modificar form-row → row g-3, btn-block → d-grid, thead-dark → table-dark
agregar_contacto.html Modificar form-row → row g-3, btn-block → d-grid, thead-dark → table-dark
soporte.html Nuevo WTForms + Bootstrap 5

📂 Estructura final del proyecto (con forms.py y soporte.html)

Después de todas las modificaciones, tu proyecto debería tener esta estructura:

mi_proyecto_flask/
├── controlador.py
├── models.py
├── forms.py              ← NUEVO
├── DB/
│   └── base_de_datos.json
├── templates/
│   ├── base.html
│   ├── header.html
│   ├── footer.html
│   ├── index.html
│   ├── galeria.html
│   ├── editar_contacto.html
│   ├── agregar_contacto.html
│   └── soporte.html      ← NUEVO
└── static/
    ├── css/
    │   └── style.css
    ├── img/
    │   ├── logo.png
    │   ├── python-logo.png
    │   ├── flask-logo.png
    │   ├── bootstrap-logo.png
    │   └── jinja2-logo.png
    ├── video/
    │   └── presentacion.mp4
    └── js/

Al final del artículo encontrarás el código completo del proyecto y un enlace para descargar el ZIP con todos los archivos listos para ejecutar.

Crear la plantilla templates/soporte.html

Una vez actualizado Bootstrap, vamos a crear en /templates la plantilla soporte.html. Por ahora solo copia mi código, luego te explicaré bien, y podrás llevarlo a la práctica cada vez que necesites un formulario.

La plantilla soporte.html incluirá:

  • El token CSRF con {{ form.hidden_tag() }}.
  • Los campos con etiquetas y clases de Bootstrap.
  • Los errores de validación mostrados con la clase is-invalid y invalid-feedback.

⚠️ Importante: El bloque de mensajes flash ya está en base.html, por lo que no debes repetirlo aquí. Si lo incluyes, verás los mensajes duplicados. Además está en el base para que podamos incluir mensajes de error o éxito personalizados en toda la app; por ejemplo, luego podríamos modificar nuestro formulario anterior de agregar o editar contactos para mostrar éxito/fallo.

{% extends "base.html" %}

{% block title %}Soporte{% endblock %}

{% block content %}

<div class="row justify-content-center">
    <div class="col-md-8">
        <h1 class="text-center mb-4">🛠️ Soporte Técnico</h1>
        <p class="text-center text-muted">¿Tienes algún problema o duda? Completa el formulario y te responderemos lo antes posible.</p>

        <form method="POST" action="{{ url_for('soporte') }}" class="mt-4">
            {{ form.hidden_tag() }}

            <div class="mb-3">
                {{ form.nombre.label(class="form-label") }}
                {{ form.nombre(class="form-control" + (" is-invalid" if form.nombre.errors else "")) }}
                {% if form.nombre.errors %}
                    <div class="invalid-feedback">
                        {% for error in form.nombre.errors %}
                            {{ error }}
                        {% endfor %}
                    </div>
                {% endif %}
            </div>

            <div class="mb-3">
                {{ form.email.label(class="form-label") }}
                {{ form.email(class="form-control" + (" is-invalid" if form.email.errors else "")) }}
                {% if form.email.errors %}
                    <div class="invalid-feedback">
                        {% for error in form.email.errors %}
                            {{ error }}
                        {% endfor %}
                    </div>
                {% endif %}
            </div>

            <div class="mb-3">
                {{ form.mensaje.label(class="form-label") }}
                {{ form.mensaje(class="form-control" + (" is-invalid" if form.mensaje.errors else ""), rows=5) }}
                {% if form.mensaje.errors %}
                    <div class="invalid-feedback">
                        {% for error in form.mensaje.errors %}
                            {{ error }}
                        {% endfor %}
                    </div>
                {% endif %}
            </div>

            <div class="d-grid">
                {{ form.submit(class="btn btn-primary btn-lg") }}
            </div>
        </form>
    </div>
</div>

{% endblock %}

Detalles importantes:

  • {{ form.hidden_tag() }} genera los campos ocultos necesarios para el formulario, incluido el token CSRF. Si falta, el formulario no se validará.
  • Usamos form.nombre.errors para mostrar errores de validación justo debajo de cada campo.
  • La clase is-invalid se añade condicionalmente para que Bootstrap resalte los campos con errores.
  • Los mensajes flash ya se muestran desde base.html, no es necesario repetirlos aquí.

meme nerd simpson
Ya levanta el servidor flask neerd!

Se verá algo así. Prueba iniciar el servidor en tu terminal con:

python3 controlador.py

Bien, ahora a experimentar con esta wea:

 

 


🧪 Vamos a romper el formulario a propósito

La mejor manera de entender la validación es intentar enviar datos incorrectos. Vamos a probar diferentes casos.

Prueba 1 — Todo vacío

Captura de Ejemplo formulario errorEnvía el formulario sin escribir nada en ningún campo. Deberías ver errores como:

  • «Este campo es obligatorio» en Nombre.
  • «Este campo es obligatorio» en Email.
  • «Este campo es obligatorio» en Mensaje.

Prueba 2 — Email incorrecto

Captura ejemplo Formulario error 2 email inválido

Escribe en el campo Email algo como botspammer@ (sin dominio).

Resultado: el validador Email() detectará que el formato no es válido y mostrará un error.

Prueba 3 — Mensaje demasiado corto

Captura de pantalla soporte formulario con wtforms error 3 mensaje pocos carácteres.

Escribe solo Hola en el campo Mensaje.

Resultado: el validador Length(min=10) mostrará un error personalizado: «El mensaje debe tener entre 10 y 500 caracteres.»

Prueba 4 — Datos correctos

formulario-flask-con-wtforms
Soporte Email simula ser enviado

Completa todos los campos con datos válidos y envía.

Resultado: validación OK, procesamiento, mensaje flash de éxito y redirección.

Captura terminal funcionando código correcto para enviar formulario en flask con wtforms

Y si recibes un error ni modo, revisa la consola de tu VSCode y deberías dar fácilmente con el error. Como último recurso puedes pedir ayuda o buscar el error en Google, también, si quieres, puedes preguntarle a la IA. Eso sí, no te acostumbres a hacer todo con IA aún, no es el momento, pero puedes permitirte una ayudita en caso de que no lo logres sol@.

 


🔄 ¿Qué pasa si algo falla durante el procesamiento?

Ya tenemos el formulario en forms.py, la ruta en controlador.py y la plantilla en soporte.html. Ahora vamos a profundizar en el controlador, añadiendo manejo de excepciones con try/except para que, si algo falla (por ejemplo, el envío de un email), el usuario reciba un mensaje de advertencia.

La diferencia clave es:

validate_on_submit()
↓
¿Los datos son válidos?

try/except
↓
¿El procesamiento posterior falla?

Aunque en este post no vamos a enviar correos reales (eso lo veremos en el próximo), es importante adelantar la estructura de cómo manejaríamos los errores.

El siguiente fragmento muestra únicamente la parte dentro de if form.validate_on_submit(): donde añadimos try/except:

if form.validate_on_submit():
    nombre = form.nombre.data
    email = form.email.data
    mensaje = form.mensaje.data

    try:
        # Aquí iría el envío del email (lo veremos en el próximo post)
        # Por ahora simulamos que todo funciona
        print(f"Enviando email desde {email} con mensaje: {mensaje}")

        # Si todo sale bien, mostramos éxito
        flash('¡Tu mensaje ha sido enviado correctamente!', 'success')
        return redirect(url_for('soporte'))

    except Exception as e:
        # Si ocurre cualquier error, lo capturamos y mostramos un mensaje de advertencia
        print(f"Error al enviar el mensaje: {e}")
        flash('Hubo un problema al enviar tu mensaje. Por favor, inténtalo de nuevo más tarde.', 'warning')
        # No redirigimos, nos quedamos en la misma página para que el usuario pueda corregir

Explicación del try/except:

  • Envolvemos la lógica de envío (o cualquier operación que pueda fallar) dentro de un bloque try.
  • Si todo sale bien, mostramos un mensaje de éxito con flash('mensaje', 'success').
  • Si ocurre una excepción (por ejemplo, el servidor de correo no responde), la capturamos en except Exception as e y mostramos un mensaje de advertencia ('warning') con flash().
  • Al no hacer redirect, el usuario permanece en la misma página y puede intentarlo de nuevo.
⚠️ Importante

Usamos except Exception as e aquí para aprender el concepto y mantener el ejemplo sencillo. En aplicaciones reales, conviene capturar excepciones más específicas cuando sabemos qué errores pueden producirse (por ejemplo, SMTPAuthenticationError).

💡 Mensajes flash con Bootstrap

Las categorías de flash() (success, warning, danger, info) se convierten automáticamente en clases de Bootstrap (alert-success, alert-warning, etc.) gracias al código que pusimos en base.html. Así, los mensajes se ven profesionales y consistentes.

El flujo completo de flash() + redirect() es:

POST
 ↓
procesamiento
 ↓
flash()
 ↓
redirect()
 ↓
GET
 ↓
get_flashed_messages()
 ↓
base.html
 ↓

🧪 Problemas frecuentes con WTForms y Flask

Que no te asusten los errores; sirven para aprender y mejorar, pero solo si entendemos que provoca el error y eso lo hacemos mirando la terminal, ya que en nuestra aplicación tenemos marcado el debug en True.

Aquí tienes una lista de errores comunes que puedes encontrar al trabajar con WTForms y cómo solucionarlos:

Error Causa Solución
ModuleNotFoundError: No module named 'flask_wtf' No has instalado Flask-WTF. Ejecuta pip install Flask-WTF
ModuleNotFoundError: No module named 'email_validator' Falta la librería para el validador Email(). Ejecuta pip install email-validator
El formulario no valida y no muestra errores Falta el token CSRF ({{ form.hidden_tag() }}). Asegúrate de incluir {{ form.hidden_tag() }} dentro del formulario.
Error: The CSRF token is missing Falta SECRET_KEY o el token no se está enviando. Verifica que app.config['SECRET_KEY'] esté definido y que {{ form.hidden_tag() }} esté en el formulario.
validate_on_submit() devuelve False y no sé por qué. Puede ser que la petición no sea POST, que haya errores en form.errors (revisa CSRF y validadores), o que falte SECRET_KEY o dependencias. Inspecciona form.errors para ver los mensajes de error, verifica que el formulario incluya hidden_tag() y que la ruta acepte POST.
Los errores no se muestran en el formulario No estás usando is-invalid y invalid-feedback. Asegúrate de añadir is-invalid a los campos con errores y mostrar form.campo.errors.

❓ Preguntas frecuentes sobre formularios en Flask

❓ Preguntas frecuentes
¿Cómo crear un formulario en Flask con WTForms?

Define una clase que herede de FlaskForm en forms.py, declara los campos como atributos de clase y usa form = MiForm() en la ruta. Luego renderiza con render_template('vista.html', form=form).

¿Cómo validar un formulario en Flask?

Usa validate_on_submit() dentro de la ruta. Si devuelve True, los datos son válidos; si es False, los errores están en form.errors.

¿Qué es WTForms en Flask?

WTForms es una librería de Python para trabajar con formularios. Flask-WTF es la extensión que lo conecta con Flask, añadiendo protección CSRF y otras integraciones.

¿Qué diferencia hay entre WTForms y Flask-WTF?

WTForms es la librería base que proporciona campos, validadores y procesamiento. Flask-WTF es la extensión que integra WTForms con Flask, añadiendo soporte para CSRF, sesiones y otras utilidades propias de Flask.

¿Qué hace validate_on_submit()?

Verifica si la petición es POST y si todos los validadores y el CSRF pasan. Devuelve True si es válido, False en caso contrario. Si hay errores, los guarda en form.errors.

¿Qué es CSRF y cómo lo protege WTForms?

CSRF es un ataque donde un sitio malicioso engaña al usuario para realizar acciones no deseadas.

Token informáticaFlask-WTF genera un token CSRF asociado a la sesión y lo firma mediante una clave secreta. Cuando llega una petición protegida, el servidor comprueba que el token sea válido y corresponda con el que espera para esa sesión.

¿Para qué sirve form.hidden_tag()?

Genera los campos ocultos necesarios para el formulario, incluido el token CSRF. Es obligatorio para que Flask-WTF pueda validar el formulario.

¿Por qué necesito SECRET_KEY?

La SECRET_KEY se usa para firmar el token CSRF y otros mecanismos de seguridad. Es fundamental para proteger tu aplicación.

¿Por qué Email() me da error?

El validador Email() requiere la librería email-validator. Instálala con pip install email-validator.

¿Qué hace DataRequired()?

Comprueba en el servidor que el campo tenga un valor. Puede complementar la validación HTML del navegador, pero no debemos depender únicamente de la validación del cliente.

¿Cómo mostrar errores de WTForms con Bootstrap?

Usa la clase is-invalid en el campo y invalid-feedback para el mensaje de error. Bootstrap 5 tiene soporte nativo para validación del servidor.

¿Qué pasa si aparece ModuleNotFoundError: No module named 'flask_wtf'?

No has instalado Flask-WTF. Ejecuta pip install Flask-WTF.


🎯 Resumen y próximos pasos

Hoy has aprendido a crear formularios profesionales en Flask con WTForms. Has visto:

  • ✅ La diferencia entre GET y POST.MVC meme
  • ✅ Cómo instalar y configurar Flask-WTF y email-validator.
  • ✅ Cómo definir formularios con clases en forms.py.
  • ✅ Cómo usar validadores (DataRequired, Email, Length).
  • ✅ Cómo renderizar formularios con Bootstrap 5, mostrando errores y mensajes flash (¡sin duplicarlos!).
  • ✅ Cómo procesar los datos en el controlador con validate_on_submit().
  • ✅ Cómo manejar errores con try/except para dar feedback al visitante de tu formulario.
  • ✅ La importancia de validar en el servidor y proteger tus formularios con CSRF.
  • ✅ La migración de toda la aplicación a Bootstrap 5, mejorando la estructura con container en el navbar.

También has visto cómo romper el formulario a propósito para entender cómo funciona la validación en la práctica, y has conocido los problemas frecuentes y sus soluciones.

enviar formularios en flask usando gmail

En el próximo post, vamos a dar el salto definitivo: enviar correos reales desde el formulario usando Flask-Mail y una cuenta de Gmail (SMTP). Así, cuando un usuario complete el formulario de soporte, recibirás un email con su mensaje.

🗃️ Código Completo de la aplicación WTForms + Bootstrap

📦 Código completo de la aplicación Flask
📌 Descripción del proyecto

Este es el proyecto final del post:

«Formularios en Flask: WTForms, validación y estilos con Bootstrap«

Aplicación de directorio telefónico con Flask que implementa:

  • ✅ CRUD completo sobre JSON (Modelo Persona).
  • ✅ Arquitectura MVC (Modelo-Vista-Controlador).
  • ✅ Plantillas Jinja2 con herencia y bloques.
  • ✅ Formulario de soporte con WTForms, validación y CSRF.
  • Bootstrap 5 para estilos responsive.
  • ✅ Galería de imágenes y video estático.

Incluye todos los archivos necesarios para ejecutar la aplicación.

🟢 controlador.py
# ============================================================
# controlador.py - Aplicacion Flask con CRUD sobre JSON
# ============================================================
from flask import Flask, redirect, request, render_template, flash, url_for
from models import Persona
from forms import SoporteForm

app = Flask(__name__)
app.config['SECRET_KEY'] = 'una-clave-muy-secreta'

@app.route('/', methods=['GET'])
def inicio():
Todos = Persona.leer_contacto('id', 'all')
return render_template('index.html', Todos=Todos, index_ver=0)

@app.route('/ver/', methods=['GET'])
def ver(index):
Todos = Persona.leer_contacto('id', 'all')
return render_template('index.html', Todos=Todos, index_ver=index)

@app.route('/editar/', methods=['GET', 'POST'])
def editar(index):
Todos = Persona.leer_contacto('id', 'all')
if request.method == 'POST':
id = int(request.form['id'])
Nombre = request.form['Nombre']
Apellido = request.form['Apellido']
Apodo = request.form['Apodo']
Telefono = request.form['Telefono']
Direccion = request.form['Direccion']

Persona.actualizar_contacto(id, 'nombre', Nombre)
Persona.actualizar_contacto(id, 'apellido', Apellido)
Persona.actualizar_contacto(id, 'apodo', Apodo)
Persona.actualizar_contacto(id, 'telefono', Telefono)
Persona.actualizar_contacto(id, 'direccion', Direccion)

return render_template('editar_contacto.html', Todos=Todos, index_ver=index)

@app.route('/agregar', methods=['GET', 'POST'])
def agregar():
Todos = Persona.leer_contacto('id', 'all')
if request.method == 'POST':
Nombre = request.form['Nombre']
Apellido = request.form['Apellido']
Apodo = request.form['Apodo']
Telefono = request.form['Telefono']
Direccion = request.form['Direccion']

nuevo_contacto = Persona(Nombre, Apellido, Apodo, Telefono, Direccion)
nuevo_contacto.crear_contacto()

return redirect(url_for('agregar'))

return render_template('agregar_contacto.html', Todos=Todos)

@app.route('/eliminar/', methods=['GET'])
def eliminar(id):
Persona.eliminar_contacto(id)
return redirect('/')

@app.route('/galeria')
def galeria():
imagenes = [
{'titulo': 'Python', 'archivo': 'python-logo.png', 'descripcion': 'El lenguaje mas versatil'},
{'titulo': 'Flask', 'archivo': 'flask-logo.png', 'descripcion': 'Microframework minimalista'},
{'titulo': 'Bootstrap', 'archivo': 'bootstrap-logo.png', 'descripcion': 'Framework CSS profesional'},
{'titulo': 'Jinja2', 'archivo': 'jinja2-logo.png', 'descripcion': 'Motor de plantillas para Flask'},
]
return render_template('galeria.html', imagenes=imagenes)

@app.route('/soporte', methods=['GET', 'POST'])
def soporte():
form = SoporteForm()
if form.validate_on_submit():
nombre = form.nombre.data
email = form.email.data
mensaje = form.mensaje.data

try:
print(f"Mensaje de {nombre} ({email}): {mensaje}")
flash('Tu mensaje ha sido enviado correctamente!', 'success')
return redirect(url_for('soporte'))
except Exception as e:
print(f"Error: {e}")
flash('Hubo un problema al enviar tu mensaje. Intentalo de nuevo.', 'warning')

return render_template('soporte.html', form=form)

@app.route('/agradecimiento', methods=['GET'])
def agradecer():
return 'Gracias pythones.net!'

if __name__ == '__main__':
app.run('127.0.0.1', 5000, debug=True)
#fin

🔵 models.py
#BLOQUE IMPORT
import os
import json

THIS_FOLDER = os.path.dirname(os.path.abspath(__file__))
my_file = os.path.join(THIS_FOLDER + '/DB/' 'base_de_datos.json')

def leer_json():
with open(my_file, "r") as f:
datos = json.load(f)
return datos
datos = leer_json()

def modificar_json():
with open(my_file, "w") as modid:
json.dump(datos, modid, indent=4)

class Persona(object):
contador_id = datos["Configuraciones"][0]["contador_id_db"]

def __init__(self, nombre, apellido, apodo, telefono, direccion):
self.id = Persona.contador_id
self.nombre = nombre
self.apellido = apellido
self.apodo = apodo
self.telefono = telefono
self.direccion = direccion

def crear_contacto(self):
datos["Configuraciones"][0]["contador_id_db"] += 1
modificar_json()
Persona.contador_id += 1
nueva_persona = Persona(
self.nombre, self.apellido, self.apodo,
self.telefono, self.direccion).__dict__
datos['Personas'].append(nueva_persona)
modificar_json()

def leer_contacto(atr, valor):
if valor == 'all':
todas = {}
for i, persona in enumerate(datos["Personas"]):
todas[i] = persona
return todas
else:
encontradas = {}
for persona in datos["Personas"]:
if persona[atr] == valor:
indice = datos["Personas"].index(persona)
encontradas[indice] = persona
return encontradas

def actualizar_contacto(id, atr, nuevo_valor):
for persona in datos["Personas"]:
if persona["id"] == id:
indice = datos["Personas"].index(persona)
datos["Personas"][indice][atr] = nuevo_valor
modificar_json()

def eliminar_contacto(id):
for persona in datos["Personas"]:
if persona["id"] == id:
indice = datos["Personas"].index(persona)
datos["Personas"].pop(indice)
modificar_json()

🔴 templates/base.html

{% include 'header.html' %}

<main class="container mt-4">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}</main>
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">{{ message }}
<button class="btn-close" type="button" data-bs-dismiss="alert" aria-label="Close"></button></div>
{% endfor %}
{% endif %}
{% endwith %}

{% block content %}{% endblock %}

{% include 'footer.html' %}

<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js"></script>

🔴 templates/header.html

<nav class="navbar navbar-expand-lg navbar-light bg-light">
<div class="container"><a class="navbar-brand" href="{{ url_for('inicio') }}">
<img class="d-inline-block align-top" src="{{ url_for('static', filename='img/logo.png') }}" alt="Logo" height="40" />
Directorio Online
</a>
 
<div id="navbarNav" class="collapse navbar-collapse">
<ul class="navbar-nav ms-auto">
 	<li class="nav-item {% if request.endpoint == 'inicio' %}active{% endif %}"><a class="nav-link" href="{{ url_for('inicio') }}">Inicio</a></li>
 	<li class="nav-item {% if request.endpoint == 'agregar' %}active{% endif %}"><a class="nav-link" href="{{ url_for('agregar') }}">Agregar</a></li>
 	<li class="nav-item {% if request.endpoint == 'galeria' %}active{% endif %}"><a class="nav-link" href="{{ url_for('galeria') }}">Galeria</a></li>
 	<li class="nav-item {% if request.endpoint == 'soporte' %}active{% endif %}"><a class="nav-link" href="{{ url_for('soporte') }}">Soporte</a></li>
</ul>
</div>
</div>
</nav>
🔴 templates/footer.html

<footer class="text-center py-3 mt-4 border-top">
<p class="mb-0">© 2026 - Directorio Telefonico con Flask</p>

</footer>
🔴 templates/index.html
{% extends "base.html" %}

{% block title %}Inicio - Directorio Telefonico{% endblock %}

{% block content %}
<h1 style="text-align: center;">Directorio Telefonico - Mi primera aplicacion en Flask</h1>
<div class="row mt-4">
<div class="col-md-6">
<h2>Datos completos de la persona</h2>
{% for (index, persona) in Todos.items() %}
{% if index == index_ver %}
<table class="table table-bordered table-striped">
<thead class="table-dark">
<tr>
<th>Nombre</th>
<th>Apellido</th>
<th>Apodo</th>
<th>Telefono</th>
<th>Direccion</th>
</tr>
</thead>
<tbody>
<tr>
<td>{{ persona.nombre }}</td>
<td>{{ persona.apellido }}</td>
<td>{{ persona.apodo }}</td>
<td>{{ persona.telefono }}</td>
<td>{{ persona.direccion }}</td>
</tr>
</tbody>
</table>
{% endif %}
{% endfor %}

</div>
<div class="col-md-6">
<h2>Personas</h2>
{% for (index, persona) in Todos.items() %}{% endfor %}
<table class="table table-hover">
<thead>
<tr>
<th>Nombre</th>
<th>Acciones</th>
</tr>
</thead>
<tbody>
<tr>
<td>{{ persona.nombre }}, {{ persona.apellido }}</td>
<td><a class="btn btn-sm btn-info" href="{{ url_for('ver', index=index) }}">Ver</a>
<a class="btn btn-sm btn-warning" href="{{ url_for('editar', index=index) }}">Editar</a>
<a class="btn btn-sm btn-danger" href="{{ url_for('eliminar', id=persona.id) }}">Eliminar</a></td>
</tr>
<tr>
<td colspan="2"><a class="btn btn-success d-grid" href="{{ url_for('agregar') }}">Agregar contacto</a></td>
</tr>
</tbody>
</table>
</div>
</div>
{% endblock %}

🔴 templates/editar_contacto.html
{% extends "base.html" %}

{% block title %}Editar Contacto{% endblock %}

{% block content %}
<div class="row mt-4">
<div class="col-md-6">
<h2>Datos completos de la persona</h2>
{% for (index, persona) in Todos.items() %}
{% if index == index_ver %}
<table class="table table-bordered table-striped">
<thead class="table-dark">
<tr>
<th>Nombre</th>
<th>Apellido</th>
<th>Apodo</th>
<th>Telefono</th>
<th>Direccion</th>
</tr>
</thead>
<tbody>
<tr>
<td>{{ persona.nombre }}</td>
<td>{{ persona.apellido }}</td>
<td>{{ persona.apodo }}</td>
<td>{{ persona.telefono }}</td>
<td>{{ persona.direccion }}</td>
</tr>
</tbody>
</table>
{% endif %}
{% endfor %}

</div>
<div class="col-md-6">
<h2>Personas</h2>
{% for (index, persona) in Todos.items() %}{% endfor %}
<table class="table table-hover">
<thead>
<tr>
<th>Nombre</th>
<th>Acciones</th>
</tr>
</thead>
<tbody>
<tr>
<td>{{ persona.nombre }}, {{ persona.apellido }}</td>
<td><a class="btn btn-sm btn-info" href="{{ url_for('ver', index=index) }}">Ver</a>
<a class="btn btn-sm btn-warning" href="{{ url_for('editar', index=index) }}">Editar</a>
<a class="btn btn-sm btn-danger" href="{{ url_for('eliminar', id=persona.id) }}">Eliminar</a></td>
</tr>
<tr>
<td colspan="2"><a class="btn btn-success d-grid" href="{{ url_for('agregar') }}">Agregar contacto</a></td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- SECCION DE EDICION (formulario) -->
<div class="row mt-4">
<div class="col-12">
<h2>Modificar Persona</h2>
<h4>Base de datos JSON (Index = {{ index_ver }} ID = {{ Todos[index_ver].id }})</h4>
<form action="{{ url_for('editar', index=index_ver) }}" method="post">
<div class="row g-3">

<input name="id" type="hidden" value="{{ Todos[index_ver].id }}" />
<div class="col-md-2"><input class="form-control" name="Nombre" type="text" value="{{ Todos[index_ver].nombre }}" /></div>
<div class="col-md-2"><input class="form-control" name="Apellido" type="text" value="{{ Todos[index_ver].apellido }}" /></div>
<div class="col-md-2"><input class="form-control" name="Apodo" type="text" value="{{ Todos[index_ver].apodo }}" /></div>
<div class="col-md-2"><input class="form-control" name="Telefono" type="tel" value="{{ Todos[index_ver].telefono }}" /></div>
<div class="col-md-2"><input class="form-control" name="Direccion" type="text" value="{{ Todos[index_ver].direccion }}" /></div>
<div class="col-md-2"><button class="btn btn-primary d-grid" type="submit">Modificar</button></div>
</div>
</form></div>
</div>
{% endblock %}

🔴 templates/agregar_contacto.html
{% extends "base.html" %}

{% block title %}Agregar Contacto{% endblock %}

{% block content %}
<div class="row mt-4">
<div class="col-md-6">
<h2>Agregar Persona</h2>
<form action="{{ url_for('agregar') }}" method="post">
<div class="row g-3">
<div class="col-md-6"><input class="form-control" name="Nombre" type="text" placeholder="Nombre" /></div>
<div class="col-md-6"><input class="form-control" name="Apellido" type="text" placeholder="Apellido" /></div>
<div class="col-md-6"><input class="form-control" name="Apodo" type="text" placeholder="Apodo" /></div>
<div class="col-md-6"><input class="form-control" name="Telefono" type="tel" placeholder="Telefono" /></div>
<div class="col-12"><input class="form-control" name="Direccion" type="text" placeholder="Direccion" /></div>
<div class="col-12"><button class="btn btn-success d-grid" type="submit">Agregar contacto</button></div>
</div>
</form></div>
<div class="col-md-6">
<h2>Personas</h2>
{% for (index, persona) in Todos.items() %}{% endfor %}
<table class="table table-hover">
<thead>
<tr>
<th>Nombre</th>
<th>Acciones</th>
</tr>
</thead>
<tbody>
<tr>
<td>{{ persona.nombre }}, {{ persona.apellido }}</td>
<td><a class="btn btn-sm btn-info" href="{{ url_for('ver', index=index) }}">Ver</a>
<a class="btn btn-sm btn-warning" href="{{ url_for('editar', index=index) }}">Editar</a>
<a class="btn btn-sm btn-danger" href="{{ url_for('eliminar', id=persona.id) }}">Eliminar</a></td>
</tr>
<tr>
<td colspan="2"><a class="btn btn-success d-grid" href="{{ url_for('agregar') }}">Agregar contacto</a></td>
</tr>
</tbody>
</table>
</div>
</div>
{% endblock %}

🔴 templates/galeria.html
{% extends "base.html" %}

{% block title %}Galeria de Imagenes{% endblock %}

{% block content %}
<h1 class="text-center my-4">Galeria de Tecnologias</h1>
<div class="row">

{% for imagen in imagenes %}
<div class="col-md-3 col-sm-6 mb-4">
<div class="card h-100 shadow-sm">

<img class="card-img-top" style="height: 200px; object-fit: contain; background: #f8f9fa; padding: 15px;" src="{{ url_for('static', filename='img/' + imagen.archivo) }}" alt="{{ imagen.titulo }}" />
<div class="card-body">
<h5 class="card-title">{{ imagen.titulo }}</h5>
<p class="card-text">{{ imagen.descripcion }}</p>

</div>
</div>
</div>
{% endfor %}

</div>
<div class="text-center mt-4"><a class="btn btn-primary" href="{{ url_for('inicio') }}">Volver al directorio</a></div>
<!-- Video de ejemplo (opcional) -->
<div style="text-align: center; margin: 30px 0;"><video style="max-width: 600px; border-radius: 8px; display: block; margin: 0 auto; background: #000;" autoplay="autoplay" loop="loop" muted="" width="100%" height="150">
<source src="{{ url_for('static', filename='video/presentacion.mp4') }}" type="video/mp4" />
Tu navegador no soporta la reproduccion de videos.
</video></div>
{% endblock %}

🔴 templates/soporte.html
{% extends "base.html" %}

{% block title %}Soporte{% endblock %}

{% block content %}
<div class="row justify-content-center">
<div class="col-md-8">
<h1 class="text-center mb-4">Soporte Tecnico</h1>
<p class="text-center text-muted">Tienes algun problema o duda? Completa el formulario y te responderemos lo antes posible.</p>

<form class="mt-4" action="{{ url_for('soporte') }}" method="POST">{{ form.hidden_tag() }}
<div class="mb-3">{{ form.nombre.label(class="form-label") }}
{{ form.nombre(class="form-control" + (" is-invalid" if form.nombre.errors else "")) }}
{% if form.nombre.errors %}
<div class="invalid-feedback">{% for error in form.nombre.errors %}{{ error }}{% endfor %}</div>
{% endif %}

</div>
<div class="mb-3">{{ form.email.label(class="form-label") }}
{{ form.email(class="form-control" + (" is-invalid" if form.email.errors else "")) }}
{% if form.email.errors %}
<div class="invalid-feedback">{% for error in form.email.errors %}{{ error }}{% endfor %}</div>
{% endif %}

</div>
<div class="mb-3">{{ form.mensaje.label(class="form-label") }}
{{ form.mensaje(class="form-control" + (" is-invalid" if form.mensaje.errors else ""), rows=5) }}
{% if form.mensaje.errors %}
<div class="invalid-feedback">{% for error in form.mensaje.errors %}{{ error }}{% endfor %}</div>
{% endif %}

</div>
<div class="d-grid">{{ form.submit(class="btn btn-primary btn-lg") }}</div>
</form></div>
</div>
{% endblock %}

🔴 static/css/style.css
/* ============================================================
ESTILOS PERSONALIZADOS - Pythones Flask App
============================================================ */

.navbar {
background-color: #f8f9fa;
border-bottom: 2px solid #e0e0e0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}

.navbar .navbar-brand {
color: #2c3e50;
font-weight: 600;
font-size: 1.3rem;
letter-spacing: -0.5px;
}

.navbar .navbar-brand:hover {
color: #27ae60;
}

.navbar .navbar-nav .nav-link {
color: #555;
font-weight: 500;
transition: color 0.2s ease;
padding: 0.5rem 1rem;
}

.navbar .navbar-nav .nav-link:hover {
color: #27ae60;
}

.navbar .navbar-nav .nav-item.active .nav-link {
color: #27ae60;
font-weight: 600;
border-bottom: 2px solid #27ae60;
padding-bottom: 0.3rem;
}

footer {
background-color: #f8f9fa;
color: #7f8c8d;
border-top: 1px solid #e0e0e0;
padding: 20px 0;
margin-top: 40px;
}

footer p {
margin: 0;
font-size: 0.9rem;
}

body {
background-color: #fcfcfc;
min-height: 100vh;
display: flex;
flex-direction: column;
}

main {
flex: 1;
}

.table {
background-color: #ffffff;
border-radius: 6px;
overflow: hidden;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
}

.table thead th {
background-color: #f1f2f6;
border-bottom: 2px solid #dcdde1;
font-weight: 600;
color: #2c3e50;
}

.btn {
border-radius: 20px;
padding: 6px 18px;
font-weight: 500;
font-size: 0.85rem;
}

.btn-success {
background-color: #27ae60;
border-color: #27ae60;
}

.btn-success:hover {
background-color: #219a52;
border-color: #219a52;
}

.btn-warning {
background-color: #f39c12;
border-color: #f39c12;
color: #fff;
}

.btn-warning:hover {
background-color: #d68910;
border-color: #d68910;
}

.btn-danger {
background-color: #e74c3c;
border-color: #e74c3c;
}

.btn-danger:hover {
background-color: #c0392b;
border-color: #c0392b;
}

.btn-primary {
background-color: #3498db;
border-color: #3498db;
}

.btn-primary:hover {
background-color: #2980b9;
border-color: #2980b9;
}

h1, h2, h3, h4 {
color: #2c3e50;
}

h1 {
font-weight: 700;
letter-spacing: -1px;
}

h2 {
font-weight: 600;
border-bottom: 2px solid #ecf0f1;
padding-bottom: 8px;
margin-bottom: 20px;
}

.card {
transition: transform 0.2s ease, box-shadow 0.2s ease;
}

.card:hover {
transform: scale(1.02);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
}

.alert {
border-radius: 8px;
border: none;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}

📁 DB/base_de_datos.json
{
"Personas": [
{
"id": 0,
"nombre": "Mariano",
"apellido": "Laca",
"apodo": "Marianito",
"telefono": "11-2345-6789",
"direccion": "Calle Falsa 123, Buenos Aires"
},
{
"id": 7,
"nombre": "test",
"apellido": "tester",
"apodo": "el tester",
"telefono": "34351111111",
"direccion": "Algun lugar de la matrix"
}
],
"Configuraciones": [
{
"contador_id_db": 7
}
]
}

📥 Descargar proyecto completo (ZIP)

Descarga el proyecto completo con todos los archivos listos para ejecutar:

Instrucciones de instalación:

  1. Descomprime el archivo ZIP en una carpeta.
  2. Crea un entorno virtual: python -m venv venv
  3. Activa el entorno virtual:
    • Windows: venv\Scripts\activate
    • Linux/Mac: source venv/bin/activate
  4. Instala las dependencias: pip install flask flask-wtf email-validator
  5. Ejecuta la aplicación: python controlador.py
  6. Abre tu navegador en http://127.0.0.1:5000

✅ El proyecto incluye:

  • Todos los archivos Python (controlador.py, models.py, forms.py).
  • Todas las plantillas HTML actualizadas a Bootstrap 5.
  • Archivos estáticos (CSS, imágenes, video).
  • Base de datos JSON de ejemplo con contactos.
  • Ruta de soporte con formulario WTForms completamente funcional.

🐍 ¿Te ha servido este post? Déjame un comentario o compártelo con quien esté aprendiendo Flask.

📅 Última actualización: agosto 19, 2026

¡Compartir es una forma de agradecer! :)

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio