diff --git a/lms/djangoapps/grades/management/commands/send_segment_events_for_failed_learners.py b/lms/djangoapps/grades/management/commands/send_segment_events_for_failed_learners.py new file mode 100644 index 0000000000..3c1a11ac0b --- /dev/null +++ b/lms/djangoapps/grades/management/commands/send_segment_events_for_failed_learners.py @@ -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']}]") diff --git a/lms/djangoapps/grades/management/commands/tests/test_send_segment_events_for_failed_learners.py b/lms/djangoapps/grades/management/commands/tests/test_send_segment_events_for_failed_learners.py new file mode 100644 index 0000000000..e188a0deb9 --- /dev/null +++ b/lms/djangoapps/grades/management/commands/tests/test_send_segment_events_for_failed_learners.py @@ -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)