feat: add booking REST API

This commit is contained in:
2026-04-29 09:45:44 +02:00
parent 441d73d473
commit 89cf08647c
8 changed files with 429 additions and 1 deletions

View File

@@ -0,0 +1,41 @@
from rest_framework import serializers
class StrictSerializer(serializers.Serializer):
def validate(self, attrs):
unknown_fields = set(self.initial_data) - set(self.fields)
if unknown_fields:
raise serializers.ValidationError(
{field: ["Unexpected field."] for field in sorted(unknown_fields)}
)
return attrs
class ReservationCreateSerializer(StrictSerializer):
name = serializers.CharField(max_length=200, trim_whitespace=True)
email = serializers.EmailField()
phone = serializers.CharField(max_length=40, trim_whitespace=True, required=False, allow_blank=True)
party_size = serializers.IntegerField(min_value=1)
notes = serializers.CharField(trim_whitespace=True, required=False, allow_blank=True)
class ReservationCreateResponseSerializer(serializers.Serializer):
id = serializers.IntegerField()
status = serializers.CharField()
performance = serializers.IntegerField(source="performance_id")
party_size = serializers.IntegerField()
message = serializers.CharField()
class ReservationConfirmSerializer(StrictSerializer):
token = serializers.CharField(trim_whitespace=True, allow_blank=False)
class ReservationConfirmResponseSerializer(serializers.Serializer):
reservation_id = serializers.IntegerField(source="reservation.id")
status = serializers.CharField(source="reservation.status")
party_size = serializers.IntegerField(source="reservation.party_size")
qr_code_url = serializers.SerializerMethodField()
def get_qr_code_url(self, result):
return f"/api/reservations/{result.reservation.id}/qr-code/"

View File

@@ -0,0 +1,153 @@
from datetime import timedelta
from django.urls import reverse
from django.utils import timezone
from rest_framework import status
from rest_framework.test import APITestCase
from bookings.models import Reservation
from bookings.services import generate_confirmation_token
from shows.models import Performance, Show, Venue
class BookingApiTests(APITestCase):
def setUp(self):
self.show = Show.objects.create(
title="Open Stage",
slug="open-stage-api",
summary="A contemporary theatre performance.",
description="Full public show description.",
is_published=True,
)
self.hidden_show = Show.objects.create(
title="Hidden Stage",
slug="hidden-stage-api",
is_published=False,
)
self.venue = Venue.objects.create(
name="AzioneLab Theatre",
slug="azionelab-theatre-api",
address="Via Example 1",
city="Rome",
)
self.performance = Performance.objects.create(
show=self.show,
venue=self.venue,
starts_at=timezone.now() + timedelta(days=7),
room_capacity=3,
)
Performance.objects.create(
show=self.hidden_show,
venue=self.venue,
starts_at=timezone.now() + timedelta(days=8),
room_capacity=5,
)
def test_show_list_returns_published_shows(self):
response = self.client.get(reverse("api-show-list"))
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data["results"]), 1)
self.assertEqual(response.data["results"][0]["slug"], self.show.slug)
def test_show_detail_returns_public_performances(self):
response = self.client.get(reverse("api-show-detail", kwargs={"slug": self.show.slug}))
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["slug"], self.show.slug)
self.assertEqual(len(response.data["performances"]), 1)
self.assertEqual(response.data["performances"][0]["id"], self.performance.id)
self.assertEqual(response.data["performances"][0]["available_seats"], 3)
def test_reservation_creation_success(self):
response = self.client.post(
reverse("api-reservation-create", kwargs={"performance_id": self.performance.id}),
{
"name": "Maria Rossi",
"email": "maria@example.com",
"phone": "+390600000000",
"party_size": 2,
"notes": "Front row if possible.",
},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(response.data["status"], Reservation.Status.PENDING)
self.assertEqual(response.data["performance"], self.performance.id)
self.assertNotIn("token", response.data)
self.assertNotIn("email", response.data)
self.assertEqual(Reservation.objects.count(), 1)
def test_reservation_creation_with_insufficient_seats(self):
response = self.client.post(
reverse("api-reservation-create", kwargs={"performance_id": self.performance.id}),
{
"name": "Maria Rossi",
"email": "maria@example.com",
"party_size": 4,
},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_409_CONFLICT)
self.assertEqual(response.data["status"], "booking_unavailable")
self.assertEqual(Reservation.objects.count(), 0)
def test_confirmation_success(self):
reservation = self.create_reservation()
_, raw_token = generate_confirmation_token(reservation)
response = self.client.post(
reverse("api-reservation-confirm"),
{"token": raw_token},
format="json",
)
reservation.refresh_from_db()
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["reservation_id"], reservation.id)
self.assertEqual(response.data["status"], Reservation.Status.CONFIRMED)
self.assertEqual(response.data["party_size"], reservation.party_size)
self.assertEqual(response.data["qr_code_url"], f"/api/reservations/{reservation.id}/qr-code/")
self.assertNotIn("token", response.data)
self.assertEqual(reservation.status, Reservation.Status.CONFIRMED)
def test_confirmation_with_invalid_token(self):
response = self.client.post(
reverse("api-reservation-confirm"),
{"token": "invalid-token"},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
self.assertEqual(response.data["status"], "invalid_token")
def test_duplicate_confirmation_behavior(self):
reservation = self.create_reservation()
_, raw_token = generate_confirmation_token(reservation)
first_response = self.client.post(
reverse("api-reservation-confirm"),
{"token": raw_token},
format="json",
)
second_response = self.client.post(
reverse("api-reservation-confirm"),
{"token": raw_token},
format="json",
)
self.assertEqual(first_response.status_code, status.HTTP_200_OK)
self.assertEqual(second_response.status_code, status.HTTP_409_CONFLICT)
self.assertEqual(second_response.data["status"], "already_confirmed")
def create_reservation(self, **overrides):
data = {
"performance": self.performance,
"name": "Maria Rossi",
"email": "maria@example.com",
"party_size": 1,
}
data.update(overrides)
return Reservation.objects.create(**data)

13
backend/bookings/urls.py Normal file
View File

@@ -0,0 +1,13 @@
from django.urls import path
from . import views
urlpatterns = [
path(
"performances/<int:performance_id>/reservations/",
views.create_reservation,
name="api-reservation-create",
),
path("reservations/confirm/", views.confirm_reservation, name="api-reservation-confirm"),
]

88
backend/bookings/views.py Normal file
View File

@@ -0,0 +1,88 @@
from django.shortcuts import get_object_or_404
from rest_framework import status
from rest_framework.decorators import api_view
from rest_framework.response import Response
from shows.models import Performance
from .serializers import (
ReservationConfirmResponseSerializer,
ReservationConfirmSerializer,
ReservationCreateResponseSerializer,
ReservationCreateSerializer,
)
from .services import (
AlreadyConfirmedReservation,
ExpiredToken,
InvalidToken,
NotEnoughSeats,
PerformanceNotAvailable,
confirm_reservation_from_token,
create_pending_reservation,
)
@api_view(["POST"])
def create_reservation(request, performance_id):
get_object_or_404(Performance, pk=performance_id, show__is_published=True)
serializer = ReservationCreateSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
try:
result = create_pending_reservation(
performance_id=performance_id,
name=serializer.validated_data["name"],
email=serializer.validated_data["email"],
phone=serializer.validated_data.get("phone", ""),
party_size=serializer.validated_data["party_size"],
notes=serializer.validated_data.get("notes", ""),
)
except (NotEnoughSeats, PerformanceNotAvailable) as exc:
return Response(
{"status": "booking_unavailable", "detail": str(exc)},
status=status.HTTP_409_CONFLICT,
)
response_serializer = ReservationCreateResponseSerializer(
{
"id": result.reservation.id,
"status": result.reservation.status,
"performance_id": result.reservation.performance_id,
"party_size": result.reservation.party_size,
"message": "Reservation created. Please check your email to confirm it.",
}
)
return Response(response_serializer.data, status=status.HTTP_201_CREATED)
@api_view(["POST"])
def confirm_reservation(request):
serializer = ReservationConfirmSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
try:
result = confirm_reservation_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 ExpiredToken as exc:
return Response(
{"status": "token_expired", "detail": str(exc)},
status=status.HTTP_410_GONE,
)
except NotEnoughSeats as exc:
return Response(
{"status": "not_enough_seats", "detail": str(exc)},
status=status.HTTP_409_CONFLICT,
)
except AlreadyConfirmedReservation as exc:
return Response(
{"status": "already_confirmed", "detail": str(exc)},
status=status.HTTP_409_CONFLICT,
)
response_serializer = ReservationConfirmResponseSerializer(result)
return Response(response_serializer.data)