Compare commits

8 Commits

Author SHA1 Message Date
bisco e5fcbfeb26 Merge branch 'chore/production-readiness-docs' into develop 2026-04-29 23:04:21 +02:00
bisco 7fc0a931ce docs: add production readiness notes 2026-04-29 23:00:57 +02:00
bisco b692ae70ba Merge branch 'fix/api-throttling' into develop 2026-04-29 22:59:02 +02:00
bisco 0533a1799f fix(api): add basic booking throttling 2026-04-29 22:57:09 +02:00
bisco a8f2a7c803 Merge branch 'fix/admin-token-visibility' into develop 2026-04-29 22:48:27 +02:00
bisco 7a46e288cf fix(admin): hide reservation token hashes 2026-04-29 22:45:16 +02:00
bisco 33307a5de2 Merge branch 'fix/site-base-url' into develop 2026-04-29 22:01:19 +02:00
bisco a5189669f6 fix(config): align site base url defaults 2026-04-29 21:59:31 +02:00
10 changed files with 196 additions and 25 deletions
+7 -4
View File
@@ -12,10 +12,10 @@ FRONTEND_PORT=8080
DJANGO_SECRET_KEY=change-me
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1
DJANGO_CSRF_TRUSTED_ORIGINS=http://localhost:8080
DJANGO_CSRF_TRUSTED_ORIGINS=http://localhost
DJANGO_DEBUG=true
CORS_ALLOWED_ORIGINS=http://localhost:4200,http://localhost:8080,http://localhost
SITE_BASE_URL=http://localhost:8080
CORS_ALLOWED_ORIGINS=http://localhost:4200,http://localhost
SITE_BASE_URL=http://localhost
TIME_ZONE=Europe/Rome
EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend
DEFAULT_FROM_EMAIL=no-reply@azionelab.local
@@ -28,4 +28,7 @@ POSTGRES_PORT=5432
DATABASE_URL=postgres://azionelab:change-me@postgres:5432/azionelab
ENVIRONMENT=local
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.
+8 -1
View File
@@ -24,7 +24,7 @@ def csv_env(name, default=""):
ALLOWED_HOSTS = csv_env("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1")
CSRF_TRUSTED_ORIGINS = csv_env("DJANGO_CSRF_TRUSTED_ORIGINS")
CORS_ALLOWED_ORIGINS = csv_env("CORS_ALLOWED_ORIGINS")
SITE_BASE_URL = os.environ.get("SITE_BASE_URL", "http://localhost:8080").rstrip("/")
SITE_BASE_URL = os.environ.get("SITE_BASE_URL", "http://localhost").rstrip("/")
INSTALLED_APPS = [
"django.contrib.admin",
@@ -123,4 +123,11 @@ REST_FRAMEWORK = {
"DEFAULT_PARSER_CLASSES": [
"rest_framework.parsers.JSONParser",
],
"DEFAULT_THROTTLE_RATES": {
# Small-theatre defaults: stricter on public booking flows, looser for staff operations.
"reservation_create": "20/hour",
"reservation_confirm": "60/hour",
"check_in_preview": "600/hour",
"check_in_confirm": "600/hour",
},
}
+5 -8
View File
@@ -40,8 +40,8 @@ class ReservationAdminForm(forms.ModelForm):
class ReservationTokenInline(admin.TabularInline):
model = ReservationToken
extra = 0
readonly_fields = ("token_hash", "used_at", "created_at")
fields = ("purpose", "token_hash", "expires_at", "used_at", "created_at")
readonly_fields = ("used_at", "created_at")
fields = ("purpose", "expires_at", "used_at", "created_at")
can_delete = False
@@ -231,13 +231,10 @@ class ReservationAdmin(admin.ModelAdmin):
@admin.register(ReservationToken)
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")
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")
autocomplete_fields = ("reservation",)
@admin.display(description="Token hash")
def token_preview(self, obj):
return obj.token_hash[:12]
+26
View File
@@ -83,3 +83,29 @@ class ReservationAdminTests(TestCase):
"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")
+49
View File
@@ -1,4 +1,5 @@
from datetime import timedelta
from unittest.mock import patch
from django.core import mail
from django.urls import reverse
@@ -9,6 +10,7 @@ from rest_framework.test import APITestCase
from bookings.models import Reservation
from bookings.services import generate_confirmation_token
from bookings.views import ReservationConfirmThrottle, ReservationCreateThrottle
from shows.models import Performance, Show, Venue
@@ -104,6 +106,32 @@ class BookingApiTests(APITestCase):
self.assertEqual(len(callbacks), 1)
self.assertEqual(len(mail.outbox), 0)
def test_reservation_creation_is_throttled(self):
with patch.dict(ReservationCreateThrottle.THROTTLE_RATES, {"reservation_create": "1/minute"}, clear=False):
with self.captureOnCommitCallbacks(execute=True):
first_response = self.client.post(
reverse("api-reservation-create", kwargs={"performance_id": self.performance.id}),
{
"name": "Maria Rossi",
"email": "maria@example.com",
"party_size": 1,
},
format="json",
)
second_response = self.client.post(
reverse("api-reservation-create", kwargs={"performance_id": self.performance.id}),
{
"name": "Maria Rossi",
"email": "maria@example.com",
"party_size": 1,
},
format="json",
)
self.assertEqual(first_response.status_code, status.HTTP_201_CREATED)
self.assertEqual(second_response.status_code, status.HTTP_429_TOO_MANY_REQUESTS)
def test_reservation_creation_with_insufficient_seats(self):
response = self.client.post(
reverse("api-reservation-create", kwargs={"performance_id": self.performance.id}),
@@ -188,6 +216,27 @@ class BookingApiTests(APITestCase):
self.assertEqual(second_response.status_code, status.HTTP_409_CONFLICT)
self.assertEqual(second_response.data["status"], "already_confirmed")
def test_confirmation_is_throttled(self):
with patch.dict(ReservationConfirmThrottle.THROTTLE_RATES, {"reservation_confirm": "1/minute"}, clear=False):
first_reservation = self.create_reservation(email="first@example.com")
_, first_raw_token = generate_confirmation_token(first_reservation)
second_reservation = self.create_reservation(email="second@example.com")
_, second_raw_token = generate_confirmation_token(second_reservation)
first_response = self.client.post(
reverse("api-reservation-confirm"),
{"token": first_raw_token},
format="json",
)
second_response = self.client.post(
reverse("api-reservation-confirm"),
{"token": second_raw_token},
format="json",
)
self.assertEqual(first_response.status_code, status.HTTP_200_OK)
self.assertEqual(second_response.status_code, status.HTTP_429_TOO_MANY_REQUESTS)
@override_settings(SITE_BASE_URL="https://tickets.azionelab.example")
def test_qr_retrieval_success_for_confirmed_reservation(self):
reservation = self.create_reservation()
+12 -1
View File
@@ -1,7 +1,8 @@
from django.shortcuts import get_object_or_404
from rest_framework import status
from rest_framework.decorators import api_view
from rest_framework.decorators import api_view, throttle_classes
from rest_framework.response import Response
from rest_framework.throttling import AnonRateThrottle
from shows.models import Performance
@@ -25,7 +26,16 @@ from .services import (
)
class ReservationCreateThrottle(AnonRateThrottle):
scope = "reservation_create"
class ReservationConfirmThrottle(AnonRateThrottle):
scope = "reservation_confirm"
@api_view(["POST"])
@throttle_classes([ReservationCreateThrottle])
def create_reservation(request, performance_id):
get_object_or_404(Performance, pk=performance_id, show__is_published=True)
@@ -60,6 +70,7 @@ def create_reservation(request, performance_id):
@api_view(["GET", "POST"])
@throttle_classes([ReservationConfirmThrottle])
def confirm_reservation(request):
payload = request.query_params if request.method == "GET" else request.data
serializer = ReservationConfirmSerializer(data=payload)
+25
View File
@@ -1,13 +1,16 @@
from datetime import timedelta
from unittest.mock import patch
from django.contrib.auth import get_user_model
from django.urls import reverse
from django.test.utils import override_settings
from django.utils import timezone
from rest_framework import status
from rest_framework.test import APITestCase
from bookings.models import Reservation, ReservationToken
from checkins.models import CheckIn
from checkins.views import CheckInPreviewThrottle
from shows.models import Performance, Show, Venue
@@ -105,6 +108,28 @@ class CheckInApiTests(APITestCase):
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
self.assertEqual(response.data["status"], "invalid_token")
def test_preview_is_throttled_for_staff_user(self):
with patch.dict(CheckInPreviewThrottle.THROTTLE_RATES, {"check_in_preview": "1/minute"}, clear=False):
first_reservation = self.create_reservation(email="first@example.com")
_, first_raw_token = self.create_check_in_token(first_reservation)
second_reservation = self.create_reservation(email="second@example.com")
_, second_raw_token = self.create_check_in_token(second_reservation)
self.client.force_authenticate(user=self.staff_user)
first_response = self.client.post(
reverse("api-check-in-preview"),
{"token": first_raw_token},
format="json",
)
second_response = self.client.post(
reverse("api-check-in-preview"),
{"token": second_raw_token},
format="json",
)
self.assertEqual(first_response.status_code, status.HTTP_200_OK)
self.assertEqual(second_response.status_code, status.HTTP_429_TOO_MANY_REQUESTS)
def test_check_in_success_as_staff_user(self):
reservation = self.create_reservation()
_, raw_token = self.create_check_in_token(reservation)
+18 -11
View File
@@ -1,8 +1,9 @@
from rest_framework import status
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.response import Response
from rest_framework.throttling import UserRateThrottle
from .serializers import (
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):
def has_permission(self, request, view):
return bool(request.user and request.user.is_staff)
def staff_check_in_view(view_func):
view_func = permission_classes([IsAuthenticated, IsStaffUser])(view_func)
view_func = authentication_classes([BasicAuthentication, SessionAuthentication])(view_func)
view_func = api_view(["POST"])(view_func)
return view_func
@staff_check_in_view
@api_view(["POST"])
@authentication_classes([BasicAuthentication, SessionAuthentication])
@permission_classes([IsAuthenticated, IsStaffUser])
@throttle_classes([CheckInPreviewThrottle])
def check_in_preview(request):
serializer = CheckInTokenSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
@@ -73,7 +77,10 @@ def check_in_preview(request):
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):
serializer = CheckInTokenSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
+35
View File
@@ -1,5 +1,28 @@
# Deployment
## Production Readiness
Before a real deployment, treat `.env.example` as local-development only. Create a separate `.env` for production and replace all placeholder values.
Required production changes:
- set `DJANGO_DEBUG=false`;
- set a strong random `DJANGO_SECRET_KEY`;
- set `DJANGO_ALLOWED_HOSTS` to the real public hostnames only;
- set `DJANGO_CSRF_TRUSTED_ORIGINS` to the real public HTTPS origins;
- set `SITE_BASE_URL` to the real public HTTPS base URL used for confirmation emails and QR/check-in links;
- replace the console email backend with real SMTP settings and a valid sender address;
- publish only nginx and terminate HTTPS at nginx or a trusted upstream reverse proxy;
- keep `collectstatic --noinput` in the deployment flow before `up -d`;
- persist the PostgreSQL named volume and configure tested backups before accepting bookings;
- create the first admin account explicitly with `python manage.py createsuperuser`.
Reverse proxy and HTTPS notes:
- the current nginx template listens on plain HTTP port `80` only and must be adapted for production TLS;
- if TLS is terminated by another reverse proxy, forward the public host and scheme correctly so generated links remain accurate;
- keep `SITE_BASE_URL`, `DJANGO_ALLOWED_HOSTS`, and `DJANGO_CSRF_TRUSTED_ORIGINS` aligned with the final public URL.
AzioneLab should deploy with a simple Docker Compose topology:
- `nginx`: public reverse proxy and static frontend server;
@@ -92,17 +115,28 @@ Generated QR codes may also be generated on demand instead of stored as files. I
Copy `.env.example` to `.env` and replace all placeholder values before running or deploying the stack.
`.env.example` is intentionally local-dev oriented. Do not use it unchanged for production.
Required backend configuration:
- `DJANGO_SECRET_KEY`;
- `DJANGO_ALLOWED_HOSTS`;
- `DJANGO_CSRF_TRUSTED_ORIGINS`;
- `DJANGO_DEBUG=false`;
- `CORS_ALLOWED_ORIGINS`;
- `SITE_BASE_URL`;
- `TIME_ZONE`;
- `DATABASE_URL` or equivalent database settings;
- email host, port, username, password, TLS settings, and sender address;
- public site URL used to build confirmation and QR verification links.
Local Docker convention:
- use nginx as the public entrypoint at `http://localhost`;
- set `SITE_BASE_URL=http://localhost`;
- keep `DJANGO_CSRF_TRUSTED_ORIGINS` and browser-facing `CORS_ALLOWED_ORIGINS` aligned with that public URL;
- if you publish nginx on a different port, update `SITE_BASE_URL` and trusted origins to the same host and port.
Required database configuration:
- database name;
@@ -142,6 +176,7 @@ Expected production-style flow:
docker compose --env-file .env -f infra/docker/compose.yml build
docker compose --env-file .env -f infra/docker/compose.yml run --rm backend python manage.py migrate
docker compose --env-file .env -f infra/docker/compose.yml run --rm backend python manage.py collectstatic --noinput
docker compose --env-file .env -f infra/docker/compose.yml run --rm backend python manage.py createsuperuser
docker compose --env-file .env -f infra/docker/compose.yml up -d
```
+11
View File
@@ -147,9 +147,20 @@ Deployment should follow least privilege:
- avoid privileged containers;
- use explicit image tags rather than `latest`;
- persist PostgreSQL data in a named volume;
- run production with `DJANGO_DEBUG=false`;
- use a strong private `DJANGO_SECRET_KEY`;
- restrict `DJANGO_ALLOWED_HOSTS` and `DJANGO_CSRF_TRUSTED_ORIGINS` to the real public deployment hosts;
- keep `SITE_BASE_URL` set to the real public HTTPS URL so email and QR links are correct;
- configure TLS for production;
- serve static and media files without exposing private files.
Operational production notes:
- `.env.example` is for local development and examples only, not direct production use;
- replace the console email backend with real SMTP settings before sending reservation emails;
- create admin accounts explicitly and protect them with strong passwords and limited access;
- keep verified database backups for the PostgreSQL volume before accepting live bookings.
## Logging
Logs should help diagnose operational issues without exposing sensitive data.