Commit e208386e by zubair-arbi Committed by Zubair Afzal

Update offer's Range model to use course catalog query for applying enterprise coupon

ENT-169
parent ca89f929
......@@ -89,8 +89,8 @@ class CourseCatalogMockMixin(object):
course_run_url_with_query_and_partner_code = '{}course_runs/?q={}&partner={}'.format(
settings.COURSE_CATALOG_API_URL,
partner_code if partner_code else 'edx',
query if query else 'id:course*'
query if query else 'id:course*',
partner_code if partner_code else 'edx'
)
httpretty.register_uri(
httpretty.GET,
......@@ -129,6 +129,49 @@ class CourseCatalogMockMixin(object):
content_type='application/json'
)
def mock_get_catalog_contains_api_for_failure(self, partner_code, course_run_ids, query, error):
"""
Helper function to register a course catalog API endpoint with failure
for getting course runs information.
"""
def callback(request, uri, headers): # pylint: disable=unused-argument
raise error
catalog_contains_course_run_url = '{}course_runs/contains/?course_run_ids={}&query={}&partner={}'.format(
settings.COURSE_CATALOG_API_URL,
(course_run_id for course_run_id in course_run_ids),
query,
partner_code,
)
httpretty.register_uri(
method=httpretty.GET,
uri=catalog_contains_course_run_url,
responses=[
httpretty.Response(body=callback, content_type='application/json', status_code=500)
]
)
def mock_get_catalog_course_runs_for_failure(self, partner_code, query, error):
"""
Helper function to register a course catalog API endpoint with failure
for getting course runs information.
"""
def callback(request, uri, headers): # pylint: disable=unused-argument
raise error
course_run_url_with_query_and_partner_code = '{}course_runs/?q={}&partner={}'.format(
settings.COURSE_CATALOG_API_URL,
query,
partner_code,
)
httpretty.register_uri(
method=httpretty.GET,
uri=course_run_url_with_query_and_partner_code,
responses=[
httpretty.Response(body=callback, content_type='application/json', status_code=500)
]
)
class CouponMixin(object):
""" Mixin for preparing data for coupons and creating coupons. """
......
......@@ -17,12 +17,11 @@ class CourseCatalogServiceMockMixin(object):
super(CourseCatalogServiceMockMixin, self).setUp()
cache.clear()
def mock_course_discovery_api_for_catalog_by_resource_id(self, catalog_query='title: *'):
def mock_course_discovery_api_for_catalog_by_resource_id(self, catalog_id=1, catalog_query='title: *'):
"""
Helper function to register course catalog API endpoint for a
single catalog with its resource id.
"""
catalog_id = 1
course_discovery_api_response = {
'id': catalog_id,
'name': 'Catalog {}'.format(catalog_id),
......@@ -122,15 +121,23 @@ class CourseCatalogServiceMockMixin(object):
responses=mocked_api_responses
)
def mock_course_discovery_api_for_failure(self):
def mock_course_discovery_api_for_catalogs_with_failure(self, error, catalog_id=None):
"""
Helper function to register course catalog API endpoint for a
failure.
Helper function to register course catalog API endpoint for catalogs
with failure.
"""
def callback(request, uri, headers): # pylint: disable=unused-argument
raise error
if catalog_id:
course_catalog_uri = '{}{}/'.format(self.COURSE_DISCOVERY_CATALOGS_URL, catalog_id)
else:
course_catalog_uri = self.COURSE_DISCOVERY_CATALOGS_URL
httpretty.register_uri(
method=httpretty.GET,
uri=self.COURSE_DISCOVERY_CATALOGS_URL,
uri=course_catalog_uri,
responses=[
httpretty.Response(body='Clunk', content_type='application/json', status_code=500)
httpretty.Response(body=callback, content_type='application/json', status_code=500)
]
)
......@@ -3,6 +3,7 @@ import hashlib
import ddt
from django.core.cache import cache
import httpretty
from requests.exceptions import ConnectionError
from ecommerce.core.constants import ENROLLMENT_CODE_SWITCH
from ecommerce.core.tests import toggle_switch
......@@ -175,7 +176,8 @@ class GetCourseCatalogUtilTests(CourseCatalogServiceMockMixin, TestCase):
Verify that method "get_course_catalogs" raises exception in case
the Course Discovery API fails to return data.
"""
self.mock_course_discovery_api_for_failure()
exception = ConnectionError
self.mock_course_discovery_api_for_catalogs_with_failure(exception)
with self.assertRaises(Exception):
with self.assertRaises(exception):
get_course_catalogs(self.request.site)
......@@ -16,11 +16,10 @@ from oscar.core.loading import get_model
from requests.exceptions import ConnectionError, Timeout
from slumber.exceptions import SlumberBaseException
from ecommerce.coupons.utils import get_catalog_course_runs
from ecommerce.coupons.views import voucher_is_valid
from ecommerce.courses.utils import get_course_catalogs
from ecommerce.enterprise.utils import is_enterprise_feature_enabled
from ecommerce.extensions.api.serializers import retrieve_voucher
from ecommerce.extensions.api.serializers import retrieve_all_vouchers
logger = logging.getLogger(__name__)
......@@ -34,28 +33,29 @@ def get_entitlement_voucher(request, product):
learner.
Arguments:
request (HttpRequest): request with voucher data
product (Product): A product that has course_key as attribute (seat or
product (Product): A product that has course_id as attribute (seat or
bulk enrollment coupon)
request (HttpRequest): request with voucher data
"""
if not is_enterprise_feature_enabled():
return None
vouchers = get_vouchers_for_learner(request.site, request.user)
if vouchers:
entitlement_voucher = get_available_voucher_for_product(request, product, vouchers)
return entitlement_voucher
vouchers = get_course_vouchers_for_learner(request.site, request.user, product.course_id)
if not vouchers:
return None
return None
entitlement_voucher = get_available_voucher_for_product(request, product, vouchers)
return entitlement_voucher
def get_vouchers_for_learner(site, user):
def get_course_vouchers_for_learner(site, user, course_id):
"""
Get vouchers against the list of all enterprise entitlements for the
provided learner.
provided learner and course id.
Arguments:
course_id (str): The course ID.
site: (django.contrib.sites.Site) site instance
user: (django.contrib.auth.User) django auth user
......@@ -63,7 +63,7 @@ def get_vouchers_for_learner(site, user):
list of Voucher class objects
"""
entitlements = get_entitlements_for_learner(site, user)
entitlements = get_course_entitlements_for_learner(site, user, course_id)
if not entitlements:
return None
......@@ -78,18 +78,19 @@ def get_vouchers_for_learner(site, user):
)
return None
entitlement_voucher = retrieve_voucher(coupon_product)
vouchers.append(entitlement_voucher)
entitlement_voucher = retrieve_all_vouchers(coupon_product)
vouchers.extend(entitlement_voucher)
return vouchers
def get_entitlements_for_learner(site, user):
def get_course_entitlements_for_learner(site, user, course_id):
"""
Get entitlements for the provided learner if the provided learner is
affiliated with an enterprise.
Get entitlements for the provided learner against the provided course id
if the provided learner is affiliated with an enterprise.
Arguments:
course_id (str): The course ID.
site: (django.contrib.sites.Site) site instance
user: (django.contrib.auth.User) django auth user
......@@ -108,14 +109,90 @@ def get_entitlements_for_learner(site, user):
return None
try:
enterprise_catalog_id = enterprise_learner_data[0]['enterprise_customer']['catalog']
entitlements = enterprise_learner_data[0]['enterprise_customer']['enterprise_customer_entitlements']
except KeyError:
logger.error('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
# Before returning entitlements verify that the provided course exists in
# the enterprise course catalog
if not is_course_in_enterprise_catalog(site, course_id, enterprise_catalog_id):
return None
return entitlements
def is_course_in_enterprise_catalog(site, course_id, enterprise_catalog_id):
"""
Verify that the provided course id exists in the site base list of course
run keys from the provided enterprise course catalog.
Arguments:
course_id (str): The course ID.
site: (django.contrib.sites.Site) site instance
enterprise_catalog_id (Int): Course catalog id of enterprise
Returns:
Boolean
"""
try:
enterprise_course_catalog = get_course_catalogs(site=site, resource_id=enterprise_catalog_id)
except (ConnectionError, SlumberBaseException, Timeout):
logger.exception('Unable to connect to Course Catalog service for course catalogs.')
return None
if is_course_in_catalog_query(site, course_id, enterprise_course_catalog.get('query')):
return True
return False
def is_course_in_catalog_query(site, course_id, enterprise_catalog_query):
"""
Find out if the provided course exists in list of courses against the
enterprise course catalog query.
Arguments:
site: (django.contrib.sites.Site) site instance
course_id (Int): Course catalog id of enterprise
enterprise_catalog_query (Str): Enterprise course catalog query
Returns:
Boolean
"""
partner_code = site.siteconfiguration.partner.short_code
cache_key = hashlib.md5(
'{site_domain}_{partner_code}_catalog_query_contains_{course_id}_{query}'.format(
site_domain=site.domain,
partner_code=partner_code,
course_id=course_id,
query=enterprise_catalog_query
)
).hexdigest()
response = cache.get(cache_key)
if not response:
try:
response = site.siteconfiguration.course_catalog_api_client.course_runs.contains.get(
query=enterprise_catalog_query,
course_run_ids=course_id,
partner=partner_code
)
cache.set(cache_key, response, settings.COURSES_API_CACHE_TIMEOUT)
except (ConnectionError, SlumberBaseException, Timeout):
logger.exception('Unable to connect to Course Catalog service for course runs.')
return False
try:
is_course_in_course_runs = response['course_runs'][course_id]
except KeyError:
return False
return is_course_in_course_runs
def get_enterprise_learner_data(site, user):
"""
Fetch information related to enterprise and its entitlements according to
......@@ -220,7 +297,7 @@ def get_available_voucher_for_product(request, product, vouchers):
product.
Arguments:
product (Product): A product that has course_key as attribute (seat or
product (Product): A product that has course_id as attribute (seat or
bulk enrollment coupon)
request (HttpRequest): request with voucher data
vouchers: (List) List of voucher class objects for an enterprise
......@@ -229,50 +306,10 @@ def get_available_voucher_for_product(request, product, vouchers):
for voucher in vouchers:
is_valid_voucher, __ = voucher_is_valid(voucher, [product], request)
if is_valid_voucher:
voucher_course_ids = get_course_ids_from_voucher(request.site, voucher)
if product.course_id in voucher_course_ids:
voucher_offer = voucher.offers.first()
offer_range = voucher_offer.condition.range
if offer_range.contains_product(product):
return voucher
def get_course_ids_from_voucher(site, voucher):
"""
Get site base list of course run keys from the provided voucher object.
Arguments:
site: (django.contrib.sites.Site) site instance
voucher (Voucher): voucher class object
Returns:
list of course ids
"""
voucher_offer = voucher.offers.first()
offer_range = voucher_offer.condition.range
if offer_range.course_catalog:
try:
course_catalog = get_course_catalogs(site=site, resource_id=offer_range.course_catalog)
except (ConnectionError, SlumberBaseException, Timeout):
logger.error('Unable to connect to Course Catalog service for course catalogs.')
return None
try:
course_runs = get_catalog_course_runs(site, course_catalog.get('query'))['results']
except (ConnectionError, SlumberBaseException, Timeout, KeyError):
logger.error('Unable to get course runs from Course Catalog service.')
return None
voucher_course_ids = [course_run.get('key') for course_run in course_runs if course_run.get('key')]
elif offer_range.catalog_query:
try:
course_runs = get_catalog_course_runs(site, offer_range.catalog_query)['results']
except (ConnectionError, SlumberBaseException, Timeout, KeyError):
logger.error('Unable to get course runs from Course Catalog service.')
return None
voucher_course_ids = [course_run.get('key') for course_run in course_runs if course_run.get('key')]
else:
stock_records = offer_range.catalog.stock_records.all()
seats = Product.objects.filter(id__in=[sr.product.id for sr in stock_records])
voucher_course_ids = [seat.course_id for seat in seats]
return voucher_course_ids
# Explicitly return None in case product has no valid voucher
return None
......@@ -110,7 +110,7 @@ class EnterpriseServiceMockMixin(object):
def mock_enterprise_learner_api_for_learner_with_invalid_response(self):
"""
Helper function to register enterprise learner API endpoint for a
learner with invalid API reponse structure.
learner with invalid API response structure.
"""
enterprise_learner_api_response = {
'count': 0,
......@@ -151,6 +151,49 @@ class EnterpriseServiceMockMixin(object):
content_type='application/json'
)
def mock_enterprise_learner_api_for_learner_with_invalid_entitlements_response(self):
"""
Helper function to register enterprise learner API endpoint for a
learner with partial invalid API response structure for the enterprise
customer entitlements.
"""
enterprise_learner_api_response = {
'count': 0,
'num_pages': 1,
'current_page': 1,
'results': [
{
'enterprise_customer': {
'uuid': 'cf246b88-d5f6-4908-a522-fc307e0b0c59',
'name': 'TestShib',
'catalog': 1,
'active': True,
'site': {
'domain': 'example.com',
'name': 'example.com'
},
'invalid-unexpected-enterprise_customer_entitlements-key': [
{
'enterprise_customer': 'cf246b88-d5f6-4908-a522-fc307e0b0c59',
'entitlement_id': 1
}
]
}
}
],
'next': None,
'start': 0,
'previous': None
}
enterprise_learner_api_response_json = json.dumps(enterprise_learner_api_response)
httpretty.register_uri(
method=httpretty.GET,
uri=self.ENTERPRISE_LEARNER_URL,
body=enterprise_learner_api_response_json,
content_type='application/json'
)
def mock_enterprise_learner_api_for_failure(self):
"""
Helper function to register enterprise learner API endpoint for a
......
......@@ -83,6 +83,11 @@ def retrieve_voucher(obj):
return obj.attr.coupon_vouchers.vouchers.first()
def retrieve_all_vouchers(obj):
"""Helper method to retrieve all vouchers from coupon. """
return obj.attr.coupon_vouchers.vouchers.all()
def retrieve_voucher_usage(obj):
"""Helper method to retrieve usage from voucher. """
return retrieve_voucher(obj).usage
......
......@@ -179,7 +179,7 @@ class CatalogViewSetTest(CatalogMixin, CourseCatalogMockMixin, CourseCatalogServ
empty results list in case the Course Discovery API fails to return
data.
"""
self.mock_course_discovery_api_for_failure()
self.mock_course_discovery_api_for_catalogs_with_failure(ConnectionError)
request = self.prepare_request('/api/v2/coupons/course_catalogs/')
response = CatalogViewSet().course_catalogs(request)
......
......@@ -7,9 +7,12 @@ from django.core.cache import cache
from django.db import models
from django.utils.translation import ugettext_lazy as _
from oscar.apps.offer.abstract_models import AbstractBenefit, AbstractConditionalOffer, AbstractRange
from requests.exceptions import ConnectionError, Timeout
from slumber.exceptions import SlumberBaseException
from threadlocals.threadlocals import get_current_request
from ecommerce.core.utils import log_message_and_raise_validation_error
from ecommerce.courses.utils import get_course_catalogs
class Benefit(AbstractBenefit):
......@@ -220,21 +223,30 @@ class Range(AbstractRange):
if self.course_seat_types:
validate_credit_seat_type(self.course_seat_types)
def run_catalog_query(self, product):
def run_catalog_query(self, product, query=None):
"""
Retrieve the results from running the query contained in catalog_query field.
"""
if not query:
query = self.catalog_query
request = get_current_request()
partner_code = request.site.siteconfiguration.partner.short_code
cache_key = hashlib.md5(
'catalog_query_contains [{}] [{}]'.format(self.catalog_query, product.course_id)
'{site_domain}_{partner_code}_catalog_query_contains_{course_id}_{query}'.format(
site_domain=request.site.domain,
partner_code=partner_code,
course_id=product.course_id,
query=query
)
).hexdigest()
response = cache.get(cache_key)
if not response: # pragma: no cover
request = get_current_request()
try:
response = request.site.siteconfiguration.course_catalog_api_client.course_runs.contains.get(
query=self.catalog_query,
query=query,
course_run_ids=product.course_id,
partner=request.site.siteconfiguration.partner.short_code
partner=partner_code
)
cache.set(cache_key, response, settings.COURSES_API_CACHE_TIMEOUT)
except: # pylint: disable=bare-except
......@@ -246,7 +258,21 @@ class Range(AbstractRange):
"""
Assert if the range contains the product.
"""
if self.catalog_query and self.course_seat_types:
if self.course_catalog:
request = get_current_request()
try:
course_catalog = get_course_catalogs(site=request.site, resource_id=self.course_catalog)
except (ConnectionError, SlumberBaseException, Timeout):
raise Exception(
'Unable to connect to Course Catalog service for catalog with id [%s].' % self.course_catalog
)
response = self.run_catalog_query(product, course_catalog.get('query'))
# Range can have a catalog query and 'regular' products in it,
# therefor an OR is used to check for both possibilities.
return ((response['course_runs'][product.course_id]) or
super(Range, self).contains_product(product)) # pylint: disable=bad-super-call
elif self.catalog_query and self.course_seat_types:
if product.attr.certificate_type.lower() in self.course_seat_types: # pylint: disable=unsupported-membership-test
response = self.run_catalog_query(product)
# Range can have a catalog query and 'regular' products in it,
......
......@@ -13,16 +13,18 @@ from oscar.test import factories
from ecommerce.core.tests.decorators import mock_course_catalog_api_client
from ecommerce.coupons.tests.mixins import CourseCatalogMockMixin, CouponMixin
from ecommerce.courses.tests.mixins import CourseCatalogServiceMockMixin
from ecommerce.extensions.catalogue.tests.mixins import CourseCatalogTestMixin
from ecommerce.tests.testcases import TestCase
Catalog = get_model('catalogue', 'Catalog')
ConditionalOffer = get_model('offer', 'ConditionalOffer')
Range = get_model('offer', 'Range')
@ddt.ddt
class RangeTests(CouponMixin, CourseCatalogTestMixin, CourseCatalogMockMixin, TestCase):
class RangeTests(CouponMixin, CourseCatalogServiceMockMixin, CourseCatalogTestMixin, CourseCatalogMockMixin, TestCase):
def setUp(self):
super(RangeTests, self).setUp()
......@@ -100,7 +102,15 @@ class RangeTests(CouponMixin, CourseCatalogTestMixin, CourseCatalogMockMixin, Te
request.site = self.site
self.range.catalog_query = 'key:*'
cache_key = hashlib.md5('catalog_query_contains [{}] [{}]'.format('key:*', seat.course_id)).hexdigest()
partner_code = request.site.siteconfiguration.partner.short_code
cache_key = hashlib.md5(
'{site_domain}_{partner_code}_catalog_query_contains_{course_id}_{query}'.format(
site_domain=request.site.domain,
partner_code=partner_code,
course_id=seat.course_id,
query=self.range.catalog_query
)
).hexdigest()
cached_response = cache.get(cache_key)
self.assertIsNone(cached_response)
......@@ -129,6 +139,54 @@ class RangeTests(CouponMixin, CourseCatalogTestMixin, CourseCatalogMockMixin, Te
@httpretty.activate
@mock_course_catalog_api_client
def test_course_catalog_query_range_contains_product(self):
"""
Verify that the method "contains_product" returns True (boolean) if a
product is in it's range for a course catalog Range.
"""
catalog_query = 'key:*'
course, seat = self.create_course_and_seat()
self.mock_dynamic_catalog_contains_api(query=catalog_query, course_run_ids=[course.id])
false_response = self.range.contains_product(seat)
self.assertFalse(false_response)
course_catalog_id = 1
self.mock_course_discovery_api_for_catalog_by_resource_id(
catalog_id=course_catalog_id, catalog_query=catalog_query
)
self.range.catalog_query = None
self.range.course_seat_types = None
self.range.course_catalog = course_catalog_id
self.range.save()
response = self.range.contains_product(seat)
self.assertTrue(response)
@httpretty.activate
@mock_course_catalog_api_client
def test_course_catalog_query_range_contains_product_for_failure(self):
"""
Verify that the method "contains_product" raises exception if the
method "get_course_catalogs" is unable to get the catalog from course
catalog service for a course catalog Range.
"""
__, seat = self.create_course_and_seat()
course_catalog_id = 1
self.range.catalog_query = None
self.range.course_seat_types = None
self.range.course_catalog = course_catalog_id
self.range.save()
with self.assertRaises(Exception) as error:
self.range.contains_product(seat)
expected_exception_message = 'Unable to connect to Course Catalog service for catalog with id [%s].' %\
self.range.course_catalog
self.assertEqual(error.exception.message, expected_exception_message)
@httpretty.activate
@mock_course_catalog_api_client
def test_query_range_all_products(self):
"""
all_products() should return seats from the query.
......
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