feat: add reservation QR retrieval

This commit is contained in:
2026-04-29 10:59:47 +02:00
parent 0c679391ed
commit 635a9a5c63
7 changed files with 124 additions and 6 deletions

View File

@@ -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")

View File

@@ -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),
) )

View File

@@ -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,

View File

@@ -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(

View File

@@ -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"),
] ]

View File

@@ -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)

View File

@@ -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