From 7c90da588415e6d6b082ef97ee7a520e5b0dd1e8 Mon Sep 17 00:00:00 2001 From: bisco Date: Wed, 29 Apr 2026 23:47:22 +0200 Subject: [PATCH] feat(admin): add manual reservation operations --- backend/bookings/admin.py | 219 +++++++++++++++++- backend/bookings/services.py | 124 ++++++++-- .../admin/bookings/reservation/operation.html | 35 +++ backend/bookings/test_admin.py | 51 ++++ backend/bookings/test_services.py | 51 ++++ backend/checkins/services.py | 23 ++ backend/checkins/test_services.py | 22 ++ docs/booking-flow.md | 8 + 8 files changed, 507 insertions(+), 26 deletions(-) create mode 100644 backend/bookings/templates/admin/bookings/reservation/operation.html diff --git a/backend/bookings/admin.py b/backend/bookings/admin.py index fefd222..eaca5af 100644 --- a/backend/bookings/admin.py +++ b/backend/bookings/admin.py @@ -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( + "/manual-confirm/", + self.admin_site.admin_view(self.manual_confirm_view), + name="bookings_reservation_manual_confirm", + ), + path( + "/check-in-pass/", + self.admin_site.admin_view(self.check_in_pass_view), + name="bookings_reservation_check_in_pass", + ), + path( + "/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('Generate operational QR / URL', 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'Confirm reservation now') + 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'Mark as checked in') + if obj.status == Reservation.Status.CONFIRMED: + pass_url = reverse("admin:bookings_reservation_check_in_pass", args=[obj.pk]) + links.append(f'Show QR / check-in URL') + 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): diff --git a/backend/bookings/services.py b/backend/bookings/services.py index 9d963f3..e05e2fe 100644 --- a/backend/bookings/services.py +++ b/backend/bookings/services.py @@ -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,37 +184,53 @@ 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( - 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( + 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, - raw_check_in_token=raw_check_in_token, - ), - qr_code_url=build_check_in_preview_url(raw_check_in_token), - ) + confirmation_token=confirmation_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): @@ -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), + ) diff --git a/backend/bookings/templates/admin/bookings/reservation/operation.html b/backend/bookings/templates/admin/bookings/reservation/operation.html new file mode 100644 index 0000000..a123e80 --- /dev/null +++ b/backend/bookings/templates/admin/bookings/reservation/operation.html @@ -0,0 +1,35 @@ +{% extends "admin/base_site.html" %} + +{% block content %} +
+

{{ heading }}

+

{{ description }}

+ +

+ Reservation: {{ original.name }}
+ Show: {{ original.performance.show.title }}
+ Performance: {{ original.performance.starts_at }}
+ Party size: {{ original.party_size }} +

+ + {% if qr_code_url %} +

+ Check-in URL:
+ {{ qr_code_url }} +

+ {% endif %} + + {% if qr_code_image %} +

Operational QR code

+ {% endif %} + + {% if submit_label %} +
+ {% csrf_token %} + +
+ {% endif %} + +

Back to reservation

+
+{% endblock %} diff --git a/backend/bookings/test_admin.py b/backend/bookings/test_admin.py index 016bfe7..4b2eddb 100644 --- a/backend/bookings/test_admin.py +++ b/backend/bookings/test_admin.py @@ -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) diff --git a/backend/bookings/test_services.py b/backend/bookings/test_services.py index b6fe073..9aa0f82 100644 --- a/backend/bookings/test_services.py +++ b/backend/bookings/test_services.py @@ -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, diff --git a/backend/checkins/services.py b/backend/checkins/services.py index cda31d7..9707c97 100644 --- a/backend/checkins/services.py +++ b/backend/checkins/services.py @@ -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.") diff --git a/backend/checkins/test_services.py b/backend/checkins/test_services.py index 36eb789..13d71b3 100644 --- a/backend/checkins/test_services.py +++ b/backend/checkins/test_services.py @@ -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( diff --git a/docs/booking-flow.md b/docs/booking-flow.md index 46b8667..4426edf 100644 --- a/docs/booking-flow.md +++ b/docs/booking-flow.md @@ -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: