From 5cad1871e7bc19dfb80f29ef14445fa96eb699ba Mon Sep 17 00:00:00 2001 From: bisco Date: Wed, 29 Apr 2026 19:23:42 +0200 Subject: [PATCH] feat(operations): improve reservation and check-in flows --- backend/bookings/admin.py | 210 +++++++++++- backend/bookings/test_admin.py | 84 +++++ backend/checkins/serializers.py | 1 + backend/checkins/test_api.py | 1 + backend/checkins/views.py | 1 + backend/shows/admin.py | 15 + docs/api-contract.md | 1 + docs/booking-flow.md | 13 + .../check-in-placeholder-page.component.ts | 315 +++++++++++++++++- .../src/app/services/shows-api.service.ts | 1 + 10 files changed, 627 insertions(+), 15 deletions(-) create mode 100644 backend/bookings/test_admin.py diff --git a/backend/bookings/admin.py b/backend/bookings/admin.py index ab2e2c2..a078ea5 100644 --- a/backend/bookings/admin.py +++ b/backend/bookings/admin.py @@ -1,6 +1,40 @@ -from django.contrib import admin +from django import forms +from django.contrib import admin, messages from .models import Reservation, ReservationToken +from .services import PerformanceNotAvailable, create_pending_reservation + + +class ReservationAdminForm(forms.ModelForm): + class Meta: + model = Reservation + fields = ("performance", "name", "email", "phone", "party_size", "notes") + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["performance"].help_text = ( + "Choose the exact performance. A pending reservation and confirmation email will be created." + ) + self.fields["party_size"].help_text = "Seats requested for this guest or group." + self.fields["notes"].help_text = "Optional internal note for staff." + + def clean(self): + cleaned_data = super().clean() + performance = cleaned_data.get("performance") + party_size = cleaned_data.get("party_size") + + if performance and not performance.is_booking_enabled: + self.add_error("performance", "Booking is currently disabled for this performance.") + + if performance and party_size: + available_seats = performance.available_seats() + if party_size > available_seats: + self.add_error( + "party_size", + f"Only {available_seats} seats are currently available for this performance.", + ) + + return cleaned_data class ReservationTokenInline(admin.TabularInline): @@ -13,23 +47,187 @@ class ReservationTokenInline(admin.TabularInline): @admin.register(Reservation) class ReservationAdmin(admin.ModelAdmin): + form = ReservationAdminForm list_display = ( + "show_title", + "performance_starts_at", + "venue_name", "name", "email", - "performance", "party_size", "status", + "confirmation_state_display", + "check_in_state_display", + ) + list_filter = ( + "status", + "performance__show", + "performance__venue", + "performance__starts_at", "confirmed_at", "qr_code_generated_at", - "created_at", ) - list_filter = ("status", "performance", "created_at", "confirmed_at") - search_fields = ("name", "email", "phone", "performance__show__title") + search_fields = ( + "name", + "email", + "phone", + "performance__show__title", + "performance__venue__name", + ) inlines = (ReservationTokenInline,) list_select_related = ("performance", "performance__show", "performance__venue") - readonly_fields = ("created_at", "updated_at", "confirmed_at", "qr_code_generated_at") + readonly_fields = ( + "status", + "show_title", + "performance_starts_at", + "venue_name", + "confirmation_state_display", + "check_in_state_display", + "created_at", + "updated_at", + "confirmed_at", + "qr_code_generated_at", + ) autocomplete_fields = ("performance",) + def get_queryset(self, request): + return super().get_queryset(request).select_related( + "performance", + "performance__show", + "performance__venue", + "check_in", + ) + + def get_inlines(self, request, obj): + if obj is None: + return () + return super().get_inlines(request, obj) + + def get_changeform_initial_data(self, request): + initial = super().get_changeform_initial_data(request) + performance_id = request.GET.get("performance") + if performance_id: + initial["performance"] = performance_id + return initial + + def get_fieldsets(self, request, obj=None): + if obj is None: + return ( + ( + "Create manual reservation", + { + "description": ( + "Use this form when staff needs to enter a reservation manually. " + "The reservation stays pending and the standard confirmation email is sent automatically." + ), + "fields": ("performance", "name", "email", "phone", "party_size", "notes"), + }, + ), + ) + + return ( + ( + "Reservation", + { + "fields": ( + "performance", + "show_title", + "performance_starts_at", + "venue_name", + "name", + "email", + "phone", + "party_size", + "notes", + ), + }, + ), + ( + "Operational status", + { + "fields": ( + "status", + "confirmation_state_display", + "check_in_state_display", + "confirmed_at", + "qr_code_generated_at", + ), + }, + ), + ( + "Timestamps", + { + "classes": ("collapse",), + "fields": ("created_at", "updated_at"), + }, + ), + ) + + def save_model(self, request, obj, form, change): + if change: + super().save_model(request, obj, form, change) + return + + try: + result = create_pending_reservation( + performance_id=form.cleaned_data["performance"].id, + name=form.cleaned_data["name"], + email=form.cleaned_data["email"], + phone=form.cleaned_data.get("phone", ""), + party_size=form.cleaned_data["party_size"], + notes=form.cleaned_data.get("notes", ""), + ) + except PerformanceNotAvailable as exc: + form.add_error("performance", str(exc)) + raise forms.ValidationError(str(exc)) from exc + + created = result.reservation + obj.pk = created.pk + obj._state.adding = False + obj.performance = created.performance + obj.status = created.status + obj.name = created.name + obj.email = created.email + obj.phone = created.phone + obj.party_size = created.party_size + obj.notes = created.notes + obj.created_at = created.created_at + obj.updated_at = created.updated_at + obj.confirmed_at = created.confirmed_at + obj.qr_code_generated_at = created.qr_code_generated_at + + self.message_user( + request, + "Pending reservation created. The guest must confirm it from the email link before check-in.", + level=messages.SUCCESS, + ) + + @admin.display(description="Show", ordering="performance__show__title") + def show_title(self, obj): + return obj.performance.show.title + + @admin.display(description="Performance", ordering="performance__starts_at") + def performance_starts_at(self, obj): + return obj.performance.starts_at + + @admin.display(description="Venue", ordering="performance__venue__name") + def venue_name(self, obj): + return obj.performance.venue.name + + @admin.display(description="Confirmation") + def confirmation_state_display(self, obj): + if obj.status == Reservation.Status.CONFIRMED: + return "Confirmed" + if obj.status == Reservation.Status.EXPIRED: + return "Confirmation expired" + if obj.status == Reservation.Status.CANCELLED: + return "Cancelled" + return "Waiting for email confirmation" + + @admin.display(description="Check-in") + def check_in_state_display(self, obj): + return "Checked in" if hasattr(obj, "check_in") else "Not checked in" + @admin.register(ReservationToken) class ReservationTokenAdmin(admin.ModelAdmin): diff --git a/backend/bookings/test_admin.py b/backend/bookings/test_admin.py new file mode 100644 index 0000000..7f4eb95 --- /dev/null +++ b/backend/bookings/test_admin.py @@ -0,0 +1,84 @@ +from datetime import timedelta + +from django.contrib.auth import get_user_model +from django.core import mail +from django.test import TestCase +from django.test.utils import override_settings +from django.urls import reverse +from django.utils import timezone + +from bookings.models import Reservation, ReservationToken +from shows.models import Performance, Show, Venue + + +@override_settings( + EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend", + SITE_BASE_URL="https://tickets.azionelab.example", +) +class ReservationAdminTests(TestCase): + def setUp(self): + user_model = get_user_model() + self.admin_user = user_model.objects.create_superuser( + username="admin-bookings", + email="admin@example.com", + password="password123", + ) + self.client.force_login(self.admin_user) + self.show = Show.objects.create( + title="Open Stage", + slug="open-stage-admin", + is_published=True, + ) + self.venue = Venue.objects.create( + name="AzioneLab Theatre", + slug="azionelab-theatre-admin", + address="Via Example 1", + city="Rome", + ) + self.performance = Performance.objects.create( + show=self.show, + venue=self.venue, + starts_at=timezone.now() + timedelta(days=7), + room_capacity=20, + ) + + def test_reservation_add_page_accepts_preselected_performance(self): + response = self.client.get( + reverse("admin:bookings_reservation_add"), + {"performance": self.performance.id}, + ) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Create manual reservation") + self.assertContains(response, "The reservation stays pending") + + def test_admin_can_create_manual_reservation_with_standard_email_flow(self): + response = self.client.post( + reverse("admin:bookings_reservation_add"), + { + "performance": self.performance.id, + "name": "Maria Rossi", + "email": "maria@example.com", + "phone": "+390600000000", + "party_size": 2, + "notes": "Entered by staff at the venue desk.", + "_save": "Save", + }, + ) + + reservation = Reservation.objects.get() + self.assertEqual(response.status_code, 302) + self.assertEqual(reservation.performance, self.performance) + self.assertEqual(reservation.status, Reservation.Status.PENDING) + self.assertEqual(reservation.party_size, 2) + self.assertTrue( + ReservationToken.objects.filter( + reservation=reservation, + purpose=ReservationToken.Purpose.CONFIRMATION, + ).exists() + ) + self.assertEqual(len(mail.outbox), 1) + self.assertIn( + "https://tickets.azionelab.example/api/reservations/confirm/?token=", + mail.outbox[0].body, + ) diff --git a/backend/checkins/serializers.py b/backend/checkins/serializers.py index f5c1bab..d716992 100644 --- a/backend/checkins/serializers.py +++ b/backend/checkins/serializers.py @@ -18,6 +18,7 @@ class CheckInPreviewResponseSerializer(serializers.Serializer): reservation_id = serializers.IntegerField() performance_id = serializers.IntegerField() show_title = serializers.CharField() + venue_name = serializers.CharField() starts_at = serializers.DateTimeField() party_size = serializers.IntegerField() diff --git a/backend/checkins/test_api.py b/backend/checkins/test_api.py index 05e80b1..344014d 100644 --- a/backend/checkins/test_api.py +++ b/backend/checkins/test_api.py @@ -57,6 +57,7 @@ class CheckInApiTests(APITestCase): self.assertEqual(response.data["reservation_id"], reservation.id) self.assertEqual(response.data["performance_id"], self.performance.id) self.assertEqual(response.data["show_title"], self.show.title) + self.assertEqual(response.data["venue_name"], self.venue.name) self.assertEqual(response.data["party_size"], reservation.party_size) self.assertNotIn("name", response.data) self.assertNotIn("email", response.data) diff --git a/backend/checkins/views.py b/backend/checkins/views.py index 6d0cb62..b1db8f0 100644 --- a/backend/checkins/views.py +++ b/backend/checkins/views.py @@ -65,6 +65,7 @@ def check_in_preview(request): "reservation_id": preview.reservation_id, "performance_id": preview.performance_id, "show_title": preview.show_title, + "venue_name": preview.venue_name, "starts_at": preview.starts_at, "party_size": preview.party_size, } diff --git a/backend/shows/admin.py b/backend/shows/admin.py index 1293d64..4395594 100644 --- a/backend/shows/admin.py +++ b/backend/shows/admin.py @@ -1,4 +1,6 @@ from django.contrib import admin +from django.urls import reverse +from django.utils.html import format_html from .models import Performance, Show, Venue @@ -32,6 +34,7 @@ class PerformanceAdmin(admin.ModelAdmin): "manually_occupied_seats", "available_seats_display", "is_booking_enabled", + "create_reservation_link", ) list_filter = ("is_booking_enabled", "starts_at", "show", "venue") search_fields = ("show__title", "venue__name", "venue__city") @@ -49,3 +52,15 @@ class PerformanceAdmin(admin.ModelAdmin): ): return "-" return obj.available_seats() + + @admin.display(description="Manual reservation") + def create_reservation_link(self, obj): + if not getattr(obj, "pk", None): + return "-" + + url = reverse("admin:bookings_reservation_add") + return format_html( + 'Create reservation', + url, + obj.pk, + ) diff --git a/docs/api-contract.md b/docs/api-contract.md index 8f19501..b0a3f0d 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -271,6 +271,7 @@ Response `200 OK`: "reservation_id": 123, "performance_id": 10, "show_title": "The Open Stage", + "venue_name": "AzioneLab Theatre", "starts_at": "2026-05-15T20:30:00+02:00", "party_size": 2 } diff --git a/docs/booking-flow.md b/docs/booking-flow.md index 4278f79..d4335a5 100644 --- a/docs/booking-flow.md +++ b/docs/booking-flow.md @@ -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. +## 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. 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 If the same QR code is scanned again: diff --git a/frontend/src/app/pages/check-in-placeholder-page.component.ts b/frontend/src/app/pages/check-in-placeholder-page.component.ts index 73cc24e..55063dc 100644 --- a/frontend/src/app/pages/check-in-placeholder-page.component.ts +++ b/frontend/src/app/pages/check-in-placeholder-page.component.ts @@ -1,6 +1,14 @@ import { DatePipe } from '@angular/common'; import { HttpErrorResponse } from '@angular/common/http'; -import { ChangeDetectionStrategy, Component, DestroyRef, inject, signal } from '@angular/core'; +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'; @@ -10,7 +18,11 @@ import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { CheckInPreviewResponse, ShowsApiService } from '../services/shows-api.service'; +import { + CheckInConfirmResponse, + CheckInPreviewResponse, + ShowsApiService, +} from '../services/shows-api.service'; type UiState = | 'idle' @@ -24,6 +36,18 @@ type UiState = | 'unauthorized' | 'error'; +type CameraState = 'ready' | 'starting' | 'active' | 'unsupported' | 'denied' | 'error'; + +type DetectedBarcode = { + rawValue?: string; +}; + +type BarcodeDetectorInstance = { + detect(source: HTMLCanvasElement): Promise; +}; + +type BarcodeDetectorConstructor = new (options?: { formats?: string[] }) => BarcodeDetectorInstance; + @Component({ standalone: true, imports: [ @@ -41,11 +65,49 @@ type UiState = +
+
+

Camera scan

+

Optional on supported browsers. If the QR contains a full check-in URL, the token is extracted automatically.

+
+ +
+ @if (cameraState() === 'active') { + + } @else { + + } +
+ + @if (cameraState() === 'active') { +
+ + +
+ } + + @if (cameraMessage()) { +

{{ cameraMessage() }}

+ } +
+
Opaque token @@ -69,19 +131,22 @@ type UiState = - @if (state() === 'preview_success' && previewData()) { + @if (previewData() && shouldShowPreview()) {

Admission preview

Show
{{ previewData()!.show_title }}
+
Venue
{{ previewData()!.venue_name }}
Starts at
{{ previewData()!.starts_at | date: 'EEEE d MMMM, HH:mm' }}
Party size
{{ previewData()!.party_size }}
Reservation
#{{ previewData()!.reservation_id }}
-
} - @if (state() === 'confirm_success') { -

Check-in confirmed successfully.

+ @if (state() === 'confirm_success' && confirmData()) { +

+ Check-in confirmed at {{ confirmData()!.checked_in_at | date: 'HH:mm' }}. +

} @if (state() === 'invalid_token') { @@ -148,6 +215,48 @@ type UiState = 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 { width: 100%; } @@ -159,7 +268,12 @@ type UiState = flex-wrap: wrap; } - button[mat-flat-button] { + .scanner-actions { + justify-content: flex-start; + } + + button[mat-flat-button], + button[mat-stroked-button] { min-width: 150px; display: inline-flex; align-items: center; @@ -213,6 +327,20 @@ 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; + @ViewChild('scannerCanvas') private scannerCanvas?: ElementRef; protected readonly tokenForm = this.formBuilder.nonNullable.group({ token: ['', [Validators.required]], @@ -220,6 +348,17 @@ export class CheckInPlaceholderPageComponent { protected readonly state = signal('idle'); protected readonly previewData = signal(null); + protected readonly confirmData = signal(null); + protected readonly cameraState = signal(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) { @@ -235,6 +374,7 @@ export class CheckInPlaceholderPageComponent { this.state.set('preview_loading'); this.previewData.set(null); + this.confirmData.set(null); this.showsApi.previewCheckIn(token) .pipe(takeUntilDestroyed(this.destroyRef)) @@ -259,17 +399,174 @@ export class CheckInPlaceholderPageComponent { this.showsApi.confirmCheckIn(token) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe({ - next: () => { + next: (response) => { + this.confirmData.set(response); this.state.set('confirm_success'); }, error: (error: HttpErrorResponse) => this.setErrorState(error), }); } + protected async startScanner(): Promise { + 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 { + 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'); diff --git a/frontend/src/app/services/shows-api.service.ts b/frontend/src/app/services/shows-api.service.ts index b34d08c..b369313 100644 --- a/frontend/src/app/services/shows-api.service.ts +++ b/frontend/src/app/services/shows-api.service.ts @@ -57,6 +57,7 @@ export type CheckInPreviewResponse = { reservation_id: number; performance_id: number; show_title: string; + venue_name: string; starts_at: string; party_size: number; };