diff --git a/lms/djangoapps/commerce/__init__.py b/lms/djangoapps/commerce/__init__.py new file mode 100644 index 0000000000..9d684dfb90 --- /dev/null +++ b/lms/djangoapps/commerce/__init__.py @@ -0,0 +1 @@ +""" Commerce app. """ diff --git a/lms/djangoapps/commerce/constants.py b/lms/djangoapps/commerce/constants.py new file mode 100644 index 0000000000..fbbeae0666 --- /dev/null +++ b/lms/djangoapps/commerce/constants.py @@ -0,0 +1,21 @@ +""" Constants for this app as well as the external API. """ + + +class OrderStatus(object): + """Constants representing all known order statuses. """ + OPEN = 'Open' + ORDER_CANCELLED = 'Order Cancelled' + BEING_PROCESSED = 'Being Processed' + PAYMENT_CANCELLED = 'Payment Cancelled' + PAID = 'Paid' + FULFILLMENT_ERROR = 'Fulfillment Error' + COMPLETE = 'Complete' + REFUNDED = 'Refunded' + + +class Messages(object): + """ Strings used to populate response messages. """ + NO_ECOM_API = u'E-Commerce API not setup. Enrolled {username} in {course_id} directly.' + NO_SKU_ENROLLED = u'The {enrollment_mode} mode for {course_id} does not have a SKU. Enrolling {username} directly.' + ORDER_COMPLETED = u'Order {order_number} was completed.' + ORDER_INCOMPLETE_ENROLLED = u'Order {order_number} was created, but is not yet complete. User was enrolled.' diff --git a/lms/djangoapps/commerce/http.py b/lms/djangoapps/commerce/http.py new file mode 100644 index 0000000000..443703a51f --- /dev/null +++ b/lms/djangoapps/commerce/http.py @@ -0,0 +1,21 @@ +""" HTTP-related entities. """ + +from rest_framework.status import HTTP_503_SERVICE_UNAVAILABLE, HTTP_200_OK + +from util.json_request import JsonResponse + + +class DetailResponse(JsonResponse): + """ JSON response that simply contains a detail field. """ + + def __init__(self, message, status=HTTP_200_OK): + data = {'detail': message} + super(DetailResponse, self).__init__(object=data, status=status) + + +class ApiErrorResponse(DetailResponse): + """ Response returned when calls to the E-Commerce API fail or the returned data is invalid. """ + + def __init__(self): + message = 'Call to E-Commerce API failed. Order creation failed.' + super(ApiErrorResponse, self).__init__(message=message, status=HTTP_503_SERVICE_UNAVAILABLE) diff --git a/lms/djangoapps/commerce/models.py b/lms/djangoapps/commerce/models.py new file mode 100644 index 0000000000..32ec058432 --- /dev/null +++ b/lms/djangoapps/commerce/models.py @@ -0,0 +1,3 @@ +""" +This file is intentionally empty. Django 1.6 and below require a models.py file for all apps. +""" diff --git a/lms/djangoapps/commerce/tests.py b/lms/djangoapps/commerce/tests.py new file mode 100644 index 0000000000..c26e05a75f --- /dev/null +++ b/lms/djangoapps/commerce/tests.py @@ -0,0 +1,247 @@ +""" Tests for commerce views. """ + +import json +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 course_modes.models import CourseMode +from enrollment.api import add_enrollment +from student.models import CourseEnrollment +from student.tests.factories import UserFactory, CourseModeFactory + + +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(ModuleStoreTestCase): + """ + Tests for the commerce orders view. + """ + + def _login(self): + """ Log into LMS. """ + self.client.login(username=self.user.username, password='test') + + def _post_to_view(self, course_id=None): + """ + POST to the view being tested. + + Arguments + course_id (str) -- ID of course for which a seat should be ordered. + + :return: Response + """ + 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'] + self.assertEqual(actual, expected_msg) + + def assertValidEcommerceApiErrorResponse(self, response): + """ Asserts the response is a valid response sent when the E-Commerce API is unavailable. """ + self.assertEqual(response.status_code, 503) + self.assertResponseMessage(response, 'Call to E-Commerce API failed. Order creation failed.') + + def setUp(self): + super(OrdersViewTests, self).setUp() + self.url = reverse('commerce:orders') + self.user = UserFactory() + self._login() + + self.course = CourseFactory.create() + + # TODO Verify this is the best method to create CourseMode objects. + # TODO Find/create constants for the modes. + for mode in ['honor', 'verified', 'audit']: + CourseModeFactory.create( + course_id=self.course.id, + mode_slug=mode, + mode_display_name=mode, + sku=uuid4().hex.decode('ascii') + ) + + def test_login_required(self): + """ + The view should return HTTP 403 status if the user is not logged in. + """ + self.client.logout() + self.assertEqual(403, self._post_to_view().status_code) + + @data('delete', 'get', 'put') + def test_post_required(self, method): + """ + Verify that the view only responds to POST operations. + """ + response = getattr(self.client, method)(self.url) + self.assertEqual(405, response.status_code) + + def test_invalid_course(self): + """ + If the course does not exist, the view should return HTTP 406. + """ + # TODO Test inactive courses, and those not open for enrollment. + self.assertEqual(406, self._post_to_view('aaa/bbb/ccc').status_code) + + def test_invalid_request_data(self): + """ + If invalid data is supplied with the request, the view should return HTTP 406. + """ + 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.assertFalse(CourseEnrollment.is_enrolled(self.user, self.course.id)) + + @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 + + self._mock_ecommerce_api(body=request_callback) + response = self._post_to_view() + self.assertValidEcommerceApiErrorResponse(response) + self.assertFalse(CourseEnrollment.is_enrolled(self.user, self.course.id)) + + @httpretty.activate + 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. + """ + self._mock_ecommerce_api(body='TOTALLY NOT JSON!') + response = self._post_to_view() + self.assertValidEcommerceApiErrorResponse(response) + self.assertFalse(CourseEnrollment.is_enrolled(self.user, self.course.id)) + + @data(True, False) + @httpretty.activate + def test_course_with_honor_seat_sku(self, user_is_active): + """ + 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 + + def request_callback(_method, _uri, headers): + """ Mock the E-Commerce API's call to the enrollment API. """ + add_enrollment(self.user.username, unicode(self.course.id), 'honor') + return 200, headers, ECOMMERCE_API_SUCCESSFUL_BODY + + self._mock_ecommerce_api(body=request_callback) + response = self._post_to_view() + + # Validate the response content + msg = Messages.ORDER_COMPLETED.format(order_number=ORDER_NUMBER) + self.assertResponseMessage(response, msg) + + self.assertTrue(CourseEnrollment.is_enrolled(self.user, self.course.id)) + + # 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)) + + @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() + self.assertEqual(response.status_code, 202) + msg = Messages.ORDER_INCOMPLETE_ENROLLED.format(order_number=ORDER_NUMBER) + self.assertResponseMessage(response, msg) + + # TODO Eventually we should NOT be enrolling users directly from this view. + self.assertTrue(CourseEnrollment.is_enrolled(self.user, self.course.id)) + + @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 + redirected to the user dashboard. + """ + # Remove SKU from all course modes + for course_mode in CourseMode.objects.filter(course_id=self.course.id): + course_mode.sku = None + course_mode.save() + + # Place an order + self._mock_ecommerce_api() + response = self._post_to_view() + + # Validate the response content + self.assertEqual(response.status_code, 200) + msg = Messages.NO_SKU_ENROLLED.format(enrollment_mode='honor', course_id=self.course.id, + username=self.user.username) + self.assertResponseMessage(response, msg) + + # The user should be enrolled, and no calls made to the E-Commerce API + self.assertTrue(CourseEnrollment.is_enrolled(self.user, self.course.id)) + self.assertIsInstance(httpretty.last_request(), HTTPrettyRequestEmpty) + + @httpretty.activate + @override_settings(ECOMMERCE_API_URL=None, ECOMMERCE_API_SIGNING_KEY=None) + def test_no_settings(self): + """ + If no settings exist to define the E-Commerce API URL or signing key, the view should enroll the user. + """ + response = self._post_to_view() + + # Validate the response + self._mock_ecommerce_api() + self.assertEqual(response.status_code, 200) + msg = Messages.NO_ECOM_API.format(username=self.user.username, course_id=self.course.id) + self.assertResponseMessage(response, msg) + + # Ensure that the user is not enrolled and that no calls were made to the E-Commerce API + self.assertTrue(CourseEnrollment.is_enrolled(self.user, self.course.id)) + self.assertIsInstance(httpretty.last_request(), HTTPrettyRequestEmpty) diff --git a/lms/djangoapps/commerce/urls.py b/lms/djangoapps/commerce/urls.py new file mode 100644 index 0000000000..3fa815adc8 --- /dev/null +++ b/lms/djangoapps/commerce/urls.py @@ -0,0 +1,12 @@ +""" +Defines the URL routes for this app. +""" + +from django.conf.urls import patterns, url + +from .views import OrdersView + +urlpatterns = patterns( + '', + url(r'^orders/$', OrdersView.as_view(), name="orders"), +) diff --git a/lms/djangoapps/commerce/views.py b/lms/djangoapps/commerce/views.py new file mode 100644 index 0000000000..c16ab8fa52 --- /dev/null +++ b/lms/djangoapps/commerce/views.py @@ -0,0 +1,159 @@ +""" Commerce views. """ + +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 +from rest_framework.views import APIView + +from commerce.constants import OrderStatus, Messages +from commerce.http import DetailResponse, ApiErrorResponse +from course_modes.models import CourseMode +from courseware import courses +from enrollment.api import add_enrollment +from util.authentication import SessionAuthenticationAllowInactiveUser + + +log = logging.getLogger(__name__) + + +class OrdersView(APIView): + """ Creates an order with a course seat and enrolls users. """ + + # LMS utilizes User.user_is_active to indicate email verification, not whether an account is active. Sigh! + authentication_classes = (SessionAuthenticationAllowInactiveUser,) + permission_classes = (IsAuthenticated,) + + def _is_data_valid(self, request): + """ + Validates the data posted to the view. + + Arguments + request -- HTTP request + + Returns + Tuple (data_is_valid, course_key, error_msg) + """ + course_id = request.DATA.get('course_id') + + if not course_id: + return False, None, u'Field course_id is missing.' + + try: + course_key = CourseKey.from_string(course_id) + courses.get_course(course_key) + except (InvalidKeyError, ValueError)as ex: + log.exception(u'Unable to locate course matching %s.', course_id) + return False, None, ex.message + + return True, course_key, None + + 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, getattr(settings, 'ECOMMERCE_API_SIGNING_KEY')) + + def _enroll(self, course_key, user): + """ Enroll the user in the course. """ + add_enrollment(user.username, unicode(course_key)) + + def post(self, request, *args, **kwargs): # pylint: disable=unused-argument + """ + Attempt to create the order and enroll the user. + """ + user = request.user + valid, course_key, error = self._is_data_valid(request) + 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) + + if not (ecommerce_api_url and ecommerce_api_signing_key): + self._enroll(course_key, user) + msg = Messages.NO_ECOM_API.format(username=user.username, course_id=unicode(course_key)) + log.debug(msg) + return DetailResponse(msg) + + # Default to honor mode. In the future we may expand this view to support additional modes. + mode = CourseMode.DEFAULT_MODE_SLUG + course_modes = CourseMode.objects.filter(course_id=course_key, mode_slug=mode, sku__isnull=False) + + # If there are no course modes with SKUs, enroll the user without contacting the external API. + if not course_modes.exists(): + msg = Messages.NO_SKU_ENROLLED.format(enrollment_mode=mode, course_id=unicode(course_key), + username=user.username) + log.debug(msg) + self._enroll(course_key, user) + return DetailResponse(msg) + + # Contact external API + headers = { + 'Content-Type': 'application/json', + 'Authorization': 'JWT {}'.format(self._get_jwt(user)) + } + + url = '{}/orders/'.format(ecommerce_api_url.strip('/')) + + try: + timeout = getattr(settings, 'ECOMMERCE_API_TIMEOUT', 5) + response = requests.post(url, data={'sku': course_modes[0].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: + msg = Messages.ORDER_COMPLETED.format(order_number=order_number) + log.debug(msg) + return DetailResponse(msg) + else: + # TODO Before this functionality is fully rolled-out, this branch should be updated to NOT enroll the + # user. Enrollments must be initiated by the E-Commerce API only. + self._enroll(course_key, user) + msg = u'Order %(order_number)s was received with %(status)s status. Expected %(complete_status)s. ' \ + u'User %(username)s was enrolled in %(course_id)s by LMS.' + msg_kwargs = { + 'order_number': order_number, + 'status': order_status, + 'complete_status': OrderStatus.COMPLETE, + 'username': user.username, + 'course_id': unicode(course_key), + } + log.error(msg, msg_kwargs) + + 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) + + return ApiErrorResponse() diff --git a/lms/djangoapps/edxnotes/tests.py b/lms/djangoapps/edxnotes/tests.py index 1b41e26641..f198629cc0 100644 --- a/lms/djangoapps/edxnotes/tests.py +++ b/lms/djangoapps/edxnotes/tests.py @@ -916,7 +916,7 @@ class EdxNotesViewsTest(ModuleStoreTestCase): response = self.client.get(self.get_token_url) self.assertEqual(response.status_code, 200) client = Client.objects.get(name='edx-notes') - jwt.decode(response.content, client.client_secret) + jwt.decode(response.content, client.client_secret, audience=client.client_id) @patch.dict("django.conf.settings.FEATURES", {"ENABLE_EDXNOTES": True}) def test_get_id_token_anonymous(self): diff --git a/lms/envs/common.py b/lms/envs/common.py index 43e5c698c8..0f049b357f 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -1649,7 +1649,9 @@ INSTALLED_APPS = ( # CORS and cross-domain CSRF 'corsheaders', - 'cors_csrf' + 'cors_csrf', + + 'commerce', ) ######################### CSRF ######################################### @@ -2086,3 +2088,8 @@ ACCOUNT_VISIBILITY_CONFIGURATION = { 'profile_image', ], } + +# E-Commerce API Configuration +ECOMMERCE_API_URL = None +ECOMMERCE_API_SIGNING_KEY = None +ECOMMERCE_API_TIMEOUT = 5 diff --git a/lms/urls.py b/lms/urls.py index f53481a5c7..96d7fba785 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -498,6 +498,7 @@ if settings.FEATURES.get('RESTRICT_ENROLL_BY_REG_METHOD'): # Shopping cart urlpatterns += ( url(r'^shoppingcart/', include('shoppingcart.urls')), + url(r'^commerce/', include('commerce.urls', namespace='commerce')), ) # Embargo diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 9dfb926cbd..401d7ce7fe 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -66,6 +66,7 @@ polib==1.0.3 pycrypto>=2.6 pygments==2.0.1 pygraphviz==1.1 +PyJWT==0.4.3 pymongo==2.7.2 pyparsing==2.0.1 python-memcached==1.48 diff --git a/requirements/edx/github.txt b/requirements/edx/github.txt index 28629698ac..63f67eaca3 100644 --- a/requirements/edx/github.txt +++ b/requirements/edx/github.txt @@ -34,7 +34,7 @@ git+https://github.com/mitocw/django-cas.git@60a5b8e5a62e63e0d5d224a87f0b489201a -e git+https://github.com/edx/opaque-keys.git@1254ed4d615a428591850656f39f26509b86d30a#egg=opaque-keys -e git+https://github.com/edx/ease.git@97de68448e5495385ba043d3091f570a699d5b5f#egg=ease -e git+https://github.com/edx/i18n-tools.git@193cebd9aa784f8899ef496f2aa050b08eff402b#egg=i18n-tools --e git+https://github.com/edx/edx-oauth2-provider.git@0.4.1#egg=oauth2-provider +-e git+https://github.com/edx/edx-oauth2-provider.git@0.4.2#egg=oauth2-provider -e git+https://github.com/edx/edx-val.git@fbec6efc86abb36f55de947baacc2092881dcde2#egg=edx-val -e git+https://github.com/pmitros/RecommenderXBlock.git@9b07e807c89ba5761827d0387177f71aa57ef056#egg=recommender-xblock -e git+https://github.com/edx/edx-milestones.git@547f2250ee49e73ce8d7ff4e78ecf1b049892510#egg=edx-milestones