diff --git a/cms/djangoapps/contentstore/views/__init__.py b/cms/djangoapps/contentstore/views/__init__.py index 5e644468fdb3..9e2e1e1828b9 100644 --- a/cms/djangoapps/contentstore/views/__init__.py +++ b/cms/djangoapps/contentstore/views/__init__.py @@ -12,6 +12,7 @@ from .helpers import * from .item import * from .import_export import * +from .library import * from .preview import * from .public import * from .export_git import * diff --git a/cms/djangoapps/contentstore/views/helpers.py b/cms/djangoapps/contentstore/views/helpers.py index 34ef869f170f..14b9e4c34392 100644 --- a/cms/djangoapps/contentstore/views/helpers.py +++ b/cms/djangoapps/contentstore/views/helpers.py @@ -106,6 +106,11 @@ def xblock_studio_url(xblock, parent_xblock=None): url=reverse_course_url('course_handler', xblock.location.course_key), usage_key=urllib.quote(unicode(xblock.location)) ) + elif category == 'library': + course_key = xblock.location.course_key + if course_key.branch == "library": + course_key = course_key.for_branch(None) # library branch is implied, so make the URL prettier/consistent + return reverse_course_url('library_handler', course_key) else: return reverse_usage_url('container_handler', xblock.location) diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py index 132bcbff6c29..746374b3946f 100644 --- a/cms/djangoapps/contentstore/views/item.py +++ b/cms/djangoapps/contentstore/views/item.py @@ -203,7 +203,7 @@ def xblock_view_handler(request, usage_key_string, view_name): if 'application/json' in accept_header: store = modulestore() - xblock = store.get_item(usage_key) + xblock = store.get_item(usage_key, remove_branch=False) container_views = ['container_preview', 'reorderable_container_child_preview'] # wrap the generated fragment in the xmodule_editor div so that the javascript @@ -314,7 +314,7 @@ def _update_with_callback(xblock, user, old_metadata=None, old_content=None): xblock.editor_saved(user, old_metadata, old_content) # Update after the callback so any changes made in the callback will get persisted. - return modulestore().update_item(xblock, user.id) + return modulestore().update_item(xblock, user.id, remove_branch=False) def _save_xblock(user, xblock, data=None, children_strings=None, metadata=None, nullout=None, @@ -462,7 +462,7 @@ def _create_item(request): store = modulestore() with store.bulk_operations(usage_key.course_key): - parent = store.get_item(usage_key) + parent = store.get_item(usage_key, remove_branch=False) dest_usage_key = usage_key.replace(category=category, name=uuid4().hex) # get the metadata, display_name, and definition from the request @@ -493,6 +493,7 @@ def _create_item(request): definition_data=data, metadata=metadata, runtime=parent.runtime, + remove_branch=False, ) # VS[compat] cdodge: This is a hack because static_tabs also have references from the course module, so @@ -518,7 +519,7 @@ def _duplicate_item(parent_usage_key, duplicate_source_usage_key, user, display_ """ store = modulestore() with store.bulk_operations(duplicate_source_usage_key.course_key): - source_item = store.get_item(duplicate_source_usage_key) + source_item = store.get_item(duplicate_source_usage_key, remove_branch=False) # Change the blockID to be unique. dest_usage_key = source_item.location.replace(name=uuid4().hex) category = dest_usage_key.block_type @@ -541,6 +542,7 @@ def _duplicate_item(parent_usage_key, duplicate_source_usage_key, user, display_ definition_data=source_item.get_explicitly_set_fields_by_scope(Scope.content), metadata=duplicate_metadata, runtime=source_item.runtime, + remove_branch=False, ) # Children are not automatically copied over (and not all xblocks have a 'children' attribute). @@ -553,7 +555,7 @@ def _duplicate_item(parent_usage_key, duplicate_source_usage_key, user, display_ store.update_item(dest_module, user.id) if 'detached' not in source_item.runtime.load_block_type(category)._class_tags: - parent = store.get_item(parent_usage_key) + parent = store.get_item(parent_usage_key, remove_branch=False) # If source was already a child of the parent, add duplicate immediately afterward. # Otherwise, add child to end. if source_item.location in parent.children: @@ -623,7 +625,7 @@ def _get_xblock(usage_key, user): store = modulestore() with store.bulk_operations(usage_key.course_key): try: - return store.get_item(usage_key, depth=None) + return store.get_item(usage_key, depth=None, remove_branch=False) except ItemNotFoundError: if usage_key.category in CREATE_IF_NOT_FOUND: # Create a new one for certain categories only. Used for course info handouts. @@ -696,7 +698,10 @@ def safe_get_username(user_id): has_changes = modulestore().has_changes(xblock) if (is_xblock_unit or course_outline) else None if graders is None: - graders = CourseGradingModel.fetch(xblock.location.course_key).graders + if xblock.category != "library": + graders = CourseGradingModel.fetch(xblock.location.course_key).graders + else: + graders = [] # Compute the child info first so it can be included in aggregate information for the parent should_visit_children = include_child_info and (course_outline and not is_xblock_unit or not course_outline) diff --git a/cms/djangoapps/contentstore/views/library.py b/cms/djangoapps/contentstore/views/library.py new file mode 100644 index 000000000000..d0fee14695fc --- /dev/null +++ b/cms/djangoapps/contentstore/views/library.py @@ -0,0 +1,148 @@ +""" +Views related to content libraries. +A content library is an optional branch that contains a flat list of XBlocks +which can be re-used in the "normal" branches of the course or other courses. +""" +from __future__ import absolute_import + +import json +import logging + +from contentstore.views.item import create_xblock_info +from django.http import HttpResponse, HttpResponseBadRequest, Http404 +from django.contrib.auth.decorators import login_required +from django.core.exceptions import PermissionDenied +from django.conf import settings +from django.utils.translation import ugettext as _ +from django_future.csrf import ensure_csrf_cookie +from edxmako.shortcuts import render_to_response +from opaque_keys.edx.keys import CourseKey +from xmodule.library_module import LibraryDescriptor +from xmodule.modulestore.exceptions import ItemNotFoundError +from xmodule.modulestore import ModuleStoreEnum +from xmodule.modulestore.django import modulestore + +from .access import has_course_access +from .component import get_component_templates +from util.json_request import JsonResponse + +__all__ = ['library_handler'] + +log = logging.getLogger(__name__) + +LIBRARIES_ENABLED = settings.FEATURES.get('ENABLE_CONTENT_LIBRARIES', False) + + +@login_required +@ensure_csrf_cookie +def library_handler(request, course_key_string=None): + """ + RESTful interface to most content library related functionality. + """ + if not LIBRARIES_ENABLED: + raise Http404 # Should never happen because we test the feature in urls.py also + + response_format = 'html' + if request.REQUEST.get('format', 'html') == 'json' or 'application/json' in request.META.get('HTTP_ACCEPT', 'text/html'): + response_format = 'json' + + if course_key_string: + course_key = CourseKey.from_string(course_key_string) + if not has_course_access(request.user, course_key): + raise PermissionDenied() + + if course_key.deprecated: + # Only courses stored in Split Mongo will work, and split requires Locators, not deprecated keys + return HttpResponseBadRequest("This course's modulestore does not support content libraries.") + + if not course_key.branch: + course_key = course_key.for_branch("library") + + store = modulestore() + + try: + library = store.get_course(course_key, remove_branch=False) + if library is None: + raise ItemNotFoundError # Inconsistency: mixed modulestore returns None, whereas split raises exception + except ItemNotFoundError: + if store.has_course(course_key.for_branch(None)): + # There is a course, but no library [yet] + if "create" in request.GET: + # Create the library branch & root XBlock: + store.create_branch( + org=course_key.org, + course=course_key.course, + run=course_key.run, + branch='library', + user_id=request.user.id, + fields={"display_name": "New Library"}, + root_category='library', + root_block_id='library', + ) + return JsonResponse({ + "result": "success", + }) + return HttpResponse( + "" + "No library exists for {course_id}. Would you like to create one? " + "Yes" + "" + .format(course_id=course_key.for_branch(None)) + ) + else: + raise Http404 + + if not isinstance(library, LibraryDescriptor): + return HttpResponseBadRequest("Course key specified is not a library.") + + if request.method == 'GET': + return library_blocks_view(request, library, response_format) + return HttpResponseBadRequest("Invalid request method.") + + # List all courses with a library: + split_store = modulestore()._get_modulestore_by_type(ModuleStoreEnum.Type.split) + libraries = [] + for i in split_store.find_matching_course_indexes("library"): + libraries.append({ + "version": "{}".format(i["versions"]["library"]), + "course": i["course"], + "org": i["org"], + "run": i["run"], + }) + return JsonResponse(libraries) + + +def library_blocks_view(request, library, response_format): + """ + The main view of a course's content library. + Shows all the XBlocks in the library, and allows adding/editing/deleting + them. + Can be called with response_format="json" to get a JSON-formatted list of + the XBlocks in the library along with library metadata. + """ + children = library.children + if response_format == "json": + # The JSON response for this request is short and sweet: + prev_version = library.runtime.course_entry.structure['previous_version'] + return JsonResponse({ + "display_name": library.display_name, + "library_id": unicode(library.location.course_key), # library.course_id raises UndefinedContext - fix? + "version": unicode(library.runtime.course_entry.course_key.version), + "previous_version": unicode(prev_version) if prev_version else None, + "blocks": [unicode(x) for x in children], + }) + + course = modulestore().get_course(library.location.course_key.for_branch(None)) + xblock_info = create_xblock_info(library, include_ancestor_info=False, graders=[]) + + component_templates = get_component_templates(course) + + return render_to_response('library.html', { + 'context_course': course, # Needed only for display of menus at top of page. + 'action': 'view', + 'xblock': library, + 'xblock_locator': library.location, + 'unit': None, + 'component_templates': json.dumps(component_templates), + 'xblock_info': xblock_info, + }) diff --git a/cms/templates/library.html b/cms/templates/library.html new file mode 100644 index 000000000000..78c02a2e0116 --- /dev/null +++ b/cms/templates/library.html @@ -0,0 +1,102 @@ +<%inherit file="base.html" /> +<%! +import json + +from contentstore.views.helpers import xblock_studio_url, xblock_type_display_name +from django.utils.translation import ugettext as _ +%> +<%block name="title">${xblock.display_name_with_default} ${xblock_type_display_name(xblock)} +<%block name="bodyclass">is-signedin course container view-container + +<%namespace name='static' file='static_content.html'/> + +<%! +templates = ["basic-modal", "modal-button", "edit-xblock-modal", + "editor-mode-button", "upload-dialog", "image-modal", + "add-xblock-component", "add-xblock-component-button", "add-xblock-component-menu", + "add-xblock-component-menu-problem", "xblock-string-field-editor", "publish-xblock", "publish-history", + "unit-outline", "container-message"] +%> +<%block name="header_extras"> +% for template_name in templates: + +% endfor + + + +<%block name="jsextra"> + + + + +<%block name="content"> + + +
+
+ + + +
+
+ +
+
+
+ +
+
+ +
+

