generated from bisco/codex-bootstrap
feat: add booking REST API
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
from django.contrib import admin
|
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.decorators import api_view
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
|
||||||
@@ -12,4 +12,6 @@ def health(request):
|
|||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("admin/", admin.site.urls),
|
path("admin/", admin.site.urls),
|
||||||
path("api/health/", health, name="health"),
|
path("api/health/", health, name="health"),
|
||||||
|
path("api/", include("shows.urls")),
|
||||||
|
path("api/", include("bookings.urls")),
|
||||||
]
|
]
|
||||||
|
|||||||
41
backend/bookings/serializers.py
Normal file
41
backend/bookings/serializers.py
Normal 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/"
|
||||||
153
backend/bookings/test_api.py
Normal file
153
backend/bookings/test_api.py
Normal 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
13
backend/bookings/urls.py
Normal 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
88
backend/bookings/views.py
Normal 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)
|
||||||
55
backend/shows/serializers.py
Normal file
55
backend/shows/serializers.py
Normal 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
11
backend/shows/urls.py
Normal 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
65
backend/shows/views.py
Normal 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)
|
||||||
Reference in New Issue
Block a user