Compare commits

47 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
bisco ad92dce047 Merge branch 'feature/frontend-confirmation-qr' into develop 2026-04-29 18:20:06 +02:00
bisco 24d3f4d30f feat(frontend): add reservation confirmation page 2026-04-29 18:19:05 +02:00
bisco 302e3461ad Merge branch 'feature/frontend-booking' into develop 2026-04-29 18:15:00 +02:00
bisco 144c48c02f feat(frontend): add performance booking form 2026-04-29 18:14:07 +02:00
bisco 56d8c31a0d Merge branch 'feature/frontend-show-detail' into develop 2026-04-29 15:40:28 +02:00
bisco c3a2addd47 feat: add frontend show detail page 2026-04-29 14:37:37 +02:00
bisco 6488b6db87 Merge branch 'feature/frontend-show-list' into develop 2026-04-29 12:37:41 +02:00
bisco e1977e49c3 feat: load shows from frontend API 2026-04-29 12:20:39 +02:00
bisco c67c2c7d18 Merge branch 'feature/frontend-skeleton' into develop 2026-04-29 12:18:00 +02:00
bisco 5f30029f4b feat: add Angular frontend skeleton 2026-04-29 12:17:07 +02:00
bisco 35ae0278b7 Merge branch 'feature/admin-demo-data' into develop 2026-04-29 12:08:31 +02:00
bisco d1801b8c9b feat: improve admin and add demo data command 2026-04-29 12:06:55 +02:00
bisco 2b71b7a418 Merge branch 'feature/reservation-qr-retrieval' into develop 2026-04-29 11:13:04 +02:00
bisco 635a9a5c63 feat: add reservation QR retrieval 2026-04-29 10:59:47 +02:00
bisco 0c679391ed test: capture expected email failure logs 2026-04-29 10:54:52 +02:00
bisco 4422d13f15 fix: isolate Django tests from SMTP 2026-04-29 10:47:18 +02:00
bisco 2a2ca39ba9 Merge branch 'feature/email-and-qr' into develop 2026-04-29 10:28:10 +02:00
bisco 4ae85947e0 fix: use absolute public booking URLs 2026-04-29 10:18:22 +02:00
bisco c46d803951 feat: add email confirmation and QR generation 2026-04-29 10:12:31 +02:00
bisco 15814f8ccc Merge branch 'feature/backend-checkin-api' into develop 2026-04-29 10:04:41 +02:00
bisco 9560963139 feat: add check-in REST API 2026-04-29 10:01:06 +02:00
bisco 09e3243034 Merge branch 'feature/backend-booking-api' into develop 2026-04-29 09:57:10 +02:00
bisco 89cf08647c feat: add booking REST API 2026-04-29 09:45:44 +02:00
bisco 441d73d473 Merge branch 'feature/checkin-services' into develop 2026-04-29 09:37:20 +02:00
bisco a7dfcaf5f2 feat: add check-in service layer 2026-04-29 09:34:21 +02:00
bisco b1f6fcf1f2 Merge branch 'feature/booking-services' into develop 2026-04-28 17:17:59 +02:00
bisco f818f72dc6 feat: add booking service layer 2026-04-28 17:17:02 +02:00
bisco 71ff02c25a Merge branch 'feature/initial-domain-models' into develop 2026-04-28 17:09:28 +02:00
bisco 3cd5455aa2 feat: add initial Django domain models 2026-04-28 17:08:27 +02:00
bisco 01e6023112 Merge branch 'docs/update-project-config' into develop 2026-04-28 11:39:55 +02:00
bisco db69e24211 chore: update Codex project configuration 2026-04-28 11:34:33 +02:00
69 changed files with 5722 additions and 77 deletions
+154 -53
View File
@@ -1,31 +1,115 @@
# Project configuration for Codex # AzioneLab project configuration for Codex
Edit this file for each repository.
## Project identity ## Project identity
Project name: `CHANGE_ME` Project name: `AzioneLab`
Project description: `CHANGE_ME`
Primary language/runtime: `CHANGE_ME` Project description: public website for a theatre company with a simple booking system for performances, email confirmation, QR code generation, and staff entrance check-in.
Primary language/runtime: Python with Django 5.2 LTS.
## Project mode ## Project mode
Choose one:
```text ```text
project_mode: personal project_mode: personal
project_mode: work
``` ```
Rules: Docker base image policy is neutral, but images must use explicit tags and must not use `latest`.
- `personal`: Docker base image policy is neutral. ## Tech stack
- `work`: prefer Red Hat UBI minimal images when possible.
- Backend: Python, Django 5.2 LTS, Django REST Framework.
- Backend runtime: gunicorn.
- Frontend: Angular with Angular Material.
- Database: PostgreSQL.
- Reverse proxy: nginx.
- Deployment: Docker Compose.
- QR codes: a small Python QR code library such as `qrcode` or `segno`.
- Email: Django email backend configured through environment variables.
Do not add Celery, Redis, message brokers, background workers, or extra services unless a future ADR explicitly changes this decision.
## Architecture
AzioneLab uses a pragmatic monolith-oriented architecture:
- a Django backend owns APIs, admin, booking, confirmation, QR generation, check-in validation, and transactional capacity rules;
- an Angular frontend renders public pages, show listings, booking forms, confirmation states, and staff check-in UI;
- PostgreSQL is the system of record;
- nginx is the public reverse proxy and serves frontend assets;
- Docker Compose defines the initial deployment topology.
Only nginx should be exposed publicly in production. Backend, frontend, and PostgreSQL services should remain on the internal Compose network.
## Key features
- Public descriptive pages for the theatre company.
- Public show list and show detail pages.
- Public booking form for a specific performance.
- Administration through Django admin.
- Configurable shows, venues, performance dates, room capacity, manually occupied seats, and optional additional seats.
- Email confirmation before a reservation becomes confirmed.
- QR code generation after reservation confirmation.
- QR codes usable on smartphones or printed copies.
- Staff/admin check-in flow using a mobile-friendly page to scan or enter QR tokens.
- QR verification preview and check-in confirmation endpoints.
- Duplicate check-in prevention.
## Domain and business constraints
- A `Performance` belongs to one `Show` and one `Venue`.
- Available seats are calculated server-side as:
```text
room capacity + additional seats - manually occupied seats - confirmed reservations
```
- Reservations start as `pending`.
- Reservations become `confirmed` only through a valid confirmation link.
- Confirmed reservations receive a QR code.
- QR codes must contain only an opaque verification token or URL, never personal data.
- Staff/admin users perform check-in.
- A reservation must be confirmed before check-in.
- A reservation cannot be checked in twice.
- Capacity validation must happen server-side and must avoid overbooking.
- Store reservation status explicitly.
- Public booking endpoints must validate input strictly.
- Admin functionality must require authentication.
## Security constraints
- Do not commit secrets, credentials, private keys, raw tokens, or real personal data.
- Use opaque, random, non-guessable tokens for confirmation and QR verification.
- Do not expose personal data in QR codes.
- Do not log raw tokens, credentials, session cookies, authorization headers, or full booking payloads.
- CORS must allow only configured Angular frontend origins through `CORS_ALLOWED_ORIGINS`.
- Keep `.env` files out of version control; `.env.example` may contain example values only.
- Prefer least privilege for containers, users, networks, and filesystem access.
## Development rules
Codex MUST:
- work only on the current feature branch;
- not merge into `develop`;
- keep changes minimal, focused, and easy to review;
- preserve existing architecture decisions unless the user explicitly requests a change;
- update documentation when behavior, deployment, operation, security, or architecture changes;
- create or update ADRs for architectural decisions;
- use Conventional Commits;
- run the configured Docker-based checks before committing;
- report tests/checks, residual risks, and rollback notes.
Codex MUST NOT:
- add Celery or Redis;
- add unnecessary services or dependencies;
- implement unrelated features while handling a focused task;
- introduce broad rewrites or formatting-only churn;
- disable authentication, authorization, CSRF, CORS restrictions, TLS verification, input validation, or security checks without an explicit ADR.
## Enabled profiles ## Enabled profiles
Enable only the profiles that apply to this repository:
```text ```text
enabled_profiles: enabled_profiles:
- docker - docker
@@ -35,74 +119,91 @@ enabled_profiles:
## Branching model ## Branching model
Codex MUST: 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:
- start from `develop`;
- create one branch per task;
- use one of these prefixes:
- `feature/` - `feature/`
- `fix/` - `fix/`
- `hotfix/`
- `chore/` - `chore/`
- `docs/` - `docs/`
- `refactor/`
Examples: Do not merge task branches into `develop`. Leave integration to the repository owner or a separate explicit request.
```text
feature/add-healthcheck
fix/selinux-authorized-keys
hotfix/restore-container-startup
```
## Commit style ## Commit style
Codex MUST use Conventional Commits. Use Conventional Commits.
Examples: Examples:
```text ```text
feat: add Docker healthcheck feat: add health endpoint
fix: correct Ansible SELinux handling fix: correct backend Docker workdir
docs: add ADR for deployment strategy docs: update deployment notes
test: add regression tests for parser test: add reservation capacity checks
refactor: simplify container startup logic refactor: simplify booking token validation
chore: update Codex project metadata chore: update Codex project configuration
``` ```
## Test command ## Testing approach
All tests MUST be executed inside Docker containers. All checks must run inside Docker containers when applicable.
Configure the canonical test command for this repository: Canonical check command:
```bash ```bash
docker compose --env-file .env.example -f infra/docker/compose.yml config docker compose --env-file .env.example -f infra/docker/compose.yml config
docker compose --env-file .env.example -f infra/docker/compose.yml run --rm --build backend python manage.py test docker compose --env-file .env.example -f infra/docker/compose.yml run --rm --build backend python manage.py test
``` ```
Examples: Current coverage:
```bash - Docker Compose configuration validation;
docker compose run --rm app pytest - Django backend tests, including the health endpoint test.
```
```bash Run formatting or linting only when a project configuration for those tools exists.
docker compose run --rm app ruff check .
docker compose run --rm app pytest
```
```bash ## Deployment approach
docker compose run --rm ansible ansible-playbook --syntax-check playbook.yml
```
If no test command is configured, Codex MUST: Deployment uses `infra/docker/compose.yml` with explicit services:
1. report that tests are not configured; - `nginx`: public reverse proxy;
2. suggest the appropriate Docker-based test command; - `frontend`: Angular frontend build/static service served by nginx;
3. avoid claiming that the task is fully verified. - `backend`: Django application served by gunicorn;
- `postgres`: PostgreSQL with named volume persistence.
## Documentation language Configuration comes from `.env`. `.env.example` documents the required environment variables with example values. PostgreSQL data is persisted in the `postgres_data` named volume.
## Documentation and ADRs
Primary documentation:
- `docs/architecture.md`
- `docs/domain-model.md`
- `docs/api-contract.md`
- `docs/booking-flow.md`
- `docs/security-notes.md`
- `docs/deployment.md`
- `docs/testing.md`
Accepted ADRs currently define:
- Django monolith backend;
- no async task queue at this stage;
- opaque QR token strategy;
- email confirmation flow;
- staff check-in with token validation;
- Docker Compose deployment.
Documentation language: English. Documentation language: English.
Code comments language: English. Code comments language: English.
+12 -4
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,13 @@ 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
TIME_ZONE=Europe/Rome TIME_ZONE=Europe/Rome
EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend
DEFAULT_FROM_EMAIL=no-reply@azionelab.local
POSTGRES_DB=azionelab POSTGRES_DB=azionelab
POSTGRES_USER=azionelab POSTGRES_USER=azionelab
@@ -24,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.
+20 -1
View File
@@ -1,4 +1,5 @@
import os import os
import sys
from pathlib import Path from pathlib import Path
import dj_database_url import dj_database_url
@@ -23,6 +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").rstrip("/")
INSTALLED_APPS = [ INSTALLED_APPS = [
"django.contrib.admin", "django.contrib.admin",
@@ -101,7 +103,17 @@ TIME_ZONE = os.environ.get("TIME_ZONE", "Europe/Rome")
USE_I18N = True USE_I18N = True
USE_TZ = True USE_TZ = True
STATIC_URL = "static/" EMAIL_BACKEND = os.environ.get(
"EMAIL_BACKEND",
"django.core.mail.backends.smtp.EmailBackend",
)
DEFAULT_FROM_EMAIL = os.environ.get("DEFAULT_FROM_EMAIL", "no-reply@azionelab.local")
if "test" in sys.argv:
EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"
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 = {
@@ -111,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",
},
} }
+4 -1
View File
@@ -1,5 +1,5 @@
from django.contrib import admin from django.contrib import admin
from django.urls import path from django.urls import include, path
from rest_framework.decorators import api_view from rest_framework.decorators import api_view
from rest_framework.response import Response from rest_framework.response import Response
@@ -12,4 +12,7 @@ def health(request):
urlpatterns = [ urlpatterns = [
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
path("api/health/", health, name="health"), path("api/health/", health, name="health"),
path("api/", include("shows.urls")),
path("api/", include("bookings.urls")),
path("api/", include("checkins.urls")),
] ]
+240
View File
@@ -0,0 +1,240 @@
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):
model = ReservationToken
extra = 0
readonly_fields = ("used_at", "created_at")
fields = ("purpose", "expires_at", "used_at", "created_at")
can_delete = False
@admin.register(Reservation)
class ReservationAdmin(admin.ModelAdmin):
form = ReservationAdminForm
list_display = (
"show_title",
"performance_starts_at",
"venue_name",
"name",
"email",
"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",
)
search_fields = (
"name",
"email",
"phone",
"performance__show__title",
"performance__venue__name",
)
inlines = (ReservationTokenInline,)
list_select_related = ("performance", "performance__show", "performance__venue")
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):
list_display = ("reservation", "purpose", "expires_at", "used_at", "created_at")
list_filter = ("purpose", "expires_at", "used_at", "created_at")
search_fields = ("reservation__name", "reservation__email", "token_hash")
readonly_fields = ("created_at", "used_at")
exclude = ("token_hash",)
list_select_related = ("reservation", "reservation__performance")
autocomplete_fields = ("reservation",)
+38
View File
@@ -0,0 +1,38 @@
import logging
from django.conf import settings
from django.core.mail import send_mail
logger = logging.getLogger(__name__)
CONFIRMATION_PATH = "/api/reservations/confirm/"
def build_confirmation_link(raw_confirmation_token):
return f"{settings.SITE_BASE_URL}{CONFIRMATION_PATH}?token={raw_confirmation_token}"
def send_confirmation_email(*, reservation, raw_confirmation_token):
confirmation_link = build_confirmation_link(raw_confirmation_token)
subject = f"Confirm your reservation for {reservation.performance.show.title}"
message = (
"Thank you for your reservation request.\n\n"
"Please confirm your reservation by opening this link:\n"
f"{confirmation_link}\n\n"
"If you did not request this reservation, you can ignore this email."
)
try:
send_mail(
subject=subject,
message=message,
from_email=None,
recipient_list=[reservation.email],
fail_silently=False,
)
except Exception:
logger.exception(
"Failed to send confirmation email for reservation %s.",
reservation.id,
)
@@ -0,0 +1,95 @@
# Generated by Django 5.2.3 on 2026-04-28
import django.core.validators
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("shows", "0001_initial"),
]
operations = [
migrations.CreateModel(
name="Reservation",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"status",
models.CharField(
choices=[
("pending", "Pending"),
("confirmed", "Confirmed"),
("cancelled", "Cancelled"),
("expired", "Expired"),
],
db_index=True,
default="pending",
max_length=20,
),
),
("name", models.CharField(max_length=200)),
("email", models.EmailField(db_index=True, max_length=254)),
("phone", models.CharField(blank=True, max_length=40)),
("party_size", models.PositiveIntegerField(validators=[django.core.validators.MinValueValidator(1)])),
("notes", models.TextField(blank=True)),
("confirmed_at", models.DateTimeField(blank=True, null=True)),
("qr_code_generated_at", models.DateTimeField(blank=True, null=True)),
(
"performance",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="reservations",
to="shows.performance",
),
),
],
options={
"ordering": ["-created_at"],
"indexes": [
models.Index(fields=["performance", "status"], name="bookings_re_perform_730504_idx"),
models.Index(fields=["email"], name="bookings_re_email_924b70_idx"),
models.Index(fields=["created_at"], name="bookings_re_created_823f26_idx"),
],
},
),
migrations.CreateModel(
name="ReservationToken",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
(
"purpose",
models.CharField(
choices=[("confirmation", "Confirmation"), ("check_in", "Check-in")],
db_index=True,
max_length=20,
),
),
("token_hash", models.CharField(max_length=64, unique=True)),
("expires_at", models.DateTimeField()),
("used_at", models.DateTimeField(blank=True, null=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"reservation",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="tokens",
to="bookings.reservation",
),
),
],
options={
"ordering": ["-created_at"],
"indexes": [
models.Index(fields=["reservation", "purpose"], name="bookings_re_reserva_017db4_idx"),
models.Index(fields=["purpose", "expires_at"], name="bookings_re_purpose_36fca5_idx"),
models.Index(fields=["used_at"], name="bookings_re_used_at_ae1c4d_idx"),
],
},
),
]
+1
View File
@@ -0,0 +1 @@
+138 -1
View File
@@ -1 +1,138 @@
# Domain models will be added when booking behavior is implemented. import hashlib
import secrets
from django.core.validators import MinValueValidator
from django.core.exceptions import ValidationError
from django.db import models
from django.utils import timezone
from shows.models import Performance, TimeStampedModel
class Reservation(TimeStampedModel):
class Status(models.TextChoices):
PENDING = "pending", "Pending"
CONFIRMED = "confirmed", "Confirmed"
CANCELLED = "cancelled", "Cancelled"
EXPIRED = "expired", "Expired"
performance = models.ForeignKey(
Performance,
on_delete=models.PROTECT,
related_name="reservations",
)
status = models.CharField(
max_length=20,
choices=Status.choices,
default=Status.PENDING,
db_index=True,
)
name = models.CharField(max_length=200)
email = models.EmailField(db_index=True)
phone = models.CharField(max_length=40, blank=True)
party_size = models.PositiveIntegerField(validators=[MinValueValidator(1)])
notes = models.TextField(blank=True)
confirmed_at = models.DateTimeField(blank=True, null=True)
qr_code_generated_at = models.DateTimeField(blank=True, null=True)
class Meta:
ordering = ["-created_at"]
indexes = [
models.Index(fields=["performance", "status"]),
models.Index(fields=["email"]),
models.Index(fields=["created_at"]),
]
def __str__(self):
return f"{self.name} ({self.party_size}) for {self.performance}"
@property
def is_confirmed(self):
return self.status == self.Status.CONFIRMED
def confirm(self):
if self.status != self.Status.PENDING:
raise ValidationError("Only pending reservations can be confirmed.")
if not self.confirmed_at:
self.confirmed_at = timezone.now()
self.status = self.Status.CONFIRMED
self.save(update_fields=["status", "confirmed_at", "updated_at"])
def confirm_with_token(self, raw_token):
token = self.tokens.get_valid_token(raw_token, ReservationToken.Purpose.CONFIRMATION)
self.confirm()
token.mark_used()
return token
class ReservationTokenQuerySet(models.QuerySet):
def get_valid_token(self, raw_token, purpose):
token_hash = ReservationToken.hash_token(raw_token)
now = timezone.now()
return self.get(
token_hash=token_hash,
purpose=purpose,
used_at__isnull=True,
expires_at__gt=now,
)
class ReservationToken(models.Model):
class Purpose(models.TextChoices):
CONFIRMATION = "confirmation", "Confirmation"
CHECK_IN = "check_in", "Check-in"
reservation = models.ForeignKey(
Reservation,
on_delete=models.CASCADE,
related_name="tokens",
)
purpose = models.CharField(max_length=20, choices=Purpose.choices, db_index=True)
token_hash = models.CharField(max_length=64, unique=True)
expires_at = models.DateTimeField()
used_at = models.DateTimeField(blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True)
objects = ReservationTokenQuerySet.as_manager()
class Meta:
ordering = ["-created_at"]
indexes = [
models.Index(fields=["reservation", "purpose"]),
models.Index(fields=["purpose", "expires_at"]),
models.Index(fields=["used_at"]),
]
def __str__(self):
return f"{self.get_purpose_display()} token for reservation {self.reservation_id}"
@staticmethod
def generate_raw_token():
return secrets.token_urlsafe(32)
@staticmethod
def hash_token(raw_token):
return hashlib.sha256(raw_token.encode("utf-8")).hexdigest()
@classmethod
def create_token(cls, reservation, purpose, expires_at):
raw_token = cls.generate_raw_token()
token = cls.objects.create(
reservation=reservation,
purpose=purpose,
token_hash=cls.hash_token(raw_token),
expires_at=expires_at,
)
return token, raw_token
@property
def is_expired(self):
return timezone.now() >= self.expires_at
@property
def is_used(self):
return self.used_at is not None
def mark_used(self):
self.used_at = timezone.now()
self.save(update_fields=["used_at"])
+30
View File
@@ -0,0 +1,30 @@
import base64
from io import BytesIO
import qrcode
from django.conf import settings
from .models import Reservation
CHECK_IN_PREVIEW_PATH = "/api/check-ins/preview/"
def build_check_in_preview_url(raw_check_in_token):
return f"{settings.SITE_BASE_URL}{CHECK_IN_PREVIEW_PATH}?token={raw_check_in_token}"
def generate_check_in_qr_png(raw_check_in_token):
qr_image = qrcode.make(build_check_in_preview_url(raw_check_in_token))
buffer = BytesIO()
qr_image.save(buffer, format="PNG")
return buffer.getvalue()
def generate_check_in_qr_base64(*, reservation, raw_check_in_token):
if reservation.status != Reservation.Status.CONFIRMED:
raise ValueError("QR codes are available only for confirmed reservations.")
png_bytes = generate_check_in_qr_png(raw_check_in_token)
encoded = base64.b64encode(png_bytes).decode("ascii")
return f"data:image/png;base64,{encoded}"
+45
View File
@@ -0,0 +1,45 @@
from rest_framework import serializers
class StrictSerializer(serializers.Serializer):
def validate(self, attrs):
unknown_fields = set(self.initial_data) - set(self.fields)
if unknown_fields:
raise serializers.ValidationError(
{field: ["Unexpected field."] for field in sorted(unknown_fields)}
)
return attrs
class ReservationCreateSerializer(StrictSerializer):
name = serializers.CharField(max_length=200, trim_whitespace=True)
email = serializers.EmailField()
phone = serializers.CharField(max_length=40, trim_whitespace=True, required=False, allow_blank=True)
party_size = serializers.IntegerField(min_value=1)
notes = serializers.CharField(trim_whitespace=True, required=False, allow_blank=True)
class ReservationCreateResponseSerializer(serializers.Serializer):
id = serializers.IntegerField()
status = serializers.CharField()
performance = serializers.IntegerField(source="performance_id")
party_size = serializers.IntegerField()
message = serializers.CharField()
class ReservationConfirmSerializer(StrictSerializer):
token = serializers.CharField(trim_whitespace=True, allow_blank=False)
class ReservationQRResponseSerializer(serializers.Serializer):
reservation_id = serializers.IntegerField(source="reservation.id")
qr_code_url = serializers.CharField()
qr_code_image = serializers.CharField()
class ReservationConfirmResponseSerializer(serializers.Serializer):
reservation_id = serializers.IntegerField(source="reservation.id")
status = serializers.CharField(source="reservation.status")
party_size = serializers.IntegerField(source="reservation.party_size")
qr_code_url = serializers.CharField()
qr_code_image = serializers.CharField()
+239
View File
@@ -0,0 +1,239 @@
from dataclasses import dataclass
from datetime import timedelta
from django.db import transaction
from django.db.models import Sum
from django.utils import timezone
from shows.models import Performance
from .emailing import send_confirmation_email
from .models import Reservation, ReservationToken
from .qr import build_check_in_preview_url, generate_check_in_qr_base64
CONFIRMATION_TOKEN_TTL = timedelta(hours=48)
CHECK_IN_TOKEN_AFTER_PERFORMANCE_TTL = timedelta(days=1)
class BookingServiceError(Exception):
"""Base class for booking service domain errors."""
class PerformanceNotAvailable(BookingServiceError):
pass
class NotEnoughSeats(BookingServiceError):
pass
class InvalidToken(BookingServiceError):
pass
class ExpiredToken(BookingServiceError):
pass
class AlreadyConfirmedReservation(BookingServiceError):
pass
class ReservationNotConfirmed(BookingServiceError):
pass
@dataclass(frozen=True)
class PendingReservationResult:
reservation: Reservation
confirmation_token: ReservationToken
raw_confirmation_token: str
available_seats: int
@dataclass(frozen=True)
class ConfirmedReservationResult:
reservation: Reservation
confirmation_token: ReservationToken
check_in_token: ReservationToken
raw_check_in_token: str
available_seats: int
qr_code_image: str
qr_code_url: str
@dataclass(frozen=True)
class ReservationQRResult:
reservation: Reservation
qr_code_image: str
qr_code_url: str
def calculate_available_seats(performance):
confirmed_seats = (
Reservation.objects.filter(
performance=performance,
status=Reservation.Status.CONFIRMED,
).aggregate(total=Sum("party_size"))["total"]
or 0
)
return performance.configured_capacity - confirmed_seats
def create_pending_reservation(
*,
performance_id,
name,
email,
party_size,
phone="",
notes="",
confirmation_expires_at=None,
):
with transaction.atomic():
performance = _get_locked_bookable_performance(performance_id)
available_seats = calculate_available_seats(performance)
if party_size > available_seats:
raise NotEnoughSeats("Not enough seats are available for this performance.")
reservation = Reservation(
performance=performance,
name=name,
email=email,
phone=phone,
party_size=party_size,
notes=notes,
)
reservation.full_clean()
reservation.save()
confirmation_token, raw_confirmation_token = generate_confirmation_token(
reservation,
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(
reservation=reservation,
confirmation_token=confirmation_token,
raw_confirmation_token=raw_confirmation_token,
available_seats=available_seats,
)
return result
def generate_confirmation_token(reservation, *, expires_at=None):
return ReservationToken.create_token(
reservation=reservation,
purpose=ReservationToken.Purpose.CONFIRMATION,
expires_at=expires_at or timezone.now() + CONFIRMATION_TOKEN_TTL,
)
def confirm_reservation_from_token(raw_token):
token_hash = ReservationToken.hash_token(raw_token)
token_was_expired = False
with transaction.atomic():
try:
confirmation_token = (
ReservationToken.objects.select_for_update()
.select_related("reservation")
.get(token_hash=token_hash, purpose=ReservationToken.Purpose.CONFIRMATION)
)
except ReservationToken.DoesNotExist as exc:
raise InvalidToken("Confirmation token is invalid.") from exc
reservation = Reservation.objects.select_for_update().get(pk=confirmation_token.reservation_id)
if confirmation_token.is_used:
if reservation.status == Reservation.Status.CONFIRMED:
raise AlreadyConfirmedReservation("Reservation is already confirmed.")
raise InvalidToken("Confirmation token has already been used.")
if confirmation_token.is_expired:
if reservation.status == Reservation.Status.PENDING:
reservation.status = Reservation.Status.EXPIRED
reservation.save(update_fields=["status", "updated_at"])
token_was_expired = True
if not token_was_expired and reservation.status == Reservation.Status.CONFIRMED:
raise AlreadyConfirmedReservation("Reservation is already confirmed.")
if not token_was_expired and reservation.status != Reservation.Status.PENDING:
raise InvalidToken("Confirmation token is not valid for this reservation.")
if not token_was_expired:
performance = _get_locked_bookable_performance(reservation.performance_id)
available_seats = calculate_available_seats(performance)
if reservation.party_size > available_seats:
raise NotEnoughSeats("Not enough seats are available for this performance.")
reservation.confirm()
confirmation_token.mark_used()
check_in_token, raw_check_in_token = ReservationToken.create_token(
reservation=reservation,
purpose=ReservationToken.Purpose.CHECK_IN,
expires_at=performance.starts_at + CHECK_IN_TOKEN_AFTER_PERFORMANCE_TTL,
)
reservation.qr_code_generated_at = timezone.now()
reservation.save(update_fields=["qr_code_generated_at", "updated_at"])
if token_was_expired:
raise ExpiredToken("Confirmation token has expired.")
return ConfirmedReservationResult(
reservation=reservation,
confirmation_token=confirmation_token,
check_in_token=check_in_token,
raw_check_in_token=raw_check_in_token,
available_seats=available_seats - reservation.party_size,
qr_code_image=generate_check_in_qr_base64(
reservation=reservation,
raw_check_in_token=raw_check_in_token,
),
qr_code_url=build_check_in_preview_url(raw_check_in_token),
)
def retrieve_reservation_qr_from_token(raw_token):
try:
check_in_token = ReservationToken.objects.select_related("reservation").get_valid_token(
raw_token,
ReservationToken.Purpose.CHECK_IN,
)
except ReservationToken.DoesNotExist as exc:
raise InvalidToken("Check-in token is invalid.") from exc
reservation = check_in_token.reservation
if reservation.status != Reservation.Status.CONFIRMED:
raise ReservationNotConfirmed("Reservation must be confirmed before QR retrieval.")
return ReservationQRResult(
reservation=reservation,
qr_code_image=generate_check_in_qr_base64(
reservation=reservation,
raw_check_in_token=raw_token,
),
qr_code_url=build_check_in_preview_url(raw_token),
)
def _get_locked_bookable_performance(performance_id):
try:
performance = Performance.objects.select_for_update().get(pk=performance_id)
except Performance.DoesNotExist as exc:
raise PerformanceNotAvailable("Performance is not available for booking.") from exc
if not performance.is_booking_enabled:
raise PerformanceNotAvailable("Performance is not available for booking.")
return performance
+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")
+314
View File
@@ -0,0 +1,314 @@
from datetime import timedelta
from unittest.mock import patch
from django.core import mail
from django.urls import reverse
from django.test.utils import override_settings
from django.utils import timezone
from rest_framework import status
from rest_framework.test import APITestCase
from bookings.models import Reservation
from bookings.services import generate_confirmation_token
from bookings.views import ReservationConfirmThrottle, ReservationCreateThrottle
from shows.models import Performance, Show, Venue
class BookingApiTests(APITestCase):
def setUp(self):
self.show = Show.objects.create(
title="Open Stage",
slug="open-stage-api",
summary="A contemporary theatre performance.",
description="Full public show description.",
is_published=True,
)
self.hidden_show = Show.objects.create(
title="Hidden Stage",
slug="hidden-stage-api",
is_published=False,
)
self.venue = Venue.objects.create(
name="AzioneLab Theatre",
slug="azionelab-theatre-api",
address="Via Example 1",
city="Rome",
)
self.performance = Performance.objects.create(
show=self.show,
venue=self.venue,
starts_at=timezone.now() + timedelta(days=7),
room_capacity=3,
)
Performance.objects.create(
show=self.hidden_show,
venue=self.venue,
starts_at=timezone.now() + timedelta(days=8),
room_capacity=5,
)
def test_show_list_returns_published_shows(self):
response = self.client.get(reverse("api-show-list"))
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data["results"]), 1)
self.assertEqual(response.data["results"][0]["slug"], self.show.slug)
def test_show_detail_returns_public_performances(self):
response = self.client.get(reverse("api-show-detail", kwargs={"slug": self.show.slug}))
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["slug"], self.show.slug)
self.assertEqual(len(response.data["performances"]), 1)
self.assertEqual(response.data["performances"][0]["id"], self.performance.id)
self.assertEqual(response.data["performances"][0]["available_seats"], 3)
@override_settings(SITE_BASE_URL="https://tickets.azionelab.example")
def test_reservation_creation_success(self):
with self.captureOnCommitCallbacks(execute=True):
response = self.client.post(
reverse("api-reservation-create", kwargs={"performance_id": self.performance.id}),
{
"name": "Maria Rossi",
"email": "maria@example.com",
"phone": "+390600000000",
"party_size": 2,
"notes": "Front row if possible.",
},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(response.data["status"], Reservation.Status.PENDING)
self.assertEqual(response.data["performance"], self.performance.id)
self.assertNotIn("token", response.data)
self.assertNotIn("email", response.data)
self.assertEqual(Reservation.objects.count(), 1)
self.assertEqual(len(mail.outbox), 1)
self.assertIn(
"https://tickets.azionelab.example/api/reservations/confirm/?token=",
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):
response = self.client.post(
reverse("api-reservation-create", kwargs={"performance_id": self.performance.id}),
{
"name": "Maria Rossi",
"email": "maria@example.com",
"party_size": 4,
},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_409_CONFLICT)
self.assertEqual(response.data["status"], "booking_unavailable")
self.assertEqual(Reservation.objects.count(), 0)
@override_settings(SITE_BASE_URL="https://tickets.azionelab.example")
def test_confirmation_success(self):
reservation = self.create_reservation()
_, raw_token = generate_confirmation_token(reservation)
response = self.client.post(
reverse("api-reservation-confirm"),
{"token": raw_token},
format="json",
)
reservation.refresh_from_db()
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["reservation_id"], reservation.id)
self.assertEqual(response.data["status"], Reservation.Status.CONFIRMED)
self.assertEqual(response.data["party_size"], reservation.party_size)
self.assertTrue(
response.data["qr_code_url"].startswith(
"https://tickets.azionelab.example/api/check-ins/preview/?token="
)
)
self.assertNotIn(raw_token, response.data["qr_code_url"])
self.assertNotIn("token", response.data)
self.assertTrue(response.data["qr_code_image"].startswith("data:image/png;base64,"))
self.assertEqual(reservation.status, Reservation.Status.CONFIRMED)
def test_confirmation_success_via_email_link_get_request(self):
reservation = self.create_reservation(email="get@example.com")
_, raw_token = generate_confirmation_token(reservation)
response = self.client.get(
reverse("api-reservation-confirm"),
{"token": raw_token},
)
reservation.refresh_from_db()
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["reservation_id"], reservation.id)
self.assertEqual(reservation.status, Reservation.Status.CONFIRMED)
def test_confirmation_with_invalid_token(self):
response = self.client.post(
reverse("api-reservation-confirm"),
{"token": "invalid-token"},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
self.assertEqual(response.data["status"], "invalid_token")
def test_duplicate_confirmation_behavior(self):
reservation = self.create_reservation()
_, raw_token = generate_confirmation_token(reservation)
first_response = self.client.post(
reverse("api-reservation-confirm"),
{"token": raw_token},
format="json",
)
second_response = self.client.post(
reverse("api-reservation-confirm"),
{"token": raw_token},
format="json",
)
self.assertEqual(first_response.status_code, status.HTTP_200_OK)
self.assertEqual(second_response.status_code, status.HTTP_409_CONFLICT)
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")
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()
_, raw_token = generate_confirmation_token(reservation)
self.client.post(
reverse("api-reservation-confirm"),
{"token": raw_token},
format="json",
)
response = self.client.get(
reverse("api-reservation-qr"),
{"token": raw_token},
)
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
self.assertEqual(response.data["status"], "invalid_token")
def test_qr_retrieval_fails_for_invalid_token(self):
response = self.client.get(
reverse("api-reservation-qr"),
{"token": "invalid-token"},
)
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
self.assertEqual(response.data["status"], "invalid_token")
def test_qr_retrieval_fails_for_pending_reservation(self):
reservation = self.create_reservation()
_, raw_token = generate_confirmation_token(reservation)
response = self.client.get(
reverse("api-reservation-qr"),
{"token": raw_token},
)
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
self.assertEqual(response.data["status"], "invalid_token")
self.assertEqual(reservation.status, Reservation.Status.PENDING)
def create_reservation(self, **overrides):
data = {
"performance": self.performance,
"name": "Maria Rossi",
"email": "maria@example.com",
"party_size": 1,
}
data.update(overrides)
return Reservation.objects.create(**data)
+331
View File
@@ -0,0 +1,331 @@
from datetime import timedelta
from unittest.mock import patch
from django.core import mail
from django.db import connection
from django.test import TestCase
from django.test.utils import CaptureQueriesContext
from django.test.utils import override_settings
from django.utils import timezone
from bookings.models import Reservation, ReservationToken
from bookings.qr import build_check_in_preview_url, generate_check_in_qr_base64
from bookings.services import (
AlreadyConfirmedReservation,
ExpiredToken,
InvalidToken,
NotEnoughSeats,
calculate_available_seats,
confirm_reservation_from_token,
create_pending_reservation,
generate_confirmation_token,
retrieve_reservation_qr_from_token,
)
from shows.models import Performance, Show, Venue
class BookingServiceTests(TestCase):
def setUp(self):
self.show = Show.objects.create(
title="Open Stage",
slug="open-stage-services",
is_published=True,
)
self.venue = Venue.objects.create(
name="AzioneLab Theatre",
slug="azionelab-theatre-services",
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=3,
)
def test_create_pending_reservation_generates_confirmation_token(self):
result = create_pending_reservation(
performance_id=self.performance.id,
name="Maria Rossi",
email="maria@example.com",
party_size=2,
)
self.assertEqual(result.reservation.status, Reservation.Status.PENDING)
self.assertEqual(result.reservation.party_size, 2)
self.assertEqual(result.confirmation_token.purpose, ReservationToken.Purpose.CONFIRMATION)
self.assertEqual(
result.confirmation_token.token_hash,
ReservationToken.hash_token(result.raw_confirmation_token),
)
self.assertNotIn("maria@example.com", result.raw_confirmation_token)
@override_settings(
EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend",
SITE_BASE_URL="https://tickets.azionelab.example",
)
def test_create_pending_reservation_sends_confirmation_email_after_commit(self):
with self.captureOnCommitCallbacks(execute=True) as callbacks:
result = create_pending_reservation(
performance_id=self.performance.id,
name="Maria Rossi",
email="maria@example.com",
party_size=1,
)
self.assertEqual(len(callbacks), 1)
self.assertEqual(len(mail.outbox), 1)
self.assertEqual(mail.outbox[0].to, ["maria@example.com"])
self.assertIn(result.raw_confirmation_token, mail.outbox[0].body)
self.assertIn(
"https://tickets.azionelab.example/api/reservations/confirm/?token=",
mail.outbox[0].body,
)
@override_settings(EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend")
def test_create_pending_reservation_defers_email_until_commit(self):
with self.captureOnCommitCallbacks(execute=False) as callbacks:
create_pending_reservation(
performance_id=self.performance.id,
name="Maria Rossi",
email="maria@example.com",
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(Reservation.objects.count(), 1)
mocked_send_mail.assert_called_once()
self.assertTrue(
any(
"Failed to send confirmation email for reservation" in log_entry
for log_entry in captured_logs.output
)
)
def test_generate_confirmation_token_returns_raw_token_once(self):
reservation = self.create_reservation()
token, raw_token = generate_confirmation_token(reservation)
self.assertEqual(token.purpose, ReservationToken.Purpose.CONFIRMATION)
self.assertEqual(token.token_hash, ReservationToken.hash_token(raw_token))
self.assertGreater(token.expires_at, timezone.now())
@override_settings(SITE_BASE_URL="https://tickets.azionelab.example")
def test_confirm_reservation_from_valid_token(self):
reservation = self.create_reservation(party_size=2)
token, raw_token = generate_confirmation_token(reservation)
result = confirm_reservation_from_token(raw_token)
reservation.refresh_from_db()
token.refresh_from_db()
self.assertEqual(reservation.status, Reservation.Status.CONFIRMED)
self.assertIsNotNone(reservation.confirmed_at)
self.assertIsNotNone(reservation.qr_code_generated_at)
self.assertIsNotNone(token.used_at)
self.assertEqual(result.check_in_token.purpose, ReservationToken.Purpose.CHECK_IN)
self.assertEqual(
result.check_in_token.token_hash,
ReservationToken.hash_token(result.raw_check_in_token),
)
self.assertEqual(result.available_seats, 1)
self.assertEqual(
result.qr_code_url,
build_check_in_preview_url(result.raw_check_in_token),
)
self.assertTrue(
result.qr_code_url.startswith(
"https://tickets.azionelab.example/api/check-ins/preview/?token="
)
)
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")
def test_qr_code_is_generated_for_confirmed_reservation(self):
reservation = self.create_reservation(
status=Reservation.Status.CONFIRMED,
confirmed_at=timezone.now(),
)
raw_check_in_token = "opaque-check-in-token"
qr_code_image = generate_check_in_qr_base64(
reservation=reservation,
raw_check_in_token=raw_check_in_token,
)
self.assertTrue(qr_code_image.startswith("data:image/png;base64,"))
self.assertGreater(len(qr_code_image), len("data:image/png;base64,"))
self.assertEqual(
build_check_in_preview_url(raw_check_in_token),
"https://tickets.azionelab.example/api/check-ins/preview/?token=opaque-check-in-token",
)
def test_qr_code_is_not_generated_for_pending_reservation(self):
reservation = self.create_reservation()
with self.assertRaisesMessage(
ValueError,
"QR codes are available only for confirmed reservations.",
):
generate_check_in_qr_base64(
reservation=reservation,
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):
Reservation.objects.create(
performance=self.performance,
name="Confirmed Guest",
email="confirmed@example.com",
party_size=3,
status=Reservation.Status.CONFIRMED,
confirmed_at=timezone.now(),
)
reservation = self.create_reservation(party_size=1)
token, raw_token = generate_confirmation_token(reservation)
with self.assertRaises(NotEnoughSeats):
confirm_reservation_from_token(raw_token)
reservation.refresh_from_db()
token.refresh_from_db()
self.assertEqual(reservation.status, Reservation.Status.PENDING)
self.assertIsNone(token.used_at)
def test_pending_reservation_does_not_reduce_confirmed_availability(self):
create_pending_reservation(
performance_id=self.performance.id,
name="Pending Guest",
email="pending@example.com",
party_size=3,
)
self.assertEqual(calculate_available_seats(self.performance), 3)
def test_invalid_token_fails(self):
with self.assertRaises(InvalidToken):
confirm_reservation_from_token("not-a-real-token")
def test_expired_token_marks_pending_reservation_expired(self):
reservation = self.create_reservation()
token, raw_token = generate_confirmation_token(
reservation,
expires_at=timezone.now() - timedelta(minutes=1),
)
with self.assertRaises(ExpiredToken):
confirm_reservation_from_token(raw_token)
reservation.refresh_from_db()
token.refresh_from_db()
self.assertEqual(reservation.status, Reservation.Status.EXPIRED)
self.assertIsNone(token.used_at)
def test_duplicate_confirmation_is_rejected_safely(self):
reservation = self.create_reservation()
token, raw_token = generate_confirmation_token(reservation)
confirm_reservation_from_token(raw_token)
with self.assertRaises(AlreadyConfirmedReservation):
confirm_reservation_from_token(raw_token)
reservation.refresh_from_db()
token.refresh_from_db()
self.assertEqual(reservation.status, Reservation.Status.CONFIRMED)
self.assertIsNotNone(token.used_at)
self.assertEqual(
ReservationToken.objects.filter(
reservation=reservation,
purpose=ReservationToken.Purpose.CHECK_IN,
).count(),
1,
)
def test_confirmation_rechecks_capacity_after_another_confirmation(self):
self.performance.room_capacity = 1
self.performance.save(update_fields=["room_capacity", "updated_at"])
first = self.create_reservation(email="first@example.com", party_size=1)
second = self.create_reservation(email="second@example.com", party_size=1)
_, first_raw_token = generate_confirmation_token(first)
second_token, second_raw_token = generate_confirmation_token(second)
confirm_reservation_from_token(first_raw_token)
with self.assertRaises(NotEnoughSeats):
confirm_reservation_from_token(second_raw_token)
second.refresh_from_db()
second_token.refresh_from_db()
self.assertEqual(second.status, Reservation.Status.PENDING)
self.assertIsNone(second_token.used_at)
self.assertEqual(calculate_available_seats(self.performance), 0)
def test_confirmation_uses_row_level_locking(self):
reservation = self.create_reservation()
_, raw_token = generate_confirmation_token(reservation)
with CaptureQueriesContext(connection) as queries:
confirm_reservation_from_token(raw_token)
self.assertTrue(any("FOR UPDATE" in query["sql"] for query in queries))
def create_reservation(self, **overrides):
data = {
"performance": self.performance,
"name": "Maria Rossi",
"email": "maria@example.com",
"party_size": 1,
}
data.update(overrides)
return Reservation.objects.create(**data)
+119
View File
@@ -0,0 +1,119 @@
from datetime import timedelta
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
from django.db import IntegrityError
from django.test import TestCase
from django.utils import timezone
from bookings.models import Reservation, ReservationToken
from checkins.models import CheckIn
from shows.models import Performance, Show, Venue
class DomainModelTests(TestCase):
def setUp(self):
self.show = Show.objects.create(
title="Open Stage",
slug="open-stage",
is_published=True,
)
self.venue = Venue.objects.create(
name="AzioneLab Theatre",
slug="azionelab-theatre",
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=10,
manually_occupied_seats=2,
additional_seats=3,
)
def create_reservation(self, **overrides):
data = {
"performance": self.performance,
"name": "Maria Rossi",
"email": "maria@example.com",
"party_size": 2,
}
data.update(overrides)
return Reservation.objects.create(**data)
def test_available_seats_count_only_confirmed_reservations(self):
self.create_reservation(party_size=4)
self.create_reservation(
party_size=3,
status=Reservation.Status.CONFIRMED,
confirmed_at=timezone.now(),
)
self.assertEqual(self.performance.configured_capacity, 11)
self.assertEqual(self.performance.confirmed_seats(), 3)
self.assertEqual(self.performance.available_seats(), 8)
def test_reservation_lifecycle_pending_to_confirmed_with_token(self):
reservation = self.create_reservation()
token, raw_token = ReservationToken.create_token(
reservation=reservation,
purpose=ReservationToken.Purpose.CONFIRMATION,
expires_at=timezone.now() + timedelta(hours=2),
)
reservation.confirm_with_token(raw_token)
reservation.refresh_from_db()
token.refresh_from_db()
self.assertEqual(reservation.status, Reservation.Status.CONFIRMED)
self.assertIsNotNone(reservation.confirmed_at)
self.assertIsNotNone(token.used_at)
def test_only_pending_reservations_can_be_confirmed(self):
reservation = self.create_reservation(status=Reservation.Status.CANCELLED)
token, raw_token = ReservationToken.create_token(
reservation=reservation,
purpose=ReservationToken.Purpose.CONFIRMATION,
expires_at=timezone.now() + timedelta(hours=2),
)
with self.assertRaises(ValidationError):
reservation.confirm_with_token(raw_token)
token.refresh_from_db()
self.assertIsNone(token.used_at)
def test_capacity_configuration_rejects_impossible_manual_occupancy(self):
performance = Performance(
show=self.show,
venue=self.venue,
starts_at=timezone.now() + timedelta(days=8),
room_capacity=5,
additional_seats=1,
manually_occupied_seats=7,
)
with self.assertRaises(ValidationError):
performance.full_clean()
def test_check_in_requires_confirmed_reservation(self):
reservation = self.create_reservation()
user = get_user_model().objects.create_user(username="staff", password="test")
check_in = CheckIn(reservation=reservation, checked_in_by=user)
with self.assertRaises(ValidationError):
check_in.full_clean()
def test_reservation_cannot_be_checked_in_twice(self):
reservation = self.create_reservation(
status=Reservation.Status.CONFIRMED,
confirmed_at=timezone.now(),
)
user = get_user_model().objects.create_user(username="staff", password="test")
CheckIn.objects.create(reservation=reservation, checked_in_by=user)
with self.assertRaises((IntegrityError, ValidationError)):
CheckIn.objects.create(reservation=reservation, checked_in_by=user)
+14
View File
@@ -0,0 +1,14 @@
from django.urls import path
from . import views
urlpatterns = [
path(
"performances/<int:performance_id>/reservations/",
views.create_reservation,
name="api-reservation-create",
),
path("reservations/confirm/", views.confirm_reservation, name="api-reservation-confirm"),
path("reservations/qr/", views.retrieve_reservation_qr, name="api-reservation-qr"),
]
+125
View File
@@ -0,0 +1,125 @@
from django.shortcuts import get_object_or_404
from rest_framework import status
from rest_framework.decorators import api_view, throttle_classes
from rest_framework.response import Response
from rest_framework.throttling import AnonRateThrottle
from shows.models import Performance
from .serializers import (
ReservationConfirmResponseSerializer,
ReservationConfirmSerializer,
ReservationCreateResponseSerializer,
ReservationCreateSerializer,
ReservationQRResponseSerializer,
)
from .services import (
AlreadyConfirmedReservation,
ExpiredToken,
InvalidToken,
NotEnoughSeats,
PerformanceNotAvailable,
ReservationNotConfirmed,
confirm_reservation_from_token,
create_pending_reservation,
retrieve_reservation_qr_from_token,
)
class ReservationCreateThrottle(AnonRateThrottle):
scope = "reservation_create"
class ReservationConfirmThrottle(AnonRateThrottle):
scope = "reservation_confirm"
@api_view(["POST"])
@throttle_classes([ReservationCreateThrottle])
def create_reservation(request, performance_id):
get_object_or_404(Performance, pk=performance_id, show__is_published=True)
serializer = ReservationCreateSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
try:
result = create_pending_reservation(
performance_id=performance_id,
name=serializer.validated_data["name"],
email=serializer.validated_data["email"],
phone=serializer.validated_data.get("phone", ""),
party_size=serializer.validated_data["party_size"],
notes=serializer.validated_data.get("notes", ""),
)
except (NotEnoughSeats, PerformanceNotAvailable) as exc:
return Response(
{"status": "booking_unavailable", "detail": str(exc)},
status=status.HTTP_409_CONFLICT,
)
response_serializer = ReservationCreateResponseSerializer(
{
"id": result.reservation.id,
"status": result.reservation.status,
"performance_id": result.reservation.performance_id,
"party_size": result.reservation.party_size,
"message": "Reservation created. Please check your email to confirm it.",
}
)
return Response(response_serializer.data, status=status.HTTP_201_CREATED)
@api_view(["GET", "POST"])
@throttle_classes([ReservationConfirmThrottle])
def confirm_reservation(request):
payload = request.query_params if request.method == "GET" else request.data
serializer = ReservationConfirmSerializer(data=payload)
serializer.is_valid(raise_exception=True)
try:
result = confirm_reservation_from_token(serializer.validated_data["token"])
except InvalidToken as exc:
return Response(
{"status": "invalid_token", "detail": str(exc)},
status=status.HTTP_404_NOT_FOUND,
)
except ExpiredToken as exc:
return Response(
{"status": "token_expired", "detail": str(exc)},
status=status.HTTP_410_GONE,
)
except NotEnoughSeats as exc:
return Response(
{"status": "not_enough_seats", "detail": str(exc)},
status=status.HTTP_409_CONFLICT,
)
except AlreadyConfirmedReservation as exc:
return Response(
{"status": "already_confirmed", "detail": str(exc)},
status=status.HTTP_409_CONFLICT,
)
response_serializer = ReservationConfirmResponseSerializer(result)
return Response(response_serializer.data)
@api_view(["GET"])
def retrieve_reservation_qr(request):
serializer = ReservationConfirmSerializer(data=request.query_params)
serializer.is_valid(raise_exception=True)
try:
result = retrieve_reservation_qr_from_token(serializer.validated_data["token"])
except InvalidToken as exc:
return Response(
{"status": "invalid_token", "detail": str(exc)},
status=status.HTTP_404_NOT_FOUND,
)
except ReservationNotConfirmed as exc:
return Response(
{"status": "reservation_not_confirmed", "detail": str(exc)},
status=status.HTTP_409_CONFLICT,
)
response_serializer = ReservationQRResponseSerializer(result)
return Response(response_serializer.data)
+30
View File
@@ -0,0 +1,30 @@
from django.contrib import admin
from .models import CheckIn
@admin.register(CheckIn)
class CheckInAdmin(admin.ModelAdmin):
list_display = (
"reservation",
"performance",
"checked_in_at",
"checked_in_by",
"source",
"created_at",
)
list_filter = ("source", "checked_in_at", "created_at")
search_fields = (
"reservation__name",
"reservation__email",
"reservation__performance__show__title",
"checked_in_by__username",
"checked_in_by__email",
)
readonly_fields = ("created_at", "updated_at", "checked_in_at")
list_select_related = ("reservation", "reservation__performance", "checked_in_by")
autocomplete_fields = ("reservation", "checked_in_by")
@admin.display(description="Performance")
def performance(self, obj):
return obj.reservation.performance
@@ -0,0 +1,54 @@
# Generated by Django 5.2.3 on 2026-04-28
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("bookings", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="CheckIn",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("checked_in_at", models.DateTimeField(default=django.utils.timezone.now)),
(
"source",
models.CharField(choices=[("qr_scan", "QR scan"), ("manual", "Manual")], default="qr_scan", max_length=20),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"checked_in_by",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="checkins",
to=settings.AUTH_USER_MODEL,
),
),
(
"reservation",
models.OneToOneField(
on_delete=django.db.models.deletion.PROTECT,
related_name="check_in",
to="bookings.reservation",
),
),
],
options={
"ordering": ["-checked_in_at"],
"indexes": [
models.Index(fields=["checked_in_at"], name="checkins_ch_checked_761e33_idx"),
models.Index(fields=["checked_in_by"], name="checkins_ch_checked_becaae_idx"),
],
},
),
]
+1
View File
@@ -0,0 +1 @@
+46 -1
View File
@@ -1 +1,46 @@
# Domain models will be added when check-in behavior is implemented. from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
from django.utils import timezone
from bookings.models import Reservation
class CheckIn(models.Model):
class Source(models.TextChoices):
QR_SCAN = "qr_scan", "QR scan"
MANUAL = "manual", "Manual"
reservation = models.OneToOneField(
Reservation,
on_delete=models.PROTECT,
related_name="check_in",
)
checked_in_at = models.DateTimeField(default=timezone.now)
checked_in_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
related_name="checkins",
)
source = models.CharField(max_length=20, choices=Source.choices, default=Source.QR_SCAN)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["-checked_in_at"]
indexes = [
models.Index(fields=["checked_in_at"]),
models.Index(fields=["checked_in_by"]),
]
def __str__(self):
return f"Check-in for reservation {self.reservation_id}"
def clean(self):
super().clean()
if self.reservation_id and not self.reservation.is_confirmed:
raise ValidationError({"reservation": "Only confirmed reservations can be checked in."})
def save(self, *args, **kwargs):
self.full_clean()
return super().save(*args, **kwargs)
+32
View File
@@ -0,0 +1,32 @@
from rest_framework import serializers
class CheckInTokenSerializer(serializers.Serializer):
token = serializers.CharField(trim_whitespace=True, allow_blank=False)
def validate(self, attrs):
unknown_fields = set(self.initial_data) - set(self.fields)
if unknown_fields:
raise serializers.ValidationError(
{field: ["Unexpected field."] for field in sorted(unknown_fields)}
)
return attrs
class CheckInPreviewResponseSerializer(serializers.Serializer):
status = serializers.CharField()
reservation_id = serializers.IntegerField()
performance_id = serializers.IntegerField()
show_title = serializers.CharField()
venue_name = serializers.CharField()
starts_at = serializers.DateTimeField()
party_size = serializers.IntegerField()
class CheckInConfirmResponseSerializer(serializers.Serializer):
status = serializers.CharField()
reservation_id = serializers.IntegerField(source="preview.reservation_id")
performance_id = serializers.IntegerField(source="preview.performance_id")
party_size = serializers.IntegerField(source="preview.party_size")
checked_in_at = serializers.DateTimeField(source="check_in.checked_in_at")
checked_in_by = serializers.IntegerField(source="check_in.checked_in_by_id")
+129
View File
@@ -0,0 +1,129 @@
from dataclasses import dataclass
from django.db import IntegrityError, transaction
from bookings.models import Reservation, ReservationToken
from .models import CheckIn
class CheckInServiceError(Exception):
"""Base class for check-in service domain errors."""
class InvalidToken(CheckInServiceError):
pass
class ReservationNotConfirmed(CheckInServiceError):
pass
class AlreadyCheckedIn(CheckInServiceError):
pass
class MissingStaffUser(CheckInServiceError):
pass
@dataclass(frozen=True)
class CheckInPreview:
reservation_id: int
performance_id: int
show_title: str
venue_name: str
starts_at: object
party_size: int
checked_in: bool
@dataclass(frozen=True)
class CheckInResult:
check_in: CheckIn
preview: CheckInPreview
def preview_check_in_token(raw_token, *, staff_user):
_validate_staff_user(staff_user)
reservation = _get_reservation_for_check_in_token(raw_token)
_validate_reservation_for_check_in(reservation)
return _build_preview(reservation)
def confirm_check_in_from_token(raw_token, *, staff_user, source=CheckIn.Source.QR_SCAN):
_validate_staff_user(staff_user)
with transaction.atomic():
reservation = _get_reservation_for_check_in_token(raw_token, lock_token=True)
reservation = (
Reservation.objects.select_for_update()
.select_related("performance__show", "performance__venue")
.get(pk=reservation.pk)
)
_validate_reservation_for_check_in(reservation)
try:
check_in = CheckIn.objects.create(
reservation=reservation,
checked_in_by=staff_user,
source=source,
)
except IntegrityError as exc:
raise AlreadyCheckedIn("Reservation has already been checked in.") from exc
return CheckInResult(check_in=check_in, preview=_build_preview(reservation))
def _validate_staff_user(staff_user):
if staff_user is None:
raise MissingStaffUser("A staff user is required for check-in.")
if not getattr(staff_user, "is_authenticated", False):
raise MissingStaffUser("An authenticated staff user is required for check-in.")
if not getattr(staff_user, "is_staff", False):
raise MissingStaffUser("A staff user is required for check-in.")
def _get_reservation_for_check_in_token(raw_token, *, lock_token=False):
if not isinstance(raw_token, str) or not raw_token:
raise InvalidToken("Check-in token is invalid.")
queryset = ReservationToken.objects.select_related(
"reservation__performance__show",
"reservation__performance__venue",
)
if lock_token:
queryset = queryset.select_for_update()
try:
token = queryset.get(
token_hash=ReservationToken.hash_token(raw_token),
purpose=ReservationToken.Purpose.CHECK_IN,
)
except ReservationToken.DoesNotExist as exc:
raise InvalidToken("Check-in token is invalid.") from exc
if token.used_at is not None or token.is_expired:
raise InvalidToken("Check-in token is invalid.")
return token.reservation
def _validate_reservation_for_check_in(reservation):
if reservation.status != Reservation.Status.CONFIRMED:
raise ReservationNotConfirmed("Reservation must be confirmed before check-in.")
if hasattr(reservation, "check_in"):
raise AlreadyCheckedIn("Reservation has already been checked in.")
def _build_preview(reservation):
performance = reservation.performance
return CheckInPreview(
reservation_id=reservation.id,
performance_id=performance.id,
show_title=performance.show.title,
venue_name=performance.venue.name,
starts_at=performance.starts_at,
party_size=reservation.party_size,
checked_in=hasattr(reservation, "check_in"),
)
+253
View File
@@ -0,0 +1,253 @@
from datetime import timedelta
from unittest.mock import patch
from django.contrib.auth import get_user_model
from django.urls import reverse
from django.test.utils import override_settings
from django.utils import timezone
from rest_framework import status
from rest_framework.test import APITestCase
from bookings.models import Reservation, ReservationToken
from checkins.models import CheckIn
from checkins.views import CheckInPreviewThrottle
from shows.models import Performance, Show, Venue
class CheckInApiTests(APITestCase):
def setUp(self):
self.show = Show.objects.create(
title="Open Stage",
slug="open-stage-checkin-api",
is_published=True,
)
self.venue = Venue.objects.create(
name="AzioneLab Theatre",
slug="azionelab-theatre-checkin-api",
address="Via Example 1",
city="Rome",
)
self.performance = Performance.objects.create(
show=self.show,
venue=self.venue,
starts_at=timezone.now() + timedelta(days=7),
room_capacity=20,
)
self.staff_user = get_user_model().objects.create_user(
username="staff-api",
password="test",
is_staff=True,
)
self.regular_user = get_user_model().objects.create_user(
username="regular-api",
password="test",
is_staff=False,
)
def test_preview_success_as_staff_user(self):
reservation = self.create_reservation()
_, raw_token = self.create_check_in_token(reservation)
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_200_OK)
self.assertEqual(response.data["status"], "valid")
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)
self.assertNotIn("phone", response.data)
def test_preview_denied_for_anonymous_user(self):
reservation = self.create_reservation()
_, raw_token = self.create_check_in_token(reservation)
response = self.client.post(
reverse("api-check-in-preview"),
{"token": raw_token},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_preview_fails_for_invalid_token(self):
self.client.force_authenticate(user=self.staff_user)
response = self.client.post(
reverse("api-check-in-preview"),
{"token": "invalid-token"},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
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):
reservation = self.create_reservation()
_, raw_token = self.create_check_in_token(reservation)
self.client.force_authenticate(user=self.staff_user)
response = self.client.post(
reverse("api-check-in-confirm"),
{"token": raw_token},
format="json",
)
check_in = CheckIn.objects.get(reservation=reservation)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["status"], "checked_in")
self.assertEqual(response.data["reservation_id"], reservation.id)
self.assertEqual(response.data["performance_id"], self.performance.id)
self.assertEqual(response.data["party_size"], reservation.party_size)
self.assertEqual(response.data["checked_in_by"], self.staff_user.id)
self.assertIsNotNone(response.data["checked_in_at"])
self.assertEqual(check_in.checked_in_by, self.staff_user)
def test_check_in_denied_for_anonymous_user(self):
reservation = self.create_reservation()
_, raw_token = self.create_check_in_token(reservation)
response = self.client.post(
reverse("api-check-in-confirm"),
{"token": raw_token},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
self.assertFalse(CheckIn.objects.filter(reservation=reservation).exists())
def test_check_in_denied_for_non_staff_authenticated_user(self):
reservation = self.create_reservation()
_, raw_token = self.create_check_in_token(reservation)
self.client.force_authenticate(user=self.regular_user)
response = self.client.post(
reverse("api-check-in-confirm"),
{"token": raw_token},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertFalse(CheckIn.objects.filter(reservation=reservation).exists())
def test_check_in_fails_for_pending_reservation(self):
reservation = self.create_reservation(status=Reservation.Status.PENDING, confirmed_at=None)
_, raw_token = self.create_check_in_token(reservation)
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_409_CONFLICT)
self.assertEqual(response.data["status"], "reservation_not_confirmed")
self.assertFalse(CheckIn.objects.filter(reservation=reservation).exists())
def test_duplicate_check_in_fails(self):
reservation = self.create_reservation()
_, raw_token = self.create_check_in_token(reservation)
self.client.force_authenticate(user=self.staff_user)
first_response = self.client.post(
reverse("api-check-in-confirm"),
{"token": raw_token},
format="json",
)
second_response = self.client.post(
reverse("api-check-in-confirm"),
{"token": raw_token},
format="json",
)
self.assertEqual(first_response.status_code, status.HTTP_200_OK)
self.assertEqual(second_response.status_code, status.HTTP_409_CONFLICT)
self.assertEqual(second_response.data["status"], "already_checked_in")
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):
data = {
"performance": self.performance,
"name": "Maria Rossi",
"email": "maria@example.com",
"party_size": 2,
"status": Reservation.Status.CONFIRMED,
"confirmed_at": timezone.now(),
}
data.update(overrides)
return Reservation.objects.create(**data)
def create_check_in_token(self, reservation):
return ReservationToken.create_token(
reservation=reservation,
purpose=ReservationToken.Purpose.CHECK_IN,
expires_at=self.performance.starts_at + timedelta(days=1),
)
+156
View File
@@ -0,0 +1,156 @@
from datetime import timedelta
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.utils import timezone
from bookings.models import Reservation, ReservationToken
from checkins.models import CheckIn
from checkins.services import (
AlreadyCheckedIn,
InvalidToken,
MissingStaffUser,
ReservationNotConfirmed,
confirm_check_in_from_token,
preview_check_in_token,
)
from shows.models import Performance, Show, Venue
class CheckInServiceTests(TestCase):
def setUp(self):
self.show = Show.objects.create(
title="Open Stage",
slug="open-stage-checkins",
is_published=True,
)
self.venue = Venue.objects.create(
name="AzioneLab Theatre",
slug="azionelab-theatre-checkins",
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,
)
self.staff_user = get_user_model().objects.create_user(
username="staff",
password="test",
is_staff=True,
)
def test_successful_preview_returns_minimum_admission_data(self):
reservation = self.create_reservation()
_, raw_token = self.create_check_in_token(reservation)
preview = preview_check_in_token(raw_token, staff_user=self.staff_user)
self.assertEqual(preview.reservation_id, reservation.id)
self.assertEqual(preview.performance_id, self.performance.id)
self.assertEqual(preview.show_title, self.show.title)
self.assertEqual(preview.venue_name, self.venue.name)
self.assertEqual(preview.party_size, reservation.party_size)
self.assertFalse(preview.checked_in)
self.assertFalse(hasattr(preview, "name"))
self.assertFalse(hasattr(preview, "email"))
self.assertFalse(hasattr(preview, "phone"))
def test_preview_fails_for_invalid_token(self):
with self.assertRaises(InvalidToken):
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):
reservation = self.create_reservation()
_, raw_token = self.create_check_in_token(reservation)
result = confirm_check_in_from_token(raw_token, staff_user=self.staff_user)
self.assertEqual(result.check_in.reservation, reservation)
self.assertEqual(result.check_in.checked_in_by, self.staff_user)
self.assertEqual(result.check_in.source, CheckIn.Source.QR_SCAN)
self.assertTrue(result.preview.checked_in)
def test_check_in_fails_for_pending_reservation(self):
reservation = self.create_reservation(status=Reservation.Status.PENDING, confirmed_at=None)
_, raw_token = self.create_check_in_token(reservation)
with self.assertRaises(ReservationNotConfirmed):
confirm_check_in_from_token(raw_token, staff_user=self.staff_user)
self.assertFalse(CheckIn.objects.filter(reservation=reservation).exists())
def test_duplicate_check_in_fails(self):
reservation = self.create_reservation()
_, raw_token = self.create_check_in_token(reservation)
confirm_check_in_from_token(raw_token, staff_user=self.staff_user)
with self.assertRaises(AlreadyCheckedIn):
confirm_check_in_from_token(raw_token, staff_user=self.staff_user)
self.assertEqual(CheckIn.objects.filter(reservation=reservation).count(), 1)
def test_check_in_stores_timestamp_and_staff_user(self):
reservation = self.create_reservation()
_, raw_token = self.create_check_in_token(reservation)
before_check_in = timezone.now()
result = confirm_check_in_from_token(
raw_token,
staff_user=self.staff_user,
source=CheckIn.Source.MANUAL,
)
self.assertGreaterEqual(result.check_in.checked_in_at, before_check_in)
self.assertEqual(result.check_in.checked_in_by, self.staff_user)
self.assertEqual(result.check_in.source, CheckIn.Source.MANUAL)
def test_check_in_requires_staff_user(self):
reservation = self.create_reservation()
_, raw_token = self.create_check_in_token(reservation)
with self.assertRaises(MissingStaffUser):
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):
data = {
"performance": self.performance,
"name": "Maria Rossi",
"email": "maria@example.com",
"party_size": 2,
"status": Reservation.Status.CONFIRMED,
"confirmed_at": timezone.now(),
}
data.update(overrides)
return Reservation.objects.create(**data)
def create_check_in_token(self, reservation):
return ReservationToken.create_token(
reservation=reservation,
purpose=ReservationToken.Purpose.CHECK_IN,
expires_at=self.performance.starts_at + timedelta(days=1),
)
+9
View File
@@ -0,0 +1,9 @@
from django.urls import path
from . import views
urlpatterns = [
path("check-ins/preview/", views.check_in_preview, name="api-check-in-preview"),
path("check-ins/confirm/", views.check_in_confirm, name="api-check-in-confirm"),
]
+118
View File
@@ -0,0 +1,118 @@
from rest_framework import status
from rest_framework.authentication import BasicAuthentication, SessionAuthentication
from rest_framework.decorators import api_view, authentication_classes, permission_classes, throttle_classes
from rest_framework.permissions import BasePermission, IsAuthenticated
from rest_framework.response import Response
from rest_framework.throttling import UserRateThrottle
from .serializers import (
CheckInConfirmResponseSerializer,
CheckInPreviewResponseSerializer,
CheckInTokenSerializer,
)
from .services import (
AlreadyCheckedIn,
InvalidToken,
MissingStaffUser,
ReservationNotConfirmed,
confirm_check_in_from_token,
preview_check_in_token,
)
class CheckInPreviewThrottle(UserRateThrottle):
scope = "check_in_preview"
class CheckInConfirmThrottle(UserRateThrottle):
scope = "check_in_confirm"
class IsStaffUser(BasePermission):
def has_permission(self, request, view):
return bool(request.user and request.user.is_staff)
@api_view(["POST"])
@authentication_classes([BasicAuthentication, SessionAuthentication])
@permission_classes([IsAuthenticated, IsStaffUser])
@throttle_classes([CheckInPreviewThrottle])
def check_in_preview(request):
serializer = CheckInTokenSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
try:
preview = preview_check_in_token(serializer.validated_data["token"], staff_user=request.user)
except InvalidToken as exc:
return Response(
{"status": "invalid_token", "detail": str(exc)},
status=status.HTTP_404_NOT_FOUND,
)
except ReservationNotConfirmed as exc:
return Response(
{"status": "reservation_not_confirmed", "detail": str(exc)},
status=status.HTTP_409_CONFLICT,
)
except AlreadyCheckedIn as exc:
return Response(
{"status": "already_checked_in", "detail": str(exc)},
status=status.HTTP_409_CONFLICT,
)
except MissingStaffUser as exc:
return Response(
{"status": "staff_user_required", "detail": str(exc)},
status=status.HTTP_403_FORBIDDEN,
)
response_serializer = CheckInPreviewResponseSerializer(
{
"status": "valid",
"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,
}
)
return Response(response_serializer.data)
@api_view(["POST"])
@authentication_classes([BasicAuthentication, SessionAuthentication])
@permission_classes([IsAuthenticated, IsStaffUser])
@throttle_classes([CheckInConfirmThrottle])
def check_in_confirm(request):
serializer = CheckInTokenSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
try:
result = confirm_check_in_from_token(serializer.validated_data["token"], staff_user=request.user)
except InvalidToken as exc:
return Response(
{"status": "invalid_token", "detail": str(exc)},
status=status.HTTP_404_NOT_FOUND,
)
except ReservationNotConfirmed as exc:
return Response(
{"status": "reservation_not_confirmed", "detail": str(exc)},
status=status.HTTP_409_CONFLICT,
)
except AlreadyCheckedIn as exc:
return Response(
{"status": "already_checked_in", "detail": str(exc)},
status=status.HTTP_409_CONFLICT,
)
except MissingStaffUser as exc:
return Response(
{"status": "staff_user_required", "detail": str(exc)},
status=status.HTTP_403_FORBIDDEN,
)
response_serializer = CheckInConfirmResponseSerializer(
{
"status": "checked_in",
"check_in": result.check_in,
"preview": result.preview,
}
)
return Response(response_serializer.data)
+66
View File
@@ -0,0 +1,66 @@
from django.contrib import admin
from django.urls import reverse
from django.utils.html import format_html
from .models import Performance, Show, Venue
@admin.register(Show)
class ShowAdmin(admin.ModelAdmin):
list_display = ("title", "slug", "is_published", "created_at", "updated_at")
list_filter = ("is_published",)
search_fields = ("title", "slug", "summary", "description")
prepopulated_fields = {"slug": ("title",)}
readonly_fields = ("created_at", "updated_at")
@admin.register(Venue)
class VenueAdmin(admin.ModelAdmin):
list_display = ("name", "slug", "city", "address", "created_at", "updated_at")
list_filter = ("city",)
search_fields = ("name", "slug", "address", "city", "notes")
prepopulated_fields = {"slug": ("name",)}
readonly_fields = ("created_at", "updated_at")
@admin.register(Performance)
class PerformanceAdmin(admin.ModelAdmin):
list_display = (
"show",
"venue",
"starts_at",
"room_capacity",
"additional_seats",
"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")
list_select_related = ("show", "venue")
readonly_fields = ("created_at", "updated_at", "available_seats_display")
autocomplete_fields = ("show", "venue")
@admin.display(description="Available seats")
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()
@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,
)
+1
View File
@@ -0,0 +1 @@
@@ -0,0 +1 @@
@@ -0,0 +1,126 @@
import sys
from datetime import datetime
from datetime import timedelta
from django.conf import settings
from django.core.management.base import BaseCommand, CommandError
from django.utils import timezone
from shows.models import Performance, Show, Venue
class Command(BaseCommand):
help = "Create or update local demo data for AzioneLab."
def handle(self, *args, **options):
if not settings.DEBUG and "test" not in sys.argv:
raise CommandError("seed_demo_data is available only in local or test environments.")
today = timezone.localdate()
venues = [
{
"name": "AzioneLab Theatre",
"slug": "azionelab-theatre",
"address": "Via Example 1",
"city": "Rome",
"notes": "Main house for evening performances.",
},
{
"name": "Studio Nuovo",
"slug": "studio-nuovo",
"address": "Via Example 22",
"city": "Rome",
"notes": "Smaller venue for workshops and matinees.",
},
]
shows = [
{
"title": "Open Stage",
"slug": "open-stage",
"summary": "A contemporary theatre performance.",
"description": "A compact demo production for manual backend testing.",
"poster_image": "",
"is_published": True,
},
{
"title": "City Echoes",
"slug": "city-echoes",
"summary": "An ensemble piece set across modern Rome.",
"description": "A second published show with a different venue mix.",
"poster_image": "",
"is_published": True,
},
]
venue_map = {}
show_map = {}
for venue_data in venues:
venue, _ = Venue.objects.update_or_create(
slug=venue_data["slug"],
defaults=venue_data,
)
venue_map[venue.slug] = venue
for show_data in shows:
show, _ = Show.objects.update_or_create(
slug=show_data["slug"],
defaults=show_data,
)
show_map[show.slug] = show
performances = [
{
"show": show_map["open-stage"],
"venue": venue_map["azionelab-theatre"],
"starts_at": self._performance_starts_at(today + timedelta(days=7), hour=20, minute=30),
"room_capacity": 120,
"manually_occupied_seats": 8,
"additional_seats": 4,
"is_booking_enabled": True,
},
{
"show": show_map["open-stage"],
"venue": venue_map["studio-nuovo"],
"starts_at": self._performance_starts_at(today + timedelta(days=14), hour=18, minute=0),
"room_capacity": 60,
"manually_occupied_seats": 2,
"additional_seats": 0,
"is_booking_enabled": True,
},
{
"show": show_map["city-echoes"],
"venue": venue_map["azionelab-theatre"],
"starts_at": self._performance_starts_at(today + timedelta(days=21), hour=20, minute=30),
"room_capacity": 140,
"manually_occupied_seats": 12,
"additional_seats": 6,
"is_booking_enabled": True,
},
]
created_or_updated = 0
for performance_data in performances:
_, _created = Performance.objects.update_or_create(
show=performance_data["show"],
venue=performance_data["venue"],
starts_at=performance_data["starts_at"],
defaults={
"room_capacity": performance_data["room_capacity"],
"manually_occupied_seats": performance_data["manually_occupied_seats"],
"additional_seats": performance_data["additional_seats"],
"is_booking_enabled": performance_data["is_booking_enabled"],
},
)
created_or_updated += 1
self.stdout.write(
self.style.SUCCESS(
f"Demo data ready: {len(show_map)} shows, {len(venue_map)} venues, {created_or_updated} performances."
)
)
def _performance_starts_at(self, day, *, hour, minute):
naive = datetime.combine(day, datetime.min.time()).replace(hour=hour, minute=minute)
return timezone.make_aware(naive, timezone.get_current_timezone())
+98
View File
@@ -0,0 +1,98 @@
# Generated by Django 5.2.3 on 2026-04-28
import django.db.models.deletion
from django.db import migrations, models
from django.db.models import F, Q
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name="Show",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("title", models.CharField(max_length=200)),
("slug", models.SlugField(max_length=220, unique=True)),
("summary", models.TextField(blank=True)),
("description", models.TextField(blank=True)),
("poster_image", models.URLField(blank=True)),
("is_published", models.BooleanField(db_index=True, default=False)),
],
options={
"ordering": ["title"],
"indexes": [
models.Index(fields=["slug"], name="shows_show_slug_83daa9_idx"),
models.Index(fields=["is_published"], name="shows_show_is_publ_63247e_idx"),
],
},
),
migrations.CreateModel(
name="Venue",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("name", models.CharField(max_length=200)),
("slug", models.SlugField(max_length=220, unique=True)),
("address", models.CharField(max_length=255)),
("city", models.CharField(max_length=120)),
("notes", models.TextField(blank=True)),
],
options={
"ordering": ["name"],
"indexes": [
models.Index(fields=["slug"], name="shows_venue_slug_0717a3_idx"),
models.Index(fields=["city"], name="shows_venue_city_acfb26_idx"),
],
},
),
migrations.CreateModel(
name="Performance",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("starts_at", models.DateTimeField(db_index=True)),
("room_capacity", models.PositiveIntegerField()),
("manually_occupied_seats", models.PositiveIntegerField(default=0)),
("additional_seats", models.PositiveIntegerField(default=0)),
("is_booking_enabled", models.BooleanField(db_index=True, default=True)),
(
"show",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="performances",
to="shows.show",
),
),
(
"venue",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="performances",
to="shows.venue",
),
),
],
options={
"ordering": ["starts_at"],
"indexes": [
models.Index(fields=["show", "starts_at"], name="shows_perfo_show_id_bae2ea_idx"),
models.Index(fields=["venue", "starts_at"], name="shows_perfo_venue_i_fcdf27_idx"),
models.Index(fields=["is_booking_enabled", "starts_at"], name="shows_perfo_is_book_9371e4_idx"),
],
"constraints": [
models.CheckConstraint(
condition=Q(("manually_occupied_seats__lte", F("room_capacity") + F("additional_seats"))),
name="performance_manual_seats_within_capacity",
),
],
},
),
]
+1
View File
@@ -0,0 +1 @@
+85 -1
View File
@@ -1 +1,85 @@
# Domain models will be added when show management is implemented. from django.db import models
from django.db.models import F, Q, Sum
class TimeStampedModel(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
abstract = True
class Show(TimeStampedModel):
title = models.CharField(max_length=200)
slug = models.SlugField(max_length=220, unique=True)
summary = models.TextField(blank=True)
description = models.TextField(blank=True)
poster_image = models.URLField(blank=True)
is_published = models.BooleanField(default=False, db_index=True)
class Meta:
ordering = ["title"]
indexes = [
models.Index(fields=["slug"]),
models.Index(fields=["is_published"]),
]
def __str__(self):
return self.title
class Venue(TimeStampedModel):
name = models.CharField(max_length=200)
slug = models.SlugField(max_length=220, unique=True)
address = models.CharField(max_length=255)
city = models.CharField(max_length=120)
notes = models.TextField(blank=True)
class Meta:
ordering = ["name"]
indexes = [
models.Index(fields=["slug"]),
models.Index(fields=["city"]),
]
def __str__(self):
return self.name
class Performance(TimeStampedModel):
show = models.ForeignKey(Show, on_delete=models.PROTECT, related_name="performances")
venue = models.ForeignKey(Venue, on_delete=models.PROTECT, related_name="performances")
starts_at = models.DateTimeField(db_index=True)
room_capacity = models.PositiveIntegerField()
manually_occupied_seats = models.PositiveIntegerField(default=0)
additional_seats = models.PositiveIntegerField(default=0)
is_booking_enabled = models.BooleanField(default=True, db_index=True)
class Meta:
ordering = ["starts_at"]
indexes = [
models.Index(fields=["show", "starts_at"]),
models.Index(fields=["venue", "starts_at"]),
models.Index(fields=["is_booking_enabled", "starts_at"]),
]
constraints = [
models.CheckConstraint(
condition=Q(manually_occupied_seats__lte=F("room_capacity") + F("additional_seats")),
name="performance_manual_seats_within_capacity",
),
]
def __str__(self):
return f"{self.show} at {self.starts_at:%Y-%m-%d %H:%M}"
@property
def configured_capacity(self):
return self.room_capacity + self.additional_seats - self.manually_occupied_seats
def confirmed_seats(self):
result = self.reservations.filter(status="confirmed").aggregate(total=Sum("party_size"))
return result["total"] or 0
def available_seats(self):
return self.configured_capacity - self.confirmed_seats()
+55
View File
@@ -0,0 +1,55 @@
from rest_framework import serializers
class PublicShowListSerializer(serializers.Serializer):
id = serializers.IntegerField()
title = serializers.CharField()
slug = serializers.SlugField()
summary = serializers.CharField()
poster_image = serializers.URLField(allow_blank=True)
class PublicVenueSummarySerializer(serializers.Serializer):
name = serializers.CharField()
city = serializers.CharField()
class PublicVenueDetailSerializer(PublicVenueSummarySerializer):
address = serializers.CharField()
class PublicShowSummarySerializer(serializers.Serializer):
title = serializers.CharField()
slug = serializers.SlugField()
summary = serializers.CharField()
class PublicPerformanceListSerializer(serializers.Serializer):
id = serializers.IntegerField()
show = PublicShowSummarySerializer()
venue = PublicVenueSummarySerializer()
starts_at = serializers.DateTimeField()
booking_enabled = serializers.BooleanField(source="is_booking_enabled")
available_seats = serializers.IntegerField()
class PublicShowPerformanceSerializer(serializers.Serializer):
id = serializers.IntegerField()
starts_at = serializers.DateTimeField()
venue = PublicVenueSummarySerializer()
booking_enabled = serializers.BooleanField(source="is_booking_enabled")
available_seats = serializers.IntegerField()
class PublicShowDetailSerializer(PublicShowListSerializer):
description = serializers.CharField()
performances = PublicShowPerformanceSerializer(many=True, source="public_performances")
class PublicPerformanceDetailSerializer(serializers.Serializer):
id = serializers.IntegerField()
show = PublicShowSummarySerializer()
venue = PublicVenueDetailSerializer()
starts_at = serializers.DateTimeField()
booking_enabled = serializers.BooleanField(source="is_booking_enabled")
available_seats = serializers.IntegerField()
+32
View File
@@ -0,0 +1,32 @@
from django.contrib import admin
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 checkins.models import CheckIn
from shows.models import Performance, Show, Venue
class AdminRegistrationTests(SimpleTestCase):
def test_core_models_are_registered_in_admin(self):
for model in (Show, Venue, Performance, Reservation, ReservationToken, CheckIn):
with self.subTest(model=model.__name__):
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")
+31
View File
@@ -0,0 +1,31 @@
from django.core.management import call_command
from django.test import TestCase
from shows.models import Performance, Show, Venue
class SeedDemoDataCommandTests(TestCase):
def test_seed_demo_data_runs_successfully(self):
call_command("seed_demo_data")
self.assertEqual(Show.objects.count(), 2)
self.assertEqual(Venue.objects.count(), 2)
self.assertEqual(Performance.objects.count(), 3)
self.assertTrue(Show.objects.filter(is_published=True).exists())
def test_seed_demo_data_is_idempotent(self):
call_command("seed_demo_data")
first_counts = (
Show.objects.count(),
Venue.objects.count(),
Performance.objects.count(),
)
call_command("seed_demo_data")
second_counts = (
Show.objects.count(),
Venue.objects.count(),
Performance.objects.count(),
)
self.assertEqual(first_counts, second_counts)
+11
View File
@@ -0,0 +1,11 @@
from django.urls import path
from . import views
urlpatterns = [
path("shows/", views.show_list, name="api-show-list"),
path("shows/<slug:slug>/", views.show_detail, name="api-show-detail"),
path("performances/", views.performance_list, name="api-performance-list"),
path("performances/<int:pk>/", views.performance_detail, name="api-performance-detail"),
]
+65
View File
@@ -0,0 +1,65 @@
from django.shortcuts import get_object_or_404
from django.utils import timezone
from django.utils.dateparse import parse_datetime
from rest_framework import status
from rest_framework.decorators import api_view
from rest_framework.response import Response
from .models import Performance, Show
from .serializers import (
PublicPerformanceDetailSerializer,
PublicPerformanceListSerializer,
PublicShowDetailSerializer,
PublicShowListSerializer,
)
def public_performance_queryset():
return Performance.objects.select_related("show", "venue").filter(
show__is_published=True,
starts_at__gte=timezone.now(),
)
@api_view(["GET"])
def show_list(request):
shows = Show.objects.filter(is_published=True).order_by("title")
serializer = PublicShowListSerializer(shows, many=True)
return Response({"results": serializer.data})
@api_view(["GET"])
def show_detail(request, slug):
show = get_object_or_404(Show, slug=slug, is_published=True)
show.public_performances = public_performance_queryset().filter(show=show)
serializer = PublicShowDetailSerializer(show)
return Response(serializer.data)
@api_view(["GET"])
def performance_list(request):
performances = public_performance_queryset()
show_slug = request.query_params.get("show")
if show_slug:
performances = performances.filter(show__slug=show_slug)
from_value = request.query_params.get("from")
if from_value:
starts_from = parse_datetime(from_value)
if starts_from is None:
return Response(
{"from": ["Enter a valid ISO 8601 date/time."]},
status=status.HTTP_400_BAD_REQUEST,
)
performances = performances.filter(starts_at__gte=starts_from)
serializer = PublicPerformanceListSerializer(performances.order_by("starts_at"), many=True)
return Response({"results": serializer.data})
@api_view(["GET"])
def performance_detail(request, pk):
performance = get_object_or_404(public_performance_queryset(), pk=pk)
serializer = PublicPerformanceDetailSerializer(performance)
return Response(serializer.data)
@@ -0,0 +1,29 @@
# ADR-0005: Prevent Overbooking with Database Transactions
Date: 2026-04-28
## Status
Accepted
## Context
AzioneLab allows multiple visitors to create pending reservations for the same performance. Pending reservations do not guarantee seats, so final capacity must be enforced when a reservation is confirmed.
Concurrent confirmation requests could otherwise read the same availability and confirm more seats than the performance allows.
## Decision
Use PostgreSQL transactions and Django ORM row-level locking to serialize capacity checks for a performance.
When creating or confirming a reservation, the backend locks the related `Performance` row with `select_for_update()`, recalculates confirmed seats server-side, and proceeds only if enough seats remain.
Confirmation performs the final capacity check inside the transaction before changing the reservation to `confirmed` and consuming the confirmation token.
## Consequences
- Capacity is enforced by the backend and database, not by frontend availability values.
- Concurrent confirmations for the same performance are serialized by the locked `Performance` row.
- Pending reservations can exceed current availability, but only confirmed reservations consume seats.
- The design stays simple and works with Django ORM and PostgreSQL.
- Very busy booking moments may queue briefly on the performance row lock.
+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.
+2
View File
@@ -0,0 +1,2 @@
/dist
/node_modules
+65
View File
@@ -0,0 +1,65 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"azionelab": {
"projectType": "application",
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"options": {
"outputPath": "dist/azionelab",
"browser": "src/main.ts",
"index": "src/index.html",
"polyfills": ["zone.js"],
"tsConfig": "tsconfig.app.json",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"@angular/material/prebuilt-themes/azure-blue.css",
"src/styles.css"
]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "2mb",
"maximumError": "5mb"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"configurations": {
"production": {
"buildTarget": "azionelab:build:production"
},
"development": {
"buildTarget": "azionelab:build:development"
}
},
"defaultConfiguration": "development"
}
}
}
}
}
+32
View File
@@ -0,0 +1,32 @@
{
"name": "azionelab-frontend",
"version": "0.0.1",
"private": true,
"scripts": {
"start": "ng serve",
"build": "ng build",
"test": "ng test"
},
"dependencies": {
"@angular/animations": "^19.2.0",
"@angular/cdk": "^19.2.0",
"@angular/common": "^19.2.0",
"@angular/compiler": "^19.2.0",
"@angular/core": "^19.2.0",
"@angular/forms": "^19.2.0",
"@angular/material": "^19.2.0",
"@angular/platform-browser": "^19.2.0",
"@angular/platform-browser-dynamic": "^19.2.0",
"@angular/router": "^19.2.0",
"rxjs": "^7.8.1",
"tslib": "^2.8.1",
"zone.js": "^0.15.0"
},
"devDependencies": {
"@angular-devkit/build-angular": "^19.2.0",
"@angular/cli": "^19.2.0",
"@angular/compiler-cli": "^19.2.0",
"@types/node": "^22.10.2",
"typescript": "^5.7.2"
}
}
+1
View File
@@ -0,0 +1 @@
+119
View File
@@ -0,0 +1,119 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatToolbarModule } from '@angular/material/toolbar';
@Component({
selector: 'app-root',
standalone: true,
imports: [
RouterOutlet,
RouterLink,
RouterLinkActive,
MatToolbarModule,
MatButtonModule,
MatIconModule,
],
template: `
<div class="app-shell">
<mat-toolbar class="app-toolbar">
<a class="brand" routerLink="/">
<span class="brand-mark">A</span>
<span class="brand-text">
<strong>AzioneLab</strong>
<small>Theatre and reservations</small>
</span>
</a>
<nav class="main-nav">
<a mat-button routerLink="/" routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }">Home</a>
<a mat-button routerLink="/shows" routerLinkActive="active">Shows</a>
<a mat-button routerLink="/check-in" routerLinkActive="active">Check-in</a>
</nav>
</mat-toolbar>
<main class="page-shell">
<router-outlet></router-outlet>
</main>
</div>
`,
styles: [`
.app-shell {
min-height: 100vh;
}
.app-toolbar {
position: sticky;
top: 0;
z-index: 10;
display: flex;
justify-content: space-between;
gap: 16px;
min-height: 72px;
padding: 0 24px;
background: rgba(251, 247, 242, 0.88);
backdrop-filter: blur(18px);
border-bottom: 1px solid var(--azionelab-border);
}
.brand {
display: inline-flex;
align-items: center;
gap: 12px;
color: inherit;
text-decoration: none;
}
.brand-mark {
display: inline-grid;
place-items: center;
width: 42px;
height: 42px;
border-radius: 10px;
background: linear-gradient(135deg, var(--azionelab-accent), #ca6d3b);
color: white;
font-weight: 700;
}
.brand-text {
display: flex;
flex-direction: column;
line-height: 1.1;
}
.brand-text small {
color: var(--azionelab-muted);
font-size: 0.74rem;
}
.main-nav {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
}
.main-nav .active {
background: rgba(159, 47, 40, 0.08);
}
.page-shell {
padding: 32px 20px 56px;
}
@media (max-width: 800px) {
.app-toolbar {
align-items: flex-start;
flex-direction: column;
padding: 16px 16px 14px;
}
.main-nav {
width: 100%;
}
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppComponent {}
+18
View File
@@ -0,0 +1,18 @@
import { Routes } from '@angular/router';
import { BookingPlaceholderPageComponent } from './pages/booking-placeholder-page.component';
import { CheckInPlaceholderPageComponent } from './pages/check-in-placeholder-page.component';
import { HomePageComponent } from './pages/home-page.component';
import { ReservationConfirmPageComponent } from './pages/reservation-confirm-page.component';
import { ShowDetailPlaceholderPageComponent } from './pages/show-detail-placeholder-page.component';
import { ShowListPageComponent } from './pages/show-list-page.component';
export const appRoutes: Routes = [
{ path: '', component: HomePageComponent, title: 'AzioneLab' },
{ path: 'shows', component: ShowListPageComponent, title: 'Shows | AzioneLab' },
{ path: 'shows/:slug', component: ShowDetailPlaceholderPageComponent, title: 'Show detail | AzioneLab' },
{ path: 'performances/:id/book', component: BookingPlaceholderPageComponent, title: 'Book | AzioneLab' },
{ path: 'reservations/confirm', component: ReservationConfirmPageComponent, title: 'Confirm reservation | AzioneLab' },
{ path: 'check-in', component: CheckInPlaceholderPageComponent, title: 'Check-in | AzioneLab' },
{ path: '**', redirectTo: '' },
];
@@ -0,0 +1,4 @@
export const environment = {
production: true,
apiBaseUrl: '/api',
};
@@ -0,0 +1,267 @@
import { ChangeDetectionStrategy, Component, DestroyRef, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { HttpErrorResponse } from '@angular/common/http';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { ReservationCreatePayload, ShowsApiService } from '../services/shows-api.service';
type ApiValidationErrors = Record<string, string[]>;
@Component({
standalone: true,
imports: [
ReactiveFormsModule,
RouterLink,
MatButtonModule,
MatCardModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatProgressSpinnerModule,
],
template: `
<section class="page">
<header class="page-header">
<p class="eyebrow">Booking</p>
<h1>Reserve seats</h1>
<p class="supporting">Performance {{ performanceId }}. Complete the form and we will send a confirmation email.</p>
</header>
<mat-card class="content-card">
<mat-card-content>
@if (isSuccess()) {
<div class="status-copy success" aria-live="polite">
<mat-icon>check_circle</mat-icon>
<div>
<h2>Reservation created</h2>
<p>check your email</p>
</div>
</div>
} @else {
<form [formGroup]="bookingForm" (ngSubmit)="submit()" novalidate>
<div class="form-grid">
<mat-form-field appearance="outline">
<mat-label>Name</mat-label>
<input matInput type="text" formControlName="name" autocomplete="name" />
@if (bookingForm.controls.name.touched && bookingForm.controls.name.hasError('required')) {
<mat-error>Name is required.</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Email</mat-label>
<input matInput type="email" formControlName="email" autocomplete="email" />
@if (bookingForm.controls.email.touched && bookingForm.controls.email.hasError('required')) {
<mat-error>Email is required.</mat-error>
}
@if (bookingForm.controls.email.touched && bookingForm.controls.email.hasError('email')) {
<mat-error>Enter a valid email address.</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Number of seats</mat-label>
<input matInput type="number" min="1" step="1" formControlName="partySize" />
@if (bookingForm.controls.partySize.touched && bookingForm.controls.partySize.hasError('required')) {
<mat-error>Number of seats is required.</mat-error>
}
@if (bookingForm.controls.partySize.touched && bookingForm.controls.partySize.hasError('min')) {
<mat-error>At least 1 seat is required.</mat-error>
}
</mat-form-field>
</div>
@if (submitError()) {
<p class="error-message" aria-live="assertive">{{ submitError() }}</p>
}
@if (fieldErrors().length > 0) {
<div class="field-errors" aria-live="assertive">
@for (message of fieldErrors(); track message) {
<p>{{ message }}</p>
}
</div>
}
<div class="actions">
<button mat-flat-button type="submit" [disabled]="isSubmitting()">
@if (isSubmitting()) {
<mat-progress-spinner mode="indeterminate" diameter="18"></mat-progress-spinner>
<span>Submitting...</span>
} @else {
<span>Reserve</span>
}
</button>
<a mat-button routerLink="/shows">Back to shows</a>
</div>
</form>
}
</mat-card-content>
</mat-card>
</section>
`,
styles: [`
.page {
max-width: 760px;
margin: 0 auto;
}
.page-header {
margin-bottom: 24px;
}
.eyebrow {
margin: 0 0 10px;
color: var(--azionelab-accent);
text-transform: uppercase;
font-size: 0.78rem;
font-weight: 700;
}
h1 {
margin: 0;
font-size: clamp(2rem, 4vw, 3rem);
}
.supporting {
color: var(--azionelab-muted);
line-height: 1.6;
max-width: 56ch;
margin: 14px 0 0;
}
.content-card {
border-radius: 8px;
border: 1px solid var(--azionelab-border);
background: var(--azionelab-surface);
box-shadow: var(--azionelab-shadow);
}
.form-grid {
display: grid;
gap: 14px;
}
mat-form-field {
width: 100%;
}
.error-message,
.field-errors p {
margin: 0;
color: #b3261e;
line-height: 1.4;
font-size: 0.92rem;
}
.field-errors {
display: grid;
gap: 6px;
margin-top: 10px;
}
.actions {
display: flex;
align-items: center;
gap: 12px;
margin-top: 18px;
flex-wrap: wrap;
}
.actions button[mat-flat-button] {
min-width: 130px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.status-copy {
display: flex;
align-items: flex-start;
gap: 14px;
}
.status-copy h2 {
margin: 0 0 6px;
}
.status-copy p {
margin: 0;
color: var(--azionelab-muted);
}
.status-copy.success mat-icon {
color: #2e7d32;
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BookingPlaceholderPageComponent {
private readonly destroyRef = inject(DestroyRef);
private readonly formBuilder = inject(FormBuilder);
private readonly route = inject(ActivatedRoute);
private readonly showsApi = inject(ShowsApiService);
protected readonly performanceId = this.route.snapshot.paramMap.get('id') ?? '';
protected readonly isSubmitting = signal(false);
protected readonly isSuccess = signal(false);
protected readonly submitError = signal('');
protected readonly fieldErrors = signal<string[]>([]);
protected readonly bookingForm = this.formBuilder.nonNullable.group({
name: ['', [Validators.required, Validators.maxLength(200)]],
email: ['', [Validators.required, Validators.email]],
partySize: [1, [Validators.required, Validators.min(1)]],
});
protected submit(): void {
this.submitError.set('');
this.fieldErrors.set([]);
if (this.bookingForm.invalid) {
this.bookingForm.markAllAsTouched();
return;
}
const payload: ReservationCreatePayload = {
name: this.bookingForm.controls.name.value.trim(),
email: this.bookingForm.controls.email.value.trim(),
party_size: this.bookingForm.controls.partySize.value,
};
this.isSubmitting.set(true);
this.showsApi.createReservation(this.performanceId, payload)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: () => {
this.isSubmitting.set(false);
this.isSuccess.set(true);
this.bookingForm.disable();
},
error: (error: HttpErrorResponse) => {
this.isSubmitting.set(false);
if (error.status === 400 && error.error && typeof error.error === 'object') {
this.fieldErrors.set(this.flattenValidationErrors(error.error as ApiValidationErrors));
return;
}
this.submitError.set('Could not create reservation. Please try again.');
},
});
}
private flattenValidationErrors(errors: ApiValidationErrors): string[] {
return Object.entries(errors).flatMap(([field, messages]) => {
const label = field === 'party_size' ? 'number of seats' : field;
return messages.map((message) => `${label}: ${message}`);
});
}
}
@@ -0,0 +1,593 @@
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 { MatFormFieldModule } from '@angular/material/form-field';
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({
standalone: true,
imports: [
DatePipe,
ReactiveFormsModule,
RouterLink,
MatButtonModule,
MatCardModule,
MatFormFieldModule,
MatInputModule,
MatProgressSpinnerModule,
],
template: `
<section class="page">
<header class="page-header">
<p class="eyebrow">Staff check-in</p>
<h1>Token validation</h1>
<p class="supporting">Enter a token manually or scan a QR code to preview admission data and confirm entrance.</p>
</header>
<mat-card class="content-card">
<mat-card-content>
<section class="scanner-panel">
<div class="scanner-copy">
<h2>Camera scan</h2>
<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>
</section>
`,
styles: [`
.page {
max-width: 760px;
margin: 0 auto;
}
.page-header {
margin-bottom: 22px;
}
.eyebrow {
margin: 0 0 10px;
color: var(--azionelab-accent);
text-transform: uppercase;
font-size: 0.78rem;
font-weight: 700;
}
h1 {
margin: 0;
font-size: clamp(2rem, 4vw, 3rem);
}
.supporting {
color: var(--azionelab-muted);
line-height: 1.6;
max-width: 50ch;
}
.content-card {
border-radius: 8px;
border: 1px solid var(--azionelab-border);
background: var(--azionelab-surface);
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%;
}
.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,
})
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');
}
}
@@ -0,0 +1,113 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { RouterLink } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { API_BASE_URL } from '../services/api-config.token';
@Component({
standalone: true,
imports: [RouterLink, MatButtonModule, MatCardModule],
template: `
<section class="hero">
<div class="hero-copy">
<p class="eyebrow">AzioneLab Theatre Company</p>
<h1>Public website and booking UI foundations.</h1>
<p class="supporting">
This Angular shell is wired for the existing Django APIs and ready for the next booking-focused iterations.
</p>
<div class="hero-actions">
<a mat-flat-button color="primary" routerLink="/shows">Browse shows</a>
<a mat-stroked-button routerLink="/check-in">Check-in area</a>
</div>
</div>
<div class="hero-panel">
<mat-card>
<mat-card-title>Frontend wiring</mat-card-title>
<mat-card-content>
<p><strong>API base URL</strong></p>
<code>{{ apiBaseUrl }}</code>
<p class="panel-note">Placeholders are in place for public content, booking, and staff check-in flows.</p>
</mat-card-content>
</mat-card>
</div>
</section>
`,
styles: [`
.hero {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(280px, 0.9fr);
gap: 28px;
align-items: stretch;
max-width: 1180px;
margin: 0 auto;
}
.hero-copy {
padding: 36px 0;
}
.eyebrow {
margin: 0 0 12px;
color: var(--azionelab-accent);
text-transform: uppercase;
font-size: 0.78rem;
font-weight: 700;
}
h1 {
margin: 0;
max-width: 10ch;
font-size: clamp(2.5rem, 5vw, 4.75rem);
line-height: 0.95;
}
.supporting {
max-width: 52ch;
color: var(--azionelab-muted);
font-size: 1.08rem;
line-height: 1.65;
margin: 20px 0 0;
}
.hero-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
margin-top: 28px;
}
.hero-panel mat-card {
height: 100%;
border-radius: 8px;
border: 1px solid var(--azionelab-border);
background: var(--azionelab-surface);
box-shadow: var(--azionelab-shadow);
}
code {
display: inline-block;
margin-top: 8px;
padding: 10px 12px;
border-radius: 8px;
background: rgba(30, 27, 24, 0.06);
}
.panel-note {
margin-top: 20px;
color: var(--azionelab-muted);
line-height: 1.5;
}
@media (max-width: 900px) {
.hero {
grid-template-columns: 1fr;
}
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class HomePageComponent {
protected readonly apiBaseUrl = inject(API_BASE_URL);
}
@@ -0,0 +1,210 @@
import { ChangeDetectionStrategy, Component, DestroyRef, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { HttpErrorResponse } from '@angular/common/http';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { ReservationConfirmResponse, ShowsApiService } from '../services/shows-api.service';
type ConfirmationState = 'loading' | 'success' | 'invalid' | 'expired' | 'error';
@Component({
standalone: true,
imports: [
RouterLink,
MatButtonModule,
MatCardModule,
MatIconModule,
MatProgressSpinnerModule,
],
template: `
<section class="page">
<header class="page-header">
<p class="eyebrow">Reservation confirmation</p>
<h1>Email confirmation</h1>
</header>
<mat-card class="status-card">
<mat-card-content>
@if (state() === 'loading') {
<div class="status-copy" aria-live="polite">
<mat-progress-spinner mode="indeterminate" diameter="36"></mat-progress-spinner>
<div>
<h2>Confirming reservation...</h2>
<p>Please wait while we validate your link.</p>
</div>
</div>
}
@if (state() === 'success' && confirmation()) {
<div class="status-copy success" aria-live="polite">
<mat-icon>check_circle</mat-icon>
<div>
<h2>Reservation confirmed</h2>
<p>Your seats are confirmed. Present this QR code at check-in.</p>
</div>
</div>
@if (confirmation()!.qr_code_image) {
<div class="qr-panel">
<img [src]="confirmation()!.qr_code_image" alt="Reservation QR code" />
</div>
}
@if (confirmation()!.qr_code_url) {
<p class="meta">Check-in URL: <a [href]="confirmation()!.qr_code_url">{{ confirmation()!.qr_code_url }}</a></p>
}
}
@if (state() === 'invalid') {
<div class="status-copy" aria-live="assertive">
<mat-icon>error</mat-icon>
<div>
<h2>Invalid confirmation link</h2>
<p>This token is not valid. Please use the latest email confirmation link.</p>
</div>
</div>
}
@if (state() === 'expired') {
<div class="status-copy" aria-live="assertive">
<mat-icon>schedule</mat-icon>
<div>
<h2>Confirmation link expired</h2>
<p>This link has expired. Please create a new reservation.</p>
</div>
</div>
}
@if (state() === 'error') {
<div class="status-copy" aria-live="assertive">
<mat-icon>warning</mat-icon>
<div>
<h2>Could not confirm reservation</h2>
<p>Please try again in a moment.</p>
</div>
</div>
}
</mat-card-content>
<mat-card-actions>
<a mat-button routerLink="/">Home</a>
<a mat-button routerLink="/shows">Shows</a>
</mat-card-actions>
</mat-card>
</section>
`,
styles: [`
.page {
max-width: 760px;
margin: 0 auto;
}
.page-header {
margin-bottom: 24px;
}
.eyebrow {
margin: 0 0 10px;
color: var(--azionelab-accent);
text-transform: uppercase;
font-size: 0.78rem;
font-weight: 700;
}
h1 {
margin: 0;
font-size: clamp(2rem, 4vw, 3rem);
}
.status-card {
border-radius: 8px;
border: 1px solid var(--azionelab-border);
background: var(--azionelab-surface);
box-shadow: var(--azionelab-shadow);
}
.status-copy {
display: flex;
align-items: flex-start;
gap: 14px;
}
.status-copy h2 {
margin: 0 0 6px;
font-size: 1.2rem;
}
.status-copy p {
margin: 0;
color: var(--azionelab-muted);
line-height: 1.5;
}
.status-copy.success mat-icon {
color: #2e7d32;
}
.qr-panel {
margin-top: 18px;
padding: 14px;
border-radius: 8px;
border: 1px solid var(--azionelab-border);
display: inline-block;
background: white;
}
.qr-panel img {
width: min(280px, 100%);
height: auto;
display: block;
}
.meta {
margin: 14px 0 0;
word-break: break-word;
color: var(--azionelab-muted);
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ReservationConfirmPageComponent {
private readonly destroyRef = inject(DestroyRef);
private readonly route = inject(ActivatedRoute);
private readonly showsApi = inject(ShowsApiService);
protected readonly state = signal<ConfirmationState>('loading');
protected readonly confirmation = signal<ReservationConfirmResponse | null>(null);
constructor() {
const token = this.route.snapshot.queryParamMap.get('token')?.trim() ?? '';
if (!token) {
this.state.set('invalid');
return;
}
this.showsApi.confirmReservation(token)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (response) => {
this.confirmation.set(response);
this.state.set('success');
},
error: (error: HttpErrorResponse) => {
if (error.status === 404 || error.status === 400) {
this.state.set('invalid');
return;
}
if (error.status === 410) {
this.state.set('expired');
return;
}
this.state.set('error');
},
});
}
}
@@ -0,0 +1,301 @@
import { DatePipe } from '@angular/common';
import { ChangeDetectionStrategy, Component, DestroyRef, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { switchMap, map, of } from 'rxjs';
import { ShowDetail, ShowPerformance, ShowsApiService } from '../services/shows-api.service';
@Component({
standalone: true,
imports: [
DatePipe,
RouterLink,
MatButtonModule,
MatCardModule,
MatIconModule,
MatProgressSpinnerModule,
],
template: `
<section class="page">
@if (isLoading()) {
<div class="status-panel" aria-live="polite">
<mat-progress-spinner mode="indeterminate" diameter="40"></mat-progress-spinner>
<p>Loading show details...</p>
</div>
} @else if (errorMessage()) {
<mat-card class="status-card" aria-live="assertive">
<mat-card-content>
<div class="status-copy">
<mat-icon>error</mat-icon>
<div>
<h1>Could not load this show</h1>
<p>{{ errorMessage() }}</p>
</div>
</div>
</mat-card-content>
<mat-card-actions>
<button mat-flat-button type="button" (click)="reload()">Try again</button>
<a mat-button routerLink="/shows">Back to shows</a>
</mat-card-actions>
</mat-card>
} @else if (show()) {
<header class="page-header">
<p class="eyebrow">Show detail</p>
<h1>{{ show()!.title }}</h1>
<p class="supporting">{{ show()!.description || show()!.summary }}</p>
</header>
<section class="section">
<div class="section-heading">
<div>
<h2>Upcoming performances</h2>
<p>Choose a performance to continue to the booking placeholder.</p>
</div>
<a mat-button routerLink="/shows">Back to show list</a>
</div>
@if (performances().length === 0) {
<mat-card class="status-card" aria-live="polite">
<mat-card-content>
<div class="status-copy">
<mat-icon>theaters</mat-icon>
<div>
<h2>No performances published yet</h2>
<p>This show is online, but there are no upcoming performances available right now.</p>
</div>
</div>
</mat-card-content>
</mat-card>
} @else {
<div class="performance-grid">
@for (performance of performances(); track performance.id) {
<mat-card class="performance-card">
<mat-card-title>{{ performance.starts_at | date: 'EEEE d MMMM, HH:mm' }}</mat-card-title>
<mat-card-subtitle>{{ performance.venue.name }}, {{ performance.venue.city }}</mat-card-subtitle>
<mat-card-content>
<dl class="performance-meta">
<div>
<dt>Venue</dt>
<dd>{{ performance.venue.name }}</dd>
</div>
<div>
<dt>City</dt>
<dd>{{ performance.venue.city }}</dd>
</div>
<div>
<dt>Available seats</dt>
<dd>{{ performance.available_seats }}</dd>
</div>
</dl>
</mat-card-content>
<mat-card-actions>
@if (performance.booking_enabled) {
<a mat-flat-button [routerLink]="['/performances', performance.id, 'book']">Book this performance</a>
} @else {
<button mat-stroked-button type="button" disabled>Booking unavailable</button>
}
</mat-card-actions>
</mat-card>
}
</div>
}
</section>
}
</section>
`,
styles: [`
.page {
max-width: 1080px;
margin: 0 auto;
}
.page-header {
margin-bottom: 28px;
}
.eyebrow {
margin: 0 0 10px;
color: var(--azionelab-accent);
text-transform: uppercase;
font-size: 0.78rem;
font-weight: 700;
}
h1 {
margin: 0;
font-size: clamp(2rem, 4vw, 3.2rem);
}
.supporting {
margin: 14px 0 0;
color: var(--azionelab-muted);
line-height: 1.6;
max-width: 64ch;
}
.section {
display: grid;
gap: 20px;
}
.section-heading {
display: flex;
justify-content: space-between;
gap: 20px;
align-items: end;
}
.section-heading h2 {
margin: 0 0 6px;
font-size: 1.4rem;
}
.section-heading p {
margin: 0;
color: var(--azionelab-muted);
}
.performance-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px;
}
.performance-card,
.status-card {
border-radius: 8px;
border: 1px solid var(--azionelab-border);
background: var(--azionelab-surface);
box-shadow: var(--azionelab-shadow);
}
.performance-card {
min-height: 260px;
}
.performance-meta {
display: grid;
gap: 14px;
margin: 0;
}
.performance-meta div {
display: grid;
gap: 2px;
}
.performance-meta dt {
font-size: 0.78rem;
font-weight: 700;
text-transform: uppercase;
color: var(--azionelab-muted);
}
.performance-meta dd {
margin: 0;
font-size: 0.98rem;
}
.status-panel,
.status-copy {
display: flex;
align-items: center;
gap: 16px;
}
.status-panel {
min-height: 240px;
justify-content: center;
color: var(--azionelab-muted);
}
.status-copy {
align-items: flex-start;
}
.status-copy h1,
.status-copy h2 {
margin: 0 0 8px;
font-size: 1.2rem;
}
.status-copy p {
margin: 0;
color: var(--azionelab-muted);
line-height: 1.6;
}
@media (max-width: 860px) {
.section-heading {
align-items: flex-start;
flex-direction: column;
}
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ShowDetailPlaceholderPageComponent {
private readonly destroyRef = inject(DestroyRef);
private readonly route = inject(ActivatedRoute);
private readonly showsApi = inject(ShowsApiService);
protected readonly show = signal<ShowDetail | null>(null);
protected readonly performances = signal<ShowPerformance[]>([]);
protected readonly isLoading = signal(true);
protected readonly errorMessage = signal('');
constructor() {
this.loadShow();
}
protected reload(): void {
this.loadShow();
}
private loadShow(): void {
const slug = this.route.snapshot.paramMap.get('slug');
if (!slug) {
this.errorMessage.set('The requested show is missing a valid identifier.');
this.show.set(null);
this.performances.set([]);
this.isLoading.set(false);
return;
}
this.isLoading.set(true);
this.errorMessage.set('');
this.showsApi.getShow(slug)
.pipe(
switchMap((show) => {
if (show.performances) {
return of({ show, performances: show.performances });
}
return this.showsApi.listPerformancesForShow(slug).pipe(
map(({ results }) => ({ show, performances: results })),
);
}),
takeUntilDestroyed(this.destroyRef),
)
.subscribe({
next: ({ show, performances }) => {
this.show.set(show);
this.performances.set(performances);
this.isLoading.set(false);
},
error: () => {
this.show.set(null);
this.performances.set([]);
this.errorMessage.set('Please try again in a moment.');
this.isLoading.set(false);
},
});
}
}
@@ -0,0 +1,211 @@
import { ChangeDetectionStrategy, Component, DestroyRef, inject, signal } from '@angular/core';
import { RouterLink } from '@angular/router';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { ShowListItem, ShowsApiService } from '../services/shows-api.service';
@Component({
standalone: true,
imports: [RouterLink, MatButtonModule, MatCardModule, MatIconModule, MatProgressSpinnerModule],
template: `
<section class="page">
<header class="page-header">
<div>
<p class="eyebrow">Public shows</p>
<h1>Shows</h1>
</div>
<p class="supporting">
Browse current productions published from the AzioneLab backend.
</p>
</header>
@if (isLoading()) {
<div class="status-panel" aria-live="polite">
<mat-progress-spinner mode="indeterminate" diameter="40"></mat-progress-spinner>
<p>Loading shows...</p>
</div>
} @else if (errorMessage()) {
<mat-card class="status-card" aria-live="assertive">
<mat-card-content>
<div class="status-copy">
<mat-icon>error</mat-icon>
<div>
<h2>Could not load shows</h2>
<p>{{ errorMessage() }}</p>
</div>
</div>
</mat-card-content>
<mat-card-actions>
<button mat-flat-button type="button" (click)="reload()">Try again</button>
</mat-card-actions>
</mat-card>
} @else if (shows().length === 0) {
<mat-card class="status-card" aria-live="polite">
<mat-card-content>
<div class="status-copy">
<mat-icon>theaters</mat-icon>
<div>
<h2>No shows published yet</h2>
<p>Published productions will appear here as soon as they are available.</p>
</div>
</div>
</mat-card-content>
</mat-card>
} @else {
<div class="show-grid">
@for (show of shows(); track show.slug) {
<mat-card class="show-card">
<mat-card-title>{{ show.title }}</mat-card-title>
<mat-card-content>
<p>{{ show.summary }}</p>
</mat-card-content>
<mat-card-actions>
<a mat-button [routerLink]="['/shows', show.slug]">Open detail</a>
</mat-card-actions>
</mat-card>
}
</div>
}
</section>
`,
styles: [`
.page {
max-width: 1180px;
margin: 0 auto;
}
.page-header {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(260px, 380px);
gap: 24px;
align-items: end;
margin-bottom: 24px;
}
.eyebrow {
margin: 0 0 10px;
color: var(--azionelab-accent);
text-transform: uppercase;
font-size: 0.78rem;
font-weight: 700;
}
h1 {
margin: 0;
font-size: clamp(2rem, 4vw, 3rem);
}
.supporting {
margin: 0;
color: var(--azionelab-muted);
line-height: 1.6;
}
.show-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 20px;
}
.status-panel,
.status-copy {
display: flex;
align-items: center;
gap: 16px;
}
.status-panel {
min-height: 220px;
justify-content: center;
color: var(--azionelab-muted);
}
.status-card {
max-width: 680px;
border-radius: 8px;
border: 1px solid var(--azionelab-border);
background: var(--azionelab-surface);
box-shadow: var(--azionelab-shadow);
}
.status-copy {
align-items: flex-start;
}
.status-copy h2 {
margin: 0 0 8px;
font-size: 1.2rem;
}
.status-copy p {
margin: 0;
color: var(--azionelab-muted);
line-height: 1.6;
}
.show-card {
display: flex;
flex-direction: column;
min-height: 220px;
border-radius: 8px;
border: 1px solid var(--azionelab-border);
background: var(--azionelab-surface);
box-shadow: var(--azionelab-shadow);
}
.show-card mat-card-content {
flex: 1;
}
.show-card p {
color: var(--azionelab-muted);
line-height: 1.6;
}
@media (max-width: 860px) {
.page-header {
grid-template-columns: 1fr;
}
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ShowListPageComponent {
private readonly destroyRef = inject(DestroyRef);
private readonly showsApi = inject(ShowsApiService);
protected readonly shows = signal<ShowListItem[]>([]);
protected readonly isLoading = signal(true);
protected readonly errorMessage = signal('');
constructor() {
this.loadShows();
}
protected reload(): void {
this.loadShows();
}
private loadShows(): void {
this.isLoading.set(true);
this.errorMessage.set('');
this.showsApi.listShows()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: ({ results }) => {
this.shows.set(results);
this.isLoading.set(false);
},
error: () => {
this.shows.set([]);
this.errorMessage.set('Please try again in a moment.');
this.isLoading.set(false);
},
});
}
}
@@ -0,0 +1,7 @@
import { InjectionToken } from '@angular/core';
import { environment } from '../environments/environment';
export const API_BASE_URL = new InjectionToken<string>('API_BASE_URL', {
factory: () => environment.apiBaseUrl,
});
@@ -0,0 +1,123 @@
import { inject, Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { API_BASE_URL } from './api-config.token';
export type ShowListItem = {
id: number;
title: string;
slug: string;
summary: string;
poster_image: string;
};
export type VenueSummary = {
name: string;
city: string;
};
export type ShowPerformance = {
id: number;
starts_at: string;
venue: VenueSummary;
booking_enabled: boolean;
available_seats: number;
};
export type ShowDetail = ShowListItem & {
description: string;
performances?: ShowPerformance[];
};
export type ReservationCreatePayload = {
name: string;
email: string;
party_size: number;
};
export type ReservationCreateResponse = {
id: number;
status: string;
performance: number;
party_size: number;
message: string;
};
export type ReservationConfirmResponse = {
reservation_id: number;
status: string;
party_size: number;
qr_code_url?: 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 = {
results: ShowListItem[];
};
type PerformanceListResponse = {
results: ShowPerformance[];
};
@Injectable({
providedIn: 'root',
})
export class ShowsApiService {
private readonly http = inject(HttpClient);
private readonly apiBaseUrl = inject(API_BASE_URL);
listShows(): Observable<ShowListResponse> {
return this.http.get<ShowListResponse>(`${this.apiBaseUrl}/shows/`);
}
getShow(slug: string): Observable<ShowDetail> {
return this.http.get<ShowDetail>(`${this.apiBaseUrl}/shows/${slug}/`);
}
listPerformancesForShow(slug: string): Observable<PerformanceListResponse> {
return this.http.get<PerformanceListResponse>(`${this.apiBaseUrl}/performances/`, {
params: { show: slug },
});
}
createReservation(performanceId: string, payload: ReservationCreatePayload): Observable<ReservationCreateResponse> {
return this.http.post<ReservationCreateResponse>(
`${this.apiBaseUrl}/performances/${performanceId}/reservations/`,
payload,
);
}
confirmReservation(token: string): Observable<ReservationConfirmResponse> {
return this.http.get<ReservationConfirmResponse>(`${this.apiBaseUrl}/reservations/confirm/`, {
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 });
}
}
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>AzioneLab</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<app-root></app-root>
</body>
</html>
+15
View File
@@ -0,0 +1,15 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { provideAnimations } from '@angular/platform-browser/animations';
import { provideHttpClient } from '@angular/common/http';
import { provideRouter } from '@angular/router';
import { AppComponent } from './app/app.component';
import { appRoutes } from './app/app.routes';
bootstrapApplication(AppComponent, {
providers: [
provideAnimations(),
provideHttpClient(),
provideRouter(appRoutes),
],
}).catch((err) => console.error(err));
+33
View File
@@ -0,0 +1,33 @@
:root {
--azionelab-bg: #f3eee6;
--azionelab-surface: rgba(255, 255, 255, 0.78);
--azionelab-ink: #1e1b18;
--azionelab-muted: #645b53;
--azionelab-accent: #9f2f28;
--azionelab-accent-strong: #7f211c;
--azionelab-border: rgba(30, 27, 24, 0.12);
--azionelab-shadow: 0 18px 48px rgba(46, 28, 18, 0.12);
}
* {
box-sizing: border-box;
}
html, body {
margin: 0;
min-height: 100%;
font-family: "Segoe UI", "Helvetica Neue", sans-serif;
color: var(--azionelab-ink);
background:
radial-gradient(circle at top right, rgba(159, 47, 40, 0.12), transparent 28%),
radial-gradient(circle at left center, rgba(140, 116, 86, 0.14), transparent 35%),
linear-gradient(180deg, #fbf7f2 0%, var(--azionelab-bg) 100%);
}
body {
min-height: 100vh;
}
button, input, textarea {
font: inherit;
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
},
"files": ["src/main.ts"],
"include": ["src/**/*.d.ts"]
}
+30
View File
@@ -0,0 +1,30 @@
{
"compileOnSave": false,
"compilerOptions": {
"baseUrl": "./",
"outDir": "./dist/out-tsc",
"forceConsistentCasingInFileNames": true,
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"esModuleInterop": true,
"sourceMap": true,
"declaration": false,
"experimentalDecorators": true,
"moduleResolution": "bundler",
"importHelpers": true,
"target": "ES2022",
"module": "ES2022",
"useDefineForClassFields": false,
"lib": ["ES2022", "dom"]
},
"angularCompilerOptions": {
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
}
}
+7 -1
View File
@@ -19,16 +19,20 @@ 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:
build: build:
context: ./frontend context: ../..
dockerfile: infra/docker/frontend/Dockerfile
image: azionelab-frontend:local image: azionelab-frontend:local
expose: expose:
- "${FRONTEND_PORT:-8080}" - "${FRONTEND_PORT:-8080}"
@@ -65,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
@@ -74,6 +79,7 @@ services:
volumes: volumes:
postgres_data: postgres_data:
django_static:
networks: networks:
internal: internal:
+14 -2
View File
@@ -1,6 +1,18 @@
FROM node:22.12.0-alpine AS build
WORKDIR /app
COPY frontend/package.json /app/package.json
RUN npm install
COPY frontend/ /app/
RUN npm run build
FROM nginx:1.27.0-alpine FROM nginx:1.27.0-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf COPY infra/docker/frontend/nginx.conf /etc/nginx/conf.d/default.conf
COPY html/ /usr/share/nginx/html/ COPY --from=build /app/dist/azionelab/browser/ /usr/share/nginx/html/
EXPOSE 8080 EXPOSE 8080
@@ -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/ {
+1
View File
@@ -4,3 +4,4 @@ django-cors-headers==4.7.0
dj-database-url==2.3.0 dj-database-url==2.3.0
gunicorn==23.0.0 gunicorn==23.0.0
psycopg[binary]==3.2.9 psycopg[binary]==3.2.9
qrcode[pil]==8.2