${_("Loading...")}

+
+
+ +
+
+
+ diff --git a/cms/templates/widgets/header.html b/cms/templates/widgets/header.html index 3f42d06ace87..412fc848760a 100644 --- a/cms/templates/widgets/header.html +++ b/cms/templates/widgets/header.html @@ -61,6 +61,11 @@

${_("Course" + % if settings.FEATURES.get('ENABLE_CONTENT_LIBRARIES') and not course_key.deprecated: + + % endif diff --git a/cms/urls.py b/cms/urls.py index bd99ae6c7ed7..d4f9c7a45017 100644 --- a/cms/urls.py +++ b/cms/urls.py @@ -109,6 +109,11 @@ url(r'^i18n.js$', 'django.views.i18n.javascript_catalog', js_info_dict), ) +if settings.FEATURES.get('ENABLE_CONTENT_LIBRARIES'): + urlpatterns += ( + url(r'^library/{}?$'.format(settings.COURSE_KEY_PATTERN), + 'contentstore.views.library_handler', name='library_handler'), + ) if settings.FEATURES.get('ENABLE_EXPORT_GIT'): urlpatterns += (url(r'^export_git/{}$'.format(settings.COURSE_KEY_PATTERN), diff --git a/common/lib/xmodule/setup.py b/common/lib/xmodule/setup.py index 5182a8454c7d..3c99d6e21a10 100644 --- a/common/lib/xmodule/setup.py +++ b/common/lib/xmodule/setup.py @@ -11,6 +11,8 @@ "discuss = xmodule.backcompat_module:TranslateCustomTagDescriptor", "html = xmodule.html_module:HtmlDescriptor", "image = xmodule.backcompat_module:TranslateCustomTagDescriptor", + "library = xmodule.library_module:LibraryDescriptor", + "library_content = xmodule.library_content_module:LibraryContentDescriptor", "error = xmodule.error_module:ErrorDescriptor", "peergrading = xmodule.peer_grading_module:PeerGradingDescriptor", "poll_question = xmodule.poll_module:PollDescriptor", diff --git a/common/lib/xmodule/xmodule/library_content_module.py b/common/lib/xmodule/xmodule/library_content_module.py new file mode 100644 index 000000000000..94e6a7b72758 --- /dev/null +++ b/common/lib/xmodule/xmodule/library_content_module.py @@ -0,0 +1,266 @@ +from bson.objectid import ObjectId +from collections import namedtuple +from copy import copy +from opaque_keys.edx.locator import CourseLocator +from webob import Response +from xblock.core import XBlock +from xblock.fields import Scope, String, List, Integer, Boolean +from xblock.fragment import Fragment +from xmodule.modulestore.exceptions import ItemNotFoundError +from xmodule.x_module import XModule, STUDENT_VIEW +from xmodule.seq_module import SequenceDescriptor +from xmodule.studio_editable import StudioEditableModule, StudioEditableDescriptor +from pkg_resources import resource_string + +# Make '_' a no-op so we can scrape strings +_ = lambda text: text + +# enum helper in lieu of enum34 +def enum(**enums): + return type('Enum', (), enums) + +class LibraryVersionReference(namedtuple("LibraryVersionReference", "library_id version")): + """ + A reference to a specific library, with an optional version. + The version is used to find out when the LibraryContentXBlock was last + updated with the latest content from the library. + + library_id is a CourseLocator + version is an ObjectId or None + """ + def __new__(cls, library_id, version=None): + # pylint: disable=super-on-old-class + if not isinstance(library_id, CourseLocator): + library_id = CourseLocator.from_string(library_id) + if library_id.version: + assert (version is None) or (version == library_id.version) + if not version: + version = library_id.version + library_id = library_id.for_version(None) + if version and not isinstance(version, ObjectId): + version = ObjectId(version) + return super(LibraryVersionReference, cls).__new__(cls, library_id, version) + + @staticmethod + def from_json(value): + return LibraryVersionReference(*value) + + def to_json(self): + # TODO: Is there anyway for an xblock to *store* an ObjectId as + # part of the List() field value? self.version should really be + # stored in mongo as an ObjectId. + return [unicode(self.library_id), unicode(self.version) if self.version else None] + +class LibraryList(List): + """ + Special List class for listing references to content libraries. + Is simply a list of LibraryVersionReference tuples. + """ + def from_json(self, values): + # values might be a list of lists, or a list of strings + # Normally the runtime gives us: + # [[u'course-v1:ProblemX+PR0B+2014+branch@library', '5436ffec56c02c13806a4c1b'], ...] + # But the studio editor gives us: + # [u'course-v1:ProblemX+PR0B+2014+branch@library,5436ffec56c02c13806a4c1b', ...] + # TODO: Fix studio's strange behaviour or get a custom widget + def parse(val): + if isinstance(val, unicode) or isinstance(val, str): + val = val.strip(' []') + parts = val.rsplit(',', 1) + val = [parts[0], parts[1] if len(parts) > 1 else None] + return LibraryVersionReference.from_json(val) + return [parse(v) for v in values] + + def to_json(self, values): + return [lvr.to_json() for lvr in values] + + +class LibraryContentFields(object): + source_libraries = LibraryList( + display_name=_("Library"), + help=_("Which content library to draw content from"), + default=[], + scope=Scope.settings, + ) + mode = String( + help=_("Determines how content is drawn from the library"), + default="random", + values=[ + {"display_name": _("Choose first n"), "value": "first"}, + {"display_name": _("Choose n at random"), "value": "random"} + #{"display_name": _("Manually selected"), "value": "manual"} + ], + scope=Scope.settings, + ) + max_count = Integer( + display_name=_("Count"), + help=_("How many components to select from the library"), + default=1, + scope=Scope.settings, + ) + filters = String(default="") # TBD + has_score = Boolean( + display_name=_("Graded"), + help=_("Is this a graded assignment"), + default=False, + scope=Scope.settings, + ) + weight = Integer( + display_name=_("Weight"), + help=_("If this is a graded assignment, this determines the total point value available."), + default=1, + scope=Scope.settings, + ) + has_children = True + + +class LibraryContentModule(LibraryContentFields, XModule, StudioEditableModule): + ''' Layout module for laying out submodules vertically.''' + + def student_view(self, context): + fragment = Fragment() + contents = [] + + child_context = {} if not context else copy(context) + child_context['child_of_vertical'] = True + + for child in self.get_display_items(): + rendered_child = child.render(STUDENT_VIEW, child_context) + fragment.add_frag_resources(rendered_child) + + contents.append({ + 'id': child.location.to_deprecated_string(), + 'content': rendered_child.content + }) + + fragment.add_content(self.system.render_template('vert_module.html', { + 'items': contents, + 'xblock_context': context, + })) + return fragment + + def author_view(self, context): + """ + Renders the Studio views. + Normal studio view: displays library status and has an "Update" button. + Studio container view: displays a preview of all possible children. + """ + fragment = Fragment() + root_xblock = context.get('root_xblock') + is_root = root_xblock and root_xblock.location == self.location + + if is_root: + # User has clicked the "View" link. Show a preview of all possible children: + if self.children: + self.render_children(context, fragment, can_reorder=False, can_add=False) + else: + fragment.add_content(u'

{}

'.format( + _('No matching content found in library, no library configured, or not yet loaded from library.') + )) + else: + # When shown on a unit page, don't show any sort of preview - just the status of this block. + LibraryStatus = enum( + NONE=0, # no library configured + INVALID=1, # invalid configuration or library has been deleted/corrupted + OK=2, # library configured correctly and should be working fine + ) + UpdateStatus = enum( + CANNOT=0, # Cannot update - library is not set, invalid, deleted, etc. + NEEDED=1, # An update is needed - prompt the user to update + UP_TO_DATE=2, # No update necessary - library is up to date + ) + library_names = [] + library_status = LibraryStatus.OK + update_status = UpdateStatus.UP_TO_DATE + if self.source_libraries: + for library_key, version in self.source_libraries: + library = self._get_library(library_key) + if library is None: + library_status = LibraryStatus.INVALID + update_status = UpdateStatus.CANNOT + break + library_names.append(library.display_name) + latest_version = library.location.course_key.version + if version is None or version != latest_version: + update_status=UpdateStatus.NEEDED + # else library is up to date. + else: + library_status = LibraryStatus.NONE + update_status = UpdateStatus.CANNOT + fragment.add_content(self.system.render_template('library-block-author-view.html', { + 'library_status': library_status, + 'LibraryStatus': LibraryStatus, + 'update_status': update_status, + 'UpdateStatus': UpdateStatus, + 'library_names': library_names, + 'max_count': self.max_count, + 'mode': self.mode, + 'num_children': len(self.children), + })) + fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/library_content_edit.js')) + fragment.initialize_js('LibraryContentAuthorView') + return fragment + + def _get_library(self, library_key): + """ + Given a library key like "course-v1:ProblemX+PR0B+2014+branch@library", + return the 'library' XBlock with meta-information about the library. + + Returns None on error. + """ + if not isinstance(library_key, CourseLocator): + library_key = CourseLocator.from_string(library_key) + assert library_key.version is None + + # TODO: Is this too tightly coupled to split? May need to abstract this into a service + # provided by the CMS runtime. + try: + library = self.runtime.descriptor_runtime.modulestore.get_course(library_key, remove_branch=False, remove_version=False) + except ItemNotFoundError: + return None + # library's version should be in library.location.course_key.version + # TODO: Is this guaranteed? + assert library.location.course_key.version is not None + # Note library version is also possibly available at library.runtime.course_entry.course_key.version + return library + + +@XBlock.wants('user') +class LibraryContentDescriptor(LibraryContentFields, SequenceDescriptor, StudioEditableDescriptor): + """ + Descriptor class for LibraryContentModule XBlock. + """ + module_class = LibraryContentModule + + @XBlock.handler + def refresh_children(self, request, _): + user_id = self.runtime.service(self, 'user').user_id + new_children = [] + + store = self.system.modulestore + with store.bulk_operations(self.location.course_key): + new_libraries = [] + for library_key, version in self.source_libraries: + library = self._xmodule._get_library(library_key) + for c in library.children: + child = store.get_item(c, depth=9) + new_child_info = store.create_item( + user_id, + self.location.course_key, + c.block_type, + definition_locator=child.definition_locator, + # TODO: metadata= data from Scope.settings fields - as temporary thing until they get stored in definitions, + runtime=self.system, + ) + new_children.append(new_child_info.location) + new_libraries.append(LibraryVersionReference(library_key, library.location.course_key.version)) + self.source_libraries = new_libraries + self.children = new_children + # TODO: This currently creates orphans in the modulestore's block structure for any old children. + self.system.modulestore.update_item(self, None) + return Response() + + js = {'coffee': [resource_string(__name__, 'js/src/vertical/edit.coffee')]} + js_module_name = "VerticalDescriptor" + + # TODO: definition_to_xml etc. diff --git a/common/lib/xmodule/xmodule/library_module.py b/common/lib/xmodule/xmodule/library_module.py new file mode 100644 index 000000000000..c1f224d79719 --- /dev/null +++ b/common/lib/xmodule/xmodule/library_module.py @@ -0,0 +1,53 @@ +""" +'library' XBlock/XModule + +The "library" XBlock/XModule is the root of every content library structure +tree. All content blocks in the library are its children. It is analagous to +the "course" XBlock/XModule used as the root of each normal course structure +tree. + +This block should only ever be present in the "library" branch of a course, +and it should never have a parent block. +""" +import logging + +from xmodule.vertical_module import VerticalDescriptor, VerticalModule + +from xblock.fields import Scope, String + +log = logging.getLogger(__name__) + +# Make '_' a no-op so we can scrape strings +_ = lambda text: text + + +class LibraryFields(object): + """ + Fields of the "library" XBlock - see below. + """ + display_name = String( + help=_("Enter the name of the library as it should appear in Studio."), + default="Library", + display_name=_("Library Display Name"), + scope=Scope.settings + ) + has_children = True + + +class LibraryDescriptor(LibraryFields, VerticalDescriptor): + """ + Descriptor for our library XBlock/XModule. + """ + module_class = VerticalModule + + def __init__(self, *args, **kwargs): + """ + Expects the same arguments as XModuleDescriptor.__init__ + """ + super(LibraryDescriptor, self).__init__(*args, **kwargs) + + def __unicode__(self): + return u"Library: {}".format(self.display_name) + + def __str__(self): + return "Library: {}".format(self.display_name) diff --git a/common/lib/xmodule/xmodule/modulestore/mixed.py b/common/lib/xmodule/xmodule/modulestore/mixed.py index 99c01438a454..f4deaba1bd49 100644 --- a/common/lib/xmodule/xmodule/modulestore/mixed.py +++ b/common/lib/xmodule/xmodule/modulestore/mixed.py @@ -376,6 +376,15 @@ def create_course(self, org, course, run, user_id, **kwargs): return course + def create_branch(self, org, course, run, branch, user_id, **kwargs): + """ + Create a new branch of an existing course, if supported. + Used for content libraries. + """ + course_key = self.make_course_key(org, course, run) + store = self._verify_modulestore_support(course_key, 'create_branch') + return store.create_branch(org, course, run, branch, user_id, **kwargs) + @strip_key def clone_course(self, source_course_id, dest_course_id, user_id, fields=None, **kwargs): """ diff --git a/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py b/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py index 03ed1d5d3707..357272a58785 100644 --- a/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py +++ b/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py @@ -1279,7 +1279,7 @@ def create_item( # persist the definition if persisted != passed if (definition_locator is None or isinstance(definition_locator.definition_id, LocalId)): definition_locator = self.create_definition_from_data(course_key, new_def_data, block_type, user_id) - elif new_def_data is not None: + elif new_def_data: definition_locator, _ = self.update_definition_from_data(course_key, definition_locator, new_def_data, user_id) # copy the structure and modify the new one @@ -1398,7 +1398,7 @@ def clone_course(self, source_course_id, dest_course_id, user_id, fields=None, * def create_course( self, org, course, run, user_id, master_branch=None, fields=None, versions_dict=None, search_targets=None, root_category='course', - root_block_id=None, **kwargs + root_block_id=None, allow_existing_branches=False, **kwargs ): """ Create a new entry in the active courses index which points to an existing or new structure. Returns @@ -1441,6 +1441,10 @@ def create_course( and the values are structure guids. If provided, the new course will reuse this version (unless you also provide any fields overrides, see above). if not provided, will create a mostly empty course structure with just a category course root xblock. + + allow_existing_branches: If true, override the normal behaviour and allow creation of a new branch, + even if some other branches of the course exist. Use this for specialized branches only. Will still + fail if the specified branch already exists. """ # either need to assert this or have a default assert master_branch is not None @@ -1448,7 +1452,15 @@ def create_course( locator = CourseLocator(org=org, course=course, run=run, branch=master_branch) index = self.get_course_index(locator) if index is not None: - raise DuplicateCourseError(locator, index) + if allow_existing_branches: + # Some branches of this org/course/run already exist, but we're + # going to allow that because of this flag. Just make sure the new + # branch doesn't yet exist. + versions_dict = index['versions'] + if locator.branch in versions_dict: + raise DuplicateCourseError(locator, index) + else: + raise DuplicateCourseError(locator, index) partitioned_fields = self.partition_fields_by_scope(root_category, fields) block_fields = partitioned_fields[Scope.settings] @@ -1524,25 +1536,39 @@ def create_course( locator = locator.replace(version_guid=new_id) with self.bulk_operations(locator): self.update_structure(locator, draft_structure) - index_entry = { - '_id': ObjectId(), - 'org': org, - 'course': course, - 'run': run, - 'edited_by': user_id, - 'edited_on': datetime.datetime.now(UTC), - 'versions': versions_dict, - 'schema_version': self.SCHEMA_VERSION, - 'search_targets': search_targets or {}, - } - if fields is not None: - self._update_search_targets(index_entry, fields) - self.insert_course_index(locator, index_entry) + if index is None: + index_entry = { + '_id': ObjectId(), + 'org': org, + 'course': course, + 'run': run, + 'edited_by': user_id, + 'edited_on': datetime.datetime.now(UTC), + 'versions': versions_dict, + 'schema_version': self.SCHEMA_VERSION, + 'search_targets': search_targets or {}, + } + if fields is not None: + self._update_search_targets(index_entry, fields) + self.insert_course_index(locator, index_entry) + else: + index['versions'] = versions_dict + self._get_bulk_ops_record(locator).set_structure_for_branch(master_branch, draft_structure) # expensive hack to persist default field values set in __init__ method (e.g., wiki_slug) course = self.get_course(locator, **kwargs) return self.update_item(course, user_id, **kwargs) + def create_branch(self, org, course, run, branch, user_id, **kwargs): + """ + Create an empty custom branch on an existing course. + Arguments are generally the same as for create_course() + """ + kwargs["master_branch"] = branch + kwargs["allow_existing_branches"] = True + # We need to bypass the code in split_draft, so call our own method explicitly: + return SplitMongoModuleStore.create_course(self, org, course, run, user_id, **kwargs) + def update_item(self, descriptor, user_id, allow_not_found=False, force=False, **kwargs): """ Save the descriptor's fields. it doesn't descend the course dag to save the children. diff --git a/common/lib/xmodule/xmodule/modulestore/split_mongo/split_draft.py b/common/lib/xmodule/xmodule/modulestore/split_mongo/split_draft.py index 6d06db94c71a..f47e8917ea2c 100644 --- a/common/lib/xmodule/xmodule/modulestore/split_mongo/split_draft.py +++ b/common/lib/xmodule/xmodule/modulestore/split_mongo/split_draft.py @@ -157,7 +157,10 @@ def delete_item(self, location, user_id, revision=None, **kwargs): elif revision == ModuleStoreEnum.RevisionOption.all: branches_to_delete = [ModuleStoreEnum.BranchName.published, ModuleStoreEnum.BranchName.draft] elif revision is None: - branches_to_delete = [ModuleStoreEnum.BranchName.draft] + if location.course_key.branch: + branches_to_delete = [location.course_key.branch] # Delete from whatever branch is explicitly requested + else: + branches_to_delete = [ModuleStoreEnum.BranchName.draft] # Default else: raise UnsupportedRevisionError( [ diff --git a/common/lib/xmodule/xmodule/public/js/library_content_edit.js b/common/lib/xmodule/xmodule/public/js/library_content_edit.js new file mode 100644 index 000000000000..a7e81041e140 --- /dev/null +++ b/common/lib/xmodule/xmodule/public/js/library_content_edit.js @@ -0,0 +1,20 @@ +/* JavaScript for editing operations that can be done on LibraryContentXBlock */ +window.LibraryContentAuthorView = function (runtime, element) { + $(element).find('.library-update-btn').on('click', function(e) { + e.preventDefault(); + // Update the XBlock with the latest matching content from the library: + runtime.notify('save', { + state: 'start', + element: element, + message: gettext('Updating with latest library content…') + }); + $.post(runtime.handlerUrl(element, 'refresh_children')).done(function() { + runtime.notify('save', { + state: 'end', + element: element + }); + runtime.refreshXBlock(element); + // TODO: Why does neither save nor refreshXBlock actually refresh the XBlock? Both should. + }); + }); +}; diff --git a/lms/templates/library-block-author-view.html b/lms/templates/library-block-author-view.html new file mode 100644 index 000000000000..d49fa1283351 --- /dev/null +++ b/lms/templates/library-block-author-view.html @@ -0,0 +1,18 @@ +<%! +from django.utils.translation import ugettext as _ +from django.core.urlresolvers import reverse +%> +
+ % if library_status == LibraryStatus.OK: +

${_('This component will be replaced by {mode} {max_count} components from the {num_children} matching components from {lib_names}.').format(mode=mode, max_count=max_count, num_children=num_children, lib_names=', '.join(library_names))}

+ % if update_status == UpdateStatus.NEEDED: +

${_('This component is out of date.')} ↻ ${_('Update now with latest components from the library')}

+ % elif update_status == UpdateStatus.UP_TO_DATE: +

${_(u'✓ Up to date.')}

+ % endif + % elif library_status == LibraryStatus.NONE: +

${_('No library or filters configured. Press "Edit" to configure.')}

+ % else: +

${_('Library is invalid, corrupt, or has been deleted.')}

+ % endif +