Merge branch 'feature/admin-manual-reservation-actions' into develop

This commit is contained in:
bisco
2026-04-29 23:50:17 +02:00
8 changed files with 507 additions and 26 deletions

View File

@@ -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):

View File

@@ -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,39 +184,55 @@ 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
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, reservation=reservation,
confirmation_token=confirmation_token, 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): def retrieve_reservation_qr_from_token(raw_token):
try: try:
check_in_token = ReservationToken.objects.select_related("reservation").get_valid_token( 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.") 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),
)

View File

@@ -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 %}

View File

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

View File

@@ -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,

View File

@@ -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.")

View File

@@ -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(

View File

@@ -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: