From 8c53720071b81c6f7e1e1b3d08cbe2473a28293a Mon Sep 17 00:00:00 2001 From: moeez96 Date: Mon, 20 Mar 2023 14:28:53 +0500 Subject: [PATCH 1/4] feat: Error if products in basket are already purchased --- ecommerce/extensions/iap/api/v1/utils.py | 19 +++++++++++++++++++ ecommerce/extensions/iap/api/v1/views.py | 14 ++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 ecommerce/extensions/iap/api/v1/utils.py diff --git a/ecommerce/extensions/iap/api/v1/utils.py b/ecommerce/extensions/iap/api/v1/utils.py new file mode 100644 index 00000000000..cb694ce6895 --- /dev/null +++ b/ecommerce/extensions/iap/api/v1/utils.py @@ -0,0 +1,19 @@ + + +from oscar.core.loading import get_model + +from ecommerce.extensions.order.utils import UserAlreadyPlacedOrder + +Product = get_model('catalogue', 'Product') + + +def products_in_basket_already_purchased(user, basket, site): + """ + Check if products in a basket are already purchased by a user. + """ + products = Product.objects.filter(line__order__basket=basket) + for product in products: + if not product.is_enrollment_code_product and \ + UserAlreadyPlacedOrder.user_already_placed_order(user=user, product=product, site=site): + return True + return False diff --git a/ecommerce/extensions/iap/api/v1/views.py b/ecommerce/extensions/iap/api/v1/views.py index fb75714a5a0..c09f1c8cb36 100644 --- a/ecommerce/extensions/iap/api/v1/views.py +++ b/ecommerce/extensions/iap/api/v1/views.py @@ -56,7 +56,11 @@ ) from ecommerce.extensions.iap.api.v1.exceptions import RefundCompletionException from ecommerce.extensions.iap.api.v1.serializers import MobileOrderSerializer +<<<<<<< Updated upstream from ecommerce.extensions.iap.models import IAPProcessorConfiguration +======= +from ecommerce.extensions.iap.api.v1.utils import products_in_basket_already_purchased +>>>>>>> Stashed changes from ecommerce.extensions.iap.processors.android_iap import AndroidIAP from ecommerce.extensions.iap.processors.ios_iap import IOSIAP from ecommerce.extensions.order.exceptions import AlreadyPlacedOrderException @@ -166,6 +170,9 @@ def _get_basket(self, request, basket_id): Applicator().apply(basket, basket.owner, self.request) basket_add_organization_attribute(basket, self.request.GET) + if products_in_basket_already_purchased(request.user, basket, request.site): + raise AlreadyPlacedOrderException + return basket # Disable atomicity for the view. Otherwise, we'd be unable to commit to the database @@ -191,6 +198,8 @@ def post(self, request): except ObjectDoesNotExist: logger.exception(LOGGER_BASKET_NOT_FOUND, basket_id) return JsonResponse({'error': ERROR_BASKET_NOT_FOUND.format(basket_id)}, status=400) + except AlreadyPlacedOrderException: + return JsonResponse({'error': _(ERROR_ALREADY_PURCHASED)}, status=406) except: # pylint: disable=bare-except error_message = ERROR_WHILE_OBTAINING_BASKET_FOR_USER.format(request.user.email) logger.exception(error_message) @@ -228,6 +237,11 @@ class MobileCheckoutView(APIView): permission_classes = (IsAuthenticated,) def post(self, request): + basket_id = request.data.get('basket_id') + basket = request.user.baskets.get(id=basket_id) + if products_in_basket_already_purchased(request.user, basket, request.site): + return JsonResponse({'error': _(ERROR_ALREADY_PURCHASED)}, status=406) + response = CheckoutView.as_view()(request._request) # pylint: disable=W0212 if response.status_code != 200: return JsonResponse({'error': response.content.decode()}, status=response.status_code) From d76c87092236fe4c90ca9bf2e95cd581b14f4527 Mon Sep 17 00:00:00 2001 From: moeez96 Date: Tue, 21 Mar 2023 11:54:26 +0500 Subject: [PATCH 2/4] refactor: Add tests, Improve error message --- .../extensions/iap/api/v1/tests/test_utils.py | 39 +++++++++++++++++++ ecommerce/extensions/iap/api/v1/views.py | 10 ++--- 2 files changed, 44 insertions(+), 5 deletions(-) create mode 100644 ecommerce/extensions/iap/api/v1/tests/test_utils.py diff --git a/ecommerce/extensions/iap/api/v1/tests/test_utils.py b/ecommerce/extensions/iap/api/v1/tests/test_utils.py new file mode 100644 index 00000000000..e0367cb7c8d --- /dev/null +++ b/ecommerce/extensions/iap/api/v1/tests/test_utils.py @@ -0,0 +1,39 @@ +import mock + +from ecommerce.courses.tests.factories import CourseFactory +from ecommerce.extensions.iap.api.v1.utils import products_in_basket_already_purchased +from ecommerce.extensions.order.utils import UserAlreadyPlacedOrder +from ecommerce.extensions.test.factories import create_basket, create_order +from ecommerce.tests.testcases import TestCase + + +class TestProductsInBasketPurchased(TestCase): + """ Tests for products_in_basket_already_purchased method. """ + + def setUp(self): + super(TestProductsInBasketPurchased, self).setUp() + self.user = self.create_user() + self.client.login(username=self.user.username, password=self.password) + + self.course = CourseFactory(partner=self.partner) + product = self.course.create_or_update_seat('verified', False, 50) + self.basket = create_basket( + owner=self.user, site=self.site, price='50.0', product_class=product.product_class + ) + create_order(site=self.site, user=self.user, basket=self.basket) + + def test_already_purchased(self): + """ + Test products in basket already purchased by user + """ + with mock.patch.object(UserAlreadyPlacedOrder, 'user_already_placed_order', return_value=True): + return_value = products_in_basket_already_purchased(self.user, self.basket, self.site) + self.assertTrue(return_value) + + def test_not_purchased_yet(self): + """ + Test products in basket not yet purchased by user + """ + with mock.patch.object(UserAlreadyPlacedOrder, 'user_already_placed_order', return_value=False): + return_value = products_in_basket_already_purchased(self.user, self.basket, self.site) + self.assertFalse(return_value) diff --git a/ecommerce/extensions/iap/api/v1/views.py b/ecommerce/extensions/iap/api/v1/views.py index c09f1c8cb36..d73db04ed6e 100644 --- a/ecommerce/extensions/iap/api/v1/views.py +++ b/ecommerce/extensions/iap/api/v1/views.py @@ -56,11 +56,8 @@ ) from ecommerce.extensions.iap.api.v1.exceptions import RefundCompletionException from ecommerce.extensions.iap.api.v1.serializers import MobileOrderSerializer -<<<<<<< Updated upstream -from ecommerce.extensions.iap.models import IAPProcessorConfiguration -======= from ecommerce.extensions.iap.api.v1.utils import products_in_basket_already_purchased ->>>>>>> Stashed changes +from ecommerce.extensions.iap.models import IAPProcessorConfiguration from ecommerce.extensions.iap.processors.android_iap import AndroidIAP from ecommerce.extensions.iap.processors.ios_iap import IOSIAP from ecommerce.extensions.order.exceptions import AlreadyPlacedOrderException @@ -238,7 +235,10 @@ class MobileCheckoutView(APIView): def post(self, request): basket_id = request.data.get('basket_id') - basket = request.user.baskets.get(id=basket_id) + try: + basket = request.user.baskets.get(id=basket_id) + except ObjectDoesNotExist: + return JsonResponse({'error': ERROR_BASKET_NOT_FOUND.format(basket_id)}, status=400) if products_in_basket_already_purchased(request.user, basket, request.site): return JsonResponse({'error': _(ERROR_ALREADY_PURCHASED)}, status=406) From 7b4a524beaad75e8017f6cc0245cf379e81a68c8 Mon Sep 17 00:00:00 2001 From: moeez96 Date: Tue, 21 Mar 2023 15:15:31 +0500 Subject: [PATCH 3/4] refactor: Update docstring --- ecommerce/extensions/iap/api/v1/views.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/ecommerce/extensions/iap/api/v1/views.py b/ecommerce/extensions/iap/api/v1/views.py index d73db04ed6e..b35c02743c2 100644 --- a/ecommerce/extensions/iap/api/v1/views.py +++ b/ecommerce/extensions/iap/api/v1/views.py @@ -151,15 +151,14 @@ def payment_processor(self): def _get_basket(self, request, basket_id): """ - Retrieve a basket using a payment ID. + Retrieve a basket using a basket ID. Arguments: - payment_id: payment_id received from PayPal. + basket_id: basket_id representing basket. Returns: - It will return related basket or log exception and return None if - duplicate payment_id received or any other exception occurred. - + It will return related basket or raise AlreadyPlacedOrderException + if products in basket have already been purchased. """ basket = request.user.baskets.get(id=basket_id) basket.strategy = request.strategy From 8eeb3525089e5cb0b5e8016bda1f4836b1a3aceb Mon Sep 17 00:00:00 2001 From: moeez96 Date: Wed, 22 Mar 2023 16:39:42 +0500 Subject: [PATCH 4/4] test: Increase coverage --- .../extensions/iap/api/v1/tests/test_views.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/ecommerce/extensions/iap/api/v1/tests/test_views.py b/ecommerce/extensions/iap/api/v1/tests/test_views.py index 2688c71992e..5895bcb95cd 100644 --- a/ecommerce/extensions/iap/api/v1/tests/test_views.py +++ b/ecommerce/extensions/iap/api/v1/tests/test_views.py @@ -480,6 +480,19 @@ def test_post_order_exception(self, mock_handle_post_order): self.assertEqual(response.status_code, expected_response_status_code) self.assertEqual(response.content, expected_response_content) + def test_already_purchased_basket(self): + with mock.patch.object(GooglePlayValidator, 'validate') as fake_google_validation: + fake_google_validation.return_value = { + 'resource': { + 'orderId': 'orderId.android.test.purchased' + } + } + with mock.patch.object(UserAlreadyPlacedOrder, 'user_already_placed_order', return_value=True): + create_order(site=self.site, user=self.user, basket=self.basket) + response = self.client.post(self.path, data=self.post_data) + self.assertEqual(response.status_code, 406) + self.assertEqual(response.json().get('error'), ERROR_ALREADY_PURCHASED) + class TestMobileCheckoutView(TestCase): """ Tests for MobileCheckoutView API view. """ @@ -533,6 +546,13 @@ def test_view_response(self): self.assertIn(reverse('iap:iap-execute'), response_data['payment_page_url']) self.assertEqual(response_data['payment_processor'], self.processor_name) + def test_already_purchased_basket(self): + with mock.patch.object(UserAlreadyPlacedOrder, 'user_already_placed_order', return_value=True): + create_order(site=self.site, user=self.user, basket=self.basket) + response = self.client.post(self.path, data=self.post_data) + self.assertEqual(response.status_code, 406) + self.assertEqual(response.json().get('error'), ERROR_ALREADY_PURCHASED) + class BaseRefundTests(RefundTestMixin, AccessTokenMixin, JwtMixin, TestCase): MODEL_LOGGER_NAME = 'ecommerce.core.models'