Commit 240462ae by zubair-arbi Committed by Zubair Afzal

automated entitlemetns applying for the enterprise learners

ENT-121
parent f69662f3
......@@ -330,6 +330,20 @@ class SiteConfiguration(models.Model):
return EdxRestApiClient(settings.COURSE_CATALOG_API_URL, jwt=self.access_token)
@cached_property
def enterprise_api_client(self):
"""
Constructs a Slumber-based REST API client for the provided site.
Example:
site.siteconfiguration.enterprise_api_client.enterprise-learner(learner.username).get()
Returns:
EdxRestApiClient: The client to access the Enterprise service.
"""
return EdxRestApiClient(settings.ENTERPRISE_API_URL, jwt=self.access_token)
class User(AbstractUser):
"""Custom user model for use with OIDC."""
......
......@@ -41,3 +41,42 @@ def mock_course_catalog_api_client(test):
if isinstance(test, type):
return decorate_class(test)
return decorate_callable(test)
def mock_enterprise_api_client(test):
"""
Custom decorator for mocking the property "enterprise_api_client" of
siteconfiguration to construct a new instance of EdxRestApiClient with a
dummy jwt value.
"""
def decorate_class(klass):
for attr in dir(klass):
# Decorate only callable unit tests.
if not attr.startswith('test_'):
continue
attr_value = getattr(klass, attr)
if not hasattr(attr_value, '__call__'):
continue
setattr(klass, attr, decorate_callable(attr_value))
return klass
def decorate_callable(test):
@functools.wraps(test)
def wrapper(*args, **kw):
with mock.patch(
'ecommerce.core.models.SiteConfiguration.enterprise_api_client',
mock.PropertyMock(
return_value=EdxRestApiClient(
settings.ENTERPRISE_API_URL,
jwt='auth-token'
)
)
):
return test(*args, **kw)
return wrapper
if isinstance(test, type):
return decorate_class(test)
return decorate_callable(test)
......@@ -18,6 +18,7 @@ from ecommerce.tests.mixins import LmsApiMockMixin
from ecommerce.tests.testcases import TestCase
COURSE_CATALOG_API_URL = 'https://catalog.example.com/api/v1/'
ENTERPRISE_API_URL = 'https://enterprise.example.com/api/v1/'
def _make_site_config(payment_processors_str, site_id=1):
......@@ -365,6 +366,22 @@ class SiteConfigurationTests(TestCase):
self.assertIsInstance(client_auth, SuppliedJwtAuth)
self.assertEqual(client_auth.token, token)
@httpretty.activate
@override_settings(ENTERPRISE_API_URL=ENTERPRISE_API_URL)
def test_enterprise_api_client(self):
"""
Verify the property "enterprise_api_client" returns a Slumber-based
REST API client for enterprise service API.
"""
token = self.mock_access_token_response()
client = self.site.siteconfiguration.enterprise_api_client
client_store = client._store # pylint: disable=protected-access
client_auth = client_store['session'].auth
self.assertEqual(client_store['base_url'], ENTERPRISE_API_URL)
self.assertIsInstance(client_auth, SuppliedJwtAuth)
self.assertEqual(client_auth.token, token)
class HelperMethodTests(TestCase):
""" Tests helper methods in models.py """
......
......@@ -50,12 +50,17 @@ class CourseCatalogMockMixin(object):
content_type='application/json'
)
def mock_dynamic_catalog_course_runs_api(self, course_run=None, query=None, course_run_info=None):
""" Helper function to register a dynamic course catalog API endpoint for the course run information. """
def mock_dynamic_catalog_course_runs_api(self, course_run=None, partner_code=None, query=None,
course_run_info=None):
"""
Helper function to register a course catalog API endpoint for getting
course runs information.
"""
if not course_run_info:
course_run_info = {
'count': 1,
'next': 'path/to/next/page',
'next': None,
'previous': None,
'results': [{
'key': course_run.id,
'title': course_run.name,
......@@ -82,6 +87,18 @@ class CourseCatalogMockMixin(object):
content_type='application/json'
)
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*'
)
httpretty.register_uri(
httpretty.GET,
course_run_url_with_query_and_partner_code,
body=course_run_info_json,
content_type='application/json'
)
course_run_url_with_key = '{}course_runs/{}/'.format(
settings.COURSE_CATALOG_API_URL,
course_run.id if course_run else 'course-v1:test+test+test'
......
......@@ -5,34 +5,91 @@ from django.conf import settings
from django.core.cache import cache
from oscar.core.loading import get_model
from ecommerce.courses.utils import traverse_pagination
Product = get_model('catalogue', 'Product')
def get_range_catalog_query_results(limit, query, site, offset=None):
def get_catalog_course_runs(site, query, limit=None, offset=None):
"""
Get catalog query results
Get course runs for a site on the basis of provided query from the Course
Catalog API.
This method will get all course runs by recursively retrieving API
next urls in the API response if no limit is provided.
Arguments:
limit (int): Number of results per page
offset (int): Page offset
query (str): ElasticSearch Query
site (Site): Site object containing Site Configuration data
offset (int): Page offset
Example:
>>> get_catalog_course_runs(site, query, limit=1)
{
"count": 1,
"next": "None",
"previous": "None",
"results": [{
"key": "course-v1:edX+DemoX+Demo_Course",
"title": edX Demonstration Course,
"start": "2016-05-01T00:00:00Z",
"image": {
"src": "path/to/the/course/image"
},
"enrollment_end": None
}],
}
Returns:
dict: Query seach results received from Course Catalog API
dict: Query search results for course runs received from Course
Catalog API
Raises:
ConnectionError: requests exception "ConnectionError"
SlumberBaseException: slumber exception "SlumberBaseException"
Timeout: requests exception "Timeout"
"""
api_resource_name = 'course_runs'
partner_code = site.siteconfiguration.partner.short_code
cache_key = 'course_runs_{}_{}_{}_{}'.format(query, limit, offset, partner_code)
cache_key = '{site_domain}_{partner_code}_{resource}_{query}_{limit}_{offset}'.format(
site_domain=site.domain,
partner_code=partner_code,
resource=api_resource_name,
query=query,
limit=limit,
offset=offset
)
cache_key = hashlib.md5(cache_key).hexdigest()
response = cache.get(cache_key)
if not response:
response = site.siteconfiguration.course_catalog_api_client.course_runs.get(
limit=limit,
offset=offset,
q=query,
partner=partner_code
)
api = site.siteconfiguration.course_catalog_api_client
endpoint = getattr(api, api_resource_name)
if limit:
response = endpoint().get(
partner=partner_code,
q=query,
limit=limit,
offset=offset
)
else:
response = endpoint().get(
partner=partner_code,
q=query
)
all_response_results = traverse_pagination(response, endpoint)
response = {
'count': len(all_response_results),
'next': 'None',
'previous': 'None',
'results': all_response_results,
}
cache.set(cache_key, response, settings.COURSES_API_CACHE_TIMEOUT)
return response
......
......@@ -17,25 +17,18 @@ class CourseCatalogServiceMockMixin(object):
super(CourseCatalogServiceMockMixin, self).setUp()
cache.clear()
def mock_course_discovery_api_for_catalog_by_resource_id(self):
def mock_course_discovery_api_for_catalog_by_resource_id(self, 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 = {
'count': 1,
'next': None,
'previous': None,
'results': [
{
'id': catalog_id,
'name': 'Catalog {}'.format(catalog_id),
'query': 'title: *',
'courses_count': 0,
'viewers': []
}
]
'id': catalog_id,
'name': 'Catalog {}'.format(catalog_id),
'query': catalog_query,
'courses_count': 0,
'viewers': []
}
course_discovery_api_response_json = json.dumps(course_discovery_api_response)
single_catalog_uri = '{}{}/'.format(self.COURSE_DISCOVERY_CATALOGS_URL, catalog_id)
......
......@@ -124,9 +124,7 @@ class GetCourseCatalogUtilTests(CourseCatalogServiceMockMixin, TestCase):
self.assertIsNone(cached_course_catalog)
response = get_course_catalogs(self.request.site, catalog_id)
self.assertEqual(response['count'], 1)
self.assertEqual(response['results'][0]['name'], 'Catalog {}'.format(catalog_id))
self.assertEqual(response['name'], 'Catalog {}'.format(catalog_id))
cached_course = cache.get(cache_key)
self.assertEqual(cached_course, response)
......
......@@ -45,6 +45,11 @@ def get_course_catalogs(site, resource_id=None):
Returns:
dict: Course catalogs received from Course Catalog API
Raises:
ConnectionError: requests exception "ConnectionError"
SlumberBaseException: slumber exception "SlumberBaseException"
Timeout: requests exception "Timeout"
"""
resource = 'catalogs'
base_cache_key = '{}.catalog.api.data'.format(site.domain)
......
"""
This package contains all workflows and communications related to the Open edX
Enterprise service.
"""
import json
import httpretty
from django.conf import settings
from django.core.cache import cache
class EnterpriseServiceMockMixin(object):
"""
Mocks for the Open edX service 'Enterprise Service' responses.
"""
ENTERPRISE_LEARNER_URL = '{}enterprise-learner/'.format(
settings.ENTERPRISE_API_URL,
)
def setUp(self):
super(EnterpriseServiceMockMixin, self).setUp()
cache.clear()
def mock_enterprise_learner_api(self, catalog_id=1, entitlement_id=1):
"""
Helper function to register enterprise learner API endpoint.
"""
enterprise_learner_api_response = {
'count': 1,
'num_pages': 1,
'current_page': 1,
'results': [
{
'enterprise_customer': {
'uuid': 'cf246b88-d5f6-4908-a522-fc307e0b0c59',
'name': 'TestShib',
'catalog': catalog_id,
'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': entitlement_id
}
]
},
'user_id': 5,
'user': {
'username': 'verified',
'first_name': '',
'last_name': '',
'email': 'verified@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': 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_learner_with_no_enterprise(self):
"""
Helper function to register enterprise learner API endpoint for a
learner which is not associated with any enterprise.
"""
enterprise_learner_api_response = {
'count': 0,
'num_pages': 1,
'current_page': 1,
'results': [],
'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_learner_with_invalid_response(self):
"""
Helper function to register enterprise learner API endpoint for a
learner with invalid API reponse structure.
"""
enterprise_learner_api_response = {
'count': 0,
'num_pages': 1,
'current_page': 1,
'results': [
{
'invalid-unexpected-key': {
'enterprise_customer': {
'uuid': 'cf246b88-d5f6-4908-a522-fc307e0b0c59',
'name': 'TestShib',
'catalog': 1,
'active': True,
'site': {
'domain': 'example.com',
'name': 'example.com'
},
'enterprise_customer_entitlements': [
{
'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
failure.
"""
httpretty.register_uri(
method=httpretty.GET,
uri=self.ENTERPRISE_LEARNER_URL,
responses=[
httpretty.Response(body='{}', content_type='application/json', status_code=500)
]
)
"""
Helper methods for enterprise app.
"""
from django.conf import settings
import waffle
def is_enterprise_feature_enabled():
"""
Returns boolean indicating whether enterprise feature is enabled or
disabled.
Example:
>> is_enterprise_feature_enabled()
True
Returns:
(bool): True if enterprise feature is enabled else False
"""
is_enterprise_enabled = waffle.switch_is_active(settings.ENABLE_ENTERPRISE_ON_RUNTIME_SWITCH)
return is_enterprise_enabled
......@@ -11,7 +11,7 @@ from rest_framework_extensions.mixins import NestedViewSetMixin
from slumber.exceptions import SlumberBaseException
from ecommerce.core.constants import DEFAULT_CATALOG_PAGE_SIZE
from ecommerce.coupons.utils import get_range_catalog_query_results
from ecommerce.coupons.utils import get_catalog_course_runs
from ecommerce.extensions.api import serializers
from ecommerce.courses.utils import get_course_catalogs
......@@ -48,10 +48,10 @@ class CatalogViewSet(NestedViewSetMixin, ReadOnlyModelViewSet):
if query and seat_types:
seat_types = seat_types.split(',')
try:
response = get_range_catalog_query_results(
limit=limit,
query=query,
response = get_catalog_course_runs(
site=request.site,
query=query,
limit=limit,
offset=offset
)
results = response['results']
......
......@@ -17,7 +17,7 @@ from slumber.exceptions import SlumberBaseException
from ecommerce.core.constants import DEFAULT_CATALOG_PAGE_SIZE
from ecommerce.courses.models import Course
from ecommerce.courses.utils import get_course_info_from_catalog
from ecommerce.coupons.utils import get_range_catalog_query_results
from ecommerce.coupons.utils import get_catalog_course_runs
from ecommerce.extensions.api import serializers
from ecommerce.extensions.api.permissions import IsOffersOrIsAuthenticatedAndStaff
from ecommerce.extensions.api.v2.views import NonDestroyableModelViewSet
......@@ -140,11 +140,11 @@ class VoucherViewSet(NonDestroyableModelViewSet):
multiple_credit_providers = False
credit_provider_price = None
response = get_range_catalog_query_results(
response = get_catalog_course_runs(
site=request.site,
query=catalog_query,
limit=request.GET.get('limit', DEFAULT_CATALOG_PAGE_SIZE),
offset=request.GET.get('offset'),
query=catalog_query,
site=request.site
)
next_page = response['next']
products, stock_records = self.retrieve_course_objects(response['results'], course_seat_types)
......
......@@ -16,6 +16,7 @@ from ecommerce.core.constants import ENROLLMENT_CODE_PRODUCT_CLASS_NAME, SEAT_PR
from ecommerce.core.exceptions import SiteConfigurationError
from ecommerce.core.url_utils import get_lms_url
from ecommerce.courses.utils import get_certificate_type_display_value, get_course_info_from_catalog, mode_for_seat
from ecommerce.enterprise.entitlements import get_entitlement_voucher
from ecommerce.extensions.analytics.utils import prepare_analytics_data
from ecommerce.extensions.basket.utils import get_basket_switch_data, prepare_basket
from ecommerce.extensions.offer.utils import format_benefit_value
......@@ -23,6 +24,7 @@ from ecommerce.extensions.partner.shortcuts import get_partner_for_site
from ecommerce.extensions.payment.constants import CLIENT_SIDE_CHECKOUT_FLAG_NAME
from ecommerce.extensions.payment.forms import PaymentForm
Benefit = get_model('offer', 'Benefit')
logger = logging.getLogger(__name__)
StockRecord = get_model('partner', 'StockRecord')
......@@ -51,6 +53,10 @@ class BasketSingleItemView(View):
except StockRecord.DoesNotExist:
return HttpResponseBadRequest(_('SKU [{sku}] does not exist.').format(sku=sku))
if voucher is None:
# Find and apply the enterprise entitlement on the learner basket
voucher = get_entitlement_voucher(request, product)
# If the product isn't available then there's no reason to continue with the basket addition
purchase_info = request.strategy.fetch_for_product(product)
if not purchase_info.availability.is_available_to_buy:
......
......@@ -279,6 +279,9 @@ LOCAL_APPS = [
# Sailthru email marketing integration
'ecommerce.sailthru',
# Enterprise app for ecommerce
'ecommerce.enterprise',
]
# See: https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps
......@@ -568,3 +571,13 @@ ENROLLMENT_CODE_EXIPRATION_DATE = datetime.datetime.now() + datetime.timedelta(w
AFFILIATE_COOKIE_KEY = 'affiliate_id'
CRISPY_TEMPLATE_PACK = 'bootstrap3'
# ENTERPRISE APP CONFIGURATION
# URL for Enterprise service API
ENTERPRISE_API_URL = 'http://localhost:8000/enterprise/api/v1/'
# Cache enterprise response from Enterprise API.
ENTERPRISE_API_CACHE_TIMEOUT = 3600 # Value is in seconds
# Name for waffle switch to use for enabling enterprise features on runtime.
ENABLE_ENTERPRISE_ON_RUNTIME_SWITCH = 'enable_enterprise_on_runtime'
# END ENTERPRISE APP CONFIGURATION
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