🧩 Bloques Jinja2: Herencia, includes y plantillas en Flask

Jinja2 - Logotipo

 

 

🧩 Bloques Jinja2: Herencia y plantillas en Flask (Guía completa)

Si ya sabes renderizar templates y pasar variables con Flask, ahora es momento de dar el siguiente paso: organizar tus vistas como un profesional usando bloques Jinja2.

En este post vamos a profundizar en los bloques Jinja2, la herencia de plantillas y cómo estructurar tus proyectos para que sean modulares, reutilizables y fáciles de mantener. Veremos desde la sintaxis básica hasta ejemplos prácticos con header.html, footer.html y base.html. Dominar los bloques Jinja2 es clave para construir aplicaciones web profesionales con Flask.

🎯 Lo que aprenderás en este post

Al finalizar este tutorial, sabrás:

  • ✅ Qué son los bloques Jinja2 y cómo usarlos.
  • ✅ La diferencia entre {% extends %} y {% include %}.
  • ✅ Cómo crear una plantilla base (base.html) con herencia.
  • ✅ Cómo usar url_for para generar URLs dinámicas.
  • ✅ Cómo meter código Python y condicionales dentro de tus templates.
  • ✅ Cómo integrar Bootstrap y CSS personalizado en Flask.

📌 Requisito previo: Haber completado la Parte 4 – Jinja2: La Vista de la serie Flask, donde vimos los fundamentos de las plantillas Jinja2.

 

 

📦 Variables en Jinja2

Antes de meternos de lleno en la herencia de plantillas Jinja2, recordemos lo básico: Jinja2 es el motor de plantillas que usa Flask para renderizar HTML dinámico. Nos permite ejecutar código Python dentro de nuestras vistas usando una sintaxis muy sencilla.

Para mostrar una variable que pasaste desde el controlador con render_template(), usamos doble llave:

{{ nombre }}

Y en el controlador la pasas así:

return render_template('index.html', nombre="Mariano")

También puedes crear variables directamente en el template con {% set %}:

{% set edad = 30 %}
{% set nombre, apellido = "Mariano", "Laca" %}

⚠️ Importante: Estas variables se crean al renderizar la página y no son persistentes. Jinja2 es un puente de conexión entre el backend y el frontend, no un lugar para ejecutar lógica pesada. El código complejo, funciones y procesamiento de datos deben ir en el controlador.

 

 

🔁 Condicionales en Jinja2

Puedes usar condicionales para mostrar u ocultar partes del HTML según ciertas condiciones. Esto es muy útil para personalizar la experiencia del usuario, y es una de las características más usadas en los bloques Jinja2.

{% if usuario_logueado %}
    <p>Bienvenido, {{ nombre }}!</p>
{% else %}
    <p>Por favor, inicia sesión.</p>
{% endif %}

💡 Recuerda: Los condicionales en Jinja2 son para decisiones de presentación, no para lógica de negocio. La validación de datos y reglas complejas deben ir en el controlador.

 

 

🔄 Bucles (FOR) en Jinja2

Jinja2 solo permite el bucle for, muy útil para listar elementos como usuarios, productos, comentarios, etc. Combinado con los bloques Jinja2, permite generar listados dinámicos de forma muy eficiente.

{% for item in lista_de_items %}
    <li>{{ item }}</li>
{% endfor %}

💡 Recuerda: Los bucles son para generar HTML a partir de datos. Si necesitas filtrar u ordenar, hazlo en el controlador.

 

 

🔧 Filtros en Jinja2 (modificadores de variables)

Jinja2 incluye una serie de filtros que te permiten modificar el valor de una variable antes de mostrarla. Se aplican usando el símbolo | (pipe) y son muy útiles para dar formato a los datos sin tener que modificar el controlador. Los filtros son una herramienta complementaria a los bloques Jinja2 que enriquecen la presentación de los datos.

Aquí tienes algunos de los filtros más comunes:

{{ nombre|upper }}          <!-- Convierte a mayúsculas -->
{{ nombre|lower }}          <!-- Convierte a minúsculas -->
{{ nombre|capitalize }}     <!-- Primera letra mayúscula -->
{{ lista|length }}          <!-- Devuelve el número de elementos -->
{{ texto|default("Sin valor") }} <!-- Valor por defecto si la variable está vacía -->
{{ lista|join(", ") }}      <!-- Une una lista con un separador -->
{{ numero|round(2) }}       <!-- Redondea un número a 2 decimales -->

💡 Ejemplo práctico: Si tienes una lista de nombres y quieres mostrarlos separados por comas, puedes hacer {{ nombres|join(", ") }} y Jinja2 se encarga de todo.

📌 Consejo: Los filtros son para presentación, no para lógica de negocio. Si necesitas transformar datos complejos, hazlo en el controlador.

 

 

 

🧩 ¿Qué son los bloques Jinja2?

Jinja2 - Qué son los bloques en jinja2 Si alguna vez has navegado por internet, te habrás dado cuenta de que casi todos los sitios web tienen partes que se repiten en todas sus páginas. ¿No te suena?

Piensa en cualquier página web que visites a diario: YouTube, Wikipedia, tu blog favorito, incluso la web de tu banco. Todas tienen un encabezado (header) y un pie de página (footer) que son exactamente iguales en cada sección. El logo, el menú de navegación, el buscador, el aviso de cookies, el copyright… todo eso está ahí siempre, sin importar si estás viendo el inicio, un artículo o la página de contacto.

💡 ¿Y qué es exactamente un header y un footer?

  • 🔝 El header (cabecera) es la parte superior de la página. Normalmente contiene el logo, el menú de navegación principal, el buscador, y a veces el acceso a redes sociales o al perfil de usuario. Es como la entrada de una tienda: te dice dónde estás y te ofrece los caminos para moverte por el sitio.
  • 👇 El footer (pie de página) es la parte inferior. Suele incluir enlaces legales (aviso legal, política de privacidad, cookies), el copyright, y a veces enlaces a redes sociales o un mapa del sitio. Es como el cartel de salida de una tienda: te despide y te da información adicional sobre la empresa.

Ahora, la pregunta del millón: ¿qué harías si tuvieras 10, 50 o 100 páginas en tu sitio web? ¿Copiarías y pegarías el header y el footer en cada una de ellas?

🎯 Si tu respuesta fue «sí», estás a punto de aprender por qué eso es una pésima idea. Imagina que tienes 50 páginas y el cliente te pide cambiar el logo o añadir un nuevo enlace al menú. Tendrías que editar 50 archivos HTML uno por uno. Y si te equivocas en uno, tienes un error. Y si el cliente te pide otro cambio, vuelta a empezar. Eso no es programar, eso es sufrir.

Curso de python 3: Python Total en 16 díasAquí es donde entran los bloques Jinja2 para salvarte la vida.

Los bloques Jinja2 son la herramienta más poderosa del motor de plantillas. Nos permiten definir «huecos» en una plantilla base que las páginas hijas pueden rellenar con su propio contenido. Esta técnica se conoce como herencia de plantillas Jinja2.

El header y el footer los escribes UNA SOLA VEZ en sus propios archivos, y luego los incluyes donde los necesites. ¿Que hay que cambiar el menú? Un solo archivo, un solo cambio, y todas las páginas se actualizan automáticamente. Así, sin estrés, sin errores, sin perder horas.

Imagina que estás construyendo una casa. Los bloques Jinja2 son como los espacios vacíos que dejas en las paredes para poner puertas y ventanas. No sabes exactamente qué puerta o ventana irá en cada sitio, pero sabes que ahí va algo. Y lo mejor: puedes cambiar la puerta sin tener que tirar toda la pared.

En Flask, defines esos «espacios vacíos» en tu plantilla base (base.html) usando {% block %}.

Luego, en cada página hija (como index.html, blog.html, etc.), rellenas esos huecos con el contenido específico de cada página. Observa un diagrama sencillo:


diagrama-jinja2-plantillas

En el diagrama de arriba, la plantilla base (base.html) es el esqueleto visual del sitio web (VISTA). Define la estructura general, pero no contiene el header ni el footer directamente. En lugar de eso, utiliza bloques de inclusión ({% include 'header.html' %} y {% include 'footer.html' %}) para insertar esos elementos comunes. Luego, en el medio, deja un «hueco» que llamamos {% block content %}.

💡 ¿Ya vas viendo la magia? El header y el footer están en archivos separados, y todas las páginas los comparten sin necesidad de copiar y pegar nada. Cuando quieras cambiar el menú, solo tocas header.html y ¡zas! se actualiza en todo el sitio al instante.

📌 Nota avanzada sobre bloques Jinja2: Si necesitas extender un bloque sin sobrescribirlo completamente, puedes usar {{ super() }} dentro de un bloque en una plantilla hija. Esto te permite añadir contenido al bloque manteniendo lo que ya tenía la plantilla base. Es muy útil para añadir elementos a un head o a un footer sin perder lo que ya estaba definido. Esta es una de las técnicas más poderosas dentro de la herencia de plantillas Jinja2.

te has preguntado?

¿Listo para poner a prueba estos conocimientos en la práctica? De esta forma aprenderás hoy a crear tus propias plantillas Jinja2 para comenzar a desarrollar aplicaciones mantenibles y escalables de alto nivel con Python Flask. Arremángate como buen desarrollador y comencemos a trabajar:

 

 

 

 

🧪 Ejemplo práctico listo: Herencia con Header y Footer en Flask

 

Ejemplo herencia header y footer aplicación plantilla con bloques jinja2

Antes de ver el código en detalle, te sugiero que montes este proyecto en tu PC para que puedas ver los resultados en tiempo real. Sigue estos pasos:

  1. Crea una carpeta llamada mi_proyecto_jinja2.
  2. Abre la terminal en esa carpeta y crea un entorno virtual:
    python -m venv venv
    #o
    python3 -m venv venv
  3. Activa el entorno virtual:
    • En Windows: venv\Scripts\activate
    • En Linux/Mac: source venv/bin/activate
  4. Instala Flask en el entorno:
    pip install flask
  5. Crea los archivos que te voy a mostrar en los ejemplos. Copia y pega el código en tu editor de texto (VS Code recomendado). La estructura de carpetas debe ser:
mi_proyecto_jinja2/
├── controlador.py
└── templates/
    ├── base.html
    ├── header.html
    ├── footer.html
    ├── index.html
    └── blog.html

💡 Consejo: Trabajar con un entorno virtual es una buena práctica profesional porque aísla las dependencias de tu proyecto y evita conflictos con otros proyectos.

 

👉 Código de plantilla ejemplo bloques Jinja2

🧪 Ejemplo de herencia con header y footer
📄 base.html – La plantilla base

Este es el esqueleto de todo el sitio. Aquí se incluyen el header y el footer, y se define el bloque content que las páginas hijas van a rellenar.

<!DOCTYPE html>
<html>
<head>
    <title>{% block title %}Mi Sitio{% endblock %}</title>
</head>
<body>
    {% include 'header.html' %}  <!-- 🔴 Header rojo -->

    <main>
        {% block content %}{% endblock %}  <!-- 🟢 ¡Aquí cambia el contenido! -->
    </main>

    {% include 'footer.html' %}  <!-- 🔵 Footer azul -->
</body>
</html>

💡 Fíjate: base.html solo define la estructura. No tiene contenido propio, solo «huecos» que las páginas hijas van a llenar.

🔴 header.html – El encabezado (rojo)

Este es el header. Es rojo para que lo identifiques fácilmente. Aparece en TODAS las páginas.

<header style="background-color: #ff0000; color: white; padding: 20px; text-align: center;">
    <h1>HEADER.html</h1>
    <nav>
        <a href="/" style="color: white; margin: 10px;">Inicio</a>
        <a href="/blog" style="color: white; margin: 10px;">Blog</a>
        <a href="/contacto" style="color: white; margin: 10px;">Contacto</a>
    </nav>
