From 4cc7b75d16d096703815d380855f8c3b0d1976cb Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Tue, 28 Oct 2014 22:23:26 -0700 Subject: [PATCH] Studio support for creating and editing libraries. SOL-1, SOL-2, SOL-3 --- cms/djangoapps/contentstore/utils.py | 7 + cms/djangoapps/contentstore/views/__init__.py | 1 + .../contentstore/views/component.py | 10 + cms/djangoapps/contentstore/views/course.py | 26 +++ cms/djangoapps/contentstore/views/helpers.py | 5 +- cms/djangoapps/contentstore/views/item.py | 19 +- cms/djangoapps/contentstore/views/library.py | 185 ++++++++++++++++++ .../views/tests/test_course_index.py | 25 ++- .../contentstore/views/tests/test_helpers.py | 7 +- .../contentstore/views/tests/test_item.py | 51 ++++- .../contentstore/views/tests/test_library.py | 185 ++++++++++++++++++ cms/envs/bok_choy.env.json | 3 +- cms/envs/test.py | 3 + cms/static/js/factories/library.js | 23 +++ cms/static/js/index.js | 112 ++++++++--- .../js/spec/views/pages/course_rerun_spec.js | 4 +- cms/static/js/spec/views/pages/index_spec.js | 84 +++++++- .../js/views/utils/create_course_utils.js | 49 +---- .../js/views/utils/create_library_utils.js | 129 ++++++++++++ cms/static/js/views/utils/view_utils.js | 61 +++++- cms/static/sass/elements/_forms.scss | 2 +- cms/static/sass/views/_dashboard.scss | 39 +++- cms/templates/base.html | 2 + cms/templates/container.html | 7 - cms/templates/index.html | 127 +++++++++++- .../js/mock/mock-index-page.underscore | 60 ++++++ cms/templates/library.html | 66 +++++++ cms/urls.py | 6 + common/djangoapps/xmodule_modifiers.py | 7 +- .../modulestore/split_mongo/split_draft.py | 8 +- .../modulestore/tests/test_libraries.py | 11 ++ common/static/coffee/src/xblock/core.coffee | 5 +- common/static/js/spec_helpers/ajax_helpers.js | 13 +- common/test/acceptance/fixtures/base.py | 184 +++++++++++++++++ common/test/acceptance/fixtures/course.py | 182 +---------------- common/test/acceptance/fixtures/library.py | 101 ++++++++++ .../test/acceptance/pages/studio/container.py | 8 +- common/test/acceptance/pages/studio/index.py | 69 +++++++ .../test/acceptance/pages/studio/library.py | 97 +++++++++ common/test/acceptance/pages/studio/utils.py | 24 +++ .../tests/studio/base_studio_test.py | 48 +++++ .../tests/studio/test_studio_home.py | 67 +++++++ .../tests/studio/test_studio_library.py | 104 ++++++++++ 43 files changed, 1944 insertions(+), 282 deletions(-) create mode 100644 cms/djangoapps/contentstore/views/library.py create mode 100644 cms/djangoapps/contentstore/views/tests/test_library.py create mode 100644 cms/static/js/factories/library.js create mode 100644 cms/static/js/views/utils/create_library_utils.js create mode 100644 cms/templates/library.html create mode 100644 common/test/acceptance/fixtures/base.py create mode 100644 common/test/acceptance/fixtures/library.py create mode 100644 common/test/acceptance/pages/studio/library.py create mode 100644 common/test/acceptance/tests/studio/test_studio_home.py create mode 100644 common/test/acceptance/tests/studio/test_studio_library.py 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/component.py b/cms/djangoapps/contentstore/views/component.py index 40209bf37dc9..5fcaea81c4f3 100644 --- a/cms/djangoapps/contentstore/views/component.py +++ b/cms/djangoapps/contentstore/views/component.py @@ -56,6 +56,15 @@ ADVANCED_PROBLEM_TYPES = settings.ADVANCED_PROBLEM_TYPES +CONTAINER_TEMPATES = [ + "basic-modal", "modal-button", "edit-xblock-modal", + "editor-mode-button", "upload-dialog", "image-modal", + "add-xblock-component", "add-xblock-component-button", "add-xblock-component-menu", + "add-xblock-component-menu-problem", "xblock-string-field-editor", "publish-xblock", "publish-history", + "unit-outline", "container-message" +] + + def _advanced_component_types(): """ Return advanced component types which can be created. @@ -216,6 +225,7 @@ def container_handler(request, usage_key_string): 'xblock_info': xblock_info, 'draft_preview_link': preview_lms_link, 'published_preview_link': lms_link, + 'templates': CONTAINER_TEMPATES }) else: return HttpResponseBadRequest("Only supports HTML requests") diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index 69440fe1d85f..bdf2c5d4bd84 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 @@ -396,6 +408,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 = [ @@ -409,6 +433,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..20d2465c9ec3 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 [yet] use draft/publish + if not isinstance(xblock.location, LibraryUsageLocator): + modulestore().has_changes(modulestore().get_course(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..cafe1f32d9e0 --- /dev/null +++ b/cms/djangoapps/contentstore/views/library.py @@ -0,0 +1,185 @@ +""" +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.views.decorators.http import require_http_methods +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, CONTAINER_TEMPATES +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 +@require_http_methods(('GET', 'POST')) +def library_handler(request, library_key_string=None): + """ + RESTful interface to most content library related functionality. + """ + if not LIBRARIES_ENABLED: + log.exception("Attempted to use the content library API when the libraries feature is disabled.") + raise Http404 # Should never happen because we test the feature in urls.py also + + if library_key_string is not None and request.method == 'POST': + return HttpResponseNotAllowed(("POST",)) + + if request.method == 'POST': + return _create_library(request) + + # request method is get, since only GET and POST are allowed by @require_http_methods(('GET', 'POST')) + if library_key_string: + return _display_library(library_key_string, request) + + return _list_libraries(request) + + +def _display_library(library_key_string, request): + """ + Displays single library + """ + library_key = CourseKey.from_string(library_key_string) + if not isinstance(library_key, LibraryLocator): + log.exception("Non-library key passed to content libraries API.") # Should never happen due to url regex + raise Http404 # This is not a library + if not has_course_access(request.user, library_key): + log.exception(u"User %s tried to access library %s without permission", request.user.username, unicode(library_key)) + raise PermissionDenied() + + library = modulestore().get_library(library_key) + if library is None: + log.exception(u"Library %s not found", unicode(library_key)) + raise Http404 + + response_format = 'html' + if request.REQUEST.get('format', 'html') == 'json' or 'application/json' in request.META.get('HTTP_ACCEPT', 'text/html'): + response_format = 'json' + + return library_blocks_view(library, response_format) + + +def _list_libraries(request): + """ + 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) + + +@expect_json +def _create_library(request): + """ + Helper method for creating a new library. + """ + if not auth.has_access(request.user, CourseCreatorRole()): + log.exception(u"User %s tried to create a library without permission", request.user.username) + raise PermissionDenied() + display_name = None + try: + display_name = request.json['display_name'] + org = request.json['org'] + library = request.json.get('number', None) + if library is None: + library = request.json['library'] + 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: + log.exception("Unable to create library - missing required JSON key.") + return JsonResponseBadRequest({ + "ErrMsg": _("Unable to create library - missing required field '{field}'".format(field=error.message)) + }) + except InvalidKeyError as error: + log.exception("Unable to create library - invalid key.") + return JsonResponseBadRequest({ + "ErrMsg": _("Unable to create library '{name}'.\n\n{err}").format(name=display_name, err=error.message)} + ) + except DuplicateCourseError: + log.exception("Unable to create library - one already exists with the same key.") + return JsonResponseBadRequest({ + 'ErrMsg': _( + 'There is already a library defined with the same ' + 'organization and library code. Please ' + 'change either organization or library code to be unique.' + ) + }) + + 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. + """ + assert isinstance(library.location.library_key, LibraryLocator) + assert isinstance(library.location, LibraryUsageLocator) + + 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.course_id), + "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) + + return render_to_response('library.html', { + 'context_library': library, + 'component_templates': json.dumps(component_templates), + 'xblock_info': xblock_info, + 'templates': CONTAINER_TEMPATES + }) diff --git a/cms/djangoapps/contentstore/views/tests/test_course_index.py b/cms/djangoapps/contentstore/views/tests/test_course_index.py index eb8adc5ea739..89205c3fc7ca 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_elements = parsed_html.find_class('library-link') + self.assertEqual(len(library_link_elements), 1) + link = library_link_elements[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 496eee0c5236..b7221e012a66 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 @@ -1419,6 +1420,54 @@ 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('has_changes', None)) + self.assertIsNone(xblock_info.get('published', None)) + self.assertIsNone(xblock_info.get('published_on', None)) + self.assertIsNone(xblock_info.get('graders', 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..8cae9710873f --- /dev/null +++ b/cms/djangoapps/contentstore/views/tests/test_library.py @@ -0,0 +1,185 @@ +""" +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... + + @patch.dict('django.conf.settings.FEATURES', {'ENABLE_CREATOR_GROUP': True}) + def test_lib_create_permission(self): + """ + Users who aren't given course creator roles shouldn't be able to create + libraries either. + """ + self.client.logout() + ns_user, password = self.create_non_staff_user() + self.client.login(username=ns_user.username, password=password) + + response = self.client.ajax_post(LIBRARY_REST_URL, { + 'org': 'org', 'library': 'lib', 'display_name': "New Library", + }) + self.assertEqual(response.status_code, 403) + + @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('already a library defined', 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: + lib = self.store.get_library(lib_key, remove_version=False, remove_branch=False) + version = lib.location.library_key.version_guid + 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)) + + def test_get_lib_edit_html(self): + """ + Test that we can get the studio view for editing a library using /library/:key/ + """ + lib = LibraryFactory.create() + + response = self.client.get(make_url_for_lib(lib.location.library_key)) + self.assertEqual(response.status_code, 200) + self.assertIn("' + errorMessage + '

