diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index 6fe7f2b276ccffa634098ce2ec75a4a4b2aeb3e1..433086e2cba281389656edd7eda7bd0f90db9d23 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 2a12a8c3eafe342bc8b226c7c9b272400691d654..278a28a108d05819bf257c5f73b053721a4a0d67 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 7b5c3fe7b95e995ef45dfaca2b1ddacfa8872a61..af0c32f14c5d815e7b7bef71185940f76726d4be 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 7f60988d85cd8f2829851a3e54c0dc1c02d21a87..89223675c7501c3d9341dcb65e0133efaab4013f 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 64971438ffc68eefbc60b68132ae0b1d0b233040..9347fcc670ef4bd6144030ca6054824c3f5fe4ca 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 a3b604d756da4db0f5c797e978e1132d5818acad..37f197aed898f178dbdbb6fa4a7ebcefdef864bf 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'),