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 import forms
|
||||||
from django.contrib import admin, messages
|
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 .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):
|
class ReservationAdminForm(forms.ModelForm):
|
||||||
@@ -83,6 +98,8 @@ class ReservationAdmin(admin.ModelAdmin):
|
|||||||
"venue_name",
|
"venue_name",
|
||||||
"confirmation_state_display",
|
"confirmation_state_display",
|
||||||
"check_in_state_display",
|
"check_in_state_display",
|
||||||
|
"check_in_access_display",
|
||||||
|
"operational_tools",
|
||||||
"created_at",
|
"created_at",
|
||||||
"updated_at",
|
"updated_at",
|
||||||
"confirmed_at",
|
"confirmed_at",
|
||||||
@@ -90,6 +107,27 @@ class ReservationAdmin(admin.ModelAdmin):
|
|||||||
)
|
)
|
||||||
autocomplete_fields = ("performance",)
|
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):
|
def get_queryset(self, request):
|
||||||
return super().get_queryset(request).select_related(
|
return super().get_queryset(request).select_related(
|
||||||
"performance",
|
"performance",
|
||||||
@@ -149,6 +187,8 @@ class ReservationAdmin(admin.ModelAdmin):
|
|||||||
"status",
|
"status",
|
||||||
"confirmation_state_display",
|
"confirmation_state_display",
|
||||||
"check_in_state_display",
|
"check_in_state_display",
|
||||||
|
"check_in_access_display",
|
||||||
|
"operational_tools",
|
||||||
"confirmed_at",
|
"confirmed_at",
|
||||||
"qr_code_generated_at",
|
"qr_code_generated_at",
|
||||||
),
|
),
|
||||||
@@ -228,6 +268,183 @@ class ReservationAdmin(admin.ModelAdmin):
|
|||||||
def check_in_state_display(self, obj):
|
def check_in_state_display(self, obj):
|
||||||
return "Checked in" if hasattr(obj, "check_in") else "Not checked in"
|
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)
|
@admin.register(ReservationToken)
|
||||||
class ReservationTokenAdmin(admin.ModelAdmin):
|
class ReservationTokenAdmin(admin.ModelAdmin):
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ class ReservationNotConfirmed(BookingServiceError):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ReservationNotPending(BookingServiceError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class PendingReservationResult:
|
class PendingReservationResult:
|
||||||
reservation: Reservation
|
reservation: Reservation
|
||||||
@@ -55,7 +59,7 @@ class PendingReservationResult:
|
|||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ConfirmedReservationResult:
|
class ConfirmedReservationResult:
|
||||||
reservation: Reservation
|
reservation: Reservation
|
||||||
confirmation_token: ReservationToken
|
confirmation_token: ReservationToken | None
|
||||||
check_in_token: ReservationToken
|
check_in_token: ReservationToken
|
||||||
raw_check_in_token: str
|
raw_check_in_token: str
|
||||||
available_seats: int
|
available_seats: int
|
||||||
@@ -70,6 +74,15 @@ class ReservationQRResult:
|
|||||||
qr_code_url: str
|
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):
|
def calculate_available_seats(performance):
|
||||||
confirmed_seats = (
|
confirmed_seats = (
|
||||||
Reservation.objects.filter(
|
Reservation.objects.filter(
|
||||||
@@ -171,37 +184,53 @@ def confirm_reservation_from_token(raw_token):
|
|||||||
raise InvalidToken("Confirmation token is not valid for this reservation.")
|
raise InvalidToken("Confirmation token is not valid for this reservation.")
|
||||||
|
|
||||||
if not token_was_expired:
|
if not token_was_expired:
|
||||||
performance = _get_locked_bookable_performance(reservation.performance_id)
|
result = _confirm_pending_reservation(
|
||||||
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(
|
|
||||||
reservation=reservation,
|
reservation=reservation,
|
||||||
purpose=ReservationToken.Purpose.CHECK_IN,
|
confirmation_token=confirmation_token,
|
||||||
expires_at=performance.starts_at + CHECK_IN_TOKEN_AFTER_PERFORMANCE_TTL,
|
|
||||||
)
|
)
|
||||||
reservation.qr_code_generated_at = timezone.now()
|
|
||||||
reservation.save(update_fields=["qr_code_generated_at", "updated_at"])
|
|
||||||
|
|
||||||
if token_was_expired:
|
if token_was_expired:
|
||||||
raise ExpiredToken("Confirmation token has expired.")
|
raise ExpiredToken("Confirmation token has expired.")
|
||||||
|
|
||||||
return ConfirmedReservationResult(
|
return result
|
||||||
reservation=reservation,
|
|
||||||
confirmation_token=confirmation_token,
|
|
||||||
check_in_token=check_in_token,
|
def confirm_reservation_manually(*, reservation_id):
|
||||||
raw_check_in_token=raw_check_in_token,
|
with transaction.atomic():
|
||||||
available_seats=available_seats - reservation.party_size,
|
reservation = Reservation.objects.select_for_update().get(pk=reservation_id)
|
||||||
qr_code_image=generate_check_in_qr_base64(
|
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,
|
reservation=reservation,
|
||||||
raw_check_in_token=raw_check_in_token,
|
confirmation_token=confirmation_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):
|
def retrieve_reservation_qr_from_token(raw_token):
|
||||||
@@ -237,3 +266,48 @@ def _get_locked_bookable_performance(performance_id):
|
|||||||
raise PerformanceNotAvailable("Performance is not available for booking.")
|
raise PerformanceNotAvailable("Performance is not available for booking.")
|
||||||
|
|
||||||
return performance
|
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 django.utils import timezone
|
||||||
|
|
||||||
from bookings.models import Reservation, ReservationToken
|
from bookings.models import Reservation, ReservationToken
|
||||||
|
from checkins.models import CheckIn
|
||||||
from shows.models import Performance, Show, Venue
|
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, token.get_purpose_display())
|
||||||
self.assertContains(change_response, "Expires at")
|
self.assertContains(change_response, "Expires at")
|
||||||
self.assertContains(change_response, "Used 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,
|
ExpiredToken,
|
||||||
InvalidToken,
|
InvalidToken,
|
||||||
NotEnoughSeats,
|
NotEnoughSeats,
|
||||||
|
ReservationNotConfirmed,
|
||||||
calculate_available_seats,
|
calculate_available_seats,
|
||||||
|
confirm_reservation_manually,
|
||||||
confirm_reservation_from_token,
|
confirm_reservation_from_token,
|
||||||
create_pending_reservation,
|
create_pending_reservation,
|
||||||
generate_confirmation_token,
|
generate_confirmation_token,
|
||||||
|
issue_check_in_access_for_reservation,
|
||||||
retrieve_reservation_qr_from_token,
|
retrieve_reservation_qr_from_token,
|
||||||
)
|
)
|
||||||
from shows.models import Performance, Show, Venue
|
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))
|
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):
|
def create_reservation(self, **overrides):
|
||||||
data = {
|
data = {
|
||||||
"performance": self.performance,
|
"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))
|
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):
|
def _validate_staff_user(staff_user):
|
||||||
if staff_user is None:
|
if staff_user is None:
|
||||||
raise MissingStaffUser("A staff user is required for check-in.")
|
raise MissingStaffUser("A staff user is required for check-in.")
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from checkins.services import (
|
|||||||
InvalidToken,
|
InvalidToken,
|
||||||
MissingStaffUser,
|
MissingStaffUser,
|
||||||
ReservationNotConfirmed,
|
ReservationNotConfirmed,
|
||||||
|
confirm_check_in_for_reservation,
|
||||||
confirm_check_in_from_token,
|
confirm_check_in_from_token,
|
||||||
preview_check_in_token,
|
preview_check_in_token,
|
||||||
)
|
)
|
||||||
@@ -125,6 +126,27 @@ class CheckInServiceTests(TestCase):
|
|||||||
with self.assertRaises(MissingStaffUser):
|
with self.assertRaises(MissingStaffUser):
|
||||||
confirm_check_in_from_token(raw_token, staff_user=None)
|
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):
|
def test_check_in_rejects_confirmation_token_even_for_confirmed_reservation(self):
|
||||||
reservation = self.create_reservation()
|
reservation = self.create_reservation()
|
||||||
_, raw_token = ReservationToken.create_token(
|
_, 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;
|
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.
|
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
|
## Duplicate Check-In
|
||||||
|
|
||||||
If the same QR code is scanned again:
|
If the same QR code is scanned again:
|
||||||
|
|||||||
Reference in New Issue
Block a user