Saltar al contenido
Volver al inicio 100 Días de Código en Python — Día 67: El blog con base de datos: crear, editar y borrar entradas (proyecto del blog, parte 3)

100 Días de Código en Python — Día 67: El blog con base de datos: crear, editar y borrar entradas (proyecto del blog, parte 3)

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 67 (nivel avanzado).

Este tutorial adapta pedagógicamente el Día 67. Las secciones 📝 Ejercicio original del curso provienen de las lecciones 476 a 481; las pistas y soluciones oficiales están en los gists enlazados y los archivos de inicio y final (day-67-starting-files-upgraded-blog.zip, con la base posts.db, y day-67-upgraded-blog-end.zip) en los recursos, no entre los archivos suministrados. Los retos 🟢🟡🟠🔴, la sección sobre |safe y la limpieza de HTML, el proyecto extra y la etapa 9 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-CKEditor 1.0.0 y nh3 0.3.7, se comprobó: que {{ ckeditor.load() }} carga CKEditor 4.22.1 desde cdn.ckeditor.com, que Flask-CKEditor avisa que falta la biblioteca bleach para su función de limpieza, y que nh3 elimina <script> y atributos como onerror de un HTML. También se verificó el formato de fecha y el comportamiento de los métodos HTTP en formularios.


Tabla de contenidos

  1. Antes de empezar: un blog que se administra desde el navegador
  2. Requisito 1: leer las entradas de la base de datos
  3. Requisito 2: crear entradas con un editor enriquecido
  4. 🔒 |safe: mostrar HTML sin abrir la puerta a ataques
  5. Requisito 3: editar entradas
  6. Requisito 4: borrar entradas
  7. 🚀 Proyecto del día: el blog con CRUD completo
  8. 🌟 Proyecto extra: el tablero de anuncios del salón
  9. 🏗️ Proyecto integrador del bloque avanzado — Etapa 9
  10. 🧩 Comprueba lo aprendido
  11. 🤖 Programar con apoyo de IA
  12. 🛠️ Correcciones técnicas al material original
  13. 📋 Matriz de trazabilidad
  14. 📦 Evidencias de aprendizaje del Día 67

0. Antes de empezar: un blog que se administra desde el navegador

(Fuentes: 476-day-67-goals-building-a-restful-blog-with-editing.md y 477-download-the-starting-project.md)

📝 Contenido original del curso

Con todo lo aprendido, el blog va más lejos: rutas para crear, editar y borrar entradas desde el propio sitio.

El proyecto de inicio (entorno virtual y requirements.txt, como en los días anteriores) trae una base de datos SQLite posts.db con entradas de ejemplo, que reemplaza el bin de n:point. Ábrela en DB Browser y familiarízate con sus campos.

Prerequisitos: el blog con Clean Blog (Días 59 y 60), Flask-WTF y Bootstrap-Flask (Día 61), CRUD con SQLAlchemy (Días 63 y 64), verbos HTTP y REST (Día 66), datetime (Día 32).

📖 El mapa de rutas (adaptación)

Acción Ruta Método en el curso Qué ve el usuario
Leer todas / GET Portada
Leer una /post/<int:post_id> GET La entrada
Crear /new-post GET (formulario) y POST (guardar) "New Post"
Editar /edit-post/<int:post_id> GET (formulario lleno) y POST (guardar) "Edit Post"
Borrar /delete/<int:post_id> GET (enlace ✘) Vuelve a la portada

Compáralo con la API del Día 66: es el mismo CRUD, pero para personas (HTML y formularios) en lugar de programas (JSON).


1. Requisito 1: leer las entradas de la base de datos

(Fuente: 478-requirement-1-be-able-to-get-blog-post-items.md)

📝 Ejercicio original del curso

  1. En lugar de n:point, obtén todas las entradas de posts.db con Flask-SQLAlchemy, en la ruta /. Hay 3 entradas de ejemplo.
  2. Haz que al hacer clic en una entrada se pueda leer. Pista: una ruta con el id único de la entrada, que la busque en la base de datos.

📖 Explicación conceptual

Python
@app.route("/")
def get_all_posts():
    posts = db.session.execute(db.select(BlogPost)).scalars().all()
    return render_template("index.html", all_posts=posts)


@app.route("/post/<int:post_id>")
def show_post(post_id):
    requested_post = db.get_or_404(BlogPost, post_id)
    return render_template("post.html", post=requested_post)

