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

Creating orders with E-Commerce API
This commit is contained in:
Clinton Blackburn
2015-03-20 15:39:34 -04:00
8 changed files with 448 additions and 215 deletions

View File

@@ -0,0 +1,86 @@
""" 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)

View File

@@ -0,0 +1,21 @@
""" 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

View File

@@ -0,0 +1,73 @@
""" 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()

View File

@@ -0,0 +1,88 @@
""" 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)

View File

@@ -6,14 +6,12 @@ from uuid import uuid4
from ddt import ddt, data
from django.core.urlresolvers import reverse
from django.test.utils import override_settings
import httpretty
from httpretty.core import HTTPrettyRequestEmpty
import jwt
from requests import Timeout
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from commerce.constants import OrderStatus, Messages
from commerce.exceptions import TimeoutError, ApiError
from commerce.tests import EcommerceApiTestMixin
from course_modes.models import CourseMode
from enrollment.api import get_enrollment
from student.models import CourseEnrollment
@@ -21,15 +19,10 @@ from student.tests.factories import UserFactory, CourseModeFactory
from student.tests.tests import EnrollmentEventTestMixin
ECOMMERCE_API_URL = 'http://example.com/api'
ECOMMERCE_API_SIGNING_KEY = 'edx'
ORDER_NUMBER = "100004"
ECOMMERCE_API_SUCCESSFUL_BODY = json.dumps({'status': OrderStatus.COMPLETE, 'number': ORDER_NUMBER})
@ddt
@override_settings(ECOMMERCE_API_URL=ECOMMERCE_API_URL, ECOMMERCE_API_SIGNING_KEY=ECOMMERCE_API_SIGNING_KEY)
class OrdersViewTests(EnrollmentEventTestMixin, ModuleStoreTestCase):
@override_settings(ECOMMERCE_API_URL=EcommerceApiTestMixin.ECOMMERCE_API_URL,
ECOMMERCE_API_SIGNING_KEY=EcommerceApiTestMixin.ECOMMERCE_API_SIGNING_KEY)
class OrdersViewTests(EnrollmentEventTestMixin, EcommerceApiTestMixin, ModuleStoreTestCase):
"""
Tests for the commerce orders view.
"""
@@ -50,18 +43,6 @@ class OrdersViewTests(EnrollmentEventTestMixin, ModuleStoreTestCase):
course_id = unicode(course_id or self.course.id)
return self.client.post(self.url, {'course_id': course_id})
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 = ECOMMERCE_API_URL + '/orders/'
body = body or ECOMMERCE_API_SUCCESSFUL_BODY
httpretty.register_uri(httpretty.POST, url, status=status, body=body)
def assertResponseMessage(self, response, expected_msg):
""" Asserts the detail field in the response's JSON body equals the expected message. """
actual = json.loads(response.content)['detail']
@@ -72,11 +53,6 @@ class OrdersViewTests(EnrollmentEventTestMixin, ModuleStoreTestCase):
self.assertEqual(response.status_code, 503)
self.assertResponseMessage(response, 'Call to E-Commerce API failed. Order creation failed.')
def assertUserEnrolled(self):
""" Asserts that the user is enrolled in the course, and that an enrollment event was fired. """
self.assertTrue(CourseEnrollment.is_enrolled(self.user, self.course.id))
self.assert_enrollment_event_was_emitted(self.user, self.course.id)
def assertUserNotEnrolled(self):
""" Asserts that the user is NOT enrolled in the course, and that an enrollment event was NOT fired. """
self.assertFalse(CourseEnrollment.is_enrolled(self.user, self.course.id))
@@ -92,7 +68,7 @@ class OrdersViewTests(EnrollmentEventTestMixin, ModuleStoreTestCase):
# TODO Verify this is the best method to create CourseMode objects.
# TODO Find/create constants for the modes.
for mode in ['honor', 'verified', 'audit']:
for mode in [CourseMode.HONOR, CourseMode.VERIFIED, CourseMode.AUDIT]:
CourseModeFactory.create(
course_id=self.course.id,
mode_slug=mode,
@@ -129,40 +105,23 @@ class OrdersViewTests(EnrollmentEventTestMixin, ModuleStoreTestCase):
self.assertEqual(406, self.client.post(self.url, {}).status_code)
self.assertEqual(406, self.client.post(self.url, {'not_course_id': ''}).status_code)
@httpretty.activate
@data(400, 401, 405, 406, 429, 500, 503)
def test_ecommerce_api_bad_status(self, status):
"""
If the E-Commerce API returns an HTTP status not equal to 200, the view should log an error and return
an HTTP 503 status.
"""
self._mock_ecommerce_api(status=status, body=json.dumps({'user_message': 'FAIL!'}))
response = self._post_to_view()
self.assertValidEcommerceApiErrorResponse(response)
self.assertUserNotEnrolled()
@httpretty.activate
def test_ecommerce_api_timeout(self):
"""
If the call to the E-Commerce API times out, the view should log an error and return an HTTP 503 status.
"""
# Verify that the view responds appropriately if calls to the E-Commerce API timeout.
def request_callback(_request, _uri, _headers):
""" Simulates API timeout """
raise Timeout
with self.mock_create_order(side_effect=TimeoutError):
response = self._post_to_view()
self._mock_ecommerce_api(body=request_callback)
response = self._post_to_view()
self.assertValidEcommerceApiErrorResponse(response)
self.assertUserNotEnrolled()
@httpretty.activate
def test_ecommerce_api_bad_data(self):
def test_ecommerce_api_error(self):
"""
If the E-Commerce API returns data that is not JSON, the view should return an HTTP 503 status.
If the E-Commerce API raises an error, the view should return an HTTP 503 status.
"""
self._mock_ecommerce_api(body='TOTALLY NOT JSON!')
response = self._post_to_view()
with self.mock_create_order(side_effect=ApiError):
response = self._post_to_view()
self.assertValidEcommerceApiErrorResponse(response)
self.assertUserNotEnrolled()
@@ -170,56 +129,45 @@ class OrdersViewTests(EnrollmentEventTestMixin, ModuleStoreTestCase):
"""
Verifies that the view contacts the E-Commerce API with the correct data and headers.
"""
self._mock_ecommerce_api(body=ECOMMERCE_API_SUCCESSFUL_BODY)
response = self._post_to_view()
with self.mock_create_order():
response = self._post_to_view()
# Validate the response content
msg = Messages.ORDER_COMPLETED.format(order_number=ORDER_NUMBER)
msg = Messages.ORDER_COMPLETED.format(order_number=self.ORDER_NUMBER)
self.assertResponseMessage(response, msg)
self.assertEqual(response.status_code, 200)
# Verify the correct information was passed to the E-Commerce API
request = httpretty.last_request()
sku = CourseMode.objects.filter(course_id=self.course.id, mode_slug='honor', sku__isnull=False)[0].sku
self.assertEqual(request.body, '{{"sku": "{}"}}'.format(sku))
self.assertEqual(request.headers['Content-Type'], 'application/json')
# Verify the JWT is correct
expected_jwt = jwt.encode({'username': self.user.username, 'email': self.user.email},
ECOMMERCE_API_SIGNING_KEY)
self.assertEqual(request.headers['Authorization'], 'JWT {}'.format(expected_jwt))
@data(True, False)
@httpretty.activate
def test_course_with_honor_seat_sku(self, user_is_active):
"""
If the course has a SKU for honor mode, the view should get authorization from the E-Commerce API before
enrolling the user in the course.
If the course has a SKU, the view should get authorization from the E-Commerce API before enrolling
the user in the course. If authorization is approved, the user should be redirected to the user dashboard.
"""
# Set user's active flag
self.user.is_active = user_is_active
self.user.save() # pylint: disable=no-member
self._test_successful_ecommerce_api_call()
@httpretty.activate
def test_order_not_complete(self):
self._mock_ecommerce_api(body=json.dumps({'status': OrderStatus.OPEN, 'number': ORDER_NUMBER}))
response = self._post_to_view()
with self.mock_create_order(return_value=(self.ORDER_NUMBER,
OrderStatus.OPEN,
self.ECOMMERCE_API_SUCCESSFUL_BODY)):
response = self._post_to_view()
self.assertEqual(response.status_code, 202)
msg = Messages.ORDER_INCOMPLETE_ENROLLED.format(order_number=ORDER_NUMBER)
msg = Messages.ORDER_INCOMPLETE_ENROLLED.format(order_number=self.ORDER_NUMBER)
self.assertResponseMessage(response, msg)
# TODO Eventually we should NOT be enrolling users directly from this view.
self.assertUserEnrolled()
self.assertTrue(CourseEnrollment.is_enrolled(self.user, self.course.id))
def _test_course_without_sku(self):
"""
Validates the view bypasses the E-Commerce API when the course has no CourseModes with SKUs.
"""
# Place an order
self._mock_ecommerce_api()
response = self._post_to_view()
with self.mock_create_order() as api_mock:
response = self._post_to_view()
# Validate the response content
self.assertEqual(response.status_code, 200)
@@ -227,11 +175,9 @@ class OrdersViewTests(EnrollmentEventTestMixin, ModuleStoreTestCase):
username=self.user.username)
self.assertResponseMessage(response, msg)
# The user should be enrolled, and no calls made to the E-Commerce API
self.assertUserEnrolled()
self.assertIsInstance(httpretty.last_request(), HTTPrettyRequestEmpty)
# No calls made to the E-Commerce API
self.assertFalse(api_mock.called)
@httpretty.activate
def test_course_without_sku(self):
"""
If the course does NOT have a SKU, the user should be enrolled in the course (under the honor mode) and
@@ -244,13 +190,13 @@ class OrdersViewTests(EnrollmentEventTestMixin, ModuleStoreTestCase):
self._test_course_without_sku()
@httpretty.activate
@override_settings(ECOMMERCE_API_URL=None, ECOMMERCE_API_SIGNING_KEY=None)
def test_no_settings(self):
def test_ecommerce_service_not_configured(self):
"""
If no settings exist to define the E-Commerce API URL or signing key, the view should enroll the user.
If the E-Commerce Service is not configured, the view should enroll the user.
"""
response = self._post_to_view()
with self.mock_create_order() as api_mock:
response = self._post_to_view()
# Validate the response
self.assertEqual(response.status_code, 200)
@@ -258,10 +204,40 @@ class OrdersViewTests(EnrollmentEventTestMixin, ModuleStoreTestCase):
self.assertResponseMessage(response, msg)
# Ensure that the user is not enrolled and that no calls were made to the E-Commerce API
self.assertUserEnrolled()
self.assertIsInstance(httpretty.last_request(), HTTPrettyRequestEmpty)
self.assertTrue(CourseEnrollment.is_enrolled(self.user, self.course.id))
self.assertFalse(api_mock.called)
def assertProfessionalModeBypassed(self):
""" Verifies that the view returns HTTP 406 when a course with no honor mode is encountered. """
CourseMode.objects.filter(course_id=self.course.id).delete()
mode = CourseMode.NO_ID_PROFESSIONAL_MODE
CourseModeFactory.create(course_id=self.course.id, mode_slug=mode, mode_display_name=mode,
sku=uuid4().hex.decode('ascii'))
with self.mock_create_order() as api_mock:
response = self._post_to_view()
# The view should return an error status code
self.assertEqual(response.status_code, 406)
msg = Messages.NO_HONOR_MODE.format(course_id=self.course.id)
self.assertResponseMessage(response, msg)
# No calls should be made to the E-Commerce API.
self.assertFalse(api_mock.called)
def test_course_with_professional_mode_only(self):
""" Verifies that the view behaves appropriately when the course only has a professional mode. """
self.assertProfessionalModeBypassed()
@override_settings(ECOMMERCE_API_URL=None, ECOMMERCE_API_SIGNING_KEY=None)
def test_professional_mode_only_and_ecommerce_service_not_configured(self):
"""
Verifies that the view behaves appropriately when the course only has a professional mode and
the E-Commerce Service is not configured.
"""
self.assertProfessionalModeBypassed()
@httpretty.activate
def test_empty_sku(self):
""" If the CourseMode has an empty string for a SKU, the API should not be used. """
# Set SKU to empty string for all modes.
@@ -271,32 +247,6 @@ class OrdersViewTests(EnrollmentEventTestMixin, ModuleStoreTestCase):
self._test_course_without_sku()
def _test_professional_mode_only(self):
""" Verifies that the view behaves appropriately when the course only has a professional mode. """
CourseMode.objects.filter(course_id=self.course.id).delete()
mode = 'no-id-professional'
CourseModeFactory.create(course_id=self.course.id, mode_slug=mode, mode_display_name=mode,
sku=uuid4().hex.decode('ascii'))
self._mock_ecommerce_api()
response = self._post_to_view()
self.assertEqual(response.status_code, 406)
msg = Messages.NO_HONOR_MODE.format(course_id=self.course.id)
self.assertResponseMessage(response, msg)
@httpretty.activate
def test_course_with_professional_mode_only(self):
""" Verifies that the view behaves appropriately when the course only has a professional mode. """
self._test_professional_mode_only()
@httpretty.activate
@override_settings(ECOMMERCE_API_URL=None, ECOMMERCE_API_SIGNING_KEY=None)
def test_no_settings_and_professional_mode_only(self):
"""
Verifies that the view behaves appropriately when the course only has a professional mode and
the E-Commerce API is not configured.
"""
self._test_professional_mode_only()
def test_existing_active_enrollment(self):
""" The view should respond with HTTP 409 if the user has an existing active enrollment for the course. """
@@ -309,7 +259,6 @@ class OrdersViewTests(EnrollmentEventTestMixin, ModuleStoreTestCase):
msg = Messages.ENROLLMENT_EXISTS.format(username=self.user.username, course_id=self.course.id)
self.assertResponseMessage(response, msg)
@httpretty.activate
def test_existing_inactive_enrollment(self):
"""
If the user has an inactive enrollment for the course, the view should behave as if the

View File

@@ -1,18 +1,15 @@
""" 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()

View File

@@ -3,12 +3,14 @@
Tests of verify_student views.
"""
import json
import mock
import urllib
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
from datetime import timedelta, datetime
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):
"""

View File

@@ -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()