</header>

📌 Nota: Este header es exactamente el mismo en todas las páginas. Si lo cambias aquí, se cambia en todo el sitio.

🔵 footer.html – El pie de página (azul)

Este es el footer. Es azul para que lo identifiques fácilmente. Aparece en TODAS las páginas.

<footer style="background-color: #0000ff; color: white; padding: 15px; text-align: center;">
    <p>FOOTER.html - © 2026 Mi Sitio Web</p>
</footer>

📌 Nota: Este footer es exactamente el mismo en todas las páginas. Si lo cambias aquí, se cambia en todo el sitio.

🟢 index.html – Página de Inicio (contenido 1)

Esta es la página de inicio. Solo define el título y el contenido. El header y footer vienen de base.html.

{% extends "base.html" %}

{% block title %}Inicio{% endblock %}

{% block content %}
    <div style="background-color: #d4edda; padding: 30px; text-align: center; border-radius: 10px;">
        <h2>📄 Este es el CONTENIDO 1</h2>
        <p>Bienvenido a mi portfolio. Aquí encontrarás mis proyectos.</p>
        <img src="https://via.placeholder.com/300x150/cccccc/666666?text=Imagen+genérica" alt="Imagen genérica" style="border-radius: 8px; margin-top: 10px;" />
    </div>
{% endblock %}

¿Ves? Solo se preocupa por el content. El header y footer ya están incluidos automáticamente con el bloque «extends» de base.html. Por lo que si creamos nuevas páginas directamente partimos de extender de base y luego añadimos el título y el bloque de contenido que tendrá esa página.

🟡 blog.html – Página del Blog (contenido 2)

Esta es la página del blog. Tiene el mismo header y footer que index.html, pero el contenido es diferente.

{% extends "base.html" %}

{% block title %}Blog{% endblock %}

{% block content %}
    <div style="background-color: #fff3cd; padding: 30px; text-align: center; border-radius: 10px;">
        <h2>📝 Este es el CONTENIDO 2</h2>
        <p>Últimas entradas del blog. Aquí comparto mis aprendizajes.</p>
        <img src="https://via.placeholder.com/300x150/cccccc/666666?text=Imagen+genérica" alt="Imagen genérica" style="border-radius: 8px; margin-top: 10px;" />
    </div>
{% endblock %}

¿Ves? El header y footer son los mismos. Solo cambia el contenido y el título de la pestaña.

📸 Resultado en el navegador

Así se verían las dos páginas en el navegador. El header y footer son idénticos, solo cambia el contenido central.

🔴 HEADER (rojo) – IGUAL en ambas páginas

🔵 FOOTER (azul) – IGUAL en ambas páginas

 

 

🎯 Conclusión: El header y footer se escriben UNA SOLA VEZ en header.html y footer.html.

Las páginas solo se preocupan por su contenido, entonces al añadir por ejemplo un nuevo botón en el menú del header, simplemente modificamos «header.html» y se mostraría en todas las páginas.

¡Así de fácil es mantener un sitio web profesional!

 


📌 Explicación detallada: ¿Por qué el Header es rojo y el Footer azul?

Ahora que ya has visto el código y el resultado, vamos a entender por qué funciona así y qué está pasando realmente con los bloques Jinja2.

  • 🔴 El header es rojo porque en header.html hemos definido style="background-color: #ff0000". Ese archivo se incluye en base.html con {% include 'header.html' %}, por lo que aparece en todas las páginas que heredan de base.html.
  • 🔵 El footer es azul porque en footer.html hemos definido style="background-color: #0000ff". Se incluye de la misma manera y también aparece en todas las páginas.
  • 🟢 El contenido cambia porque cada página hija (como index.html) hereda de base.html y rellena el bloque {% block content %} con su propio contenido.

🎯 ¿Qué significa esto para ti?

  • No repites código: el header y footer se escriben una sola vez.
  • Mantenimiento sencillo: si quieres cambiar el menú, lo haces en header.html y se actualiza en todo el sitio.
  • Escalabilidad: puedes tener 100 páginas y todas compartirán la misma estructura base.

🐍

polimorfismo en python

 Esto, querido aprendiz, es lo que diferencia a un proyecto «cutre» de un proyecto profesional. Aprender a usar bloques Jinja2 con includes es como aprender a usar plantillas en Word, pero con superpoderes.

Y cuando lo domines, ningún proyecto Flask te parecerá demasiado grande.

Podrás construir proyectos de alto nivel sin repetir código absurdo y con una estructura eficiente.

 

meme

Heee pero la imagen no se ve.

-Ya nos encargaremos de las imágenes en la siguiente entrada.

-Ahora vamos a aprender a construir plantillas.

 

 

 

🎨 Mejoramos nuestra aplicación: plantillas, Bootstrap y barra de navegación

Hasta ahora hemos construido una aplicación CRUD funcional con Flask y JSON. En la Parte 4 – Jinja2: La Vista conectamos el modelo con el controlador y las vistas, logrando que nuestra app de directorio telefónico funcionara correctamente. Pero si somos sinceros… estéticamente es un poco cutre, ¿verdad? 😅

En esta sección vamos a darle un lavado de cara profesional a nuestra aplicación. Vamos a convertirla en una plantilla modular usando bloques Jinja2, añadiendo una barra de navegación con Bootstrap y estilos personalizados. Y lo mejor: no vamos a tocar ni una línea de la lógica (models.py y controlador.py siguen igual).

Si no has seguido la serie desde el principio, te recomiendo echar un vistazo a los posts anteriores para ponerte en contexto con las plantillas Jinja2 y el desarrollo web con Flask:

Recordemos que nuestra aplicación tiene una estructura muy sencilla. Si ejecutas el comando tree en la raíz de tu proyecto, deberías ver algo así:

.
├── controlador.py
├── DB
│   └── base_de_datos.json
├── models.py
└── templates
    ├── agregar_contacto.html
    ├── editar_contacto.html
    └── index.html

ejemplo-jinja2-en-aplicacion-tutorial

 

Hasta ahora, nuestras vistas (index.html, editar_contacto.html, agregar_contacto.html) eran archivos HTML independientes con su propio código. Esto funcionaba, pero si teníamos que cambiar algo del header o del footer, teníamos que modificar cada archivo uno por uno. ¡Un horror!

 

La solución es simple y elegante: vamos a separar el header y el footer en archivos independientes y los vamos a incluir en una plantilla base llamada base.html. Así, cualquier cambio en el header o footer se reflejará automáticamente en todas las páginas. Esto, querido aprendiz, es lo que diferencia a un proyecto amateur de uno profesional, y es posible gracias a los bloques Jinja2.

 

 

🚀 Manos a la obra: Creamos la estructura modular de la Vista

header-footer-base-plantilla-jinja2Vamos a crear tres nuevos archivos dentro de la carpeta templates/:

  1. base.html – La plantilla base que contendrá la estructura común de todo el sitio.
  2. header.html – El encabezado con la barra de navegación.
  3. footer.html – El pie de página.

Empecemos por el header. Pero, como siempre con una «trampa», no nos vamos a poner a diseñar un jodido header con menú y cajitas ordenaditas desde cero. Vamos a robarnos algo ya echo, aquí ya demasiado esfuerzo ponemos en leer todo esto.

¿Recuerdas que hablamos de los Frameworks? y te explique que Flask es un Framework, pues bueno la luz ahora es que los frameworks se pueden «acoplar» podemos tener diversos frameworks compatibles trabajando, y uno de ellos exclusivamente para nuestra VISTA.

 

 

🎨 Bootstrap: La forma rápida de tener una aplicación estilo profesional

 

🎨 Usamos Boostrap con Jinja2

usando boostrap + jinja2

En lugar de escribir todo el HTML y CSS de una barra de navegación desde cero como ardillas con café, vamos a usar Bootstrap, el framework front-end más popular del mundo, que en estos momentos es como un superheroe nuevo.

💡 ¿Por qué Bootstrap? Porque nos permite tener un diseño responsive y profesional con muy poco esfuerzo.

 

Además, en el próximo post de la serie vamos a profundizar: aprenderemos a personalizarlo, a usar su sistema de grillas, componentes y a integrarlo completamente con Flask. ¡Así que esto es solo un aperitivo! 🍿 y verás la fuerza conjunta de Bootstrap + Jinja2 te volará la cabeza.

Bootstrap nos va a facilitar poder construir aplicaciones responsive sin demasiado esfuerzo, esto quiere decir, que tendremos más tiempo para enfocarnos en el Backend Python Flask y no estar renegando con centrar una cajita ni «que en mi móvil se ve distinto».

 

Al ser un framework nos ordena las cosas en un marco, como si nos diera una estructura ya planificada y que funciona espectacular para que no tengamos que construir la nuestra, simplemente usamos este esqueleto y luego le damos nuestros estilos personalizados, nuestro toque creativo. En el próximo post vamos a profundizarlo bien y enumeraré todos los beneficios que su secta tiene para ti.

bootstrap-nav-bar

👉 Visita: https://getbootstrap.com/docs/4.4/components/navbar/

📌 Cómo elegir tu barra de navegación en la documentación de Bootstrap:
Una vez que estés en la página de la documentación, verás varios ejemplos de barras de navegación. No te compliques: elige la que más se ajuste a lo que necesitas (puede ser la más sencilla, con un logo y unos pocos enlaces). Debajo de cada ejemplo hay un botón que dice «Copy» o un cuadro de código. Haz clic en él para copiar todo el HTML. Ese es el código que pegaremos en nuestro header.html. No te preocupes si no entiendes todas las clases de Bootstrap, ahora solo necesitamos que funcione. Luego, en el próximo post de la serie, aprenderemos a personalizarlo a fondo. ¡Tranquilo, que no hay examen! 😉

Yo he elegido la siguiente, que es sencilla y fácil de personalizar, puedes usar la misma si no quieres morir eligiendo:

header.html – Nuestra navbar robada:

<nav class="navbar navbar-expand-lg navbar-light bg-light">
  <a class="navbar-brand" href="{{ url_for('inicio') }}">📞 Directorio</a>
  <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarNav">
    <span class="navbar-toggler-icon"></span>
  </button>
  <div class="collapse navbar-collapse" id="navbarNav">
    <ul class="navbar-nav ml-auto">
      <li class="nav-item active">
        <a class="nav-link" href="{{ url_for('inicio') }}">Inicio <span class="sr-only">(current)</span></a>
      </li>
      <li class="nav-item">
        <a class="nav-link" href="{{ url_for('agregar') }}">Agregar</a>
      </li>
      <li class="nav-item">
        <a class="nav-link" href="{{ url_for('soporte') }}">Soporte</a>
      </li>
    </ul>
  </div>
</nav>

 

Claro si abres tu aplicación no vas a ver nada correcto ni bonito, porque estás usando «Clases CSS» de Bootstrap, si. Pero no lo hemos importado a nuestra aplicación, por lo que parecerá que estás usando clases vacías, y se verá desordenado. Continúa sin ansiedad, más adelante todo va a encajar.

Primero, copiamos este código en nuestro archivo header.html. Luego, haremos lo mismo con el footer. Para el footer, usaremos algo sencillo.

footer.html:

<footer class="text-center py-3 mt-4">
    <p>&copy; 2026 - Directorio Telefónico con Flask </p>
</footer>

base.html:

Ahora, creamos nuestra plantilla base base.html. Aquí es donde ocurre la magia de los bloques Jinja2 presta especial atención.

<!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>
    <!-- Bootstrap CSS -->
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css">
    
</head>
<body>
    {% include 'header.html' %}

    <main class="container mt-4">
        {% block content %}{% endblock %}
    </main>

    {% include 'footer.html' %}

    <!-- Bootstrap JS -->
    <script src="https://code.jquery.com/jquery-3.4.1.slim.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js"></script>
    <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/js/bootstrap.min.js"></script>
</body>
</html>

 

