diff --git a/lms/djangoapps/commerce/api.py b/lms/djangoapps/commerce/api.py new file mode 100644 index 0000000000..8f943f1fca --- /dev/null +++ b/lms/djangoapps/commerce/api.py @@ -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) diff --git a/lms/djangoapps/commerce/exceptions.py b/lms/djangoapps/commerce/exceptions.py new file mode 100644 index 0000000000..1ce359a130 --- /dev/null +++ b/lms/djangoapps/commerce/exceptions.py @@ -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 diff --git a/lms/djangoapps/commerce/tests/__init__.py b/lms/djangoapps/commerce/tests/__init__.py new file mode 100644 index 0000000000..75eede3957 --- /dev/null +++ b/lms/djangoapps/commerce/tests/__init__.py @@ -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() diff --git a/lms/djangoapps/commerce/tests/test_api.py b/lms/djangoapps/commerce/tests/test_api.py new file mode 100644 index 0000000000..43a01a0746 --- /dev/null +++ b/lms/djangoapps/commerce/tests/test_api.py @@ -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) diff --git a/lms/djangoapps/commerce/tests.py b/lms/djangoapps/commerce/tests/test_views.py similarity index 64% rename from lms/djangoapps/commerce/tests.py rename to lms/djangoapps/commerce/tests/test_views.py index 20da1f0a78..434178bea8 100644 --- a/lms/djangoapps/commerce/tests.py +++ b/lms/djangoapps/commerce/tests/test_views.py @@ -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 diff --git a/lms/djangoapps/commerce/views.py b/lms/djangoapps/commerce/views.py index e5eca674b9..8e46d4a6e5 100644 --- a/lms/djangoapps/commerce/views.py +++ b/lms/djangoapps/commerce/views.py @@ -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() diff --git a/lms/djangoapps/verify_student/tests/test_views.py b/lms/djangoapps/verify_student/tests/test_views.py index 7fadfce77b..1e6965e336 100644 --- a/lms/djangoapps/verify_student/tests/test_views.py +++ b/lms/djangoapps/verify_student/tests/test_views.py @@ -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): """ diff --git a/lms/djangoapps/verify_student/views.py b/lms/djangoapps/verify_student/views.py index 58bee7efe7..27b0cd66e6 100644 --- a/lms/djangoapps/verify_student/views.py +++ b/lms/djangoapps/verify_student/views.py @@ -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()