393 lines
13 KiB
Python
393 lines
13 KiB
Python
"""
|
|
Course Outline page in Studio.
|
|
"""
|
|
|
|
|
|
from bok_choy.javascript import js_defined # lint-amnesty, pylint: disable=unused-import
|
|
from bok_choy.page_object import PageObject
|
|
from bok_choy.promise import EmptyPromise
|
|
from selenium.webdriver.support.ui import Select
|
|
|
|
from common.test.acceptance.pages.studio.container import ContainerPage
|
|
from common.test.acceptance.pages.studio.course_page import CoursePage
|
|
|
|
|
|
@js_defined('jQuery')
|
|
class CourseOutlineItem:
|
|
"""
|
|
A mixin class for any :class:`PageObject` shown in a course outline.
|
|
"""
|
|
# Note there are a few pylint disable=no-member occurances in this class, because
|
|
# it was written assuming it is going to be a mixin to a PageObject and will have functions
|
|
# such as self.wait_for_ajax, which doesn't exist on a generic `object`.
|
|
BODY_SELECTOR = None
|
|
EDIT_BUTTON_SELECTOR = '.xblock-field-value-edit'
|
|
NAME_SELECTOR = '.item-title'
|
|
NAME_INPUT_SELECTOR = '.xblock-field-input'
|
|
NAME_FIELD_WRAPPER_SELECTOR = '.xblock-title .wrapper-xblock-field'
|
|
STATUS_MESSAGE_SELECTOR = '> div[class$="-status"] .status-messages'
|
|
CONFIGURATION_BUTTON_SELECTOR = '.action-item .configure-button'
|
|
|
|
def __repr__(self):
|
|
# CourseOutlineItem is also used as a mixin for CourseOutlinePage, which doesn't have a locator
|
|
# Check for the existence of a locator so that errors when navigating to the course outline page don't show up
|
|
# as errors in the repr method instead.
|
|
try:
|
|
return f"{self.__class__.__name__}(<browser>, {self.locator!r})"
|
|
except AttributeError:
|
|
return f"{self.__class__.__name__}(<browser>)"
|
|
|
|
def _bounded_selector(self, selector):
|
|
"""
|
|
Returns `selector`, but limited to this particular `CourseOutlineItem` context
|
|
"""
|
|
# If the item doesn't have a body selector or locator, then it can't be bounded
|
|
# This happens in the context of the CourseOutlinePage
|
|
# pylint: disable=no-member
|
|
if self.BODY_SELECTOR and hasattr(self, 'locator'):
|
|
return '{}[data-locator="{}"] {}'.format(
|
|
self.BODY_SELECTOR,
|
|
self.locator,
|
|
selector
|
|
)
|
|
else:
|
|
return selector
|
|
|
|
def edit(self):
|
|
"""
|
|
Puts the item into editable form.
|
|
"""
|
|
self.q(css=self._bounded_selector(self.CONFIGURATION_BUTTON_SELECTOR)).first.click() # pylint: disable=no-member
|
|
if 'subsection' in self.BODY_SELECTOR: # lint-amnesty, pylint: disable=unsupported-membership-test
|
|
modal = SubsectionOutlineModal(self)
|
|
else:
|
|
modal = CourseOutlineModal(self)
|
|
EmptyPromise(lambda: modal.is_shown(), 'Modal is shown.') # pylint: disable=unnecessary-lambda
|
|
return modal
|
|
|
|
|
|
class CourseOutlineContainer(CourseOutlineItem):
|
|
"""
|
|
A mixin to a CourseOutline page object that adds the ability to load
|
|
a child page object by title or by index.
|
|
|
|
CHILD_CLASS must be a :class:`CourseOutlineChild` subclass.
|
|
"""
|
|
CHILD_CLASS = None
|
|
ADD_BUTTON_SELECTOR = '> .outline-content > .add-item a.button-new'
|
|
|
|
def children(self, child_class=None):
|
|
"""
|
|
Returns all the children page objects of class child_class.
|
|
"""
|
|
if not child_class:
|
|
child_class = self.CHILD_CLASS
|
|
# pylint: disable=no-member
|
|
return self.q(css=self._bounded_selector(child_class.BODY_SELECTOR)).map(
|
|
lambda el: child_class(self.browser, el.get_attribute('data-locator'))).results
|
|
|
|
def child_at(self, index, child_class=None):
|
|
"""
|
|
Returns the child at the specified index.
|
|
:type self: object
|
|
"""
|
|
if not child_class:
|
|
child_class = self.CHILD_CLASS
|
|
|
|
return self.children(child_class)[index]
|
|
|
|
|
|
class CourseOutlineChild(PageObject, CourseOutlineItem):
|
|
"""
|
|
A page object that will be used as a child of :class:`CourseOutlineContainer`.
|
|
"""
|
|
url = None
|
|
BODY_SELECTOR = '.outline-item'
|
|
|
|
def __init__(self, browser, locator):
|
|
super().__init__(browser)
|
|
self.locator = locator
|
|
|
|
def is_browser_on_page(self):
|
|
return self.q(css=f'{self.BODY_SELECTOR}[data-locator="{self.locator}"]').present
|
|
|
|
def _bounded_selector(self, selector):
|
|
"""
|
|
Return `selector`, but limited to this particular `CourseOutlineChild` context
|
|
"""
|
|
return '{}[data-locator="{}"] {}'.format(
|
|
self.BODY_SELECTOR,
|
|
self.locator,
|
|
selector
|
|
)
|
|
|
|
|
|
class CourseOutlineUnit(CourseOutlineChild):
|
|
"""
|
|
PageObject that wraps a unit link on the Studio Course Outline page.
|
|
"""
|
|
url = None
|
|
BODY_SELECTOR = '.outline-unit'
|
|
NAME_SELECTOR = '.unit-title a'
|
|
|
|
def go_to(self):
|
|
"""
|
|
Open the container page linked to by this unit link, and return
|
|
an initialized :class:`.ContainerPage` for that unit.
|
|
"""
|
|
return ContainerPage(self.browser, self.locator).visit()
|
|
|
|
def is_browser_on_page(self):
|
|
return self.q(css=self.BODY_SELECTOR).present
|
|
|
|
def children(self):
|
|
return self.q(css=self._bounded_selector(self.BODY_SELECTOR)).map(
|
|
lambda el: CourseOutlineUnit(self.browser, el.get_attribute('data-locator'))).results
|
|
|
|
|
|
class CourseOutlineSubsection(CourseOutlineContainer, CourseOutlineChild):
|
|
"""
|
|
:class`.PageObject` that wraps a subsection block on the Studio Course Outline page.
|
|
"""
|
|
url = None
|
|
|
|
BODY_SELECTOR = '.outline-subsection'
|
|
NAME_SELECTOR = '.subsection-title'
|
|
NAME_FIELD_WRAPPER_SELECTOR = '.subsection-header .wrapper-xblock-field'
|
|
CHILD_CLASS = CourseOutlineUnit
|
|
|
|
def unit(self, title):
|
|
"""
|
|
Return the :class:`.CourseOutlineUnit with the title `title`.
|
|
"""
|
|
return self.child(title) # lint-amnesty, pylint: disable=no-member
|
|
|
|
def units(self):
|
|
"""
|
|
Returns the units in this subsection.
|
|
"""
|
|
return self.children()
|
|
|
|
def unit_at(self, index):
|
|
"""
|
|
Returns the CourseOutlineUnit at the specified index.
|
|
"""
|
|
return self.child_at(index)
|
|
|
|
def add_unit(self):
|
|
"""
|
|
Adds a unit to this subsection
|
|
"""
|
|
self.q(css=self._bounded_selector(self.ADD_BUTTON_SELECTOR)).click()
|
|
|
|
|
|
class CourseOutlineSection(CourseOutlineContainer, CourseOutlineChild):
|
|
"""
|
|
:class`.PageObject` that wraps a section block on the Studio Course Outline page.
|
|
"""
|
|
url = None
|
|
BODY_SELECTOR = '.outline-section'
|
|
NAME_SELECTOR = '.section-title'
|
|
NAME_FIELD_WRAPPER_SELECTOR = '.section-header .wrapper-xblock-field'
|
|
CHILD_CLASS = CourseOutlineSubsection
|
|
|
|
def subsection(self, title):
|
|
"""
|
|
Return the :class:`.CourseOutlineSubsection` with the title `title`.
|
|
"""
|
|
return self.child(title) # lint-amnesty, pylint: disable=no-member
|
|
|
|
def subsections(self):
|
|
"""
|
|
Returns a list of the CourseOutlineSubsections of this section
|
|
"""
|
|
return self.children()
|
|
|
|
def subsection_at(self, index):
|
|
"""
|
|
Returns the CourseOutlineSubsection at the specified index.
|
|
"""
|
|
return self.child_at(index)
|
|
|
|
def add_subsection(self):
|
|
"""
|
|
Adds a subsection to this section
|
|
"""
|
|
self.add_child() # lint-amnesty, pylint: disable=no-member
|
|
|
|
|
|
class ExpandCollapseLinkState:
|
|
"""
|
|
Represents the three states that the expand/collapse link can be in
|
|
"""
|
|
MISSING = 0
|
|
COLLAPSE = 1
|
|
EXPAND = 2
|
|
|
|
|
|
class CourseOutlinePage(CoursePage, CourseOutlineContainer):
|
|
"""
|
|
Course Outline page in Studio.
|
|
"""
|
|
url_path = "course"
|
|
CHILD_CLASS = CourseOutlineSection
|
|
EXPAND_COLLAPSE_CSS = '.button-toggle-expand-collapse'
|
|
BOTTOM_ADD_SECTION_BUTTON = '.outline > .add-section .button-new'
|
|
|
|
def is_browser_on_page(self):
|
|
return all([
|
|
self.q(css='body.view-outline').present,
|
|
self.q(css='.content-primary').present,
|
|
self.q(css='div.ui-loading.is-hidden').present
|
|
])
|
|
|
|
def section_at(self, index):
|
|
"""
|
|
Returns the :class:`.CourseOutlineSection` at the specified index.
|
|
"""
|
|
return self.child_at(index)
|
|
|
|
def start_reindex(self):
|
|
"""
|
|
Starts course reindex by clicking reindex button
|
|
"""
|
|
self.reindex_button.click() # lint-amnesty, pylint: disable=no-member
|
|
|
|
def open_subsection_settings_dialog(self, index=0):
|
|
"""
|
|
clicks on the settings button of subsection.
|
|
"""
|
|
self.q(css=".subsection-header-actions .configure-button").nth(index).click()
|
|
self.wait_for_element_presence('.course-outline-modal', 'Subsection settings modal is present.')
|
|
|
|
def select_advanced_tab(self, desired_item='special_exam'):
|
|
"""
|
|
Select the advanced settings tab
|
|
"""
|
|
self.q(css=".settings-tab-button[data-tab='advanced']").first.click()
|
|
if desired_item == 'special_exam':
|
|
self.wait_for_element_presence('input.no_special_exam', 'Special exam settings fields not present.')
|
|
if desired_item == 'gated_content':
|
|
self.wait_for_element_visibility('#is_prereq', 'Gating settings fields are present.')
|
|
|
|
|
|
class CourseOutlineModal:
|
|
"""
|
|
Page object specifically for a modal window on the course outline page.
|
|
|
|
Subsections are handled slightly differently in some regards, and should use SubsectionOutlineModal.
|
|
"""
|
|
MODAL_SELECTOR = ".wrapper-modal-window"
|
|
|
|
def __init__(self, page):
|
|
self.page = page
|
|
|
|
def _bounded_selector(self, selector):
|
|
"""
|
|
Returns `selector`, but limited to this particular `CourseOutlineModal` context.
|
|
"""
|
|
return " ".join([self.MODAL_SELECTOR, selector])
|
|
|
|
def is_shown(self):
|
|
"""
|
|
Return whether or not the modal defined by self.MODAL_SELECTOR is shown.
|
|
"""
|
|
return self.page.q(css=self.MODAL_SELECTOR).present
|
|
|
|
def find_css(self, selector):
|
|
"""
|
|
Find the given css selector on the page.
|
|
"""
|
|
return self.page.q(css=self._bounded_selector(selector))
|
|
|
|
def click(self, selector, index=0):
|
|
"""
|
|
Perform a Click action on the given selector.
|
|
"""
|
|
self.find_css(selector).nth(index).click()
|
|
|
|
def save(self):
|
|
"""
|
|
Click the save action button, and wait for the ajax call to return.
|
|
"""
|
|
self.click(".action-save")
|
|
self.page.wait_for_ajax()
|
|
|
|
@property
|
|
def policy(self):
|
|
"""
|
|
Select the grading format with `value` in the drop-down list.
|
|
"""
|
|
element = self.find_css('#grading_type')[0]
|
|
return self.get_selected_option_text(element)
|
|
|
|
@policy.setter
|
|
def policy(self, grading_label):
|
|
"""
|
|
Select the grading format with `value` in the drop-down list.
|
|
"""
|
|
element = self.find_css('#grading_type')[0]
|
|
select = Select(element)
|
|
select.select_by_visible_text(grading_label)
|
|
|
|
EmptyPromise(
|
|
lambda: self.policy == grading_label,
|
|
"Grading label is updated.",
|
|
).fulfill()
|
|
|
|
def get_selected_option_text(self, element):
|
|
"""
|
|
Returns the text of the first selected option for the element.
|
|
"""
|
|
if element:
|
|
select = Select(element)
|
|
return select.first_selected_option.text
|
|
else:
|
|
return None
|
|
|
|
|
|
class SubsectionOutlineModal(CourseOutlineModal):
|
|
"""
|
|
Subclass to handle a few special cases with subsection modals.
|
|
"""
|
|
|
|
@property
|
|
def is_explicitly_locked(self):
|
|
"""
|
|
Override - returns True if staff_only is set.
|
|
"""
|
|
return self.subsection_visibility == 'staff_only'
|
|
|
|
@property
|
|
def subsection_visibility(self):
|
|
"""
|
|
Returns the current visibility setting for a subsection
|
|
"""
|
|
self.ensure_staff_lock_visible() # lint-amnesty, pylint: disable=no-member
|
|
return self.find_css('input[name=content-visibility]:checked').first.attrs('value')[0]
|
|
|
|
@is_explicitly_locked.setter
|
|
def is_explicitly_locked(self, value):
|
|
"""
|
|
Override - sets visibility to staff_only if True, else 'visible'.
|
|
|
|
For hide_after_due, use the set_subsection_visibility method directly.
|
|
"""
|
|
self.subsection_visibility = 'staff_only' if value else 'visible'
|
|
|
|
@subsection_visibility.setter
|
|
def subsection_visibility(self, value):
|
|
"""
|
|
Sets the subsection visibility to the given value.
|
|
"""
|
|
self.ensure_staff_lock_visible() # lint-amnesty, pylint: disable=no-member
|
|
self.find_css('input[name=content-visibility][value=' + value + ']').click()
|
|
EmptyPromise(lambda: value == self.subsection_visibility, "Subsection visibility is updated").fulfill()
|
|
|
|
@property
|
|
def is_staff_lock_visible(self):
|
|
"""
|
|
Override - Returns true if the staff lock option is visible.
|
|
"""
|
|
return self.find_css('input[name=content-visibility]').visible
|