Café y Código

5. Bases de Datos, Modelos, Migraciones y Django ORM 📚

1. Caso de Estudio: Sistema de Biblioteca Digital

Para dominar las bases de datos en Django no basta con un modelo simple de 4 campos. A lo largo de esta lección desarrollaremos el modelo de datos de una Biblioteca Digital Universitaria, un sistema con alta riqueza relacional y reglas de negocio reales:

  • PerfilLector: Relación 1:1 con el usuario del sistema, carnet UUID, fotos de credencial y límites de préstamos.
  • Autor: Información bibliográfica, correos de contacto y fechas de nacimiento.
  • Categoría: Estructura jerárquica en árbol autorreferencial (ej: Tecnología > Programación > Python).
  • Tag (Etiqueta): Clasificación flexible muchos a muchos (N:M) con colores hexadecimales.
  • Libro: La entidad central, que incorpora prácticamente todos los tipos de datos que ofrece Django (UUID, Slugs, Decimales, Duraciones, Archivos, JSON de metadatos, Enums, restricciones CheckConstraint e índices).
  • SolicitudPrestamo: Modelo intermedio (Through Model) enriquecido que gestiona el ciclo de vida de los préstamos, fechas límite, estados y multas por mora.

2. Modelos con Todos los Tipos de Datos y Relaciones

A continuación se presenta la definición profesional completa en models.py:

