Módulo 0 · Puesta a punto

Lección 00b — Modelado inicial de TicketFlow

Del negocio a las invariantes y de ahí al esquema: constraints que impiden vender un asiento dos veces.

Publicada
En esta lección
  1. Ejercicio 1 — Implementación
  2. Ejercicio 2 — Demostración de la invariante I1
  3. Ejercicio 3 — Disponibilidad
  4. Ejercicio 4 — Máquina de estado
  5. Ejercicio 5 — Diseño de lo que falta (solución razonada)
  6. Ejercicio 6 — Reflexión de escala
  7. Resumen del profesor

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 sobre auth.User vs 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 unique debe mostrar los índices únicos parciales.
  • PROTECT en organizer, user, event (desde Reservation) y seat (desde ReservationItem). CASCADE solo donde el hijo no tiene valor sin el padre (Seat muere con su evento; ReservationItem muere 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

  1. La última línea lanza IntegrityError parecido a:
django.db.utils.IntegrityError: duplicate key value violates unique constraint "uniq_active_reservation_per_seat"
DETAIL: Key (seat_id)=(1) already exists.
  1. 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.
  2. Lo demostrado: la integridad del negocio la garantiza la base de datos, no la buena voluntad del código. Un if en 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):

python
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"]) de Seat hace que el filter(event_id=...) sea un index scan en vez de un full scan.
  • Con connection.queries deberías ver una sola SELECT con JOIN/subconsulta.

Ejercicio 4 — Máquina de estado

python
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
  1. Con expires_at en el pasado, confirm() lanza InvalidTransition("Reserva expirada..."). La invariante I2 vive en el modelo, no en la vista.
  2. cancel() doble: la segunda es no-op (devuelve False). 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)

  1. Fila VIP como producto: añadir TicketType (nombre, precio, sector/filas aplicables) y que ReservationItem referencie ticket_type ademá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).
  2. 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 <= 4 por 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.
  3. Entradas sin asiento: mezclar ambos en Seat con number=NULL contamina la identidad física del asiento y rompe las constraints parciales (dos "asientos sin número" serían indistinguibles a nivel de unique). Mejor: un TicketType con seated=True/False y 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

  1. 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.
  2. ReservationItem (crece con cada reserva activa histórica) y el índice compuesto de Seat. Con 2M de asientos y reservas antiguas acumuladas, los filtros por reservation__status escanean más de lo necesario; llegará la partición por estado/fecha (Lección 55).
  3. Lo barato-hoy, carísimo-mañana:
  4. Índices correctos ya (hechos: state+starts_at, event+sector+row+number).
  5. expires_at indexado con estado (hecho: status+expires_at) porque el job de expiración lo consulta cada minuto.
  6. Nunca borrar: estados y fechas en vez de DELETE (histórico auditable).
  7. 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).