Skip to content
This repository has been archived by the owner on Nov 4, 2024. It is now read-only.

feat: add SDN endpoints #3985

Merged
merged 16 commits into from
Jun 16, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you need this return here if you're returning the same in the next line?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good catch. i'll simplify. previously i had more code here


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'])
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this a TODO?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good question! this is more of an [inform] to compare how the previous SDN flow works. nothing to do at the moment, unless we have a requirement to attach product objects to the metadata record (which for our case, i don't think we do, because most of the purchasing happens outside of ecommerce)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see, thanks!


# 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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it worth moving some of the same code to an utils function to be used in both files?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's really only a few lines that are truly shared, because we do not use the deactivate or sitconfiguration logic here. in case our use case evolves at all in the short term, i'd like to keep these separate

not called during a normal checkout flow (as of 6/8/2023).
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe worth specifying what "normal" is? Or have an example (like "non-subscriptions", "B2C one time payment", etc)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i like "B2C one time payment" -- i'll use that. thanks

"""
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:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At this point we would deactivate the user - but since we will be doing this logic from subscriptions we'll rely on the return dictionary and deactivate wherever we make this POST I'm assuming?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

correct. i have split up the logic from the one time checkout flow into 3 parts to make their use more modular/convenient for external services: 1) endpoint for getting SDN hit counts 2) endpoint for creating SDNfailure metadata records 3) separate place to deactivate user (subs)

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)