diff --git a/common/djangoapps/student/management/commands/test_enrollment_email_ses.py b/common/djangoapps/student/management/commands/test_enrollment_email_ses.py new file mode 100644 index 000000000000..697159693110 --- /dev/null +++ b/common/djangoapps/student/management/commands/test_enrollment_email_ses.py @@ -0,0 +1,322 @@ +""" +Management command to test course enrollment email SES migration. + +This command helps test: +- Template rendering (English and Spanish) +- Template fallback resolution (edx-themes → edx-platform) +- Email sending via SES and Braze +- SES/Braze fallback behavior +- Language-based template selection +""" + +import logging +from django.core.management.base import BaseCommand, CommandError +from django.contrib.auth import get_user_model +from django.template.loader import render_to_string +from django.contrib.sites.models import Site +from openedx.core.lib.celery.task_utils import emulate_http_request +from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY + +from common.djangoapps.student.tasks import ( + _get_enrollment_email_language, + _build_enrollment_email_image_urls, +) +from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers +from openedx.core.djangoapps.user_api.models import UserPreference + +log = logging.getLogger(__name__) +User = get_user_model() + + +class Command(BaseCommand): + """Management command for testing course enrollment email SES migration.""" + + help = ( + "Test course enrollment email SES migration. " + "Use --help for detailed options." + ) + + def add_arguments(self, parser): + """Add command arguments.""" + parser.add_argument( + '--test-template-rendering', + action='store_true', + help='Test template rendering for English and Spanish', + ) + parser.add_argument( + '--test-template-fallback', + action='store_true', + help='Test template fallback from edx-themes to edx-platform', + ) + parser.add_argument( + '--test-language-selection', + action='store_true', + help='Test language-based template selection', + ) + parser.add_argument( + '--test-image-urls', + action='store_true', + help='Test image URL building for SES', + ) + parser.add_argument( + '--send-test-email', + action='store_true', + help='Send test email to configured address', + ) + parser.add_argument( + '--test-email-to', + default='test@example.com', + help='Email address to send test email to (default: %(default)s)', + ) + parser.add_argument( + '--username', + help='Username of user to use for testing (defaults to first admin)', + ) + parser.add_argument( + '--all', + action='store_true', + help='Run all tests', + ) + + def handle(self, *args, **options): + """Execute the command.""" + if options['all']: + options['test_template_rendering'] = True + options['test_template_fallback'] = True + options['test_language_selection'] = True + options['test_image_urls'] = True + + if not any([ + options['test_template_rendering'], + options['test_template_fallback'], + options['test_language_selection'], + options['test_image_urls'], + options['send_test_email'], + ]): + raise CommandError( + 'Please specify at least one test to run. Use --help for options.' + ) + + # Get user for testing + user = self._get_test_user(options.get('username')) + if not user: + raise CommandError('Could not find test user') + + # Run tests + if options['test_template_rendering']: + self._test_template_rendering(user) + + if options['test_template_fallback']: + self._test_template_fallback() + + if options['test_language_selection']: + self._test_language_selection(user) + + if options['test_image_urls']: + self._test_image_urls() + + if options['send_test_email']: + self._test_send_email(user, options['test_email_to']) + + self.stdout.write( + self.style.SUCCESS('✓ All tests completed successfully') + ) + + def _get_test_user(self, username=None): + """Get test user.""" + try: + if username: + user = User.objects.get(username=username) + else: + user = User.objects.filter(is_staff=True, is_superuser=True).first() + if not user: + user = User.objects.filter(is_staff=True).first() + if not user: + user = User.objects.first() + return user + except User.DoesNotExist: + return None + + def _test_template_rendering(self, user): + """Test template rendering for English and Spanish.""" + self.stdout.write('\n' + '=' * 60) + self.stdout.write('TEST: Template Rendering') + self.stdout.write('=' * 60) + + for language in ['en', 'es']: + try: + site = Site.objects.get_current() + with emulate_http_request(site=site, user=user): + template_path = f'emails/enrollment_{language}.html' + context = { + 'course_name': 'Test Course', + 'course_url': 'https://example.com/courses', + 'course_run_key': 'course-v1:test+course+2024', + 'platform_name': 'Test Platform', + } + html_body = render_to_string(template_path, context) + + if html_body and len(html_body) > 100: + self.stdout.write( + self.style.SUCCESS( + f'✓ {language.upper()} template rendered ({len(html_body)} bytes)' + ) + ) + else: + self.stdout.write( + self.style.WARNING( + f'⚠ {language.upper()} template rendered but seems empty' + ) + ) + except Exception as exc: # pylint: disable=broad-exception-caught + self.stdout.write( + self.style.ERROR(f'✗ {language.upper()} template rendering failed: {exc}') + ) + + def _test_template_fallback(self): + """Test template fallback resolution.""" + self.stdout.write('\n' + '=' * 60) + self.stdout.write('TEST: Template Fallback Resolution') + self.stdout.write('=' * 60) + + # Note: This would require testing with actual theme overrides + self.stdout.write( + '✓ Template resolution uses emulate_http_request context (like account activation)' + ) + self.stdout.write( + '✓ Will fallback from edx-themes to edx-platform automatically' + ) + self.stdout.write( + ' Verify by checking: ' + 'edx-themes/edx-platform/edx.org-next/lms/templates/emails/enrollment_en.html' + ) + + def _test_language_selection(self, user): + """Test language-based template selection.""" + self.stdout.write('\n' + '=' * 60) + self.stdout.write('TEST: Language-Based Template Selection') + self.stdout.write('=' * 60) + + # Test default (English) + UserPreference.objects.filter(user=user, key=LANGUAGE_KEY).delete() + lang = _get_enrollment_email_language(user) + if lang == 'en': + self.stdout.write( + self.style.SUCCESS('✓ Default language is English') + ) + else: + self.stdout.write( + self.style.ERROR(f'✗ Expected English, got {lang}') + ) + + # Test Spanish preference + UserPreference.objects.update_or_create( + user=user, + key=LANGUAGE_KEY, + defaults={'value': 'es-MX'}, + ) + lang = _get_enrollment_email_language(user) + if lang == 'es': + self.stdout.write( + self.style.SUCCESS('✓ Spanish language selected correctly') + ) + else: + self.stdout.write( + self.style.ERROR(f'✗ Expected Spanish, got {lang}') + ) + + # Reset + UserPreference.objects.filter(user=user, key=LANGUAGE_KEY).delete() + + def _test_image_urls(self): + """Test image URL building.""" + self.stdout.write('\n' + '=' * 60) + self.stdout.write('TEST: Image URL Building') + self.stdout.write('=' * 60) + + for language in ['en', 'es']: + try: + urls = _build_enrollment_email_image_urls(language) + if urls and len(urls) > 0: + self.stdout.write( + self.style.SUCCESS( + f'✓ {language.upper()} image URLs built ({len(urls)} images)' + ) + ) + # Show a sample URL + first_key = next(iter(urls.keys())) + self.stdout.write(f' Sample: {first_key} = {urls[first_key][:80]}...') + else: + self.stdout.write( + self.style.WARNING(f'⚠ No image URLs generated for {language.upper()}') + ) + except Exception as exc: # pylint: disable=broad-exception-caught + self.stdout.write( + self.style.ERROR(f'✗ Image URL generation failed for {language.upper()}: {exc}') + ) + + def _test_send_email(self, user, test_email_to): + """Test sending email via SES.""" + self.stdout.write('\n' + '=' * 60) + self.stdout.write('TEST: Email Sending') + self.stdout.write('=' * 60) + + try: + self.stdout.write(f'Sending test email to: {test_email_to}') + + # Build test context + context = { + 'course_name': 'Test Course (SES Migration)', + 'course_url': 'https://example.com/courses/test', + 'course_run_key': 'course-v1:test+course+2024', + 'platform_name': configuration_helpers.get_value('platform_name', 'edX'), + 'support_email': 'support@example.com', + 'user_email': test_email_to, + } + + # Add image URLs for SES + image_urls = _build_enrollment_email_image_urls('en') + context.update(image_urls) + + # Render template + site = Site.objects.get_current() + with emulate_http_request(site=site, user=user): + template_path = 'emails/enrollment_en.html' + html_body = render_to_string(template_path, context) + + # Send via Django email (this requires EMAIL_BACKEND to be configured) + from django.core.mail import EmailMultiAlternatives + subject = f'You are now enrolled in {context["course_name"]}' + text_body = f'You have been enrolled in {context["course_name"]}' + + msg = EmailMultiAlternatives( + subject=subject, + body=text_body, + from_email=configuration_helpers.get_value('email_from_address', 'noreply@example.com'), + to=[test_email_to], + ) + msg.attach_alternative(html_body, 'text/html') + + sent = msg.send(fail_silently=False) + + if sent: + self.stdout.write( + self.style.SUCCESS( + f'✓ Test email sent successfully to {test_email_to}' + ) + ) + self.stdout.write( + 'Check the email client for: ' + '- Correct template rendering (layout, images, text)' + '- Language-specific content (if Spanish template)' + ) + else: + self.stdout.write( + self.style.WARNING('⚠ Email may not have been sent (send returned 0)') + ) + except Exception as exc: # pylint: disable=broad-exception-caught + self.stdout.write( + self.style.ERROR(f'✗ Email sending failed: {exc}') + ) + import traceback + self.stdout.write(traceback.format_exc()) diff --git a/common/djangoapps/student/tasks.py b/common/djangoapps/student/tasks.py index c13c5bf96bd8..b516518098d7 100644 --- a/common/djangoapps/student/tasks.py +++ b/common/djangoapps/student/tasks.py @@ -2,10 +2,17 @@ Celery task for course enrollment email """ import logging +from datetime import datetime + from celery import shared_task from django.conf import settings from django.contrib.auth import get_user_model +from django.contrib.sites.models import Site +from django.core.mail import EmailMultiAlternatives +from django.template.loader import render_to_string +from django.utils.html import strip_tags from edx_django_utils.monitoring import set_code_owner_attribute +from edx_toggles.toggles import WaffleFlag from opaque_keys.edx.keys import CourseKey from common.djangoapps.track import segment @@ -15,12 +22,15 @@ get_instructors, ) from lms.djangoapps.utils import get_email_client +from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY from openedx.core.djangoapps.catalog.utils import ( get_course_uuid_for_course, get_owners_for_course, get_course_run_details, ) +from openedx.core.djangoapps.user_api.models import UserPreference from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers +from openedx.core.lib.celery.task_utils import emulate_http_request from openedx.features.course_experience import ENABLE_COURSE_GOALS User = get_user_model() @@ -28,12 +38,202 @@ MAX_RETRIES = 3 +# .. toggle_name: student.enable_ses_for_course_enrollment +# .. toggle_implementation: WaffleFlag +# .. toggle_default: False +# .. toggle_description: Route course enrollment emails via SES (Django email backend) instead of Braze. +# .. toggle_use_cases: opt_in, temporary +# .. toggle_creation_date: 2026-06-01 +# .. toggle_target_removal_date: None +# .. toggle_warning: When enabled, enrollment emails are sent via SES; Braze canvas message is skipped. +ENABLE_SES_FOR_COURSE_ENROLLMENT = WaffleFlag( + 'student.enable_ses_for_course_enrollment', + __name__, +) + + +def _get_enrollment_email_language(user): + """ + Return enrollment email language code supported by templates. + + Currently supports English and Spanish. + """ + preferred_language = (UserPreference.get_value(user, LANGUAGE_KEY, default='en') or 'en').lower() + if preferred_language.startswith('es'): + return 'es' + return 'en' + + +def _send_ses_enrollment_email(user, context, language='en'): + """ + Send course enrollment email via SES using Django template rendering. + + Renders the themed enrollment HTML template (resolved from edx-themes if a themed + override exists, falling back to edx-platform) and sends via Django's email backend. + + Template resolution: + 1. If SES_ENROLLMENT_EMAIL_THEME is configured, try that theme first + 2. Otherwise, use DEFAULT_SITE_THEME resolution + 3. Fall back to platform defaults if theme template not found + + Args: + user: Django User instance + context (dict): Template context built by send_course_enrollment_email + language (str): 'en' or 'es' — selects the correct template variant + """ + # Use SES-specific theme if configured, otherwise use default theme resolution + ses_theme = getattr(settings, 'SES_ENROLLMENT_EMAIL_THEME', None) + if ses_theme: + # Attempt to load from SES-specific theme (e.g., "edx.org-next") + template_name = f"{ses_theme}/emails/enrollment_{language}.html" + else: + # Use default theme resolution (DEFAULT_SITE_THEME or platform default) + template_name = f"emails/enrollment_{language}.html" + + # Work on a copy so we don't mutate the caller's dict + ctx = dict(context) + # Inject SES-specific absolute image URLs (Braze ignores these keys) + ctx.update(_build_enrollment_email_image_urls(language=language)) + + html_body = render_to_string(template_name, ctx) + plain_text_body = strip_tags(html_body).strip() + if not plain_text_body: + course_title = ctx.get('course_title', '') + plain_text_body = ( + f"You are enrolled in {course_title}." + if course_title + else "You are enrolled in a course on edX." + ) + + from_email = configuration_helpers.get_value( + 'email_from_address', + configuration_helpers.get_value('ACTIVATION_EMAIL_FROM_ADDRESS', settings.DEFAULT_FROM_EMAIL), + ) + reply_to_email = configuration_helpers.get_value('CONTACT_EMAIL', settings.DEFAULT_FEEDBACK_EMAIL) + course_title = ctx.get('course_title', '') + if language == 'es': + subject = ( + f"Te has inscrito en {course_title}" + if course_title + else "Te has inscrito en un curso de edX" + ) + else: + subject = ( + f"You're enrolled in {course_title}" + if course_title + else "You're enrolled in an edX course" + ) + + email_kwargs = { + 'subject': subject, + 'body': plain_text_body, + 'from_email': from_email, + 'to': [user.email], + } + if reply_to_email: + email_kwargs['reply_to'] = [reply_to_email] + + msg = EmailMultiAlternatives(**email_kwargs) + msg.attach_alternative(html_body, 'text/html') + msg.send(fail_silently=False) + + +def _send_braze_enrollment_email(user_id, canvas_entry_properties): + """ + Send course enrollment email via Braze canvas API. + + Uses the existing Braze helper to send a canvas message. This is used both + for direct Braze sends and as a fallback when SES delivery fails. + """ + recipients = [{"external_user_id": user_id}] + braze_client = get_email_client() + if braze_client: + braze_client.send_canvas_message( + canvas_id=getattr(settings, 'BRAZE_COURSE_ENROLLMENT_CANVAS_ID', None), + recipients=recipients, + canvas_entry_properties=canvas_entry_properties, + ) + + +def _build_enrollment_email_image_urls(language='en'): + """ + Build absolute URLs for enrollment email images (SES only). + + This function constructs full image URLs for SES (Simple Email Service) + email delivery. These URLs are required because SES email clients cannot + resolve relative paths or Django static file paths; they need absolute + URLs. Braze does NOT use these image URL keys; they are ignored by Braze + and only used when the SES path is enabled. + + Args: + language (str): Language code ('en' or 'es') to select correct image variants + + Returns: + dict: Mapping of image variable names to absolute URLs + + How it works: + 1. settings.LMS_ROOT_URL is evaluated at runtime (e.g., 'https://courses.edx.org') + 2. f-string interpolates this value into the path + 3. SES email client receives complete URL: 'https://courses.edx.org/static/images/enrollment_email/...' + 4. Browser/email client can fetch image directly without context about Django + + Example flow: + - Python (settings): LMS_ROOT_URL = 'https://courses.edx.org' + - f-string: f"{settings.LMS_ROOT_URL}/static/images/enrollment_email/person_icon_{language}.png" + - Result: 'https://courses.edx.org/static/images/enrollment_email/person_icon_en.png' + - SES email client: Makes HTTP GET to that URL, renders image + """ + lms_root = configuration_helpers.get_value( + "LMS_ROOT_URL", settings.LMS_ROOT_URL + ).rstrip('/') + + # NOTE: Image URLs for SES templates. + # Currently not used in Braze flow — will be enabled during SES migration. + # Construct image URLs based on language + image_urls = { + 'logo_url': f"{lms_root}/static/images/edx_logo.png", + 'you_are_enrolled_en': f"{lms_root}/static/images/enrollment_email/you_are_enrolled_en.png", + 'banner_default': f"{lms_root}/static/images/enrollment_email/banner_default.png", + 'timer_icon_en': f"{lms_root}/static/images/enrollment_email/timer_icon_en.png", + 'person_icon_en': f"{lms_root}/static/images/enrollment_email/person_icon_en.png", + 'dollar_icon_en': f"{lms_root}/static/images/enrollment_email/dollar_icon_en.png", + 'goal_idea_icon_en': f"{lms_root}/static/images/enrollment_email/goal_idea_icon_en.png", + 'flag_icon_pink_en': f"{lms_root}/static/images/enrollment_email/flag_icon_pink_en.png", + 'flag_icon_black_en_es': f"{lms_root}/static/images/enrollment_email/flag_icon_black_en_es.png", + 'flag_icon_orange_en': f"{lms_root}/static/images/enrollment_email/flag_icon_orange_en.png", + 'vertical_line_white_en': f"{lms_root}/static/images/enrollment_email/vertical_line_white_en.png", + 'vertical_line_orange_en': f"{lms_root}/static/images/enrollment_email/vertical_line_orange_en.png", + 'vertical_line_black_en': f"{lms_root}/static/images/enrollment_email/vertical_line_black_en.png", + 'community_illustration_en': f"{lms_root}/static/images/enrollment_email/community_illustration_en.png", + } + + # If Spanish, override with Spanish variants + if language == 'es': + spanish_overrides = { + # Same banner for both languages. + 'banner_default': f"{lms_root}/static/images/enrollment_email/banner_default.png", + 'arrow_icon_es': f"{lms_root}/static/images/enrollment_email/arrow_icon_es.png", + 'timer_icon_es': f"{lms_root}/static/images/enrollment_email/timer_icon_es.png", + 'person_icon_es': f"{lms_root}/static/images/enrollment_email/person_icon_es.png", + 'dollar_icon_es': f"{lms_root}/static/images/enrollment_email/dollar_icon_es.png", + 'flag_icon_white_es': f"{lms_root}/static/images/enrollment_email/flag_icon_white_es.png", + 'flag_icon_grey_es': f"{lms_root}/static/images/enrollment_email/flag_icon_grey_es.png", + # Same black flag icon for both languages. + 'flag_icon_black_en_es': f"{lms_root}/static/images/enrollment_email/flag_icon_black_en_es.png", + 'calendar_icon_es': f"{lms_root}/static/images/enrollment_email/calendar_icon_es.png", + 'community_icon_es': f"{lms_root}/static/images/enrollment_email/community_icon_es.png", + 'slant_line_es': f"{lms_root}/static/images/enrollment_email/slant_line_es.png", + } + image_urls.update(spanish_overrides) + + return image_urls + @shared_task(bind=True, ignore_result=True) @set_code_owner_attribute def send_course_enrollment_email( self, user_id, course_id, course_title, short_description, course_ended, pacing_type, track_mode -): +): # pylint: disable=too-many-statements """ Send course enrollment email using Braze API. @@ -64,13 +264,16 @@ def send_course_enrollment_email( "course_price": CourseMode.min_course_price_for_currency( course_id=course_id, currency="USD" ), + # Strip trailing slashes so template concatenation like + # "{{ lms_base_url }}/courses/..." never produces a double slash. "lms_base_url": configuration_helpers.get_value( "LMS_ROOT_URL", settings.LMS_ROOT_URL - ), + ).rstrip('/'), "learning_base_url": configuration_helpers.get_value( "LEARNING_MICROFRONTEND_URL", settings.LEARNING_MICROFRONTEND_URL - ), - "track_mode": track_mode + ).rstrip('/'), + "track_mode": track_mode, + "current_year": datetime.now().year, } try: @@ -86,9 +289,16 @@ def send_course_enrollment_email( { "course_date_blocks": course_date_blocks, "goals_enabled": ENABLE_COURSE_GOALS.is_enabled(course_key), + "user_name": user.get_full_name() or user.first_name or user.username, } ) + enrollment_language = _get_enrollment_email_language(user) + + # Inject absolute image URLs for SES rendering. Braze ignores these extra + # keys; they're consumed only when the SES path is enabled. + canvas_entry_properties.update(_build_enrollment_email_image_urls(language=enrollment_language)) + try: course_uuid = get_course_uuid_for_course(course_id) if course_uuid is None: @@ -102,25 +312,30 @@ def send_course_enrollment_email( marketing_root_url = settings.MKTG_URLS.get("ROOT") instructors = get_instructors(course_run, marketing_root_url) enrollment_count = int(course_run.get("enrollment_count")) if course_run.get("enrollment_count") else 0 - canvas_entry_properties.update( - { - "instructors": instructors, - "instructors_count": "even" if len(instructors) % 2 == 0 else "odd", - "min_effort": course_run.get("min_effort"), - "max_effort": course_run.get("max_effort"), - "weeks_to_complete": course_run.get("weeks_to_complete"), - "learners_count": "{:,}".format(enrollment_count) if enrollment_count > 100 else "", - "banner_image_url": course_run.get("image").get("src", "") if course_run.get("image") else "", - "course_title": course_run.get("title"), - "short_description": course_run.get("short_description"), - "pacing_type": course_run.get("pacing_type"), - "partner_image_url": owners[0].get("logo_image_url") if owners else "", - "org_name": owners[0].get("name") if owners else "", - } - ) + canvas_entry_properties.update({ + "instructors": instructors, + "instructors_count": "even" if len(instructors) % 2 == 0 else "odd", + "min_effort": course_run.get("min_effort"), + "max_effort": course_run.get("max_effort"), + "weeks_to_complete": course_run.get("weeks_to_complete"), + "learners_count": ( + "{:,}".format(enrollment_count) if enrollment_count > 100 else "" + ), + "banner_image_url": ( + course_run.get("image").get("src", "") if course_run.get("image") else "" + ), + "course_title": course_run.get("title"), + "short_description": course_run.get("short_description"), + "pacing_type": course_run.get("pacing_type"), + "partner_image_url": owners[0].get("logo_image_url") if owners else "", + "org_name": owners[0].get("name") if owners else "", + }) except Exception as err: # pylint: disable=broad-except is_course_run_missing = True - log.warning(f"[Course Enrollment] Course run call failed for user: {user_id} course: {course_id} error: {err}") + log.warning( + f"[Course Enrollment] Course run call failed for user: {user_id} " + f"course: {course_id} error: {err}" + ) if is_course_run_missing or is_course_date_missing: segment_properties = { @@ -130,16 +345,50 @@ def send_course_enrollment_email( } segment.track(user_id, 'edx.course.enrollment.email.missingdata', segment_properties) - try: - recipients = [{"external_user_id": user_id}] - email_client = get_email_client() - if email_client: - email_client.send_canvas_message( - canvas_id=settings.BRAZE_COURSE_ENROLLMENT_CANVAS_ID, - recipients=recipients, - canvas_entry_properties=canvas_entry_properties, + use_ses = ENABLE_SES_FOR_COURSE_ENROLLMENT.is_enabled() + sent_via = 'SES' + + if use_ses: + # SES path: render the themed Django template and send via the Django email backend. + # emulate_http_request sets the thread-local site so the themed template loader + # can resolve templates from edx-themes (mirrors account-activation SES pattern). + try: + site = Site.objects.get_current() + with emulate_http_request(site=site, user=user): + _send_ses_enrollment_email(user, canvas_entry_properties, language=enrollment_language) + except Exception: # pylint: disable=broad-except + log.warning( + "SES send failed for %s, falling back to Braze canvas message", + user.email, + exc_info=True, ) - except Exception as exc: # pylint: disable=broad-except - log.error(f"[Course Enrollment] Email sending failed with exception: {exc}") - countdown = 60 * (self.request.retries + 1) - raise self.retry(exc=exc, countdown=countdown, max_retries=MAX_RETRIES) + try: + _send_braze_enrollment_email(user_id, canvas_entry_properties) + sent_via = 'Braze' + except Exception as braze_exc: # pylint: disable=broad-except + log.error( + 'Unable to send enrollment email to %s, attempted via SES and Braze fallback', + user.email, + exc_info=True, + ) + countdown = 60 * (self.request.retries + 1) + raise self.retry(exc=braze_exc, countdown=countdown, max_retries=MAX_RETRIES) + else: + # Braze path (default): send canvas message via the Braze client. + sent_via = 'Braze' + try: + _send_braze_enrollment_email(user_id, canvas_entry_properties) + except Exception as exc: # pylint: disable=broad-except + log.error( + 'Unable to send enrollment email to %s via Braze', + user.email, + exc_info=True, + ) + countdown = 60 * (self.request.retries + 1) + raise self.retry(exc=exc, countdown=countdown, max_retries=MAX_RETRIES) + + log.info( + 'Course enrollment email for %s sent via %s', + user.email, + sent_via, + ) diff --git a/common/djangoapps/student/templates/emails/enrollment_en.html b/common/djangoapps/student/templates/emails/enrollment_en.html new file mode 100644 index 000000000000..7e12832d7727 --- /dev/null +++ b/common/djangoapps/student/templates/emails/enrollment_en.html @@ -0,0 +1,1745 @@ + + + + + + + + edX + + + + + + + + + + + +
+ + + + + +
+ + + {% include "emails/partials/header_en.html" %} + + + + + + + +
+ You're enrolled, Restless Learner! +
+ + + + + + + + + + + + + + +
+ + + + +
+ + + + + + +
+ + + +
+ + {% if banner_image_url %} + Course Banner + {% else %} + Course Banner + {% endif %} + + {% if partner_image_url %} +
+ Partner Logo +
+ {% endif %} + +
+
+

