overview.py 22.7 KB
Newer Older
1 2 3
"""
Course Outline page in Studio.
"""
4 5
import datetime

6
from bok_choy.page_object import PageObject
7
from bok_choy.promise import EmptyPromise
8

9
from selenium.webdriver.support.ui import Select
10
from selenium.webdriver.common.keys import Keys
11
from selenium.webdriver.common.action_chains import ActionChains
12

13
from .course_page import CoursePage
14
from .container import ContainerPage
15
from .utils import set_input_value_and_save, set_input_value, click_css, confirm_prompt
16

17

18 19 20 21 22
class CourseOutlineItem(object):
    """
    A mixin class for any :class:`PageObject` shown in a course outline.
    """
    BODY_SELECTOR = None
Ben McMorran committed
23
    EDIT_BUTTON_SELECTOR = '.xblock-field-value-edit'
24
    NAME_SELECTOR = '.item-title'
Ben McMorran committed
25
    NAME_INPUT_SELECTOR = '.xblock-field-input'
26
    NAME_FIELD_WRAPPER_SELECTOR = '.xblock-title .wrapper-xblock-field'
27
    STATUS_MESSAGE_SELECTOR = '> div[class$="status"] .status-message'
28
    CONFIGURATION_BUTTON_SELECTOR = '.action-item .configure-button'
29 30

    def __repr__(self):
31 32 33
        # 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.
34 35 36 37
        try:
            return "{}(<browser>, {!r})".format(self.__class__.__name__, self.locator)
        except AttributeError:
            return "{}(<browser>)".format(self.__class__.__name__)
38 39 40 41 42

    def _bounded_selector(self, selector):
        """
        Returns `selector`, but limited to this particular `CourseOutlineItem` context
        """
43 44 45 46 47 48 49 50 51 52
        # 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
        if self.BODY_SELECTOR and hasattr(self, 'locator'):
            return '{}[data-locator="{}"] {}'.format(
                self.BODY_SELECTOR,
                self.locator,
                selector
            )
        else:
            return selector
53 54 55 56 57 58 59 60 61 62 63 64

    @property
    def name(self):
        """
        Returns the display name of this object.
        """
        name_element = self.q(css=self._bounded_selector(self.NAME_SELECTOR)).first
        if name_element:
            return name_element.text[0]
        else:
            return None

65 66 67 68 69 70 71 72 73 74 75 76 77 78
    @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

    @property
    def status_message(self):
        """
        Returns the status message of this item.
        """
        return self.q(css=self._bounded_selector(self.STATUS_MESSAGE_SELECTOR)).text[0]

79 80
    @property
    def has_staff_lock_warning(self):
81
        """ Returns True if the 'Contains staff only content' message is visible """
82 83 84 85 86 87 88
        return self.status_message == 'Contains staff only content' 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")

89 90 91 92 93 94 95 96 97 98
    def edit_name(self):
        """
        Puts the item's name into editable form.
        """
        self.q(css=self._bounded_selector(self.EDIT_BUTTON_SELECTOR)).first.click()

    def enter_name(self, new_name):
        """
        Enters new_name as the item's display name.
        """
Ben McMorran committed
99
        set_input_value(self, self._bounded_selector(self.NAME_INPUT_SELECTOR), new_name)
100

101 102 103 104
    def change_name(self, new_name):
        """
        Changes the container's name.
        """
105
        self.edit_name()
106 107 108
        set_input_value_and_save(self, self._bounded_selector(self.NAME_INPUT_SELECTOR), new_name)
        self.wait_for_ajax()

109 110 111 112 113 114 115
    def finalize_name(self):
        """
        Presses ENTER, saving the value of the display name for this item.
        """
        self.q(css=self._bounded_selector(self.NAME_INPUT_SELECTOR)).results[0].send_keys(Keys.ENTER)
        self.wait_for_ajax()

116 117 118 119 120 121 122 123
    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()

124 125 126 127 128 129 130 131
    def in_editable_form(self):
        """
        Return whether this outline item's display name is in its editable form.
        """
        return "is-editing" in self.q(
            css=self._bounded_selector(self.NAME_FIELD_WRAPPER_SELECTOR)
        )[0].get_attribute("class")