Compara con el Día 59: allí recorrías una lista buscando el id; ahora la base de datos lo busca por ti (get_or_404) y responde 404 si no existe.


2. Requisito 2: crear entradas con un editor enriquecido

(Fuente: 479-requirement-2-be-able-to-post-a-new-blog-post.md)

🎯 Objetivo de aprendizaje

Crear un formulario de WTForms con un campo de texto enriquecido (CKEditor), guardar la entrada con la fecha calculada en el servidor y redirigir.

📝 Ejercicio original del curso

Mostrar el formulario. Crea la ruta /new-post que renderice make-post.html al pulsar "Create New Post". El formulario tiene 5 campos: título, subtítulo, nombre del autor, URL de la imagen de fondo y el cuerpo de la entrada. El cuerpo debe ser un editor CKEditor completo, con el paquete Flask-CKEditor. "Divide la tarea en pasos más pequeños".

  • Pista 1: cada vez que cambies las plantillas de Jinja, detén y vuelve a ejecutar el servidor.
  • Pista 2: cómo enlazar el botón "Create New Post".
  • Pista 3: crear y renderizar el formulario de WTF (sin el cuerpo por ahora).
  • Pista 4: agregar CKEditor al cuerpo: inicializarlo con la aplicación y cargarlo en make-post.html.

Guardar la entrada. Al enviar, los datos se guardan como un objeto BlogPost en posts.db, y el usuario vuelve a la portada, donde aparece la nueva entrada.

La fecha no va en el formulario: se calcula en el servidor con datetime, con el formato "August 31, 2019" (mes completo, día, año completo).

Para los curiosos: CKEditor guarda el cuerpo como HTML, con su estructura y estilos. Para que post.html lo muestre como HTML (y no como texto con etiquetas), se usa el filtro safe de Jinja: {{ post.body|safe }}. "Por simplicidad no estamos limpiando (sanitising) el HTML y asumimos que podemos confiar en que nuestros autores no publiquen un <script> malicioso".

📖 Explicación conceptual

Python
from datetime import date
from flask_ckeditor import CKEditor, CKEditorField
from wtforms import StringField, SubmitField
from wtforms.validators import DataRequired, URL

ckeditor = CKEditor(app)


class CreatePostForm(FlaskForm):
    title = StringField("Blog Post Title", validators=[DataRequired()])
    subtitle = StringField("Subtitle", validators=[DataRequired()])
    author = StringField("Your Name", validators=[DataRequired()])
    img_url = StringField("Blog Image URL", validators=[DataRequired(), URL()])
    body = CKEditorField("Blog Content", validators=[DataRequired()])
    submit = SubmitField("Submit Post")


@app.route("/new-post", methods=["GET", "POST"])
def add_new_post():
    form = CreatePostForm()
    if form.validate_on_submit():
        new_post = BlogPost(
            title=form.title.data,
            subtitle=form.subtitle.data,
            body=form.body.data,
            img_url=form.img_url.data,
            author=form.author.data,
            date=date.today().strftime("%B %d, %Y"),
        )
        db.session.add(new_post)
        db.session.commit()
        return redirect(url_for("get_all_posts"))
    return render_template("make-post.html", form=form)

En make-post.html: {{ ckeditor.load() }} (carga el editor) y {{ ckeditor.config(name='body') }} (lo aplica al campo body), además de {{ render_form(form) }}.

La fecha: strftime("%B %d, %Y") da "September 27, 2026" en inglés. Para español, con el sistema en otro idioma los resultados cambian; lo más predecible es armar el texto a mano:

Python
MESES = ["enero", "febrero", "marzo", "abril", "mayo", "junio", "julio",
         "agosto", "septiembre", "octubre", "noviembre", "diciembre"]
hoy = date.today()
fecha_es = f"{hoy.day} de {MESES[hoy.month - 1]} de {hoy.year}"   # "27 de septiembre de 2026"

🛠️ Nota técnica: guardar la fecha como texto es lo que hace posts.db, pero impide ordenar bien las entradas (alfabéticamente, "April" va antes que "January"). En un proyecto propio, guarda un tipo Date y dale formato solo al mostrarla.

