feat: send segment event for failed learners for a course

This commit is contained in:
muhammad-ammar
2022-05-26 17:39:16 +05:00
parent 290236390b
commit c0a5dac128
2 changed files with 291 additions and 0 deletions

View File

@@ -0,0 +1,136 @@
"""
Send segment events for failed learners.
"""
import logging
from datetime import timedelta
from django.core.management.base import BaseCommand
from django.core.paginator import Paginator
from django.utils import timezone
from common.djangoapps.course_modes.models import CourseMode
from common.djangoapps.student.models import CourseEnrollment
from common.djangoapps.track import segment
from lms.djangoapps.grades.models import PersistentCourseGrade
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
log = logging.getLogger(__name__)
PAID_ENROLLMENT_MODES = [
CourseMode.MASTERS,
CourseMode.VERIFIED,
CourseMode.CREDIT_MODE,
CourseMode.PROFESSIONAL,
CourseMode.NO_ID_PROFESSIONAL_MODE,
]
EVENT_NAME = 'edx.course.learner.failed'
class Command(BaseCommand):
"""
Example usage:
$ ./manage.py lms send_segment_events_for_failed_learners
"""
help = 'Send segment events for failed learners.'
def add_arguments(self, parser):
"""
Entry point to add arguments.
"""
parser.add_argument(
'--dry-run',
action='store_true',
dest='dry_run',
default=False,
help='Dry Run, print log messages without firing the segment event.',
)
def get_courses(self):
"""
Find all courses where end date has passed 31 days ago course grade override date is course.end + 30 days
but we are adding grace period of 1 day to mitigate any edge cases due to last minute grade override.
"""
thirty_one_days_ago = timezone.now().date() - timedelta(days=31)
return CourseOverview.objects.exclude(end__isnull=True).filter(end__date=thirty_one_days_ago)
def get_course_failed_user_ids(self, course):
"""
Get list of all the enrolled users that failed the given course. This method will only consider paid enrolments.
Arguments:
course (CourseOverview): Course overview instance whose failed enrolments should be returned.
Returns:
(generator): An iterator with paginated user ids, each iteration will return 500 item list of user ids.
"""
failed_grade_user_ids = PersistentCourseGrade.objects.filter(
passed_timestamp__isnull=True,
course_id=course.id,
).values_list('user_id', flat=True)
paginator = Paginator(failed_grade_user_ids, 500)
for page_number in paginator.page_range:
page = paginator.page(page_number)
failed_grade_user_ids = list(page.object_list)
# exclude all non-paid enrollments
failed_user_ids = CourseEnrollment.objects.filter(
course_id=course.id,
user_id__in=failed_grade_user_ids,
mode__in=PAID_ENROLLMENT_MODES,
is_active=True
).values_list('user_id', flat=True)
failed_user_ids = list(failed_user_ids)
yield failed_user_ids
def handle(self, *args, **options):
"""
Command's entery point.
"""
should_fire_event = not options['dry_run']
log_prefix = '[SEND_SEGMENT_EVENTS_FOR_FAILED_LEARNERS]'
if not should_fire_event:
log_prefix = '[DRY RUN]'
stats = {
'failed_course_user_ids': {},
}
log.info(f'{log_prefix} Command started.')
for course in self.get_courses():
# course metadata for event
course_org = course.org
course_id = str(course.id)
course_display_name = course.display_name
stats['failed_course_user_ids'][course_id] = []
for course_failed_user_ids in self.get_course_failed_user_ids(course):
# for each failed enrollment, send a segment event
for course_failed_user_id in course_failed_user_ids:
event_properties = {
'LMS_USER_ID': course_failed_user_id,
'COURSERUN_KEY': course_id,
'COURSE_TITLE': course_display_name,
'COURSE_ORG_NAME': course_org,
'PASSED': 0,
}
if should_fire_event:
segment.track(course_failed_user_id, EVENT_NAME, event_properties)
stats['failed_course_user_ids'][course_id].append(course_failed_user_id)
log.info(
"{} Segment event fired for failed learner. Event: [{}], Data: [{}]".format(
log_prefix,
EVENT_NAME,
event_properties
)
)
log.info(f"{log_prefix} Command completed. Stats: [{stats['failed_course_user_ids']}]")

View File

