diff --git a/backend/azionelab/urls.py b/backend/azionelab/urls.py index a7ddb42..bda6372 100644 --- a/backend/azionelab/urls.py +++ b/backend/azionelab/urls.py @@ -14,4 +14,5 @@ urlpatterns = [ path("api/health/", health, name="health"), path("api/", include("shows.urls")), path("api/", include("bookings.urls")), + path("api/", include("checkins.urls")), ] diff --git a/backend/checkins/serializers.py b/backend/checkins/serializers.py new file mode 100644 index 0000000..f5c1bab --- /dev/null +++ b/backend/checkins/serializers.py @@ -0,0 +1,31 @@ +from rest_framework import serializers + + +class CheckInTokenSerializer(serializers.Serializer): + token = serializers.CharField(trim_whitespace=True, allow_blank=False) + + 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 CheckInPreviewResponseSerializer(serializers.Serializer): + status = serializers.CharField() + reservation_id = serializers.IntegerField() + performance_id = serializers.IntegerField() + show_title = serializers.CharField() + starts_at = serializers.DateTimeField() + party_size = serializers.IntegerField() + + +class CheckInConfirmResponseSerializer(serializers.Serializer): + status = serializers.CharField() + reservation_id = serializers.IntegerField(source="preview.reservation_id") + performance_id = serializers.IntegerField(source="preview.performance_id") + party_size = serializers.IntegerField(source="preview.party_size") + checked_in_at = serializers.DateTimeField(source="check_in.checked_in_at") + checked_in_by = serializers.IntegerField(source="check_in.checked_in_by_id") diff --git a/backend/checkins/test_api.py b/backend/checkins/test_api.py new file mode 100644 index 0000000..05e80b1 --- /dev/null +++ b/backend/checkins/test_api.py @@ -0,0 +1,190 @@ +from datetime import timedelta + +from django.contrib.auth import get_user_model +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, ReservationToken +from checkins.models import CheckIn +from shows.models import Performance, Show, Venue + + +class CheckInApiTests(APITestCase): + def setUp(self): + self.show = Show.objects.create( + title="Open Stage", + slug="open-stage-checkin-api", + is_published=True, + ) + self.venue = Venue.objects.create( + name="AzioneLab Theatre", + slug="azionelab-theatre-checkin-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=20, + ) + self.staff_user = get_user_model().objects.create_user( + username="staff-api", + password="test", + is_staff=True, + ) + self.regular_user = get_user_model().objects.create_user( + username="regular-api", + password="test", + is_staff=False, + ) + + def test_preview_success_as_staff_user(self): + reservation = self.create_reservation() + _, raw_token = self.create_check_in_token(reservation) + self.client.force_authenticate(user=self.staff_user) + + response = self.client.post( + reverse("api-check-in-preview"), + {"token": raw_token}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["status"], "valid") + self.assertEqual(response.data["reservation_id"], reservation.id) + self.assertEqual(response.data["performance_id"], self.performance.id) + self.assertEqual(response.data["show_title"], self.show.title) + self.assertEqual(response.data["party_size"], reservation.party_size) + self.assertNotIn("name", response.data) + self.assertNotIn("email", response.data) + self.assertNotIn("phone", response.data) + + def test_preview_denied_for_anonymous_user(self): + reservation = self.create_reservation() + _, raw_token = self.create_check_in_token(reservation) + + response = self.client.post( + reverse("api-check-in-preview"), + {"token": raw_token}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_preview_fails_for_invalid_token(self): + self.client.force_authenticate(user=self.staff_user) + + response = self.client.post( + reverse("api-check-in-preview"), + {"token": "invalid-token"}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertEqual(response.data["status"], "invalid_token") + + def test_check_in_success_as_staff_user(self): + reservation = self.create_reservation() + _, raw_token = self.create_check_in_token(reservation) + self.client.force_authenticate(user=self.staff_user) + + response = self.client.post( + reverse("api-check-in-confirm"), + {"token": raw_token}, + format="json", + ) + + check_in = CheckIn.objects.get(reservation=reservation) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["status"], "checked_in") + self.assertEqual(response.data["reservation_id"], reservation.id) + self.assertEqual(response.data["performance_id"], self.performance.id) + self.assertEqual(response.data["party_size"], reservation.party_size) + self.assertEqual(response.data["checked_in_by"], self.staff_user.id) + self.assertIsNotNone(response.data["checked_in_at"]) + self.assertEqual(check_in.checked_in_by, self.staff_user) + + def test_check_in_denied_for_anonymous_user(self): + reservation = self.create_reservation() + _, raw_token = self.create_check_in_token(reservation) + + response = self.client.post( + reverse("api-check-in-confirm"), + {"token": raw_token}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + self.assertFalse(CheckIn.objects.filter(reservation=reservation).exists()) + + def test_check_in_denied_for_non_staff_authenticated_user(self): + reservation = self.create_reservation() + _, raw_token = self.create_check_in_token(reservation) + self.client.force_authenticate(user=self.regular_user) + + response = self.client.post( + reverse("api-check-in-confirm"), + {"token": raw_token}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertFalse(CheckIn.objects.filter(reservation=reservation).exists()) + + def test_check_in_fails_for_pending_reservation(self): + reservation = self.create_reservation(status=Reservation.Status.PENDING, confirmed_at=None) + _, raw_token = self.create_check_in_token(reservation) + self.client.force_authenticate(user=self.staff_user) + + response = self.client.post( + reverse("api-check-in-confirm"), + {"token": raw_token}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_409_CONFLICT) + self.assertEqual(response.data["status"], "reservation_not_confirmed") + self.assertFalse(CheckIn.objects.filter(reservation=reservation).exists()) + + def test_duplicate_check_in_fails(self): + reservation = self.create_reservation() + _, raw_token = self.create_check_in_token(reservation) + self.client.force_authenticate(user=self.staff_user) + first_response = self.client.post( + reverse("api-check-in-confirm"), + {"token": raw_token}, + format="json", + ) + + second_response = self.client.post( + reverse("api-check-in-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_checked_in") + self.assertEqual(CheckIn.objects.filter(reservation=reservation).count(), 1) + + def create_reservation(self, **overrides): + data = { + "performance": self.performance, + "name": "Maria Rossi", + "email": "maria@example.com", + "party_size": 2, + "status": Reservation.Status.CONFIRMED, + "confirmed_at": timezone.now(), + } + data.update(overrides) + return Reservation.objects.create(**data) + + def create_check_in_token(self, reservation): + return ReservationToken.create_token( + reservation=reservation, + purpose=ReservationToken.Purpose.CHECK_IN, + expires_at=self.performance.starts_at + timedelta(days=1), + ) diff --git a/backend/checkins/urls.py b/backend/checkins/urls.py new file mode 100644 index 0000000..7adbb7f --- /dev/null +++ b/backend/checkins/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from . import views + + +urlpatterns = [ + path("check-ins/preview/", views.check_in_preview, name="api-check-in-preview"), + path("check-ins/confirm/", views.check_in_confirm, name="api-check-in-confirm"), +] diff --git a/backend/checkins/views.py b/backend/checkins/views.py new file mode 100644 index 0000000..6d0cb62 --- /dev/null +++ b/backend/checkins/views.py @@ -0,0 +1,110 @@ +from rest_framework import status +from rest_framework.authentication import BasicAuthentication, SessionAuthentication +from rest_framework.decorators import api_view, authentication_classes, permission_classes +from rest_framework.permissions import BasePermission, IsAuthenticated +from rest_framework.response import Response + +from .serializers import ( + CheckInConfirmResponseSerializer, + CheckInPreviewResponseSerializer, + CheckInTokenSerializer, +) +from .services import ( + AlreadyCheckedIn, + InvalidToken, + MissingStaffUser, + ReservationNotConfirmed, + confirm_check_in_from_token, + preview_check_in_token, +) + + +class IsStaffUser(BasePermission): + def has_permission(self, request, view): + return bool(request.user and request.user.is_staff) + + +def staff_check_in_view(view_func): + view_func = permission_classes([IsAuthenticated, IsStaffUser])(view_func) + view_func = authentication_classes([BasicAuthentication, SessionAuthentication])(view_func) + view_func = api_view(["POST"])(view_func) + return view_func + + +@staff_check_in_view +def check_in_preview(request): + serializer = CheckInTokenSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + try: + preview = preview_check_in_token(serializer.validated_data["token"], staff_user=request.user) + except InvalidToken as exc: + return Response( + {"status": "invalid_token", "detail": str(exc)}, + status=status.HTTP_404_NOT_FOUND, + ) + except ReservationNotConfirmed as exc: + return Response( + {"status": "reservation_not_confirmed", "detail": str(exc)}, + status=status.HTTP_409_CONFLICT, + ) + except AlreadyCheckedIn as exc: + return Response( + {"status": "already_checked_in", "detail": str(exc)}, + status=status.HTTP_409_CONFLICT, + ) + except MissingStaffUser as exc: + return Response( + {"status": "staff_user_required", "detail": str(exc)}, + status=status.HTTP_403_FORBIDDEN, + ) + + response_serializer = CheckInPreviewResponseSerializer( + { + "status": "valid", + "reservation_id": preview.reservation_id, + "performance_id": preview.performance_id, + "show_title": preview.show_title, + "starts_at": preview.starts_at, + "party_size": preview.party_size, + } + ) + return Response(response_serializer.data) + + +@staff_check_in_view +def check_in_confirm(request): + serializer = CheckInTokenSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + try: + result = confirm_check_in_from_token(serializer.validated_data["token"], staff_user=request.user) + except InvalidToken as exc: + return Response( + {"status": "invalid_token", "detail": str(exc)}, + status=status.HTTP_404_NOT_FOUND, + ) + except ReservationNotConfirmed as exc: + return Response( + {"status": "reservation_not_confirmed", "detail": str(exc)}, + status=status.HTTP_409_CONFLICT, + ) + except AlreadyCheckedIn as exc: + return Response( + {"status": "already_checked_in", "detail": str(exc)}, + status=status.HTTP_409_CONFLICT, + ) + except MissingStaffUser as exc: + return Response( + {"status": "staff_user_required", "detail": str(exc)}, + status=status.HTTP_403_FORBIDDEN, + ) + + response_serializer = CheckInConfirmResponseSerializer( + { + "status": "checked_in", + "check_in": result.check_in, + "preview": result.preview, + } + ) + return Response(response_serializer.data)