generated from bisco/codex-bootstrap
Merge branch 'feature/backend-booking-api' into develop
This commit is contained in:
@@ -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")),
|
||||
]
|
||||
|
||||
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