Skip to content

Commit

Permalink
feat: add SDN endpoints (#3985)
Browse files Browse the repository at this point in the history
* feat: add endpoint to run SDN check and return counts

* feat: add SDNCheckFailure REST APi
  • Loading branch information
christopappas committed Jun 16, 2023
1 parent 110d834 commit fd492fc
Show file tree
Hide file tree
Showing 5 changed files with 270 additions and 11 deletions.
15 changes: 15 additions & 0 deletions ecommerce/extensions/payment/serializers.py
Original file line number Diff line number Diff line change
@@ -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__'
93 changes: 93 additions & 0 deletions ecommerce/extensions/payment/tests/views/test_sdn.py
Original file line number Diff line number Diff line change
@@ -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


Expand All @@ -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}
5 changes: 4 additions & 1 deletion ecommerce/extensions/payment/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand All @@ -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 = [
Expand Down
10 changes: 0 additions & 10 deletions ecommerce/extensions/payment/views/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
158 changes: 158 additions & 0 deletions ecommerce/extensions/payment/views/sdn.py
Original file line number Diff line number Diff line change
@@ -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)

0 comments on commit fd492fc

Please sign in to comment.