diff --git a/backend/bookings/serializers.py b/backend/bookings/serializers.py index 93f0022..20d8ba8 100644 --- a/backend/bookings/serializers.py +++ b/backend/bookings/serializers.py @@ -31,6 +31,12 @@ class ReservationConfirmSerializer(StrictSerializer): 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): reservation_id = serializers.IntegerField(source="reservation.id") status = serializers.CharField(source="reservation.status") diff --git a/backend/bookings/services.py b/backend/bookings/services.py index 6433999..389c9d7 100644 --- a/backend/bookings/services.py +++ b/backend/bookings/services.py @@ -40,6 +40,10 @@ class AlreadyConfirmedReservation(BookingServiceError): pass +class ReservationNotConfirmed(BookingServiceError): + pass + + @dataclass(frozen=True) class PendingReservationResult: reservation: Reservation @@ -59,6 +63,13 @@ class ConfirmedReservationResult: qr_code_url: str +@dataclass(frozen=True) +class ReservationQRResult: + reservation: Reservation + qr_code_image: str + qr_code_url: str + + def calculate_available_seats(performance): confirmed_seats = ( Reservation.objects.filter( @@ -184,9 +195,32 @@ def confirm_reservation_from_token(raw_token): available_seats=available_seats - reservation.party_size, qr_code_image=generate_check_in_qr_base64( 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), ) diff --git a/backend/bookings/test_api.py b/backend/bookings/test_api.py index c9b22e8..b5ddab4 100644 --- a/backend/bookings/test_api.py +++ b/backend/bookings/test_api.py @@ -170,6 +170,54 @@ class BookingApiTests(APITestCase): self.assertEqual(second_response.status_code, status.HTTP_409_CONFLICT) 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): data = { "performance": self.performance, diff --git a/backend/bookings/test_services.py b/backend/bookings/test_services.py index 8ed99bd..7480e5c 100644 --- a/backend/bookings/test_services.py +++ b/backend/bookings/test_services.py @@ -130,7 +130,7 @@ class BookingServiceTests(TestCase): self.assertEqual(result.available_seats, 1) self.assertEqual( result.qr_code_url, - build_check_in_preview_url(result.raw_check_in_token), + build_check_in_preview_url(raw_token), ) self.assertTrue( result.qr_code_url.startswith( diff --git a/backend/bookings/urls.py b/backend/bookings/urls.py index 08d38de..fd350c5 100644 --- a/backend/bookings/urls.py +++ b/backend/bookings/urls.py @@ -10,4 +10,5 @@ urlpatterns = [ name="api-reservation-create", ), path("reservations/confirm/", views.confirm_reservation, name="api-reservation-confirm"), + path("reservations/qr/", views.retrieve_reservation_qr, name="api-reservation-qr"), ] diff --git a/backend/bookings/views.py b/backend/bookings/views.py index 6e97d00..30df713 100644 --- a/backend/bookings/views.py +++ b/backend/bookings/views.py @@ -10,6 +10,7 @@ from .serializers import ( ReservationConfirmSerializer, ReservationCreateResponseSerializer, ReservationCreateSerializer, + ReservationQRResponseSerializer, ) from .services import ( AlreadyConfirmedReservation, @@ -17,8 +18,10 @@ from .services import ( InvalidToken, NotEnoughSeats, PerformanceNotAvailable, + ReservationNotConfirmed, confirm_reservation_from_token, create_pending_reservation, + retrieve_reservation_qr_from_token, ) @@ -87,3 +90,25 @@ def confirm_reservation(request): response_serializer = ReservationConfirmResponseSerializer(result) 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) diff --git a/backend/checkins/services.py b/backend/checkins/services.py index b28f5bc..40375f0 100644 --- a/backend/checkins/services.py +++ b/backend/checkins/services.py @@ -98,13 +98,17 @@ def _get_reservation_for_check_in_token(raw_token, *, lock_token=False): try: token = queryset.get( token_hash=ReservationToken.hash_token(raw_token), - purpose=ReservationToken.Purpose.CHECK_IN, - used_at__isnull=True, ) except ReservationToken.DoesNotExist as 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.") return token.reservation