diff --git a/common/test/acceptance/fixtures/course.py b/common/test/acceptance/fixtures/course.py index c5b1a05849..00321fd672 100644 --- a/common/test/acceptance/fixtures/course.py +++ b/common/test/acceptance/fixtures/course.py @@ -227,9 +227,9 @@ class CourseFixture(XBlockContainerFixture): self._configure_course() @property - def course_outline(self): + def studio_course_outline_as_json(self): """ - Retrieves course outline in JSON format. + Retrieves Studio course outline in JSON format. """ url = STUDIO_BASE_URL + '/course/' + self._course_key + "?format=json" response = self.session.get(url, headers=self.headers) diff --git a/common/test/acceptance/pages/lms/course_home.py b/common/test/acceptance/pages/lms/course_home.py new file mode 100644 index 0000000000..1b34680e6e --- /dev/null +++ b/common/test/acceptance/pages/lms/course_home.py @@ -0,0 +1,148 @@ +""" +LMS Course Home page object +""" + +from bok_choy.page_object import PageObject + +from common.test.acceptance.pages.lms.course_page import CoursePage +from common.test.acceptance.pages.lms.courseware import CoursewarePage + + +class CourseHomePage(CoursePage): + """ + Course home page, including course outline. + """ + + url_path = "course/" + + def is_browser_on_page(self): + return self.q(css='.course-outline').present + + def __init__(self, browser, course_id): + super(CourseHomePage, self).__init__(browser, course_id) + self.course_id = course_id + self.outline = CourseOutlinePage(browser, self) + # TODO: TNL-6546: Remove the following + self.unified_course_view = False + + +class CourseOutlinePage(PageObject): + """ + Course outline fragment of page. + """ + + url = None + + def __init__(self, browser, parent_page): + super(CourseOutlinePage, self).__init__(browser) + self.parent_page = parent_page + self.courseware_page = CoursewarePage(self.browser, self.parent_page.course_id) + + def is_browser_on_page(self): + return self.parent_page.is_browser_on_page + + @property + def sections(self): + """ + Return a dictionary representation of sections and subsections. + + Example: + + { + 'Introduction': ['Course Overview'], + 'Week 1': ['Lesson 1', 'Lesson 2', 'Homework'] + 'Final Exam': ['Final Exam'] + } + + You can use these titles in `go_to_section` to navigate to the section. + """ + # Dict to store the result + outline_dict = dict() + + section_titles = self._section_titles() + + # Get the section titles for each chapter + for sec_index, sec_title in enumerate(section_titles): + + if len(section_titles) < 1: + self.warning("Could not find subsections for '{0}'".format(sec_title)) + else: + # Add one to convert list index (starts at 0) to CSS index (starts at 1) + outline_dict[sec_title] = self._subsection_titles(sec_index + 1) + + return outline_dict + + def go_to_section(self, section_title, subsection_title): + """ + Go to the section in the courseware. + Every section must have at least one subsection, so specify + both the section and subsection title. + + Example: + go_to_section("Week 1", "Lesson 1") + """ + + # Get the section by index + try: + section_index = self._section_titles().index(section_title) + except ValueError: + self.warning("Could not find section '{0}'".format(section_title)) + return + + # Get the subsection by index + try: + subsection_index = self._subsection_titles(section_index + 1).index(subsection_title) + except ValueError: + msg = "Could not find subsection '{0}' in section '{1}'".format(subsection_title, section_title) + self.warning(msg) + return + + # Convert list indices (start at zero) to CSS indices (start at 1) + subsection_css = ( + ".outline-item.section:nth-of-type({0}) .subsection:nth-of-type({1}) .outline-item" + ).format(section_index + 1, subsection_index + 1) + + # Click the subsection and ensure that the page finishes reloading + self.q(css=subsection_css).first.click() + self.courseware_page.wait_for_page() + + # TODO: TNL-6546: Remove this if/visit_unified_course_view + if self.parent_page.unified_course_view: + self.courseware_page.nav.visit_unified_course_view() + + self._wait_for_course_section(section_title, subsection_title) + + def _section_titles(self): + """ + Return a list of all section titles on the page. + """ + section_css = '.section-name span' + return self.q(css=section_css).map(lambda el: el.text.strip()).results + + def _subsection_titles(self, section_index): + """ + Return a list of all subsection titles on the page + for the section at index `section_index` (starts at 1). + """ + # Retrieve the subsection title for the section + # Add one to the list index to get the CSS index, which starts at one + subsection_css = ( + # TODO: TNL-6387: Will need to switch to this selector for subsections + # ".outline-item.section:nth-of-type({0}) .subsection span:nth-of-type(1)" + ".outline-item.section:nth-of-type({0}) .subsection a" + ).format(section_index) + + return self.q( + css=subsection_css + ).map( + lambda el: el.get_attribute('innerHTML').strip() + ).results + + def _wait_for_course_section(self, section_title, subsection_title): + """ + Ensures the user navigates to the course content page with the correct section and subsection. + """ + self.wait_for( + promise_check_func=lambda: self.courseware_page.nav.is_on_section(section_title, subsection_title), + description="Waiting for course page with section '{0}' and subsection '{1}'".format(section_title, subsection_title) + ) diff --git a/common/test/acceptance/pages/lms/course_nav.py b/common/test/acceptance/pages/lms/course_nav.py deleted file mode 100644 index 154e1fb2f0..0000000000 --- a/common/test/acceptance/pages/lms/course_nav.py +++ /dev/null @@ -1,212 +0,0 @@ -""" -Course navigation page object -""" - -import re -from bok_choy.page_object import PageObject, unguarded -from bok_choy.promise import EmptyPromise - - -class CourseNavPage(PageObject): - """ - Navigate sections and sequences in the courseware. - """ - - url = None - - def is_browser_on_page(self): - return self.q(css='div.course-index').present - - @property - def sections(self): - """ - Return a dictionary representation of sections and subsections. - - Example: - - { - 'Introduction': ['Course Overview'], - 'Week 1': ['Lesson 1', 'Lesson 2', 'Homework'] - 'Final Exam': ['Final Exam'] - } - - You can use these titles in `go_to_section` to navigate to the section. - """ - # Dict to store the result - nav_dict = dict() - - section_titles = self._section_titles() - - # Get the section titles for each chapter - for sec_index, sec_title in enumerate(section_titles): - - if len(section_titles) < 1: - self.warning("Could not find subsections for '{0}'".format(sec_title)) - else: - # Add one to convert list index (starts at 0) to CSS index (starts at 1) - nav_dict[sec_title] = self._subsection_titles(sec_index + 1) - - return nav_dict - - @property - def sequence_items(self): - """ - Return a list of sequence items on the page. - Sequence items are one level below subsections in the course nav. - - Example return value: - ['Chemical Bonds Video', 'Practice Problems', 'Homework'] - """ - seq_css = 'ol#sequence-list>li>.nav-item>.sequence-tooltip' - return self.q(css=seq_css).map(self._clean_seq_titles).results - - def go_to_section(self, section_title, subsection_title): - """ - Go to the section in the courseware. - Every section must have at least one subsection, so specify - both the section and subsection title. - - Example: - go_to_section("Week 1", "Lesson 1") - """ - - # For test stability, disable JQuery animations (opening / closing menus) - self.browser.execute_script("jQuery.fx.off = true;") - - # Get the section by index - try: - sec_index = self._section_titles().index(section_title) - except ValueError: - self.warning("Could not find section '{0}'".format(section_title)) - return - - # Click the section to ensure it's open (no harm in clicking twice if it's already open) - # Add one to convert from list index to CSS index - section_css = '.course-navigation .chapter:nth-of-type({0})'.format(sec_index + 1) - self.q(css=section_css).first.click() - - # Get the subsection by index - try: - subsec_index = self._subsection_titles(sec_index + 1).index(subsection_title) - except ValueError: - msg = "Could not find subsection '{0}' in section '{1}'".format(subsection_title, section_title) - self.warning(msg) - return - - # Convert list indices (start at zero) to CSS indices (start at 1) - subsection_css = ( - ".course-navigation .chapter-content-container:nth-of-type({0}) " - ".menu-item:nth-of-type({1})" - ).format(sec_index + 1, subsec_index + 1) - - # Click the subsection and ensure that the page finishes reloading - self.q(css=subsection_css).first.click() - self._on_section_promise(section_title, subsection_title).fulfill() - - def go_to_vertical(self, vertical_title): - """ - Within a section/subsection, navigate to the vertical with `vertical_title`. - """ - - # Get the index of the item in the sequence - all_items = self.sequence_items - - try: - seq_index = all_items.index(vertical_title) - - except ValueError: - msg = "Could not find sequential '{0}'. Available sequentials: [{1}]".format( - vertical_title, ", ".join(all_items) - ) - self.warning(msg) - - else: - - # Click on the sequence item at the correct index - # Convert the list index (starts at 0) to a CSS index (starts at 1) - seq_css = "ol#sequence-list>li:nth-of-type({0})>.nav-item".format(seq_index + 1) - self.q(css=seq_css).first.click() - # Click triggers an ajax event - self.wait_for_ajax() - - def _section_titles(self): - """ - Return a list of all section titles on the page. - """ - chapter_css = '.course-navigation .chapter .group-heading' - return self.q(css=chapter_css).map(lambda el: el.text.strip()).results - - def _subsection_titles(self, section_index): - """ - Return a list of all subsection titles on the page - for the section at index `section_index` (starts at 1). - """ - # Retrieve the subsection title for the section - # Add one to the list index to get the CSS index, which starts at one - subsection_css = ( - ".course-navigation .chapter-content-container:nth-of-type({0}) " - ".menu-item a p:nth-of-type(1)" - ).format(section_index) - - # If the element is visible, we can get its text directly - # Otherwise, we need to get the HTML - # It *would* make sense to always get the HTML, but unfortunately - # the open tab has some child tags that we don't want. - return self.q( - css=subsection_css - ).map( - lambda el: el.text.strip().split('\n')[0] if el.is_displayed() else el.get_attribute('innerHTML').strip() - ).results - - def _on_section_promise(self, section_title, subsection_title): - """ - Return a `Promise` that is fulfilled when the user is on - the correct section and subsection. - """ - desc = "currently at section '{0}' and subsection '{1}'".format(section_title, subsection_title) - return EmptyPromise( - lambda: self.is_on_section(section_title, subsection_title), desc - ) - - @unguarded - def is_on_section(self, section_title, subsection_title): - """ - Return a boolean indicating whether the user is on the section and subsection - with the specified titles. - - This assumes that the currently expanded section is the one we're on - That's true right after we click the section/subsection, but not true in general - (the user could go to a section, then expand another tab). - """ - current_section_list = self.q(css='.course-navigation .chapter.is-open .group-heading').text - current_subsection_list = self.q(css='.course-navigation .chapter-content-container .menu-item.active a p').text - - if len(current_section_list) == 0: - self.warning("Could not find the current section") - return False - - elif len(current_subsection_list) == 0: - self.warning("Could not find current subsection") - return False - - else: - return ( - current_section_list[0].strip() == section_title and - current_subsection_list[0].strip().split('\n')[0] == subsection_title - ) - - # Regular expression to remove HTML span tags from a string - REMOVE_SPAN_TAG_RE = re.compile(r'(.+) tags that we don't want. + return self.q( + css=subsection_css + ).map( + lambda el: el.text.strip().split('\n')[0] if el.is_displayed() else el.get_attribute('innerHTML').strip() + ).results + + # TODO: TNL-6546: Remove method, outline no longer on courseware page + def _on_section_promise(self, section_title, subsection_title): + """ + Return a `Promise` that is fulfilled when the user is on + the correct section and subsection. + """ + desc = "currently at section '{0}' and subsection '{1}'".format(section_title, subsection_title) + return EmptyPromise( + lambda: self.is_on_section(section_title, subsection_title), desc + ) + + def go_to_outline(self): + """ + Navigates using breadcrumb to the course outline on the course home page. + + Returns CourseHomePage page object. + """ + # To avoid circular dependency, importing inside the function + from common.test.acceptance.pages.lms.course_home import CourseHomePage + + course_home_page = CourseHomePage(self.browser, self.parent_page.course_id) + self.q(css='.path a').click() + course_home_page.wait_for_page() + return course_home_page + + @unguarded + def is_on_section(self, section_title, subsection_title): + """ + Return a boolean indicating whether the user is on the section and subsection + with the specified titles. + + """ + # TODO: TNL-6546: Remove if/else; always use unified_course_view version (if) + if self.unified_course_view: + # breadcrumb location of form: "SECTION_TITLE > SUBSECTION_TITLE > SEQUENTIAL_TITLE" + bread_crumb_current = self.q(css='.position').text + if len(bread_crumb_current) != 1: + self.warning("Could not find the current bread crumb with section and subsection.") + return False + + return bread_crumb_current[0].strip().startswith(section_title + ' > ' + subsection_title + ' > ') + + else: + # This assumes that the currently expanded section is the one we're on + # That's true right after we click the section/subsection, but not true in general + # (the user could go to a section, then expand another tab). + current_section_list = self.q(css='.course-navigation .chapter.is-open .group-heading').text + current_subsection_list = self.q(css='.course-navigation .chapter-content-container .menu-item.active a p').text + + if len(current_section_list) == 0: + self.warning("Could not find the current section") + return False + + elif len(current_subsection_list) == 0: + self.warning("Could not find current subsection") + return False + + else: + return ( + current_section_list[0].strip() == section_title and + current_subsection_list[0].strip().split('\n')[0] == subsection_title + ) + + # Regular expression to remove HTML span tags from a string + REMOVE_SPAN_TAG_RE = re.compile(r'(.+) <%block name="content"> -
+
+ diff --git a/openedx/features/course_experience/templates/course_experience/course-outline-fragment.html b/openedx/features/course_experience/templates/course_experience/course-outline-fragment.html index 444778d07f..c6431043f8 100644 --- a/openedx/features/course_experience/templates/course_experience/course-outline-fragment.html +++ b/openedx/features/course_experience/templates/course_experience/course-outline-fragment.html @@ -10,7 +10,7 @@ from django.utils.translation import ugettext as _ CourseOutlineFactory('.block-tree'); -
+
    % for section in blocks.get('children') or []:
  1. % endfor
-
+ diff --git a/openedx/features/course_experience/views/course_outline.py b/openedx/features/course_experience/views/course_outline.py index c0d42f2fc9..855e40e37c 100644 --- a/openedx/features/course_experience/views/course_outline.py +++ b/openedx/features/course_experience/views/course_outline.py @@ -46,7 +46,7 @@ class CourseOutlineFragmentView(FragmentView): user=request.user, nav_depth=3, requested_fields=['children', 'display_name', 'type'], - block_types_filter=['course', 'chapter', 'vertical', 'sequential'] + block_types_filter=['course', 'chapter', 'sequential'] ) course_block_tree = all_blocks['blocks'][all_blocks['root']] # Get the root of the block tree