Saltar al contenido
Inicio 100 Días de Código en Python — Día 57: Páginas dinámicas con Jinja y tu primer blog

100 Días de Código en Python — Día 57: Páginas dinámicas con Jinja y tu primer blog

Nivel: Grado décimo Área: Tecnología e Informática Modalidad: Tutorial práctico Fuente: Curso "100 Days of Code: The Complete Python Pro Bootcamp" (Angela Yu, App Brewery). Contenido del Día 57 (nivel intermedio+).

Este tutorial adapta pedagógicamente el Día 57. Las secciones 📝 Contenido original del curso y 📝 Ejercicio original del curso provienen de las lecciones 406 a 411. Los archivos de inicio y la solución del proyecto del blog están en los recursos de la lección 411 y en el gist enlazado allí. Los retos 🟢🟡🟠🔴, la sección sobre datos que predicen personas, el proyecto extra y la etapa 4 del proyecto integrador son creaciones de esta adaptación. Las correcciones al material original aparecen como 🛠️ Nota técnica.

Nota sobre las verificaciones: el 27 de septiembre de 2026, con Python 3.14, Flask 3.1.3, Jinja 3.1.6 y requests 2.34, se comprobó: que agify.io y genderize.io responden sin clave, que agify informa un límite de 25 consultas por día en sus cabeceras, qué devuelven ambas con un nombre desconocido, que la API de n:point del blog (api.npoint.io/c790b4d5cab58020d391) sigue devolviendo las 3 entradas, y el comportamiento de url_for, del escape automático y de las comparaciones en Jinja. Todo el código del proyecto del día se ejecutó con el cliente de pruebas de Flask.


Tabla de contenidos

  1. Antes de empezar: una plantilla, muchas páginas
  2. Jinja: expresiones con {{ }} y datos desde Python
  3. Reto del curso: Jinja + APIs
  4. Bloques de varias líneas: {% for %} y {% if %}
  5. Construir URL con url_for
  6. 🚀 Proyecto del día: el blog, parte 1
  7. 🌟 Proyecto extra: el menú del restaurante escolar
  8. 🏗️ Proyecto integrador del bloque Flask — Etapa 4
  9. 🧩 Comprueba lo aprendido
  10. 🤖 Programar con apoyo de IA
  11. 🛠️ Correcciones técnicas al material original
  12. 📋 Matriz de trazabilidad
  13. 📦 Evidencias de aprendizaje del Día 57

0. Antes de empezar: una plantilla, muchas páginas

(Fuente: 406 - Day 57 Goals- what you will make by the end of the day.md)

📝 Contenido original del curso

Si tienes un blog con muchas entradas, no quieres un archivo HTML distinto para cada una. Un lenguaje de plantillas permite escribir una estructura (diseño, estilo) y reemplazar solo algunas partes (título, subtítulo, cuerpo) con contenido dinámico cada vez que se carga una página. Para Python, ese lenguaje es Jinja. Al final del día tendrás un blog sencillo: una portada con los títulos y una página de detalle por entrada, todas con el mismo diseño.

Prerequisitos: render_template, carpetas templates y static (Día 56), rutas con variables (Día 55), requests y APIs (Días 33 a 37), **kwargs (Día 27), datetime (Día 32).

📖 La idea en una imagen (adaptación)

Texto
        datos (Python)                 plantilla (HTML + Jinja)                página final
{"title": "The Life of Cactus"}  +   <h2>{{ post.title }}</h2>    ──▶   <h2>The Life of Cactus</h2>

Es la misma idea de las f-strings, pero para páginas enteras y en un archivo aparte.


1. Jinja: expresiones con {{ }} y datos desde Python

(Fuente: 407 - Using Jinja to Produce Dynamic HTML Pages.md)

🎯 Objetivo de aprendizaje

Insertar valores calculados en Python dentro de una plantilla HTML usando {{ }} y los argumentos de render_template.

📝 Contenido original del curso

Se parte de un server.py con "Hola mundo" (disponible en los recursos). Primer reto: crear templates/index.html con un <h1> y renderizarlo en lugar del texto.

Las llaves dobles. Si en el HTML escribes <h1>5 * 6</h1>, se ve el texto tal cual. Pero con <h1>{{ 5 * 6 }}</h1> se ve 30: lo que está entre {{ }} se evalúa y el resultado se inserta. "Por eso se llama lenguaje de plantillas": el HTML es la plantilla fija y solo las partes entre llaves cambian.

