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 deurl_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
- Antes de empezar: una plantilla, muchas páginas
- Jinja: expresiones con
{{ }}y datos desde Python - Reto del curso: Jinja + APIs
- Bloques de varias líneas:
{% for %}y{% if %} - Construir URL con
url_for - 🚀 Proyecto del día: el blog, parte 1
- 🌟 Proyecto extra: el menú del restaurante escolar
- 🏗️ Proyecto integrador del bloque Flask — Etapa 4
- 🧩 Comprueba lo aprendido
- 🤖 Programar con apoyo de IA
- 🛠️ Correcciones técnicas al material original
- 📋 Matriz de trazabilidad
- 📦 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, carpetastemplatesystatic(Día 56), rutas con variables (Día 55),requestsy APIs (Días 33 a 37),**kwargs(Día 27),datetime(Día 32).
📖 La idea en una imagen (adaptación)
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:
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)<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.
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)<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 <b>x</b>). 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 porquerandomno 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 unstyle. - 🟡 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.pyo 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:
- Ruta
@app.route("/guess/<name>")y funciónguess(name). - Plantilla
guess.htmlcon los tres marcadores. 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).- En la plantilla,
{{ person_name.title() }}pone la inicial en mayúscula. - 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)
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:
- Con un nombre desconocido,
ageygenderllegan comonull(en Python,None). La página diría "maybe None years old". - El parámetro
country_id=COajusta la predicción a Colombia. Conrequestses mejor pasarlo conparamsque armar la URL a mano:
respuesta = requests.get("https://api.agify.io", params={"name": name, "country_id": "CO"}, timeout=10)- 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=COy 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.
@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:
{% 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:
{% 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):
<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
idpar (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
nombreynota) 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):
@app.route("/blog")
def get_blog():
...<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:
<a href="{{ url_for('get_blog', num=3) }}">Go To Blog</a>@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
- Descarga los archivos de inicio de los recursos de la lección.
- Ejecuta
main.py: debe verse el sitio con su estilo. - 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. - Agrega un enlace "Read" al final de cada vista previa que lleve a una página con la entrada completa:
title,subtitleybody. Cada entrada debe vivir en la rutaURL/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 conid1, 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/<...>, ¿elidllega como número o como texto? ¿Con qué lo vas a comparar? - ¿Qué debe pasar en
/post/99?
Requisitos funcionales
- Portada con título y subtítulo de cada entrada, generados con un ciclo.
- Un enlace "Read" por entrada construido con
url_for. - Ruta
/post/<int:...>que muestre título, subtítulo y cuerpo de la entrada pedida. - Una entrada inexistente devuelve un mensaje claro con código 404.
Restricciones técnicas
- Una sola plantilla
post.htmlpara 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
- Comprender los archivos de inicio (¿qué hay en
templatesy enstatic?). 2. Entradas: el JSON de n:point y elidde la URL. 3. Procesos: descargar → listar → buscar porid. 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_fora una ruta que solo imprime elidrecibido. - 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:
@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 queindexsea un número, comparable conpost["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>sinint:,indexllega como el texto"2", la comparaciónpost["id"] == indexes siempreFalsey 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 ') |
🧪 Prueba y depuración
@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){% 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_forconpost.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
- Los datos en un archivo
menu.json(lista de días; cada día condia,plato,bebida,postrey una listaalergenos). Crea al menos 5 días con datos ficticios. - Portada
/con una tabla de la semana generada con{% for %}; los días con alérgenos muestran un ícono ⚠️ (con{% if %}). - Ruta
/dia/<nombre>con el detalle de un día, enlazada conurl_fordesde la tabla. - Ruta
/sin/<alergeno>que muestre solo los días sin ese alérgeno (filtrado en Python con una comprensión de listas). - 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/Lunescontra/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í" enalergenos./dia/domingomuestra un mensaje amable con código 404.- Si
menu.jsontiene 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):
- Mueve los datos de integrantes y actividades a un archivo
datos.jsony cárgalo al arrancar el servidor. /integrantesgenera la lista con{% for %}; cada integrante enlaza a/integrante/<int:id>conurl_for./actividadesmuestra las actividades futuras y pasadas separadas (la clasificación se hace en Python condatetime).- Pie de página con el año actual en todas las páginas.
- Reemplaza los decoradores de estilo del Día 55 por HTML en las plantillas: ¿qué ventaja tiene?
8. 🧩 Comprueba lo aprendido
Conceptuales
- ¿Qué diferencia hay entre
{{ }}y{% %}? - ¿Por qué el número aleatorio se calcula en
server.pyy no en la plantilla? - ¿Qué recibe
url_for: el nombre de la ruta o el de la función? - ¿Qué hace Jinja con una variable que contiene
<script>? - ¿Por qué una predicción de edad por nombre no sirve para tomar decisiones sobre una persona?
Análisis de código
{% 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
@app.route("/")
def home():
return render_template("index.html", nombre="ana maría")<h1>{{ nombre.title() }} tiene {{ nombre | length }} caracteres</h1>Depuración
{% 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/2y 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{% %},endforolvidado, módulos dentro de Jinja,url_forcon el nombre de la ruta, comillas dobles dentro dehref,idcomo texto contra número, URL de la documentación en lugar de la API, límite de consultas agotado.