From 8bb52f177f5dceb52ca44298f0bdde3878bb68e3 Mon Sep 17 00:00:00 2001 From: Ben Holt Date: Tue, 10 Sep 2019 14:08:54 -0400 Subject: [PATCH] REV-935 add api endpoint to decide if mobile should show upsell (#21612) Add experimental api endpoint for mobile upsell experiment, more unit tests coming soon --- .../experiments/tests/test_views_custom.py | 32 ++++ lms/djangoapps/experiments/urls.py | 3 +- lms/djangoapps/experiments/views_custom.py | 169 ++++++++++++++++++ 3 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 lms/djangoapps/experiments/tests/test_views_custom.py create mode 100644 lms/djangoapps/experiments/views_custom.py diff --git a/lms/djangoapps/experiments/tests/test_views_custom.py b/lms/djangoapps/experiments/tests/test_views_custom.py new file mode 100644 index 0000000000..c375d5e3bc --- /dev/null +++ b/lms/djangoapps/experiments/tests/test_views_custom.py @@ -0,0 +1,32 @@ +""" +Tests for experimentation views +""" +from __future__ import absolute_import + +from django.urls import reverse +from rest_framework.test import APITestCase + +from lms.djangoapps.course_blocks.transformers.tests.helpers import ModuleStoreTestCase +from student.tests.factories import UserFactory + +CROSS_DOMAIN_REFERER = 'https://ecommerce.edx.org' + + +class Rev934Tests(APITestCase, ModuleStoreTestCase): + def test_logged_in(self): + """Test mobile app upsell API""" + url = reverse('api_experiments:rev_934') + user = UserFactory() + + # Not-logged-in returns 401 + response = self.client.get(url) + self.assertEqual(response.status_code, 401) + + # No-course-id returns show_upsell false + self.client.login( + username=user.username, + password=UserFactory._DEFAULT_PASSWORD, # pylint: disable=protected-access + ) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data['show_upsell'], False) diff --git a/lms/djangoapps/experiments/urls.py b/lms/djangoapps/experiments/urls.py index 0b1f8bacc1..f0a013611c 100644 --- a/lms/djangoapps/experiments/urls.py +++ b/lms/djangoapps/experiments/urls.py @@ -5,7 +5,7 @@ from __future__ import absolute_import from django.conf.urls import include, url -from experiments import routers, views +from experiments import routers, views, views_custom router = routers.DefaultRouter() router.register(r'data', views.ExperimentDataViewSet, base_name='data') @@ -13,5 +13,6 @@ router.register(r'key-value', views.ExperimentKeyValueViewSet, base_name='key_va app_name = 'experiments' urlpatterns = [ + url(r'^v0/custom/REV-934/', views_custom.Rev934.as_view(), name='rev_934'), url(r'^v0/', include(router.urls, namespace='v0')), ] diff --git a/lms/djangoapps/experiments/views_custom.py b/lms/djangoapps/experiments/views_custom.py new file mode 100644 index 0000000000..f18efbb6b7 --- /dev/null +++ b/lms/djangoapps/experiments/views_custom.py @@ -0,0 +1,169 @@ +""" +The Discount API Views should return information about discounts that apply to the user and course. + +""" +# -*- coding: utf-8 -*- + +from __future__ import absolute_import + +from django.utils.decorators import method_decorator +from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication +from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser +from opaque_keys.edx.keys import CourseKey +from rest_framework.response import Response +from rest_framework.views import APIView + +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview +from openedx.core.djangoapps.cors_csrf.decorators import ensure_csrf_cookie_cross_domain +from openedx.core.djangoapps.waffle_utils import WaffleFlag, WaffleFlagNamespace +from openedx.core.lib.api.authentication import OAuth2AuthenticationAllowInactiveUser +from openedx.core.lib.api.permissions import ApiKeyHeaderPermissionIsAuthenticated +from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin + +from course_modes.models import CourseMode +from lms.djangoapps.experiments.utils import get_base_experiment_metadata_context +from lms.djangoapps.experiments.stable_bucketing import stable_bucketing_hash_group +from student.models import CourseEnrollment +from track import segment + + +# .. feature_toggle_name: experiments.mobile_upsell_rev934 +# .. feature_toggle_type: flag +# .. feature_toggle_default: False +# .. feature_toggle_description: Toggle mobile upsell enabled +# .. feature_toggle_category: experiments +# .. feature_toggle_use_cases: monitored_rollout +# .. feature_toggle_creation_date: 2019-09-05 +# .. feature_toggle_expiration_date: None +# .. feature_toggle_warnings: None +# .. feature_toggle_tickets: REV-934 +# .. feature_toggle_status: supported +MOBILE_UPSELL_FLAG = WaffleFlag( + waffle_namespace=WaffleFlagNamespace(name=u'experiments'), + flag_name=u'mobile_upsell_rev934', + flag_undefined_default=False +) +MOBILE_UPSELL_EXPERIMENT = 'mobile_upsell_experiment' + + +class Rev934(DeveloperErrorViewMixin, APIView): + """ + **Use Cases** + + Request upsell information for mobile app users + + **Example Requests** + + GET /api/experiments/v0/custom/REV-934/?course_id={course_key_string} + + **Response Values** + + Body consists of the following fields: + show_upsell: + whether to show upsell in the moble app in this case + price: + (optional) the price to show if show_upsell is true + basket_url: + (optional) the url to the checkout page with the course's sku if show_upsell is true + upsell_flag: + (optional) false if the upsell flag is off, not present otherwise + + Response: + { + "show_upsell": true, + "price": "$199", + "basket_url": "https://ecommerce.edx.org/basket/add?sku=abcdef" + } + + **Parameters:** + + course_key_string: + The course key that may be upsold + + **Returns** + + * 200 on success with above fields. + * 401 if there is no user signed in. + + Example response: + { + "show_upsell": true, + "price": "$199", + "basket_url": "https://ecommerce.edx.org/basket/add?sku=abcdef" + } + """ + # http://localhost:18000/api/experiments/v0/custom/REV-934/?course_id=course-v1:edX+DemoX+Demo_Course + + authentication_classes = ( + JwtAuthentication, + OAuth2AuthenticationAllowInactiveUser, + SessionAuthenticationAllowInactiveUser, + ) + permission_classes = (ApiKeyHeaderPermissionIsAuthenticated,) + + @method_decorator(ensure_csrf_cookie_cross_domain) + def get(self, request): + """ + Return the if the course should be upsold in the mobile app, if the user has appropriate permissions. + """ + if not MOBILE_UPSELL_FLAG.is_enabled(): + return Response({ + 'show_upsell': False, + 'upsell_flag': False, + }) + + if 'course_id' not in request.GET: + return Response({ + 'show_upsell': False, + }) + + # HACK: the url decoding converts plus to space; put them back + course_id = request.GET.get('course_id').replace(' ', '+') + course_key = CourseKey.from_string(course_id) + course = CourseOverview.get_from_id(course_key) + user = request.user + + enrollment = None + user_enrollments = None + has_non_audit_enrollments = False + try: + user_enrollments = CourseEnrollment.objects.select_related('course').filter(user_id=user.id) + has_non_audit_enrollments = user_enrollments.exclude(mode__in=CourseMode.UPSELL_TO_VERIFIED_MODES).exists() + enrollment = CourseEnrollment.objects.select_related( + 'course' + ).get(user_id=user.id, course_id=course.id) + except CourseEnrollment.DoesNotExist: + pass # Not enrolled, use the default values + + context = get_base_experiment_metadata_context(course, user, enrollment, user_enrollments) + + bucket = stable_bucketing_hash_group(MOBILE_UPSELL_EXPERIMENT, 2, user.username) + if hasattr(request, 'session') and MOBILE_UPSELL_EXPERIMENT not in request.session: + properties = { + 'site': request.site.domain, + 'app_label': 'experiments', + 'bucket': bucket, + 'experiment': 'REV-934', + } + segment.track( + user_id=user.id, + event_name='edx.bi.experiment.user.bucketed', + properties=properties, + ) + + # Mark that we've recorded this bucketing, so that we don't do it again this session + request.session[MOBILE_UPSELL_EXPERIMENT] = True + + show_upsell = bucket != 0 and not has_non_audit_enrollments + if show_upsell: + return Response({ + 'show_upsell': show_upsell, + 'price': context.get('upgrade_price'), + 'basket_url': context.get('upgrade_link'), + }) + else: + return Response({ + 'show_upsell': show_upsell, + 'upsell_flag': MOBILE_UPSELL_FLAG.is_enabled(), + 'experiment_bucket': bucket, + })