🛠️ Nota técnica (verificado): Flask-CKEditor 1.0 carga CKEditor 4.22.1, la última versión libre de CKEditor 4, cuyo soporte gratuito terminó en 2023. Funciona para aprender, pero el propio editor puede mostrar un aviso de que la versión no es segura. En un proyecto real, evalúa alternativas mantenidas (otros editores, o Markdown).

🛠️ Nota técnica: la pista 1 dice que hay que reiniciar el servidor tras cambiar las plantillas. Con el modo de depuración activado (Día 55), Flask recarga las plantillas solo; basta con refrescar el navegador.

⚠️ Errores frecuentes

Error Causa Solución
El campo del cuerpo es un cuadro de texto simple Falta {{ ckeditor.load() }} o ckeditor.config(name='body') Agrégalos en make-post.html
UndefinedError: 'ckeditor' is undefined No inicializaste CKEditor(app) Inicialízalo en main.py
El cuerpo se ve con las etiquetas <p>…</p> como texto Jinja escapa el HTML (Día 57) Sección 3
La entrada nueva no aparece Faltó commit() o no rediriges a la portada Revisa el orden

3. 🔒 |safe: mostrar HTML sin abrir la puerta a ataques

(Sección creada para esta adaptación a partir de la nota final de la lección 479.)

🎯 Objetivo de aprendizaje

Entender qué desactiva |safe y limpiar el HTML antes de guardarlo.

📖 Explicación conceptual

Desde el Día 57 sabes que Jinja escapa las variables para evitar XSS. |safe le dice a Jinja: "confía, muéstralo como HTML". El curso lo acepta porque "confiamos en nuestros autores". Pero:

  • el Día 69 cualquier usuario podrá escribir comentarios con el mismo editor;
  • una cuenta de autor puede ser robada;
  • alguien puede enviar el formulario sin usar CKEditor (con requests.post, Día 66) y meter lo que quiera en body.

Solución: limpiar (sanitizar) el HTML al recibirlo, dejando solo etiquetas seguras. La biblioteca nh3 (rápida y mantenida) lo hace (verificado):

Python
import nh3

html_limpio = nh3.clean('<p>Hola <b>mundo</b><script>alert(1)</script><img src=x onerror=alert(1)></p>')
print(html_limpio)   # <p>Hola <b>mundo</b><img src="x"></p>

El <script> y el atributo onerror desaparecieron; el formato (<p>, <b>) se conserva. Aplícalo antes de guardar: body=nh3.clean(form.body.data). Así |safe muestra un HTML que ya revisaste.

🛠️ Nota técnica (verificado): Flask-CKEditor trae una función cleanify para esto, pero depende de la biblioteca bleach, que sus autores declararon obsoleta en 2023; al importar Flask-CKEditor sin ella aparece el aviso The "bleach" library is not installed (verificado). nh3 es una alternativa moderna y mantenida para la misma tarea.


4. Requisito 3: editar entradas

(Fuente: 480-requirement-3-be-able-to-edit-existing-blog-posts.md)

📝 Ejercicio original del curso

Activar el botón "Edit". Al final de cada entrada (post.html) hay un botón "Edit Post" que debe llevar a make-post.html.

  1. Crea la ruta /edit-post/<post_id>; el botón hace un GET con el id de la entrada que se está leyendo.
  2. Cambia make-post.html para que el <h1> diga "New Post" o "Edit Post" según de dónde venga el usuario.
  3. Corrige el href del botón en post.html con el id.

Llenar el formulario con los datos existentes, para no reescribir todo:

Python
edit_form = CreatePostForm(
    title=post.title,
    subtitle=post.subtitle,
    img_url=post.img_url,
    author=post.author,
    body=post.body
)

Guardar y volver a la entrada. Al pulsar "Submit Post", se actualiza la base de datos y se redirige a post.html de esa entrada.

Nota del curso: los formularios HTML (incluido WTForms) no aceptan PUT, PATCH ni DELETE; aunque esto sería un PUT (reemplazar datos), se recibe como POST. Y la fecha no cambia: es la de la publicación original, no la de la edición.

📖 Explicación conceptual

