generated from bisco/codex-bootstrap
Merge branch 'feature/admin-manual-reservation-actions' into develop
This commit is contained in:
@@ -1,8 +1,23 @@
|
||||
from django import forms
|
||||
from django.contrib import admin, messages
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.template.response import TemplateResponse
|
||||
from django.urls import path, reverse
|
||||
from django.utils.html import format_html
|
||||
|
||||
from .models import Reservation, ReservationToken
|
||||
from .services import PerformanceNotAvailable, create_pending_reservation
|
||||
from .services import (
|
||||
AlreadyConfirmedReservation,
|
||||
NotEnoughSeats,
|
||||
PerformanceNotAvailable,
|
||||
ReservationNotConfirmed,
|
||||
ReservationNotPending,
|
||||
confirm_reservation_manually,
|
||||
create_pending_reservation,
|
||||
issue_check_in_access_for_reservation,
|
||||
)
|
||||
from checkins.models import CheckIn
|
||||
from checkins.services import AlreadyCheckedIn, confirm_check_in_for_reservation
|
||||
|
||||
|
||||
class ReservationAdminForm(forms.ModelForm):
|
||||
@@ -83,6 +98,8 @@ class ReservationAdmin(admin.ModelAdmin):
|
||||
"venue_name",
|
||||
"confirmation_state_display",
|
||||
"check_in_state_display",
|
||||
"check_in_access_display",
|
||||
"operational_tools",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"confirmed_at",
|
||||
@@ -90,6 +107,27 @@ class ReservationAdmin(admin.ModelAdmin):
|
||||
)
|
||||
autocomplete_fields = ("performance",)
|
||||
|
||||
def get_urls(self):
|
||||
urls = super().get_urls()
|
||||
custom_urls = [
|
||||
path(
|
||||
"<path:object_id>/manual-confirm/",
|
||||
self.admin_site.admin_view(self.manual_confirm_view),
|
||||
name="bookings_reservation_manual_confirm",
|
||||
),
|
||||
path(
|
||||
"<path:object_id>/check-in-pass/",
|
||||
self.admin_site.admin_view(self.check_in_pass_view),
|
||||
name="bookings_reservation_check_in_pass",
|
||||
),
|
||||
path(
|
||||
"<path:object_id>/manual-check-in/",
|
||||
self.admin_site.admin_view(self.manual_check_in_view),
|
||||
name="bookings_reservation_manual_check_in",
|
||||
),
|
||||
]
|
||||
return custom_urls + urls
|
||||
|
||||
def get_queryset(self, request):
|
||||
return super().get_queryset(request).select_related(
|
||||
"performance",
|
||||
@@ -149,6 +187,8 @@ class ReservationAdmin(admin.ModelAdmin):
|
||||
"status",
|
||||
"confirmation_state_display",
|
||||
"check_in_state_display",
|
||||
"check_in_access_display",
|
||||
"operational_tools",
|
||||
"confirmed_at",
|
||||
"qr_code_generated_at",
|
||||
),
|
||||
@@ -228,6 +268,183 @@ class ReservationAdmin(admin.ModelAdmin):
|
||||
def check_in_state_display(self, obj):
|
||||
return "Checked in" if hasattr(obj, "check_in") else "Not checked in"
|
||||
|
||||
@admin.display(description="Check-in access")
|
||||
def check_in_access_display(self, obj):
|
||||
if obj.status != Reservation.Status.CONFIRMED:
|
||||
return "Available after confirmation"
|
||||
|
||||
url = reverse("admin:bookings_reservation_check_in_pass", args=[obj.pk])
|
||||
return format_html('<a href="{}">Generate operational QR / URL</a>', url)
|
||||
|
||||
@admin.display(description="Operational tools")
|
||||
def operational_tools(self, obj):
|
||||
links = []
|
||||
if obj.status == Reservation.Status.PENDING:
|
||||
confirm_url = reverse("admin:bookings_reservation_manual_confirm", args=[obj.pk])
|
||||
links.append(f'<a href="{confirm_url}">Confirm reservation now</a>')
|
||||
if obj.status == Reservation.Status.CONFIRMED and not hasattr(obj, "check_in"):
|
||||
check_in_url = reverse("admin:bookings_reservation_manual_check_in", args=[obj.pk])
|
||||
links.append(f'<a href="{check_in_url}">Mark as checked in</a>')
|
||||
if obj.status == Reservation.Status.CONFIRMED:
|
||||
pass_url = reverse("admin:bookings_reservation_check_in_pass", args=[obj.pk])
|
||||
links.append(f'<a href="{pass_url}">Show QR / check-in URL</a>')
|
||||
if not links:
|
||||
return "-"
|
||||
return format_html(" | ".join(links))
|
||||
|
||||
def manual_confirm_view(self, request, object_id):
|
||||
reservation = self.get_object(request, object_id)
|
||||
if reservation is None:
|
||||
return self._redirect_to_changelist()
|
||||
|
||||
if request.method == "POST":
|
||||
try:
|
||||
result = confirm_reservation_manually(reservation_id=reservation.pk)
|
||||
except AlreadyConfirmedReservation:
|
||||
self.message_user(request, "Reservation is already confirmed.", level=messages.WARNING)
|
||||
return HttpResponseRedirect(self._change_url(reservation.pk))
|
||||
except ReservationNotPending as exc:
|
||||
self.message_user(request, str(exc), level=messages.ERROR)
|
||||
return HttpResponseRedirect(self._change_url(reservation.pk))
|
||||
except NotEnoughSeats as exc:
|
||||
self.message_user(request, str(exc), level=messages.ERROR)
|
||||
return HttpResponseRedirect(self._change_url(reservation.pk))
|
||||
|
||||
self.message_user(
|
||||
request,
|
||||
"Reservation confirmed manually. A check-in token was generated for operations.",
|
||||
level=messages.SUCCESS,
|
||||
)
|
||||
return self._render_operation_page(
|
||||
request,
|
||||
reservation=result.reservation,
|
||||
title="Reservation confirmed manually",
|
||||
heading="Reservation confirmed manually",
|
||||
description=(
|
||||
"Use this operational QR code or check-in URL only when the guest cannot complete the normal email flow."
|
||||
),
|
||||
qr_code_image=result.qr_code_image,
|
||||
qr_code_url=result.qr_code_url,
|
||||
)
|
||||
|
||||
return self._render_operation_page(
|
||||
request,
|
||||
reservation=reservation,
|
||||
title="Confirm reservation manually",
|
||||
heading="Confirm reservation manually",
|
||||
description=(
|
||||
"This bypasses guest email confirmation, but still rechecks booking availability and generates a check-in token."
|
||||
),
|
||||
submit_label="Confirm reservation",
|
||||
)
|
||||
|
||||
def check_in_pass_view(self, request, object_id):
|
||||
reservation = self.get_object(request, object_id)
|
||||
if reservation is None:
|
||||
return self._redirect_to_changelist()
|
||||
|
||||
if request.method == "POST":
|
||||
try:
|
||||
result = issue_check_in_access_for_reservation(reservation_id=reservation.pk)
|
||||
except ReservationNotConfirmed as exc:
|
||||
self.message_user(request, str(exc), level=messages.ERROR)
|
||||
return HttpResponseRedirect(self._change_url(reservation.pk))
|
||||
|
||||
self.message_user(
|
||||
request,
|
||||
"Operational check-in access generated for this reservation.",
|
||||
level=messages.SUCCESS,
|
||||
)
|
||||
return self._render_operation_page(
|
||||
request,
|
||||
reservation=result.reservation,
|
||||
title="Operational check-in access",
|
||||
heading="Operational check-in access",
|
||||
description=(
|
||||
"This page shows a one-time operational QR code and check-in URL for manual testing or guest support."
|
||||
),
|
||||
qr_code_image=result.qr_code_image,
|
||||
qr_code_url=result.qr_code_url,
|
||||
)
|
||||
|
||||
return self._render_operation_page(
|
||||
request,
|
||||
reservation=reservation,
|
||||
title="Generate operational check-in access",
|
||||
heading="Generate operational check-in access",
|
||||
description=(
|
||||
"Generate a fresh QR code and check-in URL without exposing token hashes in normal admin screens."
|
||||
),
|
||||
submit_label="Generate QR / URL",
|
||||
)
|
||||
|
||||
def manual_check_in_view(self, request, object_id):
|
||||
reservation = self.get_object(request, object_id)
|
||||
if reservation is None:
|
||||
return self._redirect_to_changelist()
|
||||
|
||||
if request.method == "POST":
|
||||
try:
|
||||
confirm_check_in_for_reservation(
|
||||
reservation_id=reservation.pk,
|
||||
staff_user=request.user,
|
||||
source=CheckIn.Source.MANUAL,
|
||||
)
|
||||
except ReservationNotConfirmed as exc:
|
||||
self.message_user(request, str(exc), level=messages.ERROR)
|
||||
return HttpResponseRedirect(self._change_url(reservation.pk))
|
||||
except AlreadyCheckedIn:
|
||||
self.message_user(request, "Reservation has already been checked in.", level=messages.WARNING)
|
||||
return HttpResponseRedirect(self._change_url(reservation.pk))
|
||||
|
||||
self.message_user(request, "Reservation marked as checked in.", level=messages.SUCCESS)
|
||||
return HttpResponseRedirect(self._change_url(reservation.pk))
|
||||
|
||||
return self._render_operation_page(
|
||||
request,
|
||||
reservation=reservation,
|
||||
title="Mark reservation as checked in",
|
||||
heading="Mark reservation as checked in",
|
||||
description="Use this only for staff-side emergency or desk check-in workflows.",
|
||||
submit_label="Confirm check-in",
|
||||
)
|
||||
|
||||
def _change_url(self, reservation_id):
|
||||
return reverse("admin:bookings_reservation_change", args=[reservation_id])
|
||||
|
||||
def _redirect_to_changelist(self):
|
||||
return HttpResponseRedirect(reverse("admin:bookings_reservation_changelist"))
|
||||
|
||||
def _render_operation_page(
|
||||
self,
|
||||
request,
|
||||
*,
|
||||
reservation,
|
||||
title,
|
||||
heading,
|
||||
description,
|
||||
submit_label=None,
|
||||
qr_code_image=None,
|
||||
qr_code_url=None,
|
||||
):
|
||||
context = {
|
||||
**self.admin_site.each_context(request),
|
||||
"opts": self.model._meta,
|
||||
"original": reservation,
|
||||
"title": title,
|
||||
"heading": heading,
|
||||
"description": description,
|
||||
"submit_label": submit_label,
|
||||
"qr_code_image": qr_code_image,
|
||||
"qr_code_url": qr_code_url,
|
||||
"change_url": self._change_url(reservation.pk),
|
||||
}
|
||||
return TemplateResponse(
|
||||
request,
|
||||
"admin/bookings/reservation/operation.html",
|
||||
context,
|
||||
)
|
||||
|
||||
|
||||
@admin.register(ReservationToken)
|
||||
class ReservationTokenAdmin(admin.ModelAdmin):
|
||||
|
||||
@@ -44,6 +44,10 @@ class ReservationNotConfirmed(BookingServiceError):
|
||||
pass
|
||||
|
||||
|
||||
class ReservationNotPending(BookingServiceError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PendingReservationResult:
|
||||
reservation: Reservation
|
||||
@@ -55,7 +59,7 @@ class PendingReservationResult:
|
||||
@dataclass(frozen=True)
|
||||
class ConfirmedReservationResult:
|
||||
reservation: Reservation
|
||||
confirmation_token: ReservationToken
|
||||
confirmation_token: ReservationToken | None
|
||||
check_in_token: ReservationToken
|
||||
raw_check_in_token: str
|
||||
available_seats: int
|
||||
@@ -70,6 +74,15 @@ class ReservationQRResult:
|
||||
qr_code_url: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReservationCheckInAccessResult:
|
||||
reservation: Reservation
|
||||
check_in_token: ReservationToken
|
||||
raw_check_in_token: str
|
||||
qr_code_image: str
|
||||
qr_code_url: str
|
||||
|
||||
|
||||
def calculate_available_seats(performance):
|
||||
confirmed_seats = (
|
||||
Reservation.objects.filter(
|
||||
@@ -171,39 +184,55 @@ def confirm_reservation_from_token(raw_token):
|
||||
raise InvalidToken("Confirmation token is not valid for this reservation.")
|
||||
|
||||
if not token_was_expired:
|
||||
performance = _get_locked_bookable_performance(reservation.performance_id)
|
||||
available_seats = calculate_available_seats(performance)
|
||||
if reservation.party_size > available_seats:
|
||||
raise NotEnoughSeats("Not enough seats are available for this performance.")
|
||||
|
||||
reservation.confirm()
|
||||
confirmation_token.mark_used()
|
||||
|
||||
check_in_token, raw_check_in_token = ReservationToken.create_token(
|
||||
result = _confirm_pending_reservation(
|
||||
reservation=reservation,
|
||||
purpose=ReservationToken.Purpose.CHECK_IN,
|
||||
expires_at=performance.starts_at + CHECK_IN_TOKEN_AFTER_PERFORMANCE_TTL,
|
||||
confirmation_token=confirmation_token,
|
||||
)
|
||||
reservation.qr_code_generated_at = timezone.now()
|
||||
reservation.save(update_fields=["qr_code_generated_at", "updated_at"])
|
||||
|
||||
if token_was_expired:
|
||||
raise ExpiredToken("Confirmation token has expired.")
|
||||
|
||||
return ConfirmedReservationResult(
|
||||
return result
|
||||
|
||||
|
||||
def confirm_reservation_manually(*, reservation_id):
|
||||
with transaction.atomic():
|
||||
reservation = Reservation.objects.select_for_update().get(pk=reservation_id)
|
||||
if reservation.status == Reservation.Status.CONFIRMED:
|
||||
raise AlreadyConfirmedReservation("Reservation is already confirmed.")
|
||||
if reservation.status != Reservation.Status.PENDING:
|
||||
raise ReservationNotPending("Only pending reservations can be confirmed manually.")
|
||||
|
||||
confirmation_token = (
|
||||
ReservationToken.objects.select_for_update()
|
||||
.filter(
|
||||
reservation=reservation,
|
||||
purpose=ReservationToken.Purpose.CONFIRMATION,
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.first()
|
||||
)
|
||||
return _confirm_pending_reservation(
|
||||
reservation=reservation,
|
||||
confirmation_token=confirmation_token,
|
||||
check_in_token=check_in_token,
|
||||
raw_check_in_token=raw_check_in_token,
|
||||
available_seats=available_seats - reservation.party_size,
|
||||
qr_code_image=generate_check_in_qr_base64(
|
||||
reservation=reservation,
|
||||
raw_check_in_token=raw_check_in_token,
|
||||
),
|
||||
qr_code_url=build_check_in_preview_url(raw_check_in_token),
|
||||
)
|
||||
|
||||
|
||||
def issue_check_in_access_for_reservation(*, reservation_id):
|
||||
with transaction.atomic():
|
||||
reservation = (
|
||||
Reservation.objects.select_for_update()
|
||||
.select_related("performance__show", "performance__venue")
|
||||
.get(pk=reservation_id)
|
||||
)
|
||||
if reservation.status != Reservation.Status.CONFIRMED:
|
||||
raise ReservationNotConfirmed("Reservation must be confirmed before QR retrieval.")
|
||||
|
||||
result = _issue_check_in_access(reservation)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def retrieve_reservation_qr_from_token(raw_token):
|
||||
try:
|
||||
check_in_token = ReservationToken.objects.select_related("reservation").get_valid_token(
|
||||
@@ -237,3 +266,48 @@ def _get_locked_bookable_performance(performance_id):
|
||||
raise PerformanceNotAvailable("Performance is not available for booking.")
|
||||
|
||||
return performance
|
||||
|
||||
|
||||
def _confirm_pending_reservation(*, reservation, confirmation_token=None):
|
||||
performance = _get_locked_bookable_performance(reservation.performance_id)
|
||||
available_seats = calculate_available_seats(performance)
|
||||
if reservation.party_size > available_seats:
|
||||
raise NotEnoughSeats("Not enough seats are available for this performance.")
|
||||
|
||||
reservation.confirm()
|
||||
if confirmation_token is not None and not confirmation_token.is_used:
|
||||
confirmation_token.mark_used()
|
||||
|
||||
check_in_access = _issue_check_in_access(reservation)
|
||||
|
||||
return ConfirmedReservationResult(
|
||||
reservation=reservation,
|
||||
confirmation_token=confirmation_token,
|
||||
check_in_token=check_in_access.check_in_token,
|
||||
raw_check_in_token=check_in_access.raw_check_in_token,
|
||||
available_seats=available_seats - reservation.party_size,
|
||||
qr_code_image=check_in_access.qr_code_image,
|
||||
qr_code_url=check_in_access.qr_code_url,
|
||||
)
|
||||
|
||||
|
||||
def _issue_check_in_access(reservation):
|
||||
check_in_token, raw_check_in_token = ReservationToken.create_token(
|
||||
reservation=reservation,
|
||||
purpose=ReservationToken.Purpose.CHECK_IN,
|
||||
expires_at=reservation.performance.starts_at + CHECK_IN_TOKEN_AFTER_PERFORMANCE_TTL,
|
||||
)
|
||||
if reservation.qr_code_generated_at is None:
|
||||
reservation.qr_code_generated_at = timezone.now()
|
||||
reservation.save(update_fields=["qr_code_generated_at", "updated_at"])
|
||||
|
||||
return ReservationCheckInAccessResult(
|
||||
reservation=reservation,
|
||||
check_in_token=check_in_token,
|
||||
raw_check_in_token=raw_check_in_token,
|
||||
qr_code_image=generate_check_in_qr_base64(
|
||||
reservation=reservation,
|
||||
raw_check_in_token=raw_check_in_token,
|
||||
),
|
||||
qr_code_url=build_check_in_preview_url(raw_check_in_token),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
{% extends "admin/base_site.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div id="content-main">
|
||||
<h1>{{ heading }}</h1>
|
||||
<p>{{ description }}</p>
|
||||
|
||||
<p>
|
||||
<strong>Reservation:</strong> {{ original.name }}<br>
|
||||
<strong>Show:</strong> {{ original.performance.show.title }}<br>
|
||||
<strong>Performance:</strong> {{ original.performance.starts_at }}<br>
|
||||
<strong>Party size:</strong> {{ original.party_size }}
|
||||
</p>
|
||||
|
||||
{% if qr_code_url %}
|
||||
<p>
|
||||
<strong>Check-in URL:</strong><br>
|
||||
<a href="{{ qr_code_url }}">{{ qr_code_url }}</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if qr_code_image %}
|
||||
<p><img src="{{ qr_code_image }}" alt="Operational QR code"></p>
|
||||
{% endif %}
|
||||
|
||||
{% if submit_label %}
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<input type="submit" value="{{ submit_label }}" class="default">
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<p><a href="{{ change_url }}">Back to reservation</a></p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -8,6 +8,7 @@ from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from bookings.models import Reservation, ReservationToken
|
||||
from checkins.models import CheckIn
|
||||
from shows.models import Performance, Show, Venue
|
||||
|
||||
|
||||
@@ -109,3 +110,53 @@ class ReservationAdminTests(TestCase):
|
||||
self.assertContains(change_response, token.get_purpose_display())
|
||||
self.assertContains(change_response, "Expires at")
|
||||
self.assertContains(change_response, "Used at")
|
||||
|
||||
def test_admin_can_confirm_pending_reservation_manually_and_get_qr_access(self):
|
||||
reservation = Reservation.objects.create(
|
||||
performance=self.performance,
|
||||
name="Maria Rossi",
|
||||
email="maria@example.com",
|
||||
party_size=2,
|
||||
)
|
||||
confirmation_token, _ = ReservationToken.create_token(
|
||||
reservation=reservation,
|
||||
purpose=ReservationToken.Purpose.CONFIRMATION,
|
||||
expires_at=timezone.now() + timedelta(hours=2),
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
reverse("admin:bookings_reservation_manual_confirm", args=[reservation.id]),
|
||||
)
|
||||
|
||||
reservation.refresh_from_db()
|
||||
confirmation_token.refresh_from_db()
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(reservation.status, Reservation.Status.CONFIRMED)
|
||||
self.assertIsNotNone(confirmation_token.used_at)
|
||||
self.assertContains(response, "Reservation confirmed manually")
|
||||
self.assertContains(response, "/api/check-ins/preview/?token=")
|
||||
self.assertTrue(
|
||||
ReservationToken.objects.filter(
|
||||
reservation=reservation,
|
||||
purpose=ReservationToken.Purpose.CHECK_IN,
|
||||
).exists()
|
||||
)
|
||||
|
||||
def test_admin_can_mark_confirmed_reservation_as_checked_in(self):
|
||||
reservation = Reservation.objects.create(
|
||||
performance=self.performance,
|
||||
name="Checked Guest",
|
||||
email="checked@example.com",
|
||||
party_size=1,
|
||||
status=Reservation.Status.CONFIRMED,
|
||||
confirmed_at=timezone.now(),
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
reverse("admin:bookings_reservation_manual_check_in", args=[reservation.id]),
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 302)
|
||||
check_in = CheckIn.objects.get(reservation=reservation)
|
||||
self.assertEqual(check_in.checked_in_by, self.admin_user)
|
||||
self.assertEqual(check_in.source, CheckIn.Source.MANUAL)
|
||||
|
||||
@@ -15,10 +15,13 @@ from bookings.services import (
|
||||
ExpiredToken,
|
||||
InvalidToken,
|
||||
NotEnoughSeats,
|
||||
ReservationNotConfirmed,
|
||||
calculate_available_seats,
|
||||
confirm_reservation_manually,
|
||||
confirm_reservation_from_token,
|
||||
create_pending_reservation,
|
||||
generate_confirmation_token,
|
||||
issue_check_in_access_for_reservation,
|
||||
retrieve_reservation_qr_from_token,
|
||||
)
|
||||
from shows.models import Performance, Show, Venue
|
||||
@@ -382,6 +385,54 @@ class BookingServiceTests(TestCase):
|
||||
|
||||
self.assertTrue(any("FOR UPDATE" in query["sql"] for query in queries))
|
||||
|
||||
@override_settings(SITE_BASE_URL="https://tickets.azionelab.example")
|
||||
def test_manual_confirmation_reuses_capacity_rules_and_generates_check_in_access(self):
|
||||
reservation = self.create_reservation()
|
||||
confirmation_token, _ = generate_confirmation_token(reservation)
|
||||
|
||||
result = confirm_reservation_manually(reservation_id=reservation.id)
|
||||
|
||||
reservation.refresh_from_db()
|
||||
confirmation_token.refresh_from_db()
|
||||
self.assertEqual(reservation.status, Reservation.Status.CONFIRMED)
|
||||
self.assertIsNotNone(confirmation_token.used_at)
|
||||
self.assertEqual(result.check_in_token.purpose, ReservationToken.Purpose.CHECK_IN)
|
||||
self.assertTrue(
|
||||
result.qr_code_url.startswith(
|
||||
"https://tickets.azionelab.example/api/check-ins/preview/?token="
|
||||
)
|
||||
)
|
||||
|
||||
def test_manual_confirmation_rejects_non_pending_reservation(self):
|
||||
reservation = self.create_reservation(
|
||||
status=Reservation.Status.CONFIRMED,
|
||||
confirmed_at=timezone.now(),
|
||||
)
|
||||
|
||||
with self.assertRaises(AlreadyConfirmedReservation):
|
||||
confirm_reservation_manually(reservation_id=reservation.id)
|
||||
|
||||
@override_settings(SITE_BASE_URL="https://tickets.azionelab.example")
|
||||
def test_issue_check_in_access_requires_confirmed_reservation(self):
|
||||
reservation = self.create_reservation()
|
||||
|
||||
with self.assertRaises(ReservationNotConfirmed):
|
||||
issue_check_in_access_for_reservation(reservation_id=reservation.id)
|
||||
|
||||
@override_settings(SITE_BASE_URL="https://tickets.azionelab.example")
|
||||
def test_issue_check_in_access_generates_qr_for_confirmed_reservation(self):
|
||||
reservation = self.create_reservation(
|
||||
status=Reservation.Status.CONFIRMED,
|
||||
confirmed_at=timezone.now(),
|
||||
)
|
||||
|
||||
result = issue_check_in_access_for_reservation(reservation_id=reservation.id)
|
||||
|
||||
self.assertEqual(result.reservation, reservation)
|
||||
self.assertEqual(result.check_in_token.purpose, ReservationToken.Purpose.CHECK_IN)
|
||||
self.assertTrue(result.qr_code_image.startswith("data:image/png;base64,"))
|
||||
self.assertIn("/api/check-ins/preview/?token=", result.qr_code_url)
|
||||
|
||||
def create_reservation(self, **overrides):
|
||||
data = {
|
||||
"performance": self.performance,
|
||||
|
||||
@@ -75,6 +75,29 @@ def confirm_check_in_from_token(raw_token, *, staff_user, source=CheckIn.Source.
|
||||
return CheckInResult(check_in=check_in, preview=_build_preview(reservation))
|
||||
|
||||
|
||||
def confirm_check_in_for_reservation(*, reservation_id, staff_user, source=CheckIn.Source.MANUAL):
|
||||
_validate_staff_user(staff_user)
|
||||
|
||||
with transaction.atomic():
|
||||
reservation = (
|
||||
Reservation.objects.select_for_update()
|
||||
.select_related("performance__show", "performance__venue")
|
||||
.get(pk=reservation_id)
|
||||
)
|
||||
_validate_reservation_for_check_in(reservation)
|
||||
|
||||
try:
|
||||
check_in = CheckIn.objects.create(
|
||||
reservation=reservation,
|
||||
checked_in_by=staff_user,
|
||||
source=source,
|
||||
)
|
||||
except IntegrityError as exc:
|
||||
raise AlreadyCheckedIn("Reservation has already been checked in.") from exc
|
||||
|
||||
return CheckInResult(check_in=check_in, preview=_build_preview(reservation))
|
||||
|
||||
|
||||
def _validate_staff_user(staff_user):
|
||||
if staff_user is None:
|
||||
raise MissingStaffUser("A staff user is required for check-in.")
|
||||
|
||||
@@ -11,6 +11,7 @@ from checkins.services import (
|
||||
InvalidToken,
|
||||
MissingStaffUser,
|
||||
ReservationNotConfirmed,
|
||||
confirm_check_in_for_reservation,
|
||||
confirm_check_in_from_token,
|
||||
preview_check_in_token,
|
||||
)
|
||||
@@ -125,6 +126,27 @@ class CheckInServiceTests(TestCase):
|
||||
with self.assertRaises(MissingStaffUser):
|
||||
confirm_check_in_from_token(raw_token, staff_user=None)
|
||||
|
||||
def test_manual_check_in_by_reservation_id_succeeds_for_confirmed_reservation(self):
|
||||
reservation = self.create_reservation()
|
||||
|
||||
result = confirm_check_in_for_reservation(
|
||||
reservation_id=reservation.id,
|
||||
staff_user=self.staff_user,
|
||||
)
|
||||
|
||||
self.assertEqual(result.check_in.reservation, reservation)
|
||||
self.assertEqual(result.check_in.checked_in_by, self.staff_user)
|
||||
self.assertEqual(result.check_in.source, CheckIn.Source.MANUAL)
|
||||
|
||||
def test_manual_check_in_by_reservation_id_rejects_pending_reservation(self):
|
||||
reservation = self.create_reservation(status=Reservation.Status.PENDING, confirmed_at=None)
|
||||
|
||||
with self.assertRaises(ReservationNotConfirmed):
|
||||
confirm_check_in_for_reservation(
|
||||
reservation_id=reservation.id,
|
||||
staff_user=self.staff_user,
|
||||
)
|
||||
|
||||
def test_check_in_rejects_confirmation_token_even_for_confirmed_reservation(self):
|
||||
reservation = self.create_reservation()
|
||||
_, raw_token = ReservationToken.create_token(
|
||||
|
||||
@@ -114,6 +114,14 @@ This operational flow should still follow the same backend rules as the public b
|
||||
5. after the reservation transaction commits, the backend sends the standard confirmation email;
|
||||
6. the guest still confirms through the email link before the reservation becomes confirmed and usable for check-in.
|
||||
|
||||
For operational testing or guest-support exceptions, Django admin also provides staff-only manual tools:
|
||||
|
||||
1. staff may confirm a pending reservation from the reservation admin page;
|
||||
2. manual confirmation still rechecks booking availability before confirming;
|
||||
3. the backend generates the same `check_in` token type used by the normal confirmation flow;
|
||||
4. admin can generate a one-time operational QR code and check-in URL without showing token hashes in normal admin screens;
|
||||
5. staff may mark a confirmed reservation as checked in from admin when the browser/mobile check-in flow is unavailable.
|
||||
|
||||
## Duplicate Check-In
|
||||
|
||||
If the same QR code is scanned again:
|
||||
|
||||
Reference in New Issue
Block a user