From 2ba4957951ed92bf21dacd5caf05c2bf676ad4fe Mon Sep 17 00:00:00 2001 From: Xavier Antoviaque <xavier@antoviaque.org> Date: Tue, 18 Mar 2014 08:49:16 -0400 Subject: [PATCH] xblock-external-ui: Add XBlock API call to render XBlock views xblock-external-ui: Include CSRF token in the API answer xblock-external-ui: Include full path when building local_url xblock-external-ui: Fix TestHandleXBlockCallback & bok_choy, add tests xblock-external-ui: Only return `instance` in `_invoke_xblock_handler()` xblock-external-ui: Group resources by hash tag to avoid duplicate loads xblock-external-ui: PEP8 xblock-external-ui: Fail early if the XBlock view is called anonymously We used to serve anonymous requests, but most XBlocks assume that the user is logged in, which can generate a lot of errors when the user is accessed or when an XBlock ajax callback is queried. Fail early to only get one error per page load, and prevent displaying the XBlock altogether when the LMS doesn't find an active user session. xblock-external-ui: Add request params in view render context xblock-external-ui: HTTP error status when file is too large for handler xblock-external-ui: Fix unicode encodings in XBlock rendering xblock-external-ui: Feature flag for API call ENABLE_XBLOCK_VIEW_ENDPOINT --- lms/djangoapps/courseware/module_render.py | 107 ++++++++++++++---- .../courseware/tests/test_module_render.py | 36 +++++- lms/djangoapps/lms_xblock/runtime.py | 4 +- lms/envs/bok_choy.env.json | 2 +- lms/envs/common.py | 7 ++ lms/urls.py | 5 + 6 files changed, 137 insertions(+), 24 deletions(-) diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index 6fe7f2b276c..433086e2cba 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -1,3 +1,8 @@ +""" +Module rendering +""" + +import hashlib import json import logging import mimetypes @@ -5,6 +10,7 @@ import mimetypes import static_replace import xblock.reference.plugins +from collections import OrderedDict from functools import partial from requests.auth import HTTPBasicAuth import dogstats_wrapper as dog_stats_api @@ -13,6 +19,8 @@ from opaque_keys import InvalidKeyError from django.conf import settings from django.contrib.auth.models import User from django.core.cache import cache +from django.core.context_processors import csrf +from django.core.exceptions import PermissionDenied from django.core.urlresolvers import reverse from django.http import Http404, HttpResponse from django.views.decorators.csrf import csrf_exempt @@ -32,7 +40,7 @@ from student.models import anonymous_id_for_user, user_by_anonymous_id from xblock.core import XBlock from xblock.fields import Scope from xblock.runtime import KvsFieldData, KeyValueStore -from xblock.exceptions import NoSuchHandlerError +from xblock.exceptions import NoSuchHandlerError, NoSuchViewError from xblock.django.request import django_to_webob_request, webob_to_django_response from xmodule.error_module import ErrorDescriptor, NonStaffErrorDescriptor from xmodule.exceptions import NotFoundError, ProcessingError @@ -781,7 +789,7 @@ def handle_xblock_callback_noauth(request, course_id, usage_id, handler, suffix= """ request.user.known = False - return _invoke_xblock_handler(request, course_id, usage_id, handler, suffix, request.user) + return _invoke_xblock_handler(request, course_id, usage_id, handler, suffix) def handle_xblock_callback(request, course_id, usage_id, handler, suffix=None): @@ -802,7 +810,7 @@ def handle_xblock_callback(request, course_id, usage_id, handler, suffix=None): if not request.user.is_authenticated(): return HttpResponse('Unauthenticated', status=403) - return _invoke_xblock_handler(request, course_id, usage_id, handler, suffix, request.user) + return _invoke_xblock_handler(request, course_id, usage_id, handler, suffix) def xblock_resource(request, block_type, uri): # pylint: disable=unused-argument @@ -822,31 +830,20 @@ def xblock_resource(request, block_type, uri): # pylint: disable=unused-argumen return HttpResponse(content, mimetype=mimetype) -def _invoke_xblock_handler(request, course_id, usage_id, handler, suffix, user): +def _get_module_by_usage_id(request, course_id, usage_id): """ - Invoke an XBlock handler, either authenticated or not. - - Arguments: - request (HttpRequest): the current request - course_id (str): A string of the form org/course/run - usage_id (str): A string of the form i4x://org/course/category/name@revision - handler (str): The name of the handler to invoke - suffix (str): The suffix to pass to the handler when invoked - user (User): The currently logged in user + Gets a module instance based on its `usage_id` in a course, for a given request/user + Returns (instance, tracking_context) """ + user = request.user + try: course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id) usage_key = course_id.make_usage_key_from_deprecated_string(unquote_slashes(usage_id)) except InvalidKeyError: raise Http404("Invalid location") - # Check submitted files - files = request.FILES or {} - error_msg = _check_files_limits(files) - if error_msg: - return HttpResponse(json.dumps({'success': error_msg})) - try: descriptor = modulestore().get_item(usage_key) descriptor_orig_usage_key, descriptor_orig_version = modulestore().get_block_original_usage(usage_key) @@ -859,13 +856,13 @@ def _invoke_xblock_handler(request, course_id, usage_id, handler, suffix, user): ) raise Http404 - tracking_context_name = 'module_callback_handler' tracking_context = { 'module': { 'display_name': descriptor.display_name_with_default, 'usage_key': unicode(descriptor.location), } } + # For blocks that are inherited from a content library, we add some additional metadata: if descriptor_orig_usage_key is not None: tracking_context['module']['original_usage_key'] = unicode(descriptor_orig_usage_key) @@ -884,6 +881,30 @@ def _invoke_xblock_handler(request, course_id, usage_id, handler, suffix, user): log.debug("No module %s for user %s -- access denied?", usage_key, user) raise Http404 + return (instance, tracking_context) + + +def _invoke_xblock_handler(request, course_id, usage_id, handler, suffix): + """ + Invoke an XBlock handler, either authenticated or not. + + Arguments: + request (HttpRequest): the current request + course_id (str): A string of the form org/course/run + usage_id (str): A string of the form i4x://org/course/category/name@revision + handler (str): The name of the handler to invoke + suffix (str): The suffix to pass to the handler when invoked + """ + + # Check submitted files + files = request.FILES or {} + error_msg = _check_files_limits(files) + if error_msg: + return JsonResponse(object={'success': error_msg}, status=413) + + instance, tracking_context = _get_module_by_usage_id(request, course_id, usage_id) + + tracking_context_name = 'module_callback_handler' req = django_to_webob_request(request) try: with tracker.get_tracker().context(tracking_context_name, tracking_context): @@ -912,6 +933,52 @@ def _invoke_xblock_handler(request, course_id, usage_id, handler, suffix, user): return webob_to_django_response(resp) +def hash_resource(resource): + """ + Hash a :class:`xblock.fragment.FragmentResource + """ + md5 = hashlib.md5() + for data in resource: + md5.update(repr(data)) + return md5.hexdigest() + + +def xblock_view(request, course_id, usage_id, view_name): + """ + Returns the rendered view of a given XBlock, with related resources + + Returns a json object containing two keys: + html: The rendered html of the view + resources: A list of tuples where the first element is the resource hash, and + the second is the resource description + """ + if not settings.FEATURES.get('ENABLE_XBLOCK_VIEW_ENDPOINT', False): + log.warn("Attempt to use deactivated XBlock view endpoint -" + " see FEATURES['ENABLE_XBLOCK_VIEW_ENDPOINT']") + raise Http404 + + if not request.user.is_authenticated(): + raise PermissionDenied + + instance, tracking_context = _get_module_by_usage_id(request, course_id, usage_id) + + try: + fragment = instance.render(view_name, context=request.GET) + except NoSuchViewError: + log.exception("Attempt to render missing view on %s: %s", instance, view_name) + raise Http404 + + hashed_resources = OrderedDict() + for resource in fragment.resources: + hashed_resources[hash_resource(resource)] = resource + + return JsonResponse({ + 'html': fragment.content, + 'resources': hashed_resources.items(), + 'csrf_token': str(csrf(request)['csrf_token']), + }) + + def get_score_bucket(grade, max_grade): """ Function to split arbitrary score ranges into 3 buckets. diff --git a/lms/djangoapps/courseware/tests/test_module_render.py b/lms/djangoapps/courseware/tests/test_module_render.py index 2a12a8c3eaf..278a28a108d 100644 --- a/lms/djangoapps/courseware/tests/test_module_render.py +++ b/lms/djangoapps/courseware/tests/test_module_render.py @@ -16,6 +16,7 @@ from django.contrib.auth.models import AnonymousUser from mock import MagicMock, patch, Mock from opaque_keys.edx.keys import UsageKey, CourseKey from opaque_keys.edx.locations import SlashSeparatedCourseKey +from courseware.module_render import hash_resource from xblock.field_data import FieldData from xblock.runtime import Runtime from xblock.fields import ScopeIds @@ -246,6 +247,14 @@ class ModuleRenderTestCase(ModuleStoreTestCase, LoginEnrollmentTestCase): render.get_module_for_descriptor(self.mock_user, request, descriptor, field_data_cache, self.toy_course.id) render.get_module_for_descriptor(self.mock_user, request, descriptor, field_data_cache, self.toy_course.id) + def test_hash_resource(self): + """ + Ensure that the resource hasher works and does not fail on unicode, + decoded or otherwise. + """ + resources = ['ASCII text', u'â„ I am a special snowflake.', "â„ So am I, but I didn't tell you."] + self.assertEqual(hash_resource(resources), 'a76e27c8e80ca3efd7ce743093aa59e0') + class TestHandleXBlockCallback(ModuleStoreTestCase, LoginEnrollmentTestCase): """ @@ -316,7 +325,7 @@ class TestHandleXBlockCallback(ModuleStoreTestCase, LoginEnrollmentTestCase): json.dumps({ 'success': 'Submission aborted! Maximum %d files may be submitted at once' % settings.MAX_FILEUPLOADS_PER_INPUT - }) + }, indent=2) ) def test_too_large_file(self): @@ -336,7 +345,7 @@ class TestHandleXBlockCallback(ModuleStoreTestCase, LoginEnrollmentTestCase): json.dumps({ 'success': 'Submission aborted! Your file "%s" is too large (max size: %d MB)' % (inputfile.name, settings.STUDENT_FILEUPLOAD_MAX_SIZE / (1000 ** 2)) - }) + }, indent=2) ) def test_xmodule_dispatch(self): @@ -399,6 +408,29 @@ class TestHandleXBlockCallback(ModuleStoreTestCase, LoginEnrollmentTestCase): 'bad_dispatch', ) + @patch.dict('django.conf.settings.FEATURES', {'ENABLE_XBLOCK_VIEW_ENDPOINT': True}) + def test_xblock_view_handler(self): + args = [ + 'edX/toy/2012_Fall', + quote_slashes('i4x://edX/toy/videosequence/Toy_Videos'), + 'student_view' + ] + xblock_view_url = reverse( + 'xblock_view', + args=args + ) + + request = self.request_factory.get(xblock_view_url) + request.user = self.mock_user + response = render.xblock_view(request, *args) + self.assertEquals(200, response.status_code) + + expected = ['csrf_token', 'html', 'resources'] + content = json.loads(response.content) + for section in expected: + self.assertIn(section, content) + self.assertIn('<div class="xblock xblock-student_view xmodule_display', content['html']) + @ddt.ddt class TestTOC(ModuleStoreTestCase): diff --git a/lms/djangoapps/lms_xblock/runtime.py b/lms/djangoapps/lms_xblock/runtime.py index 7b5c3fe7b95..af0c32f14c5 100644 --- a/lms/djangoapps/lms_xblock/runtime.py +++ b/lms/djangoapps/lms_xblock/runtime.py @@ -5,6 +5,7 @@ Module implementing `xblock.runtime.Runtime` functionality for the LMS import re import xblock.reference.plugins +from django.conf import settings from django.core.urlresolvers import reverse from django.conf import settings from lms.djangoapps.lms_xblock.models import XBlockAsidesConfig @@ -118,10 +119,11 @@ class LmsHandlerUrls(object): """ local_resource_url for Studio """ - return reverse('xblock_resource_url', kwargs={ + path = reverse('xblock_resource_url', kwargs={ 'block_type': block.scope_ids.block_type, 'uri': uri, }) + return '//{}{}'.format(settings.SITE_NAME, path) class LmsPartitionService(PartitionService): diff --git a/lms/envs/bok_choy.env.json b/lms/envs/bok_choy.env.json index 7f60988d85c..89223675c75 100644 --- a/lms/envs/bok_choy.env.json +++ b/lms/envs/bok_choy.env.json @@ -104,7 +104,7 @@ "SEGMENT_IO_LMS": true, "SERVER_EMAIL": "devops@example.com", "SESSION_COOKIE_DOMAIN": null, - "SITE_NAME": "localhost", + "SITE_NAME": "localhost:8003", "STATIC_ROOT_BASE": "** OVERRIDDEN **", "STATIC_URL_BASE": "/static/", "SYSLOG_SERVER": "", diff --git a/lms/envs/common.py b/lms/envs/common.py index 64971438ffc..9347fcc670e 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -133,6 +133,13 @@ FEATURES = { # Toggles OAuth2 authentication provider 'ENABLE_OAUTH2_PROVIDER': False, + # Allows to enable an API endpoint to serve XBlock view, used for example by external applications. + # See jquey-xblock: https://github.com/edx-solutions/jquery-xblock + 'ENABLE_XBLOCK_VIEW_ENDPOINT': False, + + # Allows to configure the LMS to provide CORS headers to serve requests from other domains + 'ENABLE_CORS_HEADERS': False, + # Can be turned off if course lists need to be hidden. Effects views and templates. 'COURSES_ARE_BROWSABLE': True, diff --git a/lms/urls.py b/lms/urls.py index a3b604d756d..37f197aed89 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -237,6 +237,11 @@ if settings.COURSEWARE_ENABLED: url(r'^courses/{course_key}/xblock/{usage_key}/handler/(?P<handler>[^/]*)(?:/(?P<suffix>.*))?$'.format(course_key=settings.COURSE_ID_PATTERN, usage_key=settings.USAGE_ID_PATTERN), 'courseware.module_render.handle_xblock_callback', name='xblock_handler'), + url(r'^courses/{course_key}/xblock/{usage_key}/view/(?P<view_name>[^/]*)$'.format( + course_key=settings.COURSE_ID_PATTERN, + usage_key=settings.USAGE_ID_PATTERN), + 'courseware.module_render.xblock_view', + name='xblock_view'), url(r'^courses/{course_key}/xblock/{usage_key}/handler_noauth/(?P<handler>[^/]*)(?:/(?P<suffix>.*))?$'.format(course_key=settings.COURSE_ID_PATTERN, usage_key=settings.USAGE_ID_PATTERN), 'courseware.module_render.handle_xblock_callback_noauth', name='xblock_handler_noauth'), -- GitLab