Python
@app.route("/edit-post/<int:post_id>", methods=["GET", "POST"])
def edit_post(post_id):
    post = db.get_or_404(BlogPost, post_id)
    edit_form = CreatePostForm(
        title=post.title, subtitle=post.subtitle, img_url=post.img_url,
        author=post.author, body=post.body,
    )
    if edit_form.validate_on_submit():
        post.title = edit_form.title.data
        post.subtitle = edit_form.subtitle.data
        post.img_url = edit_form.img_url.data
        post.author = edit_form.author.data
        post.body = edit_form.body.data
        db.session.commit()
        return redirect(url_for("show_post", post_id=post.id))
    return render_template("make-post.html", form=edit_form, is_edit=True)
HTML
<h1>{% if is_edit %}Edit Post{% else %}New Post{% endif %}</h1>

¿Por qué funciona crear el formulario con los datos viejos también en el POST? Flask-WTF toma los datos del formulario enviado (request.form) antes que los valores por defecto que le pasas, así que edit_form.title.data tiene lo que el usuario escribió.

Una plantilla para dos usos: make-post.html sirve para crear y para editar; solo cambia el título. Es el principio DRY (Día 59).

Retos adicionales (creados para esta adaptación)

  • 🟢 Reto 1 — Aplicación: cambia también el texto del botón ("Publicar" o "Guardar cambios").
  • 🟡 Reto 2 — Ampliación: agrega un campo editado con la fecha de la última edición y muéstralo en la entrada ("Editado el…"), sin cambiar la fecha original.
  • 🟠 Reto 3 — Desafío: usa form.populate_obj(post) (documentación de WTForms) para no copiar campo por campo. ¿Qué riesgo tiene si el formulario tuviera un campo que no debería cambiarse?
  • 🔴 Reto 4 — Pensamiento computacional: guarda un historial de versiones de cada entrada (una tabla aparte con el cuerpo anterior y la fecha) y una página para ver las versiones. ¿Qué pasa con el espacio en la base de datos?

5. Requisito 4: borrar entradas

(Fuente: 481-requirement-4-be-able-delete-blog-posts.md)

📝 Ejercicio original del curso

  1. En index.html, junto a cada entrada, un enlace que muestre el carácter ✘. Al hacer clic, se borra la entrada y se vuelve a la portada.
  2. En main.py, "crea una ruta DELETE" en /delete/<post_id>.

📖 Explicación conceptual

Python
@app.route("/delete/<int:post_id>")
def delete_post(post_id):
    post_to_delete = db.get_or_404(BlogPost, post_id)
    db.session.delete(post_to_delete)
    db.session.commit()
    return redirect(url_for("get_all_posts"))

🛠️ Nota técnica: el enunciado habla de una "ruta DELETE", pero un enlace (<a href>) siempre hace una solicitud GET, y los formularios HTML solo admiten GET y POST (como dice la propia lección 480). Por eso la ruta, en la práctica, atiende GET. Borrar con GET tiene los riesgos que vimos en los Días 63 y 64 (cualquier página puede provocar el borrado con un enlace o una imagen, y hoy no hay inicio de sesión). La versión robusta usa un formulario POST con protección CSRF:

HTML
<form action="{{ url_for('delete_post', post_id=post.id) }}" method="post" class="d-inline">
    <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
    <button type="submit" class="btn btn-link p-0" onclick="return confirm('¿Borrar esta entrada?')">✘</button>
</form>

(csrf_token() está disponible en las plantillas si activas CSRFProtect(app) de Flask-WTF.) El verdadero método DELETE se usa desde programas, como en la API del Día 66.


6. 🚀 Proyecto del día: el blog con CRUD completo

(Integra los requisitos 1 a 4 del curso: 478 a 481.)

Problema

Para publicar o corregir una entrada, el autor tenía que editar la base de datos con DB Browser. Nadie más podría administrar el blog.

Objetivo

Que el blog permita leer, crear (con editor enriquecido), editar y borrar entradas desde el navegador.

Requisitos funcionales

Los 4 requisitos del curso (secciones 1, 2, 4 y 5).

Restricciones técnicas

  • Una sola plantilla make-post.html para crear y editar.
  • La fecha se calcula en el servidor y no cambia al editar.
  • HTML del cuerpo limpiado con nh3 antes de guardarlo.
  • get_or_404 en todas las rutas con id.

Planificación

  1. Comprender posts.db en DB Browser. 2. Entradas: formularios e ids. 3. Procesos: CRUD. 4. Salidas: páginas y redirecciones. 5. Dividir en los 4 requisitos. 6. Diseñar la tabla de rutas (sección 0). 7. Implementar. 8. Probar. 9. Corregir. 10. Mejorar.

