diff --git a/common/lib/xmodule/xmodule/js/src/sequence/display.js b/common/lib/xmodule/xmodule/js/src/sequence/display.js index 380eebcd9e..b3fb21600c 100644 --- a/common/lib/xmodule/xmodule/js/src/sequence/display.js +++ b/common/lib/xmodule/xmodule/js/src/sequence/display.js @@ -268,7 +268,17 @@ this.updatePageTitle(); sequenceLinks = this.content_container.find('a.seqnav'); sequenceLinks.click(this.goto); - this.path.text(this.el.find('.nav-item.active').data('path')); + + edx.HtmlUtils.setHtml( + this.path, + edx.HtmlUtils.template($('#sequence-breadcrumbs-tpl').text())({ + courseId: this.el.parent().data('course-id'), + blockId: this.id, + pathText: this.el.find('.nav-item.active').data('path'), + unifiedCourseView: this.path.data('unified-course-view') + }) + ); + this.sr_container.focus(); } }; diff --git a/common/static/common/templates/sequence-breadcrumbs.underscore b/common/static/common/templates/sequence-breadcrumbs.underscore new file mode 100644 index 0000000000..da2bd4ba91 --- /dev/null +++ b/common/static/common/templates/sequence-breadcrumbs.underscore @@ -0,0 +1,9 @@ +<% if (unifiedCourseView) { %> + + + <%- gettext('Return to course outline') %> + <%- gettext('Outline') %> + + > +<% } %> +<%- pathText %> 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'(.+)')] + return [part.strip() for part in self.q(css='.path .position').text[0].split('>')] def unit_title_visible(self): """ Check if unit title is visible """ @@ -319,3 +328,255 @@ class CoursewareSequentialTabPage(CoursePage): return the body of the sequential currently selected """ return self.q(css='#seq_content .xblock').text[0] + + +class CourseNavPage(PageObject): + """ + Handles navigation on the courseware pages, including sequence navigation and + breadcrumbs. + """ + + url = None + + def __init__(self, browser, parent_page): + super(CourseNavPage, self).__init__(browser) + self.parent_page = parent_page + # TODO: TNL-6546: Remove the following + self.unified_course_view = False + + def is_browser_on_page(self): + return self.parent_page.is_browser_on_page + + # TODO: TNL-6546: Remove method, outline no longer on courseware 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 + 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 + + # TODO: TNL-6546: Remove method, outline no longer on courseware page + 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() + + # TODO: TNL-6546: Remove method, outline no longer on courseware page + 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 + + # TODO: TNL-6546: Remove method, outline no longer on courseware page + 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 + + # 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'(.+) * { - @include margin-left($baseline/2); - vertical-align: middle; - height: 34px; - } - - .form-actions > button { - height: 34px; - } - .form-actions > *:first-child { @include margin-left(0); } diff --git a/lms/templates/courseware/courseware.html b/lms/templates/courseware/courseware.html index c82ca94ac2..210d695eed 100644 --- a/lms/templates/courseware/courseware.html +++ b/lms/templates/courseware/courseware.html @@ -3,6 +3,8 @@ <%namespace name='static' file='/static_content.html'/> <%def name="online_help_token()"><% return "courseware" %> <%! +import waffle + from django.utils.translation import ugettext as _ from django.conf import settings @@ -27,7 +29,7 @@ from openedx.core.djangolib.js_utils import js_escaped_string <%block name="header_extras"> -% for template_name in ["image-modal"]: +% for template_name in ["image-modal", "sequence-breadcrumbs"]: @@ -152,7 +154,10 @@ ${HTML(fragment.foot_html())} % endif
-
+
% if getattr(course, 'entrance_exam_enabled') and \ getattr(course, 'entrance_exam_minimum_score_pct') and \ entrance_exam_current_score is not UNDEFINED: diff --git a/lms/urls.py b/lms/urls.py index e5b6736bdd..cdcb0b8b34 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -601,10 +601,19 @@ urlpatterns += ( name='edxnotes_endpoints', ), + # Branding API url( r'^api/branding/v1/', include('branding.api_urls') ), + + # Course experience + url( + r'^courses/{}/course/'.format( + settings.COURSE_ID_PATTERN, + ), + include('openedx.features.course_experience.urls'), + ), ) if settings.FEATURES["ENABLE_TEAMS"]: diff --git a/openedx/features/README.rst b/openedx/features/README.rst new file mode 100644 index 0000000000..ce592b57f0 --- /dev/null +++ b/openedx/features/README.rst @@ -0,0 +1,6 @@ +Open EdX Features +----------------- + +This is the root package for Open edX features that extend the edX platform. +The intention is that these features would ideally live in an external +repository, but for now they live in edx-platform but are cleanly modularized. diff --git a/openedx/features/__init__.py b/openedx/features/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openedx/features/course_experience/__init__.py b/openedx/features/course_experience/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openedx/features/course_experience/static/course_experience/fixtures/course-outline-fragment.html b/openedx/features/course_experience/static/course_experience/fixtures/course-outline-fragment.html new file mode 100644 index 0000000000..45e64be1f3 --- /dev/null +++ b/openedx/features/course_experience/static/course_experience/fixtures/course-outline-fragment.html @@ -0,0 +1,124 @@ +
+
    +
  1. +
    + + Introduction +
    +
      +
    1. + + Demo Course Overview + +
    2. +
    +
  2. +
  3. +
    + + Example Week 1: Getting Started +
    +
      +
    1. + + Lesson 1 - Getting Started + +
    2. +
    3. + + Homework - Question Styles + +
    4. +
    +
  4. +
  5. +
    + + Example Week 2: Get Interactive +
    +
      +
    1. + + Lesson 2 - Let's Get Interactive! + +
    2. +
    3. + + Homework - Labs and Demos + +
    4. +
    5. + + Homework - Essays + +
    6. +
    +
  6. +
  7. +
    + + Example Week 3: Be Social +
    +
      +
    1. + + Lesson 3 - Be Social + +
    2. +
    3. + + Homework - Find Your Study Buddy + +
    4. +
    5. + + More Ways to Connect + +
    6. +
    +
  8. +
  9. +
    + + About Exams and Certificates +
    +
      +
    1. + + edX Exams + +
    2. +
    +
  10. +
  11. +
    + + holding section +
    +
      +
    1. + + New Subsection + +
    2. +
    +
  12. +
+
diff --git a/openedx/features/course_experience/static/course_experience/js/course_outline_factory.js b/openedx/features/course_experience/static/course_experience/js/course_outline_factory.js new file mode 100644 index 0000000000..3f2d3643c5 --- /dev/null +++ b/openedx/features/course_experience/static/course_experience/js/course_outline_factory.js @@ -0,0 +1,29 @@ +(function(define) { + 'use strict'; + + define([ + 'jquery', + 'edx-ui-toolkit/js/utils/constants' + ], + function($, constants) { + return function(root) { + // In the future this factory could instantiate a Backbone view or React component that handles events + $(root).keydown(function(event) { + var $focusable = $('.outline-item.focusable'), + currentFocusIndex = $.inArray(event.target, $focusable); + + switch (event.keyCode) { // eslint-disable-line default-case + case constants.keyCodes.down: + event.preventDefault(); + $focusable.eq(Math.min(currentFocusIndex + 1, $focusable.length - 1)).focus(); + break; + case constants.keyCodes.up: + event.preventDefault(); + $focusable.eq(Math.max(currentFocusIndex - 1, 0)).focus(); + break; + } + }); + }; + } + ); +}).call(this, define || RequireJS.define); diff --git a/openedx/features/course_experience/static/course_experience/js/spec/course_outline_factory_spec.js b/openedx/features/course_experience/static/course_experience/js/spec/course_outline_factory_spec.js new file mode 100644 index 0000000000..ccea16b8f2 --- /dev/null +++ b/openedx/features/course_experience/static/course_experience/js/spec/course_outline_factory_spec.js @@ -0,0 +1,86 @@ +define([ + 'jquery', + 'edx-ui-toolkit/js/utils/constants', + 'course_experience/js/course_outline_factory' +], + function($, constants, CourseOutlineFactory) { + 'use strict'; + + describe('Course outline factory', function() { + describe('keyboard listener', function() { + var triggerKeyListener = function(current, destination, keyCode) { + current.focus(); + spyOn(destination, 'focus'); + + $('.block-tree').trigger($.Event('keydown', { + keyCode: keyCode, + target: current + })); + }; + + beforeEach(function() { + loadFixtures('course_experience/fixtures/course-outline-fragment.html'); + CourseOutlineFactory('.block-tree'); + }); + + describe('when the down arrow is pressed', function() { + it('moves focus from a subsection to the next subsection in the outline', function() { + var current = $('a.focusable:contains("Homework - Labs and Demos")')[0], + destination = $('a.focusable:contains("Homework - Essays")')[0]; + + triggerKeyListener(current, destination, constants.keyCodes.down); + + expect(destination.focus).toHaveBeenCalled(); + }); + + it('moves focus to the section list if at a section boundary', function() { + var current = $('li.focusable:contains("Example Week 3: Be Social")')[0], + destination = $('ol.focusable:contains("Lesson 3 - Be Social")')[0]; + + triggerKeyListener(current, destination, constants.keyCodes.down); + + expect(destination.focus).toHaveBeenCalled(); + }); + + it('moves focus to the next section if on the last subsection', function() { + var current = $('a.focusable:contains("Homework - Essays")')[0], + destination = $('li.focusable:contains("Example Week 3: Be Social")')[0]; + + triggerKeyListener(current, destination, constants.keyCodes.down); + + expect(destination.focus).toHaveBeenCalled(); + }); + }); + + describe('when the up arrow is pressed', function() { + it('moves focus from a subsection to the previous subsection in the outline', function() { + var current = $('a.focusable:contains("Homework - Essays")')[0], + destination = $('a.focusable:contains("Homework - Labs and Demos")')[0]; + + triggerKeyListener(current, destination, constants.keyCodes.up); + + expect(destination.focus).toHaveBeenCalled(); + }); + + it('moves focus to the section group if at the first subsection', function() { + var current = $('a.focusable:contains("Lesson 3 - Be Social")')[0], + destination = $('ol.focusable:contains("Lesson 3 - Be Social")')[0]; + + triggerKeyListener(current, destination, constants.keyCodes.up); + + expect(destination.focus).toHaveBeenCalled(); + }); + + it('moves focus last subsection of the previous section if at a section boundary', function() { + var current = $('li.focusable:contains("Example Week 3: Be Social")')[0], + destination = $('a.focusable:contains("Homework - Essays")')[0]; + + triggerKeyListener(current, destination, constants.keyCodes.up); + + expect(destination.focus).toHaveBeenCalled(); + }); + }); + }); + }); + } +); diff --git a/openedx/features/course_experience/templates/course_experience/course-home.html b/openedx/features/course_experience/templates/course_experience/course-home.html new file mode 100644 index 0000000000..8744adea38 --- /dev/null +++ b/openedx/features/course_experience/templates/course_experience/course-home.html @@ -0,0 +1,66 @@ +## mako + +<%! main_css = "style-main-v2" %> + +<%page expression_filter="h"/> +<%inherit file="../main.html" /> +<%namespace name='static' file='../static_content.html'/> +<%def name="online_help_token()"><% return "courseware" %> +<%def name="course_name()"> +<% return _("{course_number} Courseware").format(course_number=course.display_number_with_default) %> + + +<%! +import json +from django.utils.translation import ugettext as _ +from django.template.defaultfilters import escapejs +from django.core.urlresolvers import reverse + +from django_comment_client.permissions import has_permission +from openedx.core.djangolib.js_utils import dump_js_escaped_json, js_escaped_string +from openedx.core.djangolib.markup import HTML +%> + +<%block name="bodyclass">course + +<%block name="pagetitle">${course_name()} + +<%include file="../courseware/course_navigation.html" args="active_page='courseware'" /> + +<%block name="headextra"> +${HTML(outline_fragment.head_html())} + + +<%block name="js_extra"> +${HTML(outline_fragment.foot_html())} + + +<%block name="content"> +
+ +
+ ${HTML(outline_fragment.body_html())} +
+
+ 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 new file mode 100644 index 0000000000..c6431043f8 --- /dev/null +++ b/openedx/features/course_experience/templates/course_experience/course-outline-fragment.html @@ -0,0 +1,42 @@ +## mako + +<%namespace name='static' file='../static_content.html'/> + +<%! +from django.utils.translation import ugettext as _ +%> + +<%static:require_module_async module_name="course_experience/js/course_outline_factory" class_name="CourseOutlineFactory"> + CourseOutlineFactory('.block-tree'); + + +
+
    + % for section in blocks.get('children') or []: +
  1. +
    + ${ section['display_name'] } +
    +
      + % for subsection in section.get('children') or []: +
    1. + + ${ subsection['display_name'] } + +
    2. + % endfor +
    +
  2. + % endfor +
+
diff --git a/openedx/features/course_experience/tests/__init__.py b/openedx/features/course_experience/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openedx/features/course_experience/tests/views/__init__.py b/openedx/features/course_experience/tests/views/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openedx/features/course_experience/tests/views/test_course_outline.py b/openedx/features/course_experience/tests/views/test_course_outline.py new file mode 100644 index 0000000000..76a6cfa456 --- /dev/null +++ b/openedx/features/course_experience/tests/views/test_course_outline.py @@ -0,0 +1,72 @@ +""" +Tests for the Course Outline view and supporting views. +""" +from django.core.urlresolvers import reverse + +from student.models import CourseEnrollment +from student.tests.factories import UserFactory + +from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory + + +class TestCourseOutlinePage(SharedModuleStoreTestCase): + """ + Test the new course outline view. + """ + @classmethod + def setUpClass(cls): + """Set up the simplest course possible.""" + # setUpClassAndTestData() already calls setUpClass on SharedModuleStoreTestCase + # pylint: disable=super-method-not-called + with super(TestCourseOutlinePage, cls).setUpClassAndTestData(): + cls.courses = [] + course = CourseFactory.create() + with cls.store.bulk_operations(course.id): + chapter = ItemFactory.create(category='chapter', parent_location=course.location) + section = ItemFactory.create(category='sequential', parent_location=chapter.location) + ItemFactory.create(category='vertical', parent_location=section.location) + + cls.courses.append(course) + + course = CourseFactory.create() + with cls.store.bulk_operations(course.id): + chapter = ItemFactory.create(category='chapter', parent_location=course.location) + section = ItemFactory.create(category='sequential', parent_location=chapter.location) + section2 = ItemFactory.create(category='sequential', parent_location=chapter.location) + ItemFactory.create(category='vertical', parent_location=section.location) + ItemFactory.create(category='vertical', parent_location=section2.location) + + @classmethod + def setUpTestData(cls): + """Set up and enroll our fake user in the course.""" + cls.password = 'test' + cls.user = UserFactory(password=cls.password) + for course in cls.courses: + CourseEnrollment.enroll(cls.user, course.id) + + def setUp(self): + """ + Set up for the tests. + """ + super(TestCourseOutlinePage, self).setUp() + self.client.login(username=self.user.username, password=self.password) + + def test_render(self): + for course in self.courses: + url = reverse( + 'edx.course_experience.course_home', + kwargs={ + 'course_id': unicode(course.id), + } + ) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + response_content = response.content.decode("utf-8") + + for chapter in course.children: + self.assertIn(chapter.display_name, response_content) + for section in chapter.children: + self.assertIn(section.display_name, response_content) + for vertical in section.children: + self.assertNotIn(vertical.display_name, response_content) diff --git a/openedx/features/course_experience/urls.py b/openedx/features/course_experience/urls.py new file mode 100644 index 0000000000..3e25fd59ba --- /dev/null +++ b/openedx/features/course_experience/urls.py @@ -0,0 +1,21 @@ +""" +Defines URLs for the course experience. +""" + +from django.conf.urls import url + +from views.course_home import CourseHomeView +from views.course_outline import CourseOutlineFragmentView + +urlpatterns = [ + url( + r'^$', + CourseHomeView.as_view(), + name='edx.course_experience.course_home', + ), + url( + r'^outline_fragment$', + CourseOutlineFragmentView.as_view(), + name='edx.course_experience.course_outline_fragment_view', + ), +] diff --git a/openedx/features/course_experience/views/__init__.py b/openedx/features/course_experience/views/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openedx/features/course_experience/views/course_home.py b/openedx/features/course_experience/views/course_home.py new file mode 100644 index 0000000000..343f245d26 --- /dev/null +++ b/openedx/features/course_experience/views/course_home.py @@ -0,0 +1,50 @@ +""" +Views for the course home page. +""" + +from django.contrib.auth.decorators import login_required +from django.core.context_processors import csrf +from django.shortcuts import render_to_response +from django.utils.decorators import method_decorator +from django.views.decorators.cache import cache_control +from django.views.decorators.csrf import ensure_csrf_cookie +from django.views.generic import View + +from courseware.courses import get_course_with_access +from opaque_keys.edx.keys import CourseKey +from util.views import ensure_valid_course_key + +from course_outline import CourseOutlineFragmentView + + +class CourseHomeView(View): + """ + The home page for a course. + """ + @method_decorator(login_required) + @method_decorator(ensure_csrf_cookie) + @method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True)) + @method_decorator(ensure_valid_course_key) + def get(self, request, course_id): + """ + Displays the home page for the specified course. + + Arguments: + request: HTTP request + course_id (unicode): course id + """ + course_key = CourseKey.from_string(course_id) + course = get_course_with_access(request.user, 'load', course_key, check_if_enrolled=True) + + # Render the outline as a fragment + outline_fragment = CourseOutlineFragmentView().render_to_fragment(request, course_id=course_id) + + # Render the entire unified course view + context = { + 'csrf': csrf(request)['csrf_token'], + 'course': course, + 'outline_fragment': outline_fragment, + 'disable_courseware_js': True, + 'uses_pattern_library': True, + } + return render_to_response('course_experience/course-home.html', context) diff --git a/openedx/features/course_experience/views/course_outline.py b/openedx/features/course_experience/views/course_outline.py new file mode 100644 index 0000000000..855e40e37c --- /dev/null +++ b/openedx/features/course_experience/views/course_outline.py @@ -0,0 +1,61 @@ +""" +Views to show a course outline. +""" + +from django.core.context_processors import csrf +from django.template.loader import render_to_string + +from courseware.courses import get_course_with_access +from lms.djangoapps.course_api.blocks.api import get_blocks +from opaque_keys.edx.keys import CourseKey +from web_fragments.fragment import Fragment +from web_fragments.views import FragmentView +from xmodule.modulestore.django import modulestore + + +class CourseOutlineFragmentView(FragmentView): + """ + Course outline fragment to be shown in the unified course view. + """ + + def populate_children(self, block, all_blocks): + """ + For a passed block, replace each id in its children array with the full representation of that child, + which will be looked up by id in the passed all_blocks dict. + Recursively do the same replacement for children of those children. + """ + children = block.get('children') or [] + + for i in range(len(children)): + child_id = block['children'][i] + child_detail = self.populate_children(all_blocks[child_id], all_blocks) + block['children'][i] = child_detail + + return block + + def render_to_fragment(self, request, course_id=None, **kwargs): + """ + Renders the course outline as a fragment. + """ + course_key = CourseKey.from_string(course_id) + course = get_course_with_access(request.user, 'load', course_key, check_if_enrolled=True) + course_usage_key = modulestore().make_course_usage_key(course_key) + all_blocks = get_blocks( + request, + course_usage_key, + user=request.user, + nav_depth=3, + requested_fields=['children', 'display_name', 'type'], + block_types_filter=['course', 'chapter', 'sequential'] + ) + + course_block_tree = all_blocks['blocks'][all_blocks['root']] # Get the root of the block tree + + context = { + 'csrf': csrf(request)['csrf_token'], + 'course': course, + # Recurse through the block tree, fleshing out each child object + 'blocks': self.populate_children(course_block_tree, all_blocks['blocks']) + } + html = render_to_string('course_experience/course-outline-fragment.html', context) + return Fragment(html) diff --git a/pavelib/quality.py b/pavelib/quality.py index 7f1c12ceb4..8228ac8fc7 100644 --- a/pavelib/quality.py +++ b/pavelib/quality.py @@ -5,13 +5,20 @@ from paver.easy import sh, task, cmdopts, needs, BuildFailure import json import os import re +from string import join from openedx.core.djangolib.markup import HTML from .utils.envs import Env from .utils.timer import timed -ALL_SYSTEMS = 'lms,cms,common,openedx,pavelib' +ALL_SYSTEMS = [ + 'cms', + 'common', + 'lms', + 'openedx', + 'pavelib', +] def top_python_dirs(dirname): @@ -45,7 +52,7 @@ def find_fixme(options): Run pylint on system code, only looking for fixme items. """ num_fixme = 0 - systems = getattr(options, 'system', ALL_SYSTEMS).split(',') + systems = getattr(options, 'system', '').split(',') or ALL_SYSTEMS for system in systems: # Directory to put the pylint report in. @@ -93,7 +100,7 @@ def run_pylint(options): num_violations = 0 violations_limit = int(getattr(options, 'limit', -1)) errors = getattr(options, 'errors', False) - systems = getattr(options, 'system', ALL_SYSTEMS).split(',') + systems = getattr(options, 'system', '').split(',') or ALL_SYSTEMS # Make sure the metrics subdirectory exists Env.METRICS_DIR.makedirs_p() @@ -234,7 +241,7 @@ def run_complexity(): Uses radon to examine cyclomatic complexity. For additional details on radon, see http://radon.readthedocs.org/ """ - system_string = 'cms/ lms/ common/ openedx/' + system_string = join(ALL_SYSTEMS, '/ ') + '/' complexity_report_dir = (Env.REPORT_DIR / "complexity") complexity_report = complexity_report_dir / "python_complexity.log"