feat: emit event when a celebration/streak is updated (#36801)
This PR adds a new event that is emit when a celebration/streak is being recorded. This event will allow better analytics around learner engagement in their courses for OeX instances that have the milestones celebration feature enabled.
This commit is contained in:
@@ -11,29 +11,20 @@ file and check it in at the same time as your model changes. To do that,
|
||||
3. Add the migration file created in edx-platform/common/djangoapps/student/migrations/
|
||||
"""
|
||||
|
||||
import crum
|
||||
import hashlib # lint-amnesty, pylint: disable=wrong-import-order
|
||||
import json # lint-amnesty, pylint: disable=wrong-import-order
|
||||
import logging # lint-amnesty, pylint: disable=wrong-import-order
|
||||
import uuid # lint-amnesty, pylint: disable=wrong-import-order
|
||||
from datetime import datetime, timedelta # lint-amnesty, pylint: disable=wrong-import-order
|
||||
from functools import total_ordering # lint-amnesty, pylint: disable=wrong-import-order
|
||||
from importlib import import_module # lint-amnesty, pylint: disable=wrong-import-order
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from functools import total_ordering
|
||||
from importlib import import_module
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from .course_enrollment import (
|
||||
ALLOWEDTOENROLL_TO_ENROLLED,
|
||||
CourseEnrollment,
|
||||
CourseEnrollmentAllowed,
|
||||
CourseOverview,
|
||||
ManualEnrollmentAudit,
|
||||
segment
|
||||
)
|
||||
|
||||
import crum
|
||||
from config_models.models import ConfigurationModel
|
||||
from django.apps import apps
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.signals import user_logged_in, user_logged_out
|
||||
from django.contrib.sites.models import Site
|
||||
from django.core.cache import cache
|
||||
@@ -64,6 +55,16 @@ from openedx.core.djangoapps.xmodule_django.models import NoneToEmptyManager
|
||||
from openedx.core.djangolib.model_mixins import DeletableByUserValue
|
||||
from openedx.core.toggles import ENTRANCE_EXAMS
|
||||
|
||||
from .course_enrollment import (
|
||||
ALLOWEDTOENROLL_TO_ENROLLED,
|
||||
CourseEnrollment,
|
||||
CourseEnrollmentAllowed,
|
||||
CourseOverview,
|
||||
ManualEnrollmentAudit,
|
||||
segment
|
||||
)
|
||||
|
||||
User = get_user_model()
|
||||
log = logging.getLogger(__name__)
|
||||
AUDIT_LOG = logging.getLogger("audit")
|
||||
SessionStore = import_module(settings.SESSION_ENGINE).SessionStore # pylint: disable=invalid-name
|
||||
@@ -72,6 +73,7 @@ IS_MARKETABLE = 'is_marketable'
|
||||
|
||||
USER_LOGGED_IN_EVENT_NAME = 'edx.user.login'
|
||||
USER_LOGGED_OUT_EVENT_NAME = 'edx.user.logout'
|
||||
USER_STREAK_UPDATED_EVENT_NAME = "edx.user.celebration.streak_updated"
|
||||
|
||||
|
||||
class AnonymousUserId(models.Model):
|
||||
@@ -1769,7 +1771,7 @@ class UserCelebration(TimeStampedModel):
|
||||
# Celebrate if we didn't already celebrate today
|
||||
streak_length_to_celebrate = streak_length
|
||||
|
||||
return last_day_of_streak, streak_length, streak_length_to_celebrate
|
||||
return last_day_of_streak, streak_length, streak_length_to_celebrate, already_updated_streak_today
|
||||
|
||||
def _update_streak(self, last_day_of_streak, streak_length):
|
||||
""" Update the celebration with the new streak data """
|
||||
@@ -1781,6 +1783,32 @@ class UserCelebration(TimeStampedModel):
|
||||
|
||||
self.save()
|
||||
|
||||
def _emit_streak_update_event(self, user: User, course_id: str, current_streak_length: int) -> None:
|
||||
"""
|
||||
Emits a server-side event using the event tracking library with details about the learner's current streak. The
|
||||
course run ID is included to enable tracking of progress trends over time.
|
||||
|
||||
Args:
|
||||
user (User): The user whose streak is being updated.
|
||||
course_id (str): The course run ID the learner is currently engaged with.
|
||||
current_streak_length (int): The number of consecutive days the user has been active.
|
||||
"""
|
||||
context = {
|
||||
"user_id": user.id,
|
||||
"course_id": course_id,
|
||||
}
|
||||
data = {
|
||||
"user_id": user.id,
|
||||
"current_course_id": course_id,
|
||||
"current_streak_length": current_streak_length,
|
||||
}
|
||||
|
||||
with tracker.get_tracker().context(USER_STREAK_UPDATED_EVENT_NAME, context):
|
||||
tracker.emit(
|
||||
USER_STREAK_UPDATED_EVENT_NAME,
|
||||
data,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _get_celebration(cls, user, course_key):
|
||||
""" Retrieve (or create) the celebration for the provided user and course_key """
|
||||
@@ -1795,30 +1823,46 @@ class UserCelebration(TimeStampedModel):
|
||||
|
||||
@classmethod
|
||||
def perform_streak_updates(cls, user, course_key, browser_timezone=None):
|
||||
""" Determine if the user should see a streak celebration and
|
||||
return the length of the streak the user should celebrate.
|
||||
Also update the streak data that is stored in the database."""
|
||||
"""
|
||||
Determine if the user should see a streak celebration and return the length of the streak the user should
|
||||
celebrate.
|
||||
|
||||
Additionally, we record any updates to the current streak in the database and emit a server side
|
||||
event about the update.
|
||||
|
||||
Args:
|
||||
user (User): The user whose streak is being updated.
|
||||
course_key (CourseLocator): The Course Run key of the course the user is currently engaged with when
|
||||
recording the streak update.
|
||||
browser_timezone (str): String representing the current time zone set from a user's web browser.
|
||||
May be null.
|
||||
|
||||
Returns:
|
||||
streak_length_to_celebrate (int): A number representing how many days in a row a learner has been actively
|
||||
engaging in learning content in the courseware.
|
||||
"""
|
||||
# importing here to avoid a circular import
|
||||
from lms.djangoapps.courseware.masquerade import is_masquerading_as_specific_student
|
||||
if not user or user.is_anonymous:
|
||||
return None
|
||||
|
||||
if is_masquerading_as_specific_student(user, course_key):
|
||||
if (
|
||||
not user
|
||||
or user.is_anonymous
|
||||
or is_masquerading_as_specific_student(user, course_key)
|
||||
):
|
||||
return None
|
||||
|
||||
celebration = cls._get_celebration(user, course_key)
|
||||
|
||||
if not celebration:
|
||||
return None
|
||||
|
||||
today = cls._get_now(browser_timezone).date()
|
||||
|
||||
# pylint: disable=protected-access
|
||||
last_day_of_streak, streak_length, streak_length_to_celebrate = \
|
||||
last_day_of_streak, streak_length, streak_length_to_celebrate, already_updated_streak_today = \
|
||||
celebration._calculate_streak_updates(today)
|
||||
# pylint: enable=protected-access
|
||||
|
||||
cls._update_streak(celebration, last_day_of_streak, streak_length)
|
||||
if not already_updated_streak_today:
|
||||
cls._update_streak(celebration, last_day_of_streak, streak_length)
|
||||
cls._emit_streak_update_event(celebration, user, str(course_key), streak_length)
|
||||
|
||||
return streak_length_to_celebrate
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import datetime
|
||||
import hashlib
|
||||
from unittest import mock
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import ddt
|
||||
import pytz
|
||||
@@ -460,6 +461,51 @@ class UserCelebrationTests(SharedModuleStoreTestCase):
|
||||
UserCelebration.perform_streak_updates(self.user, self.course_key)
|
||||
update_streak_mock.assert_not_called()
|
||||
|
||||
def test_event_emit_when_streak_is_updated(self):
|
||||
"""
|
||||
Ensure we call the `emit_streak_update_event` method when a streak is updated.
|
||||
"""
|
||||
with mock.patch.object(UserCelebration, '_emit_streak_update_event') as emit_streak_event_mock:
|
||||
UserCelebration.perform_streak_updates(self.user, self.course_key)
|
||||
emit_streak_event_mock.assert_called_once()
|
||||
|
||||
@mock.patch("common.djangoapps.student.models.user.tracker.emit")
|
||||
@mock.patch("common.djangoapps.student.models.tracker.get_tracker")
|
||||
def test_emit_streak_update_event(self, mock_get_tracker, mock_tracker):
|
||||
"""
|
||||
Ensure the event emission code of the `emit_streak_update_event` method works as expected.
|
||||
"""
|
||||
mock_context_manager = MagicMock()
|
||||
mock_context_manager.__enter__.return_value = None
|
||||
mock_context_manager.__exit__.return_value = None
|
||||
|
||||
mock_tracker_instance = MagicMock()
|
||||
mock_tracker_instance.context.return_value = mock_context_manager
|
||||
mock_get_tracker.return_value = mock_tracker_instance
|
||||
|
||||
expected_data = {
|
||||
"user_id": self.user.id,
|
||||
"current_course_id": str(self.course_key),
|
||||
"current_streak_length": 4,
|
||||
}
|
||||
|
||||
celebration = UserCelebration()
|
||||
# pylint: disable=protected-access
|
||||
celebration._emit_streak_update_event(self.user, str(self.course_key), 4)
|
||||
# pylint: enable=protected-access
|
||||
|
||||
mock_tracker_instance.context.assert_called_once_with(
|
||||
"edx.user.celebration.streak_updated",
|
||||
{
|
||||
"user_id": self.user.id,
|
||||
"course_id": str(self.course_key),
|
||||
}
|
||||
)
|
||||
mock_tracker.assert_called_once_with(
|
||||
"edx.user.celebration.streak_updated",
|
||||
expected_data,
|
||||
)
|
||||
|
||||
|
||||
class PendingNameChangeTests(SharedModuleStoreTestCase):
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user