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/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' 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..b35c02743c2 100644 --- a/ecommerce/extensions/iap/api/v1/views.py +++ b/ecommerce/extensions/iap/api/v1/views.py @@ -56,6 +56,7 @@ ) from ecommerce.extensions.iap.api.v1.exceptions import RefundCompletionException from ecommerce.extensions.iap.api.v1.serializers import MobileOrderSerializer +from ecommerce.extensions.iap.api.v1.utils import products_in_basket_already_purchased 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 @@ -150,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 @@ -166,6 +166,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 +194,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 +233,14 @@ class MobileCheckoutView(APIView): permission_classes = (IsAuthenticated,) def post(self, request): + basket_id = request.data.get('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) + response = CheckoutView.as_view()(request._request) # pylint: disable=W0212 if response.status_code != 200: return JsonResponse({'error': response.content.decode()}, status=response.status_code)