+%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)