Skip to content
Open
48 changes: 48 additions & 0 deletions cms/djangoapps/contentstore/tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,54 @@ def test_no_inheritance_for_orphan(self):
self.assertFalse(utils.ancestor_has_staff_lock(self.orphan))


class InheritedOptionalCompletionTest(CourseTestCase):
"""Tests for determining if an xblock inherits optional completion."""

def setUp(self):
super().setUp()
chapter = BlockFactory.create(category='chapter', parent=self.course)
sequential = BlockFactory.create(category='sequential', parent=chapter)
vertical = BlockFactory.create(category='vertical', parent=sequential)
html = BlockFactory.create(category='html', parent=vertical)
problem = BlockFactory.create(
category='problem', parent=vertical, data="<problem></problem>"
)
self.chapter = self.store.get_item(chapter.location)
self.sequential = self.store.get_item(sequential.location)
self.vertical = self.store.get_item(vertical.location)
self.html = self.store.get_item(html.location)
self.problem = self.store.get_item(problem.location)
self.orphan = BlockFactory.create(category='vertical', parent_location=self.sequential.location)

def set_optional_completion(self, xblock, value):
""" Sets optional_completion to specified value and calls update_item to persist the change. """
xblock.optional_completion = value
self.store.update_item(xblock, self.user.id)

def update_optional_completions(self, chapter, sequential, vertical):
self.set_optional_completion(self.chapter, chapter)
self.set_optional_completion(self.sequential, sequential)
self.set_optional_completion(self.vertical, vertical)

def test_no_inheritance(self):
"""Tests that vertical with no optional ancestors does not have an inherited optional completion"""
self.update_optional_completions(False, False, False)
self.assertFalse(utils.ancestor_has_optional_completion(self.vertical))
self.update_optional_completions(False, False, True)
self.assertFalse(utils.ancestor_has_optional_completion(self.vertical))

def test_inheritance_in_optional_subsection(self):
"""Tests that a vertical in an optional subsection has an inherited optional completion"""
self.update_optional_completions(False, True, False)
self.assertTrue(utils.ancestor_has_optional_completion(self.vertical))
self.update_optional_completions(False, True, True)
self.assertTrue(utils.ancestor_has_optional_completion(self.vertical))

def test_no_inheritance_for_orphan(self):
"""Tests that an orphaned xblock does not inherit optional completion"""
self.assertFalse(utils.ancestor_has_optional_completion(self.orphan))


class GroupVisibilityTest(CourseTestCase):
"""
Test content group access rules.
Expand Down
16 changes: 15 additions & 1 deletion cms/djangoapps/contentstore/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -342,7 +342,7 @@ def find_staff_lock_source(xblock):

def ancestor_has_staff_lock(xblock, parent_xblock=None):
"""
Returns True iff one of xblock's ancestors has staff lock.
Returns True if one of xblock's ancestors has staff lock.
Can avoid mongo query by passing in parent_xblock.
"""
if parent_xblock is None:
Expand All @@ -354,6 +354,20 @@ def ancestor_has_staff_lock(xblock, parent_xblock=None):
return parent_xblock.visible_to_staff_only


def ancestor_has_optional_completion(xblock, parent_xblock=None):
"""
Returns True if one of xblock's ancestors has optional completion.
Can avoid mongo query by passing in parent_xblock.
"""
if parent_xblock is None:
parent_location = modulestore().get_parent_location(xblock.location,
revision=ModuleStoreEnum.RevisionOption.draft_preferred)
if not parent_location:
return False
parent_xblock = modulestore().get_item(parent_location)
return parent_xblock.optional_completion


def reverse_url(handler_name, key_name=None, key_value=None, kwargs=None):
"""
Creates the URL for the given handler.
Expand Down
4 changes: 4 additions & 0 deletions cms/djangoapps/contentstore/views/block.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
from xmodule.x_module import AUTHOR_VIEW, PREVIEW_VIEWS, STUDENT_VIEW, STUDIO_VIEW # lint-amnesty, pylint: disable=wrong-import-order

from ..utils import (
ancestor_has_optional_completion,
ancestor_has_staff_lock,
find_release_date_source,
find_staff_lock_source,
Expand Down Expand Up @@ -1321,6 +1322,7 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F
'group_access': xblock.group_access,
'user_partitions': user_partitions,
'show_correctness': xblock.show_correctness,
'optional_completion': xblock.optional_completion,
})

if xblock.category == 'sequential':
Expand Down Expand Up @@ -1405,6 +1407,8 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F
xblock_info['ancestor_has_staff_lock'] = False