📋 Desglose de base.html: ¿Qué estamos haciendo paso a paso?

Vamos a ver con calma qué está pasando dentro de base.html. No te preocupes si no entiendes todo ahora, la práctica hará que lo interiorices. Aquí tienes el checklist de lo que hemos logrado:

  • Vinculamos Bootstrap (CSS) – En el <head> añadimos la hoja de estilos de Bootstrap desde un CDN. Así todas nuestras páginas heredan su diseño responsive y sus componentes sin tener que escribir CSS desde cero.
  • Vinculamos Bootstrap (JavaScript) – Al final del <body> añadimos los scripts de jQuery, Popper.js y Bootstrap JS. Esto es necesario para que funcionen los componentes interactivos como menús desplegables, ventanas modales, y la magia del framework.
  • Incluimos el header y footer – Usamos {% include 'header.html' %} y {% include 'footer.html' %} para insertar el encabezado y pie de página en todas las páginas. Así no tenemos que copiar y pegar el mismo código en cada archivo.
  • Definimos un «hueco» para el contenido – Con {% block content %}{% endblock %} creamos un espacio que las páginas hijas (como index.html) rellenarán con su propio contenido. Esto es el núcleo de la herencia de plantillas.
  • Centralizamos la estructura – Todo lo que es común (header, footer, estilos, scripts) está en un solo lugar. Si mañana queremos cambiar el color de fondo o añadir un nuevo script, lo hacemos una sola vez en base.html y se actualiza en todo el sitio.

💡 ¿Por qué vinculamos Bootstrap en la plantilla base y no en cada página? Porque queremos que todas las páginas de nuestra aplicación tengan acceso a Bootstrap. Al ponerlo en base.html, cualquier página que herede de ella (usando {% extends %}) ya tendrá Bootstrap cargado automáticamente, no nos olvidaremos al crear nuevas páginas de incluirlo porque se incluye en base. [Así conformamos una plantilla de nuestra app trabajando con Herencias de bloques Jinja2.]

Presta atención a cada línea de código y a la estructura general ordenada.

💡 Fíjate en las líneas clave de los bloques Jinja2:

  • {% include 'header.html' %} – Inserta el header en todas las páginas.
  • {% include 'footer.html' %} – Inserta el footer en todas las páginas.
  • {% block content %}{% endblock %} – Define un «hueco» que las páginas hijas rellenarán con su contenido específico.
  • {{ url_for('static', filename='css/style.css') }} – Enlaza nuestro archivo CSS personalizado usando url_for.

 

header-footer-base-jinja2-bootstrap

 

👉 Extender todas las páginas de base.html

Ahora, modificamos nuestras páginas hijas (index.html, editar_contacto.html, agregar_contacto.html) para que todas  extiendan debase.html y rellenen el bloque content con su propio contenido.

Por ejemplo, nuestro index.html quedaría así:

{% extends "base.html" %} #Extendemos de nuestro template base.html

{% block title %}Inicio - Directorio{% endblock %} #Definimos el Titulo de cada página

{% block content %} #El contenido original de la página.
    <h1>Directorio Telefónico</h1>
    <!-- Aquí va TODO el contenido de tu index.html original -->
    <p>Listado de contactos...</p>
{% endblock %}

Si te fijas lo que hacemos es «envolver» el contenido crucial de cada página dentro del bloque «content«, porque como ya extendemos de base el código del head, header, footer, todo será traído desde el extends o los include, por lo que solo no insteresa del index la parte que está dentro del body:

<!-- COMIENZA EL BODY -->
<h1 style="text-align:center">Directorio Telefónico - Mi primer aplicación en Flask</h1>
<br>

<div>
  <!-- DIV IZQUIERDA -->
  <div style="width: 60%; float:left; text-align:center;">
    <h2>Datos completos de la persona</h2>
    <table border="1" style="width: 100%;">
      <tr>
        <td>Nombre</td>
        <td>Apellido</td>
        <td>Apodo</td>
        <td>Teléfono</td>
        <td>Dirección</td>
      </tr>
      {% for (index, persona) in Todos.items() %}
      {% if index == index_ver %}
      <tr>
        <td>{{persona.nombre}}</td>
        <td>{{persona.apellido}}</td>
        <td>{{persona.apodo}}</td>
        <td>{{persona.telefono}}</td>
        <td>{{persona.direccion}}</td>
      </tr>
      {% endif %}
      {% endfor %}
    </table>
  </div>

  <!-- DIV DERECHA -->
  <div style="width: 40%; float:right; text-align:center;">
    <h2>Personas</h2>
    <table border="1" style="width: 100%;">
      {% for (index, persona) in Todos.items() %}
      <tr>
        <td>{{persona.nombre}}, {{persona.apellido}}</td>
        <td><a href="{{url_for('ver', index = index)}}"><button name="ver">Ver</button></a></td>
        <td><a href="{{url_for('editar', index = index)}}"><button name="editar">Editar</button></a></td>
        <td><a href="{{url_for('eliminar', id = persona.id)}}"><button name="eliminar">Eliminar</button></a></td>
      </tr>
      {% endfor %}
      <tr>
        <td><a href="{{url_for('agregar')}}"><button name="agregar">Agregar</button></a></td>
      </tr>
    </table>
  </div>
</div>
<!-- TERMINA EL BODY -->

Entonces nuestro index.html envuelto quedaría así:

{% extends "base.html" %} #Extendemos de base.html por lo que el header aparecerá por estár definido allí (include header.html)

{% block title %}Inicio - Directorio Telefónico{% endblock %} #Bueno definimos el titulo de esta página, si no lo defines tomará el de base.html

{% block content %}  #Aquí comienza el contenido de index.html, lo importante que se mostrará en la parte que definimos el bloque content, envuelto por base.html.

<h1 style="text-align:center;">Directorio Telefónico - Mi primera aplicación en Flask</h1>
<br>

<div>
    <!-- DIV IZQUIERDA -->
    <div style="width: 60%; float:left; text-align:center;">
        <h2>Datos completos de la persona</h2>
        <table border="1" style="width: 100%;">
            <tr>
                <td>Nombre</td>
                <td>Apellido</td>
                <td>Apodo</td>
                <td>Teléfono</td>
                <td>Dirección</td>
            </tr>
            {% for (index, persona) in Todos.items() %}
            {% if index == index_ver %}
            <tr>
                <td>{{persona.nombre}}</td>
                <td>{{persona.apellido}}</td>
                <td>{{persona.apodo}}</td>
                <td>{{persona.telefono}}</td>
                <td>{{persona.direccion}}</td>
            </tr>
            {% endif %}
            {% endfor %}
        </table>
    </div>

    <!-- DIV DERECHA -->
    <div style="width: 40%; float:right; text-align:center;">
        <h2>Personas</h2>
        <table border="1" style="width: 100%;">
            {% for (index, persona) in Todos.items() %}
            <tr>
                <td>{{persona.nombre}}, {{persona.apellido}}</td>
                <td><a href="{{url_for('ver', index = index)}}"><button name="ver">Ver</button></a></td>
                <td><a href="{{url_for('editar', index = index)}}"><button name="editar">Editar</button></a></td>
                <td><a href="{{url_for('eliminar', id = persona.id)}}"><button name="eliminar">Eliminar</button></a></td>
            </tr>
            {% endfor %}
            <tr>
                <td><a href="{{url_for('agregar')}}"><button name="agregar">Agregar</button></a></td>
            </tr>
        </table>
    </div>
</div>

{% endblock %} #Fin del bloque content, el bloque del medio, no hay nada más que agregar, el footer se incluye desde base (include footer.html)

Básicamente lo que está dentro de los body será nuestro «content» el resto se elimina de todas las páginas hijas.

diagrama-app-plantillas-jinja2

Por favor, compara cada archivo antiguo vs nuevo hasta que notes la diferencia & similitud en lo que hacemos.

Captura de nuestra app con bootsrap + jinja2
Si ves como ha cambiado el estilo de nuestra aplicación en general. Además de estar la barra de navegación de Boostrap arriba en el header. Se ve algo fea igualmente, pero ya lo corregiremos más adelante, al igual que el footer está casi encima del contenido.

Y ahora hacemos lo mismo para editar_contacto.html y agregar_contacto.html. Solo cambia el título y el contenido dentro del bloque content. Esta es la esencia de la herencia de plantillas Jinja2. Aquí te dejo el código completo de todos los html, lo ideal es que lo hagas tu mismo reestructurando el código de todas las páginas pero por las dudas aquí está:

 

Código – Plantilla base Jinja2 + Bootstrap

Código completo del Proyecto + Repositorio
📁 Información de la aplicación

Directorio Telefónico – Aplicación CRUD con Flask y Jinja2 (Modularizada)

Esta es una aplicación web desarrollada con Flask (microframework de Python) que implementa un CRUD completo (Crear, Leer, Actualizar y Eliminar) sobre una base de datos JSON. El objetivo principal es mostrar cómo estructurar un proyecto siguiendo el patrón MVC (Modelo-Vista-Controlador) y cómo utilizar el motor de plantillas Jinja2 con herencia de plantillas (base.html, header.html, footer.html) para generar contenido dinámico en el frontend y añadimos bootstrap.

Tecnologías utilizadas:

  • Python 3 – Lenguaje de programación.
  • Flask – Framework web ligero.
  • Jinja2 – Motor de plantillas para renderizar HTML dinámico.
  • JSON – Almacenamiento persistente de datos (archivo base_de_datos.json).
  • Bootstrap 4.4 – Framework CSS para el diseño responsive (integrado vía CDN).

Funcionalidades CRUD:

  • CREATE – Añadir nuevos contactos mediante el formulario en /agregar.
  • READ – Listar todos los contactos en la página de inicio y ver detalles individuales en /ver/<id>.
  • UPDATE – Editar contactos existentes desde /editar/<id>.
  • DELETE – Eliminar contactos mediante la ruta /eliminar/<id>.

Estructura del proyecto (modularizada):

  • models.py – Contiene la clase Persona y los métodos CRUD.
  • controlador.py – Define las rutas y la lógica de navegación.
  • templates/base.html – Plantilla base que incluye el header, el footer y el bloque content.
  • templates/header.html – Barra de navegación común a todas las páginas.
  • templates/footer.html – Pie de página común.
  • templates/index.html, editar_contacto.html, agregar_contacto.html – Páginas hijas que extienden de base.html.
  • DB/ – Carpeta que almacena el archivo JSON con los datos.

Propósito educativo:
Este proyecto está diseñado para enseñar los fundamentos del desarrollo web con Flask, la integración de Jinja2 y la implementación de operaciones CRUD, así como la organización modular de las vistas usando herencia de plantillas. Es un punto de partida ideal para quienes desean aprender a crear aplicaciones web en Python con buenas prácticas.

🐍 controlador.py
# ============================================================
# controlador.py - Aplicación Flask con CRUD sobre JSON
# ============================================================
# Este archivo actúa como el CONTROLADOR en el patrón MVC.
# Gestiona las peticiones HTTP, orquesta la lógica de negocio
# (usando el modelo Persona) y renderiza las vistas (templates).
# ============================================================

from flask import Flask, redirect, request, render_template
from models import Persona  # Importamos el modelo para acceder a los datos

app = Flask(__name__)  # Instancia de la aplicación Flask


# ============================================================
# RUTA PRINCIPAL (READ - Listar todos los contactos)
# ============================================================
@app.route('/', methods=['GET'])
def inicio():
    """
    Página de inicio de la aplicación.
    - Método: GET
    - Función: Obtiene todas las personas almacenadas en la base de datos JSON
      mediante el método leer_contacto('id', 'all') del modelo.
    - Renderiza: index.html pasando el diccionario 'Todos' con todas las personas
      y 'index_ver=0' para que por defecto se muestre la primera persona.
    """
    Todos = Persona.leer_contacto('id', 'all')
    return render_template('index.html', Todos=Todos, index_ver=0)


