Merge pull request #30150 from openedx/hammad/ENT-5524
feat: added management command to fire segments events to suggest courses from programs.
This commit is contained in:
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user