Merge branch 'feature/email-and-qr' into develop

This commit is contained in:
2026-04-29 10:28:10 +02:00
10 changed files with 200 additions and 8 deletions

View File

@@ -15,6 +15,7 @@ DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1
DJANGO_CSRF_TRUSTED_ORIGINS=http://localhost:8080 DJANGO_CSRF_TRUSTED_ORIGINS=http://localhost:8080
DJANGO_DEBUG=false DJANGO_DEBUG=false
CORS_ALLOWED_ORIGINS=http://localhost:4200,http://localhost:8080 CORS_ALLOWED_ORIGINS=http://localhost:4200,http://localhost:8080
SITE_BASE_URL=http://localhost:8080
TIME_ZONE=Europe/Rome TIME_ZONE=Europe/Rome
POSTGRES_DB=azionelab POSTGRES_DB=azionelab

View File

@@ -23,6 +23,7 @@ def csv_env(name, default=""):
ALLOWED_HOSTS = csv_env("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1") ALLOWED_HOSTS = csv_env("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1")
CSRF_TRUSTED_ORIGINS = csv_env("DJANGO_CSRF_TRUSTED_ORIGINS") CSRF_TRUSTED_ORIGINS = csv_env("DJANGO_CSRF_TRUSTED_ORIGINS")
CORS_ALLOWED_ORIGINS = csv_env("CORS_ALLOWED_ORIGINS") CORS_ALLOWED_ORIGINS = csv_env("CORS_ALLOWED_ORIGINS")
SITE_BASE_URL = os.environ.get("SITE_BASE_URL", "http://localhost:8080").rstrip("/")
INSTALLED_APPS = [ INSTALLED_APPS = [
"django.contrib.admin", "django.contrib.admin",

View File

@@ -0,0 +1,38 @@
import logging
from django.conf import settings
from django.core.mail import send_mail
logger = logging.getLogger(__name__)
CONFIRMATION_PATH = "/api/reservations/confirm/"
def build_confirmation_link(raw_confirmation_token):
return f"{settings.SITE_BASE_URL}{CONFIRMATION_PATH}?token={raw_confirmation_token}"
def send_confirmation_email(*, reservation, raw_confirmation_token):
confirmation_link = build_confirmation_link(raw_confirmation_token)
subject = f"Confirm your reservation for {reservation.performance.show.title}"
message = (
"Thank you for your reservation request.\n\n"
"Please confirm your reservation by opening this link:\n"
f"{confirmation_link}\n\n"
"If you did not request this reservation, you can ignore this email."
)
try:
send_mail(
subject=subject,
message=message,
from_email=None,
recipient_list=[reservation.email],
fail_silently=False,
)
except Exception:
logger.exception(
"Failed to send confirmation email for reservation %s.",
reservation.id,
)

30
backend/bookings/qr.py Normal file
View File

@@ -0,0 +1,30 @@
import base64
from io import BytesIO
import qrcode
from django.conf import settings
from .models import Reservation
CHECK_IN_PREVIEW_PATH = "/api/check-ins/preview/"
def build_check_in_preview_url(raw_check_in_token):
return f"{settings.SITE_BASE_URL}{CHECK_IN_PREVIEW_PATH}?token={raw_check_in_token}"
def generate_check_in_qr_png(raw_check_in_token):
qr_image = qrcode.make(build_check_in_preview_url(raw_check_in_token))
buffer = BytesIO()
qr_image.save(buffer, format="PNG")
return buffer.getvalue()
def generate_check_in_qr_base64(*, reservation, raw_check_in_token):
if reservation.status != Reservation.Status.CONFIRMED:
raise ValueError("QR codes are available only for confirmed reservations.")
png_bytes = generate_check_in_qr_png(raw_check_in_token)
encoded = base64.b64encode(png_bytes).decode("ascii")
return f"data:image/png;base64,{encoded}"

View File

@@ -35,7 +35,5 @@ class ReservationConfirmResponseSerializer(serializers.Serializer):
reservation_id = serializers.IntegerField(source="reservation.id") reservation_id = serializers.IntegerField(source="reservation.id")
status = serializers.CharField(source="reservation.status") status = serializers.CharField(source="reservation.status")
party_size = serializers.IntegerField(source="reservation.party_size") party_size = serializers.IntegerField(source="reservation.party_size")
qr_code_url = serializers.SerializerMethodField() qr_code_url = serializers.CharField()
qr_code_image = serializers.CharField()
def get_qr_code_url(self, result):
return f"/api/reservations/{result.reservation.id}/qr-code/"

View File

@@ -7,7 +7,9 @@ from django.utils import timezone
from shows.models import Performance from shows.models import Performance
from .emailing import send_confirmation_email
from .models import Reservation, ReservationToken from .models import Reservation, ReservationToken
from .qr import build_check_in_preview_url, generate_check_in_qr_base64
CONFIRMATION_TOKEN_TTL = timedelta(hours=48) CONFIRMATION_TOKEN_TTL = timedelta(hours=48)
@@ -53,6 +55,8 @@ class ConfirmedReservationResult:
check_in_token: ReservationToken check_in_token: ReservationToken
raw_check_in_token: str raw_check_in_token: str
available_seats: int available_seats: int
qr_code_image: str
qr_code_url: str
def calculate_available_seats(performance): def calculate_available_seats(performance):
@@ -98,12 +102,17 @@ def create_pending_reservation(
expires_at=confirmation_expires_at, expires_at=confirmation_expires_at,
) )
return 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
def generate_confirmation_token(reservation, *, expires_at=None): def generate_confirmation_token(reservation, *, expires_at=None):
@@ -173,6 +182,11 @@ def confirm_reservation_from_token(raw_token):
check_in_token=check_in_token, check_in_token=check_in_token,
raw_check_in_token=raw_check_in_token, raw_check_in_token=raw_check_in_token,
available_seats=available_seats - reservation.party_size, available_seats=available_seats - reservation.party_size,
qr_code_image=generate_check_in_qr_base64(
reservation=reservation,
raw_check_in_token=raw_check_in_token,
),
qr_code_url=build_check_in_preview_url(raw_check_in_token),
) )

