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 bd72cf10c7..61955cd36d 100644 --- a/lms/djangoapps/course_home_api/course_metadata/v1/serializers.py +++ b/lms/djangoapps/course_home_api/course_metadata/v1/serializers.py @@ -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 """ 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 0afa1d5ba9..4272ae5c70 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 @@ -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 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 dd9b3b5295..78a323d810 100644 --- a/lms/djangoapps/course_home_api/course_metadata/v1/views.py +++ b/lms/djangoapps/course_home_api/course_metadata/v1/views.py @@ -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) diff --git a/lms/djangoapps/courseware/testutils.py b/lms/djangoapps/courseware/testutils.py index c89c7dcc9d..6c4caa6be1 100644 --- a/lms/djangoapps/courseware/testutils.py +++ b/lms/djangoapps/courseware/testutils.py @@ -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 diff --git a/lms/djangoapps/courseware/utils.py b/lms/djangoapps/courseware/utils.py index 308e7235cd..6ed5be423b 100644 --- a/lms/djangoapps/courseware/utils.py +++ b/lms/djangoapps/courseware/utils.py @@ -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 diff --git a/lms/djangoapps/experiments/flags.py b/lms/djangoapps/experiments/flags.py index 8b1bd8c36c..e2783e2f49 100644 --- a/lms/djangoapps/experiments/flags.py +++ b/lms/djangoapps/experiments/flags.py @@ -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): diff --git a/lms/djangoapps/experiments/utils.py b/lms/djangoapps/experiments/utils.py index e6e657ff4c..824ec46aa0 100644 --- a/lms/djangoapps/experiments/utils.py +++ b/lms/djangoapps/experiments/utils.py @@ -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): """ diff --git a/openedx/core/djangoapps/courseware_api/tests/test_views.py b/openedx/core/djangoapps/courseware_api/tests/test_views.py index c9a5b31e18..e7cdc71f1d 100644 --- a/openedx/core/djangoapps/courseware_api/tests/test_views.py +++ b/openedx/core/djangoapps/courseware_api/tests/test_views.py @@ -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), diff --git a/openedx/core/djangoapps/courseware_api/utils.py b/openedx/core/djangoapps/courseware_api/utils.py index 7c916b61a5..4c23c701fd 100644 --- a/openedx/core/djangoapps/courseware_api/utils.py +++ b/openedx/core/djangoapps/courseware_api/utils.py @@ -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): diff --git a/openedx/core/djangoapps/courseware_api/views.py b/openedx/core/djangoapps/courseware_api/views.py index 70681c4414..d29f2c9903 100644 --- a/openedx/core/djangoapps/courseware_api/views.py +++ b/openedx/core/djangoapps/courseware_api/views.py @@ -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): diff --git a/openedx/features/discounts/applicability.py b/openedx/features/discounts/applicability.py index e11f288c01..f9f48908cf 100644 --- a/openedx/features/discounts/applicability.py +++ b/openedx/features/discounts/applicability.py @@ -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