05/08
CRUD completo con ModelForms
Hasta la Semana 4 solo podías dar de alta registros desde /admin — el panel automático de Django. Esta semana construyes tus propias pantallas: un formulario para crear, una tabla para listar, una pantalla para editar y una pantalla de confirmación para eliminar. Es el mismo modelo Servicio de la Unidad 2, ahora con un CRUD público hecho por ti, sin pasar por /admin.
u3s5-apellidopaterno-apellidomaterno-nombres y su Web Service en https://u3s5-apellidopaterno-apellidomaterno-nombres.onrender.com — ahí se puede crear, editar y borrar un Servicio real desde el navegador, sin usar /admin.
ModelForm, {% csrf_token %} y el ciclo petición/respuesta
Un ModelForm es un formulario HTML que Django genera automáticamente a partir de un modelo: mismos campos, mismos tipos, mismas validaciones. Evita repetir a mano lo que ya escribiste en models.py.
Crear el archivo forms.py
# forms: trae las herramientas de Django para construir formularios
from django import forms
# Servicio: el modelo de la Unidad 2, importado desde este mismo directorio
from .models import Servicio
# class ServicioForm: un formulario generado automáticamente a partir del modelo Servicio
class ServicioForm(forms.ModelForm):
# Meta: clase interna donde le dices a Django de qué modelo y qué campos generar
class Meta:
# model: el modelo del que se generan los campos del formulario
model = Servicio
# fields: lista de campos del modelo que sí aparecen en el formulario
# "propietario" queda fuera a propósito: lo asigna la vista, no el usuario
fields = ['titulo', 'descripcion', 'precio', 'categoria']
Cada tipo de campo del modelo (CharField, DecimalField, TextField...) se convierte solo en el tipo de <input> correspondiente en HTML, y hereda las mismas validaciones (ej. max_length=120) sin que las repitas.
Por qué todo <form method="POST"> necesita {% csrf_token %}
CSRF (Cross-Site Request Forgery) es un ataque donde una página externa engaña a tu navegador para enviar un formulario a tu sitio sin que lo notes. Django exige un token secreto de un solo uso dentro de cada formulario POST; sin él, la petición se rechaza automáticamente.
{# method="POST": los datos viajan ocultos dentro de la petición, no en la URL #}
<form method="POST">
{# csrf_token: Django inserta aquí un campo oculto con un token secreto #}
{% csrf_token %}
{# resto de los campos del formulario #}
{# cierra el formulario #}
</form>
En las plantillas de Django los comentarios se escriben {# … #}, no <!-- … -->. Django lee las etiquetas {% … %} aunque estén dentro de <!-- -->: un comentario como <!-- {% for %} --> rompe la página con TemplateSyntaxError, y uno con {% for servicio in servicios %} repite las filas de la tabla. Lo que va entre {# #} Django lo borra antes de enviar la página, así que nunca causa problemas. Cada comentario {# #} debe caber en una sola línea.
El ciclo petición/respuesta de un formulario
GET: el navegador pide la página → Django responde con el formulario vacío (o precargado, si es edición). POST: el navegador envía los datos escritos → Django valida → si es válido, guarda y redirige; si no, vuelve a mostrar el formulario con los errores marcados.
Vista y template de "Crear" — y actualizar "Listar" con enlaces
La vista de "Crear" muestra el formulario vacío en GET y, en POST, valida y guarda un Servicio nuevo asignándole automáticamente el usuario que inició sesión como propietario.
Hacer que @login_required use el login de /admin
Las vistas de crear, editar y eliminar llevan @login_required: si nadie inició sesión, Django manda al visitante a la dirección de LOGIN_URL. Por defecto esa dirección es /accounts/login/, que no existe en tu proyecto (daría «Page not found 404»). Mientras llega tu login propio en la Semana 6, apúntala al login del panel /admin. Abre miproyecto/settings.py en VS Code, baja hasta el final del archivo y pega estas líneas:
# LOGIN_URL: a dónde manda @login_required a quien quiere entrar sin haber iniciado sesión
# '/admin/login/': por ahora usamos la pantalla de login del panel /admin; la Semana 6 la cambia por una propia
LOGIN_URL = '/admin/login/'
Guarda con Ctrl + S. Ahora, si alguien sin sesión abre /servicios/nuevo/, verá el login de /admin y, al entrar con tu superusuario, regresará solo al formulario.
views.py completo, con la vista nueva crear_servicio
Abre core/views.py, selecciona todo con Ctrl + A, bórralo y pega el bloque completo de abajo. Conserva tus vistas inicio (Semana 3) y servicios (Semana 4), trae todos los import que usarás esta semana y agrega al final crear_servicio. Guarda con Ctrl + S.
# render: junta una plantilla con datos · redirect: manda al navegador a otra URL · get_object_or_404: busca un registro o responde 404
from django.shortcuts import render, redirect, get_object_or_404
# connection: datos de la conexión a la base (lo usa la vista "inicio" de la Semana 3)
from django.db import connection
# login_required: decorador que exige sesión iniciada antes de entrar a la vista
from django.contrib.auth.decorators import login_required
# Servicio: el modelo de la Semana 4, importado desde este mismo directorio
from .models import Servicio
# ServicioForm: el ModelForm que creaste en el paso 01
from .forms import ServicioForm
# inicio: la vista de la Semana 3 que muestra a qué base estás conectado (no cambia)
def inicio(request):
# settings_dict: diccionario con ENGINE, HOST, NAME, etc. de la conexión activa
info_bd = connection.settings_dict
# contexto: los datos que la plantilla podrá usar
contexto = {
# motor: el tipo de base de datos (debe decir mysql)
'motor': info_bd['ENGINE'],
# host: el servidor de la base (en Aiven termina en aivencloud.com)
'host': info_bd['HOST'],
# cierre del diccionario contexto
}
# render: junta inicio.html con el contexto y devuelve la página
return render(request, 'core/inicio.html', contexto)
# servicios: la vista del listado de la Semana 4 (no cambia)
def servicios(request):
# .objects.all(): pide TODOS los servicios a MySQL
lista_servicios = Servicio.objects.all()
# entrega la lista a la plantilla con el nombre "servicios"
return render(request, 'core/servicios.html', {'servicios': lista_servicios})
# @login_required: si nadie inició sesión, Django lo manda a LOGIN_URL (el login de /admin)
@login_required
# crear_servicio: la vista que atiende /servicios/nuevo/
def crear_servicio(request):
# request.method: distingue si el navegador pide el formulario (GET) o lo está enviando (POST)
if request.method == 'POST':
# ServicioForm(request.POST): reconstruye el formulario con los datos que el usuario tecleó
form = ServicioForm(request.POST)
# is_valid(): corre todas las validaciones del modelo (tipos, max_length, campos obligatorios)
if form.is_valid():
# commit=False: crea el objeto Servicio en memoria SIN guardarlo todavía en MySQL
nuevo = form.save(commit=False)
# propietario: se asigna aquí, no en el formulario, para que nadie pueda falsificarlo
nuevo.propietario = request.user
# save(): ahora sí guarda el registro completo en MySQL
nuevo.save()
# redirect('servicios'): evita reenvíos duplicados si el usuario recarga la página
return redirect('servicios')
# si no es POST, es GET: el usuario apenas abrió la página
else:
# ServicioForm() sin datos: formulario vacío
form = ServicioForm()
# render: dibuja la plantilla, pasándole el formulario (vacío o con errores)
return render(request, 'core/servicio_form.html', {'form': form, 'titulo_pagina': 'Nuevo servicio'})
Crear el molde base.html (lo comparten todas las páginas)
Las pantallas de esta semana necesitan la estructura completa de una página (<html>, <head>, Tailwind). Para no repetirla en cada archivo, se escribe una sola vez en un «molde» y las demás plantillas lo usan con {% extends %}. En VS Code, clic derecho sobre la carpeta core/templates/core/ → New File → escribe base.html y pega:
{# base.html: el "molde" que comparten todas las páginas; las demás plantillas solo rellenan el bloque "contenido" #}
<!DOCTYPE html>
{# lang="es": el contenido de la página está en español #}
<html lang="es">
{# head: datos de la página que no se ven (título, acentos, estilos) #}
<head>
{# charset UTF-8: permite acentos y ñ #}
<meta charset="UTF-8">
{# viewport: hace que la página se adapte al ancho del celular #}
<meta name="viewport" content="width=device-width, initial-scale=1">
{# block titulo: cada página puede cambiar el texto de la pestaña; si no lo cambia, dice "Servicios" #}
<title>{% block titulo %}Servicios{% endblock %}</title>
{# Play CDN de Tailwind: el mismo que usaste en la Semana 2 y en servicios.html #}
<script src="https://cdn.tailwindcss.com"></script>
{# style: reglas mínimas para que se vean los campos que dibuja form.as_p (Tailwind les quita el borde) #}
<style>
/* input, textarea, select: borde gris, esquinas redondeadas, relleno y ancho completo en cada campo */
input, textarea, select { border: 1px solid #94a3b8; border-radius: .5rem; padding: .4rem .6rem; width: 100%; }
/* label: cada etiqueta en su propia línea y en negritas */
label { display: block; font-weight: 600; margin-bottom: .2rem; }
/* .errorlist: la lista de errores que genera Django; se pinta en rojo */
.errorlist { color: #dc2626; font-size: .9rem; }
/* .helptext: los textos de ayuda de Django (ej. reglas de contraseña), en gris y pequeños */
.helptext { color: #64748b; font-size: .8rem; display: block; }
{# cierra el bloque de estilos #}
</style>
{# cierra el head #}
</head>
{# body: lo que se ve; max-w-2xl + mx-auto centran el contenido con un ancho máximo #}
<body class="max-w-2xl mx-auto py-10 px-6 bg-white text-slate-900">
{# block contenido: aquí cada página (listado, formulario, eliminar) pone lo suyo #}
{% block contenido %}{% endblock %}
{# cierra el body #}
</body>
{# cierra el documento HTML #}
</html>
El bloque <style> es necesario: Tailwind quita el borde a todos los campos, y sin esas reglas los cuadros de texto de form.as_p serían invisibles y los errores no saldrían en rojo.
Template reutilizable del formulario
Clic derecho en core/templates/core/ → New File → servicio_form.html, y pega:
{# extends: esta página usa el molde base.html y solo rellena sus bloques (debe ser la primera etiqueta) #}
{% extends 'core/base.html' %}
{# block titulo: el texto de la pestaña ("Nuevo servicio" o "Editar servicio") #}
{% block titulo %}{{ titulo_pagina }}{% endblock %}
{# block contenido: todo lo que va dentro del body de base.html #}
{% block contenido %}
{# h1: muestra "Nuevo servicio" o "Editar servicio" según lo que mande la vista #}
<h1 class="text-2xl font-bold mb-4">{{ titulo_pagina }}</h1>
{# method="POST": este formulario envía datos que van a modificar la base de datos #}
<form method="POST" class="space-y-4 max-w-md">
{# csrf_token: obligatorio en todo formulario POST de Django #}
{% csrf_token %}
{# form.as_p: dibuja cada campo envuelto en un párrafo, con sus errores si los hay #}
{{ form.as_p }}
{# type="submit": botón que dispara el envío del formulario #}
<button type="submit" class="bg-blue-600 hover:bg-blue-500 text-white font-semibold px-4 py-2 rounded-lg">
{# texto visible dentro del botón #}
Guardar
{# cierra el botón de enviar #}
</button>
{# enlace para regresar al listado sin guardar nada #}
<a href="{% url 'servicios' %}" class="ml-3 text-slate-600 underline">Cancelar</a>
{# cierra el formulario: todo lo de arriba se envía junto al dar clic #}
</form>
{# endblock: fin del bloque contenido #}
{% endblock %}
form.as_p es la forma más simple de dibujar un formulario; el estilo Tailwind detallado por campo queda como mejora opcional para tu proyecto final.
Agregar enlaces "Nuevo", "Editar" y "Eliminar" en la tabla de "Listar"
Abre core/templates/core/servicios.html (el de la Semana 4), selecciona todo con Ctrl + A, bórralo y pega el archivo completo. Ahora usa el molde base.html, agrega el botón «+ Nuevo servicio», la columna Acciones y un aviso cuando no hay servicios.
{# extends: el listado también usa el molde base.html (debe ser la primera etiqueta) #}
{% extends 'core/base.html' %}
{# block contenido: lo que va dentro del body de base.html #}
{% block contenido %}
{# h1: título de la página #}
<h1 class="text-2xl font-bold mb-6">Servicios</h1>
{# enlace a la ruta con name="crear_servicio" (la registras en el paso 05) #}
<a href="{% url 'crear_servicio' %}" class="inline-block mb-4 bg-blue-600 text-white px-4 py-2 rounded-lg">+ Nuevo servicio</a>
{# table w-full: la tabla ocupa todo el ancho; border dibuja el contorno gris #}
<table class="w-full border border-slate-200 rounded-lg overflow-hidden">
{# thead: la fila de encabezados, con fondo gris claro #}
<thead class="bg-slate-100">
{# tr: la única fila del encabezado #}
<tr>
{# th: encabezado de la columna del nombre #}
<th class="text-left px-4 py-2">Servicio</th>
{# th: encabezado de la columna del precio #}
<th class="text-left px-4 py-2">Precio</th>
{# th: NUEVO, encabezado de la columna con los enlaces Editar / Eliminar #}
<th class="text-left px-4 py-2">Acciones</th>
{# cierra la fila del encabezado #}
</tr>
{# cierra el encabezado de la tabla #}
</thead>
{# tbody: el cuerpo de la tabla, donde van las filas de datos #}
<tbody>
{# for: repite la fila una vez por cada servicio de la lista #}
{% for servicio in servicios %}
{# tr: una fila por servicio; border-t dibuja la línea gris de arriba #}
<tr class="border-t border-slate-200">
{# td: celda con el título del servicio #}
<td class="px-4 py-2">{{ servicio.titulo }}</td>
{# td: celda con el precio; el $ es texto fijo antes del número #}
<td class="px-4 py-2">${{ servicio.precio }}</td>
{# td: celda de las acciones #}
<td class="px-4 py-2">
{# url 'editar_servicio' servicio.pk: arma la dirección /servicios/<id>/editar/ #}
<a href="{% url 'editar_servicio' servicio.pk %}" class="text-blue-600 underline">Editar</a>
{# url 'eliminar_servicio' servicio.pk: arma la dirección /servicios/<id>/eliminar/ #}
<a href="{% url 'eliminar_servicio' servicio.pk %}" class="text-red-600 underline ml-3">Eliminar</a>
{# cierra la celda de las acciones #}
</td>
{# cierra la fila de este servicio #}
</tr>
{# empty: lo que se muestra si la lista no tiene ningún servicio #}
{% empty %}
{# fila única con el aviso; colspan="3" la estira a lo ancho de las 3 columnas #}
<tr><td colspan="3" class="px-4 py-6 text-center text-slate-500">Todavía no hay servicios.</td></tr>
{# endfor: fin de la repetición #}
{% endfor %}
{# cierra el cuerpo de la tabla #}
</tbody>
{# cierra la tabla #}
</table>
{# endblock: fin del bloque contenido #}
{% endblock %}
{% url 'nombre' %} genera la ruta a partir del name= que le des en urls.py (paso 05) — si más adelante cambias la ruta, el enlace se actualiza solo.
Si abres /servicios/ ahora, Django marcará NoReverseMatch: la plantilla ya pide las rutas crear_servicio, editar_servicio y eliminar_servicio, pero se registran hasta el paso 05. Termina los pasos 03 y 04 y prueba todo junto en 05.2.
servicio.pk es la clave primaria del registro (su id). La necesitas en cada enlace de Editar/Eliminar para que Django sepa cuál de todos los servicios estás por modificar.
Vista y template de "Editar" reutilizando el mismo formulario
Editar es casi idéntico a crear: la única diferencia es que el formulario arranca precargado con los datos del registro que ya existe, usando instance=.
Vista editar_servicio
En core/views.py baja hasta el final del archivo (debajo de crear_servicio), deja una línea en blanco y pega esta función. No agregues import: ya los pusiste todos arriba en el paso 02.
# @login_required: solo usuarios con sesión iniciada pueden editar
@login_required
# pk: la clave primaria (id) que Django extrae del pedazo <int:pk> de la URL (paso 05)
def editar_servicio(request, pk):
# busca el Servicio con ese id; si no existe, responde 404 automáticamente
servicio = get_object_or_404(Servicio, pk=pk)
# si el usuario envió el formulario con cambios, la petición llega como POST
if request.method == 'POST':
# instance=servicio: le dice al formulario "actualiza ESTE registro, no crees uno nuevo"
form = ServicioForm(request.POST, instance=servicio)
# is_valid(): revisa tipos de dato y campos obligatorios
if form.is_valid():
# save(): al tener instance=, guarda los cambios sobre el mismo registro (UPDATE, no INSERT)
form.save()
# redirect: regresa al listado después de guardar
return redirect('servicios')
# si no es POST, es GET: se acaba de abrir la pantalla de edición
else:
# instance=servicio sin datos de POST: precarga el formulario con los valores actuales
form = ServicioForm(instance=servicio)
# render: reutiliza servicio_form.html con el título "Editar servicio"
return render(request, 'core/servicio_form.html', {'form': form, 'titulo_pagina': 'Editar servicio'})
Fíjate que reutiliza el mismo template servicio_form.html del paso 02 — no hace falta crear uno nuevo para editar.
La diferencia entre crear y editar se resume en una palabra: instance. Sin ella, el formulario está vacío y save() hace un INSERT; con ella, el formulario está precargado y save() hace un UPDATE sobre ese mismo id.
Vista de "Eliminar" con pantalla de confirmación
Borrar nunca debe pasar con un simple enlace GET: alguien podría borrar registros sin querer (o un bot podría seguir el enlace). Por eso se muestra primero una pantalla de confirmación, y el borrado real solo ocurre en POST.
Vista eliminar_servicio
Otra vez al final de core/views.py, debajo de editar_servicio, pega:
# @login_required: solo usuarios con sesión iniciada pueden borrar
@login_required
# eliminar_servicio: recibe el pk (id) desde la URL /servicios/<pk>/eliminar/
def eliminar_servicio(request, pk):
# busca el registro exacto a borrar, igual que en editar_servicio
servicio = get_object_or_404(Servicio, pk=pk)
# solo se borra si llega un POST (el botón de confirmar), nunca con un simple GET
if request.method == 'POST':
# delete(): borra la fila de MySQL de verdad
servicio.delete()
# redirect: regresa al listado, ya sin el registro borrado
return redirect('servicios')
# si es GET, solo muestra la pantalla de "¿Seguro?"; todavía no borra nada
return render(request, 'core/servicio_confirm_delete.html', {'servicio': servicio})
Template de confirmación
Clic derecho en core/templates/core/ → New File → servicio_confirm_delete.html, y pega:
{# extends: la pantalla de confirmación también usa el molde base.html #}
{% extends 'core/base.html' %}
{# block titulo: el texto de la pestaña del navegador #}
{% block titulo %}Eliminar servicio{% endblock %}
{# block contenido: lo que va dentro del body de base.html #}
{% block contenido %}
{# h1: pregunta de confirmación con el título del servicio a borrar #}
<h1 class="text-2xl font-bold mb-4">¿Eliminar "{{ servicio.titulo }}"?</h1>
{# p: aviso de que la acción no se puede deshacer #}
<p class="mb-4 text-slate-600">Esta acción no se puede deshacer.</p>
{# este formulario no tiene campos de texto: solo confirma la acción con un POST #}
<form method="POST">
{# csrf_token: obligatorio en todo formulario POST de Django #}
{% csrf_token %}
{# botón rojo (bg-red-600) que envía el POST y confirma el borrado #}
<button type="submit" class="bg-red-600 hover:bg-red-500 text-white font-semibold px-4 py-2 rounded-lg">
{# texto visible dentro del botón rojo #}
Sí, eliminar
{# cierra el botón de confirmar #}
</button>
{# enlace de vuelta a la lista, para cancelar sin borrar nada #}
<a href="{% url 'servicios' %}" class="ml-3 text-slate-600 underline">Cancelar</a>
{# cierra el formulario de confirmación #}
</form>
{# endblock: fin del bloque contenido #}
{% endblock %}
Nunca borres un registro directamente dentro de una vista que responde a GET. Si un buscador (o cualquier programa) sigue automáticamente el enlace "Eliminar", terminaría borrando datos reales sin que nadie lo haya pedido.
Registrar las rutas, hacer deploy y probar el CRUD completo en producción
Las tres vistas nuevas necesitan su propia URL. Después revisas si hay migraciones, subes todo a un repositorio nuevo u3s5-…, creas su Web Service en Render con sus 9 variables de entorno y pruebas el ciclo completo en producción.
Agregar las rutas nuevas
Abre core/urls.py, selecciona todo con Ctrl + A, bórralo y pega el archivo completo. Ojo: conserva la ruta vacía '' de la página de inicio de la Semana 3; si la borras, la raíz de tu sitio da «404».
# path: función para declarar una ruta URL y a qué vista apunta
from django.urls import path
# views: el módulo con todas las funciones-vista de esta app
from . import views
# urlpatterns: la lista de rutas que Django revisa, en orden, para cada URL
urlpatterns = [
# '': la página de inicio de la Semana 3 ("Hola desde MySQL"); no la borres
path('', views.inicio, name='inicio'),
# /servicios/: la vista de listado que ya existía desde la Semana 3
path('servicios/', views.servicios, name='servicios'),
# /servicios/nuevo/: formulario en blanco para crear un registro
path('servicios/nuevo/', views.crear_servicio, name='crear_servicio'),
# /servicios/<int:pk>/editar/: <int:pk> captura el id de la URL y lo pasa como argumento a la vista
path('servicios/<int:pk>/editar/', views.editar_servicio, name='editar_servicio'),
# /servicios/<int:pk>/eliminar/: mismo patrón, para la pantalla de confirmación de borrado
path('servicios/<int:pk>/eliminar/', views.eliminar_servicio, name='eliminar_servicio'),
# cierra la lista urlpatterns
]
Probar todo en tu computadora antes de subir
Antes de publicar, comprueba que todo funciona en local. En la terminal de VS Code, con (venv) activo y en la carpeta que tiene manage.py:
# Revisa que no haya errores de escritura en settings, urls, vistas ni modelos
python manage.py check
# Arranca el servidor en http://127.0.0.1:8000 (ocupa la terminal; se detiene con Ctrl + C)
python manage.py runserver
- Abre
http://127.0.0.1:8000/servicios/: debes ver la tabla con la columna Acciones y el botón azul + Nuevo servicio. - Clic en + Nuevo servicio. Si no habías iniciado sesión, aparece el login de
/admin: entra con tu superusuario y regresarás solo al formulario. - Llena los 4 campos y clic en Guardar: vuelves a la tabla y el servicio nuevo aparece.
- Clic en Editar de ese servicio: el formulario sale con sus datos. Cambia el precio y Guardar.
- Clic en Eliminar: sale «¿Eliminar …?». Clic en Sí, eliminar y el registro desaparece de la tabla.
- Abre
http://127.0.0.1:8000/servicios/999/editar/(un id que no existe): debe salir «Page not found (404)». - Detén el servidor con Ctrl + C en la terminal.
| Error que ves | Causa | Solución |
|---|---|---|
TemplateSyntaxError | Un comentario <!-- --> con {% %} adentro, o falta un {% endblock %} / {% endfor %} | Usa comentarios {# #} (paso 01) y compara tu archivo con el de la guía. |
NoReverseMatch | Una ruta de {% url %} no está en urls.py o su name= está mal escrito | Revisa que core/urls.py sea igual al de 05.1. |
TemplateDoesNotExist | El archivo no está en core/templates/core/ o tiene otro nombre | Revisa la carpeta y el nombre exacto (minúsculas, .html). |
AttributeError: module 'core.views' has no attribute … | Falta pegar una función en views.py | Revisa que estén crear_servicio, editar_servicio y eliminar_servicio. |
Page not found en /accounts/login/ | Falta LOGIN_URL | Repite el paso 02.1. |
| La página sale sin estilos o sin bordes en los campos | La plantilla no empieza con {% extends 'core/base.html' %} | Revisa la primera línea de código de la plantilla. |
Revisar si hay migraciones pendientes (antes de subir)
Esta práctica no cambia el modelo: solo agrega forms.py, vistas, templates y rutas. Los formularios no crean tablas, así que normalmente no hay migración nueva. Pero si al hacer la validación del paso 06 (o por cualquier otro motivo) tocaste core/models.py — agregaste un campo, cambiaste un max_length, pusiste unique=True… — entonces sí hay un cambio que MySQL necesita conocer. Compruébalo en la terminal de VS Code, con (venv) activo y dentro de la carpeta que tiene manage.py:
# Compara tus modelos con las migraciones guardadas; si responde "No changes detected", no hay nada que hacer
python manage.py makemigrations
# Solo si el comando anterior creó un archivo (ej. core/migrations/0002_...py): aplícalo en tu base de Aiven
python manage.py migrate
# Lista las migraciones de core: todas deben salir con [X] (aplicadas)
python manage.py showmigrations core
En el plan Free de Render no hay pestaña «Shell», así que nunca vas a escribir migrate dentro de Render. Lo hace solo porque está al final del Build Command (paso 05.5): en cada deploy corre python manage.py migrate. Para que funcione, el archivo nuevo de core/migrations/ tiene que subirse a GitHub junto con tu código (el git add . del paso 05.4 lo incluye; no lo pongas en .gitignore). Si no hubo cambios, el log de Render dirá No migrations to apply. y eso es correcto. Como tu compu y Render usan la misma base de Aiven, si ya corriste migrate en local, Render tampoco tendrá nada pendiente.
Crear el repositorio u3s5-… y subir los cambios
Cada práctica va en su propio repositorio y en su propio Web Service de Render, con el nombre u3s5-apellidopaterno-apellidomaterno-nombres (minúsculas, sin acentos, ñ → n, espacios → guion). Ej.: u3s5-hernandez-nunez-maria-jose. El repo de la Semana 4 (u2s4-…) se queda intacto como evidencia.
La regla del nombre · siempre igual (repo de GitHub y servicio de Render)
- Prefijo de la práctica:
u+ número de unidad +s+ número de semana. Esta práctica esu3s5. - Todo en minúsculas, sin acentos (
á → a), lañse escribeny los espacios se cambian por guion-. - Orden fijo: prefijo → apellido paterno → apellido materno → nombre(s). Si tienes dos nombres, van los dos.
- El mismo nombre se usa en dos lugares: como Repository name en GitHub y como Name del servicio en Render (paso 05.5). Así tu URL queda
https://u3s5-….onrender.com. - GitHub solo acepta letras sin acento, números,
-,_y.(máximo 100 caracteres). Por eso la regla quita acentos y espacios.
| Alumno | Nombre del repositorio y del servicio de Render |
|---|---|
| María José Hernández Núñez | u3s5-hernandez-nunez-maria-jose |
| José Ángel De la Cruz Peña | u3s5-de-la-cruz-pena-jose-angel |
| Ana López (un solo apellido) | u3s5-lopez-ana |
| Prefijo | Práctica del curso |
|---|---|
u1s1 | Unidad 1 · Semana 1 — Sitio estático en Render |
u1s2 | Unidad 1 · Semana 2 — Tailwind CSS |
u2s3 | Unidad 2 · Semana 3 — Django + MySQL en Aiven |
u2s4 | Unidad 2 · Semana 4 — Modelos y deploy en Render |
u3s5 | Unidad 3 · Semana 5 — CRUD con ModelForms (esta guía) |
u3s6 | Unidad 3 · Semana 6 — Autenticación y sesiones |
u4s7 | Unidad 4 · Semana 7 — Pulido y defensa |
u4s8 | Unidad 4 · Semana 8 — Deploy final |
Genera tu nombre sin errores
u3s5-hernandez-nunez-maria-joseTu repo quedará en https://github.com/TU_USUARIO/u3s5-hernandez-nunez-maria-jose y, con el mismo nombre en Render (paso 05.5), tu sitio en https://u3s5-hernandez-nunez-maria-jose.onrender.com. Los datos de ejemplo están precargados: escribe los tuyos y usa «Copiar nombre».
- Entra a
https://github.comy confirma arriba a la derecha que es tu cuenta. - Clic en + → New repository. En Repository name pega el nombre que copiaste del generador de arriba (
u3s5-…). - Déjalo en Public y no marques «Add a README», «.gitignore» ni «license» (el repo debe nacer vacío). Clic en Create repository y copia la URL que termina en
.git.
# Muestra a qué repo apunta hoy tu proyecto (debe salir el u2s4-… de la Semana 4)
git remote -v
# Cambia "origin" al repo nuevo de esta práctica; pega TU URL (set-url porque origin ya existe)
git remote set-url origin https://github.com/TU_USUARIO/u3s5-hernandez-nunez-maria-jose.git
# Confirma que ahora apunta al u3s5-… (sale dos veces: fetch y push)
git remote -v
# Lista lo que cambió: NO deben aparecer .env, venv/ ni staticfiles/
git status
# Agrega todos los archivos nuevos y modificados (incluye core/migrations/ si hubo migración)
git add .
# Crea el commit con un mensaje que describe el cambio
git commit -m "U3S5: CRUD completo de Servicio con ModelForms"
# Sube la rama main al repo nuevo; -u la deja vinculada para que después baste con "git push"
git push -u origin main
Recarga la página del repo en GitHub: deben verse manage.py, requirements.txt, core/forms.py y tus templates. Si ves .env, avisa al profesor antes de seguir (hay que cambiar la contraseña de Aiven).
Crear el Web Service en Render, campo por campo
- Abre
https://dashboard.render.come inicia sesión con GitHub. - Arriba a la derecha: + New → Web Service. No elijas «Static Site».
- En «Source Code» elige Git Provider, busca tu repo
u3s5-…(no elu2s4-…) y clic en Connect. - Si el repo no aparece: clic en Configure GitHub, en «Repository access» agrega
u3s5-…, guarda y regresa a Render.
Llena solo estos campos; el resto déjalo como viene:
| Campo | Qué escribir | Por qué |
|---|---|---|
| Name | El mismo nombre del repo, el que copiaste del generador (ej. u3s5-hernandez-nunez-maria-jose) | Se vuelve tu dirección: https://u3s5-hernandez-nunez-maria-jose.onrender.com. Anótala; la usas en 05.6. |
| Language | Python 3 | Render lo detecta por requirements.txt; si no, elígelo. |
| Branch | main | Cada git push a esta rama redespliega solo. |
| Region | La que venga (ej. Oregon (US West)) | Dónde está el servidor. Cualquiera sirve. |
| Root Directory | Vacío | Solo se llena si manage.py está en una subcarpeta (ej. backend). |
| Build Command | El del bloque de abajo | Se corre una vez en cada deploy: instala, junta estáticos y migra. |
| Start Command | El del bloque de abajo | Se queda corriendo siempre para atender visitas. |
| Instance Type | Free | Gratis; alcanza para el curso. |
# 1) instala requirements.txt 2) junta los estáticos para whitenoise 3) aplica migraciones en Aiven; "&&" = "si el anterior salió bien, sigue"
pip install -r requirements.txt && python manage.py collectstatic --noinput && python manage.py migrate
# arranca gunicorn con miproyecto/wsgi.py; cambia "miproyecto" por la carpeta que tiene settings.py
gunicorn miproyecto.wsgi
¿Qué va en lugar de miproyecto? En VS Code abre settings.py, busca con Ctrl + F WSGI_APPLICATION y copia lo que está antes de .application. Ej.: WSGI_APPLICATION = 'miproyecto.wsgi.application' → gunicorn miproyecto.wsgi. Es el mismo Start Command que usaste en la Semana 4.
Las 9 variables de entorno: nombre, valor y de dónde sale
Render no tiene tu .env (no se sube a GitHub). En el mismo formulario baja a «Environment Variables». Los nombres (Key) deben escribirse exactamente así, en mayúsculas, porque settings.py los busca con config('NOMBRE'):
| Key (nombre exacto) | Value (qué debe tener) | De dónde lo sacas |
|---|---|---|
DB_NAME | Nombre de la base, normalmente defaultdb | Aiven → tu servicio MySQL → Overview → «Database name». Igual que en tu .env. |
DB_USER | Usuario de MySQL, normalmente avnadmin | Aiven → Overview → «User». Igual que en tu .env. |
DB_PASSWORD | La contraseña real de Aiven | Aiven → Overview → «Password» (icono del ojo). Igual que en tu .env. |
DB_HOST | El host, termina en .aivencloud.com, sin https:// | Aiven → Overview → «Host». Igual que en tu .env. |
DB_PORT | Un número de 5 dígitos (ej. 12345), no 3306 | Aiven → Overview → «Port». Igual que en tu .env. |
SECRET_KEY | Una clave larga nueva, distinta de la local | Se genera en la terminal de VS Code (bloque de abajo). No la reutilices. |
DEBUG | False (con F mayúscula) | En producción siempre False. En tu .env local va True. |
ALLOWED_HOSTS | u3s5-hernandez-nunez-maria-jose.onrender.com | Tu dominio de Render: sin https:// y sin / al final. |
CSRF_TRUSTED_ORIGINS | https://u3s5-hernandez-nunez-maria-jose.onrender.com | La dirección completa: con https:// y sin / al final. Sin ella, los formularios del CRUD y el login dan «403 CSRF». |
# Imprime una clave aleatoria de ~67 caracteres; cópiala completa y pégala como valor de SECRET_KEY en Render
python -c "import secrets; print(secrets.token_urlsafe(50))"
Para cargarlas tienes dos formas: rápida, clic en Add from .env, pega el bloque de abajo con tus valores y clic en Add variables; o manual, clic 9 veces en + Add Environment Variable escribiendo Key y Value.
# nombre de la base de Aiven (cópialo de tu .env local)
DB_NAME=defaultdb
# usuario de Aiven (cópialo de tu .env local)
DB_USER=avnadmin
# contraseña real de Aiven (cópiala de tu .env local)
DB_PASSWORD=tu-password-real-de-aiven
# host real de Aiven, sin https:// (cópialo de tu .env local)
DB_HOST=tu-host-real.aivencloud.com
# puerto real de Aiven (cópialo de tu .env local)
DB_PORT=12345
# clave NUEVA generada con el comando de arriba, solo para Render
SECRET_KEY=pega-aqui-la-clave-nueva
# en producción SIEMPRE False
DEBUG=False
# tu dominio de esta práctica: SIN https:// y SIN / al final
ALLOWED_HOSTS=u3s5-hernandez-nunez-maria-jose.onrender.com
# tu dirección completa: CON https:// y SIN / al final
CSRF_TRUSTED_ORIGINS=https://u3s5-hernandez-nunez-maria-jose.onrender.com
En «Add from .env» Render puede tomar las líneas que empiezan con # como comentarios, pero para no arriesgarte pega solo las 9 líneas NOMBRE=valor, sin los comentarios. Sin espacios antes ni después del =, y sin comillas.
Si el nombre ya estaba ocupado, Render le agrega letras (ej. u3s5-...-a1b2.onrender.com). Al terminar, copia la dirección real que aparece debajo del nombre del servicio y corrige ALLOWED_HOSTS y CSRF_TRUSTED_ORIGINS en la pestaña Environment → Edit → Save, rebuild, and deploy.
Desplegar y leer el log
Clic en Deploy Web Service. Tarda de 2 a 5 minutos. En el log debes ver, en este orden:
# 1. Render descarga tu repo u3s5-… de GitHub
==> Cloning from https://github.com/tu-usuario/u3s5-hernandez-nunez-maria-jose
# 2. empieza tu Build Command
==> Running build command 'pip install -r requirements.txt && ...'
# 3. pip instaló Django, PyMySQL, gunicorn, whitenoise, python-decouple...
Successfully installed Django-5.1.4 PyMySQL-1.1.1 gunicorn-23.0.0 ...
# 4. collectstatic juntó tus CSS/JS e imágenes
127 static files copied to '/opt/render/project/src/staticfiles', 381 post-processed.
# 5. migrate se conectó a Aiven; si no cambiaste modelos, no hay nada pendiente (correcto)
No migrations to apply.
# 6. el build terminó bien
==> Build successful 🎉
# 7. arranca tu Start Command
==> Running 'gunicorn miproyecto.wsgi'
# 8. el estado cambia a "Live" en verde: tu sitio ya responde
==> Your service is live 🎉
| Error en el log o en el navegador | Causa | Solución |
|---|---|---|
SECRET_KEY not found (u otra variable) | Falta una variable o el nombre tiene un error de dedo | Pestaña Environment: revisa las 9 y sus nombres exactos. |
ModuleNotFoundError: No module named 'miproyecto' | Start Command con otro nombre de carpeta | Settings → «Start Command»: corrígelo (ver 05.5). |
Can't connect to MySQL server / Access denied | Algún DB_ mal copiado o Aiven apagado | Compara con tu .env y revisa que Aiven diga «Running». |
Bad Request (400) al abrir la página | ALLOWED_HOSTS no coincide con tu dominio | Corrígelo sin https:// y sin /. |
Prohibido (403) · CSRF verification failed al guardar un formulario | Falta o está mal CSRF_TRUSTED_ORIGINS | Debe ser https://tu-dominio.onrender.com, sin / final. |
TemplateDoesNotExist: core/servicio_form.html | El template no se subió o tiene otro nombre | Revisa en GitHub la carpeta core/templates/core/. |
Table '...core_servicio' doesn't exist / Unknown column | Cambiaste el modelo y no se subió la migración | Repite 05.3 y sube core/migrations/ con git push. |
Después de corregir algo en Environment, Render redespliega solo. Si corregiste código, haz git push; o usa Manual Deploy → Deploy latest commit.
Probar el CRUD completo ya en Render
En https://u3s5-tus-apellidos-nombres.onrender.com/servicios/: tu superusuario de la Semana 4 ya existe porque Render usa la misma base de Aiven; inicia sesión en /admin/ con tu superusuario, vuelve a /servicios/, crea un registro nuevo con "+ Nuevo servicio", edítalo y por último elimínalo — confirmando cada paso en la tabla.
¿Por qué te pide el login de /admin/ para usar tu propio CRUD? Porque así lo configuraste con LOGIN_URL en el paso 02.1. Django comparte una sola sesión de navegador entre /admin y el resto del sitio: iniciar sesión en cualquiera de los dos te deja autenticado en ambos. La Semana 6 reemplaza este "login prestado" por tu propia pantalla de Registro/Login.
Práctica autónoma — validación personalizada de un campo
Las validaciones automáticas del ModelForm (tipos de dato, max_length) no alcanzan siempre. Django permite agregar reglas propias con un método clean_<campo>.
Agregar clean_precio al formulario
Abre core/forms.py, selecciona todo con Ctrl + A, bórralo y pega el archivo completo (es el mismo del paso 01 con un import y una función nuevos).
# forms: trae las herramientas de Django para construir formularios
from django import forms
# ValidationError: la excepción que Django usa para reportar un error de validación
from django.core.exceptions import ValidationError
# Servicio: el modelo de la Unidad 2, importado desde este mismo directorio
from .models import Servicio
# ServicioForm: el mismo ModelForm del paso 01, ahora con una regla extra
class ServicioForm(forms.ModelForm):
# Meta: configuración del formulario (de qué modelo sale y qué campos muestra)
class Meta:
# model: el formulario se genera a partir del modelo Servicio
model = Servicio
# fields: los campos que aparecen en el formulario, en este orden
fields = ['titulo', 'descripcion', 'precio', 'categoria']
# clean_precio: Django lo llama automáticamente al validar el campo "precio"
def clean_precio(self):
# self.cleaned_data: diccionario con los valores ya convertidos a su tipo de Python
precio = self.cleaned_data['precio']
# regla propia: rechazar precios en cero o negativos, algo que DecimalField no revisa solo
if precio <= 0:
# ValidationError: si se dispara, el formulario se considera inválido y muestra este mensaje
raise ValidationError('El precio debe ser mayor a 0.')
# si no se disparó el error, hay que devolver el valor limpio para que el formulario lo use
return precio
Confirmar que el error se ve en pantalla
{{ form.as_p }} (usado desde el paso 02) ya dibuja los errores de cada campo automáticamente — no hace falta tocar el template. Prueba crear un servicio con precio 0 y confirma que aparece el mensaje en rojo debajo del campo.
Subir el cambio
# agrega el forms.py modificado al área de preparación de Git
git add core/forms.py
# crea el commit con un mensaje que explica el cambio
git commit -m "Agregar validacion personalizada: precio debe ser mayor a 0"
# sube el commit a GitHub; Render detecta el push y redespliega solo
git push
El patrón clean_<nombre_de_campo> sirve para cualquier campo del formulario — es el lugar correcto para reglas de negocio que no son solo "de tipo de dato" (ej. una fecha que no puede ser futura, un texto que no puede repetirse).
Entregar y presentar
Marca cada punto a medida que lo completas — queda guardado en este navegador.
Cómo subir la práctica
Actualizar el README
<!-- # : título principal del README (lo primero que se ve en GitHub) -->
# Proyecto Django — [Tus Nombres y Apellidos]
<!-- ## : subtítulo de la sección con el enlace a producción -->
## Demo en vivo (producción)
<!-- la URL pública de tu Web Service u3s5-… en Render -->
https://u3s5-hernandez-nunez-maria-jose.onrender.com
<!-- ## : subtítulo con lo que se hizo esta semana -->
## Semana 5 — CRUD completo con ModelForms
<!-- primera línea de la descripción de la práctica -->
Crear, editar y eliminar un Servicio desde pantallas propias
<!-- segunda línea de la descripción de la práctica -->
(sin usar /admin), con validacion personalizada en el precio.
Las líneas <!-- … --> son comentarios de Markdown: GitHub no las muestra en la página del repo, así que puedes dejarlas o borrarlas.
# agrega solo el README modificado al área de preparación de Git
git add README.md
# crea el commit con un mensaje que explica el cambio
git commit -m "Actualizar README con avance de semana 5"
# sube el commit al repo u3s5-…; Render detecta el push y redespliega solo
git push
Entregar
Pega en la tarea de la Semana 5: el enlace del repo (ej. https://github.com/tu-usuario/u3s5-hernandez-nunez-maria-jose) y la URL pública de Render (ej. https://u3s5-hernandez-nunez-maria-jose.onrender.com/servicios/).
Cómo presentar en vivo — guion paso a paso
Un recorrido de ~5 minutos frente al profesor.
Mostrar el listado en producción
Abre /servicios/ y señala los enlaces "+ Nuevo servicio", "Editar" y "Eliminar".
Crear un registro en vivo
Completa el formulario con datos reales y muestra que aparece de inmediato en la tabla.
Editar ese mismo registro
Explica que es el mismo formulario de crear, precargado gracias a instance=.
Mostrar la validación personalizada
Intenta guardar un precio en 0 y muestra el mensaje de error; explica que viene de clean_precio en forms.py, no del modelo.
Eliminar con confirmación
Muestra la pantalla de "¿Seguro?" y explica por qué el borrado nunca ocurre en una petición GET.
Tabla de referencias — de dónde sale toda la información
Todo lo de esta guía viene de documentación oficial de cada herramienta, no de blogs de terceros.
| Herramienta | Para qué | Dónde |
|---|---|---|
| Django — ModelForm | Generar formularios a partir de modelos | docs.djangoproject.com — ModelForm |
| Django — CSRF protection | Por qué y cómo funciona {% csrf_token %} |
docs.djangoproject.com — CSRF |
| Django — Forms: the view | Patrón GET/POST para procesar formularios | docs.djangoproject.com — Forms |
| Django — get_object_or_404 | Buscar un registro o devolver 404 automáticamente | docs.djangoproject.com — Shortcuts |
| Django — URL dispatcher | path(), convertidores <int:pk> y name= |
docs.djangoproject.com — URLs |
| Django — Form validation | Validación personalizada con clean_<campo> |
docs.djangoproject.com — Form validation |
| Django — Migrations | Cuándo hace falta makemigrations/migrate |
docs.djangoproject.com — Migrations |
| Render — Deploy Django | Web Service, Build Command y Start Command con gunicorn | render.com/docs — Deploy Django |
| Render — Environment Variables | Cargar variables a mano o con «Add from .env» | render.com/docs — Environment Variables |
| Django — Settings de despliegue | SECRET_KEY, DEBUG, ALLOWED_HOSTS, CSRF_TRUSTED_ORIGINS |
docs.djangoproject.com — Deployment checklist |