# ============================================================
# RUTA VER (READ - Mostrar un contacto específico)
# ============================================================
@app.route('/ver/<int:index>', methods=['GET'])
def ver(index):
    """
    Muestra los detalles completos de una persona en particular.
    - Método: GET
    - Parámetro URL: index (entero) → posición de la persona en el diccionario.
    - Función: Recarga la misma vista index.html pero con 'index_ver' igual al
      índice recibido, lo que activa el condicional en la plantilla para mostrar
      solo esa persona.
    """
    Todos = Persona.leer_contacto('id', 'all')
    return render_template('index.html', Todos=Todos, index_ver=index)


# ============================================================
# RUTA EDITAR (UPDATE - Modificar un contacto)
# ============================================================
@app.route('/editar/<int:index>', methods=['GET', 'POST'])
def editar(index):
    """
    Permite modificar los datos de una persona existente.
    - Métodos: GET y POST
    - Parámetro URL: index (entero) → posición de la persona a editar.
    - GET: Muestra el formulario con los datos actuales de la persona.
    - POST: Recibe los datos del formulario, actualiza el registro en la base de
      datos JSON mediante el modelo, y redirige a la misma vista para reflejar
      los cambios.
    """
    Todos = Persona.leer_contacto('id', 'all')
    if request.method == 'POST':
        # Obtenemos los datos enviados desde el formulario HTML
        id = int(request.form['id'])          # ID oculto de la persona
        Nombre = request.form['Nombre']
        Apellido = request.form['Apellido']
        Apodo = request.form['Apodo']
        Telefono = request.form['Telefono']
        Direccion = request.form['Direccion']

        # Llamamos al modelo para actualizar cada atributo
        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)
        # Podríamos redirigir a la misma página de edición para mostrar los cambios
        # return redirect(f'/editar/{index}')
    else:
        # Si es GET, solo mostramos el formulario sin procesar nada
        pass

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


# ============================================================
# RUTA AGREGAR (CREATE - Crear un nuevo contacto)
# ============================================================
@app.route('/agregar', methods=['GET', 'POST'])
def agregar():
    """
    Permite añadir una nueva persona a la base de datos.
    - Métodos: GET y POST
    - GET: Muestra un formulario vacío para que el usuario ingrese los datos.
    - POST: Recibe los datos del formulario, crea una nueva instancia de Persona
      con esos valores y llama a crear_contacto() para guardarla en el JSON.
      Luego redirige a la misma URL (GET) para recargar la página y mostrar la
      lista actualizada con el nuevo contacto.
    """
    Todos = Persona.leer_contacto('id', 'all')
    if request.method == 'POST':
        # Obtenemos los datos del formulario
        Nombre = request.form['Nombre']
        Apellido = request.form['Apellido']
        Apodo = request.form['Apodo']
        Telefono = request.form['Telefono']
        Direccion = request.form['Direccion']

        # Creamos un objeto Persona y lo persistimos
        nuevo_contacto = Persona(Nombre, Apellido, Apodo, Telefono, Direccion)
        nuevo_contacto.crear_contacto()

        # Redirigimos a la misma página para que se recargue con la lista actualizada
        return redirect('agregar')
    else:
        pass

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


# ============================================================
# RUTA ELIMINAR (DELETE - Borrar un contacto)
# ============================================================
@app.route('/eliminar/<int:id>', methods=['GET'])
def eliminar(id):
    """
    Elimina una persona de la base de datos mediante su ID.
    - Método: GET (por simplicidad, aunque lo correcto sería DELETE)
    - Parámetro URL: id (entero) → identificador único de la persona.
    - Función: Llama al método eliminar_contacto(id) del modelo y redirige a la
      página principal para que se actualice la lista de contactos.
    """
    Persona.eliminar_contacto(id)
    return redirect('/')


# ============================================================
# RUTA DE AGRADECIMIENTO (Ejemplo adicional)
# ============================================================
@app.route('/agradecimiento', methods=['GET'])
def agradecer():
    return 'Gracias pythones.net!'


# ============================================================
# INICIO DE LA APLICACIÓN
# ============================================================
if __name__ == '__main__':
    app.run('127.0.0.1', 5000, debug=True)
🗄️ models.py
# ============================================================
# models.py - Modelo de datos para la aplicación Flask
# ============================================================
# Este archivo actúa como el MODELO en el patrón MVC.
# Contiene la lógica de negocio y el acceso a la base de datos
# (en este caso, un archivo JSON que funciona como almacenamiento persistente).
# ============================================================

import os
import json

# ============================================================
# CONFIGURACIÓN DE LA RUTA DEL ARCHIVO JSON
# ============================================================

# Obtenemos la ruta absoluta del directorio donde se encuentra este script
THIS_FOLDER = os.path.dirname(os.path.abspath(__file__))
# Construimos la ruta al archivo base_de_datos.json dentro de la carpeta /DB/
my_file = os.path.join(THIS_FOLDER + '/DB/' 'base_de_datos.json')


# ============================================================
# FUNCIONES DE ACCESO A DATOS (Persistencia)
# ============================================================

def leer_json():
    """
    Lee el archivo JSON y devuelve su contenido como un diccionario de Python.
    - Abre el archivo en modo lectura ('r').
    - Carga los datos con json.load().
    - Cierra el archivo automáticamente.
    - Retorna el diccionario 'datos'.
    """
    with open(my_file, "r") as f:
        datos = json.load(f)
        f.close()
        return datos

# Cargamos los datos al iniciar el módulo (se ejecuta una sola vez)
datos = leer_json()


def modificar_json():
    """
    Guarda el diccionario 'datos' actualizado en el archivo JSON.
    - Abre el archivo en modo escritura ('w').
    - Escribe los datos con formato indentado (4 espacios) para legibilidad.
    - Cierra el archivo.
    - Esta función se llama después de cada operación que modifica los datos.
    """
    with open(my_file, "w") as modid:
        json.dump(datos, modid, indent=4)
        modid.close()


# ============================================================
# CLASE PERSONA - REPRESENTA UNA ENTIDAD DEL DOMINIO
# ============================================================

class Persona(object):
    """
    Clase que modela a una persona (contacto) en el directorio telefónico.
    - Atributos: id, nombre, apellido, apodo, telefono, direccion.
    - Proporciona métodos CRUD para gestionar instancias en la base de datos JSON.
    - El 'id' es autoincremental y se gestiona mediante 'contador_id'.
    """

    # Variable de clase que almacena el último ID asignado (se sincroniza con el JSON)
    contador_id = datos["Configuraciones"][0]["contador_id_db"]

    def __init__(self, nombre, apellido, apodo, telefono, direccion):
        """
        Constructor de la clase Persona.
        - Asigna un ID único tomado de contador_id.
        - Almacena los demás atributos proporcionados.
        - Imprime en consola el ID asignado (útil para depuración).
        """
        print("El contador está en: ", Persona.contador_id)
        self.id = Persona.contador_id          # ID único autoincremental
        self.nombre = nombre
        self.apellido = apellido
        self.apodo = apodo
        self.telefono = telefono
        self.direccion = direccion

    # ============================================================
    # MÉTODO CREATE - Crear un nuevo contacto
    # ============================================================

    def crear_contacto(self):
        """
        Guarda la instancia actual como un nuevo registro en la base de datos JSON.
        - Incrementa el contador de IDs en el archivo JSON y en la variable de clase.
        - Convierte la instancia a diccionario (__dict__) y la añade a la lista 'Personas'.
        - Persiste los cambios llamando a modificar_json().
        - Imprime el nuevo valor del contador (para depuración).
        """
        # Actualizamos el contador en la base de datos
        datos["Configuraciones"][0]["contador_id_db"] += 1
        modificar_json()
        print("El contador está ahora en: ", datos["Configuraciones"][0]["contador_id_db"])
        Persona.contador_id += 1  # Sincronizamos la variable de clase

        # Creamos una nueva instancia (esto es necesario para que __init__ asigne el ID correcto)
        # Pero aquí reutilizamos la instancia actual: convertimos self a diccionario
        nueva_persona = Persona(
            self.nombre,
            self.apellido,
            self.apodo,
            self.telefono,
            self.direccion
        ).__dict__

        # Añadimos el diccionario a la lista de personas
        datos['Personas'].append(nueva_persona)
        modificar_json()  # Guardamos los cambios

    # ============================================================
    # MÉTODO READ - Leer contactos (búsqueda)
    # ============================================================

    def leer_contacto(atr, valor):
        """
        Busca personas según un atributo y un valor dado.
        - Si valor == 'all': devuelve todas las personas con su índice como clave.
        - Si valor != 'all': busca personas donde el atributo 'atr' sea igual a 'valor'.
        - Retorna un diccionario {índice: persona_diccionario} con los resultados.
        - Este método es de clase (@staticmethod de facto) porque no necesita una instancia.
        """
        if valor == 'all':
            # Devuelve todas las personas con su índice como clave
            todas = {}
            for i, persona in enumerate(datos["Personas"]):
                todas[i] = persona
            return todas
        else:
            # Búsqueda por atributo específico
            encontradas = {}
            for persona in datos["Personas"]:
                if persona[atr] == valor:
                    indice = datos["Personas"].index(persona)
                    encontradas[indice] = persona
            return encontradas

    # ============================================================
    # MÉTODO UPDATE - Actualizar un contacto existente
    # ============================================================

    def actualizar_contacto(id, atr, nuevo_valor):
        """
        Actualiza un atributo específico de una persona identificada por su ID.
        - Itera sobre la lista de personas hasta encontrar el ID.
        - Una vez encontrado, obtiene su índice y modifica el atributo indicado.
        - Persiste los cambios llamando a modificar_json().
        - No retorna nada (la actualización se hace in-place).
        """
        for persona in datos["Personas"]:
            if persona["id"] == id:
                print(persona)  # Mostramos el estado anterior (depuración)
                indice = datos["Personas"].index(persona)
                datos["Personas"][indice][atr] = nuevo_valor
                print(datos["Personas"][indice][atr])  # Mostramos el nuevo valor (depuración)
                modificar_json()
                break  # Salimos del bucle tras encontrar y actualizar

    # ============================================================
    # MÉTODO DELETE - Eliminar un contacto
    # ============================================================

    def eliminar_contacto(id):
        """
        Elimina la persona con el ID especificado de la base de datos.
        - Itera sobre la lista de personas hasta encontrar el ID.
        - Una vez encontrado, obtiene su índice y lo elimina con pop().
        - Persiste los cambios llamando a modificar_json().
        - Muestra un mensaje en consola con la persona eliminada (depuración).
        """
        for persona in datos["Personas"]:
            if persona["id"] == id:
                print("Se va a borrar: ", persona)
                indice = datos["Personas"].index(persona)
                datos["Personas"].pop(indice)
                modificar_json()
                break  # Salimos tras eliminar
🧩 base.html – Plantilla base
<!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>
    
    <!-- Bootstrap CSS (CDN) -->
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css" 
          integrity="sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh" 
          crossorigin="anonymous">
</head>
<body>
    {% include 'header.html' %}  <!-- 🔝 Barra de navegación común -->

    <main class="container mt-4">
        {% block content %}{% endblock %}  <!-- 🔥 Contenido específico de cada página -->
    </main>

    {% include 'footer.html' %}  <!-- 👣 Pie de página común -->

    <!-- Bootstrap JS (jQuery, Popper, Bootstrap) -->
    <script src="https://code.jquery.com/jquery-3.4.1.slim.min.js" 
            integrity="sha384-J6qa4849blE2+poT4WnyKhv5vZF5SrPo0iEjwBvKU7imGFAV0wwj1yYfoRSJoZ+n" 
            crossorigin="anonymous"></script>
    <script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js" 
            integrity="sha384-Q6E9RHvbIyZFJoft+2mJbHaEWldlvI9IOYy5n3zV9zzTtmI3UksdQRVvoxMfooAo" 
            crossorigin="anonymous"></script>
    <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/js/bootstrap.min.js" 
            integrity="sha384-wfSDF2E50Y2D1uUdj0O3uMBJnjuUD4Ih7YwaYd1iqfktj0Uod8GCExl3Og8ifwB6" 
            crossorigin="anonymous"></script>
