generated from bisco/codex-bootstrap
Merge branch 'feature/email-and-qr' into develop
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
38
backend/bookings/emailing.py
Normal file
38
backend/bookings/emailing.py
Normal 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
30
backend/bookings/qr.py
Normal 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}"
|
||||||
@@ -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/"
|
|
||||||
|
|||||||
@@ -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),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user