diff --git a/backend/azionelab/urls.py b/backend/azionelab/urls.py index 46dde6e..a7ddb42 100644 --- a/backend/azionelab/urls.py +++ b/backend/azionelab/urls.py @@ -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")), ] diff --git a/backend/bookings/serializers.py b/backend/bookings/serializers.py new file mode 100644 index 0000000..3314b2d --- /dev/null +++ b/backend/bookings/serializers.py @@ -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/" diff --git a/backend/bookings/test_api.py b/backend/bookings/test_api.py new file mode 100644 index 0000000..2116534 --- /dev/null +++ b/backend/bookings/test_api.py @@ -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) diff --git a/backend/bookings/urls.py b/backend/bookings/urls.py new file mode 100644 index 0000000..08d38de --- /dev/null +++ b/backend/bookings/urls.py @@ -0,0 +1,13 @@ +from django.urls import path + +from . import views + + +urlpatterns = [ + path( + "performances//reservations/", + views.create_reservation, + name="api-reservation-create", + ), + path("reservations/confirm/", views.confirm_reservation, name="api-reservation-confirm"), +] diff --git a/backend/bookings/views.py b/backend/bookings/views.py new file mode 100644 index 0000000..501279f --- /dev/null +++ b/backend/bookings/views.py @@ -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) diff --git a/backend/shows/serializers.py b/backend/shows/serializers.py new file mode 100644 index 0000000..e6971de --- /dev/null +++ b/backend/shows/serializers.py @@ -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() diff --git a/backend/shows/urls.py b/backend/shows/urls.py new file mode 100644 index 0000000..9c927ec --- /dev/null +++ b/backend/shows/urls.py @@ -0,0 +1,11 @@ +from django.urls import path + +from . import views + + +urlpatterns = [ + path("shows/", views.show_list, name="api-show-list"), + path("shows//", views.show_detail, name="api-show-detail"), + path("performances/", views.performance_list, name="api-performance-list"), + path("performances//", views.performance_detail, name="api-performance-detail"), +] diff --git a/backend/shows/views.py b/backend/shows/views.py new file mode 100644 index 0000000..b9b69ff --- /dev/null +++ b/backend/shows/views.py @@ -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)