</body>
</html>
🔝 header.html – Barra de navegación
<nav class="navbar navbar-expand-lg navbar-light bg-light">
  <a class="navbar-brand" href="{{ url_for('inicio') }}">📞 Directorio</a>
  <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarNav">
    <span class="navbar-toggler-icon"></span>
  </button>
  <div class="collapse navbar-collapse" id="navbarNav">
    <ul class="navbar-nav ml-auto">
      <li class="nav-item active">
        <a class="nav-link" href="{{ url_for('inicio') }}">Inicio <span class="sr-only">(current)</span></a>
      </li>
      <li class="nav-item">
        <a class="nav-link" href="{{ url_for('agregar') }}">Agregar</a>
      </li>
      <li class="nav-item">
        <a class="nav-link" href="{{ url_for('soporte') }}">Soporte</a>
      </li>
    </ul>
  </div>
</nav>
👣 footer.html – Pie de página
<footer class="text-center py-3 mt-4 border-top">
    <p class="mb-0">&copy; 2026 - Directorio Telefónico con Flask </p>
</footer>
🏠 index.html – Página de inicio (extiende base.html)
{% extends "base.html" %}

{% block title %}Inicio - Directorio Telefónico{% endblock %}

{% block content %}

<h1 style="text-align:center;">Directorio Telefónico - Mi primera aplicación en Flask</h1>
<br>

<div>
    <!-- DIV IZQUIERDA -->
    <div style="width: 60%; float:left; text-align:center;">
        <h2>Datos completos de la persona</h2>
        <table border="1" style="width: 100%;">
            <tr>
                <td>Nombre</td>
                <td>Apellido</td>
                <td>Apodo</td>
                <td>Teléfono</td>
                <td>Dirección</td>
            </tr>
            {% for (index, persona) in Todos.items() %}
            {% if index == index_ver %}
            <tr>
                <td>{{persona.nombre}}</td>
                <td>{{persona.apellido}}</td>
                <td>{{persona.apodo}}</td>
                <td>{{persona.telefono}}</td>
                <td>{{persona.direccion}}</td>
            </tr>
            {% endif %}
            {% endfor %}
        </table>
    </div>

    <!-- DIV DERECHA -->
    <div style="width: 40%; float:right; text-align:center;">
        <h2>Personas</h2>
        <table border="1" style="width: 100%;">
            {% for (index, persona) in Todos.items() %}
            <tr>
                <td>{{persona.nombre}}, {{persona.apellido}}</td>
                <td><a href="{{url_for('ver', index = index)}}"><button name="ver">Ver</button></a></td>
                <td><a href="{{url_for('editar', index = index)}}"><button name="editar">Editar</button></a></td>
                <td><a href="{{url_for('eliminar', id = persona.id)}}"><button name="eliminar">Eliminar</button></a></td>
            </tr>
            {% endfor %}
            <tr>
                <td><a href="{{url_for('agregar')}}"><button name="agregar">Agregar</button></a></td>
            </tr>
        </table>
    </div>
</div>

{% endblock %}
✏️ editar_contacto.html – Editar contacto (extiende base.html)
{% extends "base.html" %}

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

{% block content %}

<h1 style="text-align:center;">Directorio Telefónico - Editar contacto</h1>
<br>

<div>
    <!-- DIV IZQUIERDA (datos actuales) -->
    <div style="width: 60%; float:left; text-align:center;">
        <h2>Datos completos de la persona</h2>
        <table border="1" style="width: 100%;">
            <tr>
                <td>Nombre</td>
                <td>Apellido</td>
                <td>Apodo</td>
                <td>Teléfono</td>
                <td>Dirección</td>
            </tr>
            {% for (index, persona) in Todos.items() %}
            {% if index == index_ver %}
            <tr>
                <td>{{persona.nombre}}</td>
                <td>{{persona.apellido}}</td>
                <td>{{persona.apodo}}</td>
                <td>{{persona.telefono}}</td>
                <td>{{persona.direccion}}</td>
            </tr>
            {% endif %}
            {% endfor %}
        </table>
    </div>

    <!-- DIV DERECHA (lista de personas) -->
    <div style="width: 40%; float:right; text-align:center;">
        <h2>Personas</h2>
        <table border="1" style="width: 100%;">
            {% for (index, persona) in Todos.items() %}
            <tr>
                <td>{{persona.nombre}}, {{persona.apellido}}</td>
                <td><a href="{{url_for('ver', index = index)}}"><button name="ver">Ver</button></a></td>
                <td><a href="{{url_for('editar', index = index)}}"><button name="editar">Editar</button></a></td>
                <td><button name="eliminar">Eliminar</button></td>
            </tr>
            {% endfor %}
            <tr>
                <td><button name="agregar">Agregar</button></td>
            </tr>
        </table>
    </div>
</div>

<!-- SECCIÓN DE EDICIÓN (formulario) -->
<div style="clear:both; text-align:center; margin-top:40px; border-top:2px solid #ccc; padding-top:20px;">
    <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">
        <table border="1" style="width: 80%; margin: 0 auto;">
            <tr>
                <td>Nombre</td>
                <td>Apellido</td>
                <td>Apodo</td>
                <td>Teléfono</td>
                <td>Dirección</td>
            </tr>
            <tr>
                <input type="hidden" name="id" value="{{Todos[index_ver].id}}">
                <td><input type="text" name="Nombre" value="{{Todos[index_ver].nombre}}" style="max-width: 100px;"></td>
                <td><input type="text" name="Apellido" value="{{Todos[index_ver].apellido}}" style="max-width: 100px;"></td>
                <td><input type="text" name="Apodo" value="{{Todos[index_ver].apodo}}" style="max-width: 100px;"></td>
                <td><input type="tel" name="Telefono" value="{{Todos[index_ver].telefono}}" style="max-width: 100px;"></td>
                <td><input type="text" name="Direccion" value="{{Todos[index_ver].direccion}}" style="max-width: 100px;"></td>
            </tr>
        </table>
        <br>
        <button type="submit">Modificar</button>
    </form>
</div>

{% endblock %}
➕ agregar_contacto.html – Agregar contacto (extiende base.html)
{% extends "base.html" %}

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

{% block content %}

<h1 style="text-align:center;">Directorio Telefónico - Agregar contacto</h1>
<br>

<div>
    <!-- DIV IZQUIERDA (formulario) -->
    <div style="width: 60%; float:left; text-align:center;">
        <h2>Agregar Persona</h2>
        <table border="1" style="width: 100%;">
            <tr>
                <td>Nombre</td>
                <td>Apellido</td>
                <td>Apodo</td>
                <td>Teléfono</td>
                <td>Dirección</td>
            </tr>
            <form action="{{url_for('agregar')}}" method="post">
            <tr>
                <td><input type="text" name="Nombre" placeholder="Nombre" style="max-width: 100px;"></td>
                <td><input type="text" name="Apellido" placeholder="Apellido" style="max-width: 100px;"></td>
                <td><input type="text" name="Apodo" placeholder="Apodo" style="max-width: 100px;"></td>
                <td><input type="tel" name="Telefono" placeholder="Teléfono" style="max-width: 100px;"></td>
                <td><input type="text" name="Direccion" placeholder="Dirección" style="max-width: 100px;"></td>
            </tr>
        </table>
        <br>
        <button type="submit">Agregar</button>
        </form>
    </div>

    <!-- DIV DERECHA (lista de personas) -->
    <div style="width: 40%; float:right; text-align:center;">
        <h2>Personas</h2>
        <table border="1" style="width: 100%;">
            {% for (index, persona) in Todos.items() %}
            <tr>
                <td>{{persona.nombre}}, {{persona.apellido}}</td>
                <td><a href="{{url_for('ver', index = index)}}"><button name="ver">Ver</button></a></td>
                <td><a href="{{url_for('editar', index = index)}}"><button name="editar">Editar</button></a></td>
                <td><button name="eliminar">Eliminar</button></td>
            </tr>
            {% endfor %}
            <tr>
                <td><a href="{{url_for('agregar')}}"><button name="agregar">Agregar</button></a></td>
            </tr>
        </table>
    </div>
</div>

{% endblock %}

¿Ves qué fácil? Ahora, cuando abras tu aplicación en el navegador, verás la barra de navegación y el footer en todas las páginas.

⚠️ Pero aún hay un detalle: los enlaces de la barra de navegación no funcionan porque tienen # en lugar de las rutas reales. Y otro detalle no menor, es que se ve como una app de los 80. Vamos a corregir todo eso:

 

 

🔗 Usando url_for para enlaces dinámicos

Recuerda que en Flask, para generar URLs de forma segura y flexible, usamos url_for. Este bloque de Jinja2 nos permite crear enlaces a las rutas de nuestra aplicación sin tener que escribir la URL a mano. Es una de las herramientas más útiles dentro de los bloques Jinja2.

💡 ¿Por qué es mejor usar url_for que escribir la URL directamente?

  • Mantenibilidad: Si cambias la ruta en controlador.py (por ejemplo, de /inicio a /home), no tienes que buscar y reemplazar en todos tus templates. url_for genera la URL automáticamente a partir del nombre de la función.
  • Seguridad: url_for escapa automáticamente los caracteres especiales, evitando problemas de inyección en las URLs.
  • Flexibilidad: Puedes pasar parámetros fácilmente, como en url_for('ver', index=0), y Flask construirá la URL correcta.
  • Portabilidad: Si tu aplicación se mueve de dominio o cambia la estructura de URLs, url_for se adapta sin que tengas que tocar nada.

Modifiquemos nuestra header.html para usar url_for y definimos enlaces para inicio, agregar contacto y soporte (formulario de contacto):

<nav class="navbar navbar-expand-lg navbar-light bg-light">
  <a class="navbar-brand" href="{{ url_for('inicio') }}">📞 Directorio</a>
  <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarNav">
    <span class="navbar-toggler-icon"></span>
  </button>
  <div class="collapse navbar-collapse" id="navbarNav">
    <ul class="navbar-nav ml-auto">
      <li class="nav-item active">
        <a class="nav-link" href="{{ url_for('inicio') }}">Inicio <span class="sr-only">(current)</span></a>
      </li>
      <li class="nav-item">
        <a class="nav-link" href="{{ url_for('agregar') }}">Agregar</a>
      </li>
      <li class="nav-item">
        <a class="nav-link" href="{{ url_for('soporte') }}">Soporte</a>
      </li>
    </ul>
  </div>
</nav>

 

 

💡 Importante: url_for recibe como parámetro el nombre de la función de la ruta, no la URL. Por ejemplo, si en tu controlador.py tienes @app.route('/') con la función def inicio():, entonces usas {{ url_for('inicio') }}. Así de simple.

Aplicación modificamos header

Genial, ahora los enlaces funcionan. Pero hay un nuevo problema: la clase active de Bootstrap que resalta la página actual. Si estamos en «Agregar«, la clase active debería estar en ese elemento del menú, no en «Inicio«. ¿Cómo lo solucionamos usando bloques Jinja2?

menu-condicional-bootstrap-y-jinja2
Yo quiero que al visitar «Agregar» se marque «Agregar» como elemento resaltado del menú. Y lo mismo si estoy en Inicio o en Soporte en un futuro.
¿Cómo lo hacemos? Aquí el poder de Jinja2 + Bootstrap

 

 

🧠 Código Python en el template: condicionales con request.endpoint

Aquí es donde Jinja2 se vuelve realmente poderoso. Podemos meter código Python dentro del HTML usando la sintaxis de Jinja2. En este caso, vamos a usar un condicional IF para comprobar en qué página estamos y así añadir la clase active dinámicamente. Esto es posible gracias a la flexibilidad de los bloques Jinja2.