biblioteca/models.py
PYTHON
1import uuid
2from datetime import timedelta
3from django.db import models
4from django.contrib.auth.models import User
5from django.core.exceptions import ValidationError
6from django.core.validators import MinValueValidator, MaxValueValidator
7from django.utils.text import slugify
8
9
10def validar_isbn(valor: str):
11 limpio = valor.replace("-", "").replace(" ", "").upper()
12 if len(limpio) not in (10, 13):
13 raise ValidationError("El ISBN debe contener exactamente 10 o 13 caracteres numéricos.")
14
15
16# ========================================================
17# 1. PERFIL DE LECTOR (OneToOneField con User)
18# ========================================================
19class PerfilLector(models.Model):
20 user = models.OneToOneField(
21 User,
22 on_delete=models.CASCADE,
23 related_name='perfil_lector',
24 verbose_name="Usuario"
25 )
26 codigo_socio = models.UUIDField(
27 default=uuid.uuid4,
28 unique=True,
29 editable=False,
30 help_text="Identificador único global del socio"
31 )
32 telefono = models.CharField(max_length=20, blank=True)
33 direccion = models.TextField(blank=True, verbose_name="Dirección de residencia")
34 fecha_nacimiento = models.DateField(null=True, blank=True)
35 foto_perfil = models.ImageField(upload_to='lectores/fotos/%Y/%m/', null=True, blank=True)
36 limite_prestamos_simultaneos = models.PositiveSmallIntegerField(
37 default=3,
38 validators=[MinValueValidator(1), MaxValueValidator(10)]
39 )
40 esta_sancionado = models.BooleanField(
41 default=False,
42 help_text="Impide nuevos préstamos si tiene deudas o entregas fuera de plazo"
43 )
44 created_at = models.DateTimeField(auto_now_add=True)
45
46 def __str__(self):
47 return f"{self.user.get_full_name() or self.user.username} (Carnet: {str(self.codigo_socio)[:8]})"
48
49
50# ========================================================
51# 2. AUTOR (One-to-Many con Libro)
52# ========================================================
53class Autor(models.Model):
54 nombre = models.CharField(max_length=150, db_index=True)
55 apellidos = models.CharField(max_length=150)
56 pseudonimo = models.CharField(max_length=100, blank=True, null=True)
57 biografia = models.TextField(blank=True)
58 email = models.EmailField(blank=True, null=True)
59 sitio_web = models.URLField(blank=True)
60 fecha_nacimiento = models.DateField(null=True, blank=True)
61 es_autor_destacado = models.BooleanField(default=False)
62
63 class Meta:
64 verbose_name_plural = "Autores"
65 ordering = ['apellidos', 'nombre']
66
67 @property
68 def nombre_completo(self):
69 return f"{self.nombre} {self.apellidos}"
70
71 def __str__(self):
72 return self.nombre_completo
73
74
75# ========================================================
76# 3. CATEGORÍA (Relación Autorreferencial / Árbol)
77# ========================================================
78class Categoria(models.Model):
79 nombre = models.CharField(max_length=100, unique=True)
80 slug = models.SlugField(max_length=120, unique=True)
81 descripcion = models.TextField(blank=True)
82 # Jerarquía: una categoría puede tener una categoría padre
83 parent = models.ForeignKey(
84 'self',
85 on_delete=models.CASCADE,
86 null=True,
87 blank=True,
88 related_name='subcategorias'
89 )
90
91 def save(self, *args, **kwargs):
92 if not self.slug:
93 self.slug = slugify(self.nombre)
94 super().save(*args, **kwargs)
95
96 def __str__(self):
97 if self.parent:
98 return f"{self.parent} > {self.nombre}"
99 return self.nombre
100
101
102# ========================================================
103# 4. TAG / ETIQUETA (ManyToMany con Libro)
104# ========================================================
105class Tag(models.Model):
106 nombre = models.CharField(max_length=50, unique=True)
107 slug = models.SlugField(max_length=60, unique=True)
108 color_hex = models.CharField(max_length=7, default="#3B82F6", help_text="Color HEX (ej: #10B981)")
109
110 def save(self, *args, **kwargs):
111 if not self.slug:
112 self.slug = slugify(self.nombre)
113 super().save(*args, **kwargs)
114
115 def __str__(self):
116 return f"#{self.nombre}"
117
118
119# ========================================================
120# 5. LIBRO (Tipos Exhaustivos, Constraints e Índices)
121# ========================================================
122class Libro(models.Model):
123 class Formato(models.TextChoices):
124 TAPA_DURA = 'TAPA_DURA', 'Tapa Dura'
125 TAPA_BLANDA = 'TAPA_BLANDA', 'Tapa Blanda'
126 BOLSILLO = 'BOLSILLO', 'Edición de Bolsillo'
127 DIGITAL = 'DIGITAL', 'E-Book / Digital'
128
129 class Idioma(models.TextChoices):
130 ES = 'es', 'Español'
131 EN = 'en', 'Inglés'
132 FR = 'fr', 'Francés'
133
134 # Identificadores
135 id = models.BigAutoField(primary_key=True)
136 uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
137 titulo = models.CharField(max_length=255, db_index=True)
138 subtitulo = models.CharField(max_length=255, blank=True)
139 slug = models.SlugField(max_length=280, unique=True)
140 isbn = models.CharField(max_length=20, unique=True, validators=[validar_isbn])
141
142 # Texto largo y números
143 sinopsis = models.TextField()
144 paginas = models.PositiveIntegerField(validators=[MinValueValidator(1)])
145 edicion = models.PositiveSmallIntegerField(default=1)
146 stock_total = models.PositiveIntegerField(default=1)
147 stock_disponible = models.PositiveIntegerField(default=1)
148 precio_reposicion = models.DecimalField(
149 max_digits=8,
150 decimal_places=2,
151 help_text="Costo en caso de extravío"
152 )
153
154 # Enums y opciones
155 formato = models.CharField(max_length=20, choices=Formato.choices, default=Formato.TAPA_BLANDA)
156 idioma = models.CharField(max_length=2, choices=Idioma.choices, default=Idioma.ES)
157
158 # Fechas y Duraciones
159 fecha_publicacion = models.DateField()
160 tiempo_estimado_lectura = models.DurationField(null=True, blank=True, help_text="Ej: 14 horas de lectura")
161 created_at = models.DateTimeField(auto_now_add=True)
162 updated_at = models.DateTimeField(auto_now=True)
163
164 # Archivos y Multimedia
165 portada = models.ImageField(upload_to='libros/portadas/', null=True, blank=True)
166 capitulo_muestra_pdf = models.FileField(upload_to='libros/muestras/', null=True, blank=True)
167
168 # JSONField para datos no estructurados
169 metadatos = models.JSONField(
170 default=dict,
171 blank=True,
172 help_text="Datos libres: {'dimensiones': '21x15 cm', 'editorial': 'O\'Reilly', 'peso_gr': 420}"
173 )
174
175 disponible_para_prestamo = models.BooleanField(default=True)
176
177 # Relaciones
178 autor = models.ForeignKey(Autor, on_delete=models.CASCADE, related_name='libros')
179 categoria = models.ForeignKey(Categoria, on_delete=models.PROTECT, related_name='libros')
180 tags = models.ManyToManyField(Tag, related_name='libros', blank=True)
181
182 class Meta:
183 ordering = ['-created_at']
184 indexes = [
185 models.Index(fields=['titulo', 'fecha_publicacion']),
186 models.Index(fields=['slug']),
187 ]
188 constraints = [
189 # El stock disponible nunca puede ser mayor al stock total
190 models.CheckConstraint(
191 check=models.Q(stock_disponible__lte=models.F('stock_total')),
192 name='chk_stock_disponible_valido'
193 ),
194 # El stock disponible no puede ser negativo
195 models.CheckConstraint(
196 check=models.Q(stock_disponible__gte=0),
197 name='chk_stock_no_negativo'
198 )
199 ]
200
201 def save(self, *args, **kwargs):
202 if not self.slug:
203 self.slug = slugify(f"{self.titulo}-{str(self.uuid)[:8]}")
204 super().save(*args, **kwargs)
205
206 def __str__(self):
207 return f"{self.titulo} - {self.autor.nombre_completo}"
208
209
210# ========================================================
211# 6. SOLICITUD DE PRÉSTAMO (Through Model / M:N Enriquecida)
212# ========================================================
213class SolicitudPrestamo(models.Model):
214 class Estado(models.TextChoices):
215 PENDIENTE = 'PENDIENTE', 'Pendiente de Aprobación'
216 APROBADA = 'APROBADA', 'En Préstamo (Activa)'
217 RECHAZADA = 'RECHAZADA', 'Rechazada'
218 DEVUELTA = 'DEVUELTA', 'Devuelta a Tiempo'
219 DEVUELTA_CON_MORA = 'DEVUELTA_CON_MORA', 'Devuelta Fuera de Plazo'
220 EXTRAVIADO = 'EXTRAVIADO', 'Libro Extraviado'
221
222 codigo_prestamo = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
223 usuario = models.ForeignKey(User, on_delete=models.CASCADE, related_name='solicitudes_prestamo')
224 libro = models.ForeignKey(Libro, on_delete=models.CASCADE, related_name='solicitudes_prestamo')
225 estado = models.CharField(max_length=25, choices=Estado.choices, default=Estado.PENDIENTE, db_index=True)
226
227 fecha_solicitud = models.DateTimeField(auto_now_add=True)
228 fecha_inicio = models.DateField(null=True, blank=True)
229 fecha_limite_devolucion = models.DateField(null=True, blank=True)
230 fecha_devolucion_real = models.DateField(null=True, blank=True)
231
232 multa_acumulada = models.DecimalField(max_digits=6, decimal_places=2, default=0.00)
233 notas_usuario = models.TextField(blank=True)
234 notas_bibliotecario = models.TextField(blank=True)
235
236 class Meta:
237 ordering = ['-fecha_solicitud']
238 # Evita que un mismo usuario solicite el mismo libro si ya tiene una solicitud pendiente
239 constraints = [
240 models.UniqueConstraint(
241 fields=['usuario', 'libro'],
242 condition=models.Q(estado='PENDIENTE'),
243 name='unique_solicitud_pendiente_usuario_libro'
244 )
245 ]
246
247 def __str__(self):
248 return f"Préstamo #{str(self.codigo_prestamo)[:8]} - {self.usuario.username} ({self.estado})"

