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:
33
common/djangoapps/student/migrations/0040_usercelebration.py
Normal file
33
common/djangoapps/student/migrations/0040_usercelebration.py
Normal 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,
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -40,3 +40,4 @@ class CourseHomeMetadataSerializer(serializers.Serializer):
|
||||
original_user_is_staff = serializers.BooleanField()
|
||||
tabs = CourseTabSerializer(many=True)
|
||||
title = serializers.CharField()
|
||||
celebrations = serializers.DictField()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user