APLICAWEB 4to/Propuesta Intensiva 8 Semanas/Unidad 3/Semana 5
4 hrs guiadas + 1 hr autónoma
semana
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.

Entregable: el repositorio 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.
01

ModelForm, {% csrf_token %} y el ciclo petición/respuesta

⏱ 0:00–0:40práctica guiada

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.

01

Crear el archivo forms.py

core/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']
TIP

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.

02

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.

plantilla (fragmento genérico)
{# 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>
IMPORTANTE · COMENTARIOS EN PLANTILLAS

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.

03

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.

02

Vista y template de "Crear" — y actualizar "Listar" con enlaces

⏱ 0:40–1:40práctica guiada

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.

01

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:

miproyecto/settings.py — pega al FINAL del archivo
# 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.

02

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.

core/views.py — archivo COMPLETO (reemplaza todo)
# 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'})
03

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:

core/templates/core/base.html — archivo NUEVO
{# 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.

04

Template reutilizable del formulario

Clic derecho en core/templates/core/ → New File → servicio_form.html, y pega:

core/templates/core/servicio_form.html — archivo NUEVO
{# 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.

05

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.

core/templates/core/servicios.html — archivo COMPLETO (reemplaza todo)
{# 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.

NO PRUEBES TODAVÍA

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.

OJO

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.

03

Vista y template de "Editar" reutilizando el mismo formulario

⏱ 1:40–2:40práctica guiada

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=.

01

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.

core/views.py — pega al FINAL del archivo
# @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.

TIP

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.

04

Vista de "Eliminar" con pantalla de confirmación

⏱ 2:40–3:20práctica guiada

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.

01

Vista eliminar_servicio

Otra vez al final de core/views.py, debajo de editar_servicio, pega:

core/views.py — pega al FINAL del archivo
# @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})
02

Template de confirmación

Clic derecho en core/templates/core/ → New File → servicio_confirm_delete.html, y pega:

core/templates/core/servicio_confirm_delete.html — archivo NUEVO
{# 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 %}
OJO

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.

05

Registrar las rutas, hacer deploy y probar el CRUD completo en producción

⏱ 3:20–4:00práctica guiada

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.

05.1

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».

core/urls.py — archivo COMPLETO (reemplaza todo)
# 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
]
05.2

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:

TERMINAL DE VS CODE — PowerShell · con (venv) activo
# 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
  1. Abre http://127.0.0.1:8000/servicios/: debes ver la tabla con la columna Acciones y el botón azul + Nuevo servicio.
  2. 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.
  3. Llena los 4 campos y clic en Guardar: vuelves a la tabla y el servicio nuevo aparece.
  4. Clic en Editar de ese servicio: el formulario sale con sus datos. Cambia el precio y Guardar.
  5. Clic en Eliminar: sale «¿Eliminar …?». Clic en Sí, eliminar y el registro desaparece de la tabla.
  6. Abre http://127.0.0.1:8000/servicios/999/editar/ (un id que no existe): debe salir «Page not found (404)».
  7. Detén el servidor con Ctrl + C en la terminal.
Error que vesCausaSolución
TemplateSyntaxErrorUn comentario <!-- --> con {% %} adentro, o falta un {% endblock %} / {% endfor %}Usa comentarios {# #} (paso 01) y compara tu archivo con el de la guía.
NoReverseMatchUna ruta de {% url %} no está en urls.py o su name= está mal escritoRevisa que core/urls.py sea igual al de 05.1.
TemplateDoesNotExistEl archivo no está en core/templates/core/ o tiene otro nombreRevisa la carpeta y el nombre exacto (minúsculas, .html).
AttributeError: module 'core.views' has no attribute …Falta pegar una función en views.pyRevisa que estén crear_servicio, editar_servicio y eliminar_servicio.
Page not found en /accounts/login/Falta LOGIN_URLRepite el paso 02.1.
La página sale sin estilos o sin bordes en los camposLa plantilla no empieza con {% extends 'core/base.html' %}Revisa la primera línea de código de la plantilla.
05.3

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:

TERMINAL DE VS CODE — PowerShell · con (venv) activo
# 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
NOTA · MIGRACIONES EN RENDER

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.

05.4

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)

prefijou3s5- apellido paternohernandez- apellido maternonunez- nombre(s)maria-jose
  • Prefijo de la práctica: u + número de unidad + s + número de semana. Esta práctica es u3s5.
  • Todo en minúsculas, sin acentos (á → a), la ñ se escribe n y 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.
AlumnoNombre del repositorio y del servicio de Render
María José Hernández Núñezu3s5-hernandez-nunez-maria-jose
José Ángel De la Cruz Peñau3s5-de-la-cruz-pena-jose-angel
Ana López (un solo apellido)u3s5-lopez-ana
PrefijoPráctica del curso
u1s1Unidad 1 · Semana 1 — Sitio estático en Render
u1s2Unidad 1 · Semana 2 — Tailwind CSS
u2s3Unidad 2 · Semana 3 — Django + MySQL en Aiven
u2s4Unidad 2 · Semana 4 — Modelos y deploy en Render
u3s5Unidad 3 · Semana 5 — CRUD con ModelForms (esta guía)
u3s6Unidad 3 · Semana 6 — Autenticación y sesiones
u4s7Unidad 4 · Semana 7 — Pulido y defensa
u4s8Unidad 4 · Semana 8 — Deploy final

Genera tu nombre sin errores

Repositorio y servicio de Renderu3s5-hernandez-nunez-maria-jose

Tu 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».

  1. Entra a https://github.com y confirma arriba a la derecha que es tu cuenta.
  2. Clic en + → New repository. En Repository name pega el nombre que copiaste del generador de arriba (u3s5-…).
  3. 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.
TERMINAL DE VS CODE — PowerShell · en la carpeta que tiene manage.py
# 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).

05.5

Crear el Web Service en Render, campo por campo

  1. Abre https://dashboard.render.com e inicia sesión con GitHub.
  2. Arriba a la derecha: + New → Web Service. No elijas «Static Site».
  3. En «Source Code» elige Git Provider, busca tu repo u3s5-… (no el u2s4-…) y clic en Connect.
  4. 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:

CampoQué escribirPor qué
NameEl 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.
LanguagePython 3Render lo detecta por requirements.txt; si no, elígelo.
BranchmainCada git push a esta rama redespliega solo.
RegionLa que venga (ej. Oregon (US West))Dónde está el servidor. Cualquiera sirve.
Root DirectoryVacíoSolo se llena si manage.py está en una subcarpeta (ej. backend).
Build CommandEl del bloque de abajoSe corre una vez en cada deploy: instala, junta estáticos y migra.
Start CommandEl del bloque de abajoSe queda corriendo siempre para atender visitas.
Instance TypeFreeGratis; alcanza para el curso.
RENDER — campo «Build Command» (en una sola línea)
# 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
RENDER — campo «Start Command»
# 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.

05.6

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_NAMENombre de la base, normalmente defaultdbAiven → tu servicio MySQL → Overview → «Database name». Igual que en tu .env.
DB_USERUsuario de MySQL, normalmente avnadminAiven → Overview → «User». Igual que en tu .env.
DB_PASSWORDLa contraseña real de AivenAiven → Overview → «Password» (icono del ojo). Igual que en tu .env.
DB_HOSTEl host, termina en .aivencloud.com, sin https://Aiven → Overview → «Host». Igual que en tu .env.
DB_PORTUn número de 5 dígitos (ej. 12345), no 3306Aiven → Overview → «Port». Igual que en tu .env.
SECRET_KEYUna clave larga nueva, distinta de la localSe genera en la terminal de VS Code (bloque de abajo). No la reutilices.
DEBUGFalse (con F mayúscula)En producción siempre False. En tu .env local va True.
ALLOWED_HOSTSu3s5-hernandez-nunez-maria-jose.onrender.comTu dominio de Render: sin https:// y sin / al final.
CSRF_TRUSTED_ORIGINShttps://u3s5-hernandez-nunez-maria-jose.onrender.comLa dirección completa: con https:// y sin / al final. Sin ella, los formularios del CRUD y el login dan «403 CSRF».
TERMINAL DE VS CODE — generar la SECRET_KEY de producción
# 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.

RENDER — «Add from .env» (cambia TODOS los valores por los tuyos)
# 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
OJO

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.

TIP

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.

05.7

Desplegar y leer el log

Clic en Deploy Web Service. Tarda de 2 a 5 minutos. En el log debes ver, en este orden:

LOG DE RENDER — lo que significa cada línea
# 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 navegadorCausaSolución
SECRET_KEY not found (u otra variable)Falta una variable o el nombre tiene un error de dedoPestaña Environment: revisa las 9 y sus nombres exactos.
ModuleNotFoundError: No module named 'miproyecto'Start Command con otro nombre de carpetaSettings → «Start Command»: corrígelo (ver 05.5).
Can't connect to MySQL server / Access deniedAlgún DB_ mal copiado o Aiven apagadoCompara con tu .env y revisa que Aiven diga «Running».
Bad Request (400) al abrir la páginaALLOWED_HOSTS no coincide con tu dominioCorrígelo sin https:// y sin /.
Prohibido (403) · CSRF verification failed al guardar un formularioFalta o está mal CSRF_TRUSTED_ORIGINSDebe ser https://tu-dominio.onrender.com, sin / final.
TemplateDoesNotExist: core/servicio_form.htmlEl template no se subió o tiene otro nombreRevisa en GitHub la carpeta core/templates/core/.
Table '...core_servicio' doesn't exist / Unknown columnCambiaste el modelo y no se subió la migraciónRepite 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.

05.8

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.

TIP

¿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.

06

Práctica autónoma — validación personalizada de un campo

⏱ 1 hrpráctica autónoma

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>.

01

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).

core/forms.py — archivo COMPLETO (reemplaza todo)
# 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
02

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.

03

Subir el cambio

TERMINAL DE VS CODE — PowerShell
# 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
TIP

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).

07

Entregar y presentar

Marca cada punto a medida que lo completas — queda guardado en este navegador.

0/7

Cómo subir la práctica

01

Actualizar el README

README.md
<!-- # : 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.

TERMINAL DE VS CODE — PowerShell
# 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
02

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.

0:00

Mostrar el listado en producción

Abre /servicios/ y señala los enlaces "+ Nuevo servicio", "Editar" y "Eliminar".

1:00

Crear un registro en vivo

Completa el formulario con datos reales y muestra que aparece de inmediato en la tabla.

2:00

Editar ese mismo registro

Explica que es el mismo formulario de crear, precargado gracias a instance=.

3:00

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.

4:00

Eliminar con confirmación

Muestra la pantalla de "¿Seguro?" y explica por qué el borrado nunca ocurre en una petición GET.

08

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.

HerramientaPara 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