From 75d89e811543fece03f5ab2df213ffdc7037d482 Mon Sep 17 00:00:00 2001 From: Andy Armstrong Date: Mon, 30 Jan 2017 22:52:03 -0500 Subject: [PATCH 01/11] Implement a stub unified course tab --- lms/djangoapps/courseware/tabs.py | 14 +++- lms/djangoapps/courseware/views/views.py | 53 ++++++++++++++ lms/templates/courseware/course-outline.html | 12 ++++ .../courseware/unified-course-view.html | 72 +++++++++++++++++++ lms/urls.py | 15 ++++ 5 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 lms/templates/courseware/course-outline.html create mode 100644 lms/templates/courseware/unified-course-view.html diff --git a/lms/djangoapps/courseware/tabs.py b/lms/djangoapps/courseware/tabs.py index 2da310d4f3..919cc8fccd 100644 --- a/lms/djangoapps/courseware/tabs.py +++ b/lms/djangoapps/courseware/tabs.py @@ -2,6 +2,8 @@ This module is essentially a broker to xmodule/tabs.py -- it was originally introduced to perform some LMS-specific tab display gymnastics for the Entrance Exams feature """ +import waffle + from django.conf import settings from django.utils.translation import ugettext as _, ugettext_noop @@ -9,7 +11,7 @@ from courseware.access import has_access from courseware.entrance_exams import user_can_skip_entrance_exam from openedx.core.lib.course_tabs import CourseTabPluginManager from student.models import CourseEnrollment -from xmodule.tabs import CourseTab, CourseTabList, key_checker +from xmodule.tabs import CourseTab, CourseTabList, key_checker, link_reverse_func class EnrolledTab(CourseTab): @@ -34,6 +36,16 @@ class CoursewareTab(EnrolledTab): is_movable = False is_default = False + @property + def link_func(self): + """ + Returns a function that computes the URL for this tab. + """ + if waffle.switch_is_active('unified_course_view'): + return link_reverse_func('unified_course_view') + else: + return link_reverse_func('courseware') + class CourseInfoTab(CourseTab): """ diff --git a/lms/djangoapps/courseware/views/views.py b/lms/djangoapps/courseware/views/views.py index 482b6dddd4..57a6e88251 100644 --- a/lms/djangoapps/courseware/views/views.py +++ b/lms/djangoapps/courseware/views/views.py @@ -25,6 +25,7 @@ from django.http import ( QueryDict, ) from django.shortcuts import redirect +from django.template.loader import render_to_string from django.utils.decorators import method_decorator from django.utils.timezone import UTC from django.utils.translation import ugettext as _ @@ -1623,3 +1624,55 @@ def financial_assistance_form(request): } ], }) + + +class UnifiedCourseView(View): + """ + Unified view 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 main view 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_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('courseware/unified-course-view.html', context) + + +class CourseOutlineFragmentView(FragmentView): + """ + Course outline fragment to be shown in the unified course view. + """ + + def render_fragment(self, request, course_id=None): + """ + 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) + context = { + 'csrf': csrf(request)['csrf_token'], + 'course': course, + } + html = render_to_string('courseware/course-outline.html', context) + return Fragment(html) diff --git a/lms/templates/courseware/course-outline.html b/lms/templates/courseware/course-outline.html new file mode 100644 index 0000000000..09ff7acfbf --- /dev/null +++ b/lms/templates/courseware/course-outline.html @@ -0,0 +1,12 @@ +## mako + +<%namespace name='static' file='../static_content.html'/> + +<%! +import json +from django.utils.translation import ugettext as _ +%> + +
+

Hello, world!

+
diff --git a/lms/templates/courseware/unified-course-view.html b/lms/templates/courseware/unified-course-view.html new file mode 100644 index 0000000000..27d1b07241 --- /dev/null +++ b/lms/templates/courseware/unified-course-view.html @@ -0,0 +1,72 @@ +## 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/lms/urls.py b/lms/urls.py index e5b6736bdd..2583fc9a77 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -11,6 +11,7 @@ from django.conf.urls.static import static from courseware.views.views import CourseTabView, EnrollStaffView, StaticCourseTabView from config_models.views import ConfigurationModelCurrentAPIView from courseware.views.index import CoursewareIndex +from courseware.views.views import UnifiedCourseView, CourseOutlineFragmentView from openedx.core.djangoapps.auth_exchange.views import LoginWithAccessTokenView from openedx.core.djangoapps.catalog.models import CatalogIntegration from openedx.core.djangoapps.programs.models import ProgramsApiConfig @@ -379,6 +380,20 @@ urlpatterns += ( name='html_book', ), + url( + r'^courses/{}/course/?$'.format( + settings.COURSE_ID_PATTERN, + ), + UnifiedCourseView.as_view(), + name='unified_course_view', + ), + url( + r'^courses/{}/course/outline?$'.format( + settings.COURSE_ID_PATTERN, + ), + CourseOutlineFragmentView.as_view(), + name='course_outline_fragment_view', + ), url( r'^courses/{}/courseware/?$'.format( settings.COURSE_ID_PATTERN, From 4be657c1badfa8ec3781da54aa8236e5e17c4847 Mon Sep 17 00:00:00 2001 From: Brian Jacobel Date: Mon, 6 Feb 2017 16:27:33 -0500 Subject: [PATCH 02/11] Get the block tree and display it on the outline page --- lms/djangoapps/courseware/views/views.py | 30 ++++++++++++++++++++ lms/templates/courseware/course-outline.html | 17 ++++++++++- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/lms/djangoapps/courseware/views/views.py b/lms/djangoapps/courseware/views/views.py index 57a6e88251..88c365f46a 100644 --- a/lms/djangoapps/courseware/views/views.py +++ b/lms/djangoapps/courseware/views/views.py @@ -45,6 +45,7 @@ from lms.djangoapps.grades.new.course_grade import CourseGradeFactory from lms.djangoapps.instructor.enrollment import uses_shib from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification from lms.djangoapps.ccx.custom_exception import CCXLocatorValidationException +from lms.djangoapps.course_api.blocks.api import get_blocks import shoppingcart import survey.utils @@ -1664,15 +1665,44 @@ 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_fragment(self, request, course_id=None): """ 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', 'vertical', '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('courseware/course-outline.html', context) return Fragment(html) diff --git a/lms/templates/courseware/course-outline.html b/lms/templates/courseware/course-outline.html index 09ff7acfbf..66559d7052 100644 --- a/lms/templates/courseware/course-outline.html +++ b/lms/templates/courseware/course-outline.html @@ -4,9 +4,24 @@ <%! import json +import pprint from django.utils.translation import ugettext as _ %>
-

