OAuth2 API to call XBlock handlers

This commit is contained in:
Chandrakant Gopalan
2017-08-31 16:03:17 -04:00
committed by Daniel Clemente Laboreo
parent 1765c99a5a
commit a3df96bf1c
5 changed files with 199 additions and 102 deletions

View File

@@ -17,11 +17,17 @@ from django.template.context_processors import csrf
from django.core.exceptions import PermissionDenied
from django.core.urlresolvers import reverse
from django.http import Http404, HttpResponse, HttpResponseForbidden
from django.utils.text import slugify
from django.views.decorators.csrf import csrf_exempt
from edx_proctoring.services import ProctoringService
from edx_rest_framework_extensions.authentication import JwtAuthentication
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey, UsageKey
from requests.auth import HTTPBasicAuth
from rest_framework.authentication import SessionAuthentication
from rest_framework.permissions import IsAuthenticatedOrReadOnly
from rest_framework.views import APIView
from rest_framework_oauth.authentication import OAuth2Authentication
from six import text_type
from xblock.core import XBlock
from xblock.django.request import django_to_webob_request, webob_to_django_response
@@ -68,7 +74,6 @@ from student.roles import CourseBetaTesterRole
from track import contexts
from util import milestones_helpers
from util.json_request import JsonResponse
from django.utils.text import slugify
from util.sandboxing import can_execute_unsafe_code, get_python_lib_zip
from xblock_django.user_service import DjangoXBlockUserService
from xmodule.contentstore.django import contentstore
@@ -927,39 +932,31 @@ def handle_xblock_callback_noauth(request, course_id, usage_id, handler, suffix=
return _invoke_xblock_handler(request, course_id, usage_id, handler, suffix, course=course)
def handle_xblock_callback(request, course_id, usage_id, handler, suffix=None):
class XblockCallbackView(APIView):
"""
Generic view for extensions. This is where AJAX calls go.
Arguments:
request (Request): Django request.
course_id (str): Course containing the block
usage_id (str)
handler (str)
suffix (str)
Raises:
Http404: If the course is not found in the modulestore.
Class-based view for extensions. This is where AJAX calls go.
Return 403 error if the user is not logged in. Raises Http404 if
the location and course_id do not identify a valid module, the module is
not accessible by the user, or the module raises NotFoundError. If the
module raises any other error, it will escape this function.
"""
# NOTE (CCB): Allow anonymous GET calls (e.g. for transcripts). Modifying this view is simpler than updating
# the XBlocks to use `handle_xblock_callback_noauth`...which is practically identical to this view.
if request.method != 'GET' and not request.user.is_authenticated:
return HttpResponseForbidden()
authentication_classes = (JwtAuthentication, SessionAuthentication, OAuth2Authentication,)
permission_classes = (IsAuthenticatedOrReadOnly,)
request.user.known = request.user.is_authenticated
def get(self, request, course_id, usage_id, handler, suffix=None):
return _get_course_and_invoke_handler(request, course_id, usage_id, handler, suffix)
try:
course_key = CourseKey.from_string(course_id)
except InvalidKeyError:
raise Http404('{} is not a valid course key'.format(course_id))
def post(self, request, course_id, usage_id, handler, suffix=None):
return _get_course_and_invoke_handler(request, course_id, usage_id, handler, suffix)
with modulestore().bulk_operations(course_key):
try:
course = modulestore().get_course(course_key)
except ItemNotFoundError:
raise Http404('{} does not exist in the modulestore'.format(course_id))
def put(self, request, course_id, usage_id, handler, suffix=None):
return _get_course_and_invoke_handler(request, course_id, usage_id, handler, suffix)
return _invoke_xblock_handler(request, course_id, usage_id, handler, suffix, course=course)
def patch(self, request, course_id, usage_id, handler, suffix=None):
return _get_course_and_invoke_handler(request, course_id, usage_id, handler, suffix)
def delete(self, request, course_id, usage_id, handler, suffix=None):
return _get_course_and_invoke_handler(request, course_id, usage_id, handler, suffix)
def get_module_by_usage_id(request, course_id, usage_id, disable_staff_debug_info=False, course=None):
@@ -1037,10 +1034,11 @@ def _invoke_xblock_handler(request, course_id, usage_id, handler, suffix, course
"""
# Check submitted files
files = request.FILES or {}
error_msg = _check_files_limits(files)
if error_msg:
return JsonResponse({'success': error_msg}, status=413)
if request.method == 'POST' and request.META.get('CONTENT_TYPE', '').startswith('multipart/form-data'):
files = request.FILES
error_msg = _check_files_limits(files)
if error_msg:
return JsonResponse({'success': error_msg}, status=413)
# Make a CourseKey from the course_id, raising a 404 upon parse error.
try:
@@ -1095,6 +1093,27 @@ def _invoke_xblock_handler(request, course_id, usage_id, handler, suffix, course
return webob_to_django_response(resp)
def _get_course_and_invoke_handler(request, course_id, usage_id, handler, suffix):
"""
Gets the course object to pass as an additional argument to invoke the xblock handler.
"""
request.user.known = request.user.is_authenticated
try:
course_key = CourseKey.from_string(course_id)
except InvalidKeyError:
raise Http404('{} is not a valid course key'.format(course_id))
with modulestore().bulk_operations(course_key):
try:
course = modulestore().get_course(course_key)
except ItemNotFoundError:
raise Http404('{} does not exist in the modulestore'.format(course_id))
return _invoke_xblock_handler(request, course_id, usage_id, handler, suffix, course=course)
def hash_resource(resource):
"""
Hash a :class:`web_fragments.fragment.FragmentResource

View File

@@ -4,6 +4,7 @@ Tests use cases related to LMS Entrance Exam behavior, such as gated content acc
from django.core.urlresolvers import reverse
from django.test.client import RequestFactory
from mock import Mock, patch
from rest_framework.test import APIRequestFactory
from capa.tests.response_xml_factory import MultipleChoiceResponseXMLFactory
from courseware.entrance_exams import (
@@ -13,7 +14,7 @@ from courseware.entrance_exams import (
user_has_passed_entrance_exam
)
from courseware.model_data import FieldDataCache
from courseware.module_render import get_module, handle_xblock_callback, toc_for_course
from courseware.module_render import get_module, XblockCallbackView, toc_for_course
from courseware.tests.factories import InstructorFactory, StaffFactory, UserFactory
from courseware.tests.helpers import LoginEnrollmentTestCase
from milestones.tests.utils import MilestonesTestCaseMixin
@@ -534,14 +535,15 @@ class EntranceExamTestCases(LoginEnrollmentTestCase, ModuleStoreTestCase, Milest
"""
Tests entrance exam xblock has `entrance_exam_passed` key in json response.
"""
request_factory = RequestFactory()
request_factory = APIRequestFactory()
data = {'input_{}_2_1'.format(unicode(self.problem_1.location.html_id())): 'choice_2'}
request = request_factory.post(
'problem_check',
data=data
)
request.user = self.user
response = handle_xblock_callback(
view = XblockCallbackView.as_view()
response = view(
request,
unicode(self.course.id),
unicode(self.problem_1.location),

View File

@@ -28,6 +28,7 @@ from mock import MagicMock, Mock, patch
from nose.plugins.attrib import attr
from opaque_keys.edx.keys import CourseKey, UsageKey
from pyquery import PyQuery
from rest_framework.test import APIRequestFactory
from six import text_type
from web_fragments.fragment import Fragment
from xblock.completable import CompletableXBlockMixin
@@ -44,7 +45,12 @@ from courseware.field_overrides import OverrideFieldData
from courseware.masquerade import CourseMasquerade
from courseware.model_data import FieldDataCache
from courseware.models import StudentModule
from courseware.module_render import get_module_for_descriptor, hash_resource
from courseware.module_render import (
get_module_for_descriptor,
hash_resource,
XblockCallbackView,
handle_xblock_callback_noauth,
)
from courseware.tests.factories import GlobalStaffFactory, StudentModuleFactory, UserFactory
from courseware.tests.test_submitting_problems import TestSubmittingProblems
from courseware.tests.tests import LoginEnrollmentTestCase
@@ -69,6 +75,8 @@ from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory, ToyC
from xmodule.modulestore.tests.test_asides import AsideTestType
from xmodule.x_module import STUDENT_VIEW, CombinedSystem, XModule, XModuleDescriptor
from edx_oauth2_provider.tests.factories import AccessTokenFactory, ClientFactory
TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT
@@ -299,7 +307,8 @@ class ModuleRenderTestCase(SharedModuleStoreTestCase, LoginEnrollmentTestCase):
self.dispatch
)
def test_anonymous_handle_xblock_callback(self):
def test_anonymous_post_xblock_callback(self):
"""Test that anonymous POST are not allowed."""
dispatch_url = reverse(
'xblock_handler',
args=[
@@ -310,7 +319,55 @@ class ModuleRenderTestCase(SharedModuleStoreTestCase, LoginEnrollmentTestCase):
]
)
response = self.client.post(dispatch_url, {'position': 2})
self.assertEquals(403, response.status_code)
# "401 Unauthorized", despite its name, means authentication error, i.e. no token/key/password supplied
self.assertEquals(401, response.status_code)
def test_anonymous_get_xblock_callback(self):
"""Test that anonymous GET are allowed."""
dispatch_url = reverse(
'xblock_handler',
args=[
text_type(self.course_key),
quote_slashes(text_type(self.course_key.make_usage_key('videosequence', 'Toy_Videos'))),
'xmodule_handler',
'goto_position',
]
)
# Calling 'goto_position' with GET won't have side effects but it triggers the permission tests
response = self.client.get(dispatch_url)
self.assertEquals(200, response.status_code)
def test_session_authentication(self):
""" Test that the xblock endpoint supports session authentication. """
self.client.login(username=self.mock_user.username, password="test")
dispatch_url = reverse(
'xblock_handler',
args=[
text_type(self.course_key),
quote_slashes(text_type(self.course_key.make_usage_key('videosequence', 'Toy_Videos'))),
'xmodule_handler',
'goto_position'
]
)
response = self.client.post(dispatch_url)
self.assertEqual(200, response.status_code)
def test_oauth_authentication(self):
""" Test that the xblock endpoint supports oauth authentication. """
dispatch_url = reverse(
'xblock_handler',
args=[
text_type(self.course_key),
quote_slashes(text_type(self.course_key.make_usage_key('videosequence', 'Toy_Videos'))),
'xmodule_handler',
'goto_position'
]
)
access_token = AccessTokenFactory(user=self.mock_user, client=ClientFactory()).token
headers = {}
headers['HTTP_AUTHORIZATION'] = 'Bearer ' + access_token
response = self.client.post(dispatch_url, {}, **headers)
self.assertEqual(200, response.status_code)
def test_missing_position_handler(self):
"""
@@ -449,7 +506,7 @@ class ModuleRenderTestCase(SharedModuleStoreTestCase, LoginEnrollmentTestCase):
@ddt.ddt
class TestHandleXBlockCallback(SharedModuleStoreTestCase, LoginEnrollmentTestCase):
"""
Test the handle_xblock_callback function
Test the handle_xblock_callback view
"""
@classmethod
def setUpClass(cls):
@@ -463,6 +520,7 @@ class TestHandleXBlockCallback(SharedModuleStoreTestCase, LoginEnrollmentTestCas
self.location = self.course_key.make_usage_key('chapter', 'Overview')
self.mock_user = UserFactory.create()
self.request_factory = RequestFactory()
self.api_request_factory = APIRequestFactory()
# Construct a mock module for the modulestore to return
self.mock_module = MagicMock()
@@ -500,7 +558,16 @@ class TestHandleXBlockCallback(SharedModuleStoreTestCase, LoginEnrollmentTestCas
content_type='application/json',
)
request.user = self.mock_user
response = render.handle_xblock_callback(
# Django tests have CSRF checks disabled by default, but DRF always wants to see a CSRF token if we use
# SessionAuthentication (check http://www.django-rest-framework.org/api-guide/testing/#csrf)
# Instead of getting the CSRF token as per that code (which doesn't work in this case due to RequestFactory),
# we disable CSRF for this request too.
# See https://dammit.nl/20141111-disabling-csrf-checking-in-django-rest-framework-to-enable-replay.html
setattr(request, '_dont_enforce_csrf_checks', True)
view = XblockCallbackView.as_view()
response = view(
request,
unicode(course.id),
quote_slashes(unicode(block.scope_ids.usage_id)),
@@ -511,25 +578,27 @@ class TestHandleXBlockCallback(SharedModuleStoreTestCase, LoginEnrollmentTestCas
return response
def test_invalid_location(self):
request = self.request_factory.post('dummy_url', data={'position': 1})
request = self.api_request_factory.post('dummy_url', data={'position': 1})
request.user = self.mock_user
with self.assertRaises(Http404):
render.handle_xblock_callback(
request,
text_type(self.course_key),
'invalid Location',
'dummy_handler'
'dummy_dispatch'
)
view = XblockCallbackView.as_view()
resp = view(
request,
text_type(self.course_key),
'invalid Location',
'dummy_handler'
'dummy_dispatch'
)
self.assertEquals(resp.status_code, 404)
def test_too_many_files(self):
request = self.request_factory.post(
request = self.api_request_factory.post(
'dummy_url',
data={'file_id': (self._mock_file(), ) * (settings.MAX_FILEUPLOADS_PER_INPUT + 1)}
)
request.user = self.mock_user
view = XblockCallbackView.as_view()
self.assertEquals(
render.handle_xblock_callback(
view(
request,
text_type(self.course_key),
quote_slashes(text_type(self.location)),
@@ -543,13 +612,14 @@ class TestHandleXBlockCallback(SharedModuleStoreTestCase, LoginEnrollmentTestCas
def test_too_large_file(self):
inputfile = self._mock_file(size=1 + settings.STUDENT_FILEUPLOAD_MAX_SIZE)
request = self.request_factory.post(
request = self.api_request_factory.post(
'dummy_url',
data={'file_id': inputfile}
)
request.user = self.mock_user
view = XblockCallbackView.as_view()
self.assertEquals(
render.handle_xblock_callback(
view(
request,
text_type(self.course_key),
quote_slashes(text_type(self.location)),
@@ -562,9 +632,10 @@ class TestHandleXBlockCallback(SharedModuleStoreTestCase, LoginEnrollmentTestCas
)
def test_xmodule_dispatch(self):
request = self.request_factory.post('dummy_url', data={'position': 1})
request = self.api_request_factory.post('dummy_url', data={'position': 1})
request.user = self.mock_user
response = render.handle_xblock_callback(
view = XblockCallbackView.as_view()
response = view(
request,
text_type(self.course_key),
quote_slashes(text_type(self.location)),
@@ -574,66 +645,70 @@ class TestHandleXBlockCallback(SharedModuleStoreTestCase, LoginEnrollmentTestCas
self.assertIsInstance(response, HttpResponse)
def test_bad_course_id(self):
request = self.request_factory.post('dummy_url')
request = self.api_request_factory.post('dummy_url')
request.user = self.mock_user
with self.assertRaises(Http404):
render.handle_xblock_callback(
request,
'bad_course_id',
quote_slashes(text_type(self.location)),
'xmodule_handler',
'goto_position',
)
view = XblockCallbackView.as_view()
resp = view(
request,
'bad_course_id',
quote_slashes(text_type(self.location)),
'xmodule_handler',
'goto_position',
)
self.assertEquals(resp.status_code, 404)
def test_bad_location(self):
request = self.request_factory.post('dummy_url')
request = self.api_request_factory.post('dummy_url')
request.user = self.mock_user
with self.assertRaises(Http404):
render.handle_xblock_callback(
request,
text_type(self.course_key),
quote_slashes(text_type(self.course_key.make_usage_key('chapter', 'bad_location'))),
'xmodule_handler',
'goto_position',
)
view = XblockCallbackView.as_view()
resp = view(
request,
text_type(self.course_key),
quote_slashes(text_type(self.course_key.make_usage_key('chapter', 'bad_location'))),
'xmodule_handler',
'goto_position',
)
self.assertEquals(resp.status_code, 404)
def test_bad_xmodule_dispatch(self):
request = self.request_factory.post('dummy_url')
request = self.api_request_factory.post('dummy_url')
request.user = self.mock_user
with self.assertRaises(Http404):
render.handle_xblock_callback(
request,
text_type(self.course_key),
quote_slashes(text_type(self.location)),
'xmodule_handler',
'bad_dispatch',
)
view = XblockCallbackView.as_view()
resp = view(
request,
text_type(self.course_key),
quote_slashes(text_type(self.location)),
'xmodule_handler',
'bad_dispatch',
)
self.assertEquals(resp.status_code, 404)
def test_missing_handler(self):
request = self.request_factory.post('dummy_url')
request = self.api_request_factory.post('dummy_url')
request.user = self.mock_user
with self.assertRaises(Http404):
render.handle_xblock_callback(
request,
text_type(self.course_key),
quote_slashes(text_type(self.location)),
'bad_handler',
'bad_dispatch',
)
view = XblockCallbackView.as_view()
resp = view(
request,
text_type(self.course_key),
quote_slashes(text_type(self.location)),
'bad_handler',
'bad_dispatch',
)
self.assertEquals(resp.status_code, 404)
@XBlock.register_temp_plugin(GradedStatelessXBlock, identifier='stateless_scorer')
def test_score_without_student_state(self):
course = CourseFactory.create()
block = ItemFactory.create(category='stateless_scorer', parent=course)
request = self.request_factory.post(
request = self.api_request_factory.post(
'dummy_url',
data=json.dumps({"grade": 0.75}),
content_type='application/json'
)
request.user = self.mock_user
response = render.handle_xblock_callback(
view = XblockCallbackView.as_view()
response = view(
request,
unicode(course.id),
quote_slashes(unicode(block.scope_ids.usage_id)),
@@ -658,14 +733,15 @@ class TestHandleXBlockCallback(SharedModuleStoreTestCase, LoginEnrollmentTestCas
with completion_waffle.waffle().override(completion_waffle.ENABLE_COMPLETION_TRACKING, False):
course = CourseFactory.create()
block = ItemFactory.create(category='comp', parent=course)
request = self.request_factory.post(
request = self.api_request_factory.post(
'/',
data=json.dumps(data),
content_type='application/json',
)
request.user = self.mock_user
with patch('completion.models.BlockCompletionManager.submit_completion') as mock_complete:
render.handle_xblock_callback(
view = XblockCallbackView.as_view()
view(
request,
unicode(course.id),
quote_slashes(unicode(block.scope_ids.usage_id)),
@@ -732,7 +808,7 @@ class TestHandleXBlockCallback(SharedModuleStoreTestCase, LoginEnrollmentTestCas
request.user.real_user.masquerade_settings = CourseMasquerade(course.id, user_name="jem")
with patch('courseware.module_render.is_masquerading_as_specific_student') as mock_masq:
mock_masq.return_value = True
response = render.handle_xblock_callback(
response = handle_xblock_callback_noauth(
request,
unicode(course.id),
quote_slashes(unicode(block.scope_ids.usage_id)),
@@ -1911,7 +1987,8 @@ class TestModuleTrackingContext(SharedModuleStoreTestCase):
descriptor = ItemFactory.create(**descriptor_kwargs)
render.handle_xblock_callback(
view = XblockCallbackView.as_view()
view(
self.request,
text_type(self.course.id),
quote_slashes(text_type(descriptor.location)),

View File

@@ -1,5 +1,5 @@
$(document).ajaxError(function(event, jXHR) {
if (jXHR.status === 403) {
if (jXHR.status === 403 || jXHR.status === 401) {
var message = gettext(
'You have been logged out of your edX account. ' +
'Click Okay to log in again now. ' +

View File

@@ -1,7 +1,6 @@
"""
URLs for LMS
"""
from django.conf import settings
from django.conf.urls import include, url
from django.conf.urls.static import static
@@ -13,7 +12,7 @@ from rest_framework_swagger.views import get_swagger_view
from branding import views as branding_views
from config_models.views import ConfigurationModelCurrentAPIView
from courseware.masquerade import handle_ajax as courseware_masquerade_handle_ajax
from courseware.module_render import handle_xblock_callback, handle_xblock_callback_noauth, xblock_view, xqueue_callback
from courseware.module_render import handle_xblock_callback_noauth, XblockCallbackView, xblock_view, xqueue_callback
from courseware.views import views as courseware_views
from courseware.views.index import CoursewareIndex
from courseware.views.views import CourseTabView, EnrollStaffView, StaticCourseTabView
@@ -250,7 +249,7 @@ urlpatterns += [
course_key=settings.COURSE_ID_PATTERN,
usage_key=settings.USAGE_ID_PATTERN,
),
handle_xblock_callback,
XblockCallbackView.as_view(),
name='xblock_handler',
),
url(