Datos desde el servidor. Si necesitas algo que requiere un import (por ejemplo, un número aleatorio), es mejor calcularlo en Python y enviarlo a la plantilla. render_template acepta, después del nombre de la plantilla, tantos argumentos de palabra clave como quieras (su firma tiene **context, igual que **kwargs). Cada argumento se convierte en una variable de la plantilla con ese nombre:

Python
import random
from flask import Flask, render_template

app = Flask(__name__)


@app.route("/")
def home():
    random_number = random.randint(1, 10)
    return render_template("index.html", num=random_number)
HTML
<h1>Hello World!</h1>
<h3>Random Number: {{ num }}</h3>

Cada recarga muestra un número distinto.

📝 Ejercicio original del curso: el año del pie de página

Muchos sitios (¡incluido el de Jinja!) tienen un pie de página "© 2019" que nadie actualiza porque es texto fijo. Hay hasta un sitio que hace campaña contra los pies de página desactualizados, y el suyo calcula el año con JavaScript. El reto: un <footer> con un párrafo que muestre el año actual calculado con Python.

Python
import datetime as dt

@app.route("/")
def home():
    random_number = random.randint(1, 10)
    current_year = dt.datetime.now().year
    return render_template("index.html", num=random_number, year=current_year)
HTML
<footer>
    <p>Copyright {{ year }}. Built by Angela.</p>
</footer>

📖 Explicación conceptual