3. Migraciones a Fondo: Ciclo de Vida y Ejercicios

Una migración en Django es la forma en que el framework propaga los cambios que realizas en tus modelos (agregar un campo, borrar un modelo, etc.) hacia el esquema de tu base de datos (PostgreSQL, SQLite, MySQL).

3.1 Los 4 Comandos Esenciales

  • python manage.py makemigrations: Inspecciona tus modelos y crea un nuevo archivo de migración con las operaciones detectadas.
  • python manage.py migrate: Aplica las migraciones pendientes sobre la base de datos física.
  • python manage.py showmigrations: Muestra una lista visual con [X] para migraciones ya aplicadas y [ ] para pendientes.
  • python manage.py sqlmigrate app 0001: Muestra el código SQL exacto que ejecutará Django antes de correrlo (ideal para auditoría en producción).

3.2 El Gran Reto en Producción: Agregar Campos NOT NULL

Si tu tabla Libro ya tiene 5.000 registros creados y de pronto agregas una clave foránea autor = ForeignKey(Autor, on_delete=CASCADE) sin valor por defecto, la base de datos rechazará la migración.

La estrategia profesional en 3 pasos:

  1. Fase 1: Agregar el campo como nullable: autor = ForeignKey(Autor, null=True, blank=True) y correr makemigrations + migrate.
  2. Fase 2: Ejecutar una Migración de Datos (Data Migration) con RunPython para calcular o asignar los autores existentes.
  3. Fase 3: Cambiar el campo a null=False y generar la última migración. Ahora que ninguna fila tiene NULL, la base de datos aplica la restricción sin errores.

3.3 Migración de Datos con RunPython

Django permite escribir código Python dentro de las migraciones para transformar datos de manera segura:

