Commit 33330966 by Brandon DeRosier

ENT-134 Add consent gate when redeeming coupons from enterprise customers

parent c1b5a22b
...@@ -283,6 +283,15 @@ class SiteConfiguration(models.Model): ...@@ -283,6 +283,15 @@ class SiteConfiguration(models.Model):
""" """
return urljoin(self.lms_url_root, path) return urljoin(self.lms_url_root, path)
def build_enterprise_service_url(self, path=''):
"""
Returns path joined with the appropriate Enterprise service URL root for the current site.
Returns:
str
"""
return urljoin(settings.ENTERPRISE_SERVICE_URL, path)
@property @property
def commerce_api_url(self): def commerce_api_url(self):
""" Returns the URL for the root of the Commerce API (hosted by LMS). """ """ Returns the URL for the root of the Commerce API (hosted by LMS). """
...@@ -311,7 +320,12 @@ class SiteConfiguration(models.Model): ...@@ -311,7 +320,12 @@ class SiteConfiguration(models.Model):
@property @property
def enterprise_api_url(self): def enterprise_api_url(self):
""" Returns the URL for the Enterprise service. """ """ Returns the URL for the Enterprise service. """
return self.build_lms_url('/enterprise/api/v1') return settings.ENTERPRISE_API_URL
@property
def enterprise_grant_data_sharing_url(self):
""" Returns the URL for the Enterprise data sharing permission view. """
return self.build_enterprise_service_url('grant_data_sharing_permissions')
@property @property
def access_token(self): def access_token(self):
...@@ -365,7 +379,7 @@ class SiteConfiguration(models.Model): ...@@ -365,7 +379,7 @@ class SiteConfiguration(models.Model):
EdxRestApiClient: The client to access the Enterprise service. EdxRestApiClient: The client to access the Enterprise service.
""" """
return EdxRestApiClient(settings.ENTERPRISE_API_URL, jwt=self.access_token) return EdxRestApiClient(self.enterprise_api_url, jwt=self.access_token)
@cached_property @cached_property
def user_api_client(self): def user_api_client(self):
......
...@@ -18,6 +18,11 @@ from ecommerce.core.url_utils import get_lms_url ...@@ -18,6 +18,11 @@ from ecommerce.core.url_utils import get_lms_url
from ecommerce.coupons.tests.mixins import CouponMixin from ecommerce.coupons.tests.mixins import CouponMixin
from ecommerce.coupons.views import voucher_is_valid from ecommerce.coupons.views import voucher_is_valid
from ecommerce.courses.tests.factories import CourseFactory from ecommerce.courses.tests.factories import CourseFactory
from ecommerce.enterprise.tests.mixins import EnterpriseServiceMockMixin
from ecommerce.enterprise.utils import (
get_enterprise_course_consent_url,
get_enterprise_customer_data_sharing_consent_token,
)
from ecommerce.extensions.api import exceptions from ecommerce.extensions.api import exceptions
from ecommerce.extensions.catalogue.tests.mixins import CourseCatalogTestMixin from ecommerce.extensions.catalogue.tests.mixins import CourseCatalogTestMixin
from ecommerce.extensions.test.factories import prepare_voucher from ecommerce.extensions.test.factories import prepare_voucher
...@@ -25,6 +30,7 @@ from ecommerce.extensions.voucher.utils import get_voucher_and_products_from_cod ...@@ -25,6 +30,7 @@ from ecommerce.extensions.voucher.utils import get_voucher_and_products_from_cod
from ecommerce.tests.mixins import ApiMockMixin, LmsApiMockMixin from ecommerce.tests.mixins import ApiMockMixin, LmsApiMockMixin
from ecommerce.tests.testcases import TestCase from ecommerce.tests.testcases import TestCase
Applicator = get_class('offer.utils', 'Applicator') Applicator = get_class('offer.utils', 'Applicator')
Basket = get_model('basket', 'Basket') Basket = get_model('basket', 'Basket')
Benefit = get_model('offer', 'Benefit') Benefit = get_model('offer', 'Benefit')
...@@ -38,6 +44,7 @@ VoucherApplication = get_model('voucher', 'VoucherApplication') ...@@ -38,6 +44,7 @@ VoucherApplication = get_model('voucher', 'VoucherApplication')
CONTENT_TYPE = 'application/json' CONTENT_TYPE = 'application/json'
COUPON_CODE = 'COUPONTEST' COUPON_CODE = 'COUPONTEST'
ENTERPRISE_CUSTOMER = 'cf246b88-d5f6-4908-a522-fc307e0b0c59'
class CouponAppViewTests(TestCase): class CouponAppViewTests(TestCase):
...@@ -264,7 +271,8 @@ class CouponOfferViewTests(ApiMockMixin, CouponMixin, CourseCatalogTestMixin, Lm ...@@ -264,7 +271,8 @@ class CouponOfferViewTests(ApiMockMixin, CouponMixin, CourseCatalogTestMixin, Lm
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
class CouponRedeemViewTests(CouponMixin, CourseCatalogTestMixin, LmsApiMockMixin, TestCase): class CouponRedeemViewTests(CouponMixin, CourseCatalogTestMixin, LmsApiMockMixin, EnterpriseServiceMockMixin,
TestCase):
redeem_url = reverse('coupons:redeem') redeem_url = reverse('coupons:redeem')
def setUp(self): def setUp(self):
...@@ -283,27 +291,40 @@ class CouponRedeemViewTests(CouponMixin, CourseCatalogTestMixin, LmsApiMockMixin ...@@ -283,27 +291,40 @@ class CouponRedeemViewTests(CouponMixin, CourseCatalogTestMixin, LmsApiMockMixin
self.catalog.stock_records.add(StockRecord.objects.get(product=self.seat)) self.catalog.stock_records.add(StockRecord.objects.get(product=self.seat))
self.student_dashboard_url = get_lms_url(self.site.siteconfiguration.student_dashboard_url) self.student_dashboard_url = get_lms_url(self.site.siteconfiguration.student_dashboard_url)
def redeem_url_with_params(self, code=COUPON_CODE): def redeem_url_with_params(self, code=COUPON_CODE, consent_token=None):
""" Constructs the coupon redemption URL with the proper string query parameters. """ """ Constructs the coupon redemption URL with the proper string query parameters. """
return self.redeem_url + '?code={}&sku={}'.format(code, self.stock_record.partner_sku) params = {
'code': code,
def create_and_test_coupon_and_return_code(self, benefit_value=90, code=COUPON_CODE, email_domains=None): 'sku': self.stock_record.partner_sku,
}
if consent_token is not None:
params['consent_token'] = consent_token
return '{base}?{params}'.format(base=self.redeem_url, params=urllib.urlencode(params))
def create_and_test_coupon_and_return_code(
self,
benefit_value=90,
code=COUPON_CODE,
email_domains=None,
enterprise_customer=None
):
""" Creates coupon and returns code. """ """ Creates coupon and returns code. """
coupon = self.create_coupon( coupon = self.create_coupon(
benefit_value=benefit_value, benefit_value=benefit_value,
catalog=self.catalog, catalog=self.catalog,
code=code, code=code,
email_domains=email_domains email_domains=email_domains,
enterprise_customer=enterprise_customer
) )
coupon_code = coupon.attr.coupon_vouchers.vouchers.first().code coupon_code = coupon.attr.coupon_vouchers.vouchers.first().code
self.assertEqual(Voucher.objects.filter(code=coupon_code).count(), 1) self.assertEqual(Voucher.objects.filter(code=coupon_code).count(), 1)
return coupon_code return coupon_code
def assert_redemption_page_redirects(self, expected_url, target=200, code=COUPON_CODE): def assert_redemption_page_redirects(self, expected_url, target=200, code=COUPON_CODE, consent_token=None):
""" Verify redirect from redeem page to expected page. """ """ Verify redirect from redeem page to expected page. """
self.request.user = self.user self.request.user = self.user
self.mock_enrollment_api(self.request, self.user, self.course.id, is_active=False, mode=self.course_mode) self.mock_enrollment_api(self.request, self.user, self.course.id, is_active=False, mode=self.course_mode)
response = self.client.get(self.redeem_url_with_params(code=code)) response = self.client.get(self.redeem_url_with_params(code=code, consent_token=consent_token))
self.assertRedirects(response, expected_url, status_code=302, target_status_code=target) self.assertRedirects(response, expected_url, status_code=302, target_status_code=target)
def test_login_required(self): def test_login_required(self):
...@@ -376,6 +397,107 @@ class CouponRedeemViewTests(CouponMixin, CourseCatalogTestMixin, LmsApiMockMixin ...@@ -376,6 +397,107 @@ class CouponRedeemViewTests(CouponMixin, CourseCatalogTestMixin, LmsApiMockMixin
) )
@httpretty.activate @httpretty.activate
def test_enterprise_customer_redirect_no_consent(self):
""" Verify the view redirects to LMS when an enrollment code is provided. """
code = self.create_and_test_coupon_and_return_code(
benefit_value=100,
code='',
enterprise_customer=ENTERPRISE_CUSTOMER
)
self.request.user = self.user
self.mock_enrollment_api(self.request, self.user, self.course.id, is_active=False, mode=self.course_mode)
self.mock_account_api(self.request, self.user.username, data={'is_active': True})
self.mock_access_token_response()
self.mock_specific_enterprise_customer_api(ENTERPRISE_CUSTOMER)
consent_token = get_enterprise_customer_data_sharing_consent_token(
self.request.user.access_token,
self.course.id,
ENTERPRISE_CUSTOMER
)
expected_url = get_enterprise_course_consent_url(
self.site,
code,
self.stock_record.partner_sku,
consent_token,
self.course.id,
ENTERPRISE_CUSTOMER
)
response = self.client.get(self.redeem_url_with_params(code=code))
self.assertEqual(response.status_code, status.HTTP_302_FOUND)
self.assertEqual(response.url, expected_url)
@httpretty.activate
def test_enterprise_customer_invalid_consent_token(self):
""" Verify that the view renders an error when the consent token doesn't match. """
code = self.create_and_test_coupon_and_return_code(
benefit_value=100,
code='',
enterprise_customer=ENTERPRISE_CUSTOMER
)
self.request.user = self.user
self.mock_enrollment_api(self.request, self.user, self.course.id, is_active=False, mode=self.course_mode)
self.mock_account_api(self.request, self.user.username, data={'is_active': True})
self.mock_access_token_response()
self.mock_specific_enterprise_customer_api(ENTERPRISE_CUSTOMER)
response = self.client.get(self.redeem_url_with_params(code=code, consent_token='invalid_consent_token'))
self.assertEqual(response.context['error'], 'Invalid data sharing consent token provided.')
@httpretty.activate
def test_enterprise_customer_does_not_exist(self):
"""
Verify that a generic error is rendered when the corresponding EnterpriseCustomer doesn't exist
on the Enterprise service.
"""
code = self.create_and_test_coupon_and_return_code(
benefit_value=100,
code='',
enterprise_customer=ENTERPRISE_CUSTOMER
)
self.request.user = self.user
self.mock_enrollment_api(self.request, self.user, self.course.id, is_active=False, mode=self.course_mode)
self.mock_account_api(self.request, self.user.username, data={'is_active': True})
self.mock_access_token_response()
self.mock_enterprise_customer_api_not_found(ENTERPRISE_CUSTOMER)
response = self.client.get(self.redeem_url_with_params(code=code))
self.assertEqual(response.context['error'], 'Couldn\'t find a matching Enterprise Customer for this coupon.')
@httpretty.activate
def test_enterprise_customer_successful_redemption(self):
""" Verify the view redirects to LMS when valid consent is provided. """
code = self.create_and_test_coupon_and_return_code(
benefit_value=100,
code='',
enterprise_customer=ENTERPRISE_CUSTOMER
)
self.request.user = self.user
self.mock_enrollment_api(self.request, self.user, self.course.id, is_active=False, mode=self.course_mode)
self.mock_account_api(self.request, self.user.username, data={'is_active': True})
self.mock_access_token_response()
self.mock_specific_enterprise_customer_api(ENTERPRISE_CUSTOMER)
self.mock_enterprise_learner_api_for_learner_with_no_enterprise()
self.mock_enterprise_learner_post_api()
consent_token = get_enterprise_customer_data_sharing_consent_token(
self.request.user.access_token,
self.course.id,
ENTERPRISE_CUSTOMER
)
self.assert_redemption_page_redirects(
self.student_dashboard_url,
target=status.HTTP_301_MOVED_PERMANENTLY,
code=code,
consent_token=consent_token,
)
last_request = httpretty.last_request()
self.assertEqual(last_request.path, '/api/enrollment/v1/enrollment')
self.assertEqual(last_request.method, 'POST')
@httpretty.activate
def test_multiple_vouchers(self): def test_multiple_vouchers(self):
""" Verify a redirect to LMS happens when a basket with already existing vouchers is used. """ """ Verify a redirect to LMS happens when a basket with already existing vouchers is used. """
code = self.create_and_test_coupon_and_return_code(benefit_value=100, code='') code = self.create_and_test_coupon_and_return_code(benefit_value=100, code='')
......
...@@ -19,6 +19,12 @@ from ecommerce.core.url_utils import get_ecommerce_url ...@@ -19,6 +19,12 @@ from ecommerce.core.url_utils import get_ecommerce_url
from ecommerce.core.views import StaffOnlyMixin from ecommerce.core.views import StaffOnlyMixin
from ecommerce.coupons.decorators import login_required_for_credit from ecommerce.coupons.decorators import login_required_for_credit
from ecommerce.extensions.api import exceptions from ecommerce.extensions.api import exceptions
from ecommerce.enterprise.exceptions import EnterpriseDoesNotExist
from ecommerce.enterprise.utils import (
get_enterprise_course_consent_url,
get_enterprise_customer_data_sharing_consent_token,
get_enterprise_customer_from_voucher,
)
from ecommerce.extensions.basket.utils import prepare_basket from ecommerce.extensions.basket.utils import prepare_basket
from ecommerce.extensions.checkout.mixins import EdxOrderPlacementMixin from ecommerce.extensions.checkout.mixins import EdxOrderPlacementMixin
from ecommerce.extensions.voucher.utils import get_voucher_and_products_from_code from ecommerce.extensions.voucher.utils import get_voucher_and_products_from_code
...@@ -26,6 +32,7 @@ from ecommerce.extensions.voucher.utils import get_voucher_and_products_from_cod ...@@ -26,6 +32,7 @@ from ecommerce.extensions.voucher.utils import get_voucher_and_products_from_cod
Applicator = get_class('offer.utils', 'Applicator') Applicator = get_class('offer.utils', 'Applicator')
Basket = get_model('basket', 'Basket') Basket = get_model('basket', 'Basket')
Benefit = get_model('offer', 'Benefit') Benefit = get_model('offer', 'Benefit')
ConditionalOffer = get_model('offer', 'ConditionalOffer')
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
OrderLineVouchers = get_model('voucher', 'OrderLineVouchers') OrderLineVouchers = get_model('voucher', 'OrderLineVouchers')
Order = get_model('order', 'Order') Order = get_model('order', 'Order')
...@@ -149,7 +156,7 @@ class CouponRedeemView(EdxOrderPlacementMixin, View): ...@@ -149,7 +156,7 @@ class CouponRedeemView(EdxOrderPlacementMixin, View):
if not voucher.offers.first().is_email_valid(request.user.email): if not voucher.offers.first().is_email_valid(request.user.email):
return render(request, template_name, {'error': _('You are not eligible to use this coupon.')}) return render(request, template_name, {'error': _('You are not eligible to use this coupon.')})
if not request.user.account_details(request)['is_active']: if not request.user.account_details(request).get('is_active'):
return render( return render(
request, request,
template_name, template_name,
...@@ -159,6 +166,47 @@ class CouponRedeemView(EdxOrderPlacementMixin, View): ...@@ -159,6 +166,47 @@ class CouponRedeemView(EdxOrderPlacementMixin, View):
if request.user.is_user_already_enrolled(request, product): if request.user.is_user_already_enrolled(request, product):
return render(request, template_name, {'error': _('You are already enrolled in the course.')}) return render(request, template_name, {'error': _('You are already enrolled in the course.')})
try:
enterprise_customer = get_enterprise_customer_from_voucher(request.site, request.user.access_token, voucher)
except EnterpriseDoesNotExist as e:
# If an EnterpriseException is caught while pulling the EnterpriseCustomer, that means there's no
# corresponding EnterpriseCustomer in the Enterprise service (which should never happen).
logger.exception(e.message)
return render(
request,
template_name,
{'error': _('Couldn\'t find a matching Enterprise Customer for this coupon.')}
)
if enterprise_customer is not None and enterprise_customer.get('enable_data_sharing_consent'):
consent_token = get_enterprise_customer_data_sharing_consent_token(
request.user.access_token,
product.course.id,
enterprise_customer['id']
)
received_consent_token = request.GET.get('consent_token')
if received_consent_token:
# If the consent token is set, then the user is returning from the consent view. Render out an error
# if the computed token doesn't match the one received from the redirect URL.
if received_consent_token != consent_token:
return render(
request,
template_name,
{'error': _('Invalid data sharing consent token provided.')}
)
else:
# The user hasn't been redirected to the interstitial consent view to collect consent, so
# redirect them now.
redirect_url = get_enterprise_course_consent_url(
request.site,
code,
sku,
consent_token,
product.course.id,
enterprise_customer['id']
)
return HttpResponseRedirect(redirect_url)
basket = prepare_basket(request, product, voucher) basket = prepare_basket(request, product, voucher)
if basket.total_excl_tax == 0: if basket.total_excl_tax == 0:
self.place_free_order(basket) self.place_free_order(basket)
......
"""
Exceptions used by the Enterprise app.
"""
class EnterpriseDoesNotExist(Exception):
"""
Exception for errors related to Enterprise service data.
"""
pass
...@@ -9,6 +9,9 @@ class EnterpriseServiceMockMixin(object): ...@@ -9,6 +9,9 @@ class EnterpriseServiceMockMixin(object):
""" """
Mocks for the Open edX service 'Enterprise Service' responses. Mocks for the Open edX service 'Enterprise Service' responses.
""" """
ENTERPRISE_CUSTOMER_URL = '{}enterprise-customer/'.format(
settings.ENTERPRISE_API_URL,
)
ENTERPRISE_LEARNER_URL = '{}enterprise-learner/'.format( ENTERPRISE_LEARNER_URL = '{}enterprise-learner/'.format(
settings.ENTERPRISE_API_URL, settings.ENTERPRISE_API_URL,
) )
...@@ -17,7 +20,68 @@ class EnterpriseServiceMockMixin(object): ...@@ -17,7 +20,68 @@ class EnterpriseServiceMockMixin(object):
super(EnterpriseServiceMockMixin, self).setUp() super(EnterpriseServiceMockMixin, self).setUp()
cache.clear() cache.clear()
def mock_enterprise_learner_api(self, catalog_id=1, entitlement_id=1, learner_id=1): def mock_specific_enterprise_customer_api(self, uuid):
"""
Helper function to register the enterprise customer API endpoint.
"""
enterprise_customer_api_response = {
'uuid': uuid,
'name': 'TestShib',
'catalog': 0,
'active': True,
'site': {
'domain': 'example.com',
'name': 'example.com'
},
'enable_data_sharing_consent': True,
'enforce_data_sharing_consent': 'at_login',
'enterprise_customer_users': [
1
],
'branding_configuration': {
'enterprise_customer': 'cf246b88-d5f6-4908-a522-fc307e0b0c59',
'logo': 'https://open.edx.org/sites/all/themes/edx_open/logo.png'
},
'enterprise_customer_entitlements': [
{
'enterprise_customer': 'cf246b88-d5f6-4908-a522-fc307e0b0c59',
'entitlement_id': 0
}
]
}
enterprise_customer_api_response_json = json.dumps(enterprise_customer_api_response)
httpretty.register_uri(
method=httpretty.GET,
uri='{}{}/'.format(self.ENTERPRISE_CUSTOMER_URL, uuid),
body=enterprise_customer_api_response_json,
content_type='application/json'
)
def mock_enterprise_customer_api_not_found(self, uuid):
"""
Helper function to register the enterprise customer API endpoint.
"""
enterprise_customer_api_response = {
'detail': 'Not found.'
}
enterprise_customer_api_response_json = json.dumps(enterprise_customer_api_response)
httpretty.register_uri(
method=httpretty.GET,
uri='{}{}/'.format(self.ENTERPRISE_CUSTOMER_URL, uuid),
body=enterprise_customer_api_response_json,
content_type='application/json',
status=404,
)
def mock_enterprise_learner_api(
self,
catalog_id=1,
entitlement_id=1,
learner_id=1,
enterprise_customer_uuid='cf246b88-d5f6-4908-a522-fc307e0b0c59'
):
""" """
Helper function to register enterprise learner API endpoint. Helper function to register enterprise learner API endpoint.
""" """
...@@ -29,7 +93,7 @@ class EnterpriseServiceMockMixin(object): ...@@ -29,7 +93,7 @@ class EnterpriseServiceMockMixin(object):
{ {
'id': learner_id, 'id': learner_id,
'enterprise_customer': { 'enterprise_customer': {
'uuid': 'cf246b88-d5f6-4908-a522-fc307e0b0c59', 'uuid': enterprise_customer_uuid,
'name': 'TestShib', 'name': 'TestShib',
'catalog': catalog_id, 'catalog': catalog_id,
'active': True, 'active': True,
...@@ -43,12 +107,12 @@ class EnterpriseServiceMockMixin(object): ...@@ -43,12 +107,12 @@ class EnterpriseServiceMockMixin(object):
1 1
], ],
'branding_configuration': { 'branding_configuration': {
'enterprise_customer': 'cf246b88-d5f6-4908-a522-fc307e0b0c59', 'enterprise_customer': enterprise_customer_uuid,
'logo': 'https://open.edx.org/sites/all/themes/edx_open/logo.png' 'logo': 'https://open.edx.org/sites/all/themes/edx_open/logo.png'
}, },
'enterprise_customer_entitlements': [ 'enterprise_customer_entitlements': [
{ {
'enterprise_customer': 'cf246b88-d5f6-4908-a522-fc307e0b0c59', 'enterprise_customer': enterprise_customer_uuid,
'entitlement_id': entitlement_id 'entitlement_id': entitlement_id
} }
] ]
...@@ -85,6 +149,23 @@ class EnterpriseServiceMockMixin(object): ...@@ -85,6 +149,23 @@ class EnterpriseServiceMockMixin(object):
content_type='application/json' content_type='application/json'
) )
def mock_enterprise_learner_post_api(self):
"""
Helper function to register the enterprise learner POST API endpoint.
"""
enterprise_learner_api_response = {
'enterprise_customer': 'cf246b88-d5f6-4908-a522-fc307e0b0c59',
'username': 'the_j_meister',
}
enterprise_learner_api_response_json = json.dumps(enterprise_learner_api_response)
httpretty.register_uri(
method=httpretty.POST,
uri=self.ENTERPRISE_LEARNER_URL,
body=enterprise_learner_api_response_json,
content_type='application/json'
)
def mock_enterprise_learner_api_for_learner_with_no_enterprise(self): def mock_enterprise_learner_api_for_learner_with_no_enterprise(self):
""" """
Helper function to register enterprise learner API endpoint for a Helper function to register enterprise learner API endpoint for a
......
from __future__ import unicode_literals
import ddt
import httpretty
from ecommerce.core.tests.decorators import mock_enterprise_api_client
from ecommerce.enterprise.utils import get_enterprise_customer, get_or_create_enterprise_customer_user
from ecommerce.enterprise.tests.mixins import EnterpriseServiceMockMixin
from ecommerce.tests.testcases import TestCase
TEST_ENTERPRISE_CUSTOMER_UUID = 'cf246b88-d5f6-4908-a522-fc307e0b0c59'
@ddt.ddt
@httpretty.activate
class EnterpriseUtilsTests(EnterpriseServiceMockMixin, TestCase):
def setUp(self):
super(EnterpriseUtilsTests, self).setUp()
self.learner = self.create_user(is_staff=True)
self.client.login(username=self.learner.username, password=self.password)
def test_get_enterprise_customer(self):
"""
Verify that "get_enterprise_customer" returns an appropriate response from the
"enterprise-customer" Enterprise service API endpoint.
"""
self.mock_specific_enterprise_customer_api(TEST_ENTERPRISE_CUSTOMER_UUID)
response = get_enterprise_customer(self.site, self.learner.access_token, TEST_ENTERPRISE_CUSTOMER_UUID)
self.assertEqual(TEST_ENTERPRISE_CUSTOMER_UUID, response.get('id'))
@mock_enterprise_api_client
@ddt.data(
(
['mock_enterprise_learner_api'],
{'user_id': 5},
),
(
[
'mock_enterprise_learner_api_for_learner_with_no_enterprise',
'mock_enterprise_learner_post_api',
],
{
'enterprise_customer': 'cf246b88-d5f6-4908-a522-fc307e0b0c59',
'username': 'the_j_meister',
},
)
)
@ddt.unpack
def test_post_enterprise_customer_user(self, mock_helpers, expected_return):
"""
Verify that "get_enterprise_customer" returns an appropriate response from the
"enterprise-customer" Enterprise service API endpoint.
"""
for mock in mock_helpers:
getattr(self, mock)()
response = get_or_create_enterprise_customer_user(
self.site,
TEST_ENTERPRISE_CUSTOMER_UUID,
self.learner.username
)
self.assertDictContainsSubset(expected_return, response)
""" """
Helper methods for enterprise app. Helper methods for enterprise app.
""" """
import hashlib
import hmac
from urllib import urlencode
from django.conf import settings from django.conf import settings
from django.core.urlresolvers import reverse
from edx_rest_api_client.client import EdxRestApiClient
from oscar.core.loading import get_model
import waffle import waffle
from slumber.exceptions import HttpNotFoundError
from ecommerce.courses.utils import traverse_pagination
from ecommerce.enterprise.exceptions import EnterpriseDoesNotExist
ConditionalOffer = get_model('offer', 'ConditionalOffer')
def is_enterprise_feature_enabled(): def is_enterprise_feature_enabled():
...@@ -20,3 +33,134 @@ def is_enterprise_feature_enabled(): ...@@ -20,3 +33,134 @@ def is_enterprise_feature_enabled():
""" """
is_enterprise_enabled = waffle.switch_is_active(settings.ENABLE_ENTERPRISE_ON_RUNTIME_SWITCH) is_enterprise_enabled = waffle.switch_is_active(settings.ENABLE_ENTERPRISE_ON_RUNTIME_SWITCH)
return is_enterprise_enabled return is_enterprise_enabled
def get_enterprise_customer(site, token, uuid):
"""
Return a single enterprise customer
"""
client = EdxRestApiClient(
site.siteconfiguration.enterprise_api_url,
oauth_access_token=token
)
path = ['enterprise-customer', str(uuid)]
client = reduce(getattr, path, client)
try:
response = client.get()
except HttpNotFoundError:
return None
return {
'name': response['name'],
'id': response['uuid'],
'enable_data_sharing_consent': response['enable_data_sharing_consent'],
}
def get_enterprise_customers(site, token):
resource = 'enterprise-customer'
client = EdxRestApiClient(
site.siteconfiguration.enterprise_api_url,
oauth_access_token=token
)
endpoint = getattr(client, resource)
response = endpoint.get()
return [
{
'name': each['name'],
'id': each['uuid'],
}
for each in traverse_pagination(response, endpoint)
]
def get_or_create_enterprise_customer_user(site, enterprise_customer_uuid, username):
"""
Create a new EnterpriseCustomerUser on the enterprise service if one doesn't already exist.
Return the EnterpriseCustomerUser data.
"""
data = {
'enterprise_customer': str(enterprise_customer_uuid),
'username': username,
}
api_resource_name = 'enterprise-learner'
api = site.siteconfiguration.enterprise_api_client
endpoint = getattr(api, api_resource_name)
get_response = endpoint.get(**data)
if get_response.get('count') == 1:
result = get_response['results'][0]
return result
response = endpoint.post(data)
return response
def get_enterprise_customer_from_voucher(site, token, voucher):
"""
Given a Voucher, find the associated Enterprise Customer and retrieve data about
that customer from the Enterprise service. If there is no Enterprise Customer
associated with the Voucher, `None` is returned.
"""
try:
offer = voucher.offers.get(benefit__range__enterprise_customer__isnull=False)
except ConditionalOffer.DoesNotExist:
# There's no Enterprise Customer associated with this voucher.
return None
# Get information about the enterprise customer from the Enterprise service.
enterprise_customer_uuid = offer.benefit.range.enterprise_customer
enterprise_customer = get_enterprise_customer(site, token, enterprise_customer_uuid)
if enterprise_customer is None:
raise EnterpriseDoesNotExist(
'Enterprise customer with UUID {uuid} does not exist in the Enterprise service.'.format(
uuid=enterprise_customer_uuid
)
)
return enterprise_customer
def get_enterprise_course_consent_url(site, code, sku, consent_token, course_id, enterprise_customer_uuid):
"""
Construct the URL that should be used for redirecting the user to the Enterprise service for
collecting consent. The URL contains a specially crafted "next" parameter that will result
in the user being redirected back to the coupon redemption view with the verified consent token.
"""
callback_url = '{protocol}://{domain}{resource}?{params}'.format(
protocol=settings.PROTOCOL,
domain=site.domain,
resource=reverse('coupons:redeem'),
params=urlencode({
'code': code,
'sku': sku,
'consent_token': consent_token,
})
)
request_params = {
'course_id': course_id,
'enterprise_id': enterprise_customer_uuid,
'enrollment_deferred': True,
'next': callback_url,
}
redirect_url = '{base}?{params}'.format(
base=site.siteconfiguration.enterprise_grant_data_sharing_url,
params=urlencode(request_params)
)
return redirect_url
def get_enterprise_customer_data_sharing_consent_token(access_token, course_id, enterprise_customer_uuid):
"""
Generate a sha256 hmac token unique to an end-user Access Token, Course, and
Enterprise Customer combination.
"""
consent_token_hmac = hmac.new(
str(access_token),
'{course_id}_{enterprise_uuid}'.format(
course_id=course_id,
enterprise_uuid=enterprise_customer_uuid,
),
digestmod=hashlib.sha256,
)
return consent_token_hmac.hexdigest()
...@@ -23,7 +23,7 @@ class TestEnterpriseCustomerView(TestCase): ...@@ -23,7 +23,7 @@ class TestEnterpriseCustomerView(TestCase):
] ]
} }
@mock.patch('ecommerce.extensions.api.v2.views.enterprise.EdxRestApiClient') @mock.patch('ecommerce.enterprise.utils.EdxRestApiClient')
def test_get_customers(self, mock_client): def test_get_customers(self, mock_client):
instance = mock_client.return_value instance = mock_client.return_value
setattr( setattr(
......
from edx_rest_api_client.client import EdxRestApiClient
from rest_framework import generics from rest_framework import generics
from rest_framework.permissions import IsAuthenticated, IsAdminUser from rest_framework.permissions import IsAuthenticated, IsAdminUser
from rest_framework.response import Response from rest_framework.response import Response
from ecommerce.courses.utils import traverse_pagination from ecommerce.enterprise.utils import get_enterprise_customers
class EnterpriseCustomerViewSet(generics.GenericAPIView): class EnterpriseCustomerViewSet(generics.GenericAPIView):
...@@ -13,20 +12,3 @@ class EnterpriseCustomerViewSet(generics.GenericAPIView): ...@@ -13,20 +12,3 @@ class EnterpriseCustomerViewSet(generics.GenericAPIView):
def get(self, request): def get(self, request):
site = request.site site = request.site
return Response(data={'results': get_enterprise_customers(site, token=request.user.access_token)}) return Response(data={'results': get_enterprise_customers(site, token=request.user.access_token)})
def get_enterprise_customers(site, token):
resource = 'enterprise-customer'
client = EdxRestApiClient(
site.siteconfiguration.enterprise_api_url,
oauth_access_token=token
)
endpoint = getattr(client, resource)
response = endpoint.get()
return [
{
'name': each['name'],
'id': each['uuid'],
}
for each in traverse_pagination(response, endpoint)
]
...@@ -19,6 +19,7 @@ from ecommerce.core.constants import ENROLLMENT_CODE_PRODUCT_CLASS_NAME, SEAT_PR ...@@ -19,6 +19,7 @@ from ecommerce.core.constants import ENROLLMENT_CODE_PRODUCT_CLASS_NAME, SEAT_PR
from ecommerce.core.url_utils import get_lms_enrollment_api_url from ecommerce.core.url_utils import get_lms_enrollment_api_url
from ecommerce.courses.models import Course from ecommerce.courses.models import Course
from ecommerce.courses.utils import mode_for_seat from ecommerce.courses.utils import mode_for_seat
from ecommerce.enterprise.utils import get_or_create_enterprise_customer_user
from ecommerce.extensions.analytics.utils import audit_log, parse_tracking_context from ecommerce.extensions.analytics.utils import audit_log, parse_tracking_context
from ecommerce.extensions.checkout.utils import get_receipt_page_url from ecommerce.extensions.checkout.utils import get_receipt_page_url
from ecommerce.extensions.fulfillment.status import LINE from ecommerce.extensions.fulfillment.status import LINE
...@@ -209,6 +210,25 @@ class EnrollmentFulfillmentModule(BaseFulfillmentModule): ...@@ -209,6 +210,25 @@ class EnrollmentFulfillmentModule(BaseFulfillmentModule):
} }
) )
try: try:
# Collect the EnterpriseCustomer UUID from the coupon, if any.
enterprise_customer_uuid = None
for discount in order.discounts.all():
enterprise_customer_uuid = discount.voucher.benefit.range.enterprise_customer
if enterprise_customer_uuid is not None:
data['enterprise_course_consent'] = True
break
# If an EnterpriseCustomer UUID is associated with the coupon, create an EnterpriseCustomerUser
# on the Enterprise service if one doesn't already exist.
if enterprise_customer_uuid is not None:
get_or_create_enterprise_customer_user(
order.site,
enterprise_customer_uuid,
order.user.username
)
# Post to the Enrollment API. The LMS will take care of posting a new EnterpriseCourseEnrollment to
# the Enterprise service if the user+course has a corresponding EnterpriseCustomerUser.
response = self._post_to_enrollment_api(data, user=order.user) response = self._post_to_enrollment_api(data, user=order.user)
if response.status_code == status.HTTP_200_OK: if response.status_code == status.HTTP_200_OK:
......
...@@ -576,8 +576,8 @@ AFFILIATE_COOKIE_KEY = 'affiliate_id' ...@@ -576,8 +576,8 @@ AFFILIATE_COOKIE_KEY = 'affiliate_id'
CRISPY_TEMPLATE_PACK = 'bootstrap3' CRISPY_TEMPLATE_PACK = 'bootstrap3'
# ENTERPRISE APP CONFIGURATION # ENTERPRISE APP CONFIGURATION
# URL for Enterprise service API # URL for Enterprise service
ENTERPRISE_API_URL = 'http://localhost:8000/enterprise/api/v1/' ENTERPRISE_SERVICE_URL = 'http://localhost:8000/enterprise/'
# Cache enterprise response from Enterprise API. # Cache enterprise response from Enterprise API.
ENTERPRISE_API_CACHE_TIMEOUT = 3600 # Value is in seconds ENTERPRISE_API_CACHE_TIMEOUT = 3600 # Value is in seconds
......
...@@ -3,6 +3,7 @@ from ecommerce.settings.production import * ...@@ -3,6 +3,7 @@ from ecommerce.settings.production import *
DEBUG = True DEBUG = True
ENABLE_AUTO_AUTH = True ENABLE_AUTO_AUTH = True
PROTOCOL = 'http'
# Docker does not support the syslog socket at /dev/log. Rely on the console. # Docker does not support the syslog socket at /dev/log. Rely on the console.
LOGGING['handlers']['local'] = { LOGGING['handlers']['local'] = {
......
"""Development settings and globals.""" """Development settings and globals."""
from __future__ import absolute_import from __future__ import absolute_import
from urlparse import urljoin
from ecommerce.settings.base import * from ecommerce.settings.base import *
...@@ -120,3 +121,5 @@ ENABLE_AUTO_AUTH = True ...@@ -120,3 +121,5 @@ ENABLE_AUTO_AUTH = True
# Lastly, see if the developer has any local overrides. # Lastly, see if the developer has any local overrides.
if os.path.isfile(join(dirname(abspath(__file__)), 'private.py')): if os.path.isfile(join(dirname(abspath(__file__)), 'private.py')):
from .private import * # pylint: disable=import-error from .private import * # pylint: disable=import-error
ENTERPRISE_API_URL = urljoin(ENTERPRISE_SERVICE_URL, 'api/v1/')
"""Production settings and globals.""" """Production settings and globals."""
from os import environ from os import environ
from urlparse import urljoin
# Normally you should not import ANYTHING from Django directly # Normally you should not import ANYTHING from Django directly
# into your settings, but ImproperlyConfigured is an exception. # into your settings, but ImproperlyConfigured is an exception.
...@@ -10,6 +11,9 @@ import yaml ...@@ -10,6 +11,9 @@ import yaml
from ecommerce.settings.base import * from ecommerce.settings.base import *
# Protocol used for construcing absolute callback URLs
PROTOCOL = 'https'
# Enable offline compression of CSS/JS # Enable offline compression of CSS/JS
COMPRESS_ENABLED = True COMPRESS_ENABLED = True
COMPRESS_OFFLINE = True COMPRESS_OFFLINE = True
...@@ -79,3 +83,5 @@ for __, configs in PAYMENT_PROCESSOR_CONFIG.iteritems(): ...@@ -79,3 +83,5 @@ for __, configs in PAYMENT_PROCESSOR_CONFIG.iteritems():
'error_path': PAYMENT_PROCESSOR_ERROR_PATH, 'error_path': PAYMENT_PROCESSOR_ERROR_PATH,
}) })
# END PAYMENT PROCESSOR OVERRIDES # END PAYMENT PROCESSOR OVERRIDES
ENTERPRISE_API_URL = urljoin(ENTERPRISE_SERVICE_URL, 'api/v1/')
from __future__ import absolute_import from __future__ import absolute_import
from path import Path from path import Path
from urlparse import urljoin
from ecommerce.settings.base import * from ecommerce.settings.base import *
SITE_ID = 1 SITE_ID = 1
PROTOCOL = 'http'
# TEST SETTINGS # TEST SETTINGS
INSTALLED_APPS += ( INSTALLED_APPS += (
...@@ -142,3 +144,5 @@ COMPREHENSIVE_THEME_DIRS = [ ...@@ -142,3 +144,5 @@ COMPREHENSIVE_THEME_DIRS = [
] ]
DEFAULT_SITE_THEME = "test-theme" DEFAULT_SITE_THEME = "test-theme"
ENTERPRISE_API_URL = urljoin(ENTERPRISE_SERVICE_URL, 'api/v1/')
...@@ -20,7 +20,7 @@ edx-django-sites-extensions==1.0.0 ...@@ -20,7 +20,7 @@ edx-django-sites-extensions==1.0.0
edx-drf-extensions==1.2.2 edx-drf-extensions==1.2.2
edx-ecommerce-worker==0.6.0 edx-ecommerce-worker==0.6.0
edx-opaque-keys==0.3.1 edx-opaque-keys==0.3.1
edx-rest-api-client==1.6.0 edx-rest-api-client==1.7.1
jsonfield==1.0.3 jsonfield==1.0.3
libsass==0.9.2 libsass==0.9.2
ndg-httpsclient==0.4.0 ndg-httpsclient==0.4.0
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment