generated from bisco/codex-bootstrap
458 lines
17 KiB
Python
458 lines
17 KiB
Python
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(
|
|
"<path:object_id>/manual-confirm/",
|
|
self.admin_site.admin_view(self.manual_confirm_view),
|
|
name="bookings_reservation_manual_confirm",
|
|
),
|
|
path(
|
|
"<path:object_id>/check-in-pass/",
|
|
self.admin_site.admin_view(self.check_in_pass_view),
|
|
name="bookings_reservation_check_in_pass",
|
|
),
|
|
path(
|
|
"<path:object_id>/manual-check-in/",
|
|
self.admin_site.admin_view(self.manual_check_in_view),
|
|
name="bookings_reservation_manual_check_in",
|
|
),
|
|
]
|
|
return custom_urls + urls
|
|
|
|
def get_queryset(self, request):
|
|
return super().get_queryset(request).select_related(
|
|
"performance",
|
|
"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('<a href="{}">Generate operational QR / URL</a>', url)
|
|
|
|
@admin.display(description="Operational tools")
|
|
def operational_tools(self, obj):
|
|
links = []
|
|
if obj.status == Reservation.Status.PENDING:
|
|
confirm_url = reverse("admin:bookings_reservation_manual_confirm", args=[obj.pk])
|
|
links.append(f'<a href="{confirm_url}">Confirm reservation now</a>')
|
|
if obj.status == Reservation.Status.CONFIRMED and not hasattr(obj, "check_in"):
|
|
check_in_url = reverse("admin:bookings_reservation_manual_check_in", args=[obj.pk])
|
|
links.append(f'<a href="{check_in_url}">Mark as checked in</a>')
|
|
if obj.status == Reservation.Status.CONFIRMED:
|
|
pass_url = reverse("admin:bookings_reservation_check_in_pass", args=[obj.pk])
|
|
links.append(f'<a href="{pass_url}">Show QR / check-in URL</a>')
|
|
if not links:
|
|
return "-"
|
|
return format_html(" | ".join(links))
|
|
|
|
def manual_confirm_view(self, request, object_id):
|
|
reservation = self.get_object(request, object_id)
|
|
if reservation is None:
|
|
return self._redirect_to_changelist()
|
|
|
|
if request.method == "POST":
|
|
try:
|
|
result = confirm_reservation_manually(reservation_id=reservation.pk)
|
|
except AlreadyConfirmedReservation:
|
|
self.message_user(request, "Reservation is already confirmed.", level=messages.WARNING)
|
|
return HttpResponseRedirect(self._change_url(reservation.pk))
|
|
except ReservationNotPending as exc:
|
|
self.message_user(request, str(exc), level=messages.ERROR)
|
|
return HttpResponseRedirect(self._change_url(reservation.pk))
|
|
except NotEnoughSeats as exc:
|
|
self.message_user(request, str(exc), level=messages.ERROR)
|
|
return HttpResponseRedirect(self._change_url(reservation.pk))
|
|
|
|
self.message_user(
|
|
request,
|
|
"Reservation confirmed manually. A check-in token was generated for operations.",
|
|
level=messages.SUCCESS,
|
|
)
|
|
return self._render_operation_page(
|
|
request,
|
|
reservation=result.reservation,
|
|
title="Reservation confirmed manually",
|
|
heading="Reservation confirmed manually",
|
|
description=(
|
|
"Use this operational QR code or check-in URL only when the guest cannot complete the normal email flow."
|
|
),
|
|
qr_code_image=result.qr_code_image,
|
|
qr_code_url=result.qr_code_url,
|
|
)
|
|
|
|
return self._render_operation_page(
|
|
request,
|
|
reservation=reservation,
|
|
title="Confirm reservation manually",
|
|
heading="Confirm reservation manually",
|
|
description=(
|
|
"This bypasses guest email confirmation, but still rechecks booking availability and generates a check-in token."
|
|
),
|
|
submit_label="Confirm reservation",
|
|
)
|
|
|
|
def check_in_pass_view(self, request, object_id):
|
|
reservation = self.get_object(request, object_id)
|
|
if reservation is None:
|
|
return self._redirect_to_changelist()
|
|
|
|
if request.method == "POST":
|
|
try:
|
|
result = issue_check_in_access_for_reservation(reservation_id=reservation.pk)
|
|
except ReservationNotConfirmed as exc:
|
|
self.message_user(request, str(exc), level=messages.ERROR)
|
|
return HttpResponseRedirect(self._change_url(reservation.pk))
|
|
|
|
self.message_user(
|
|
request,
|
|
"Operational check-in access generated for this reservation.",
|
|
level=messages.SUCCESS,
|
|
)
|
|
return self._render_operation_page(
|
|
request,
|
|
reservation=result.reservation,
|
|
title="Operational check-in access",
|
|
heading="Operational check-in access",
|
|
description=(
|
|
"This page shows a one-time operational QR code and check-in URL for manual testing or guest support."
|
|
),
|
|
qr_code_image=result.qr_code_image,
|
|
qr_code_url=result.qr_code_url,
|
|
)
|
|
|
|
return self._render_operation_page(
|
|
request,
|
|
reservation=reservation,
|
|
title="Generate operational check-in access",
|
|
heading="Generate operational check-in access",
|
|
description=(
|
|
"Generate a fresh QR code and check-in URL without exposing token hashes in normal admin screens."
|
|
),
|
|
submit_label="Generate QR / URL",
|
|
)
|
|
|
|
def manual_check_in_view(self, request, object_id):
|
|
reservation = self.get_object(request, object_id)
|
|
if reservation is None:
|
|
return self._redirect_to_changelist()
|
|
|
|
if request.method == "POST":
|
|
try:
|
|
confirm_check_in_for_reservation(
|
|
reservation_id=reservation.pk,
|
|
staff_user=request.user,
|
|
source=CheckIn.Source.MANUAL,
|
|
)
|
|
except ReservationNotConfirmed as exc:
|
|
self.message_user(request, str(exc), level=messages.ERROR)
|
|
return HttpResponseRedirect(self._change_url(reservation.pk))
|
|
except AlreadyCheckedIn:
|
|
self.message_user(request, "Reservation has already been checked in.", level=messages.WARNING)
|
|
return HttpResponseRedirect(self._change_url(reservation.pk))
|
|
|
|
self.message_user(request, "Reservation marked as checked in.", level=messages.SUCCESS)
|
|
return HttpResponseRedirect(self._change_url(reservation.pk))
|
|
|
|
return self._render_operation_page(
|
|
request,
|
|
reservation=reservation,
|
|
title="Mark reservation as checked in",
|
|
heading="Mark reservation as checked in",
|
|
description="Use this only for staff-side emergency or desk check-in workflows.",
|
|
submit_label="Confirm check-in",
|
|
)
|
|
|
|
def _change_url(self, reservation_id):
|
|
return reverse("admin:bookings_reservation_change", args=[reservation_id])
|
|
|
|
def _redirect_to_changelist(self):
|
|
return HttpResponseRedirect(reverse("admin:bookings_reservation_changelist"))
|
|
|
|
def _render_operation_page(
|
|
self,
|
|
request,
|
|
*,
|
|
reservation,
|
|
title,
|
|
heading,
|
|
description,
|
|
submit_label=None,
|
|
qr_code_image=None,
|
|
qr_code_url=None,
|
|
):
|
|
context = {
|
|
**self.admin_site.each_context(request),
|
|
"opts": self.model._meta,
|
|
"original": reservation,
|
|
"title": title,
|
|
"heading": heading,
|
|
"description": description,
|
|
"submit_label": submit_label,
|
|
"qr_code_image": qr_code_image,
|
|
"qr_code_url": qr_code_url,
|
|
"change_url": self._change_url(reservation.pk),
|
|
}
|
|
return TemplateResponse(
|
|
request,
|
|
"admin/bookings/reservation/operation.html",
|
|
context,
|
|
)
|
|
|
|
|
|
@admin.register(ReservationToken)
|
|
class ReservationTokenAdmin(admin.ModelAdmin):
|
|
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",)
|