Hello, world!

+
    + % for unit in blocks.get('children') or []: +
  • ${ unit['display_name'] }
  • +
      + % for section in unit.get('children') or []: +
    • ${ section['display_name'] }
    • +
        + % for subsection in section.get('children') or []: +
      • ${ subsection['display_name'] }
      • + % endfor +
      + % endfor +
    + % endfor +
From db6c2fe8bf2abd3a6a2a81f42d25b688f19191ff Mon Sep 17 00:00:00 2001 From: Brian Jacobel Date: Tue, 7 Feb 2017 11:27:40 -0500 Subject: [PATCH 03/11] Outline will only show section and subsection, not unit --- lms/envs/common.py | 1 + .../js/courseware/course_outline_factory.js | 29 +++++++++++++++++++ lms/templates/courseware/course-outline.html | 25 +++++++--------- requirements/edx/base.txt | 2 ++ 4 files changed, 43 insertions(+), 14 deletions(-) create mode 100644 lms/static/js/courseware/course_outline_factory.js diff --git a/lms/envs/common.py b/lms/envs/common.py index 562c4c1de8..de3897913d 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -1736,6 +1736,7 @@ REQUIRE_JS_PATH_OVERRIDES = { 'js/student_account/logistration_factory': 'js/student_account/logistration_factory.js', 'js/student_profile/views/learner_profile_factory': 'js/student_profile/views/learner_profile_factory.js', 'js/courseware/courseware_factory': 'js/courseware/courseware_factory.js', + 'js/courseware/course_outline_factory': 'js/courseware/course_outline_factory.js', 'js/groups/views/cohorts_dashboard_factory': 'js/groups/views/cohorts_dashboard_factory.js', 'draggabilly': 'js/vendor/draggabilly.js' } diff --git a/lms/static/js/courseware/course_outline_factory.js b/lms/static/js/courseware/course_outline_factory.js new file mode 100644 index 0000000000..3f2d3643c5 --- /dev/null +++ b/lms/static/js/courseware/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/lms/templates/courseware/course-outline.html b/lms/templates/courseware/course-outline.html index 66559d7052..2e1d9f2c53 100644 --- a/lms/templates/courseware/course-outline.html +++ b/lms/templates/courseware/course-outline.html @@ -3,23 +3,20 @@ <%namespace name='static' file='../static_content.html'/> <%! -import json -import pprint from django.utils.translation import ugettext as _ %> -
-
    - % for unit in blocks.get('children') or []: -
  • ${ unit['display_name'] }
  • -
      - % for section in unit.get('children') or []: -
    • ${ section['display_name'] }
    • -
        - % for subsection in section.get('children') or []: -
      • ${ subsection['display_name'] }
      • - % endfor -
      +<%static:require_module_async module_name="js/courseware/course_outline_factory" class_name="CourseOutlineFactory"> + CourseOutlineFactory('.block-tree'); + + +
      +
        + % for section in blocks.get('children') or []: +
      • ${ section['display_name'] }
      • +
          + % for subsection in section.get('children') or []: +
        • ${ subsection['display_name'] }
        • % endfor
        % endfor diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index b530997765..8cbb0b4083 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -111,7 +111,9 @@ django-ratelimit-backend==1.0 unicodecsv==0.9.4 django-require==1.0.11 pyuca==1.1 +web-fragments==0.1.0 wrapt==1.10.5 +XBlock==0.4.14 zendesk==1.1.1 # This needs to be installed *after* Cython, which is in pre.txt From f531205cd91b50c835049bcdcc3e440f6831909b Mon Sep 17 00:00:00 2001 From: alisan617 Date: Wed, 8 Feb 2017 17:24:40 -0500 Subject: [PATCH 04/11] styling --- lms/static/sass/_build-lms-v2.scss | 3 ++ .../sass/shared-v2/_course-outline.scss | 44 +++++++++++++++++++ lms/templates/courseware/course-outline.html | 9 +++- 3 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 lms/static/sass/shared-v2/_course-outline.scss diff --git a/lms/static/sass/_build-lms-v2.scss b/lms/static/sass/_build-lms-v2.scss index e12692dd48..342ead67f7 100644 --- a/lms/static/sass/_build-lms-v2.scss +++ b/lms/static/sass/_build-lms-v2.scss @@ -20,3 +20,6 @@ @import 'shared-v2/help-tab'; @import 'notifications'; + +// course outline +@import 'shared-v2/course-outline'; diff --git a/lms/static/sass/shared-v2/_course-outline.scss b/lms/static/sass/shared-v2/_course-outline.scss new file mode 100644 index 0000000000..8bf1ea112d --- /dev/null +++ b/lms/static/sass/shared-v2/_course-outline.scss @@ -0,0 +1,44 @@ +.course-outline { + color: $lms-gray; + + ul { + margin: 0 $baseline; + list-style: none; + + > ul { + @include margin-left($baseline / 2); + } + + li.section-name { + @include padding($baseline * 0.75, $baseline * 0.75, $baseline * 0.75, $baseline / 4); + background-color: $lms-background-color; + border-top: 1px solid $lms-border-color; + } + + ul.outline-item { + @include margin-left($baseline); + padding-bottom: ($baseline / 2); + + li { + + a { + padding: ($baseline / 4) ($baseline * 1.5); + display: block; + + &:hover { + background-color: $lms-background-color; + text-decoration: none; + } + } + } + } + + .icon { + margin: 0 ($baseline * 0.75); + position: relative; + top: -2px; + font-size: 12px; + } + } + +} diff --git a/lms/templates/courseware/course-outline.html b/lms/templates/courseware/course-outline.html index 2e1d9f2c53..66af18f66a 100644 --- a/lms/templates/courseware/course-outline.html +++ b/lms/templates/courseware/course-outline.html @@ -13,10 +13,15 @@ from django.utils.translation import ugettext as _
          % for section in blocks.get('children') or []: -
        • ${ section['display_name'] }
        • +
        • + + ${ section['display_name'] } +
        • % endfor From b63472520cdd982b5693ed96ddbbba89d22c8fdd Mon Sep 17 00:00:00 2001 From: Brian Jacobel Date: Tue, 14 Feb 2017 14:46:58 -0500 Subject: [PATCH 05/11] Link outline subsections into the course --- .../xmodule/js/src/sequence/display.js | 11 ++++++- .../templates/sequence-breadcrumbs.underscore | 7 ++++ .../test/acceptance/pages/lms/courseware.py | 2 +- lms/djangoapps/courseware/static_tab.html | 33 +++++++++++++++++++ lms/djangoapps/courseware/tabs.py | 4 ++- lms/djangoapps/courseware/views/index.py | 6 +++- lms/djangoapps/courseware/views/views.py | 4 +-- .../sass/shared-v2/_course-outline.scss | 9 ++--- lms/templates/courseware/course-outline.html | 29 ---------------- lms/templates/courseware/courseware.html | 2 +- 10 files changed, 67 insertions(+), 40 deletions(-) create mode 100644 common/static/common/templates/sequence-breadcrumbs.underscore create mode 100644 lms/djangoapps/courseware/static_tab.html delete mode 100644 lms/templates/courseware/course-outline.html diff --git a/common/lib/xmodule/xmodule/js/src/sequence/display.js b/common/lib/xmodule/xmodule/js/src/sequence/display.js index 380eebcd9e..a0c1407943 100644 --- a/common/lib/xmodule/xmodule/js/src/sequence/display.js +++ b/common/lib/xmodule/xmodule/js/src/sequence/display.js @@ -268,7 +268,16 @@ 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') + }) + ); + 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..c66e14b334 --- /dev/null +++ b/common/static/common/templates/sequence-breadcrumbs.underscore @@ -0,0 +1,7 @@ + + + <%- gettext('Return to course outline') %> + <%- gettext('Outline') %> + + > +<%- pathText %> diff --git a/common/test/acceptance/pages/lms/courseware.py b/common/test/acceptance/pages/lms/courseware.py index d3807324da..39e39ffea4 100644 --- a/common/test/acceptance/pages/lms/courseware.py +++ b/common/test/acceptance/pages/lms/courseware.py @@ -274,7 +274,7 @@ class CoursewarePage(CoursePage): @property def breadcrumb(self): """ Return the course tree breadcrumb shown above the sequential bar """ - return [part.strip() for part in self.q(css='.path').text[0].split('>')] + 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 """ diff --git a/lms/djangoapps/courseware/static_tab.html b/lms/djangoapps/courseware/static_tab.html new file mode 100644 index 0000000000..3d977bc017 --- /dev/null +++ b/lms/djangoapps/courseware/static_tab.html @@ -0,0 +1,33 @@ +## mako + +<%page expression_filter="h"/> +<%! +from openedx.core.djangolib.markup import HTML +%> + +<%inherit file="/main.html" /> +<%block name="bodyclass">view-in-course view-statictab ${course.css_class or ''} +<%namespace name='static' file='/static_content.html'/> + +<%block name="headextra"> +<%static:css group='style-course-vendor'/> +<%static:css group='style-course'/> +${HTML(fragment.head_html())} + +<%block name="js_extra"> + +<%include file="/mathjax_include.html" args="disable_fast_preview=True"/> +${HTML(fragment.foot_html())} + + +<%block name="pagetitle">${tab['name']} | ${course.display_number_with_default} + +<%include file="/courseware/course_navigation.html" args="active_page=active_page" /> + +
          +
          +
          + ${HTML(fragment.body_html())} +
          +
          +
          diff --git a/lms/djangoapps/courseware/tabs.py b/lms/djangoapps/courseware/tabs.py index 919cc8fccd..09d36c463b 100644 --- a/lms/djangoapps/courseware/tabs.py +++ b/lms/djangoapps/courseware/tabs.py @@ -10,6 +10,7 @@ from django.utils.translation import ugettext as _, ugettext_noop from courseware.access import has_access from courseware.entrance_exams import user_can_skip_entrance_exam from openedx.core.lib.course_tabs import CourseTabPluginManager +from request_cache.middleware import RequestCache from student.models import CourseEnrollment from xmodule.tabs import CourseTab, CourseTabList, key_checker, link_reverse_func @@ -41,7 +42,8 @@ class CoursewareTab(EnrolledTab): """ Returns a function that computes the URL for this tab. """ - if waffle.switch_is_active('unified_course_view'): + request = RequestCache.get_current_request() + if waffle.flag_is_active(request, 'unified_course_view'): return link_reverse_func('unified_course_view') else: return link_reverse_func('courseware') diff --git a/lms/djangoapps/courseware/views/index.py b/lms/djangoapps/courseware/views/index.py index 8cf7eee08f..a836cd35d6 100644 --- a/lms/djangoapps/courseware/views/index.py +++ b/lms/djangoapps/courseware/views/index.py @@ -28,6 +28,7 @@ except ImportError: newrelic = None # pylint: disable=invalid-name import urllib +import waffle from lms.djangoapps.gating.api import get_entrance_exam_score_ratio, get_entrance_exam_usage_key from lms.djangoapps.grades.new.course_grade import CourseGradeFactory @@ -35,6 +36,7 @@ from opaque_keys.edx.keys import CourseKey from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY from openedx.core.djangoapps.user_api.preferences.api import get_user_preference from openedx.core.djangoapps.crawlers.models import CrawlersConfig +from request_cache.middleware import RequestCache from shoppingcart.models import CourseRegistrationCode from student.models import CourseEnrollment from student.views import is_course_blocked @@ -402,6 +404,7 @@ class CoursewareIndex(View): Returns and creates the rendering context for the courseware. Also returns the table of contents for the courseware. """ + request = RequestCache.get_current_request() courseware_context = { 'csrf': csrf(self.request)['csrf_token'], 'COURSE_TITLE': self.course.display_name_with_default_escaped, @@ -417,7 +420,8 @@ class CoursewareIndex(View): 'language_preference': self._get_language_preference(), 'disable_optimizely': True, 'section_title': None, - 'sequence_title': None + 'sequence_title': None, + 'disable_accordion': waffle.flag_is_active(request, 'unified_course_view') } table_of_contents = toc_for_course( self.effective_user, diff --git a/lms/djangoapps/courseware/views/views.py b/lms/djangoapps/courseware/views/views.py index 88c365f46a..adb8659f9d 100644 --- a/lms/djangoapps/courseware/views/views.py +++ b/lms/djangoapps/courseware/views/views.py @@ -1680,7 +1680,7 @@ class CourseOutlineFragmentView(FragmentView): return block - def render_fragment(self, request, course_id=None): + def render_fragment(self, request, course_id=None, **kwargs): """ Renders the course outline as a fragment. """ @@ -1704,5 +1704,5 @@ class CourseOutlineFragmentView(FragmentView): # Recurse through the block tree, fleshing out each child object 'blocks': self.populate_children(course_block_tree, all_blocks['blocks']) } - html = render_to_string('courseware/course-outline.html', context) + html = render_to_string('courseware/course_outline.html', context) return Fragment(html) diff --git a/lms/static/sass/shared-v2/_course-outline.scss b/lms/static/sass/shared-v2/_course-outline.scss index 8bf1ea112d..819c1a9ee1 100644 --- a/lms/static/sass/shared-v2/_course-outline.scss +++ b/lms/static/sass/shared-v2/_course-outline.scss @@ -1,21 +1,22 @@ .course-outline { color: $lms-gray; - ul { + ol { margin: 0 $baseline; list-style: none; - > ul { + > ol { @include margin-left($baseline / 2); } - li.section-name { + div.section-name { @include padding($baseline * 0.75, $baseline * 0.75, $baseline * 0.75, $baseline / 4); background-color: $lms-background-color; border-top: 1px solid $lms-border-color; + margin-bottom: $baseline * 0.5; } - ul.outline-item { + ol.outline-item { @include margin-left($baseline); padding-bottom: ($baseline / 2); diff --git a/lms/templates/courseware/course-outline.html b/lms/templates/courseware/course-outline.html deleted file mode 100644 index 66af18f66a..0000000000 --- a/lms/templates/courseware/course-outline.html +++ /dev/null @@ -1,29 +0,0 @@ -## mako - -<%namespace name='static' file='../static_content.html'/> - -<%! -from django.utils.translation import ugettext as _ -%> - -<%static:require_module_async module_name="js/courseware/course_outline_factory" class_name="CourseOutlineFactory"> - CourseOutlineFactory('.block-tree'); - - -
          -
            - % for section in blocks.get('children') or []: -
          • - - ${ section['display_name'] } -
          • - - % endfor -
          -
          diff --git a/lms/templates/courseware/courseware.html b/lms/templates/courseware/courseware.html index c82ca94ac2..902e26b954 100644 --- a/lms/templates/courseware/courseware.html +++ b/lms/templates/courseware/courseware.html @@ -27,7 +27,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"]: From 10105b7bae9652d72f5a916543af3d5b169ccc63 Mon Sep 17 00:00:00 2001 From: Brian Jacobel Date: Fri, 24 Feb 2017 16:14:14 -0500 Subject: [PATCH 06/11] Start JS specs for key listeners on course outline page --- .../fixtures/courseware/course_outline.html | 124 ++++++++++++++++++ .../courseware/course_outline_factory_spec.js | 86 ++++++++++++ lms/static/lms/js/spec/main.js | 1 + lms/templates/courseware/course_outline.html | 43 ++++++ 4 files changed, 254 insertions(+) create mode 100644 lms/static/js/fixtures/courseware/course_outline.html create mode 100644 lms/static/js/spec/courseware/course_outline_factory_spec.js create mode 100644 lms/templates/courseware/course_outline.html diff --git a/lms/static/js/fixtures/courseware/course_outline.html b/lms/static/js/fixtures/courseware/course_outline.html new file mode 100644 index 0000000000..45e64be1f3 --- /dev/null +++ b/lms/static/js/fixtures/courseware/course_outline.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/lms/static/js/spec/courseware/course_outline_factory_spec.js b/lms/static/js/spec/courseware/course_outline_factory_spec.js new file mode 100644 index 0000000000..3fde909c3e --- /dev/null +++ b/lms/static/js/spec/courseware/course_outline_factory_spec.js @@ -0,0 +1,86 @@ +define([ + 'jquery', + 'edx-ui-toolkit/js/utils/constants', + 'js/courseware/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('js/fixtures/courseware/course_outline.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/lms/static/lms/js/spec/main.js b/lms/static/lms/js/spec/main.js index 5759cc11b1..11ecbffcba 100644 --- a/lms/static/lms/js/spec/main.js +++ b/lms/static/lms/js/spec/main.js @@ -694,6 +694,7 @@ 'js/spec/courseware/course_home_events_spec.js', 'js/spec/courseware/link_clicked_events_spec.js', 'js/spec/courseware/updates_visibility_spec.js', + 'js/spec/courseware/course_outline_factory_spec.js', 'js/spec/dashboard/donation.js', 'js/spec/dashboard/dropdown_spec.js', 'js/spec/dashboard/track_events_spec.js', diff --git a/lms/templates/courseware/course_outline.html b/lms/templates/courseware/course_outline.html new file mode 100644 index 0000000000..c4d7d95533 --- /dev/null +++ b/lms/templates/courseware/course_outline.html @@ -0,0 +1,43 @@ +## mako + +<%namespace name='static' file='../static_content.html'/> + +<%! +from django.utils.translation import ugettext as _ +%> + +<%static:require_module_async module_name="js/courseware/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 +
          +
          From 559057591fe58c86d1da5fd1a9bf9b2e9cd4773c Mon Sep 17 00:00:00 2001 From: Brian Jacobel Date: Mon, 27 Feb 2017 16:39:30 -0500 Subject: [PATCH 07/11] Update to new styling --- .../sass/shared-v2/_course-outline.scss | 65 ++++++++++--------- lms/static/sass/shared-v2/_layouts.scss | 10 --- lms/templates/courseware/course_outline.html | 5 +- .../courseware/unified-course-view.html | 2 +- 4 files changed, 38 insertions(+), 44 deletions(-) diff --git a/lms/static/sass/shared-v2/_course-outline.scss b/lms/static/sass/shared-v2/_course-outline.scss index 819c1a9ee1..0fde15faf8 100644 --- a/lms/static/sass/shared-v2/_course-outline.scss +++ b/lms/static/sass/shared-v2/_course-outline.scss @@ -1,45 +1,50 @@ .course-outline { color: $lms-gray; - ol { - margin: 0 $baseline; - list-style: none; + .block-tree { + margin: 0; + list-style-type: none; - > ol { - @include margin-left($baseline / 2); - } + .section { + margin: 0 (-1 * $baseline); + width: calc(100% + (2 * $baseline)); + padding: 0 ($baseline * 2); - div.section-name { - @include padding($baseline * 0.75, $baseline * 0.75, $baseline * 0.75, $baseline / 4); - background-color: $lms-background-color; - border-top: 1px solid $lms-border-color; - margin-bottom: $baseline * 0.5; - } + &:not(:first-child) { + border-top: 1px solid $lms-border-color; - ol.outline-item { - @include margin-left($baseline); - padding-bottom: ($baseline / 2); + .section-name { + margin-top: $baseline; + } + } - li { + .section-name { + @include margin(0, 0, ($baseline / 2), ($baseline / 2)); + padding: 0; + font-weight: bold; + } - a { - padding: ($baseline / 4) ($baseline * 1.5); - display: block; + .outline-item { + @include padding-left(0); + } - &:hover { - background-color: $lms-background-color; - text-decoration: none; + ol.outline-item { + margin: 0 0 ($baseline / 2) 0; + + .subsection { + list-style-type: none; + + a.outline-item { + display: block; + padding: ($baseline / 2); + + &:hover { + background-color: palette(primary, x-back); + text-decoration: none; + } } } } } - - .icon { - margin: 0 ($baseline * 0.75); - position: relative; - top: -2px; - font-size: 12px; - } } - } diff --git a/lms/static/sass/shared-v2/_layouts.scss b/lms/static/sass/shared-v2/_layouts.scss index 22f36dffac..5045a9210d 100644 --- a/lms/static/sass/shared-v2/_layouts.scss +++ b/lms/static/sass/shared-v2/_layouts.scss @@ -46,16 +46,6 @@ display: inline-block; } - .form-actions > * { - @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/course_outline.html b/lms/templates/courseware/course_outline.html index c4d7d95533..c9473597c6 100644 --- a/lms/templates/courseware/course_outline.html +++ b/lms/templates/courseware/course_outline.html @@ -15,18 +15,17 @@ from django.utils.translation import ugettext as _ % for section in blocks.get('children') or []:
        • - ${ section['display_name'] }
            % for subsection in section.get('children') or []: -
          1. +
          2. From 6f5249d399f5bf5fc4d45e9bb0458b53fce31f22 Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Thu, 2 Mar 2017 13:05:10 -0500 Subject: [PATCH 08/11] Beginning of python unit tests. --- .../tests/test_field_override_performance.py | 54 +++++++------- lms/djangoapps/courseware/static_tab.html | 33 --------- .../tests/test_course_outline_views.py | 72 +++++++++++++++++++ lms/djangoapps/courseware/tests/test_views.py | 6 +- lms/djangoapps/courseware/views/views.py | 4 +- requirements/edx/base.txt | 2 - 6 files changed, 104 insertions(+), 67 deletions(-) delete mode 100644 lms/djangoapps/courseware/static_tab.html create mode 100644 lms/djangoapps/courseware/tests/test_course_outline_views.py diff --git a/lms/djangoapps/ccx/tests/test_field_override_performance.py b/lms/djangoapps/ccx/tests/test_field_override_performance.py index aedcab4313..4919e762ff 100644 --- a/lms/djangoapps/ccx/tests/test_field_override_performance.py +++ b/lms/djangoapps/ccx/tests/test_field_override_performance.py @@ -231,18 +231,18 @@ class TestFieldOverrideMongoPerformance(FieldOverridePerformanceTestCase): # # of sql queries to default, # # of mongo queries, # ) - ('no_overrides', 1, True, False): (21, 6), - ('no_overrides', 2, True, False): (21, 6), - ('no_overrides', 3, True, False): (21, 6), - ('ccx', 1, True, False): (21, 6), - ('ccx', 2, True, False): (21, 6), - ('ccx', 3, True, False): (21, 6), - ('no_overrides', 1, False, False): (21, 6), - ('no_overrides', 2, False, False): (21, 6), - ('no_overrides', 3, False, False): (21, 6), - ('ccx', 1, False, False): (21, 6), - ('ccx', 2, False, False): (21, 6), - ('ccx', 3, False, False): (21, 6), + ('no_overrides', 1, True, False): (22, 6), + ('no_overrides', 2, True, False): (22, 6), + ('no_overrides', 3, True, False): (22, 6), + ('ccx', 1, True, False): (22, 6), + ('ccx', 2, True, False): (22, 6), + ('ccx', 3, True, False): (22, 6), + ('no_overrides', 1, False, False): (22, 6), + ('no_overrides', 2, False, False): (22, 6), + ('no_overrides', 3, False, False): (22, 6), + ('ccx', 1, False, False): (22, 6), + ('ccx', 2, False, False): (22, 6), + ('ccx', 3, False, False): (22, 6), } @@ -254,19 +254,19 @@ class TestFieldOverrideSplitPerformance(FieldOverridePerformanceTestCase): __test__ = True TEST_DATA = { - ('no_overrides', 1, True, False): (21, 3), - ('no_overrides', 2, True, False): (21, 3), - ('no_overrides', 3, True, False): (21, 3), - ('ccx', 1, True, False): (21, 3), - ('ccx', 2, True, False): (21, 3), - ('ccx', 3, True, False): (21, 3), - ('ccx', 1, True, True): (22, 3), - ('ccx', 2, True, True): (22, 3), - ('ccx', 3, True, True): (22, 3), - ('no_overrides', 1, False, False): (21, 3), - ('no_overrides', 2, False, False): (21, 3), - ('no_overrides', 3, False, False): (21, 3), - ('ccx', 1, False, False): (21, 3), - ('ccx', 2, False, False): (21, 3), - ('ccx', 3, False, False): (21, 3), + ('no_overrides', 1, True, False): (22, 3), + ('no_overrides', 2, True, False): (22, 3), + ('no_overrides', 3, True, False): (22, 3), + ('ccx', 1, True, False): (22, 3), + ('ccx', 2, True, False): (22, 3), + ('ccx', 3, True, False): (22, 3), + ('ccx', 1, True, True): (23, 3), + ('ccx', 2, True, True): (23, 3), + ('ccx', 3, True, True): (23, 3), + ('no_overrides', 1, False, False): (22, 3), + ('no_overrides', 2, False, False): (22, 3), + ('no_overrides', 3, False, False): (22, 3), + ('ccx', 1, False, False): (22, 3), + ('ccx', 2, False, False): (22, 3), + ('ccx', 3, False, False): (22, 3), } diff --git a/lms/djangoapps/courseware/static_tab.html b/lms/djangoapps/courseware/static_tab.html deleted file mode 100644 index 3d977bc017..0000000000 --- a/lms/djangoapps/courseware/static_tab.html +++ /dev/null @@ -1,33 +0,0 @@ -## mako - -<%page expression_filter="h"/> -<%! -from openedx.core.djangolib.markup import HTML -%> - -<%inherit file="/main.html" /> -<%block name="bodyclass">view-in-course view-statictab ${course.css_class or ''} -<%namespace name='static' file='/static_content.html'/> - -<%block name="headextra"> -<%static:css group='style-course-vendor'/> -<%static:css group='style-course'/> -${HTML(fragment.head_html())} - -<%block name="js_extra"> - -<%include file="/mathjax_include.html" args="disable_fast_preview=True"/> -${HTML(fragment.foot_html())} - - -<%block name="pagetitle">${tab['name']} | ${course.display_number_with_default} - -<%include file="/courseware/course_navigation.html" args="active_page=active_page" /> - -
            -
            -
            - ${HTML(fragment.body_html())} -
            -
            -
            diff --git a/lms/djangoapps/courseware/tests/test_course_outline_views.py b/lms/djangoapps/courseware/tests/test_course_outline_views.py new file mode 100644 index 0000000000..c57c0a683d --- /dev/null +++ b/lms/djangoapps/courseware/tests/test_course_outline_views.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( + 'unified_course_view', + 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/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py index 5bfd709857..63c93c751a 100644 --- a/lms/djangoapps/courseware/tests/test_views.py +++ b/lms/djangoapps/courseware/tests/test_views.py @@ -1419,17 +1419,17 @@ class ProgressPageTests(ModuleStoreTestCase): """Test that query counts remain the same for self-paced and instructor-paced courses.""" SelfPacedConfiguration(enabled=self_paced_enabled).save() self.setup_course(self_paced=self_paced) - with self.assertNumQueries(38), check_mongo_calls(4): + with self.assertNumQueries(39), check_mongo_calls(4): self._get_progress_page() def test_progress_queries(self): self.setup_course() - with self.assertNumQueries(38), check_mongo_calls(4): + with self.assertNumQueries(39), check_mongo_calls(4): self._get_progress_page() # subsequent accesses to the progress page require fewer queries. for _ in range(2): - with self.assertNumQueries(24), check_mongo_calls(4): + with self.assertNumQueries(25), check_mongo_calls(4): self._get_progress_page() @patch( diff --git a/lms/djangoapps/courseware/views/views.py b/lms/djangoapps/courseware/views/views.py index adb8659f9d..403906df79 100644 --- a/lms/djangoapps/courseware/views/views.py +++ b/lms/djangoapps/courseware/views/views.py @@ -1647,7 +1647,7 @@ class UnifiedCourseView(View): course = get_course_with_access(request.user, 'load', course_key, check_if_enrolled=True) # Render the outline as a fragment - outline_fragment = CourseOutlineFragmentView().render_fragment(request, course_id=course_id) + outline_fragment = CourseOutlineFragmentView().render_to_fragment(request, course_id=course_id) # Render the entire unified course view context = { @@ -1680,7 +1680,7 @@ class CourseOutlineFragmentView(FragmentView): return block - def render_fragment(self, request, course_id=None, **kwargs): + def render_to_fragment(self, request, course_id=None, **kwargs): """ Renders the course outline as a fragment. """ diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 8cbb0b4083..b530997765 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -111,9 +111,7 @@ django-ratelimit-backend==1.0 unicodecsv==0.9.4 django-require==1.0.11 pyuca==1.1 -web-fragments==0.1.0 wrapt==1.10.5 -XBlock==0.4.14 zendesk==1.1.1 # This needs to be installed *after* Cython, which is in pre.txt From 435a6f522561d359527b5d3e7bb5f4be5fbe7e7f Mon Sep 17 00:00:00 2001 From: Andy Armstrong Date: Mon, 6 Mar 2017 17:32:52 -0500 Subject: [PATCH 09/11] Make course_experience a feature --- lms/djangoapps/courseware/tabs.py | 2 +- lms/djangoapps/courseware/views/views.py | 82 ------------------- lms/envs/common.py | 4 +- lms/static/course_experience | 1 + lms/static/karma_lms.conf.js | 1 + lms/static/lms/js/build.js | 1 + lms/static/lms/js/spec/main.js | 2 +- lms/urls.py | 24 ++---- openedx/features/README.rst | 6 ++ openedx/features/__init__.py | 0 .../features/course_experience/__init__.py | 0 .../fixtures/course-outline-fragment.html | 0 .../js}/course_outline_factory.js | 0 .../js/spec}/course_outline_factory_spec.js | 4 +- .../course_experience/course-home.html | 0 .../course-outline-fragment.html | 0 .../course_experience/tests/__init__.py | 0 .../course_experience/tests/views/__init__.py | 0 .../tests/views/test_course_outline.py | 2 +- openedx/features/course_experience/urls.py | 21 +++++ .../course_experience/views/__init__.py | 0 .../course_experience/views/course_home.py | 50 +++++++++++ .../course_experience/views/course_outline.py | 61 ++++++++++++++ pavelib/quality.py | 15 +++- 24 files changed, 169 insertions(+), 107 deletions(-) create mode 120000 lms/static/course_experience create mode 100644 openedx/features/README.rst create mode 100644 openedx/features/__init__.py create mode 100644 openedx/features/course_experience/__init__.py rename lms/static/js/fixtures/courseware/course_outline.html => openedx/features/course_experience/static/course_experience/fixtures/course-outline-fragment.html (100%) rename {lms/static/js/courseware => openedx/features/course_experience/static/course_experience/js}/course_outline_factory.js (100%) rename {lms/static/js/spec/courseware => openedx/features/course_experience/static/course_experience/js/spec}/course_outline_factory_spec.js (96%) rename lms/templates/courseware/unified-course-view.html => openedx/features/course_experience/templates/course_experience/course-home.html (100%) rename lms/templates/courseware/course_outline.html => openedx/features/course_experience/templates/course_experience/course-outline-fragment.html (100%) create mode 100644 openedx/features/course_experience/tests/__init__.py create mode 100644 openedx/features/course_experience/tests/views/__init__.py rename lms/djangoapps/courseware/tests/test_course_outline_views.py => openedx/features/course_experience/tests/views/test_course_outline.py (98%) create mode 100644 openedx/features/course_experience/urls.py create mode 100644 openedx/features/course_experience/views/__init__.py create mode 100644 openedx/features/course_experience/views/course_home.py create mode 100644 openedx/features/course_experience/views/course_outline.py diff --git a/lms/djangoapps/courseware/tabs.py b/lms/djangoapps/courseware/tabs.py index 09d36c463b..1f86fbb14d 100644 --- a/lms/djangoapps/courseware/tabs.py +++ b/lms/djangoapps/courseware/tabs.py @@ -44,7 +44,7 @@ class CoursewareTab(EnrolledTab): """ request = RequestCache.get_current_request() if waffle.flag_is_active(request, 'unified_course_view'): - return link_reverse_func('unified_course_view') + return link_reverse_func('edx.course_experience.course_home') else: return link_reverse_func('courseware') diff --git a/lms/djangoapps/courseware/views/views.py b/lms/djangoapps/courseware/views/views.py index 403906df79..291d68cf22 100644 --- a/lms/djangoapps/courseware/views/views.py +++ b/lms/djangoapps/courseware/views/views.py @@ -45,7 +45,6 @@ from lms.djangoapps.grades.new.course_grade import CourseGradeFactory from lms.djangoapps.instructor.enrollment import uses_shib from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification from lms.djangoapps.ccx.custom_exception import CCXLocatorValidationException -from lms.djangoapps.course_api.blocks.api import get_blocks import shoppingcart import survey.utils @@ -1625,84 +1624,3 @@ def financial_assistance_form(request): } ], }) - - -class UnifiedCourseView(View): - """ - Unified view 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 main view 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('courseware/unified-course-view.html', context) - - -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', 'vertical', '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('courseware/course_outline.html', context) - return Fragment(html) diff --git a/lms/envs/common.py b/lms/envs/common.py index de3897913d..47c400f957 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -1736,7 +1736,6 @@ REQUIRE_JS_PATH_OVERRIDES = { 'js/student_account/logistration_factory': 'js/student_account/logistration_factory.js', 'js/student_profile/views/learner_profile_factory': 'js/student_profile/views/learner_profile_factory.js', 'js/courseware/courseware_factory': 'js/courseware/courseware_factory.js', - 'js/courseware/course_outline_factory': 'js/courseware/course_outline_factory.js', 'js/groups/views/cohorts_dashboard_factory': 'js/groups/views/cohorts_dashboard_factory.js', 'draggabilly': 'js/vendor/draggabilly.js' } @@ -2174,6 +2173,9 @@ INSTALLED_APPS = ( # Unusual migrations 'database_fixups', + + # Features + 'openedx.features.course_experience', ) ######################### CSRF ######################################### diff --git a/lms/static/course_experience b/lms/static/course_experience new file mode 120000 index 0000000000..7daa76d5a5 --- /dev/null +++ b/lms/static/course_experience @@ -0,0 +1 @@ +../../openedx/features/course_experience/static/course_experience \ No newline at end of file diff --git a/lms/static/karma_lms.conf.js b/lms/static/karma_lms.conf.js index 04deb9c5f0..d520316d71 100644 --- a/lms/static/karma_lms.conf.js +++ b/lms/static/karma_lms.conf.js @@ -27,6 +27,7 @@ var options = { // Otherwise Istanbul which is used for coverage tracking will cause tests to not run. sourceFiles: [ {pattern: 'coffee/src/**/!(*spec).js'}, + {pattern: 'course_experience/js/**/!(*spec).js'}, {pattern: 'discussion/js/**/!(*spec).js'}, {pattern: 'js/**/!(*spec|djangojs).js'}, {pattern: 'lms/js/**/!(*spec).js'}, diff --git a/lms/static/lms/js/build.js b/lms/static/lms/js/build.js index 2a1b44b360..1a2a536abf 100644 --- a/lms/static/lms/js/build.js +++ b/lms/static/lms/js/build.js @@ -18,6 +18,7 @@ * done. */ modules: getModulesList([ + 'course_experience/js/course_outline_factory', 'discussion/js/discussion_board_factory', 'discussion/js/discussion_profile_page_factory', 'js/api_admin/catalog_preview_factory', diff --git a/lms/static/lms/js/spec/main.js b/lms/static/lms/js/spec/main.js index 11ecbffcba..30449257f6 100644 --- a/lms/static/lms/js/spec/main.js +++ b/lms/static/lms/js/spec/main.js @@ -679,6 +679,7 @@ }); testFiles = [ + 'course_experience/js/spec/course_outline_factory_spec.js', 'discussion/js/spec/discussion_board_factory_spec.js', 'discussion/js/spec/discussion_profile_page_factory_spec.js', 'discussion/js/spec/discussion_board_view_spec.js', @@ -694,7 +695,6 @@ 'js/spec/courseware/course_home_events_spec.js', 'js/spec/courseware/link_clicked_events_spec.js', 'js/spec/courseware/updates_visibility_spec.js', - 'js/spec/courseware/course_outline_factory_spec.js', 'js/spec/dashboard/donation.js', 'js/spec/dashboard/dropdown_spec.js', 'js/spec/dashboard/track_events_spec.js', diff --git a/lms/urls.py b/lms/urls.py index 2583fc9a77..cdcb0b8b34 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -11,7 +11,6 @@ from django.conf.urls.static import static from courseware.views.views import CourseTabView, EnrollStaffView, StaticCourseTabView from config_models.views import ConfigurationModelCurrentAPIView from courseware.views.index import CoursewareIndex -from courseware.views.views import UnifiedCourseView, CourseOutlineFragmentView from openedx.core.djangoapps.auth_exchange.views import LoginWithAccessTokenView from openedx.core.djangoapps.catalog.models import CatalogIntegration from openedx.core.djangoapps.programs.models import ProgramsApiConfig @@ -380,20 +379,6 @@ urlpatterns += ( name='html_book', ), - url( - r'^courses/{}/course/?$'.format( - settings.COURSE_ID_PATTERN, - ), - UnifiedCourseView.as_view(), - name='unified_course_view', - ), - url( - r'^courses/{}/course/outline?$'.format( - settings.COURSE_ID_PATTERN, - ), - CourseOutlineFragmentView.as_view(), - name='course_outline_fragment_view', - ), url( r'^courses/{}/courseware/?$'.format( settings.COURSE_ID_PATTERN, @@ -616,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/lms/static/js/fixtures/courseware/course_outline.html b/openedx/features/course_experience/static/course_experience/fixtures/course-outline-fragment.html similarity index 100% rename from lms/static/js/fixtures/courseware/course_outline.html rename to openedx/features/course_experience/static/course_experience/fixtures/course-outline-fragment.html diff --git a/lms/static/js/courseware/course_outline_factory.js b/openedx/features/course_experience/static/course_experience/js/course_outline_factory.js similarity index 100% rename from lms/static/js/courseware/course_outline_factory.js rename to openedx/features/course_experience/static/course_experience/js/course_outline_factory.js diff --git a/lms/static/js/spec/courseware/course_outline_factory_spec.js b/openedx/features/course_experience/static/course_experience/js/spec/course_outline_factory_spec.js similarity index 96% rename from lms/static/js/spec/courseware/course_outline_factory_spec.js rename to openedx/features/course_experience/static/course_experience/js/spec/course_outline_factory_spec.js index 3fde909c3e..ccea16b8f2 100644 --- a/lms/static/js/spec/courseware/course_outline_factory_spec.js +++ b/openedx/features/course_experience/static/course_experience/js/spec/course_outline_factory_spec.js @@ -1,7 +1,7 @@ define([ 'jquery', 'edx-ui-toolkit/js/utils/constants', - 'js/courseware/course_outline_factory' + 'course_experience/js/course_outline_factory' ], function($, constants, CourseOutlineFactory) { 'use strict'; @@ -19,7 +19,7 @@ define([ }; beforeEach(function() { - loadFixtures('js/fixtures/courseware/course_outline.html'); + loadFixtures('course_experience/fixtures/course-outline-fragment.html'); CourseOutlineFactory('.block-tree'); }); diff --git a/lms/templates/courseware/unified-course-view.html b/openedx/features/course_experience/templates/course_experience/course-home.html similarity index 100% rename from lms/templates/courseware/unified-course-view.html rename to openedx/features/course_experience/templates/course_experience/course-home.html diff --git a/lms/templates/courseware/course_outline.html b/openedx/features/course_experience/templates/course_experience/course-outline-fragment.html similarity index 100% rename from lms/templates/courseware/course_outline.html rename to openedx/features/course_experience/templates/course_experience/course-outline-fragment.html 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/lms/djangoapps/courseware/tests/test_course_outline_views.py b/openedx/features/course_experience/tests/views/test_course_outline.py similarity index 98% rename from lms/djangoapps/courseware/tests/test_course_outline_views.py rename to openedx/features/course_experience/tests/views/test_course_outline.py index c57c0a683d..76a6cfa456 100644 --- a/lms/djangoapps/courseware/tests/test_course_outline_views.py +++ b/openedx/features/course_experience/tests/views/test_course_outline.py @@ -55,7 +55,7 @@ class TestCourseOutlinePage(SharedModuleStoreTestCase): def test_render(self): for course in self.courses: url = reverse( - 'unified_course_view', + 'edx.course_experience.course_home', kwargs={ 'course_id': unicode(course.id), } 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..c0d42f2fc9 --- /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', 'vertical', '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" From e2264d0796bf1f6705b21c23106e0a1912bd2807 Mon Sep 17 00:00:00 2001 From: Brian Jacobel Date: Thu, 9 Mar 2017 14:03:15 -0500 Subject: [PATCH 10/11] Hide '<- Outline >' link in breadcrumbs if using old course view --- .../lib/xmodule/xmodule/js/src/sequence/display.js | 3 ++- .../templates/sequence-breadcrumbs.underscore | 14 ++++++++------ lms/templates/courseware/courseware.html | 7 ++++++- .../course_experience/course-outline-fragment.html | 2 +- 4 files changed, 17 insertions(+), 9 deletions(-) diff --git a/common/lib/xmodule/xmodule/js/src/sequence/display.js b/common/lib/xmodule/xmodule/js/src/sequence/display.js index a0c1407943..b3fb21600c 100644 --- a/common/lib/xmodule/xmodule/js/src/sequence/display.js +++ b/common/lib/xmodule/xmodule/js/src/sequence/display.js @@ -274,7 +274,8 @@ 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') + pathText: this.el.find('.nav-item.active').data('path'), + unifiedCourseView: this.path.data('unified-course-view') }) ); diff --git a/common/static/common/templates/sequence-breadcrumbs.underscore b/common/static/common/templates/sequence-breadcrumbs.underscore index c66e14b334..da2bd4ba91 100644 --- a/common/static/common/templates/sequence-breadcrumbs.underscore +++ b/common/static/common/templates/sequence-breadcrumbs.underscore @@ -1,7 +1,9 @@ - - - <%- gettext('Return to course outline') %> - <%- gettext('Outline') %> - - > +<% if (unifiedCourseView) { %> + + + <%- gettext('Return to course outline') %> + <%- gettext('Outline') %> + + > +<% } %> <%- pathText %> diff --git a/lms/templates/courseware/courseware.html b/lms/templates/courseware/courseware.html index 902e26b954..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 @@ -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/openedx/features/course_experience/templates/course_experience/course-outline-fragment.html b/openedx/features/course_experience/templates/course_experience/course-outline-fragment.html index c9473597c6..444778d07f 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 @@ -6,7 +6,7 @@ from django.utils.translation import ugettext as _ %> -<%static:require_module_async module_name="js/courseware/course_outline_factory" class_name="CourseOutlineFactory"> +<%static:require_module_async module_name="course_experience/js/course_outline_factory" class_name="CourseOutlineFactory"> CourseOutlineFactory('.block-tree'); From c37137a6b552bd6ce4f74671adc26fcfdbaa447d Mon Sep 17 00:00:00 2001 From: Robert Raposa Date: Mon, 6 Mar 2017 14:22:24 -0500 Subject: [PATCH 11/11] Add course outline bokchoy tests. - Rename CourseOutlinePage to StudioCourseOutlinePage in lms tests. - Introduce CourseHomePage with outline child. - Add a11y, breadcrumbs, and waffle. --- common/test/acceptance/fixtures/course.py | 4 +- .../test/acceptance/pages/lms/course_home.py | 148 ++++++++++ .../test/acceptance/pages/lms/course_nav.py | 212 -------------- .../test/acceptance/pages/lms/courseware.py | 265 +++++++++++++++++- .../acceptance/tests/lms/test_bookmarks.py | 25 +- .../tests/lms/test_certificate_web_view.py | 42 +-- .../test/acceptance/tests/lms/test_library.py | 8 +- common/test/acceptance/tests/lms/test_lms.py | 134 ++++++--- .../test_lms_cohorted_courseware_search.py | 10 +- .../tests/lms/test_lms_courseware.py | 120 ++++---- .../tests/lms/test_lms_courseware_search.py | 22 +- .../tests/lms/test_lms_dashboard_search.py | 32 +-- .../acceptance/tests/lms/test_lms_edxnotes.py | 10 +- .../tests/lms/test_lms_entrance_exams.py | 2 +- .../acceptance/tests/lms/test_lms_gating.py | 46 +-- .../lms/test_lms_instructor_dashboard.py | 20 +- .../test_lms_split_test_courseware_search.py | 12 +- .../tests/lms/test_progress_page.py | 24 +- .../tests/studio/test_studio_outline.py | 7 +- .../tests/video/test_video_module.py | 18 +- lms/djangoapps/courseware/views/views.py | 1 - lms/envs/bok_choy.py | 12 + .../course_experience/course-home.html | 16 +- .../course-outline-fragment.html | 4 +- .../course_experience/views/course_outline.py | 2 +- 25 files changed, 737 insertions(+), 459 deletions(-) create mode 100644 common/test/acceptance/pages/lms/course_home.py delete mode 100644 common/test/acceptance/pages/lms/course_nav.py 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