Commit 243e2660 by Brian Wilson

Merge release to master for 20150317 release.

parent 4860837e
"""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)
"""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,80 +43,14 @@ CSRF cookie. ...@@ -43,80 +43,14 @@ CSRF cookie.
""" """
import logging import logging
import urlparse
from django.conf import settings from django.conf import settings
from django.middleware.csrf import CsrfViewMiddleware from django.middleware.csrf import CsrfViewMiddleware
from django.core.exceptions import MiddlewareNotUsed, ImproperlyConfigured from django.core.exceptions import MiddlewareNotUsed, ImproperlyConfigured
log = logging.getLogger(__name__) from cors_csrf.helpers import is_cross_domain_request_allowed, skip_cross_domain_referer_check
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: log = logging.getLogger(__name__)
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): class CorsCSRFMiddleware(CsrfViewMiddleware):
...@@ -134,18 +68,8 @@ class CorsCSRFMiddleware(CsrfViewMiddleware): ...@@ -134,18 +68,8 @@ class CorsCSRFMiddleware(CsrfViewMiddleware):
log.debug("Could not disable CSRF middleware referer check for cross-domain request.") log.debug("Could not disable CSRF middleware referer check for cross-domain request.")
return return
is_secure_default = request.is_secure with skip_cross_domain_referer_check(request):
return super(CorsCSRFMiddleware, self).process_view(request, callback, callback_args, callback_kwargs)
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
class CsrfCrossDomainCookieMiddleware(object): class CsrfCrossDomainCookieMiddleware(object):
......
"""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 ...@@ -6,6 +6,8 @@ import json
import unittest import unittest
from mock import patch from mock import patch
from django.test import Client
from django.core.handlers.wsgi import WSGIRequest
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
from rest_framework import status from rest_framework import status
...@@ -504,3 +506,81 @@ class EnrollmentEmbargoTest(UrlResetMixin, ModuleStoreTestCase): ...@@ -504,3 +506,81 @@ class EnrollmentEmbargoTest(UrlResetMixin, ModuleStoreTestCase):
url = reverse('courseenrollments') url = reverse('courseenrollments')
resp = self.client.get(url) resp = self.client.get(url)
return json.loads(resp.content) 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 ...@@ -15,6 +15,7 @@ from rest_framework.throttling import UserRateThrottle
from rest_framework.views import APIView from rest_framework.views import APIView
from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.keys import CourseKey
from embargo import api as embargo_api from embargo import api as embargo_api
from cors_csrf.authentication import SessionAuthenticationCrossDomainCsrf
from cors_csrf.decorators import ensure_csrf_cookie_cross_domain from cors_csrf.decorators import ensure_csrf_cookie_cross_domain
from util.authentication import SessionAuthenticationAllowInactiveUser, OAuth2AuthenticationAllowInactiveUser from util.authentication import SessionAuthenticationAllowInactiveUser, OAuth2AuthenticationAllowInactiveUser
from util.disable_rate_limit import can_disable_rate_limit from util.disable_rate_limit import can_disable_rate_limit
...@@ -25,6 +26,11 @@ from enrollment.errors import ( ...@@ -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): class ApiKeyPermissionMixIn(object):
""" """
This mixin is used to provide a convenience function for doing individual permission checks This mixin is used to provide a convenience function for doing individual permission checks
...@@ -277,7 +283,7 @@ class EnrollmentListView(APIView, ApiKeyPermissionMixIn): ...@@ -277,7 +283,7 @@ class EnrollmentListView(APIView, ApiKeyPermissionMixIn):
* user: The ID of the user. * user: The ID of the user.
""" """
authentication_classes = OAuth2AuthenticationAllowInactiveUser, SessionAuthenticationAllowInactiveUser authentication_classes = OAuth2AuthenticationAllowInactiveUser, EnrollmentCrossDomainSessionAuth
permission_classes = ApiKeyHeaderPermissionIsAuthenticated, permission_classes = ApiKeyHeaderPermissionIsAuthenticated,
throttle_classes = EnrollmentUserThrottle, throttle_classes = EnrollmentUserThrottle,
......
...@@ -25,7 +25,7 @@ def can_execute_unsafe_code(course_id): ...@@ -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, # 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 # 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', []): 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 True
return False return False
......
...@@ -3,6 +3,7 @@ Tests for sandboxing.py in util app ...@@ -3,6 +3,7 @@ Tests for sandboxing.py in util app
""" """
from django.test import TestCase from django.test import TestCase
from opaque_keys.edx.locator import LibraryLocator
from util.sandboxing import can_execute_unsafe_code from util.sandboxing import can_execute_unsafe_code
from django.test.utils import override_settings from django.test.utils import override_settings
from opaque_keys.edx.locations import SlashSeparatedCourseKey from opaque_keys.edx.locations import SlashSeparatedCourseKey
...@@ -12,12 +13,13 @@ class SandboxingTest(TestCase): ...@@ -12,12 +13,13 @@ class SandboxingTest(TestCase):
""" """
Test sandbox whitelisting 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): def test_sandbox_exclusion(self):
""" """
Test to make sure that a non-match returns false 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(SlashSeparatedCourseKey('edX', 'notful', 'empty')))
self.assertFalse(can_execute_unsafe_code(LibraryLocator('edY', 'test_bank')))
@override_settings(COURSES_WITH_UNSAFE_CODE=['edX/full/.*']) @override_settings(COURSES_WITH_UNSAFE_CODE=['edX/full/.*'])
def test_sandbox_inclusion(self): def test_sandbox_inclusion(self):
...@@ -26,10 +28,12 @@ class SandboxingTest(TestCase): ...@@ -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', '2012_Fall')))
self.assertTrue(can_execute_unsafe_code(SlashSeparatedCourseKey('edX', 'full', '2013_Spring'))) 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 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', '2012_Fall')))
self.assertFalse(can_execute_unsafe_code(SlashSeparatedCourseKey('edX', 'full', '2013_Spring'))) 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 @@ ...@@ -6,38 +6,33 @@
## This enables ASCIIMathJAX, and is used by js_textbox ## This enables ASCIIMathJAX, and is used by js_textbox
<%def name="mathjaxConfig()"> %if mathjax_mode is not Undefined and mathjax_mode == 'wiki':
%if mathjax_mode is not Undefined and mathjax_mode == 'wiki': <script type="text/x-mathjax-config">
MathJax.Hub.Config({ MathJax.Hub.Config({
tex2jax: {inlineMath: [ ['$','$'], ["\\(","\\)"]], tex2jax: {inlineMath: [ ['$','$'], ["\\(","\\)"]],
displayMath: [ ['$$','$$'], ["\\[","\\]"]]} displayMath: [ ['$$','$$'], ["\\[","\\]"]]}
}); });
%else: HUB = MathJax.Hub
MathJax.Hub.Config({ </script>
tex2jax: { %else:
inlineMath: [ <script type="text/x-mathjax-config">
["\\(","\\)"], MathJax.Hub.Config({
['[mathjaxinline]','[/mathjaxinline]'] tex2jax: {
], inlineMath: [
displayMath: [ ["\\(","\\)"],
["\\[","\\]"], ['[mathjaxinline]','[/mathjaxinline]']
['[mathjax]','[/mathjax]'] ],
] displayMath: [
} ["\\[","\\]"],
}); ['[mathjax]','[/mathjax]']
%endif ]
MathJax.Hub.Configured(); }
window.HUB = MathJax.Hub; });
</%def> HUB = MathJax.Hub
</script>
%endif
<!-- This must appear after all mathjax-config blocks, so it is after the imports from the other templates. <!-- 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 It can't be run through static.url because MathJax uses crazy url introspection to do lazy loading of
MathJax extension libraries --> MathJax extension libraries -->
<script type="text/javascript"> <script type="text/javascript" src="https://cdn.mathjax.org/mathjax/2.4-latest/MathJax.js?config=TeX-MML-AM_HTMLorMML-full"></script>
;(function (require) {
'use strict';
require(['mathjax'], function() {
${mathjaxConfig()}
});
}).call(this, require || RequireJS.require);
</script>
...@@ -19,3 +19,4 @@ class Messages(object): ...@@ -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.' 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_COMPLETED = u'Order {order_number} was completed.'
ORDER_INCOMPLETE_ENROLLED = u'Order {order_number} was created, but is not yet complete. User was enrolled.' 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): ...@@ -254,7 +254,6 @@ class OrdersViewTests(EnrollmentEventTestMixin, ModuleStoreTestCase):
response = self._post_to_view() response = self._post_to_view()
# Validate the response # Validate the response
self._mock_ecommerce_api()
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
msg = Messages.NO_ECOM_API.format(username=self.user.username, course_id=self.course.id) msg = Messages.NO_ECOM_API.format(username=self.user.username, course_id=self.course.id)
self.assertResponseMessage(response, msg) self.assertResponseMessage(response, msg)
...@@ -272,3 +271,29 @@ class OrdersViewTests(EnrollmentEventTestMixin, ModuleStoreTestCase): ...@@ -272,3 +271,29 @@ class OrdersViewTests(EnrollmentEventTestMixin, ModuleStoreTestCase):
course_mode.save() course_mode.save()
self._test_course_without_sku() 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): ...@@ -54,17 +54,16 @@ class OrdersView(APIView):
return True, course_key, None 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. Returns a JWT object with the specified user's info.
Raises AttributeError if settings.ECOMMERCE_API_SIGNING_KEY is not set.
""" """
data = { data = {
'username': user.username, 'username': user.username,
'email': user.email '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): def _enroll(self, course_key, user):
""" Enroll the user in the course. """ """ Enroll the user in the course. """
...@@ -79,40 +78,44 @@ class OrdersView(APIView): ...@@ -79,40 +78,44 @@ class OrdersView(APIView):
if not valid: if not valid:
return DetailResponse(error, status=HTTP_406_NOT_ACCEPTABLE) 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 # Ensure that the E-Commerce API is setup properly
ecommerce_api_url = getattr(settings, 'ECOMMERCE_API_URL', None) ecommerce_api_url = getattr(settings, 'ECOMMERCE_API_URL', None)
ecommerce_api_signing_key = getattr(settings, 'ECOMMERCE_API_SIGNING_KEY', None) ecommerce_api_signing_key = getattr(settings, 'ECOMMERCE_API_SIGNING_KEY', None)
if not (ecommerce_api_url and ecommerce_api_signing_key): if not (ecommerce_api_url and ecommerce_api_signing_key):
self._enroll(course_key, user) 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) log.debug(msg)
return DetailResponse(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 # Contact external API
headers = { headers = {
'Content-Type': 'application/json', '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('/')) url = '{}/orders/'.format(ecommerce_api_url.strip('/'))
try: try:
timeout = getattr(settings, 'ECOMMERCE_API_TIMEOUT', 5) 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) timeout=timeout)
except Exception as ex: # pylint: disable=broad-except except Exception as ex: # pylint: disable=broad-except
log.exception('Call to E-Commerce API failed: %s.', ex.message) log.exception('Call to E-Commerce API failed: %s.', ex.message)
...@@ -144,7 +147,7 @@ class OrdersView(APIView): ...@@ -144,7 +147,7 @@ class OrdersView(APIView):
'status': order_status, 'status': order_status,
'complete_status': OrderStatus.COMPLETE, 'complete_status': OrderStatus.COMPLETE,
'username': user.username, 'username': user.username,
'course_id': unicode(course_key), 'course_id': course_id,
} }
log.error(msg, msg_kwargs) log.error(msg, msg_kwargs)
......
...@@ -556,3 +556,8 @@ XBLOCK_SETTINGS = ENV_TOKENS.get('XBLOCK_SETTINGS', {}) ...@@ -556,3 +556,8 @@ XBLOCK_SETTINGS = ENV_TOKENS.get('XBLOCK_SETTINGS', {})
##### CDN EXPERIMENT/MONITORING FLAGS ##### ##### CDN EXPERIMENT/MONITORING FLAGS #####
PERFORMANCE_GRAPHITE_URL = ENV_TOKENS.get('PERFORMANCE_GRAPHITE_URL', PERFORMANCE_GRAPHITE_URL) PERFORMANCE_GRAPHITE_URL = ENV_TOKENS.get('PERFORMANCE_GRAPHITE_URL', PERFORMANCE_GRAPHITE_URL)
CDN_VIDEO_URLS = ENV_TOKENS.get('CDN_VIDEO_URLS', CDN_VIDEO_URLS) 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 @@ ...@@ -67,7 +67,6 @@
"ova": 'js/vendor/ova/ova', "ova": 'js/vendor/ova/ova',
"catch": 'js/vendor/ova/catch/js/catch', "catch": 'js/vendor/ova/catch/js/catch',
"handlebars": 'js/vendor/ova/catch/js/handlebars-1.1.2', "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 // end of files needed by OVA
}, },
shim: { shim: {
......
...@@ -29,7 +29,7 @@ git+https://github.com/mitocw/django-cas.git@60a5b8e5a62e63e0d5d224a87f0b489201a ...@@ -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/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-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/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/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/opaque-keys.git@1254ed4d615a428591850656f39f26509b86d30a#egg=opaque-keys
-e git+https://github.com/edx/ease.git@97de68448e5495385ba043d3091f570a699d5b5f#egg=ease -e git+https://github.com/edx/ease.git@97de68448e5495385ba043d3091f570a699d5b5f#egg=ease
......
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