Para ello, usamos request.endpoint, que nos devuelve el nombre de la función de la ruta actual. Por ejemplo, si estamos en la página de inicio, request.endpoint será 'inicio'.

Modificamos header.html de la siguiente manera:

<nav class="navbar navbar-expand-lg navbar-light bg-light">
  <a class="navbar-brand" href="{{ url_for('inicio') }}">📞 Directorio</a>
  <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarNav">
    <span class="navbar-toggler-icon"></span>
  </button>
  <div class="collapse navbar-collapse" id="navbarNav">
    <ul class="navbar-nav ml-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 == 'soporte' %}active{% endif %}">
        <a class="nav-link" href="{{ url_for('soporte') }}">Soporte</a>
      </li>
    </ul>
  </div>
</nav>

 

💡 ¿Qué hace este código? Para cada elemento del menú, comprobamos si el request.endpoint coincide con el nombre de la función de la ruta. Si es así, añadimos la clase active. Si no, no se añade nada.

Fíjate aquí por ejemplo:

<li class="nav-item {% if request.endpoint == 'agregar' %}active{% endif %}"> <a class="nav-link" href="{{ url_for('agregar') }}">Agregar</a> </li>

Si leemos de izquierda a derecha tenemos un «li» con la clase que indica a Bootstrap que es un «item» de la barra de navegación, pero inmediatamente inicia un Bloque Jinja2 con un condicional que comprueba el «request.endpoint» y lo compara («==«) diciendo, si el endpoint o final de nuestra ruta es igual a «agregar» el resultado de esta función entonces aplica «active» seguido de la clase, sino, no hacer nada. Y luego ya continua el clásico a href nuestro enlace url_for.

Entonces sencillamente se consulta mediante «request.endpoint» donde diablos está el usuario, y si está en agregar se marca como activo. Lo mismo para cada elemento, entonces si estoy en inicio se marcará inicio y así sucesivamente.

Y habrás notado lo fácil que resulta meter condicionales en el HTML. Pero no hay que hacer abuso de ellos, solo para casos particulares en los que nos sentimos obligados a incrustarlos en la plantilla. Lo ideal es procesar el código en la parte del Controlador y no en las Vistas. Sin embargo, en situaciones como esta, los bloques Jinja2 nos dan la flexibilidad que necesitamos.

 

🎨 Estilos personalizados con CSS en nuestra aplicación Flask

Css personalizado en flask

Nuestra aplicación ya es modular y funcional, pero la barra de navegación sigue siendo la fea por defecto de Bootstrap. Vamos a darle nuestro toque personal con un diseño más limpio y profesional.

Para ello, vamos a realizar tres pasos sencillos:

  1. Crear la carpeta static/css/ dentro de nuestro proyecto.
  2. Crear el archivo style.css dentro de esa carpeta.
  3. Enlazar el CSS en base.html usando url_for.

Vamos paso a paso. Primero, en la raíz de tu proyecto (donde están controlador.py y la carpeta templates/), crea una carpeta llamada staticdentro de ella otra llamada css.

 

La estructura debería quedar así:

mi_aplicacion_flask/
├── controlador.py
├── models.py
├── DB/
│   └── base_de_datos.json
├── templates/
│   ├── base.html
│   ├── header.html
│   ├── footer.html
│   ├── index.html
│   ├── editar_contacto.html
│   └── agregar_contacto.html
└── static/
    └── css/
        └── style.css   <-- ¡Aquí van nuestros estilos personalizados!

Ahora, dentro de static/css/style.css, vamos a pegar el siguiente código.

He creado un estilo con tonos grises y un toque de verde suave, que combina con la temática de Pythones sin ser demasiado llamativo. Puedes usar el tuyo propio o copiar este:

/* ============================================================
   ESTILOS PERSONALIZADOS - Pythones Flask App
   ============================================================ */

/* ---- Barra de navegación ---- */
.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;
}

/* Elemento activo del menú */
.navbar .navbar-nav .nav-item.active .nav-link {
    color: #27ae60;
    font-weight: 600;
    border-bottom: 2px solid #27ae60;
    padding-bottom: 0.3rem;
}

/* ---- Pie de página ---- */
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;
}

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

main {
    flex: 1;
}

/* Tablas con un poco más de estilo */
.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;
}

/* Botones mejorados */
.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;
}

/* Títulos */
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;
}

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

💡 ¿Qué hace este CSS?

  • Fondo claro para la barra de navegación y el footer, con bordes sutiles.
  • Verde suave (#27ae60) para los enlaces activos y hover, sin ser estridente.
  • Sombra ligera en la barra de navegación para darle profundidad.
  • Elemento activo del menú subrayado con una línea verde.
  • Tablas y botones con bordes redondeados y sombras suaves para un look más moderno.
  • Tipografía limpia y espaciado generoso para mejorar la legibilidad.

Ahora, el paso más importante: vincular este CSS en nuestra plantilla base. Abre templates/base.html y añade esta línea dentro del <head>, justo después del enlace a Bootstrap:

<!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>
    
    <!-- Bootstrap CSS (CDN) -->
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css">
    
    <!-- ✅ CSS PERSONALIZADO -->
    <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body>
    {% include 'header.html' %}

    <main class="container mt-4">
        {% block content %}{% endblock %}
    </main>

    {% include 'footer.html' %}

    <!-- Bootstrap JS -->
    <script src="https://code.jquery.com/jquery-3.4.1.slim.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js"></script>
    <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/js/bootstrap.min.js"></script>
</body>
</html>

🎯 ¿Qué estamos haciendo aquí? Usamos {{ url_for('static', filename='css/style.css') }} para que Flask genere la ruta correcta al archivo CSS. Así, no importa dónde esté tu aplicación, siempre encontrará el archivo.

nuevo-estilo-css-personalizado-bootstrap-jinja2

Y ahora, si recargas tu aplicación, verás que la barra de navegación (header) y el footer han cambiado completamente. ¡Se ve mucho más profesional y limpio! 🎉

💡 Tip de diseño: Usar colores neutros con un toque de verde da una sensación de confianza y profesionalismo. Es el estilo que usan muchas startups y aplicaciones SaaS modernas. ¡Menos es más!

 

🎯 Resultado final

¡Y ya está! Hemos convertido nuestra aplicación del directorio telefónico en una aplicación modular, profesional y con estilo. Ahora:

  • ✅ El header y footer están en archivos separados y se reutilizan en todas las páginas.
  • ✅ Los enlaces se generan dinámicamente con url_for.
  • ✅ La página activa se resalta automáticamente con request.endpoint.
  • ✅ La aplicación tiene un diseño personalizado con Bootstrap y CSS propio.

💡 Créeme, tu mente va a volar pensando todo lo que puedes hacer a partir de aquí con un poquito de tiempo libre y código Python3. 🚀

Con esto, ya tienes una base sólida para organizar cualquier proyecto Flask de forma profesional usando bloques Jinja2. En el próximo post, profundizaremos en Bootstrap, formularios de contacto y en cómo enviar correos desde Flask con Flask-Mail.

Código final de nuestra aplicación CRUD con Flask Jinja2 + Bootstrap

✅ Código completo FINAL del proyecto (con CSS personalizado y menú activo)
📁 Información de la versión final

Esta es la versión completa del proyecto al finalizar el tutorial. Incluye:

  • ✅ CSS personalizado en static/css/style.css y enlazado en base.html.
  • ✅ Menú activo dinámico con request.endpoint en header.html.
  • ✅ Todos los botones de la tabla lateral (Ver, Editar, Eliminar, Agregar) con url_for.
🐍 controlador.py
# ============================================================
# controlador.py - Aplicación Flask con CRUD sobre JSON
# ============================================================
# Este archivo actúa como el CONTROLADOR en el patrón MVC.
# Gestiona las peticiones HTTP, orquesta la lógica de negocio
# (usando el modelo Persona) y renderiza las vistas (templates).
# ============================================================

from flask import Flask, redirect, request, render_template
from models import Persona  # Importamos el modelo para acceder a los datos

app = Flask(__name__)  # Instancia de la aplicación Flask


# ============================================================
# RUTA PRINCIPAL (READ - Listar todos los contactos)
# ============================================================
@app.route('/', methods=['GET'])
def inicio():
    """
    Página de inicio de la aplicación.
    - Método: GET
    - Función: Obtiene todas las personas almacenadas en la base de datos JSON
      mediante el método leer_contacto('id', 'all') del modelo.
    - Renderiza: index.html pasando el diccionario 'Todos' con todas las personas
      y 'index_ver=0' para que por defecto se muestre la primera persona.
    """
    Todos = Persona.leer_contacto('id', 'all')
    return render_template('index.html', Todos=Todos, index_ver=0)


# ============================================================
# RUTA VER (READ - Mostrar un contacto específico)
# ============================================================
@app.route('/ver/<int:index>', methods=['GET'])
def ver(index):
    """
    Muestra los detalles completos de una persona en particular.
    - Método: GET
    - Parámetro URL: index (entero) → posición de la persona en el diccionario.
    - Función: Recarga la misma vista index.html pero con 'index_ver' igual al
      índice recibido, lo que activa el condicional en la plantilla para mostrar
      solo esa persona.
    """
    Todos = Persona.leer_contacto('id', 'all')
    return render_template('index.html', Todos=Todos, index_ver=index)


# ============================================================
# RUTA EDITAR (UPDATE - Modificar un contacto)
# ============================================================
@app.route('/editar/<int:index>', methods=['GET', 'POST'])
def editar(index):
    """
    Permite modificar los datos de una persona existente.
    - Métodos: GET y POST
    - Parámetro URL: index (entero) → posición de la persona a editar.
    - GET: Muestra el formulario con los datos actuales de la persona.
    - POST: Recibe los datos del formulario, actualiza el registro en la base de
      datos JSON mediante el modelo, y redirige a la misma vista para reflejar
      los cambios.
    """
    Todos = Persona.leer_contacto('id', 'all')
    if request.method == 'POST':
        # Obtenemos los datos enviados desde el formulario HTML
        id = int(request.form['id'])          # ID oculto de la persona
        Nombre = request.form['Nombre']
        Apellido = request.form['Apellido']
        Apodo = request.form['Apodo']
        Telefono = request.form['Telefono']
        Direccion = request.form['Direccion']

        # Llamamos al modelo para actualizar cada atributo
        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)
        # Podríamos redirigir a la misma página de edición para mostrar los cambios
        # return redirect(f'/editar/{index}')
    else:
        # Si es GET, solo mostramos el formulario sin procesar nada
        pass

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


# ============================================================
# RUTA AGREGAR (CREATE - Crear un nuevo contacto)
# ============================================================
@app.route('/agregar', methods=['GET', 'POST'])
def agregar():
    """
    Permite añadir una nueva persona a la base de datos.
    - Métodos: GET y POST
    - GET: Muestra un formulario vacío para que el usuario ingrese los datos.
    - POST: Recibe los datos del formulario, crea una nueva instancia de Persona
      con esos valores y llama a crear_contacto() para guardarla en el JSON.
      Luego redirige a la misma URL (GET) para recargar la página y mostrar la
      lista actualizada con el nuevo contacto.
    """
    Todos = Persona.leer_contacto('id', 'all')
    if request.method == 'POST':
        # Obtenemos los datos del formulario
        Nombre = request.form['Nombre']
        Apellido = request.form['Apellido']
        Apodo = request.form['Apodo']
        Telefono = request.form['Telefono']
        Direccion = request.form['Direccion']

        # Creamos un objeto Persona y lo persistimos
        nuevo_contacto = Persona(Nombre, Apellido, Apodo, Telefono, Direccion)
        nuevo_contacto.crear_contacto()

        # Redirigimos a la misma página para que se recargue con la lista actualizada
        return redirect('agregar')
    else:
        pass

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


