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.
This commit is contained in:
committed by
Clinton Blackburn
parent
9d9dccdb97
commit
eaa7a22058
1
lms/djangoapps/commerce/__init__.py
Normal file
1
lms/djangoapps/commerce/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
""" Commerce app. """
|
||||
21
lms/djangoapps/commerce/constants.py
Normal file
21
lms/djangoapps/commerce/constants.py
Normal file
@@ -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.'
|
||||
21
lms/djangoapps/commerce/http.py
Normal file
21
lms/djangoapps/commerce/http.py
Normal file
@@ -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)
|
||||
3
lms/djangoapps/commerce/models.py
Normal file
3
lms/djangoapps/commerce/models.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
This file is intentionally empty. Django 1.6 and below require a models.py file for all apps.
|
||||
"""
|
||||
247
lms/djangoapps/commerce/tests.py
Normal file
247
lms/djangoapps/commerce/tests.py
Normal file
@@ -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)
|
||||
12
lms/djangoapps/commerce/urls.py
Normal file
12
lms/djangoapps/commerce/urls.py
Normal file
@@ -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"),
|
||||
)
|
||||
159
lms/djangoapps/commerce/views.py
Normal file
159
lms/djangoapps/commerce/views.py
Normal file
@@ -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()
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user