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_DEBUG=false
CORS_ALLOWED_ORIGINS=http://localhost:4200,http://localhost:8080
SITE_BASE_URL=http://localhost:8080
TIME_ZONE=Europe/Rome
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")
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("/")
INSTALLED_APPS = [
"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")
status = serializers.CharField(source="reservation.status")
party_size = serializers.IntegerField(source="reservation.party_size")
qr_code_url = serializers.SerializerMethodField()
def get_qr_code_url(self, result):
return f"/api/reservations/{result.reservation.id}/qr-code/"
qr_code_url = serializers.CharField()
qr_code_image = serializers.CharField()

View File

@@ -7,7 +7,9 @@ from django.utils import timezone
from shows.models import Performance
from .emailing import send_confirmation_email
from .models import Reservation, ReservationToken
from .qr import build_check_in_preview_url, generate_check_in_qr_base64
CONFIRMATION_TOKEN_TTL = timedelta(hours=48)
@@ -53,6 +55,8 @@ class ConfirmedReservationResult:
check_in_token: ReservationToken
raw_check_in_token: str
available_seats: int
qr_code_image: str
qr_code_url: str
def calculate_available_seats(performance):
@@ -98,12 +102,17 @@ def create_pending_reservation(
expires_at=confirmation_expires_at,
)
return PendingReservationResult(
result = PendingReservationResult(
reservation=reservation,
confirmation_token=confirmation_token,
raw_confirmation_token=raw_confirmation_token,
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):
@@ -173,6 +182,11 @@ def confirm_reservation_from_token(raw_token):
check_in_token=check_in_token,
raw_check_in_token=raw_check_in_token,
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 django.core import mail
from django.urls import reverse
from django.test.utils import override_settings
from django.utils import timezone
from rest_framework import status
from rest_framework.test import APITestCase
@@ -59,6 +61,7 @@ class BookingApiTests(APITestCase):
self.assertEqual(response.data["performances"][0]["id"], self.performance.id)
self.assertEqual(response.data["performances"][0]["available_seats"], 3)
@override_settings(SITE_BASE_URL="https://tickets.azionelab.example")
def test_reservation_creation_success(self):
response = self.client.post(
reverse("api-reservation-create", kwargs={"performance_id": self.performance.id}),
@@ -78,6 +81,11 @@ class BookingApiTests(APITestCase):
self.assertNotIn("token", response.data)
self.assertNotIn("email", response.data)
self.assertEqual(Reservation.objects.count(), 1)
self.assertEqual(len(mail.outbox), 1)
self.assertIn(
"https://tickets.azionelab.example/api/reservations/confirm/?token=",
mail.outbox[0].body,
)
def test_reservation_creation_with_insufficient_seats(self):
response = self.client.post(
@@ -94,6 +102,7 @@ class BookingApiTests(APITestCase):
self.assertEqual(response.data["status"], "booking_unavailable")
self.assertEqual(Reservation.objects.count(), 0)
@override_settings(SITE_BASE_URL="https://tickets.azionelab.example")
def test_confirmation_success(self):
reservation = self.create_reservation()
_, raw_token = generate_confirmation_token(reservation)
@@ -109,8 +118,27 @@ class BookingApiTests(APITestCase):
self.assertEqual(response.data["reservation_id"], reservation.id)
self.assertEqual(response.data["status"], Reservation.Status.CONFIRMED)
self.assertEqual(response.data["party_size"], reservation.party_size)
self.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.assertTrue(response.data["qr_code_image"].startswith("data:image/png;base64,"))
self.assertEqual(reservation.status, Reservation.Status.CONFIRMED)
def test_confirmation_success_via_email_link_get_request(self):
reservation = self.create_reservation(email="get@example.com")
_, raw_token = generate_confirmation_token(reservation)
response = self.client.get(
reverse("api-reservation-confirm"),
{"token": raw_token},
)
reservation.refresh_from_db()
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["reservation_id"], reservation.id)
self.assertEqual(reservation.status, Reservation.Status.CONFIRMED)
def test_confirmation_with_invalid_token(self):

View File

@@ -1,11 +1,15 @@
from datetime import timedelta
from unittest.mock import patch
from django.core import mail
from django.db import connection
from django.test import TestCase
from django.test.utils import CaptureQueriesContext
from django.test.utils import override_settings
from django.utils import timezone
from bookings.models import Reservation, ReservationToken
from bookings.qr import build_check_in_preview_url, generate_check_in_qr_base64
from bookings.services import (
AlreadyConfirmedReservation,
ExpiredToken,
@@ -56,6 +60,39 @@ class BookingServiceTests(TestCase):
)
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):
reservation = self.create_reservation()
@@ -65,6 +102,7 @@ class BookingServiceTests(TestCase):
self.assertEqual(token.token_hash, ReservationToken.hash_token(raw_token))
self.assertGreater(token.expires_at, timezone.now())
@override_settings(SITE_BASE_URL="https://tickets.azionelab.example")
def test_confirm_reservation_from_valid_token(self):
reservation = self.create_reservation(party_size=2)
token, raw_token = generate_confirmation_token(reservation)
@@ -83,6 +121,48 @@ class BookingServiceTests(TestCase):
ReservationToken.hash_token(result.raw_check_in_token),
)
self.assertEqual(result.available_seats, 1)
self.assertEqual(
result.qr_code_url,
build_check_in_preview_url(result.raw_check_in_token),
)
self.assertTrue(
result.qr_code_url.startswith(
"https://tickets.azionelab.example/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):
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)
@api_view(["POST"])
@api_view(["GET", "POST"])
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)
try:

View File

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