fix: updated management command to consider in-progress courses before suggestion.

This commit is contained in:
hammadahmadwaqas
2022-12-02 16:08:20 +05:00
parent 08007033f3
commit 583346f67b
2 changed files with 133 additions and 50 deletions

View File

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

View File

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