migracion_runpython.py
PYTHON
1# migrations/0003_migrar_autores_texto_a_fk.py
2from django.db import migrations
3
4def migrar_hacia_adelante(apps, schema_editor):
5 # REGLA CRÍTICA: Usar apps.get_model() en lugar de importar el modelo directamente
6 Libro = apps.get_model('biblioteca', 'Libro')
7 Autor = apps.get_model('biblioteca', 'Autor')
8
9 # Autor por defecto para libros huérfanos
10 autor_defecto, _ = Autor.objects.get_or_create(
11 nombre='Autor',
12 apellidos='Desconocido'
13 )
14
15 for libro in Libro.objects.filter(autor__isnull=True):
16 libro.autor = autor_defecto
17 libro.save(update_fields=['autor'])
18
19def revertir_migracion(apps, schema_editor):
20 # Lógica inversa si se hace rollback
21 pass
22
23class Migration(migrations.Migration):
24 dependencies = [
25 ('biblioteca', '0002_autor_libro_autor_nullable'),
26 ]
27
28 operations = [
29 migrations.RunPython(migrar_hacia_adelante, reverse_code=revertir_migracion),
30 ]

3.4 Reversión y Rollbacks

Si una migración causa problemas, puedes revertirla retrocediendo al identificador anterior:

Reversión de Migraciones (CMD / PowerShell)
CMD
1# 1. Revertir la app biblioteca hasta la migración 0002
2(env) PS C:\proyectos\biblioteca> python manage.py migrate biblioteca 0002
3
4# 2. Revertir absolutamente todas las migraciones de la app biblioteca
5(env) PS C:\proyectos\biblioteca> python manage.py migrate biblioteca zero

🎯 Ejercicios Prácticos de Migraciones

Ejercicio 1: Añadir ISBN único a una tabla con registros

Problema: Tienes 100 libros en la BD sin ISBN y necesitas agregar isbn = CharField(unique=True). Si agregas default="", todas las filas tendrán la misma cadena vacía, violando la unicidad.

Solución: Agrega el campo con null=True, unique=True. Luego crea una migración vacía con RunPython que asigne un código temporal único (ej: TEMP-0001) a cada libro existente. Finalmente, cambia a null=False, unique=True.

Ejercicio 2: Migrar campo de texto separado por comas a ManyToManyField

Problema: El modelo anterior tenía etiquetas_texto = "python, backend, web". Ahora tenemos el modelo Tag.

poblar_tags_m2m.py
PYTHON
1def poblar_tags(apps, schema_editor):
2 Libro = apps.get_model('biblioteca', 'Libro')
3 Tag = apps.get_model('biblioteca', 'Tag')
4 for libro in Libro.objects.all():
5 for tag_nombre in libro.etiquetas_texto.split(','):
6 nombre = tag_nombre.strip()
7 if nombre:
8 tag_obj, _ = Tag.objects.get_or_create(nombre=nombre)
9 libro.tags.add(tag_obj)

4. Seeder con Faker: Generación Masiva de Datos

Probar consultas avanzadas de ORM con solo 2 o 3 registros es imposible. La mejor práctica en Django es crear un Management Command que utilice la librería faker:

