Compare commits

16 Commits

Author SHA1 Message Date
bisco e5fcbfeb26 Merge branch 'chore/production-readiness-docs' into develop 2026-04-29 23:04:21 +02:00
bisco 7fc0a931ce docs: add production readiness notes 2026-04-29 23:00:57 +02:00
bisco b692ae70ba Merge branch 'fix/api-throttling' into develop 2026-04-29 22:59:02 +02:00
bisco 0533a1799f fix(api): add basic booking throttling 2026-04-29 22:57:09 +02:00
bisco a8f2a7c803 Merge branch 'fix/admin-token-visibility' into develop 2026-04-29 22:48:27 +02:00
bisco 7a46e288cf fix(admin): hide reservation token hashes 2026-04-29 22:45:16 +02:00
bisco 33307a5de2 Merge branch 'fix/site-base-url' into develop 2026-04-29 22:01:19 +02:00
bisco a5189669f6 fix(config): align site base url defaults 2026-04-29 21:59:31 +02:00
bisco aef2a31977 docs: add lightweight git workflow policy 2026-04-29 21:54:49 +02:00
bisco 13a05f6d0d fix(security): separate booking and check-in tokens 2026-04-29 21:49:21 +02:00
bisco 5cad1871e7 feat(operations): improve reservation and check-in flows 2026-04-29 19:23:42 +02:00
bisco 6c5b5d99bc added debug settings 2026-04-29 19:00:59 +02:00
bisco 0fe57dc47f fix(admin): handle unsaved performance seats display 2026-04-29 18:56:19 +02:00
bisco 784076e6be fix(docker): serve django static files via nginx 2026-04-29 18:46:53 +02:00
bisco c82103cc66 Merge branch 'feature/frontend-checkin' into develop 2026-04-29 18:25:58 +02:00
bisco 51f449ced0 feat(frontend): add staff check-in flow 2026-04-29 18:23:05 +02:00
24 changed files with 1335 additions and 110 deletions
+10 -2
View File
@@ -121,14 +121,22 @@ enabled_profiles:
Work must happen on the current feature branch for the task. Work must happen on the current feature branch for the task.
## Git Workflow Policy
- `develop` is the integration branch.
- `main` is reserved for stable releases.
- For non-trivial changes, Codex MUST work on a dedicated task branch.
- Allowed task branch prefixes: `feature/`, `fix/`, `docs/`, `chore/`.
- Codex MUST NOT merge into `develop` or `main`.
- Codex may commit only on the current task branch after required checks pass.
- The human operator reviews, merges, pushes, and deletes branches.
Allowed branch prefixes when a new branch is explicitly needed: Allowed branch prefixes when a new branch is explicitly needed:
- `feature/` - `feature/`
- `fix/` - `fix/`
- `hotfix/`
- `chore/` - `chore/`
- `docs/` - `docs/`
- `refactor/`
Do not merge task branches into `develop`. Leave integration to the repository owner or a separate explicit request. Do not merge task branches into `develop`. Leave integration to the repository owner or a separate explicit request.
+10 -5
View File
@@ -3,7 +3,7 @@
COMPOSE_PROJECT_NAME=azionelab COMPOSE_PROJECT_NAME=azionelab
NGINX_HTTP_PORT=8080 NGINX_HTTP_PORT=80
BACKEND_HOST=backend BACKEND_HOST=backend
BACKEND_PORT=8000 BACKEND_PORT=8000
@@ -12,10 +12,10 @@ FRONTEND_PORT=8080
DJANGO_SECRET_KEY=change-me DJANGO_SECRET_KEY=change-me
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1 DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1
DJANGO_CSRF_TRUSTED_ORIGINS=http://localhost:8080 DJANGO_CSRF_TRUSTED_ORIGINS=http://localhost
DJANGO_DEBUG=false DJANGO_DEBUG=true
CORS_ALLOWED_ORIGINS=http://localhost:4200,http://localhost:8080 CORS_ALLOWED_ORIGINS=http://localhost:4200,http://localhost
SITE_BASE_URL=http://localhost:8080 SITE_BASE_URL=http://localhost
TIME_ZONE=Europe/Rome TIME_ZONE=Europe/Rome
EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend
DEFAULT_FROM_EMAIL=no-reply@azionelab.local DEFAULT_FROM_EMAIL=no-reply@azionelab.local
@@ -27,3 +27,8 @@ POSTGRES_HOST=postgres
POSTGRES_PORT=5432 POSTGRES_PORT=5432
DATABASE_URL=postgres://azionelab:change-me@postgres:5432/azionelab DATABASE_URL=postgres://azionelab:change-me@postgres:5432/azionelab
ENVIRONMENT=local
# 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.
+10 -2
View File
@@ -24,7 +24,7 @@ def csv_env(name, default=""):
ALLOWED_HOSTS = csv_env("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1") 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:8080").rstrip("/") SITE_BASE_URL = os.environ.get("SITE_BASE_URL", "http://localhost").rstrip("/")
INSTALLED_APPS = [ INSTALLED_APPS = [
"django.contrib.admin", "django.contrib.admin",
@@ -112,7 +112,8 @@ DEFAULT_FROM_EMAIL = os.environ.get("DEFAULT_FROM_EMAIL", "no-reply@azionelab.lo
if "test" in sys.argv: if "test" in sys.argv:
EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend" EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"
STATIC_URL = "static/" STATIC_URL = "/static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
REST_FRAMEWORK = { REST_FRAMEWORK = {
@@ -122,4 +123,11 @@ REST_FRAMEWORK = {
"DEFAULT_PARSER_CLASSES": [ "DEFAULT_PARSER_CLASSES": [
"rest_framework.parsers.JSONParser", "rest_framework.parsers.JSONParser",
], ],
"DEFAULT_THROTTLE_RATES": {
# Small-theatre defaults: stricter on public booking flows, looser for staff operations.
"reservation_create": "20/hour",
"reservation_confirm": "60/hour",
"check_in_preview": "600/hour",
"check_in_confirm": "600/hour",
},
} }
+209 -14
View File
@@ -1,45 +1,240 @@
from django.contrib import admin from django import forms
from django.contrib import admin, messages
from .models import Reservation, ReservationToken from .models import Reservation, ReservationToken
from .services import PerformanceNotAvailable, create_pending_reservation
class ReservationAdminForm(forms.ModelForm):
class Meta:
model = Reservation
fields = ("performance", "name", "email", "phone", "party_size", "notes")
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["performance"].help_text = (
"Choose the exact performance. A pending reservation and confirmation email will be created."
)
self.fields["party_size"].help_text = "Seats requested for this guest or group."
self.fields["notes"].help_text = "Optional internal note for staff."
def clean(self):
cleaned_data = super().clean()
performance = cleaned_data.get("performance")
party_size = cleaned_data.get("party_size")
if performance and not performance.is_booking_enabled:
self.add_error("performance", "Booking is currently disabled for this performance.")
if performance and party_size:
available_seats = performance.available_seats()
if party_size > available_seats:
self.add_error(
"party_size",
f"Only {available_seats} seats are currently available for this performance.",
)
return cleaned_data
class ReservationTokenInline(admin.TabularInline): class ReservationTokenInline(admin.TabularInline):
model = ReservationToken model = ReservationToken
extra = 0 extra = 0
readonly_fields = ("token_hash", "used_at", "created_at") readonly_fields = ("used_at", "created_at")
fields = ("purpose", "token_hash", "expires_at", "used_at", "created_at") fields = ("purpose", "expires_at", "used_at", "created_at")
can_delete = False can_delete = False
@admin.register(Reservation) @admin.register(Reservation)
class ReservationAdmin(admin.ModelAdmin): class ReservationAdmin(admin.ModelAdmin):
form = ReservationAdminForm
list_display = ( list_display = (
"show_title",
"performance_starts_at",
"venue_name",
"name", "name",
"email", "email",
"performance",
"party_size", "party_size",
"status", "status",
"confirmation_state_display",
"check_in_state_display",
)
list_filter = (
"status",
"performance__show",
"performance__venue",
"performance__starts_at",
"confirmed_at", "confirmed_at",
"qr_code_generated_at", "qr_code_generated_at",
"created_at",
) )
list_filter = ("status", "performance", "created_at", "confirmed_at") search_fields = (
search_fields = ("name", "email", "phone", "performance__show__title") "name",
"email",
"phone",
"performance__show__title",
"performance__venue__name",
)
inlines = (ReservationTokenInline,) inlines = (ReservationTokenInline,)
list_select_related = ("performance", "performance__show", "performance__venue") list_select_related = ("performance", "performance__show", "performance__venue")
readonly_fields = ("created_at", "updated_at", "confirmed_at", "qr_code_generated_at") readonly_fields = (
"status",
"show_title",
"performance_starts_at",
"venue_name",
"confirmation_state_display",
"check_in_state_display",
"created_at",
"updated_at",
"confirmed_at",
"qr_code_generated_at",
)
autocomplete_fields = ("performance",) autocomplete_fields = ("performance",)
def get_queryset(self, request):
return super().get_queryset(request).select_related(
"performance",
"performance__show",
"performance__venue",
"check_in",
)
def get_inlines(self, request, obj):
if obj is None:
return ()
return super().get_inlines(request, obj)
def get_changeform_initial_data(self, request):
initial = super().get_changeform_initial_data(request)
performance_id = request.GET.get("performance")
if performance_id:
initial["performance"] = performance_id
return initial
def get_fieldsets(self, request, obj=None):
if obj is None:
return (
(
"Create manual reservation",
{
"description": (
"Use this form when staff needs to enter a reservation manually. "
"The reservation stays pending and the standard confirmation email is sent automatically."
),
"fields": ("performance", "name", "email", "phone", "party_size", "notes"),
},
),
)
return (
(
"Reservation",
{
"fields": (
"performance",
"show_title",
"performance_starts_at",
"venue_name",
"name",
"email",
"phone",
"party_size",
"notes",
),
},
),
(
"Operational status",
{
"fields": (
"status",
"confirmation_state_display",
"check_in_state_display",
"confirmed_at",
"qr_code_generated_at",
),
},
),
(
"Timestamps",
{
"classes": ("collapse",),
"fields": ("created_at", "updated_at"),
},
),
)
def save_model(self, request, obj, form, change):
if change:
super().save_model(request, obj, form, change)
return
try:
result = create_pending_reservation(
performance_id=form.cleaned_data["performance"].id,
name=form.cleaned_data["name"],
email=form.cleaned_data["email"],
phone=form.cleaned_data.get("phone", ""),
party_size=form.cleaned_data["party_size"],
notes=form.cleaned_data.get("notes", ""),
)
except PerformanceNotAvailable as exc:
form.add_error("performance", str(exc))
raise forms.ValidationError(str(exc)) from exc
created = result.reservation
obj.pk = created.pk
obj._state.adding = False
obj.performance = created.performance
obj.status = created.status
obj.name = created.name
obj.email = created.email
obj.phone = created.phone
obj.party_size = created.party_size
obj.notes = created.notes
obj.created_at = created.created_at
obj.updated_at = created.updated_at
obj.confirmed_at = created.confirmed_at
obj.qr_code_generated_at = created.qr_code_generated_at
self.message_user(
request,
"Pending reservation created. The guest must confirm it from the email link before check-in.",
level=messages.SUCCESS,
)
@admin.display(description="Show", ordering="performance__show__title")
def show_title(self, obj):
return obj.performance.show.title
@admin.display(description="Performance", ordering="performance__starts_at")
def performance_starts_at(self, obj):
return obj.performance.starts_at
@admin.display(description="Venue", ordering="performance__venue__name")
def venue_name(self, obj):
return obj.performance.venue.name
@admin.display(description="Confirmation")
def confirmation_state_display(self, obj):
if obj.status == Reservation.Status.CONFIRMED:
return "Confirmed"
if obj.status == Reservation.Status.EXPIRED:
return "Confirmation expired"
if obj.status == Reservation.Status.CANCELLED:
return "Cancelled"
return "Waiting for email confirmation"
@admin.display(description="Check-in")
def check_in_state_display(self, obj):
return "Checked in" if hasattr(obj, "check_in") else "Not checked in"
@admin.register(ReservationToken) @admin.register(ReservationToken)
class ReservationTokenAdmin(admin.ModelAdmin): class ReservationTokenAdmin(admin.ModelAdmin):
list_display = ("reservation", "purpose", "expires_at", "used_at", "created_at", "token_preview") list_display = ("reservation", "purpose", "expires_at", "used_at", "created_at")
list_filter = ("purpose", "expires_at", "used_at", "created_at") list_filter = ("purpose", "expires_at", "used_at", "created_at")
search_fields = ("reservation__name", "reservation__email", "token_hash") search_fields = ("reservation__name", "reservation__email", "token_hash")
readonly_fields = ("token_hash", "created_at", "used_at") readonly_fields = ("created_at", "used_at")
exclude = ("token_hash",)
list_select_related = ("reservation", "reservation__performance") list_select_related = ("reservation", "reservation__performance")
autocomplete_fields = ("reservation",) autocomplete_fields = ("reservation",)
@admin.display(description="Token hash")
def token_preview(self, obj):
return obj.token_hash[:12]
+14 -11
View File
@@ -113,16 +113,19 @@ def create_pending_reservation(
expires_at=confirmation_expires_at, expires_at=confirmation_expires_at,
) )
transaction.on_commit(
lambda reservation=reservation, raw_confirmation_token=raw_confirmation_token: send_confirmation_email(
reservation=reservation,
raw_confirmation_token=raw_confirmation_token,
)
)
result = PendingReservationResult( result = PendingReservationResult(
reservation=reservation, reservation=reservation,
confirmation_token=confirmation_token, confirmation_token=confirmation_token,
raw_confirmation_token=raw_confirmation_token, raw_confirmation_token=raw_confirmation_token,
available_seats=available_seats, available_seats=available_seats,
) )
send_confirmation_email(
reservation=result.reservation,
raw_confirmation_token=result.raw_confirmation_token,
)
return result return result
@@ -195,22 +198,22 @@ def confirm_reservation_from_token(raw_token):
available_seats=available_seats - reservation.party_size, available_seats=available_seats - reservation.party_size,
qr_code_image=generate_check_in_qr_base64( qr_code_image=generate_check_in_qr_base64(
reservation=reservation, reservation=reservation,
raw_check_in_token=raw_token, raw_check_in_token=raw_check_in_token,
), ),
qr_code_url=build_check_in_preview_url(raw_token), qr_code_url=build_check_in_preview_url(raw_check_in_token),
) )
def retrieve_reservation_qr_from_token(raw_token): def retrieve_reservation_qr_from_token(raw_token):
try: try:
confirmation_token = ReservationToken.objects.select_related("reservation").get( check_in_token = ReservationToken.objects.select_related("reservation").get_valid_token(
token_hash=ReservationToken.hash_token(raw_token), raw_token,
purpose=ReservationToken.Purpose.CONFIRMATION, ReservationToken.Purpose.CHECK_IN,
) )
except ReservationToken.DoesNotExist as exc: except ReservationToken.DoesNotExist as exc:
raise InvalidToken("Confirmation token is invalid.") from exc raise InvalidToken("Check-in token is invalid.") from exc
reservation = confirmation_token.reservation reservation = check_in_token.reservation
if reservation.status != Reservation.Status.CONFIRMED: if reservation.status != Reservation.Status.CONFIRMED:
raise ReservationNotConfirmed("Reservation must be confirmed before QR retrieval.") raise ReservationNotConfirmed("Reservation must be confirmed before QR retrieval.")
+111
View File
@@ -0,0 +1,111 @@
from datetime import timedelta
from django.contrib.auth import get_user_model
from django.core import mail
from django.test import TestCase
from django.test.utils import override_settings
from django.urls import reverse
from django.utils import timezone
from bookings.models import Reservation, ReservationToken
from shows.models import Performance, Show, Venue
@override_settings(
EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend",
SITE_BASE_URL="https://tickets.azionelab.example",
)
class ReservationAdminTests(TestCase):
def setUp(self):
user_model = get_user_model()
self.admin_user = user_model.objects.create_superuser(
username="admin-bookings",
email="admin@example.com",
password="password123",
)
self.client.force_login(self.admin_user)
self.show = Show.objects.create(
title="Open Stage",
slug="open-stage-admin",
is_published=True,
)
self.venue = Venue.objects.create(
name="AzioneLab Theatre",
slug="azionelab-theatre-admin",
address="Via Example 1",
city="Rome",
)
self.performance = Performance.objects.create(
show=self.show,
venue=self.venue,
starts_at=timezone.now() + timedelta(days=7),
room_capacity=20,
)
def test_reservation_add_page_accepts_preselected_performance(self):
response = self.client.get(
reverse("admin:bookings_reservation_add"),
{"performance": self.performance.id},
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Create manual reservation")
self.assertContains(response, "The reservation stays pending")
def test_admin_can_create_manual_reservation_with_standard_email_flow(self):
with self.captureOnCommitCallbacks(execute=True):
response = self.client.post(
reverse("admin:bookings_reservation_add"),
{
"performance": self.performance.id,
"name": "Maria Rossi",
"email": "maria@example.com",
"phone": "+390600000000",
"party_size": 2,
"notes": "Entered by staff at the venue desk.",
"_save": "Save",
},
)
reservation = Reservation.objects.get()
self.assertEqual(response.status_code, 302)
self.assertEqual(reservation.performance, self.performance)
self.assertEqual(reservation.status, Reservation.Status.PENDING)
self.assertEqual(reservation.party_size, 2)
self.assertTrue(
ReservationToken.objects.filter(
reservation=reservation,
purpose=ReservationToken.Purpose.CONFIRMATION,
).exists()
)
self.assertEqual(len(mail.outbox), 1)
self.assertIn(
"https://tickets.azionelab.example/api/reservations/confirm/?token=",
mail.outbox[0].body,
)
def test_token_hash_is_hidden_in_token_admin_views(self):
reservation = Reservation.objects.create(
performance=self.performance,
name="Maria Rossi",
email="maria@example.com",
party_size=2,
)
token, _ = ReservationToken.create_token(
reservation=reservation,
purpose=ReservationToken.Purpose.CONFIRMATION,
expires_at=timezone.now() + timedelta(hours=2),
)
changelist_response = self.client.get(reverse("admin:bookings_reservationtoken_changelist"))
change_response = self.client.get(
reverse("admin:bookings_reservationtoken_change", args=[token.id]),
)
self.assertEqual(changelist_response.status_code, 200)
self.assertEqual(change_response.status_code, 200)
self.assertNotContains(changelist_response, token.token_hash)
self.assertNotContains(change_response, token.token_hash)
self.assertContains(change_response, token.get_purpose_display())
self.assertContains(change_response, "Expires at")
self.assertContains(change_response, "Used at")
+108 -23
View File
@@ -1,4 +1,5 @@
from datetime import timedelta from datetime import timedelta
from unittest.mock import patch
from django.core import mail from django.core import mail
from django.urls import reverse from django.urls import reverse
@@ -9,6 +10,7 @@ from rest_framework.test import APITestCase
from bookings.models import Reservation from bookings.models import Reservation
from bookings.services import generate_confirmation_token from bookings.services import generate_confirmation_token
from bookings.views import ReservationConfirmThrottle, ReservationCreateThrottle
from shows.models import Performance, Show, Venue from shows.models import Performance, Show, Venue
@@ -63,17 +65,18 @@ class BookingApiTests(APITestCase):
@override_settings(SITE_BASE_URL="https://tickets.azionelab.example") @override_settings(SITE_BASE_URL="https://tickets.azionelab.example")
def test_reservation_creation_success(self): def test_reservation_creation_success(self):
response = self.client.post( with self.captureOnCommitCallbacks(execute=True):
reverse("api-reservation-create", kwargs={"performance_id": self.performance.id}), response = self.client.post(
{ reverse("api-reservation-create", kwargs={"performance_id": self.performance.id}),
"name": "Maria Rossi", {
"email": "maria@example.com", "name": "Maria Rossi",
"phone": "+390600000000", "email": "maria@example.com",
"party_size": 2, "phone": "+390600000000",
"notes": "Front row if possible.", "party_size": 2,
}, "notes": "Front row if possible.",
format="json", },
) format="json",
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(response.data["status"], Reservation.Status.PENDING) self.assertEqual(response.data["status"], Reservation.Status.PENDING)
@@ -87,6 +90,48 @@ class BookingApiTests(APITestCase):
mail.outbox[0].body, mail.outbox[0].body,
) )
def test_reservation_creation_schedules_email_after_commit(self):
with self.captureOnCommitCallbacks(execute=False) as callbacks:
response = self.client.post(
reverse("api-reservation-create", kwargs={"performance_id": self.performance.id}),
{
"name": "Maria Rossi",
"email": "maria@example.com",
"party_size": 2,
},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(len(callbacks), 1)
self.assertEqual(len(mail.outbox), 0)
def test_reservation_creation_is_throttled(self):
with patch.dict(ReservationCreateThrottle.THROTTLE_RATES, {"reservation_create": "1/minute"}, clear=False):
with self.captureOnCommitCallbacks(execute=True):
first_response = self.client.post(
reverse("api-reservation-create", kwargs={"performance_id": self.performance.id}),
{
"name": "Maria Rossi",
"email": "maria@example.com",
"party_size": 1,
},
format="json",
)
second_response = self.client.post(
reverse("api-reservation-create", kwargs={"performance_id": self.performance.id}),
{
"name": "Maria Rossi",
"email": "maria@example.com",
"party_size": 1,
},
format="json",
)
self.assertEqual(first_response.status_code, status.HTTP_201_CREATED)
self.assertEqual(second_response.status_code, status.HTTP_429_TOO_MANY_REQUESTS)
def test_reservation_creation_with_insufficient_seats(self): def test_reservation_creation_with_insufficient_seats(self):
response = self.client.post( response = self.client.post(
reverse("api-reservation-create", kwargs={"performance_id": self.performance.id}), reverse("api-reservation-create", kwargs={"performance_id": self.performance.id}),
@@ -123,6 +168,7 @@ class BookingApiTests(APITestCase):
"https://tickets.azionelab.example/api/check-ins/preview/?token=" "https://tickets.azionelab.example/api/check-ins/preview/?token="
) )
) )
self.assertNotIn(raw_token, response.data["qr_code_url"])
self.assertNotIn("token", response.data) self.assertNotIn("token", response.data)
self.assertTrue(response.data["qr_code_image"].startswith("data:image/png;base64,")) self.assertTrue(response.data["qr_code_image"].startswith("data:image/png;base64,"))
self.assertEqual(reservation.status, Reservation.Status.CONFIRMED) self.assertEqual(reservation.status, Reservation.Status.CONFIRMED)
@@ -170,8 +216,55 @@ class BookingApiTests(APITestCase):
self.assertEqual(second_response.status_code, status.HTTP_409_CONFLICT) self.assertEqual(second_response.status_code, status.HTTP_409_CONFLICT)
self.assertEqual(second_response.data["status"], "already_confirmed") self.assertEqual(second_response.data["status"], "already_confirmed")
def test_confirmation_is_throttled(self):
with patch.dict(ReservationConfirmThrottle.THROTTLE_RATES, {"reservation_confirm": "1/minute"}, clear=False):
first_reservation = self.create_reservation(email="first@example.com")
_, first_raw_token = generate_confirmation_token(first_reservation)
second_reservation = self.create_reservation(email="second@example.com")
_, second_raw_token = generate_confirmation_token(second_reservation)
first_response = self.client.post(
reverse("api-reservation-confirm"),
{"token": first_raw_token},
format="json",
)
second_response = self.client.post(
reverse("api-reservation-confirm"),
{"token": second_raw_token},
format="json",
)
self.assertEqual(first_response.status_code, status.HTTP_200_OK)
self.assertEqual(second_response.status_code, status.HTTP_429_TOO_MANY_REQUESTS)
@override_settings(SITE_BASE_URL="https://tickets.azionelab.example") @override_settings(SITE_BASE_URL="https://tickets.azionelab.example")
def test_qr_retrieval_success_for_confirmed_reservation(self): def test_qr_retrieval_success_for_confirmed_reservation(self):
reservation = self.create_reservation()
_, raw_token = generate_confirmation_token(reservation)
confirmation_response = self.client.post(
reverse("api-reservation-confirm"),
{"token": raw_token},
format="json",
)
check_in_token = confirmation_response.data["qr_code_url"].split("token=", 1)[1]
response = self.client.get(
reverse("api-reservation-qr"),
{"token": check_in_token},
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["reservation_id"], reservation.id)
self.assertTrue(
response.data["qr_code_url"].startswith(
"https://tickets.azionelab.example/api/check-ins/preview/?token="
)
)
self.assertTrue(response.data["qr_code_image"].startswith("data:image/png;base64,"))
self.assertNotIn("email", response.data)
self.assertNotIn("name", response.data)
def test_qr_retrieval_rejects_confirmation_token(self):
reservation = self.create_reservation() reservation = self.create_reservation()
_, raw_token = generate_confirmation_token(reservation) _, raw_token = generate_confirmation_token(reservation)
self.client.post( self.client.post(
@@ -185,16 +278,8 @@ class BookingApiTests(APITestCase):
{"token": raw_token}, {"token": raw_token},
) )
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
self.assertEqual(response.data["reservation_id"], reservation.id) self.assertEqual(response.data["status"], "invalid_token")
self.assertTrue(
response.data["qr_code_url"].startswith(
"https://tickets.azionelab.example/api/check-ins/preview/?token="
)
)
self.assertTrue(response.data["qr_code_image"].startswith("data:image/png;base64,"))
self.assertNotIn("email", response.data)
self.assertNotIn("name", response.data)
def test_qr_retrieval_fails_for_invalid_token(self): def test_qr_retrieval_fails_for_invalid_token(self):
response = self.client.get( response = self.client.get(
@@ -214,8 +299,8 @@ class BookingApiTests(APITestCase):
{"token": raw_token}, {"token": raw_token},
) )
self.assertEqual(response.status_code, status.HTTP_409_CONFLICT) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
self.assertEqual(response.data["status"], "reservation_not_confirmed") self.assertEqual(response.data["status"], "invalid_token")
self.assertEqual(reservation.status, Reservation.Status.PENDING) self.assertEqual(reservation.status, Reservation.Status.PENDING)
def create_reservation(self, **overrides): def create_reservation(self, **overrides):
+64 -12
View File
@@ -19,6 +19,7 @@ from bookings.services import (
confirm_reservation_from_token, confirm_reservation_from_token,
create_pending_reservation, create_pending_reservation,
generate_confirmation_token, generate_confirmation_token,
retrieve_reservation_qr_from_token,
) )
from shows.models import Performance, Show, Venue from shows.models import Performance, Show, Venue
@@ -64,14 +65,16 @@ class BookingServiceTests(TestCase):
EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend", EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend",
SITE_BASE_URL="https://tickets.azionelab.example", SITE_BASE_URL="https://tickets.azionelab.example",
) )
def test_create_pending_reservation_sends_confirmation_email(self): def test_create_pending_reservation_sends_confirmation_email_after_commit(self):
result = create_pending_reservation( with self.captureOnCommitCallbacks(execute=True) as callbacks:
performance_id=self.performance.id, result = create_pending_reservation(
name="Maria Rossi", performance_id=self.performance.id,
email="maria@example.com", name="Maria Rossi",
party_size=1, email="maria@example.com",
) party_size=1,
)
self.assertEqual(len(callbacks), 1)
self.assertEqual(len(mail.outbox), 1) self.assertEqual(len(mail.outbox), 1)
self.assertEqual(mail.outbox[0].to, ["maria@example.com"]) self.assertEqual(mail.outbox[0].to, ["maria@example.com"])
self.assertIn(result.raw_confirmation_token, mail.outbox[0].body) self.assertIn(result.raw_confirmation_token, mail.outbox[0].body)
@@ -80,16 +83,30 @@ class BookingServiceTests(TestCase):
mail.outbox[0].body, mail.outbox[0].body,
) )
@patch("bookings.emailing.send_mail", side_effect=RuntimeError("SMTP down")) @override_settings(EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend")
def test_create_pending_reservation_logs_email_failure_without_crashing(self, mocked_send_mail): def test_create_pending_reservation_defers_email_until_commit(self):
with self.assertLogs("bookings.emailing", level="ERROR") as captured_logs: with self.captureOnCommitCallbacks(execute=False) as callbacks:
result = create_pending_reservation( create_pending_reservation(
performance_id=self.performance.id, performance_id=self.performance.id,
name="Maria Rossi", name="Maria Rossi",
email="maria@example.com", email="maria@example.com",
party_size=1, party_size=1,
) )
self.assertEqual(len(callbacks), 1)
self.assertEqual(len(mail.outbox), 0)
@patch("bookings.emailing.send_mail", side_effect=RuntimeError("SMTP down"))
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.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.assertEqual(result.reservation.status, Reservation.Status.PENDING)
self.assertEqual(Reservation.objects.count(), 1) self.assertEqual(Reservation.objects.count(), 1)
mocked_send_mail.assert_called_once() mocked_send_mail.assert_called_once()
@@ -130,7 +147,7 @@ class BookingServiceTests(TestCase):
self.assertEqual(result.available_seats, 1) self.assertEqual(result.available_seats, 1)
self.assertEqual( self.assertEqual(
result.qr_code_url, result.qr_code_url,
build_check_in_preview_url(raw_token), build_check_in_preview_url(result.raw_check_in_token),
) )
self.assertTrue( self.assertTrue(
result.qr_code_url.startswith( result.qr_code_url.startswith(
@@ -139,6 +156,19 @@ class BookingServiceTests(TestCase):
) )
self.assertTrue(result.qr_code_image.startswith("data:image/png;base64,")) self.assertTrue(result.qr_code_image.startswith("data:image/png;base64,"))
@override_settings(SITE_BASE_URL="https://tickets.azionelab.example")
def test_confirmation_token_cannot_be_reused_as_qr_or_check_in_token(self):
reservation = self.create_reservation()
_, raw_token = generate_confirmation_token(reservation)
result = confirm_reservation_from_token(raw_token)
self.assertNotEqual(raw_token, result.raw_check_in_token)
self.assertNotEqual(
build_check_in_preview_url(raw_token),
result.qr_code_url,
)
@override_settings(SITE_BASE_URL="https://tickets.azionelab.example") @override_settings(SITE_BASE_URL="https://tickets.azionelab.example")
def test_qr_code_is_generated_for_confirmed_reservation(self): def test_qr_code_is_generated_for_confirmed_reservation(self):
reservation = self.create_reservation( reservation = self.create_reservation(
@@ -171,6 +201,28 @@ class BookingServiceTests(TestCase):
raw_check_in_token="opaque-check-in-token", raw_check_in_token="opaque-check-in-token",
) )
def test_qr_retrieval_rejects_confirmation_token(self):
reservation = self.create_reservation()
_, raw_confirmation_token = generate_confirmation_token(reservation)
confirm_reservation_from_token(raw_confirmation_token)
with self.assertRaises(InvalidToken):
retrieve_reservation_qr_from_token(raw_confirmation_token)
def test_qr_retrieval_accepts_check_in_token(self):
reservation = self.create_reservation()
_, raw_confirmation_token = generate_confirmation_token(reservation)
result = confirm_reservation_from_token(raw_confirmation_token)
qr_result = retrieve_reservation_qr_from_token(result.raw_check_in_token)
self.assertEqual(qr_result.reservation, reservation)
self.assertEqual(
qr_result.qr_code_url,
build_check_in_preview_url(result.raw_check_in_token),
)
self.assertTrue(qr_result.qr_code_image.startswith("data:image/png;base64,"))
def test_confirmation_fails_when_capacity_is_exhausted(self): def test_confirmation_fails_when_capacity_is_exhausted(self):
Reservation.objects.create( Reservation.objects.create(
performance=self.performance, performance=self.performance,
+12 -1
View File
@@ -1,7 +1,8 @@
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from rest_framework import status from rest_framework import status
from rest_framework.decorators import api_view from rest_framework.decorators import api_view, throttle_classes
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.throttling import AnonRateThrottle
from shows.models import Performance from shows.models import Performance
@@ -25,7 +26,16 @@ from .services import (
) )
class ReservationCreateThrottle(AnonRateThrottle):
scope = "reservation_create"
class ReservationConfirmThrottle(AnonRateThrottle):
scope = "reservation_confirm"
@api_view(["POST"]) @api_view(["POST"])
@throttle_classes([ReservationCreateThrottle])
def create_reservation(request, performance_id): def create_reservation(request, performance_id):
get_object_or_404(Performance, pk=performance_id, show__is_published=True) get_object_or_404(Performance, pk=performance_id, show__is_published=True)
@@ -60,6 +70,7 @@ def create_reservation(request, performance_id):
@api_view(["GET", "POST"]) @api_view(["GET", "POST"])
@throttle_classes([ReservationConfirmThrottle])
def confirm_reservation(request): def confirm_reservation(request):
payload = request.query_params if request.method == "GET" else request.data payload = request.query_params if request.method == "GET" else request.data
serializer = ReservationConfirmSerializer(data=payload) serializer = ReservationConfirmSerializer(data=payload)
+1
View File
@@ -18,6 +18,7 @@ class CheckInPreviewResponseSerializer(serializers.Serializer):
reservation_id = serializers.IntegerField() reservation_id = serializers.IntegerField()
performance_id = serializers.IntegerField() performance_id = serializers.IntegerField()
show_title = serializers.CharField() show_title = serializers.CharField()
venue_name = serializers.CharField()
starts_at = serializers.DateTimeField() starts_at = serializers.DateTimeField()
party_size = serializers.IntegerField() party_size = serializers.IntegerField()
+2 -7
View File
@@ -98,17 +98,12 @@ def _get_reservation_for_check_in_token(raw_token, *, lock_token=False):
try: try:
token = queryset.get( token = queryset.get(
token_hash=ReservationToken.hash_token(raw_token), token_hash=ReservationToken.hash_token(raw_token),
purpose=ReservationToken.Purpose.CHECK_IN,
) )
except ReservationToken.DoesNotExist as exc: except ReservationToken.DoesNotExist as exc:
raise InvalidToken("Check-in token is invalid.") from exc raise InvalidToken("Check-in token is invalid.") from exc
if token.purpose == ReservationToken.Purpose.CHECK_IN: if token.used_at is not None or token.is_expired:
if token.used_at is not None or token.is_expired:
raise InvalidToken("Check-in token is invalid.")
elif token.purpose == ReservationToken.Purpose.CONFIRMATION:
if token.reservation.status != Reservation.Status.CONFIRMED:
raise InvalidToken("Check-in token is invalid.")
else:
raise InvalidToken("Check-in token is invalid.") raise InvalidToken("Check-in token is invalid.")
return token.reservation return token.reservation
+63
View File
@@ -1,13 +1,16 @@
from datetime import timedelta from datetime import timedelta
from unittest.mock import patch
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.urls import reverse from django.urls import reverse
from django.test.utils import override_settings
from django.utils import timezone from django.utils import timezone
from rest_framework import status from rest_framework import status
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
from bookings.models import Reservation, ReservationToken from bookings.models import Reservation, ReservationToken
from checkins.models import CheckIn from checkins.models import CheckIn
from checkins.views import CheckInPreviewThrottle
from shows.models import Performance, Show, Venue from shows.models import Performance, Show, Venue
@@ -57,6 +60,7 @@ class CheckInApiTests(APITestCase):
self.assertEqual(response.data["reservation_id"], reservation.id) self.assertEqual(response.data["reservation_id"], reservation.id)
self.assertEqual(response.data["performance_id"], self.performance.id) self.assertEqual(response.data["performance_id"], self.performance.id)
self.assertEqual(response.data["show_title"], self.show.title) self.assertEqual(response.data["show_title"], self.show.title)
self.assertEqual(response.data["venue_name"], self.venue.name)
self.assertEqual(response.data["party_size"], reservation.party_size) self.assertEqual(response.data["party_size"], reservation.party_size)
self.assertNotIn("name", response.data) self.assertNotIn("name", response.data)
self.assertNotIn("email", response.data) self.assertNotIn("email", response.data)
@@ -86,6 +90,46 @@ class CheckInApiTests(APITestCase):
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
self.assertEqual(response.data["status"], "invalid_token") self.assertEqual(response.data["status"], "invalid_token")
def test_preview_rejects_confirmation_token(self):
reservation = self.create_reservation()
_, raw_token = ReservationToken.create_token(
reservation=reservation,
purpose=ReservationToken.Purpose.CONFIRMATION,
expires_at=timezone.now() + timedelta(hours=2),
)
self.client.force_authenticate(user=self.staff_user)
response = self.client.post(
reverse("api-check-in-preview"),
{"token": raw_token},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
self.assertEqual(response.data["status"], "invalid_token")
def test_preview_is_throttled_for_staff_user(self):
with patch.dict(CheckInPreviewThrottle.THROTTLE_RATES, {"check_in_preview": "1/minute"}, clear=False):
first_reservation = self.create_reservation(email="first@example.com")
_, first_raw_token = self.create_check_in_token(first_reservation)
second_reservation = self.create_reservation(email="second@example.com")
_, second_raw_token = self.create_check_in_token(second_reservation)
self.client.force_authenticate(user=self.staff_user)
first_response = self.client.post(
reverse("api-check-in-preview"),
{"token": first_raw_token},
format="json",
)
second_response = self.client.post(
reverse("api-check-in-preview"),
{"token": second_raw_token},
format="json",
)
self.assertEqual(first_response.status_code, status.HTTP_200_OK)
self.assertEqual(second_response.status_code, status.HTTP_429_TOO_MANY_REQUESTS)
def test_check_in_success_as_staff_user(self): def test_check_in_success_as_staff_user(self):
reservation = self.create_reservation() reservation = self.create_reservation()
_, raw_token = self.create_check_in_token(reservation) _, raw_token = self.create_check_in_token(reservation)
@@ -170,6 +214,25 @@ class CheckInApiTests(APITestCase):
self.assertEqual(second_response.data["status"], "already_checked_in") self.assertEqual(second_response.data["status"], "already_checked_in")
self.assertEqual(CheckIn.objects.filter(reservation=reservation).count(), 1) self.assertEqual(CheckIn.objects.filter(reservation=reservation).count(), 1)
def test_check_in_rejects_confirmation_token(self):
reservation = self.create_reservation()
_, raw_token = ReservationToken.create_token(
reservation=reservation,
purpose=ReservationToken.Purpose.CONFIRMATION,
expires_at=timezone.now() + timedelta(hours=2),
)
self.client.force_authenticate(user=self.staff_user)
response = self.client.post(
reverse("api-check-in-confirm"),
{"token": raw_token},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
self.assertEqual(response.data["status"], "invalid_token")
self.assertFalse(CheckIn.objects.filter(reservation=reservation).exists())
def create_reservation(self, **overrides): def create_reservation(self, **overrides):
data = { data = {
"performance": self.performance, "performance": self.performance,
+22
View File
@@ -62,6 +62,17 @@ class CheckInServiceTests(TestCase):
with self.assertRaises(InvalidToken): with self.assertRaises(InvalidToken):
preview_check_in_token("invalid-token", staff_user=self.staff_user) preview_check_in_token("invalid-token", staff_user=self.staff_user)
def test_preview_rejects_confirmation_token_even_for_confirmed_reservation(self):
reservation = self.create_reservation()
_, raw_token = ReservationToken.create_token(
reservation=reservation,
purpose=ReservationToken.Purpose.CONFIRMATION,
expires_at=timezone.now() + timedelta(hours=2),
)
with self.assertRaises(InvalidToken):
preview_check_in_token(raw_token, staff_user=self.staff_user)
def test_check_in_succeeds_for_confirmed_reservation(self): def test_check_in_succeeds_for_confirmed_reservation(self):
reservation = self.create_reservation() reservation = self.create_reservation()
_, raw_token = self.create_check_in_token(reservation) _, raw_token = self.create_check_in_token(reservation)
@@ -114,6 +125,17 @@ 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_check_in_rejects_confirmation_token_even_for_confirmed_reservation(self):
reservation = self.create_reservation()
_, raw_token = ReservationToken.create_token(
reservation=reservation,
purpose=ReservationToken.Purpose.CONFIRMATION,
expires_at=timezone.now() + timedelta(hours=2),
)
with self.assertRaises(InvalidToken):
confirm_check_in_from_token(raw_token, staff_user=self.staff_user)
def create_reservation(self, **overrides): def create_reservation(self, **overrides):
data = { data = {
"performance": self.performance, "performance": self.performance,
+19 -11
View File
@@ -1,8 +1,9 @@
from rest_framework import status from rest_framework import status
from rest_framework.authentication import BasicAuthentication, SessionAuthentication from rest_framework.authentication import BasicAuthentication, SessionAuthentication
from rest_framework.decorators import api_view, authentication_classes, permission_classes from rest_framework.decorators import api_view, authentication_classes, permission_classes, throttle_classes
from rest_framework.permissions import BasePermission, IsAuthenticated from rest_framework.permissions import BasePermission, IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.throttling import UserRateThrottle
from .serializers import ( from .serializers import (
CheckInConfirmResponseSerializer, CheckInConfirmResponseSerializer,
@@ -19,19 +20,22 @@ from .services import (
) )
class CheckInPreviewThrottle(UserRateThrottle):
scope = "check_in_preview"
class CheckInConfirmThrottle(UserRateThrottle):
scope = "check_in_confirm"
class IsStaffUser(BasePermission): class IsStaffUser(BasePermission):
def has_permission(self, request, view): def has_permission(self, request, view):
return bool(request.user and request.user.is_staff) return bool(request.user and request.user.is_staff)
@api_view(["POST"])
def staff_check_in_view(view_func): @authentication_classes([BasicAuthentication, SessionAuthentication])
view_func = permission_classes([IsAuthenticated, IsStaffUser])(view_func) @permission_classes([IsAuthenticated, IsStaffUser])
view_func = authentication_classes([BasicAuthentication, SessionAuthentication])(view_func) @throttle_classes([CheckInPreviewThrottle])
view_func = api_view(["POST"])(view_func)
return view_func
@staff_check_in_view
def check_in_preview(request): def check_in_preview(request):
serializer = CheckInTokenSerializer(data=request.data) serializer = CheckInTokenSerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
@@ -65,6 +69,7 @@ def check_in_preview(request):
"reservation_id": preview.reservation_id, "reservation_id": preview.reservation_id,
"performance_id": preview.performance_id, "performance_id": preview.performance_id,
"show_title": preview.show_title, "show_title": preview.show_title,
"venue_name": preview.venue_name,
"starts_at": preview.starts_at, "starts_at": preview.starts_at,
"party_size": preview.party_size, "party_size": preview.party_size,
} }
@@ -72,7 +77,10 @@ def check_in_preview(request):
return Response(response_serializer.data) return Response(response_serializer.data)
@staff_check_in_view @api_view(["POST"])
@authentication_classes([BasicAuthentication, SessionAuthentication])
@permission_classes([IsAuthenticated, IsStaffUser])
@throttle_classes([CheckInConfirmThrottle])
def check_in_confirm(request): def check_in_confirm(request):
serializer = CheckInTokenSerializer(data=request.data) serializer = CheckInTokenSerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
+22
View File
@@ -1,4 +1,6 @@
from django.contrib import admin from django.contrib import admin
from django.urls import reverse
from django.utils.html import format_html
from .models import Performance, Show, Venue from .models import Performance, Show, Venue
@@ -32,6 +34,7 @@ class PerformanceAdmin(admin.ModelAdmin):
"manually_occupied_seats", "manually_occupied_seats",
"available_seats_display", "available_seats_display",
"is_booking_enabled", "is_booking_enabled",
"create_reservation_link",
) )
list_filter = ("is_booking_enabled", "starts_at", "show", "venue") list_filter = ("is_booking_enabled", "starts_at", "show", "venue")
search_fields = ("show__title", "venue__name", "venue__city") search_fields = ("show__title", "venue__name", "venue__city")
@@ -41,4 +44,23 @@ class PerformanceAdmin(admin.ModelAdmin):
@admin.display(description="Available seats") @admin.display(description="Available seats")
def available_seats_display(self, obj): def available_seats_display(self, obj):
if (
not getattr(obj, "pk", None)
or obj.room_capacity is None
or obj.additional_seats is None
or obj.manually_occupied_seats is None
):
return "-"
return obj.available_seats() return obj.available_seats()
@admin.display(description="Manual reservation")
def create_reservation_link(self, obj):
if not getattr(obj, "pk", None):
return "-"
url = reverse("admin:bookings_reservation_add")
return format_html(
'<a href="{}?performance={}">Create reservation</a>',
url,
obj.pk,
)
+20 -1
View File
@@ -1,5 +1,7 @@
from django.contrib import admin from django.contrib import admin
from django.test import SimpleTestCase from django.contrib.auth import get_user_model
from django.test import SimpleTestCase, TestCase
from django.urls import reverse
from bookings.models import Reservation, ReservationToken from bookings.models import Reservation, ReservationToken
from checkins.models import CheckIn from checkins.models import CheckIn
@@ -11,3 +13,20 @@ class AdminRegistrationTests(SimpleTestCase):
for model in (Show, Venue, Performance, Reservation, ReservationToken, CheckIn): for model in (Show, Venue, Performance, Reservation, ReservationToken, CheckIn):
with self.subTest(model=model.__name__): with self.subTest(model=model.__name__):
self.assertTrue(admin.site.is_registered(model)) self.assertTrue(admin.site.is_registered(model))
class PerformanceAdminTests(TestCase):
def setUp(self):
user_model = get_user_model()
self.admin_user = user_model.objects.create_superuser(
username="admin",
email="admin@example.com",
password="password123",
)
self.client.force_login(self.admin_user)
def test_performance_add_page_renders_for_unsaved_object(self):
response = self.client.get(reverse("admin:shows_performance_add"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Available seats")
+4 -2
View File
@@ -204,7 +204,7 @@ Response `200 OK`:
"reservation_id": 123, "reservation_id": 123,
"status": "confirmed", "status": "confirmed",
"party_size": 2, "party_size": 2,
"qr_code_url": "https://example.org/api/reservations/123/qr-code/" "qr_code_url": "https://example.org/api/check-ins/preview/?token=opaque-check-in-token"
} }
``` ```
@@ -222,13 +222,14 @@ Status codes:
GET /api/reservations/{id}/qr-code/ GET /api/reservations/{id}/qr-code/
``` ```
Returns the generated QR code for a confirmed reservation. Access must be protected by a valid QR token, signed URL, or equivalent control so that reservation IDs are not enough to retrieve QR codes. Returns the generated QR code for a confirmed reservation. Access must be protected by a valid opaque `check_in` token, signed URL, or equivalent control so that reservation IDs are not enough to retrieve QR codes.
Response `200 OK`: Response `200 OK`:
```json ```json
{ {
"reservation_id": 123, "reservation_id": 123,
"qr_code_url": "https://example.org/api/check-ins/preview/?token=opaque-check-in-token",
"qr_code_image": "data:image/png;base64,...", "qr_code_image": "data:image/png;base64,...",
"printable": true "printable": true
} }
@@ -271,6 +272,7 @@ Response `200 OK`:
"reservation_id": 123, "reservation_id": 123,
"performance_id": 10, "performance_id": 10,
"show_title": "The Open Stage", "show_title": "The Open Stage",
"venue_name": "AzioneLab Theatre",
"starts_at": "2026-05-15T20:30:00+02:00", "starts_at": "2026-05-15T20:30:00+02:00",
"party_size": 2 "party_size": 2
} }
+16 -3
View File
@@ -25,7 +25,7 @@ Availability shown to visitors is informational. The backend recalculates availa
- requested seats do not exceed currently available seats. - requested seats do not exceed currently available seats.
6. The backend creates a `pending` reservation. 6. The backend creates a `pending` reservation.
7. The backend creates a random opaque confirmation token. 7. The backend creates a random opaque confirmation token.
8. The backend sends an email with a confirmation link. 8. After the transaction commits successfully, the backend sends an email with a confirmation link.
9. The frontend tells the visitor to check their email. 9. The frontend tells the visitor to check their email.
The reservation is not confirmed at this stage. The reservation is not confirmed at this stage.
@@ -45,8 +45,8 @@ The reservation is not confirmed at this stage.
6. The backend recalculates confirmed reservations for the performance. 6. The backend recalculates confirmed reservations for the performance.
7. The backend confirms the reservation only if enough seats remain. 7. The backend confirms the reservation only if enough seats remain.
8. The backend marks the confirmation token as used. 8. The backend marks the confirmation token as used.
9. The backend creates a QR verification token. 9. The backend creates a separate `check_in` token for QR verification.
10. The backend generates a QR code containing the opaque QR token or a verification URL. 10. The backend generates a QR code containing only the opaque check-in token or a verification URL built from that token.
11. The backend returns or sends the QR code to the visitor. 11. The backend returns or sends the QR code to the visitor.
If there is no longer enough capacity, the backend must not confirm the reservation. If there is no longer enough capacity, the backend must not confirm the reservation.
@@ -101,6 +101,19 @@ The QR code must not contain:
The token remains opaque throughout the flow. The QR code must not expose visitor name, email address, phone number, notes, or other personal data. The token remains opaque throughout the flow. The QR code must not expose visitor name, email address, phone number, notes, or other personal data.
## Manual Reservations In Admin
Staff can also create a reservation manually from Django admin for a specific performance.
This operational flow should still follow the same backend rules as the public booking flow:
1. staff selects the performance and enters guest contact details and party size;
2. the backend validates booking availability and capacity;
3. the backend creates a `pending` reservation;
4. the backend creates the normal confirmation token;
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.
## Duplicate Check-In ## Duplicate Check-In
If the same QR code is scanned again: If the same QR code is scanned again:
+35
View File
@@ -1,5 +1,28 @@
# Deployment # Deployment
## Production Readiness
Before a real deployment, treat `.env.example` as local-development only. Create a separate `.env` for production and replace all placeholder values.
Required production changes:
- set `DJANGO_DEBUG=false`;
- set a strong random `DJANGO_SECRET_KEY`;
- set `DJANGO_ALLOWED_HOSTS` to the real public hostnames only;
- set `DJANGO_CSRF_TRUSTED_ORIGINS` to the real public HTTPS origins;
- set `SITE_BASE_URL` to the real public HTTPS base URL used for confirmation emails and QR/check-in links;
- replace the console email backend with real SMTP settings and a valid sender address;
- publish only nginx and terminate HTTPS at nginx or a trusted upstream reverse proxy;
- keep `collectstatic --noinput` in the deployment flow before `up -d`;
- persist the PostgreSQL named volume and configure tested backups before accepting bookings;
- create the first admin account explicitly with `python manage.py createsuperuser`.
Reverse proxy and HTTPS notes:
- the current nginx template listens on plain HTTP port `80` only and must be adapted for production TLS;
- if TLS is terminated by another reverse proxy, forward the public host and scheme correctly so generated links remain accurate;
- keep `SITE_BASE_URL`, `DJANGO_ALLOWED_HOSTS`, and `DJANGO_CSRF_TRUSTED_ORIGINS` aligned with the final public URL.
AzioneLab should deploy with a simple Docker Compose topology: AzioneLab should deploy with a simple Docker Compose topology:
- `nginx`: public reverse proxy and static frontend server; - `nginx`: public reverse proxy and static frontend server;
@@ -92,17 +115,28 @@ Generated QR codes may also be generated on demand instead of stored as files. I
Copy `.env.example` to `.env` and replace all placeholder values before running or deploying the stack. Copy `.env.example` to `.env` and replace all placeholder values before running or deploying the stack.
`.env.example` is intentionally local-dev oriented. Do not use it unchanged for production.
Required backend configuration: Required backend configuration:
- `DJANGO_SECRET_KEY`; - `DJANGO_SECRET_KEY`;
- `DJANGO_ALLOWED_HOSTS`; - `DJANGO_ALLOWED_HOSTS`;
- `DJANGO_CSRF_TRUSTED_ORIGINS`; - `DJANGO_CSRF_TRUSTED_ORIGINS`;
- `DJANGO_DEBUG=false`;
- `CORS_ALLOWED_ORIGINS`; - `CORS_ALLOWED_ORIGINS`;
- `SITE_BASE_URL`;
- `TIME_ZONE`; - `TIME_ZONE`;
- `DATABASE_URL` or equivalent database settings; - `DATABASE_URL` or equivalent database settings;
- email host, port, username, password, TLS settings, and sender address; - email host, port, username, password, TLS settings, and sender address;
- public site URL used to build confirmation and QR verification links. - public site URL used to build confirmation and QR verification links.
Local Docker convention:
- use nginx as the public entrypoint at `http://localhost`;
- set `SITE_BASE_URL=http://localhost`;
- 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.
Required database configuration: Required database configuration:
- database name; - database name;
@@ -142,6 +176,7 @@ Expected production-style flow:
docker compose --env-file .env -f infra/docker/compose.yml build docker compose --env-file .env -f infra/docker/compose.yml build
docker compose --env-file .env -f infra/docker/compose.yml run --rm backend python manage.py migrate docker compose --env-file .env -f infra/docker/compose.yml run --rm backend python manage.py migrate
docker compose --env-file .env -f infra/docker/compose.yml run --rm backend python manage.py collectstatic --noinput docker compose --env-file .env -f infra/docker/compose.yml run --rm backend python manage.py collectstatic --noinput
docker compose --env-file .env -f infra/docker/compose.yml run --rm backend python manage.py createsuperuser
docker compose --env-file .env -f infra/docker/compose.yml up -d docker compose --env-file .env -f infra/docker/compose.yml up -d
``` ```
+15 -1
View File
@@ -36,6 +36,9 @@ Rules:
- do not encode personal data in tokens; - do not encode personal data in tokens;
- store token hashes when practical; - store token hashes when practical;
- treat raw tokens as secrets; - treat raw tokens as secrets;
- keep confirmation tokens and check-in tokens separate by purpose;
- allow confirmation tokens only for reservation confirmation;
- allow check-in tokens only for QR retrieval and check-in validation;
- mark one-time confirmation tokens as used after successful confirmation; - mark one-time confirmation tokens as used after successful confirmation;
- expire confirmation tokens after a reasonable period; - expire confirmation tokens after a reasonable period;
- keep QR tokens valid only for the intended performance and check-in period where practical. - keep QR tokens valid only for the intended performance and check-in period where practical.
@@ -144,9 +147,20 @@ Deployment should follow least privilege:
- avoid privileged containers; - avoid privileged containers;
- use explicit image tags rather than `latest`; - use explicit image tags rather than `latest`;
- persist PostgreSQL data in a named volume; - persist PostgreSQL data in a named volume;
- run production with `DJANGO_DEBUG=false`;
- use a strong private `DJANGO_SECRET_KEY`;
- restrict `DJANGO_ALLOWED_HOSTS` and `DJANGO_CSRF_TRUSTED_ORIGINS` to the real public deployment hosts;
- keep `SITE_BASE_URL` set to the real public HTTPS URL so email and QR links are correct;
- configure TLS for production; - configure TLS for production;
- serve static and media files without exposing private files. - serve static and media files without exposing private files.
Operational production notes:
- `.env.example` is for local development and examples only, not direct production use;
- replace the console email backend with real SMTP settings before sending reservation emails;
- create admin accounts explicitly and protect them with strong passwords and limited access;
- keep verified database backups for the PostgreSQL volume before accepting live bookings.
## Logging ## Logging
Logs should help diagnose operational issues without exposing sensitive data. Logs should help diagnose operational issues without exposing sensitive data.
@@ -165,7 +179,7 @@ Do not log:
Initial residual risks: Initial residual risks:
- synchronous email can make booking responses depend on SMTP availability; - synchronous email after commit can still add latency to booking requests even though it no longer runs inside the reservation transaction;
- QR codes can be copied, so duplicate check-in prevention must be reliable; - QR codes can be copied, so duplicate check-in prevention must be reliable;
- staff account compromise would expose admin and check-in functionality; - staff account compromise would expose admin and check-in functionality;
- retention and deletion rules for personal data still need a project policy. - retention and deletion rules for personal data still need a project policy.
@@ -1,28 +1,180 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'; import { DatePipe } from '@angular/common';
import { HttpErrorResponse } from '@angular/common/http';
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
ElementRef,
ViewChild,
inject,
signal,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import {
CheckInConfirmResponse,
CheckInPreviewResponse,
ShowsApiService,
} from '../services/shows-api.service';
type UiState =
| 'idle'
| 'preview_loading'
| 'preview_success'
| 'confirm_loading'
| 'confirm_success'
| 'invalid_token'
| 'pending_reservation'
| 'already_checked_in'
| 'unauthorized'
| 'error';
type CameraState = 'ready' | 'starting' | 'active' | 'unsupported' | 'denied' | 'error';
type DetectedBarcode = {
rawValue?: string;
};
type BarcodeDetectorInstance = {
detect(source: HTMLCanvasElement): Promise<DetectedBarcode[]>;
};
type BarcodeDetectorConstructor = new (options?: { formats?: string[] }) => BarcodeDetectorInstance;
@Component({ @Component({
standalone: true, standalone: true,
imports: [MatCardModule, MatFormFieldModule, MatInputModule], imports: [
DatePipe,
ReactiveFormsModule,
RouterLink,
MatButtonModule,
MatCardModule,
MatFormFieldModule,
MatInputModule,
MatProgressSpinnerModule,
],
template: ` template: `
<section class="page"> <section class="page">
<header class="page-header"> <header class="page-header">
<p class="eyebrow">Staff check-in</p> <p class="eyebrow">Staff check-in</p>
<h1>Mobile-friendly placeholder</h1> <h1>Token validation</h1>
<p class="supporting"> <p class="supporting">Enter a token manually or scan a QR code to preview admission data and confirm entrance.</p>
This route is ready for the authenticated token preview and check-in confirmation flow.
</p>
</header> </header>
<mat-card class="content-card"> <mat-card class="content-card">
<mat-card-title>Future scan/lookup input</mat-card-title>
<mat-card-content> <mat-card-content>
<mat-form-field appearance="outline" class="full-width"> <section class="scanner-panel">
<mat-label>Opaque QR token</mat-label> <div class="scanner-copy">
<input matInput placeholder="Paste or scan token" readonly> <h2>Camera scan</h2>
</mat-form-field> <p>Optional on supported browsers. If the QR contains a full check-in URL, the token is extracted automatically.</p>
</div>
<div class="actions scanner-actions">
@if (cameraState() === 'active') {
<button mat-stroked-button type="button" (click)="stopScanner()">Stop camera</button>
} @else {
<button
mat-stroked-button
type="button"
(click)="startScanner()"
[disabled]="cameraState() === 'unsupported' || cameraState() === 'starting'"
>
@if (cameraState() === 'starting') {
<mat-progress-spinner mode="indeterminate" diameter="18"></mat-progress-spinner>
<span>Starting camera...</span>
} @else {
<span>Use camera</span>
}
</button>
}
</div>
@if (cameraState() === 'active') {
<div class="camera-frame">
<video #scannerVideo autoplay playsinline muted></video>
<canvas #scannerCanvas class="scanner-canvas" aria-hidden="true"></canvas>
</div>
}
@if (cameraMessage()) {
<p class="camera-message">{{ cameraMessage() }}</p>
}
</section>
<form [formGroup]="tokenForm" (ngSubmit)="preview()" novalidate>
<mat-form-field appearance="outline" class="full-width">
<mat-label>Opaque token</mat-label>
<input matInput formControlName="token" autocomplete="off" />
@if (tokenForm.controls.token.touched && tokenForm.controls.token.hasError('required')) {
<mat-error>Token is required.</mat-error>
}
</mat-form-field>
<div class="actions">
<button mat-flat-button type="submit" [disabled]="isBusy()">
@if (state() === 'preview_loading') {
<mat-progress-spinner mode="indeterminate" diameter="18"></mat-progress-spinner>
<span>Validating...</span>
} @else {
<span>Preview check-in</span>
}
</button>
<a mat-button routerLink="/">Home</a>
<a mat-button routerLink="/shows">Shows</a>
</div>
</form>
@if (previewData() && shouldShowPreview()) {
<section class="preview-panel" aria-live="polite">
<h2>Admission preview</h2>
<dl>
<div><dt>Show</dt><dd>{{ previewData()!.show_title }}</dd></div>
<div><dt>Venue</dt><dd>{{ previewData()!.venue_name }}</dd></div>
<div><dt>Starts at</dt><dd>{{ previewData()!.starts_at | date: 'EEEE d MMMM, HH:mm' }}</dd></div>
<div><dt>Party size</dt><dd>{{ previewData()!.party_size }}</dd></div>
<div><dt>Reservation</dt><dd>#{{ previewData()!.reservation_id }}</dd></div>
</dl>
<button mat-flat-button type="button" (click)="confirm()" [disabled]="isBusy() || state() === 'confirm_success'">
@if (state() === 'confirm_loading') {
<mat-progress-spinner mode="indeterminate" diameter="18"></mat-progress-spinner>
<span>Confirming...</span>
} @else if (state() === 'confirm_success') {
<span>Checked in</span>
} @else {
<span>Confirm check-in</span>
}
</button>
</section>
}
@if (state() === 'confirm_success' && confirmData()) {
<p class="success-message" aria-live="polite">
Check-in confirmed at {{ confirmData()!.checked_in_at | date: 'HH:mm' }}.
</p>
}
@if (state() === 'invalid_token') {
<p class="error-message" aria-live="assertive">Invalid token.</p>
}
@if (state() === 'pending_reservation') {
<p class="error-message" aria-live="assertive">Reservation is still pending confirmation.</p>
}
@if (state() === 'already_checked_in') {
<p class="error-message" aria-live="assertive">This reservation is already checked in.</p>
}
@if (state() === 'unauthorized') {
<p class="error-message" aria-live="assertive">You are not authorized. Staff login is required.</p>
}
@if (state() === 'error') {
<p class="error-message" aria-live="assertive">Something went wrong. Please try again.</p>
}
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
</section> </section>
@@ -63,10 +215,379 @@ import { MatInputModule } from '@angular/material/input';
box-shadow: var(--azionelab-shadow); box-shadow: var(--azionelab-shadow);
} }
.scanner-panel {
display: grid;
gap: 14px;
margin-bottom: 18px;
padding-bottom: 18px;
border-bottom: 1px solid var(--azionelab-border);
}
.scanner-copy h2 {
margin: 0 0 6px;
font-size: 1.15rem;
}
.scanner-copy p,
.camera-message {
margin: 0;
color: var(--azionelab-muted);
line-height: 1.5;
}
.camera-message {
font-size: 0.95rem;
}
.camera-frame {
overflow: hidden;
border-radius: 8px;
border: 1px solid var(--azionelab-border);
background: #151515;
}
.camera-frame video {
display: block;
width: 100%;
aspect-ratio: 4 / 3;
object-fit: cover;
}
.scanner-canvas {
display: none;
}
.full-width { .full-width {
width: 100%; width: 100%;
} }
.actions {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.scanner-actions {
justify-content: flex-start;
}
button[mat-flat-button],
button[mat-stroked-button] {
min-width: 150px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.preview-panel {
margin-top: 18px;
border-top: 1px solid var(--azionelab-border);
padding-top: 16px;
}
.preview-panel h2 {
margin: 0 0 12px;
font-size: 1.15rem;
}
.preview-panel dl {
display: grid;
gap: 10px;
margin: 0 0 16px;
}
.preview-panel dt {
font-size: 0.8rem;
text-transform: uppercase;
color: var(--azionelab-muted);
font-weight: 700;
}
.preview-panel dd {
margin: 2px 0 0;
}
.success-message {
margin: 16px 0 0;
color: #2e7d32;
font-weight: 600;
}
.error-message {
margin: 16px 0 0;
color: #b3261e;
font-weight: 500;
}
`], `],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class CheckInPlaceholderPageComponent {} export class CheckInPlaceholderPageComponent {
private readonly destroyRef = inject(DestroyRef);
private readonly formBuilder = inject(FormBuilder);
private readonly showsApi = inject(ShowsApiService);
private readonly barcodeDetectorCtor = (globalThis as { BarcodeDetector?: BarcodeDetectorConstructor }).BarcodeDetector;
private readonly scannerSupported =
!!this.barcodeDetectorCtor &&
typeof navigator !== 'undefined' &&
!!navigator.mediaDevices &&
typeof navigator.mediaDevices.getUserMedia === 'function';
private detector: BarcodeDetectorInstance | null = null;
private scannerStream: MediaStream | null = null;
private scanFrameId: number | null = null;
private scanInFlight = false;
@ViewChild('scannerVideo') private scannerVideo?: ElementRef<HTMLVideoElement>;
@ViewChild('scannerCanvas') private scannerCanvas?: ElementRef<HTMLCanvasElement>;
protected readonly tokenForm = this.formBuilder.nonNullable.group({
token: ['', [Validators.required]],
});
protected readonly state = signal<UiState>('idle');
protected readonly previewData = signal<CheckInPreviewResponse | null>(null);
protected readonly confirmData = signal<CheckInConfirmResponse | null>(null);
protected readonly cameraState = signal<CameraState>(this.scannerSupported ? 'ready' : 'unsupported');
protected readonly cameraMessage = signal(
this.scannerSupported
? 'Open the camera to scan a QR code, or keep using manual token entry.'
: 'Camera scanning is not available in this browser. Manual token entry still works.',
);
constructor() {
this.destroyRef.onDestroy(() => this.stopScanner());
}
protected preview(): void {
if (this.tokenForm.invalid) {
this.tokenForm.markAllAsTouched();
return;
}
const token = this.tokenForm.controls.token.value.trim();
if (!token) {
this.tokenForm.controls.token.setErrors({ required: true });
return;
}
this.state.set('preview_loading');
this.previewData.set(null);
this.confirmData.set(null);
this.showsApi.previewCheckIn(token)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (response) => {
this.previewData.set(response);
this.state.set('preview_success');
},
error: (error: HttpErrorResponse) => this.setErrorState(error),
});
}
protected confirm(): void {
const token = this.tokenForm.controls.token.value.trim();
if (!token) {
this.state.set('invalid_token');
return;
}
this.state.set('confirm_loading');
this.showsApi.confirmCheckIn(token)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (response) => {
this.confirmData.set(response);
this.state.set('confirm_success');
},
error: (error: HttpErrorResponse) => this.setErrorState(error),
});
}
protected async startScanner(): Promise<void> {
if (!this.scannerSupported || !this.barcodeDetectorCtor) {
this.cameraState.set('unsupported');
this.cameraMessage.set('Camera scanning is not available in this browser. Manual token entry still works.');
return;
}
this.stopScanner();
this.cameraState.set('starting');
this.cameraMessage.set('Starting camera...');
try {
this.scannerStream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: { ideal: 'environment' } },
audio: false,
});
this.detector = new this.barcodeDetectorCtor({ formats: ['qr_code'] });
this.cameraState.set('active');
this.cameraMessage.set('Point the camera at the visitor QR code.');
this.scheduleScan();
} catch (error) {
this.stopScanner();
if (error instanceof DOMException && (error.name === 'NotAllowedError' || error.name === 'SecurityError')) {
this.cameraState.set('denied');
this.cameraMessage.set('Camera access was denied. You can continue with manual token entry.');
return;
}
this.cameraState.set('error');
this.cameraMessage.set('Could not start the camera. You can continue with manual token entry.');
}
}
protected stopScanner(): void {
if (this.scanFrameId !== null) {
cancelAnimationFrame(this.scanFrameId);
this.scanFrameId = null;
}
if (this.scannerStream) {
for (const track of this.scannerStream.getTracks()) {
track.stop();
}
this.scannerStream = null;
}
if (this.scannerVideo?.nativeElement) {
this.scannerVideo.nativeElement.pause();
this.scannerVideo.nativeElement.srcObject = null;
}
this.detector = null;
this.scanInFlight = false;
if (this.scannerSupported && this.cameraState() === 'active') {
this.cameraState.set('ready');
this.cameraMessage.set('Camera stopped. You can scan again or continue with manual token entry.');
}
}
protected isBusy(): boolean {
return this.state() === 'preview_loading' || this.state() === 'confirm_loading';
}
protected shouldShowPreview(): boolean {
return (
this.state() === 'preview_success'
|| this.state() === 'confirm_loading'
|| this.state() === 'confirm_success'
);
}
private scheduleScan(): void {
this.scanFrameId = requestAnimationFrame(() => {
void this.scanFrame();
});
}
private async scanFrame(): Promise<void> {
if (this.cameraState() !== 'active' || !this.detector) {
return;
}
const video = this.scannerVideo?.nativeElement;
const canvas = this.scannerCanvas?.nativeElement;
if (!video || !canvas || this.scanInFlight) {
this.scheduleScan();
return;
}
if (!this.scannerStream && !video.srcObject) {
this.scheduleScan();
return;
}
if (video.srcObject !== this.scannerStream) {
video.srcObject = this.scannerStream;
await video.play();
}
if (video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA || video.videoWidth === 0) {
this.scheduleScan();
return;
}
const context = canvas.getContext('2d');
if (!context) {
this.cameraState.set('error');
this.cameraMessage.set('Camera scan is not available right now. Please enter the token manually.');
this.stopScanner();
return;
}
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
context.drawImage(video, 0, 0, canvas.width, canvas.height);
this.scanInFlight = true;
try {
const barcodes = await this.detector.detect(canvas);
const rawValue = barcodes[0]?.rawValue ?? '';
const token = this.extractToken(rawValue);
if (token) {
this.tokenForm.controls.token.setValue(token);
this.tokenForm.controls.token.markAsTouched();
this.cameraMessage.set('QR captured. Validating token...');
this.stopScanner();
this.preview();
return;
}
} catch {
this.cameraState.set('error');
this.cameraMessage.set('Camera scan failed. Please enter the token manually.');
this.stopScanner();
return;
} finally {
this.scanInFlight = false;
}
this.scheduleScan();
}
private extractToken(rawValue: string): string {
const trimmedValue = rawValue.trim();
if (!trimmedValue) {
return '';
}
try {
const parsedUrl = new URL(trimmedValue);
return parsedUrl.searchParams.get('token')?.trim() ?? trimmedValue;
} catch {
return trimmedValue;
}
}
private setErrorState(error: HttpErrorResponse): void {
if (error.status === 401 || error.status === 403) {
this.state.set('unauthorized');
return;
}
if (error.status === 404) {
this.state.set('invalid_token');
return;
}
if (error.status === 409 && error.error?.status === 'reservation_not_confirmed') {
this.state.set('pending_reservation');
return;
}
if (error.status === 409 && error.error?.status === 'already_checked_in') {
this.state.set('already_checked_in');
return;
}
this.state.set('error');
}
}
@@ -52,6 +52,25 @@ export type ReservationConfirmResponse = {
qr_code_image?: string; qr_code_image?: string;
}; };
export type CheckInPreviewResponse = {
status: 'valid';
reservation_id: number;
performance_id: number;
show_title: string;
venue_name: string;
starts_at: string;
party_size: number;
};
export type CheckInConfirmResponse = {
status: 'checked_in';
reservation_id: number;
performance_id: number;
party_size: number;
checked_in_at: string;
checked_in_by: number;
};
type ShowListResponse = { type ShowListResponse = {
results: ShowListItem[]; results: ShowListItem[];
}; };
@@ -93,4 +112,12 @@ export class ShowsApiService {
params: { token }, params: { token },
}); });
} }
previewCheckIn(token: string): Observable<CheckInPreviewResponse> {
return this.http.post<CheckInPreviewResponse>(`${this.apiBaseUrl}/check-ins/preview/`, { token });
}
confirmCheckIn(token: string): Observable<CheckInConfirmResponse> {
return this.http.post<CheckInConfirmResponse>(`${this.apiBaseUrl}/check-ins/confirm/`, { token });
}
} }
+5
View File
@@ -19,11 +19,14 @@ services:
POSTGRES_PORT: ${POSTGRES_PORT:-5432} POSTGRES_PORT: ${POSTGRES_PORT:-5432}
expose: expose:
- "${BACKEND_PORT:-8000}" - "${BACKEND_PORT:-8000}"
volumes:
- django_static:/app/staticfiles
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
networks: networks:
- internal - internal
user: "0:0"
restart: unless-stopped restart: unless-stopped
frontend: frontend:
@@ -66,6 +69,7 @@ services:
NGINX_ENVSUBST_FILTER: "^(BACKEND_HOST|BACKEND_PORT|FRONTEND_HOST|FRONTEND_PORT)$" NGINX_ENVSUBST_FILTER: "^(BACKEND_HOST|BACKEND_PORT|FRONTEND_HOST|FRONTEND_PORT)$"
volumes: volumes:
- ./nginx/templates:/etc/nginx/templates:ro - ./nginx/templates:/etc/nginx/templates:ro
- django_static:/var/www/static:ro
depends_on: depends_on:
- backend - backend
- frontend - frontend
@@ -75,6 +79,7 @@ services:
volumes: volumes:
postgres_data: postgres_data:
django_static:
networks: networks:
internal: internal:
@@ -29,9 +29,9 @@ server {
} }
location /static/ { location /static/ {
proxy_pass http://azionelab_backend; alias /var/www/static/;
proxy_set_header Host $host; access_log off;
proxy_set_header X-Forwarded-Proto $scheme; expires 1d;
} }
location /media/ { location /media/ {