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 ( 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 Meta: model = Reservation fields = ("performance", "name", "email", "phone", "party_size", "notes") def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.fields["performance"].help_text = ( "Choose the exact performance. A pending reservation and confirmation email will be created." ) self.fields["party_size"].help_text = "Seats requested for this guest or group." self.fields["notes"].help_text = "Optional internal note for staff." def clean(self): cleaned_data = super().clean() performance = cleaned_data.get("performance") party_size = cleaned_data.get("party_size") if performance and not performance.is_booking_enabled: self.add_error("performance", "Booking is currently disabled for this performance.") if performance and party_size: available_seats = performance.available_seats() if party_size > available_seats: self.add_error( "party_size", f"Only {available_seats} seats are currently available for this performance.", ) return cleaned_data class ReservationTokenInline(admin.TabularInline): model = ReservationToken extra = 0 readonly_fields = ("used_at", "created_at") fields = ("purpose", "expires_at", "used_at", "created_at") can_delete = False @admin.register(Reservation) class ReservationAdmin(admin.ModelAdmin): form = ReservationAdminForm list_display = ( "show_title", "performance_starts_at", "venue_name", "name", "email", "party_size", "status", "confirmation_state_display", "check_in_state_display", ) list_filter = ( "status", "performance__show", "performance__venue", "performance__starts_at", "confirmed_at", "qr_code_generated_at", ) search_fields = ( "name", "email", "phone", "performance__show__title", "performance__venue__name", ) inlines = (ReservationTokenInline,) list_select_related = ("performance", "performance__show", "performance__venue") readonly_fields = ( "status", "show_title", "performance_starts_at", "venue_name", "confirmation_state_display", "check_in_state_display", "check_in_access_display", "operational_tools", "created_at", "updated_at", "confirmed_at", "qr_code_generated_at", ) 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", "performance__show", "performance__venue", "check_in", ) def get_inlines(self, request, obj): if obj is None: return () return super().get_inlines(request, obj) def get_changeform_initial_data(self, request): initial = super().get_changeform_initial_data(request) performance_id = request.GET.get("performance") if performance_id: initial["performance"] = performance_id return initial def get_fieldsets(self, request, obj=None): if obj is None: return ( ( "Create manual reservation", { "description": ( "Use this form when staff needs to enter a reservation manually. " "The reservation stays pending and the standard confirmation email is sent automatically." ), "fields": ("performance", "name", "email", "phone", "party_size", "notes"), }, ), ) return ( ( "Reservation", { "fields": ( "performance", "show_title", "performance_starts_at", "venue_name", "name", "email", "phone", "party_size", "notes", ), }, ), ( "Operational status", { "fields": ( "status", "confirmation_state_display", "check_in_state_display", "check_in_access_display", "operational_tools", "confirmed_at", "qr_code_generated_at", ), }, ), ( "Timestamps", { "classes": ("collapse",), "fields": ("created_at", "updated_at"), }, ), ) def save_model(self, request, obj, form, change): if change: super().save_model(request, obj, form, change) return try: result = create_pending_reservation( performance_id=form.cleaned_data["performance"].id, name=form.cleaned_data["name"], email=form.cleaned_data["email"], phone=form.cleaned_data.get("phone", ""), party_size=form.cleaned_data["party_size"], notes=form.cleaned_data.get("notes", ""), ) except PerformanceNotAvailable as exc: form.add_error("performance", str(exc)) raise forms.ValidationError(str(exc)) from exc created = result.reservation obj.pk = created.pk obj._state.adding = False obj.performance = created.performance obj.status = created.status obj.name = created.name obj.email = created.email obj.phone = created.phone obj.party_size = created.party_size obj.notes = created.notes obj.created_at = created.created_at obj.updated_at = created.updated_at obj.confirmed_at = created.confirmed_at obj.qr_code_generated_at = created.qr_code_generated_at self.message_user( request, "Pending reservation created. The guest must confirm it from the email link before check-in.", level=messages.SUCCESS, ) @admin.display(description="Show", ordering="performance__show__title") def show_title(self, obj): return obj.performance.show.title @admin.display(description="Performance", ordering="performance__starts_at") def performance_starts_at(self, obj): return obj.performance.starts_at @admin.display(description="Venue", ordering="performance__venue__name") def venue_name(self, obj): return obj.performance.venue.name @admin.display(description="Confirmation") def confirmation_state_display(self, obj): if obj.status == Reservation.Status.CONFIRMED: return "Confirmed" if obj.status == Reservation.Status.EXPIRED: return "Confirmation expired" if obj.status == Reservation.Status.CANCELLED: return "Cancelled" return "Waiting for email confirmation" @admin.display(description="Check-in") 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): list_display = ("reservation", "purpose", "expires_at", "used_at", "created_at") list_filter = ("purpose", "expires_at", "used_at", "created_at") search_fields = ("reservation__name", "reservation__email", "token_hash") readonly_fields = ("created_at", "used_at") exclude = ("token_hash",) list_select_related = ("reservation", "reservation__performance") autocomplete_fields = ("reservation",)