'); $('.new-course-save').addClass('is-disabled'); }); }; - var cancelNewCourse = function (e) { - e.preventDefault(); - $('.new-course-button').removeClass('is-disabled'); - $('.wrapper-create-course').removeClass('is-shown'); - // Clear out existing fields and errors - _.each( - ['.new-course-name', '.new-course-org', '.new-course-number', '.new-course-run'], - function (field) { - $(field).val(''); - } - ); - $('#course_creation_error').html(''); - $('.wrap-error').removeClass('is-shown'); - $('.new-course-save').off('click'); + var makeCancelHandler = function (addType) { + return function(e) { + e.preventDefault(); + $('.new-'+addType+'-button').removeClass('is-disabled'); + $('.wrapper-create-'+addType).removeClass('is-shown'); + // Clear out existing fields and errors + $('#create-'+addType+'-form input[type=text]').val(''); + $('#'+addType+'_creation_error').html(''); + $('.create-'+addType+' .wrap-error').removeClass('is-shown'); + $('.new-'+addType+'-save').off('click'); + }; }; var addNewCourse = function (e) { @@ -73,18 +89,70 @@ define(["domReady", "jquery", "underscore", "js/utils/cancel_on_escape", "js/vie var $courseName = $('.new-course-name'); $courseName.focus().select(); $('.new-course-save').on('click', saveNewCourse); - $cancelButton.bind('click', cancelNewCourse); + $cancelButton.bind('click', makeCancelHandler('course')); CancelOnEscape($cancelButton); 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(); + + var 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 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', makeCancelHandler('library')); + 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/spec/views/pages/index_spec.js b/cms/static/js/spec/views/pages/index_spec.js index 2a7974cd591d..52b71a834957 100644 --- a/cms/static/js/spec/views/pages/index_spec.js +++ b/cms/static/js/spec/views/pages/index_spec.js @@ -2,7 +2,7 @@ define(["jquery", "js/common_helpers/ajax_helpers", "js/spec_helpers/view_helper "js/views/utils/view_utils"], function ($, AjaxHelpers, ViewHelpers, IndexUtils, ViewUtils) { describe("Course listing page", function () { - var mockIndexPageHTML = readFixtures('mock/mock-index-page.underscore'), fillInFields; + var mockIndexPageHTML = readFixtures('mock/mock-index-page.underscore'); var fillInFields = function (org, number, run, name) { $('.new-course-org').val(org); @@ -11,6 +11,12 @@ define(["jquery", "js/common_helpers/ajax_helpers", "js/spec_helpers/view_helper $('.new-course-name').val(name); }; + var fillInLibraryFields = function(org, number, name) { + $('.new-library-org').val(org).keyup(); + $('.new-library-number').val(number).keyup(); + $('.new-library-name').val(name).keyup(); + }; + beforeEach(function () { ViewHelpers.installMockAnalytics(); appendSetFixtures(mockIndexPageHTML); @@ -57,9 +63,83 @@ define(["jquery", "js/common_helpers/ajax_helpers", "js/spec_helpers/view_helper AjaxHelpers.respondWithJson(requests, { ErrMsg: 'error message' }); - expect($('.wrap-error')).toHaveClass('is-shown'); + expect($('.create-course .wrap-error')).toHaveClass('is-shown'); expect($('#course_creation_error')).toContainText('error message'); expect($('.new-course-save')).toHaveClass('is-disabled'); }); + + it("saves new libraries", function () { + var requests = AjaxHelpers.requests(this); + var redirectSpy = spyOn(ViewUtils, 'redirect'); + $('.new-library-button').click(); + fillInLibraryFields('DemoX', 'DM101', 'Demo library'); + $('.new-library-save').click(); + AjaxHelpers.expectJsonRequest(requests, 'POST', '/library/', { + org: 'DemoX', + number: 'DM101', + display_name: 'Demo library' + }); + AjaxHelpers.respondWithJson(requests, { + url: 'dummy_test_url' + }); + expect(redirectSpy).toHaveBeenCalledWith('dummy_test_url'); + }); + + it("displays an error when a required field is blank", function () { + var requests = AjaxHelpers.requests(this); + var requests_count = requests.length; + $('.new-library-button').click(); + var values = ['DemoX', 'DM101', 'Demo library']; + // Try making each of these three values empty one at a time and ensure the form won't submit: + for (var i=0; i characters.'); - // Ensure that org/course_num/run < 65 chars. + // Ensure that org, course_num and run passes checkTotalKeyLengthViolations validateTotalCourseItemsLength = function () { - var totalLength = _.reduce( + ViewUtils.checkTotalKeyLengthViolations( + selectors, classes, [selectors.org, selectors.number, selectors.run], - function (sum, ele) { - return sum + $(ele).val().length; - }, 0 + keyLengthViolationMessage ); - if (totalLength > 65) { - $(selectors.errorWrapper).addClass(classes.shown).removeClass(classes.hiding); - $(selectors.errorMessage).html('

