Compare commits

..

4 Commits

Author SHA1 Message Date
bisco 47449ce8dd Merge branch 'feature/show-image-upload' into develop 2026-04-30 00:08:27 +02:00
bisco ded07346a6 feat(shows): add uploaded show images 2026-04-30 00:05:23 +02:00
bisco b51ca9fdbf Merge branch 'feature/admin-manual-reservation-actions' into develop 2026-04-29 23:50:17 +02:00
bisco 7c90da5884 feat(admin): add manual reservation operations 2026-04-29 23:47:22 +02:00
22 changed files with 733 additions and 41 deletions
+2
View File
@@ -116,6 +116,8 @@ if "test" in sys.argv:
STATIC_URL = "/static/" STATIC_URL = "/static/"
STATIC_ROOT = BASE_DIR / "staticfiles" STATIC_ROOT = BASE_DIR / "staticfiles"
MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR / "media"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
REST_FRAMEWORK = { REST_FRAMEWORK = {
+5
View File
@@ -1,4 +1,6 @@
from django.contrib import admin from django.contrib import admin
from django.conf import settings
from django.conf.urls.static import static
from django.urls import include, path from django.urls import include, path
from rest_framework.decorators import api_view from rest_framework.decorators import api_view
from rest_framework.response import Response from rest_framework.response import Response
@@ -16,3 +18,6 @@ urlpatterns = [
path("api/", include("bookings.urls")), path("api/", include("bookings.urls")),
path("api/", include("checkins.urls")), path("api/", include("checkins.urls")),
] ]
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
+218 -1
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):
+99 -25
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,37 +184,53 @@ 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
reservation=reservation,
confirmation_token=confirmation_token,
check_in_token=check_in_token, def confirm_reservation_manually(*, reservation_id):
raw_check_in_token=raw_check_in_token, with transaction.atomic():
available_seats=available_seats - reservation.party_size, reservation = Reservation.objects.select_for_update().get(pk=reservation_id)
qr_code_image=generate_check_in_qr_base64( 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,
raw_check_in_token=raw_check_in_token, confirmation_token=confirmation_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):
@@ -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),
)
@@ -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 %}
+51
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)
+51
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,
+23
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.")
+22
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(
+27 -2
View File
@@ -7,11 +7,36 @@ from .models import Performance, Show, Venue
@admin.register(Show) @admin.register(Show)
class ShowAdmin(admin.ModelAdmin): class ShowAdmin(admin.ModelAdmin):
list_display = ("title", "slug", "is_published", "created_at", "updated_at") list_display = ("title", "slug", "is_published", "image_preview", "created_at", "updated_at")
list_filter = ("is_published",) list_filter = ("is_published",)
search_fields = ("title", "slug", "summary", "description") search_fields = ("title", "slug", "summary", "description")
prepopulated_fields = {"slug": ("title",)} prepopulated_fields = {"slug": ("title",)}
readonly_fields = ("created_at", "updated_at") readonly_fields = ("image_preview", "created_at", "updated_at")
fields = (
"title",
"slug",
"summary",
"description",
"uploaded_image",
"poster_image",
"image_preview",
"is_published",
"created_at",
"updated_at",
)
@admin.display(description="Preview")
def image_preview(self, obj):
if not getattr(obj, "pk", None):
return "-"
image_url = obj.image_url()
if not image_url:
return "No image"
return format_html(
'<img src="{}" alt="{}" style="max-width: 120px; border-radius: 6px;" />',
image_url,
obj.title,
)
@admin.register(Venue) @admin.register(Venue)
@@ -0,0 +1,17 @@
# Generated by Django 5.2.3 on 2026-04-29
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("shows", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="show",
name="uploaded_image",
field=models.ImageField(blank=True, upload_to="shows/"),
),
]
+9
View File
@@ -16,6 +16,7 @@ class Show(TimeStampedModel):
summary = models.TextField(blank=True) summary = models.TextField(blank=True)
description = models.TextField(blank=True) description = models.TextField(blank=True)
poster_image = models.URLField(blank=True) poster_image = models.URLField(blank=True)
uploaded_image = models.ImageField(upload_to="shows/", blank=True)
is_published = models.BooleanField(default=False, db_index=True) is_published = models.BooleanField(default=False, db_index=True)
class Meta: class Meta:
@@ -28,6 +29,14 @@ class Show(TimeStampedModel):
def __str__(self): def __str__(self):
return self.title return self.title
def image_url(self, request=None):
if self.uploaded_image:
image_url = self.uploaded_image.url
if request is not None:
return request.build_absolute_uri(image_url)
return image_url
return self.poster_image
class Venue(TimeStampedModel): class Venue(TimeStampedModel):
name = models.CharField(max_length=200) name = models.CharField(max_length=200)
+10 -2
View File
@@ -1,7 +1,15 @@
from rest_framework import serializers from rest_framework import serializers
class PublicShowListSerializer(serializers.Serializer): class PublicShowImageMixin(serializers.Serializer):
image_url = serializers.SerializerMethodField()
def get_image_url(self, obj):
request = self.context.get("request")
return obj.image_url(request=request)
class PublicShowListSerializer(PublicShowImageMixin, serializers.Serializer):
id = serializers.IntegerField() id = serializers.IntegerField()
title = serializers.CharField() title = serializers.CharField()
slug = serializers.SlugField() slug = serializers.SlugField()
@@ -18,7 +26,7 @@ class PublicVenueDetailSerializer(PublicVenueSummarySerializer):
address = serializers.CharField() address = serializers.CharField()
class PublicShowSummarySerializer(serializers.Serializer): class PublicShowSummarySerializer(PublicShowImageMixin, serializers.Serializer):
title = serializers.CharField() title = serializers.CharField()
slug = serializers.SlugField() slug = serializers.SlugField()
summary = serializers.CharField() summary = serializers.CharField()
+77
View File
@@ -0,0 +1,77 @@
from datetime import timedelta
from tempfile import TemporaryDirectory
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import override_settings
from django.urls import reverse
from django.utils import timezone
from rest_framework import status
from rest_framework.test import APITestCase
from shows.models import Performance, Show, Venue
SMALL_GIF = (
b"GIF89a\x01\x00\x01\x00\x80\x00\x00"
b"\x00\x00\x00\xff\xff\xff!\xf9\x04\x01"
b"\x00\x00\x00\x00,\x00\x00\x00\x00\x01"
b"\x00\x01\x00\x00\x02\x02D\x01\x00;"
)
class ShowApiTests(APITestCase):
def setUp(self):
self.temp_media = TemporaryDirectory()
self.addCleanup(self.temp_media.cleanup)
self.settings_override = override_settings(MEDIA_ROOT=self.temp_media.name)
self.settings_override.enable()
self.addCleanup(self.settings_override.disable)
self.show = Show.objects.create(
title="Open Stage",
slug="open-stage-media",
summary="A contemporary theatre performance.",
description="Full public show description.",
poster_image="https://cdn.example.com/open-stage-poster.jpg",
is_published=True,
)
self.external_only_show = Show.objects.create(
title="External Poster Stage",
slug="external-poster-stage",
summary="External image only.",
description="External image only.",
poster_image="https://cdn.example.com/external-only.jpg",
is_published=True,
)
self.venue = Venue.objects.create(
name="AzioneLab Theatre",
slug="azionelab-theatre-show-api",
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_show_list_prefers_uploaded_image_url_when_present(self):
self.show.uploaded_image.save(
"open-stage.gif",
SimpleUploadedFile("open-stage.gif", SMALL_GIF, content_type="image/gif"),
save=True,
)
response = self.client.get(reverse("api-show-list"))
self.assertEqual(response.status_code, status.HTTP_200_OK)
list_item = next(item for item in response.data["results"] if item["slug"] == self.show.slug)
self.assertTrue(list_item["image_url"].endswith("/media/shows/open-stage.gif"))
self.assertEqual(list_item["poster_image"], "https://cdn.example.com/open-stage-poster.jpg")
def test_show_detail_falls_back_to_existing_external_image_url(self):
response = self.client.get(reverse("api-show-detail", kwargs={"slug": self.external_only_show.slug}))
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["image_url"], "https://cdn.example.com/external-only.jpg")
+8 -4
View File
@@ -24,7 +24,7 @@ def public_performance_queryset():
@api_view(["GET"]) @api_view(["GET"])
def show_list(request): def show_list(request):
shows = Show.objects.filter(is_published=True).order_by("title") shows = Show.objects.filter(is_published=True).order_by("title")
serializer = PublicShowListSerializer(shows, many=True) serializer = PublicShowListSerializer(shows, many=True, context={"request": request})
return Response({"results": serializer.data}) return Response({"results": serializer.data})
@@ -32,7 +32,7 @@ def show_list(request):
def show_detail(request, slug): def show_detail(request, slug):
show = get_object_or_404(Show, slug=slug, is_published=True) show = get_object_or_404(Show, slug=slug, is_published=True)
show.public_performances = public_performance_queryset().filter(show=show) show.public_performances = public_performance_queryset().filter(show=show)
serializer = PublicShowDetailSerializer(show) serializer = PublicShowDetailSerializer(show, context={"request": request})
return Response(serializer.data) return Response(serializer.data)
@@ -54,12 +54,16 @@ def performance_list(request):
) )
performances = performances.filter(starts_at__gte=starts_from) performances = performances.filter(starts_at__gte=starts_from)
serializer = PublicPerformanceListSerializer(performances.order_by("starts_at"), many=True) serializer = PublicPerformanceListSerializer(
performances.order_by("starts_at"),
many=True,
context={"request": request},
)
return Response({"results": serializer.data}) return Response({"results": serializer.data})
@api_view(["GET"]) @api_view(["GET"])
def performance_detail(request, pk): def performance_detail(request, pk):
performance = get_object_or_404(public_performance_queryset(), pk=pk) performance = get_object_or_404(public_performance_queryset(), pk=pk)
serializer = PublicPerformanceDetailSerializer(performance) serializer = PublicPerformanceDetailSerializer(performance, context={"request": request})
return Response(serializer.data) return Response(serializer.data)
+8
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:
+3 -1
View File
@@ -106,7 +106,7 @@ The database should not be published to the host in production.
Recommended volumes: Recommended volumes:
- `postgres_data`: PostgreSQL data directory; - `postgres_data`: PostgreSQL data directory;
- `media`: uploaded media and generated QR assets if stored on disk; - `media`: uploaded show images and generated QR assets if stored on disk;
- `static`: collected Django static files if served by nginx from a shared volume. - `static`: collected Django static files if served by nginx from a shared volume.
Generated QR codes may also be generated on demand instead of stored as files. If stored, they must not reveal personal data and access must remain controlled. Generated QR codes may also be generated on demand instead of stored as files. If stored, they must not reveal personal data and access must remain controlled.
@@ -150,6 +150,7 @@ Required nginx configuration:
- upstream backend service name and port; - upstream backend service name and port;
- static frontend root; - static frontend root;
- proxy rules for `/api/` and `/admin/`; - proxy rules for `/api/` and `/admin/`;
- media root for `/media/` if uploaded assets are served by nginx from a shared volume;
- TLS certificate paths for production. - TLS certificate paths for production.
Secrets must be provided through deployment-managed environment variables, Docker secrets, or another secret manager. Do not commit real secret values. Secrets must be provided through deployment-managed environment variables, Docker secrets, or another secret manager. Do not commit real secret values.
@@ -214,6 +215,7 @@ Database rollback needs special care once migrations exist. Down migrations or b
## Operational Notes ## Operational Notes
- Configure database backups before accepting real bookings. - Configure database backups before accepting real bookings.
- Back up the shared media volume together with the database if staff uploads show images.
- Monitor backend errors, email delivery failures, and check-in failures. - Monitor backend errors, email delivery failures, and check-in failures.
- Keep container images explicitly versioned; do not use `latest` tags. - Keep container images explicitly versioned; do not use `latest` tags.
- Keep the system small until operational needs justify additional services. - Keep the system small until operational needs justify additional services.
@@ -45,9 +45,16 @@ import { ShowDetail, ShowPerformance, ShowsApiService } from '../services/shows-
</mat-card> </mat-card>
} @else if (show()) { } @else if (show()) {
<header class="page-header"> <header class="page-header">
<p class="eyebrow">Show detail</p> <div class="hero-copy">
<h1>{{ show()!.title }}</h1> <p class="eyebrow">Show detail</p>
<p class="supporting">{{ show()!.description || show()!.summary }}</p> <h1>{{ show()!.title }}</h1>
<p class="supporting">{{ show()!.description || show()!.summary }}</p>
</div>
@if (show()!.image_url) {
<div class="hero-image-wrap">
<img class="hero-image" [src]="show()!.image_url" [alt]="show()!.title" />
</div>
}
</header> </header>
<section class="section"> <section class="section">
@@ -115,9 +122,17 @@ import { ShowDetail, ShowPerformance, ShowsApiService } from '../services/shows-
} }
.page-header { .page-header {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(280px, 420px);
gap: 24px;
align-items: start;
margin-bottom: 28px; margin-bottom: 28px;
} }
.hero-copy {
min-width: 0;
}
.eyebrow { .eyebrow {
margin: 0 0 10px; margin: 0 0 10px;
color: var(--azionelab-accent); color: var(--azionelab-accent);
@@ -138,6 +153,24 @@ import { ShowDetail, ShowPerformance, ShowsApiService } from '../services/shows-
max-width: 64ch; max-width: 64ch;
} }
.hero-image-wrap {
overflow: hidden;
border-radius: 12px;
border: 1px solid var(--azionelab-border);
background:
linear-gradient(135deg, rgba(207, 71, 51, 0.16), rgba(15, 22, 36, 0.08)),
#f8f1ea;
box-shadow: var(--azionelab-shadow);
aspect-ratio: 4 / 5;
}
.hero-image {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.section { .section {
display: grid; display: grid;
gap: 20px; gap: 20px;
@@ -231,6 +264,10 @@ import { ShowDetail, ShowPerformance, ShowsApiService } from '../services/shows-
} }
@media (max-width: 860px) { @media (max-width: 860px) {
.page-header {
grid-template-columns: 1fr;
}
.section-heading { .section-heading {
align-items: flex-start; align-items: flex-start;
flex-direction: column; flex-direction: column;
@@ -59,6 +59,11 @@ import { ShowListItem, ShowsApiService } from '../services/shows-api.service';
<div class="show-grid"> <div class="show-grid">
@for (show of shows(); track show.slug) { @for (show of shows(); track show.slug) {
<mat-card class="show-card"> <mat-card class="show-card">
@if (show.image_url) {
<div class="show-image-wrap">
<img class="show-image" [src]="show.image_url" [alt]="show.title" />
</div>
}
<mat-card-title>{{ show.title }}</mat-card-title> <mat-card-title>{{ show.title }}</mat-card-title>
<mat-card-content> <mat-card-content>
<p>{{ show.summary }}</p> <p>{{ show.summary }}</p>
@@ -157,6 +162,22 @@ import { ShowListItem, ShowsApiService } from '../services/shows-api.service';
box-shadow: var(--azionelab-shadow); box-shadow: var(--azionelab-shadow);
} }
.show-image-wrap {
aspect-ratio: 16 / 10;
overflow: hidden;
border-bottom: 1px solid var(--azionelab-border);
background:
linear-gradient(135deg, rgba(207, 71, 51, 0.14), rgba(15, 22, 36, 0.06)),
#f8f1ea;
}
.show-image {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.show-card mat-card-content { .show-card mat-card-content {
flex: 1; flex: 1;
} }
@@ -9,6 +9,7 @@ export type ShowListItem = {
title: string; title: string;
slug: string; slug: string;
summary: string; summary: string;
image_url: string;
poster_image: string; poster_image: string;
}; };
+3
View File
@@ -21,6 +21,7 @@ services:
- "${BACKEND_PORT:-8000}" - "${BACKEND_PORT:-8000}"
volumes: volumes:
- django_static:/app/staticfiles - django_static:/app/staticfiles
- django_media:/app/media
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
@@ -70,6 +71,7 @@ services:
volumes: volumes:
- ./nginx/templates:/etc/nginx/templates:ro - ./nginx/templates:/etc/nginx/templates:ro
- django_static:/var/www/static:ro - django_static:/var/www/static:ro
- django_media:/var/www/media:ro
depends_on: depends_on:
- backend - backend
- frontend - frontend
@@ -80,6 +82,7 @@ services:
volumes: volumes:
postgres_data: postgres_data:
django_static: django_static:
django_media:
networks: networks:
internal: internal:
@@ -35,9 +35,9 @@ server {
} }
location /media/ { location /media/ {
proxy_pass http://azionelab_backend; alias /var/www/media/;
proxy_set_header Host $host; access_log off;
proxy_set_header X-Forwarded-Proto $scheme; expires 1d;
} }
location / { location / {