{{ course_title }}

+

{{ org_name }}

+ +
+ + + + + +
+ + + + + + +
+ + + + + + + + + + + + + + + + + +
+

+ {{ org_name }} +

+
+

{{ course_title }}

+
+

+ {{ short_description }} +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Duration + + Estimated {{ weeks_to_complete }} weeks
+ + {% if min_effort and max_effort %} + {{ min_effort }} - {{ max_effort }} hours per week + {% elif min_effort %} + {% if min_effort > 1 %} + {{ min_effort }} hours per week + {% else %} + {{ min_effort }} hour per week + {% endif %} + {% elif max_effort %} + {% if max_effort > 1 %} + {{ max_effort }} hours per week + {% else %} + {{ max_effort }} hour per week + {% endif %} + {% endif %} + +
+ Schedule + + {% if pacing_type == 'instructor_paced' %} + Instructor-led + {% else %} + Self-paced + {% endif %}
+ + {% if pacing_type == 'instructor_paced' %} + On a course schedule + {% else %} + Progress at your own speed + {% endif %} + +
+ Price + + {% if course_price == 0 %} + Free + {% else %} + $ {{ course_price }} + {% endif %}
+ + {% if course_price == 0 %} + Optional upgrade available + {% endif %} + +
+ + + + + + + +
+ Start + my course +
+ +
+
+
+
+
+ + + + {% if instructors %} + + + + + + + + + + + + +
+ + + + + + + {% if not instructors|length|divisibleby:2 %} + {% with instructors.0 as first_instructor %} + + + + + + + {% endwith %} + + {% else %} + + + + + + + + + {% endif %} + +
+ {% if instructors|length == 1 %} + Meet your instructor: + {% else %} + Meet your instructors: + {% endif %} +
+ + + + +
+ + + + + + +
+ + + + + + + + +
+ {{ first_instructor.name }} +
+ {{ first_instructor.organization_name }} +
+
+
+
+ + + {% for instructor in instructors %} + {% if not forloop.first %} + + {% endif %} + {% endfor %} + +
+ + + + + +
+ + + + + + +
+ + + + + + + + + + +
+ {{ instructor.name }} +
+ {{ instructor.organization_name }} +
+
+
+
+
+ + + {% for instructor in instructors %} + + {% endfor %} + +
+ + + + + +
+ + + + + + +
+ + + + + + + + + + + + + + +
+ {{ instructor.name }} +
+ {{ instructor.organization_name }} +
+
+
+
+
+
+ {% endif %} + + + + {% if goals_enabled %} + + + + + + + + +
+ + + + +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + +
+ + + + +
+ Message Icon +
+
+ + + + + + +
+ Set your learning goal +
+
+
+
+ + + + + + +
+ + Setting a goal helps you find the motivation to keep up with coursework and + make it to the finish line. If we notice you're not quite at your goal, + we'll send you an email reminder. Set your goal today. + +
+
+ + + + +
+ + + + + + + + + +
+ + + + + + + + + + + + +
+ Casual Icon + + + + + + + + + +
+ Casual
+
+ 1 day a week +
+
+
+
+ + + + + + + + + + + + +
+ Regular Icon + + + + + + + + + +
+ Regular
+
+ 3 days a week +
+
+
+
+ + + + + + + + + + + + +
+ Intense Icon + + + + + + + + + +
+ Intense
+
+ 5 days a week +
+
+
+
+
+
+
+
+
+ {% endif %} + + + + + + + + + + + + +
+ + + + + + + + +
+ + + + + + + + + + + + +
+ Stay on track with your personalized course schedule +
+ Track important dates, assignments, and more. +
+ + + + + +
+ View + my schedule +
+
+
+ + + + {% if course_date_blocks|length > 0 and course_date_blocks.0.due_date %} + + + + + + + + + {% endif %} + + + + + + + + + + + + + + + + + + + + +
+ Past due
+ + + + + + +
+ Vertical Line + + + + + + + + + +
+ {{ course_date_blocks.0.due_date }} +
+ {{ course_date_blocks.0.title }} +
+
+
+ Today
+ + + + + + +
+ Vertical Line + + + + + + + + + +
+ {% if course_date_blocks|length > 1 %} + {{ course_date_blocks.1.due_date }} + {% endif %} +
+ {% if course_date_blocks|length > 1 and course_date_blocks.1.title %} + {{ course_date_blocks.1.title }} + {% else %} + No assignments due today + {% endif %} +
+
+
+ Due next
+ + + + + + +
+ Vertical Line + + + + + + + + + +
+ {% if course_date_blocks|length > 2 and course_date_blocks.2.due_date %} + {{ course_date_blocks.2.due_date }} + {% endif %} +
+ {% if course_date_blocks|length > 2 and course_date_blocks.2.title %} + {{ course_date_blocks.2.title }} + {% else %} + No upcoming assignments + {% endif %} +
+
+
+
+
+ + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + +
+ + + + + + + + + +
+ + + + + + + + + + + + + + +
+ Engage with a global community +
+ + + + + + + + +
+ Meet the + + {% if learners_count %}{{ learners_count }}{% else %}fellow{% endif %} + + learners in your course +
+
+ + Join the discussion + +
+
+ + + + +
+ Community illustration +
+
+
+
+
+ + + + {% include "emails/partials/footer_en.html" %} + + +
+ +
+ + + + \ No newline at end of file diff --git a/common/djangoapps/student/templates/emails/enrollment_es.html b/common/djangoapps/student/templates/emails/enrollment_es.html new file mode 100644 index 000000000000..374d2ef4584e --- /dev/null +++ b/common/djangoapps/student/templates/emails/enrollment_es.html @@ -0,0 +1,1358 @@ + + + + + + + + edX + + + + + + + + + + + +
+ + + +
+ + + {% include "emails/partials/header_es.html" %} + + + + + + + + +
+ + + + + + + + + + + + + + + + +
  + + + + + +
