From 5d62a7b39604c8c19f527417baf86614f61570e9 Mon Sep 17 00:00:00 2001 From: jawad khan Date: Wed, 15 May 2024 02:35:19 +0500 Subject: [PATCH] feat: Merged basket creation and checkout in one View (#4160) * feat: Merged basket creation and checkout in one View --- ecommerce/extensions/iap/api/v1/constants.py | 1 + .../extensions/iap/api/v1/tests/test_views.py | 76 +++++++++++++++---- ecommerce/extensions/iap/api/v1/urls.py | 2 + ecommerce/extensions/iap/api/v1/views.py | 48 ++++++++++++ 4 files changed, 113 insertions(+), 14 deletions(-) diff --git a/ecommerce/extensions/iap/api/v1/constants.py b/ecommerce/extensions/iap/api/v1/constants.py index f276b888237..8c4e65f4fbe 100644 --- a/ecommerce/extensions/iap/api/v1/constants.py +++ b/ecommerce/extensions/iap/api/v1/constants.py @@ -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" diff --git a/ecommerce/extensions/iap/api/v1/tests/test_views.py b/ecommerce/extensions/iap/api/v1/tests/test_views.py index abb08faa875..d48d8a56a54 100644 --- a/ecommerce/extensions/iap/api/v1/tests/test_views.py +++ b/ecommerce/extensions/iap/api/v1/tests/test_views.py @@ -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() @@ -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: @@ -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))) @@ -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')) @@ -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) @@ -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'), @@ -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), @@ -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), @@ -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' diff --git a/ecommerce/extensions/iap/api/v1/urls.py b/ecommerce/extensions/iap/api/v1/urls.py index cc9ae3b2c9a..de53db216db 100644 --- a/ecommerce/extensions/iap/api/v1/urls.py +++ b/ecommerce/extensions/iap/api/v1/urls.py @@ -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'), diff --git a/ecommerce/extensions/iap/api/v1/views.py b/ecommerce/extensions/iap/api/v1/views.py index 7bcae9d690c..e9fd354eabe 100644 --- a/ecommerce/extensions/iap/api/v1/views.py +++ b/ecommerce/extensions/iap/api/v1/views.py @@ -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, @@ -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,)