132 133 134 135 136 137 138 139 140 141 142 143 144
    def edit(self):
        self.q(css=self._bounded_selector(self.CONFIGURATION_BUTTON_SELECTOR)).first.click()
        modal = CourseOutlineModal(self)
        EmptyPromise(lambda: modal.is_shown(), 'Modal is shown.')
        return modal

    @property
    def release_date(self):
        element = self.q(css=self._bounded_selector(".status-release-value"))
        return element.first.text[0] if element.present else None

    @property
    def due_date(self):
145
        element = self.q(css=self._bounded_selector(".status-grading-date"))
146 147 148 149
        return element.first.text[0] if element.present else None

    @property
    def policy(self):
150
        element = self.q(css=self._bounded_selector(".status-grading-value"))
151 152
        return element.first.text[0] if element.present else None

153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168
    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.')
        modal.publish()

    @property
    def publish_action(self):
        """
        Returns the link for publishing a unit.
        """
        return self.q(css=self._bounded_selector('.action-publish')).first

169 170

class CourseOutlineContainer(CourseOutlineItem):
171
    """
172
    A mixin to a CourseOutline page object that adds the ability to load
173
    a child page object by title or by index.
174 175 176 177

    CHILD_CLASS must be a :class:`CourseOutlineChild` subclass.
    """
    CHILD_CLASS = None
178
    ADD_BUTTON_SELECTOR = '> .outline-content > .add-item a.button-new'
179

180
    def child(self, title, child_class=None):
181 182 183 184
        """

        :type self: object
        """
185 186
        if not child_class:
            child_class = self.CHILD_CLASS
187

188
        return child_class(
189
            self.browser,
190
            self.q(css=child_class.BODY_SELECTOR).filter(
191 192 193
                lambda el: title in [inner.text for inner in
                                     el.find_elements_by_css_selector(child_class.NAME_SELECTOR)]
            ).attrs('data-locator')[0]
194 195
        )

196 197 198 199 200 201
    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
202
        return self.q(css=self._bounded_selector(child_class.BODY_SELECTOR)).map(
203 204
            lambda el: child_class(self.browser, el.get_attribute('data-locator'))).results

205 206 207 208 209 210 211 212
    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

213 214 215 216 217 218 219 220
        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,
221
            self._bounded_selector(self.ADD_BUTTON_SELECTOR),
222
            require_notification=require_notification,
223 224
        )

225 226 227 228 229 230 231 232
    def toggle_expand(self):
        """
        Toggle the expansion of this subsection.
        """

        self.browser.execute_script("jQuery.fx.off = true;")

        def subsection_expanded():
233
            add_button = self.q(css=self._bounded_selector(self.ADD_BUTTON_SELECTOR)).first.results
234 235 236 237
            return add_button and add_button[0].is_displayed()

        currently_expanded = subsection_expanded()

238
        self.q(css=self._bounded_selector('.ui-toggle-expansion i')).first.click()
239 240 241 242 243 244 245 246 247 248 249 250 251

        EmptyPromise(
            lambda: subsection_expanded() != currently_expanded,
            "Check that the container {} has been toggled".format(self.locator)
        ).fulfill()

        return self

    @property
    def is_collapsed(self):
        """
        Return whether this outline item is currently collapsed.
        """
252
        return "is-collapsed" in self.q(css=self._bounded_selector('')).first.attrs("class")[0]
253

254

255
class CourseOutlineChild(PageObject, CourseOutlineItem):
256
    """
257
    A page object that will be used as a child of :class:`CourseOutlineContainer`.
258
    """
259 260 261
    url = None
    BODY_SELECTOR = '.outline-item'

262 263 264 265 266 267 268
    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

269 270 271 272 273 274 275
    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)

276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307
    def _bounded_selector(self, selector):
        """
        Return `selector`, but limited to this particular `CourseOutlineChild` context
        """
        return '{}[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]
David Baumgold committed
308
        return [descendant for descendant in descendants if descendant.locator not in grand_locators]
309

310

311 312
class CourseOutlineUnit(CourseOutlineChild):
    """
313
    PageObject that wraps a unit link on the Studio Course Outline page.
314 315
    """
    url = None
316 317
    BODY_SELECTOR = '.outline-unit'
    NAME_SELECTOR = '.unit-title a'
318 319 320

    def go_to(self):
        """
321 322
        Open the container page linked to by this unit link, and return
        an initialized :class:`.ContainerPage` for that unit.
323
        """
324
        return ContainerPage(self.browser, self.locator).visit()
cahrens committed
325

326 327 328
    def is_browser_on_page(self):
        return self.q(css=self.BODY_SELECTOR).present

329 330 331
    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
332

333

334
class CourseOutlineSubsection(CourseOutlineContainer, CourseOutlineChild):
335
    """
336
    :class`.PageObject` that wraps a subsection block on the Studio Course Outline page.
337
    """
338 339
    url = None

340 341 342
    BODY_SELECTOR = '.outline-subsection'
    NAME_SELECTOR = '.subsection-title'
    NAME_FIELD_WRAPPER_SELECTOR = '.subsection-header .wrapper-xblock-field'
343 344 345 346 347 348 349 350
    CHILD_CLASS = CourseOutlineUnit

    def unit(self, title):
        """
        Return the :class:`.CourseOutlineUnit with the title `title`.
        """
        return self.child(title)

351
    def units(self):
352
        """
353
        Returns the units in this subsection.
354
        """
355
        return self.children()
356

357 358 359 360 361
    def unit_at(self, index):
        """
        Returns the CourseOutlineUnit at the specified index.
        """
        return self.child_at(index)
362

363 364 365 366
    def add_unit(self):
        """
        Adds a unit to this subsection
        """
367
        self.q(css=self._bounded_selector(self.ADD_BUTTON_SELECTOR)).click()
368

369

370
class CourseOutlineSection(CourseOutlineContainer, CourseOutlineChild):
371
    """
372
    :class`.PageObject` that wraps a section block on the Studio Course Outline page.
373 374
    """
    url = None
375 376 377
    BODY_SELECTOR = '.outline-section'
    NAME_SELECTOR = '.section-title'
    NAME_FIELD_WRAPPER_SELECTOR = '.section-header .wrapper-xblock-field'
378 379 380 381 382 383 384 385
    CHILD_CLASS = CourseOutlineSubsection

    def subsection(self, title):
        """
        Return the :class:`.CourseOutlineSubsection` with the title `title`.
        """
        return self.child(title)

386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412
    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:
    """
    Represents the three states that the expand/collapse link can be in
    """
    MISSING = 0
    COLLAPSE = 1
    EXPAND = 2

413 414 415 416 417

class CourseOutlinePage(CoursePage, CourseOutlineContainer):
    """
    Course Outline page in Studio.
    """
418
    url_path = "course"
419
    CHILD_CLASS = CourseOutlineSection
Ben McMorran committed
420
    EXPAND_COLLAPSE_CSS = '.button-toggle-expand-collapse'
421
    BOTTOM_ADD_SECTION_BUTTON = '.outline > .add-section .button-new'
422 423

    def is_browser_on_page(self):
424
        return self.q(css='body.view-outline').present and self.q(css='div.ui-loading.is-hidden').present
425

426 427 428 429 430 431 432
    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.browser.switch_to_window(self.browser.window_handles[-1])

433 434 435 436 437
    def section(self, title):
        """
        Return the :class:`.CourseOutlineSection` with the title `title`.
        """
        return self.child(title)
cahrens committed
438

439 440 441 442 443
    def section_at(self, index):
        """
        Returns the :class:`.CourseOutlineSection` at the specified index.
        """
        return self.child_at(index)
cahrens committed
444

