Skip to content

Commit fca1dad

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

11 files changed

Lines changed: 920 additions & 51 deletions

File tree

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

0 commit comments

Comments
 (0)