Merge branch 'feature/backend-booking-api' into develop

This commit is contained in:
2026-04-29 09:57:10 +02:00
8 changed files with 429 additions and 1 deletions

View File

@@ -1,5 +1,5 @@
from django.contrib import admin
from django.urls import path
from django.urls import include, path
from rest_framework.decorators import api_view
from rest_framework.response import Response
@@ -12,4 +12,6 @@ def health(request):
urlpatterns = [
path("admin/", admin.site.urls),
path("api/health/", health, name="health"),
path("api/", include("shows.urls")),
path("api/", include("bookings.urls")),
]

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)

View File

@@ -0,0 +1,55 @@
from rest_framework import serializers
class PublicShowListSerializer(serializers.Serializer):
id = serializers.IntegerField()
title = serializers.CharField()
slug = serializers.SlugField()
summary = serializers.CharField()
poster_image = serializers.URLField(allow_blank=True)
class PublicVenueSummarySerializer(serializers.Serializer):
name = serializers.CharField()
city = serializers.CharField()
class PublicVenueDetailSerializer(PublicVenueSummarySerializer):
address = serializers.CharField()
class PublicShowSummarySerializer(serializers.Serializer):
title = serializers.CharField()
slug = serializers.SlugField()
summary = serializers.CharField()
class PublicPerformanceListSerializer(serializers.Serializer):
id = serializers.IntegerField()
show = PublicShowSummarySerializer()
venue = PublicVenueSummarySerializer()
starts_at = serializers.DateTimeField()
booking_enabled = serializers.BooleanField(source="is_booking_enabled")
available_seats = serializers.IntegerField()
class PublicShowPerformanceSerializer(serializers.Serializer):
id = serializers.IntegerField()
starts_at = serializers.DateTimeField()
venue = PublicVenueSummarySerializer()
booking_enabled = serializers.BooleanField(source="is_booking_enabled")
available_seats = serializers.IntegerField()
class PublicShowDetailSerializer(PublicShowListSerializer):
description = serializers.CharField()
performances = PublicShowPerformanceSerializer(many=True, source="public_performances")
class PublicPerformanceDetailSerializer(serializers.Serializer):
id = serializers.IntegerField()
show = PublicShowSummarySerializer()
venue = PublicVenueDetailSerializer()
starts_at = serializers.DateTimeField()
booking_enabled = serializers.BooleanField(source="is_booking_enabled")
available_seats = serializers.IntegerField()

11
backend/shows/urls.py Normal file
View File

@@ -0,0 +1,11 @@
from django.urls import path
from . import views
urlpatterns = [
path("shows/", views.show_list, name="api-show-list"),
path("shows/<slug:slug>/", views.show_detail, name="api-show-detail"),
path("performances/", views.performance_list, name="api-performance-list"),
path("performances/<int:pk>/", views.performance_detail, name="api-performance-detail"),
]

65
backend/shows/views.py Normal file
View File

@@ -0,0 +1,65 @@
from django.shortcuts import get_object_or_404
from django.utils import timezone
from django.utils.dateparse import parse_datetime
from rest_framework import status
from rest_framework.decorators import api_view
from rest_framework.response import Response
from .models import Performance, Show
from .serializers import (
PublicPerformanceDetailSerializer,
PublicPerformanceListSerializer,
PublicShowDetailSerializer,
PublicShowListSerializer,
)
def public_performance_queryset():
return Performance.objects.select_related("show", "venue").filter(
show__is_published=True,
starts_at__gte=timezone.now(),
)
@api_view(["GET"])
def show_list(request):
shows = Show.objects.filter(is_published=True).order_by("title")
serializer = PublicShowListSerializer(shows, many=True)
return Response({"results": serializer.data})
@api_view(["GET"])
def show_detail(request, slug):
show = get_object_or_404(Show, slug=slug, is_published=True)
show.public_performances = public_performance_queryset().filter(show=show)
serializer = PublicShowDetailSerializer(show)
return Response(serializer.data)
@api_view(["GET"])
def performance_list(request):
performances = public_performance_queryset()
show_slug = request.query_params.get("show")
if show_slug:
performances = performances.filter(show__slug=show_slug)
from_value = request.query_params.get("from")
if from_value:
starts_from = parse_datetime(from_value)
if starts_from is None:
return Response(
{"from": ["Enter a valid ISO 8601 date/time."]},
status=status.HTTP_400_BAD_REQUEST,
)
performances = performances.filter(starts_at__gte=starts_from)
serializer = PublicPerformanceListSerializer(performances.order_by("starts_at"), many=True)
return Response({"results": serializer.data})
@api_view(["GET"])
def performance_detail(request, pk):
performance = get_object_or_404(public_performance_queryset(), pk=pk)
serializer = PublicPerformanceDetailSerializer(performance)
return Response(serializer.data)