Merge pull request #27505 from edx/AA-759

[AA-759] feat: Add flag for AA-759 streak celebration discount experiment
This commit is contained in:
Matthew Piatetsky
2021-05-10 14:43:40 -04:00
committed by GitHub
11 changed files with 218 additions and 36 deletions

View File

@@ -9,6 +9,8 @@ from django.urls import reverse
from django.utils.translation import ugettext as _
from rest_framework import serializers
from lms.djangoapps.course_home_api.mixins import VerifiedModeSerializerMixin
class CourseTabSerializer(serializers.Serializer):
"""
@@ -27,7 +29,7 @@ class CourseTabSerializer(serializers.Serializer):
return request.build_absolute_uri(tab.link_func(self.context.get('course'), reverse))
class CourseHomeMetadataSerializer(serializers.Serializer):
class CourseHomeMetadataSerializer(VerifiedModeSerializerMixin, serializers.Serializer):
"""
Serializer for the Course Home Course Metadata
"""

View File

@@ -3,6 +3,7 @@ Tests for the Course Home Course Metadata API in the Course Home API
"""
import ddt
import mock
from django.urls import reverse
from edx_toggles.toggles.testutils import override_waffle_flag
@@ -15,6 +16,8 @@ from lms.djangoapps.courseware.toggles import (
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
from lms.djangoapps.experiments.testutils import override_experiment_waffle_flag
from lms.djangoapps.experiments.utils import STREAK_DISCOUNT_EXPERIMENT_FLAG
@ddt.ddt
@@ -74,6 +77,9 @@ class CourseHomeMetadataTests(BaseCourseHomeTests):
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
with override_experiment_waffle_flag(STREAK_DISCOUNT_EXPERIMENT_FLAG, active=True):
with mock.patch('common.djangoapps.student.models.UserCelebration.perform_streak_updates', return_value=3):
response = self.client.get(self.url, content_type='application/json')
celebrations = response.json()['celebrations']
assert celebrations['streak_length_to_celebrate'] == 3
assert celebrations['streak_discount_experiment_enabled'] is True

View File

@@ -11,6 +11,7 @@ from opaque_keys.edx.keys import CourseKey # lint-amnesty, pylint: disable=reim
from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication
from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser
from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser
from openedx.core.djangoapps.courseware_api.utils import get_celebrations_dict
from openedx.core.djangoapps.courseware_api.views import CoursewareMeta
from common.djangoapps.student.models import CourseEnrollment, UserCelebration
@@ -81,17 +82,15 @@ class CourseHomeMetadataView(RetrieveAPIView):
username = request.user.username if request.user.username else None
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
)
}
enrollment = CourseEnrollment.get_enrollment(request.user, course_key_string)
user_is_enrolled = bool(enrollment)
courseware_meta = CoursewareMeta(course_key, request, request.user.username)
can_load_courseware = courseware_meta.is_microfrontend_enabled_for_user()
browser_timezone = self.request.query_params.get('browser_timezone', None)
celebrations = get_celebrations_dict(request.user, enrollment, course, browser_timezone)
data = {
'course_id': course.id,
'username': username,
@@ -108,5 +107,7 @@ class CourseHomeMetadataView(RetrieveAPIView):
}
context = self.get_serializer_context()
context['course'] = course
context['course_overview'] = course
context['enrollment'] = enrollment
serializer = self.get_serializer_class()(data, context=context)
return Response(serializer.data)

View File

