diff --git a/commerce_coordinator/apps/commercetools/clients.py b/commerce_coordinator/apps/commercetools/clients.py index a76a9a29..786fc143 100644 --- a/commerce_coordinator/apps/commercetools/clients.py +++ b/commerce_coordinator/apps/commercetools/clients.py @@ -737,3 +737,39 @@ def retire_customer_anonymize_fields( f"error correlation id {err.correlation_id} and error/s: {err.errors}" ) raise err + + def is_first_time_discount_eligible(self, email: str, code: str) -> bool: + """ + Check if a user is eligible for a first time discount + Args: + email (str): Email of the user + code (str): First time discount code + Returns (bool): True if the user is eligible for a first time discount + """ + try: + discounted_orders = self.base_client.orders.query( + where=[ + "customerEmail=:email", + "orderState=:orderState", + "discountCodes(discountCode is defined)" + ], + predicate_var={'email': email, 'orderState': 'Complete'}, + expand=["discountCodes[*].discountCode"] + ) + + if discounted_orders.total < 1: + return True + + discounted_orders = discounted_orders.results + + for order in discounted_orders: + discount_code = order.discount_codes[0].discount_code.obj.code + if discount_code == code: + return False + + return True + except CommercetoolsError as err: # pragma no cover + # Logs & ignores version conflict errors due to duplicate Commercetools messages + handle_commercetools_error(err, f"Unable to check if user {email} is eligible for a " + f"first time discount", True) + return True diff --git a/commerce_coordinator/apps/commercetools/tests/test_clients.py b/commerce_coordinator/apps/commercetools/tests/test_clients.py index 300f7eed..f4b6fc71 100644 --- a/commerce_coordinator/apps/commercetools/tests/test_clients.py +++ b/commerce_coordinator/apps/commercetools/tests/test_clients.py @@ -883,6 +883,89 @@ def test_update_customer_with_anonymized_fields_exception(self): log_mock.assert_called_once_with(expected_message) + def test_is_first_time_discount_eligible_success(self): + base_url = self.client_set.get_base_url_from_client() + email = 'email@example.com' + code = 'discount-code' + + mock_orders = { + "total": 1, + "results": [ + { + "discountCodes": [ + { + "discountCode": { + "obj": { + "code": 'another-code' + } + } + } + ] + } + ] + } + + with requests_mock.Mocker(real_http=True, case_sensitive=False) as mocker: + mocker.get( + f"{base_url}orders", + json=mock_orders, + status_code=200 + ) + + result = self.client_set.client.is_first_time_discount_eligible(email, code) + self.assertTrue(result) + + def test_is_first_time_discount_not_eligible(self): + base_url = self.client_set.get_base_url_from_client() + email = 'email@example.com' + code = 'discount-code' + + mock_orders = { + "total": 1, + "results": [ + { + "discountCodes": [ + { + "discountCode": { + "obj": { + "code": code + } + } + } + ] + } + ] + } + + with requests_mock.Mocker(real_http=True, case_sensitive=False) as mocker: + mocker.get( + f"{base_url}orders", + json=mock_orders, + status_code=200 + ) + + result = self.client_set.client.is_first_time_discount_eligible(email, code) + self.assertFalse(result) + + def test_is_first_time_discount_eligible_invalid_email(self): + invalid_email = "invalid_email@example.com" + code = 'discount-code' + base_url = self.client_set.get_base_url_from_client() + + mock_orders = { + "total": 0 + } + + with requests_mock.Mocker(real_http=True, case_sensitive=False) as mocker: + mocker.get( + f"{base_url}orders", + json=mock_orders, + status_code=200 + ) + + result = self.client_set.client.is_first_time_discount_eligible(invalid_email, code) + self.assertTrue(result) + class PaginatedResultsTest(TestCase): """Tests for the simple logic in our Paginated Results Class""" diff --git a/commerce_coordinator/apps/lms/serializers.py b/commerce_coordinator/apps/lms/serializers.py index 7b0d2b16..ef8a7bff 100644 --- a/commerce_coordinator/apps/lms/serializers.py +++ b/commerce_coordinator/apps/lms/serializers.py @@ -84,3 +84,11 @@ class UserRetiredInputSerializer(CoordinatorSerializer): Serializer for User Deactivation/Retirement input validation """ edx_lms_user_id = serializers.IntegerField(allow_null=False) + + +class FirstTimeDiscountInputSerializer(CoordinatorSerializer): + """ + Serializer for First Time Discount input validation + """ + email = serializers.EmailField(required=True) + code = serializers.CharField(required=True) diff --git a/commerce_coordinator/apps/lms/tests/test_views.py b/commerce_coordinator/apps/lms/tests/test_views.py index 7eb2bb7d..a7c35753 100644 --- a/commerce_coordinator/apps/lms/tests/test_views.py +++ b/commerce_coordinator/apps/lms/tests/test_views.py @@ -382,3 +382,88 @@ def test_post_with_unexpected_exception_fails(self, mock_filter): response = self.client.post(self.url, self.valid_payload, format='json') self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR) + + +@ddt.ddt +class FirstTimeDiscountEligibleViewTests(APITestCase): + """ + Tests for the FirstTimeDiscountEligibleView to check if a user is eligible for a first-time discount. + """ + + test_user_username = 'test' + test_user_email = 'test@example.com' + test_user_password = 'secret' + test_discount = 'first_time_discount' + + valid_payload = { + 'email': test_user_email, + 'code': test_discount, + } + + invalid_payload = { + 'email': None, + 'code': 'any_discount', + } + + url = reverse('lms:first_time_discount_eligible') + + def setUp(self): + super().setUp() + self.user = User.objects.create_user( + self.test_user_username, + self.test_user_email, + self.test_user_password, + is_staff=True, + ) + + def tearDown(self): + super().tearDown() + self.client.logout() + + def authenticate_user(self): + self.client.login(username=self.test_user_username, password=self.test_user_password) + self.client.force_authenticate(user=self.user) + + @patch( + 'commerce_coordinator.apps.commercetools.clients.CommercetoolsAPIClient' + '.is_first_time_discount_eligible' + ) + def test_get_with_valid_email_eligibility_true(self, mock_is_first_time_discount_eligible): + """ + Test case where the user is eligible for a first-time discount. + """ + self.authenticate_user() + mock_is_first_time_discount_eligible.return_value = True + + response = self.client.post(self.url, self.valid_payload, format='json') + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, {"is_eligible": True}) + mock_is_first_time_discount_eligible.assert_called_once_with(self.test_user_email, self.test_discount) + + @patch( + 'commerce_coordinator.apps.commercetools.clients.CommercetoolsAPIClient' + '.is_first_time_discount_eligible' + ) + def test_get_with_valid_email_eligibility_false(self, mock_is_first_time_discount_eligible): + """ + Test case where the user is not eligible for a first-time discount. + """ + self.authenticate_user() + mock_is_first_time_discount_eligible.return_value = False + + response = self.client.post(self.url, self.valid_payload, format='json') + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, {"is_eligible": False}) + mock_is_first_time_discount_eligible.assert_called_once_with(self.test_user_email, self.test_discount) + + def test_get_with_missing_email_fails(self): + """ + Test case where the email is not provided in the request query params. + """ + self.authenticate_user() + + response = self.client.post(self.url, self.invalid_payload, format='json') + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) diff --git a/commerce_coordinator/apps/lms/urls.py b/commerce_coordinator/apps/lms/urls.py index 9ea99966..dee18971 100644 --- a/commerce_coordinator/apps/lms/urls.py +++ b/commerce_coordinator/apps/lms/urls.py @@ -5,6 +5,7 @@ from django.urls import path from commerce_coordinator.apps.lms.views import ( + FirstTimeDiscountEligibleView, OrderDetailsRedirectView, PaymentPageRedirectView, RefundView, @@ -16,5 +17,6 @@ path('payment_page_redirect/', PaymentPageRedirectView.as_view(), name='payment_page_redirect'), path('order_details_page_redirect/', OrderDetailsRedirectView.as_view(), name='order_details_page_redirect'), path('refund/', RefundView.as_view(), name='refund'), - path('user_retirement/', RetirementView.as_view(), name='user_retirement') + path('user_retirement/', RetirementView.as_view(), name='user_retirement'), + path('first-time-discount-eligible/', FirstTimeDiscountEligibleView.as_view(), name='first_time_discount_eligible'), ] diff --git a/commerce_coordinator/apps/lms/views.py b/commerce_coordinator/apps/lms/views.py index d5b5981a..d5f7051a 100644 --- a/commerce_coordinator/apps/lms/views.py +++ b/commerce_coordinator/apps/lms/views.py @@ -4,10 +4,12 @@ import logging from urllib.parse import urlencode, urljoin +from commercetools import CommercetoolsError from django.conf import settings from django.http import HttpResponseBadRequest, HttpResponseRedirect from edx_rest_framework_extensions.permissions import LoginRedirectIfUnauthenticated from openedx_filters.exceptions import OpenEdxFilterException +from requests import HTTPError from rest_framework.exceptions import ValidationError from rest_framework.permissions import IsAdminUser from rest_framework.response import Response @@ -15,6 +17,7 @@ from rest_framework.throttling import UserRateThrottle from rest_framework.views import APIView +from commerce_coordinator.apps.commercetools.clients import CommercetoolsAPIClient from commerce_coordinator.apps.core.constants import HttpHeadersNames, MediaTypes from commerce_coordinator.apps.lms.filters import ( OrderRefundRequested, @@ -23,6 +26,7 @@ ) from commerce_coordinator.apps.lms.serializers import ( CourseRefundInputSerializer, + FirstTimeDiscountInputSerializer, UserRetiredInputSerializer, enrollment_attribute_key ) @@ -334,3 +338,32 @@ def post(self, request) -> Response: logger.exception(f"[RefundView] Exception raised in {self.post.__name__} with error {repr(e)}") return Response('Exception occurred while retiring Commercetools customer', status=HTTP_500_INTERNAL_SERVER_ERROR) + + +class FirstTimeDiscountEligibleView(APIView): + """View to check if a user is eligible for a first time discount""" + permission_classes = [IsAdminUser] + throttle_classes = (UserRateThrottle,) + + def post(self, request): + """Return True if user is eligible for a first time discount.""" + validator = FirstTimeDiscountInputSerializer(data=request.data) + validator.is_valid(raise_exception=True) + + email = validator.validated_data['email'] + code = validator.validated_data['code'] + + try: + ct_api_client = CommercetoolsAPIClient() + is_eligible = ct_api_client.is_first_time_discount_eligible(email, code) + + output = { + 'is_eligible': is_eligible + } + return Response(output) + except CommercetoolsError as err: # pragma no cover + logger.exception(f"[FirstTimeDiscountEligibleView] Commercetools Error: {err}, {err.errors}") + except HTTPError as err: # pragma no cover + logger.exception(f"[FirstTimeDiscountEligibleView] HTTP Error: {err}") + + return Response({'is_eligible': True})