if course_outline:
xblock_info['ancestor_has_optional_completion'] = ancestor_has_optional_completion(xblock, parent_xblock)

if xblock_info['has_explicit_staff_lock']:
xblock_info['staff_only_message'] = True
elif child_info and child_info['children']:
Expand Down
2 changes: 1 addition & 1 deletion cms/djangoapps/contentstore/views/tests/test_block.py
Original file line number Diff line number Diff line change
Expand Up @@ -2630,7 +2630,7 @@ def test_json_responses(self):

@ddt.data(
(ModuleStoreEnum.Type.split, 3, 3),
(ModuleStoreEnum.Type.mongo, 8, 12),
(ModuleStoreEnum.Type.mongo, 10, 14),
)
@ddt.unpack
def test_xblock_outline_handler_mongo_calls(self, store_type, chapter_queries, chapter_queries_1):
Expand Down
1 change: 1 addition & 0 deletions cms/djangoapps/models/settings/course_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ class CourseMetadata:
'highlights_enabled_for_messaging',
'is_onboarding_exam',
'discussions_settings',
'optional_completion',
]

@classmethod
Expand Down
4 changes: 4 additions & 0 deletions cms/static/js/models/xblock_info.js
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,10 @@ define(
*/
ancestor_has_staff_lock: null,
/**
* True if this any of this xblock's ancestors are optional for completion.
*/
ancestor_has_optional_completion: null,
/**
* The xblock which is determining the staff lock value. For instance, for a unit,
* this will either be the parent subsection or the grandparent section.
* This can be null if the xblock has no inherited staff lock. Will only be present if
Expand Down
176 changes: 175 additions & 1 deletion cms/static/js/spec/views/pages/course_outline_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,7 @@ describe('CourseOutlinePage', function() {
'staff-lock-editor', 'unit-access-editor', 'discussion-editor', 'content-visibility-editor',
'settings-modal-tabs', 'timed-examination-preference-editor', 'access-editor',
'show-correctness-editor', 'highlights-editor', 'highlights-enable-editor',
'course-highlights-enable'
'course-highlights-enable', 'optional-completion-editor'
]);
appendSetFixtures(mockOutlinePage);
mockCourseJSON = createMockCourseJSON({}, [
Expand Down Expand Up @@ -1021,6 +1021,62 @@ describe('CourseOutlinePage', function() {
);
expect($modalWindow.find('.outline-subsection').length).toBe(2);
});

it('hides optional completion checkbox by default', function() {
createCourseOutlinePage(this, mockCourseJSON, false);
outlinePage.$('.section-header-actions .configure-button').click();
expect($('.edit-optional-completion')).not.toExist();
});

describe('supports optional completion and', function () {
beforeEach(function() {
window.course.attributes.completion_tracking_enabled = true;
});

it('shows optional completion checkbox unchecked by default', function() {
createCourseOutlinePage(this, mockCourseJSON, false);
outlinePage.$('.section-header-actions .configure-button').click();
expect($('.edit-optional-completion')).toExist();
expect($('#optional_completion').is(':checked')).toBe(false);
});

it('shows optional completion checkbox checked', function() {
var mockCourseJSON = createMockCourseJSON({}, [
createMockSectionJSON({optional_completion: true})
]);
createCourseOutlinePage(this, mockCourseJSON, false);
outlinePage.$('.section-header-actions .configure-button').click();
expect($('#optional_completion').is(':disabled')).toBe(false);
expect($('#optional_completion').is(':checked')).toBe(true);
});

it('disables optional completion checkbox when the parent uses optional completion', function() {
var mockCourseJSON = createMockCourseJSON({}, [
createMockSectionJSON({ancestor_has_optional_completion: true})
]);
createCourseOutlinePage(this, mockCourseJSON, false);
outlinePage.$('.section-header-actions .configure-button').click();
expect($('#optional_completion').is(':disabled')).toBe(true);
});

it('sets optional completion to null instead of false', function() {
var mockCourseJSON = createMockCourseJSON({}, [
createMockSectionJSON({optional_completion: true})
]);
createCourseOutlinePage(this, mockCourseJSON, false);
outlinePage.$('.section-header-actions .configure-button').click();
expect($('#optional_completion').is(':checked')).toBe(true);
$('#optional_completion').click()
expect($('#optional_completion').is(':checked')).toBe(false);
$('.wrapper-modal-window .action-save').click();
AjaxHelpers.expectJsonRequest(requests, 'POST', '/xblock/mock-section', {
publish: 'republish',
metadata: {
optional_completion: null
}
});
});
});
});

describe('Subsection', function() {
Expand Down Expand Up @@ -2321,6 +2377,76 @@ describe('CourseOutlinePage', function() {
);
});
})

it('hides optional completion checkbox by default', function() {
createCourseOutlinePage(this, mockCourseJSON, false);
outlinePage.$('.outline-subsection .configure-button').click();
expect($('.edit-optional-completion')).not.toExist();
});

describe('supports optional completion and', function () {
beforeEach(function() {
window.course.attributes.completion_tracking_enabled = true;
});

it('shows optional completion checkbox unchecked by default', function() {
createCourseOutlinePage(this, mockCourseJSON, false);
outlinePage.$('.outline-subsection .configure-button').click();
expect($('.edit-optional-completion')).toExist();
expect($('#optional_completion').is(':checked')).toBe(false);
});

it('shows optional completion checkbox checked', function() {
var mockCourseJSON = createMockCourseJSON({}, [
createMockSectionJSON({}, [
createMockSubsectionJSON({optional_completion: true}, [])
])
]);
createCourseOutlinePage(this, mockCourseJSON, false);
outlinePage.$('.outline-subsection .configure-button').click();
expect($('#optional_completion').is(':disabled')).toBe(false);
expect($('#optional_completion').is(':checked')).toBe(true);
});

it('disables optional completion checkbox when the parent uses optional completion', function() {
var mockCourseJSON = createMockCourseJSON({}, [
createMockSectionJSON({}, [
createMockSubsectionJSON({ancestor_has_optional_completion: true}, [])
])
]);
createCourseOutlinePage(this, mockCourseJSON, false);
outlinePage.$('.outline-subsection .configure-button').click();
expect($('#optional_completion').is(':disabled')).toBe(true);
});

it('sets optional completion to null instead of false', function() {
var mockCourseJSON = createMockCourseJSON({}, [
createMockSectionJSON({}, [
createMockSubsectionJSON({optional_completion: true}, [])
])
]);
createCourseOutlinePage(this, mockCourseJSON, false);
outlinePage.$('.outline-subsection .configure-button').click();
expect($('#optional_completion').is(':checked')).toBe(true);
$('#optional_completion').click()
expect($('#optional_completion').is(':checked')).toBe(false);
$('.wrapper-modal-window .action-save').click();
AjaxHelpers.expectJsonRequest(requests, 'POST', '/xblock/mock-subsection', {
publish: 'republish',
graderType: 'notgraded',
isPrereq: false,
metadata: {
optional_completion: null,
due: null,
is_practice_exam: false,
is_time_limited: false,
is_proctored_enabled: false,
default_time_limit_minutes: null,
is_onboarding_exam: false,
}
});
});
});
});

// Note: most tests for units can be found in Bok Choy
Expand Down Expand Up @@ -2437,6 +2563,54 @@ describe('CourseOutlinePage', function() {
])
]);
});

it('hides optional completion checkbox by default', function() {
getUnitStatus({}, {});
outlinePage.$('.outline-unit .configure-button').click();
expect($('.edit-optional-completion')).not.toExist();
});

describe('supports optional completion and', function () {
beforeEach(function() {
window.course.attributes.completion_tracking_enabled = true;
});

it('shows optional completion checkbox unchecked by default', function() {
getUnitStatus({}, {});
outlinePage.$('.outline-unit .configure-button').click();
expect($('.edit-optional-completion')).toExist();
expect($('#optional_completion').is(':checked')).toBe(false);
});

it('shows optional completion checkbox checked', function() {
getUnitStatus({optional_completion: true}, {});
outlinePage.$('.outline-unit .configure-button').click();
expect($('#optional_completion').is(':disabled')).toBe(false);
expect($('#optional_completion').is(':checked')).toBe(true);
});

it('disables optional completion checkbox when the parent uses optional completion', function() {
getUnitStatus({ancestor_has_optional_completion: true}, {});
outlinePage.$('.outline-unit .configure-button').click();
expect($('#optional_completion').is(':disabled')).toBe(true);
});

it('sets optional completion to null instead of false', function() {
getUnitStatus({optional_completion: true}, {});
outlinePage.$('.outline-unit .configure-button').click();
expect($('#optional_completion').is(':checked')).toBe(true);
$('#optional_completion').click()
expect($('#optional_completion').is(':checked')).toBe(false);
$('.wrapper-modal-window .action-save').click();
AjaxHelpers.expectJsonRequest(requests, 'POST', '/xblock/mock-unit', {
publish: 'republish',
metadata: {
visible_to_staff_only: null,
optional_completion: null
}
});
});
});
});

describe('Date and Time picker', function() {
Expand Down
Loading