Sintaxis de Jinja Para qué sirve Ejemplo
{{ ... }} Mostrar el resultado de una expresión {{ num }}, {{ 5 * 6 }}
{% ... %} Instrucciones (ciclos, condiciones) — sección 3 {% for post in posts %}
{# ... #} Comentario que no llega al navegador {# TODO: mejorar #}

Jinja se parece a Python, pero no es Python. Permite operaciones, acceder a atributos y llamar métodos ({{ nombre.title() }} funciona, verificado), pero no permite import ni definir funciones. La lógica va en server.py; la plantilla solo muestra.

Filtros. Jinja trae funciones que se aplican con una barra vertical: {{ nombre | title }} hace lo mismo que .title() (verificado), {{ lista | length }} cuenta elementos, {{ texto | upper }} pasa a mayúsculas.

Escape automático. Con render_template, Jinja escapa las variables: si una variable contiene <b>x</b>, la página muestra el texto <b>x</b>, no una negrita (verificado: sale &lt;b&gt;x&lt;/b&gt;). Es la protección contra XSS que en el Día 55 tenías que poner a mano con escape.

🛠️ Nota técnica: el video dice que lo que está entre llaves "se evalúa como código Python". Es una simplificación útil para empezar, pero son expresiones de Jinja: algo como {{ random.randint(1, 10) }} falla porque random no existe dentro de la plantilla. Por eso el curso calcula el número en el servidor.

⚠️ Errores frecuentes

Error Causa Solución
La página muestra {{ num }} literalmente Abriste el HTML con doble clic, no a través de Flask Visita http://127.0.0.1:5000/
La variable sale vacía, sin error El nombre en render_template no coincide con el de la plantilla (num= contra {{ numero }}) Usa exactamente el mismo nombre; Jinja no avisa si una variable no existe
jinja2.exceptions.UndefinedError: 'random' is undefined Intentaste usar un módulo de Python dentro de la plantilla Calcula en server.py y envía el resultado
TemplateSyntaxError Llaves mal cerradas: {{ num } o { num }} Revisa que abran y cierren igual

Retos adicionales (creados para esta adaptación)

  • 🟢 Reto 1 — Aplicación: envía a la plantilla tu nombre, tu edad y tu color favorito, y úsalos en un <h1>, un <p> y un style.
  • 🟡 Reto 2 — Ampliación: muestra cuántos días faltan para las vacaciones (calcula la diferencia de fechas en Python y envía solo el número).
  • 🟠 Reto 3 — Desafío: envía una lista de 5 frases motivadoras y muestra una al azar eligiéndola en Python. Luego muestra cuántas frases hay usando un filtro de Jinja.
  • 🔴 Reto 4 — Pensamiento computacional: decide para cada tarea si debe ir en server.py o en la plantilla, y justifica: (a) calcular un promedio de notas, (b) poner el promedio en negrita, (c) decidir si el estudiante aprobó, (d) pintar de rojo el promedio si reprobó.

2. Reto del curso: Jinja + APIs

(Fuente: 408 - Challenge- Combining Jinja Templating with APIs.md)

📝 Ejercicio original del curso

Dos API muy sencillas, sin registro ni autenticación:

  • agify.io: recibe un nombre y predice la edad (https://api.agify.io?name=...). Devuelve un JSON con el nombre, la edad estimada y cuántas veces se muestreó ese nombre (count).
  • genderize.io: recibe un nombre y predice el género (https://api.genderize.io?name=...).

La profesora bromea: según agify, las personas llamadas Angela tienen en promedio 63 años.

El reto: una ruta /guess/<name> que muestre:

  • "Hey Name," (con la primera letra en mayúscula),
  • "I think you are gender,"
  • "And maybe age years old."

Tiempo sugerido: 15 a 20 minutos, usando la documentación de Flask, de Jinja y de ambas API.

La solución del video:

  1. Ruta @app.route("/guess/<name>") y función guess(name).
  2. Plantilla guess.html con los tres marcadores.
  3. render_template("guess.html", person_name=name, gender=gender, age=age): el nombre del argumento es el que se usa en la plantilla (person_name) y el valor viene de la URL (name).
  4. En la plantilla, {{ person_name.title() }} pone la inicial en mayúscula.
  5. Con requests.get(f"https://api.genderize.io?name={name}") se obtiene el JSON y se extrae ["gender"]; lo mismo con agify y ["age"].

Resultado: "Hey Katie, I think you are female, and maybe 32 years old".

🔍 Antes de programar

  • ¿Qué información tenemos? Un nombre en la URL.
  • ¿Qué necesitamos obtener? Dos datos de dos API distintas.
  • ¿Dónde se hacen las solicitudes: en la plantilla o en el servidor?
  • ¿Qué pasa si la API no conoce el nombre, o si no hay Internet?

📖 Lo que devuelven hoy las API (verificado)

Texto
https://api.agify.io?name=katie      → {"count":16189,"name":"katie","age":44}
https://api.genderize.io?name=katie  → {"count":327174,"name":"katie","gender":"female","probability":1.0}
https://api.agify.io?name=xqzwv      → {"count":0,"name":"xqzwv","age":null}
https://api.agify.io?name=valentina&country_id=CO → {"count":1595,"name":"valentina","age":29,"country_id":"CO"}

Tres aprendizajes:

  1. Con un nombre desconocido, age y gender llegan como null (en Python, None). La página diría "maybe None years old".
  2. El parámetro country_id=CO ajusta la predicción a Colombia. Con requests es mejor pasarlo con params que armar la URL a mano:
Python
   respuesta = requests.get("https://api.agify.io", params={"name": name, "country_id": "CO"}, timeout=10)
  1. Las predicciones cambian con el tiempo: la profesora obtuvo 32 para Katie; hoy agify dice 44.

🛠️ Nota técnica (verificado): agify.io informa en sus cabeceras x-rate-limit-limit: 25: 25 consultas por día en el plan gratuito, contadas por dirección IP. En un salón, todos los computadores suelen salir a Internet con la misma IP, así que el grupo entero puede agotar el límite en minutos (y la API responde con error 429). Estrategias: guardar en un diccionario los nombres ya consultados para no repetirlos, usar datos de prueba mientras programas y hacer las consultas reales solo al final.

📖 ¿Qué tan confiable es "adivinar" a una persona? (adaptación)

Estas API no "saben" nada de ti: calculan estadísticas a partir de registros públicos donde aparece un nombre. Por eso:

  • Pueden equivocarse con cualquier persona concreta (hay muchas Katies de 15 años).
  • genderize solo contempla dos categorías y confunde nombres que se usan para varios géneros.
  • Usar estas predicciones para decidir algo sobre una persona (por ejemplo, qué publicidad mostrarle o si aceptar su solicitud) puede ser discriminatorio.

Discute con tu grupo: ¿para qué sí sería razonable usarlas? (Pista: estudiar tendencias de nombres en una población, no juzgar a un individuo.)

⚠️ Errores frecuentes

Error Causa Solución
KeyError: 'gender' La respuesta fue un error (por ejemplo, límite excedido) y no trae esa clave Revisa respuesta.status_code y usa datos.get("gender")
"maybe None years old" Nombre desconocido Condición en la plantilla o en el servidor (sección 3)
La página tarda varios segundos Dos solicitudes a Internet en cada visita Normal; puedes guardar resultados ya consultados
Error 429 Se agotó el límite diario Caché de resultados y datos de prueba

Retos adicionales (creados para esta adaptación)

  • 🟢 Reto 1: agrega la probabilidad de genderize como porcentaje ("con un 100 % de confianza").
  • 🟡 Reto 2: usa country_id=CO y compara la edad estimada de tres nombres con y sin el país.
  • 🟠 Reto 3: guarda los resultados en un diccionario cache = {} a nivel del módulo; si el nombre ya se consultó, no llames a la API. Imprime en la terminal "desde caché" o "desde la API".
  • 🔴 Reto 4 — Pensamiento computacional: diseña la respuesta de la página para los casos: nombre conocido, nombre desconocido (null), API sin conexión, límite agotado. Escríbelos como una tabla de pruebas antes de programarlos.

3. Bloques de varias líneas: {% for %} y {% if %}

(Fuente: 409 - Multiline Statements with Jinja.md)

🎯 Objetivo de aprendizaje

Recorrer listas y mostrar contenido condicional dentro de una plantilla.

📝 Contenido original del curso

Para instrucciones de varias líneas (ciclos, condiciones) se usa otra marca: {% %}. Para practicar, la profesora crea las entradas de un blog en n:point, un servicio que guarda un JSON y lo ofrece como API, sin iniciar sesión. Cada entrada tiene id, title, subtitle y body.

Python
@app.route("/blog")
def blog():
    blog_url = "https://api.npoint.io/c790b4d5cab58020d391"
    response = requests.get(blog_url)
    all_posts = response.json()
    return render_template("blog.html", posts=all_posts)

En blog.html, un ciclo crea un <h1> y un <h2> por entrada:

HTML
{% for blog_post in posts: %}
    <h1>{{ blog_post["title"] }}</h1>
    <h2>{{ blog_post["subtitle"] }}</h2>
{% endfor %}
  • Cada línea de "código" va dentro de su propio {% %}; las líneas de HTML con {{ }} van normales.
  • Como en HTML no hay sangría que marque el final, el ciclo se cierra con la palabra de Jinja endfor.

Resultado: "The Life of Cactus", "Top 15 Things to do When You are Bored" e "Introduction to Intermittent Fasting", cada uno con su subtítulo.

Condicionales: igual, con {% if %} y {% endif %}. Reto del video: mostrar solo la entrada con id igual a 2:

HTML
{% for blog_post in posts: %}
    {% if blog_post["id"] == 2: %}
        <h1>{{ blog_post["title"] }}</h1>
        <h2>{{ blog_post["subtitle"] }}</h2>
    {% endif %}
{% endfor %}

📖 Explicación conceptual

Los dos puntos son opcionales. Jinja acepta {% for post in posts: %} (como en el curso) y {% for post in posts %} (como en su documentación). Lo habitual es sin dos puntos.

Dos formas de leer un diccionario (ambas funcionan en Jinja): {{ post["title"] }} y {{ post.title }}.

Herramientas extra del ciclo (documentación de Jinja):

HTML
<ul>
{% for post in posts %}
    <li>{{ loop.index }}. {{ post.title }}</li>   {# loop.index cuenta desde 1 #}
{% else %}
    <li>Todavía no hay entradas.</li>             {# se muestra si la lista está vacía #}
{% endfor %}
</ul>

elif y else también existen: {% if nota >= 4 %}…{% elif nota >= 3 %}…{% else %}…{% endif %}.

¿Filtrar en la plantilla o en Python? El reto del id == 2 recorre las tres entradas en la plantilla para mostrar una. Funciona, pero cuando la tarea es elegir datos, suele ser más claro hacerlo en Python y enviar solo lo necesario. Lo verás en el proyecto del día.

⚠️ Errores frecuentes

Error Causa Solución
TemplateSyntaxError: Unexpected end of template ... 'endfor' Falta {% endfor %} o {% endif %} Cada bloque se cierra
TemplateSyntaxError con {{ for ... }} Usaste llaves dobles para una instrucción Instrucciones con {% %}
El if nunca se cumple, sin error Comparas un número con un texto (2 == "2") Revisa los tipos (sección 4 y proyecto del día)
Ciclo vacío sin mensaje La lista llegó vacía o con otro nombre {% else %} en el ciclo; revisa el nombre en render_template

Retos adicionales (creados para esta adaptación)

  • 🟢 Reto 1: numera las entradas con loop.index.
  • 🟡 Reto 2: muestra el título en negrita solo para las entradas con id par (blog_post.id % 2 == 0).
  • 🟠 Reto 3: en server.py, crea la lista filtrada [p for p in all_posts if p["id"] == 2] (Día 26) y envíala. Compara la plantilla con la del curso: ¿cuál es más fácil de leer?
  • 🔴 Reto 4: muestra una tabla HTML de notas (lista de diccionarios con nombre y nota) donde las notas menores de 3 salgan en rojo, y al final una fila con el promedio calculado en Python.

4. Construir URL con url_for

(Fuente: 410 - URL Building with Flask.md)

🎯 Objetivo de aprendizaje

Generar enlaces entre páginas con url_for, incluyendo valores para las rutas con variables.

📝 Contenido original del curso

Para enlazar la portada con la página del blog, en lugar de escribir la dirección a mano se usa url_for, disponible en todas las plantillas. Recibe el nombre de la función de Flask (no la ruta):

Python
@app.route("/blog")
def get_blog():
    ...
HTML
<a href="{{ url_for('get_blog') }}">Go To Blog</a>

(La profesora renombra la función a get_blog para que no se confunda con la ruta /blog. Como el atributo href usa comillas dobles, el nombre va entre comillas simples.)

Con parámetros: se agregan como argumentos de palabra clave, igual que en render_template. Para capturarlos, la ruta los declara con corchetes angulares y la función los recibe:

HTML
<a href="{{ url_for('get_blog', num=3) }}">Go To Blog</a>
Python
@app.route("/blog/<num>")
def get_blog(num):
    print(num)
    ...

Al hacer clic, la URL es /blog/3 y la terminal imprime 3: el valor viajó desde la plantilla hasta el servidor.

📖 Explicación conceptual

¿Por qué no escribir href="/blog/3" directamente? Porque si mañana la ruta cambia a /articulos/<num>, url_for genera automáticamente los enlaces nuevos en todas las plantillas; los escritos a mano quedarían rotos.

Comportamiento verificado con Flask 3.1:

En la plantilla Ruta de la función URL generada
url_for('get_blog') /blog /blog
url_for('get_blog', num=3) /blog (sin variable) /blog?num=3 ← va como parámetro de consulta
url_for('get_blog', num=3) /blog/<num> /blog/3
url_for('static', filename='css/styles.css') (ruta interna de Flask) /static/css/styles.css (Día 56)

La segunda fila explica un error común: si olvidas <num> en la ruta, el enlace igual funciona, pero el número queda en ?num=3 y tu función no lo recibe como parámetro.

⚠️ Errores frecuentes

Error Causa Solución
werkzeug.routing.exceptions.BuildError: Could not build url for endpoint 'blog' Pusiste el nombre de la ruta o una función que no existe El nombre exacto de la función
BuildError ... Did you forget to specify values ['num']? La ruta tiene <num> y no enviaste el valor url_for('get_blog', num=...)
href="{{ url_for("get_blog") }}" corta el atributo Comillas dobles dentro de comillas dobles Comillas simples adentro
La URL sale con ?num=3 La ruta no declara <num> Agrega la variable a la ruta

5. 🚀 Proyecto del día: el blog, parte 1

(Fuente: 411-blog-capstone-project-part-1-templating.md)

📝 Ejercicio original del curso

  1. Descarga los archivos de inicio de los recursos de la lección.
  2. Ejecuta main.py: debe verse el sitio con su estilo.
  3. Usando la API de las entradas del blog en n:point (https://www.npoint.io/docs/c790b4d5cab58020d391), muestra en la portada el título y el subtítulo de todas las entradas.
  4. Agrega un enlace "Read" al final de cada vista previa que lleve a una página con la entrada completa: title, subtitle y body. Cada entrada debe vivir en la ruta URL/post/blog_id.

🛠️ Nota técnica: el enlace del curso es la página de documentación de n:point. Los datos (JSON) están en https://api.npoint.io/c790b4d5cab58020d391 (verificado el 27/09/2026: 3 entradas con id 1, 2 y 3).

Problema y contexto

Un blog necesita que agregar una entrada no signifique crear un archivo HTML nuevo: los datos deben vivir aparte y las páginas deben generarse solas.

🔍 Antes de programar

  • ¿Cuántas plantillas necesitas? (Pista: una para la portada y una para cualquier entrada.)
  • ¿Cuándo descargas las entradas: al arrancar el servidor o en cada visita? ¿Qué ventaja y qué riesgo tiene cada opción?
  • En /post/<...>, ¿el id llega como número o como texto? ¿Con qué lo vas a comparar?
  • ¿Qué debe pasar en /post/99?

Requisitos funcionales

  1. Portada con título y subtítulo de cada entrada, generados con un ciclo.
  2. Un enlace "Read" por entrada construido con url_for.
  3. Ruta /post/<int:...> que muestre título, subtítulo y cuerpo de la entrada pedida.
  4. Una entrada inexistente devuelve un mensaje claro con código 404.

Restricciones técnicas

  • Una sola plantilla post.html para todas las entradas.
  • Nada de URL escritas a mano en los enlaces internos.
  • La búsqueda de la entrada se hace en Python, no en la plantilla.

Planificación

  1. Comprender los archivos de inicio (¿qué hay en templates y en static?). 2. Entradas: el JSON de n:point y el id de la URL. 3. Procesos: descargar → listar → buscar por id. 4. Salidas: portada y página de entrada. 5. Dividir en dos rutas. 6. Diseñar la plantilla de entrada. 7. Implementar. 8. Probar. 9. Corregir. 10. Mejorar.

Construcción paso a paso

  • Versión 1: la portada muestra {{ posts }} tal cual (para ver qué llegó).
  • Versión 2: ciclo con título y subtítulo.
  • Versión 3: enlaces "Read" con url_for a una ruta que solo imprime el id recibido.
  • Versión 4: la ruta busca la entrada y renderiza post.html.
  • Versión final: caso de entrada inexistente y revisión del diseño.

Una base para la ruta de detalle, para que la completes:

Python
@app.route("/post/<int:index>")
def show_post(index):
    for post in posts:
        if post["id"] == index:
            return render_template("post.html", post=post)
    return render_template("post.html", post=None), 404
  • <int:index> hace que index sea un número, comparable con post["id"] (que en el JSON es número).
  • Devolver una tupla (página, 404) le dice a Flask qué código de estado enviar.

🛠️ Nota técnica (verificado): si la ruta es /post/<index> sin int:, index llega como el texto "2", la comparación post["id"] == index es siempre False y la página no muestra nada, sin ningún error. Es uno de los errores más difíciles de encontrar de este proyecto.

Pruebas

Caso Resultado esperado (verificado con la API actual)
/ Los 3 títulos con sus subtítulos y 3 enlaces "Read"
Enlaces "Read" Apuntan a /post/1, /post/2 y /post/3
/post/2 "Top 15 Things to do When You are Bored" con su cuerpo
/post/99 Mensaje "No existe" y código 404
/post/abc 404 (lo rechaza el convertidor)
Subtítulo con apóstrofo (Don't know...) Se ve bien (Jinja lo escapa como &#39;)

🧪 Prueba y depuración

Python
@app.route("/")
def home():
    response = requests.get("https://www.npoint.io/docs/c790b4d5cab58020d391")
    return render_template("index.html", all_posts=response.json())

@app.route("/post/<index>")
def show_post(index):
    for post in posts:
        if post["id"] == index:
            requested = post
    return render_template("post.html", post=requested)
HTML
{% for post in posts %}
  <a href="/post/{{ post.id }}">Read</a>
{% endfor %}

Encuentra cinco problemas: la URL es la de la documentación y no la de la API; la variable se envía como all_posts pero la plantilla usa posts; falta int: en la ruta; si no hay coincidencia, requested no existe (UnboundLocalError); el enlace está escrito a mano en lugar de usar url_for.

⭐ Extensión del proyecto

  • Mostrar en la portada solo los primeros 100 caracteres del cuerpo, con un filtro de Jinja (truncate).
  • Un botón "Siguiente entrada" en cada página que use url_for con post.id + 1 (y que no aparezca en la última).
  • Adelanto del Día 59: investiga {% extends %} y {% block %} en la documentación de Jinja para no repetir el encabezado y el pie en cada plantilla.

6. 🌟 Proyecto extra: el menú del restaurante escolar

(Proyecto creado para esta adaptación; no existe en el curso original.)

Situación

El restaurante escolar publica el menú de la semana en una hoja pegada en la pared. La asociación de padres quiere verlo en el celular, con alertas de alérgenos.

Requisitos

  1. Los datos en un archivo menu.json (lista de días; cada día con dia, plato, bebida, postre y una lista alergenos). Crea al menos 5 días con datos ficticios.
  2. Portada / con una tabla de la semana generada con {% for %}; los días con alérgenos muestran un ícono ⚠️ (con {% if %}).
  3. Ruta /dia/<nombre> con el detalle de un día, enlazada con url_for desde la tabla.
  4. Ruta /sin/<alergeno> que muestre solo los días sin ese alérgeno (filtrado en Python con una comprensión de listas).
  5. El pie de página con el año actual y la fecha de la última actualización del archivo (os.path.getmtime).

Decisiones que debes tomar (y justificar)

  • ¿Qué pasa con /dia/Lunes contra /dia/lunes? ¿Cómo lo resuelves?
  • ¿Por qué leer un archivo local en lugar de una API? ¿Qué ganas y qué pierdes?

Casos de prueba

  • /sin/maní no muestra ningún día que tenga "maní" en alergenos.
  • /dia/domingo muestra un mensaje amable con código 404.
  • Si menu.json tiene una lista vacía, la portada dice "Menú no disponible" ({% else %} del ciclo).

7. 🏗️ Proyecto integrador del bloque Flask — Etapa 4

(Proyecto creado para esta adaptación. Descripción completa en el tutorial del Día 58.)

El Portal del Grupo — Etapa 4 (hoy):

  1. Mueve los datos de integrantes y actividades a un archivo datos.json y cárgalo al arrancar el servidor.
  2. /integrantes genera la lista con {% for %}; cada integrante enlaza a /integrante/<int:id> con url_for.
  3. /actividades muestra las actividades futuras y pasadas separadas (la clasificación se hace en Python con datetime).
  4. Pie de página con el año actual en todas las páginas.
  5. Reemplaza los decoradores de estilo del Día 55 por HTML en las plantillas: ¿qué ventaja tiene?

8. 🧩 Comprueba lo aprendido

Conceptuales

  1. ¿Qué diferencia hay entre {{ }} y {% %}?
  2. ¿Por qué el número aleatorio se calcula en server.py y no en la plantilla?
  3. ¿Qué recibe url_for: el nombre de la ruta o el de la función?
  4. ¿Qué hace Jinja con una variable que contiene <script>?
  5. ¿Por qué una predicción de edad por nombre no sirve para tomar decisiones sobre una persona?

Análisis de código

HTML
{% for n in numeros %}
  {% if n % 2 == 0 %}<b>{{ n }}</b>{% else %}{{ n }}{% endif %}
{% endfor %}

Si numeros = [1, 2, 3, 4], ¿qué HTML se genera?

Predicción

Python
@app.route("/")
def home():
    return render_template("index.html", nombre="ana maría")
HTML
<h1>{{ nombre.title() }} tiene {{ nombre | length }} caracteres</h1>

Depuración

HTML
{% for post in posts %}
    <h2>{{ post.title }}</h2>
    <a href="{{ url_for("show_post", index=post.id) }}">Leer</a>

Encuentra los dos errores.

Modificación

Cambia el reto de agify para que, si la edad llega como None, la página diga "No tengo datos suficientes para adivinar tu edad" (resuélvelo en la plantilla con {% if %}).

Creación

Crea una ruta /tabla/<int:n> que muestre la tabla de multiplicar de n del 1 al 10, generada con un {% for i in range(1, 11) %} en la plantilla.


9. 🤖 Programar con apoyo de IA

Ciclo de trabajo: formula el problema → inténtalo tú → consulta a la IA → lee y entiende → prueba → compara con la documentación oficial → modifica → explica con tus palabras.

Actividad 1 — Comprobar límites. Pregunta a una IA cuántas consultas gratuitas permite agify.io. Compruébalo tú mirando las cabeceras de la respuesta (respuesta.headers) y la página de precios del servicio. ¿Coinciden?

Actividad 2 — El error silencioso. Muestra a una IA tu ruta /post/<index> (sin int:) y di que "la página sale vacía". ¿Encuentra el problema de tipos? Si no, dale una pista y evalúa cuánto tardó.

Actividad 3 — Jinja no es Python. Pide a una IA una plantilla que use random para elegir una frase. Si la genera, ejecútala y explica el error con la nota técnica de la sección 1 y la documentación de Jinja ("Template Designer Documentation").


10. 🛠️ Correcciones técnicas al material original

Archivo Afirmación o comportamiento del curso Observación Tratamiento
407 Lo que está entre {{ }} "se evalúa como código Python" Son expresiones de Jinja: parecidas, pero sin import ni módulos Nota técnica de la sección 1
408 agify y genderize, sin registro ni autenticación Siguen sin clave, pero agify limita a 25 consultas diarias por IP (verificado); con nombres desconocidos devuelven null; las predicciones cambian con el tiempo Sección 2: nota técnica y tabla de respuestas
408 Armar la URL con f-string Mejor params= en requests.get (codifica tildes y espacios) Sección 2
409 {% for blog_post in posts: %} con dos puntos Jinja lo acepta, pero la sintaxis habitual es sin dos puntos Sección 3
410 url_for('get_blog', num=3) con la ruta /blog/<num> Si la ruta no declara <num>, el valor va como ?num=3 (verificado) Tabla de la sección 4
411 Enlace a https://www.npoint.io/docs/... Es la página de documentación; los datos están en https://api.npoint.io/... (verificado) Nota técnica de la sección 5
411 Ruta URL/post/blog_id Sin int:, el id llega como texto y la comparación con el número del JSON falla sin error (verificado) Nota técnica de la sección 5

11. 📋 Matriz de trazabilidad

Contenido original Archivo de origen Concepto desarrollado Ejercicio original Reto adicional Proyecto relacionado
Lenguaje de plantillas, Jinja, blog con portada y páginas de detalle 406 Sección 0 — — Sección 5
Archivos de inicio, index.html, {{ 5 * 6 }}, número aleatorio enviado con render_template, **context, reto del año en el pie de página con datetime 407 Sección 1 Renderizar index.html; año del pie de página Retos 1–4 de la sección 1; filtros y escape Secciones 5, 6 y 7
agify.io y genderize.io, ruta /guess/<name>, guess.html, person_name, .title(), requests y JSON 408 Sección 2 Reto de Jinja + API Retos 1–4 de la sección 2; ética de las predicciones —
{% %}, n:point, ruta /blog, requests.get(...).json(), {% for %} y endfor, {% if %} y endif, reto del id 2 409 Sección 3 Listar entradas; mostrar solo la 2 Retos 1–4 de la sección 3; loop.index, {% else %} Secciones 5 y 6
url_for, enlace "Go To Blog", renombrar a get_blog, comillas simples, parámetros, /blog/<num> 410 Sección 4 Enlace con parámetro Tabla de comportamiento de url_for Secciones 5, 6 y 7
Proyecto del blog, parte 1: archivos de inicio, API de n:point, títulos y subtítulos, enlace "Read", ruta /post/blog_id, solución 411 Sección 5 Proyecto del día Extensiones de la sección 5 Sección 5
(No existe en el curso original) — — — Menú del restaurante escolar Sección 6
(No existe en el curso original) — — — Portal del Grupo, etapa 4 Sección 7

12. 📦 Evidencias de aprendizaje del Día 57

  • El blog funcionando: capturas de la portada, de /post/2 y de /post/99 (con su 404).
  • El reto de agify y genderize con los casos de la tabla del Reto 4 de la sección 2 (incluido el nombre desconocido).
  • Un párrafo sobre los límites éticos de predecir edad y género por el nombre.
  • El menú del restaurante escolar (proyecto extra).
  • La etapa 4 del Portal del Grupo.
  • Cuaderno de errores: variable con otro nombre en la plantilla, {{ }} contra {% %}, endfor olvidado, módulos dentro de Jinja, url_for con el nombre de la ruta, comillas dobles dentro de href, id como texto contra número, URL de la documentación en lugar de la API, límite de consultas agotado.