@@ -11,10 +11,15 @@ from urllib.parse import urlencode
import ddt
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from lms.djangoapps.courseware.utils import is_mode_upsellable
from openedx.features.course_experience.url_helpers import get_courseware_url, ExperienceOption
from common.djangoapps.student.tests.factories import AdminFactory, CourseEnrollmentFactory, UserFactory
from common.djangoapps.course_modes.models import CourseMode
from common.djangoapps.course_modes.tests.factories import CourseModeFactory
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory, check_mongo_calls
from .field_overrides import OverrideModulestoreFieldData
@@ -289,3 +294,40 @@ class FieldOverrideTestMixin:
def tearDown(self):
super().tearDown()
OverrideModulestoreFieldData.provider_classes = None
@ddt.ddt
class CoursewareUtilsTests(SharedModuleStoreTestCase):
"""
Tests of the courseware utils file
"""
def setUp(self):
super().setUp()
self.course = CourseFactory.create()
self.user = UserFactory.create()
@ddt.data(
(CourseMode.HONOR, True),
(CourseMode.PROFESSIONAL, False),
(CourseMode.VERIFIED, False),
(CourseMode.AUDIT, True),
(CourseMode.NO_ID_PROFESSIONAL_MODE, False),
(CourseMode.CREDIT_MODE, False),
(CourseMode.MASTERS, False),
(CourseMode.EXECUTIVE_EDUCATION, False),
)
@ddt.unpack
def test_is_mode_upsellable(self, mode, is_upsellable):
"""
Test if this is a mode that is upsellable
"""
CourseModeFactory.create(mode_slug=mode, course_id=self.course.id)
if mode == CourseMode.CREDIT_MODE:
CourseModeFactory.create(mode_slug=CourseMode.VERIFIED, course_id=self.course.id)
enrollment = CourseEnrollmentFactory(
is_active=True,
mode=mode,
course_id=self.course.id,
user=self.user
)
assert is_mode_upsellable(self.user, enrollment) is is_upsellable

View File

@@ -35,6 +35,36 @@ def verified_upgrade_deadline_link(user, course=None, course_id=None):
return EcommerceService().upgrade_url(user, course_id)
def is_mode_upsellable(user, enrollment, course=None):
"""
Return whether the user is enrolled in a mode that can be upselled to another mode,
usually audit upselled to verified.
The partition code allows this function to more accurately return results for masquerading users.
Arguments:
user (:class:`.AuthUser`): The user from the request.user property
enrollment (:class:`.CourseEnrollment`): The enrollment under consideration.
course (:class:`.ModulestoreCourse`): Optional passed in modulestore course.
If provided, it is expected to correspond to `enrollment.course.id`.
If not provided, the course will be loaded from the modulestore.
We use the course to retrieve user partitions when calculating whether
the upgrade link will be shown.
"""
partition_service = PartitionService(enrollment.course.id, course=course)
enrollment_track_partition = partition_service.get_user_partition(ENROLLMENT_TRACK_PARTITION_ID)
group = partition_service.get_group(user, enrollment_track_partition)
current_mode = None
if group:
try:
current_mode = [
mode.get('slug') for mode in settings.COURSE_ENROLLMENT_MODES.values() if mode['id'] == group.id
].pop()
except IndexError:
pass
upsellable_mode = not current_mode or current_mode in CourseMode.UPSELL_TO_VERIFIED_MODES
return upsellable_mode
def can_show_verified_upgrade(user, enrollment, course=None):
"""
Return whether this user can be shown upgrade message.
@@ -51,20 +81,8 @@ def can_show_verified_upgrade(user, enrollment, course=None):
"""
if enrollment is None:
return False # this got accidentally flipped in 2017 (commit 8468357), but leaving alone to not switch again
partition_service = PartitionService(enrollment.course.id, course=course)
enrollment_track_partition = partition_service.get_user_partition(ENROLLMENT_TRACK_PARTITION_ID)
group = partition_service.get_group(user, enrollment_track_partition)
current_mode = None
if group:
try:
current_mode = [
mode.get('slug') for mode in settings.COURSE_ENROLLMENT_MODES.values() if mode['id'] == group.id
].pop()
except IndexError:
pass
upgradable_mode = not current_mode or current_mode in CourseMode.UPSELL_TO_VERIFIED_MODES
if not upgradable_mode:
if not is_mode_upsellable(user, enrollment, course):
return False
upgrade_deadline = enrollment.upgrade_deadline

View File

@@ -11,6 +11,7 @@ from crum import get_current_request
from edx_django_utils.cache import RequestCache
from common.djangoapps.track import segment
from common.djangoapps.course_modes.models import CourseMode
from lms.djangoapps.experiments.stable_bucketing import stable_bucketing_hash_group
from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag
@@ -263,6 +264,21 @@ class ExperimentWaffleFlag(CourseWaffleFlag):
# Mark that we've recorded this bucketing, so that we don't do it again this session
request.session[session_key] = True
# Temporary event for AA-759 experiment
if course_key and self._experiment_name == 'AA-759':
modes_dict = CourseMode.modes_for_course_dict(course_id=course_key, include_expired=False)
verified_mode = modes_dict.get('verified', None)
if verified_mode:
segment.track(
user_id=user.id,
event_name='edx.bi.experiment.AA759.bucketed',
properties={
'course_id': course_key,
'bucket': bucket,
'sku': verified_mode.sku,
}
)
return self._cache_bucket(experiment_name, bucket)
def is_enabled(self, course_key=None):

