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/health/", health, name="health"),
|
||||||
path("api/", include("shows.urls")),
|
path("api/", include("shows.urls")),
|
||||||
path("api/", include("bookings.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