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).
17 min de lectura
15 secciones
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.
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).
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".
from datetime import date
from flask_ckeditor import CKEditor, CKEditorField
from wtforms import StringField, SubmitField
from wtforms.validators import DataRequired, URL
ckeditor = CKEditor(app)classCreatePostForm(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"])defadd_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
1234
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.
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):
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.
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.
<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).
🟢 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?
🛠️ 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
1234
<formaction="{{ url_for('delete_post', post_id=post.id) }}"method="post"class="d-inline"><inputtype="hidden"name="csrf_token"value="{{ csrf_token() }}"><buttontype="submit"class="btn btn-link p-0"onclick="returnconfirm('¿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.
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.
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.
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.
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).
CRUD completo con una sola plantilla de formulario; el borrado con POST y confirmación.
Portada con los anuncios destacados primero y luego por fecha; los vencidos, en una sección "Anteriores".
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?
13
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
/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
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.