diff --git a/lms/envs/common.py b/lms/envs/common.py index 8557225763..c5f8235c69 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -2279,6 +2279,7 @@ INSTALLED_APPS = [ 'openedx.features.learner_profile', 'openedx.features.course_duration_limits', 'openedx.features.content_type_gating', + 'openedx.features.discounts', 'experiments', diff --git a/lms/urls.py b/lms/urls.py index 2471ca224d..57199e19ff 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -139,6 +139,7 @@ urlpatterns = [ url(r'^dashboard/', include('learner_dashboard.urls')), url(r'^api/experiments/', include('experiments.urls', namespace='api_experiments')), + url(r'^api/discounts/', include('openedx.features.discounts.urls', namespace='api_discounts')), ] if settings.FEATURES.get('ENABLE_MOBILE_REST_API'): diff --git a/openedx/features/discounts/__init__.py b/openedx/features/discounts/__init__.py new file mode 100644 index 0000000000..2b2e8e7df6 --- /dev/null +++ b/openedx/features/discounts/__init__.py @@ -0,0 +1,5 @@ +""" +Discounts are determined by a combination of user and course, and have a one to one relationship with the enrollment +(if already enrolled) or a join table of user and course. They are determined in LMS, because all of the data for +the business rules exists here. Discount rules are meant to be permanent. +""" diff --git a/openedx/features/discounts/applicability.py b/openedx/features/discounts/applicability.py new file mode 100644 index 0000000000..76b21fc61c --- /dev/null +++ b/openedx/features/discounts/applicability.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +""" +Contains code related to computing discount percentage +and discount applicability. +""" +from openedx.core.djangoapps.waffle_utils import WaffleFlag, WaffleFlagNamespace + +# .. feature_toggle_name: discounts.enable_discounting +# .. feature_toggle_type: flag +# .. feature_toggle_default: False +# .. feature_toggle_description: Toggle discounts always being disabled +# .. feature_toggle_category: discounts +# .. feature_toggle_use_cases: monitored_rollout +# .. feature_toggle_creation_date: 2019-4-16 +# .. feature_toggle_expiration_date: None +# .. feature_toggle_warnings: None +# .. feature_toggle_tickets: REVEM-282 +# .. feature_toggle_status: supported +DISCOUNT_APPLICABILITY_FLAG = WaffleFlag( + waffle_namespace=WaffleFlagNamespace(name=u'discounts'), + flag_name=u'enable_discounting', + flag_undefined_default=False +) + + +def can_recieve_discount(user, course_key_string): # pylint: disable=unused-argument + """ + Check all the business logic about whether this combination of user and course + can recieve a discount. + """ + # Always disable discounts until we are ready to enable this feature + if not DISCOUNT_APPLICABILITY_FLAG.is_enabled(): + return False + + # TODO: Add additional conditions to return False here + + return True + + +def discount_percentage(): + """ + Get the configured discount amount. + """ + # TODO: Add configuration information here + return 15 diff --git a/openedx/features/discounts/apps.py b/openedx/features/discounts/apps.py new file mode 100644 index 0000000000..f9380cee0b --- /dev/null +++ b/openedx/features/discounts/apps.py @@ -0,0 +1,11 @@ + +""" +Discounts application configuration +""" +# -*- coding: utf-8 -*- + +from django.apps import AppConfig + + +class DiscountsConfig(AppConfig): + name = 'openedx.features.discounts' diff --git a/openedx/features/discounts/tests/__init__.py b/openedx/features/discounts/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openedx/features/discounts/tests/test_applicability.py b/openedx/features/discounts/tests/test_applicability.py new file mode 100644 index 0000000000..74cd06b00d --- /dev/null +++ b/openedx/features/discounts/tests/test_applicability.py @@ -0,0 +1,25 @@ +"""Tests of openedx.features.discounts.applicability""" +# -*- coding: utf-8 -*- + +from student.tests.factories import UserFactory +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory + +from ..applicability import can_recieve_discount + + +class TestApplicability(ModuleStoreTestCase): + """ + Applicability determines if this combination of user and course can receive a discount. Make + sure that all of the business conditions work. + """ + + def setUp(self): + super(TestApplicability, self).setUp() + self.user = UserFactory.create() + self.course = CourseFactory.create(run='test', display_name='test') + + def test_can_recieve_discount(self): + # Right now, no one should be able to recieve the discount + applicability = can_recieve_discount(user=self.user, course_key_string=self.course.id) + self.assertEqual(applicability, False) diff --git a/openedx/features/discounts/tests/test_views.py b/openedx/features/discounts/tests/test_views.py new file mode 100644 index 0000000000..402026f4f3 --- /dev/null +++ b/openedx/features/discounts/tests/test_views.py @@ -0,0 +1,55 @@ +"""Tests of openedx.features.discounts.views""" +# -*- coding: utf-8 -*- +import jwt + +from django.test.client import Client +from django.urls import reverse + +from xmodule.modulestore.tests.factories import CourseFactory +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from student.tests.factories import UserFactory, TEST_PASSWORD + + +class TestCourseUserDiscount(ModuleStoreTestCase): + """ + CourseUserDiscount should return a jwt with the information if this combination of user and + course can receive a discount, and how much that discount should be. + """ + + def setUp(self): + super(TestCourseUserDiscount, self).setUp() + self.user = UserFactory.create() + self.course = CourseFactory.create(run='test', display_name='test') + self.client = Client() + self.url = reverse('api_discounts:course_user_discount', kwargs={'course_key_string': unicode(self.course.id)}) + + def test_url(self): + """ + Test that the url hasn't changed + """ + assert self.url == ('/api/discounts/course/' + unicode(self.course.id)) + + def test_course_user_discount(self): + """ + Test that the api returns a jwt with the discount information + """ + self.client.login(username=self.user.username, password=TEST_PASSWORD) + # the endpoint should return a 200 if all goes well + response = self.client.get(self.url) + assert response.status_code == 200 + + # for now, it should always return false + expected_payload = {'discount_applicable': False, 'discount_percent': 15} + assert expected_payload['discount_applicable'] == response.data['discount_applicable'] + + # make sure that the response matches the expected response + response_payload = jwt.decode(response.data['jwt'], verify=False) + assert all(item in response_payload.items() for item in expected_payload.items()) + + def test_course_user_discount_no_user(self): + """ + Test that the endpoint returns a 401 if there is no user signed in + """ + # the endpoint should return a 401 because the user is not logged in + response = self.client.get(self.url) + assert response.status_code == 401 diff --git a/openedx/features/discounts/urls.py b/openedx/features/discounts/urls.py new file mode 100644 index 0000000000..239702ae4a --- /dev/null +++ b/openedx/features/discounts/urls.py @@ -0,0 +1,11 @@ +""" +Discount API URLs +""" +from django.conf import settings +from django.conf.urls import url + +from .views import CourseUserDiscount + +urlpatterns = [ + url(r'^course/{}'.format(settings.COURSE_KEY_PATTERN), CourseUserDiscount.as_view(), name='course_user_discount'), +] diff --git a/openedx/features/discounts/views.py b/openedx/features/discounts/views.py new file mode 100644 index 0000000000..bfcf0f7374 --- /dev/null +++ b/openedx/features/discounts/views.py @@ -0,0 +1,73 @@ +""" +The Discount API Views should return information about discounts that apply to the user and course. + +""" +# -*- coding: utf-8 -*- + +from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication +from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser + +from openedx.core.djangoapps.cors_csrf.decorators import ensure_csrf_cookie_cross_domain +from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user +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 rest_framework.response import Response +from rest_framework.views import APIView +from django.utils.decorators import method_decorator + +from .applicability import can_recieve_discount, discount_percentage + + +class CourseUserDiscount(DeveloperErrorViewMixin, APIView): + """ + **Use Cases** + + Request discount information for a user and course + + **Example Requests** + + GET /api/discounts/v1/course/{course_key_string} + + **Response Values** + + Body consists of the following fields: + discount_applicable: + whether the user can recieve a discount for this course + jwt: + the jwt with user information and discount information + + **Parameters:** + + course_key_string: + The course key for the which the discount should be applied + + **Returns** + + * 200 on success with above fields. + * 401 if there is no user signed in. + + Example response: + { + "discount_applicable": false, + "jwt": xxxxxxxx.xxxxxxxx.xxxxxxx + } + """ + authentication_classes = (JwtAuthentication, OAuth2AuthenticationAllowInactiveUser, + SessionAuthenticationAllowInactiveUser,) + permission_classes = (ApiKeyHeaderPermissionIsAuthenticated,) + + # Since the course about page on the marketing site uses this API to auto-enroll users, + # we need to support cross-domain CSRF. + @method_decorator(ensure_csrf_cookie_cross_domain) + def get(self, request, course_key_string): + """ + Return the discount percent, if the user has appropriate permissions. + """ + discount_applicable = can_recieve_discount(user=request.user, course_key_string=course_key_string) + discount_percent = discount_percentage() + payload = {'discount_applicable': discount_applicable, 'discount_percent': discount_percent} + return Response({ + 'discount_applicable': discount_applicable, + 'jwt': create_jwt_for_user(request.user, additional_claims=payload)})