Ticket signup: hold the card, then confirm by email

Python · Django · advanced · modification

Adds the ticket signup endpoint. After the body is parsed, one `transaction.atomic()` block locks the event row first, so the duplicate, closed and capacity checks, the card hold and the ticket insert are serialised per event. The card is only authorised here (the capture job charges it once the ticket exists), so a rollback after the hold never charges anyone. The confirmation email is registered with `transaction.on_commit(..., robust=True)`: it goes out only after the ticket commits, and an SMTP failure is logged instead of turning a committed signup into a 500. Gateway failures map to 402/502 with a generic message, and the log line carries the event id and the exception type, never the token. Tested locally with concurrent signups against Postgres.

Django 5.1 on PostgreSQL 16, `ATOMIC_REQUESTS = False`, `USE_TZ = True`, session auth; the frontend sends the CSRF token in `X-CSRFToken`. Existing models: `events.Event` with `title`, `starts_at` (aware datetime), `capacity` and `price_cents` (positive integers; every event is paid) and `currency` (ISO 4217 code); `tickets.Ticket` with `event` (FK, `related_name="tickets"`), `user`, `authorization_id`, `captured_at` (null until the capture job runs), `created_at` and a `UniqueConstraint` on (`event`, `user`). Tickets are never deleted or cancelled (support handles refunds and keeps the ticket), and this endpoint is the only code that creates them. `payments.gateway` is the existing client: `authorize(*, amount_cents, currency, payment_method)` returns a `Hold` with an `id`, raises `gateway.CardDeclined` (a subclass of `gateway.GatewayError`) when the card is declined and `GatewayError` for any other failure, including its built-in 5-second timeout, and never retries. Email goes through the provider's SMTP relay with `EMAIL_TIMEOUT = 10` and `DEFAULT_FROM_EMAIL` set. Events are local: `TIME_ZONE` is the venue city's zone, so local times need no zone label. Error reporting does not capture frame locals. Every account has a verified, non-empty `email`.

Requirements

Files touched

--- config/urls.py
     path("admin/", admin.site.urls),
     path("events/", include("events.urls")),
+    path("api/", include("tickets.urls")),
 ]
 
--- tickets/urls.py
+from django.urls import path
+
+from . import views
+
+app_name = "tickets"
+
+urlpatterns = [
+    path("events/<int:event_id>/signup/", views.signup, name="signup"),
+]
+
--- tickets/views.py
+import json
+import logging
+
+from django.db import transaction
+from django.http import JsonResponse
+from django.shortcuts import get_object_or_404
+from django.utils import timezone
+from django.views.decorators.http import require_POST
+
+from events.models import Event
+from payments import gateway
+

Review this PR

Python practice