' + gettext('The combined length of the organization, course number, and course run fields cannot be more than 65 characters.') + '

'); - $(selectors.save).addClass(classes.disabled); - } - else { - $(selectors.errorWrapper).removeClass(classes.shown).addClass(classes.hiding); - } }; setNewCourseFieldInErr = function (el, msg) { @@ -117,7 +90,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 +111,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..b53f7c8b5398 --- /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) { + "use strict"; + return function (selectors, classes) { + var validateTotalKeyLength, setNewLibraryFieldInErr, hasInvalidRequiredFields, + createLibrary, validateFilledFields, configureHandlers; + + var validateRequiredField = ViewUtils.validateRequiredField; + var validateURLItemEncoding = ViewUtils.validateURLItemEncoding; + + var keyLengthViolationMessage = gettext("The combined length of the organization and library code fields cannot be more than <%=limit%> characters."); + + // Ensure that org/librarycode passes validateTotalKeyLength check + validateTotalKeyLength = function () { + ViewUtils.checkTotalKeyLengthViolations( + selectors, classes, + [selectors.org, selectors.number], + keyLengthViolationMessage + ); + }; + + setNewLibraryFieldInErr = function (element, message) { + if (message) { + element.addClass(classes.error); + element.children(selectors.tipError).addClass(classes.showing).removeClass(classes.hiding).text(message); + $(selectors.save).addClass(classes.disabled); + } + else { + element.removeClass(classes.error); + element.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, element) { + var $element = $(element); + var error = validateRequiredField($element.val()); + setNewLibraryFieldInErr($element.parent(), error); + return error ? true : acc; + }, + false + ); + }; + + createLibrary = function (libraryInfo, errorHandler) { + $.postJSON( + '/library/', + libraryInfo + ).done(function (data) { + ViewUtils.redirect(data.url); + }).fail(function(jqXHR, textStatus, errorThrown) { + var reason = errorThrown; + if (jqXHR.responseText) { + try { + var detailedReason = $.parseJSON(jqXHR.responseText).ErrMsg; + if (detailedReason) { + reason = detailedReason; + } + } catch (e) {} + } + errorHandler(reason); + }); + }; + + // Ensure that all fields are not empty + validateFilledFields = function () { + return _.reduce( + [selectors.org, selectors.number, selectors.name], + function (acc, element) { + var $element = $(element); + return $element.val().length !== 0 ? acc : false; + }, + true + ); + }; + + // Handle validation asynchronously + configureHandlers = function () { + _.each( + [selectors.org, selectors.number], + function (element) { + var $element = $(element); + $element.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 === $.ui.keyCode.TAB) { + return; + } + var error = validateURLItemEncoding($element.val(), $(selectors.allowUnicode).val() === 'True'); + setNewLibraryFieldInErr($element.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..3f06a4f62942 100644 --- a/cms/static/js/views/utils/view_utils.js +++ b/cms/static/js/views/utils/view_utils.js @@ -5,7 +5,11 @@ 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, validateURLItemEncoding, validateTotalKeyLength, checkTotalKeyLengthViolations; + + // see https://openedx.atlassian.net/browse/TNL-889 for what is it and why it's 65 + var MAX_SUM_KEY_LENGTH = 65; /** * Toggles the expanded state of the current element. @@ -173,6 +177,55 @@ 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 ''; + }; + + // Ensure that sum length of key field values <= ${MAX_SUM_KEY_LENGTH} chars. + validateTotalKeyLength = function (key_field_selectors) { + var totalLength = _.reduce( + key_field_selectors, + function (sum, ele) { return sum + $(ele).val().length;}, + 0 + ); + return totalLength <= MAX_SUM_KEY_LENGTH; + }; + + checkTotalKeyLengthViolations = function(selectors, classes, key_field_selectors, message_tpl) { + if (!validateTotalKeyLength(key_field_selectors)) { + $(selectors.errorWrapper).addClass(classes.shown).removeClass(classes.hiding); + $(selectors.errorMessage).html('

' + _.template(message_tpl, {limit: MAX_SUM_KEY_LENGTH}) + '

'); + $(selectors.save).addClass(classes.disabled); + } else { + $(selectors.errorWrapper).removeClass(classes.shown).addClass(classes.hiding); + } + }; + return { 'toggleExpandCollapse': toggleExpandCollapse, 'showLoadingIndicator': showLoadingIndicator, @@ -186,6 +239,10 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification", "js 'setScrollOffset': setScrollOffset, 'redirect': redirect, 'reload': reload, - 'hasChangedAttributes': hasChangedAttributes + 'hasChangedAttributes': hasChangedAttributes, + 'validateRequiredField': validateRequiredField, + 'validateURLItemEncoding': validateURLItemEncoding, + 'validateTotalKeyLength': validateTotalKeyLength, + 'checkTotalKeyLengthViolations': checkTotalKeyLengthViolations }; }); diff --git a/cms/static/sass/elements/_forms.scss b/cms/static/sass/elements/_forms.scss index 5979c92ff740..9befe26c7abd 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..1a1430f0127e 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; @@ -622,7 +653,7 @@ // course listings - .create-course { + .create-course, .create-library { .row { @include clearfix(); 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/container.html b/cms/templates/container.html index b24cffe2ea10..663184a9b192 100644 --- a/cms/templates/container.html +++ b/cms/templates/container.html @@ -18,13 +18,6 @@ <%namespace name='static' file='static_content.html'/> -<%! -templates = ["basic-modal", "modal-button", "edit-xblock-modal", - "editor-mode-button", "upload-dialog", "image-modal", - "add-xblock-component", "add-xblock-component-button", "add-xblock-component-menu", - "add-xblock-component-menu-problem", "xblock-string-field-editor", "publish-xblock", "publish-history", - "unit-outline", "container-message"] -%> <%block name="header_extras"> % for template_name in templates: +% endfor + + +<%block name="requirejs"> + require(["js/factories/library"], function(LibraryFactory) { + LibraryFactory( + ${component_templates | n}, + ${json.dumps(xblock_info) | n} + ); + }); + + +<%block name="content"> + + +
+
+ +
+
+ +
+
+
+ +
+
+ +
+

${_("Loading")}

+
+
+ +
+
+
+ diff --git a/cms/urls.py b/cms/urls.py index 6548044b964f..077f533b9d93 100644 --- a/cms/urls.py +++ b/cms/urls.py @@ -110,6 +110,12 @@ url(r'^i18n.js$', 'django.views.i18n.javascript_catalog', js_info_dict), ) +if settings.FEATURES.get('ENABLE_CONTENT_LIBRARIES'): + LIBRARY_KEY_PATTERN = r'(?Plibrary-v1:[^/+]+\+[^/+]+)' + urlpatterns += ( + url(r'^library/{}?$'.format(LIBRARY_KEY_PATTERN), + 'contentstore.views.library_handler', name='library_handler'), + ) if settings.FEATURES.get('ENABLE_EXPORT_GIT'): urlpatterns += (url( diff --git a/common/djangoapps/xmodule_modifiers.py b/common/djangoapps/xmodule_modifiers.py index 2349f1e3beac..9ef249cb37cf 100644 --- a/common/djangoapps/xmodule_modifiers.py +++ b/common/djangoapps/xmodule_modifiers.py @@ -86,13 +86,14 @@ def wrap_xblock(runtime_class, block, view, frag, context, usage_id_serializer, data['type'] = block.js_module_name shim_xmodule_js(frag) + data['block-type'] = block.scope_ids.block_type + data['usage-id'] = usage_id_serializer(block.scope_ids.usage_id) + data['request-token'] = request_token + if frag.js_init_fn: data['init'] = frag.js_init_fn data['runtime-class'] = runtime_class data['runtime-version'] = frag.js_init_version - data['block-type'] = block.scope_ids.block_type - data['usage-id'] = usage_id_serializer(block.scope_ids.usage_id) - data['request-token'] = request_token if block.name: data['name'] = block.name diff --git a/common/lib/xmodule/xmodule/modulestore/split_mongo/split_draft.py b/common/lib/xmodule/xmodule/modulestore/split_mongo/split_draft.py index d44d2c0bafd3..ccaa85acdce6 100644 --- a/common/lib/xmodule/xmodule/modulestore/split_mongo/split_draft.py +++ b/common/lib/xmodule/xmodule/modulestore/split_mongo/split_draft.py @@ -5,7 +5,7 @@ from xmodule.modulestore.split_mongo.split import SplitMongoModuleStore, EXCLUDE_ALL from xmodule.exceptions import InvalidVersionError from xmodule.modulestore import ModuleStoreEnum -from xmodule.modulestore.exceptions import InsufficientSpecificationError +from xmodule.modulestore.exceptions import InsufficientSpecificationError, ItemNotFoundError from xmodule.modulestore.draft_and_published import ( ModuleStoreDraftAndPublished, DIRECT_ONLY_CATEGORIES, UnsupportedRevisionError ) @@ -409,7 +409,11 @@ def convert_to_draft(self, location, user_id): pass def _get_head(self, xblock, branch): - course_structure = self._lookup_course(xblock.location.course_key.for_branch(branch)).structure + try: + course_structure = self._lookup_course(xblock.location.course_key.for_branch(branch)).structure + except ItemNotFoundError: + # There is no published version xblock container, e.g. Library + return None return self._get_block_from_structure(course_structure, BlockKey.from_usage_key(xblock.location)) def _get_version(self, block): diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_libraries.py b/common/lib/xmodule/xmodule/modulestore/tests/test_libraries.py index d0dafcb70775..158d04c22dc4 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_libraries.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_libraries.py @@ -197,3 +197,14 @@ def test_library_author_view(self): with patch('xmodule.html_module.HtmlDescriptor.author_view', hello_render, create=True): result = library.render(AUTHOR_VIEW, context) self.assertIn(message, result.content) + + def test_xblock_in_lib_have_published_version_returns_false(self): + library = LibraryFactory.create(modulestore=self.store) + block = ItemFactory.create( + category="html", + parent_location=library.location, + user_id=self.user_id, + publish_item=False, + modulestore=self.store, + ) + self.assertFalse(self.store.has_published_version(block)) diff --git a/common/static/coffee/src/xblock/core.coffee b/common/static/coffee/src/xblock/core.coffee index bafcff97c991..024ec7b345f2 100644 --- a/common/static/coffee/src/xblock/core.coffee +++ b/common/static/coffee/src/xblock/core.coffee @@ -28,9 +28,10 @@ block = initFn(runtime, element) ? {} block.runtime = runtime else - elementTag = $('
').append($element.clone()).html(); - console.log("Block #{elementTag} is missing data-runtime, data-runtime-version or data-init, and can't be initialized") block = {} + if runtime? or version? or initFnName? + elementTag = $('
').append($element.clone()).html(); + console.log("Block #{elementTag} is missing data-runtime, data-runtime-version or data-init, and can't be initialized") block.element = element block.name = $element.data("name") diff --git a/common/static/js/spec_helpers/ajax_helpers.js b/common/static/js/spec_helpers/ajax_helpers.js index 0b2769be5708..3e51f91fd229 100644 --- a/common/static/js/spec_helpers/ajax_helpers.js +++ b/common/static/js/spec_helpers/ajax_helpers.js @@ -77,13 +77,20 @@ define(['sinon', 'underscore'], function(sinon, _) { JSON.stringify(jsonResponse)); }; - respondWithError = function(requests, requestIndex) { + respondWithError = function(requests, statusCode, jsonResponse, requestIndex) { if (_.isUndefined(requestIndex)) { requestIndex = requests.length - 1; } - requests[requestIndex].respond(500, + if (_.isUndefined(statusCode)) { + statusCode = 500; + } + if (_.isUndefined(jsonResponse)) { + jsonResponse = {}; + } + requests[requestIndex].respond(statusCode, { 'Content-Type': 'application/json' }, - JSON.stringify({ })); + JSON.stringify(jsonResponse) + ); }; respondToDelete = function(requests, requestIndex) { diff --git a/common/test/acceptance/fixtures/base.py b/common/test/acceptance/fixtures/base.py new file mode 100644 index 000000000000..059f34bfce4b --- /dev/null +++ b/common/test/acceptance/fixtures/base.py @@ -0,0 +1,184 @@ +import re +import requests +import json +from lazy import lazy + +from . import STUDIO_BASE_URL + + +class StudioApiLoginError(Exception): + """ + Error occurred while logging in to the Studio API. + """ + pass + + +class StudioApiFixture(object): + """ + Base class for fixtures that use the Studio restful API. + """ + def __init__(self): + # Info about the auto-auth user used to create the course/library. + self.user = {} + + @lazy + def session(self): + """ + Log in as a staff user, then return a `requests` `session` object for the logged in user. + Raises a `StudioApiLoginError` if the login fails. + """ + # Use auto-auth to retrieve the session for a logged in user + session = requests.Session() + response = session.get(STUDIO_BASE_URL + "/auto_auth?staff=true") + + # Return the session from the request + if response.ok: + # auto_auth returns information about the newly created user + # capture this so it can be used by by the testcases. + user_pattern = re.compile('Logged in user {0} \({1}\) with password {2} and user_id {3}'.format( + '(?P\S+)', '(?P[^\)]+)', '(?P\S+)', '(?P\d+)')) + user_matches = re.match(user_pattern, response.text) + if user_matches: + self.user = user_matches.groupdict() + + return session + + else: + msg = "Could not log in to use Studio restful API. Status code: {0}".format(response.status_code) + raise StudioApiLoginError(msg) + + @lazy + def session_cookies(self): + """ + Log in as a staff user, then return the cookies for the session (as a dict) + Raises a `StudioApiLoginError` if the login fails. + """ + return {key: val for key, val in self.session.cookies.items()} + + @lazy + def headers(self): + """ + Default HTTP headers dict. + """ + return { + 'Content-type': 'application/json', + 'Accept': 'application/json', + 'X-CSRFToken': self.session_cookies.get('csrftoken', '') + } + + +class XBlockContainerFixture(StudioApiFixture): + FixtureError = None + + def __init__(self): + self.children = [] + super(XBlockContainerFixture, self).__init__() + + def add_children(self, *args): + """ + Add children XBlock to the container. + Each item in `args` is an `XBlockFixtureDesc` object. + + Returns the fixture to allow chaining. + """ + self.children.extend(args) + return self + + def _create_xblock_children(self, parent_loc, xblock_descriptions): + """ + Recursively create XBlock children. + """ + for desc in xblock_descriptions: + loc = self.create_xblock(parent_loc, desc) + self._create_xblock_children(loc, desc.children) + + def create_xblock(self, parent_loc, xblock_desc): + """ + Create an XBlock with `parent_loc` (the location of the parent block) + and `xblock_desc` (an `XBlockFixtureDesc` instance). + """ + create_payload = { + 'category': xblock_desc.category, + 'display_name': xblock_desc.display_name, + } + + if parent_loc is not None: + create_payload['parent_locator'] = parent_loc + + # Create the new XBlock + response = self.session.post( + STUDIO_BASE_URL + '/xblock/', + data=json.dumps(create_payload), + headers=self.headers, + ) + + if not response.ok: + msg = "Could not create {0}. Status was {1}".format(xblock_desc, response.status_code) + raise self.FixtureError(msg) + + try: + loc = response.json().get('locator') + xblock_desc.locator = loc + except ValueError: + raise self.FixtureError("Could not decode JSON from '{0}'".format(response.content)) + + # Configure the XBlock + response = self.session.post( + STUDIO_BASE_URL + '/xblock/' + loc, + data=xblock_desc.serialize(), + headers=self.headers, + ) + + if response.ok: + return loc + else: + raise self.FixtureError("Could not update {0}. Status code: {1}".format(xblock_desc, response.status_code)) + + def _update_xblock(self, locator, data): + """ + Update the xblock at `locator`. + """ + # Create the new XBlock + response = self.session.put( + "{}/xblock/{}".format(STUDIO_BASE_URL, locator), + data=json.dumps(data), + headers=self.headers, + ) + + if not response.ok: + msg = "Could not update {} with data {}. Status was {}".format(locator, data, response.status_code) + raise self.FixtureError(msg) + + def _encode_post_dict(self, post_dict): + """ + Encode `post_dict` (a dictionary) as UTF-8 encoded JSON. + """ + return json.dumps({ + k: v.encode('utf-8') if isinstance(v, basestring) else v + for k, v in post_dict.items() + }) + + def get_nested_xblocks(self, category=None): + """ + Return a list of nested XBlocks for the container that can be filtered by + category. + """ + xblocks = self._get_nested_xblocks(self) + if category: + xblocks = filter(lambda x: x.category == category, xblocks) + return xblocks + + def _get_nested_xblocks(self, xblock_descriptor): + """ + Return a list of nested XBlocks for the container. + """ + xblocks = list(xblock_descriptor.children) + for child in xblock_descriptor.children: + xblocks.extend(self._get_nested_xblocks(child)) + return xblocks + + def _publish_xblock(self, locator): + """ + Publish the xblock at `locator`. + """ + self._update_xblock(locator, {'publish': 'make_public'}) diff --git a/common/test/acceptance/fixtures/course.py b/common/test/acceptance/fixtures/course.py index 69836fbee048..d1c68d7b9b5f 100644 --- a/common/test/acceptance/fixtures/course.py +++ b/common/test/acceptance/fixtures/course.py @@ -4,77 +4,17 @@ import mimetypes import json -import re + import datetime -import requests + from textwrap import dedent from collections import namedtuple from path import path -from lazy import lazy + from opaque_keys.edx.keys import CourseKey from . import STUDIO_BASE_URL - - -class StudioApiLoginError(Exception): - """ - Error occurred while logging in to the Studio API. - """ - pass - - -class StudioApiFixture(object): - """ - Base class for fixtures that use the Studio restful API. - """ - def __init__(self): - # Info about the auto-auth user used to create the course. - self.user = {} - - @lazy - def session(self): - """ - Log in as a staff user, then return a `requests` `session` object for the logged in user. - Raises a `StudioApiLoginError` if the login fails. - """ - # Use auto-auth to retrieve the session for a logged in user - session = requests.Session() - response = session.get(STUDIO_BASE_URL + "/auto_auth?staff=true") - - # Return the session from the request - if response.ok: - # auto_auth returns information about the newly created user - # capture this so it can be used by by the testcases. - user_pattern = re.compile('Logged in user {0} \({1}\) with password {2} and user_id {3}'.format( - '(?P\S+)', '(?P[^\)]+)', '(?P\S+)', '(?P\d+)')) - user_matches = re.match(user_pattern, response.text) - if user_matches: - self.user = user_matches.groupdict() - - return session - - else: - msg = "Could not log in to use Studio restful API. Status code: {0}".format(response.status_code) - raise StudioApiLoginError(msg) - - @lazy - def session_cookies(self): - """ - Log in as a staff user, then return the cookies for the session (as a dict) - Raises a `StudioApiLoginError` if the login fails. - """ - return {key: val for key, val in self.session.cookies.items()} - - @lazy - def headers(self): - """ - Default HTTP headers dict. - """ - return { - 'Content-type': 'application/json', - 'Accept': 'application/json', - 'X-CSRFToken': self.session_cookies.get('csrftoken', '') - } +from .base import XBlockContainerFixture class XBlockFixtureDesc(object): @@ -105,7 +45,7 @@ def __init__(self, category, display_name, data=None, metadata=None, grader_type def add_children(self, *args): """ Add child XBlocks to this XBlock. - Each item in `args` is an `XBlockFixtureDescriptor` object. + Each item in `args` is an `XBlockFixtureDesc` object. Returns the `xblock_desc` instance to allow chaining. """ @@ -161,7 +101,7 @@ class CourseFixtureError(Exception): pass -class CourseFixture(StudioApiFixture): +class CourseFixture(XBlockContainerFixture): """ Fixture for ensuring that a course exists. @@ -169,6 +109,8 @@ class CourseFixture(StudioApiFixture): between tests, you should use unique course identifiers for each fixture. """ + FixtureError = CourseFixtureError + def __init__(self, org, number, run, display_name, start_date=None, end_date=None): """ Configure the course fixture to create a course with @@ -181,6 +123,7 @@ def __init__(self, org, number, run, display_name, start_date=None, end_date=Non These have the same meaning as in the Studio restful API /course end-point. """ + super(CourseFixture, self).__init__() self._course_dict = { 'org': org, 'number': number, @@ -202,7 +145,6 @@ def __init__(self, org, number, run, display_name, start_date=None, end_date=Non self._updates = [] self._handouts = [] - self.children = [] self._assets = [] self._advanced_settings = {} self._course_key = None @@ -213,16 +155,6 @@ def __str__(self): """ return "".format(**self._course_dict) - def add_children(self, *args): - """ - Add children XBlock to the course. - Each item in `args` is an `XBlockFixtureDescriptor` object. - - Returns the course fixture to allow chaining. - """ - self.children.extend(args) - return self - def add_update(self, update): """ Add an update to the course. `update` should be a `CourseUpdateDesc`. @@ -450,101 +382,7 @@ def _create_xblock_children(self, parent_loc, xblock_descriptions): """ Recursively create XBlock children. """ - for desc in xblock_descriptions: - loc = self.create_xblock(parent_loc, desc) - self._create_xblock_children(loc, desc.children) - + super(CourseFixture, self)._create_xblock_children(parent_loc, xblock_descriptions) self._publish_xblock(parent_loc) - def get_nested_xblocks(self, category=None): - """ - Return a list of nested XBlocks for the course that can be filtered by - category. - """ - xblocks = self._get_nested_xblocks(self) - if category: - xblocks = filter(lambda x: x.category == category, xblocks) - return xblocks - - def _get_nested_xblocks(self, xblock_descriptor): - """ - Return a list of nested XBlocks for the course. - """ - xblocks = list(xblock_descriptor.children) - for child in xblock_descriptor.children: - xblocks.extend(self._get_nested_xblocks(child)) - return xblocks - - def create_xblock(self, parent_loc, xblock_desc): - """ - Create an XBlock with `parent_loc` (the location of the parent block) - and `xblock_desc` (an `XBlockFixtureDesc` instance). - """ - create_payload = { - 'category': xblock_desc.category, - 'display_name': xblock_desc.display_name, - } - - if parent_loc is not None: - create_payload['parent_locator'] = parent_loc - - # Create the new XBlock - response = self.session.post( - STUDIO_BASE_URL + '/xblock/', - data=json.dumps(create_payload), - headers=self.headers, - ) - - if not response.ok: - msg = "Could not create {0}. Status was {1}".format(xblock_desc, response.status_code) - raise CourseFixtureError(msg) - - try: - loc = response.json().get('locator') - xblock_desc.locator = loc - except ValueError: - raise CourseFixtureError("Could not decode JSON from '{0}'".format(response.content)) - # Configure the XBlock - response = self.session.post( - STUDIO_BASE_URL + '/xblock/' + loc, - data=xblock_desc.serialize(), - headers=self.headers, - ) - - if response.ok: - return loc - else: - raise CourseFixtureError( - "Could not update {0}. Status code: {1}".format( - xblock_desc, response.status_code)) - - def _publish_xblock(self, locator): - """ - Publish the xblock at `locator`. - """ - self._update_xblock(locator, {'publish': 'make_public'}) - - def _update_xblock(self, locator, data): - """ - Update the xblock at `locator`. - """ - # Create the new XBlock - response = self.session.put( - "{}/xblock/{}".format(STUDIO_BASE_URL, locator), - data=json.dumps(data), - headers=self.headers, - ) - - if not response.ok: - msg = "Could not update {} with data {}. Status was {}".format(locator, data, response.status_code) - raise CourseFixtureError(msg) - - def _encode_post_dict(self, post_dict): - """ - Encode `post_dict` (a dictionary) as UTF-8 encoded JSON. - """ - return json.dumps({ - k: v.encode('utf-8') if isinstance(v, basestring) else v - for k, v in post_dict.items() - }) diff --git a/common/test/acceptance/fixtures/library.py b/common/test/acceptance/fixtures/library.py new file mode 100644 index 000000000000..35b8f60772de --- /dev/null +++ b/common/test/acceptance/fixtures/library.py @@ -0,0 +1,101 @@ +""" +Fixture to create a Content Library +""" + +from opaque_keys.edx.keys import CourseKey + +from . import STUDIO_BASE_URL +from .base import XBlockContainerFixture + + +class LibraryFixtureError(Exception): + """ + Error occurred while installing a library fixture. + """ + pass + + +class LibraryFixture(XBlockContainerFixture): + """ + Fixture for ensuring that a library exists. + + WARNING: This fixture is NOT idempotent. To avoid conflicts + between tests, you should use unique library identifiers for each fixture. + """ + + FixtureError = LibraryFixtureError + + def __init__(self, org, number, display_name): + """ + Configure the library fixture to create a library with + """ + super(LibraryFixture, self).__init__() + self.library_info = { + 'org': org, + 'number': number, + 'display_name': display_name + } + + self._library_key = None + super(LibraryFixture, self).__init__() + + def __str__(self): + """ + String representation of the library fixture, useful for debugging. + """ + return "".format(**self.library_info) + + def install(self): + """ + Create the library and XBlocks within the library. + This is NOT an idempotent method; if the library already exists, this will + raise a `LibraryFixtureError`. You should use unique library identifiers to avoid + conflicts between tests. + """ + self._create_library() + self._create_xblock_children(self.library_location, self.children) + + return self + + @property + def library_key(self): + """ + Get the LibraryLocator for this library, as a string. + """ + return self._library_key + + @property + def library_location(self): + """ + Return the locator string for the LibraryRoot XBlock that is the root of the library hierarchy. + """ + lib_key = CourseKey.from_string(self._library_key) + return unicode(lib_key.make_usage_key('library', 'library')) + + def _create_library(self): + """ + Create the library described in the fixture. + Will fail if the library already exists. + """ + response = self.session.post( + STUDIO_BASE_URL + '/library/', + data=self._encode_post_dict(self.library_info), + headers=self.headers + ) + + if response.ok: + self._library_key = response.json()['library_key'] + else: + try: + err_msg = response.json().get('ErrMsg') + except ValueError: + err_msg = "Unknown Error" + raise LibraryFixtureError( + "Could not create library {}. Status was {}, error was: {}".format(self.library_info, response.status_code, err_msg) + ) + + def create_xblock(self, parent_loc, xblock_desc): + # Disable publishing for library XBlocks: + xblock_desc.publish = "not-applicable" + + return super(LibraryFixture, self).create_xblock(parent_loc, xblock_desc) diff --git a/common/test/acceptance/pages/studio/container.py b/common/test/acceptance/pages/studio/container.py index 878a0e0bbfb8..491dd0d05169 100644 --- a/common/test/acceptance/pages/studio/container.py +++ b/common/test/acceptance/pages/studio/container.py @@ -6,7 +6,7 @@ from bok_choy.promise import Promise, EmptyPromise from . import BASE_URL -from utils import click_css, confirm_prompt +from .utils import click_css, confirm_prompt, type_in_codemirror class ContainerPage(PageObject): @@ -362,6 +362,12 @@ def open_basic_tab(self): """ self._click_button('basic_tab') + def set_codemirror_text(self, text, index=0): + """ + Set the text of a CodeMirror editor that is part of this xblock's settings. + """ + type_in_codemirror(self, index, text, find_prefix='$("{}").find'.format(self.editor_selector)) + def save_settings(self): """ Click on settings Save button. diff --git a/common/test/acceptance/pages/studio/index.py b/common/test/acceptance/pages/studio/index.py index af163eca6852..cc81ed3ed933 100644 --- a/common/test/acceptance/pages/studio/index.py +++ b/common/test/acceptance/pages/studio/index.py @@ -28,6 +28,10 @@ def course_runs(self): def has_processing_courses(self): return self.q(css='.courses-processing').present + @property + def page_subheader(self): + return self.q(css='.content-primary .introduction .copy p').first.text[0] + def create_rerun(self, display_name): """ Clicks the create rerun link of the course specified by display_name. @@ -40,3 +44,68 @@ def click_course_run(self, run): Clicks on the course with run given by run. """ self.q(css='.course-run .value').filter(lambda el: el.text == run)[0].click() + + def has_new_library_button(self): + """ + (bool) is the "New Library" button present? + """ + return self.q(css='.new-library-button').present + + def click_new_library(self): + """ + Click on the "New Library" button + """ + self.q(css='.new-library-button').click() + + def is_new_library_form_visible(self): + """ + Is the new library form visisble? + """ + return self.q(css='.wrapper-create-library').visible + + def fill_new_library_form(self, display_name, org, number): + """ + Fill out the form to create a new library. + Must have called click_new_library() first. + """ + field = lambda fn: self.q(css='.wrapper-create-library #new-library-{}'.format(fn)) + field('name').fill(display_name) + field('org').fill(org) + field('number').fill(number) + + def is_new_library_form_valid(self): + """ + IS the new library form ready to submit? + """ + return ( + self.q(css='.wrapper-create-library .new-library-save:not(.is-disabled)').present and + not self.q(css='.wrapper-create-library .wrap-error.is-shown').present + ) + + def submit_new_library_form(self): + """ + Submit the new library form. + """ + self.q(css='.wrapper-create-library .new-library-save').click() + + def list_libraries(self): + """ + List all the libraries found on the page's list of libraries. + """ + self.q(css='#course-index-tabs .libraries-tab a').click() # Workaround Selenium/Firefox bug: `.text` property is broken on invisible elements + div2info = lambda element: { + 'name': element.find_element_by_css_selector('.course-title').text, + 'org': element.find_element_by_css_selector('.course-org .value').text, + 'number': element.find_element_by_css_selector('.course-num .value').text, + 'url': element.find_element_by_css_selector('a.library-link').get_attribute('href'), + } + return self.q(css='.libraries li.course-item').map(div2info).results + + def has_library(self, **kwargs): + """ + Does the page's list of libraries include a library matching kwargs? + """ + for lib in self.list_libraries(): + if all([lib[key] == kwargs[key] for key in kwargs]): + return True + return False diff --git a/common/test/acceptance/pages/studio/library.py b/common/test/acceptance/pages/studio/library.py new file mode 100644 index 000000000000..e87c556da968 --- /dev/null +++ b/common/test/acceptance/pages/studio/library.py @@ -0,0 +1,97 @@ +""" +Library edit page in Studio +""" + +from bok_choy.page_object import PageObject +from .container import XBlockWrapper +from ...tests.helpers import disable_animations +from .utils import confirm_prompt, wait_for_notification +from . import BASE_URL + + +class LibraryPage(PageObject): + """ + Library page in Studio + """ + + def __init__(self, browser, locator): + super(LibraryPage, self).__init__(browser) + self.locator = locator + + @property + def url(self): + """ + URL to the library edit page for the given library. + """ + return "{}/library/{}".format(BASE_URL, unicode(self.locator)) + + def is_browser_on_page(self): + """ + Returns True iff the browser has loaded the library edit page. + """ + return self.q(css='body.view-library').present + + def get_header_title(self): + """ + The text of the main heading (H1) visible on the page. + """ + return self.q(css='h1.page-header-title').text + + def wait_until_ready(self): + """ + When the page first loads, there is a loading indicator and most + functionality is not yet available. This waits for that loading to + finish. + + Always call this before using the page. It also disables animations + for improved test reliability. + """ + self.wait_for_ajax() + self.wait_for_element_invisibility('.ui-loading', 'Wait for the page to complete its initial loading of XBlocks via AJAX') + disable_animations(self) + + @property + def xblocks(self): + """ + Return a list of xblocks loaded on the container page. + """ + return self._get_xblocks() + + def click_duplicate_button(self, xblock_id): + """ + Click on the duplicate button for the given XBlock + """ + self._action_btn_for_xblock_id(xblock_id, "duplicate").click() + wait_for_notification(self) + self.wait_for_ajax() + + def click_delete_button(self, xblock_id, confirm=True): + """ + Click on the delete button for the given XBlock + """ + self._action_btn_for_xblock_id(xblock_id, "delete").click() + if confirm: + confirm_prompt(self) # this will also wait_for_notification() + self.wait_for_ajax() + + def _get_xblocks(self): + """ + Create an XBlockWrapper for each XBlock div found on the page. + """ + prefix = '.wrapper-xblock.level-page ' + return self.q(css=prefix + XBlockWrapper.BODY_SELECTOR).map(lambda el: XBlockWrapper(self.browser, el.get_attribute('data-locator'))).results + + def _div_for_xblock_id(self, xblock_id): + """ + Given an XBlock's usage locator as a string, return the WebElement for + that block's wrapper div. + """ + return self.q(css='.wrapper-xblock.level-page .studio-xblock-wrapper').filter(lambda el: el.get_attribute('data-locator') == xblock_id) + + def _action_btn_for_xblock_id(self, xblock_id, action): + """ + Given an XBlock's usage locator as a string, return one of its action + buttons. + action is 'edit', 'duplicate', or 'delete' + """ + return self._div_for_xblock_id(xblock_id)[0].find_element_by_css_selector('.header-actions .{action}-button.action-button'.format(action=action)) diff --git a/common/test/acceptance/pages/studio/utils.py b/common/test/acceptance/pages/studio/utils.py index a94f50ba6fa1..dd8ec091a347 100644 --- a/common/test/acceptance/pages/studio/utils.py +++ b/common/test/acceptance/pages/studio/utils.py @@ -103,6 +103,30 @@ def add_advanced_component(page, menu_index, name): click_css(page, component_css, 0) +def add_component(page, item_type, specific_type): + """ + Click one of the "Add New Component" buttons. + + item_type should be "advanced", "html", "problem", or "video" + + specific_type is required for some types and should be something like + "Blank Common Problem". + """ + btn = page.q(css='.add-xblock-component .add-xblock-component-button[data-type={}]'.format(item_type)) + multiple_templates = btn.filter(lambda el: 'multiple-templates' in el.get_attribute('class')).present + btn.click() + if multiple_templates: + sub_template_menu_div_selector = '.new-component-{}'.format(item_type) + page.wait_for_element_visibility(sub_template_menu_div_selector, 'Wait for the templates sub-menu to appear') + page.wait_for_element_invisibility('.add-xblock-component .new-component', 'Wait for the add component menu to disappear') + + all_options = page.q(css='.new-component-{} ul.new-component-template li a span'.format(item_type)) + chosen_option = all_options.filter(lambda el: el.text == specific_type).first + chosen_option.click() + wait_for_notification(page) + page.wait_for_ajax() + + @js_defined('window.jQuery') def type_in_codemirror(page, index, text, find_prefix="$"): script = """ diff --git a/common/test/acceptance/tests/studio/base_studio_test.py b/common/test/acceptance/tests/studio/base_studio_test.py index fa07533fba86..ec94f7f058c5 100644 --- a/common/test/acceptance/tests/studio/base_studio_test.py +++ b/common/test/acceptance/tests/studio/base_studio_test.py @@ -1,5 +1,10 @@ +""" +Base classes used by studio tests. +""" +from bok_choy.web_app_test import WebAppTest from ...pages.studio.auto_auth import AutoAuthPage from ...fixtures.course import CourseFixture +from ...fixtures.library import LibraryFixture from ..helpers import UniqueCourseTest from ...pages.studio.overview import CourseOutlinePage from ...pages.studio.utils import verify_ordering @@ -98,3 +103,46 @@ def do_action_and_verify(self, action, expected_ordering): # Reload the page to see that the change was persisted. container = self.go_to_nested_container_page() verify_ordering(self, container, expected_ordering) + + +class StudioLibraryTest(WebAppTest): + """ + Base class for all Studio library tests. + """ + + def setUp(self, is_staff=False): # pylint: disable=arguments-differ + """ + Install a library with no content using a fixture. + """ + super(StudioLibraryTest, self).setUp() + fixture = LibraryFixture( + 'test_org', + self.unique_id, + 'Test Library {}'.format(self.unique_id), + ) + self.populate_library_fixture(fixture) + fixture.install() + self.library_info = fixture.library_info + self.library_key = fixture.library_key + self.user = fixture.user + self.log_in(self.user, is_staff) + + def populate_library_fixture(self, library_fixture): + """ + Populate the children of the test course fixture. + """ + pass + + def log_in(self, user, is_staff=False): + """ + Log in as the user that created the library. + By default the user will not have staff access unless is_staff is passed as True. + """ + auth_page = AutoAuthPage( + self.browser, + staff=is_staff, + username=user.get('username'), + email=user.get('email'), + password=user.get('password') + ) + auth_page.visit() diff --git a/common/test/acceptance/tests/studio/test_studio_home.py b/common/test/acceptance/tests/studio/test_studio_home.py new file mode 100644 index 000000000000..9dc9b0249716 --- /dev/null +++ b/common/test/acceptance/tests/studio/test_studio_home.py @@ -0,0 +1,67 @@ +""" +Acceptance tests for Home Page (My Courses / My Libraries). +""" +from bok_choy.web_app_test import WebAppTest +from opaque_keys.edx.locator import LibraryLocator + +from ...pages.studio.auto_auth import AutoAuthPage +from ...pages.studio.library import LibraryPage +from ...pages.studio.index import DashboardPage + + +class CreateLibraryTest(WebAppTest): + """ + Test that we can create a new content library on the studio home page. + """ + + def setUp(self): + """ + Load the helper for the home page (dashboard page) + """ + super(CreateLibraryTest, self).setUp() + + self.auth_page = AutoAuthPage(self.browser, staff=True) + self.dashboard_page = DashboardPage(self.browser) + + def test_subheader(self): + """ + From the home page: + Verify that subheader is correct + """ + self.auth_page.visit() + self.dashboard_page.visit() + + self.assertIn("courses and libraries", self.dashboard_page.page_subheader) + + def test_create_library(self): + """ + From the home page: + Click "New Library" + Fill out the form + Submit the form + We should be redirected to the edit view for the library + Return to the home page + The newly created library should now appear in the list of libraries + """ + name = "New Library Name" + org = "TestOrgX" + number = "TESTLIB" + + self.auth_page.visit() + self.dashboard_page.visit() + self.assertFalse(self.dashboard_page.has_library(name=name, org=org, number=number)) + self.assertTrue(self.dashboard_page.has_new_library_button()) + + self.dashboard_page.click_new_library() + self.assertTrue(self.dashboard_page.is_new_library_form_visible()) + self.dashboard_page.fill_new_library_form(name, org, number) + self.assertTrue(self.dashboard_page.is_new_library_form_valid()) + self.dashboard_page.submit_new_library_form() + + # The next page is the library edit view; make sure it loads: + lib_page = LibraryPage(self.browser, LibraryLocator(org, number)) + lib_page.wait_for_page() + + # Then go back to the home page and make sure the new library is listed there: + self.dashboard_page.visit() + self.assertTrue(self.dashboard_page.has_library(name=name, org=org, number=number)) diff --git a/common/test/acceptance/tests/studio/test_studio_library.py b/common/test/acceptance/tests/studio/test_studio_library.py new file mode 100644 index 000000000000..d5ad890e376c --- /dev/null +++ b/common/test/acceptance/tests/studio/test_studio_library.py @@ -0,0 +1,104 @@ +""" +Acceptance tests for Content Libraries in Studio +""" + +from .base_studio_test import StudioLibraryTest +from ...pages.studio.utils import add_component +from ...pages.studio.library import LibraryPage + + +class LibraryEditPageTest(StudioLibraryTest): + """ + Test the functionality of the library edit page. + """ + def setUp(self): # pylint: disable=arguments-differ + """ + Ensure a library exists and navigate to the library edit page. + """ + super(LibraryEditPageTest, self).setUp(is_staff=True) + self.lib_page = LibraryPage(self.browser, self.library_key) + self.lib_page.visit() + self.lib_page.wait_until_ready() + + def test_page_header(self): + """ + Scenario: Ensure that the library's name is displayed in the header and title. + Given I have a library in Studio + And I navigate to Library Page in Studio + Then I can see library name in page header title + And I can see library name in browser page title + """ + self.assertIn(self.library_info['display_name'], self.lib_page.get_header_title()) + self.assertIn(self.library_info['display_name'], self.browser.title) + + def test_add_duplicate_delete_actions(self): + """ + Scenario: Ensure that we can add an HTML block, duplicate it, then delete the original. + Given I have a library in Studio with no XBlocks + And I navigate to Library Page in Studio + Then there are no XBlocks displayed + When I add Text XBlock + Then one XBlock is displayed + When I duplicate first XBlock + Then two XBlocks are displayed + And those XBlocks locators' are different + When I delete first XBlock + Then one XBlock is displayed + And displayed XBlock are second one + """ + self.assertEqual(len(self.lib_page.xblocks), 0) + + # Create a new block: + add_component(self.lib_page, "html", "Text") + self.assertEqual(len(self.lib_page.xblocks), 1) + first_block_id = self.lib_page.xblocks[0].locator + + # Duplicate the block: + self.lib_page.click_duplicate_button(first_block_id) + self.assertEqual(len(self.lib_page.xblocks), 2) + second_block_id = self.lib_page.xblocks[1].locator + self.assertNotEqual(first_block_id, second_block_id) + + # Delete the first block: + self.lib_page.click_delete_button(first_block_id, confirm=True) + self.assertEqual(len(self.lib_page.xblocks), 1) + self.assertEqual(self.lib_page.xblocks[0].locator, second_block_id) + + def test_add_edit_xblock(self): + """ + Scenario: Ensure that we can add an XBlock, edit it, then see the resulting changes. + Given I have a library in Studio with no XBlocks + And I navigate to Library Page in Studio + Then there are no XBlocks displayed + When I add Multiple Choice XBlock + Then one XBlock is displayed + When I edit first XBlock + And I go to basic tab + And set it's text to a fairly trivial question about Battlestar Galactica + And save XBlock + Then one XBlock is displayed + And first XBlock student content contains at least part of text I set + """ + self.assertEqual(len(self.lib_page.xblocks), 0) + # Create a new problem block: + add_component(self.lib_page, "problem", "Multiple Choice") + self.assertEqual(len(self.lib_page.xblocks), 1) + problem_block = self.lib_page.xblocks[0] + # Edit it: + problem_block.edit() + problem_block.open_basic_tab() + problem_block.set_codemirror_text( + """ + >>Who is "Starbuck"?<< + (x) Kara Thrace + ( ) William Adama + ( ) Laura Roslin + ( ) Lee Adama + ( ) Gaius Baltar + """ + ) + problem_block.save_settings() + # Check that the save worked: + self.assertEqual(len(self.lib_page.xblocks), 1) + problem_block = self.lib_page.xblocks[0] + self.assertIn("Laura Roslin", problem_block.student_content)