From c46d8039515b43cd738f6fd28db9bef4f96b8115 Mon Sep 17 00:00:00 2001 From: bisco Date: Wed, 29 Apr 2026 10:12:31 +0200 Subject: [PATCH 1/2] feat: add email confirmation and QR generation --- backend/bookings/emailing.py | 37 ++++++++++++++++++ backend/bookings/qr.py | 29 ++++++++++++++ backend/bookings/serializers.py | 6 +-- backend/bookings/services.py | 16 +++++++- backend/bookings/test_api.py | 20 +++++++++- backend/bookings/test_services.py | 63 +++++++++++++++++++++++++++++++ backend/bookings/views.py | 5 ++- requirements/backend.txt | 1 + 8 files changed, 169 insertions(+), 8 deletions(-) create mode 100644 backend/bookings/emailing.py create mode 100644 backend/bookings/qr.py diff --git a/backend/bookings/emailing.py b/backend/bookings/emailing.py new file mode 100644 index 0000000..1d12343 --- /dev/null +++ b/backend/bookings/emailing.py @@ -0,0 +1,37 @@ +import logging + +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"{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, + ) diff --git a/backend/bookings/qr.py b/backend/bookings/qr.py new file mode 100644 index 0000000..40cd6ff --- /dev/null +++ b/backend/bookings/qr.py @@ -0,0 +1,29 @@ +import base64 +from io import BytesIO + +import qrcode + +from .models import Reservation + + +CHECK_IN_PREVIEW_PATH = "/api/check-ins/preview/" + + +def build_check_in_preview_url(raw_check_in_token): + return f"{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}" diff --git a/backend/bookings/serializers.py b/backend/bookings/serializers.py index 3314b2d..93f0022 100644 --- a/backend/bookings/serializers.py +++ b/backend/bookings/serializers.py @@ -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() diff --git a/backend/bookings/services.py b/backend/bookings/services.py index 688ce54..6433999 100644 --- a/backend/bookings/services.py +++ b/backend/bookings/services.py @@ -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), ) diff --git a/backend/bookings/test_api.py b/backend/bookings/test_api.py index 2116534..151a8dc 100644 --- a/backend/bookings/test_api.py +++ b/backend/bookings/test_api.py @@ -1,5 +1,6 @@ from datetime import timedelta +from django.core import mail from django.urls import reverse from django.utils import timezone from rest_framework import status @@ -78,6 +79,8 @@ 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("/api/reservations/confirm/?token=", mail.outbox[0].body) def test_reservation_creation_with_insufficient_seats(self): response = self.client.post( @@ -109,8 +112,23 @@ 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("/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): diff --git a/backend/bookings/test_services.py b/backend/bookings/test_services.py index 8e3c597..891ab77 100644 --- a/backend/bookings/test_services.py +++ b/backend/bookings/test_services.py @@ -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,33 @@ class BookingServiceTests(TestCase): ) self.assertNotIn("maria@example.com", result.raw_confirmation_token) + @override_settings(EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend") + 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("/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() @@ -83,6 +114,38 @@ 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_image.startswith("data:image/png;base64,")) + + 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,")) + + 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( diff --git a/backend/bookings/views.py b/backend/bookings/views.py index 501279f..6e97d00 100644 --- a/backend/bookings/views.py +++ b/backend/bookings/views.py @@ -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: diff --git a/requirements/backend.txt b/requirements/backend.txt index 2c08bbf..375b85a 100644 --- a/requirements/backend.txt +++ b/requirements/backend.txt @@ -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 From 4ae85947e038410016b0024a923140ae418c3ce4 Mon Sep 17 00:00:00 2001 From: bisco Date: Wed, 29 Apr 2026 10:18:22 +0200 Subject: [PATCH 2/2] fix: use absolute public booking URLs --- .env.example | 1 + backend/azionelab/settings.py | 1 + backend/bookings/emailing.py | 3 ++- backend/bookings/qr.py | 3 ++- backend/bookings/test_api.py | 14 ++++++++++++-- backend/bookings/test_services.py | 21 +++++++++++++++++++-- 6 files changed, 37 insertions(+), 6 deletions(-) diff --git a/.env.example b/.env.example index 4fadffe..bc46029 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/backend/azionelab/settings.py b/backend/azionelab/settings.py index cf89f8f..45fff65 100644 --- a/backend/azionelab/settings.py +++ b/backend/azionelab/settings.py @@ -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", diff --git a/backend/bookings/emailing.py b/backend/bookings/emailing.py index 1d12343..e8719fb 100644 --- a/backend/bookings/emailing.py +++ b/backend/bookings/emailing.py @@ -1,5 +1,6 @@ import logging +from django.conf import settings from django.core.mail import send_mail @@ -9,7 +10,7 @@ CONFIRMATION_PATH = "/api/reservations/confirm/" def build_confirmation_link(raw_confirmation_token): - return f"{CONFIRMATION_PATH}?token={raw_confirmation_token}" + return f"{settings.SITE_BASE_URL}{CONFIRMATION_PATH}?token={raw_confirmation_token}" def send_confirmation_email(*, reservation, raw_confirmation_token): diff --git a/backend/bookings/qr.py b/backend/bookings/qr.py index 40cd6ff..6d69e08 100644 --- a/backend/bookings/qr.py +++ b/backend/bookings/qr.py @@ -2,6 +2,7 @@ import base64 from io import BytesIO import qrcode +from django.conf import settings from .models import Reservation @@ -10,7 +11,7 @@ CHECK_IN_PREVIEW_PATH = "/api/check-ins/preview/" def build_check_in_preview_url(raw_check_in_token): - return f"{CHECK_IN_PREVIEW_PATH}?token={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): diff --git a/backend/bookings/test_api.py b/backend/bookings/test_api.py index 151a8dc..c9b22e8 100644 --- a/backend/bookings/test_api.py +++ b/backend/bookings/test_api.py @@ -2,6 +2,7 @@ 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 @@ -60,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}), @@ -80,7 +82,10 @@ class BookingApiTests(APITestCase): self.assertNotIn("email", response.data) self.assertEqual(Reservation.objects.count(), 1) self.assertEqual(len(mail.outbox), 1) - self.assertIn("/api/reservations/confirm/?token=", mail.outbox[0].body) + 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( @@ -97,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) @@ -112,7 +118,11 @@ 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.assertTrue(response.data["qr_code_url"].startswith("/api/check-ins/preview/?token=")) + 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) diff --git a/backend/bookings/test_services.py b/backend/bookings/test_services.py index 891ab77..231b11a 100644 --- a/backend/bookings/test_services.py +++ b/backend/bookings/test_services.py @@ -60,7 +60,10 @@ class BookingServiceTests(TestCase): ) self.assertNotIn("maria@example.com", result.raw_confirmation_token) - @override_settings(EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend") + @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, @@ -72,7 +75,10 @@ class BookingServiceTests(TestCase): 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("/api/reservations/confirm/?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): @@ -96,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) @@ -118,8 +125,14 @@ class BookingServiceTests(TestCase): 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, @@ -134,6 +147,10 @@ class BookingServiceTests(TestCase): 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()