@@ -0,0 +1,155 @@
"""
Tests for `send_segment_events_for_failed_learners` management command.
"""
import random
from datetime import timedelta
from unittest import mock
from unittest.mock import patch
import ddt
from django.core.management import call_command
from django.utils import timezone
from xmodule.modulestore.tests.django_utils import \
SharedModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order
from common.djangoapps.student.models import CourseEnrollment
from common.djangoapps.student.tests.factories import UserFactory
from lms.djangoapps.grades.management.commands import send_segment_events_for_failed_learners
from lms.djangoapps.grades.management.commands.send_segment_events_for_failed_learners import (
EVENT_NAME,
PAID_ENROLLMENT_MODES
)
from lms.djangoapps.grades.models import PersistentCourseGrade
from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory
@ddt.ddt
class TestSendSegmentEventsForFailedLearnersCommand(SharedModuleStoreTestCase):
"""
Tests `send_segment_events_for_failed_learners` management command.
"""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.command = send_segment_events_for_failed_learners.Command()
# we will create enrollments for paid modes plus `audit` mode
enrollment_modes = PAID_ENROLLMENT_MODES + ['audit']
# import pdb ; pdb.set_trace()
cls.course_end = timezone.now() - timedelta(days=31)
cls.course_overviews = CourseOverviewFactory.create_batch(4, end=cls.course_end)
# set end date for a course 100 days ago from the current date
course = cls.course_overviews[2]
course.end = timezone.now() - timedelta(days=100)
course.save()
# set end date to None
course = cls.course_overviews[3]
course.end = None
course.save()
cls.course_keys = [str(course_overview.id) for course_overview in cls.course_overviews]
cls.users = [UserFactory.create(username=f'user{idx}') for idx in range(5)]
for user in cls.users:
for course_overview in cls.course_overviews:
CourseEnrollment.enroll(user, course_overview.id, mode=random.choice(enrollment_modes))
params = [
{
"user_id": user.id,
"course_id": course_overview.id,
"course_version": "Alice",
"percent_grade": 0.0,
"letter_grade": "",
"passed_timestamp": None,
},
{
"user_id": user.id,
"course_id": course_overview.id,
"course_version": "Bob",
"percent_grade": 77.7,
"letter_grade": "Great job",
"passed_timestamp": timezone.now() - timedelta(days=1),
},
]
# randomly create passed and failed grades
PersistentCourseGrade.objects.create(**random.choice(params))
def construct_event_call_data(self):
"""
Construct segment event call data for verification.
"""
event_call_data = []
for course in self.command.get_courses():
for course_failed_user_ids in self.command.get_course_failed_user_ids(course):
for course_failed_user_id in course_failed_user_ids:
event_call_data.append([
course_failed_user_id,
EVENT_NAME,
{
'LMS_USER_ID': course_failed_user_id,
'COURSERUN_KEY': str(course.id),
'COURSE_TITLE': course.display_name,
'COURSE_ORG_NAME': course.org,
'PASSED': 0,
}
])
return event_call_data
def test_get_courses(self):
"""
Verify that `get_courses` method returns correct courses.
"""
courses = self.command.get_courses()
assert len(courses) == 2
for index in range(2):
assert courses[index].id == self.course_overviews[index].id
assert self.course_end.date() == courses[index].end.date() == self.course_overviews[index].end.date()
def test_get_course_failed_user_ids(self):
"""
Verify that `get_course_failed_user_ids` method returns correct user ids.
* user id must have a paid enrollment
* user id must have a failed grade
"""
for course in self.course_overviews:
for user_ids in self.command.get_course_failed_user_ids(course):
for user_id in user_ids:
# user id must have a paid enrollment
assert CourseEnrollment.objects.filter(
course_id=course.id,
user_id=user_id,
mode__in=PAID_ENROLLMENT_MODES,
is_active=True
).exists()
# user id must have a failed grade
assert PersistentCourseGrade.objects.filter(
passed_timestamp__isnull=True,
course_id=course.id,
user_id=user_id,
).exists()
@patch('lms.djangoapps.grades.management.commands.send_segment_events_for_failed_learners.segment.track')
def test_command_dry_run(self, segment_track_mock):
"""
Verify that management command does not fire any segment event in dry run mode.
"""
call_command(self.command, '--dry-run')
segment_track_mock.assert_has_calls([])
@patch('lms.djangoapps.grades.management.commands.send_segment_events_for_failed_learners.segment.track')
def test_command(self, segment_track_mock):
"""
Verify that management command fires segment events with correct data.
* Event should be fired for failed learners only.
* Event should be fired for paid enrollments only.
"""
call_command(self.command)
expected_segment_event_calls = [mock.call(*event_data) for event_data in self.construct_event_call_data()]
segment_track_mock.assert_has_calls(expected_segment_event_calls)