+%block>
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 '
+
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
+ }
);
});
%block>
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 @@
-
-
-
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))}
${_('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))}
${_('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))}
${_('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 @@
+
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 """
+
+
+
+
+""".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 """
-
-
- 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}>{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}>{problem_type}>".format(problem_type=problem_type)
+ problem = ""
+ for problem_type in args:
+ problem += "<{problem_type}>{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">