No leas esto sin haber intentado los ejercicios. La corrección es donde se fija el aprendizaje.
Ejercicio 1 — Implementación
Puntos de control:
AUTH_USER_MODEL = "events.User"declarado antes de la primera migración. Si lo hiciste después y ya había migraciones, habrás visto el aviso de Django sobreauth.Uservs el nuevo modelo — de ahí la advertencia.- Las constraints se crean en la migración. Verifícalas:
python manage.py sqlmigrate events 0001 | grep -i uniquedebe mostrar los índices únicos parciales. PROTECTenorganizer,user,event(desde Reservation) yseat(desde ReservationItem). CASCADE solo donde el hijo no tiene valor sin el padre (Seatmuere con su evento;ReservationItemmuere con su reserva).
Errores típicos: olvidar settings.AUTH_USER_MODEL y hardcodear "events.User" en el ForeignKey (funciona, pero es menos flexible), o poner on_delete=CASCADE en Payment→Reservation (¡borrar una reserva no debe borrar pagos! — auditoría).
Ejercicio 2 — Demostración de la invariante I1
- La última línea lanza
IntegrityErrorparecido a:
django.db.utils.IntegrityError: duplicate key value violates unique constraint "uniq_active_reservation_per_seat"
DETAIL: Key (seat_id)=(1) already exists.- Sin las constraints, se crea la segunda reserva: dos usuarios "tienen" el mismo asiento. Al confirmar ambas, el negocio ha vendido dos veces la misma silla: devoluciones, datos rotos, resarcimientos y, en un caso real, sanciones.
- Lo demostrado: la integridad del negocio la garantiza la base de datos, no la buena voluntad del código. Un
ifen la vista entre "consulto si está libre" y "creo la reserva" tiene una ventana de milliseconds en la que otro request hace lo mismo (race condition). La constraint es atómica.
Nota fina: al capturar el IntegrityError en una vista real se respondería 409 Conflict, exactamente el código que diseñaste en la Lección 01, Ejercicio 2. La teoría de HTTP y el modelo de datos se abrazan.
Ejercicio 3 — Disponibilidad
Solución correcta típica (una sola query):
from django.db.models import Q
def available_seats(event_id):
return (
Seat.objects
.filter(event_id=event_id)
.filter(
Q(reservation_items__isnull=True)
| ~Q(reservation_items__reservation__status__in=[
ReservationState.PENDING_PAYMENT, ReservationState.CONFIRMED
])
)
.distinct()
)Lo importante:
- Debe ser 1 query. Si usaste un loop por asiento (
for seat in seats: seat.reservation_items...), has ejecutado N+1 queries: el clásico que mata APIs (Lección 09). - El índice
Index(fields=["event", "sector", "row", "number"])deSeathace que elfilter(event_id=...)sea un index scan en vez de un full scan. - Con
connection.queriesdeberías ver una sola SELECT con JOIN/subconsulta.
Ejercicio 4 — Máquina de estado
class Reservation(TimeStampedModel):
# ... campos ...
def confirm(self, when):
"""PENDING_PAYMENT -> CONFIRMED con pago correcto y sin expirar."""
if self.status != ReservationState.PENDING_PAYMENT:
raise InvalidTransition(f"Solo PENDING_PAYMENT puede confirmarse (estado actual: {self.status})")
if when >= self.expires_at:
raise InvalidTransition("Reserva expirada; no es confirmable")
has_payment = self.payments.filter(status=PaymentState.SUCCEEDED).exists()
if not has_payment:
raise InvalidTransition("Se requiere pago SUCCEEDED para confirmar")
self.status = ReservationState.CONFIRMED
self.save(update_fields=["status", "updated_at"])
def cancel(self, when):
"""PENDING_PAYMENT/CONFIRMED -> CANCELLED. Idempotente: cancelar dos veces no falla."""
if self.status == ReservationState.CANCELLED:
return False # no-op idempotente: la intención del llamante ya está satisfecha
if self.status not in (ReservationState.PENDING_PAYMENT, ReservationState.CONFIRMED):
raise InvalidTransition(f"Estado no cancelable: {self.status}")
self.status = ReservationState.CANCELLED
self.save(update_fields=["status", "updated_at"])
return True- Con
expires_aten el pasado,confirm()lanzaInvalidTransition("Reserva expirada..."). La invariante I2 vive en el modelo, no en la vista. cancel()doble: la segunda es no-op (devuelveFalse). Justificación: la idempotencia en operaciones de estado protege contra reintentos del cliente y mensajes duplicados de la cola. Si lanzaras excepción, un reintento legítimo fallaría. Esta decisión reaparece en la Lección 14 (idempotencia de APIs) y la 30 (consumidores de colas).
Ejercicio 5 — Diseño de lo que falta (solución razonada)
- Fila VIP como producto: añadir
TicketType(nombre, precio, sector/filas aplicables) y queReservationItemreferencieticket_typeademás del asiento. Es un cambio razonable y el precio deja de estar en el item para venir del tipo. Para v1 lo rechazo porque añade una entidad y consultas antes de validar el modelo base, pero es la primera extensión natural. Criterio: extiende cuando el caso de uso exista, no "por si acaso" (YAGNI). - Máximo 4 asientos por usuario/evento: es una regla de negocio (puede cambiar mañana), no una ley física del esquema. Va en el modelo/servicio (p. ej. validación al crear la reserva, en el mismo punto donde se hace la transacción). Una constraint de BD (
COUNT <= 4por usuario+evento activo) es defendible como defensa en profundidad pero rígida para cambios de negocio; un serializer valida solo entrada HTTP, no invocaciones desde otros servicios/cron. Regla: invariantes estructurales → BD; reglas de negocio → modelo/servicio. - Entradas sin asiento: mezclar ambos en
Seatconnumber=NULLcontamina la identidad física del asiento y rompe las constraints parciales (dos "asientos sin número" serían indistinguibles a nivel de unique). Mejor: unTicketTypeconseated=True/Falsey para eventos no sentados usar aforo (contador) en lugar de asientos, con una constraint de capacidad. Trade-off real: dos mecanismos de control de aforo que conviene unificar tras la Lección 10 (transacciones) — la discusión honesta vale más que la respuesta "cerrada".
Ejercicio 6 — Reflexión de escala
- La consulta de disponibilidad: todo el mundo la hace al abrir la página del evento. Se lee miles de veces por segundo en picos de venta.
ReservationItem(crece con cada reserva activa histórica) y el índice compuesto deSeat. Con 2M de asientos y reservas antiguas acumuladas, los filtros porreservation__statusescanean más de lo necesario; llegará la partición por estado/fecha (Lección 55).- Lo barato-hoy, carísimo-mañana:
- Índices correctos ya (hechos:
state+starts_at,event+sector+row+number). expires_atindexado con estado (hecho:status+expires_at) porque el job de expiración lo consulta cada minuto.- Nunca borrar: estados y fechas en vez de DELETE (histórico auditable).
- IDs enteros + UUID público separados (la API no expone IDs secuenciales; lo vemos en Lección 13).
Resumen del profesor
- El modelado senior es: negocio → invariantes → esquema. Nunca al revés.
- La constraint de BD no es un detalle técnico: es la garantía contractual de que el sistema no vende dos veces el mismo asiento.
- Las máquinas de estado viven en métodos del modelo, y las transiciones ilegales lanzan excepciones de dominio.
- Cada decisión del modelo tiene un trade-off nombrable. Si no sabes decir qué sacrificas, no has decidido: has copiado.
Cuando entregues los ejercicios, corregimos y seguimos con el temario (Lección 01 si no está hecha, luego Lección 02 — Redes).