feat: add email confirmation and QR generation

This commit is contained in:
2026-04-29 10:12:31 +02:00
parent 15814f8ccc
commit c46d803951
8 changed files with 169 additions and 8 deletions

View File

@@ -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,
)

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

@@ -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}"

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,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):

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,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(

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