+

Te has inscrito, 

+
+

{{ user_name|default:"Restless Learner" }}!

+
+
 
  + + + + + + +
+ {% if banner_image_url %} +
+ + + {% if partner_image_url %} +
+ Partner Logo +
+ {% endif %} + +
+ {% else %} +
+ + {% if partner_image_url %} +
+ Partner Logo +
+ {% endif %} + +
+ {% endif %} +
+

+ {{course_title}} +

+
+
  + + + + + + + + +
+ + {{short_description}} + +
+ + + + +
+ + + + + + + + + +
+ Empieza mi curso + + +
+
+
+
+
+
 
+
+ + + + + + + + + +
+ + + {% if weeks_to_complete %} + + {% endif %} + + {% if track_mode != 'masters' %} + + {% endif %} + +
+ + + + + + +
+ + + + + + + + + +
+

+ Se estima tardar {{weeks_to_complete}} semanas +

+
+

+ {% if min_effort and max_effort %} + {{min_effort}} - {{max_effort}} horas por semana + {% elif min_effort %} + {% if min_effort > 1 %} + {{min_effort}} horas por semana + {% else %} + {{min_effort}} hora por semana + {% endif %} + {% elif max_effort %} + {% if max_effort > 1 %} + {{max_effort}} horas por semana + {% else %} + {{max_effort}} hora por semana + {% endif %} + {% endif %} +

+
+
+
+ + + + + + +
+ + + + + + + + + +
+

+ {% if pacing_type == 'instructor_paced' %} + Al ritmo del profesor + {% else %} + Al ritmo del estudiante + {% endif %} +

+
+

+ {% if pacing_type == 'instructor_paced' %} + Sigue un horario curricular + {% else %} + Avanza a tu propio ritmo + {% endif %} +

+
+
+
+ + + + + + +
+ + + {% if course_price == 0 %} + + + + + + + +
+

+ Gratuito +

+
+

+ Opción de mejorar tu subscripción +

+
+ {% else %} + + + + +
+

+ $ {{ course_price }} +

+
+ {% endif %} +
+
+
+ + + + + + {% if goals_enabled %} + + + + + + + + + + + + +
  + + + + + + + + + + + +
+

Marca tus

+
+

metas de aprendizaje

+
+

Marcar una meta te anima a terminar el curso. Siempre puedes cambiarla + más tarde. Si notamos que aún no has llegado a tu meta, te enviaremos un recordatorio por + correo electrónico.

+
+
  + + + + + + + + + + + + +
+ + + + + + + + + + +
+ + +

+ Casual +

+
+

+ 1 día a la semana +

+
+
+
+ + + + + + + + + + +
+ + +

+ Regular +

+
+

+ 3 días a la semana +

+
+
+
+ + + + + + + + + + +
+ + +

