Este tutorial adapta pedagógicamente el Día 69, el último paso del proyecto del blog. Las secciones 📝 Ejercicio original del curso provienen de las lecciones 494 a 500; las soluciones oficiales están en los gists enlazados y los archivos de inicio y final (
day-69-starting-files-blog-with-users.zip, conblog.db, yday-69-blog-with-users-end.zip) en los recursos. Los retos 🟢🟡🟠🔴, las secciones sobre autorización segura, comentarios seguros y privacidad de Gravatar, el proyecto extra y la etapa 11 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, Flask-Login 0.6.3, Flask-SQLAlchemy 3.1.1 y SQLAlchemy 2.1.1, se construyeron los modelos
User,BlogPostyCommentcon relaciones uno-a-muchos bidireccionales y se probaron en ambas direcciones. Se comprobó que un decorador@admin_onlyque solo comparacurrent_user.id != 1produce un error 500 con un visitante anónimo (AttributeError: 'AnonymousUserMixin' object has no attribute 'id'), y que Gravatar responde con imágenes tanto para hashes MD5 como SHA-256 del correo.
Tabla de contenidos
- Antes de empezar: un blog con lectores
- Requisito 1: registrar usuarios
- Requisito 2: iniciar sesión, mensajes y barra de navegación
- Requisito 3: proteger rutas con
@admin_only - Bases de datos relacionales: usuarios y entradas
- Requisito 4: comentarios
- 🔒 Comentarios seguros
- Avatares con Gravatar
- 🚀 Proyecto del día: el blog completo con usuarios
- 🌟 Proyecto extra: el foro de preguntas de la clase
- 🏗️ Proyecto integrador del bloque avanzado — Etapa 11
- 🧩 Comprueba lo aprendido
- 🤖 Programar con apoyo de IA
- 🛠️ Correcciones técnicas al material original
- 📋 Matriz de trazabilidad
- 📦 Evidencias de aprendizaje del Día 69
0. Antes de empezar: un blog con lectores
(Fuentes: 494-day-69-goals-adding-users-to-our-blog-project.md y 495-download-the-starting-project.md)
📝 Contenido original del curso
¿Y si cualquiera pudiera registrarse y comentar las entradas? Para eso hay que agregar autenticación al blog. Es el paso final del proyecto: al terminar tendrás un blog completo que puedes publicar.
El proyecto de inicio (entorno virtual, requirements.txt) trae blog.db con entradas de ejemplo. Recórrelo con DB Browser y en el navegador: las páginas de registro e inicio de sesión están incompletas. "Revisa todo el código antes de empezar".
Prerequisitos: el blog con CRUD (Día 67), autenticación con Flask-Login y Werkzeug (Día 68), decoradores con
*args/**kwargsyfunctools.wraps(Días 54 y 55), Flask-WTF, Bootstrap-Flask y CKEditor (Días 61 y 67).
📖 Roles en el blog (adaptación)
| Rol | Puede |
|---|---|
| Visitante (sin sesión) | Leer entradas y comentarios |
| Lector registrado | Todo lo anterior + comentar |
| Administrador (el usuario con id 1) | Todo lo anterior + crear, editar y borrar entradas |
Esto es autorización: decidir qué puede hacer cada quien (Día 68).
1. Requisito 1: registrar usuarios
(Fuente: 496-requirement-1-register-new-users.md)
📝 Ejercicio original del curso
- Crea en
forms.pyun formulario de WTFormsRegisterForm. - Crea una tabla
Useren la base de datos; los datos del formulario crean un registro en ella. - En la ruta
/register, crea el usuario con la contraseña con hash y sal usando Werkzeug. - Renderiza el formulario en
register.htmlconrender_form()de Bootstrap-Flask.
Pista: todavía sin Flask-Login; solo crear el usuario.
📖 Explicación conceptual
class RegisterForm(FlaskForm):
email = StringField("Email", validators=[DataRequired(), Email()])
password = PasswordField("Password", validators=[DataRequired(), Length(min=12)])
name = StringField("Name", validators=[DataRequired()])
submit = SubmitField("Sign Me Up!")La ruta es la misma del Día 68, ahora con WTForms: form.validate_on_submit(), generate_password_hash(form.password.data) y db.session.add(...).
🛠️ Nota técnica: la lección dice "una tabla
Userdentro deposts.db", pero el proyecto de este día usablog.db. Es la misma idea: la tabla nueva va en la base de datos del proyecto. Recuerdaemail_validatorsi usasEmail()(Día 61).
2. Requisito 2: iniciar sesión, mensajes y barra de navegación
(Fuente: 497-requirement-2-login-registered-users.md)
📝 Ejercicio original del curso
- Los usuarios registrados inician sesión en
/login(Flask-Login y lo del día anterior). Pistas: buscar al usuario conwhere()por su correo;render_formenlogin.html. - Una línea en
/registerpara que, al registrarse, el usuario quede con la sesión iniciada y vuelva a la portada. - Si alguien se registra con un correo que ya existe: redirigir a
/logincon un mensaje flash (<p class="flash">) que le pida iniciar sesión. - En
/login, si el correo no existe o la contraseña no coincide: volver a/logincon un mensaje que explique el problema. - La barra de navegación (
header.html) muestra "Login" y "Register" a quien no ha iniciado sesión, y "Log Out" a quien sí. - La ruta
/logoutcierra la sesión y vuelve a la portada.
📖 Explicación conceptual
Es exactamente el Día 68 aplicado al blog. Lo nuevo es la barra de navegación compartida en header.html (Día 59): como se incluye en todas las páginas, basta con un {% if current_user.is_authenticated %} para que todo el sitio se adapte.
3. Requisito 3: proteger rutas con @admin_only
(Fuente: 498-requirement-3-protect-routes.md)
🎯 Objetivo de aprendizaje
Escribir un decorador propio de autorización y responder 403 a quien no tiene permiso.
📝 Ejercicio original del curso
En el blog, el primer usuario registrado es el administrador: solo él crea, edita y borra entradas.
- El primer usuario tiene
id1. Úsalo enindex.htmlypost.htmlpara que solo el administrador vea los botones "Create New Post", "Edit Post" y borrar. - Que no vea los botones no impide que alguien escriba
/edit-post,/new-posto/deletea mano. Protege esas rutas con un decorador propio@admin_only: si el id decurrent_useres 1, puede entrar; si no, recibe un error 403.
Pistas: repasa los decoradores (Día 54); mira cómo está hecho el decorador login_required de Flask; usa la función abort() para devolver errores HTTP.
📖 Explicación conceptual (verificada)
La versión que compara solo el id falla con los visitantes anónimos:
def admin_only(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if current_user.id != 1: # ⚠️ un visitante anónimo no tiene .id
return abort(403)
return f(*args, **kwargs)
return decorated_functionVerificado: un visitante sin sesión que entra a /new-post provoca AttributeError: 'AnonymousUserMixin' object has no attribute 'id' y un error 500. La corrección:
from functools import wraps
from flask import abort
def admin_only(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_authenticated or current_user.id != 1:
return abort(403)
return f(*args, **kwargs)
return decorated_function
@app.route("/new-post", methods=["GET", "POST"])
@admin_only
def add_new_post():
...Resultados verificados con la versión corregida: anónimo → 403; lector → 403; administrador → 200.
Por qué ocultar el botón no basta: la plantilla decide qué se ve; el decorador decide qué se puede hacer. La seguridad siempre se aplica en el servidor.
🛠️ Nota técnica: "el administrador es el usuario con id 1" funciona para aprender, pero en un sitio publicado el primero que se registre se vuelve administrador (¡podría ser un desconocido!). Lo robusto es una columna
es_admin(orol) enUser, que se asigna a mano o con un script, y el decorador compruebacurrent_user.es_admin.
Retos adicionales (creados para esta adaptación)
- 🟢 Reto 1 — Aplicación: personaliza la página de error 403 con
@app.errorhandler(403)y el diseño del blog. - 🟡 Reto 2 — Ampliación: reemplaza
id == 1por una columnaes_adminy crea un scripthacer_admin.py <correo>. - 🟠 Reto 3 — Desafío: escribe un decorador con parámetro,
@rol_requerido("editor"), para un blog con roles lector, editor y administrador (pista: tres niveles de funciones, como@app.route("/"), Día 55). - 🔴 Reto 4 — Pensamiento computacional: permite que un editor edite solo sus propias entradas y el administrador, todas. ¿Dónde va esa comprobación: en el decorador o dentro de la ruta? ¿Por qué?
4. Bases de datos relacionales: usuarios y entradas
(Fuente: 499-creating-relational-databases.md)
🎯 Objetivo de aprendizaje
Crear una relación uno-a-muchos bidireccional con ForeignKey y relationship(), y entender por qué un cambio de esquema rompe la base de datos existente.
📝 Contenido original del curso
Como el primer usuario es el dueño del blog, tiene sentido vincular cada entrada con el usuario que la escribió (y, en el futuro, invitar a otros autores). Hace falta una relación entre las tablas User y BlogPost: ver qué entradas escribió un usuario, o qué usuario escribió una entrada.
Con Python puro, un User podría tener un atributo posts con una lista de BlogPost. Así es fácil ir del usuario a sus entradas… pero ¿cómo ir de una entrada a su autor? Para eso están las bases de datos relacionales (SQLite, MySQL, PostgreSQL), con ForeignKey (clave foránea) y relationship().
Reto 1: modifica User y BlogPost para crear una relación uno-a-muchos bidireccional: User es el padre y BlogPost el hijo. Esto agrega una clave foránea author_id: es un cambio que rompe el sitio; verás OperationalError: no such column: blog_posts.author_id.
Recrear la base de datos: la base vieja no tiene la columna author_id. Como no hay que conservar los datos de prueba, detén el servidor, borra blog.db, reinicia, registra un nuevo administrador y crea una entrada. "Esto muestra algo importante: el esquema de la base de datos debe definirse temprano. Con una aplicación publicada y llena de datos, habría que migrar la base de datos para conservarlos".
Reto 2: el nombre del autor desapareció de index.html y post.html. Corrígelo. Pista: author ahora es un objeto User.
📖 Explicación conceptual (verificada con SQLAlchemy 2.1)
from typing import List
from sqlalchemy import ForeignKey
from sqlalchemy.orm import relationship
class User(UserMixin, db.Model):
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
...
posts: Mapped[List["BlogPost"]] = relationship(back_populates="author")
class BlogPost(db.Model):
__tablename__ = "blog_posts"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
author_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
author: Mapped["User"] = relationship(back_populates="posts")
...| Pieza | Qué es |
|---|---|
author_id |
La columna real en la tabla blog_posts: guarda el id del usuario |
ForeignKey("users.id") |
Dice que ese número apunta a la columna id de la tabla users |
author |
Un atributo de Python (no es columna) que entrega el objeto User |
posts |
La lista de entradas del usuario |
back_populates |
Conecta las dos direcciones: si agregas una entrada a usuario.posts, su author se actualiza |
Verificado: con una entrada creada como BlogPost(title="Cactus", author=admin), entrada.author.name da "Admin" y admin.posts da [entrada].
En las plantillas: {{ post.author }} ahora muestra algo como <User 1>; lo correcto es {{ post.author.name }} (Reto 2 del curso).
📖 Migraciones (adaptación)
Borrar blog.db está bien con datos de prueba. Con datos reales se usa una herramienta de migraciones, como Flask-Migrate (basada en Alembic), que compara tus modelos con la base de datos y genera los cambios (ALTER TABLE ... ADD COLUMN ...) sin perder información. Vale la pena conocerla antes de publicar un proyecto.
⚠️ Errores frecuentes
| Error | Causa | Solución |
|---|---|---|
OperationalError: no such column: blog_posts.author_id |
La base de datos es de antes del cambio | Borrar y recrear (en desarrollo) o migrar |
NoForeignKeysError o errores de relationship |
ForeignKey apunta a un nombre de tabla equivocado ("user.id" en lugar de "users.id") |
Usa el __tablename__ exacto |
La página muestra <User 1> |
Mostraste el objeto, no su nombre | post.author.name |
back_populates que no coinciden |
Los nombres de los dos lados no se corresponden | posts ↔ author en ambos lados |
🛠️ Nota técnica: el ejemplo de Python puro de la lección tiene dos errores:
User(...)recibeposts=[...], pero__init__no tiene ese parámetro (seríaTypeError), y termina con}en lugar de)(seríaSyntaxError). La idea (un usuario con una lista de entradas) es correcta.
5. Requisito 4: comentarios
(Fuente: 500-requirement-4-allow-any-user-to-add-comments-to-blogposts.md, pasos 1 a 7)
📝 Ejercicio original del curso
- Crea en
forms.pyunCommentFormcon un soloCKEditorField. - Crea la tabla
Comment(__tablename__ = "comments") conid(clave primaria) ytext. - Relación uno-a-muchos entre
User(padre) yComment(hijo): un usuario, muchos comentarios. - Relación uno-a-muchos entre
BlogPost(padre) yComment(hijo): una entrada, muchos comentarios. - Cambiaste el esquema otra vez: detén el servidor, borra
blog.db, reinicia, registra el administrador, crea una entrada y crea otro usuario, un lector. - Solo los usuarios autenticados pueden comentar; si no, mensaje flash pidiendo iniciar sesión y redirección a
/login. Actualiza la ruta/post/<int:post_id>(pista: ¿necesita POST?). Prueba como el lector "John Doe" y revisa el comentario enblog.db. - Muestra en
post.htmltodos los comentarios de la entrada. Pistas:commentses un atributo de cada entrada y se usa como lista; el texto viene de CKEditor, así que está en HTML.
📖 Explicación conceptual
class Comment(db.Model):
__tablename__ = "comments"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
text: Mapped[str] = mapped_column(Text, nullable=False)
author_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
comment_author: Mapped["User"] = relationship(back_populates="comments")
post_id: Mapped[int] = mapped_column(ForeignKey("blog_posts.id"))
parent_post: Mapped["BlogPost"] = relationship(back_populates="comments")
# y en User: comments: Mapped[List["Comment"]] = relationship(back_populates="comment_author")
# y en BlogPost: comments: Mapped[List["Comment"]] = relationship(back_populates="parent_post")
@app.route("/post/<int:post_id>", methods=["GET", "POST"])
def show_post(post_id):
requested_post = db.get_or_404(BlogPost, post_id)
comment_form = CommentForm()
if comment_form.validate_on_submit():
if not current_user.is_authenticated:
flash("You need to login or register to comment.")
return redirect(url_for("login"))
new_comment = Comment(text=comment_form.comment_text.data,
comment_author=current_user, parent_post=requested_post)
db.session.add(new_comment)
db.session.commit()
return redirect(url_for("show_post", post_id=post_id)) # POST/Redirect/GET (Día 60)
return render_template("post.html", post=requested_post, current_user=current_user, form=comment_form){% for comment in post.comments %}
<li>
<div class="commentText">{{ comment.text|safe }}</div>
<span class="sub-text">{{ comment.comment_author.name }}</span>
</li>
{% endfor %}Comment tiene dos claves foráneas: pertenece a un usuario y a una entrada. Verificado: entrada.comments[0].comment_author.name da el nombre del lector, y lector.comments[0].parent_post.title da el título de la entrada.
6. 🔒 Comentarios seguros
(Sección creada para esta adaptación a partir del paso 7 de la lección 500.)
En el Día 67 el curso aceptó mostrar el cuerpo de las entradas con |safe "porque confiamos en nuestros autores". Hoy, cualquier persona que se registre puede escribir comentarios, y se muestran con |safe. Un comentario con <script> (enviado sin pasar por CKEditor, por ejemplo con requests.post) se ejecutaría en el navegador de todos los que lean la entrada, incluido el administrador: podría, por ejemplo, hacer acciones en su nombre. Es un XSS almacenado, uno de los ataques más serios.
Solución obligatoria en este proyecto: limpiar el HTML con nh3 (Día 67) antes de guardarlo, y permitir solo unas pocas etiquetas:
import nh3
ETIQUETAS_PERMITIDAS = {"p", "b", "strong", "i", "em", "u", "ul", "ol", "li", "a", "br"}
texto_limpio = nh3.clean(comment_form.comment_text.data, tags=ETIQUETAS_PERMITIDAS)Otras medidas razonables: un límite de longitud (Length(max=2000)) y la posibilidad de que el administrador borre comentarios.
7. Avatares con Gravatar
(Fuente: 500-requirement-4-allow-any-user-to-add-comments-to-blogposts.md, sección final)
📝 Ejercicio original del curso
Gravatar ofrece imágenes de perfil para comentaristas en muchos blogs; cada persona sube su imagen en gravatar.com asociada a su correo. El paquete flask-gravatar no es compatible con las versiones recientes de Flask (no se actualiza desde 2018), pero no hace falta: basta con calcular el hash del correo y pedirle la imagen a Gravatar (ver la sección de avatares del Flask Mega-Tutorial y la documentación de imágenes de Gravatar).
📖 Explicación conceptual (verificada)
import hashlib
def gravatar_url(email, size=100):
digest = hashlib.sha256(email.strip().lower().encode("utf-8")).hexdigest()
return f"https://www.gravatar.com/avatar/{digest}?d=identicon&s={size}"
app.jinja_env.globals["gravatar_url"] = gravatar_url # disponible en todas las plantillas<img src="{{ gravatar_url(comment.comment_author.email) }}" alt="Avatar de {{ comment.comment_author.name }}">- El correo se normaliza (
strip().lower()) antes del hash: "Ana@Correo.co " y "ana@correo.co" son el mismo. d=identicongenera un dibujo geométrico para quien no tenga cuenta en Gravatar.- Gravatar acepta el hash SHA-256 y el MD5 que usan tutoriales más antiguos (ambos verificados).
📖 Privacidad (adaptación)
Publicar el hash del correo no lo oculta del todo: como viste en el Día 68, un atacante puede calcular el hash de una lista de correos probables y compararlos. Para un proyecto escolar con menores de edad, considera generar el avatar a partir del id o del nombre del usuario (por ejemplo, con las iniciales y un color) en lugar de su correo.
8. 🚀 Proyecto del día: el blog completo con usuarios
(Integra los requisitos 1 a 4 del curso: 496 a 500.)
Problema
El blog necesita lectores que participen, un administrador que controle el contenido y datos conectados entre sí (quién escribió qué).
Objetivo
El blog final del curso: registro e inicio de sesión, administrador protegido con @admin_only, relaciones entre usuarios, entradas y comentarios, y avatares.
Requisitos funcionales
Los 4 requisitos del curso, más las correcciones de seguridad de las secciones 3 y 6.
Restricciones técnicas
@admin_onlyque responde 403 (no 500) a anónimos y lectores.- Comentarios limpiados con nh3.
- Relaciones con
back_populatesen ambos lados. SECRET_KEYen.env; contraseñas con Werkzeug.
Planificación
- Comprender
blog.dby el código de inicio. 2. Entradas: formularios. 3. Procesos: registrar, autenticar, autorizar, relacionar, comentar. 4. Salidas: páginas según el rol. 5. Dividir en los 4 requisitos. 6. Diseñar el esquema en papel (tres tablas y sus flechas). 7. Implementar. 8. Probar con tres roles. 9. Corregir. 10. Mejorar.
Construcción paso a paso
- Versión 1: registro.
- Versión 2: inicio y cierre de sesión, mensajes y barra de navegación.
- Versión 3:
@admin_onlyy botones ocultos. - Versión 4: relación usuario-entradas y base recreada.
- Versión 5: comentarios.
- Versión final: limpieza de HTML y avatares.
Pruebas
| Caso | Anónimo | Lector | Administrador |
|---|---|---|---|
| Ver la portada y las entradas | ✅ | ✅ | ✅ |
| Ver botones de crear, editar, borrar | ❌ | ❌ | ✅ |
Entrar a /new-post escribiendo la URL |
403 | 403 | 200 |
| Comentar | Redirige a /login con mensaje |
✅ | ✅ |
Comentario con <script> |
— | Se guarda sin el script | — |
| Autor de una entrada | Se ve el nombre, no <User 1> |
igual | igual |
🧪 Prueba y depuración
def admin_only(f):
def decorated_function():
if current_user.id == 1:
return f()
return abort(403)
return decorated_function
@admin_only
@app.route("/delete/<int:post_id>")
def delete_post(post_id):
...Encuentra cuatro problemas: falta @wraps(f) (dos rutas con el mismo decorador chocarían, Día 55); el wrapper no acepta *args, **kwargs, así que falla con post_id; con un visitante anónimo, current_user.id da error 500; y @admin_only está encima de @app.route, así que Flask registra la función sin protección.
⭐ Extensión del proyecto
- El administrador puede borrar comentarios; cada lector, solo los suyos.
- Contador de comentarios en la portada (
{{ post.comments|length }}). - Una página de perfil
/usuario/<int:id>con sus comentarios.
9. 🌟 Proyecto extra: el foro de preguntas de la clase
(Proyecto creado para esta adaptación; no existe en el curso original.)
Situación
Muchos estudiantes no preguntan en clase por pena. El docente quiere un foro donde publicar dudas sobre los temas y responderlas entre todos.
Requisitos
- Tres tablas relacionadas:
Usuario(conrol: estudiante o docente),Pregunta(título, texto, tema, autor, fecha, resuelta sí/no) yRespuesta(texto, autor, pregunta, fecha, marcada como "mejor respuesta" sí/no). - Registro e inicio de sesión; solo usuarios autenticados preguntan y responden.
- Solo el autor de la pregunta o un docente puede marcar la mejor respuesta y la pregunta como resuelta (decorador o comprobación en la ruta).
- Texto limpiado con nh3.
- Portada con las preguntas sin resolver primero y filtro por tema.
Decisiones que debes tomar (y justificar)
- ¿Permitir preguntas anónimas para los demás estudiantes (pero no para el docente)? ¿Cómo lo guardas en la base de datos?
- ¿Qué hacer con respuestas ofensivas?
Casos de prueba
- Un estudiante que no es el autor intenta marcar la mejor respuesta → 403.
- Al marcar la mejor respuesta, la pregunta pasa a "resuelta" y baja en la portada.
pregunta.respuestasyrespuesta.preguntafuncionan en ambas direcciones.
10. 🏗️ Proyecto integrador del bloque avanzado — Etapa 11
(Proyecto creado para esta adaptación. Descripción completa en el tutorial del Día 70.)
La Plataforma del Club — Etapa 11 (hoy):
- Columna
rolenUsuario(miembro o coordinador) y un decorador@solo_coordinadorque responda 403 correctamente también a los anónimos. - Relación uno-a-muchos
Usuario→Noticia(autor) y una relación muchos-a-muchosUsuario↔Actividadpara las inscripciones (investiga en la documentación de SQLAlchemy la "tabla de asociación"). - Comentarios en las noticias, limpiados con nh3.
- Página "Mis actividades" con las actividades en las que el usuario está inscrito.
11. 🧩 Comprueba lo aprendido
Conceptuales
- ¿Qué es una clave foránea? ¿Qué diferencia hay entre
author_idyauthor? - ¿Qué significa que una relación sea "bidireccional"?
- ¿Por qué hubo que borrar
blog.dbal agregarauthor_id? ¿Qué se haría con datos reales? - ¿Por qué ocultar un botón no protege una ruta?
- ¿Por qué los comentarios con
|safeson más peligrosos que las entradas del administrador?
Análisis de código
entrada = db.get_or_404(BlogPost, 1)
print(entrada.author.name, len(entrada.comments))
print([c.comment_author.name for c in entrada.comments])¿Qué información obtiene cada línea y de qué tablas viene?
Predicción
Con el @admin_only original del curso (solo current_user.id != 1), ¿qué código de estado recibe un visitante sin sesión en /new-post? ¿Y con la versión corregida?
Depuración
"Después de agregar la tabla Comment, al abrir cualquier entrada sale no such table: comments". ¿Qué pasó y cómo se resuelve en desarrollo?
Modificación
Cambia @admin_only para que, en lugar de 403, redirija a la portada con un mensaje flash. ¿Qué opción es mejor para el usuario? ¿Y para un programa que use la API?
Creación
Escribe una ruta /autor/<int:user_id> que muestre el nombre del usuario, sus entradas y cuántos comentarios ha escrito, usando solo las relaciones (sin consultas extra).
12. 🤖 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 — El decorador de administrador. Pide a una IA un decorador admin_only para Flask-Login. Pruébalo con un visitante anónimo. ¿Da 403 o 500? Compara con la sección 3.
Actividad 2 — Relaciones con el estilo actual. Pide a una IA los modelos User y BlogPost con una relación uno-a-muchos. ¿Usa db.Column y backref (estilo antiguo) o Mapped, mapped_column y back_populates (SQLAlchemy 2)? Compara con la documentación "Basic Relationship Patterns".
Actividad 3 — XSS en comentarios. Pregunta a una IA si es seguro mostrar comentarios con |safe. Pídele un ejemplo de ataque (solo como texto) y verifica que tu limpieza con nh3 lo neutraliza.
13. 🛠️ Correcciones técnicas al material original
| Archivo | Afirmación o comportamiento del curso | Observación | Tratamiento |
|---|---|---|---|
498 |
@admin_only: "si el id del usuario es 1, puede entrar; si no, 403" |
Si se compara solo current_user.id, un visitante anónimo provoca un error 500 (verificado) |
Sección 3 |
498 |
El primer usuario registrado es el administrador | En un sitio publicado, cualquiera podría registrarse primero | Nota técnica de la sección 3 |
500 |
Mostrar los comentarios de cualquier usuario con |safe |
XSS almacenado; limpiar con nh3 | Sección 6 |
499 |
Ejemplo de Python puro con posts=[...] |
TypeError (el constructor no tiene ese parámetro) y } en lugar de ) |
Nota técnica de la sección 4 |
496 |
"Tabla User dentro de posts.db" |
El proyecto de este día usa blog.db |
Nota técnica de la sección 1 |
499, 500 |
Borrar y recrear la base de datos | Válido en desarrollo; con datos reales se usan migraciones (Flask-Migrate) | Sección 4 |
500 |
Gravatar con el hash del correo | Funciona con SHA-256 y MD5 (verificado); el hash no oculta del todo el correo | Sección 7 |
14. 📋 Matriz de trazabilidad
| Contenido original | Archivo de origen | Concepto desarrollado | Ejercicio original | Reto adicional | Proyecto relacionado |
|---|---|---|---|---|---|
| Usuarios que comentan, autenticación, paso final del blog | 494 |
Sección 0 | — | Roles | Sección 8 |
Proyecto de inicio, blog.db, recorrer el código |
495 |
Sección 0 | Preparar el proyecto | — | Sección 8 |
RegisterForm, tabla User, hash con Werkzeug, render_form |
496 |
Sección 1 | Requisito 1 | — | Secciones 8 y 9 |
Inicio de sesión, sesión al registrarse, flash por correo repetido y credenciales erróneas, barra de navegación, /logout |
497 |
Sección 2 | Requisito 2 (pasos 1–6) | — | Secciones 8 y 9 |
Administrador id 1, botones ocultos, decorador @admin_only, 403, abort() |
498 |
Sección 3 | Requisito 3 | Retos 1–4 de la sección 3 | Secciones 8, 9 y 10 |
Relación usuario-entradas, ForeignKey, relationship(), uno-a-muchos bidireccional, author_id, error de columna, recrear la base, migración, post.author.name |
499 |
Sección 4 | Retos 1 y 2 | Migraciones | Secciones 8, 9 y 10 |
CommentForm, tabla comments, relaciones con User y BlogPost, recrear la base, comentar solo autenticado, mostrar comentarios, Gravatar, flask-gravatar incompatible, Mega-Tutorial |
500 |
Secciones 5 y 7 | Requisito 4 (pasos 1–7) y avatares | Comentarios seguros; privacidad | Secciones 8, 9 y 10 |
| (No existe en el curso original) | — | Sección 6 | — | Limpieza de comentarios con nh3 | Secciones 8, 9 y 10 |
| (No existe en el curso original) | — | — | — | Foro de preguntas de la clase | Sección 9 |
| (No existe en el curso original) | — | — | — | Plataforma del Club, etapa 11 | Sección 10 |
15. 📦 Evidencias de aprendizaje del Día 69
- El blog completo, con la tabla de pruebas por rol y capturas de un comentario con avatar.
- El diagrama del esquema (tres tablas y sus relaciones).
- La prueba de que un anónimo recibe 403 (no 500) y de que un comentario con
<script>se limpia. - El foro de preguntas de la clase (proyecto extra).
- La etapa 11 de la Plataforma del Club.
- Cuaderno de errores:
no such columntras cambiar el esquema,ForeignKeycon el nombre de tabla equivocado,<User 1>en la plantilla,back_populatesmal emparejado,@admin_onlycon anónimos, decorador sinwrapsni*args, decorador encima de la ruta,|safeen comentarios.