generated from bisco/codex-bootstrap
Compare commits
6 Commits
v0.1.0-mvp
...
47449ce8dd
| Author | SHA1 | Date | |
|---|---|---|---|
| 47449ce8dd | |||
| ded07346a6 | |||
| b51ca9fdbf | |||
| 7c90da5884 | |||
| ffbe1a5b04 | |||
| 1a5e9103f6 |
@@ -32,3 +32,4 @@ ENVIRONMENT=local
|
|||||||
|
|
||||||
# Local convention: nginx is the public entrypoint on http://localhost.
|
# Local convention: nginx is the public entrypoint on http://localhost.
|
||||||
# If you change the published nginx port, update SITE_BASE_URL and trusted origins to match.
|
# If you change the published nginx port, update SITE_BASE_URL and trusted origins to match.
|
||||||
|
# In local/debug mode, failed or attempted reservation emails also log the confirmation URL for manual browser testing.
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from django.core.exceptions import ImproperlyConfigured
|
|||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
DEBUG = os.environ.get("DJANGO_DEBUG", "false").lower() == "true"
|
DEBUG = os.environ.get("DJANGO_DEBUG", "false").lower() == "true"
|
||||||
|
ENVIRONMENT = os.environ.get("ENVIRONMENT", "production").lower()
|
||||||
SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY")
|
SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY")
|
||||||
if not SECRET_KEY:
|
if not SECRET_KEY:
|
||||||
if DEBUG:
|
if DEBUG:
|
||||||
@@ -25,6 +26,7 @@ ALLOWED_HOSTS = csv_env("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1")
|
|||||||
CSRF_TRUSTED_ORIGINS = csv_env("DJANGO_CSRF_TRUSTED_ORIGINS")
|
CSRF_TRUSTED_ORIGINS = csv_env("DJANGO_CSRF_TRUSTED_ORIGINS")
|
||||||
CORS_ALLOWED_ORIGINS = csv_env("CORS_ALLOWED_ORIGINS")
|
CORS_ALLOWED_ORIGINS = csv_env("CORS_ALLOWED_ORIGINS")
|
||||||
SITE_BASE_URL = os.environ.get("SITE_BASE_URL", "http://localhost").rstrip("/")
|
SITE_BASE_URL = os.environ.get("SITE_BASE_URL", "http://localhost").rstrip("/")
|
||||||
|
LOG_RESERVATION_CONFIRMATION_URLS = DEBUG or ENVIRONMENT == "local"
|
||||||
|
|
||||||
INSTALLED_APPS = [
|
INSTALLED_APPS = [
|
||||||
"django.contrib.admin",
|
"django.contrib.admin",
|
||||||
@@ -114,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 = {
|
||||||
|
|||||||
@@ -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
@@ -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):
|
||||||
|
|||||||
@@ -13,6 +13,18 @@ def build_confirmation_link(raw_confirmation_token):
|
|||||||
return f"{settings.SITE_BASE_URL}{CONFIRMATION_PATH}?token={raw_confirmation_token}"
|
return f"{settings.SITE_BASE_URL}{CONFIRMATION_PATH}?token={raw_confirmation_token}"
|
||||||
|
|
||||||
|
|
||||||
|
def _log_confirmation_link_for_local_debug(*, reservation, confirmation_link, reason):
|
||||||
|
if not settings.LOG_RESERVATION_CONFIRMATION_URLS:
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Local reservation confirmation link for manual testing (%s) for reservation %s: %s",
|
||||||
|
reason,
|
||||||
|
reservation.id,
|
||||||
|
confirmation_link,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def send_confirmation_email(*, reservation, raw_confirmation_token):
|
def send_confirmation_email(*, reservation, raw_confirmation_token):
|
||||||
confirmation_link = build_confirmation_link(raw_confirmation_token)
|
confirmation_link = build_confirmation_link(raw_confirmation_token)
|
||||||
subject = f"Confirm your reservation for {reservation.performance.show.title}"
|
subject = f"Confirm your reservation for {reservation.performance.show.title}"
|
||||||
@@ -23,6 +35,12 @@ def send_confirmation_email(*, reservation, raw_confirmation_token):
|
|||||||
"If you did not request this reservation, you can ignore this email."
|
"If you did not request this reservation, you can ignore this email."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_log_confirmation_link_for_local_debug(
|
||||||
|
reservation=reservation,
|
||||||
|
confirmation_link=confirmation_link,
|
||||||
|
reason="email send attempt",
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
send_mail(
|
send_mail(
|
||||||
subject=subject,
|
subject=subject,
|
||||||
@@ -32,6 +50,11 @@ def send_confirmation_email(*, reservation, raw_confirmation_token):
|
|||||||
fail_silently=False,
|
fail_silently=False,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
_log_confirmation_link_for_local_debug(
|
||||||
|
reservation=reservation,
|
||||||
|
confirmation_link=confirmation_link,
|
||||||
|
reason="email send failure fallback",
|
||||||
|
)
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"Failed to send confirmation email for reservation %s.",
|
"Failed to send confirmation email for reservation %s.",
|
||||||
reservation.id,
|
reservation.id,
|
||||||
|
|||||||
@@ -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,39 +184,55 @@ 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
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
reservation=reservation,
|
||||||
confirmation_token=confirmation_token,
|
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(
|
|
||||||
reservation=reservation,
|
|
||||||
raw_check_in_token=raw_check_in_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):
|
||||||
try:
|
try:
|
||||||
check_in_token = ReservationToken.objects.select_related("reservation").get_valid_token(
|
check_in_token = ReservationToken.objects.select_related("reservation").get_valid_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 %}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -96,6 +99,34 @@ class BookingServiceTests(TestCase):
|
|||||||
self.assertEqual(len(callbacks), 1)
|
self.assertEqual(len(callbacks), 1)
|
||||||
self.assertEqual(len(mail.outbox), 0)
|
self.assertEqual(len(mail.outbox), 0)
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend",
|
||||||
|
LOG_RESERVATION_CONFIRMATION_URLS=True,
|
||||||
|
SITE_BASE_URL="https://tickets.azionelab.example",
|
||||||
|
)
|
||||||
|
def test_create_pending_reservation_logs_confirmation_link_in_local_mode(self):
|
||||||
|
with self.assertLogs("bookings.emailing", level="INFO") as captured_logs:
|
||||||
|
with self.captureOnCommitCallbacks(execute=True):
|
||||||
|
result = create_pending_reservation(
|
||||||
|
performance_id=self.performance.id,
|
||||||
|
name="Maria Rossi",
|
||||||
|
email="maria@example.com",
|
||||||
|
party_size=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result.reservation.status, Reservation.Status.PENDING)
|
||||||
|
self.assertTrue(
|
||||||
|
any(
|
||||||
|
(
|
||||||
|
"Local reservation confirmation link for manual testing "
|
||||||
|
"(email send attempt)"
|
||||||
|
) in log_entry
|
||||||
|
and result.raw_confirmation_token in log_entry
|
||||||
|
for log_entry in captured_logs.output
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@override_settings(LOG_RESERVATION_CONFIRMATION_URLS=False)
|
||||||
@patch("bookings.emailing.send_mail", side_effect=RuntimeError("SMTP down"))
|
@patch("bookings.emailing.send_mail", side_effect=RuntimeError("SMTP down"))
|
||||||
def test_create_pending_reservation_logs_email_failure_without_crashing(self, mocked_send_mail):
|
def test_create_pending_reservation_logs_email_failure_without_crashing(self, mocked_send_mail):
|
||||||
with self.assertLogs("bookings.emailing", level="ERROR") as captured_logs:
|
with self.assertLogs("bookings.emailing", level="ERROR") as captured_logs:
|
||||||
@@ -116,6 +147,40 @@ class BookingServiceTests(TestCase):
|
|||||||
for log_entry in captured_logs.output
|
for log_entry in captured_logs.output
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
self.assertFalse(
|
||||||
|
any(result.raw_confirmation_token in log_entry for log_entry in captured_logs.output)
|
||||||
|
)
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
LOG_RESERVATION_CONFIRMATION_URLS=True,
|
||||||
|
SITE_BASE_URL="https://tickets.azionelab.example",
|
||||||
|
)
|
||||||
|
@patch("bookings.emailing.send_mail", side_effect=RuntimeError("SMTP down"))
|
||||||
|
def test_create_pending_reservation_logs_confirmation_link_on_email_failure_in_local_mode(
|
||||||
|
self,
|
||||||
|
mocked_send_mail,
|
||||||
|
):
|
||||||
|
with self.assertLogs("bookings.emailing", level="INFO") as captured_logs:
|
||||||
|
with self.captureOnCommitCallbacks(execute=True):
|
||||||
|
result = create_pending_reservation(
|
||||||
|
performance_id=self.performance.id,
|
||||||
|
name="Maria Rossi",
|
||||||
|
email="maria@example.com",
|
||||||
|
party_size=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result.reservation.status, Reservation.Status.PENDING)
|
||||||
|
mocked_send_mail.assert_called_once()
|
||||||
|
self.assertTrue(
|
||||||
|
any(
|
||||||
|
(
|
||||||
|
"Local reservation confirmation link for manual testing "
|
||||||
|
"(email send failure fallback)"
|
||||||
|
) in log_entry
|
||||||
|
and result.raw_confirmation_token in log_entry
|
||||||
|
for log_entry in captured_logs.output
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
def test_generate_confirmation_token_returns_raw_token_once(self):
|
def test_generate_confirmation_token_returns_raw_token_once(self):
|
||||||
reservation = self.create_reservation()
|
reservation = self.create_reservation()
|
||||||
@@ -320,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,
|
||||||
|
|||||||
@@ -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.")
|
||||||
|
|||||||
@@ -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
@@ -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/"),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
+4
-1
@@ -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.
|
||||||
@@ -136,6 +136,7 @@ Local Docker convention:
|
|||||||
- set `SITE_BASE_URL=http://localhost`;
|
- set `SITE_BASE_URL=http://localhost`;
|
||||||
- keep `DJANGO_CSRF_TRUSTED_ORIGINS` and browser-facing `CORS_ALLOWED_ORIGINS` aligned with that public URL;
|
- keep `DJANGO_CSRF_TRUSTED_ORIGINS` and browser-facing `CORS_ALLOWED_ORIGINS` aligned with that public URL;
|
||||||
- if you publish nginx on a different port, update `SITE_BASE_URL` and trusted origins to the same host and port.
|
- if you publish nginx on a different port, update `SITE_BASE_URL` and trusted origins to the same host and port.
|
||||||
|
- local/debug reservation email sends also log the confirmation URL so browser testing can continue even if SMTP is missing or fails.
|
||||||
|
|
||||||
Required database configuration:
|
Required database configuration:
|
||||||
|
|
||||||
@@ -149,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.
|
||||||
@@ -213,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">
|
||||||
|
<div class="hero-copy">
|
||||||
<p class="eyebrow">Show detail</p>
|
<p class="eyebrow">Show detail</p>
|
||||||
<h1>{{ show()!.title }}</h1>
|
<h1>{{ show()!.title }}</h1>
|
||||||
<p class="supporting">{{ show()!.description || show()!.summary }}</p>
|
<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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 / {
|
||||||
|
|||||||
Reference in New Issue
Block a user