This repository has been archived by the owner on Nov 4, 2024. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 254
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: add product entitlement info api (#3945)
- Loading branch information
1 parent
8929375
commit 7b3b047
Showing
9 changed files
with
260 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
103
ecommerce/bff/subscriptions/tests/test_subscription_views.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'), | ||
] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters