added radio input fields to pre-requisite selection

Initial changes to gate section based on completion percentage

code refactoring and added evaluation of completion milestone

Fixed broken unit tests and added new tests

Fixed broken tests and quality violations

Fixed Pep8 violation

Fixed eslint quality violations

Test changes as suggested by reviewer

changes after feedbacy from reviewer

Update the docstring with suggested changes

excluding chapter from the blocks

Disallow empty values for min score and min completion

Changes afte feedback from UX/Accessibility

removed blank line
This commit is contained in:
Zia Fazal
2018-04-05 17:59:26 +05:00
parent c346b6423e
commit 53cf5638b6
20 changed files with 471 additions and 156 deletions

View File

@@ -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)

View File

@@ -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
)

View File

@@ -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

View File

@@ -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)

View File

@@ -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() {

View File

@@ -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
};
}
});

View File

@@ -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;
}
}
}
}

View File

@@ -4,7 +4,7 @@
<div class="modal-section-content gating-prereq">
<ul class="list-fields list-input">
<p class="field-message">
<%- gettext('Select a prerequisite subsection and enter a minimum score percentage to limit access to this subsection.') %>
<%- gettext('Select a prerequisite subsection and enter a minimum score percentage and minimum completion percentage to limit access to this subsection. Allowed values are 0-100') %>
</p>
<li class="field field-select">
<label class="label">
@@ -18,14 +18,25 @@
</label>
</li>
<li id="prereq_min_score_input" class="field field-input input-cosmetic">
<label class="label">
<label for="prereq_min_score">
<%- gettext('Minimum Score:') %>
<input type="text" id="prereq_min_score" name="prereq_min_score" class="input input-text" size="3" />
</label>
<input type="number" id="prereq_min_score" aria-describedby="msp" class="input input-text percentage" min="0" max="100" />
<span id="msp">%</span>
</li>
<div id="prereq_min_score_error" class="message-status error">
<li id="prereq_min_completion_input" class="field field-input input-cosmetic">
<label for="prereq_min_completion">
<%- gettext('Minimum Completion:') %>
</label>
<input type="number" id="prereq_min_completion" aria-describedby="mcp" class="input input-text percentage" min="0" max="100" />
<span id="mcp">%</span>
</li>
<li id="prereq_min_score_error" class="message-status error">
<%- gettext('The minimum score percentage must be a whole number between 0 and 100.') %>
</div>
</li>
<li id="prereq_min_completion_error" class="message-status error">
<%- gettext('The minimum completion percentage must be a whole number between 0 and 100.') %>
</li>
</ul>
</div>
<% } %>

View File

@@ -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()

View File

@@ -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):
"""

View File

@@ -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):

View File

@@ -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):

View File

@@ -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):
"""

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -16,7 +16,7 @@ from openedx.core.djangolib.markup import Text
</h3>
<p>
${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)}
<p>
<a href="${prereq_url}" class="btn btn-brand">${_("Go to Prerequisite Section")}

View File

@@ -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):

View File

@@ -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)

View File

@@ -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,
)