diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index d794f638ea..380d09fe23 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -7,6 +7,8 @@ the top. Include a label indicating the component affected.
LMS: Instructors can request and see content of previous bulk emails sent in the instructor dashboard.
+Studio: Backbone version of the course outline page. STUD-1726.
+
Studio: New advanced setting "invitation_only" for courses. This setting overrides the enrollment start/end dates
if set. LMS-2670
diff --git a/cms/djangoapps/contentstore/features/common.py b/cms/djangoapps/contentstore/features/common.py
index c22eb92083..25269906e8 100644
--- a/cms/djangoapps/contentstore/features/common.py
+++ b/cms/djangoapps/contentstore/features/common.py
@@ -57,6 +57,26 @@ def i_have_opened_a_new_course(_step):
open_new_course()
+@step('I have populated a new course in Studio$')
+def i_have_populated_a_new_course(_step):
+ world.clear_courses()
+ course = world.CourseFactory.create()
+ world.scenario_dict['COURSE'] = course
+ section = world.ItemFactory.create(parent_location=course.location)
+ world.ItemFactory.create(
+ parent_location=section.location,
+ category='sequential',
+ display_name='Subsection One',
+ )
+ user = create_studio_user(is_staff=False)
+ add_course_author(user, course)
+
+ log_into_studio()
+
+ world.css_click('a.course-link')
+ world.wait_for_js_to_load()
+
+
@step('(I select|s?he selects) the new course')
def select_new_course(_step, whom):
course_link_css = 'a.course-link'
@@ -182,24 +202,9 @@ def create_a_course():
assert_true(world.is_css_present(course_title_css))
-def add_section(name='My Section'):
- link_css = 'a.new-courseware-section-button'
- world.css_click(link_css)
- name_css = 'input.new-section-name'
- save_css = 'input.new-section-name-save'
- world.css_fill(name_css, name)
- world.css_click(save_css)
- span_css = 'span.section-name-span'
- assert_true(world.is_css_present(span_css))
-
-
-def add_subsection(name='Subsection One'):
- css = 'a.new-subsection-item'
- world.css_click(css)
- name_css = 'input.new-subsection-name-input'
- save_css = 'input.new-subsection-name-save'
- world.css_fill(name_css, name)
- world.css_click(save_css)
+def add_section():
+ world.css_click('.course-outline .add-button')
+ assert_true(world.is_css_present('.outline-item-section .xblock-field-value'))
def set_date_and_time(date_css, desired_date, time_css, desired_time, key=None):
@@ -230,36 +235,13 @@ def i_enabled_the_advanced_module(step, module):
@world.absorb
-def create_course_with_unit():
+def create_unit_from_course_outline():
"""
- Prepare for tests by creating a course with a section, subsection, and unit.
- Performs the following:
- Clear out all courseware
- Create a course with a section, subsection, and unit
- Create a user and make that user a course author
- Log the user into studio
- Open the course from the dashboard
- Expand the section and click on the New Unit link
- The end result is the page where the user is editing the new unit
+ Expands the section and clicks on the New Unit link.
+ The end result is the page where the user is editing the new unit.
"""
- world.clear_courses()
- course = world.CourseFactory.create()
- world.scenario_dict['COURSE'] = course
- section = world.ItemFactory.create(parent_location=course.location)
- world.ItemFactory.create(
- parent_location=section.location,
- category='sequential',
- display_name='Subsection One',
- )
- user = create_studio_user(is_staff=False)
- add_course_author(user, course)
-
- log_into_studio()
- world.css_click('a.course-link')
-
- world.wait_for_js_to_load()
css_selectors = [
- 'div.section-item a.expand-collapse', 'a.new-unit-item'
+ '.outline-item-subsection .expand-collapse', '.outline-item-subsection .add-button'
]
for selector in css_selectors:
world.css_click(selector)
@@ -273,7 +255,8 @@ def create_course_with_unit():
@step('I have clicked the new unit button$')
@step(u'I am in Studio editing a new unit$')
def edit_new_unit(step):
- create_course_with_unit()
+ step.given('I have populated a new course in Studio')
+ create_unit_from_course_outline()
@step('the save notification button is disabled')
diff --git a/cms/djangoapps/contentstore/features/course-overview.feature b/cms/djangoapps/contentstore/features/course-outline.feature
similarity index 69%
rename from cms/djangoapps/contentstore/features/course-overview.feature
rename to cms/djangoapps/contentstore/features/course-outline.feature
index a10237de5d..67efc84588 100644
--- a/cms/djangoapps/contentstore/features/course-overview.feature
+++ b/cms/djangoapps/contentstore/features/course-outline.feature
@@ -1,43 +1,43 @@
@shard_1
-Feature: CMS.Course Overview
+Feature: CMS.Course Outline
In order to quickly view the details of a course's section and set release dates and grading
As a course author
- I want to use the course overview page
+ I want to use the course outline page
- Scenario: The default layout for the overview page is to show sections in expanded view
+ Scenario: The default layout for the outline page is to show sections in expanded view
Given I have a course with multiple sections
- When I navigate to the course overview page
+ When I navigate to the course outline page
Then I see the "Collapse All Sections" link
And all sections are expanded
Scenario: Expand /collapse for a course with no sections
Given I have a course with no sections
- When I navigate to the course overview page
+ When I navigate to the course outline page
Then I do not see the "Collapse All Sections" link
Scenario: Collapse link appears after creating first section of a course
Given I have a course with no sections
- When I navigate to the course overview page
+ When I navigate to the course outline page
And I add a section
Then I see the "Collapse All Sections" link
And all sections are expanded
- Scenario: Collapse link is not removed after last section of a course is deleted
+ Scenario: Collapse link is removed after last section of a course is deleted
Given I have a course with 1 section
- And I navigate to the course overview page
- When I will confirm all alerts
+ And I navigate to the course outline page
And I press the "section" delete icon
- Then I see the "Collapse All Sections" link
+ When I will confirm all alerts
+ Then I do not see the "Collapse All Sections" link
Scenario: Collapsing all sections when all sections are expanded
- Given I navigate to the courseware page of a course with multiple sections
+ Given I navigate to the outline page of a course with multiple sections
And all sections are expanded
When I click the "Collapse All Sections" link
Then I see the "Expand All Sections" link
And all sections are collapsed
Scenario: Collapsing all sections when 1 or more sections are already collapsed
- Given I navigate to the courseware page of a course with multiple sections
+ Given I navigate to the outline page of a course with multiple sections
And all sections are expanded
When I collapse the first section
And I click the "Collapse All Sections" link
@@ -45,14 +45,14 @@ Feature: CMS.Course Overview
And all sections are collapsed
Scenario: Expanding all sections when all sections are collapsed
- Given I navigate to the courseware page of a course with multiple sections
+ Given I navigate to the outline page of a course with multiple sections
And I click the "Collapse All Sections" link
When I click the "Expand All Sections" link
Then I see the "Collapse All Sections" link
And all sections are expanded
Scenario: Expanding all sections when 1 or more sections are already expanded
- Given I navigate to the courseware page of a course with multiple sections
+ Given I navigate to the outline page of a course with multiple sections
And I click the "Collapse All Sections" link
When I expand the first section
And I click the "Expand All Sections" link
diff --git a/cms/djangoapps/contentstore/features/course-overview.py b/cms/djangoapps/contentstore/features/course-outline.py
similarity index 53%
rename from cms/djangoapps/contentstore/features/course-overview.py
rename to cms/djangoapps/contentstore/features/course-outline.py
index 6890e39491..f3203880aa 100644
--- a/cms/djangoapps/contentstore/features/course-overview.py
+++ b/cms/djangoapps/contentstore/features/course-outline.py
@@ -48,75 +48,83 @@ def have_a_course_with_two_sections(step):
display_name='Subsection Beta',)
-@step(u'I navigate to the course overview page$')
-def navigate_to_the_course_overview_page(step):
+@step(u'I navigate to the course outline page$')
+def navigate_to_the_course_outline_page(step):
create_studio_user(is_staff=True)
log_into_studio()
course_locator = 'a.course-link'
world.css_click(course_locator)
-@step(u'I navigate to the courseware page of a course with multiple sections')
-def nav_to_the_courseware_page_of_a_course_with_multiple_sections(step):
+@step(u'I navigate to the outline page of a course with multiple sections')
+def nav_to_the_outline_page_of_a_course_with_multiple_sections(step):
step.given('I have a course with multiple sections')
- step.given('I navigate to the course overview page')
+ step.given('I navigate to the course outline page')
@step(u'I add a section')
def i_add_a_section(step):
- add_section(name='My New Section That I Just Added')
+ add_section()
-@step(u'I click the "([^"]*)" link$')
-def i_click_the_text_span(step, text):
- span_locator = '.toggle-button-sections span'
- assert_true(world.browser.is_element_present_by_css(span_locator))
- # first make sure that the expand/collapse text is the one you expected
- assert_true(world.css_has_value(span_locator, text))
- world.css_click(span_locator)
+@step(u'I press the "section" delete icon')
+def i_press_the_section_delete_icon(step):
+ delete_locator = 'section .outline-item-section > .wrapper-xblock-header a.delete-button'
+ world.css_click(delete_locator)
-@step(u'I collapse the first section$')
-def i_collapse_a_section(step):
- collapse_locator = 'section.courseware-section a.collapse'
- world.css_click(collapse_locator)
+@step(u'I will confirm all alerts')
+def i_confirm_all_alerts(step):
+ confirm_locator = '.prompt .nav-actions a.action-primary'
+ world.css_click(confirm_locator)
-@step(u'I expand the first section$')
-def i_expand_a_section(step):
- expand_locator = 'section.courseware-section a.expand'
- world.css_click(expand_locator)
-
-
-@step(u'I see the "([^"]*)" link$')
-def i_see_the_span_with_text(step, text):
- span_locator = '.toggle-button-sections span'
- assert_true(world.css_has_value(span_locator, text))
+@step(u'I see the "([^"]*) All Sections" link$')
+def i_see_the_collapse_expand_all_span(step, text):
+ if text == "Collapse":
+ span_locator = '.toggle-button-expand-collapse .collapse-all .label'
+ elif text == "Expand":
+ span_locator = '.toggle-button-expand-collapse .expand-all .label'
assert_true(world.css_visible(span_locator))
-@step(u'I do not see the "([^"]*)" link$')
-def i_do_not_see_the_span_with_text(step, text):
- # Note that the span will exist on the page but not be visible
- span_locator = '.toggle-button-sections span'
- assert_true(world.is_css_present(span_locator))
+@step(u'I do not see the "([^"]*) All Sections" link$')
+def i_do_not_see_the_collapse_expand_all_span(step, text):
+ if text == "Collapse":
+ span_locator = '.toggle-button-expand-collapse .collapse-all .label'
+ elif text == "Expand":
+ span_locator = '.toggle-button-expand-collapse .expand-all .label'
assert_false(world.css_visible(span_locator))
-@step(u'all sections are expanded$')
-def all_sections_are_expanded(step):
+@step(u'I click the "([^"]*) All Sections" link$')
+def i_click_the_collapse_expand_all_span(step, text):
+ if text == "Collapse":
+ span_locator = '.toggle-button-expand-collapse .collapse-all .label'
+ elif text == "Expand":
+ span_locator = '.toggle-button-expand-collapse .expand-all .label'
+ assert_true(world.browser.is_element_present_by_css(span_locator))
+ world.css_click(span_locator)
+
+
+@step(u'I ([^"]*) the first section$')
+def i_collapse_expand_a_section(step, text):
+ if text == "collapse":
+ locator = 'section .outline-item-section .ui-toggle-expansion'
+ elif text == "expand":
+ locator = 'section .outline-item-section .ui-toggle-expansion'
+ world.css_click(locator)
+
+
+@step(u'all sections are ([^"]*)$')
+def all_sections_are_collapsed_or_expanded(step, text):
subsection_locator = 'div.subsection-list'
subsections = world.css_find(subsection_locator)
for index in range(len(subsections)):
- assert_true(world.css_visible(subsection_locator, index=index))
-
-
-@step(u'all sections are collapsed$')
-def all_sections_are_collapsed(step):
- subsection_locator = 'div.subsection-list'
- subsections = world.css_find(subsection_locator)
- for index in range(len(subsections)):
- assert_false(world.css_visible(subsection_locator, index=index))
+ if text == "collapsed":
+ assert_false(world.css_visible(subsection_locator, index=index))
+ elif text == "expanded":
+ assert_true(world.css_visible(subsection_locator, index=index))
@step(u"I change an assignment's grading status")
diff --git a/cms/djangoapps/contentstore/features/course-updates.py b/cms/djangoapps/contentstore/features/course-updates.py
index 4793948b19..69670013cb 100644
--- a/cms/djangoapps/contentstore/features/course-updates.py
+++ b/cms/djangoapps/contentstore/features/course-updates.py
@@ -130,3 +130,9 @@ def verify_text_in_editor_and_update(button_css, before, after):
text = get_codemirror_value()
assert_in(before, text)
change_text(after)
+
+
+@step('I see a "(saving|deleting)" notification')
+def i_see_a_mini_notification(_step, _type):
+ saving_css = '.wrapper-notification-mini'
+ assert world.is_css_present(saving_css)
diff --git a/cms/djangoapps/contentstore/features/courses.py b/cms/djangoapps/contentstore/features/courses.py
index a0502fe92a..6425d1c4c5 100644
--- a/cms/djangoapps/contentstore/features/courses.py
+++ b/cms/djangoapps/contentstore/features/courses.py
@@ -66,5 +66,5 @@ def i_am_on_tab(step, tab_name):
@step('I see a link for adding a new section$')
def i_see_new_section_link(step):
- link_css = 'a.new-courseware-section-button'
+ link_css = '.course-outline .add-button'
assert world.css_has_text(link_css, 'New Section')
diff --git a/cms/djangoapps/contentstore/features/discussion-editor.py b/cms/djangoapps/contentstore/features/discussion-editor.py
index d16279be4d..bca91f1a74 100644
--- a/cms/djangoapps/contentstore/features/discussion-editor.py
+++ b/cms/djangoapps/contentstore/features/discussion-editor.py
@@ -6,7 +6,7 @@ from lettuce import world, step
@step('I have created a Discussion Tag$')
def i_created_discussion_tag(step):
- world.create_course_with_unit()
+ step.given('I am in Studio editing a new unit')
world.create_component_instance(
step=step,
category='discussion',
diff --git a/cms/djangoapps/contentstore/features/grading.feature b/cms/djangoapps/contentstore/features/grading.feature
index 6c357a171e..419309d12d 100644
--- a/cms/djangoapps/contentstore/features/grading.feature
+++ b/cms/djangoapps/contentstore/features/grading.feature
@@ -32,8 +32,7 @@ Feature: CMS.Course Grading
Then I see that the grade range has changed
Scenario: Users can modify Assignment types
- Given I have opened a new course in Studio
- And I have populated the course
+ Given I have populated a new course in Studio
And I am viewing the grading settings
When I change assignment type "Homework" to "New Type"
And I press the "Save" notification button
@@ -42,8 +41,7 @@ Feature: CMS.Course Grading
And I do not see the assignment name "Homework"
Scenario: Users can delete Assignment types
- Given I have opened a new course in Studio
- And I have populated the course
+ Given I have populated a new course in Studio
And I am viewing the grading settings
When I delete the assignment type "Homework"
And I press the "Save" notification button
@@ -51,8 +49,7 @@ Feature: CMS.Course Grading
Then I do not see the assignment name "Homework"
Scenario: Users can add Assignment types
- Given I have opened a new course in Studio
- And I have populated the course
+ Given I have populated a new course in Studio
And I am viewing the grading settings
When I add a new assignment type "New Type"
And I press the "Save" notification button
@@ -71,31 +68,27 @@ Feature: CMS.Course Grading
Then the assignment weight is displayed as "7"
Scenario: Settings are only persisted when saved
- Given I have opened a new course in Studio
- And I have populated the course
+ Given I have populated a new course in Studio
And I am viewing the grading settings
When I change assignment type "Homework" to "New Type"
Then I do not see the changes persisted on refresh
Scenario: Settings are reset on cancel
- Given I have opened a new course in Studio
- And I have populated the course
+ Given I have populated a new course in Studio
And I am viewing the grading settings
When I change assignment type "Homework" to "New Type"
And I press the "Cancel" notification button
Then I see the assignment type "Homework"
Scenario: Confirmation is shown on save
- Given I have opened a new course in Studio
- And I have populated the course
+ Given I have populated a new course in Studio
And I am viewing the grading settings
When I change assignment type "Homework" to "New Type"
And I press the "Save" notification button
Then I see a confirmation that my changes have been saved
Scenario: User cannot save invalid settings
- Given I have opened a new course in Studio
- And I have populated the course
+ Given I have populated a new course in Studio
And I am viewing the grading settings
When I change assignment type "Homework" to ""
Then the save notification button is disabled
@@ -104,8 +97,7 @@ Feature: CMS.Course Grading
@skip_internetexplorer
@skip_safari
Scenario: User can edit grading range names
- Given I have opened a new course in Studio
- And I have populated the course
+ Given I have populated a new course in Studio
And I am viewing the grading settings
When I change the highest grade range to "Good"
And I press the "Save" notification button
@@ -113,14 +105,12 @@ Feature: CMS.Course Grading
Then I see the highest grade range is "Good"
Scenario: User cannot edit failing grade range name
- Given I have opened a new course in Studio
- And I have populated the course
+ Given I have populated a new course in Studio
And I am viewing the grading settings
Then I cannot edit the "Fail" grade range
Scenario: User can set a grace period greater than one day
- Given I have opened a new course in Studio
- And I have populated the course
+ Given I have populated a new course in Studio
And I am viewing the grading settings
When I change the grace period to "48:00"
And I press the "Save" notification button
@@ -128,8 +118,7 @@ Feature: CMS.Course Grading
Then I see the grace period is "48:00"
Scenario: Grace periods of more than 59 minutes are wrapped to the correct time
- Given I have opened a new course in Studio
- And I have populated the course
+ Given I have populated a new course in Studio
And I am viewing the grading settings
When I change the grace period to "01:99"
And I press the "Save" notification button
diff --git a/cms/djangoapps/contentstore/features/grading.py b/cms/djangoapps/contentstore/features/grading.py
index 862d2f2462..431b6d06b5 100644
--- a/cms/djangoapps/contentstore/features/grading.py
+++ b/cms/djangoapps/contentstore/features/grading.py
@@ -82,19 +82,21 @@ def main_course_page(step):
@step(u'I do( not)? see the assignment name "([^"]*)"$')
def see_assignment_name(step, do_not, name):
- assignment_menu_css = 'ul.menu > li > a'
- # First assert that it is there, make take a bit to redraw
- assert_true(
- world.css_find(assignment_menu_css),
- msg="Could not find assignment menu"
- )
-
- assignment_menu = world.css_find(assignment_menu_css)
- allnames = [item.html for item in assignment_menu]
- if do_not:
- assert_not_in(name, allnames)
- else:
- assert_in(name, allnames)
+ # TODO: rewrite this once grading has been added back to the course outline
+ pass
+ # assignment_menu_css = 'ul.menu > li > a'
+ # # First assert that it is there, make take a bit to redraw
+ # assert_true(
+ # world.css_find(assignment_menu_css),
+ # msg="Could not find assignment menu"
+ # )
+ #
+ # assignment_menu = world.css_find(assignment_menu_css)
+ # allnames = [item.html for item in assignment_menu]
+ # if do_not:
+ # assert_not_in(name, allnames)
+ # else:
+ # assert_in(name, allnames)
@step(u'I delete the assignment type "([^"]*)"$')
@@ -128,12 +130,6 @@ def verify_weight(step, weight):
assert_equal(world.css_value(weight_id, -1), weight)
-@step(u'I have populated the course')
-def populate_course(step):
- step.given('I have added a new section')
- step.given('I have added a new subsection')
-
-
@step(u'I do not see the changes persisted on refresh$')
def changes_not_persisted(step):
reload_the_page(step)
diff --git a/cms/djangoapps/contentstore/features/help.feature b/cms/djangoapps/contentstore/features/help.feature
index ef6bfe33cc..4876396cfd 100644
--- a/cms/djangoapps/contentstore/features/help.feature
+++ b/cms/djangoapps/contentstore/features/help.feature
@@ -51,11 +51,3 @@ Feature: CMS.Help
Scenario: Users can access online help on the unit page
Given I am in Studio editing a new unit
Then I should see online help for "units"
-
-
- Scenario: Users can access online help on the subsection page
- Given I have opened a new course section in Studio
- And I have added a new subsection
- And I click on the subsection
- Then I should see online help for "subsections"
-
diff --git a/cms/djangoapps/contentstore/features/html-editor.py b/cms/djangoapps/contentstore/features/html-editor.py
index 6baed40eac..9fdcd0a96b 100644
--- a/cms/djangoapps/contentstore/features/html-editor.py
+++ b/cms/djangoapps/contentstore/features/html-editor.py
@@ -10,7 +10,7 @@ CODEMIRROR_SELECTOR_PREFIX = "$('iframe').contents().find"
@step('I have created a Blank HTML Page$')
def i_created_blank_html_page(step):
- world.create_course_with_unit()
+ step.given('I am in Studio editing a new unit')
world.create_component_instance(
step=step,
category='html',
@@ -20,7 +20,7 @@ def i_created_blank_html_page(step):
@step('I have created a raw HTML component')
def i_created_raw_html(step):
- world.create_course_with_unit()
+ step.given('I am in Studio editing a new unit')
world.create_component_instance(
step=step,
category='html',
@@ -40,7 +40,7 @@ def i_see_only_the_html_display_name(step):
@step('I have created an E-text Written in LaTeX$')
def i_created_etext_in_latex(step):
- world.create_course_with_unit()
+ step.given('I am in Studio editing a new unit')
step.given('I have enabled latex compiler')
world.create_component_instance(
step=step,
diff --git a/cms/djangoapps/contentstore/features/problem-editor.py b/cms/djangoapps/contentstore/features/problem-editor.py
index 44647b5eeb..65cf8c5e95 100644
--- a/cms/djangoapps/contentstore/features/problem-editor.py
+++ b/cms/djangoapps/contentstore/features/problem-editor.py
@@ -19,13 +19,13 @@ MATLAB_API_KEY = "Matlab API key"
@step('I have created a Blank Common Problem$')
def i_created_blank_common_problem(step):
- world.create_course_with_unit()
+ step.given('I am in Studio editing a new unit')
step.given("I have created another Blank Common Problem")
@step('I have created a unit with advanced module "(.*)"$')
def i_created_unit_with_advanced_module(step, advanced_module):
- world.create_course_with_unit()
+ step.given('I am in Studio editing a new unit')
url = world.browser.url
step.given("I select the Advanced Settings")
@@ -239,7 +239,7 @@ def enable_latex_compiler(step):
@step('I have created a LaTeX Problem')
def create_latex_problem(step):
- world.create_course_with_unit()
+ step.given('I am in Studio editing a new unit')
step.given('I have enabled latex compiler')
world.create_component_instance(
step=step,
diff --git a/cms/djangoapps/contentstore/features/section.feature b/cms/djangoapps/contentstore/features/section.feature
deleted file mode 100644
index 4ad3f8efa3..0000000000
--- a/cms/djangoapps/contentstore/features/section.feature
+++ /dev/null
@@ -1,44 +0,0 @@
-@shard_2
-Feature: CMS.Create Section
- In order offer a course on the edX platform
- As a course author
- I want to create and edit sections
-
- Scenario: Add a new section to a course
- Given I have opened a new course in Studio
- When I click the New Section link
- And I enter the section name and click save
- Then I see my section on the Courseware page
- And I see a release date for my section
- And I see a link to create a new subsection
-
- Scenario: Add a new section (with a quote in the name) to a course (bug #216)
- Given I have opened a new course in Studio
- When I click the New Section link
- And I enter a section name with a quote and click save
- Then I see my section name with a quote on the Courseware page
- And I click to edit the section name
- Then I see the complete section name with a quote in the editor
-
- Scenario: Edit section release date
- Given I have opened a new course in Studio
- And I have added a new section
- When I click the Edit link for the release date
- And I set the section release date to 12/25/2013
- Then the section release date is updated
- And I see a "saving" notification
-
- Scenario: Section name not clickable on editing release date
- Given I have opened a new course in Studio
- And I have added a new section
- When I click the Edit link for the release date
- And I click on section name in Section Release Date modal
- Then I see no form for editing section name in modal
-
- Scenario: Delete section
- Given I have opened a new course in Studio
- And I have added a new section
- When I will confirm all alerts
- And I press the "section" delete icon
- And I confirm the prompt
- Then the section does not exist
diff --git a/cms/djangoapps/contentstore/features/section.py b/cms/djangoapps/contentstore/features/section.py
deleted file mode 100644
index eef87e1f4e..0000000000
--- a/cms/djangoapps/contentstore/features/section.py
+++ /dev/null
@@ -1,142 +0,0 @@
-# pylint: disable=C0111
-# pylint: disable=W0621
-
-from lettuce import world, step
-from common import *
-from nose.tools import assert_equal # pylint: disable=E0611
-
-
-@step('I click the New Section link$')
-def i_click_new_section_link(_step):
- link_css = 'a.new-courseware-section-button'
- world.css_click(link_css)
-
-
-@step('I enter the section name and click save$')
-def i_save_section_name(_step):
- save_section_name('My Section')
-
-
-@step('I enter a section name with a quote and click save$')
-def i_save_section_name_with_quote(_step):
- save_section_name('Section with "Quote"')
-
-
-@step('I have added a new section$')
-def i_have_added_new_section(_step):
- add_section()
-
-
-@step('I click the Edit link for the release date$')
-def i_click_the_edit_link_for_the_release_date(_step):
- button_css = 'div.section-published-date a.edit-release-date'
- world.css_click(button_css)
-
-
-@step('I set the section release date to ([0-9/-]+)( [0-9:]+)?')
-def set_section_release_date(_step, datestring, timestring):
- if hasattr(timestring, "strip"):
- timestring = timestring.strip()
- if not timestring:
- timestring = "00:00"
- set_date_and_time(
- 'input.start-date.date.hasDatepicker', datestring,
- 'input.start-time.time.ui-timepicker-input', timestring)
- world.browser.click_link_by_text('Save')
-
-
-@step('I see a "(saving|deleting)" notification')
-def i_see_a_mini_notification(_step, _type):
- saving_css = '.wrapper-notification-mini'
- assert world.is_css_present(saving_css)
-
-
-@step('I see my section on the Courseware page$')
-def i_see_my_section_on_the_courseware_page(_step):
- see_my_section_on_the_courseware_page('My Section')
-
-
-@step('I see my section name with a quote on the Courseware page$')
-def i_see_my_section_name_with_quote_on_the_courseware_page(_step):
- see_my_section_on_the_courseware_page('Section with "Quote"')
-
-
-@step('I click to edit the section name$')
-def i_click_to_edit_section_name(_step):
- world.css_click('span.section-name-span')
-
-
-@step('I click on section name in Section Release Date modal$')
-def i_click_on_section_name_in_modal(_step):
- world.css_click('.modal-window .section-name')
-
-
-@step('I see no form for editing section name in modal$')
-def edit_section_name_form_not_exist(_step):
- assert world.is_css_not_present('.modal-window .section-name input')
-
-
-@step('I see the complete section name with a quote in the editor$')
-def i_see_complete_section_name_with_quote_in_editor(_step):
- css = '.section-name-edit input[type=text]'
- assert world.is_css_present(css)
- assert_equal(world.css_value(css), 'Section with "Quote"')
-
-
-@step('the section does not exist$')
-def section_does_not_exist(_step):
- css = 'h3[data-name="My Section"]'
- assert world.is_css_not_present(css)
-
-
-@step('I see a release date for my section$')
-def i_see_a_release_date_for_my_section(_step):
- import re
-
- css = 'span.published-status'
- assert world.is_css_present(css)
- status_text = world.css_text(css)
-
- # e.g. 11/06/2012 at 16:25
- msg = 'Release date:'
- date_regex = r'(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d\d?, \d{4}'
- if not re.search(date_regex, status_text):
- print status_text, date_regex
- time_regex = r'[0-2]\d:[0-5]\d( \w{3})?'
- if not re.search(time_regex, status_text):
- print status_text, time_regex
- match_string = r'%s\s+%s at %s' % (msg, date_regex, time_regex)
- if not re.match(match_string, status_text):
- print status_text, match_string
- assert re.match(match_string, status_text)
-
-
-@step('I see a link to create a new subsection$')
-def i_see_a_link_to_create_a_new_subsection(_step):
- css = 'a.new-subsection-item'
- assert world.is_css_present(css)
-
-
-@step('the section release date picker is not visible$')
-def the_section_release_date_picker_not_visible(_step):
- css = 'div.edit-subsection-publish-settings'
- assert not world.css_visible(css)
-
-
-@step('the section release date is updated$')
-def the_section_release_date_is_updated(_step):
- css = 'span.published-status'
- status_text = world.css_text(css)
- assert_equal(status_text, 'Release date: 12/25/2013 at 00:00 UTC')
-
-
-def save_section_name(name):
- name_css = '.new-section-name'
- save_css = '.new-section-name-save'
- world.css_fill(name_css, name)
- world.css_click(save_css)
-
-
-def see_my_section_on_the_courseware_page(name):
- section_css = 'span.section-name-span'
- assert world.css_has_text(section_css, name)
diff --git a/cms/djangoapps/contentstore/features/subsection.feature b/cms/djangoapps/contentstore/features/subsection.feature
deleted file mode 100644
index 77440190b3..0000000000
--- a/cms/djangoapps/contentstore/features/subsection.feature
+++ /dev/null
@@ -1,75 +0,0 @@
-@shard_2
-Feature: CMS.Create Subsection
- In order offer a course on the edX platform
- As a course author
- I want to create and edit subsections
-
- Scenario: Add a new subsection to a section
- Given I have opened a new course section in Studio
- When I click the New Subsection link
- And I enter the subsection name and click save
- Then I see my subsection on the Courseware page
-
- Scenario: Add a new subsection (with a name containing a quote) to a section (bug #216)
- Given I have opened a new course section in Studio
- When I click the New Subsection link
- And I enter a subsection name with a quote and click save
- Then I see my subsection name with a quote on the Courseware page
- And I click on the subsection
- Then I see the complete subsection name with a quote in the editor
-
- Scenario: Assign grading type to a subsection and verify it is still shown after refresh (bug #258)
- Given I have opened a new course section in Studio
- And I have added a new subsection
- And I mark it as Homework
- Then I see it marked as Homework
- And I reload the page
- Then I see it marked as Homework
-
- # Safari has trouble saving the date in Sauce
- @skip_safari
- Scenario: Set a due date in a different year (bug #256)
- Given I have opened a new subsection in Studio
- And I set the subsection release date to 12/25/2011 03:00
- And I set the subsection due date to 01/02/2012 04:00
- Then I see the subsection release date is 12/25/2011 03:00
- And I see the subsection due date is 01/02/2012 04:00
- And I reload the page
- Then I see the subsection release date is 12/25/2011 03:00
- And I see the subsection due date is 01/02/2012 04:00
-
- @skip_safari
- Scenario: Set release and due dates of subsection on enter
- Given I have opened a new subsection in Studio
- And I set the subsection release date on enter to 04/04/2014 03:00
- And I set the subsection due date on enter to 04/04/2014 04:00
- Then I see the subsection release date is 04/04/2014 03:00
- And I see the subsection due date is 04/04/2014 04:00
- And I reload the page
- Then I see the subsection release date is 04/04/2014 03:00
- And I see the subsection due date is 04/04/2014 04:00
-
- Scenario: Delete a subsection
- Given I have opened a new course section in Studio
- And I have added a new subsection
- And I see my subsection on the Courseware page
- When I will confirm all alerts
- And I press the "subsection" delete icon
- And I confirm the prompt
- Then the subsection does not exist
-
- @skip_safari
- Scenario: Sync to Section
- Given I have opened a new course section in Studio
- And I click the Edit link for the release date
- And I set the section release date to 01/02/2103
- And I have added a new subsection
- And I click on the subsection
- And I set the subsection release date to 06/20/2104
- Then I see the subsection release date is 06/20/2104
- And I reload the page
- Then I see the subsection release date is 06/20/2104
- And I click the link to sync release date to section
- And I wait for "1" second
- And I reload the page
- Then I see the subsection release date is 01/02/2103
diff --git a/cms/djangoapps/contentstore/features/subsection.py b/cms/djangoapps/contentstore/features/subsection.py
deleted file mode 100644
index 6f8489beb3..0000000000
--- a/cms/djangoapps/contentstore/features/subsection.py
+++ /dev/null
@@ -1,158 +0,0 @@
-# pylint: disable=C0111
-# pylint: disable=W0621
-
-from lettuce import world, step
-from common import *
-from nose.tools import assert_equal # pylint: disable=E0611
-
-############### ACTIONS ####################
-
-
-@step('I have opened a new course section in Studio$')
-def i_have_opened_a_new_course_section(step):
- open_new_course()
- add_section()
-
-
-@step('I have added a new subsection$')
-def i_have_added_a_new_subsection(step):
- add_subsection()
-
-
-@step('I have opened a new subsection in Studio$')
-def i_have_opened_a_new_subsection(step):
- step.given('I have opened a new course section in Studio')
- step.given('I have added a new subsection')
- world.css_click('span.subsection-name-value')
-
-
-@step('I click the New Subsection link')
-def i_click_the_new_subsection_link(step):
- world.css_click('a.new-subsection-item')
-
-
-@step('I enter the subsection name and click save$')
-def i_save_subsection_name(step):
- save_subsection_name('Subsection One')
-
-
-@step('I enter a subsection name with a quote and click save$')
-def i_save_subsection_name_with_quote(step):
- save_subsection_name('Subsection With "Quote"')
-
-
-@step('I click on the subsection$')
-def click_on_subsection(step):
- world.css_click('span.subsection-name-value')
-
-
-@step('I see the complete subsection name with a quote in the editor$')
-def i_see_complete_subsection_name_with_quote_in_editor(step):
- css = '.subsection-display-name-input'
- assert world.is_css_present(css)
- assert_equal(world.css_value(css), 'Subsection With "Quote"')
-
-
-@step('I set the subsection release date to ([0-9/-]+)( [0-9:]+)?')
-def set_subsection_release_date(_step, datestring, timestring):
- set_subsection_date('input#start_date', datestring, 'input#start_time', timestring)
-
-
-@step('I set the subsection release date on enter to ([0-9/-]+)( [0-9:]+)?')
-def set_subsection_release_date_on_enter(_step, datestring, timestring): # pylint: disable-msg=invalid-name
- set_subsection_date('input#start_date', datestring, 'input#start_time', timestring, 'ENTER')
-
-
-@step('I set the subsection due date to ([0-9/-]+)( [0-9:]+)?')
-def set_subsection_due_date(_step, datestring, timestring, key=None):
- if not world.css_visible('input#due_date'):
- world.css_click('.due-date-input .set-date')
-
- assert world.css_visible('input#due_date')
- set_subsection_date('input#due_date', datestring, 'input#due_time', timestring, key)
-
-
-@step('I set the subsection due date on enter to ([0-9/-]+)( [0-9:]+)?')
-def set_subsection_due_date_on_enter(_step, datestring, timestring): # pylint: disable-msg=invalid-name
- set_subsection_due_date(_step, datestring, timestring, 'ENTER')
-
-
-@step('I mark it as Homework$')
-def i_mark_it_as_homework(step):
- world.css_click('a.menu-toggle')
- world.browser.click_link_by_text('Homework')
-
-
-@step('I see it marked as Homework$')
-def i_see_it_marked__as_homework(step):
- assert_equal(world.css_value(".status-label"), 'Homework')
-
-
-@step('I click the link to sync release date to section')
-def click_sync_release_date(step):
- world.css_click('.sync-date')
-
-
-############ ASSERTIONS ###################
-
-
-@step('I see my subsection on the Courseware page$')
-def i_see_my_subsection_on_the_courseware_page(step):
- see_subsection_name('Subsection One')
-
-
-@step('I see my subsection name with a quote on the Courseware page$')
-def i_see_my_subsection_name_with_quote_on_the_courseware_page(step):
- see_subsection_name('Subsection With "Quote"')
-
-
-@step('the subsection does not exist$')
-def the_subsection_does_not_exist(step):
- css = 'span.subsection-name'
- assert world.is_css_not_present(css)
-
-
-@step('I see the subsection release date is ([0-9/-]+)( [0-9:]+)?')
-def i_see_subsection_release(_step, datestring, timestring):
- if hasattr(timestring, "strip"):
- timestring = timestring.strip()
- assert_equal(datestring, get_date('input#start_date'))
- if timestring:
- assert_equal(timestring, get_date('input#start_time'))
-
-
-@step('I see the subsection due date is ([0-9/-]+)( [0-9:]+)?')
-def i_see_subsection_due(_step, datestring, timestring):
- if hasattr(timestring, "strip"):
- timestring = timestring.strip()
- assert_equal(datestring, get_date('input#due_date'))
- if timestring:
- assert_equal(timestring, get_date('input#due_time'))
-
-
-############ HELPER METHODS ###################
-def get_date(css):
- return world.css_find(css).first.value.strip()
-
-
-def save_subsection_name(name):
- name_css = 'input.new-subsection-name-input'
- save_css = 'input.new-subsection-name-save'
- world.css_fill(name_css, name)
- world.css_click(save_css)
-
-
-def see_subsection_name(name):
- css = 'span.subsection-name'
- assert world.is_css_present(css)
- css = 'span.subsection-name-value'
- assert world.css_has_text(css, name)
-
-
-def set_subsection_date(date_css, datestring, time_css, timestring, key=None):
- if hasattr(timestring, "strip"):
- timestring = timestring.strip()
- if not timestring:
- timestring = "00:00"
-
- set_date_and_time(date_css, datestring, time_css, timestring, key)
diff --git a/cms/djangoapps/contentstore/features/video.py b/cms/djangoapps/contentstore/features/video.py
index 083193760d..ebe94f4a79 100644
--- a/cms/djangoapps/contentstore/features/video.py
+++ b/cms/djangoapps/contentstore/features/video.py
@@ -31,11 +31,10 @@ def configure_youtube_api(_step, action):
raise ValueError('Parameter `action` should be one of "proxies" or "blocks".')
@step('I have created a Video component$')
-def i_created_a_video_component(_step):
-
- world.create_course_with_unit()
+def i_created_a_video_component(step):
+ step.given('I am in Studio editing a new unit')
world.create_component_instance(
- step=_step,
+ step=step,
category='video',
)
diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py
index fe17d849c6..1c8a274a18 100644
--- a/cms/djangoapps/contentstore/tests/test_contentstore.py
+++ b/cms/djangoapps/contentstore/tests/test_contentstore.py
@@ -1209,7 +1209,10 @@ class ContentStoreTest(ContentStoreTestCase):
resp = self._show_course_overview(course.id)
self.assertContains(
resp,
- '',
+ ''.format(
+ locator='i4x://MITx/999/course/Robot_Super_Course',
+ course_key='MITx/999/Robot_Super_Course',
+ ),
status_code=200,
html=True
)
diff --git a/cms/djangoapps/contentstore/tests/utils.py b/cms/djangoapps/contentstore/tests/utils.py
index 0682560f70..fcfa7ef979 100644
--- a/cms/djangoapps/contentstore/tests/utils.py
+++ b/cms/djangoapps/contentstore/tests/utils.py
@@ -102,10 +102,11 @@ class CourseTestCase(ModuleStoreTestCase):
"""
Add 2 chapters, 4 sections, 8 verticals, 16 problems to self.course (branching 2)
"""
+ user_id = self.user.id
def descend(parent, stack):
xblock_type = stack.pop(0)
for _ in range(2):
- child = ItemFactory.create(category=xblock_type, parent_location=parent.location)
+ child = ItemFactory.create(category=xblock_type, parent_location=parent.location, user_id=user_id)
if stack:
descend(child, stack)
diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py
index 2bc93fcf79..193b0e088d 100644
--- a/cms/djangoapps/contentstore/views/component.py
+++ b/cms/djangoapps/contentstore/views/component.py
@@ -156,6 +156,7 @@ def container_handler(request, usage_key_string):
component_templates = get_component_templates(course)
ancestor_xblocks = []
parent = get_parent_xblock(xblock)
+ action = request.REQUEST.get('action', 'view')
is_unit_page = is_unit(xblock)
unit = xblock if is_unit_page else None
@@ -172,7 +173,10 @@ def container_handler(request, usage_key_string):
assert subsection is not None, "Could not determine parent subsection from unit " + unicode(unit.location)
section = get_parent_xblock(subsection)
assert section is not None, "Could not determine ancestor section from unit " + unicode(unit.location)
- xblock_info = create_xblock_info(usage_key, xblock)
+
+ # Fetch the XBlock info for use by the container page. Note that it includes information
+ # about the block's ancestors and siblings for use by the Unit Outline.
+ xblock_info = create_xblock_info(xblock, include_ancestor_info=is_unit_page)
# Create the link for preview.
preview_lms_base = settings.FEATURES.get('PREVIEW_LMS_BASE')
@@ -198,6 +202,7 @@ def container_handler(request, usage_key_string):
return render_to_response('container.html', {
'context_course': course, # Needed only for display of menus at top of page.
+ 'action': action,
'xblock': xblock,
'xblock_locator': xblock.location,
'unit': unit,
diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py
index 7b67dbf6df..dcc802b030 100644
--- a/cms/djangoapps/contentstore/views/course.py
+++ b/cms/djangoapps/contentstore/views/course.py
@@ -55,6 +55,7 @@ from .component import (
ADVANCED_COMPONENT_TYPES,
)
from .tasks import rerun_course
+from .item import create_xblock_info
from opaque_keys.edx.keys import CourseKey
from course_creators.views import get_course_creator_status, add_user_with_status_unrequested
@@ -210,7 +211,7 @@ def course_handler(request, course_key_string=None):
response_format = request.REQUEST.get('format', 'html')
if response_format == 'json' or 'application/json' in request.META.get('HTTP_ACCEPT', 'application/json'):
if request.method == 'GET':
- return JsonResponse(_course_json(request, CourseKey.from_string(course_key_string)))
+ return JsonResponse(_course_outline_json(request, CourseKey.from_string(course_key_string)))
elif request.method == 'POST': # not sure if this is only post. If one will have ids, it goes after access
return _create_or_rerun_course(request)
elif not has_course_access(request.user, CourseKey.from_string(course_key_string)):
@@ -230,30 +231,16 @@ def course_handler(request, course_key_string=None):
return HttpResponseNotFound()
-@login_required
-def _course_json(request, course_key):
+def _course_outline_json(request, course_key):
"""
- Returns a JSON overview of a course
+ Returns a JSON representation of the course module and recursively all of its children.
"""
course_module = _get_course_module(course_key, request.user, depth=None)
- return _xmodule_json(course_module, course_module.id)
-
-
-def _xmodule_json(xmodule, course_id):
- """
- Returns a JSON overview of an XModule
- """
- is_container = xmodule.has_children
- result = {
- 'display_name': xmodule.display_name,
- 'id': unicode(xmodule.location),
- 'category': xmodule.category,
- 'is_draft': getattr(xmodule, 'is_draft', False),
- 'is_container': is_container,
- }
- if is_container:
- result['children'] = [_xmodule_json(child, course_id) for child in xmodule.get_children()]
- return result
+ return create_xblock_info(
+ course_module,
+ include_child_info=True,
+ include_children_predicate=lambda xblock: not xblock.category == 'vertical'
+ )
def _accessible_courses_list(request):
@@ -384,27 +371,68 @@ def course_index(request, course_key):
course_module = _get_course_module(course_key, request.user, depth=3)
lms_link = get_lms_link_for_item(course_module.location)
sections = course_module.get_children()
+ course_structure = _course_outline_json(request, course_key)
+ locator_to_show = request.REQUEST.get('show', None)
try:
current_action = CourseRerunState.objects.find_first(course_key=course_key, should_display=True)
except (ItemNotFoundError, CourseActionStateItemNotFoundError):
current_action = None
-
- return render_to_response('overview.html', {
+
+ return render_to_response('course_outline.html', {
'context_course': course_module,
'lms_link': lms_link,
'sections': sections,
+ 'course_structure': course_structure,
+ 'initial_state': course_outline_initial_state(locator_to_show, course_structure) if locator_to_show else None,
'course_graders': json.dumps(
CourseGradingModel.fetch(course_key).graders
),
- 'new_section_category': 'chapter',
- 'new_subsection_category': 'sequential',
- 'new_unit_category': 'vertical',
- 'category': 'vertical',
'rerun_notification_id': current_action.id if current_action else None,
})
+def course_outline_initial_state(locator_to_show, course_structure):
+ """
+ Returns the desired initial state for the course outline view. If the 'show' request parameter
+ was provided, then the view's initial state will be to have the desired item fully expanded
+ and to scroll to see the new item.
+ """
+ def find_xblock_info(xblock_info, locator):
+ """
+ Finds the xblock info for the specified locator.
+ """
+ if xblock_info['id'] == locator:
+ return xblock_info
+ children = xblock_info['child_info']['children'] if xblock_info['child_info'] else None
+ if children:
+ for child_xblock_info in children:
+ result = find_xblock_info(child_xblock_info, locator)
+ if result:
+ return result
+ return None
+
+ def collect_all_locators(locators, xblock_info):
+ """
+ Collect all the locators for an xblock and its children.
+ """
+ locators.append(xblock_info['id'])
+ children = xblock_info['child_info']['children'] if xblock_info['child_info'] else None
+ if children:
+ for child_xblock_info in children:
+ collect_all_locators(locators, child_xblock_info)
+
+ selected_xblock_info = find_xblock_info(course_structure, locator_to_show)
+ if not selected_xblock_info:
+ return None
+ expanded_locators = []
+ collect_all_locators(expanded_locators, selected_xblock_info)
+ return {
+ 'locator_to_show': locator_to_show,
+ 'expanded_locators': expanded_locators
+ }
+
+
@expect_json
def _create_or_rerun_course(request):
"""
diff --git a/cms/djangoapps/contentstore/views/helpers.py b/cms/djangoapps/contentstore/views/helpers.py
index cefe82d4d4..45c6a8c17e 100644
--- a/cms/djangoapps/contentstore/views/helpers.py
+++ b/cms/djangoapps/contentstore/views/helpers.py
@@ -4,6 +4,8 @@ Helper methods for Studio views.
from __future__ import absolute_import
+import urllib
+
from django.conf import settings
from django.http import HttpResponse
from django.shortcuts import redirect
@@ -74,9 +76,7 @@ def xblock_has_own_studio_page(xblock):
2. Verticals that are either:
- themselves treated as units
- a direct child of a unit
- 3. XBlocks with children, except for:
- - sequentials (aka subsections)
- - chapters (aka sections)
+ 3. XBlocks that support children
"""
category = xblock.category
@@ -85,8 +85,6 @@ def xblock_has_own_studio_page(xblock):
elif category == 'vertical':
parent_xblock = get_parent_xblock(xblock)
return is_unit(parent_xblock) if parent_xblock else False
- elif category == 'sequential':
- return False
# All other xblocks with children have their own page
return xblock.has_children
@@ -99,8 +97,13 @@ def xblock_studio_url(xblock):
if not xblock_has_own_studio_page(xblock):
return None
category = xblock.category
- if category in ('course', 'chapter'):
+ if category == 'course':
return reverse_course_url('course_handler', xblock.location.course_key)
+ elif category in ('chapter', 'sequential'):
+ return u'{url}?show={usage_key}'.format(
+ url=reverse_course_url('course_handler', xblock.location.course_key),
+ usage_key=urllib.quote(unicode(xblock.location))
+ )
else:
return reverse_usage_url('container_handler', xblock.location)
@@ -116,13 +119,33 @@ def xblock_type_display_name(xblock, default_display_name=None):
"""
if hasattr(xblock, 'category'):
- if is_unit(xblock):
- return _('Unit')
category = xblock.category
+ if category == 'vertical' and not is_unit(xblock):
+ return _('Vertical')
else:
category = xblock
+ if category == 'chapter':
+ return _('Section')
+ elif category == 'sequential':
+ return _('Subsection')
+ elif category == 'vertical':
+ return _('Unit')
component_class = XBlock.load_class(category, select=settings.XBLOCK_SELECT_FUNCTION)
if hasattr(component_class, 'display_name') and component_class.display_name.default:
return _(component_class.display_name.default)
else:
return default_display_name
+
+
+def xblock_primary_child_category(xblock):
+ """
+ Returns the primary child category for the specified xblock, or None if there is not a primary category.
+ """
+ category = xblock.category
+ if category == 'course':
+ return 'chapter'
+ elif category == 'chapter':
+ return 'sequential'
+ elif category == 'sequential':
+ return 'vertical'
+ return None
diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py
index 07a7309df2..f7eb285bcc 100644
--- a/cms/djangoapps/contentstore/views/item.py
+++ b/cms/djangoapps/contentstore/views/item.py
@@ -26,27 +26,30 @@ from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError, InvalidLocationError
from xmodule.modulestore.inheritance import own_metadata
from xmodule.x_module import PREVIEW_VIEWS, STUDIO_VIEW, STUDENT_VIEW
-from contentstore.utils import compute_publish_state
-from xmodule.modulestore import PublishState
from django.contrib.auth.models import User
from util.date_utils import get_default_time_display
from util.json_request import expect_json, JsonResponse
from .access import has_course_access
-from contentstore.views.helpers import is_unit
+from contentstore.views.helpers import is_unit, xblock_studio_url, xblock_primary_child_category, \
+ xblock_type_display_name, get_parent_xblock
from contentstore.views.preview import get_preview_fragment
from edxmako.shortcuts import render_to_string
from models.settings.course_grading import CourseGradingModel
from cms.lib.xblock.runtime import handler_url, local_resource_url
from opaque_keys.edx.keys import UsageKey, CourseKey
-__all__ = ['orphan_handler', 'xblock_handler', 'xblock_view_handler']
+__all__ = ['orphan_handler', 'xblock_handler', 'xblock_view_handler', 'xblock_outline_handler']
log = logging.getLogger(__name__)
CREATE_IF_NOT_FOUND = ['course_info']
+# Useful constants for defining predicates
+NEVER = lambda x: False
+ALWAYS = lambda x: True
+
# In order to allow descriptors to use a handler url, we need to
# monkey-patch the x_module library.
@@ -87,7 +90,7 @@ def xblock_handler(request, usage_key_string):
json: returns representation of the xblock (locator id, data, and metadata).
if ?fields=graderType, it returns the graderType for the unit instead of the above.
html: returns HTML for rendering the xblock (which includes both the "preview" view and the "editor" view)
- PUT or POST
+ PUT or POST or PATCH
json: if xblock locator is specified, update the xblock instance. The json payload can contain
these fields, all optional:
:data: the new value for the data.
@@ -254,6 +257,33 @@ def xblock_view_handler(request, usage_key_string, view_name):
return HttpResponse(status=406)
+# pylint: disable=unused-argument
+@require_http_methods(("GET"))
+@login_required
+@expect_json
+def xblock_outline_handler(request, usage_key_string):
+ """
+ The restful handler for requests for XBlock information about the block and its children.
+ This is used by the course outline in particular to construct the tree representation of
+ a course.
+ """
+ usage_key = UsageKey.from_string(usage_key_string)
+ if not has_course_access(request.user, usage_key.course_key):
+ raise PermissionDenied()
+
+ response_format = request.REQUEST.get('format', 'html')
+ if response_format == 'json' or 'application/json' in request.META.get('HTTP_ACCEPT', 'application/json'):
+ store = modulestore()
+ root_xblock = store.get_item(usage_key)
+ return JsonResponse(create_xblock_info(
+ root_xblock,
+ include_child_info=True,
+ include_children_predicate=lambda xblock: not xblock.category == 'vertical'
+ ))
+ else:
+ return Http404
+
+
def _save_item(user, usage_key, data=None, children=None, metadata=None, nullout=None,
grader_type=None, publish=None):
"""
@@ -541,17 +571,25 @@ def _get_module_info(usage_key, user, rewrite_static_links=True):
)
# Note that children aren't being returned until we have a use case.
- return create_xblock_info(usage_key, module, data, own_metadata(module))
+ return create_xblock_info(module, data=data, metadata=own_metadata(module), include_ancestor_info=True)
-def create_xblock_info(usage_key, xblock, data=None, metadata=None):
+def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=False, include_child_info=False,
+ include_children_predicate=NEVER):
"""
Creates the information needed for client-side XBlockInfo.
If data or metadata are not specified, their information will not be added
(regardless of whether or not the xblock actually has data or metadata).
+
+ There are two optional boolean parameters:
+ include_ancestor_info - if true, ancestor info is added to the response
+ include_child_info - if true, direct child info is included in the response
+
+ In addition, an optional include_children_predicate argument can be provided to define whether or
+ not a particular xblock should have its children included.
"""
- publish_state = compute_publish_state(xblock) if xblock else None
+ published = modulestore().has_item(xblock.location, revision=ModuleStoreEnum.RevisionOption.published_only)
def safe_get_username(user_id):
"""
@@ -574,16 +612,68 @@ def create_xblock_info(usage_key, xblock, data=None, metadata=None):
"id": unicode(xblock.location),
"display_name": xblock.display_name_with_default,
"category": xblock.category,
- "has_changes": modulestore().has_changes(usage_key),
- "published": publish_state in (PublishState.public, PublishState.draft),
+ "has_changes": modulestore().has_changes(xblock.location),
+ "published": published,
"edited_on": get_default_time_display(xblock.subtree_edited_on) if xblock.subtree_edited_on else None,
"edited_by": safe_get_username(xblock.subtree_edited_by),
"published_on": get_default_time_display(xblock.published_date) if xblock.published_date else None,
"published_by": safe_get_username(xblock.published_by),
+ 'studio_url': xblock_studio_url(xblock),
}
if data is not None:
xblock_info["data"] = data
if metadata is not None:
xblock_info["metadata"] = metadata
-
+ if include_ancestor_info:
+ xblock_info['ancestor_info'] = _create_xblock_ancestor_info(xblock)
+ if include_child_info and xblock.has_children:
+ xblock_info['child_info'] = _create_xblock_child_info(
+ xblock, include_children_predicate=include_children_predicate
+ )
return xblock_info
+
+
+def _create_xblock_ancestor_info(xblock):
+ """
+ Returns information about the ancestors of an xblock. Note that the direct parent will also return
+ information about all of its children.
+ """
+ ancestors = []
+
+ def collect_ancestor_info(ancestor, include_child_info=False):
+ """
+ Collect xblock info regarding the specified xblock and its ancestors.
+ """
+ if ancestor:
+ direct_children_only = lambda parent: parent == ancestor
+ ancestors.append(create_xblock_info(
+ ancestor,
+ include_child_info=include_child_info,
+ include_children_predicate=direct_children_only
+ ))
+ collect_ancestor_info(get_parent_xblock(ancestor))
+ collect_ancestor_info(get_parent_xblock(xblock), include_child_info=True)
+ return {
+ 'ancestors': ancestors
+ }
+
+
+def _create_xblock_child_info(xblock, include_children_predicate=NEVER):
+ """
+ Returns information about the children of an xblock, as well as about the primary category
+ of xblock expected as children.
+ """
+ child_info = {}
+ child_category = xblock_primary_child_category(xblock)
+ if child_category:
+ child_info = {
+ 'category': child_category,
+ 'display_name': xblock_type_display_name(child_category, default_display_name=child_category),
+ }
+ if xblock.has_children and include_children_predicate(xblock):
+ child_info['children'] = [
+ create_xblock_info(
+ child, include_child_info=True, include_children_predicate=include_children_predicate
+ ) for child in xblock.get_children()
+ ]
+ return child_info
diff --git a/cms/djangoapps/contentstore/views/tests/test_container_page.py b/cms/djangoapps/contentstore/views/tests/test_container_page.py
index 81a272061d..e3fd69d609 100644
--- a/cms/djangoapps/contentstore/views/tests/test_container_page.py
+++ b/cms/djangoapps/contentstore/views/tests/test_container_page.py
@@ -52,12 +52,15 @@ class ContainerPageTestCase(StudioPageTestCase):
'data-locator="{0}" data-course-key="{0.course_key}">'.format(self.child_container.location)
),
expected_breadcrumbs=(
- r'\s*Week 1\s*\s*'
- r'\s*Lesson 1\s*\s*'
- r'\s*Unit\s*'
+ r'\s*Week 1\s*\s*'
+ r'\s*Lesson 1\s*\s*'
+ r'\s*Unit\s*'
).format(
course=re.escape(unicode(self.course.id)),
unit=re.escape(unicode(self.vertical.location)),
+ classes='navigation-item navigation-link navigation-parent',
+ section_parameters=re.escape(u'?show=i4x%3A//MITx/999/chapter/Week_1'),
+ subsection_parameters=re.escape(u'?show=i4x%3A//MITx/999/sequential/Lesson_1'),
),
)
@@ -77,14 +80,17 @@ class ContainerPageTestCase(StudioPageTestCase):
'data-locator="{0}" data-course-key="{0.course_key}">'.format(draft_container.location)
),
expected_breadcrumbs=(
- r'\s*Week 1\s*\s*'
- r'\s*Lesson 1\s*\s*'
- r'\s*Unit\s*\s*'
- r'\s*Split Test\s*'
+ r'\s*Week 1\s*\s*'
+ r'\s*Lesson 1\s*\s*'
+ r'\s*Unit\s*\s*'
+ r'\s*Split Test\s*'
).format(
course=re.escape(unicode(self.course.id)),
unit=re.escape(unicode(self.vertical.location)),
- split_test=re.escape(unicode(self.child_container.location))
+ split_test=re.escape(unicode(self.child_container.location)),
+ classes='navigation-item navigation-link navigation-parent',
+ section_parameters=re.escape(u'?show=i4x%3A//MITx/999/chapter/Week_1'),
+ subsection_parameters=re.escape(u'?show=i4x%3A//MITx/999/sequential/Lesson_1'),
),
)
diff --git a/cms/djangoapps/contentstore/views/tests/test_course_index.py b/cms/djangoapps/contentstore/views/tests/test_course_index.py
index cc82dc6fd5..d444742a7c 100644
--- a/cms/djangoapps/contentstore/views/tests/test_course_index.py
+++ b/cms/djangoapps/contentstore/views/tests/test_course_index.py
@@ -7,7 +7,10 @@ import lxml
from contentstore.tests.utils import CourseTestCase
from contentstore.utils import reverse_course_url, add_instructor
from contentstore.views.access import has_course_access
+from contentstore.views.course import course_outline_initial_state
from course_action_state.models import CourseRerunState
+from contentstore.views.item import create_xblock_info
+from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
from opaque_keys.edx.locator import CourseLocator
from student.tests.factories import UserFactory
@@ -190,3 +193,84 @@ class TestCourseIndex(CourseTestCase):
self.assert_correct_json_response(child_response)
else:
self.assertFalse('children' in json_response)
+
+
+class TestCourseOutline(CourseTestCase):
+ """
+ Unit tests for the course outline.
+ """
+ def setUp(self):
+ """
+ Set up the for the course outline tests.
+ """
+ super(TestCourseOutline, self).setUp()
+ self.chapter = ItemFactory.create(
+ parent_location=self.course.location, category='chapter', display_name="Week 1"
+ )
+ self.sequential = ItemFactory.create(
+ parent_location=self.chapter.location, category='sequential', display_name="Lesson 1"
+ )
+ self.vertical = ItemFactory.create(
+ parent_location=self.sequential.location, category='vertical', display_name='Subsection 1'
+ )
+ self.video = ItemFactory.create(
+ parent_location=self.vertical.location, category="video", display_name="My Video"
+ )
+
+ def test_json_responses(self):
+ """
+ Verify the JSON responses returned for the course.
+ """
+ outline_url = reverse_course_url('course_handler', self.course.id)
+ resp = self.client.get(outline_url, HTTP_ACCEPT='application/json')
+ json_response = json.loads(resp.content)
+
+ # First spot check some values in the root response
+ self.assertEqual(json_response['category'], 'course')
+ self.assertEqual(json_response['id'], 'i4x://MITx/999/course/Robot_Super_Course')
+ self.assertEqual(json_response['display_name'], 'Robot Super Course')
+ self.assertTrue(json_response['published'])
+
+ # Now verify the first child
+ children = json_response['child_info']['children']
+ self.assertTrue(len(children) > 0)
+ first_child_response = children[0]
+ self.assertEqual(first_child_response['category'], 'chapter')
+ self.assertEqual(first_child_response['id'], 'i4x://MITx/999/chapter/Week_1')
+ self.assertEqual(first_child_response['display_name'], 'Week 1')
+ self.assertTrue(first_child_response['published'])
+ self.assertTrue(len(first_child_response['child_info']['children']) > 0)
+
+ # Finally, validate the entire response for consistency
+ self.assert_correct_json_response(json_response)
+
+ def assert_correct_json_response(self, json_response):
+ """
+ Asserts that the JSON response is syntactically consistent
+ """
+ self.assertIsNotNone(json_response['display_name'])
+ self.assertIsNotNone(json_response['id'])
+ self.assertIsNotNone(json_response['category'])
+ self.assertIsNotNone(json_response['published'])
+ if json_response.get('child_info', None):
+ for child_response in json_response['child_info']['children']:
+ self.assert_correct_json_response(child_response)
+
+ def test_course_outline_initial_state(self):
+ course_module = modulestore().get_item(self.course.location)
+ course_structure = create_xblock_info(
+ course_module,
+ include_child_info=True,
+ include_children_predicate=lambda xblock: not xblock.category == 'vertical'
+ )
+
+ # Verify that None is returned for a non-existent locator
+ self.assertIsNone(course_outline_initial_state('no-such-locator', course_structure))
+
+ # Verify that the correct initial state is returned for the test chapter
+ chapter_locator = unicode(self.chapter.location)
+ initial_state = course_outline_initial_state(chapter_locator, course_structure)
+ self.assertEqual(initial_state['locator_to_show'], chapter_locator)
+ expanded_locators = initial_state['expanded_locators']
+ self.assertIn(unicode(self.sequential.location), expanded_locators)
+ self.assertIn(unicode(self.vertical.location), expanded_locators)
\ No newline at end of file
diff --git a/cms/djangoapps/contentstore/views/tests/test_helpers.py b/cms/djangoapps/contentstore/views/tests/test_helpers.py
index 4df6ce5241..d9e33f2be4 100644
--- a/cms/djangoapps/contentstore/views/tests/test_helpers.py
+++ b/cms/djangoapps/contentstore/views/tests/test_helpers.py
@@ -22,12 +22,17 @@ class HelpersTestCase(CourseTestCase):
chapter = ItemFactory.create(parent_location=self.course.location, category='chapter',
display_name="Week 1")
self.assertEqual(xblock_studio_url(chapter),
- u'/course/MITx/999/Robot_Super_Course')
+ u'/course/MITx/999/Robot_Super_Course?show={escaped_usage_key}'.format(
+ escaped_usage_key='i4x%3A//MITx/999/chapter/Week_1'
+ ))
- # Verify lesson URL
+ # Verify sequential URL
sequential = ItemFactory.create(parent_location=chapter.location, category='sequential',
display_name="Lesson 1")
- self.assertIsNone(xblock_studio_url(sequential))
+ self.assertEqual(xblock_studio_url(sequential),
+ u'/course/MITx/999/Robot_Super_Course?show={escaped_usage_key}'.format(
+ escaped_usage_key='i4x%3A//MITx/999/sequential/Lesson_1'
+ ))
# Verify unit URL
vertical = ItemFactory.create(parent_location=sequential.location, category='vertical',
@@ -48,13 +53,25 @@ class HelpersTestCase(CourseTestCase):
def test_xblock_type_display_name(self):
+ # Verify chapter type display name
chapter = ItemFactory.create(parent_location=self.course.location, category='chapter')
+ self.assertEqual(xblock_type_display_name(chapter), u'Section')
+ self.assertEqual(xblock_type_display_name('chapter'), u'Section')
+
+ # Verify sequential type display name
sequential = ItemFactory.create(parent_location=chapter.location, category='sequential')
+ self.assertEqual(xblock_type_display_name(sequential), u'Subsection')
+ self.assertEqual(xblock_type_display_name('sequential'), u'Subsection')
# Verify unit type display names
vertical = ItemFactory.create(parent_location=sequential.location, category='vertical')
self.assertEqual(xblock_type_display_name(vertical), u'Unit')
- self.assertIsNone(xblock_type_display_name('vertical'))
+ self.assertEqual(xblock_type_display_name('vertical'), u'Unit')
+
+ # Verify child vertical type display name
+ child_vertical = ItemFactory.create(parent_location=vertical.location, category='vertical',
+ display_name='Child Vertical')
+ self.assertEqual(xblock_type_display_name(child_vertical), u'Vertical')
# Verify video type display names
video = ItemFactory.create(parent_location=vertical.location, category="video")
diff --git a/cms/djangoapps/contentstore/views/tests/test_item.py b/cms/djangoapps/contentstore/views/tests/test_item.py
index fb4c37075c..8f1afc7820 100644
--- a/cms/djangoapps/contentstore/views/tests/test_item.py
+++ b/cms/djangoapps/contentstore/views/tests/test_item.py
@@ -19,12 +19,14 @@ from contentstore.views.component import (
component_handler, get_component_templates
)
+from contentstore.views.item import create_xblock_info, ALWAYS
from contentstore.tests.utils import CourseTestCase
from student.tests.factories import UserFactory
from xmodule.capa_module import CapaDescriptor
from xmodule.modulestore import PublishState
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore
+from xmodule.modulestore.tests.factories import ItemFactory
from xmodule.x_module import STUDIO_VIEW, STUDENT_VIEW
from xblock.exceptions import NoSuchHandlerError
from opaque_keys.edx.keys import UsageKey, CourseKey
@@ -1025,3 +1027,170 @@ class TestComponentTemplates(CourseTestCase):
self.assertIsNotNone(ora_template)
self.assertEqual(ora_template.get('category'), 'openassessment')
self.assertIsNone(ora_template.get('boilerplate_name', None))
+
+
+class TestXBlockInfo(ItemTest):
+ """
+ Unit tests for XBlock's outline handling.
+ """
+ def setUp(self):
+ super(TestXBlockInfo, self).setUp()
+ user_id = self.user.id
+ self.chapter = ItemFactory.create(
+ parent_location=self.course.location, category='chapter', display_name="Week 1", user_id=user_id
+ )
+ self.sequential = ItemFactory.create(
+ parent_location=self.chapter.location, category='sequential', display_name="Lesson 1", user_id=user_id
+ )
+ self.vertical = ItemFactory.create(
+ parent_location=self.sequential.location, category='vertical', display_name='Unit 1', user_id=user_id
+ )
+ self.video = ItemFactory.create(
+ parent_location=self.vertical.location, category='video', display_name='My Video', user_id=user_id
+ )
+
+ def test_json_responses(self):
+ outline_url = reverse_usage_url('xblock_outline_handler', self.usage_key)
+ resp = self.client.get(outline_url, HTTP_ACCEPT='application/json')
+ json_response = json.loads(resp.content)
+ self.validate_course_xblock_info(json_response)
+
+ def test_chapter_xblock_info(self):
+ chapter = modulestore().get_item(self.chapter.location)
+ xblock_info = create_xblock_info(
+ chapter,
+ include_child_info=True,
+ include_children_predicate=ALWAYS,
+ )
+ self.validate_chapter_xblock_info(xblock_info)
+
+ def test_sequential_xblock_info(self):
+ sequential = modulestore().get_item(self.sequential.location)
+ xblock_info = create_xblock_info(
+ sequential,
+ include_child_info=True,
+ include_children_predicate=ALWAYS,
+ )
+ self.validate_sequential_xblock_info(xblock_info)
+
+ def test_vertical_xblock_info(self):
+ vertical = modulestore().get_item(self.vertical.location)
+ xblock_info = create_xblock_info(
+ vertical,
+ include_child_info=True,
+ include_children_predicate=ALWAYS,
+ include_ancestor_info=True
+ )
+ self.validate_vertical_xblock_info(xblock_info)
+
+ def test_component_xblock_info(self):
+ video = modulestore().get_item(self.video.location)
+ xblock_info = create_xblock_info(
+ video,
+ include_child_info=True,
+ include_children_predicate=ALWAYS
+ )
+ self.validate_component_xblock_info(xblock_info)
+
+ def validate_course_xblock_info(self, xblock_info, has_child_info=True):
+ """
+ Validate that the xblock info is correct for the test course.
+ """
+ self.assertEqual(xblock_info['category'], 'course')
+ self.assertEqual(xblock_info['id'], 'i4x://MITx/999/course/Robot_Super_Course')
+ self.assertEqual(xblock_info['display_name'], 'Robot Super Course')
+ self.assertTrue(xblock_info['published'])
+
+ # Finally, validate the entire response for consistency
+ self.validate_xblock_info_consistency(xblock_info, has_child_info=has_child_info)
+
+ def validate_chapter_xblock_info(self, xblock_info, has_child_info=True):
+ """
+ Validate that the xblock info is correct for the test chapter.
+ """
+ self.assertEqual(xblock_info['category'], 'chapter')
+ self.assertEqual(xblock_info['id'], 'i4x://MITx/999/chapter/Week_1')
+ self.assertEqual(xblock_info['display_name'], 'Week 1')
+ self.assertTrue(xblock_info['published'])
+ self.assertEqual(xblock_info['edited_by'], 'testuser')
+
+ # Finally, validate the entire response for consistency
+ self.validate_xblock_info_consistency(xblock_info, has_child_info=has_child_info)
+
+ def validate_sequential_xblock_info(self, xblock_info, has_child_info=True):
+ """
+ Validate that the xblock info is correct for the test chapter.
+ """
+ self.assertEqual(xblock_info['category'], 'sequential')
+ self.assertEqual(xblock_info['id'], 'i4x://MITx/999/sequential/Lesson_1')
+ self.assertEqual(xblock_info['display_name'], 'Lesson 1')
+ self.assertTrue(xblock_info['published'])
+ self.assertEqual(xblock_info['edited_by'], 'testuser')
+
+ # Finally, validate the entire response for consistency
+ self.validate_xblock_info_consistency(xblock_info, has_child_info=has_child_info)
+
+ def validate_vertical_xblock_info(self, xblock_info):
+ """
+ Validate that the xblock info is correct for the test vertical.
+ """
+ self.assertEqual(xblock_info['category'], 'vertical')
+ self.assertEqual(xblock_info['id'], 'i4x://MITx/999/vertical/Unit_1')
+ self.assertEqual(xblock_info['display_name'], 'Unit 1')
+ self.assertTrue(xblock_info['published'])
+ self.assertEqual(xblock_info['edited_by'], 'testuser')
+
+ # Validate that the correct ancestor info has been included
+ ancestor_info = xblock_info.get('ancestor_info', None)
+ self.assertIsNotNone(ancestor_info)
+ ancestors = ancestor_info['ancestors']
+ self.assertEqual(len(ancestors), 3)
+ self.validate_sequential_xblock_info(ancestors[0], has_child_info=True)
+ self.validate_chapter_xblock_info(ancestors[1], has_child_info=False)
+ self.validate_course_xblock_info(ancestors[2], has_child_info=False)
+
+ # Finally, validate the entire response for consistency
+ self.validate_xblock_info_consistency(xblock_info, has_child_info=True, has_ancestor_info=True)
+
+ def validate_component_xblock_info(self, xblock_info):
+ """
+ Validate that the xblock info is correct for the test component.
+ """
+ self.assertEqual(xblock_info['category'], 'video')
+ self.assertEqual(xblock_info['id'], 'i4x://MITx/999/video/My_Video')
+ self.assertEqual(xblock_info['display_name'], 'My Video')
+ self.assertTrue(xblock_info['published'])
+ self.assertEqual(xblock_info['edited_by'], 'testuser')
+
+ # Finally, validate the entire response for consistency
+ self.validate_xblock_info_consistency(xblock_info)
+
+ def validate_xblock_info_consistency(self, xblock_info, has_ancestor_info=False, has_child_info=False):
+ """
+ Validate that the xblock info is internally consistent.
+ """
+ self.assertIsNotNone(xblock_info['display_name'])
+ self.assertIsNotNone(xblock_info['id'])
+ self.assertIsNotNone(xblock_info['category'])
+ self.assertIsNotNone(xblock_info['published'])
+ self.assertEqual(xblock_info['edited_by'], 'testuser')
+ if has_ancestor_info:
+ self.assertIsNotNone(xblock_info.get('ancestor_info', None))
+ ancestors = xblock_info['ancestor_info']['ancestors']
+ for ancestor in xblock_info['ancestor_info']['ancestors']:
+ self.validate_xblock_info_consistency(
+ ancestor,
+ has_child_info=(ancestor == ancestors[0]) # Only the direct ancestor includes children
+ )
+ else:
+ self.assertIsNone(xblock_info.get('ancestor_info', None))
+ if has_child_info:
+ self.assertIsNotNone(xblock_info.get('child_info', None))
+ if xblock_info['child_info'].get('children', None):
+ for child_response in xblock_info['child_info']['children']:
+ self.validate_xblock_info_consistency(
+ child_response,
+ has_child_info=(not child_response.get('child_info', None) == None)
+ )
+ else:
+ self.assertIsNone(xblock_info.get('child_info', None))
diff --git a/cms/static/coffee/spec/main.coffee b/cms/static/coffee/spec/main.coffee
index 38d4444059..59df0f722b 100644
--- a/cms/static/coffee/spec/main.coffee
+++ b/cms/static/coffee/spec/main.coffee
@@ -202,10 +202,8 @@ define([
"coffee/spec/models/settings_grading_spec", "coffee/spec/models/textbook_spec",
"coffee/spec/models/upload_spec",
- "coffee/spec/views/section_spec",
"coffee/spec/views/course_info_spec", "coffee/spec/views/feedback_spec",
"coffee/spec/views/metadata_edit_spec", "coffee/spec/views/module_edit_spec",
- "coffee/spec/views/overview_spec",
"coffee/spec/views/textbook_spec", "coffee/spec/views/upload_spec",
"js/spec/video/transcripts/utils_spec", "js/spec/video/transcripts/editor_spec",
@@ -214,23 +212,26 @@ define([
"js/spec/models/component_template_spec",
"js/spec/models/explicit_url_spec",
+ "js/spec/models/group_configuration_spec",
"js/spec/utils/drag_and_drop_spec",
"js/spec/utils/handle_iframe_binding_spec",
"js/spec/utils/module_spec",
- "js/spec/views/baseview_spec",
"js/spec/views/paging_spec",
"js/spec/views/assets_spec",
- "js/spec/views/group_configuration_spec",
-
+ "js/spec/views/baseview_spec",
"js/spec/views/container_spec",
+ "js/spec/views/group_configuration_spec",
+ "js/spec/views/paging_spec",
+ "js/spec/views/unit_outline_spec",
"js/spec/views/xblock_spec",
"js/spec/views/xblock_editor_spec",
"js/spec/views/pages/container_spec",
"js/spec/views/pages/container_subviews_spec",
"js/spec/views/pages/group_configurations_spec",
+ "js/spec/views/pages/course_outline_spec",
"js/spec/views/modals/base_modal_spec",
"js/spec/views/modals/edit_xblock_spec",
diff --git a/cms/static/coffee/spec/views/section_spec.coffee b/cms/static/coffee/spec/views/section_spec.coffee
deleted file mode 100644
index 6b8175c096..0000000000
--- a/cms/static/coffee/spec/views/section_spec.coffee
+++ /dev/null
@@ -1,85 +0,0 @@
-define ["js/models/section", "js/views/section_show", "js/views/section_edit", "js/spec_helpers/create_sinon"], (Section, SectionShow, SectionEdit, create_sinon) ->
-
- describe "SectionShow", ->
- describe "Basic", ->
- beforeEach ->
- spyOn(SectionShow.prototype, "switchToEditView")
- .andCallThrough()
- @model = new Section({
- id: 42
- name: "Life, the Universe, and Everything"
- })
- @view = new SectionShow({model: @model})
- @view.render()
-
- it "should contain the model name", ->
- expect(@view.$el).toHaveText(@model.get('name'))
-
- it "should call switchToEditView when clicked", ->
- @view.$el.click()
- expect(@view.switchToEditView).toHaveBeenCalled()
-
- it "should pass the same element to SectionEdit when switching views", ->
- spyOn(SectionEdit.prototype, 'initialize').andCallThrough()
- @view.switchToEditView()
- expect(SectionEdit.prototype.initialize).toHaveBeenCalled()
- expect(SectionEdit.prototype.initialize.mostRecentCall.args[0].el).toEqual(@view.el)
-
- describe "SectionEdit", ->
- describe "Basic", ->
- tpl = readFixtures('section-name-edit.underscore')
- feedback_tpl = readFixtures('system-feedback.underscore')
-
- beforeEach ->
- setFixtures($("
+%block>
+
+<%block name="header_extras">
+% for template_name in ['course-outline', 'xblock-string-field-editor']:
+
+% endfor
+%block>
+
+<%block name="content">
+