feat: add course enrollment email task (#31241)

This commit is contained in:
Shahbaz Shabbir
2022-11-18 17:25:52 +05:00
committed by GitHub
parent de0b132f10
commit ee6f3164c3
7 changed files with 433 additions and 9 deletions

View File

@@ -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 = ''

View File

@@ -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

View File

@@ -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,

View File

@@ -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)

View File

@@ -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
),
)

View File

@@ -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()

View File

@@ -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 = ''