From 3e0f08ebc2efe05ec581b5e0f3899bf5e4e58f98 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Tue, 28 Oct 2014 22:23:26 -0700 Subject: [PATCH 01/99] Studio support for creating and editing libraries (PR 6046) 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 | 41 +++- cms/templates/base.html | 2 + cms/templates/container.html | 7 - cms/templates/index.html | 128 ++++++++++- .../js/mock/mock-index-page.underscore | 60 +++++ cms/templates/library.html | 66 ++++++ cms/urls.py | 7 + .../modulestore/split_mongo/split_draft.py | 8 +- .../modulestore/tests/test_libraries.py | 11 + common/static/js/xblock/core.js | 4 +- common/test/acceptance/fixtures/base.py | 196 ++++++++++++++++ common/test/acceptance/fixtures/course.py | 211 ++---------------- common/test/acceptance/fixtures/library.py | 92 ++++++++ .../test/acceptance/pages/studio/container.py | 8 +- common/test/acceptance/pages/studio/index.py | 72 ++++++ .../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 +++++++++ 41 files changed, 1948 insertions(+), 296 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 914ad1ec65..e6c6d458cc 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -296,6 +296,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 26bb619fb3..9cceccd6e6 100644 --- a/cms/djangoapps/contentstore/views/__init__.py +++ b/cms/djangoapps/contentstore/views/__init__.py @@ -12,6 +12,7 @@ from .error import * 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 70a470f9dc..9768542ea8 100644 --- a/cms/djangoapps/contentstore/views/component.py +++ b/cms/djangoapps/contentstore/views/component.py @@ -56,6 +56,15 @@ ADVANCED_COMPONENT_POLICY_KEY = 'advanced_modules' 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. @@ -202,6 +211,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 cbc40b6f31..57ffa6b9e1 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -38,6 +38,7 @@ from contentstore.utils import ( 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 @@ from .component import ( 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_author_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 course_listing(request): ) 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 course_listing(request): 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 34ef869f17..3769c81978 100644 --- a/cms/djangoapps/contentstore/views/helpers.py +++ b/cms/djangoapps/contentstore/views/helpers.py @@ -13,7 +13,7 @@ from django.utils.translation import ugettext as _ 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 2e6528ed0d..37a7c83d53 100644 --- a/cms/djangoapps/contentstore/views/item.py +++ b/cms/djangoapps/contentstore/views/item.py @@ -47,6 +47,7 @@ from edxmako.shortcuts import render_to_string 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'] @@ -660,7 +661,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) @@ -701,12 +704,16 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F 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) @@ -726,7 +733,7 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F 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), @@ -734,7 +741,7 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F "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 0000000000..15e54a37ce --- /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 .component import get_component_templates, CONTAINER_TEMPATES +from student.auth import has_course_author_access +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_author_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_author_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 b0ea997b4d..965879e154 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 lxml 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 student.auth import has_course_author_access from contentstore.views.course import course_outline_initial_state from contentstore.views.item import create_xblock_info, VisibilityState @@ -14,7 +14,7 @@ from course_action_state.models import CourseRerunState 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 @@ class TestCourseIndex(CourseTestCase): 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 034a9002fb..576ea38808 100644 --- a/cms/djangoapps/contentstore/views/tests/test_helpers.py +++ b/cms/djangoapps/contentstore/views/tests/test_helpers.py @@ -4,7 +4,7 @@ Unit tests for helpers.py. 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 @@ class HelpersTestCase(CourseTestCase): 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 b496a7ffc4..3f4fa86cba 100644 --- a/cms/djangoapps/contentstore/views/tests/test_item.py +++ b/cms/djangoapps/contentstore/views/tests/test_item.py @@ -24,7 +24,8 @@ from student.tests.factories import UserFactory 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 @@ -1420,6 +1421,54 @@ class TestXBlockInfo(ItemTest): 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 0000000000..8cae971087 --- /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').attr('aria-disabled', true); }); }; - var cancelNewCourse = function (e) { - e.preventDefault(); - $('.new-course-button').removeClass('is-disabled').attr('aria-disabled', false); - $('.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').attr('aria-disabled', false);; + $('.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 bcf881b986..320f8d6557 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 2a7974cd59..52b71a8349 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 0000000000..b53f7c8b53 --- /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 ad564a4b66..9cd2c4ef58 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 6e5ffae474..c6425b1144 100644 --- a/cms/static/sass/elements/_forms.scss +++ b/cms/static/sass/elements/_forms.scss @@ -409,7 +409,6 @@ form { // ==================== .wrapper-create-element { height: 0; - margin-bottom: $baseline; opacity: 0.0; pointer-events: none; overflow: hidden; @@ -420,6 +419,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 48d1ff9641..c41fc0c7fd 100644 --- a/cms/static/sass/views/_dashboard.scss +++ b/cms/static/sass/views/_dashboard.scss @@ -289,10 +289,42 @@ // ==================== - // ELEM: course listings - .courses { - margin: $baseline 0; + // 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-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 d02d85fb0e..9f1948e87c 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 ${settings.STUDIO_NAME} diff --git a/cms/templates/container.html b/cms/templates/container.html index 3d41fb1709..aa3a3d8884 100644 --- a/cms/templates/container.html +++ b/cms/templates/container.html @@ -18,13 +18,6 @@ from django.utils.translation import ugettext as _ <%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 6017092a12..d5a3b9b83a 100644 --- a/cms/urls.py +++ b/cms/urls.py @@ -112,6 +112,13 @@ urlpatterns += patterns( 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( r'^export_git/{}$'.format( 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 11b23295a4..8870c89ee6 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 @@ Module for the dual-branch fall-back Draft->Published Versioning ModuleStore 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 ) @@ -411,7 +411,11 @@ class DraftVersioningModuleStore(SplitMongoModuleStore, ModuleStoreDraftAndPubli 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 252d984622..ef8a6d4d69 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_libraries.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_libraries.py @@ -206,3 +206,14 @@ class TestLibraries(MixedSplitTestCase): with patch('xmodule.x_module.DescriptorSystem.applicable_aside_types', lambda self, block: []): 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/js/xblock/core.js b/common/static/js/xblock/core.js index ffef2b2762..99b2ae0489 100644 --- a/common/static/js/xblock/core.js +++ b/common/static/js/xblock/core.js @@ -23,10 +23,10 @@ if (runtime && version && initFnName) { return new window[runtime]['v' + version]; } else { - if (!runtime || !version || !initFnName) { + if (runtime || version || initFnName) { var elementTag = $('
').append($element.clone()).html(); console.log('Block ' + elementTag + ' is missing data-runtime, data-runtime-version or data-init, and can\'t be initialized'); - } + } // else this XBlock doesn't have a JS init function. return null; } } diff --git a/common/test/acceptance/fixtures/base.py b/common/test/acceptance/fixtures/base.py new file mode 100644 index 0000000000..0f2e272383 --- /dev/null +++ b/common/test/acceptance/fixtures/base.py @@ -0,0 +1,196 @@ +""" +Common code shared by course and library fixtures. +""" +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(r'Logged in user {0} \({1}\) with password {2} and user_id {3}'.format( + r'(?P\S+)', r'(?P[^\)]+)', r'(?P\S+)', r'(?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 FixtureError(Exception): + """ + Error occurred while installing a course or library fixture. + """ + pass + + +class XBlockContainerFixture(StudioApiFixture): + """ + Base class for course and library fixtures. + """ + + 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 FixtureError(msg) + + try: + loc = response.json().get('locator') + xblock_desc.locator = loc + except ValueError: + raise 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 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 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 = [x for x in xblocks if x.category == category] + 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 69836fbee0..1e5bca8a33 100644 --- a/common/test/acceptance/fixtures/course.py +++ b/common/test/acceptance/fixtures/course.py @@ -4,77 +4,17 @@ Fixture to create a course and course components (XBlocks). 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, FixtureError class XBlockFixtureDesc(object): @@ -105,7 +45,7 @@ class XBlockFixtureDesc(object): 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. """ @@ -154,14 +94,7 @@ class XBlockFixtureDesc(object): CourseUpdateDesc = namedtuple("CourseUpdateDesc", ['date', 'content']) -class CourseFixtureError(Exception): - """ - Error occurred while installing a course fixture. - """ - pass - - -class CourseFixture(StudioApiFixture): +class CourseFixture(XBlockContainerFixture): """ Fixture for ensuring that a course exists. @@ -181,6 +114,7 @@ class CourseFixture(StudioApiFixture): 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 +136,6 @@ class CourseFixture(StudioApiFixture): self._updates = [] self._handouts = [] - self.children = [] self._assets = [] self._advanced_settings = {} self._course_key = None @@ -213,16 +146,6 @@ class CourseFixture(StudioApiFixture): """ 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`. @@ -252,7 +175,7 @@ class CourseFixture(StudioApiFixture): """ Create the course and XBlocks within the course. This is NOT an idempotent method; if the course already exists, this will - raise a `CourseFixtureError`. You should use unique course identifiers to avoid + raise a `FixtureError`. You should use unique course identifiers to avoid conflicts between tests. """ self._create_course() @@ -308,18 +231,18 @@ class CourseFixture(StudioApiFixture): err = response.json().get('ErrMsg') except ValueError: - raise CourseFixtureError( + raise FixtureError( "Could not parse response from course request as JSON: '{0}'".format( response.content)) # This will occur if the course identifier is not unique if err is not None: - raise CourseFixtureError("Could not create course {0}. Error message: '{1}'".format(self, err)) + raise FixtureError("Could not create course {0}. Error message: '{1}'".format(self, err)) if response.ok: self._course_key = response.json()['course_key'] else: - raise CourseFixtureError( + raise FixtureError( "Could not create course {0}. Status was {1}".format( self._course_dict, response.status_code)) @@ -333,14 +256,14 @@ class CourseFixture(StudioApiFixture): response = self.session.get(url, headers=self.headers) if not response.ok: - raise CourseFixtureError( + raise FixtureError( "Could not retrieve course details. Status was {0}".format( response.status_code)) try: details = response.json() except ValueError: - raise CourseFixtureError( + raise FixtureError( "Could not decode course details as JSON: '{0}'".format(details) ) @@ -354,7 +277,7 @@ class CourseFixture(StudioApiFixture): ) if not response.ok: - raise CourseFixtureError( + raise FixtureError( "Could not update course details to '{0}' with {1}: Status was {2}.".format( self._course_details, url, response.status_code)) @@ -382,7 +305,7 @@ class CourseFixture(StudioApiFixture): response = self.session.post(url, data=payload, headers=self.headers) if not response.ok: - raise CourseFixtureError( + raise FixtureError( "Could not update course handouts with {0}. Status was {1}".format(url, response.status_code)) def _install_course_updates(self): @@ -399,14 +322,14 @@ class CourseFixture(StudioApiFixture): response = self.session.post(url, headers=self.headers, data=payload) if not response.ok: - raise CourseFixtureError( + raise FixtureError( "Could not add update to course: {0} with {1}. Status was {2}".format( update, url, response.status_code)) def _upload_assets(self): """ Upload assets - :raise CourseFixtureError: + :raise FixtureError: """ url = STUDIO_BASE_URL + self._assets_url @@ -426,7 +349,7 @@ class CourseFixture(StudioApiFixture): upload_response = self.session.post(url, files=files, headers=headers) if not upload_response.ok: - raise CourseFixtureError('Could not upload {asset_name} with {url}. Status code: {code}'.format( + raise FixtureError('Could not upload {asset_name} with {url}. Status code: {code}'.format( asset_name=asset_name, url=url, code=upload_response.status_code)) def _add_advanced_settings(self): @@ -442,7 +365,7 @@ class CourseFixture(StudioApiFixture): ) if not response.ok: - raise CourseFixtureError( + raise FixtureError( "Could not update advanced details to '{0}' with {1}: Status was {2}.".format( self._advanced_settings, url, response.status_code)) @@ -450,101 +373,7 @@ class CourseFixture(StudioApiFixture): """ 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 0000000000..f97b8e9fc2 --- /dev/null +++ b/common/test/acceptance/fixtures/library.py @@ -0,0 +1,92 @@ +""" +Fixture to create a Content Library +""" + +from opaque_keys.edx.keys import CourseKey + +from . import STUDIO_BASE_URL +from .base import XBlockContainerFixture, FixtureError + + +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. + """ + + 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 `FixtureError`. 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 FixtureError( + "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 14a28703fe..3833b2581c 100644 --- a/common/test/acceptance/pages/studio/container.py +++ b/common/test/acceptance/pages/studio/container.py @@ -6,7 +6,7 @@ from bok_choy.page_object import PageObject 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): @@ -365,6 +365,12 @@ class XBlockWrapper(PageObject): """ 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 af163eca68..aed9a5faae 100644 --- a/common/test/acceptance/pages/studio/index.py +++ b/common/test/acceptance/pages/studio/index.py @@ -28,6 +28,13 @@ class DashboardPage(PageObject): def has_processing_courses(self): return self.q(css='.courses-processing').present + @property + def page_subheader(self): + """ + Get the text of the introductory copy seen below the Welcome header. ("Here are all of...") + """ + 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 +47,68 @@ class DashboardPage(PageObject): 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 0000000000..e87c556da9 --- /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 a94f50ba6f..dd8ec091a3 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 fa07533fba..ec94f7f058 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 @@ class ContainerBase(StudioCourseTest): # 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 0000000000..9dc9b02497 --- /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 0000000000..d5ad890e37 --- /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) From 058176144ee08b7a78ff48148f9dbe89837c3900 Mon Sep 17 00:00:00 2001 From: Jonathan Piacenti Date: Tue, 2 Dec 2014 17:58:34 +0000 Subject: [PATCH 02/99] Removed the ability to add Discussion and advanced components to Content Libraries. --- .../contentstore/views/component.py | 20 ++++++++--- cms/djangoapps/contentstore/views/item.py | 7 ++++ cms/djangoapps/contentstore/views/library.py | 2 +- .../contentstore/views/tests/test_item.py | 35 +++++++++++++++++++ .../contentstore/views/tests/test_library.py | 14 ++++++++ .../tests/studio/test_studio_library.py | 7 +++- 6 files changed, 78 insertions(+), 7 deletions(-) diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py index 9768542ea8..90f1dde267 100644 --- a/cms/djangoapps/contentstore/views/component.py +++ b/cms/djangoapps/contentstore/views/component.py @@ -217,9 +217,9 @@ def container_handler(request, usage_key_string): return HttpResponseBadRequest("Only supports HTML requests") -def get_component_templates(course): +def get_component_templates(courselike, library=False): """ - Returns the applicable component templates that can be used by the specified course. + Returns the applicable component templates that can be used by the specified course or library. """ def create_template_dict(name, cat, boilerplate_name=None, is_common=False): """ @@ -250,7 +250,13 @@ def get_component_templates(course): categories = set() # The component_templates array is in the order of "advanced" (if present), followed # by the components in the order listed in COMPONENT_TYPES. - for category in COMPONENT_TYPES: + component_types = COMPONENT_TYPES[:] + + # Libraries do not support discussions + if library: + component_types = [component for component in component_types if component != 'discussion'] + + for category in component_types: templates_for_category = [] component_class = _load_mixed_class(category) # add the default template with localized display name @@ -264,7 +270,7 @@ def get_component_templates(course): if hasattr(component_class, 'templates'): for template in component_class.templates(): filter_templates = getattr(component_class, 'filter_templates', None) - if not filter_templates or filter_templates(template, course): + if not filter_templates or filter_templates(template, courselike): templates_for_category.append( create_template_dict( _(template['metadata'].get('display_name')), @@ -289,11 +295,15 @@ def get_component_templates(course): "display_name": component_display_names[category] }) + # Libraries do not support advanced components at this time. + if library: + return component_templates + # Check if there are any advanced modules specified in the course policy. # These modules should be specified as a list of strings, where the strings # are the names of the modules in ADVANCED_COMPONENT_TYPES that should be # enabled for the course. - course_advanced_keys = course.advanced_modules + course_advanced_keys = courselike.advanced_modules advanced_component_templates = {"type": "advanced", "templates": [], "display_name": _("Advanced")} advanced_component_types = _advanced_component_types() # Set component types according to course policy file diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py index 37a7c83d53..67f1dc268e 100644 --- a/cms/djangoapps/contentstore/views/item.py +++ b/cms/djangoapps/contentstore/views/item.py @@ -460,6 +460,13 @@ def _create_item(request): if not has_course_author_access(request.user, usage_key.course_key): raise PermissionDenied() + if isinstance(usage_key, LibraryUsageLocator): + # Only these categories are supported at this time. + if category not in ['html', 'problem', 'video']: + return HttpResponseBadRequest( + "Category '%s' not supported for Libraries" % category, content_type='text/plain' + ) + store = modulestore() with store.bulk_operations(usage_key.course_key): parent = store.get_item(usage_key) diff --git a/cms/djangoapps/contentstore/views/library.py b/cms/djangoapps/contentstore/views/library.py index 15e54a37ce..1fdc8381a8 100644 --- a/cms/djangoapps/contentstore/views/library.py +++ b/cms/djangoapps/contentstore/views/library.py @@ -175,7 +175,7 @@ def library_blocks_view(library, response_format): }) xblock_info = create_xblock_info(library, include_ancestor_info=False, graders=[]) - component_templates = get_component_templates(library) + component_templates = get_component_templates(library, library=True) return render_to_response('library.html', { 'context_library': library, diff --git a/cms/djangoapps/contentstore/views/tests/test_item.py b/cms/djangoapps/contentstore/views/tests/test_item.py index 3f4fa86cba..d6d913a594 100644 --- a/cms/djangoapps/contentstore/views/tests/test_item.py +++ b/cms/djangoapps/contentstore/views/tests/test_item.py @@ -1469,6 +1469,41 @@ class TestLibraryXBlockInfo(ModuleStoreTestCase): self.assertIsNone(xblock_info.get('graders', None)) +class TestLibraryXBlockCreation(ItemTest): + """ + Tests the adding of XBlocks to Library + """ + def test_add_xblock(self): + """ + Verify we can add an XBlock to a Library. + """ + lib = LibraryFactory.create() + self.create_xblock(parent_usage_key=lib.location, display_name='Test', category="html") + lib = self.store.get_library(lib.location.library_key) + self.assertTrue(lib.children) + xblock_locator = lib.children[0] + self.assertEqual(self.store.get_item(xblock_locator).display_name, 'Test') + + def test_no_add_discussion(self): + """ + Verify we cannot add a discussion module to a Library. + """ + lib = LibraryFactory.create() + response = self.create_xblock(parent_usage_key=lib.location, display_name='Test', category='discussion') + self.assertEqual(response.status_code, 400) + lib = self.store.get_library(lib.location.library_key) + self.assertFalse(lib.children) + + def test_no_add_advanced(self): + lib = LibraryFactory.create() + lib.advanced_modules = ['lti'] + lib.save() + response = self.create_xblock(parent_usage_key=lib.location, display_name='Test', category='lti') + self.assertEqual(response.status_code, 400) + lib = self.store.get_library(lib.location.library_key) + self.assertFalse(lib.children) + + 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 index 8cae971087..9ab5bc06cc 100644 --- a/cms/djangoapps/contentstore/views/tests/test_library.py +++ b/cms/djangoapps/contentstore/views/tests/test_library.py @@ -4,6 +4,7 @@ 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 contentstore.views.component import get_component_templates from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import LibraryFactory from mock import patch @@ -183,3 +184,16 @@ class UnitTestLibraries(ModuleStoreTestCase): lib = LibraryFactory.create() response = self.client.get(make_url_for_lib(lib.location.library_key)) self.assertEqual(response.status_code, 403) + + def test_get_component_templates(self): + """ + Verify that templates for adding discussion and advanced components to + content libraries are not provided. + """ + lib = LibraryFactory.create() + lib.advanced_modules = ['lti'] + lib.save() + templates = [template['type'] for template in get_component_templates(lib, library=True)] + self.assertIn('problem', templates) + self.assertNotIn('discussion', templates) + self.assertNotIn('advanced', templates) diff --git a/common/test/acceptance/tests/studio/test_studio_library.py b/common/test/acceptance/tests/studio/test_studio_library.py index d5ad890e37..b505ac140d 100644 --- a/common/test/acceptance/tests/studio/test_studio_library.py +++ b/common/test/acceptance/tests/studio/test_studio_library.py @@ -1,7 +1,6 @@ """ 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 @@ -102,3 +101,9 @@ class LibraryEditPageTest(StudioLibraryTest): self.assertEqual(len(self.lib_page.xblocks), 1) problem_block = self.lib_page.xblocks[0] self.assertIn("Laura Roslin", problem_block.student_content) + + def test_no_discussion_button(self): + """ + Ensure the UI is not loaded for adding discussions. + """ + self.assertFalse(self.browser.find_elements_by_css_selector('span.large-discussion-icon')) From ff1a08cbd541d07def08adcf77c1dc8599329fcc Mon Sep 17 00:00:00 2001 From: "E. Kolpakov" Date: Mon, 3 Nov 2014 20:20:29 +0700 Subject: [PATCH 03/99] Paging for LibraryView added with JS tests. --- cms/djangoapps/contentstore/views/item.py | 18 +- cms/static/coffee/spec/main.coffee | 1 + cms/static/js/factories/container.js | 22 +- cms/static/js/factories/library.js | 22 +- .../js/spec/views/library_container_spec.js | 489 +++++++++ .../js/spec/views/pages/container_spec.js | 941 +++++++++--------- cms/static/js/views/container.js | 4 + cms/static/js/views/library_container.js | 164 +++ cms/static/js/views/pages/container.js | 52 +- cms/static/js/views/paging_footer.js | 6 + cms/static/sass/elements/_pagination.scss | 119 +++ .../sass/elements/_uploaded-assets.scss | 115 +-- cms/static/sass/elements/_xblocks.scss | 31 + cms/static/sass/style-app-extend1-rtl.scss | 1 + cms/static/sass/style-app-extend1.scss | 1 + cms/templates/container.html | 5 +- ...ontainer-paged-after-add-xblock.underscore | 283 ++++++ .../mock-container-paged-xblock.underscore | 257 +++++ cms/templates/library.html | 8 +- .../xmodule/xmodule/library_root_xblock.py | 54 +- .../xmodule/video_module/video_handlers.py | 1 - .../studio_render_paged_children_view.html | 23 + 22 files changed, 1987 insertions(+), 630 deletions(-) create mode 100644 cms/static/js/spec/views/library_container_spec.js create mode 100644 cms/static/js/views/library_container.js create mode 100644 cms/static/sass/elements/_pagination.scss create mode 100644 cms/templates/js/mock/mock-container-paged-after-add-xblock.underscore create mode 100644 cms/templates/js/mock/mock-container-paged-xblock.underscore create mode 100644 lms/templates/studio_render_paged_children_view.html diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py index 67f1dc268e..e46d83d70b 100644 --- a/cms/djangoapps/contentstore/views/item.py +++ b/cms/djangoapps/contentstore/views/item.py @@ -237,12 +237,28 @@ def xblock_view_handler(request, usage_key_string, view_name): if view_name == 'reorderable_container_child_preview': reorderable_items.add(xblock.location) + paging = None + try: + if request.REQUEST.get('enable_paging', 'false') == 'true': + paging = { + 'page_number': int(request.REQUEST.get('page_number', 0)), + 'page_size': int(request.REQUEST.get('page_size', 0)), + } + except ValueError: + log.exception( + "Couldn't parse paging parameters: enable_paging: %s, page_number: %s, page_size: %s", + request.REQUEST.get('enable_paging', 'false'), + request.REQUEST.get('page_number', 0), + request.REQUEST.get('page_size', 0) + ) + # Set up the context to be passed to each XBlock's render method. context = { 'is_pages_view': is_pages_view, # This setting disables the recursive wrapping of xblocks 'is_unit_page': is_unit(xblock), 'root_xblock': xblock if (view_name == 'container_preview') else None, - 'reorderable_items': reorderable_items + 'reorderable_items': reorderable_items, + 'paging': paging } fragment = get_preview_fragment(request, xblock, context) diff --git a/cms/static/coffee/spec/main.coffee b/cms/static/coffee/spec/main.coffee index 3a7b2c046a..1bd1177264 100644 --- a/cms/static/coffee/spec/main.coffee +++ b/cms/static/coffee/spec/main.coffee @@ -239,6 +239,7 @@ define([ "js/spec/views/assets_spec", "js/spec/views/baseview_spec", "js/spec/views/container_spec", + "js/spec/views/library_container_spec", "js/spec/views/group_configuration_spec", "js/spec/views/paging_spec", "js/spec/views/unit_outline_spec", diff --git a/cms/static/js/factories/container.js b/cms/static/js/factories/container.js index 93cdeb8fd9..ea48bb2a98 100644 --- a/cms/static/js/factories/container.js +++ b/cms/static/js/factories/container.js @@ -1,22 +1,20 @@ define([ - 'jquery', 'js/models/xblock_info', 'js/views/pages/container', + 'jquery', 'underscore', 'js/models/xblock_info', 'js/views/pages/container', 'js/collections/component_template', 'xmodule', 'coffee/src/main', 'xblock/cms.runtime.v1' ], -function($, XBlockInfo, ContainerPage, ComponentTemplates, xmoduleLoader) { +function($, _, XBlockInfo, ContainerPage, ComponentTemplates, xmoduleLoader) { 'use strict'; - return function (componentTemplates, XBlockInfoJson, action, isUnitPage) { - var templates = new ComponentTemplates(componentTemplates, {parse: true}), - mainXBlockInfo = new XBlockInfo(XBlockInfoJson, {parse: true}); + return function (componentTemplates, XBlockInfoJson, action, options) { + var main_options = { + el: $('#content'), + model: new XBlockInfo(XBlockInfoJson, {parse: true}), + action: action, + templates: new ComponentTemplates(componentTemplates, {parse: true}) + }; xmoduleLoader.done(function () { - var view = new ContainerPage({ - el: $('#content'), - model: mainXBlockInfo, - action: action, - templates: templates, - isUnitPage: isUnitPage - }); + var view = new ContainerPage(_.extend(main_options, options)); view.render(); }); }; diff --git a/cms/static/js/factories/library.js b/cms/static/js/factories/library.js index 2729a3cf27..e7834f60ef 100644 --- a/cms/static/js/factories/library.js +++ b/cms/static/js/factories/library.js @@ -1,22 +1,20 @@ define([ - 'jquery', 'js/models/xblock_info', 'js/views/pages/container', + 'jquery', 'underscore', 'js/models/xblock_info', 'js/views/pages/container', 'js/collections/component_template', 'xmodule', 'coffee/src/main', 'xblock/cms.runtime.v1' ], -function($, XBlockInfo, ContainerPage, ComponentTemplates, xmoduleLoader) { +function($, _, XBlockInfo, ContainerPage, ComponentTemplates, xmoduleLoader) { 'use strict'; - return function (componentTemplates, XBlockInfoJson) { - var templates = new ComponentTemplates(componentTemplates, {parse: true}), - mainXBlockInfo = new XBlockInfo(XBlockInfoJson, {parse: true}); + return function (componentTemplates, XBlockInfoJson, options) { + var main_options = { + el: $('#content'), + model: new XBlockInfo(XBlockInfoJson, {parse: true}), + templates: new ComponentTemplates(componentTemplates, {parse: true}), + action: 'view' + }; xmoduleLoader.done(function () { - var view = new ContainerPage({ - el: $('#content'), - model: mainXBlockInfo, - action: "view", - templates: templates, - isUnitPage: false - }); + var view = new ContainerPage(_.extend(main_options, options)); view.render(); }); }; diff --git a/cms/static/js/spec/views/library_container_spec.js b/cms/static/js/spec/views/library_container_spec.js new file mode 100644 index 0000000000..2d39cdc358 --- /dev/null +++ b/cms/static/js/spec/views/library_container_spec.js @@ -0,0 +1,489 @@ +define([ "jquery", "underscore", "js/common_helpers/ajax_helpers", "URI", "js/models/xblock_info", + "js/views/library_container", "js/views/paging_header", "js/views/paging_footer"], + function ($, _, AjaxHelpers, URI, XBlockInfo, PagedContainer, PagingContainer, PagingFooter) { + + var htmlResponseTpl = _.template('' + + '
' + ); + + function getResponseHtml(options){ + return '
' + + '
' + + htmlResponseTpl(options) + + '' + + '
' + } + + var PAGE_SIZE = 3; + + var mockFirstPage = { + resources: [], + html: getResponseHtml({ + start: 0, + displayed: PAGE_SIZE, + total: PAGE_SIZE + 1 + }) + }; + + var mockSecondPage = { + resources: [], + html: getResponseHtml({ + start: PAGE_SIZE, + displayed: 1, + total: PAGE_SIZE + 1 + }) + }; + + var mockEmptyPage = { + resources: [], + html: getResponseHtml({ + start: 0, + displayed: 0, + total: 0 + }) + }; + + var respondWithMockPage = function(requests) { + var requestIndex = requests.length - 1; + var request = requests[requestIndex]; + var url = new URI(request.url); + var queryParameters = url.query(true); // Returns an object with each query parameter stored as a value + var page = queryParameters.page_number; + var response = page === "0" ? mockFirstPage : mockSecondPage; + AjaxHelpers.respondWithJson(requests, response, requestIndex); + }; + + var MockPagingView = PagedContainer.extend({ + view: 'container_preview', + el: $("
"), + model: new XBlockInfo({}, {parse: true}) + }); + + describe("Paging Container", function() { + var pagingContainer; + + beforeEach(function () { + var feedbackTpl = readFixtures('system-feedback.underscore'); + setFixtures($(" + + +
+ +
+
+
+
+ +
+
    +
  • + +
  • +
+
+
+
+
+
+
+
+
+
+
+
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+
+
+
+
+
+
+
+
+
+
+
+
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+
+
+
+
+
+
+
+
+
+
+
+
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+
+
+
+
+
+
+
+
+
+
+
+
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
    +
  • + +
  • +
+
+
+
+ +
+
+
+
+
+
+
+
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+
+
+
+
+
+
+
+
+
+
+
+
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+
+
+
+
+
+
+
+
+
+
+
+
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+
+
+
+
+
+
+
+
+
+
+ +
+ diff --git a/cms/templates/js/mock/mock-container-paged-xblock.underscore b/cms/templates/js/mock/mock-container-paged-xblock.underscore new file mode 100644 index 0000000000..2314bb8925 --- /dev/null +++ b/cms/templates/js/mock/mock-container-paged-xblock.underscore @@ -0,0 +1,257 @@ +
+
+
+ Test Container +
+
+
    +
+
+
+
+
+
+ + + + +
+ +
+
+
+
+ +
+
    +
  • + +
  • +
+
+
+
+
+
+
+
+
+
+
+
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+
+
+
+
+
+
+
+
+
+
+
+
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+
+
+
+
+
+
+
+
+
+
+
+
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
    +
  • + +
  • +
+
+
+
+ +
+
+
+
+
+
+
+
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+
+
+
+
+
+
+
+
+
+
+
+
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+
+
+
+
+
+
+
+
+
+
+
+
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+
+
+
+
+
+
+
+
+
+
+ +
+
diff --git a/cms/templates/library.html b/cms/templates/library.html index 70bd836bad..dc9baa5736 100644 --- a/cms/templates/library.html +++ b/cms/templates/library.html @@ -22,8 +22,12 @@ from django.utils.translation import ugettext as _ <%block name="requirejs"> require(["js/factories/library"], function(LibraryFactory) { LibraryFactory( - ${component_templates | n}, - ${json.dumps(xblock_info) | n} + ${component_templates | n}, ${json.dumps(xblock_info) | n}, + { + isUnitPage: false, + enable_paging: true, + page_size: 10 + } ); }); diff --git a/common/lib/xmodule/xmodule/library_root_xblock.py b/common/lib/xmodule/xmodule/library_root_xblock.py index dc00aaa97f..497a145b79 100644 --- a/common/lib/xmodule/xmodule/library_root_xblock.py +++ b/common/lib/xmodule/xmodule/library_root_xblock.py @@ -3,10 +3,10 @@ """ import logging -from .studio_editable import StudioEditableModule from xblock.core import XBlock from xblock.fields import Scope, String, List from xblock.fragment import Fragment +from xmodule.studio_editable import StudioEditableModule log = logging.getLogger(__name__) @@ -42,29 +42,55 @@ class LibraryRoot(XBlock): def author_view(self, context): """ - Renders the Studio preview view, which supports drag and drop. + Renders the Studio preview view. """ fragment = Fragment() + self.render_children(context, fragment, can_reorder=False, can_add=True) + return fragment + + def render_children(self, context, fragment, can_reorder=False, can_add=False): # pylint: disable=unused-argument + """ + Renders the children of the module with HTML appropriate for Studio. If can_reorder is True, + then the children will be rendered to support drag and drop. + """ contents = [] - for child_key in self.children: # pylint: disable=E1101 - context['reorderable_items'].add(child_key) + paging = context.get('paging', None) + + children_count = len(self.children) # pylint: disable=no-member + item_start, item_end = 0, children_count + + # TODO sort children + if paging: + page_number = paging.get('page_number', 0) + raw_page_size = paging.get('page_size', None) + page_size = raw_page_size if raw_page_size is not None else children_count + item_start, item_end = page_size * page_number, page_size * (page_number + 1) + + children_to_show = self.children[item_start:item_end] # pylint: disable=no-member + + for child_key in children_to_show: # pylint: disable=E1101 child = self.runtime.get_block(child_key) - rendered_child = self.runtime.render_child(child, StudioEditableModule.get_preview_view_name(child), context) + child_view_name = StudioEditableModule.get_preview_view_name(child) + rendered_child = self.runtime.render_child(child, child_view_name, context) fragment.add_frag_resources(rendered_child) contents.append({ - 'id': unicode(child_key), - 'content': rendered_child.content, + 'id': child.location.to_deprecated_string(), + 'content': rendered_child.content }) - fragment.add_content(self.runtime.render_template("studio_render_children_view.html", { - 'items': contents, - 'xblock_context': context, - 'can_add': True, - 'can_reorder': True, - })) - return fragment + fragment.add_content( + self.runtime.render_template("studio_render_paged_children_view.html", { + 'items': contents, + 'xblock_context': context, + 'can_add': can_add, + 'can_reorder': False, + 'first_displayed': item_start, + 'total_children': children_count, + 'displayed_children': len(children_to_show) + }) + ) @property def display_org_with_default(self): diff --git a/common/lib/xmodule/xmodule/video_module/video_handlers.py b/common/lib/xmodule/xmodule/video_module/video_handlers.py index 9e9db860ca..1ba427c357 100644 --- a/common/lib/xmodule/xmodule/video_module/video_handlers.py +++ b/common/lib/xmodule/xmodule/video_module/video_handlers.py @@ -155,7 +155,6 @@ class VideoStudentViewHandlers(object): if transcript_name: # Get the asset path for course - asset_path = None course = self.descriptor.runtime.modulestore.get_course(self.course_id) if course.static_asset_path: asset_path = course.static_asset_path diff --git a/lms/templates/studio_render_paged_children_view.html b/lms/templates/studio_render_paged_children_view.html new file mode 100644 index 0000000000..fe5b5403e1 --- /dev/null +++ b/lms/templates/studio_render_paged_children_view.html @@ -0,0 +1,23 @@ +<%! from django.utils.translation import ugettext as _ %> + +<%namespace name='static' file='static_content.html'/> + +% for template_name in ["paging-header", "paging-footer"]: + +% endfor + +
+ +
+ +% for item in items: + ${item['content']} +% endfor + +% if can_add: +
+% endif + + From ed3b9720783bce91505431580d2d421395321040 Mon Sep 17 00:00:00 2001 From: Jonathan Piacenti Date: Thu, 4 Dec 2014 21:25:52 +0000 Subject: [PATCH 04/99] Added tests for Library pagination. --- .../test/acceptance/pages/studio/library.py | 53 ++++++ .../tests/studio/test_studio_library.py | 161 ++++++++++++++++++ 2 files changed, 214 insertions(+) diff --git a/common/test/acceptance/pages/studio/library.py b/common/test/acceptance/pages/studio/library.py index e87c556da9..5572b1a91e 100644 --- a/common/test/acceptance/pages/studio/library.py +++ b/common/test/acceptance/pages/studio/library.py @@ -3,6 +3,7 @@ Library edit page in Studio """ from bok_choy.page_object import PageObject +from selenium.webdriver.common.keys import Keys from .container import XBlockWrapper from ...tests.helpers import disable_animations from .utils import confirm_prompt, wait_for_notification @@ -74,6 +75,58 @@ class LibraryPage(PageObject): confirm_prompt(self) # this will also wait_for_notification() self.wait_for_ajax() + def nav_disabled(self, position, arrows=('next', 'previous')): + """ + Verifies that pagination nav is disabled. Position can be 'top' or 'bottom'. + + To specify a specific arrow, pass an iterable with a single element, 'next' or 'previous'. + """ + return all([ + self.q(css='nav.%s * a.%s-page-link.is-disabled' % (position, arrow)) + for arrow in arrows + ]) + + def move_back(self, position): + """ + Clicks one of the forward nav buttons. Position can be 'top' or 'bottom'. + """ + self.q(css='nav.%s * a.previous-page-link' % position)[0].click() + self.wait_until_ready() + + def move_forward(self, position): + """ + Clicks one of the forward nav buttons. Position can be 'top' or 'bottom'. + """ + self.q(css='nav.%s * a.next-page-link' % position)[0].click() + self.wait_until_ready() + + def revisit(self): + """ + Visit the page's URL, instead of refreshing, so that a new state is created. + """ + self.browser.get(self.browser.current_url) + self.wait_until_ready() + + def go_to_page(self, number): + """ + Enter a number into the page number input field, and then try to navigate to it. + """ + page_input = self.q(css="#page-number-input")[0] + page_input.click() + page_input.send_keys(str(number)) + page_input.send_keys(Keys.RETURN) + self.wait_until_ready() + + def check_page_unchanged(self, first_block_name): + """ + Used to make sure that a page has not transitioned after a bogus number is given. + """ + if not self.xblocks[0].name == first_block_name: + return False + if not self.q(css='#page-number-input')[0].get_attribute('value') == '': + return False + return True + def _get_xblocks(self): """ Create an XBlockWrapper for each XBlock div found on the page. diff --git a/common/test/acceptance/tests/studio/test_studio_library.py b/common/test/acceptance/tests/studio/test_studio_library.py index b505ac140d..5529f36032 100644 --- a/common/test/acceptance/tests/studio/test_studio_library.py +++ b/common/test/acceptance/tests/studio/test_studio_library.py @@ -1,11 +1,14 @@ """ Acceptance tests for Content Libraries in Studio """ +from ddt import ddt, data + from .base_studio_test import StudioLibraryTest from ...pages.studio.utils import add_component from ...pages.studio.library import LibraryPage +@ddt class LibraryEditPageTest(StudioLibraryTest): """ Test the functionality of the library edit page. @@ -107,3 +110,161 @@ class LibraryEditPageTest(StudioLibraryTest): Ensure the UI is not loaded for adding discussions. """ self.assertFalse(self.browser.find_elements_by_css_selector('span.large-discussion-icon')) + + def test_library_pagination(self): + """ + Scenario: Ensure that adding several XBlocks to a library results in pagination. + Given that I have a library in Studio with no XBlocks + And I create 10 Multiple Choice XBlocks + Then 10 are displayed. + When I add one more Multiple Choice XBlock + Then 1 XBlock will be displayed + When I delete that XBlock + Then 10 are displayed. + """ + self.assertEqual(len(self.lib_page.xblocks), 0) + for _ in range(0, 10): + add_component(self.lib_page, "problem", "Multiple Choice") + self.assertEqual(len(self.lib_page.xblocks), 10) + add_component(self.lib_page, "problem", "Multiple Choice") + self.assertEqual(len(self.lib_page.xblocks), 1) + self.lib_page.click_delete_button(self.lib_page.xblocks[0].locator) + self.assertEqual(len(self.lib_page.xblocks), 10) + + @data('top', 'bottom') + def test_nav_present_but_disabled(self, position): + """ + Scenario: Ensure that the navigation buttons aren't active when there aren't enough XBlocks. + Given that I have a library in Studio with no XBlocks + The Navigation buttons should be disabled. + When I add 5 multiple Choice XBlocks + The Navigation buttons should be disabled. + """ + self.assertEqual(len(self.lib_page.xblocks), 0) + self.assertTrue(self.lib_page.nav_disabled(position)) + for _ in range(0, 5): + add_component(self.lib_page, "problem", "Multiple Choice") + self.assertTrue(self.lib_page.nav_disabled(position)) + + @data('top', 'bottom') + def test_nav_buttons(self, position): + """ + Scenario: Ensure that the navigation buttons work. + Given that I have a library in Studio with no XBlocks + And I create 10 Multiple Choice XBlocks + And I create 10 Checkbox XBlocks + And I create 10 Dropdown XBlocks + And I revisit the page + The previous button should be disabled. + The first XBlock should be a Multiple Choice XBlock + Then if I hit the next button + The first XBlock should be a Checkboxes XBlock + Then if I hit the next button + The first XBlock should be a Dropdown XBlock + And the next button should be disabled + Then if I hit the previous button + The first XBlock should be an Checkboxes XBlock + Then if I hit the previous button + The first XBlock should be a Multipe Choice XBlock + And the previous button should be disabled + """ + self.assertEqual(len(self.lib_page.xblocks), 0) + block_types = [('problem', 'Multiple Choice'), ('problem', 'Checkboxes'), ('problem', 'Dropdown')] + for block_type in block_types: + for _ in range(0, 10): + add_component(self.lib_page, *block_type) + + # Don't refresh, as that may contain additional state. + self.lib_page.revisit() + + # Check forward navigation + self.assertTrue(self.lib_page.nav_disabled(position, ['previous'])) + self.assertEqual(self.lib_page.xblocks[0].name, 'Multiple Choice') + self.lib_page.move_forward(position) + self.assertEqual(self.lib_page.xblocks[0].name, 'Checkboxes') + self.lib_page.move_forward(position) + self.assertEqual(self.lib_page.xblocks[0].name, 'Dropdown') + self.lib_page.nav_disabled(position, ['next']) + + # Check backward navigation + self.lib_page.move_back(position) + self.assertEqual(self.lib_page.xblocks[0].name, 'Checkboxes') + self.lib_page.move_back(position) + self.assertEqual(self.lib_page.xblocks[0].name, 'Multiple Choice') + self.assertTrue(self.lib_page.nav_disabled(position, ['previous'])) + + def test_arbitrary_page_selection(self): + """ + Scenario: I can pick a specific page number of a Library at will. + Given that I have a library in Studio with no XBlocks + And I create 10 Multiple Choice XBlocks + And I create 10 Checkboxes XBlocks + And I create 10 Dropdown XBlocks + And I create 10 Numerical Input XBlocks + And I revisit the page + When I go to the 3rd page + The first XBlock should be a Dropdown XBlock + When I go to the 4th Page + The first XBlock should be a Numerical Input XBlock + When I go to the 1st page + The first XBlock should be a Multiple Choice XBlock + When I go to the 2nd page + The first XBlock should be a Checkboxes XBlock + """ + self.assertEqual(len(self.lib_page.xblocks), 0) + block_types = [ + ('problem', 'Multiple Choice'), ('problem', 'Checkboxes'), ('problem', 'Dropdown'), + ('problem', 'Numerical Input'), + ] + for block_type in block_types: + for _ in range(0, 10): + add_component(self.lib_page, *block_type) + + # Don't refresh, as that may contain additional state. + self.lib_page.revisit() + self.lib_page.go_to_page(3) + self.assertEqual(self.lib_page.xblocks[0].name, 'Dropdown') + self.lib_page.go_to_page(4) + self.assertEqual(self.lib_page.xblocks[0].name, 'Numerical Input') + self.lib_page.go_to_page(1) + self.assertEqual(self.lib_page.xblocks[0].name, 'Multiple Choice') + self.lib_page.go_to_page(2) + self.assertEqual(self.lib_page.xblocks[0].name, 'Checkboxes') + + def test_bogus_page_selection(self): + """ + Scenario: I can't pick a nonsense page number of a Library + Given that I have a library in Studio with no XBlocks + And I create 10 Multiple Choice XBlocks + And I create 10 Checkboxes XBlocks + And I create 10 Dropdown XBlocks + And I create 10 Numerical Input XBlocks + And I revisit the page + When I attempt to go to the 'a'th page + The input field will be cleared and no change of XBlocks will be made + When I attempt to visit the 5th page + The input field will be cleared and no change of XBlocks will be made + When I attempt to visit the -1st page + The input field will be cleared and no change of XBlocks will be made + When I attempt to visit the 0th page + The input field will be cleared and no change of XBlocks will be made + """ + self.assertEqual(len(self.lib_page.xblocks), 0) + block_types = [ + ('problem', 'Multiple Choice'), ('problem', 'Checkboxes'), ('problem', 'Dropdown'), + ('problem', 'Numerical Input'), + ] + for block_type in block_types: + for _ in range(0, 10): + add_component(self.lib_page, *block_type) + + self.lib_page.revisit() + self.assertEqual(self.lib_page.xblocks[0].name, 'Multiple Choice') + self.lib_page.go_to_page('a') + self.assertTrue(self.lib_page.check_page_unchanged('Multiple Choice')) + self.lib_page.go_to_page(-1) + self.assertTrue(self.lib_page.check_page_unchanged('Multiple Choice')) + self.lib_page.go_to_page(5) + self.assertTrue(self.lib_page.check_page_unchanged('Multiple Choice')) + self.lib_page.go_to_page(0) + self.assertTrue(self.lib_page.check_page_unchanged('Multiple Choice')) From 80c517ecd1c8eb3e327ee67f7d827751d26a20c3 Mon Sep 17 00:00:00 2001 From: Jonathan Piacenti Date: Mon, 8 Dec 2014 22:22:02 +0000 Subject: [PATCH 05/99] Addressed notes from reviewers on Library Pagination. --- cms/djangoapps/contentstore/views/item.py | 5 +- .../js/spec/views/pages/container_spec.js | 32 +- cms/static/js/views/library_container.js | 71 +++-- cms/static/js/views/pages/container.js | 23 +- cms/static/sass/elements/_pagination.scss | 8 +- cms/static/sass/elements/_xblocks.scss | 2 +- ...ontainer-paged-after-add-xblock.underscore | 283 ------------------ .../js/mock/mock-xblock-paged.underscore | 21 ++ .../xmodule/xmodule/library_root_xblock.py | 3 +- .../xmodule/video_module/video_handlers.py | 1 + 10 files changed, 113 insertions(+), 336 deletions(-) delete mode 100644 cms/templates/js/mock/mock-container-paged-after-add-xblock.underscore create mode 100644 cms/templates/js/mock/mock-xblock-paged.underscore diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py index e46d83d70b..bd2a57e1a9 100644 --- a/cms/djangoapps/contentstore/views/item.py +++ b/cms/djangoapps/contentstore/views/item.py @@ -206,6 +206,7 @@ def xblock_view_handler(request, usage_key_string, view_name): store = modulestore() xblock = store.get_item(usage_key) container_views = ['container_preview', 'reorderable_container_child_preview'] + library = isinstance(usage_key, LibraryUsageLocator) # wrap the generated fragment in the xmodule_editor div so that the javascript # can bind to it correctly @@ -234,7 +235,7 @@ def xblock_view_handler(request, usage_key_string, view_name): # are being shown in a reorderable container, so the xblock is automatically # added to the list. reorderable_items = set() - if view_name == 'reorderable_container_child_preview': + if not library and view_name == 'reorderable_container_child_preview': reorderable_items.add(xblock.location) paging = None @@ -258,7 +259,7 @@ def xblock_view_handler(request, usage_key_string, view_name): 'is_unit_page': is_unit(xblock), 'root_xblock': xblock if (view_name == 'container_preview') else None, 'reorderable_items': reorderable_items, - 'paging': paging + 'paging': paging, } fragment = get_preview_fragment(request, xblock, context) diff --git a/cms/static/js/spec/views/pages/container_spec.js b/cms/static/js/spec/views/pages/container_spec.js index 6f4b4baf46..d5ec6938dc 100644 --- a/cms/static/js/spec/views/pages/container_spec.js +++ b/cms/static/js/spec/views/pages/container_spec.js @@ -273,7 +273,7 @@ define(["jquery", "underscore", "underscore.string", "js/common_helpers/ajax_hel }); describe("xblock operations", function () { - var getGroupElement, + var getGroupElement, paginated, NUM_COMPONENTS_PER_GROUP = 3, GROUP_TO_TEST = "A", allComponentsInGroup = _.map( _.range(NUM_COMPONENTS_PER_GROUP), @@ -282,6 +282,11 @@ define(["jquery", "underscore", "underscore.string", "js/common_helpers/ajax_hel } ); + paginated = function () { + return containerPage.enable_paging; + }; + + getGroupElement = function () { return containerPage.$("[data-locator='locator-group-" + GROUP_TO_TEST + "']"); }; @@ -294,6 +299,7 @@ define(["jquery", "underscore", "underscore.string", "js/common_helpers/ajax_hel promptSpy = EditHelpers.createPromptSpy(); }); + clickDelete = function (componentIndex, clickNo) { // find all delete buttons for the given group @@ -307,21 +313,25 @@ define(["jquery", "underscore", "underscore.string", "js/common_helpers/ajax_hel EditHelpers.confirmPrompt(promptSpy, clickNo); }; - deleteComponent = function (componentIndex) { + deleteComponent = function (componentIndex, requestOffset) { clickDelete(componentIndex); AjaxHelpers.respondWithJson(requests, {}); // second to last request contains given component's id (to delete the component) AjaxHelpers.expectJsonRequest(requests, 'DELETE', '/xblock/locator-component-' + GROUP_TO_TEST + (componentIndex + 1), - null, requests.length - 2); + null, requests.length - requestOffset); // final request to refresh the xblock info AjaxHelpers.expectJsonRequest(requests, 'GET', '/xblock/locator-container'); }; deleteComponentWithSuccess = function (componentIndex) { - deleteComponent(componentIndex); + var deleteOffset; + + deleteOffset = paginated() ? 3 : 2; + + deleteComponent(componentIndex, deleteOffset); // verify the new list of components within the group expectComponents( @@ -350,9 +360,16 @@ define(["jquery", "underscore", "underscore.string", "js/common_helpers/ajax_hel containerPage.$('.delete-button').first().click(); EditHelpers.confirmPrompt(promptSpy); AjaxHelpers.respondWithJson(requests, {}); + var deleteOffset; + + if (paginated()) { + deleteOffset = 3; + } else { + deleteOffset = 2; + } // expect the second to last request to be a delete of the xblock AjaxHelpers.expectJsonRequest(requests, 'DELETE', '/xblock/locator-broken-javascript', - null, requests.length - 2); + null, requests.length - deleteOffset); // expect the last request to be a fetch of the xblock info for the parent container AjaxHelpers.expectJsonRequest(requests, 'GET', '/xblock/locator-container'); }); @@ -511,7 +528,7 @@ define(["jquery", "underscore", "underscore.string", "js/common_helpers/ajax_hel }); describe('Template Picker', function () { - var showTemplatePicker, verifyCreateHtmlComponent; + var showTemplatePicker, verifyCreateHtmlComponent, call_count; showTemplatePicker = function () { containerPage.$('.new-component .new-component-type a.multiple-templates')[0].click(); @@ -519,6 +536,7 @@ define(["jquery", "underscore", "underscore.string", "js/common_helpers/ajax_hel verifyCreateHtmlComponent = function (test, templateIndex, expectedRequest) { var xblockCount; + // call_count = paginated() ? 18: 10; renderContainerPage(test, mockContainerXBlockHtml); showTemplatePicker(); xblockCount = containerPage.$('.studio-xblock-wrapper').length; @@ -557,6 +575,6 @@ define(["jquery", "underscore", "underscore.string", "js/common_helpers/ajax_hel { enable_paging: true, page_size: 42 }, { initial: 'mock/mock-container-paged-xblock.underscore', - add_response: 'mock/mock-container-paged-after-add-xblock.underscore' + add_response: 'mock/mock-xblock-paged.underscore' }); }); diff --git a/cms/static/js/views/library_container.js b/cms/static/js/views/library_container.js index b655833289..a8c15999ab 100644 --- a/cms/static/js/views/library_container.js +++ b/cms/static/js/views/library_container.js @@ -1,19 +1,16 @@ -define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext", "js/views/feedback_notification", +define(["jquery", "underscore", "js/views/container", "js/utils/module", "gettext", "js/views/feedback_notification", "js/views/paging_header", "js/views/paging_footer"], - function ($, _, XBlockView, ModuleUtils, gettext, NotificationView, PagingHeader, PagingFooter) { - var LibraryContainerView = XBlockView.extend({ + function ($, _, ContainerView, ModuleUtils, gettext, NotificationView, PagingHeader, PagingFooter) { + var LibraryContainerView = ContainerView.extend({ // Store the request token of the first xblock on the page (which we know was rendered by Studio when // the page was generated). Use that request token to filter out user-defined HTML in any // child xblocks within the page. - requestToken: "", initialize: function(options){ var self = this; - XBlockView.prototype.initialize.call(this); + ContainerView.prototype.initialize.call(this); this.page_size = this.options.page_size || 10; - if (options) { - this.page_reload_callback = options.page_reload_callback; - } + this.page_reload_callback = options.page_reload_callback || function () {}; // emulating Backbone.paginator interface this.collection = { currentPage: 0, @@ -30,9 +27,6 @@ define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext", render: function(options) { var eff_options = options || {}; - if (eff_options.block_added) { - this.collection.currentPage = this.getPageCount(this.collection.totalCount+1) - 1; - } eff_options.page_number = typeof eff_options.page_number !== "undefined" ? eff_options.page_number : this.collection.currentPage; @@ -53,9 +47,8 @@ define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext", success: function(fragment) { self.handleXBlockFragment(fragment, options); self.processPaging({ requested_page: options.page_number }); - if (options.paging && self.page_reload_callback){ - self.page_reload_callback(self.$el); - } + // This is expected to render the add xblock components menu. + self.page_reload_callback(self.$el) } }); }, @@ -69,12 +62,12 @@ define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext", }, getPageCount: function(total_count){ - if (total_count==0) return 1; + if (total_count===0) return 1; return Math.ceil(total_count / this.page_size); }, setPage: function(page_number) { - this.render({ page_number: page_number, paging: true }); + this.render({ page_number: page_number}); }, nextPage: function() { @@ -129,32 +122,54 @@ define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext", }, xblockReady: function () { - XBlockView.prototype.xblockReady.call(this); + ContainerView.prototype.xblockReady.call(this); this.requestToken = this.$('div.xblock').first().data('request-token'); }, - refresh: function() { }, + refresh: function(block_added) { + if (block_added) { + this.collection.totalCount += 1; + this.collection._size +=1; + if (this.collection.totalCount == 1) { + this.render(); + return + } + this.collection.totalPages = this.getPageCount(this.collection.totalCount); + var new_page = this.collection.totalPages - 1; + // If we're on a new page due to overflow, or this is the first item, set the page. + if (((this.collection.currentPage) != new_page) || this.collection.totalCount == 1) { + this.setPage(new_page); + } else { + this.pagingHeader.render(); + this.pagingFooter.render(); + } + } + }, acknowledgeXBlockDeletion: function (locator){ this.notifyRuntime('deleted-child', locator); this.collection._size -= 1; this.collection.totalCount -= 1; - // pages are counted from 0 - thus currentPage == 1 if we're on second page - if (this.collection._size == 0 && this.collection.currentPage >= 1) { - this.setPage(this.collection.currentPage - 1); - this.collection.totalPages -= 1; - } - else { + var current_page = this.collection.currentPage; + var total_pages = this.getPageCount(this.collection.totalCount); + this.collection.totalPages = total_pages; + // Starts counting from 0 + if ((current_page + 1) > total_pages) { + // The number of total pages has changed. Move down. + // Also, be mindful of the off-by-one. + this.setPage(total_pages - 1) + } else if ((current_page + 1) != total_pages) { + // Refresh page to get any blocks shifted from the next page. + this.setPage(current_page) + } else { + // We're on the last page, just need to update the numbers in the + // pagination interface. this.pagingHeader.render(); this.pagingFooter.render(); } }, - makeRequestSpecificSelector: function(selector) { - return 'div.xblock[data-request-token="' + this.requestToken + '"] > ' + selector; - }, - sortDisplayName: function() { return "Date added"; // TODO add support for sorting } diff --git a/cms/static/js/views/pages/container.js b/cms/static/js/views/pages/container.js index c63e7f00f6..771e9afb1b 100644 --- a/cms/static/js/views/pages/container.js +++ b/cms/static/js/views/pages/container.js @@ -119,8 +119,11 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views // Notify the runtime that the page has been successfully shown xblockView.notifyRuntime('page-shown', self); - // Render the add buttons - self.renderAddXBlockComponents(); + // Render the add buttons. Paged containers should do this on their own. + if (!self.enable_paging) { + // Render the add buttons + self.renderAddXBlockComponents(); + } // Refresh the views now that the xblock is visible self.onXBlockRefresh(xblockView); @@ -141,8 +144,8 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views return this.xblockView.model.urlRoot; }, - onXBlockRefresh: function(xblockView) { - this.xblockView.refresh(); + onXBlockRefresh: function(xblockView, block_added) { + this.xblockView.refresh(block_added); // Update publish and last modified information from the server. this.model.fetch(); }, @@ -274,10 +277,10 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views rootLocator = this.xblockView.model.id; if (xblockElement.length === 0 || xblockElement.data('locator') === rootLocator) { this.render({refresh: true, block_added: block_added}); - } else if (parentElement.hasClass('reorderable-container')) { - this.refreshChildXBlock(xblockElement); + } else if (parentElement.hasClass('reorderable-container') || this.enable_paging) { + this.refreshChildXBlock(xblockElement, block_added); } else { - this.refreshXBlock(this.findXBlockElement(parentElement), block_added); + this.refreshXBlock(this.findXBlockElement(parentElement)); } }, @@ -285,9 +288,11 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views * Refresh an xblock element inline on the page, using the specified xblockInfo. * Note that the element is removed and replaced with the newly rendered xblock. * @param xblockElement The xblock element to be refreshed. + * @param block_added Specifies if a block has been added, rather than just needs + * refreshing. * @returns {jQuery promise} A promise representing the complete operation. */ - refreshChildXBlock: function(xblockElement) { + refreshChildXBlock: function(xblockElement, block_added) { var self = this, xblockInfo, TemporaryXBlockView, @@ -313,7 +318,7 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views }); return temporaryView.render({ success: function() { - self.onXBlockRefresh(temporaryView); + self.onXBlockRefresh(temporaryView, block_added); temporaryView.unbind(); // Remove the temporary view } }); diff --git a/cms/static/sass/elements/_pagination.scss b/cms/static/sass/elements/_pagination.scss index f3ba465b80..379d8785e3 100644 --- a/cms/static/sass/elements/_pagination.scss +++ b/cms/static/sass/elements/_pagination.scss @@ -2,7 +2,7 @@ // ========================== %pagination { - @include clearfix; + @include clearfix(); display: inline-block; width: flex-grid(3, 12); @@ -48,7 +48,7 @@ } .nav-label { - @extend .sr; + @extend %cont-text-sr; } .pagination-form, @@ -89,7 +89,7 @@ .page-number-label, .submit-pagination-form { - @extend .sr; + @extend %cont-text-sr; } .page-number-input { @@ -116,4 +116,4 @@ } } } -} \ No newline at end of file +} diff --git a/cms/static/sass/elements/_xblocks.scss b/cms/static/sass/elements/_xblocks.scss index 66b1e603a6..68298caaf8 100644 --- a/cms/static/sass/elements/_xblocks.scss +++ b/cms/static/sass/elements/_xblocks.scss @@ -105,7 +105,7 @@ .container-paging-header { .meta-wrap { - margin: $baseline $baseline/2; + margin: $baseline ($baseline/2); } .meta { @extend %t-copy-sub2; diff --git a/cms/templates/js/mock/mock-container-paged-after-add-xblock.underscore b/cms/templates/js/mock/mock-container-paged-after-add-xblock.underscore deleted file mode 100644 index cb260c9bca..0000000000 --- a/cms/templates/js/mock/mock-container-paged-after-add-xblock.underscore +++ /dev/null @@ -1,283 +0,0 @@ - -
-
-
- Test Container -
-
-
    -
-
-
-
-
-
- - - - -
- -
-
-
-
- -
-
    -
  • - -
  • -
-
-
-
-
-
-
-
-
-
-
-
    -
  • - -
  • -
  • - -
  • -
  • - -
  • -
  • - -
  • -
-
-
-
-
-
-
-
-
-
-
-
-
    -
  • - -
  • -
  • - -
  • -
  • - -
  • -
  • - -
  • -
-
-
-
-
-
-
-
-
-
-
-
-
    -
  • - -
  • -
  • - -
  • -
  • - -
  • -
  • - -
  • -
-
-
-
-
-
-
-
-
-
-
-
-
    -
  • - -
  • -
  • - -
  • -
  • - -
  • -
  • - -
  • -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- -
-
    -
  • - -
  • -
-
-
-
- -
-
-
-
-
-
-
-
    -
  • - -
  • -
  • - -
  • -
  • - -
  • -
  • - -
  • -
-
-
-
-
-
-
-
-
-
-
-
-
    -
  • - -
  • -
  • - -
  • -
  • - -
  • -
  • - -
  • -
-
-
-
-
-
-
-
-
-
-
-
-
    -
  • - -
  • -
  • - -
  • -
  • - -
  • -
  • - -
  • -
-
-
-
-
-
-
-
-
-
-
- -
-
diff --git a/cms/templates/js/mock/mock-xblock-paged.underscore b/cms/templates/js/mock/mock-xblock-paged.underscore new file mode 100644 index 0000000000..c6c2c881d8 --- /dev/null +++ b/cms/templates/js/mock/mock-xblock-paged.underscore @@ -0,0 +1,21 @@ +
+
+
+ Mock XBlock +
+ +
+
+
+

Mock XBlock

+
+
+
diff --git a/common/lib/xmodule/xmodule/library_root_xblock.py b/common/lib/xmodule/xmodule/library_root_xblock.py index 497a145b79..3118f9a258 100644 --- a/common/lib/xmodule/xmodule/library_root_xblock.py +++ b/common/lib/xmodule/xmodule/library_root_xblock.py @@ -76,7 +76,7 @@ class LibraryRoot(XBlock): fragment.add_frag_resources(rendered_child) contents.append({ - 'id': child.location.to_deprecated_string(), + 'id': unicode(child.location), 'content': rendered_child.content }) @@ -85,7 +85,6 @@ class LibraryRoot(XBlock): 'items': contents, 'xblock_context': context, 'can_add': can_add, - 'can_reorder': False, 'first_displayed': item_start, 'total_children': children_count, 'displayed_children': len(children_to_show) diff --git a/common/lib/xmodule/xmodule/video_module/video_handlers.py b/common/lib/xmodule/xmodule/video_module/video_handlers.py index 1ba427c357..9e9db860ca 100644 --- a/common/lib/xmodule/xmodule/video_module/video_handlers.py +++ b/common/lib/xmodule/xmodule/video_module/video_handlers.py @@ -155,6 +155,7 @@ class VideoStudentViewHandlers(object): if transcript_name: # Get the asset path for course + asset_path = None course = self.descriptor.runtime.modulestore.get_course(self.course_id) if course.static_asset_path: asset_path = course.static_asset_path From 7188c3a328b8d1aafd9d09d0be56bfb5c030fa6c Mon Sep 17 00:00:00 2001 From: Jonathan Piacenti Date: Thu, 11 Dec 2014 19:53:08 +0000 Subject: [PATCH 06/99] Factored out Pagination into its own Container view. --- cms/static/js/views/library_container.js | 180 +---------------------- cms/static/js/views/paged_container.js | 158 ++++++++++++++++++++ cms/static/js/views/paging.js | 40 +---- cms/static/js/views/paging_mixin.js | 37 +++++ 4 files changed, 202 insertions(+), 213 deletions(-) create mode 100644 cms/static/js/views/paged_container.js create mode 100644 cms/static/js/views/paging_mixin.js diff --git a/cms/static/js/views/library_container.js b/cms/static/js/views/library_container.js index a8c15999ab..7c48e83cee 100644 --- a/cms/static/js/views/library_container.js +++ b/cms/static/js/views/library_container.js @@ -1,179 +1,7 @@ -define(["jquery", "underscore", "js/views/container", "js/utils/module", "gettext", "js/views/feedback_notification", +define(["jquery", "underscore", "js/views/paged_container", "js/utils/module", "gettext", "js/views/feedback_notification", "js/views/paging_header", "js/views/paging_footer"], - function ($, _, ContainerView, ModuleUtils, gettext, NotificationView, PagingHeader, PagingFooter) { - var LibraryContainerView = ContainerView.extend({ - // Store the request token of the first xblock on the page (which we know was rendered by Studio when - // the page was generated). Use that request token to filter out user-defined HTML in any - // child xblocks within the page. - - initialize: function(options){ - var self = this; - ContainerView.prototype.initialize.call(this); - this.page_size = this.options.page_size || 10; - this.page_reload_callback = options.page_reload_callback || function () {}; - // emulating Backbone.paginator interface - this.collection = { - currentPage: 0, - totalPages: 0, - totalCount: 0, - sortDirection: "desc", - start: 0, - _size: 0, - - bind: function() {}, // no-op - size: function() { return self.collection._size; } - }; - }, - - render: function(options) { - var eff_options = options || {}; - eff_options.page_number = typeof eff_options.page_number !== "undefined" - ? eff_options.page_number - : this.collection.currentPage; - return this.renderPage(eff_options); - }, - - renderPage: function(options){ - var self = this, - view = this.view, - xblockInfo = this.model, - xblockUrl = xblockInfo.url(); - return $.ajax({ - url: decodeURIComponent(xblockUrl) + "/" + view, - type: 'GET', - cache: false, - data: this.getRenderParameters(options.page_number), - headers: { Accept: 'application/json' }, - success: function(fragment) { - self.handleXBlockFragment(fragment, options); - self.processPaging({ requested_page: options.page_number }); - // This is expected to render the add xblock components menu. - self.page_reload_callback(self.$el) - } - }); - }, - - getRenderParameters: function(page_number) { - return { - enable_paging: true, - page_size: this.page_size, - page_number: page_number - }; - }, - - getPageCount: function(total_count){ - if (total_count===0) return 1; - return Math.ceil(total_count / this.page_size); - }, - - setPage: function(page_number) { - this.render({ page_number: page_number}); - }, - - nextPage: function() { - var collection = this.collection, - currentPage = collection.currentPage, - lastPage = collection.totalPages - 1; - if (currentPage < lastPage) { - this.setPage(currentPage + 1); - } - }, - - previousPage: function() { - var collection = this.collection, - currentPage = collection.currentPage; - if (currentPage > 0) { - this.setPage(currentPage - 1); - } - }, - - processPaging: function(options){ - var $element = this.$el.find('.xblock-container-paging-parameters'), - total = $element.data('total'), - displayed = $element.data('displayed'), - start = $element.data('start'); - - this.collection.currentPage = options.requested_page; - this.collection.totalCount = total; - this.collection.totalPages = this.getPageCount(total); - this.collection.start = start; - this.collection._size = displayed; - - this.processPagingHeaderAndFooter(); - }, - - processPagingHeaderAndFooter: function(){ - if (this.pagingHeader) - this.pagingHeader.undelegateEvents(); - if (this.pagingFooter) - this.pagingFooter.undelegateEvents(); - - this.pagingHeader = new PagingHeader({ - view: this, - el: this.$el.find('.container-paging-header') - }); - this.pagingFooter = new PagingFooter({ - view: this, - el: this.$el.find('.container-paging-footer') - }); - - this.pagingHeader.render(); - this.pagingFooter.render(); - }, - - xblockReady: function () { - ContainerView.prototype.xblockReady.call(this); - - this.requestToken = this.$('div.xblock').first().data('request-token'); - }, - - refresh: function(block_added) { - if (block_added) { - this.collection.totalCount += 1; - this.collection._size +=1; - if (this.collection.totalCount == 1) { - this.render(); - return - } - this.collection.totalPages = this.getPageCount(this.collection.totalCount); - var new_page = this.collection.totalPages - 1; - // If we're on a new page due to overflow, or this is the first item, set the page. - if (((this.collection.currentPage) != new_page) || this.collection.totalCount == 1) { - this.setPage(new_page); - } else { - this.pagingHeader.render(); - this.pagingFooter.render(); - } - } - }, - - acknowledgeXBlockDeletion: function (locator){ - this.notifyRuntime('deleted-child', locator); - this.collection._size -= 1; - this.collection.totalCount -= 1; - var current_page = this.collection.currentPage; - var total_pages = this.getPageCount(this.collection.totalCount); - this.collection.totalPages = total_pages; - // Starts counting from 0 - if ((current_page + 1) > total_pages) { - // The number of total pages has changed. Move down. - // Also, be mindful of the off-by-one. - this.setPage(total_pages - 1) - } else if ((current_page + 1) != total_pages) { - // Refresh page to get any blocks shifted from the next page. - this.setPage(current_page) - } else { - // We're on the last page, just need to update the numbers in the - // pagination interface. - this.pagingHeader.render(); - this.pagingFooter.render(); - } - }, - - sortDisplayName: function() { - return "Date added"; // TODO add support for sorting - } - }); - + function ($, _, PagedContainerView) { + // To be extended with Library-specific features later. + var LibraryContainerView = PagedContainerView; return LibraryContainerView; }); // end define(); diff --git a/cms/static/js/views/paged_container.js b/cms/static/js/views/paged_container.js new file mode 100644 index 0000000000..cd7590156a --- /dev/null +++ b/cms/static/js/views/paged_container.js @@ -0,0 +1,158 @@ +define(["jquery", "underscore", "js/views/container", "js/utils/module", "gettext", + "js/views/feedback_notification", "js/views/paging_header", "js/views/paging_footer", "js/views/paging_mixin"], + function ($, _, ContainerView, ModuleUtils, gettext, NotificationView, PagingHeader, PagingFooter, PagingMixin) { + var PagedContainerView = ContainerView.extend(PagingMixin).extend({ + initialize: function(options){ + var self = this; + ContainerView.prototype.initialize.call(this); + this.page_size = this.options.page_size || 10; + this.page_reload_callback = options.page_reload_callback || function () {}; + // emulating Backbone.paginator interface + this.collection = { + currentPage: 0, + totalPages: 0, + totalCount: 0, + sortDirection: "desc", + start: 0, + _size: 0, + + bind: function() {}, // no-op + size: function() { return self.collection._size; } + }; + }, + + render: function(options) { + var eff_options = options || {}; + eff_options.page_number = typeof eff_options.page_number !== "undefined" + ? eff_options.page_number + : this.collection.currentPage; + return this.renderPage(eff_options); + }, + + renderPage: function(options){ + var self = this, + view = this.view, + xblockInfo = this.model, + xblockUrl = xblockInfo.url(); + return $.ajax({ + url: decodeURIComponent(xblockUrl) + "/" + view, + type: 'GET', + cache: false, + data: this.getRenderParameters(options.page_number), + headers: { Accept: 'application/json' }, + success: function(fragment) { + self.handleXBlockFragment(fragment, options); + self.processPaging({ requested_page: options.page_number }); + // This is expected to render the add xblock components menu. + self.page_reload_callback(self.$el) + } + }); + }, + + getRenderParameters: function(page_number) { + return { + enable_paging: true, + page_size: this.page_size, + page_number: page_number + }; + }, + + getPageCount: function(total_count){ + if (total_count===0) return 1; + return Math.ceil(total_count / this.page_size); + }, + + setPage: function(page_number) { + this.render({ page_number: page_number}); + }, + + processPaging: function(options){ + var $element = this.$el.find('.xblock-container-paging-parameters'), + total = $element.data('total'), + displayed = $element.data('displayed'), + start = $element.data('start'); + + this.collection.currentPage = options.requested_page; + this.collection.totalCount = total; + this.collection.totalPages = this.getPageCount(total); + this.collection.start = start; + this.collection._size = displayed; + + this.processPagingHeaderAndFooter(); + }, + + processPagingHeaderAndFooter: function(){ + if (this.pagingHeader) + this.pagingHeader.undelegateEvents(); + if (this.pagingFooter) + this.pagingFooter.undelegateEvents(); + + this.pagingHeader = new PagingHeader({ + view: this, + el: this.$el.find('.container-paging-header') + }); + this.pagingFooter = new PagingFooter({ + view: this, + el: this.$el.find('.container-paging-footer') + }); + + this.pagingHeader.render(); + this.pagingFooter.render(); + }, + + xblockReady: function () { + ContainerView.prototype.xblockReady.call(this); + + this.requestToken = this.$('div.xblock').first().data('request-token'); + }, + + refresh: function(block_added) { + if (block_added) { + this.collection.totalCount += 1; + this.collection._size +=1; + if (this.collection.totalCount == 1) { + this.render(); + return + } + this.collection.totalPages = this.getPageCount(this.collection.totalCount); + var new_page = this.collection.totalPages - 1; + // If we're on a new page due to overflow, or this is the first item, set the page. + if (((this.collection.currentPage) != new_page) || this.collection.totalCount == 1) { + this.setPage(new_page); + } else { + this.pagingHeader.render(); + this.pagingFooter.render(); + } + } + }, + + acknowledgeXBlockDeletion: function (locator){ + this.notifyRuntime('deleted-child', locator); + this.collection._size -= 1; + this.collection.totalCount -= 1; + var current_page = this.collection.currentPage; + var total_pages = this.getPageCount(this.collection.totalCount); + this.collection.totalPages = total_pages; + // Starts counting from 0 + if ((current_page + 1) > total_pages) { + // The number of total pages has changed. Move down. + // Also, be mindful of the off-by-one. + this.setPage(total_pages - 1) + } else if ((current_page + 1) != total_pages) { + // Refresh page to get any blocks shifted from the next page. + this.setPage(current_page) + } else { + // We're on the last page, just need to update the numbers in the + // pagination interface. + this.pagingHeader.render(); + this.pagingFooter.render(); + } + }, + + sortDisplayName: function() { + return "Date added"; // TODO add support for sorting + } + }); + + return PagedContainerView; + }); // end define(); diff --git a/cms/static/js/views/paging.js b/cms/static/js/views/paging.js index c6c3a491ca..c4d9b1b602 100644 --- a/cms/static/js/views/paging.js +++ b/cms/static/js/views/paging.js @@ -1,7 +1,7 @@ -define(["underscore", "js/views/baseview", "js/views/feedback_alert", "gettext"], - function(_, BaseView, AlertView, gettext) { +define(["underscore", "js/views/baseview", "js/views/feedback_alert", "gettext", "js/views/paging_mixin"], + function(_, BaseView, AlertView, gettext, PagingMixin) { - var PagingView = BaseView.extend({ + var PagingView = BaseView.extend(PagingMixin).extend({ // takes a Backbone Paginator as a model sortableColumns: {}, @@ -21,43 +21,10 @@ define(["underscore", "js/views/baseview", "js/views/feedback_alert", "gettext"] this.$('#' + sortColumn).addClass('current-sort'); }, - setPage: function(page) { - var self = this, - collection = self.collection, - oldPage = collection.currentPage; - collection.goTo(page, { - reset: true, - success: function() { - window.scrollTo(0, 0); - }, - error: function(collection) { - collection.currentPage = oldPage; - self.onError(); - } - }); - }, - onError: function() { // Do nothing by default }, - nextPage: function() { - var collection = this.collection, - currentPage = collection.currentPage, - lastPage = collection.totalPages - 1; - if (currentPage < lastPage) { - this.setPage(currentPage + 1); - } - }, - - previousPage: function() { - var collection = this.collection, - currentPage = collection.currentPage; - if (currentPage > 0) { - this.setPage(currentPage - 1); - } - }, - /** * Registers information about a column that can be sorted. * @param columnName The element name of the column. @@ -110,6 +77,5 @@ define(["underscore", "js/views/baseview", "js/views/feedback_alert", "gettext"] this.setPage(0); } }); - return PagingView; }); // end define(); diff --git a/cms/static/js/views/paging_mixin.js b/cms/static/js/views/paging_mixin.js new file mode 100644 index 0000000000..d2c1700e5d --- /dev/null +++ b/cms/static/js/views/paging_mixin.js @@ -0,0 +1,37 @@ +define(["jquery", "underscore"], + function ($, _) { + var PagedMixin = { + setPage: function (page) { + var self = this, + collection = self.collection, + oldPage = collection.currentPage; + collection.goTo(page, { + reset: true, + success: function () { + window.scrollTo(0, 0); + }, + error: function (collection) { + collection.currentPage = oldPage; + self.onError(); + } + }); + }, + nextPage: function() { + var collection = this.collection, + currentPage = collection.currentPage, + lastPage = collection.totalPages - 1; + if (currentPage < lastPage) { + this.setPage(currentPage + 1); + } + }, + + previousPage: function() { + var collection = this.collection, + currentPage = collection.currentPage; + if (currentPage > 0) { + this.setPage(currentPage - 1); + } + } + }; + return PagedMixin; + }); From 074e4cfa2282bc5b2c0c9ec0b065c14087c89f02 Mon Sep 17 00:00:00 2001 From: Jonathan Piacenti Date: Fri, 12 Dec 2014 19:19:56 +0000 Subject: [PATCH 07/99] Addressed further review notes for Library Pagination --- cms/djangoapps/contentstore/views/item.py | 19 +- cms/static/coffee/spec/main.coffee | 2 +- cms/static/js/factories/container.js | 10 +- cms/static/js/factories/library.js | 11 +- ...tainer_spec.js => paged_container_spec.js} | 4 +- .../js/spec/views/pages/container_spec.js | 43 ++-- cms/static/js/views/container.js | 2 + cms/static/js/views/library_container.js | 5 +- cms/static/js/views/paged_container.js | 44 ++-- cms/static/js/views/pages/container.js | 50 ++-- cms/static/js/views/pages/paged_container.js | 36 +++ cms/static/js/views/paging_footer.js | 2 + cms/static/js/views/paging_mixin.js | 4 +- cms/templates/library.html | 1 - .../xmodule/xmodule/library_root_xblock.py | 5 +- .../test/acceptance/pages/studio/library.py | 56 +---- .../acceptance/pages/studio/pagination.py | 62 +++++ .../tests/studio/test_studio_library.py | 222 ++++++++++-------- 18 files changed, 334 insertions(+), 244 deletions(-) rename cms/static/js/spec/views/{library_container_spec.js => paged_container_spec.js} (99%) create mode 100644 cms/static/js/views/pages/paged_container.js create mode 100644 common/test/acceptance/pages/studio/pagination.py diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py index bd2a57e1a9..847d9c91af 100644 --- a/cms/djangoapps/contentstore/views/item.py +++ b/cms/djangoapps/contentstore/views/item.py @@ -205,8 +205,7 @@ def xblock_view_handler(request, usage_key_string, view_name): if 'application/json' in accept_header: store = modulestore() xblock = store.get_item(usage_key) - container_views = ['container_preview', 'reorderable_container_child_preview'] - library = isinstance(usage_key, LibraryUsageLocator) + container_views = ['container_preview', 'reorderable_container_child_preview', 'container_child_preview'] # wrap the generated fragment in the xmodule_editor div so that the javascript # can bind to it correctly @@ -235,7 +234,7 @@ def xblock_view_handler(request, usage_key_string, view_name): # are being shown in a reorderable container, so the xblock is automatically # added to the list. reorderable_items = set() - if not library and view_name == 'reorderable_container_child_preview': + if view_name == 'reorderable_container_child_preview': reorderable_items.add(xblock.location) paging = None @@ -246,11 +245,15 @@ def xblock_view_handler(request, usage_key_string, view_name): 'page_size': int(request.REQUEST.get('page_size', 0)), } except ValueError: - log.exception( - "Couldn't parse paging parameters: enable_paging: %s, page_number: %s, page_size: %s", - request.REQUEST.get('enable_paging', 'false'), - request.REQUEST.get('page_number', 0), - request.REQUEST.get('page_size', 0) + return HttpResponse( + content="Couldn't parse paging parameters: enable_paging: " + "%s, page_number: %s, page_size: %s".format( + request.REQUEST.get('enable_paging', 'false'), + request.REQUEST.get('page_number', 0), + request.REQUEST.get('page_size', 0) + ), + status=400, + content_type="text/plain", ) # Set up the context to be passed to each XBlock's render method. diff --git a/cms/static/coffee/spec/main.coffee b/cms/static/coffee/spec/main.coffee index 1bd1177264..b83442a9a6 100644 --- a/cms/static/coffee/spec/main.coffee +++ b/cms/static/coffee/spec/main.coffee @@ -239,7 +239,7 @@ define([ "js/spec/views/assets_spec", "js/spec/views/baseview_spec", "js/spec/views/container_spec", - "js/spec/views/library_container_spec", + "js/spec/views/paged_container_spec", "js/spec/views/group_configuration_spec", "js/spec/views/paging_spec", "js/spec/views/unit_outline_spec", diff --git a/cms/static/js/factories/container.js b/cms/static/js/factories/container.js index ea48bb2a98..429ae58f51 100644 --- a/cms/static/js/factories/container.js +++ b/cms/static/js/factories/container.js @@ -7,11 +7,11 @@ function($, _, XBlockInfo, ContainerPage, ComponentTemplates, xmoduleLoader) { 'use strict'; return function (componentTemplates, XBlockInfoJson, action, options) { var main_options = { - el: $('#content'), - model: new XBlockInfo(XBlockInfoJson, {parse: true}), - action: action, - templates: new ComponentTemplates(componentTemplates, {parse: true}) - }; + el: $('#content'), + model: new XBlockInfo(XBlockInfoJson, {parse: true}), + action: action, + templates: new ComponentTemplates(componentTemplates, {parse: true}) + }; xmoduleLoader.done(function () { var view = new ContainerPage(_.extend(main_options, options)); diff --git a/cms/static/js/factories/library.js b/cms/static/js/factories/library.js index e7834f60ef..76ac47413d 100644 --- a/cms/static/js/factories/library.js +++ b/cms/static/js/factories/library.js @@ -1,20 +1,21 @@ define([ - 'jquery', 'underscore', 'js/models/xblock_info', 'js/views/pages/container', - 'js/collections/component_template', 'xmodule', 'coffee/src/main', + 'jquery', 'underscore', 'js/models/xblock_info', 'js/views/pages/paged_container', + 'js/views/library_container', 'js/collections/component_template', 'xmodule', 'coffee/src/main', 'xblock/cms.runtime.v1' ], -function($, _, XBlockInfo, ContainerPage, ComponentTemplates, xmoduleLoader) { +function($, _, XBlockInfo, PagedContainerPage, LibraryContainerView, ComponentTemplates, xmoduleLoader) { 'use strict'; return function (componentTemplates, XBlockInfoJson, options) { var main_options = { el: $('#content'), model: new XBlockInfo(XBlockInfoJson, {parse: true}), templates: new ComponentTemplates(componentTemplates, {parse: true}), - action: 'view' + action: 'view', + viewClass: LibraryContainerView }; xmoduleLoader.done(function () { - var view = new ContainerPage(_.extend(main_options, options)); + var view = new PagedContainerPage(_.extend(main_options, options)); view.render(); }); }; diff --git a/cms/static/js/spec/views/library_container_spec.js b/cms/static/js/spec/views/paged_container_spec.js similarity index 99% rename from cms/static/js/spec/views/library_container_spec.js rename to cms/static/js/spec/views/paged_container_spec.js index 2d39cdc358..524f88e552 100644 --- a/cms/static/js/spec/views/library_container_spec.js +++ b/cms/static/js/spec/views/paged_container_spec.js @@ -1,6 +1,6 @@ define([ "jquery", "underscore", "js/common_helpers/ajax_helpers", "URI", "js/models/xblock_info", - "js/views/library_container", "js/views/paging_header", "js/views/paging_footer"], - function ($, _, AjaxHelpers, URI, XBlockInfo, PagedContainer, PagingContainer, PagingFooter) { + "js/views/paged_container", "js/views/paging_header", "js/views/paging_footer"], + function ($, _, AjaxHelpers, URI, XBlockInfo, PagedContainer, PagingHeader, PagingFooter) { var htmlResponseTpl = _.template('' + '
' diff --git a/cms/static/js/spec/views/pages/container_spec.js b/cms/static/js/spec/views/pages/container_spec.js index d5ec6938dc..ce862aac7d 100644 --- a/cms/static/js/spec/views/pages/container_spec.js +++ b/cms/static/js/spec/views/pages/container_spec.js @@ -1,7 +1,7 @@ define(["jquery", "underscore", "underscore.string", "js/common_helpers/ajax_helpers", "js/common_helpers/template_helpers", "js/spec_helpers/edit_helpers", - "js/views/pages/container", "js/models/xblock_info", "jquery.simulate"], - function ($, _, str, AjaxHelpers, TemplateHelpers, EditHelpers, ContainerPage, XBlockInfo) { + "js/views/pages/container", "js/views/pages/paged_container", "js/models/xblock_info"], + function ($, _, str, AjaxHelpers, TemplateHelpers, EditHelpers, ContainerPage, PagedContainerPage, XBlockInfo) { function parameterized_suite(label, global_page_options, fixtures) { describe(label + " ContainerPage", function () { @@ -13,7 +13,8 @@ define(["jquery", "underscore", "underscore.string", "js/common_helpers/ajax_hel mockBadContainerXBlockHtml = readFixtures('mock/mock-bad-javascript-container-xblock.underscore'), mockBadXBlockContainerXBlockHtml = readFixtures('mock/mock-bad-xblock-container-xblock.underscore'), mockUpdatedContainerXBlockHtml = readFixtures('mock/mock-updated-container-xblock.underscore'), - mockXBlockEditorHtml = readFixtures('mock/mock-xblock-editor.underscore'); + mockXBlockEditorHtml = readFixtures('mock/mock-xblock-editor.underscore'), + PageClass = fixtures.page; beforeEach(function () { var newDisplayName = 'New Display Name'; @@ -62,7 +63,7 @@ define(["jquery", "underscore", "underscore.string", "js/common_helpers/ajax_hel templates: EditHelpers.mockComponentTemplates, el: $('#content') }; - return new ContainerPage(_.extend(options || {}, global_page_options, default_options)); + return new PageClass(_.extend(options || {}, global_page_options, default_options)); }; renderContainerPage = function (test, html, options) { @@ -273,7 +274,7 @@ define(["jquery", "underscore", "underscore.string", "js/common_helpers/ajax_hel }); describe("xblock operations", function () { - var getGroupElement, paginated, + var getGroupElement, paginated, getDeleteOffset, NUM_COMPONENTS_PER_GROUP = 3, GROUP_TO_TEST = "A", allComponentsInGroup = _.map( _.range(NUM_COMPONENTS_PER_GROUP), @@ -283,9 +284,13 @@ define(["jquery", "underscore", "underscore.string", "js/common_helpers/ajax_hel ); paginated = function () { - return containerPage.enable_paging; + return containerPage instanceof PagedContainerPage; }; + getDeleteOffset = function () { + // Paginated containers will make an additional AJAX request. + return paginated() ? 3 : 2; + }; getGroupElement = function () { return containerPage.$("[data-locator='locator-group-" + GROUP_TO_TEST + "']"); @@ -316,8 +321,6 @@ define(["jquery", "underscore", "underscore.string", "js/common_helpers/ajax_hel deleteComponent = function (componentIndex, requestOffset) { clickDelete(componentIndex); AjaxHelpers.respondWithJson(requests, {}); - - // second to last request contains given component's id (to delete the component) AjaxHelpers.expectJsonRequest(requests, 'DELETE', '/xblock/locator-component-' + GROUP_TO_TEST + (componentIndex + 1), null, requests.length - requestOffset); @@ -329,8 +332,7 @@ define(["jquery", "underscore", "underscore.string", "js/common_helpers/ajax_hel deleteComponentWithSuccess = function (componentIndex) { var deleteOffset; - deleteOffset = paginated() ? 3 : 2; - + deleteOffset = getDeleteOffset(); deleteComponent(componentIndex, deleteOffset); // verify the new list of components within the group @@ -356,17 +358,12 @@ define(["jquery", "underscore", "underscore.string", "js/common_helpers/ajax_hel }); it("can delete an xblock with broken JavaScript", function () { + var deleteOffset = getDeleteOffset(); renderContainerPage(this, mockBadContainerXBlockHtml); containerPage.$('.delete-button').first().click(); EditHelpers.confirmPrompt(promptSpy); AjaxHelpers.respondWithJson(requests, {}); - var deleteOffset; - if (paginated()) { - deleteOffset = 3; - } else { - deleteOffset = 2; - } // expect the second to last request to be a delete of the xblock AjaxHelpers.expectJsonRequest(requests, 'DELETE', '/xblock/locator-broken-javascript', null, requests.length - deleteOffset); @@ -528,7 +525,7 @@ define(["jquery", "underscore", "underscore.string", "js/common_helpers/ajax_hel }); describe('Template Picker', function () { - var showTemplatePicker, verifyCreateHtmlComponent, call_count; + var showTemplatePicker, verifyCreateHtmlComponent; showTemplatePicker = function () { containerPage.$('.new-component .new-component-type a.multiple-templates')[0].click(); @@ -536,7 +533,6 @@ define(["jquery", "underscore", "underscore.string", "js/common_helpers/ajax_hel verifyCreateHtmlComponent = function (test, templateIndex, expectedRequest) { var xblockCount; - // call_count = paginated() ? 18: 10; renderContainerPage(test, mockContainerXBlockHtml); showTemplatePicker(); xblockCount = containerPage.$('.studio-xblock-wrapper').length; @@ -568,12 +564,17 @@ define(["jquery", "underscore", "underscore.string", "js/common_helpers/ajax_hel } parameterized_suite("Non paged", - { enable_paging: false }, - { initial: 'mock/mock-container-xblock.underscore', add_response: 'mock/mock-xblock.underscore' } + { }, + { + page: ContainerPage, + initial: 'mock/mock-container-xblock.underscore', + add_response: 'mock/mock-xblock.underscore' + } ); parameterized_suite("Paged", - { enable_paging: true, page_size: 42 }, + { page_size: 42 }, { + page: PagedContainerPage, initial: 'mock/mock-container-paged-xblock.underscore', add_response: 'mock/mock-xblock-paged.underscore' }); diff --git a/cms/static/js/views/container.js b/cms/static/js/views/container.js index ec89208b44..a99993fe5d 100644 --- a/cms/static/js/views/container.js +++ b/cms/static/js/views/container.js @@ -9,6 +9,8 @@ define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext", // child xblocks within the page. requestToken: "", + new_child_view: 'reorderable_container_child_preview', + xblockReady: function () { XBlockView.prototype.xblockReady.call(this); var reorderableClass, reorderableContainer, diff --git a/cms/static/js/views/library_container.js b/cms/static/js/views/library_container.js index 7c48e83cee..ea09c69c89 100644 --- a/cms/static/js/views/library_container.js +++ b/cms/static/js/views/library_container.js @@ -1,6 +1,5 @@ -define(["jquery", "underscore", "js/views/paged_container", "js/utils/module", "gettext", "js/views/feedback_notification", - "js/views/paging_header", "js/views/paging_footer"], - function ($, _, PagedContainerView) { +define(["js/views/paged_container"], + function (PagedContainerView) { // To be extended with Library-specific features later. var LibraryContainerView = PagedContainerView; return LibraryContainerView; diff --git a/cms/static/js/views/paged_container.js b/cms/static/js/views/paged_container.js index cd7590156a..a8cd7aec32 100644 --- a/cms/static/js/views/paged_container.js +++ b/cms/static/js/views/paged_container.js @@ -5,9 +5,13 @@ define(["jquery", "underscore", "js/views/container", "js/utils/module", "gettex initialize: function(options){ var self = this; ContainerView.prototype.initialize.call(this); - this.page_size = this.options.page_size || 10; - this.page_reload_callback = options.page_reload_callback || function () {}; - // emulating Backbone.paginator interface + this.page_size = this.options.page_size; + // Reference to the page model + this.page = options.page; + // XBlocks are rendered via Django views and templates rather than underscore templates, and so don't + // have a Backbone model for us to manipulate in a backbone collection. Here, we emulate the interface + // of backbone.paginator so that we can use the Paging Header and Footer with this page. As a + // consequence, however, we have to manipulate its members manually. this.collection = { currentPage: 0, totalPages: 0, @@ -15,18 +19,23 @@ define(["jquery", "underscore", "js/views/container", "js/utils/module", "gettex sortDirection: "desc", start: 0, _size: 0, - - bind: function() {}, // no-op + // Paging header and footer expect this to be a Backbone model they can listen to for changes, but + // they cannot. Provide the bind function for them, but have it do nothing. + bind: function() {}, + // size() on backbone collections shows how many objects are in the collection, or in the case + // of paginator, on the current page. size: function() { return self.collection._size; } }; }, + new_child_view: 'container_child_preview', + render: function(options) { - var eff_options = options || {}; - eff_options.page_number = typeof eff_options.page_number !== "undefined" - ? eff_options.page_number + options = options || {}; + options.page_number = typeof options.page_number !== "undefined" + ? options.page_number : this.collection.currentPage; - return this.renderPage(eff_options); + return this.renderPage(options); }, renderPage: function(options){ @@ -43,16 +52,15 @@ define(["jquery", "underscore", "js/views/container", "js/utils/module", "gettex success: function(fragment) { self.handleXBlockFragment(fragment, options); self.processPaging({ requested_page: options.page_number }); - // This is expected to render the add xblock components menu. - self.page_reload_callback(self.$el) + self.page.renderAddXBlockComponents() } }); }, getRenderParameters: function(page_number) { return { - enable_paging: true, page_size: this.page_size, + enable_paging: true, page_number: page_number }; }, @@ -67,6 +75,8 @@ define(["jquery", "underscore", "js/views/container", "js/utils/module", "gettex }, processPaging: function(options){ + // We have the Django template sneak us the pagination information, + // and we load it from a div here. var $element = this.$el.find('.xblock-container-paging-parameters'), total = $element.data('total'), displayed = $element.data('displayed'), @@ -82,6 +92,8 @@ define(["jquery", "underscore", "js/views/container", "js/utils/module", "gettex }, processPagingHeaderAndFooter: function(){ + // Rendering the container view detaches the header and footer from the DOM. + // It's just as easy to recreate them as it is to try to shove them back into the tree. if (this.pagingHeader) this.pagingHeader.undelegateEvents(); if (this.pagingFooter) @@ -100,12 +112,6 @@ define(["jquery", "underscore", "js/views/container", "js/utils/module", "gettex this.pagingFooter.render(); }, - xblockReady: function () { - ContainerView.prototype.xblockReady.call(this); - - this.requestToken = this.$('div.xblock').first().data('request-token'); - }, - refresh: function(block_added) { if (block_added) { this.collection.totalCount += 1; @@ -150,7 +156,7 @@ define(["jquery", "underscore", "js/views/container", "js/utils/module", "gettex }, sortDisplayName: function() { - return "Date added"; // TODO add support for sorting + return gettext("Date added"); // TODO add support for sorting } }); diff --git a/cms/static/js/views/pages/container.js b/cms/static/js/views/pages/container.js index 771e9afb1b..406e6e9b03 100644 --- a/cms/static/js/views/pages/container.js +++ b/cms/static/js/views/pages/container.js @@ -3,10 +3,10 @@ * This page allows the user to understand and manipulate the xblock and its children. */ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views/utils/view_utils", - "js/views/container", "js/views/library_container", "js/views/xblock", "js/views/components/add_xblock", "js/views/modals/edit_xblock", + "js/views/container", "js/views/xblock", "js/views/components/add_xblock", "js/views/modals/edit_xblock", "js/models/xblock_info", "js/views/xblock_string_field_editor", "js/views/pages/container_subviews", "js/views/unit_outline", "js/views/utils/xblock_utils"], - function ($, _, gettext, BasePage, ViewUtils, ContainerView, PagedContainerView, XBlockView, AddXBlockComponent, + function ($, _, gettext, BasePage, ViewUtils, ContainerView, XBlockView, AddXBlockComponent, EditXBlockModal, XBlockInfo, XBlockStringFieldEditor, ContainerSubviews, UnitOutlineView, XBlockUtils) { 'use strict'; @@ -25,12 +25,16 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views view: 'container_preview', + defaultViewClass: ContainerView, + + // Overridable by subclasses-- determines whether the XBlock component + // addition menu is added on initialization. You may set this to false + // if your subclass handles it. + components_on_init: true, + initialize: function(options) { BasePage.prototype.initialize.call(this, options); - this.enable_paging = options.enable_paging || false; - if (this.enable_paging) { - this.page_size = options.page_size || 10; - } + this.viewClass = options.viewClass || this.defaultViewClass; this.nameEditor = new XBlockStringFieldEditor({ el: this.$('.wrapper-xblock-field'), model: this.model @@ -75,26 +79,16 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views } }, - getXBlockView: function(){ - var self = this, - parameters = { - el: this.$('.wrapper-xblock'), - model: this.model, - view: this.view - }; + getViewParameters: function () { + return { + el: this.$('.wrapper-xblock'), + model: this.model, + view: this.view + } + }, - if (this.enable_paging) { - parameters = _.extend(parameters, { - page_size: this.page_size, - page_reload_callback: function($element) { - self.renderAddXBlockComponents(); - } - }); - return new PagedContainerView(parameters); - } - else { - return new ContainerView(parameters); - } + getXBlockView: function(){ + return new this.viewClass(this.getViewParameters()); }, render: function(options) { @@ -120,7 +114,7 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views xblockView.notifyRuntime('page-shown', self); // Render the add buttons. Paged containers should do this on their own. - if (!self.enable_paging) { + if (self.components_on_init) { // Render the add buttons self.renderAddXBlockComponents(); } @@ -277,7 +271,7 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views rootLocator = this.xblockView.model.id; if (xblockElement.length === 0 || xblockElement.data('locator') === rootLocator) { this.render({refresh: true, block_added: block_added}); - } else if (parentElement.hasClass('reorderable-container') || this.enable_paging) { + } else if (parentElement.hasClass('reorderable-container')) { this.refreshChildXBlock(xblockElement, block_added); } else { this.refreshXBlock(this.findXBlockElement(parentElement)); @@ -313,7 +307,7 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views }); temporaryView = new TemporaryXBlockView({ model: xblockInfo, - view: 'reorderable_container_child_preview', + view: self.xblockView.new_child_view, el: xblockElement }); return temporaryView.render({ diff --git a/cms/static/js/views/pages/paged_container.js b/cms/static/js/views/pages/paged_container.js new file mode 100644 index 0000000000..916bf3005e --- /dev/null +++ b/cms/static/js/views/pages/paged_container.js @@ -0,0 +1,36 @@ +/** + * PagedXBlockContainerPage is a variant of XBlockContainerPage that supports Pagination. + */ +define(["jquery", "underscore", "gettext", "js/views/pages/container", "js/views/paged_container"], + function ($, _, gettext, XBlockContainerPage, PagedContainerView) { + 'use strict'; + var PagedXBlockContainerPage = XBlockContainerPage.extend({ + + defaultViewClass: PagedContainerView, + components_on_init: false, + + initialize: function (options){ + this.page_size = options.page_size || 10; + XBlockContainerPage.prototype.initialize.call(this, options); + }, + + getViewParameters: function () { + return _.extend(XBlockContainerPage.prototype.getViewParameters.call(this), { + page_size: this.page_size, + page: this + }); + }, + + refreshXBlock: function(element, block_added) { + var xblockElement = this.findXBlockElement(element), + rootLocator = this.xblockView.model.id; + if (xblockElement.length === 0 || xblockElement.data('locator') === rootLocator) { + this.render({refresh: true, block_added: block_added}); + } else { + this.refreshChildXBlock(xblockElement, block_added); + } + } + + }); + return PagedXBlockContainerPage; + }); diff --git a/cms/static/js/views/paging_footer.js b/cms/static/js/views/paging_footer.js index 86d776aafd..a897e8275a 100644 --- a/cms/static/js/views/paging_footer.js +++ b/cms/static/js/views/paging_footer.js @@ -44,6 +44,8 @@ define(["underscore", "js/views/baseview"], function(_, BaseView) { if (pageNumber <= 0) { pageNumber = false; } + // If we still have a page number by this point, + // and it's not the current page, load it. if (pageNumber && pageNumber !== currentPage) { view.setPage(pageNumber - 1); } diff --git a/cms/static/js/views/paging_mixin.js b/cms/static/js/views/paging_mixin.js index d2c1700e5d..16d518f856 100644 --- a/cms/static/js/views/paging_mixin.js +++ b/cms/static/js/views/paging_mixin.js @@ -1,5 +1,5 @@ -define(["jquery", "underscore"], - function ($, _) { +define([], + function () { var PagedMixin = { setPage: function (page) { var self = this, diff --git a/cms/templates/library.html b/cms/templates/library.html index dc9baa5736..d367c333d2 100644 --- a/cms/templates/library.html +++ b/cms/templates/library.html @@ -25,7 +25,6 @@ from django.utils.translation import ugettext as _ ${component_templates | n}, ${json.dumps(xblock_info) | n}, { isUnitPage: false, - enable_paging: true, page_size: 10 } ); diff --git a/common/lib/xmodule/xmodule/library_root_xblock.py b/common/lib/xmodule/xmodule/library_root_xblock.py index 3118f9a258..6a58cf1b1d 100644 --- a/common/lib/xmodule/xmodule/library_root_xblock.py +++ b/common/lib/xmodule/xmodule/library_root_xblock.py @@ -50,8 +50,7 @@ class LibraryRoot(XBlock): def render_children(self, context, fragment, can_reorder=False, can_add=False): # pylint: disable=unused-argument """ - Renders the children of the module with HTML appropriate for Studio. If can_reorder is True, - then the children will be rendered to support drag and drop. + Renders the children of the module with HTML appropriate for Studio. Reordering is not supported. """ contents = [] @@ -77,7 +76,7 @@ class LibraryRoot(XBlock): contents.append({ 'id': unicode(child.location), - 'content': rendered_child.content + 'content': rendered_child.content, }) fragment.add_content( diff --git a/common/test/acceptance/pages/studio/library.py b/common/test/acceptance/pages/studio/library.py index 5572b1a91e..64f93f2116 100644 --- a/common/test/acceptance/pages/studio/library.py +++ b/common/test/acceptance/pages/studio/library.py @@ -3,14 +3,14 @@ Library edit page in Studio """ from bok_choy.page_object import PageObject -from selenium.webdriver.common.keys import Keys +from ...pages.studio.pagination import PaginatedMixin 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): +class LibraryPage(PageObject, PaginatedMixin): """ Library page in Studio """ @@ -75,58 +75,6 @@ class LibraryPage(PageObject): confirm_prompt(self) # this will also wait_for_notification() self.wait_for_ajax() - def nav_disabled(self, position, arrows=('next', 'previous')): - """ - Verifies that pagination nav is disabled. Position can be 'top' or 'bottom'. - - To specify a specific arrow, pass an iterable with a single element, 'next' or 'previous'. - """ - return all([ - self.q(css='nav.%s * a.%s-page-link.is-disabled' % (position, arrow)) - for arrow in arrows - ]) - - def move_back(self, position): - """ - Clicks one of the forward nav buttons. Position can be 'top' or 'bottom'. - """ - self.q(css='nav.%s * a.previous-page-link' % position)[0].click() - self.wait_until_ready() - - def move_forward(self, position): - """ - Clicks one of the forward nav buttons. Position can be 'top' or 'bottom'. - """ - self.q(css='nav.%s * a.next-page-link' % position)[0].click() - self.wait_until_ready() - - def revisit(self): - """ - Visit the page's URL, instead of refreshing, so that a new state is created. - """ - self.browser.get(self.browser.current_url) - self.wait_until_ready() - - def go_to_page(self, number): - """ - Enter a number into the page number input field, and then try to navigate to it. - """ - page_input = self.q(css="#page-number-input")[0] - page_input.click() - page_input.send_keys(str(number)) - page_input.send_keys(Keys.RETURN) - self.wait_until_ready() - - def check_page_unchanged(self, first_block_name): - """ - Used to make sure that a page has not transitioned after a bogus number is given. - """ - if not self.xblocks[0].name == first_block_name: - return False - if not self.q(css='#page-number-input')[0].get_attribute('value') == '': - return False - return True - def _get_xblocks(self): """ Create an XBlockWrapper for each XBlock div found on the page. diff --git a/common/test/acceptance/pages/studio/pagination.py b/common/test/acceptance/pages/studio/pagination.py new file mode 100644 index 0000000000..a976149c37 --- /dev/null +++ b/common/test/acceptance/pages/studio/pagination.py @@ -0,0 +1,62 @@ +""" +Mixin to include for Paginated container pages +""" +from selenium.webdriver.common.keys import Keys + + +class PaginatedMixin(object): + """ + Mixin class used for paginated page tests. + """ + def nav_disabled(self, position, arrows=('next', 'previous')): + """ + Verifies that pagination nav is disabled. Position can be 'top' or 'bottom'. + + `top` is the header, `bottom` is the footer. + + To specify a specific arrow, pass an iterable with a single element, 'next' or 'previous'. + """ + return all([ + self.q(css='nav.%s * a.%s-page-link.is-disabled' % (position, arrow)) + for arrow in arrows + ]) + + def move_back(self, position): + """ + Clicks one of the forward nav buttons. Position can be 'top' or 'bottom'. + """ + self.q(css='nav.%s * a.previous-page-link' % position)[0].click() + self.wait_until_ready() + + def move_forward(self, position): + """ + Clicks one of the forward nav buttons. Position can be 'top' or 'bottom'. + """ + self.q(css='nav.%s * a.next-page-link' % position)[0].click() + self.wait_until_ready() + + def go_to_page(self, number): + """ + Enter a number into the page number input field, and then try to navigate to it. + """ + page_input = self.q(css="#page-number-input")[0] + page_input.click() + page_input.send_keys(str(number)) + page_input.send_keys(Keys.RETURN) + self.wait_until_ready() + + def get_page_number(self): + """ + Returns the page number as the page represents it, in string form. + """ + return self.q(css="span.current-page")[0].get_attribute('innerHTML') + + def check_page_unchanged(self, first_block_name): + """ + Used to make sure that a page has not transitioned after a bogus number is given. + """ + if not self.xblocks[0].name == first_block_name: + return False + if not self.q(css='#page-number-input')[0].get_attribute('value') == '': + return False + return True diff --git a/common/test/acceptance/tests/studio/test_studio_library.py b/common/test/acceptance/tests/studio/test_studio_library.py index 5529f36032..491c9093d0 100644 --- a/common/test/acceptance/tests/studio/test_studio_library.py +++ b/common/test/acceptance/tests/studio/test_studio_library.py @@ -4,6 +4,7 @@ Acceptance tests for Content Libraries in Studio from ddt import ddt, data from .base_studio_test import StudioLibraryTest +from ...fixtures.course import XBlockFixtureDesc from ...pages.studio.utils import add_component from ...pages.studio.library import LibraryPage @@ -137,109 +138,64 @@ class LibraryEditPageTest(StudioLibraryTest): Scenario: Ensure that the navigation buttons aren't active when there aren't enough XBlocks. Given that I have a library in Studio with no XBlocks The Navigation buttons should be disabled. - When I add 5 multiple Choice XBlocks + When I add a multiple choice problem The Navigation buttons should be disabled. """ self.assertEqual(len(self.lib_page.xblocks), 0) self.assertTrue(self.lib_page.nav_disabled(position)) - for _ in range(0, 5): - add_component(self.lib_page, "problem", "Multiple Choice") + add_component(self.lib_page, "problem", "Multiple Choice") self.assertTrue(self.lib_page.nav_disabled(position)) - @data('top', 'bottom') - def test_nav_buttons(self, position): + +@ddt +class LibraryNavigationTest(StudioLibraryTest): + """ + Test common Navigation actions + """ + def setUp(self): # pylint: disable=arguments-differ """ - Scenario: Ensure that the navigation buttons work. - Given that I have a library in Studio with no XBlocks - And I create 10 Multiple Choice XBlocks - And I create 10 Checkbox XBlocks - And I create 10 Dropdown XBlocks - And I revisit the page - The previous button should be disabled. - The first XBlock should be a Multiple Choice XBlock - Then if I hit the next button - The first XBlock should be a Checkboxes XBlock - Then if I hit the next button - The first XBlock should be a Dropdown XBlock - And the next button should be disabled - Then if I hit the previous button - The first XBlock should be an Checkboxes XBlock - Then if I hit the previous button - The first XBlock should be a Multipe Choice XBlock - And the previous button should be disabled + Ensure a library exists and navigate to the library edit page. """ - self.assertEqual(len(self.lib_page.xblocks), 0) - block_types = [('problem', 'Multiple Choice'), ('problem', 'Checkboxes'), ('problem', 'Dropdown')] - for block_type in block_types: - for _ in range(0, 10): - add_component(self.lib_page, *block_type) + super(LibraryNavigationTest, self).setUp(is_staff=True) + self.lib_page = LibraryPage(self.browser, self.library_key) + self.lib_page.visit() + self.lib_page.wait_until_ready() - # Don't refresh, as that may contain additional state. - self.lib_page.revisit() - - # Check forward navigation - self.assertTrue(self.lib_page.nav_disabled(position, ['previous'])) - self.assertEqual(self.lib_page.xblocks[0].name, 'Multiple Choice') - self.lib_page.move_forward(position) - self.assertEqual(self.lib_page.xblocks[0].name, 'Checkboxes') - self.lib_page.move_forward(position) - self.assertEqual(self.lib_page.xblocks[0].name, 'Dropdown') - self.lib_page.nav_disabled(position, ['next']) - - # Check backward navigation - self.lib_page.move_back(position) - self.assertEqual(self.lib_page.xblocks[0].name, 'Checkboxes') - self.lib_page.move_back(position) - self.assertEqual(self.lib_page.xblocks[0].name, 'Multiple Choice') - self.assertTrue(self.lib_page.nav_disabled(position, ['previous'])) + def populate_library_fixture(self, library_fixture): + """ + Create four pages worth of XBlocks, and offset by one so each is named + after the number they should be in line by the user's perception. + """ + # pylint: disable=attribute-defined-outside-init + self.blocks = [XBlockFixtureDesc('html', str(i)) for i in xrange(1, 41)] + library_fixture.add_children(*self.blocks) def test_arbitrary_page_selection(self): """ Scenario: I can pick a specific page number of a Library at will. - Given that I have a library in Studio with no XBlocks - And I create 10 Multiple Choice XBlocks - And I create 10 Checkboxes XBlocks - And I create 10 Dropdown XBlocks - And I create 10 Numerical Input XBlocks - And I revisit the page + Given that I have a library in Studio with 40 XBlocks When I go to the 3rd page - The first XBlock should be a Dropdown XBlock + The first XBlock should be the 21st XBlock When I go to the 4th Page - The first XBlock should be a Numerical Input XBlock + The first XBlock should be the 31st XBlock When I go to the 1st page - The first XBlock should be a Multiple Choice XBlock + The first XBlock should be the 1st XBlock When I go to the 2nd page - The first XBlock should be a Checkboxes XBlock + The first XBlock should be the 11th XBlock """ - self.assertEqual(len(self.lib_page.xblocks), 0) - block_types = [ - ('problem', 'Multiple Choice'), ('problem', 'Checkboxes'), ('problem', 'Dropdown'), - ('problem', 'Numerical Input'), - ] - for block_type in block_types: - for _ in range(0, 10): - add_component(self.lib_page, *block_type) - - # Don't refresh, as that may contain additional state. - self.lib_page.revisit() self.lib_page.go_to_page(3) - self.assertEqual(self.lib_page.xblocks[0].name, 'Dropdown') + self.assertEqual(self.lib_page.xblocks[0].name, '21') self.lib_page.go_to_page(4) - self.assertEqual(self.lib_page.xblocks[0].name, 'Numerical Input') + self.assertEqual(self.lib_page.xblocks[0].name, '31') self.lib_page.go_to_page(1) - self.assertEqual(self.lib_page.xblocks[0].name, 'Multiple Choice') + self.assertEqual(self.lib_page.xblocks[0].name, '1') self.lib_page.go_to_page(2) - self.assertEqual(self.lib_page.xblocks[0].name, 'Checkboxes') + self.assertEqual(self.lib_page.xblocks[0].name, '11') def test_bogus_page_selection(self): """ Scenario: I can't pick a nonsense page number of a Library - Given that I have a library in Studio with no XBlocks - And I create 10 Multiple Choice XBlocks - And I create 10 Checkboxes XBlocks - And I create 10 Dropdown XBlocks - And I create 10 Numerical Input XBlocks - And I revisit the page + Given that I have a library in Studio with 40 XBlocks When I attempt to go to the 'a'th page The input field will be cleared and no change of XBlocks will be made When I attempt to visit the 5th page @@ -249,22 +205,104 @@ class LibraryEditPageTest(StudioLibraryTest): When I attempt to visit the 0th page The input field will be cleared and no change of XBlocks will be made """ - self.assertEqual(len(self.lib_page.xblocks), 0) - block_types = [ - ('problem', 'Multiple Choice'), ('problem', 'Checkboxes'), ('problem', 'Dropdown'), - ('problem', 'Numerical Input'), - ] - for block_type in block_types: - for _ in range(0, 10): - add_component(self.lib_page, *block_type) - - self.lib_page.revisit() - self.assertEqual(self.lib_page.xblocks[0].name, 'Multiple Choice') + self.assertEqual(self.lib_page.xblocks[0].name, '1') self.lib_page.go_to_page('a') - self.assertTrue(self.lib_page.check_page_unchanged('Multiple Choice')) + self.assertTrue(self.lib_page.check_page_unchanged('1')) self.lib_page.go_to_page(-1) - self.assertTrue(self.lib_page.check_page_unchanged('Multiple Choice')) + self.assertTrue(self.lib_page.check_page_unchanged('1')) self.lib_page.go_to_page(5) - self.assertTrue(self.lib_page.check_page_unchanged('Multiple Choice')) + self.assertTrue(self.lib_page.check_page_unchanged('1')) self.lib_page.go_to_page(0) - self.assertTrue(self.lib_page.check_page_unchanged('Multiple Choice')) + self.assertTrue(self.lib_page.check_page_unchanged('1')) + + @data('top', 'bottom') + def test_nav_buttons(self, position): + """ + Scenario: Ensure that the navigation buttons work. + Given that I have a library in Studio with 40 XBlocks + The previous button should be disabled. + The first XBlock should be the 1st XBlock + Then if I hit the next button + The first XBlock should be the 11th XBlock + Then if I hit the next button + The first XBlock should be the 21st XBlock + Then if I hit the next button + The first XBlock should be the 31st XBlock + And the next button should be disabled + Then if I hit the previous button + The first XBlock should be the 21st XBlock + Then if I hit the previous button + The first XBlock should be the 11th XBlock + Then if I hit the previous button + The first XBlock should be the 1st XBlock + And the previous button should be disabled + """ + # Check forward navigation + self.assertTrue(self.lib_page.nav_disabled(position, ['previous'])) + self.assertEqual(self.lib_page.xblocks[0].name, '1') + self.lib_page.move_forward(position) + self.assertEqual(self.lib_page.xblocks[0].name, '11') + self.lib_page.move_forward(position) + self.assertEqual(self.lib_page.xblocks[0].name, '21') + self.lib_page.move_forward(position) + self.assertEqual(self.lib_page.xblocks[0].name, '31') + self.lib_page.nav_disabled(position, ['next']) + + # Check backward navigation + self.lib_page.move_back(position) + self.assertEqual(self.lib_page.xblocks[0].name, '21') + self.lib_page.move_back(position) + self.assertEqual(self.lib_page.xblocks[0].name, '11') + self.lib_page.move_back(position) + self.assertEqual(self.lib_page.xblocks[0].name, '1') + self.assertTrue(self.lib_page.nav_disabled(position, ['previous'])) + + def test_library_pagination(self): + """ + Scenario: Ensure that adding several XBlocks to a library results in pagination. + Given that I have a library in Studio with 40 XBlocks + Then 10 are displayed + And the first XBlock will be the 1st one + And I'm on the 1st page + When I add 1 Multiple Choice XBlock + Then 1 XBlock will be displayed + And I'm on the 5th page + The first XBlock will be the newest one + When I delete that XBlock + Then 10 are displayed + And I'm on the 4th page + And the first XBlock is the 31st one + And the last XBlock is the 40th one. + """ + self.assertEqual(len(self.lib_page.xblocks), 10) + self.assertEqual(self.lib_page.get_page_number(), '1') + self.assertEqual(self.lib_page.xblocks[0].name, '1') + add_component(self.lib_page, "problem", "Multiple Choice") + self.assertEqual(len(self.lib_page.xblocks), 1) + self.assertEqual(self.lib_page.get_page_number(), '5') + self.assertEqual(self.lib_page.xblocks[0].name, "Multiple Choice") + self.lib_page.click_delete_button(self.lib_page.xblocks[0].locator) + self.assertEqual(len(self.lib_page.xblocks), 10) + self.assertEqual(self.lib_page.get_page_number(), '4') + self.assertEqual(self.lib_page.xblocks[0].name, '31') + self.assertEqual(self.lib_page.xblocks[-1].name, '40') + + def test_delete_shifts_blocks(self): + """ + Scenario: Ensure that removing an XBlock shifts other blocks back. + Given that I have a library in Studio with 40 XBlocks + Then 10 are displayed + And I will be on the first page + When I delete the third XBlock + There will be 10 displayed + And the first XBlock will be the first one + And the last XBlock will be the 11th one + And I will be on the first page + """ + self.assertEqual(len(self.lib_page.xblocks), 10) + self.assertEqual(self.lib_page.get_page_number(), '1') + self.lib_page.click_delete_button(self.lib_page.xblocks[2].locator, confirm=True) + self.assertEqual(len(self.lib_page.xblocks), 10) + self.assertEqual(self.lib_page.xblocks[0].name, '1') + self.assertEqual(self.lib_page.xblocks[-1].name, '11') + self.assertEqual(self.lib_page.get_page_number(), '1') From eddf44d853d02693e3997b0df799e89babf2c4da Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Tue, 28 Oct 2014 23:10:40 -0700 Subject: [PATCH 08/99] Library Content XModule --- cms/envs/common.py | 1 + common/lib/xmodule/setup.py | 1 + .../xmodule/xmodule/library_content_module.py | 441 ++++++++++++++++++ .../xmodule/public/js/library_content_edit.js | 24 + lms/djangoapps/courseware/models.py | 4 + lms/templates/library-block-author-view.html | 17 + lms/templates/staff_problem_info.html | 2 +- 7 files changed, 489 insertions(+), 1 deletion(-) create mode 100644 common/lib/xmodule/xmodule/library_content_module.py create mode 100644 common/lib/xmodule/xmodule/public/js/library_content_edit.js create mode 100644 lms/templates/library-block-author-view.html diff --git a/cms/envs/common.py b/cms/envs/common.py index 0d3b637acc..b7d4c8cb27 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -766,6 +766,7 @@ ADVANCED_COMPONENT_TYPES = [ '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/common/lib/xmodule/setup.py b/common/lib/xmodule/setup.py index f0721e91a4..f2b548efe9 100644 --- a/common/lib/xmodule/setup.py +++ b/common/lib/xmodule/setup.py @@ -11,6 +11,7 @@ XMODULES = [ "discuss = xmodule.backcompat_module:TranslateCustomTagDescriptor", "html = xmodule.html_module:HtmlDescriptor", "image = xmodule.backcompat_module:TranslateCustomTagDescriptor", + "library_content = xmodule.library_content_module:LibraryContentDescriptor", "error = xmodule.error_module:ErrorDescriptor", "peergrading = xmodule.peer_grading_module:PeerGradingDescriptor", "poll_question = xmodule.poll_module:PollDescriptor", diff --git a/common/lib/xmodule/xmodule/library_content_module.py b/common/lib/xmodule/xmodule/library_content_module.py new file mode 100644 index 0000000000..092bc2a931 --- /dev/null +++ b/common/lib/xmodule/xmodule/library_content_module.py @@ -0,0 +1,441 @@ +""" +LibraryContent: The XBlock used to include blocks from a library in a course. +""" +from bson.objectid import ObjectId +from collections import namedtuple +from copy import copy +import hashlib +from .mako_module import MakoModuleDescriptor +from opaque_keys.edx.locator import LibraryLocator +import random +from webob import Response +from xblock.core import XBlock +from xblock.fields import Scope, String, List, Integer, Boolean +from xblock.fragment import Fragment +from xmodule.modulestore.exceptions import ItemNotFoundError +from xmodule.x_module import XModule, STUDENT_VIEW +from xmodule.studio_editable import StudioEditableModule, StudioEditableDescriptor +from .xml_module import XmlDescriptor +from pkg_resources import resource_string + +# Make '_' a no-op so we can scrape strings +_ = lambda text: text + + +def enum(**enums): + """ enum helper in lieu of enum34 """ + return type('Enum', (), enums) + + +class LibraryVersionReference(namedtuple("LibraryVersionReference", "library_id version")): + """ + A reference to a specific library, with an optional version. + The version is used to find out when the LibraryContentXBlock was last + updated with the latest content from the library. + + library_id is a LibraryLocator + version is an ObjectId or None + """ + def __new__(cls, library_id, version=None): + # pylint: disable=super-on-old-class + if not isinstance(library_id, LibraryLocator): + library_id = LibraryLocator.from_string(library_id) + if library_id.version_guid: + assert (version is None) or (version == library_id.version_guid) + if not version: + version = library_id.version_guid + library_id = library_id.for_version(None) + if version and not isinstance(version, ObjectId): + version = ObjectId(version) + return super(LibraryVersionReference, cls).__new__(cls, library_id, version) + + @staticmethod + def from_json(value): + """ + Implement from_json to convert from JSON + """ + return LibraryVersionReference(*value) + + def to_json(self): + """ + Implement to_json to convert value to JSON + """ + # TODO: Is there anyway for an xblock to *store* an ObjectId as + # part of the List() field value? + return [unicode(self.library_id), unicode(self.version) if self.version else None] # pylint: disable=no-member + + +class LibraryList(List): + """ + Special List class for listing references to content libraries. + Is simply a list of LibraryVersionReference tuples. + """ + def from_json(self, values): + """ + Implement from_json to convert from JSON. + + values might be a list of lists, or a list of strings + Normally the runtime gives us: + [[u'library-v1:ProblemX+PR0B', '5436ffec56c02c13806a4c1b'], ...] + But the studio editor gives us: + [u'library-v1:ProblemX+PR0B,5436ffec56c02c13806a4c1b', ...] + """ + def parse(val): + """ Convert this list entry from its JSON representation """ + if isinstance(val, basestring): + val = val.strip(' []') + parts = val.rsplit(',', 1) + val = [parts[0], parts[1] if len(parts) > 1 else None] + return LibraryVersionReference.from_json(val) + return [parse(v) for v in values] + + def to_json(self, values): + """ + Implement to_json to convert value to JSON + """ + return [lvr.to_json() for lvr in values] + + +class LibraryContentFields(object): + """ + Fields for the LibraryContentModule. + + Separated out for now because they need to be added to the module and the + descriptor. + """ + # Please note the display_name of each field below is used in + # common/test/acceptance/pages/studio/overview.py:StudioLibraryContentXBlockEditModal + # to locate input elements - keep synchronized + display_name = String( + display_name=_("Display Name"), + help=_("Display name for this module"), + default="Library Content", + scope=Scope.settings, + ) + source_libraries = LibraryList( + display_name=_("Libraries"), + help=_("Enter a library ID for each library from which you want to draw content."), + default=[], + scope=Scope.settings, + ) + mode = String( + help=_("Determines how content is drawn from the library"), + default="random", + values=[ + {"display_name": _("Choose n at random"), "value": "random"} + # Future addition: Choose a new random set of n every time the student refreshes the block, for self tests + # Future addition: manually selected blocks + ], + scope=Scope.settings, + ) + max_count = Integer( + display_name=_("Count"), + help=_("Enter the number of components to display to each student."), + default=1, + scope=Scope.settings, + ) + filters = String(default="") # TBD + has_score = Boolean( + display_name=_("Scored"), + help=_("Set this value to True if this module is either a graded assignment or a practice problem."), + default=False, + scope=Scope.settings, + ) + selected = List( + # This is a list of (block_type, block_id) tuples used to record which random/first set of matching blocks was selected per user + default=[], + scope=Scope.user_state, + ) + has_children = True + + +def _get_library(modulestore, library_key): + """ + Given a library key like "library-v1:ProblemX+PR0B", return the + 'library' XBlock with meta-information about the library. + + Returns None on error. + """ + if not isinstance(library_key, LibraryLocator): + library_key = LibraryLocator.from_string(library_key) + assert library_key.version_guid is None + + # TODO: Is this too tightly coupled to split? May need to abstract this into a service + # provided by the CMS runtime. + try: + library = modulestore.get_library(library_key, remove_version=False) + except ItemNotFoundError: + return None + # We need to know the library's version so ensure it's set in library.location.library_key.version_guid + assert library.location.library_key.version_guid is not None + return library + + +#pylint: disable=abstract-method +class LibraryContentModule(LibraryContentFields, XModule, StudioEditableModule): + """ + An XBlock whose children are chosen dynamically from a content library. + Can be used to create randomized assessments among other things. + + Note: technically, all matching blocks from the content library are added + as children of this block, but only a subset of those children are shown to + any particular student. + """ + def selected_children(self): + """ + Returns a set() of block_ids indicating which of the possible children + have been selected to display to the current user. + + This reads and updates the "selected" field, which has user_state scope. + + Note: self.selected and the return value contain block_ids. To get + actual BlockUsageLocators, it is necessary to use self.children, + because the block_ids alone do not specify the block type. + """ + if hasattr(self, "_selected_set"): + # Already done: + return self._selected_set # pylint: disable=access-member-before-definition + # Determine which of our children we will show: + selected = set(tuple(k) for k in self.selected) # set of (block_type, block_id) tuples + valid_block_keys = set([(c.block_type, c.block_id) for c in self.children]) # pylint: disable=no-member + # Remove any selected blocks that are no longer valid: + selected -= (selected - valid_block_keys) + # If max_count has been decreased, we may have to drop some previously selected blocks: + while len(selected) > self.max_count: + selected.pop() + # Do we have enough blocks now? + num_to_add = self.max_count - len(selected) + if num_to_add > 0: + # We need to select [more] blocks to display to this user: + if self.mode == "random": + pool = valid_block_keys - selected + num_to_add = min(len(pool), num_to_add) + selected |= set(random.sample(pool, num_to_add)) + # We now have the correct n random children to show for this user. + else: + raise NotImplementedError("Unsupported mode.") + # Save our selections to the user state, to ensure consistency: + self.selected = list(selected) # TODO: this doesn't save from the LMS "Progress" page. + # Cache the results + self._selected_set = selected # pylint: disable=attribute-defined-outside-init + return selected + + def _get_selected_child_blocks(self): + """ + Generator returning XBlock instances of the children selected for the + current user. + """ + for block_type, block_id in self.selected_children(): + yield self.runtime.get_block(self.location.course_key.make_usage_key(block_type, block_id)) + + def student_view(self, context): + fragment = Fragment() + contents = [] + child_context = {} if not context else copy(context) + + for child in self._get_selected_child_blocks(): + for displayable in child.displayable_items(): + rendered_child = displayable.render(STUDENT_VIEW, child_context) + fragment.add_frag_resources(rendered_child) + contents.append({ + 'id': displayable.location.to_deprecated_string(), + 'content': rendered_child.content + }) + + fragment.add_content(self.system.render_template('vert_module.html', { + 'items': contents, + 'xblock_context': context, + })) + return fragment + + def author_view(self, context): + """ + Renders the Studio views. + Normal studio view: displays library status and has an "Update" button. + Studio container view: displays a preview of all possible children. + """ + fragment = Fragment() + root_xblock = context.get('root_xblock') + is_root = root_xblock and root_xblock.location == self.location + + if is_root: + # User has clicked the "View" link. Show a preview of all possible children: + if self.children: # pylint: disable=no-member + self.render_children(context, fragment, can_reorder=False, can_add=False) + else: + fragment.add_content(u'

{}

'.format( + _('No matching content found in library, no library configured, or not yet loaded from library.') + )) + else: + # When shown on a unit page, don't show any sort of preview - just the status of this block. + LibraryStatus = enum( # pylint: disable=invalid-name + NONE=0, # no library configured + INVALID=1, # invalid configuration or library has been deleted/corrupted + OK=2, # library configured correctly and should be working fine + ) + UpdateStatus = enum( # pylint: disable=invalid-name + CANNOT=0, # Cannot update - library is not set, invalid, deleted, etc. + NEEDED=1, # An update is needed - prompt the user to update + UP_TO_DATE=2, # No update necessary - library is up to date + ) + library_names = [] + library_status = LibraryStatus.OK + update_status = UpdateStatus.UP_TO_DATE + if self.source_libraries: + for library_key, version in self.source_libraries: + library = _get_library(self.runtime.descriptor_runtime.modulestore, library_key) + if library is None: + library_status = LibraryStatus.INVALID + update_status = UpdateStatus.CANNOT + break + library_names.append(library.display_name) + latest_version = library.location.library_key.version_guid + if version is None or version != latest_version: + update_status = UpdateStatus.NEEDED + # else library is up to date. + else: + library_status = LibraryStatus.NONE + update_status = UpdateStatus.CANNOT + fragment.add_content(self.system.render_template('library-block-author-view.html', { + 'library_status': library_status, + 'LibraryStatus': LibraryStatus, + 'update_status': update_status, + 'UpdateStatus': UpdateStatus, + 'library_names': library_names, + 'max_count': self.max_count, + 'mode': self.mode, + 'num_children': len(self.children), # pylint: disable=no-member + })) + fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/library_content_edit.js')) + fragment.initialize_js('LibraryContentAuthorView') + return fragment + + def get_child_descriptors(self): + """ + Return only the subset of our children relevant to the current student. + """ + return list(self._get_selected_child_blocks()) + + +@XBlock.wants('user') +class LibraryContentDescriptor(LibraryContentFields, MakoModuleDescriptor, XmlDescriptor, StudioEditableDescriptor): + """ + Descriptor class for LibraryContentModule XBlock. + """ + module_class = LibraryContentModule + mako_template = 'widgets/metadata-edit.html' + js = {'coffee': [resource_string(__name__, 'js/src/vertical/edit.coffee')]} + js_module_name = "VerticalDescriptor" + + @XBlock.handler + def refresh_children(self, request, suffix): # pylint: disable=unused-argument + """ + Refresh children: + This method is to be used when any of the libraries that this block + references have been updated. It will re-fetch all matching blocks from + the libraries, and copy them as children of this block. The children + will be given new block_ids, but the definition ID used should be the + exact same definition ID used in the library. + + This method will update this block's 'source_libraries' field to store + the version number of the libraries used, so we easily determine if + this block is up to date or not. + """ + user_id = self.runtime.service(self, 'user').user_id + root_children = [] + + store = self.system.modulestore + with store.bulk_operations(self.location.course_key): + # Currently, ALL children are essentially deleted and then re-added + # in a way that preserves their block_ids (and thus should preserve + # student data, grades, analytics, etc.) + # Once course-level field overrides are implemented, this will + # change to a more conservative implementation. + + # First, delete all our existing children to avoid block_id conflicts when we add them: + for child in self.children: # pylint: disable=access-member-before-definition + store.delete_item(child, user_id) + + # Now add all matching children, and record the library version we use: + new_libraries = [] + for library_key, old_version in self.source_libraries: # pylint: disable=unused-variable + library = _get_library(self.system.modulestore, library_key) # pylint: disable=protected-access + + def copy_children_recursively(from_block): + """ + Internal method to copy blocks from the library recursively + """ + new_children = [] + for child_key in from_block.children: + child = store.get_item(child_key, depth=9) + # We compute a block_id for each matching child block found in the library. + # block_ids are unique within any branch, but are not unique per-course or globally. + # We need our block_ids to be consistent when content in the library is updated, so + # we compute block_id as a hash of three pieces of data: + unique_data = "{}:{}:{}".format( + self.location.block_id, # Must not clash with other usages of the same library in this course + unicode(library_key.for_version(None)).encode("utf-8"), # The block ID below is only unique within a library, so we need this too + child_key.block_id, # Child block ID. Should not change even if the block is edited. + ) + child_block_id = hashlib.sha1(unique_data).hexdigest()[:20] + fields = {} + for field in child.fields.itervalues(): + if field.scope == Scope.settings and field.is_set_on(child): + fields[field.name] = field.read_from(child) + if child.has_children: + fields['children'] = copy_children_recursively(from_block=child) + new_child_info = store.create_item( + user_id, + self.location.course_key, + child_key.block_type, + block_id=child_block_id, + definition_locator=child.definition_locator, + runtime=self.system, + fields=fields, + ) + new_children.append(new_child_info.location) + return new_children + root_children.extend(copy_children_recursively(from_block=library)) + new_libraries.append(LibraryVersionReference(library_key, library.location.library_key.version_guid)) + self.source_libraries = new_libraries + self.children = root_children # pylint: disable=attribute-defined-outside-init + self.system.modulestore.update_item(self, user_id) + return Response() + + def has_dynamic_children(self): + """ + Inform the runtime that our children vary per-user. + See get_child_descriptors() above + """ + return True + + def get_content_titles(self): + """ + Returns list of friendly titles for our selected children only; without + thi, all possible children's titles would be seen in the sequence bar in + the LMS. + + This overwrites the get_content_titles method included in x_module by default. + """ + titles = [] + for child in self._xmodule.get_child_descriptors(): + titles.extend(child.get_content_titles()) + return titles + + @classmethod + def definition_from_xml(cls, xml_object, system): + """ XML support not yet implemented. """ + raise NotImplementedError + + def definition_to_xml(self, resource_fs): + """ XML support not yet implemented. """ + raise NotImplementedError + + @classmethod + def from_xml(cls, xml_data, system, id_generator): + """ XML support not yet implemented. """ + raise NotImplementedError + + def export_to_xml(self, resource_fs): + """ XML support not yet implemented. """ + raise NotImplementedError diff --git a/common/lib/xmodule/xmodule/public/js/library_content_edit.js b/common/lib/xmodule/xmodule/public/js/library_content_edit.js new file mode 100644 index 0000000000..9a84a21404 --- /dev/null +++ b/common/lib/xmodule/xmodule/public/js/library_content_edit.js @@ -0,0 +1,24 @@ +/* JavaScript for editing operations that can be done on LibraryContentXBlock */ +window.LibraryContentAuthorView = function (runtime, element) { + $(element).find('.library-update-btn').on('click', function(e) { + e.preventDefault(); + // Update the XBlock with the latest matching content from the library: + runtime.notify('save', { + state: 'start', + element: element, + message: gettext('Updating with latest library content') + }); + $.post(runtime.handlerUrl(element, 'refresh_children')).done(function() { + runtime.notify('save', { + state: 'end', + element: element + }); + // runtime.refreshXBlock(element); + // The above does not work, because this XBlock's runtime has no reference + // to the page (XBlockContainerPage). Only the Vertical XBlock's runtime has + // a reference to the page, and we have no way of getting a reference to it. + // So instead we: + location.reload(); + }); + }); +}; diff --git a/lms/djangoapps/courseware/models.py b/lms/djangoapps/courseware/models.py index 56818d4e2e..d1f1f45b89 100644 --- a/lms/djangoapps/courseware/models.py +++ b/lms/djangoapps/courseware/models.py @@ -32,6 +32,10 @@ class StudentModule(models.Model): MODULE_TYPES = (('problem', 'problem'), ('video', 'video'), ('html', 'html'), + ('course', 'course'), + ('chapter', 'Section'), + ('sequential', 'Subsection'), + ('library_content', 'Library Content'), ) ## These three are the key for the object module_type = models.CharField(max_length=32, choices=MODULE_TYPES, default='problem', db_index=True) diff --git a/lms/templates/library-block-author-view.html b/lms/templates/library-block-author-view.html new file mode 100644 index 0000000000..521946a903 --- /dev/null +++ b/lms/templates/library-block-author-view.html @@ -0,0 +1,17 @@ +<%! +from django.utils.translation import ugettext as _ +%> +
+ % if library_status == LibraryStatus.OK: +

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

+ % if update_status == UpdateStatus.NEEDED: +

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

+ % elif update_status == UpdateStatus.UP_TO_DATE: +

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

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

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

+ % else: +

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

+ % endif +
diff --git a/lms/templates/staff_problem_info.html b/lms/templates/staff_problem_info.html index 75d2789d7c..f486bfc6f6 100644 --- a/lms/templates/staff_problem_info.html +++ b/lms/templates/staff_problem_info.html @@ -4,7 +4,7 @@ ## The JS for this is defined in xqa_interface.html ${block_content} -%if location.category in ['problem','video','html','combinedopenended','graphical_slider_tool']: +%if location.category in ['problem','video','html','combinedopenended','graphical_slider_tool', 'library_content']: % if edit_link:
Edit From e1f6ca93ec36481a7eabcadd439d0ab4ca270f46 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Sat, 1 Nov 2014 19:47:28 -0700 Subject: [PATCH 09/99] Unit and integration tests of content libraries --- .../contentstore/tests/test_libraries.py | 256 ++++++++++++++++++ .../xmodule/tests/test_library_content.py | 142 ++++++++++ 2 files changed, 398 insertions(+) create mode 100644 cms/djangoapps/contentstore/tests/test_libraries.py create mode 100644 common/lib/xmodule/xmodule/tests/test_library_content.py diff --git a/cms/djangoapps/contentstore/tests/test_libraries.py b/cms/djangoapps/contentstore/tests/test_libraries.py new file mode 100644 index 0000000000..b6c6119ed1 --- /dev/null +++ b/cms/djangoapps/contentstore/tests/test_libraries.py @@ -0,0 +1,256 @@ +""" +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.preview import _load_preview_module +from contentstore.views.tests.test_library import LIBRARY_REST_URL +import ddt +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 + + +@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_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. + """ + return ItemFactory.create( + category='library_content', + parent_location=course.location, + user_id=self.user.id, + publish_item=False, + source_libraries=[LibraryVersionReference(library_key)], + **(other_settings or {}) + ) + + 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(get_test_system(), 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 + """ + session_data = {} + + def bind_module(descriptor): + """ + Helper to use the CMS's module system so we can access student-specific fields. + """ + request = Mock(user=self.user, session=session_data) + return _load_preview_module(request, descriptor) # pylint: disable=protected-access + + # Create many blocks in the library and add them to a course: + for num in range(0, 8): + ItemFactory.create( + 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() + + 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): + """ + Fetch the child shown to the current user. + """ + children = block.get_child_descriptors() + self.assertEqual(len(children), 1) + return children[0] + + # Check which child a student will see: + bind_module(lc_block) + chosen_child = get_child_of_lc_block(lc_block) + chosen_child_defn_id = chosen_child.definition_locator.definition_id + lc_block.save() + + 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, 6): # Repeat many times b/c blocks are randomized + lc_block = modulestore().get_item(lc_block_key) # Reload block from the database + bind_module(lc_block) + 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) + # 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, + 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, + 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/common/lib/xmodule/xmodule/tests/test_library_content.py b/common/lib/xmodule/xmodule/tests/test_library_content.py new file mode 100644 index 0000000000..2b52386e37 --- /dev/null +++ b/common/lib/xmodule/xmodule/tests/test_library_content.py @@ -0,0 +1,142 @@ +# -*- coding: utf-8 -*- +""" +Basic unit tests for LibraryContentModule + +Higher-level tests are in `cms/djangoapps/contentstore/tests/test_libraries.py`. +""" +import ddt +from xmodule.library_content_module import LibraryVersionReference +from xmodule.modulestore.tests.factories import LibraryFactory, CourseFactory, ItemFactory +from xmodule.modulestore.tests.utils import MixedSplitTestCase +from xmodule.tests import get_test_system +from xmodule.validation import StudioValidationMessage + + +@ddt.ddt +class TestLibraries(MixedSplitTestCase): + """ + Basic unit tests for LibraryContentModule (library_content_module.py) + """ + def setUp(self): + super(TestLibraries, self).setUp() + + self.library = LibraryFactory.create(modulestore=self.store) + self.lib_blocks = [ + ItemFactory.create( + category="html", + parent_location=self.library.location, + user_id=self.user_id, + publish_item=False, + metadata={"data": "Hello world from block {}".format(i), }, + modulestore=self.store, + ) + for i in range(1, 5) + ] + self.course = CourseFactory.create(modulestore=self.store) + self.chapter = ItemFactory.create( + category="chapter", + parent_location=self.course.location, + user_id=self.user_id, + modulestore=self.store, + ) + self.sequential = ItemFactory.create( + category="sequential", + parent_location=self.chapter.location, + user_id=self.user_id, + modulestore=self.store, + ) + self.vertical = ItemFactory.create( + category="vertical", + parent_location=self.sequential.location, + user_id=self.user_id, + modulestore=self.store, + ) + self.lc_block = ItemFactory.create( + category="library_content", + parent_location=self.vertical.location, + user_id=self.user_id, + modulestore=self.store, + metadata={ + 'max_count': 1, + 'source_libraries': [LibraryVersionReference(self.library.location.library_key)] + } + ) + + def _bind_course_module(self, module): + """ + Bind a module (part of self.course) so we can access student-specific data. + """ + module_system = get_test_system(course_id=self.course.location.course_key) + module_system.descriptor_runtime = module.runtime + + def get_module(descriptor): + """Mocks module_system get_module function""" + sub_module_system = get_test_system(course_id=self.course.location.course_key) + sub_module_system.get_module = get_module + sub_module_system.descriptor_runtime = descriptor.runtime + descriptor.bind_for_student(sub_module_system, descriptor._field_data) # pylint: disable=protected-access + return descriptor + + module_system.get_module = get_module + module.xmodule_runtime = module_system + + def test_lib_content_block(self): + """ + Test that blocks from a library are copied and added as children + """ + # Check that the LibraryContent block has no children initially + # Normally the children get added when the "source_libraries" setting + # is updated, but the way we do it through a factory doesn't do that. + self.assertEqual(len(self.lc_block.children), 0) + # Update the LibraryContent module: + self.lc_block.refresh_children(None, None) + # Check that all blocks from the library are now children of the block: + self.assertEqual(len(self.lc_block.children), len(self.lib_blocks)) + + def test_children_seen_by_a_user(self): + """ + Test that each student sees only one block as a child of the LibraryContent block. + """ + self.lc_block.refresh_children(None, None) + self.lc_block = self.store.get_item(self.lc_block.location) + self._bind_course_module(self.lc_block) + # Make sure the runtime knows that the block's children vary per-user: + self.assertTrue(self.lc_block.has_dynamic_children()) + + self.assertEqual(len(self.lc_block.children), len(self.lib_blocks)) + + # Check how many children each user will see: + self.assertEqual(len(self.lc_block.get_child_descriptors()), 1) + # Check that get_content_titles() doesn't return titles for hidden/unused children + self.assertEqual(len(self.lc_block.get_content_titles()), 1) + + def test_validation(self): + """ + Test that the validation method of LibraryContent blocks is working. + """ + # When source_libraries is blank, the validation summary should say this block needs to be configured: + self.lc_block.source_libraries = [] + result = self.lc_block.validate() + self.assertFalse(result) # Validation fails due to at least one warning/message + self.assertTrue(result.summary) + self.assertEqual(StudioValidationMessage.NOT_CONFIGURED, result.summary.type) + + # When source_libraries references a non-existent library, we should get an error: + self.lc_block.source_libraries = [LibraryVersionReference("library-v1:BAD+WOLF")] + result = self.lc_block.validate() + self.assertFalse(result) # Validation fails due to at least one warning/message + self.assertTrue(result.summary) + self.assertEqual(StudioValidationMessage.ERROR, result.summary.type) + self.assertIn("invalid", result.summary.text) + + # When source_libraries is set but the block needs to be updated, the summary should say so: + self.lc_block.source_libraries = [LibraryVersionReference(self.library.location.library_key)] + result = self.lc_block.validate() + self.assertFalse(result) # Validation fails due to at least one warning/message + self.assertTrue(result.summary) + self.assertEqual(StudioValidationMessage.WARNING, result.summary.type) + self.assertIn("out of date", result.summary.text) + + # Now if we update the block, all validation should pass: + self.lc_block.refresh_children(None, None) + self.assertTrue(self.lc_block.validate()) From d25673ec7210cfeb0bfd3aad654c020c2ed62b1d Mon Sep 17 00:00:00 2001 From: "E. Kolpakov" Date: Mon, 8 Dec 2014 15:16:42 +0700 Subject: [PATCH 10/99] LibraryContent bok choy acceptance tests --- .../xmodule/xmodule/library_content_module.py | 1 + common/test/acceptance/fixtures/course.py | 2 - common/test/acceptance/fixtures/library.py | 1 + common/test/acceptance/pages/lms/library.py | 37 ++++ .../test/acceptance/pages/studio/library.py | 179 +++++++++++++++++- .../test/acceptance/tests/lms/test_library.py | 169 +++++++++++++++++ .../tests/studio/base_studio_test.py | 6 +- .../tests/studio/test_studio_library.py | 4 +- .../studio/test_studio_library_container.py | 133 +++++++++++++ 9 files changed, 521 insertions(+), 11 deletions(-) create mode 100644 common/test/acceptance/pages/lms/library.py create mode 100644 common/test/acceptance/tests/lms/test_library.py create mode 100644 common/test/acceptance/tests/studio/test_studio_library_container.py diff --git a/common/lib/xmodule/xmodule/library_content_module.py b/common/lib/xmodule/xmodule/library_content_module.py index 092bc2a931..2d1e386847 100644 --- a/common/lib/xmodule/xmodule/library_content_module.py +++ b/common/lib/xmodule/xmodule/library_content_module.py @@ -119,6 +119,7 @@ class LibraryContentFields(object): scope=Scope.settings, ) mode = String( + display_name=_("Mode"), help=_("Determines how content is drawn from the library"), default="random", values=[ diff --git a/common/test/acceptance/fixtures/course.py b/common/test/acceptance/fixtures/course.py index 1e5bca8a33..656a12a965 100644 --- a/common/test/acceptance/fixtures/course.py +++ b/common/test/acceptance/fixtures/course.py @@ -375,5 +375,3 @@ class CourseFixture(XBlockContainerFixture): """ super(CourseFixture, self)._create_xblock_children(parent_loc, xblock_descriptions) self._publish_xblock(parent_loc) - - diff --git a/common/test/acceptance/fixtures/library.py b/common/test/acceptance/fixtures/library.py index f97b8e9fc2..5692c078db 100644 --- a/common/test/acceptance/fixtures/library.py +++ b/common/test/acceptance/fixtures/library.py @@ -27,6 +27,7 @@ class LibraryFixture(XBlockContainerFixture): 'display_name': display_name } + self.display_name = display_name self._library_key = None super(LibraryFixture, self).__init__() diff --git a/common/test/acceptance/pages/lms/library.py b/common/test/acceptance/pages/lms/library.py new file mode 100644 index 0000000000..8655fae79f --- /dev/null +++ b/common/test/acceptance/pages/lms/library.py @@ -0,0 +1,37 @@ +""" +Library Content XBlock Wrapper +""" +from bok_choy.page_object import PageObject + + +class LibraryContentXBlockWrapper(PageObject): + """ + A PageObject representing a wrapper around a LibraryContent block seen in the LMS + """ + url = None + BODY_SELECTOR = '.xblock-student_view div' + + def __init__(self, browser, locator): + super(LibraryContentXBlockWrapper, self).__init__(browser) + self.locator = locator + + def is_browser_on_page(self): + return self.q(css='{}[data-id="{}"]'.format(self.BODY_SELECTOR, self.locator)).present + + def _bounded_selector(self, selector): + """ + Return `selector`, but limited to this particular block's context + """ + return '{}[data-id="{}"] {}'.format( + self.BODY_SELECTOR, + self.locator, + selector + ) + + @property + def children_contents(self): + """ + Gets contents of all child XBlocks as list of strings + """ + child_blocks = self.q(css=self._bounded_selector("div[data-id]")) + return frozenset(child.text for child in child_blocks) diff --git a/common/test/acceptance/pages/studio/library.py b/common/test/acceptance/pages/studio/library.py index 64f93f2116..3151324cd0 100644 --- a/common/test/acceptance/pages/studio/library.py +++ b/common/test/acceptance/pages/studio/library.py @@ -3,8 +3,12 @@ Library edit page in Studio """ from bok_choy.page_object import PageObject -from ...pages.studio.pagination import PaginatedMixin +from bok_choy.promise import EmptyPromise +from selenium.webdriver.common.keys import Keys +from selenium.webdriver.support.select import Select +from .overview import CourseOutlineModal from .container import XBlockWrapper +from ...pages.studio.pagination import PaginatedMixin from ...tests.helpers import disable_animations from .utils import confirm_prompt, wait_for_notification from . import BASE_URL @@ -48,7 +52,10 @@ class LibraryPage(PageObject, PaginatedMixin): 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') + self.wait_for_element_invisibility( + '.ui-loading', + 'Wait for the page to complete its initial loading of XBlocks via AJAX' + ) disable_animations(self) @property @@ -80,14 +87,18 @@ class LibraryPage(PageObject, PaginatedMixin): 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 + 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) + 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): """ @@ -95,4 +106,162 @@ class LibraryPage(PageObject, PaginatedMixin): 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)) + return self._div_for_xblock_id(xblock_id)[0].find_element_by_css_selector( + '.header-actions .{action}-button.action-button'.format(action=action) + ) + + +class StudioLibraryContentXBlockEditModal(CourseOutlineModal, PageObject): + """ + Library Content XBlock Modal edit window + """ + url = None + MODAL_SELECTOR = ".wrapper-modal-window-edit-xblock" + + # Labels used to identify the fields on the edit modal: + LIBRARY_LABEL = "Libraries" + COUNT_LABEL = "Count" + SCORED_LABEL = "Scored" + + def is_browser_on_page(self): + """ + Check that we are on the right page in the browser. + """ + return self.is_shown() + + @property + def library_key(self): + """ + Gets value of first library key input + """ + library_key_input = self.get_metadata_input(self.LIBRARY_LABEL) + if library_key_input is not None: + return library_key_input.get_attribute('value').strip(',') + return None + + @library_key.setter + def library_key(self, library_key): + """ + Sets value of first library key input, creating it if necessary + """ + library_key_input = self.get_metadata_input(self.LIBRARY_LABEL) + if library_key_input is None: + library_key_input = self._add_library_key() + if library_key is not None: + # can't use lib_text.clear() here as input get deleted by client side script + library_key_input.send_keys(Keys.HOME) + library_key_input.send_keys(Keys.SHIFT, Keys.END) + library_key_input.send_keys(library_key) + else: + library_key_input.clear() + EmptyPromise(lambda: self.library_key == library_key, "library_key is updated in modal.").fulfill() + + @property + def count(self): + """ + Gets value of children count input + """ + return int(self.get_metadata_input(self.COUNT_LABEL).get_attribute('value')) + + @count.setter + def count(self, count): + """ + Sets value of children count input + """ + count_text = self.get_metadata_input(self.COUNT_LABEL) + count_text.clear() + count_text.send_keys(count) + EmptyPromise(lambda: self.count == count, "count is updated in modal.").fulfill() + + @property + def scored(self): + """ + Gets value of scored select + """ + value = self.get_metadata_input(self.SCORED_LABEL).get_attribute('value') + if value == 'True': + return True + elif value == 'False': + return False + raise ValueError("Unknown value {value} set for {label}".format(value=value, label=self.SCORED_LABEL)) + + @scored.setter + def scored(self, scored): + """ + Sets value of scored select + """ + select_element = self.get_metadata_input(self.SCORED_LABEL) + select_element.click() + scored_select = Select(select_element) + scored_select.select_by_value(str(scored)) + EmptyPromise(lambda: self.scored == scored, "scored is updated in modal.").fulfill() + + def _add_library_key(self): + """ + Adds library key input + """ + wrapper = self._get_metadata_element(self.LIBRARY_LABEL) + add_button = wrapper.find_element_by_xpath(".//a[contains(@class, 'create-action')]") + add_button.click() + return self._get_list_inputs(wrapper)[0] + + def _get_list_inputs(self, list_wrapper): + """ + Finds nested input elements (useful for List and Dict fields) + """ + return list_wrapper.find_elements_by_xpath(".//input[@type='text']") + + def _get_metadata_element(self, metadata_key): + """ + Gets metadata input element (a wrapper div for List and Dict fields) + """ + metadata_inputs = self.find_css(".metadata_entry .wrapper-comp-setting label.setting-label") + target_label = [elem for elem in metadata_inputs if elem.text == metadata_key][0] + label_for = target_label.get_attribute('for') + return self.find_css("#" + label_for)[0] + + def get_metadata_input(self, metadata_key): + """ + Gets input/select element for given field + """ + element = self._get_metadata_element(metadata_key) + if element.tag_name == 'div': + # List or Dict field - return first input + # TODO support multiple values + inputs = self._get_list_inputs(element) + element = inputs[0] if inputs else None + return element + + +class StudioLibraryContainerXBlockWrapper(XBlockWrapper): + """ + Wraps :class:`.container.XBlockWrapper` for use with LibraryContent blocks + """ + url = None + + @classmethod + def from_xblock_wrapper(cls, xblock_wrapper): + """ + Factory method: creates :class:`.StudioLibraryContainerXBlockWrapper` from :class:`.container.XBlockWrapper` + """ + return cls(xblock_wrapper.browser, xblock_wrapper.locator) + + @property + def header_text(self): + """ + Gets library content text + """ + return self.get_body_paragraphs().first.text[0] + + def get_body_paragraphs(self): + """ + Gets library content body paragraphs + """ + return self.q(css=self._bounded_selector(".xblock-message-area p")) + + def refresh_children(self): + """ + Click "Update now..." button + """ + refresh_button = self.q(css=self._bounded_selector(".library-update-btn")) + refresh_button.click() diff --git a/common/test/acceptance/tests/lms/test_library.py b/common/test/acceptance/tests/lms/test_library.py new file mode 100644 index 0000000000..78d699faa6 --- /dev/null +++ b/common/test/acceptance/tests/lms/test_library.py @@ -0,0 +1,169 @@ +# -*- coding: utf-8 -*- +""" +End-to-end tests for LibraryContent block in LMS +""" +import ddt + +from ..helpers import UniqueCourseTest +from ...pages.studio.auto_auth import AutoAuthPage +from ...pages.studio.overview import CourseOutlinePage +from ...pages.studio.library import StudioLibraryContentXBlockEditModal, StudioLibraryContainerXBlockWrapper +from ...pages.lms.courseware import CoursewarePage +from ...pages.lms.library import LibraryContentXBlockWrapper +from ...pages.common.logout import LogoutPage +from ...fixtures.course import CourseFixture, XBlockFixtureDesc +from ...fixtures.library import LibraryFixture + +SECTION_NAME = 'Test Section' +SUBSECTION_NAME = 'Test Subsection' +UNIT_NAME = 'Test Unit' + + +@ddt.ddt +class LibraryContentTest(UniqueCourseTest): + """ + Test courseware. + """ + USERNAME = "STUDENT_TESTER" + EMAIL = "student101@example.com" + + STAFF_USERNAME = "STAFF_TESTER" + STAFF_EMAIL = "staff101@example.com" + + def setUp(self): + """ + Set up library, course and library content XBlock + """ + super(LibraryContentTest, self).setUp() + + self.courseware_page = CoursewarePage(self.browser, self.course_id) + + self.course_outline = CourseOutlinePage( + self.browser, + self.course_info['org'], + self.course_info['number'], + self.course_info['run'] + ) + + self.library_fixture = LibraryFixture('test_org', self.unique_id, 'Test Library {}'.format(self.unique_id)) + self.library_fixture.add_children( + XBlockFixtureDesc("html", "Html1", data='html1'), + XBlockFixtureDesc("html", "Html2", data='html2'), + XBlockFixtureDesc("html", "Html3", data='html3'), + ) + + self.library_fixture.install() + self.library_info = self.library_fixture.library_info + self.library_key = self.library_fixture.library_key + + # Install a course with library content xblock + self.course_fixture = CourseFixture( + self.course_info['org'], self.course_info['number'], + self.course_info['run'], self.course_info['display_name'] + ) + + library_content_metadata = { + 'source_libraries': [self.library_key], + 'mode': 'random', + 'max_count': 1, + 'has_score': False + } + + self.lib_block = XBlockFixtureDesc('library_content', "Library Content", metadata=library_content_metadata) + + self.course_fixture.add_children( + XBlockFixtureDesc('chapter', SECTION_NAME).add_children( + XBlockFixtureDesc('sequential', SUBSECTION_NAME).add_children( + XBlockFixtureDesc('vertical', UNIT_NAME).add_children( + self.lib_block + ) + ) + ) + ) + + self.course_fixture.install() + + def _refresh_library_content_children(self, count=1): + """ + Performs library block refresh in Studio, configuring it to show {count} children + """ + unit_page = self._go_to_unit_page(True) + library_container_block = StudioLibraryContainerXBlockWrapper.from_xblock_wrapper(unit_page.xblocks[0]) + modal = StudioLibraryContentXBlockEditModal(library_container_block.edit()) + modal.count = count + library_container_block.save_settings() + library_container_block.refresh_children() + self._go_to_unit_page(change_login=False) + unit_page.wait_for_page() + unit_page.publish_action.click() + unit_page.wait_for_ajax() + self.assertIn("Published and Live", unit_page.publish_title) + + @property + def library_xblocks_texts(self): + """ + Gets texts of all xblocks in library + """ + return frozenset(child.data for child in self.library_fixture.children) + + def _go_to_unit_page(self, change_login=True): + """ + Open unit page in Studio + """ + if change_login: + LogoutPage(self.browser).visit() + self._auto_auth(self.STAFF_USERNAME, self.STAFF_EMAIL, True) + self.course_outline.visit() + subsection = self.course_outline.section(SECTION_NAME).subsection(SUBSECTION_NAME) + return subsection.toggle_expand().unit(UNIT_NAME).go_to() + + def _goto_library_block_page(self, block_id=None): + """ + Open library page in LMS + """ + self.courseware_page.visit() + block_id = block_id if block_id is not None else self.lib_block.locator + #pylint: disable=attribute-defined-outside-init + self.library_content_page = LibraryContentXBlockWrapper(self.browser, block_id) + + def _auto_auth(self, username, email, staff): + """ + Logout and login with given credentials. + """ + AutoAuthPage(self.browser, username=username, email=email, + course_id=self.course_id, staff=staff).visit() + + @ddt.data(1, 2, 3) + def test_shows_random_xblocks_from_configured(self, count): + """ + Scenario: Ensures that library content shows {count} random xblocks from library in LMS + Given I have a library, a course and a LibraryContent block in that course + When I go to studio unit page for library content xblock as staff + And I set library content xblock to display {count} random children + And I refresh library content xblock and pulbish unit + When I go to LMS courseware page for library content xblock as student + Then I can see {count} random xblocks from the library + """ + self._refresh_library_content_children(count=count) + self._auto_auth(self.USERNAME, self.EMAIL, False) + self._goto_library_block_page() + children_contents = self.library_content_page.children_contents + self.assertEqual(len(children_contents), count) + self.assertLessEqual(children_contents, self.library_xblocks_texts) + + def test_shows_all_if_max_set_to_greater_value(self): + """ + Scenario: Ensures that library content shows {count} random xblocks from library in LMS + Given I have a library, a course and a LibraryContent block in that course + When I go to studio unit page for library content xblock as staff + And I set library content xblock to display more children than library have + And I refresh library content xblock and pulbish unit + When I go to LMS courseware page for library content xblock as student + Then I can see all xblocks from the library + """ + self._refresh_library_content_children(count=10) + self._auto_auth(self.USERNAME, self.EMAIL, False) + self._goto_library_block_page() + children_contents = self.library_content_page.children_contents + self.assertEqual(len(children_contents), 3) + self.assertEqual(children_contents, self.library_xblocks_texts) diff --git a/common/test/acceptance/tests/studio/base_studio_test.py b/common/test/acceptance/tests/studio/base_studio_test.py index ec94f7f058..02fdcbe998 100644 --- a/common/test/acceptance/tests/studio/base_studio_test.py +++ b/common/test/acceptance/tests/studio/base_studio_test.py @@ -109,8 +109,9 @@ class StudioLibraryTest(WebAppTest): """ Base class for all Studio library tests. """ + as_staff = True - def setUp(self, is_staff=False): # pylint: disable=arguments-differ + def setUp(self): # pylint: disable=arguments-differ """ Install a library with no content using a fixture. """ @@ -122,10 +123,11 @@ class StudioLibraryTest(WebAppTest): ) self.populate_library_fixture(fixture) fixture.install() + self.library_fixture = fixture self.library_info = fixture.library_info self.library_key = fixture.library_key self.user = fixture.user - self.log_in(self.user, is_staff) + self.log_in(self.user, self.as_staff) def populate_library_fixture(self, library_fixture): """ diff --git a/common/test/acceptance/tests/studio/test_studio_library.py b/common/test/acceptance/tests/studio/test_studio_library.py index 491c9093d0..b0d6cffb1a 100644 --- a/common/test/acceptance/tests/studio/test_studio_library.py +++ b/common/test/acceptance/tests/studio/test_studio_library.py @@ -18,7 +18,7 @@ class LibraryEditPageTest(StudioLibraryTest): """ Ensure a library exists and navigate to the library edit page. """ - super(LibraryEditPageTest, self).setUp(is_staff=True) + super(LibraryEditPageTest, self).setUp() self.lib_page = LibraryPage(self.browser, self.library_key) self.lib_page.visit() self.lib_page.wait_until_ready() @@ -156,7 +156,7 @@ class LibraryNavigationTest(StudioLibraryTest): """ Ensure a library exists and navigate to the library edit page. """ - super(LibraryNavigationTest, self).setUp(is_staff=True) + super(LibraryNavigationTest, self).setUp() self.lib_page = LibraryPage(self.browser, self.library_key) self.lib_page.visit() self.lib_page.wait_until_ready() diff --git a/common/test/acceptance/tests/studio/test_studio_library_container.py b/common/test/acceptance/tests/studio/test_studio_library_container.py new file mode 100644 index 0000000000..7bb712c779 --- /dev/null +++ b/common/test/acceptance/tests/studio/test_studio_library_container.py @@ -0,0 +1,133 @@ +""" +Acceptance tests for Library Content in LMS +""" +import ddt +from .base_studio_test import StudioLibraryTest, ContainerBase +from ...pages.studio.library import StudioLibraryContentXBlockEditModal, StudioLibraryContainerXBlockWrapper +from ...fixtures.course import XBlockFixtureDesc + +SECTION_NAME = 'Test Section' +SUBSECTION_NAME = 'Test Subsection' +UNIT_NAME = 'Test Unit' + + +@ddt.ddt +class StudioLibraryContainerTest(ContainerBase, StudioLibraryTest): + """ + Test Library Content block in LMS + """ + def setUp(self): + """ + Install library with some content and a course using fixtures + """ + super(StudioLibraryContainerTest, self).setUp() + self.outline.visit() + subsection = self.outline.section(SECTION_NAME).subsection(SUBSECTION_NAME) + self.unit_page = subsection.toggle_expand().unit(UNIT_NAME).go_to() + + def populate_library_fixture(self, library_fixture): + """ + Populate the children of the test course fixture. + """ + library_fixture.add_children( + XBlockFixtureDesc("html", "Html1"), + XBlockFixtureDesc("html", "Html2"), + XBlockFixtureDesc("html", "Html3"), + ) + + def populate_course_fixture(self, course_fixture): + """ Install a course with sections/problems, tabs, updates, and handouts """ + library_content_metadata = { + 'source_libraries': [self.library_key], + 'mode': 'random', + 'max_count': 1, + 'has_score': False + } + + course_fixture.add_children( + XBlockFixtureDesc('chapter', SECTION_NAME).add_children( + XBlockFixtureDesc('sequential', SUBSECTION_NAME).add_children( + XBlockFixtureDesc('vertical', UNIT_NAME).add_children( + XBlockFixtureDesc('library_content', "Library Content", metadata=library_content_metadata) + ) + ) + ) + ) + + def _get_library_xblock_wrapper(self, xblock): + """ + Wraps xblock into :class:`...pages.studio.library.StudioLibraryContainerXBlockWrapper` + """ + return StudioLibraryContainerXBlockWrapper.from_xblock_wrapper(xblock) + + @ddt.data( + ('library-v1:111+111', 1, True), + ('library-v1:edX+L104', 2, False), + ('library-v1:OtherX+IDDQD', 3, True), + ) + @ddt.unpack + def test_can_edit_metadata(self, library_key, max_count, scored): + """ + Scenario: Given I have a library, a course and library content xblock in a course + When I go to studio unit page for library content block + And I edit library content metadata and save it + Then I can ensure that data is persisted + """ + library_container = self._get_library_xblock_wrapper(self.unit_page.xblocks[0]) + edit_modal = StudioLibraryContentXBlockEditModal(library_container.edit()) + edit_modal.library_key = library_key + edit_modal.count = max_count + edit_modal.scored = scored + + library_container.save_settings() # saving settings + + # open edit window again to verify changes are persistent + edit_modal = StudioLibraryContentXBlockEditModal(library_container.edit()) + self.assertEqual(edit_modal.library_key, library_key) + self.assertEqual(edit_modal.count, max_count) + self.assertEqual(edit_modal.scored, scored) + + def test_no_library_shows_library_not_configured(self): + """ + Scenario: Given I have a library, a course and library content xblock in a course + When I go to studio unit page for library content block + And I edit set library key to none + Then I can see that library content block is misconfigured + """ + expected_text = 'No library or filters configured. Press "Edit" to configure.' + library_container = self._get_library_xblock_wrapper(self.unit_page.xblocks[0]) + + # precondition check - assert library is configured before we remove it + self.assertNotIn(expected_text, library_container.header_text) + + edit_modal = StudioLibraryContentXBlockEditModal(library_container.edit()) + edit_modal.library_key = None + + library_container.save_settings() + + self.assertIn(expected_text, library_container.header_text) + + @ddt.data( + 'library-v1:111+111', + 'library-v1:edX+L104', + ) + def test_set_missing_library_shows_correct_label(self, library_key): + """ + Scenario: Given I have a library, a course and library content xblock in a course + When I go to studio unit page for library content block + And I edit set library key to non-existent library + Then I can see that library content block is misconfigured + """ + expected_text = "Library is invalid, corrupt, or has been deleted." + + library_container = self._get_library_xblock_wrapper(self.unit_page.xblocks[0]) + + # precondition check - assert library is configured before we remove it + self.assertNotIn(expected_text, library_container.header_text) + + edit_modal = StudioLibraryContentXBlockEditModal(library_container.edit()) + edit_modal.library_key = library_key + + library_container.save_settings() + + self.assertIn(expected_text, library_container.header_text) From d4e82424775e2e7d1ab190acda29bed703f8dbee Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Wed, 10 Dec 2014 13:02:38 -0800 Subject: [PATCH 11/99] Friendly error message when library key is invalid --- cms/djangoapps/contentstore/views/item.py | 5 ++-- .../contentstore/views/tests/test_item.py | 23 +++++++++++++++++++ .../xmodule/xmodule/library_content_module.py | 17 +++++++++++--- 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py index 847d9c91af..3ed4305a2c 100644 --- a/cms/djangoapps/contentstore/views/item.py +++ b/cms/djangoapps/contentstore/views/item.py @@ -426,8 +426,9 @@ def _save_xblock(user, xblock, data=None, children_strings=None, metadata=None, else: try: value = field.from_json(value) - except ValueError: - return JsonResponse({"error": "Invalid data"}, 400) + except ValueError as verr: + reason = _("Invalid data ({details})").format(details=verr.message) if verr.message else _("Invalid data") + return JsonResponse({"error": reason}, 400) field.write_to(xblock, value) # update the xblock and call any xblock callbacks diff --git a/cms/djangoapps/contentstore/views/tests/test_item.py b/cms/djangoapps/contentstore/views/tests/test_item.py index d6d913a594..6b595ae007 100644 --- a/cms/djangoapps/contentstore/views/tests/test_item.py +++ b/cms/djangoapps/contentstore/views/tests/test_item.py @@ -894,6 +894,29 @@ class TestEditItem(ItemTest): self._verify_published_with_draft(unit_usage_key) self._verify_published_with_draft(html_usage_key) + def test_field_value_errors(self): + """ + Test that if the user's input causes a ValueError on an XBlock field, + we provide a friendly error message back to the user. + """ + response = self.create_xblock(parent_usage_key=self.seq_usage_key, category='video') + video_usage_key = self.response_usage_key(response) + update_url = reverse_usage_url('xblock_handler', video_usage_key) + + response = self.client.ajax_post( + update_url, + data={ + 'id': unicode(video_usage_key), + 'metadata': { + 'saved_video_position': "Not a valid relative time", + }, + } + ) + self.assertEqual(response.status_code, 400) + parsed = json.loads(response.content) + self.assertIn("error", parsed) + self.assertIn("Incorrect RelativeTime value", parsed["error"]) # See xmodule/fields.py + class TestEditSplitModule(ItemTest): """ diff --git a/common/lib/xmodule/xmodule/library_content_module.py b/common/lib/xmodule/xmodule/library_content_module.py index 2d1e386847..4233429e0b 100644 --- a/common/lib/xmodule/xmodule/library_content_module.py +++ b/common/lib/xmodule/xmodule/library_content_module.py @@ -1,11 +1,12 @@ """ LibraryContent: The XBlock used to include blocks from a library in a course. """ -from bson.objectid import ObjectId +from bson.objectid import ObjectId, InvalidId from collections import namedtuple from copy import copy import hashlib from .mako_module import MakoModuleDescriptor +from opaque_keys import InvalidKeyError from opaque_keys.edx.locator import LibraryLocator import random from webob import Response @@ -46,7 +47,10 @@ class LibraryVersionReference(namedtuple("LibraryVersionReference", "library_id version = library_id.version_guid library_id = library_id.for_version(None) if version and not isinstance(version, ObjectId): - version = ObjectId(version) + try: + version = ObjectId(version) + except InvalidId: + raise ValueError(version) return super(LibraryVersionReference, cls).__new__(cls, library_id, version) @staticmethod @@ -86,7 +90,14 @@ class LibraryList(List): val = val.strip(' []') parts = val.rsplit(',', 1) val = [parts[0], parts[1] if len(parts) > 1 else None] - return LibraryVersionReference.from_json(val) + try: + return LibraryVersionReference.from_json(val) + except InvalidKeyError: + try: + friendly_val = val[0] # Just get the library key part, not the version + except IndexError: + friendly_val = unicode(val) + raise ValueError(_('"{value}" is not a valid library ID.').format(value=friendly_val)) return [parse(v) for v in values] def to_json(self, values): From 9f85c0f0aaf406346073a2d8ee06bc6888352228 Mon Sep 17 00:00:00 2001 From: Jonathan Piacenti Date: Wed, 26 Nov 2014 21:48:08 +0000 Subject: [PATCH 12/99] Added explanation to container view of Library Block. --- common/lib/xmodule/xmodule/library_content_module.py | 7 ++++++- lms/templates/library-block-author-preview-header.html | 10 ++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 lms/templates/library-block-author-preview-header.html diff --git a/common/lib/xmodule/xmodule/library_content_module.py b/common/lib/xmodule/xmodule/library_content_module.py index 4233429e0b..753d193855 100644 --- a/common/lib/xmodule/xmodule/library_content_module.py +++ b/common/lib/xmodule/xmodule/library_content_module.py @@ -251,7 +251,7 @@ class LibraryContentModule(LibraryContentFields, XModule, StudioEditableModule): fragment.add_frag_resources(rendered_child) contents.append({ 'id': displayable.location.to_deprecated_string(), - 'content': rendered_child.content + 'content': rendered_child.content, }) fragment.add_content(self.system.render_template('vert_module.html', { @@ -273,6 +273,11 @@ class LibraryContentModule(LibraryContentFields, XModule, StudioEditableModule): if is_root: # User has clicked the "View" link. Show a preview of all possible children: if self.children: # pylint: disable=no-member + fragment.add_content(self.system.render_template("library-block-author-preview-header.html", { + 'max_count': self.max_count, + 'display_name': self.display_name or self.url_name, + 'mode': self.mode, + })) self.render_children(context, fragment, can_reorder=False, can_add=False) else: fragment.add_content(u'

{}

'.format( diff --git a/lms/templates/library-block-author-preview-header.html b/lms/templates/library-block-author-preview-header.html new file mode 100644 index 0000000000..4596281b67 --- /dev/null +++ b/lms/templates/library-block-author-preview-header.html @@ -0,0 +1,10 @@ +<%! from django.utils.translation import ugettext as _ %> +
+
+

+ + ${_('Showing all matching content eligible to be added into {display_name}. Each student will be assigned {mode} {max_count} components from this list.').format(max_count=max_count, display_name=display_name, mode=mode)} + +

+
+
From 80ea764c9dcd5f3a94c4863de857dfc4c201f5c7 Mon Sep 17 00:00:00 2001 From: Jonathan Piacenti Date: Wed, 26 Nov 2014 23:57:16 +0000 Subject: [PATCH 13/99] Made errors on Library blocks use validate functionality. --- .../xmodule/xmodule/library_content_module.py | 77 ++++++++++++------- lms/templates/library-block-author-view.html | 6 +- 2 files changed, 52 insertions(+), 31 deletions(-) diff --git a/common/lib/xmodule/xmodule/library_content_module.py b/common/lib/xmodule/xmodule/library_content_module.py index 753d193855..476c97b011 100644 --- a/common/lib/xmodule/xmodule/library_content_module.py +++ b/common/lib/xmodule/xmodule/library_content_module.py @@ -14,6 +14,7 @@ from xblock.core import XBlock from xblock.fields import Scope, String, List, Integer, Boolean from xblock.fragment import Fragment from xmodule.modulestore.exceptions import ItemNotFoundError +from xmodule.validation import StudioValidationMessage, StudioValidation from xmodule.x_module import XModule, STUDENT_VIEW from xmodule.studio_editable import StudioEditableModule, StudioEditableDescriptor from .xml_module import XmlDescriptor @@ -260,6 +261,40 @@ class LibraryContentModule(LibraryContentFields, XModule, StudioEditableModule): })) return fragment + def validate(self): + """ + Validates the state of this Library Content Module Instance. This + is the override of the general XBlock method, and it will also ask + its superclass to validate. + """ + validation = super(LibraryContentModule, self).validate() + if not isinstance(validation, StudioValidation): + validation = StudioValidation.copy(validation) + if not self.source_libraries: + validation.set_summary( + StudioValidationMessage( + StudioValidationMessage.NOT_CONFIGURED, + _(u"A library has not yet been selected."), + action_class='edit-button', + action_label=_(u"Select a Library") + ) + ) + return validation + for library_key, version in self.source_libraries: # pylint: disable=unused-variable + library = _get_library(self.runtime.descriptor_runtime.modulestore, library_key) + if library is None: + validation.set_summary( + StudioValidationMessage( + StudioValidationMessage.ERROR, + _(u'Library is invalid, corrupt, or has been deleted.'), + action_class='edit-button', + action_label=_(u"Edit Library List") + ) + ) + break + + return validation + def author_view(self, context): """ Renders the Studio views. @@ -284,41 +319,31 @@ class LibraryContentModule(LibraryContentFields, XModule, StudioEditableModule): _('No matching content found in library, no library configured, or not yet loaded from library.') )) else: - # When shown on a unit page, don't show any sort of preview - just the status of this block. - LibraryStatus = enum( # pylint: disable=invalid-name - NONE=0, # no library configured - INVALID=1, # invalid configuration or library has been deleted/corrupted - OK=2, # library configured correctly and should be working fine - ) UpdateStatus = enum( # pylint: disable=invalid-name CANNOT=0, # Cannot update - library is not set, invalid, deleted, etc. NEEDED=1, # An update is needed - prompt the user to update UP_TO_DATE=2, # No update necessary - library is up to date ) + # When shown on a unit page, don't show any sort of preview - just the status of this block. + library_ok = bool(self.source_libraries) # True if at least one source library is defined library_names = [] - library_status = LibraryStatus.OK update_status = UpdateStatus.UP_TO_DATE - if self.source_libraries: - for library_key, version in self.source_libraries: - library = _get_library(self.runtime.descriptor_runtime.modulestore, library_key) - if library is None: - library_status = LibraryStatus.INVALID - update_status = UpdateStatus.CANNOT - break - library_names.append(library.display_name) - latest_version = library.location.library_key.version_guid - if version is None or version != latest_version: - update_status = UpdateStatus.NEEDED - # else library is up to date. - else: - library_status = LibraryStatus.NONE - update_status = UpdateStatus.CANNOT + for library_key, version in self.source_libraries: + library = _get_library(self.runtime.descriptor_runtime.modulestore, library_key) + if library is None: + update_status = UpdateStatus.CANNOT + library_ok = False + break + library_names.append(library.display_name) + latest_version = library.location.library_key.version_guid + if version is None or version != latest_version: + update_status = UpdateStatus.NEEDED + fragment.add_content(self.system.render_template('library-block-author-view.html', { - 'library_status': library_status, - 'LibraryStatus': LibraryStatus, - 'update_status': update_status, - 'UpdateStatus': UpdateStatus, 'library_names': library_names, + 'library_ok': library_ok, + 'UpdateStatus': UpdateStatus, + 'update_status': update_status, 'max_count': self.max_count, 'mode': self.mode, 'num_children': len(self.children), # pylint: disable=no-member diff --git a/lms/templates/library-block-author-view.html b/lms/templates/library-block-author-view.html index 521946a903..ce1542cc58 100644 --- a/lms/templates/library-block-author-view.html +++ b/lms/templates/library-block-author-view.html @@ -2,16 +2,12 @@ from django.utils.translation import ugettext as _ %>
- % if library_status == LibraryStatus.OK: + % if library_ok:

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

% if update_status == UpdateStatus.NEEDED:

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

% elif update_status == UpdateStatus.UP_TO_DATE:

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

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

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

- % else: -

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

% endif
From e498872ab1fdd361fa65bac981e30c75cf9f1586 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Wed, 10 Dec 2014 14:08:51 -0800 Subject: [PATCH 14/99] Move update link to the validation area --- .../xmodule/xmodule/library_content_module.py | 63 +++++++++---------- .../xmodule/public/js/library_content_edit.js | 12 +++- .../test/acceptance/pages/studio/container.py | 39 ++++++++++++ .../test/acceptance/pages/studio/library.py | 11 +--- .../studio/test_studio_library_container.py | 47 ++++++++++---- .../library-block-author-preview-header.html | 2 +- lms/templates/library-block-author-view.html | 9 +-- 7 files changed, 116 insertions(+), 67 deletions(-) diff --git a/common/lib/xmodule/xmodule/library_content_module.py b/common/lib/xmodule/xmodule/library_content_module.py index 476c97b011..d9e28e93fd 100644 --- a/common/lib/xmodule/xmodule/library_content_module.py +++ b/common/lib/xmodule/xmodule/library_content_module.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- """ LibraryContent: The XBlock used to include blocks from a library in a course. """ @@ -280,9 +281,21 @@ class LibraryContentModule(LibraryContentFields, XModule, StudioEditableModule): ) ) return validation - for library_key, version in self.source_libraries: # pylint: disable=unused-variable + for library_key, version in self.source_libraries: library = _get_library(self.runtime.descriptor_runtime.modulestore, library_key) - if library is None: + if library is not None: + latest_version = library.location.library_key.version_guid + if version is None or version != latest_version: + validation.set_summary( + StudioValidationMessage( + StudioValidationMessage.WARNING, + _(u'This component is out of date. The library has new content.'), + action_class='library-update-btn', # TODO: change this to action_runtime_event='...' once the unit page supports that feature. + action_label=_(u"↻ Update now") + ) + ) + break + else: validation.set_summary( StudioValidationMessage( StudioValidationMessage.ERROR, @@ -298,7 +311,7 @@ class LibraryContentModule(LibraryContentFields, XModule, StudioEditableModule): def author_view(self, context): """ Renders the Studio views. - Normal studio view: displays library status and has an "Update" button. + Normal studio view: If block is properly configured, displays library status summary Studio container view: displays a preview of all possible children. """ fragment = Fragment() @@ -311,45 +324,25 @@ class LibraryContentModule(LibraryContentFields, XModule, StudioEditableModule): fragment.add_content(self.system.render_template("library-block-author-preview-header.html", { 'max_count': self.max_count, 'display_name': self.display_name or self.url_name, - 'mode': self.mode, })) self.render_children(context, fragment, can_reorder=False, can_add=False) - else: - fragment.add_content(u'

{}

'.format( - _('No matching content found in library, no library configured, or not yet loaded from library.') - )) else: - UpdateStatus = enum( # pylint: disable=invalid-name - CANNOT=0, # Cannot update - library is not set, invalid, deleted, etc. - NEEDED=1, # An update is needed - prompt the user to update - UP_TO_DATE=2, # No update necessary - library is up to date - ) # When shown on a unit page, don't show any sort of preview - just the status of this block. - library_ok = bool(self.source_libraries) # True if at least one source library is defined library_names = [] - update_status = UpdateStatus.UP_TO_DATE - for library_key, version in self.source_libraries: + for library_key, version in self.source_libraries: # pylint: disable=unused-variable library = _get_library(self.runtime.descriptor_runtime.modulestore, library_key) - if library is None: - update_status = UpdateStatus.CANNOT - library_ok = False - break - library_names.append(library.display_name) - latest_version = library.location.library_key.version_guid - if version is None or version != latest_version: - update_status = UpdateStatus.NEEDED + if library is not None: + library_names.append(library.display_name) - fragment.add_content(self.system.render_template('library-block-author-view.html', { - 'library_names': library_names, - 'library_ok': library_ok, - 'UpdateStatus': UpdateStatus, - 'update_status': update_status, - 'max_count': self.max_count, - 'mode': self.mode, - 'num_children': len(self.children), # pylint: disable=no-member - })) - fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/library_content_edit.js')) - fragment.initialize_js('LibraryContentAuthorView') + if library_names: + fragment.add_content(self.system.render_template('library-block-author-view.html', { + 'library_names': library_names, + 'max_count': self.max_count, + 'num_children': len(self.children), # pylint: disable=no-member + })) + # The following JS is used to make the "Update now" button work on the unit page and the container view: + fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/library_content_edit.js')) + fragment.initialize_js('LibraryContentAuthorView') return fragment def get_child_descriptors(self): diff --git a/common/lib/xmodule/xmodule/public/js/library_content_edit.js b/common/lib/xmodule/xmodule/public/js/library_content_edit.js index 9a84a21404..2db019fedd 100644 --- a/common/lib/xmodule/xmodule/public/js/library_content_edit.js +++ b/common/lib/xmodule/xmodule/public/js/library_content_edit.js @@ -1,6 +1,14 @@ -/* JavaScript for editing operations that can be done on LibraryContentXBlock */ +/* JavaScript for special editing operations that can be done on LibraryContentXBlock */ window.LibraryContentAuthorView = function (runtime, element) { - $(element).find('.library-update-btn').on('click', function(e) { + "use strict"; + var usage_id = $(element).data('usage-id'); + // The "Update Now" button is not a child of 'element', as it is in the validation message area + // But it is still inside this xblock's wrapper element, which we can easily find: + var $wrapper = $(element).parents('*[data-locator="'+usage_id+'"]'); + + // We can't bind to the button itself because in the bok choy test environment, + // it may not yet exist at this point in time... not sure why. + $wrapper.on('click', '.library-update-btn', function(e) { e.preventDefault(); // Update the XBlock with the latest matching content from the library: runtime.notify('save', { diff --git a/common/test/acceptance/pages/studio/container.py b/common/test/acceptance/pages/studio/container.py index 3833b2581c..58c4fe9235 100644 --- a/common/test/acceptance/pages/studio/container.py +++ b/common/test/acceptance/pages/studio/container.py @@ -336,6 +336,45 @@ class XBlockWrapper(PageObject): grand_locators = [grandkid.locator for grandkid in grandkids] return [descendant for descendant in descendants if descendant.locator not in grand_locators] + @property + def has_validation_message(self): + """ Is a validation warning/error/message shown? """ + return self.q(css=self._bounded_selector('.xblock-message.validation')).present + + def _validation_paragraph(self, css_class): + """ Helper method to return the

element of a validation warning """ + return self.q(css=self._bounded_selector('.xblock-message.validation p.{}'.format(css_class))) + + @property + def has_validation_warning(self): + """ Is a validation warning shown? """ + return self._validation_paragraph('warning').present + + @property + def has_validation_error(self): + """ Is a validation error shown? """ + return self._validation_paragraph('error').present + + @property + def has_validation_not_configured_warning(self): + """ Is a validation "not configured" message shown? """ + return self._validation_paragraph('not-configured').present + + @property + def validation_warning_text(self): + """ Get the text of the validation warning. """ + return self._validation_paragraph('warning').text[0] + + @property + def validation_error_text(self): + """ Get the text of the validation error. """ + return self._validation_paragraph('error').text[0] + + @property + def validation_not_configured_warning_text(self): + """ Get the text of the validation "not configured" message. """ + return self._validation_paragraph('not-configured').text[0] + @property def preview_selector(self): return self._bounded_selector('.xblock-student_view,.xblock-author_view') diff --git a/common/test/acceptance/pages/studio/library.py b/common/test/acceptance/pages/studio/library.py index 3151324cd0..ea7f2299f9 100644 --- a/common/test/acceptance/pages/studio/library.py +++ b/common/test/acceptance/pages/studio/library.py @@ -246,13 +246,6 @@ class StudioLibraryContainerXBlockWrapper(XBlockWrapper): """ return cls(xblock_wrapper.browser, xblock_wrapper.locator) - @property - def header_text(self): - """ - Gets library content text - """ - return self.get_body_paragraphs().first.text[0] - def get_body_paragraphs(self): """ Gets library content body paragraphs @@ -263,5 +256,7 @@ class StudioLibraryContainerXBlockWrapper(XBlockWrapper): """ Click "Update now..." button """ - refresh_button = self.q(css=self._bounded_selector(".library-update-btn")) + btn_selector = self._bounded_selector(".library-update-btn") + refresh_button = self.q(css=btn_selector) refresh_button.click() + self.wait_for_element_absence(btn_selector, 'Wait for the XBlock to reload') diff --git a/common/test/acceptance/tests/studio/test_studio_library_container.py b/common/test/acceptance/tests/studio/test_studio_library_container.py index 7bb712c779..ba66bdd7b1 100644 --- a/common/test/acceptance/tests/studio/test_studio_library_container.py +++ b/common/test/acceptance/tests/studio/test_studio_library_container.py @@ -94,40 +94,61 @@ class StudioLibraryContainerTest(ContainerBase, StudioLibraryTest): And I edit set library key to none Then I can see that library content block is misconfigured """ - expected_text = 'No library or filters configured. Press "Edit" to configure.' + expected_text = 'A library has not yet been selected.' + expected_action = 'Select a Library' library_container = self._get_library_xblock_wrapper(self.unit_page.xblocks[0]) - # precondition check - assert library is configured before we remove it - self.assertNotIn(expected_text, library_container.header_text) + # precondition check - the library block should be configured before we remove the library setting + self.assertFalse(library_container.has_validation_not_configured_warning) edit_modal = StudioLibraryContentXBlockEditModal(library_container.edit()) edit_modal.library_key = None - library_container.save_settings() - self.assertIn(expected_text, library_container.header_text) + self.assertTrue(library_container.has_validation_not_configured_warning) + self.assertIn(expected_text, library_container.validation_not_configured_warning_text) + self.assertIn(expected_action, library_container.validation_not_configured_warning_text) - @ddt.data( - 'library-v1:111+111', - 'library-v1:edX+L104', - ) - def test_set_missing_library_shows_correct_label(self, library_key): + def test_set_missing_library_shows_correct_label(self): """ Scenario: Given I have a library, a course and library content xblock in a course When I go to studio unit page for library content block And I edit set library key to non-existent library Then I can see that library content block is misconfigured """ + nonexistent_lib_key = 'library-v1:111+111' expected_text = "Library is invalid, corrupt, or has been deleted." library_container = self._get_library_xblock_wrapper(self.unit_page.xblocks[0]) # precondition check - assert library is configured before we remove it - self.assertNotIn(expected_text, library_container.header_text) + self.assertFalse(library_container.has_validation_error) edit_modal = StudioLibraryContentXBlockEditModal(library_container.edit()) - edit_modal.library_key = library_key + edit_modal.library_key = nonexistent_lib_key library_container.save_settings() - self.assertIn(expected_text, library_container.header_text) + self.assertTrue(library_container.has_validation_error) + self.assertIn(expected_text, library_container.validation_error_text) + + def test_out_of_date_message(self): + """ + Scenario: Given I have a library, a course and library content xblock in a course + When I go to studio unit page for library content block + Then I can see that library content block needs to be updated + When I click on the update link + Then I can see that the content no longer needs to be updated + """ + expected_text = "This component is out of date. The library has new content." + library_container = self._get_library_xblock_wrapper(self.unit_page.xblocks[0]) + + self.assertTrue(library_container.has_validation_warning) + self.assertIn(expected_text, library_container.validation_warning_text) + + library_container.refresh_children() + + self.unit_page.wait_for_page() # Wait for the page to reload + library_container = self._get_library_xblock_wrapper(self.unit_page.xblocks[0]) + + self.assertFalse(library_container.has_validation_message) diff --git a/lms/templates/library-block-author-preview-header.html b/lms/templates/library-block-author-preview-header.html index 4596281b67..ad76623deb 100644 --- a/lms/templates/library-block-author-preview-header.html +++ b/lms/templates/library-block-author-preview-header.html @@ -3,7 +3,7 @@

- ${_('Showing all matching content eligible to be added into {display_name}. Each student will be assigned {mode} {max_count} components from this list.').format(max_count=max_count, display_name=display_name, mode=mode)} + ${_('Showing all matching content eligible to be added into {display_name}. Each student will be assigned {max_count} component[s] drawn randomly from this list.').format(max_count=max_count, display_name=display_name)}

diff --git a/lms/templates/library-block-author-view.html b/lms/templates/library-block-author-view.html index ce1542cc58..46202aa2a9 100644 --- a/lms/templates/library-block-author-view.html +++ b/lms/templates/library-block-author-view.html @@ -2,12 +2,5 @@ from django.utils.translation import ugettext as _ %>
- % if library_ok: -

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

- % if update_status == UpdateStatus.NEEDED: -

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

- % elif update_status == UpdateStatus.UP_TO_DATE: -

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

- % endif - % endif +

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

From 904007a9e4df983cd53c41daba5facd3976c9b09 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Wed, 10 Dec 2014 20:59:49 -0800 Subject: [PATCH 15/99] Fix: don't need to reload the whole page to refresh_children from the container view --- .../xmodule/public/js/library_content_edit.js | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/common/lib/xmodule/xmodule/public/js/library_content_edit.js b/common/lib/xmodule/xmodule/public/js/library_content_edit.js index 2db019fedd..89011789b9 100644 --- a/common/lib/xmodule/xmodule/public/js/library_content_edit.js +++ b/common/lib/xmodule/xmodule/public/js/library_content_edit.js @@ -1,10 +1,11 @@ /* JavaScript for special editing operations that can be done on LibraryContentXBlock */ window.LibraryContentAuthorView = function (runtime, element) { "use strict"; - var usage_id = $(element).data('usage-id'); + var $element = $(element); + var usage_id = $element.data('usage-id'); // The "Update Now" button is not a child of 'element', as it is in the validation message area // But it is still inside this xblock's wrapper element, which we can easily find: - var $wrapper = $(element).parents('*[data-locator="'+usage_id+'"]'); + var $wrapper = $element.parents('*[data-locator="'+usage_id+'"]'); // We can't bind to the button itself because in the bok choy test environment, // it may not yet exist at this point in time... not sure why. @@ -21,12 +22,15 @@ window.LibraryContentAuthorView = function (runtime, element) { state: 'end', element: element }); - // runtime.refreshXBlock(element); - // The above does not work, because this XBlock's runtime has no reference - // to the page (XBlockContainerPage). Only the Vertical XBlock's runtime has - // a reference to the page, and we have no way of getting a reference to it. - // So instead we: - location.reload(); + if ($element.closest('.wrapper-xblock').is(':not(.level-page)')) { + // We are on a course unit page. The notify('save') should refresh this block, + // but that is only working on the container page view of this block. + // Why? On the unit page, this XBlock's runtime has no reference to the + // XBlockContainerPage - only the top-level XBlock (a vertical) runtime does. + // But unfortunately there is no way to get a reference to our parent block's + // JS 'runtime' object. So instead we must refresh the whole page: + location.reload(); + } }); }); }; From 76b6d33b806617c96a58d50ae49de70fa6d98ada Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Thu, 11 Dec 2014 00:40:08 -0800 Subject: [PATCH 16/99] Refresh children automatically when library setting is changed --- .../xmodule/xmodule/library_content_module.py | 24 ++++++++++++++++--- .../test/acceptance/pages/studio/container.py | 8 +++++++ .../test/acceptance/tests/lms/test_library.py | 1 - .../studio/test_studio_library_container.py | 22 ++++++++++++----- 4 files changed, 45 insertions(+), 10 deletions(-) diff --git a/common/lib/xmodule/xmodule/library_content_module.py b/common/lib/xmodule/xmodule/library_content_module.py index d9e28e93fd..f89f147330 100644 --- a/common/lib/xmodule/xmodule/library_content_module.py +++ b/common/lib/xmodule/xmodule/library_content_module.py @@ -363,7 +363,7 @@ class LibraryContentDescriptor(LibraryContentFields, MakoModuleDescriptor, XmlDe js_module_name = "VerticalDescriptor" @XBlock.handler - def refresh_children(self, request, suffix): # pylint: disable=unused-argument + def refresh_children(self, request, suffix, update_db=True): # pylint: disable=unused-argument """ Refresh children: This method is to be used when any of the libraries that this block @@ -375,8 +375,12 @@ class LibraryContentDescriptor(LibraryContentFields, MakoModuleDescriptor, XmlDe This method will update this block's 'source_libraries' field to store the version number of the libraries used, so we easily determine if this block is up to date or not. + + If update_db is True (default), this will explicitly persist the changes + to the modulestore by calling update_item() """ - user_id = self.runtime.service(self, 'user').user_id + user_service = self.runtime.service(self, 'user') + user_id = user_service.user_id if user_service else None # May be None when creating bok choy test fixtures root_children = [] store = self.system.modulestore @@ -395,6 +399,8 @@ class LibraryContentDescriptor(LibraryContentFields, MakoModuleDescriptor, XmlDe new_libraries = [] for library_key, old_version in self.source_libraries: # pylint: disable=unused-variable library = _get_library(self.system.modulestore, library_key) # pylint: disable=protected-access + if library is None: + raise ValueError("Required library not found.") def copy_children_recursively(from_block): """ @@ -434,9 +440,21 @@ class LibraryContentDescriptor(LibraryContentFields, MakoModuleDescriptor, XmlDe new_libraries.append(LibraryVersionReference(library_key, library.location.library_key.version_guid)) self.source_libraries = new_libraries self.children = root_children # pylint: disable=attribute-defined-outside-init - self.system.modulestore.update_item(self, user_id) + if update_db: + self.system.modulestore.update_item(self, user_id) return Response() + def editor_saved(self, user, old_metadata, old_content): + """ + If source_libraries has been edited, refresh_children automatically. + """ + old_source_libraries = LibraryList().from_json(old_metadata.get('source_libraries', [])) + if set(old_source_libraries) != set(self.source_libraries): + try: + self.refresh_children(None, None, update_db=False) # update_db=False since update_item() is about to be called anyways + except ValueError: + pass # The validation area will display an error message, no need to do anything now. + def has_dynamic_children(self): """ Inform the runtime that our children vary per-user. diff --git a/common/test/acceptance/pages/studio/container.py b/common/test/acceptance/pages/studio/container.py index 58c4fe9235..a5ee42e9c4 100644 --- a/common/test/acceptance/pages/studio/container.py +++ b/common/test/acceptance/pages/studio/container.py @@ -312,6 +312,14 @@ class XBlockWrapper(PageObject): """ return self.q(css=self._bounded_selector('.xblock-student_view'))[0].text + @property + def author_content(self): + """ + Returns the text content of the xblock as displayed on the container page. + (For blocks which implement a distinct author_view). + """ + return self.q(css=self._bounded_selector('.xblock-author_view'))[0].text + @property def name(self): titles = self.q(css=self._bounded_selector(self.NAME_SELECTOR)).text diff --git a/common/test/acceptance/tests/lms/test_library.py b/common/test/acceptance/tests/lms/test_library.py index 78d699faa6..f83e6b94e9 100644 --- a/common/test/acceptance/tests/lms/test_library.py +++ b/common/test/acceptance/tests/lms/test_library.py @@ -92,7 +92,6 @@ class LibraryContentTest(UniqueCourseTest): modal = StudioLibraryContentXBlockEditModal(library_container_block.edit()) modal.count = count library_container_block.save_settings() - library_container_block.refresh_children() self._go_to_unit_page(change_login=False) unit_page.wait_for_page() unit_page.publish_action.click() diff --git a/common/test/acceptance/tests/studio/test_studio_library_container.py b/common/test/acceptance/tests/studio/test_studio_library_container.py index ba66bdd7b1..6e8fddeb8e 100644 --- a/common/test/acceptance/tests/studio/test_studio_library_container.py +++ b/common/test/acceptance/tests/studio/test_studio_library_container.py @@ -136,19 +136,29 @@ class StudioLibraryContainerTest(ContainerBase, StudioLibraryTest): """ Scenario: Given I have a library, a course and library content xblock in a course When I go to studio unit page for library content block + Then I update the library being used + Then I refresh the page Then I can see that library content block needs to be updated When I click on the update link Then I can see that the content no longer needs to be updated """ expected_text = "This component is out of date. The library has new content." - library_container = self._get_library_xblock_wrapper(self.unit_page.xblocks[0]) + library_block = self._get_library_xblock_wrapper(self.unit_page.xblocks[0]) - self.assertTrue(library_container.has_validation_warning) - self.assertIn(expected_text, library_container.validation_warning_text) + self.assertFalse(library_block.has_validation_warning) + self.assertIn("3 matching components", library_block.author_content) - library_container.refresh_children() + self.library_fixture.create_xblock(self.library_fixture.library_location, XBlockFixtureDesc("html", "Html4")) + + self.unit_page.visit() # Reload the page + + self.assertTrue(library_block.has_validation_warning) + self.assertIn(expected_text, library_block.validation_warning_text) + + library_block.refresh_children() self.unit_page.wait_for_page() # Wait for the page to reload - library_container = self._get_library_xblock_wrapper(self.unit_page.xblocks[0]) + library_block = self._get_library_xblock_wrapper(self.unit_page.xblocks[0]) - self.assertFalse(library_container.has_validation_message) + self.assertFalse(library_block.has_validation_message) + self.assertIn("4 matching components", library_block.author_content) From eb4b1d57c33e6eb75fa09e81125200b64c73f6e9 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Thu, 11 Dec 2014 15:10:38 -0800 Subject: [PATCH 17/99] Fix greedy intrusion of split_test documentation --- cms/templates/container.html | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/cms/templates/container.html b/cms/templates/container.html index 2336a7c097..d2c7ce3167 100644 --- a/cms/templates/container.html +++ b/cms/templates/container.html @@ -103,7 +103,7 @@ from django.utils.translation import ugettext as _
diff --git a/cms/urls.py b/cms/urls.py index 293672afbd..70c1b729c9 100644 --- a/cms/urls.py +++ b/cms/urls.py @@ -121,6 +121,8 @@ if settings.FEATURES.get('ENABLE_CONTENT_LIBRARIES'): urlpatterns += ( url(r'^library/{}?$'.format(LIBRARY_KEY_PATTERN), 'contentstore.views.library_handler', name='library_handler'), + url(r'^library/{}/team/$'.format(LIBRARY_KEY_PATTERN), + 'contentstore.views.manage_library_users', name='manage_library_users'), ) if settings.FEATURES.get('ENABLE_EXPORT_GIT'): From d38e69c69ac5fde0776dc01572081caabac6e797 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Tue, 16 Dec 2014 21:41:04 -0800 Subject: [PATCH 30/99] Acceptance & Jasmine tests for library permissions editor --- cms/static/coffee/spec/main.coffee | 1 + .../js/spec/views/pages/library_users_spec.js | 78 ++++++++ .../js/mock/mock-manage-users-lib.underscore | 146 ++++++++++++++ common/djangoapps/student/views.py | 10 +- .../test/acceptance/pages/studio/auto_auth.py | 7 +- common/test/acceptance/pages/studio/users.py | 189 ++++++++++++++++++ .../tests/studio/test_studio_library.py | 137 +++++++++++++ 7 files changed, 563 insertions(+), 5 deletions(-) create mode 100644 cms/static/js/spec/views/pages/library_users_spec.js create mode 100644 cms/templates/js/mock/mock-manage-users-lib.underscore create mode 100644 common/test/acceptance/pages/studio/users.py diff --git a/cms/static/coffee/spec/main.coffee b/cms/static/coffee/spec/main.coffee index b83442a9a6..33a8162031 100644 --- a/cms/static/coffee/spec/main.coffee +++ b/cms/static/coffee/spec/main.coffee @@ -256,6 +256,7 @@ define([ "js/spec/views/pages/course_outline_spec", "js/spec/views/pages/course_rerun_spec", "js/spec/views/pages/index_spec", + "js/spec/views/pages/library_users_spec", "js/spec/views/modals/base_modal_spec", "js/spec/views/modals/edit_xblock_spec", diff --git a/cms/static/js/spec/views/pages/library_users_spec.js b/cms/static/js/spec/views/pages/library_users_spec.js new file mode 100644 index 0000000000..376d95fb23 --- /dev/null +++ b/cms/static/js/spec/views/pages/library_users_spec.js @@ -0,0 +1,78 @@ +define([ + "jquery", "js/common_helpers/ajax_helpers", "js/spec_helpers/view_helpers", + "js/factories/manage_users_lib", "js/views/utils/view_utils" +], +function ($, AjaxHelpers, ViewHelpers, ManageUsersFactory, ViewUtils) { + "use strict"; + describe("Library Instructor Access Page", function () { + var mockHTML = readFixtures('mock/mock-manage-users-lib.underscore'); + + beforeEach(function () { + ViewHelpers.installMockAnalytics(); + appendSetFixtures(mockHTML); + ManageUsersFactory( + "Mock Library", + ["honor@example.com", "audit@example.com", "staff@example.com"], + "dummy_change_role_url" + ); + }); + + afterEach(function () { + ViewHelpers.removeMockAnalytics(); + }); + + it("can give a user permission to use the library", function () { + var requests = AjaxHelpers.requests(this); + var reloadSpy = spyOn(ViewUtils, 'reload'); + $('.create-user-button').click(); + expect($('.wrapper-create-user')).toHaveClass('is-shown'); + $('.user-email-input').val('other@example.com'); + $('.form-create.create-user .action-primary').click(); + AjaxHelpers.expectJsonRequest(requests, 'POST', 'dummy_change_role_url', {role: 'library_user'}); + AjaxHelpers.respondWithJson(requests, {'result': 'ok'}); + expect(reloadSpy).toHaveBeenCalled(); + }); + + it("can cancel adding a user to the library", function () { + $('.create-user-button').click(); + $('.form-create.create-user .action-secondary').click(); + expect($('.wrapper-create-user')).not.toHaveClass('is-shown'); + }); + + it("displays an error when the required field is blank", function () { + var requests = AjaxHelpers.requests(this); + $('.create-user-button').click(); + $('.user-email-input').val(''); + var errorPromptSelector = '.wrapper-prompt.is-shown .prompt.error'; + expect($(errorPromptSelector).length).toEqual(0); + $('.form-create.create-user .action-primary').click(); + expect($(errorPromptSelector).length).toEqual(1); + expect($(errorPromptSelector)).toContainText('You must enter a valid email address'); + expect(requests.length).toEqual(0); + }); + + it("displays an error when the user has already been added", function () { + var requests = AjaxHelpers.requests(this); + $('.create-user-button').click(); + $('.user-email-input').val('honor@example.com'); + var warningPromptSelector = '.wrapper-prompt.is-shown .prompt.warning'; + expect($(warningPromptSelector).length).toEqual(0); + $('.form-create.create-user .action-primary').click(); + expect($(warningPromptSelector).length).toEqual(1); + expect($(warningPromptSelector)).toContainText('Already a library team member'); + expect(requests.length).toEqual(0); + }); + + + it("can remove a user's permission to access the library", function () { + var requests = AjaxHelpers.requests(this); + var reloadSpy = spyOn(ViewUtils, 'reload'); + $('.user-item[data-email="honor@example.com"] .action-delete .delete').click(); + expect($('.wrapper-prompt.is-shown .prompt.warning').length).toEqual(1); + $('.wrapper-prompt.is-shown .action-primary').click(); + AjaxHelpers.expectJsonRequest(requests, 'DELETE', 'dummy_change_role_url', {role: null}); + AjaxHelpers.respondWithJson(requests, {'result': 'ok'}); + expect(reloadSpy).toHaveBeenCalled(); + }); + }); +}); diff --git a/cms/templates/js/mock/mock-manage-users-lib.underscore b/cms/templates/js/mock/mock-manage-users-lib.underscore new file mode 100644 index 0000000000..d5b9ebf97b --- /dev/null +++ b/cms/templates/js/mock/mock-manage-users-lib.underscore @@ -0,0 +1,146 @@ +
+
+

+ Settings + > Instructor Access +

+ + +
+
+ +
+
+
+
+
+
+

Grant Instructor Access to This Library

+ +
+ New Instructor Information + +
    +
  1. + + + Please provide the email address of the instructor you'd like to add +
  2. +
+
+
+ +
+ + +
+
+
+ +
    + +
  1. + + + + Current Role: + + Staff + + + + + + + + +
  2. + +
  3. + + + + Current Role: + + Admin + + + + + + + + +
  4. + +
  5. + + + + Current Role: + + User + + + + + + + + +
  6. +
+ +
+
+
diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index f5f75485c8..9fc8cd6987 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -1746,6 +1746,7 @@ def auto_auth(request): * `staff`: Set to "true" to make the user global staff. * `course_id`: Enroll the student in the course with `course_id` * `roles`: Comma-separated list of roles to grant the student in the course with `course_id` + * `no_login`: Define this to create the user but not login If username, email, or password are not provided, use randomly generated credentials. @@ -1765,6 +1766,7 @@ def auto_auth(request): if course_id: course_key = CourseLocator.from_string(course_id) role_names = [v.strip() for v in request.GET.get('roles', '').split(',') if v.strip()] + login_when_done = 'no_login' not in request.GET # Get or create the user object post_data = { @@ -1808,14 +1810,16 @@ def auto_auth(request): user.roles.add(role) # Log in as the user - user = authenticate(username=username, password=password) - login(request, user) + if login_when_done: + user = authenticate(username=username, password=password) + login(request, user) create_comments_service_user(user) # Provide the user with a valid CSRF token # then return a 200 response - success_msg = u"Logged in user {0} ({1}) with password {2} and user_id {3}".format( + success_msg = u"{} user {} ({}) with password {} and user_id {}".format( + u"Logged in" if login_when_done else "Created", username, email, password, user.id ) response = HttpResponse(success_msg) diff --git a/common/test/acceptance/pages/studio/auto_auth.py b/common/test/acceptance/pages/studio/auto_auth.py index e8beeaca5b..2e2cffd677 100644 --- a/common/test/acceptance/pages/studio/auto_auth.py +++ b/common/test/acceptance/pages/studio/auto_auth.py @@ -15,7 +15,7 @@ class AutoAuthPage(PageObject): this url will create a user and log them in. """ - def __init__(self, browser, username=None, email=None, password=None, staff=None, course_id=None, roles=None): + def __init__(self, browser, username=None, email=None, password=None, staff=None, course_id=None, roles=None, no_login=None): """ Auto-auth is an end-point for HTTP GET requests. By default, it will create accounts with random user credentials, @@ -51,6 +51,9 @@ class AutoAuthPage(PageObject): if roles is not None: self._params['roles'] = roles + if no_login: + self._params['no_login'] = True + @property def url(self): """ @@ -66,7 +69,7 @@ class AutoAuthPage(PageObject): def is_browser_on_page(self): message = self.q(css='BODY').text[0] - match = re.search(r'Logged in user ([^$]+) with password ([^$]+) and user_id ([^$]+)$', message) + match = re.search(r'(Logged in|Created) user ([^$]+) with password ([^$]+) and user_id ([^$]+)$', message) return True if match else False def get_user_id(self): diff --git a/common/test/acceptance/pages/studio/users.py b/common/test/acceptance/pages/studio/users.py new file mode 100644 index 0000000000..c1a2427d56 --- /dev/null +++ b/common/test/acceptance/pages/studio/users.py @@ -0,0 +1,189 @@ +""" +Page classes to test either the Course Team page or the Library Team page. +""" +from bok_choy.promise import EmptyPromise +from bok_choy.page_object import PageObject +from ...tests.helpers import disable_animations +from . import BASE_URL + + +def wait_for_ajax_or_reload(browser): + """ + Wait for all ajax requests to finish, OR for the page to reload. + Normal wait_for_ajax() chokes on occasion if the pages reloads, + giving "WebDriverException: Message: u'jQuery is not defined'" + """ + def _is_ajax_finished(): + """ Wait for jQuery to finish all AJAX calls, if it is present. """ + return browser.execute_script("return typeof(jQuery) == 'undefined' || jQuery.active == 0") + + EmptyPromise(_is_ajax_finished, "Finished waiting for ajax requests.").fulfill() + + +class UsersPage(PageObject): + """ + Base class for either the Course Team page or the Library Team page + """ + + def __init__(self, browser, locator): + super(UsersPage, self).__init__(browser) + self.locator = locator + + @property + def url(self): + """ + URL to this page - override in subclass + """ + raise NotImplementedError + + def is_browser_on_page(self): + """ + Returns True iff the browser has loaded the page. + """ + return self.q(css='body.view-team').present + + @property + def users(self): + """ + Return a list of users listed on this page. + """ + return self.q(css='.user-list .user-item').map(lambda el: UserWrapper(self.browser, el.get_attribute('data-email'))).results + + @property + def has_add_button(self): + """ + Is the "New Team Member" button present? + """ + return self.q(css='.create-user-button').present + + def click_add_button(self): + """ + Click on the "New Team Member" button + """ + self.q(css='.create-user-button').click() + + @property + def new_user_form_visible(self): + """ Is the new user form visible? """ + return self.q(css='.form-create.create-user .user-email-input').visible + + def set_new_user_email(self, email): + """ Set the value of the "New User Email Address" field. """ + self.q(css='.form-create.create-user .user-email-input').fill(email) + + def click_submit_new_user_form(self): + """ Submit the "New User" form """ + self.q(css='.form-create.create-user .action-primary').click() + wait_for_ajax_or_reload(self.browser) + + +class LibraryUsersPage(UsersPage): + """ + Library Team page in Studio + """ + + @property + def url(self): + """ + URL to the "User Access" page for the given library. + """ + return "{}/library/{}/team/".format(BASE_URL, unicode(self.locator)) + + +class UserWrapper(PageObject): + """ + A PageObject representing a wrapper around a user listed on the course/library team page. + """ + url = None + COMPONENT_BUTTONS = { + 'basic_tab': '.editor-tabs li.inner_tab_wrap:nth-child(1) > a', + 'advanced_tab': '.editor-tabs li.inner_tab_wrap:nth-child(2) > a', + 'save_settings': '.action-save', + } + + def __init__(self, browser, email): + super(UserWrapper, self).__init__(browser) + self.email = email + self.selector = '.user-list .user-item[data-email="{}"]'.format(self.email) + + def is_browser_on_page(self): + """ + Sanity check that our wrapper element is on the page. + """ + return self.q(css=self.selector).present + + def _bounded_selector(self, selector): + """ + Return `selector`, but limited to this particular user entry's context + """ + return '{} {}'.format(self.selector, selector) + + @property + def name(self): + """ Get this user's username, as displayed. """ + return self.q(css=self._bounded_selector('.user-username')).text[0] + + @property + def role_label(self): + """ Get this user's role, as displayed. """ + return self.q(css=self._bounded_selector('.flag-role .value')).text[0] + + @property + def is_current_user(self): + """ Does the UI indicate that this is the current user? """ + return self.q(css=self._bounded_selector('.flag-role .msg-you')).present + + @property + def can_promote(self): + """ Can this user be promoted to a more powerful role? """ + return self.q(css=self._bounded_selector('.add-admin-role')).present + + @property + def promote_button_text(self): + """ What does the promote user button say? """ + return self.q(css=self._bounded_selector('.add-admin-role')).text[0] + + def click_promote(self): + """ Click on the button to promote this user to the more powerful role """ + self.q(css=self._bounded_selector('.add-admin-role')).click() + wait_for_ajax_or_reload(self.browser) + + @property + def can_demote(self): + """ Can this user be demoted to a less powerful role? """ + return self.q(css=self._bounded_selector('.remove-admin-role')).present + + @property + def demote_button_text(self): + """ What does the demote user button say? """ + return self.q(css=self._bounded_selector('.remove-admin-role')).text[0] + + def click_demote(self): + """ Click on the button to demote this user to the less powerful role """ + self.q(css=self._bounded_selector('.remove-admin-role')).click() + wait_for_ajax_or_reload(self.browser) + + @property + def can_delete(self): + """ Can this user be deleted? """ + return self.q(css=self._bounded_selector('.action-delete:not(.is-disabled) .remove-user')).present + + def click_delete(self): + """ Click the button to delete this user. """ + disable_animations(self) + self.q(css=self._bounded_selector('.remove-user')).click() + # We can't use confirm_prompt because its wait_for_ajax is flaky when the page is expected to reload. + self.wait_for_element_visibility('.prompt', 'Prompt is visible') + self.wait_for_element_visibility('.prompt .action-primary', 'Confirmation button is visible') + self.q(css='.prompt .action-primary').click() + wait_for_ajax_or_reload(self.browser) + + @property + def has_no_change_warning(self): + """ Does this have a warning in place of the promote/demote buttons? """ + return self.q(css=self._bounded_selector('.notoggleforyou')).present + + @property + def no_change_warning_text(self): + """ Text of the warning seen in place of the promote/demote buttons. """ + return self.q(css=self._bounded_selector('.notoggleforyou')).text[0] diff --git a/common/test/acceptance/tests/studio/test_studio_library.py b/common/test/acceptance/tests/studio/test_studio_library.py index b0d6cffb1a..afd5e8eb36 100644 --- a/common/test/acceptance/tests/studio/test_studio_library.py +++ b/common/test/acceptance/tests/studio/test_studio_library.py @@ -5,8 +5,10 @@ from ddt import ddt, data from .base_studio_test import StudioLibraryTest from ...fixtures.course import XBlockFixtureDesc +from ...pages.studio.auto_auth import AutoAuthPage from ...pages.studio.utils import add_component from ...pages.studio.library import LibraryPage +from ...pages.studio.users import LibraryUsersPage @ddt @@ -306,3 +308,138 @@ class LibraryNavigationTest(StudioLibraryTest): self.assertEqual(self.lib_page.xblocks[0].name, '1') self.assertEqual(self.lib_page.xblocks[-1].name, '11') self.assertEqual(self.lib_page.get_page_number(), '1') + + +class LibraryUsersPageTest(StudioLibraryTest): + """ + Test the functionality of the library "Instructor Access" page. + """ + def setUp(self): + super(LibraryUsersPageTest, self).setUp() + + # Create a second user for use in these tests: + AutoAuthPage(self.browser, username="second", email="second@example.com", no_login=True).visit() + + self.page = LibraryUsersPage(self.browser, self.library_key) + self.page.visit() + + def _expect_refresh(self): + """ + Wait for the page to reload. + """ + self.page = LibraryUsersPage(self.browser, self.library_key).wait_for_page() + + def test_user_management(self): + """ + Scenario: Ensure that we can edit the permissions of users. + Given I have a library in Studio where I am the only admin + assigned (which is the default for a newly-created library) + And I navigate to Library "Instructor Access" Page in Studio + Then there should be one user listed (myself), and I must + not be able to remove myself or my instructor privilege. + + When I click Add Intructor + Then I see a form to complete + When I complete the form and submit it + Then I can see the new user is listed as a "User" of the library + + When I click to Add Staff permissions to the new user + Then I can see the new user has staff permissions and that I am now + able to promote them to an Admin or remove their staff permissions. + + When I click to Add Admin permissions to the new user + Then I can see the new user has admin permissions and that I can now + remove Admin permissions from either user. + """ + def check_is_only_admin(user): + """ + Ensure user is an admin user and cannot be removed. + (There must always be at least one admin user.) + """ + self.assertIn("admin", user.role_label.lower()) + self.assertFalse(user.can_promote) + self.assertFalse(user.can_demote) + self.assertFalse(user.can_delete) + self.assertTrue(user.has_no_change_warning) + self.assertIn("Promote another member to Admin to remove admin rights", user.no_change_warning_text) + + self.assertEqual(len(self.page.users), 1) + user = self.page.users[0] + self.assertTrue(user.is_current_user) + check_is_only_admin(user) + + # Add a new user: + + self.assertTrue(self.page.has_add_button) + self.assertFalse(self.page.new_user_form_visible) + self.page.click_add_button() + self.assertTrue(self.page.new_user_form_visible) + self.page.set_new_user_email('second@example.com') + self.page.click_submit_new_user_form() + + # Check the new user's listing: + + def get_two_users(): + """ + Expect two users to be listed, one being me, and another user. + Returns me, them + """ + users = self.page.users + self.assertEqual(len(users), 2) + self.assertEqual(len([u for u in users if u.is_current_user]), 1) + if users[0].is_current_user: + return users[0], users[1] + else: + return users[1], users[0] + + self._expect_refresh() + user_me, them = get_two_users() + check_is_only_admin(user_me) + + self.assertIn("user", them.role_label.lower()) + self.assertTrue(them.can_promote) + self.assertIn("Add Staff Access", them.promote_button_text) + self.assertFalse(them.can_demote) + self.assertTrue(them.can_delete) + self.assertFalse(them.has_no_change_warning) + + # Add Staff permissions to the new user: + + them.click_promote() + self._expect_refresh() + user_me, them = get_two_users() + check_is_only_admin(user_me) + + self.assertIn("staff", them.role_label.lower()) + self.assertTrue(them.can_promote) + self.assertIn("Add Admin Access", them.promote_button_text) + self.assertTrue(them.can_demote) + self.assertIn("Remove Staff Access", them.demote_button_text) + self.assertTrue(them.can_delete) + self.assertFalse(them.has_no_change_warning) + + # Add Admin permissions to the new user: + + them.click_promote() + self._expect_refresh() + user_me, them = get_two_users() + self.assertIn("admin", user_me.role_label.lower()) + self.assertFalse(user_me.can_promote) + self.assertTrue(user_me.can_demote) + self.assertTrue(user_me.can_delete) + self.assertFalse(user_me.has_no_change_warning) + + self.assertIn("admin", them.role_label.lower()) + self.assertFalse(them.can_promote) + self.assertTrue(them.can_demote) + self.assertIn("Remove Admin Access", them.demote_button_text) + self.assertTrue(them.can_delete) + self.assertFalse(them.has_no_change_warning) + + # Delete the new user: + + them.click_delete() + self._expect_refresh() + self.assertEqual(len(self.page.users), 1) + user = self.page.users[0] + self.assertTrue(user.is_current_user) From 42ee0571e75548d990be6dcaa7a37aec5775e609 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Tue, 30 Dec 2014 21:15:42 -0800 Subject: [PATCH 31/99] Read-only users get "Details" instead of "Edit" button, remove "Save" option --- cms/djangoapps/contentstore/views/item.py | 2 + cms/djangoapps/contentstore/views/preview.py | 1 + .../js/spec/views/modals/edit_xblock_spec.js | 2 +- cms/static/js/views/modals/edit_xblock.js | 9 ++-- cms/static/js/views/paged_container.js | 1 - cms/static/js/views/pages/container.js | 8 +-- cms/templates/studio_xblock_wrapper.html | 50 +++++++++++-------- 7 files changed, 39 insertions(+), 34 deletions(-) diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py index a98a5cf193..d535cf11fb 100644 --- a/cms/djangoapps/contentstore/views/item.py +++ b/cms/djangoapps/contentstore/views/item.py @@ -234,6 +234,7 @@ def xblock_view_handler(request, usage_key_string, view_name): elif view_name in (PREVIEW_VIEWS + container_views): is_pages_view = view_name == STUDENT_VIEW # Only the "Pages" view uses student view in Studio + can_edit = has_studio_write_access(request.user, usage_key.course_key) # Determine the items to be shown as reorderable. Note that the view # 'reorderable_container_child_preview' is only rendered for xblocks that @@ -266,6 +267,7 @@ def xblock_view_handler(request, usage_key_string, view_name): context = { 'is_pages_view': is_pages_view, # This setting disables the recursive wrapping of xblocks 'is_unit_page': is_unit(xblock), + 'can_edit': can_edit, 'root_xblock': xblock if (view_name == 'container_preview') else None, 'reorderable_items': reorderable_items, 'paging': paging, diff --git a/cms/djangoapps/contentstore/views/preview.py b/cms/djangoapps/contentstore/views/preview.py index 523d8d1846..9f9aca8da1 100644 --- a/cms/djangoapps/contentstore/views/preview.py +++ b/cms/djangoapps/contentstore/views/preview.py @@ -226,6 +226,7 @@ def _studio_wrap_xblock(xblock, view, frag, context, display_name_only=False): 'content': frag.content, 'is_root': is_root, 'is_reorderable': is_reorderable, + 'can_edit': context.get('can_edit', True), } html = render_to_string('studio_xblock_wrapper.html', template_context) frag = wrap_fragment(frag, html) diff --git a/cms/static/js/spec/views/modals/edit_xblock_spec.js b/cms/static/js/spec/views/modals/edit_xblock_spec.js index 99de1404c8..b159998e7f 100644 --- a/cms/static/js/spec/views/modals/edit_xblock_spec.js +++ b/cms/static/js/spec/views/modals/edit_xblock_spec.js @@ -49,7 +49,7 @@ define(["jquery", "underscore", "js/common_helpers/ajax_helpers", "js/spec_helpe var requests = AjaxHelpers.requests(this); modal = showModal(requests, mockXBlockEditorHtml); expect(modal.$('.action-save')).not.toBeVisible(); - expect(modal.$('.action-cancel').text()).toBe('OK'); + expect(modal.$('.action-cancel').text()).toBe('Close'); }); it('shows the correct title', function() { diff --git a/cms/static/js/views/modals/edit_xblock.js b/cms/static/js/views/modals/edit_xblock.js index 67e9de6f88..ef2de588a0 100644 --- a/cms/static/js/views/modals/edit_xblock.js +++ b/cms/static/js/views/modals/edit_xblock.js @@ -65,7 +65,8 @@ define(["jquery", "underscore", "gettext", "js/views/modals/base_modal", "js/vie onDisplayXBlock: function() { var editorView = this.editorView, - title = this.getTitle(); + title = this.getTitle(), + readOnlyView = (this.editOptions && this.editOptions.readOnlyView) || !editorView.xblock.save; // Notify the runtime that the modal has been shown editorView.notifyRuntime('modal-shown', this); @@ -88,7 +89,7 @@ define(["jquery", "underscore", "gettext", "js/views/modals/base_modal", "js/vie // If the xblock is not using custom buttons then choose which buttons to show if (!editorView.hasCustomButtons()) { // If the xblock does not support save then disable the save button - if (!editorView.xblock.save) { + if (readOnlyView) { this.disableSave(); } this.getActionBar().show(); @@ -101,8 +102,8 @@ define(["jquery", "underscore", "gettext", "js/views/modals/base_modal", "js/vie disableSave: function() { var saveButton = this.getActionButton('save'), cancelButton = this.getActionButton('cancel'); - saveButton.hide(); - cancelButton.text(gettext('OK')); + saveButton.parent().hide(); + cancelButton.text(gettext('Close')); cancelButton.addClass('action-primary'); }, diff --git a/cms/static/js/views/paged_container.js b/cms/static/js/views/paged_container.js index 4b300843b5..c2b97f3b54 100644 --- a/cms/static/js/views/paged_container.js +++ b/cms/static/js/views/paged_container.js @@ -53,7 +53,6 @@ define(["jquery", "underscore", "js/views/container", "js/utils/module", "gettex self.handleXBlockFragment(fragment, options); self.processPaging({ requested_page: options.page_number }); self.page.renderAddXBlockComponents(); - self.page.updateBlockActions(); } }); }, diff --git a/cms/static/js/views/pages/container.js b/cms/static/js/views/pages/container.js index 018b03fe8f..cea6c43120 100644 --- a/cms/static/js/views/pages/container.js +++ b/cms/static/js/views/pages/container.js @@ -140,7 +140,6 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views onXBlockRefresh: function(xblockView, block_added) { this.xblockView.refresh(block_added); - this.updateBlockActions(); // Update publish and last modified information from the server. this.model.fetch(); }, @@ -161,12 +160,6 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views } }, - updateBlockActions: function() { - if (!this.options.canEdit) { - this.xblockView.$el.find('.action-duplicate, .action-delete, .action-drag').remove(); - } - }, - editXBlock: function(event) { var xblockElement = this.findXBlockElement(event.target), self = this, @@ -174,6 +167,7 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views event.preventDefault(); modal.edit(xblockElement, this.model, { + readOnlyView: !this.options.canEdit, refresh: function() { self.refreshXBlock(xblockElement, false); } diff --git a/cms/templates/studio_xblock_wrapper.html b/cms/templates/studio_xblock_wrapper.html index 914f8ec6e0..44ec7298fa 100644 --- a/cms/templates/studio_xblock_wrapper.html +++ b/cms/templates/studio_xblock_wrapper.html @@ -55,30 +55,38 @@ messages = json.dumps(xblock.validate().to_json())
From 95c8125609749e7029a1f09f064c9de579154a29 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Wed, 31 Dec 2014 12:46:02 -0800 Subject: [PATCH 32/99] Accessibility fix per cptvitamin --- cms/templates/js/system-feedback.underscore | 1 + 1 file changed, 1 insertion(+) diff --git a/cms/templates/js/system-feedback.underscore b/cms/templates/js/system-feedback.underscore index 57f768ce3f..0d9a692879 100644 --- a/cms/templates/js/system-feedback.underscore +++ b/cms/templates/js/system-feedback.underscore @@ -4,6 +4,7 @@ id="<%= type %>-<%= intent %>" aria-hidden="<% if(obj.shown) { %>false<% } else { %>true<% } %>" aria-labelledby="<%= type %>-<%= intent %>-title" + tabindex="-1" <% if (obj.message) { %>aria-describedby="<%= type %>-<%= intent %>-description" <% } %> <% if (obj.actions) { %>role="dialog"<% } %> > From 7303966c13a81f0e33f2f32f6099185c35d86ebe Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Wed, 31 Dec 2014 15:55:18 -0800 Subject: [PATCH 33/99] Check permissions when updating LibraryContent blocks --- .../contentstore/tests/test_libraries.py | 38 ++++++++++++++++++- cms/djangoapps/contentstore/views/preview.py | 24 ++++++++++++ .../xmodule/xmodule/library_content_module.py | 4 +- common/lib/xmodule/xmodule/library_tools.py | 7 +++- .../studio/test_studio_library_container.py | 13 ++++++- 5 files changed, 80 insertions(+), 6 deletions(-) diff --git a/cms/djangoapps/contentstore/tests/test_libraries.py b/cms/djangoapps/contentstore/tests/test_libraries.py index 5828c223f3..c6fdc00301 100644 --- a/cms/djangoapps/contentstore/tests/test_libraries.py +++ b/cms/djangoapps/contentstore/tests/test_libraries.py @@ -67,7 +67,7 @@ class LibraryTestCase(ModuleStoreTestCase): **(other_settings or {}) ) - def _refresh_children(self, lib_content_block): + def _refresh_children(self, lib_content_block, status_code_expected=200): """ Helper method: Uses the REST API to call the 'refresh_children' handler of a LibraryContent block @@ -76,7 +76,7 @@ class LibraryTestCase(ModuleStoreTestCase): 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) + self.assertEqual(response.status_code, status_code_expected) return modulestore().get_item(lib_content_block.location) def _bind_module(self, descriptor, user=None): @@ -556,3 +556,37 @@ class TestLibraryAccess(LibraryTestCase): self.assertIn(response.status_code, (200, 403)) # 400 would be ambiguous duplicate_action_allowed = (response.status_code == 200) self.assertEqual(duplicate_action_allowed, expected_result) + + @ddt.data( + (LibraryUserRole, CourseStaffRole, True), + (CourseStaffRole, CourseStaffRole, True), + (None, CourseStaffRole, False), + (LibraryUserRole, None, False), + ) + @ddt.unpack + def test_refresh_library_content_permissions(self, library_role, course_role, expected_result): + """ + Test that the LibraryContent block's 'refresh_children' handler will correctly + handle permissions and allow/refuse when updating its content with the latest + version of a library. We try updating from a library with (write, read, or no) + access to a course with (write or no) access. + """ + # As staff user, add a block to self.library: + ItemFactory.create(category="html", parent_location=self.library.location, user_id=self.user.id, publish_item=False) + # And create a course: + with modulestore().default_store(ModuleStoreEnum.Type.split): + course = CourseFactory.create() + + self._login_as_non_staff_user() + + # Assign roles: + if library_role: + library_role(self.lib_key).add_users(self.non_staff_user) + if course_role: + course_role(course.location.course_key).add_users(self.non_staff_user) + + # Try updating our library content block: + lc_block = self._add_library_content_block(course, self.lib_key) + self._bind_module(lc_block, user=self.non_staff_user) # We must use the CMS's module system in order to get permissions checks. + lc_block = self._refresh_children(lc_block, status_code_expected=200 if expected_result else 403) + self.assertEqual(len(lc_block.children), 1 if expected_result else 0) diff --git a/cms/djangoapps/contentstore/views/preview.py b/cms/djangoapps/contentstore/views/preview.py index 9f9aca8da1..63e1a6b792 100644 --- a/cms/djangoapps/contentstore/views/preview.py +++ b/cms/djangoapps/contentstore/views/preview.py @@ -22,6 +22,7 @@ from xblock.runtime import KvsFieldData from xblock.django.request import webob_to_django_response, django_to_webob_request from xblock.exceptions import NoSuchHandlerError from xblock.fragment import Fragment +from student.auth import has_studio_read_access, has_studio_write_access from lms.djangoapps.lms_xblock.field_data import LmsFieldData from cms.lib.xblock.field_data import CmsFieldData @@ -124,6 +125,28 @@ class StudioUserService(object): return self._request.user.id +class StudioPermissionsService(object): + """ + Service that can provide information about a user's permissions. + + Deprecated. To be replaced by a more general authorization service. + + Only used by LibraryContentDescriptor (and library_tools.py). + """ + + def __init__(self, request): + super(StudioPermissionsService, self).__init__() + self._request = request + + def can_read(self, course_key): + """ Does the user have read access to the given course/library? """ + return has_studio_read_access(self._request.user, course_key) + + def can_write(self, course_key): + """ Does the user have read access to the given course/library? """ + return has_studio_write_access(self._request.user, course_key) + + def _preview_module_system(request, descriptor, field_data): """ Returns a ModuleSystem for the specified descriptor that is specialized for @@ -153,6 +176,7 @@ def _preview_module_system(request, descriptor, field_data): ] descriptor.runtime._services['user'] = StudioUserService(request) # pylint: disable=protected-access + descriptor.runtime._services['studio_user_permissions'] = StudioPermissionsService(request) # pylint: disable=protected-access return PreviewModuleSystem( static_url=settings.STATIC_URL, diff --git a/common/lib/xmodule/xmodule/library_content_module.py b/common/lib/xmodule/xmodule/library_content_module.py index 8571540f75..5004a5d51a 100644 --- a/common/lib/xmodule/xmodule/library_content_module.py +++ b/common/lib/xmodule/xmodule/library_content_module.py @@ -279,6 +279,7 @@ class LibraryContentModule(LibraryContentFields, XModule, StudioEditableModule): @XBlock.wants('user') @XBlock.wants('library_tools') # Only needed in studio +@XBlock.wants('studio_user_permissions') # Only available in studio class LibraryContentDescriptor(LibraryContentFields, MakoModuleDescriptor, XmlDescriptor, StudioEditableDescriptor): """ Descriptor class for LibraryContentModule XBlock. @@ -307,8 +308,9 @@ class LibraryContentDescriptor(LibraryContentFields, MakoModuleDescriptor, XmlDe """ lib_tools = self.runtime.service(self, 'library_tools') user_service = self.runtime.service(self, 'user') + user_perms = self.runtime.service(self, 'studio_user_permissions') user_id = user_service.user_id if user_service else None # May be None when creating bok choy test fixtures - lib_tools.update_children(self, user_id, update_db) + lib_tools.update_children(self, user_id, user_perms, update_db) return Response() def validate(self): diff --git a/common/lib/xmodule/xmodule/library_tools.py b/common/lib/xmodule/xmodule/library_tools.py index bd52f3e2ac..f8dcadf80e 100644 --- a/common/lib/xmodule/xmodule/library_tools.py +++ b/common/lib/xmodule/xmodule/library_tools.py @@ -2,6 +2,7 @@ XBlock runtime services for LibraryContentModule """ import hashlib +from django.core.exceptions import PermissionDenied from opaque_keys.edx.locator import LibraryLocator from xblock.fields import Scope from xmodule.library_content_module import LibraryVersionReference @@ -44,7 +45,7 @@ class LibraryToolsService(object): return library.location.library_key.version_guid return None - def update_children(self, dest_block, user_id, update_db=True): + def update_children(self, dest_block, user_id, user_perms=None, update_db=True): """ This method is to be used when any of the libraries that a LibraryContentModule references have been updated. It will re-fetch all matching blocks from @@ -62,6 +63,8 @@ class LibraryToolsService(object): anyways. Otherwise, orphaned blocks may be created. """ root_children = [] + if user_perms and not user_perms.can_write(dest_block.location.course_key): + raise PermissionDenied() with self.store.bulk_operations(dest_block.location.course_key): # Currently, ALL children are essentially deleted and then re-added @@ -76,6 +79,8 @@ class LibraryToolsService(object): library = self._get_library(library_key) if library is None: raise ValueError("Required library not found.") + if user_perms and not user_perms.can_read(library_key): + raise PermissionDenied() libraries.append((library_key, library)) # Next, delete all our existing children to avoid block_id conflicts when we add them: diff --git a/common/test/acceptance/tests/studio/test_studio_library_container.py b/common/test/acceptance/tests/studio/test_studio_library_container.py index d7a592c79f..42fdfc4dc5 100644 --- a/common/test/acceptance/tests/studio/test_studio_library_container.py +++ b/common/test/acceptance/tests/studio/test_studio_library_container.py @@ -2,8 +2,11 @@ Acceptance tests for Library Content in LMS """ import ddt -from .base_studio_test import StudioLibraryTest, ContainerBase +from .base_studio_test import StudioLibraryTest +from ...fixtures.course import CourseFixture +from ..helpers import UniqueCourseTest from ...pages.studio.library import StudioLibraryContentXBlockEditModal, StudioLibraryContainerXBlockWrapper +from ...pages.studio.overview import CourseOutlinePage from ...fixtures.course import XBlockFixtureDesc SECTION_NAME = 'Test Section' @@ -12,7 +15,7 @@ UNIT_NAME = 'Test Unit' @ddt.ddt -class StudioLibraryContainerTest(ContainerBase, StudioLibraryTest): +class StudioLibraryContainerTest(StudioLibraryTest, UniqueCourseTest): """ Test Library Content block in LMS """ @@ -21,6 +24,12 @@ class StudioLibraryContainerTest(ContainerBase, StudioLibraryTest): Install library with some content and a course using fixtures """ super(StudioLibraryContainerTest, self).setUp() + # Also create a course: + self.course_fixture = CourseFixture(self.course_info['org'], self.course_info['number'], self.course_info['run'], self.course_info['display_name']) + self.populate_course_fixture(self.course_fixture) + self.course_fixture.install() + self.outline = CourseOutlinePage(self.browser, self.course_info['org'], self.course_info['number'], self.course_info['run']) + self.outline.visit() subsection = self.outline.section(SECTION_NAME).subsection(SUBSECTION_NAME) self.unit_page = subsection.toggle_expand().unit(UNIT_NAME).go_to() From 4d454e307845d532abca9dbc13ec0d30a49cbcb7 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Mon, 5 Jan 2015 14:15:03 -0800 Subject: [PATCH 34/99] Documentation updates from catong --- cms/static/js/factories/manage_users.js | 2 +- cms/static/js/factories/manage_users_lib.js | 2 +- cms/templates/manage_users_lib.html | 25 ++++++------------- .../tests/studio/test_studio_library.py | 2 +- 4 files changed, 10 insertions(+), 21 deletions(-) diff --git a/cms/static/js/factories/manage_users.js b/cms/static/js/factories/manage_users.js index 5886f5eab9..2f4e5b406f 100644 --- a/cms/static/js/factories/manage_users.js +++ b/cms/static/js/factories/manage_users.js @@ -32,7 +32,7 @@ define(['jquery', 'underscore', 'gettext', 'js/views/feedback_prompt'], function msg = new PromptView.Warning({ title: gettext('Already a course team member'), message: _.template( - gettext("{email} is already on the “{course}” team. If you're trying to add a new member, please double-check the email address you provided."), { + gettext("{email} is already on the {course} team. Recheck the email address if you want to add a new member."), { email: email, course: course.escape('name') }, {interpolate: /\{(.+?)\}/g} diff --git a/cms/static/js/factories/manage_users_lib.js b/cms/static/js/factories/manage_users_lib.js index bf2fdcd6f7..388ec56c73 100644 --- a/cms/static/js/factories/manage_users_lib.js +++ b/cms/static/js/factories/manage_users_lib.js @@ -67,7 +67,7 @@ function($, _, gettext, PromptView, ViewUtils) { msg = new PromptView.Warning({ title: gettext('Already a library team member'), message: _.template( - gettext("{email} is already on the “{course}” team. If you're trying to add a new member, please double-check the email address you provided."), { + gettext("{email} is already on the {course} team. Recheck the email address if you want to add a new member."), { email: email, course: libraryName }, {interpolate: /\{(.+?)\}/g} diff --git a/cms/templates/manage_users_lib.html b/cms/templates/manage_users_lib.html index f361e01d50..0f3959ed93 100644 --- a/cms/templates/manage_users_lib.html +++ b/cms/templates/manage_users_lib.html @@ -44,7 +44,7 @@
  • - ${_("Please provide the email address of the user you'd like to add")} + ${_("Provide the email address of the user you want to add")}
  • @@ -99,7 +99,7 @@ % else:
  • - ${_("Promote another member to Admin to remove admin rights")} + ${_("Promote another member to Admin to remove your admin rights")}
  • % endif % elif is_staff: @@ -133,7 +133,7 @@

    ${_('Add More Users to This Library')}

    -

    ${_('Adding team members makes content authoring collaborative. Users must be signed up for Studio and have an active account. ')}

    +

    ${_('Grant other members of your course team access to this library. New library users must have an active {studio_name} account.').format(studio_name=settings.STUDIO_SHORT_NAME)}

    @@ -149,22 +149,11 @@
    diff --git a/common/test/acceptance/tests/studio/test_studio_library.py b/common/test/acceptance/tests/studio/test_studio_library.py index afd5e8eb36..f7cb98ad03 100644 --- a/common/test/acceptance/tests/studio/test_studio_library.py +++ b/common/test/acceptance/tests/studio/test_studio_library.py @@ -361,7 +361,7 @@ class LibraryUsersPageTest(StudioLibraryTest): self.assertFalse(user.can_demote) self.assertFalse(user.can_delete) self.assertTrue(user.has_no_change_warning) - self.assertIn("Promote another member to Admin to remove admin rights", user.no_change_warning_text) + self.assertIn("Promote another member to Admin to remove your admin rights", user.no_change_warning_text) self.assertEqual(len(self.page.users), 1) user = self.page.users[0] From db813d1eb118c730433afb94fa9fc00f7fd99f7b Mon Sep 17 00:00:00 2001 From: "E. Kolpakov" Date: Wed, 17 Dec 2014 17:44:14 +0300 Subject: [PATCH 35/99] List of CAPA input types + setting to choose one --- .../xmodule/xmodule/library_content_module.py | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/common/lib/xmodule/xmodule/library_content_module.py b/common/lib/xmodule/xmodule/library_content_module.py index 5004a5d51a..45452a088d 100644 --- a/common/lib/xmodule/xmodule/library_content_module.py +++ b/common/lib/xmodule/xmodule/library_content_module.py @@ -5,6 +5,7 @@ LibraryContent: The XBlock used to include blocks from a library in a course. from bson.objectid import ObjectId, InvalidId from collections import namedtuple from copy import copy + from .mako_module import MakoModuleDescriptor from opaque_keys import InvalidKeyError from opaque_keys.edx.locator import LibraryLocator @@ -19,6 +20,7 @@ from xmodule.studio_editable import StudioEditableModule, StudioEditableDescript from .xml_module import XmlDescriptor from pkg_resources import resource_string + # Make '_' a no-op so we can scrape strings _ = lambda text: text @@ -28,6 +30,40 @@ def enum(**enums): return type('Enum', (), enums) +def _get_capa_types(): + capa_types = { + 'annotationinput': _('Annotation'), + 'checkboxgroup': _('Checkbox Group'), + 'checkboxtextgroup': _('Checkbox Text Group'), + 'chemicalequationinput': _('Chemical Equation'), + 'choicegroup': _('Choice Group'), + 'codeinput': _('Code Input'), + 'crystallography': _('Crystallography'), + 'designprotein2dinput': _('Design Protein 2D'), + 'drag_and_drop_input': _('Drag and Drop'), + 'editageneinput': _('Edit A Gene'), + 'editamoleculeinput': _('Edit A Molecule'), + 'filesubmission': _('File Submission'), + 'formulaequationinput': _('Formula Equation'), + 'imageinput': _('Image'), + 'javascriptinput': _('Javascript Input'), + 'jsinput': _('JS Input'), + 'matlabinput': _('Matlab'), + 'optioninput': _('Select option'), + 'radiogroup': _('Radio Group'), + 'radiotextgroup': _('Radio Text Group'), + 'schematic': _('Schematic'), + 'textbox': _('Code Text Input'), + 'textline': _('Text Line'), + 'vsepr_input': _('VSEPR'), + } + + return sorted([ + {'value': capa_type, 'display_name': caption} + for capa_type, caption in capa_types.items() + ], key=lambda item: item.get('display_name')) + + class LibraryVersionReference(namedtuple("LibraryVersionReference", "library_id version")): """ A reference to a specific library, with an optional version. @@ -146,6 +182,13 @@ class LibraryContentFields(object): default=1, scope=Scope.settings, ) + capa_type = String( + display_name=_("Problem Type"), + help=_("The type of components to include in this block"), + default="any", + values=[{"display_name": _("Any Type"), "value": "any"}] + _get_capa_types(), + scope=Scope.settings, + ) filters = String(default="") # TBD has_score = Boolean( display_name=_("Scored"), From e06b6fea62082da8b5e5048db18c08c3f438693d Mon Sep 17 00:00:00 2001 From: "E. Kolpakov" Date: Wed, 17 Dec 2014 19:07:45 +0300 Subject: [PATCH 36/99] Filtering children by CAPA input type. --- .../xmodule/xmodule/library_content_module.py | 29 ++++++++++++++++--- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/common/lib/xmodule/xmodule/library_content_module.py b/common/lib/xmodule/xmodule/library_content_module.py index 45452a088d..8a59c4a764 100644 --- a/common/lib/xmodule/xmodule/library_content_module.py +++ b/common/lib/xmodule/xmodule/library_content_module.py @@ -25,6 +25,10 @@ from pkg_resources import resource_string _ = lambda text: text +ANY_CAPA_TYPE_VALUE = 'any' +CAPA_BLOCK_TYPE = 'problem' + + def enum(**enums): """ enum helper in lieu of enum34 """ return type('Enum', (), enums) @@ -32,6 +36,7 @@ def enum(**enums): def _get_capa_types(): capa_types = { + ANY_CAPA_TYPE_VALUE: _('Any Type'), 'annotationinput': _('Annotation'), 'checkboxgroup': _('Checkbox Group'), 'checkboxtextgroup': _('Checkbox Text Group'), @@ -185,8 +190,8 @@ class LibraryContentFields(object): capa_type = String( display_name=_("Problem Type"), help=_("The type of components to include in this block"), - default="any", - values=[{"display_name": _("Any Type"), "value": "any"}] + _get_capa_types(), + default=ANY_CAPA_TYPE_VALUE, + values=_get_capa_types(), scope=Scope.settings, ) filters = String(default="") # TBD @@ -215,6 +220,21 @@ class LibraryContentModule(LibraryContentFields, XModule, StudioEditableModule): as children of this block, but only a subset of those children are shown to any particular student. """ + def _filter_children(self, child_locator): + if self.capa_type == ANY_CAPA_TYPE_VALUE: + return True + + if child_locator.block_type != CAPA_BLOCK_TYPE: + return False + + block = self.runtime.get_block(child_locator) + + if not hasattr(block, 'lcp'): + return True + + return any(self.capa_type in capa_input.tags for capa_input in block.lcp.inputs.values()) + + def selected_children(self): """ Returns a set() of block_ids indicating which of the possible children @@ -231,7 +251,7 @@ class LibraryContentModule(LibraryContentFields, XModule, StudioEditableModule): return self._selected_set # pylint: disable=access-member-before-definition # Determine which of our children we will show: selected = set(tuple(k) for k in self.selected) # set of (block_type, block_id) tuples - valid_block_keys = set([(c.block_type, c.block_id) for c in self.children]) # pylint: disable=no-member + valid_block_keys = set([(c.block_type, c.block_id) for c in self.children if self._filter_children(c)]) # pylint: disable=no-member # Remove any selected blocks that are no longer valid: selected -= (selected - valid_block_keys) # If max_count has been decreased, we may have to drop some previously selected blocks: @@ -407,7 +427,8 @@ class LibraryContentDescriptor(LibraryContentFields, MakoModuleDescriptor, XmlDe If source_libraries has been edited, refresh_children automatically. """ old_source_libraries = LibraryList().from_json(old_metadata.get('source_libraries', [])) - if set(old_source_libraries) != set(self.source_libraries): + if (set(old_source_libraries) != set(self.source_libraries) or + old_metadata.get('capa_type', ANY_CAPA_TYPE_VALUE) != self.capa_type): try: self.refresh_children(None, None, update_db=False) # update_db=False since update_item() is about to be called anyways except ValueError: From 2b45e10f340ba51fc3a4dca9221fc7fe65fead53 Mon Sep 17 00:00:00 2001 From: "E. Kolpakov" Date: Mon, 22 Dec 2014 18:37:05 +0300 Subject: [PATCH 37/99] Bok choy acceptance tests --- .../xmodule/xmodule/library_content_module.py | 16 +- common/test/acceptance/pages/lms/library.py | 11 ++ .../test/acceptance/pages/studio/library.py | 19 ++ .../test/acceptance/tests/lms/test_library.py | 162 ++++++++++++++++-- 4 files changed, 188 insertions(+), 20 deletions(-) diff --git a/common/lib/xmodule/xmodule/library_content_module.py b/common/lib/xmodule/xmodule/library_content_module.py index 8a59c4a764..e2e2481324 100644 --- a/common/lib/xmodule/xmodule/library_content_module.py +++ b/common/lib/xmodule/xmodule/library_content_module.py @@ -35,8 +35,10 @@ def enum(**enums): def _get_capa_types(): + """ + Gets capa types tags and labels + """ capa_types = { - ANY_CAPA_TYPE_VALUE: _('Any Type'), 'annotationinput': _('Annotation'), 'checkboxgroup': _('Checkbox Group'), 'checkboxtextgroup': _('Checkbox Text Group'), @@ -54,7 +56,7 @@ def _get_capa_types(): 'javascriptinput': _('Javascript Input'), 'jsinput': _('JS Input'), 'matlabinput': _('Matlab'), - 'optioninput': _('Select option'), + 'optioninput': _('Select Option'), 'radiogroup': _('Radio Group'), 'radiotextgroup': _('Radio Text Group'), 'schematic': _('Schematic'), @@ -63,7 +65,7 @@ def _get_capa_types(): 'vsepr_input': _('VSEPR'), } - return sorted([ + return [{'value': ANY_CAPA_TYPE_VALUE, 'display_name': _('Any Type')}] + sorted([ {'value': capa_type, 'display_name': caption} for capa_type, caption in capa_types.items() ], key=lambda item: item.get('display_name')) @@ -221,6 +223,9 @@ class LibraryContentModule(LibraryContentFields, XModule, StudioEditableModule): any particular student. """ def _filter_children(self, child_locator): + """ + Filters children by CAPA problem type, if configured + """ if self.capa_type == ANY_CAPA_TYPE_VALUE: return True @@ -234,7 +239,6 @@ class LibraryContentModule(LibraryContentFields, XModule, StudioEditableModule): return any(self.capa_type in capa_input.tags for capa_input in block.lcp.inputs.values()) - def selected_children(self): """ Returns a set() of block_ids indicating which of the possible children @@ -427,8 +431,8 @@ class LibraryContentDescriptor(LibraryContentFields, MakoModuleDescriptor, XmlDe If source_libraries has been edited, refresh_children automatically. """ old_source_libraries = LibraryList().from_json(old_metadata.get('source_libraries', [])) - if (set(old_source_libraries) != set(self.source_libraries) or - old_metadata.get('capa_type', ANY_CAPA_TYPE_VALUE) != self.capa_type): + if set(old_source_libraries) != set(self.source_libraries) or \ + old_metadata.get('capa_type', ANY_CAPA_TYPE_VALUE) != self.capa_type: try: self.refresh_children(None, None, update_db=False) # update_db=False since update_item() is about to be called anyways except ValueError: diff --git a/common/test/acceptance/pages/lms/library.py b/common/test/acceptance/pages/lms/library.py index 8655fae79f..3397082344 100644 --- a/common/test/acceptance/pages/lms/library.py +++ b/common/test/acceptance/pages/lms/library.py @@ -16,6 +16,9 @@ class LibraryContentXBlockWrapper(PageObject): self.locator = locator def is_browser_on_page(self): + """ + Checks if page is opened + """ return self.q(css='{}[data-id="{}"]'.format(self.BODY_SELECTOR, self.locator)).present def _bounded_selector(self, selector): @@ -35,3 +38,11 @@ class LibraryContentXBlockWrapper(PageObject): """ child_blocks = self.q(css=self._bounded_selector("div[data-id]")) return frozenset(child.text for child in child_blocks) + + @property + def children_headers(self): + """ + Gets headers if all child XBlocks as list of strings + """ + child_blocks_headers = self.q(css=self._bounded_selector("div[data-id] h2.problem-header")) + return frozenset(child.text for child in child_blocks_headers) diff --git a/common/test/acceptance/pages/studio/library.py b/common/test/acceptance/pages/studio/library.py index ea7f2299f9..71df4c4ad1 100644 --- a/common/test/acceptance/pages/studio/library.py +++ b/common/test/acceptance/pages/studio/library.py @@ -122,6 +122,7 @@ class StudioLibraryContentXBlockEditModal(CourseOutlineModal, PageObject): LIBRARY_LABEL = "Libraries" COUNT_LABEL = "Count" SCORED_LABEL = "Scored" + PROBLEM_TYPE_LABEL = "Problem Type" def is_browser_on_page(self): """ @@ -196,6 +197,24 @@ class StudioLibraryContentXBlockEditModal(CourseOutlineModal, PageObject): scored_select.select_by_value(str(scored)) EmptyPromise(lambda: self.scored == scored, "scored is updated in modal.").fulfill() + @property + def capa_type(self): + """ + Gets value of CAPA type select + """ + return self.get_metadata_input(self.PROBLEM_TYPE_LABEL).get_attribute('value') + + @capa_type.setter + def capa_type(self, value): + """ + Sets value of CAPA type select + """ + select_element = self.get_metadata_input(self.PROBLEM_TYPE_LABEL) + select_element.click() + problem_type_select = Select(select_element) + problem_type_select.select_by_value(value) + EmptyPromise(lambda: self.capa_type == value, "problem type is updated in modal.").fulfill() + def _add_library_key(self): """ Adds library key input diff --git a/common/test/acceptance/tests/lms/test_library.py b/common/test/acceptance/tests/lms/test_library.py index f83e6b94e9..4d6f34e822 100644 --- a/common/test/acceptance/tests/lms/test_library.py +++ b/common/test/acceptance/tests/lms/test_library.py @@ -19,22 +19,22 @@ SUBSECTION_NAME = 'Test Subsection' UNIT_NAME = 'Test Unit' -@ddt.ddt -class LibraryContentTest(UniqueCourseTest): - """ - Test courseware. - """ +class LibraryContentTestBase(UniqueCourseTest): + """ Base class for library content block tests """ USERNAME = "STUDENT_TESTER" EMAIL = "student101@example.com" STAFF_USERNAME = "STAFF_TESTER" STAFF_EMAIL = "staff101@example.com" + def populate_library_fixture(self, library_fixture): + pass + def setUp(self): """ Set up library, course and library content XBlock """ - super(LibraryContentTest, self).setUp() + super(LibraryContentTestBase, self).setUp() self.courseware_page = CoursewarePage(self.browser, self.course_id) @@ -46,11 +46,7 @@ class LibraryContentTest(UniqueCourseTest): ) self.library_fixture = LibraryFixture('test_org', self.unique_id, 'Test Library {}'.format(self.unique_id)) - self.library_fixture.add_children( - XBlockFixtureDesc("html", "Html1", data='html1'), - XBlockFixtureDesc("html", "Html2", data='html2'), - XBlockFixtureDesc("html", "Html3", data='html3'), - ) + self.populate_library_fixture(self.library_fixture) self.library_fixture.install() self.library_info = self.library_fixture.library_info @@ -83,7 +79,7 @@ class LibraryContentTest(UniqueCourseTest): self.course_fixture.install() - def _refresh_library_content_children(self, count=1): + def _change_library_content_settings(self, count=1, capa_type=None): """ Performs library block refresh in Studio, configuring it to show {count} children """ @@ -91,6 +87,8 @@ class LibraryContentTest(UniqueCourseTest): library_container_block = StudioLibraryContainerXBlockWrapper.from_xblock_wrapper(unit_page.xblocks[0]) modal = StudioLibraryContentXBlockEditModal(library_container_block.edit()) modal.count = count + if capa_type is not None: + modal.capa_type = capa_type library_container_block.save_settings() self._go_to_unit_page(change_login=False) unit_page.wait_for_page() @@ -124,6 +122,7 @@ class LibraryContentTest(UniqueCourseTest): block_id = block_id if block_id is not None else self.lib_block.locator #pylint: disable=attribute-defined-outside-init self.library_content_page = LibraryContentXBlockWrapper(self.browser, block_id) + self.library_content_page.wait_for_page() def _auto_auth(self, username, email, staff): """ @@ -132,6 +131,22 @@ class LibraryContentTest(UniqueCourseTest): AutoAuthPage(self.browser, username=username, email=email, course_id=self.course_id, staff=staff).visit() + +@ddt.ddt +class LibraryContentTest(LibraryContentTestBase): + """ + Test courseware. + """ + def populate_library_fixture(self, library_fixture): + """ + Populates library fixture with XBlock Fixtures + """ + library_fixture.add_children( + XBlockFixtureDesc("html", "Html1", data='html1'), + XBlockFixtureDesc("html", "Html2", data='html2'), + XBlockFixtureDesc("html", "Html3", data='html3'), + ) + @ddt.data(1, 2, 3) def test_shows_random_xblocks_from_configured(self, count): """ @@ -143,7 +158,7 @@ class LibraryContentTest(UniqueCourseTest): When I go to LMS courseware page for library content xblock as student Then I can see {count} random xblocks from the library """ - self._refresh_library_content_children(count=count) + self._change_library_content_settings(count=count) self._auto_auth(self.USERNAME, self.EMAIL, False) self._goto_library_block_page() children_contents = self.library_content_page.children_contents @@ -160,9 +175,128 @@ class LibraryContentTest(UniqueCourseTest): When I go to LMS courseware page for library content xblock as student Then I can see all xblocks from the library """ - self._refresh_library_content_children(count=10) + self._change_library_content_settings(count=10) self._auto_auth(self.USERNAME, self.EMAIL, False) self._goto_library_block_page() children_contents = self.library_content_page.children_contents self.assertEqual(len(children_contents), 3) self.assertEqual(children_contents, self.library_xblocks_texts) + + +@ddt.ddt +class StudioLibraryContainerCapaFilterTest(LibraryContentTestBase): + """ + Test Library Content block in LMS + """ + def _get_problem_choice_group_text(self, name, items): + """ Generates Choice Group CAPA problem XML """ + items_text = "\n".join([ + "{item}".format(correct=correct, item=item) + for item, correct in items + ]) + + return """ +

    {name}

    + + {items} + +
    """.format(name=name, items=items_text) + + def _get_problem_select_text(self, name, items, correct): + """ Generates Select Option CAPA problem XML """ + items_text = ",".join(map(lambda item: "'{0}'".format(item), items)) + + return """ +

    {name}

    + + + +
    """.format(name=name, options=items_text, correct=correct) + + def populate_library_fixture(self, library_fixture): + """ + Populates library fixture with XBlock Fixtures + """ + library_fixture.add_children( + XBlockFixtureDesc( + "problem", "Problem Choice Group 1", + data=self._get_problem_choice_group_text("Problem Choice Group 1 Text", [("1", False), ('2', True)]) + ), + XBlockFixtureDesc( + "problem", "Problem Choice Group 2", + data=self._get_problem_choice_group_text("Problem Choice Group 2 Text", [("Q", True), ('W', False)]) + ), + XBlockFixtureDesc( + "problem", "Problem Select 1", + data=self._get_problem_select_text("Problem Select 1 Text", ["Option 1", "Option 2"], "Option 1") + ), + XBlockFixtureDesc( + "problem", "Problem Select 2", + data=self._get_problem_select_text("Problem Select 2 Text", ["Option 3", "Option 4"], "Option 4") + ), + ) + + @property + def _problem_headers(self): + """ Expected XBLock headers according to populate_library_fixture """ + return frozenset(child.display_name.upper() for child in self.library_fixture.children) + + @ddt.data(1, 3) + def test_any_capa_type_shows_all(self, count): + """ + Scenario: Ensure setting "Any Type" for Problem Type does not filter out Problems + Given I have a library with two "Select Option" and two "Choice Group" problems, and a course containing + LibraryContent XBlock configured to draw XBlocks from that library + When I go to studio unit page for library content xblock as staff + And I set library content xblock Problem Type to "Any Type" and Count to {count} + And I refresh library content xblock and pulbish unit + When I go to LMS courseware page for library content xblock as student + Then I can see {count} xblocks from the library of any type + """ + self._change_library_content_settings(count=count, capa_type="Any Type") + self._auto_auth(self.USERNAME, self.EMAIL, False) + self._goto_library_block_page() + children_headers = self.library_content_page.children_headers + self.assertEqual(len(children_headers), count) + self.assertLessEqual(children_headers, self._problem_headers) + + @ddt.data( + ('Choice Group', 1, ["Problem Choice Group 1", "Problem Choice Group 2"]), + ('Select Option', 2, ["Problem Select 1", "Problem Select 2"]), + ) + @ddt.unpack + def test_capa_type_shows_only_chosen_type(self, capa_type, count, expected_headers): + """ + Scenario: Ensure setting "{capa_type}" for Problem Type draws aonly problem of {capa_type} from library + Given I have a library with two "Select Option" and two "Choice Group" problems, and a course containing + LibraryContent XBlock configured to draw XBlocks from that library + When I go to studio unit page for library content xblock as staff + And I set library content xblock Problem Type to "{capa_type}" and Count to {count} + And I refresh library content xblock and pulbish unit + When I go to LMS courseware page for library content xblock as student + Then I can see {count} xblocks from the library of {capa_type} + """ + self._change_library_content_settings(count=count, capa_type=capa_type) + self._auto_auth(self.USERNAME, self.EMAIL, False) + self._goto_library_block_page() + children_headers = self.library_content_page.children_headers + self.assertEqual(len(children_headers), count) + self.assertLessEqual(children_headers, self._problem_headers) + self.assertLessEqual(children_headers, set(map(lambda header: header.upper(), expected_headers))) + + def test_missing_capa_type_shows_none(self): + """ + Scenario: Ensure setting "{capa_type}" for Problem Type that is not present in library results in empty XBlock + Given I have a library with two "Select Option" and two "Choice Group" problems, and a course containing + LibraryContent XBlock configured to draw XBlocks from that library + When I go to studio unit page for library content xblock as staff + And I set library content xblock Problem Type to type not present in library + And I refresh library content xblock and pulbish unit + When I go to LMS courseware page for library content xblock as student + Then I can see no xblocks + """ + self._change_library_content_settings(count=1, capa_type="Matlab") + self._auto_auth(self.USERNAME, self.EMAIL, False) + self._goto_library_block_page() + children_headers = self.library_content_page.children_headers + self.assertEqual(len(children_headers), 0) From 66874aca341b7aa863341e709f9cdbdfa8afde80 Mon Sep 17 00:00:00 2001 From: "E. Kolpakov" Date: Tue, 23 Dec 2014 11:38:33 +0300 Subject: [PATCH 38/99] Fixed typo + combined problem type tests into single test --- common/test/acceptance/pages/lms/library.py | 2 +- .../test/acceptance/tests/lms/test_library.py | 91 +++++++++---------- 2 files changed, 44 insertions(+), 49 deletions(-) diff --git a/common/test/acceptance/pages/lms/library.py b/common/test/acceptance/pages/lms/library.py index 3397082344..6978b5fa0b 100644 --- a/common/test/acceptance/pages/lms/library.py +++ b/common/test/acceptance/pages/lms/library.py @@ -42,7 +42,7 @@ class LibraryContentXBlockWrapper(PageObject): @property def children_headers(self): """ - Gets headers if all child XBlocks as list of strings + Gets headers of all child XBlocks as list of strings """ child_blocks_headers = self.q(css=self._bounded_selector("div[data-id] h2.problem-header")) return frozenset(child.text for child in child_blocks_headers) diff --git a/common/test/acceptance/tests/lms/test_library.py b/common/test/acceptance/tests/lms/test_library.py index 4d6f34e822..53b26238c5 100644 --- a/common/test/acceptance/tests/lms/test_library.py +++ b/common/test/acceptance/tests/lms/test_library.py @@ -119,6 +119,9 @@ class LibraryContentTestBase(UniqueCourseTest): Open library page in LMS """ self.courseware_page.visit() + paragraphs = self.courseware_page.q(css='.course-content p') + if paragraphs and "You were most recently in" in paragraphs.text[0]: + paragraphs[0].find_element_by_tag_name('a').click() block_id = block_id if block_id is not None else self.lib_block.locator #pylint: disable=attribute-defined-outside-init self.library_content_page = LibraryContentXBlockWrapper(self.browser, block_id) @@ -241,62 +244,54 @@ class StudioLibraryContainerCapaFilterTest(LibraryContentTestBase): """ Expected XBLock headers according to populate_library_fixture """ return frozenset(child.display_name.upper() for child in self.library_fixture.children) - @ddt.data(1, 3) - def test_any_capa_type_shows_all(self, count): + def _set_library_content_settings(self, count=1, capa_type="Any Type"): """ - Scenario: Ensure setting "Any Type" for Problem Type does not filter out Problems - Given I have a library with two "Select Option" and two "Choice Group" problems, and a course containing - LibraryContent XBlock configured to draw XBlocks from that library - When I go to studio unit page for library content xblock as staff - And I set library content xblock Problem Type to "Any Type" and Count to {count} - And I refresh library content xblock and pulbish unit - When I go to LMS courseware page for library content xblock as student - Then I can see {count} xblocks from the library of any type - """ - self._change_library_content_settings(count=count, capa_type="Any Type") - self._auto_auth(self.USERNAME, self.EMAIL, False) - self._goto_library_block_page() - children_headers = self.library_content_page.children_headers - self.assertEqual(len(children_headers), count) - self.assertLessEqual(children_headers, self._problem_headers) - - @ddt.data( - ('Choice Group', 1, ["Problem Choice Group 1", "Problem Choice Group 2"]), - ('Select Option', 2, ["Problem Select 1", "Problem Select 2"]), - ) - @ddt.unpack - def test_capa_type_shows_only_chosen_type(self, capa_type, count, expected_headers): - """ - Scenario: Ensure setting "{capa_type}" for Problem Type draws aonly problem of {capa_type} from library - Given I have a library with two "Select Option" and two "Choice Group" problems, and a course containing - LibraryContent XBlock configured to draw XBlocks from that library - When I go to studio unit page for library content xblock as staff - And I set library content xblock Problem Type to "{capa_type}" and Count to {count} - And I refresh library content xblock and pulbish unit - When I go to LMS courseware page for library content xblock as student - Then I can see {count} xblocks from the library of {capa_type} + Sets library content XBlock parameters, saves, publishes unit, goes to LMS unit page and + gets children XBlock headers to assert against them """ self._change_library_content_settings(count=count, capa_type=capa_type) self._auto_auth(self.USERNAME, self.EMAIL, False) self._goto_library_block_page() - children_headers = self.library_content_page.children_headers - self.assertEqual(len(children_headers), count) - self.assertLessEqual(children_headers, self._problem_headers) - self.assertLessEqual(children_headers, set(map(lambda header: header.upper(), expected_headers))) + return self.library_content_page.children_headers - def test_missing_capa_type_shows_none(self): + def test_problem_type_selector(self): """ - Scenario: Ensure setting "{capa_type}" for Problem Type that is not present in library results in empty XBlock + Scenario: Ensure setting "Any Type" for Problem Type does not filter out Problems Given I have a library with two "Select Option" and two "Choice Group" problems, and a course containing LibraryContent XBlock configured to draw XBlocks from that library - When I go to studio unit page for library content xblock as staff - And I set library content xblock Problem Type to type not present in library - And I refresh library content xblock and pulbish unit + When I set library content xblock Problem Type to "Any Type" and Count to 3 and publish unit When I go to LMS courseware page for library content xblock as student - Then I can see no xblocks + Then I can see 3 xblocks from the library of any type + When I set library content xblock Problem Type to "Choice Group" and Count to 1 and publish unit + When I go to LMS courseware page for library content xblock as student + Then I can see 1 xblock from the library of "Choice Group" type + When I set library content xblock Problem Type to "Select Option" and Count to 2 and publish unit + When I go to LMS courseware page for library content xblock as student + Then I can see 2 xblock from the library of "Select Option" type + When I set library content xblock Problem Type to "Matlab" and Count to 2 and publish unit + When I go to LMS courseware page for library content xblock as student + Then I can see 0 xblocks from the library """ - self._change_library_content_settings(count=1, capa_type="Matlab") - self._auto_auth(self.USERNAME, self.EMAIL, False) - self._goto_library_block_page() - children_headers = self.library_content_page.children_headers - self.assertEqual(len(children_headers), 0) + children_headers = self._set_library_content_settings(count=3, capa_type="Any Type") + self.assertEqual(len(children_headers), 3) + self.assertLessEqual(children_headers, self._problem_headers) + + # Choice group test + children_headers = self._set_library_content_settings(count=1, capa_type="Choice Group") + self.assertEqual(len(children_headers), 1) + self.assertLessEqual( + children_headers, + set(map(lambda header: header.upper(), ["Problem Choice Group 1", "Problem Choice Group 2"])) + ) + + # Choice group test + children_headers = self._set_library_content_settings(count=2, capa_type="Select Option") + self.assertEqual(len(children_headers), 2) + self.assertLessEqual( + children_headers, + set(map(lambda header: header.upper(), ["Problem Select 1", "Problem Select 2"])) + ) + + # Missing problem type test + children_headers = self._set_library_content_settings(count=2, capa_type="Matlab") + self.assertEqual(children_headers, set()) From fcbc8446d6ee9ddb22bce3399ef5ff6bc2009753 Mon Sep 17 00:00:00 2001 From: "E. Kolpakov" Date: Wed, 24 Dec 2014 11:33:53 +0300 Subject: [PATCH 39/99] Problem type filtering on `update_children` event --- common/lib/xmodule/xmodule/capa_module.py | 9 ++++++++ .../xmodule/xmodule/library_content_module.py | 19 +--------------- common/lib/xmodule/xmodule/library_tools.py | 22 ++++++++++++++++--- 3 files changed, 29 insertions(+), 21 deletions(-) diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index 776dc36e3f..4c7f785c6d 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -2,10 +2,12 @@ import json import logging import sys +from lxml import etree from pkg_resources import resource_string from .capa_base import CapaMixin, CapaFields, ComplexEncoder +from capa import inputtypes from .progress import Progress from xmodule.x_module import XModule, module_attr from xmodule.raw_module import RawDescriptor @@ -172,6 +174,13 @@ class CapaDescriptor(CapaFields, RawDescriptor): ]) return non_editable_fields + @property + def problem_types(self): + """ Low-level problem type introspection for content libraries filtering by problem type """ + tree = etree.XML(self.data) + registered_tas = inputtypes.registry.registered_tags() + return set([node.tag for node in tree.iter() if node.tag in registered_tas]) + # Proxy to CapaModule for access to any of its attributes answer_available = module_attr('answer_available') check_button_name = module_attr('check_button_name') diff --git a/common/lib/xmodule/xmodule/library_content_module.py b/common/lib/xmodule/xmodule/library_content_module.py index e2e2481324..11d23106f4 100644 --- a/common/lib/xmodule/xmodule/library_content_module.py +++ b/common/lib/xmodule/xmodule/library_content_module.py @@ -222,23 +222,6 @@ class LibraryContentModule(LibraryContentFields, XModule, StudioEditableModule): as children of this block, but only a subset of those children are shown to any particular student. """ - def _filter_children(self, child_locator): - """ - Filters children by CAPA problem type, if configured - """ - if self.capa_type == ANY_CAPA_TYPE_VALUE: - return True - - if child_locator.block_type != CAPA_BLOCK_TYPE: - return False - - block = self.runtime.get_block(child_locator) - - if not hasattr(block, 'lcp'): - return True - - return any(self.capa_type in capa_input.tags for capa_input in block.lcp.inputs.values()) - def selected_children(self): """ Returns a set() of block_ids indicating which of the possible children @@ -255,7 +238,7 @@ class LibraryContentModule(LibraryContentFields, XModule, StudioEditableModule): return self._selected_set # pylint: disable=access-member-before-definition # Determine which of our children we will show: selected = set(tuple(k) for k in self.selected) # set of (block_type, block_id) tuples - valid_block_keys = set([(c.block_type, c.block_id) for c in self.children if self._filter_children(c)]) # pylint: disable=no-member + valid_block_keys = set([(c.block_type, c.block_id) for c in self.children]) # pylint: disable=no-member # Remove any selected blocks that are no longer valid: selected -= (selected - valid_block_keys) # If max_count has been decreased, we may have to drop some previously selected blocks: diff --git a/common/lib/xmodule/xmodule/library_tools.py b/common/lib/xmodule/xmodule/library_tools.py index f8dcadf80e..d0e6768ed8 100644 --- a/common/lib/xmodule/xmodule/library_tools.py +++ b/common/lib/xmodule/xmodule/library_tools.py @@ -5,8 +5,9 @@ import hashlib from django.core.exceptions import PermissionDenied from opaque_keys.edx.locator import LibraryLocator from xblock.fields import Scope -from xmodule.library_content_module import LibraryVersionReference +from xmodule.library_content_module import LibraryVersionReference, ANY_CAPA_TYPE_VALUE from xmodule.modulestore.exceptions import ItemNotFoundError +from xmodule.capa_module import CapaDescriptor class LibraryToolsService(object): @@ -45,6 +46,18 @@ class LibraryToolsService(object): return library.location.library_key.version_guid return None + def _filter_child(self, dest_block, child_descriptor): + """ + Filters children by CAPA problem type, if configured + """ + if dest_block.capa_type == ANY_CAPA_TYPE_VALUE: + return True + + if not isinstance(child_descriptor, CapaDescriptor): + return False + + return dest_block.capa_type in child_descriptor.problem_types + def update_children(self, dest_block, user_id, user_perms=None, update_db=True): """ This method is to be used when any of the libraries that a LibraryContentModule @@ -91,13 +104,16 @@ class LibraryToolsService(object): new_libraries = [] for library_key, library in libraries: - def copy_children_recursively(from_block): + def copy_children_recursively(from_block, filter_problem_type=True): """ Internal method to copy blocks from the library recursively """ new_children = [] for child_key in from_block.children: child = self.store.get_item(child_key, depth=9) + + if filter_problem_type and not self._filter_child(dest_block, child): + continue # We compute a block_id for each matching child block found in the library. # block_ids are unique within any branch, but are not unique per-course or globally. # We need our block_ids to be consistent when content in the library is updated, so @@ -125,7 +141,7 @@ class LibraryToolsService(object): ) new_children.append(new_child_info.location) return new_children - root_children.extend(copy_children_recursively(from_block=library)) + root_children.extend(copy_children_recursively(from_block=library, filter_problem_type=True)) new_libraries.append(LibraryVersionReference(library_key, library.location.library_key.version_guid)) dest_block.source_libraries = new_libraries dest_block.children = root_children From ea428273e6ede08c2175d55f54fbf66fb2caf96d Mon Sep 17 00:00:00 2001 From: "E. Kolpakov" Date: Mon, 29 Dec 2014 15:29:30 +0300 Subject: [PATCH 40/99] Switched to filtering by response type rather than input type --- common/lib/xmodule/xmodule/capa_module.py | 6 +-- .../xmodule/xmodule/library_content_module.py | 46 +++++++++---------- common/lib/xmodule/xmodule/library_tools.py | 2 +- .../test/acceptance/tests/lms/test_library.py | 6 +-- 4 files changed, 29 insertions(+), 31 deletions(-) diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index 4c7f785c6d..47583d9706 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -7,7 +7,7 @@ from lxml import etree from pkg_resources import resource_string from .capa_base import CapaMixin, CapaFields, ComplexEncoder -from capa import inputtypes +from capa import responsetypes from .progress import Progress from xmodule.x_module import XModule, module_attr from xmodule.raw_module import RawDescriptor @@ -178,8 +178,8 @@ class CapaDescriptor(CapaFields, RawDescriptor): def problem_types(self): """ Low-level problem type introspection for content libraries filtering by problem type """ tree = etree.XML(self.data) - registered_tas = inputtypes.registry.registered_tags() - return set([node.tag for node in tree.iter() if node.tag in registered_tas]) + registered_tags = responsetypes.registry.registered_tags() + return set([node.tag for node in tree.iter() if node.tag in registered_tags]) # Proxy to CapaModule for access to any of its attributes answer_available = module_attr('answer_available') diff --git a/common/lib/xmodule/xmodule/library_content_module.py b/common/lib/xmodule/xmodule/library_content_module.py index 11d23106f4..d8512bcdbe 100644 --- a/common/lib/xmodule/xmodule/library_content_module.py +++ b/common/lib/xmodule/xmodule/library_content_module.py @@ -39,30 +39,28 @@ def _get_capa_types(): Gets capa types tags and labels """ capa_types = { - 'annotationinput': _('Annotation'), - 'checkboxgroup': _('Checkbox Group'), - 'checkboxtextgroup': _('Checkbox Text Group'), - 'chemicalequationinput': _('Chemical Equation'), - 'choicegroup': _('Choice Group'), - 'codeinput': _('Code Input'), - 'crystallography': _('Crystallography'), - 'designprotein2dinput': _('Design Protein 2D'), - 'drag_and_drop_input': _('Drag and Drop'), - 'editageneinput': _('Edit A Gene'), - 'editamoleculeinput': _('Edit A Molecule'), - 'filesubmission': _('File Submission'), - 'formulaequationinput': _('Formula Equation'), - 'imageinput': _('Image'), - 'javascriptinput': _('Javascript Input'), - 'jsinput': _('JS Input'), - 'matlabinput': _('Matlab'), - 'optioninput': _('Select Option'), - 'radiogroup': _('Radio Group'), - 'radiotextgroup': _('Radio Text Group'), - 'schematic': _('Schematic'), - 'textbox': _('Code Text Input'), - 'textline': _('Text Line'), - 'vsepr_input': _('VSEPR'), + # basic tab + 'choiceresponse': _('Checkboxes'), + 'optionresponse': _('Dropdown'), + 'multiplechoiceresponse': _('Multiple Choice'), + 'truefalseresponse': _('True/False Choice'), + 'numericalresponse': _('Numerical Input'), + 'stringresponse': _('Text Input'), + + # advanced tab + 'schematicresponse': _('Circuit Schematic Builder'), + 'customresponse': _('Custom Evaluated Script'), + 'imageresponse': _('Image Mapped Input'), + 'formularesponse': _('Math Expression Input'), + 'jsmeresponse': _('Molecular Structure'), + + # not in "Add Component" menu + 'javascriptresponse': _('Javascript Input'), + 'symbolicresponse': _('Symbolic Math Input'), + 'coderesponse': _('Code Input'), + 'externalresponse': _('External Grader'), + 'annotationresponse': _('Annotation Input'), + 'choicetextresponse': _('Checkboxes With Text Input'), } return [{'value': ANY_CAPA_TYPE_VALUE, 'display_name': _('Any Type')}] + sorted([ diff --git a/common/lib/xmodule/xmodule/library_tools.py b/common/lib/xmodule/xmodule/library_tools.py index d0e6768ed8..3b902622c5 100644 --- a/common/lib/xmodule/xmodule/library_tools.py +++ b/common/lib/xmodule/xmodule/library_tools.py @@ -104,7 +104,7 @@ class LibraryToolsService(object): new_libraries = [] for library_key, library in libraries: - def copy_children_recursively(from_block, filter_problem_type=True): + def copy_children_recursively(from_block, filter_problem_type=False): """ Internal method to copy blocks from the library recursively """ diff --git a/common/test/acceptance/tests/lms/test_library.py b/common/test/acceptance/tests/lms/test_library.py index 53b26238c5..cd28f81da2 100644 --- a/common/test/acceptance/tests/lms/test_library.py +++ b/common/test/acceptance/tests/lms/test_library.py @@ -277,7 +277,7 @@ class StudioLibraryContainerCapaFilterTest(LibraryContentTestBase): self.assertLessEqual(children_headers, self._problem_headers) # Choice group test - children_headers = self._set_library_content_settings(count=1, capa_type="Choice Group") + children_headers = self._set_library_content_settings(count=1, capa_type="Multiple Choice") self.assertEqual(len(children_headers), 1) self.assertLessEqual( children_headers, @@ -285,7 +285,7 @@ class StudioLibraryContainerCapaFilterTest(LibraryContentTestBase): ) # Choice group test - children_headers = self._set_library_content_settings(count=2, capa_type="Select Option") + children_headers = self._set_library_content_settings(count=2, capa_type="Dropdown") self.assertEqual(len(children_headers), 2) self.assertLessEqual( children_headers, @@ -293,5 +293,5 @@ class StudioLibraryContainerCapaFilterTest(LibraryContentTestBase): ) # Missing problem type test - children_headers = self._set_library_content_settings(count=2, capa_type="Matlab") + children_headers = self._set_library_content_settings(count=2, capa_type="Custom Evaluated Script") self.assertEqual(children_headers, set()) From 33ce3d42fff85eb1ffe2a3d30e8f65a687c81629 Mon Sep 17 00:00:00 2001 From: "E. Kolpakov" Date: Mon, 29 Dec 2014 16:07:32 +0300 Subject: [PATCH 41/99] Validation warning when no content matches configured filters --- .../xmodule/xmodule/library_content_module.py | 64 +++++++++++++------ common/lib/xmodule/xmodule/library_tools.py | 35 ++++++---- 2 files changed, 67 insertions(+), 32 deletions(-) diff --git a/common/lib/xmodule/xmodule/library_content_module.py b/common/lib/xmodule/xmodule/library_content_module.py index d8512bcdbe..49237c7881 100644 --- a/common/lib/xmodule/xmodule/library_content_module.py +++ b/common/lib/xmodule/xmodule/library_content_module.py @@ -361,6 +361,31 @@ class LibraryContentDescriptor(LibraryContentFields, MakoModuleDescriptor, XmlDe lib_tools.update_children(self, user_id, user_perms, update_db) return Response() + def _validate_library_version(self, validation, lib_tools, version, library_key): + latest_version = lib_tools.get_library_version(library_key) + if latest_version is not None: + if version is None or version != latest_version: + validation.set_summary( + StudioValidationMessage( + StudioValidationMessage.WARNING, + _(u'This component is out of date. The library has new content.'), + action_class='library-update-btn', # TODO: change this to action_runtime_event='...' once the unit page supports that feature. + action_label=_(u"↻ Update now") + ) + ) + return False + else: + validation.set_summary( + StudioValidationMessage( + StudioValidationMessage.ERROR, + _(u'Library is invalid, corrupt, or has been deleted.'), + action_class='edit-button', + action_label=_(u"Edit Library List") + ) + ) + return False + return True + def validate(self): """ Validates the state of this Library Content Module Instance. This @@ -381,30 +406,27 @@ class LibraryContentDescriptor(LibraryContentFields, MakoModuleDescriptor, XmlDe ) return validation lib_tools = self.runtime.service(self, 'library_tools') + has_children_matching_filter = False for library_key, version in self.source_libraries: - latest_version = lib_tools.get_library_version(library_key) - if latest_version is not None: - if version is None or version != latest_version: - validation.set_summary( - StudioValidationMessage( - StudioValidationMessage.WARNING, - _(u'This component is out of date. The library has new content.'), - action_class='library-update-btn', # TODO: change this to action_runtime_event='...' once the unit page supports that feature. - action_label=_(u"↻ Update now") - ) - ) - break - else: - validation.set_summary( - StudioValidationMessage( - StudioValidationMessage.ERROR, - _(u'Library is invalid, corrupt, or has been deleted.'), - action_class='edit-button', - action_label=_(u"Edit Library List") - ) - ) + if not self._validate_library_version(validation, lib_tools, version, library_key): break + library = lib_tools.get_library(library_key) + children_matching_filter = lib_tools.get_filtered_children(library, self.capa_type) + # get_filtered_children returns generator, so we're basically checking if there are at least one child + # that satisfy filtering. Children are never equal to None, so None is returned only if generator was empty + has_children_matching_filter |= next(children_matching_filter, None) is not None + + if not has_children_matching_filter and validation.empty: + validation.set_summary( + StudioValidationMessage( + StudioValidationMessage.WARNING, + _(u'There are no content matching configured filters in the selected libraries.'), + action_class='edit-button', + action_label=_(u"Edit Library List") + ) + ) + return validation def editor_saved(self, user, old_metadata, old_content): diff --git a/common/lib/xmodule/xmodule/library_tools.py b/common/lib/xmodule/xmodule/library_tools.py index 3b902622c5..ad0d78a35b 100644 --- a/common/lib/xmodule/xmodule/library_tools.py +++ b/common/lib/xmodule/xmodule/library_tools.py @@ -18,7 +18,7 @@ class LibraryToolsService(object): def __init__(self, modulestore): self.store = modulestore - def _get_library(self, library_key): + def get_library(self, library_key): """ Given a library key like "library-v1:ProblemX+PR0B", return the 'library' XBlock with meta-information about the library. @@ -39,24 +39,39 @@ class LibraryToolsService(object): Get the version (an ObjectID) of the given library. Returns None if the library does not exist. """ - library = self._get_library(lib_key) + library = self.get_library(lib_key) if library: # We need to know the library's version so ensure it's set in library.location.library_key.version_guid assert library.location.library_key.version_guid is not None return library.location.library_key.version_guid return None - def _filter_child(self, dest_block, child_descriptor): + def _filter_child(self, capa_type, child_descriptor): """ Filters children by CAPA problem type, if configured """ - if dest_block.capa_type == ANY_CAPA_TYPE_VALUE: + if capa_type == ANY_CAPA_TYPE_VALUE: return True if not isinstance(child_descriptor, CapaDescriptor): return False - return dest_block.capa_type in child_descriptor.problem_types + return capa_type in child_descriptor.problem_types + + def get_filtered_children(self, from_block, capa_type=ANY_CAPA_TYPE_VALUE): + """ + Filters children of `from_block` that satisfy filter criteria + Returns generator containing (child_key, child) for all children matching filter criteria + """ + children = ( + (child_key, self.store.get_item(child_key, depth=9)) + for child_key in from_block.children + ) + return ( + (child_key, child) + for child_key, child in children + if self._filter_child(capa_type, child) + ) def update_children(self, dest_block, user_id, user_perms=None, update_db=True): """ @@ -89,7 +104,7 @@ class LibraryToolsService(object): # First, load and validate the source_libraries: libraries = [] for library_key, old_version in dest_block.source_libraries: # pylint: disable=unused-variable - library = self._get_library(library_key) + library = self.get_library(library_key) if library is None: raise ValueError("Required library not found.") if user_perms and not user_perms.can_read(library_key): @@ -109,11 +124,9 @@ class LibraryToolsService(object): Internal method to copy blocks from the library recursively """ new_children = [] - for child_key in from_block.children: - child = self.store.get_item(child_key, depth=9) - - if filter_problem_type and not self._filter_child(dest_block, child): - continue + target_capa_type = dest_block.capa_type if filter_problem_type else ANY_CAPA_TYPE_VALUE + filtered_children = self.get_filtered_children(from_block, target_capa_type) + for child_key, child in filtered_children: # We compute a block_id for each matching child block found in the library. # block_ids are unique within any branch, but are not unique per-course or globally. # We need our block_ids to be consistent when content in the library is updated, so From 620ba8a1640c8b6ec49528d78696b306b6d96ba4 Mon Sep 17 00:00:00 2001 From: "E. Kolpakov" Date: Mon, 29 Dec 2014 16:32:48 +0300 Subject: [PATCH 42/99] Test for warning message when no content is configured. --- .../xmodule/xmodule/library_content_module.py | 6 ++- .../studio/test_studio_library_container.py | 42 +++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/common/lib/xmodule/xmodule/library_content_module.py b/common/lib/xmodule/xmodule/library_content_module.py index 49237c7881..fcbef1fc00 100644 --- a/common/lib/xmodule/xmodule/library_content_module.py +++ b/common/lib/xmodule/xmodule/library_content_module.py @@ -26,7 +26,6 @@ _ = lambda text: text ANY_CAPA_TYPE_VALUE = 'any' -CAPA_BLOCK_TYPE = 'problem' def enum(**enums): @@ -362,6 +361,9 @@ class LibraryContentDescriptor(LibraryContentFields, MakoModuleDescriptor, XmlDe return Response() def _validate_library_version(self, validation, lib_tools, version, library_key): + """ + Validates library version + """ latest_version = lib_tools.get_library_version(library_key) if latest_version is not None: if version is None or version != latest_version: @@ -423,7 +425,7 @@ class LibraryContentDescriptor(LibraryContentFields, MakoModuleDescriptor, XmlDe StudioValidationMessage.WARNING, _(u'There are no content matching configured filters in the selected libraries.'), action_class='edit-button', - action_label=_(u"Edit Library List") + action_label=_(u"Edit Problem Type Filter") ) ) diff --git a/common/test/acceptance/tests/studio/test_studio_library_container.py b/common/test/acceptance/tests/studio/test_studio_library_container.py index 42fdfc4dc5..747db98752 100644 --- a/common/test/acceptance/tests/studio/test_studio_library_container.py +++ b/common/test/acceptance/tests/studio/test_studio_library_container.py @@ -42,6 +42,14 @@ class StudioLibraryContainerTest(StudioLibraryTest, UniqueCourseTest): XBlockFixtureDesc("html", "Html1"), XBlockFixtureDesc("html", "Html2"), XBlockFixtureDesc("html", "Html3"), + + XBlockFixtureDesc( + "problem", "Dropdown", + data=""" + +

    Dropdown

    + +
    """) ) def populate_course_fixture(self, course_fixture): @@ -171,3 +179,37 @@ class StudioLibraryContainerTest(StudioLibraryTest, UniqueCourseTest): self.assertFalse(library_block.has_validation_message) #self.assertIn("4 matching components", library_block.author_content) # Removed this assert until a summary message is added back to the author view (SOL-192) + + def test_no_content_message(self): + """ + Scenario: Given I have a library, a course and library content xblock in a course + When I go to studio unit page for library content block + And I set Problem Type selector so that no libraries have matching content + Then I can see that "No matching content" warning is shown + """ + expected_text = 'There are no content matching configured filters in the selected libraries. ' \ + 'Edit Problem Type Filter' + + library_container = self._get_library_xblock_wrapper(self.unit_page.xblocks[0]) + + # precondition check - assert library has children matching filter criteria + self.assertFalse(library_container.has_validation_error) + self.assertFalse(library_container.has_validation_warning) + + edit_modal = StudioLibraryContentXBlockEditModal(library_container.edit()) + self.assertEqual(edit_modal.capa_type, "Any Type") # precondition check + edit_modal.capa_type = "Custom Evaluated Script" + + library_container.save_settings() + + self.assertTrue(library_container.has_validation_warning) + self.assertIn(expected_text, library_container.validation_warning_text) + + edit_modal = StudioLibraryContentXBlockEditModal(library_container.edit()) + self.assertEqual(edit_modal.capa_type, "Custom Evaluated Script") # precondition check + edit_modal.capa_type = "Dropdown" + library_container.save_settings() + + # Library should contain single Dropdown problem, so now there should be no errors again + self.assertFalse(library_container.has_validation_error) + self.assertFalse(library_container.has_validation_warning) From b2a17b35b0f1de8f6da4336cc043c0c568791116 Mon Sep 17 00:00:00 2001 From: "E. Kolpakov" Date: Tue, 30 Dec 2014 11:12:10 +0300 Subject: [PATCH 43/99] Validation warning if library content XBlock configured to fetch more problems than libraries and filtering allow --- .../xmodule/xmodule/library_content_module.py | 30 +++++++++++++++---- .../studio/test_studio_library_container.py | 27 +++++++++++++++++ 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/common/lib/xmodule/xmodule/library_content_module.py b/common/lib/xmodule/xmodule/library_content_module.py index fcbef1fc00..b8058e8e7f 100644 --- a/common/lib/xmodule/xmodule/library_content_module.py +++ b/common/lib/xmodule/xmodule/library_content_module.py @@ -388,6 +388,11 @@ class LibraryContentDescriptor(LibraryContentFields, MakoModuleDescriptor, XmlDe return False return True + def _set_validation_error_if_empty(self, validation, summary): + """ Helper method to only set validation summary if it's empty """ + if validation.empty: + validation.set_summary(summary) + def validate(self): """ Validates the state of this Library Content Module Instance. This @@ -408,19 +413,20 @@ class LibraryContentDescriptor(LibraryContentFields, MakoModuleDescriptor, XmlDe ) return validation lib_tools = self.runtime.service(self, 'library_tools') - has_children_matching_filter = False + matching_children_count = 0 for library_key, version in self.source_libraries: if not self._validate_library_version(validation, lib_tools, version, library_key): break library = lib_tools.get_library(library_key) children_matching_filter = lib_tools.get_filtered_children(library, self.capa_type) - # get_filtered_children returns generator, so we're basically checking if there are at least one child - # that satisfy filtering. Children are never equal to None, so None is returned only if generator was empty - has_children_matching_filter |= next(children_matching_filter, None) is not None + # get_filtered_children returns generator, so can't use len. + # And we don't actually need those children, so no point of constructing a list + matching_children_count += sum(1 for child in children_matching_filter) - if not has_children_matching_filter and validation.empty: - validation.set_summary( + if matching_children_count == 0: + self._set_validation_error_if_empty( + validation, StudioValidationMessage( StudioValidationMessage.WARNING, _(u'There are no content matching configured filters in the selected libraries.'), @@ -429,6 +435,18 @@ class LibraryContentDescriptor(LibraryContentFields, MakoModuleDescriptor, XmlDe ) ) + if matching_children_count < self.max_count: + self._set_validation_error_if_empty( + validation, + StudioValidationMessage( + StudioValidationMessage.WARNING, + _(u'Configured to fetch {count} blocks, library and filter settings yield only {actual} blocks.') + .format(actual=matching_children_count, count=self.max_count), + action_class='edit-button', + action_label=_(u"Edit block configuration") + ) + ) + return validation def editor_saved(self, user, old_metadata, old_content): diff --git a/common/test/acceptance/tests/studio/test_studio_library_container.py b/common/test/acceptance/tests/studio/test_studio_library_container.py index 747db98752..4634fb09ac 100644 --- a/common/test/acceptance/tests/studio/test_studio_library_container.py +++ b/common/test/acceptance/tests/studio/test_studio_library_container.py @@ -186,6 +186,8 @@ class StudioLibraryContainerTest(StudioLibraryTest, UniqueCourseTest): When I go to studio unit page for library content block And I set Problem Type selector so that no libraries have matching content Then I can see that "No matching content" warning is shown + When I set Problem Type selector so that there are matching content + Then I can see that warning messages are not shown """ expected_text = 'There are no content matching configured filters in the selected libraries. ' \ 'Edit Problem Type Filter' @@ -213,3 +215,28 @@ class StudioLibraryContainerTest(StudioLibraryTest, UniqueCourseTest): # Library should contain single Dropdown problem, so now there should be no errors again self.assertFalse(library_container.has_validation_error) self.assertFalse(library_container.has_validation_warning) + + def test_not_enough_children_blocks(self): + """ + Scenario: Given I have a library, a course and library content xblock in a course + When I go to studio unit page for library content block + And I set Problem Type selector so "Any" + Then I can see that "No matching content" warning is shown + """ + expected_tpl = "Configured to fetch {count} blocks, library and filter settings yield only {actual} blocks." + + library_container = self._get_library_xblock_wrapper(self.unit_page.xblocks[0]) + + # precondition check - assert block is configured fine + self.assertFalse(library_container.has_validation_error) + self.assertFalse(library_container.has_validation_warning) + + edit_modal = StudioLibraryContentXBlockEditModal(library_container.edit()) + edit_modal.count = 50 + library_container.save_settings() + + self.assertTrue(library_container.has_validation_warning) + self.assertIn( + expected_tpl.format(count=50, actual=len(self.library_fixture.children)), + library_container.validation_warning_text + ) From 6ac258850fbeef75852e8b0818c573f1291226ce Mon Sep 17 00:00:00 2001 From: "E. Kolpakov" Date: Tue, 30 Dec 2014 11:18:17 +0300 Subject: [PATCH 44/99] Improved help message. --- common/lib/xmodule/xmodule/library_content_module.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/library_content_module.py b/common/lib/xmodule/xmodule/library_content_module.py index b8058e8e7f..0d2886ac9d 100644 --- a/common/lib/xmodule/xmodule/library_content_module.py +++ b/common/lib/xmodule/xmodule/library_content_module.py @@ -188,7 +188,7 @@ class LibraryContentFields(object): ) capa_type = String( display_name=_("Problem Type"), - help=_("The type of components to include in this block"), + help=_('Choose a problem type to fetch from the library. If "Any Type" is selected no filtering is applied.'), default=ANY_CAPA_TYPE_VALUE, values=_get_capa_types(), scope=Scope.settings, From 332d2b03685f7d661475933b5998d3edea99708a Mon Sep 17 00:00:00 2001 From: Jonathan Piacenti Date: Wed, 31 Dec 2014 19:31:42 +0000 Subject: [PATCH 45/99] Addressed notes from reviewers about Library content filters. --- common/lib/capa/capa/responsetypes.py | 18 ++++++++ .../xmodule/xmodule/library_content_module.py | 42 +++++++------------ .../test/acceptance/tests/lms/test_library.py | 11 +++-- .../studio/test_studio_library_container.py | 6 +-- 4 files changed, 42 insertions(+), 35 deletions(-) diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index e857bf11f0..30d3dc577f 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -58,6 +58,8 @@ registry = TagRegistry() CorrectMap = correctmap.CorrectMap # pylint: disable=invalid-name CORRECTMAP_PY = None +# Make '_' a no-op so we can scrape strings +_ = lambda text: text #----------------------------------------------------------------------------- # Exceptions @@ -439,6 +441,7 @@ class JavascriptResponse(LoncapaResponse): Javascript using Node.js. """ + human_name = _('Javascript Input') tags = ['javascriptresponse'] max_inputfields = 1 allowed_inputfields = ['javascriptinput'] @@ -684,6 +687,7 @@ class ChoiceResponse(LoncapaResponse): """ + human_name = _('Checkboxes') tags = ['choiceresponse'] max_inputfields = 1 allowed_inputfields = ['checkboxgroup', 'radiogroup'] @@ -754,6 +758,7 @@ class MultipleChoiceResponse(LoncapaResponse): """ # TODO: handle direction and randomize + human_name = _('Multiple Choice') tags = ['multiplechoiceresponse'] max_inputfields = 1 allowed_inputfields = ['choicegroup'] @@ -1042,6 +1047,7 @@ class MultipleChoiceResponse(LoncapaResponse): @registry.register class TrueFalseResponse(MultipleChoiceResponse): + human_name = _('True/False Choice') tags = ['truefalseresponse'] def mc_setup_response(self): @@ -1073,6 +1079,7 @@ class OptionResponse(LoncapaResponse): TODO: handle direction and randomize """ + human_name = _('Dropdown') tags = ['optionresponse'] hint_tag = 'optionhint' allowed_inputfields = ['optioninput'] @@ -1108,6 +1115,7 @@ class NumericalResponse(LoncapaResponse): to a number (e.g. `4+5/2^2`), and accepts with a tolerance. """ + human_name = _('Numerical Input') tags = ['numericalresponse'] hint_tag = 'numericalhint' allowed_inputfields = ['textline', 'formulaequationinput'] @@ -1308,6 +1316,7 @@ class StringResponse(LoncapaResponse): """ + human_name = _('Text Input') tags = ['stringresponse'] hint_tag = 'stringhint' allowed_inputfields = ['textline'] @@ -1426,6 +1435,7 @@ class CustomResponse(LoncapaResponse): or in a """ + human_name = _('Custom Evaluated Script') tags = ['customresponse'] allowed_inputfields = ['textline', 'textbox', 'crystallography', @@ -1800,6 +1810,7 @@ class SymbolicResponse(CustomResponse): Symbolic math response checking, using symmath library. """ + human_name = _('Symbolic Math Input') tags = ['symbolicresponse'] max_inputfields = 1 @@ -1868,6 +1879,7 @@ class CodeResponse(LoncapaResponse): """ + human_name = _('Code Input') tags = ['coderesponse'] allowed_inputfields = ['textbox', 'filesubmission', 'matlabinput'] max_inputfields = 1 @@ -2145,6 +2157,7 @@ class ExternalResponse(LoncapaResponse): """ + human_name = _('External Grader') tags = ['externalresponse'] allowed_inputfields = ['textline', 'textbox'] awdmap = { @@ -2302,6 +2315,7 @@ class FormulaResponse(LoncapaResponse): Checking of symbolic math response using numerical sampling. """ + human_name = _('Math Expression Input') tags = ['formularesponse'] hint_tag = 'formulahint' allowed_inputfields = ['textline', 'formulaequationinput'] @@ -2514,6 +2528,7 @@ class SchematicResponse(LoncapaResponse): """ Circuit schematic response type. """ + human_name = _('Circuit Schematic Builder') tags = ['schematicresponse'] allowed_inputfields = ['schematic'] @@ -2592,6 +2607,7 @@ class ImageResponse(LoncapaResponse): True, if click is inside any region or rectangle. Otherwise False. """ + human_name = _('Image Mapped Input') tags = ['imageresponse'] allowed_inputfields = ['imageinput'] @@ -2710,6 +2726,7 @@ class AnnotationResponse(LoncapaResponse): The response contains both a comment (student commentary) and an option (student tag). Only the tag is currently graded. Answers may be incorrect, partially correct, or correct. """ + human_name = _('Annotation Input') tags = ['annotationresponse'] allowed_inputfields = ['annotationinput'] max_inputfields = 1 @@ -2834,6 +2851,7 @@ class ChoiceTextResponse(LoncapaResponse): ChoiceResponse. """ + human_name = _('Checkboxes With Text Input') tags = ['choicetextresponse'] max_inputfields = 1 allowed_inputfields = ['choicetextgroup', diff --git a/common/lib/xmodule/xmodule/library_content_module.py b/common/lib/xmodule/xmodule/library_content_module.py index 0d2886ac9d..c6e728bb8a 100644 --- a/common/lib/xmodule/xmodule/library_content_module.py +++ b/common/lib/xmodule/xmodule/library_content_module.py @@ -5,6 +5,7 @@ LibraryContent: The XBlock used to include blocks from a library in a course. from bson.objectid import ObjectId, InvalidId from collections import namedtuple from copy import copy +from capa.responsetypes import registry from .mako_module import MakoModuleDescriptor from opaque_keys import InvalidKeyError @@ -33,34 +34,18 @@ def enum(**enums): return type('Enum', (), enums) +def _get_human_name(problem_class): + """ + Get the human-friendly name for a problem type. + """ + return getattr(problem_class, 'human_name', problem_class.__name__) + + def _get_capa_types(): """ Gets capa types tags and labels """ - capa_types = { - # basic tab - 'choiceresponse': _('Checkboxes'), - 'optionresponse': _('Dropdown'), - 'multiplechoiceresponse': _('Multiple Choice'), - 'truefalseresponse': _('True/False Choice'), - 'numericalresponse': _('Numerical Input'), - 'stringresponse': _('Text Input'), - - # advanced tab - 'schematicresponse': _('Circuit Schematic Builder'), - 'customresponse': _('Custom Evaluated Script'), - 'imageresponse': _('Image Mapped Input'), - 'formularesponse': _('Math Expression Input'), - 'jsmeresponse': _('Molecular Structure'), - - # not in "Add Component" menu - 'javascriptresponse': _('Javascript Input'), - 'symbolicresponse': _('Symbolic Math Input'), - 'coderesponse': _('Code Input'), - 'externalresponse': _('External Grader'), - 'annotationresponse': _('Annotation Input'), - 'choicetextresponse': _('Checkboxes With Text Input'), - } + capa_types = {tag: _get_human_name(registry.get_class_for_tag(tag)) for tag in registry.registered_tags()} return [{'value': ANY_CAPA_TYPE_VALUE, 'display_name': _('Any Type')}] + sorted([ {'value': capa_type, 'display_name': caption} @@ -429,9 +414,9 @@ class LibraryContentDescriptor(LibraryContentFields, MakoModuleDescriptor, XmlDe validation, StudioValidationMessage( StudioValidationMessage.WARNING, - _(u'There are no content matching configured filters in the selected libraries.'), + _(u'There are no matching problem types in the specified libraries.'), action_class='edit-button', - action_label=_(u"Edit Problem Type Filter") + action_label=_(u"Select another problem type") ) ) @@ -440,10 +425,11 @@ class LibraryContentDescriptor(LibraryContentFields, MakoModuleDescriptor, XmlDe validation, StudioValidationMessage( StudioValidationMessage.WARNING, - _(u'Configured to fetch {count} blocks, library and filter settings yield only {actual} blocks.') + _(u'The specified libraries are configured to fetch {count} problems, ' + u'but there are only {actual} matching problems.') .format(actual=matching_children_count, count=self.max_count), action_class='edit-button', - action_label=_(u"Edit block configuration") + action_label=_(u"Edit configuration") ) ) diff --git a/common/test/acceptance/tests/lms/test_library.py b/common/test/acceptance/tests/lms/test_library.py index cd28f81da2..d152f43386 100644 --- a/common/test/acceptance/tests/lms/test_library.py +++ b/common/test/acceptance/tests/lms/test_library.py @@ -28,7 +28,10 @@ class LibraryContentTestBase(UniqueCourseTest): STAFF_EMAIL = "staff101@example.com" def populate_library_fixture(self, library_fixture): - pass + """ + To be overwritten by subclassed tests. Used to install a library to + run tests on. + """ def setUp(self): """ @@ -207,7 +210,7 @@ class StudioLibraryContainerCapaFilterTest(LibraryContentTestBase): def _get_problem_select_text(self, name, items, correct): """ Generates Select Option CAPA problem XML """ - items_text = ",".join(map(lambda item: "'{0}'".format(item), items)) + items_text = ",".join(["'{0}'".format(item) for item in items]) return """

    {name}

    @@ -281,7 +284,7 @@ class StudioLibraryContainerCapaFilterTest(LibraryContentTestBase): self.assertEqual(len(children_headers), 1) self.assertLessEqual( children_headers, - set(map(lambda header: header.upper(), ["Problem Choice Group 1", "Problem Choice Group 2"])) + set([header.upper() for header in ["Problem Choice Group 1", "Problem Choice Group 2"]]) ) # Choice group test @@ -289,7 +292,7 @@ class StudioLibraryContainerCapaFilterTest(LibraryContentTestBase): self.assertEqual(len(children_headers), 2) self.assertLessEqual( children_headers, - set(map(lambda header: header.upper(), ["Problem Select 1", "Problem Select 2"])) + set([header.upper() for header in ["Problem Select 1", "Problem Select 2"]]) ) # Missing problem type test diff --git a/common/test/acceptance/tests/studio/test_studio_library_container.py b/common/test/acceptance/tests/studio/test_studio_library_container.py index 4634fb09ac..c482ce090f 100644 --- a/common/test/acceptance/tests/studio/test_studio_library_container.py +++ b/common/test/acceptance/tests/studio/test_studio_library_container.py @@ -189,8 +189,7 @@ class StudioLibraryContainerTest(StudioLibraryTest, UniqueCourseTest): When I set Problem Type selector so that there are matching content Then I can see that warning messages are not shown """ - expected_text = 'There are no content matching configured filters in the selected libraries. ' \ - 'Edit Problem Type Filter' + expected_text = 'There are no matching problem types in the specified libraries. Select another problem type' library_container = self._get_library_xblock_wrapper(self.unit_page.xblocks[0]) @@ -223,7 +222,8 @@ class StudioLibraryContainerTest(StudioLibraryTest, UniqueCourseTest): And I set Problem Type selector so "Any" Then I can see that "No matching content" warning is shown """ - expected_tpl = "Configured to fetch {count} blocks, library and filter settings yield only {actual} blocks." + expected_tpl = "The specified libraries are configured to fetch {count} problems, " \ + "but there are only {actual} matching problems." library_container = self._get_library_xblock_wrapper(self.unit_page.xblocks[0]) From f2f363c8f002a528e3fb976acb3e470f884a9357 Mon Sep 17 00:00:00 2001 From: "E. Kolpakov" Date: Mon, 5 Jan 2015 13:57:52 +0300 Subject: [PATCH 46/99] Added reference to TNL ticket mentioned in TODO + improved formatting of XML templates in tests --- .../xmodule/xmodule/library_content_module.py | 4 ++- .../test/acceptance/tests/lms/test_library.py | 27 ++++++++++--------- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/common/lib/xmodule/xmodule/library_content_module.py b/common/lib/xmodule/xmodule/library_content_module.py index c6e728bb8a..fa8341ef11 100644 --- a/common/lib/xmodule/xmodule/library_content_module.py +++ b/common/lib/xmodule/xmodule/library_content_module.py @@ -356,7 +356,9 @@ class LibraryContentDescriptor(LibraryContentFields, MakoModuleDescriptor, XmlDe StudioValidationMessage( StudioValidationMessage.WARNING, _(u'This component is out of date. The library has new content.'), - action_class='library-update-btn', # TODO: change this to action_runtime_event='...' once the unit page supports that feature. + # TODO: change this to action_runtime_event='...' once the unit page supports that feature. + # See https://openedx.atlassian.net/browse/TNL-993 + action_class='library-update-btn', action_label=_(u"↻ Update now") ) ) diff --git a/common/test/acceptance/tests/lms/test_library.py b/common/test/acceptance/tests/lms/test_library.py index d152f43386..91bec16617 100644 --- a/common/test/acceptance/tests/lms/test_library.py +++ b/common/test/acceptance/tests/lms/test_library.py @@ -3,6 +3,7 @@ End-to-end tests for LibraryContent block in LMS """ import ddt +import textwrap from ..helpers import UniqueCourseTest from ...pages.studio.auto_auth import AutoAuthPage @@ -201,23 +202,25 @@ class StudioLibraryContainerCapaFilterTest(LibraryContentTestBase): for item, correct in items ]) - return """ -

    {name}

    - - {items} - -
    """.format(name=name, items=items_text) + return textwrap.dedent(""" + +

    {name}

    + + {items} + +
    """).format(name=name, items=items_text) def _get_problem_select_text(self, name, items, correct): """ Generates Select Option CAPA problem XML """ items_text = ",".join(["'{0}'".format(item) for item in items]) - return """ -

    {name}

    - - - -
    """.format(name=name, options=items_text, correct=correct) + return textwrap.dedent(""" + +

    {name}

    + + + +
    """).format(name=name, options=items_text, correct=correct) def populate_library_fixture(self, library_fixture): """ From 07241aa1de69f81386f33d6a29b00654e90b0a68 Mon Sep 17 00:00:00 2001 From: "E. Kolpakov" Date: Tue, 6 Jan 2015 11:13:10 +0300 Subject: [PATCH 47/99] Addressed review notes: * Added tests for `problem_types` CapaDescriptor property * Improved indentation in tests * Improved validation messages --- common/lib/capa/capa/responsetypes.py | 2 +- .../xmodule/xmodule/library_content_module.py | 27 +++++-- .../xmodule/xmodule/tests/test_capa_module.py | 75 +++++++++++++++---- .../test/acceptance/tests/lms/test_library.py | 2 +- .../studio/test_studio_library_container.py | 13 ++-- 5 files changed, 91 insertions(+), 28 deletions(-) diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index 30d3dc577f..63eff0bc21 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -441,7 +441,7 @@ class JavascriptResponse(LoncapaResponse): Javascript using Node.js. """ - human_name = _('Javascript Input') + human_name = _('JavaScript Input') tags = ['javascriptresponse'] max_inputfields = 1 allowed_inputfields = ['javascriptinput'] diff --git a/common/lib/xmodule/xmodule/library_content_module.py b/common/lib/xmodule/xmodule/library_content_module.py index fa8341ef11..3b624adf90 100644 --- a/common/lib/xmodule/xmodule/library_content_module.py +++ b/common/lib/xmodule/xmodule/library_content_module.py @@ -6,6 +6,7 @@ from bson.objectid import ObjectId, InvalidId from collections import namedtuple from copy import copy from capa.responsetypes import registry +from gettext import ngettext from .mako_module import MakoModuleDescriptor from opaque_keys import InvalidKeyError @@ -359,7 +360,8 @@ class LibraryContentDescriptor(LibraryContentFields, MakoModuleDescriptor, XmlDe # TODO: change this to action_runtime_event='...' once the unit page supports that feature. # See https://openedx.atlassian.net/browse/TNL-993 action_class='library-update-btn', - action_label=_(u"↻ Update now") + # Translators: ↻ is an UTF icon symbol, no need translating it. + action_label=_(u"↻ Update now.") ) ) return False @@ -369,7 +371,7 @@ class LibraryContentDescriptor(LibraryContentFields, MakoModuleDescriptor, XmlDe StudioValidationMessage.ERROR, _(u'Library is invalid, corrupt, or has been deleted.'), action_class='edit-button', - action_label=_(u"Edit Library List") + action_label=_(u"Edit Library List.") ) ) return False @@ -395,7 +397,7 @@ class LibraryContentDescriptor(LibraryContentFields, MakoModuleDescriptor, XmlDe StudioValidationMessage.NOT_CONFIGURED, _(u"A library has not yet been selected."), action_class='edit-button', - action_label=_(u"Select a Library") + action_label=_(u"Select a Library.") ) ) return validation @@ -418,7 +420,7 @@ class LibraryContentDescriptor(LibraryContentFields, MakoModuleDescriptor, XmlDe StudioValidationMessage.WARNING, _(u'There are no matching problem types in the specified libraries.'), action_class='edit-button', - action_label=_(u"Select another problem type") + action_label=_(u"Select another problem type.") ) ) @@ -427,11 +429,20 @@ class LibraryContentDescriptor(LibraryContentFields, MakoModuleDescriptor, XmlDe validation, StudioValidationMessage( StudioValidationMessage.WARNING, - _(u'The specified libraries are configured to fetch {count} problems, ' - u'but there are only {actual} matching problems.') - .format(actual=matching_children_count, count=self.max_count), + ( + ngettext( + u'The specified libraries are configured to fetch {count} problem, ', + u'The specified libraries are configured to fetch {count} problems, ', + self.max_count + ) + + ngettext( + u'but there are only {actual} matching problem.', + u'but there are only {actual} matching problems.', + matching_children_count + ) + ).format(count=self.max_count, actual=matching_children_count), action_class='edit-button', - action_label=_(u"Edit configuration") + action_label=_(u"Edit the library configuration.") ) ) diff --git a/common/lib/xmodule/xmodule/tests/test_capa_module.py b/common/lib/xmodule/xmodule/tests/test_capa_module.py index c829e29654..667aefc993 100644 --- a/common/lib/xmodule/xmodule/tests/test_capa_module.py +++ b/common/lib/xmodule/xmodule/tests/test_capa_module.py @@ -19,10 +19,11 @@ from webob.multidict import MultiDict import xmodule from xmodule.tests import DATA_DIR +from capa import responsetypes from capa.responsetypes import (StudentInputError, LoncapaProblemError, ResponseError) from capa.xqueue_interface import XQueueInterface -from xmodule.capa_module import CapaModule, ComplexEncoder +from xmodule.capa_module import CapaModule, CapaDescriptor, ComplexEncoder from opaque_keys.edx.locations import Location from xblock.field_data import DictFieldData from xblock.fields import ScopeIds @@ -1660,6 +1661,62 @@ class CapaModuleTest(unittest.TestCase): ('answerpool', ['choice_1', 'choice_3', 'choice_2', 'choice_0'])) self.assertEquals(event_info['success'], 'incorrect') +@ddt.ddt +class CapaDescriptorTest(unittest.TestCase): + def _create_descriptor(self, xml): + """ Creates a CapaDescriptor to run test against """ + descriptor = CapaDescriptor(get_test_system(), scope_ids=1) + descriptor.data = xml + return descriptor + + @ddt.data(*responsetypes.registry.registered_tags()) + def test_all_response_types(self, response_tag): + """ Tests that every registered response tag is correctly returned """ + xml = "<{response_tag}>".format(response_tag=response_tag) + descriptor = self._create_descriptor(xml) + self.assertEquals(descriptor.problem_types, {response_tag}) + + def test_response_types_ignores_non_response_tags(self): + xml = textwrap.dedent(""" + +

    Label

    +
    Some comment
    + + + Apple + Banana + Chocolate + Donut + + +
    + """) + descriptor = self._create_descriptor(xml) + self.assertEquals(descriptor.problem_types, {"multiplechoiceresponse"}) + + def test_response_types_multiple_tags(self): + xml = textwrap.dedent(""" + +

    Label

    +
    Some comment
    + + + Donut + + + + + Buggy + + + + + +
    + """) + descriptor = self._create_descriptor(xml) + self.assertEquals(descriptor.problem_types, {"multiplechoiceresponse", "optionresponse"}) + class ComplexEncoderTest(unittest.TestCase): def test_default(self): @@ -1690,18 +1747,10 @@ class TestProblemCheckTracking(unittest.TestCase):

    Which piece of furniture is built for sitting?

    - - a table - - - a desk - - - a chair - - - a bookshelf - + a table + a desk + a chair + a bookshelf

    Which of the following are musical instruments?

    diff --git a/common/test/acceptance/tests/lms/test_library.py b/common/test/acceptance/tests/lms/test_library.py index 91bec16617..cb4fd238de 100644 --- a/common/test/acceptance/tests/lms/test_library.py +++ b/common/test/acceptance/tests/lms/test_library.py @@ -293,7 +293,7 @@ class StudioLibraryContainerCapaFilterTest(LibraryContentTestBase): # Choice group test children_headers = self._set_library_content_settings(count=2, capa_type="Dropdown") self.assertEqual(len(children_headers), 2) - self.assertLessEqual( + self.assertEqual( children_headers, set([header.upper() for header in ["Problem Select 1", "Problem Select 2"]]) ) diff --git a/common/test/acceptance/tests/studio/test_studio_library_container.py b/common/test/acceptance/tests/studio/test_studio_library_container.py index c482ce090f..ac385eb7ce 100644 --- a/common/test/acceptance/tests/studio/test_studio_library_container.py +++ b/common/test/acceptance/tests/studio/test_studio_library_container.py @@ -1,6 +1,7 @@ """ Acceptance tests for Library Content in LMS """ +import textwrap import ddt from .base_studio_test import StudioLibraryTest from ...fixtures.course import CourseFixture @@ -45,11 +46,13 @@ class StudioLibraryContainerTest(StudioLibraryTest, UniqueCourseTest): XBlockFixtureDesc( "problem", "Dropdown", - data=""" - -

    Dropdown

    - -
    """) + data=textwrap.dedent(""" + +

    Dropdown

    + +
    + """) + ) ) def populate_course_fixture(self, course_fixture): From 4e5b6c8bca36932246ea8fae11c75bb48f2e08b6 Mon Sep 17 00:00:00 2001 From: "E. Kolpakov" Date: Tue, 6 Jan 2015 13:55:16 +0300 Subject: [PATCH 48/99] Added problem type filtering related tests. --- .../xmodule/xmodule/tests/test_capa_module.py | 1 + .../xmodule/tests/test_library_content.py | 84 ++++++++++++++++++- 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/tests/test_capa_module.py b/common/lib/xmodule/xmodule/tests/test_capa_module.py index 667aefc993..2e0661dbbe 100644 --- a/common/lib/xmodule/xmodule/tests/test_capa_module.py +++ b/common/lib/xmodule/xmodule/tests/test_capa_module.py @@ -1661,6 +1661,7 @@ class CapaModuleTest(unittest.TestCase): ('answerpool', ['choice_1', 'choice_3', 'choice_2', 'choice_0'])) self.assertEquals(event_info['success'], 'incorrect') + @ddt.ddt class CapaDescriptorTest(unittest.TestCase): def _create_descriptor(self, xml): diff --git a/common/lib/xmodule/xmodule/tests/test_library_content.py b/common/lib/xmodule/xmodule/tests/test_library_content.py index 2b52386e37..0ae8a60c28 100644 --- a/common/lib/xmodule/xmodule/tests/test_library_content.py +++ b/common/lib/xmodule/xmodule/tests/test_library_content.py @@ -5,7 +5,7 @@ Basic unit tests for LibraryContentModule Higher-level tests are in `cms/djangoapps/contentstore/tests/test_libraries.py`. """ import ddt -from xmodule.library_content_module import LibraryVersionReference +from xmodule.library_content_module import LibraryVersionReference, ANY_CAPA_TYPE_VALUE from xmodule.modulestore.tests.factories import LibraryFactory, CourseFactory, ItemFactory from xmodule.modulestore.tests.utils import MixedSplitTestCase from xmodule.tests import get_test_system @@ -80,6 +80,29 @@ class TestLibraries(MixedSplitTestCase): module_system.get_module = get_module module.xmodule_runtime = module_system + def _get_capa_problem_type_xml(self, problem_type): + """ Helper function to create empty CAPA problem definition """ + return "<{problem_type}>".format(problem_type=problem_type) + + def _create_capa_problems(self): + """ Helper function to create two capa problems: multiplechoiceresponse and optionresponse """ + ItemFactory.create( + category="problem", + parent_location=self.library.location, + user_id=self.user_id, + publish_item=False, + data=self._get_capa_problem_type_xml("multiplechoiceresponse"), + modulestore=self.store, + ) + ItemFactory.create( + category="problem", + parent_location=self.library.location, + user_id=self.user_id, + publish_item=False, + data=self._get_capa_problem_type_xml("optionresponse"), + modulestore=self.store, + ) + def test_lib_content_block(self): """ Test that blocks from a library are copied and added as children @@ -140,3 +163,62 @@ class TestLibraries(MixedSplitTestCase): # Now if we update the block, all validation should pass: self.lc_block.refresh_children(None, None) self.assertTrue(self.lc_block.validate()) + + # Set max_count to higher value than exists in library + self.lc_block.max_count = 50 + result = self.lc_block.validate() + self.assertFalse(result) # Validation fails due to at least one warning/message + self.assertTrue(result.summary) + self.assertEqual(StudioValidationMessage.WARNING, result.summary.type) + self.assertIn("only 4 matching problems", result.summary.text) + + # Add some capa problems so we can check problem type validation messages + self.lc_block.max_count = 1 + self._create_capa_problems() + self.lc_block.refresh_children(None, None) + self.assertTrue(self.lc_block.validate()) + + # Existing problem type should pass validation + self.lc_block.max_count = 1 + self.lc_block.capa_type = 'multiplechoiceresponse' + self.assertTrue(self.lc_block.validate()) + + # ... unless requested more blocks than exists in library + self.lc_block.max_count = 3 + self.lc_block.capa_type = 'multiplechoiceresponse' + result = self.lc_block.validate() + self.assertFalse(result) # Validation fails due to at least one warning/message + self.assertTrue(result.summary) + self.assertEqual(StudioValidationMessage.WARNING, result.summary.type) + self.assertIn("only 1 matching problem", result.summary.text) + + # Missing problem type should always fail validation + self.lc_block.max_count = 1 + self.lc_block.capa_type = 'customresponse' + result = self.lc_block.validate() + self.assertFalse(result) # Validation fails due to at least one warning/message + self.assertTrue(result.summary) + self.assertEqual(StudioValidationMessage.WARNING, result.summary.type) + self.assertIn("no matching problem types", result.summary.text) + + def test_capa_type_filtering(self): + """ + Test that the capa type filter is actually filtering children + """ + self._create_capa_problems() + self.assertEqual(len(self.lc_block.children), 0) # precondition check + self.lc_block.capa_type = "multiplechoiceresponse" + self.lc_block.refresh_children(None, None) + self.assertEqual(len(self.lc_block.children), 1) + + self.lc_block.capa_type = "optionresponse" + self.lc_block.refresh_children(None, None) + self.assertEqual(len(self.lc_block.children), 1) + + self.lc_block.capa_type = "customresponse" + self.lc_block.refresh_children(None, None) + self.assertEqual(len(self.lc_block.children), 0) + + self.lc_block.capa_type = ANY_CAPA_TYPE_VALUE + self.lc_block.refresh_children(None, None) + self.assertEqual(len(self.lc_block.children), len(self.lib_blocks) + 2) From 7c11a83faaf3a79703f6d6ab9fa2911f8abe4578 Mon Sep 17 00:00:00 2001 From: Jonathan Piacenti Date: Tue, 6 Jan 2015 21:16:09 +0000 Subject: [PATCH 49/99] Addressed nits. --- common/lib/xmodule/xmodule/library_content_module.py | 6 +++--- common/lib/xmodule/xmodule/library_tools.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/common/lib/xmodule/xmodule/library_content_module.py b/common/lib/xmodule/xmodule/library_content_module.py index 3b624adf90..7918c54ecc 100644 --- a/common/lib/xmodule/xmodule/library_content_module.py +++ b/common/lib/xmodule/xmodule/library_content_module.py @@ -361,7 +361,7 @@ class LibraryContentDescriptor(LibraryContentFields, MakoModuleDescriptor, XmlDe # See https://openedx.atlassian.net/browse/TNL-993 action_class='library-update-btn', # Translators: ↻ is an UTF icon symbol, no need translating it. - action_label=_(u"↻ Update now.") + action_label=_(u"{0} Update now.").format(u"↻") ) ) return False @@ -453,8 +453,8 @@ class LibraryContentDescriptor(LibraryContentFields, MakoModuleDescriptor, XmlDe If source_libraries has been edited, refresh_children automatically. """ old_source_libraries = LibraryList().from_json(old_metadata.get('source_libraries', [])) - if set(old_source_libraries) != set(self.source_libraries) or \ - old_metadata.get('capa_type', ANY_CAPA_TYPE_VALUE) != self.capa_type: + if (set(old_source_libraries) != set(self.source_libraries) or + old_metadata.get('capa_type', ANY_CAPA_TYPE_VALUE) != self.capa_type): try: self.refresh_children(None, None, update_db=False) # update_db=False since update_item() is about to be called anyways except ValueError: diff --git a/common/lib/xmodule/xmodule/library_tools.py b/common/lib/xmodule/xmodule/library_tools.py index ad0d78a35b..94d4e0fdf4 100644 --- a/common/lib/xmodule/xmodule/library_tools.py +++ b/common/lib/xmodule/xmodule/library_tools.py @@ -64,7 +64,7 @@ class LibraryToolsService(object): Returns generator containing (child_key, child) for all children matching filter criteria """ children = ( - (child_key, self.store.get_item(child_key, depth=9)) + (child_key, self.store.get_item(child_key, depth=None)) for child_key in from_block.children ) return ( From d82da9e740bf88a41e49d6a9fcfc766b20f58132 Mon Sep 17 00:00:00 2001 From: Jonathan Piacenti Date: Tue, 6 Jan 2015 21:35:39 +0000 Subject: [PATCH 50/99] Test filter for problems with multiple capa response types. --- .../xmodule/tests/test_library_content.py | 53 +++++++++++-------- 1 file changed, 31 insertions(+), 22 deletions(-) diff --git a/common/lib/xmodule/xmodule/tests/test_library_content.py b/common/lib/xmodule/xmodule/tests/test_library_content.py index 0ae8a60c28..5ab2eaa575 100644 --- a/common/lib/xmodule/xmodule/tests/test_library_content.py +++ b/common/lib/xmodule/xmodule/tests/test_library_content.py @@ -80,28 +80,33 @@ class TestLibraries(MixedSplitTestCase): module_system.get_module = get_module module.xmodule_runtime = module_system - def _get_capa_problem_type_xml(self, problem_type): + def _get_capa_problem_type_xml(self, *args): """ Helper function to create empty CAPA problem definition """ - return "<{problem_type}>".format(problem_type=problem_type) + problem = "" + for problem_type in args: + problem += "<{problem_type}>".format(problem_type=problem_type) + problem += "" + return problem def _create_capa_problems(self): - """ Helper function to create two capa problems: multiplechoiceresponse and optionresponse """ - ItemFactory.create( - category="problem", - parent_location=self.library.location, - user_id=self.user_id, - publish_item=False, - data=self._get_capa_problem_type_xml("multiplechoiceresponse"), - modulestore=self.store, - ) - ItemFactory.create( - category="problem", - parent_location=self.library.location, - user_id=self.user_id, - publish_item=False, - data=self._get_capa_problem_type_xml("optionresponse"), - modulestore=self.store, - ) + """ + Helper function to create a set of capa problems to test against. + + Creates four blocks total. + """ + problem_types = [ + ["multiplechoiceresponse"], ["optionresponse"], ["optionresponse", "coderesponse"], + ["coderesponse", "optionresponse"] + ] + for problem_type in problem_types: + ItemFactory.create( + category="problem", + parent_location=self.library.location, + user_id=self.user_id, + publish_item=False, + data=self._get_capa_problem_type_xml(*problem_type), + modulestore=self.store, + ) def test_lib_content_block(self): """ @@ -184,7 +189,7 @@ class TestLibraries(MixedSplitTestCase): self.assertTrue(self.lc_block.validate()) # ... unless requested more blocks than exists in library - self.lc_block.max_count = 3 + self.lc_block.max_count = 10 self.lc_block.capa_type = 'multiplechoiceresponse' result = self.lc_block.validate() self.assertFalse(result) # Validation fails due to at least one warning/message @@ -213,7 +218,11 @@ class TestLibraries(MixedSplitTestCase): self.lc_block.capa_type = "optionresponse" self.lc_block.refresh_children(None, None) - self.assertEqual(len(self.lc_block.children), 1) + self.assertEqual(len(self.lc_block.children), 3) + + self.lc_block.capa_type = "coderesponse" + self.lc_block.refresh_children(None, None) + self.assertEqual(len(self.lc_block.children), 2) self.lc_block.capa_type = "customresponse" self.lc_block.refresh_children(None, None) @@ -221,4 +230,4 @@ class TestLibraries(MixedSplitTestCase): self.lc_block.capa_type = ANY_CAPA_TYPE_VALUE self.lc_block.refresh_children(None, None) - self.assertEqual(len(self.lc_block.children), len(self.lib_blocks) + 2) + self.assertEqual(len(self.lc_block.children), len(self.lib_blocks) + 4) From 2be036fe0e3ab0e98ce9fb6ec5bbc24368ed9097 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Tue, 6 Jan 2015 23:01:40 -0800 Subject: [PATCH 51/99] Simplifications and changes to reduce conflicts with PR 6399 --- .../xmodule/xmodule/library_content_module.py | 11 ++---- common/lib/xmodule/xmodule/library_tools.py | 38 +++++++------------ .../xmodule/tests/test_library_content.py | 28 +++++++++----- .../studio/test_studio_library_container.py | 23 +++++------ 4 files changed, 48 insertions(+), 52 deletions(-) diff --git a/common/lib/xmodule/xmodule/library_content_module.py b/common/lib/xmodule/xmodule/library_content_module.py index 7918c54ecc..218272de8a 100644 --- a/common/lib/xmodule/xmodule/library_content_module.py +++ b/common/lib/xmodule/xmodule/library_content_module.py @@ -323,7 +323,7 @@ class LibraryContentDescriptor(LibraryContentFields, MakoModuleDescriptor, XmlDe js_module_name = "VerticalDescriptor" @XBlock.handler - def refresh_children(self, request, suffix, update_db=True): # pylint: disable=unused-argument + def refresh_children(self, request=None, suffix=None, update_db=True): # pylint: disable=unused-argument """ Refresh children: This method is to be used when any of the libraries that this block @@ -402,17 +402,12 @@ class LibraryContentDescriptor(LibraryContentFields, MakoModuleDescriptor, XmlDe ) return validation lib_tools = self.runtime.service(self, 'library_tools') - matching_children_count = 0 for library_key, version in self.source_libraries: if not self._validate_library_version(validation, lib_tools, version, library_key): break - library = lib_tools.get_library(library_key) - children_matching_filter = lib_tools.get_filtered_children(library, self.capa_type) - # get_filtered_children returns generator, so can't use len. - # And we don't actually need those children, so no point of constructing a list - matching_children_count += sum(1 for child in children_matching_filter) - + # Note: we assume refresh_children() has been called since the last time fields like source_libraries or capa_types were changed. + matching_children_count = len(self.children) # pylint: disable=no-member if matching_children_count == 0: self._set_validation_error_if_empty( validation, diff --git a/common/lib/xmodule/xmodule/library_tools.py b/common/lib/xmodule/xmodule/library_tools.py index 94d4e0fdf4..f9b9f3edab 100644 --- a/common/lib/xmodule/xmodule/library_tools.py +++ b/common/lib/xmodule/xmodule/library_tools.py @@ -18,7 +18,7 @@ class LibraryToolsService(object): def __init__(self, modulestore): self.store = modulestore - def get_library(self, library_key): + def _get_library(self, library_key): """ Given a library key like "library-v1:ProblemX+PR0B", return the 'library' XBlock with meta-information about the library. @@ -39,39 +39,26 @@ class LibraryToolsService(object): Get the version (an ObjectID) of the given library. Returns None if the library does not exist. """ - library = self.get_library(lib_key) + library = self._get_library(lib_key) if library: # We need to know the library's version so ensure it's set in library.location.library_key.version_guid assert library.location.library_key.version_guid is not None return library.location.library_key.version_guid return None - def _filter_child(self, capa_type, child_descriptor): + def _filter_child(self, usage_key, capa_type): """ Filters children by CAPA problem type, if configured """ if capa_type == ANY_CAPA_TYPE_VALUE: return True - if not isinstance(child_descriptor, CapaDescriptor): + if usage_key.block_type != "problem": return False - return capa_type in child_descriptor.problem_types - - def get_filtered_children(self, from_block, capa_type=ANY_CAPA_TYPE_VALUE): - """ - Filters children of `from_block` that satisfy filter criteria - Returns generator containing (child_key, child) for all children matching filter criteria - """ - children = ( - (child_key, self.store.get_item(child_key, depth=None)) - for child_key in from_block.children - ) - return ( - (child_key, child) - for child_key, child in children - if self._filter_child(capa_type, child) - ) + descriptor = self.store.get_item(usage_key, depth=0) + assert isinstance(descriptor, CapaDescriptor) + return capa_type in descriptor.problem_types def update_children(self, dest_block, user_id, user_perms=None, update_db=True): """ @@ -104,7 +91,7 @@ class LibraryToolsService(object): # First, load and validate the source_libraries: libraries = [] for library_key, old_version in dest_block.source_libraries: # pylint: disable=unused-variable - library = self.get_library(library_key) + library = self._get_library(library_key) if library is None: raise ValueError("Required library not found.") if user_perms and not user_perms.can_read(library_key): @@ -124,9 +111,12 @@ class LibraryToolsService(object): Internal method to copy blocks from the library recursively """ new_children = [] - target_capa_type = dest_block.capa_type if filter_problem_type else ANY_CAPA_TYPE_VALUE - filtered_children = self.get_filtered_children(from_block, target_capa_type) - for child_key, child in filtered_children: + if filter_problem_type: + filtered_children = [key for key in from_block.children if self._filter_child(key, dest_block.capa_type)] + else: + filtered_children = from_block.children + for child_key in filtered_children: + child = self.store.get_item(child_key, depth=None) # We compute a block_id for each matching child block found in the library. # block_ids are unique within any branch, but are not unique per-course or globally. # We need our block_ids to be consistent when content in the library is updated, so diff --git a/common/lib/xmodule/xmodule/tests/test_library_content.py b/common/lib/xmodule/xmodule/tests/test_library_content.py index 5ab2eaa575..fec957d0cd 100644 --- a/common/lib/xmodule/xmodule/tests/test_library_content.py +++ b/common/lib/xmodule/xmodule/tests/test_library_content.py @@ -138,9 +138,10 @@ class TestLibraries(MixedSplitTestCase): # Check that get_content_titles() doesn't return titles for hidden/unused children self.assertEqual(len(self.lc_block.get_content_titles()), 1) - def test_validation(self): + def test_validation_of_course_libraries(self): """ - Test that the validation method of LibraryContent blocks is working. + Test that the validation method of LibraryContent blocks can validate + the source_libraries setting. """ # When source_libraries is blank, the validation summary should say this block needs to be configured: self.lc_block.source_libraries = [] @@ -166,11 +167,17 @@ class TestLibraries(MixedSplitTestCase): self.assertIn("out of date", result.summary.text) # Now if we update the block, all validation should pass: - self.lc_block.refresh_children(None, None) + self.lc_block.refresh_children() self.assertTrue(self.lc_block.validate()) + def test_validation_of_matching_blocks(self): + """ + Test that the validation method of LibraryContent blocks can warn + the user about problems with other settings (max_count and capa_type). + """ # Set max_count to higher value than exists in library self.lc_block.max_count = 50 + self.lc_block.refresh_children() # In the normal studio editing process, editor_saved() calls refresh_children at this point result = self.lc_block.validate() self.assertFalse(result) # Validation fails due to at least one warning/message self.assertTrue(result.summary) @@ -180,17 +187,19 @@ class TestLibraries(MixedSplitTestCase): # Add some capa problems so we can check problem type validation messages self.lc_block.max_count = 1 self._create_capa_problems() - self.lc_block.refresh_children(None, None) + self.lc_block.refresh_children() self.assertTrue(self.lc_block.validate()) # Existing problem type should pass validation self.lc_block.max_count = 1 self.lc_block.capa_type = 'multiplechoiceresponse' + self.lc_block.refresh_children() self.assertTrue(self.lc_block.validate()) # ... unless requested more blocks than exists in library self.lc_block.max_count = 10 self.lc_block.capa_type = 'multiplechoiceresponse' + self.lc_block.refresh_children() result = self.lc_block.validate() self.assertFalse(result) # Validation fails due to at least one warning/message self.assertTrue(result.summary) @@ -200,6 +209,7 @@ class TestLibraries(MixedSplitTestCase): # Missing problem type should always fail validation self.lc_block.max_count = 1 self.lc_block.capa_type = 'customresponse' + self.lc_block.refresh_children() result = self.lc_block.validate() self.assertFalse(result) # Validation fails due to at least one warning/message self.assertTrue(result.summary) @@ -213,21 +223,21 @@ class TestLibraries(MixedSplitTestCase): self._create_capa_problems() self.assertEqual(len(self.lc_block.children), 0) # precondition check self.lc_block.capa_type = "multiplechoiceresponse" - self.lc_block.refresh_children(None, None) + self.lc_block.refresh_children() self.assertEqual(len(self.lc_block.children), 1) self.lc_block.capa_type = "optionresponse" - self.lc_block.refresh_children(None, None) + self.lc_block.refresh_children() self.assertEqual(len(self.lc_block.children), 3) self.lc_block.capa_type = "coderesponse" - self.lc_block.refresh_children(None, None) + self.lc_block.refresh_children() self.assertEqual(len(self.lc_block.children), 2) self.lc_block.capa_type = "customresponse" - self.lc_block.refresh_children(None, None) + self.lc_block.refresh_children() self.assertEqual(len(self.lc_block.children), 0) self.lc_block.capa_type = ANY_CAPA_TYPE_VALUE - self.lc_block.refresh_children(None, None) + self.lc_block.refresh_children() self.assertEqual(len(self.lc_block.children), len(self.lib_blocks) + 4) diff --git a/common/test/acceptance/tests/studio/test_studio_library_container.py b/common/test/acceptance/tests/studio/test_studio_library_container.py index ac385eb7ce..ce04643745 100644 --- a/common/test/acceptance/tests/studio/test_studio_library_container.py +++ b/common/test/acceptance/tests/studio/test_studio_library_container.py @@ -43,16 +43,6 @@ class StudioLibraryContainerTest(StudioLibraryTest, UniqueCourseTest): XBlockFixtureDesc("html", "Html1"), XBlockFixtureDesc("html", "Html2"), XBlockFixtureDesc("html", "Html3"), - - XBlockFixtureDesc( - "problem", "Dropdown", - data=textwrap.dedent(""" - -

    Dropdown

    - -
    - """) - ) ) def populate_course_fixture(self, course_fixture): @@ -189,9 +179,20 @@ class StudioLibraryContainerTest(StudioLibraryTest, UniqueCourseTest): When I go to studio unit page for library content block And I set Problem Type selector so that no libraries have matching content Then I can see that "No matching content" warning is shown - When I set Problem Type selector so that there are matching content + When I set Problem Type selector so that there is matching content Then I can see that warning messages are not shown """ + # Add a single "Dropdown" type problem to the library (which otherwise has only HTML blocks): + self.library_fixture.create_xblock(self.library_fixture.library_location, XBlockFixtureDesc( + "problem", "Dropdown", + data=textwrap.dedent(""" + +

    Dropdown

    + +
    + """) + )) + expected_text = 'There are no matching problem types in the specified libraries. Select another problem type' library_container = self._get_library_xblock_wrapper(self.unit_page.xblocks[0]) From 21b02544c06912a98084a2580f8c41b3612262a3 Mon Sep 17 00:00:00 2001 From: "E. Kolpakov" Date: Wed, 7 Jan 2015 14:01:00 +0300 Subject: [PATCH 52/99] Added tests for `editor_saved` library content xblock method Retriggering Jenkins --- .../contentstore/tests/test_libraries.py | 93 +++++++++++++++++++ .../xmodule/xmodule/library_content_module.py | 6 +- 2 files changed, 96 insertions(+), 3 deletions(-) diff --git a/cms/djangoapps/contentstore/tests/test_libraries.py b/cms/djangoapps/contentstore/tests/test_libraries.py index c6fdc00301..bc8975eb5d 100644 --- a/cms/djangoapps/contentstore/tests/test_libraries.py +++ b/cms/djangoapps/contentstore/tests/test_libraries.py @@ -326,6 +326,99 @@ class TestLibraries(LibraryTestCase): html_block = modulestore().get_item(lc_block.children[0]) self.assertEqual(html_block.data, data_value) + def test_refreshes_children_if_libraries_change(self): + library2key = self._create_library("org2", "lib2", "Library2") + library2 = modulestore().get_library(library2key) + data1, data2 = "Hello world!", "Hello other world!" + ItemFactory.create( + category="html", + parent_location=self.library.location, + user_id=self.user.id, + publish_item=False, + display_name="Lib1: HTML BLock", + data=data1, + ) + + ItemFactory.create( + category="html", + parent_location=library2.location, + user_id=self.user.id, + publish_item=False, + display_name="Lib 2: HTML BLock", + data=data2, + ) + + # 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) + + # Now, change the block settings to have an invalid library key: + resp = self._update_item( + lc_block.location, + {"source_libraries": [[str(library2key)]]}, + ) + self.assertEqual(resp.status_code, 200) + lc_block = modulestore().get_item(lc_block.location) + + self.assertEqual(len(lc_block.children), 1) + html_block = modulestore().get_item(lc_block.children[0]) + self.assertEqual(html_block.data, data2) + + def test_refreshes_children_if_capa_type_change(self): + name1, name2 = "Option Problem", "Multiple Choice Problem" + ItemFactory.create( + category="problem", + parent_location=self.library.location, + user_id=self.user.id, + publish_item=False, + display_name=name1, + data="", + ) + ItemFactory.create( + category="problem", + parent_location=self.library.location, + user_id=self.user.id, + publish_item=False, + display_name=name2, + data="", + ) + + # 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), 2) + + resp = self._update_item( + lc_block.location, + {"capa_type": 'optionresponse'}, + ) + self.assertEqual(resp.status_code, 200) + lc_block = modulestore().get_item(lc_block.location) + + self.assertEqual(len(lc_block.children), 1) + html_block = modulestore().get_item(lc_block.children[0]) + self.assertEqual(html_block.display_name, name1) + + resp = self._update_item( + lc_block.location, + {"capa_type": 'multiplechoiceresponse'}, + ) + self.assertEqual(resp.status_code, 200) + lc_block = modulestore().get_item(lc_block.location) + + self.assertEqual(len(lc_block.children), 1) + html_block = modulestore().get_item(lc_block.children[0]) + self.assertEqual(html_block.display_name, name2) + @ddt.ddt class TestLibraryAccess(LibraryTestCase): diff --git a/common/lib/xmodule/xmodule/library_content_module.py b/common/lib/xmodule/xmodule/library_content_module.py index 218272de8a..1b68e27a59 100644 --- a/common/lib/xmodule/xmodule/library_content_module.py +++ b/common/lib/xmodule/xmodule/library_content_module.py @@ -360,8 +360,8 @@ class LibraryContentDescriptor(LibraryContentFields, MakoModuleDescriptor, XmlDe # TODO: change this to action_runtime_event='...' once the unit page supports that feature. # See https://openedx.atlassian.net/browse/TNL-993 action_class='library-update-btn', - # Translators: ↻ is an UTF icon symbol, no need translating it. - action_label=_(u"{0} Update now.").format(u"↻") + # Translators: {refresh_icon} placeholder is substituted to "↻" (without double quotes) + action_label=_(u"{refresh_icon} Update now.").format(refresh_icon=u"↻") ) ) return False @@ -445,7 +445,7 @@ class LibraryContentDescriptor(LibraryContentFields, MakoModuleDescriptor, XmlDe def editor_saved(self, user, old_metadata, old_content): """ - If source_libraries has been edited, refresh_children automatically. + If source_libraries or capa_type has been edited, refresh_children automatically. """ old_source_libraries = LibraryList().from_json(old_metadata.get('source_libraries', [])) if (set(old_source_libraries) != set(self.source_libraries) or From 3857a1c1ee34ca25c4a6f96070df50ffbcc1a2f5 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Wed, 29 Oct 2014 22:55:17 -0700 Subject: [PATCH 53/99] Support for overriding Scope.settings values when library content is used in a course --- .../contentstore/tests/test_libraries.py | 127 +++++++++++++++ .../xmodule/xmodule/library_content_module.py | 9 +- common/lib/xmodule/xmodule/library_tools.py | 96 +++-------- .../xmodule/modulestore/inheritance.py | 4 +- .../lib/xmodule/xmodule/modulestore/mixed.py | 8 + .../split_mongo/caching_descriptor_system.py | 15 +- .../xmodule/modulestore/split_mongo/split.py | 150 +++++++++++++++++- .../modulestore/split_mongo/split_draft.py | 20 +++ .../split_mongo/split_mongo_kvs.py | 15 +- .../modulestore/tests/test_libraries.py | 144 ++++++++++++++++- .../xmodule/tests/test_library_content.py | 5 +- common/lib/xmodule/xmodule/x_module.py | 9 +- 12 files changed, 495 insertions(+), 107 deletions(-) diff --git a/cms/djangoapps/contentstore/tests/test_libraries.py b/cms/djangoapps/contentstore/tests/test_libraries.py index bc8975eb5d..a3414266bd 100644 --- a/cms/djangoapps/contentstore/tests/test_libraries.py +++ b/cms/djangoapps/contentstore/tests/test_libraries.py @@ -683,3 +683,130 @@ class TestLibraryAccess(LibraryTestCase): self._bind_module(lc_block, user=self.non_staff_user) # We must use the CMS's module system in order to get permissions checks. lc_block = self._refresh_children(lc_block, status_code_expected=200 if expected_result else 403) self.assertEqual(len(lc_block.children), 1 if expected_result else 0) + + +class TestOverrides(LibraryTestCase): + """ + Test that overriding block Scope.settings fields from a library in a specific course works + """ + def setUp(self): + super(TestOverrides, self).setUp() + self.original_display_name = "A Problem Block" + self.original_weight = 1 + + # Create a problem block in the library: + self.problem = ItemFactory.create( + category="problem", + parent_location=self.library.location, + display_name=self.original_display_name, # display_name is a Scope.settings field + weight=self.original_weight, # weight is also a Scope.settings field + user_id=self.user.id, + publish_item=False, + ) + + # Also create a course: + with modulestore().default_store(ModuleStoreEnum.Type.split): + self.course = CourseFactory.create() + + # Add a LibraryContent block to the course: + self.lc_block = self._add_library_content_block(self.course, self.lib_key) + self.lc_block = self._refresh_children(self.lc_block) + self.problem_in_course = modulestore().get_item(self.lc_block.children[0]) + + def test_overrides(self): + """ + Test that we can override Scope.settings values in a course. + """ + new_display_name = "Modified Problem Title" + new_weight = 10 + self.problem_in_course.display_name = new_display_name + self.problem_in_course.weight = new_weight + modulestore().update_item(self.problem_in_course, self.user.id) + + # Add a second LibraryContent block to the course, with no override: + lc_block2 = self._add_library_content_block(self.course, self.lib_key) + lc_block2 = self._refresh_children(lc_block2) + # Re-load the two problem blocks - one with and one without an override: + self.problem_in_course = modulestore().get_item(self.lc_block.children[0]) + problem2_in_course = modulestore().get_item(lc_block2.children[0]) + + self.assertEqual(self.problem_in_course.display_name, new_display_name) + self.assertEqual(self.problem_in_course.weight, new_weight) + + self.assertEqual(problem2_in_course.display_name, self.original_display_name) + self.assertEqual(problem2_in_course.weight, self.original_weight) + + def test_reset_override(self): + """ + If we override a setting and then reset it, we should get the library value. + """ + new_display_name = "Modified Problem Title" + new_weight = 10 + self.problem_in_course.display_name = new_display_name + self.problem_in_course.weight = new_weight + modulestore().update_item(self.problem_in_course, self.user.id) + self.problem_in_course = modulestore().get_item(self.problem_in_course.location) + + self.assertEqual(self.problem_in_course.display_name, new_display_name) + self.assertEqual(self.problem_in_course.weight, new_weight) + + # Reset: + for field_name in ["display_name", "weight"]: + self.problem_in_course.fields[field_name].delete_from(self.problem_in_course) + + # Save, reload, and verify: + modulestore().update_item(self.problem_in_course, self.user.id) + self.problem_in_course = modulestore().get_item(self.problem_in_course.location) + + self.assertEqual(self.problem_in_course.display_name, self.original_display_name) + self.assertEqual(self.problem_in_course.weight, self.original_weight) + + def test_consistent_definitions(self): + """ + Make sure that the new child of the LibraryContent block + shares its definition with the original (self.problem). + + This test is specific to split mongo. + """ + definition_id = self.problem.definition_locator.definition_id + self.assertEqual(self.problem_in_course.definition_locator.definition_id, definition_id) + + # Now even if we change some Scope.settings fields and refresh, the definition should be unchanged + self.problem.weight = 20 + self.problem.display_name = "NEW" + modulestore().update_item(self.problem, self.user.id) + self.lc_block = self._refresh_children(self.lc_block) + self.problem_in_course = modulestore().get_item(self.problem_in_course.location) + + self.assertEqual(self.problem.definition_locator.definition_id, definition_id) + self.assertEqual(self.problem_in_course.definition_locator.definition_id, definition_id) + + def test_persistent_overrides(self): + """ + Test that when we override Scope.settings values in a course, + the override values persist even when the block is refreshed + with updated blocks from the library. + """ + new_display_name = "Modified Problem Title" + new_weight = 15 + self.problem_in_course.display_name = new_display_name + self.problem_in_course.weight = new_weight + + modulestore().update_item(self.problem_in_course, self.user.id) + self.problem_in_course = modulestore().get_item(self.problem_in_course.location) + self.assertEqual(self.problem_in_course.display_name, new_display_name) + self.assertEqual(self.problem_in_course.weight, new_weight) + + # Change the settings in the library version: + self.problem.display_name = "X" + self.problem.weight = 99 + new_data_value = "

    We change the data as well to check that non-overriden fields do get updated.

    " + self.problem.data = new_data_value + modulestore().update_item(self.problem, self.user.id) + + self.lc_block = self._refresh_children(self.lc_block) + self.problem_in_course = modulestore().get_item(self.problem_in_course.location) + + self.assertEqual(self.problem_in_course.display_name, new_display_name) + self.assertEqual(self.problem_in_course.weight, new_weight) + self.assertEqual(self.problem_in_course.data, new_data_value) diff --git a/common/lib/xmodule/xmodule/library_content_module.py b/common/lib/xmodule/xmodule/library_content_module.py index 1b68e27a59..9cedc5bbff 100644 --- a/common/lib/xmodule/xmodule/library_content_module.py +++ b/common/lib/xmodule/xmodule/library_content_module.py @@ -323,7 +323,7 @@ class LibraryContentDescriptor(LibraryContentFields, MakoModuleDescriptor, XmlDe js_module_name = "VerticalDescriptor" @XBlock.handler - def refresh_children(self, request=None, suffix=None, update_db=True): # pylint: disable=unused-argument + def refresh_children(self, request=None, suffix=None): # pylint: disable=unused-argument """ Refresh children: This method is to be used when any of the libraries that this block @@ -335,15 +335,12 @@ class LibraryContentDescriptor(LibraryContentFields, MakoModuleDescriptor, XmlDe This method will update this block's 'source_libraries' field to store the version number of the libraries used, so we easily determine if this block is up to date or not. - - If update_db is True (default), this will explicitly persist the changes - to the modulestore by calling update_item() """ lib_tools = self.runtime.service(self, 'library_tools') user_service = self.runtime.service(self, 'user') user_perms = self.runtime.service(self, 'studio_user_permissions') user_id = user_service.user_id if user_service else None # May be None when creating bok choy test fixtures - lib_tools.update_children(self, user_id, user_perms, update_db) + lib_tools.update_children(self, user_id, user_perms) return Response() def _validate_library_version(self, validation, lib_tools, version, library_key): @@ -451,7 +448,7 @@ class LibraryContentDescriptor(LibraryContentFields, MakoModuleDescriptor, XmlDe if (set(old_source_libraries) != set(self.source_libraries) or old_metadata.get('capa_type', ANY_CAPA_TYPE_VALUE) != self.capa_type): try: - self.refresh_children(None, None, update_db=False) # update_db=False since update_item() is about to be called anyways + self.refresh_children() except ValueError: pass # The validation area will display an error message, no need to do anything now. diff --git a/common/lib/xmodule/xmodule/library_tools.py b/common/lib/xmodule/xmodule/library_tools.py index f9b9f3edab..4bdf51bdce 100644 --- a/common/lib/xmodule/xmodule/library_tools.py +++ b/common/lib/xmodule/xmodule/library_tools.py @@ -1,10 +1,8 @@ """ XBlock runtime services for LibraryContentModule """ -import hashlib from django.core.exceptions import PermissionDenied from opaque_keys.edx.locator import LibraryLocator -from xblock.fields import Scope from xmodule.library_content_module import LibraryVersionReference, ANY_CAPA_TYPE_VALUE from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule.capa_module import CapaDescriptor @@ -60,7 +58,7 @@ class LibraryToolsService(object): assert isinstance(descriptor, CapaDescriptor) return capa_type in descriptor.problem_types - def update_children(self, dest_block, user_id, user_perms=None, update_db=True): + def update_children(self, dest_block, user_id, user_perms=None): """ This method is to be used when any of the libraries that a LibraryContentModule references have been updated. It will re-fetch all matching blocks from @@ -71,82 +69,28 @@ class LibraryToolsService(object): This method will update dest_block's 'source_libraries' field to store the version number of the libraries used, so we easily determine if dest_block is up to date or not. - - If update_db is True (default), this will explicitly persist the changes - to the modulestore by calling update_item(). Only set update_db False if - you know for sure that dest_block is about to be saved to the modulestore - anyways. Otherwise, orphaned blocks may be created. """ - root_children = [] if user_perms and not user_perms.can_write(dest_block.location.course_key): raise PermissionDenied() + new_libraries = [] + source_blocks = [] + for library_key, __ in dest_block.source_libraries: + library = self._get_library(library_key) + if library is None: + raise ValueError("Required library not found.") + if user_perms and not user_perms.can_read(library_key): + raise PermissionDenied() + filter_children = (dest_block.capa_type != ANY_CAPA_TYPE_VALUE) + if filter_children: + # Apply simple filtering based on CAPA problem types: + source_blocks.extend([key for key in library.children if self._filter_child(key, dest_block.capa_type)]) + else: + source_blocks.extend(library.children) + new_libraries.append(LibraryVersionReference(library_key, library.location.library_key.version_guid)) + with self.store.bulk_operations(dest_block.location.course_key): - # Currently, ALL children are essentially deleted and then re-added - # in a way that preserves their block_ids (and thus should preserve - # student data, grades, analytics, etc.) - # Once course-level field overrides are implemented, this will - # change to a more conservative implementation. - - # First, load and validate the source_libraries: - libraries = [] - for library_key, old_version in dest_block.source_libraries: # pylint: disable=unused-variable - library = self._get_library(library_key) - if library is None: - raise ValueError("Required library not found.") - if user_perms and not user_perms.can_read(library_key): - raise PermissionDenied() - libraries.append((library_key, library)) - - # Next, delete all our existing children to avoid block_id conflicts when we add them: - for child in dest_block.children: - self.store.delete_item(child, user_id) - - # Now add all matching children, and record the library version we use: - new_libraries = [] - for library_key, library in libraries: - - def copy_children_recursively(from_block, filter_problem_type=False): - """ - Internal method to copy blocks from the library recursively - """ - new_children = [] - if filter_problem_type: - filtered_children = [key for key in from_block.children if self._filter_child(key, dest_block.capa_type)] - else: - filtered_children = from_block.children - for child_key in filtered_children: - child = self.store.get_item(child_key, depth=None) - # We compute a block_id for each matching child block found in the library. - # block_ids are unique within any branch, but are not unique per-course or globally. - # We need our block_ids to be consistent when content in the library is updated, so - # we compute block_id as a hash of three pieces of data: - unique_data = "{}:{}:{}".format( - dest_block.location.block_id, # Must not clash with other usages of the same library in this course - unicode(library_key.for_version(None)).encode("utf-8"), # The block ID below is only unique within a library, so we need this too - child_key.block_id, # Child block ID. Should not change even if the block is edited. - ) - child_block_id = hashlib.sha1(unique_data).hexdigest()[:20] - fields = {} - for field in child.fields.itervalues(): - if field.scope == Scope.settings and field.is_set_on(child): - fields[field.name] = field.read_from(child) - if child.has_children: - fields['children'] = copy_children_recursively(from_block=child) - new_child_info = self.store.create_item( - user_id, - dest_block.location.course_key, - child_key.block_type, - block_id=child_block_id, - definition_locator=child.definition_locator, - runtime=dest_block.system, - fields=fields, - ) - new_children.append(new_child_info.location) - return new_children - root_children.extend(copy_children_recursively(from_block=library, filter_problem_type=True)) - new_libraries.append(LibraryVersionReference(library_key, library.location.library_key.version_guid)) dest_block.source_libraries = new_libraries - dest_block.children = root_children - if update_db: - self.store.update_item(dest_block, user_id) + self.store.update_item(dest_block, user_id) + dest_block.children = self.store.copy_from_template(source_blocks, dest_block.location, user_id) + # ^-- copy_from_template updates the children in the DB but we must also set .children here to avoid overwriting the DB again diff --git a/common/lib/xmodule/xmodule/modulestore/inheritance.py b/common/lib/xmodule/xmodule/modulestore/inheritance.py index 296fdb80ca..c0e3e1b7f1 100644 --- a/common/lib/xmodule/xmodule/modulestore/inheritance.py +++ b/common/lib/xmodule/xmodule/modulestore/inheritance.py @@ -283,6 +283,8 @@ class InheritanceKeyValueStore(KeyValueStore): def default(self, key): """ - Check to see if the default should be from inheritance rather than from the field's global default + Check to see if the default should be from inheritance. If not + inheriting, this will raise KeyError which will cause the caller to use + the field's global default. """ return self.inherited_settings[key.field_name] diff --git a/common/lib/xmodule/xmodule/modulestore/mixed.py b/common/lib/xmodule/xmodule/modulestore/mixed.py index 24e68ea143..6175427659 100644 --- a/common/lib/xmodule/xmodule/modulestore/mixed.py +++ b/common/lib/xmodule/xmodule/modulestore/mixed.py @@ -676,6 +676,14 @@ class MixedModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase): store = self._verify_modulestore_support(course_key, 'import_xblock') return store.import_xblock(user_id, course_key, block_type, block_id, fields, runtime) + @strip_key + def copy_from_template(self, source_keys, dest_key, user_id, **kwargs): + """ + See :py:meth `SplitMongoModuleStore.copy_from_template` + """ + store = self._verify_modulestore_support(dest_key.course_key, 'copy_from_template') + return store.copy_from_template(source_keys, dest_key, user_id) + @strip_key def update_item(self, xblock, user_id, allow_not_found=False, **kwargs): """ diff --git a/common/lib/xmodule/xmodule/modulestore/split_mongo/caching_descriptor_system.py b/common/lib/xmodule/xmodule/modulestore/split_mongo/caching_descriptor_system.py index 3c357c8751..73a950b355 100644 --- a/common/lib/xmodule/xmodule/modulestore/split_mongo/caching_descriptor_system.py +++ b/common/lib/xmodule/xmodule/modulestore/split_mongo/caching_descriptor_system.py @@ -169,16 +169,17 @@ class CachingDescriptorSystem(MakoDescriptorSystem, EditInfoRuntimeMixin): if block_key is None: block_key = BlockKey(json_data['block_type'], LocalId()) + convert_fields = lambda field: self.modulestore.convert_references_to_keys( + course_key, class_, field, self.course_entry.structure['blocks'], + ) + if definition_id is not None and not json_data.get('definition_loaded', False): definition_loader = DefinitionLazyLoader( self.modulestore, course_key, block_key.type, definition_id, - lambda fields: self.modulestore.convert_references_to_keys( - course_key, self.load_block_type(block_key.type), - fields, self.course_entry.structure['blocks'], - ) + convert_fields, ) else: definition_loader = None @@ -193,9 +194,8 @@ class CachingDescriptorSystem(MakoDescriptorSystem, EditInfoRuntimeMixin): block_id=block_key.id, ) - converted_fields = self.modulestore.convert_references_to_keys( - block_locator.course_key, class_, json_data.get('fields', {}), self.course_entry.structure['blocks'], - ) + converted_fields = convert_fields(json_data.get('fields', {})) + converted_defaults = convert_fields(json_data.get('defaults', {})) if block_key in self._parent_map: parent_key = self._parent_map[block_key] parent = course_key.make_usage_key(parent_key.type, parent_key.id) @@ -204,6 +204,7 @@ class CachingDescriptorSystem(MakoDescriptorSystem, EditInfoRuntimeMixin): kvs = SplitMongoKVS( definition_loader, converted_fields, + converted_defaults, parent=parent, field_decorator=kwargs.get('field_decorator') ) diff --git a/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py b/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py index 5cb62bcf57..3d809f7981 100644 --- a/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py +++ b/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py @@ -27,6 +27,8 @@ Representation: **** 'definition': the db id of the record containing the content payload for this xblock **** 'fields': the Scope.settings and children field values ***** 'children': This is stored as a list of (block_type, block_id) pairs + **** 'defaults': Scope.settings default values copied from a template block (used e.g. when + blocks are copied from a library to a course) **** 'edit_info': dictionary: ***** 'edited_on': when was this xblock's fields last changed (will be edited_on value of update_version structure) @@ -53,6 +55,7 @@ Representation: import copy import threading import datetime +import hashlib import logging from contracts import contract, new_contract from importlib import import_module @@ -691,12 +694,9 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase): for block in new_module_data.itervalues(): if block['definition'] in definitions: - converted_fields = self.convert_references_to_keys( - course_key, system.load_block_type(block['block_type']), - definitions[block['definition']].get('fields'), - system.course_entry.structure['blocks'], - ) - block['fields'].update(converted_fields) + definition = definitions[block['definition']] + # convert_fields was being done here, but it gets done later in the runtime's xblock_from_json + block['fields'].update(definition.get('fields')) block['definition_loaded'] = True system.module_data.update(new_module_data) @@ -2071,6 +2071,144 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase): self.update_structure(destination_course, destination_structure) self._update_head(destination_course, index_entry, destination_course.branch, destination_structure['_id']) + @contract(source_keys="list(BlockUsageLocator)", dest_usage=BlockUsageLocator) + def copy_from_template(self, source_keys, dest_usage, user_id): + """ + Flexible mechanism for inheriting content from an external course/library/etc. + + Will copy all of the XBlocks whose keys are passed as `source_course` so that they become + children of the XBlock whose key is `dest_usage`. Any previously existing children of + `dest_usage` that haven't been replaced/updated by this copy_from_template operation will + be deleted. + + Unlike `copy()`, this does not care whether the resulting blocks are positioned similarly + in their new course/library. However, the resulting blocks will be in the same relative + order as `source_keys`. + + If any of the blocks specified already exist as children of the destination block, they + will be updated rather than duplicated or replaced. If they have Scope.settings field values + overriding inherited default values, those overrides will be preserved. + + IMPORTANT: This method does not preserve block_id - in other words, every block that is + copied will be assigned a new block_id. This is because we assume that the same source block + may be copied into one course in multiple places. However, it *is* guaranteed that every + time this method is called for the same source block and dest_usage, the same resulting + block id will be generated. + + :param source_keys: a list of BlockUsageLocators. Order is preserved. + + :param dest_usage: The BlockUsageLocator that will become the parent of an inherited copy + of all the xblocks passed in `source_keys`. + + :param user_id: The user who will get credit for making this change. + """ + # Preload the block structures for all source courses/libraries/etc. + # so that we can access descendant information quickly + source_structures = {} + for key in source_keys: + course_key = key.course_key.for_version(None) + if course_key.branch is None: + raise ItemNotFoundError("branch is required for all source keys when using copy_from_template") + if course_key not in source_structures: + with self.bulk_operations(course_key): + source_structures[course_key] = self._lookup_course(course_key).structure + + destination_course = dest_usage.course_key + with self.bulk_operations(destination_course): + index_entry = self.get_course_index(destination_course) + if index_entry is None: + raise ItemNotFoundError(destination_course) + dest_structure = self._lookup_course(destination_course).structure + old_dest_structure_version = dest_structure['_id'] + dest_structure = self.version_structure(destination_course, dest_structure, user_id) + + # Set of all descendent block IDs of dest_usage that are to be replaced: + block_key = BlockKey(dest_usage.block_type, dest_usage.block_id) + orig_descendants = set(self.descendants(dest_structure['blocks'], block_key, depth=None, descendent_map={})) + orig_descendants.remove(block_key) # The descendants() method used above adds the block itself, which we don't consider a descendant. + new_descendants = self._copy_from_template(source_structures, source_keys, dest_structure, block_key, user_id) + + # Update the edit info: + dest_info = dest_structure['blocks'][block_key] + + # Update the edit_info: + dest_info['edit_info']['previous_version'] = dest_info['edit_info']['update_version'] + dest_info['edit_info']['update_version'] = old_dest_structure_version + dest_info['edit_info']['edited_by'] = user_id + dest_info['edit_info']['edited_on'] = datetime.datetime.now(UTC) + + orphans = orig_descendants - new_descendants + for orphan in orphans: + del dest_structure['blocks'][orphan] + + self.update_structure(destination_course, dest_structure) + self._update_head(destination_course, index_entry, destination_course.branch, dest_structure['_id']) + # Return usage locators for all the new children: + return [destination_course.make_usage_key(*k) for k in dest_structure['blocks'][block_key]['fields']['children']] + + def _copy_from_template(self, source_structures, source_keys, dest_structure, new_parent_block_key, user_id): + """ + Internal recursive implementation of copy_from_template() + + Returns the new set of BlockKeys that are the new descendants of the block with key 'block_key' + """ + # pylint: disable=no-member + # ^-- Until pylint gets namedtuple support, it will give warnings about BlockKey attributes + new_blocks = set() + + new_children = list() # ordered list of the new children of new_parent_block_key + + for usage_key in source_keys: + src_course_key = usage_key.course_key.for_version(None) + block_key = BlockKey(usage_key.block_type, usage_key.block_id) + source_structure = source_structures.get(src_course_key, []) + if block_key not in source_structure['blocks']: + raise ItemNotFoundError(usage_key) + source_block_info = source_structure['blocks'][block_key] + + # Compute a new block ID. This new block ID must be consistent when this + # method is called with the same (source_key, dest_structure) pair + unique_data = "{}:{}:{}".format( + unicode(src_course_key).encode("utf-8"), + block_key.id, + new_parent_block_key.id, + ) + new_block_id = hashlib.sha1(unique_data).hexdigest()[:20] + new_block_key = BlockKey(block_key.type, new_block_id) + + # Now clone block_key to new_block_key: + new_block_info = copy.deepcopy(source_block_info) + # Note that new_block_info now points to the same definition ID entry as source_block_info did + existing_block_info = dest_structure['blocks'].get(new_block_key, {}) + # Inherit the Scope.settings values from 'fields' to 'defaults' + new_block_info['defaults'] = new_block_info['fields'] + new_block_info['fields'] = existing_block_info.get('fields', {}) # Preserve any existing overrides + if 'children' in new_block_info['defaults']: + del new_block_info['defaults']['children'] # Will be set later + new_block_info['block_id'] = new_block_key.id + new_block_info['edit_info'] = existing_block_info.get('edit_info', {}) + new_block_info['edit_info']['previous_version'] = new_block_info['edit_info'].get('update_version', None) + new_block_info['edit_info']['update_version'] = dest_structure['_id'] + # Note we do not set 'source_version' - it's only used for copying identical blocks from draft to published as part of publishing workflow. + # Setting it to the source_block_info structure version here breaks split_draft's has_changes() method. + new_block_info['edit_info']['edited_by'] = user_id + new_block_info['edit_info']['edited_on'] = datetime.datetime.now(UTC) + dest_structure['blocks'][new_block_key] = new_block_info + + children = source_block_info['fields'].get('children') + if children: + children = [src_course_key.make_usage_key(child.type, child.id) for child in children] + new_blocks |= self._copy_from_template(source_structures, children, dest_structure, new_block_key, user_id) + + new_blocks.add(new_block_key) + # And add new_block_key to the list of new_parent_block_key's new children: + new_children.append(new_block_key) + + # Update the children of new_parent_block_key + dest_structure['blocks'][new_parent_block_key]['fields']['children'] = new_children + + return new_blocks + def delete_item(self, usage_locator, user_id, force=False): """ Delete the block or tree rooted at block (if delete_children) and any references w/in the course to the block 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 8870c89ee6..c651512c82 100644 --- a/common/lib/xmodule/xmodule/modulestore/split_mongo/split_draft.py +++ b/common/lib/xmodule/xmodule/modulestore/split_mongo/split_draft.py @@ -93,6 +93,26 @@ class DraftVersioningModuleStore(SplitMongoModuleStore, ModuleStoreDraftAndPubli # version_agnostic b/c of above assumption in docstring self.publish(location.version_agnostic(), user_id, blacklist=EXCLUDE_ALL, **kwargs) + def copy_from_template(self, source_keys, dest_key, user_id, **kwargs): + """ + See :py:meth `SplitMongoModuleStore.copy_from_template` + """ + source_keys = [self._map_revision_to_branch(key) for key in source_keys] + dest_key = self._map_revision_to_branch(dest_key) + new_keys = super(DraftVersioningModuleStore, self).copy_from_template(source_keys, dest_key, user_id) + if dest_key.branch == ModuleStoreEnum.BranchName.draft: + # Check if any of new_keys or their descendants need to be auto-published. + # We don't use _auto_publish_no_children since children may need to be published. + with self.bulk_operations(dest_key.course_key): + keys_to_check = list(new_keys) + while keys_to_check: + usage_key = keys_to_check.pop() + if usage_key.category in DIRECT_ONLY_CATEGORIES: + self.publish(usage_key.version_agnostic(), user_id, blacklist=EXCLUDE_ALL, **kwargs) + children = getattr(self.get_item(usage_key, **kwargs), "children", []) + keys_to_check.extend(children) # e.g. if usage_key is a chapter, it may have an auto-publish sequential child + return new_keys + def update_item(self, descriptor, user_id, allow_not_found=False, force=False, **kwargs): old_descriptor_locn = descriptor.location descriptor.location = self._map_revision_to_branch(old_descriptor_locn) diff --git a/common/lib/xmodule/xmodule/modulestore/split_mongo/split_mongo_kvs.py b/common/lib/xmodule/xmodule/modulestore/split_mongo/split_mongo_kvs.py index 0cfa672143..2124732242 100644 --- a/common/lib/xmodule/xmodule/modulestore/split_mongo/split_mongo_kvs.py +++ b/common/lib/xmodule/xmodule/modulestore/split_mongo/split_mongo_kvs.py @@ -19,17 +19,20 @@ class SplitMongoKVS(InheritanceKeyValueStore): """ @contract(parent="BlockUsageLocator | None") - def __init__(self, definition, initial_values, parent, field_decorator=None): + def __init__(self, definition, initial_values, default_values, parent, field_decorator=None): """ :param definition: either a lazyloader or definition id for the definition :param initial_values: a dictionary of the locally set values + :param default_values: any Scope.settings field defaults that are set locally + (copied from a template block with copy_from_template) """ # deepcopy so that manipulations of fields does not pollute the source super(SplitMongoKVS, self).__init__(copy.deepcopy(initial_values)) self._definition = definition # either a DefinitionLazyLoader or the db id of the definition. # if the db id, then the definition is presumed to be loaded into _fields + self._defaults = default_values # a decorator function for field values (to be called when a field is accessed) if field_decorator is None: self.field_decorator = lambda x: x @@ -110,6 +113,16 @@ class SplitMongoKVS(InheritanceKeyValueStore): # if someone changes it so that they do, then change any tests of field.name in xx._field_data return key.field_name in self._fields + def default(self, key): + """ + Check to see if the default should be from the template's defaults (if any) + rather than the global default or inheritance. + """ + if self._defaults and key.field_name in self._defaults: + return self._defaults[key.field_name] + # If not, try inheriting from a parent, then use the XBlock type's normal default value: + return super(SplitMongoKVS, self).default(key) + def _load_definition(self): """ Update fields w/ the lazily loaded definitions diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_libraries.py b/common/lib/xmodule/xmodule/modulestore/tests/test_libraries.py index ef8a6d4d69..323fd23301 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_libraries.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_libraries.py @@ -10,8 +10,9 @@ from mock import patch from opaque_keys.edx.locator import LibraryLocator from xblock.fragment import Fragment from xblock.runtime import Runtime as VanillaRuntime -from xmodule.modulestore.exceptions import DuplicateCourseError -from xmodule.modulestore.tests.factories import LibraryFactory, ItemFactory, check_mongo_calls +from xmodule.modulestore import ModuleStoreEnum +from xmodule.modulestore.exceptions import DuplicateCourseError, ItemNotFoundError +from xmodule.modulestore.tests.factories import CourseFactory, LibraryFactory, ItemFactory, check_mongo_calls from xmodule.modulestore.tests.utils import MixedSplitTestCase from xmodule.x_module import AUTHOR_VIEW @@ -217,3 +218,142 @@ class TestLibraries(MixedSplitTestCase): modulestore=self.store, ) self.assertFalse(self.store.has_published_version(block)) + + +@ddt.ddt +class TestSplitCopyTemplate(MixedSplitTestCase): + """ + Test for split's copy_from_template method. + Currently it is only used for content libraries. + However for this test, we make sure it also works when copying from course to course. + """ + @ddt.data( + LibraryFactory, + CourseFactory, + ) + def test_copy_from_template(self, source_type): + """ + Test that the behavior of copy_from_template() matches its docstring + """ + source_container = source_type.create(modulestore=self.store) # Either a library or a course + course = CourseFactory.create(modulestore=self.store) + # Add a vertical with a capa child to the source library/course: + vertical_block = ItemFactory.create( + category="vertical", + parent_location=source_container.location, + user_id=self.user_id, + publish_item=False, + modulestore=self.store, + ) + problem_library_display_name = "Problem Library Display Name" + problem_block = ItemFactory.create( + category="problem", + parent_location=vertical_block.location, + user_id=self.user_id, + publish_item=False, + modulestore=self.store, + display_name=problem_library_display_name, + markdown="Problem markdown here" + ) + + if source_type == LibraryFactory: + source_container = self.store.get_library(source_container.location.library_key, remove_version=False, remove_branch=False) + else: + source_container = self.store.get_course(source_container.location.course_key, remove_version=False, remove_branch=False) + + # Inherit the vertical and the problem from the library into the course: + source_keys = [source_container.children[0]] + new_blocks = self.store.copy_from_template(source_keys, dest_key=course.location, user_id=self.user_id) + self.assertEqual(len(new_blocks), 1) + + course = self.store.get_course(course.location.course_key) # Reload from modulestore + + self.assertEqual(len(course.children), 1) + vertical_block_course = self.store.get_item(course.children[0]) + self.assertEqual(new_blocks[0], vertical_block_course.location) + problem_block_course = self.store.get_item(vertical_block_course.children[0]) + self.assertEqual(problem_block_course.display_name, problem_library_display_name) + + # Override the display_name and weight: + new_display_name = "The Trouble with Tribbles" + new_weight = 20 + problem_block_course.display_name = new_display_name + problem_block_course.weight = new_weight + self.store.update_item(problem_block_course, self.user_id) + + # Test that "Any previously existing children of `dest_usage` that haven't been replaced/updated by this copy_from_template operation will be deleted." + extra_block = ItemFactory.create( + category="html", + parent_location=vertical_block_course.location, + user_id=self.user_id, + publish_item=False, + modulestore=self.store, + ) + + # Repeat the copy_from_template(): + new_blocks2 = self.store.copy_from_template(source_keys, dest_key=course.location, user_id=self.user_id) + self.assertEqual(new_blocks, new_blocks2) + # Reload problem_block_course: + problem_block_course = self.store.get_item(problem_block_course.location) + self.assertEqual(problem_block_course.display_name, new_display_name) + self.assertEqual(problem_block_course.weight, new_weight) + + # Ensure that extra_block was deleted: + vertical_block_course = self.store.get_item(new_blocks2[0]) + self.assertEqual(len(vertical_block_course.children), 1) + with self.assertRaises(ItemNotFoundError): + self.store.get_item(extra_block.location) + + def test_copy_from_template_auto_publish(self): + """ + Make sure that copy_from_template works with things like 'chapter' that + are always auto-published. + """ + source_course = CourseFactory.create(modulestore=self.store) + course = CourseFactory.create(modulestore=self.store) + make_block = lambda category, parent: ItemFactory.create(category=category, parent_location=parent.location, user_id=self.user_id, modulestore=self.store) + + # Populate the course: + about = make_block("about", source_course) + chapter = make_block("chapter", source_course) + sequential = make_block("sequential", chapter) + # And three blocks that are NOT auto-published: + vertical = make_block("vertical", sequential) + make_block("problem", vertical) + html = make_block("html", source_course) + + # Reload source_course since we need its branch and version to use copy_from_template: + source_course = self.store.get_course(source_course.location.course_key, remove_version=False, remove_branch=False) + + # Inherit the vertical and the problem from the library into the course: + source_keys = [block.location for block in [about, chapter, html]] + block_keys = self.store.copy_from_template(source_keys, dest_key=course.location, user_id=self.user_id) + self.assertEqual(len(block_keys), len(source_keys)) + + # Build dict of the new blocks in 'course', keyed by category (which is a unique key in our case) + new_blocks = {} + block_keys = set(block_keys) + while block_keys: + key = block_keys.pop() + block = self.store.get_item(key) + new_blocks[block.category] = block + block_keys.update(set(getattr(block, "children", []))) + + # Check that auto-publish blocks with no children are indeed published: + def published_version_exists(block): + """ Does a published version of block exist? """ + try: + self.store.get_item(block.location.for_branch(ModuleStoreEnum.BranchName.published)) + return True + except ItemNotFoundError: + return False + + # Check that the auto-publish blocks have been published: + self.assertFalse(self.store.has_changes(new_blocks["about"])) + self.assertTrue(published_version_exists(new_blocks["chapter"])) # We can't use has_changes because it includes descendants + self.assertTrue(published_version_exists(new_blocks["sequential"])) # Ditto + # Check that non-auto-publish blocks and blocks with non-auto-publish descendants show changes: + self.assertTrue(self.store.has_changes(new_blocks["html"])) + self.assertTrue(self.store.has_changes(new_blocks["problem"])) + self.assertTrue(self.store.has_changes(new_blocks["chapter"])) # Will have changes since a child block has changes. + self.assertFalse(published_version_exists(new_blocks["vertical"])) # Verify that our published_version_exists works diff --git a/common/lib/xmodule/xmodule/tests/test_library_content.py b/common/lib/xmodule/xmodule/tests/test_library_content.py index fec957d0cd..b92404df10 100644 --- a/common/lib/xmodule/xmodule/tests/test_library_content.py +++ b/common/lib/xmodule/xmodule/tests/test_library_content.py @@ -117,7 +117,8 @@ class TestLibraries(MixedSplitTestCase): # is updated, but the way we do it through a factory doesn't do that. self.assertEqual(len(self.lc_block.children), 0) # Update the LibraryContent module: - self.lc_block.refresh_children(None, None) + self.lc_block.refresh_children() + self.lc_block = self.store.get_item(self.lc_block.location) # Check that all blocks from the library are now children of the block: self.assertEqual(len(self.lc_block.children), len(self.lib_blocks)) @@ -125,7 +126,7 @@ class TestLibraries(MixedSplitTestCase): """ Test that each student sees only one block as a child of the LibraryContent block. """ - self.lc_block.refresh_children(None, None) + self.lc_block.refresh_children() self.lc_block = self.store.get_item(self.lc_block.location) self._bind_course_module(self.lc_block) # Make sure the runtime knows that the block's children vary per-user: diff --git a/common/lib/xmodule/xmodule/x_module.py b/common/lib/xmodule/xmodule/x_module.py index 00487d4848..770d6429ad 100644 --- a/common/lib/xmodule/xmodule/x_module.py +++ b/common/lib/xmodule/xmodule/x_module.py @@ -1250,6 +1250,7 @@ class DescriptorSystem(MetricsMixin, ConfigurableFragmentWrapper, Runtime): # p :param xblock: :param field: """ + # pylint: disable=protected-access # in runtime b/c runtime contains app-specific xblock behavior. Studio's the only app # which needs this level of introspection right now. runtime also is 'allowed' to know # about the kvs, dbmodel, etc. @@ -1257,12 +1258,8 @@ class DescriptorSystem(MetricsMixin, ConfigurableFragmentWrapper, Runtime): # p result = {} result['explicitly_set'] = xblock._field_data.has(xblock, field.name) try: - block_inherited = xblock.xblock_kvs.inherited_settings - except AttributeError: # if inherited_settings doesn't exist on kvs - block_inherited = {} - if field.name in block_inherited: - result['default_value'] = block_inherited[field.name] - else: + result['default_value'] = xblock._field_data.default(xblock, field.name) + except KeyError: result['default_value'] = field.to_json(field.default) return result From 325c36069a0653c850afa3b81a3695c31d7a8bfa Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Tue, 23 Dec 2014 13:59:44 -0800 Subject: [PATCH 54/99] Unrelated: fix two bugs when duplicating a LibraryContentModule --- cms/djangoapps/contentstore/views/item.py | 8 ++++++-- common/lib/xmodule/xmodule/modulestore/inheritance.py | 4 ++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py index d535cf11fb..57f1d59ca9 100644 --- a/cms/djangoapps/contentstore/views/item.py +++ b/cms/djangoapps/contentstore/views/item.py @@ -559,7 +559,10 @@ def _duplicate_item(parent_usage_key, duplicate_source_usage_key, user, display_ category = dest_usage_key.block_type # Update the display name to indicate this is a duplicate (unless display name provided). - duplicate_metadata = own_metadata(source_item) + duplicate_metadata = {} # Can't use own_metadata(), b/c it converts data for JSON serialization - not suitable for setting metadata of the new block + for field in source_item.fields.values(): + if (field.scope == Scope.settings and field.is_set_on(source_item)): + duplicate_metadata[field.name] = field.read_from(source_item) if display_name is not None: duplicate_metadata['display_name'] = display_name else: @@ -584,7 +587,8 @@ def _duplicate_item(parent_usage_key, duplicate_source_usage_key, user, display_ dest_module.children = [] for child in source_item.children: dupe = _duplicate_item(dest_module.location, child, user=user) - dest_module.children.append(dupe) + if dupe not in dest_module.children: # _duplicate_item may add the child for us. + dest_module.children.append(dupe) store.update_item(dest_module, user.id) if 'detached' not in source_item.runtime.load_block_type(category)._class_tags: diff --git a/common/lib/xmodule/xmodule/modulestore/inheritance.py b/common/lib/xmodule/xmodule/modulestore/inheritance.py index c0e3e1b7f1..3ec2f96dbd 100644 --- a/common/lib/xmodule/xmodule/modulestore/inheritance.py +++ b/common/lib/xmodule/xmodule/modulestore/inheritance.py @@ -211,8 +211,8 @@ def inherit_metadata(descriptor, inherited_data): def own_metadata(module): """ - Return a dictionary that contains only non-inherited field keys, - mapped to their serialized values + Return a JSON-friendly dictionary that contains only non-inherited field + keys, mapped to their serialized values """ return module.get_explicitly_set_fields_by_scope(Scope.settings) From e768fb9a4b18c3b87bdbc3908b3729017b431948 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Thu, 18 Dec 2014 22:38:07 -0800 Subject: [PATCH 55/99] Fix two split mongo bugs that were causing problems... --- .../xmodule/xmodule/modulestore/split_mongo/mongo_connection.py | 2 +- common/lib/xmodule/xmodule/modulestore/split_mongo/split.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/common/lib/xmodule/xmodule/modulestore/split_mongo/mongo_connection.py b/common/lib/xmodule/xmodule/modulestore/split_mongo/mongo_connection.py index 8b4b872c43..9535a5232d 100644 --- a/common/lib/xmodule/xmodule/modulestore/split_mongo/mongo_connection.py +++ b/common/lib/xmodule/xmodule/modulestore/split_mongo/mongo_connection.py @@ -255,7 +255,7 @@ class MongoConnection(object): """ Retrieve all definitions listed in `definitions`. """ - return self.definitions.find({'$in': {'_id': definitions}}) + return self.definitions.find({'_id': {'$in': definitions}}) def insert_definition(self, definition): """ diff --git a/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py b/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py index 3d809f7981..a0d9fea0cd 100644 --- a/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py +++ b/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py @@ -673,7 +673,7 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase): new_module_data = {} for block_id in base_block_ids: new_module_data = self.descendants( - system.course_entry.structure['blocks'], + copy.deepcopy(system.course_entry.structure['blocks']), # copy or our changes like setting 'definition_loaded' will affect the active bulk operation data block_id, depth, new_module_data From ea579bf54b9ebb7cccba9a4c72bf4d31bee3d52f Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Mon, 29 Dec 2014 20:01:36 -0800 Subject: [PATCH 56/99] Workaround an issue with capa modules --- .../xmodule/xmodule/modulestore/split_mongo/split.py | 10 ++++++++++ .../xmodule/modulestore/tests/test_libraries.py | 4 ++++ 2 files changed, 14 insertions(+) diff --git a/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py b/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py index a0d9fea0cd..7fa26e9d8b 100644 --- a/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py +++ b/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py @@ -2182,6 +2182,16 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase): existing_block_info = dest_structure['blocks'].get(new_block_key, {}) # Inherit the Scope.settings values from 'fields' to 'defaults' new_block_info['defaults'] = new_block_info['fields'] + + # + # CAPA modules store their 'markdown' value (an alternate representation of their content) in Scope.settings rather than Scope.content :-/ + # markdown is a field that really should not be overridable - it fundamentally changes the content. + # capa modules also use a custom editor that always saves their markdown field to the metadata, even if it hasn't changed, which breaks our override system. + # So until capa modules are fixed, we special-case them and remove their markdown fields, forcing the inherited version to use XML only. + if usage_key.block_type == 'problem' and 'markdown' in new_block_info['defaults']: + del new_block_info['defaults']['markdown'] + # + new_block_info['fields'] = existing_block_info.get('fields', {}) # Preserve any existing overrides if 'children' in new_block_info['defaults']: del new_block_info['defaults']['children'] # Will be set later diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_libraries.py b/common/lib/xmodule/xmodule/modulestore/tests/test_libraries.py index 323fd23301..fd9e941784 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_libraries.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_libraries.py @@ -274,6 +274,10 @@ class TestSplitCopyTemplate(MixedSplitTestCase): problem_block_course = self.store.get_item(vertical_block_course.children[0]) self.assertEqual(problem_block_course.display_name, problem_library_display_name) + # Check that when capa modules are copied, their "markdown" fields (Scope.settings) are removed. (See note in split.py:copy_from_template()) + self.assertIsNotNone(problem_block.markdown) + self.assertIsNone(problem_block_course.markdown) + # Override the display_name and weight: new_display_name = "The Trouble with Tribbles" new_weight = 20 From 55fb45fb2477f45a38636dc6b183fde45e8628aa Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Tue, 30 Dec 2014 01:35:41 -0800 Subject: [PATCH 57/99] Acceptance test --- .../test/acceptance/pages/studio/container.py | 23 +++++++++ .../studio/test_studio_library_container.py | 51 +++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/common/test/acceptance/pages/studio/container.py b/common/test/acceptance/pages/studio/container.py index a5ee42e9c4..8f4ccb8590 100644 --- a/common/test/acceptance/pages/studio/container.py +++ b/common/test/acceptance/pages/studio/container.py @@ -285,6 +285,7 @@ class XBlockWrapper(PageObject): COMPONENT_BUTTONS = { 'basic_tab': '.editor-tabs li.inner_tab_wrap:nth-child(1) > a', 'advanced_tab': '.editor-tabs li.inner_tab_wrap:nth-child(2) > a', + 'settings_tab': '.editor-modes .settings-button', 'save_settings': '.action-save', } @@ -412,6 +413,28 @@ class XBlockWrapper(PageObject): """ self._click_button('basic_tab') + def open_settings_tab(self): + """ + If editing, click on the "Settings" tab + """ + self._click_button('settings_tab') + + def set_field_val(self, field_display_name, field_value): + """ + If editing, set the value of a field. + """ + selector = '{} li.field label:contains("{}") + input'.format(self.editor_selector, field_display_name) + script = "$(arguments[0]).val(arguments[1]).change();" + self.browser.execute_script(script, selector, field_value) + + def reset_field_val(self, field_display_name): + """ + If editing, reset the value of a field to its default. + """ + scope = '{} li.field label:contains("{}")'.format(self.editor_selector, field_display_name) + script = "$(arguments[0]).siblings('.setting-clear').click();" + self.browser.execute_script(script, scope) + def set_codemirror_text(self, text, index=0): """ Set the text of a CodeMirror editor that is part of this xblock's settings. diff --git a/common/test/acceptance/tests/studio/test_studio_library_container.py b/common/test/acceptance/tests/studio/test_studio_library_container.py index ce04643745..593243e628 100644 --- a/common/test/acceptance/tests/studio/test_studio_library_container.py +++ b/common/test/acceptance/tests/studio/test_studio_library_container.py @@ -244,3 +244,54 @@ class StudioLibraryContainerTest(StudioLibraryTest, UniqueCourseTest): expected_tpl.format(count=50, actual=len(self.library_fixture.children)), library_container.validation_warning_text ) + + def test_settings_overrides(self): + """ + Scenario: Given I have a library, a course and library content xblock in a course + When I go to studio unit page for library content block + And when I click the "View" link + Then I can see a preview of the blocks drawn from the library. + + When I edit one of the blocks to change a setting such as "display_name", + Then I can see the new setting is overriding the library version. + + When I subsequently click to refresh the content with the latest from the library, + Then I can see that the overrided version of the setting is preserved. + + When I click to edit the block and reset the setting, + then I can see that the setting's field defaults back to the library version. + """ + block_wrapper_unit_page = self._get_library_xblock_wrapper(self.unit_page.xblocks[0].children[0]) + container_page = block_wrapper_unit_page.go_to_container() + library_block = self._get_library_xblock_wrapper(container_page.xblocks[0]) + + self.assertFalse(library_block.has_validation_message) + self.assertEqual(len(library_block.children), 3) + + block = library_block.children[0] + self.assertIn(block.name, ("Html1", "Html2", "Html3")) + name_default = block.name + + block.edit() + new_display_name = "A new name for this HTML block" + block.set_field_val("Display Name", new_display_name) + block.save_settings() + + self.assertEqual(block.name, new_display_name) + + # Create a new block, causing a new library version: + self.library_fixture.create_xblock(self.library_fixture.library_location, XBlockFixtureDesc("html", "Html4")) + + container_page.visit() # Reload + self.assertTrue(library_block.has_validation_warning) + library_block.refresh_children() + container_page.wait_for_page() # Wait for the page to reload + + self.assertEqual(len(library_block.children), 4) + self.assertEqual(block.name, new_display_name) + + # Reset: + block.edit() + block.reset_field_val("Display Name") + block.save_settings() + self.assertEqual(block.name, name_default) From 4d8251066c3a1135db935867729a33b73b64fb02 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Tue, 6 Jan 2015 21:41:22 -0800 Subject: [PATCH 58/99] Fix bug w/ publishing ( + test), move copy_from_template tests to their own file --- .../xmodule/modulestore/split_mongo/split.py | 10 +- .../modulestore/tests/test_libraries.py | 148 +---------------- .../tests/test_split_copy_from_template.py | 155 ++++++++++++++++++ .../xmodule/modulestore/tests/utils.py | 15 ++ 4 files changed, 179 insertions(+), 149 deletions(-) create mode 100644 common/lib/xmodule/xmodule/modulestore/tests/test_split_copy_from_template.py diff --git a/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py b/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py index 7fa26e9d8b..6c375d2dfc 100644 --- a/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py +++ b/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py @@ -2843,7 +2843,8 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase): self._filter_blacklist(copy.copy(new_block['fields']), blacklist), new_block['definition'], destination_version, - raw=True + raw=True, + block_defaults=new_block.get('defaults') ) # introduce new edit info field for tracing where copied/published blocks came @@ -2882,7 +2883,7 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase): self._delete_if_true_orphan(BlockKey(*child), structure) del structure['blocks'][orphan] - def _new_block(self, user_id, category, block_fields, definition_id, new_id, raw=False): + def _new_block(self, user_id, category, block_fields, definition_id, new_id, raw=False, block_defaults=None): """ Create the core document structure for a block. @@ -2893,7 +2894,7 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase): """ if not raw: block_fields = self._serialize_fields(category, block_fields) - return { + document = { 'block_type': category, 'definition': definition_id, 'fields': block_fields, @@ -2904,6 +2905,9 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase): 'update_version': new_id } } + if block_defaults: + document['defaults'] = block_defaults + return document @contract(block_key=BlockKey) def _get_block_from_structure(self, structure, block_key): diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_libraries.py b/common/lib/xmodule/xmodule/modulestore/tests/test_libraries.py index fd9e941784..ef8a6d4d69 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_libraries.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_libraries.py @@ -10,9 +10,8 @@ from mock import patch from opaque_keys.edx.locator import LibraryLocator from xblock.fragment import Fragment from xblock.runtime import Runtime as VanillaRuntime -from xmodule.modulestore import ModuleStoreEnum -from xmodule.modulestore.exceptions import DuplicateCourseError, ItemNotFoundError -from xmodule.modulestore.tests.factories import CourseFactory, LibraryFactory, ItemFactory, check_mongo_calls +from xmodule.modulestore.exceptions import DuplicateCourseError +from xmodule.modulestore.tests.factories import LibraryFactory, ItemFactory, check_mongo_calls from xmodule.modulestore.tests.utils import MixedSplitTestCase from xmodule.x_module import AUTHOR_VIEW @@ -218,146 +217,3 @@ class TestLibraries(MixedSplitTestCase): modulestore=self.store, ) self.assertFalse(self.store.has_published_version(block)) - - -@ddt.ddt -class TestSplitCopyTemplate(MixedSplitTestCase): - """ - Test for split's copy_from_template method. - Currently it is only used for content libraries. - However for this test, we make sure it also works when copying from course to course. - """ - @ddt.data( - LibraryFactory, - CourseFactory, - ) - def test_copy_from_template(self, source_type): - """ - Test that the behavior of copy_from_template() matches its docstring - """ - source_container = source_type.create(modulestore=self.store) # Either a library or a course - course = CourseFactory.create(modulestore=self.store) - # Add a vertical with a capa child to the source library/course: - vertical_block = ItemFactory.create( - category="vertical", - parent_location=source_container.location, - user_id=self.user_id, - publish_item=False, - modulestore=self.store, - ) - problem_library_display_name = "Problem Library Display Name" - problem_block = ItemFactory.create( - category="problem", - parent_location=vertical_block.location, - user_id=self.user_id, - publish_item=False, - modulestore=self.store, - display_name=problem_library_display_name, - markdown="Problem markdown here" - ) - - if source_type == LibraryFactory: - source_container = self.store.get_library(source_container.location.library_key, remove_version=False, remove_branch=False) - else: - source_container = self.store.get_course(source_container.location.course_key, remove_version=False, remove_branch=False) - - # Inherit the vertical and the problem from the library into the course: - source_keys = [source_container.children[0]] - new_blocks = self.store.copy_from_template(source_keys, dest_key=course.location, user_id=self.user_id) - self.assertEqual(len(new_blocks), 1) - - course = self.store.get_course(course.location.course_key) # Reload from modulestore - - self.assertEqual(len(course.children), 1) - vertical_block_course = self.store.get_item(course.children[0]) - self.assertEqual(new_blocks[0], vertical_block_course.location) - problem_block_course = self.store.get_item(vertical_block_course.children[0]) - self.assertEqual(problem_block_course.display_name, problem_library_display_name) - - # Check that when capa modules are copied, their "markdown" fields (Scope.settings) are removed. (See note in split.py:copy_from_template()) - self.assertIsNotNone(problem_block.markdown) - self.assertIsNone(problem_block_course.markdown) - - # Override the display_name and weight: - new_display_name = "The Trouble with Tribbles" - new_weight = 20 - problem_block_course.display_name = new_display_name - problem_block_course.weight = new_weight - self.store.update_item(problem_block_course, self.user_id) - - # Test that "Any previously existing children of `dest_usage` that haven't been replaced/updated by this copy_from_template operation will be deleted." - extra_block = ItemFactory.create( - category="html", - parent_location=vertical_block_course.location, - user_id=self.user_id, - publish_item=False, - modulestore=self.store, - ) - - # Repeat the copy_from_template(): - new_blocks2 = self.store.copy_from_template(source_keys, dest_key=course.location, user_id=self.user_id) - self.assertEqual(new_blocks, new_blocks2) - # Reload problem_block_course: - problem_block_course = self.store.get_item(problem_block_course.location) - self.assertEqual(problem_block_course.display_name, new_display_name) - self.assertEqual(problem_block_course.weight, new_weight) - - # Ensure that extra_block was deleted: - vertical_block_course = self.store.get_item(new_blocks2[0]) - self.assertEqual(len(vertical_block_course.children), 1) - with self.assertRaises(ItemNotFoundError): - self.store.get_item(extra_block.location) - - def test_copy_from_template_auto_publish(self): - """ - Make sure that copy_from_template works with things like 'chapter' that - are always auto-published. - """ - source_course = CourseFactory.create(modulestore=self.store) - course = CourseFactory.create(modulestore=self.store) - make_block = lambda category, parent: ItemFactory.create(category=category, parent_location=parent.location, user_id=self.user_id, modulestore=self.store) - - # Populate the course: - about = make_block("about", source_course) - chapter = make_block("chapter", source_course) - sequential = make_block("sequential", chapter) - # And three blocks that are NOT auto-published: - vertical = make_block("vertical", sequential) - make_block("problem", vertical) - html = make_block("html", source_course) - - # Reload source_course since we need its branch and version to use copy_from_template: - source_course = self.store.get_course(source_course.location.course_key, remove_version=False, remove_branch=False) - - # Inherit the vertical and the problem from the library into the course: - source_keys = [block.location for block in [about, chapter, html]] - block_keys = self.store.copy_from_template(source_keys, dest_key=course.location, user_id=self.user_id) - self.assertEqual(len(block_keys), len(source_keys)) - - # Build dict of the new blocks in 'course', keyed by category (which is a unique key in our case) - new_blocks = {} - block_keys = set(block_keys) - while block_keys: - key = block_keys.pop() - block = self.store.get_item(key) - new_blocks[block.category] = block - block_keys.update(set(getattr(block, "children", []))) - - # Check that auto-publish blocks with no children are indeed published: - def published_version_exists(block): - """ Does a published version of block exist? """ - try: - self.store.get_item(block.location.for_branch(ModuleStoreEnum.BranchName.published)) - return True - except ItemNotFoundError: - return False - - # Check that the auto-publish blocks have been published: - self.assertFalse(self.store.has_changes(new_blocks["about"])) - self.assertTrue(published_version_exists(new_blocks["chapter"])) # We can't use has_changes because it includes descendants - self.assertTrue(published_version_exists(new_blocks["sequential"])) # Ditto - # Check that non-auto-publish blocks and blocks with non-auto-publish descendants show changes: - self.assertTrue(self.store.has_changes(new_blocks["html"])) - self.assertTrue(self.store.has_changes(new_blocks["problem"])) - self.assertTrue(self.store.has_changes(new_blocks["chapter"])) # Will have changes since a child block has changes. - self.assertFalse(published_version_exists(new_blocks["vertical"])) # Verify that our published_version_exists works diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_split_copy_from_template.py b/common/lib/xmodule/xmodule/modulestore/tests/test_split_copy_from_template.py new file mode 100644 index 0000000000..3d73178644 --- /dev/null +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_split_copy_from_template.py @@ -0,0 +1,155 @@ +""" +Tests for split's copy_from_template method. +Currently it is only used for content libraries. +However for these tests, we make sure it also works when copying from course to course. +""" +import ddt +from xmodule.modulestore import ModuleStoreEnum +from xmodule.modulestore.exceptions import ItemNotFoundError +from xmodule.modulestore.tests.factories import CourseFactory, LibraryFactory +from xmodule.modulestore.tests.utils import MixedSplitTestCase + + +@ddt.ddt +class TestSplitCopyTemplate(MixedSplitTestCase): + """ + Test for split's copy_from_template method. + """ + @ddt.data( + LibraryFactory, + CourseFactory, + ) + def test_copy_from_template(self, source_type): + """ + Test that the behavior of copy_from_template() matches its docstring + """ + source_container = source_type.create(modulestore=self.store) # Either a library or a course + course = CourseFactory.create(modulestore=self.store) + # Add a vertical with a capa child to the source library/course: + vertical_block = self.make_block("vertical", source_container) + problem_library_display_name = "Problem Library Display Name" + problem_block = self.make_block("problem", vertical_block, display_name=problem_library_display_name, markdown="Problem markdown here") + + if source_type == LibraryFactory: + source_container = self.store.get_library(source_container.location.library_key, remove_version=False, remove_branch=False) + else: + source_container = self.store.get_course(source_container.location.course_key, remove_version=False, remove_branch=False) + + # Inherit the vertical and the problem from the library into the course: + source_keys = [source_container.children[0]] + new_blocks = self.store.copy_from_template(source_keys, dest_key=course.location, user_id=self.user_id) + self.assertEqual(len(new_blocks), 1) + + course = self.store.get_course(course.location.course_key) # Reload from modulestore + + self.assertEqual(len(course.children), 1) + vertical_block_course = self.store.get_item(course.children[0]) + self.assertEqual(new_blocks[0], vertical_block_course.location) + problem_block_course = self.store.get_item(vertical_block_course.children[0]) + self.assertEqual(problem_block_course.display_name, problem_library_display_name) + + # Check that when capa modules are copied, their "markdown" fields (Scope.settings) are removed. (See note in split.py:copy_from_template()) + self.assertIsNotNone(problem_block.markdown) + self.assertIsNone(problem_block_course.markdown) + + # Override the display_name and weight: + new_display_name = "The Trouble with Tribbles" + new_weight = 20 + problem_block_course.display_name = new_display_name + problem_block_course.weight = new_weight + self.store.update_item(problem_block_course, self.user_id) + + # Test that "Any previously existing children of `dest_usage` that haven't been replaced/updated by this copy_from_template operation will be deleted." + extra_block = self.make_block("html", vertical_block_course) + + # Repeat the copy_from_template(): + new_blocks2 = self.store.copy_from_template(source_keys, dest_key=course.location, user_id=self.user_id) + self.assertEqual(new_blocks, new_blocks2) + # Reload problem_block_course: + problem_block_course = self.store.get_item(problem_block_course.location) + self.assertEqual(problem_block_course.display_name, new_display_name) + self.assertEqual(problem_block_course.weight, new_weight) + + # Ensure that extra_block was deleted: + vertical_block_course = self.store.get_item(new_blocks2[0]) + self.assertEqual(len(vertical_block_course.children), 1) + with self.assertRaises(ItemNotFoundError): + self.store.get_item(extra_block.location) + + def test_copy_from_template_publish(self): + """ + Test that copy_from_template's "defaults" data is not lost + when blocks are published. + """ + # Create a library with a problem: + source_library = LibraryFactory.create(modulestore=self.store) + display_name_expected = "CUSTOM Library Display Name" + self.make_block("problem", source_library, display_name=display_name_expected) + # Reload source_library since we need its branch and version to use copy_from_template: + source_library = self.store.get_library(source_library.location.library_key, remove_version=False, remove_branch=False) + # And a course with a vertical: + course = CourseFactory.create(modulestore=self.store) + self.make_block("vertical", course) + + problem_key_in_course = self.store.copy_from_template(source_library.children, dest_key=course.location, user_id=self.user_id)[0] + + # We do the following twice because different methods get used inside split modulestore on first vs. subsequent publish + for __ in range(0, 2): + # Publish: + self.store.publish(problem_key_in_course, self.user_id) + # Test that the defaults values are there. + problem_published = self.store.get_item(problem_key_in_course.for_branch(ModuleStoreEnum.BranchName.published)) + self.assertEqual(problem_published.display_name, display_name_expected) + + def test_copy_from_template_auto_publish(self): + """ + Make sure that copy_from_template works with things like 'chapter' that + are always auto-published. + """ + source_course = CourseFactory.create(modulestore=self.store) + course = CourseFactory.create(modulestore=self.store) + + # Populate the course: + about = self.make_block("about", source_course) + chapter = self.make_block("chapter", source_course) + sequential = self.make_block("sequential", chapter) + # And three blocks that are NOT auto-published: + vertical = self.make_block("vertical", sequential) + self.make_block("problem", vertical) + html = self.make_block("html", source_course) + + # Reload source_course since we need its branch and version to use copy_from_template: + source_course = self.store.get_course(source_course.location.course_key, remove_version=False, remove_branch=False) + + # Inherit the vertical and the problem from the library into the course: + source_keys = [block.location for block in [about, chapter, html]] + block_keys = self.store.copy_from_template(source_keys, dest_key=course.location, user_id=self.user_id) + self.assertEqual(len(block_keys), len(source_keys)) + + # Build dict of the new blocks in 'course', keyed by category (which is a unique key in our case) + new_blocks = {} + block_keys = set(block_keys) + while block_keys: + key = block_keys.pop() + block = self.store.get_item(key) + new_blocks[block.category] = block + block_keys.update(set(getattr(block, "children", []))) + + # Check that auto-publish blocks with no children are indeed published: + def published_version_exists(block): + """ Does a published version of block exist? """ + try: + self.store.get_item(block.location.for_branch(ModuleStoreEnum.BranchName.published)) + return True + except ItemNotFoundError: + return False + + # Check that the auto-publish blocks have been published: + self.assertFalse(self.store.has_changes(new_blocks["about"])) + self.assertTrue(published_version_exists(new_blocks["chapter"])) # We can't use has_changes because it includes descendants + self.assertTrue(published_version_exists(new_blocks["sequential"])) # Ditto + # Check that non-auto-publish blocks and blocks with non-auto-publish descendants show changes: + self.assertTrue(self.store.has_changes(new_blocks["html"])) + self.assertTrue(self.store.has_changes(new_blocks["problem"])) + self.assertTrue(self.store.has_changes(new_blocks["chapter"])) # Will have changes since a child block has changes. + self.assertFalse(published_version_exists(new_blocks["vertical"])) # Verify that our published_version_exists works diff --git a/common/lib/xmodule/xmodule/modulestore/tests/utils.py b/common/lib/xmodule/xmodule/modulestore/tests/utils.py index 76c379ec0b..986a54df9f 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/utils.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/utils.py @@ -10,6 +10,7 @@ from xmodule.modulestore.draft_and_published import ModuleStoreDraftAndPublished from xmodule.modulestore.edit_info import EditInfoMixin from xmodule.modulestore.inheritance import InheritanceMixin from xmodule.modulestore.mixed import MixedModuleStore +from xmodule.modulestore.tests.factories import ItemFactory from xmodule.modulestore.tests.mongo_connection import MONGO_PORT_NUM, MONGO_HOST from xmodule.tests import DATA_DIR @@ -108,3 +109,17 @@ class MixedSplitTestCase(TestCase): ) self.addCleanup(self.store.close_all_connections) self.addCleanup(self.store._drop_database) # pylint: disable=protected-access + + def make_block(self, category, parent_block, **kwargs): + """ + Create a block of type `category` as a child of `parent_block`, in any + course or library. You can pass any field values as kwargs. + """ + extra = {"publish_item": False, "user_id": self.user_id} + extra.update(kwargs) + return ItemFactory.create( + category=category, + parent_location=parent_block.location, + modulestore=self.store, + **extra + ) From c17ba15fbf306676c5ba3f9b55754e74960a23cc Mon Sep 17 00:00:00 2001 From: Jonathan Piacenti Date: Tue, 25 Nov 2014 16:13:14 +0000 Subject: [PATCH 59/99] Made empty course url the 'home' url instead. --- cms/djangoapps/contentstore/views/course.py | 6 +++-- cms/templates/index.html | 29 +-------------------- cms/urls.py | 1 + 3 files changed, 6 insertions(+), 30 deletions(-) diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index 5bad2efa22..f40e86f082 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -1,6 +1,7 @@ """ Views related to operations on course objects """ +from django.shortcuts import redirect import json import random import string # pylint: disable=deprecated-module @@ -71,7 +72,8 @@ from microsite_configuration import microsite from xmodule.course_module import CourseFields -__all__ = ['course_info_handler', 'course_handler', 'course_info_update_handler', +__all__ = ['course_info_handler', 'course_handler', 'course_listing', + 'course_info_update_handler', 'course_rerun_handler', 'settings_handler', 'grading_handler', @@ -230,7 +232,7 @@ def course_handler(request, course_key_string=None): return HttpResponseBadRequest() elif request.method == 'GET': # assume html if course_key_string is None: - return course_listing(request) + return redirect(reverse("home")) else: return course_index(request, CourseKey.from_string(course_key_string)) else: diff --git a/cms/templates/index.html b/cms/templates/index.html index 4bce500604..a59cbeb0d2 100644 --- a/cms/templates/index.html +++ b/cms/templates/index.html @@ -14,7 +14,7 @@ <%block name="content">
    -

    ${_("My Courses")}

    +

    ${_("Studio Home")}

    % if user.is_active:
    From 6cd4932a6315c611641bbc546e16838dddb9378e Mon Sep 17 00:00:00 2001 From: Matjaz Gregoric Date: Fri, 14 Nov 2014 09:08:14 +0100 Subject: [PATCH 62/99] Update text on new content library form. --- cms/templates/index.html | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/cms/templates/index.html b/cms/templates/index.html index a59cbeb0d2..64d78ffeda 100644 --- a/cms/templates/index.html +++ b/cms/templates/index.html @@ -129,15 +129,14 @@
  • - ${_("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.")} + ${_("The public organization name for your library.")} ${_("This cannot be changed.")}
  • - - ## Translators: This is an example for the "number" used to identify a library, seen when filling out the form to create a new library. This example is short for "Computer Science Problems". The example number may contain letters but must not contain spaces. - - ${_("The unique code that identifies this library.")} ${_("Note: This is part of your library URL, so no spaces or special characters are allowed.")} ${_("This cannot be changed.")} + + + ${_("The {em_start}major version number{em_end} of your library. Minor revisions are tracked as edits happen within a library.").format(em_start='', em_end='')}
  • From f8f08b78a981fda037d72c0bc8d985600612e9b8 Mon Sep 17 00:00:00 2001 From: Matjaz Gregoric Date: Fri, 14 Nov 2014 08:55:21 +0100 Subject: [PATCH 63/99] Libraries UI: remove underline on hover. This changes the style of the inactive libraries/courses tab when hovering. Only the active tab on the dashboard page gets the blue underline. When hovering over an inactive tab, the color of the text changes, but the underline does not appear. --- cms/static/sass/views/_dashboard.scss | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/cms/static/sass/views/_dashboard.scss b/cms/static/sass/views/_dashboard.scss index 1afd5e4612..7dbb967400 100644 --- a/cms/static/sass/views/_dashboard.scss +++ b/cms/static/sass/views/_dashboard.scss @@ -299,19 +299,21 @@ line-height: $baseline*2; margin: 0 10px; - &.active, &:hover { + &.active { border-bottom: 4px solid $blue; } + &.active, &:hover { + a { + color: $gray-d2; + } + } + a { color: $blue; cursor: pointer; display: inline-block; } - - &.active a { - color: $gray-d2; - } } } From fc4eed4240268adc28a0fa18540543eb5ced2b3b Mon Sep 17 00:00:00 2001 From: Jonathan Piacenti Date: Fri, 28 Nov 2014 16:52:00 +0000 Subject: [PATCH 64/99] Added test for /home/ redirect. --- .../test/acceptance/tests/studio/test_studio_general.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/common/test/acceptance/tests/studio/test_studio_general.py b/common/test/acceptance/tests/studio/test_studio_general.py index 88dc314673..c0b409b66b 100644 --- a/common/test/acceptance/tests/studio/test_studio_general.py +++ b/common/test/acceptance/tests/studio/test_studio_general.py @@ -88,6 +88,15 @@ class CoursePagesTest(StudioCourseTest): ] ] + def test_page_redirect(self): + """ + /course/ is the base URL for all courses, but by itself, it should + redirect to /home/. + """ + self.dashboard_page = DashboardPage(self.browser) + self.dashboard_page.visit() + self.assertEqual(self.browser.current_url.strip('/').rsplit('/')[-1], 'home') + @skip('Intermittently failing with Page not found error for Assets. TE-418') def test_page_existence(self): """ From eabd6c8d27b14969d70366491b598ce4b3248d6b Mon Sep 17 00:00:00 2001 From: Jonathan Piacenti Date: Fri, 28 Nov 2014 17:23:45 +0000 Subject: [PATCH 65/99] Quality check and test fixes. --- .../contentstore/tests/test_contentstore.py | 8 ++--- .../contentstore/tests/test_i18n.py | 10 +++--- cms/djangoapps/contentstore/tests/tests.py | 10 +++--- cms/djangoapps/contentstore/views/public.py | 2 +- .../views/tests/test_course_index.py | 4 +-- cms/templates/index.html | 2 +- common/djangoapps/student/tests/test_login.py | 2 +- .../pages/lms/login_and_register.py | 2 ++ common/test/acceptance/tests/lms/test_lms.py | 5 ++- docs/shared/conf.py | 1 - pavelib/paver_tests/test_prereqs.py | 31 +++++++++++++++++-- pavelib/prereqs.py | 2 +- 12 files changed, 55 insertions(+), 24 deletions(-) diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index 27714230dc..e0122f4cd9 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -1167,7 +1167,7 @@ class ContentStoreTest(ContentStoreTestCase): def test_course_index_view_with_no_courses(self): """Test viewing the index page with no courses""" # Create a course so there is something to view - resp = self.client.get_html('/course/') + resp = self.client.get_html('/home/') self.assertContains( resp, '

    My Courses

    ', @@ -1189,7 +1189,7 @@ class ContentStoreTest(ContentStoreTestCase): def test_course_index_view_with_course(self): """Test viewing the index page with an existing course""" CourseFactory.create(display_name='Robot Super Educational Course') - resp = self.client.get_html('/course/') + resp = self.client.get_html('/home/') self.assertContains( resp, '

    Robot Super Educational Course

    ', @@ -1604,7 +1604,7 @@ class RerunCourseTest(ContentStoreTestCase): Asserts that the given course key is in the accessible course listing section of the html and NOT in the unsucceeded course action section of the html. """ - course_listing = lxml.html.fromstring(self.client.get_html('/course/').content) + course_listing = lxml.html.fromstring(self.client.get_html('/home/').content) self.assertEqual(len(self.get_course_listing_elements(course_listing, course_key)), 1) self.assertEqual(len(self.get_unsucceeded_course_action_elements(course_listing, course_key)), 0) @@ -1613,7 +1613,7 @@ class RerunCourseTest(ContentStoreTestCase): Asserts that the given course key is in the unsucceeded course action section of the html and NOT in the accessible course listing section of the html. """ - course_listing = lxml.html.fromstring(self.client.get_html('/course/').content) + course_listing = lxml.html.fromstring(self.client.get_html('/home/').content) self.assertEqual(len(self.get_course_listing_elements(course_listing, course_key)), 0) self.assertEqual(len(self.get_unsucceeded_course_action_elements(course_listing, course_key)), 1) diff --git a/cms/djangoapps/contentstore/tests/test_i18n.py b/cms/djangoapps/contentstore/tests/test_i18n.py index e9e1739488..da79117220 100644 --- a/cms/djangoapps/contentstore/tests/test_i18n.py +++ b/cms/djangoapps/contentstore/tests/test_i18n.py @@ -44,9 +44,9 @@ class InternationalizationTest(ModuleStoreTestCase): self.client = AjaxEnabledTestClient() self.client.login(username=self.uname, password=self.password) - resp = self.client.get_html('/course/') + resp = self.client.get_html('/home/') self.assertContains(resp, - '

    My Courses

    ', + '

    Studio Home

    ', status_code=200, html=True) @@ -56,13 +56,13 @@ class InternationalizationTest(ModuleStoreTestCase): self.client.login(username=self.uname, password=self.password) resp = self.client.get_html( - '/course/', + '/home/', {}, HTTP_ACCEPT_LANGUAGE='en', ) self.assertContains(resp, - '

    My Courses

    ', + '

    Studio Home

    ', status_code=200, html=True) @@ -81,7 +81,7 @@ class InternationalizationTest(ModuleStoreTestCase): self.client.login(username=self.uname, password=self.password) resp = self.client.get_html( - '/course/', + '/home/', {}, HTTP_ACCEPT_LANGUAGE='eo' ) diff --git a/cms/djangoapps/contentstore/tests/tests.py b/cms/djangoapps/contentstore/tests/tests.py index 9975764345..76429befa6 100644 --- a/cms/djangoapps/contentstore/tests/tests.py +++ b/cms/djangoapps/contentstore/tests/tests.py @@ -234,13 +234,13 @@ class AuthTestCase(ContentStoreTestCase): def test_private_pages_auth(self): """Make sure pages that do require login work.""" auth_pages = ( - '/course/', + '/home/', ) # These are pages that should just load when the user is logged in # (no data needed) simple_auth_pages = ( - '/course/', + '/home/', ) # need an activated user @@ -266,7 +266,7 @@ class AuthTestCase(ContentStoreTestCase): def test_index_auth(self): # not logged in. Should return a redirect. - resp = self.client.get_html('/course/') + resp = self.client.get_html('/home/') self.assertEqual(resp.status_code, 302) # Logged in should work. @@ -283,7 +283,7 @@ class AuthTestCase(ContentStoreTestCase): self.login(self.email, self.pw) # make sure we can access courseware immediately - course_url = '/course/' + course_url = '/home/' resp = self.client.get_html(course_url) self.assertEquals(resp.status_code, 200) @@ -293,7 +293,7 @@ class AuthTestCase(ContentStoreTestCase): resp = self.client.get_html(course_url) # re-request, and we should get a redirect to login page - self.assertRedirects(resp, settings.LOGIN_REDIRECT_URL + '?next=/course/') + self.assertRedirects(resp, settings.LOGIN_REDIRECT_URL + '?next=/home/') class ForumTestCase(CourseTestCase): diff --git a/cms/djangoapps/contentstore/views/public.py b/cms/djangoapps/contentstore/views/public.py index 597bb5e187..7bc6545868 100644 --- a/cms/djangoapps/contentstore/views/public.py +++ b/cms/djangoapps/contentstore/views/public.py @@ -66,6 +66,6 @@ def login_page(request): def howitworks(request): "Proxy view" if request.user.is_authenticated(): - return redirect('/course/') + return redirect('/home/') else: return render_to_response('howitworks.html', {}) diff --git a/cms/djangoapps/contentstore/views/tests/test_course_index.py b/cms/djangoapps/contentstore/views/tests/test_course_index.py index 965879e154..32b5ccbf2c 100644 --- a/cms/djangoapps/contentstore/views/tests/test_course_index.py +++ b/cms/djangoapps/contentstore/views/tests/test_course_index.py @@ -42,7 +42,7 @@ class TestCourseIndex(CourseTestCase): """ Test getting the list of courses and then pulling up their outlines """ - index_url = '/course/' + index_url = '/home/' index_response = authed_client.get(index_url, {}, HTTP_ACCEPT='text/html') parsed_html = lxml.html.fromstring(index_response.content) course_link_eles = parsed_html.find_class('course-link') @@ -68,7 +68,7 @@ class TestCourseIndex(CourseTestCase): # Add a library: lib1 = LibraryFactory.create() - index_url = '/course/' + index_url = '/home/' 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') diff --git a/cms/templates/index.html b/cms/templates/index.html index 64d78ffeda..378dff06bd 100644 --- a/cms/templates/index.html +++ b/cms/templates/index.html @@ -2,7 +2,7 @@ <%inherit file="base.html" /> <%def name="online_help_token()"><% return "home" %> -<%block name="title">${_("My Courses")} +<%block name="title">${_("Studio Home")} <%block name="bodyclass">is-signedin index view-dashboard <%block name="requirejs"> diff --git a/common/djangoapps/student/tests/test_login.py b/common/djangoapps/student/tests/test_login.py index 828e951cbb..8cf07e3a80 100644 --- a/common/djangoapps/student/tests/test_login.py +++ b/common/djangoapps/student/tests/test_login.py @@ -482,7 +482,7 @@ class LoginOAuthTokenMixin(object): self._setup_user_response(success=True) response = self.client.post(self.url, {"access_token": "dummy"}) self.assertEqual(response.status_code, 204) - self.assertEqual(self.client.session['_auth_user_id'], self.user.id) + self.assertEqual(self.client.session['_auth_user_id'], self.user.id) # pylint: disable=no-member def test_invalid_token(self): self._setup_user_response(success=False) diff --git a/common/test/acceptance/pages/lms/login_and_register.py b/common/test/acceptance/pages/lms/login_and_register.py index 3714179858..33d5779a01 100644 --- a/common/test/acceptance/pages/lms/login_and_register.py +++ b/common/test/acceptance/pages/lms/login_and_register.py @@ -246,6 +246,7 @@ class CombinedLoginAndRegisterPage(PageObject): def wait_for_errors(self): """Wait for errors to be visible, then return them. """ def _check_func(): + """Return success status and any errors that occurred.""" errors = self.errors return (bool(errors), errors) return Promise(_check_func, "Errors are visible").fulfill() @@ -259,6 +260,7 @@ class CombinedLoginAndRegisterPage(PageObject): def wait_for_success(self): """Wait for a success message to be visible, then return it.""" def _check_func(): + """Return success status and any errors that occurred.""" success = self.success return (bool(success), success) return Promise(_check_func, "Success message is visible").fulfill() diff --git a/common/test/acceptance/tests/lms/test_lms.py b/common/test/acceptance/tests/lms/test_lms.py index 94dd57d64a..cf3be2d193 100644 --- a/common/test/acceptance/tests/lms/test_lms.py +++ b/common/test/acceptance/tests/lms/test_lms.py @@ -119,7 +119,7 @@ class LoginFromCombinedPageTest(UniqueCourseTest): def test_password_reset_success(self): # Create a user account - email, password = self._create_unique_user() + email, password = self._create_unique_user() # pylint: disable=unused-variable # Navigate to the password reset form and try to submit it self.login_page.visit().password_reset(email=email) @@ -141,6 +141,9 @@ class LoginFromCombinedPageTest(UniqueCourseTest): ) def _create_unique_user(self): + """ + Create a new user with a unique name and email. + """ username = "test_{uuid}".format(uuid=self.unique_id[0:6]) email = "{user}@example.com".format(user=username) password = "password" diff --git a/docs/shared/conf.py b/docs/shared/conf.py index dfedf01ca8..992f0e54f3 100644 --- a/docs/shared/conf.py +++ b/docs/shared/conf.py @@ -22,7 +22,6 @@ # ----------------------------------------------------------------------------- import os -import sys BASEDIR = os.path.dirname(os.path.abspath(__file__)) diff --git a/pavelib/paver_tests/test_prereqs.py b/pavelib/paver_tests/test_prereqs.py index e4586d1843..47437ee907 100644 --- a/pavelib/paver_tests/test_prereqs.py +++ b/pavelib/paver_tests/test_prereqs.py @@ -1,12 +1,21 @@ - import os import unittest from pavelib.prereqs import no_prereq_install class TestPaverPrereqInstall(unittest.TestCase): - + """ + Test the status of the NO_PREREQ_INSTALL variable, its presence and how + paver handles it. + """ def check_val(self, set_val, expected_val): + """ + Verify that setting the variable to a certain value returns + the expected boolean for it. + + As environment variables are only stored as strings, we have to cast + whatever it's set at to a boolean that does not violate expectations. + """ _orig_environ = dict(os.environ) os.environ['NO_PREREQ_INSTALL'] = set_val self.assertEqual( @@ -21,19 +30,37 @@ class TestPaverPrereqInstall(unittest.TestCase): os.environ.update(_orig_environ) def test_no_prereq_install_true(self): + """ + Ensure that 'true' will be True. + """ self.check_val('true', True) def test_no_prereq_install_false(self): + """ + Ensure that 'false' will be False. + """ self.check_val('false', False) def test_no_prereq_install_True(self): + """ + Ensure that 'True' will be True. + """ self.check_val('True', True) def test_no_prereq_install_False(self): + """ + Ensure that 'False' will be False. + """ self.check_val('False', False) def test_no_prereq_install_0(self): + """ + Ensure that '0' will be False. + """ self.check_val('0', False) def test_no_prereq_install_1(self): + """ + Ensure that '1' will be True. + """ self.check_val('1', True) diff --git a/pavelib/prereqs.py b/pavelib/prereqs.py index 0a334a8152..220a5387c4 100644 --- a/pavelib/prereqs.py +++ b/pavelib/prereqs.py @@ -41,7 +41,7 @@ def no_prereq_install(): try: return vals[val] - except: + except KeyError: return False From ccc392893e6c466d17ecd90ada4d3363551df822 Mon Sep 17 00:00:00 2001 From: Matjaz Gregoric Date: Wed, 17 Dec 2014 12:28:34 +0100 Subject: [PATCH 66/99] Update tests. Most of the updates are related to the My Courses -> Studio Home change. --- cms/djangoapps/contentstore/features/common.py | 2 +- cms/djangoapps/contentstore/features/courses.py | 8 ++++---- .../contentstore/features/help.feature | 2 +- .../contentstore/features/signup.feature | 4 ++-- cms/djangoapps/contentstore/features/signup.py | 2 +- .../contentstore/tests/test_contentstore.py | 3 +-- .../js/mock/mock-index-page.underscore | 17 +++++------------ cms/templates/widgets/header.html | 2 +- common/djangoapps/terrain/ui_helpers.py | 2 +- common/test/acceptance/pages/studio/index.py | 11 ++--------- .../acceptance/tests/studio/test_studio_home.py | 10 ---------- 11 files changed, 19 insertions(+), 44 deletions(-) diff --git a/cms/djangoapps/contentstore/features/common.py b/cms/djangoapps/contentstore/features/common.py index 41c6b7cffd..692ec695ce 100644 --- a/cms/djangoapps/contentstore/features/common.py +++ b/cms/djangoapps/contentstore/features/common.py @@ -171,7 +171,7 @@ def log_into_studio( world.log_in(username=uname, password=password, email=email, name=name) # Navigate to the studio dashboard world.visit('/') - assert_in(uname, world.css_text('h2.title', timeout=10)) + assert_in(uname, world.css_text('span.account-username', timeout=10)) def add_course_author(user, course): diff --git a/cms/djangoapps/contentstore/features/courses.py b/cms/djangoapps/contentstore/features/courses.py index 7b3ccbcbd2..075d8525b5 100644 --- a/cms/djangoapps/contentstore/features/courses.py +++ b/cms/djangoapps/contentstore/features/courses.py @@ -33,8 +33,8 @@ def i_create_a_course(step): create_a_course() -@step('I click the course link in My Courses$') -def i_click_the_course_link_in_my_courses(step): +@step('I click the course link in Studio Home$') +def i_click_the_course_link_in_studio_home(step): course_css = 'a.course-link' world.css_click(course_css) @@ -52,8 +52,8 @@ def courseware_page_has_loaded_in_studio(step): assert world.is_css_present(course_title_css) -@step('I see the course listed in My Courses$') -def i_see_the_course_in_my_courses(step): +@step('I see the course listed in Studio Home$') +def i_see_the_course_in_studio_home(step): course_css = 'h3.class-title' assert world.css_has_text(course_css, world.scenario_dict['COURSE'].display_name) diff --git a/cms/djangoapps/contentstore/features/help.feature b/cms/djangoapps/contentstore/features/help.feature index eb0f872247..567a2f2526 100644 --- a/cms/djangoapps/contentstore/features/help.feature +++ b/cms/djangoapps/contentstore/features/help.feature @@ -11,7 +11,7 @@ Feature: CMS.Help Scenario: Users can access online help within a course Given I have opened a new course in Studio - And I click the course link in My Courses + And I click the course link in Studio Home Then I should see online help for "outline" And I go to the course updates page diff --git a/cms/djangoapps/contentstore/features/signup.feature b/cms/djangoapps/contentstore/features/signup.feature index 1fe254adaa..0954551059 100644 --- a/cms/djangoapps/contentstore/features/signup.feature +++ b/cms/djangoapps/contentstore/features/signup.feature @@ -26,7 +26,7 @@ Feature: CMS.Sign in And I visit the url "/signin?next=http://www.google.com/" When I fill in and submit the signin form And I wait for "2" seconds - Then I should see that the path is "/course/" + Then I should see that the path is "/home/" Scenario: Login with mistyped credentials Given I have opened a new course in Studio @@ -41,4 +41,4 @@ Feature: CMS.Sign in Then I should not see a login error message And I submit the signin form And I wait for "2" seconds - Then I should see that the path is "/course/" + Then I should see that the path is "/home/" diff --git a/cms/djangoapps/contentstore/features/signup.py b/cms/djangoapps/contentstore/features/signup.py index 014ff2eb69..b1c65edea5 100644 --- a/cms/djangoapps/contentstore/features/signup.py +++ b/cms/djangoapps/contentstore/features/signup.py @@ -25,7 +25,7 @@ def i_press_the_button_on_the_registration_form(step): @step('I should see an email verification prompt') def i_should_see_an_email_verification_prompt(step): - world.css_has_text('h1.page-header', u'My Courses') + world.css_has_text('h1.page-header', u'Studio Home') world.css_has_text('div.msg h3.title', u'We need to verify your email address') diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index e0122f4cd9..a107d65449 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -1166,11 +1166,10 @@ class ContentStoreTest(ContentStoreTestCase): def test_course_index_view_with_no_courses(self): """Test viewing the index page with no courses""" - # Create a course so there is something to view resp = self.client.get_html('/home/') self.assertContains( resp, - '

    My Courses

    ', + '

    Studio Home

    ', status_code=200, html=True ) diff --git a/cms/templates/js/mock/mock-index-page.underscore b/cms/templates/js/mock/mock-index-page.underscore index af8c3e973e..0dffc7d244 100644 --- a/cms/templates/js/mock/mock-index-page.underscore +++ b/cms/templates/js/mock/mock-index-page.underscore @@ -1,6 +1,6 @@
    -

    My Courses

    +

    Studio Home

    diff --git a/cms/templates/library.html b/cms/templates/library.html index c58b1d4e51..c66c7bfa44 100644 --- a/cms/templates/library.html +++ b/cms/templates/library.html @@ -43,7 +43,19 @@ from django.utils.translation import ugettext as _

    ${context_library.display_name_with_default | h}

    +
    + +
    From a0c590e50c2e72a374e3ce057a60a342ccd8fb46 Mon Sep 17 00:00:00 2001 From: Matjaz Gregoric Date: Sat, 3 Jan 2015 10:18:29 +0100 Subject: [PATCH 72/99] Mark Library 'mode' field non-editable. The only supported mode is currently 'random', so it doesn't make sense to expose the 'mode' in the edit form. --- common/lib/xmodule/xmodule/library_content_module.py | 9 +++++++++ .../lib/xmodule/xmodule/tests/test_library_content.py | 11 ++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/library_content_module.py b/common/lib/xmodule/xmodule/library_content_module.py index ed740af487..972919e2fa 100644 --- a/common/lib/xmodule/xmodule/library_content_module.py +++ b/common/lib/xmodule/xmodule/library_content_module.py @@ -322,6 +322,15 @@ class LibraryContentDescriptor(LibraryContentFields, MakoModuleDescriptor, XmlDe js = {'coffee': [resource_string(__name__, 'js/src/vertical/edit.coffee')]} js_module_name = "VerticalDescriptor" + @property + def non_editable_metadata_fields(self): + non_editable_fields = super(LibraryContentDescriptor, self).non_editable_metadata_fields + # The only supported mode is currently 'random'. + # Add the mode field to non_editable_metadata_fields so that it doesn't + # render in the edit form. + non_editable_fields.append(LibraryContentFields.mode) + return non_editable_fields + @XBlock.handler def refresh_children(self, request=None, suffix=None): # pylint: disable=unused-argument """ diff --git a/common/lib/xmodule/xmodule/tests/test_library_content.py b/common/lib/xmodule/xmodule/tests/test_library_content.py index b92404df10..bb8ac794f3 100644 --- a/common/lib/xmodule/xmodule/tests/test_library_content.py +++ b/common/lib/xmodule/xmodule/tests/test_library_content.py @@ -5,7 +5,7 @@ Basic unit tests for LibraryContentModule Higher-level tests are in `cms/djangoapps/contentstore/tests/test_libraries.py`. """ import ddt -from xmodule.library_content_module import LibraryVersionReference, ANY_CAPA_TYPE_VALUE +from xmodule.library_content_module import LibraryVersionReference, ANY_CAPA_TYPE_VALUE, LibraryContentDescriptor from xmodule.modulestore.tests.factories import LibraryFactory, CourseFactory, ItemFactory from xmodule.modulestore.tests.utils import MixedSplitTestCase from xmodule.tests import get_test_system @@ -242,3 +242,12 @@ class TestLibraries(MixedSplitTestCase): self.lc_block.capa_type = ANY_CAPA_TYPE_VALUE self.lc_block.refresh_children() self.assertEqual(len(self.lc_block.children), len(self.lib_blocks) + 4) + + def test_non_editable_settings(self): + """ + Test the settings that are marked as "non-editable". + """ + non_editable_metadata_fields = self.lc_block.non_editable_metadata_fields + self.assertIn(LibraryContentDescriptor.mode, non_editable_metadata_fields) + self.assertNotIn(LibraryContentDescriptor.display_name, non_editable_metadata_fields) + From 116abf1fc07039a714f71719684b6c6a86546407 Mon Sep 17 00:00:00 2001 From: Matjaz Gregoric Date: Sat, 3 Jan 2015 10:27:57 +0100 Subject: [PATCH 73/99] Modify error message when library with same org/code exists. Users are unlikely to change their organization name. --- cms/djangoapps/contentstore/views/library.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cms/djangoapps/contentstore/views/library.py b/cms/djangoapps/contentstore/views/library.py index c599cc1559..9aeb9c9b13 100644 --- a/cms/djangoapps/contentstore/views/library.py +++ b/cms/djangoapps/contentstore/views/library.py @@ -142,7 +142,7 @@ def _create_library(request): 'ErrMsg': _( 'There is already a library defined with the same ' 'organization and library code. Please ' - 'change either organization or library code to be unique.' + 'change your library code so that it is unique within your organization.' ) }) From 6a09809cdde36d59a59c20592d7a0cf003e05066 Mon Sep 17 00:00:00 2001 From: Matjaz Gregoric Date: Tue, 6 Jan 2015 09:56:49 +0100 Subject: [PATCH 74/99] Slower scroll to 'Add New Component'. --- cms/static/js/views/pages/container.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cms/static/js/views/pages/container.js b/cms/static/js/views/pages/container.js index 179a2a0529..5938217888 100644 --- a/cms/static/js/views/pages/container.js +++ b/cms/static/js/views/pages/container.js @@ -326,7 +326,7 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views scrollToNewComponentButtons: function(event) { event.preventDefault(); - $.scrollTo(this.$('.add-xblock-component')); + $.scrollTo(this.$('.add-xblock-component'), {duration: 250}); } }); From 5eab2c5f52f8d8a5d51894f659ba9ca1d50d68a1 Mon Sep 17 00:00:00 2001 From: Matjaz Gregoric Date: Tue, 6 Jan 2015 10:53:49 +0100 Subject: [PATCH 75/99] Update text on new library form. --- cms/templates/index.html | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/cms/templates/index.html b/cms/templates/index.html index 2b29e56bcf..8bf79551c7 100644 --- a/cms/templates/index.html +++ b/cms/templates/index.html @@ -134,9 +134,10 @@
  • - - - ${_("The {em_start}major version number{em_end} of your library. Minor revisions are tracked as edits happen within a library.").format(em_start='', em_end='')} + + ## Translators: This is an example for the "code" used to identify a library, seen when filling out the form to create a new library. This example is short for "Computer Science Problems". The example number may contain letters but must not contain spaces. + + ${_("The unique code that identifies this library.")} ${_("Note: This is part of your library URL, so no spaces or special characters are allowed.")} ${_("This cannot be changed.")}
  • From 99caf602cbcbb6336f36b5c04349e9bc3da251b9 Mon Sep 17 00:00:00 2001 From: "E. Kolpakov" Date: Tue, 6 Jan 2015 18:34:54 +0300 Subject: [PATCH 76/99] Refactoring create_course_utils and create_library_utils to reduce the amount of differences (mostly renames --- cms/static/js/index.js | 4 +- .../js/spec/views/pages/course_rerun_spec.js | 2 +- cms/static/js/views/course_rerun.js | 2 +- .../js/views/utils/create_course_utils.js | 74 ++++++++++--------- .../js/views/utils/create_library_utils.js | 31 ++++---- 5 files changed, 60 insertions(+), 53 deletions(-) diff --git a/cms/static/js/index.js b/cms/static/js/index.js index a78e0d5e90..74239e3a26 100644 --- a/cms/static/js/index.js +++ b/cms/static/js/index.js @@ -60,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) { + CreateCourseUtils.create(course_info, function (errorMessage) { $('.create-course .wrap-error').addClass('is-shown'); $('#course_creation_error').html('

    ' + errorMessage + '

    '); $('.new-course-save').addClass('is-disabled').attr('aria-disabled', true); @@ -114,7 +114,7 @@ define(["domReady", "jquery", "underscore", "js/utils/cancel_on_escape", "js/vie }; analytics.track('Created a Library', lib_info); - CreateLibraryUtils.createLibrary(lib_info, function (errorMessage) { + CreateLibraryUtils.create(lib_info, function (errorMessage) { $('.create-library .wrap-error').addClass('is-shown'); $('#library_creation_error').html('

    ' + errorMessage + '

    '); $('.new-library-save').addClass('is-disabled').attr('aria-disabled', true); 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 320f8d6557..ba6cd799aa 100644 --- a/cms/static/js/spec/views/pages/course_rerun_spec.js +++ b/cms/static/js/spec/views/pages/course_rerun_spec.js @@ -62,7 +62,7 @@ define(["jquery", "js/common_helpers/ajax_helpers", "js/spec_helpers/view_helper describe("Error messages", function () { var setErrorMessage = function(selector, message) { var element = $(selector).parent(); - CreateCourseUtils.setNewCourseFieldInErr(element, message); + CreateCourseUtils.setFieldInErr(element, message); return element; }; diff --git a/cms/static/js/views/course_rerun.js b/cms/static/js/views/course_rerun.js index b8d361aac4..9ef7a7f265 100644 --- a/cms/static/js/views/course_rerun.js +++ b/cms/static/js/views/course_rerun.js @@ -41,7 +41,7 @@ define(["domReady", "jquery", "underscore", "js/views/utils/create_course_utils" }; analytics.track('Reran a Course', course_info); - CreateCourseUtils.createCourse(course_info, function (errorMessage) { + CreateCourseUtils.create(course_info, function (errorMessage) { $('.wrapper-error').addClass('is-shown').removeClass('is-hidden'); $('#course_rerun_error').html('

    ' + errorMessage + '

    '); $('.rerun-course-save').addClass('is-disabled').attr('aria-disabled', true).removeClass('is-processing').html(gettext('Create Re-run')); diff --git a/cms/static/js/views/utils/create_course_utils.js b/cms/static/js/views/utils/create_course_utils.js index 735d22b708..6f01821547 100644 --- a/cms/static/js/views/utils/create_course_utils.js +++ b/cms/static/js/views/utils/create_course_utils.js @@ -3,38 +3,42 @@ */ define(["jquery", "underscore", "gettext", "js/views/utils/view_utils"], function ($, _, gettext, ViewUtils) { + "use strict"; return function (selectors, classes) { - var toggleSaveButton, validateTotalCourseItemsLength, setNewCourseFieldInErr, - hasInvalidRequiredFields, createCourse, validateFilledFields, configureHandlers; + var toggleSaveButton, validateTotalKeyLength, setFieldInErr, + hasInvalidRequiredFields, create, validateFilledFields, configureHandlers; var validateRequiredField = ViewUtils.validateRequiredField; var validateURLItemEncoding = ViewUtils.validateURLItemEncoding; - var keyLengthViolationMessage = gettext('The combined length of the organization, course number, and course run fields cannot be more than <%=limit%> characters.'); + var keyLengthViolationMessage = gettext("The combined length of the organization, course number, and course run fields cannot be more than <%=limit%> characters."); + + var keyFieldSelectors = [selectors.org, selectors.number, selectors.run]; + var nonEmptyCheckFieldSelectors = [selectors.name, selectors.org, selectors.number, selectors.run]; toggleSaveButton = function (is_enabled) { var is_disabled = !is_enabled; $(selectors.save).toggleClass(classes.disabled, is_disabled).attr('aria-disabled', is_disabled); }; - // Ensure that org, course_num and run passes checkTotalKeyLengthViolations - validateTotalCourseItemsLength = function () { + // Ensure that key fields passes checkTotalKeyLengthViolations check + validateTotalKeyLength = function () { ViewUtils.checkTotalKeyLengthViolations( selectors, classes, - [selectors.org, selectors.number, selectors.run], + keyFieldSelectors, keyLengthViolationMessage ); }; - setNewCourseFieldInErr = function (el, msg) { - if (msg) { - el.addClass(classes.error); - el.children(selectors.tipError).addClass(classes.showing).removeClass(classes.hiding).text(msg); + setFieldInErr = function (element, message) { + if (message) { + element.addClass(classes.error); + element.children(selectors.tipError).addClass(classes.showing).removeClass(classes.hiding).text(message); toggleSaveButton(false); } else { - el.removeClass(classes.error); - el.children(selectors.tipError).addClass(classes.hiding).removeClass(classes.showing); + 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) { toggleSaveButton(true); @@ -45,18 +49,18 @@ define(["jquery", "underscore", "gettext", "js/views/utils/view_utils"], // One final check for empty values hasInvalidRequiredFields = function () { return _.reduce( - [selectors.name, selectors.org, selectors.number, selectors.run], - function (acc, ele) { - var $ele = $(ele); - var error = validateRequiredField($ele.val()); - setNewCourseFieldInErr($ele.parent(), error); + nonEmptyCheckFieldSelectors, + function (acc, element) { + var $element = $(element); + var error = validateRequiredField($element.val()); + setFieldInErr($element.parent(), error); return error ? true : acc; }, false ); }; - createCourse = function (courseInfo, errorHandler) { + create = function (courseInfo, errorHandler) { $.postJSON( '/course/', courseInfo, @@ -73,10 +77,10 @@ define(["jquery", "underscore", "gettext", "js/views/utils/view_utils"], // Ensure that all fields are not empty validateFilledFields = function () { return _.reduce( - [selectors.org, selectors.number, selectors.run, selectors.name], - function (acc, ele) { - var $ele = $(ele); - return $ele.val().length !== 0 ? acc : false; + nonEmptyCheckFieldSelectors, + function (acc, element) { + var $element = $(element); + return $element.val().length !== 0 ? acc : false; }, true ); @@ -85,19 +89,19 @@ define(["jquery", "underscore", "gettext", "js/views/utils/view_utils"], // Handle validation asynchronously configureHandlers = function () { _.each( - [selectors.org, selectors.number, selectors.run], - function (ele) { - var $ele = $(ele); - $ele.on('keyup', function (event) { + keyFieldSelectors, + 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 === 9) { + if (event.keyCode === $.ui.keyCode.TAB) { return; } - var error = validateURLItemEncoding($ele.val(), $(selectors.allowUnicode).val() === 'True'); - setNewCourseFieldInErr($ele.parent(), error); - validateTotalCourseItemsLength(); + var error = validateURLItemEncoding($element.val(), $(selectors.allowUnicode).val() === 'True'); + setFieldInErr($element.parent(), error); + validateTotalKeyLength(); if (!validateFilledFields()) { toggleSaveButton(false); } @@ -107,8 +111,8 @@ define(["jquery", "underscore", "gettext", "js/views/utils/view_utils"], var $name = $(selectors.name); $name.on('keyup', function () { var error = validateRequiredField($name.val()); - setNewCourseFieldInErr($name.parent(), error); - validateTotalCourseItemsLength(); + setFieldInErr($name.parent(), error); + validateTotalKeyLength(); if (!validateFilledFields()) { toggleSaveButton(false); } @@ -116,10 +120,10 @@ define(["jquery", "underscore", "gettext", "js/views/utils/view_utils"], }; return { - validateTotalCourseItemsLength: validateTotalCourseItemsLength, - setNewCourseFieldInErr: setNewCourseFieldInErr, + validateTotalKeyLength: validateTotalKeyLength, + setFieldInErr: setFieldInErr, hasInvalidRequiredFields: hasInvalidRequiredFields, - createCourse: createCourse, + create: create, validateFilledFields: validateFilledFields, configureHandlers: configureHandlers }; diff --git a/cms/static/js/views/utils/create_library_utils.js b/cms/static/js/views/utils/create_library_utils.js index 7c7b6cffa1..d417e07d93 100644 --- a/cms/static/js/views/utils/create_library_utils.js +++ b/cms/static/js/views/utils/create_library_utils.js @@ -5,29 +5,32 @@ define(["jquery", "underscore", "gettext", "js/views/utils/view_utils"], function ($, _, gettext, ViewUtils) { "use strict"; return function (selectors, classes) { - var toggleSaveButton, validateTotalKeyLength, setNewLibraryFieldInErr, - hasInvalidRequiredFields, createLibrary, validateFilledFields, configureHandlers; + var toggleSaveButton, validateTotalKeyLength, setFieldInErr, + hasInvalidRequiredFields, create, 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."); + var keyFieldSelectors = [selectors.org, selectors.number]; + var nonEmptyCheckFieldSelectors = [selectors.name, selectors.org, selectors.number]; + toggleSaveButton = function (is_enabled) { var is_disabled = !is_enabled; $(selectors.save).toggleClass(classes.disabled, is_disabled).attr('aria-disabled', is_disabled); }; - // Ensure that org/librarycode passes validateTotalKeyLength check + // Ensure that key fields passes checkTotalKeyLengthViolations check validateTotalKeyLength = function () { ViewUtils.checkTotalKeyLengthViolations( selectors, classes, - [selectors.org, selectors.number], + keyFieldSelectors, keyLengthViolationMessage ); }; - setNewLibraryFieldInErr = function (element, message) { + setFieldInErr = function (element, message) { if (message) { element.addClass(classes.error); element.children(selectors.tipError).addClass(classes.showing).removeClass(classes.hiding).text(message); @@ -46,18 +49,18 @@ define(["jquery", "underscore", "gettext", "js/views/utils/view_utils"], // One final check for empty values hasInvalidRequiredFields = function () { return _.reduce( - [selectors.name, selectors.org, selectors.number], + nonEmptyCheckFieldSelectors, function (acc, element) { var $element = $(element); var error = validateRequiredField($element.val()); - setNewLibraryFieldInErr($element.parent(), error); + setFieldInErr($element.parent(), error); return error ? true : acc; }, false ); }; - createLibrary = function (libraryInfo, errorHandler) { + create = function (libraryInfo, errorHandler) { $.postJSON( '/library/', libraryInfo @@ -80,7 +83,7 @@ define(["jquery", "underscore", "gettext", "js/views/utils/view_utils"], // Ensure that all fields are not empty validateFilledFields = function () { return _.reduce( - [selectors.org, selectors.number, selectors.name], + nonEmptyCheckFieldSelectors, function (acc, element) { var $element = $(element); return $element.val().length !== 0 ? acc : false; @@ -92,7 +95,7 @@ define(["jquery", "underscore", "gettext", "js/views/utils/view_utils"], // Handle validation asynchronously configureHandlers = function () { _.each( - [selectors.org, selectors.number], + keyFieldSelectors, function (element) { var $element = $(element); $element.on('keyup', function (event) { @@ -103,7 +106,7 @@ define(["jquery", "underscore", "gettext", "js/views/utils/view_utils"], return; } var error = validateURLItemEncoding($element.val(), $(selectors.allowUnicode).val() === 'True'); - setNewLibraryFieldInErr($element.parent(), error); + setFieldInErr($element.parent(), error); validateTotalKeyLength(); if (!validateFilledFields()) { toggleSaveButton(false); @@ -114,7 +117,7 @@ define(["jquery", "underscore", "gettext", "js/views/utils/view_utils"], var $name = $(selectors.name); $name.on('keyup', function () { var error = validateRequiredField($name.val()); - setNewLibraryFieldInErr($name.parent(), error); + setFieldInErr($name.parent(), error); validateTotalKeyLength(); if (!validateFilledFields()) { toggleSaveButton(false); @@ -124,9 +127,9 @@ define(["jquery", "underscore", "gettext", "js/views/utils/view_utils"], return { validateTotalKeyLength: validateTotalKeyLength, - setNewLibraryFieldInErr: setNewLibraryFieldInErr, + setFieldInErr: setFieldInErr, hasInvalidRequiredFields: hasInvalidRequiredFields, - createLibrary: createLibrary, + create: create, validateFilledFields: validateFilledFields, configureHandlers: configureHandlers }; From 91469f902ff5939ae3ea52da88d5075204aa9ba3 Mon Sep 17 00:00:00 2001 From: Jonathan Piacenti Date: Tue, 6 Jan 2015 20:36:18 +0000 Subject: [PATCH 77/99] Refactored shared elements of create factories into base class. --- cms/static/js/index.js | 4 +- .../js/spec/views/pages/course_rerun_spec.js | 2 +- cms/static/js/views/course_rerun.js | 2 +- .../js/views/utils/create_course_utils.js | 111 +--------------- .../js/views/utils/create_library_utils.js | 115 +--------------- .../js/views/utils/create_utils_base.js | 123 ++++++++++++++++++ 6 files changed, 137 insertions(+), 220 deletions(-) create mode 100644 cms/static/js/views/utils/create_utils_base.js diff --git a/cms/static/js/index.js b/cms/static/js/index.js index 74239e3a26..e1c91d03a1 100644 --- a/cms/static/js/index.js +++ b/cms/static/js/index.js @@ -2,7 +2,7 @@ define(["domReady", "jquery", "underscore", "js/utils/cancel_on_escape", "js/vie "js/views/utils/create_library_utils", "js/views/utils/view_utils"], function (domReady, $, _, CancelOnEscape, CreateCourseUtilsFactory, CreateLibraryUtilsFactory, ViewUtils) { "use strict"; - var CreateCourseUtils = CreateCourseUtilsFactory({ + var CreateCourseUtils = new CreateCourseUtilsFactory({ name: '.new-course-name', org: '.new-course-org', number: '.new-course-number', @@ -21,7 +21,7 @@ define(["domReady", "jquery", "underscore", "js/utils/cancel_on_escape", "js/vie error: 'error' }); - var CreateLibraryUtils = CreateLibraryUtilsFactory({ + var CreateLibraryUtils = new CreateLibraryUtilsFactory({ name: '.new-library-name', org: '.new-library-org', number: '.new-library-number', 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 ba6cd799aa..01a5b9c858 100644 --- a/cms/static/js/spec/views/pages/course_rerun_spec.js +++ b/cms/static/js/spec/views/pages/course_rerun_spec.js @@ -26,7 +26,7 @@ define(["jquery", "js/common_helpers/ajax_helpers", "js/spec_helpers/view_helper }, mockCreateCourseRerunHTML = readFixtures('mock/mock-create-course-rerun.underscore'); - var CreateCourseUtils = CreateCourseUtilsFactory(selectors, classes); + var CreateCourseUtils = new CreateCourseUtilsFactory(selectors, classes); var fillInFields = function (org, number, run, name) { $(selectors.org).val(org); diff --git a/cms/static/js/views/course_rerun.js b/cms/static/js/views/course_rerun.js index 9ef7a7f265..cf02733b0f 100644 --- a/cms/static/js/views/course_rerun.js +++ b/cms/static/js/views/course_rerun.js @@ -1,6 +1,6 @@ define(["domReady", "jquery", "underscore", "js/views/utils/create_course_utils", "js/views/utils/view_utils"], function (domReady, $, _, CreateCourseUtilsFactory, ViewUtils) { - var CreateCourseUtils = CreateCourseUtilsFactory({ + var CreateCourseUtils = new CreateCourseUtilsFactory({ name: '.rerun-course-name', org: '.rerun-course-org', number: '.rerun-course-number', diff --git a/cms/static/js/views/utils/create_course_utils.js b/cms/static/js/views/utils/create_course_utils.js index 6f01821547..68ee1c7a70 100644 --- a/cms/static/js/views/utils/create_course_utils.js +++ b/cms/static/js/views/utils/create_course_utils.js @@ -1,66 +1,17 @@ /** * Provides utilities for validating courses during creation, for both new courses and reruns. */ -define(["jquery", "underscore", "gettext", "js/views/utils/view_utils"], - function ($, _, gettext, ViewUtils) { +define(["jquery", "gettext", "js/views/utils/view_utils", "js/views/utils/create_utils_base"], + function ($, gettext, ViewUtils, CreateUtilsFactory) { "use strict"; return function (selectors, classes) { - var toggleSaveButton, validateTotalKeyLength, setFieldInErr, - hasInvalidRequiredFields, create, validateFilledFields, configureHandlers; - - var validateRequiredField = ViewUtils.validateRequiredField; - var validateURLItemEncoding = ViewUtils.validateURLItemEncoding; - var keyLengthViolationMessage = gettext("The combined length of the organization, course number, and course run fields cannot be more than <%=limit%> characters."); - var keyFieldSelectors = [selectors.org, selectors.number, selectors.run]; var nonEmptyCheckFieldSelectors = [selectors.name, selectors.org, selectors.number, selectors.run]; - toggleSaveButton = function (is_enabled) { - var is_disabled = !is_enabled; - $(selectors.save).toggleClass(classes.disabled, is_disabled).attr('aria-disabled', is_disabled); - }; + CreateUtilsFactory.call(this, selectors, classes, keyLengthViolationMessage, keyFieldSelectors, nonEmptyCheckFieldSelectors); - // Ensure that key fields passes checkTotalKeyLengthViolations check - validateTotalKeyLength = function () { - ViewUtils.checkTotalKeyLengthViolations( - selectors, classes, - keyFieldSelectors, - keyLengthViolationMessage - ); - }; - - setFieldInErr = function (element, message) { - if (message) { - element.addClass(classes.error); - element.children(selectors.tipError).addClass(classes.showing).removeClass(classes.hiding).text(message); - toggleSaveButton(false); - } - 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) { - toggleSaveButton(true); - } - } - }; - - // One final check for empty values - hasInvalidRequiredFields = function () { - return _.reduce( - nonEmptyCheckFieldSelectors, - function (acc, element) { - var $element = $(element); - var error = validateRequiredField($element.val()); - setFieldInErr($element.parent(), error); - return error ? true : acc; - }, - false - ); - }; - - create = function (courseInfo, errorHandler) { + this.create = function (courseInfo, errorHandler) { $.postJSON( '/course/', courseInfo, @@ -73,59 +24,5 @@ define(["jquery", "underscore", "gettext", "js/views/utils/view_utils"], } ); }; - - // Ensure that all fields are not empty - validateFilledFields = function () { - return _.reduce( - nonEmptyCheckFieldSelectors, - function (acc, element) { - var $element = $(element); - return $element.val().length !== 0 ? acc : false; - }, - true - ); - }; - - // Handle validation asynchronously - configureHandlers = function () { - _.each( - keyFieldSelectors, - 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'); - setFieldInErr($element.parent(), error); - validateTotalKeyLength(); - if (!validateFilledFields()) { - toggleSaveButton(false); - } - }); - } - ); - var $name = $(selectors.name); - $name.on('keyup', function () { - var error = validateRequiredField($name.val()); - setFieldInErr($name.parent(), error); - validateTotalKeyLength(); - if (!validateFilledFields()) { - toggleSaveButton(false); - } - }); - }; - - return { - validateTotalKeyLength: validateTotalKeyLength, - setFieldInErr: setFieldInErr, - hasInvalidRequiredFields: hasInvalidRequiredFields, - create: create, - validateFilledFields: validateFilledFields, - configureHandlers: configureHandlers - }; }; }); diff --git a/cms/static/js/views/utils/create_library_utils.js b/cms/static/js/views/utils/create_library_utils.js index d417e07d93..9cda9580da 100644 --- a/cms/static/js/views/utils/create_library_utils.js +++ b/cms/static/js/views/utils/create_library_utils.js @@ -1,66 +1,17 @@ /** * Provides utilities for validating libraries during creation. */ -define(["jquery", "underscore", "gettext", "js/views/utils/view_utils"], - function ($, _, gettext, ViewUtils) { +define(["jquery", "gettext", "js/views/utils/view_utils", "js/views/utils/create_utils_base"], + function ($, gettext, ViewUtils, CreateUtilsFactory) { "use strict"; return function (selectors, classes) { - var toggleSaveButton, validateTotalKeyLength, setFieldInErr, - hasInvalidRequiredFields, create, 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."); - + var keyLengthViolationMessage = gettext("The combined length of the organization and library code fields cannot be more than <%=limit%> characters.") var keyFieldSelectors = [selectors.org, selectors.number]; var nonEmptyCheckFieldSelectors = [selectors.name, selectors.org, selectors.number]; - toggleSaveButton = function (is_enabled) { - var is_disabled = !is_enabled; - $(selectors.save).toggleClass(classes.disabled, is_disabled).attr('aria-disabled', is_disabled); - }; + CreateUtilsFactory.call(this, selectors, classes, keyLengthViolationMessage, keyFieldSelectors, nonEmptyCheckFieldSelectors); - // Ensure that key fields passes checkTotalKeyLengthViolations check - validateTotalKeyLength = function () { - ViewUtils.checkTotalKeyLengthViolations( - selectors, classes, - keyFieldSelectors, - keyLengthViolationMessage - ); - }; - - setFieldInErr = function (element, message) { - if (message) { - element.addClass(classes.error); - element.children(selectors.tipError).addClass(classes.showing).removeClass(classes.hiding).text(message); - toggleSaveButton(false); - } - 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) { - toggleSaveButton(true); - } - } - }; - - // One final check for empty values - hasInvalidRequiredFields = function () { - return _.reduce( - nonEmptyCheckFieldSelectors, - function (acc, element) { - var $element = $(element); - var error = validateRequiredField($element.val()); - setFieldInErr($element.parent(), error); - return error ? true : acc; - }, - false - ); - }; - - create = function (libraryInfo, errorHandler) { + this.create = function (libraryInfo, errorHandler) { $.postJSON( '/library/', libraryInfo @@ -78,60 +29,6 @@ define(["jquery", "underscore", "gettext", "js/views/utils/view_utils"], } errorHandler(reason); }); - }; - - // Ensure that all fields are not empty - validateFilledFields = function () { - return _.reduce( - nonEmptyCheckFieldSelectors, - function (acc, element) { - var $element = $(element); - return $element.val().length !== 0 ? acc : false; - }, - true - ); - }; - - // Handle validation asynchronously - configureHandlers = function () { - _.each( - keyFieldSelectors, - 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'); - setFieldInErr($element.parent(), error); - validateTotalKeyLength(); - if (!validateFilledFields()) { - toggleSaveButton(false); - } - }); - } - ); - var $name = $(selectors.name); - $name.on('keyup', function () { - var error = validateRequiredField($name.val()); - setFieldInErr($name.parent(), error); - validateTotalKeyLength(); - if (!validateFilledFields()) { - toggleSaveButton(false); - } - }); - }; - - return { - validateTotalKeyLength: validateTotalKeyLength, - setFieldInErr: setFieldInErr, - hasInvalidRequiredFields: hasInvalidRequiredFields, - create: create, - validateFilledFields: validateFilledFields, - configureHandlers: configureHandlers - }; + } }; }); diff --git a/cms/static/js/views/utils/create_utils_base.js b/cms/static/js/views/utils/create_utils_base.js new file mode 100644 index 0000000000..480587223b --- /dev/null +++ b/cms/static/js/views/utils/create_utils_base.js @@ -0,0 +1,123 @@ +/** + * Mixin class for creation of things like courses and libraries. + */ +define(["jquery", "underscore", "gettext", "js/views/utils/view_utils"], + function ($, _, gettext, ViewUtils) { + return function (selectors, classes, keyLengthViolationMessage, keyFieldSelectors, nonEmptyCheckFieldSelectors) { + var self = this; + + this.selectors = selectors; + this.classes = classes; + this.validateRequiredField = ViewUtils.validateRequiredField; + this.validateURLItemEncoding = ViewUtils.validateURLItemEncoding; + this.keyLengthViolationMessage = keyLengthViolationMessage; + // Key fields for your model, like [selectors.org, selectors.number] + this.keyFieldSelectors = keyFieldSelectors; + // Fields that must not be empty on your model. + this.nonEmptyCheckFieldSelectors = nonEmptyCheckFieldSelectors; + + this.create = function (courseInfo, errorHandler) { + // Replace this with a function that will make a request to create the object. + }; + + // Ensure that key fields passes checkTotalKeyLengthViolations check + this.validateTotalKeyLength = function () { + ViewUtils.checkTotalKeyLengthViolations( + self.selectors, self.classes, + self.keyFieldSelectors, + self.keyLengthViolationMessage + ); + }; + + this.toggleSaveButton = function (is_enabled) { + var is_disabled = !is_enabled; + $(self.selectors.save).toggleClass(self.classes.disabled, is_disabled).attr('aria-disabled', is_disabled); + }; + + this.setFieldInErr = function (element, message) { + if (message) { + element.addClass(self.classes.error); + element.children(self.selectors.tipError).addClass(self.classes.showing).removeClass(self.classes.hiding).text(message); + self.toggleSaveButton(false); + } + else { + element.removeClass(self.classes.error); + element.children(self.selectors.tipError).addClass(self.classes.hiding).removeClass(self.classes.showing); + // One "error" div is always present, but hidden or shown + if ($(self.selectors.error).length === 1) { + self.toggleSaveButton(true); + } + } + }; + + // One final check for empty values + this.hasInvalidRequiredFields = function () { + return _.reduce( + self.nonEmptyCheckFieldSelectors, + function (acc, element) { + var $element = $(element); + var error = self.validateRequiredField($element.val()); + self.setFieldInErr($element.parent(), error); + return error ? true : acc; + }, + false + ); + }; + + // Ensure that all fields are not empty + this.validateFilledFields = function () { + return _.reduce( + self.nonEmptyCheckFieldSelectors, + function (acc, element) { + var $element = $(element); + return $element.val().length !== 0 ? acc : false; + }, + true + ); + }; + + // Handle validation asynchronously + this.configureHandlers = function () { + _.each( + self.keyFieldSelectors, + 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 = self.validateURLItemEncoding($element.val(), $(self.selectors.allowUnicode).val() === 'True'); + self.setFieldInErr($element.parent(), error); + self.validateTotalKeyLength(); + if (!self.validateFilledFields()) { + self.toggleSaveButton(false); + } + }); + } + ); + + var $name = $(self.selectors.name); + $name.on('keyup', function () { + var error = self.validateRequiredField($name.val()); + self.setFieldInErr($name.parent(), error); + self.validateTotalKeyLength(); + if (!self.validateFilledFields()) { + self.toggleSaveButton(false); + } + }); + }; + + return { + validateTotalKeyLength: self.validateTotalKeyLength, + setFieldInErr: self.setFieldInErr, + hasInvalidRequiredFields: self.hasInvalidRequiredFields, + create: self.create, + validateFilledFields: self.validateFilledFields, + configureHandlers: self.configureHandlers + }; + } + } +); From 5342fa27b9cad5b089b074e5301b381438432ff5 Mon Sep 17 00:00:00 2001 From: Jonathan Piacenti Date: Wed, 7 Jan 2015 20:05:44 +0000 Subject: [PATCH 78/99] Quality fixes. --- cms/djangoapps/contentstore/features/courses.py | 2 ++ common/djangoapps/terrain/ui_helpers.py | 1 + common/lib/xmodule/xmodule/tests/test_library_content.py | 1 - 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/cms/djangoapps/contentstore/features/courses.py b/cms/djangoapps/contentstore/features/courses.py index 075d8525b5..2640b5c233 100644 --- a/cms/djangoapps/contentstore/features/courses.py +++ b/cms/djangoapps/contentstore/features/courses.py @@ -33,6 +33,7 @@ def i_create_a_course(step): create_a_course() +# pylint disable=unused-argument, invalid-name @step('I click the course link in Studio Home$') def i_click_the_course_link_in_studio_home(step): course_css = 'a.course-link' @@ -53,6 +54,7 @@ def courseware_page_has_loaded_in_studio(step): @step('I see the course listed in Studio Home$') +# pylint disable=unused-argument def i_see_the_course_in_studio_home(step): course_css = 'h3.class-title' assert world.css_has_text(course_css, world.scenario_dict['COURSE'].display_name) diff --git a/common/djangoapps/terrain/ui_helpers.py b/common/djangoapps/terrain/ui_helpers.py index 57e90820f8..f13811467a 100644 --- a/common/djangoapps/terrain/ui_helpers.py +++ b/common/djangoapps/terrain/ui_helpers.py @@ -48,6 +48,7 @@ REQUIREJS_WAIT = { "js/base", "js/models/course", "js/models/location", "js/models/section"], # Dashboard + # pylint disable=anomalous-backslash-in-string re.compile('^Studio Home \|'): [ "js/sock", "gettext", "js/base", "jquery.ui", "coffee/src/main", "underscore"], diff --git a/common/lib/xmodule/xmodule/tests/test_library_content.py b/common/lib/xmodule/xmodule/tests/test_library_content.py index bb8ac794f3..a9b924503d 100644 --- a/common/lib/xmodule/xmodule/tests/test_library_content.py +++ b/common/lib/xmodule/xmodule/tests/test_library_content.py @@ -250,4 +250,3 @@ class TestLibraries(MixedSplitTestCase): non_editable_metadata_fields = self.lc_block.non_editable_metadata_fields self.assertIn(LibraryContentDescriptor.mode, non_editable_metadata_fields) self.assertNotIn(LibraryContentDescriptor.display_name, non_editable_metadata_fields) - From 23baab6b0109da8e5c1b49364a3b9d7108992057 Mon Sep 17 00:00:00 2001 From: "E. Kolpakov" Date: Thu, 8 Jan 2015 13:04:25 +0300 Subject: [PATCH 79/99] Added test to exercise library root paged rendering --- .../modulestore/tests/test_libraries.py | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_libraries.py b/common/lib/xmodule/xmodule/modulestore/tests/test_libraries.py index ef8a6d4d69..6038e7ca81 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_libraries.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_libraries.py @@ -207,6 +207,44 @@ class TestLibraries(MixedSplitTestCase): result = library.render(AUTHOR_VIEW, context) self.assertIn(message, result.content) + @patch('xmodule.modulestore.split_mongo.caching_descriptor_system.CachingDescriptorSystem.render', VanillaRuntime.render) + def test_library_author_view_with_paging(self): + """ + Test that LibraryRoot.author_view can apply paging + We have to patch the runtime (module system) in order to be able to + render blocks in our test environment. + """ + library = LibraryFactory.create(modulestore=self.store) + # Add five HTML blocks to the library: + blocks = [ + ItemFactory.create( + category="html", + parent_location=library.location, + user_id=self.user_id, + publish_item=False, + modulestore=self.store, + data="HtmlBlock"+str(i) + ) + for i in range(5) + ] + library = self.store.get_library(library.location.library_key) + + def render_and_check_contents(page, page_size): + context = {'reorderable_items': set(), 'paging': {'page_number': page, 'page_size': page_size}} + expected_blocks = blocks[page_size*page:page_size*(page+1)] + result = library.render(AUTHOR_VIEW, context) + + for expected_block in expected_blocks: + self.assertIn(expected_block.data, result.content) + + hello_render = lambda block, _: Fragment(block.data) + with patch('xmodule.html_module.HtmlDescriptor.author_view', hello_render, create=True): + with patch('xmodule.x_module.DescriptorSystem.applicable_aside_types', lambda self, block: []): + render_and_check_contents(0, 3) + render_and_check_contents(1, 3) + render_and_check_contents(0, 2) + render_and_check_contents(1, 2) + def test_xblock_in_lib_have_published_version_returns_false(self): library = LibraryFactory.create(modulestore=self.store) block = ItemFactory.create( From 184fd01ae9530a441ae7d0a37ed5be77ff5cbc8c Mon Sep 17 00:00:00 2001 From: "E. Kolpakov" Date: Thu, 8 Jan 2015 13:15:10 +0300 Subject: [PATCH 80/99] Created dedicated LibraryRoot test file and moved rendering tests from modulestore/tests/test_libraries --- .../modulestore/tests/test_libraries.py | 72 +----------------- .../xmodule/tests/test_library_root.py | 76 +++++++++++++++++++ 2 files changed, 78 insertions(+), 70 deletions(-) create mode 100644 common/lib/xmodule/xmodule/tests/test_library_root.py diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_libraries.py b/common/lib/xmodule/xmodule/modulestore/tests/test_libraries.py index 6038e7ca81..03ee84ae35 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_libraries.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_libraries.py @@ -6,14 +6,12 @@ Higher-level tests are in `cms/djangoapps/contentstore`. """ from bson.objectid import ObjectId import ddt -from mock import patch from opaque_keys.edx.locator import LibraryLocator -from xblock.fragment import Fragment -from xblock.runtime import Runtime as VanillaRuntime + from xmodule.modulestore.exceptions import DuplicateCourseError from xmodule.modulestore.tests.factories import LibraryFactory, ItemFactory, check_mongo_calls from xmodule.modulestore.tests.utils import MixedSplitTestCase -from xmodule.x_module import AUTHOR_VIEW + @ddt.ddt @@ -179,72 +177,6 @@ class TestLibraries(MixedSplitTestCase): version = lib.location.library_key.version_guid self.assertIsInstance(version, ObjectId) - @patch('xmodule.modulestore.split_mongo.caching_descriptor_system.CachingDescriptorSystem.render', VanillaRuntime.render) - def test_library_author_view(self): - """ - Test that LibraryRoot.author_view can run and includes content from its - children. - We have to patch the runtime (module system) in order to be able to - render blocks in our test environment. - """ - library = LibraryFactory.create(modulestore=self.store) - # Add one HTML block to the library: - ItemFactory.create( - category="html", - parent_location=library.location, - user_id=self.user_id, - publish_item=False, - modulestore=self.store, - ) - library = self.store.get_library(library.location.library_key) - - context = {'reorderable_items': set(), } - # Patch the HTML block to always render "Hello world" - message = u"Hello world" - hello_render = lambda _, context: Fragment(message) - with patch('xmodule.html_module.HtmlDescriptor.author_view', hello_render, create=True): - with patch('xmodule.x_module.DescriptorSystem.applicable_aside_types', lambda self, block: []): - result = library.render(AUTHOR_VIEW, context) - self.assertIn(message, result.content) - - @patch('xmodule.modulestore.split_mongo.caching_descriptor_system.CachingDescriptorSystem.render', VanillaRuntime.render) - def test_library_author_view_with_paging(self): - """ - Test that LibraryRoot.author_view can apply paging - We have to patch the runtime (module system) in order to be able to - render blocks in our test environment. - """ - library = LibraryFactory.create(modulestore=self.store) - # Add five HTML blocks to the library: - blocks = [ - ItemFactory.create( - category="html", - parent_location=library.location, - user_id=self.user_id, - publish_item=False, - modulestore=self.store, - data="HtmlBlock"+str(i) - ) - for i in range(5) - ] - library = self.store.get_library(library.location.library_key) - - def render_and_check_contents(page, page_size): - context = {'reorderable_items': set(), 'paging': {'page_number': page, 'page_size': page_size}} - expected_blocks = blocks[page_size*page:page_size*(page+1)] - result = library.render(AUTHOR_VIEW, context) - - for expected_block in expected_blocks: - self.assertIn(expected_block.data, result.content) - - hello_render = lambda block, _: Fragment(block.data) - with patch('xmodule.html_module.HtmlDescriptor.author_view', hello_render, create=True): - with patch('xmodule.x_module.DescriptorSystem.applicable_aside_types', lambda self, block: []): - render_and_check_contents(0, 3) - render_and_check_contents(1, 3) - render_and_check_contents(0, 2) - render_and_check_contents(1, 2) - def test_xblock_in_lib_have_published_version_returns_false(self): library = LibraryFactory.create(modulestore=self.store) block = ItemFactory.create( diff --git a/common/lib/xmodule/xmodule/tests/test_library_root.py b/common/lib/xmodule/xmodule/tests/test_library_root.py new file mode 100644 index 0000000000..e17dafef85 --- /dev/null +++ b/common/lib/xmodule/xmodule/tests/test_library_root.py @@ -0,0 +1,76 @@ +from mock import patch + +from xblock.fragment import Fragment +from xblock.runtime import Runtime as VanillaRuntime +from xmodule.x_module import AUTHOR_VIEW + +from xmodule.modulestore.tests.factories import LibraryFactory, CourseFactory, ItemFactory +from xmodule.modulestore.tests.utils import MixedSplitTestCase + + +class TestLibraryRoot(MixedSplitTestCase): + @patch('xmodule.modulestore.split_mongo.caching_descriptor_system.CachingDescriptorSystem.render', VanillaRuntime.render) + def test_library_author_view(self): + """ + Test that LibraryRoot.author_view can run and includes content from its + children. + We have to patch the runtime (module system) in order to be able to + render blocks in our test environment. + """ + library = LibraryFactory.create(modulestore=self.store) + # Add one HTML block to the library: + ItemFactory.create( + category="html", + parent_location=library.location, + user_id=self.user_id, + publish_item=False, + modulestore=self.store, + ) + library = self.store.get_library(library.location.library_key) + + context = {'reorderable_items': set(), } + # Patch the HTML block to always render "Hello world" + message = u"Hello world" + hello_render = lambda _, context: Fragment(message) + with patch('xmodule.html_module.HtmlDescriptor.author_view', hello_render, create=True): + with patch('xmodule.x_module.DescriptorSystem.applicable_aside_types', lambda self, block: []): + result = library.render(AUTHOR_VIEW, context) + self.assertIn(message, result.content) + + @patch('xmodule.modulestore.split_mongo.caching_descriptor_system.CachingDescriptorSystem.render', VanillaRuntime.render) + def test_library_author_view_with_paging(self): + """ + Test that LibraryRoot.author_view can apply paging + We have to patch the runtime (module system) in order to be able to + render blocks in our test environment. + """ + library = LibraryFactory.create(modulestore=self.store) + # Add five HTML blocks to the library: + blocks = [ + ItemFactory.create( + category="html", + parent_location=library.location, + user_id=self.user_id, + publish_item=False, + modulestore=self.store, + data="HtmlBlock"+str(i) + ) + for i in range(5) + ] + library = self.store.get_library(library.location.library_key) + + def render_and_check_contents(page, page_size): + context = {'reorderable_items': set(), 'paging': {'page_number': page, 'page_size': page_size}} + expected_blocks = blocks[page_size*page:page_size*(page+1)] + result = library.render(AUTHOR_VIEW, context) + + for expected_block in expected_blocks: + self.assertIn(expected_block.data, result.content) + + hello_render = lambda block, _: Fragment(block.data) + with patch('xmodule.html_module.HtmlDescriptor.author_view', hello_render, create=True): + with patch('xmodule.x_module.DescriptorSystem.applicable_aside_types', lambda self, block: []): + render_and_check_contents(0, 3) + render_and_check_contents(1, 3) + render_and_check_contents(0, 2) + render_and_check_contents(1, 2) \ No newline at end of file From e59071ed67f7c44851349873fe95afa547d06480 Mon Sep 17 00:00:00 2001 From: "E. Kolpakov" Date: Thu, 8 Jan 2015 14:17:24 +0300 Subject: [PATCH 81/99] Test for ValueError when updating_children for non-existing library --- .../contentstore/tests/test_libraries.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/cms/djangoapps/contentstore/tests/test_libraries.py b/cms/djangoapps/contentstore/tests/test_libraries.py index a3414266bd..957d8b0faa 100644 --- a/cms/djangoapps/contentstore/tests/test_libraries.py +++ b/cms/djangoapps/contentstore/tests/test_libraries.py @@ -419,6 +419,24 @@ class TestLibraries(LibraryTestCase): html_block = modulestore().get_item(lc_block.children[0]) self.assertEqual(html_block.display_name, name2) + def test_refresh_fails_for_unknown_library(self): + # 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), 0) + + # Now, change the block settings to have an invalid library key: + resp = self._update_item( + lc_block.location, + {"source_libraries": [["library-v1:NOT+FOUND", None]]}, + ) + self.assertEqual(resp.status_code, 200) + with self.assertRaises(ValueError): + self._refresh_children(lc_block, status_code_expected=400) @ddt.ddt class TestLibraryAccess(LibraryTestCase): From d0919c93ce8ba6225a90596a12c65a1c5ab12333 Mon Sep 17 00:00:00 2001 From: "E. Kolpakov" Date: Thu, 8 Jan 2015 14:31:08 +0300 Subject: [PATCH 82/99] Added docstrings --- cms/djangoapps/contentstore/tests/test_libraries.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cms/djangoapps/contentstore/tests/test_libraries.py b/cms/djangoapps/contentstore/tests/test_libraries.py index 957d8b0faa..1d022820f3 100644 --- a/cms/djangoapps/contentstore/tests/test_libraries.py +++ b/cms/djangoapps/contentstore/tests/test_libraries.py @@ -327,6 +327,7 @@ class TestLibraries(LibraryTestCase): self.assertEqual(html_block.data, data_value) def test_refreshes_children_if_libraries_change(self): + """ Tests that children are automatically refreshed if libraries list changes """ library2key = self._create_library("org2", "lib2", "Library2") library2 = modulestore().get_library(library2key) data1, data2 = "Hello world!", "Hello other world!" @@ -370,6 +371,7 @@ class TestLibraries(LibraryTestCase): self.assertEqual(html_block.data, data2) def test_refreshes_children_if_capa_type_change(self): + """ Tests that children are automatically refreshed if capa type field changes """ name1, name2 = "Option Problem", "Multiple Choice Problem" ItemFactory.create( category="problem", @@ -420,6 +422,7 @@ class TestLibraries(LibraryTestCase): self.assertEqual(html_block.display_name, name2) def test_refresh_fails_for_unknown_library(self): + """ Tests that refresh children fails if unknown library is configured """ # Create a course: with modulestore().default_store(ModuleStoreEnum.Type.split): course = CourseFactory.create() From 84edced10a110686c8876b9e82ba5d22983b9f66 Mon Sep 17 00:00:00 2001 From: "E. Kolpakov" Date: Thu, 8 Jan 2015 17:10:38 +0300 Subject: [PATCH 83/99] Render tests for library content block preview and author views --- .../xmodule/tests/test_library_content.py | 51 +++++++++++++++++-- .../xmodule/tests/test_library_root.py | 27 +++++----- 2 files changed, 59 insertions(+), 19 deletions(-) diff --git a/common/lib/xmodule/xmodule/tests/test_library_content.py b/common/lib/xmodule/xmodule/tests/test_library_content.py index a9b924503d..4c88a40e6d 100644 --- a/common/lib/xmodule/xmodule/tests/test_library_content.py +++ b/common/lib/xmodule/xmodule/tests/test_library_content.py @@ -5,6 +5,12 @@ Basic unit tests for LibraryContentModule Higher-level tests are in `cms/djangoapps/contentstore/tests/test_libraries.py`. """ import ddt +from mock import patch + +from xblock.fragment import Fragment +from xblock.runtime import Runtime as VanillaRuntime + +from xmodule.x_module import AUTHOR_VIEW, STUDENT_VIEW from xmodule.library_content_module import LibraryVersionReference, ANY_CAPA_TYPE_VALUE, LibraryContentDescriptor from xmodule.modulestore.tests.factories import LibraryFactory, CourseFactory, ItemFactory from xmodule.modulestore.tests.utils import MixedSplitTestCase @@ -12,13 +18,15 @@ from xmodule.tests import get_test_system from xmodule.validation import StudioValidationMessage -@ddt.ddt -class TestLibraries(MixedSplitTestCase): +_dummy_render = lambda block, _: Fragment(block.data) + + +class BaseTestLibraryContainer(MixedSplitTestCase): """ - Basic unit tests for LibraryContentModule (library_content_module.py) + Base class for TestLibraryContainer and TestLibraryContainerRender """ def setUp(self): - super(TestLibraries, self).setUp() + super(BaseTestLibraryContainer, self).setUp() self.library = LibraryFactory.create(modulestore=self.store) self.lib_blocks = [ @@ -62,7 +70,7 @@ class TestLibraries(MixedSplitTestCase): } ) - def _bind_course_module(self, module): + def _bind_course_module(self, module, render=None): """ Bind a module (part of self.course) so we can access student-specific data. """ @@ -108,6 +116,11 @@ class TestLibraries(MixedSplitTestCase): modulestore=self.store, ) +@ddt.ddt +class TestLibraryContainer(BaseTestLibraryContainer): + """ + Basic unit tests for LibraryContentModule (library_content_module.py) + """ def test_lib_content_block(self): """ Test that blocks from a library are copied and added as children @@ -250,3 +263,31 @@ class TestLibraries(MixedSplitTestCase): non_editable_metadata_fields = self.lc_block.non_editable_metadata_fields self.assertIn(LibraryContentDescriptor.mode, non_editable_metadata_fields) self.assertNotIn(LibraryContentDescriptor.display_name, non_editable_metadata_fields) + + +@patch('xmodule.modulestore.split_mongo.caching_descriptor_system.CachingDescriptorSystem.render', VanillaRuntime.render) +@patch('xmodule.html_module.HtmlModule.author_view', _dummy_render, create=True) +@patch('xmodule.html_module.HtmlModule.student_view', _dummy_render, create=True) +@patch('xmodule.x_module.DescriptorSystem.applicable_aside_types', lambda self, block: []) +class TestLibraryContentRender(BaseTestLibraryContainer): + """ + Rendering unit tests for LibraryContentModule (library_content_module.py) + """ + def test_preivew_view(self): + """ Test preview view rendering """ + self.lc_block.refresh_children() + self.lc_block = self.store.get_item(self.lc_block.location) + self.assertEqual(len(self.lc_block.children), len(self.lib_blocks)) + self._bind_course_module(self.lc_block) + rendered = self.lc_block.render(AUTHOR_VIEW, {'root_xblock': self.lc_block}) + self.assertIn("Hello world from block 1", rendered.content) + + def test_author_view(self): + """ Test author view rendering """ + self.lc_block.refresh_children() + self.lc_block = self.store.get_item(self.lc_block.location) + self.assertEqual(len(self.lc_block.children), len(self.lib_blocks)) + self._bind_course_module(self.lc_block) + rendered = self.lc_block.render(AUTHOR_VIEW, {}) + self.assertEqual("", rendered.content) # content should be empty + self.assertEqual("LibraryContentAuthorView", rendered.js_init_fn) # but some js initialization should happen \ No newline at end of file diff --git a/common/lib/xmodule/xmodule/tests/test_library_root.py b/common/lib/xmodule/xmodule/tests/test_library_root.py index e17dafef85..0dc0d1d7d3 100644 --- a/common/lib/xmodule/xmodule/tests/test_library_root.py +++ b/common/lib/xmodule/xmodule/tests/test_library_root.py @@ -7,9 +7,13 @@ from xmodule.x_module import AUTHOR_VIEW from xmodule.modulestore.tests.factories import LibraryFactory, CourseFactory, ItemFactory from xmodule.modulestore.tests.utils import MixedSplitTestCase +_dummy_render = lambda block, _: Fragment(block.data) + +@patch('xmodule.modulestore.split_mongo.caching_descriptor_system.CachingDescriptorSystem.render', VanillaRuntime.render) +@patch('xmodule.html_module.HtmlDescriptor.author_view', _dummy_render, create=True) +@patch('xmodule.x_module.DescriptorSystem.applicable_aside_types', lambda self, block: []) class TestLibraryRoot(MixedSplitTestCase): - @patch('xmodule.modulestore.split_mongo.caching_descriptor_system.CachingDescriptorSystem.render', VanillaRuntime.render) def test_library_author_view(self): """ Test that LibraryRoot.author_view can run and includes content from its @@ -17,6 +21,7 @@ class TestLibraryRoot(MixedSplitTestCase): We have to patch the runtime (module system) in order to be able to render blocks in our test environment. """ + message = u"Hello world" library = LibraryFactory.create(modulestore=self.store) # Add one HTML block to the library: ItemFactory.create( @@ -25,19 +30,16 @@ class TestLibraryRoot(MixedSplitTestCase): user_id=self.user_id, publish_item=False, modulestore=self.store, + data=message ) library = self.store.get_library(library.location.library_key) context = {'reorderable_items': set(), } # Patch the HTML block to always render "Hello world" - message = u"Hello world" - hello_render = lambda _, context: Fragment(message) - with patch('xmodule.html_module.HtmlDescriptor.author_view', hello_render, create=True): - with patch('xmodule.x_module.DescriptorSystem.applicable_aside_types', lambda self, block: []): - result = library.render(AUTHOR_VIEW, context) + + result = library.render(AUTHOR_VIEW, context) self.assertIn(message, result.content) - @patch('xmodule.modulestore.split_mongo.caching_descriptor_system.CachingDescriptorSystem.render', VanillaRuntime.render) def test_library_author_view_with_paging(self): """ Test that LibraryRoot.author_view can apply paging @@ -67,10 +69,7 @@ class TestLibraryRoot(MixedSplitTestCase): for expected_block in expected_blocks: self.assertIn(expected_block.data, result.content) - hello_render = lambda block, _: Fragment(block.data) - with patch('xmodule.html_module.HtmlDescriptor.author_view', hello_render, create=True): - with patch('xmodule.x_module.DescriptorSystem.applicable_aside_types', lambda self, block: []): - render_and_check_contents(0, 3) - render_and_check_contents(1, 3) - render_and_check_contents(0, 2) - render_and_check_contents(1, 2) \ No newline at end of file + render_and_check_contents(0, 3) + render_and_check_contents(1, 3) + render_and_check_contents(0, 2) + render_and_check_contents(1, 2) \ No newline at end of file From f51329039ac7a77d0c14428c66d66bae4e681a4d Mon Sep 17 00:00:00 2001 From: "E. Kolpakov" Date: Thu, 8 Jan 2015 19:13:19 +0300 Subject: [PATCH 84/99] Tests for LibraryList --- .../xmodule/tests/test_library_content.py | 49 ++++++++++++++++++- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/common/lib/xmodule/xmodule/tests/test_library_content.py b/common/lib/xmodule/xmodule/tests/test_library_content.py index 4c88a40e6d..49495dce3a 100644 --- a/common/lib/xmodule/xmodule/tests/test_library_content.py +++ b/common/lib/xmodule/xmodule/tests/test_library_content.py @@ -6,12 +6,16 @@ Higher-level tests are in `cms/djangoapps/contentstore/tests/test_libraries.py`. """ import ddt from mock import patch +from unittest import TestCase +from bson.objectid import ObjectId + +from opaque_keys.edx.locator import LibraryLocator from xblock.fragment import Fragment from xblock.runtime import Runtime as VanillaRuntime from xmodule.x_module import AUTHOR_VIEW, STUDENT_VIEW -from xmodule.library_content_module import LibraryVersionReference, ANY_CAPA_TYPE_VALUE, LibraryContentDescriptor +from xmodule.library_content_module import LibraryVersionReference, LibraryList, ANY_CAPA_TYPE_VALUE, LibraryContentDescriptor from xmodule.modulestore.tests.factories import LibraryFactory, CourseFactory, ItemFactory from xmodule.modulestore.tests.utils import MixedSplitTestCase from xmodule.tests import get_test_system @@ -290,4 +294,45 @@ class TestLibraryContentRender(BaseTestLibraryContainer): self._bind_course_module(self.lc_block) rendered = self.lc_block.render(AUTHOR_VIEW, {}) self.assertEqual("", rendered.content) # content should be empty - self.assertEqual("LibraryContentAuthorView", rendered.js_init_fn) # but some js initialization should happen \ No newline at end of file + self.assertEqual("LibraryContentAuthorView", rendered.js_init_fn) # but some js initialization should happen + + +class TestLibraryList(TestCase): + """ Tests for LibraryList XBlock Field """ + def test_from_json_runtime_style(self): + """ + Test that LibraryList can parse raw libraries list as passed by runtime + """ + lib_list = LibraryList() + lib1_key, lib1_version = u'library-v1:Org1+Lib1', '5436ffec56c02c13806a4c1b' + lib2_key, lib2_version = u'library-v1:Org2+Lib2', '112dbaf312c0daa019ce9992' + raw = [[lib1_key, lib1_version], [lib2_key, lib2_version]] + parsed = lib_list.from_json(raw) + self.assertEqual(len(parsed), 2) + self.assertEquals(parsed[0].library_id, LibraryLocator.from_string(lib1_key)) + self.assertEquals(parsed[0].version, ObjectId(lib1_version)) + self.assertEquals(parsed[1].library_id, LibraryLocator.from_string(lib2_key)) + self.assertEquals(parsed[1].version, ObjectId(lib2_version)) + + def test_from_json_studio_editor_style(self): + """ + Test that LibraryList can parse raw libraries list as passed by studio editor + """ + lib_list = LibraryList() + lib1_key, lib1_version = u'library-v1:Org1+Lib1', '5436ffec56c02c13806a4c1b' + lib2_key, lib2_version = u'library-v1:Org2+Lib2', '112dbaf312c0daa019ce9992' + raw = [lib1_key+','+lib1_version, lib2_key+','+lib2_version] + parsed = lib_list.from_json(raw) + self.assertEqual(len(parsed), 2) + self.assertEquals(parsed[0].library_id, LibraryLocator.from_string(lib1_key)) + self.assertEquals(parsed[0].version, ObjectId(lib1_version)) + self.assertEquals(parsed[1].library_id, LibraryLocator.from_string(lib2_key)) + self.assertEquals(parsed[1].version, ObjectId(lib2_version)) + + def test_from_json_invalid_value(self): + """ + Test that LibraryList raises Value error if invalid library key is given + """ + lib_list = LibraryList() + with self.assertRaises(ValueError): + lib_list.from_json(["Not-a-library-key,whatever"]) \ No newline at end of file From 1a387d8217a92fab77c12175411be0f95957fe0a Mon Sep 17 00:00:00 2001 From: "E. Kolpakov" Date: Thu, 8 Jan 2015 20:26:18 +0300 Subject: [PATCH 85/99] Test for paging in items.py --- .../contentstore/tests/test_libraries.py | 1 + .../contentstore/views/tests/test_item.py | 57 +++++++++++++++++-- .../xmodule/tests/test_library_content.py | 18 +++--- .../xmodule/tests/test_library_root.py | 18 ++++-- 4 files changed, 77 insertions(+), 17 deletions(-) diff --git a/cms/djangoapps/contentstore/tests/test_libraries.py b/cms/djangoapps/contentstore/tests/test_libraries.py index 1d022820f3..b2e748fd55 100644 --- a/cms/djangoapps/contentstore/tests/test_libraries.py +++ b/cms/djangoapps/contentstore/tests/test_libraries.py @@ -441,6 +441,7 @@ class TestLibraries(LibraryTestCase): with self.assertRaises(ValueError): self._refresh_children(lc_block, status_code_expected=400) + @ddt.ddt class TestLibraryAccess(LibraryTestCase): """ diff --git a/cms/djangoapps/contentstore/views/tests/test_item.py b/cms/djangoapps/contentstore/views/tests/test_item.py index 6b595ae007..69898bfbe2 100644 --- a/cms/djangoapps/contentstore/views/tests/test_item.py +++ b/cms/djangoapps/contentstore/views/tests/test_item.py @@ -3,7 +3,7 @@ import json from datetime import datetime, timedelta import ddt -from mock import patch +from mock import patch, Mock, PropertyMock from pytz import UTC from webob import Response @@ -18,6 +18,7 @@ from contentstore.views.component import ( component_handler, get_component_templates ) + from contentstore.views.item import create_xblock_info, ALWAYS, VisibilityState, _xblock_type_and_display_name from contentstore.tests.utils import CourseTestCase from student.tests.factories import UserFactory @@ -86,12 +87,18 @@ class ItemTest(CourseTestCase): class GetItemTest(ItemTest): """Tests for '/xblock' GET url.""" - def _get_container_preview(self, usage_key): + def _get_preview(self, usage_key, data=None): + """ Makes a request to xblock preview handler """ + preview_url = reverse_usage_url("xblock_view_handler", usage_key, {'view_name': 'container_preview'}) + data = data if data else {} + resp = self.client.get(preview_url, data, HTTP_ACCEPT='application/json') + return resp + + def _get_container_preview(self, usage_key, data=None): """ Returns the HTML and resources required for the xblock at the specified UsageKey """ - preview_url = reverse_usage_url("xblock_view_handler", usage_key, {'view_name': 'container_preview'}) - resp = self.client.get(preview_url, HTTP_ACCEPT='application/json') + resp = self._get_preview(usage_key, data) self.assertEqual(resp.status_code, 200) resp_content = json.loads(resp.content) html = resp_content['html'] @@ -100,6 +107,14 @@ class GetItemTest(ItemTest): self.assertIsNotNone(resources) return html, resources + def _get_container_preview_with_error(self, usage_key, expected_code, data=None, content_contains=None): + """ Make request and asserts on response code and response contents """ + resp = self._get_preview(usage_key, data) + self.assertEqual(resp.status_code, expected_code) + if content_contains: + self.assertIn(content_contains, resp.content) + return resp + @ddt.data( (1, 21, 23, 35, 37), (2, 22, 24, 38, 39), @@ -247,6 +262,40 @@ class GetItemTest(ItemTest): self.assertIn('New_NAME_A', html) self.assertIn('New_NAME_B', html) + def test_valid_paging(self): + """ + Tests that valid paging is passed along to underlying block + """ + with patch('contentstore.views.item.get_preview_fragment') as patched_get_preview_fragment: + retval = Mock() + type(retval).content = PropertyMock(return_value="Some content") + type(retval).resources = PropertyMock(return_value=[]) + patched_get_preview_fragment.return_value = retval + + root_usage_key = self._create_vertical() + _, _ = self._get_container_preview( + root_usage_key, + {'enable_paging': 'true', 'page_number': 0, 'page_size': 2} + ) + call_args = patched_get_preview_fragment.call_args[0] + _, _, context = call_args + self.assertIn('paging', context) + self.assertEqual({'page_number': 0, 'page_size': 2}, context['paging']) + + @ddt.data([1, 'invalid'], ['invalid', 2]) + @ddt.unpack + def test_invalid_paging(self, page_number, page_size): + """ + Tests that valid paging is passed along to underlying block + """ + root_usage_key = self._create_vertical() + self._get_container_preview_with_error( + root_usage_key, + 400, + data={'enable_paging': 'true', 'page_number': page_number, 'page_size': page_size}, + content_contains="Couldn't parse paging parameters" + ) + class DeleteItem(ItemTest): """Tests for '/xblock' DELETE url.""" diff --git a/common/lib/xmodule/xmodule/tests/test_library_content.py b/common/lib/xmodule/xmodule/tests/test_library_content.py index 49495dce3a..50046ba9e0 100644 --- a/common/lib/xmodule/xmodule/tests/test_library_content.py +++ b/common/lib/xmodule/xmodule/tests/test_library_content.py @@ -14,15 +14,17 @@ from opaque_keys.edx.locator import LibraryLocator from xblock.fragment import Fragment from xblock.runtime import Runtime as VanillaRuntime -from xmodule.x_module import AUTHOR_VIEW, STUDENT_VIEW -from xmodule.library_content_module import LibraryVersionReference, LibraryList, ANY_CAPA_TYPE_VALUE, LibraryContentDescriptor +from xmodule.x_module import AUTHOR_VIEW +from xmodule.library_content_module import ( + LibraryVersionReference, LibraryList, ANY_CAPA_TYPE_VALUE, LibraryContentDescriptor +) from xmodule.modulestore.tests.factories import LibraryFactory, CourseFactory, ItemFactory from xmodule.modulestore.tests.utils import MixedSplitTestCase from xmodule.tests import get_test_system from xmodule.validation import StudioValidationMessage -_dummy_render = lambda block, _: Fragment(block.data) +dummy_render = lambda block, _: Fragment(block.data) class BaseTestLibraryContainer(MixedSplitTestCase): @@ -74,7 +76,7 @@ class BaseTestLibraryContainer(MixedSplitTestCase): } ) - def _bind_course_module(self, module, render=None): + def _bind_course_module(self, module): """ Bind a module (part of self.course) so we can access student-specific data. """ @@ -120,6 +122,7 @@ class BaseTestLibraryContainer(MixedSplitTestCase): modulestore=self.store, ) + @ddt.ddt class TestLibraryContainer(BaseTestLibraryContainer): """ @@ -270,8 +273,7 @@ class TestLibraryContainer(BaseTestLibraryContainer): @patch('xmodule.modulestore.split_mongo.caching_descriptor_system.CachingDescriptorSystem.render', VanillaRuntime.render) -@patch('xmodule.html_module.HtmlModule.author_view', _dummy_render, create=True) -@patch('xmodule.html_module.HtmlModule.student_view', _dummy_render, create=True) +@patch('xmodule.html_module.HtmlModule.author_view', dummy_render, create=True) @patch('xmodule.x_module.DescriptorSystem.applicable_aside_types', lambda self, block: []) class TestLibraryContentRender(BaseTestLibraryContainer): """ @@ -321,7 +323,7 @@ class TestLibraryList(TestCase): lib_list = LibraryList() lib1_key, lib1_version = u'library-v1:Org1+Lib1', '5436ffec56c02c13806a4c1b' lib2_key, lib2_version = u'library-v1:Org2+Lib2', '112dbaf312c0daa019ce9992' - raw = [lib1_key+','+lib1_version, lib2_key+','+lib2_version] + raw = [lib1_key + ',' + lib1_version, lib2_key + ',' + lib2_version] parsed = lib_list.from_json(raw) self.assertEqual(len(parsed), 2) self.assertEquals(parsed[0].library_id, LibraryLocator.from_string(lib1_key)) @@ -335,4 +337,4 @@ class TestLibraryList(TestCase): """ lib_list = LibraryList() with self.assertRaises(ValueError): - lib_list.from_json(["Not-a-library-key,whatever"]) \ No newline at end of file + lib_list.from_json(["Not-a-library-key,whatever"]) diff --git a/common/lib/xmodule/xmodule/tests/test_library_root.py b/common/lib/xmodule/xmodule/tests/test_library_root.py index 0dc0d1d7d3..a3ea602496 100644 --- a/common/lib/xmodule/xmodule/tests/test_library_root.py +++ b/common/lib/xmodule/xmodule/tests/test_library_root.py @@ -1,19 +1,26 @@ +# -*- coding: utf-8 -*- +""" +Basic unit tests for LibraryRoot +""" from mock import patch from xblock.fragment import Fragment from xblock.runtime import Runtime as VanillaRuntime from xmodule.x_module import AUTHOR_VIEW -from xmodule.modulestore.tests.factories import LibraryFactory, CourseFactory, ItemFactory +from xmodule.modulestore.tests.factories import LibraryFactory, ItemFactory from xmodule.modulestore.tests.utils import MixedSplitTestCase -_dummy_render = lambda block, _: Fragment(block.data) +dummy_render = lambda block, _: Fragment(block.data) @patch('xmodule.modulestore.split_mongo.caching_descriptor_system.CachingDescriptorSystem.render', VanillaRuntime.render) -@patch('xmodule.html_module.HtmlDescriptor.author_view', _dummy_render, create=True) +@patch('xmodule.html_module.HtmlDescriptor.author_view', dummy_render, create=True) @patch('xmodule.x_module.DescriptorSystem.applicable_aside_types', lambda self, block: []) class TestLibraryRoot(MixedSplitTestCase): + """ + Basic unit tests for LibraryRoot (library_root_xblock.py) + """ def test_library_author_view(self): """ Test that LibraryRoot.author_view can run and includes content from its @@ -62,8 +69,9 @@ class TestLibraryRoot(MixedSplitTestCase): library = self.store.get_library(library.location.library_key) def render_and_check_contents(page, page_size): + """ Renders block and asserts on returned content """ context = {'reorderable_items': set(), 'paging': {'page_number': page, 'page_size': page_size}} - expected_blocks = blocks[page_size*page:page_size*(page+1)] + expected_blocks = blocks[page_size * page:page_size * (page + 1)] result = library.render(AUTHOR_VIEW, context) for expected_block in expected_blocks: @@ -72,4 +80,4 @@ class TestLibraryRoot(MixedSplitTestCase): render_and_check_contents(0, 3) render_and_check_contents(1, 3) render_and_check_contents(0, 2) - render_and_check_contents(1, 2) \ No newline at end of file + render_and_check_contents(1, 2) From 6a2af3ebdc6a1ffc2b85101d2a3f4d7ff676486e Mon Sep 17 00:00:00 2001 From: "E. Kolpakov" Date: Thu, 8 Jan 2015 21:05:54 +0300 Subject: [PATCH 86/99] pylint fixes --- .../lib/xmodule/xmodule/modulestore/tests/test_libraries.py | 1 - common/lib/xmodule/xmodule/tests/test_library_content.py | 2 +- common/lib/xmodule/xmodule/tests/test_library_root.py | 4 ++-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_libraries.py b/common/lib/xmodule/xmodule/modulestore/tests/test_libraries.py index 03ee84ae35..7381692239 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_libraries.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_libraries.py @@ -13,7 +13,6 @@ from xmodule.modulestore.tests.factories import LibraryFactory, ItemFactory, che from xmodule.modulestore.tests.utils import MixedSplitTestCase - @ddt.ddt class TestLibraries(MixedSplitTestCase): """ diff --git a/common/lib/xmodule/xmodule/tests/test_library_content.py b/common/lib/xmodule/xmodule/tests/test_library_content.py index 50046ba9e0..f8d45f640d 100644 --- a/common/lib/xmodule/xmodule/tests/test_library_content.py +++ b/common/lib/xmodule/xmodule/tests/test_library_content.py @@ -24,7 +24,7 @@ from xmodule.tests import get_test_system from xmodule.validation import StudioValidationMessage -dummy_render = lambda block, _: Fragment(block.data) +dummy_render = lambda block, _: Fragment(block.data) # pylint: disable=invalid-name class BaseTestLibraryContainer(MixedSplitTestCase): diff --git a/common/lib/xmodule/xmodule/tests/test_library_root.py b/common/lib/xmodule/xmodule/tests/test_library_root.py index a3ea602496..8774e7bb32 100644 --- a/common/lib/xmodule/xmodule/tests/test_library_root.py +++ b/common/lib/xmodule/xmodule/tests/test_library_root.py @@ -11,7 +11,7 @@ from xmodule.x_module import AUTHOR_VIEW from xmodule.modulestore.tests.factories import LibraryFactory, ItemFactory from xmodule.modulestore.tests.utils import MixedSplitTestCase -dummy_render = lambda block, _: Fragment(block.data) +dummy_render = lambda block, _: Fragment(block.data) # pylint: disable=invalid-name @patch('xmodule.modulestore.split_mongo.caching_descriptor_system.CachingDescriptorSystem.render', VanillaRuntime.render) @@ -62,7 +62,7 @@ class TestLibraryRoot(MixedSplitTestCase): user_id=self.user_id, publish_item=False, modulestore=self.store, - data="HtmlBlock"+str(i) + data="HtmlBlock" + str(i) ) for i in range(5) ] From 134a75b367be1e9f6a390f5fef13d607e8acc582 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Thu, 8 Jan 2015 17:10:01 -0800 Subject: [PATCH 87/99] Minor tweaks to reduce conflicts with PR 6492 --- .../xmodule/tests/test_library_content.py | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/common/lib/xmodule/xmodule/tests/test_library_content.py b/common/lib/xmodule/xmodule/tests/test_library_content.py index f8d45f640d..5f32dab130 100644 --- a/common/lib/xmodule/xmodule/tests/test_library_content.py +++ b/common/lib/xmodule/xmodule/tests/test_library_content.py @@ -4,12 +4,10 @@ Basic unit tests for LibraryContentModule Higher-level tests are in `cms/djangoapps/contentstore/tests/test_libraries.py`. """ -import ddt -from mock import patch -from unittest import TestCase from bson.objectid import ObjectId - +from mock import patch from opaque_keys.edx.locator import LibraryLocator +from unittest import TestCase from xblock.fragment import Fragment from xblock.runtime import Runtime as VanillaRuntime @@ -27,12 +25,12 @@ from xmodule.validation import StudioValidationMessage dummy_render = lambda block, _: Fragment(block.data) # pylint: disable=invalid-name -class BaseTestLibraryContainer(MixedSplitTestCase): +class LibraryContentTest(MixedSplitTestCase): """ - Base class for TestLibraryContainer and TestLibraryContainerRender + Base class for tests of LibraryContentModule (library_content_module.py) """ def setUp(self): - super(BaseTestLibraryContainer, self).setUp() + super(LibraryContentTest, self).setUp() self.library = LibraryFactory.create(modulestore=self.store) self.lib_blocks = [ @@ -123,10 +121,9 @@ class BaseTestLibraryContainer(MixedSplitTestCase): ) -@ddt.ddt -class TestLibraryContainer(BaseTestLibraryContainer): +class TestLibraryContentModule(LibraryContentTest): """ - Basic unit tests for LibraryContentModule (library_content_module.py) + Basic unit tests for LibraryContentModule """ def test_lib_content_block(self): """ @@ -275,9 +272,9 @@ class TestLibraryContainer(BaseTestLibraryContainer): @patch('xmodule.modulestore.split_mongo.caching_descriptor_system.CachingDescriptorSystem.render', VanillaRuntime.render) @patch('xmodule.html_module.HtmlModule.author_view', dummy_render, create=True) @patch('xmodule.x_module.DescriptorSystem.applicable_aside_types', lambda self, block: []) -class TestLibraryContentRender(BaseTestLibraryContainer): +class TestLibraryContentRender(LibraryContentTest): """ - Rendering unit tests for LibraryContentModule (library_content_module.py) + Rendering unit tests for LibraryContentModule """ def test_preivew_view(self): """ Test preview view rendering """ From 195d5b57bcd9940e145cdf97632b304518a2daa5 Mon Sep 17 00:00:00 2001 From: "E. Kolpakov" Date: Mon, 12 Jan 2015 18:09:03 +0300 Subject: [PATCH 88/99] pylint fixes --- .../contentstore/features/courses.py | 10 ++-- .../contentstore/tests/test_libraries.py | 35 ++++++++++---- cms/djangoapps/contentstore/views/item.py | 24 +++++++--- cms/djangoapps/contentstore/views/library.py | 20 +++++--- .../contentstore/views/tests/test_item.py | 3 +- .../contentstore/views/tests/test_library.py | 5 +- cms/djangoapps/contentstore/views/user.py | 5 +- cms/urls.py | 4 +- common/djangoapps/student/auth.py | 4 +- common/djangoapps/terrain/ui_helpers.py | 2 +- common/lib/xmodule/xmodule/capa_module.py | 2 +- .../xmodule/xmodule/library_content_module.py | 11 +++-- common/lib/xmodule/xmodule/library_tools.py | 3 +- .../xmodule/modulestore/split_mongo/split.py | 31 +++++++++---- .../modulestore/split_mongo/split_draft.py | 4 +- .../tests/test_split_copy_from_template.py | 46 +++++++++++++------ .../xmodule/tests/test_library_content.py | 7 ++- .../xmodule/tests/test_library_root.py | 4 +- common/test/acceptance/fixtures/library.py | 6 +-- .../test/acceptance/pages/studio/auto_auth.py | 3 +- .../test/acceptance/pages/studio/container.py | 2 + common/test/acceptance/pages/studio/index.py | 3 +- common/test/acceptance/pages/studio/users.py | 4 +- common/test/acceptance/pages/studio/utils.py | 5 +- .../tests/studio/test_studio_general.py | 2 +- .../studio/test_studio_library_container.py | 15 ++++-- 26 files changed, 183 insertions(+), 77 deletions(-) diff --git a/cms/djangoapps/contentstore/features/courses.py b/cms/djangoapps/contentstore/features/courses.py index 2640b5c233..cb7b85f076 100644 --- a/cms/djangoapps/contentstore/features/courses.py +++ b/cms/djangoapps/contentstore/features/courses.py @@ -1,5 +1,6 @@ # pylint: disable=missing-docstring # pylint: disable=redefined-outer-name +# pylint: disable=unused-argument from lettuce import world, step from common import * @@ -33,7 +34,7 @@ def i_create_a_course(step): create_a_course() -# pylint disable=unused-argument, invalid-name +# pylint: disable=invalid-name @step('I click the course link in Studio Home$') def i_click_the_course_link_in_studio_home(step): course_css = 'a.course-link' @@ -42,7 +43,11 @@ def i_click_the_course_link_in_studio_home(step): @step('I see an error about the length of the org/course/run tuple') def i_see_error_about_length(step): - assert world.css_has_text('#course_creation_error', 'The combined length of the organization, course number, and course run fields cannot be more than 65 characters.') + assert world.css_has_text( + '#course_creation_error', + 'The combined length of the organization, course number, ' + 'and course run fields cannot be more than 65 characters.' + ) ############ ASSERTIONS ################### @@ -54,7 +59,6 @@ def courseware_page_has_loaded_in_studio(step): @step('I see the course listed in Studio Home$') -# pylint disable=unused-argument def i_see_the_course_in_studio_home(step): course_css = 'h3.class-title' assert world.css_has_text(course_css, world.scenario_dict['COURSE'].display_name) diff --git a/cms/djangoapps/contentstore/tests/test_libraries.py b/cms/djangoapps/contentstore/tests/test_libraries.py index b2e748fd55..dc6d8b976d 100644 --- a/cms/djangoapps/contentstore/tests/test_libraries.py +++ b/cms/djangoapps/contentstore/tests/test_libraries.py @@ -67,6 +67,13 @@ class LibraryTestCase(ModuleStoreTestCase): **(other_settings or {}) ) + def _add_simple_content_block(self): + """ Adds simple HTML block to library """ + return ItemFactory.create( + category="html", parent_location=self.library.location, + user_id=self.user.id, publish_item=False + ) + def _refresh_children(self, lib_content_block, status_code_expected=200): """ Helper method: Uses the REST API to call the 'refresh_children' handler @@ -74,7 +81,11 @@ class LibraryTestCase(ModuleStoreTestCase): """ 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'}) + 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, status_code_expected) return modulestore().get_item(lib_content_block.location) @@ -128,7 +139,7 @@ class TestLibraries(LibraryTestCase): 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) + self._add_simple_content_block() with modulestore().default_store(ModuleStoreEnum.Type.split): course = CourseFactory.create() @@ -203,9 +214,9 @@ class TestLibraries(LibraryTestCase): """ 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) + block1 = self._add_simple_content_block() 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) + block2 = self._add_simple_content_block() def_id2 = block2.definition_locator.definition_id self.assertNotEqual(def_id1, def_id2) @@ -461,7 +472,10 @@ class TestLibraryAccess(LibraryTestCase): def _assert_cannot_create_library(self, org="org", library="libfail", expected_code=403): """ Ensure the current user is not able to create a library. """ self.assertTrue(expected_code >= 300) - response = self.client.ajax_post(LIBRARY_REST_URL, {'org': org, 'library': library, 'display_name': "Irrelevant"}) + response = self.client.ajax_post( + LIBRARY_REST_URL, + {'org': org, 'library': library, 'display_name': "Irrelevant"} + ) self.assertEqual(response.status_code, expected_code) key = LibraryLocator(org=org, library=library) self.assertEqual(modulestore().get_library(key), None) @@ -574,7 +588,7 @@ class TestLibraryAccess(LibraryTestCase): Test the read-only role (LibraryUserRole and its org-level equivalent) """ # As staff user, add a block to self.library: - block = ItemFactory.create(category="html", parent_location=self.library.location, user_id=self.user.id, publish_item=False) + block = self._add_simple_content_block() # Login as a non_staff_user: self._login_as_non_staff_user() @@ -650,7 +664,7 @@ class TestLibraryAccess(LibraryTestCase): from a library with (write, read, or no) access to a course with (write or no) access. """ # As staff user, add a block to self.library: - block = ItemFactory.create(category="html", parent_location=self.library.location, user_id=self.user.id, publish_item=False) + block = self._add_simple_content_block() # And create a course: with modulestore().default_store(ModuleStoreEnum.Type.split): course = CourseFactory.create() @@ -687,7 +701,7 @@ class TestLibraryAccess(LibraryTestCase): access to a course with (write or no) access. """ # As staff user, add a block to self.library: - ItemFactory.create(category="html", parent_location=self.library.location, user_id=self.user.id, publish_item=False) + self._add_simple_content_block() # And create a course: with modulestore().default_store(ModuleStoreEnum.Type.split): course = CourseFactory.create() @@ -702,7 +716,8 @@ class TestLibraryAccess(LibraryTestCase): # Try updating our library content block: lc_block = self._add_library_content_block(course, self.lib_key) - self._bind_module(lc_block, user=self.non_staff_user) # We must use the CMS's module system in order to get permissions checks. + # We must use the CMS's module system in order to get permissions checks. + self._bind_module(lc_block, user=self.non_staff_user) lc_block = self._refresh_children(lc_block, status_code_expected=200 if expected_result else 403) self.assertEqual(len(lc_block.children), 1 if expected_result else 0) @@ -822,7 +837,7 @@ class TestOverrides(LibraryTestCase): # Change the settings in the library version: self.problem.display_name = "X" self.problem.weight = 99 - new_data_value = "

    We change the data as well to check that non-overriden fields do get updated.

    " + new_data_value = "

    Changed data to check that non-overriden fields *do* get updated.

    " self.problem.data = new_data_value modulestore().update_item(self.problem, self.user.id) diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py index 57f1d59ca9..e390811096 100644 --- a/cms/djangoapps/contentstore/views/item.py +++ b/cms/djangoapps/contentstore/views/item.py @@ -169,7 +169,10 @@ def xblock_handler(request, usage_key_string): source_course = duplicate_source_usage_key.course_key dest_course = parent_usage_key.course_key - if not has_studio_write_access(request.user, dest_course) or not has_studio_read_access(request.user, source_course): + if ( + not has_studio_write_access(request.user, dest_course) or + not has_studio_read_access(request.user, source_course) + ): raise PermissionDenied() dest_usage_key = _duplicate_item( @@ -252,6 +255,7 @@ def xblock_view_handler(request, usage_key_string, view_name): 'page_size': int(request.REQUEST.get('page_size', 0)), } except ValueError: + # pylint: disable=too-many-format-args return HttpResponse( content="Couldn't parse paging parameters: enable_paging: " "%s, page_number: %s, page_size: %s".format( @@ -435,7 +439,9 @@ def _save_xblock(user, xblock, data=None, children_strings=None, metadata=None, try: value = field.from_json(value) except ValueError as verr: - reason = _("Invalid data ({details})").format(details=verr.message) if verr.message else _("Invalid data") + reason = _("Invalid data") + if verr.message: + reason = _("Invalid data ({details})").format(details=verr.message) return JsonResponse({"error": reason}, 400) field.write_to(xblock, value) @@ -544,7 +550,9 @@ def _create_item(request): ) store.update_item(course, request.user.id) - return JsonResponse({"locator": unicode(created_block.location), "courseKey": unicode(created_block.location.course_key)}) + return JsonResponse( + {"locator": unicode(created_block.location), "courseKey": unicode(created_block.location.course_key)} + ) def _duplicate_item(parent_usage_key, duplicate_source_usage_key, user, display_name=None): @@ -559,9 +567,11 @@ def _duplicate_item(parent_usage_key, duplicate_source_usage_key, user, display_ category = dest_usage_key.block_type # Update the display name to indicate this is a duplicate (unless display name provided). - duplicate_metadata = {} # Can't use own_metadata(), b/c it converts data for JSON serialization - not suitable for setting metadata of the new block + # Can't use own_metadata(), b/c it converts data for JSON serialization - + # not suitable for setting metadata of the new block + duplicate_metadata = {} for field in source_item.fields.values(): - if (field.scope == Scope.settings and field.is_set_on(source_item)): + if field.scope == Scope.settings and field.is_set_on(source_item): duplicate_metadata[field.name] = field.read_from(source_item) if display_name is not None: duplicate_metadata['display_name'] = display_name @@ -746,7 +756,9 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F 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 or for library blocks - has_changes = modulestore().has_changes(xblock) if (is_xblock_unit or course_outline) and not is_library_block else None + has_changes = None + if (is_xblock_unit or course_outline) and not is_library_block: + has_changes = modulestore().has_changes(xblock) if graders is None: if not is_library_block: diff --git a/cms/djangoapps/contentstore/views/library.py b/cms/djangoapps/contentstore/views/library.py index 9aeb9c9b13..2bd433c83f 100644 --- a/cms/djangoapps/contentstore/views/library.py +++ b/cms/djangoapps/contentstore/views/library.py @@ -26,7 +26,9 @@ from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore.django import modulestore from .component import get_component_templates, CONTAINER_TEMPATES -from student.auth import STUDIO_VIEW_USERS, STUDIO_EDIT_ROLES, get_user_permissions, has_studio_read_access, has_studio_write_access +from student.auth import ( + STUDIO_VIEW_USERS, STUDIO_EDIT_ROLES, get_user_permissions, has_studio_read_access, has_studio_write_access +) from student.roles import CourseCreatorRole, CourseInstructorRole, CourseStaffRole, LibraryUserRole from student import auth from util.json_request import expect_json, JsonResponse, JsonResponseBadRequest @@ -71,7 +73,10 @@ def _display_library(library_key_string, request): 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_studio_read_access(request.user, library_key): - log.exception(u"User %s tried to access library %s without permission", request.user.username, unicode(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) @@ -80,7 +85,10 @@ def _display_library(library_key_string, request): raise Http404 response_format = 'html' - if request.REQUEST.get('format', 'html') == 'json' or 'application/json' in request.META.get('HTTP_ACCEPT', 'text/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, request.user, response_format) @@ -134,8 +142,8 @@ def _create_library(request): 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)} - ) + "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({ @@ -203,7 +211,7 @@ def manage_library_users(request, library_key_string): if not isinstance(library_key, LibraryLocator): raise Http404 # This is not a library user_perms = get_user_permissions(request.user, library_key) - if not (user_perms & STUDIO_VIEW_USERS): + if not user_perms & STUDIO_VIEW_USERS: raise PermissionDenied() library = modulestore().get_library(library_key) if library is None: diff --git a/cms/djangoapps/contentstore/views/tests/test_item.py b/cms/djangoapps/contentstore/views/tests/test_item.py index 69898bfbe2..c322233319 100644 --- a/cms/djangoapps/contentstore/views/tests/test_item.py +++ b/cms/djangoapps/contentstore/views/tests/test_item.py @@ -1508,7 +1508,8 @@ class TestLibraryXBlockInfo(ModuleStoreTestCase): 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 + 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): diff --git a/cms/djangoapps/contentstore/views/tests/test_library.py b/cms/djangoapps/contentstore/views/tests/test_library.py index 0b0abe2e3b..7289022e8f 100644 --- a/cms/djangoapps/contentstore/views/tests/test_library.py +++ b/cms/djangoapps/contentstore/views/tests/test_library.py @@ -215,7 +215,10 @@ class UnitTestLibraries(ModuleStoreTestCase): self.assertNotIn(extra_user.username, response.content) # Now add extra_user to the library: - user_details_url = reverse_course_url('course_team_handler', library.location.library_key, kwargs={'email': extra_user.email}) + user_details_url = reverse_course_url( + 'course_team_handler', + library.location.library_key, kwargs={'email': extra_user.email} + ) edit_response = self.client.ajax_post(user_details_url, {"role": LibraryUserRole.ROLE}) self.assertIn(edit_response.status_code, (200, 204)) diff --git a/cms/djangoapps/contentstore/views/user.py b/cms/djangoapps/contentstore/views/user.py index 812890a451..93a7c66a77 100644 --- a/cms/djangoapps/contentstore/views/user.py +++ b/cms/djangoapps/contentstore/views/user.py @@ -67,7 +67,7 @@ def _manage_users(request, course_key): """ # check that logged in user has permissions to this item user_perms = get_user_permissions(request.user, course_key) - if not (user_perms & STUDIO_VIEW_USERS): + if not user_perms & STUDIO_VIEW_USERS: raise PermissionDenied() course_module = modulestore().get_course(course_key) @@ -156,7 +156,8 @@ def _course_team_user(request, course_key, email): role = role_type(course_key) if role_type.ROLE == new_role: if (requester_perms & STUDIO_EDIT_ROLES) or (user.id == request.user.id and old_roles): - # User has STUDIO_EDIT_ROLES permission or is currently a member of a higher role, and is thus demoting themself + # User has STUDIO_EDIT_ROLES permission or + # is currently a member of a higher role, and is thus demoting themself auth.add_users(request.user, role, user) role_added = True else: diff --git a/cms/urls.py b/cms/urls.py index 81077120fc..ce46e77908 100644 --- a/cms/urls.py +++ b/cms/urls.py @@ -6,7 +6,9 @@ from ratelimitbackend import admin admin.autodiscover() # Pattern to match a course key or a library key -COURSELIKE_KEY_PATTERN = r'(?P({}|{}))'.format(r'[^/]+/[^/]+/[^/]+', r'[^/:]+:[^/+]+\+[^/+]+(\+[^/]+)?') +COURSELIKE_KEY_PATTERN = r'(?P({}|{}))'.format( + r'[^/]+/[^/]+/[^/]+', r'[^/:]+:[^/+]+\+[^/+]+(\+[^/]+)?' +) # Pattern to match a library key only LIBRARY_KEY_PATTERN = r'(?Plibrary-v1:[^/+]+\+[^/+]+)' diff --git a/common/djangoapps/student/auth.py b/common/djangoapps/student/auth.py index 90b311e77e..a927bb5097 100644 --- a/common/djangoapps/student/auth.py +++ b/common/djangoapps/student/auth.py @@ -17,7 +17,7 @@ STUDIO_EDIT_ROLES = 8 STUDIO_VIEW_USERS = 4 STUDIO_EDIT_CONTENT = 2 STUDIO_VIEW_CONTENT = 1 -# In addition to the above, one is always allowed to "demote" oneself to a lower role within a course, or remove oneself. +# In addition to the above, one is always allowed to "demote" oneself to a lower role within a course, or remove oneself def has_access(user, role): @@ -70,7 +70,7 @@ def get_user_permissions(user, course_key, org=None): if OrgStaffRole(org=org).has_user(user) or (course_key and has_access(user, CourseStaffRole(course_key))): return STUDIO_VIEW_USERS | STUDIO_EDIT_CONTENT | STUDIO_VIEW_CONTENT # Otherwise, for libraries, users can view only: - if (course_key and isinstance(course_key, LibraryLocator)): + if course_key and isinstance(course_key, LibraryLocator): if OrgLibraryUserRole(org=org).has_user(user) or has_access(user, LibraryUserRole(course_key)): return STUDIO_VIEW_USERS | STUDIO_VIEW_CONTENT return 0 diff --git a/common/djangoapps/terrain/ui_helpers.py b/common/djangoapps/terrain/ui_helpers.py index f13811467a..2b5248a1ab 100644 --- a/common/djangoapps/terrain/ui_helpers.py +++ b/common/djangoapps/terrain/ui_helpers.py @@ -48,7 +48,7 @@ REQUIREJS_WAIT = { "js/base", "js/models/course", "js/models/location", "js/models/section"], # Dashboard - # pylint disable=anomalous-backslash-in-string + # pylint: disable=anomalous-backslash-in-string re.compile('^Studio Home \|'): [ "js/sock", "gettext", "js/base", "jquery.ui", "coffee/src/main", "underscore"], diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index 47583d9706..b748b359f1 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -177,7 +177,7 @@ class CapaDescriptor(CapaFields, RawDescriptor): @property def problem_types(self): """ Low-level problem type introspection for content libraries filtering by problem type """ - tree = etree.XML(self.data) + tree = etree.XML(self.data) # pylint: disable=no-member registered_tags = responsetypes.registry.registered_tags() return set([node.tag for node in tree.iter() if node.tag in registered_tags]) diff --git a/common/lib/xmodule/xmodule/library_content_module.py b/common/lib/xmodule/xmodule/library_content_module.py index 972919e2fa..e4c3252b37 100644 --- a/common/lib/xmodule/xmodule/library_content_module.py +++ b/common/lib/xmodule/xmodule/library_content_module.py @@ -20,7 +20,7 @@ from xmodule.validation import StudioValidationMessage, StudioValidation from xmodule.x_module import XModule, STUDENT_VIEW from xmodule.studio_editable import StudioEditableModule, StudioEditableDescriptor from .xml_module import XmlDescriptor -from pkg_resources import resource_string +from pkg_resources import resource_string # pylint: disable=no-name-in-module # Make '_' a no-op so we can scrape strings @@ -187,7 +187,8 @@ class LibraryContentFields(object): scope=Scope.settings, ) selected = List( - # This is a list of (block_type, block_id) tuples used to record which random/first set of matching blocks was selected per user + # This is a list of (block_type, block_id) tuples used to record + # which random/first set of matching blocks was selected per user default=[], scope=Scope.user_state, ) @@ -296,7 +297,8 @@ class LibraryContentModule(LibraryContentFields, XModule, StudioEditableModule): 'display_name': self.display_name or self.url_name, })) self.render_children(context, fragment, can_reorder=False, can_add=False) - # else: When shown on a unit page, don't show any sort of preview - just the status of this block in the validation area. + # else: When shown on a unit page, don't show any sort of preview - + # just the status of this block in the validation area. # The following JS is used to make the "Update now" button work on the unit page and the container view: fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/library_content_edit.js')) @@ -412,7 +414,8 @@ class LibraryContentDescriptor(LibraryContentFields, MakoModuleDescriptor, XmlDe if not self._validate_library_version(validation, lib_tools, version, library_key): break - # Note: we assume refresh_children() has been called since the last time fields like source_libraries or capa_types were changed. + # Note: we assume refresh_children() has been called + # since the last time fields like source_libraries or capa_types were changed. matching_children_count = len(self.children) # pylint: disable=no-member if matching_children_count == 0: self._set_validation_error_if_empty( diff --git a/common/lib/xmodule/xmodule/library_tools.py b/common/lib/xmodule/xmodule/library_tools.py index 4bdf51bdce..8f3c6d0cee 100644 --- a/common/lib/xmodule/xmodule/library_tools.py +++ b/common/lib/xmodule/xmodule/library_tools.py @@ -93,4 +93,5 @@ class LibraryToolsService(object): dest_block.source_libraries = new_libraries self.store.update_item(dest_block, user_id) dest_block.children = self.store.copy_from_template(source_blocks, dest_block.location, user_id) - # ^-- copy_from_template updates the children in the DB but we must also set .children here to avoid overwriting the DB again + # ^-- copy_from_template updates the children in the DB + # but we must also set .children here to avoid overwriting the DB again diff --git a/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py b/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py index 6c375d2dfc..c4d5d638e8 100644 --- a/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py +++ b/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py @@ -673,7 +673,8 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase): new_module_data = {} for block_id in base_block_ids: new_module_data = self.descendants( - copy.deepcopy(system.course_entry.structure['blocks']), # copy or our changes like setting 'definition_loaded' will affect the active bulk operation data + # copy or our changes like setting 'definition_loaded' will affect the active bulk operation data + copy.deepcopy(system.course_entry.structure['blocks']), block_id, depth, new_module_data @@ -2125,8 +2126,11 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase): # Set of all descendent block IDs of dest_usage that are to be replaced: block_key = BlockKey(dest_usage.block_type, dest_usage.block_id) orig_descendants = set(self.descendants(dest_structure['blocks'], block_key, depth=None, descendent_map={})) - orig_descendants.remove(block_key) # The descendants() method used above adds the block itself, which we don't consider a descendant. - new_descendants = self._copy_from_template(source_structures, source_keys, dest_structure, block_key, user_id) + # The descendants() method used above adds the block itself, which we don't consider a descendant. + orig_descendants.remove(block_key) + new_descendants = self._copy_from_template( + source_structures, source_keys, dest_structure, block_key, user_id + ) # Update the edit info: dest_info = dest_structure['blocks'][block_key] @@ -2144,7 +2148,10 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase): self.update_structure(destination_course, dest_structure) self._update_head(destination_course, index_entry, destination_course.branch, dest_structure['_id']) # Return usage locators for all the new children: - return [destination_course.make_usage_key(*k) for k in dest_structure['blocks'][block_key]['fields']['children']] + return [ + destination_course.make_usage_key(*k) + for k in dest_structure['blocks'][block_key]['fields']['children'] + ] def _copy_from_template(self, source_structures, source_keys, dest_structure, new_parent_block_key, user_id): """ @@ -2184,10 +2191,13 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase): new_block_info['defaults'] = new_block_info['fields'] # - # CAPA modules store their 'markdown' value (an alternate representation of their content) in Scope.settings rather than Scope.content :-/ + # CAPA modules store their 'markdown' value (an alternate representation of their content) + # in Scope.settings rather than Scope.content :-/ # markdown is a field that really should not be overridable - it fundamentally changes the content. - # capa modules also use a custom editor that always saves their markdown field to the metadata, even if it hasn't changed, which breaks our override system. - # So until capa modules are fixed, we special-case them and remove their markdown fields, forcing the inherited version to use XML only. + # capa modules also use a custom editor that always saves their markdown field to the metadata, + # even if it hasn't changed, which breaks our override system. + # So until capa modules are fixed, we special-case them and remove their markdown fields, + # forcing the inherited version to use XML only. if usage_key.block_type == 'problem' and 'markdown' in new_block_info['defaults']: del new_block_info['defaults']['markdown'] # @@ -2199,7 +2209,8 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase): new_block_info['edit_info'] = existing_block_info.get('edit_info', {}) new_block_info['edit_info']['previous_version'] = new_block_info['edit_info'].get('update_version', None) new_block_info['edit_info']['update_version'] = dest_structure['_id'] - # Note we do not set 'source_version' - it's only used for copying identical blocks from draft to published as part of publishing workflow. + # Note we do not set 'source_version' - it's only used for copying identical blocks + # from draft to published as part of publishing workflow. # Setting it to the source_block_info structure version here breaks split_draft's has_changes() method. new_block_info['edit_info']['edited_by'] = user_id new_block_info['edit_info']['edited_on'] = datetime.datetime.now(UTC) @@ -2208,7 +2219,9 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase): children = source_block_info['fields'].get('children') if children: children = [src_course_key.make_usage_key(child.type, child.id) for child in children] - new_blocks |= self._copy_from_template(source_structures, children, dest_structure, new_block_key, user_id) + new_blocks |= self._copy_from_template( + source_structures, children, dest_structure, new_block_key, user_id + ) new_blocks.add(new_block_key) # And add new_block_key to the list of new_parent_block_key's new children: diff --git a/common/lib/xmodule/xmodule/modulestore/split_mongo/split_draft.py b/common/lib/xmodule/xmodule/modulestore/split_mongo/split_draft.py index c651512c82..30fe251823 100644 --- a/common/lib/xmodule/xmodule/modulestore/split_mongo/split_draft.py +++ b/common/lib/xmodule/xmodule/modulestore/split_mongo/split_draft.py @@ -110,7 +110,8 @@ class DraftVersioningModuleStore(SplitMongoModuleStore, ModuleStoreDraftAndPubli if usage_key.category in DIRECT_ONLY_CATEGORIES: self.publish(usage_key.version_agnostic(), user_id, blacklist=EXCLUDE_ALL, **kwargs) children = getattr(self.get_item(usage_key, **kwargs), "children", []) - keys_to_check.extend(children) # e.g. if usage_key is a chapter, it may have an auto-publish sequential child + # e.g. if usage_key is a chapter, it may have an auto-publish sequential child + keys_to_check.extend(children) return new_keys def update_item(self, descriptor, user_id, allow_not_found=False, force=False, **kwargs): @@ -431,6 +432,7 @@ class DraftVersioningModuleStore(SplitMongoModuleStore, ModuleStoreDraftAndPubli pass def _get_head(self, xblock, branch): + """ Gets block at the head of specified branch """ try: course_structure = self._lookup_course(xblock.location.course_key.for_branch(branch)).structure except ItemNotFoundError: diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_split_copy_from_template.py b/common/lib/xmodule/xmodule/modulestore/tests/test_split_copy_from_template.py index 3d73178644..bfdd7d0fea 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_split_copy_from_template.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_split_copy_from_template.py @@ -28,12 +28,18 @@ class TestSplitCopyTemplate(MixedSplitTestCase): # Add a vertical with a capa child to the source library/course: vertical_block = self.make_block("vertical", source_container) problem_library_display_name = "Problem Library Display Name" - problem_block = self.make_block("problem", vertical_block, display_name=problem_library_display_name, markdown="Problem markdown here") + problem_block = self.make_block( + "problem", vertical_block, display_name=problem_library_display_name, markdown="Problem markdown here" + ) if source_type == LibraryFactory: - source_container = self.store.get_library(source_container.location.library_key, remove_version=False, remove_branch=False) + source_container = self.store.get_library( + source_container.location.library_key, remove_version=False, remove_branch=False + ) else: - source_container = self.store.get_course(source_container.location.course_key, remove_version=False, remove_branch=False) + source_container = self.store.get_course( + source_container.location.course_key, remove_version=False, remove_branch=False + ) # Inherit the vertical and the problem from the library into the course: source_keys = [source_container.children[0]] @@ -48,7 +54,8 @@ class TestSplitCopyTemplate(MixedSplitTestCase): problem_block_course = self.store.get_item(vertical_block_course.children[0]) self.assertEqual(problem_block_course.display_name, problem_library_display_name) - # Check that when capa modules are copied, their "markdown" fields (Scope.settings) are removed. (See note in split.py:copy_from_template()) + # Check that when capa modules are copied, their "markdown" fields (Scope.settings) are removed. + # (See note in split.py:copy_from_template()) self.assertIsNotNone(problem_block.markdown) self.assertIsNone(problem_block_course.markdown) @@ -59,7 +66,8 @@ class TestSplitCopyTemplate(MixedSplitTestCase): problem_block_course.weight = new_weight self.store.update_item(problem_block_course, self.user_id) - # Test that "Any previously existing children of `dest_usage` that haven't been replaced/updated by this copy_from_template operation will be deleted." + # Test that "Any previously existing children of `dest_usage` + # that haven't been replaced/updated by this copy_from_template operation will be deleted." extra_block = self.make_block("html", vertical_block_course) # Repeat the copy_from_template(): @@ -86,19 +94,26 @@ class TestSplitCopyTemplate(MixedSplitTestCase): display_name_expected = "CUSTOM Library Display Name" self.make_block("problem", source_library, display_name=display_name_expected) # Reload source_library since we need its branch and version to use copy_from_template: - source_library = self.store.get_library(source_library.location.library_key, remove_version=False, remove_branch=False) + source_library = self.store.get_library( + source_library.location.library_key, remove_version=False, remove_branch=False + ) # And a course with a vertical: course = CourseFactory.create(modulestore=self.store) self.make_block("vertical", course) - problem_key_in_course = self.store.copy_from_template(source_library.children, dest_key=course.location, user_id=self.user_id)[0] + problem_key_in_course = self.store.copy_from_template( + source_library.children, dest_key=course.location, user_id=self.user_id + )[0] - # We do the following twice because different methods get used inside split modulestore on first vs. subsequent publish + # We do the following twice because different methods get used inside + # split modulestore on first vs. subsequent publish for __ in range(0, 2): # Publish: self.store.publish(problem_key_in_course, self.user_id) # Test that the defaults values are there. - problem_published = self.store.get_item(problem_key_in_course.for_branch(ModuleStoreEnum.BranchName.published)) + problem_published = self.store.get_item( + problem_key_in_course.for_branch(ModuleStoreEnum.BranchName.published) + ) self.assertEqual(problem_published.display_name, display_name_expected) def test_copy_from_template_auto_publish(self): @@ -119,7 +134,9 @@ class TestSplitCopyTemplate(MixedSplitTestCase): html = self.make_block("html", source_course) # Reload source_course since we need its branch and version to use copy_from_template: - source_course = self.store.get_course(source_course.location.course_key, remove_version=False, remove_branch=False) + source_course = self.store.get_course( + source_course.location.course_key, remove_version=False, remove_branch=False + ) # Inherit the vertical and the problem from the library into the course: source_keys = [block.location for block in [about, chapter, html]] @@ -146,10 +163,13 @@ class TestSplitCopyTemplate(MixedSplitTestCase): # Check that the auto-publish blocks have been published: self.assertFalse(self.store.has_changes(new_blocks["about"])) - self.assertTrue(published_version_exists(new_blocks["chapter"])) # We can't use has_changes because it includes descendants + # We can't use has_changes because it includes descendants + self.assertTrue(published_version_exists(new_blocks["chapter"])) self.assertTrue(published_version_exists(new_blocks["sequential"])) # Ditto # Check that non-auto-publish blocks and blocks with non-auto-publish descendants show changes: self.assertTrue(self.store.has_changes(new_blocks["html"])) self.assertTrue(self.store.has_changes(new_blocks["problem"])) - self.assertTrue(self.store.has_changes(new_blocks["chapter"])) # Will have changes since a child block has changes. - self.assertFalse(published_version_exists(new_blocks["vertical"])) # Verify that our published_version_exists works + # Will have changes since a child block has changes. + self.assertTrue(self.store.has_changes(new_blocks["chapter"])) + # Verify that our published_version_exists works + self.assertFalse(published_version_exists(new_blocks["vertical"])) diff --git a/common/lib/xmodule/xmodule/tests/test_library_content.py b/common/lib/xmodule/xmodule/tests/test_library_content.py index 5f32dab130..0a60895f0a 100644 --- a/common/lib/xmodule/xmodule/tests/test_library_content.py +++ b/common/lib/xmodule/xmodule/tests/test_library_content.py @@ -195,7 +195,8 @@ class TestLibraryContentModule(LibraryContentTest): """ # Set max_count to higher value than exists in library self.lc_block.max_count = 50 - self.lc_block.refresh_children() # In the normal studio editing process, editor_saved() calls refresh_children at this point + # In the normal studio editing process, editor_saved() calls refresh_children at this point + self.lc_block.refresh_children() result = self.lc_block.validate() self.assertFalse(result) # Validation fails due to at least one warning/message self.assertTrue(result.summary) @@ -269,7 +270,9 @@ class TestLibraryContentModule(LibraryContentTest): self.assertNotIn(LibraryContentDescriptor.display_name, non_editable_metadata_fields) -@patch('xmodule.modulestore.split_mongo.caching_descriptor_system.CachingDescriptorSystem.render', VanillaRuntime.render) +@patch( + 'xmodule.modulestore.split_mongo.caching_descriptor_system.CachingDescriptorSystem.render', VanillaRuntime.render +) @patch('xmodule.html_module.HtmlModule.author_view', dummy_render, create=True) @patch('xmodule.x_module.DescriptorSystem.applicable_aside_types', lambda self, block: []) class TestLibraryContentRender(LibraryContentTest): diff --git a/common/lib/xmodule/xmodule/tests/test_library_root.py b/common/lib/xmodule/xmodule/tests/test_library_root.py index 8774e7bb32..574bcf4152 100644 --- a/common/lib/xmodule/xmodule/tests/test_library_root.py +++ b/common/lib/xmodule/xmodule/tests/test_library_root.py @@ -14,7 +14,9 @@ from xmodule.modulestore.tests.utils import MixedSplitTestCase dummy_render = lambda block, _: Fragment(block.data) # pylint: disable=invalid-name -@patch('xmodule.modulestore.split_mongo.caching_descriptor_system.CachingDescriptorSystem.render', VanillaRuntime.render) +@patch( + 'xmodule.modulestore.split_mongo.caching_descriptor_system.CachingDescriptorSystem.render', VanillaRuntime.render +) @patch('xmodule.html_module.HtmlDescriptor.author_view', dummy_render, create=True) @patch('xmodule.x_module.DescriptorSystem.applicable_aside_types', lambda self, block: []) class TestLibraryRoot(MixedSplitTestCase): diff --git a/common/test/acceptance/fixtures/library.py b/common/test/acceptance/fixtures/library.py index 5692c078db..a7a55d207d 100644 --- a/common/test/acceptance/fixtures/library.py +++ b/common/test/acceptance/fixtures/library.py @@ -82,9 +82,9 @@ class LibraryFixture(XBlockContainerFixture): err_msg = response.json().get('ErrMsg') except ValueError: err_msg = "Unknown Error" - raise FixtureError( - "Could not create library {}. Status was {}, error was: {}".format(self.library_info, response.status_code, err_msg) - ) + raise FixtureError("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: diff --git a/common/test/acceptance/pages/studio/auto_auth.py b/common/test/acceptance/pages/studio/auto_auth.py index 2e2cffd677..d3759b1136 100644 --- a/common/test/acceptance/pages/studio/auto_auth.py +++ b/common/test/acceptance/pages/studio/auto_auth.py @@ -15,7 +15,8 @@ class AutoAuthPage(PageObject): this url will create a user and log them in. """ - def __init__(self, browser, username=None, email=None, password=None, staff=None, course_id=None, roles=None, no_login=None): + def __init__(self, browser, username=None, email=None, password=None, + staff=None, course_id=None, roles=None, no_login=None): """ Auto-auth is an end-point for HTTP GET requests. By default, it will create accounts with random user credentials, diff --git a/common/test/acceptance/pages/studio/container.py b/common/test/acceptance/pages/studio/container.py index 8f4ccb8590..88a05f9e02 100644 --- a/common/test/acceptance/pages/studio/container.py +++ b/common/test/acceptance/pages/studio/container.py @@ -365,6 +365,7 @@ class XBlockWrapper(PageObject): return self._validation_paragraph('error').present @property + # pylint: disable=invalid-name def has_validation_not_configured_warning(self): """ Is a validation "not configured" message shown? """ return self._validation_paragraph('not-configured').present @@ -380,6 +381,7 @@ class XBlockWrapper(PageObject): return self._validation_paragraph('error').text[0] @property + # pylint: disable=invalid-name def validation_not_configured_warning_text(self): """ Get the text of the validation "not configured" message. """ return self._validation_paragraph('not-configured').text[0] diff --git a/common/test/acceptance/pages/studio/index.py b/common/test/acceptance/pages/studio/index.py index 2a69b43343..d6d6a25603 100644 --- a/common/test/acceptance/pages/studio/index.py +++ b/common/test/acceptance/pages/studio/index.py @@ -88,7 +88,8 @@ class DashboardPage(PageObject): """ 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 + # Workaround Selenium/Firefox bug: `.text` property is broken on invisible elements + self.q(css='#course-index-tabs .libraries-tab a').click() div2info = lambda element: { 'name': element.find_element_by_css_selector('.course-title').text, 'org': element.find_element_by_css_selector('.course-org .value').text, diff --git a/common/test/acceptance/pages/studio/users.py b/common/test/acceptance/pages/studio/users.py index c1a2427d56..5c0216f893 100644 --- a/common/test/acceptance/pages/studio/users.py +++ b/common/test/acceptance/pages/studio/users.py @@ -47,7 +47,9 @@ class UsersPage(PageObject): """ Return a list of users listed on this page. """ - return self.q(css='.user-list .user-item').map(lambda el: UserWrapper(self.browser, el.get_attribute('data-email'))).results + return self.q(css='.user-list .user-item').map( + lambda el: UserWrapper(self.browser, el.get_attribute('data-email')) + ).results @property def has_add_button(self): diff --git a/common/test/acceptance/pages/studio/utils.py b/common/test/acceptance/pages/studio/utils.py index dd8ec091a3..df86d5deca 100644 --- a/common/test/acceptance/pages/studio/utils.py +++ b/common/test/acceptance/pages/studio/utils.py @@ -118,7 +118,10 @@ def add_component(page, item_type, specific_type): 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') + 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 diff --git a/common/test/acceptance/tests/studio/test_studio_general.py b/common/test/acceptance/tests/studio/test_studio_general.py index c0b409b66b..adaa46227d 100644 --- a/common/test/acceptance/tests/studio/test_studio_general.py +++ b/common/test/acceptance/tests/studio/test_studio_general.py @@ -93,7 +93,7 @@ class CoursePagesTest(StudioCourseTest): /course/ is the base URL for all courses, but by itself, it should redirect to /home/. """ - self.dashboard_page = DashboardPage(self.browser) + self.dashboard_page = DashboardPage(self.browser) # pylint: disable=attribute-defined-outside-init self.dashboard_page.visit() self.assertEqual(self.browser.current_url.strip('/').rsplit('/')[-1], 'home') diff --git a/common/test/acceptance/tests/studio/test_studio_library_container.py b/common/test/acceptance/tests/studio/test_studio_library_container.py index 593243e628..175ee08539 100644 --- a/common/test/acceptance/tests/studio/test_studio_library_container.py +++ b/common/test/acceptance/tests/studio/test_studio_library_container.py @@ -26,10 +26,15 @@ class StudioLibraryContainerTest(StudioLibraryTest, UniqueCourseTest): """ super(StudioLibraryContainerTest, self).setUp() # Also create a course: - self.course_fixture = CourseFixture(self.course_info['org'], self.course_info['number'], self.course_info['run'], self.course_info['display_name']) + self.course_fixture = CourseFixture( + self.course_info['org'], self.course_info['number'], + self.course_info['run'], self.course_info['display_name'] + ) self.populate_course_fixture(self.course_fixture) self.course_fixture.install() - self.outline = CourseOutlinePage(self.browser, self.course_info['org'], self.course_info['number'], self.course_info['run']) + self.outline = CourseOutlinePage( + self.browser, self.course_info['org'], self.course_info['number'], self.course_info['run'] + ) self.outline.visit() subsection = self.outline.section(SECTION_NAME).subsection(SUBSECTION_NAME) @@ -156,7 +161,8 @@ class StudioLibraryContainerTest(StudioLibraryTest, UniqueCourseTest): library_block = self._get_library_xblock_wrapper(self.unit_page.xblocks[0]) self.assertFalse(library_block.has_validation_warning) - #self.assertIn("3 matching components", library_block.author_content) # Removed this assert until a summary message is added back to the author view (SOL-192) + # Removed this assert until a summary message is added back to the author view (SOL-192) + #self.assertIn("3 matching components", library_block.author_content) self.library_fixture.create_xblock(self.library_fixture.library_location, XBlockFixtureDesc("html", "Html4")) @@ -171,7 +177,8 @@ class StudioLibraryContainerTest(StudioLibraryTest, UniqueCourseTest): library_block = self._get_library_xblock_wrapper(self.unit_page.xblocks[0]) self.assertFalse(library_block.has_validation_message) - #self.assertIn("4 matching components", library_block.author_content) # Removed this assert until a summary message is added back to the author view (SOL-192) + # Removed this assert until a summary message is added back to the author view (SOL-192) + #self.assertIn("4 matching components", library_block.author_content) def test_no_content_message(self): """ From e4ea28f7f687339457a0da5ddeb66a1c0f78e943 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Thu, 8 Jan 2015 11:59:02 -0800 Subject: [PATCH 89/99] Minor pylint fixes --- cms/djangoapps/contentstore/features/courses.py | 3 +-- common/djangoapps/terrain/ui_helpers.py | 13 ++++++------- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/cms/djangoapps/contentstore/features/courses.py b/cms/djangoapps/contentstore/features/courses.py index cb7b85f076..218faded58 100644 --- a/cms/djangoapps/contentstore/features/courses.py +++ b/cms/djangoapps/contentstore/features/courses.py @@ -34,9 +34,8 @@ def i_create_a_course(step): create_a_course() -# pylint: disable=invalid-name @step('I click the course link in Studio Home$') -def i_click_the_course_link_in_studio_home(step): +def i_click_the_course_link_in_studio_home(step): # pylint: disable=invalid-name course_css = 'a.course-link' world.css_click(course_css) diff --git a/common/djangoapps/terrain/ui_helpers.py b/common/djangoapps/terrain/ui_helpers.py index 2b5248a1ab..cdd9e2fbd6 100644 --- a/common/djangoapps/terrain/ui_helpers.py +++ b/common/djangoapps/terrain/ui_helpers.py @@ -28,28 +28,27 @@ GLOBAL_WAIT_FOR_TIMEOUT = 60 REQUIREJS_WAIT = { # Settings - Schedule & Details - re.compile('^Schedule & Details Settings \|'): [ + re.compile(r'^Schedule & Details Settings \|'): [ "jquery", "js/base", "js/models/course", "js/models/settings/course_details", "js/views/settings/main"], # Settings - Advanced Settings - re.compile('^Advanced Settings \|'): [ + re.compile(r'^Advanced Settings \|'): [ "jquery", "js/base", "js/models/course", "js/models/settings/advanced", "js/views/settings/advanced", "codemirror"], # Unit page - re.compile('^Unit \|'): [ + re.compile(r'^Unit \|'): [ "jquery", "js/base", "js/models/xblock_info", "js/views/pages/container", "js/collections/component_template", "xmodule", "coffee/src/main", "xblock/cms.runtime.v1"], # Content - Outline # Note that calling your org, course number, or display name, 'course' will mess this up - re.compile('^Course Outline \|'): [ + re.compile(r'^Course Outline \|'): [ "js/base", "js/models/course", "js/models/location", "js/models/section"], # Dashboard - # pylint: disable=anomalous-backslash-in-string - re.compile('^Studio Home \|'): [ + re.compile(r'^Studio Home \|'): [ "js/sock", "gettext", "js/base", "jquery.ui", "coffee/src/main", "underscore"], @@ -60,7 +59,7 @@ REQUIREJS_WAIT = { ], # Pages - re.compile('^Pages \|'): [ + re.compile(r'^Pages \|'): [ 'js/models/explicit_url', 'coffee/src/views/tabs', 'xmodule', 'coffee/src/main', 'xblock/cms.runtime.v1' ], From 91cb371de056f310ef8639f7a5c9ea1118aaa443 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Fri, 19 Dec 2014 14:16:24 -0800 Subject: [PATCH 90/99] Emit per-student library content events to the tracking log for analytics --- .../xmodule/xmodule/library_content_module.py | 34 ++++++++++++++++--- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/common/lib/xmodule/xmodule/library_content_module.py b/common/lib/xmodule/xmodule/library_content_module.py index e4c3252b37..78a8e0eecf 100644 --- a/common/lib/xmodule/xmodule/library_content_module.py +++ b/common/lib/xmodule/xmodule/library_content_module.py @@ -221,24 +221,50 @@ class LibraryContentModule(LibraryContentFields, XModule, StudioEditableModule): # Already done: return self._selected_set # pylint: disable=access-member-before-definition # Determine which of our children we will show: + jsonify_block_keys = lambda keys: [unicode(self.location.course_key.make_usage_key(*key)) for key in keys] selected = set(tuple(k) for k in self.selected) # set of (block_type, block_id) tuples valid_block_keys = set([(c.block_type, c.block_id) for c in self.children]) # pylint: disable=no-member # Remove any selected blocks that are no longer valid: - selected -= (selected - valid_block_keys) + invalid_block_keys = (selected - valid_block_keys) + if invalid_block_keys: + selected -= invalid_block_keys + # Publish an event for analytics purposes: + self.runtime.publish(self, "edx.librarycontentblock.content.removed", { + "location": unicode(self.location), + "blocks": jsonify_block_keys(invalid_block_keys), + "reason": "invalid", # Deleted from library or library being used has changed + }) # If max_count has been decreased, we may have to drop some previously selected blocks: + overlimit_block_keys = set() while len(selected) > self.max_count: - selected.pop() + overlimit_block_keys.add(selected.pop()) + if overlimit_block_keys: + # Publish an event for analytics purposes: + self.runtime.publish(self, "edx.librarycontentblock.content.removed", { + "location": unicode(self.location), + "blocks": jsonify_block_keys(overlimit_block_keys), + "reason": "overlimit", + }) # Do we have enough blocks now? num_to_add = self.max_count - len(selected) if num_to_add > 0: + added_block_keys = None # We need to select [more] blocks to display to this user: + pool = valid_block_keys - selected if self.mode == "random": - pool = valid_block_keys - selected num_to_add = min(len(pool), num_to_add) - selected |= set(random.sample(pool, num_to_add)) + added_block_keys = set(random.sample(pool, num_to_add)) # We now have the correct n random children to show for this user. else: raise NotImplementedError("Unsupported mode.") + selected |= added_block_keys + if added_block_keys: + # Publish an event for analytics purposes: + self.runtime.publish(self, "edx.librarycontentblock.content.assigned", { + "location": unicode(self.location), + "added": jsonify_block_keys(added_block_keys), + "result": jsonify_block_keys(selected), + }) # Save our selections to the user state, to ensure consistency: self.selected = list(selected) # TODO: this doesn't save from the LMS "Progress" page. # Cache the results From 407265bc45a2bb8fe65ca5955ee6b12d0c9d671d Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Fri, 19 Dec 2014 23:22:30 -0800 Subject: [PATCH 91/99] Store the usage locator of library blocks in split when they are inherited into a course --- .../lib/xmodule/xmodule/modulestore/mixed.py | 12 ++++++++++ .../xmodule/modulestore/split_mongo/split.py | 22 +++++++++++++++++++ .../modulestore/split_mongo/split_draft.py | 9 ++++++++ 3 files changed, 43 insertions(+) diff --git a/common/lib/xmodule/xmodule/modulestore/mixed.py b/common/lib/xmodule/xmodule/modulestore/mixed.py index 6175427659..85ba62d82e 100644 --- a/common/lib/xmodule/xmodule/modulestore/mixed.py +++ b/common/lib/xmodule/xmodule/modulestore/mixed.py @@ -509,6 +509,18 @@ class MixedModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase): store = self._get_modulestore_for_courseid(location.course_key) return store.get_parent_location(location, **kwargs) + def get_block_original_usage(self, usage_key): + """ + If a block was inherited into another structure using copy_from_template, + this will return the original block usage locator from which the + copy was inherited. + """ + try: + store = self._verify_modulestore_support(usage_key.course_key, 'get_block_original_usage') + return store.get_block_original_usage(usage_key) + except NotImplementedError: + return None, None + def get_modulestore_type(self, course_id): """ Returns a type which identifies which modulestore is servicing the given course_id. diff --git a/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py b/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py index c4d5d638e8..0d7a861b2b 100644 --- a/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py +++ b/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py @@ -454,12 +454,17 @@ class SplitBulkWriteMixin(BulkOperationsMixin): if block_info['edit_info'].get('update_version') == update_version: return + original_usage = block_info['edit_info'].get('original_usage') + original_usage_version = block_info['edit_info'].get('original_usage_version') block_info['edit_info'] = { 'edited_on': datetime.datetime.now(UTC), 'edited_by': user_id, 'previous_version': block_info['edit_info']['update_version'], 'update_version': update_version, } + if original_usage: + block_info['edit_info']['original_usage'] = original_usage + block_info['edit_info']['original_usage_version'] = original_usage_version def find_matching_course_indexes(self, branch=None, search_targets=None): """ @@ -1254,6 +1259,21 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase): # TODO implement pass + def get_block_original_usage(self, usage_key): + """ + If a block was inherited into another structure using copy_from_template, + this will return the original block usage locator and version from + which the copy was inherited. + + Returns usage_key, version if the data is available, otherwise returns (None, None) + """ + blocks = self._lookup_course(usage_key.course_key).structure['blocks'] + block = blocks.get(BlockKey.from_usage_key(usage_key)) + if block and 'original_usage' in block['edit_info']: + usage_key = BlockUsageLocator.from_string(block['edit_info']['original_usage']) + return usage_key, block['edit_info'].get('original_usage_version') + return None, None + def create_definition_from_data(self, course_key, new_def_data, category, user_id): """ Pull the definition fields out of descriptor and save to the db as a new definition @@ -2214,6 +2234,8 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase): # Setting it to the source_block_info structure version here breaks split_draft's has_changes() method. new_block_info['edit_info']['edited_by'] = user_id new_block_info['edit_info']['edited_on'] = datetime.datetime.now(UTC) + new_block_info['edit_info']['original_usage'] = unicode(usage_key.replace(branch=None, version_guid=None)) + new_block_info['edit_info']['original_usage_version'] = source_block_info['edit_info'].get('update_version') dest_structure['blocks'][new_block_key] = new_block_info children = source_block_info['fields'].get('children') diff --git a/common/lib/xmodule/xmodule/modulestore/split_mongo/split_draft.py b/common/lib/xmodule/xmodule/modulestore/split_mongo/split_draft.py index 30fe251823..d58be235dc 100644 --- a/common/lib/xmodule/xmodule/modulestore/split_mongo/split_draft.py +++ b/common/lib/xmodule/xmodule/modulestore/split_mongo/split_draft.py @@ -268,6 +268,15 @@ class DraftVersioningModuleStore(SplitMongoModuleStore, ModuleStoreDraftAndPubli location = self._map_revision_to_branch(location, revision=revision) return super(DraftVersioningModuleStore, self).get_parent_location(location, **kwargs) + def get_block_original_usage(self, usage_key): + """ + If a block was inherited into another structure using copy_from_template, + this will return the original block usage locator from which the + copy was inherited. + """ + usage_key = self._map_revision_to_branch(usage_key) + return super(DraftVersioningModuleStore, self).get_block_original_usage(usage_key) + def get_orphans(self, course_key, **kwargs): course_key = self._map_revision_to_branch(course_key) return super(DraftVersioningModuleStore, self).get_orphans(course_key, **kwargs) From 3973317aa90718f6c2f407bbf9cdb7b58135609c Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Fri, 9 Jan 2015 11:01:40 -0800 Subject: [PATCH 92/99] Report the original LibraryUsageLocator in LMS analytics --- .../xmodule/xmodule/library_content_module.py | 15 +++++--- common/lib/xmodule/xmodule/library_tools.py | 38 +++++++++++++++++++ lms/djangoapps/lms_xblock/runtime.py | 2 + 3 files changed, 50 insertions(+), 5 deletions(-) diff --git a/common/lib/xmodule/xmodule/library_content_module.py b/common/lib/xmodule/xmodule/library_content_module.py index 78a8e0eecf..e3384efefc 100644 --- a/common/lib/xmodule/xmodule/library_content_module.py +++ b/common/lib/xmodule/xmodule/library_content_module.py @@ -220,8 +220,11 @@ class LibraryContentModule(LibraryContentFields, XModule, StudioEditableModule): if hasattr(self, "_selected_set"): # Already done: return self._selected_set # pylint: disable=access-member-before-definition + + lib_tools = self.runtime.service(self, 'library_tools') + format_block_keys = lambda block_keys: lib_tools.create_block_analytics_summary(self.location.course_key, block_keys) + # Determine which of our children we will show: - jsonify_block_keys = lambda keys: [unicode(self.location.course_key.make_usage_key(*key)) for key in keys] selected = set(tuple(k) for k in self.selected) # set of (block_type, block_id) tuples valid_block_keys = set([(c.block_type, c.block_id) for c in self.children]) # pylint: disable=no-member # Remove any selected blocks that are no longer valid: @@ -231,8 +234,9 @@ class LibraryContentModule(LibraryContentFields, XModule, StudioEditableModule): # Publish an event for analytics purposes: self.runtime.publish(self, "edx.librarycontentblock.content.removed", { "location": unicode(self.location), - "blocks": jsonify_block_keys(invalid_block_keys), + "removed": format_block_keys(invalid_block_keys), "reason": "invalid", # Deleted from library or library being used has changed + "result": format_block_keys(selected), }) # If max_count has been decreased, we may have to drop some previously selected blocks: overlimit_block_keys = set() @@ -242,8 +246,9 @@ class LibraryContentModule(LibraryContentFields, XModule, StudioEditableModule): # Publish an event for analytics purposes: self.runtime.publish(self, "edx.librarycontentblock.content.removed", { "location": unicode(self.location), - "blocks": jsonify_block_keys(overlimit_block_keys), + "removed": format_block_keys(overlimit_block_keys), "reason": "overlimit", + "result": format_block_keys(selected), }) # Do we have enough blocks now? num_to_add = self.max_count - len(selected) @@ -262,8 +267,8 @@ class LibraryContentModule(LibraryContentFields, XModule, StudioEditableModule): # Publish an event for analytics purposes: self.runtime.publish(self, "edx.librarycontentblock.content.assigned", { "location": unicode(self.location), - "added": jsonify_block_keys(added_block_keys), - "result": jsonify_block_keys(selected), + "added": format_block_keys(added_block_keys), + "result": format_block_keys(selected), }) # Save our selections to the user state, to ensure consistency: self.selected = list(selected) # TODO: this doesn't save from the LMS "Progress" page. diff --git a/common/lib/xmodule/xmodule/library_tools.py b/common/lib/xmodule/xmodule/library_tools.py index 8f3c6d0cee..aebe2aef98 100644 --- a/common/lib/xmodule/xmodule/library_tools.py +++ b/common/lib/xmodule/xmodule/library_tools.py @@ -44,6 +44,44 @@ class LibraryToolsService(object): return library.location.library_key.version_guid return None + def create_block_analytics_summary(self, course_key, block_keys): + """ + Given a CourseKey and a list of (block_type, block_id) pairs, + prepare the JSON-ready metadata needed for analytics logging. + + This is [ + {"usage_key": x, "original_usage_key": y, "original_usage_version": z, "descendants": [...]} + ] + where the main list contains all top-level blocks, and descendants contains a *flat* list of all + descendants of the top level blocks, if any. + """ + def summarize_block(usage_key): + """ Basic information about the given block """ + orig_key, orig_version = self.store.get_block_original_usage(usage_key) + return { + "usage_key": unicode(usage_key), + "original_usage_key": unicode(orig_key) if orig_key else None, + "original_usage_version": unicode(orig_version) if orig_version else None, + } + + result_json = [] + for block_key in block_keys: + key = course_key.make_usage_key(*block_key) + info = summarize_block(key) + info['descendants'] = [] + try: + block = self.store.get_item(key, depth=None) # Load the item and all descendants + children = list(getattr(block, "children", [])) + while children: + child_key = children.pop() + child = self.store.get_item(child_key) + info['descendants'].append(summarize_block(child_key)) + children.extend(getattr(child, "children", [])) + except ItemNotFoundError: + pass # The block has been deleted + result_json.append(info) + return result_json + def _filter_child(self, usage_key, capa_type): """ Filters children by CAPA problem type, if configured diff --git a/lms/djangoapps/lms_xblock/runtime.py b/lms/djangoapps/lms_xblock/runtime.py index 49d0abbf38..7b5c3fe7b9 100644 --- a/lms/djangoapps/lms_xblock/runtime.py +++ b/lms/djangoapps/lms_xblock/runtime.py @@ -10,6 +10,7 @@ from django.conf import settings from lms.djangoapps.lms_xblock.models import XBlockAsidesConfig from openedx.core.djangoapps.user_api.api import course_tag as user_course_tag_api from xmodule.modulestore.django import modulestore +from xmodule.library_tools import LibraryToolsService from xmodule.x_module import ModuleSystem from xmodule.partitions.partitions_service import PartitionService @@ -199,6 +200,7 @@ class LmsModuleSystem(LmsHandlerUrls, ModuleSystem): # pylint: disable=abstract course_id=kwargs.get('course_id'), track_function=kwargs.get('track_function', None), ) + services['library_tools'] = LibraryToolsService(modulestore()) services['fs'] = xblock.reference.plugins.FSService() self.request_token = kwargs.pop('request_token', None) super(LmsModuleSystem, self).__init__(**kwargs) From 3a973d4274355aafb3e896ce9bfd5318d0487355 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Fri, 9 Jan 2015 11:02:17 -0800 Subject: [PATCH 93/99] Unit tests for library content analytics --- .../xmodule/modulestore/tests/utils.py | 1 + .../xmodule/tests/test_library_content.py | 237 ++++++++++++++---- 2 files changed, 186 insertions(+), 52 deletions(-) diff --git a/common/lib/xmodule/xmodule/modulestore/tests/utils.py b/common/lib/xmodule/xmodule/modulestore/tests/utils.py index 986a54df9f..10d3dd1742 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/utils.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/utils.py @@ -119,6 +119,7 @@ class MixedSplitTestCase(TestCase): extra.update(kwargs) return ItemFactory.create( category=category, + parent=parent_block, parent_location=parent_block.location, modulestore=self.store, **extra diff --git a/common/lib/xmodule/xmodule/tests/test_library_content.py b/common/lib/xmodule/xmodule/tests/test_library_content.py index 0a60895f0a..7eb1c81a18 100644 --- a/common/lib/xmodule/xmodule/tests/test_library_content.py +++ b/common/lib/xmodule/xmodule/tests/test_library_content.py @@ -5,22 +5,22 @@ Basic unit tests for LibraryContentModule Higher-level tests are in `cms/djangoapps/contentstore/tests/test_libraries.py`. """ from bson.objectid import ObjectId -from mock import patch +from mock import Mock, patch from opaque_keys.edx.locator import LibraryLocator from unittest import TestCase from xblock.fragment import Fragment from xblock.runtime import Runtime as VanillaRuntime -from xmodule.x_module import AUTHOR_VIEW from xmodule.library_content_module import ( LibraryVersionReference, LibraryList, ANY_CAPA_TYPE_VALUE, LibraryContentDescriptor ) -from xmodule.modulestore.tests.factories import LibraryFactory, CourseFactory, ItemFactory +from xmodule.library_tools import LibraryToolsService +from xmodule.modulestore.tests.factories import LibraryFactory, CourseFactory from xmodule.modulestore.tests.utils import MixedSplitTestCase from xmodule.tests import get_test_system from xmodule.validation import StudioValidationMessage - +from xmodule.x_module import AUTHOR_VIEW dummy_render = lambda block, _: Fragment(block.data) # pylint: disable=invalid-name @@ -32,46 +32,21 @@ class LibraryContentTest(MixedSplitTestCase): def setUp(self): super(LibraryContentTest, self).setUp() + self.tools = LibraryToolsService(self.store) self.library = LibraryFactory.create(modulestore=self.store) self.lib_blocks = [ - ItemFactory.create( - category="html", - parent_location=self.library.location, - user_id=self.user_id, - publish_item=False, - metadata={"data": "Hello world from block {}".format(i), }, - modulestore=self.store, - ) + self.make_block("html", self.library, data="Hello world from block {}".format(i)) for i in range(1, 5) ] self.course = CourseFactory.create(modulestore=self.store) - self.chapter = ItemFactory.create( - category="chapter", - parent_location=self.course.location, - user_id=self.user_id, - modulestore=self.store, - ) - self.sequential = ItemFactory.create( - category="sequential", - parent_location=self.chapter.location, - user_id=self.user_id, - modulestore=self.store, - ) - self.vertical = ItemFactory.create( - category="vertical", - parent_location=self.sequential.location, - user_id=self.user_id, - modulestore=self.store, - ) - self.lc_block = ItemFactory.create( - category="library_content", - parent_location=self.vertical.location, - user_id=self.user_id, - modulestore=self.store, - metadata={ - 'max_count': 1, - 'source_libraries': [LibraryVersionReference(self.library.location.library_key)] - } + self.chapter = self.make_block("chapter", self.course) + self.sequential = self.make_block("sequential", self.chapter) + self.vertical = self.make_block("vertical", self.sequential) + self.lc_block = self.make_block( + "library_content", + self.vertical, + max_count=1, + source_libraries=[LibraryVersionReference(self.library.location.library_key)] ) def _bind_course_module(self, module): @@ -80,6 +55,7 @@ class LibraryContentTest(MixedSplitTestCase): """ module_system = get_test_system(course_id=self.course.location.course_key) module_system.descriptor_runtime = module.runtime + module_system._services['library_tools'] = self.tools # pylint: disable=protected-access def get_module(descriptor): """Mocks module_system get_module function""" @@ -92,6 +68,11 @@ class LibraryContentTest(MixedSplitTestCase): module_system.get_module = get_module module.xmodule_runtime = module_system + +class TestLibraryContentModule(LibraryContentTest): + """ + Basic unit tests for LibraryContentModule + """ def _get_capa_problem_type_xml(self, *args): """ Helper function to create empty CAPA problem definition """ problem = "" @@ -111,20 +92,8 @@ class LibraryContentTest(MixedSplitTestCase): ["coderesponse", "optionresponse"] ] for problem_type in problem_types: - ItemFactory.create( - category="problem", - parent_location=self.library.location, - user_id=self.user_id, - publish_item=False, - data=self._get_capa_problem_type_xml(*problem_type), - modulestore=self.store, - ) + self.make_block("problem", self.library, data=self._get_capa_problem_type_xml(*problem_type)) - -class TestLibraryContentModule(LibraryContentTest): - """ - Basic unit tests for LibraryContentModule - """ def test_lib_content_block(self): """ Test that blocks from a library are copied and added as children @@ -338,3 +307,167 @@ class TestLibraryList(TestCase): lib_list = LibraryList() with self.assertRaises(ValueError): lib_list.from_json(["Not-a-library-key,whatever"]) + + +class TestLibraryContentAnalytics(LibraryContentTest): + """ + Test analytics features of LibraryContentModule + """ + def setUp(self): + super(TestLibraryContentAnalytics, self).setUp() + self.publisher = Mock() + self.lc_block.refresh_children() + self.lc_block = self.store.get_item(self.lc_block.location) + self._bind_course_module(self.lc_block) + self.lc_block.xmodule_runtime.publish = self.publisher + + def _assert_event_was_published(self, event_type): + """ + Check that a LibraryContentModule analytics event was published by self.lc_block. + """ + self.assertTrue(self.publisher.called) + self.assertTrue(len(self.publisher.call_args[0]), 3) + _, event_name, event_data = self.publisher.call_args[0] + self.assertEqual(event_name, "edx.librarycontentblock.content.{}".format(event_type)) + self.assertEqual(event_data["location"], unicode(self.lc_block.location)) + return event_data + + def test_assigned_event(self): + """ + Test the "assigned" event emitted when a student is assigned specific blocks. + """ + # In the beginning was the lc_block and it assigned one child to the student: + child = self.lc_block.get_child_descriptors()[0] + child_lib_location, child_lib_version = self.store.get_block_original_usage(child.location) + self.assertIsInstance(child_lib_version, ObjectId) + event_data = self._assert_event_was_published("assigned") + block_info = { + "usage_key": unicode(child.location), + "original_usage_key": unicode(child_lib_location), + "original_usage_version": unicode(child_lib_version), + "descendants": [], + } + self.assertEqual(event_data, { + "location": unicode(self.lc_block.location), + "added": [block_info], + "result": [block_info], + }) + self.publisher.reset_mock() + + # Now increase max_count so that one more child will be added: + self.lc_block.max_count = 2 + del self.lc_block._xmodule._selected_set # Clear the cache (only needed because we skip saving/re-loading the block) pylint: disable=protected-access + children = self.lc_block.get_child_descriptors() + self.assertEqual(len(children), 2) + child, new_child = children if children[0].location == child.location else reversed(children) + event_data = self._assert_event_was_published("assigned") + self.assertEqual(event_data["added"][0]["usage_key"], unicode(new_child.location)) + self.assertEqual(len(event_data["result"]), 2) + + def test_assigned_descendants(self): + """ + Test the "assigned" event emitted includes descendant block information. + """ + # Replace the blocks in the library with a block that has descendants: + with self.store.bulk_operations(self.library.location.library_key): + self.library.children = [] + main_vertical = self.make_block("vertical", self.library) + inner_vertical = self.make_block("vertical", main_vertical) + html_block = self.make_block("html", inner_vertical) + problem_block = self.make_block("problem", inner_vertical) + self.lc_block.refresh_children() + + # Reload lc_block and set it up for a student: + self.lc_block = self.store.get_item(self.lc_block.location) + self._bind_course_module(self.lc_block) + self.lc_block.xmodule_runtime.publish = self.publisher + + # Get the keys of each of our blocks, as they appear in the course: + course_usage_main_vertical = self.lc_block.children[0] + course_usage_inner_vertical = self.store.get_item(course_usage_main_vertical).children[0] + inner_vertical_in_course = self.store.get_item(course_usage_inner_vertical) + course_usage_html = inner_vertical_in_course.children[0] + course_usage_problem = inner_vertical_in_course.children[1] + + # Trigger a publish event: + self.lc_block.get_child_descriptors() + event_data = self._assert_event_was_published("assigned") + + for block_list in (event_data["added"], event_data["result"]): + self.assertEqual(len(block_list), 1) # The main_vertical is the only root block added, and is the only result. + self.assertEqual(block_list[0]["usage_key"], unicode(course_usage_main_vertical)) + + # Check that "descendants" is a flat, unordered list of all of main_vertical's descendants: + descendants_expected = {} + for lib_key, course_usage_key in ( + (inner_vertical.location, course_usage_inner_vertical), + (html_block.location, course_usage_html), + (problem_block.location, course_usage_problem), + ): + descendants_expected[unicode(course_usage_key)] = { + "usage_key": unicode(course_usage_key), + "original_usage_key": unicode(lib_key), + "original_usage_version": unicode(self.store.get_block_original_usage(course_usage_key)[1]), + } + self.assertEqual(len(block_list[0]["descendants"]), len(descendants_expected)) + for descendant in block_list[0]["descendants"]: + self.assertEqual(descendant, descendants_expected.get(descendant["usage_key"])) + + def test_removed_overlimit(self): + """ + Test the "removed" event emitted when we un-assign blocks previously assigned to a student. + We go from one blocks assigned to none because max_count has been decreased. + """ + # Decrease max_count to 1, causing the block to be overlimit: + self.lc_block.get_child_descriptors() # We must call an XModule method before we can change max_count - otherwise the change has no effect + self.publisher.reset_mock() # Clear the "assigned" event that was just published. + self.lc_block.max_count = 0 + del self.lc_block._xmodule._selected_set # Clear the cache (only needed because we skip saving/re-loading the block) pylint: disable=protected-access + + # Check that the event says that one block was removed, leaving no blocks left: + children = self.lc_block.get_child_descriptors() + self.assertEqual(len(children), 0) + event_data = self._assert_event_was_published("removed") + self.assertEqual(len(event_data["removed"]), 1) + self.assertEqual(event_data["result"], []) + self.assertEqual(event_data["reason"], "overlimit") + + def test_removed_invalid(self): + """ + Test the "removed" event emitted when we un-assign blocks previously assigned to a student. + We go from two blocks assigned, to one because the others have been deleted from the library. + """ + # Start by assigning two blocks to the student: + self.lc_block.get_child_descriptors() # We must call an XModule method before we can change max_count - otherwise the change has no effect + self.lc_block.max_count = 2 + del self.lc_block._xmodule._selected_set # Clear the cache (only needed because we skip saving/re-loading the block) pylint: disable=protected-access + initial_blocks_assigned = self.lc_block.get_child_descriptors() + self.assertEqual(len(initial_blocks_assigned), 2) + self.publisher.reset_mock() # Clear the "assigned" event that was just published. + # Now make sure that one of the assigned blocks will have to be un-assigned. + # To cause an "invalid" event, we delete all blocks from the content library except for one of the two already assigned to the student: + keep_block_key = initial_blocks_assigned[0].location + keep_block_lib_usage_key, keep_block_lib_version = self.store.get_block_original_usage(keep_block_key) + deleted_block_key = initial_blocks_assigned[1].location + self.library.children = [keep_block_lib_usage_key] + self.store.update_item(self.library, self.user_id) + self.lc_block.refresh_children() + del self.lc_block._xmodule._selected_set # Clear the cache (only needed because we skip saving/re-loading the block) pylint: disable=protected-access + + # Check that the event says that one block was removed, leaving one block left: + children = self.lc_block.get_child_descriptors() + self.assertEqual(len(children), 1) + event_data = self._assert_event_was_published("removed") + self.assertEqual(event_data["removed"], [{ + "usage_key": unicode(deleted_block_key), + "original_usage_key": None, # Note: original_usage_key info is sadly unavailable because the block has been deleted so that info can no longer be retrieved + "original_usage_version": None, + "descendants": [], + }]) + self.assertEqual(event_data["result"], [{ + "usage_key": unicode(keep_block_key), + "original_usage_key": unicode(keep_block_lib_usage_key), + "original_usage_version": unicode(keep_block_lib_version), + "descendants": [], + }]) + self.assertEqual(event_data["reason"], "invalid") From 9a116529cecfc3221646caf47625f2b346d3d2de Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Fri, 9 Jan 2015 11:02:49 -0800 Subject: [PATCH 94/99] Add module key and library source info to problem events --- lms/djangoapps/courseware/module_render.py | 6 ++++ .../courseware/tests/test_module_render.py | 30 +++++++++++++++---- 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index 07c4d15d38..2f84fcb155 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -755,6 +755,7 @@ def _invoke_xblock_handler(request, course_id, usage_id, handler, suffix, user): try: descriptor = modulestore().get_item(usage_key) + descriptor_orig_usage_key, descriptor_orig_version = modulestore().get_block_original_usage(usage_key) except ItemNotFoundError: log.warn( "Invalid location for course id {course_id}: {usage_key}".format( @@ -768,8 +769,13 @@ def _invoke_xblock_handler(request, course_id, usage_id, handler, suffix, user): tracking_context = { 'module': { 'display_name': descriptor.display_name_with_default, + 'usage_key': unicode(descriptor.location), } } + # For blocks that are inherited from a content library, we add some additional metadata: + if descriptor_orig_usage_key is not None: + tracking_context['module']['original_usage_key'] = unicode(descriptor_orig_usage_key) + tracking_context['module']['original_usage_version'] = unicode(descriptor_orig_version) field_data_cache = FieldDataCache.cache_for_descriptor_descendents( course_id, diff --git a/lms/djangoapps/courseware/tests/test_module_render.py b/lms/djangoapps/courseware/tests/test_module_render.py index 7e17441142..ca7c07f8a4 100644 --- a/lms/djangoapps/courseware/tests/test_module_render.py +++ b/lms/djangoapps/courseware/tests/test_module_render.py @@ -5,6 +5,7 @@ Test for lms courseware app, module render unit from functools import partial import json +from bson import ObjectId import ddt from django.http import Http404, HttpResponse from django.core.urlresolvers import reverse @@ -13,6 +14,7 @@ from django.test.client import RequestFactory from django.test.utils import override_settings from django.contrib.auth.models import AnonymousUser from mock import MagicMock, patch, Mock +from opaque_keys.edx.keys import UsageKey from opaque_keys.edx.locations import SlashSeparatedCourseKey from xblock.field_data import FieldData from xblock.runtime import Runtime @@ -971,12 +973,13 @@ class TestModuleTrackingContext(ModuleStoreTestCase): def test_context_contains_display_name(self, mock_tracker): problem_display_name = u'Option Response Problem' - actual_display_name = self.handle_callback_and_get_display_name_from_event(mock_tracker, problem_display_name) - self.assertEquals(problem_display_name, actual_display_name) + module_info = self.handle_callback_and_get_module_info_from_event(mock_tracker, problem_display_name) + self.assertEquals(problem_display_name, module_info['display_name']) - def handle_callback_and_get_display_name_from_event(self, mock_tracker, problem_display_name=None): + def handle_callback_and_get_module_info_from_event(self, mock_tracker, problem_display_name=None): """ - Creates a fake module, invokes the callback and extracts the display name from the emitted problem_check event. + Creates a fake module, invokes the callback and extracts the 'module' + metadata from the emitted problem_check event. """ descriptor_kwargs = { 'category': 'problem', @@ -1000,12 +1003,27 @@ class TestModuleTrackingContext(ModuleStoreTestCase): event = mock_call[1][0] self.assertEquals(event['event_type'], 'problem_check') - return event['context']['module']['display_name'] + return event['context']['module'] def test_missing_display_name(self, mock_tracker): - actual_display_name = self.handle_callback_and_get_display_name_from_event(mock_tracker) + actual_display_name = self.handle_callback_and_get_module_info_from_event(mock_tracker)['display_name'] self.assertTrue(actual_display_name.startswith('problem')) + def test_library_source_information(self, mock_tracker): + """ + Check that XBlocks that are inherited from a library include the + information about their library block source in events. + We patch the modulestore to avoid having to create a library. + """ + original_usage_key = UsageKey.from_string(u'block-v1:A+B+C+type@problem+block@abcd1234') + original_usage_version = ObjectId() + with patch('xmodule.modulestore.mixed.MixedModuleStore.get_block_original_usage', lambda _, key: (original_usage_key, original_usage_version)): + module_info = self.handle_callback_and_get_module_info_from_event(mock_tracker) + self.assertIn('original_usage_key', module_info) + self.assertEqual(module_info['original_usage_key'], unicode(original_usage_key)) + self.assertIn('original_usage_version', module_info) + self.assertEqual(module_info['original_usage_version'], unicode(original_usage_version)) + class TestXmoduleRuntimeEvent(TestSubmittingProblems): """ From 10fe9c01802a8b8d73a2169142fc66ef89b811ff Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Mon, 12 Jan 2015 11:00:51 -0800 Subject: [PATCH 95/99] pylint fixes --- .../xmodule/xmodule/library_content_module.py | 2 +- .../xmodule/tests/test_library_content.py | 37 +++++++++++-------- .../courseware/tests/test_module_render.py | 11 +++--- 3 files changed, 29 insertions(+), 21 deletions(-) diff --git a/common/lib/xmodule/xmodule/library_content_module.py b/common/lib/xmodule/xmodule/library_content_module.py index e3384efefc..c1d5dc9fb5 100644 --- a/common/lib/xmodule/xmodule/library_content_module.py +++ b/common/lib/xmodule/xmodule/library_content_module.py @@ -222,7 +222,7 @@ class LibraryContentModule(LibraryContentFields, XModule, StudioEditableModule): return self._selected_set # pylint: disable=access-member-before-definition lib_tools = self.runtime.service(self, 'library_tools') - format_block_keys = lambda block_keys: lib_tools.create_block_analytics_summary(self.location.course_key, block_keys) + format_block_keys = lambda keys: lib_tools.create_block_analytics_summary(self.location.course_key, keys) # Determine which of our children we will show: selected = set(tuple(k) for k in self.selected) # set of (block_type, block_id) tuples diff --git a/common/lib/xmodule/xmodule/tests/test_library_content.py b/common/lib/xmodule/xmodule/tests/test_library_content.py index 7eb1c81a18..c3bb661337 100644 --- a/common/lib/xmodule/xmodule/tests/test_library_content.py +++ b/common/lib/xmodule/xmodule/tests/test_library_content.py @@ -356,7 +356,8 @@ class TestLibraryContentAnalytics(LibraryContentTest): # Now increase max_count so that one more child will be added: self.lc_block.max_count = 2 - del self.lc_block._xmodule._selected_set # Clear the cache (only needed because we skip saving/re-loading the block) pylint: disable=protected-access + # Clear the cache (only needed because we skip saving/re-loading the block) pylint: disable=protected-access + del self.lc_block._xmodule._selected_set children = self.lc_block.get_child_descriptors() self.assertEqual(len(children), 2) child, new_child = children if children[0].location == child.location else reversed(children) @@ -394,24 +395,25 @@ class TestLibraryContentAnalytics(LibraryContentTest): event_data = self._assert_event_was_published("assigned") for block_list in (event_data["added"], event_data["result"]): - self.assertEqual(len(block_list), 1) # The main_vertical is the only root block added, and is the only result. + self.assertEqual(len(block_list), 1) # main_vertical is the only root block added, and is the only result. self.assertEqual(block_list[0]["usage_key"], unicode(course_usage_main_vertical)) # Check that "descendants" is a flat, unordered list of all of main_vertical's descendants: - descendants_expected = {} - for lib_key, course_usage_key in ( + descendants_expected = ( (inner_vertical.location, course_usage_inner_vertical), (html_block.location, course_usage_html), (problem_block.location, course_usage_problem), - ): - descendants_expected[unicode(course_usage_key)] = { + ) + descendant_data_expected = {} + for lib_key, course_usage_key in descendants_expected: + descendant_data_expected[unicode(course_usage_key)] = { "usage_key": unicode(course_usage_key), "original_usage_key": unicode(lib_key), "original_usage_version": unicode(self.store.get_block_original_usage(course_usage_key)[1]), } - self.assertEqual(len(block_list[0]["descendants"]), len(descendants_expected)) + self.assertEqual(len(block_list[0]["descendants"]), len(descendant_data_expected)) for descendant in block_list[0]["descendants"]: - self.assertEqual(descendant, descendants_expected.get(descendant["usage_key"])) + self.assertEqual(descendant, descendant_data_expected.get(descendant["usage_key"])) def test_removed_overlimit(self): """ @@ -419,10 +421,11 @@ class TestLibraryContentAnalytics(LibraryContentTest): We go from one blocks assigned to none because max_count has been decreased. """ # Decrease max_count to 1, causing the block to be overlimit: - self.lc_block.get_child_descriptors() # We must call an XModule method before we can change max_count - otherwise the change has no effect + self.lc_block.get_child_descriptors() # This line is needed in the test environment or the change has no effect self.publisher.reset_mock() # Clear the "assigned" event that was just published. self.lc_block.max_count = 0 - del self.lc_block._xmodule._selected_set # Clear the cache (only needed because we skip saving/re-loading the block) pylint: disable=protected-access + # Clear the cache (only needed because we skip saving/re-loading the block) pylint: disable=protected-access + del self.lc_block._xmodule._selected_set # Check that the event says that one block was removed, leaving no blocks left: children = self.lc_block.get_child_descriptors() @@ -438,21 +441,24 @@ class TestLibraryContentAnalytics(LibraryContentTest): We go from two blocks assigned, to one because the others have been deleted from the library. """ # Start by assigning two blocks to the student: - self.lc_block.get_child_descriptors() # We must call an XModule method before we can change max_count - otherwise the change has no effect + self.lc_block.get_child_descriptors() # This line is needed in the test environment or the change has no effect self.lc_block.max_count = 2 - del self.lc_block._xmodule._selected_set # Clear the cache (only needed because we skip saving/re-loading the block) pylint: disable=protected-access + # Clear the cache (only needed because we skip saving/re-loading the block) pylint: disable=protected-access + del self.lc_block._xmodule._selected_set initial_blocks_assigned = self.lc_block.get_child_descriptors() self.assertEqual(len(initial_blocks_assigned), 2) self.publisher.reset_mock() # Clear the "assigned" event that was just published. # Now make sure that one of the assigned blocks will have to be un-assigned. - # To cause an "invalid" event, we delete all blocks from the content library except for one of the two already assigned to the student: + # To cause an "invalid" event, we delete all blocks from the content library + # except for one of the two already assigned to the student: keep_block_key = initial_blocks_assigned[0].location keep_block_lib_usage_key, keep_block_lib_version = self.store.get_block_original_usage(keep_block_key) deleted_block_key = initial_blocks_assigned[1].location self.library.children = [keep_block_lib_usage_key] self.store.update_item(self.library, self.user_id) self.lc_block.refresh_children() - del self.lc_block._xmodule._selected_set # Clear the cache (only needed because we skip saving/re-loading the block) pylint: disable=protected-access + # Clear the cache (only needed because we skip saving/re-loading the block) pylint: disable=protected-access + del self.lc_block._xmodule._selected_set # Check that the event says that one block was removed, leaving one block left: children = self.lc_block.get_child_descriptors() @@ -460,7 +466,8 @@ class TestLibraryContentAnalytics(LibraryContentTest): event_data = self._assert_event_was_published("removed") self.assertEqual(event_data["removed"], [{ "usage_key": unicode(deleted_block_key), - "original_usage_key": None, # Note: original_usage_key info is sadly unavailable because the block has been deleted so that info can no longer be retrieved + "original_usage_key": None, # Note: original_usage_key info is sadly unavailable because the block has been + # deleted so that info can no longer be retrieved "original_usage_version": None, "descendants": [], }]) diff --git a/lms/djangoapps/courseware/tests/test_module_render.py b/lms/djangoapps/courseware/tests/test_module_render.py index ca7c07f8a4..d6ce93563e 100644 --- a/lms/djangoapps/courseware/tests/test_module_render.py +++ b/lms/djangoapps/courseware/tests/test_module_render.py @@ -973,10 +973,10 @@ class TestModuleTrackingContext(ModuleStoreTestCase): def test_context_contains_display_name(self, mock_tracker): problem_display_name = u'Option Response Problem' - module_info = self.handle_callback_and_get_module_info_from_event(mock_tracker, problem_display_name) + module_info = self.handle_callback_and_get_module_info(mock_tracker, problem_display_name) self.assertEquals(problem_display_name, module_info['display_name']) - def handle_callback_and_get_module_info_from_event(self, mock_tracker, problem_display_name=None): + def handle_callback_and_get_module_info(self, mock_tracker, problem_display_name=None): """ Creates a fake module, invokes the callback and extracts the 'module' metadata from the emitted problem_check event. @@ -1006,7 +1006,7 @@ class TestModuleTrackingContext(ModuleStoreTestCase): return event['context']['module'] def test_missing_display_name(self, mock_tracker): - actual_display_name = self.handle_callback_and_get_module_info_from_event(mock_tracker)['display_name'] + actual_display_name = self.handle_callback_and_get_module_info(mock_tracker)['display_name'] self.assertTrue(actual_display_name.startswith('problem')) def test_library_source_information(self, mock_tracker): @@ -1017,8 +1017,9 @@ class TestModuleTrackingContext(ModuleStoreTestCase): """ original_usage_key = UsageKey.from_string(u'block-v1:A+B+C+type@problem+block@abcd1234') original_usage_version = ObjectId() - with patch('xmodule.modulestore.mixed.MixedModuleStore.get_block_original_usage', lambda _, key: (original_usage_key, original_usage_version)): - module_info = self.handle_callback_and_get_module_info_from_event(mock_tracker) + mock_get_original_usage = lambda _, key: (original_usage_key, original_usage_version) + with patch('xmodule.modulestore.mixed.MixedModuleStore.get_block_original_usage', mock_get_original_usage): + module_info = self.handle_callback_and_get_module_info(mock_tracker) self.assertIn('original_usage_key', module_info) self.assertEqual(module_info['original_usage_key'], unicode(original_usage_key)) self.assertIn('original_usage_version', module_info) From 05fc6738f6a47ebf73940b7a81221b6ee5521d24 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Mon, 12 Jan 2015 11:14:35 -0800 Subject: [PATCH 96/99] Add in "previous_count" and "max_count" --- .../xmodule/xmodule/library_content_module.py | 36 +++++++++---------- .../xmodule/tests/test_library_content.py | 4 +++ 2 files changed, 22 insertions(+), 18 deletions(-) diff --git a/common/lib/xmodule/xmodule/library_content_module.py b/common/lib/xmodule/xmodule/library_content_module.py index c1d5dc9fb5..4b21f66252 100644 --- a/common/lib/xmodule/xmodule/library_content_module.py +++ b/common/lib/xmodule/xmodule/library_content_module.py @@ -221,35 +221,39 @@ class LibraryContentModule(LibraryContentFields, XModule, StudioEditableModule): # Already done: return self._selected_set # pylint: disable=access-member-before-definition + selected = set(tuple(k) for k in self.selected) # set of (block_type, block_id) tuples assigned to this student + previous_count = len(selected) + lib_tools = self.runtime.service(self, 'library_tools') format_block_keys = lambda keys: lib_tools.create_block_analytics_summary(self.location.course_key, keys) + def publish_event(event_name, **kwargs): + """ Publish an event for analytics purposes """ + event_data = { + "location": unicode(self.location), + "result": format_block_keys(selected), + "previous_count": previous_count, + "max_count": self.max_count, + } + event_data.update(kwargs) + self.runtime.publish(self, "edx.librarycontentblock.content.{}".format(event_name), event_data) + # Determine which of our children we will show: - selected = set(tuple(k) for k in self.selected) # set of (block_type, block_id) tuples valid_block_keys = set([(c.block_type, c.block_id) for c in self.children]) # pylint: disable=no-member # Remove any selected blocks that are no longer valid: invalid_block_keys = (selected - valid_block_keys) if invalid_block_keys: selected -= invalid_block_keys # Publish an event for analytics purposes: - self.runtime.publish(self, "edx.librarycontentblock.content.removed", { - "location": unicode(self.location), - "removed": format_block_keys(invalid_block_keys), - "reason": "invalid", # Deleted from library or library being used has changed - "result": format_block_keys(selected), - }) + # reason "invalid" means deleted from library or a different library is now being used. + publish_event("removed", removed=format_block_keys(invalid_block_keys), reason="invalid") # If max_count has been decreased, we may have to drop some previously selected blocks: overlimit_block_keys = set() while len(selected) > self.max_count: overlimit_block_keys.add(selected.pop()) if overlimit_block_keys: # Publish an event for analytics purposes: - self.runtime.publish(self, "edx.librarycontentblock.content.removed", { - "location": unicode(self.location), - "removed": format_block_keys(overlimit_block_keys), - "reason": "overlimit", - "result": format_block_keys(selected), - }) + publish_event("removed", removed=format_block_keys(overlimit_block_keys), reason="overlimit") # Do we have enough blocks now? num_to_add = self.max_count - len(selected) if num_to_add > 0: @@ -265,11 +269,7 @@ class LibraryContentModule(LibraryContentFields, XModule, StudioEditableModule): selected |= added_block_keys if added_block_keys: # Publish an event for analytics purposes: - self.runtime.publish(self, "edx.librarycontentblock.content.assigned", { - "location": unicode(self.location), - "added": format_block_keys(added_block_keys), - "result": format_block_keys(selected), - }) + publish_event("assigned", added=format_block_keys(added_block_keys)) # Save our selections to the user state, to ensure consistency: self.selected = list(selected) # TODO: this doesn't save from the LMS "Progress" page. # Cache the results diff --git a/common/lib/xmodule/xmodule/tests/test_library_content.py b/common/lib/xmodule/xmodule/tests/test_library_content.py index c3bb661337..77835c04b1 100644 --- a/common/lib/xmodule/xmodule/tests/test_library_content.py +++ b/common/lib/xmodule/xmodule/tests/test_library_content.py @@ -351,6 +351,8 @@ class TestLibraryContentAnalytics(LibraryContentTest): "location": unicode(self.lc_block.location), "added": [block_info], "result": [block_info], + "previous_count": 0, + "max_count": 1, }) self.publisher.reset_mock() @@ -364,6 +366,8 @@ class TestLibraryContentAnalytics(LibraryContentTest): event_data = self._assert_event_was_published("assigned") self.assertEqual(event_data["added"][0]["usage_key"], unicode(new_child.location)) self.assertEqual(len(event_data["result"]), 2) + self.assertEqual(event_data["previous_count"], 1) + self.assertEqual(event_data["max_count"], 2) def test_assigned_descendants(self): """ From 6ea5a8a595d993795178ca6b9ad59af8edd1efd9 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Mon, 12 Jan 2015 13:08:40 -0800 Subject: [PATCH 97/99] Fix hard-coded studio name --- cms/templates/index.html | 4 ++-- cms/templates/widgets/header.html | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cms/templates/index.html b/cms/templates/index.html index 8bf79551c7..947aa86512 100644 --- a/cms/templates/index.html +++ b/cms/templates/index.html @@ -2,7 +2,7 @@ <%inherit file="base.html" /> <%def name="online_help_token()"><% return "home" %> -<%block name="title">${_("Studio Home")} +<%block name="title">${_("{studio_name} Home").format(studio_name=settings.STUDIO_SHORT_NAME)} <%block name="bodyclass">is-signedin index view-dashboard <%block name="requirejs"> @@ -14,7 +14,7 @@ <%block name="content">
    -

    ${_("Studio Home")}

    +

    ${_("{studio_name} Home").format(studio_name=settings.STUDIO_SHORT_NAME)}

    % if user.is_active:
    @@ -30,7 +30,7 @@ @@ -54,7 +54,7 @@
    - + Expand or Collapse Group A @@ -156,7 +156,7 @@
    - + Expand or Collapse Group B diff --git a/cms/templates/js/mock/mock-index-page.underscore b/cms/templates/js/mock/mock-index-page.underscore index 0dffc7d244..737234ff8d 100644 --- a/cms/templates/js/mock/mock-index-page.underscore +++ b/cms/templates/js/mock/mock-index-page.underscore @@ -9,7 +9,7 @@ New Course diff --git a/cms/templates/js/mock/mock-manage-users-lib.underscore b/cms/templates/js/mock/mock-manage-users-lib.underscore index d5b9ebf97b..fb0ea5e67d 100644 --- a/cms/templates/js/mock/mock-manage-users-lib.underscore +++ b/cms/templates/js/mock/mock-manage-users-lib.underscore @@ -9,7 +9,7 @@

    Page Actions

    @@ -72,7 +72,7 @@ Remove Staff Access
  • - Delete the user, honor + Delete the user, honor
  • @@ -103,7 +103,7 @@ Remove Admin Access
  • - Delete the user, audit + Delete the user, audit
  • @@ -134,7 +134,7 @@ Add Staff Access
  • - Delete the user, staff + Delete the user, staff
  • diff --git a/cms/templates/library.html b/cms/templates/library.html index c66c7bfa44..9a7d8fda9a 100644 --- a/cms/templates/library.html +++ b/cms/templates/library.html @@ -51,7 +51,7 @@ from django.utils.translation import ugettext as _ @@ -68,7 +68,7 @@ from django.utils.translation import ugettext as _
    -

    ${_("Loading")}

    +

    ${_("Loading")}

    diff --git a/cms/templates/widgets/header.html b/cms/templates/widgets/header.html index ca4570ced8..97b005a8ae 100644 --- a/cms/templates/widgets/header.html +++ b/cms/templates/widgets/header.html @@ -150,7 +150,7 @@
    diff --git a/common/test/acceptance/tests/studio/test_studio_library.py b/common/test/acceptance/tests/studio/test_studio_library.py index f7cb98ad03..c27c69e9bb 100644 --- a/common/test/acceptance/tests/studio/test_studio_library.py +++ b/common/test/acceptance/tests/studio/test_studio_library.py @@ -148,6 +148,27 @@ class LibraryEditPageTest(StudioLibraryTest): add_component(self.lib_page, "problem", "Multiple Choice") self.assertTrue(self.lib_page.nav_disabled(position)) + def test_delete_deletes_only_desired_block(self): + """ + Scenario: Ensure that when deleting XBlock only desired XBlock is deleted + Given that I have a library in Studio with no XBlocks + And I create Blank Common Problem XBlock + And I create Checkboxes XBlock + When I delete Blank Problem XBlock + Then Checkboxes XBlock is not deleted + And Blank Common Problem XBlock is deleted + """ + self.assertEqual(len(self.lib_page.xblocks), 0) + add_component(self.lib_page, "problem", "Blank Common Problem") + add_component(self.lib_page, "problem", "Checkboxes") + self.assertEqual(len(self.lib_page.xblocks), 2) + self.assertIn("Blank Common Problem", self.lib_page.xblocks[0].name) + self.assertIn("Checkboxes", self.lib_page.xblocks[1].name) + self.lib_page.click_delete_button(self.lib_page.xblocks[0].locator) + self.assertEqual(len(self.lib_page.xblocks), 1) + problem_block = self.lib_page.xblocks[0] + self.assertIn("Checkboxes", problem_block.name) + @ddt class LibraryNavigationTest(StudioLibraryTest): diff --git a/lms/templates/studio_render_paged_children_view.html b/lms/templates/studio_render_paged_children_view.html index fe5b5403e1..565cc40f66 100644 --- a/lms/templates/studio_render_paged_children_view.html +++ b/lms/templates/studio_render_paged_children_view.html @@ -12,9 +12,11 @@
    +
    % for item in items: ${item['content']} % endfor +
    % if can_add: