Commit 966cedea by Saleem Latif Committed by Saleem Latif

Update ecommerce enterprise entitlements to account for learner consent

parent d6d2bb7a
from __future__ import unicode_literals from __future__ import unicode_literals
import hashlib
import logging import logging
import six
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
...@@ -18,3 +21,27 @@ def log_message_and_raise_validation_error(message): ...@@ -18,3 +21,27 @@ def log_message_and_raise_validation_error(message):
""" """
logger.error(message) logger.error(message)
raise ValidationError(message) raise ValidationError(message)
def get_cache_key(**kwargs):
"""
Get MD5 encoded cache key for given arguments.
Here is the format of key before MD5 encryption.
key1:value1__key2:value2 ...
Example:
>>> get_cache_key(site_domain="example.com", resource="catalogs")
# Here is key format for above call
# "site_domain:example.com__resource:catalogs"
a54349175618ff1659dee0978e3149ca
Arguments:
**kwargs: Key word arguments that need to be present in cache key.
Returns:
An MD5 encoded key uniquely identified by the key word arguments.
"""
key = '__'.join(['{}:{}'.format(item, value) for item, value in six.iteritems(kwargs)])
return hashlib.md5(key).hexdigest()
...@@ -120,7 +120,7 @@ class CourseCatalogMockMixin(object): ...@@ -120,7 +120,7 @@ class CourseCatalogMockMixin(object):
course_run_info_json = json.dumps(course_contains_info) course_run_info_json = json.dumps(course_contains_info)
course_run_url = '{}course_runs/contains/?course_run_ids={}&query={}'.format( course_run_url = '{}course_runs/contains/?course_run_ids={}&query={}'.format(
settings.COURSE_CATALOG_API_URL, settings.COURSE_CATALOG_API_URL,
(course_run_id for course_run_id in course_run_ids), ",".join(course_run_id for course_run_id in course_run_ids),
query if query else 'id:course*' query if query else 'id:course*'
) )
httpretty.register_uri( httpretty.register_uri(
......
...@@ -80,7 +80,7 @@ class GetVoucherTests(CourseCatalogTestMixin, TestCase): ...@@ -80,7 +80,7 @@ class GetVoucherTests(CourseCatalogTestMixin, TestCase):
def test_no_product(self): def test_no_product(self):
""" Verify that an exception is raised if there is no product. """ """ Verify that an exception is raised if there is no product. """
code = FuzzyText().fuzz() code = FuzzyText().fuzz().upper()
voucher = VoucherFactory(code=code) voucher = VoucherFactory(code=code)
offer = ConditionalOfferFactory() offer = ConditionalOfferFactory()
voucher.offers.add(offer) voucher.offers.add(offer)
...@@ -223,7 +223,7 @@ class CouponOfferViewTests(ApiMockMixin, CouponMixin, CourseCatalogTestMixin, Lm ...@@ -223,7 +223,7 @@ class CouponOfferViewTests(ApiMockMixin, CouponMixin, CourseCatalogTestMixin, Lm
def test_no_product(self): def test_no_product(self):
""" Verify an error is returned for voucher with no product. """ """ Verify an error is returned for voucher with no product. """
code = FuzzyText().fuzz() code = FuzzyText().fuzz().upper()
no_product_range = RangeFactory() no_product_range = RangeFactory()
prepare_voucher(code=code, _range=no_product_range) prepare_voucher(code=code, _range=no_product_range)
url = self.path + '?code={}'.format(code) url = self.path + '?code={}'.format(code)
...@@ -327,7 +327,7 @@ class CouponRedeemViewTests(CouponMixin, CourseCatalogTestMixin, LmsApiMockMixin ...@@ -327,7 +327,7 @@ class CouponRedeemViewTests(CouponMixin, CourseCatalogTestMixin, LmsApiMockMixin
def test_invalid_voucher_code(self): def test_invalid_voucher_code(self):
""" Verify an error is returned when voucher does not exist. """ """ Verify an error is returned when voucher does not exist. """
code = FuzzyText().fuzz() code = FuzzyText().fuzz().upper()
url = self.redeem_url + '?code={}&sku={}'.format(code, self.stock_record.partner_sku) url = self.redeem_url + '?code={}&sku={}'.format(code, self.stock_record.partner_sku)
response = self.client.get(url) response = self.client.get(url)
msg = 'No voucher found with code {code}'.format(code=code) msg = 'No voucher found with code {code}'.format(code=code)
...@@ -344,7 +344,7 @@ class CouponRedeemViewTests(CouponMixin, CourseCatalogTestMixin, LmsApiMockMixin ...@@ -344,7 +344,7 @@ class CouponRedeemViewTests(CouponMixin, CourseCatalogTestMixin, LmsApiMockMixin
""" Verify an error is returned for expired coupon. """ """ Verify an error is returned for expired coupon. """
start_datetime = now() - datetime.timedelta(days=20) start_datetime = now() - datetime.timedelta(days=20)
end_datetime = now() - datetime.timedelta(days=10) end_datetime = now() - datetime.timedelta(days=10)
code = FuzzyText().fuzz() code = FuzzyText().fuzz().upper()
__, product = prepare_voucher(code=code, start_datetime=start_datetime, end_datetime=end_datetime) __, product = prepare_voucher(code=code, start_datetime=start_datetime, end_datetime=end_datetime)
url = self.redeem_url + '?code={}&sku={}'.format(code, StockRecord.objects.get(product=product).partner_sku) url = self.redeem_url + '?code={}&sku={}'.format(code, StockRecord.objects.get(product=product).partner_sku)
response = self.client.get(url) response = self.client.get(url)
......
"""
Methods for fetching enterprise API data.
"""
from django.conf import settings
from django.core.cache import cache
from ecommerce.core.utils import get_cache_key
def fetch_enterprise_learner_entitlements(site, learner_id):
"""
Fetch enterprise learner entitlements along-with data sharing consent requirement.
Arguments:
site (Site): site instance.
learner_id (int): Primary key identifier for the enterprise learner.
Example:
>>> from django.contrib.sites.shortcuts import get_current_site
>>> site = get_current_site()
>>> fetch_enterprise_learner_entitlements(site, 1)
[
{
"requires_consent": False,
"entitlement_id": 1
},
]
Returns:
(list): Containing dicts of the following structure
{
"requires_consent": True,
"entitlement_id": 1
}
Raises:
ConnectionError: requests exception "ConnectionError", raised if if ecommerce is unable to connect
to enterprise api server.
SlumberBaseException: base slumber exception "SlumberBaseException", raised if API response contains
http error status like 4xx, 5xx etc.
Timeout: requests exception "Timeout", raised if enterprise API is taking too long for returning
a response. This exception is raised for both connection timeout and read timeout.
"""
resource_url = 'enterprise-learner/{learner_id}/entitlements'.format(learner_id=learner_id)
cache_key = get_cache_key(
site_domain=site.domain,
partner_code=site.siteconfiguration.partner.short_code,
resource=resource_url,
learner_id=learner_id
)
entitlements = cache.get(cache_key)
if not entitlements:
api = site.siteconfiguration.enterprise_api_client
entitlements = getattr(api, resource_url).get()
cache.set(cache_key, entitlements, settings.ENTERPRISE_API_CACHE_TIMEOUT)
return entitlements
def fetch_enterprise_learner_data(site, user):
"""
Fetch information related to enterprise and its entitlements from the Enterprise
Service.
Example:
fetch_enterprise_learner_data(site, user)
Arguments:
site: (Site) site instance
user: (User) django auth user
Returns:
dict: {
"enterprise_api_response_for_learner": {
"count": 1,
"num_pages": 1,
"current_page": 1,
"results": [
{
"enterprise_customer": {
"uuid": "cf246b88-d5f6-4908-a522-fc307e0b0c59",
"name": "TestShib",
"catalog": 2,
"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": 69
}
]
},
"user_id": 5,
"user": {
"username": "staff",
"first_name": "",
"last_name": "",
"email": "staff@example.com",
"is_staff": true,
"is_active": true,
"date_joined": "2016-09-01T19:18:26.026495Z"
},
"data_sharing_consent": [
{
"user": 1,
"state": "enabled",
"enabled": true
}
]
}
],
"next": null,
"start": 0,
"previous": null
}
}
Raises:
ConnectionError: requests exception "ConnectionError", raised if if ecommerce is unable to connect
to enterprise api server.
SlumberBaseException: base slumber exception "SlumberBaseException", raised if API response contains
http error status like 4xx, 5xx etc.
Timeout: requests exception "Timeout", raised if enterprise API is taking too long for returning
a response. This exception is raised for both connection timeout and read timeout.
"""
api_resource_name = 'enterprise-learner'
partner_code = site.siteconfiguration.partner.short_code
cache_key = get_cache_key(
site_domain=site.domain,
partner_code=partner_code,
resource=api_resource_name,
username=user.username
)
response = cache.get(cache_key)
if not response:
api = site.siteconfiguration.enterprise_api_client
endpoint = getattr(api, api_resource_name)
querystring = {'username': user.username}
response = endpoint().get(**querystring)
cache.set(cache_key, response, settings.ENTERPRISE_API_CACHE_TIMEOUT)
return response
...@@ -21,6 +21,8 @@ from ecommerce.courses.utils import get_course_catalogs ...@@ -21,6 +21,8 @@ from ecommerce.courses.utils import get_course_catalogs
from ecommerce.enterprise.utils import is_enterprise_feature_enabled from ecommerce.enterprise.utils import is_enterprise_feature_enabled
from ecommerce.extensions.api.serializers import retrieve_all_vouchers from ecommerce.extensions.api.serializers import retrieve_all_vouchers
from ecommerce.enterprise import api as enterprise_api
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
Product = get_model('catalogue', 'Product') Product = get_model('catalogue', 'Product')
...@@ -70,12 +72,9 @@ def get_course_vouchers_for_learner(site, user, course_id): ...@@ -70,12 +72,9 @@ def get_course_vouchers_for_learner(site, user, course_id):
vouchers = [] vouchers = []
for entitlement in entitlements: for entitlement in entitlements:
try: try:
coupon_product = Product.objects.filter(product_class__name='Coupon').get(id=entitlement['entitlement_id']) coupon_product = Product.objects.filter(product_class__name='Coupon').get(id=entitlement)
except Product.DoesNotExist: except Product.DoesNotExist:
logger.exception( logger.exception('There was an error getting coupon product with the entitlement id %s', entitlement)
'There was an error getting coupon product with the entitlement id %s',
entitlement['entitlement_id']
)
return None return None
entitlement_voucher = retrieve_all_vouchers(coupon_product) entitlement_voucher = retrieve_all_vouchers(coupon_product)
...@@ -94,9 +93,11 @@ def get_course_entitlements_for_learner(site, user, course_id): ...@@ -94,9 +93,11 @@ def get_course_entitlements_for_learner(site, user, course_id):
site: (django.contrib.sites.Site) site instance site: (django.contrib.sites.Site) site instance
user: (django.contrib.auth.User) django auth user user: (django.contrib.auth.User) django auth user
Returns:
(list): List of entitlement ids, where entitlement id is actually a voucher id.
""" """
try: try:
enterprise_learner_data = get_enterprise_learner_data(site, user)['results'] enterprise_learner_data = enterprise_api.fetch_enterprise_learner_data(site, user)['results']
except (ConnectionError, SlumberBaseException, Timeout, KeyError, TypeError): except (ConnectionError, SlumberBaseException, Timeout, KeyError, TypeError):
logger.exception( logger.exception(
'Failed to retrieve enterprise info for the learner [%s]', 'Failed to retrieve enterprise info for the learner [%s]',
...@@ -110,7 +111,7 @@ def get_course_entitlements_for_learner(site, user, course_id): ...@@ -110,7 +111,7 @@ def get_course_entitlements_for_learner(site, user, course_id):
try: try:
enterprise_catalog_id = enterprise_learner_data[0]['enterprise_customer']['catalog'] enterprise_catalog_id = enterprise_learner_data[0]['enterprise_customer']['catalog']
entitlements = enterprise_learner_data[0]['enterprise_customer']['enterprise_customer_entitlements'] learner_id = enterprise_learner_data[0]['id']
except KeyError: except KeyError:
logger.exception('Invalid structure for enterprise learner API response for the learner [%s]', user.username) logger.exception('Invalid structure for enterprise learner API response for the learner [%s]', user.username)
return None return None
...@@ -120,6 +121,26 @@ def get_course_entitlements_for_learner(site, user, course_id): ...@@ -120,6 +121,26 @@ def get_course_entitlements_for_learner(site, user, course_id):
if not is_course_in_enterprise_catalog(site, course_id, enterprise_catalog_id): if not is_course_in_enterprise_catalog(site, course_id, enterprise_catalog_id):
return None return None
try:
entitlements = enterprise_api.fetch_enterprise_learner_entitlements(site, learner_id)
except (ConnectionError, SlumberBaseException, Timeout):
logger.exception(
'Failed to retrieve entitlements for enterprise learner [%s].',
learner_id
)
return None
try:
# Currently, we are returning only those entitlements that
# do not require any further action on part of enterprise learner
entitlements = [item['entitlement_id'] for item in entitlements['entitlements'] if not item['requires_consent']]
except KeyError:
logger.exception(
'Invalid structure for enterprise learner entitlements API response for enterprise learner [%s].',
learner_id,
)
return None
return entitlements return entitlements
...@@ -193,104 +214,6 @@ def is_course_in_catalog_query(site, course_id, enterprise_catalog_query): ...@@ -193,104 +214,6 @@ def is_course_in_catalog_query(site, course_id, enterprise_catalog_query):
return is_course_in_course_runs return is_course_in_course_runs
def get_enterprise_learner_data(site, user):
"""
Fetch information related to enterprise and its entitlements according to
the eligibility criterion for the provided learners from the Enterprise
Service.
Example:
get_enterprise_learner_data(site, user)
Arguments:
site: (django.contrib.sites.Site) site instance
user: (django.contrib.auth.User) django auth user
Returns:
dict: {
"enterprise_api_response_for_learner": {
"count": 1,
"num_pages": 1,
"current_page": 1,
"results": [
{
"enterprise_customer": {
"uuid": "cf246b88-d5f6-4908-a522-fc307e0b0c59",
"name": "TestShib",
"catalog": 2,
"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": 69
}
]
},
"user_id": 5,
"user": {
"username": "staff",
"first_name": "",
"last_name": "",
"email": "staff@example.com",
"is_staff": true,
"is_active": true,
"date_joined": "2016-09-01T19:18:26.026495Z"
},
"data_sharing_consent": [
{
"user": 1,
"state": "enabled",
"enabled": true
}
]
}
],
"next": null,
"start": 0,
"previous": null
}
}
Raises:
ConnectionError: requests exception "ConnectionError"
SlumberBaseException: slumber exception "SlumberBaseException"
Timeout: requests exception "Timeout"
"""
api_resource_name = 'enterprise-learner'
partner_code = site.siteconfiguration.partner.short_code
cache_key = '{site_domain}_{partner_code}_{resource}_{username}'.format(
site_domain=site.domain,
partner_code=partner_code,
resource=api_resource_name,
username=user.username
)
cache_key = hashlib.md5(cache_key).hexdigest()
response = cache.get(cache_key)
if not response:
api = site.siteconfiguration.enterprise_api_client
endpoint = getattr(api, api_resource_name)
querystring = {'username': user.username}
response = endpoint().get(**querystring)
cache.set(cache_key, response, settings.ENTERPRISE_API_CACHE_TIMEOUT)
return response
def get_available_voucher_for_product(request, product, vouchers): def get_available_voucher_for_product(request, product, vouchers):
""" """
Get first active entitlement from a list of vouchers for the given Get first active entitlement from a list of vouchers for the given
......
...@@ -17,7 +17,7 @@ class EnterpriseServiceMockMixin(object): ...@@ -17,7 +17,7 @@ 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): def mock_enterprise_learner_api(self, catalog_id=1, entitlement_id=1, learner_id=1):
""" """
Helper function to register enterprise learner API endpoint. Helper function to register enterprise learner API endpoint.
""" """
...@@ -27,6 +27,7 @@ class EnterpriseServiceMockMixin(object): ...@@ -27,6 +27,7 @@ class EnterpriseServiceMockMixin(object):
'current_page': 1, 'current_page': 1,
'results': [ 'results': [
{ {
'id': learner_id,
'enterprise_customer': { 'enterprise_customer': {
'uuid': 'cf246b88-d5f6-4908-a522-fc307e0b0c59', 'uuid': 'cf246b88-d5f6-4908-a522-fc307e0b0c59',
'name': 'TestShib', 'name': 'TestShib',
...@@ -202,7 +203,42 @@ class EnterpriseServiceMockMixin(object): ...@@ -202,7 +203,42 @@ class EnterpriseServiceMockMixin(object):
httpretty.register_uri( httpretty.register_uri(
method=httpretty.GET, method=httpretty.GET,
uri=self.ENTERPRISE_LEARNER_URL, uri=self.ENTERPRISE_LEARNER_URL,
status=500,
)
def mock_learner_entitlements_api_failure(self, learner_id, status=500):
"""
Helper function to return 500 error while accessing learner entitlements api endpoint.
"""
httpretty.register_uri(
method=httpretty.GET,
uri='{base_url}{learner_id}/entitlements/'.format(
base_url=self.ENTERPRISE_LEARNER_URL, learner_id=learner_id,
),
responses=[ responses=[
httpretty.Response(body='{}', content_type='application/json', status_code=500) httpretty.Response(body='{}', content_type='application/json', status=status)
]
)
def mock_enterprise_learner_entitlements_api(self, learner_id=1, entitlement_id=1, require_consent=False):
"""
Helper function to register enterprise learner entitlements API endpoint.
"""
enterprise_learner_entitlements_api_response = {
'entitlements': [
{
'entitlement_id': entitlement_id,
'requires_consent': require_consent,
}
] ]
}
learner_entitlements_json = json.dumps(enterprise_learner_entitlements_api_response)
httpretty.register_uri(
method=httpretty.GET,
uri='{base_url}{learner_id}/entitlements/'.format(
base_url=self.ENTERPRISE_LEARNER_URL, learner_id=learner_id,
),
body=learner_entitlements_json,
content_type='application/json'
) )
from django.core.cache import cache
from django.conf import settings
import httpretty
from oscar.core.loading import get_model
from ecommerce.core.tests import toggle_switch
from ecommerce.core.tests.decorators import mock_enterprise_api_client
from ecommerce.enterprise import api as enterprise_api
from ecommerce.enterprise.tests.mixins import EnterpriseServiceMockMixin
from ecommerce.extensions.partner.strategy import DefaultStrategy
from ecommerce.tests.testcases import TestCase
from ecommerce.core.utils import get_cache_key
Catalog = get_model('catalogue', 'Catalog')
StockRecord = get_model('partner', 'StockRecord')
@httpretty.activate
class EnterpriseAPITests(EnterpriseServiceMockMixin, TestCase):
def setUp(self):
super(EnterpriseAPITests, self).setUp()
self.learner = self.create_user(is_staff=True)
self.client.login(username=self.learner.username, password=self.password)
# Enable enterprise functionality
toggle_switch(settings.ENABLE_ENTERPRISE_ON_RUNTIME_SWITCH, True)
self.request.user = self.learner
self.request.site = self.site
self.request.strategy = DefaultStrategy()
def tearDown(self):
# Reset HTTPretty state (clean up registered urls and request history)
httpretty.reset()
def _assert_num_requests(self, count):
"""
DRY helper for verifying request counts.
"""
self.assertEqual(len(httpretty.httpretty.latest_requests), count)
def _assert_fetch_enterprise_learner_data(self):
"""
Helper method to validate the response from the method
"fetch_enterprise_learner_data".
"""
cache_key = get_cache_key(
site_domain=self.request.site.domain,
partner_code=self.request.site.siteconfiguration.partner.short_code,
resource='enterprise-learner',
username=self.learner.username
)
cached_enterprise_learner_response = cache.get(cache_key)
self.assertIsNone(cached_enterprise_learner_response)
response = enterprise_api.fetch_enterprise_learner_data(self.request.site, self.learner)
self.assertEqual(len(response['results']), 1)
cached_course = cache.get(cache_key)
self.assertEqual(cached_course, response)
@mock_enterprise_api_client
def test_fetch_enterprise_learner_data(self):
"""
Verify that method "fetch_enterprise_learner_data" returns a proper
response for the enterprise learner.
"""
self.mock_enterprise_learner_api()
self._assert_fetch_enterprise_learner_data()
# API should be hit only once in this test case
expected_number_of_requests = 1
# Verify the API was hit once
self._assert_num_requests(expected_number_of_requests)
# Now fetch the enterprise learner data again and verify that there was
# no actual call to Enterprise API, as the data will be fetched from
# the cache
enterprise_api.fetch_enterprise_learner_data(self.request.site, self.learner)
self._assert_num_requests(expected_number_of_requests)
@mock_enterprise_api_client
def test_fetch_enterprise_learner_entitlements(self):
"""
Verify that method "fetch_enterprise_learner_data" returns a proper
response for the enterprise learner.
"""
# API should be hit only twice in this test case,
# once by `fetch_enterprise_learner_data` and once by `fetch_enterprise_learner_entitlements`.
expected_number_of_requests = 2
self.mock_enterprise_learner_api()
enterprise_learners = enterprise_api.fetch_enterprise_learner_data(self.request.site, self.learner)
enterprise_learner_id = enterprise_learners['results'][0]['id']
self.mock_enterprise_learner_entitlements_api(enterprise_learner_id)
enterprise_api.fetch_enterprise_learner_entitlements(self.request.site, enterprise_learner_id)
# Verify the API was hit just two times, once by `fetch_enterprise_learner_data`
# and once by `fetch_enterprise_learner_entitlements`
self._assert_num_requests(expected_number_of_requests)
# Now fetch the enterprise learner entitlements again and verify that there was
# no actual call to Enterprise API, as the data will be taken from
# the cache
enterprise_api.fetch_enterprise_learner_entitlements(self.request.site, enterprise_learner_id)
self._assert_num_requests(expected_number_of_requests)
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