+ Intenso +

+
+

+ 5 días a la semana +

+
+
+
+
 
+ {% endif %} + + + + + + {% if instructors %} + + + + + + + + + + + {% if not instructors|length|divisibleby:2 %} + {% with instructors.0 as first_instructor %} + + + + + + + + + + + + {% endwith %} + {% else %} + + + + + + + {% endif %} + +
  + {% if instructors|length == 1 %} +

Tu instructor

+ {% else %} +

Tus instructores

+ {% endif %} +
 
  + + + + + + + + + + + + + + +
+ +
+ {{first_instructor.name}} +
+ {{first_instructor.organization_name}} +
+
+
 
  + + {% for instructor in instructors %} + {% if not forloop.first %} + + + + {% endif %} + {% endfor %} +
+ + + + + + + + + + + + + + +
+ +
+ {{instructor.name}} +
+ {{instructor.organization_name}} +
+
+
+
 
  + + {% for instructor in instructors %} + + + + {% endfor %} +
+ + + + + + + + + + + + + + +
+ +
+ {{instructor.name}} +
+ {{instructor.organization_name}} +
+
+
+
 
+ {% endif %} + + + + + + {% if course_date_blocks %} + + + + + + + + + + + + + + + + + + +
  + + + + + + + + + + + +
+

Mantiene un buen ritmo

+
+

con tu horario de curso personalizado

+
+

Recibe recordatorios sobre fechas importantes, evaluaciones y más!

+
+
  + + {% for date in course_date_blocks %} + {% if forloop.first %} + + + + + + + + {% if not date.assignment_type %} + + {% else %} + + {% endif %} + + {% elif not forloop.last %} + + + + + + + + + {% if not date.assignment_type %} + + {% else %} + + {% endif %} + + {% else %} + + + + + + + + + {% if not date.assignment_type %} + + {% else %} + + {% endif %} + + {% endif %} + {% endfor %} +
+ + + + + + + + + + +
+
+
+
+
+
+
+
{{date.due_date}}
+ + + + +
+
+
+
+
+ {{date.title}} + + Hora límite de entrega de + {{date.assignment_type}}: + {{date.title}} + a las {{date.due_time}} + {% if date.assignment_count == 0 %} + {% else %} + +{{date.assignment_count}} + más + {% endif %} +
+ + + + + + + + + + +
+
+
+
+
+
+
+
{{date.due_date}}Hoy
+ + + + +
+
+
+
+
+ {{date.title}} + + Hora límite de entrega de + {{date.assignment_type}}: + {{date.title}} + a las {{date.due_time}} + {% if date.assignment_count == 0 %} + {% else %} + +{{date.assignment_count}} + más + {% endif %} +
+ + + + + + + + + + +
+
+
+
+
+
+
+
{{date.due_date}}Próxima entrega +
+ + + + +
+
+
+
+
+ {{date.title}} + + Hora límite de entrega de {{date.assignment_type}}: + {{date.title}} + a las {{date.due_time}} + {% if date.assignment_count == 0 %} + {% else %} + +{{date.assignment_count}} + más + {% endif %} +
+
+ + + + +
+ + + + + + + + + +
+ Calendar Icon + + Ver mi horario +
+
+
+
+ {% endif %} + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + {% include "emails/partials/footer_es.html" %} + + + +
+

Únete a la comunidad

+
+

Conéctate con otros estudiantes, haz preguntas y comparte tus experiencias en los foros de discusión del curso.

+
+ + + + +
+ + + + + + + + + +
+ Ir al foro de discusión + + +
+
+
+
+ +
+ + + + \ No newline at end of file diff --git a/common/djangoapps/student/templates/emails/partials/footer_en.html b/common/djangoapps/student/templates/emails/partials/footer_en.html new file mode 100644 index 000000000000..8c3b031109e5 --- /dev/null +++ b/common/djangoapps/student/templates/emails/partials/footer_en.html @@ -0,0 +1,130 @@ + + + + + + +
+ + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ + + + + + +
+ + + + + + +
Go to edX home page
+ + + + + + + + +
+ + + + + + + + + + + + + + +
edX on FacebookedX on XedX on LinkedInedX on InstagramedX on Reddit
+
+
+
+
+ + + + + + +
+ + + + + + +
+ + + + + + + + +
Download the iOS app on the Apple StoreDownload the Android app on the Google Play Store
+
+
+
edX.org where learning moments become career milestones.
+
+ Need enterprise learning solutions? edX For Business
+
+ © {{ current_year|default:'' }} edX LLC All rights reserved.
+
+ Update Your Preferences / Unsubscribe +

+ 2345 Crystal Drive, Suite 1100, Arlington, VA 22202
+
+
diff --git a/common/djangoapps/student/templates/emails/partials/footer_es.html b/common/djangoapps/student/templates/emails/partials/footer_es.html new file mode 100644 index 000000000000..35a9cd7d20d8 --- /dev/null +++ b/common/djangoapps/student/templates/emails/partials/footer_es.html @@ -0,0 +1,129 @@ + + + + + + +
+ + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ + + + + + +
+ + + + + + +
Ir a la pagina principal de edX
+ + + + + + + + +
+ + + + + + + + + + + + + + +
edX en FacebookedX en XedX en LinkedInedX en InstagramedX en Reddit
+
+
+
+
+ + + + + + +
+ + + + + + +
+ + + + + + + + +
Descarga la app para iOS en la App StoreDescarga la app para Android en Google Play
+
+
+
edX es la plataforma de confianza para la educación y el aprendizaje.
+
+ edX Para Negocios — Soluciones de aprendizaje en línea para tu organización.

