Merge branch 'fix/dev-email-confirmation-logging' into develop

This commit is contained in:
bisco
2026-04-29 23:40:25 +02:00
5 changed files with 89 additions and 0 deletions

View File

@@ -32,3 +32,4 @@ ENVIRONMENT=local
# Local convention: nginx is the public entrypoint on http://localhost. # Local convention: nginx is the public entrypoint on http://localhost.
# If you change the published nginx port, update SITE_BASE_URL and trusted origins to match. # If you change the published nginx port, update SITE_BASE_URL and trusted origins to match.
# In local/debug mode, failed or attempted reservation emails also log the confirmation URL for manual browser testing.

View File

@@ -9,6 +9,7 @@ from django.core.exceptions import ImproperlyConfigured
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
DEBUG = os.environ.get("DJANGO_DEBUG", "false").lower() == "true" DEBUG = os.environ.get("DJANGO_DEBUG", "false").lower() == "true"
ENVIRONMENT = os.environ.get("ENVIRONMENT", "production").lower()
SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY") SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY")
if not SECRET_KEY: if not SECRET_KEY:
if DEBUG: if DEBUG:
@@ -25,6 +26,7 @@ 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").rstrip("/") SITE_BASE_URL = os.environ.get("SITE_BASE_URL", "http://localhost").rstrip("/")
LOG_RESERVATION_CONFIRMATION_URLS = DEBUG or ENVIRONMENT == "local"
INSTALLED_APPS = [ INSTALLED_APPS = [
"django.contrib.admin", "django.contrib.admin",

View File

@@ -13,6 +13,18 @@ def build_confirmation_link(raw_confirmation_token):
return f"{settings.SITE_BASE_URL}{CONFIRMATION_PATH}?token={raw_confirmation_token}" return f"{settings.SITE_BASE_URL}{CONFIRMATION_PATH}?token={raw_confirmation_token}"
def _log_confirmation_link_for_local_debug(*, reservation, confirmation_link, reason):
if not settings.LOG_RESERVATION_CONFIRMATION_URLS:
return
logger.info(
"Local reservation confirmation link for manual testing (%s) for reservation %s: %s",
reason,
reservation.id,
confirmation_link,
)
def send_confirmation_email(*, reservation, raw_confirmation_token): def send_confirmation_email(*, reservation, raw_confirmation_token):
confirmation_link = build_confirmation_link(raw_confirmation_token) confirmation_link = build_confirmation_link(raw_confirmation_token)
subject = f"Confirm your reservation for {reservation.performance.show.title}" subject = f"Confirm your reservation for {reservation.performance.show.title}"
@@ -23,6 +35,12 @@ def send_confirmation_email(*, reservation, raw_confirmation_token):
"If you did not request this reservation, you can ignore this email." "If you did not request this reservation, you can ignore this email."
) )
_log_confirmation_link_for_local_debug(
reservation=reservation,
confirmation_link=confirmation_link,
reason="email send attempt",
)
try: try:
send_mail( send_mail(
subject=subject, subject=subject,
@@ -32,6 +50,11 @@ def send_confirmation_email(*, reservation, raw_confirmation_token):
fail_silently=False, fail_silently=False,
) )
except Exception: except Exception:
_log_confirmation_link_for_local_debug(
reservation=reservation,
confirmation_link=confirmation_link,
reason="email send failure fallback",
)
logger.exception( logger.exception(
"Failed to send confirmation email for reservation %s.", "Failed to send confirmation email for reservation %s.",
reservation.id, reservation.id,

View File

@@ -96,6 +96,34 @@ class BookingServiceTests(TestCase):
self.assertEqual(len(callbacks), 1) self.assertEqual(len(callbacks), 1)
self.assertEqual(len(mail.outbox), 0) self.assertEqual(len(mail.outbox), 0)
@override_settings(
EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend",
LOG_RESERVATION_CONFIRMATION_URLS=True,
SITE_BASE_URL="https://tickets.azionelab.example",
)
def test_create_pending_reservation_logs_confirmation_link_in_local_mode(self):
with self.assertLogs("bookings.emailing", level="INFO") as captured_logs:
with self.captureOnCommitCallbacks(execute=True):
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.assertTrue(
any(
(
"Local reservation confirmation link for manual testing "
"(email send attempt)"
) in log_entry
and result.raw_confirmation_token in log_entry
for log_entry in captured_logs.output
)
)
@override_settings(LOG_RESERVATION_CONFIRMATION_URLS=False)
@patch("bookings.emailing.send_mail", side_effect=RuntimeError("SMTP down")) @patch("bookings.emailing.send_mail", side_effect=RuntimeError("SMTP down"))
def test_create_pending_reservation_logs_email_failure_without_crashing(self, mocked_send_mail): def test_create_pending_reservation_logs_email_failure_without_crashing(self, mocked_send_mail):
with self.assertLogs("bookings.emailing", level="ERROR") as captured_logs: with self.assertLogs("bookings.emailing", level="ERROR") as captured_logs:
@@ -116,6 +144,40 @@ class BookingServiceTests(TestCase):
for log_entry in captured_logs.output for log_entry in captured_logs.output
) )
) )
self.assertFalse(
any(result.raw_confirmation_token in log_entry for log_entry in captured_logs.output)
)
@override_settings(
LOG_RESERVATION_CONFIRMATION_URLS=True,
SITE_BASE_URL="https://tickets.azionelab.example",
)
@patch("bookings.emailing.send_mail", side_effect=RuntimeError("SMTP down"))
def test_create_pending_reservation_logs_confirmation_link_on_email_failure_in_local_mode(
self,
mocked_send_mail,
):
with self.assertLogs("bookings.emailing", level="INFO") as captured_logs:
with self.captureOnCommitCallbacks(execute=True):
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)
mocked_send_mail.assert_called_once()
self.assertTrue(
any(
(
"Local reservation confirmation link for manual testing "
"(email send failure fallback)"
) in log_entry
and result.raw_confirmation_token in log_entry
for log_entry in captured_logs.output
)
)
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()

View File

@@ -136,6 +136,7 @@ Local Docker convention:
- set `SITE_BASE_URL=http://localhost`; - set `SITE_BASE_URL=http://localhost`;
- keep `DJANGO_CSRF_TRUSTED_ORIGINS` and browser-facing `CORS_ALLOWED_ORIGINS` aligned with that public URL; - keep `DJANGO_CSRF_TRUSTED_ORIGINS` and browser-facing `CORS_ALLOWED_ORIGINS` aligned with that public URL;
- if you publish nginx on a different port, update `SITE_BASE_URL` and trusted origins to the same host and port. - if you publish nginx on a different port, update `SITE_BASE_URL` and trusted origins to the same host and port.
- local/debug reservation email sends also log the confirmation URL so browser testing can continue even if SMTP is missing or fails.
Required database configuration: Required database configuration: