This will remove imports from __future__ that are no longer needed. https://docs.python.org/3.5/library/2to3.html#2to3fixer-future
1209 lines
40 KiB
Python
1209 lines
40 KiB
Python
"""
|
|
Course Outline page in Studio.
|
|
"""
|
|
|
|
|
|
import datetime
|
|
|
|
from bok_choy.javascript import js_defined, wait_for_js
|
|
from bok_choy.page_object import PageObject
|
|
from bok_choy.promise import EmptyPromise
|
|
from selenium.webdriver import ActionChains
|
|
from selenium.webdriver.common.keys import Keys
|
|
from selenium.webdriver.support.ui import Select
|
|
from six.moves import map, range
|
|
|
|
from common.test.acceptance.pages.common.utils import click_css, confirm_prompt
|
|
from common.test.acceptance.pages.studio.container import ContainerPage
|
|
from common.test.acceptance.pages.studio.course_page import CoursePage
|
|
from common.test.acceptance.pages.studio.utils import set_input_value, set_input_value_and_save
|
|
from common.test.acceptance.tests.helpers import disable_animations, enable_animations, select_option_by_text
|
|
|
|
|
|
@js_defined('jQuery')
|
|
class CourseOutlineItem(object):
|
|
"""
|
|
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 u"{}(<browser>, {!r})".format(self.__class__.__name__, self.locator)
|
|
except AttributeError:
|
|
return u"{}(<browser>)".format(self.__class__.__name__)
|
|
|
|
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 u'{}[data-locator="{}"] {}'.format(
|
|
self.BODY_SELECTOR,
|
|
self.locator,
|
|
selector
|
|
)
|
|
else:
|
|
return selector
|
|
|
|
@property
|
|
def name(self):
|
|
"""
|
|
Returns the display name of this object.
|
|
"""
|
|
name_element = self.q(css=self._bounded_selector(self.NAME_SELECTOR)).first # pylint: disable=no-member
|
|
if name_element:
|
|
return name_element.text[0]
|
|
else:
|
|
return None
|
|
|
|
@property
|
|
def has_status_message(self):
|
|
"""
|
|
Returns True if the item has a status message, False otherwise.
|
|
"""
|
|
return self.q(css=self._bounded_selector(self.STATUS_MESSAGE_SELECTOR)).first.visible # pylint: disable=no-member
|
|
|
|
@property
|
|
def status_message(self):
|
|
"""
|
|
Returns the status message of this item.
|
|
"""
|
|
selector = self._bounded_selector(self.STATUS_MESSAGE_SELECTOR)
|
|
return self.q(css=selector).text[0] # pylint: disable=no-member
|
|
|
|
@property
|
|
def has_staff_lock_warning(self):
|
|
""" Returns True if the 'Contains staff only content' message is visible """
|
|
return self.status_message == 'Contains staff only content' if self.has_status_message else False
|
|
|
|
@property
|
|
def has_restricted_warning(self):
|
|
""" Returns True if the 'Access to this unit is restricted to' message is visible """
|
|
return 'Access to this unit is restricted to' in self.status_message if self.has_status_message else False
|
|
|
|
@property
|
|
def is_staff_only(self):
|
|
""" Returns True if the visiblity state of this item is staff only (has a black sidebar) """
|
|
return "is-staff-only" in self.q(css=self._bounded_selector(''))[0].get_attribute("class") # pylint: disable=no-member
|
|
|
|
def edit_name(self):
|
|
"""
|
|
Puts the item's name into editable form.
|
|
"""
|
|
self.q(css=self._bounded_selector(self.EDIT_BUTTON_SELECTOR)).first.click() # pylint: disable=no-member
|
|
|
|
def enter_name(self, new_name):
|
|
"""
|
|
Enters new_name as the item's display name.
|
|
"""
|
|
set_input_value(self, self._bounded_selector(self.NAME_INPUT_SELECTOR), new_name)
|
|
|
|
def change_name(self, new_name):
|
|
"""
|
|
Changes the container's name.
|
|
"""
|
|
self.edit_name()
|
|
set_input_value_and_save(self, self._bounded_selector(self.NAME_INPUT_SELECTOR), new_name)
|
|
self.wait_for_ajax() # pylint: disable=no-member
|
|
|
|
def finalize_name(self):
|
|
"""
|
|
Presses ENTER, saving the value of the display name for this item.
|
|
"""
|
|
# pylint: disable=no-member
|
|
self.q(css=self._bounded_selector(self.NAME_INPUT_SELECTOR)).results[0].send_keys(Keys.ENTER)
|
|
self.wait_for_ajax()
|
|
|
|
def set_staff_lock(self, is_locked):
|
|
"""
|
|
Sets the explicit staff lock of item on the container page to is_locked.
|
|
"""
|
|
modal = self.edit()
|
|
modal.is_explicitly_locked = is_locked
|
|
modal.save()
|
|
|
|
def get_enrollment_select_options(self):
|
|
"""
|
|
Gets the option names available for unit group access
|
|
"""
|
|
modal = self.edit()
|
|
group_options = self.q(css='.group-select-title option').text
|
|
modal.cancel()
|
|
return group_options
|
|
|
|
def toggle_unit_access(self, partition_name, group_ids):
|
|
"""
|
|
Toggles unit access to the groups in group_ids
|
|
"""
|
|
if group_ids:
|
|
modal = self.edit()
|
|
groups_select = self.q(css='.group-select-title select')
|
|
select_option_by_text(groups_select, partition_name)
|
|
|
|
for group_id in group_ids:
|
|
checkbox = self.q(css=u'#content-group-{group_id}'.format(group_id=group_id))
|
|
checkbox.click()
|
|
modal.save()
|
|
|
|
def in_editable_form(self):
|
|
"""
|
|
Return whether this outline item's display name is in its editable form.
|
|
"""
|
|
# pylint: disable=no-member
|
|
return "is-editing" in self.q(
|
|
css=self._bounded_selector(self.NAME_FIELD_WRAPPER_SELECTOR)
|
|
)[0].get_attribute("class")
|
|
|
|
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:
|
|
modal = SubsectionOutlineModal(self)
|
|
else:
|
|
modal = CourseOutlineModal(self)
|
|
EmptyPromise(lambda: modal.is_shown(), 'Modal is shown.') # pylint: disable=unnecessary-lambda
|
|
return modal
|
|
|
|
@property
|
|
def release_date(self):
|
|
"""
|
|
Returns the release date from the page. Date is "mm/dd/yyyy" string.
|
|
"""
|
|
element = self.q(css=self._bounded_selector(".status-release-value")) # pylint: disable=no-member
|
|
return element.first.text[0] if element.present else None
|
|
|
|
@property
|
|
def due_date(self):
|
|
"""
|
|
Returns the due date from the page. Date is "mm/dd/yyyy" string.
|
|
"""
|
|
element = self.q(css=self._bounded_selector(".status-grading-date")) # pylint: disable=no-member
|
|
return element.first.text[0] if element.present else None
|
|
|
|
@property
|
|
def policy(self):
|
|
"""
|
|
Select the grading format with `value` in the drop-down list.
|
|
"""
|
|
element = self.q(css=self._bounded_selector(".status-grading-value")) # pylint: disable=no-member
|
|
return element.first.text[0] if element.present else None
|
|
|
|
@wait_for_js
|
|
def publish(self):
|
|
"""
|
|
Publish the unit.
|
|
"""
|
|
click_css(self, self._bounded_selector('.action-publish'), require_notification=False)
|
|
modal = CourseOutlineModal(self)
|
|
EmptyPromise(lambda: modal.is_shown(), 'Modal is shown.') # pylint: disable=unnecessary-lambda
|
|
modal.publish()
|
|
|
|
@property
|
|
def publish_action(self):
|
|
"""
|
|
Returns the link for publishing a unit.
|
|
"""
|
|
return self.q(css=self._bounded_selector('.action-publish')).first # pylint: disable=no-member
|
|
|
|
|
|
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 child(self, title, child_class=None):
|
|
"""
|
|
|
|
:type self: object
|
|
"""
|
|
if not child_class:
|
|
child_class = self.CHILD_CLASS
|
|
|
|
# pylint: disable=no-member
|
|
return child_class(
|
|
self.browser,
|
|
self.q(css=child_class.BODY_SELECTOR).filter(
|
|
lambda el: title in [inner.text for inner in
|
|
el.find_elements_by_css_selector(child_class.NAME_SELECTOR)]
|
|
).attrs('data-locator')[0]
|
|
)
|
|
|
|
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]
|
|
|
|
def add_child(self, require_notification=True):
|
|
"""
|
|
Adds a child to this xblock, waiting for notifications.
|
|
"""
|
|
click_css(
|
|
self,
|
|
self._bounded_selector(self.ADD_BUTTON_SELECTOR),
|
|
require_notification=require_notification,
|
|
)
|
|
|
|
def expand_subsection(self):
|
|
"""
|
|
Toggle the expansion of this subsection.
|
|
"""
|
|
disable_animations(self)
|
|
|
|
def subsection_expanded():
|
|
"""
|
|
Returns whether or not this subsection is expanded.
|
|
"""
|
|
self.wait_for_element_presence(
|
|
self._bounded_selector(self.ADD_BUTTON_SELECTOR), 'Toggle control is present'
|
|
)
|
|
css_element = self._bounded_selector(self.ADD_BUTTON_SELECTOR)
|
|
add_button = self.q(css=css_element).first.results # pylint: disable=no-member
|
|
self.scroll_to_element(css_element) # pylint: disable=no-member
|
|
return add_button and add_button[0].is_displayed()
|
|
|
|
currently_expanded = subsection_expanded()
|
|
|
|
# Need to click slightly off-center in order for the click to be recognized.
|
|
css_element = self._bounded_selector('.ui-toggle-expansion .fa')
|
|
self.scroll_to_element(css_element) # pylint: disable=no-member
|
|
ele = self.browser.find_element_by_css_selector(css_element) # pylint: disable=no-member
|
|
ActionChains(self.browser).move_to_element_with_offset(ele, 8, 8).click().perform() # pylint: disable=no-member
|
|
self.wait_for_element_presence(self._bounded_selector(self.ADD_BUTTON_SELECTOR), u'Subsection is expanded')
|
|
|
|
EmptyPromise(
|
|
lambda: subsection_expanded() != currently_expanded,
|
|
u"Check that the container {} has been toggled".format(self.locator)
|
|
).fulfill()
|
|
|
|
enable_animations(self)
|
|
|
|
return self
|
|
|
|
@property
|
|
def is_collapsed(self):
|
|
"""
|
|
Return whether this outline item is currently collapsed.
|
|
"""
|
|
css_element = self._bounded_selector('')
|
|
self.scroll_to_element(css_element) # pylint: disable=no-member
|
|
return "is-collapsed" in self.q(css=css_element).first.attrs("class")[0] # pylint: disable=no-member
|
|
|
|
|
|
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(CourseOutlineChild, self).__init__(browser)
|
|
self.locator = locator
|
|
|
|
def is_browser_on_page(self):
|
|
return self.q(css='{}[data-locator="{}"]'.format(self.BODY_SELECTOR, self.locator)).present
|
|
|
|
def delete(self, cancel=False):
|
|
"""
|
|
Clicks the delete button, then cancels at the confirmation prompt if cancel is True.
|
|
"""
|
|
click_css(self, self._bounded_selector('.delete-button'), require_notification=False)
|
|
confirm_prompt(self, cancel)
|
|
|
|
def _bounded_selector(self, selector):
|
|
"""
|
|
Return `selector`, but limited to this particular `CourseOutlineChild` context
|
|
"""
|
|
return u'{}[data-locator="{}"] {}'.format(
|
|
self.BODY_SELECTOR,
|
|
self.locator,
|
|
selector
|
|
)
|
|
|
|
@property
|
|
def name(self):
|
|
titles = self.q(css=self._bounded_selector(self.NAME_SELECTOR)).text
|
|
if titles:
|
|
return titles[0]
|
|
else:
|
|
return None
|
|
|
|
@property
|
|
def children(self):
|
|
"""
|
|
Will return any first-generation descendant items of this item.
|
|
"""
|
|
descendants = self.q(css=self._bounded_selector(self.BODY_SELECTOR)).map(
|
|
lambda el: CourseOutlineChild(self.browser, el.get_attribute('data-locator'))).results
|
|
|
|
# Now remove any non-direct descendants.
|
|
grandkids = []
|
|
for descendant in descendants:
|
|
grandkids.extend(descendant.children)
|
|
|
|
grand_locators = [grandkid.locator for grandkid in grandkids]
|
|
return [descendant for descendant in descendants if descendant.locator not in grand_locators]
|
|
|
|
|
|
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)
|
|
|
|
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)
|
|
|
|
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()
|
|
|
|
|
|
class ExpandCollapseLinkState(object):
|
|
"""
|
|
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 click_course_status_section_start_date_link(self):
|
|
self.course_start_date_link.click()
|
|
|
|
def click_course_status_section_checklists_link(self):
|
|
self.course_checklists_link.click()
|
|
|
|
def view_live(self):
|
|
"""
|
|
Clicks the "View Live" link and switches to the new tab
|
|
"""
|
|
click_css(self, '.view-live-button', require_notification=False)
|
|
self.wait_for_page()
|
|
self.browser.switch_to_window(self.browser.window_handles[-1])
|
|
|
|
def section(self, title):
|
|
"""
|
|
Return the :class:`.CourseOutlineSection` with the title `title`.
|
|
"""
|
|
return self.child(title)
|
|
|
|
def section_at(self, index):
|
|
"""
|
|
Returns the :class:`.CourseOutlineSection` at the specified index.
|
|
"""
|
|
return self.child_at(index)
|
|
|
|
def click_section_name(self, parent_css=''):
|
|
"""
|
|
Find and click on first section name in course outline
|
|
"""
|
|
self.q(css=u'{} .section-name'.format(parent_css)).first.click()
|
|
|
|
def get_section_name(self, parent_css='', page_refresh=False):
|
|
"""
|
|
Get the list of names of all sections present
|
|
"""
|
|
if page_refresh:
|
|
self.browser.refresh()
|
|
return self.q(css=u'{} .section-name'.format(parent_css)).text
|
|
|
|
def section_name_edit_form_present(self, parent_css=''):
|
|
"""
|
|
Check that section name edit form present
|
|
"""
|
|
return self.q(css=u'{} .section-name input'.format(parent_css)).present
|
|
|
|
def change_section_name(self, new_name, parent_css=''):
|
|
"""
|
|
Change section name of first section present in course outline
|
|
"""
|
|
self.click_section_name(parent_css)
|
|
self.q(css=u'{} .section-name input'.format(parent_css)).first.fill(new_name)
|
|
self.q(css=u'{} .section-name .save-button'.format(parent_css)).first.click()
|
|
self.wait_for_ajax()
|
|
|
|
def sections(self):
|
|
"""
|
|
Returns the sections of this course outline page.
|
|
"""
|
|
return self.children()
|
|
|
|
def add_section_from_top_button(self):
|
|
"""
|
|
Clicks the button for adding a section which resides at the top of the screen.
|
|
"""
|
|
click_css(self, '.wrapper-mast nav.nav-actions .button-new')
|
|
|
|
def add_section_from_bottom_button(self, click_child_icon=False):
|
|
"""
|
|
Clicks the button for adding a section which resides at the bottom of the screen.
|
|
"""
|
|
element_css = self.BOTTOM_ADD_SECTION_BUTTON
|
|
if click_child_icon:
|
|
element_css += " .fa-plus"
|
|
|
|
click_css(self, element_css)
|
|
|
|
def toggle_expand_collapse(self):
|
|
"""
|
|
Toggles whether all sections are expanded or collapsed
|
|
"""
|
|
self.q(css=self.EXPAND_COLLAPSE_CSS).click()
|
|
|
|
def start_reindex(self):
|
|
"""
|
|
Starts course reindex by clicking reindex button
|
|
"""
|
|
self.reindex_button.click()
|
|
|
|
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 change_problem_release_date(self):
|
|
"""
|
|
Sets a new start date
|
|
"""
|
|
self.q(css=".subsection-header-actions .configure-button").first.click()
|
|
self.q(css="#start_date").fill("01/01/2030")
|
|
self.q(css=".action-save").first.click()
|
|
self.wait_for_ajax()
|
|
|
|
def change_problem_due_date(self, date):
|
|
"""
|
|
Sets a new due date.
|
|
|
|
Expects date to be a string that will be accepted by the input (for example, '01/01/1970')
|
|
"""
|
|
self.q(css=".subsection-header-actions .configure-button").first.click()
|
|
self.q(css="#due_date").fill(date)
|
|
self.q(css=".action-save").first.click()
|
|
self.wait_for_ajax()
|
|
|
|
def select_visibility_tab(self):
|
|
"""
|
|
Select the advanced settings tab
|
|
"""
|
|
self.q(css=".settings-tab-button[data-tab='visibility']").first.click()
|
|
self.wait_for_element_presence('input[value=hide_after_due]', 'Visibility fields not 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.')
|
|
|
|
def make_exam_proctored(self):
|
|
"""
|
|
Makes a Proctored exam.
|
|
"""
|
|
self.q(css="input.proctored_exam").first.click()
|
|
self.q(css=".action-save").first.click()
|
|
self.wait_for_ajax()
|
|
|
|
def make_exam_timed(self, hide_after_due=False):
|
|
"""
|
|
Makes a timed exam.
|
|
"""
|
|
self.q(css="input.timed_exam").first.click()
|
|
if hide_after_due:
|
|
self.select_visibility_tab()
|
|
self.q(css='input[name=content-visibility][value=hide_after_due]').first.click()
|
|
self.q(css=".action-save").first.click()
|
|
self.wait_for_ajax()
|
|
|
|
def make_subsection_hidden_after_due_date(self):
|
|
"""
|
|
Sets a subsection to be hidden after due date.
|
|
"""
|
|
self.q(css='input[value=hide_after_due]').first.click()
|
|
self.q(css=".action-save").first.click()
|
|
self.wait_for_ajax()
|
|
|
|
def select_none_exam(self):
|
|
"""
|
|
Choose "none" exam but do not press enter
|
|
"""
|
|
self.q(css="input.no_special_exam").first.click()
|
|
|
|
def select_timed_exam(self):
|
|
"""
|
|
Choose a timed exam but do not press enter
|
|
"""
|
|
self.q(css="input.timed_exam").first.click()
|
|
|
|
def select_proctored_exam(self):
|
|
"""
|
|
Choose a proctored exam but do not press enter
|
|
"""
|
|
self.q(css="input.proctored_exam").first.click()
|
|
|
|
def select_practice_exam(self):
|
|
"""
|
|
Choose a practice exam but do not press enter
|
|
"""
|
|
self.q(css="input.practice_exam").first.click()
|
|
|
|
def time_allotted_field_visible(self):
|
|
"""
|
|
returns whether the time allotted field is visible
|
|
"""
|
|
return self.q(css=".field-time-limit").visible
|
|
|
|
def exam_review_rules_field_visible(self):
|
|
"""
|
|
Returns whether the review rules field is visible
|
|
"""
|
|
return self.q(css=".field-exam-review-rules").visible
|
|
|
|
def proctoring_items_are_displayed(self):
|
|
"""
|
|
Returns True if all the items are found.
|
|
"""
|
|
|
|
# The None radio button
|
|
if not self.q(css="input.no_special_exam").present:
|
|
return False
|
|
|
|
# The Timed exam radio button
|
|
if not self.q(css="input.timed_exam").present:
|
|
return False
|
|
|
|
# The Proctored exam radio button
|
|
if not self.q(css="input.proctored_exam").present:
|
|
return False
|
|
|
|
# The Practice exam radio button
|
|
if not self.q(css="input.practice_exam").present:
|
|
return False
|
|
|
|
return True
|
|
|
|
def make_gating_prerequisite(self):
|
|
"""
|
|
Makes a subsection a gating prerequisite.
|
|
"""
|
|
if not self.q(css="#is_prereq")[0].is_selected():
|
|
self.q(css='label[for="is_prereq"]').click()
|
|
self.q(css=".action-save").first.click()
|
|
self.wait_for_ajax()
|
|
|
|
def add_prerequisite_to_subsection(self, min_score, min_completion):
|
|
"""
|
|
Adds a prerequisite to a subsection.
|
|
"""
|
|
Select(self.q(css="#prereq")[0]).select_by_index(1)
|
|
self.q(css="#prereq_min_score").fill(min_score)
|
|
self.q(css="#prereq_min_completion").fill(min_completion)
|
|
self.q(css=".action-save").first.click()
|
|
self.wait_for_ajax()
|
|
|
|
def gating_prerequisite_checkbox_is_visible(self):
|
|
"""
|
|
Returns True if the gating prerequisite checkbox is visible.
|
|
"""
|
|
|
|
# The Prerequisite checkbox is visible
|
|
return self.q(css="#is_prereq").visible
|
|
|
|
def gating_prerequisite_checkbox_is_checked(self):
|
|
"""
|
|
Returns True if the gating prerequisite checkbox is checked.
|
|
"""
|
|
|
|
# The Prerequisite checkbox is checked
|
|
return self.q(css="#is_prereq:checked").present
|
|
|
|
def gating_prerequisites_dropdown_is_visible(self):
|
|
"""
|
|
Returns True if the gating prerequisites dropdown is visible.
|
|
"""
|
|
|
|
# The Prerequisites dropdown is visible
|
|
return self.q(css="#prereq").visible
|
|
|
|
def gating_prerequisite_min_score_is_visible(self):
|
|
"""
|
|
Returns True if the gating prerequisite minimum score input is visible.
|
|
"""
|
|
|
|
# The Prerequisites dropdown is visible
|
|
return self.q(css="#prereq_min_score").visible
|
|
|
|
@property
|
|
def has_course_status_section(self):
|
|
# SFE and SFE-wrapper classes come from studio-frontend and
|
|
# wrap content provided by the studio-frontend package
|
|
return self.q(css='.course-status .SFE .SFE-wrapper').is_present()
|
|
|
|
@property
|
|
def course_start_date_link(self):
|
|
return self.q(css='.status-link').first
|
|
|
|
@property
|
|
def course_checklists_link(self):
|
|
return self.q(css='.status-link').nth(1)
|
|
|
|
@property
|
|
def bottom_add_section_button(self):
|
|
"""
|
|
Returns the query representing the bottom add section button.
|
|
"""
|
|
return self.q(css=self.BOTTOM_ADD_SECTION_BUTTON).first
|
|
|
|
@property
|
|
def has_no_content_message(self):
|
|
"""
|
|
Returns true if a message informing the user that the course has no content is visible
|
|
"""
|
|
return self.q(css='.outline .no-content').is_present()
|
|
|
|
@property
|
|
def has_rerun_notification(self):
|
|
"""
|
|
Returns true iff the rerun notification is present on the page.
|
|
"""
|
|
return self.q(css='.wrapper-alert.is-shown').is_present()
|
|
|
|
def dismiss_rerun_notification(self):
|
|
"""
|
|
Clicks the dismiss button in the rerun notification.
|
|
"""
|
|
self.q(css='.dismiss-button').click()
|
|
|
|
@property
|
|
def expand_collapse_link_state(self):
|
|
"""
|
|
Returns the current state of the expand/collapse link
|
|
"""
|
|
link = self.q(css=self.EXPAND_COLLAPSE_CSS)[0]
|
|
if not link.is_displayed():
|
|
return ExpandCollapseLinkState.MISSING
|
|
elif "collapse-all" in link.get_attribute("class"):
|
|
return ExpandCollapseLinkState.COLLAPSE
|
|
else:
|
|
return ExpandCollapseLinkState.EXPAND
|
|
|
|
@property
|
|
def reindex_button(self):
|
|
"""
|
|
Returns reindex button.
|
|
"""
|
|
return self.q(css=".button.button-reindex")[0]
|
|
|
|
def expand_all_subsections(self):
|
|
"""
|
|
Expands all the subsections in this course.
|
|
"""
|
|
for section in self.sections():
|
|
if section.is_collapsed:
|
|
section.expand_subsection()
|
|
for subsection in section.subsections():
|
|
if subsection.is_collapsed:
|
|
subsection.expand_subsection()
|
|
|
|
@property
|
|
def xblocks(self):
|
|
"""
|
|
Return a list of xblocks loaded on the outline page.
|
|
"""
|
|
return self.children(CourseOutlineChild)
|
|
|
|
@property
|
|
def license(self):
|
|
"""
|
|
Returns the course license text, if present. Else returns None.
|
|
"""
|
|
return self.q(css=".license-value").first.text[0]
|
|
|
|
@property
|
|
def deprecated_warning_visible(self):
|
|
"""
|
|
Returns true if the deprecated warning is visible.
|
|
"""
|
|
return self.q(css='.wrapper-alert-error.is-shown').is_present()
|
|
|
|
@property
|
|
def warning_heading_text(self):
|
|
"""
|
|
Returns deprecated warning heading text.
|
|
"""
|
|
return self.q(css='.warning-heading-text').text[0]
|
|
|
|
@property
|
|
def components_list_heading(self):
|
|
"""
|
|
Returns deprecated warning component list heading text.
|
|
"""
|
|
return self.q(css='.components-list-heading-text').text[0]
|
|
|
|
@property
|
|
def modules_remove_text_shown(self):
|
|
"""
|
|
Returns True if deprecated warning advance modules remove text is visible.
|
|
"""
|
|
return self.q(css='.advance-modules-remove-text').visible
|
|
|
|
@property
|
|
def modules_remove_text(self):
|
|
"""
|
|
Returns deprecated warning advance modules remove text.
|
|
"""
|
|
return self.q(css='.advance-modules-remove-text').text[0]
|
|
|
|
@property
|
|
def components_visible(self):
|
|
"""
|
|
Returns True if components list visible.
|
|
"""
|
|
return self.q(css='.components-list').visible
|
|
|
|
@property
|
|
def components_display_names(self):
|
|
"""
|
|
Returns deprecated warning components display name list.
|
|
"""
|
|
return self.q(css='.components-list li>a').text
|
|
|
|
@property
|
|
def deprecated_advance_modules(self):
|
|
"""
|
|
Returns deprecated advance modules list.
|
|
"""
|
|
return self.q(css='.advance-modules-list li').text
|
|
|
|
|
|
class CourseOutlineModal(object):
|
|
"""
|
|
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()
|
|
|
|
def publish(self):
|
|
"""
|
|
Click the publish action button, and wait for the ajax call to return.
|
|
"""
|
|
self.click(".action-publish")
|
|
self.page.wait_for_ajax()
|
|
|
|
def cancel(self):
|
|
"""
|
|
Click the cancel action button.
|
|
"""
|
|
self.click(".action-cancel")
|
|
|
|
def has_release_date(self):
|
|
"""
|
|
Check if the input box for the release date exists in the subsection's settings window
|
|
"""
|
|
return self.find_css("#start_date").present
|
|
|
|
def has_release_time(self):
|
|
"""
|
|
Check if the input box for the release time exists in the subsection's settings window
|
|
"""
|
|
return self.find_css("#start_time").present
|
|
|
|
def has_due_date(self):
|
|
"""
|
|
Check if the input box for the due date exists in the subsection's settings window
|
|
"""
|
|
return self.find_css("#due_date").present
|
|
|
|
def has_due_time(self):
|
|
"""
|
|
Check if the input box for the due time exists in the subsection's settings window
|
|
"""
|
|
return self.find_css("#due_time").present
|
|
|
|
def has_policy(self):
|
|
"""
|
|
Check if the input for the grading policy is present.
|
|
"""
|
|
return self.find_css("#grading_type").present
|
|
|
|
def set_date(self, property_name, input_selector, date):
|
|
"""
|
|
Set `date` value to input pointed by `selector` and `property_name`.
|
|
"""
|
|
month, day, year = list(map(int, date.split('/')))
|
|
self.click(input_selector)
|
|
if getattr(self, property_name):
|
|
current_month, current_year = list(map(int, getattr(self, property_name).split('/')[1:]))
|
|
else: # Use default timepicker values, which are current month and year.
|
|
current_month, current_year = datetime.datetime.today().month, datetime.datetime.today().year
|
|
date_diff = 12 * (year - current_year) + month - current_month
|
|
selector = u"a.ui-datepicker-{}".format('next' if date_diff > 0 else 'prev')
|
|
for __ in range(abs(date_diff)):
|
|
self.page.q(css=selector).click()
|
|
self.page.q(css="a.ui-state-default").nth(day - 1).click() # set day
|
|
self.page.wait_for_element_invisibility("#ui-datepicker-div", "datepicker should be closed")
|
|
EmptyPromise(
|
|
lambda: getattr(self, property_name) == u'{m}/{d}/{y}'.format(m=month, d=day, y=year),
|
|
u"{} is updated in modal.".format(property_name)
|
|
).fulfill()
|
|
|
|
def set_time(self, input_selector, time):
|
|
"""
|
|
Set `time` value to input pointed by `input_selector`
|
|
Not using the time picker to make sure it's not being rounded up
|
|
"""
|
|
|
|
self.page.q(css=input_selector).fill(time)
|
|
self.page.q(css=input_selector).results[0].send_keys(Keys.ENTER)
|
|
|
|
@property
|
|
def release_date(self):
|
|
"""
|
|
Returns the unit's release date. Date is "mm/dd/yyyy" string.
|
|
"""
|
|
return self.find_css("#start_date").first.attrs('value')[0]
|
|
|
|
@release_date.setter
|
|
def release_date(self, date):
|
|
"""
|
|
Sets the unit's release date to `date`. Date is "mm/dd/yyyy" string.
|
|
"""
|
|
self.set_date('release_date', "#start_date", date)
|
|
|
|
@property
|
|
def release_time(self):
|
|
"""
|
|
Returns the current value of the release time. Default is u'00:00'
|
|
"""
|
|
return self.find_css("#start_time").first.attrs('value')[0]
|
|
|
|
@release_time.setter
|
|
def release_time(self, time):
|
|
"""
|
|
Time is "HH:MM" string.
|
|
"""
|
|
self.set_time("#start_time", time)
|
|
|
|
@property
|
|
def due_date(self):
|
|
"""
|
|
Returns the due date from the page. Date is "mm/dd/yyyy" string.
|
|
"""
|
|
return self.find_css("#due_date").first.attrs('value')[0]
|
|
|
|
@due_date.setter
|
|
def due_date(self, date):
|
|
"""
|
|
Sets the due date for the unit. Date is "mm/dd/yyyy" string.
|
|
"""
|
|
self.set_date('due_date', "#due_date", date)
|
|
|
|
@property
|
|
def due_time(self):
|
|
"""
|
|
Returns the current value of the release time. Default is u''
|
|
"""
|
|
return self.find_css("#due_time").first.attrs('value')[0]
|
|
|
|
@due_time.setter
|
|
def due_time(self, time):
|
|
"""
|
|
Time is "HH:MM" string.
|
|
"""
|
|
self.set_time("#due_time", time)
|
|
|
|
@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()
|
|
|
|
@property
|
|
def is_staff_lock_visible(self):
|
|
"""
|
|
Returns True if the staff lock option is visible.
|
|
"""
|
|
return self.find_css('#staff_lock').visible
|
|
|
|
def ensure_staff_lock_visible(self):
|
|
"""
|
|
Ensures the staff lock option is visible, clicking on the advanced tab
|
|
if needed.
|
|
"""
|
|
if not self.is_staff_lock_visible:
|
|
self.find_css(".settings-tab-button[data-tab=visibility]").click()
|
|
EmptyPromise(
|
|
lambda: self.is_staff_lock_visible,
|
|
"Staff lock option is visible",
|
|
).fulfill()
|
|
|
|
@property
|
|
def is_explicitly_locked(self):
|
|
"""
|
|
Returns true if the explict staff lock checkbox is checked, false otherwise.
|
|
"""
|
|
self.ensure_staff_lock_visible()
|
|
return self.find_css('#staff_lock')[0].is_selected()
|
|
|
|
@is_explicitly_locked.setter
|
|
def is_explicitly_locked(self, value):
|
|
"""
|
|
Checks the explicit staff lock box if value is true, otherwise selects "visible".
|
|
"""
|
|
self.ensure_staff_lock_visible()
|
|
if value != self.is_explicitly_locked:
|
|
self.find_css('#staff_lock').click()
|
|
EmptyPromise(lambda: value == self.is_explicitly_locked, "Explicit staff lock is updated").fulfill()
|
|
|
|
def shows_staff_lock_warning(self):
|
|
"""
|
|
Returns true iff the staff lock warning is visible.
|
|
"""
|
|
return self.find_css('.staff-lock .tip-warning').visible
|
|
|
|
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()
|
|
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()
|
|
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
|