generated from bisco/codex-bootstrap
Compare commits
103 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e843d0810 | |||
| 0c465b53cd | |||
| 329935dcdd | |||
| bb1f59c0b3 | |||
| fd1ce63f7a | |||
| 3f008b7096 | |||
| fb7ced584b | |||
| 621d3af1f1 | |||
| b68e0a7c5d | |||
| 00d09ceb8b | |||
| d55d2b14ba | |||
| 92e1bc1b7c | |||
| 185003e001 | |||
| 6c163fd800 | |||
| 5db0a38441 | |||
| 5abf019b1a | |||
| fbbb1ae5fe | |||
| cf4e9ec239 | |||
| 3957987b07 | |||
| c3a345d60b | |||
| 978fe1a7ff | |||
| c7c0657f6d | |||
| b8d2dade40 | |||
| d6d83fbb07 | |||
| f343512ec2 | |||
| 769614278c | |||
| bc6f1c3c2e | |||
| c725fdb912 | |||
| cafa9226e3 | |||
| 9a378902d5 | |||
| 099b2f10ca | |||
|
|
9c9acd3e1d | ||
|
|
833d4e629c | ||
|
|
240ea3aba3 | ||
|
|
8a47740049 | ||
|
|
ff9f9f2716 | ||
|
|
05de8c75a2 | ||
|
|
3dca43bc5c | ||
|
|
1629544b76 | ||
|
|
47449ce8dd | ||
|
|
ded07346a6 | ||
|
|
b51ca9fdbf | ||
|
|
7c90da5884 | ||
|
|
ffbe1a5b04 | ||
|
|
1a5e9103f6 | ||
|
|
e5fcbfeb26 | ||
|
|
7fc0a931ce | ||
|
|
b692ae70ba | ||
|
|
0533a1799f | ||
|
|
a8f2a7c803 | ||
|
|
7a46e288cf | ||
|
|
33307a5de2 | ||
|
|
a5189669f6 | ||
|
|
aef2a31977 | ||
|
|
13a05f6d0d | ||
|
|
5cad1871e7 | ||
|
|
6c5b5d99bc | ||
|
|
0fe57dc47f | ||
|
|
784076e6be | ||
|
|
c82103cc66 | ||
|
|
51f449ced0 | ||
|
|
ad92dce047 | ||
|
|
24d3f4d30f | ||
|
|
302e3461ad | ||
|
|
144c48c02f | ||
| 56d8c31a0d | |||
| c3a2addd47 | |||
| 6488b6db87 | |||
| e1977e49c3 | |||
| c67c2c7d18 | |||
| 5f30029f4b | |||
| 35ae0278b7 | |||
| d1801b8c9b | |||
| 2b71b7a418 | |||
| 635a9a5c63 | |||
| 0c679391ed | |||
| 4422d13f15 | |||
| 2a2ca39ba9 | |||
| 4ae85947e0 | |||
| c46d803951 | |||
| 15814f8ccc | |||
| 9560963139 | |||
| 09e3243034 | |||
| 89cf08647c | |||
| 441d73d473 | |||
| a7dfcaf5f2 | |||
| b1f6fcf1f2 | |||
| f818f72dc6 | |||
| 71ff02c25a | |||
| 3cd5455aa2 | |||
| 01e6023112 | |||
| db69e24211 | |||
| 2b84f6250f | |||
| 3a93af4f32 | |||
| d8da268a53 | |||
| 65a36a72e0 | |||
| b1e1be84b9 | |||
| 04f31c5105 | |||
| 97427a0864 | |||
| 18ab0a8b99 | |||
| a386197043 | |||
| aede012d42 | |||
| 1eb6aca588 |
@@ -1,31 +1,115 @@
|
||||
# Project configuration for Codex
|
||||
|
||||
Edit this file for each repository.
|
||||
# AzioneLab project configuration for Codex
|
||||
|
||||
## Project identity
|
||||
|
||||
Project name: `CHANGE_ME`
|
||||
Project description: `CHANGE_ME`
|
||||
Primary language/runtime: `CHANGE_ME`
|
||||
Project name: `AzioneLab`
|
||||
|
||||
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
|
||||
|
||||
Choose one:
|
||||
|
||||
```text
|
||||
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.
|
||||
- `work`: prefer Red Hat UBI minimal images when possible.
|
||||
## Tech stack
|
||||
|
||||
- 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
|
||||
|
||||
Enable only the profiles that apply to this repository:
|
||||
|
||||
```text
|
||||
enabled_profiles:
|
||||
- docker
|
||||
@@ -35,73 +119,91 @@ enabled_profiles:
|
||||
|
||||
## Branching model
|
||||
|
||||
Codex MUST:
|
||||
Work must happen on the current feature branch for the task.
|
||||
|
||||
- start from `develop`;
|
||||
- create one branch per task;
|
||||
- use one of these prefixes:
|
||||
- `feature/`
|
||||
- `fix/`
|
||||
- `hotfix/`
|
||||
- `chore/`
|
||||
- `docs/`
|
||||
- `refactor/`
|
||||
## Git Workflow Policy
|
||||
|
||||
Examples:
|
||||
- `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.
|
||||
|
||||
```text
|
||||
feature/add-healthcheck
|
||||
fix/selinux-authorized-keys
|
||||
hotfix/restore-container-startup
|
||||
```
|
||||
Allowed branch prefixes when a new branch is explicitly needed:
|
||||
|
||||
- `feature/`
|
||||
- `fix/`
|
||||
- `chore/`
|
||||
- `docs/`
|
||||
|
||||
Do not merge task branches into `develop`. Leave integration to the repository owner or a separate explicit request.
|
||||
|
||||
## Commit style
|
||||
|
||||
Codex MUST use Conventional Commits.
|
||||
Use Conventional Commits.
|
||||
|
||||
Examples:
|
||||
|
||||
```text
|
||||
feat: add Docker healthcheck
|
||||
fix: correct Ansible SELinux handling
|
||||
docs: add ADR for deployment strategy
|
||||
test: add regression tests for parser
|
||||
refactor: simplify container startup logic
|
||||
chore: update Codex project metadata
|
||||
feat: add health endpoint
|
||||
fix: correct backend Docker workdir
|
||||
docs: update deployment notes
|
||||
test: add reservation capacity checks
|
||||
refactor: simplify booking token validation
|
||||
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
|
||||
CHANGE_ME
|
||||
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
|
||||
```
|
||||
|
||||
Examples:
|
||||
Current coverage:
|
||||
|
||||
```bash
|
||||
docker compose run --rm app pytest
|
||||
```
|
||||
- Docker Compose configuration validation;
|
||||
- Django backend tests, including the health endpoint test.
|
||||
|
||||
```bash
|
||||
docker compose run --rm app ruff check .
|
||||
docker compose run --rm app pytest
|
||||
```
|
||||
Run formatting or linting only when a project configuration for those tools exists.
|
||||
|
||||
```bash
|
||||
docker compose run --rm ansible ansible-playbook --syntax-check playbook.yml
|
||||
```
|
||||
## Deployment approach
|
||||
|
||||
If no test command is configured, Codex MUST:
|
||||
Deployment uses `infra/docker/compose.yml` with explicit services:
|
||||
|
||||
1. report that tests are not configured;
|
||||
2. suggest the appropriate Docker-based test command;
|
||||
3. avoid claiming that the task is fully verified.
|
||||
- `nginx`: public reverse proxy;
|
||||
- `frontend`: Angular frontend build/static service served by nginx;
|
||||
- `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.
|
||||
Code comments language: English.
|
||||
|
||||
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
||||
.git
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
35
.env.example
Normal file
35
.env.example
Normal file
@@ -0,0 +1,35 @@
|
||||
# AzioneLab Docker Compose example environment.
|
||||
# Copy this file to .env and replace placeholder values before deployment.
|
||||
|
||||
COMPOSE_PROJECT_NAME=azionelab
|
||||
|
||||
NGINX_HTTP_PORT=80
|
||||
|
||||
BACKEND_HOST=backend
|
||||
BACKEND_PORT=8000
|
||||
FRONTEND_HOST=frontend
|
||||
FRONTEND_PORT=8080
|
||||
|
||||
DJANGO_SECRET_KEY=change-me
|
||||
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1
|
||||
DJANGO_CSRF_TRUSTED_ORIGINS=http://localhost
|
||||
DJANGO_DEBUG=true
|
||||
CORS_ALLOWED_ORIGINS=http://localhost:4200,http://localhost
|
||||
SITE_BASE_URL=http://localhost
|
||||
TIME_ZONE=Europe/Rome
|
||||
EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend
|
||||
DEFAULT_FROM_EMAIL=no-reply@azionelab.local
|
||||
|
||||
POSTGRES_DB=azionelab
|
||||
POSTGRES_USER=azionelab
|
||||
POSTGRES_PASSWORD=change-me
|
||||
POSTGRES_HOST=postgres
|
||||
POSTGRES_PORT=5432
|
||||
|
||||
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.
|
||||
# In local/debug mode, failed or attempted reservation emails also log the confirmation URL for manual browser testing.
|
||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
1
backend/azionelab/__init__.py
Normal file
1
backend/azionelab/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
8
backend/azionelab/asgi.py
Normal file
8
backend/azionelab/asgi.py
Normal file
@@ -0,0 +1,8 @@
|
||||
import os
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "azionelab.settings")
|
||||
|
||||
application = get_asgi_application()
|
||||
137
backend/azionelab/settings.py
Normal file
137
backend/azionelab/settings.py
Normal file
@@ -0,0 +1,137 @@
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import dj_database_url
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
DEBUG = os.environ.get("DJANGO_DEBUG", "false").lower() == "true"
|
||||
ENVIRONMENT = os.environ.get("ENVIRONMENT", "production").lower()
|
||||
SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY")
|
||||
if not SECRET_KEY:
|
||||
if DEBUG:
|
||||
SECRET_KEY = "insecure-development-key"
|
||||
else:
|
||||
raise ImproperlyConfigured("DJANGO_SECRET_KEY must be set when DJANGO_DEBUG is false.")
|
||||
|
||||
|
||||
def csv_env(name, default=""):
|
||||
return [item.strip() for item in os.environ.get(name, default).split(",") if item.strip()]
|
||||
|
||||
|
||||
ALLOWED_HOSTS = csv_env("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1")
|
||||
CSRF_TRUSTED_ORIGINS = csv_env("DJANGO_CSRF_TRUSTED_ORIGINS")
|
||||
CORS_ALLOWED_ORIGINS = csv_env("CORS_ALLOWED_ORIGINS")
|
||||
SITE_BASE_URL = os.environ.get("SITE_BASE_URL", "http://localhost").rstrip("/")
|
||||
LOG_RESERVATION_CONFIRMATION_URLS = DEBUG or ENVIRONMENT == "local"
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
"rest_framework",
|
||||
"corsheaders",
|
||||
"shows",
|
||||
"bookings",
|
||||
"checkins",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"corsheaders.middleware.CorsMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
]
|
||||
|
||||
ROOT_URLCONF = "azionelab.urls"
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [],
|
||||
"APP_DIRS": True,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = "azionelab.wsgi.application"
|
||||
|
||||
DATABASES = {
|
||||
"default": dj_database_url.config(
|
||||
default=(
|
||||
f"postgres://{os.environ.get('POSTGRES_USER', 'azionelab')}:"
|
||||
f"{os.environ.get('POSTGRES_PASSWORD', 'azionelab')}"
|
||||
f"@{os.environ.get('POSTGRES_HOST', 'postgres')}:"
|
||||
f"{os.environ.get('POSTGRES_PORT', '5432')}/"
|
||||
f"{os.environ.get('POSTGRES_DB', 'azionelab')}"
|
||||
),
|
||||
conn_max_age=60,
|
||||
)
|
||||
}
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
|
||||
},
|
||||
]
|
||||
|
||||
LANGUAGE_CODE = "en-us"
|
||||
TIME_ZONE = os.environ.get("TIME_ZONE", "Europe/Rome")
|
||||
USE_I18N = True
|
||||
USE_TZ = True
|
||||
|
||||
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"
|
||||
MEDIA_URL = "/media/"
|
||||
MEDIA_ROOT = BASE_DIR / "media"
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
|
||||
REST_FRAMEWORK = {
|
||||
"DEFAULT_RENDERER_CLASSES": [
|
||||
"rest_framework.renderers.JSONRenderer",
|
||||
],
|
||||
"DEFAULT_PARSER_CLASSES": [
|
||||
"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",
|
||||
},
|
||||
}
|
||||
10
backend/azionelab/tests.py
Normal file
10
backend/azionelab/tests.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from django.test import SimpleTestCase
|
||||
from django.urls import reverse
|
||||
|
||||
|
||||
class HealthEndpointTests(SimpleTestCase):
|
||||
def test_health_endpoint_returns_ok(self):
|
||||
response = self.client.get(reverse("health"))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json(), {"status": "ok"})
|
||||
23
backend/azionelab/urls.py
Normal file
23
backend/azionelab/urls.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from django.contrib import admin
|
||||
from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
from django.urls import include, path
|
||||
from rest_framework.decorators import api_view
|
||||
from rest_framework.response import Response
|
||||
|
||||
|
||||
@api_view(["GET"])
|
||||
def health(request):
|
||||
return Response({"status": "ok"})
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("admin/", admin.site.urls),
|
||||
path("api/health/", health, name="health"),
|
||||
path("api/", include("shows.urls")),
|
||||
path("api/", include("bookings.urls")),
|
||||
path("api/", include("checkins.urls")),
|
||||
]
|
||||
|
||||
if settings.DEBUG:
|
||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
8
backend/azionelab/wsgi.py
Normal file
8
backend/azionelab/wsgi.py
Normal file
@@ -0,0 +1,8 @@
|
||||
import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "azionelab.settings")
|
||||
|
||||
application = get_wsgi_application()
|
||||
1
backend/bookings/__init__.py
Normal file
1
backend/bookings/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
457
backend/bookings/admin.py
Normal file
457
backend/bookings/admin.py
Normal file
@@ -0,0 +1,457 @@
|
||||
from django import forms
|
||||
from django.contrib import admin, messages
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.template.response import TemplateResponse
|
||||
from django.urls import path, reverse
|
||||
from django.utils.html import format_html
|
||||
|
||||
from .models import Reservation, ReservationToken
|
||||
from .services import (
|
||||
AlreadyConfirmedReservation,
|
||||
NotEnoughSeats,
|
||||
PerformanceNotAvailable,
|
||||
ReservationNotConfirmed,
|
||||
ReservationNotPending,
|
||||
confirm_reservation_manually,
|
||||
create_pending_reservation,
|
||||
issue_check_in_access_for_reservation,
|
||||
)
|
||||
from checkins.models import CheckIn
|
||||
from checkins.services import AlreadyCheckedIn, confirm_check_in_for_reservation
|
||||
|
||||
|
||||
class ReservationAdminForm(forms.ModelForm):
|
||||
class 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",
|
||||
"check_in_access_display",
|
||||
"operational_tools",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"confirmed_at",
|
||||
"qr_code_generated_at",
|
||||
)
|
||||
autocomplete_fields = ("performance",)
|
||||
|
||||
def get_urls(self):
|
||||
urls = super().get_urls()
|
||||
custom_urls = [
|
||||
path(
|
||||
"<path:object_id>/manual-confirm/",
|
||||
self.admin_site.admin_view(self.manual_confirm_view),
|
||||
name="bookings_reservation_manual_confirm",
|
||||
),
|
||||
path(
|
||||
"<path:object_id>/check-in-pass/",
|
||||
self.admin_site.admin_view(self.check_in_pass_view),
|
||||
name="bookings_reservation_check_in_pass",
|
||||
),
|
||||
path(
|
||||
"<path:object_id>/manual-check-in/",
|
||||
self.admin_site.admin_view(self.manual_check_in_view),
|
||||
name="bookings_reservation_manual_check_in",
|
||||
),
|
||||
]
|
||||
return custom_urls + urls
|
||||
|
||||
def get_queryset(self, request):
|
||||
return super().get_queryset(request).select_related(
|
||||
"performance",
|
||||
"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",
|
||||
"check_in_access_display",
|
||||
"operational_tools",
|
||||
"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.display(description="Check-in access")
|
||||
def check_in_access_display(self, obj):
|
||||
if obj.status != Reservation.Status.CONFIRMED:
|
||||
return "Available after confirmation"
|
||||
|
||||
url = reverse("admin:bookings_reservation_check_in_pass", args=[obj.pk])
|
||||
return format_html('<a href="{}">Generate operational QR / URL</a>', url)
|
||||
|
||||
@admin.display(description="Operational tools")
|
||||
def operational_tools(self, obj):
|
||||
links = []
|
||||
if obj.status == Reservation.Status.PENDING:
|
||||
confirm_url = reverse("admin:bookings_reservation_manual_confirm", args=[obj.pk])
|
||||
links.append(f'<a href="{confirm_url}">Confirm reservation now</a>')
|
||||
if obj.status == Reservation.Status.CONFIRMED and not hasattr(obj, "check_in"):
|
||||
check_in_url = reverse("admin:bookings_reservation_manual_check_in", args=[obj.pk])
|
||||
links.append(f'<a href="{check_in_url}">Mark as checked in</a>')
|
||||
if obj.status == Reservation.Status.CONFIRMED:
|
||||
pass_url = reverse("admin:bookings_reservation_check_in_pass", args=[obj.pk])
|
||||
links.append(f'<a href="{pass_url}">Show QR / check-in URL</a>')
|
||||
if not links:
|
||||
return "-"
|
||||
return format_html(" | ".join(links))
|
||||
|
||||
def manual_confirm_view(self, request, object_id):
|
||||
reservation = self.get_object(request, object_id)
|
||||
if reservation is None:
|
||||
return self._redirect_to_changelist()
|
||||
|
||||
if request.method == "POST":
|
||||
try:
|
||||
result = confirm_reservation_manually(reservation_id=reservation.pk)
|
||||
except AlreadyConfirmedReservation:
|
||||
self.message_user(request, "Reservation is already confirmed.", level=messages.WARNING)
|
||||
return HttpResponseRedirect(self._change_url(reservation.pk))
|
||||
except ReservationNotPending as exc:
|
||||
self.message_user(request, str(exc), level=messages.ERROR)
|
||||
return HttpResponseRedirect(self._change_url(reservation.pk))
|
||||
except NotEnoughSeats as exc:
|
||||
self.message_user(request, str(exc), level=messages.ERROR)
|
||||
return HttpResponseRedirect(self._change_url(reservation.pk))
|
||||
|
||||
self.message_user(
|
||||
request,
|
||||
"Reservation confirmed manually. A check-in token was generated for operations.",
|
||||
level=messages.SUCCESS,
|
||||
)
|
||||
return self._render_operation_page(
|
||||
request,
|
||||
reservation=result.reservation,
|
||||
title="Reservation confirmed manually",
|
||||
heading="Reservation confirmed manually",
|
||||
description=(
|
||||
"Use this operational QR code or check-in URL only when the guest cannot complete the normal email flow."
|
||||
),
|
||||
qr_code_image=result.qr_code_image,
|
||||
qr_code_url=result.qr_code_url,
|
||||
)
|
||||
|
||||
return self._render_operation_page(
|
||||
request,
|
||||
reservation=reservation,
|
||||
title="Confirm reservation manually",
|
||||
heading="Confirm reservation manually",
|
||||
description=(
|
||||
"This bypasses guest email confirmation, but still rechecks booking availability and generates a check-in token."
|
||||
),
|
||||
submit_label="Confirm reservation",
|
||||
)
|
||||
|
||||
def check_in_pass_view(self, request, object_id):
|
||||
reservation = self.get_object(request, object_id)
|
||||
if reservation is None:
|
||||
return self._redirect_to_changelist()
|
||||
|
||||
if request.method == "POST":
|
||||
try:
|
||||
result = issue_check_in_access_for_reservation(reservation_id=reservation.pk)
|
||||
except ReservationNotConfirmed as exc:
|
||||
self.message_user(request, str(exc), level=messages.ERROR)
|
||||
return HttpResponseRedirect(self._change_url(reservation.pk))
|
||||
|
||||
self.message_user(
|
||||
request,
|
||||
"Operational check-in access generated for this reservation.",
|
||||
level=messages.SUCCESS,
|
||||
)
|
||||
return self._render_operation_page(
|
||||
request,
|
||||
reservation=result.reservation,
|
||||
title="Operational check-in access",
|
||||
heading="Operational check-in access",
|
||||
description=(
|
||||
"This page shows a one-time operational QR code and check-in URL for manual testing or guest support."
|
||||
),
|
||||
qr_code_image=result.qr_code_image,
|
||||
qr_code_url=result.qr_code_url,
|
||||
)
|
||||
|
||||
return self._render_operation_page(
|
||||
request,
|
||||
reservation=reservation,
|
||||
title="Generate operational check-in access",
|
||||
heading="Generate operational check-in access",
|
||||
description=(
|
||||
"Generate a fresh QR code and check-in URL without exposing token hashes in normal admin screens."
|
||||
),
|
||||
submit_label="Generate QR / URL",
|
||||
)
|
||||
|
||||
def manual_check_in_view(self, request, object_id):
|
||||
reservation = self.get_object(request, object_id)
|
||||
if reservation is None:
|
||||
return self._redirect_to_changelist()
|
||||
|
||||
if request.method == "POST":
|
||||
try:
|
||||
confirm_check_in_for_reservation(
|
||||
reservation_id=reservation.pk,
|
||||
staff_user=request.user,
|
||||
source=CheckIn.Source.MANUAL,
|
||||
)
|
||||
except ReservationNotConfirmed as exc:
|
||||
self.message_user(request, str(exc), level=messages.ERROR)
|
||||
return HttpResponseRedirect(self._change_url(reservation.pk))
|
||||
except AlreadyCheckedIn:
|
||||
self.message_user(request, "Reservation has already been checked in.", level=messages.WARNING)
|
||||
return HttpResponseRedirect(self._change_url(reservation.pk))
|
||||
|
||||
self.message_user(request, "Reservation marked as checked in.", level=messages.SUCCESS)
|
||||
return HttpResponseRedirect(self._change_url(reservation.pk))
|
||||
|
||||
return self._render_operation_page(
|
||||
request,
|
||||
reservation=reservation,
|
||||
title="Mark reservation as checked in",
|
||||
heading="Mark reservation as checked in",
|
||||
description="Use this only for staff-side emergency or desk check-in workflows.",
|
||||
submit_label="Confirm check-in",
|
||||
)
|
||||
|
||||
def _change_url(self, reservation_id):
|
||||
return reverse("admin:bookings_reservation_change", args=[reservation_id])
|
||||
|
||||
def _redirect_to_changelist(self):
|
||||
return HttpResponseRedirect(reverse("admin:bookings_reservation_changelist"))
|
||||
|
||||
def _render_operation_page(
|
||||
self,
|
||||
request,
|
||||
*,
|
||||
reservation,
|
||||
title,
|
||||
heading,
|
||||
description,
|
||||
submit_label=None,
|
||||
qr_code_image=None,
|
||||
qr_code_url=None,
|
||||
):
|
||||
context = {
|
||||
**self.admin_site.each_context(request),
|
||||
"opts": self.model._meta,
|
||||
"original": reservation,
|
||||
"title": title,
|
||||
"heading": heading,
|
||||
"description": description,
|
||||
"submit_label": submit_label,
|
||||
"qr_code_image": qr_code_image,
|
||||
"qr_code_url": qr_code_url,
|
||||
"change_url": self._change_url(reservation.pk),
|
||||
}
|
||||
return TemplateResponse(
|
||||
request,
|
||||
"admin/bookings/reservation/operation.html",
|
||||
context,
|
||||
)
|
||||
|
||||
|
||||
@admin.register(ReservationToken)
|
||||
class ReservationTokenAdmin(admin.ModelAdmin):
|
||||
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",)
|
||||
6
backend/bookings/apps.py
Normal file
6
backend/bookings/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class BookingsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "bookings"
|
||||
59
backend/bookings/emailing.py
Normal file
59
backend/bookings/emailing.py
Normal file
@@ -0,0 +1,59 @@
|
||||
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 _log_confirmation_link_for_local_debug(*, confirmation_link):
|
||||
if not settings.LOG_RESERVATION_CONFIRMATION_URLS:
|
||||
return
|
||||
|
||||
logger.warning(
|
||||
"LOCAL DEV confirmation URL: %s",
|
||||
confirmation_link,
|
||||
)
|
||||
|
||||
|
||||
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."
|
||||
)
|
||||
|
||||
_log_confirmation_link_for_local_debug(
|
||||
confirmation_link=confirmation_link,
|
||||
)
|
||||
|
||||
try:
|
||||
send_mail(
|
||||
subject=subject,
|
||||
message=message,
|
||||
from_email=None,
|
||||
recipient_list=[reservation.email],
|
||||
fail_silently=False,
|
||||
)
|
||||
except Exception:
|
||||
if settings.LOG_RESERVATION_CONFIRMATION_URLS:
|
||||
logger.warning(
|
||||
"Local/debug email delivery failed for reservation %s.",
|
||||
reservation.id,
|
||||
)
|
||||
return
|
||||
|
||||
logger.exception(
|
||||
"Failed to send confirmation email for reservation %s.",
|
||||
reservation.id,
|
||||
)
|
||||
95
backend/bookings/migrations/0001_initial.py
Normal file
95
backend/bookings/migrations/0001_initial.py
Normal file
@@ -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
backend/bookings/migrations/__init__.py
Normal file
1
backend/bookings/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
140
backend/bookings/models.py
Normal file
140
backend/bookings/models.py
Normal file
@@ -0,0 +1,140 @@
|
||||
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):
|
||||
if self.party_size > 1:
|
||||
return f"{self.name} ({self.party_size})"
|
||||
return self.name
|
||||
|
||||
@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
backend/bookings/qr.py
Normal file
30
backend/bookings/qr.py
Normal file
@@ -0,0 +1,30 @@
|
||||
import base64
|
||||
from io import BytesIO
|
||||
|
||||
import qrcode
|
||||
from django.conf import settings
|
||||
|
||||
from .models import Reservation
|
||||
|
||||
|
||||
CHECK_IN_PAGE_PATH = "/check-in"
|
||||
|
||||
|
||||
def build_check_in_preview_url(raw_check_in_token):
|
||||
return f"{settings.SITE_BASE_URL}{CHECK_IN_PAGE_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
backend/bookings/serializers.py
Normal file
45
backend/bookings/serializers.py
Normal 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()
|
||||
313
backend/bookings/services.py
Normal file
313
backend/bookings/services.py
Normal file
@@ -0,0 +1,313 @@
|
||||
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
|
||||
|
||||
|
||||
class ReservationNotPending(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 | None
|
||||
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
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReservationCheckInAccessResult:
|
||||
reservation: Reservation
|
||||
check_in_token: ReservationToken
|
||||
raw_check_in_token: str
|
||||
qr_code_image: str
|
||||
qr_code_url: str
|
||||
|
||||
|
||||
def calculate_available_seats(performance):
|
||||
confirmed_seats = (
|
||||
Reservation.objects.filter(
|
||||
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:
|
||||
result = _confirm_pending_reservation(
|
||||
reservation=reservation,
|
||||
confirmation_token=confirmation_token,
|
||||
)
|
||||
|
||||
if token_was_expired:
|
||||
raise ExpiredToken("Confirmation token has expired.")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def confirm_reservation_manually(*, reservation_id):
|
||||
with transaction.atomic():
|
||||
reservation = Reservation.objects.select_for_update().get(pk=reservation_id)
|
||||
if reservation.status == Reservation.Status.CONFIRMED:
|
||||
raise AlreadyConfirmedReservation("Reservation is already confirmed.")
|
||||
if reservation.status != Reservation.Status.PENDING:
|
||||
raise ReservationNotPending("Only pending reservations can be confirmed manually.")
|
||||
|
||||
confirmation_token = (
|
||||
ReservationToken.objects.select_for_update()
|
||||
.filter(
|
||||
reservation=reservation,
|
||||
purpose=ReservationToken.Purpose.CONFIRMATION,
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.first()
|
||||
)
|
||||
return _confirm_pending_reservation(
|
||||
reservation=reservation,
|
||||
confirmation_token=confirmation_token,
|
||||
)
|
||||
|
||||
|
||||
def issue_check_in_access_for_reservation(*, reservation_id):
|
||||
with transaction.atomic():
|
||||
reservation = (
|
||||
Reservation.objects.select_for_update()
|
||||
.select_related("performance__show", "performance__venue")
|
||||
.get(pk=reservation_id)
|
||||
)
|
||||
if reservation.status != Reservation.Status.CONFIRMED:
|
||||
raise ReservationNotConfirmed("Reservation must be confirmed before QR retrieval.")
|
||||
|
||||
result = _issue_check_in_access(reservation)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def retrieve_reservation_qr_from_token(raw_token):
|
||||
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
|
||||
|
||||
|
||||
def _confirm_pending_reservation(*, reservation, confirmation_token=None):
|
||||
performance = _get_locked_bookable_performance(reservation.performance_id)
|
||||
available_seats = calculate_available_seats(performance)
|
||||
if reservation.party_size > available_seats:
|
||||
raise NotEnoughSeats("Not enough seats are available for this performance.")
|
||||
|
||||
reservation.confirm()
|
||||
if confirmation_token is not None and not confirmation_token.is_used:
|
||||
confirmation_token.mark_used()
|
||||
|
||||
check_in_access = _issue_check_in_access(reservation)
|
||||
|
||||
return ConfirmedReservationResult(
|
||||
reservation=reservation,
|
||||
confirmation_token=confirmation_token,
|
||||
check_in_token=check_in_access.check_in_token,
|
||||
raw_check_in_token=check_in_access.raw_check_in_token,
|
||||
available_seats=available_seats - reservation.party_size,
|
||||
qr_code_image=check_in_access.qr_code_image,
|
||||
qr_code_url=check_in_access.qr_code_url,
|
||||
)
|
||||
|
||||
|
||||
def _issue_check_in_access(reservation):
|
||||
check_in_token, raw_check_in_token = ReservationToken.create_token(
|
||||
reservation=reservation,
|
||||
purpose=ReservationToken.Purpose.CHECK_IN,
|
||||
expires_at=reservation.performance.starts_at + CHECK_IN_TOKEN_AFTER_PERFORMANCE_TTL,
|
||||
)
|
||||
if reservation.qr_code_generated_at is None:
|
||||
reservation.qr_code_generated_at = timezone.now()
|
||||
reservation.save(update_fields=["qr_code_generated_at", "updated_at"])
|
||||
|
||||
return ReservationCheckInAccessResult(
|
||||
reservation=reservation,
|
||||
check_in_token=check_in_token,
|
||||
raw_check_in_token=raw_check_in_token,
|
||||
qr_code_image=generate_check_in_qr_base64(
|
||||
reservation=reservation,
|
||||
raw_check_in_token=raw_check_in_token,
|
||||
),
|
||||
qr_code_url=build_check_in_preview_url(raw_check_in_token),
|
||||
)
|
||||
@@ -0,0 +1,35 @@
|
||||
{% extends "admin/base_site.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div id="content-main">
|
||||
<h1>{{ heading }}</h1>
|
||||
<p>{{ description }}</p>
|
||||
|
||||
<p>
|
||||
<strong>Reservation:</strong> {{ original.name }}<br>
|
||||
<strong>Show:</strong> {{ original.performance.show.title }}<br>
|
||||
<strong>Performance:</strong> {{ original.performance.starts_at }}<br>
|
||||
<strong>Party size:</strong> {{ original.party_size }}
|
||||
</p>
|
||||
|
||||
{% if qr_code_url %}
|
||||
<p>
|
||||
<strong>Check-in URL:</strong><br>
|
||||
<a href="{{ qr_code_url }}">{{ qr_code_url }}</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if qr_code_image %}
|
||||
<p><img src="{{ qr_code_image }}" alt="Operational QR code"></p>
|
||||
{% endif %}
|
||||
|
||||
{% if submit_label %}
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<input type="submit" value="{{ submit_label }}" class="default">
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<p><a href="{{ change_url }}">Back to reservation</a></p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
179
backend/bookings/test_admin.py
Normal file
179
backend/bookings/test_admin.py
Normal file
@@ -0,0 +1,179 @@
|
||||
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 checkins.models import CheckIn
|
||||
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")
|
||||
|
||||
def test_admin_can_confirm_pending_reservation_manually_and_get_qr_access(self):
|
||||
reservation = Reservation.objects.create(
|
||||
performance=self.performance,
|
||||
name="Maria Rossi",
|
||||
email="maria@example.com",
|
||||
party_size=2,
|
||||
)
|
||||
confirmation_token, _ = ReservationToken.create_token(
|
||||
reservation=reservation,
|
||||
purpose=ReservationToken.Purpose.CONFIRMATION,
|
||||
expires_at=timezone.now() + timedelta(hours=2),
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
reverse("admin:bookings_reservation_manual_confirm", args=[reservation.id]),
|
||||
)
|
||||
|
||||
reservation.refresh_from_db()
|
||||
confirmation_token.refresh_from_db()
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(reservation.status, Reservation.Status.CONFIRMED)
|
||||
self.assertIsNotNone(confirmation_token.used_at)
|
||||
self.assertContains(response, "Reservation confirmed manually")
|
||||
self.assertContains(response, "/check-in?token=")
|
||||
self.assertTrue(
|
||||
ReservationToken.objects.filter(
|
||||
reservation=reservation,
|
||||
purpose=ReservationToken.Purpose.CHECK_IN,
|
||||
).exists()
|
||||
)
|
||||
|
||||
def test_admin_can_mark_confirmed_reservation_as_checked_in(self):
|
||||
reservation = Reservation.objects.create(
|
||||
performance=self.performance,
|
||||
name="Checked Guest",
|
||||
email="checked@example.com",
|
||||
party_size=1,
|
||||
status=Reservation.Status.CONFIRMED,
|
||||
confirmed_at=timezone.now(),
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
reverse("admin:bookings_reservation_manual_check_in", args=[reservation.id]),
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 302)
|
||||
check_in = CheckIn.objects.get(reservation=reservation)
|
||||
self.assertEqual(check_in.checked_in_by, self.admin_user)
|
||||
self.assertEqual(check_in.source, CheckIn.Source.MANUAL)
|
||||
|
||||
def test_reservation_string_is_concise_without_performance_details(self):
|
||||
single_guest_reservation = Reservation.objects.create(
|
||||
performance=self.performance,
|
||||
name="Maria Rossi",
|
||||
email="maria@example.com",
|
||||
party_size=1,
|
||||
)
|
||||
group_reservation = Reservation.objects.create(
|
||||
performance=self.performance,
|
||||
name="Luca Bianchi",
|
||||
email="luca@example.com",
|
||||
party_size=3,
|
||||
)
|
||||
|
||||
self.assertEqual(str(single_guest_reservation), "Maria Rossi")
|
||||
self.assertEqual(str(group_reservation), "Luca Bianchi (3)")
|
||||
359
backend/bookings/test_api.py
Normal file
359
backend/bookings/test_api.py
Normal file
@@ -0,0 +1,359 @@
|
||||
from datetime import timedelta
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.cache import cache
|
||||
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 APIClient, 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):
|
||||
cache.clear()
|
||||
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,
|
||||
)
|
||||
|
||||
@override_settings(SITE_BASE_URL="https://tickets.azionelab.example")
|
||||
def test_reservation_creation_allows_anonymous_post_without_csrf(self):
|
||||
csrf_client = APIClient(enforce_csrf_checks=True)
|
||||
|
||||
with self.captureOnCommitCallbacks(execute=True):
|
||||
response = csrf_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(response.data["status"], Reservation.Status.PENDING)
|
||||
|
||||
@override_settings(SITE_BASE_URL="https://tickets.azionelab.example")
|
||||
def test_reservation_creation_ignores_session_csrf_for_public_endpoint(self):
|
||||
csrf_client = APIClient(enforce_csrf_checks=True)
|
||||
user = get_user_model().objects.create_user(
|
||||
username="box-office",
|
||||
email="staff@example.com",
|
||||
password="test-pass-123",
|
||||
)
|
||||
csrf_client.force_login(user)
|
||||
|
||||
with self.captureOnCommitCallbacks(execute=True):
|
||||
response = csrf_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(response.data["status"], Reservation.Status.PENDING)
|
||||
|
||||
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/check-in?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/check-in?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)
|
||||
475
backend/bookings/test_services.py
Normal file
475
backend/bookings/test_services.py
Normal file
@@ -0,0 +1,475 @@
|
||||
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,
|
||||
ReservationNotConfirmed,
|
||||
calculate_available_seats,
|
||||
confirm_reservation_manually,
|
||||
confirm_reservation_from_token,
|
||||
create_pending_reservation,
|
||||
generate_confirmation_token,
|
||||
issue_check_in_access_for_reservation,
|
||||
retrieve_reservation_qr_from_token,
|
||||
)
|
||||
from shows.models import Performance, Show, Venue
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@override_settings(
|
||||
EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend",
|
||||
LOG_RESERVATION_CONFIRMATION_URLS=True,
|
||||
SITE_BASE_URL="https://tickets.azionelab.example",
|
||||
)
|
||||
def test_create_pending_reservation_logs_confirmation_link_in_local_mode(self):
|
||||
with self.assertLogs("bookings.emailing", level="WARNING") as captured_logs:
|
||||
with self.captureOnCommitCallbacks(execute=True):
|
||||
result = create_pending_reservation(
|
||||
performance_id=self.performance.id,
|
||||
name="Maria Rossi",
|
||||
email="maria@example.com",
|
||||
party_size=1,
|
||||
)
|
||||
|
||||
self.assertEqual(result.reservation.status, Reservation.Status.PENDING)
|
||||
self.assertTrue(
|
||||
any(
|
||||
"LOCAL DEV confirmation URL:" in log_entry
|
||||
and result.raw_confirmation_token in log_entry
|
||||
for log_entry in captured_logs.output
|
||||
)
|
||||
)
|
||||
|
||||
@override_settings(LOG_RESERVATION_CONFIRMATION_URLS=False)
|
||||
@patch("bookings.emailing.send_mail", side_effect=RuntimeError("SMTP down"))
|
||||
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
|
||||
)
|
||||
)
|
||||
self.assertFalse(
|
||||
any(result.raw_confirmation_token in log_entry for log_entry in captured_logs.output)
|
||||
)
|
||||
|
||||
@override_settings(
|
||||
LOG_RESERVATION_CONFIRMATION_URLS=True,
|
||||
SITE_BASE_URL="https://tickets.azionelab.example",
|
||||
)
|
||||
@patch("bookings.emailing.send_mail", side_effect=RuntimeError("SMTP down"))
|
||||
def test_create_pending_reservation_logs_confirmation_link_before_email_failure_in_local_mode(
|
||||
self,
|
||||
mocked_send_mail,
|
||||
):
|
||||
with self.assertLogs("bookings.emailing", level="WARNING") as captured_logs:
|
||||
with self.captureOnCommitCallbacks(execute=True):
|
||||
result = create_pending_reservation(
|
||||
performance_id=self.performance.id,
|
||||
name="Maria Rossi",
|
||||
email="maria@example.com",
|
||||
party_size=1,
|
||||
)
|
||||
|
||||
self.assertEqual(result.reservation.status, Reservation.Status.PENDING)
|
||||
mocked_send_mail.assert_called_once()
|
||||
confirmation_log_index = next(
|
||||
index
|
||||
for index, log_entry in enumerate(captured_logs.output)
|
||||
if "LOCAL DEV confirmation URL:" in log_entry
|
||||
and result.raw_confirmation_token in log_entry
|
||||
)
|
||||
failure_log_index = next(
|
||||
index
|
||||
for index, log_entry in enumerate(captured_logs.output)
|
||||
if "Local/debug email delivery failed for reservation" in log_entry
|
||||
)
|
||||
|
||||
self.assertLess(confirmation_log_index, failure_log_index)
|
||||
self.assertEqual(
|
||||
sum(
|
||||
result.raw_confirmation_token in log_entry
|
||||
for log_entry in captured_logs.output
|
||||
),
|
||||
1,
|
||||
)
|
||||
self.assertFalse(
|
||||
any("Traceback" in log_entry for log_entry in captured_logs.output)
|
||||
)
|
||||
|
||||
@override_settings(
|
||||
LOG_RESERVATION_CONFIRMATION_URLS=False,
|
||||
SITE_BASE_URL="https://tickets.azionelab.example",
|
||||
)
|
||||
@patch("bookings.emailing.send_mail")
|
||||
def test_create_pending_reservation_does_not_log_confirmation_link_outside_local_mode(
|
||||
self,
|
||||
mocked_send_mail,
|
||||
):
|
||||
with self.assertNoLogs("bookings.emailing", level="WARNING"):
|
||||
with self.captureOnCommitCallbacks(execute=True):
|
||||
result = create_pending_reservation(
|
||||
performance_id=self.performance.id,
|
||||
name="Maria Rossi",
|
||||
email="maria@example.com",
|
||||
party_size=1,
|
||||
)
|
||||
|
||||
self.assertEqual(result.reservation.status, Reservation.Status.PENDING)
|
||||
mocked_send_mail.assert_called_once()
|
||||
|
||||
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/check-in?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/check-in?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))
|
||||
|
||||
@override_settings(SITE_BASE_URL="https://tickets.azionelab.example")
|
||||
def test_manual_confirmation_reuses_capacity_rules_and_generates_check_in_access(self):
|
||||
reservation = self.create_reservation()
|
||||
confirmation_token, _ = generate_confirmation_token(reservation)
|
||||
|
||||
result = confirm_reservation_manually(reservation_id=reservation.id)
|
||||
|
||||
reservation.refresh_from_db()
|
||||
confirmation_token.refresh_from_db()
|
||||
self.assertEqual(reservation.status, Reservation.Status.CONFIRMED)
|
||||
self.assertIsNotNone(confirmation_token.used_at)
|
||||
self.assertEqual(result.check_in_token.purpose, ReservationToken.Purpose.CHECK_IN)
|
||||
self.assertTrue(
|
||||
result.qr_code_url.startswith(
|
||||
"https://tickets.azionelab.example/check-in?token="
|
||||
)
|
||||
)
|
||||
|
||||
def test_manual_confirmation_rejects_non_pending_reservation(self):
|
||||
reservation = self.create_reservation(
|
||||
status=Reservation.Status.CONFIRMED,
|
||||
confirmed_at=timezone.now(),
|
||||
)
|
||||
|
||||
with self.assertRaises(AlreadyConfirmedReservation):
|
||||
confirm_reservation_manually(reservation_id=reservation.id)
|
||||
|
||||
@override_settings(SITE_BASE_URL="https://tickets.azionelab.example")
|
||||
def test_issue_check_in_access_requires_confirmed_reservation(self):
|
||||
reservation = self.create_reservation()
|
||||
|
||||
with self.assertRaises(ReservationNotConfirmed):
|
||||
issue_check_in_access_for_reservation(reservation_id=reservation.id)
|
||||
|
||||
@override_settings(SITE_BASE_URL="https://tickets.azionelab.example")
|
||||
def test_issue_check_in_access_generates_qr_for_confirmed_reservation(self):
|
||||
reservation = self.create_reservation(
|
||||
status=Reservation.Status.CONFIRMED,
|
||||
confirmed_at=timezone.now(),
|
||||
)
|
||||
|
||||
result = issue_check_in_access_for_reservation(reservation_id=reservation.id)
|
||||
|
||||
self.assertEqual(result.reservation, reservation)
|
||||
self.assertEqual(result.check_in_token.purpose, ReservationToken.Purpose.CHECK_IN)
|
||||
self.assertTrue(result.qr_code_image.startswith("data:image/png;base64,"))
|
||||
self.assertIn("/check-in?token=", result.qr_code_url)
|
||||
|
||||
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
backend/bookings/tests.py
Normal file
119
backend/bookings/tests.py
Normal 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
backend/bookings/urls.py
Normal file
14
backend/bookings/urls.py
Normal 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"),
|
||||
]
|
||||
128
backend/bookings/views.py
Normal file
128
backend/bookings/views.py
Normal file
@@ -0,0 +1,128 @@
|
||||
from django.shortcuts import get_object_or_404
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import api_view, authentication_classes, permission_classes, throttle_classes
|
||||
from rest_framework.permissions import AllowAny
|
||||
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"])
|
||||
@authentication_classes([])
|
||||
@permission_classes([AllowAny])
|
||||
@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)
|
||||
1
backend/checkins/__init__.py
Normal file
1
backend/checkins/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
34
backend/checkins/admin.py
Normal file
34
backend/checkins/admin.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import CheckIn
|
||||
|
||||
|
||||
@admin.register(CheckIn)
|
||||
class CheckInAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"reservation_guest_name",
|
||||
"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="Reservation", ordering="reservation__name")
|
||||
def reservation_guest_name(self, obj):
|
||||
return obj.reservation.name
|
||||
|
||||
@admin.display(description="Performance")
|
||||
def performance(self, obj):
|
||||
return obj.reservation.performance
|
||||
6
backend/checkins/apps.py
Normal file
6
backend/checkins/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CheckinsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "checkins"
|
||||
54
backend/checkins/migrations/0001_initial.py
Normal file
54
backend/checkins/migrations/0001_initial.py
Normal file
@@ -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
backend/checkins/migrations/__init__.py
Normal file
1
backend/checkins/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
46
backend/checkins/models.py
Normal file
46
backend/checkins/models.py
Normal file
@@ -0,0 +1,46 @@
|
||||
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
backend/checkins/serializers.py
Normal file
32
backend/checkins/serializers.py
Normal 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")
|
||||
152
backend/checkins/services.py
Normal file
152
backend/checkins/services.py
Normal file
@@ -0,0 +1,152 @@
|
||||
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 confirm_check_in_for_reservation(*, reservation_id, staff_user, source=CheckIn.Source.MANUAL):
|
||||
_validate_staff_user(staff_user)
|
||||
|
||||
with transaction.atomic():
|
||||
reservation = (
|
||||
Reservation.objects.select_for_update()
|
||||
.select_related("performance__show", "performance__venue")
|
||||
.get(pk=reservation_id)
|
||||
)
|
||||
_validate_reservation_for_check_in(reservation)
|
||||
|
||||
try:
|
||||
check_in = CheckIn.objects.create(
|
||||
reservation=reservation,
|
||||
checked_in_by=staff_user,
|
||||
source=source,
|
||||
)
|
||||
except IntegrityError as exc:
|
||||
raise AlreadyCheckedIn("Reservation has already been checked in.") from exc
|
||||
|
||||
return CheckInResult(check_in=check_in, preview=_build_preview(reservation))
|
||||
|
||||
|
||||
def _validate_staff_user(staff_user):
|
||||
if staff_user is None:
|
||||
raise MissingStaffUser("A staff user is required for check-in.")
|
||||
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"),
|
||||
)
|
||||
56
backend/checkins/test_admin.py
Normal file
56
backend/checkins/test_admin.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
|
||||
from bookings.models import Reservation
|
||||
from checkins.admin import CheckInAdmin
|
||||
from checkins.models import CheckIn
|
||||
from shows.models import Performance, Show, Venue
|
||||
|
||||
|
||||
class CheckInAdminTests(TestCase):
|
||||
def setUp(self):
|
||||
user_model = get_user_model()
|
||||
self.admin_user = user_model.objects.create_superuser(
|
||||
username="admin-checkins",
|
||||
email="admin-checkins@example.com",
|
||||
password="password123",
|
||||
)
|
||||
self.show = Show.objects.create(
|
||||
title="Open Stage",
|
||||
slug="open-stage-checkins-admin",
|
||||
is_published=True,
|
||||
)
|
||||
self.venue = Venue.objects.create(
|
||||
name="AzioneLab Theatre",
|
||||
slug="azionelab-theatre-checkins-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,
|
||||
)
|
||||
self.reservation = Reservation.objects.create(
|
||||
performance=self.performance,
|
||||
name="Maria Rossi",
|
||||
email="maria@example.com",
|
||||
party_size=3,
|
||||
status=Reservation.Status.CONFIRMED,
|
||||
confirmed_at=timezone.now(),
|
||||
)
|
||||
self.check_in = CheckIn.objects.create(
|
||||
reservation=self.reservation,
|
||||
checked_in_at=timezone.now(),
|
||||
checked_in_by=self.admin_user,
|
||||
source=CheckIn.Source.MANUAL,
|
||||
)
|
||||
self.model_admin = CheckInAdmin(CheckIn, admin.site)
|
||||
|
||||
def test_reservation_column_shows_guest_name_only(self):
|
||||
self.assertEqual(self.model_admin.reservation_guest_name(self.check_in), "Maria Rossi")
|
||||
253
backend/checkins/test_api.py
Normal file
253
backend/checkins/test_api.py
Normal 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),
|
||||
)
|
||||
197
backend/checkins/test_services.py
Normal file
197
backend/checkins/test_services.py
Normal file
@@ -0,0 +1,197 @@
|
||||
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_for_reservation,
|
||||
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_preview_rejects_expired_check_in_token(self):
|
||||
reservation = self.create_reservation()
|
||||
_, raw_token = ReservationToken.create_token(
|
||||
reservation=reservation,
|
||||
purpose=ReservationToken.Purpose.CHECK_IN,
|
||||
expires_at=timezone.now() - timedelta(minutes=1),
|
||||
)
|
||||
|
||||
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_manual_check_in_by_reservation_id_succeeds_for_confirmed_reservation(self):
|
||||
reservation = self.create_reservation()
|
||||
|
||||
result = confirm_check_in_for_reservation(
|
||||
reservation_id=reservation.id,
|
||||
staff_user=self.staff_user,
|
||||
)
|
||||
|
||||
self.assertEqual(result.check_in.reservation, reservation)
|
||||
self.assertEqual(result.check_in.checked_in_by, self.staff_user)
|
||||
self.assertEqual(result.check_in.source, CheckIn.Source.MANUAL)
|
||||
|
||||
def test_manual_check_in_by_reservation_id_rejects_pending_reservation(self):
|
||||
reservation = self.create_reservation(status=Reservation.Status.PENDING, confirmed_at=None)
|
||||
|
||||
with self.assertRaises(ReservationNotConfirmed):
|
||||
confirm_check_in_for_reservation(
|
||||
reservation_id=reservation.id,
|
||||
staff_user=self.staff_user,
|
||||
)
|
||||
|
||||
def test_check_in_rejects_confirmation_token_even_for_confirmed_reservation(self):
|
||||
reservation = self.create_reservation()
|
||||
_, raw_token = ReservationToken.create_token(
|
||||
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 test_check_in_rejects_used_check_in_token(self):
|
||||
reservation = self.create_reservation()
|
||||
token, raw_token = self.create_check_in_token(reservation)
|
||||
token.mark_used()
|
||||
|
||||
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
backend/checkins/urls.py
Normal file
9
backend/checkins/urls.py
Normal 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
backend/checkins/views.py
Normal file
118
backend/checkins/views.py
Normal 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)
|
||||
14
backend/manage.py
Normal file
14
backend/manage.py
Normal file
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env python
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "azionelab.settings")
|
||||
from django.core.management import execute_from_command_line
|
||||
|
||||
execute_from_command_line(sys.argv)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
1
backend/shows/__init__.py
Normal file
1
backend/shows/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
91
backend/shows/admin.py
Normal file
91
backend/shows/admin.py
Normal file
@@ -0,0 +1,91 @@
|
||||
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", "image_preview", "created_at", "updated_at")
|
||||
list_filter = ("is_published",)
|
||||
search_fields = ("title", "slug", "summary", "description")
|
||||
prepopulated_fields = {"slug": ("title",)}
|
||||
readonly_fields = ("image_preview", "created_at", "updated_at")
|
||||
fields = (
|
||||
"title",
|
||||
"slug",
|
||||
"summary",
|
||||
"description",
|
||||
"uploaded_image",
|
||||
"poster_image",
|
||||
"image_preview",
|
||||
"is_published",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
|
||||
@admin.display(description="Preview")
|
||||
def image_preview(self, obj):
|
||||
if not getattr(obj, "pk", None):
|
||||
return "-"
|
||||
image_url = obj.image_url()
|
||||
if not image_url:
|
||||
return "No image"
|
||||
return format_html(
|
||||
'<img src="{}" alt="{}" style="max-width: 120px; border-radius: 6px;" />',
|
||||
image_url,
|
||||
obj.title,
|
||||
)
|
||||
|
||||
|
||||
@admin.register(Venue)
|
||||
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,
|
||||
)
|
||||
6
backend/shows/apps.py
Normal file
6
backend/shows/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ShowsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "shows"
|
||||
1
backend/shows/management/__init__.py
Normal file
1
backend/shows/management/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
backend/shows/management/commands/__init__.py
Normal file
1
backend/shows/management/commands/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
126
backend/shows/management/commands/seed_demo_data.py
Normal file
126
backend/shows/management/commands/seed_demo_data.py
Normal file
@@ -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
backend/shows/migrations/0001_initial.py
Normal file
98
backend/shows/migrations/0001_initial.py
Normal 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",
|
||||
),
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
17
backend/shows/migrations/0002_show_uploaded_image.py
Normal file
17
backend/shows/migrations/0002_show_uploaded_image.py
Normal file
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 5.2.3 on 2026-04-29
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("shows", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="show",
|
||||
name="uploaded_image",
|
||||
field=models.ImageField(blank=True, upload_to="shows/"),
|
||||
),
|
||||
]
|
||||
1
backend/shows/migrations/__init__.py
Normal file
1
backend/shows/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
94
backend/shows/models.py
Normal file
94
backend/shows/models.py
Normal file
@@ -0,0 +1,94 @@
|
||||
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)
|
||||
uploaded_image = models.ImageField(upload_to="shows/", 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
|
||||
|
||||
def image_url(self, request=None):
|
||||
if self.uploaded_image:
|
||||
image_url = self.uploaded_image.url
|
||||
if request is not None:
|
||||
return request.build_absolute_uri(image_url)
|
||||
return image_url
|
||||
return self.poster_image
|
||||
|
||||
|
||||
class Venue(TimeStampedModel):
|
||||
name = models.CharField(max_length=200)
|
||||
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()
|
||||
63
backend/shows/serializers.py
Normal file
63
backend/shows/serializers.py
Normal file
@@ -0,0 +1,63 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
class PublicShowImageMixin(serializers.Serializer):
|
||||
image_url = serializers.SerializerMethodField()
|
||||
|
||||
def get_image_url(self, obj):
|
||||
request = self.context.get("request")
|
||||
return obj.image_url(request=request)
|
||||
|
||||
|
||||
class PublicShowListSerializer(PublicShowImageMixin, serializers.Serializer):
|
||||
id = serializers.IntegerField()
|
||||
title = serializers.CharField()
|
||||
slug = serializers.SlugField()
|
||||
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(PublicShowImageMixin, 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
backend/shows/test_admin.py
Normal file
32
backend/shows/test_admin.py
Normal 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")
|
||||
77
backend/shows/test_api.py
Normal file
77
backend/shows/test_api.py
Normal file
@@ -0,0 +1,77 @@
|
||||
from datetime import timedelta
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import override_settings
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from shows.models import Performance, Show, Venue
|
||||
|
||||
|
||||
SMALL_GIF = (
|
||||
b"GIF89a\x01\x00\x01\x00\x80\x00\x00"
|
||||
b"\x00\x00\x00\xff\xff\xff!\xf9\x04\x01"
|
||||
b"\x00\x00\x00\x00,\x00\x00\x00\x00\x01"
|
||||
b"\x00\x01\x00\x00\x02\x02D\x01\x00;"
|
||||
)
|
||||
|
||||
|
||||
class ShowApiTests(APITestCase):
|
||||
def setUp(self):
|
||||
self.temp_media = TemporaryDirectory()
|
||||
self.addCleanup(self.temp_media.cleanup)
|
||||
self.settings_override = override_settings(MEDIA_ROOT=self.temp_media.name)
|
||||
self.settings_override.enable()
|
||||
self.addCleanup(self.settings_override.disable)
|
||||
|
||||
self.show = Show.objects.create(
|
||||
title="Open Stage",
|
||||
slug="open-stage-media",
|
||||
summary="A contemporary theatre performance.",
|
||||
description="Full public show description.",
|
||||
poster_image="https://cdn.example.com/open-stage-poster.jpg",
|
||||
is_published=True,
|
||||
)
|
||||
self.external_only_show = Show.objects.create(
|
||||
title="External Poster Stage",
|
||||
slug="external-poster-stage",
|
||||
summary="External image only.",
|
||||
description="External image only.",
|
||||
poster_image="https://cdn.example.com/external-only.jpg",
|
||||
is_published=True,
|
||||
)
|
||||
self.venue = Venue.objects.create(
|
||||
name="AzioneLab Theatre",
|
||||
slug="azionelab-theatre-show-api",
|
||||
address="Via Example 1",
|
||||
city="Rome",
|
||||
)
|
||||
self.performance = Performance.objects.create(
|
||||
show=self.show,
|
||||
venue=self.venue,
|
||||
starts_at=timezone.now() + timedelta(days=7),
|
||||
room_capacity=20,
|
||||
)
|
||||
|
||||
def test_show_list_prefers_uploaded_image_url_when_present(self):
|
||||
self.show.uploaded_image.save(
|
||||
"open-stage.gif",
|
||||
SimpleUploadedFile("open-stage.gif", SMALL_GIF, content_type="image/gif"),
|
||||
save=True,
|
||||
)
|
||||
|
||||
response = self.client.get(reverse("api-show-list"))
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
list_item = next(item for item in response.data["results"] if item["slug"] == self.show.slug)
|
||||
self.assertTrue(list_item["image_url"].endswith("/media/shows/open-stage.gif"))
|
||||
self.assertEqual(list_item["poster_image"], "https://cdn.example.com/open-stage-poster.jpg")
|
||||
|
||||
def test_show_detail_falls_back_to_existing_external_image_url(self):
|
||||
response = self.client.get(reverse("api-show-detail", kwargs={"slug": self.external_only_show.slug}))
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data["image_url"], "https://cdn.example.com/external-only.jpg")
|
||||
31
backend/shows/test_management.py
Normal file
31
backend/shows/test_management.py
Normal 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
backend/shows/urls.py
Normal file
11
backend/shows/urls.py
Normal 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"),
|
||||
]
|
||||
69
backend/shows/views.py
Normal file
69
backend/shows/views.py
Normal file
@@ -0,0 +1,69 @@
|
||||
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, context={"request": request})
|
||||
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, context={"request": request})
|
||||
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,
|
||||
context={"request": request},
|
||||
)
|
||||
return Response({"results": serializer.data})
|
||||
|
||||
|
||||
@api_view(["GET"])
|
||||
def performance_detail(request, pk):
|
||||
performance = get_object_or_404(public_performance_queryset(), pk=pk)
|
||||
serializer = PublicPerformanceDetailSerializer(performance, context={"request": request})
|
||||
return Response(serializer.data)
|
||||
35
docs/adr/0001-use-django-monolith.md
Normal file
35
docs/adr/0001-use-django-monolith.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# ADR-0001: Use Django Monolith
|
||||
|
||||
Date: 2026-04-28
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
AzioneLab needs a public theatre company website, a simple booking system, administration tools, email confirmation, QR code generation, and entrance check-in.
|
||||
|
||||
The project is small enough that separate backend services would add operational cost without clear benefit. The selected backend stack is Python, Django 5.2 LTS, Django REST Framework, PostgreSQL, and gunicorn.
|
||||
|
||||
## Decision
|
||||
|
||||
Use a single Django monolith for the backend.
|
||||
|
||||
The Django application will own:
|
||||
|
||||
- public REST APIs;
|
||||
- booking and confirmation logic;
|
||||
- QR code generation;
|
||||
- check-in validation;
|
||||
- Django admin for internal management;
|
||||
- database transactions for capacity enforcement.
|
||||
|
||||
The Angular frontend remains a separate client application served through nginx.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The system stays simple to develop, deploy, and operate.
|
||||
- Django admin can cover the initial administration needs without building custom admin screens immediately.
|
||||
- Booking and capacity rules can be enforced close to the data model.
|
||||
- The monolith may need to be split later if traffic, team size, or operational needs grow.
|
||||
26
docs/adr/0002-no-async-task-queue.md
Normal file
26
docs/adr/0002-no-async-task-queue.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# ADR-0002: Do Not Add an Async Task Queue Yet
|
||||
|
||||
Date: 2026-04-28
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
The initial booking flow requires email delivery and QR code generation. These operations are important, but the project does not yet require high-volume background processing, scheduled jobs, retries across many task types, or distributed workers.
|
||||
|
||||
Adding Celery, Redis, or another queue would increase deployment and operational complexity for a small theatre company website.
|
||||
|
||||
## Decision
|
||||
|
||||
Do not use Celery, Redis, or any asynchronous task queue at this stage.
|
||||
|
||||
The backend will perform initial email sending and QR code generation synchronously within the Django application, with clear error handling and logging that avoids sensitive data.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The deployment remains small: Django, PostgreSQL, Angular static assets, and nginx.
|
||||
- Local development and production operations are easier to understand.
|
||||
- Email provider latency or outages can affect booking and confirmation responses.
|
||||
- If email volume, retry needs, or long-running work become a real problem, the project can introduce a task queue later with a new ADR.
|
||||
26
docs/adr/0003-qr-code-token-strategy.md
Normal file
26
docs/adr/0003-qr-code-token-strategy.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# ADR-0003: Use Opaque Tokens in QR Codes
|
||||
|
||||
Date: 2026-04-28
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
Confirmed reservations need QR codes that visitors can show on a smartphone or printed page. Staff must be able to scan the QR code at the entrance and verify the reservation.
|
||||
|
||||
QR codes are easy to copy or share, so they must not expose personal data or encode reservation details directly.
|
||||
|
||||
## Decision
|
||||
|
||||
QR codes will contain only an opaque, random, non-guessable token or a verification URL containing that token.
|
||||
|
||||
The backend will resolve the token server-side, validate that the reservation is confirmed, and reject duplicate check-ins. Tokens must not contain names, email addresses, phone numbers, notes, or other personal data.
|
||||
|
||||
## Consequences
|
||||
|
||||
- QR codes remain privacy-preserving even if printed, forwarded, or photographed.
|
||||
- Token validation stays centralized in the backend.
|
||||
- Check-in requires backend availability at the venue.
|
||||
- Duplicate check-in prevention depends on reliable server-side state and database constraints.
|
||||
27
docs/adr/0004-email-confirmation-flow.md
Normal file
27
docs/adr/0004-email-confirmation-flow.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# ADR-0004: Use Email Confirmation for Reservations
|
||||
|
||||
Date: 2026-04-28
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
Public visitors can request seats for a specific performance. The system needs a simple way to verify that the requester controls the provided email address before issuing a confirmed reservation and QR code.
|
||||
|
||||
The system also needs to avoid overbooking when multiple pending reservations exist for the same performance.
|
||||
|
||||
## Decision
|
||||
|
||||
Create reservations as `pending` first, then send an email confirmation link containing an opaque confirmation token.
|
||||
|
||||
A reservation becomes `confirmed` only when the confirmation link is opened and the backend validates the token. Final capacity validation happens during confirmation inside a database transaction. A QR code is generated only after successful confirmation.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Visitors receive a clear confirmation step before the reservation is finalized.
|
||||
- Mistyped or unreachable email addresses are less likely to consume capacity.
|
||||
- Pending reservations do not guarantee seats.
|
||||
- A visitor may lose availability if other reservations are confirmed before they open the confirmation link.
|
||||
- The booking flow depends on email delivery working reliably enough for the audience.
|
||||
@@ -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.
|
||||
29
docs/adr/0006-staff-check-in-with-token-validation.md
Normal file
29
docs/adr/0006-staff-check-in-with-token-validation.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# ADR-0006: Staff Check-In with Token Validation
|
||||
|
||||
Date: 2026-04-28
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
Confirmed reservations need a simple entrance check-in process. Staff should be able to use a mobile-friendly web page to scan a visitor's QR code or enter the token manually.
|
||||
|
||||
QR codes may be shown on smartphones, printed, forwarded, or photographed, so they must not expose personal data. Check-in must be restricted to authenticated staff or admin users and must prevent duplicate entrance.
|
||||
|
||||
## Decision
|
||||
|
||||
Use staff-only Django REST Framework endpoints for QR verification preview and check-in confirmation.
|
||||
|
||||
The QR code will contain only an opaque verification token or URL. The backend will validate the token server-side, require a confirmed reservation, reject duplicate check-ins, and create or update a `CheckIn` record with timestamp and staff user on successful confirmation.
|
||||
|
||||
No Celery, Redis, or separate check-in service is required.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Staff can use a simple mobile web page for scanning or manual token entry.
|
||||
- QR codes remain privacy-preserving because they do not contain personal data.
|
||||
- Check-in decisions stay centralized in the Django backend and PostgreSQL state.
|
||||
- The entrance workflow depends on backend availability at the venue.
|
||||
- Duplicate check-ins can be blocked with application checks and database constraints.
|
||||
27
docs/adr/0007-use-docker-compose-for-deployment.md
Normal file
27
docs/adr/0007-use-docker-compose-for-deployment.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# ADR-0007: Use Docker Compose for Deployment
|
||||
|
||||
Date: 2026-04-28
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
AzioneLab needs a simple production-oriented deployment for a small theatre company website. The initial runtime services are Django with gunicorn, an Angular frontend served by nginx, PostgreSQL, and an nginx reverse proxy.
|
||||
|
||||
The project does not need Celery, Redis, a container orchestrator, or a more complex platform at this stage.
|
||||
|
||||
## Decision
|
||||
|
||||
Use Docker Compose as the initial deployment mechanism.
|
||||
|
||||
The Compose setup will define explicit `backend`, `frontend`, `postgres`, and `nginx` services under `infra/docker/compose.yml`. Configuration is provided through `.env`, PostgreSQL data is stored in a named volume, and only the reverse proxy publishes a host port.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The deployment remains easy to understand, run, and review.
|
||||
- The same topology can support local infrastructure checks and small production deployments.
|
||||
- PostgreSQL persistence is explicit through a named volume.
|
||||
- The setup can be replaced later if hosting or scaling needs outgrow Docker Compose.
|
||||
- Operators must manage `.env`, backups, TLS, and image updates carefully.
|
||||
383
docs/api-contract.md
Normal file
383
docs/api-contract.md
Normal file
@@ -0,0 +1,383 @@
|
||||
# API Contract
|
||||
|
||||
This document proposes the initial REST API for AzioneLab. Endpoint names are intentionally explicit and small-project friendly.
|
||||
|
||||
All examples use JSON unless noted otherwise.
|
||||
|
||||
## Public Content
|
||||
|
||||
### List Shows
|
||||
|
||||
```http
|
||||
GET /api/shows/
|
||||
```
|
||||
|
||||
Returns published shows.
|
||||
|
||||
Response `200 OK`:
|
||||
|
||||
```json
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "The Open Stage",
|
||||
"slug": "the-open-stage",
|
||||
"summary": "A contemporary theatre performance.",
|
||||
"poster_image": "https://example.org/media/shows/open-stage.jpg"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Show Detail
|
||||
|
||||
```http
|
||||
GET /api/shows/{slug}/
|
||||
```
|
||||
|
||||
Response `200 OK`:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"title": "The Open Stage",
|
||||
"slug": "the-open-stage",
|
||||
"summary": "A contemporary theatre performance.",
|
||||
"description": "Full public show description.",
|
||||
"poster_image": "https://example.org/media/shows/open-stage.jpg",
|
||||
"performances": [
|
||||
{
|
||||
"id": 10,
|
||||
"starts_at": "2026-05-15T20:30:00+02:00",
|
||||
"venue": {
|
||||
"name": "AzioneLab Theatre",
|
||||
"city": "Rome"
|
||||
},
|
||||
"booking_enabled": true,
|
||||
"available_seats": 24
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Status codes:
|
||||
|
||||
- `200 OK`: show found;
|
||||
- `404 Not Found`: show does not exist or is not published.
|
||||
|
||||
### List Performances
|
||||
|
||||
```http
|
||||
GET /api/performances/
|
||||
```
|
||||
|
||||
Optional filters:
|
||||
|
||||
- `show`: show slug;
|
||||
- `from`: start date/time lower bound.
|
||||
|
||||
Response `200 OK`:
|
||||
|
||||
```json
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"id": 10,
|
||||
"show": {
|
||||
"title": "The Open Stage",
|
||||
"slug": "the-open-stage"
|
||||
},
|
||||
"venue": {
|
||||
"name": "AzioneLab Theatre",
|
||||
"city": "Rome"
|
||||
},
|
||||
"starts_at": "2026-05-15T20:30:00+02:00",
|
||||
"booking_enabled": true,
|
||||
"available_seats": 24
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Performance Detail
|
||||
|
||||
```http
|
||||
GET /api/performances/{id}/
|
||||
```
|
||||
|
||||
Response `200 OK`:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 10,
|
||||
"show": {
|
||||
"title": "The Open Stage",
|
||||
"slug": "the-open-stage",
|
||||
"summary": "A contemporary theatre performance."
|
||||
},
|
||||
"venue": {
|
||||
"name": "AzioneLab Theatre",
|
||||
"address": "Via Example 10",
|
||||
"city": "Rome"
|
||||
},
|
||||
"starts_at": "2026-05-15T20:30:00+02:00",
|
||||
"booking_enabled": true,
|
||||
"available_seats": 24
|
||||
}
|
||||
```
|
||||
|
||||
Status codes:
|
||||
|
||||
- `200 OK`: performance found;
|
||||
- `404 Not Found`: performance does not exist or is not public.
|
||||
|
||||
## Booking
|
||||
|
||||
### Create Reservation
|
||||
|
||||
```http
|
||||
POST /api/performances/{id}/reservations/
|
||||
```
|
||||
|
||||
Creates a pending reservation and sends a confirmation email.
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Maria Rossi",
|
||||
"email": "maria.rossi@example.org",
|
||||
"phone": "+390600000000",
|
||||
"party_size": 2,
|
||||
"notes": "We will arrive a few minutes early."
|
||||
}
|
||||
```
|
||||
|
||||
Response `201 Created`:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 123,
|
||||
"status": "pending",
|
||||
"performance": 10,
|
||||
"party_size": 2,
|
||||
"message": "Reservation created. Please check your email to confirm it."
|
||||
}
|
||||
```
|
||||
|
||||
Status codes:
|
||||
|
||||
- `201 Created`: pending reservation created;
|
||||
- `400 Bad Request`: invalid input;
|
||||
- `404 Not Found`: performance does not exist or is not public;
|
||||
- `409 Conflict`: booking is closed or capacity is already unavailable.
|
||||
|
||||
Validation rules:
|
||||
|
||||
- `name` is required;
|
||||
- `email` must be a valid email address;
|
||||
- `party_size` must be a positive integer;
|
||||
- public clients must not set reservation status;
|
||||
- the backend must validate booking availability server-side.
|
||||
|
||||
### Confirm Reservation
|
||||
|
||||
```http
|
||||
POST /api/reservations/confirm/
|
||||
```
|
||||
|
||||
Confirms a pending reservation using the token from the email link.
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"token": "opaque-confirmation-token"
|
||||
}
|
||||
```
|
||||
|
||||
Response `200 OK`:
|
||||
|
||||
```json
|
||||
{
|
||||
"reservation_id": 123,
|
||||
"status": "confirmed",
|
||||
"party_size": 2,
|
||||
"qr_code_url": "https://example.org/api/check-ins/preview/?token=opaque-check-in-token"
|
||||
}
|
||||
```
|
||||
|
||||
Status codes:
|
||||
|
||||
- `200 OK`: reservation confirmed;
|
||||
- `400 Bad Request`: token is missing or malformed;
|
||||
- `404 Not Found`: token is unknown;
|
||||
- `409 Conflict`: token already used, reservation already handled, or no capacity remains;
|
||||
- `410 Gone`: token expired.
|
||||
|
||||
### Retrieve QR Code
|
||||
|
||||
```http
|
||||
GET /api/reservations/{id}/qr-code/
|
||||
```
|
||||
|
||||
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`:
|
||||
|
||||
```json
|
||||
{
|
||||
"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,...",
|
||||
"printable": true
|
||||
}
|
||||
```
|
||||
|
||||
Status codes:
|
||||
|
||||
- `200 OK`: QR code available;
|
||||
- `403 Forbidden`: caller is not allowed to access the QR code;
|
||||
- `404 Not Found`: reservation not found;
|
||||
- `409 Conflict`: reservation is not confirmed.
|
||||
|
||||
## Check-In
|
||||
|
||||
Check-in endpoints are for authenticated staff or admin users. Staff use a mobile-friendly Angular page to scan the QR code with a device camera or enter the token manually.
|
||||
|
||||
The QR code must contain only an opaque verification token or a verification URL containing that token. The backend resolves and validates the token server-side.
|
||||
|
||||
### QR Verification Preview
|
||||
|
||||
```http
|
||||
POST /api/check-ins/preview/
|
||||
```
|
||||
|
||||
Validates a QR token and returns a preview before staff confirms entrance. This endpoint must not create a successful check-in.
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"token": "opaque-check-in-token"
|
||||
}
|
||||
```
|
||||
|
||||
Response `200 OK`:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "valid",
|
||||
"reservation_id": 123,
|
||||
"performance_id": 10,
|
||||
"show_title": "The Open Stage",
|
||||
"venue_name": "AzioneLab Theatre",
|
||||
"starts_at": "2026-05-15T20:30:00+02:00",
|
||||
"party_size": 2
|
||||
}
|
||||
```
|
||||
|
||||
Status codes:
|
||||
|
||||
- `200 OK`: token is valid and reservation can be checked in;
|
||||
- `400 Bad Request`: token is missing or malformed;
|
||||
- `401 Unauthorized`: staff authentication is missing;
|
||||
- `403 Forbidden`: authenticated user cannot preview check-in;
|
||||
- `404 Not Found`: token is unknown;
|
||||
- `409 Conflict`: reservation is not confirmed or was already checked in;
|
||||
- `410 Gone`: token is expired.
|
||||
|
||||
The preview response should include only the minimum information staff need to validate the party. It must not expose unnecessary reservation personal data.
|
||||
|
||||
### Check-In Confirmation
|
||||
|
||||
```http
|
||||
POST /api/check-ins/confirm/
|
||||
```
|
||||
|
||||
Validates the token again and records successful entrance.
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"token": "opaque-check-in-token"
|
||||
}
|
||||
```
|
||||
|
||||
Response `200 OK`:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "checked_in",
|
||||
"reservation_id": 123,
|
||||
"performance_id": 10,
|
||||
"party_size": 2,
|
||||
"checked_in_at": "2026-05-15T19:55:00+02:00",
|
||||
"checked_in_by": 7
|
||||
}
|
||||
```
|
||||
|
||||
Status codes:
|
||||
|
||||
- `200 OK`: reservation checked in;
|
||||
- `400 Bad Request`: token is missing or malformed;
|
||||
- `401 Unauthorized`: staff authentication is missing;
|
||||
- `403 Forbidden`: authenticated user cannot confirm check-in;
|
||||
- `404 Not Found`: token is unknown;
|
||||
- `409 Conflict`: reservation is not confirmed or was already checked in;
|
||||
- `410 Gone`: token is expired.
|
||||
|
||||
Successful confirmation creates a `CheckIn` record, or updates an existing incomplete check-in record for the reservation. A reservation cannot have two successful check-ins.
|
||||
|
||||
Error responses should use clear machine-readable states so the staff interface can show simple messages.
|
||||
|
||||
Example `409 Conflict` for duplicate check-in:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "already_checked_in",
|
||||
"detail": "This reservation has already been checked in."
|
||||
}
|
||||
```
|
||||
|
||||
Example `409 Conflict` for unconfirmed reservation:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "reservation_not_confirmed",
|
||||
"detail": "This reservation is not confirmed."
|
||||
}
|
||||
```
|
||||
|
||||
## Administration
|
||||
|
||||
The initial administration API is Django admin.
|
||||
|
||||
Admin paths:
|
||||
|
||||
```http
|
||||
GET /admin/
|
||||
```
|
||||
|
||||
Admin users can manage shows, venues, performances, reservations, reservation tokens, and check-ins according to staff permissions.
|
||||
|
||||
Status codes:
|
||||
|
||||
- `302 Found`: unauthenticated browser redirected to admin login;
|
||||
- `200 OK`: authenticated admin page;
|
||||
- `403 Forbidden`: authenticated user lacks the required permission.
|
||||
|
||||
## Error Format
|
||||
|
||||
Use Django REST Framework's standard validation error format unless a project-specific envelope is introduced later.
|
||||
|
||||
Example `400 Bad Request`:
|
||||
|
||||
```json
|
||||
{
|
||||
"email": ["Enter a valid email address."],
|
||||
"party_size": ["Ensure this value is greater than or equal to 1."]
|
||||
}
|
||||
```
|
||||
@@ -1,13 +1,164 @@
|
||||
# Architecture
|
||||
|
||||
Describe the project architecture here.
|
||||
AzioneLab is a public website for a theatre company with a small booking system for performances.
|
||||
|
||||
Include:
|
||||
The architecture is intentionally simple: one Django backend, one Angular frontend, one PostgreSQL database, and nginx as the public reverse proxy. There is no Celery, Redis, message broker, or separate worker service at this stage.
|
||||
|
||||
- main components;
|
||||
- runtime dependencies;
|
||||
- data flow;
|
||||
- persistence;
|
||||
- external integrations;
|
||||
- deployment topology;
|
||||
- relevant ADRs.
|
||||
## Components
|
||||
|
||||
### Public frontend
|
||||
|
||||
The public frontend is an Angular application using Angular Material.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- render descriptive pages for the theatre company;
|
||||
- render the public list of shows;
|
||||
- render public show and performance detail pages;
|
||||
- provide the booking form for a selected performance;
|
||||
- show reservation submission, confirmation, and check-in feedback states;
|
||||
- call the backend through REST APIs.
|
||||
|
||||
The frontend must not calculate authoritative availability. It may display availability returned by the backend, but the backend remains responsible for final capacity validation.
|
||||
|
||||
### Backend API
|
||||
|
||||
The backend is a Django 5.2 LTS application using Django REST Framework.
|
||||
|
||||
The initial backend skeleton lives under `backend/` and includes the Django project, Django admin, the `shows`, `bookings`, and `checkins` apps, CORS configuration for the Angular frontend, PostgreSQL configuration through environment variables, and a health endpoint at `/api/health/`.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- expose public read APIs for shows, venues, and performances;
|
||||
- expose public booking and reservation confirmation APIs;
|
||||
- expose an authenticated check-in verification API;
|
||||
- provide Django admin or an equivalent authenticated administration area;
|
||||
- store reservations, tokens, and check-in state;
|
||||
- calculate performance availability server-side;
|
||||
- send reservation confirmation emails;
|
||||
- generate QR codes after reservation confirmation.
|
||||
|
||||
The backend runs with gunicorn in production.
|
||||
|
||||
### Administration area
|
||||
|
||||
The first administration area should use Django admin unless a custom Angular admin becomes necessary later.
|
||||
|
||||
Administrators can manage:
|
||||
|
||||
- shows;
|
||||
- venues;
|
||||
- performances;
|
||||
- total room capacity;
|
||||
- manually occupied seats;
|
||||
- optional additional seats available during booking;
|
||||
- reservation status and check-in records when operationally necessary.
|
||||
|
||||
Admin functionality must require authenticated staff access.
|
||||
|
||||
### Database
|
||||
|
||||
PostgreSQL is the system of record.
|
||||
|
||||
It stores:
|
||||
|
||||
- show and venue content;
|
||||
- performance scheduling and capacity configuration;
|
||||
- reservations and explicit reservation status;
|
||||
- reservation tokens used for confirmation and QR verification;
|
||||
- check-in records.
|
||||
|
||||
Capacity checks must happen inside database transactions to avoid overbooking when multiple users book at the same time.
|
||||
|
||||
### Email
|
||||
|
||||
The backend sends transactional emails for:
|
||||
|
||||
- reservation confirmation link after booking submission;
|
||||
- optional confirmation success email containing the QR code or QR verification link.
|
||||
|
||||
Email delivery can use Django's email backend configuration. No asynchronous email worker is required initially; failures should be logged without exposing tokens or personal data.
|
||||
|
||||
### QR code generation
|
||||
|
||||
The backend generates QR codes using a small Python library such as `qrcode` or `segno`.
|
||||
|
||||
QR codes must contain only an opaque token or verification URL. They must not contain names, email addresses, phone numbers, notes, or other personal data.
|
||||
|
||||
### nginx
|
||||
|
||||
nginx is the public entry point.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- terminate HTTP traffic, and TLS when configured;
|
||||
- serve the built Angular static assets;
|
||||
- reverse proxy API and admin requests to gunicorn;
|
||||
- serve static and media files according to the deployment configuration.
|
||||
|
||||
## Runtime Dependencies
|
||||
|
||||
Required runtime dependencies:
|
||||
|
||||
- Python;
|
||||
- Django 5.2 LTS;
|
||||
- Django REST Framework;
|
||||
- gunicorn;
|
||||
- PostgreSQL;
|
||||
- Angular;
|
||||
- Angular Material;
|
||||
- nginx;
|
||||
- Docker Compose;
|
||||
- a Python QR code library such as `qrcode` or `segno`;
|
||||
- an SMTP-compatible email provider or relay.
|
||||
|
||||
Not included at this stage:
|
||||
|
||||
- Celery;
|
||||
- Redis;
|
||||
- background workers;
|
||||
- separate search, cache, or queue services.
|
||||
|
||||
## Data Flow
|
||||
|
||||
1. A visitor opens the website through nginx.
|
||||
2. nginx serves the Angular frontend.
|
||||
3. The frontend calls public backend API endpoints for shows and performances.
|
||||
4. The visitor submits a booking form for a specific performance.
|
||||
5. The backend validates input, checks capacity server-side, creates a pending reservation, creates a confirmation token, and sends a confirmation email.
|
||||
6. The visitor opens the confirmation link.
|
||||
7. The backend validates the token, confirms the reservation if capacity is still available, and generates a QR code token or QR code image.
|
||||
8. The visitor presents the QR code at the venue using a smartphone or printed copy.
|
||||
9. Staff scans the QR code.
|
||||
10. The backend validates the token and records a check-in if the reservation is confirmed and not already checked in.
|
||||
|
||||
## Deployment Topology
|
||||
|
||||
The initial deployment uses Docker Compose with these services:
|
||||
|
||||
- `nginx`: public reverse proxy and static frontend server;
|
||||
- `frontend`: Angular build stage or static asset build source;
|
||||
- `backend`: Django application served by gunicorn;
|
||||
- `postgres`: PostgreSQL database.
|
||||
|
||||
Only nginx should be publicly exposed. The backend and database should be reachable only on the internal Compose network.
|
||||
|
||||
The initial Compose files live under `infra/docker/`. The backend and frontend images are placeholders until the Django and Angular applications are implemented.
|
||||
|
||||
## Architectural Constraints
|
||||
|
||||
- Keep the booking workflow synchronous and explicit.
|
||||
- Keep all capacity validation on the backend.
|
||||
- Store reservation status explicitly.
|
||||
- Use opaque, random, non-guessable tokens.
|
||||
- Do not place personal data in QR codes.
|
||||
- Avoid optional infrastructure until the project needs it.
|
||||
- Prefer Django admin for internal management before building custom admin UI.
|
||||
|
||||
## Relevant ADRs
|
||||
|
||||
- [ADR-0001: Use Django Monolith](adr/0001-use-django-monolith.md)
|
||||
- [ADR-0002: Do Not Add an Async Task Queue Yet](adr/0002-no-async-task-queue.md)
|
||||
- [ADR-0003: Use Opaque Tokens in QR Codes](adr/0003-qr-code-token-strategy.md)
|
||||
- [ADR-0004: Use Email Confirmation for Reservations](adr/0004-email-confirmation-flow.md)
|
||||
- [ADR-0007: Use Docker Compose for Deployment](adr/0007-use-docker-compose-for-deployment.md)
|
||||
|
||||
180
docs/booking-flow.md
Normal file
180
docs/booking-flow.md
Normal file
@@ -0,0 +1,180 @@
|
||||
# Booking Flow
|
||||
|
||||
This document describes the full public booking, confirmation, QR generation, and entrance check-in flow.
|
||||
|
||||
## 1. Public Discovery
|
||||
|
||||
1. A visitor opens the public website.
|
||||
2. The Angular frontend requests published shows and upcoming performances from the backend.
|
||||
3. The visitor opens a show detail page.
|
||||
4. The frontend displays upcoming public performances and availability returned by the backend.
|
||||
|
||||
Availability shown to visitors is informational. The backend recalculates availability when reservation state changes.
|
||||
|
||||
## 2. Booking Submission
|
||||
|
||||
1. The visitor selects a performance.
|
||||
2. The frontend displays the booking form for that performance.
|
||||
3. The visitor enters contact details and party size.
|
||||
4. The frontend submits the booking request to the backend.
|
||||
5. The backend validates:
|
||||
- required fields;
|
||||
- email format;
|
||||
- positive party size;
|
||||
- performance exists and booking is open;
|
||||
- requested seats do not exceed currently available seats.
|
||||
6. The backend creates a `pending` reservation.
|
||||
7. The backend creates a random opaque confirmation token.
|
||||
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.
|
||||
|
||||
The reservation is not confirmed at this stage.
|
||||
|
||||
## 3. Email Confirmation
|
||||
|
||||
1. The visitor opens the confirmation link from the email.
|
||||
2. The frontend or backend submits the confirmation token to the confirmation endpoint.
|
||||
3. The backend validates that the token:
|
||||
- exists;
|
||||
- has the `confirmation` purpose;
|
||||
- belongs to a pending reservation;
|
||||
- has not expired;
|
||||
- has not already been used.
|
||||
4. The backend starts a database transaction.
|
||||
5. The backend locks the related performance row.
|
||||
6. The backend recalculates confirmed reservations for the performance.
|
||||
7. The backend confirms the reservation only if enough seats remain.
|
||||
8. The backend marks the confirmation token as used.
|
||||
9. The backend creates a separate `check_in` token for QR verification.
|
||||
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.
|
||||
|
||||
If there is no longer enough capacity, the backend must not confirm the reservation.
|
||||
|
||||
## 4. QR Code Delivery
|
||||
|
||||
After confirmation, the visitor receives access to a QR code.
|
||||
|
||||
The QR code may be delivered as:
|
||||
|
||||
- an image displayed on the confirmation page;
|
||||
- an image or link in a confirmation email;
|
||||
- a printable page linked from the confirmation result.
|
||||
|
||||
The QR code must be usable from a smartphone screen or printed copy.
|
||||
|
||||
The QR code must contain:
|
||||
|
||||
- an opaque check-in token; or
|
||||
- a verification URL containing an opaque token.
|
||||
|
||||
The QR code must not contain:
|
||||
|
||||
- visitor name;
|
||||
- email address;
|
||||
- phone number;
|
||||
- notes;
|
||||
- party size if avoidable;
|
||||
- any other personal data.
|
||||
|
||||
## 5. Entrance Check-In
|
||||
|
||||
1. Staff signs in with an account that has check-in permission.
|
||||
2. Staff opens a mobile-friendly web page for entrance check-in.
|
||||
3. Staff scans the visitor's QR code with the device camera or enters the QR token manually.
|
||||
4. The QR code provides only an opaque check-in token or verification URL.
|
||||
5. The frontend submits the token to the backend for a verification preview.
|
||||
6. The backend validates:
|
||||
- staff authentication and permission;
|
||||
- token exists and has the `check_in` purpose;
|
||||
- reservation exists;
|
||||
- reservation is confirmed;
|
||||
- token is valid for the performance check-in window if such a window is configured;
|
||||
- reservation has not already been checked in.
|
||||
7. The backend returns a preview with only the minimum information needed for admission, such as performance, party size, and check-in state.
|
||||
8. Staff confirms check-in in the mobile web page.
|
||||
9. The backend validates the token again server-side.
|
||||
10. The backend creates a `CheckIn` record, or updates an existing incomplete check-in record for the same reservation.
|
||||
11. The backend stores the check-in timestamp and authenticated staff user.
|
||||
12. The backend returns a successful check-in response.
|
||||
13. Staff admits the visitor party.
|
||||
|
||||
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.
|
||||
|
||||
For operational testing or guest-support exceptions, Django admin also provides staff-only manual tools:
|
||||
|
||||
1. staff may confirm a pending reservation from the reservation admin page;
|
||||
2. manual confirmation still rechecks booking availability before confirming;
|
||||
3. the backend generates the same `check_in` token type used by the normal confirmation flow;
|
||||
4. admin can generate a one-time operational QR code and check-in URL without showing token hashes in normal admin screens;
|
||||
5. staff may mark a confirmed reservation as checked in from admin when the browser/mobile check-in flow is unavailable.
|
||||
|
||||
## Duplicate Check-In
|
||||
|
||||
If the same QR code is scanned again:
|
||||
|
||||
1. The backend detects an existing check-in for the reservation.
|
||||
2. The backend returns `409 Conflict`.
|
||||
3. The backend does not create a second check-in.
|
||||
|
||||
The response should be clear enough for staff to understand that the reservation was already used.
|
||||
|
||||
## Check-In Failure States
|
||||
|
||||
Failed validation must return clear error states without creating a successful check-in.
|
||||
|
||||
Expected check-in failures include:
|
||||
|
||||
- missing or malformed token;
|
||||
- unknown token;
|
||||
- expired token;
|
||||
- staff user is not authenticated;
|
||||
- staff user does not have check-in permission;
|
||||
- reservation is not confirmed;
|
||||
- reservation was already checked in;
|
||||
- token is not valid for the selected performance or check-in window.
|
||||
|
||||
## Capacity Handling
|
||||
|
||||
Capacity is calculated as:
|
||||
|
||||
```text
|
||||
room capacity + additional seats - manually occupied seats - confirmed reservations
|
||||
```
|
||||
|
||||
Only confirmed reservations consume capacity. Pending reservations represent interest, not a guaranteed seat.
|
||||
|
||||
To avoid overbooking, final confirmation must be transactional:
|
||||
|
||||
1. lock the performance;
|
||||
2. recalculate confirmed seats;
|
||||
3. compare availability with requested party size;
|
||||
4. confirm only when seats are available.
|
||||
|
||||
## Failure States
|
||||
|
||||
Expected failure states include:
|
||||
|
||||
- invalid booking input;
|
||||
- booking disabled for the performance;
|
||||
- no capacity remaining;
|
||||
- confirmation token expired;
|
||||
- confirmation token already used;
|
||||
- QR token invalid;
|
||||
- reservation not confirmed;
|
||||
- reservation already checked in.
|
||||
|
||||
Each failure should return a clear status code and a concise user-facing message.
|
||||
@@ -1,15 +1,222 @@
|
||||
# Deployment
|
||||
|
||||
Describe how this project is deployed.
|
||||
## Production Readiness
|
||||
|
||||
Include:
|
||||
Before a real deployment, treat `.env.example` as local-development only. Create a separate `.env` for production and replace all placeholder values.
|
||||
|
||||
- environments;
|
||||
- Docker/Compose usage;
|
||||
- required configuration;
|
||||
- secrets handling;
|
||||
- exposed ports;
|
||||
- volumes;
|
||||
- networks;
|
||||
- deployment commands;
|
||||
- rollback procedure.
|
||||
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 one 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:
|
||||
|
||||
- `nginx`: public reverse proxy and static frontend server;
|
||||
- `frontend`: Angular build source or build stage for static assets;
|
||||
- `backend`: Django 5.2 LTS application served by gunicorn;
|
||||
- `postgres`: PostgreSQL database.
|
||||
|
||||
Only nginx should expose public ports. The backend and database should stay on the internal Compose network.
|
||||
|
||||
The initial Compose setup is located at `infra/docker/compose.yml`.
|
||||
|
||||
## Services
|
||||
|
||||
### nginx
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- listen on public HTTP and HTTPS ports;
|
||||
- serve built Angular files;
|
||||
- proxy `/api/` and `/admin/` requests to the backend;
|
||||
- serve static and media files according to the selected storage layout;
|
||||
- apply request size and timeout limits appropriate for booking and admin usage.
|
||||
|
||||
Public ports:
|
||||
|
||||
- `80` for HTTP;
|
||||
- `443` for HTTPS in production.
|
||||
|
||||
### frontend
|
||||
|
||||
The frontend is an Angular application using Angular Material.
|
||||
|
||||
Deployment options:
|
||||
|
||||
- build the Angular app in a Docker build stage and copy static files into the nginx image;
|
||||
- or run a one-shot build container that writes static files to a shared volume consumed by nginx.
|
||||
|
||||
The first option is preferred for a simple production deployment because nginx can serve immutable built assets without a long-running Node process.
|
||||
|
||||
At the infrastructure placeholder stage, the `frontend` service serves a static placeholder page with nginx. The Angular build will replace this placeholder later.
|
||||
|
||||
### backend
|
||||
|
||||
The backend is a Django application served by gunicorn.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- REST API;
|
||||
- Django admin;
|
||||
- booking, confirmation, QR generation, and check-in logic;
|
||||
- transactional capacity validation;
|
||||
- email sending.
|
||||
|
||||
The backend should run database migrations before or during deployment through an explicit operational command, not as hidden startup magic unless that choice is documented later.
|
||||
|
||||
The `backend` service runs gunicorn against the Django WSGI application. The current backend is an initial skeleton with Django admin, Django REST Framework, CORS configuration, the `shows`, `bookings`, and `checkins` apps, and a health endpoint.
|
||||
|
||||
### postgres
|
||||
|
||||
PostgreSQL is the only database service.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- persistent application data;
|
||||
- reservation and check-in state;
|
||||
- transactional capacity enforcement.
|
||||
|
||||
Use a named Docker volume for database data.
|
||||
|
||||
## Networks
|
||||
|
||||
Recommended Compose networks:
|
||||
|
||||
- `public`: nginx-facing network when needed;
|
||||
- `internal`: private network for nginx, backend, and db communication.
|
||||
|
||||
The database should not be published to the host in production.
|
||||
|
||||
## Volumes
|
||||
|
||||
Recommended volumes:
|
||||
|
||||
- `postgres_data`: PostgreSQL data directory;
|
||||
- `media`: uploaded show images and generated QR assets if stored on disk;
|
||||
- `static`: collected Django static files if served by nginx from a shared volume.
|
||||
|
||||
Generated QR codes may also be generated on demand instead of stored as files. If stored, they must not reveal personal data and access must remain controlled.
|
||||
|
||||
## Configuration
|
||||
|
||||
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:
|
||||
|
||||
- `DJANGO_SECRET_KEY`;
|
||||
- `DJANGO_ALLOWED_HOSTS`;
|
||||
- `DJANGO_CSRF_TRUSTED_ORIGINS`;
|
||||
- `DJANGO_DEBUG=false`;
|
||||
- `CORS_ALLOWED_ORIGINS`;
|
||||
- `SITE_BASE_URL`;
|
||||
- `TIME_ZONE`;
|
||||
- `DATABASE_URL` or equivalent database settings;
|
||||
- email host, port, username, password, TLS settings, and sender address;
|
||||
- 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 `SITE_BASE_URL` as a single URL value, never a comma-separated list;
|
||||
- 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.
|
||||
- local/debug reservation email sends also log the confirmation URL so browser testing can continue even if SMTP is missing or fails.
|
||||
|
||||
Required database configuration:
|
||||
|
||||
- database name;
|
||||
- database user;
|
||||
- database password;
|
||||
- data volume path.
|
||||
|
||||
Required nginx configuration:
|
||||
|
||||
- upstream backend service name and port;
|
||||
- static frontend root;
|
||||
- proxy rules for `/api/` and `/admin/`;
|
||||
- media root for `/media/` if uploaded assets are served by nginx from a shared volume;
|
||||
- TLS certificate paths for production.
|
||||
|
||||
Secrets must be provided through deployment-managed environment variables, Docker secrets, or another secret manager. Do not commit real secret values.
|
||||
|
||||
## Example Request Routing
|
||||
|
||||
```text
|
||||
Visitor browser
|
||||
-> nginx
|
||||
-> Angular static files
|
||||
-> /api/ requests proxied to backend:gunicorn
|
||||
-> /admin/ requests proxied to backend:gunicorn
|
||||
Backend
|
||||
-> PostgreSQL
|
||||
-> SMTP provider
|
||||
```
|
||||
|
||||
## Deployment Commands
|
||||
|
||||
The exact commands will be finalized when application code and Compose files are added.
|
||||
|
||||
Expected production-style flow:
|
||||
|
||||
```bash
|
||||
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 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
|
||||
```
|
||||
|
||||
Expected validation commands:
|
||||
|
||||
```bash
|
||||
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 -f infra/docker/compose.yml run --rm backend python manage.py check --deploy
|
||||
docker compose --env-file .env -f infra/docker/compose.yml run --rm backend python manage.py test
|
||||
```
|
||||
|
||||
The canonical repository check for the current stage is:
|
||||
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
## Rollback
|
||||
|
||||
Rollback should be designed around immutable images and database backups.
|
||||
|
||||
Basic rollback steps:
|
||||
|
||||
1. identify the previous known-good image tags or Git commit;
|
||||
2. stop the current Compose stack;
|
||||
3. deploy the previous image tags or commit;
|
||||
4. restore the database from backup only if a migration or data change requires it;
|
||||
5. run smoke checks for public pages, booking creation, confirmation, and check-in.
|
||||
|
||||
Database rollback needs special care once migrations exist. Down migrations or backup restore procedures should be documented before production use.
|
||||
|
||||
## Operational Notes
|
||||
|
||||
- Configure database backups before accepting real bookings.
|
||||
- Back up the shared media volume together with the database if staff uploads show images.
|
||||
- Monitor backend errors, email delivery failures, and check-in failures.
|
||||
- Keep container images explicitly versioned; do not use `latest` tags.
|
||||
- Keep the system small until operational needs justify additional services.
|
||||
|
||||
193
docs/domain-model.md
Normal file
193
docs/domain-model.md
Normal file
@@ -0,0 +1,193 @@
|
||||
# Domain Model
|
||||
|
||||
This document describes the core domain concepts for AzioneLab's theatre website and booking system.
|
||||
|
||||
## Show
|
||||
|
||||
A show is a theatrical production presented by the company.
|
||||
|
||||
Suggested fields:
|
||||
|
||||
- `id`: internal identifier;
|
||||
- `title`: public show title;
|
||||
- `slug`: stable public URL identifier;
|
||||
- `summary`: short public description;
|
||||
- `description`: full public description;
|
||||
- `poster_image`: optional public image;
|
||||
- `is_published`: controls public visibility;
|
||||
- `created_at`: creation timestamp;
|
||||
- `updated_at`: last update timestamp.
|
||||
|
||||
Relationships:
|
||||
|
||||
- one show can have many performances.
|
||||
|
||||
Rules:
|
||||
|
||||
- unpublished shows are not listed publicly;
|
||||
- a public show detail page may include only published upcoming performances.
|
||||
|
||||
## Venue
|
||||
|
||||
A venue is the place where a performance happens.
|
||||
|
||||
Suggested fields:
|
||||
|
||||
- `id`: internal identifier;
|
||||
- `name`: public venue name;
|
||||
- `slug`: stable public URL identifier;
|
||||
- `address`: public address;
|
||||
- `city`: venue city;
|
||||
- `notes`: optional public or internal venue notes;
|
||||
- `created_at`: creation timestamp;
|
||||
- `updated_at`: last update timestamp.
|
||||
|
||||
Relationships:
|
||||
|
||||
- one venue can host many performances.
|
||||
|
||||
## Performance
|
||||
|
||||
A performance is a scheduled presentation of one show at one venue.
|
||||
|
||||
Suggested fields:
|
||||
|
||||
- `id`: internal identifier;
|
||||
- `show`: required reference to `Show`;
|
||||
- `venue`: required reference to `Venue`;
|
||||
- `starts_at`: performance date and time;
|
||||
- `room_capacity`: configured total room capacity;
|
||||
- `manually_occupied_seats`: seats unavailable because they are reserved outside the public booking system;
|
||||
- `additional_seats`: optional extra seats made available during booking;
|
||||
- `is_booking_enabled`: controls whether public booking is open;
|
||||
- `created_at`: creation timestamp;
|
||||
- `updated_at`: last update timestamp.
|
||||
|
||||
Relationships:
|
||||
|
||||
- each performance belongs to one show;
|
||||
- each performance belongs to one venue;
|
||||
- one performance can have many reservations.
|
||||
|
||||
Availability formula:
|
||||
|
||||
```text
|
||||
available seats =
|
||||
room capacity
|
||||
+ additional seats
|
||||
- manually occupied seats
|
||||
- confirmed reservations
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- capacity values must not be negative;
|
||||
- `manually_occupied_seats` must not exceed `room_capacity + additional_seats`;
|
||||
- only confirmed reservations reduce public availability;
|
||||
- pending reservations do not guarantee a seat until confirmation;
|
||||
- final capacity validation must happen server-side when confirming a reservation;
|
||||
- changes to capacity configuration must preserve existing confirmed reservations.
|
||||
|
||||
## Reservation
|
||||
|
||||
A reservation is a booking request for a specific performance.
|
||||
|
||||
Suggested fields:
|
||||
|
||||
- `id`: internal identifier;
|
||||
- `performance`: required reference to `Performance`;
|
||||
- `status`: explicit status such as `pending`, `confirmed`, `cancelled`, or `expired`;
|
||||
- `name`: reservation contact name;
|
||||
- `email`: reservation contact email;
|
||||
- `phone`: optional reservation contact phone;
|
||||
- `party_size`: number of requested seats;
|
||||
- `notes`: optional visitor note;
|
||||
- `confirmed_at`: timestamp set when the reservation is confirmed;
|
||||
- `qr_code_generated_at`: timestamp set when the QR code is generated;
|
||||
- `created_at`: creation timestamp;
|
||||
- `updated_at`: last update timestamp.
|
||||
|
||||
Relationships:
|
||||
|
||||
- each reservation belongs to one performance;
|
||||
- one reservation can have one or more reservation tokens for different purposes;
|
||||
- one confirmed reservation can have at most one successful check-in.
|
||||
|
||||
Rules:
|
||||
|
||||
- a new reservation starts as `pending`;
|
||||
- a reservation becomes `confirmed` only through a valid confirmation token;
|
||||
- a confirmed reservation receives a QR code;
|
||||
- `party_size` must be positive;
|
||||
- the backend must reject confirmation if the requested seats would exceed availability;
|
||||
- personal data must never be stored in QR codes.
|
||||
|
||||
## ReservationToken
|
||||
|
||||
A reservation token is an opaque token used for confirmation or QR verification.
|
||||
|
||||
Suggested fields:
|
||||
|
||||
- `id`: internal identifier;
|
||||
- `reservation`: required reference to `Reservation`;
|
||||
- `purpose`: token purpose, such as `confirmation` or `check_in`;
|
||||
- `token_hash`: server-side hash of the opaque token;
|
||||
- `expires_at`: optional expiration timestamp;
|
||||
- `used_at`: timestamp set when a one-time token is consumed;
|
||||
- `created_at`: creation timestamp.
|
||||
|
||||
Relationships:
|
||||
|
||||
- each token belongs to one reservation.
|
||||
|
||||
Rules:
|
||||
|
||||
- raw tokens must be random, non-guessable, and generated with a cryptographically secure generator;
|
||||
- store only a hash of the token when practical;
|
||||
- confirmation tokens should be single use;
|
||||
- QR tokens may remain valid until the performance check-in window closes;
|
||||
- tokens must not encode personal data.
|
||||
|
||||
## CheckIn
|
||||
|
||||
A check-in records entrance validation for a confirmed reservation.
|
||||
|
||||
Suggested fields:
|
||||
|
||||
- `id`: internal identifier;
|
||||
- `reservation`: required unique reference to `Reservation`;
|
||||
- `checked_in_at`: timestamp of successful check-in;
|
||||
- `checked_in_by`: required authenticated staff user reference for successful check-in;
|
||||
- `source`: optional source such as `qr_scan` or `manual`;
|
||||
- `created_at`: creation timestamp;
|
||||
- `updated_at`: last update timestamp.
|
||||
|
||||
Relationships:
|
||||
|
||||
- each check-in belongs to one reservation;
|
||||
- a reservation can have at most one successful check-in.
|
||||
|
||||
Rules:
|
||||
|
||||
- check-in is performed only by authenticated staff or admin users;
|
||||
- staff use a mobile-friendly web page to scan the QR code or enter the token manually;
|
||||
- the QR code contains only an opaque verification token or URL;
|
||||
- the backend validates the token server-side;
|
||||
- only confirmed reservations can be checked in;
|
||||
- a reservation cannot be checked in twice;
|
||||
- successful check-in creates a `CheckIn` record, or updates an existing incomplete check-in record for the same reservation;
|
||||
- successful check-in records must include `checked_in_at` and `checked_in_by`;
|
||||
- failed check-in attempts should return a clear status without changing successful check-in state;
|
||||
- check-in must not expose unnecessary personal data to scanning clients.
|
||||
|
||||
## Anti-Overbooking Rule
|
||||
|
||||
The backend must enforce capacity inside a transaction when confirming reservations.
|
||||
|
||||
Recommended approach:
|
||||
|
||||
- lock the relevant `Performance` row during confirmation;
|
||||
- count confirmed seats for that performance;
|
||||
- compare requested seats with available seats;
|
||||
- confirm only if enough seats remain;
|
||||
- otherwise leave the reservation pending or mark it as expired/rejected according to the future product decision.
|
||||
185
docs/security-notes.md
Normal file
185
docs/security-notes.md
Normal file
@@ -0,0 +1,185 @@
|
||||
# Security Notes
|
||||
|
||||
This document records security assumptions and controls for AzioneLab's initial architecture.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- The public website is readable by anonymous visitors.
|
||||
- Booking endpoints are public but must validate input strictly.
|
||||
- Administration and check-in functions require authenticated staff users.
|
||||
- HTTPS is expected in production.
|
||||
- PostgreSQL is reachable only from trusted application containers.
|
||||
- Email is sent through a configured SMTP provider or relay.
|
||||
|
||||
## Personal Data
|
||||
|
||||
Reservations contain personal data such as name, email address, optional phone number, and optional notes.
|
||||
|
||||
Controls:
|
||||
|
||||
- collect only data required to manage the reservation;
|
||||
- do not expose reservation personal data through public APIs;
|
||||
- do not include personal data in QR codes;
|
||||
- keep check-in preview and confirmation responses limited to operational admission data;
|
||||
- avoid logging request bodies from booking and confirmation endpoints;
|
||||
- avoid logging raw tokens;
|
||||
- restrict admin access to staff users who need it.
|
||||
|
||||
## Token Handling
|
||||
|
||||
Reservation tokens are used for email confirmation and QR verification.
|
||||
|
||||
Rules:
|
||||
|
||||
- generate tokens with a cryptographically secure random generator;
|
||||
- make tokens opaque and non-guessable;
|
||||
- do not encode personal data in tokens;
|
||||
- store token hashes when practical;
|
||||
- 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;
|
||||
- expire confirmation tokens after a reasonable period;
|
||||
- keep QR tokens valid only for the intended performance and check-in period where practical.
|
||||
|
||||
## QR Codes
|
||||
|
||||
QR codes must contain only:
|
||||
|
||||
- an opaque verification token; or
|
||||
- a verification URL containing an opaque token.
|
||||
|
||||
QR codes must not contain:
|
||||
|
||||
- name;
|
||||
- email;
|
||||
- phone;
|
||||
- notes;
|
||||
- reservation metadata that identifies the visitor.
|
||||
|
||||
The check-in endpoint resolves the token server-side and returns only the minimum information staff need.
|
||||
|
||||
## Check-In Security
|
||||
|
||||
Check-in is performed by authenticated staff or admin users through a mobile-friendly web page.
|
||||
|
||||
Controls:
|
||||
|
||||
- require staff authentication for QR verification preview and check-in confirmation;
|
||||
- allow QR scanning and manual token entry in the staff interface;
|
||||
- validate every token server-side;
|
||||
- require the reservation to be confirmed before check-in;
|
||||
- reject duplicate check-in attempts;
|
||||
- store successful check-in timestamp and staff user;
|
||||
- return clear validation states without exposing unnecessary personal data;
|
||||
- do not log raw QR tokens.
|
||||
|
||||
## Authentication and Authorization
|
||||
|
||||
Required controls:
|
||||
|
||||
- Django admin requires authenticated staff accounts;
|
||||
- check-in verification preview and confirmation require authenticated staff or admin users;
|
||||
- staff permissions should separate content management from operational check-in when practical;
|
||||
- public APIs must not allow clients to set protected fields such as reservation status, token values, or check-in state.
|
||||
- CORS must allow only configured Angular frontend origins through `CORS_ALLOWED_ORIGINS`.
|
||||
|
||||
## Input Validation
|
||||
|
||||
Public booking endpoints must validate:
|
||||
|
||||
- required fields;
|
||||
- email format;
|
||||
- maximum field lengths;
|
||||
- positive party size;
|
||||
- booking status for the selected performance;
|
||||
- capacity availability;
|
||||
- unexpected fields.
|
||||
|
||||
Validation must happen server-side even when the frontend also validates input.
|
||||
|
||||
## Anti-Overbooking
|
||||
|
||||
The backend must enforce capacity server-side.
|
||||
|
||||
Capacity calculation:
|
||||
|
||||
```text
|
||||
available seats =
|
||||
room capacity
|
||||
+ additional seats
|
||||
- manually occupied seats
|
||||
- confirmed reservations
|
||||
```
|
||||
|
||||
Controls:
|
||||
|
||||
- use database transactions for confirmation;
|
||||
- lock the performance row or use an equivalent consistency control;
|
||||
- recalculate availability inside the transaction;
|
||||
- confirm the reservation only when enough seats remain;
|
||||
- ensure duplicate confirmation requests are idempotent or rejected safely;
|
||||
- do not rely on frontend availability values.
|
||||
|
||||
## Secrets
|
||||
|
||||
Secrets must not be committed to the repository.
|
||||
|
||||
Expected secret configuration:
|
||||
|
||||
- Django `SECRET_KEY`;
|
||||
- database password;
|
||||
- SMTP credentials;
|
||||
- TLS private keys or certificate automation credentials, if used.
|
||||
|
||||
Use environment variables, Docker secrets, or deployment-managed secret injection. Documentation and example configuration should use placeholders only.
|
||||
|
||||
For the Docker Compose setup, copy `.env.example` to `.env` and replace placeholder values outside version control. The repository ignores `.env` and `.env.*` files except `.env.example`.
|
||||
|
||||
## Deployment Security
|
||||
|
||||
Deployment should follow least privilege:
|
||||
|
||||
- expose only nginx publicly;
|
||||
- keep backend and database on an internal Docker network;
|
||||
- do not publish backend, frontend, or PostgreSQL ports to the host in production;
|
||||
- avoid privileged containers;
|
||||
- use explicit image tags rather than `latest`;
|
||||
- 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;
|
||||
- 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
|
||||
|
||||
Logs should help diagnose operational issues without exposing sensitive data.
|
||||
|
||||
Do not log:
|
||||
|
||||
- raw confirmation tokens;
|
||||
- raw QR tokens;
|
||||
- full booking payloads;
|
||||
- passwords;
|
||||
- session cookies;
|
||||
- authorization headers;
|
||||
- SMTP credentials.
|
||||
|
||||
## Residual Risks
|
||||
|
||||
Initial residual risks:
|
||||
|
||||
- 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;
|
||||
- staff account compromise would expose admin and check-in functionality;
|
||||
- retention and deletion rules for personal data still need a project policy.
|
||||
@@ -1,16 +1,13 @@
|
||||
# Security
|
||||
|
||||
Describe security assumptions and controls.
|
||||
AzioneLab security assumptions and controls are documented in [security-notes.md](security-notes.md).
|
||||
|
||||
Include:
|
||||
The initial security model covers:
|
||||
|
||||
- authentication;
|
||||
- authorization;
|
||||
- network exposure;
|
||||
- TLS/certificates;
|
||||
- secrets management;
|
||||
- logging of sensitive data;
|
||||
- container privileges;
|
||||
- filesystem permissions;
|
||||
- dependency management;
|
||||
- relevant ADRs.
|
||||
- public website access;
|
||||
- authenticated administration and check-in;
|
||||
- reservation privacy;
|
||||
- opaque token handling;
|
||||
- QR code privacy;
|
||||
- server-side capacity validation;
|
||||
- deployment and logging assumptions.
|
||||
|
||||
@@ -7,7 +7,8 @@ All tests should run inside Docker containers.
|
||||
## Canonical test command
|
||||
|
||||
```bash
|
||||
CHANGE_ME
|
||||
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
|
||||
```
|
||||
|
||||
## Test categories
|
||||
@@ -21,3 +22,8 @@ Describe applicable categories:
|
||||
- Ansible syntax checks;
|
||||
- Docker/Compose validation;
|
||||
- smoke tests.
|
||||
|
||||
## Current coverage
|
||||
|
||||
- Docker Compose configuration validation;
|
||||
- Django backend unit tests, including the initial health endpoint test.
|
||||
|
||||
2
frontend/.gitignore
vendored
Normal file
2
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/dist
|
||||
/node_modules
|
||||
70
frontend/angular.json
Normal file
70
frontend/angular.json
Normal file
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"$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"
|
||||
},
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "src/assets",
|
||||
"output": "assets"
|
||||
}
|
||||
],
|
||||
"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
frontend/package.json
Normal file
32
frontend/package.json
Normal 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
frontend/public/favicon.ico
Normal file
1
frontend/public/favicon.ico
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
209
frontend/src/app/app.component.ts
Normal file
209
frontend/src/app/app.component.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
standalone: true,
|
||||
imports: [
|
||||
RouterOutlet,
|
||||
RouterLink,
|
||||
RouterLinkActive,
|
||||
MatButtonModule,
|
||||
],
|
||||
template: `
|
||||
<div class="app-shell">
|
||||
<header class="site-header">
|
||||
<div class="header-inner">
|
||||
<a class="brand" routerLink="/" aria-label="AzioneLab">
|
||||
<img class="brand-logo" src="assets/azionelab-logo.png" alt="AzioneLab" />
|
||||
</a>
|
||||
|
||||
<nav class="main-nav" aria-label="Primary navigation">
|
||||
<a mat-button routerLink="/" routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }">Inizio</a>
|
||||
<a mat-button routerLink="/shows" routerLinkActive="active">Spettacoli</a>
|
||||
<a mat-button routerLink="/check-in" routerLinkActive="active">Accoglienza</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="page-shell">
|
||||
<router-outlet></router-outlet>
|
||||
</main>
|
||||
|
||||
<footer class="site-footer">
|
||||
<div class="footer-inner">
|
||||
<div class="footer-copy-block">
|
||||
<p class="footer-title">AzioneLab</p>
|
||||
<p class="footer-copy">Laboratori teatrali & produzioni audio/visive</p>
|
||||
<p class="footer-meta">Direzione artistica: Ernesto Estatico</p>
|
||||
</div>
|
||||
<nav class="footer-nav" aria-label="Footer navigation">
|
||||
<a routerLink="/">Inizio</a>
|
||||
<a routerLink="/shows">Spettacoli</a>
|
||||
<a routerLink="/check-in">Accoglienza</a>
|
||||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.app-shell {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.topline-inner,
|
||||
.header-inner,
|
||||
.footer-inner {
|
||||
width: min(100%, var(--azionelab-shell-width));
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.site-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
background: var(--azionelab-surface);
|
||||
border-bottom: 1px solid var(--azionelab-border);
|
||||
}
|
||||
|
||||
.header-inner {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
min-height: 72px;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
flex: 0 0 auto;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
filter: none;
|
||||
opacity: 1;
|
||||
mix-blend-mode: normal;
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
width: 164px;
|
||||
height: auto;
|
||||
max-height: 44px;
|
||||
display: block;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
filter: none;
|
||||
opacity: 1;
|
||||
mix-blend-mode: normal;
|
||||
mask: none;
|
||||
-webkit-mask: none;
|
||||
}
|
||||
|
||||
.main-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.main-nav .active {
|
||||
color: var(--azionelab-accent-strong);
|
||||
}
|
||||
|
||||
.page-shell {
|
||||
flex: 1;
|
||||
padding: 40px 0 88px;
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
border-top: 1px solid var(--azionelab-border);
|
||||
background: var(--azionelab-bg-strong);
|
||||
}
|
||||
|
||||
.footer-inner {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.5fr) auto;
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
padding: 32px 24px 36px;
|
||||
}
|
||||
|
||||
.footer-title {
|
||||
margin: 0 0 8px;
|
||||
font-family: var(--azionelab-serif);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--azionelab-ink);
|
||||
}
|
||||
|
||||
.footer-copy {
|
||||
margin: 0 0 6px;
|
||||
max-width: 52ch;
|
||||
color: var(--azionelab-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.footer-meta {
|
||||
margin: 0;
|
||||
color: var(--azionelab-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.footer-nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.footer-nav a {
|
||||
color: var(--azionelab-ink-soft);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.header-inner {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
min-height: auto;
|
||||
padding: 14px 16px;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.main-nav {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.brand {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
width: 150px;
|
||||
max-height: 40px;
|
||||
}
|
||||
|
||||
.footer-inner {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 28px 16px 32px;
|
||||
}
|
||||
|
||||
.footer-nav {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class AppComponent {}
|
||||
18
frontend/src/app/app.routes.ts
Normal file
18
frontend/src/app/app.routes.ts
Normal 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: 'Spettacoli | AzioneLab' },
|
||||
{ path: 'shows/:slug', component: ShowDetailPlaceholderPageComponent, title: 'Scheda spettacolo | AzioneLab' },
|
||||
{ path: 'performances/:id/book', component: BookingPlaceholderPageComponent, title: 'Prenotazione | AzioneLab' },
|
||||
{ path: 'reservations/confirm', component: ReservationConfirmPageComponent, title: 'Conferma prenotazione | AzioneLab' },
|
||||
{ path: 'check-in', component: CheckInPlaceholderPageComponent, title: 'Check-in | AzioneLab' },
|
||||
{ path: '**', redirectTo: '' },
|
||||
];
|
||||
4
frontend/src/app/environments/environment.ts
Normal file
4
frontend/src/app/environments/environment.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export const environment = {
|
||||
production: true,
|
||||
apiBaseUrl: '/api',
|
||||
};
|
||||
409
frontend/src/app/pages/booking-placeholder-page.component.ts
Normal file
409
frontend/src/app/pages/booking-placeholder-page.component.ts
Normal file
@@ -0,0 +1,409 @@
|
||||
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">
|
||||
<div class="booking-shell">
|
||||
<header class="page-header">
|
||||
<p class="eyebrow">Prenotazione</p>
|
||||
<h1>Prenota il tuo posto</h1>
|
||||
<p class="supporting">
|
||||
Compila il modulo con i dati essenziali. Ti invieremo un'email per confermare la richiesta e completare la prenotazione con serenita'.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<mat-card class="content-card">
|
||||
<mat-card-content>
|
||||
@if (isSuccess()) {
|
||||
<div class="status-panel success" aria-live="polite">
|
||||
<div class="status-icon">
|
||||
<mat-icon fontSet="material-symbols-outlined">mark_email_read</mat-icon>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Controlla la tua email</h2>
|
||||
<p>Ti abbiamo appena inviato il link per confermare la prenotazione. Apri il messaggio, completa l'ultimo passaggio e tieni pronto il QR code che riceverai dopo la conferma.</p>
|
||||
<div class="status-steps">
|
||||
<span><mat-icon fontSet="material-symbols-outlined">mail</mat-icon> Apri l'email che ti abbiamo inviato</span>
|
||||
<span><mat-icon fontSet="material-symbols-outlined">verified</mat-icon> Conferma i posti con un tocco</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<form [formGroup]="bookingForm" (ngSubmit)="submit()" novalidate>
|
||||
<div class="intro-note">
|
||||
<mat-icon fontSet="material-symbols-outlined">info</mat-icon>
|
||||
<p>Ti chiediamo solo il necessario. La conferma via email ci aiuta a tenere la disponibilita' chiara per tutti.</p>
|
||||
</div>
|
||||
|
||||
<div class="form-grid">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-icon matPrefix fontSet="material-symbols-outlined">person</mat-icon>
|
||||
<mat-label>Nome</mat-label>
|
||||
<input matInput type="text" formControlName="name" autocomplete="name" />
|
||||
@if (bookingForm.controls.name.touched && bookingForm.controls.name.hasError('required')) {
|
||||
<mat-error>Il nome e' obbligatorio.</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-icon matPrefix fontSet="material-symbols-outlined">mail</mat-icon>
|
||||
<mat-label>Email</mat-label>
|
||||
<input matInput type="email" formControlName="email" autocomplete="email" />
|
||||
<mat-hint>Qui arrivera' il link per confermare la tua richiesta.</mat-hint>
|
||||
@if (bookingForm.controls.email.touched && bookingForm.controls.email.hasError('required')) {
|
||||
<mat-error>L'email e' obbligatoria.</mat-error>
|
||||
}
|
||||
@if (bookingForm.controls.email.touched && bookingForm.controls.email.hasError('email')) {
|
||||
<mat-error>Inserisci un indirizzo email valido.</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-icon matPrefix fontSet="material-symbols-outlined">group</mat-icon>
|
||||
<mat-label>Numero di posti</mat-label>
|
||||
<input matInput type="number" min="1" step="1" formControlName="partySize" />
|
||||
<mat-hint>Indica quante persone desideri includere nella prenotazione.</mat-hint>
|
||||
@if (bookingForm.controls.partySize.touched && bookingForm.controls.partySize.hasError('required')) {
|
||||
<mat-error>Il numero di posti e' obbligatorio.</mat-error>
|
||||
}
|
||||
@if (bookingForm.controls.partySize.touched && bookingForm.controls.partySize.hasError('min')) {
|
||||
<mat-error>Devi richiedere almeno 1 posto.</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
@if (submitError()) {
|
||||
<div class="message-panel error" aria-live="assertive">
|
||||
<mat-icon fontSet="material-symbols-outlined">error</mat-icon>
|
||||
<p>{{ submitError() }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (fieldErrors().length > 0) {
|
||||
<div class="message-panel error field-errors" aria-live="assertive">
|
||||
<mat-icon fontSet="material-symbols-outlined">warning</mat-icon>
|
||||
<div>
|
||||
<p class="message-title">Controlla i dati evidenziati:</p>
|
||||
@for (message of fieldErrors(); track message) {
|
||||
<p>{{ message }}</p>
|
||||
}
|
||||
</div>
|
||||
</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>Invio in corso...</span>
|
||||
} @else {
|
||||
<ng-container>
|
||||
<mat-icon fontSet="material-symbols-outlined">confirmation_number</mat-icon>
|
||||
<span>Conferma prenotazione</span>
|
||||
</ng-container>
|
||||
}
|
||||
</button>
|
||||
<a mat-button routerLink="/shows">Torna agli spettacoli</a>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
</section>
|
||||
`,
|
||||
styles: [`
|
||||
.booking-shell {
|
||||
width: min(100%, 480px);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin: 0 0 28px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 3rem;
|
||||
}
|
||||
|
||||
.supporting {
|
||||
margin: 16px auto 0;
|
||||
max-width: 42ch;
|
||||
}
|
||||
|
||||
.content-card {
|
||||
border-radius: var(--azionelab-radius-lg);
|
||||
border: 1px solid var(--azionelab-border);
|
||||
background: var(--azionelab-surface);
|
||||
box-shadow: var(--azionelab-shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
mat-card-content {
|
||||
padding: 32px !important;
|
||||
}
|
||||
|
||||
.intro-note {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 16px;
|
||||
background: rgba(159, 47, 40, 0.06);
|
||||
color: var(--azionelab-muted);
|
||||
}
|
||||
|
||||
.intro-note p {
|
||||
margin: 0;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.intro-note mat-icon {
|
||||
color: var(--azionelab-accent);
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
mat-form-field {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.message-panel {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-top: 14px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.message-panel.error {
|
||||
background: var(--azionelab-error-bg);
|
||||
border-color: var(--azionelab-error-border);
|
||||
color: var(--azionelab-error-ink);
|
||||
}
|
||||
|
||||
.message-panel p,
|
||||
.field-errors p {
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.message-title {
|
||||
font-weight: 700;
|
||||
margin-bottom: 6px !important;
|
||||
}
|
||||
|
||||
.field-errors > div {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin-top: 22px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.actions button[mat-flat-button] {
|
||||
min-width: 220px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.status-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 18px;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.status-panel h2 {
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
|
||||
.status-panel p {
|
||||
margin: 0;
|
||||
color: var(--azionelab-muted);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.status-panel.success {
|
||||
padding: 26px 22px;
|
||||
border-radius: 18px;
|
||||
background: var(--azionelab-success-bg);
|
||||
border: 1px solid var(--azionelab-success-border);
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 16px;
|
||||
background: rgba(46, 125, 50, 0.12);
|
||||
}
|
||||
|
||||
.status-icon mat-icon {
|
||||
color: var(--azionelab-success-ink);
|
||||
}
|
||||
|
||||
.status-steps {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.status-steps span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
color: var(--azionelab-success-ink);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.status-steps mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
h1 {
|
||||
font-size: 2.3rem;
|
||||
}
|
||||
|
||||
mat-card-content {
|
||||
padding: 22px !important;
|
||||
}
|
||||
|
||||
.status-panel,
|
||||
.message-panel,
|
||||
.intro-note {
|
||||
border-radius: 14px;
|
||||
}
|
||||
}
|
||||
`],
|
||||
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('Non siamo riusciti a inviare la richiesta in questo momento. Riprova tra poco.');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private flattenValidationErrors(errors: ApiValidationErrors): string[] {
|
||||
return Object.entries(errors).flatMap(([field, messages]) => {
|
||||
const labelMap: Record<string, string> = {
|
||||
name: 'nome',
|
||||
email: 'email',
|
||||
party_size: 'numero di posti',
|
||||
};
|
||||
const label = labelMap[field] ?? field;
|
||||
return messages.map((message) => `${label}: ${this.translateValidationMessage(message)}`);
|
||||
});
|
||||
}
|
||||
|
||||
private translateValidationMessage(message: string): string {
|
||||
const translations: Record<string, string> = {
|
||||
'This field is required.': 'questo campo e\' obbligatorio.',
|
||||
'Enter a valid email address.': 'inserisci un indirizzo email valido.',
|
||||
'Ensure this value is greater than or equal to 1.': 'inserisci un valore maggiore o uguale a 1.',
|
||||
};
|
||||
|
||||
return translations[message] ?? message;
|
||||
}
|
||||
}
|
||||
635
frontend/src/app/pages/check-in-placeholder-page.component.ts
Normal file
635
frontend/src/app/pages/check-in-placeholder-page.component.ts
Normal file
@@ -0,0 +1,635 @@
|
||||
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 { ActivatedRoute, 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">Accoglienza staff</p>
|
||||
<h1>Controllo accessi</h1>
|
||||
<p class="supporting">Usa questa pagina per verificare rapidamente il QR code o il token della prenotazione e registrare l'ingresso senza incertezze.</p>
|
||||
</header>
|
||||
|
||||
<div class="checkin-grid">
|
||||
<mat-card class="side-card">
|
||||
<mat-card-content>
|
||||
<p class="side-label">Ingresso sala</p>
|
||||
<h2>Uno strumento pensato per accogliere bene, anche nei momenti piu' intensi.</h2>
|
||||
<ul class="side-list">
|
||||
<li>Inquadra il QR code se la fotocamera del dispositivo e' disponibile.</li>
|
||||
<li>Inserisci il token a mano se la scansione non e' praticabile.</li>
|
||||
<li>Conferma l'ingresso solo quando i dati a schermo corrispondono alla prenotazione del pubblico.</li>
|
||||
</ul>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
||||
<mat-card class="content-card">
|
||||
<mat-card-content>
|
||||
<section class="scanner-panel">
|
||||
<div class="scanner-copy">
|
||||
<h2>Scansione con fotocamera</h2>
|
||||
<p>Nei browser compatibili il token viene letto automaticamente dal QR code, anche quando contiene l'intero link di check-in.</p>
|
||||
</div>
|
||||
|
||||
<div class="actions scanner-actions">
|
||||
@if (cameraState() === 'active') {
|
||||
<button mat-stroked-button type="button" (click)="stopScanner()">Ferma fotocamera</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>Avvio fotocamera...</span>
|
||||
} @else {
|
||||
<span>Usa fotocamera</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>Token opaco</mat-label>
|
||||
<input matInput formControlName="token" autocomplete="off" />
|
||||
@if (tokenForm.controls.token.touched && tokenForm.controls.token.hasError('required')) {
|
||||
<mat-error>Il token e' obbligatorio.</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>Verifica in corso...</span>
|
||||
} @else {
|
||||
<span>Controlla prenotazione</span>
|
||||
}
|
||||
</button>
|
||||
<a mat-button routerLink="/">Inizio</a>
|
||||
<a mat-button routerLink="/shows">Spettacoli</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@if (previewData() && shouldShowPreview()) {
|
||||
<section class="preview-panel" aria-live="polite">
|
||||
<h2>Dati per l'ingresso</h2>
|
||||
<dl>
|
||||
<div><dt>Spettacolo</dt><dd>{{ previewData()!.show_title }}</dd></div>
|
||||
<div><dt>Spazio</dt><dd>{{ previewData()!.venue_name }}</dd></div>
|
||||
<div><dt>Inizio</dt><dd>{{ previewData()!.starts_at | date: 'EEEE d MMMM, HH:mm' }}</dd></div>
|
||||
<div><dt>Posti</dt><dd>{{ previewData()!.party_size }}</dd></div>
|
||||
<div><dt>Prenotazione</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>Registrazione in corso...</span>
|
||||
} @else if (state() === 'confirm_success') {
|
||||
<span>Ingresso registrato</span>
|
||||
} @else {
|
||||
<span>Registra ingresso</span>
|
||||
}
|
||||
</button>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (state() === 'confirm_success' && confirmData()) {
|
||||
<p class="success-message" aria-live="polite">
|
||||
Ingresso registrato alle {{ confirmData()!.checked_in_at | date: 'HH:mm' }}.
|
||||
</p>
|
||||
}
|
||||
|
||||
@if (state() === 'invalid_token') {
|
||||
<p class="error-message" aria-live="assertive">Il token inserito non e' valido.</p>
|
||||
}
|
||||
@if (state() === 'pending_reservation') {
|
||||
<p class="error-message" aria-live="assertive">La prenotazione non e' ancora stata confermata dal pubblico.</p>
|
||||
}
|
||||
@if (state() === 'already_checked_in') {
|
||||
<p class="error-message" aria-live="assertive">Questa prenotazione risulta gia' registrata in ingresso.</p>
|
||||
}
|
||||
@if (state() === 'unauthorized') {
|
||||
<p class="error-message" aria-live="assertive">Non sei autorizzato. Accedi a <code>/admin</code> con un account staff, lascia ricaricare la pagina con quella sessione e poi riprova.</p>
|
||||
}
|
||||
@if (state() === 'error') {
|
||||
<p class="error-message" aria-live="assertive">Non siamo riusciti a completare la verifica. Riprova tra poco.</p>
|
||||
}
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
</section>
|
||||
`,
|
||||
styles: [`
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.supporting {
|
||||
max-width: 50ch;
|
||||
}
|
||||
|
||||
.checkin-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 300px) minmax(0, 1fr);
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.side-card,
|
||||
.content-card {
|
||||
border-radius: var(--azionelab-radius-lg);
|
||||
border: 1px solid var(--azionelab-border);
|
||||
background: var(--azionelab-surface-strong);
|
||||
box-shadow: var(--azionelab-shadow);
|
||||
}
|
||||
|
||||
.side-card {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 252, 248, 0.98), rgba(247, 238, 227, 0.94));
|
||||
}
|
||||
|
||||
.side-label {
|
||||
margin: 0 0 10px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--azionelab-accent);
|
||||
}
|
||||
|
||||
.side-card h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.side-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin: 18px 0 0;
|
||||
padding-left: 18px;
|
||||
color: var(--azionelab-ink-soft);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.checkin-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class CheckInPlaceholderPageComponent {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly formBuilder = inject(FormBuilder);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
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
|
||||
? 'Apri la fotocamera per scansionare un QR code, oppure continua con l\'inserimento manuale del token.'
|
||||
: 'La scansione con fotocamera non e\' disponibile in questo browser. Puoi comunque inserire il token manualmente.',
|
||||
);
|
||||
|
||||
constructor() {
|
||||
this.destroyRef.onDestroy(() => this.stopScanner());
|
||||
const tokenFromQuery = this.route.snapshot.queryParamMap.get('token')?.trim() ?? '';
|
||||
if (tokenFromQuery) {
|
||||
this.tokenForm.controls.token.setValue(tokenFromQuery);
|
||||
this.tokenForm.controls.token.markAsTouched();
|
||||
this.preview();
|
||||
}
|
||||
}
|
||||
|
||||
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('La scansione con fotocamera non e\' disponibile in questo browser. Puoi comunque inserire il token manualmente.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.stopScanner();
|
||||
this.cameraState.set('starting');
|
||||
this.cameraMessage.set('Avvio della fotocamera in corso...');
|
||||
|
||||
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('Inquadra il QR code del visitatore.');
|
||||
this.scheduleScan();
|
||||
} catch (error) {
|
||||
this.stopScanner();
|
||||
|
||||
if (error instanceof DOMException && (error.name === 'NotAllowedError' || error.name === 'SecurityError')) {
|
||||
this.cameraState.set('denied');
|
||||
this.cameraMessage.set('L\'accesso alla fotocamera e\' stato negato. Puoi continuare con l\'inserimento manuale del token.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.cameraState.set('error');
|
||||
this.cameraMessage.set('Non siamo riusciti ad avviare la fotocamera. Puoi continuare con l\'inserimento manuale del token.');
|
||||
}
|
||||
}
|
||||
|
||||
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('Fotocamera fermata. Puoi riavviare la scansione oppure continuare con l\'inserimento manuale del token.');
|
||||
}
|
||||
}
|
||||
|
||||
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('La scansione non e\' disponibile in questo momento. Inserisci il token manualmente.');
|
||||
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 acquisito. Verifica del token in corso...');
|
||||
this.stopScanner();
|
||||
this.preview();
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
this.cameraState.set('error');
|
||||
this.cameraMessage.set('La scansione non e\' andata a buon fine. Inserisci il token manualmente.');
|
||||
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');
|
||||
}
|
||||
}
|
||||
231
frontend/src/app/pages/home-page.component.ts
Normal file
231
frontend/src/app/pages/home-page.component.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [RouterLink, MatButtonModule, MatCardModule],
|
||||
template: `
|
||||
<section class="hero">
|
||||
<div class="hero-inner page">
|
||||
<div class="hero-copy">
|
||||
<p class="hero-kicker">AZIONELAB</p>
|
||||
<h1>Laboratori teatrali & produzioni audio/visive</h1>
|
||||
<p class="hero-direction">Direzione artistica a cura di Ernesto Estatico</p>
|
||||
<p class="supporting">
|
||||
Un luogo di ricerca, presenza e relazione, dove il teatro incontra la formazione e la scena si apre al pubblico con un ritmo piu' umano, piu' vicino, piu' vivo.
|
||||
</p>
|
||||
<div class="hero-actions">
|
||||
<a mat-flat-button color="primary" routerLink="/shows">Scopri gli spettacoli</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hero-aside" aria-hidden="true">
|
||||
<p>Programmazioni, laboratori e attraversamenti scenici pensati per spazi raccolti e sguardi attenti.</p>
|
||||
<span>AzioneLab abita il tempo dell'incontro prima ancora di quello della prenotazione.</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="overview page">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Uno spazio da attraversare</p>
|
||||
<h2>Il sito accompagna il pubblico verso gli spettacoli senza perdere il tono di una compagnia teatrale</h2>
|
||||
</div>
|
||||
<p class="supporting">Ogni passaggio resta leggibile e misurato: si guarda, si sceglie, si prenota, si arriva in sala con la sensazione di essere attesi.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-grid">
|
||||
<mat-card class="feature-card">
|
||||
<mat-card-title>Una programmazione da leggere con calma</mat-card-title>
|
||||
<mat-card-content>
|
||||
<p>Le schede mettono in evidenza i dettagli utili senza appesantire la scena: titolo, sintesi, immagini e accesso alla prenotazione.</p>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
<mat-card class="feature-card">
|
||||
<mat-card-title>Una prenotazione semplice e rassicurante</mat-card-title>
|
||||
<mat-card-content>
|
||||
<p>La conferma via email mantiene il percorso leggero per chi prenota e affidabile per chi organizza la capienza.</p>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
<mat-card class="feature-card">
|
||||
<mat-card-title>Un'accoglienza pensata per il lavoro in sala</mat-card-title>
|
||||
<mat-card-content>
|
||||
<p>Dall'ingresso alla verifica del QR code, tutto resta discreto, chiaro e adatto a un contesto teatrale.</p>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="journey page">
|
||||
<div class="journey-copy">
|
||||
<p class="eyebrow">Il percorso del pubblico</p>
|
||||
<h2>Dalla scoperta dello spettacolo all'ingresso, in pochi passaggi essenziali</h2>
|
||||
</div>
|
||||
<ol class="journey-steps">
|
||||
<li>Esplora gli spettacoli in programma e apri la scheda che ti incuriosisce.</li>
|
||||
<li>Invia la richiesta di prenotazione e confermala dall'email ricevuta.</li>
|
||||
<li>Porta con te il QR code sul telefono o su carta e raggiungi la sala con semplicita'.</li>
|
||||
</ol>
|
||||
</section>
|
||||
`,
|
||||
styles: [`
|
||||
.hero {
|
||||
width: 100%;
|
||||
margin: 0 0 72px;
|
||||
padding: 56px 0 72px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(47, 125, 87, 0.05), rgba(47, 125, 87, 0) 42%),
|
||||
linear-gradient(180deg, #fcfbf8 0%, #f8f6f0 100%);
|
||||
border-bottom: 1px solid var(--azionelab-border);
|
||||
}
|
||||
|
||||
.hero-inner {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.3fr) minmax(240px, 0.7fr);
|
||||
gap: 36px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.hero-copy {
|
||||
padding-top: 18px;
|
||||
}
|
||||
|
||||
.hero-kicker {
|
||||
margin: 0 0 18px;
|
||||
color: var(--azionelab-accent);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
max-width: 11ch;
|
||||
font-size: 4.3rem;
|
||||
line-height: 0.96;
|
||||
}
|
||||
|
||||
.hero-direction {
|
||||
margin: 20px 0 0;
|
||||
color: var(--azionelab-ink-soft);
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
margin-top: 34px;
|
||||
}
|
||||
|
||||
.hero-aside {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
padding: 0 0 10px;
|
||||
color: var(--azionelab-muted);
|
||||
}
|
||||
|
||||
.hero-aside p,
|
||||
.hero-aside span {
|
||||
margin: 0;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.hero-aside span {
|
||||
max-width: 24ch;
|
||||
}
|
||||
|
||||
.overview,
|
||||
.journey {
|
||||
margin-top: 0;
|
||||
margin-bottom: 72px;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(300px, 460px);
|
||||
gap: 28px;
|
||||
align-items: end;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.section-heading h2,
|
||||
.journey-copy h2 {
|
||||
margin: 0;
|
||||
max-width: 16ch;
|
||||
}
|
||||
|
||||
.feature-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
border-radius: var(--azionelab-radius-md);
|
||||
border: 1px solid var(--azionelab-border);
|
||||
background: var(--azionelab-surface);
|
||||
box-shadow: var(--azionelab-shadow);
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
min-height: 220px;
|
||||
}
|
||||
|
||||
.feature-card mat-card-title {
|
||||
margin-bottom: 12px;
|
||||
font-family: var(--azionelab-serif);
|
||||
font-size: 1.28rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.feature-card p {
|
||||
margin: 0;
|
||||
color: var(--azionelab-muted);
|
||||
line-height: 1.72;
|
||||
}
|
||||
|
||||
.journey-copy {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.journey-steps {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 20px;
|
||||
margin: 0;
|
||||
padding-left: 22px;
|
||||
color: var(--azionelab-ink-soft);
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.hero-inner,
|
||||
.section-heading,
|
||||
.journey-steps,
|
||||
.feature-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.hero {
|
||||
margin-bottom: 56px;
|
||||
padding: 40px 0 56px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2.4rem;
|
||||
}
|
||||
}
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class HomePageComponent {
|
||||
}
|
||||
412
frontend/src/app/pages/reservation-confirm-page.component.ts
Normal file
412
frontend/src/app/pages/reservation-confirm-page.component.ts
Normal file
@@ -0,0 +1,412 @@
|
||||
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">
|
||||
<div class="confirmation-shell">
|
||||
<header class="page-header">
|
||||
<p class="eyebrow">Conferma prenotazione</p>
|
||||
<h1>Prenotazione confermata</h1>
|
||||
<p class="supporting">Quando la conferma va a buon fine, il tuo QR code e' pronto per accompagnarti all'ingresso in sala.</p>
|
||||
</header>
|
||||
|
||||
<mat-card class="status-card">
|
||||
<mat-card-content>
|
||||
@if (state() === 'loading') {
|
||||
<div class="status-panel loading" aria-live="polite">
|
||||
<mat-progress-spinner mode="indeterminate" diameter="36"></mat-progress-spinner>
|
||||
<div>
|
||||
<h2>Stiamo completando la tua conferma...</h2>
|
||||
<p>Un attimo ancora, stiamo verificando il link ricevuto via email.</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (state() === 'success' && confirmation()) {
|
||||
<div class="status-panel success" aria-live="polite">
|
||||
<div class="status-icon">
|
||||
<mat-icon fontSet="material-symbols-outlined">verified</mat-icon>
|
||||
</div>
|
||||
<div>
|
||||
<h2>I tuoi posti sono confermati</h2>
|
||||
<p>Perfetto: la prenotazione e' andata a buon fine. Tieni questo QR code a portata di mano e mostralo all'ingresso quando arrivi.</p>
|
||||
<div class="success-points">
|
||||
<span><mat-icon fontSet="material-symbols-outlined">qr_code_2</mat-icon> QR pronto da mostrare</span>
|
||||
<span><mat-icon fontSet="material-symbols-outlined">theater_comedy</mat-icon> Ti aspettiamo in sala</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (confirmation()!.qr_code_image) {
|
||||
<div class="qr-panel">
|
||||
<p class="panel-label">Il tuo QR code di ingresso</p>
|
||||
<img [src]="confirmation()!.qr_code_image" alt="QR code della prenotazione" />
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (confirmation()!.qr_code_url) {
|
||||
<div class="meta-card">
|
||||
<mat-icon fontSet="material-symbols-outlined">link</mat-icon>
|
||||
<p>Link di accesso: <a [href]="confirmation()!.qr_code_url">{{ confirmation()!.qr_code_url }}</a></p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="next-steps">
|
||||
<div>
|
||||
<p class="step-label">Portalo con te</p>
|
||||
<p>Conserva il QR code sul telefono oppure stampalo. All'ingresso bastera' mostrarlo allo staff.</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="step-label">Tieni l'email a portata di mano</p>
|
||||
<p>Se ne avrai bisogno, potrai riaprire questa pagina in qualsiasi momento dal messaggio di conferma.</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (state() === 'invalid') {
|
||||
<div class="status-panel error" aria-live="assertive">
|
||||
<div class="status-icon">
|
||||
<mat-icon fontSet="material-symbols-outlined">error</mat-icon>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Link di conferma non valido</h2>
|
||||
<p>Questo link non risulta valido. Ti consigliamo di usare l'ultimo messaggio ricevuto via email.</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (state() === 'expired') {
|
||||
<div class="status-panel warning" aria-live="assertive">
|
||||
<div class="status-icon">
|
||||
<mat-icon fontSet="material-symbols-outlined">schedule</mat-icon>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Link di conferma scaduto</h2>
|
||||
<p>Il link che hai aperto non e' piu' attivo. Ti chiediamo di creare una nuova prenotazione.</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (state() === 'error') {
|
||||
<div class="status-panel error" aria-live="assertive">
|
||||
<div class="status-icon">
|
||||
<mat-icon fontSet="material-symbols-outlined">warning</mat-icon>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Non siamo riusciti a completare la conferma</h2>
|
||||
<p>Riprova tra qualche istante: il tuo link potrebbe avere bisogno di un nuovo tentativo.</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</mat-card-content>
|
||||
|
||||
<mat-card-actions>
|
||||
<a mat-button routerLink="/shows">Torna agli spettacoli</a>
|
||||
</mat-card-actions>
|
||||
</mat-card>
|
||||
</div>
|
||||
</section>
|
||||
`,
|
||||
styles: [`
|
||||
.confirmation-shell {
|
||||
width: min(100%, 700px);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 28px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 3rem;
|
||||
}
|
||||
|
||||
.supporting {
|
||||
max-width: 40ch;
|
||||
margin: 16px auto 0;
|
||||
}
|
||||
|
||||
.status-card {
|
||||
border-radius: var(--azionelab-radius-lg);
|
||||
border: 1px solid var(--azionelab-border);
|
||||
background: var(--azionelab-surface-strong);
|
||||
box-shadow: var(--azionelab-shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
mat-card-content {
|
||||
padding: 28px !important;
|
||||
}
|
||||
|
||||
mat-card-actions {
|
||||
padding: 0 28px 28px !important;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.status-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 18px;
|
||||
padding: 24px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.status-panel h2 {
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
|
||||
.status-panel p {
|
||||
margin: 0;
|
||||
color: var(--azionelab-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.status-panel.loading {
|
||||
background: rgba(159, 47, 40, 0.04);
|
||||
border-color: rgba(159, 47, 40, 0.1);
|
||||
}
|
||||
|
||||
.status-panel.success {
|
||||
background: var(--azionelab-success-bg);
|
||||
border-color: var(--azionelab-success-border);
|
||||
}
|
||||
|
||||
.status-panel.warning {
|
||||
background: #fff7ea;
|
||||
border-color: rgba(181, 126, 0, 0.15);
|
||||
}
|
||||
|
||||
.status-panel.error {
|
||||
background: var(--azionelab-error-bg);
|
||||
border-color: var(--azionelab-error-border);
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 16px;
|
||||
flex: 0 0 auto;
|
||||
background: rgba(30, 27, 24, 0.06);
|
||||
}
|
||||
|
||||
.status-panel.success .status-icon {
|
||||
background: rgba(46, 125, 50, 0.12);
|
||||
}
|
||||
|
||||
.status-panel.warning .status-icon {
|
||||
background: rgba(181, 126, 0, 0.14);
|
||||
}
|
||||
|
||||
.status-panel.error .status-icon {
|
||||
background: rgba(179, 38, 30, 0.12);
|
||||
}
|
||||
|
||||
.status-panel.success .status-icon mat-icon {
|
||||
color: var(--azionelab-success-ink);
|
||||
}
|
||||
|
||||
.status-panel.warning .status-icon mat-icon {
|
||||
color: #9b6c00;
|
||||
}
|
||||
|
||||
.status-panel.error .status-icon mat-icon {
|
||||
color: var(--azionelab-error-ink);
|
||||
}
|
||||
|
||||
.success-points {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.success-points span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
color: var(--azionelab-success-ink);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.success-points mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.qr-panel {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
margin: 22px auto 0;
|
||||
padding: 22px;
|
||||
border-radius: var(--azionelab-radius-md);
|
||||
border: 1px solid var(--azionelab-border);
|
||||
background: #ffffff;
|
||||
width: min(100%, 360px);
|
||||
}
|
||||
|
||||
.panel-label {
|
||||
margin: 0 0 12px;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
color: var(--azionelab-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.qr-panel img {
|
||||
width: min(280px, 100%);
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.meta-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
margin-top: 18px;
|
||||
padding: 14px 16px;
|
||||
border-radius: var(--azionelab-radius-md);
|
||||
background: var(--azionelab-bg-strong);
|
||||
color: var(--azionelab-muted);
|
||||
}
|
||||
|
||||
.meta-card p {
|
||||
margin: 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.meta-card a {
|
||||
color: var(--azionelab-accent-strong);
|
||||
}
|
||||
|
||||
.next-steps {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
.next-steps > div {
|
||||
padding: 16px;
|
||||
border-radius: var(--azionelab-radius-md);
|
||||
background: rgba(34, 28, 24, 0.035);
|
||||
border: 1px solid var(--azionelab-border);
|
||||
}
|
||||
|
||||
.step-label {
|
||||
margin: 0 0 6px !important;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--azionelab-accent);
|
||||
}
|
||||
|
||||
.next-steps p {
|
||||
margin: 0;
|
||||
line-height: 1.55;
|
||||
color: var(--azionelab-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
h1 {
|
||||
font-size: 2.3rem;
|
||||
}
|
||||
|
||||
mat-card-content {
|
||||
padding: 22px !important;
|
||||
}
|
||||
|
||||
mat-card-actions {
|
||||
padding: 0 22px 20px !important;
|
||||
}
|
||||
|
||||
.status-panel {
|
||||
padding: 18px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.qr-panel {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.qr-panel img {
|
||||
width: min(100%, 280px);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.next-steps {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
`],
|
||||
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');
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
357
frontend/src/app/pages/show-detail-placeholder-page.component.ts
Normal file
357
frontend/src/app/pages/show-detail-placeholder-page.component.ts
Normal file
@@ -0,0 +1,357 @@
|
||||
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>Caricamento dei dettagli dello spettacolo...</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>Non siamo riusciti a caricare questo spettacolo</h1>
|
||||
<p>{{ errorMessage() }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
<mat-card-actions>
|
||||
<button mat-flat-button type="button" (click)="reload()">Riprova</button>
|
||||
<a mat-button routerLink="/shows">Torna agli spettacoli</a>
|
||||
</mat-card-actions>
|
||||
</mat-card>
|
||||
} @else if (show()) {
|
||||
<section class="show-hero">
|
||||
@if (show()!.image_url || show()!.poster_image; as heroImage) {
|
||||
<div class="hero-image-wrap">
|
||||
<img class="hero-image" [src]="heroImage" [alt]="show()!.title" />
|
||||
</div>
|
||||
}
|
||||
|
||||
<header class="page-header">
|
||||
<div class="hero-copy">
|
||||
<p class="eyebrow">Scheda spettacolo</p>
|
||||
<h1>{{ show()!.title }}</h1>
|
||||
<p class="hero-description">{{ show()!.description || show()!.summary }}</p>
|
||||
</div>
|
||||
</header>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>Prossime repliche</h2>
|
||||
<p>Scegli la replica che preferisci e prosegui verso la prenotazione.</p>
|
||||
</div>
|
||||
<a mat-button routerLink="/shows">Torna all'elenco</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>Nessuna replica pubblicata per ora</h2>
|
||||
<p>Lo spettacolo e' online, ma al momento non ci sono date disponibili.</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">
|
||||
<div class="performance-kicker">Replica disponibile</div>
|
||||
<mat-card-title>{{ performance.starts_at | date: 'EEEE d MMMM' }}</mat-card-title>
|
||||
<mat-card-content>
|
||||
<dl class="performance-meta">
|
||||
<div>
|
||||
<dt>Luogo</dt>
|
||||
<dd>{{ performance.venue.name }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Data / orario</dt>
|
||||
<dd>{{ performance.starts_at | date: 'EEEE d MMMM, HH:mm' }}</dd>
|
||||
</div>
|
||||
@if (performance.available_seats !== null && performance.available_seats !== undefined) {
|
||||
<div>
|
||||
<dt>Posti disponibili</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']">Prenota il tuo posto</a>
|
||||
} @else {
|
||||
<button mat-stroked-button type="button" disabled>Prenotazione non disponibile</button>
|
||||
}
|
||||
</mat-card-actions>
|
||||
</mat-card>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
</section>
|
||||
`,
|
||||
styles: [`
|
||||
.show-hero {
|
||||
display: grid;
|
||||
gap: 28px;
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.hero-copy {
|
||||
min-width: 0;
|
||||
max-width: 860px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
max-width: 12ch;
|
||||
font-size: 4rem;
|
||||
line-height: 0.98;
|
||||
}
|
||||
|
||||
.hero-description {
|
||||
margin: 18px 0 0;
|
||||
max-width: 62ch;
|
||||
color: var(--azionelab-muted);
|
||||
font-size: 1.08rem;
|
||||
line-height: 1.82;
|
||||
}
|
||||
|
||||
.hero-image-wrap {
|
||||
overflow: hidden;
|
||||
border-radius: var(--azionelab-radius-lg);
|
||||
border: 1px solid var(--azionelab-border);
|
||||
background: var(--azionelab-bg-strong);
|
||||
box-shadow: var(--azionelab-shadow-strong);
|
||||
aspect-ratio: 16 / 8;
|
||||
}
|
||||
|
||||
.hero-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.section-heading h2 {
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
|
||||
.performance-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.performance-card,
|
||||
.status-card {
|
||||
border-radius: var(--azionelab-radius-md);
|
||||
border: 1px solid var(--azionelab-border);
|
||||
background: var(--azionelab-surface);
|
||||
box-shadow: var(--azionelab-shadow);
|
||||
}
|
||||
|
||||
.performance-card {
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
.performance-card mat-card-title,
|
||||
.performance-card mat-card-content,
|
||||
.performance-card mat-card-actions {
|
||||
padding-left: 22px;
|
||||
padding-right: 22px;
|
||||
}
|
||||
|
||||
.performance-kicker {
|
||||
padding: 22px 22px 0;
|
||||
color: var(--azionelab-accent);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.performance-card mat-card-title {
|
||||
margin-top: 10px;
|
||||
font-family: var(--azionelab-serif);
|
||||
font-size: 1.6rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.06;
|
||||
}
|
||||
|
||||
.performance-meta {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
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: 1rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.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) {
|
||||
h1 {
|
||||
font-size: 2.6rem;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.hero-image-wrap {
|
||||
aspect-ratio: 4 / 5;
|
||||
}
|
||||
}
|
||||
`],
|
||||
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('Lo spettacolo richiesto non ha un identificativo valido.');
|
||||
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('Riprova tra qualche istante.');
|
||||
this.isLoading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
284
frontend/src/app/pages/show-list-page.component.ts
Normal file
284
frontend/src/app/pages/show-list-page.component.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
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">Programmazione</p>
|
||||
<h1>Spettacoli in programma</h1>
|
||||
</div>
|
||||
<p class="supporting">
|
||||
Una selezione di lavori, attraversamenti scenici e appuntamenti da leggere con calma, immagine dopo immagine, scheda dopo scheda.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
@if (isLoading()) {
|
||||
<div class="status-panel" aria-live="polite">
|
||||
<mat-progress-spinner mode="indeterminate" diameter="40"></mat-progress-spinner>
|
||||
<p>Caricamento degli spettacoli in corso...</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>Non siamo riusciti a caricare gli spettacoli</h2>
|
||||
<p>{{ errorMessage() }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
<mat-card-actions>
|
||||
<button mat-flat-button type="button" (click)="reload()">Riprova</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>Nessuno spettacolo pubblicato per ora</h2>
|
||||
<p>Le produzioni disponibili compariranno qui non appena saranno online.</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">
|
||||
@if (getShowImage(show); as showImage) {
|
||||
<div class="show-image-wrap">
|
||||
<img class="show-image" [src]="showImage" [alt]="show.title" />
|
||||
</div>
|
||||
}
|
||||
<div class="card-body">
|
||||
<div class="card-topline">
|
||||
<span class="card-label">In programma</span>
|
||||
</div>
|
||||
<mat-card-title>{{ show.title }}</mat-card-title>
|
||||
<mat-card-content>
|
||||
<p>{{ show.summary }}</p>
|
||||
<p class="show-note">Apri la scheda per vedere le prossime date e i dettagli di prenotazione.</p>
|
||||
</mat-card-content>
|
||||
<mat-card-actions>
|
||||
<a mat-button [routerLink]="['/shows', show.slug]">{{ getShowCta(show) }}</a>
|
||||
</mat-card-actions>
|
||||
</div>
|
||||
</mat-card>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
`,
|
||||
styles: [`
|
||||
.page-header {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(300px, 430px);
|
||||
gap: 28px;
|
||||
align-items: end;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.supporting {
|
||||
margin: 0;
|
||||
max-width: 36ch;
|
||||
}
|
||||
|
||||
.show-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 28px;
|
||||
}
|
||||
|
||||
.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: var(--azionelab-radius-md);
|
||||
border: 1px solid var(--azionelab-border);
|
||||
background: var(--azionelab-surface-strong);
|
||||
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: 520px;
|
||||
overflow: hidden;
|
||||
border-radius: var(--azionelab-radius-md);
|
||||
border: 1px solid var(--azionelab-border);
|
||||
background: var(--azionelab-surface);
|
||||
box-shadow: var(--azionelab-shadow);
|
||||
}
|
||||
|
||||
.show-image-wrap {
|
||||
aspect-ratio: 4 / 5;
|
||||
overflow: hidden;
|
||||
border-bottom: 1px solid var(--azionelab-border);
|
||||
background: var(--azionelab-bg-strong);
|
||||
}
|
||||
|
||||
.show-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
transition: transform 180ms ease;
|
||||
}
|
||||
|
||||
.show-card:hover .show-image {
|
||||
transform: scale(1.015);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
padding: 22px 22px 20px;
|
||||
}
|
||||
|
||||
.card-topline {
|
||||
padding: 0 0 12px;
|
||||
}
|
||||
|
||||
.card-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 24px;
|
||||
color: var(--azionelab-accent);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.show-card mat-card-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.show-card mat-card-title,
|
||||
.show-card mat-card-content,
|
||||
.show-card mat-card-actions {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.show-card mat-card-title {
|
||||
margin-top: 0;
|
||||
font-family: var(--azionelab-serif);
|
||||
font-size: 1.85rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.04;
|
||||
}
|
||||
|
||||
.show-card p {
|
||||
color: var(--azionelab-muted);
|
||||
line-height: 1.74;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.show-note {
|
||||
margin-top: 18px !important;
|
||||
padding-top: 18px;
|
||||
border-top: 1px solid var(--azionelab-border);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.show-card mat-card-actions {
|
||||
padding-top: 22px;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.page-header {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.show-grid {
|
||||
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();
|
||||
}
|
||||
|
||||
protected getShowImage(show: ShowListItem): string {
|
||||
return show.image_url || show.poster_image || '';
|
||||
}
|
||||
|
||||
protected getShowCta(_show: ShowListItem): string {
|
||||
return 'Scopri lo spettacolo';
|
||||
}
|
||||
|
||||
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('Riprova tra qualche istante.');
|
||||
this.isLoading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
7
frontend/src/app/services/api-config.token.ts
Normal file
7
frontend/src/app/services/api-config.token.ts
Normal file
@@ -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,
|
||||
});
|
||||
157
frontend/src/app/services/shows-api.service.ts
Normal file
157
frontend/src/app/services/shows-api.service.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpHeaders } 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;
|
||||
image_url: 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 },
|
||||
this.buildStaffRequestOptions(),
|
||||
);
|
||||
}
|
||||
|
||||
confirmCheckIn(token: string): Observable<CheckInConfirmResponse> {
|
||||
return this.http.post<CheckInConfirmResponse>(
|
||||
`${this.apiBaseUrl}/check-ins/confirm/`,
|
||||
{ token },
|
||||
this.buildStaffRequestOptions(),
|
||||
);
|
||||
}
|
||||
|
||||
private buildStaffRequestOptions(): { headers?: HttpHeaders; withCredentials: true } {
|
||||
const csrfToken = this.readCookie('csrftoken');
|
||||
|
||||
return {
|
||||
withCredentials: true,
|
||||
headers: csrfToken ? new HttpHeaders({ 'X-CSRFToken': csrfToken }) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private readCookie(name: string): string {
|
||||
if (typeof document === 'undefined' || !document.cookie) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const cookiePrefix = `${name}=`;
|
||||
for (const cookie of document.cookie.split(';')) {
|
||||
const trimmedCookie = cookie.trim();
|
||||
if (trimmedCookie.startsWith(cookiePrefix)) {
|
||||
return decodeURIComponent(trimmedCookie.slice(cookiePrefix.length));
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
}
|
||||
BIN
frontend/src/assets/azione-lab.jpg
Normal file
BIN
frontend/src/assets/azione-lab.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
BIN
frontend/src/assets/azionelab-logo.png
Normal file
BIN
frontend/src/assets/azionelab-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 171 KiB |
15
frontend/src/assets/azionelab-logo.svg
Normal file
15
frontend/src/assets/azionelab-logo.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 220" role="img" aria-labelledby="title desc">
|
||||
<title id="title">AzioneLab</title>
|
||||
<desc id="desc">Logo AzioneLab con payoff e direzione artistica.</desc>
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#c54f41"/>
|
||||
<stop offset="100%" stop-color="#7f251f"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="12" y="18" width="120" height="120" rx="28" fill="url(#bg)"/>
|
||||
<path d="M46 109L74 48h17l28 61h-17l-5-12H67l-5 12H46zm27-26h18L82 60 73 83z" fill="#fff7f0"/>
|
||||
<text x="160" y="78" fill="#221c18" font-size="52" font-weight="700" font-family="Georgia, 'Times New Roman', serif">AzioneLab</text>
|
||||
<text x="160" y="122" fill="#5f5650" font-size="24" font-family="Arial, Helvetica, sans-serif">Laboratori teatrali & produzioni audio/visive</text>
|
||||
<text x="160" y="158" fill="#8f332d" font-size="20" font-family="Arial, Helvetica, sans-serif">Direzione artistica a cura di Ernesto Estatico</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
18
frontend/src/index.html
Normal file
18
frontend/src/index.html
Normal file
@@ -0,0 +1,18 @@
|
||||
<!doctype html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>AzioneLab</title>
|
||||
<base href="/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,600;9..144,700&family=Manrope:wght@400;500;600;700&family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,500,0,0"
|
||||
rel="stylesheet"
|
||||
>
|
||||
</head>
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
</html>
|
||||
15
frontend/src/main.ts
Normal file
15
frontend/src/main.ts
Normal 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));
|
||||
167
frontend/src/styles.css
Normal file
167
frontend/src/styles.css
Normal file
@@ -0,0 +1,167 @@
|
||||
:root {
|
||||
--azionelab-bg: #fcfbf8;
|
||||
--azionelab-bg-strong: #f5f3ee;
|
||||
--azionelab-surface: #ffffff;
|
||||
--azionelab-surface-strong: #ffffff;
|
||||
--azionelab-surface-dark: #121212;
|
||||
--azionelab-ink: #111111;
|
||||
--azionelab-ink-soft: #1e1e1e;
|
||||
--azionelab-muted: #666666;
|
||||
--azionelab-accent: #2f7d57;
|
||||
--azionelab-accent-strong: #225b40;
|
||||
--azionelab-accent-soft: #dcefe5;
|
||||
--azionelab-highlight: #2f7d57;
|
||||
--azionelab-border: rgba(17, 17, 17, 0.10);
|
||||
--azionelab-border-strong: rgba(17, 17, 17, 0.16);
|
||||
--azionelab-shadow: none;
|
||||
--azionelab-shadow-strong: none;
|
||||
--azionelab-radius-sm: 8px;
|
||||
--azionelab-radius-md: 12px;
|
||||
--azionelab-radius-lg: 18px;
|
||||
--azionelab-shell-width: 1200px;
|
||||
--azionelab-copy-width: 66ch;
|
||||
--azionelab-section-gap: 48px;
|
||||
--azionelab-sans: "Manrope", "Helvetica Neue", Arial, sans-serif;
|
||||
--azionelab-serif: "Fraunces", Georgia, "Times New Roman", serif;
|
||||
--azionelab-success-bg: #edf7ef;
|
||||
--azionelab-success-ink: #1f5f2b;
|
||||
--azionelab-success-border: rgba(46, 125, 50, 0.18);
|
||||
--azionelab-error-bg: #fff3f1;
|
||||
--azionelab-error-ink: #8b2a20;
|
||||
--azionelab-error-border: rgba(179, 38, 30, 0.16);
|
||||
--mdc-typography-brand-font-family: var(--azionelab-serif);
|
||||
--mdc-typography-plain-font-family: var(--azionelab-sans);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
font-family: var(--azionelab-sans);
|
||||
font-weight: 500;
|
||||
color: var(--azionelab-ink);
|
||||
background: var(--azionelab-bg);
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
body,
|
||||
p,
|
||||
li,
|
||||
dt,
|
||||
dd,
|
||||
label,
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select,
|
||||
option,
|
||||
a {
|
||||
font-family: var(--azionelab-sans);
|
||||
}
|
||||
|
||||
h1, h2, h3 {
|
||||
font-family: var(--azionelab-serif);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0;
|
||||
color: var(--azionelab-ink);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 2rem;
|
||||
line-height: 1.08;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.3rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
button, input, textarea, select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.mat-mdc-button-base,
|
||||
.mat-mdc-unelevated-button,
|
||||
.mat-mdc-outlined-button,
|
||||
.mat-mdc-button,
|
||||
.mat-mdc-card,
|
||||
.mat-mdc-form-field,
|
||||
.mdc-button,
|
||||
.mdc-text-field,
|
||||
.mdc-floating-label,
|
||||
.mdc-text-field__input {
|
||||
font-family: var(--azionelab-sans) !important;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--azionelab-accent-strong);
|
||||
text-decoration-thickness: 1px;
|
||||
text-underline-offset: 0.16em;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||
}
|
||||
|
||||
.page {
|
||||
width: min(100%, var(--azionelab-shell-width));
|
||||
margin: 0 auto;
|
||||
padding-left: 24px;
|
||||
padding-right: 24px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 12px;
|
||||
color: var(--azionelab-accent);
|
||||
text-transform: uppercase;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
|
||||
.supporting {
|
||||
color: var(--azionelab-muted);
|
||||
font-size: 1.04rem;
|
||||
line-height: 1.78;
|
||||
max-width: var(--azionelab-copy-width);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
h1 {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.72rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
h1 {
|
||||
font-size: 2.05rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.42rem;
|
||||
}
|
||||
|
||||
.page {
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
}
|
||||
}
|
||||
9
frontend/tsconfig.app.json
Normal file
9
frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/app",
|
||||
"types": []
|
||||
},
|
||||
"files": ["src/main.ts"],
|
||||
"include": ["src/**/*.d.ts"]
|
||||
}
|
||||
30
frontend/tsconfig.json
Normal file
30
frontend/tsconfig.json
Normal 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
|
||||
}
|
||||
}
|
||||
19
infra/docker/backend/Dockerfile
Normal file
19
infra/docker/backend/Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
||||
FROM python:3.13.4-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN useradd --create-home --shell /usr/sbin/nologin appuser
|
||||
|
||||
COPY requirements/backend.txt /app/requirements/backend.txt
|
||||
RUN pip install --no-cache-dir -r /app/requirements/backend.txt
|
||||
|
||||
COPY backend/ /app/
|
||||
|
||||
USER appuser
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "azionelab.wsgi:application"]
|
||||
91
infra/docker/compose.yml
Normal file
91
infra/docker/compose.yml
Normal file
@@ -0,0 +1,91 @@
|
||||
services:
|
||||
backend:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: infra/docker/backend/Dockerfile
|
||||
image: azionelab-backend:local
|
||||
environment:
|
||||
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY}
|
||||
DJANGO_ALLOWED_HOSTS: ${DJANGO_ALLOWED_HOSTS}
|
||||
DJANGO_CSRF_TRUSTED_ORIGINS: ${DJANGO_CSRF_TRUSTED_ORIGINS}
|
||||
DJANGO_DEBUG: ${DJANGO_DEBUG:-false}
|
||||
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS}
|
||||
SITE_BASE_URL: ${SITE_BASE_URL}
|
||||
EMAIL_BACKEND: ${EMAIL_BACKEND}
|
||||
TIME_ZONE: ${TIME_ZONE:-Europe/Rome}
|
||||
DATABASE_URL: ${DATABASE_URL}
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_HOST: ${POSTGRES_HOST:-postgres}
|
||||
POSTGRES_PORT: ${POSTGRES_PORT:-5432}
|
||||
expose:
|
||||
- "${BACKEND_PORT:-8000}"
|
||||
volumes:
|
||||
- django_static:/app/staticfiles
|
||||
- django_media:/app/media
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- internal
|
||||
user: "0:0"
|
||||
restart: unless-stopped
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: infra/docker/frontend/Dockerfile
|
||||
image: azionelab-frontend:local
|
||||
expose:
|
||||
- "${FRONTEND_PORT:-8080}"
|
||||
networks:
|
||||
- internal
|
||||
restart: unless-stopped
|
||||
|
||||
postgres:
|
||||
image: postgres:16.3-alpine
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\""]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- internal
|
||||
restart: unless-stopped
|
||||
|
||||
nginx:
|
||||
image: nginx:1.27.0-alpine
|
||||
ports:
|
||||
- "${NGINX_HTTP_PORT:-8080}:80"
|
||||
environment:
|
||||
BACKEND_HOST: ${BACKEND_HOST:-backend}
|
||||
BACKEND_PORT: ${BACKEND_PORT:-8000}
|
||||
FRONTEND_HOST: ${FRONTEND_HOST:-frontend}
|
||||
FRONTEND_PORT: ${FRONTEND_PORT:-8080}
|
||||
NGINX_ENVSUBST_FILTER: "^(BACKEND_HOST|BACKEND_PORT|FRONTEND_HOST|FRONTEND_PORT)$"
|
||||
volumes:
|
||||
- ./nginx/templates:/etc/nginx/templates:ro
|
||||
- django_static:/var/www/static:ro
|
||||
- django_media:/var/www/media:ro
|
||||
depends_on:
|
||||
- backend
|
||||
- frontend
|
||||
networks:
|
||||
- internal
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
django_static:
|
||||
django_media:
|
||||
|
||||
networks:
|
||||
internal:
|
||||
driver: bridge
|
||||
18
infra/docker/frontend/Dockerfile
Normal file
18
infra/docker/frontend/Dockerfile
Normal file
@@ -0,0 +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
|
||||
|
||||
COPY infra/docker/frontend/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist/azionelab/browser/ /usr/share/nginx/html/
|
||||
|
||||
EXPOSE 8080
|
||||
14
infra/docker/frontend/html/index.html
Normal file
14
infra/docker/frontend/html/index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>AzioneLab</title>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>AzioneLab frontend placeholder</h1>
|
||||
<p>The Angular application build will replace this page.</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
15
infra/docker/frontend/nginx.conf
Normal file
15
infra/docker/frontend/nginx.conf
Normal file
@@ -0,0 +1,15 @@
|
||||
server {
|
||||
listen 8080;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location /assets/ {
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user