".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/lms/library.py b/common/test/acceptance/pages/lms/library.py
new file mode 100644
index 0000000000..6978b5fa0b
--- /dev/null
+++ b/common/test/acceptance/pages/lms/library.py
@@ -0,0 +1,48 @@
+"""
+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):
+ """
+ Checks if page is opened
+ """
+ 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)
+
+ @property
+ def children_headers(self):
+ """
+ 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/pages/lms/login_and_register.py b/common/test/acceptance/pages/lms/login_and_register.py
index 3714179858..33d5779a01 100644
--- a/common/test/acceptance/pages/lms/login_and_register.py
+++ b/common/test/acceptance/pages/lms/login_and_register.py
@@ -246,6 +246,7 @@ class CombinedLoginAndRegisterPage(PageObject):
def wait_for_errors(self):
"""Wait for errors to be visible, then return them. """
def _check_func():
+ """Return success status and any errors that occurred."""
errors = self.errors
return (bool(errors), errors)
return Promise(_check_func, "Errors are visible").fulfill()
@@ -259,6 +260,7 @@ class CombinedLoginAndRegisterPage(PageObject):
def wait_for_success(self):
"""Wait for a success message to be visible, then return it."""
def _check_func():
+ """Return success status and any errors that occurred."""
success = self.success
return (bool(success), success)
return Promise(_check_func, "Success message is visible").fulfill()
diff --git a/common/test/acceptance/pages/studio/auto_auth.py b/common/test/acceptance/pages/studio/auto_auth.py
index e8beeaca5b..d3759b1136 100644
--- a/common/test/acceptance/pages/studio/auto_auth.py
+++ b/common/test/acceptance/pages/studio/auto_auth.py
@@ -15,7 +15,8 @@ class AutoAuthPage(PageObject):
this url will create a user and log them in.
"""
- def __init__(self, browser, username=None, email=None, password=None, staff=None, course_id=None, roles=None):
+ 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 +52,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 +70,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/container.py b/common/test/acceptance/pages/studio/container.py
index 14a28703fe..88a05f9e02 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):
@@ -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',
}
@@ -312,6 +313,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
@@ -336,6 +345,47 @@ 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
+ # pylint: disable=invalid-name
+ def has_validation_not_configured_warning(self):
+ """ Is a validation "not configured" message shown? """
+ return self._validation_paragraph('not-configured').present
+
+ @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
+ # pylint: disable=invalid-name
+ def validation_not_configured_warning_text(self):
+ """ Get the text of the validation "not configured" message. """
+ return self._validation_paragraph('not-configured').text[0]
+
@property
def preview_selector(self):
return self._bounded_selector('.xblock-student_view,.xblock-author_view')
@@ -365,6 +415,34 @@ 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.
+ """
+ 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..d6d6a25603 100644
--- a/common/test/acceptance/pages/studio/index.py
+++ b/common/test/acceptance/pages/studio/index.py
@@ -1,5 +1,5 @@
"""
-My Courses page in Studio
+Studio Home page
"""
from bok_choy.page_object import PageObject
@@ -8,7 +8,7 @@ from . import BASE_URL
class DashboardPage(PageObject):
"""
- My Courses page in Studio
+ Studio Home page
"""
url = BASE_URL + "/course/"
@@ -40,3 +40,69 @@ 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.
+ """
+ # Workaround Selenium/Firefox bug: `.text` property is broken on invisible elements
+ self.q(css='#course-index-tabs .libraries-tab a').click()
+ div2info = lambda element: {
+ 'name': element.find_element_by_css_selector('.course-title').text,
+ 'org': element.find_element_by_css_selector('.course-org .value').text,
+ '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..71df4c4ad1
--- /dev/null
+++ b/common/test/acceptance/pages/studio/library.py
@@ -0,0 +1,281 @@
+"""
+Library edit page in Studio
+"""
+
+from bok_choy.page_object import PageObject
+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
+
+
+class LibraryPage(PageObject, PaginatedMixin):
+ """
+ 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)
+ )
+
+
+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"
+ PROBLEM_TYPE_LABEL = "Problem Type"
+
+ 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()
+
+ @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
+ """
+ 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)
+
+ 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
+ """
+ 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/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/pages/studio/users.py b/common/test/acceptance/pages/studio/users.py
new file mode 100644
index 0000000000..5c0216f893
--- /dev/null
+++ b/common/test/acceptance/pages/studio/users.py
@@ -0,0 +1,191 @@
+"""
+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/pages/studio/utils.py b/common/test/acceptance/pages/studio/utils.py
index a94f50ba6f..df86d5deca 100644
--- a/common/test/acceptance/pages/studio/utils.py
+++ b/common/test/acceptance/pages/studio/utils.py
@@ -103,6 +103,33 @@ 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/lms/test_library.py b/common/test/acceptance/tests/lms/test_library.py
new file mode 100644
index 0000000000..cb4fd238de
--- /dev/null
+++ b/common/test/acceptance/tests/lms/test_library.py
@@ -0,0 +1,303 @@
+# -*- coding: utf-8 -*-
+"""
+End-to-end tests for LibraryContent block in LMS
+"""
+import ddt
+import textwrap
+
+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'
+
+
+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):
+ """
+ To be overwritten by subclassed tests. Used to install a library to
+ run tests on.
+ """
+
+ def setUp(self):
+ """
+ Set up library, course and library content XBlock
+ """
+ super(LibraryContentTestBase, 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.populate_library_fixture(self.library_fixture)
+
+ 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 _change_library_content_settings(self, count=1, capa_type=None):
+ """
+ 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
+ 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()
+ 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()
+ 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)
+ self.library_content_page.wait_for_page()
+
+ 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.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):
+ """
+ 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._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
+ 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._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 textwrap.dedent("""
+
+ {name}
+
+ {items}
+
+ """).format(name=name, items=items_text)
+
+ def _get_problem_select_text(self, name, items, correct):
+ """ Generates Select Option CAPA problem XML """
+ items_text = ",".join(["'{0}'".format(item) for item in items])
+
+ return textwrap.dedent("""
+
+ {name}
+
+
+
+ """).format(name=name, options=items_text, correct=correct)
+
+ def populate_library_fixture(self, library_fixture):
+ """
+ Populates library fixture with XBlock Fixtures
+ """
+ library_fixture.add_children(
+ XBlockFixtureDesc(
+ "problem", "Problem Choice Group 1",
+ data=self._get_problem_choice_group_text("Problem Choice Group 1 Text", [("1", False), ('2', True)])
+ ),
+ XBlockFixtureDesc(
+ "problem", "Problem Choice Group 2",
+ data=self._get_problem_choice_group_text("Problem Choice Group 2 Text", [("Q", True), ('W', False)])
+ ),
+ XBlockFixtureDesc(
+ "problem", "Problem Select 1",
+ data=self._get_problem_select_text("Problem Select 1 Text", ["Option 1", "Option 2"], "Option 1")
+ ),
+ XBlockFixtureDesc(
+ "problem", "Problem Select 2",
+ data=self._get_problem_select_text("Problem Select 2 Text", ["Option 3", "Option 4"], "Option 4")
+ ),
+ )
+
+ @property
+ def _problem_headers(self):
+ """ Expected XBLock headers according to populate_library_fixture """
+ return frozenset(child.display_name.upper() for child in self.library_fixture.children)
+
+ def _set_library_content_settings(self, count=1, capa_type="Any 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()
+ return self.library_content_page.children_headers
+
+ def test_problem_type_selector(self):
+ """
+ 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 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 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
+ """
+ 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="Multiple Choice")
+ self.assertEqual(len(children_headers), 1)
+ self.assertLessEqual(
+ children_headers,
+ set([header.upper() for header in ["Problem Choice Group 1", "Problem Choice Group 2"]])
+ )
+
+ # Choice group test
+ children_headers = self._set_library_content_settings(count=2, capa_type="Dropdown")
+ self.assertEqual(len(children_headers), 2)
+ self.assertEqual(
+ children_headers,
+ set([header.upper() for header in ["Problem Select 1", "Problem Select 2"]])
+ )
+
+ # Missing problem type test
+ children_headers = self._set_library_content_settings(count=2, capa_type="Custom Evaluated Script")
+ self.assertEqual(children_headers, set())
diff --git a/common/test/acceptance/tests/lms/test_lms.py b/common/test/acceptance/tests/lms/test_lms.py
index 94dd57d64a..cf3be2d193 100644
--- a/common/test/acceptance/tests/lms/test_lms.py
+++ b/common/test/acceptance/tests/lms/test_lms.py
@@ -119,7 +119,7 @@ class LoginFromCombinedPageTest(UniqueCourseTest):
def test_password_reset_success(self):
# Create a user account
- email, password = self._create_unique_user()
+ email, password = self._create_unique_user() # pylint: disable=unused-variable
# Navigate to the password reset form and try to submit it
self.login_page.visit().password_reset(email=email)
@@ -141,6 +141,9 @@ class LoginFromCombinedPageTest(UniqueCourseTest):
)
def _create_unique_user(self):
+ """
+ Create a new user with a unique name and email.
+ """
username = "test_{uuid}".format(uuid=self.unique_id[0:6])
email = "{user}@example.com".format(user=username)
password = "password"
diff --git a/common/test/acceptance/tests/studio/base_studio_test.py b/common/test/acceptance/tests/studio/base_studio_test.py
index fa07533fba..02fdcbe998 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,48 @@ 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.
+ """
+ as_staff = True
+
+ def setUp(self): # 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_fixture = fixture
+ self.library_info = fixture.library_info
+ self.library_key = fixture.library_key
+ self.user = fixture.user
+ self.log_in(self.user, self.as_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_general.py b/common/test/acceptance/tests/studio/test_studio_general.py
index 88dc314673..adaa46227d 100644
--- a/common/test/acceptance/tests/studio/test_studio_general.py
+++ b/common/test/acceptance/tests/studio/test_studio_general.py
@@ -88,6 +88,15 @@ class CoursePagesTest(StudioCourseTest):
]
]
+ def test_page_redirect(self):
+ """
+ /course/ is the base URL for all courses, but by itself, it should
+ redirect to /home/.
+ """
+ self.dashboard_page = DashboardPage(self.browser) # pylint: disable=attribute-defined-outside-init
+ self.dashboard_page.visit()
+ self.assertEqual(self.browser.current_url.strip('/').rsplit('/')[-1], 'home')
+
@skip('Intermittently failing with Page not found error for Assets. TE-418')
def test_page_existence(self):
"""
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..6c20df9cfc
--- /dev/null
+++ b/common/test/acceptance/tests/studio/test_studio_home.py
@@ -0,0 +1,57 @@
+"""
+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_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..c27c69e9bb
--- /dev/null
+++ b/common/test/acceptance/tests/studio/test_studio_library.py
@@ -0,0 +1,466 @@
+"""
+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.auto_auth import AutoAuthPage
+from ...pages.studio.utils import add_component
+from ...pages.studio.library import LibraryPage
+from ...pages.studio.users import LibraryUsersPage
+
+
+@ddt
+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()
+ 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)
+
+ 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'))
+
+ 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 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))
+ add_component(self.lib_page, "problem", "Multiple Choice")
+ self.assertTrue(self.lib_page.nav_disabled(position))
+
+ def test_delete_deletes_only_desired_block(self):
+ """
+ Scenario: Ensure that when deleting XBlock only desired XBlock is deleted
+ Given that I have a library in Studio with no XBlocks
+ And I create Blank Common Problem XBlock
+ And I create Checkboxes XBlock
+ When I delete Blank Problem XBlock
+ Then Checkboxes XBlock is not deleted
+ And Blank Common Problem XBlock is deleted
+ """
+ self.assertEqual(len(self.lib_page.xblocks), 0)
+ add_component(self.lib_page, "problem", "Blank Common Problem")
+ add_component(self.lib_page, "problem", "Checkboxes")
+ self.assertEqual(len(self.lib_page.xblocks), 2)
+ self.assertIn("Blank Common Problem", self.lib_page.xblocks[0].name)
+ self.assertIn("Checkboxes", self.lib_page.xblocks[1].name)
+ self.lib_page.click_delete_button(self.lib_page.xblocks[0].locator)
+ self.assertEqual(len(self.lib_page.xblocks), 1)
+ problem_block = self.lib_page.xblocks[0]
+ self.assertIn("Checkboxes", problem_block.name)
+
+
+@ddt
+class LibraryNavigationTest(StudioLibraryTest):
+ """
+ Test common Navigation actions
+ """
+ def setUp(self): # pylint: disable=arguments-differ
+ """
+ Ensure a library exists and navigate to the library edit page.
+ """
+ super(LibraryNavigationTest, self).setUp()
+ self.lib_page = LibraryPage(self.browser, self.library_key)
+ self.lib_page.visit()
+ self.lib_page.wait_until_ready()
+
+ 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 40 XBlocks
+ When I go to the 3rd page
+ The first XBlock should be the 21st XBlock
+ When I go to the 4th Page
+ The first XBlock should be the 31st XBlock
+ When I go to the 1st page
+ The first XBlock should be the 1st XBlock
+ When I go to the 2nd page
+ The first XBlock should be the 11th XBlock
+ """
+ self.lib_page.go_to_page(3)
+ self.assertEqual(self.lib_page.xblocks[0].name, '21')
+ self.lib_page.go_to_page(4)
+ self.assertEqual(self.lib_page.xblocks[0].name, '31')
+ self.lib_page.go_to_page(1)
+ self.assertEqual(self.lib_page.xblocks[0].name, '1')
+ self.lib_page.go_to_page(2)
+ 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 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
+ 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(self.lib_page.xblocks[0].name, '1')
+ self.lib_page.go_to_page('a')
+ self.assertTrue(self.lib_page.check_page_unchanged('1'))
+ self.lib_page.go_to_page(-1)
+ self.assertTrue(self.lib_page.check_page_unchanged('1'))
+ self.lib_page.go_to_page(5)
+ self.assertTrue(self.lib_page.check_page_unchanged('1'))
+ self.lib_page.go_to_page(0)
+ 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')
+
+
+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 your 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)
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..175ee08539
--- /dev/null
+++ b/common/test/acceptance/tests/studio/test_studio_library_container.py
@@ -0,0 +1,304 @@
+"""
+Acceptance tests for Library Content in LMS
+"""
+import textwrap
+import ddt
+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'
+SUBSECTION_NAME = 'Test Subsection'
+UNIT_NAME = 'Test Unit'
+
+
+@ddt.ddt
+class StudioLibraryContainerTest(StudioLibraryTest, UniqueCourseTest):
+ """
+ Test Library Content block in LMS
+ """
+ def setUp(self):
+ """
+ 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()
+
+ 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 = '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 - 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.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)
+
+ 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.assertFalse(library_container.has_validation_error)
+
+ edit_modal = StudioLibraryContentXBlockEditModal(library_container.edit())
+ edit_modal.library_key = nonexistent_lib_key
+
+ library_container.save_settings()
+
+ 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 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_block = self._get_library_xblock_wrapper(self.unit_page.xblocks[0])
+
+ self.assertFalse(library_block.has_validation_warning)
+ # Removed this assert until a summary message is added back to the author view (SOL-192)
+ #self.assertIn("3 matching components", library_block.author_content)
+
+ self.library_fixture.create_xblock(self.library_fixture.library_location, XBlockFixtureDesc("html", "Html4"))
+
+ 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_block = self._get_library_xblock_wrapper(self.unit_page.xblocks[0])
+
+ self.assertFalse(library_block.has_validation_message)
+ # Removed this assert until a summary message is added back to the author view (SOL-192)
+ #self.assertIn("4 matching components", library_block.author_content)
+
+ def test_no_content_message(self):
+ """
+ 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
+ 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])
+
+ # 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)
+
+ 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 = "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])
+
+ # 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
+ )
+
+ 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)
diff --git a/docs/shared/conf.py b/docs/shared/conf.py
index bc3f65c90f..d8a2681882 100644
--- a/docs/shared/conf.py
+++ b/docs/shared/conf.py
@@ -22,7 +22,6 @@
# -----------------------------------------------------------------------------
import os
-import sys
BASEDIR = os.path.dirname(os.path.abspath(__file__))
diff --git a/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/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py
index 07c4d15d38..2f84fcb155 100644
--- a/lms/djangoapps/courseware/module_render.py
+++ b/lms/djangoapps/courseware/module_render.py
@@ -755,6 +755,7 @@ def _invoke_xblock_handler(request, course_id, usage_id, handler, suffix, user):
try:
descriptor = modulestore().get_item(usage_key)
+ descriptor_orig_usage_key, descriptor_orig_version = modulestore().get_block_original_usage(usage_key)
except ItemNotFoundError:
log.warn(
"Invalid location for course id {course_id}: {usage_key}".format(
@@ -768,8 +769,13 @@ def _invoke_xblock_handler(request, course_id, usage_id, handler, suffix, user):
tracking_context = {
'module': {
'display_name': descriptor.display_name_with_default,
+ 'usage_key': unicode(descriptor.location),
}
}
+ # For blocks that are inherited from a content library, we add some additional metadata:
+ if descriptor_orig_usage_key is not None:
+ tracking_context['module']['original_usage_key'] = unicode(descriptor_orig_usage_key)
+ tracking_context['module']['original_usage_version'] = unicode(descriptor_orig_version)
field_data_cache = FieldDataCache.cache_for_descriptor_descendents(
course_id,
diff --git a/lms/djangoapps/courseware/tests/test_module_render.py b/lms/djangoapps/courseware/tests/test_module_render.py
index 7e17441142..d6ce93563e 100644
--- a/lms/djangoapps/courseware/tests/test_module_render.py
+++ b/lms/djangoapps/courseware/tests/test_module_render.py
@@ -5,6 +5,7 @@ Test for lms courseware app, module render unit
from functools import partial
import json
+from bson import ObjectId
import ddt
from django.http import Http404, HttpResponse
from django.core.urlresolvers import reverse
@@ -13,6 +14,7 @@ from django.test.client import RequestFactory
from django.test.utils import override_settings
from django.contrib.auth.models import AnonymousUser
from mock import MagicMock, patch, Mock
+from opaque_keys.edx.keys import UsageKey
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from xblock.field_data import FieldData
from xblock.runtime import Runtime
@@ -971,12 +973,13 @@ class TestModuleTrackingContext(ModuleStoreTestCase):
def test_context_contains_display_name(self, mock_tracker):
problem_display_name = u'Option Response Problem'
- actual_display_name = self.handle_callback_and_get_display_name_from_event(mock_tracker, problem_display_name)
- self.assertEquals(problem_display_name, actual_display_name)
+ module_info = self.handle_callback_and_get_module_info(mock_tracker, problem_display_name)
+ self.assertEquals(problem_display_name, module_info['display_name'])
- def handle_callback_and_get_display_name_from_event(self, mock_tracker, problem_display_name=None):
+ def handle_callback_and_get_module_info(self, mock_tracker, problem_display_name=None):
"""
- Creates a fake module, invokes the callback and extracts the display name from the emitted problem_check event.
+ Creates a fake module, invokes the callback and extracts the 'module'
+ metadata from the emitted problem_check event.
"""
descriptor_kwargs = {
'category': 'problem',
@@ -1000,12 +1003,28 @@ class TestModuleTrackingContext(ModuleStoreTestCase):
event = mock_call[1][0]
self.assertEquals(event['event_type'], 'problem_check')
- return event['context']['module']['display_name']
+ return event['context']['module']
def test_missing_display_name(self, mock_tracker):
- actual_display_name = self.handle_callback_and_get_display_name_from_event(mock_tracker)
+ actual_display_name = self.handle_callback_and_get_module_info(mock_tracker)['display_name']
self.assertTrue(actual_display_name.startswith('problem'))
+ def test_library_source_information(self, mock_tracker):
+ """
+ Check that XBlocks that are inherited from a library include the
+ information about their library block source in events.
+ We patch the modulestore to avoid having to create a library.
+ """
+ original_usage_key = UsageKey.from_string(u'block-v1:A+B+C+type@problem+block@abcd1234')
+ original_usage_version = ObjectId()
+ mock_get_original_usage = lambda _, key: (original_usage_key, original_usage_version)
+ with patch('xmodule.modulestore.mixed.MixedModuleStore.get_block_original_usage', mock_get_original_usage):
+ module_info = self.handle_callback_and_get_module_info(mock_tracker)
+ self.assertIn('original_usage_key', module_info)
+ self.assertEqual(module_info['original_usage_key'], unicode(original_usage_key))
+ self.assertIn('original_usage_version', module_info)
+ self.assertEqual(module_info['original_usage_version'], unicode(original_usage_version))
+
class TestXmoduleRuntimeEvent(TestSubmittingProblems):
"""
diff --git a/lms/djangoapps/lms_xblock/runtime.py b/lms/djangoapps/lms_xblock/runtime.py
index 49d0abbf38..7b5c3fe7b9 100644
--- a/lms/djangoapps/lms_xblock/runtime.py
+++ b/lms/djangoapps/lms_xblock/runtime.py
@@ -10,6 +10,7 @@ from django.conf import settings
from lms.djangoapps.lms_xblock.models import XBlockAsidesConfig
from openedx.core.djangoapps.user_api.api import course_tag as user_course_tag_api
from xmodule.modulestore.django import modulestore
+from xmodule.library_tools import LibraryToolsService
from xmodule.x_module import ModuleSystem
from xmodule.partitions.partitions_service import PartitionService
@@ -199,6 +200,7 @@ class LmsModuleSystem(LmsHandlerUrls, ModuleSystem): # pylint: disable=abstract
course_id=kwargs.get('course_id'),
track_function=kwargs.get('track_function', None),
)
+ services['library_tools'] = LibraryToolsService(modulestore())
services['fs'] = xblock.reference.plugins.FSService()
self.request_token = kwargs.pop('request_token', None)
super(LmsModuleSystem, self).__init__(**kwargs)
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..b4de62d8a0
--- /dev/null
+++ b/lms/templates/library-block-author-preview-header.html
@@ -0,0 +1,14 @@
+<%! from django.utils.translation import ungettext %>
+
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
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..565cc40f66
--- /dev/null
+++ b/lms/templates/studio_render_paged_children_view.html
@@ -0,0 +1,25 @@
+<%! 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
+
+
diff --git a/pavelib/paver_tests/test_prereqs.py b/pavelib/paver_tests/test_prereqs.py
index e4586d1843..47437ee907 100644
--- a/pavelib/paver_tests/test_prereqs.py
+++ b/pavelib/paver_tests/test_prereqs.py
@@ -1,12 +1,21 @@
-
import os
import unittest
from pavelib.prereqs import no_prereq_install
class TestPaverPrereqInstall(unittest.TestCase):
-
+ """
+ Test the status of the NO_PREREQ_INSTALL variable, its presence and how
+ paver handles it.
+ """
def check_val(self, set_val, expected_val):
+ """
+ Verify that setting the variable to a certain value returns
+ the expected boolean for it.
+
+ As environment variables are only stored as strings, we have to cast
+ whatever it's set at to a boolean that does not violate expectations.
+ """
_orig_environ = dict(os.environ)
os.environ['NO_PREREQ_INSTALL'] = set_val
self.assertEqual(
@@ -21,19 +30,37 @@ class TestPaverPrereqInstall(unittest.TestCase):
os.environ.update(_orig_environ)
def test_no_prereq_install_true(self):
+ """
+ Ensure that 'true' will be True.
+ """
self.check_val('true', True)
def test_no_prereq_install_false(self):
+ """
+ Ensure that 'false' will be False.
+ """
self.check_val('false', False)
def test_no_prereq_install_True(self):
+ """
+ Ensure that 'True' will be True.
+ """
self.check_val('True', True)
def test_no_prereq_install_False(self):
+ """
+ Ensure that 'False' will be False.
+ """
self.check_val('False', False)
def test_no_prereq_install_0(self):
+ """
+ Ensure that '0' will be False.
+ """
self.check_val('0', False)
def test_no_prereq_install_1(self):
+ """
+ Ensure that '1' will be True.
+ """
self.check_val('1', True)
diff --git a/pavelib/prereqs.py b/pavelib/prereqs.py
index 0a334a8152..220a5387c4 100644
--- a/pavelib/prereqs.py
+++ b/pavelib/prereqs.py
@@ -41,7 +41,7 @@ def no_prereq_install():
try:
return vals[val]
- except:
+ except KeyError:
return False