Skip to content
This repository has been archived by the owner on Nov 4, 2024. It is now read-only.

feat: Embargo check for subscription Programs #3960

Merged
merged 1 commit into from
May 17, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 55 additions & 15 deletions ecommerce/bff/subscriptions/tests/test_subscription_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from rest_framework import status

from ecommerce.core.constants import COURSE_ENTITLEMENT_PRODUCT_CLASS_NAME
from ecommerce.core.models import SiteConfiguration
from ecommerce.coupons.tests.mixins import DiscoveryMockMixin
from ecommerce.extensions.catalogue.tests.mixins import DiscoveryTestMixin
from ecommerce.tests.factories import ProductFactory
Expand All @@ -25,8 +26,15 @@ def setUp(self):
super().setUp()
self.user = self.create_user(is_staff=True)
self.client.login(username=self.user.username, password=self.password)
self.ip_address = "mock_address"

def test_with_skus(self):
site_configuration = SiteConfiguration.objects.get(site=self.site)
site_configuration.enable_embargo_check = True
site_configuration.save()

@mock.patch('ecommerce.bff.subscriptions.views.embargo_check')
def test_with_skus(self, mock_embargo_check):
mock_embargo_check.return_value = True
product_class, _ = ProductClass.objects.get_or_create(name=COURSE_ENTITLEMENT_PRODUCT_CLASS_NAME)

product1 = ProductFactory(title="test product 1", product_class=product_class, stockrecords__partner=self.partner)
Expand All @@ -46,16 +54,18 @@ def test_with_skus(self):

url = reverse('bff:subscriptions:product-entitlement-info')

response = self.client.get(url, data=[('sku', product1.stockrecords.first().partner_sku),
('sku', product2.stockrecords.first().partner_sku)
])
response = self.client.post(url, data={'skus': [product1.stockrecords.first().partner_sku,
product2.stockrecords.first().partner_sku],
'user_ip_address': self.ip_address, 'username': self.user.username
})

self.assertEqual(response.status_code, status.HTTP_200_OK)
expected_data = [
expected_data = {'data': [
{'course_uuid': product1.attr.UUID, 'mode': product1.attr.certificate_type,
'sku': product1.stockrecords.first().partner_sku},
{'course_uuid': product2.attr.UUID, 'mode': product2.attr.certificate_type,
'sku': product2.stockrecords.first().partner_sku},
]
]}
self.assertCountEqual(json.loads(response.content.decode('utf-8')), expected_data)

@mock.patch('ecommerce.bff.subscriptions.views.logger.error')
Expand All @@ -75,29 +85,59 @@ def test_with_valid_and_invalid_products(self, mock_log):

url = reverse('bff:subscriptions:product-entitlement-info')

response = self.client.get(url, data=[('sku', product1.stockrecords.first().partner_sku),
('sku', product2.stockrecords.first().partner_sku)
])
response = self.client.post(url, data={'skus': [product1.stockrecords.first().partner_sku,
product2.stockrecords.first().partner_sku],
'user_ip_address': self.ip_address, 'username': self.user.username
})

mock_log.assert_called_once_with(f"B2C_SUBSCRIPTIONS: Product {product2}"
f"does not have a UUID attribute or mode is None")
f" does not have a UUID attribute or mode is None")
self.assertEqual(response.status_code, status.HTTP_200_OK)
expected_data = [
expected_data = {'data': [
{'course_uuid': product1.attr.UUID, 'mode': product1.attr.certificate_type,
'sku': product1.stockrecords.first().partner_sku}
]
]}
self.assertCountEqual(json.loads(response.content.decode('utf-8')), expected_data)

def test_with_invalid_sku(self):
url = reverse('bff:subscriptions:product-entitlement-info')
response = self.client.get(url, data=[('sku', 1), ('sku', 2)])
response = self.client.post(url, data={'skus': ["blah", "blah-2"],
'user_ip_address': self.ip_address, 'username': self.user.username
})
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
expected_data = {'error': 'Products with SKU(s) [1, 2] do not exist.'}
expected_data = {'error': 'Products with SKU(s) [blah, blah-2] do not exist.'}
self.assertCountEqual(json.loads(response.content.decode('utf-8')), expected_data)

def test_with_empty_sku(self):
url = reverse('bff:subscriptions:product-entitlement-info')
response = self.client.get(url)
response = self.client.post(url)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
expected_data = {'error': 'No SKUs provided.'}
self.assertCountEqual(json.loads(response.content.decode('utf-8')), expected_data)

@mock.patch('ecommerce.bff.subscriptions.views.embargo_check')
def test_embargo_failure(self, mock_embargo_check):
# In actual we don't expect Embargo to be False for any COURSE ENTITLEMENT product
# in its current Implementation. But we are mocking it to test the failure case.
# This will be fixed as a result of https://2u-internal.atlassian.net/browse/REV-3559

mock_embargo_check.return_value = False
product_class, _ = ProductClass.objects.get_or_create(name=COURSE_ENTITLEMENT_PRODUCT_CLASS_NAME)

product1 = ProductFactory(title="test product 1", product_class=product_class, stockrecords__partner=self.partner)
product1.attr.UUID = str(uuid.uuid4())
product1.attr.certificate_type = 'verified'
product1.attr.id_verification_required = False

