From ee6f3164c3eade6f2cfca7bfb137bbc0c8be61cb Mon Sep 17 00:00:00 2001 From: Shahbaz Shabbir <32649010+shahbaz-arbisoft@users.noreply.github.com> Date: Fri, 18 Nov 2022 17:25:52 +0500 Subject: [PATCH] feat: add course enrollment email task (#31241) --- cms/envs/common.py | 7 + common/djangoapps/student/helpers.py | 9 + common/djangoapps/student/models.py | 8 + common/djangoapps/student/tasks.py | 126 ++++++++ common/djangoapps/student/tests/test_tasks.py | 270 ++++++++++++++++++ common/djangoapps/student/toggles.py | 18 +- lms/envs/common.py | 4 + 7 files changed, 433 insertions(+), 9 deletions(-) create mode 100644 common/djangoapps/student/tasks.py create mode 100644 common/djangoapps/student/tests/test_tasks.py diff --git a/cms/envs/common.py b/cms/envs/common.py index 54c16cf930..10aaa3d096 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -2695,3 +2695,10 @@ INACTIVE_USER_LOGIN = True # Redirect URL for inactive user. If not set, user will be redirected to /login after the login itself (loop) INACTIVE_USER_URL = f'http://{CMS_BASE}' + +######################## BRAZE API SETTINGS ######################## + +EDX_BRAZE_API_KEY = None +EDX_BRAZE_API_SERVER = None + +BRAZE_COURSE_ENROLLMENT_CANVAS_ID = '' diff --git a/common/djangoapps/student/helpers.py b/common/djangoapps/student/helpers.py index f0f62292f1..35c6c1a7bb 100644 --- a/common/djangoapps/student/helpers.py +++ b/common/djangoapps/student/helpers.py @@ -857,6 +857,14 @@ def _prepare_date_block(block, block_date, user_timezone): return block +def _remove_date_key_from_course_dates(course_data): + """ + Remove date key from course dates list + """ + _ = course_data.pop('date') + return course_data + + def get_course_dates_for_email(user, course_id, request): """ Getting nearest dates from today one would be before today and one @@ -904,4 +912,5 @@ def get_course_dates_for_email(user, course_id, request): if course_date_list[2]['date'] == '': course_date_list[2].update(_prepare_date_block(block, block_date, user_timezone)) + course_date_list = list(map(_remove_date_key_from_course_dates, course_date_list)) return course_date_list diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index 673e170072..c164f6338e 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -1557,6 +1557,8 @@ class CourseEnrollment(models.Model): Emits an event to explicitly track course enrollment and unenrollment. """ from openedx.core.djangoapps.schedules.config import set_up_external_updates_for_enrollment + from common.djangoapps.student.toggles import should_send_enrollment_email + from common.djangoapps.student.tasks import send_course_enrollment_email segment_properties = { 'category': 'conversion', @@ -1595,6 +1597,12 @@ class CourseEnrollment(models.Model): segment_traits['email'] = self.user.email if event_name == EVENT_NAME_ENROLLMENT_ACTIVATED: + if should_send_enrollment_email(): + course_pacing_type = 'self-paced' if self.course_overview.self_paced else 'instructor-paced' + send_course_enrollment_email.apply_async((self.user.id, str(self.course_id), + self.course_overview.display_name, + self.course_overview.short_description, + course_pacing_type)) segment_properties['email'] = self.user.email # This next property is for an experiment, see method's comments for more information segment_properties['external_course_updates'] = set_up_external_updates_for_enrollment(self.user, diff --git a/common/djangoapps/student/tasks.py b/common/djangoapps/student/tasks.py new file mode 100644 index 0000000000..e5c9a856f5 --- /dev/null +++ b/common/djangoapps/student/tasks.py @@ -0,0 +1,126 @@ +""" +Celery task for course enrollment email +""" +import logging +from celery import shared_task +from django.conf import settings +from django.contrib.auth import get_user_model +from opaque_keys.edx.keys import CourseKey + +from common.djangoapps.course_modes.models import CourseMode +from common.djangoapps.student.helpers import ( + get_course_dates_for_email, + get_instructors, +) +from lms.djangoapps.utils import get_braze_client +from openedx.core.djangoapps.catalog.utils import ( + get_course_uuid_for_course, + get_owners_for_course, + get_course_run_details, +) +from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers +from openedx.features.course_experience import ENABLE_COURSE_GOALS + +User = get_user_model() +log = logging.getLogger(__name__) + +MAX_RETRIES = 1 +COUNTDOWN = 60 + + +@shared_task(bind=True, ignore_result=True) +def send_course_enrollment_email( + self, user_id, course_id, course_title, short_description, pacing_type +): + """ + Send course enrollment email using Braze API. + + Email is configured as Braze canvas message. We get the canvas properties for the + email from course discovery service. + In case the course run call to discovery fails, we use the course details sent + to the celery task in our email. + """ + course_date_blocks, course_key = [], None + course_run_fields = [ + "key", + "title", + "short_description", + "marketing_url", + "pacing_type", + "min_effort", + "max_effort", + "weeks_to_complete", + "enrollment_count", + "image", + "staff", + ] + canvas_entry_properties = { + "course_title": course_title, + "short_description": short_description, + "pacing_type": pacing_type, + "course_run_key": course_id, + "course_price": CourseMode.min_course_price_for_currency( + course_id=course_id, currency="USD" + ), + "lms_base_url": configuration_helpers.get_value( + "LMS_ROOT_URL", settings.LMS_ROOT_URL + ), + "learning_base_url": configuration_helpers.get_value( + "LEARNING_MICROFRONTEND_URL", settings.LEARNING_MICROFRONTEND_URL + ), + } + + try: + user = User.objects.get(id=user_id) + course_key = CourseKey.from_string(course_id) + course_date_blocks = get_course_dates_for_email(user, course_key, request=None) + except Exception: # pylint: disable=broad-except + pass + + canvas_entry_properties.update( + { + "course_date_blocks": course_date_blocks, + "goals_enabled": ENABLE_COURSE_GOALS.is_enabled(course_key), + } + ) + + try: + course_uuid = get_course_uuid_for_course(course_id) + owners = get_owners_for_course(course_uuid=course_uuid) or [{}] + course_run = get_course_run_details(course_id, course_run_fields) + + 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") or "", + } + ) + except Exception: # pylint: disable=broad-except + log.exception( + f"Unable to get data for user {user_id} from discovery for course {course_id}" + ) + + try: + recipients = [{"external_user_id": user_id}] + braze_client = get_braze_client() + if braze_client: + braze_client.send_canvas_message( + canvas_id=settings.BRAZE_COURSE_ENROLLMENT_CANVAS_ID, + recipients=recipients, + canvas_entry_properties=canvas_entry_properties, + ) + except Exception as exc: # pylint: disable=broad-except + log.exception(f"Unable to send email due to exception: {exc}") + raise self.retry(exc=exc, countdown=COUNTDOWN, max_retries=MAX_RETRIES) diff --git a/common/djangoapps/student/tests/test_tasks.py b/common/djangoapps/student/tests/test_tasks.py new file mode 100644 index 0000000000..96525ea340 --- /dev/null +++ b/common/djangoapps/student/tests/test_tasks.py @@ -0,0 +1,270 @@ +""" +Celery task tests +""" +from django.conf import settings +from django.test.utils import override_settings +from unittest.mock import patch, Mock + +from common.djangoapps.student.tasks import send_course_enrollment_email +from common.djangoapps.student.tests.factories import UserFactory +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory + +BRAZE_COURSE_ENROLLMENT_CANVAS_ID = "braze-canvas-id" + + +@override_settings( + BRAZE_COURSE_ENROLLMENT_CANVAS_ID=BRAZE_COURSE_ENROLLMENT_CANVAS_ID, + LEARNING_MICROFRONTEND_URL="https://learningmfe.openedx.org", +) +class TestCourseEnrollmentEmailTask(ModuleStoreTestCase): + """ + Tests for send_course_enrollment_email task. + """ + + def setUp(self): + """ + Set up tests + """ + super().setUp() + self.user = UserFactory.create( + username="joe", email="joe@joe.com", password="password" + ) + self.course = CourseFactory.create() + self.course_uuid = "d08af18e-7fd5-45eb-a834-a9decc6d9afa" + self.send_course_enrollment_email_kwargs = { + "user_id": self.user.id, + "course_id": str(self.course.id), + "course_title": "Test course", + "short_description": "Short description of course", + "pacing_type": "self-paced", + } + + @staticmethod + def _get_course_run(): + """ + Helper method for course run details. + """ + return { + "title": "Test Course", + "short_description": "An introduction to computer science.", + "weeks_to_complete": 8, + "min_effort": 5, + "max_effort": 10, + "pacing_type": "self-paced", + "image": { + "src": "https://prod/media/course/image/a3d1899c3344.png", + }, + "staff": [ + { + "given_name": "Mario", + "family_name": "Ricci", + "slug": "mario-ricci", + "position": { + "organization_name": "University of Adelaide", + }, + "profile_image_url": "https://prod.org/media/people/profile_images/0ad.jpg", + }, + ], + "learners_count": "12345", + } + + @staticmethod + def _get_course_owners(): + """ + Helper method for course owner details. + """ + return [ + { + "logo_image_url": "https://prod/organization/logos/2cc39992c67a.png", + } + ] + + @staticmethod + def _get_course_dates(): + """ + Helper method for course dates. + """ + return [ + { + "due_date": "Thu, Jul 28, 2022", + "title": "Course starts", + "assignment_type": "", + "link": "", + "assignment_count": 0, + "due_time": "", + }, + { + "due_date": "Thu, Aug 25, 2022", + "title": "", + "assignment_type": "", + "link": "", + "assignment_count": 0, + "due_time": "", + }, + { + "due_date": "Mon, Aug 29, 2022", + "title": "Importance of an Operations Mindset", + "assignment_type": "Ops Challenge", + "link": "https://courses.edx.org/courses/course-v1:BabsonX+EPS03x+3T2018", + "assignment_count": 5, + "due_time": "2:25 AM GMT+5", + }, + ] + + def _get_canvas_properties( + self, add_course_run_details=True, add_course_dates=True + ): + """ + Helper method that returns canvas entry properties. + """ + canvas_properties = { + "course_run_key": str(self.course.id), + "learning_base_url": "https://learningmfe.openedx.org", + "lms_base_url": settings.LMS_ROOT_URL, + "course_price": 0, + "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"], + } + + if add_course_dates: + canvas_properties.update({"course_date_blocks": self._get_course_dates()}) + + if add_course_run_details: + course_run = self._get_course_run() + canvas_properties.update( + { + "instructors": [ + { + "name": "Mario Ricci", + "profile_image_url": "https://prod.org/media/people/profile_images/0ad.jpg", + "organization_name": "University of Adelaide", + "bio_url": "None/bio/mario-ricci", + } + ], + "instructors_count": "odd", + "min_effort": course_run["min_effort"], + "max_effort": course_run["max_effort"], + "weeks_to_complete": course_run["weeks_to_complete"], + "learners_count": "", + "banner_image_url": course_run["image"]["src"], + "course_title": course_run["title"], + "short_description": course_run["short_description"], + "pacing_type": course_run["pacing_type"], + "partner_image_url": self._get_course_owners()[0]["logo_image_url"], + } + ) + + return canvas_properties + + @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_braze_client") + def test_success_calls_for_canvas_properties( + self, + mock_get_braze_client, + mock_get_course_dates_for_email, + mock_get_course_run_details, + mock_get_owners_for_course, + mock_get_course_uuid_for_course, + ): + """ + Test to verify the "canvas entry properties" for enrollment email when + all external calls are successful. + """ + mock_get_course_uuid_for_course.return_value = self.course_uuid + mock_get_owners_for_course.return_value = self._get_course_owners() + mock_get_course_run_details.return_value = self._get_course_run() + mock_get_course_dates_for_email.return_value = self._get_course_dates() + + send_course_enrollment_email.apply_async( + kwargs=self.send_course_enrollment_email_kwargs + ) + mock_get_braze_client.return_value.send_canvas_message.assert_called_with( + canvas_id=BRAZE_COURSE_ENROLLMENT_CANVAS_ID, + recipients=[ + { + "external_user_id": self.user.id, + } + ], + canvas_entry_properties=self._get_canvas_properties(), + ) + + @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_braze_client") + @patch( + "common.djangoapps.student.tasks.get_course_dates_for_email", + Mock(side_effect=Exception), + ) + def test_canvas_properties_without_course_dates( + self, + mock_get_braze_client, + mock_get_course_run_details, + mock_get_owners_for_course, + mock_get_course_uuid_for_course, + ): + """ + Test that if exception is raised for the course dates call, correct + canvas properties are sent to Braze. + """ + mock_get_course_uuid_for_course.return_value = self.course_uuid + mock_get_owners_for_course.return_value = self._get_course_owners() + mock_get_course_run_details.return_value = self._get_course_run() + + send_course_enrollment_email.apply_async( + kwargs=self.send_course_enrollment_email_kwargs + ) + mock_get_braze_client.return_value.send_canvas_message.assert_called_with( + canvas_id=BRAZE_COURSE_ENROLLMENT_CANVAS_ID, + recipients=[ + { + "external_user_id": self.user.id, + } + ], + canvas_entry_properties=self._get_canvas_properties(add_course_dates=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_dates_for_email") + @patch("common.djangoapps.student.tasks.get_braze_client") + @patch( + "common.djangoapps.student.tasks.get_course_run_details", + Mock(side_effect=Exception), + ) + def test_canvas_properties_without_discovery_call( + self, + mock_get_braze_client, + mock_get_course_dates_for_email, + mock_get_owners_for_course, + mock_get_course_uuid_for_course, + ): + """ + Test to verify the "canvas entry properties" for enrollment email when + course run call is failed. + """ + mock_get_course_uuid_for_course.return_value = self.course_uuid + mock_get_owners_for_course.return_value = self._get_course_owners() + mock_get_course_dates_for_email.return_value = self._get_course_dates() + + send_course_enrollment_email.apply_async( + kwargs=self.send_course_enrollment_email_kwargs + ) + mock_get_braze_client.return_value.send_canvas_message.assert_called_with( + canvas_id=BRAZE_COURSE_ENROLLMENT_CANVAS_ID, + recipients=[ + { + "external_user_id": self.user.id, + } + ], + canvas_entry_properties=self._get_canvas_properties( + add_course_run_details=False + ), + ) diff --git a/common/djangoapps/student/toggles.py b/common/djangoapps/student/toggles.py index e28a27395f..5e0668cb34 100644 --- a/common/djangoapps/student/toggles.py +++ b/common/djangoapps/student/toggles.py @@ -40,20 +40,20 @@ def should_show_2u_recommendations(): return ENABLE_2U_RECOMMENDATIONS_ON_DASHBOARD.is_enabled() -# Waffle flag to enable redesigned course enrollment confirmation email. -# .. toggle_name: student.enable_redesign_enrollment_confirmation_email +# Waffle flag to enable course enrollment confirmation email. +# .. toggle_name: student.enable_enrollment_confirmation_email # .. toggle_implementation: WaffleFlag # .. toggle_default: False -# .. toggle_description: Enable redesign email template only for staff users for testing. -# .. toggle_use_cases: temporary +# .. toggle_description: Enable course enrollment email template +# .. toggle_use_cases: opt_in # .. toggle_creation_date: 2022-08-05 # .. toggle_target_removal_date: None # .. toggle_warning: None -# .. toggle_tickets: VAN-1064 -ENROLLMENT_CONFIRMATION_EMAIL_REDESIGN = WaffleFlag( - f'{WAFFLE_FLAG_NAMESPACE}.enable_redesign_enrollment_confirmation_email', __name__ +# .. toggle_tickets: VAN-1129 +ENROLLMENT_CONFIRMATION_EMAIL = WaffleFlag( + f'{WAFFLE_FLAG_NAMESPACE}.enable_enrollment_confirmation_email', __name__ ) -def should_send_redesign_email(): - return ENROLLMENT_CONFIRMATION_EMAIL_REDESIGN.is_enabled() +def should_send_enrollment_email(): + return ENROLLMENT_CONFIRMATION_EMAIL.is_enabled() diff --git a/lms/envs/common.py b/lms/envs/common.py index 0d48109753..44ff22133b 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -4740,8 +4740,12 @@ PASSWORD_RESET_EMAIL_RATE = '2/h' SAVE_FOR_LATER_IP_RATE_LIMIT = '100/d' SAVE_FOR_LATER_EMAIL_RATE_LIMIT = '5/h' + +#### BRAZE API SETTINGS #### + EDX_BRAZE_API_KEY = None EDX_BRAZE_API_SERVER = None +BRAZE_COURSE_ENROLLMENT_CANVAS_ID = '' ### SETTINGS FOR AMPLITUDE #### AMPLITUDE_URL = ''