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

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

Revisión grupal de bitácora de commits

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

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.

01

Ver el historial completo en una sola línea por commit

TERMINAL DE VS CODE — PowerShell · en la carpeta que tiene manage.py
# --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
02

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.

OJO

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.

de dónde sale esto Git Docs: git log
02

Sesión de pulido: paginación, búsqueda simple y estados vacíos

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

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.

01

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:

core/views.py — pega DEBAJO de «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
02

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:

core/views.py — REEMPLAZA la función servicios
# @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.

03

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:

core/templates/core/servicios.html — archivo COMPLETO (reemplaza todo)
{# 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 a value="{{ 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 de page=…; 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-auto hace 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).
04

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

core/views.py — archivo COMPLETO al terminar el paso 02 (para comparar)
# 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')
05

Probar todo en tu computadora

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 (se detiene con Ctrl + C)
python manage.py runserver
  1. 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».
  2. Abajo de la tabla debe decir «Página 1 de 2». Clic en «Siguiente →»: la URL cambia a ?page=2.
  3. Escribe parte de un título en el buscador y clic en «Buscar»: solo quedan los que coinciden, sin importar mayúsculas.
  4. 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.
  5. Escribe a mano ?page=99 en la URL: debe mostrar la última página, no un error.
  6. Achica la ventana del navegador a lo más angosto: la tabla se desliza de lado y no rompe la página.
Error que vesCausaSolución
NameError: name 'Paginator' is not definedFalta el import del sub-paso 01Pega las 2 líneas debajo de from django.contrib import messages.
Al pasar de página se pierde la búsquedaLos 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.
TemplateSyntaxErrorComentario <!-- --> con {% %} adentro, o falta un {% endif %}Compara con la plantilla de la guía (los comentarios van con {# #}).
TIP

{% empty %} vive dentro del mismo {% for %}, justo antes de {% endfor %} — no hace falta un {% if %} aparte para detectar la lista vacía.

03

UI review por pares — checklist de accesibilidad y responsividad

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

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

1

¿Todo botón que solo tiene un ícono (sin texto) lleva un aria-label describiendo su acción?

2

¿El texto de menor contraste (ej. text-slate-400 sobre blanco) se sigue leyendo cómodo, o hace falta oscurecerlo?

3

¿Se puede recorrer todo el CRUD solo con la tecla Tab, viendo siempre un foco visible?

4

Achicando la ventana a ~375px de ancho: ¿la tabla o el formulario se rompen, o se reacomodan?

5

¿Los botones y enlaces son cómodos de tocar en una pantalla táctil (alto mínimo aproximado de 44px)?

6

¿El estado vacío del paso 02 se ve intencional, o parece que la página "se rompió"?

TIP

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.

04

Simulacro de Live Coding (rol al azar)

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

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 de reto asignado al azar
# 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."
01

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.

OJO

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.

05

Examen conceptual rápido — repaso de las 3 Unidades técnicas

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

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.

1

Flujo HTTP: recorre paso a paso qué pasa desde que el navegador pide una URL hasta que ves el HTML final.

2

ORM: ¿qué SQL genera por detrás Servicio.objects.filter(propietario=request.user)?

3

Arquitectura MTV: ¿qué responsabilidad tiene el Model, cuál la View y cuál el Template en tu propio proyecto?

4

Seguridad: ¿por qué propietario=request.user dentro de get_object_or_404 evita que alguien edite datos ajenos?

TIP

Estas 4 preguntas son un resumen de las que ya aparecen en detalle en Unidad 2 y Unidad 3 de la lista de cotejo — repásalas ahí si alguna te generó dudas.

06

Práctica autónoma — preparar el guion de defensa de 2 minutos

⏱ 1 hrpráctica autónoma

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.

plantilla de guion (completar con tu proyecto)
# 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-…]."
01

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.

TIP

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.

de dónde sale esto Anexo H — Guion de Defensa Oral
07

Publicar la versión pulida en Render (repo u4s7-…)

práctica guiadaen tu computadora y en render.com

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.

07.1

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:

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

07.2

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)

prefijou4s7- 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 u4s7.
  • 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 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.
AlumnoNombre del repositorio y del servicio de Render
María José Hernández Núñezu4s7-hernandez-nunez-maria-jose
José Ángel De la Cruz Peñau4s7-de-la-cruz-pena-jose-angel
Ana López (un solo apellido)u4s7-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
u3s6Unidad 3 · Semana 6 — Autenticación y sesiones
u4s7Unidad 4 · Semana 7 — Pulido y defensa (esta guía)
u4s8Unidad 4 · Semana 8 — Deploy final

Genera tu nombre sin errores

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

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

  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 (u4s7-…).
  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 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).

07.3

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 u4s7-… (no el u3s6-…) y clic en Connect.
  4. 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:

CampoQué escribirPor qué
NameEl 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.
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 desde la Semana 4.

07.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_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_HOSTSu4s7-hernandez-nunez-maria-jose.onrender.comTu dominio de Render: sin https:// y sin / al final.
CSRF_TRUSTED_ORIGINShttps://u4s7-hernandez-nunez-maria-jose.onrender.comLa dirección completa: con https:// y sin / al final. Sin ella, el registro, el login y los formularios del CRUD 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=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
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. 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.

07.5

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 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 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 07.3).
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/login.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 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.pyAgrega 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.

07.6

Probar la versión pulida en producción

  1. 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).
  2. Repite en producción las pruebas del paso 02.05: «Página 1 de 2», «Siguiente →», buscar, buscar algo inexistente y «Quitar búsqueda».
  3. Confirma el candado de HTTPS y que /no-existe/ muestra un «Not Found» simple, sin la página amarilla de Django.
  4. Pásale esta URL a tu compañero para la UI review del paso 03.
08

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 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í].
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 7"
# sube el commit al repo u4s7-…; Render detecta el push y redespliega solo
git push
02

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

de dónde sale esto Django Docs: Pagination
09

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