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