product1.attr.save()
product1.refresh_from_db()

url = reverse('bff:subscriptions:product-entitlement-info')

response = self.client.post(url, data={'skus': [product1.stockrecords.first().partner_sku],
'user_ip_address': self.ip_address, 'username': self.user.username
})

self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
expected_data = {'error': 'User blocked by embargo check', 'error_code': 'embargo_failed'}
self.assertCountEqual(json.loads(response.content.decode('utf-8')), expected_data)
38 changes: 25 additions & 13 deletions ecommerce/bff/subscriptions/views.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import logging

from django.http import HttpResponseBadRequest
from django.utils.html import escape
from django.contrib.auth import get_user_model
from oscar.core.loading import get_model
from rest_framework import generics, status
from rest_framework.permissions import IsAuthenticated
Expand All @@ -12,33 +11,49 @@
from ecommerce.extensions.api.exceptions import BadRequestException
from ecommerce.extensions.api.throttles import ServiceUserThrottle
from ecommerce.extensions.partner.shortcuts import get_partner_for_site
from ecommerce.extensions.payment.utils import embargo_check

logger = logging.getLogger(__name__)

Product = get_model('catalogue', 'Product')
User = get_user_model()


class ProductEntitlementInfoView(generics.GenericAPIView):

serializer_class = CourseEntitlementInfoSerializer
permission_classes = (IsAuthenticated, CanGetProductEntitlementInfo,)
permission_classes = (IsAuthenticated, CanGetProductEntitlementInfo)
throttle_classes = [ServiceUserThrottle]

def get(self, request, *args, **kwargs):
def post(self, request, *args, **kwargs):
try:
skus = self._get_skus(self.request)
skus = request.POST.getlist('skus', [])
username = request.POST.get('username', None)
site = request.site
user_ip_address = request.POST.get('user_ip_address', None)

products = self._get_products_by_skus(skus)
available_products = self._get_available_products(products)
data = []
if request.site.siteconfiguration.enable_embargo_check:
if not embargo_check(username, site, available_products, user_ip_address):
logger.error(
'B2C_SUBSCRIPTIONS: User [%s] blocked by embargo, not continuing with the checkout process.',
username
)
return Response({'error': 'User blocked by embargo check',
'error_code': 'embargo_failed'},
status=status.HTTP_400_BAD_REQUEST)

for product in available_products:
mode = self._mode_for_product(product)
if hasattr(product.attr, 'UUID') and mode is not None:
data.append({'course_uuid': product.attr.UUID, 'mode': mode,
'sku': product.stockrecords.first().partner_sku})
else:
logger.error(f"B2C_SUBSCRIPTIONS: Product {product}"
"does not have a UUID attribute or mode is None")
return Response(data)
" does not have a UUID attribute or mode is None")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see you're just adding a space here, but I have a question for this scenario - should it return a bad request if there is no UUID or mode? What's the expected behavior?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We expect that normally for the course products in a program we would have both the UUID and and mode available but if for a particular course we don't have them then we don't block the whole flow. Let's say there are three courses in a program and one of them happens to not have mode or UUID field then we still entitle the remaining two courses and just log the error out for that particular course.

return Response({'data': data})
except BadRequestException as e:
return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST)

Expand All @@ -56,6 +71,9 @@ def _get_available_products(self, products):
return available_products

def _get_products_by_skus(self, skus):
if not skus:
raise BadRequestException(('No SKUs provided.'))

partner = get_partner_for_site(self.request)
products = Product.objects.filter(stockrecords__partner=partner, stockrecords__partner_sku__in=skus)
if not products:
Expand All @@ -74,9 +92,3 @@ def _mode_for_product(self, product):
if mode == 'professional' and not getattr(product.attr, 'id_verification_required', False):
return 'no-id-professional'
return mode

def _get_skus(self, request):
skus = [escape(sku) for sku in request.GET.getlist('sku')]
if not skus:
raise BadRequestException(('No SKUs provided.'))
return skus
9 changes: 7 additions & 2 deletions ecommerce/extensions/payment/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import re
from urllib.parse import urljoin

from django.contrib.auth import get_user_model
from django.utils.translation import ugettext_lazy as _
from oscar.core.loading import get_model

Expand All @@ -12,6 +13,7 @@
Basket = get_model('basket', 'Basket')
BasketAttribute = get_model('basket', 'BasketAttribute')
BasketAttributeType = get_model('basket', 'BasketAttributeType')
User = get_user_model()


def get_basket_program_uuid(basket):
Expand Down Expand Up @@ -99,7 +101,7 @@ def clean_field_value(value):
return re.sub(r'[\^:"\']', '', value)


def embargo_check(user, site, products):
def embargo_check(user, site, products, ip=None):
""" Checks if the user has access to purchase products by calling the LMS embargo API.

Args:
Expand All @@ -109,8 +111,11 @@ def embargo_check(user, site, products):
Returns:
Bool
"""

courses = []
_, _, ip = parse_tracking_context(user, usage='embargo')

if not ip and isinstance(user, User):
_, _, ip = parse_tracking_context(user, usage='embargo')

for product in products:
# We only are checking Seats
Expand Down