seed_biblioteca.py
PYTHON
1# biblioteca/management/commands/seed_biblioteca.py
2import random
3from datetime import timedelta
4from django.core.management.base import BaseCommand
5from django.contrib.auth.models import User
6from django.db import transaction
7from django.utils import timezone
8from faker import Faker
9
10from biblioteca.models import (
11 PerfilLector, Autor, Categoria, Tag, Libro, SolicitudPrestamo
12)
13
14class Command(BaseCommand):
15 help = "Puebla la biblioteca con datos realistas usando Faker"
16
17 @transaction.atomic
18 def handle(self, *args, **options):
19 fake = Faker(['es_ES'])
20 self.stdout.write("Generando datos de prueba...")
21
22 # 1. Categorías con jerarquía
23 padre_tec, _ = Categoria.objects.get_or_create(nombre="Tecnología")
24 cat_python, _ = Categoria.objects.get_or_create(nombre="Python", parent=padre_tec)
25 cat_db, _ = Categoria.objects.get_or_create(nombre="Bases de Datos", parent=padre_tec)
26
27 # 2. Tags
28 tags = [Tag.objects.get_or_create(nombre=n)[0] for n in ["Backend", "Principiantes", "Avanzado", "Destacado"]]
29
30 # 3. Autores
31 autores = []
32 for _ in range(8):
33 autores.append(Autor.objects.create(
34 nombre=fake.first_name(),
35 apellidos=fake.last_name(),
36 biografia=fake.paragraph(),
37 email=fake.unique.email()
38 ))
39
40 # 4. Socios y usuarios
41 for i in range(10):
42 user = User.objects.create_user(username=f"lector_{i}", password="password123")
43 PerfilLector.objects.create(
44 user=user,
45 telefono=fake.phone_number(),
46 limite_prestamos_simultaneos=3
47 )
48
49 # 5. Libros
50 for _ in range(40):
51 stock = random.randint(2, 8)
52 libro = Libro.objects.create(
53 titulo=fake.catch_phrase().title(),
54 isbn=fake.unique.isbn13(),
55 sinopsis=fake.text(max_nb_chars=300),
56 paginas=random.randint(120, 800),
57 stock_total=stock,
58 stock_disponible=random.randint(0, stock),
59 precio_reposicion=round(random.uniform(20.0, 75.0), 2),
60 fecha_publicacion=fake.date_between(start_date='-5y', end_date='today'),
61 autor=random.choice(autores),
62 categoria=random.choice([cat_python, cat_db]),
63 metadatos={"editorial": fake.company(), "edicion": random.randint(1, 3)}
64 )
65 libro.tags.set(random.sample(tags, k=random.randint(1, 2)))
66
67 self.stdout.write(self.style.SUCCESS("✔ 40 libros, autores y lectores creados con éxito!"))

Para ejecutar el seeder en la terminal con el entorno virtual activo:

Ejecución de Seeder en CMD con (env)
CMD
1(env) PS C:\proyectos\biblioteca> pip install faker
2(env) PS C:\proyectos\biblioteca> python manage.py seed_biblioteca

5. Django ORM: De Consultas Básicas a Experto

El ORM (Object-Relational Mapping) de Django traduce tus métodos de Python en sentencias SQL optimizadas. A continuación exploramos su potencial desde lo fundamental hasta técnicas de optimización empresarial.

5.1 Nivel 1: Consultas y Lookups Básicos

orm_nivel_1.py
PYTHON
1from biblioteca.models import Libro, Autor
2
3# 1. Obtener todos los registros (Lazy QuerySet: no va a la BD hasta que se itera)
4todos = Libro.objects.all()
5
6# 2. Obtener un único elemento (lanza DoesNotExist o MultipleObjectsReturned)
7libro = Libro.objects.get(id=1)
8primer_libro = Libro.objects.filter(disponible_para_prestamo=True).first()
9
10# 3. Comprobación de existencia y conteo ultra-rápidos en SQL
11hay_disponibles = Libro.objects.filter(stock_disponible__gt=0).exists() # Genera SELECT EXISTS(...)
12total_libros = Libro.objects.count() # Genera SELECT COUNT(*)
13
14# 4. Operadores de Filtro (Field Lookups):
15Libro.objects.filter(titulo__icontains="django") # ILIKE '%django%'
16Libro.objects.filter(paginas__gte=300) # WHERE paginas >= 300
17Libro.objects.filter(idioma__in=['es', 'en']) # WHERE idioma IN ('es', 'en')
18Libro.objects.filter(fecha_publicacion__range=["2022-01-01", "2024-12-31"]) # BETWEEN
19Autor.objects.filter(email__isnull=False) # WHERE email IS NOT NULL

5.2 Nivel 2: Consultas Relacionales y Proyecciones

orm_nivel_2.py
PYTHON
1# 1. Filtrar a través de relaciones mediante doble guion bajo (__)
2# Libros cuyo autor se apellida "García"
3libros_garcia = Libro.objects.filter(autor__apellidos__iexact="García")
4
5# Libros que tengan el Tag con nombre "Backend" (relación ManyToMany)
6libros_backend = Libro.objects.filter(tags__nombre="Backend").distinct()
7
8# Consulta inversa: Autores que tengan algún libro de más de 600 páginas
9autores_extensos = Autor.objects.filter(libros__paginas__gt=600).distinct()
10
11# 2. Proyecciones (Optimizar ancho de banda solicitando solo ciertas columnas)
12# Devuelve lista de diccionarios: [{'id': 1, 'titulo': 'Clean Code'}]
13Libro.objects.values('id', 'titulo', 'autor__nombre')[:5]
14
15# Devuelve una lista plana de valores: ['Clean Code', 'The Pragmatic Programmer', ...]
16titulos = Libro.objects.values_list('titulo', flat=True)[:10]
17
18# 3. Ordenación
19Libro.objects.order_by('-fecha_publicacion', 'titulo')

