Commit ac4a43a1 by Saleem Latif Committed by Saleem Latif

Update coupon redemption page to account for the catalog id

parent da2db893
...@@ -16,3 +16,4 @@ Vedran Karačić <vedran@edx.org> ...@@ -16,3 +16,4 @@ Vedran Karačić <vedran@edx.org>
Awais Jibran <awaisdar001@gmail.com> Awais Jibran <awaisdar001@gmail.com>
Bill DeRusha <bill@edx.org> Bill DeRusha <bill@edx.org>
Ivan Ivić <iivic@edx.org> Ivan Ivić <iivic@edx.org>
Saleem Latif <saleem_ee@hotmail.com>
...@@ -52,6 +52,41 @@ class CourseCatalogMockMixin(object): ...@@ -52,6 +52,41 @@ class CourseCatalogMockMixin(object):
content_type='application/json' content_type='application/json'
) )
def mock_fetch_course_catalog(self, catalog_id=1, expected_query="*:*", expected_status='200'):
"""
Helper function to register a catalog API endpoint for fetching catalog by catalog id.
"""
course_catalog = {
"id": 1,
"name": "All Courses",
"query": expected_query,
"courses_count": 1,
"viewers": []
}
course_run_info_json = json.dumps(course_catalog)
course_run_url = '{}catalogs/{}/'.format(
settings.COURSE_CATALOG_API_URL,
catalog_id,
)
httpretty.register_uri(
httpretty.GET, course_run_url,
body=course_run_info_json,
content_type='application/json',
status=expected_status,
)
def mock_course_catalog_api_for_catalog_voucher(
self, catalog_id=1, query="*:*", expected_status='200', course_run=None,
):
"""
Helper function to register course catalog API endpoint for fetching course run information and
catalog by catalog id.
"""
self.mock_fetch_course_catalog(catalog_id=catalog_id, expected_query=query, expected_status=expected_status)
self.mock_dynamic_catalog_course_runs_api(query=query, course_run=course_run)
def mock_dynamic_catalog_course_runs_api(self, course_run=None, partner_code=None, query=None, def mock_dynamic_catalog_course_runs_api(self, course_run=None, partner_code=None, query=None,
course_run_info=None): course_run_info=None):
""" """
......
...@@ -87,7 +87,7 @@ class GetVoucherTests(CourseCatalogTestMixin, TestCase): ...@@ -87,7 +87,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().upper() code = FuzzyText().fuzz()
voucher = VoucherFactory(code=code) voucher = VoucherFactory(code=code)
offer = ConditionalOfferFactory() offer = ConditionalOfferFactory()
voucher.offers.add(offer) voucher.offers.add(offer)
...@@ -230,7 +230,7 @@ class CouponOfferViewTests(ApiMockMixin, CouponMixin, CourseCatalogTestMixin, Lm ...@@ -230,7 +230,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().upper() code = FuzzyText().fuzz()
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)
...@@ -348,7 +348,7 @@ class CouponRedeemViewTests(CouponMixin, CourseCatalogTestMixin, LmsApiMockMixin ...@@ -348,7 +348,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().upper() code = FuzzyText().fuzz()
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)
...@@ -365,7 +365,7 @@ class CouponRedeemViewTests(CouponMixin, CourseCatalogTestMixin, LmsApiMockMixin ...@@ -365,7 +365,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().upper() code = FuzzyText().fuzz()
__, 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)
......
""" Coupon related utility functions. """ """ Coupon related utility functions. """
import logging
import hashlib import hashlib
from django.conf import settings from django.conf import settings
from django.core.cache import cache from django.core.cache import cache
from oscar.core.loading import get_model from oscar.core.loading import get_model
from slumber.exceptions import HttpNotFoundError
from ecommerce.courses.utils import traverse_pagination from ecommerce.courses.utils import traverse_pagination
from ecommerce.core.utils import get_cache_key
Product = get_model('catalogue', 'Product') Product = get_model('catalogue', 'Product')
logger = logging.getLogger(__name__)
def get_catalog_course_runs(site, query, limit=None, offset=None): def get_catalog_course_runs(site, query, limit=None, offset=None):
""" """
...@@ -106,3 +111,57 @@ def prepare_course_seat_types(course_seat_types): ...@@ -106,3 +111,57 @@ def prepare_course_seat_types(course_seat_types):
if course_seat_types: if course_seat_types:
return ','.join(seat_type.lower() for seat_type in course_seat_types) return ','.join(seat_type.lower() for seat_type in course_seat_types)
return None return None
def fetch_course_catalog(site, catalog_id):
"""
Fetch course catalog for the given catalog id.
This method will fetch catalog for given catalog id, if there is no catalog with the given
catalog id, method will return `None`.
Arguments:
site (Site): Instance of the current site.
catalog_id (int): An integer specifying the primary key value of catalog to fetch.
Example:
>>> fetch_course_catalog(site, catalog_id=1)
{
"id": 1,
"name": "All Courses",
"query": "*:*",
...
}
Returns:
(dict): A dictionary containing key/value pairs corresponding to catalog attribute/values.
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 = 'catalogs'
cache_key = get_cache_key(
site_domain=site.domain,
resource=api_resource,
catalog_id=catalog_id,
)
response = cache.get(cache_key)
if not response:
api = site.siteconfiguration.course_catalog_api_client
endpoint = getattr(api, api_resource)
try:
response = endpoint(catalog_id).get()
except HttpNotFoundError:
logger.exception("Catalog '%s' not found.", catalog_id)
raise
cache.set(cache_key, response, settings.COURSES_API_CACHE_TIMEOUT)
return response
...@@ -13,6 +13,7 @@ from opaque_keys.edx.keys import CourseKey ...@@ -13,6 +13,7 @@ from opaque_keys.edx.keys import CourseKey
from oscar.core.loading import get_model from oscar.core.loading import get_model
from oscar.test.factories import BenefitFactory, OrderLineFactory, OrderFactory, RangeFactory from oscar.test.factories import BenefitFactory, OrderLineFactory, OrderFactory, RangeFactory
from requests.exceptions import ConnectionError, Timeout from requests.exceptions import ConnectionError, Timeout
from rest_framework import status
from rest_framework.test import APIRequestFactory from rest_framework.test import APIRequestFactory
from slumber.exceptions import SlumberBaseException from slumber.exceptions import SlumberBaseException
...@@ -407,6 +408,47 @@ class VoucherViewOffersEndpointTests(CourseCatalogMockMixin, CouponMixin, Course ...@@ -407,6 +408,47 @@ class VoucherViewOffersEndpointTests(CourseCatalogMockMixin, CouponMixin, Course
'voucher_end_date': voucher.end_datetime, 'voucher_end_date': voucher.end_datetime,
}) })
@mock_course_catalog_api_client
def test_get_offers_for_course_catalog_voucher(self):
""" Verify that the course offers data is returned for a course catalog voucher. """
catalog_id = 1
catalog_query = '*:*'
# Populate database for the test case.
course, seat = self.create_course_and_seat()
new_range, __ = Range.objects.get_or_create(course_catalog=catalog_id, course_seat_types='verified')
new_range.add_product(seat)
voucher, __ = prepare_voucher(_range=new_range, benefit_value=10)
# Mock network calls
self.mock_dynamic_catalog_course_runs_api(query=catalog_query, course_run=course)
self.mock_fetch_course_catalog(catalog_id=catalog_id, expected_query=catalog_query)
benefit = voucher.offers.first().benefit
request = self.prepare_offers_listing_request(voucher.code)
offers = VoucherViewSet().get_offers(request=request, voucher=voucher)['results']
first_offer = offers[0]
# Verify that offers are returned when voucher is created using course catalog
self.assertEqual(len(offers), 1)
self.assertDictEqual(first_offer, {
'benefit': {
'type': benefit.type,
'value': benefit.value
},
'contains_verified': True,
'course_start_date': '2016-05-01T00:00:00Z',
'id': course.id,
'image_url': 'path/to/the/course/image',
'multiple_credit_providers': False,
'organization': CourseKey.from_string(course.id).org,
'credit_provider_price': None,
'seat_type': course.type,
'stockrecords': serializers.StockRecordSerializer(seat.stockrecords.first()).data,
'title': course.name,
'voucher_end_date': voucher.end_datetime,
})
def test_get_course_offer_data(self): def test_get_course_offer_data(self):
""" Verify that the course offers data is properly formatted. """ """ Verify that the course offers data is properly formatted. """
benefit = BenefitFactory() benefit = BenefitFactory()
...@@ -474,3 +516,73 @@ class VoucherViewOffersEndpointTests(CourseCatalogMockMixin, CouponMixin, Course ...@@ -474,3 +516,73 @@ class VoucherViewOffersEndpointTests(CourseCatalogMockMixin, CouponMixin, Course
self.assertEqual(offer['image_url'], '') self.assertEqual(offer['image_url'], '')
self.assertEqual(offer['course_start_date'], None) self.assertEqual(offer['course_start_date'], None)
@mock_course_catalog_api_client
def test_offers_api_endpoint_for_course_catalog_voucher(self):
"""
Verify that the course offers data is returned for a course catalog voucher.
"""
catalog_id = 1
catalog_query = '*:*'
# Populate database for the test case.
course, seat = self.create_course_and_seat()
new_range, __ = Range.objects.get_or_create(course_catalog=catalog_id, course_seat_types='verified')
new_range.add_product(seat)
voucher, __ = prepare_voucher(_range=new_range, benefit_value=10)
# Mock network calls
self.mock_course_catalog_api_for_catalog_voucher(
catalog_id=catalog_id, query=catalog_query, course_run=course
)
benefit = voucher.offers.first().benefit
request = self.prepare_offers_listing_request(voucher.code)
response = self.endpointView(request)
# Verify that offers are returned when voucher is created using course catalog
self.assertEqual(response.status_code, 200)
self.assertListEqual(
response.data['results'],
[{
'benefit': {
'type': benefit.type,
'value': benefit.value
},
'contains_verified': True,
'course_start_date': '2016-05-01T00:00:00Z',
'id': course.id,
'image_url': 'path/to/the/course/image',
'multiple_credit_providers': False,
'organization': CourseKey.from_string(course.id).org,
'credit_provider_price': None,
'seat_type': course.type,
'stockrecords': serializers.StockRecordSerializer(seat.stockrecords.first()).data,
'title': course.name,
'voucher_end_date': voucher.end_datetime,
}],
)
@mock_course_catalog_api_client
def test_get_offers_for_course_catalog_voucher_api_error(self):
"""
Verify that offers api endpoint returns proper message if course catalog api returns error.
"""
catalog_id = 1
catalog_query = '*:*'
# Populate database for the test case.
course, seat = self.create_course_and_seat()
new_range, __ = Range.objects.get_or_create(course_catalog=catalog_id, course_seat_types='verified')
new_range.add_product(seat)
voucher, __ = prepare_voucher(_range=new_range, benefit_value=10)
# Mock network calls
self.mock_course_catalog_api_for_catalog_voucher(
catalog_id=catalog_id, query=catalog_query, expected_status=status.HTTP_404_NOT_FOUND, course_run=course
)
request = self.prepare_offers_listing_request(voucher.code)
response = self.endpointView(request)
# Verify that offers are returned when voucher is created using course catalog
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
...@@ -15,9 +15,9 @@ from rest_framework.response import Response ...@@ -15,9 +15,9 @@ from rest_framework.response import Response
from slumber.exceptions import SlumberBaseException from slumber.exceptions import SlumberBaseException
from ecommerce.core.constants import DEFAULT_CATALOG_PAGE_SIZE from ecommerce.core.constants import DEFAULT_CATALOG_PAGE_SIZE
from ecommerce.coupons.utils import get_catalog_course_runs
from ecommerce.courses.models import Course from ecommerce.courses.models import Course
from ecommerce.courses.utils import get_course_info_from_catalog from ecommerce.courses.utils import get_course_info_from_catalog
from ecommerce.coupons.utils import get_catalog_course_runs, fetch_course_catalog
from ecommerce.extensions.api import serializers from ecommerce.extensions.api import serializers
from ecommerce.extensions.api.permissions import IsOffersOrIsAuthenticatedAndStaff from ecommerce.extensions.api.permissions import IsOffersOrIsAuthenticatedAndStaff
from ecommerce.extensions.api.v2.views import NonDestroyableModelViewSet from ecommerce.extensions.api.v2.views import NonDestroyableModelViewSet
...@@ -74,10 +74,10 @@ class VoucherViewSet(NonDestroyableModelViewSet): ...@@ -74,10 +74,10 @@ class VoucherViewSet(NonDestroyableModelViewSet):
try: try:
offers_data = self.get_offers(request, voucher) offers_data = self.get_offers(request, voucher)
except (ConnectionError, SlumberBaseException, Timeout): except (ConnectionError, SlumberBaseException, Timeout):
logger.error('Could not get course information.') logger.error('Could not connect to course catalog service.')
return Response(status=status.HTTP_400_BAD_REQUEST) return Response(status=status.HTTP_400_BAD_REQUEST)
except Product.DoesNotExist: except Product.DoesNotExist:
logger.error('Could not get product information for voucher with code %s.', code) logger.error('Could not locate product for voucher with code %s.', code)
return Response(status=status.HTTP_404_NOT_FOUND) return Response(status=status.HTTP_404_NOT_FOUND)
next_page = offers_data['next'] next_page = offers_data['next']
...@@ -214,9 +214,14 @@ class VoucherViewSet(NonDestroyableModelViewSet): ...@@ -214,9 +214,14 @@ class VoucherViewSet(NonDestroyableModelViewSet):
""" """
benefit = voucher.offers.first().benefit benefit = voucher.offers.first().benefit
catalog_query = benefit.range.catalog_query catalog_query = benefit.range.catalog_query
catalog_id = benefit.range.course_catalog
next_page = None next_page = None
offers = [] offers = []
if catalog_id:
catalog = fetch_course_catalog(request.site, catalog_id)
catalog_query = catalog.get("query") if catalog else catalog_query
if catalog_query: if catalog_query:
offers, next_page = self.get_offers_from_query(request, voucher, catalog_query) offers, next_page = self.get_offers_from_query(request, voucher, catalog_query)
else: else:
......
...@@ -297,7 +297,7 @@ class Range(AbstractRange): ...@@ -297,7 +297,7 @@ class Range(AbstractRange):
return len(self.all_products()) return len(self.all_products())
def all_products(self): def all_products(self):
if self.catalog_query and self.course_seat_types: if (self.catalog_query or self.course_catalog) and self.course_seat_types:
# Backbone calls the Voucher Offers API endpoint which gets the products from the Course Catalog Service # Backbone calls the Voucher Offers API endpoint which gets the products from the Course Catalog Service
return [] return []
if self.catalog: if self.catalog:
......
...@@ -635,7 +635,7 @@ def get_voucher_and_products_from_code(code): ...@@ -635,7 +635,7 @@ def get_voucher_and_products_from_code(code):
voucher_range = voucher.offers.first().benefit.range voucher_range = voucher.offers.first().benefit.range
products = voucher_range.all_products() products = voucher_range.all_products()
if products or voucher_range.catalog_query: if products or voucher_range.catalog_query or voucher_range.course_catalog:
# List of products is empty in case of Multi-course coupon # List of products is empty in case of Multi-course coupon
return voucher, products return voucher, products
else: else:
......
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