generated from bisco/codex-bootstrap
feat: add check-in REST API
This commit is contained in:
@@ -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")),
|
||||
]
|
||||
|
||||
31
backend/checkins/serializers.py
Normal file
31
backend/checkins/serializers.py
Normal file
@@ -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")
|
||||
190
backend/checkins/test_api.py
Normal file
190
backend/checkins/test_api.py
Normal file
@@ -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),
|
||||
)
|
||||
9
backend/checkins/urls.py
Normal file
9
backend/checkins/urls.py
Normal file
@@ -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"),
|
||||
]
|
||||
110
backend/checkins/views.py
Normal file
110
backend/checkins/views.py
Normal file
@@ -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)
|
||||
Reference in New Issue
Block a user