Skip to content

Commit

Permalink
feat: add product entitlement info api (#3945)
Browse files Browse the repository at this point in the history
  • Loading branch information
aht007 authored and christopappas committed Dec 4, 2023
1 parent 8929375 commit 7b3b047
Show file tree
Hide file tree
Showing 9 changed files with 260 additions and 1 deletion.
15 changes: 15 additions & 0 deletions ecommerce/bff/subscriptions/permissions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
"""
Permission classes for Product Entitlement Information API
"""
from django.conf import settings
from rest_framework import permissions


class CanGetProductEntitlementInfo(permissions.BasePermission):
"""
Grant access to the product entitlement API for the service user or superusers.
"""

def has_permission(self, request, view):
return request.user.is_superuser or request.user.is_staff or (
request.user.username == settings.SUBSCRIPTIONS_SERVICE_WORKER_USERNAME)
7 changes: 7 additions & 0 deletions ecommerce/bff/subscriptions/serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from rest_framework import serializers


class CourseEntitlementInfoSerializer(serializers.Serializer):
course_uuid = serializers.CharField()
mode = serializers.CharField()
sku = serializers.CharField()
36 changes: 36 additions & 0 deletions ecommerce/bff/subscriptions/tests/test_permissions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"""
Tests for subscriptions API permissions
"""

from ecommerce.bff.subscriptions.permissions import CanGetProductEntitlementInfo
from ecommerce.tests.testcases import TestCase


class CanGetProductEntitlementInfoTest(TestCase):
""" Tests for get product entitlement API permissions """

def test_api_permission_staff(self):
self.user = self.create_user(is_staff=True)
self.request.user = self.user
result = CanGetProductEntitlementInfo().has_permission(self.request, None)
assert result is True

def test_api_permission_user_granted_permission(self):
user = self.create_user()
self.request.user = user

with self.settings(SUBSCRIPTIONS_SERVICE_WORKER_USERNAME=user.username):
result = CanGetProductEntitlementInfo().has_permission(self.request, None)
assert result is True

def test_api_permission_superuser(self):
self.user = self.create_user(is_superuser=True)
self.request.user = self.user
result = CanGetProductEntitlementInfo().has_permission(self.request, None)
assert result is True

def test_api_permission_user_not_granted_permission(self):
self.user = self.create_user()
self.request.user = self.user
result = CanGetProductEntitlementInfo().has_permission(self.request, None)
assert result is False
103 changes: 103 additions & 0 deletions ecommerce/bff/subscriptions/tests/test_subscription_views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import json
import uuid
from unittest import mock

from django.urls import reverse
from oscar.core.loading import get_model
from oscar.test.factories import ProductFactory
from rest_framework import status

from ecommerce.core.constants import COURSE_ENTITLEMENT_PRODUCT_CLASS_NAME
from ecommerce.coupons.tests.mixins import DiscoveryMockMixin
from ecommerce.extensions.catalogue.tests.mixins import DiscoveryTestMixin
from ecommerce.tests.factories import ProductFactory
from ecommerce.tests.testcases import TestCase

Catalog = get_model('catalogue', 'Catalog')
StockRecord = get_model('partner', 'StockRecord')
Product = get_model('catalogue', 'Product')
ProductClass = get_model('catalogue', 'ProductClass')


class ProductEntitlementInfoViewTestCase(DiscoveryTestMixin, DiscoveryMockMixin, TestCase):

def setUp(self):
super().setUp()
self.user = self.create_user(is_staff=True)
self.client.login(username=self.user.username, password=self.password)

def test_with_skus(self):
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

product2 = ProductFactory(title="test product 2", product_class=product_class, stockrecords__partner=self.partner)
product2.attr.UUID = str(uuid.uuid4())
product2.attr.certificate_type = 'professional'
product2.attr.id_verification_required = True

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

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)
])
self.assertEqual(response.status_code, status.HTTP_200_OK)
expected_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')
def test_with_valid_and_invalid_products(self, mock_log):
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

# product2 is invalid because it does not have either one or both of UUID and certificate_type
product2 = ProductFactory(title="test product 2", product_class=product_class, stockrecords__partner=self.partner)

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

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)
])

