generated from bisco/codex-bootstrap
feat: add reservation QR retrieval
This commit is contained in:
@@ -31,6 +31,12 @@ class ReservationConfirmSerializer(StrictSerializer):
|
|||||||
token = serializers.CharField(trim_whitespace=True, allow_blank=False)
|
token = serializers.CharField(trim_whitespace=True, allow_blank=False)
|
||||||
|
|
||||||
|
|
||||||
|
class ReservationQRResponseSerializer(serializers.Serializer):
|
||||||
|
reservation_id = serializers.IntegerField(source="reservation.id")
|
||||||
|
qr_code_url = serializers.CharField()
|
||||||
|
qr_code_image = serializers.CharField()
|
||||||
|
|
||||||
|
|
||||||
class ReservationConfirmResponseSerializer(serializers.Serializer):
|
class ReservationConfirmResponseSerializer(serializers.Serializer):
|
||||||
reservation_id = serializers.IntegerField(source="reservation.id")
|
reservation_id = serializers.IntegerField(source="reservation.id")
|
||||||
status = serializers.CharField(source="reservation.status")
|
status = serializers.CharField(source="reservation.status")
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ class AlreadyConfirmedReservation(BookingServiceError):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ReservationNotConfirmed(BookingServiceError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class PendingReservationResult:
|
class PendingReservationResult:
|
||||||
reservation: Reservation
|
reservation: Reservation
|
||||||
@@ -59,6 +63,13 @@ class ConfirmedReservationResult:
|
|||||||
qr_code_url: str
|
qr_code_url: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ReservationQRResult:
|
||||||
|
reservation: Reservation
|
||||||
|
qr_code_image: str
|
||||||
|
qr_code_url: str
|
||||||
|
|
||||||
|
|
||||||
def calculate_available_seats(performance):
|
def calculate_available_seats(performance):
|
||||||
confirmed_seats = (
|
confirmed_seats = (
|
||||||
Reservation.objects.filter(
|
Reservation.objects.filter(
|
||||||
@@ -184,9 +195,32 @@ def confirm_reservation_from_token(raw_token):
|
|||||||
available_seats=available_seats - reservation.party_size,
|
available_seats=available_seats - reservation.party_size,
|
||||||
qr_code_image=generate_check_in_qr_base64(
|
qr_code_image=generate_check_in_qr_base64(
|
||||||
reservation=reservation,
|
reservation=reservation,
|
||||||
raw_check_in_token=raw_check_in_token,
|
raw_check_in_token=raw_token,
|
||||||
),
|
),
|
||||||
qr_code_url=build_check_in_preview_url(raw_check_in_token),
|
qr_code_url=build_check_in_preview_url(raw_token),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def retrieve_reservation_qr_from_token(raw_token):
|
||||||
|
try:
|
||||||
|
confirmation_token = ReservationToken.objects.select_related("reservation").get(
|
||||||
|
token_hash=ReservationToken.hash_token(raw_token),
|
||||||
|
purpose=ReservationToken.Purpose.CONFIRMATION,
|
||||||
|
)
|
||||||
|
except ReservationToken.DoesNotExist as exc:
|
||||||
|
raise InvalidToken("Confirmation token is invalid.") from exc
|
||||||
|
|
||||||
|
reservation = confirmation_token.reservation
|
||||||
|
if reservation.status != Reservation.Status.CONFIRMED:
|
||||||
|
raise ReservationNotConfirmed("Reservation must be confirmed before QR retrieval.")
|
||||||
|
|
||||||
|
return ReservationQRResult(
|
||||||
|
reservation=reservation,
|
||||||
|
qr_code_image=generate_check_in_qr_base64(
|
||||||
|
reservation=reservation,
|
||||||
|
raw_check_in_token=raw_token,
|
||||||
|
),
|
||||||
|
qr_code_url=build_check_in_preview_url(raw_token),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -170,6 +170,54 @@ class BookingApiTests(APITestCase):
|
|||||||
self.assertEqual(second_response.status_code, status.HTTP_409_CONFLICT)
|
self.assertEqual(second_response.status_code, status.HTTP_409_CONFLICT)
|
||||||
self.assertEqual(second_response.data["status"], "already_confirmed")
|
self.assertEqual(second_response.data["status"], "already_confirmed")
|
||||||
|
|
||||||
|
@override_settings(SITE_BASE_URL="https://tickets.azionelab.example")
|
||||||
|
def test_qr_retrieval_success_for_confirmed_reservation(self):
|
||||||
|
reservation = self.create_reservation()
|
||||||
|
_, raw_token = generate_confirmation_token(reservation)
|
||||||
|
self.client.post(
|
||||||
|
reverse("api-reservation-confirm"),
|
||||||
|
{"token": raw_token},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("api-reservation-qr"),
|
||||||
|
{"token": raw_token},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(response.data["reservation_id"], reservation.id)
|
||||||
|
self.assertTrue(
|
||||||
|
response.data["qr_code_url"].startswith(
|
||||||
|
"https://tickets.azionelab.example/api/check-ins/preview/?token="
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertTrue(response.data["qr_code_image"].startswith("data:image/png;base64,"))
|
||||||
|
self.assertNotIn("email", response.data)
|
||||||
|
self.assertNotIn("name", response.data)
|
||||||
|
|
||||||
|
def test_qr_retrieval_fails_for_invalid_token(self):
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("api-reservation-qr"),
|
||||||
|
{"token": "invalid-token"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
||||||
|
self.assertEqual(response.data["status"], "invalid_token")
|
||||||
|
|
||||||
|
def test_qr_retrieval_fails_for_pending_reservation(self):
|
||||||
|
reservation = self.create_reservation()
|
||||||
|
_, raw_token = generate_confirmation_token(reservation)
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("api-reservation-qr"),
|
||||||
|
{"token": raw_token},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_409_CONFLICT)
|
||||||
|
self.assertEqual(response.data["status"], "reservation_not_confirmed")
|
||||||
|
self.assertEqual(reservation.status, Reservation.Status.PENDING)
|
||||||
|
|
||||||
def create_reservation(self, **overrides):
|
def create_reservation(self, **overrides):
|
||||||
data = {
|
data = {
|
||||||
"performance": self.performance,
|
"performance": self.performance,
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ class BookingServiceTests(TestCase):
|
|||||||
self.assertEqual(result.available_seats, 1)
|
self.assertEqual(result.available_seats, 1)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
result.qr_code_url,
|
result.qr_code_url,
|
||||||
build_check_in_preview_url(result.raw_check_in_token),
|
build_check_in_preview_url(raw_token),
|
||||||
)
|
)
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
result.qr_code_url.startswith(
|
result.qr_code_url.startswith(
|
||||||
|
|||||||
@@ -10,4 +10,5 @@ urlpatterns = [
|
|||||||
name="api-reservation-create",
|
name="api-reservation-create",
|
||||||
),
|
),
|
||||||
path("reservations/confirm/", views.confirm_reservation, name="api-reservation-confirm"),
|
path("reservations/confirm/", views.confirm_reservation, name="api-reservation-confirm"),
|
||||||
|
path("reservations/qr/", views.retrieve_reservation_qr, name="api-reservation-qr"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from .serializers import (
|
|||||||
ReservationConfirmSerializer,
|
ReservationConfirmSerializer,
|
||||||
ReservationCreateResponseSerializer,
|
ReservationCreateResponseSerializer,
|
||||||
ReservationCreateSerializer,
|
ReservationCreateSerializer,
|
||||||
|
ReservationQRResponseSerializer,
|
||||||
)
|
)
|
||||||
from .services import (
|
from .services import (
|
||||||
AlreadyConfirmedReservation,
|
AlreadyConfirmedReservation,
|
||||||
@@ -17,8 +18,10 @@ from .services import (
|
|||||||
InvalidToken,
|
InvalidToken,
|
||||||
NotEnoughSeats,
|
NotEnoughSeats,
|
||||||
PerformanceNotAvailable,
|
PerformanceNotAvailable,
|
||||||
|
ReservationNotConfirmed,
|
||||||
confirm_reservation_from_token,
|
confirm_reservation_from_token,
|
||||||
create_pending_reservation,
|
create_pending_reservation,
|
||||||
|
retrieve_reservation_qr_from_token,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -87,3 +90,25 @@ def confirm_reservation(request):
|
|||||||
|
|
||||||
response_serializer = ReservationConfirmResponseSerializer(result)
|
response_serializer = ReservationConfirmResponseSerializer(result)
|
||||||
return Response(response_serializer.data)
|
return Response(response_serializer.data)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(["GET"])
|
||||||
|
def retrieve_reservation_qr(request):
|
||||||
|
serializer = ReservationConfirmSerializer(data=request.query_params)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = retrieve_reservation_qr_from_token(serializer.validated_data["token"])
|
||||||
|
except InvalidToken as exc:
|
||||||
|
return Response(
|
||||||
|
{"status": "invalid_token", "detail": str(exc)},
|
||||||
|
status=status.HTTP_404_NOT_FOUND,
|
||||||
|
)
|
||||||
|
except ReservationNotConfirmed as exc:
|
||||||
|
return Response(
|
||||||
|
{"status": "reservation_not_confirmed", "detail": str(exc)},
|
||||||
|
status=status.HTTP_409_CONFLICT,
|
||||||
|
)
|
||||||
|
|
||||||
|
response_serializer = ReservationQRResponseSerializer(result)
|
||||||
|
return Response(response_serializer.data)
|
||||||
|
|||||||
@@ -98,13 +98,17 @@ def _get_reservation_for_check_in_token(raw_token, *, lock_token=False):
|
|||||||
try:
|
try:
|
||||||
token = queryset.get(
|
token = queryset.get(
|
||||||
token_hash=ReservationToken.hash_token(raw_token),
|
token_hash=ReservationToken.hash_token(raw_token),
|
||||||
purpose=ReservationToken.Purpose.CHECK_IN,
|
|
||||||
used_at__isnull=True,
|
|
||||||
)
|
)
|
||||||
except ReservationToken.DoesNotExist as exc:
|
except ReservationToken.DoesNotExist as exc:
|
||||||
raise InvalidToken("Check-in token is invalid.") from exc
|
raise InvalidToken("Check-in token is invalid.") from exc
|
||||||
|
|
||||||
if token.is_expired:
|
if token.purpose == ReservationToken.Purpose.CHECK_IN:
|
||||||
|
if token.used_at is not None or token.is_expired:
|
||||||
|
raise InvalidToken("Check-in token is invalid.")
|
||||||
|
elif token.purpose == ReservationToken.Purpose.CONFIRMATION:
|
||||||
|
if token.reservation.status != Reservation.Status.CONFIRMED:
|
||||||
|
raise InvalidToken("Check-in token is invalid.")
|
||||||
|
else:
|
||||||
raise InvalidToken("Check-in token is invalid.")
|
raise InvalidToken("Check-in token is invalid.")
|
||||||
|
|
||||||
return token.reservation
|
return token.reservation
|
||||||
|
|||||||
Reference in New Issue
Block a user