5.3 Nivel 3: El Problema N+1 y su Solución

El problema N+1 es el asesino de rendimiento número 1 en aplicaciones web. Ocurre cuando iteras sobre un listado y en cada iteración disparas una nueva consulta SQL para traer la relación:

problema_n_mas_1.py
PYTHON
1# ❌ CÓDIGO INEFICIENTE: 1 consulta para traer 100 libros + 100 consultas para cada autor = 101 consultas!
2for libro in Libro.objects.all()[:100]:
3 print(libro.titulo, libro.autor.nombre)

Django resuelve esto con dos métodos fundamentales:

  • select_related(*fields): Para relaciones 1:1 y ForeignKey (muchos a uno). Realiza un INNER JOIN o LEFT JOIN en una sola consulta SQL.
  • prefetch_related(*fields): Para relaciones ManyToMany y relaciones inversas. Realiza consultas separadas agrupadas con WHERE id IN (...) y las ensambla en memoria en Python.
select_prefetch_related.py
PYTHON
1from django.db.models import Prefetch
2from biblioteca.models import Libro, SolicitudPrestamo
3
4# ✔ CÓDIGO HIPER-OPTIMIZADO: Resuelve 100 libros, autores, categorías y tags en solo 2 consultas SQL:
5libros = Libro.objects.select_related(
6 'autor', 'categoria' # SQL JOIN directo
7).prefetch_related(
8 'tags' # Query separada con WHERE id IN (...)
9)
10
11for l in libros[:100]:
12 print(l.titulo, l.autor.nombre_completo, l.categoria.nombre, [t.nombre for t in l.tags.all()])
13
14# Prefetch avanzado con filtros: Traer libros precargando SOLO sus solicitudes aprobadas
15solicitudes_activas = SolicitudPrestamo.objects.filter(estado=SolicitudPrestamo.Estado.APROBADA)
16libros_con_prestamos = Libro.objects.prefetch_related(
17 Prefetch('solicitudes_prestamo', queryset=solicitudes_activas, to_attr='prestamos_activos')
18)

5.4 Nivel 4: Agregaciones y Anotaciones

  • aggregate(): Reduce todo el QuerySet a un único diccionario resumen (ej: promedio o total global).
  • annotate(): Añade una columna calculada a cada fila del QuerySet (equivalente al GROUP BY de SQL).
aggregate_annotate.py
PYTHON
1from django.db.models import Avg, Sum, Count, Max, Q
2from django.contrib.auth.models import User
3
4# 1. Agregación Global
5resumen = Libro.objects.aggregate(
6 total_ejemplares=Sum('stock_total'),
7 precio_promedio=Avg('precio_reposicion'),
8 libro_mas_costoso=Max('precio_reposicion')
9)
10# Retorna: {'total_ejemplares': 350, 'precio_promedio': 42.50, 'libro_mas_costoso': 89.90}
11
12# 2. Anotación por fila: ¿Cuántos libros ha escrito cada autor?
13autores_productivos = Autor.objects.annotate(
14 total_libros=Count('libros')
15).filter(total_libros__gte=2).order_by('-total_libros')
16
17for autor in autores_productivos:
18 print(f"{autor.nombre_completo}: {autor.total_libros} libros en catálogo")
19
20# 3. Anotación condicional: Usuarios con su total de préstamos y cuántos tuvieron mora
21lectores_reporte = User.objects.annotate(
22 total_pedidos=Count('solicitudes_prestamo'),
23 pedidos_con_mora=Count(
24 'solicitudes_prestamo',
25 filter=Q(solicitudes_prestamo__estado='DEVUELTA_CON_MORA')
26 )
27)

5.5 Nivel 5: Consultas Avanzadas (Q, F, Case/When, Subquery)

1. Objetos Q (Lógica Booleana Compleja)

Permiten utilizar operadores OR (|), AND (&) y NOT (~):

q_objects.py
PYTHON
1from django.db.models import Q
2
3# Libros de la categoría 'Python' O que tengan el tag 'Backend', pero que NO estén agotados:
4disponibles = Libro.objects.filter(
5 (Q(categoria__nombre="Python") | Q(tags__nombre="Backend")) &
6 ~Q(stock_disponible=0)
7).distinct()

2. Objetos F (Comparar columnas y actualizar sin Race Conditions)

f_objects.py
PYTHON
1from django.db.models import F
2
3# 1. Comparar columnas de la misma fila: Libros donde el stock disponible sea menor al 30% del total
4libros_escasos = Libro.objects.filter(
5 stock_disponible__lt=F('stock_total') * 0.30
6)
7
8# 2. Actualización atómica directa en la base de datos (Thread-safe):
9# Descontar 1 del stock disponible sin leer primero el valor en Python
10Libro.objects.filter(id=5).update(stock_disponible=F('stock_disponible') - 1)