Construcción paso a paso

  • Versión 1: portada y entradas desde posts.db.
  • Versión 2: formulario de nueva entrada sin CKEditor.
  • Versión 3: CKEditor y guardado con la fecha.
  • Versión 4: edición con el formulario lleno.
  • Versión 5: borrado.
  • Versión final: limpieza del HTML y borrado con POST.

Pruebas

Caso Resultado esperado
Crear una entrada con negritas y una lista Se ve con formato en post.html
Crear una entrada con <script>alert(1)</script> en el HTML del cuerpo El script no aparece en la página
URL de imagen inválida Error de validación
Editar el título de una entrada Cambia; la fecha sigue igual; vuelve a la entrada
/edit-post/999 404
Borrar Desaparece de la portada
Título "New Post" o "Edit Post" Según el caso

🧪 Prueba y depuración

Python
@app.route("/edit-post/<post_id>", methods=["GET", "POST"])
def edit_post(post_id):
    post = db.session.get(BlogPost, post_id)
    edit_form = CreatePostForm(title=post.title, subtitle=post.subtitle, body=post.body)
    if edit_form.validate_on_submit():
        post.title = edit_form.title.data
        post.date = date.today().strftime("%B %d, %Y")
        db.session.commit()
        return redirect(url_for("get_all_posts"))
    return render_template("make-post.html", form=edit_form)

Encuentra cinco problemas: falta int:; si el id no existe, post es None y falla (mejor get_or_404); el formulario no se llena con author ni img_url, que son obligatorios, así que nunca valida; se cambia la fecha (no debe cambiar) y solo se actualiza el título; y no se indica que es edición ni se redirige a la entrada.

⭐ Extensión del proyecto

  • Un campo "borrador": las entradas en borrador no aparecen en la portada.
  • Contar las palabras del cuerpo sin las etiquetas HTML (pista: nh3 puede quitar todas las etiquetas con nh3.clean(html, tags=set())).

7. 🌟 Proyecto extra: el tablero de anuncios del salón

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

Situación

El representante del curso publica los anuncios (tareas, fechas de evaluaciones, salidas) en un grupo de mensajería donde se pierden. Quiere un tablero web.

Requisitos

  1. Modelo Anuncio (título, categoría: Tarea/Evaluación/Evento/General, contenido con CKEditor, fecha de publicación, fecha de vencimiento opcional, destacado sí/no).
  2. CRUD completo con una sola plantilla de formulario; el borrado con POST y confirmación.
  3. Portada con los anuncios destacados primero y luego por fecha; los vencidos, en una sección "Anteriores".
  4. Contenido limpiado con nh3.
  5. Filtro por categoría (/categoria/<nombre>).

Decisiones que debes tomar (y justificar)

  • Sin inicio de sesión (Día 68), cualquiera podría publicar. ¿Qué medida temporal tomas (por ejemplo, una clave en .env)?
  • ¿Qué etiquetas HTML permites y cuáles no?

Casos de prueba

  • Un anuncio vencido aparece en "Anteriores".
  • Un anuncio con <iframe> pierde el iframe al guardarse.
  • Editar un anuncio no cambia su fecha de publicación.

8. 🏗️ Proyecto integrador del bloque avanzado — Etapa 9

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

La Plataforma del Club — Etapa 9 (hoy):

  1. Sección de noticias del club con CRUD completo desde el navegador (reemplaza el JSON de la etapa 1 por una tabla Noticia).
  2. Cuerpo con editor enriquecido, limpiado con nh3.
  3. Una plantilla para crear y editar; borrado con POST.
  4. Por ahora, las rutas de crear, editar y borrar quedan protegidas con una clave temporal; el Día 68 las protegerás con usuarios.

9. 🧩 Comprueba lo aprendido

Conceptuales

  1. ¿Por qué la fecha de la entrada se calcula en el servidor y no está en el formulario?
  2. ¿Qué hace el filtro |safe? ¿Qué riesgo trae?
  3. ¿Por qué la edición llega como POST si en REST sería PUT?
  4. ¿Cómo se reutiliza make-post.html para crear y editar?
  5. ¿Qué método HTTP usa un enlace <a>?

Análisis de código

HTML
<div>{{ post.body }}</div>
<div>{{ post.body|safe }}</div>

