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: +
+
+
+ +
+ +
+

${_("Create a New Library")}

+ +
+
+
+

${_("Beta Feature Warning")}

+
+

${_("Content Libraries are a beta feature and should be used for testing/development purposes only. Any libraries you create now may not be compatible with future updates!")}

+
+
+
+
+ +
+ ${_("Required Information to Create a New Library")} + +
    +
  1. + + + ${_("The public display name for your library.")} + +
  2. +
  3. + + + ${_("The name of the organization sponsoring the library.")} ${_("Note: This is part of your library URL, so no spaces or special characters are allowed.")} ${_("This cannot be changed.")} + +
  4. + +
  5. + + + ${_("The unique code that identifies this library.")} ${_("Note: This is part of your library URL, so no spaces or special characters are allowed and it cannot be changed.")} + +
  6. +
+ +
+
+ +
+ + + +
+
+
+ % endif + % endif @@ -208,8 +273,15 @@

${course_info['display_name']}

%endif + %if libraries_enabled: + + %endif + %if len(courses) > 0: -
+
%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 +