Compare commits

69 Commits

Author SHA1 Message Date
bisco 5e843d0810 feat: redesign public reservation flow pages 2026-05-05 16:38:53 +02:00
bisco 0c465b53cd feat: add editorial homepage and show list layout 2026-05-05 16:16:46 +02:00
bisco 329935dcdd feat: add frontend layout foundation 2026-05-05 15:50:05 +02:00
bisco bb1f59c0b3 Merge branch 'fix/frontend-logo-background' into develop 2026-05-05 13:58:01 +02:00
bisco fd1ce63f7a fix: remove header logo visual effects 2026-05-05 12:51:07 +02:00
bisco 3f008b7096 fix: use azionelab png logo 2026-05-05 12:46:16 +02:00
bisco fb7ced584b Merge branch 'fix/frontend-font-logo-consistency' into develop 2026-05-05 12:43:22 +02:00
bisco 621d3af1f1 fix: align frontend fonts and logo layout 2026-05-05 12:40:56 +02:00
bisco b68e0a7c5d Merge branch 'feature/frontend-typography-header' into develop 2026-05-05 12:31:27 +02:00
bisco 00d09ceb8b feat: refine frontend typography and branding 2026-05-05 12:28:19 +02:00
bisco d55d2b14ba changed logo 2026-05-05 12:21:22 +02:00
bisco 92e1bc1b7c Merge branch 'fix/nginx-frontend-assets' into develop 2026-05-05 11:55:08 +02:00
bisco 185003e001 fix: serve frontend assets without spa fallback 2026-05-05 11:53:21 +02:00
bisco 6c163fd800 feat: refine frontend italian copy 2026-05-05 11:09:01 +02:00
bisco 5db0a38441 Merge branch 'feature/frontend-branding-it' into develop 2026-05-05 11:05:32 +02:00
bisco 5abf019b1a fix: use provided azionelab logo asset 2026-05-05 11:02:59 +02:00
bisco fbbb1ae5fe feat: apply azionelab branding to frontend 2026-05-05 10:46:10 +02:00
bisco cf4e9ec239 Merge branch 'feature/frontend-visual-design' into develop 2026-05-05 10:27:54 +02:00
bisco 3957987b07 feat: refresh frontend visual design 2026-05-05 10:26:03 +02:00
bisco c3a345d60b Merge branch 'fix/qr-checkin-email-config' into develop 2026-05-04 10:11:38 +02:00
bisco 978fe1a7ff fix: tighten check-in token handling 2026-05-04 09:43:03 +02:00
bisco c7c0657f6d Merge branch 'fix/frontend-checkin-csrf' into develop 2026-04-30 13:51:16 +02:00
bisco b8d2dade40 fix: send csrf token for staff check-in 2026-04-30 13:40:09 +02:00
bisco d6d83fbb07 Merge branch 'fix/frontend-staff-session-auth' into develop 2026-04-30 13:25:33 +02:00
bisco f343512ec2 fix: send staff session credentials for check-in 2026-04-30 12:18:19 +02:00
bisco 769614278c Merge branch 'fix/site-base-url-runtime' into develop 2026-04-30 11:58:59 +02:00
bisco bc6f1c3c2e fix: pass site base url to backend container 2026-04-30 11:48:50 +02:00
bisco c725fdb912 Merge branch 'fix/public-booking-csrf' into develop 2026-04-30 11:34:31 +02:00
bisco cafa9226e3 fix: allow public reservation posts without csrf 2026-04-30 11:28:49 +02:00
bisco 9a378902d5 Merge branch 'fix/checkin-frontend-url' into develop 2026-04-30 11:01:18 +02:00
bisco 099b2f10ca fix: point check-in QR links to frontend page 2026-04-30 09:50:58 +02:00
bisco 9c9acd3e1d Merge branch 'fix/local-email-console-backend' into develop 2026-04-30 01:35:25 +02:00
bisco 833d4e629c fix: show local confirmation links before email send 2026-04-30 01:29:10 +02:00
bisco 240ea3aba3 Merge branch 'fix/dev-confirmation-log-order' into develop 2026-04-30 01:20:32 +02:00
bisco 8a47740049 fix: log confirmation link before email delivery 2026-04-30 01:18:04 +02:00
bisco ff9f9f2716 Merge branch 'feature/frontend-form-polish' into develop 2026-04-30 01:01:35 +02:00
bisco 05de8c75a2 feat: polish booking and confirmation UI 2026-04-30 00:59:43 +02:00
bisco 3dca43bc5c Merge branch 'fix/admin-reservation-labels' into develop 2026-04-30 00:54:13 +02:00
bisco 1629544b76 fix: simplify reservation admin labels 2026-04-30 00:47:36 +02:00
bisco 47449ce8dd Merge branch 'feature/show-image-upload' into develop 2026-04-30 00:08:27 +02:00
bisco ded07346a6 feat(shows): add uploaded show images 2026-04-30 00:05:23 +02:00
bisco b51ca9fdbf Merge branch 'feature/admin-manual-reservation-actions' into develop 2026-04-29 23:50:17 +02:00
bisco 7c90da5884 feat(admin): add manual reservation operations 2026-04-29 23:47:22 +02:00
bisco ffbe1a5b04 Merge branch 'fix/dev-email-confirmation-logging' into develop 2026-04-29 23:40:25 +02:00
bisco 1a5e9103f6 fix(dev): log confirmation links locally 2026-04-29 23:39:12 +02:00
bisco e5fcbfeb26 Merge branch 'chore/production-readiness-docs' into develop 2026-04-29 23:04:21 +02:00
bisco 7fc0a931ce docs: add production readiness notes 2026-04-29 23:00:57 +02:00
bisco b692ae70ba Merge branch 'fix/api-throttling' into develop 2026-04-29 22:59:02 +02:00
bisco 0533a1799f fix(api): add basic booking throttling 2026-04-29 22:57:09 +02:00
bisco a8f2a7c803 Merge branch 'fix/admin-token-visibility' into develop 2026-04-29 22:48:27 +02:00
bisco 7a46e288cf fix(admin): hide reservation token hashes 2026-04-29 22:45:16 +02:00
bisco 33307a5de2 Merge branch 'fix/site-base-url' into develop 2026-04-29 22:01:19 +02:00
bisco a5189669f6 fix(config): align site base url defaults 2026-04-29 21:59:31 +02:00
bisco aef2a31977 docs: add lightweight git workflow policy 2026-04-29 21:54:49 +02:00
bisco 13a05f6d0d fix(security): separate booking and check-in tokens 2026-04-29 21:49:21 +02:00
bisco 5cad1871e7 feat(operations): improve reservation and check-in flows 2026-04-29 19:23:42 +02:00
bisco 6c5b5d99bc added debug settings 2026-04-29 19:00:59 +02:00
bisco 0fe57dc47f fix(admin): handle unsaved performance seats display 2026-04-29 18:56:19 +02:00
bisco 784076e6be fix(docker): serve django static files via nginx 2026-04-29 18:46:53 +02:00
bisco c82103cc66 Merge branch 'feature/frontend-checkin' into develop 2026-04-29 18:25:58 +02:00
bisco 51f449ced0 feat(frontend): add staff check-in flow 2026-04-29 18:23:05 +02:00
bisco ad92dce047 Merge branch 'feature/frontend-confirmation-qr' into develop 2026-04-29 18:20:06 +02:00
bisco 24d3f4d30f feat(frontend): add reservation confirmation page 2026-04-29 18:19:05 +02:00
bisco 302e3461ad Merge branch 'feature/frontend-booking' into develop 2026-04-29 18:15:00 +02:00
bisco 144c48c02f feat(frontend): add performance booking form 2026-04-29 18:14:07 +02:00
bisco 56d8c31a0d Merge branch 'feature/frontend-show-detail' into develop 2026-04-29 15:40:28 +02:00
bisco c3a2addd47 feat: add frontend show detail page 2026-04-29 14:37:37 +02:00
bisco 6488b6db87 Merge branch 'feature/frontend-show-list' into develop 2026-04-29 12:37:41 +02:00
bisco e1977e49c3 feat: load shows from frontend API 2026-04-29 12:20:39 +02:00
50 changed files with 4314 additions and 453 deletions
+10 -2
View File
@@ -121,14 +121,22 @@ enabled_profiles:
Work must happen on the current feature branch for the task. Work must happen on the current feature branch for the task.
## Git Workflow Policy
- `develop` is the integration branch.
- `main` is reserved for stable releases.
- For non-trivial changes, Codex MUST work on a dedicated task branch.
- Allowed task branch prefixes: `feature/`, `fix/`, `docs/`, `chore/`.
- Codex MUST NOT merge into `develop` or `main`.
- Codex may commit only on the current task branch after required checks pass.
- The human operator reviews, merges, pushes, and deletes branches.
Allowed branch prefixes when a new branch is explicitly needed: Allowed branch prefixes when a new branch is explicitly needed:
- `feature/` - `feature/`
- `fix/` - `fix/`
- `hotfix/`
- `chore/` - `chore/`
- `docs/` - `docs/`
- `refactor/`
Do not merge task branches into `develop`. Leave integration to the repository owner or a separate explicit request. Do not merge task branches into `develop`. Leave integration to the repository owner or a separate explicit request.
+11 -5
View File
@@ -3,7 +3,7 @@
COMPOSE_PROJECT_NAME=azionelab COMPOSE_PROJECT_NAME=azionelab
NGINX_HTTP_PORT=8080 NGINX_HTTP_PORT=80
BACKEND_HOST=backend BACKEND_HOST=backend
BACKEND_PORT=8000 BACKEND_PORT=8000
@@ -12,10 +12,10 @@ FRONTEND_PORT=8080
DJANGO_SECRET_KEY=change-me DJANGO_SECRET_KEY=change-me
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1 DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1
DJANGO_CSRF_TRUSTED_ORIGINS=http://localhost:8080 DJANGO_CSRF_TRUSTED_ORIGINS=http://localhost
DJANGO_DEBUG=false DJANGO_DEBUG=true
CORS_ALLOWED_ORIGINS=http://localhost:4200,http://localhost:8080 CORS_ALLOWED_ORIGINS=http://localhost:4200,http://localhost
SITE_BASE_URL=http://localhost:8080 SITE_BASE_URL=http://localhost
TIME_ZONE=Europe/Rome TIME_ZONE=Europe/Rome
EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend
DEFAULT_FROM_EMAIL=no-reply@azionelab.local DEFAULT_FROM_EMAIL=no-reply@azionelab.local
@@ -27,3 +27,9 @@ POSTGRES_HOST=postgres
POSTGRES_PORT=5432 POSTGRES_PORT=5432
DATABASE_URL=postgres://azionelab:change-me@postgres:5432/azionelab DATABASE_URL=postgres://azionelab:change-me@postgres:5432/azionelab
ENVIRONMENT=local
# Local convention: nginx is the public entrypoint on http://localhost.
# If you change the published nginx port, update SITE_BASE_URL and trusted origins to match.
# In local/debug mode, failed or attempted reservation emails also log the confirmation URL for manual browser testing.
+14 -2
View File
@@ -9,6 +9,7 @@ from django.core.exceptions import ImproperlyConfigured
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
DEBUG = os.environ.get("DJANGO_DEBUG", "false").lower() == "true" DEBUG = os.environ.get("DJANGO_DEBUG", "false").lower() == "true"
ENVIRONMENT = os.environ.get("ENVIRONMENT", "production").lower()
SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY") SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY")
if not SECRET_KEY: if not SECRET_KEY:
if DEBUG: if DEBUG:
@@ -24,7 +25,8 @@ def csv_env(name, default=""):
ALLOWED_HOSTS = csv_env("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1") ALLOWED_HOSTS = csv_env("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1")
CSRF_TRUSTED_ORIGINS = csv_env("DJANGO_CSRF_TRUSTED_ORIGINS") CSRF_TRUSTED_ORIGINS = csv_env("DJANGO_CSRF_TRUSTED_ORIGINS")
CORS_ALLOWED_ORIGINS = csv_env("CORS_ALLOWED_ORIGINS") CORS_ALLOWED_ORIGINS = csv_env("CORS_ALLOWED_ORIGINS")
SITE_BASE_URL = os.environ.get("SITE_BASE_URL", "http://localhost:8080").rstrip("/") SITE_BASE_URL = os.environ.get("SITE_BASE_URL", "http://localhost").rstrip("/")
LOG_RESERVATION_CONFIRMATION_URLS = DEBUG or ENVIRONMENT == "local"
INSTALLED_APPS = [ INSTALLED_APPS = [
"django.contrib.admin", "django.contrib.admin",
@@ -112,7 +114,10 @@ DEFAULT_FROM_EMAIL = os.environ.get("DEFAULT_FROM_EMAIL", "no-reply@azionelab.lo
if "test" in sys.argv: if "test" in sys.argv:
EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend" EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"
STATIC_URL = "static/" STATIC_URL = "/static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR / "media"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
REST_FRAMEWORK = { REST_FRAMEWORK = {
@@ -122,4 +127,11 @@ REST_FRAMEWORK = {
"DEFAULT_PARSER_CLASSES": [ "DEFAULT_PARSER_CLASSES": [
"rest_framework.parsers.JSONParser", "rest_framework.parsers.JSONParser",
], ],
"DEFAULT_THROTTLE_RATES": {
# Small-theatre defaults: stricter on public booking flows, looser for staff operations.
"reservation_create": "20/hour",
"reservation_confirm": "60/hour",
"check_in_preview": "600/hour",
"check_in_confirm": "600/hour",
},
} }
+5
View File
@@ -1,4 +1,6 @@
from django.contrib import admin from django.contrib import admin
from django.conf import settings
from django.conf.urls.static import static
from django.urls import include, path from django.urls import include, path
from rest_framework.decorators import api_view from rest_framework.decorators import api_view
from rest_framework.response import Response from rest_framework.response import Response
@@ -16,3 +18,6 @@ urlpatterns = [
path("api/", include("bookings.urls")), path("api/", include("bookings.urls")),
path("api/", include("checkins.urls")), path("api/", include("checkins.urls")),
] ]
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
+426 -14
View File
@@ -1,45 +1,457 @@
from django.contrib import admin 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 .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): class ReservationTokenInline(admin.TabularInline):
model = ReservationToken model = ReservationToken
extra = 0 extra = 0
readonly_fields = ("token_hash", "used_at", "created_at") readonly_fields = ("used_at", "created_at")
fields = ("purpose", "token_hash", "expires_at", "used_at", "created_at") fields = ("purpose", "expires_at", "used_at", "created_at")
can_delete = False can_delete = False
@admin.register(Reservation) @admin.register(Reservation)
class ReservationAdmin(admin.ModelAdmin): class ReservationAdmin(admin.ModelAdmin):
form = ReservationAdminForm
list_display = ( list_display = (
"show_title",
"performance_starts_at",
"venue_name",
"name", "name",
"email", "email",
"performance",
"party_size", "party_size",
"status", "status",
"confirmation_state_display",
"check_in_state_display",
)
list_filter = (
"status",
"performance__show",
"performance__venue",
"performance__starts_at",
"confirmed_at", "confirmed_at",
"qr_code_generated_at", "qr_code_generated_at",
"created_at",
) )
list_filter = ("status", "performance", "created_at", "confirmed_at") search_fields = (
search_fields = ("name", "email", "phone", "performance__show__title") "name",
"email",
"phone",
"performance__show__title",
"performance__venue__name",
)
inlines = (ReservationTokenInline,) inlines = (ReservationTokenInline,)
list_select_related = ("performance", "performance__show", "performance__venue") list_select_related = ("performance", "performance__show", "performance__venue")
readonly_fields = ("created_at", "updated_at", "confirmed_at", "qr_code_generated_at") readonly_fields = (
"status",
"show_title",
"performance_starts_at",
"venue_name",
"confirmation_state_display",
"check_in_state_display",
"check_in_access_display",
"operational_tools",
"created_at",
"updated_at",
"confirmed_at",
"qr_code_generated_at",
)
autocomplete_fields = ("performance",) autocomplete_fields = ("performance",)
def get_urls(self):
urls = super().get_urls()
custom_urls = [
path(
"<path:object_id>/manual-confirm/",
self.admin_site.admin_view(self.manual_confirm_view),
name="bookings_reservation_manual_confirm",
),
path(
"<path:object_id>/check-in-pass/",
self.admin_site.admin_view(self.check_in_pass_view),
name="bookings_reservation_check_in_pass",
),
path(
"<path:object_id>/manual-check-in/",
self.admin_site.admin_view(self.manual_check_in_view),
name="bookings_reservation_manual_check_in",
),
]
return custom_urls + urls
def get_queryset(self, request):
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) @admin.register(ReservationToken)
class ReservationTokenAdmin(admin.ModelAdmin): class ReservationTokenAdmin(admin.ModelAdmin):
list_display = ("reservation", "purpose", "expires_at", "used_at", "created_at", "token_preview") list_display = ("reservation", "purpose", "expires_at", "used_at", "created_at")
list_filter = ("purpose", "expires_at", "used_at", "created_at") list_filter = ("purpose", "expires_at", "used_at", "created_at")
search_fields = ("reservation__name", "reservation__email", "token_hash") search_fields = ("reservation__name", "reservation__email", "token_hash")
readonly_fields = ("token_hash", "created_at", "used_at") readonly_fields = ("created_at", "used_at")
exclude = ("token_hash",)
list_select_related = ("reservation", "reservation__performance") list_select_related = ("reservation", "reservation__performance")
autocomplete_fields = ("reservation",) autocomplete_fields = ("reservation",)
@admin.display(description="Token hash")
def token_preview(self, obj):
return obj.token_hash[:12]
+21
View File
@@ -13,6 +13,16 @@ def build_confirmation_link(raw_confirmation_token):
return f"{settings.SITE_BASE_URL}{CONFIRMATION_PATH}?token={raw_confirmation_token}" return f"{settings.SITE_BASE_URL}{CONFIRMATION_PATH}?token={raw_confirmation_token}"
def _log_confirmation_link_for_local_debug(*, 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): def send_confirmation_email(*, reservation, raw_confirmation_token):
confirmation_link = build_confirmation_link(raw_confirmation_token) confirmation_link = build_confirmation_link(raw_confirmation_token)
subject = f"Confirm your reservation for {reservation.performance.show.title}" subject = f"Confirm your reservation for {reservation.performance.show.title}"
@@ -23,6 +33,10 @@ def send_confirmation_email(*, reservation, raw_confirmation_token):
"If you did not request this reservation, you can ignore this email." "If you did not request this reservation, you can ignore this email."
) )
_log_confirmation_link_for_local_debug(
confirmation_link=confirmation_link,
)
try: try:
send_mail( send_mail(
subject=subject, subject=subject,
@@ -32,6 +46,13 @@ def send_confirmation_email(*, reservation, raw_confirmation_token):
fail_silently=False, fail_silently=False,
) )
except Exception: except Exception:
if settings.LOG_RESERVATION_CONFIRMATION_URLS:
logger.warning(
"Local/debug email delivery failed for reservation %s.",
reservation.id,
)
return
logger.exception( logger.exception(
"Failed to send confirmation email for reservation %s.", "Failed to send confirmation email for reservation %s.",
reservation.id, reservation.id,
+3 -1
View File
@@ -44,7 +44,9 @@ class Reservation(TimeStampedModel):
] ]
def __str__(self): def __str__(self):
return f"{self.name} ({self.party_size}) for {self.performance}" if self.party_size > 1:
return f"{self.name} ({self.party_size})"
return self.name
@property @property
def is_confirmed(self): def is_confirmed(self):
+2 -2
View File
@@ -7,11 +7,11 @@ from django.conf import settings
from .models import Reservation from .models import Reservation
CHECK_IN_PREVIEW_PATH = "/api/check-ins/preview/" CHECK_IN_PAGE_PATH = "/check-in"
def build_check_in_preview_url(raw_check_in_token): def build_check_in_preview_url(raw_check_in_token):
return f"{settings.SITE_BASE_URL}{CHECK_IN_PREVIEW_PATH}?token={raw_check_in_token}" 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): def generate_check_in_qr_png(raw_check_in_token):
+111 -34
View File
@@ -44,6 +44,10 @@ class ReservationNotConfirmed(BookingServiceError):
pass pass
class ReservationNotPending(BookingServiceError):
pass
@dataclass(frozen=True) @dataclass(frozen=True)
class PendingReservationResult: class PendingReservationResult:
reservation: Reservation reservation: Reservation
@@ -55,7 +59,7 @@ class PendingReservationResult:
@dataclass(frozen=True) @dataclass(frozen=True)
class ConfirmedReservationResult: class ConfirmedReservationResult:
reservation: Reservation reservation: Reservation
confirmation_token: ReservationToken confirmation_token: ReservationToken | None
check_in_token: ReservationToken check_in_token: ReservationToken
raw_check_in_token: str raw_check_in_token: str
available_seats: int available_seats: int
@@ -70,6 +74,15 @@ class ReservationQRResult:
qr_code_url: str qr_code_url: str
@dataclass(frozen=True)
class ReservationCheckInAccessResult:
reservation: Reservation
check_in_token: ReservationToken
raw_check_in_token: str
qr_code_image: str
qr_code_url: str
def calculate_available_seats(performance): def calculate_available_seats(performance):
confirmed_seats = ( confirmed_seats = (
Reservation.objects.filter( Reservation.objects.filter(
@@ -113,16 +126,19 @@ def create_pending_reservation(
expires_at=confirmation_expires_at, expires_at=confirmation_expires_at,
) )
transaction.on_commit(
lambda reservation=reservation, raw_confirmation_token=raw_confirmation_token: send_confirmation_email(
reservation=reservation,
raw_confirmation_token=raw_confirmation_token,
)
)
result = PendingReservationResult( result = PendingReservationResult(
reservation=reservation, reservation=reservation,
confirmation_token=confirmation_token, confirmation_token=confirmation_token,
raw_confirmation_token=raw_confirmation_token, raw_confirmation_token=raw_confirmation_token,
available_seats=available_seats, available_seats=available_seats,
) )
send_confirmation_email(
reservation=result.reservation,
raw_confirmation_token=result.raw_confirmation_token,
)
return result return result
@@ -168,49 +184,65 @@ def confirm_reservation_from_token(raw_token):
raise InvalidToken("Confirmation token is not valid for this reservation.") raise InvalidToken("Confirmation token is not valid for this reservation.")
if not token_was_expired: if not token_was_expired:
performance = _get_locked_bookable_performance(reservation.performance_id) result = _confirm_pending_reservation(
available_seats = calculate_available_seats(performance)
if reservation.party_size > available_seats:
raise NotEnoughSeats("Not enough seats are available for this performance.")
reservation.confirm()
confirmation_token.mark_used()
check_in_token, raw_check_in_token = ReservationToken.create_token(
reservation=reservation, reservation=reservation,
purpose=ReservationToken.Purpose.CHECK_IN, confirmation_token=confirmation_token,
expires_at=performance.starts_at + CHECK_IN_TOKEN_AFTER_PERFORMANCE_TTL,
) )
reservation.qr_code_generated_at = timezone.now()
reservation.save(update_fields=["qr_code_generated_at", "updated_at"])
if token_was_expired: if token_was_expired:
raise ExpiredToken("Confirmation token has expired.") raise ExpiredToken("Confirmation token has expired.")
return ConfirmedReservationResult( return result
reservation=reservation,
confirmation_token=confirmation_token,
check_in_token=check_in_token, def confirm_reservation_manually(*, reservation_id):
raw_check_in_token=raw_check_in_token, with transaction.atomic():
available_seats=available_seats - reservation.party_size, reservation = Reservation.objects.select_for_update().get(pk=reservation_id)
qr_code_image=generate_check_in_qr_base64( if reservation.status == Reservation.Status.CONFIRMED:
raise AlreadyConfirmedReservation("Reservation is already confirmed.")
if reservation.status != Reservation.Status.PENDING:
raise ReservationNotPending("Only pending reservations can be confirmed manually.")
confirmation_token = (
ReservationToken.objects.select_for_update()
.filter(
reservation=reservation,
purpose=ReservationToken.Purpose.CONFIRMATION,
)
.order_by("-created_at")
.first()
)
return _confirm_pending_reservation(
reservation=reservation, reservation=reservation,
raw_check_in_token=raw_token, confirmation_token=confirmation_token,
), )
qr_code_url=build_check_in_preview_url(raw_token),
)
def issue_check_in_access_for_reservation(*, reservation_id):
with transaction.atomic():
reservation = (
Reservation.objects.select_for_update()
.select_related("performance__show", "performance__venue")
.get(pk=reservation_id)
)
if reservation.status != Reservation.Status.CONFIRMED:
raise ReservationNotConfirmed("Reservation must be confirmed before QR retrieval.")
result = _issue_check_in_access(reservation)
return result
def retrieve_reservation_qr_from_token(raw_token): def retrieve_reservation_qr_from_token(raw_token):
try: try:
confirmation_token = ReservationToken.objects.select_related("reservation").get( check_in_token = ReservationToken.objects.select_related("reservation").get_valid_token(
token_hash=ReservationToken.hash_token(raw_token), raw_token,
purpose=ReservationToken.Purpose.CONFIRMATION, ReservationToken.Purpose.CHECK_IN,
) )
except ReservationToken.DoesNotExist as exc: except ReservationToken.DoesNotExist as exc:
raise InvalidToken("Confirmation token is invalid.") from exc raise InvalidToken("Check-in token is invalid.") from exc
reservation = confirmation_token.reservation reservation = check_in_token.reservation
if reservation.status != Reservation.Status.CONFIRMED: if reservation.status != Reservation.Status.CONFIRMED:
raise ReservationNotConfirmed("Reservation must be confirmed before QR retrieval.") raise ReservationNotConfirmed("Reservation must be confirmed before QR retrieval.")
@@ -234,3 +266,48 @@ def _get_locked_bookable_performance(performance_id):
raise PerformanceNotAvailable("Performance is not available for booking.") raise PerformanceNotAvailable("Performance is not available for booking.")
return performance return performance
def _confirm_pending_reservation(*, reservation, confirmation_token=None):
performance = _get_locked_bookable_performance(reservation.performance_id)
available_seats = calculate_available_seats(performance)
if reservation.party_size > available_seats:
raise NotEnoughSeats("Not enough seats are available for this performance.")
reservation.confirm()
if confirmation_token is not None and not confirmation_token.is_used:
confirmation_token.mark_used()
check_in_access = _issue_check_in_access(reservation)
return ConfirmedReservationResult(
reservation=reservation,
confirmation_token=confirmation_token,
check_in_token=check_in_access.check_in_token,
raw_check_in_token=check_in_access.raw_check_in_token,
available_seats=available_seats - reservation.party_size,
qr_code_image=check_in_access.qr_code_image,
qr_code_url=check_in_access.qr_code_url,
)
def _issue_check_in_access(reservation):
check_in_token, raw_check_in_token = ReservationToken.create_token(
reservation=reservation,
purpose=ReservationToken.Purpose.CHECK_IN,
expires_at=reservation.performance.starts_at + CHECK_IN_TOKEN_AFTER_PERFORMANCE_TTL,
)
if reservation.qr_code_generated_at is None:
reservation.qr_code_generated_at = timezone.now()
reservation.save(update_fields=["qr_code_generated_at", "updated_at"])
return ReservationCheckInAccessResult(
reservation=reservation,
check_in_token=check_in_token,
raw_check_in_token=raw_check_in_token,
qr_code_image=generate_check_in_qr_base64(
reservation=reservation,
raw_check_in_token=raw_check_in_token,
),
qr_code_url=build_check_in_preview_url(raw_check_in_token),
)
@@ -0,0 +1,35 @@
{% extends "admin/base_site.html" %}
{% block content %}
<div id="content-main">
<h1>{{ heading }}</h1>
<p>{{ description }}</p>
<p>
<strong>Reservation:</strong> {{ original.name }}<br>
<strong>Show:</strong> {{ original.performance.show.title }}<br>
<strong>Performance:</strong> {{ original.performance.starts_at }}<br>
<strong>Party size:</strong> {{ original.party_size }}
</p>
{% if qr_code_url %}
<p>
<strong>Check-in URL:</strong><br>
<a href="{{ qr_code_url }}">{{ qr_code_url }}</a>
</p>
{% endif %}
{% if qr_code_image %}
<p><img src="{{ qr_code_image }}" alt="Operational QR code"></p>
{% endif %}
{% if submit_label %}
<form method="post">
{% csrf_token %}
<input type="submit" value="{{ submit_label }}" class="default">
</form>
{% endif %}
<p><a href="{{ change_url }}">Back to reservation</a></p>
</div>
{% endblock %}
+179
View 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)")
+155 -25
View File
@@ -1,19 +1,24 @@
from datetime import timedelta 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.core import mail
from django.urls import reverse from django.urls import reverse
from django.test.utils import override_settings from django.test.utils import override_settings
from django.utils import timezone from django.utils import timezone
from rest_framework import status from rest_framework import status
from rest_framework.test import APITestCase from rest_framework.test import APIClient, APITestCase
from bookings.models import Reservation from bookings.models import Reservation
from bookings.services import generate_confirmation_token from bookings.services import generate_confirmation_token
from bookings.views import ReservationConfirmThrottle, ReservationCreateThrottle
from shows.models import Performance, Show, Venue from shows.models import Performance, Show, Venue
class BookingApiTests(APITestCase): class BookingApiTests(APITestCase):
def setUp(self): def setUp(self):
cache.clear()
self.show = Show.objects.create( self.show = Show.objects.create(
title="Open Stage", title="Open Stage",
slug="open-stage-api", slug="open-stage-api",
@@ -63,17 +68,18 @@ class BookingApiTests(APITestCase):
@override_settings(SITE_BASE_URL="https://tickets.azionelab.example") @override_settings(SITE_BASE_URL="https://tickets.azionelab.example")
def test_reservation_creation_success(self): def test_reservation_creation_success(self):
response = self.client.post( with self.captureOnCommitCallbacks(execute=True):
reverse("api-reservation-create", kwargs={"performance_id": self.performance.id}), response = self.client.post(
{ reverse("api-reservation-create", kwargs={"performance_id": self.performance.id}),
"name": "Maria Rossi", {
"email": "maria@example.com", "name": "Maria Rossi",
"phone": "+390600000000", "email": "maria@example.com",
"party_size": 2, "phone": "+390600000000",
"notes": "Front row if possible.", "party_size": 2,
}, "notes": "Front row if possible.",
format="json", },
) format="json",
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(response.data["status"], Reservation.Status.PENDING) self.assertEqual(response.data["status"], Reservation.Status.PENDING)
@@ -87,6 +93,90 @@ class BookingApiTests(APITestCase):
mail.outbox[0].body, 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): def test_reservation_creation_with_insufficient_seats(self):
response = self.client.post( response = self.client.post(
reverse("api-reservation-create", kwargs={"performance_id": self.performance.id}), reverse("api-reservation-create", kwargs={"performance_id": self.performance.id}),
@@ -120,9 +210,10 @@ class BookingApiTests(APITestCase):
self.assertEqual(response.data["party_size"], reservation.party_size) self.assertEqual(response.data["party_size"], reservation.party_size)
self.assertTrue( self.assertTrue(
response.data["qr_code_url"].startswith( response.data["qr_code_url"].startswith(
"https://tickets.azionelab.example/api/check-ins/preview/?token=" "https://tickets.azionelab.example/check-in?token="
) )
) )
self.assertNotIn(raw_token, response.data["qr_code_url"])
self.assertNotIn("token", response.data) self.assertNotIn("token", response.data)
self.assertTrue(response.data["qr_code_image"].startswith("data:image/png;base64,")) self.assertTrue(response.data["qr_code_image"].startswith("data:image/png;base64,"))
self.assertEqual(reservation.status, Reservation.Status.CONFIRMED) self.assertEqual(reservation.status, Reservation.Status.CONFIRMED)
@@ -170,8 +261,55 @@ class BookingApiTests(APITestCase):
self.assertEqual(second_response.status_code, status.HTTP_409_CONFLICT) self.assertEqual(second_response.status_code, status.HTTP_409_CONFLICT)
self.assertEqual(second_response.data["status"], "already_confirmed") self.assertEqual(second_response.data["status"], "already_confirmed")
def test_confirmation_is_throttled(self):
with patch.dict(ReservationConfirmThrottle.THROTTLE_RATES, {"reservation_confirm": "1/minute"}, clear=False):
first_reservation = self.create_reservation(email="first@example.com")
_, first_raw_token = generate_confirmation_token(first_reservation)
second_reservation = self.create_reservation(email="second@example.com")
_, second_raw_token = generate_confirmation_token(second_reservation)
first_response = self.client.post(
reverse("api-reservation-confirm"),
{"token": first_raw_token},
format="json",
)
second_response = self.client.post(
reverse("api-reservation-confirm"),
{"token": second_raw_token},
format="json",
)
self.assertEqual(first_response.status_code, status.HTTP_200_OK)
self.assertEqual(second_response.status_code, status.HTTP_429_TOO_MANY_REQUESTS)
@override_settings(SITE_BASE_URL="https://tickets.azionelab.example") @override_settings(SITE_BASE_URL="https://tickets.azionelab.example")
def test_qr_retrieval_success_for_confirmed_reservation(self): def test_qr_retrieval_success_for_confirmed_reservation(self):
reservation = self.create_reservation()
_, raw_token = generate_confirmation_token(reservation)
confirmation_response = self.client.post(
reverse("api-reservation-confirm"),
{"token": raw_token},
format="json",
)
check_in_token = confirmation_response.data["qr_code_url"].split("token=", 1)[1]
response = self.client.get(
reverse("api-reservation-qr"),
{"token": check_in_token},
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["reservation_id"], reservation.id)
self.assertTrue(
response.data["qr_code_url"].startswith(
"https://tickets.azionelab.example/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() reservation = self.create_reservation()
_, raw_token = generate_confirmation_token(reservation) _, raw_token = generate_confirmation_token(reservation)
self.client.post( self.client.post(
@@ -185,16 +323,8 @@ class BookingApiTests(APITestCase):
{"token": raw_token}, {"token": raw_token},
) )
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
self.assertEqual(response.data["reservation_id"], reservation.id) self.assertEqual(response.data["status"], "invalid_token")
self.assertTrue(
response.data["qr_code_url"].startswith(
"https://tickets.azionelab.example/api/check-ins/preview/?token="
)
)
self.assertTrue(response.data["qr_code_image"].startswith("data:image/png;base64,"))
self.assertNotIn("email", response.data)
self.assertNotIn("name", response.data)
def test_qr_retrieval_fails_for_invalid_token(self): def test_qr_retrieval_fails_for_invalid_token(self):
response = self.client.get( response = self.client.get(
@@ -214,8 +344,8 @@ class BookingApiTests(APITestCase):
{"token": raw_token}, {"token": raw_token},
) )
self.assertEqual(response.status_code, status.HTTP_409_CONFLICT) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
self.assertEqual(response.data["status"], "reservation_not_confirmed") self.assertEqual(response.data["status"], "invalid_token")
self.assertEqual(reservation.status, Reservation.Status.PENDING) self.assertEqual(reservation.status, Reservation.Status.PENDING)
def create_reservation(self, **overrides): def create_reservation(self, **overrides):
+210 -14
View File
@@ -15,10 +15,14 @@ from bookings.services import (
ExpiredToken, ExpiredToken,
InvalidToken, InvalidToken,
NotEnoughSeats, NotEnoughSeats,
ReservationNotConfirmed,
calculate_available_seats, calculate_available_seats,
confirm_reservation_manually,
confirm_reservation_from_token, confirm_reservation_from_token,
create_pending_reservation, create_pending_reservation,
generate_confirmation_token, generate_confirmation_token,
issue_check_in_access_for_reservation,
retrieve_reservation_qr_from_token,
) )
from shows.models import Performance, Show, Venue from shows.models import Performance, Show, Venue
@@ -64,14 +68,16 @@ class BookingServiceTests(TestCase):
EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend", EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend",
SITE_BASE_URL="https://tickets.azionelab.example", SITE_BASE_URL="https://tickets.azionelab.example",
) )
def test_create_pending_reservation_sends_confirmation_email(self): def test_create_pending_reservation_sends_confirmation_email_after_commit(self):
result = create_pending_reservation( with self.captureOnCommitCallbacks(execute=True) as callbacks:
performance_id=self.performance.id, result = create_pending_reservation(
name="Maria Rossi", performance_id=self.performance.id,
email="maria@example.com", name="Maria Rossi",
party_size=1, email="maria@example.com",
) party_size=1,
)
self.assertEqual(len(callbacks), 1)
self.assertEqual(len(mail.outbox), 1) self.assertEqual(len(mail.outbox), 1)
self.assertEqual(mail.outbox[0].to, ["maria@example.com"]) self.assertEqual(mail.outbox[0].to, ["maria@example.com"])
self.assertIn(result.raw_confirmation_token, mail.outbox[0].body) self.assertIn(result.raw_confirmation_token, mail.outbox[0].body)
@@ -80,16 +86,55 @@ class BookingServiceTests(TestCase):
mail.outbox[0].body, mail.outbox[0].body,
) )
@patch("bookings.emailing.send_mail", side_effect=RuntimeError("SMTP down")) @override_settings(EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend")
def test_create_pending_reservation_logs_email_failure_without_crashing(self, mocked_send_mail): def test_create_pending_reservation_defers_email_until_commit(self):
with self.assertLogs("bookings.emailing", level="ERROR") as captured_logs: with self.captureOnCommitCallbacks(execute=False) as callbacks:
result = create_pending_reservation( create_pending_reservation(
performance_id=self.performance.id, performance_id=self.performance.id,
name="Maria Rossi", name="Maria Rossi",
email="maria@example.com", email="maria@example.com",
party_size=1, party_size=1,
) )
self.assertEqual(len(callbacks), 1)
self.assertEqual(len(mail.outbox), 0)
@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(result.reservation.status, Reservation.Status.PENDING)
self.assertEqual(Reservation.objects.count(), 1) self.assertEqual(Reservation.objects.count(), 1)
mocked_send_mail.assert_called_once() mocked_send_mail.assert_called_once()
@@ -99,6 +144,74 @@ class BookingServiceTests(TestCase):
for log_entry in captured_logs.output for log_entry in captured_logs.output
) )
) )
self.assertFalse(
any(result.raw_confirmation_token in log_entry for log_entry in captured_logs.output)
)
@override_settings(
LOG_RESERVATION_CONFIRMATION_URLS=True,
SITE_BASE_URL="https://tickets.azionelab.example",
)
@patch("bookings.emailing.send_mail", side_effect=RuntimeError("SMTP down"))
def test_create_pending_reservation_logs_confirmation_link_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): def test_generate_confirmation_token_returns_raw_token_once(self):
reservation = self.create_reservation() reservation = self.create_reservation()
@@ -130,15 +243,28 @@ class BookingServiceTests(TestCase):
self.assertEqual(result.available_seats, 1) self.assertEqual(result.available_seats, 1)
self.assertEqual( self.assertEqual(
result.qr_code_url, result.qr_code_url,
build_check_in_preview_url(raw_token), build_check_in_preview_url(result.raw_check_in_token),
) )
self.assertTrue( self.assertTrue(
result.qr_code_url.startswith( result.qr_code_url.startswith(
"https://tickets.azionelab.example/api/check-ins/preview/?token=" "https://tickets.azionelab.example/check-in?token="
) )
) )
self.assertTrue(result.qr_code_image.startswith("data:image/png;base64,")) self.assertTrue(result.qr_code_image.startswith("data:image/png;base64,"))
@override_settings(SITE_BASE_URL="https://tickets.azionelab.example")
def test_confirmation_token_cannot_be_reused_as_qr_or_check_in_token(self):
reservation = self.create_reservation()
_, raw_token = generate_confirmation_token(reservation)
result = confirm_reservation_from_token(raw_token)
self.assertNotEqual(raw_token, result.raw_check_in_token)
self.assertNotEqual(
build_check_in_preview_url(raw_token),
result.qr_code_url,
)
@override_settings(SITE_BASE_URL="https://tickets.azionelab.example") @override_settings(SITE_BASE_URL="https://tickets.azionelab.example")
def test_qr_code_is_generated_for_confirmed_reservation(self): def test_qr_code_is_generated_for_confirmed_reservation(self):
reservation = self.create_reservation( reservation = self.create_reservation(
@@ -156,7 +282,7 @@ class BookingServiceTests(TestCase):
self.assertGreater(len(qr_code_image), len("data:image/png;base64,")) self.assertGreater(len(qr_code_image), len("data:image/png;base64,"))
self.assertEqual( self.assertEqual(
build_check_in_preview_url(raw_check_in_token), build_check_in_preview_url(raw_check_in_token),
"https://tickets.azionelab.example/api/check-ins/preview/?token=opaque-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): def test_qr_code_is_not_generated_for_pending_reservation(self):
@@ -171,6 +297,28 @@ class BookingServiceTests(TestCase):
raw_check_in_token="opaque-check-in-token", raw_check_in_token="opaque-check-in-token",
) )
def test_qr_retrieval_rejects_confirmation_token(self):
reservation = self.create_reservation()
_, raw_confirmation_token = generate_confirmation_token(reservation)
confirm_reservation_from_token(raw_confirmation_token)
with self.assertRaises(InvalidToken):
retrieve_reservation_qr_from_token(raw_confirmation_token)
def test_qr_retrieval_accepts_check_in_token(self):
reservation = self.create_reservation()
_, raw_confirmation_token = generate_confirmation_token(reservation)
result = confirm_reservation_from_token(raw_confirmation_token)
qr_result = retrieve_reservation_qr_from_token(result.raw_check_in_token)
self.assertEqual(qr_result.reservation, reservation)
self.assertEqual(
qr_result.qr_code_url,
build_check_in_preview_url(result.raw_check_in_token),
)
self.assertTrue(qr_result.qr_code_image.startswith("data:image/png;base64,"))
def test_confirmation_fails_when_capacity_is_exhausted(self): def test_confirmation_fails_when_capacity_is_exhausted(self):
Reservation.objects.create( Reservation.objects.create(
performance=self.performance, performance=self.performance,
@@ -268,6 +416,54 @@ class BookingServiceTests(TestCase):
self.assertTrue(any("FOR UPDATE" in query["sql"] for query in queries)) self.assertTrue(any("FOR UPDATE" in query["sql"] for query in queries))
@override_settings(SITE_BASE_URL="https://tickets.azionelab.example")
def test_manual_confirmation_reuses_capacity_rules_and_generates_check_in_access(self):
reservation = self.create_reservation()
confirmation_token, _ = generate_confirmation_token(reservation)
result = confirm_reservation_manually(reservation_id=reservation.id)
reservation.refresh_from_db()
confirmation_token.refresh_from_db()
self.assertEqual(reservation.status, Reservation.Status.CONFIRMED)
self.assertIsNotNone(confirmation_token.used_at)
self.assertEqual(result.check_in_token.purpose, ReservationToken.Purpose.CHECK_IN)
self.assertTrue(
result.qr_code_url.startswith(
"https://tickets.azionelab.example/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): def create_reservation(self, **overrides):
data = { data = {
"performance": self.performance, "performance": self.performance,
+15 -1
View File
@@ -1,7 +1,9 @@
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from rest_framework import status from rest_framework import status
from rest_framework.decorators import api_view from rest_framework.decorators import api_view, authentication_classes, permission_classes, throttle_classes
from rest_framework.permissions import AllowAny
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.throttling import AnonRateThrottle
from shows.models import Performance from shows.models import Performance
@@ -25,7 +27,18 @@ from .services import (
) )
class ReservationCreateThrottle(AnonRateThrottle):
scope = "reservation_create"
class ReservationConfirmThrottle(AnonRateThrottle):
scope = "reservation_confirm"
@api_view(["POST"]) @api_view(["POST"])
@authentication_classes([])
@permission_classes([AllowAny])
@throttle_classes([ReservationCreateThrottle])
def create_reservation(request, performance_id): def create_reservation(request, performance_id):
get_object_or_404(Performance, pk=performance_id, show__is_published=True) get_object_or_404(Performance, pk=performance_id, show__is_published=True)
@@ -60,6 +73,7 @@ def create_reservation(request, performance_id):
@api_view(["GET", "POST"]) @api_view(["GET", "POST"])
@throttle_classes([ReservationConfirmThrottle])
def confirm_reservation(request): def confirm_reservation(request):
payload = request.query_params if request.method == "GET" else request.data payload = request.query_params if request.method == "GET" else request.data
serializer = ReservationConfirmSerializer(data=payload) serializer = ReservationConfirmSerializer(data=payload)
+5 -1
View File
@@ -6,7 +6,7 @@ from .models import CheckIn
@admin.register(CheckIn) @admin.register(CheckIn)
class CheckInAdmin(admin.ModelAdmin): class CheckInAdmin(admin.ModelAdmin):
list_display = ( list_display = (
"reservation", "reservation_guest_name",
"performance", "performance",
"checked_in_at", "checked_in_at",
"checked_in_by", "checked_in_by",
@@ -25,6 +25,10 @@ class CheckInAdmin(admin.ModelAdmin):
list_select_related = ("reservation", "reservation__performance", "checked_in_by") list_select_related = ("reservation", "reservation__performance", "checked_in_by")
autocomplete_fields = ("reservation", "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") @admin.display(description="Performance")
def performance(self, obj): def performance(self, obj):
return obj.reservation.performance return obj.reservation.performance
+1
View File
@@ -18,6 +18,7 @@ class CheckInPreviewResponseSerializer(serializers.Serializer):
reservation_id = serializers.IntegerField() reservation_id = serializers.IntegerField()
performance_id = serializers.IntegerField() performance_id = serializers.IntegerField()
show_title = serializers.CharField() show_title = serializers.CharField()
venue_name = serializers.CharField()
starts_at = serializers.DateTimeField() starts_at = serializers.DateTimeField()
party_size = serializers.IntegerField() party_size = serializers.IntegerField()
+25 -7
View File
@@ -75,6 +75,29 @@ def confirm_check_in_from_token(raw_token, *, staff_user, source=CheckIn.Source.
return CheckInResult(check_in=check_in, preview=_build_preview(reservation)) return CheckInResult(check_in=check_in, preview=_build_preview(reservation))
def confirm_check_in_for_reservation(*, reservation_id, staff_user, source=CheckIn.Source.MANUAL):
_validate_staff_user(staff_user)
with transaction.atomic():
reservation = (
Reservation.objects.select_for_update()
.select_related("performance__show", "performance__venue")
.get(pk=reservation_id)
)
_validate_reservation_for_check_in(reservation)
try:
check_in = CheckIn.objects.create(
reservation=reservation,
checked_in_by=staff_user,
source=source,
)
except IntegrityError as exc:
raise AlreadyCheckedIn("Reservation has already been checked in.") from exc
return CheckInResult(check_in=check_in, preview=_build_preview(reservation))
def _validate_staff_user(staff_user): def _validate_staff_user(staff_user):
if staff_user is None: if staff_user is None:
raise MissingStaffUser("A staff user is required for check-in.") raise MissingStaffUser("A staff user is required for check-in.")
@@ -98,17 +121,12 @@ def _get_reservation_for_check_in_token(raw_token, *, lock_token=False):
try: try:
token = queryset.get( token = queryset.get(
token_hash=ReservationToken.hash_token(raw_token), token_hash=ReservationToken.hash_token(raw_token),
purpose=ReservationToken.Purpose.CHECK_IN,
) )
except ReservationToken.DoesNotExist as exc: except ReservationToken.DoesNotExist as exc:
raise InvalidToken("Check-in token is invalid.") from exc raise InvalidToken("Check-in token is invalid.") from exc
if token.purpose == ReservationToken.Purpose.CHECK_IN: if token.used_at is not None or token.is_expired:
if token.used_at is not None or token.is_expired:
raise InvalidToken("Check-in token is invalid.")
elif token.purpose == ReservationToken.Purpose.CONFIRMATION:
if token.reservation.status != Reservation.Status.CONFIRMED:
raise InvalidToken("Check-in token is invalid.")
else:
raise InvalidToken("Check-in token is invalid.") raise InvalidToken("Check-in token is invalid.")
return token.reservation return token.reservation
+56
View 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")
+63
View File
@@ -1,13 +1,16 @@
from datetime import timedelta from datetime import timedelta
from unittest.mock import patch
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.urls import reverse from django.urls import reverse
from django.test.utils import override_settings
from django.utils import timezone from django.utils import timezone
from rest_framework import status from rest_framework import status
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
from bookings.models import Reservation, ReservationToken from bookings.models import Reservation, ReservationToken
from checkins.models import CheckIn from checkins.models import CheckIn
from checkins.views import CheckInPreviewThrottle
from shows.models import Performance, Show, Venue from shows.models import Performance, Show, Venue
@@ -57,6 +60,7 @@ class CheckInApiTests(APITestCase):
self.assertEqual(response.data["reservation_id"], reservation.id) self.assertEqual(response.data["reservation_id"], reservation.id)
self.assertEqual(response.data["performance_id"], self.performance.id) self.assertEqual(response.data["performance_id"], self.performance.id)
self.assertEqual(response.data["show_title"], self.show.title) self.assertEqual(response.data["show_title"], self.show.title)
self.assertEqual(response.data["venue_name"], self.venue.name)
self.assertEqual(response.data["party_size"], reservation.party_size) self.assertEqual(response.data["party_size"], reservation.party_size)
self.assertNotIn("name", response.data) self.assertNotIn("name", response.data)
self.assertNotIn("email", response.data) self.assertNotIn("email", response.data)
@@ -86,6 +90,46 @@ class CheckInApiTests(APITestCase):
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
self.assertEqual(response.data["status"], "invalid_token") self.assertEqual(response.data["status"], "invalid_token")
def test_preview_rejects_confirmation_token(self):
reservation = self.create_reservation()
_, raw_token = ReservationToken.create_token(
reservation=reservation,
purpose=ReservationToken.Purpose.CONFIRMATION,
expires_at=timezone.now() + timedelta(hours=2),
)
self.client.force_authenticate(user=self.staff_user)
response = self.client.post(
reverse("api-check-in-preview"),
{"token": raw_token},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
self.assertEqual(response.data["status"], "invalid_token")
def test_preview_is_throttled_for_staff_user(self):
with patch.dict(CheckInPreviewThrottle.THROTTLE_RATES, {"check_in_preview": "1/minute"}, clear=False):
first_reservation = self.create_reservation(email="first@example.com")
_, first_raw_token = self.create_check_in_token(first_reservation)
second_reservation = self.create_reservation(email="second@example.com")
_, second_raw_token = self.create_check_in_token(second_reservation)
self.client.force_authenticate(user=self.staff_user)
first_response = self.client.post(
reverse("api-check-in-preview"),
{"token": first_raw_token},
format="json",
)
second_response = self.client.post(
reverse("api-check-in-preview"),
{"token": second_raw_token},
format="json",
)
self.assertEqual(first_response.status_code, status.HTTP_200_OK)
self.assertEqual(second_response.status_code, status.HTTP_429_TOO_MANY_REQUESTS)
def test_check_in_success_as_staff_user(self): def test_check_in_success_as_staff_user(self):
reservation = self.create_reservation() reservation = self.create_reservation()
_, raw_token = self.create_check_in_token(reservation) _, raw_token = self.create_check_in_token(reservation)
@@ -170,6 +214,25 @@ class CheckInApiTests(APITestCase):
self.assertEqual(second_response.data["status"], "already_checked_in") self.assertEqual(second_response.data["status"], "already_checked_in")
self.assertEqual(CheckIn.objects.filter(reservation=reservation).count(), 1) self.assertEqual(CheckIn.objects.filter(reservation=reservation).count(), 1)
def test_check_in_rejects_confirmation_token(self):
reservation = self.create_reservation()
_, raw_token = ReservationToken.create_token(
reservation=reservation,
purpose=ReservationToken.Purpose.CONFIRMATION,
expires_at=timezone.now() + timedelta(hours=2),
)
self.client.force_authenticate(user=self.staff_user)
response = self.client.post(
reverse("api-check-in-confirm"),
{"token": raw_token},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
self.assertEqual(response.data["status"], "invalid_token")
self.assertFalse(CheckIn.objects.filter(reservation=reservation).exists())
def create_reservation(self, **overrides): def create_reservation(self, **overrides):
data = { data = {
"performance": self.performance, "performance": self.performance,
+63
View File
@@ -11,6 +11,7 @@ from checkins.services import (
InvalidToken, InvalidToken,
MissingStaffUser, MissingStaffUser,
ReservationNotConfirmed, ReservationNotConfirmed,
confirm_check_in_for_reservation,
confirm_check_in_from_token, confirm_check_in_from_token,
preview_check_in_token, preview_check_in_token,
) )
@@ -62,6 +63,28 @@ class CheckInServiceTests(TestCase):
with self.assertRaises(InvalidToken): with self.assertRaises(InvalidToken):
preview_check_in_token("invalid-token", staff_user=self.staff_user) preview_check_in_token("invalid-token", staff_user=self.staff_user)
def test_preview_rejects_confirmation_token_even_for_confirmed_reservation(self):
reservation = self.create_reservation()
_, raw_token = ReservationToken.create_token(
reservation=reservation,
purpose=ReservationToken.Purpose.CONFIRMATION,
expires_at=timezone.now() + timedelta(hours=2),
)
with self.assertRaises(InvalidToken):
preview_check_in_token(raw_token, staff_user=self.staff_user)
def test_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): def test_check_in_succeeds_for_confirmed_reservation(self):
reservation = self.create_reservation() reservation = self.create_reservation()
_, raw_token = self.create_check_in_token(reservation) _, raw_token = self.create_check_in_token(reservation)
@@ -114,6 +137,46 @@ class CheckInServiceTests(TestCase):
with self.assertRaises(MissingStaffUser): with self.assertRaises(MissingStaffUser):
confirm_check_in_from_token(raw_token, staff_user=None) confirm_check_in_from_token(raw_token, staff_user=None)
def test_manual_check_in_by_reservation_id_succeeds_for_confirmed_reservation(self):
reservation = self.create_reservation()
result = confirm_check_in_for_reservation(
reservation_id=reservation.id,
staff_user=self.staff_user,
)
self.assertEqual(result.check_in.reservation, reservation)
self.assertEqual(result.check_in.checked_in_by, self.staff_user)
self.assertEqual(result.check_in.source, CheckIn.Source.MANUAL)
def test_manual_check_in_by_reservation_id_rejects_pending_reservation(self):
reservation = self.create_reservation(status=Reservation.Status.PENDING, confirmed_at=None)
with self.assertRaises(ReservationNotConfirmed):
confirm_check_in_for_reservation(
reservation_id=reservation.id,
staff_user=self.staff_user,
)
def test_check_in_rejects_confirmation_token_even_for_confirmed_reservation(self):
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): def create_reservation(self, **overrides):
data = { data = {
"performance": self.performance, "performance": self.performance,
+19 -11
View File
@@ -1,8 +1,9 @@
from rest_framework import status from rest_framework import status
from rest_framework.authentication import BasicAuthentication, SessionAuthentication from rest_framework.authentication import BasicAuthentication, SessionAuthentication
from rest_framework.decorators import api_view, authentication_classes, permission_classes from rest_framework.decorators import api_view, authentication_classes, permission_classes, throttle_classes
from rest_framework.permissions import BasePermission, IsAuthenticated from rest_framework.permissions import BasePermission, IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.throttling import UserRateThrottle
from .serializers import ( from .serializers import (
CheckInConfirmResponseSerializer, CheckInConfirmResponseSerializer,
@@ -19,19 +20,22 @@ from .services import (
) )
class CheckInPreviewThrottle(UserRateThrottle):
scope = "check_in_preview"
class CheckInConfirmThrottle(UserRateThrottle):
scope = "check_in_confirm"
class IsStaffUser(BasePermission): class IsStaffUser(BasePermission):
def has_permission(self, request, view): def has_permission(self, request, view):
return bool(request.user and request.user.is_staff) return bool(request.user and request.user.is_staff)
@api_view(["POST"])
def staff_check_in_view(view_func): @authentication_classes([BasicAuthentication, SessionAuthentication])
view_func = permission_classes([IsAuthenticated, IsStaffUser])(view_func) @permission_classes([IsAuthenticated, IsStaffUser])
view_func = authentication_classes([BasicAuthentication, SessionAuthentication])(view_func) @throttle_classes([CheckInPreviewThrottle])
view_func = api_view(["POST"])(view_func)
return view_func
@staff_check_in_view
def check_in_preview(request): def check_in_preview(request):
serializer = CheckInTokenSerializer(data=request.data) serializer = CheckInTokenSerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
@@ -65,6 +69,7 @@ def check_in_preview(request):
"reservation_id": preview.reservation_id, "reservation_id": preview.reservation_id,
"performance_id": preview.performance_id, "performance_id": preview.performance_id,
"show_title": preview.show_title, "show_title": preview.show_title,
"venue_name": preview.venue_name,
"starts_at": preview.starts_at, "starts_at": preview.starts_at,
"party_size": preview.party_size, "party_size": preview.party_size,
} }
@@ -72,7 +77,10 @@ def check_in_preview(request):
return Response(response_serializer.data) return Response(response_serializer.data)
@staff_check_in_view @api_view(["POST"])
@authentication_classes([BasicAuthentication, SessionAuthentication])
@permission_classes([IsAuthenticated, IsStaffUser])
@throttle_classes([CheckInConfirmThrottle])
def check_in_confirm(request): def check_in_confirm(request):
serializer = CheckInTokenSerializer(data=request.data) serializer = CheckInTokenSerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
+49 -2
View File
@@ -1,15 +1,42 @@
from django.contrib import admin from django.contrib import admin
from django.urls import reverse
from django.utils.html import format_html
from .models import Performance, Show, Venue from .models import Performance, Show, Venue
@admin.register(Show) @admin.register(Show)
class ShowAdmin(admin.ModelAdmin): class ShowAdmin(admin.ModelAdmin):
list_display = ("title", "slug", "is_published", "created_at", "updated_at") list_display = ("title", "slug", "is_published", "image_preview", "created_at", "updated_at")
list_filter = ("is_published",) list_filter = ("is_published",)
search_fields = ("title", "slug", "summary", "description") search_fields = ("title", "slug", "summary", "description")
prepopulated_fields = {"slug": ("title",)} prepopulated_fields = {"slug": ("title",)}
readonly_fields = ("created_at", "updated_at") readonly_fields = ("image_preview", "created_at", "updated_at")
fields = (
"title",
"slug",
"summary",
"description",
"uploaded_image",
"poster_image",
"image_preview",
"is_published",
"created_at",
"updated_at",
)
@admin.display(description="Preview")
def image_preview(self, obj):
if not getattr(obj, "pk", None):
return "-"
image_url = obj.image_url()
if not image_url:
return "No image"
return format_html(
'<img src="{}" alt="{}" style="max-width: 120px; border-radius: 6px;" />',
image_url,
obj.title,
)
@admin.register(Venue) @admin.register(Venue)
@@ -32,6 +59,7 @@ class PerformanceAdmin(admin.ModelAdmin):
"manually_occupied_seats", "manually_occupied_seats",
"available_seats_display", "available_seats_display",
"is_booking_enabled", "is_booking_enabled",
"create_reservation_link",
) )
list_filter = ("is_booking_enabled", "starts_at", "show", "venue") list_filter = ("is_booking_enabled", "starts_at", "show", "venue")
search_fields = ("show__title", "venue__name", "venue__city") search_fields = ("show__title", "venue__name", "venue__city")
@@ -41,4 +69,23 @@ class PerformanceAdmin(admin.ModelAdmin):
@admin.display(description="Available seats") @admin.display(description="Available seats")
def available_seats_display(self, obj): def available_seats_display(self, obj):
if (
not getattr(obj, "pk", None)
or obj.room_capacity is None
or obj.additional_seats is None
or obj.manually_occupied_seats is None
):
return "-"
return obj.available_seats() return obj.available_seats()
@admin.display(description="Manual reservation")
def create_reservation_link(self, obj):
if not getattr(obj, "pk", None):
return "-"
url = reverse("admin:bookings_reservation_add")
return format_html(
'<a href="{}?performance={}">Create reservation</a>',
url,
obj.pk,
)
@@ -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/"),
),
]
+9
View File
@@ -16,6 +16,7 @@ class Show(TimeStampedModel):
summary = models.TextField(blank=True) summary = models.TextField(blank=True)
description = models.TextField(blank=True) description = models.TextField(blank=True)
poster_image = models.URLField(blank=True) poster_image = models.URLField(blank=True)
uploaded_image = models.ImageField(upload_to="shows/", blank=True)
is_published = models.BooleanField(default=False, db_index=True) is_published = models.BooleanField(default=False, db_index=True)
class Meta: class Meta:
@@ -28,6 +29,14 @@ class Show(TimeStampedModel):
def __str__(self): def __str__(self):
return self.title return self.title
def image_url(self, request=None):
if self.uploaded_image:
image_url = self.uploaded_image.url
if request is not None:
return request.build_absolute_uri(image_url)
return image_url
return self.poster_image
class Venue(TimeStampedModel): class Venue(TimeStampedModel):
name = models.CharField(max_length=200) name = models.CharField(max_length=200)
+10 -2
View File
@@ -1,7 +1,15 @@
from rest_framework import serializers from rest_framework import serializers
class PublicShowListSerializer(serializers.Serializer): class PublicShowImageMixin(serializers.Serializer):
image_url = serializers.SerializerMethodField()
def get_image_url(self, obj):
request = self.context.get("request")
return obj.image_url(request=request)
class PublicShowListSerializer(PublicShowImageMixin, serializers.Serializer):
id = serializers.IntegerField() id = serializers.IntegerField()
title = serializers.CharField() title = serializers.CharField()
slug = serializers.SlugField() slug = serializers.SlugField()
@@ -18,7 +26,7 @@ class PublicVenueDetailSerializer(PublicVenueSummarySerializer):
address = serializers.CharField() address = serializers.CharField()
class PublicShowSummarySerializer(serializers.Serializer): class PublicShowSummarySerializer(PublicShowImageMixin, serializers.Serializer):
title = serializers.CharField() title = serializers.CharField()
slug = serializers.SlugField() slug = serializers.SlugField()
summary = serializers.CharField() summary = serializers.CharField()
+20 -1
View File
@@ -1,5 +1,7 @@
from django.contrib import admin from django.contrib import admin
from django.test import SimpleTestCase from django.contrib.auth import get_user_model
from django.test import SimpleTestCase, TestCase
from django.urls import reverse
from bookings.models import Reservation, ReservationToken from bookings.models import Reservation, ReservationToken
from checkins.models import CheckIn from checkins.models import CheckIn
@@ -11,3 +13,20 @@ class AdminRegistrationTests(SimpleTestCase):
for model in (Show, Venue, Performance, Reservation, ReservationToken, CheckIn): for model in (Show, Venue, Performance, Reservation, ReservationToken, CheckIn):
with self.subTest(model=model.__name__): with self.subTest(model=model.__name__):
self.assertTrue(admin.site.is_registered(model)) self.assertTrue(admin.site.is_registered(model))
class PerformanceAdminTests(TestCase):
def setUp(self):
user_model = get_user_model()
self.admin_user = user_model.objects.create_superuser(
username="admin",
email="admin@example.com",
password="password123",
)
self.client.force_login(self.admin_user)
def test_performance_add_page_renders_for_unsaved_object(self):
response = self.client.get(reverse("admin:shows_performance_add"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Available seats")
+77
View 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")
+8 -4
View File
@@ -24,7 +24,7 @@ def public_performance_queryset():
@api_view(["GET"]) @api_view(["GET"])
def show_list(request): def show_list(request):
shows = Show.objects.filter(is_published=True).order_by("title") shows = Show.objects.filter(is_published=True).order_by("title")
serializer = PublicShowListSerializer(shows, many=True) serializer = PublicShowListSerializer(shows, many=True, context={"request": request})
return Response({"results": serializer.data}) return Response({"results": serializer.data})
@@ -32,7 +32,7 @@ def show_list(request):
def show_detail(request, slug): def show_detail(request, slug):
show = get_object_or_404(Show, slug=slug, is_published=True) show = get_object_or_404(Show, slug=slug, is_published=True)
show.public_performances = public_performance_queryset().filter(show=show) show.public_performances = public_performance_queryset().filter(show=show)
serializer = PublicShowDetailSerializer(show) serializer = PublicShowDetailSerializer(show, context={"request": request})
return Response(serializer.data) return Response(serializer.data)
@@ -54,12 +54,16 @@ def performance_list(request):
) )
performances = performances.filter(starts_at__gte=starts_from) performances = performances.filter(starts_at__gte=starts_from)
serializer = PublicPerformanceListSerializer(performances.order_by("starts_at"), many=True) serializer = PublicPerformanceListSerializer(
performances.order_by("starts_at"),
many=True,
context={"request": request},
)
return Response({"results": serializer.data}) return Response({"results": serializer.data})
@api_view(["GET"]) @api_view(["GET"])
def performance_detail(request, pk): def performance_detail(request, pk):
performance = get_object_or_404(public_performance_queryset(), pk=pk) performance = get_object_or_404(public_performance_queryset(), pk=pk)
serializer = PublicPerformanceDetailSerializer(performance) serializer = PublicPerformanceDetailSerializer(performance, context={"request": request})
return Response(serializer.data) return Response(serializer.data)
+4 -2
View File
@@ -204,7 +204,7 @@ Response `200 OK`:
"reservation_id": 123, "reservation_id": 123,
"status": "confirmed", "status": "confirmed",
"party_size": 2, "party_size": 2,
"qr_code_url": "https://example.org/api/reservations/123/qr-code/" "qr_code_url": "https://example.org/api/check-ins/preview/?token=opaque-check-in-token"
} }
``` ```
@@ -222,13 +222,14 @@ Status codes:
GET /api/reservations/{id}/qr-code/ GET /api/reservations/{id}/qr-code/
``` ```
Returns the generated QR code for a confirmed reservation. Access must be protected by a valid QR token, signed URL, or equivalent control so that reservation IDs are not enough to retrieve QR codes. Returns the generated QR code for a confirmed reservation. Access must be protected by a valid opaque `check_in` token, signed URL, or equivalent control so that reservation IDs are not enough to retrieve QR codes.
Response `200 OK`: Response `200 OK`:
```json ```json
{ {
"reservation_id": 123, "reservation_id": 123,
"qr_code_url": "https://example.org/api/check-ins/preview/?token=opaque-check-in-token",
"qr_code_image": "data:image/png;base64,...", "qr_code_image": "data:image/png;base64,...",
"printable": true "printable": true
} }
@@ -271,6 +272,7 @@ Response `200 OK`:
"reservation_id": 123, "reservation_id": 123,
"performance_id": 10, "performance_id": 10,
"show_title": "The Open Stage", "show_title": "The Open Stage",
"venue_name": "AzioneLab Theatre",
"starts_at": "2026-05-15T20:30:00+02:00", "starts_at": "2026-05-15T20:30:00+02:00",
"party_size": 2 "party_size": 2
} }
+24 -3
View File
@@ -25,7 +25,7 @@ Availability shown to visitors is informational. The backend recalculates availa
- requested seats do not exceed currently available seats. - requested seats do not exceed currently available seats.
6. The backend creates a `pending` reservation. 6. The backend creates a `pending` reservation.
7. The backend creates a random opaque confirmation token. 7. The backend creates a random opaque confirmation token.
8. The backend sends an email with a confirmation link. 8. After the transaction commits successfully, the backend sends an email with a confirmation link.
9. The frontend tells the visitor to check their email. 9. The frontend tells the visitor to check their email.
The reservation is not confirmed at this stage. The reservation is not confirmed at this stage.
@@ -45,8 +45,8 @@ The reservation is not confirmed at this stage.
6. The backend recalculates confirmed reservations for the performance. 6. The backend recalculates confirmed reservations for the performance.
7. The backend confirms the reservation only if enough seats remain. 7. The backend confirms the reservation only if enough seats remain.
8. The backend marks the confirmation token as used. 8. The backend marks the confirmation token as used.
9. The backend creates a QR verification token. 9. The backend creates a separate `check_in` token for QR verification.
10. The backend generates a QR code containing the opaque QR token or a verification URL. 10. The backend generates a QR code containing only the opaque check-in token or a verification URL built from that token.
11. The backend returns or sends the QR code to the visitor. 11. The backend returns or sends the QR code to the visitor.
If there is no longer enough capacity, the backend must not confirm the reservation. If there is no longer enough capacity, the backend must not confirm the reservation.
@@ -101,6 +101,27 @@ The QR code must not contain:
The token remains opaque throughout the flow. The QR code must not expose visitor name, email address, phone number, notes, or other personal data. The token remains opaque throughout the flow. The QR code must not expose visitor name, email address, phone number, notes, or other personal data.
## Manual Reservations In Admin
Staff can also create a reservation manually from Django admin for a specific performance.
This operational flow should still follow the same backend rules as the public booking flow:
1. staff selects the performance and enters guest contact details and party size;
2. the backend validates booking availability and capacity;
3. the backend creates a `pending` reservation;
4. the backend creates the normal confirmation token;
5. after the reservation transaction commits, the backend sends the standard confirmation email;
6. the guest still confirms through the email link before the reservation becomes confirmed and usable for check-in.
For operational testing or guest-support exceptions, Django admin also provides staff-only manual tools:
1. staff may confirm a pending reservation from the reservation admin page;
2. manual confirmation still rechecks booking availability before confirming;
3. the backend generates the same `check_in` token type used by the normal confirmation flow;
4. admin can generate a one-time operational QR code and check-in URL without showing token hashes in normal admin screens;
5. staff may mark a confirmed reservation as checked in from admin when the browser/mobile check-in flow is unavailable.
## Duplicate Check-In ## Duplicate Check-In
If the same QR code is scanned again: If the same QR code is scanned again:
+40 -1
View File
@@ -1,5 +1,28 @@
# Deployment # Deployment
## Production Readiness
Before a real deployment, treat `.env.example` as local-development only. Create a separate `.env` for production and replace all placeholder values.
Required production changes:
- set `DJANGO_DEBUG=false`;
- set a strong random `DJANGO_SECRET_KEY`;
- set `DJANGO_ALLOWED_HOSTS` to the real public hostnames only;
- set `DJANGO_CSRF_TRUSTED_ORIGINS` to the real public HTTPS origins;
- set `SITE_BASE_URL` to 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: AzioneLab should deploy with a simple Docker Compose topology:
- `nginx`: public reverse proxy and static frontend server; - `nginx`: public reverse proxy and static frontend server;
@@ -83,7 +106,7 @@ The database should not be published to the host in production.
Recommended volumes: Recommended volumes:
- `postgres_data`: PostgreSQL data directory; - `postgres_data`: PostgreSQL data directory;
- `media`: uploaded media and generated QR assets if stored on disk; - `media`: uploaded show images and generated QR assets if stored on disk;
- `static`: collected Django static files if served by nginx from a shared volume. - `static`: collected Django static files if served by nginx from a shared volume.
Generated QR codes may also be generated on demand instead of stored as files. If stored, they must not reveal personal data and access must remain controlled. Generated QR codes may also be generated on demand instead of stored as files. If stored, they must not reveal personal data and access must remain controlled.
@@ -92,17 +115,30 @@ Generated QR codes may also be generated on demand instead of stored as files. I
Copy `.env.example` to `.env` and replace all placeholder values before running or deploying the stack. Copy `.env.example` to `.env` and replace all placeholder values before running or deploying the stack.
`.env.example` is intentionally local-dev oriented. Do not use it unchanged for production.
Required backend configuration: Required backend configuration:
- `DJANGO_SECRET_KEY`; - `DJANGO_SECRET_KEY`;
- `DJANGO_ALLOWED_HOSTS`; - `DJANGO_ALLOWED_HOSTS`;
- `DJANGO_CSRF_TRUSTED_ORIGINS`; - `DJANGO_CSRF_TRUSTED_ORIGINS`;
- `DJANGO_DEBUG=false`;
- `CORS_ALLOWED_ORIGINS`; - `CORS_ALLOWED_ORIGINS`;
- `SITE_BASE_URL`;
- `TIME_ZONE`; - `TIME_ZONE`;
- `DATABASE_URL` or equivalent database settings; - `DATABASE_URL` or equivalent database settings;
- email host, port, username, password, TLS settings, and sender address; - email host, port, username, password, TLS settings, and sender address;
- public site URL used to build confirmation and QR verification links. - public site URL used to build confirmation and QR verification links.
Local Docker convention:
- use nginx as the public entrypoint at `http://localhost`;
- set `SITE_BASE_URL=http://localhost`;
- keep `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: Required database configuration:
- database name; - database name;
@@ -115,6 +151,7 @@ Required nginx configuration:
- upstream backend service name and port; - upstream backend service name and port;
- static frontend root; - static frontend root;
- proxy rules for `/api/` and `/admin/`; - proxy rules for `/api/` and `/admin/`;
- media root for `/media/` if uploaded assets are served by nginx from a shared volume;
- TLS certificate paths for production. - TLS certificate paths for production.
Secrets must be provided through deployment-managed environment variables, Docker secrets, or another secret manager. Do not commit real secret values. Secrets must be provided through deployment-managed environment variables, Docker secrets, or another secret manager. Do not commit real secret values.
@@ -142,6 +179,7 @@ Expected production-style flow:
docker compose --env-file .env -f infra/docker/compose.yml build docker compose --env-file .env -f infra/docker/compose.yml build
docker compose --env-file .env -f infra/docker/compose.yml run --rm backend python manage.py migrate docker compose --env-file .env -f infra/docker/compose.yml run --rm backend python manage.py migrate
docker compose --env-file .env -f infra/docker/compose.yml run --rm backend python manage.py collectstatic --noinput docker compose --env-file .env -f infra/docker/compose.yml run --rm backend python manage.py collectstatic --noinput
docker compose --env-file .env -f infra/docker/compose.yml run --rm backend python manage.py createsuperuser
docker compose --env-file .env -f infra/docker/compose.yml up -d docker compose --env-file .env -f infra/docker/compose.yml up -d
``` ```
@@ -178,6 +216,7 @@ Database rollback needs special care once migrations exist. Down migrations or b
## Operational Notes ## Operational Notes
- Configure database backups before accepting real bookings. - Configure database backups before accepting real bookings.
- Back up the shared media volume together with the database if staff uploads show images.
- Monitor backend errors, email delivery failures, and check-in failures. - Monitor backend errors, email delivery failures, and check-in failures.
- Keep container images explicitly versioned; do not use `latest` tags. - Keep container images explicitly versioned; do not use `latest` tags.
- Keep the system small until operational needs justify additional services. - Keep the system small until operational needs justify additional services.
+15 -1
View File
@@ -36,6 +36,9 @@ Rules:
- do not encode personal data in tokens; - do not encode personal data in tokens;
- store token hashes when practical; - store token hashes when practical;
- treat raw tokens as secrets; - treat raw tokens as secrets;
- keep confirmation tokens and check-in tokens separate by purpose;
- allow confirmation tokens only for reservation confirmation;
- allow check-in tokens only for QR retrieval and check-in validation;
- mark one-time confirmation tokens as used after successful confirmation; - mark one-time confirmation tokens as used after successful confirmation;
- expire confirmation tokens after a reasonable period; - expire confirmation tokens after a reasonable period;
- keep QR tokens valid only for the intended performance and check-in period where practical. - keep QR tokens valid only for the intended performance and check-in period where practical.
@@ -144,9 +147,20 @@ Deployment should follow least privilege:
- avoid privileged containers; - avoid privileged containers;
- use explicit image tags rather than `latest`; - use explicit image tags rather than `latest`;
- persist PostgreSQL data in a named volume; - persist PostgreSQL data in a named volume;
- run production with `DJANGO_DEBUG=false`;
- use a strong private `DJANGO_SECRET_KEY`;
- restrict `DJANGO_ALLOWED_HOSTS` and `DJANGO_CSRF_TRUSTED_ORIGINS` to the real public deployment hosts;
- keep `SITE_BASE_URL` set to the real public HTTPS URL so email and QR links are correct;
- configure TLS for production; - configure TLS for production;
- serve static and media files without exposing private files. - serve static and media files without exposing private files.
Operational production notes:
- `.env.example` is for local development and examples only, not direct production use;
- replace the console email backend with real SMTP settings before sending reservation emails;
- create admin accounts explicitly and protect them with strong passwords and limited access;
- keep verified database backups for the PostgreSQL volume before accepting live bookings.
## Logging ## Logging
Logs should help diagnose operational issues without exposing sensitive data. Logs should help diagnose operational issues without exposing sensitive data.
@@ -165,7 +179,7 @@ Do not log:
Initial residual risks: Initial residual risks:
- synchronous email can make booking responses depend on SMTP availability; - synchronous email after commit can still add latency to booking requests even though it no longer runs inside the reservation transaction;
- QR codes can be copied, so duplicate check-in prevention must be reliable; - QR codes can be copied, so duplicate check-in prevention must be reliable;
- staff account compromise would expose admin and check-in functionality; - staff account compromise would expose admin and check-in functionality;
- retention and deletion rules for personal data still need a project policy. - retention and deletion rules for personal data still need a project policy.
+5
View File
@@ -21,6 +21,11 @@
{ {
"glob": "**/*", "glob": "**/*",
"input": "public" "input": "public"
},
{
"glob": "**/*",
"input": "src/assets",
"output": "assets"
} }
], ],
"styles": [ "styles": [
+140 -50
View File
@@ -1,8 +1,6 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'; import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatToolbarModule } from '@angular/material/toolbar';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
@@ -11,106 +9,198 @@ import { MatToolbarModule } from '@angular/material/toolbar';
RouterOutlet, RouterOutlet,
RouterLink, RouterLink,
RouterLinkActive, RouterLinkActive,
MatToolbarModule,
MatButtonModule, MatButtonModule,
MatIconModule,
], ],
template: ` template: `
<div class="app-shell"> <div class="app-shell">
<mat-toolbar class="app-toolbar"> <header class="site-header">
<a class="brand" routerLink="/"> <div class="header-inner">
<span class="brand-mark">A</span> <a class="brand" routerLink="/" aria-label="AzioneLab">
<span class="brand-text"> <img class="brand-logo" src="assets/azionelab-logo.png" alt="AzioneLab" />
<strong>AzioneLab</strong> </a>
<small>Theatre and reservations</small>
</span>
</a>
<nav class="main-nav"> <nav class="main-nav" aria-label="Primary navigation">
<a mat-button routerLink="/" routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }">Home</a> <a mat-button routerLink="/" routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }">Inizio</a>
<a mat-button routerLink="/shows" routerLinkActive="active">Shows</a> <a mat-button routerLink="/shows" routerLinkActive="active">Spettacoli</a>
<a mat-button routerLink="/check-in" routerLinkActive="active">Check-in</a> <a mat-button routerLink="/check-in" routerLinkActive="active">Accoglienza</a>
</nav> </nav>
</mat-toolbar> </div>
</header>
<main class="page-shell"> <main class="page-shell">
<router-outlet></router-outlet> <router-outlet></router-outlet>
</main> </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> </div>
`, `,
styles: [` styles: [`
.app-shell { .app-shell {
min-height: 100vh; min-height: 100vh;
display: flex;
flex-direction: column;
} }
.app-toolbar { .topline-inner,
.header-inner,
.footer-inner {
width: min(100%, var(--azionelab-shell-width));
margin: 0 auto;
}
.site-header {
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 10; z-index: 20;
background: var(--azionelab-surface);
border-bottom: 1px solid var(--azionelab-border);
}
.header-inner {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
gap: 16px; align-items: center;
gap: 20px;
min-height: 72px; min-height: 72px;
padding: 0 24px; padding: 0 24px;
background: rgba(251, 247, 242, 0.88);
backdrop-filter: blur(18px);
border-bottom: 1px solid var(--azionelab-border);
} }
.brand { .brand {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 12px; 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; color: inherit;
text-decoration: none; text-decoration: none;
} }
.brand-mark { .brand-logo {
display: inline-grid; width: 164px;
place-items: center; height: auto;
width: 42px; max-height: 44px;
height: 42px; display: block;
border-radius: 10px; background: transparent;
background: linear-gradient(135deg, var(--azionelab-accent), #ca6d3b); box-shadow: none;
color: white; filter: none;
font-weight: 700; opacity: 1;
} mix-blend-mode: normal;
mask: none;
.brand-text { -webkit-mask: none;
display: flex;
flex-direction: column;
line-height: 1.1;
}
.brand-text small {
color: var(--azionelab-muted);
font-size: 0.74rem;
} }
.main-nav { .main-nav {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-end;
gap: 4px; gap: 4px;
flex-wrap: wrap; flex-wrap: wrap;
} }
.main-nav .active { .main-nav .active {
background: rgba(159, 47, 40, 0.08); color: var(--azionelab-accent-strong);
} }
.page-shell { .page-shell {
padding: 32px 20px 56px; 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) { @media (max-width: 800px) {
.app-toolbar { .header-inner {
align-items: flex-start;
flex-direction: column; flex-direction: column;
padding: 16px 16px 14px; align-items: flex-start;
min-height: auto;
padding: 14px 16px;
gap: 14px;
} }
.main-nav { .main-nav {
width: 100%; 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;
} }
} }
`], `],
+5 -3
View File
@@ -3,14 +3,16 @@ import { Routes } from '@angular/router';
import { BookingPlaceholderPageComponent } from './pages/booking-placeholder-page.component'; import { BookingPlaceholderPageComponent } from './pages/booking-placeholder-page.component';
import { CheckInPlaceholderPageComponent } from './pages/check-in-placeholder-page.component'; import { CheckInPlaceholderPageComponent } from './pages/check-in-placeholder-page.component';
import { HomePageComponent } from './pages/home-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 { ShowDetailPlaceholderPageComponent } from './pages/show-detail-placeholder-page.component';
import { ShowListPageComponent } from './pages/show-list-page.component'; import { ShowListPageComponent } from './pages/show-list-page.component';
export const appRoutes: Routes = [ export const appRoutes: Routes = [
{ path: '', component: HomePageComponent, title: 'AzioneLab' }, { path: '', component: HomePageComponent, title: 'AzioneLab' },
{ path: 'shows', component: ShowListPageComponent, title: 'Shows | AzioneLab' }, { path: 'shows', component: ShowListPageComponent, title: 'Spettacoli | AzioneLab' },
{ path: 'shows/:slug', component: ShowDetailPlaceholderPageComponent, title: 'Show detail | AzioneLab' }, { path: 'shows/:slug', component: ShowDetailPlaceholderPageComponent, title: 'Scheda spettacolo | AzioneLab' },
{ path: 'book/:performanceId', component: BookingPlaceholderPageComponent, title: 'Book | 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: 'check-in', component: CheckInPlaceholderPageComponent, title: 'Check-in | AzioneLab' },
{ path: '**', redirectTo: '' }, { path: '**', redirectTo: '' },
]; ];
@@ -1,74 +1,409 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'; import { ChangeDetectionStrategy, Component, DestroyRef, inject, signal } from '@angular/core';
import { ActivatedRoute } from '@angular/router'; 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 { MatCardModule } from '@angular/material/card';
import { MatDividerModule } from '@angular/material/divider'; import { MatFormFieldModule } from '@angular/material/form-field';
import { MatListModule } from '@angular/material/list'; 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({ @Component({
standalone: true, standalone: true,
imports: [MatCardModule, MatDividerModule, MatListModule], imports: [
ReactiveFormsModule,
RouterLink,
MatButtonModule,
MatCardModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatProgressSpinnerModule,
],
template: ` template: `
<section class="page"> <section class="page">
<header class="page-header"> <div class="booking-shell">
<p class="eyebrow">Booking</p> <header class="page-header">
<h1>Performance {{ performanceId }}</h1> <p class="eyebrow">Prenotazione</p>
<p class="supporting"> <h1>Prenota il tuo posto</h1>
This page will host the reservation form and confirmation states backed by the existing booking APIs. <p class="supporting">
</p> Compila il modulo con i dati essenziali. Ti invieremo un'email per confermare la richiesta e completare la prenotazione con serenita'.
</header> </p>
</header>
<mat-card class="content-card"> <mat-card class="content-card">
<mat-card-title>Planned interactions</mat-card-title> <mat-card-content>
<mat-card-content> @if (isSuccess()) {
<mat-list> <div class="status-panel success" aria-live="polite">
<mat-list-item>Load performance detail and availability</mat-list-item> <div class="status-icon">
<mat-list-item>Submit pending reservation</mat-list-item> <mat-icon fontSet="material-symbols-outlined">mark_email_read</mat-icon>
<mat-list-item>Show email confirmation guidance</mat-list-item> </div>
</mat-list> <div>
</mat-card-content> <h2>Controlla la tua email</h2>
</mat-card> <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> </section>
`, `,
styles: [` styles: [`
.page { .booking-shell {
max-width: 900px; width: min(100%, 480px);
margin: 0 auto; margin: 0 auto;
} }
.page-header { .page-header {
margin-bottom: 22px; margin: 0 0 28px;
} text-align: center;
.eyebrow {
margin: 0 0 10px;
color: var(--azionelab-accent);
text-transform: uppercase;
font-size: 0.78rem;
font-weight: 700;
} }
h1 { h1 {
margin: 0; margin: 0;
font-size: clamp(2rem, 4vw, 3rem); font-size: 3rem;
} }
.supporting { .supporting {
color: var(--azionelab-muted); margin: 16px auto 0;
line-height: 1.6; max-width: 42ch;
max-width: 50ch;
} }
.content-card { .content-card {
border-radius: 8px; border-radius: var(--azionelab-radius-lg);
border: 1px solid var(--azionelab-border); border: 1px solid var(--azionelab-border);
background: var(--azionelab-surface); background: var(--azionelab-surface);
box-shadow: var(--azionelab-shadow); 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, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class BookingPlaceholderPageComponent { export class BookingPlaceholderPageComponent {
protected readonly performanceId = this.route.snapshot.paramMap.get('performanceId') ?? '0'; private readonly destroyRef = inject(DestroyRef);
private readonly formBuilder = inject(FormBuilder);
private readonly route = inject(ActivatedRoute);
private readonly showsApi = inject(ShowsApiService);
constructor(private readonly route: ActivatedRoute) {} 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;
}
} }
@@ -1,72 +1,635 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'; import { DatePipe } from '@angular/common';
import { HttpErrorResponse } from '@angular/common/http';
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
ElementRef,
ViewChild,
inject,
signal,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import {
CheckInConfirmResponse,
CheckInPreviewResponse,
ShowsApiService,
} from '../services/shows-api.service';
type UiState =
| 'idle'
| 'preview_loading'
| 'preview_success'
| 'confirm_loading'
| 'confirm_success'
| 'invalid_token'
| 'pending_reservation'
| 'already_checked_in'
| 'unauthorized'
| 'error';
type CameraState = 'ready' | 'starting' | 'active' | 'unsupported' | 'denied' | 'error';
type DetectedBarcode = {
rawValue?: string;
};
type BarcodeDetectorInstance = {
detect(source: HTMLCanvasElement): Promise<DetectedBarcode[]>;
};
type BarcodeDetectorConstructor = new (options?: { formats?: string[] }) => BarcodeDetectorInstance;
@Component({ @Component({
standalone: true, standalone: true,
imports: [MatCardModule, MatFormFieldModule, MatInputModule], imports: [
DatePipe,
ReactiveFormsModule,
RouterLink,
MatButtonModule,
MatCardModule,
MatFormFieldModule,
MatInputModule,
MatProgressSpinnerModule,
],
template: ` template: `
<section class="page"> <section class="page">
<header class="page-header"> <header class="page-header">
<p class="eyebrow">Staff check-in</p> <p class="eyebrow">Accoglienza staff</p>
<h1>Mobile-friendly placeholder</h1> <h1>Controllo accessi</h1>
<p class="supporting"> <p class="supporting">Usa questa pagina per verificare rapidamente il QR code o il token della prenotazione e registrare l'ingresso senza incertezze.</p>
This route is ready for the authenticated token preview and check-in confirmation flow.
</p>
</header> </header>
<mat-card class="content-card"> <div class="checkin-grid">
<mat-card-title>Future scan/lookup input</mat-card-title> <mat-card class="side-card">
<mat-card-content> <mat-card-content>
<mat-form-field appearance="outline" class="full-width"> <p class="side-label">Ingresso sala</p>
<mat-label>Opaque QR token</mat-label> <h2>Uno strumento pensato per accogliere bene, anche nei momenti piu' intensi.</h2>
<input matInput placeholder="Paste or scan token" readonly> <ul class="side-list">
</mat-form-field> <li>Inquadra il QR code se la fotocamera del dispositivo e' disponibile.</li>
</mat-card-content> <li>Inserisci il token a mano se la scansione non e' praticabile.</li>
</mat-card> <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> </section>
`, `,
styles: [` styles: [`
.page {
max-width: 760px;
margin: 0 auto;
}
.page-header { .page-header {
margin-bottom: 22px; margin-bottom: 24px;
}
.eyebrow {
margin: 0 0 10px;
color: var(--azionelab-accent);
text-transform: uppercase;
font-size: 0.78rem;
font-weight: 700;
}
h1 {
margin: 0;
font-size: clamp(2rem, 4vw, 3rem);
} }
.supporting { .supporting {
color: var(--azionelab-muted);
line-height: 1.6;
max-width: 50ch; 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 { .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-radius: 8px;
border: 1px solid var(--azionelab-border); border: 1px solid var(--azionelab-border);
background: var(--azionelab-surface); background: #151515;
box-shadow: var(--azionelab-shadow); }
.camera-frame video {
display: block;
width: 100%;
aspect-ratio: 4 / 3;
object-fit: cover;
}
.scanner-canvas {
display: none;
} }
.full-width { .full-width {
width: 100%; width: 100%;
} }
.actions {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.scanner-actions {
justify-content: flex-start;
}
button[mat-flat-button],
button[mat-stroked-button] {
min-width: 150px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.preview-panel {
margin-top: 18px;
border-top: 1px solid var(--azionelab-border);
padding-top: 16px;
}
.preview-panel h2 {
margin: 0 0 12px;
font-size: 1.15rem;
}
.preview-panel dl {
display: grid;
gap: 10px;
margin: 0 0 16px;
}
.preview-panel dt {
font-size: 0.8rem;
text-transform: uppercase;
color: var(--azionelab-muted);
font-weight: 700;
}
.preview-panel dd {
margin: 2px 0 0;
}
.success-message {
margin: 16px 0 0;
color: #2e7d32;
font-weight: 600;
}
.error-message {
margin: 16px 0 0;
color: #b3261e;
font-weight: 500;
}
@media (max-width: 760px) {
.checkin-grid {
grid-template-columns: 1fr;
}
}
`], `],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class CheckInPlaceholderPageComponent {} 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');
}
}
+172 -54
View File
@@ -1,113 +1,231 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { API_BASE_URL } from '../services/api-config.token';
@Component({ @Component({
standalone: true, standalone: true,
imports: [RouterLink, MatButtonModule, MatCardModule], imports: [RouterLink, MatButtonModule, MatCardModule],
template: ` template: `
<section class="hero"> <section class="hero">
<div class="hero-copy"> <div class="hero-inner page">
<p class="eyebrow">AzioneLab Theatre Company</p> <div class="hero-copy">
<h1>Public website and booking UI foundations.</h1> <p class="hero-kicker">AZIONELAB</p>
<p class="supporting"> <h1>Laboratori teatrali & produzioni audio/visive</h1>
This Angular shell is wired for the existing Django APIs and ready for the next booking-focused iterations. <p class="hero-direction">Direzione artistica a cura di Ernesto Estatico</p>
</p> <p class="supporting">
<div class="hero-actions"> 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.
<a mat-flat-button color="primary" routerLink="/shows">Browse shows</a> </p>
<a mat-stroked-button routerLink="/check-in">Check-in area</a> <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>
</div> </div>
</section>
<div class="hero-panel"> <section class="overview page">
<mat-card> <div class="section-heading">
<mat-card-title>Frontend wiring</mat-card-title> <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> <mat-card-content>
<p><strong>API base URL</strong></p> <p>Le schede mettono in evidenza i dettagli utili senza appesantire la scena: titolo, sintesi, immagini e accesso alla prenotazione.</p>
<code>{{ apiBaseUrl }}</code> </mat-card-content>
<p class="panel-note">Placeholders are in place for public content, booking, and staff check-in flows.</p> </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-content>
</mat-card> </mat-card>
</div> </div>
</section> </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: [` styles: [`
.hero { .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; display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(280px, 0.9fr); grid-template-columns: minmax(0, 1.3fr) minmax(240px, 0.7fr);
gap: 28px; gap: 36px;
align-items: stretch; align-items: end;
max-width: 1180px;
margin: 0 auto;
} }
.hero-copy { .hero-copy {
padding: 36px 0; padding-top: 18px;
} }
.eyebrow { .hero-kicker {
margin: 0 0 12px; margin: 0 0 18px;
color: var(--azionelab-accent); color: var(--azionelab-accent);
font-size: 0.9rem;
font-weight: 800;
letter-spacing: 0.18em;
text-transform: uppercase; text-transform: uppercase;
font-size: 0.78rem;
font-weight: 700;
} }
h1 { h1 {
margin: 0; margin: 0;
max-width: 10ch; max-width: 11ch;
font-size: clamp(2.5rem, 5vw, 4.75rem); font-size: 4.3rem;
line-height: 0.95; line-height: 0.96;
} }
.supporting { .hero-direction {
max-width: 52ch;
color: var(--azionelab-muted);
font-size: 1.08rem;
line-height: 1.65;
margin: 20px 0 0; margin: 20px 0 0;
color: var(--azionelab-ink-soft);
font-size: 1.05rem;
line-height: 1.5;
} }
.hero-actions { .hero-actions {
display: flex; margin-top: 34px;
gap: 12px;
flex-wrap: wrap;
margin-top: 28px;
} }
.hero-panel mat-card { .hero-aside {
height: 100%; display: grid;
border-radius: 8px; 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); border: 1px solid var(--azionelab-border);
background: var(--azionelab-surface); background: var(--azionelab-surface);
box-shadow: var(--azionelab-shadow); box-shadow: var(--azionelab-shadow);
} }
code { .feature-card {
display: inline-block; min-height: 220px;
margin-top: 8px;
padding: 10px 12px;
border-radius: 8px;
background: rgba(30, 27, 24, 0.06);
} }
.panel-note { .feature-card mat-card-title {
margin-top: 20px; margin-bottom: 12px;
font-family: var(--azionelab-serif);
font-size: 1.28rem;
font-weight: 600;
}
.feature-card p {
margin: 0;
color: var(--azionelab-muted); color: var(--azionelab-muted);
line-height: 1.5; 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) { @media (max-width: 900px) {
.hero { .hero-inner,
.section-heading,
.journey-steps,
.feature-grid {
grid-template-columns: 1fr; 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, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class HomePageComponent { export class HomePageComponent {
protected readonly apiBaseUrl = inject(API_BASE_URL);
} }
@@ -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');
},
});
}
}
@@ -1,73 +1,357 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'; import { DatePipe } from '@angular/common';
import { TitleCasePipe } 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 { ActivatedRoute, RouterLink } from '@angular/router';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card'; 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({ @Component({
standalone: true, standalone: true,
imports: [TitleCasePipe, RouterLink, MatButtonModule, MatCardModule], imports: [
DatePipe,
RouterLink,
MatButtonModule,
MatCardModule,
MatIconModule,
MatProgressSpinnerModule,
],
template: ` template: `
<section class="page"> <section class="page">
<header class="page-header"> @if (isLoading()) {
<p class="eyebrow">Show detail</p> <div class="status-panel" aria-live="polite">
<h1>{{ slug | titlecase }}</h1> <mat-progress-spinner mode="indeterminate" diameter="40"></mat-progress-spinner>
<p class="supporting"> <p>Caricamento dei dettagli dello spettacolo...</p>
This placeholder will bind to the public show detail and performance endpoints. </div>
</p> } @else if (errorMessage()) {
</header> <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>
}
<mat-card class="content-card"> <header class="page-header">
<mat-card-title>Next UI step</mat-card-title> <div class="hero-copy">
<mat-card-content> <p class="eyebrow">Scheda spettacolo</p>
<p>Wire show copy, upcoming performances, and booking entry points from the backend contract.</p> <h1>{{ show()!.title }}</h1>
</mat-card-content> <p class="hero-description">{{ show()!.description || show()!.summary }}</p>
<mat-card-actions> </div>
<a mat-button routerLink="/book/10">Open booking placeholder</a> </header>
</mat-card-actions> </section>
</mat-card>
<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> </section>
`, `,
styles: [` styles: [`
.page { .show-hero {
max-width: 960px; display: grid;
margin: 0 auto; gap: 28px;
margin-bottom: 48px;
} }
.page-header { .page-header {
margin-bottom: 22px; align-items: start;
} }
.eyebrow { .hero-copy {
margin: 0 0 10px; min-width: 0;
color: var(--azionelab-accent); max-width: 860px;
text-transform: uppercase;
font-size: 0.78rem;
font-weight: 700;
} }
h1 { h1 {
margin: 0; margin: 0;
font-size: clamp(2rem, 4vw, 3.2rem); max-width: 12ch;
font-size: 4rem;
line-height: 0.98;
} }
.supporting { .hero-description {
margin: 18px 0 0;
max-width: 62ch;
color: var(--azionelab-muted); color: var(--azionelab-muted);
line-height: 1.6; font-size: 1.08rem;
max-width: 52ch; line-height: 1.82;
} }
.content-card { .hero-image-wrap {
border-radius: 8px; 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); border: 1px solid var(--azionelab-border);
background: var(--azionelab-surface); background: var(--azionelab-surface);
box-shadow: var(--azionelab-shadow); 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, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class ShowDetailPlaceholderPageComponent { export class ShowDetailPlaceholderPageComponent {
protected readonly slug = this.route.snapshot.paramMap.get('slug') ?? 'show'; private readonly destroyRef = inject(DestroyRef);
private readonly route = inject(ActivatedRoute);
private readonly showsApi = inject(ShowsApiService);
constructor(private readonly route: ActivatedRoute) {} 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);
},
});
}
} }
@@ -1,125 +1,284 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'; import { ChangeDetectionStrategy, Component, DestroyRef, inject, signal } from '@angular/core';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { MatChipsModule } from '@angular/material/chips'; import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
type DemoShow = { import { ShowListItem, ShowsApiService } from '../services/shows-api.service';
slug: string;
title: string;
summary: string;
venue: string;
startsAt: string;
};
@Component({ @Component({
standalone: true, standalone: true,
imports: [RouterLink, MatButtonModule, MatCardModule, MatChipsModule], imports: [RouterLink, MatButtonModule, MatCardModule, MatIconModule, MatProgressSpinnerModule],
template: ` template: `
<section class="page"> <section class="page">
<header class="page-header"> <header class="page-header">
<div> <div>
<p class="eyebrow">Public shows</p> <p class="eyebrow">Programmazione</p>
<h1>Show list placeholder</h1> <h1>Spettacoli in programma</h1>
</div> </div>
<p class="supporting"> <p class="supporting">
This page is ready to bind to <code>GET /api/shows/</code> and <code>GET /api/performances/</code>. Una selezione di lavori, attraversamenti scenici e appuntamenti da leggere con calma, immagine dopo immagine, scheda dopo scheda.
</p> </p>
</header> </header>
<div class="show-grid"> @if (isLoading()) {
@for (show of demoShows; track show.slug) { <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"> <mat-card class="show-card">
<mat-card-title>{{ show.title }}</mat-card-title> @if (getShowImage(show); as showImage) {
<mat-card-subtitle>{{ show.venue }}</mat-card-subtitle> <div class="show-image-wrap">
<mat-card-content> <img class="show-image" [src]="showImage" [alt]="show.title" />
<p>{{ show.summary }}</p> </div>
<mat-chip-set> }
<mat-chip>{{ show.startsAt }}</mat-chip> <div class="card-body">
</mat-chip-set> <div class="card-topline">
</mat-card-content> <span class="card-label">In programma</span>
<mat-card-actions> </div>
<a mat-button [routerLink]="['/shows', show.slug]">Open detail</a> <mat-card-title>{{ show.title }}</mat-card-title>
</mat-card-actions> <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> </mat-card>
} }
</div> </div>
}
</section> </section>
`, `,
styles: [` styles: [`
.page {
max-width: 1180px;
margin: 0 auto;
}
.page-header { .page-header {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) minmax(260px, 380px); grid-template-columns: minmax(0, 1fr) minmax(300px, 430px);
gap: 24px; gap: 28px;
align-items: end; align-items: end;
margin-bottom: 24px; margin-bottom: 40px;
}
.eyebrow {
margin: 0 0 10px;
color: var(--azionelab-accent);
text-transform: uppercase;
font-size: 0.78rem;
font-weight: 700;
}
h1 {
margin: 0;
font-size: clamp(2rem, 4vw, 3rem);
} }
.supporting { .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; margin: 0;
color: var(--azionelab-muted); color: var(--azionelab-muted);
line-height: 1.6; line-height: 1.6;
} }
.show-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 20px;
}
.show-card { .show-card {
border-radius: 8px; display: flex;
flex-direction: column;
min-height: 520px;
overflow: hidden;
border-radius: var(--azionelab-radius-md);
border: 1px solid var(--azionelab-border); border: 1px solid var(--azionelab-border);
background: var(--azionelab-surface); background: var(--azionelab-surface);
box-shadow: var(--azionelab-shadow); 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 { .show-card p {
color: var(--azionelab-muted); color: var(--azionelab-muted);
line-height: 1.6; 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) { @media (max-width: 860px) {
.page-header { .page-header {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.show-grid {
grid-template-columns: 1fr;
}
} }
`], `],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class ShowListPageComponent { export class ShowListPageComponent {
protected readonly demoShows: DemoShow[] = [ private readonly destroyRef = inject(DestroyRef);
{ private readonly showsApi = inject(ShowsApiService);
slug: 'open-stage',
title: 'Open Stage', protected readonly shows = signal<ShowListItem[]>([]);
summary: 'Placeholder list item for the first published production.', protected readonly isLoading = signal(true);
venue: 'AzioneLab Theatre', protected readonly errorMessage = signal('');
startsAt: 'May 15, 20:30',
}, constructor() {
{ this.loadShows();
slug: 'city-echoes', }
title: 'City Echoes',
summary: 'Second sample entry showing how cards will map to live API data.', protected reload(): void {
venue: 'Studio Nuovo', this.loadShows();
startsAt: 'May 22, 18:00', }
},
]; 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);
},
});
}
} }
@@ -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 '';
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

+15
View 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 &amp; 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

+7 -1
View File
@@ -1,10 +1,16 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="it">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>AzioneLab</title> <title>AzioneLab</title>
<base href="/"> <base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1"> <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> </head>
<body> <body>
<app-root></app-root> <app-root></app-root>
+148 -14
View File
@@ -1,12 +1,36 @@
:root { :root {
--azionelab-bg: #f3eee6; --azionelab-bg: #fcfbf8;
--azionelab-surface: rgba(255, 255, 255, 0.78); --azionelab-bg-strong: #f5f3ee;
--azionelab-ink: #1e1b18; --azionelab-surface: #ffffff;
--azionelab-muted: #645b53; --azionelab-surface-strong: #ffffff;
--azionelab-accent: #9f2f28; --azionelab-surface-dark: #121212;
--azionelab-accent-strong: #7f211c; --azionelab-ink: #111111;
--azionelab-border: rgba(30, 27, 24, 0.12); --azionelab-ink-soft: #1e1e1e;
--azionelab-shadow: 0 18px 48px rgba(46, 28, 18, 0.12); --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);
} }
* { * {
@@ -16,18 +40,128 @@
html, body { html, body {
margin: 0; margin: 0;
min-height: 100%; min-height: 100%;
font-family: "Segoe UI", "Helvetica Neue", sans-serif; font-family: var(--azionelab-sans);
font-weight: 500;
color: var(--azionelab-ink); color: var(--azionelab-ink);
background: background: var(--azionelab-bg);
radial-gradient(circle at top right, rgba(159, 47, 40, 0.12), transparent 28%),
radial-gradient(circle at left center, rgba(140, 116, 86, 0.14), transparent 35%),
linear-gradient(180deg, #fbf7f2 0%, var(--azionelab-bg) 100%);
} }
body { body {
min-height: 100vh; min-height: 100vh;
} }
button, input, textarea { 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; 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;
}
}
+10
View File
@@ -10,6 +10,8 @@ services:
DJANGO_CSRF_TRUSTED_ORIGINS: ${DJANGO_CSRF_TRUSTED_ORIGINS} DJANGO_CSRF_TRUSTED_ORIGINS: ${DJANGO_CSRF_TRUSTED_ORIGINS}
DJANGO_DEBUG: ${DJANGO_DEBUG:-false} DJANGO_DEBUG: ${DJANGO_DEBUG:-false}
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS} CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS}
SITE_BASE_URL: ${SITE_BASE_URL}
EMAIL_BACKEND: ${EMAIL_BACKEND}
TIME_ZONE: ${TIME_ZONE:-Europe/Rome} TIME_ZONE: ${TIME_ZONE:-Europe/Rome}
DATABASE_URL: ${DATABASE_URL} DATABASE_URL: ${DATABASE_URL}
POSTGRES_DB: ${POSTGRES_DB} POSTGRES_DB: ${POSTGRES_DB}
@@ -19,11 +21,15 @@ services:
POSTGRES_PORT: ${POSTGRES_PORT:-5432} POSTGRES_PORT: ${POSTGRES_PORT:-5432}
expose: expose:
- "${BACKEND_PORT:-8000}" - "${BACKEND_PORT:-8000}"
volumes:
- django_static:/app/staticfiles
- django_media:/app/media
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
networks: networks:
- internal - internal
user: "0:0"
restart: unless-stopped restart: unless-stopped
frontend: frontend:
@@ -66,6 +72,8 @@ services:
NGINX_ENVSUBST_FILTER: "^(BACKEND_HOST|BACKEND_PORT|FRONTEND_HOST|FRONTEND_PORT)$" NGINX_ENVSUBST_FILTER: "^(BACKEND_HOST|BACKEND_PORT|FRONTEND_HOST|FRONTEND_PORT)$"
volumes: volumes:
- ./nginx/templates:/etc/nginx/templates:ro - ./nginx/templates:/etc/nginx/templates:ro
- django_static:/var/www/static:ro
- django_media:/var/www/media:ro
depends_on: depends_on:
- backend - backend
- frontend - frontend
@@ -75,6 +83,8 @@ services:
volumes: volumes:
postgres_data: postgres_data:
django_static:
django_media:
networks: networks:
internal: internal:
+4
View File
@@ -5,6 +5,10 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
location /assets/ {
try_files $uri =404;
}
location / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }
@@ -29,15 +29,15 @@ server {
} }
location /static/ { location /static/ {
proxy_pass http://azionelab_backend; alias /var/www/static/;
proxy_set_header Host $host; access_log off;
proxy_set_header X-Forwarded-Proto $scheme; expires 1d;
} }
location /media/ { location /media/ {
proxy_pass http://azionelab_backend; alias /var/www/media/;
proxy_set_header Host $host; access_log off;
proxy_set_header X-Forwarded-Proto $scheme; expires 1d;
} }
location / { location / {