Skip to content

Commit 9196156

Browse files
authored
feat: optionally emit course completion analytics when a learner enters the courseware (#36507)
This PR attempts to improve the ability to collect analytics about learner's progress in their courses. Currently, the only place we regularly calculate course progress is when a learner visits the "Progress" tab in the courseware. Now, _optionally_, when a learner visits the home page of their course, we will enqueue a Celery task that will calculate their progress and emit a tracking event. This event is gated by use of the COURSE_HOME_SEND_COURSE_PROGRESS_ANALYTICS_FOR_STUDENT waffle flag.
1 parent cc4c2c3 commit 9196156

7 files changed

Lines changed: 293 additions & 12 deletions

File tree

lms/djangoapps/course_home_api/outline/tests/test_view.py

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,25 +3,26 @@
33
"""
44

55
import itertools
6+
import json
67
from datetime import datetime, timedelta, timezone
7-
from lms.djangoapps.grades.course_grade_factory import CourseGradeFactory
8-
from unittest.mock import Mock, patch # lint-amnesty, pylint: disable=wrong-import-order
8+
from unittest.mock import Mock, patch
99

10-
import ddt # lint-amnesty, pylint: disable=wrong-import-order
11-
import json # lint-amnesty, pylint: disable=wrong-import-order
10+
import ddt
1211
from completion.models import BlockCompletion
13-
from django.conf import settings # lint-amnesty, pylint: disable=wrong-import-order
12+
from django.conf import settings
1413
from django.test import override_settings
15-
from django.urls import reverse # lint-amnesty, pylint: disable=wrong-import-order
16-
from edx_toggles.toggles.testutils import override_waffle_flag # lint-amnesty, pylint: disable=wrong-import-order
14+
from django.urls import reverse
15+
from edx_toggles.toggles.testutils import override_waffle_flag
1716

1817
from cms.djangoapps.contentstore.outlines import update_outline_from_modulestore
1918
from common.djangoapps.course_modes.models import CourseMode
2019
from common.djangoapps.course_modes.tests.factories import CourseModeFactory
2120
from common.djangoapps.student.models import CourseEnrollment
2221
from common.djangoapps.student.roles import CourseInstructorRole
2322
from common.djangoapps.student.tests.factories import UserFactory
23+
from lms.djangoapps.course_home_api.toggles import COURSE_HOME_SEND_COURSE_PROGRESS_ANALYTICS_FOR_STUDENT
2424
from lms.djangoapps.course_home_api.tests.utils import BaseCourseHomeTests
25+
from lms.djangoapps.grades.course_grade_factory import CourseGradeFactory
2526
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
2627
from openedx.core.djangoapps.content.learning_sequences.api import replace_course_outline
2728
from openedx.core.djangoapps.content.learning_sequences.data import CourseOutlineData, CourseVisibility
@@ -33,12 +34,15 @@
3334
COURSE_ENABLE_UNENROLLED_ACCESS_FLAG,
3435
ENABLE_COURSE_GOALS
3536
)
36-
from openedx.features.discounts.applicability import (
37-
DISCOUNT_APPLICABILITY_FLAG,
38-
FIRST_PURCHASE_DISCOUNT_OVERRIDE_FLAG
37+
from openedx.features.discounts.applicability import DISCOUNT_APPLICABILITY_FLAG, FIRST_PURCHASE_DISCOUNT_OVERRIDE_FLAG
38+
from xmodule.course_block import (
39+
COURSE_VISIBILITY_PUBLIC,
40+
COURSE_VISIBILITY_PUBLIC_OUTLINE
41+
)
42+
from xmodule.modulestore.tests.factories import (
43+
BlockFactory,
44+
CourseFactory
3945
)
40-
from xmodule.course_block import COURSE_VISIBILITY_PUBLIC, COURSE_VISIBILITY_PUBLIC_OUTLINE # lint-amnesty, pylint: disable=wrong-import-order
41-
from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory # lint-amnesty, pylint: disable=wrong-import-order
4246

4347

4448
@ddt.ddt
@@ -461,6 +465,25 @@ def test_cannot_enroll_if_full(self):
461465
CourseEnrollment.enroll(UserFactory(), self.course.id) # grr, some rando took our spot!
462466
self.assert_can_enroll(False)
463467

468+
@override_waffle_flag(COURSE_HOME_SEND_COURSE_PROGRESS_ANALYTICS_FOR_STUDENT, active=True)
469+
@patch("lms.djangoapps.course_home_api.outline.views.collect_progress_for_user_in_course.delay")
470+
def test_course_progress_analytics_enabled(self, mock_task):
471+
"""
472+
Ensures that the `calculate_course_progress_for_user_in_course` task is enqueued, with the correct args, only
473+
if the feature is enabled.
474+
"""
475+
self.client.get(self.url)
476+
mock_task.assert_called_once_with(str(self.course.id), self.user.id)
477+
478+
@override_waffle_flag(COURSE_HOME_SEND_COURSE_PROGRESS_ANALYTICS_FOR_STUDENT, active=False)
479+
@patch("lms.djangoapps.course_home_api.outline.views.collect_progress_for_user_in_course.delay")
480+
def test_course_progress_analytics_disabled(self, mock_task):
481+
"""
482+
Ensures that the `calculate_course_progress_for_user_in_course` task is not run if the feature is disabled.
483+
"""
484+
self.client.get(self.url)
485+
mock_task.assert_not_called()
486+
464487

465488
@ddt.ddt
466489
class SidebarBlocksTestViews(BaseCourseHomeTests):

lms/djangoapps/course_home_api/outline/views.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@
3535
OutlineTabSerializer,
3636
)
3737
from lms.djangoapps.course_home_api.utils import get_course_or_403
38+
from lms.djangoapps.course_home_api.tasks import collect_progress_for_user_in_course
39+
from lms.djangoapps.course_home_api.toggles import send_course_progress_analytics_for_student_is_enabled
3840
from lms.djangoapps.courseware.access import has_access
3941
from lms.djangoapps.courseware.context_processor import user_timezone_locale_prefs
4042
from lms.djangoapps.courseware.courses import get_course_date_blocks, get_course_info_section
@@ -366,6 +368,9 @@ def get(self, request, *args, **kwargs): # pylint: disable=too-many-statements
366368
context['enrollment'] = enrollment
367369
serializer = self.get_serializer_class()(data, context=context)
368370

371+
if send_course_progress_analytics_for_student_is_enabled(course_key) and not user_is_masquerading:
372+
collect_progress_for_user_in_course.delay(course_key_string, request.user.id)
373+
369374
return Response(serializer.data)
370375

371376
def finalize_response(self, request, response, *args, **kwargs):
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"""
2+
Python APIs exposed for the progress tracking functionality of the course home API.
3+
"""
4+
5+
from django.contrib.auth import get_user_model
6+
from opaque_keys.edx.keys import CourseKey
7+
8+
from lms.djangoapps.courseware.courses import get_course_blocks_completion_summary
9+
10+
11+
User = get_user_model()
12+
13+
14+
def calculate_progress_for_learner_in_course(course_key: CourseKey, user: User) -> dict:
15+
"""
16+
Calculate a given learner's progress in the specified course run.
17+
"""
18+
summary = get_course_blocks_completion_summary(course_key, user)
19+
if not summary:
20+
return {}
21+
22+
complete_count = summary.get("complete_count", 0)
23+
locked_count = summary.get("locked_count", 0)
24+
incomplete_count = summary.get("incomplete_count", 0)
25+
26+
# This completion calculation mirrors the logic used in the CompletionDonutChart component on the Learning MFE's
27+
# Progress tab. It's duplicated here to enable backend reporting on learner progress. Ideally, this logic should be
28+
# refactored in the future so that the calculation is handled solely on the backend, eliminating the need for it to
29+
# be done in the frontend.
30+
num_total_units = complete_count + incomplete_count + locked_count
31+
complete_percentage = round(complete_count / num_total_units, 2)
32+
locked_percentage = round(locked_count / num_total_units, 2)
33+
incomplete_percentage = 1.00 - complete_percentage - locked_percentage
34+
35+
return {
36+
"complete_count": complete_count,
37+
"locked_count": locked_count,
38+
"incomplete_count": incomplete_count,
39+
"total_count": num_total_units,
40+
"complete_percentage": complete_percentage,
41+
"locked_percentage": locked_percentage,
42+
"incomplete_percentage": incomplete_percentage
43+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
"""
2+
Tests for the Python APIs exposed by the Progress API of the Course Home API app.
3+
"""
4+
5+
from unittest.mock import patch
6+
7+
from django.test import TestCase
8+
9+
from lms.djangoapps.course_home_api.progress.api import calculate_progress_for_learner_in_course
10+
11+
12+
class ProgressApiTests(TestCase):
13+
"""
14+
Tests for the progress calculation functions.
15+
"""
16+
@patch("lms.djangoapps.course_home_api.progress.api.get_course_blocks_completion_summary")
17+
def test_calculate_progress_for_learner_in_course(self, mock_get_summary):
18+
"""
19+
A test to verify functionality of the function under test.
20+
"""
21+
get_summary_return_val = {
22+
"complete_count": 5,
23+
"incomplete_count": 2,
24+
"locked_count": 1,
25+
}
26+
mock_get_summary.return_value = get_summary_return_val
27+
28+
expected_data = {
29+
"complete_count": 5,
30+
"incomplete_count": 2,
31+
"locked_count": 1,
32+
"total_count": 8,
33+
"complete_percentage": 0.62,
34+
"locked_percentage": 0.12,
35+
"incomplete_percentage": 0.26,
36+
}
37+
38+
results = calculate_progress_for_learner_in_course("some_course", "some_user")
39+
assert mock_get_summary.called_once_with("some_course", "some_user")
40+
assert results == expected_data
41+
42+
@patch("lms.djangoapps.course_home_api.progress.api.get_course_blocks_completion_summary")
43+
def test_calculate_progress_for_learner_in_course_summary_empty(self, mock_get_summary):
44+
"""
45+
A test to verify functionality of the function under test if a block summary is not received.
46+
"""
47+
mock_get_summary.return_value = {}
48+
49+
results = calculate_progress_for_learner_in_course("some_course", "some_user")
50+
assert not results
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
"""
2+
Celery tasks used by the `course_home_api` app.
3+
"""
4+
import logging
5+
6+
from celery import shared_task
7+
from django.contrib.auth import get_user_model
8+
from edx_django_utils.monitoring import set_code_owner_attribute
9+
from eventtracking import tracker
10+
from opaque_keys import InvalidKeyError
11+
from opaque_keys.edx.keys import CourseKey
12+
13+
from common.djangoapps.student.models_api import get_course_enrollment
14+
from lms.djangoapps.course_home_api.progress.api import calculate_progress_for_learner_in_course
15+
16+
User = get_user_model()
17+
COURSE_COMPLETION_FOR_USER_EVENT_NAME = "edx.bi.user.course-progress"
18+
19+
log = logging.getLogger(__name__)
20+
21+
22+
@shared_task
23+
@set_code_owner_attribute
24+
def collect_progress_for_user_in_course(course_id: str, user_id: str) -> None:
25+
"""
26+
Celery task that retrieves a learner's progress in a given course.
27+
"""
28+
try:
29+
course_key = CourseKey.from_string(course_id)
30+
except InvalidKeyError:
31+
log.warning(f"Invalid course id {course_id}, aborting task.")
32+
return
33+
34+
try:
35+
user = User.objects.get(id=user_id)
36+
except User.DoesNotExist:
37+
log.warning(f"Could not retrieve a user with id {user_id}, aborting task.")
38+
return
39+
40+
progress = calculate_progress_for_learner_in_course(course_key, user)
41+
enrollment = get_course_enrollment(user, course_key)
42+
# add a few extra fields to the returned data to make the event payload a bit more usable
43+
progress["user_id"] = user.id
44+
progress["course_id"] = course_id
45+
progress["enrollment_mode"] = enrollment.mode
46+
47+
tracker.emit(
48+
COURSE_COMPLETION_FOR_USER_EVENT_NAME,
49+
progress
50+
)
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
"""
2+
Tests for Celery tasks used by the `course_home_api` app.
3+
"""
4+
5+
from unittest.mock import patch
6+
7+
from opaque_keys.edx.keys import CourseKey
8+
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
9+
10+
from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory
11+
from lms.djangoapps.course_home_api.tasks import (
12+
COURSE_COMPLETION_FOR_USER_EVENT_NAME,
13+
collect_progress_for_user_in_course
14+
)
15+
from openedx.core.djangoapps.catalog.tests.factories import CourseFactory, CourseRunFactory
16+
17+
18+
class CalculateCompletionTaskTests(ModuleStoreTestCase):
19+
"""
20+
Tests for the `emit_course_completion_analytics_for_user` Celery task.
21+
"""
22+
def setUp(self):
23+
super().setUp()
24+
self.user = UserFactory()
25+
self.course_run = CourseRunFactory()
26+
self.course_run_key_string = self.course_run['key']
27+
self.course = CourseFactory(key=self.course_run_key_string, course_runs=[self.course_run])
28+
self.enrollment = CourseEnrollmentFactory(
29+
user=self.user,
30+
course_id=self.course_run_key_string,
31+
mode="verified"
32+
)
33+
34+
@patch("lms.djangoapps.course_home_api.tasks.calculate_progress_for_learner_in_course")
35+
@patch("lms.djangoapps.course_home_api.tasks.tracker.emit")
36+
def test_successful_event_emission(self, mock_tracker, mock_progress):
37+
"""
38+
Test to ensure a tracker event is emit by the task with the expected completion information.
39+
"""
40+
mock_progress.return_value = {
41+
"complete_count": 5,
42+
"incomplete_count": 2,
43+
"locked_count": 1,
44+
"total_count": 8,
45+
"complete_percentage": 0.62,
46+
"locked_percentage": 0.12,
47+
"incomplete_percentage": 0.26,
48+
}
49+
50+
expected_data = {
51+
"user_id": self.user.id,
52+
"course_id": self.course_run_key_string,
53+
"enrollment_mode": self.enrollment.mode,
54+
"complete_count": 5,
55+
"incomplete_count": 2,
56+
"locked_count": 1,
57+
"total_count": 8,
58+
"complete_percentage": 0.62,
59+
"locked_percentage": 0.12,
60+
"incomplete_percentage": 0.26,
61+
}
62+
63+
collect_progress_for_user_in_course(self.course_run_key_string, self.user.id)
64+
mock_progress.assert_called_once_with(CourseKey.from_string(self.course_run_key_string), self.user)
65+
mock_tracker.assert_called_once_with(
66+
COURSE_COMPLETION_FOR_USER_EVENT_NAME,
67+
expected_data,
68+
)
69+
70+
@patch("lms.djangoapps.course_home_api.tasks.calculate_progress_for_learner_in_course")
71+
@patch("lms.djangoapps.course_home_api.tasks.tracker.emit")
72+
def test_aborted_task_user_dne(self, mock_tracker, mock_progress):
73+
"""
74+
Test to ensure the task is aborted if we cannot find the user for some reason.
75+
"""
76+
collect_progress_for_user_in_course(self.course_run_key_string, 8675309)
77+
mock_progress.assert_not_called()
78+
mock_tracker.assert_not_called()
79+
80+
@patch("lms.djangoapps.course_home_api.tasks.calculate_progress_for_learner_in_course")
81+
@patch("lms.djangoapps.course_home_api.tasks.tracker.emit")
82+
def test_aborted_task_bad_course_id(self, mock_tracker, mock_progress):
83+
"""
84+
Test to ensure the task is aborted if the course key provided is no good.
85+
"""
86+
collect_progress_for_user_in_course("nonsense", self.user.id)
87+
mock_progress.assert_not_called()
88+
mock_tracker.assert_not_called()

lms/djangoapps/course_home_api/toggles.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,21 @@
3636
)
3737

3838

39+
# Waffle flag to enable emission of course progress analytics for students in their courses.
40+
#
41+
# .. toggle_name: course_home.send_course_progress_analytics_for_student
42+
# .. toggle_implementation: CourseWaffleFlag
43+
# .. toggle_default: False
44+
# .. toggle_description: This toggle controls whether the system will enqueue a Celery task responsible for emitting an
45+
# analytics events describing how much course content a learner has completed in a course.
46+
# .. toggle_use_cases: open_edx
47+
# .. toggle_creation_date: 2025-04-02
48+
# .. toggle_target_removal_date: None
49+
COURSE_HOME_SEND_COURSE_PROGRESS_ANALYTICS_FOR_STUDENT = CourseWaffleFlag(
50+
f'{WAFFLE_FLAG_NAMESPACE}.send_course_progress_analytics_for_student', __name__
51+
)
52+
53+
3954
def course_home_mfe_progress_tab_is_active(course_key):
4055
# Avoiding a circular dependency
4156
from .models import DisableProgressPageStackedConfig
@@ -51,3 +66,10 @@ def new_discussion_sidebar_view_is_enabled(course_key):
5166
Returns True if the new discussion sidebar view is enabled for the given course.
5267
"""
5368
return COURSE_HOME_NEW_DISCUSSION_SIDEBAR_VIEW.is_enabled(course_key)
69+
70+
71+
def send_course_progress_analytics_for_student_is_enabled(course_key):
72+
"""
73+
Returns True if the course completion analytics feature is enabled for a given course.
74+
"""
75+
return COURSE_HOME_SEND_COURSE_PROGRESS_ANALYTICS_FOR_STUDENT.is_enabled(course_key)

0 commit comments

Comments
 (0)