View File

@@ -17,6 +17,7 @@ from common.djangoapps.student.models import CourseEnrollment
from lms.djangoapps.commerce.utils import EcommerceService
from lms.djangoapps.courseware.access import has_staff_access_to_preview_mode
from lms.djangoapps.courseware.utils import can_show_verified_upgrade, verified_upgrade_deadline_link
from lms.djangoapps.experiments.flags import ExperimentWaffleFlag
from openedx.core.djangoapps.catalog.utils import get_programs
from openedx.core.djangoapps.django_comment_common.models import Role
from openedx.core.djangoapps.schedules.models import Schedule
@@ -73,6 +74,22 @@ UPSELL_TRACKING_FLAG = LegacyWaffleFlag(
)
# TODO END: Clean up as part of REV-1205 (End)
# .. toggle_name: streak_celebration.AA-759
# .. toggle_implementation: ExperimentWaffleFlag
# .. toggle_default: False
# .. toggle_description: This experiment flag enables an engagement discount incentive message.
# .. toggle_warnings: This flag depends on the streak celebration feature being enabled
# .. toggle_use_cases: temporary
# .. toggle_creation_date: 2021-05-05
# .. toggle_target_removal_date: 2021-07-05
# .. toggle_tickets: https://openedx.atlassian.net/browse/AA-759
STREAK_DISCOUNT_EXPERIMENT_FLAG = ExperimentWaffleFlag(
LegacyWaffleFlagNamespace(name='streak_celebration'),
'discount_experiment_AA759',
__name__,
use_course_aware_bucketing=False
)
def check_and_get_upgrade_link_and_date(user, enrollment=None, course=None):
"""

View File

@@ -14,6 +14,8 @@ from django.contrib.auth import get_user_model
from django.test.client import RequestFactory
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.certificates.api import get_certificate_url
from lms.djangoapps.certificates.tests.factories import (
GeneratedCertificateFactory, LinkedInAddToProfileConfigurationFactory
@@ -27,6 +29,8 @@ from lms.djangoapps.courseware.toggles import (
REDIRECT_TO_COURSEWARE_MICROFRONTEND,
COURSEWARE_MICROFRONTEND_SPECIAL_EXAMS,
)
from lms.djangoapps.experiments.testutils import override_experiment_waffle_flag
from lms.djangoapps.experiments.utils import STREAK_DISCOUNT_EXPERIMENT_FLAG
from lms.djangoapps.verify_student.services import IDVerificationService
from common.djangoapps.student.models import (
CourseEnrollment, CourseEnrollmentCelebration
@@ -105,6 +109,14 @@ class CourseApiTestViews(BaseCoursewareTests, MasqueradeMixin):
)
cls.store.update_item(cls.course, cls.user.id)
LinkedInAddToProfileConfigurationFactory.create()
CourseModeFactory(course_id=cls.course.id, mode_slug=CourseMode.AUDIT)
CourseModeFactory(
course_id=cls.course.id,
mode_slug=CourseMode.VERIFIED,
expiration_datetime=datetime(3028, 1, 1),
min_price=149,
sku='ABCD1234',
)
@ddt.data(
(True, None, ACCESS_DENIED),
@@ -285,9 +297,12 @@ class CourseApiTestViews(BaseCoursewareTests, MasqueradeMixin):
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
with override_experiment_waffle_flag(STREAK_DISCOUNT_EXPERIMENT_FLAG, active=True):
with mock.patch('common.djangoapps.student.models.UserCelebration.perform_streak_updates', return_value=3):
response = self.client.get(self.url, content_type='application/json')
celebrations = response.json()['celebrations']
assert celebrations['streak_length_to_celebrate'] == 3
assert celebrations['streak_discount_experiment_enabled'] is True
@ddt.data(
(False, False),

View File

@@ -5,8 +5,40 @@ Courseware API Mixins.
from babel.numbers import get_currency_symbol
from common.djangoapps.course_modes.models import CourseMode
from common.djangoapps.student.models import CourseEnrollmentCelebration, UserCelebration
from lms.djangoapps.courseware.utils import can_show_verified_upgrade, verified_upgrade_deadline_link
from lms.djangoapps.experiments.utils import STREAK_DISCOUNT_EXPERIMENT_FLAG
from openedx.features.course_duration_limits.access import get_user_course_expiration_date
from openedx.features.discounts.applicability import can_show_streak_discount_experiment_coupon
def get_celebrations_dict(user, enrollment, course, browser_timezone):
"""
Returns a dict of celebrations that should be performed.
"""
if not enrollment:
return {
'first_section': False,
'streak_length_to_celebrate': None,
'streak_discount_experiment_enabled': False,
}
streak_length_to_celebrate = UserCelebration.perform_streak_updates(
user, course.id, browser_timezone
)
celebrations = {
'first_section': CourseEnrollmentCelebration.should_celebrate_first_section(enrollment),
'streak_length_to_celebrate': streak_length_to_celebrate,
'streak_discount_experiment_enabled': False,
}
# We only want to bucket people into the AA-759 experiment if they are going to see the streak celebration
if streak_length_to_celebrate:
# We only want to bucket people into the AA-759 experiment
# if the course has not ended, is upgradeable and the user is not an enterprise learner
if can_show_streak_discount_experiment_coupon(user, course):
celebrations['streak_discount_experiment_enabled'] = STREAK_DISCOUNT_EXPERIMENT_FLAG.is_enabled()
return celebrations
def serialize_upgrade_info(user, course_overview, enrollment):

View File

@@ -46,6 +46,7 @@ from lms.djangoapps.verify_student.services import IDVerificationService
from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser
from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin
from openedx.core.lib.courses import get_course_by_id
from openedx.core.djangoapps.courseware_api.utils import get_celebrations_dict
from openedx.core.djangoapps.programs.utils import ProgramProgressMeter
from openedx.features.course_experience import DISPLAY_COURSE_SOCK_FLAG
from openedx.features.content_type_gating.models import ContentTypeGatingConfig
@@ -54,8 +55,7 @@ from openedx.features.discounts.utils import generate_offer_data
from common.djangoapps.student.models import (
CourseEnrollment,
CourseEnrollmentCelebration,
LinkedInAddToProfileConfiguration,
UserCelebration
LinkedInAddToProfileConfiguration
)
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.search import path_to_location
@@ -90,6 +90,7 @@ class CoursewareMeta:
self.original_user_is_staff = has_access(self.request.user, 'staff', self.overview).has_access
self.original_user_is_global_staff = self.request.user.is_staff
self.course_key = course_key
self.course = get_course_by_id(self.course_key)
self.course_masquerade, self.effective_user = setup_masquerade(
self.request,
course_key,
@@ -209,15 +210,11 @@ class CoursewareMeta:
@property
def celebrations(self):
"""
Returns a list of celebrations that should be performed.
Returns a dict 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
),
}
celebrations = get_celebrations_dict(self.effective_user, self.enrollment_object, self.course, browser_timezone)
return celebrations
@property
def user_has_passing_grade(self):

