diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index d25ffe0376..d28a3ab3de 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -1,7 +1,8 @@ #pylint: disable=E1101 -import shutil +import json import mock +import shutil from textwrap import dedent @@ -503,7 +504,9 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): This verifies that a video caption url is as we expect it to be """ resp = self._test_preview(Location('i4x', 'edX', 'toy', 'video', 'sample_video', None)) - self.assertContains(resp, 'data-caption-asset-path="/c4x/edX/toy/asset/subs_"') + self.assertEquals(resp.status_code, 200) + content = json.loads(resp.content) + self.assertIn('data-caption-asset-path="/c4x/edX/toy/asset/subs_"', content['html']) def _test_preview(self, location): """ Preview test case. """ @@ -514,7 +517,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): locator = loc_mapper().translate_location( course_items[0].location.course_id, location, True, True ) - resp = self.client.get_html(locator.url_reverse('xblock')) + resp = self.client.get_fragment(locator.url_reverse('xblock')) self.assertEqual(resp.status_code, 200) # TODO: uncomment when preview no longer has locations being returned. # _test_no_locations(self, resp) diff --git a/cms/djangoapps/contentstore/tests/utils.py b/cms/djangoapps/contentstore/tests/utils.py index 19d1f174d2..39acda3d8a 100644 --- a/cms/djangoapps/contentstore/tests/utils.py +++ b/cms/djangoapps/contentstore/tests/utils.py @@ -57,6 +57,13 @@ class AjaxEnabledTestClient(Client): """ return self.get(path, data or {}, follow, HTTP_ACCEPT="application/json", **extra) + def get_fragment(self, path, data=None, follow=False, **extra): + """ + Convenience method for client.get which sets the accept type to application/x-fragment+json + """ + return self.get(path, data or {}, follow, HTTP_ACCEPT="application/x-fragment+json", **extra) + + @override_settings(MODULESTORE=TEST_MODULESTORE) class CourseTestCase(ModuleStoreTestCase): diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py index 3d8f81a6ef..8d80d9e0bb 100644 --- a/cms/djangoapps/contentstore/views/item.py +++ b/cms/djangoapps/contentstore/views/item.py @@ -1,26 +1,31 @@ """Views for items (modules).""" +import hashlib import logging from uuid import uuid4 +from collections import OrderedDict from functools import partial from static_replace import replace_static_urls from xmodule_modifiers import wrap_xblock +from django.conf import settings from django.core.exceptions import PermissionDenied from django.contrib.auth.decorators import login_required -from django.http import HttpResponseBadRequest +from django.http import HttpResponseBadRequest, HttpResponse +from django.utils.translation import ugettext as _ from django.views.decorators.http import require_http_methods from xblock.fields import Scope +from xblock.fragment import Fragment from xblock.core import XBlock +import xmodule.x_module from xmodule.modulestore.django import modulestore, loc_mapper from xmodule.modulestore.exceptions import ItemNotFoundError, InvalidLocationError from xmodule.modulestore.inheritance import own_metadata from xmodule.modulestore.locator import BlockUsageLocator from xmodule.modulestore import Location -from xmodule.x_module import prefer_xmodules from util.json_request import expect_json, JsonResponse from util.string_utils import str_to_bool @@ -31,10 +36,10 @@ from ..utils import get_modulestore from .access import has_course_access from .helpers import _xmodule_recurse -from preview import handler_prefix, get_preview_html -from edxmako.shortcuts import render_to_response, render_to_string +from contentstore.views.preview import get_preview_fragment +from edxmako.shortcuts import render_to_string from models.settings.course_grading import CourseGradingModel -from django.utils.translation import ugettext as _ +from cms.lib.xblock.runtime import handler_url __all__ = ['orphan_handler', 'xblock_handler'] @@ -43,6 +48,22 @@ log = logging.getLogger(__name__) CREATE_IF_NOT_FOUND = ['course_info'] +# In order to allow descriptors to use a handler url, we need to +# monkey-patch the x_module library. +# TODO: Remove this code when Runtimes are no longer created by modulestores +xmodule.x_module.descriptor_global_handler_url = handler_url + + +def hash_resource(resource): + """ + Hash a :class:`xblock.fragment.FragmentResource + """ + md5 = hashlib.md5() + for data in resource: + md5.update(data) + return md5.hexdigest() + + # pylint: disable=unused-argument @require_http_methods(("DELETE", "GET", "PUT", "POST")) @login_required @@ -88,7 +109,42 @@ def xblock_handler(request, tag=None, package_id=None, branch=None, version_guid old_location = loc_mapper().translate_locator_to_location(locator) if request.method == 'GET': - if 'application/json' in request.META.get('HTTP_ACCEPT', 'application/json'): + accept_header = request.META.get('HTTP_ACCEPT', 'application/json') + + if 'application/x-fragment+json' in accept_header: + component = modulestore().get_item(old_location) + + # Wrap the generated fragment in the xmodule_editor div so that the javascript + # can bind to it correctly + component.runtime.wrappers.append(partial(wrap_xblock, 'StudioRuntime')) + + try: + editor_fragment = component.render('studio_view') + # catch exceptions indiscriminately, since after this point they escape the + # dungeon and surface as uneditable, unsaveable, and undeletable + # component-goblins. + except Exception as exc: # pylint: disable=W0703 + log.debug("Unable to render studio_view for %r", component, exc_info=True) + editor_fragment = Fragment(render_to_string('html_error.html', {'message': str(exc)})) + + modulestore().save_xmodule(component) + + preview_fragment = get_preview_fragment(request, component) + + hashed_resources = OrderedDict() + for resource in editor_fragment.resources + preview_fragment.resources: + hashed_resources[hash_resource(resource)] = resource + + return JsonResponse({ + 'html': render_to_string('component.html', { + 'preview': preview_fragment.content, + 'editor': editor_fragment.content, + 'label': component.display_name or component.scope_ids.block_type, + }), + 'resources': hashed_resources.items() + }) + + elif 'application/json' in accept_header: fields = request.REQUEST.get('fields', '').split(',') if 'graderType' in fields: # right now can't combine output of this w/ output of _get_module_info, but worthy goal @@ -97,25 +153,8 @@ def xblock_handler(request, tag=None, package_id=None, branch=None, version_guid rsp = _get_module_info(locator) return JsonResponse(rsp) else: - component = modulestore().get_item(old_location) - # Wrap the generated fragment in the xmodule_editor div so that the javascript - # can bind to it correctly - component.runtime.wrappers.append(partial(wrap_xblock, handler_prefix)) + return HttpResponse(status=406) - try: - content = component.render('studio_view').content - # catch exceptions indiscriminately, since after this point they escape the - # dungeon and surface as uneditable, unsaveable, and undeletable - # component-goblins. - except Exception as exc: # pylint: disable=W0703 - log.debug("Unable to render studio_view for %r", component, exc_info=True) - content = render_to_string('html_error.html', {'message': str(exc)}) - - return render_to_response('component.html', { - 'preview': get_preview_html(request, component), - 'editor': content, - 'label': component.display_name or component.category, - }) elif request.method == 'DELETE': delete_children = str_to_bool(request.REQUEST.get('recurse', 'False')) delete_all_versions = str_to_bool(request.REQUEST.get('all_versions', 'False')) @@ -281,7 +320,7 @@ def _create_item(request): data = None template_id = request.json.get('boilerplate') if template_id is not None: - clz = XBlock.load_class(category, select=prefer_xmodules) + clz = parent.runtime.load_block_type(category) if clz is not None: template = clz.get_template(template_id) if template is not None: diff --git a/cms/djangoapps/contentstore/views/preview.py b/cms/djangoapps/contentstore/views/preview.py index 11bea40f9f..53de39dcf2 100644 --- a/cms/djangoapps/contentstore/views/preview.py +++ b/cms/djangoapps/contentstore/views/preview.py @@ -1,11 +1,12 @@ import logging +import hashlib from functools import partial from django.conf import settings from django.core.urlresolvers import reverse from django.http import Http404, HttpResponseBadRequest from django.contrib.auth.decorators import login_required -from edxmako.shortcuts import render_to_response, render_to_string +from edxmako.shortcuts import render_to_string from xmodule_modifiers import replace_static_urls, wrap_xblock from xmodule.error_module import ErrorDescriptor @@ -15,6 +16,7 @@ from xmodule.x_module import ModuleSystem from xblock.runtime import KvsFieldData from xblock.django.request import webob_to_django_response, django_to_webob_request from xblock.exceptions import NoSuchHandlerError +from xblock.fragment import Fragment from lms.lib.xblock.field_data import LmsFieldData from lms.lib.xblock.runtime import quote_slashes, unquote_slashes @@ -33,20 +35,6 @@ __all__ = ['preview_handler'] log = logging.getLogger(__name__) -def handler_prefix(block, handler='', suffix=''): - """ - Return a url prefix for XBlock handler_url. The full handler_url - should be '{prefix}/{handler}/{suffix}?{query}'. - - Trailing `/`s are removed from the returned url. - """ - return reverse('preview_handler', kwargs={ - 'usage_id': quote_slashes(unicode(block.scope_ids.usage_id).encode('utf-8')), - 'handler': handler, - 'suffix': suffix, - }).rstrip('/?') - - @login_required def preview_handler(request, usage_id, handler, suffix=''): """ @@ -91,7 +79,11 @@ class PreviewModuleSystem(ModuleSystem): # pylint: disable=abstract-method An XModule ModuleSystem for use in Studio previews """ def handler_url(self, block, handler_name, suffix='', query='', thirdparty=False): - return handler_prefix(block, handler_name, suffix) + '?' + query + return reverse('preview_handler', kwargs={ + 'usage_id': quote_slashes(unicode(block.scope_ids.usage_id).encode('utf-8')), + 'handler': handler_name, + 'suffix': suffix, + }) + '?' + query def _preview_module_system(request, descriptor): @@ -123,7 +115,7 @@ def _preview_module_system(request, descriptor): # Set up functions to modify the fragment produced by student_view wrappers=( # This wrapper wraps the module in the template specified above - partial(wrap_xblock, handler_prefix, display_name_only=descriptor.location.category == 'static_tab'), + partial(wrap_xblock, 'PreviewRuntime', display_name_only=descriptor.location.category == 'static_tab'), # This wrapper replaces urls in the output that start with /static # with the correct course-specific url for the static content @@ -153,15 +145,15 @@ def _load_preview_module(request, descriptor): return descriptor -def get_preview_html(request, descriptor): +def get_preview_fragment(request, descriptor): """ Returns the HTML returned by the XModule's student_view, specified by the descriptor and idx. """ module = _load_preview_module(request, descriptor) try: - content = module.render("student_view").content + fragment = module.render("student_view") except Exception as exc: # pylint: disable=W0703 log.debug("Unable to render student_view for %r", module, exc_info=True) - content = render_to_string('html_error.html', {'message': str(exc)}) - return content + fragment = Fragment(render_to_string('html_error.html', {'message': str(exc)})) + return fragment diff --git a/cms/lib/xblock/runtime.py b/cms/lib/xblock/runtime.py index 5c749a34c6..e0e2491028 100644 --- a/cms/lib/xblock/runtime.py +++ b/cms/lib/xblock/runtime.py @@ -4,7 +4,6 @@ XBlock runtime implementations for edX Studio from django.core.urlresolvers import reverse -import xmodule.x_module from lms.lib.xblock.runtime import quote_slashes @@ -17,7 +16,7 @@ def handler_url(block, handler_name, suffix='', query='', thirdparty=False): raise NotImplementedError("edX Studio doesn't support third-party xblock handler urls") url = reverse('component_handler', kwargs={ - 'usage_id': quote_slashes(str(block.scope_ids.usage_id)), + 'usage_id': quote_slashes(unicode(block.scope_ids.usage_id).encode('utf-8')), 'handler': handler_name, 'suffix': suffix, }).rstrip('/') @@ -27,4 +26,3 @@ def handler_url(block, handler_name, suffix='', query='', thirdparty=False): return url -xmodule.x_module.descriptor_global_handler_url = handler_url diff --git a/cms/static/coffee/spec/main.coffee b/cms/static/coffee/spec/main.coffee index 2cbc85d7c1..705ff08238 100644 --- a/cms/static/coffee/spec/main.coffee +++ b/cms/static/coffee/spec/main.coffee @@ -28,6 +28,7 @@ requirejs.config({ "tinymce": "xmodule_js/common_static/js/vendor/tiny_mce/tiny_mce", "jquery.tinymce": "xmodule_js/common_static/js/vendor/tiny_mce/jquery.tinymce", "xmodule": "xmodule_js/src/xmodule", + "xblock/cms.runtime.v1": "coffee/src/xblock/cms.runtime.v1", "xblock": "xmodule_js/common_static/coffee/src/xblock", "utility": "xmodule_js/common_static/js/src/utility", "accessibility": "xmodule_js/common_static/js/src/accessibility_tools", diff --git a/cms/static/coffee/spec/main_squire.coffee b/cms/static/coffee/spec/main_squire.coffee index a8e052fd2e..bc19272283 100644 --- a/cms/static/coffee/spec/main_squire.coffee +++ b/cms/static/coffee/spec/main_squire.coffee @@ -27,6 +27,7 @@ requirejs.config({ "tinymce": "xmodule_js/common_static/js/vendor/tiny_mce/tiny_mce", "jquery.tinymce": "xmodule_js/common_static/js/vendor/tiny_mce/jquery.tinymce", "xmodule": "xmodule_js/src/xmodule", + "xblock/cms.runtime.v1": "coffee/src/xblock/cms.runtime.v1", "xblock": "xmodule_js/common_static/coffee/src/xblock", "utility": "xmodule_js/common_static/js/src/utility", "sinon": "xmodule_js/common_static/js/vendor/sinon-1.7.1", diff --git a/cms/static/coffee/spec/views/module_edit_spec.coffee b/cms/static/coffee/spec/views/module_edit_spec.coffee index 36716668d3..e9e3db2a29 100644 --- a/cms/static/coffee/spec/views/module_edit_spec.coffee +++ b/cms/static/coffee/spec/views/module_edit_spec.coffee @@ -1,4 +1,4 @@ -define ["coffee/src/views/module_edit", "js/models/module_info", "xmodule"], (ModuleEdit, ModuleModel) -> +define ["jquery", "coffee/src/views/module_edit", "js/models/module_info", "xmodule"], ($, ModuleEdit, ModuleModel) -> describe "ModuleEdit", -> beforeEach -> @@ -24,7 +24,7 @@ define ["coffee/src/views/module_edit", "js/models/module_info", "xmodule"], (Mo """ - spyOn($.fn, 'load').andReturn(@moduleData) + spyOn($, 'ajax').andReturn(@moduleData) @moduleEdit = new ModuleEdit( el: $(".component") @@ -56,14 +56,63 @@ define ["coffee/src/views/module_edit", "js/models/module_info", "xmodule"], (Mo beforeEach -> spyOn(@moduleEdit, 'loadDisplay') spyOn(@moduleEdit, 'delegateEvents') + spyOn($.fn, 'append') + spyOn($, 'getScript') + + window.loadedXBlockResources = undefined + @moduleEdit.render() + $.ajax.mostRecentCall.args[0].success( + html: '