Commit eaa7a220 by Clinton Blackburn Committed by Clinton Blackburn

Added commerce/purchase endpoint

This new endpoint is intended to replace enrollment API call used on the login+registration page. Instead of directly enrolling students, the view will contact the external e-commerce API (Oscar) to create a new order. Oscar will be responsible for completing the order and enrolling the student.

This behavior will only apply to course modes with associated SKUs. All other course mode enrollments will be processed directly by LMS.
parent 9d9dccdb
""" 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.
"""
"""
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):
......
......@@ -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
......@@ -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
......
......@@ -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