# ============================================================
# RUTA ELIMINAR (DELETE - Borrar un contacto)
# ============================================================
@app.route('/eliminar/<int:id>', methods=['GET'])
def eliminar(id):
    """
    Elimina una persona de la base de datos mediante su ID.
    - Método: GET (por simplicidad, aunque lo correcto sería DELETE)
    - Parámetro URL: id (entero) → identificador único de la persona.
    - Función: Llama al método eliminar_contacto(id) del modelo y redirige a la
      página principal para que se actualice la lista de contactos.
    """
    Persona.eliminar_contacto(id)
    return redirect('/')


# ============================================================
# RUTA DE AGRADECIMIENTO (Ejemplo adicional)
# ============================================================
@app.route('/agradecimiento', methods=['GET'])
def agradecer():
    return 'Gracias pythones.net!'


# ============================================================
# INICIO DE LA APLICACIÓN
# ============================================================
if __name__ == '__main__':
    app.run('127.0.0.1', 5000, debug=True)
🗄️ models.py
# ============================================================
# models.py - Modelo de datos para la aplicación Flask
# ============================================================
# Este archivo actúa como el MODELO en el patrón MVC.
# Contiene la lógica de negocio y el acceso a la base de datos
# (en este caso, un archivo JSON que funciona como almacenamiento persistente).
# ============================================================

import os
import json

# ============================================================
# CONFIGURACIÓN DE LA RUTA DEL ARCHIVO JSON
# ============================================================

# Obtenemos la ruta absoluta del directorio donde se encuentra este script
THIS_FOLDER = os.path.dirname(os.path.abspath(__file__))
# Construimos la ruta al archivo base_de_datos.json dentro de la carpeta /DB/
my_file = os.path.join(THIS_FOLDER + '/DB/' 'base_de_datos.json')


# ============================================================
# FUNCIONES DE ACCESO A DATOS (Persistencia)
# ============================================================

def leer_json():
    """
    Lee el archivo JSON y devuelve su contenido como un diccionario de Python.
    - Abre el archivo en modo lectura ('r').
    - Carga los datos con json.load().
    - Cierra el archivo automáticamente.
    - Retorna el diccionario 'datos'.
    """
    with open(my_file, "r") as f:
        datos = json.load(f)
        f.close()
        return datos

# Cargamos los datos al iniciar el módulo (se ejecuta una sola vez)
datos = leer_json()


def modificar_json():
    """
    Guarda el diccionario 'datos' actualizado en el archivo JSON.
    - Abre el archivo en modo escritura ('w').
    - Escribe los datos con formato indentado (4 espacios) para legibilidad.
    - Cierra el archivo.
    - Esta función se llama después de cada operación que modifica los datos.
    """
    with open(my_file, "w") as modid:
        json.dump(datos, modid, indent=4)
        modid.close()


# ============================================================
# CLASE PERSONA - REPRESENTA UNA ENTIDAD DEL DOMINIO
# ============================================================

class Persona(object):
    """
    Clase que modela a una persona (contacto) en el directorio telefónico.
    - Atributos: id, nombre, apellido, apodo, telefono, direccion.
    - Proporciona métodos CRUD para gestionar instancias en la base de datos JSON.
    - El 'id' es autoincremental y se gestiona mediante 'contador_id'.
    """

    # Variable de clase que almacena el último ID asignado (se sincroniza con el JSON)
    contador_id = datos["Configuraciones"][0]["contador_id_db"]

    def __init__(self, nombre, apellido, apodo, telefono, direccion):
        """
        Constructor de la clase Persona.
        - Asigna un ID único tomado de contador_id.
        - Almacena los demás atributos proporcionados.
        - Imprime en consola el ID asignado (útil para depuración).
        """
        print("El contador está en: ", Persona.contador_id)
        self.id = Persona.contador_id          # ID único autoincremental
        self.nombre = nombre
        self.apellido = apellido
        self.apodo = apodo
        self.telefono = telefono
        self.direccion = direccion

    # ============================================================
    # MÉTODO CREATE - Crear un nuevo contacto
    # ============================================================

    def crear_contacto(self):
        """
        Guarda la instancia actual como un nuevo registro en la base de datos JSON.
        - Incrementa el contador de IDs en el archivo JSON y en la variable de clase.
        - Convierte la instancia a diccionario (__dict__) y la añade a la lista 'Personas'.
        - Persiste los cambios llamando a modificar_json().
        - Imprime el nuevo valor del contador (para depuración).
        """
        # Actualizamos el contador en la base de datos
        datos["Configuraciones"][0]["contador_id_db"] += 1
        modificar_json()
        print("El contador está ahora en: ", datos["Configuraciones"][0]["contador_id_db"])
        Persona.contador_id += 1  # Sincronizamos la variable de clase

        # Creamos una nueva instancia (esto es necesario para que __init__ asigne el ID correcto)
        # Pero aquí reutilizamos la instancia actual: convertimos self a diccionario
        nueva_persona = Persona(
            self.nombre,
            self.apellido,
            self.apodo,
            self.telefono,
            self.direccion
        ).__dict__

        # Añadimos el diccionario a la lista de personas
        datos['Personas'].append(nueva_persona)
        modificar_json()  # Guardamos los cambios

    # ============================================================
    # MÉTODO READ - Leer contactos (búsqueda)
    # ============================================================

    def leer_contacto(atr, valor):
        """
        Busca personas según un atributo y un valor dado.
        - Si valor == 'all': devuelve todas las personas con su índice como clave.
        - Si valor != 'all': busca personas donde el atributo 'atr' sea igual a 'valor'.
        - Retorna un diccionario {índice: persona_diccionario} con los resultados.
        - Este método es de clase (@staticmethod de facto) porque no necesita una instancia.
        """
        if valor == 'all':
            # Devuelve todas las personas con su índice como clave
            todas = {}
            for i, persona in enumerate(datos["Personas"]):
                todas[i] = persona
            return todas
        else:
            # Búsqueda por atributo específico
            encontradas = {}
            for persona in datos["Personas"]:
                if persona[atr] == valor:
                    indice = datos["Personas"].index(persona)
                    encontradas[indice] = persona
            return encontradas

    # ============================================================
    # MÉTODO UPDATE - Actualizar un contacto existente
    # ============================================================

    def actualizar_contacto(id, atr, nuevo_valor):
        """
        Actualiza un atributo específico de una persona identificada por su ID.
        - Itera sobre la lista de personas hasta encontrar el ID.
        - Una vez encontrado, obtiene su índice y modifica el atributo indicado.
        - Persiste los cambios llamando a modificar_json().
        - No retorna nada (la actualización se hace in-place).
        """
        for persona in datos["Personas"]:
            if persona["id"] == id:
                print(persona)  # Mostramos el estado anterior (depuración)
                indice = datos["Personas"].index(persona)
                datos["Personas"][indice][atr] = nuevo_valor
                print(datos["Personas"][indice][atr])  # Mostramos el nuevo valor (depuración)
                modificar_json()
                break  # Salimos del bucle tras encontrar y actualizar

    # ============================================================
    # MÉTODO DELETE - Eliminar un contacto
    # ============================================================

    def eliminar_contacto(id):
        """
        Elimina la persona con el ID especificado de la base de datos.
        - Itera sobre la lista de personas hasta encontrar el ID.
        - Una vez encontrado, obtiene su índice y lo elimina con pop().
        - Persiste los cambios llamando a modificar_json().
        - Muestra un mensaje en consola con la persona eliminada (depuración).
        """
        for persona in datos["Personas"]:
            if persona["id"] == id:
                print("Se va a borrar: ", persona)
                indice = datos["Personas"].index(persona)
                datos["Personas"].pop(indice)
                modificar_json()
                break  # Salimos tras eliminar
📄 base_de_datos.json (estructura inicial)
{
  "Configuraciones": [
    {
      "contador_id_db": 2
    }
  ],
  "Personas": [
    {
      "id": 0,
      "nombre": "Juan",
      "apellido": "Pérez",
      "apodo": "juanpe",
      "telefono": "123456789",
      "direccion": "Calle Falsa 123"
    },
    {
      "id": 1,
      "nombre": "María",
      "apellido": "Gómez",
      "apodo": "mary",
      "telefono": "987654321",
      "direccion": "Avenida Siempreviva 456"
    }
  ]
}
🧩 base.html – Plantilla base (con CSS personalizado)
<!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>
    
    <!-- Bootstrap CSS (CDN) -->
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css" 
          integrity="sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh" 
          crossorigin="anonymous">

    <!-- ✅ CSS PERSONALIZADO (se carga DESPUÉS de Bootstrap para sobrescribir) -->
    <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body>
    {% include 'header.html' %}  <!-- 🔝 Barra de navegación común -->

    <main class="container mt-4">
        {% block content %}{% endblock %}  <!-- 🔥 Contenido específico de cada página -->
    </main>

    {% include 'footer.html' %}  <!-- 👣 Pie de página común -->

    <!-- Bootstrap JS (jQuery, Popper, Bootstrap) -->
    <script src="https://code.jquery.com/jquery-3.4.1.slim.min.js" 
            integrity="sha384-J6qa4849blE2+poT4WnyKhv5vZF5SrPo0iEjwBvKU7imGFAV0wwj1yYfoRSJoZ+n" 
            crossorigin="anonymous"></script>
    <script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js" 
            integrity="sha384-Q6E9RHvbIyZFJoft+2mJbHaEWldlvI9IOYy5n3zV9zzTtmI3UksdQRVvoxMfooAo" 
            crossorigin="anonymous"></script>
    <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/js/bootstrap.min.js" 
            integrity="sha384-wfSDF2E50Y2D1uUdj0O3uMBJnjuUD4Ih7YwaYd1iqfktj0Uod8GCExl3Og8ifwB6" 
            crossorigin="anonymous"></script>
</body>
</html>
🔝 header.html – Barra de navegación con menú activo
<nav class="navbar navbar-expand-lg navbar-light bg-light">
  <a class="navbar-brand" href="{{ url_for('inicio') }}">📞 Directorio</a>
  <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarNav">
    <span class="navbar-toggler-icon"></span>
  </button>
  <div class="collapse navbar-collapse" id="navbarNav">
    <ul class="navbar-nav ml-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 == 'soporte' %}active{% endif %}">
        <a class="nav-link" href="{{ url_for('soporte') }}">Soporte</a>
      </li>
    </ul>
  </div>
</nav>
👣 footer.html – Pie de página
<footer class="text-center py-3 mt-4 border-top">
    <p class="mb-0">&copy; 2026 - Directorio Telefónico con Flask </p>
</footer>
🎨 static/css/style.css – Estilos personalizados
/* ============================================================
   ESTILOS PERSONALIZADOS - Pythones Flask App
   ============================================================ */

/* ---- Barra de navegación ---- */
.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;
}

/* Elemento activo del menú */
.navbar .navbar-nav .nav-item.active .nav-link {
    color: #27ae60;
    font-weight: 600;
    border-bottom: 2px solid #27ae60;
    padding-bottom: 0.3rem;
}

/* ---- Pie de página ---- */
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;
}

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

main {
    flex: 1;
}

/* Tablas con un poco más de estilo */
.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;
}

/* Botones mejorados */
.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;
}

/* Títulos */
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;
}

