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:
Justin Hynes
2025-05-29 09:35:12 -04:00
committed by GitHub
parent a9c78cd8f7
commit 3704811d21
2 changed files with 120 additions and 30 deletions

View File

@@ -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

View File

@@ -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):
"""