From fd492fc39a1868b285d9f6e5e4da86f0fe5ee27c Mon Sep 17 00:00:00 2001 From: Chris Pappas Date: Fri, 16 Jun 2023 12:30:29 -0400 Subject: [PATCH] feat: add SDN endpoints (#3985) * feat: add endpoint to run SDN check and return counts * feat: add SDNCheckFailure REST APi --- ecommerce/extensions/payment/serializers.py | 15 ++ .../payment/tests/views/test_sdn.py | 93 +++++++++++ ecommerce/extensions/payment/urls.py | 5 +- .../extensions/payment/views/__init__.py | 10 -- ecommerce/extensions/payment/views/sdn.py | 158 ++++++++++++++++++ 5 files changed, 270 insertions(+), 11 deletions(-) create mode 100644 ecommerce/extensions/payment/serializers.py create mode 100644 ecommerce/extensions/payment/views/sdn.py diff --git a/ecommerce/extensions/payment/serializers.py b/ecommerce/extensions/payment/serializers.py new file mode 100644 index 00000000000..2e74f6b8879 --- /dev/null +++ b/ecommerce/extensions/payment/serializers.py @@ -0,0 +1,15 @@ +"""Payment Extension Serializers. """ + +from rest_framework import serializers + +from ecommerce.extensions.payment.models import SDNCheckFailure + + +class SDNCheckFailureSerializer(serializers.ModelSerializer): + """ + Serializer for SDNCheckFailure model. + """ + + class Meta: + model = SDNCheckFailure + fields = '__all__' diff --git a/ecommerce/extensions/payment/tests/views/test_sdn.py b/ecommerce/extensions/payment/tests/views/test_sdn.py index 71a6820c5c7..c89254bdbd3 100644 --- a/ecommerce/extensions/payment/tests/views/test_sdn.py +++ b/ecommerce/extensions/payment/tests/views/test_sdn.py @@ -1,7 +1,11 @@ +import json +import mock from django.urls import reverse +from requests.exceptions import HTTPError +from ecommerce.extensions.payment.models import SDNCheckFailure from ecommerce.tests.testcases import TestCase @@ -13,3 +17,92 @@ def test_sdn_logout_context(self): logout_url = self.site.siteconfiguration.build_lms_url('logout') response = self.client.get(self.failure_path) self.assertEqual(response.context['logout_url'], logout_url) + + +class SDNCheckViewTests(TestCase): + sdn_check_path = reverse('sdn:check') + + def setUp(self): + super().setUp() + self.user = self.create_user() + self.client.login(username=self.user.username, password=self.password) + self.post_params = { + 'lms_user_id': 1337, + 'name': 'Bowser, King of the Koopas', + 'city': 'Northern Chocolate Island', + 'country': 'Mushroom Kingdom', + } + + def test_sdn_check_missing_args(self): + response = self.client.post(self.sdn_check_path) + assert response.status_code == 400 + + @mock.patch('ecommerce.extensions.payment.views.sdn.checkSDNFallback') + @mock.patch('ecommerce.extensions.payment.views.sdn.SDNClient.search') + def test_sdn_check_search_fails_uses_fallback(self, mock_search, mock_fallback): + mock_search.side_effect = [HTTPError] + mock_fallback.return_value = 0 + response = self.client.post(self.sdn_check_path, data=self.post_params) + assert response.status_code == 200 + assert response.json()['hit_count'] == 0 + + @mock.patch('ecommerce.extensions.payment.views.sdn.checkSDNFallback') + @mock.patch('ecommerce.extensions.payment.views.sdn.SDNClient.search') + def test_sdn_check_search_succeeds(self, mock_search, mock_fallback): + mock_search.return_value = {'total': 4} + response = self.client.post(self.sdn_check_path, data=self.post_params) + assert response.status_code == 200 + assert response.json()['hit_count'] == 4 + assert response.json()['sdn_response'] == {'total': 4} + mock_fallback.assert_not_called() + + +class SDNCheckFailureViewTests(TestCase): + sdn_check_path = reverse('sdn:metadata') + + def setUp(self): + super().setUp() + self.user = self.create_user(is_staff=True) + self.client.login(username=self.user.username, password=self.password) + self.post_params = { + 'full_name': 'Princess Peach', + 'username': 'toadstool_is_cool', + 'city': 'Mushroom Castle', + 'country': 'US', + 'sdn_check_response': { # This will be a large JSON blob when returned from SDN API + 'total': 1, + }, + } + + def test_non_staff_cannot_access_endpoint(self): + self.user.is_staff = False + self.user.save() + response = self.client.post(self.sdn_check_path, data=self.post_params, content_type='application/json') + assert response.status_code == 403 + + def test_missing_payload_arg_400(self): + del self.post_params['full_name'] + response = self.client.post(self.sdn_check_path, data=self.post_params, content_type='application/json') + assert response.status_code == 400 + + def test_sdn_response_response_missing_required_field_400(self): + del self.post_params['sdn_check_response']['total'] + assert 'sdn_check_response' in self.post_params # so it's clear we deleted the sub dict's key + + response = self.client.post(self.sdn_check_path, data=self.post_params, content_type='application/json') + assert response.status_code == 400 + + def test_happy_path_create(self): + assert SDNCheckFailure.objects.count() == 0 + json_payload = json.dumps(self.post_params) + response = self.client.post(self.sdn_check_path, data=json_payload, content_type='application/json') + + assert response.status_code == 201 + assert SDNCheckFailure.objects.count() == 1 + + check_failure_object = SDNCheckFailure.objects.first() + assert check_failure_object.full_name == 'Princess Peach' + assert check_failure_object.username == 'toadstool_is_cool' + assert check_failure_object.city == 'Mushroom Castle' + assert check_failure_object.country == 'US' + assert check_failure_object.sdn_check_response == {'total': 1} diff --git a/ecommerce/extensions/payment/urls.py b/ecommerce/extensions/payment/urls.py index 6ae80cadaaa..3b6cd7b02bf 100644 --- a/ecommerce/extensions/payment/urls.py +++ b/ecommerce/extensions/payment/urls.py @@ -3,7 +3,8 @@ from django.conf import settings from django.conf.urls import include, url -from ecommerce.extensions.payment.views import PaymentFailedView, SDNFailure, cybersource, paypal, stripe +from ecommerce.extensions.payment.views import PaymentFailedView, cybersource, paypal, stripe +from ecommerce.extensions.payment.views.sdn import SDNCheckFailureView, SDNCheckView, SDNFailure CYBERSOURCE_APPLE_PAY_URLS = [ url(r'^authorize/$', cybersource.CybersourceApplePayAuthorizationView.as_view(), name='authorize'), @@ -20,7 +21,9 @@ ] SDN_URLS = [ + url(r'^check/$', SDNCheckView.as_view(), name='check'), url(r'^failure/$', SDNFailure.as_view(), name='failure'), + url(r'^metadata/$', SDNCheckFailureView.as_view(), name='metadata'), ] STRIPE_URLS = [ diff --git a/ecommerce/extensions/payment/views/__init__.py b/ecommerce/extensions/payment/views/__init__.py index f6db5491705..2e75577a56e 100644 --- a/ecommerce/extensions/payment/views/__init__.py +++ b/ecommerce/extensions/payment/views/__init__.py @@ -31,16 +31,6 @@ def get_context_data(self, **kwargs): return context -class SDNFailure(TemplateView): - """ Display an error page when the SDN check fails at checkout. """ - template_name = 'oscar/checkout/sdn_failure.html' - - def get_context_data(self, **kwargs): - context = super(SDNFailure, self).get_context_data(**kwargs) - context['logout_url'] = self.request.site.siteconfiguration.build_lms_url('/logout') - return context - - class BasePaymentSubmitView(View): """ Base class for payment submission views. diff --git a/ecommerce/extensions/payment/views/sdn.py b/ecommerce/extensions/payment/views/sdn.py new file mode 100644 index 00000000000..1b450bc9c4d --- /dev/null +++ b/ecommerce/extensions/payment/views/sdn.py @@ -0,0 +1,158 @@ +import logging + +from django.conf import settings +from django.contrib.auth.decorators import login_required +from django.http import JsonResponse +from django.utils.decorators import method_decorator +from django.views.generic import TemplateView, View +from requests.exceptions import HTTPError, Timeout +from rest_framework import status, views +from rest_framework.permissions import IsAdminUser, IsAuthenticated + +from ecommerce.extensions.payment.core.sdn import SDNClient, checkSDNFallback +from ecommerce.extensions.payment.models import SDNCheckFailure +from ecommerce.extensions.payment.serializers import SDNCheckFailureSerializer + +logger = logging.getLogger(__name__) + + +class SDNCheckFailureView(views.APIView): + """ + REST API for SDNCheckFailure class. + """ + http_method_names = ['post', 'options'] + permission_classes = [IsAuthenticated, IsAdminUser] + serializer_class = SDNCheckFailureSerializer + + def _validate_arguments(self, payload): + + invalid = False + reasons = [] + # Check for presence of required variables + for arg in ['full_name', 'username', 'city', 'country', 'sdn_check_response']: + if not payload.get(arg): + reason = f'{arg} is missing or blank.' + reasons.append(reason) + if reasons: + invalid = True + return invalid, reasons + + return invalid, reasons + + def post(self, request, *args, **kwargs): # pylint: disable=unused-argument + payload = request.data + invalid, reasons = self._validate_arguments(payload) + if invalid is True: + logger.warning( + 'Invalid payload for request user %s against SDNCheckFailureView endpoint. Reasons: %s', + request.user, + reasons, + ) + return JsonResponse( + {'error': ' '.join(reasons)}, + status=400, + ) + + sdn_check_failure = SDNCheckFailure.objects.create( + full_name=payload['full_name'], + username=payload['username'], + city=payload['city'], + country=payload['country'], + site=request.site, + sdn_check_response=payload['sdn_check_response'], + ) + + # This is the point where we would add the products to the SDNCheckFailure obj. + # We, however, do not know whether the products themselves are relevant to the flow + # calling this endpoint. If you wanted to attach products to the failure record, you + # can use skus handed to this endpoint to filter Products using their stockrecords: + # Product.objects.filter(stockrecords__partner_sku__in=['C92A142','ABC123']) + + # Return a response + data = self.serializer_class(sdn_check_failure, context={'request': request}).data + return JsonResponse(data, status=status.HTTP_201_CREATED) + + +class SDNFailure(TemplateView): + """ Display an error page when the SDN check fails at checkout. """ + template_name = 'oscar/checkout/sdn_failure.html' + + def get_context_data(self, **kwargs): + context = super(SDNFailure, self).get_context_data(**kwargs) + context['logout_url'] = self.request.site.siteconfiguration.build_lms_url('/logout') + return context + + +class SDNCheckView(View): + """ + View for external services to use to run SDN checks against. + + While this endpoint uses a lot of logic from sdn.py, this endpoint is + not called during a normal checkout flow (as of 6/8/2023). + """ + http_method_names = ['post', 'options'] + + @method_decorator(login_required) + def post(self, request): + """ + Use data provided to check against SDN list. + + Return a count of hits. + """ + payload = request.POST + + # Make sure we have the values needed to carry out the request + missing_args = [] + for expected_arg in ['lms_user_id', 'name', 'city', 'country']: + if not payload.get(expected_arg): + missing_args.append(expected_arg) + + if missing_args: + return JsonResponse({ + 'missing_args': ', '.join(missing_args) + }, status=400) + + # Begin the check logic + lms_user_id = payload.get('lms_user_id') + name = payload.get('name') + city = payload.get('city') + country = payload.get('country') + sdn_list = payload.get('sdn_list', 'ISN,SDN') # Set SDN lists to a sane default + + sdn_check = SDNClient( + api_url=settings.SDN_CHECK_API_URL, + api_key=settings.SDN_CHECK_API_KEY, + sdn_list=sdn_list + ) + try: + response = sdn_check.search(name, city, country) + except (HTTPError, Timeout) as e: + logger.info( + 'SDNCheck: SDN API call received an error: %s. SDNFallback function called for user %s.', + str(e), + lms_user_id + ) + sdn_fallback_hit_count = checkSDNFallback( + name, + city, + country + ) + response = {'total': sdn_fallback_hit_count} + + hit_count = response['total'] + if hit_count > 0: + logger.info( + 'SDNCheck Endpoint called for lms user [%s]. It received %d hit(s).', + lms_user_id, + hit_count, + ) + else: + logger.info( + 'SDNCheck function called for lms user [%s]. It did not receive a hit.', + lms_user_id, + ) + json_data = { + 'hit_count': hit_count, + 'sdn_response': response, + } + return JsonResponse(json_data, status=200)