/* Alertas y mensajes */
.alert {
    border-radius: 8px;
    border: none;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
🏠 index.html – Página de inicio (extiende base.html)
{% extends "base.html" %}

{% block title %}Inicio - Directorio Telefónico{% endblock %}

{% block content %}

<h1 style="text-align:center;">Directorio Telefónico - Mi primera aplicación en Flask</h1>
<br>

<div>
    <!-- DIV IZQUIERDA -->
    <div style="width: 60%; float:left; text-align:center;">
        <h2>Datos completos de la persona</h2>
        <table border="1" style="width: 100%;">
            <tr>
                <td>Nombre</td>
                <td>Apellido</td>
                <td>Apodo</td>
                <td>Teléfono</td>
                <td>Dirección</td>
            </tr>
            {% for (index, persona) in Todos.items() %}
            {% if index == index_ver %}
            <tr>
                <td>{{persona.nombre}}</td>
                <td>{{persona.apellido}}</td>
                <td>{{persona.apodo}}</td>
                <td>{{persona.telefono}}</td>
                <td>{{persona.direccion}}</td>
            </tr>
            {% endif %}
            {% endfor %}
        </table>
    </div>

    <!-- DIV DERECHA -->
    <div style="width: 40%; float:right; text-align:center;">
        <h2>Personas</h2>
        <table border="1" style="width: 100%;">
            {% for (index, persona) in Todos.items() %}
            <tr>
                <td>{{persona.nombre}}, {{persona.apellido}}</td>
                <td><a href="{{url_for('ver', index = index)}}"><button name="ver">Ver</button></a></td>
                <td><a href="{{url_for('editar', index = index)}}"><button name="editar">Editar</button></a></td>
                <td><a href="{{url_for('eliminar', id = persona.id)}}"><button name="eliminar">Eliminar</button></a></td>
            </tr>
            {% endfor %}
            <tr>
                <td><a href="{{url_for('agregar')}}"><button name="agregar">Agregar</button></a></td>
            </tr>
        </table>
    </div>
</div>

{% endblock %}
✏️ editar_contacto.html – Editar contacto (extiende base.html)
{% extends "base.html" %}

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

{% block content %}

<h1 style="text-align:center;">Directorio Telefónico - Editar contacto</h1>
<br>

<div>
    <!-- DIV IZQUIERDA (datos actuales) -->
    <div style="width: 60%; float:left; text-align:center;">
        <h2>Datos completos de la persona</h2>
        <table border="1" style="width: 100%;">
            <tr>
                <td>Nombre</td>
                <td>Apellido</td>
                <td>Apodo</td>
                <td>Teléfono</td>
                <td>Dirección</td>
            </tr>
            {% for (index, persona) in Todos.items() %}
            {% if index == index_ver %}
            <tr>
                <td>{{persona.nombre}}</td>
                <td>{{persona.apellido}}</td>
                <td>{{persona.apodo}}</td>
                <td>{{persona.telefono}}</td>
                <td>{{persona.direccion}}</td>
            </tr>
            {% endif %}
            {% endfor %}
        </table>
    </div>

    <!-- DIV DERECHA (lista de personas) -->
    <div style="width: 40%; float:right; text-align:center;">
        <h2>Personas</h2>
        <table border="1" style="width: 100%;">
            {% for (index, persona) in Todos.items() %}
            <tr>
                <td>{{persona.nombre}}, {{persona.apellido}}</td>
                <td><a href="{{url_for('ver', index = index)}}"><button name="ver">Ver</button></a></td>
                <td><a href="{{url_for('editar', index = index)}}"><button name="editar">Editar</button></a></td>
                <td><a href="{{url_for('eliminar', id = persona.id)}}"><button name="eliminar">Eliminar</button></a></td>
            </tr>
            {% endfor %}
            <tr>
                <td><a href="{{url_for('agregar')}}"><button name="agregar">Agregar</button></a></td>
            </tr>
        </table>
    </div>
</div>

<!-- SECCIÓN DE EDICIÓN (formulario) -->
<div style="clear:both; text-align:center; margin-top:40px; border-top:2px solid #ccc; padding-top:20px;">
    <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">
        <table border="1" style="width: 80%; margin: 0 auto;">
            <tr>
                <td>Nombre</td>
                <td>Apellido</td>
                <td>Apodo</td>
                <td>Teléfono</td>
                <td>Dirección</td>
            </tr>
            <tr>
                <input type="hidden" name="id" value="{{Todos[index_ver].id}}">
                <td><input type="text" name="Nombre" value="{{Todos[index_ver].nombre}}" style="max-width: 100px;"></td>
                <td><input type="text" name="Apellido" value="{{Todos[index_ver].apellido}}" style="max-width: 100px;"></td>
                <td><input type="text" name="Apodo" value="{{Todos[index_ver].apodo}}" style="max-width: 100px;"></td>
                <td><input type="tel" name="Telefono" value="{{Todos[index_ver].telefono}}" style="max-width: 100px;"></td>
                <td><input type="text" name="Direccion" value="{{Todos[index_ver].direccion}}" style="max-width: 100px;"></td>
            </tr>
        </table>
        <br>
        <button type="submit">Modificar</button>
    </form>
</div>

{% endblock %}
➕ agregar_contacto.html – Agregar contacto (extiende base.html)
{% extends "base.html" %}

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

{% block content %}

<h1 style="text-align:center;">Directorio Telefónico - Agregar contacto</h1>
<br>

<div>
    <!-- DIV IZQUIERDA (formulario) -->
    <div style="width: 60%; float:left; text-align:center;">
        <h2>Agregar Persona</h2>
        <table border="1" style="width: 100%;">
            <tr>
                <td>Nombre</td>
                <td>Apellido</td>
                <td>Apodo</td>
                <td>Teléfono</td>
                <td>Dirección</td>
            </tr>
            <form action="{{url_for('agregar')}}" method="post">
            <tr>
                <td><input type="text" name="Nombre" placeholder="Nombre" style="max-width: 100px;"></td>
                <td><input type="text" name="Apellido" placeholder="Apellido" style="max-width: 100px;"></td>
                <td><input type="text" name="Apodo" placeholder="Apodo" style="max-width: 100px;"></td>
                <td><input type="tel" name="Telefono" placeholder="Teléfono" style="max-width: 100px;"></td>
                <td><input type="text" name="Direccion" placeholder="Dirección" style="max-width: 100px;"></td>
            </tr>
        </table>
        <br>
        <button type="submit">Agregar</button>
        </form>
    </div>

    <!-- DIV DERECHA (lista de personas) -->
    <div style="width: 40%; float:right; text-align:center;">
        <h2>Personas</h2>
        <table border="1" style="width: 100%;">
            {% for (index, persona) in Todos.items() %}
            <tr>
                <td>{{persona.nombre}}, {{persona.apellido}}</td>
                <td><a href="{{url_for('ver', index = index)}}"><button name="ver">Ver</button></a></td>
                <td><a href="{{url_for('editar', index = index)}}"><button name="editar">Editar</button></a></td>
                <td><a href="{{url_for('eliminar', id = persona.id)}}"><button name="eliminar">Eliminar</button></a></td>
            </tr>
            {% endfor %}
            <tr>
                <td><a href="{{url_for('agregar')}}"><button name="agregar">Agregar</button></a></td>
            </tr>
        </table>
    </div>
</div>

{% endblock %}
❓ ¿Cómo te ha ido? Preguntas frecuentes sobre Jinja2 y Flask
1️⃣ ¿Qué nombre usar en url_for()?

Debe coincidir exactamente con el nombre de la función en controlador.py. Ej: si tienes def inicio(): usás {{ url_for('inicio') }}, no la URL.

2️⃣ ¿Por qué mi menú no se marca como activo?

Porque no usaste el condicional {% if request.endpoint == 'nombre_funcion' %}active{% endif %} en cada <li> de header.html. Revisá el ejemplo del post.

3️⃣ ¿Dónde pongo mis archivos CSS y JS?

Dentro de static/css/ y static/js/. Los enlazás en base.html con {{ url_for('static', filename='css/style.css') }}.

4️⃣ ¿Puedo tener más de un bloque en base.html?

¡Sí! Podés definir todos los que quieras: {% block content %}, {% block scripts %}, etc. Cada página hija rellena los que necesita.

5️⃣ ¿Qué pasa si no defino un bloque en la página hija?

Se usa el contenido que tenga ese bloque en base.html (si tiene algo) o queda vacío. Es útil para poner contenido por defecto.

6️⃣ ¿Cómo activo el menú en subpáginas (ej. /inicio/usuario)?

Usá request.endpoint.startswith('inicio') en lugar de ==. Ej: {% if request.endpoint.startswith('inicio') %}active{% endif %}.

7️⃣ ¿Puedo incluir un archivo dentro de un bloque?

Sí, con {% include 'algo.html' %} dentro del bloque. Ideal para reutilizar componentes en secciones específicas.

8️⃣ Mi CSS no se carga, ¿qué reviso?

Verificá: (1) carpeta static/css/ en la raíz, (2) nombre del archivo style.css, (3) enlace en base.html después de Bootstrap, (4) servidor corriendo, (5) forzar recarga con Ctrl+F5.

 

🎯 Todos los elementos de Jinja2 – Resumen

Lo que hemos visto es solo el principio de lo que puedes lograr con los bloques Jinja2. Con ellos, puedes hacer cosas como:

  • Mostrar un bloque en unas páginas y ocultarlo en otras (por ejemplo, un sidebar que solo aparece en el blog si el usuario está logueado).
  • Que un bloque tenga contenido por defecto pero que las páginas hijas puedan sobrescribirlo si quieren.
  • Crear «recursividad» visual: puedes tener bloques dentro de bloques, como una muñeca rusa, para construir estructuras complejas de forma sencilla.
  • Reutilizar el mismo bloque en múltiples páginas sin tener que copiar y pegar el código HTML cada vez.

💡 Créeme, tu mente va a volar pensando todo lo que puedes hacer a partir de aquí con un poquito de tiempo libre y código Python3. 🚀

📚 Resumen de sintaxis Jinja2 – Guía rápida

Aquí tienes un resumen de todos los bloques Jinja2 y funciones que hemos visto en este post. Úsalo como referencia rápida cuando estés construyendo tus propias plantillas Jinja2.

Elemento Sintaxis ¿Para qué sirve?
Variable {{ variable }} Muestra el valor de una variable pasada desde el controlador con render_template().
Crear variable {% set nombre = "valor" %} Crea variables dentro del template (no persistentes, solo para el renderizado).
Condicional IF {% if condicion %} ... {% endif %} Muestra u oculta partes del HTML según una condición. Útil para decisiones de presentación.
Condicional con ELSE {% if condicion %} ... {% else %} ... {% endif %} Alternativa cuando la condición no se cumple.
Bucle FOR {% for item in lista %} ... {% endfor %} Itera sobre listas, diccionarios o cualquier objeto iterable para generar HTML repetitivo.
Herencia (extends) {% extends "base.html" %} Indica que una página hija hereda la estructura de una plantilla base. Opcionalmente, se puede usar {{ super() }} para extender bloques sin sobrescribir.
Bloque (block) {% block nombre %}{% endblock %} Define un «hueco» en la plantilla base que las páginas hijas pueden rellenar con su propio contenido.
Inclusión (include) {% include "header.html" %} Inserta el contenido de otro archivo HTML en el template actual. Perfecto para header, footer, etc.
URL dinámica {{ url_for('nombre_funcion') }} Genera URLs de forma segura usando el nombre de la función de la ruta, no la URL fija. Mejora mantenibilidad y seguridad.
URL con parámetros {{ url_for('ver', index=0) }} Genera URLs dinámicas con parámetros, como /ver/0.
Archivos estáticos {{ url_for('static', filename='css/style.css') }} Enlaza archivos estáticos (CSS, JS, imágenes) desde la carpeta static/.
Endpoint actual {{ request.endpoint }} Devuelve el nombre de la función de la ruta actual. Útil para saber en qué página estamos.
Filtro (ejemplo) {{ nombre|upper }} Modifica el valor de una variable con un filtro. Ej: |upper convierte a mayúsculas, |length cuenta caracteres, etc.

📌 Consejo: Guarda esta tabla como referencia. La sintaxis de Jinja2 es sencilla pero muy poderosa. Con estos elementos ya puedes construir cualquier interfaz web con Flask.

📅 Última actualización: agosto 5, 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