3. Expresiones Condicionales con Case y When (IF-THEN-ELSE en SQL)

case_when.py
PYTHON
1from django.db.models import Case, When, Value, CharField
2
3# Clasificar la urgencia de reabastecimiento en una sola consulta
4libros_prioridad = Libro.objects.annotate(
5 urgencia=Case(
6 When(stock_disponible=0, then=Value('URGENTE: Agotado')),
7 When(stock_disponible__lte=2, then=Value('ALTA: Pocas copias')),
8 default=Value('NORMAL: Disponible'),
9 output_field=CharField()
10 )
11).values('titulo', 'stock_disponible', 'urgencia')

4. Subconsultas Correlacionadas (Subquery y OuterRef)

Permiten traer campos calculados de otra tabla sin hacer múltiples consultas:

subquery_outerref.py
PYTHON
1from django.db.models import Subquery, OuterRef
2
3# Para cada Autor, traer el título de su libro publicado más recientemente
4libro_reciente = Libro.objects.filter(
5 autor=OuterRef('pk')
6).order_by('-fecha_publicacion')
7
8autores = Autor.objects.annotate(
9 ultimo_libro=Subquery(libro_reciente.values('titulo')[:1]),
10 fecha_ultimo_libro=Subquery(libro_reciente.values('fecha_publicacion')[:1])
11)
12
13for a in autores:
14 print(f"{a.nombre_completo} -> {a.ultimo_libro} ({a.fecha_ultimo_libro})")

💼 Desafíos Prácticos de ORM

Desafío 1: Top 5 Usuarios más Deudores

Consulta: Obtener los 5 usuarios que acumulen la mayor cantidad total de multas impagas en la biblioteca.

top_deudores.py
PYTHON
1from django.db.models import Sum
2
3top_deudores = User.objects.annotate(
4 total_deuda=Sum('solicitudes_prestamo__multa_acumulada')
5).filter(total_deuda__gt=0).order_by('-total_deuda')[:5]

Desafío 2: Autores Inactivos / Sin Publicaciones

Consulta: Encontrar autores que no tengan ningún libro registrado.

autores_sin_libros.py
PYTHON
1# Libros con relación nula en el join inverso:
2autores_sin_libros = Autor.objects.filter(libros__isnull=True)

⚡ Laboratorio Interactivo: Inspector de Django ORM y SQL Nativo

El ORM de Django es perezoso (lazy): solo ejecuta una consulta SQL cuando los datos son efectivamente consumidos (al iterar, indexar o evaluar). Usa este laboratorio para comparar la consulta en Python con la instrucción SQL nativa generada para ambos proyectos.

🗄️

Django ORM & SQL Inspector

Observa cómo Django traduce Python a SQL nativo optimizado

🐍 Consulta Python (Django ORM):QuerySet Lazy Evaluation
Libro.objects.filter(disponible_para_prestamo=True).values('titulo', 'precio_reposicion')
SQL Nativo Generado (PostgreSQL / SQLite):str(query.query)
SELECT "libros_libro"."titulo", "libros_libro"."precio_reposicion"
FROM "libros_libro"
WHERE "libros_libro"."disponible_para_prestamo" = TRUE
ORDER BY "libros_libro"."fecha_publicacion" DESC;
📊 Resultados Retornados (Tuplas de Base de Datos):3 filas
tituloprecio_reposiciondisp
Clean Code$42.00✔ Sí
Python Fluente 2da Ed.$65.00✔ Sí
Django 5 Profesional$55.00✔ Sí
💡 Clave Didáctica: Genera una cláusula WHERE directa indexada sobre el campo booleano.

5. Ejercicios con Segundo Proyecto: Modelos y ORM de E-Commerce (`tienda`) 🛒

Aplica los conceptos de modelado, restricciones a nivel de base de datos y consultas agregadas sobre el segundo proyecto: Tienda E-Commerce (C:\proyectos\django\tienda).

Reto 5.1: Modelo Producto con Restricciones CheckConstraint y Slugify

Modela en catalogo/models.py la clase Producto con clave foránea a CategoriaProducto (protegiendo el borrado con PROTECT), campos precio y costo, y dos restricciones de integridad en Meta.constraints: 1. El precio debe ser mayor o igual al costo (precio__gte=F('costo')). 2. El stock no puede ser negativo (stock__gte=0).

