APLICAWEB 4to/Propuesta Intensiva 8 Semanas/Unidad 4/Práctica final u4p3
Entrega · mar 10 nov · 6:00 am
práctica
final u4p3

Categorías, facturas y reportes SQL sobre tu CRUD de servicios

Partes del proyecto que ya tienes funcionando (servicios + login de la Semana 7) y le agregas tres tablas relacionadas: Categoria, Factura y DetalleFactura. La tabla de usuarios (auth_user) ya está relacionada con tus servicios y ahora también con tus facturas. Al final escribes 6 consultas SQL a mano y las muestras en una página profesional que funciona con cualquier consulta.

Entregable: el repositorio u4p3-apellidopaterno-apellidomaterno-nombres, su Web Service en https://u4p3-apellidopaterno-apellidomaterno-nombres.onrender.com y un video donde lees esta guía en voz alta y la haces paso a paso, sin brincarte ninguna línea. Límite: martes 10 de noviembre, 6:00 am.
3tablas nuevas
6consultas SQL
4pantallas nuevas
≈3 hde video
ANTES DE GRABAR

Haz esta práctica completa al menos 2 veces antes de grabar el video. Llega a la grabación sabiendo qué hace cada paso, cada línea de código y cada consulta SQL. Si en el video se nota que es la primera vez que la haces (dudas en cada paso, lees sin entender, errores que no sabes explicar), tu video no será aceptado.

V

Reglas obligatorias del video

las mismas de la recuperaciónse revisan antes de calificar

Esta práctica se entrega en video, con las mismas reglas de la recuperación de prácticas. Si no cumples cualquiera de ellas, el video no se acepta y tendrías que rehacerlo completo.

1

Presentación inicial

Al inicio dices tu nombre completo (apellido paterno, apellido materno, nombres), tu grupo (DSM4-1 o DSM4-2), la asignatura (Aplicaciones Web) y la práctica (u4p3, práctica final de la Unidad 4). Tu cuenta de GitHub debe verse en pantalla, con tus dos apellidos.

"Hola, mi nombre es Prado Díaz Bernardo, del grupo DSM4-1, de la asignatura Aplicaciones Web. En este video voy a realizar la práctica final u4p3 de la Unidad 4: a mi CRUD de servicios le voy a agregar las tablas de categorías, facturas y detalle de factura, y voy a escribir seis consultas SQL a mano que se muestran en una página de reportes, publicada en Render…"

2

Pantalla en 3 secciones, durante todo el video

  • Lado izquierdo: VS Code, la terminal de VS Code y el navegador con tu app (runserver, GitHub, Render).
  • Lado derecho, arriba: esta guía abierta en el paso que vas leyendo.
  • Esquina inferior derecha: tu cámara, de 1/6 de la altura de la pantalla, con tu rostro visible.
Distribución de pantalla: código a la izquierda, guía arriba a la derecha y cámara abajo a la derecha VS Code · Terminal · App runserver, GitHub, Render Esta guía en el paso que lees Cámara 1/6 →
Captura de ejemplo de una grabación con el sistema a la izquierda, la guía a la derecha y la cámara del docente en la esquina inferior derecha
Ejemplo real de cómo debe verse la pantalla (grabación del docente en otra asignatura). En tu video, la guía de la derecha es esta página.
3

Audio y video

  • Grabado todo: la guía completa, en local y publicada en Render.
  • Volumen alto, sin ruido de fondo y solo tu voz.
  • Resolución mínima 720p: el código y el texto de la guía deben leerse.
4

Leer y hacer, línea por línea, sin brincarte ninguna

Es la regla central. Por cada paso de esta guía:

  1. Lees en voz alta el título y la explicación del paso.
  2. En cada bloque de código lees cada comentario (las líneas grises que empiezan con #, {# o --) y escribes o pegas la línea de abajo en ese momento, explicando con tus palabras qué hace.
  3. Guardas con Ctrl + S, corres el comando en la terminal de VS Code y muestras el resultado.
  4. No se vale pegar un archivo completo en silencio, ni narrar algo que ya estaba hecho antes de grabar.
OJO

Nunca muestres a cámara el contenido de tu .env ni tus contraseñas de Aiven. Si un error te detiene, pausa la grabación, resuélvelo, reanuda y explica qué pasó y qué cambiaste.

5

Despedida y cierre

Repites tu nombre y grupo, resumes lo que construiste, muestras por última vez el repositorio u4p3-… y la URL de Render funcionando, y te despides.

"Esto ha sido todo. Mi nombre es Prado Díaz Bernardo, del grupo DSM4-1. En esta práctica convertí la categoría de texto en una tabla relacionada sin perder datos, agregué facturas con su detalle, escribí seis consultas SQL con SUM, COUNT, MIN, MAX, HAVING y RANK, las optimicé con índices y las publiqué en Render. Aprendí a migrar datos y a leer el plan de ejecución de MySQL. Gracias por su atención."

6

Formato y herramientas

  • Un solo video. Si grabas por partes, únelas en un solo archivo antes de entregarlo. No hay tiempo máximo.
  • No debe verse el uso de IA durante la grabación.
  • Graba con OBS Studio (gratuito, recomendado), Zoom, ShareX, Loom o Camtasia, sin marca de agua.
7

Marcas para la revisión: "Paso NN", un commit por paso y capturas

Tu video se revisa con apoyo de herramientas: se transcribe el audio, se cotejan tus commits y se abren los minutos que tú marcas. Para que eso funcione, cumple estas 3 marcas. Si faltan, tu entrega se revisa completa a mano y queda al final de la fila.

  1. Di el número del paso en voz alta al empezar cada uno, antes de leer el título: "Paso cero dos: Tabla Categoria y una llave foránea temporal…".
  2. Haz un commit al terminar cada paso del 02 al 12 (todos cambian archivos) y uno más en el paso 15 y al entregar: 13 commits en total. Se hacen en local; el git push llega hasta el paso 15. Así tu historial muestra que construiste la práctica paso por paso.
  3. Toma las capturas que pide la tabla de EVIDENCIAS.md en el momento en que aparece el resultado (Win + Shift + S, pega en Paint y guarda en la carpeta evidencias/ de tu proyecto, junto a manage.py).
13 COMMITS

El mensaje exacto de cada commit, con tu nombre al final, está en la sección C · Los 13 commits, justo antes del paso 01.

TIP

Haz el commit frente a la cámara: tarda 10 segundos y es la prueba de que ese paso lo hiciste tú, en ese momento. Nunca agregues el .env: si aparece en git status, revisa tu .gitignore antes de seguir.

C

Los 13 commits y tu firma

⏱ 2 minescribe tu nombre una vez

Haces un commit al terminar cada paso del 02 al 12, otro en el paso 15 y el último al entregar. Cada mensaje empieza con paso NN y termina con tu nombre. Escribe aquí tu nombre y todos los mensajes de esta guía se cambian por el tuyo.

Cómo se escribe cada mensaje de commit

siemprepaso NN: lo que hicistetabla Categoria y …- paterno-materno-nombresprado-diaz-bernardo
  • Empieza con paso NN, con el número a dos dígitos (paso 02, no paso 2). La revisión solo cuenta los commits que empiezan así; por eso tu nombre va al final.
  • Después van dos puntos, un espacio y lo que hiciste, en minúsculas y sin acentos (foranea, migracion).
  • Termina con - y tu nombre unido con guiones, igual que tu repositorio sin el u4p3-: minúsculas, sin acentos, la ñ como n y todos tus nombres.
  • Los pasos 01, 13 y 14 no llevan commit: no cambian archivos. Sus capturas entran en el commit del paso 15.
  • Si escribiste mal un mensaje y aún no haces push, corrígelo con git commit --amend -m "mensaje correcto".

Escribe tu nombre y la guía pone tu firma en todos los commits

tu firma para los commitsprado-diaz-bernardo

Los 13 mensajes exactos, en orden

  1. 1
    Final del paso 02paso 02: tabla Categoria y llave foranea temporal - prado-diaz-bernardo
  2. 2
    Final del paso 03paso 03: migracion de datos que copia las categorias - prado-diaz-bernardo
  3. 3
    Final del paso 04paso 04: quitar categoria de texto y renombrar la llave foranea - prado-diaz-bernardo
  4. 4
    Final del paso 05paso 05: tablas Factura y DetalleFactura con indices - prado-diaz-bernardo
  5. 5
    Final del paso 06paso 06: registrar categorias y facturas en el admin - prado-diaz-bernardo
  6. 6
    Final del paso 07paso 07: formularios de categoria, factura y renglones - prado-diaz-bernardo
  7. 7
    Final del paso 08paso 08: reportes.py con las 6 consultas SQL - prado-diaz-bernardo
  8. 8
    Final del paso 09paso 09: vistas de categorias, facturas y reportes - prado-diaz-bernardo
  9. 9
    Final del paso 10paso 10: rutas de categorias, facturas y reportes - prado-diaz-bernardo
  10. 10
    Final del paso 11paso 11: plantillas de categorias, facturas y reportes - prado-diaz-bernardo
  11. 11
    Final del paso 12paso 12: comando cargar_demo con datos de prueba - prado-diaz-bernardo
  12. 12
    Paso 15, antes del git pushpaso 15: capturas y subida a GitHub para Render - prado-diaz-bernardo
  13. 13
    Paso 16, después de grabarevidencias: minutos del video - prado-diaz-bernardo
01

Las 5 tablas y cómo se relacionan

⏱ 5 minexplícalo en voz alta

Hoy tienes 2 tablas: auth_user (los usuarios de Django) y core_servicio, donde la categoría es un texto libre. Al terminar tendrás 5 tablas unidas por llaves foráneas (FK). Cada flecha es una FK: la tabla de donde sale la flecha guarda el id de la tabla a la que apunta.

Diagrama de las 5 tablas: auth_user, core_categoria, core_servicio, core_factura y core_detallefactura auth_user id (PK)usernamepassword (hash) core_categoria · NUEVA id (PK)nombre (único)descripcion core_servicio id (PK)propietario_id (FK)categoria_id (FK) ★titulo · preciodescripcion · creado_en★ antes era texto core_factura · NUEVA id (PK) → folio F-00001usuario_id (FK)clientefecha · creado_en core_detallefactura · NUEVA id (PK)factura_id (FK)servicio_id (FK)cantidad · precio_unitario N:1 N:1 N:1 N:1 N:1
RelaciónSe lee asíQué pasa al borrar
Servicio → UserUn usuario tiene muchos servicios (ya lo tienes desde la Semana 6).CASCADE: si se borra el usuario, se borran sus servicios.
Servicio → CategoriaUna categoría agrupa muchos servicios.PROTECT: no se puede borrar una categoría con servicios.
Factura → UserUn usuario emite muchas facturas.CASCADE
DetalleFactura → FacturaUna factura tiene muchos renglones.CASCADE: si se borra la factura, se van sus renglones.
DetalleFactura → ServicioUn servicio aparece en muchos renglones de distintas facturas.PROTECT: no se puede borrar un servicio ya facturado.
¿POR QUÉ?

Con la categoría como texto, "Diseño", "diseño" y "Disenio" son tres categorías distintas y no puedes agruparlas bien. Con una tabla, cada categoría existe una sola vez y los reportes con GROUP BY salen correctos. El precio_unitario se guarda en el renglón para que, si mañana cambias el precio del servicio, tus facturas viejas no cambien.

02

Tabla Categoria y una llave foránea temporal

⏱ 15 mincore/models.py

Cambiar una columna de texto por una llave foránea sin perder datos se hace en 3 migraciones (pasos 02, 03 y 04). En esta primera creas la tabla nueva y una FK temporal llamada categoria_ref; la columna de texto vieja se queda por ahora.

01

Confirma que partes de un proyecto sano

Abre en VS Code la carpeta de tu proyecto de la Semana 7 u 8 (la que tiene manage.py), abre la terminal con Ctrl + ñ y activa el entorno virtual.

TERMINAL DE VS CODE — PowerShell · en la carpeta que tiene manage.py
# Activa el entorno virtual; al inicio de la línea debe aparecer (venv)
.\venv\Scripts\Activate.ps1
# Revisa que el proyecto no tenga errores antes de cambiar nada
python manage.py check
# Si responde "No changes detected", no quedaron cambios de modelos sin migrar
python manage.py makemigrations

Debe decir System check identified no issues (0 silenced) y No changes detected. Si sale otra cosa, corrígelo antes de seguir.

02

Reemplaza core/models.py completo

Ctrl + P → models.py → Ctrl + A y pega este archivo. Léelo línea por línea mientras lo explicas.

OJO

Si tu Servicio tiene un campo extra que agregaste en un live coding (por ejemplo disponible = models.BooleanField(default=True)), vuelve a escribirlo dentro de class Servicio. Si no lo haces, la siguiente migración borraría esa columna.

core/models.py — archivo COMPLETO (paso 0
# models: trae las herramientas de Django para describir tablas como clases
from django.db import models
# User: el modelo de usuario que Django ya trae incluido (login, contraseñas, sesiones)
from django.contrib.auth.models import User


# class Categoria: NUEVA tabla "core_categoria"; va ARRIBA de Servicio porque Servicio la va a usar
class Categoria(models.Model):
    # nombre: texto corto y obligatorio; unique=True impide dos categorías con el mismo nombre
    nombre = models.CharField(max_length=60, unique=True)

    # descripcion: texto largo opcional; blank=True permite dejarlo vacío en los formularios
    descripcion = models.TextField(blank=True)

    # class Meta: opciones extra de la tabla (no son columnas)
    class Meta:
        # ordering: cuando se pidan categorías, saldrán ordenadas por nombre de la A a la Z
        ordering = ['nombre']

    # __str__: el texto que se ve en las listas desplegables y en /admin
    def __str__(self):
        # devuelve el nombre en vez de "Categoria object (1)"
        return self.nombre


# class Servicio: la tabla "core_servicio" que ya tienes desde la Unidad 2
class Servicio(models.Model):
    # propietario: el usuario dueño del servicio (Semana 6); no cambia
    propietario = models.ForeignKey(User, on_delete=models.CASCADE)

    # titulo: texto corto y obligatorio; no cambia
    titulo = models.CharField(max_length=120)

    # descripcion: texto largo; no cambia
    descripcion = models.TextField()

    # precio: decimal con 2 dígitos después del punto; no cambia
    precio = models.DecimalField(max_digits=8, decimal_places=2)

    # categoria: la columna de TEXTO vieja; se queda por ahora para no perder lo que ya escribiste
    categoria = models.CharField(max_length=60)

    # categoria_ref: NUEVA llave foránea a Categoria (temporal; en el paso 04 se renombra a "categoria")
    # on_delete=PROTECT: MySQL no deja borrar una categoría si todavía tiene servicios
    # null=True: los servicios que ya existen quedan vacíos hasta que el paso 03 les copie su categoría
    # related_name='servicios': desde una categoría se llega a sus servicios con categoria.servicios
    categoria_ref = models.ForeignKey(Categoria, on_delete=models.PROTECT, null=True, related_name='servicios')

    # creado_en: fecha y hora de creación automática; no cambia
    creado_en = models.DateTimeField(auto_now_add=True)

    # __str__: el texto que representa al servicio; no cambia
    def __str__(self):
        # devuelve el título del servicio
        return self.titulo
03

Genera y aplica la migración

TERMINAL DE VS CODE — PowerShell · con (venv) activo
# Compara models.py con las migraciones y crea el archivo nuevo en core/migrations/
python manage.py makemigrations core
# Aplica el cambio en tu base MySQL de Aiven: crea core_categoria y la columna categoria_ref_id
python manage.py migrate
LO QUE DEBES VER (el número 0002 puede ser otro en tu proyecto)
# makemigrations: el archivo nuevo y lo que contiene
Migrations for 'core':
  core\migrations\0002_categoria_servicio_categoria_ref.py
    + Create model Categoria
    + Add field categoria_ref to servicio
# migrate: se aplicó en MySQL
  Applying core.0002_categoria_servicio_categoria_ref... OK

Commit del paso 02 · commit 1 de 13

Al terminar el paso, frente a la cámara. En git status deben aparecer estos archivos (y nunca .env). El número de la migración puede ser otro en tu proyecto.

  • core/models.py — modificado
  • core/migrations/0002_categoria_servicio_categoria_ref.py — nuevo
  • evidencias/02-migrate.png — nuevo
TERMINAL DE VS CODE — PowerShell · commit del paso 02
# Revisa qué archivos cambiaste en este paso: NO debe aparecer .env
git status
# Agrega todos los archivos de este paso
git add .
# Guarda el paso 02: empieza con "paso 02" y termina con tu nombre
git commit -m "paso 02: tabla Categoria y llave foranea temporal - prado-diaz-bernardo"
# Confirma que el commit quedó en tu historial
git log --oneline -3
03

Migración de datos: copiar cada categoría de texto a la tabla nueva

⏱ 15 minmigración escrita a mano

Una migración de datos no cambia columnas: mueve información. Esta lee el texto de cada servicio (por ejemplo "Diseño"), busca o crea esa categoría en core_categoria y la guarda en categoria_ref.

01

Crea una migración vacía

TERMINAL DE VS CODE — PowerShell · con (venv) activo
# --empty: crea un archivo de migración sin operaciones · --name: le pone un nombre que se entienda
python manage.py makemigrations core --empty --name copiar_categorias

Django crea core/migrations/0003_copiar_categorias.py (el número puede ser otro). Ábrelo con Ctrl + P.

02

Escribe la función que copia los datos

Deja tal cual el bloque dependencies que escribió Django (apunta a tu migración anterior). Agrega el import, la función copiar_categorias y la línea dentro de operations, para que el archivo quede así:

core/migrations/0003_copiar_categorias.py — así debe qued
# migrations: herramientas de Django para escribir migraciones a mano
from django.db import migrations


# copiar_categorias: función que Django ejecuta UNA vez cuando corres "migrate"
# apps: los modelos tal como estaban en ESTE punto de la historia (no importes desde core.models aquí)
# schema_editor: el objeto que habla con MySQL; esta función no lo necesita, pero Django siempre lo manda
def copiar_categorias(apps, schema_editor):
    # get_model: pide la versión "histórica" de Servicio, la que todavía tiene la columna de texto
    Servicio = apps.get_model('core', 'Servicio')
    # get_model: pide la versión histórica de Categoria, la tabla nueva del paso 01
    Categoria = apps.get_model('core', 'Categoria')
    # recorre todos los servicios de TODOS los usuarios, uno por uno
    for servicio in Servicio.objects.all():
        # strip(): quita espacios de más; si el texto quedó vacío se usa "Sin categoría"
        nombre = (servicio.categoria or '').strip() or 'Sin categoría'
        # nombre__iexact: busca la categoría sin importar mayúsculas ("diseño" y "Diseño" son la misma)
        categoria = Categoria.objects.filter(nombre__iexact=nombre).first()
        # si no existe todavía...
        if categoria is None:
            # ...la crea con el nombre tal como estaba escrito en el servicio
            categoria = Categoria.objects.create(nombre=nombre)
        # categoria_ref: apunta la llave foránea nueva a la categoría encontrada o creada
        servicio.categoria_ref = categoria
        # update_fields: guarda SOLO esa columna, sin tocar las demás
        servicio.save(update_fields=['categoria_ref'])


# class Migration: Django la generó con "makemigrations --empty"; solo cambias "operations"
class Migration(migrations.Migration):

    # dependencies: DÉJALA COMO LA ESCRIBIÓ DJANGO; apunta a tu migración anterior (su número puede ser otro)
    dependencies = [
        # ejemplo: la migración del paso 01; en tu archivo puede llamarse distinto
        ('core', '0002_categoria_servicio_categoria_ref'),
    # cierra la lista de dependencias
    ]

    # operations: lo que hace esta migración, en orden
    operations = [
        # RunPython: corre copiar_categorias al migrar; noop = si algún día deshaces la migración, no hace nada
        migrations.RunPython(copiar_categorias, migrations.RunPython.noop),
    # cierra la lista de operaciones
    ]
03

Aplícala

TERMINAL DE VS CODE — PowerShell · con (venv) activo
# Ejecuta copiar_categorias una sola vez sobre tus servicios en Aiven
python manage.py migrate

Debe decir Applying core.0003_copiar_categorias... OK. Todavía no hay pantalla de categorías; las verás en el paso 06.

Commit del paso 03 · commit 2 de 13

Al terminar el paso, frente a la cámara. En git status deben aparecer estos archivos (y nunca .env). El número de la migración puede ser otro en tu proyecto.

  • core/migrations/0003_copiar_categorias.py — nuevo
  • evidencias/03-copiar.png — nuevo
TERMINAL DE VS CODE — PowerShell · commit del paso 03
# Revisa qué archivos cambiaste en este paso: NO debe aparecer .env
git status
# Agrega todos los archivos de este paso
git add .
# Guarda el paso 03: empieza con "paso 03" y termina con tu nombre
git commit -m "paso 03: migracion de datos que copia las categorias - prado-diaz-bernardo"
# Confirma que el commit quedó en tu historial
git log --oneline -3
04

Quitar la columna de texto y renombrar la llave foránea

⏱ 15 minmodels.py + migración a mano

Ya con los datos copiados, borras la columna de texto categoria y le cambias el nombre a categoria_ref para que se llame categoria. Así tu forms.py, tus vistas y tus plantillas siguen usando el mismo nombre.

01

Actualiza core/models.py

Solo cambia el bloque de la categoría dentro de class Servicio: se borra la línea de texto y la FK pasa a llamarse categoria. Archivo completo para comparar:

core/models.py — archivo COMPLETO (paso 0
# models: trae las herramientas de Django para describir tablas como clases
from django.db import models
# User: el modelo de usuario que Django ya trae incluido (login, contraseñas, sesiones)
from django.contrib.auth.models import User


# class Categoria: NUEVA tabla "core_categoria"; va ARRIBA de Servicio porque Servicio la va a usar
class Categoria(models.Model):
    # nombre: texto corto y obligatorio; unique=True impide dos categorías con el mismo nombre
    nombre = models.CharField(max_length=60, unique=True)

    # descripcion: texto largo opcional; blank=True permite dejarlo vacío en los formularios
    descripcion = models.TextField(blank=True)

    # class Meta: opciones extra de la tabla (no son columnas)
    class Meta:
        # ordering: cuando se pidan categorías, saldrán ordenadas por nombre de la A a la Z
        ordering = ['nombre']

    # __str__: el texto que se ve en las listas desplegables y en /admin
    def __str__(self):
        # devuelve el nombre en vez de "Categoria object (1)"
        return self.nombre


# class Servicio: la tabla "core_servicio" que ya tienes desde la Unidad 2
class Servicio(models.Model):
    # propietario: el usuario dueño del servicio (Semana 6); no cambia
    propietario = models.ForeignKey(User, on_delete=models.CASCADE)

    # titulo: texto corto y obligatorio; no cambia
    titulo = models.CharField(max_length=120)

    # descripcion: texto largo; no cambia
    descripcion = models.TextField()

    # precio: decimal con 2 dígitos después del punto; no cambia
    precio = models.DecimalField(max_digits=8, decimal_places=2)

    # categoria: AHORA es llave foránea a Categoria (antes era texto); en MySQL la columna se llama categoria_id
    # on_delete=PROTECT: MySQL no deja borrar una categoría si todavía tiene servicios
    # null=True: la columna acepta vacío, pero el formulario sí la exige (no tiene blank=True)
    # related_name='servicios': desde una categoría se llega a sus servicios con categoria.servicios
    categoria = models.ForeignKey(Categoria, on_delete=models.PROTECT, null=True, related_name='servicios')

    # creado_en: fecha y hora de creación automática; no cambia
    creado_en = models.DateTimeField(auto_now_add=True)

    # __str__: el texto que representa al servicio; no cambia
    def __str__(self):
        # devuelve el título del servicio
        return self.titulo
02

Escribe la migración a mano

OJO

No uses makemigrations normal aquí: Django creería que cambiaste el tipo de la columna de texto a número y la migración fallaría en MySQL. Por eso se escribe a mano con una migración vacía.

TERMINAL DE VS CODE — PowerShell · con (venv) activo
# Otra migración vacía, ahora para borrar y renombrar
python manage.py makemigrations core --empty --name renombrar_categoria

Abre el archivo nuevo (0004_renombrar_categoria.py), conserva su dependencies y escribe las dos operaciones:

core/migrations/0004_renombrar_categoria.py — así debe qued
# migrations: herramientas de Django para escribir migraciones a mano
from django.db import migrations


# class Migration: Django la generó con "makemigrations --empty"; solo cambias "operations"
class Migration(migrations.Migration):

    # dependencies: DÉJALA COMO LA ESCRIBIÓ DJANGO; debe apuntar a la migración copiar_categorias
    dependencies = [
        # ejemplo: la migración del paso 03; en tu archivo puede tener otro número
        ('core', '0003_copiar_categorias'),
    # cierra la lista de dependencias
    ]

    # operations: se ejecutan en orden, de arriba hacia abajo
    operations = [
        # 1) RemoveField: borra la columna de TEXTO "categoria" (ya se copió a categoria_ref en el paso 03)
        migrations.RemoveField(model_name='servicio', name='categoria'),
        # 2) RenameField: cambia el nombre categoria_ref → categoria; los datos y la llave foránea se conservan
        migrations.RenameField(model_name='servicio', old_name='categoria_ref', new_name='categoria'),
    # cierra la lista de operaciones
    ]
03

Aplica y confirma que Django está de acuerdo

TERMINAL DE VS CODE — PowerShell · con (venv) activo
# Borra la columna de texto y renombra categoria_ref_id a categoria_id en MySQL
python manage.py migrate
# Si tu models.py y tus migraciones coinciden, responde "No changes detected"
python manage.py makemigrations core
# Lista las migraciones: todas con [X]
python manage.py showmigrations core

Abre http://127.0.0.1:8000/servicios/ con python manage.py runserver: tu listado debe seguir funcionando y, al editar un servicio, la categoría ya es una lista desplegable con la categoría correcta seleccionada.

Commit del paso 04 · commit 3 de 13

Al terminar el paso, frente a la cámara. En git status deben aparecer estos archivos (y nunca .env). El número de la migración puede ser otro en tu proyecto.

  • core/models.py — modificado
  • core/migrations/0004_renombrar_categoria.py — nuevo
  • evidencias/04-renombrar.png — nuevo
TERMINAL DE VS CODE — PowerShell · commit del paso 04
# Revisa qué archivos cambiaste en este paso: NO debe aparecer .env
git status
# Agrega todos los archivos de este paso
git add .
# Guarda el paso 04: empieza con "paso 04" y termina con tu nombre
git commit -m "paso 04: quitar categoria de texto y renombrar la llave foranea - prado-diaz-bernardo"
# Confirma que el commit quedó en tu historial
git log --oneline -3
05

Facturas, detalle de factura e índices

⏱ 20 mincore/models.py

Una factura tiene un encabezado (cliente y fecha) y varios renglones (qué servicio, cuántos y a qué precio). Por eso son dos tablas. También agregas índices: le dicen a MySQL cómo encontrar rápido los datos que más vas a consultar.

01

Versión final de core/models.py

Lo nuevo: el import de timezone, la class Meta con índice dentro de Servicio y las clases Factura y DetalleFactura al final.

core/models.py — archivo COMPLETO y FIN
# models: trae las herramientas de Django para describir tablas como clases
from django.db import models
# User: el modelo de usuario que Django ya trae incluido (login, contraseñas, sesiones)
from django.contrib.auth.models import User
# timezone: da la fecha de hoy según la zona horaria de settings.py
from django.utils import timezone


# class Categoria: NUEVA tabla "core_categoria"; va ARRIBA de Servicio porque Servicio la va a usar
class Categoria(models.Model):
    # nombre: texto corto y obligatorio; unique=True impide dos categorías con el mismo nombre
    nombre = models.CharField(max_length=60, unique=True)

    # descripcion: texto largo opcional; blank=True permite dejarlo vacío en los formularios
    descripcion = models.TextField(blank=True)

    # class Meta: opciones extra de la tabla (no son columnas)
    class Meta:
        # ordering: cuando se pidan categorías, saldrán ordenadas por nombre de la A a la Z
        ordering = ['nombre']

    # __str__: el texto que se ve en las listas desplegables y en /admin
    def __str__(self):
        # devuelve el nombre en vez de "Categoria object (1)"
        return self.nombre


# class Servicio: la tabla "core_servicio" que ya tienes desde la Unidad 2
class Servicio(models.Model):
    # propietario: el usuario dueño del servicio (Semana 6); no cambia
    propietario = models.ForeignKey(User, on_delete=models.CASCADE)

    # titulo: texto corto y obligatorio; no cambia
    titulo = models.CharField(max_length=120)

    # descripcion: texto largo; no cambia
    descripcion = models.TextField()

    # precio: decimal con 2 dígitos después del punto; no cambia
    precio = models.DecimalField(max_digits=8, decimal_places=2)

    # categoria: AHORA es llave foránea a Categoria (antes era texto); en MySQL la columna se llama categoria_id
    # on_delete=PROTECT: MySQL no deja borrar una categoría si todavía tiene servicios
    # null=True: la columna acepta vacío, pero el formulario sí la exige (no tiene blank=True)
    # related_name='servicios': desde una categoría se llega a sus servicios con categoria.servicios
    categoria = models.ForeignKey(Categoria, on_delete=models.PROTECT, null=True, related_name='servicios')

    # creado_en: fecha y hora de creación automática; no cambia
    creado_en = models.DateTimeField(auto_now_add=True)

    # class Meta: NUEVA en Servicio; aquí van los índices para que las consultas sean rápidas
    class Meta:
        # indexes: lista de índices que Django creará en MySQL con "migrate"
        indexes = [
            # índice compuesto (propietario, precio): acelera "los servicios de un usuario" y su MIN/MAX de precio
            models.Index(fields=['propietario', 'precio'], name='servicio_prop_precio_idx'),
        # cierra la lista de índices
        ]

    # __str__: el texto que representa al servicio; no cambia
    def __str__(self):
        # devuelve el título del servicio
        return self.titulo


# class Factura: NUEVA tabla "core_factura"; el encabezado de cada venta
class Factura(models.Model):
    # usuario: quién emite la factura (el dueño de los servicios); el mismo candado que propietario en Servicio
    # related_name='facturas': desde un usuario se llega a sus facturas con usuario.facturas
    usuario = models.ForeignKey(User, on_delete=models.CASCADE, related_name='facturas')

    # cliente: nombre de la persona o empresa a la que le vendes
    cliente = models.CharField(max_length=120)

    # fecha: día de la venta; default=timezone.localdate pone la fecha de hoy si no se escribe otra
    fecha = models.DateField(default=timezone.localdate)

    # creado_en: fecha y hora exacta en que se guardó el registro
    creado_en = models.DateTimeField(auto_now_add=True)

    # class Meta: orden por defecto e índices de la tabla
    class Meta:
        # ordering: primero las facturas más recientes; '-id' desempata las del mismo día
        ordering = ['-fecha', '-id']
        # indexes: lista de índices de esta tabla
        indexes = [
            # índice compuesto (usuario, fecha): acelera "mis facturas" y los reportes por fecha
            models.Index(fields=['usuario', 'fecha'], name='factura_usuario_fecha_idx'),
        # cierra la lista de índices
        ]

    # @property: folio se usa como si fuera un campo (factura.folio), pero se calcula y no ocupa columna
    @property
    # folio: arma un folio legible a partir del id
    def folio(self):
        # si la factura todavía no se guarda, no tiene id
        if self.pk is None:
            # texto provisional mientras no exista en MySQL
            return 'F-nueva'
        # :05d rellena con ceros a la izquierda: id 7 → F-00007
        return f'F-{self.pk:05d}'

    # __str__: texto que representa a la factura en /admin
    def __str__(self):
        # ejemplo: "F-00007 · Papelería Luna"
        return f'{self.folio} · {self.cliente}'


# class DetalleFactura: NUEVA tabla "core_detallefactura"; cada renglón (partida) de una factura
class DetalleFactura(models.Model):
    # factura: a qué factura pertenece el renglón; CASCADE = si se borra la factura, se borran sus renglones
    # related_name='detalles': desde una factura se llega a sus renglones con factura.detalles
    factura = models.ForeignKey(Factura, on_delete=models.CASCADE, related_name='detalles')

    # servicio: qué servicio se vendió; PROTECT = no se puede borrar un servicio que ya aparece en una factura
    # related_name='detalles': desde un servicio se llega a los renglones donde se vendió
    servicio = models.ForeignKey(Servicio, on_delete=models.PROTECT, related_name='detalles')

    # cantidad: cuántas unidades se vendieron; solo números enteros positivos, 1 si no se indica
    cantidad = models.PositiveIntegerField(default=1)

    # precio_unitario: el precio AL MOMENTO de la venta; si mañana cambias el precio, la factura no cambia
    precio_unitario = models.DecimalField(max_digits=10, decimal_places=2)

    # @property: subtotal se calcula, no se guarda en MySQL
    @property
    # subtotal: cantidad por precio unitario
    def subtotal(self):
        # ejemplo: 3 × 150.00 = 450.00
        return self.cantidad * self.precio_unitario

    # __str__: texto que representa al renglón en /admin
    def __str__(self):
        # ejemplo: "3 × Clase de álgebra"
        return f'{self.cantidad} × {self.servicio}'
02

Migra y mira el SQL que Django genera

TERMINAL DE VS CODE — PowerShell · con (venv) activo
# Crea la migración con las 2 tablas nuevas y los 2 índices
python manage.py makemigrations core
# Muestra el SQL que se va a ejecutar en MySQL (cambia 0005 por el número que te dio el comando anterior)
python manage.py sqlmigrate core 0005
# Lo aplica en Aiven
python manage.py migrate
LO QUE DEBES VER EN makemigrations
# las 2 tablas, sus llaves foráneas y los 2 índices
Migrations for 'core':
  core\migrations\0005_detallefactura_factura_and_more.py
    + Create model DetalleFactura
    + Create model Factura
    + Create index servicio_prop_precio_idx on field(s) propietario, precio of model servicio
    + Add field servicio to detallefactura
    + Add field usuario to factura
    + Add field factura to detallefactura
    + Create index factura_usuario_fecha_idx on field(s) usuario, fecha of model factura

En la salida de sqlmigrate busca CREATE TABLE `core_factura`, CREATE TABLE `core_detallefactura` y los dos CREATE INDEX. Explica en voz alta que MySQL además crea un índice por cada llave foránea.

Commit del paso 05 · commit 4 de 13

Al terminar el paso, frente a la cámara. En git status deben aparecer estos archivos (y nunca .env). El número de la migración puede ser otro en tu proyecto.

  • core/models.py — modificado
  • core/migrations/0005_detallefactura_factura_and_more.py — nuevo
  • evidencias/05-indices.png — nuevo
TERMINAL DE VS CODE — PowerShell · commit del paso 05
# Revisa qué archivos cambiaste en este paso: NO debe aparecer .env
git status
# Agrega todos los archivos de este paso
git add .
# Guarda el paso 05: empieza con "paso 05" y termina con tu nombre
git commit -m "paso 05: tablas Factura y DetalleFactura con indices - prado-diaz-bernardo"
# Confirma que el commit quedó en tu historial
git log --oneline -3
06

Registrar las tablas nuevas en /admin

⏱ 10 mincore/admin.py

El panel /admin sirve para revisar que las categorías se copiaron bien y para ver cada factura con sus renglones.

01

Reemplaza core/admin.py completo

core/admin.py — archivo COMPLE
# admin: el panel de administración que Django trae de fábrica (/admin)
from django.contrib import admin
# los 4 modelos de la app: el que ya tenías y los 3 nuevos
from .models import Servicio, Categoria, Factura, DetalleFactura


# register(Servicio): igual que desde la Semana 4; ahora con columnas visibles en la lista
@admin.register(Servicio)
# ServicioAdmin: configura cómo se ve Servicio dentro de /admin
class ServicioAdmin(admin.ModelAdmin):
    # list_display: columnas que se ven en la lista de servicios
    list_display = ['titulo', 'categoria', 'precio', 'propietario']
    # list_filter: filtros de la barra derecha (por categoría)
    list_filter = ['categoria']


# register(Categoria): la tabla nueva aparece en /admin
@admin.register(Categoria)
# CategoriaAdmin: configuración de Categoria en /admin
class CategoriaAdmin(admin.ModelAdmin):
    # list_display: columnas visibles
    list_display = ['nombre', 'descripcion']


# DetalleFacturaInline: muestra los renglones DENTRO de la pantalla de cada factura
class DetalleFacturaInline(admin.TabularInline):
    # model: los renglones son registros de DetalleFactura
    model = DetalleFactura
    # extra: cuántos renglones vacíos extra se muestran para capturar
    extra = 1


# register(Factura): la tabla nueva aparece en /admin, con sus renglones incluidos
@admin.register(Factura)
# FacturaAdmin: configuración de Factura en /admin
class FacturaAdmin(admin.ModelAdmin):
    # list_display: folio es la @property del modelo; también se puede mostrar como columna
    list_display = ['folio', 'cliente', 'fecha', 'usuario']
    # inlines: dibuja la tabla de renglones debajo de los datos de la factura
    inlines = [DetalleFacturaInline]

Con runserver abre http://127.0.0.1:8000/admin/: deben aparecer Categorias, Facturas y Servicios. En Categorias verás las que copió el paso 03.

Commit del paso 06 · commit 5 de 13

Al terminar el paso, frente a la cámara. En git status deben aparecer estos archivos (y nunca .env).

  • core/admin.py — modificado
  • evidencias/06-admin.png — nuevo
TERMINAL DE VS CODE — PowerShell · commit del paso 06
# Revisa qué archivos cambiaste en este paso: NO debe aparecer .env
git status
# Agrega todos los archivos de este paso
git add .
# Guarda el paso 06: empieza con "paso 06" y termina con tu nombre
git commit -m "paso 06: registrar categorias y facturas en el admin - prado-diaz-bernardo"
# Confirma que el commit quedó en tu historial
git log --oneline -3
de dónde sale esto Django Docs: InlineModelAdmin
07

Formularios: categoría, factura y renglones

⏱ 15 mincore/forms.py

Un formset es un grupo de formularios iguales que se envían juntos. Aquí cada renglón de la factura es un formulario, y inlineformset_factory los liga a la factura padre.

01

Reemplaza core/forms.py completo

core/forms.py — archivo COMPLE
# 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
# inlineformset_factory: fabrica un "formset" = varios formularios hijos (renglones) ligados a un padre (factura)
from django.forms import inlineformset_factory
# los modelos que usan estos formularios
from .models import Servicio, Categoria, Factura, DetalleFactura


# ServicioForm: el mismo ModelForm de la Semana 5; "categoria" ahora se dibuja como lista desplegable
class ServicioForm(forms.ModelForm):
    # Meta: de qué modelo sale el formulario y qué campos muestra
    class Meta:
        # model: el formulario se genera a partir del modelo Servicio
        model = Servicio
        # fields: los mismos 4 campos; como categoria ahora es llave foránea, Django pone un <select>
        fields = ['titulo', 'descripcion', 'precio', 'categoria']

    # clean_precio: tu validación de la Semana 5; no cambia
    def clean_precio(self):
        # self.cleaned_data: los valores ya convertidos a su tipo de Python
        precio = self.cleaned_data['precio']
        # regla propia: rechazar precios en cero o negativos
        if precio <= 0:
            # el formulario se vuelve inválido y muestra este mensaje junto al campo
            raise ValidationError('El precio debe ser mayor a 0.')
        # devuelve el valor limpio para que el formulario lo use
        return precio


# CategoriaForm: NUEVO formulario para dar de alta y editar categorías
class CategoriaForm(forms.ModelForm):
    # Meta: configuración del formulario
    class Meta:
        # model: sale del modelo Categoria
        model = Categoria
        # fields: los dos campos que se capturan
        fields = ['nombre', 'descripcion']
        # widgets: cambia cómo se dibuja un campo; aquí la descripción es una caja de 2 renglones
        widgets = {'descripcion': forms.Textarea(attrs={'rows': 2})}


# FacturaForm: NUEVO formulario para el encabezado de la factura (cliente y fecha)
class FacturaForm(forms.ModelForm):
    # Meta: configuración del formulario
    class Meta:
        # model: sale del modelo Factura
        model = Factura
        # fields: usuario NO va aquí; lo pone la vista con request.user para que nadie lo falsifique
        fields = ['cliente', 'fecha']
        # widgets: type="date" muestra el calendario del navegador; format ISO para que el navegador lo entienda
        widgets = {'fecha': forms.DateInput(attrs={'type': 'date'}, format='%Y-%m-%d')}


# DetalleFacturaForm: NUEVO formulario para UN renglón de la factura (servicio y cantidad)
class DetalleFacturaForm(forms.ModelForm):
    # Meta: configuración del formulario
    class Meta:
        # model: sale del modelo DetalleFactura
        model = DetalleFactura
        # fields: precio_unitario NO va aquí; la vista lo copia del servicio para que no se pueda alterar
        fields = ['servicio', 'cantidad']

    # __init__: se ejecuta al crear el formulario; recibe "usuario" para filtrar la lista de servicios
    def __init__(self, *args, usuario=None, **kwargs):
        # super().__init__: deja que Django arme el formulario normal primero
        super().__init__(*args, **kwargs)
        # queryset: en la lista desplegable solo aparecen TUS servicios; select_related evita una consulta por renglón
        self.fields['servicio'].queryset = Servicio.objects.filter(propietario=usuario).select_related('categoria')
        # label_from_instance: el texto de cada opción, con su precio (ej. "Clase de álgebra — $150.00")
        self.fields['servicio'].label_from_instance = lambda s: f'{s.titulo} — ${s.precio}'


# DetalleFormSet: varios DetalleFacturaForm juntos, todos ligados a UNA Factura
DetalleFormSet = inlineformset_factory(
    # Factura: el modelo padre
    Factura,
    # DetalleFactura: el modelo hijo (los renglones)
    DetalleFactura,
    # form: cada renglón usa el formulario de arriba
    form=DetalleFacturaForm,
    # extra=3: además del renglón obligatorio se dibujan 3 renglones vacíos
    extra=3,
    # min_num=1 + validate_min=True: la factura debe tener al menos 1 renglón lleno
    min_num=1,
    # validate_min: hace que el mínimo de arriba se revise al guardar
    validate_min=True,
    # can_delete=False: en una factura nueva no hace falta la casilla "borrar renglón"
    can_delete=False,
# cierra la llamada a inlineformset_factory
)
SEGURIDAD

El usuario y el precio no están en los formularios: los pone la vista. Y la lista de servicios de cada renglón solo trae tus servicios, así nadie puede facturar el servicio de otro usuario cambiando el HTML.

Commit del paso 07 · commit 6 de 13

Al terminar el paso, frente a la cámara. En git status deben aparecer estos archivos (y nunca .env).

  • core/forms.py — modificado
TERMINAL DE VS CODE — PowerShell · commit del paso 07
# Revisa qué archivos cambiaste en este paso: NO debe aparecer .env
git status
# Agrega todos los archivos de este paso
git add .
# Guarda el paso 07: empieza con "paso 07" y termina con tu nombre
git commit -m "paso 07: formularios de categoria, factura y renglones - prado-diaz-bernardo"
# Confirma que el commit quedó en tu historial
git log --oneline -3
08

reportes.py: las 6 consultas SQL escritas a mano

⏱ 40 minarchivo nuevo core/reportes.py

Aquí no usas el ORM: escribes el SQL directo que MySQL ejecuta. Cada consulta es un diccionario con su título y su SQL; la función ejecutar_sql corre cualquiera de ellas y devuelve columnas y filas, y la página del paso 11 las dibuja sin importar cuál sea.

#ReporteTécnica SQLPor qué es interesante
1Ingresos por categoríaSUM() + GROUP BYUne 4 tablas y suma dinero por grupo.
2Servicios por categoríaCOUNT() + GROUP BY + LEFT JOINIncluye categorías sin servicios (salen con 0); COUNT(s.id) vs COUNT(*).
3Más barato y más caro, con empatesMIN() + MAX() en subconsultaSi dos servicios cuestan lo mismo, salen los dos (no solo uno, como pasaría con LIMIT 1).
4Servicios nunca facturadosLEFT JOIN + IS NULLEncuentra lo que no existe en otra tabla (anti-join).
5Clientes sobre el promedioHAVING + subconsulta con AVG()Filtra grupos después de sumar; WHERE no puede hacerlo.
6Ranking por categoríaWITH + RANK() OVER (PARTITION BY)Funciones de ventana de MySQL 8: lugares 1, 1, 3 cuando hay empate.
01

Crea core/reportes.py

En el explorador de VS Code, clic derecho sobre la carpeta core → New File → reportes.py. Escríbelo leyendo cada comentario: en el video explica qué hace cada línea de SQL.

core/reportes.py — archivo NUEVO comple
# time: para medir cuántos milisegundos tarda MySQL en responder cada consulta
import time
# Decimal: el tipo de número que MySQL devuelve para precios y sumas de dinero
from decimal import Decimal
# connection: la misma conexión a MySQL que usa Django; con ella se ejecuta SQL escrito a mano
from django.db import connection


# CONSULTAS: la lista de reportes; cada uno es un diccionario con sus datos y su SQL escrito a mano
# %(uid)s: el lugar donde Django pondrá TU id de usuario de forma segura (nunca se pega texto dentro del SQL)
CONSULTAS = [
    # ---------- Consulta 1: SUM() + GROUP BY ----------
    {
        # clave: la parte final de la dirección, ej. /reportes/ingresos-por-categoria/
        'clave': 'ingresos-por-categoria',
        # numero: el número del reporte en la lista
        'numero': 1,
        # titulo: lo que se lee como encabezado
        'titulo': 'Ingresos por categoría',
        # tecnica: la técnica de SQL que practica esta consulta
        'tecnica': 'SUM() + GROUP BY',
        # descripcion: qué responde la consulta, en lenguaje de negocio
        'descripcion': 'Cuánto dinero ha generado cada categoría sumando todos los renglones de tus facturas.',
        # sql: la consulta escrita a mano; las líneas con -- son comentarios de SQL y MySQL las ignora
        'sql': """
-- columna 1: el nombre de la categoría
SELECT c.nombre AS categoria,
-- columna 2: SUM suma la cantidad de unidades vendidas de cada grupo
       SUM(d.cantidad) AS unidades_vendidas,
-- columna 3: SUM de cantidad por precio = el dinero cobrado en cada grupo
       SUM(d.cantidad * d.precio_unitario) AS ingresos
-- se parte de los renglones de factura (d)
FROM core_detallefactura AS d
-- cada renglón se une con su factura (f) para saber de quién es
JOIN core_factura AS f ON f.id = d.factura_id
-- y con su servicio (s) para llegar a la categoría
JOIN core_servicio AS s ON s.id = d.servicio_id
-- y con la categoría (c) para obtener su nombre
JOIN core_categoria AS c ON c.id = s.categoria_id
-- solo las facturas del usuario que tiene la sesión iniciada
WHERE f.usuario_id = %(uid)s
-- GROUP BY: junta en un solo renglón todo lo que sea de la misma categoría
GROUP BY c.id, c.nombre
-- ordena de la categoría que más vende a la que menos
ORDER BY ingresos DESC
-- fin del SQL: las comillas triples de abajo cierran el texto de la consulta
""",
    # cierra la consulta 1
    },
    # ---------- Consulta 2: COUNT() + GROUP BY ----------
    {
        # clave: parte final de la dirección
        'clave': 'servicios-por-categoria',
        # numero: número del reporte
        'numero': 2,
        # titulo: encabezado del reporte
        'titulo': 'Servicios por categoría',
        # tecnica: lo que se practica
        'tecnica': 'COUNT() + GROUP BY + LEFT JOIN',
        # descripcion: qué responde
        'descripcion': 'Cuántos servicios tienes en cada categoría, incluidas las categorías que todavía no usas (salen con 0).',
        # sql: la consulta escrita a mano
        'sql': """
-- columna 1: el nombre de la categoría
SELECT c.nombre AS categoria,
-- COUNT(s.id) cuenta solo los servicios que sí existen; COUNT(*) contaría 1 aunque no hubiera ninguno
       COUNT(s.id) AS servicios
-- se parte de TODAS las categorías
FROM core_categoria AS c
-- LEFT JOIN: conserva la categoría aunque no tenga servicios
LEFT JOIN core_servicio AS s
-- condición 1 de la unión: el servicio pertenece a esa categoría
       ON s.categoria_id = c.id
-- condición 2 (dentro del ON, no en WHERE): solo se cuentan TUS servicios
      AND s.propietario_id = %(uid)s
-- un renglón por categoría
GROUP BY c.id, c.nombre
-- primero las que tienen más servicios; si empatan, en orden alfabético
ORDER BY servicios DESC, c.nombre
-- fin del SQL: las comillas triples de abajo cierran el texto de la consulta
""",
    # cierra la consulta 2
    },
    # ---------- Consulta 3: MIN() y MAX() con empates ----------
    {
        # clave: parte final de la dirección
        'clave': 'precio-minimo-y-maximo',
        # numero: número del reporte
        'numero': 3,
        # titulo: encabezado del reporte
        'titulo': 'Servicio más barato y más caro (con empates)',
        # tecnica: lo que se practica
        'tecnica': 'MIN() + MAX() en una subconsulta',
        # descripcion: qué responde
        'descripcion': 'Muestra TODOS los servicios que tienen el precio mínimo y TODOS los que tienen el máximo: si dos cuestan lo mismo, salen los dos.',
        # sql: la consulta escrita a mano
        'sql': """
-- CASE: escribe una etiqueta según si el precio es el mínimo o el máximo
SELECT CASE WHEN s.precio = m.minimo THEN 'Más barato (MIN)' ELSE 'Más caro (MAX)' END AS posicion,
-- el título del servicio
       s.titulo AS servicio,
-- el nombre de su categoría
       c.nombre AS categoria,
-- su precio
       s.precio
-- se parte de los servicios (s)
FROM core_servicio AS s
-- subconsulta (m): calcula UNA sola vez el precio mínimo y el máximo de tus servicios
JOIN (SELECT MIN(precio) AS minimo, MAX(precio) AS maximo
-- de la tabla de servicios
      FROM core_servicio
-- solo los tuyos; el índice (propietario, precio) hace este paso muy rápido
      WHERE propietario_id = %(uid)s) AS m
-- IN: se queda con cada servicio cuyo precio sea igual al mínimo o al máximo (así entran los empates)
  ON s.precio IN (m.minimo, m.maximo)
-- LEFT JOIN: trae el nombre de la categoría (y no pierde el servicio si no tuviera)
LEFT JOIN core_categoria AS c ON c.id = s.categoria_id
-- otra vez solo tus servicios
WHERE s.propietario_id = %(uid)s
-- primero los baratos y luego los caros; los empates, por título
ORDER BY s.precio, s.titulo
-- fin del SQL: las comillas triples de abajo cierran el texto de la consulta
""",
    # cierra la consulta 3
    },
    # ---------- Consulta 4: servicios sin vender (LEFT JOIN + IS NULL) ----------
    {
        # clave: parte final de la dirección
        'clave': 'servicios-sin-vender',
        # numero: número del reporte
        'numero': 4,
        # titulo: encabezado del reporte
        'titulo': 'Servicios que nunca se han facturado',
        # tecnica: lo que se practica
        'tecnica': 'LEFT JOIN + IS NULL (anti-join)',
        # descripcion: qué responde
        'descripcion': 'Los servicios que todavía no aparecen en ninguna factura: candidatos a promoción o a revisar su precio.',
        # sql: la consulta escrita a mano
        'sql': """
-- columnas: título, categoría y precio del servicio
SELECT s.titulo AS servicio,
-- nombre de la categoría
       c.nombre AS categoria,
-- precio actual
       s.precio
-- se parte de los servicios (s)
FROM core_servicio AS s
-- LEFT JOIN con los renglones de factura: si el servicio nunca se vendió, d queda vacío (NULL)
LEFT JOIN core_detallefactura AS d ON d.servicio_id = s.id
-- trae el nombre de la categoría
LEFT JOIN core_categoria AS c ON c.id = s.categoria_id
-- solo tus servicios
WHERE s.propietario_id = %(uid)s
-- IS NULL: se queda SOLO con los que no encontraron ningún renglón de factura
  AND d.id IS NULL
-- los más caros primero
ORDER BY s.precio DESC
-- fin del SQL: las comillas triples de abajo cierran el texto de la consulta
""",
    # cierra la consulta 4
    },
    # ---------- Consulta 5: clientes por encima del promedio (HAVING) ----------
    {
        # clave: parte final de la dirección
        'clave': 'clientes-sobre-el-promedio',
        # numero: número del reporte
        'numero': 5,
        # titulo: encabezado del reporte
        'titulo': 'Clientes que gastan más que el promedio',
        # tecnica: lo que se practica
        'tecnica': 'HAVING + subconsulta con AVG()',
        # descripcion: qué responde
        'descripcion': 'Tus mejores clientes: los que han comprado en total más que el promedio de todos tus clientes.',
        # sql: la consulta escrita a mano
        'sql': """
-- columna 1: el nombre del cliente
SELECT f.cliente,
-- COUNT(DISTINCT f.id): cuántas facturas distintas tiene (DISTINCT evita contar una factura por cada renglón)
       COUNT(DISTINCT f.id) AS facturas,
-- SUM: el total que ha comprado
       SUM(d.cantidad * d.precio_unitario) AS total_comprado
-- se parte de las facturas (f)
FROM core_factura AS f
-- unidas con sus renglones (d)
JOIN core_detallefactura AS d ON d.factura_id = f.id
-- solo tus facturas
WHERE f.usuario_id = %(uid)s
-- un renglón por cliente
GROUP BY f.cliente
-- HAVING: filtra DESPUÉS de agrupar (WHERE no puede usar SUM); deja solo a quien supera el promedio
HAVING SUM(d.cantidad * d.precio_unitario) > (
-- AVG: el promedio de lo que compró cada cliente
    SELECT AVG(t.total_cliente)
-- t: una tabla temporal con el total de cada cliente
    FROM (SELECT SUM(d2.cantidad * d2.precio_unitario) AS total_cliente
-- facturas (f2) para la tabla temporal
          FROM core_factura AS f2
-- unidas con sus renglones (d2)
          JOIN core_detallefactura AS d2 ON d2.factura_id = f2.id
-- también solo las tuyas
          WHERE f2.usuario_id = %(uid)s
-- un total por cliente
          GROUP BY f2.cliente) AS t
-- cierra la subconsulta del promedio
)
-- el que más compra primero
ORDER BY total_comprado DESC
-- fin del SQL: las comillas triples de abajo cierran el texto de la consulta
""",
    # cierra la consulta 5
    },
    # ---------- Consulta 6: ranking con empates por categoría (RANK) ----------
    {
        # clave: parte final de la dirección
        'clave': 'ranking-por-categoria',
        # numero: número del reporte
        'numero': 6,
        # titulo: encabezado del reporte
        'titulo': 'Ranking de servicios más vendidos por categoría',
        # tecnica: lo que se practica
        'tecnica': 'WITH (CTE) + RANK() OVER (PARTITION BY …)',
        # descripcion: qué responde
        'descripcion': 'El lugar de cada servicio dentro de su categoría según las unidades vendidas; si dos venden lo mismo, comparten el lugar.',
        # sql: la consulta escrita a mano (MySQL 8 o más nuevo; Aiven usa MySQL 8)
        'sql': """
-- WITH: crea una tabla temporal llamada "ventas" que solo existe durante esta consulta
WITH ventas AS (
-- columna: nombre de la categoría
    SELECT c.nombre AS categoria,
-- columna: título del servicio
           s.titulo AS servicio,
-- columna: unidades vendidas de ese servicio
           SUM(d.cantidad) AS unidades
-- renglones de factura (d)
    FROM core_detallefactura AS d
-- con su factura (f)
    JOIN core_factura AS f ON f.id = d.factura_id
-- con su servicio (s)
    JOIN core_servicio AS s ON s.id = d.servicio_id
-- con su categoría (c)
    JOIN core_categoria AS c ON c.id = s.categoria_id
-- solo tus facturas
    WHERE f.usuario_id = %(uid)s
-- un renglón por servicio
    GROUP BY c.nombre, s.id, s.titulo
-- cierra la tabla temporal "ventas"
)
-- columna: la categoría
SELECT categoria,
-- RANK() OVER: numera 1, 2, 3… dentro de cada categoría (PARTITION BY); con empate repite el número
       RANK() OVER (PARTITION BY categoria ORDER BY unidades DESC) AS lugar,
-- columna: el servicio
       servicio,
-- columna: sus unidades
       unidades
-- se lee de la tabla temporal
FROM ventas
-- ordenado por categoría y luego por lugar
ORDER BY categoria, lugar, servicio
-- fin del SQL: las comillas triples de abajo cierran el texto de la consulta
""",
    # cierra la consulta 6
    },
# cierra la lista CONSULTAS
]


# buscar: devuelve la consulta cuya clave coincide, o None si no existe
def buscar(clave):
    # recorre la lista de consultas
    for consulta in CONSULTAS:
        # si la clave coincide con la que pidió la URL...
        if consulta['clave'] == clave:
            # ...devuelve ese diccionario
            return consulta
    # si ninguna coincidió, no hay reporte con esa clave
    return None


# ejecutar_sql: corre CUALQUIER consulta SELECT y devuelve columnas, filas y tiempo; no importa qué consulta sea
def ejecutar_sql(sql, parametros=None):
    # perf_counter: un cronómetro de alta precisión; se anota la hora de inicio
    inicio = time.perf_counter()
    # connection.cursor(): abre un cursor, el objeto que manda el SQL a MySQL y recibe la respuesta
    with connection.cursor() as cursor:
        # execute: manda el SQL; los parámetros se escapan por separado (protege contra inyección SQL)
        cursor.execute(sql, parametros)
        # description: los datos de cada columna del resultado; [0] es su nombre
        columnas = [columna[0] for columna in cursor.description]
        # fetchall: trae todas las filas del resultado como una lista de tuplas
        filas = cursor.fetchall()
    # milisegundos que pasaron desde el inicio
    milisegundos = (time.perf_counter() - inicio) * 1000
    # devuelve las tres cosas juntas
    return columnas, filas, milisegundos


# formatear: convierte un valor de MySQL en texto bonito y dice si es número (para alinearlo a la derecha)
def formatear(valor):
    # None: MySQL no tiene dato (NULL)
    if valor is None:
        # se muestra un guion largo
        return {'texto': '—', 'numero': False}
    # bool: verdadero/falso se muestra en español
    if isinstance(valor, bool):
        # Sí o No
        return {'texto': 'Sí' if valor else 'No', 'numero': False}
    # int: números enteros con separador de miles (1,250)
    if isinstance(valor, int):
        # :, agrega las comas de miles
        return {'texto': f'{valor:,}', 'numero': True}
    # Decimal o float: dinero y promedios con 2 decimales (1,250.50)
    if isinstance(valor, (Decimal, float)):
        # :,.2f = comas de miles y 2 decimales
        return {'texto': f'{valor:,.2f}', 'numero': True}
    # cualquier otra cosa (texto, fechas): se convierte a texto tal cual
    return {'texto': str(valor), 'numero': False}
OJO

Nunca armes el SQL pegando texto con + o con f"..." (por ejemplo f"WHERE usuario_id = {request.user.id}"). Eso abre la puerta a la inyección SQL. Siempre usa %(uid)s y pasa el valor en parametros. Por la misma razón, en estas consultas no escribas el símbolo de porcentaje suelto (ni en los comentarios): si lo necesitas, escríbelo doble.

Commit del paso 08 · commit 7 de 13

Al terminar el paso, frente a la cámara. En git status deben aparecer estos archivos (y nunca .env).

  • core/reportes.py — nuevo
TERMINAL DE VS CODE — PowerShell · commit del paso 08
# Revisa qué archivos cambiaste en este paso: NO debe aparecer .env
git status
# Agrega todos los archivos de este paso
git add .
# Guarda el paso 08: empieza con "paso 08" y termina con tu nombre
git commit -m "paso 08: reportes.py con las 6 consultas SQL - prado-diaz-bernardo"
# Confirma que el commit quedó en tu historial
git log --oneline -3
09

Las vistas nuevas y los cambios a las de servicios

⏱ 35 mincore/views.py

Lo nuevo: categorias, editar_categoria, eliminar_categoria, facturas, crear_factura, detalle_factura, reportes y reporte. Cambian servicios (trae la categoría en la misma consulta y busca por ella) y eliminar_servicio (avisa si el servicio ya está facturado). Las demás quedan igual.

01

Reemplaza core/views.py completo

core/views.py — archivo COMPLE
# csv: para armar el archivo .csv que se descarga desde cada reporte
import csv
# render, redirect, get_object_or_404: los mismos atajos de Django que ya usabas
from django.shortcuts import render, redirect, get_object_or_404
# HttpResponse: respuesta "a mano" (la usa la descarga CSV) · Http404: error "no existe"
from django.http import HttpResponse, Http404
# connection: datos de la conexión (vista inicio) · transaction: guardar todo o nada · DatabaseError: errores de MySQL
from django.db import connection, transaction, DatabaseError
# Sum, Count, F, Q: funciones del ORM para sumar, contar, usar columnas y combinar filtros · ProtectedError: aviso de PROTECT
from django.db.models import Sum, Count, F, Q, ProtectedError
# login_required: decorador que exige sesión iniciada
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: registro listo · AuthenticationForm: inicio de sesión listo
from django.contrib.auth.forms import UserCreationForm, AuthenticationForm
# messages: mensajes flash que se muestran una sola vez
from django.contrib import messages
# Paginator: divide una lista larga en páginas
from django.core.paginator import Paginator
# los 3 modelos que usan estas vistas (DetalleFactura se usa a través de factura.detalles)
from .models import Servicio, Categoria, Factura
# los formularios: el de servicio que ya tenías y los 3 nuevos
from .forms import ServicioForm, CategoriaForm, FacturaForm, DetalleFormSet
# reportes: el archivo nuevo con las consultas SQL escritas a mano
from . import reportes as reportes_sql


# 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
@login_required
# servicios: el listado de la Semana 7, ahora también busca por categoría y trae la categoría en la misma consulta
def servicios(request):
    # select_related('categoria'): un JOIN trae servicio + categoría de una vez (sin esto, 1 consulta extra por renglón)
    lista_servicios = Servicio.objects.filter(propietario=request.user).select_related('categoria').order_by('-creado_en', '-id')
    # q: el texto del buscador; '' si no hay búsqueda
    query = request.GET.get('q', '').strip()
    # si el usuario escribió algo...
    if query:
        # Q(...) | Q(...): busca el texto en el título O en el nombre de la categoría
        lista_servicios = lista_servicios.filter(Q(titulo__icontains=query) | Q(categoria__nombre__icontains=query))
    # Paginator: páginas de 5 registros
    paginator = Paginator(lista_servicios, 5)
    # get_page: la página pedida en ?page=
    pagina = paginator.get_page(request.GET.get('page'))
    # render: la misma plantilla de la Semana 7 (con la columna Categoría nueva)
    return render(request, 'core/servicios.html', {'servicios': pagina, 'query': query})


# @login_required: solo con sesión
@login_required
# crear_servicio: no cambia respecto a la Semana 7
def crear_servicio(request):
    # POST = el usuario envió el formulario
    if request.method == 'POST':
        # reconstruye el formulario con lo que tecleó
        form = ServicioForm(request.POST)
        # valida tipos, obligatorios y clean_precio
        if form.is_valid():
            # crea el objeto en memoria sin guardarlo todavía
            nuevo = form.save(commit=False)
            # el dueño lo pone la vista, no el formulario
            nuevo.propietario = request.user
            # ahora sí lo guarda en MySQL
            nuevo.save()
            # regresa al listado
            return redirect('servicios')
    # GET = el usuario apenas abrió la página
    else:
        # formulario vacío
        form = ServicioForm()
    # dibuja el formulario (vacío o con errores)
    return render(request, 'core/servicio_form.html', {'form': form, 'titulo_pagina': 'Nuevo servicio'})


# @login_required: solo con sesión
@login_required
# editar_servicio: no cambia respecto a la Semana 7
def editar_servicio(request, pk):
    # el id correcto Y que sea tuyo; si no, 404
    servicio = get_object_or_404(Servicio, pk=pk, propietario=request.user)
    # POST = se enviaron cambios
    if request.method == 'POST':
        # instance: actualiza ESE registro
        form = ServicioForm(request.POST, instance=servicio)
        # valida los datos
        if form.is_valid():
            # guarda el UPDATE en MySQL
            form.save()
            # regresa al listado
            return redirect('servicios')
    # GET = se abrió la pantalla de edición
    else:
        # formulario precargado
        form = ServicioForm(instance=servicio)
    # dibuja el mismo template de crear
    return render(request, 'core/servicio_form.html', {'form': form, 'titulo_pagina': 'Editar servicio'})


# @login_required: solo con sesión
@login_required
# eliminar_servicio: CAMBIA; si el servicio ya está en una factura, PROTECT impide borrarlo y se avisa
def eliminar_servicio(request, pk):
    # el id correcto Y que sea tuyo; si no, 404
    servicio = get_object_or_404(Servicio, pk=pk, propietario=request.user)
    # solo se borra con el botón de confirmar (POST)
    if request.method == 'POST':
        # try: intenta borrar
        try:
            # delete(): borra la fila en MySQL
            servicio.delete()
            # mensaje verde de confirmación
            messages.success(request, f'Se eliminó "{servicio.titulo}".')
        # ProtectedError: Django lo lanza porque DetalleFactura.servicio tiene on_delete=PROTECT
        except ProtectedError:
            # messages.error: mensaje rojo; el servicio sigue existiendo
            messages.error(request, f'No se puede eliminar "{servicio.titulo}": ya aparece en una factura.')
        # en ambos casos regresa al listado
        return redirect('servicios')
    # GET: solo muestra la pantalla de "¿Seguro?"
    return render(request, 'core/servicio_confirm_delete.html', {'servicio': servicio})


# @login_required: solo con sesión
@login_required
# categorias: NUEVA; en la misma página se listan las categorías y se da de alta una nueva
def categorias(request):
    # POST = se envió el formulario de "Nueva categoría"
    if request.method == 'POST':
        # reconstruye el formulario con lo tecleado
        form = CategoriaForm(request.POST)
        # valida (incluye que el nombre no se repita, por unique=True)
        if form.is_valid():
            # guarda la categoría en MySQL
            form.save()
            # mensaje verde
            messages.success(request, 'Categoría creada.')
            # recarga la misma página (evita reenvíos al recargar)
            return redirect('categorias')
    # GET = se abrió la página
    else:
        # formulario vacío
        form = CategoriaForm()
    # annotate + Count: agrega a cada categoría cuántos servicios TUYOS tiene, en una sola consulta (GROUP BY)
    lista = Categoria.objects.annotate(total_servicios=Count('servicios', filter=Q(servicios__propietario=request.user)))
    # dibuja la página con el formulario y la lista
    return render(request, 'core/categorias.html', {'form': form, 'categorias': lista})


# @login_required: solo con sesión
@login_required
# editar_categoria: NUEVA; cambia el nombre o la descripción de una categoría
def editar_categoria(request, pk):
    # busca la categoría; si no existe, 404 (las categorías son un catálogo común para todos)
    categoria = get_object_or_404(Categoria, pk=pk)
    # POST = se enviaron cambios
    if request.method == 'POST':
        # instance: actualiza ESA categoría
        form = CategoriaForm(request.POST, instance=categoria)
        # valida
        if form.is_valid():
            # guarda el UPDATE
            form.save()
            # mensaje verde
            messages.success(request, 'Categoría actualizada.')
            # regresa a la lista
            return redirect('categorias')
    # GET = se abrió la pantalla
    else:
        # formulario precargado
        form = CategoriaForm(instance=categoria)
    # dibuja el formulario de edición
    return render(request, 'core/categoria_form.html', {'form': form})


# @login_required: solo con sesión
@login_required
# eliminar_categoria: NUEVA; solo acepta POST y respeta PROTECT
def eliminar_categoria(request, pk):
    # busca la categoría o da 404
    categoria = get_object_or_404(Categoria, pk=pk)
    # solo se borra con el botón (POST), nunca con un enlace (GET)
    if request.method == 'POST':
        # try: intenta borrar
        try:
            # delete(): borra la fila
            categoria.delete()
            # mensaje verde
            messages.success(request, f'Se eliminó la categoría "{categoria.nombre}".')
        # ProtectedError: la categoría todavía tiene servicios (Servicio.categoria es PROTECT)
        except ProtectedError:
            # mensaje rojo
            messages.error(request, f'No se puede eliminar "{categoria.nombre}": todavía tiene servicios.')
    # en cualquier caso regresa a la lista
    return redirect('categorias')


# @login_required: solo con sesión
@login_required
# facturas: NUEVA; lista tus facturas con su total calculado por MySQL
def facturas(request):
    # annotate: MySQL calcula para cada factura su total (SUM) y cuántos renglones tiene (COUNT) en UNA consulta
    lista = (Factura.objects.filter(usuario=request.user)
             # total: suma de cantidad × precio_unitario de sus renglones
             .annotate(total=Sum(F('detalles__cantidad') * F('detalles__precio_unitario')),
                       # partidas: número de renglones
                       partidas=Count('detalles'))
             # order_by: con annotate (GROUP BY) Django ignora el ordering del Meta; se repite aquí para paginar bien
             .order_by('-fecha', '-id'))
    # Paginator: páginas de 10 facturas
    pagina = Paginator(lista, 10).get_page(request.GET.get('page'))
    # dibuja la lista
    return render(request, 'core/facturas.html', {'facturas': pagina})


# @login_required: solo con sesión
@login_required
# crear_factura: NUEVA; guarda el encabezado y sus renglones juntos
def crear_factura(request):
    # factura nueva en memoria, ya con el usuario que la emite
    factura = Factura(usuario=request.user)
    # POST = se envió el formulario
    if request.method == 'POST':
        # form: el encabezado (cliente y fecha)
        form = FacturaForm(request.POST, instance=factura)
        # formset: los renglones; form_kwargs manda el usuario a cada DetalleFacturaForm para filtrar sus servicios
        formset = DetalleFormSet(request.POST, instance=factura, form_kwargs={'usuario': request.user})
        # ambos deben ser válidos: datos del encabezado y al menos 1 renglón correcto
        if form.is_valid() and formset.is_valid():
            # transaction.atomic: si algo falla a la mitad, MySQL deshace TODO (no quedan facturas sin renglones)
            with transaction.atomic():
                # guarda el encabezado; ahora la factura ya tiene id
                factura = form.save()
                # commit=False: arma los renglones sin guardarlos para completarles el precio
                detalles = formset.save(commit=False)
                # recorre cada renglón capturado
                for detalle in detalles:
                    # precio_unitario: se copia del servicio en este momento (precio histórico)
                    detalle.precio_unitario = detalle.servicio.precio
                    # guarda el renglón ligado a la factura
                    detalle.save()
            # mensaje verde con el folio
            messages.success(request, f'Factura {factura.folio} guardada.')
            # va al detalle de la factura recién creada
            return redirect('detalle_factura', pk=factura.pk)
    # GET = se abrió la página
    else:
        # encabezado vacío
        form = FacturaForm(instance=factura)
        # renglones vacíos, con la lista de servicios filtrada al usuario
        formset = DetalleFormSet(instance=factura, form_kwargs={'usuario': request.user})
    # dibuja el formulario
    return render(request, 'core/factura_form.html', {'form': form, 'formset': formset})


# @login_required: solo con sesión
@login_required
# detalle_factura: NUEVA; muestra una factura con sus renglones y su total
def detalle_factura(request, pk):
    # el id correcto Y que la hayas emitido tú; si no, 404
    factura = get_object_or_404(Factura, pk=pk, usuario=request.user)
    # select_related: trae servicio y categoría de cada renglón en la misma consulta
    detalles = factura.detalles.select_related('servicio__categoria')
    # aggregate: MySQL suma el total de la factura; "or 0" por si no tuviera renglones
    total = detalles.aggregate(total=Sum(F('cantidad') * F('precio_unitario')))['total'] or 0
    # dibuja el detalle
    return render(request, 'core/factura_detalle.html', {'factura': factura, 'detalles': detalles, 'total': total})


# @login_required: solo con sesión
@login_required
# reportes: NUEVA; la lista de los 6 reportes con SQL escrito a mano
def reportes(request):
    # manda la lista CONSULTAS de reportes.py a la plantilla
    return render(request, 'core/reportes.html', {'consultas': reportes_sql.CONSULTAS})


# @login_required: solo con sesión
@login_required
# reporte: NUEVA; ejecuta UNA consulta y la dibuja como tabla, sin importar cuál sea
def reporte(request, clave):
    # buscar: encuentra la consulta por su clave (la parte final de la URL)
    consulta = reportes_sql.buscar(clave)
    # si no existe esa clave...
    if consulta is None:
        # ...error 404
        raise Http404('Reporte no encontrado')
    # parametros: el valor de %(uid)s; Django lo pasa a MySQL de forma segura
    parametros = {'uid': request.user.id}
    # ver_plan: True si la URL trae ?plan=1 (botón "Ver plan EXPLAIN")
    ver_plan = request.GET.get('plan') == '1'
    # EXPLAIN + la consulta: MySQL no ejecuta la consulta, explica CÓMO la ejecutaría (qué índices usa)
    sql = 'EXPLAIN ' + consulta['sql'] if ver_plan else consulta['sql']
    # error: aquí se guarda el mensaje si MySQL rechaza el SQL
    error = None
    # try: intenta ejecutar el SQL
    try:
        # ejecutar_sql: devuelve nombres de columnas, filas y milisegundos
        columnas, filas, milisegundos = reportes_sql.ejecutar_sql(sql, parametros)
    # DatabaseError: un error de SQL (columna mal escrita, coma de más, etc.)
    except DatabaseError as e:
        # se muestra en la página en vez de tumbar el sitio con un error 500
        columnas, filas, milisegundos, error = [], [], 0, str(e)
    # ?formato=csv: descarga del resultado
    if request.GET.get('formato') == 'csv' and error is None:
        # content_type: le dice al navegador que es un archivo CSV en UTF-8
        respuesta = HttpResponse(content_type='text/csv; charset=utf-8')
        # Content-Disposition: lo descarga con el nombre de la clave (ej. ingresos-por-categoria.csv)
        respuesta['Content-Disposition'] = f'attachment; filename="{clave}.csv"'
        # '': marca invisible para que Excel lea bien los acentos
        respuesta.write('')
        # csv.writer: escribe renglones separados por comas en la respuesta
        escritor = csv.writer(respuesta)
        # primer renglón: los nombres de las columnas
        escritor.writerow(columnas)
        # después, cada fila del resultado
        escritor.writerows(filas)
        # entrega el archivo
        return respuesta
    # celdas: cada valor convertido a {'texto', 'numero'} para dibujarlo bonito y alinear los números
    celdas = [[reportes_sql.formatear(valor) for valor in fila] for fila in filas]
    # contexto: todo lo que la plantilla necesita
    contexto = {
        # consulta: título, técnica, descripción y SQL
        'consulta': consulta,
        # sql_mostrado: el SQL que realmente se ejecutó (con EXPLAIN si se pidió)
        'sql_mostrado': sql.strip(),
        # columnas: encabezados de la tabla
        'columnas': columnas,
        # filas: las celdas ya formateadas
        'filas': celdas,
        # milisegundos: redondeado a 1 decimal
        'milisegundos': round(milisegundos, 1),
        # ver_plan: para marcar qué botón está activo
        'ver_plan': ver_plan,
        # error: el mensaje de MySQL o None
        'error': error,
    # cierra el contexto
    }
    # dibuja la página del reporte
    return render(request, 'core/reporte.html', contexto)


# registro: no cambia (Semana 6)
def registro(request):
    # POST = se envió el formulario de registro
    if request.method == 'POST':
        # reconstruye el formulario
        form = UserCreationForm(request.POST)
        # valida usuario y contraseñas
        if form.is_valid():
            # crea el usuario con la contraseña hasheada
            usuario_nuevo = form.save()
            # inicia sesión automáticamente
            login(request, usuario_nuevo)
            # mensaje de bienvenida
            messages.success(request, f'Cuenta creada. ¡Bienvenido, {usuario_nuevo.username}!')
            # va al listado
            return redirect('servicios')
    # GET = se abrió la página
    else:
        # formulario vacío
        form = UserCreationForm()
    # dibuja el registro
    return render(request, 'core/registro.html', {'form': form})


# iniciar_sesion: no cambia (Semana 6)
def iniciar_sesion(request):
    # POST = se enviaron usuario y contraseña
    if request.method == 'POST':
        # el formulario de login necesita request
        form = AuthenticationForm(request, data=request.POST)
        # valida contra la base
        if form.is_valid():
            # guarda la sesión
            login(request, form.get_user())
            # mensaje verde
            messages.success(request, 'Sesión iniciada correctamente.')
            # va al listado
            return redirect('servicios')
    # GET = se abrió la página
    else:
        # formulario vacío
        form = AuthenticationForm()
    # dibuja el login
    return render(request, 'core/login.html', {'form': form})


# cerrar_sesion: no cambia (Semana 6)
def cerrar_sesion(request):
    # invalida la sesión
    logout(request)
    # mensaje informativo
    messages.info(request, 'Sesión cerrada correctamente.')
    # va al login
    return redirect('login')
OPTIMIZACIÓN

select_related hace un JOIN para traer servicio y categoría en una consulta en vez de una por renglón (el problema "N+1"). annotate(Sum, Count) hace que MySQL calcule los totales de todas las facturas en una consulta con GROUP BY, en lugar de sumar en Python.

Commit del paso 09 · commit 8 de 13

Al terminar el paso, frente a la cámara. En git status deben aparecer estos archivos (y nunca .env).

  • core/views.py — modificado
TERMINAL DE VS CODE — PowerShell · commit del paso 09
# Revisa qué archivos cambiaste en este paso: NO debe aparecer .env
git status
# Agrega todos los archivos de este paso
git add .
# Guarda el paso 09: empieza con "paso 09" y termina con tu nombre
git commit -m "paso 09: vistas de categorias, facturas y reportes - prado-diaz-bernardo"
# Confirma que el commit quedó en tu historial
git log --oneline -3
10

Las rutas nuevas

⏱ 5 mincore/urls.py
01

Reemplaza core/urls.py completo

core/urls.py — archivo COMPLE
# path: función para declarar cada ruta
from django.urls import path
# views: el archivo core/views.py
from . import views

# urlpatterns: la lista de rutas que Django revisa, en orden, para cada URL
urlpatterns = [
    # página de inicio (Semana 3)
    path('', views.inicio, name='inicio'),
    # listado de servicios
    path('servicios/', views.servicios, name='servicios'),
    # crear servicio
    path('servicios/nuevo/', views.crear_servicio, name='crear_servicio'),
    # editar servicio
    path('servicios/<int:pk>/editar/', views.editar_servicio, name='editar_servicio'),
    # eliminar servicio
    path('servicios/<int:pk>/eliminar/', views.eliminar_servicio, name='eliminar_servicio'),
    # NUEVA: lista y alta de categorías
    path('categorias/', views.categorias, name='categorias'),
    # NUEVA: editar una categoría
    path('categorias/<int:pk>/editar/', views.editar_categoria, name='editar_categoria'),
    # NUEVA: eliminar una categoría (solo POST)
    path('categorias/<int:pk>/eliminar/', views.eliminar_categoria, name='eliminar_categoria'),
    # NUEVA: lista de facturas
    path('facturas/', views.facturas, name='facturas'),
    # NUEVA: crear factura con sus renglones
    path('facturas/nueva/', views.crear_factura, name='crear_factura'),
    # NUEVA: detalle de una factura
    path('facturas/<int:pk>/', views.detalle_factura, name='detalle_factura'),
    # NUEVA: lista de reportes SQL
    path('reportes/', views.reportes, name='reportes'),
    # NUEVA: un reporte; <slug:clave> acepta letras, números y guiones (ej. ingresos-por-categoria)
    path('reportes/<slug:clave>/', views.reporte, name='reporte'),
    # registro (Semana 6)
    path('registro/', views.registro, name='registro'),
    # login (Semana 6)
    path('login/', views.iniciar_sesion, name='login'),
    # logout (Semana 6)
    path('logout/', views.cerrar_sesion, name='logout'),
# cierra la lista urlpatterns
]

Commit del paso 10 · commit 9 de 13

Al terminar el paso, frente a la cámara. En git status deben aparecer estos archivos (y nunca .env).

  • core/urls.py — modificado
TERMINAL DE VS CODE — PowerShell · commit del paso 10
# Revisa qué archivos cambiaste en este paso: NO debe aparecer .env
git status
# Agrega todos los archivos de este paso
git add .
# Guarda el paso 10: empieza con "paso 10" y termina con tu nombre
git commit -m "paso 10: rutas de categorias, facturas y reportes - prado-diaz-bernardo"
# Confirma que el commit quedó en tu historial
git log --oneline -3
11

Plantillas y cómo debe verse cada pantalla

⏱ 40 mincore/templates/core/

Dos plantillas cambian (base.html y servicios.html) y siete son nuevas. Créalas todas en core/templates/core/. Debajo de cada una está la pantalla como debe verse en tu navegador.

01

base.html: menú de secciones y mensajes de error en rojo

core/templates/core/base.html — archivo COMPLE
{# base.html: el molde de todas las páginas; ahora con menú de secciones, mensajes de color y más ancho #}
<!DOCTYPE html>
{# lang="es": el contenido está en español #}
<html lang="es">
{# head: datos que no se ven #}
<head>
  {# charset UTF-8: acentos y ñ #}
  <meta charset="UTF-8">
  {# viewport: se adapta al celular #}
  <meta name="viewport" content="width=device-width, initial-scale=1">
  {# block titulo: el texto de la pestaña; si una página no lo cambia, dice "Servicios" #}
  <title>{% block titulo %}Servicios{% endblock %}</title>
  {# Play CDN de Tailwind: el mismo de la Semana 2 #}
  <script src="https://cdn.tailwindcss.com"></script>
  {# style: reglas mínimas para los campos que dibuja Django #}
  <style>
    /* input, textarea, select: borde gris, esquinas redondeadas y ancho completo */
    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: errores de Django en rojo */
    .errorlist { color: #dc2626; font-size: .9rem; }
    /* .helptext: textos de ayuda en gris */
    .helptext { color: #64748b; font-size: .8rem; display: block; }
  {# cierra los estilos #}
  </style>
{# cierra el head #}
</head>
{# body: max-w-5xl (antes 2xl) deja espacio para las tablas de facturas y reportes #}
<body class="max-w-5xl mx-auto py-8 px-4 sm:px-6 bg-white text-slate-900">
  {# nav: barra superior; flex-wrap acomoda los enlaces en 2 renglones en celular #}
  <nav class="flex flex-wrap justify-between items-center gap-3 mb-6 text-sm border-b border-slate-200 pb-4">
    {# div: grupo izquierdo con las secciones del sistema #}
    <div class="flex flex-wrap gap-4 font-semibold">
      {# if: el menú de secciones solo se ve con sesión iniciada #}
      {% if user.is_authenticated %}
        {# enlace al listado de servicios #}
        <a href="{% url 'servicios' %}" class="text-blue-700 hover:underline">Servicios</a>
        {# NUEVO: enlace a categorías #}
        <a href="{% url 'categorias' %}" class="text-blue-700 hover:underline">Categorías</a>
        {# NUEVO: enlace a facturas #}
        <a href="{% url 'facturas' %}" class="text-blue-700 hover:underline">Facturas</a>
        {# NUEVO: enlace a reportes SQL #}
        <a href="{% url 'reportes' %}" class="text-blue-700 hover:underline">Reportes SQL</a>
      {# endif: fin del menú de secciones #}
      {% endif %}
    {# cierra el grupo izquierdo #}
    </div>
    {# div: grupo derecho con la sesión #}
    <div class="flex items-center gap-4">
      {# if: True si hay sesión iniciada #}
      {% if user.is_authenticated %}
        {# nombre del usuario #}
        <span>Hola, <b>{{ user.username }}</b></span>
        {# cerrar sesión #}
        <a href="{% url 'logout' %}" class="text-blue-600 underline">Cerrar sesión</a>
      {# else: visitante sin sesión #}
      {% else %}
        {# iniciar sesión #}
        <a href="{% url 'login' %}" class="text-blue-600 underline">Iniciar sesión</a>
        {# crear cuenta #}
        <a href="{% url 'registro' %}" class="text-blue-600 underline">Crear cuenta</a>
      {# endif: fin del bloque de sesión #}
      {% endif %}
    {# cierra el grupo derecho #}
    </div>
  {# cierra la barra superior #}
  </nav>
  {# for: recorre los mensajes flash #}
  {% for message in messages %}
    {# message.tags: "error" para messages.error (rojo); cualquier otro (success, info) va en verde #}
    <div class="{% if message.tags == 'error' %}bg-red-100 text-red-800{% else %}bg-emerald-100 text-emerald-800{% endif %} px-4 py-2 rounded-lg mb-4">{{ message }}</div>
  {# endfor: fin de los mensajes #}
  {% endfor %}
  {# block contenido: aquí cada página pone lo suyo #}
  {% block contenido %}{% endblock %}
{# cierra el body #}
</body>
{# cierra el documento #}
</html>
02

servicios.html: columna Categoría y búsqueda por categoría

Es tu listado de la Semana 7 con 4 cambios marcados como NUEVO: el texto del buscador, el encabezado y la celda de categoría, y colspan="4".

core/templates/core/servicios.html — archivo COMPLE
{# 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 o categoría..." aria-label="Buscar servicios por título o categoría" 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: NUEVO encabezado de la columna de la categoría #}
          <th class="text-left px-4 py-2">Categoría</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: NUEVA celda con la categoría; muestra su nombre gracias a __str__ (default si quedó vacía) #}
          <td class="px-4 py-2">{{ servicio.categoria|default:"Sin categoría" }}</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="4" (antes 3) la estira a lo ancho de las 4 columnas #}
        <tr>
          {# if query: el mensaje cambia si la lista está vacía por una búsqueda o porque no hay registros #}
          <td colspan="4" 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 %}

Así debe verse: listado de servicios buscando «tutor»; la búsqueda encuentra por el nombre de la categoría

03

categorias.html (nuevo)

core/templates/core/categorias.html — archivo NUE
{# extends: usa el molde base.html (debe ser la primera etiqueta) #}
{% extends 'core/base.html' %}

{# block titulo: texto de la pestaña #}
{% block titulo %}Categorías{% endblock %}

{# block contenido: lo que va dentro del body #}
{% block contenido %}
  {# h1: título de la página #}
  <h1 class="text-2xl font-bold mb-6">Categorías</h1>
  {# grid: en celular una columna; desde md, formulario a la izquierda y tabla a la derecha #}
  <div class="grid gap-6 md:grid-cols-3">
    {# form: alta de una categoría nueva; ocupa 1 de las 3 columnas #}
    <form method="POST" class="space-y-3 border border-slate-200 rounded-xl p-4 h-fit">
      {# csrf_token: obligatorio en todo formulario POST #}
      {% csrf_token %}
      {# h2: título del formulario #}
      <h2 class="font-semibold">Nueva categoría</h2>
      {# form.as_p: dibuja nombre y descripción con sus errores #}
      {{ form.as_p }}
      {# botón que envía el formulario #}
      <button type="submit" class="w-full bg-blue-600 hover:bg-blue-500 text-white font-semibold px-4 py-2 rounded-lg">Guardar</button>
    {# cierra el formulario #}
    </form>
    {# div: la tabla ocupa 2 de las 3 columnas; overflow-x-auto la desliza en celular #}
    <div class="md:col-span-2 overflow-x-auto">
      {# table: lista de categorías #}
      <table class="w-full border border-slate-200 rounded-lg overflow-hidden">
        {# thead: encabezados #}
        <thead class="bg-slate-100">
          {# tr: fila de encabezados #}
          <tr>
            {# th: nombre #}
            <th class="text-left px-4 py-2">Categoría</th>
            {# th: total de servicios; text-right alinea números a la derecha #}
            <th class="text-right px-4 py-2">Mis servicios</th>
            {# th: acciones #}
            <th class="text-left px-4 py-2">Acciones</th>
          {# cierra la fila de encabezados #}
          </tr>
        {# cierra thead #}
        </thead>
        {# tbody: filas de datos #}
        <tbody>
          {# for: una fila por categoría #}
          {% for categoria in categorias %}
          {# tr: fila de la categoría #}
          <tr class="border-t border-slate-200">
            {# td: nombre y, debajo, la descripción en gris #}
            <td class="px-4 py-2"><b>{{ categoria.nombre }}</b><br><span class="text-sm text-slate-500">{{ categoria.descripcion }}</span></td>
            {# td: total_servicios viene del annotate(Count) de la vista #}
            <td class="px-4 py-2 text-right">{{ categoria.total_servicios }}</td>
            {# td: enlaces de acción en un solo renglón #}
            <td class="px-4 py-2 whitespace-nowrap">
              {# enlace a /categorias/<id>/editar/ #}
              <a href="{% url 'editar_categoria' categoria.pk %}" class="text-blue-600 underline">Editar</a>
              {# form POST: borrar solo con botón; onsubmit confirm() pide confirmación en el navegador #}
              <form method="POST" action="{% url 'eliminar_categoria' categoria.pk %}" class="inline" onsubmit="return confirm('¿Eliminar esta categoría?');">
                {# csrf_token: también en este mini formulario #}
                {% csrf_token %}
                {# botón con aspecto de enlace rojo; w-auto anula el ancho completo de base.html #}
                <button type="submit" class="text-red-600 underline ml-3 w-auto">Eliminar</button>
              {# cierra el mini formulario #}
              </form>
            {# cierra la celda de acciones #}
            </td>
          {# cierra la fila #}
          </tr>
          {# empty: si todavía no hay categorías #}
          {% empty %}
          {# fila con el aviso, a lo ancho de las 3 columnas #}
          <tr><td colspan="3" class="text-center text-slate-500 py-6">Aún no hay categorías. Crea la primera con el formulario.</td></tr>
          {# endfor: fin de la repetición #}
          {% endfor %}
        {# cierra tbody #}
        </tbody>
      {# cierra la tabla #}
      </table>
    {# cierra el contenedor de la tabla #}
    </div>
  {# cierra la cuadrícula #}
  </div>
{# endblock: fin del contenido #}
{% endblock %}

Así debe verse: lista de categorías con el aviso rojo al intentar borrar una que tiene servicios

04

categoria_form.html (nuevo)

core/templates/core/categoria_form.html — archivo NUE
{# extends: usa el molde base.html #}
{% extends 'core/base.html' %}

{# block titulo: texto de la pestaña #}
{% block titulo %}Editar categoría{% endblock %}

{# block contenido: lo que va dentro del body #}
{% block contenido %}
  {# h1: título de la página #}
  <h1 class="text-2xl font-bold mb-4">Editar categoría</h1>
  {# form: max-w-md lo deja angosto y legible #}
  <form method="POST" class="space-y-4 max-w-md">
    {# csrf_token: obligatorio en POST #}
    {% csrf_token %}
    {# form.as_p: nombre y descripción precargados #}
    {{ form.as_p }}
    {# botón guardar; w-auto anula el ancho completo #}
    <button type="submit" class="w-auto bg-blue-600 hover:bg-blue-500 text-white font-semibold px-4 py-2 rounded-lg">Guardar</button>
    {# regresar sin guardar #}
    <a href="{% url 'categorias' %}" class="ml-3 text-slate-600 underline">Cancelar</a>
  {# cierra el formulario #}
  </form>
{# endblock: fin del contenido #}
{% endblock %}
05

facturas.html (nuevo)

core/templates/core/facturas.html — archivo NUE
{# extends: usa el molde base.html #}
{% extends 'core/base.html' %}

{# block titulo: texto de la pestaña #}
{% block titulo %}Facturas{% endblock %}

{# block contenido: lo que va dentro del body #}
{% block contenido %}
  {# h1: título #}
  <h1 class="text-2xl font-bold mb-6">Facturas</h1>
  {# enlace para crear una factura nueva #}
  <a href="{% url 'crear_factura' %}" class="inline-block mb-4 bg-blue-600 text-white px-4 py-2 rounded-lg">+ Nueva factura</a>
  {# overflow-x-auto: la tabla se desliza de lado en celular #}
  <div class="overflow-x-auto">
    {# table: lista de facturas #}
    <table class="w-full border border-slate-200 rounded-lg overflow-hidden">
      {# thead: encabezados #}
      <thead class="bg-slate-100">
        {# tr: fila de encabezados #}
        <tr>
          {# th: folio #}
          <th class="text-left px-4 py-2">Folio</th>
          {# th: fecha #}
          <th class="text-left px-4 py-2">Fecha</th>
          {# th: cliente #}
          <th class="text-left px-4 py-2">Cliente</th>
          {# th: renglones, alineado a la derecha por ser número #}
          <th class="text-right px-4 py-2">Partidas</th>
          {# th: total #}
          <th class="text-right px-4 py-2">Total</th>
        {# cierra la fila #}
        </tr>
      {# cierra thead #}
      </thead>
      {# tbody: filas de datos #}
      <tbody>
        {# for: una fila por factura de la página actual #}
        {% for factura in facturas %}
        {# tr: fila de la factura #}
        <tr class="border-t border-slate-200">
          {# td: folio como enlace al detalle #}
          <td class="px-4 py-2"><a href="{% url 'detalle_factura' factura.pk %}" class="text-blue-600 underline font-mono">{{ factura.folio }}</a></td>
          {# td: date:"d/m/Y" muestra la fecha como 05/11/2026 #}
          <td class="px-4 py-2">{{ factura.fecha|date:"d/m/Y" }}</td>
          {# td: cliente #}
          <td class="px-4 py-2">{{ factura.cliente }}</td>
          {# td: partidas viene del annotate(Count) #}
          <td class="px-4 py-2 text-right">{{ factura.partidas }}</td>
          {# td: total viene del annotate(Sum); floatformat:2 deja siempre 2 decimales #}
          <td class="px-4 py-2 text-right font-semibold">${{ factura.total|floatformat:2 }}</td>
        {# cierra la fila #}
        </tr>
        {# empty: si no hay facturas #}
        {% empty %}
        {# aviso a lo ancho de las 5 columnas #}
        <tr><td colspan="5" class="text-center text-slate-500 py-6">Aún no tienes facturas. ¡Crea la primera!</td></tr>
        {# endfor: fin de la repetición #}
        {% endfor %}
      {# cierra tbody #}
      </tbody>
    {# cierra la tabla #}
    </table>
  {# cierra el contenedor #}
  </div>
  {# if: controles de página solo si hay más de una #}
  {% if facturas.paginator.num_pages > 1 %}
  {# nav: barra de paginación #}
  <nav class="mt-4 flex items-center gap-2" aria-label="Paginación">
    {# página anterior si existe #}
    {% if facturas.has_previous %}<a href="?page={{ facturas.previous_page_number }}" class="text-blue-600 underline px-2 py-2">← Anterior</a>{% endif %}
    {# página actual de total #}
    <span class="text-slate-500">Página {{ facturas.number }} de {{ facturas.paginator.num_pages }}</span>
    {# página siguiente si existe #}
    {% if facturas.has_next %}<a href="?page={{ facturas.next_page_number }}" class="text-blue-600 underline px-2 py-2">Siguiente →</a>{% endif %}
  {# cierra la paginación #}
  </nav>
  {# endif: fin de la paginación #}
  {% endif %}
{# endblock: fin del contenido #}
{% endblock %}

Así debe verse: lista de facturas; Partidas y Total los calcula MySQL con COUNT y SUM

06

factura_form.html (nuevo)

core/templates/core/factura_form.html — archivo NUE
{# extends: usa el molde base.html #}
{% extends 'core/base.html' %}

{# block titulo: texto de la pestaña #}
{% block titulo %}Nueva factura{% endblock %}

{# block contenido: lo que va dentro del body #}
{% block contenido %}
  {# h1: título #}
  <h1 class="text-2xl font-bold mb-4">Nueva factura</h1>
  {# form: UN solo formulario envía el encabezado y todos los renglones juntos #}
  <form method="POST" class="space-y-6">
    {# csrf_token: obligatorio en POST #}
    {% csrf_token %}
    {# grid: cliente y fecha lado a lado desde sm #}
    <div class="grid gap-4 sm:grid-cols-2 max-w-2xl">
      {# form.as_p: dibuja cliente y fecha (con calendario) #}
      {{ form.as_p }}
    {# cierra el encabezado #}
    </div>
    {# management_form: 4 campos ocultos que le dicen a Django cuántos renglones vienen; SIN esto el formset falla #}
    {{ formset.management_form }}
    {# non_form_errors: errores generales del formset (ej. "envía al menos 1 formulario") #}
    {% if formset.non_form_errors %}<div class="bg-red-100 text-red-800 px-4 py-2 rounded-lg">{{ formset.non_form_errors }}</div>{% endif %}
    {# overflow-x-auto: la tabla de renglones se desliza en celular #}
    <div class="overflow-x-auto">
      {# table: un renglón por cada formulario del formset #}
      <table class="w-full border border-slate-200 rounded-lg overflow-hidden">
        {# thead: encabezados #}
        <thead class="bg-slate-100">
          {# tr: fila de encabezados #}
          <tr>
            {# th: número de renglón #}
            <th class="text-left px-3 py-2 w-10">#</th>
            {# th: servicio #}
            <th class="text-left px-3 py-2">Servicio</th>
            {# th: cantidad #}
            <th class="text-left px-3 py-2 w-32">Cantidad</th>
          {# cierra la fila #}
          </tr>
        {# cierra thead #}
        </thead>
        {# tbody: los renglones #}
        <tbody>
          {# for: recorre cada formulario del formset (1 obligatorio + 3 extra) #}
          {% for renglon in formset %}
          {# tr: un renglón de captura #}
          <tr class="border-t border-slate-200 align-top">
            {# td: forloop.counter = 1, 2, 3, 4; hidden_fields = campos ocultos id y factura que Django necesita #}
            <td class="px-3 py-2 text-slate-500">{{ forloop.counter }}{% for oculto in renglon.hidden_fields %}{{ oculto }}{% endfor %}</td>
            {# td: la lista desplegable de TUS servicios y su error si lo hay #}
            <td class="px-3 py-2">{{ renglon.servicio }}{{ renglon.servicio.errors }}</td>
            {# td: la cantidad y su error #}
            <td class="px-3 py-2">{{ renglon.cantidad }}{{ renglon.cantidad.errors }}</td>
          {# cierra el renglón #}
          </tr>
          {# endfor: fin de los renglones #}
          {% endfor %}
        {# cierra tbody #}
        </tbody>
      {# cierra la tabla #}
      </table>
    {# cierra el contenedor #}
    </div>
    {# p: ayuda; los renglones que dejes sin servicio se ignoran #}
    <p class="text-sm text-slate-500">Llena al menos un renglón. Los renglones sin servicio se ignoran. El precio se toma del servicio al guardar.</p>
    {# botón guardar; w-auto anula el ancho completo #}
    <button type="submit" class="w-auto bg-blue-600 hover:bg-blue-500 text-white font-semibold px-4 py-2 rounded-lg">Guardar factura</button>
    {# regresar sin guardar #}
    <a href="{% url 'facturas' %}" class="ml-3 text-slate-600 underline">Cancelar</a>
  {# cierra el formulario #}
  </form>
{# endblock: fin del contenido #}
{% endblock %}

Así debe verse: formulario de factura con 1 renglón obligatorio y 3 opcionales; cada lista solo trae tus servicios

07

factura_detalle.html (nuevo)

core/templates/core/factura_detalle.html — archivo NUE
{# extends: usa el molde base.html #}
{% extends 'core/base.html' %}

{# block titulo: el folio en la pestaña #}
{% block titulo %}Factura {{ factura.folio }}{% endblock %}

{# block contenido: lo que va dentro del body #}
{% block contenido %}
  {# encabezado: folio a la izquierda y datos a la derecha; flex-wrap en celular #}
  <div class="flex flex-wrap justify-between items-start gap-4 mb-6">
    {# div: folio y cliente #}
    <div>
      {# p: etiqueta pequeña #}
      <p class="text-sm text-slate-500">Factura</p>
      {# h1: folio en letra monoespaciada #}
      <h1 class="text-2xl font-bold font-mono">{{ factura.folio }}</h1>
      {# p: cliente #}
      <p class="mt-1">Cliente: <b>{{ factura.cliente }}</b></p>
    {# cierra folio y cliente #}
    </div>
    {# div: fecha y emisor alineados a la derecha #}
    <div class="text-right text-sm">
      {# p: fecha con formato día/mes/año #}
      <p>Fecha: <b>{{ factura.fecha|date:"d/m/Y" }}</b></p>
      {# p: el usuario que la emitió #}
      <p>Emitida por: <b>{{ factura.usuario.username }}</b></p>
    {# cierra fecha y emisor #}
    </div>
  {# cierra el encabezado #}
  </div>
  {# overflow-x-auto: la tabla se desliza en celular #}
  <div class="overflow-x-auto">
    {# table: renglones de la factura #}
    <table class="w-full border border-slate-200 rounded-lg overflow-hidden">
      {# thead: encabezados #}
      <thead class="bg-slate-100">
        {# tr: fila de encabezados #}
        <tr>
          {# th: servicio #}
          <th class="text-left px-4 py-2">Servicio</th>
          {# th: categoría #}
          <th class="text-left px-4 py-2">Categoría</th>
          {# th: cantidad #}
          <th class="text-right px-4 py-2">Cantidad</th>
          {# th: precio unitario #}
          <th class="text-right px-4 py-2">Precio unitario</th>
          {# th: subtotal #}
          <th class="text-right px-4 py-2">Subtotal</th>
        {# cierra la fila #}
        </tr>
      {# cierra thead #}
      </thead>
      {# tbody: los renglones #}
      <tbody>
        {# for: un renglón por detalle #}
        {% for detalle in detalles %}
        {# tr: fila del renglón #}
        <tr class="border-t border-slate-200">
          {# td: título del servicio #}
          <td class="px-4 py-2">{{ detalle.servicio.titulo }}</td>
          {# td: su categoría (ya viene por select_related) #}
          <td class="px-4 py-2">{{ detalle.servicio.categoria|default:"Sin categoría" }}</td>
          {# td: cantidad #}
          <td class="px-4 py-2 text-right">{{ detalle.cantidad }}</td>
          {# td: precio guardado al momento de la venta #}
          <td class="px-4 py-2 text-right">${{ detalle.precio_unitario }}</td>
          {# td: subtotal = la @property del modelo #}
          <td class="px-4 py-2 text-right">${{ detalle.subtotal|floatformat:2 }}</td>
        {# cierra la fila #}
        </tr>
        {# endfor: fin de los renglones #}
        {% endfor %}
      {# cierra tbody #}
      </tbody>
      {# tfoot: pie de la tabla con el total #}
      <tfoot>
        {# tr: fila del total con fondo gris #}
        <tr class="border-t-2 border-slate-300 bg-slate-50">
          {# td: colspan=4 ocupa las 4 primeras columnas #}
          <td colspan="4" class="px-4 py-3 text-right font-semibold">Total</td>
          {# td: total calculado por aggregate(Sum) en la vista #}
          <td class="px-4 py-3 text-right text-lg font-bold">${{ total|floatformat:2 }}</td>
        {# cierra la fila del total #}
        </tr>
      {# cierra tfoot #}
      </tfoot>
    {# cierra la tabla #}
    </table>
  {# cierra el contenedor #}
  </div>
  {# enlace de regreso #}
  <a href="{% url 'facturas' %}" class="inline-block mt-6 text-slate-600 underline">← Volver a facturas</a>
{# endblock: fin del contenido #}
{% endblock %}

Así debe verse: detalle de la factura recién guardada; el total lo suma MySQL con aggregate(Sum)

08

reportes.html (nuevo)

core/templates/core/reportes.html — archivo NUE
{# extends: usa el molde base.html #}
{% extends 'core/base.html' %}

{# block titulo: texto de la pestaña #}
{% block titulo %}Reportes SQL{% endblock %}

{# block contenido: lo que va dentro del body #}
{% block contenido %}
  {# h1: título #}
  <h1 class="text-2xl font-bold">Reportes SQL</h1>
  {# p: explicación breve #}
  <p class="text-slate-600 mt-1 mb-6">Cada reporte ejecuta una consulta SQL escrita a mano directamente en MySQL.</p>
  {# grid: tarjetas en 1, 2 o 3 columnas según el ancho #}
  <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
    {# for: una tarjeta por consulta de la lista CONSULTAS #}
    {% for consulta in consultas %}
    {# a: toda la tarjeta es un enlace a /reportes/<clave>/ #}
    <a href="{% url 'reporte' consulta.clave %}" class="block border border-slate-200 rounded-xl p-4 hover:border-blue-500 hover:shadow-md transition">
      {# span: número del reporte en un círculo azul #}
      <span class="inline-grid place-items-center w-8 h-8 rounded-full bg-blue-600 text-white font-bold">{{ consulta.numero }}</span>
      {# h2: título #}
      <h2 class="font-semibold mt-3">{{ consulta.titulo }}</h2>
      {# p: técnica SQL en letra monoespaciada #}
      <p class="font-mono text-xs text-blue-700 mt-1">{{ consulta.tecnica }}</p>
      {# p: descripción #}
      <p class="text-sm text-slate-600 mt-2">{{ consulta.descripcion }}</p>
    {# cierra la tarjeta #}
    </a>
    {# endfor: fin de las tarjetas #}
    {% endfor %}
  {# cierra la cuadrícula #}
  </div>
{# endblock: fin del contenido #}
{% endblock %}

Así debe verse: la lista de los 6 reportes; cada tarjeta abre su consulta

09

reporte.html (nuevo): la tabla que sirve para cualquier consulta

Esta plantilla no conoce los nombres de las columnas: los recibe de MySQL (cursor.description) y dibuja tantas columnas como lleguen. Por eso funciona igual para las 6 consultas y para cualquiera que agregues después.

core/templates/core/reporte.html — archivo NUE
{# extends: usa el molde base.html #}
{% extends 'core/base.html' %}

{# block titulo: el título del reporte en la pestaña #}
{% block titulo %}{{ consulta.titulo }}{% endblock %}

{# block contenido: lo que va dentro del body #}
{% block contenido %}
  {# enlace de regreso a la lista de reportes #}
  <a href="{% url 'reportes' %}" class="text-sm text-slate-600 underline">← Todos los reportes</a>
  {# encabezado: número, título y técnica #}
  <div class="flex items-start gap-3 mt-3">
    {# span: número del reporte #}
    <span class="inline-grid place-items-center w-9 h-9 rounded-full bg-blue-600 text-white font-bold shrink-0">{{ consulta.numero }}</span>
    {# div: título y técnica #}
    <div>
      {# h1: título #}
      <h1 class="text-2xl font-bold">{{ consulta.titulo }}</h1>
      {# p: técnica SQL #}
      <p class="font-mono text-sm text-blue-700">{{ consulta.tecnica }}</p>
    {# cierra título y técnica #}
    </div>
  {# cierra el encabezado #}
  </div>
  {# p: descripción del reporte #}
  <p class="text-slate-600 mt-3">{{ consulta.descripcion }}</p>

  {# barra de botones: resultados, plan EXPLAIN y descarga CSV #}
  <div class="flex flex-wrap gap-2 mt-5">
    {# botón Resultados; se pinta azul si NO se está viendo el plan #}
    <a href="?" class="px-3 py-1.5 rounded-lg text-sm font-semibold {% if not ver_plan %}bg-blue-600 text-white{% else %}border border-slate-300{% endif %}">Resultados</a>
    {# botón Plan; ?plan=1 hace que la vista anteponga EXPLAIN #}
    <a href="?plan=1" class="px-3 py-1.5 rounded-lg text-sm font-semibold {% if ver_plan %}bg-blue-600 text-white{% else %}border border-slate-300{% endif %}">Ver plan (EXPLAIN)</a>
    {# botón CSV; ?formato=csv descarga el resultado #}
    <a href="?formato=csv" class="px-3 py-1.5 rounded-lg text-sm font-semibold border border-slate-300">Descargar CSV</a>
  {# cierra la barra de botones #}
  </div>

  {# if error: MySQL rechazó la consulta #}
  {% if error %}
    {# caja roja con el mensaje exacto de MySQL #}
    <div class="mt-5 bg-red-100 text-red-800 px-4 py-3 rounded-lg"><b>MySQL respondió con un error:</b> {{ error }}</div>
  {# else: la consulta sí corrió #}
  {% else %}
    {# chips: filas, columnas y tiempo #}
    <div class="flex flex-wrap gap-2 mt-5 text-sm">
      {# length: cuántas filas regresó #}
      <span class="bg-slate-100 rounded-full px-3 py-1"><b>{{ filas|length }}</b> filas</span>
      {# cuántas columnas #}
      <span class="bg-slate-100 rounded-full px-3 py-1"><b>{{ columnas|length }}</b> columnas</span>
      {# milisegundos medidos en reportes.py #}
      <span class="bg-slate-100 rounded-full px-3 py-1"><b>{{ milisegundos }}</b> ms</span>
    {# cierra los chips #}
    </div>
    {# max-h-[70vh] + overflow-auto: la tabla se desplaza dentro de su caja si es muy larga o ancha #}
    <div class="mt-3 border border-slate-200 rounded-xl overflow-auto max-h-[70vh]">
      {# table: la tabla GENÉRICA; sirve para cualquier consulta porque las columnas vienen del resultado #}
      <table class="w-full text-sm">
        {# thead sticky: los encabezados se quedan fijos arriba al desplazar #}
        <thead class="bg-slate-100 sticky top-0">
          {# tr: fila de encabezados #}
          <tr>
            {# for: un th por cada nombre de columna que devolvió MySQL #}
            {% for columna in columnas %}
            {# th: nombre de la columna; uppercase lo pone en mayúsculas #}
            <th class="text-left px-4 py-2 font-semibold uppercase text-xs tracking-wide text-slate-600 whitespace-nowrap">{{ columna }}</th>
            {# endfor: fin de los encabezados #}
            {% endfor %}
          {# cierra la fila #}
          </tr>
        {# cierra thead #}
        </thead>
        {# tbody: los datos #}
        <tbody>
          {# for: una fila por cada fila del resultado #}
          {% for fila in filas %}
          {# tr: even:bg-slate-50 pinta de gris una fila sí y una no (efecto cebra) #}
          <tr class="border-t border-slate-100 even:bg-slate-50 hover:bg-blue-50">
            {# for: una celda por valor #}
            {% for celda in fila %}
            {# td: si es número se alinea a la derecha con números de ancho fijo (tabular-nums) #}
            <td class="px-4 py-2 {% if celda.numero %}text-right tabular-nums{% endif %}">{{ celda.texto }}</td>
            {# endfor: fin de las celdas #}
            {% endfor %}
          {# cierra la fila #}
          </tr>
          {# empty: la consulta no regresó filas #}
          {% empty %}
          {# aviso a lo ancho de todas las columnas #}
          <tr><td colspan="{{ columnas|length }}" class="text-center text-slate-500 py-8">La consulta no devolvió filas. Captura facturas para ver resultados.</td></tr>
          {# endfor: fin de las filas #}
          {% endfor %}
        {# cierra tbody #}
        </tbody>
      {# cierra la tabla #}
      </table>
    {# cierra el contenedor de la tabla #}
    </div>
  {# endif: fin del bloque error / resultados #}
  {% endif %}
  {# details: el SQL va al final y plegado; se abre con un clic sobre la barra oscura #}
  <details class="mt-5 rounded-xl overflow-hidden border border-slate-800">
    {# summary: la barra en la que se hace clic #}
    <summary class="cursor-pointer bg-slate-800 text-slate-200 text-xs font-mono px-4 py-2">▸ Ver el SQL ejecutado en MySQL</summary>
    {# pre: respeta saltos de línea y espacios; overflow-x-auto desliza las líneas largas #}
    <pre class="bg-slate-900 text-emerald-200 text-xs leading-relaxed p-4 overflow-x-auto"><code>{{ sql_mostrado }}</code></pre>
  {# cierra el bloque de SQL #}
  </details>
{# endblock: fin del contenido #}
{% endblock %}

Así debe verse: consulta 3: los dos servicios de $150.00 y los dos de $2500.00 salen porque empatan

Commit del paso 11 · commit 10 de 13

Al terminar el paso, frente a la cámara. En git status deben aparecer estos archivos (y nunca .env).

  • core/templates/core/base.html — modificado
  • core/templates/core/servicios.html — modificado
  • core/templates/core/categorias.html — nuevo
  • core/templates/core/categoria_form.html — nuevo
  • core/templates/core/facturas.html — nuevo
  • core/templates/core/factura_form.html — nuevo
  • core/templates/core/factura_detalle.html — nuevo
  • core/templates/core/reportes.html — nuevo
  • core/templates/core/reporte.html — nuevo
TERMINAL DE VS CODE — PowerShell · commit del paso 11
# Revisa qué archivos cambiaste en este paso: NO debe aparecer .env
git status
# Agrega todos los archivos de este paso
git add .
# Guarda el paso 11: empieza con "paso 11" y termina con tu nombre
git commit -m "paso 11: plantillas de categorias, facturas y reportes - prado-diaz-bernardo"
# Confirma que el commit quedó en tu historial
git log --oneline -3
12

Datos de prueba con un comando propio

⏱ 15 mincore/management/commands/cargar_demo.py

Para que los reportes muestren empates, categorías vacías y servicios sin vender necesitas datos pensados para eso. Un comando de Django los carga con una sola línea: python manage.py cargar_demo tu_usuario.

01

Crea las carpetas del comando

TERMINAL DE VS CODE — PowerShell · en la carpeta que tiene manage.py
# Crea las carpetas core\management\commands (-Force: no falla si ya existen)
New-Item -ItemType Directory -Force core\management\commands
# __init__.py vacío: convierte la carpeta management en un paquete de Python
New-Item -ItemType File core\management\__init__.py
# __init__.py vacío: lo mismo para commands
New-Item -ItemType File core\management\commands\__init__.py
02

Crea core/management/commands/cargar_demo.py

core/management/commands/cargar_demo.py — archivo NUE
# date, timedelta: para poner fechas de factura en días distintos
from datetime import date, timedelta
# BaseCommand: la clase base de los comandos de "python manage.py ..." · CommandError: error que se muestra en la terminal
from django.core.management.base import BaseCommand, CommandError
# User: para buscar al usuario dueño de los datos
from django.contrib.auth.models import User
# transaction: todo o nada
from django.db import transaction
# los modelos que se llenan
from core.models import Categoria, Servicio, Factura, DetalleFactura

# CATEGORIAS: nombre y descripción; "Marketing" se queda sin servicios a propósito (aparece con 0 en la consulta 2)
CATEGORIAS = [
    # categoría 1
    ('Diseño', 'Logos, tarjetas e identidad visual'),
    # categoría 2
    ('Tutorías', 'Clases y asesorías académicas'),
    # categoría 3
    ('Desarrollo web', 'Sitios y aplicaciones'),
    # categoría 4: sin servicios
    ('Marketing', 'Campañas en redes sociales'),
# cierra la lista
]

# SERVICIOS: (título, precio, categoría); hay 2 empatados en el mínimo (150) y 2 en el máximo (2500)
SERVICIOS = [
    # empate en el precio MÍNIMO
    ('Clase de álgebra', '150.00', 'Tutorías'),
    # empate en el precio MÍNIMO
    ('Clase de física', '150.00', 'Tutorías'),
    # precio intermedio
    ('Tarjetas de presentación', '300.00', 'Diseño'),
    # precio intermedio
    ('Logo profesional', '800.00', 'Diseño'),
    # precio intermedio; nunca se vende (aparece en la consulta 4)
    ('Landing page', '1200.00', 'Desarrollo web'),
    # empate en el precio MÁXIMO
    ('Asesoría de tesis', '2500.00', 'Tutorías'),
    # empate en el precio MÁXIMO
    ('Sitio web completo', '2500.00', 'Desarrollo web'),
# cierra la lista
]

# FACTURAS: (cliente, días atrás, [(servicio, cantidad), ...])
FACTURAS = [
    # factura 1
    ('Papelería Luna', 20, [('Logo profesional', 1), ('Tarjetas de presentación', 2)]),
    # factura 2
    ('Colegio Ámbar', 15, [('Clase de álgebra', 4), ('Clase de física', 4)]),
    # factura 3
    ('Papelería Luna', 10, [('Tarjetas de presentación', 3)]),
    # factura 4
    ('Taller Ruiz', 7, [('Sitio web completo', 1)]),
    # factura 5
    ('Colegio Ámbar', 3, [('Asesoría de tesis', 1), ('Clase de álgebra', 2)]),
    # factura 6
    ('Ana Torres', 1, [('Clase de física', 2)]),
# cierra la lista
]


# Command: el nombre del archivo (cargar_demo.py) es el nombre del comando
class Command(BaseCommand):
    # help: texto que sale con "python manage.py help cargar_demo"
    help = 'Carga categorías, servicios y facturas de prueba para un usuario'

    # add_arguments: declara que el comando recibe el nombre de usuario
    def add_arguments(self, parser):
        # usuario: argumento obligatorio, ej. "python manage.py cargar_demo maria"
        parser.add_argument('usuario')

    # handle: lo que hace el comando
    def handle(self, *args, **opciones):
        # busca al usuario por su nombre
        try:
            # get: trae al usuario o lanza DoesNotExist
            usuario = User.objects.get(username=opciones['usuario'])
        # si no existe...
        except User.DoesNotExist:
            # ...se detiene con un mensaje claro
            raise CommandError('Ese usuario no existe. Escribe uno que ya tenga cuenta.')
        # atomic: si algo falla, no queda nada a medias
        with transaction.atomic():
            # recorre las categorías
            for nombre, descripcion in CATEGORIAS:
                # get_or_create: no la duplica si ya existe
                Categoria.objects.get_or_create(nombre=nombre, defaults={'descripcion': descripcion})
            # servicios: diccionario título → Servicio, para armar las facturas
            servicios = {}
            # recorre los servicios
            for titulo, precio, categoria in SERVICIOS:
                # get_or_create: busca por dueño y título; si no existe lo crea con estos datos
                servicio, creado = Servicio.objects.get_or_create(
                    # propietario: el usuario indicado
                    propietario=usuario,
                    # titulo: el título del servicio
                    titulo=titulo,
                    # defaults: solo se usan al crear
                    defaults={'descripcion': 'Servicio de prueba', 'precio': precio,
                              # categoria: la categoría por nombre
                              'categoria': Categoria.objects.get(nombre=categoria)},
                # cierra get_or_create
                )
                # guarda el servicio en el diccionario
                servicios[titulo] = servicio
            # si ya se habían cargado las facturas de prueba, no se repiten
            if Factura.objects.filter(usuario=usuario, cliente='Papelería Luna').exists():
                # aviso en amarillo
                self.stdout.write(self.style.WARNING('Las facturas de prueba ya existían; no se duplicaron.'))
                # termina el comando
                return
            # recorre las facturas
            for cliente, dias, renglones in FACTURAS:
                # crea el encabezado con la fecha de hace "dias" días
                factura = Factura.objects.create(usuario=usuario, cliente=cliente, fecha=date.today() - timedelta(days=dias))
                # recorre los renglones
                for titulo, cantidad in renglones:
                    # servicio del renglón
                    servicio = servicios[titulo]
                    # crea el renglón copiando el precio actual
                    DetalleFactura.objects.create(factura=factura, servicio=servicio, cantidad=cantidad, precio_unitario=servicio.precio)
        # mensaje verde de éxito
        self.stdout.write(self.style.SUCCESS(f'Datos de prueba cargados para {usuario.username}.'))
03

Ejecútalo con tu usuario

TERMINAL DE VS CODE — PowerShell · con (venv) activo
# Cambia "tu_usuario" por el usuario con el que inicias sesión en tu app
python manage.py cargar_demo tu_usuario
# Si lo corres otra vez no duplica nada: avisa que las facturas ya existían
python manage.py cargar_demo tu_usuario
LO QUE DEBES VER
# primera vez
Datos de prueba cargados para tu_usuario.
# segunda vez
Las facturas de prueba ya existían; no se duplicaron.

Después captura una factura a mano desde /facturas/nueva/ con 2 renglones, para demostrar en el video que el formulario funciona.

Commit del paso 12 · commit 11 de 13

Al terminar el paso, frente a la cámara. En git status deben aparecer estos archivos (y nunca .env).

  • core/management/__init__.py — nuevo
  • core/management/commands/__init__.py — nuevo
  • core/management/commands/cargar_demo.py — nuevo
  • evidencias/12-cargar-demo.png — nuevo
TERMINAL DE VS CODE — PowerShell · commit del paso 12
# Revisa qué archivos cambiaste en este paso: NO debe aparecer .env
git status
# Agrega todos los archivos de este paso
git add .
# Guarda el paso 12: empieza con "paso 12" y termina con tu nombre
git commit -m "paso 12: comando cargar_demo con datos de prueba - prado-diaz-bernardo"
# Confirma que el commit quedó en tu historial
git log --oneline -3
13

Probar todo en tu computadora

⏱ 20 minpython manage.py runserver

Con runserver encendido recorre cada pantalla. Si solo tienes los datos de prueba del paso 12, los 6 reportes deben dar exactamente estos resultados (si ya tenías servicios propios, aparecerán también).

  1. /servicios/: columna Categoría; busca tutor y salen los de Tutorías.
  2. /categorias/: Marketing con 0 servicios; intenta eliminar Diseño → mensaje rojo "todavía tiene servicios".
  3. /servicios/: intenta eliminar Clase de álgebra → mensaje rojo "ya aparece en una factura".
  4. /facturas/nueva/: guarda sin renglones → error; guarda con 2 renglones → te lleva al detalle con su total.
  5. Crea una cuenta nueva con /registro/: en Facturas y en la lista de servicios de una factura nueva no debe ver nada tuyo.

1. Ingresos por categoría · 3 filas

categoriaunidades_vendidasingresos
Tutorías134,300.00
Desarrollo web12,500.00
Diseño62,300.00

2. Servicios por categoría · 4 filas

categoriaservicios
Tutorías3
Desarrollo web2
Diseño2
Marketing0

3. Servicio más barato y más caro (con empates) · 4 filas

posicionserviciocategoriaprecio
Más barato (MIN)Clase de físicaTutorías150.00
Más barato (MIN)Clase de álgebraTutorías150.00
Más caro (MAX)Asesoría de tesisTutorías2,500.00
Más caro (MAX)Sitio web completoDesarrollo web2,500.00

4. Servicios que nunca se han facturado · 1 filas

serviciocategoriaprecio
Landing pageDesarrollo web1,200.00

5. Clientes que gastan más que el promedio · 3 filas

clientefacturastotal_comprado
Colegio Ámbar24,000.00
Taller Ruiz12,500.00
Papelería Luna22,300.00

6. Ranking de servicios más vendidos por categoría · 6 filas

categorialugarserviciounidades
Desarrollo web1Sitio web completo1
Diseño1Tarjetas de presentación5
Diseño2Logo profesional1
Tutorías1Clase de física6
Tutorías1Clase de álgebra6
Tutorías3Asesoría de tesis1

Los renglones verdes son empates: en la consulta 3 dos servicios comparten el precio mínimo y dos el máximo; en la 6, Clase de álgebra y Clase de física comparten el lugar 1 y el siguiente lugar es el 3.

14

Optimización: leer el plan de ejecución con EXPLAIN

⏱ 15 minbotón "Ver plan (EXPLAIN)"

EXPLAIN antes de un SELECT no trae datos: MySQL te dice cómo va a buscarlos. En cada reporte, el botón Ver plan (EXPLAIN) lo hace por ti y lo dibuja con la misma tabla genérica.

Columna de EXPLAINQué significaQué quieres ver
tableLa tabla (o alias) que lee ese renglón del plan.s, f, d, c o <derived2> (una subconsulta).
typeCómo recorre la tabla.ref, eq_ref o range = usa un índice. ALL = lee toda la tabla.
keyEl índice que eligió MySQL.Por ejemplo servicio_prop_precio_idx o factura_usuario_fecha_idx.
rowsCuántas filas cree que va a revisar.Entre menos, mejor.
ExtraDetalles del plan.Using index es bueno; Using temporary y Using filesort son normales cuando hay GROUP BY u ORDER BY.

Así debe verse: ejemplo de plan EXPLAIN de la consulta 3 (se muestran 7 de sus 12 columnas; tus números de rows pueden ser otros)

EN EL VIDEO

Abre el plan de la consulta 3 y señala la columna key: explica que el índice (propietario, precio) del paso 05 deja a MySQL encontrar el mínimo y el máximo de tus servicios sin leer la tabla completa. Con pocos datos MySQL a veces prefiere leer todo (ALL) porque es igual de rápido; dilo si te pasa.

15

Repositorio u4p3-… y Web Service en Render

⏱ 25 minGitHub + Render

Igual que en cada práctica: repositorio nuevo, Web Service nuevo y las mismas 9 variables de entorno. Las migraciones de los pasos 02 a 05 se aplican solas en Render porque tu Build Command termina en python manage.py migrate.

01

El nombre: u4p3-apellidopaterno-apellidomaterno-nombres

prefijou4p3- apellido paternoprado- apellido maternodiaz- nombre(s)bernardo

Minúsculas, sin acentos, la ñ como n y guiones en lugar de espacios. El mismo nombre va en GitHub y en Render.

02

Revisa migraciones y sube el código

  1. En https://github.com confirma que es tu cuenta, clic en + → New repository, nombre u4p3-…, Public, sin README ni .gitignore, y copia la URL que termina en .git.
TERMINAL DE VS CODE — PowerShell · en la carpeta que tiene manage.py
# Debe decir "No changes detected": todas tus migraciones ya existen como archivo
python manage.py makemigrations
# Muestra a qué repo apunta hoy tu proyecto
git remote -v
# Cambia origin al repo nuevo de esta práctica (pega TU URL)
git remote set-url origin https://github.com/TU_USUARIO/u4p3-prado-diaz-bernardo.git
# Confirma el cambio
git remote -v
# Revisa lo que se va a subir: NO deben aparecer .env, venv/ ni staticfiles/
git status
# Tus commits "paso 02" a "paso 12" deben aparecer aquí, uno por paso
git log --oneline -15
# Agrega lo que falte, incluidas las capturas de la carpeta evidencias/
git add .
# Commit 12 de 13: el del paso 15, con tu nombre al final
git commit -m "paso 15: capturas y subida a GitHub para Render - prado-diaz-bernardo"
# Sube la rama main al repo nuevo
git push -u origin main
03

Crea el Web Service u4p3-…

  1. https://dashboard.render.com → + New → Web Service → tu repo u4p3-… → Connect.
  2. Name: el mismo del repo · Language: Python 3 · Branch: main · Instance Type: Free.
  3. Build Command: pip install -r requirements.txt && python manage.py collectstatic --noinput && python manage.py migrate
  4. Start Command: gunicorn miproyecto.wsgi (cambia miproyecto por la carpeta que tiene tu settings.py).
  5. Environment Variables: las mismas 9 de la Semana 8 (DB_NAME, DB_USER, DB_PASSWORD, DB_HOST, DB_PORT, SECRET_KEY, DEBUG=False), con ALLOWED_HOSTS y CSRF_TRUSTED_ORIGINS apuntando a u4p3-….onrender.com. Si dudas, revisa la tabla de la Semana 8, paso 02.4.
  6. Clic en Deploy Web Service.
LOG DE RENDER — las líneas que importan en esta práctica
# migrate: como tu compu ya aplicó las migraciones en la misma base de Aiven, aquí no hay nada pendiente
No migrations to apply.
# el build terminó bien
==> Build successful 🎉
# el sitio quedó publicado
==> Your service is live 🎉
04

Prueba en producción

Abre https://u4p3-….onrender.com/reportes/, inicia sesión y abre los 6 reportes, el plan EXPLAIN de la consulta 3 y la descarga CSV de la consulta 1. Crea una factura desde Render. Si algo falla, la tabla de errores de la Semana 8, paso 02.5 tiene la solución.

16

Entrega y lista de verificación

límite: martes 10 de noviembre, 6:00 am

Después de grabar: EVIDENCIAS.md con los minutos de tu video

Con el video ya terminado y unido en un solo archivo, ábrelo y anota en qué minuto aparece cada punto. El revisor abre directo esos minutos, así que deben ser exactos (formato hh:mm:ss). En VS Code: clic derecho en el espacio vacío del explorador (al nivel de manage.py) → New File → EVIDENCIAS.md, y pega esta plantilla.

EVIDENCIAS.md — archivo NUEVO, en la carpeta que tiene manage.py
<!-- Datos del alumno: cambia los de ejemplo por los tuyos -->
# Evidencias u4p3 — Prado Díaz Bernardo — DSM4-1

<!-- Enlace del video en Drive (con permiso "Cualquier persona con el enlace") -->
Video: https://drive.google.com/...
<!-- URL de tu Web Service -->
Render: https://u4p3-prado-diaz-bernardo.onrender.com

<!-- Una fila por punto: escribe el minuto donde se ve; la captura ya debe estar en evidencias/ -->
| #  | Punto                                              | Minuto   | Captura                          |
|----|----------------------------------------------------|----------|----------------------------------|
| 1  | Presentación con nombre, grupo y GitHub            | 00:00:00 | —                                |
| 2  | Paso 02: migrate de la tabla Categoria OK          |          | evidencias/02-migrate.png        |
| 3  | Paso 03: migración que copia categorías OK         |          | evidencias/03-copiar.png         |
| 4  | Paso 04: quitar texto y renombrar, sin cambios     |          | evidencias/04-renombrar.png      |
| 5  | Paso 05: SQL de los 2 índices (sqlmigrate)         |          | evidencias/05-indices.png        |
| 6  | Paso 06: /admin con Categorias, Facturas, Servicios|          | evidencias/06-admin.png          |
| 7  | Paso 08: explico las 6 consultas de reportes.py    |          | —                                |
| 8  | Paso 12: cargar_demo con tu usuario                |          | evidencias/12-cargar-demo.png    |
| 9  | Paso 13: aviso rojo al borrar categoría con servicios |       | evidencias/13-categoria-aviso.png|
| 10 | Paso 13: factura con 2 renglones y su total        |          | evidencias/13-factura.png        |
| 11 | Paso 13: reportes 3 y 6 con empates en verde       |          | evidencias/13-reporte3.png y 13-reporte6.png |
| 12 | Paso 13: cuenta nueva que no ve mis datos          |          | evidencias/13-cuenta-nueva.png   |
| 13 | Paso 14: EXPLAIN de la consulta 3, columna key     |          | evidencias/14-explain.png        |
| 14 | Paso 15: Render en vivo, /reportes/ y CSV          |          | evidencias/15-render.png         |
| 15 | Despedida con resumen                              |          | —                                |
TERMINAL DE VS CODE — PowerShell · ya fuera de la grabación
# Agrega EVIDENCIAS.md (y cualquier captura que falte)
git add .
# Commit 13 de 13, el último: los minutos del video, con tu nombre al final
git commit -m "evidencias: minutos del video - prado-diaz-bernardo"
# Sube el commit al repositorio u4p3-… (Render se vuelve a publicar solo, no pasa nada)
git push
OJO

Cada minuto se coteja contra tu video y cada captura contra tu historial de commits. Un minuto que no muestra lo que dice la fila, o una captura que no sale en el video, cuenta como punto no cumplido.

  1. Sube el video a Google Drive en una carpeta llamada u4p3-apellidopaternoapellidomaterno (ejemplo: u4p3-pradodiaz).
  2. Abre los permisos: clic derecho → Compartir → "Cualquier persona con el enlace puede ver". Pruébalo en una ventana de incógnito.
  3. Envía el correo a Tenor_Prado@yahoo.com.mx con el asunto Apellido Paterno Apellido Materno Nombres Grupo u4p3 (ejemplo: Prado Diaz Bernardo DSM4-1 u4p3). En el cuerpo: lo mismo del asunto, el enlace del repositorio u4p3-…, la URL de Render y el enlace de Drive.
0/0
17

Referencias oficiales

Todo lo de esta guía sale de la documentación oficial de Django y de MySQL.

TemaPara quéDónde
Django — Migraciones de datosCopiar el texto de categoría a la tabla nuevadocs.djangoproject.com — Data migrations
Django — Operaciones de migraciónRunPython, RemoveField y RenameFielddocs.djangoproject.com — Migration operations
Django — Índicesmodels.Index en class Metadocs.djangoproject.com — Model index reference
Django — Inline formsetsFactura con varios renglones en un formulariodocs.djangoproject.com — Inline formsets
Django — SQL directoconnection.cursor(), parámetros y cursor.descriptiondocs.djangoproject.com — Performing raw SQL queries
Django — Agregación y optimizaciónannotate, aggregate, select_relateddocs.djangoproject.com — Database access optimization
Django — Comandos propiospython manage.py cargar_demodocs.djangoproject.com — Custom management commands
MySQL — Funciones de agregaciónSUM, COUNT, MIN, MAX, AVG con GROUP BY y HAVINGdev.mysql.com — Aggregate Functions
MySQL — Funciones de ventanaRANK() OVER (PARTITION BY …)dev.mysql.com — Window Functions
MySQL — WITH (CTE)Tabla temporal dentro de una consultadev.mysql.com — WITH (Common Table Expressions)
MySQL — EXPLAINLeer el plan de ejecucióndev.mysql.com — EXPLAIN Output Format

← Inicio del curso · Reglas de video (recuperación) · Calendario final →