fix: updated management command to consider in-progress courses before suggestion.
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user