diff --git a/lms/djangoapps/program_enrollments/management/commands/send_program_course_nudge_email.py b/lms/djangoapps/program_enrollments/management/commands/send_program_course_nudge_email.py new file mode 100644 index 0000000000..28e5eefe73 --- /dev/null +++ b/lms/djangoapps/program_enrollments/management/commands/send_program_course_nudge_email.py @@ -0,0 +1,195 @@ +""" +Django management command for sending nudge emails to learners after they complete once course in a program, to suggest +to complete possible next course from same program. +""" + +import logging +from collections import defaultdict +from datetime import timedelta +from operator import itemgetter +from urllib.parse import urljoin + +from django.conf import settings +from django.contrib.auth import get_user_model +from django.contrib.sites.models import Site +from django.core.management import BaseCommand +from django.utils import timezone + +from common.djangoapps.track import segment +from lms.djangoapps.grades.models import PersistentCourseGrade +from openedx.core.constants import COURSE_PUBLISHED +from openedx.core.djangoapps.catalog.utils import get_programs +from openedx.core.djangoapps.programs.utils import ProgramProgressMeter + +User = get_user_model() + +LOGGER = logging.getLogger(__name__) + + +class Command(BaseCommand): + """ + Django management command for sending nudge emails to learners + + This command sends nudge emails to learners after they complete once course in a program, to suggest to complete + possible next course from same program. + + Example usage: + $ ./manage.py lms send_program_course_nudge_email + $ ./manage.py lms send_program_course_nudge_email --no-commit + """ + + def get_passed_course_to_users_maps(self): + """ + Returns mapping between course passed yesterday with passing users. + """ + passed_course_to_users_maps = defaultdict(list) + yesterday = timezone.now().date() - timedelta(days=1) + passed_grades = PersistentCourseGrade.objects.filter( + passed_timestamp__date=yesterday, + ) + + passing_user_ids = list(passed_grades.values_list('user_id', flat=True).distinct()) + passing_users = User.objects.filter(id__in=passing_user_ids) + user_id_to_user_map = {user.id: user for user in passing_users} + + for passing_grade in passed_grades: + user = user_id_to_user_map[passing_grade.user_id] + passed_course_to_users_maps[str(passing_grade.course_id)].append(user) + + LOGGER.info( + '[Program Course Nudge Email] Found [%s] passing grades on [%s] date with [%s] distinct users ' + 'and [%s] distinct courses', + passed_grades.count(), + yesterday, + len(passing_user_ids), + len(passed_course_to_users_maps.keys()), + ) + + return passed_course_to_users_maps + + def valid_course_run(self, course_run): + """ + Check if a course run is in enrollable state. + """ + return course_run['is_enrollable'] \ + and course_run['is_marketable'] \ + and course_run['marketing_url'] \ + and course_run['status'] == COURSE_PUBLISHED + + def get_course_run_to_suggest(self, programs_progress, completed_course_id): + """ + Finds out enrollable course run from programs Generated by ProgramProgressMeter. + + Returns: Suggested program and course_run dicts + """ + for program in programs_progress: + for not_started_courses in program['not_started']: + for course_run in not_started_courses['course_runs']: + if self.valid_course_run(course_run) and course_run['key'] != completed_course_id: + return program, course_run + return None, None + + def sort_programs(self, programs): + """ + Sorts programs based on their revenue ranking. + """ + sort_revenue_order = { + 'MicroMasters': 1, + 'Professional Program': 2, + 'Professional Certificate': 3, + 'XSeries': 4, + 'Masters': 5, + 'MicroBachelors': 6, + } + for program in programs: + program['sort_revenue_order'] = sort_revenue_order.get(program['type'], 7) + + return sorted(programs, key=itemgetter('sort_revenue_order')) + + def get_course_run(self, program, course_run_id): + """ + get course run from a program. + """ + for course in program['courses']: + for course_run in course['course_runs']: + if course_run['key'] == course_run_id: + return course_run + + def get_program(self, programs, program_progress): + """ + get detailed program. + """ + for program in programs: + if program['uuid'] == program_progress['uuid']: + return program + + def emit_event(self, user, program, suggested_course_run, completed_course_run): + """ + Emit the Segment event which will be used by Braze to send the email + """ + event_properties = { + 'COURSE_ONE_NAME': completed_course_run['title'], + 'PROGRAM_TYPE': program['type'], + 'PROGRAM_TITLE': program['title'], + 'COURSE_TWO_NAME': suggested_course_run['title'], + 'COURSE_TWO_SHORT_DESCRIPTION': suggested_course_run['short_description'], + 'COURSE_TWO_LINK': urljoin(settings.MKTG_URLS.get('ROOT'), suggested_course_run['marketing_url']), + } + segment.track(user.id, 'edx.bi.program.course-enrollment.nudge', event_properties) + + LOGGER.info( + '[Program Course Nudge Email] Segment event fired to suggested. ' + 'Completed Course: [%s], Program: [%s], Suggested Course: [%s], User: [%s].', + completed_course_run['key'], + program['uuid'], + suggested_course_run['key'], + user.username, + ) + + def add_arguments(self, parser): + """ + Entry point to add arguments. + """ + parser.add_argument( + '--no-commit', + action='store_true', + dest='no_commit', + default=False, + help='Dry Run, print log messages without committing anything.', + ) + + def handle(self, *args, **options): + """ + Command's entry point. + """ + should_commit = not options['no_commit'] + + email_sent_records = [] + site = Site.objects.get_current() + course_to_users_maps = self.get_passed_course_to_users_maps() + + for completed_course_id, users in course_to_users_maps.items(): + course_linked_programs = get_programs(course=completed_course_id) + course_linked_programs = self.sort_programs(course_linked_programs) + if course_linked_programs: + for user in users: + meter = ProgramProgressMeter(site=site, user=user, include_course_entitlements=False) + programs_progress = meter.progress(programs=course_linked_programs, count_only=False) + suggested_program_progress, suggested_course_run = self.get_course_run_to_suggest( + programs_progress, completed_course_id + ) + if suggested_course_run: + suggested_program = self.get_program(course_linked_programs, suggested_program_progress) + completed_course_run = self.get_course_run(suggested_program, completed_course_id) + if should_commit: + self.emit_event(user, suggested_program, suggested_course_run, completed_course_run) + email_sent_records.append( + f'User: {user.username}, Completed Course: {completed_course_id}, ' + f'Suggested Course: {suggested_course_run["key"]}' + ) + + LOGGER.info( + '[Program Course Nudge Email] %s Emails sent. Records: %s', + len(email_sent_records), + email_sent_records, + ) diff --git a/lms/djangoapps/program_enrollments/management/commands/tests/test_send_program_course_nudge_email.py b/lms/djangoapps/program_enrollments/management/commands/tests/test_send_program_course_nudge_email.py new file mode 100644 index 0000000000..9b04c04170 --- /dev/null +++ b/lms/djangoapps/program_enrollments/management/commands/tests/test_send_program_course_nudge_email.py @@ -0,0 +1,102 @@ +""" +Tests for the send_program_course_nudge_email management command. +""" +from datetime import timedelta +from unittest.mock import patch + +import ddt +from django.core.management import call_command +from django.test import TestCase +from django.utils import timezone +from testfixtures import LogCapture + +from common.djangoapps.student.tests.factories import UserFactory +from lms.djangoapps.grades.models import PersistentCourseGrade +from openedx.core.djangoapps.catalog.tests.factories import CourseFactory, CourseRunFactory, ProgramFactory + +LOG_PATH = 'lms.djangoapps.program_enrollments.management.commands.send_program_course_nudge_email' + + +@ddt.ddt +class TestSendProgramCourseNudgeEmailCommand(TestCase): + """ + Test send_program_course_nudge_email command. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.command = 'send_program_course_nudge_email' + + def setUp(self): + super().setUp() + self.user_1 = UserFactory() + self.user_2 = UserFactory() + + self.enrolled_course_run = CourseRunFactory() + self.course_run_1 = CourseRunFactory() + self.course_run_2 = CourseRunFactory() + self.enrolled_course = CourseFactory(course_runs=[self.enrolled_course_run]) + self.unenrolled_course_1 = CourseFactory(course_runs=[self.course_run_1]) + self.unenrolled_course_2 = CourseFactory(course_runs=[self.course_run_2]) + + self.enrolled_program_1 = ProgramFactory( + courses=[self.enrolled_course, self.unenrolled_course_1], + type='MicroBachelors' + ) + self.enrolled_program_2 = ProgramFactory( + courses=[self.enrolled_course, self.unenrolled_course_2], + type='MicroMasters' + ) + self.unenrolled_program = ProgramFactory() + self.create_grade(user_id=self.user_1.id, course_id=self.enrolled_course_run['key']) + self.create_grade(user_id=self.user_2.id, course_id=self.enrolled_course_run['key']) + + self.all_programs = [self.enrolled_program_1, self.enrolled_program_2, self.unenrolled_program] + + def create_grade(self, user_id, course_id): + """ + Create PersistentCourseGrade records for given user and course + """ + params = { + "user_id": user_id, + "course_id": course_id, + "course_version": "JoeMcEwing", + "percent_grade": 77.7, + "letter_grade": "Great job", + "passed_timestamp": timezone.now() - timedelta(days=1), + } + PersistentCourseGrade.objects.create(**params) + + @ddt.data( + False, True + ) + @patch('common.djangoapps.student.models.segment.track') + @patch('lms.djangoapps.program_enrollments.management.commands.send_program_course_nudge_email.get_programs') + def test_email_send(self, add_no_commit, get_programs_mock, mock_track): + """ + Test Segment fired as expected. + """ + get_programs_mock.return_value = [self.enrolled_program_1, self.enrolled_program_2] + with LogCapture() as logger: + if add_no_commit: + call_command(self.command, '--no-commit') + assert mock_track.call_count == 0 + else: + call_command(self.command) + assert mock_track.call_count == 2 + logger.check_present( + ( + LOG_PATH, + 'INFO', + f"[Program Course Nudge Email] 2 Emails sent. Records: [" + f"'User: {self.user_1.username}, Completed Course: {self.enrolled_course_run['key']}," + f" Suggested Course: {self.course_run_2['key']}', " + f"'User: {self.user_2.username}, Completed Course: {self.enrolled_course_run['key']}," + f" Suggested Course: {self.course_run_2['key']}']" + ) + ) + if add_no_commit: + assert mock_track.call_count == 0 + else: + assert mock_track.call_count == 2 diff --git a/openedx/core/djangoapps/catalog/tests/factories.py b/openedx/core/djangoapps/catalog/tests/factories.py index 263ddd2ae0..922e237371 100644 --- a/openedx/core/djangoapps/catalog/tests/factories.py +++ b/openedx/core/djangoapps/catalog/tests/factories.py @@ -156,6 +156,8 @@ class CourseRunFactory(DictFactoryBase): end = factory.LazyFunction(generate_zulu_datetime) enrollment_end = factory.LazyFunction(generate_zulu_datetime) enrollment_start = factory.LazyFunction(generate_zulu_datetime) + is_enrollable = True + is_marketable = True image = ImageFactory() key = factory.LazyFunction(generate_course_run_key) marketing_url = factory.Faker('url')