diff --git a/common/djangoapps/student/migrations/0040_usercelebration.py b/common/djangoapps/student/migrations/0040_usercelebration.py new file mode 100644 index 0000000000..aea99c39c8 --- /dev/null +++ b/common/djangoapps/student/migrations/0040_usercelebration.py @@ -0,0 +1,33 @@ +# Generated by Django 2.2.18 on 2021-02-18 22:49 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import model_utils.fields + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('student', '0039_anon_id_context'), + ] + + operations = [ + migrations.CreateModel( + name='UserCelebration', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), + ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), + ('last_day_of_streak', models.DateField(blank=True, default=None, null=True)), + ('streak_length', models.IntegerField(default=0)), + ('longest_ever_streak', models.IntegerField(default=0)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='celebration', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index 86177ee397..d4d4012e0a 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -12,6 +12,7 @@ file and check it in at the same time as your model changes. To do that, """ +import crum import hashlib import json import logging @@ -50,7 +51,7 @@ from eventtracking import tracker from model_utils.models import TimeStampedModel from opaque_keys.edx.django.models import CourseKeyField, LearningContextKeyField from opaque_keys.edx.keys import CourseKey -from pytz import UTC +from pytz import UTC, timezone from simple_history.models import HistoricalRecords from six import text_type from six.moves import range @@ -74,7 +75,10 @@ from lms.djangoapps.courseware.models import ( DynamicUpgradeDeadlineConfiguration, OrgDynamicUpgradeDeadlineConfiguration, ) -from lms.djangoapps.courseware.toggles import COURSEWARE_PROCTORING_IMPROVEMENTS +from lms.djangoapps.courseware.toggles import ( + courseware_mfe_streak_celebration_is_active, + COURSEWARE_PROCTORING_IMPROVEMENTS, +) from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.enrollments.api import ( @@ -3116,6 +3120,135 @@ class AccountRecoveryConfiguration(ConfigurationModel): ) +class UserCelebration(TimeStampedModel): + """ + Keeps track of how we've celebrated a user's progress on the platform. + This class is for course agnostic celebrations (not specific to a particular enrollment). + CourseEnrollmentCelebration is for celebrations that happen separately for each separate course. + + .. no_pii: + """ + user = models.OneToOneField(User, models.CASCADE, related_name='celebration') + # The last_day_of_streak and streak_length fields are used to + # control celebration of the streak feature. + # A streak is when a learner visits the learning MFE N days in a row. + # The business logic of streaks for a 3 day streak and 1 day break is the following: + # 1. Each streak should be celebrated exactly once, once the learner has completed the streak. + # 2. If a learner misses enough days to count as a break, the streak resets back to 0. + # 3. The streak is measured against the learner's configured timezone + # 4. We keep track of the total length of the streak, so there is a possibility in the future + # to add multiple celebrations for longer streaks. + # 5. We keep track of the longest_ever_streak field for potential future use for badging purposes. + last_day_of_streak = models.DateField(default=None, null=True, blank=True) + streak_length = models.IntegerField(default=0) + longest_ever_streak = models.IntegerField(default=0) + STREAK_LENGTHS_TO_CELEBRATE = [3] + STREAK_BREAK_LENGTH = 1 + + def __str__(self): + return ( + '[UserCelebration] user: {}; last_day_of_streak {}; streak_length {}; longest_ever_streak {};' + ).format(self.user.username, self.last_day_of_streak, self.streak_length, self.longest_ever_streak) + + @classmethod + def _get_now(cls, browser_timezone): + """ Retrieve the value for the current datetime in the user's timezone + + Once a user visits the learning MFE, their streak will not increment until midnight in their timezone. + The decision was to use the user's timezone and not UTC, to make each day of the streak more closely + correspond to separate days for the user. + The learning MFE passes in the browser timezone which is used as a fallback option if the user's timezone + in their account is not set. + UTC is used as a final fallback if neither timezone is set. + """ + # importing here to avoid a circular import + from lms.djangoapps.courseware.context_processor import user_timezone_locale_prefs + user_timezone_locale = user_timezone_locale_prefs(crum.get_current_request()) + user_timezone = timezone(user_timezone_locale['user_timezone'] or browser_timezone or str(UTC)) + return user_timezone.localize(datetime.now()) + + def _calculate_streak_updates(self, today): + """ Calculate the updates that should be applied to the streak fields of the provided celebration + A streak is incremented once for each day that a learner accesses the learning MFE. + A break is the amount of time that needs to pass before we stop incrementing the + existing streak and start a brand new streak. + See the UserCelebrationTests class for examples that should help clarify this behavior. + """ + last_day_of_streak = self.last_day_of_streak + streak_length = self.streak_length + streak_length_to_celebrate = None + + first_ever_streak = last_day_of_streak is None + break_length = timedelta(days=self.STREAK_BREAK_LENGTH) + should_start_new_streak = last_day_of_streak and last_day_of_streak + break_length < today + already_updated_streak_today = last_day_of_streak == today + + last_day_of_streak = today + if first_ever_streak or should_start_new_streak: + # Start new streak + streak_length = 1 + elif not already_updated_streak_today: + streak_length += 1 + if streak_length in self.STREAK_LENGTHS_TO_CELEBRATE: + # 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 + + def _update_streak(self, last_day_of_streak, streak_length): + """ Update the celebration with the new streak data """ + # If anything needs to be updated, update the celebration in the database + if last_day_of_streak != self.last_day_of_streak: + self.last_day_of_streak = last_day_of_streak + self.streak_length = streak_length + if self.longest_ever_streak < streak_length: + self.longest_ever_streak = streak_length + + self.save() + + @classmethod + def _get_celebration(cls, user, course_key): + """ Retrieve (or create) the celebration for the provided user and course_key """ + try: + # The UI for celebrations is only supported on the MFE right now, so don't turn on + # celebrations unless this enrollment's course is MFE-enabled and has milestones enabled. + if not courseware_mfe_streak_celebration_is_active(course_key): + return None + return user.celebration + except (cls.DoesNotExist, User.celebration.RelatedObjectDoesNotExist): # pylint: disable=no-member + celebration, _ = UserCelebration.objects.get_or_create(user=user) + return celebration + + @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.""" + # 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): + 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 = \ + celebration._calculate_streak_updates(today) + # pylint: enable=protected-access + + cls._update_streak(celebration, last_day_of_streak, streak_length) + + return streak_length_to_celebrate + + class CourseEnrollmentCelebration(TimeStampedModel): """ Keeps track of how we've celebrated a user's course progress. @@ -3138,7 +3271,7 @@ class CourseEnrollmentCelebration(TimeStampedModel): def __str__(self): return ( - "[CourseEnrollmentCelebration] course: {}; user: {}; first_section: {}" + '[CourseEnrollmentCelebration] course: {}; user: {}; first_section: {};' ).format(self.enrollment.course.id, self.enrollment.user.username, self.celebrate_first_section) @staticmethod diff --git a/common/djangoapps/student/tests/test_models.py b/common/djangoapps/student/tests/test_models.py index 866845c0d3..4b7afc7c27 100644 --- a/common/djangoapps/student/tests/test_models.py +++ b/common/djangoapps/student/tests/test_models.py @@ -4,29 +4,41 @@ import hashlib import ddt import factory +import mock import pytz +from crum import set_current_request from django.contrib.auth.models import AnonymousUser, User # lint-amnesty, pylint: disable=imported-auth-user from django.core.cache import cache from django.db.models import signals from django.db.models.functions import Lower from django.test import TestCase +from freezegun import freeze_time from opaque_keys.edx.keys import CourseKey +from pytz import UTC +from edx_toggles.toggles.testutils import override_waffle_flag from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.course_modes.tests.factories import CourseModeFactory from lms.djangoapps.courseware.models import DynamicUpgradeDeadlineConfiguration +from lms.djangoapps.courseware.toggles import ( + COURSEWARE_MICROFRONTEND_PROGRESS_MILESTONES, + COURSEWARE_MICROFRONTEND_PROGRESS_MILESTONES_STREAK_CELEBRATION, + REDIRECT_TO_COURSEWARE_MICROFRONTEND +) from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.schedules.models import Schedule from openedx.core.djangoapps.schedules.tests.factories import ScheduleFactory +from openedx.core.djangoapps.user_api.preferences.api import set_user_preference from openedx.core.djangolib.testing.utils import skip_unless_lms from common.djangoapps.student.models import ( ALLOWEDTOENROLL_TO_ENROLLED, AccountRecovery, CourseEnrollment, CourseEnrollmentAllowed, + UserCelebration, ManualEnrollmentAudit, PendingEmailChange, - PendingNameChange + PendingNameChange, ) from common.djangoapps.student.tests.factories import AccountRecoveryFactory, CourseEnrollmentFactory, UserFactory from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase @@ -244,6 +256,216 @@ class CourseEnrollmentTests(SharedModuleStoreTestCase): # lint-amnesty, pylint: assert enrollment_refetched.all()[0] == enrollment +@override_waffle_flag(REDIRECT_TO_COURSEWARE_MICROFRONTEND, active=True) +@override_waffle_flag(COURSEWARE_MICROFRONTEND_PROGRESS_MILESTONES, active=True) +@override_waffle_flag(COURSEWARE_MICROFRONTEND_PROGRESS_MILESTONES_STREAK_CELEBRATION, active=True) +class UserCelebrationTests(SharedModuleStoreTestCase): + """ + Tests for User Celebrations like the streak celebration + """ + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.course = CourseFactory() + cls.course_key = cls.course.id # pylint: disable=no-member + + def setUp(self): + super().setUp() + self.user = UserFactory() + self.request = mock.Mock() + self.request.user = self.user + CourseEnrollmentFactory(course_id=self.course_key) + UserCelebration.STREAK_LENGTHS_TO_CELEBRATE = [3] + UserCelebration.STREAK_BREAK_LENGTH = 1 + self.STREAK_LENGTH_TO_CELEBRATE = UserCelebration.STREAK_LENGTHS_TO_CELEBRATE[0] + self.STREAK_BREAK_LENGTH = UserCelebration.STREAK_BREAK_LENGTH + set_current_request(self.request) + self.addCleanup(set_current_request, None) + + def test_first_check_streak_celebration(self): + STREAK_LENGTH_TO_CELEBRATE = UserCelebration.perform_streak_updates(self.user, self.course_key) + today = datetime.datetime.now(UTC).date() + assert self.user.celebration.streak_length == 1 + assert self.user.celebration.last_day_of_streak == today + assert STREAK_LENGTH_TO_CELEBRATE is None + + # pylint: disable=line-too-long + def test_celebrate_only_once_in_continuous_streak(self): + """ + Sample run for a 3 day streak and 1 day break. See last column for explanation. + +---------+---------------------+--------------------+-------------------------+------------------+------------------+ + | today | streak_length | last_day_of_streak | streak_length_to_celebrate | Note | + +---------+---------------------+--------------------+-------------------------+------------------+------------------+ + | 2/4/21 | 1 | 2/4/21 | None | Day 1 of Streak | + | 2/5/21 | 2 | 2/5/21 | None | Day 2 of Streak | + | 2/6/21 | 3 | 2/6/21 | 3 | Completed 3 Day Streak so we should celebrate | + | 2/7/21 | 4 | 2/7/21 | None | Day 4 of Streak | + | 2/8/21 | 5 | 2/8/21 | None | Day 5 of Streak | + | 2/9/21 | 6 | 2/9/21 | None | Day 6 of Streak | + +---------+---------------------+--------------------+-------------------------+------------------+------------------+ + """ + now = datetime.datetime.now(UTC) + for i in range(1, (self.STREAK_LENGTH_TO_CELEBRATE * 2) + 1): + with freeze_time(now + datetime.timedelta(days=i)): + STREAK_LENGTH_TO_CELEBRATE = UserCelebration.perform_streak_updates(self.user, self.course_key) + assert bool(STREAK_LENGTH_TO_CELEBRATE) == (i == self.STREAK_LENGTH_TO_CELEBRATE) + + # pylint: disable=line-too-long + def test_longest_streak_updates_correctly(self): + """ + Sample run for a 3 day streak and 1 day break. See last column for explanation. + +---------+---------------------+--------------------+-------------------------+------------------+---------------------+ + | today | streak_length | last_day_of_streak | streak_length_to_celebrate | Note | + +---------+---------------------+--------------------+-------------------------+------------------+---------------------+ + | 2/4/21 | 1 | 2/4/21 | None | longest_streak_ever is 1 | + | 2/5/21 | 2 | 2/5/21 | None | longest_streak_ever is 2 | + | 2/6/21 | 3 | 2/6/21 | 3 | longest_streak_ever is 3 | + | 2/7/21 | 4 | 2/7/21 | None | longest_streak_ever is 4 | + | 2/8/21 | 5 | 2/8/21 | None | longest_streak_ever is 5 | + | 2/9/21 | 6 | 2/9/21 | None | longest_streak_ever is 6 | + +---------+---------------------+--------------------+-------------------------+------------------+---------------------+ + """ + now = datetime.datetime.now(UTC) + for i in range(1, (self.STREAK_LENGTH_TO_CELEBRATE * 2) + 1): + with freeze_time(now + datetime.timedelta(days=i)): + UserCelebration.perform_streak_updates(self.user, self.course_key) + assert self.user.celebration.longest_ever_streak == i + + # pylint: disable=line-too-long + def test_celebrate_only_once_with_multiple_calls_on_the_same_day(self): + """ + Sample run for a 3 day streak and 1 day break. See last column for explanation. + +---------+---------------------+--------------------+-------------------------+------------------+----------------------------+ + | today | streak_length | last_day_of_streak | streak_length_to_celebrate | Note | + +---------+---------------------+--------------------+-------------------------+------------------+----------------------------+ + | 2/4/21 | 1 | 2/4/21 | None | Day 1 of Streak | + | 2/4/21 | 1 | 2/4/21 | None | Day 1 of Streak | + | 2/5/21 | 2 | 2/5/21 | None | Day 2 of Streak | + | 2/5/21 | 2 | 2/5/21 | None | Day 2 of Streak | + | 2/6/21 | 3 | 2/6/21 | 3 | Completed 3 Day Streak so we should celebrate | + | 2/6/21 | 3 | 2/6/21 | None | Already celebrated this streak. | + +---------+---------------------+--------------------+-------------------------+------------------+----------------------------+ + """ + now = datetime.datetime.now(UTC) + for i in range(1, self.STREAK_LENGTH_TO_CELEBRATE + 1): + with freeze_time(now + datetime.timedelta(days=i)): + streak_length_to_celebrate = UserCelebration.perform_streak_updates(self.user, self.course_key) + assert bool(streak_length_to_celebrate) == (i == self.STREAK_LENGTH_TO_CELEBRATE) + streak_length_to_celebrate = UserCelebration.perform_streak_updates(self.user, self.course_key) + assert streak_length_to_celebrate is None + + def test_celebration_with_user_passed_in_timezone(self): + """ + Check that the _get_now method uses the user's timezone from the browser if none is configured + """ + now = UserCelebration._get_now('Asia/Tokyo') # pylint: disable=protected-access + assert str(now.tzinfo) == 'Asia/Tokyo' + + def test_celebration_with_user_configured_timezone(self): + """ + Check that the _get_now method uses the user's configured timezone + over the browser timezone that is passed in as a parameter + """ + set_user_preference(self.user, 'time_zone', 'Asia/Tokyo') + now = UserCelebration._get_now('America/New_York') # pylint: disable=protected-access + assert str(now.tzinfo) == 'Asia/Tokyo' + + # pylint: disable=line-too-long + def test_celebrate_twice_with_broken_streak_in_between(self): + """ + Sample run for a 3 day streak and 1 day break. See last column for explanation. + +---------+---------------------+--------------------+-------------------------+------------------+-----------------------------------------------+ + | today | streak_length | last_day_of_streak | streak_length_to_celebrate | Note | + +---------+---------------------+--------------------+-------------------------+------------------+-----------------------------------------------+ + | 2/4/21 | 1 | 2/4/21 | None | Day 1 of Streak | + | 2/5/21 | 2 | 2/5/21 | None | Day 2 of Streak | + | 2/6/21 | 3 | 2/6/21 | 3 | Completed 3 Day Streak so we should celebrate | + No Accesses on 2/7/21 + | 2/8/21 | 1 | 2/8/21 | None | Day 1 of Streak | + | 2/9/21 | 2 | 2/9/21 | None | Day 2 of Streak | + | 2/10/21 | 3 | 2/10/21 | 3 | Completed 3 Day Streak so we should celebrate | + +---------+---------------------+--------------------+-------------------------+------------------+-----------------------------------------------+ + """ + now = datetime.datetime.now(UTC) + for i in range(1, self.STREAK_LENGTH_TO_CELEBRATE + self.STREAK_BREAK_LENGTH + self.STREAK_LENGTH_TO_CELEBRATE + 1): + with freeze_time(now + datetime.timedelta(days=i)): + if self.STREAK_LENGTH_TO_CELEBRATE < i <= self.STREAK_LENGTH_TO_CELEBRATE + self.STREAK_BREAK_LENGTH: + # Don't make any checks during the break + continue + streak_length_to_celebrate = UserCelebration.perform_streak_updates(self.user, self.course_key) + if i <= self.STREAK_LENGTH_TO_CELEBRATE: + assert bool(streak_length_to_celebrate) == (i == self.STREAK_LENGTH_TO_CELEBRATE) + else: + assert bool(streak_length_to_celebrate) == (i == self.STREAK_LENGTH_TO_CELEBRATE + self.STREAK_BREAK_LENGTH + self.STREAK_LENGTH_TO_CELEBRATE) + + # pylint: disable=line-too-long + def test_streak_resets_if_day_is_missed(self): + """ + Sample run for a 3 day streak and 1 day break with the learner coming back every other day. + Therefore the streak keeps resetting. + +---------+---------------------+--------------------+-------------------------+------------------+-----------------------------------------------+ + | today | streak_length | last_day_of_streak | streak_length_to_celebrate | Note | + +---------+---------------------+--------------------+-------------------------+------------------+-----------------------------------------------+ + | 2/4/21 | 1 | 2/4/21 | None | Day 1 of Streak | + No Accesses on 2/5/21 + | 2/6/21 | 1 | 2/6/21 | None | Day 2 of streak was missed, so streak resets | + No Accesses on 2/7/21 + | 2/8/21 | 1 | 2/8/21 | None | Day 2 of streak was missed, so streak resets | + No Accesses on 2/9/21 + | 2/10/21 | 1 | 2/10/21 | None | Day 2 of streak was missed, so streak resets | + No Accesses on 2/11/21 + | 2/12/21 | 1 | 2/12/21 | None | Day 2 of streak was missed, so streak resets | + +---------+---------------------+--------------------+-------------------------+------------------+-----------------------------------------------+ + """ + now = datetime.datetime.now(UTC) + for i in range(1, self.STREAK_LENGTH_TO_CELEBRATE * 3 + 1, 2): + with freeze_time(now + datetime.timedelta(days=i)): + streak_length_to_celebrate = UserCelebration.perform_streak_updates(self.user, self.course_key) + assert self.user.celebration.last_day_of_streak == (now + datetime.timedelta(days=i)).date() + assert streak_length_to_celebrate is None + + # pylint: disable=line-too-long + def test_streak_does_not_reset_if_day_is_missed_with_longer_break(self): + """ + Sample run for a 3 day streak with the learner coming back every other day. + See last column for explanation. + +---------+---------------------+--------------------+-------------------------+------------------+ + | today | streak_length | last_day_of_streak | streak_length_to_celebrate | Note | + +---------+---------------------+--------------------+-------------------------+------------------+ + | 2/4/21 | 1 | 2/4/21 | None | Day 1 of Streak | + No Accesses on 2/5/21 + | 2/6/21 | 2 | 2/6/21 | None | Day 2 of Streak | + No Accesses on 2/7/21 + | 2/8/21 | 3 | 2/8/21 | 3 | Day 3 of streak | + No Accesses on 2/9/21 + | 2/10/21 | 4 | 2/10/21 | None | Day 4 of streak | + No Accesses on 2/11/21 + | 2/12/21 | 5 | 2/12/21 | None | Day 5 of streak | + +---------+---------------------+--------------------+-------------------------+------------------+ + """ + UserCelebration.STREAK_BREAK_LENGTH = 2 + now = datetime.datetime.now(UTC) + for i in range(1, self.STREAK_LENGTH_TO_CELEBRATE * 3 + 1, 2): + with freeze_time(now + datetime.timedelta(days=i)): + streak_length_to_celebrate = UserCelebration.perform_streak_updates(self.user, self.course_key) + assert bool(streak_length_to_celebrate) == (i == 5) + + def test_streak_masquerade(self): + """ Don't update streak data when masquerading as a specific student """ + # Update streak data when not masquerading + with mock.patch.object(UserCelebration, '_update_streak') as update_streak_mock: + for _ in range(1, self.STREAK_LENGTH_TO_CELEBRATE + 1): + UserCelebration.perform_streak_updates(self.user, self.course_key) + update_streak_mock.assert_called() + + # Don't update streak data when masquerading as a specific student + with mock.patch('lms.djangoapps.courseware.masquerade.is_masquerading_as_specific_student', return_value=True): + with mock.patch.object(UserCelebration, '_update_streak') as update_streak_mock: + for _ in range(1, self.STREAK_LENGTH_TO_CELEBRATE + 1): + UserCelebration.perform_streak_updates(self.user, self.course_key) + update_streak_mock.assert_not_called() + + class PendingNameChangeTests(SharedModuleStoreTestCase): """ Tests the deletion of PendingNameChange records diff --git a/lms/djangoapps/course_home_api/course_metadata/v1/serializers.py b/lms/djangoapps/course_home_api/course_metadata/v1/serializers.py index 160e6207e5..7a19dfc5ca 100644 --- a/lms/djangoapps/course_home_api/course_metadata/v1/serializers.py +++ b/lms/djangoapps/course_home_api/course_metadata/v1/serializers.py @@ -40,3 +40,4 @@ class CourseHomeMetadataSerializer(serializers.Serializer): original_user_is_staff = serializers.BooleanField() tabs = CourseTabSerializer(many=True) title = serializers.CharField() + celebrations = serializers.DictField() diff --git a/lms/djangoapps/course_home_api/course_metadata/v1/tests/test_views.py b/lms/djangoapps/course_home_api/course_metadata/v1/tests/test_views.py index 04a3633be8..31699bb24c 100644 --- a/lms/djangoapps/course_home_api/course_metadata/v1/tests/test_views.py +++ b/lms/djangoapps/course_home_api/course_metadata/v1/tests/test_views.py @@ -2,17 +2,25 @@ Tests for the Course Home Course Metadata API in the Course Home API """ - import ddt from django.urls import reverse +from edx_toggles.toggles.testutils import override_waffle_flag from common.djangoapps.course_modes.models import CourseMode +from lms.djangoapps.courseware.toggles import ( + COURSEWARE_MICROFRONTEND_PROGRESS_MILESTONES, + COURSEWARE_MICROFRONTEND_PROGRESS_MILESTONES_STREAK_CELEBRATION, + REDIRECT_TO_COURSEWARE_MICROFRONTEND +) from common.djangoapps.student.models import CourseEnrollment from common.djangoapps.student.tests.factories import UserFactory from lms.djangoapps.course_home_api.tests.utils import BaseCourseHomeTests @ddt.ddt +@override_waffle_flag(REDIRECT_TO_COURSEWARE_MICROFRONTEND, active=True) +@override_waffle_flag(COURSEWARE_MICROFRONTEND_PROGRESS_MILESTONES, active=True) +@override_waffle_flag(COURSEWARE_MICROFRONTEND_PROGRESS_MILESTONES_STREAK_CELEBRATION, active=True) class CourseHomeMetadataTests(BaseCourseHomeTests): """ Tests for the Course Home Course Metadata API @@ -49,3 +57,10 @@ class CourseHomeMetadataTests(BaseCourseHomeTests): url = reverse('course-home-course-metadata', args=['course-v1:unknown+course+2T2020']) response = self.client.get(url) assert response.status_code == 404 + + def test_streak_data_in_response(self): + """ Test that metadata endpoint returns data for the streak celebration """ + CourseEnrollment.enroll(self.user, self.course.id, 'audit') + response = self.client.get(self.url, content_type='application/json') + celebrations = response.json()['celebrations'] + assert 'streak_length_to_celebrate' in celebrations diff --git a/lms/djangoapps/course_home_api/course_metadata/v1/views.py b/lms/djangoapps/course_home_api/course_metadata/v1/views.py index ed16ffb98e..53f37d6ac7 100644 --- a/lms/djangoapps/course_home_api/course_metadata/v1/views.py +++ b/lms/djangoapps/course_home_api/course_metadata/v1/views.py @@ -12,7 +12,7 @@ from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthenticat from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser -from common.djangoapps.student.models import CourseEnrollment +from common.djangoapps.student.models import CourseEnrollment, UserCelebration from lms.djangoapps.course_api.api import course_detail from lms.djangoapps.course_home_api.course_metadata.v1.serializers import CourseHomeMetadataSerializer from lms.djangoapps.courseware.access import has_access @@ -48,6 +48,7 @@ class CourseHomeMetadataView(RetrieveAPIView): title: (str) The title of the tab to display url: (str) The url to view the tab title: (str) The Course's display title + celebrations: (dict) a dict of celebration data **Returns** @@ -77,7 +78,12 @@ class CourseHomeMetadataView(RetrieveAPIView): course = course_detail(request, request.user.username, course_key) user_is_enrolled = CourseEnrollment.is_enrolled(request.user, course_key_string) - + browser_timezone = request.query_params.get('browser_timezone', None) + celebrations = { + 'streak_length_to_celebrate': UserCelebration.perform_streak_updates( + request.user, course_key, browser_timezone + ) + } data = { 'course_id': course.id, 'is_staff': has_access(request.user, 'staff', course_key).has_access, @@ -88,6 +94,7 @@ class CourseHomeMetadataView(RetrieveAPIView): 'title': course.display_name_with_default, 'is_self_paced': getattr(course, 'self_paced', False), 'is_enrolled': user_is_enrolled, + 'celebrations': celebrations, } context = self.get_serializer_context() context['course'] = course diff --git a/lms/djangoapps/courseware/toggles.py b/lms/djangoapps/courseware/toggles.py index 46be367250..aed8dc0d5a 100644 --- a/lms/djangoapps/courseware/toggles.py +++ b/lms/djangoapps/courseware/toggles.py @@ -85,6 +85,22 @@ COURSEWARE_MICROFRONTEND_PROGRESS_MILESTONES_FIRST_SECTION_CELEBRATION = CourseW WAFFLE_FLAG_NAMESPACE, 'mfe_progress_milestones_first_section_celebration', __name__ ) +# .. toggle_name: courseware.mfe_progress_milestones_streak_celebration +# .. toggle_implementation: CourseWaffleFlag +# .. toggle_default: False +# .. toggle_description: Waffle flag to display a celebration modal when learner completes a configurable streak +# Supports staged rollout to students for a new micro-frontend-based implementation of the +# courseware page. +# .. toggle_use_cases: temporary, open_edx +# .. toggle_creation_date: 2021-02-16 +# .. toggle_target_removal_date: None +# .. toggle_warnings: Also set settings.LEARNING_MICROFRONTEND_URL and ENABLE_COURSEWARE_MICROFRONTEND and +# COURSEWARE_MICROFRONTEND_PROGRESS_MILESTONES. +# .. toggle_tickets: AA-304 +COURSEWARE_MICROFRONTEND_PROGRESS_MILESTONES_STREAK_CELEBRATION = CourseWaffleFlag( + WAFFLE_FLAG_NAMESPACE, 'mfe_progress_milestones_streak_celebration', __name__ +) + # .. toggle_name: courseware.proctoring_improvements # .. toggle_implementation: CourseWaffleFlag # .. toggle_default: False @@ -144,3 +160,11 @@ def courseware_mfe_first_section_celebration_is_active(course_key): COURSEWARE_MICROFRONTEND_PROGRESS_MILESTONES.is_enabled(course_key) and COURSEWARE_MICROFRONTEND_PROGRESS_MILESTONES_FIRST_SECTION_CELEBRATION.is_enabled(course_key) ) + + +def courseware_mfe_streak_celebration_is_active(course_key): + return ( + REDIRECT_TO_COURSEWARE_MICROFRONTEND.is_enabled(course_key) and + COURSEWARE_MICROFRONTEND_PROGRESS_MILESTONES.is_enabled(course_key) and + COURSEWARE_MICROFRONTEND_PROGRESS_MILESTONES_STREAK_CELEBRATION.is_enabled(course_key) + ) diff --git a/openedx/core/djangoapps/courseware_api/tests/test_views.py b/openedx/core/djangoapps/courseware_api/tests/test_views.py index 02b00d6fba..c73507e1b2 100644 --- a/openedx/core/djangoapps/courseware_api/tests/test_views.py +++ b/openedx/core/djangoapps/courseware_api/tests/test_views.py @@ -12,6 +12,7 @@ from django.conf import settings from django.test.client import RequestFactory from django.urls import reverse # lint-amnesty, pylint: disable=unused-import +from edx_toggles.toggles.testutils import override_waffle_flag from lms.djangoapps.certificates.api import get_certificate_url from lms.djangoapps.certificates.tests.factories import ( GeneratedCertificateFactory, LinkedInAddToProfileConfigurationFactory @@ -19,8 +20,15 @@ from lms.djangoapps.certificates.tests.factories import ( from lms.djangoapps.courseware.access_utils import ACCESS_DENIED, ACCESS_GRANTED from lms.djangoapps.courseware.tabs import ExternalLinkCourseTab from lms.djangoapps.courseware.tests.helpers import MasqueradeMixin +from lms.djangoapps.courseware.toggles import ( + COURSEWARE_MICROFRONTEND_PROGRESS_MILESTONES, + COURSEWARE_MICROFRONTEND_PROGRESS_MILESTONES_STREAK_CELEBRATION, + REDIRECT_TO_COURSEWARE_MICROFRONTEND +) from lms.djangoapps.verify_student.services import IDVerificationService -from common.djangoapps.student.models import CourseEnrollment, CourseEnrollmentCelebration +from common.djangoapps.student.models import ( + CourseEnrollment, CourseEnrollmentCelebration +) from common.djangoapps.student.tests.factories import CourseEnrollmentCelebrationFactory, UserFactory from xmodule.modulestore.django import modulestore from xmodule.modulestore.tests.django_utils import TEST_DATA_SPLIT_MODULESTORE, SharedModuleStoreTestCase @@ -68,6 +76,9 @@ class BaseCoursewareTests(SharedModuleStoreTestCase): @ddt.ddt +@override_waffle_flag(REDIRECT_TO_COURSEWARE_MICROFRONTEND, active=True) +@override_waffle_flag(COURSEWARE_MICROFRONTEND_PROGRESS_MILESTONES, active=True) +@override_waffle_flag(COURSEWARE_MICROFRONTEND_PROGRESS_MILESTONES_STREAK_CELEBRATION, active=True) class CourseApiTestViews(BaseCoursewareTests): """ Tests for the courseware REST API @@ -169,6 +180,13 @@ class CourseApiTestViews(BaseCoursewareTests): else: assert not response.data['can_load_courseware']['has_access'] + def test_streak_data_in_response(self): + """ Test that metadata endpoint returns data for the streak celebration """ + CourseEnrollment.enroll(self.user, self.course.id, 'audit') + response = self.client.get(self.url, content_type='application/json') + celebrations = response.json()['celebrations'] + assert 'streak_length_to_celebrate' in celebrations + class SequenceApiTestViews(BaseCoursewareTests): """ diff --git a/openedx/core/djangoapps/courseware_api/views.py b/openedx/core/djangoapps/courseware_api/views.py index e7ca1d390a..c24c84732f 100644 --- a/openedx/core/djangoapps/courseware_api/views.py +++ b/openedx/core/djangoapps/courseware_api/views.py @@ -45,7 +45,10 @@ from openedx.features.content_type_gating.models import ContentTypeGatingConfig from openedx.features.course_duration_limits.access import get_access_expiration_data from openedx.features.discounts.utils import generate_offer_data from common.djangoapps.student.models import ( - CourseEnrollment, CourseEnrollmentCelebration, LinkedInAddToProfileConfiguration + CourseEnrollment, + CourseEnrollmentCelebration, + LinkedInAddToProfileConfiguration, + UserCelebration ) from xmodule.modulestore.django import modulestore from xmodule.modulestore.search import path_to_location @@ -76,7 +79,7 @@ class CoursewareMeta: self.request.user = self.effective_user self.is_staff = has_access(self.effective_user, 'staff', self.overview).has_access self.enrollment_object = CourseEnrollment.get_enrollment(self.effective_user, self.course_key, - select_related=['celebration']) + select_related=['celebration', 'user__celebration']) def __getattr__(self, name): return getattr(self.overview, name) @@ -202,8 +205,12 @@ class CoursewareMeta: """ Returns a list of celebrations that should be performed. """ + browser_timezone = self.request.query_params.get('browser_timezone', None) return { 'first_section': CourseEnrollmentCelebration.should_celebrate_first_section(self.enrollment_object), + 'streak_length_to_celebrate': UserCelebration.perform_streak_updates( + self.effective_user, self.course_key, browser_timezone + ), } @property