Compare commits

4 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
7 changed files with 150 additions and 12 deletions
+7
View File
@@ -123,4 +123,11 @@ REST_FRAMEWORK = {
"DEFAULT_PARSER_CLASSES": [ "DEFAULT_PARSER_CLASSES": [
"rest_framework.parsers.JSONParser", "rest_framework.parsers.JSONParser",
], ],
"DEFAULT_THROTTLE_RATES": {
# Small-theatre defaults: stricter on public booking flows, looser for staff operations.
"reservation_create": "20/hour",
"reservation_confirm": "60/hour",
"check_in_preview": "600/hour",
"check_in_confirm": "600/hour",
},
} }
+49
View File
@@ -1,4 +1,5 @@
from datetime import timedelta from datetime import timedelta
from unittest.mock import patch
from django.core import mail from django.core import mail
from django.urls import reverse from django.urls import reverse
@@ -9,6 +10,7 @@ from rest_framework.test import 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
@@ -104,6 +106,32 @@ class BookingApiTests(APITestCase):
self.assertEqual(len(callbacks), 1) self.assertEqual(len(callbacks), 1)
self.assertEqual(len(mail.outbox), 0) 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}),
@@ -188,6 +216,27 @@ 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() reservation = self.create_reservation()
+12 -1
View File
@@ -1,7 +1,8 @@
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, throttle_classes
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 +26,16 @@ from .services import (
) )
class ReservationCreateThrottle(AnonRateThrottle):
scope = "reservation_create"
class ReservationConfirmThrottle(AnonRateThrottle):
scope = "reservation_confirm"
@api_view(["POST"]) @api_view(["POST"])
@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 +70,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)
+25
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
@@ -105,6 +108,28 @@ 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_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)
+18 -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)
@@ -73,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)
+28
View File
@@ -1,5 +1,28 @@
# Deployment # Deployment
## Production Readiness
Before a real deployment, treat `.env.example` as local-development only. Create a separate `.env` for production and replace all placeholder values.
Required production changes:
- set `DJANGO_DEBUG=false`;
- set a strong random `DJANGO_SECRET_KEY`;
- set `DJANGO_ALLOWED_HOSTS` to the real public hostnames only;
- set `DJANGO_CSRF_TRUSTED_ORIGINS` to the real public HTTPS origins;
- set `SITE_BASE_URL` to the real public HTTPS base URL used for confirmation emails and QR/check-in links;
- replace the console email backend with real SMTP settings and a valid sender address;
- publish only nginx and terminate HTTPS at nginx or a trusted upstream reverse proxy;
- keep `collectstatic --noinput` in the deployment flow before `up -d`;
- persist the PostgreSQL named volume and configure tested backups before accepting bookings;
- create the first admin account explicitly with `python manage.py createsuperuser`.
Reverse proxy and HTTPS notes:
- the current nginx template listens on plain HTTP port `80` only and must be adapted for production TLS;
- if TLS is terminated by another reverse proxy, forward the public host and scheme correctly so generated links remain accurate;
- keep `SITE_BASE_URL`, `DJANGO_ALLOWED_HOSTS`, and `DJANGO_CSRF_TRUSTED_ORIGINS` aligned with the final public URL.
AzioneLab should deploy with a simple Docker Compose topology: AzioneLab should deploy with a simple Docker Compose topology:
- `nginx`: public reverse proxy and static frontend server; - `nginx`: public reverse proxy and static frontend server;
@@ -92,12 +115,16 @@ 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;
@@ -149,6 +176,7 @@ Expected production-style flow:
docker compose --env-file .env -f infra/docker/compose.yml build docker compose --env-file .env -f infra/docker/compose.yml build
docker compose --env-file .env -f infra/docker/compose.yml run --rm backend python manage.py migrate docker compose --env-file .env -f infra/docker/compose.yml run --rm backend python manage.py migrate
docker compose --env-file .env -f infra/docker/compose.yml run --rm backend python manage.py collectstatic --noinput docker compose --env-file .env -f infra/docker/compose.yml run --rm backend python manage.py collectstatic --noinput
docker compose --env-file .env -f infra/docker/compose.yml run --rm backend python manage.py createsuperuser
docker compose --env-file .env -f infra/docker/compose.yml up -d docker compose --env-file .env -f infra/docker/compose.yml up -d
``` ```
+11
View File
@@ -147,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.