View File

@@ -19,6 +19,7 @@ from edx_toggles.toggles import LegacyWaffleFlag, LegacyWaffleFlagNamespace
from common.djangoapps.course_modes.models import CourseMode
from common.djangoapps.entitlements.models import CourseEntitlement
from lms.djangoapps.courseware.utils import is_mode_upsellable
from lms.djangoapps.experiments.models import ExperimentData
from lms.djangoapps.experiments.stable_bucketing import stable_bucketing_hash_group
from openedx.features.discounts.models import DiscountPercentageConfig, DiscountRestrictionConfig
@@ -82,6 +83,41 @@ def get_discount_expiration_date(user, course):
return discount_expiration_date
def can_show_streak_discount_experiment_coupon(user, course):
"""
Check whether this combination of user and course
can receive the AA-759 experiment discount.
"""
# Course end date needs to be in the future
if course.has_ended():
return False
# Course needs to have a non-expired verified mode
modes_dict = CourseMode.modes_for_course_dict(course=course, include_expired=False)
if 'verified' not in modes_dict:
return False
# Learner needs to be in an upgradeable mode
try:
enrollment = CourseEnrollment.objects.get(
user=user,
course=course.id,
)
except CourseEnrollment.DoesNotExist:
return False
if not is_mode_upsellable(user, enrollment):
return False
# We can't import this at Django load time within the openedx tests settings context
from openedx.features.enterprise_support.utils import is_enterprise_learner
# Don't give discount to enterprise users
if is_enterprise_learner(user):
return False
return True
def can_receive_discount(user, course, discount_expiration_date=None):
"""
Check all the business logic about whether this combination of user and course