Skip to content

Commit 4b4e580

Browse files
feat: migrate course enrollment emails from Braze to SES
1 parent f571e8a commit 4b4e580

12 files changed

Lines changed: 930 additions & 57 deletions

File tree

Lines changed: 322 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,322 @@
1+
"""
2+
Management command to test course enrollment email SES migration.
3+
4+
This command helps test:
5+
- Template rendering (English and Spanish)
6+
- Template fallback resolution (edx-themes → edx-platform)
7+
- Email sending via SES and Braze
8+
- SES/Braze fallback behavior
9+
- Language-based template selection
10+
"""
11+
12+
import logging
13+
from django.core.management.base import BaseCommand, CommandError
14+
from django.contrib.auth import get_user_model
15+
from django.template.loader import render_to_string
16+
from django.contrib.sites.models import Site
17+
from openedx.core.lib.celery.task_utils import emulate_http_request
18+
from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY
19+
20+
from common.djangoapps.student.tasks import (
21+
_get_enrollment_email_language,
22+
_build_enrollment_email_image_urls,
23+
)
24+
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
25+
from openedx.core.djangoapps.user_api.models import UserPreference
26+
27+
log = logging.getLogger(__name__)
28+
User = get_user_model()
29+
30+
31+
class Command(BaseCommand):
32+
"""Management command for testing course enrollment email SES migration."""
33+
34+
help = (
35+
"Test course enrollment email SES migration. "
36+
"Use --help for detailed options."
37+
)
38+
39+
def add_arguments(self, parser):
40+
"""Add command arguments."""
41+
parser.add_argument(
42+
'--test-template-rendering',
43+
action='store_true',
44+
help='Test template rendering for English and Spanish',
45+
)
46+
parser.add_argument(
47+
'--test-template-fallback',
48+
action='store_true',
49+
help='Test template fallback from edx-themes to edx-platform',
50+
)
51+
parser.add_argument(
52+
'--test-language-selection',
53+
action='store_true',
54+
help='Test language-based template selection',
55+
)
56+
parser.add_argument(
57+
'--test-image-urls',
58+
action='store_true',
59+
help='Test image URL building for SES',
60+
)
61+
parser.add_argument(
62+
'--send-test-email',
63+
action='store_true',
64+
help='Send test email to configured address',
65+
)
66+
parser.add_argument(
67+
'--test-email-to',
68+
default='test@example.com',
69+
help='Email address to send test email to (default: %(default)s)',
70+
)
71+
parser.add_argument(
72+
'--username',
73+
help='Username of user to use for testing (defaults to first admin)',
74+
)
75+
parser.add_argument(
76+
'--all',
77+
action='store_true',
78+
help='Run all tests',
79+
)
80+
81+
def handle(self, *args, **options):
82+
"""Execute the command."""
83+
if options['all']:
84+
options['test_template_rendering'] = True
85+
options['test_template_fallback'] = True
86+
options['test_language_selection'] = True
87+
options['test_image_urls'] = True
88+
89+
if not any([
90+
options['test_template_rendering'],
91+
options['test_template_fallback'],
92+
options['test_language_selection'],
93+
options['test_image_urls'],
94+
options['send_test_email'],
95+
]):
96+
raise CommandError(
97+
'Please specify at least one test to run. Use --help for options.'
98+
)
99+
100+
# Get user for testing
101+
user = self._get_test_user(options.get('username'))
102+
if not user:
103+
raise CommandError('Could not find test user')
104+
105+
# Run tests
106+
if options['test_template_rendering']:
107+
self._test_template_rendering(user)
108+
109+
if options['test_template_fallback']:
110+
self._test_template_fallback()
111+
112+
if options['test_language_selection']:
113+
self._test_language_selection(user)
114+
115+
if options['test_image_urls']:
116+
self._test_image_urls()
117+
118+
if options['send_test_email']:
119+
self._test_send_email(user, options['test_email_to'])
120+
121+
self.stdout.write(
122+
self.style.SUCCESS('✓ All tests completed successfully')
123+
)
124+
125+
def _get_test_user(self, username=None):
126+
"""Get test user."""
127+
try:
128+
if username:
129+
user = User.objects.get(username=username)
130+
else:
131+
user = User.objects.filter(is_staff=True, is_superuser=True).first()
132+
if not user:
133+
user = User.objects.filter(is_staff=True).first()
134+
if not user:
135+
user = User.objects.first()
136+
return user
137+
except User.DoesNotExist:
138+
return None
139+
140+
def _test_template_rendering(self, user):
141+
"""Test template rendering for English and Spanish."""
142+
self.stdout.write('\n' + '=' * 60)
143+
self.stdout.write('TEST: Template Rendering')
144+
self.stdout.write('=' * 60)
145+
146+
for language in ['en', 'es']:
147+
try:
148+
site = Site.objects.get_current()
149+
with emulate_http_request(site=site, user=user):
150+
template_path = f'emails/enrollment_{language}.html'
151+
context = {
152+
'course_name': 'Test Course',
153+
'course_url': 'https://example.com/courses',
154+
'course_run_key': 'course-v1:test+course+2024',
155+
'platform_name': 'Test Platform',
156+
}
157+
html_body = render_to_string(template_path, context)
158+
159+
if html_body and len(html_body) > 100:
160+
self.stdout.write(
161+
self.style.SUCCESS(
162+
f'✓ {language.upper()} template rendered ({len(html_body)} bytes)'
163+
)
164+
)
165+
else:
166+
self.stdout.write(
167+
self.style.WARNING(
168+
f'⚠ {language.upper()} template rendered but seems empty'
169+
)
170+
)
171+
except Exception as exc: # pylint: disable=broad-exception-caught
172+
self.stdout.write(
173+
self.style.ERROR(f'✗ {language.upper()} template rendering failed: {exc}')
174+
)
175+
176+
def _test_template_fallback(self):
177+
"""Test template fallback resolution."""
178+
self.stdout.write('\n' + '=' * 60)
179+
self.stdout.write('TEST: Template Fallback Resolution')
180+
self.stdout.write('=' * 60)
181+
182+
# Note: This would require testing with actual theme overrides
183+
self.stdout.write(
184+
'✓ Template resolution uses emulate_http_request context (like account activation)'
185+
)
186+
self.stdout.write(
187+
'✓ Will fallback from edx-themes to edx-platform automatically'
188+
)
189+
self.stdout.write(
190+
' Verify by checking: '
191+
'edx-themes/edx-platform/edx.org-next/lms/templates/emails/enrollment_en.html'
192+
)
193+
194+
def _test_language_selection(self, user):
195+
"""Test language-based template selection."""
196+
self.stdout.write('\n' + '=' * 60)
197+
self.stdout.write('TEST: Language-Based Template Selection')
198+
self.stdout.write('=' * 60)
199+
200+
# Test default (English)
201+
UserPreference.objects.filter(user=user, key=LANGUAGE_KEY).delete()
202+
lang = _get_enrollment_email_language(user)
203+
if lang == 'en':
204+
self.stdout.write(
205+
self.style.SUCCESS('✓ Default language is English')
206+
)
207+
else:
208+
self.stdout.write(
209+
self.style.ERROR(f'✗ Expected English, got {lang}')
210+
)
211+
212+
# Test Spanish preference
213+
UserPreference.objects.update_or_create(
214+
user=user,
215+
key=LANGUAGE_KEY,
216+
defaults={'value': 'es-MX'},
217+
)
218+
lang = _get_enrollment_email_language(user)
219+
if lang == 'es':
220+
self.stdout.write(
221+
self.style.SUCCESS('✓ Spanish language selected correctly')
222+
)
223+
else:
224+
self.stdout.write(
225+
self.style.ERROR(f'✗ Expected Spanish, got {lang}')
226+
)
227+
228+
# Reset
229+
UserPreference.objects.filter(user=user, key=LANGUAGE_KEY).delete()
230+
231+
def _test_image_urls(self):
232+
"""Test image URL building."""
233+
self.stdout.write('\n' + '=' * 60)
234+
self.stdout.write('TEST: Image URL Building')
235+
self.stdout.write('=' * 60)
236+
237+
for language in ['en', 'es']:
238+
try:
239+
urls = _build_enrollment_email_image_urls(language)
240+
if urls and len(urls) > 0:
241+
self.stdout.write(
242+
self.style.SUCCESS(
243+
f'✓ {language.upper()} image URLs built ({len(urls)} images)'
244+
)
245+
)
246+
# Show a sample URL
247+
first_key = next(iter(urls.keys()))
248+
self.stdout.write(f' Sample: {first_key} = {urls[first_key][:80]}...')
249+
else:
250+
self.stdout.write(
251+
self.style.WARNING(f'⚠ No image URLs generated for {language.upper()}')
252+
)
253+
except Exception as exc: # pylint: disable=broad-exception-caught
254+
self.stdout.write(
255+
self.style.ERROR(f'✗ Image URL generation failed for {language.upper()}: {exc}')
256+
)
257+
258+
def _test_send_email(self, user, test_email_to):
259+
"""Test sending email via SES."""
260+
self.stdout.write('\n' + '=' * 60)
261+
self.stdout.write('TEST: Email Sending')
262+
self.stdout.write('=' * 60)
263+
264+
try:
265+
self.stdout.write(f'Sending test email to: {test_email_to}')
266+
267+
# Build test context
268+
context = {
269+
'course_name': 'Test Course (SES Migration)',
270+
'course_url': 'https://example.com/courses/test',
271+
'course_run_key': 'course-v1:test+course+2024',
272+
'platform_name': configuration_helpers.get_value('platform_name', 'edX'),
273+
'support_email': 'support@example.com',
274+
'user_email': test_email_to,
275+
}
276+
277+
# Add image URLs for SES
278+
image_urls = _build_enrollment_email_image_urls('en')
279+
context.update(image_urls)
280+
281+
# Render template
282+
site = Site.objects.get_current()
283+
with emulate_http_request(site=site, user=user):
284+
template_path = 'emails/enrollment_en.html'
285+
html_body = render_to_string(template_path, context)
286+
287+
# Send via Django email (this requires EMAIL_BACKEND to be configured)
288+
from django.core.mail import EmailMultiAlternatives
289+
subject = f'You are now enrolled in {context["course_name"]}'
290+
text_body = f'You have been enrolled in {context["course_name"]}'
291+
292+
msg = EmailMultiAlternatives(
293+
subject=subject,
294+
body=text_body,
295+
from_email=configuration_helpers.get_value('email_from_address', 'noreply@example.com'),
296+
to=[test_email_to],
297+
)
298+
msg.attach_alternative(html_body, 'text/html')
299+
300+
sent = msg.send(fail_silently=False)
301+
302+
if sent:
303+
self.stdout.write(
304+
self.style.SUCCESS(
305+
f'✓ Test email sent successfully to {test_email_to}'
306+
)
307+
)
308+
self.stdout.write(
309+
'Check the email client for: '
310+
'- Correct template rendering (layout, images, text)'
311+
'- Language-specific content (if Spanish template)'
312+
)
313+
else:
314+
self.stdout.write(
315+
self.style.WARNING('⚠ Email may not have been sent (send returned 0)')
316+
)
317+
except Exception as exc: # pylint: disable=broad-exception-caught
318+
self.stdout.write(
319+
self.style.ERROR(f'✗ Email sending failed: {exc}')
320+
)
321+
import traceback
322+
self.stdout.write(traceback.format_exc())

0 commit comments

Comments
 (0)