feat: send segment event for failed learners for a course
This commit is contained in:
@@ -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']}]")
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user