diff --git a/common/djangoapps/student/models/user.py b/common/djangoapps/student/models/user.py index 7fe69bd678..ad47c942f3 100644 --- a/common/djangoapps/student/models/user.py +++ b/common/djangoapps/student/models/user.py @@ -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 diff --git a/common/djangoapps/student/tests/test_models.py b/common/djangoapps/student/tests/test_models.py index 2388a21c7d..02df1a6714 100644 --- a/common/djangoapps/student/tests/test_models.py +++ b/common/djangoapps/student/tests/test_models.py @@ -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): """