generated from bisco/codex-bootstrap
Merge branch 'feature/booking-services' into develop
This commit is contained in:
188
backend/bookings/services.py
Normal file
188
backend/bookings/services.py
Normal file
@@ -0,0 +1,188 @@
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
|
||||
from django.db import transaction
|
||||
from django.db.models import Sum
|
||||
from django.utils import timezone
|
||||
|
||||
from shows.models import Performance
|
||||
|
||||
from .models import Reservation, ReservationToken
|
||||
|
||||
|
||||
CONFIRMATION_TOKEN_TTL = timedelta(hours=48)
|
||||
CHECK_IN_TOKEN_AFTER_PERFORMANCE_TTL = timedelta(days=1)
|
||||
|
||||
|
||||
class BookingServiceError(Exception):
|
||||
"""Base class for booking service domain errors."""
|
||||
|
||||
|
||||
class PerformanceNotAvailable(BookingServiceError):
|
||||
pass
|
||||
|
||||
|
||||
class NotEnoughSeats(BookingServiceError):
|
||||
pass
|
||||
|
||||
|
||||
class InvalidToken(BookingServiceError):
|
||||
pass
|
||||
|
||||
|
||||
class ExpiredToken(BookingServiceError):
|
||||
pass
|
||||
|
||||
|
||||
class AlreadyConfirmedReservation(BookingServiceError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PendingReservationResult:
|
||||
reservation: Reservation
|
||||
confirmation_token: ReservationToken
|
||||
raw_confirmation_token: str
|
||||
available_seats: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ConfirmedReservationResult:
|
||||
reservation: Reservation
|
||||
confirmation_token: ReservationToken
|
||||
check_in_token: ReservationToken
|
||||
raw_check_in_token: str
|
||||
available_seats: int
|
||||
|
||||
|
||||
def calculate_available_seats(performance):
|
||||
confirmed_seats = (
|
||||
Reservation.objects.filter(
|
||||
performance=performance,
|
||||
status=Reservation.Status.CONFIRMED,
|
||||
).aggregate(total=Sum("party_size"))["total"]
|
||||
or 0
|
||||
)
|
||||
return performance.configured_capacity - confirmed_seats
|
||||
|
||||
|
||||
def create_pending_reservation(
|
||||
*,
|
||||
performance_id,
|
||||
name,
|
||||
email,
|
||||
party_size,
|
||||
phone="",
|
||||
notes="",
|
||||
confirmation_expires_at=None,
|
||||
):
|
||||
with transaction.atomic():
|
||||
performance = _get_locked_bookable_performance(performance_id)
|
||||
available_seats = calculate_available_seats(performance)
|
||||
if party_size > available_seats:
|
||||
raise NotEnoughSeats("Not enough seats are available for this performance.")
|
||||
|
||||
reservation = Reservation(
|
||||
performance=performance,
|
||||
name=name,
|
||||
email=email,
|
||||
phone=phone,
|
||||
party_size=party_size,
|
||||
notes=notes,
|
||||
)
|
||||
reservation.full_clean()
|
||||
reservation.save()
|
||||
|
||||
confirmation_token, raw_confirmation_token = generate_confirmation_token(
|
||||
reservation,
|
||||
expires_at=confirmation_expires_at,
|
||||
)
|
||||
|
||||
return PendingReservationResult(
|
||||
reservation=reservation,
|
||||
confirmation_token=confirmation_token,
|
||||
raw_confirmation_token=raw_confirmation_token,
|
||||
available_seats=available_seats,
|
||||
)
|
||||
|
||||
|
||||
def generate_confirmation_token(reservation, *, expires_at=None):
|
||||
return ReservationToken.create_token(
|
||||
reservation=reservation,
|
||||
purpose=ReservationToken.Purpose.CONFIRMATION,
|
||||
expires_at=expires_at or timezone.now() + CONFIRMATION_TOKEN_TTL,
|
||||
)
|
||||
|
||||
|
||||
def confirm_reservation_from_token(raw_token):
|
||||
token_hash = ReservationToken.hash_token(raw_token)
|
||||
token_was_expired = False
|
||||
|
||||
with transaction.atomic():
|
||||
try:
|
||||
confirmation_token = (
|
||||
ReservationToken.objects.select_for_update()
|
||||
.select_related("reservation")
|
||||
.get(token_hash=token_hash, purpose=ReservationToken.Purpose.CONFIRMATION)
|
||||
)
|
||||
except ReservationToken.DoesNotExist as exc:
|
||||
raise InvalidToken("Confirmation token is invalid.") from exc
|
||||
|
||||
reservation = Reservation.objects.select_for_update().get(pk=confirmation_token.reservation_id)
|
||||
|
||||
if confirmation_token.is_used:
|
||||
if reservation.status == Reservation.Status.CONFIRMED:
|
||||
raise AlreadyConfirmedReservation("Reservation is already confirmed.")
|
||||
raise InvalidToken("Confirmation token has already been used.")
|
||||
|
||||
if confirmation_token.is_expired:
|
||||
if reservation.status == Reservation.Status.PENDING:
|
||||
reservation.status = Reservation.Status.EXPIRED
|
||||
reservation.save(update_fields=["status", "updated_at"])
|
||||
token_was_expired = True
|
||||
|
||||
if not token_was_expired and reservation.status == Reservation.Status.CONFIRMED:
|
||||
raise AlreadyConfirmedReservation("Reservation is already confirmed.")
|
||||
|
||||
if not token_was_expired and reservation.status != Reservation.Status.PENDING:
|
||||
raise InvalidToken("Confirmation token is not valid for this reservation.")
|
||||
|
||||
if not token_was_expired:
|
||||
performance = _get_locked_bookable_performance(reservation.performance_id)
|
||||
available_seats = calculate_available_seats(performance)
|
||||
if reservation.party_size > available_seats:
|
||||
raise NotEnoughSeats("Not enough seats are available for this performance.")
|
||||
|
||||
reservation.confirm()
|
||||
confirmation_token.mark_used()
|
||||
|
||||
check_in_token, raw_check_in_token = ReservationToken.create_token(
|
||||
reservation=reservation,
|
||||
purpose=ReservationToken.Purpose.CHECK_IN,
|
||||
expires_at=performance.starts_at + CHECK_IN_TOKEN_AFTER_PERFORMANCE_TTL,
|
||||
)
|
||||
reservation.qr_code_generated_at = timezone.now()
|
||||
reservation.save(update_fields=["qr_code_generated_at", "updated_at"])
|
||||
|
||||
if token_was_expired:
|
||||
raise ExpiredToken("Confirmation token has expired.")
|
||||
|
||||
return ConfirmedReservationResult(
|
||||
reservation=reservation,
|
||||
confirmation_token=confirmation_token,
|
||||
check_in_token=check_in_token,
|
||||
raw_check_in_token=raw_check_in_token,
|
||||
available_seats=available_seats - reservation.party_size,
|
||||
)
|
||||
|
||||
|
||||
def _get_locked_bookable_performance(performance_id):
|
||||
try:
|
||||
performance = Performance.objects.select_for_update().get(pk=performance_id)
|
||||
except Performance.DoesNotExist as exc:
|
||||
raise PerformanceNotAvailable("Performance is not available for booking.") from exc
|
||||
|
||||
if not performance.is_booking_enabled:
|
||||
raise PerformanceNotAvailable("Performance is not available for booking.")
|
||||
|
||||
return performance
|
||||
192
backend/bookings/test_services.py
Normal file
192
backend/bookings/test_services.py
Normal file
@@ -0,0 +1,192 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from django.db import connection
|
||||
from django.test import TestCase
|
||||
from django.test.utils import CaptureQueriesContext
|
||||
from django.utils import timezone
|
||||
|
||||
from bookings.models import Reservation, ReservationToken
|
||||
from bookings.services import (
|
||||
AlreadyConfirmedReservation,
|
||||
ExpiredToken,
|
||||
InvalidToken,
|
||||
NotEnoughSeats,
|
||||
calculate_available_seats,
|
||||
confirm_reservation_from_token,
|
||||
create_pending_reservation,
|
||||
generate_confirmation_token,
|
||||
)
|
||||
from shows.models import Performance, Show, Venue
|
||||
|
||||
|
||||
class BookingServiceTests(TestCase):
|
||||
def setUp(self):
|
||||
self.show = Show.objects.create(
|
||||
title="Open Stage",
|
||||
slug="open-stage-services",
|
||||
is_published=True,
|
||||
)
|
||||
self.venue = Venue.objects.create(
|
||||
name="AzioneLab Theatre",
|
||||
slug="azionelab-theatre-services",
|
||||
address="Via Example 1",
|
||||
city="Rome",
|
||||
)
|
||||
self.performance = Performance.objects.create(
|
||||
show=self.show,
|
||||
venue=self.venue,
|
||||
starts_at=timezone.now() + timedelta(days=7),
|
||||
room_capacity=3,
|
||||
)
|
||||
|
||||
def test_create_pending_reservation_generates_confirmation_token(self):
|
||||
result = create_pending_reservation(
|
||||
performance_id=self.performance.id,
|
||||
name="Maria Rossi",
|
||||
email="maria@example.com",
|
||||
party_size=2,
|
||||
)
|
||||
|
||||
self.assertEqual(result.reservation.status, Reservation.Status.PENDING)
|
||||
self.assertEqual(result.reservation.party_size, 2)
|
||||
self.assertEqual(result.confirmation_token.purpose, ReservationToken.Purpose.CONFIRMATION)
|
||||
self.assertEqual(
|
||||
result.confirmation_token.token_hash,
|
||||
ReservationToken.hash_token(result.raw_confirmation_token),
|
||||
)
|
||||
self.assertNotIn("maria@example.com", result.raw_confirmation_token)
|
||||
|
||||
def test_generate_confirmation_token_returns_raw_token_once(self):
|
||||
reservation = self.create_reservation()
|
||||
|
||||
token, raw_token = generate_confirmation_token(reservation)
|
||||
|
||||
self.assertEqual(token.purpose, ReservationToken.Purpose.CONFIRMATION)
|
||||
self.assertEqual(token.token_hash, ReservationToken.hash_token(raw_token))
|
||||
self.assertGreater(token.expires_at, timezone.now())
|
||||
|
||||
def test_confirm_reservation_from_valid_token(self):
|
||||
reservation = self.create_reservation(party_size=2)
|
||||
token, raw_token = generate_confirmation_token(reservation)
|
||||
|
||||
result = confirm_reservation_from_token(raw_token)
|
||||
reservation.refresh_from_db()
|
||||
token.refresh_from_db()
|
||||
|
||||
self.assertEqual(reservation.status, Reservation.Status.CONFIRMED)
|
||||
self.assertIsNotNone(reservation.confirmed_at)
|
||||
self.assertIsNotNone(reservation.qr_code_generated_at)
|
||||
self.assertIsNotNone(token.used_at)
|
||||
self.assertEqual(result.check_in_token.purpose, ReservationToken.Purpose.CHECK_IN)
|
||||
self.assertEqual(
|
||||
result.check_in_token.token_hash,
|
||||
ReservationToken.hash_token(result.raw_check_in_token),
|
||||
)
|
||||
self.assertEqual(result.available_seats, 1)
|
||||
|
||||
def test_confirmation_fails_when_capacity_is_exhausted(self):
|
||||
Reservation.objects.create(
|
||||
performance=self.performance,
|
||||
name="Confirmed Guest",
|
||||
email="confirmed@example.com",
|
||||
party_size=3,
|
||||
status=Reservation.Status.CONFIRMED,
|
||||
confirmed_at=timezone.now(),
|
||||
)
|
||||
reservation = self.create_reservation(party_size=1)
|
||||
token, raw_token = generate_confirmation_token(reservation)
|
||||
|
||||
with self.assertRaises(NotEnoughSeats):
|
||||
confirm_reservation_from_token(raw_token)
|
||||
|
||||
reservation.refresh_from_db()
|
||||
token.refresh_from_db()
|
||||
self.assertEqual(reservation.status, Reservation.Status.PENDING)
|
||||
self.assertIsNone(token.used_at)
|
||||
|
||||
def test_pending_reservation_does_not_reduce_confirmed_availability(self):
|
||||
create_pending_reservation(
|
||||
performance_id=self.performance.id,
|
||||
name="Pending Guest",
|
||||
email="pending@example.com",
|
||||
party_size=3,
|
||||
)
|
||||
|
||||
self.assertEqual(calculate_available_seats(self.performance), 3)
|
||||
|
||||
def test_invalid_token_fails(self):
|
||||
with self.assertRaises(InvalidToken):
|
||||
confirm_reservation_from_token("not-a-real-token")
|
||||
|
||||
def test_expired_token_marks_pending_reservation_expired(self):
|
||||
reservation = self.create_reservation()
|
||||
token, raw_token = generate_confirmation_token(
|
||||
reservation,
|
||||
expires_at=timezone.now() - timedelta(minutes=1),
|
||||
)
|
||||
|
||||
with self.assertRaises(ExpiredToken):
|
||||
confirm_reservation_from_token(raw_token)
|
||||
|
||||
reservation.refresh_from_db()
|
||||
token.refresh_from_db()
|
||||
self.assertEqual(reservation.status, Reservation.Status.EXPIRED)
|
||||
self.assertIsNone(token.used_at)
|
||||
|
||||
def test_duplicate_confirmation_is_rejected_safely(self):
|
||||
reservation = self.create_reservation()
|
||||
token, raw_token = generate_confirmation_token(reservation)
|
||||
confirm_reservation_from_token(raw_token)
|
||||
|
||||
with self.assertRaises(AlreadyConfirmedReservation):
|
||||
confirm_reservation_from_token(raw_token)
|
||||
|
||||
reservation.refresh_from_db()
|
||||
token.refresh_from_db()
|
||||
self.assertEqual(reservation.status, Reservation.Status.CONFIRMED)
|
||||
self.assertIsNotNone(token.used_at)
|
||||
self.assertEqual(
|
||||
ReservationToken.objects.filter(
|
||||
reservation=reservation,
|
||||
purpose=ReservationToken.Purpose.CHECK_IN,
|
||||
).count(),
|
||||
1,
|
||||
)
|
||||
|
||||
def test_confirmation_rechecks_capacity_after_another_confirmation(self):
|
||||
self.performance.room_capacity = 1
|
||||
self.performance.save(update_fields=["room_capacity", "updated_at"])
|
||||
first = self.create_reservation(email="first@example.com", party_size=1)
|
||||
second = self.create_reservation(email="second@example.com", party_size=1)
|
||||
_, first_raw_token = generate_confirmation_token(first)
|
||||
second_token, second_raw_token = generate_confirmation_token(second)
|
||||
|
||||
confirm_reservation_from_token(first_raw_token)
|
||||
|
||||
with self.assertRaises(NotEnoughSeats):
|
||||
confirm_reservation_from_token(second_raw_token)
|
||||
|
||||
second.refresh_from_db()
|
||||
second_token.refresh_from_db()
|
||||
self.assertEqual(second.status, Reservation.Status.PENDING)
|
||||
self.assertIsNone(second_token.used_at)
|
||||
self.assertEqual(calculate_available_seats(self.performance), 0)
|
||||
|
||||
def test_confirmation_uses_row_level_locking(self):
|
||||
reservation = self.create_reservation()
|
||||
_, raw_token = generate_confirmation_token(reservation)
|
||||
|
||||
with CaptureQueriesContext(connection) as queries:
|
||||
confirm_reservation_from_token(raw_token)
|
||||
|
||||
self.assertTrue(any("FOR UPDATE" in query["sql"] for query in queries))
|
||||
|
||||
def create_reservation(self, **overrides):
|
||||
data = {
|
||||
"performance": self.performance,
|
||||
"name": "Maria Rossi",
|
||||
"email": "maria@example.com",
|
||||
"party_size": 1,
|
||||
}
|
||||
data.update(overrides)
|
||||
return Reservation.objects.create(**data)
|
||||
@@ -0,0 +1,29 @@
|
||||
# ADR-0005: Prevent Overbooking with Database Transactions
|
||||
|
||||
Date: 2026-04-28
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
AzioneLab allows multiple visitors to create pending reservations for the same performance. Pending reservations do not guarantee seats, so final capacity must be enforced when a reservation is confirmed.
|
||||
|
||||
Concurrent confirmation requests could otherwise read the same availability and confirm more seats than the performance allows.
|
||||
|
||||
## Decision
|
||||
|
||||
Use PostgreSQL transactions and Django ORM row-level locking to serialize capacity checks for a performance.
|
||||
|
||||
When creating or confirming a reservation, the backend locks the related `Performance` row with `select_for_update()`, recalculates confirmed seats server-side, and proceeds only if enough seats remain.
|
||||
|
||||
Confirmation performs the final capacity check inside the transaction before changing the reservation to `confirmed` and consuming the confirmation token.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Capacity is enforced by the backend and database, not by frontend availability values.
|
||||
- Concurrent confirmations for the same performance are serialized by the locked `Performance` row.
|
||||
- Pending reservations can exceed current availability, but only confirmed reservations consume seats.
|
||||
- The design stays simple and works with Django ORM and PostgreSQL.
|
||||
- Very busy booking moments may queue briefly on the performance row lock.
|
||||
Reference in New Issue
Block a user