Don't read this without having tried the exercises. Feedback is where learning sticks.
Exercise 1 — Implementation
Checkpoints:
AUTH_USER_MODEL = "events.User"declared before the first migration. If you did it later with migrations already applied, you saw Django's warning aboutauth.Uservs the new model — hence the warning.- The constraints are created in the migration. Verify them:
python manage.py sqlmigrate events 0001 | grep -i uniqueshould show the partial unique indexes. PROTECTonorganizer,user,event(from Reservation) andseat(from ReservationItem). CASCADE only where the child is worthless without the parent (Seatdies with its event;ReservationItemdies with its reservation).
Typical mistakes: forgetting settings.AUTH_USER_MODEL and hardcoding "events.User" in the ForeignKey (works, but less flexible), or on_delete=CASCADE on Payment→Reservation (deleting a reservation must not delete payments! — audit).
Exercise 2 — Proving invariant I1
- The last line raises an
IntegrityErrorsimilar to:
django.db.utils.IntegrityError: duplicate key value violates unique constraint "uniq_active_reservation_per_seat"
DETAIL: Key (seat_id)=(1) already exists.- Without the constraints, the second reservation is created: two users "own" the same seat. Once both are confirmed, the business has sold the same chair twice: refunds, broken data, compensation and, in a real case, sanctions.
- What you demonstrated: business integrity is guaranteed by the database, not by the good will of the code. An
ifin the view between "check if it's free" and "create the reservation" has a millisecond window where another request does the same (race condition). The constraint is atomic.
Fine note: catching the IntegrityError in a real view would answer 409 Conflict — exactly the code you designed in Lesson 01, Exercise 2. HTTP theory and the data model embrace.
Exercise 3 — Availability
Typical correct solution (a single 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()
)What matters:
- It must be 1 query. If you used a per-seat loop (
for seat in seats: seat.reservation_items...), you ran N+1 queries: the classic API killer (Lesson 09). - The
Index(fields=["event", "sector", "row", "number"])onSeatmakesfilter(event_id=...)an index scan instead of a full scan. - With
connection.queriesyou should see a single SELECT with a JOIN/subquery.
Exercise 4 — State machine
class Reservation(TimeStampedModel):
# ... fields ...
def confirm(self, when):
"""PENDING_PAYMENT -> CONFIRMED with a successful payment and not expired."""
if self.status != ReservationState.PENDING_PAYMENT:
raise InvalidTransition(f"Only PENDING_PAYMENT can be confirmed (current state: {self.status})")
if when >= self.expires_at:
raise InvalidTransition("Reservation expired; it cannot be confirmed")
has_payment = self.payments.filter(status=PaymentState.SUCCEEDED).exists()
if not has_payment:
raise InvalidTransition("A SUCCEEDED payment is required to confirm")
self.status = ReservationState.CONFIRMED
self.save(update_fields=["status", "updated_at"])
def cancel(self, when):
"""PENDING_PAYMENT/CONFIRMED -> CANCELLED. Idempotent: cancelling twice doesn't fail."""
if self.status == ReservationState.CANCELLED:
return False # idempotent no-op: the caller's intent is already satisfied
if self.status not in (ReservationState.PENDING_PAYMENT, ReservationState.CONFIRMED):
raise InvalidTransition(f"State cannot be cancelled: {self.status}")
self.status = ReservationState.CANCELLED
self.save(update_fields=["status", "updated_at"])
return True- With
expires_atin the past,confirm()raisesInvalidTransition("Reservation expired..."). Invariant I2 lives in the model, not in the view. - Double
cancel(): the second one is a no-op (returnsFalse). Justification: idempotency in state operations protects against client retries and duplicated queue messages. If you raised an exception, a legitimate retry would fail. This decision returns in Lesson 14 (API idempotency) and Lesson 30 (queue consumers).
Exercise 5 — Designing what's missing (reasoned solution)
- VIP row as a product: add
TicketType(name, price, applicable sector/rows) and haveReservationItemreferenceticket_typebesides the seat. It's a reasonable change and the price stops being on the item, coming from the type instead. For v1 I reject it because it adds an entity and queries before validating the base model, but it's the first natural extension. Criterion: extend when the use case exists, not "just in case" (YAGNI). - Max 4 seats per user/event: it's a business rule (it may change tomorrow), not a physical law of the schema. It lives in the model/service (e.g. validation when creating the reservation, at the same point where the transaction happens). A DB constraint (
COUNT <= 4per active user+event) is defensible as defense in depth but rigid for business changes; a serializer only validates HTTP input, not invocations from other services/cron. Rule: structural invariants → DB; business rules → model/service. - Tickets without a seat: mixing both in
Seatwithnumber=NULLpollutes the seat's physical identity and breaks the partial constraints (two "seats without a number" would be indistinguishable at the unique level). Better: aTicketTypewithseated=True/False, and for unseated events use capacity (a counter) instead of seats, with a capacity constraint. Real trade-off: two capacity-control mechanisms that should be unified after Lesson 10 (transactions) — the honest discussion is worth more than a "closed" answer.
Exercise 6 — Scale reflection
- The availability query: everyone makes it when opening the event page. It's read thousands of times per second during sales peaks.
ReservationItem(it grows with every historical active reservation) andSeat's composite index. With 2M seats and accumulated old reservations, filters byreservation__statusscan more than needed; partitioning by state/date will come (Lesson 55).- Cheap-today, very-expensive-tomorrow:
- Right indexes now (done:
state+starts_at,event+sector+row+number). expires_atindexed with state (done:status+expires_at) because the expiry job queries it every minute.- Never delete: states and dates instead of DELETE (auditable history).
- Integer IDs + public UUID separated (the API doesn't expose sequential IDs; we'll see it in Lesson 13).
Professor's summary
- Senior modeling is: business → invariants → schema. Never the other way around.
- The DB constraint is not a technical detail: it's the contractual guarantee that the system won't sell the same seat twice.
- State machines live in model methods, and illegal transitions raise domain exceptions.
- Every modeling decision has a nameable trade-off. If you can't say what you're sacrificing, you haven't decided: you've copied.
When you submit the exercises, we'll grade them and continue with the syllabus (Lesson 01 if it's not done, then Lesson 02 — Networking).