Merge pull request #7414 from edx/release-merge-20150317
Merge release to master for 20150317 release.
This commit is contained in:
29
common/djangoapps/cors_csrf/authentication.py
Normal file
29
common/djangoapps/cors_csrf/authentication.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""Django Rest Framework Authentication classes for cross-domain end-points."""
|
||||
from rest_framework import authentication
|
||||
from cors_csrf.helpers import is_cross_domain_request_allowed, skip_cross_domain_referer_check
|
||||
|
||||
|
||||
class SessionAuthenticationCrossDomainCsrf(authentication.SessionAuthentication):
|
||||
"""Session authentication that skips the referer check over secure connections.
|
||||
|
||||
Django Rest Framework's `SessionAuthentication` class calls Django's
|
||||
CSRF middleware implementation directly, which bypasses the middleware
|
||||
stack.
|
||||
|
||||
This version of `SessionAuthentication` performs the same workaround
|
||||
as `CorsCSRFMiddleware` to skip the referer check for whitelisted
|
||||
domains over a secure connection. See `cors_csrf.middleware` for
|
||||
more information.
|
||||
|
||||
Since this subclass overrides only the `enforce_csrf()` method,
|
||||
it can be mixed in with other `SessionAuthentication` subclasses.
|
||||
|
||||
"""
|
||||
|
||||
def enforce_csrf(self, request):
|
||||
"""Skip the referer check if the cross-domain request is allowed. """
|
||||
if is_cross_domain_request_allowed(request):
|
||||
with skip_cross_domain_referer_check(request):
|
||||
return super(SessionAuthenticationCrossDomainCsrf, self).enforce_csrf(request)
|
||||
else:
|
||||
return super(SessionAuthenticationCrossDomainCsrf, self).enforce_csrf(request)
|
||||
92
common/djangoapps/cors_csrf/helpers.py
Normal file
92
common/djangoapps/cors_csrf/helpers.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""Helper methods for CORS and CSRF checks. """
|
||||
import logging
|
||||
import urlparse
|
||||
import contextlib
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def is_cross_domain_request_allowed(request):
|
||||
"""Check whether we should allow the cross-domain request.
|
||||
|
||||
We allow a cross-domain request only if:
|
||||
|
||||
1) The request is made securely and the referer has "https://" as the protocol.
|
||||
2) The referer domain has been whitelisted.
|
||||
|
||||
Arguments:
|
||||
request (HttpRequest)
|
||||
|
||||
Returns:
|
||||
bool
|
||||
|
||||
"""
|
||||
referer = request.META.get('HTTP_REFERER')
|
||||
referer_parts = urlparse.urlparse(referer) if referer else None
|
||||
referer_hostname = referer_parts.hostname if referer_parts is not None else None
|
||||
|
||||
# Use CORS_ALLOW_INSECURE *only* for development and testing environments;
|
||||
# it should never be enabled in production.
|
||||
if not getattr(settings, 'CORS_ALLOW_INSECURE', False):
|
||||
if not request.is_secure():
|
||||
log.debug(
|
||||
u"Request is not secure, so we cannot send the CSRF token. "
|
||||
u"For testing purposes, you can disable this check by setting "
|
||||
u"`CORS_ALLOW_INSECURE` to True in the settings"
|
||||
)
|
||||
return False
|
||||
|
||||
if not referer:
|
||||
log.debug(u"No referer provided over a secure connection, so we cannot check the protocol.")
|
||||
return False
|
||||
|
||||
if not referer_parts.scheme == 'https':
|
||||
log.debug(u"Referer '%s' must have the scheme 'https'")
|
||||
return False
|
||||
|
||||
domain_is_whitelisted = (
|
||||
getattr(settings, 'CORS_ORIGIN_ALLOW_ALL', False) or
|
||||
referer_hostname in getattr(settings, 'CORS_ORIGIN_WHITELIST', [])
|
||||
)
|
||||
if not domain_is_whitelisted:
|
||||
if referer_hostname is None:
|
||||
# If no referer is specified, we can't check if it's a cross-domain
|
||||
# request or not.
|
||||
log.debug(u"Referrer hostname is `None`, so it is not on the whitelist.")
|
||||
elif referer_hostname != request.get_host():
|
||||
log.info(
|
||||
(
|
||||
u"Domain '%s' is not on the cross domain whitelist. "
|
||||
u"Add the domain to `CORS_ORIGIN_WHITELIST` or set "
|
||||
u"`CORS_ORIGIN_ALLOW_ALL` to True in the settings."
|
||||
), referer_hostname
|
||||
)
|
||||
else:
|
||||
log.debug(
|
||||
(
|
||||
u"Domain '%s' is the same as the hostname in the request, "
|
||||
u"so we are not going to treat it as a cross-domain request."
|
||||
), referer_hostname
|
||||
)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def skip_cross_domain_referer_check(request):
|
||||
"""Skip the cross-domain CSRF referer check.
|
||||
|
||||
Django's CSRF middleware performs the referer check
|
||||
only when the request is made over a secure connection.
|
||||
To skip the check, we patch `request.is_secure()` to
|
||||
False.
|
||||
"""
|
||||
is_secure_default = request.is_secure
|
||||
request.is_secure = lambda: False
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
request.is_secure = is_secure_default
|
||||
@@ -43,82 +43,16 @@ CSRF cookie.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import urlparse
|
||||
|
||||
from django.conf import settings
|
||||
from django.middleware.csrf import CsrfViewMiddleware
|
||||
from django.core.exceptions import MiddlewareNotUsed, ImproperlyConfigured
|
||||
|
||||
from cors_csrf.helpers import is_cross_domain_request_allowed, skip_cross_domain_referer_check
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def is_cross_domain_request_allowed(request):
|
||||
"""Check whether we should allow the cross-domain request.
|
||||
|
||||
We allow a cross-domain request only if:
|
||||
|
||||
1) The request is made securely and the referer has "https://" as the protocol.
|
||||
2) The referer domain has been whitelisted.
|
||||
|
||||
Arguments:
|
||||
request (HttpRequest)
|
||||
|
||||
Returns:
|
||||
bool
|
||||
|
||||
"""
|
||||
referer = request.META.get('HTTP_REFERER')
|
||||
referer_parts = urlparse.urlparse(referer) if referer else None
|
||||
referer_hostname = referer_parts.hostname if referer_parts is not None else None
|
||||
|
||||
# Use CORS_ALLOW_INSECURE *only* for development and testing environments;
|
||||
# it should never be enabled in production.
|
||||
if not getattr(settings, 'CORS_ALLOW_INSECURE', False):
|
||||
if not request.is_secure():
|
||||
log.debug(
|
||||
u"Request is not secure, so we cannot send the CSRF token. "
|
||||
u"For testing purposes, you can disable this check by setting "
|
||||
u"`CORS_ALLOW_INSECURE` to True in the settings"
|
||||
)
|
||||
return False
|
||||
|
||||
if not referer:
|
||||
log.debug(u"No referer provided over a secure connection, so we cannot check the protocol.")
|
||||
return False
|
||||
|
||||
if not referer_parts.scheme == 'https':
|
||||
log.debug(u"Referer '%s' must have the scheme 'https'")
|
||||
return False
|
||||
|
||||
domain_is_whitelisted = (
|
||||
getattr(settings, 'CORS_ORIGIN_ALLOW_ALL', False) or
|
||||
referer_hostname in getattr(settings, 'CORS_ORIGIN_WHITELIST', [])
|
||||
)
|
||||
if not domain_is_whitelisted:
|
||||
if referer_hostname is None:
|
||||
# If no referer is specified, we can't check if it's a cross-domain
|
||||
# request or not.
|
||||
log.debug(u"Referrer hostname is `None`, so it is not on the whitelist.")
|
||||
elif referer_hostname != request.get_host():
|
||||
log.info(
|
||||
(
|
||||
u"Domain '%s' is not on the cross domain whitelist. "
|
||||
u"Add the domain to `CORS_ORIGIN_WHITELIST` or set "
|
||||
u"`CORS_ORIGIN_ALLOW_ALL` to True in the settings."
|
||||
), referer_hostname
|
||||
)
|
||||
else:
|
||||
log.debug(
|
||||
(
|
||||
u"Domain '%s' is the same as the hostname in the request, "
|
||||
u"so we are not going to treat it as a cross-domain request."
|
||||
), referer_hostname
|
||||
)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class CorsCSRFMiddleware(CsrfViewMiddleware):
|
||||
"""
|
||||
Middleware for handling CSRF checks with CORS requests
|
||||
@@ -134,18 +68,8 @@ class CorsCSRFMiddleware(CsrfViewMiddleware):
|
||||
log.debug("Could not disable CSRF middleware referer check for cross-domain request.")
|
||||
return
|
||||
|
||||
is_secure_default = request.is_secure
|
||||
|
||||
def is_secure_patched():
|
||||
"""
|
||||
Avoid triggering the additional CSRF middleware checks on the referrer
|
||||
"""
|
||||
return False
|
||||
request.is_secure = is_secure_patched
|
||||
|
||||
res = super(CorsCSRFMiddleware, self).process_view(request, callback, callback_args, callback_kwargs)
|
||||
request.is_secure = is_secure_default
|
||||
return res
|
||||
with skip_cross_domain_referer_check(request):
|
||||
return super(CorsCSRFMiddleware, self).process_view(request, callback, callback_args, callback_kwargs)
|
||||
|
||||
|
||||
class CsrfCrossDomainCookieMiddleware(object):
|
||||
|
||||
56
common/djangoapps/cors_csrf/tests/test_authentication.py
Normal file
56
common/djangoapps/cors_csrf/tests/test_authentication.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""Tests for the CORS CSRF version of Django Rest Framework's SessionAuthentication."""
|
||||
from mock import patch
|
||||
|
||||
from django.test import TestCase
|
||||
from django.test.utils import override_settings
|
||||
from django.test.client import RequestFactory
|
||||
from django.conf import settings
|
||||
|
||||
from rest_framework.exceptions import AuthenticationFailed
|
||||
|
||||
from cors_csrf.authentication import SessionAuthenticationCrossDomainCsrf
|
||||
|
||||
|
||||
class CrossDomainAuthTest(TestCase):
|
||||
"""Tests for the CORS CSRF version of Django Rest Framework's SessionAuthentication. """
|
||||
|
||||
URL = "/dummy_url"
|
||||
REFERER = "https://www.edx.org"
|
||||
CSRF_TOKEN = 'abcd1234'
|
||||
|
||||
def setUp(self):
|
||||
super(CrossDomainAuthTest, self).setUp()
|
||||
self.auth = SessionAuthenticationCrossDomainCsrf()
|
||||
|
||||
def test_perform_csrf_referer_check(self):
|
||||
request = self._fake_request()
|
||||
with self.assertRaisesRegexp(AuthenticationFailed, 'CSRF'):
|
||||
self.auth.enforce_csrf(request)
|
||||
|
||||
@patch.dict(settings.FEATURES, {
|
||||
'ENABLE_CORS_HEADERS': True,
|
||||
'ENABLE_CROSS_DOMAIN_CSRF_COOKIE': True
|
||||
})
|
||||
@override_settings(
|
||||
CORS_ORIGIN_WHITELIST=["www.edx.org"],
|
||||
CROSS_DOMAIN_CSRF_COOKIE_NAME="prod-edx-csrftoken",
|
||||
CROSS_DOMAIN_CSRF_COOKIE_DOMAIN=".edx.org"
|
||||
)
|
||||
def test_skip_csrf_referer_check(self):
|
||||
request = self._fake_request()
|
||||
result = self.auth.enforce_csrf(request)
|
||||
self.assertIs(result, None)
|
||||
self.assertTrue(request.is_secure())
|
||||
|
||||
def _fake_request(self):
|
||||
"""Construct a fake request with a referer and CSRF token over a secure connection. """
|
||||
factory = RequestFactory()
|
||||
factory.cookies[settings.CSRF_COOKIE_NAME] = self.CSRF_TOKEN
|
||||
|
||||
request = factory.post(
|
||||
self.URL,
|
||||
HTTP_REFERER=self.REFERER,
|
||||
HTTP_X_CSRFTOKEN=self.CSRF_TOKEN
|
||||
)
|
||||
request.is_secure = lambda: True
|
||||
return request
|
||||
@@ -6,6 +6,8 @@ import json
|
||||
import unittest
|
||||
|
||||
from mock import patch
|
||||
from django.test import Client
|
||||
from django.core.handlers.wsgi import WSGIRequest
|
||||
from django.core.urlresolvers import reverse
|
||||
from rest_framework.test import APITestCase
|
||||
from rest_framework import status
|
||||
@@ -504,3 +506,81 @@ class EnrollmentEmbargoTest(UrlResetMixin, ModuleStoreTestCase):
|
||||
url = reverse('courseenrollments')
|
||||
resp = self.client.get(url)
|
||||
return json.loads(resp.content)
|
||||
|
||||
|
||||
def cross_domain_config(func):
|
||||
"""Decorator for configuring a cross-domain request. """
|
||||
feature_flag_decorator = patch.dict(settings.FEATURES, {
|
||||
'ENABLE_CORS_HEADERS': True,
|
||||
'ENABLE_CROSS_DOMAIN_CSRF_COOKIE': True
|
||||
})
|
||||
settings_decorator = override_settings(
|
||||
CORS_ORIGIN_WHITELIST=["www.edx.org"],
|
||||
CROSS_DOMAIN_CSRF_COOKIE_NAME="prod-edx-csrftoken",
|
||||
CROSS_DOMAIN_CSRF_COOKIE_DOMAIN=".edx.org"
|
||||
)
|
||||
is_secure_decorator = patch.object(WSGIRequest, 'is_secure', return_value=True)
|
||||
|
||||
return feature_flag_decorator(
|
||||
settings_decorator(
|
||||
is_secure_decorator(func)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
|
||||
class EnrollmentCrossDomainTest(ModuleStoreTestCase):
|
||||
"""Test cross-domain calls to the enrollment end-points. """
|
||||
|
||||
USERNAME = "Bob"
|
||||
EMAIL = "bob@example.com"
|
||||
PASSWORD = "edx"
|
||||
REFERER = "https://www.edx.org"
|
||||
|
||||
def setUp(self):
|
||||
""" Create a course and user, then log in. """
|
||||
super(EnrollmentCrossDomainTest, self).setUp()
|
||||
self.course = CourseFactory.create()
|
||||
self.user = UserFactory.create(username=self.USERNAME, email=self.EMAIL, password=self.PASSWORD)
|
||||
|
||||
self.client = Client(enforce_csrf_checks=True)
|
||||
self.client.login(username=self.USERNAME, password=self.PASSWORD)
|
||||
|
||||
@cross_domain_config
|
||||
def test_cross_domain_change_enrollment(self, *args): # pylint: disable=unused-argument
|
||||
csrf_cookie = self._get_csrf_cookie()
|
||||
resp = self._cross_domain_post(csrf_cookie)
|
||||
|
||||
# Expect that the request gets through successfully,
|
||||
# passing the CSRF checks (including the referer check).
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
@cross_domain_config
|
||||
def test_cross_domain_missing_csrf(self, *args): # pylint: disable=unused-argument
|
||||
resp = self._cross_domain_post('invalid_csrf_token')
|
||||
self.assertEqual(resp.status_code, 401)
|
||||
|
||||
def _get_csrf_cookie(self):
|
||||
"""Retrieve the cross-domain CSRF cookie. """
|
||||
url = reverse('courseenrollment', kwargs={
|
||||
'course_id': unicode(self.course.id)
|
||||
})
|
||||
resp = self.client.get(url, HTTP_REFERER=self.REFERER)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertIn('prod-edx-csrftoken', resp.cookies) # pylint: disable=no-member
|
||||
return resp.cookies['prod-edx-csrftoken'].value # pylint: disable=no-member
|
||||
|
||||
def _cross_domain_post(self, csrf_cookie):
|
||||
"""Perform a cross-domain POST request. """
|
||||
url = reverse('courseenrollments')
|
||||
params = json.dumps({
|
||||
'course_details': {
|
||||
'course_id': unicode(self.course.id),
|
||||
},
|
||||
'user': self.user.username
|
||||
})
|
||||
return self.client.post(
|
||||
url, params, content_type='application/json',
|
||||
HTTP_REFERER=self.REFERER,
|
||||
HTTP_X_CSRFTOKEN=csrf_cookie
|
||||
)
|
||||
|
||||
@@ -15,6 +15,7 @@ from rest_framework.throttling import UserRateThrottle
|
||||
from rest_framework.views import APIView
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from embargo import api as embargo_api
|
||||
from cors_csrf.authentication import SessionAuthenticationCrossDomainCsrf
|
||||
from cors_csrf.decorators import ensure_csrf_cookie_cross_domain
|
||||
from util.authentication import SessionAuthenticationAllowInactiveUser, OAuth2AuthenticationAllowInactiveUser
|
||||
from util.disable_rate_limit import can_disable_rate_limit
|
||||
@@ -25,6 +26,11 @@ from enrollment.errors import (
|
||||
)
|
||||
|
||||
|
||||
class EnrollmentCrossDomainSessionAuth(SessionAuthenticationAllowInactiveUser, SessionAuthenticationCrossDomainCsrf):
|
||||
"""Session authentication that allows inactive users and cross-domain requests. """
|
||||
pass
|
||||
|
||||
|
||||
class ApiKeyPermissionMixIn(object):
|
||||
"""
|
||||
This mixin is used to provide a convenience function for doing individual permission checks
|
||||
@@ -277,7 +283,7 @@ class EnrollmentListView(APIView, ApiKeyPermissionMixIn):
|
||||
* user: The ID of the user.
|
||||
"""
|
||||
|
||||
authentication_classes = OAuth2AuthenticationAllowInactiveUser, SessionAuthenticationAllowInactiveUser
|
||||
authentication_classes = OAuth2AuthenticationAllowInactiveUser, EnrollmentCrossDomainSessionAuth
|
||||
permission_classes = ApiKeyHeaderPermissionIsAuthenticated,
|
||||
throttle_classes = EnrollmentUserThrottle,
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ def can_execute_unsafe_code(course_id):
|
||||
# To others using this: the code as-is is brittle and likely to be changed in the future,
|
||||
# as per the TODO, so please consider carefully before adding more values to COURSES_WITH_UNSAFE_CODE
|
||||
for regex in getattr(settings, 'COURSES_WITH_UNSAFE_CODE', []):
|
||||
if re.match(regex, course_id.to_deprecated_string()):
|
||||
if re.match(regex, unicode(course_id)):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ Tests for sandboxing.py in util app
|
||||
"""
|
||||
|
||||
from django.test import TestCase
|
||||
from opaque_keys.edx.locator import LibraryLocator
|
||||
from util.sandboxing import can_execute_unsafe_code
|
||||
from django.test.utils import override_settings
|
||||
from opaque_keys.edx.locations import SlashSeparatedCourseKey
|
||||
@@ -12,12 +13,13 @@ class SandboxingTest(TestCase):
|
||||
"""
|
||||
Test sandbox whitelisting
|
||||
"""
|
||||
@override_settings(COURSES_WITH_UNSAFE_CODE=['edX/full/.*'])
|
||||
@override_settings(COURSES_WITH_UNSAFE_CODE=['edX/full/.*', 'library:v1-edX+.*'])
|
||||
def test_sandbox_exclusion(self):
|
||||
"""
|
||||
Test to make sure that a non-match returns false
|
||||
"""
|
||||
self.assertFalse(can_execute_unsafe_code(SlashSeparatedCourseKey('edX', 'notful', 'empty')))
|
||||
self.assertFalse(can_execute_unsafe_code(LibraryLocator('edY', 'test_bank')))
|
||||
|
||||
@override_settings(COURSES_WITH_UNSAFE_CODE=['edX/full/.*'])
|
||||
def test_sandbox_inclusion(self):
|
||||
@@ -26,10 +28,12 @@ class SandboxingTest(TestCase):
|
||||
"""
|
||||
self.assertTrue(can_execute_unsafe_code(SlashSeparatedCourseKey('edX', 'full', '2012_Fall')))
|
||||
self.assertTrue(can_execute_unsafe_code(SlashSeparatedCourseKey('edX', 'full', '2013_Spring')))
|
||||
self.assertFalse(can_execute_unsafe_code(LibraryLocator('edX', 'test_bank')))
|
||||
|
||||
def test_courses_with_unsafe_code_default(self):
|
||||
def test_courselikes_with_unsafe_code_default(self):
|
||||
"""
|
||||
Test that the default setting for COURSES_WITH_UNSAFE_CODE is an empty setting, e.g. we don't use @override_settings in these tests
|
||||
"""
|
||||
self.assertFalse(can_execute_unsafe_code(SlashSeparatedCourseKey('edX', 'full', '2012_Fall')))
|
||||
self.assertFalse(can_execute_unsafe_code(SlashSeparatedCourseKey('edX', 'full', '2013_Spring')))
|
||||
self.assertFalse(can_execute_unsafe_code(LibraryLocator('edX', 'test_bank')))
|
||||
|
||||
@@ -6,38 +6,33 @@
|
||||
## This enables ASCIIMathJAX, and is used by js_textbox
|
||||
|
||||
|
||||
<%def name="mathjaxConfig()">
|
||||
%if mathjax_mode is not Undefined and mathjax_mode == 'wiki':
|
||||
MathJax.Hub.Config({
|
||||
tex2jax: {inlineMath: [ ['$','$'], ["\\(","\\)"]],
|
||||
displayMath: [ ['$$','$$'], ["\\[","\\]"]]}
|
||||
});
|
||||
%else:
|
||||
MathJax.Hub.Config({
|
||||
tex2jax: {
|
||||
inlineMath: [
|
||||
["\\(","\\)"],
|
||||
['[mathjaxinline]','[/mathjaxinline]']
|
||||
],
|
||||
displayMath: [
|
||||
["\\[","\\]"],
|
||||
['[mathjax]','[/mathjax]']
|
||||
]
|
||||
}
|
||||
});
|
||||
%endif
|
||||
MathJax.Hub.Configured();
|
||||
window.HUB = MathJax.Hub;
|
||||
</%def>
|
||||
%if mathjax_mode is not Undefined and mathjax_mode == 'wiki':
|
||||
<script type="text/x-mathjax-config">
|
||||
MathJax.Hub.Config({
|
||||
tex2jax: {inlineMath: [ ['$','$'], ["\\(","\\)"]],
|
||||
displayMath: [ ['$$','$$'], ["\\[","\\]"]]}
|
||||
});
|
||||
HUB = MathJax.Hub
|
||||
</script>
|
||||
%else:
|
||||
<script type="text/x-mathjax-config">
|
||||
MathJax.Hub.Config({
|
||||
tex2jax: {
|
||||
inlineMath: [
|
||||
["\\(","\\)"],
|
||||
['[mathjaxinline]','[/mathjaxinline]']
|
||||
],
|
||||
displayMath: [
|
||||
["\\[","\\]"],
|
||||
['[mathjax]','[/mathjax]']
|
||||
]
|
||||
}
|
||||
});
|
||||
HUB = MathJax.Hub
|
||||
</script>
|
||||
%endif
|
||||
|
||||
<!-- This must appear after all mathjax-config blocks, so it is after the imports from the other templates.
|
||||
It can't be run through static.url because MathJax uses crazy url introspection to do lazy loading of
|
||||
MathJax extension libraries -->
|
||||
<script type="text/javascript">
|
||||
;(function (require) {
|
||||
'use strict';
|
||||
require(['mathjax'], function() {
|
||||
${mathjaxConfig()}
|
||||
});
|
||||
}).call(this, require || RequireJS.require);
|
||||
</script>
|
||||
<script type="text/javascript" src="https://cdn.mathjax.org/mathjax/2.4-latest/MathJax.js?config=TeX-MML-AM_HTMLorMML-full"></script>
|
||||
|
||||
@@ -19,3 +19,4 @@ class Messages(object):
|
||||
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.'
|
||||
NO_HONOR_MODE = u'Course {course_id} does not have an honor mode.'
|
||||
|
||||
@@ -254,7 +254,6 @@ class OrdersViewTests(EnrollmentEventTestMixin, ModuleStoreTestCase):
|
||||
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)
|
||||
@@ -272,3 +271,29 @@ class OrdersViewTests(EnrollmentEventTestMixin, ModuleStoreTestCase):
|
||||
course_mode.save()
|
||||
|
||||
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()
|
||||
|
||||
@@ -54,17 +54,16 @@ class OrdersView(APIView):
|
||||
|
||||
return True, course_key, None
|
||||
|
||||
def _get_jwt(self, user):
|
||||
def _get_jwt(self, user, ecommerce_api_signing_key):
|
||||
"""
|
||||
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'))
|
||||
return jwt.encode(data, ecommerce_api_signing_key)
|
||||
|
||||
def _enroll(self, course_key, user):
|
||||
""" Enroll the user in the course. """
|
||||
@@ -79,40 +78,44 @@ class OrdersView(APIView):
|
||||
if not valid:
|
||||
return DetailResponse(error, status=HTTP_406_NOT_ACCEPTABLE)
|
||||
|
||||
# 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.
|
||||
if not honor_mode:
|
||||
msg = Messages.NO_HONOR_MODE.format(course_id=course_id)
|
||||
return DetailResponse(msg, status=HTTP_406_NOT_ACCEPTABLE)
|
||||
elif not honor_mode.sku:
|
||||
# If there are no course modes with SKUs, enroll the user without contacting the external API.
|
||||
msg = Messages.NO_SKU_ENROLLED.format(enrollment_mode=CourseMode.HONOR, course_id=course_id,
|
||||
username=user.username)
|
||||
log.debug(msg)
|
||||
self._enroll(course_key, user)
|
||||
return DetailResponse(msg)
|
||||
|
||||
# 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))
|
||||
msg = Messages.NO_ECOM_API.format(username=user.username, course_id=course_id)
|
||||
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)\
|
||||
.exclude(sku__isnull=True).exclude(sku__exact='')
|
||||
|
||||
# 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))
|
||||
'Authorization': 'JWT {}'.format(self._get_jwt(user, ecommerce_api_signing_key))
|
||||
}
|
||||
|
||||
url = '{}/orders/'.format(ecommerce_api_url.strip('/'))
|
||||
|
||||
try:
|
||||
timeout = getattr(settings, 'ECOMMERCE_API_TIMEOUT', 5)
|
||||
response = requests.post(url, data=json.dumps({'sku': course_modes[0].sku}), headers=headers,
|
||||
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)
|
||||
@@ -144,7 +147,7 @@ class OrdersView(APIView):
|
||||
'status': order_status,
|
||||
'complete_status': OrderStatus.COMPLETE,
|
||||
'username': user.username,
|
||||
'course_id': unicode(course_key),
|
||||
'course_id': course_id,
|
||||
}
|
||||
log.error(msg, msg_kwargs)
|
||||
|
||||
|
||||
@@ -556,3 +556,8 @@ XBLOCK_SETTINGS = ENV_TOKENS.get('XBLOCK_SETTINGS', {})
|
||||
##### CDN EXPERIMENT/MONITORING FLAGS #####
|
||||
PERFORMANCE_GRAPHITE_URL = ENV_TOKENS.get('PERFORMANCE_GRAPHITE_URL', PERFORMANCE_GRAPHITE_URL)
|
||||
CDN_VIDEO_URLS = ENV_TOKENS.get('CDN_VIDEO_URLS', CDN_VIDEO_URLS)
|
||||
|
||||
##### ECOMMERCE API CONFIGURATION SETTINGS #####
|
||||
ECOMMERCE_API_URL = ENV_TOKENS.get('ECOMMERCE_API_URL', ECOMMERCE_API_URL)
|
||||
ECOMMERCE_API_SIGNING_KEY = AUTH_TOKENS.get('ECOMMERCE_API_SIGNING_KEY', ECOMMERCE_API_SIGNING_KEY)
|
||||
ECOMMERCE_API_TIMEOUT = ENV_TOKENS.get('ECOMMERCE_API_TIMEOUT', ECOMMERCE_API_TIMEOUT)
|
||||
|
||||
@@ -67,7 +67,6 @@
|
||||
"ova": 'js/vendor/ova/ova',
|
||||
"catch": 'js/vendor/ova/catch/js/catch',
|
||||
"handlebars": 'js/vendor/ova/catch/js/handlebars-1.1.2',
|
||||
"mathjax": 'https://cdn.mathjax.org/mathjax/2.4-latest/MathJax.js?config=TeX-MML-AM_HTMLorMML-full'
|
||||
// end of files needed by OVA
|
||||
},
|
||||
shim: {
|
||||
|
||||
@@ -29,7 +29,7 @@ git+https://github.com/mitocw/django-cas.git@60a5b8e5a62e63e0d5d224a87f0b489201a
|
||||
-e git+https://github.com/edx/bok-choy.git@d62839324cbea30dda564596f20175f9d5c28516#egg=bok_choy
|
||||
-e git+https://github.com/edx-solutions/django-splash.git@7579d052afcf474ece1239153cffe1c89935bc4f#egg=django-splash
|
||||
-e git+https://github.com/edx/acid-block.git@e46f9cda8a03e121a00c7e347084d142d22ebfb7#egg=acid-xblock
|
||||
-e git+https://github.com/edx/edx-ora2.git@4773573b79bc530f0fe7c8f90a10491e4224dc2d#egg=edx-ora2
|
||||
-e git+https://github.com/edx/edx-ora2.git@release-2015-03-16T17.59#egg=edx-ora2
|
||||
-e git+https://github.com/edx/edx-submissions.git@8fb070d2a3087dd7656d27022e550d12e3b85ba3#egg=edx-submissions
|
||||
-e git+https://github.com/edx/opaque-keys.git@1254ed4d615a428591850656f39f26509b86d30a#egg=opaque-keys
|
||||
-e git+https://github.com/edx/ease.git@97de68448e5495385ba043d3091f570a699d5b5f#egg=ease
|
||||
|
||||
Reference in New Issue
Block a user