diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index 6fe7f2b276..433086e2cb 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 2a12a8c3ea..278a28a108 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('
[^/]*)(?:/(?P.*))?$'.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[^/]*)$'.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[^/]*)(?:/(?P.*))?$'.format(course_key=settings.COURSE_ID_PATTERN, usage_key=settings.USAGE_ID_PATTERN), 'courseware.module_render.handle_xblock_callback_noauth', name='xblock_handler_noauth'),