Commit cff9a5aa by Clinton Blackburn

Merge pull request #7318 from edx/clintonb/register-with-oscar

Enrolling via E-Commerce API for combined login-registration page
parents a7e1f9ca 935323a8
""" 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.'
""" 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)
"""
This file is intentionally empty. Django 1.6 and below require a models.py file for all apps.
"""
""" 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)
"""
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"),
)
""" 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()
......@@ -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):
......
......@@ -1654,7 +1654,9 @@ INSTALLED_APPS = (
# CORS and cross-domain CSRF
'corsheaders',
'cors_csrf'
'cors_csrf',
'commerce',
)
######################### CSRF #########################################
......@@ -2095,3 +2097,8 @@ ACCOUNT_VISIBILITY_CONFIGURATION = {
'profile_image',
],
}
# E-Commerce API Configuration
ECOMMERCE_API_URL = None
ECOMMERCE_API_SIGNING_KEY = None
ECOMMERCE_API_TIMEOUT = 5
......@@ -5,7 +5,7 @@ define(['js/common_helpers/ajax_helpers', 'js/student_account/enrollment'],
describe( 'edx.student.account.EnrollmentInterface', function() {
var COURSE_KEY = 'edX/DemoX/Fall',
ENROLL_URL = '/api/enrollment/v1/enrollment',
ENROLL_URL = '/commerce/orders/',
FORWARD_URL = '/course_modes/choose/edX/DemoX/Fall/',
EMBARGO_MSG_URL = '/embargo/blocked-message/enrollment/default/';
......@@ -26,7 +26,7 @@ define(['js/common_helpers/ajax_helpers', 'js/student_account/enrollment'],
requests,
'POST',
ENROLL_URL,
'{"course_details":{"course_id":"edX/DemoX/Fall"}}'
'{"course_id":"edX/DemoX/Fall"}'
);
// Simulate a successful response from the server
......
......@@ -9,7 +9,7 @@ var edx = edx || {};
edx.student.account.EnrollmentInterface = {
urls: {
enrollment: '/api/enrollment/v1/enrollment',
orders: '/commerce/orders/',
trackSelection: '/course_modes/choose/'
},
......@@ -23,14 +23,11 @@ var edx = edx || {};
* @param {string} courseKey Slash-separated course key.
*/
enroll: function( courseKey ) {
var data_obj = {
course_details: {
course_id: courseKey
}
};
var data = JSON.stringify(data_obj);
var data_obj = {course_id: courseKey},
data = JSON.stringify(data_obj);
$.ajax({
url: this.urls.enrollment,
url: this.urls.orders,
type: 'POST',
contentType: 'application/json; charset=utf-8',
data: data,
......
......@@ -499,6 +499,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
......
......@@ -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
......
......@@ -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
......
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