feat: add check-in REST API

This commit is contained in:
2026-04-29 10:01:06 +02:00
parent 09e3243034
commit 9560963139
5 changed files with 341 additions and 0 deletions

View File

@@ -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")),
] ]

View 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")

View 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
View 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
View 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)