Merge pull request #24385 from open-craft/mavidser/source-from-library-xblock
[BD-14]"Source from Library" XBlock
This commit is contained in:
@@ -67,6 +67,7 @@ from util.milestones_helpers import is_entrance_exams_enabled
|
||||
from xblock_config.models import CourseEditLTIFieldsEnabledFlag
|
||||
from xblock_django.user_service import DjangoXBlockUserService
|
||||
from xmodule.course_module import DEFAULT_START_DATE
|
||||
from xmodule.library_tools import LibraryToolsService
|
||||
from xmodule.modulestore import EdxJSONEncoder, ModuleStoreEnum
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.draft_and_published import DIRECT_ONLY_CATEGORIES
|
||||
@@ -319,6 +320,8 @@ class StudioEditModuleRuntime(object):
|
||||
return ConfigurationService(CourseEditLTIFieldsEnabledFlag)
|
||||
if service_name == "teams_configuration":
|
||||
return TeamsConfigurationService()
|
||||
if service_name == "library_tools":
|
||||
return LibraryToolsService(modulestore(), self._user.id)
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ XBLOCKS = [
|
||||
"course_info = xmodule.html_module:CourseInfoBlock",
|
||||
"html = xmodule.html_module:HtmlBlock",
|
||||
"library = xmodule.library_root_xblock:LibraryRoot",
|
||||
"library_sourced = xmodule.library_sourced_block:LibrarySourcedBlock",
|
||||
"problem = xmodule.capa_module:ProblemBlock",
|
||||
"static_tab = xmodule.html_module:StaticTabBlock",
|
||||
"unit = xmodule.unit_block:UnitBlock",
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/* JavaScript for allowing editing options on LibrarySourceBlock's author view */
|
||||
window.LibrarySourceBlockAuthorView = function(runtime, element) {
|
||||
'use strict';
|
||||
var $element = $(element);
|
||||
|
||||
$element.on('click', '.save-btn', function(e) {
|
||||
var url = $(e.target).data('submit-url');
|
||||
var data = {
|
||||
values: {
|
||||
source_block_id: $element.find('input').val()
|
||||
},
|
||||
defaults: ['display_name']
|
||||
};
|
||||
e.preventDefault();
|
||||
|
||||
runtime.notify('save', {
|
||||
state: 'start',
|
||||
message: gettext('Saving'),
|
||||
element: element
|
||||
});
|
||||
$.ajax({
|
||||
type: 'POST',
|
||||
url: url,
|
||||
data: JSON.stringify(data),
|
||||
global: false // Disable error handling that conflicts with studio's notify('save') and notify('cancel')
|
||||
}).done(function() {
|
||||
runtime.notify('save', {
|
||||
state: 'end',
|
||||
element: element
|
||||
});
|
||||
}).fail(function(jqXHR) {
|
||||
var message = gettext('This may be happening because of an error with our server or your internet connection. Try refreshing the page or making sure you are online.'); // eslint-disable-line max-len
|
||||
if (jqXHR.responseText) { // Is there a more specific error message we can show?
|
||||
try {
|
||||
message = JSON.parse(jqXHR.responseText).error;
|
||||
if (typeof message === 'object' && message.messages) {
|
||||
// e.g. {"error": {"messages": [{"text": "Unknown user 'bob'!", "type": "error"}, ...]}} etc.
|
||||
message = $.map(message.messages, function(msg) { return msg.text; }).join(', ');
|
||||
}
|
||||
} catch (error) { message = jqXHR.responseText.substr(0, 300); }
|
||||
}
|
||||
runtime.notify('error', {title: gettext('Unable to update settings'), message: message});
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -434,10 +434,9 @@ class LibraryContentDescriptor(LibraryContentFields, MakoModuleDescriptor, XmlDe
|
||||
this block is up to date or not.
|
||||
"""
|
||||
user_perms = self.runtime.service(self, 'studio_user_permissions')
|
||||
user_id = self.get_user_id()
|
||||
if not self.tools:
|
||||
return Response("Library Tools unavailable in current runtime.", status=400)
|
||||
self.tools.update_children(self, user_id, user_perms)
|
||||
self.tools.update_children(self, user_perms)
|
||||
return Response()
|
||||
|
||||
# Copy over any overridden settings the course author may have applied to the blocks.
|
||||
@@ -469,7 +468,7 @@ class LibraryContentDescriptor(LibraryContentFields, MakoModuleDescriptor, XmlDe
|
||||
user_perms = self.runtime.service(self, 'studio_user_permissions')
|
||||
if not self.tools:
|
||||
raise RuntimeError("Library tools unavailable, duplication will not be sane!")
|
||||
self.tools.update_children(self, user_id, user_perms, version=self.source_library_version)
|
||||
self.tools.update_children(self, user_perms, version=self.source_library_version)
|
||||
|
||||
self._copy_overrides(store, user_id, source_block, self)
|
||||
|
||||
|
||||
127
common/lib/xmodule/xmodule/library_sourced_block.py
Normal file
127
common/lib/xmodule/xmodule/library_sourced_block.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""
|
||||
Library Sourced Content XBlock
|
||||
"""
|
||||
import logging
|
||||
|
||||
from copy import copy
|
||||
from web_fragments.fragment import Fragment
|
||||
from xblock.core import XBlock
|
||||
from xblock.fields import Scope, String
|
||||
from xblockutils.resources import ResourceLoader
|
||||
from xblockutils.studio_editable import StudioEditableXBlockMixin
|
||||
from webob import Response
|
||||
|
||||
from cms.lib.xblock.runtime import handler_url
|
||||
from xmodule.studio_editable import StudioEditableBlock as EditableChildrenMixin
|
||||
from xmodule.validation import StudioValidation, StudioValidationMessage
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
loader = ResourceLoader(__name__)
|
||||
|
||||
# Make '_' a no-op so we can scrape strings. Using lambda instead of
|
||||
# `django.utils.translation.ugettext_noop` because Django cannot be imported in this file
|
||||
_ = lambda text: text
|
||||
|
||||
|
||||
@XBlock.wants('library_tools') # Only needed in studio
|
||||
class LibrarySourcedBlock(StudioEditableXBlockMixin, EditableChildrenMixin, XBlock):
|
||||
"""
|
||||
Library Sourced Content XBlock
|
||||
|
||||
Allows copying specific XBlocks from a Blockstore-based content library into
|
||||
a modulestore-based course. The selected blocks are copied and become
|
||||
children of this block.
|
||||
|
||||
When we implement support for Blockstore-based courses, it's expected we'll
|
||||
use a different mechanism for importing library content into a course.
|
||||
"""
|
||||
display_name = String(
|
||||
help=_("The display name for this component."),
|
||||
default="Library Sourced Content",
|
||||
display_name=_("Display Name"),
|
||||
scope=Scope.content,
|
||||
)
|
||||
source_block_id = String(
|
||||
display_name=_("Library Block"),
|
||||
help=_("Enter the IDs of the library XBlock that you wish to use."),
|
||||
scope=Scope.content,
|
||||
)
|
||||
editable_fields = ("display_name", "source_block_id")
|
||||
has_children = True
|
||||
has_author_view = True
|
||||
resources_dir = 'assets/library_source_block'
|
||||
|
||||
def __str__(self):
|
||||
return "LibrarySourcedBlock: {}".format(self.display_name)
|
||||
|
||||
def author_view(self, context):
|
||||
"""
|
||||
Renders the Studio preview view.
|
||||
"""
|
||||
fragment = Fragment()
|
||||
root_xblock = context.get('root_xblock')
|
||||
is_root = root_xblock and root_xblock.location == self.location # pylint: disable=no-member
|
||||
# If block ID is not defined, ask user for the component ID in the author_view itself.
|
||||
# We don't display the editor if is_root as that page should represent the student_view without any ambiguity
|
||||
if not self.source_block_id and not is_root:
|
||||
fragment.add_content(
|
||||
loader.render_django_template('templates/library-sourced-block-author-view.html', {
|
||||
'save_url': handler_url(self, 'submit_studio_edits')
|
||||
})
|
||||
)
|
||||
fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/library_source_block.js'))
|
||||
fragment.initialize_js('LibrarySourceBlockAuthorView')
|
||||
return fragment
|
||||
|
||||
context = {} if not context else copy(context) # Isolate context - without this there are weird bugs in Studio
|
||||
# EditableChildrenMixin.render_children will render HTML that allows instructors to make edits to the children
|
||||
context['can_move'] = False
|
||||
self.render_children(context, fragment, can_reorder=False, can_add=False)
|
||||
return fragment
|
||||
|
||||
def student_view(self, context):
|
||||
"""
|
||||
Renders the view that learners see.
|
||||
"""
|
||||
result = Fragment()
|
||||
child_frags = self.runtime.render_children(self, context=context)
|
||||
result.add_resources(child_frags)
|
||||
result.add_content('<div class="library-sourced-content">')
|
||||
for frag in child_frags:
|
||||
result.add_content(frag.content)
|
||||
result.add_content('</div>')
|
||||
return result
|
||||
|
||||
def validate(self):
|
||||
"""
|
||||
Validates the state of this library_sourced_xblock Instance. This is the override of the general XBlock method,
|
||||
and it will also ask its superclass to validate.
|
||||
"""
|
||||
validation = super().validate()
|
||||
validation = StudioValidation.copy(validation)
|
||||
|
||||
if not self.source_block_id:
|
||||
validation.set_summary(
|
||||
StudioValidationMessage(
|
||||
StudioValidationMessage.NOT_CONFIGURED,
|
||||
_(u"No XBlock has been configured for this component. Enter the target ID below or in the editor"),
|
||||
action_class='edit-button',
|
||||
action_label=_(u"Open Editor")
|
||||
)
|
||||
)
|
||||
return validation
|
||||
|
||||
@XBlock.handler
|
||||
def submit_studio_edits(self, data, suffix=''):
|
||||
"""
|
||||
Save changes to this block, applying edits made in Studio.
|
||||
"""
|
||||
response = super().submit_studio_edits(data, suffix)
|
||||
# Replace our current children with the latest ones from the libraries.
|
||||
lib_tools = self.runtime.service(self, 'library_tools')
|
||||
try:
|
||||
lib_tools.import_from_blockstore(self, self.source_block_id)
|
||||
except Exception as err: # pylint: disable=broad-except
|
||||
log.exception(err)
|
||||
return Response(_(u"Importing Library Block failed - are the IDs valid and readable?"), status=400)
|
||||
return response
|
||||
@@ -1,12 +1,19 @@
|
||||
"""
|
||||
XBlock runtime services for LibraryContentModule
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
|
||||
import six
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from opaque_keys.edx.locator import LibraryLocator, LibraryUsageLocator
|
||||
from opaque_keys.edx.keys import UsageKey
|
||||
from opaque_keys.edx.locator import LibraryLocator, LibraryUsageLocator, LibraryUsageLocatorV2, BlockUsageLocator
|
||||
from search.search_engine_base import SearchEngine
|
||||
from xblock.fields import Scope
|
||||
|
||||
from openedx.core.djangoapps.content_libraries import api as library_api
|
||||
from openedx.core.djangoapps.xblock.api import load_block
|
||||
from student.auth import has_studio_write_access
|
||||
from xmodule.capa_module import ProblemBlock
|
||||
from xmodule.library_content_module import ANY_CAPA_TYPE_VALUE
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
@@ -23,8 +30,9 @@ class LibraryToolsService(object):
|
||||
Service that allows LibraryContentModule to interact with libraries in the
|
||||
modulestore.
|
||||
"""
|
||||
def __init__(self, modulestore):
|
||||
def __init__(self, modulestore, user_id):
|
||||
self.store = modulestore
|
||||
self.user_id = user_id
|
||||
|
||||
def _get_library(self, library_key):
|
||||
"""
|
||||
@@ -127,7 +135,7 @@ class LibraryToolsService(object):
|
||||
"""
|
||||
return self.store.check_supports(block.location.course_key, 'copy_from_template')
|
||||
|
||||
def update_children(self, dest_block, user_id, user_perms=None, version=None):
|
||||
def update_children(self, dest_block, user_perms=None, version=None):
|
||||
"""
|
||||
This method is to be used when the library that a LibraryContentModule
|
||||
references has been updated. It will re-fetch all matching blocks from
|
||||
@@ -163,11 +171,11 @@ class LibraryToolsService(object):
|
||||
source_blocks.extend(library.children)
|
||||
|
||||
with self.store.bulk_operations(dest_block.location.course_key):
|
||||
dest_block.source_library_version = six.text_type(library.location.library_key.version_guid)
|
||||
self.store.update_item(dest_block, user_id)
|
||||
dest_block.source_library_version = str(library.location.library_key.version_guid)
|
||||
self.store.update_item(dest_block, self.user_id)
|
||||
head_validation = not version
|
||||
dest_block.children = self.store.copy_from_template(
|
||||
source_blocks, dest_block.location, user_id, head_validation=head_validation
|
||||
source_blocks, dest_block.location, self.user_id, head_validation=head_validation
|
||||
)
|
||||
# ^-- copy_from_template updates the children in the DB
|
||||
# but we must also set .children here to avoid overwriting the DB again
|
||||
@@ -181,3 +189,101 @@ class LibraryToolsService(object):
|
||||
(lib.location.library_key.replace(version_guid=None, branch=None), lib.display_name)
|
||||
for lib in self.store.get_library_summaries()
|
||||
]
|
||||
|
||||
def import_from_blockstore(self, dest_block, blockstore_block_id):
|
||||
"""
|
||||
Imports a block from a blockstore-based learning context (usually a
|
||||
content library) into modulestore, as a new child of dest_block.
|
||||
Any existing children of dest_block are replaced.
|
||||
|
||||
This is only used by LibrarySourcedBlock. It should verify first that
|
||||
the number of block IDs is reasonable.
|
||||
"""
|
||||
dest_key = dest_block.scope_ids.usage_id
|
||||
if not isinstance(dest_key, BlockUsageLocator):
|
||||
raise TypeError("Destination {} should be a modulestore course.".format(dest_key))
|
||||
if self.user_id is None:
|
||||
raise ValueError("Cannot check user permissions - LibraryTools user_id is None")
|
||||
|
||||
dest_course_key = dest_key.context_key
|
||||
user = User.objects.get(id=self.user_id)
|
||||
if not has_studio_write_access(user, dest_course_key):
|
||||
raise PermissionDenied()
|
||||
|
||||
# Read the source block; this will also confirm that user has permission to read it.
|
||||
orig_block = load_block(UsageKey.from_string(blockstore_block_id), user)
|
||||
|
||||
with self.store.bulk_operations(dest_course_key):
|
||||
new_block_id = self._import_block(orig_block, dest_key)
|
||||
# Remove any existing children that are no longer used
|
||||
for old_child_id in set(dest_block.children) - set([new_block_id]):
|
||||
self.store.delete_item(old_child_id, self.user_id)
|
||||
# If this was called from a handler, it will save dest_block at the end, so we must update
|
||||
# dest_block.children to avoid it saving the old value of children and deleting the new ones.
|
||||
dest_block.children = self.store.get_item(dest_key).children
|
||||
|
||||
def _import_block(self, source_block, dest_parent_key):
|
||||
"""
|
||||
Recursively import a blockstore block and its children. See import_from_blockstore above.
|
||||
"""
|
||||
def generate_block_key(source_key, dest_parent_key):
|
||||
"""
|
||||
Deterministically generate an ID for the new block and return the key
|
||||
"""
|
||||
block_id = (
|
||||
dest_parent_key.block_id[:10] +
|
||||
'-' +
|
||||
hashlib.sha1(str(source_key).encode('utf-8')).hexdigest()[:10]
|
||||
)
|
||||
return dest_parent_key.context_key.make_usage_key(source_key.block_type, block_id)
|
||||
|
||||
source_key = source_block.scope_ids.usage_id
|
||||
new_block_key = generate_block_key(source_key, dest_parent_key)
|
||||
try:
|
||||
new_block = self.store.get_item(new_block_key)
|
||||
if new_block.parent != dest_parent_key:
|
||||
raise ValueError(
|
||||
"Expected existing block {} to be a child of {} but instead it's a child of {}".format(
|
||||
new_block_key, dest_parent_key, new_block.parent,
|
||||
)
|
||||
)
|
||||
except ItemNotFoundError:
|
||||
new_block = self.store.create_child(
|
||||
user_id=self.user_id,
|
||||
parent_usage_key=dest_parent_key,
|
||||
block_type=source_key.block_type,
|
||||
block_id=new_block_key.block_id,
|
||||
)
|
||||
|
||||
# Prepare a list of this block's static assets; any assets that are referenced as /static/{path} (the
|
||||
# recommended way for referencing them) will stop working, and so we rewrite the url when importing.
|
||||
# Copying assets not advised because modulestore doesn't namespace assets to each block like blockstore, which
|
||||
# might cause conflicts when the same filename is used across imported blocks.
|
||||
if isinstance(source_key, LibraryUsageLocatorV2):
|
||||
all_assets = library_api.get_library_block_static_asset_files(source_key)
|
||||
else:
|
||||
all_assets = []
|
||||
|
||||
for field_name, field in source_block.fields.items():
|
||||
if field.scope not in (Scope.settings, Scope.content):
|
||||
continue # Only copy authored field data
|
||||
if field.is_set_on(source_block) or field.is_set_on(new_block):
|
||||
field_value = getattr(source_block, field_name)
|
||||
if isinstance(field_value, str):
|
||||
# If string field (which may also be JSON/XML data), rewrite /static/... URLs to point to blockstore
|
||||
for asset in all_assets:
|
||||
field_value = field_value.replace('/static/{}'.format(asset.path), asset.url)
|
||||
setattr(new_block, field_name, field_value)
|
||||
new_block.save()
|
||||
self.store.update_item(new_block, self.user_id)
|
||||
|
||||
if new_block.has_children:
|
||||
# Delete existing children in the new block, which can be reimported again if they still exist in the
|
||||
# source library
|
||||
for existing_child_key in new_block.children:
|
||||
self.store.delete_item(existing_child_key, self.user_id)
|
||||
# Now import the children
|
||||
for child in source_block.get_children():
|
||||
self._import_block(child, new_block_key)
|
||||
|
||||
return new_block_key
|
||||
|
||||
@@ -85,7 +85,7 @@ class CachingDescriptorSystem(MakoDescriptorSystem, EditInfoRuntimeMixin):
|
||||
self.module_data = module_data
|
||||
self.default_class = default_class
|
||||
self.local_modules = {}
|
||||
self._services['library_tools'] = LibraryToolsService(modulestore)
|
||||
self._services['library_tools'] = LibraryToolsService(modulestore, user_id=None)
|
||||
|
||||
@lazy
|
||||
@contract(returns="dict(BlockKey: BlockKey)")
|
||||
|
||||
@@ -826,13 +826,11 @@ def _update_and_import_module(
|
||||
# if library exists, update source_library_version and children
|
||||
# according to this existing library and library content block.
|
||||
if store.get_library(block.source_library_key):
|
||||
|
||||
# Update library content block's children on draft branch
|
||||
with store.branch_setting(branch_setting=ModuleStoreEnum.Branch.draft_preferred):
|
||||
LibraryToolsService(store).update_children(
|
||||
LibraryToolsService(store, user_id).update_children(
|
||||
block,
|
||||
user_id,
|
||||
version=block.source_library_version
|
||||
version=block.source_library_version,
|
||||
)
|
||||
|
||||
# Publish it if importing the course for branch setting published_only.
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<div class="xblock-render">
|
||||
<p>
|
||||
<i>To display a component from a content library here, enter the component ID (XBlock ID) that you want to use:</i>
|
||||
</p>
|
||||
<div style="display: flex">
|
||||
<input type="text" name="source_component_id" style="margin-right: 10px;"/>
|
||||
<button class="btn-brand save-btn" data-submit-url="{{save_url}}">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -33,7 +33,7 @@ class LibraryContentTest(MixedSplitTestCase):
|
||||
def setUp(self):
|
||||
super(LibraryContentTest, self).setUp()
|
||||
|
||||
self.tools = LibraryToolsService(self.store)
|
||||
self.tools = LibraryToolsService(self.store, self.user_id)
|
||||
self.library = LibraryFactory.create(modulestore=self.store)
|
||||
self.lib_blocks = [
|
||||
self.make_block("html", self.library, data="Hello world from block {}".format(i))
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"""
|
||||
Tests for Source from Library XBlock
|
||||
"""
|
||||
from xblockutils.resources import ResourceLoader
|
||||
|
||||
from openedx.core.djangoapps.content_libraries.tests.base import ContentLibrariesRestApiTest
|
||||
from student.roles import CourseInstructorRole
|
||||
from cms.lib.xblock.runtime import handler_url
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
|
||||
from xmodule.tests import get_test_system
|
||||
from xmodule.x_module import AUTHOR_VIEW, STUDENT_VIEW
|
||||
|
||||
|
||||
class LibrarySourcedBlockTestCase(ContentLibrariesRestApiTest):
|
||||
"""
|
||||
Tests for LibraryToolsService which interact with blockstore-based content libraries
|
||||
"""
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.store = modulestore()
|
||||
|
||||
def test_block_views(self):
|
||||
# Create a blockstore content library
|
||||
library = self._create_library(slug="testlib1_preview", title="Test Library 1", description="Testing XBlocks")
|
||||
# Add content to the library
|
||||
html_block_id = self._add_block_to_library(library["id"], "html", "html_student_preview")["id"]
|
||||
self._set_library_block_olx(html_block_id, '<html>Student Preview Test</html>')
|
||||
|
||||
# Create a modulestore course
|
||||
course = CourseFactory.create(modulestore=self.store, user_id=self.user.id)
|
||||
CourseInstructorRole(course.id).add_users(self.user)
|
||||
# Add a "Source from Library" block to the course
|
||||
source_block = ItemFactory.create(
|
||||
category="library_sourced",
|
||||
parent=course,
|
||||
parent_location=course.location,
|
||||
user_id=self.user.id,
|
||||
modulestore=self.store
|
||||
)
|
||||
|
||||
# Check if author_view for empty block renders using the editor template
|
||||
html = source_block.render(AUTHOR_VIEW).content
|
||||
loader = ResourceLoader('xmodule.library_sourced_block')
|
||||
expected_html = loader.render_django_template('templates/library-sourced-block-author-view.html', {
|
||||
'save_url': handler_url(source_block, 'submit_studio_edits')
|
||||
})
|
||||
self.assertEqual(expected_html, html)
|
||||
|
||||
submit_studio_edits_url = '/xblock/{0}/handler/submit_studio_edits'.format(source_block.scope_ids.usage_id)
|
||||
post_data = {"values": {"source_block_id": html_block_id}, "defaults": ["display_name"]}
|
||||
# Import the html block from the library to the course
|
||||
self.client.post(submit_studio_edits_url, data=post_data, format='json')
|
||||
|
||||
# Check if author_view for a configured block renders the children correctly
|
||||
# Use self.get_block_view for rendering these as mako templates are mocked to return repr of the template
|
||||
# instead of the rendered html
|
||||
res = self.get_block_view(source_block, AUTHOR_VIEW)
|
||||
self.assertNotIn('library-sourced-block-author-view.html', res)
|
||||
self.assertIn('studio_render_children_view.html', res)
|
||||
self.assertIn('Student Preview Test', res)
|
||||
|
||||
# Check if student_view renders the children correctly
|
||||
res = self.get_block_view(source_block, STUDENT_VIEW)
|
||||
self.assertIn('Student Preview Test', res)
|
||||
|
||||
def get_block_view(self, block, view, context=None):
|
||||
"""
|
||||
Renders the specified view for a given XBlock
|
||||
"""
|
||||
context = context or {}
|
||||
block = self.store.get_item(block.location)
|
||||
module_system = get_test_system(block)
|
||||
module_system.descriptor_runtime = block._runtime # pylint: disable=protected-access
|
||||
block.bind_for_student(module_system, self.user.id)
|
||||
return module_system.render(block, view, context).content
|
||||
@@ -1,12 +1,15 @@
|
||||
"""
|
||||
Tests for library tools service.
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
from mock import patch
|
||||
|
||||
from opaque_keys.edx.keys import UsageKey
|
||||
from openedx.core.djangoapps.content_libraries import api as library_api
|
||||
from openedx.core.djangoapps.content_libraries.tests.base import ContentLibrariesRestApiTest
|
||||
from openedx.core.djangoapps.xblock.api import load_block
|
||||
from student.roles import CourseInstructorRole
|
||||
from xmodule.library_tools import LibraryToolsService
|
||||
from xmodule.modulestore.tests.factories import LibraryFactory
|
||||
from xmodule.modulestore.tests.factories import CourseFactory, LibraryFactory
|
||||
from xmodule.modulestore.tests.utils import MixedSplitTestCase
|
||||
|
||||
|
||||
@@ -16,9 +19,8 @@ class LibraryToolsServiceTest(MixedSplitTestCase):
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super(LibraryToolsServiceTest, self).setUp()
|
||||
|
||||
self.tools = LibraryToolsService(self.store)
|
||||
super().setUp()
|
||||
self.tools = LibraryToolsService(self.store, self.user_id)
|
||||
|
||||
def test_list_available_libraries(self):
|
||||
"""
|
||||
@@ -36,3 +38,59 @@ class LibraryToolsServiceTest(MixedSplitTestCase):
|
||||
"""
|
||||
_ = self.tools.list_available_libraries()
|
||||
self.assertTrue(mock_get_library_summaries.called)
|
||||
|
||||
|
||||
class ContentLibraryToolsTest(MixedSplitTestCase, ContentLibrariesRestApiTest):
|
||||
"""
|
||||
Tests for LibraryToolsService which interact with blockstore-based content libraries
|
||||
"""
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.tools = LibraryToolsService(self.store, self.user.id)
|
||||
|
||||
def test_import_from_blockstore(self):
|
||||
# Create a blockstore content library
|
||||
library = self._create_library(slug="testlib1_import", title="A Test Library", description="Testing XBlocks")
|
||||
# Create a unit block with an HTML block in it.
|
||||
unit_block_id = self._add_block_to_library(library["id"], "unit", "unit1")["id"]
|
||||
html_block_id = self._add_block_to_library(library["id"], "html", "html1", parent_block=unit_block_id)["id"]
|
||||
html_block = load_block(UsageKey.from_string(html_block_id), self.user)
|
||||
# Add assets and content to the HTML block
|
||||
self._set_library_block_asset(html_block_id, "test.txt", b"data", expect_response=200)
|
||||
self._set_library_block_olx(html_block_id, '<html><a href="/static/test.txt">Hello world</a></html>')
|
||||
|
||||
# Create a modulestore course
|
||||
course = CourseFactory.create(modulestore=self.store, user_id=self.user.id)
|
||||
CourseInstructorRole(course.id).add_users(self.user)
|
||||
# Add Source from library block to the course
|
||||
sourced_block = self.make_block("library_sourced", course, user_id=self.user.id)
|
||||
|
||||
# Import the unit block from the library to the course
|
||||
self.tools.import_from_blockstore(sourced_block, unit_block_id)
|
||||
|
||||
# Verify imported block with its children
|
||||
self.assertEqual(len(sourced_block.children), 1)
|
||||
self.assertEqual(sourced_block.children[0].category, 'unit')
|
||||
|
||||
imported_unit_block = self.store.get_item(sourced_block.children[0])
|
||||
self.assertEqual(len(imported_unit_block.children), 1)
|
||||
self.assertEqual(imported_unit_block.children[0].category, 'html')
|
||||
|
||||
imported_html_block = self.store.get_item(imported_unit_block.children[0])
|
||||
self.assertIn('Hello world', imported_html_block.data)
|
||||
|
||||
# Check that assets were imported and static paths were modified after importing
|
||||
assets = library_api.get_library_block_static_asset_files(html_block.scope_ids.usage_id)
|
||||
self.assertEqual(len(assets), 1)
|
||||
self.assertIn(assets[0].url, imported_html_block.data)
|
||||
|
||||
# Check that reimporting updates the target block
|
||||
self._set_library_block_olx(html_block_id, '<html><a href="/static/test.txt">Foo bar</a></html>')
|
||||
self.tools.import_from_blockstore(sourced_block, unit_block_id)
|
||||
|
||||
self.assertEqual(len(sourced_block.children), 1)
|
||||
imported_unit_block = self.store.get_item(sourced_block.children[0])
|
||||
self.assertEqual(len(imported_unit_block.children), 1)
|
||||
imported_html_block = self.store.get_item(imported_unit_block.children[0])
|
||||
self.assertNotIn('Hello world', imported_html_block.data)
|
||||
self.assertIn('Foo bar', imported_html_block.data)
|
||||
|
||||
@@ -152,7 +152,7 @@ class LmsModuleSystem(ModuleSystem): # pylint: disable=abstract-method
|
||||
services['completion'] = CompletionService(user=user, context_key=kwargs.get('course_id'))
|
||||
services['fs'] = xblock.reference.plugins.FSService()
|
||||
services['i18n'] = ModuleI18nService
|
||||
services['library_tools'] = LibraryToolsService(store)
|
||||
services['library_tools'] = LibraryToolsService(store, user_id=user.id if user else None)
|
||||
services['partitions'] = PartitionService(
|
||||
course_id=kwargs.get('course_id'),
|
||||
cache=request_cache_dict
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
"""
|
||||
Helper methods for working with learning contexts
|
||||
"""
|
||||
|
||||
|
||||
from opaque_keys import OpaqueKey
|
||||
from opaque_keys.edx.keys import LearningContextKey, UsageKeyV2
|
||||
|
||||
from openedx.core.djangoapps.xblock.apps import get_xblock_app_config
|
||||
@@ -38,9 +37,11 @@ def get_learning_context_impl(key):
|
||||
context_type = key.CANONICAL_NAMESPACE # e.g. 'lib'
|
||||
elif isinstance(key, UsageKeyV2):
|
||||
context_type = key.context_key.CANONICAL_NAMESPACE
|
||||
else:
|
||||
elif isinstance(key, OpaqueKey):
|
||||
# Maybe this is an older modulestore key etc.
|
||||
raise TypeError("Opaque key {} does not have a learning context.".format(key))
|
||||
else:
|
||||
raise TypeError("key '{}' is not an opaque key. You probably forgot [KeyType].from_string(...)".format(key))
|
||||
|
||||
try:
|
||||
return _learning_context_cache[context_type]
|
||||
|
||||
@@ -124,7 +124,7 @@ class CompletionServiceTestCase(CompletionWaffleTestMixin, SharedModuleStoreTest
|
||||
"""
|
||||
module_system = get_test_system(course_id=module.location.course_key)
|
||||
module_system.descriptor_runtime = module.runtime._descriptor_system # pylint: disable=protected-access
|
||||
module_system._services['library_tools'] = LibraryToolsService(self.store) # pylint: disable=protected-access
|
||||
module_system._services['library_tools'] = LibraryToolsService(self.store, self.user.id) # pylint: disable=protected-access
|
||||
|
||||
def get_module(descriptor):
|
||||
"""Mocks module_system get_module function"""
|
||||
|
||||
@@ -80,6 +80,7 @@ INSTALLED_APPS = (
|
||||
'openedx.core.djangoapps.user_api',
|
||||
'course_modes.apps.CourseModesConfig',
|
||||
'lms.djangoapps.verify_student.apps.VerifyStudentConfig',
|
||||
'openedx.core.djangoapps.content_libraries',
|
||||
'openedx.core.djangoapps.dark_lang',
|
||||
'openedx.core.djangoapps.content.course_overviews.apps.CourseOverviewsConfig',
|
||||
'openedx.core.djangoapps.content.block_structure.apps.BlockStructureConfig',
|
||||
@@ -103,6 +104,7 @@ INSTALLED_APPS = (
|
||||
# Django 1.11 demands to have imported models supported by installed apps.
|
||||
'completion',
|
||||
'entitlements',
|
||||
'organizations',
|
||||
)
|
||||
|
||||
LMS_ROOT_URL = "http://localhost:8000"
|
||||
@@ -127,6 +129,8 @@ RETIRED_USERNAME_PREFIX = 'retired__user_'
|
||||
|
||||
PROCTORING_SETTINGS = {}
|
||||
|
||||
ROOT_URLCONF = None
|
||||
RUN_BLOCKSTORE_TESTS = False
|
||||
|
||||
# Software Secure request retry settings
|
||||
# Time in seconds before a retry of the task should be 60 mints.
|
||||
|
||||
Reference in New Issue
Block a user