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 index 33738232c6..c095b2d161 100644 --- 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 @@ -5,6 +5,7 @@ to complete possible next course from same program. import logging from collections import defaultdict +from copy import deepcopy from datetime import timedelta from operator import itemgetter from urllib.parse import urljoin @@ -32,8 +33,9 @@ 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. + This command sends nudge emails to complete next course to learners after they complete a course that is linked + with one or multiple programs. if next course in the program is already in progress, command skips that program + and try to propose course for next linked program. Example usage: $ ./manage.py lms send_program_course_nudge_email @@ -79,19 +81,67 @@ class Command(BaseCommand): and course_run['image'] \ and course_run['status'] == COURSE_PUBLISHED - def get_course_run_to_suggest(self, programs_progress, completed_course_id): + @staticmethod + def get_candidate_program_and_courses(program, program_progress): + """ + Get program in a new format containing candidate_courses and in_progress_courses_ids + """ + if program['uuid'] != program_progress['uuid']: + raise RuntimeError('Program and Program Progress should be same') + not_started_courses_ids = [course['key'] for course in program_progress['not_started']] + in_progress_courses_ids = [course['key'] for course in program_progress['in_progress']] + candidate_courses = not_started_courses_ids + in_progress_courses_ids + program_copy = deepcopy(program) + return { + 'uuid': program_copy['uuid'], + 'candidate_courses': [course for course in program_copy['courses'] if course['key'] in candidate_courses], + 'in_progress_courses_ids': in_progress_courses_ids, + } + + def get_candidate_programs(self, programs, programs_progress): + """ + Get all programs with candidate courses. + """ + candidate_programs = [] + for program, program_progress in zip(programs, programs_progress): + candidate_programs.append( + self.get_candidate_program_and_courses(program, program_progress) + ) + return candidate_programs + + def get_course_run_to_suggest(self, candidate_programs, completed_course_id, user): """ Finds out enrollable course run from programs Generated by ProgramProgressMeter. Returns: Suggested program and course_run dicts """ completed_course = CourseLocator.from_string(completed_course_id) - for program in programs_progress: - for not_started_course in program['not_started']: - if completed_course.course != not_started_course['key']: - for course_run in not_started_course['course_runs']: - if self.valid_course_run(course_run) and course_run['key'] != completed_course_id: - return program, course_run, not_started_course + for program in candidate_programs: + for candidate_course in program['candidate_courses']: + if candidate_course['key'] == completed_course.course: + # ideally this should never happen as candidate courses are not completed course. + LOGGER.warning( + '[Program Course Nudge Email] Candidate course is already completed. ' + 'Program: [%s], Candidate Course: [%s], User: [%s]', + program['uuid'], + candidate_course['key'], + user.username, + + ) + continue + if candidate_course['key'] in program['in_progress_courses_ids']: + # user already doing next course in this program. skip to next program. + LOGGER.info( + '[Program Course Nudge Email] Candidate course is already in progress. ' + 'Program: [%s], Candidate Course: [%s], User: [%s]', + program['uuid'], + candidate_course['key'], + user.username, + ) + break + for course_run in candidate_course['course_runs']: + if self.valid_course_run(course_run) and course_run['key'] != completed_course_id: + return program, course_run, candidate_course return None, None, None def sort_programs(self, programs): @@ -120,7 +170,7 @@ class Command(BaseCommand): if course_run['key'] == course_run_id: return course_run - def get_program(self, programs, program_progress): + def find_detailed_program(self, programs, program_progress): """ get detailed program. """ @@ -192,11 +242,14 @@ class Command(BaseCommand): 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) + candidate_programs = self.get_candidate_programs(course_linked_programs, programs_progress) suggested_program_progress, suggested_course_run, suggested_course = self.get_course_run_to_suggest( - programs_progress, completed_course_id + candidate_programs, completed_course_id, user ) if suggested_course_run and suggested_course: - suggested_program = self.get_program(course_linked_programs, suggested_program_progress) + suggested_program = self.find_detailed_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( 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 index 441b6ef91e..7e79672068 100644 --- 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 @@ -6,21 +6,26 @@ from unittest.mock import patch import ddt from django.core.management import call_command -from django.test import TestCase from django.test.utils import override_settings from django.utils import timezone from testfixtures import LogCapture -from common.djangoapps.student.tests.factories import UserFactory +from common.djangoapps.course_modes.models import CourseMode +from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory +from lms.djangoapps.certificates.data import CertificateStatuses +from lms.djangoapps.certificates.tests.factories import GeneratedCertificateFactory from lms.djangoapps.grades.models import PersistentCourseGrade -from openedx.core.djangoapps.catalog.tests.factories import CourseFactory, CourseRunFactory, ProgramFactory +from openedx.core.djangoapps.catalog.tests.factories import CourseFactory as CatalogCourseFactory +from openedx.core.djangoapps.catalog.tests.factories import CourseRunFactory, ProgramFactory from openedx.features.enterprise_support.tests.factories import EnterpriseCustomerUserFactory +from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory LOG_PATH = 'lms.djangoapps.program_enrollments.management.commands.send_program_course_nudge_email' @ddt.ddt -class TestSendProgramCourseNudgeEmailCommand(TestCase): +class TestSendProgramCourseNudgeEmailCommand(SharedModuleStoreTestCase): """ Test send_program_course_nudge_email command. """ @@ -39,70 +44,95 @@ class TestSendProgramCourseNudgeEmailCommand(TestCase): user_id=self.user_1.id, enterprise_customer__enable_learner_portal=True ) - 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]) + completed_course = CourseFactory.create() + self.completed_course_run = CourseRunFactory(key=str(completed_course.id)) + user_1_in_progress_course = CourseFactory.create() + self.user_1_in_progress_course_run = CourseRunFactory(type='audit', key=str(user_1_in_progress_course.id)) + not_started_course_1 = CourseFactory.create() + self.not_started_course_run_1 = CourseRunFactory(key=str(not_started_course_1.id)) + not_started_course_2 = CourseFactory.create() + self.not_started_course_run_2 = CourseRunFactory(key=str(not_started_course_2.id)) - self.enrolled_program_1 = ProgramFactory( - courses=[self.enrolled_course, self.unenrolled_course_1], + self.catalog_completed_course = CatalogCourseFactory(course_runs=[self.completed_course_run]) + self.catalog_user_1_in_progress_course = CatalogCourseFactory(course_runs=[self.user_1_in_progress_course_run]) + self.catalog_not_started_course_1 = CatalogCourseFactory(course_runs=[self.not_started_course_run_1]) + self.catalog_not_started_course_2 = CatalogCourseFactory(course_runs=[self.not_started_course_run_2]) + + self.partially_completed_program_1 = ProgramFactory( + courses=[self.catalog_completed_course, self.catalog_not_started_course_1], type='MicroBachelors' ) - self.enrolled_program_2 = ProgramFactory( - courses=[self.enrolled_course, self.unenrolled_course_2], + self.partially_completed_program_2 = ProgramFactory( + courses=[ + self.catalog_completed_course, self.catalog_user_1_in_progress_course, self.catalog_not_started_course_2 + ], type='MicroMasters' ) - self.enrolled_program_3 = ProgramFactory( - courses=[self.enrolled_course], + self.completed_program = ProgramFactory( + courses=[self.catalog_completed_course], 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.not_linked_program = ProgramFactory() - self.all_programs = [self.enrolled_program_1, self.enrolled_program_2, self.unenrolled_program] + self.enroll_user(user=self.user_1, course=user_1_in_progress_course) + self.enroll_user(user=self.user_1, course=completed_course, create_grade=True) + self.enroll_user(user=self.user_2, course=completed_course, create_grade=True) - def create_grade(self, user_id, course_id): + def enroll_user(self, user, course, create_grade=False): """ 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) + CourseEnrollmentFactory(user=user, course_id=course.id, mode=CourseMode.AUDIT) + if create_grade: + 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) + GeneratedCertificateFactory( + user=user, + course_id=course.id, + status=CertificateStatuses.downloadable, + mode='verified', + download_url='www.google.com', + ) @ddt.data( - False, True + False, + True, ) @patch('common.djangoapps.student.models.segment.track') @patch('lms.djangoapps.program_enrollments.management.commands.send_program_course_nudge_email.get_programs') + @patch('lms.djangoapps.certificates.api.certificates_viewable_for_course', return_value=True) @override_settings(FEATURES=dict(ENABLE_ENTERPRISE_INTEGRATION=True)) - def test_email_send(self, add_no_commit, get_programs_mock, mock_track): + 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] + # partially_completed_program_2 program is a Micro Program so should be processed first irrespective of the + # order return from get_programs_mock. Program are sorted on based on their types + get_programs_mock.return_value = [self.partially_completed_program_1, self.partially_completed_program_2] with LogCapture() as logger: if add_no_commit: call_command(self.command, '--no-commit') else: call_command(self.command) + # As user_1_in_progress_course_run is only in-progress for user_1 and not_started for user_2 so user 2 + # should be suggested with user_1_in_progress_course_run and user_1 should be suggested with + # not_started_course_run_1 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']}']" + f"'User: {self.user_1.username}, Completed Course: {self.completed_course_run['key']}," + f" Suggested Course: {self.not_started_course_run_1['key']}', " + f"'User: {self.user_2.username}, Completed Course: {self.completed_course_run['key']}," + f" Suggested Course: {self.user_1_in_progress_course_run['key']}']" ) ) if add_no_commit: @@ -120,7 +150,7 @@ class TestSendProgramCourseNudgeEmailCommand(TestCase): """ Test Segment fired as expected. """ - get_programs_mock.return_value = [self.enrolled_program_3] + get_programs_mock.return_value = [self.completed_program] with LogCapture() as logger: if add_no_commit: call_command(self.command, '--no-commit')