445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478
    def click_section_name(self, parent_css=''):
        """
        Find and click on first section name in course outline
        """
        self.q(css='{} .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='{} .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='{} .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='{} .section-name input'.format(parent_css)).first.fill(new_name)
        self.q(css='{} .section-name .save-button'.format(parent_css)).first.click()
        self.wait_for_ajax()

    def click_release_date(self):
        """
        Open release date edit modal of first section in course outline
        """
        self.q(css='div.section-published-date a.edit-release-date').first.click()
479 480 481 482 483 484 485 486 487 488 489

    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.
        """
490
        click_css(self, '.wrapper-mast nav.nav-actions .button-new')
491

492
    def add_section_from_bottom_button(self, click_child_icon=False):
493 494 495
        """
        Clicks the button for adding a section which resides at the bottom of the screen.
        """
496 497
        element_css = self.BOTTOM_ADD_SECTION_BUTTON
        if click_child_icon:
498
            element_css += " .fa-plus"
499 500

        click_css(self, element_css)
501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519

    def toggle_expand_collapse(self):
        """
        Toggles whether all sections are expanded or collapsed
        """
        self.q(css=self.EXPAND_COLLAPSE_CSS).click()

    @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
        """
Ben McMorran committed
520
        return self.q(css='.outline .no-content').is_present()
521 522

    @property
523 524 525 526 527 528 529 530 531 532 533 534 535
    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
536 537 538 539 540 541 542 543 544 545 546
    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
547

548 549 550 551 552 553 554 555 556 557 558
    def expand_all_subsections(self):
        """
        Expands all the subsections in this course.
        """
        for section in self.sections():
            if section.is_collapsed:
                section.toggle_expand()
            for subsection in section.subsections():
                if subsection.is_collapsed:
                    subsection.toggle_expand()

559
    @property
560
    def xblocks(self):
561 562 563
        """
        Return a list of xblocks loaded on the outline page.
        """
564
        return self.children(CourseOutlineChild)
565

566 567

class CourseOutlineModal(object):
568
    MODAL_SELECTOR = ".wrapper-modal-window"
569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591

    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 self.page.q(css=self.MODAL_SELECTOR).present

    def find_css(self, selector):
        return self.page.q(css=self._bounded_selector(selector))

    def click(self, selector, index=0):
        self.find_css(selector).nth(index).click()

    def save(self):
        self.click(".action-save")
        self.page.wait_for_ajax()

592 593 594 595
    def publish(self):
        self.click(".action-publish")
        self.page.wait_for_ajax()

596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615
    def cancel(self):
        self.click(".action-cancel")

    def has_release_date(self):
        return self.find_css("#start_date").present

    def has_due_date(self):
        return self.find_css("#due_date").present

    def has_policy(self):
        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 = map(int, date.split('/'))
        self.click(input_selector)
        if getattr(self, property_name):
            current_month, current_year = map(int, getattr(self, property_name).split('/')[1:])
cahrens committed
616
        else:  # Use default timepicker values, which are current month and year.
617 618 619 620 621 622
            current_month, current_year = datetime.datetime.today().month, datetime.datetime.today().year
        date_diff = 12 * (year - current_year) + month - current_month
        selector = "a.ui-datepicker-{}".format('next' if date_diff > 0 else 'prev')
        for i in xrange(abs(date_diff)):
            self.page.q(css=selector).click()
        self.page.q(css="a.ui-state-default").nth(day - 1).click()  # set day
623
        self.page.wait_for_element_invisibility("#ui-datepicker-div", "datepicker should be closed")
624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672
        EmptyPromise(
            lambda: getattr(self, property_name) == u'{m}/{d}/{y}'.format(m=month, d=day, y=year),
            "{} is updated in modal.".format(property_name)
        ).fulfill()

    @property
    def release_date(self):
        return self.find_css("#start_date").first.attrs('value')[0]

    @release_date.setter
    def release_date(self, date):
        """
        Date is "mm/dd/yyyy" string.
        """
        self.set_date('release_date', "#start_date", date)

    @property
    def due_date(self):
        return self.find_css("#due_date").first.attrs('value')[0]

    @due_date.setter
    def due_date(self, date):
        """
        Date is "mm/dd/yyyy" string.
        """
        self.set_date('due_date', "#due_date", date)

    @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()

673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694
    @property
    def is_explicitly_locked(self):
        """
        Returns true if the explict staff lock checkbox is checked, false otherwise.
        """
        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 unchecks the box.
        """
        if value != self.is_explicitly_locked:
            self.find_css('label[for="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

695 696 697 698 699 700 701 702 703
    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