mock_log.assert_called_once_with(f"B2C_SUBSCRIPTIONS: Product {product2}"
f"does not have a UUID attribute or mode is None")
self.assertEqual(response.status_code, status.HTTP_200_OK)
expected_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)])
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
expected_data = {'error': 'Products with SKU(s) [1, 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)
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)
9 changes: 9 additions & 0 deletions ecommerce/bff/subscriptions/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@


from django.urls import path

from ecommerce.bff.subscriptions.views import ProductEntitlementInfoView

urlpatterns = [
path('product-entitlement-info/', ProductEntitlementInfoView.as_view(), name='product-entitlement-info'),
]
82 changes: 82 additions & 0 deletions ecommerce/bff/subscriptions/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import logging

from django.http import HttpResponseBadRequest
from django.utils.html import escape
from oscar.core.loading import get_model
from rest_framework import generics, status
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response

from ecommerce.bff.subscriptions.permissions import CanGetProductEntitlementInfo
from ecommerce.bff.subscriptions.serializers import CourseEntitlementInfoSerializer
from ecommerce.extensions.api.exceptions import BadRequestException
from ecommerce.extensions.api.throttles import ServiceUserThrottle
from ecommerce.extensions.partner.shortcuts import get_partner_for_site

logger = logging.getLogger(__name__)

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


class ProductEntitlementInfoView(generics.GenericAPIView):

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

def get(self, request, *args, **kwargs):
try:
skus = self._get_skus(self.request)
products = self._get_products_by_skus(skus)
available_products = self._get_available_products(products)
data = []
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)
except BadRequestException as e:
return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST)

def _get_available_products(self, products):
unavailable_product_ids = []
for product in products:
purchase_info = self.request.strategy.fetch_for_product(product)
if not purchase_info.availability.is_available_to_buy:
logger.warning('B2C_SUBSCRIPTIONS: Product [%s] is not available to buy.', product.title)
unavailable_product_ids.append(product.id)

available_products = products.exclude(id__in=unavailable_product_ids)
if not available_products:
raise BadRequestException('No product is available to buy.')
return available_products

def _get_products_by_skus(self, skus):
partner = get_partner_for_site(self.request)
products = Product.objects.filter(stockrecords__partner=partner, stockrecords__partner_sku__in=skus)
if not products:
raise BadRequestException(('Products with SKU(s) [{skus}] do not exist.').format(skus=', '.join(skus)))
return products

def _mode_for_product(self, product):
"""
Returns the purchaseable enrollment mode (aka course mode) for the specified product.
If a purchaseable enrollment mode cannot be determined, None is returned.
"""
mode = getattr(product.attr, 'certificate_type', getattr(product.attr, 'seat_type', None))
if not mode:
return None
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
2 changes: 2 additions & 0 deletions ecommerce/bff/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,6 @@

urlpatterns = [
url(r'^payment/', include(('ecommerce.bff.payment.urls', 'payment'))),
url(r'subscriptions/', include(('ecommerce.bff.subscriptions.urls', 'subscriptions')))

]
3 changes: 2 additions & 1 deletion ecommerce/extensions/api/throttles.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ def allow_request(self, request, view):
service_users = [
settings.ECOMMERCE_SERVICE_WORKER_USERNAME,
settings.PROSPECTUS_WORKER_USERNAME,
settings.DISCOVERY_WORKER_USERNAME
settings.DISCOVERY_WORKER_USERNAME,
settings.SUBSCRIPTIONS_SERVICE_WORKER_USERNAME
]
if request.user.username in service_users:
return True
Expand Down
4 changes: 4 additions & 0 deletions ecommerce/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,10 @@
# Worker used by Discovery to consume ecommerce endpoints
DISCOVERY_WORKER_USERNAME = 'discovery_worker'

# Worker used by subscriptions to consume ecommerce endpoints

SUBSCRIPTIONS_SERVICE_WORKER_USERNAME = 'subscriptions_worker'

# Used to access the Enrollment API. Set this to the same value used by the LMS.
EDX_API_KEY = 'PUT_YOUR_API_KEY_HERE'

Expand Down

0 comments on commit 7b3b047

Please sign in to comment.