feat: add booking service layer

This commit is contained in:
2026-04-28 17:17:02 +02:00
parent 71ff02c25a
commit f818f72dc6
3 changed files with 409 additions and 0 deletions

View 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