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_ROOT = BASE_DIR / "staticfiles"
MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR / "media"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
REST_FRAMEWORK = {
+5
View File
@@ -1,4 +1,6 @@
from django.contrib import admin
from django.conf import settings
from django.conf.urls.static import static
from django.urls import include, path
from rest_framework.decorators import api_view
from rest_framework.response import Response
@@ -16,3 +18,6 @@ urlpatterns = [
path("api/", include("bookings.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.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(
"<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",
@@ -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('<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):
+99 -25
View File
@@ -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),
)
@@ -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 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)
+51
View File
@@ -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,
+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))
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.")
+22
View File
@@ -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(
+27 -2
View File
@@ -7,11 +7,36 @@ from .models import Performance, Show, Venue
@admin.register(Show)
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",)
search_fields = ("title", "slug", "summary", "description")
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)
@@ -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)
description = models.TextField(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)
class Meta:
@@ -28,6 +29,14 @@ class Show(TimeStampedModel):
def __str__(self):
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):
name = models.CharField(max_length=200)
+10 -2
View File
@@ -1,7 +1,15 @@
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()
title = serializers.CharField()
slug = serializers.SlugField()
@@ -18,7 +26,7 @@ class PublicVenueDetailSerializer(PublicVenueSummarySerializer):
address = serializers.CharField()
class PublicShowSummarySerializer(serializers.Serializer):
class PublicShowSummarySerializer(PublicShowImageMixin, serializers.Serializer):
title = serializers.CharField()
slug = serializers.SlugField()
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"])
def show_list(request):
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})
@@ -32,7 +32,7 @@ def show_list(request):
def show_detail(request, slug):
show = get_object_or_404(Show, slug=slug, is_published=True)
show.public_performances = public_performance_queryset().filter(show=show)
serializer = PublicShowDetailSerializer(show)
serializer = PublicShowDetailSerializer(show, context={"request": request})
return Response(serializer.data)
@@ -54,12 +54,16 @@ def performance_list(request):
)
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})
@api_view(["GET"])
def performance_detail(request, pk):
performance = get_object_or_404(public_performance_queryset(), pk=pk)
serializer = PublicPerformanceDetailSerializer(performance)
serializer = PublicPerformanceDetailSerializer(performance, context={"request": request})
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;
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:
+3 -1
View File
@@ -106,7 +106,7 @@ The database should not be published to the host in production.
Recommended volumes:
- `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.
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;
- static frontend root;
- 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.
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
- 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.
- Keep container images explicitly versioned; do not use `latest` tags.
- Keep the system small until operational needs justify additional services.
@@ -45,9 +45,16 @@ import { ShowDetail, ShowPerformance, ShowsApiService } from '../services/shows-
</mat-card>
} @else if (show()) {
<header class="page-header">
<p class="eyebrow">Show detail</p>
<h1>{{ show()!.title }}</h1>
<p class="supporting">{{ show()!.description || show()!.summary }}</p>
<div class="hero-copy">
<p class="eyebrow">Show detail</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>
<section class="section">
@@ -115,9 +122,17 @@ import { ShowDetail, ShowPerformance, ShowsApiService } from '../services/shows-
}
.page-header {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(280px, 420px);
gap: 24px;
align-items: start;
margin-bottom: 28px;
}
.hero-copy {
min-width: 0;
}
.eyebrow {
margin: 0 0 10px;
color: var(--azionelab-accent);
@@ -138,6 +153,24 @@ import { ShowDetail, ShowPerformance, ShowsApiService } from '../services/shows-
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 {
display: grid;
gap: 20px;
@@ -231,6 +264,10 @@ import { ShowDetail, ShowPerformance, ShowsApiService } from '../services/shows-
}
@media (max-width: 860px) {
.page-header {
grid-template-columns: 1fr;
}
.section-heading {
align-items: flex-start;
flex-direction: column;
@@ -59,6 +59,11 @@ import { ShowListItem, ShowsApiService } from '../services/shows-api.service';
<div class="show-grid">
@for (show of shows(); track show.slug) {
<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-content>
<p>{{ show.summary }}</p>
@@ -157,6 +162,22 @@ import { ShowListItem, ShowsApiService } from '../services/shows-api.service';
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 {
flex: 1;
}
@@ -9,6 +9,7 @@ export type ShowListItem = {
title: string;
slug: string;
summary: string;
image_url: string;
poster_image: string;
};
+3
View File
@@ -21,6 +21,7 @@ services:
- "${BACKEND_PORT:-8000}"
volumes:
- django_static:/app/staticfiles
- django_media:/app/media
depends_on:
postgres:
condition: service_healthy
@@ -70,6 +71,7 @@ services:
volumes:
- ./nginx/templates:/etc/nginx/templates:ro
- django_static:/var/www/static:ro
- django_media:/var/www/media:ro
depends_on:
- backend
- frontend
@@ -80,6 +82,7 @@ services:
volumes:
postgres_data:
django_static:
django_media:
networks:
internal:
@@ -35,9 +35,9 @@ server {
}
location /media/ {
proxy_pass http://azionelab_backend;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
alias /var/www/media/;
access_log off;
expires 1d;
}
location / {