Create backend for three day streak celebration

This feature uses the first_day_of_streak, last_day_of_streak and last_streak_celebration fields to determine whether the user should see a celebration.
AA-304
This commit is contained in:
Matthew Piatetsky
2021-02-04 10:25:14 -05:00
parent a98f45ddba
commit 3b45a72b8e
9 changed files with 470 additions and 10 deletions

View File

@@ -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,
},
),
]

View File

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

View File

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

View File

@@ -40,3 +40,4 @@ class CourseHomeMetadataSerializer(serializers.Serializer):
original_user_is_staff = serializers.BooleanField()
tabs = CourseTabSerializer(many=True)
title = serializers.CharField()
celebrations = serializers.DictField()

View File

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

View File

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

View File

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

View File

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

View File

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