diff --git a/cms/djangoapps/contentstore/signals/handlers.py b/cms/djangoapps/contentstore/signals/handlers.py
index f32cf63fb2..7330305b3b 100644
--- a/cms/djangoapps/contentstore/signals/handlers.py
+++ b/cms/djangoapps/contentstore/signals/handlers.py
@@ -102,7 +102,7 @@ def handle_item_deleted(**kwargs):
# Remove prerequisite milestone data
gating_api.remove_prerequisite(module.location)
# Remove any 'requires' course content milestone relationships
- gating_api.set_required_content(course_key, module.location, None, None)
+ gating_api.set_required_content(course_key, module.location, None, None, None)
@receiver(GRADING_POLICY_CHANGED)
diff --git a/cms/djangoapps/contentstore/tests/test_gating.py b/cms/djangoapps/contentstore/tests/test_gating.py
index b488cd31d8..e61da3ace5 100644
--- a/cms/djangoapps/contentstore/tests/test_gating.py
+++ b/cms/djangoapps/contentstore/tests/test_gating.py
@@ -41,7 +41,7 @@ class TestHandleItemDeleted(ModuleStoreTestCase, MilestonesTestCaseMixin):
display_name="Gated Sequential"
)
gating_api.add_prerequisite(self.course.id, self.open_seq.location)
- gating_api.set_required_content(self.course.id, self.gated_seq.location, self.open_seq.location, 100)
+ gating_api.set_required_content(self.course.id, self.gated_seq.location, self.open_seq.location, 100, 100)
@patch('contentstore.signals.handlers.gating_api.set_required_content')
@patch('contentstore.signals.handlers.gating_api.remove_prerequisite')
@@ -49,7 +49,9 @@ class TestHandleItemDeleted(ModuleStoreTestCase, MilestonesTestCaseMixin):
""" Test gating milestone data is cleanup up when course content item is deleted """
handle_item_deleted(usage_key=self.chapter.location, user_id=0)
mock_remove_prereq.assert_called_with(self.open_seq.location)
- mock_set_required.assert_called_with(self.open_seq.location.course_key, self.open_seq.location, None, None)
+ mock_set_required.assert_called_with(
+ self.open_seq.location.course_key, self.open_seq.location, None, None, None
+ )
@patch('contentstore.signals.handlers.gating_api.set_required_content')
@patch('contentstore.signals.handlers.gating_api.remove_prerequisite')
@@ -57,4 +59,6 @@ class TestHandleItemDeleted(ModuleStoreTestCase, MilestonesTestCaseMixin):
""" Test gating milestone data is cleanup up when course content item is deleted """
handle_item_deleted(usage_key=self.open_seq.location, user_id=0)
mock_remove_prereq.assert_called_with(self.open_seq.location)
- mock_set_required.assert_called_with(self.open_seq.location.course_key, self.open_seq.location, None, None)
+ mock_set_required.assert_called_with(
+ self.open_seq.location.course_key, self.open_seq.location, None, None, None
+ )
diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py
index 1f22a5b77b..5baf93c53a 100644
--- a/cms/djangoapps/contentstore/views/item.py
+++ b/cms/djangoapps/contentstore/views/item.py
@@ -135,7 +135,9 @@ def xblock_handler(request, usage_key_string):
:isPrereq: Set this xblock as a prerequisite which can be used to limit access to other xblocks
:prereqUsageKey: Use the xblock identified by this usage key to limit access to this xblock
:prereqMinScore: The minimum score that needs to be achieved on the prerequisite xblock
- identifed by prereqUsageKey
+ identifed by prereqUsageKey. Ranging from 0 to 100.
+ :prereqMinCompletion: The minimum completion percentage that needs to be achieved on the
+ prerequisite xblock identifed by prereqUsageKey. Ranging from 0 to 100.
:publish: can be:
'make_public': publish the content
'republish': publish this item *only* if it was previously published
@@ -199,6 +201,7 @@ def xblock_handler(request, usage_key_string):
is_prereq=request.json.get('isPrereq'),
prereq_usage_key=request.json.get('prereqUsageKey'),
prereq_min_score=request.json.get('prereqMinScore'),
+ prereq_min_completion=request.json.get('prereqMinCompletion'),
publish=request.json.get('publish'),
fields=request.json.get('fields'),
)
@@ -480,7 +483,7 @@ def _update_with_callback(xblock, user, old_metadata=None, old_content=None):
def _save_xblock(user, xblock, data=None, children_strings=None, metadata=None, nullout=None,
grader_type=None, is_prereq=None, prereq_usage_key=None, prereq_min_score=None,
- publish=None, fields=None):
+ prereq_min_completion=None, publish=None, fields=None):
"""
Saves xblock w/ its fields. Has special processing for grader_type, publish, and nullout and Nones in metadata.
nullout means to truly set the field to None whereas nones in metadata mean to unset them (so they revert
@@ -621,7 +624,11 @@ def _save_xblock(user, xblock, data=None, children_strings=None, metadata=None,
if prereq_usage_key is not None:
gating_api.set_required_content(
- xblock.location.course_key, xblock.location, prereq_usage_key, prereq_min_score
+ xblock.location.course_key,
+ xblock.location,
+ prereq_usage_key,
+ prereq_min_score,
+ prereq_min_completion
)
# If publish is set to 'republish' and this item is not in direct only categories and has previously been
@@ -1050,12 +1057,13 @@ def _get_gating_info(course, xblock):
info["prereqs"] = [
p for p in course.gating_prerequisites if unicode(xblock.location) not in p['namespace']
]
- prereq, prereq_min_score = gating_api.get_required_content(
+ prereq, prereq_min_score, prereq_min_completion = gating_api.get_required_content(
course.id,
xblock.location
)
info["prereq"] = prereq
info["prereq_min_score"] = prereq_min_score
+ info["prereq_min_completion"] = prereq_min_completion
if prereq:
info["visibility_state"] = VisibilityState.gated
return info
diff --git a/cms/djangoapps/contentstore/views/tests/test_gating.py b/cms/djangoapps/contentstore/views/tests/test_gating.py
index ebbbf34440..887e56f8eb 100644
--- a/cms/djangoapps/contentstore/views/tests/test_gating.py
+++ b/cms/djangoapps/contentstore/views/tests/test_gating.py
@@ -1,6 +1,7 @@
"""
Unit tests for the gating feature in Studio
"""
+import ddt
import json
from mock import patch
@@ -13,6 +14,7 @@ from xmodule.modulestore.tests.django_utils import TEST_DATA_SPLIT_MODULESTORE
from xmodule.modulestore.tests.factories import ItemFactory
+@ddt.ddt
class TestSubsectionGating(CourseTestCase):
"""
Tests for the subsection gating feature
@@ -84,12 +86,13 @@ class TestSubsectionGating(CourseTestCase):
self.client.ajax_post(
self.seq2_url,
- data={'prereqUsageKey': unicode(self.seq1.location), 'prereqMinScore': '100'}
+ data={'prereqUsageKey': unicode(self.seq1.location), 'prereqMinScore': '100', 'prereqMinCompletion': '100'}
)
mock_set_required_content.assert_called_with(
self.course.id,
self.seq2.location,
unicode(self.seq1.location),
+ '100',
'100'
)
@@ -101,21 +104,31 @@ class TestSubsectionGating(CourseTestCase):
self.client.ajax_post(
self.seq2_url,
- data={'prereqUsageKey': '', 'prereqMinScore': ''}
+ data={'prereqUsageKey': '', 'prereqMinScore': '', 'prereqMinCompletion': ''}
)
mock_set_required_content.assert_called_with(
self.course.id,
self.seq2.location,
'',
+ '',
''
)
@patch('contentstore.views.item.gating_api.get_prerequisites')
@patch('contentstore.views.item.gating_api.get_required_content')
@patch('contentstore.views.item.gating_api.is_prerequisite')
- def test_get_prerequisite(self, mock_is_prereq, mock_get_required_content, mock_get_prereqs):
+ @ddt.data(
+ (90, None),
+ (None, 90),
+ (100, 100),
+ )
+ @ddt.unpack
+ def test_get_prerequisite(
+ self, min_score, min_completion,
+ mock_is_prereq, mock_get_required_content, mock_get_prereqs
+ ):
mock_is_prereq.return_value = True
- mock_get_required_content.return_value = unicode(self.seq1.location), 100
+ mock_get_required_content.return_value = unicode(self.seq1.location), min_score, min_completion
mock_get_prereqs.return_value = [
{'namespace': '{}{}'.format(unicode(self.seq1.location), GATING_NAMESPACE_QUALIFIER)},
{'namespace': '{}{}'.format(unicode(self.seq2.location), GATING_NAMESPACE_QUALIFIER)}
@@ -126,7 +139,8 @@ class TestSubsectionGating(CourseTestCase):
mock_get_prereqs.assert_called_with(self.course.id)
self.assertTrue(resp['is_prereq'])
self.assertEqual(resp['prereq'], unicode(self.seq1.location))
- self.assertEqual(resp['prereq_min_score'], 100)
+ self.assertEqual(resp['prereq_min_score'], min_score)
+ self.assertEqual(resp['prereq_min_completion'], min_completion)
self.assertEqual(resp['visibility_state'], VisibilityState.gated)
@patch('contentstore.signals.handlers.gating_api.set_required_content')
@@ -139,4 +153,4 @@ class TestSubsectionGating(CourseTestCase):
)
self.client.delete(reverse_usage_url('xblock_handler', seq3.location))
mock_remove_prereq.assert_called_with(seq3.location)
- mock_set_required.assert_called_with(seq3.location.course_key, seq3.location, None, None)
+ mock_set_required.assert_called_with(seq3.location.course_key, seq3.location, None, None, None)
diff --git a/cms/static/js/spec/views/pages/course_outline_spec.js b/cms/static/js/spec/views/pages/course_outline_spec.js
index 95dc045faa..bb9145e179 100644
--- a/cms/static/js/spec/views/pages/course_outline_spec.js
+++ b/cms/static/js/spec/views/pages/course_outline_spec.js
@@ -80,6 +80,7 @@ define(['jquery', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers', 'common/j
prereqs: [],
prereq: '',
prereq_min_score: '',
+ prereq_min_completion: '',
show_correctness: 'always',
child_info: {
category: 'vertical',
@@ -1023,9 +1024,10 @@ define(['jquery', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers', 'common/j
this.$('#is_prereq').prop('checked', true).trigger('change');
};
- selectLastPrerequisiteSubsection = function(minScore) {
+ selectLastPrerequisiteSubsection = function(minScore, minCompletion) {
this.$('#prereq option:last').prop('selected', true).trigger('change');
this.$('#prereq_min_score').val(minScore).trigger('keyup');
+ this.$('#prereq_min_completion').val(minCompletion).trigger('keyup');
};
// Helper to validate oft-checked additional option fields' visibility
@@ -1045,6 +1047,7 @@ define(['jquery', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers', 'common/j
delete mockSubsectionJSON.prereqs;
delete mockSubsectionJSON.prereq;
delete mockSubsectionJSON.prereq_min_score;
+ delete mockSubsectionJSON.prereq_min_completion;
return createMockCourseJSON({
enable_proctored_exams: false,
enable_timed_exams: false
@@ -1611,10 +1614,12 @@ define(['jquery', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers', 'common/j
]);
createCourseOutlinePage(this, mockCourseWithPreqsJSON, false);
outlinePage.$('.outline-subsection .configure-button').click();
- selectLastPrerequisiteSubsection('80');
+ selectLastPrerequisiteSubsection('80', '0');
expect($('#prereq_min_score_input').css('display')).not.toBe('none');
expect($('#prereq option:selected').val()).toBe('usage_key');
expect($('#prereq_min_score').val()).toBe('80');
+ expect($('#prereq_min_completion_input').css('display')).not.toBe('none');
+ expect($('#prereq_min_completion').val()).toBe('0');
$('.wrapper-modal-window .action-save').click();
});
@@ -1641,7 +1646,8 @@ define(['jquery', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers', 'common/j
createMockSubsectionJSON({
prereqs: [{block_usage_key: 'usage_key', block_display_name: 'Prereq Subsection 1'}],
prereq: 'usage_key',
- prereq_min_score: '80'
+ prereq_min_score: '80',
+ prereq_min_completion: '50'
}, [])
])
]);
@@ -1651,9 +1657,32 @@ define(['jquery', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers', 'common/j
expect($('#prereq option:selected').val()).toBe('usage_key');
expect($('#prereq_min_score_input').css('display')).not.toBe('none');
expect($('#prereq_min_score').val()).toBe('80');
+ expect($('#prereq_min_completion_input').css('display')).not.toBe('none');
+ expect($('#prereq_min_completion').val()).toBe('50');
});
- it('can display validation error on non-integer minimum score', function() {
+ it('can show a saved prerequisite subsection with empty min score correctly', function() {
+ var mockCourseWithPreqsJSON = createMockCourseJSON({}, [
+ createMockSectionJSON({}, [
+ createMockSubsectionJSON({
+ prereqs: [{block_usage_key: 'usage_key', block_display_name: 'Prereq Subsection 1'}],
+ prereq: 'usage_key',
+ prereq_min_score: '',
+ prereq_min_completion: '50'
+ }, [])
+ ])
+ ]);
+ createCourseOutlinePage(this, mockCourseWithPreqsJSON, false);
+ outlinePage.$('.outline-subsection .configure-button').click();
+ expect($('.gating-prereq').length).toBe(1);
+ expect($('#prereq option:selected').val()).toBe('usage_key');
+ expect($('#prereq_min_score_input').css('display')).not.toBe('none');
+ expect($('#prereq_min_score').val()).toBe('100');
+ expect($('#prereq_min_completion_input').css('display')).not.toBe('none');
+ expect($('#prereq_min_completion').val()).toBe('50');
+ });
+
+ it('can display validation error on non-integer or empty minimum score', function() {
var mockCourseWithPreqsJSON = createMockCourseJSON({}, [
createMockSectionJSON({}, [
createMockSubsectionJSON({
@@ -1663,12 +1692,39 @@ define(['jquery', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers', 'common/j
]);
createCourseOutlinePage(this, mockCourseWithPreqsJSON, false);
outlinePage.$('.outline-subsection .configure-button').click();
- selectLastPrerequisiteSubsection('abc');
+ selectLastPrerequisiteSubsection('', '50');
expect($('#prereq_min_score_error').css('display')).not.toBe('none');
+ expect($('#prereq_min_completion_error').css('display')).toBe('none');
expect($('.wrapper-modal-window .action-save').prop('disabled')).toBe(true);
expect($('.wrapper-modal-window .action-save').hasClass('is-disabled')).toBe(true);
- selectLastPrerequisiteSubsection('5.5');
+ selectLastPrerequisiteSubsection('50', '');
+ expect($('#prereq_min_score_error').css('display')).toBe('none');
+ expect($('#prereq_min_completion_error').css('display')).not.toBe('none');
+ expect($('.wrapper-modal-window .action-save').prop('disabled')).toBe(true);
+ expect($('.wrapper-modal-window .action-save').hasClass('is-disabled')).toBe(true);
+ selectLastPrerequisiteSubsection('', '');
expect($('#prereq_min_score_error').css('display')).not.toBe('none');
+ expect($('#prereq_min_completion_error').css('display')).not.toBe('none');
+ expect($('.wrapper-modal-window .action-save').prop('disabled')).toBe(true);
+ expect($('.wrapper-modal-window .action-save').hasClass('is-disabled')).toBe(true);
+ selectLastPrerequisiteSubsection('abc', '50');
+ expect($('#prereq_min_score_error').css('display')).not.toBe('none');
+ expect($('#prereq_min_completion_error').css('display')).toBe('none');
+ expect($('.wrapper-modal-window .action-save').prop('disabled')).toBe(true);
+ expect($('.wrapper-modal-window .action-save').hasClass('is-disabled')).toBe(true);
+ selectLastPrerequisiteSubsection('50', 'abc');
+ expect($('#prereq_min_score_error').css('display')).toBe('none');
+ expect($('#prereq_min_completion_error').css('display')).not.toBe('none');
+ expect($('.wrapper-modal-window .action-save').prop('disabled')).toBe(true);
+ expect($('.wrapper-modal-window .action-save').hasClass('is-disabled')).toBe(true);
+ selectLastPrerequisiteSubsection('5.5', '50');
+ expect($('#prereq_min_score_error').css('display')).not.toBe('none');
+ expect($('#prereq_min_completion_error').css('display')).toBe('none');
+ expect($('.wrapper-modal-window .action-save').prop('disabled')).toBe(true);
+ expect($('.wrapper-modal-window .action-save').hasClass('is-disabled')).toBe(true);
+ selectLastPrerequisiteSubsection('50', '5.5');
+ expect($('#prereq_min_score_error').css('display')).toBe('none');
+ expect($('#prereq_min_completion_error').css('display')).not.toBe('none');
expect($('.wrapper-modal-window .action-save').prop('disabled')).toBe(true);
expect($('.wrapper-modal-window .action-save').hasClass('is-disabled')).toBe(true);
});
@@ -1683,12 +1739,24 @@ define(['jquery', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers', 'common/j
]);
createCourseOutlinePage(this, mockCourseWithPreqsJSON, false);
outlinePage.$('.outline-subsection .configure-button').click();
- selectLastPrerequisiteSubsection('-5');
+ selectLastPrerequisiteSubsection('-5', '50');
expect($('#prereq_min_score_error').css('display')).not.toBe('none');
+ expect($('#prereq_min_completion_error').css('display')).toBe('none');
expect($('.wrapper-modal-window .action-save').prop('disabled')).toBe(true);
expect($('.wrapper-modal-window .action-save').hasClass('is-disabled')).toBe(true);
- selectLastPrerequisiteSubsection('105');
+ selectLastPrerequisiteSubsection('50', '-5');
+ expect($('#prereq_min_score_error').css('display')).toBe('none');
+ expect($('#prereq_min_completion_error').css('display')).not.toBe('none');
+ expect($('.wrapper-modal-window .action-save').prop('disabled')).toBe(true);
+ expect($('.wrapper-modal-window .action-save').hasClass('is-disabled')).toBe(true);
+ selectLastPrerequisiteSubsection('105', '50');
expect($('#prereq_min_score_error').css('display')).not.toBe('none');
+ expect($('#prereq_min_completion_error').css('display')).toBe('none');
+ expect($('.wrapper-modal-window .action-save').prop('disabled')).toBe(true);
+ expect($('.wrapper-modal-window .action-save').hasClass('is-disabled')).toBe(true);
+ selectLastPrerequisiteSubsection('50', '105');
+ expect($('#prereq_min_score_error').css('display')).toBe('none');
+ expect($('#prereq_min_completion_error').css('display')).not.toBe('none');
expect($('.wrapper-modal-window .action-save').prop('disabled')).toBe(true);
expect($('.wrapper-modal-window .action-save').hasClass('is-disabled')).toBe(true);
});
@@ -1704,14 +1772,15 @@ define(['jquery', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers', 'common/j
createCourseOutlinePage(this, mockCourseWithPreqsJSON, false);
outlinePage.$('.outline-subsection .configure-button').click();
selectAdvancedSettings();
- selectLastPrerequisiteSubsection('');
+ selectLastPrerequisiteSubsection('80', '50');
+ expect($('#prereq_min_completion_error').css('display')).toBe('none');
expect($('#prereq_min_score_error').css('display')).toBe('none');
- selectLastPrerequisiteSubsection('80');
+ selectLastPrerequisiteSubsection('0', '0');
expect($('#prereq_min_score_error').css('display')).toBe('none');
- selectLastPrerequisiteSubsection('0');
- expect($('#prereq_min_score_error').css('display')).toBe('none');
- selectLastPrerequisiteSubsection('100');
+ expect($('#prereq_min_completion_error').css('display')).toBe('none');
+ selectLastPrerequisiteSubsection('100', '100');
expect($('#prereq_min_score_error').css('display')).toBe('none');
+ expect($('#prereq_min_completion_error').css('display')).toBe('none');
});
it('release date, due date, grading type, and staff lock can be cleared.', function() {
diff --git a/cms/static/js/views/modals/course_outline_modals.js b/cms/static/js/views/modals/course_outline_modals.js
index 67140eec54..6bff80e977 100644
--- a/cms/static/js/views/modals/course_outline_modals.js
+++ b/cms/static/js/views/modals/course_outline_modals.js
@@ -565,50 +565,68 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview',
className: 'edit-settings-access',
events: {
'change #prereq': 'handlePrereqSelect',
- 'keyup #prereq_min_score': 'validateMinScore'
+ 'keyup #prereq_min_completion': 'validateScoreAndCompletion',
+ 'keyup #prereq_min_score': 'validateScoreAndCompletion'
},
afterRender: function() {
AbstractEditor.prototype.afterRender.call(this);
var prereq = this.model.get('prereq') || '';
- var prereq_min_score = this.model.get('prereq_min_score') || '';
+ var prereqMinScore = this.model.get('prereq_min_score') || '100';
+ var prereqMinCompletion = this.model.get('prereq_min_completion') || '100';
this.$('#is_prereq').prop('checked', this.model.get('is_prereq'));
this.$('#prereq option[value="' + prereq + '"]').prop('selected', true);
- this.$('#prereq_min_score').val(prereq_min_score);
+ this.$('#prereq_min_score').val(prereqMinScore);
this.$('#prereq_min_score_input').toggle(prereq.length > 0);
+ this.$('#prereq_min_completion').val(prereqMinCompletion);
+ this.$('#prereq_min_completion_input').toggle(prereq.length > 0);
},
handlePrereqSelect: function() {
var showPrereqInput = this.$('#prereq option:selected').val().length > 0;
this.$('#prereq_min_score_input').toggle(showPrereqInput);
+ this.$('#prereq_min_completion_input').toggle(showPrereqInput);
},
- validateMinScore: function() {
+ isValidPercentage: function(val) {
+ var intVal = parseInt(val, 10);
+ return (typeof val !== 'undefined' && val !== '' && intVal >= 0 && intVal <= 100 && String(intVal) === val);
+ },
+ validateScoreAndCompletion: function() {
+ var invalidInput = false;
var minScore = this.$('#prereq_min_score').val().trim();
- var minScoreInt = parseInt(minScore);
- // minScore needs to be an integer between 0 and 100
- if (
- minScore &&
- (
- typeof(minScoreInt) === 'undefined' ||
- String(minScoreInt) !== minScore ||
- minScoreInt < 0 ||
- minScoreInt > 100
- )
- ) {
+ var minCompletion = this.$('#prereq_min_completion').val().trim();
+
+ if (minScore === '' || !this.isValidPercentage(minScore)) {
+ invalidInput = true;
this.$('#prereq_min_score_error').show();
- BaseModal.prototype.disableActionButton.call(this.parent, 'save');
} else {
this.$('#prereq_min_score_error').hide();
+ }
+ if (minCompletion === '' || !this.isValidPercentage(minCompletion)) {
+ invalidInput = true;
+ this.$('#prereq_min_completion_error').show();
+ } else {
+ this.$('#prereq_min_completion_error').hide();
+ }
+ if (invalidInput) {
+ BaseModal.prototype.disableActionButton.call(this.parent, 'save');
+ } else {
BaseModal.prototype.enableActionButton.call(this.parent, 'save');
}
},
getRequestData: function() {
var minScore = this.$('#prereq_min_score').val();
+ var minCompletion = this.$('#prereq_min_completion').val();
if (minScore) {
minScore = minScore.trim();
}
+ if (minCompletion) {
+ minCompletion = minCompletion.trim();
+ }
+
return {
isPrereq: this.$('#is_prereq').is(':checked'),
prereqUsageKey: this.$('#prereq option:selected').val(),
- prereqMinScore: minScore
+ prereqMinScore: minScore,
+ prereqMinCompletion: minCompletion
};
}
});
diff --git a/cms/static/sass/elements/_modal-window.scss b/cms/static/sass/elements/_modal-window.scss
index 097a45a74f..b683518f6a 100644
--- a/cms/static/sass/elements/_modal-window.scss
+++ b/cms/static/sass/elements/_modal-window.scss
@@ -674,6 +674,11 @@
width: ($baseline*7);
}
+ input.percentage {
+ display: inline-block;
+ width: ($baseline*3);
+ }
+
.tip {
@extend %t-copy-sub1;
@@ -810,6 +815,9 @@
.edit-settings-access {
.gating-prereq {
margin-bottom: 10px;
+ .list-fields .field {
+ display: block;
+ }
}
}
}
diff --git a/cms/templates/js/access-editor.underscore b/cms/templates/js/access-editor.underscore
index 7425a0e017..dfab0c5bda 100644
--- a/cms/templates/js/access-editor.underscore
+++ b/cms/templates/js/access-editor.underscore
@@ -4,7 +4,7 @@
<% } %>
diff --git a/common/test/acceptance/pages/studio/overview.py b/common/test/acceptance/pages/studio/overview.py
index c190a2692c..d03609b42b 100644
--- a/common/test/acceptance/pages/studio/overview.py
+++ b/common/test/acceptance/pages/studio/overview.py
@@ -720,12 +720,13 @@ class CourseOutlinePage(CoursePage, CourseOutlineContainer):
self.q(css=".action-save").first.click()
self.wait_for_ajax()
- def add_prerequisite_to_subsection(self, min_score):
+ def add_prerequisite_to_subsection(self, min_score, min_completion):
"""
Adds a prerequisite to a subsection.
"""
Select(self.q(css="#prereq")[0]).select_by_index(1)
self.q(css="#prereq_min_score").fill(min_score)
+ self.q(css="#prereq_min_completion").fill(min_completion)
self.q(css=".action-save").first.click()
self.wait_for_ajax()
diff --git a/common/test/acceptance/tests/lms/test_lms_gating.py b/common/test/acceptance/tests/lms/test_lms_gating.py
index 51a389c303..b90d6e7b96 100644
--- a/common/test/acceptance/tests/lms/test_lms_gating.py
+++ b/common/test/acceptance/tests/lms/test_lms_gating.py
@@ -106,7 +106,7 @@ class GatingTest(UniqueCourseTest):
self.studio_course_outline.visit()
self.studio_course_outline.open_subsection_settings_dialog(1)
self.studio_course_outline.select_advanced_tab(desired_item='gated_content')
- self.studio_course_outline.add_prerequisite_to_subsection("80")
+ self.studio_course_outline.add_prerequisite_to_subsection("80", "")
def _fulfill_prerequisite(self):
"""
diff --git a/lms/djangoapps/course_api/blocks/transformers/tests/test_milestones.py b/lms/djangoapps/course_api/blocks/transformers/tests/test_milestones.py
index 0a589790b7..7366cd3609 100644
--- a/lms/djangoapps/course_api/blocks/transformers/tests/test_milestones.py
+++ b/lms/djangoapps/course_api/blocks/transformers/tests/test_milestones.py
@@ -49,7 +49,7 @@ class MilestonesTransformerTestCase(CourseStructureTestCase, MilestonesTestCaseM
gating_block: The block that must be completed before access is granted
"""
gating_api.add_prerequisite(self.course.id, unicode(gating_block.location))
- gating_api.set_required_content(self.course.id, gated_block.location, gating_block.location, 100)
+ gating_api.set_required_content(self.course.id, gated_block.location, gating_block.location, 100, 0)
ALL_BLOCKS = (
'course', 'A', 'B', 'C', 'ProctoredExam', 'D', 'E', 'PracticeExam', 'F', 'G', 'H', 'I', 'TimedExam', 'J', 'K'
@@ -170,12 +170,11 @@ class MilestonesTransformerTestCase(CourseStructureTestCase, MilestonesTestCaseM
self.clear_caches()
# this call triggers reevaluation of prerequisites fulfilled by the gating block.
- with patch('openedx.core.lib.gating.api._get_subsection_percentage', Mock(return_value=100)):
- lms_gating_api.evaluate_prerequisite(
- self.course,
- Mock(location=self.blocks[gating_block_ref].location),
- self.user,
- )
+ lms_gating_api.evaluate_prerequisite(
+ self.course,
+ Mock(location=self.blocks[gating_block_ref].location, percent_graded=1.0),
+ self.user,
+ )
with self.assertNumQueries(6):
self.get_blocks_and_check_against_expected(self.user, self.ALL_BLOCKS_EXCEPT_SPECIAL)
@@ -205,12 +204,11 @@ class MilestonesTransformerTestCase(CourseStructureTestCase, MilestonesTestCaseM
self.clear_caches()
# this call triggers reevaluation of prerequisites fulfilled by the gating block.
- with patch('openedx.core.lib.gating.api._get_subsection_percentage', Mock(return_value=100)):
- lms_gating_api.evaluate_prerequisite(
- self.course,
- Mock(location=self.blocks['A'].location),
- self.user,
- )
+ lms_gating_api.evaluate_prerequisite(
+ self.course,
+ Mock(location=self.blocks['A'].location, percent_graded=1.0),
+ self.user,
+ )
self.get_blocks_and_check_against_expected(self.user, self.ALL_BLOCKS)
def get_blocks_and_check_against_expected(self, user, expected_blocks):
diff --git a/lms/djangoapps/gating/api.py b/lms/djangoapps/gating/api.py
index 84ce71eefe..5155310a06 100644
--- a/lms/djangoapps/gating/api.py
+++ b/lms/djangoapps/gating/api.py
@@ -18,9 +18,9 @@ log = logging.getLogger(__name__)
def evaluate_prerequisite(course, subsection_grade, user):
"""
Evaluates any gating milestone relationships attached to the given
- subsection. If the subsection_grade meets the minimum score required
- by dependent subsections, the related milestone will be marked
- fulfilled for the user.
+ subsection. If the subsection_grade and subsection_completion meets
+ the minimum score required by dependent subsections, the related
+ milestone will be marked fulfilled for the user.
"""
prereq_milestone = gating_api.get_gating_milestone(course.id, subsection_grade.location, 'fulfills')
if prereq_milestone:
@@ -30,8 +30,13 @@ def evaluate_prerequisite(course, subsection_grade, user):
gated_content = gated_content_milestones.get(prereq_milestone['id'])
if gated_content:
+ grade_percentage = subsection_grade.percent_graded * 100.0 \
+ if hasattr(subsection_grade, 'percent_graded') else None
+
for milestone in gated_content:
- gating_api.update_milestone(milestone, subsection_grade, prereq_milestone, user.id)
+ gating_api.update_milestone(
+ milestone, subsection_grade.location, prereq_milestone, user, grade_percentage
+ )
def evaluate_entrance_exam(course_grade, user):
diff --git a/lms/djangoapps/gating/signals.py b/lms/djangoapps/gating/signals.py
index f05dd33b2d..f6896cf61a 100644
--- a/lms/djangoapps/gating/signals.py
+++ b/lms/djangoapps/gating/signals.py
@@ -1,9 +1,12 @@
"""
Signal handlers for the gating djangoapp
"""
+from django.db import models
from django.dispatch import receiver
+from completion.models import BlockCompletion
from gating import api as gating_api
+from gating.tasks import task_evaluate_subsection_completion_milestones
from lms.djangoapps.grades.signals.signals import SUBSECTION_SCORE_CHANGED
from openedx.core.djangoapps.signals.signals import COURSE_GRADE_CHANGED
@@ -24,6 +27,19 @@ def evaluate_subsection_gated_milestones(**kwargs):
gating_api.evaluate_prerequisite(kwargs['course'], subsection_grade, kwargs.get('user'))
+@receiver(models.signals.post_save, sender=BlockCompletion)
+def evaluate_subsection_completion_milestones(**kwargs):
+ """
+ Receives the BlockCompletion signal and triggers the
+ evaluation of any milestone which can be completed.
+ """
+ instance = kwargs['instance']
+ course_id = unicode(instance.course_key)
+ block_id = unicode(instance.block_key)
+ user_id = instance.user_id
+ task_evaluate_subsection_completion_milestones(course_id, block_id, user_id)
+
+
@receiver(COURSE_GRADE_CHANGED)
def evaluate_course_gated_milestones(**kwargs):
"""
diff --git a/lms/djangoapps/gating/tasks.py b/lms/djangoapps/gating/tasks.py
new file mode 100644
index 0000000000..6bf98bee10
--- /dev/null
+++ b/lms/djangoapps/gating/tasks.py
@@ -0,0 +1,64 @@
+"""
+This file contains celery tasks related to course content gating.
+"""
+import logging
+
+from celery import task
+from django.contrib.auth.models import User
+
+from gating import api as gating_api
+from lms.djangoapps.course_blocks.api import get_course_blocks
+from opaque_keys.edx.keys import CourseKey, UsageKey
+from xmodule.modulestore.django import modulestore
+
+
+log = logging.getLogger(__name__)
+
+
+@task()
+def task_evaluate_subsection_completion_milestones(course_id, block_id, user_id):
+ """
+ Updates users' milestones related to completion of a subsection.
+ Args:
+ course_id(str): Course id which triggered a completion event
+ block_id(str): Id of the completed block
+ user_id(int): Id of the user who completed a block
+ """
+ store = modulestore()
+ course_key = CourseKey.from_string(course_id)
+ with store.bulk_operations(course_key):
+ course = store.get_course(course_key)
+ if not course or not course.enable_subsection_gating:
+ log.debug(
+ "Gating: ignoring evaluation of completion milestone because it disabled for course [%s]", course_id
+ )
+ else:
+ try:
+ user = User.objects.get(id=user_id)
+ course_structure = get_course_blocks(user, store.make_course_usage_key(course_key))
+ completed_block_usage_key = UsageKey.from_string(block_id).map_into_course(course.id)
+ subsection_block = _get_subsection_of_block(completed_block_usage_key, course_structure)
+ subsection = course_structure[subsection_block]
+ log.debug(
+ "Gating: Evaluating completion milestone for subsection [%s] and user [%s]",
+ unicode(subsection.location), user.id
+ )
+ gating_api.evaluate_prerequisite(course, subsection, user)
+ except KeyError:
+ log.error("Gating: Given prerequisite subsection [%s] not found in course structure", block_id)
+
+
+def _get_subsection_of_block(usage_key, block_structure):
+ """
+ Finds subsection of a block by recursively iterating over its parents
+ :param usage_key: key of the block
+ :param block_structure: block structure
+ :return: sequential block
+ """
+ parents = block_structure.get_parents(usage_key)
+ if parents:
+ for parent_block in parents:
+ if parent_block.block_type == 'sequential':
+ return parent_block
+ else:
+ return _get_subsection_of_block(parent_block, block_structure)
diff --git a/lms/djangoapps/gating/tests/test_api.py b/lms/djangoapps/gating/tests/test_api.py
index be601e935e..0dfed52795 100644
--- a/lms/djangoapps/gating/tests/test_api.py
+++ b/lms/djangoapps/gating/tests/test_api.py
@@ -68,44 +68,58 @@ class TestEvaluatePrerequisite(GatingTestCase, MilestonesTestCaseMixin):
super(TestEvaluatePrerequisite, self).setUp()
self.user_dict = {'id': self.user.id}
self.prereq_milestone = None
- self.subsection_grade = Mock(location=self.seq1.location)
+ self.subsection_grade = Mock(location=self.seq1.location, percent_graded=0.5)
- def _setup_gating_milestone(self, min_score):
+ def _setup_gating_milestone(self, min_score, min_completion):
"""
Setup a gating milestone for testing
"""
gating_api.add_prerequisite(self.course.id, self.seq1.location)
- gating_api.set_required_content(self.course.id, self.seq2.location, self.seq1.location, min_score)
+ gating_api.set_required_content(
+ self.course.id, self.seq2.location, self.seq1.location, min_score, min_completion
+ )
self.prereq_milestone = gating_api.get_gating_milestone(self.course.id, self.seq1.location, 'fulfills')
- @patch('openedx.core.lib.gating.api._get_subsection_percentage')
- @data((50, True), (100, True), (0, False))
+ @patch('openedx.core.lib.gating.api.get_subsection_completion_percentage')
+ @data(
+ (50, 0, 50, 0, True),
+ (50, 0, 10, 0, False),
+ (0, 50, 0, 50, True),
+ (0, 50, 0, 10, False),
+ (50, 50, 50, 10, False),
+ (50, 50, 10, 50, False),
+ (50, 50, 50, 50, True),
+ )
@unpack
- def test_min_score_achieved(self, module_score, result, mock_score):
- self._setup_gating_milestone(50)
- mock_score.return_value = module_score
+ def test_min_score_achieved(
+ self, min_score, min_completion, module_score, module_completion, result, mock_completion
+ ):
+ self._setup_gating_milestone(min_score, min_completion)
+ mock_completion.return_value = module_completion
+ self.subsection_grade.percent_graded = module_score / 100.0
evaluate_prerequisite(self.course, self.subsection_grade, self.user)
self.assertEqual(milestones_api.user_has_milestone(self.user_dict, self.prereq_milestone), result)
- @patch('openedx.core.lib.gating.api._get_subsection_percentage')
+ @patch('openedx.core.lib.gating.api.get_subsection_completion_percentage')
@patch('openedx.core.lib.gating.api._get_minimum_required_percentage')
- @data((50, False), (100, True))
+ @data((50, 50, False), (100, 50, False), (50, 100, False), (100, 100, True))
@unpack
- def test_invalid_min_score(self, module_score, result, mock_min_score, mock_score):
- self._setup_gating_milestone(None)
- mock_score.return_value = module_score
- mock_min_score.return_value = 100
+ def test_invalid_min_score(self, module_score, module_completion, result, mock_min_score, mock_completion):
+ self._setup_gating_milestone(None, None)
+ mock_completion.return_value = module_completion
+ self.subsection_grade.percent_graded = module_score / 100.0
+ mock_min_score.return_value = 100, 100
evaluate_prerequisite(self.course, self.subsection_grade, self.user)
self.assertEqual(milestones_api.user_has_milestone(self.user_dict, self.prereq_milestone), result)
- @patch('openedx.core.lib.gating.api._get_subsection_percentage')
+ @patch('openedx.core.lib.gating.api.get_subsection_grade_percentage')
def test_no_prerequisites(self, mock_score):
evaluate_prerequisite(self.course, self.subsection_grade, self.user)
self.assertFalse(mock_score.called)
- @patch('openedx.core.lib.gating.api._get_subsection_percentage')
+ @patch('openedx.core.lib.gating.api.get_subsection_grade_percentage')
def test_no_gated_content(self, mock_score):
gating_api.add_prerequisite(self.course.id, self.seq1.location)
diff --git a/lms/djangoapps/gating/tests/test_integration.py b/lms/djangoapps/gating/tests/test_integration.py
index 6880957b5d..8ba2ff58c9 100644
--- a/lms/djangoapps/gating/tests/test_integration.py
+++ b/lms/djangoapps/gating/tests/test_integration.py
@@ -2,6 +2,7 @@
Integration tests for gated content.
"""
import ddt
+from completion import waffle as completion_waffle
from milestones import api as milestones_api
from milestones.tests.utils import MilestonesTestCaseMixin
from nose.plugins.attrib import attr
@@ -32,7 +33,7 @@ class TestGatedContent(MilestonesTestCaseMixin, SharedModuleStoreTestCase):
def setUp(self):
super(TestGatedContent, self).setUp()
- self.setup_gating_milestone(50)
+ self.setup_gating_milestone(50, 100)
self.non_staff_user = UserFactory()
self.staff_user = UserFactory(is_staff=True, is_superuser=True)
self.request = get_mock_request(self.non_staff_user)
@@ -110,14 +111,16 @@ class TestGatedContent(MilestonesTestCaseMixin, SharedModuleStoreTestCase):
display_name='problem 3',
)
- def setup_gating_milestone(self, min_score):
+ def setup_gating_milestone(self, min_score, min_completion):
"""
Setup a gating milestone for testing.
Gating content: seq1 (must be fulfilled before access to seq2)
Gated content: seq2 (requires completion of seq1 before access)
"""
gating_api.add_prerequisite(self.course.id, str(self.seq1.location))
- gating_api.set_required_content(self.course.id, str(self.seq2.location), str(self.seq1.location), min_score)
+ gating_api.set_required_content(
+ self.course.id, str(self.seq2.location), str(self.seq1.location), min_score, min_completion
+ )
self.prereq_milestone = gating_api.get_gating_milestone(self.course.id, self.seq1.location, 'fulfills')
def assert_access_to_gated_content(self, user):
@@ -164,29 +167,30 @@ class TestGatedContent(MilestonesTestCaseMixin, SharedModuleStoreTestCase):
self.assert_access_to_gated_content(self.staff_user)
def test_gated_content_always_in_grades(self):
- # start with a grade from a non-gated subsection
- answer_problem(self.course, self.request, self.prob3, 10, 10)
+ with completion_waffle.waffle().override(completion_waffle.ENABLE_COMPLETION_TRACKING, True):
+ # start with a grade from a non-gated subsection
+ answer_problem(self.course, self.request, self.prob3, 10, 10)
- # verify gated status and overall course grade percentage
- self.assert_user_has_prereq_milestone(self.non_staff_user, expected_has_milestone=False)
- self.assert_access_to_gated_content(self.non_staff_user)
- self.assert_course_grade(self.non_staff_user, .33)
+ # verify gated status and overall course grade percentage
+ self.assert_user_has_prereq_milestone(self.non_staff_user, expected_has_milestone=False)
+ self.assert_access_to_gated_content(self.non_staff_user)
+ self.assert_course_grade(self.non_staff_user, .33)
- # fulfill the gated requirements
- answer_problem(self.course, self.request, self.gating_prob1, 10, 10)
+ # fulfill the gated requirements
+ answer_problem(self.course, self.request, self.gating_prob1, 10, 10)
- # verify gated status and overall course grade percentage
- self.assert_user_has_prereq_milestone(self.non_staff_user, expected_has_milestone=True)
- self.assert_access_to_gated_content(self.non_staff_user)
- self.assert_course_grade(self.non_staff_user, .67)
+ # verify gated status and overall course grade percentage
+ self.assert_user_has_prereq_milestone(self.non_staff_user, expected_has_milestone=True)
+ self.assert_access_to_gated_content(self.non_staff_user)
+ self.assert_course_grade(self.non_staff_user, .67)
@ddt.data((1, 1, True), (1, 2, True), (1, 3, False), (0, 1, False))
@ddt.unpack
def test_ungating_when_fulfilled(self, earned, max_possible, result):
self.assert_user_has_prereq_milestone(self.non_staff_user, expected_has_milestone=False)
self.assert_access_to_gated_content(self.non_staff_user)
+ with completion_waffle.waffle().override(completion_waffle.ENABLE_COMPLETION_TRACKING, True):
+ answer_problem(self.course, self.request, self.gating_prob1, earned, max_possible)
- answer_problem(self.course, self.request, self.gating_prob1, earned, max_possible)
-
- self.assert_user_has_prereq_milestone(self.non_staff_user, expected_has_milestone=result)
- self.assert_access_to_gated_content(self.non_staff_user)
+ self.assert_user_has_prereq_milestone(self.non_staff_user, expected_has_milestone=result)
+ self.assert_access_to_gated_content(self.non_staff_user)
diff --git a/lms/templates/_gated_content.html b/lms/templates/_gated_content.html
index 561f59e561..2039baec5a 100644
--- a/lms/templates/_gated_content.html
+++ b/lms/templates/_gated_content.html
@@ -16,7 +16,7 @@ from openedx.core.djangolib.markup import Text
${Text(_(
- "You must earn a passing score for '{prereq_section_name}' to access this content."
+ "You must complete the prerequisites for '{prereq_section_name}' to access this content."
)).format(prereq_section_name=prereq_section_name)}
${_("Go to Prerequisite Section")}
diff --git a/openedx/core/lib/gating/api.py b/openedx/core/lib/gating/api.py
index e93ee3d2a2..b9bc9800ec 100644
--- a/openedx/core/lib/gating/api.py
+++ b/openedx/core/lib/gating/api.py
@@ -7,12 +7,13 @@ import logging
from django.contrib.auth.models import User
from django.core.urlresolvers import reverse
from django.utils.translation import ugettext as _
+
+from completion.models import BlockCompletion
from lms.djangoapps.courseware.access import _has_access_to_course
from lms.djangoapps.course_blocks.api import get_course_blocks
from lms.djangoapps.grades.subsection_grade_factory import SubsectionGradeFactory
from milestones import api as milestones_api
from opaque_keys.edx.keys import UsageKey
-from opaque_keys.edx.locator import BlockUsageLocator
from openedx.core.lib.gating.exceptions import GatingValidationError
from util import milestones_helpers
from xmodule.modulestore.django import modulestore
@@ -230,7 +231,7 @@ def is_prerequisite(course_key, prereq_content_key):
) is not None
-def set_required_content(course_key, gated_content_key, prereq_content_key, min_score):
+def set_required_content(course_key, gated_content_key, prereq_content_key, min_score='', min_completion=''):
"""
Adds a `requires` milestone relationship for the given gated_content_key if a prerequisite
prereq_content_key is provided. If prereq_content_key is None, removes the `requires`
@@ -241,6 +242,7 @@ def set_required_content(course_key, gated_content_key, prereq_content_key, min_
gated_content_key (str|UsageKey): The gated content usage key
prereq_content_key (str|UsageKey): The prerequisite content usage key
min_score (str|int): The minimum score
+ min_completion (str|int): The minimum completion percentage
Returns:
None
@@ -254,7 +256,7 @@ def set_required_content(course_key, gated_content_key, prereq_content_key, min_
if prereq_content_key:
_validate_min_score(min_score)
- requirements = {'min_score': min_score}
+ requirements = {'min_score': min_score, 'min_completion': min_completion}
if not milestone:
milestone = _get_prerequisite_milestone(prereq_content_key)
milestones_api.add_course_content_milestone(course_key, gated_content_key, 'requires', milestone, requirements)
@@ -262,7 +264,7 @@ def set_required_content(course_key, gated_content_key, prereq_content_key, min_
def get_required_content(course_key, gated_content_key):
"""
- Returns the prerequisite content usage key and minimum score needed for fulfillment
+ Returns the prerequisite content usage key, minimum score and minimum completion percentage needed for fulfillment
of that prerequisite for the given gated_content_key.
Args:
@@ -270,16 +272,18 @@ def get_required_content(course_key, gated_content_key):
gated_content_key (str|UsageKey): The gated content usage key
Returns:
- tuple: The prerequisite content usage key and minimum score, (None, None) if the content is not gated
+ tuple: The prerequisite content usage key, minimum score and minimum completion percentage,
+ (None, None, None) if the content is not gated
"""
milestone = get_gating_milestone(course_key, gated_content_key, 'requires')
if milestone:
return (
_get_gating_block_id(milestone),
- milestone.get('requirements', {}).get('min_score')
+ milestone.get('requirements', {}).get('min_score', None),
+ milestone.get('requirements', {}).get('min_completion', None),
)
else:
- return None, None
+ return None, None, None
@gating_enabled(default=[])
@@ -378,41 +382,37 @@ def compute_is_prereq_met(content_id, user_id, recalc_on_unmet=False):
'url': reverse('jump_to', kwargs={'course_id': course_key, 'location': subsection_usage_key}),
'display_name': subsection.display_name
}
-
- try:
- subsection_structure = get_course_blocks(student, subsection_usage_key)
- if any(subsection_structure):
- subsection_grade_factory = SubsectionGradeFactory(student, course_structure=subsection_structure)
- if subsection_usage_key in subsection_structure:
- # this will force a recalcuation of the subsection grade
- subsection_grade = subsection_grade_factory.update(subsection_structure[subsection_usage_key], persist_grade=False)
- prereq_met = update_milestone(milestone, subsection_grade, milestone, user_id)
- except ItemNotFoundError as err:
- log.warning("Could not find course_block for subsection=%s error=%s", subsection_usage_key, err)
+ prereq_met = update_milestone(milestone, subsection_usage_key, milestone, student)
return prereq_met, prereq_meta_info
-def update_milestone(milestone, subsection_grade, prereq_milestone, user_id):
+def update_milestone(milestone, usage_key, prereq_milestone, user, grade_percentage=None, completion_percentage=None):
"""
Updates the milestone record based on evaluation of prerequisite met.
Arguments:
milestone: The gated milestone being evaluated
- subsection_grade: The grade of the prerequisite subsection
- prerequisite_milestone: The gating milestone
- user_id: The id of the user
+ usage_key: Usage key of the prerequisite subsection
+ prereq_milestone: The gating milestone
+ user: The user who has fulfilled milestone
+ grade_percentage: Grade percentage of prerequisite subsection
+ completion_percentage: Completion percentage of prerequisite subsection
Returns:
True if prerequisite has been met, False if not
"""
- min_percentage = _get_minimum_required_percentage(milestone)
- subsection_percentage = _get_subsection_percentage(subsection_grade)
- if subsection_percentage >= min_percentage:
- milestones_helpers.add_user_milestone({'id': user_id}, prereq_milestone)
+ min_score, min_completion = _get_minimum_required_percentage(milestone)
+ if not grade_percentage:
+ grade_percentage = get_subsection_grade_percentage(usage_key, user) if min_score > 0 else 0
+ if not completion_percentage:
+ completion_percentage = get_subsection_completion_percentage(usage_key, user) if min_completion > 0 else 0
+
+ if grade_percentage >= min_score and completion_percentage >= min_completion:
+ milestones_helpers.add_user_milestone({'id': user.id}, prereq_milestone)
return True
else:
- milestones_helpers.remove_user_milestone({'id': user_id}, prereq_milestone)
+ milestones_helpers.remove_user_milestone({'id': user.id}, prereq_milestone)
return False
@@ -423,12 +423,74 @@ def _get_gating_block_id(milestone):
return milestone.get('namespace', '').replace(GATING_NAMESPACE_QUALIFIER, '')
+def get_subsection_grade_percentage(subsection_usage_key, user):
+ """
+ Computes grade percentage for a subsection in a given course for a user
+
+ Arguments:
+ subsection_usage_key: key of subsection
+ user: The user whose grade needs to be computed
+
+ Returns:
+ User's grade percentage for given subsection
+ """
+ subsection_grade_percentage = 0.0
+ try:
+ subsection_structure = get_course_blocks(user, subsection_usage_key)
+ if any(subsection_structure):
+ subsection_grade_factory = SubsectionGradeFactory(user, course_structure=subsection_structure)
+ if subsection_usage_key in subsection_structure:
+ # this will force a recalculation of the subsection grade
+ subsection_grade = subsection_grade_factory.update(
+ subsection_structure[subsection_usage_key], persist_grade=False
+ )
+ subsection_grade_percentage = subsection_grade.percent_graded * 100.0
+ except ItemNotFoundError as err:
+ log.warning("Could not find course_block for subsection=%s error=%s", subsection_usage_key, err)
+ return subsection_grade_percentage
+
+
+def get_subsection_completion_percentage(subsection_usage_key, user):
+ """
+ Computes completion percentage for a subsection in a given course for a user
+ Arguments:
+ subsection_usage_key: key of subsection
+ user: The user whose completion percentage needs to be computed
+ Returns:
+ User's completion percentage for given subsection
+ """
+ subsection_completion_percentage = 0.0
+ try:
+ subsection_structure = get_course_blocks(user, subsection_usage_key)
+ if any(subsection_structure):
+ completable_blocks = [
+ block for block in subsection_structure
+ if block.block_type not in ['chapter', 'sequential', 'vertical']
+ ]
+ if not completable_blocks:
+ return 0
+ subsection_completion_total = 0
+ course_block_completions = BlockCompletion.get_course_completions(user, subsection_usage_key.course_key)
+ for block in completable_blocks:
+ if course_block_completions.get(block):
+ subsection_completion_total += course_block_completions.get(block)
+ subsection_completion_percentage = min(
+ 100 * (subsection_completion_total / float(len(completable_blocks))), 100
+ )
+
+ except ItemNotFoundError as err:
+ log.warning("Could not find course_block for subsection=%s error=%s", subsection_usage_key, err)
+
+ return subsection_completion_percentage
+
+
def _get_minimum_required_percentage(milestone):
"""
- Returns the minimum percentage requirement for the given milestone.
+ Returns the minimum score and minimum completion percentage requirement for the given milestone.
"""
- # Default minimum score to 100
+ # Default minimum score and minimum completion percentage to 100
min_score = 100
+ min_completion = 100
requirements = milestone.get('requirements')
if requirements:
try:
@@ -438,7 +500,14 @@ def _get_minimum_required_percentage(milestone):
u'Gating: Failed to find minimum score for gating milestone %s, defaulting to 100',
json.dumps(milestone)
)
- return min_score
+ try:
+ min_completion = int(requirements.get('min_completion', 0))
+ except (ValueError, TypeError):
+ log.warning(
+ u'Gating: Failed to find minimum completion percentage for gating milestone %s, defaulting to 100',
+ json.dumps(milestone)
+ )
+ return min_score, min_completion
def _get_subsection_percentage(subsection_grade):
diff --git a/openedx/core/lib/gating/tests/test_api.py b/openedx/core/lib/gating/tests/test_api.py
index c54e631ddb..d0341e9a58 100644
--- a/openedx/core/lib/gating/tests/test_api.py
+++ b/openedx/core/lib/gating/tests/test_api.py
@@ -5,7 +5,7 @@ import unittest
from mock import patch, Mock
from nose.plugins.attrib import attr
-from ddt import ddt, data
+from ddt import ddt, data, unpack
from django.conf import settings
from lms.djangoapps.gating import api as lms_gating_api
from milestones.tests.utils import MilestonesTestCaseMixin
@@ -145,17 +145,23 @@ class TestGatingApi(ModuleStoreTestCase, MilestonesTestCaseMixin):
""" Test test_required_content """
gating_api.add_prerequisite(self.course.id, self.seq1.location)
- gating_api.set_required_content(self.course.id, self.seq2.location, self.seq1.location, 100)
+ gating_api.set_required_content(self.course.id, self.seq2.location, self.seq1.location, 100, 100)
- prereq_content_key, min_score = gating_api.get_required_content(self.course.id, self.seq2.location)
+ prereq_content_key, min_score, min_completion = gating_api.get_required_content(
+ self.course.id, self.seq2.location
+ )
self.assertEqual(prereq_content_key, unicode(self.seq1.location))
self.assertEqual(min_score, 100)
+ self.assertEqual(min_completion, 100)
- gating_api.set_required_content(self.course.id, self.seq2.location, None, None)
+ gating_api.set_required_content(self.course.id, self.seq2.location, None, None, None)
- prereq_content_key, min_score = gating_api.get_required_content(self.course.id, self.seq2.location)
+ prereq_content_key, min_score, min_completion = gating_api.get_required_content(
+ self.course.id, self.seq2.location
+ )
self.assertIsNone(prereq_content_key)
self.assertIsNone(min_score)
+ self.assertIsNone(min_completion)
def test_get_gated_content(self):
"""
@@ -179,13 +185,25 @@ class TestGatingApi(ModuleStoreTestCase, MilestonesTestCaseMixin):
self.assertEqual(gating_api.get_gated_content(self.course, student), [])
- def test_is_gate_fulfilled(self):
+ @data(
+ (100, 0, 50, 0, False),
+ (100, 0, 100, 0, True),
+ (0, 100, 0, 50, False),
+ (0, 100, 0, 100, True),
+ (100, 100, 50, 100, False),
+ (100, 100, 100, 50, False),
+ (100, 100, 100, 100, True),
+ )
+ @unpack
+ def test_is_gate_fulfilled(self, min_score, min_completion, learner_score, learner_completion, is_gate_fulfilled):
"""
Test if prereq section has any unfulfilled milestones
"""
student = UserFactory(is_staff=False)
gating_api.add_prerequisite(self.course.id, self.seq1.location)
- gating_api.set_required_content(self.course.id, self.seq2.location, self.seq1.location, 100)
+ gating_api.set_required_content(
+ self.course.id, self.seq2.location, self.seq1.location, min_score, min_completion
+ )
milestone = milestones_api.add_milestone(self.generic_milestone)
milestones_api.add_course_content_milestone(self.course.id, self.seq1.location, 'fulfills', milestone)
@@ -193,22 +211,16 @@ class TestGatingApi(ModuleStoreTestCase, MilestonesTestCaseMixin):
# complete the prerequisite to unlock the gated content
# this call triggers reevaluation of prerequisites fulfilled by the gating block.
- with patch.object(gating_api, '_get_subsection_percentage') as mock_grade:
- mock_grade.return_value = 75
+ with patch.object(gating_api, 'get_subsection_completion_percentage') as mock_grade:
+ mock_grade.return_value = learner_completion
lms_gating_api.evaluate_prerequisite(
self.course,
- Mock(location=self.seq1.location),
+ Mock(location=self.seq1.location, percent_graded=learner_score / 100.0),
student,
)
- self.assertFalse(gating_api.is_gate_fulfilled(self.course.id, self.seq1.location, student.id))
-
- mock_grade.return_value = 100
- lms_gating_api.evaluate_prerequisite(
- self.course,
- Mock(location=self.seq1.location),
- student,
+ self.assertEqual(
+ gating_api.is_gate_fulfilled(self.course.id, self.seq1.location, student.id), is_gate_fulfilled
)
- self.assertTrue(gating_api.is_gate_fulfilled(self.course.id, self.seq1.location, student.id))
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
def test_compute_is_prereq_met(self):
@@ -217,11 +229,11 @@ class TestGatingApi(ModuleStoreTestCase, MilestonesTestCaseMixin):
"""
student = UserFactory(is_staff=False)
gating_api.add_prerequisite(self.course.id, self.seq1.location)
- gating_api.set_required_content(self.course.id, self.seq2.location, self.seq1.location, 100)
+ gating_api.set_required_content(self.course.id, self.seq2.location, self.seq1.location, 100, 0)
# complete the prerequisite to unlock the gated content
# this call triggers reevaluation of prerequisites fulfilled by the gating block.
- with patch.object(gating_api, '_get_subsection_percentage') as mock_grade:
+ with patch.object(gating_api, 'get_subsection_grade_percentage') as mock_grade:
mock_grade.return_value = 75
# don't force recompute
prereq_met, prereq_meta_info = gating_api.compute_is_prereq_met(self.seq2.location, student.id, False)
diff --git a/openedx/features/course_experience/tests/views/test_course_outline.py b/openedx/features/course_experience/tests/views/test_course_outline.py
index 8943a8f8f1..dfbf8aca53 100644
--- a/openedx/features/course_experience/tests/views/test_course_outline.py
+++ b/openedx/features/course_experience/tests/views/test_course_outline.py
@@ -251,10 +251,10 @@ class TestCourseOutlinePageWithPrerequisites(SharedModuleStoreTestCase, Mileston
# complete the prerequisite to unlock the gated content
# this call triggers reevaluation of prerequisites fulfilled by the gating block.
- with patch('openedx.core.lib.gating.api._get_subsection_percentage', Mock(return_value=100)):
+ with patch('openedx.core.lib.gating.api.get_subsection_completion_percentage', Mock(return_value=100)):
lms_gating_api.evaluate_prerequisite(
self.course,
- Mock(location=self.course_blocks['prerequisite'].location),
+ Mock(location=self.course_blocks['prerequisite'].location, percent_graded=1.0),
self.user,
)