Si post.body es <p>Hola <b>mundo</b></p>, ¿qué se ve en cada div?

Predicción

Python
from datetime import date
print(date(2019, 8, 31).strftime("%B %d, %Y"))
print(date(2026, 1, 5).strftime("%B %d, %Y"))

Depuración

"Cuando edito una entrada, el formulario aparece vacío". ¿Qué le falta a la ruta?

Modificación

Cambia la portada para que muestre las entradas de la más reciente a la más antigua. ¿Qué problema aparece si la fecha está guardada como texto?

Creación

Agrega una ruta /autor/<nombre> que muestre solo las entradas de ese autor.


10. 🤖 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 — ¿Es seguro? Pide a una IA cómo mostrar el HTML de CKEditor en Jinja. Si responde solo "usa |safe", pregúntale por XSS y compara su solución con la sección 3 y con la documentación de nh3.

Actividad 2 — Borrar con un enlace. Pregunta a una IA si está bien borrar con <a href="/delete/3">. Contrasta su respuesta con la nota técnica de la sección 5.

Actividad 3 — Fechas en español. Pide a una IA la fecha actual en formato "27 de septiembre de 2026". Si usa locale.setlocale, pruébalo en tu computador y en el de un compañero. ¿Funciona igual en ambos?


11. 🛠️ Correcciones técnicas al material original

Archivo Afirmación o comportamiento del curso Observación Tratamiento
479 Mostrar el cuerpo con |safe sin limpiar el HTML Riesgo de XSS; limpiarlo con nh3 (verificado) Sección 3
479 Flask-CKEditor Carga CKEditor 4.22.1, sin soporte gratuito desde 2023 (verificado); su limpieza depende de bleach Notas técnicas de las secciones 2 y 3
479 Reiniciar el servidor tras cambiar las plantillas Con el modo de depuración se recargan solas Nota técnica de la sección 2
479 Fecha como texto "August 31, 2019" Formato en inglés y difícil de ordenar Sección 2
481 "Crea una ruta DELETE" con un enlace ✘ Un enlace hace GET; los formularios no admiten DELETE (lo dice la lección 480) Nota técnica de la sección 5
476–481 Crear, editar y borrar sin inicio de sesión Cualquiera puede modificar el blog; se resuelve en los Días 68 y 69 Proyecto extra y etapa 9

12. 📋 Matriz de trazabilidad

Contenido original Archivo de origen Concepto desarrollado Ejercicio original Reto adicional Proyecto relacionado
Crear, editar y borrar entradas en el blog 476 Sección 0 — Mapa de rutas Sección 6
Proyecto de inicio, posts.db en lugar de n:point, DB Browser 477 Sección 0 Preparar el proyecto — Sección 6
Leer todas las entradas con Flask-SQLAlchemy, ruta por id 478 Sección 1 Requisito 1 get_or_404 Sección 6
/new-post, make-post.html, 5 campos, Flask-CKEditor, pistas 1–4, guardar BlogPost, redirigir, fecha "August 31, 2019", |safe 479 Secciones 2 y 3 Requisito 2 Fecha en español; limpieza con nh3 Secciones 6, 7 y 8
/edit-post/<post_id>, "New Post"/"Edit Post", href del botón, formulario lleno, redirigir a la entrada, formularios sin PUT/PATCH/DELETE, fecha original 480 Sección 4 Requisito 3 Retos 1–4 de la sección 4 Secciones 6 y 7
Enlace ✘, /delete/<post_id> 481 Sección 5 Requisito 4 Borrar con POST y CSRF Secciones 6, 7 y 8
(No existe en el curso original) — — — Tablero de anuncios del salón Sección 7
(No existe en el curso original) — — — Plataforma del Club, etapa 9 Sección 8

13. 📦 Evidencias de aprendizaje del Día 67

  • El blog con CRUD completo y capturas de crear, editar y borrar.
  • La prueba de la entrada con <script> mostrando que la limpieza funciona.
  • El tablero de anuncios del salón (proyecto extra).
  • La etapa 9 de la Plataforma del Club.
  • Cuaderno de errores: CKEditor sin cargar, HTML mostrado como texto, |safe sin limpiar, formulario de edición vacío, fecha cambiada al editar, campos obligatorios sin llenar, borrado con GET, fecha como texto que no se ordena.