-
+
% 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 @@
+
+
+ -
+
+
+ Introduction
+
+
+ -
+
+ Demo Course Overview
+
+
+
+
+ -
+
+
+ Example Week 1: Getting Started
+
+
+ -
+
+ Lesson 1 - Getting Started
+
+
+ -
+
+ Homework - Question Styles
+
+
+
+
+ -
+
+
+ Example Week 2: Get Interactive
+
+
+ -
+
+ Lesson 2 - Let's Get Interactive!
+
+
+ -
+
+ Homework - Labs and Demos
+
+
+ -
+
+ Homework - Essays
+
+
+
+
+ -
+
+
+ Example Week 3: Be Social
+
+
+ -
+
+ Lesson 3 - Be Social
+
+
+ -
+
+ Homework - Find Your Study Buddy
+
+
+ -
+
+ More Ways to Connect
+
+
+
+
+ -
+
+
+ About Exams and Certificates
+
+
+ -
+
+ edX Exams
+
+
+
+
+ -
+
+
+ holding section
+
+
+ -
+
+ New Subsection
+
+
+
+
+
+
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>
+<%def name="course_name()">
+<% return _("{course_number} Courseware").format(course_number=course.display_number_with_default) %>
+%def>
+
+<%!
+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>
+
+<%block name="pagetitle">${course_name()}%block>
+
+<%include file="../courseware/course_navigation.html" args="active_page='courseware'" />
+
+<%block name="headextra">
+${HTML(outline_fragment.head_html())}
+%block>
+
+<%block name="js_extra">
+${HTML(outline_fragment.foot_html())}
+%block>
+
+<%block name="content">
+
+
+
+ ${HTML(outline_fragment.body_html())}
+
+
+%block>
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');
+%static:require_module_async>
+
+
+
+ % 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/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"