Ver Solución Explicada
solucion_producto_model.py
PYTHON
1# catalogo/models.py
2from django.db import models
3from django.utils.text import slugify
4from django.core.validators import MinValueValidator
5
6class Producto(models.Model):
7 sku = models.CharField(max_length=30, unique=True)
8 nombre = models.CharField(max_length=200, db_index=True)
9 slug = models.SlugField(max_length=220, unique=True, blank=True)
10 categoria = models.ForeignKey(
11 'CategoriaProducto',
12 on_delete=models.PROTECT,
13 related_name='productos'
14 )
15 precio = models.DecimalField(max_digits=10, decimal_places=2, validators=[MinValueValidator(0.01)])
16 costo = models.DecimalField(max_digits=10, decimal_places=2, validators=[MinValueValidator(0.00)])
17 stock = models.PositiveIntegerField(default=0)
18 disponible = models.BooleanField(default=True)
19
20 class Meta:
21 constraints = [
22 models.CheckConstraint(
23 check=models.Q(precio__gte=models.F('costo')),
24 name='chk_precio_gte_costo'
25 ),
26 models.CheckConstraint(
27 check=models.Q(stock__gte=0),
28 name='chk_stock_no_negativo'
29 ),
30 ]
31
32 def save(self, *args, **kwargs):
33 if not self.slug:
34 self.slug = slugify(self.nombre)
35 if self.stock == 0:
36 self.disponible = False
37 super().save(*args, **kwargs)

Reto 5.2: Migración de Datos (RunPython) para Sembrar Categorías

Crea una migración de datos vacía (python manage.py makemigrations catalogo --empty --name sembrar_categorias) que inserte de forma idempotente las categorías esenciales: 'Laptops', 'Smartphones', 'Monitores' y 'Periféricos'.

Ver Solución Explicada
0002_sembrar_categorias.py
PYTHON
1# catalogo/migrations/0002_sembrar_categorias.py
2from django.db import migrations
3from django.utils.text import slugify
4
5CATEGORIAS = ['Laptops', 'Smartphones', 'Monitores', 'Periféricos']
6
7def crear_categorias(apps, schema_editor):
8 CategoriaProducto = apps.get_model('catalogo', 'CategoriaProducto')
9 for nombre in CATEGORIAS:
10 CategoriaProducto.objects.get_or_create(
11 nombre=nombre,
12 defaults={'slug': slugify(nombre), 'activa': True}
13 )
14
15def revertir_categorias(apps, schema_editor):
16 CategoriaProducto = apps.get_model('catalogo', 'CategoriaProducto')
17 CategoriaProducto.objects.filter(nombre__in=CATEGORIAS).delete()
18
19class Migration(migrations.Migration):
20 dependencies = [
21 ('catalogo', '0001_initial'),
22 ]
23
24 operations = [
25 migrations.RunPython(crear_categorias, reverse_code=revertir_categorias),
26 ]

Reto 5.3: Consultas de Negocio Avanzadas con el ORM de la Tienda

Escribe las consultas optimizadas en Python para responder a estas 3 necesidades de la dirección comercial:

  • A. Ticket Promedio: Calcular el promedio de compra de todos los pedidos en estado 'PAGADO'.
  • B. Clientes VIP: Clientes cuyos pedidos acumulados superen los $1,000 USD.
  • C. Margen Unitario: Anotar cada producto con su margen de ganancia (precio - costo) usando F().
Ver Solución Explicada
consultas_negocio_tienda.py
PYTHON
1from django.db.models import Avg, Sum, F
2from pedidos.models import Pedido, Cliente
3from catalogo.models import Producto
4
5# A. Ticket Promedio
6ticket_promedio = Pedido.objects.filter(estado='PAGADO').aggregate(promedio=Avg('total'))['promedio'] or 0.0
7
8# B. Clientes VIP con más de $1,000 acumulados
9clientes_vip = Cliente.objects.annotate(
10 gasto_total=Sum('pedidos__total')
11).filter(gasto_total__gt=1000).select_related('user').order_by('-gasto_total')
12
13# C. Margen Unitario calculado por el motor de base de datos
14productos_con_margen = Producto.objects.annotate(
15 margen_ganancia=F('precio') - F('costo')
16).filter(disponible=True).order_by('-margen_ganancia')

Ponte a prueba

Comprueba que entiendes el modelado de datos, las relaciones y las optimizaciones del ORM de Django.

Ko-fi
Donaciones
Apoyá cafeycodigo con un café en Ko-fi. Colaboradores: insignia, muro y zona exclusiva.