Exercise 1 — Surface
POST /api/auth/register/ public 201
POST /api/auth/login/ public 200
GET /api/events/ public 200
POST /api/events/ organizer 201
GET /api/events/{uuid}/ public 200
PATCH /api/events/{uuid}/ organizer 200
GET /api/events/{uuid}/seats/ public 200
GET /api/events/{uuid}/availability/ public 200 (cached)
POST /api/reservations/ authenticated 201 / 409
GET /api/reservations/{uuid}/ owner 200
DELETE /api/reservations/{uuid}/ owner 204 (idempotent)
POST /api/reservations/{uuid}/payments/ owner 202 (queue) / 201
POST /api/payments/webhook/ system (signature) 200
POST /api/gift-cards/{code}/redeem/ authenticated 200 / 409Typical critique: POST /api/events/{id}/reserve/ (verb in path) becomes POST /api/reservations/ with {event, seats} in the body — the created resource is the reservation. An action sub-resource IS right for payments/ (it creates a payment WITHIN the reservation: real sub-resource creation, not a disguised verb).
Exercise 2 — Handler
from rest_framework.views import exception_handler
def problem_handler(exc, context):
response = exception_handler(exc, context)
if response is None:
if isinstance(exc, DomainError):
return Response(
{"type": f"https://docs.ticketflow.app/errors/{exc.code}",
"title": exc.title, "status": exc.status, "detail": exc.detail},
status=exc.status)
return None # 500 is the logger's business (26)
response.data = {"type": f"https://docs.ticketflow.app/errors/{response.status_code}",
"title": TITLES.get(response.status_code, "Error"),
"status": response.status_code,
"detail": response.data.get("detail", ""),
"errors": response.data.get("errors", [])}
return responseDomainError raised from reserve() produces exactly the designed body — Lesson 10's 409 now speaks the contract. 3. Mandatory minimum: type, title, status (detail optional: some errors only say "no" — the frontend maps by type).
Exercise 3 — Pagination
DEFAULT_PAGINATION_CLASSper view with apagination_classoverride: CursorPagination on the public listing (stable order bystarts_at), PageNumberPagination in admin.page=999on offset scans and discards 24,975 rows (OFFSET is O(n)); the cursor remains an index seek: O(log n) + 25. With 500 events you won't notice; with 500k it is the difference between 300ms and 3ms.- django-filter:
filterset_fieldsor an explicit FilterSet;OrderingFilterwithordering_fields = ["starts_at", "title"]— anything not listed does not sort.
Exercise 4 — UUID
- Migration: AddField UUID default uuid4 nullable → backfill (11: in batches) → unique. Or default uuid4 unique in one go (Django's data loader fills new rows; on existing rows the default applies on modern Postgres).
lookup_field = "uuid"on viewsets; serializers exposeuuid = UUIDField(read_only=True). The integer stops being addressable.- It would be enumerable a priori (UUID doesn't protect by itself): the layer that prevents it is authorization (owner or staff — Lesson 21) + token scopes. UUID = anti-enumeration hygiene, not access control.
Professor's summary
- Plural resources, short hierarchy, actions as creation sub-resources.
- One error format (RFC 7807) with a mappable
type: the frontend stops guessing. - Cursor for the growing public side; offset for the panel; whitelist on ordering.
- Public UUID as hygiene; the real barrier is authorization.
Next: Lesson 14 — Idempotency and versioning.