View File

@@ -1,6 +1,8 @@
from datetime import timedelta from datetime import timedelta
from django.core import mail
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
@@ -59,6 +61,7 @@ class BookingApiTests(APITestCase):
self.assertEqual(response.data["performances"][0]["id"], self.performance.id) self.assertEqual(response.data["performances"][0]["id"], self.performance.id)
self.assertEqual(response.data["performances"][0]["available_seats"], 3) self.assertEqual(response.data["performances"][0]["available_seats"], 3)
@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( response = self.client.post(
reverse("api-reservation-create", kwargs={"performance_id": self.performance.id}), reverse("api-reservation-create", kwargs={"performance_id": self.performance.id}),
@@ -78,6 +81,11 @@ class BookingApiTests(APITestCase):
self.assertNotIn("token", response.data) self.assertNotIn("token", response.data)
self.assertNotIn("email", response.data) self.assertNotIn("email", response.data)
self.assertEqual(Reservation.objects.count(), 1) self.assertEqual(Reservation.objects.count(), 1)
self.assertEqual(len(mail.outbox), 1)
self.assertIn(
"https://tickets.azionelab.example/api/reservations/confirm/?token=",
mail.outbox[0].body,
)
def test_reservation_creation_with_insufficient_seats(self): def test_reservation_creation_with_insufficient_seats(self):
response = self.client.post( response = self.client.post(
@@ -94,6 +102,7 @@ class BookingApiTests(APITestCase):
self.assertEqual(response.data["status"], "booking_unavailable") self.assertEqual(response.data["status"], "booking_unavailable")
self.assertEqual(Reservation.objects.count(), 0) self.assertEqual(Reservation.objects.count(), 0)
@override_settings(SITE_BASE_URL="https://tickets.azionelab.example")
def test_confirmation_success(self): def test_confirmation_success(self):
reservation = self.create_reservation() reservation = self.create_reservation()
_, raw_token = generate_confirmation_token(reservation) _, raw_token = generate_confirmation_token(reservation)
@@ -109,8 +118,27 @@ class BookingApiTests(APITestCase):
self.assertEqual(response.data["reservation_id"], reservation.id) self.assertEqual(response.data["reservation_id"], reservation.id)
self.assertEqual(response.data["status"], Reservation.Status.CONFIRMED) self.assertEqual(response.data["status"], Reservation.Status.CONFIRMED)
self.assertEqual(response.data["party_size"], reservation.party_size) self.assertEqual(response.data["party_size"], reservation.party_size)
self.assertEqual(response.data["qr_code_url"], f"/api/reservations/{reservation.id}/qr-code/") self.assertTrue(
response.data["qr_code_url"].startswith(
"https://tickets.azionelab.example/api/check-ins/preview/?token="
)
)
self.assertNotIn("token", response.data) self.assertNotIn("token", response.data)
self.assertTrue(response.data["qr_code_image"].startswith("data:image/png;base64,"))
self.assertEqual(reservation.status, Reservation.Status.CONFIRMED)
def test_confirmation_success_via_email_link_get_request(self):
reservation = self.create_reservation(email="get@example.com")
_, raw_token = generate_confirmation_token(reservation)
response = self.client.get(
reverse("api-reservation-confirm"),
{"token": raw_token},
)
reservation.refresh_from_db()
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["reservation_id"], reservation.id)
self.assertEqual(reservation.status, Reservation.Status.CONFIRMED) self.assertEqual(reservation.status, Reservation.Status.CONFIRMED)
def test_confirmation_with_invalid_token(self): def test_confirmation_with_invalid_token(self):

View File

@@ -1,11 +1,15 @@
from datetime import timedelta from datetime import timedelta
from unittest.mock import patch
from django.core import mail
from django.db import connection from django.db import connection
from django.test import TestCase from django.test import TestCase
from django.test.utils import CaptureQueriesContext from django.test.utils import CaptureQueriesContext
from django.test.utils import override_settings
from django.utils import timezone from django.utils import timezone
from bookings.models import Reservation, ReservationToken from bookings.models import Reservation, ReservationToken
from bookings.qr import build_check_in_preview_url, generate_check_in_qr_base64
from bookings.services import ( from bookings.services import (
AlreadyConfirmedReservation, AlreadyConfirmedReservation,
ExpiredToken, ExpiredToken,
@@ -56,6 +60,39 @@ class BookingServiceTests(TestCase):
) )
self.assertNotIn("maria@example.com", result.raw_confirmation_token) self.assertNotIn("maria@example.com", result.raw_confirmation_token)
@override_settings(
EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend",
SITE_BASE_URL="https://tickets.azionelab.example",
)
def test_create_pending_reservation_sends_confirmation_email(self):
result = create_pending_reservation(
performance_id=self.performance.id,
name="Maria Rossi",
email="maria@example.com",
party_size=1,
)
self.assertEqual(len(mail.outbox), 1)
self.assertEqual(mail.outbox[0].to, ["maria@example.com"])
self.assertIn(result.raw_confirmation_token, mail.outbox[0].body)
self.assertIn(
"https://tickets.azionelab.example/api/reservations/confirm/?token=",
mail.outbox[0].body,
)
@patch("bookings.emailing.send_mail", side_effect=RuntimeError("SMTP down"))
def test_create_pending_reservation_logs_email_failure_without_crashing(self, mocked_send_mail):
result = create_pending_reservation(
performance_id=self.performance.id,
name="Maria Rossi",
email="maria@example.com",
party_size=1,
)
self.assertEqual(result.reservation.status, Reservation.Status.PENDING)
self.assertEqual(Reservation.objects.count(), 1)
mocked_send_mail.assert_called_once()
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()
@@ -65,6 +102,7 @@ class BookingServiceTests(TestCase):
self.assertEqual(token.token_hash, ReservationToken.hash_token(raw_token)) self.assertEqual(token.token_hash, ReservationToken.hash_token(raw_token))
self.assertGreater(token.expires_at, timezone.now()) self.assertGreater(token.expires_at, timezone.now())
@override_settings(SITE_BASE_URL="https://tickets.azionelab.example")
def test_confirm_reservation_from_valid_token(self): def test_confirm_reservation_from_valid_token(self):
reservation = self.create_reservation(party_size=2) reservation = self.create_reservation(party_size=2)
token, raw_token = generate_confirmation_token(reservation) token, raw_token = generate_confirmation_token(reservation)
@@ -83,6 +121,48 @@ class BookingServiceTests(TestCase):
ReservationToken.hash_token(result.raw_check_in_token), ReservationToken.hash_token(result.raw_check_in_token),
) )
self.assertEqual(result.available_seats, 1) self.assertEqual(result.available_seats, 1)
self.assertEqual(
result.qr_code_url,
build_check_in_preview_url(result.raw_check_in_token),
)
self.assertTrue(
result.qr_code_url.startswith(
"https://tickets.azionelab.example/api/check-ins/preview/?token="
)
)
self.assertTrue(result.qr_code_image.startswith("data:image/png;base64,"))
@override_settings(SITE_BASE_URL="https://tickets.azionelab.example")
def test_qr_code_is_generated_for_confirmed_reservation(self):
reservation = self.create_reservation(
status=Reservation.Status.CONFIRMED,
confirmed_at=timezone.now(),
)
raw_check_in_token = "opaque-check-in-token"
qr_code_image = generate_check_in_qr_base64(
reservation=reservation,
raw_check_in_token=raw_check_in_token,
)
self.assertTrue(qr_code_image.startswith("data:image/png;base64,"))
self.assertGreater(len(qr_code_image), len("data:image/png;base64,"))
self.assertEqual(
build_check_in_preview_url(raw_check_in_token),
"https://tickets.azionelab.example/api/check-ins/preview/?token=opaque-check-in-token",
)
def test_qr_code_is_not_generated_for_pending_reservation(self):
reservation = self.create_reservation()
with self.assertRaisesMessage(
ValueError,
"QR codes are available only for confirmed reservations.",
):
generate_check_in_qr_base64(
reservation=reservation,
raw_check_in_token="opaque-check-in-token",
)
def test_confirmation_fails_when_capacity_is_exhausted(self): def test_confirmation_fails_when_capacity_is_exhausted(self):
Reservation.objects.create( Reservation.objects.create(

View File

@@ -56,9 +56,10 @@ def create_reservation(request, performance_id):
return Response(response_serializer.data, status=status.HTTP_201_CREATED) return Response(response_serializer.data, status=status.HTTP_201_CREATED)
@api_view(["POST"]) @api_view(["GET", "POST"])
def confirm_reservation(request): def confirm_reservation(request):
serializer = ReservationConfirmSerializer(data=request.data) payload = request.query_params if request.method == "GET" else request.data
serializer = ReservationConfirmSerializer(data=payload)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
try: try:

View File

@@ -4,3 +4,4 @@ django-cors-headers==4.7.0
dj-database-url==2.3.0 dj-database-url==2.3.0
gunicorn==23.0.0 gunicorn==23.0.0
psycopg[binary]==3.2.9 psycopg[binary]==3.2.9
qrcode[pil]==8.2