diff --git a/cms/djangoapps/contentstore/tests/test_libraries.py b/cms/djangoapps/contentstore/tests/test_libraries.py
new file mode 100644
index 000000000000..c8a56676d235
--- /dev/null
+++ b/cms/djangoapps/contentstore/tests/test_libraries.py
@@ -0,0 +1,274 @@
+"""
+Content library unit tests that require the CMS runtime.
+"""
+from contentstore.tests.utils import AjaxEnabledTestClient, parse_json
+from contentstore.utils import reverse_usage_url
+from contentstore.views.tests.test_library import LIBRARY_REST_URL
+from fs.memoryfs import MemoryFS
+from xmodule.library_content_module import LibraryVersionReference
+from xmodule.modulestore import ModuleStoreEnum
+from xmodule.modulestore.django import modulestore
+from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
+from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
+from xmodule.tests import get_test_system
+from mock import Mock
+from opaque_keys.edx.locator import CourseKey, LibraryLocator
+import ddt
+
+
+@ddt.ddt
+class TestLibraries(ModuleStoreTestCase):
+ """
+ High-level tests for libraries
+ """
+ def setUp(self):
+ user_password = super(TestLibraries, self).setUp()
+
+ self.client = AjaxEnabledTestClient()
+ self.client.login(username=self.user.username, password=user_password)
+
+ self.lib_key = self._create_library()
+ self.library = modulestore().get_library(self.lib_key)
+
+ def _create_module_system(self, course):
+ """
+ Create an xmodule system so we can use bind_for_student
+ """
+ def get_module(descriptor):
+ """Mocks module_system get_module function"""
+ module_system = get_test_system()
+ module_system.get_module = get_module
+ descriptor.bind_for_student(module_system, descriptor._field_data) # pylint: disable=protected-access
+ return descriptor
+
+ module_system = get_test_system()
+ module_system.get_module = get_module
+ module_system.descriptor_system = course.runtime
+ course.runtime.export_fs = MemoryFS()
+ return module_system
+
+ def _create_library(self, org="org", library="lib", display_name="Test Library"):
+ """
+ Helper method used to create a library. Uses the REST API.
+ """
+ response = self.client.ajax_post(LIBRARY_REST_URL, {
+ 'org': org,
+ 'library': library,
+ 'display_name': display_name,
+ })
+ self.assertEqual(response.status_code, 200)
+ lib_info = parse_json(response)
+ lib_key = CourseKey.from_string(lib_info['library_key'])
+ self.assertIsInstance(lib_key, LibraryLocator)
+ return lib_key
+
+ def _add_library_content_block(self, course, library_key, other_settings=None):
+ """
+ Helper method to add a LibraryContent block to a course.
+ The block will be configured to select content from the library
+ specified by library_key.
+ other_settings can be a dict of Scope.settings fields to set on the block.
+ """
+ metadata = {'source_libraries': [LibraryVersionReference(library_key)]}
+ if other_settings:
+ metadata.update(other_settings)
+ return ItemFactory.create(
+ category='library_content',
+ parent_location=course.location,
+ user_id=self.user.id,
+ metadata=metadata,
+ publish_item=False,
+ )
+
+ def _refresh_children(self, lib_content_block):
+ """
+ Helper method: Uses the REST API to call the 'refresh_children' handler
+ of a LibraryContent block
+ """
+ if 'user' not in lib_content_block.runtime._services: # pylint: disable=protected-access
+ lib_content_block.runtime._services['user'] = Mock(user_id=self.user.id) # pylint: disable=protected-access
+ handler_url = reverse_usage_url('component_handler', lib_content_block.location, kwargs={'handler': 'refresh_children'})
+ response = self.client.ajax_post(handler_url)
+ self.assertEqual(response.status_code, 200)
+ return modulestore().get_item(lib_content_block.location)
+
+ @ddt.data(
+ (2, 1, 1),
+ (2, 2, 2),
+ (2, 20, 2),
+ )
+ @ddt.unpack
+ def test_max_items(self, num_to_create, num_to_select, num_expected):
+ """
+ Test the 'max_count' property of LibraryContent blocks.
+ """
+ for _ in range(0, num_to_create):
+ ItemFactory.create(category="html", parent_location=self.library.location, user_id=self.user.id, publish_item=False)
+
+ with modulestore().default_store(ModuleStoreEnum.Type.split):
+ course = CourseFactory.create()
+
+ lc_block = self._add_library_content_block(course, self.lib_key, {'max_count': num_to_select})
+ self.assertEqual(len(lc_block.children), 0)
+ lc_block = self._refresh_children(lc_block)
+
+ # Now, we want to make sure that .children has the total # of potential
+ # children, and that get_child_descriptors() returns the actual children
+ # chosen for a given student.
+ # In order to be able to call get_child_descriptors(), we must first
+ # call bind_for_student:
+ lc_block.bind_for_student(self._create_module_system(course), lc_block._field_data) # pylint: disable=protected-access
+ self.assertEqual(len(lc_block.children), num_to_create)
+ self.assertEqual(len(lc_block.get_child_descriptors()), num_expected)
+
+ def test_consistent_children(self):
+ """
+ Test that the same student will always see the same selected child block
+ """
+ # Create many blocks in the library and add them to a course:
+ for num in range(0, 8):
+ ItemFactory.create(
+ metadata={"data": "This is #{}".format(num + 1)},
+ category="html", parent_location=self.library.location, user_id=self.user.id, publish_item=False
+ )
+
+ with modulestore().default_store(ModuleStoreEnum.Type.split):
+ course = CourseFactory.create()
+ module_system = self._create_module_system(course)
+
+ lc_block = self._add_library_content_block(course, self.lib_key, {'max_count': 1})
+ lc_block_key = lc_block.location
+ lc_block = self._refresh_children(lc_block)
+
+ def get_child_of_lc_block(block):
+ """
+ Helper that gets the actual child block seen by a student.
+ We cannot use get_child_descriptors because it uses features that
+ are mocked by the test runtime.
+ """
+ block_ids = list(block._xmodule.selected_children()) # pylint: disable=protected-access
+ self.assertEqual(len(block_ids), 1)
+ for child_key in block.children:
+ if child_key.block_id == block_ids[0]:
+ return modulestore().get_item(child_key)
+
+ # bind the module for a student:
+ lc_block.bind_for_student(module_system, lc_block._field_data) # pylint: disable=protected-access
+ chosen_child = get_child_of_lc_block(lc_block)
+ chosen_child_defn_id = chosen_child.definition_locator.definition_id
+
+ modulestore().update_item(lc_block, self.user.id)
+
+ # Now re-load the block and try again:
+ def check():
+ """
+ Confirm that chosen_child is still the child seen by the test student
+ """
+ for _ in range(0, 10): # Repeat many times b/c blocks are randomized
+ lc_block = modulestore().get_item(lc_block_key) # Reload block from the database
+ lc_block.bind_for_student(module_system, lc_block._field_data) # pylint: disable=protected-access
+ current_child = get_child_of_lc_block(lc_block)
+ self.assertEqual(current_child.location, chosen_child.location)
+ self.assertEqual(current_child.data, chosen_child.data)
+ self.assertEqual(current_child.definition_locator.definition_id, chosen_child_defn_id)
+ check()
+
+ # Refresh the children:
+ lc_block = self._refresh_children(lc_block)
+ lc_block.bind_for_student(module_system, lc_block._field_data) # pylint: disable=protected-access
+
+ # Now re-load the block and try yet again, in case refreshing the children changed anything:
+ check()
+
+ def test_definition_shared_with_library(self):
+ """
+ Test that the same block definition is used for the library and course[s]
+ """
+ block1 = ItemFactory.create(category="html", parent_location=self.library.location, user_id=self.user.id, publish_item=False)
+ def_id1 = block1.definition_locator.definition_id
+ block2 = ItemFactory.create(category="html", parent_location=self.library.location, user_id=self.user.id, publish_item=False)
+ def_id2 = block2.definition_locator.definition_id
+ self.assertNotEqual(def_id1, def_id2)
+
+ # Next, create a course:
+ with modulestore().default_store(ModuleStoreEnum.Type.split):
+ course = CourseFactory.create()
+
+ # Add a LibraryContent block to the course:
+ lc_block = self._add_library_content_block(course, self.lib_key)
+ lc_block = self._refresh_children(lc_block)
+ for child_key in lc_block.children:
+ child = modulestore().get_item(child_key)
+ def_id = child.definition_locator.definition_id
+ self.assertIn(def_id, (def_id1, def_id2))
+
+ def test_fields(self):
+ """
+ Test that blocks used from a library have the same field values as
+ defined by the library author.
+ """
+ data_value = "A Scope.content value"
+ name_value = "A Scope.settings value"
+ lib_block = ItemFactory.create(
+ category="html",
+ parent_location=self.library.location,
+ user_id=self.user.id,
+ publish_item=False,
+ display_name=name_value,
+ metadata={
+ "data": data_value,
+ },
+ )
+ self.assertEqual(lib_block.data, data_value)
+ self.assertEqual(lib_block.display_name, name_value)
+
+ # Next, create a course:
+ with modulestore().default_store(ModuleStoreEnum.Type.split):
+ course = CourseFactory.create()
+
+ # Add a LibraryContent block to the course:
+ lc_block = self._add_library_content_block(course, self.lib_key)
+ lc_block = self._refresh_children(lc_block)
+ course_block = modulestore().get_item(lc_block.children[0])
+
+ self.assertEqual(course_block.data, data_value)
+ self.assertEqual(course_block.display_name, name_value)
+
+ def test_block_with_children(self):
+ """
+ Test that blocks used from a library can have children.
+ """
+ data_value = "A Scope.content value"
+ name_value = "A Scope.settings value"
+ # In the library, create a vertical block with a child:
+ vert_block = ItemFactory.create(
+ category="vertical",
+ parent_location=self.library.location,
+ user_id=self.user.id,
+ publish_item=False,
+ )
+ child_block = ItemFactory.create(
+ category="html",
+ parent_location=vert_block.location,
+ user_id=self.user.id,
+ publish_item=False,
+ display_name=name_value,
+ metadata={"data": data_value, },
+ )
+ self.assertEqual(child_block.data, data_value)
+ self.assertEqual(child_block.display_name, name_value)
+
+ # Next, create a course:
+ with modulestore().default_store(ModuleStoreEnum.Type.split):
+ course = CourseFactory.create()
+
+ # Add a LibraryContent block to the course:
+ lc_block = self._add_library_content_block(course, self.lib_key)
+ lc_block = self._refresh_children(lc_block)
+ self.assertEqual(len(lc_block.children), 1)
+ course_vert_block = modulestore().get_item(lc_block.children[0])
+ self.assertEqual(len(course_vert_block.children), 1)
+ course_child_block = modulestore().get_item(course_vert_block.children[0])
+
+ self.assertEqual(course_child_block.data, data_value)
+ self.assertEqual(course_child_block.display_name, name_value)
diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py
index 2f61345f9afa..38f1abd94b71 100644
--- a/cms/djangoapps/contentstore/utils.py
+++ b/cms/djangoapps/contentstore/utils.py
@@ -293,6 +293,13 @@ def reverse_course_url(handler_name, course_key, kwargs=None):
return reverse_url(handler_name, 'course_key_string', course_key, kwargs)
+def reverse_library_url(handler_name, library_key, kwargs=None):
+ """
+ Creates the URL for handlers that use library_keys as URL parameters.
+ """
+ return reverse_url(handler_name, 'library_key_string', library_key, kwargs)
+
+
def reverse_usage_url(handler_name, usage_key, kwargs=None):
"""
Creates the URL for handlers that use usage_keys as URL parameters.
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/course.py b/cms/djangoapps/contentstore/views/course.py
index 5d5529aaf385..860e2e55d640 100644
--- a/cms/djangoapps/contentstore/views/course.py
+++ b/cms/djangoapps/contentstore/views/course.py
@@ -38,6 +38,7 @@
add_extra_panel_tab,
remove_extra_panel_tab,
reverse_course_url,
+ reverse_library_url,
reverse_usage_url,
reverse_url,
remove_all_instructors,
@@ -56,6 +57,7 @@
ADVANCED_COMPONENT_TYPES,
)
from contentstore.tasks import rerun_course
+from .library import LIBRARIES_ENABLED
from .item import create_xblock_info
from course_creators.views import get_course_creator_status, add_user_with_status_unrequested
from contentstore import utils
@@ -341,6 +343,14 @@ def _accessible_courses_list_from_groups(request):
return courses_list.values(), in_process_course_actions
+def _accessible_libraries_list(user):
+ """
+ List all libraries available to the logged in user by iterating through all libraries
+ """
+ # No need to worry about ErrorDescriptors - split's get_libraries() never returns them.
+ return [lib for lib in modulestore().get_libraries() if has_course_access(user, lib.location)]
+
+
@login_required
@ensure_csrf_cookie
def course_listing(request):
@@ -360,6 +370,8 @@ def course_listing(request):
# so fallback to iterating through all courses
courses, in_process_course_actions = _accessible_courses_list(request)
+ libraries = _accessible_libraries_list(request.user) if LIBRARIES_ENABLED else []
+
def format_course_for_view(course):
"""
Return a dict of the data which the view requires for each course
@@ -393,6 +405,18 @@ def format_in_process_course_view(uca):
}) if uca.state == CourseRerunUIStateManager.State.FAILED else ''
}
+ def format_library_for_view(library):
+ """
+ Return a dict of the data which the view requires for each library
+ """
+ return {
+ 'display_name': library.display_name,
+ 'library_key': unicode(library.location.library_key),
+ 'url': reverse_library_url('library_handler', unicode(library.location.library_key)),
+ 'org': library.display_org_with_default,
+ 'number': library.display_number_with_default,
+ }
+
# remove any courses in courses that are also in the in_process_course_actions list
in_process_action_course_keys = [uca.course_key for uca in in_process_course_actions]
courses = [
@@ -406,6 +430,8 @@ def format_in_process_course_view(uca):
return render_to_response('index.html', {
'courses': courses,
'in_process_course_actions': in_process_course_actions,
+ 'libraries_enabled': LIBRARIES_ENABLED,
+ 'libraries': [format_library_for_view(lib) for lib in libraries],
'user': request.user,
'request_course_creator_url': reverse('contentstore.views.request_course_creator'),
'course_creator_status': _get_course_creator_status(request.user),
diff --git a/cms/djangoapps/contentstore/views/helpers.py b/cms/djangoapps/contentstore/views/helpers.py
index 34ef869f170f..3769c81978fd 100644
--- a/cms/djangoapps/contentstore/views/helpers.py
+++ b/cms/djangoapps/contentstore/views/helpers.py
@@ -13,7 +13,7 @@
from edxmako.shortcuts import render_to_string, render_to_response
from xblock.core import XBlock
from xmodule.modulestore.django import modulestore
-from contentstore.utils import reverse_course_url, reverse_usage_url
+from contentstore.utils import reverse_course_url, reverse_library_url, reverse_usage_url
__all__ = ['edge', 'event', 'landing']
@@ -106,6 +106,9 @@ 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':
+ library_key = xblock.location.course_key
+ return reverse_library_url('library_handler', library_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 e09db7a4e499..c42fb711fb48 100644
--- a/cms/djangoapps/contentstore/views/item.py
+++ b/cms/djangoapps/contentstore/views/item.py
@@ -47,6 +47,7 @@
from models.settings.course_grading import CourseGradingModel
from cms.lib.xblock.runtime import handler_url, local_resource_url
from opaque_keys.edx.keys import UsageKey, CourseKey
+from opaque_keys.edx.locator import LibraryUsageLocator
__all__ = ['orphan_handler', 'xblock_handler', 'xblock_view_handler', 'xblock_outline_handler']
@@ -649,7 +650,9 @@ def _get_module_info(xblock, rewrite_static_links=True):
)
# Pre-cache has changes for the entire course because we'll need it for the ancestor info
- modulestore().has_changes(modulestore().get_course(xblock.location.course_key, depth=None))
+ # Except library blocks which don't use draft/publish
+ if not isinstance(xblock.location, LibraryUsageLocator):
+ modulestore().has_changes(modulestore().get_courselike(xblock.location.course_key, depth=None))
# Note that children aren't being returned until we have a use case.
return create_xblock_info(xblock, data=data, metadata=own_metadata(xblock), include_ancestor_info=True)
@@ -690,12 +693,16 @@ def safe_get_username(user_id):
return None
+ is_library_block = isinstance(xblock.location, LibraryUsageLocator)
is_xblock_unit = is_unit(xblock, parent_xblock)
- # this should not be calculated for Sections and Subsections on Unit page
- has_changes = modulestore().has_changes(xblock) if (is_xblock_unit or course_outline) else None
+ # this should not be calculated for Sections and Subsections on Unit page or for library blocks
+ has_changes = modulestore().has_changes(xblock) if (is_xblock_unit or course_outline) and not is_library_block else None
if graders is None:
- graders = CourseGradingModel.fetch(xblock.location.course_key).graders
+ if not is_library_block:
+ 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)
@@ -715,7 +722,7 @@ def safe_get_username(user_id):
visibility_state = _compute_visibility_state(xblock, child_info, is_xblock_unit and has_changes)
else:
visibility_state = None
- published = modulestore().has_published_version(xblock)
+ published = modulestore().has_published_version(xblock) if not is_library_block else None
xblock_info = {
"id": unicode(xblock.location),
@@ -723,7 +730,7 @@ def safe_get_username(user_id):
"category": xblock.category,
"edited_on": get_default_time_display(xblock.subtree_edited_on) if xblock.subtree_edited_on else None,
"published": published,
- "published_on": get_default_time_display(xblock.published_on) if xblock.published_on else None,
+ "published_on": get_default_time_display(xblock.published_on) if published and xblock.published_on else None,
"studio_url": xblock_studio_url(xblock, parent_xblock),
"released_to_students": datetime.now(UTC) > xblock.start,
"release_date": release_date,
diff --git a/cms/djangoapps/contentstore/views/library.py b/cms/djangoapps/contentstore/views/library.py
new file mode 100644
index 000000000000..7ea3ebf21932
--- /dev/null
+++ b/cms/djangoapps/contentstore/views/library.py
@@ -0,0 +1,162 @@
+"""
+Views related to content libraries.
+A content library is a structure containing XBlocks which can be re-used in the
+multiple courses.
+"""
+from __future__ import absolute_import
+
+import json
+import logging
+
+from contentstore.views.item import create_xblock_info
+from contentstore.utils import reverse_library_url
+from django.http import HttpResponseNotAllowed, 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 import InvalidKeyError
+from opaque_keys.edx.keys import CourseKey
+from opaque_keys.edx.locator import LibraryLocator, LibraryUsageLocator
+from xmodule.modulestore.exceptions import DuplicateCourseError
+from xmodule.modulestore import ModuleStoreEnum
+from xmodule.modulestore.django import modulestore
+
+from .access import has_course_access
+from .component import get_component_templates
+from student.roles import CourseCreatorRole
+from student import auth
+from util.json_request import expect_json, JsonResponse, JsonResponseBadRequest
+
+__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, library_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 library_key_string:
+ library_key = CourseKey.from_string(library_key_string)
+ if not isinstance(library_key, LibraryLocator):
+ raise Http404 # This is not a library
+ if not has_course_access(request.user, library_key):
+ raise PermissionDenied()
+
+ library = modulestore().get_library(library_key)
+ if library is None:
+ raise Http404
+
+ if request.method == 'GET':
+ return library_blocks_view(library, response_format)
+ return HttpResponseNotAllowed(['GET'])
+
+ elif request.method == 'POST':
+ # Create a new library:
+ return _create_library(request)
+ elif request.method == 'GET':
+ # List all accessible libraries:
+ lib_info = [
+ {
+ "display_name": lib.display_name,
+ "library_key": unicode(lib.location.library_key),
+ }
+ for lib in modulestore().get_libraries()
+ if has_course_access(request.user, lib.location.library_key)
+ ]
+ return JsonResponse(lib_info)
+ else:
+ return HttpResponseNotAllowed(['GET', 'POST'])
+
+
+@expect_json
+def _create_library(request):
+ """
+ Helper method for creating a new library.
+ """
+ if not auth.has_access(request.user, CourseCreatorRole()):
+ raise PermissionDenied()
+ try:
+ org = request.json['org']
+ library = request.json.get('number', None)
+ if library is None:
+ library = request.json['library']
+ display_name = request.json['display_name']
+ store = modulestore()
+ with store.default_store(ModuleStoreEnum.Type.split):
+ new_lib = store.create_library(
+ org=org,
+ library=library,
+ user_id=request.user.id,
+ fields={"display_name": display_name},
+ )
+ except KeyError as error:
+ return JsonResponseBadRequest({
+ "ErrMsg": _("Unable to create library - missing expected JSON key '{err}'").format(err=error.message)}
+ )
+ except InvalidKeyError as error:
+ return JsonResponseBadRequest({
+ "ErrMsg": _("Unable to create library - invalid data.\n\n{err}").format(name=display_name, err=error.message)}
+ )
+ except DuplicateCourseError as error:
+ return JsonResponseBadRequest({
+ "ErrMsg": _("Unable to create library - one already exists with that key.\n\n{err}").format(err=error.message)}
+ )
+
+ lib_key_str = unicode(new_lib.location.library_key)
+ return JsonResponse({
+ 'url': reverse_library_url('library_handler', lib_key_str),
+ 'library_key': lib_key_str,
+ })
+
+
+def library_blocks_view(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],
+ })
+
+ xblock_info = create_xblock_info(library, include_ancestor_info=False, graders=[])
+
+ component_templates = get_component_templates(library)
+
+ assert isinstance(library.location.library_key, LibraryLocator)
+ assert isinstance(library.location, LibraryUsageLocator)
+
+ return render_to_response('library.html', {
+ 'context_library': library,
+ 'action': 'view',
+ 'xblock': library,
+ 'xblock_locator': library.location,
+ 'unit': None,
+ 'component_templates': json.dumps(component_templates),
+ 'xblock_info': xblock_info,
+ })
diff --git a/cms/djangoapps/contentstore/views/tests/test_course_index.py b/cms/djangoapps/contentstore/views/tests/test_course_index.py
index eb8adc5ea739..86c9d68d020a 100644
--- a/cms/djangoapps/contentstore/views/tests/test_course_index.py
+++ b/cms/djangoapps/contentstore/views/tests/test_course_index.py
@@ -6,7 +6,7 @@
import datetime
from contentstore.tests.utils import CourseTestCase
-from contentstore.utils import reverse_course_url, add_instructor
+from contentstore.utils import reverse_course_url, reverse_library_url, add_instructor
from contentstore.views.access import has_course_access
from contentstore.views.course import course_outline_initial_state
from contentstore.views.item import create_xblock_info, VisibilityState
@@ -14,7 +14,7 @@
from util.date_utils import get_default_time_display
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore
-from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
+from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory, LibraryFactory
from opaque_keys.edx.locator import CourseLocator
from student.tests.factories import UserFactory
from course_action_state.managers import CourseRerunUIStateManager
@@ -61,6 +61,27 @@ def check_index_and_outline(self, authed_client):
course_menu_link = outline_parsed.find_class('nav-course-courseware-outline')[0]
self.assertEqual(course_menu_link.find("a").get("href"), link.get("href"))
+ def test_libraries_on_course_index(self):
+ """
+ Test getting the list of libraries from the course listing page
+ """
+ # Add a library:
+ lib1 = LibraryFactory.create()
+
+ index_url = '/course/'
+ index_response = self.client.get(index_url, {}, HTTP_ACCEPT='text/html')
+ parsed_html = lxml.html.fromstring(index_response.content)
+ library_link_eles = parsed_html.find_class('library-link')
+ self.assertEqual(len(library_link_eles), 1)
+ link = library_link_eles[0]
+ self.assertEqual(
+ link.get("href"),
+ reverse_library_url('library_handler', lib1.location.library_key),
+ )
+ # now test that url
+ outline_response = self.client.get(link.get("href"), {}, HTTP_ACCEPT='text/html')
+ self.assertEqual(outline_response.status_code, 200)
+
def test_is_staff_access(self):
"""
Test that people with is_staff see the courses and can navigate into them
diff --git a/cms/djangoapps/contentstore/views/tests/test_helpers.py b/cms/djangoapps/contentstore/views/tests/test_helpers.py
index 034a9002fb76..576ea388081d 100644
--- a/cms/djangoapps/contentstore/views/tests/test_helpers.py
+++ b/cms/djangoapps/contentstore/views/tests/test_helpers.py
@@ -4,7 +4,7 @@
from contentstore.tests.utils import CourseTestCase
from contentstore.views.helpers import xblock_studio_url, xblock_type_display_name
-from xmodule.modulestore.tests.factories import ItemFactory
+from xmodule.modulestore.tests.factories import ItemFactory, LibraryFactory
from django.utils import http
@@ -50,6 +50,11 @@ def test_xblock_studio_url(self):
display_name="My Video")
self.assertIsNone(xblock_studio_url(video))
+ # Verify library URL
+ library = LibraryFactory.create()
+ expected_url = u'/library/{}'.format(unicode(library.location.library_key))
+ self.assertEqual(xblock_studio_url(library), expected_url)
+
def test_xblock_type_display_name(self):
# Verify chapter type display name
diff --git a/cms/djangoapps/contentstore/views/tests/test_item.py b/cms/djangoapps/contentstore/views/tests/test_item.py
index a1296a94951d..e99fb27afa37 100644
--- a/cms/djangoapps/contentstore/views/tests/test_item.py
+++ b/cms/djangoapps/contentstore/views/tests/test_item.py
@@ -24,7 +24,8 @@
from xmodule.capa_module import CapaDescriptor
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore
-from xmodule.modulestore.tests.factories import ItemFactory, check_mongo_calls
+from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
+from xmodule.modulestore.tests.factories import ItemFactory, LibraryFactory, check_mongo_calls
from xmodule.x_module import STUDIO_VIEW, STUDENT_VIEW
from xblock.exceptions import NoSuchHandlerError
from opaque_keys.edx.keys import UsageKey, CourseKey
@@ -1382,6 +1383,51 @@ def validate_xblock_info_consistency(self, xblock_info, has_ancestor_info=False,
self.assertIsNone(xblock_info.get('edited_by', None))
+class TestLibraryXBlockInfo(ModuleStoreTestCase):
+ """
+ Unit tests for XBlock Info for XBlocks in a content library
+ """
+ def setUp(self):
+ super(TestLibraryXBlockInfo, self).setUp()
+ user_id = self.user.id
+ self.library = LibraryFactory.create()
+ self.top_level_html = ItemFactory.create(
+ parent_location=self.library.location, category='html', user_id=user_id, publish_item=False
+ )
+ self.vertical = ItemFactory.create(
+ parent_location=self.library.location, category='vertical', user_id=user_id, publish_item=False
+ )
+ self.child_html = ItemFactory.create(
+ parent_location=self.vertical.location, category='html', display_name='Test HTML Child Block', user_id=user_id, publish_item=False
+ )
+
+ def test_lib_xblock_info(self):
+ html_block = modulestore().get_item(self.top_level_html.location)
+ xblock_info = create_xblock_info(html_block)
+ self.validate_component_xblock_info(xblock_info, html_block)
+ self.assertIsNone(xblock_info.get('child_info', None))
+
+ def test_lib_child_xblock_info(self):
+ html_block = modulestore().get_item(self.child_html.location)
+ xblock_info = create_xblock_info(html_block, include_ancestor_info=True, include_child_info=True)
+ self.validate_component_xblock_info(xblock_info, html_block)
+ self.assertIsNone(xblock_info.get('child_info', None))
+ ancestors = xblock_info['ancestor_info']['ancestors']
+ self.assertEqual(len(ancestors), 2)
+ self.assertEqual(ancestors[0]['category'], 'vertical')
+ self.assertEqual(ancestors[0]['id'], unicode(self.vertical.location))
+ self.assertEqual(ancestors[1]['category'], 'library')
+
+ def validate_component_xblock_info(self, xblock_info, original_block):
+ """
+ Validate that the xblock info is correct for the test component.
+ """
+ self.assertEqual(xblock_info['category'], original_block.category)
+ self.assertEqual(xblock_info['id'], unicode(original_block.location))
+ self.assertEqual(xblock_info['display_name'], original_block.display_name)
+ self.assertIsNone(xblock_info.get('published_on', None))
+
+
class TestXBlockPublishingInfo(ItemTest):
"""
Unit tests for XBlock's outline handling.
diff --git a/cms/djangoapps/contentstore/views/tests/test_library.py b/cms/djangoapps/contentstore/views/tests/test_library.py
new file mode 100644
index 000000000000..abf1e610e881
--- /dev/null
+++ b/cms/djangoapps/contentstore/views/tests/test_library.py
@@ -0,0 +1,154 @@
+"""
+Unit tests for contentstore.views.library
+
+More important high-level tests are in contentstore/tests/test_libraries.py
+"""
+from contentstore.tests.utils import AjaxEnabledTestClient, parse_json
+from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
+from xmodule.modulestore.tests.factories import LibraryFactory
+from mock import patch
+from opaque_keys.edx.locator import CourseKey, LibraryLocator
+import ddt
+
+LIBRARY_REST_URL = '/library/' # URL for GET/POST requests involving libraries
+
+
+def make_url_for_lib(key):
+ """ Get the RESTful/studio URL for testing the given library """
+ if isinstance(key, LibraryLocator):
+ key = unicode(key)
+ return LIBRARY_REST_URL + key
+
+
+@ddt.ddt
+class UnitTestLibraries(ModuleStoreTestCase):
+ """
+ Unit tests for library views
+ """
+
+ def setUp(self):
+ user_password = super(UnitTestLibraries, self).setUp()
+
+ self.client = AjaxEnabledTestClient()
+ self.client.login(username=self.user.username, password=user_password)
+
+ ######################################################
+ # Tests for /library/ - list and create libraries:
+
+ @patch("contentstore.views.library.LIBRARIES_ENABLED", False)
+ def test_with_libraries_disabled(self):
+ """
+ The library URLs should return 404 if libraries are disabled.
+ """
+ response = self.client.get_json(LIBRARY_REST_URL)
+ self.assertEqual(response.status_code, 404)
+
+ def test_list_libraries(self):
+ """
+ Test that we can GET /library/ to list all libraries visible to the current user.
+ """
+ # Create some more libraries
+ libraries = [LibraryFactory.create() for _ in range(0, 3)]
+ lib_dict = dict([(lib.location.library_key, lib) for lib in libraries])
+
+ response = self.client.get_json(LIBRARY_REST_URL)
+ self.assertEqual(response.status_code, 200)
+ lib_list = parse_json(response)
+ self.assertEqual(len(lib_list), len(libraries))
+ for entry in lib_list:
+ self.assertIn("library_key", entry)
+ self.assertIn("display_name", entry)
+ key = CourseKey.from_string(entry["library_key"])
+ self.assertIn(key, lib_dict)
+ self.assertEqual(entry["display_name"], lib_dict[key].display_name)
+ del lib_dict[key] # To ensure no duplicates are matched
+
+ @ddt.data("delete", "put")
+ def test_bad_http_verb(self, verb):
+ """
+ We should get an error if we do weird requests to /library/
+ """
+ response = getattr(self.client, verb)(LIBRARY_REST_URL)
+ self.assertEqual(response.status_code, 405)
+
+ def test_create_library(self):
+ """ Create a library. """
+ response = self.client.ajax_post(LIBRARY_REST_URL, {
+ 'org': 'org',
+ 'library': 'lib',
+ 'display_name': "New Library",
+ })
+ self.assertEqual(response.status_code, 200)
+ # That's all we check. More detailed tests are in contentstore.tests.test_libraries...
+
+ @ddt.data(
+ {},
+ {'org': 'org'},
+ {'library': 'lib'},
+ {'org': 'C++', 'library': 'lib', 'display_name': 'Lib with invalid characters in key'},
+ {'org': 'Org', 'library': 'Wh@t?', 'display_name': 'Lib with invalid characters in key'},
+ )
+ def test_create_library_invalid(self, data):
+ """
+ Make sure we are prevented from creating libraries with invalid keys/data
+ """
+ response = self.client.ajax_post(LIBRARY_REST_URL, data)
+ self.assertEqual(response.status_code, 400)
+
+ def test_no_duplicate_libraries(self):
+ """
+ We should not be able to create multiple libraries with the same key
+ """
+ lib = LibraryFactory.create()
+ lib_key = lib.location.library_key
+ response = self.client.ajax_post(LIBRARY_REST_URL, {
+ 'org': lib_key.org,
+ 'library': lib_key.library,
+ 'display_name': "A Duplicate key, same as 'lib'",
+ })
+ self.assertIn('duplicate', parse_json(response)['ErrMsg'])
+ self.assertEqual(response.status_code, 400)
+
+ ######################################################
+ # Tests for /library/:lib_key/ - get a specific library as JSON or HTML editing view
+
+ def test_get_lib_info(self):
+ """
+ Test that we can get data about a library (in JSON format) using /library/:key/
+ """
+ # Create a library
+ lib_key = LibraryFactory.create().location.library_key
+ # Re-load the library from the modulestore, explicitly including version information:
+ print("\n\nLoading now:")
+ lib = self.store.get_library(lib_key, remove_version=False, remove_branch=False)
+ version = lib.location.library_key.version_guid
+ print(repr(self.store))
+ print(repr(lib.location.library_key))
+ self.assertNotEqual(version, None)
+
+ response = self.client.get_json(make_url_for_lib(lib_key))
+ self.assertEqual(response.status_code, 200)
+ info = parse_json(response)
+ self.assertEqual(info['display_name'], lib.display_name)
+ self.assertEqual(info['library_id'], unicode(lib_key))
+ self.assertEqual(info['previous_version'], None)
+ self.assertNotEqual(info['version'], None)
+ self.assertNotEqual(info['version'], '')
+ self.assertEqual(info['version'], unicode(version))
+
+ @ddt.data('library-v1:Nonexistent+library', 'course-v1:Org+Course', 'course-v1:Org+Course+Run', 'invalid')
+ def test_invalid_keys(self, key_str):
+ """
+ Check that various Nonexistent/invalid keys give 404 errors
+ """
+ response = self.client.get_json(make_url_for_lib(key_str))
+ self.assertEqual(response.status_code, 404)
+
+ def test_bad_http_verb_with_lib_key(self):
+ """
+ We should get an error if we do weird requests to /library/
+ """
+ lib = LibraryFactory.create()
+ for verb in ("post", "delete", "put"):
+ response = getattr(self.client, verb)(make_url_for_lib(lib.location.library_key))
+ self.assertEqual(response.status_code, 405)
diff --git a/cms/envs/common.py b/cms/envs/common.py
index c542b373b702..6db86414b2c3 100644
--- a/cms/envs/common.py
+++ b/cms/envs/common.py
@@ -728,6 +728,7 @@
'word_cloud',
'graphical_slider_tool',
'lti',
+ 'library_content',
# XBlocks from pmitros repos are prototypes. They should not be used
# except for edX Learning Sciences experiments on edge.edx.org without
# further work to make them robust, maintainable, finalize data formats,
diff --git a/cms/envs/test.py b/cms/envs/test.py
index 36b9e54e294d..5b06ccd33f1b 100644
--- a/cms/envs/test.py
+++ b/cms/envs/test.py
@@ -224,3 +224,6 @@
# For consistency in user-experience, keep the value of this setting in sync with
# the one in lms/envs/test.py
FEATURES['ENABLE_DISCUSSION_SERVICE'] = False
+
+# Enable content libraries code for the tests
+FEATURES['ENABLE_CONTENT_LIBRARIES'] = True
diff --git a/cms/static/js/index.js b/cms/static/js/index.js
index c9843101976b..02414dea6c14 100644
--- a/cms/static/js/index.js
+++ b/cms/static/js/index.js
@@ -1,16 +1,16 @@
define(["domReady", "jquery", "underscore", "js/utils/cancel_on_escape", "js/views/utils/create_course_utils",
- "js/views/utils/view_utils"],
- function (domReady, $, _, CancelOnEscape, CreateCourseUtilsFactory, ViewUtils) {
+ "js/views/utils/create_library_utils", "js/views/utils/view_utils"],
+ function (domReady, $, _, CancelOnEscape, CreateCourseUtilsFactory, CreateLibraryUtilsFactory, ViewUtils) {
var CreateCourseUtils = CreateCourseUtilsFactory({
name: '.new-course-name',
org: '.new-course-org',
number: '.new-course-number',
run: '.new-course-run',
save: '.new-course-save',
- errorWrapper: '.wrap-error',
+ errorWrapper: '.create-course .wrap-error',
errorMessage: '#course_creation_error',
- tipError: 'span.tip-error',
- error: '.error',
+ tipError: '.create-course span.tip-error',
+ error: '.create-course .error',
allowUnicode: '.allow-unicode-course-id'
}, {
shown: 'is-shown',
@@ -20,6 +20,24 @@ define(["domReady", "jquery", "underscore", "js/utils/cancel_on_escape", "js/vie
error: 'error'
});
+ var CreateLibraryUtils = CreateLibraryUtilsFactory({
+ name: '.new-library-name',
+ org: '.new-library-org',
+ number: '.new-library-number',
+ save: '.new-library-save',
+ errorWrapper: '.create-library .wrap-error',
+ errorMessage: '#library_creation_error',
+ tipError: '.create-library span.tip-error',
+ error: '.create-library .error',
+ allowUnicode: '.allow-unicode-library-id'
+ }, {
+ shown: 'is-shown',
+ showing: 'is-showing',
+ hiding: 'is-hiding',
+ disabled: 'is-disabled',
+ error: 'error'
+ });
+
var saveNewCourse = function (e) {
e.preventDefault();
@@ -42,7 +60,7 @@ define(["domReady", "jquery", "underscore", "js/utils/cancel_on_escape", "js/vie
analytics.track('Created a Course', course_info);
CreateCourseUtils.createCourse(course_info, function (errorMessage) {
- $('.wrap-error').addClass('is-shown');
+ $('.create-course .wrap-error').addClass('is-shown');
$('#course_creation_error').html('
' + errorMessage + '
');
$('.new-course-save').addClass('is-disabled');
});
@@ -60,7 +78,7 @@ define(["domReady", "jquery", "underscore", "js/utils/cancel_on_escape", "js/vie
}
);
$('#course_creation_error').html('');
- $('.wrap-error').removeClass('is-shown');
+ $('.create-course .wrap-error').removeClass('is-shown');
$('.new-course-save').off('click');
};
@@ -79,12 +97,78 @@ define(["domReady", "jquery", "underscore", "js/utils/cancel_on_escape", "js/vie
CreateCourseUtils.configureHandlers();
};
+ var saveNewLibrary = function (e) {
+ e.preventDefault();
+
+ if (CreateLibraryUtils.hasInvalidRequiredFields()) {
+ return;
+ }
+
+ var $newLibraryForm = $(this).closest('#create-library-form');
+ var display_name = $newLibraryForm.find('.new-library-name').val();
+ var org = $newLibraryForm.find('.new-library-org').val();
+ var number = $newLibraryForm.find('.new-library-number').val();
+
+ lib_info = {
+ org: org,
+ number: number,
+ display_name: display_name,
+ };
+
+ analytics.track('Created a Library', lib_info);
+ CreateLibraryUtils.createLibrary(lib_info, function (errorMessage) {
+ $('.create-library .wrap-error').addClass('is-shown');
+ $('#library_creation_error').html('' + errorMessage + '
');
+ $('.new-library-save').addClass('is-disabled');
+ });
+ };
+
+ var cancelNewLibrary = function (e) {
+ e.preventDefault();
+ $('.new-library-button').removeClass('is-disabled');
+ $('.wrapper-create-library').removeClass('is-shown');
+ // Clear out existing fields and errors
+ _.each(
+ ['.new-library-name', '.new-library-org', '.new-library-number'],
+ function (field) { $(field).val(''); }
+ );
+ $('#library_creation_error').html('');
+ $('.create-library .wrap-error').removeClass('is-shown');
+ $('.new-library-save').off('click');
+ };
+
+ var addNewLibrary = function (e) {
+ e.preventDefault();
+ $('.new-library-button').addClass('is-disabled');
+ $('.new-library-save').addClass('is-disabled');
+ var $newLibrary = $('.wrapper-create-library').addClass('is-shown');
+ var $cancelButton = $newLibrary.find('.new-library-cancel');
+ var $libraryName = $('.new-library-name');
+ $libraryName.focus().select();
+ $('.new-library-save').on('click', saveNewLibrary);
+ $cancelButton.bind('click', cancelNewLibrary);
+ CancelOnEscape($cancelButton);
+
+ CreateLibraryUtils.configureHandlers();
+ };
+
+ var showTab = function(tab) {
+ return function(e) {
+ e.preventDefault();
+ $('.courses-tab').toggleClass('active', tab === 'courses');
+ $('.libraries-tab').toggleClass('active', tab === 'libraries');
+ }
+ };
+
var onReady = function () {
$('.new-course-button').bind('click', addNewCourse);
+ $('.new-library-button').bind('click', addNewLibrary);
$('.dismiss-button').bind('click', ViewUtils.deleteNotificationHandler(function () {
ViewUtils.reload();
}));
$('.action-reload').bind('click', ViewUtils.reload);
+ $('#course-index-tabs .courses-tab').bind('click', showTab('courses'));
+ $('#course-index-tabs .libraries-tab').bind('click', showTab('libraries'));
};
domReady(onReady);
diff --git a/cms/static/js/spec/views/pages/course_rerun_spec.js b/cms/static/js/spec/views/pages/course_rerun_spec.js
index bcf881b98685..320f8d6557ec 100644
--- a/cms/static/js/spec/views/pages/course_rerun_spec.js
+++ b/cms/static/js/spec/views/pages/course_rerun_spec.js
@@ -49,12 +49,12 @@ define(["jquery", "js/common_helpers/ajax_helpers", "js/spec_helpers/view_helper
describe("Field validation", function () {
it("returns a message for an empty string", function () {
- var message = CreateCourseUtils.validateRequiredField('');
+ var message = ViewUtils.validateRequiredField('');
expect(message).not.toBe('');
});
it("does not return a message for a non empty string", function () {
- var message = CreateCourseUtils.validateRequiredField('edX');
+ var message = ViewUtils.validateRequiredField('edX');
expect(message).toBe('');
});
});
diff --git a/cms/static/js/views/utils/create_course_utils.js b/cms/static/js/views/utils/create_course_utils.js
index 2c0c3493ac95..88fba4a0136e 100644
--- a/cms/static/js/views/utils/create_course_utils.js
+++ b/cms/static/js/views/utils/create_course_utils.js
@@ -4,31 +4,11 @@
define(["jquery", "underscore", "gettext", "js/views/utils/view_utils"],
function ($, _, gettext, ViewUtils) {
return function (selectors, classes) {
- var validateRequiredField, validateCourseItemEncoding, validateTotalCourseItemsLength, setNewCourseFieldInErr,
- hasInvalidRequiredFields, createCourse, validateFilledFields, configureHandlers;
+ var validateTotalCourseItemsLength, setNewCourseFieldInErr, hasInvalidRequiredFields,
+ createCourse, validateFilledFields, configureHandlers;
- validateRequiredField = function (msg) {
- return msg.length === 0 ? gettext('Required field.') : '';
- };
-
- // Check that a course (org, number, run) doesn't use any special characters
- validateCourseItemEncoding = function (item) {
- var required = validateRequiredField(item);
- if (required) {
- return required;
- }
- if ($(selectors.allowUnicode).val() === 'True') {
- if (/\s/g.test(item)) {
- return gettext('Please do not use any spaces in this field.');
- }
- }
- else {
- if (item !== encodeURIComponent(item)) {
- return gettext('Please do not use any spaces or special characters in this field.');
- }
- }
- return '';
- };
+ var validateRequiredField = ViewUtils.validateRequiredField;
+ var validateURLItemEncoding = ViewUtils.validateURLItemEncoding;
// Ensure that org/course_num/run < 65 chars.
validateTotalCourseItemsLength = function () {
@@ -117,7 +97,7 @@ define(["jquery", "underscore", "gettext", "js/views/utils/view_utils"],
if (event.keyCode === 9) {
return;
}
- var error = validateCourseItemEncoding($ele.val());
+ var error = validateURLItemEncoding($ele.val(), $(selectors.allowUnicode).val() === 'True');
setNewCourseFieldInErr($ele.parent(), error);
validateTotalCourseItemsLength();
if (!validateFilledFields()) {
@@ -138,8 +118,6 @@ define(["jquery", "underscore", "gettext", "js/views/utils/view_utils"],
};
return {
- validateRequiredField: validateRequiredField,
- validateCourseItemEncoding: validateCourseItemEncoding,
validateTotalCourseItemsLength: validateTotalCourseItemsLength,
setNewCourseFieldInErr: setNewCourseFieldInErr,
hasInvalidRequiredFields: hasInvalidRequiredFields,
diff --git a/cms/static/js/views/utils/create_library_utils.js b/cms/static/js/views/utils/create_library_utils.js
new file mode 100644
index 000000000000..44acb0e97778
--- /dev/null
+++ b/cms/static/js/views/utils/create_library_utils.js
@@ -0,0 +1,129 @@
+/**
+ * Provides utilities for validating libraries during creation.
+ */
+define(["jquery", "underscore", "gettext", "js/views/utils/view_utils"],
+ function ($, _, gettext, ViewUtils) {
+ return function (selectors, classes) {
+ var validateTotalKeyLength, setNewLibraryFieldInErr, hasInvalidRequiredFields,
+ createLibrary, validateFilledFields, configureHandlers;
+
+ var validateRequiredField = ViewUtils.validateRequiredField;
+ var validateURLItemEncoding = ViewUtils.validateURLItemEncoding;
+
+ // Ensure that org/librarycode < 65 chars.
+ validateTotalKeyLength = function () {
+ var totalLength = _.reduce(
+ [selectors.org, selectors.number],
+ function (sum, ele) {
+ return sum + $(ele).val().length;
+ }, 0
+ );
+ if (totalLength > 65) {
+ $(selectors.errorWrapper).addClass(classes.shown).removeClass(classes.hiding);
+ $(selectors.errorMessage).html('' + gettext('The combined length of the organization and library code fields cannot be more than 65 characters.') + '
');
+ $(selectors.save).addClass(classes.disabled);
+ }
+ else {
+ $(selectors.errorWrapper).removeClass(classes.shown).addClass(classes.hiding);
+ }
+ };
+
+ setNewLibraryFieldInErr = function (el, msg) {
+ if (msg) {
+ el.addClass(classes.error);
+ el.children(selectors.tipError).addClass(classes.showing).removeClass(classes.hiding).text(msg);
+ $(selectors.save).addClass(classes.disabled);
+ }
+ else {
+ el.removeClass(classes.error);
+ el.children(selectors.tipError).addClass(classes.hiding).removeClass(classes.showing);
+ // One "error" div is always present, but hidden or shown
+ if ($(selectors.error).length === 1) {
+ $(selectors.save).removeClass(classes.disabled);
+ }
+ }
+ };
+
+ // One final check for empty values
+ hasInvalidRequiredFields = function () {
+ return _.reduce(
+ [selectors.name, selectors.org, selectors.number],
+ function (acc, ele) {
+ var $ele = $(ele);
+ var error = validateRequiredField($ele.val());
+ setNewLibraryFieldInErr($ele.parent(), error);
+ return error ? true : acc;
+ },
+ false
+ );
+ };
+
+ createLibrary = function (libraryInfo, errorHandler) {
+ $.postJSON(
+ '/library/',
+ libraryInfo,
+ function (data) {
+ if (data.url !== undefined) {
+ ViewUtils.redirect(data.url);
+ } else if (data.ErrMsg !== undefined) {
+ errorHandler(data.ErrMsg);
+ }
+ }
+ );
+ };
+
+ // Ensure that all fields are not empty
+ validateFilledFields = function () {
+ return _.reduce(
+ [selectors.org, selectors.number, selectors.name],
+ function (acc, ele) {
+ var $ele = $(ele);
+ return $ele.val().length !== 0 ? acc : false;
+ },
+ true
+ );
+ };
+
+ // Handle validation asynchronously
+ configureHandlers = function () {
+ _.each(
+ [selectors.org, selectors.number],
+ function (ele) {
+ var $ele = $(ele);
+ $ele.on('keyup', function (event) {
+ // Don't bother showing "required field" error when
+ // the user tabs into a new field; this is distracting
+ // and unnecessary
+ if (event.keyCode === 9) {
+ return;
+ }
+ var error = validateURLItemEncoding($ele.val(), $(selectors.allowUnicode).val() === 'True');
+ setNewLibraryFieldInErr($ele.parent(), error);
+ validateTotalKeyLength();
+ if (!validateFilledFields()) {
+ $(selectors.save).addClass(classes.disabled);
+ }
+ });
+ }
+ );
+ var $name = $(selectors.name);
+ $name.on('keyup', function () {
+ var error = validateRequiredField($name.val());
+ setNewLibraryFieldInErr($name.parent(), error);
+ validateTotalKeyLength();
+ if (!validateFilledFields()) {
+ $(selectors.save).addClass(classes.disabled);
+ }
+ });
+ };
+
+ return {
+ validateTotalKeyLength: validateTotalKeyLength,
+ setNewLibraryFieldInErr: setNewLibraryFieldInErr,
+ hasInvalidRequiredFields: hasInvalidRequiredFields,
+ createLibrary: createLibrary,
+ validateFilledFields: validateFilledFields,
+ configureHandlers: configureHandlers
+ };
+ };
+ });
diff --git a/cms/static/js/views/utils/view_utils.js b/cms/static/js/views/utils/view_utils.js
index 27d969f523f7..69f05712b357 100644
--- a/cms/static/js/views/utils/view_utils.js
+++ b/cms/static/js/views/utils/view_utils.js
@@ -5,7 +5,8 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification", "js
function ($, _, gettext, NotificationView, PromptView) {
var toggleExpandCollapse, showLoadingIndicator, hideLoadingIndicator, confirmThenRunOperation,
runOperationShowingMessage, disableElementWhileRunning, getScrollOffset, setScrollOffset,
- setScrollTop, redirect, reload, hasChangedAttributes, deleteNotificationHandler;
+ setScrollTop, redirect, reload, hasChangedAttributes, deleteNotificationHandler,
+ validateRequiredField=1, validateURLItemEncoding=2;
/**
* Toggles the expanded state of the current element.
@@ -173,6 +174,35 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification", "js
return false;
};
+ /**
+ * Helper method for course/library creation - verifies a required field is not blank.
+ */
+ validateRequiredField = function (msg) {
+ return msg.length === 0 ? gettext('Required field.') : '';
+ };
+
+ /**
+ * Helper method for course/library creation.
+ * Check that a course (org, number, run) doesn't use any special characters
+ */
+ validateURLItemEncoding = function (item, allowUnicode) {
+ var required = validateRequiredField(item);
+ if (required) {
+ return required;
+ }
+ if (allowUnicode) {
+ if (/\s/g.test(item)) {
+ return gettext('Please do not use any spaces in this field.');
+ }
+ }
+ else {
+ if (item !== encodeURIComponent(item)) {
+ return gettext('Please do not use any spaces or special characters in this field.');
+ }
+ }
+ return '';
+ };
+
return {
'toggleExpandCollapse': toggleExpandCollapse,
'showLoadingIndicator': showLoadingIndicator,
@@ -186,6 +216,8 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification", "js
'setScrollOffset': setScrollOffset,
'redirect': redirect,
'reload': reload,
- 'hasChangedAttributes': hasChangedAttributes
+ 'hasChangedAttributes': hasChangedAttributes,
+ 'validateRequiredField': validateRequiredField,
+ 'validateURLItemEncoding': validateURLItemEncoding
};
});
diff --git a/cms/static/sass/elements/_forms.scss b/cms/static/sass/elements/_forms.scss
index 9ee9ee391951..a101708a7f29 100644
--- a/cms/static/sass/elements/_forms.scss
+++ b/cms/static/sass/elements/_forms.scss
@@ -394,7 +394,6 @@ form {
// ELEM: form wrapper
.wrapper-create-element {
height: 0;
- margin-bottom: $baseline;
opacity: 0.0;
pointer-events: none;
overflow: hidden;
@@ -405,6 +404,7 @@ form {
&.is-shown {
height: auto; // define a specific height for the animating version of this UI to work properly
+ margin-bottom: $baseline;
opacity: 1.0;
pointer-events: auto;
}
diff --git a/cms/static/sass/views/_dashboard.scss b/cms/static/sass/views/_dashboard.scss
index 494500fd4d28..c20ecb8c5d72 100644
--- a/cms/static/sass/views/_dashboard.scss
+++ b/cms/static/sass/views/_dashboard.scss
@@ -289,10 +289,42 @@
// ====================
+ // Course/Library tabs
+ #course-index-tabs {
+ margin: 0;
+ font-size: 1.4rem;
+
+ li {
+ display: inline-block;
+ line-height: $baseline*2;
+ margin: 0 10px;
+
+ &.active, &:hover {
+ border-bottom: 4px solid $blue;
+ }
+
+ a {
+ color: $blue;
+ cursor: pointer;
+ display: inline-block;
+ }
+
+ &.active a {
+ color: $gray-d2;
+ }
+ }
+ }
+
// ELEM: course listings
- .courses {
- margin: $baseline 0;
+ .courses-tab, .libraries-tab {
+ display: none;
+
+ &.active {
+ display: block;
+ }
+ }
+ .courses, .libraries {
.title {
@extend %t-title6;
margin-bottom: $baseline;
@@ -311,7 +343,6 @@
}
.list-courses {
- margin-top: $baseline;
border-radius: 3px;
border: 1px solid $gray-l2;
background: $white;
diff --git a/cms/templates/base.html b/cms/templates/base.html
index 8a8a70c0ded2..0b31ebf4239c 100644
--- a/cms/templates/base.html
+++ b/cms/templates/base.html
@@ -21,6 +21,8 @@
% if context_course:
<% ctx_loc = context_course.location %>
${context_course.display_name_with_default | h} |
+ % elif context_library:
+ ${context_library.display_name_with_default | h} |
% endif
edX Studio
diff --git a/cms/templates/index.html b/cms/templates/index.html
index 7a155f9ceca4..15ba6494c41a 100644
--- a/cms/templates/index.html
+++ b/cms/templates/index.html
@@ -24,6 +24,10 @@ ${_("Page Actions")}
% if course_creator_status=='granted':
${_("New Course")}
+ % if libraries_enabled:
+
+ ${_("New Library")}
+ % endif
% elif course_creator_status=='disallowed_for_this_site' and settings.FEATURES.get('STUDIO_REQUEST_EMAIL',''):
${_("Email staff to create course")}
% endif
@@ -108,6 +112,67 @@ ${_("Create a New Course")}
+
+ %if libraries_enabled:
+
+ % endif
+
% endif
@@ -208,8 +273,15 @@ ${course_info['display_name']}
%endif
+ %if libraries_enabled:
+
+ %endif
+
%if len(courses) > 0:
-
+
%for course_info in sorted(courses, key=lambda s: s['display_name'].lower() if s['display_name'] is not None else ''):
-
@@ -246,10 +318,7 @@
${course_info['display_name']}
%else:
-
-
-
-
+
${_("Are you staff on an existing Studio course?")}
@@ -356,6 +425,41 @@ ${_('Your Course Creator Request Status:')}
% endif
+ %if len(libraries) > 0:
+
+
+ %else:
+
+
+
+
+
${_('You don\'t have any content libraries yet.')}
+
+
+
+
+ %endif
+