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

Commit

Permalink
feat: Merged basket creation and checkout in one View (#4160)
Browse files Browse the repository at this point in the history
* feat: Merged basket creation and checkout in one View
  • Loading branch information
jawad-khan authored May 14, 2024
1 parent ecfa83a commit 5d62a7b
Show file tree
Hide file tree
Showing 4 changed files with 113 additions and 14 deletions.
1 change: 1 addition & 0 deletions ecommerce/extensions/iap/api/v1/constants.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
""" Constants for iap extension apis v1 """

COURSE_ADDED_TO_BASKET = "Course added to the basket successfully"
COURSE_ADDED_AND_CHECKED_OUT_BASKET = "Course added to the basket and basket checked out successfully"
COURSE_ALREADY_PAID_ON_DEVICE = "The course upgrade has already been paid for by the user."
DISABLE_REDUNDANT_PAYMENT_CHECK_MOBILE_SWITCH_NAME = "disable_redundant_payment_check_for_mobile"
ERROR_ALREADY_PURCHASED = "You have already purchased these products"
Expand Down
76 changes: 62 additions & 14 deletions ecommerce/extensions/iap/api/v1/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ class MobileBasketAddItemsViewTests(DiscoveryMockMixin, LmsApiMockMixin, BasketM
""" MobileBasketAddItemsView view tests. """
path = reverse('iap:mobile-basket-add')
logger_name = 'ecommerce.extensions.iap.api.v1.views'
basket_status = Basket.OPEN

def setUp(self):
super(MobileBasketAddItemsViewTests, self).setUp()
Expand All @@ -115,6 +116,12 @@ def _get_response(self, product_skus, **url_params):
url += '&{}={}'.format(name, value)
return self.client.get(url)

def get_basket_from_response(self, response):
request = response.wsgi_request
basket_id = response.json()['basket_id']
basket = request.user.baskets.get(id=basket_id)
return basket

def test_add_multiple_products_to_basket(self):
""" Verify the basket accepts multiple products. """
with LogCapture(self.logger_name) as logger:
Expand All @@ -125,16 +132,15 @@ def test_add_multiple_products_to_basket(self):
logger.check((self.logger_name, 'INFO', LOGGER_STARTING_PAYMENT_FLOW % (self.user.username, skus)),
(self.logger_name, 'INFO', LOGGER_BASKET_CREATED % (self.user.username, skus)))

request = response.wsgi_request
basket = Basket.get_basket(request.user, request.site)
self.assertEqual(basket.status, Basket.OPEN)
basket = self.get_basket_from_response(response)
self.assertEqual(basket.status, self.basket_status)
self.assertEqual(basket.lines.count(), len(products))

def test_add_multiple_products_no_skus_provided(self):
""" Verify the Bad request exception is thrown when no skus are provided. """
with LogCapture(self.logger_name) as logger:
error = 'No SKUs provided.'
response = self.client.get(self.path)
response = self._get_response([])
self.assertEqual(response.status_code, 400)
self.assertEqual(response.json()['error'], error)
logger.check((self.logger_name, 'ERROR', LOGGER_BASKET_CREATION_FAILED % (self.user.username, error)))
Expand All @@ -144,7 +150,7 @@ def test_add_multiple_products_no_available_products(self):
Verify that adding multiple products to the basket results in an error if
the products do not exist.
"""
response = self.client.get(self.path, data=[('sku', 1), ('sku', 2)])
response = self._get_response([1, 2])
self.assertEqual(response.status_code, 400)
self.assertEqual(response.json()['error'], PRODUCTS_DO_NOT_EXIST.format(skus='1, 2'))

Expand Down Expand Up @@ -199,8 +205,7 @@ def test_one_already_purchased_product(self):
products = ProductFactory.create_batch(3, stockrecords__partner=self.partner)
products.append(OrderLine.objects.get(order=order).product)
response = self._get_response([product.stockrecords.first().partner_sku for product in products])
request = response.wsgi_request
basket = Basket.get_basket(request.user, request.site)
basket = self.get_basket_from_response(response)
self.assertEqual(response.status_code, 200)
self.assertEqual(basket.lines.count(), len(products) - 1)

Expand All @@ -227,9 +232,8 @@ def test_with_both_unavailable_and_available_products(self):
response = self._get_response([product.stockrecords.first().partner_sku for product in products])
self.assertEqual(response.status_code, 200)

request = response.wsgi_request
basket = Basket.get_basket(request.user, request.site)
self.assertEqual(basket.status, Basket.OPEN)
basket = self.get_basket_from_response(response)
self.assertEqual(basket.status, self.basket_status)

@ddt.data(
('false', 'False'),
Expand All @@ -241,8 +245,7 @@ def test_email_opt_in_when_explicitly_given(self, opt_in, expected_value):
Verify the email_opt_in query string is saved into a BasketAttribute.
"""
response = self._get_response(self.stock_record.partner_sku, email_opt_in=opt_in)
request = response.wsgi_request
basket = Basket.get_basket(request.user, request.site)
basket = self.get_basket_from_response(response)
basket_attribute = BasketAttribute.objects.get(
basket=basket,
attribute_type=BasketAttributeType.objects.get(name=EMAIL_OPT_IN_ATTRIBUTE),
Expand All @@ -254,8 +257,7 @@ def test_email_opt_in_when_not_given(self):
Verify that email_opt_in defaults to false if not specified.
"""
response = self._get_response(self.stock_record.partner_sku)
request = response.wsgi_request
basket = Basket.get_basket(request.user, request.site)
basket = self.get_basket_from_response(response)
basket_attribute = BasketAttribute.objects.get(
basket=basket,
attribute_type=BasketAttributeType.objects.get(name=EMAIL_OPT_IN_ATTRIBUTE),
Expand Down Expand Up @@ -680,6 +682,52 @@ def test_view_response(self):
self.assertEqual(response_data['payment_processor'], self.processor_name)


class BasketCheckoutViewTests(MobileBasketAddItemsViewTests):
path = reverse('iap:mobile-basket-checkout')
logger_name = 'ecommerce.extensions.iap.api.v1.views'
basket_status = Basket.FROZEN
processor_name = 'android-iap'

def _get_response(self, product_skus, **url_params):
formatted_url_params = ''
for name, value in url_params.items():
formatted_url_params += '&{}={}'.format(name, value)

url = self.path
if formatted_url_params:
url = '{root}?{qs}'.format(root=self.path, qs=formatted_url_params[1:])

data = {'sku': product_skus, 'payment_processor': 'android-iap'}
return self.client.post(url, data=data)

def test_authentication_required(self):
""" Verify the endpoint requires authentication. """
self.client.logout()
response = self.client.post(self.path, data={})
self.assertEqual(response.status_code, 401)

@override_settings(
PAYMENT_PROCESSORS=['ecommerce.extensions.iap.processors.android_iap.AndroidIAP']
)
def test_view_response(self):
""" Verify the endpoint returns a successful response when the user is able to checkout. """
toggle_switch(settings.PAYMENT_PROCESSOR_SWITCH_PREFIX + self.processor_name, True)
response = self._get_response(product_skus=[self.stock_record.partner_sku])
self.assertEqual(response.status_code, 200)

basket = self.get_basket_from_response(response)
self.assertEqual(basket.status, Basket.FROZEN)
response_data = response.json()
self.assertEqual(response_data['basket_id'], basket.id)

def test_invalid_processor_response(self):
""" Verify the endpoint returns a successful response when the user is able to checkout. """
data = {'sku': self.stock_record.partner_sku, 'payment_processor': 'invalid-iap'}
response = self.client.post(self.path, data=data)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.json(), {'error': 'Payment processor [invalid-iap] not found.'})


class BaseRefundTests(RefundTestMixin, AccessTokenMixin, JwtMixin, TestCase):
MODEL_LOGGER_NAME = 'ecommerce.core.models'

Expand Down
2 changes: 2 additions & 0 deletions ecommerce/extensions/iap/api/v1/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@
AndroidRefundView,
IOSRefundView,
MobileBasketAddItemsView,
MobileBasketCheckoutView,
MobileCheckoutView,
MobileCoursePurchaseExecutionView,
MobileSkusCreationView
)

urlpatterns = [
url(r'^basket-checkout/$', MobileBasketCheckoutView.as_view(), name='mobile-basket-checkout'),
url(r'^basket/add/$', MobileBasketAddItemsView.as_view(), name='mobile-basket-add'),
url(r'^checkout/$', MobileCheckoutView.as_view(), name='iap-checkout'),
url(r'^execute/$', MobileCoursePurchaseExecutionView.as_view(), name='iap-execute'),
Expand Down
48 changes: 48 additions & 0 deletions ecommerce/extensions/iap/api/v1/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
from ecommerce.extensions.basket.views import BasketLogicMixin
from ecommerce.extensions.checkout.mixins import EdxOrderPlacementMixin
from ecommerce.extensions.iap.api.v1.constants import (
COURSE_ADDED_AND_CHECKED_OUT_BASKET,
COURSE_ADDED_TO_BASKET,
COURSE_ALREADY_PAID_ON_DEVICE,
ERROR_ALREADY_PURCHASED,
Expand Down Expand Up @@ -165,6 +166,53 @@ def _get_available_products(self, request, products):
return available_products


class MobileBasketCheckoutView(MobileBasketAddItemsView):

permission_classes = (IsAuthenticated,)

def post(self, request):
# Send time when this view is called - https://openedx.atlassian.net/browse/REV-984
track_segment_event(request.site, request.user, SEGMENT_MOBILE_BASKET_ADD, {'emitted_at': time.time()})

try:
skus = self._get_skus(request)
products = self._get_products(request, skus)

logger.info(LOGGER_STARTING_PAYMENT_FLOW, request.user.username, skus)

available_products = self._get_available_products(request, products)

try:
basket = prepare_basket(request, available_products)
except AlreadyPlacedOrderException:
logger.exception(LOGGER_BASKET_ALREADY_PURCHASED, request.user.username, skus)
return JsonResponse({'error': _(ERROR_ALREADY_PURCHASED)}, status=status.HTTP_406_NOT_ACCEPTABLE)

set_email_preference_on_basket(request, basket)

logger.info(LOGGER_BASKET_CREATED, request.user.username, skus)
request.data._mutable = True # pylint: disable=W0212
request.data['basket_id'] = basket.id
response = CheckoutView.as_view()(request._request) # pylint: disable=W0212
if response.status_code != 200:
logger.exception(LOGGER_CHECKOUT_ERROR, response.content.decode(), response.status_code)
return JsonResponse({'error': response.content.decode()}, status=response.status_code)

return JsonResponse({'success': _(COURSE_ADDED_AND_CHECKED_OUT_BASKET), 'basket_id': basket.id},
status=status.HTTP_200_OK)

except BadRequestException as exc:
logger.exception(LOGGER_BASKET_CREATION_FAILED, request.user.username, str(exc))
return JsonResponse({'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST)

def _get_skus(self, request):
skus = [escape(sku) for sku in request.data.getlist('sku')]
if not skus:
raise BadRequestException(_('No SKUs provided.'))

return skus


class MobileCheckoutView(APIView):
permission_classes = (IsAuthenticated,)

Expand Down

0 comments on commit 5d62a7b

Please sign in to comment.