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.
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.
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.
Reglas obligatorias del video
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.
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…"
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.
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.
Leer y hacer, línea por línea, sin brincarte ninguna
Es la regla central. Por cada paso de esta guía:
- Lees en voz alta el título y la explicación del paso.
- 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. - Guardas con Ctrl + S, corres el comando en la terminal de VS Code y muestras el resultado.
- No se vale pegar un archivo completo en silencio, ni narrar algo que ya estaba hecho antes de grabar.
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.
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."
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.
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.
- 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…".
- 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 pushllega hasta el paso 15. Así tu historial muestra que construiste la práctica paso por paso. - 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 amanage.py).
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.
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.
Los 13 commits y tu firma
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
- Empieza con
paso NN, con el número a dos dígitos (paso 02, nopaso 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 elu4p3-: 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 congit commit --amend -m "mensaje correcto".
Escribe tu nombre y la guía pone tu firma en todos los commits
prado-diaz-bernardoLos 13 mensajes exactos, en orden
- 1Final del paso 02
paso 02: tabla Categoria y llave foranea temporal - prado-diaz-bernardo - 2Final del paso 03
paso 03: migracion de datos que copia las categorias - prado-diaz-bernardo - 3Final del paso 04
paso 04: quitar categoria de texto y renombrar la llave foranea - prado-diaz-bernardo - 4Final del paso 05
paso 05: tablas Factura y DetalleFactura con indices - prado-diaz-bernardo - 5Final del paso 06
paso 06: registrar categorias y facturas en el admin - prado-diaz-bernardo - 6Final del paso 07
paso 07: formularios de categoria, factura y renglones - prado-diaz-bernardo - 7Final del paso 08
paso 08: reportes.py con las 6 consultas SQL - prado-diaz-bernardo - 8Final del paso 09
paso 09: vistas de categorias, facturas y reportes - prado-diaz-bernardo - 9Final del paso 10
paso 10: rutas de categorias, facturas y reportes - prado-diaz-bernardo - 10Final del paso 11
paso 11: plantillas de categorias, facturas y reportes - prado-diaz-bernardo - 11Final del paso 12
paso 12: comando cargar_demo con datos de prueba - prado-diaz-bernardo - 12Paso 15, antes del
git pushpaso 15: capturas y subida a GitHub para Render - prado-diaz-bernardo - 13Paso 16, después de grabar
evidencias: minutos del video - prado-diaz-bernardo
Las 5 tablas y cómo se relacionan
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.
| Relación | Se lee así | Qué pasa al borrar |
|---|---|---|
Servicio → User | Un usuario tiene muchos servicios (ya lo tienes desde la Semana 6). | CASCADE: si se borra el usuario, se borran sus servicios. |
Servicio → Categoria | Una categoría agrupa muchos servicios. | PROTECT: no se puede borrar una categoría con servicios. |
Factura → User | Un usuario emite muchas facturas. | CASCADE |
DetalleFactura → Factura | Una factura tiene muchos renglones. | CASCADE: si se borra la factura, se van sus renglones. |
DetalleFactura → Servicio | Un servicio aparece en muchos renglones de distintas facturas. | PROTECT: no se puede borrar un servicio ya facturado. |
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.
Tabla Categoria y una llave foránea temporal
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.
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.
# 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.
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.
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.
# 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
Genera y aplica la migración
# 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
# 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— modificadocore/migrations/0002_categoria_servicio_categoria_ref.py— nuevoevidencias/02-migrate.png— nuevo
# 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
Migración de datos: copiar cada categoría de texto a la tabla nueva
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.
Crea una migración vacía
# --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.
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í:
# 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
]
Aplícala
# 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— nuevoevidencias/03-copiar.png— nuevo
# 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
Quitar la columna de texto y renombrar la llave foránea
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.
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:
# 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
Escribe la migración a mano
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.
# 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:
# 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
]
Aplica y confirma que Django está de acuerdo
# 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— modificadocore/migrations/0004_renombrar_categoria.py— nuevoevidencias/04-renombrar.png— nuevo
# 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
Facturas, detalle de factura e índices
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.
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.
# 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}'
Migra y mira el SQL que Django genera
# 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
# 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— modificadocore/migrations/0005_detallefactura_factura_and_more.py— nuevoevidencias/05-indices.png— nuevo
# 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
Registrar las tablas nuevas en /admin
El panel /admin sirve para revisar que las categorías se copiaron bien y para ver cada factura con sus renglones.
Reemplaza core/admin.py completo
# 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— modificadoevidencias/06-admin.png— nuevo
# 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
Formularios: categoría, factura y renglones
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.
Reemplaza core/forms.py completo
# 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
)
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
# 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
reportes.py: las 6 consultas SQL escritas a mano
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.
| # | Reporte | Técnica SQL | Por qué es interesante |
|---|---|---|---|
| 1 | Ingresos por categoría | SUM() + GROUP BY | Une 4 tablas y suma dinero por grupo. |
| 2 | Servicios por categoría | COUNT() + GROUP BY + LEFT JOIN | Incluye categorías sin servicios (salen con 0); COUNT(s.id) vs COUNT(*). |
| 3 | Más barato y más caro, con empates | MIN() + MAX() en subconsulta | Si dos servicios cuestan lo mismo, salen los dos (no solo uno, como pasaría con LIMIT 1). |
| 4 | Servicios nunca facturados | LEFT JOIN + IS NULL | Encuentra lo que no existe en otra tabla (anti-join). |
| 5 | Clientes sobre el promedio | HAVING + subconsulta con AVG() | Filtra grupos después de sumar; WHERE no puede hacerlo. |
| 6 | Ranking por categoría | WITH + RANK() OVER (PARTITION BY) | Funciones de ventana de MySQL 8: lugares 1, 1, 3 cuando hay empate. |
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.
# 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}
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
# 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
Las vistas nuevas y los cambios a las de servicios
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.
Reemplaza core/views.py completo
# 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')
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
# 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
Las rutas nuevas
Reemplaza core/urls.py completo
# 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
# 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
Plantillas y cómo debe verse cada pantalla
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.
base.html: menú de secciones y mensajes de error en rojo
{# 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>
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".
{# 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 %}
| Servicio | Categoría | Precio | Acciones |
|---|---|---|---|
| Asesoría de tesis | Tutorías | $2500.00 | EditarEliminar |
| Clase de física | Tutorías | $150.00 | EditarEliminar |
| Clase de álgebra | Tutorías | $150.00 | EditarEliminar |
Así debe verse: listado de servicios buscando «tutor»; la búsqueda encuentra por el nombre de la categoría
categorias.html (nuevo)
{# 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 %}
| Categoría | Mis servicios | Acciones |
|---|---|---|
| Desarrollo web Sitios y aplicaciones | 2 | EditarEliminar |
| Diseño Logos, tarjetas e identidad visual | 2 | EditarEliminar |
| Marketing Campañas en redes sociales | 0 | EditarEliminar |
| Tutorías Clases y asesorías académicas | 3 | EditarEliminar |
Así debe verse: lista de categorías con el aviso rojo al intentar borrar una que tiene servicios
categoria_form.html (nuevo)
{# 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 %}
facturas.html (nuevo)
{# 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 %}
| Folio | Fecha | Cliente | Partidas | Total |
|---|---|---|---|---|
| F-00006 | 25/10/2026 | Ana Torres | 1 | $300.00 |
| F-00005 | 23/10/2026 | Colegio Ámbar | 2 | $2800.00 |
| F-00004 | 19/10/2026 | Taller Ruiz | 1 | $2500.00 |
| F-00003 | 16/10/2026 | Papelería Luna | 1 | $900.00 |
Así debe verse: lista de facturas; Partidas y Total los calcula MySQL con COUNT y SUM
factura_form.html (nuevo)
{# 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 %}
| # | Servicio | Cantidad |
|---|---|---|
| 1 | Clase de álgebra — $150.00 ▾ | 4 |
| 2 | Asesoría de tesis — $2500.00 ▾ | 1 |
| 3 | --------- ▾ | 1 |
| 4 | --------- ▾ | 1 |
Llena al menos un renglón. Los renglones sin servicio se ignoran. El precio se toma del servicio al guardar.
Guardar factura CancelarAsí debe verse: formulario de factura con 1 renglón obligatorio y 3 opcionales; cada lista solo trae tus servicios
factura_detalle.html (nuevo)
{# 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 %}
Emitida por: tu_usuario
| Servicio | Categoría | Cantidad | Precio unitario | Subtotal |
|---|---|---|---|---|
| Clase de álgebra | Tutorías | 4 | $150.00 | $600.00 |
| Asesoría de tesis | Tutorías | 1 | $2500.00 | $2500.00 |
| Total | $3100.00 | |||
Así debe verse: detalle de la factura recién guardada; el total lo suma MySQL con aggregate(Sum)
reportes.html (nuevo)
{# 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 %}
Cada reporte ejecuta una consulta SQL escrita a mano directamente en MySQL.
Así debe verse: la lista de los 6 reportes; cada tarjeta abre su consulta
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.
{# 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 %}
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.
Resultados Ver plan (EXPLAIN) Descargar CSV4 filas4 columnas3.2 ms
| POSICION | SERVICIO | CATEGORIA | PRECIO |
|---|---|---|---|
| Más barato (MIN) | Clase de física | Tutorías | 150.00 |
| Más barato (MIN) | Clase de álgebra | Tutorías | 150.00 |
| Más caro (MAX) | Asesoría de tesis | Tutorías | 2,500.00 |
| Más caro (MAX) | Sitio web completo | Desarrollo web | 2,500.00 |
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— modificadocore/templates/core/servicios.html— modificadocore/templates/core/categorias.html— nuevocore/templates/core/categoria_form.html— nuevocore/templates/core/facturas.html— nuevocore/templates/core/factura_form.html— nuevocore/templates/core/factura_detalle.html— nuevocore/templates/core/reportes.html— nuevocore/templates/core/reporte.html— nuevo
# 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
Datos de prueba con un comando propio
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.
Crea las carpetas del comando
# 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
Crea core/management/commands/cargar_demo.py
# 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}.'))
Ejecútalo con tu usuario
# 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
# 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— nuevocore/management/commands/__init__.py— nuevocore/management/commands/cargar_demo.py— nuevoevidencias/12-cargar-demo.png— nuevo
# 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
Probar todo en tu computadora
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).
/servicios/: columna Categoría; buscatutory salen los de Tutorías./categorias/: Marketing con 0 servicios; intenta eliminar Diseño → mensaje rojo "todavía tiene servicios"./servicios/: intenta eliminar Clase de álgebra → mensaje rojo "ya aparece en una factura"./facturas/nueva/: guarda sin renglones → error; guarda con 2 renglones → te lleva al detalle con su total.- 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
| categoria | unidades_vendidas | ingresos |
|---|---|---|
| Tutorías | 13 | 4,300.00 |
| Desarrollo web | 1 | 2,500.00 |
| Diseño | 6 | 2,300.00 |
2. Servicios por categoría · 4 filas
| categoria | servicios |
|---|---|
| Tutorías | 3 |
| Desarrollo web | 2 |
| Diseño | 2 |
| Marketing | 0 |
3. Servicio más barato y más caro (con empates) · 4 filas
| posicion | servicio | categoria | precio |
|---|---|---|---|
| Más barato (MIN) | Clase de física | Tutorías | 150.00 |
| Más barato (MIN) | Clase de álgebra | Tutorías | 150.00 |
| Más caro (MAX) | Asesoría de tesis | Tutorías | 2,500.00 |
| Más caro (MAX) | Sitio web completo | Desarrollo web | 2,500.00 |
4. Servicios que nunca se han facturado · 1 filas
| servicio | categoria | precio |
|---|---|---|
| Landing page | Desarrollo web | 1,200.00 |
5. Clientes que gastan más que el promedio · 3 filas
| cliente | facturas | total_comprado |
|---|---|---|
| Colegio Ámbar | 2 | 4,000.00 |
| Taller Ruiz | 1 | 2,500.00 |
| Papelería Luna | 2 | 2,300.00 |
6. Ranking de servicios más vendidos por categoría · 6 filas
| categoria | lugar | servicio | unidades |
|---|---|---|---|
| Desarrollo web | 1 | Sitio web completo | 1 |
| Diseño | 1 | Tarjetas de presentación | 5 |
| Diseño | 2 | Logo profesional | 1 |
| Tutorías | 1 | Clase de física | 6 |
| Tutorías | 1 | Clase de álgebra | 6 |
| Tutorías | 3 | Asesoría de tesis | 1 |
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.
Optimización: leer el plan de ejecución con 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 EXPLAIN | Qué significa | Qué quieres ver |
|---|---|---|
table | La tabla (o alias) que lee ese renglón del plan. | s, f, d, c o <derived2> (una subconsulta). |
type | Cómo recorre la tabla. | ref, eq_ref o range = usa un índice. ALL = lee toda la tabla. |
key | El índice que eligió MySQL. | Por ejemplo servicio_prop_precio_idx o factura_usuario_fecha_idx. |
rows | Cuántas filas cree que va a revisar. | Entre menos, mejor. |
Extra | Detalles del plan. | Using index es bueno; Using temporary y Using filesort son normales cuando hay GROUP BY u ORDER BY. |
4 filas12 columnas
| ID | SELECT_TYPE | TABLE | TYPE | KEY | ROWS | EXTRA |
|---|---|---|---|---|---|---|
| 1 | PRIMARY | <derived2> | system | — | 1 | — |
| 1 | PRIMARY | s | ref | servicio_prop_precio_idx | 7 | Using where |
| 1 | PRIMARY | c | eq_ref | PRIMARY | 1 | — |
| 2 | DERIVED | core_servicio | ref | servicio_prop_precio_idx | 7 | Using index |
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)
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.
Repositorio u4p3-… y Web Service en 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.
El nombre: u4p3-apellidopaterno-apellidomaterno-nombres
Minúsculas, sin acentos, la ñ como n y guiones en lugar de espacios. El mismo nombre va en GitHub y en Render.
Revisa migraciones y sube el código
- En
https://github.comconfirma que es tu cuenta, clic en + → New repository, nombreu4p3-…, Public, sin README ni .gitignore, y copia la URL que termina en.git.
# 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
Crea el Web Service u4p3-…
https://dashboard.render.com→ + New → Web Service → tu repou4p3-…→ Connect.- Name: el mismo del repo · Language:
Python 3· Branch:main· Instance Type:Free. - Build Command:
pip install -r requirements.txt && python manage.py collectstatic --noinput && python manage.py migrate - Start Command:
gunicorn miproyecto.wsgi(cambiamiproyectopor la carpeta que tiene tusettings.py). - Environment Variables: las mismas 9 de la Semana 8 (
DB_NAME,DB_USER,DB_PASSWORD,DB_HOST,DB_PORT,SECRET_KEY,DEBUG=False), conALLOWED_HOSTSyCSRF_TRUSTED_ORIGINSapuntando au4p3-….onrender.com. Si dudas, revisa la tabla de la Semana 8, paso 02.4. - Clic en Deploy Web Service.
# 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 🎉
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.
Entrega y lista de verificación
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.
<!-- 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 | | — |
# 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
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.
- Sube el video a Google Drive en una carpeta llamada
u4p3-apellidopaternoapellidomaterno(ejemplo:u4p3-pradodiaz). - Abre los permisos: clic derecho → Compartir → "Cualquier persona con el enlace puede ver". Pruébalo en una ventana de incógnito.
- 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 repositoriou4p3-…, la URL de Render y el enlace de Drive.
Referencias oficiales
Todo lo de esta guía sale de la documentación oficial de Django y de MySQL.
| Tema | Para qué | Dónde |
|---|---|---|
| Django — Migraciones de datos | Copiar el texto de categoría a la tabla nueva | docs.djangoproject.com — Data migrations |
| Django — Operaciones de migración | RunPython, RemoveField y RenameField | docs.djangoproject.com — Migration operations |
| Django — Índices | models.Index en class Meta | docs.djangoproject.com — Model index reference |
| Django — Inline formsets | Factura con varios renglones en un formulario | docs.djangoproject.com — Inline formsets |
| Django — SQL directo | connection.cursor(), parámetros y cursor.description | docs.djangoproject.com — Performing raw SQL queries |
| Django — Agregación y optimización | annotate, aggregate, select_related | docs.djangoproject.com — Database access optimization |
| Django — Comandos propios | python manage.py cargar_demo | docs.djangoproject.com — Custom management commands |
| MySQL — Funciones de agregación | SUM, COUNT, MIN, MAX, AVG con GROUP BY y HAVING | dev.mysql.com — Aggregate Functions |
| MySQL — Funciones de ventana | RANK() OVER (PARTITION BY …) | dev.mysql.com — Window Functions |
| MySQL — WITH (CTE) | Tabla temporal dentro de una consulta | dev.mysql.com — WITH (Common Table Expressions) |
| MySQL — EXPLAIN | Leer el plan de ejecución | dev.mysql.com — EXPLAIN Output Format |
← Inicio del curso · Reglas de video (recuperación) · Calendario final →