Commit 89620a33 by Clinton Blackburn

Merge pull request #7378 from edx/clintonb/pay-with-oscar

Creating orders with E-Commerce API
parents 5d65f577 306a267d
""" E-Commerce API client """
import json
import logging
from django.conf import settings
import jwt
import requests
from requests import Timeout
from rest_framework.status import HTTP_200_OK
from commerce.exceptions import InvalidResponseError, TimeoutError, InvalidConfigurationError
log = logging.getLogger(__name__)
class EcommerceAPI(object):
""" E-Commerce API client. """
def __init__(self, url=None, key=None, timeout=None):
self.url = url or settings.ECOMMERCE_API_URL
self.key = key or settings.ECOMMERCE_API_SIGNING_KEY
self.timeout = timeout or getattr(settings, 'ECOMMERCE_API_TIMEOUT', 5)
if not (self.url and self.key):
raise InvalidConfigurationError('Values for both url and key must be set.')
# Remove slashes, so that we can properly format URLs regardless of
# whether the input includes a trailing slash.
self.url = self.url.strip('/')
def _get_jwt(self, user):
"""
Returns a JWT object with the specified user's info.
Raises AttributeError if settings.ECOMMERCE_API_SIGNING_KEY is not set.
"""
data = {
'username': user.username,
'email': user.email
}
return jwt.encode(data, self.key)
def create_order(self, user, sku):
"""
Create a new order.
Arguments
user -- User for which the order should be created.
sku -- SKU of the course seat being ordered.
Returns a tuple with the order number, order status, API response data.
"""
headers = {
'Content-Type': 'application/json',
'Authorization': 'JWT {}'.format(self._get_jwt(user))
}
url = '{}/orders/'.format(self.url)
try:
response = requests.post(url, data=json.dumps({'sku': sku}), headers=headers, timeout=self.timeout)
data = response.json()
except Timeout:
msg = 'E-Commerce API request timed out.'
log.error(msg)
raise TimeoutError(msg)
except ValueError:
msg = 'E-Commerce API response is not valid JSON.'
log.exception(msg)
raise InvalidResponseError(msg)
status_code = response.status_code
if status_code == HTTP_200_OK:
return data['number'], data['status'], data
else:
msg = u'Response from E-Commerce API was invalid: (%(status)d) - %(msg)s'
msg_kwargs = {
'status': status_code,
'msg': data.get('user_message'),
}
log.error(msg, msg_kwargs)
raise InvalidResponseError(msg % msg_kwargs)
""" E-Commerce-related exceptions. """
class ApiError(Exception):
""" Base class for E-Commerce API errors. """
pass
class InvalidConfigurationError(ApiError):
""" Exception raised when the API is not properly configured (e.g. settings are not set). """
pass
class InvalidResponseError(ApiError):
""" Exception raised when an API response is invalid. """
pass
class TimeoutError(ApiError):
""" Exception raised when an API requests times out. """
pass
""" Commerce app tests package. """
import json
import httpretty
import jwt
import mock
from commerce.api import EcommerceAPI
from commerce.constants import OrderStatus
class EcommerceApiTestMixin(object):
""" Mixin for tests utilizing the E-Commerce API. """
ECOMMERCE_API_URL = 'http://example.com/api'
ECOMMERCE_API_SIGNING_KEY = 'edx'
ORDER_NUMBER = '100004'
ECOMMERCE_API_SUCCESSFUL_BODY = {
'status': OrderStatus.COMPLETE,
'number': ORDER_NUMBER,
'payment_processor': 'cybersource',
'payment_parameters': {'orderNumber': ORDER_NUMBER}
}
ECOMMERCE_API_SUCCESSFUL_BODY_JSON = json.dumps(ECOMMERCE_API_SUCCESSFUL_BODY) # pylint: disable=invalid-name
def assertValidJWTAuthHeader(self, request, user, key):
""" Verifies that the JWT Authorization header is correct. """
expected_jwt = jwt.encode({'username': user.username, 'email': user.email}, key)
self.assertEqual(request.headers['Authorization'], 'JWT {}'.format(expected_jwt))
def assertValidOrderRequest(self, request, user, jwt_signing_key, sku):
""" Verifies that an order request to the E-Commerce Service is valid. """
self.assertValidJWTAuthHeader(request, user, jwt_signing_key)
self.assertEqual(request.body, '{{"sku": "{}"}}'.format(sku))
self.assertEqual(request.headers['Content-Type'], 'application/json')
def _mock_ecommerce_api(self, status=200, body=None):
"""
Mock calls to the E-Commerce API.
The calling test should be decorated with @httpretty.activate.
"""
self.assertTrue(httpretty.is_enabled(), 'Test is missing @httpretty.activate decorator.')
url = self.ECOMMERCE_API_URL + '/orders/'
body = body or self.ECOMMERCE_API_SUCCESSFUL_BODY_JSON
httpretty.register_uri(httpretty.POST, url, status=status, body=body)
class mock_create_order(object): # pylint: disable=invalid-name
""" Mocks calls to EcommerceAPI.create_order. """
patch = None
def __init__(self, **kwargs):
default_kwargs = {
'return_value': (
EcommerceApiTestMixin.ORDER_NUMBER,
OrderStatus.COMPLETE,
EcommerceApiTestMixin.ECOMMERCE_API_SUCCESSFUL_BODY
)
}
default_kwargs.update(kwargs)
self.patch = mock.patch.object(EcommerceAPI, 'create_order', mock.Mock(**default_kwargs))
def __enter__(self):
self.patch.start()
return self.patch.new
def __exit__(self, exc_type, exc_val, exc_tb): # pylint: disable=unused-argument
self.patch.stop()
""" Tests the E-Commerce API module. """
import json
from ddt import ddt, data
from django.core.urlresolvers import reverse
from django.test.testcases import TestCase
from django.test.utils import override_settings
import httpretty
from requests import Timeout
from commerce.api import EcommerceAPI
from commerce.constants import OrderStatus
from commerce.exceptions import InvalidResponseError, TimeoutError, InvalidConfigurationError
from commerce.tests import EcommerceApiTestMixin
from student.tests.factories import UserFactory
@ddt
@override_settings(ECOMMERCE_API_URL=EcommerceApiTestMixin.ECOMMERCE_API_URL,
ECOMMERCE_API_SIGNING_KEY=EcommerceApiTestMixin.ECOMMERCE_API_SIGNING_KEY)
class EcommerceAPITests(EcommerceApiTestMixin, TestCase):
""" Tests for the E-Commerce API client. """
SKU = '1234'
def setUp(self):
super(EcommerceAPITests, self).setUp()
self.url = reverse('commerce:orders')
self.user = UserFactory()
self.api = EcommerceAPI()
def test_constructor_url_strip(self):
""" Verifies that the URL is stored with trailing slashes removed. """
url = 'http://example.com'
api = EcommerceAPI(url, 'edx')
self.assertEqual(api.url, url)
api = EcommerceAPI(url + '/', 'edx')
self.assertEqual(api.url, url)
@override_settings(ECOMMERCE_API_URL=None, ECOMMERCE_API_SIGNING_KEY=None)
def test_no_settings(self):
"""
If the settings ECOMMERCE_API_URL and ECOMMERCE_API_SIGNING_KEY are invalid, the constructor should
raise a ValueError.
"""
self.assertRaises(InvalidConfigurationError, EcommerceAPI)
@httpretty.activate
def test_create_order(self):
""" Verify the method makes a call to the E-Commerce API with the correct headers and data. """
self._mock_ecommerce_api()
number, status, body = self.api.create_order(self.user, self.SKU)
# Validate the request sent to the E-Commerce API endpoint.
request = httpretty.last_request()
self.assertValidOrderRequest(request, self.user, self.ECOMMERCE_API_SIGNING_KEY, self.SKU)
# Validate the data returned by the method
self.assertEqual(number, self.ORDER_NUMBER)
self.assertEqual(status, OrderStatus.COMPLETE)
self.assertEqual(body, self.ECOMMERCE_API_SUCCESSFUL_BODY)
@httpretty.activate
@data(400, 401, 405, 406, 429, 500, 503)
def test_create_order_with_invalid_http_status(self, status):
""" If the E-Commerce API returns a non-200 status, the method should raise an InvalidResponseError. """
self._mock_ecommerce_api(status=status, body=json.dumps({'user_message': 'FAIL!'}))
self.assertRaises(InvalidResponseError, self.api.create_order, self.user, self.SKU)
@httpretty.activate
def test_create_order_with_invalid_json(self):
""" If the E-Commerce API returns un-parseable data, the method should raise an InvalidResponseError. """
self._mock_ecommerce_api(body='TOTALLY NOT JSON!')
self.assertRaises(InvalidResponseError, self.api.create_order, self.user, self.SKU)
@httpretty.activate
def test_create_order_with_timeout(self):
""" If the call to the E-Commerce API times out, the method should raise a TimeoutError. """
def request_callback(_request, _uri, _headers):
""" Simulates API timeout """
raise Timeout
self._mock_ecommerce_api(body=request_callback)
self.assertRaises(TimeoutError, self.api.create_order, self.user, self.SKU)
""" Commerce views. """
import json
import logging
from simplejson import JSONDecodeError
from django.conf import settings
import jwt
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
import requests
from rest_framework.permissions import IsAuthenticated
from rest_framework.status import HTTP_406_NOT_ACCEPTABLE, HTTP_202_ACCEPTED, HTTP_200_OK, HTTP_409_CONFLICT
from rest_framework.status import HTTP_406_NOT_ACCEPTABLE, HTTP_202_ACCEPTED, HTTP_409_CONFLICT
from rest_framework.views import APIView
from commerce.api import EcommerceAPI
from commerce.constants import OrderStatus, Messages
from commerce.exceptions import ApiError, InvalidConfigurationError
from commerce.http import DetailResponse, ApiErrorResponse
from course_modes.models import CourseMode
from courseware import courses
......@@ -55,17 +52,6 @@ class OrdersView(APIView):
return True, course_key, None
def _get_jwt(self, user, ecommerce_api_signing_key):
"""
Returns a JWT object with the specified user's info.
"""
data = {
'username': user.username,
'email': user.email
}
return jwt.encode(data, ecommerce_api_signing_key)
def _enroll(self, course_key, user):
""" Enroll the user in the course. """
add_enrollment(user.username, unicode(course_key))
......@@ -79,23 +65,17 @@ class OrdersView(APIView):
if not valid:
return DetailResponse(error, status=HTTP_406_NOT_ACCEPTABLE)
# Ensure that the E-Commerce API is setup properly
ecommerce_api_url = getattr(settings, 'ECOMMERCE_API_URL', None)
ecommerce_api_signing_key = getattr(settings, 'ECOMMERCE_API_SIGNING_KEY', None)
course_id = unicode(course_key)
# Don't do anything if an enrollment already exists
course_id = unicode(course_key)
enrollment = CourseEnrollment.get_enrollment(user, course_key)
if enrollment and enrollment.is_active:
msg = Messages.ENROLLMENT_EXISTS.format(course_id=course_id, username=user.username)
return DetailResponse(msg, status=HTTP_409_CONFLICT)
# Ensure that the course has an honor mode with SKU
honor_mode = CourseMode.mode_for_course(course_key, CourseMode.HONOR)
course_id = unicode(course_key)
# If there is no honor course mode, this most likely a Prof-Ed course. Return an error so that the JS
# redirects to track selection.
honor_mode = CourseMode.mode_for_course(course_key, CourseMode.HONOR)
if not honor_mode:
msg = Messages.NO_HONOR_MODE.format(course_id=course_id)
return DetailResponse(msg, status=HTTP_406_NOT_ACCEPTABLE)
......@@ -107,40 +87,18 @@ class OrdersView(APIView):
self._enroll(course_key, user)
return DetailResponse(msg)
# If the API is not configured, bypass it.
if not (ecommerce_api_url and ecommerce_api_signing_key):
# Setup the API and report any errors if settings are not valid.
try:
api = EcommerceAPI()
except InvalidConfigurationError:
self._enroll(course_key, user)
msg = Messages.NO_ECOM_API.format(username=user.username, course_id=course_id)
msg = Messages.NO_ECOM_API.format(username=user.username, course_id=unicode(course_key))
log.debug(msg)
return DetailResponse(msg)
# Contact external API
headers = {
'Content-Type': 'application/json',
'Authorization': 'JWT {}'.format(self._get_jwt(user, ecommerce_api_signing_key))
}
url = '{}/orders/'.format(ecommerce_api_url.strip('/'))
# Make the API call
try:
timeout = getattr(settings, 'ECOMMERCE_API_TIMEOUT', 5)
response = requests.post(url, data=json.dumps({'sku': honor_mode.sku}), headers=headers,
timeout=timeout)
except Exception as ex: # pylint: disable=broad-except
log.exception('Call to E-Commerce API failed: %s.', ex.message)
return ApiErrorResponse()
status_code = response.status_code
try:
data = response.json()
except JSONDecodeError:
log.error('E-Commerce API response is not valid JSON.')
return ApiErrorResponse()
if status_code == HTTP_200_OK:
order_number = data.get('number')
order_status = data.get('status')
order_number, order_status, _body = api.create_order(user, honor_mode.sku)
if order_status == OrderStatus.COMPLETE:
msg = Messages.ORDER_COMPLETED.format(order_number=order_number)
log.debug(msg)
......@@ -162,12 +120,6 @@ class OrdersView(APIView):
msg = Messages.ORDER_INCOMPLETE_ENROLLED.format(order_number=order_number)
return DetailResponse(msg, status=HTTP_202_ACCEPTED)
else:
msg = u'Response from E-Commerce API was invalid: (%(status)d) - %(msg)s'
msg_kwargs = {
'status': status_code,
'msg': data.get('user_message'),
}
log.error(msg, msg_kwargs)
except ApiError:
# The API will handle logging of the error.
return ApiErrorResponse()
......@@ -3,12 +3,14 @@
Tests of verify_student views.
"""
import json
import mock
import urllib
from mock import patch, Mock
import pytz
from datetime import timedelta, datetime
from uuid import uuid4
from django.test.utils import override_settings
import mock
from mock import patch, Mock
import pytz
import ddt
from django.test.client import Client
from django.test import TestCase
......@@ -17,14 +19,16 @@ from django.core.urlresolvers import reverse
from django.core.exceptions import ObjectDoesNotExist
from django.core import mail
from bs4 import BeautifulSoup
from openedx.core.djangoapps.user_api.accounts.api import get_account_settings
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from xmodule.modulestore.django import modulestore
from xmodule.modulestore import ModuleStoreEnum
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from opaque_keys.edx.locator import CourseLocator
from openedx.core.djangoapps.user_api.accounts.api import get_account_settings
from commerce.exceptions import ApiError
from commerce.tests import EcommerceApiTestMixin
from student.tests.factories import UserFactory, CourseEnrollmentFactory
from student.models import CourseEnrollment
from course_modes.tests.factories import CourseModeFactory
......@@ -839,7 +843,7 @@ class TestPayAndVerifyView(UrlResetMixin, ModuleStoreTestCase):
self.assertEqual(response_dict['course_name'], mode_display_name)
class TestCreateOrder(ModuleStoreTestCase):
class TestCreateOrder(EcommerceApiTestMixin, ModuleStoreTestCase):
"""
Tests for the create order view.
"""
......@@ -850,20 +854,27 @@ class TestCreateOrder(ModuleStoreTestCase):
self.user = UserFactory.create(username="test", password="test")
self.course = CourseFactory.create()
for mode, min_price in (('audit', 0), ('honor', 0), ('verified', 100)):
CourseModeFactory(mode_slug=mode, course_id=self.course.id, min_price=min_price)
# Set SKU to empty string to ensure view knows how to handle such values
CourseModeFactory(mode_slug=mode, course_id=self.course.id, min_price=min_price, sku='')
self.client.login(username="test", password="test")
def _post(self, data):
"""
POST to the view being tested and return the response.
"""
url = reverse('verify_student_create_order')
return self.client.post(url, data)
def test_create_order_already_verified(self):
# Verify the student so we don't need to submit photos
self._verify_student()
# Create an order
url = reverse('verify_student_create_order')
params = {
'course_id': unicode(self.course.id),
'contribution': 100
}
response = self.client.post(url, params)
response = self._post(params)
self.assertEqual(response.status_code, 200)
# Verify that the information will be sent to the correct callback URL
......@@ -884,11 +895,8 @@ class TestCreateOrder(ModuleStoreTestCase):
CourseModeFactory(mode_slug="professional", course_id=course.id, min_price=10)
# Create an order for a prof ed course
url = reverse('verify_student_create_order')
params = {
'course_id': unicode(course.id)
}
response = self.client.post(url, params)
params = {'course_id': unicode(course.id)}
response = self._post(params)
self.assertEqual(response.status_code, 200)
# Verify that the course ID and transaction type are included in "merchant-defined data"
......@@ -903,11 +911,8 @@ class TestCreateOrder(ModuleStoreTestCase):
CourseModeFactory(mode_slug="no-id-professional", course_id=course.id, min_price=10)
# Create an order for a prof ed course
url = reverse('verify_student_create_order')
params = {
'course_id': unicode(course.id)
}
response = self.client.post(url, params)
params = {'course_id': unicode(course.id)}
response = self._post(params)
self.assertEqual(response.status_code, 200)
# Verify that the course ID and transaction type are included in "merchant-defined data"
......@@ -923,11 +928,8 @@ class TestCreateOrder(ModuleStoreTestCase):
CourseModeFactory(mode_slug="professional", course_id=course.id, min_price=10)
# Create an order for a prof ed course
url = reverse('verify_student_create_order')
params = {
'course_id': unicode(course.id)
}
response = self.client.post(url, params)
params = {'course_id': unicode(course.id)}
response = self._post(params)
self.assertEqual(response.status_code, 200)
# Verify that the course ID and transaction type are included in "merchant-defined data"
......@@ -940,12 +942,11 @@ class TestCreateOrder(ModuleStoreTestCase):
self._verify_student()
# Create an order
url = reverse('verify_student_create_order')
params = {
'course_id': unicode(self.course.id),
'contribution': '1.23'
}
self.client.post(url, params)
self._post(params)
# Verify that the client's session contains the new donation amount
self.assertNotIn('donation_for_course', self.client.session)
......@@ -957,6 +958,52 @@ class TestCreateOrder(ModuleStoreTestCase):
attempt.submit()
attempt.approve()
@override_settings(ECOMMERCE_API_URL=EcommerceApiTestMixin.ECOMMERCE_API_URL,
ECOMMERCE_API_SIGNING_KEY=EcommerceApiTestMixin.ECOMMERCE_API_SIGNING_KEY)
def test_create_order_with_ecommerce_api(self):
""" Verifies that the view communicates with the E-Commerce API to create orders. """
# Keep track of the original number of orders to verify the old code is not being called.
order_count = Order.objects.count()
# Add SKU to CourseModes
for course_mode in CourseMode.objects.filter(course_id=self.course.id):
course_mode.sku = uuid4().hex.decode('ascii')
course_mode.save()
# Mock the E-Commerce Service response
with self.mock_create_order():
self._verify_student()
params = {'course_id': unicode(self.course.id), 'contribution': 100}
response = self._post(params)
# Verify the response is correct.
self.assertEqual(response.status_code, 200)
self.assertEqual(response['Content-Type'], 'application/json')
self.assertEqual(json.loads(response.content), self.ECOMMERCE_API_SUCCESSFUL_BODY['payment_parameters'])
# Verify old code is not called (e.g. no Order object created in LMS)
self.assertEqual(order_count, Order.objects.count())
def _add_course_mode_skus(self):
""" Add SKUs to the CourseMode objects for self.course. """
for course_mode in CourseMode.objects.filter(course_id=self.course.id):
course_mode.sku = uuid4().hex.decode('ascii')
course_mode.save()
@override_settings(ECOMMERCE_API_URL=EcommerceApiTestMixin.ECOMMERCE_API_URL,
ECOMMERCE_API_SIGNING_KEY=EcommerceApiTestMixin.ECOMMERCE_API_SIGNING_KEY)
def test_create_order_with_ecommerce_api_errors(self):
"""
Verifies that the view communicates with the E-Commerce API to create orders, and handles errors
appropriately.
"""
self._add_course_mode_skus()
with self.mock_create_order(side_effect=ApiError):
self._verify_student()
params = {'course_id': unicode(self.course.id), 'contribution': 100}
self.assertRaises(ApiError, self._post, params)
class TestCreateOrderView(ModuleStoreTestCase):
"""
......
......@@ -7,11 +7,9 @@ import logging
import decimal
import datetime
from collections import namedtuple
from pytz import UTC
from ipware.ip import get_ip
from edxmako.shortcuts import render_to_response, render_to_string
from django.conf import settings
from django.core.urlresolvers import reverse
from django.http import (
......@@ -26,11 +24,16 @@ from django.utils.decorators import method_decorator
from django.utils.translation import ugettext as _, ugettext_lazy
from django.contrib.auth.decorators import login_required
from django.core.mail import send_mail
from opaque_keys.edx.keys import CourseKey
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError
from edxmako.shortcuts import render_to_response, render_to_string
from openedx.core.djangoapps.user_api.accounts.api import get_account_settings, update_account_settings
from openedx.core.djangoapps.user_api.accounts import NAME_MIN_LENGTH
from openedx.core.djangoapps.user_api.errors import UserNotFound, AccountValidationError
from commerce.api import EcommerceAPI
from commerce.exceptions import ApiError
from course_modes.models import CourseMode
from student.models import CourseEnrollment
from student.views import reverification_info
......@@ -43,17 +46,13 @@ from verify_student.models import (
)
from reverification.models import MidcourseReverificationWindow
import ssencrypt
from xmodule.modulestore.exceptions import ItemNotFoundError
from opaque_keys.edx.keys import CourseKey
from .exceptions import WindowExpiredException
from xmodule.modulestore.django import modulestore
from microsite_configuration import microsite
from embargo import api as embargo_api
from util.json_request import JsonResponse
from util.date_utils import get_default_time_display
log = logging.getLogger(__name__)
EVENT_NAME_USER_ENTERED_MIDCOURSE_REVERIFY_VIEW = 'edx.course.enrollment.reverify.started'
......@@ -612,6 +611,21 @@ class PayAndVerifyView(View):
return (has_paid, bool(is_active))
def create_order_with_ecommerce_service(user, course_key, course_mode): # pylint: disable=invalid-name
""" Create a new order using the E-Commerce API. """
try:
api = EcommerceAPI()
# Make an API call to create the order and retrieve the results
_order_number, _order_status, data = api.create_order(user, course_mode.sku)
# Pass the payment parameters directly from the API response.
return HttpResponse(json.dumps(data['payment_parameters']), content_type='application/json')
except ApiError:
params = {'username': user.username, 'mode': course_mode.slug, 'course_id': unicode(course_key)}
log.error('Failed to create order for %(username)s %(mode)s mode of %(course_id)s', params)
raise
@require_POST
@login_required
def create_order(request):
......@@ -676,6 +690,9 @@ def create_order(request):
if amount < current_mode.min_price:
return HttpResponseBadRequest(_("No selected price or selected price is below minimum."))
if current_mode.sku:
return create_order_with_ecommerce_service(request.user, course_id, current_mode)
# I know, we should check this is valid. All kinds of stuff missing here
cart = Order.get_cart_for_user(request.user)
cart.clear()
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment