Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enable XBlock js and css in Studio #1949

Merged
merged 3 commits into from
Feb 5, 2014
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions cms/djangoapps/contentstore/tests/test_contentstore.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
#pylint: disable=E1101

import shutil
import json
import mock
import shutil

from textwrap import dedent

Expand Down Expand Up @@ -503,7 +504,9 @@ def test_video_module_caption_asset_path(self):
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. """
Expand All @@ -514,7 +517,7 @@ def _test_preview(self, location):
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)
Expand Down
7 changes: 7 additions & 0 deletions cms/djangoapps/contentstore/tests/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,13 @@ def get_json(self, path, data=None, follow=False, **extra):
"""
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):
Expand Down
83 changes: 61 additions & 22 deletions cms/djangoapps/contentstore/views/item.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -31,10 +36,10 @@

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']

Expand All @@ -43,6 +48,22 @@
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
Expand Down Expand Up @@ -88,34 +109,52 @@ 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'):
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
return JsonResponse(CourseGradingModel.get_section_grader_type(locator))
# TODO: pass fields to _get_module_info and only return those
rsp = _get_module_info(locator)
return JsonResponse(rsp)
else:
accept_header = request.META.get('HTTP_ACCEPT', 'application/json')

if 'application/x-fragment+json' in accept_header:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@cpennington: @singingwolfboy and I were wondering why you introduced a custom MIME type rather than just using application/json. It seems reasonable, but we weren't sure when this was a good practice versus just using application/json. Should we be doing this in some/all of our other RESTful services?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because there's other uses of application/json in this same method, and I didn't want to break those. The application/json representation is just a dump of the field values, rather than the rendered fragment.

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))
component.runtime.wrappers.append(partial(wrap_xblock, 'StudioRuntime'))

try:
content = component.render('studio_view').content
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)
content = render_to_string('html_error.html', {'message': str(exc)})
editor_fragment = Fragment(render_to_string('html_error.html', {'message': str(exc)}))

modulestore().save_xmodule(component)

preview_fragment = get_preview_fragment(request, component)

return render_to_response('component.html', {
'preview': get_preview_html(request, component),
'editor': content,
'label': component.display_name or component.category,
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
return JsonResponse(CourseGradingModel.get_section_grader_type(locator))
# TODO: pass fields to _get_module_info and only return those
rsp = _get_module_info(locator)
return JsonResponse(rsp)
else:
return HttpResponse(status=406)

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'))
Expand Down Expand Up @@ -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:
Expand Down
34 changes: 13 additions & 21 deletions cms/djangoapps/contentstore/views/preview.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -15,6 +16,7 @@
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
Expand All @@ -33,20 +35,6 @@
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=''):
"""
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
4 changes: 1 addition & 3 deletions cms/lib/xblock/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

from django.core.urlresolvers import reverse

import xmodule.x_module
from lms.lib.xblock.runtime import quote_slashes


Expand All @@ -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('/')
Expand All @@ -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
1 change: 1 addition & 0 deletions cms/static/coffee/spec/main.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions cms/static/coffee/spec/main_squire.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
57 changes: 53 additions & 4 deletions cms/static/coffee/spec/views/module_edit_spec.coffee
Original file line number Diff line number Diff line change
@@ -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 ->
Expand All @@ -24,7 +24,7 @@ define ["coffee/src/views/module_edit", "js/models/module_info", "xmodule"], (Mo
</section>
</li>
"""
spyOn($.fn, 'load').andReturn(@moduleData)
spyOn($, 'ajax').andReturn(@moduleData)

@moduleEdit = new ModuleEdit(
el: $(".component")
Expand Down Expand Up @@ -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: '<div>Response html</div>'
resources: [
['hash1', {kind: 'text', mimetype: 'text/css', data: 'inline-css'}],
['hash2', {kind: 'url', mimetype: 'text/css', data: 'css-url'}],
['hash3', {kind: 'text', mimetype: 'application/javascript', data: 'inline-js'}],
['hash4', {kind: 'url', mimetype: 'application/javascript', data: 'js-url'}],
['hash5', {placement: 'head', mimetype: 'text/html', data: 'head-html'}],
['hash6', {placement: 'not-head', mimetype: 'text/html', data: 'not-head-html'}],
]
)

it "loads the module preview and editor via ajax on the view element", ->
expect(@moduleEdit.$el.load).toHaveBeenCalledWith("/xblock/#{@moduleEdit.model.id}", jasmine.any(Function))
@moduleEdit.$el.load.mostRecentCall.args[1]()
expect($.ajax).toHaveBeenCalledWith(
url: "/xblock/#{@moduleEdit.model.id}"
type: "GET"
headers:
Accept: 'application/x-fragment+json'
success: jasmine.any(Function)
)
expect(@moduleEdit.loadDisplay).toHaveBeenCalled()
expect(@moduleEdit.delegateEvents).toHaveBeenCalled()

it "loads inline css from fragments", ->
expect($('head').append).toHaveBeenCalledWith("<style type='text/css'>inline-css</style>")

it "loads css urls from fragments", ->
expect($('head').append).toHaveBeenCalledWith("<link rel='stylesheet' href='css-url' type='text/css'>")

it "loads inline js from fragments", ->
expect($('head').append).toHaveBeenCalledWith("<script>inline-js</script>")

it "loads js urls from fragments", ->
expect($.getScript).toHaveBeenCalledWith("js-url")

it "loads head html", ->
expect($('head').append).toHaveBeenCalledWith("head-html")

it "doesn't load body html", ->
expect($.fn.append).not.toHaveBeenCalledWith('not-head-html')

it "doesn't reload resources", ->
count = $('head').append.callCount
$.ajax.mostRecentCall.args[0].success(
html: '<div>Response html 2</div>'
resources: [
['hash1', {kind: 'text', mimetype: 'text/css', data: 'inline-css'}],
]
)
expect($('head').append.callCount).toBe(count)

describe "loadDisplay", ->
beforeEach ->
spyOn(XBlock, 'initializeBlock')
Expand Down
Loading