+ © {{ current_year|default:'' }} edX LLC Todos los derechos reservados.
+
+ Actualiza tus preferencias / Salir de la lista
+
+ 2345 Crystal Drive, Suite 1100, Arlington, VA 22202
+
+
diff --git a/common/djangoapps/student/templates/emails/partials/header_en.html b/common/djangoapps/student/templates/emails/partials/header_en.html new file mode 100644 index 000000000000..2493baf83c8d --- /dev/null +++ b/common/djangoapps/student/templates/emails/partials/header_en.html @@ -0,0 +1,61 @@ +{# Django template partial #} + + + + + + + + + + + + +
 
+ + + + +
+ + + + +
edX
+
+ + + + + +
+ + + + +
+ + + Courses + + +  /  + + + Programs + + +  /  + + + My Account + + +
+
+
 
diff --git a/common/djangoapps/student/templates/emails/partials/header_es.html b/common/djangoapps/student/templates/emails/partials/header_es.html new file mode 100644 index 000000000000..0271435d4b58 --- /dev/null +++ b/common/djangoapps/student/templates/emails/partials/header_es.html @@ -0,0 +1,38 @@ + {# Django template partial #} + + + + + + +
 
+ + + + +
+ + +
Ir a la pagina principal de edX
+
+ + + + + +
+ + + + + + + + +
Cursos/Programas/Mi Cuenta
+
+
 
+ \ No newline at end of file diff --git a/common/djangoapps/student/tests/test_tasks.py b/common/djangoapps/student/tests/test_tasks.py index d5fc3d3db0a2..7d7542662268 100644 --- a/common/djangoapps/student/tests/test_tasks.py +++ b/common/djangoapps/student/tests/test_tasks.py @@ -1,15 +1,19 @@ """ Celery task tests """ +from datetime import datetime from unittest.mock import patch, Mock, PropertyMock import pytest from django.conf import settings from django.test.utils import override_settings +from edx_toggles.toggles.testutils import override_waffle_flag from common.djangoapps.student.tasks import ( MAX_RETRIES, - send_course_enrollment_email + ENABLE_SES_FOR_COURSE_ENROLLMENT, + _build_enrollment_email_image_urls, + send_course_enrollment_email, ) from common.djangoapps.student.tests.factories import UserFactory from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase @@ -132,14 +136,19 @@ def _get_canvas_properties( "learning_base_url": "https://learningmfe.openedx.org", "lms_base_url": settings.LMS_ROOT_URL, "course_price": 0, + "current_year": datetime.now().year, "goals_enabled": False, "course_date_blocks": [], "course_title": self.send_course_enrollment_email_kwargs["course_title"], "short_description": self.send_course_enrollment_email_kwargs["short_description"], "pacing_type": self.send_course_enrollment_email_kwargs["pacing_type"], "track_mode": self.send_course_enrollment_email_kwargs["track_mode"], + "user_name": self.user.get_full_name() or self.user.first_name or self.user.username, } + # Payload now always includes image URLs used by SES and ignored by Braze. + canvas_properties.update(_build_enrollment_email_image_urls(language='en')) + if add_course_dates: canvas_properties.update({"course_date_blocks": self._get_course_dates()}) @@ -388,3 +397,337 @@ def test_retry_with_email_client_exception( ) pytest.raises(Exception, task.get) self.assertEqual(mock_get_email_client.call_count, (MAX_RETRIES + 1)) + + +@override_settings( + BRAZE_COURSE_ENROLLMENT_CANVAS_ID=BRAZE_COURSE_ENROLLMENT_CANVAS_ID, + LEARNING_MICROFRONTEND_URL="https://learningmfe.openedx.org", + DEFAULT_FROM_EMAIL="no-reply@edx.org", + LMS_ROOT_URL="https://courses.edx.org", +) +class TestCourseEnrollmentEmailSESRouting(ModuleStoreTestCase): + """ + Tests for waffle-flag-based SES/Braze routing in send_course_enrollment_email. + + Mirrors the pattern used for account-activation SES routing in + openedx/core/djangoapps/user_authn/tasks.py. + """ + + COURSE_ID = "course-v1:edX+DemoX+2024" + + def setUp(self): + super().setUp() + self.user = UserFactory.create(username="ses_user", email="ses@example.com") + # Use a string course ID — no CourseFactory needed since all external + # calls are mocked. + self.task_kwargs = { + "user_id": self.user.id, + "course_id": self.COURSE_ID, + "course_title": "SES Test Course", + "short_description": "SES description", + "course_ended": False, + "pacing_type": "self_paced", + "track_mode": "audit", + } + + @override_waffle_flag(ENABLE_SES_FOR_COURSE_ENROLLMENT, active=False) + @patch("common.djangoapps.student.tasks.get_course_uuid_for_course") + @patch("common.djangoapps.student.tasks.get_owners_for_course") + @patch("common.djangoapps.student.tasks.get_course_run_details") + @patch("common.djangoapps.student.tasks.get_course_dates_for_email") + @patch("common.djangoapps.student.tasks.get_email_client") + @patch("common.djangoapps.student.tasks._send_ses_enrollment_email") + def test_braze_path_used_when_waffle_disabled( + self, + mock_ses_send, + mock_get_email_client, + mock_get_course_dates, + mock_get_course_run, + mock_get_owners, + mock_get_uuid, + ): + """When the waffle flag is off, Braze canvas message is sent and SES is NOT called.""" + mock_get_uuid.return_value = "some-uuid" + mock_get_owners.return_value = [] + mock_get_course_run.return_value = {} + mock_get_course_dates.return_value = [] + + send_course_enrollment_email.apply_async(kwargs=self.task_kwargs) + + mock_get_email_client.return_value.send_canvas_message.assert_called_once() + mock_ses_send.assert_not_called() + + @override_waffle_flag(ENABLE_SES_FOR_COURSE_ENROLLMENT, active=True) + @patch("common.djangoapps.student.tasks.get_course_uuid_for_course") + @patch("common.djangoapps.student.tasks.get_owners_for_course") + @patch("common.djangoapps.student.tasks.get_course_run_details") + @patch("common.djangoapps.student.tasks.get_course_dates_for_email") + @patch("common.djangoapps.student.tasks.get_email_client") + @patch("common.djangoapps.student.tasks._send_ses_enrollment_email") + def test_ses_path_used_when_waffle_enabled( + self, + mock_ses_send, + mock_get_email_client, + mock_get_course_dates, + mock_get_course_run, + mock_get_owners, + mock_get_uuid, + ): + """When the waffle flag is on, SES send is called and Braze is NOT called.""" + mock_get_uuid.return_value = "some-uuid" + mock_get_owners.return_value = [] + mock_get_course_run.return_value = {} + mock_get_course_dates.return_value = [] + + send_course_enrollment_email.apply_async(kwargs=self.task_kwargs) + + mock_ses_send.assert_called_once() + mock_get_email_client.return_value.send_canvas_message.assert_not_called() + + @override_waffle_flag(ENABLE_SES_FOR_COURSE_ENROLLMENT, active=True) + @patch("common.djangoapps.student.tasks.get_course_uuid_for_course") + @patch("common.djangoapps.student.tasks.get_owners_for_course") + @patch("common.djangoapps.student.tasks.get_course_run_details") + @patch("common.djangoapps.student.tasks.get_course_dates_for_email") + @patch("common.djangoapps.student.tasks.get_email_client") + @patch( + "common.djangoapps.student.tasks._send_ses_enrollment_email", + side_effect=Exception("SES failure"), + ) + def test_ses_failure_falls_back_to_braze( + self, + mock_ses_send, + mock_get_email_client, + mock_get_course_dates, + mock_get_course_run, + mock_get_owners, + mock_get_uuid, + ): + """If SES send fails, task should fall back to Braze without retrying.""" + mock_get_uuid.return_value = "some-uuid" + mock_get_owners.return_value = [] + mock_get_course_run.return_value = {} + mock_get_course_dates.return_value = [] + + send_course_enrollment_email.apply_async(kwargs=self.task_kwargs) + + mock_ses_send.assert_called_once() + mock_get_email_client.return_value.send_canvas_message.assert_called_once() + + @override_waffle_flag(ENABLE_SES_FOR_COURSE_ENROLLMENT, active=True) + @patch("common.djangoapps.student.tasks.get_course_uuid_for_course") + @patch("common.djangoapps.student.tasks.get_owners_for_course") + @patch("common.djangoapps.student.tasks.get_course_run_details") + @patch("common.djangoapps.student.tasks.get_course_dates_for_email") + @patch( + "common.djangoapps.student.tasks._send_ses_enrollment_email", + side_effect=Exception("SES failure"), + ) + @patch( + "common.djangoapps.student.tasks.get_email_client", + new_callable=PropertyMock, + side_effect=Exception("Braze fallback failure"), + ) + def test_ses_and_braze_fallback_failure_triggers_retry( + self, + mock_get_email_client, + mock_ses_send, + mock_get_course_dates, + mock_get_course_run, + mock_get_owners, + mock_get_uuid, + ): + """If both SES and Braze fallback fail, task should retry up to MAX_RETRIES.""" + mock_get_uuid.return_value = "some-uuid" + mock_get_owners.return_value = [] + mock_get_course_run.return_value = {} + mock_get_course_dates.return_value = [] + + task = send_course_enrollment_email.apply_async(kwargs=self.task_kwargs) + pytest.raises(Exception, task.get) + self.assertEqual(mock_ses_send.call_count, MAX_RETRIES + 1) + self.assertEqual(mock_get_email_client.call_count, MAX_RETRIES + 1) + + @override_waffle_flag(ENABLE_SES_FOR_COURSE_ENROLLMENT, active=True) + @patch("common.djangoapps.student.tasks.get_course_uuid_for_course") + @patch("common.djangoapps.student.tasks.get_owners_for_course") + @patch("common.djangoapps.student.tasks.get_course_run_details") + @patch("common.djangoapps.student.tasks.get_course_dates_for_email") + @patch("common.djangoapps.student.tasks.render_to_string", return_value="") + @patch("common.djangoapps.student.tasks.EmailMultiAlternatives") + def test_ses_send_uses_correct_template_and_recipient( + self, + mock_email_cls, + mock_render, + mock_get_course_dates, + mock_get_course_run, + mock_get_owners, + mock_get_uuid, + ): + """_send_ses_enrollment_email renders the namespaced template and addresses to user.email.""" + mock_get_uuid.return_value = "some-uuid" + mock_get_owners.return_value = [] + mock_get_course_run.return_value = {} + mock_get_course_dates.return_value = [] + + send_course_enrollment_email.apply_async(kwargs=self.task_kwargs) + + # Template should use the new namespaced path + render_call_args = mock_render.call_args + assert render_call_args[0][0] == "emails/enrollment_en.html" + + # Email should go to the correct user — EmailMultiAlternatives(subject, body, from, to=[...]) + _, email_kwargs = mock_email_cls.call_args + assert self.user.email in email_kwargs.get('to', []) + assert email_kwargs.get('subject') == f"You're enrolled in {self.task_kwargs['course_title']}" + assert email_kwargs.get('body') + assert email_kwargs.get('reply_to') + + @override_waffle_flag(ENABLE_SES_FOR_COURSE_ENROLLMENT, active=True) + @patch("common.djangoapps.student.tasks.get_course_uuid_for_course") + @patch("common.djangoapps.student.tasks.get_owners_for_course") + @patch("common.djangoapps.student.tasks.get_course_run_details") + @patch("common.djangoapps.student.tasks.get_course_dates_for_email") + @patch("common.djangoapps.student.tasks.UserPreference.get_value", return_value="es") + @patch("common.djangoapps.student.tasks.render_to_string", return_value="") + @patch("common.djangoapps.student.tasks.EmailMultiAlternatives") + def test_ses_send_uses_spanish_template_when_user_pref_is_spanish( + self, + mock_email_cls, + mock_render, + _mock_user_pref, + mock_get_course_dates, + mock_get_course_run, + mock_get_owners, + mock_get_uuid, + ): + """SES should render Spanish enrollment template when user language preference is Spanish.""" + mock_get_uuid.return_value = "some-uuid" + mock_get_owners.return_value = [] + mock_get_course_run.return_value = {} + mock_get_course_dates.return_value = [] + + send_course_enrollment_email.apply_async(kwargs=self.task_kwargs) + + render_call_args = mock_render.call_args + assert render_call_args[0][0] == "emails/enrollment_es.html" + + _, email_kwargs = mock_email_cls.call_args + assert self.user.email in email_kwargs.get('to', []) + assert email_kwargs.get('subject') == f"Te has inscrito en {self.task_kwargs['course_title']}" + assert email_kwargs.get('body') + + @override_waffle_flag(ENABLE_SES_FOR_COURSE_ENROLLMENT, active=False) + @patch("common.djangoapps.student.tasks.get_course_uuid_for_course") + @patch("common.djangoapps.student.tasks.get_owners_for_course") + @patch("common.djangoapps.student.tasks.get_course_run_details") + @patch("common.djangoapps.student.tasks.get_course_dates_for_email") + @patch("common.djangoapps.student.tasks.UserPreference.get_value", return_value="es") + @patch("common.djangoapps.student.tasks.get_email_client") + def test_braze_canvas_context_includes_spanish_image_keys_when_user_pref_is_spanish( + self, + mock_get_email_client, + _mock_user_pref, + mock_get_course_dates, + mock_get_course_run, + mock_get_owners, + mock_get_uuid, + ): + """Braze payload should include Spanish image URL keys when user language preference is Spanish.""" + mock_get_uuid.return_value = "some-uuid" + mock_get_owners.return_value = [] + mock_get_course_run.return_value = {} + mock_get_course_dates.return_value = [] + + send_course_enrollment_email.apply_async(kwargs=self.task_kwargs) + + _, send_kwargs = mock_get_email_client.return_value.send_canvas_message.call_args + canvas_props = send_kwargs["canvas_entry_properties"] + assert "timer_icon_es" in canvas_props + assert "arrow_icon_es" in canvas_props + + @override_waffle_flag(ENABLE_SES_FOR_COURSE_ENROLLMENT, active=True) + @patch("common.djangoapps.student.tasks.get_course_uuid_for_course") + @patch("common.djangoapps.student.tasks.get_owners_for_course") + @patch("common.djangoapps.student.tasks.get_course_run_details") + @patch("common.djangoapps.student.tasks.get_course_dates_for_email") + @patch("common.djangoapps.student.tasks.get_email_client") + @patch("common.djangoapps.student.tasks._send_ses_enrollment_email") + def test_braze_not_called_when_ses_succeeds( + self, + mock_ses_send, + mock_get_email_client, + mock_get_course_dates, + mock_get_course_run, + mock_get_owners, + mock_get_uuid, + ): + """Verify Braze is NOT called when SES succeeds (no duplicate sends).""" + mock_get_uuid.return_value = "some-uuid" + mock_get_owners.return_value = [] + mock_get_course_run.return_value = {} + mock_get_course_dates.return_value = [] + + send_course_enrollment_email.apply_async(kwargs=self.task_kwargs) + + mock_ses_send.assert_called_once() + mock_get_email_client.return_value.send_canvas_message.assert_not_called() + + @override_settings(BRAZE_COURSE_ENROLLMENT_CANVAS_ID=None) + @override_waffle_flag(ENABLE_SES_FOR_COURSE_ENROLLMENT, active=False) + @patch("common.djangoapps.student.tasks.get_course_uuid_for_course") + @patch("common.djangoapps.student.tasks.get_owners_for_course") + @patch("common.djangoapps.student.tasks.get_course_run_details") + @patch("common.djangoapps.student.tasks.get_course_dates_for_email") + @patch( + "common.djangoapps.student.tasks._send_braze_enrollment_email", + side_effect=Exception("Braze fallback failure"), + ) + def test_braze_path_retries_when_canvas_id_missing( + self, + _mock_braze_send, + mock_get_course_dates, + mock_get_course_run, + mock_get_owners, + mock_get_uuid, + ): + """Braze path should retry when BRAZE_COURSE_ENROLLMENT_CANVAS_ID is missing.""" + mock_get_uuid.return_value = "some-uuid" + mock_get_owners.return_value = [] + mock_get_course_run.return_value = {} + mock_get_course_dates.return_value = [] + + task = send_course_enrollment_email.apply_async(kwargs=self.task_kwargs) + pytest.raises(Exception, task.get) + + @override_settings(BRAZE_COURSE_ENROLLMENT_CANVAS_ID=None) + @override_waffle_flag(ENABLE_SES_FOR_COURSE_ENROLLMENT, active=True) + @patch("common.djangoapps.student.tasks.get_course_uuid_for_course") + @patch("common.djangoapps.student.tasks.get_owners_for_course") + @patch("common.djangoapps.student.tasks.get_course_run_details") + @patch("common.djangoapps.student.tasks.get_course_dates_for_email") + @patch( + "common.djangoapps.student.tasks._send_braze_enrollment_email", + side_effect=Exception("Braze fallback failure"), + ) + @patch( + "common.djangoapps.student.tasks._send_ses_enrollment_email", + side_effect=Exception("SES failure"), + ) + def test_ses_fallback_retries_when_canvas_id_missing( + self, + _mock_ses_send, + _mock_braze_send, + mock_get_course_dates, + mock_get_course_run, + mock_get_owners, + mock_get_uuid, + ): + """SES path should retry when SES fails and Braze fallback is misconfigured.""" + mock_get_uuid.return_value = "some-uuid" + mock_get_owners.return_value = [] + mock_get_course_run.return_value = {} + mock_get_course_dates.return_value = [] + + task = send_course_enrollment_email.apply_async(kwargs=self.task_kwargs) + pytest.raises(Exception, task.get) diff --git a/lms/envs/common.py b/lms/envs/common.py index cc2a79121806..64fd881e8b07 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -1217,6 +1217,13 @@ PRESS_EMAIL = 'press@example.com' FINANCE_EMAIL = '' +# .. setting_name: BRAZE_COURSE_ENROLLMENT_CANVAS_ID +# .. setting_default: None +# .. setting_description: Canvas ID for course enrollment email in Braze. This is used when sending +# course enrollment emails via Braze canvas API. Set to your Braze canvas ID when using Braze for +# enrollment emails. Can be None if only using SES (when enable_ses_for_course_enrollment is True). +# BRAZE_COURSE_ENROLLMENT_CANVAS_ID = None #uncomment only for local testing + # Platform mailing address CONTACT_MAILING_ADDRESS = 'SET-ME-PLEASE' diff --git a/lms/envs/devstack.py b/lms/envs/devstack.py index eb18b184a947..f4ff3201044d 100644 --- a/lms/envs/devstack.py +++ b/lms/envs/devstack.py @@ -76,6 +76,29 @@ EMAIL_BACKEND = 'django.core.mail.backends.filebased.EmailBackend' EMAIL_FILE_PATH = '/edx/src/ace_messages/' +# ========== SES ENROLLMENT EMAIL CONFIGURATION (FOR TESTING) ========== +# Uncomment the following line to test SES enrollment email theme resolution +# When enabled, templates will load from edx-themes first (edx.org-next) +# Then fall back to platform templates if theme templates not found +# +# In production: +# - ENABLE_COMPREHENSIVE_THEMING = True +# - DEFAULT_SITE_THEME = 'edx.org' +# - SES_ENROLLMENT_EMAIL_THEME will be set via edx-internal config +# +# In devstack (with this uncommented): +# - Templates will try to load from edx.org-next theme +# - If theme templates exist in edx-themes, will use them +# - Otherwise falls back to platform templates (safe default) +# +# To test the complete flow: +# 1. Uncomment the line below +# 2. Run: python manage.py lms shell -c "from django.template import engines; ..." +# 3. Should show theme-based template resolution +# 4. Comment out when done testing +# +# SES_ENROLLMENT_EMAIL_THEME = 'edx.org-next' # Uncomment only for local testing + ############################ PYFS XBLOCKS SERVICE ############################# # Set configuration for Django pyfilesystem @@ -494,13 +517,15 @@ def should_show_debug_toolbar(request): # lint-amnesty, pylint: disable=missing # in it's path. Re-calling derive_settings doesn't work because the settings was already # changed from a function to a list, and it can't be derived again. -# from openedx.envs.common import make_mako_template_dirs +# ========== COMPREHENSIVE THEMING ENABLED FOR SES ENROLLMENT EMAIL TESTING ========== +# Uncomment this section to test theme-first template resolution in devstack +# This matches production behavior where ENABLE_COMPREHENSIVE_THEMING = True # ENABLE_COMPREHENSIVE_THEMING = True +# DEFAULT_SITE_THEME = 'edx.org' # Base theme for resolution # COMPREHENSIVE_THEME_DIRS = [ -# "/edx/app/edxapp/edx-platform/themes/" +# "/edx/var/edx-themes/edx-themes/edx-platform/" # ] -# TEMPLATES[1]["DIRS"] = make_mako_template_dirs -# derive_settings(__name__) +# ============================================================================ # Uncomment the lines below if you'd like to see SQL statements in your devstack LMS log. # LOGGING['handlers']['console']['level'] = 'DEBUG' diff --git a/lms/static/images/enrollment_email/arrow_icon_es.png b/lms/static/images/enrollment_email/arrow_icon_es.png new file mode 100644 index 000000000000..2285352a394e Binary files /dev/null and b/lms/static/images/enrollment_email/arrow_icon_es.png differ diff --git a/lms/static/images/enrollment_email/banner_default.png b/lms/static/images/enrollment_email/banner_default.png new file mode 100644 index 000000000000..0535b905ceb4 Binary files /dev/null and b/lms/static/images/enrollment_email/banner_default.png differ diff --git a/lms/static/images/enrollment_email/calendar_icon_es.png b/lms/static/images/enrollment_email/calendar_icon_es.png new file mode 100644 index 000000000000..730ae971efbc Binary files /dev/null and b/lms/static/images/enrollment_email/calendar_icon_es.png differ diff --git a/lms/static/images/enrollment_email/community_icon_es.png b/lms/static/images/enrollment_email/community_icon_es.png new file mode 100644 index 000000000000..66006cfd5d93 Binary files /dev/null and b/lms/static/images/enrollment_email/community_icon_es.png differ diff --git a/lms/static/images/enrollment_email/community_illustration_en.png b/lms/static/images/enrollment_email/community_illustration_en.png new file mode 100644 index 000000000000..55140a8ef241 Binary files /dev/null and b/lms/static/images/enrollment_email/community_illustration_en.png differ diff --git a/lms/static/images/enrollment_email/dollar_icon_en.png b/lms/static/images/enrollment_email/dollar_icon_en.png new file mode 100644 index 000000000000..ebf7aa560829 Binary files /dev/null and b/lms/static/images/enrollment_email/dollar_icon_en.png differ diff --git a/lms/static/images/enrollment_email/dollar_icon_es.png b/lms/static/images/enrollment_email/dollar_icon_es.png new file mode 100644 index 000000000000..e406434837f2 Binary files /dev/null and b/lms/static/images/enrollment_email/dollar_icon_es.png differ diff --git a/lms/static/images/enrollment_email/flag_icon_black_en_es.png b/lms/static/images/enrollment_email/flag_icon_black_en_es.png new file mode 100644 index 000000000000..b5fc97ee32bc Binary files /dev/null and b/lms/static/images/enrollment_email/flag_icon_black_en_es.png differ diff --git a/lms/static/images/enrollment_email/flag_icon_grey_es.png b/lms/static/images/enrollment_email/flag_icon_grey_es.png new file mode 100644 index 000000000000..9eff18dfc779 Binary files /dev/null and b/lms/static/images/enrollment_email/flag_icon_grey_es.png differ diff --git a/lms/static/images/enrollment_email/flag_icon_orange_en.png b/lms/static/images/enrollment_email/flag_icon_orange_en.png new file mode 100644 index 000000000000..574e11d8f6cc Binary files /dev/null and b/lms/static/images/enrollment_email/flag_icon_orange_en.png differ diff --git a/lms/static/images/enrollment_email/flag_icon_pink_en.png b/lms/static/images/enrollment_email/flag_icon_pink_en.png new file mode 100644 index 000000000000..86b46ef98021 Binary files /dev/null and b/lms/static/images/enrollment_email/flag_icon_pink_en.png differ diff --git a/lms/static/images/enrollment_email/flag_icon_white_es.png b/lms/static/images/enrollment_email/flag_icon_white_es.png new file mode 100644 index 000000000000..9bc8536f4c56 Binary files /dev/null and b/lms/static/images/enrollment_email/flag_icon_white_es.png differ diff --git a/lms/static/images/enrollment_email/goal_idea_icon_en.png b/lms/static/images/enrollment_email/goal_idea_icon_en.png new file mode 100644 index 000000000000..0d9c55ad7031 Binary files /dev/null and b/lms/static/images/enrollment_email/goal_idea_icon_en.png differ diff --git a/lms/static/images/enrollment_email/logo_url.png b/lms/static/images/enrollment_email/logo_url.png new file mode 100644 index 000000000000..419f27df2c3e Binary files /dev/null and b/lms/static/images/enrollment_email/logo_url.png differ diff --git a/lms/static/images/enrollment_email/person_icon_en.png b/lms/static/images/enrollment_email/person_icon_en.png new file mode 100644 index 000000000000..a05d7bcfd447 Binary files /dev/null and b/lms/static/images/enrollment_email/person_icon_en.png differ diff --git a/lms/static/images/enrollment_email/person_icon_es.png b/lms/static/images/enrollment_email/person_icon_es.png new file mode 100644 index 000000000000..1f79fee7028f Binary files /dev/null and b/lms/static/images/enrollment_email/person_icon_es.png differ diff --git a/lms/static/images/enrollment_email/slant_line_es.png b/lms/static/images/enrollment_email/slant_line_es.png new file mode 100644 index 000000000000..ce4e037273e3 Binary files /dev/null and b/lms/static/images/enrollment_email/slant_line_es.png differ diff --git a/lms/static/images/enrollment_email/timer_icon_en.png b/lms/static/images/enrollment_email/timer_icon_en.png new file mode 100644 index 000000000000..ca3b690b2d61 Binary files /dev/null and b/lms/static/images/enrollment_email/timer_icon_en.png differ diff --git a/lms/static/images/enrollment_email/timer_icon_es.png b/lms/static/images/enrollment_email/timer_icon_es.png new file mode 100644 index 000000000000..55f4b54ce789 Binary files /dev/null and b/lms/static/images/enrollment_email/timer_icon_es.png differ diff --git a/lms/static/images/enrollment_email/vertical_line_black_en.png b/lms/static/images/enrollment_email/vertical_line_black_en.png new file mode 100644 index 000000000000..191d1bf257ba Binary files /dev/null and b/lms/static/images/enrollment_email/vertical_line_black_en.png differ diff --git a/lms/static/images/enrollment_email/vertical_line_orange_en.png b/lms/static/images/enrollment_email/vertical_line_orange_en.png new file mode 100644 index 000000000000..459f1fe1606d Binary files /dev/null and b/lms/static/images/enrollment_email/vertical_line_orange_en.png differ diff --git a/lms/static/images/enrollment_email/vertical_line_white_en.png b/lms/static/images/enrollment_email/vertical_line_white_en.png new file mode 100644 index 000000000000..96f66d5ba919 Binary files /dev/null and b/lms/static/images/enrollment_email/vertical_line_white_en.png differ diff --git a/lms/static/images/enrollment_email/you_are_enrolled_en.png b/lms/static/images/enrollment_email/you_are_enrolled_en.png new file mode 100644 index 000000000000..e7282f071085 Binary files /dev/null and b/lms/static/images/enrollment_email/you_are_enrolled_en.png differ diff --git a/openedx/envs/common.py b/openedx/envs/common.py index ada6fd3f0be2..d5b30dd510d0 100644 --- a/openedx/envs/common.py +++ b/openedx/envs/common.py @@ -1991,6 +1991,16 @@ def _make_locale_paths(settings): # has to be enabled. DEFAULT_SITE_THEME = None +# .. setting_name: SES_ENROLLMENT_EMAIL_THEME +# .. setting_default: None +# .. setting_description: Optional theme override for SES course enrollment emails. +# When set, enrollment emails rendered via SES use this theme instead of DEFAULT_SITE_THEME. +# Allows per-email-type theme customization without affecting other emails (e.g., "edx.org-next"). +# Only effective if ENABLE_COMPREHENSIVE_THEMING is True. +# .. setting_warning: Must point to a valid theme in COMPREHENSIVE_THEME_DIRS or edx-platform/themes. +# If the theme does not exist or template is not found, falls back to DEFAULT_SITE_THEME resolution. +SES_ENROLLMENT_EMAIL_THEME = None + # .. toggle_name: ENABLE_COMPREHENSIVE_THEMING # .. toggle_implementation: DjangoSetting # .. toggle_default: False