generated from bisco/codex-bootstrap
189 lines
5.9 KiB
Python
189 lines
5.9 KiB
Python
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
|