Commit 306a267d by Clinton Blackburn

Updated create_order endpoint to use E-Commerce API for order creation and enrollment

This functionality is activated by adding a SKU to a non-honor course mode. When the feature is activated, verified orders will be created by the E-Commerce Service instead of LMS. After payment, the E-Commerce Service will also create the enrollment.
parent 5d65f577
""" 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)
...@@ -6,14 +6,12 @@ from uuid import uuid4 ...@@ -6,14 +6,12 @@ from uuid import uuid4
from ddt import ddt, data from ddt import ddt, data
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.test.utils import override_settings 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.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.factories import CourseFactory
from commerce.constants import OrderStatus, Messages from commerce.constants import OrderStatus, Messages
from commerce.exceptions import TimeoutError, ApiError
from commerce.tests import EcommerceApiTestMixin
from course_modes.models import CourseMode from course_modes.models import CourseMode
from enrollment.api import get_enrollment from enrollment.api import get_enrollment
from student.models import CourseEnrollment from student.models import CourseEnrollment
...@@ -21,15 +19,10 @@ from student.tests.factories import UserFactory, CourseModeFactory ...@@ -21,15 +19,10 @@ from student.tests.factories import UserFactory, CourseModeFactory
from student.tests.tests import EnrollmentEventTestMixin 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 @ddt
@override_settings(ECOMMERCE_API_URL=ECOMMERCE_API_URL, ECOMMERCE_API_SIGNING_KEY=ECOMMERCE_API_SIGNING_KEY) @override_settings(ECOMMERCE_API_URL=EcommerceApiTestMixin.ECOMMERCE_API_URL,
class OrdersViewTests(EnrollmentEventTestMixin, ModuleStoreTestCase): ECOMMERCE_API_SIGNING_KEY=EcommerceApiTestMixin.ECOMMERCE_API_SIGNING_KEY)
class OrdersViewTests(EnrollmentEventTestMixin, EcommerceApiTestMixin, ModuleStoreTestCase):
""" """
Tests for the commerce orders view. Tests for the commerce orders view.
""" """
...@@ -50,18 +43,6 @@ class OrdersViewTests(EnrollmentEventTestMixin, ModuleStoreTestCase): ...@@ -50,18 +43,6 @@ class OrdersViewTests(EnrollmentEventTestMixin, ModuleStoreTestCase):
course_id = unicode(course_id or self.course.id) course_id = unicode(course_id or self.course.id)
return self.client.post(self.url, {'course_id': 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): def assertResponseMessage(self, response, expected_msg):
""" Asserts the detail field in the response's JSON body equals the expected message. """ """ Asserts the detail field in the response's JSON body equals the expected message. """
actual = json.loads(response.content)['detail'] actual = json.loads(response.content)['detail']
...@@ -72,11 +53,6 @@ class OrdersViewTests(EnrollmentEventTestMixin, ModuleStoreTestCase): ...@@ -72,11 +53,6 @@ class OrdersViewTests(EnrollmentEventTestMixin, ModuleStoreTestCase):
self.assertEqual(response.status_code, 503) self.assertEqual(response.status_code, 503)
self.assertResponseMessage(response, 'Call to E-Commerce API failed. Order creation failed.') 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): def assertUserNotEnrolled(self):
""" Asserts that the user is NOT enrolled in the course, and that an enrollment event was NOT fired. """ """ 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)) self.assertFalse(CourseEnrollment.is_enrolled(self.user, self.course.id))
...@@ -92,7 +68,7 @@ class OrdersViewTests(EnrollmentEventTestMixin, ModuleStoreTestCase): ...@@ -92,7 +68,7 @@ class OrdersViewTests(EnrollmentEventTestMixin, ModuleStoreTestCase):
# TODO Verify this is the best method to create CourseMode objects. # TODO Verify this is the best method to create CourseMode objects.
# TODO Find/create constants for the modes. # TODO Find/create constants for the modes.
for mode in ['honor', 'verified', 'audit']: for mode in [CourseMode.HONOR, CourseMode.VERIFIED, CourseMode.AUDIT]:
CourseModeFactory.create( CourseModeFactory.create(
course_id=self.course.id, course_id=self.course.id,
mode_slug=mode, mode_slug=mode,
...@@ -129,40 +105,23 @@ class OrdersViewTests(EnrollmentEventTestMixin, ModuleStoreTestCase): ...@@ -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, {}).status_code)
self.assertEqual(406, self.client.post(self.url, {'not_course_id': ''}).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): 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. 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. with self.mock_create_order(side_effect=TimeoutError):
def request_callback(_request, _uri, _headers): response = self._post_to_view()
""" Simulates API timeout """
raise Timeout
self._mock_ecommerce_api(body=request_callback)
response = self._post_to_view()
self.assertValidEcommerceApiErrorResponse(response) self.assertValidEcommerceApiErrorResponse(response)
self.assertUserNotEnrolled() self.assertUserNotEnrolled()
@httpretty.activate def test_ecommerce_api_error(self):
def test_ecommerce_api_bad_data(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!') with self.mock_create_order(side_effect=ApiError):
response = self._post_to_view() response = self._post_to_view()
self.assertValidEcommerceApiErrorResponse(response) self.assertValidEcommerceApiErrorResponse(response)
self.assertUserNotEnrolled() self.assertUserNotEnrolled()
...@@ -170,56 +129,45 @@ class OrdersViewTests(EnrollmentEventTestMixin, ModuleStoreTestCase): ...@@ -170,56 +129,45 @@ class OrdersViewTests(EnrollmentEventTestMixin, ModuleStoreTestCase):
""" """
Verifies that the view contacts the E-Commerce API with the correct data and headers. Verifies that the view contacts the E-Commerce API with the correct data and headers.
""" """
self._mock_ecommerce_api(body=ECOMMERCE_API_SUCCESSFUL_BODY) with self.mock_create_order():
response = self._post_to_view() response = self._post_to_view()
# Validate the response content # 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.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) @data(True, False)
@httpretty.activate
def test_course_with_honor_seat_sku(self, user_is_active): 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 If the course has a SKU, the view should get authorization from the E-Commerce API before enrolling
enrolling the user in the course. the user in the course. If authorization is approved, the user should be redirected to the user dashboard.
""" """
# Set user's active flag # Set user's active flag
self.user.is_active = user_is_active self.user.is_active = user_is_active
self.user.save() # pylint: disable=no-member self.user.save() # pylint: disable=no-member
self._test_successful_ecommerce_api_call() self._test_successful_ecommerce_api_call()
@httpretty.activate
def test_order_not_complete(self): def test_order_not_complete(self):
self._mock_ecommerce_api(body=json.dumps({'status': OrderStatus.OPEN, 'number': ORDER_NUMBER})) with self.mock_create_order(return_value=(self.ORDER_NUMBER,
response = self._post_to_view() OrderStatus.OPEN,
self.ECOMMERCE_API_SUCCESSFUL_BODY)):
response = self._post_to_view()
self.assertEqual(response.status_code, 202) 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) self.assertResponseMessage(response, msg)
# TODO Eventually we should NOT be enrolling users directly from this view. # 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): def _test_course_without_sku(self):
""" """
Validates the view bypasses the E-Commerce API when the course has no CourseModes with SKUs. Validates the view bypasses the E-Commerce API when the course has no CourseModes with SKUs.
""" """
# Place an order # Place an order
self._mock_ecommerce_api() with self.mock_create_order() as api_mock:
response = self._post_to_view() response = self._post_to_view()
# Validate the response content # Validate the response content
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
...@@ -227,11 +175,9 @@ class OrdersViewTests(EnrollmentEventTestMixin, ModuleStoreTestCase): ...@@ -227,11 +175,9 @@ class OrdersViewTests(EnrollmentEventTestMixin, ModuleStoreTestCase):
username=self.user.username) username=self.user.username)
self.assertResponseMessage(response, msg) self.assertResponseMessage(response, msg)
# The user should be enrolled, and no calls made to the E-Commerce API # No calls made to the E-Commerce API
self.assertUserEnrolled() self.assertFalse(api_mock.called)
self.assertIsInstance(httpretty.last_request(), HTTPrettyRequestEmpty)
@httpretty.activate
def test_course_without_sku(self): 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 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): ...@@ -244,13 +190,13 @@ class OrdersViewTests(EnrollmentEventTestMixin, ModuleStoreTestCase):
self._test_course_without_sku() self._test_course_without_sku()
@httpretty.activate
@override_settings(ECOMMERCE_API_URL=None, ECOMMERCE_API_SIGNING_KEY=None) @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 # Validate the response
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
...@@ -258,44 +204,48 @@ class OrdersViewTests(EnrollmentEventTestMixin, ModuleStoreTestCase): ...@@ -258,44 +204,48 @@ class OrdersViewTests(EnrollmentEventTestMixin, ModuleStoreTestCase):
self.assertResponseMessage(response, msg) self.assertResponseMessage(response, msg)
# Ensure that the user is not enrolled and that no calls were made to the E-Commerce API # Ensure that the user is not enrolled and that no calls were made to the E-Commerce API
self.assertUserEnrolled() self.assertTrue(CourseEnrollment.is_enrolled(self.user, self.course.id))
self.assertIsInstance(httpretty.last_request(), HTTPrettyRequestEmpty) self.assertFalse(api_mock.called)
@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.
for course_mode in CourseMode.objects.filter(course_id=self.course.id):
course_mode.sku = ''
course_mode.save()
self._test_course_without_sku() def assertProfessionalModeBypassed(self):
""" Verifies that the view returns HTTP 406 when a course with no honor mode is encountered. """
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() CourseMode.objects.filter(course_id=self.course.id).delete()
mode = 'no-id-professional' mode = CourseMode.NO_ID_PROFESSIONAL_MODE
CourseModeFactory.create(course_id=self.course.id, mode_slug=mode, mode_display_name=mode, CourseModeFactory.create(course_id=self.course.id, mode_slug=mode, mode_display_name=mode,
sku=uuid4().hex.decode('ascii')) sku=uuid4().hex.decode('ascii'))
self._mock_ecommerce_api()
response = self._post_to_view() 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) self.assertEqual(response.status_code, 406)
msg = Messages.NO_HONOR_MODE.format(course_id=self.course.id) msg = Messages.NO_HONOR_MODE.format(course_id=self.course.id)
self.assertResponseMessage(response, msg) self.assertResponseMessage(response, msg)
@httpretty.activate # No calls should be made to the E-Commerce API.
self.assertFalse(api_mock.called)
def test_course_with_professional_mode_only(self): def test_course_with_professional_mode_only(self):
""" Verifies that the view behaves appropriately when the course only has a professional mode. """ """ Verifies that the view behaves appropriately when the course only has a professional mode. """
self._test_professional_mode_only() self.assertProfessionalModeBypassed()
@httpretty.activate
@override_settings(ECOMMERCE_API_URL=None, ECOMMERCE_API_SIGNING_KEY=None) @override_settings(ECOMMERCE_API_URL=None, ECOMMERCE_API_SIGNING_KEY=None)
def test_no_settings_and_professional_mode_only(self): 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 Verifies that the view behaves appropriately when the course only has a professional mode and
the E-Commerce API is not configured. the E-Commerce Service is not configured.
""" """
self._test_professional_mode_only() self.assertProfessionalModeBypassed()
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.
for course_mode in CourseMode.objects.filter(course_id=self.course.id):
course_mode.sku = ''
course_mode.save()
self._test_course_without_sku()
def test_existing_active_enrollment(self): def test_existing_active_enrollment(self):
""" The view should respond with HTTP 409 if the user has an existing active enrollment for the course. """ """ 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): ...@@ -309,7 +259,6 @@ class OrdersViewTests(EnrollmentEventTestMixin, ModuleStoreTestCase):
msg = Messages.ENROLLMENT_EXISTS.format(username=self.user.username, course_id=self.course.id) msg = Messages.ENROLLMENT_EXISTS.format(username=self.user.username, course_id=self.course.id)
self.assertResponseMessage(response, msg) self.assertResponseMessage(response, msg)
@httpretty.activate
def test_existing_inactive_enrollment(self): def test_existing_inactive_enrollment(self):
""" """
If the user has an inactive enrollment for the course, the view should behave as if the If the user has an inactive enrollment for the course, the view should behave as if the
......
""" Commerce views. """ """ Commerce views. """
import json
import logging import logging
from simplejson import JSONDecodeError
from django.conf import settings
import jwt
from opaque_keys import InvalidKeyError from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.keys import CourseKey
import requests
from rest_framework.permissions import IsAuthenticated 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 rest_framework.views import APIView
from commerce.api import EcommerceAPI
from commerce.constants import OrderStatus, Messages from commerce.constants import OrderStatus, Messages
from commerce.exceptions import ApiError, InvalidConfigurationError
from commerce.http import DetailResponse, ApiErrorResponse from commerce.http import DetailResponse, ApiErrorResponse
from course_modes.models import CourseMode from course_modes.models import CourseMode
from courseware import courses from courseware import courses
...@@ -55,17 +52,6 @@ class OrdersView(APIView): ...@@ -55,17 +52,6 @@ class OrdersView(APIView):
return True, course_key, None 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): def _enroll(self, course_key, user):
""" Enroll the user in the course. """ """ Enroll the user in the course. """
add_enrollment(user.username, unicode(course_key)) add_enrollment(user.username, unicode(course_key))
...@@ -79,23 +65,17 @@ class OrdersView(APIView): ...@@ -79,23 +65,17 @@ class OrdersView(APIView):
if not valid: if not valid:
return DetailResponse(error, status=HTTP_406_NOT_ACCEPTABLE) 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 # Don't do anything if an enrollment already exists
course_id = unicode(course_key)
enrollment = CourseEnrollment.get_enrollment(user, course_key) enrollment = CourseEnrollment.get_enrollment(user, course_key)
if enrollment and enrollment.is_active: if enrollment and enrollment.is_active:
msg = Messages.ENROLLMENT_EXISTS.format(course_id=course_id, username=user.username) msg = Messages.ENROLLMENT_EXISTS.format(course_id=course_id, username=user.username)
return DetailResponse(msg, status=HTTP_409_CONFLICT) 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 # 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. # redirects to track selection.
honor_mode = CourseMode.mode_for_course(course_key, CourseMode.HONOR)
if not honor_mode: if not honor_mode:
msg = Messages.NO_HONOR_MODE.format(course_id=course_id) msg = Messages.NO_HONOR_MODE.format(course_id=course_id)
return DetailResponse(msg, status=HTTP_406_NOT_ACCEPTABLE) return DetailResponse(msg, status=HTTP_406_NOT_ACCEPTABLE)
...@@ -107,40 +87,18 @@ class OrdersView(APIView): ...@@ -107,40 +87,18 @@ class OrdersView(APIView):
self._enroll(course_key, user) self._enroll(course_key, user)
return DetailResponse(msg) return DetailResponse(msg)
# If the API is not configured, bypass it. # Setup the API and report any errors if settings are not valid.
if not (ecommerce_api_url and ecommerce_api_signing_key): try:
api = EcommerceAPI()
except InvalidConfigurationError:
self._enroll(course_key, user) 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) log.debug(msg)
return DetailResponse(msg) return DetailResponse(msg)
# Contact external API # Make the API call
headers = {
'Content-Type': 'application/json',
'Authorization': 'JWT {}'.format(self._get_jwt(user, ecommerce_api_signing_key))
}
url = '{}/orders/'.format(ecommerce_api_url.strip('/'))
try: try:
timeout = getattr(settings, 'ECOMMERCE_API_TIMEOUT', 5) order_number, order_status, _body = api.create_order(user, honor_mode.sku)
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')
if order_status == OrderStatus.COMPLETE: if order_status == OrderStatus.COMPLETE:
msg = Messages.ORDER_COMPLETED.format(order_number=order_number) msg = Messages.ORDER_COMPLETED.format(order_number=order_number)
log.debug(msg) log.debug(msg)
...@@ -162,12 +120,6 @@ class OrdersView(APIView): ...@@ -162,12 +120,6 @@ class OrdersView(APIView):
msg = Messages.ORDER_INCOMPLETE_ENROLLED.format(order_number=order_number) msg = Messages.ORDER_INCOMPLETE_ENROLLED.format(order_number=order_number)
return DetailResponse(msg, status=HTTP_202_ACCEPTED) return DetailResponse(msg, status=HTTP_202_ACCEPTED)
else: except ApiError:
msg = u'Response from E-Commerce API was invalid: (%(status)d) - %(msg)s' # The API will handle logging of the error.
msg_kwargs = {
'status': status_code,
'msg': data.get('user_message'),
}
log.error(msg, msg_kwargs)
return ApiErrorResponse() return ApiErrorResponse()
...@@ -3,12 +3,14 @@ ...@@ -3,12 +3,14 @@
Tests of verify_student views. Tests of verify_student views.
""" """
import json import json
import mock
import urllib import urllib
from mock import patch, Mock
import pytz
from datetime import timedelta, datetime 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 import ddt
from django.test.client import Client from django.test.client import Client
from django.test import TestCase from django.test import TestCase
...@@ -17,14 +19,16 @@ from django.core.urlresolvers import reverse ...@@ -17,14 +19,16 @@ from django.core.urlresolvers import reverse
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from django.core import mail from django.core import mail
from bs4 import BeautifulSoup 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.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.factories import CourseFactory
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore import ModuleStoreEnum
from opaque_keys.edx.locations import SlashSeparatedCourseKey from opaque_keys.edx.locations import SlashSeparatedCourseKey
from opaque_keys.edx.locator import CourseLocator 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.tests.factories import UserFactory, CourseEnrollmentFactory
from student.models import CourseEnrollment from student.models import CourseEnrollment
from course_modes.tests.factories import CourseModeFactory from course_modes.tests.factories import CourseModeFactory
...@@ -839,7 +843,7 @@ class TestPayAndVerifyView(UrlResetMixin, ModuleStoreTestCase): ...@@ -839,7 +843,7 @@ class TestPayAndVerifyView(UrlResetMixin, ModuleStoreTestCase):
self.assertEqual(response_dict['course_name'], mode_display_name) self.assertEqual(response_dict['course_name'], mode_display_name)
class TestCreateOrder(ModuleStoreTestCase): class TestCreateOrder(EcommerceApiTestMixin, ModuleStoreTestCase):
""" """
Tests for the create order view. Tests for the create order view.
""" """
...@@ -850,20 +854,27 @@ class TestCreateOrder(ModuleStoreTestCase): ...@@ -850,20 +854,27 @@ class TestCreateOrder(ModuleStoreTestCase):
self.user = UserFactory.create(username="test", password="test") self.user = UserFactory.create(username="test", password="test")
self.course = CourseFactory.create() self.course = CourseFactory.create()
for mode, min_price in (('audit', 0), ('honor', 0), ('verified', 100)): 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") 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): def test_create_order_already_verified(self):
# Verify the student so we don't need to submit photos # Verify the student so we don't need to submit photos
self._verify_student() self._verify_student()
# Create an order # Create an order
url = reverse('verify_student_create_order')
params = { params = {
'course_id': unicode(self.course.id), 'course_id': unicode(self.course.id),
'contribution': 100 'contribution': 100
} }
response = self.client.post(url, params) response = self._post(params)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
# Verify that the information will be sent to the correct callback URL # Verify that the information will be sent to the correct callback URL
...@@ -884,11 +895,8 @@ class TestCreateOrder(ModuleStoreTestCase): ...@@ -884,11 +895,8 @@ class TestCreateOrder(ModuleStoreTestCase):
CourseModeFactory(mode_slug="professional", course_id=course.id, min_price=10) CourseModeFactory(mode_slug="professional", course_id=course.id, min_price=10)
# Create an order for a prof ed course # Create an order for a prof ed course
url = reverse('verify_student_create_order') params = {'course_id': unicode(course.id)}
params = { response = self._post(params)
'course_id': unicode(course.id)
}
response = self.client.post(url, params)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
# Verify that the course ID and transaction type are included in "merchant-defined data" # Verify that the course ID and transaction type are included in "merchant-defined data"
...@@ -903,11 +911,8 @@ class TestCreateOrder(ModuleStoreTestCase): ...@@ -903,11 +911,8 @@ class TestCreateOrder(ModuleStoreTestCase):
CourseModeFactory(mode_slug="no-id-professional", course_id=course.id, min_price=10) CourseModeFactory(mode_slug="no-id-professional", course_id=course.id, min_price=10)
# Create an order for a prof ed course # Create an order for a prof ed course
url = reverse('verify_student_create_order') params = {'course_id': unicode(course.id)}
params = { response = self._post(params)
'course_id': unicode(course.id)
}
response = self.client.post(url, params)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
# Verify that the course ID and transaction type are included in "merchant-defined data" # Verify that the course ID and transaction type are included in "merchant-defined data"
...@@ -923,11 +928,8 @@ class TestCreateOrder(ModuleStoreTestCase): ...@@ -923,11 +928,8 @@ class TestCreateOrder(ModuleStoreTestCase):
CourseModeFactory(mode_slug="professional", course_id=course.id, min_price=10) CourseModeFactory(mode_slug="professional", course_id=course.id, min_price=10)
# Create an order for a prof ed course # Create an order for a prof ed course
url = reverse('verify_student_create_order') params = {'course_id': unicode(course.id)}
params = { response = self._post(params)
'course_id': unicode(course.id)
}
response = self.client.post(url, params)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
# Verify that the course ID and transaction type are included in "merchant-defined data" # Verify that the course ID and transaction type are included in "merchant-defined data"
...@@ -940,12 +942,11 @@ class TestCreateOrder(ModuleStoreTestCase): ...@@ -940,12 +942,11 @@ class TestCreateOrder(ModuleStoreTestCase):
self._verify_student() self._verify_student()
# Create an order # Create an order
url = reverse('verify_student_create_order')
params = { params = {
'course_id': unicode(self.course.id), 'course_id': unicode(self.course.id),
'contribution': '1.23' 'contribution': '1.23'
} }
self.client.post(url, params) self._post(params)
# Verify that the client's session contains the new donation amount # Verify that the client's session contains the new donation amount
self.assertNotIn('donation_for_course', self.client.session) self.assertNotIn('donation_for_course', self.client.session)
...@@ -957,6 +958,52 @@ class TestCreateOrder(ModuleStoreTestCase): ...@@ -957,6 +958,52 @@ class TestCreateOrder(ModuleStoreTestCase):
attempt.submit() attempt.submit()
attempt.approve() 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): class TestCreateOrderView(ModuleStoreTestCase):
""" """
......
...@@ -7,11 +7,9 @@ import logging ...@@ -7,11 +7,9 @@ import logging
import decimal import decimal
import datetime import datetime
from collections import namedtuple from collections import namedtuple
from pytz import UTC from pytz import UTC
from ipware.ip import get_ip from ipware.ip import get_ip
from edxmako.shortcuts import render_to_response, render_to_string
from django.conf import settings from django.conf import settings
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.http import ( from django.http import (
...@@ -26,11 +24,16 @@ from django.utils.decorators import method_decorator ...@@ -26,11 +24,16 @@ from django.utils.decorators import method_decorator
from django.utils.translation import ugettext as _, ugettext_lazy from django.utils.translation import ugettext as _, ugettext_lazy
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.core.mail import send_mail 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.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.accounts import NAME_MIN_LENGTH
from openedx.core.djangoapps.user_api.errors import UserNotFound, AccountValidationError 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 course_modes.models import CourseMode
from student.models import CourseEnrollment from student.models import CourseEnrollment
from student.views import reverification_info from student.views import reverification_info
...@@ -43,17 +46,13 @@ from verify_student.models import ( ...@@ -43,17 +46,13 @@ from verify_student.models import (
) )
from reverification.models import MidcourseReverificationWindow from reverification.models import MidcourseReverificationWindow
import ssencrypt import ssencrypt
from xmodule.modulestore.exceptions import ItemNotFoundError
from opaque_keys.edx.keys import CourseKey
from .exceptions import WindowExpiredException from .exceptions import WindowExpiredException
from xmodule.modulestore.django import modulestore
from microsite_configuration import microsite from microsite_configuration import microsite
from embargo import api as embargo_api from embargo import api as embargo_api
from util.json_request import JsonResponse from util.json_request import JsonResponse
from util.date_utils import get_default_time_display from util.date_utils import get_default_time_display
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
EVENT_NAME_USER_ENTERED_MIDCOURSE_REVERIFY_VIEW = 'edx.course.enrollment.reverify.started' EVENT_NAME_USER_ENTERED_MIDCOURSE_REVERIFY_VIEW = 'edx.course.enrollment.reverify.started'
...@@ -612,6 +611,21 @@ class PayAndVerifyView(View): ...@@ -612,6 +611,21 @@ class PayAndVerifyView(View):
return (has_paid, bool(is_active)) 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 @require_POST
@login_required @login_required
def create_order(request): def create_order(request):
...@@ -676,6 +690,9 @@ def create_order(request): ...@@ -676,6 +690,9 @@ def create_order(request):
if amount < current_mode.min_price: if amount < current_mode.min_price:
return HttpResponseBadRequest(_("No selected price or selected price is below minimum.")) 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 # I know, we should check this is valid. All kinds of stuff missing here
cart = Order.get_cart_for_user(request.user) cart = Order.get_cart_for_user(request.user)
cart.clear() 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