generated from bisco/codex-bootstrap
feat(operations): improve reservation and check-in flows
This commit is contained in:
@@ -1,6 +1,40 @@
|
||||
from django.contrib import admin
|
||||
from django import forms
|
||||
from django.contrib import admin, messages
|
||||
|
||||
from .models import Reservation, ReservationToken
|
||||
from .services import PerformanceNotAvailable, create_pending_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):
|
||||
@@ -13,23 +47,187 @@ class ReservationTokenInline(admin.TabularInline):
|
||||
|
||||
@admin.register(Reservation)
|
||||
class ReservationAdmin(admin.ModelAdmin):
|
||||
form = ReservationAdminForm
|
||||
list_display = (
|
||||
"show_title",
|
||||
"performance_starts_at",
|
||||
"venue_name",
|
||||
"name",
|
||||
"email",
|
||||
"performance",
|
||||
"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",
|
||||
"created_at",
|
||||
)
|
||||
list_filter = ("status", "performance", "created_at", "confirmed_at")
|
||||
search_fields = ("name", "email", "phone", "performance__show__title")
|
||||
search_fields = (
|
||||
"name",
|
||||
"email",
|
||||
"phone",
|
||||
"performance__show__title",
|
||||
"performance__venue__name",
|
||||
)
|
||||
inlines = (ReservationTokenInline,)
|
||||
list_select_related = ("performance", "performance__show", "performance__venue")
|
||||
readonly_fields = ("created_at", "updated_at", "confirmed_at", "qr_code_generated_at")
|
||||
readonly_fields = (
|
||||
"status",
|
||||
"show_title",
|
||||
"performance_starts_at",
|
||||
"venue_name",
|
||||
"confirmation_state_display",
|
||||
"check_in_state_display",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"confirmed_at",
|
||||
"qr_code_generated_at",
|
||||
)
|
||||
autocomplete_fields = ("performance",)
|
||||
|
||||
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",
|
||||
"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.register(ReservationToken)
|
||||
class ReservationTokenAdmin(admin.ModelAdmin):
|
||||
|
||||
84
backend/bookings/test_admin.py
Normal file
84
backend/bookings/test_admin.py
Normal file
@@ -0,0 +1,84 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core import mail
|
||||
from django.test import TestCase
|
||||
from django.test.utils import override_settings
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from bookings.models import Reservation, ReservationToken
|
||||
from shows.models import Performance, Show, Venue
|
||||
|
||||
|
||||
@override_settings(
|
||||
EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend",
|
||||
SITE_BASE_URL="https://tickets.azionelab.example",
|
||||
)
|
||||
class ReservationAdminTests(TestCase):
|
||||
def setUp(self):
|
||||
user_model = get_user_model()
|
||||
self.admin_user = user_model.objects.create_superuser(
|
||||
username="admin-bookings",
|
||||
email="admin@example.com",
|
||||
password="password123",
|
||||
)
|
||||
self.client.force_login(self.admin_user)
|
||||
self.show = Show.objects.create(
|
||||
title="Open Stage",
|
||||
slug="open-stage-admin",
|
||||
is_published=True,
|
||||
)
|
||||
self.venue = Venue.objects.create(
|
||||
name="AzioneLab Theatre",
|
||||
slug="azionelab-theatre-admin",
|
||||
address="Via Example 1",
|
||||
city="Rome",
|
||||
)
|
||||
self.performance = Performance.objects.create(
|
||||
show=self.show,
|
||||
venue=self.venue,
|
||||
starts_at=timezone.now() + timedelta(days=7),
|
||||
room_capacity=20,
|
||||
)
|
||||
|
||||
def test_reservation_add_page_accepts_preselected_performance(self):
|
||||
response = self.client.get(
|
||||
reverse("admin:bookings_reservation_add"),
|
||||
{"performance": self.performance.id},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Create manual reservation")
|
||||
self.assertContains(response, "The reservation stays pending")
|
||||
|
||||
def test_admin_can_create_manual_reservation_with_standard_email_flow(self):
|
||||
response = self.client.post(
|
||||
reverse("admin:bookings_reservation_add"),
|
||||
{
|
||||
"performance": self.performance.id,
|
||||
"name": "Maria Rossi",
|
||||
"email": "maria@example.com",
|
||||
"phone": "+390600000000",
|
||||
"party_size": 2,
|
||||
"notes": "Entered by staff at the venue desk.",
|
||||
"_save": "Save",
|
||||
},
|
||||
)
|
||||
|
||||
reservation = Reservation.objects.get()
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(reservation.performance, self.performance)
|
||||
self.assertEqual(reservation.status, Reservation.Status.PENDING)
|
||||
self.assertEqual(reservation.party_size, 2)
|
||||
self.assertTrue(
|
||||
ReservationToken.objects.filter(
|
||||
reservation=reservation,
|
||||
purpose=ReservationToken.Purpose.CONFIRMATION,
|
||||
).exists()
|
||||
)
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
self.assertIn(
|
||||
"https://tickets.azionelab.example/api/reservations/confirm/?token=",
|
||||
mail.outbox[0].body,
|
||||
)
|
||||
@@ -18,6 +18,7 @@ class CheckInPreviewResponseSerializer(serializers.Serializer):
|
||||
reservation_id = serializers.IntegerField()
|
||||
performance_id = serializers.IntegerField()
|
||||
show_title = serializers.CharField()
|
||||
venue_name = serializers.CharField()
|
||||
starts_at = serializers.DateTimeField()
|
||||
party_size = serializers.IntegerField()
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ class CheckInApiTests(APITestCase):
|
||||
self.assertEqual(response.data["reservation_id"], reservation.id)
|
||||
self.assertEqual(response.data["performance_id"], self.performance.id)
|
||||
self.assertEqual(response.data["show_title"], self.show.title)
|
||||
self.assertEqual(response.data["venue_name"], self.venue.name)
|
||||
self.assertEqual(response.data["party_size"], reservation.party_size)
|
||||
self.assertNotIn("name", response.data)
|
||||
self.assertNotIn("email", response.data)
|
||||
|
||||
@@ -65,6 +65,7 @@ def check_in_preview(request):
|
||||
"reservation_id": preview.reservation_id,
|
||||
"performance_id": preview.performance_id,
|
||||
"show_title": preview.show_title,
|
||||
"venue_name": preview.venue_name,
|
||||
"starts_at": preview.starts_at,
|
||||
"party_size": preview.party_size,
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
from django.contrib import admin
|
||||
from django.urls import reverse
|
||||
from django.utils.html import format_html
|
||||
|
||||
from .models import Performance, Show, Venue
|
||||
|
||||
@@ -32,6 +34,7 @@ class PerformanceAdmin(admin.ModelAdmin):
|
||||
"manually_occupied_seats",
|
||||
"available_seats_display",
|
||||
"is_booking_enabled",
|
||||
"create_reservation_link",
|
||||
)
|
||||
list_filter = ("is_booking_enabled", "starts_at", "show", "venue")
|
||||
search_fields = ("show__title", "venue__name", "venue__city")
|
||||
@@ -49,3 +52,15 @@ class PerformanceAdmin(admin.ModelAdmin):
|
||||
):
|
||||
return "-"
|
||||
return obj.available_seats()
|
||||
|
||||
@admin.display(description="Manual reservation")
|
||||
def create_reservation_link(self, obj):
|
||||
if not getattr(obj, "pk", None):
|
||||
return "-"
|
||||
|
||||
url = reverse("admin:bookings_reservation_add")
|
||||
return format_html(
|
||||
'<a href="{}?performance={}">Create reservation</a>',
|
||||
url,
|
||||
obj.pk,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user