07/08
Pulido funcional, UX y simulacro de defensa
El CRUD y la autenticación de la Unidad 3 ya funcionan — esta semana no se agrega ninguna función grande nueva. Se pule lo que ya existe: paginación, búsqueda simple, mensajes de "aún no hay registros", una revisión de accesibilidad entre compañeros y un simulacro completo de la defensa oral que se presenta la Semana 8.
u4s7-apellidopaterno-apellidomaterno-nombres y su Web Service en https://u4s7-apellidopaterno-apellidomaterno-nombres.onrender.com — versión "feature-complete": todo el CRUD + login + UX pulida, sin errores 500 visibles.
Revisión grupal de bitácora de commits
Antes de tocar código, se revisa si el historial de Git realmente refleja 6 semanas de progreso real, o si aparece todo junto de golpe.
Ver el historial completo en una sola línea por commit
# --oneline: muestra cada commit en una sola línea (hash corto + mensaje)
git log --oneline
# --stat: además muestra cuántos archivos cambiaron en cada commit (sal de la lista con la tecla q)
git log --oneline --stat
Autoevaluar tu propia bitácora
¿Hay al menos un commit por semana desde la Semana 3? (Las Semanas 1 y 2 fueron otro proyecto, en sus propios repositorios; el proyecto Django empezó en la Semana 3 y su historial se fue llevando a cada repo nuevo con git remote set-url.) ¿Los mensajes describen qué cambió (ej. "Agregar modelo Servicio y migraciones") o son genéricos (ej. "cambios", "update")? Anota qué semana tiene el historial más débil — eso es lo primero a mejorar hoy.
El rastreo de commits pesa 20% de la calificación de cada Unidad en la Matriz Anti-IA del curso (ver lista-cotejo-evaluacion.html). Un historial con un solo commit gigante la noche anterior es una señal de alerta explícita, aunque el código final funcione.
Sesión de pulido: paginación, búsqueda simple y estados vacíos
Tres mejoras chicas que hacen una diferencia grande en cómo se ve tu vitrina en la defensa: que no muestre 40 filas de golpe, que se pueda buscar y que no se vea "roto" cuando todavía no hay datos.
Agregar el import de Paginator
Abre core/views.py. Arriba, entre los import, busca la línea from django.contrib import messages (Semana 6) y pega debajo de ella estas 2 líneas:
# Paginator: divide una lista larga de registros en páginas de tamaño fijo
from django.core.paginator import Paginator
Paginación y búsqueda en la vista servicios
En el mismo archivo busca def servicios(request):. Selecciona desde el comentario # @login_required… que está justo arriba hasta su línea return, bórralo y pega en su lugar:
# @login_required: si no hay sesión, manda a LOGIN_URL en lugar de ejecutar la vista
@login_required
# servicios: la vista del listado, ahora con búsqueda y paginación
def servicios(request):
# filter(propietario=request.user): solo los servicios del usuario actual (Semana 6)
# order_by('-creado_en', '-id'): del más nuevo al más viejo; '-id' desempata si dos se crearon al mismo tiempo (paginar exige un orden fijo)
lista_servicios = Servicio.objects.filter(propietario=request.user).order_by('-creado_en', '-id')
# request.GET.get('q', ''): lee el texto buscado desde ?q=algo; '' si no hay búsqueda · strip() quita espacios sobrantes
query = request.GET.get('q', '').strip()
# si el usuario escribió algo en el buscador...
if query:
# titulo__icontains=query: WHERE titulo LIKE '%query%', sin distinguir mayúsculas/minúsculas
lista_servicios = lista_servicios.filter(titulo__icontains=query)
# Paginator(lista, 5): agrupa los resultados (ya filtrados) en páginas de 5 registros cada una
paginator = Paginator(lista_servicios, 5)
# request.GET.get('page'): lee el número de página desde la URL, ej. ?page=2
numero_pagina = request.GET.get('page')
# get_page(): devuelve la página pedida; si no es un número da la 1, y si se pasa del total da la última
pagina = paginator.get_page(numero_pagina)
# servicios: la página actual (se recorre igual que una lista) · query: para que el buscador recuerde lo escrito
return render(request, 'core/servicios.html', {'servicios': pagina, 'query': query})
El orden importa: primero se filtra por dueño, luego por la búsqueda y al final se pagina, para que las páginas se armen sobre los resultados ya filtrados. pagina se comporta igual que la lista de siempre dentro de {% for %}, pero además trae has_next, has_previous y number para dibujar los controles.
Plantilla del listado: buscador, estado vacío y controles de página
Abre core/templates/core/servicios.html, selecciona todo con Ctrl + A, bórralo y pega el archivo completo:
{# extends: el listado 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" #}
<a href="{% url 'crear_servicio' %}" class="inline-block mb-4 bg-blue-600 text-white px-4 py-2 rounded-lg">+ Nuevo servicio</a>
{# method="GET": la búsqueda queda en la URL (?q=...) y se puede compartir o recargar #}
<form method="GET" class="mb-4 flex flex-wrap gap-2 items-center">
{# name="q": debe coincidir con request.GET.get('q') de la vista · value: conserva lo que se buscó · aria-label: nombre para lectores de pantalla #}
<input type="text" name="q" value="{{ query }}" placeholder="Buscar por título..." aria-label="Buscar servicios por título" class="max-w-xs">
{# botón que envía la búsqueda #}
<button type="submit" class="bg-slate-700 text-white px-3 py-2 rounded-lg">Buscar</button>
{# if query: el enlace "Quitar búsqueda" solo aparece si hay algo buscado #}
{% if query %}
{# enlace a /servicios/ sin ?q=, para volver a ver todo #}
<a href="{% url 'servicios' %}" class="text-slate-600 underline">Quitar búsqueda</a>
{# endif: fin del bloque condicional #}
{% endif %}
{# cierra el formulario de búsqueda #}
</form>
{# overflow-x-auto: en celulares (~375px) la tabla se desplaza de lado en vez de romper la página #}
<div class="overflow-x-auto">
{# 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: 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 página actual #}
{% 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; whitespace-nowrap evita que los 2 enlaces se partan en 2 renglones #}
<td class="px-4 py-2 whitespace-nowrap">
{# 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: se dibuja SOLO si no hay ningún servicio que mostrar #}
{% empty %}
{# fila única con el aviso; colspan="3" la estira a lo ancho de las 3 columnas #}
<tr>
{# if query: el mensaje cambia si la lista está vacía por una búsqueda o porque no hay registros #}
<td colspan="3" class="text-center text-slate-500 py-6">{% if query %}No hay servicios que coincidan con "{{ query }}".{% else %}Aún no tienes servicios. ¡Crea el primero!{% endif %}</td>
{# cierra la fila del aviso #}
</tr>
{# endfor: fin de la repetición #}
{% endfor %}
{# cierra el cuerpo de la tabla #}
</tbody>
{# cierra la tabla #}
</table>
{# cierra el contenedor con desplazamiento lateral #}
</div>
{# if num_pages > 1: los controles de página solo aparecen si hay más de una página #}
{% if servicios.paginator.num_pages > 1 %}
{# nav: barra de paginación; aria-label la nombra para lectores de pantalla #}
<nav class="mt-4 flex items-center gap-2" aria-label="Paginación">
{# has_previous: True si no estamos en la primera página #}
{% if servicios.has_previous %}
{# previous_page_number: la página anterior · q=…: conserva la búsqueda al cambiar de página #}
<a href="?{% if query %}q={{ query|urlencode }}&{% endif %}page={{ servicios.previous_page_number }}" class="text-blue-600 underline px-2 py-2">← Anterior</a>
{# endif: fin del bloque "Anterior" #}
{% endif %}
{# number y paginator.num_pages: página actual sobre el total de páginas #}
<span class="text-slate-500">Página {{ servicios.number }} de {{ servicios.paginator.num_pages }}</span>
{# has_next: True si existe una página después de la actual #}
{% if servicios.has_next %}
{# next_page_number: la página siguiente · q=…: conserva la búsqueda al cambiar de página #}
<a href="?{% if query %}q={{ query|urlencode }}&{% endif %}page={{ servicios.next_page_number }}" class="text-blue-600 underline px-2 py-2">Siguiente →</a>
{# endif: fin del bloque "Siguiente" #}
{% endif %}
{# cierra la barra de paginación #}
</nav>
{# endif: fin del bloque de paginación #}
{% endif %}
{# endblock: fin del bloque contenido #}
{% endblock %}
- Buscador: es un
<form method="GET">; lo escrito viaja en la URL como?q=…y el campo conserva el texto gracias avalue="{{ query }}". - Estado vacío:
{% empty %}ya lo tenías desde la Semana 5; ahora el mensaje cambia si la tabla está vacía por una búsqueda sin resultados o porque todavía no hay servicios. - Controles de página: los enlaces llevan
q=…además depage=…; sin eso, al pasar a la página 2 se perdería la búsqueda. Solo aparecen si hay más de una página. - Celular:
overflow-x-autohace que la tabla se deslice de lado en pantallas de ~375px en lugar de romper la página (punto 4 de la UI review del paso 03).
Compara: así debe quedar views.py completo
Si algo falla, compara tu archivo con este (también puedes reemplazar todo tu views.py por este bloque):
# render, redirect, get_object_or_404: los mismos atajos de Django que ya usabas en la Semana 5
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
# login: inicia la sesión de un usuario · logout: la cierra
from django.contrib.auth import login, logout
# UserCreationForm: formulario de registro listo · AuthenticationForm: formulario de inicio de sesión listo
from django.contrib.auth.forms import UserCreationForm, AuthenticationForm
# messages: mensajes flash que se muestran una sola vez y luego desaparecen
from django.contrib import messages
# Paginator: divide una lista larga de registros en páginas de tamaño fijo
from django.core.paginator import Paginator
# Servicio: el modelo de la Semana 4
from .models import Servicio
# ServicioForm: el ModelForm de la Semana 5
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)
# @login_required: si no hay sesión, manda a LOGIN_URL en lugar de ejecutar la vista
@login_required
# servicios: la vista del listado, ahora con búsqueda y paginación
def servicios(request):
# filter(propietario=request.user): solo los servicios del usuario actual (Semana 6)
# order_by('-creado_en', '-id'): del más nuevo al más viejo; '-id' desempata si dos se crearon al mismo tiempo (paginar exige un orden fijo)
lista_servicios = Servicio.objects.filter(propietario=request.user).order_by('-creado_en', '-id')
# request.GET.get('q', ''): lee el texto buscado desde ?q=algo; '' si no hay búsqueda · strip() quita espacios sobrantes
query = request.GET.get('q', '').strip()
# si el usuario escribió algo en el buscador...
if query:
# titulo__icontains=query: WHERE titulo LIKE '%query%', sin distinguir mayúsculas/minúsculas
lista_servicios = lista_servicios.filter(titulo__icontains=query)
# Paginator(lista, 5): agrupa los resultados (ya filtrados) en páginas de 5 registros cada una
paginator = Paginator(lista_servicios, 5)
# request.GET.get('page'): lee el número de página desde la URL, ej. ?page=2
numero_pagina = request.GET.get('page')
# get_page(): devuelve la página pedida; si no es un número da la 1, y si se pasa del total da la última
pagina = paginator.get_page(numero_pagina)
# servicios: la página actual (se recorre igual que una lista) · query: para que el buscador recuerde lo escrito
return render(request, 'core/servicios.html', {'servicios': pagina, 'query': query})
# @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'})
# @login_required: solo usuarios con sesión pueden editar
@login_required
# pk: el id que viene en la URL /servicios/<pk>/editar/
def editar_servicio(request, pk):
# get_object_or_404 con DOS condiciones: el id correcto Y que el dueño sea el usuario actual
# si alguien intenta editar el servicio de otro cambiando el número en la URL, recibe un 404
servicio = get_object_or_404(Servicio, pk=pk, propietario=request.user)
# si el usuario envió el formulario con cambios, la petición llega como POST
if request.method == 'POST':
# instance=servicio: el formulario actualiza ESE registro en vez de crear uno nuevo
form = ServicioForm(request.POST, instance=servicio)
# is_valid(): revisa tipos, campos obligatorios y tu clean_precio
if form.is_valid():
# save(): guarda los cambios en MySQL (UPDATE)
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:
# formulario precargado con los datos actuales del servicio
form = ServicioForm(instance=servicio)
# render: dibuja el mismo template de Crear, con título "Editar servicio"
return render(request, 'core/servicio_form.html', {'form': form, 'titulo_pagina': 'Editar servicio'})
# @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):
# mismo candado que en editar: el id correcto Y que el dueño sea el usuario actual; si no, 404
servicio = get_object_or_404(Servicio, pk=pk, propietario=request.user)
# 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})
# registro: la vista que atiende /registro/ (recibe la petición del navegador)
def registro(request):
# si el usuario envió el formulario (botón «Registrarme»), la petición llega como POST
if request.method == 'POST':
# reconstruye el formulario con los datos tecleados (usuario y contraseñas)
form = UserCreationForm(request.POST)
# is_valid(): revisa que el usuario no exista y que las 2 contraseñas coincidan y sean seguras
if form.is_valid():
# save(): crea el User en MySQL, ya con la contraseña hasheada por Django
usuario_nuevo = form.save()
# login(): inicia sesión automáticamente con la cuenta recién creada
login(request, usuario_nuevo)
# messages.success: mensaje verde de bienvenida que se verá una sola vez en la siguiente página
messages.success(request, f'Cuenta creada. ¡Bienvenido, {usuario_nuevo.username}!')
# redirect: manda al usuario ya logueado al listado (name='servicios')
return redirect('servicios')
# si no es POST, es GET: el usuario apenas abrió la página
else:
# formulario vacío para cuando la vista se abre por primera vez (GET)
form = UserCreationForm()
# render: dibuja registro.html con el formulario (vacío o con los errores)
return render(request, 'core/registro.html', {'form': form})
# iniciar_sesion: la vista que atiende /login/
def iniciar_sesion(request):
# si el usuario envió usuario y contraseña, la petición llega como POST
if request.method == 'POST':
# AuthenticationForm necesita "request" además de los datos, para poder validar contra la BD
form = AuthenticationForm(request, data=request.POST)
# is_valid(): aquí Django comprueba que el usuario exista y la contraseña sea correcta
if form.is_valid():
# get_user(): devuelve el User ya verificado · login(): guarda la sesión en una cookie
login(request, form.get_user())
# messages.success: mensaje verde que confirma el inicio de sesión
messages.success(request, 'Sesión iniciada correctamente.')
# redirect: tras iniciar sesión, lo manda al listado de servicios
return redirect('servicios')
# si no es POST, es GET: el usuario apenas abrió la página de login
else:
# AuthenticationForm(): formulario vacío de usuario y contraseña
form = AuthenticationForm()
# render: dibuja login.html con el formulario (vacío o con el error de credenciales)
return render(request, 'core/login.html', {'form': form})
# cerrar_sesion: la vista que atiende /logout/
def cerrar_sesion(request):
# logout(request): invalida la sesión actual; request.user vuelve a ser AnonymousUser
logout(request)
# messages.info: guarda un mensaje que se muestra una única vez en la próxima página
messages.info(request, 'Sesión cerrada correctamente.')
# redirect: manda a la pantalla de login, donde se verá el mensaje
return redirect('login')
Probar todo en tu computadora
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 (se detiene con Ctrl + C)
python manage.py runserver
- Inicia sesión y abre
http://127.0.0.1:8000/servicios/. Para ver la paginación necesitas más de 5 servicios en tu cuenta: crea los que falten con «+ Nuevo servicio». - Abajo de la tabla debe decir «Página 1 de 2». Clic en «Siguiente →»: la URL cambia a
?page=2. - Escribe parte de un título en el buscador y clic en «Buscar»: solo quedan los que coinciden, sin importar mayúsculas.
- Busca algo que no exista (ej.
zzz): debe salir «No hay servicios que coincidan con "zzz".». Clic en «Quitar búsqueda» para volver a ver todo. - Escribe a mano
?page=99en la URL: debe mostrar la última página, no un error. - Achica la ventana del navegador a lo más angosto: la tabla se desliza de lado y no rompe la página.
| Error que ves | Causa | Solución |
|---|---|---|
NameError: name 'Paginator' is not defined | Falta el import del sub-paso 01 | Pega las 2 líneas debajo de from django.contrib import messages. |
| Al pasar de página se pierde la búsqueda | Los enlaces de página no llevan q= | Copia otra vez la plantilla completa del sub-paso 03. |
| No aparecen «Anterior / Siguiente» | Tienes 5 servicios o menos (cabe todo en una página) | Crea más servicios; los controles salen desde el sexto. |
TemplateSyntaxError | Comentario <!-- --> con {% %} adentro, o falta un {% endif %} | Compara con la plantilla de la guía (los comentarios van con {# #}). |
{% empty %} vive dentro del mismo {% for %}, justo antes de {% endfor %} — no hace falta un {% if %} aparte para detectar la lista vacía.
UI review por pares — checklist de accesibilidad y responsividad
Intercambia tu URL de producción con un compañero. Cada quien revisa el sitio del otro con esta checklist y anota hallazgos concretos (no solo "se ve bien" o "se ve mal").
¿Todo botón que solo tiene un ícono (sin texto) lleva un aria-label describiendo su acción?
¿El texto de menor contraste (ej. text-slate-400 sobre blanco) se sigue leyendo cómodo, o hace falta oscurecerlo?
¿Se puede recorrer todo el CRUD solo con la tecla Tab, viendo siempre un foco visible?
Achicando la ventana a ~375px de ancho: ¿la tabla o el formulario se rompen, o se reacomodan?
¿Los botones y enlaces son cómodos de tocar en una pantalla táctil (alto mínimo aproximado de 44px)?
¿El estado vacío del paso 02 se ve intencional, o parece que la página "se rompió"?
Guarda los 2-3 hallazgos más importantes que te haga tu compañero — corregirlos es justo el contenido de esta misma sesión si sobra tiempo, antes de publicar en el paso 07.
Simulacro de Live Coding (rol al azar)
El docente asigna al azar uno de los retos de Live Coding de la lista de cotejo de la Unidad 3. El objetivo no es que salga perfecto — es identificar en qué parte del código todavía dudas, mientras hay tiempo de repasarla antes de la Semana 8.
# Ejemplo: el mismo reto sugerido en el cierre de la Unidad 2 (lo que el docente te leería en voz alta)
"Agrega el campo 'disponible = models.BooleanField(default=True)' al modelo Servicio, corre makemigrations y migrate en vivo, y muestra que los registros existentes siguen intactos después de migrar."
Cronómetro real
Dale un límite de tiempo real (5-7 minutos) a tu simulacro, igual que en la defensa de la Semana 8 — practicar bajo presión de tiempo es parte del entrenamiento.
Si el simulacro sale mal, es información valiosa: mejor descubrirlo hoy, con una semana entera por delante para repasar, que en la defensa real de la Semana 8.
Examen conceptual rápido — repaso de las 3 Unidades técnicas
Repaso presencial, sin dispositivos, de los tres bloques conceptuales que ya se evaluaron por Unidad — no hay contenido nuevo, es memoria activa antes de la Semana 8.
Flujo HTTP: recorre paso a paso qué pasa desde que el navegador pide una URL hasta que ves el HTML final.
ORM: ¿qué SQL genera por detrás Servicio.objects.filter(propietario=request.user)?
Arquitectura MTV: ¿qué responsabilidad tiene el Model, cuál la View y cuál el Template en tu propio proyecto?
Seguridad: ¿por qué propietario=request.user dentro de get_object_or_404 evita que alguien edite datos ajenos?
Práctica autónoma — preparar el guion de defensa de 2 minutos
Un guion corto y ensayado evita quedarse en blanco frente al docente. Completa esta plantilla con tu propio proyecto — la vas a usar como base en la Semana 8.
# Bloque 1 (30 seg): qué hace el sistema; cambia lo que está entre corchetes
"Mi proyecto es [nombre], una vitrina donde [describe en una frase qué servicio o producto ofrece tu proyecto integrador]."
# Bloque 2 (45 seg): decisiones técnicas y por qué
"Usé Django porque [razón], MySQL en la nube con Aiven porque [razón], y protegí el CRUD con login porque [razón: cada usuario solo ve lo suyo]."
# Bloque 3 (30 seg): un desafío real que resolviste
"El paso más difícil fue [ej. migrar sin romper datos existentes / evitar que un usuario edite el servicio de otro], y lo resolví con [explicación breve y técnica]."
# Bloque 4 (15 seg): cierre con tus dos enlaces
"La URL en vivo es [tu URL de Render u4s7-…] y el repositorio está en [tu URL de GitHub u4s7-…]."
Cronometrarte en voz alta
Léelo en voz alta con un cronómetro real al menos 2 veces. Si se pasa de 2 minutos, recorta detalles, no bloques completos — cada bloque cubre una parte distinta de la rúbrica de defensa oral.
Este guion es la base del Anexo H — Guion de Defensa Oral palabra por palabra del curso; revísalo ahí si quieres un modelo más extenso ya redactado.
Publicar la versión pulida en Render (repo u4s7-…)
Como en cada práctica, esta versión va en su propio repositorio y su propio Web Service, con las mismas 9 variables de entorno de las Semanas 5 y 6. Tu repo u3s6-… se queda intacto como evidencia.
Revisar si hay migraciones pendientes (antes de subir)
Esta práctica no cambia el modelo: solo cambia la vista servicios y la plantilla del listado (la paginación y la búsqueda no crean columnas), así que normalmente no hay migración nueva. Pero si por cualquier 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 07.3): 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 07.2 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 u4s7-… y subir los cambios
Cada práctica va en su propio repositorio y en su propio Web Service de Render, con el nombre u4s7-apellidopaterno-apellidomaterno-nombres (minúsculas, sin acentos, ñ → n, espacios → guion). Ej.: u4s7-hernandez-nunez-maria-jose. El repo de la Semana 6 (u3s6-…) 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 esu4s7. - 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 07.3). Así tu URL queda
https://u4s7-….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 | u4s7-hernandez-nunez-maria-jose |
| José Ángel De la Cruz Peña | u4s7-de-la-cruz-pena-jose-angel |
| Ana López (un solo apellido) | u4s7-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 |
u3s6 | Unidad 3 · Semana 6 — Autenticación y sesiones |
u4s7 | Unidad 4 · Semana 7 — Pulido y defensa (esta guía) |
u4s8 | Unidad 4 · Semana 8 — Deploy final |
Genera tu nombre sin errores
u4s7-hernandez-nunez-maria-joseTu repo quedará en https://github.com/TU_USUARIO/u4s7-hernandez-nunez-maria-jose y, con el mismo nombre en Render (paso 07.3), tu sitio en https://u4s7-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 (
u4s7-…). - 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 u3s6-… de la Semana 6)
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/u4s7-hernandez-nunez-maria-jose.git
# Confirma que ahora apunta al u4s7-… (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 "U4S7: paginacion, busqueda simple y estado vacio"
# 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, manage.py, core/views.py y tus plantillas. 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
u4s7-…(no elu3s6-…) y clic en Connect. - Si el repo no aparece: clic en Configure GitHub, en «Repository access» agrega
u4s7-…, 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. u4s7-hernandez-nunez-maria-jose) | Se vuelve tu dirección: https://u4s7-hernandez-nunez-maria-jose.onrender.com. Anótala; la usas en 07.4. |
| 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 desde 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 | u4s7-hernandez-nunez-maria-jose.onrender.com | Tu dominio de Render: sin https:// y sin / al final. |
CSRF_TRUSTED_ORIGINS | https://u4s7-hernandez-nunez-maria-jose.onrender.com | La dirección completa: con https:// y sin / al final. Sin ella, el registro, el login y los formularios del CRUD 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=u4s7-hernandez-nunez-maria-jose.onrender.com
# tu dirección completa: CON https:// y SIN / al final
CSRF_TRUSTED_ORIGINS=https://u4s7-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. u4s7-...-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 u4s7-… de GitHub
==> Cloning from https://github.com/tu-usuario/u4s7-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 07.3). |
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/login.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 07.1 y sube core/migrations/ con git push. |
Al entrar a /servicios/ sin sesión manda a /accounts/login/ (404) | Falta LOGIN_URL en settings.py | Agrega LOGIN_URL = 'login' (Semana 6, paso 03) y haz 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 la versión pulida en producción
- Abre
https://u4s7-tus-apellidos-nombres.onrender.com/servicios/y entra con tu cuenta (tus usuarios y servicios ya existen: Render usa la misma base de Aiven). - Repite en producción las pruebas del paso 02.05: «Página 1 de 2», «Siguiente →», buscar, buscar algo inexistente y «Quitar búsqueda».
- Confirma el candado de HTTPS y que
/no-existe/muestra un «Not Found» simple, sin la página amarilla de Django. - Pásale esta URL a tu compañero para la UI review del paso 03.
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 del repo: cambia por tus nombres reales -->
# U4S7 — [Apellido Paterno] [Apellido Materno] [Nombre(s)]
<!-- sección con la URL pública de Render -->
## Demo en vivo (producción)
<!-- tu dirección real de Render de esta práctica -->
https://u4s7-hernandez-nunez-maria-jose.onrender.com
<!-- resumen de lo que hiciste esta semana -->
## Semana 7 — Pulido funcional y UX
<!-- primera línea del resumen -->
Paginación, búsqueda simple y estado vacío agregados al listado.
<!-- segunda línea: los hallazgos de tu compañero y cómo los corregiste -->
Hallazgos de la UI review corregidos: [lístalos aquí].
# 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 7"
# sube el commit al repo u4s7-…; Render detecta el push y redespliega solo
git push
Entregar
Pega en la tarea de la Semana 7: el enlace del repo (ej. https://github.com/tu-usuario/u4s7-hernandez-nunez-maria-jose), la URL pública de Render (ej. https://u4s7-hernandez-nunez-maria-jose.onrender.com/servicios/) y los 2-3 hallazgos de la UI review (propios y del compañero).
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 — Pagination | Paginator y get_page() |
docs.djangoproject.com — Pagination |
| Django — QuerySet field lookups | icontains y otros filtros de búsqueda |
docs.djangoproject.com — Field lookups |
| Django — for ... empty | Mostrar contenido alternativo cuando la lista está vacía | docs.djangoproject.com — for empty |
| W3C WAI-ARIA | Navegación por teclado y buenas prácticas de accesibilidad | w3.org — Keyboard interface |
| Tailwind CSS — Responsive design | Cómo probar y ajustar breakpoints | tailwindcss.com — Responsive design |
| Git — git log | Revisar el historial de commits | git-scm.com — git log |