Commit 37a7fdc0 by Douglas Hall

Added subsection gating feature

parent 1603a1ab
...@@ -139,8 +139,11 @@ ...@@ -139,8 +139,11 @@
"appendSetFixtures", "appendSetFixtures",
"spyOnEvent", "spyOnEvent",
// Django i18n catalog globals
"interpolate",
"gettext",
// Miscellaneous globals // Miscellaneous globals
"JSON", "JSON"
"gettext"
] ]
} }
...@@ -5,10 +5,12 @@ from pytz import UTC ...@@ -5,10 +5,12 @@ from pytz import UTC
from django.dispatch import receiver from django.dispatch import receiver
from xmodule.modulestore.django import SignalHandler from xmodule.modulestore.django import modulestore, SignalHandler
from contentstore.courseware_index import CoursewareSearchIndexer, LibrarySearchIndexer from contentstore.courseware_index import CoursewareSearchIndexer, LibrarySearchIndexer
from contentstore.proctoring import register_special_exams from contentstore.proctoring import register_special_exams
from openedx.core.djangoapps.credit.signals import on_course_publish from openedx.core.djangoapps.credit.signals import on_course_publish
from openedx.core.lib.gating import api as gating_api
from util.module_utils import yield_dynamic_descriptor_descendants
@receiver(SignalHandler.course_published) @receiver(SignalHandler.course_published)
...@@ -48,3 +50,30 @@ def listen_for_library_update(sender, library_key, **kwargs): # pylint: disable ...@@ -48,3 +50,30 @@ def listen_for_library_update(sender, library_key, **kwargs): # pylint: disable
from .tasks import update_library_index from .tasks import update_library_index
update_library_index.delay(unicode(library_key), datetime.now(UTC).isoformat()) update_library_index.delay(unicode(library_key), datetime.now(UTC).isoformat())
@receiver(SignalHandler.item_deleted)
def handle_item_deleted(**kwargs):
"""
Receives the item_deleted signal sent by Studio when an XBlock is removed from
the course structure and removes any gating milestone data associated with it or
its descendants.
Arguments:
kwargs (dict): Contains the content usage key of the item deleted
Returns:
None
"""
usage_key = kwargs.get('usage_key')
if usage_key:
# Strip branch info
usage_key = usage_key.for_branch(None)
course_key = usage_key.course_key
deleted_module = modulestore().get_item(usage_key)
for module in yield_dynamic_descriptor_descendants(deleted_module, kwargs.get('user_id')):
# 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)
...@@ -26,7 +26,7 @@ from xmodule.modulestore import ModuleStoreEnum ...@@ -26,7 +26,7 @@ from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.factories import CourseFactory
from xmodule.tabs import InvalidTabsException from xmodule.tabs import InvalidTabsException
from util.milestones_helpers import seed_milestone_relationship_types from milestones.tests.utils import MilestonesTestCaseMixin
from .utils import CourseTestCase from .utils import CourseTestCase
...@@ -69,7 +69,7 @@ class CourseSettingsEncoderTest(CourseTestCase): ...@@ -69,7 +69,7 @@ class CourseSettingsEncoderTest(CourseTestCase):
@ddt.ddt @ddt.ddt
class CourseDetailsViewTest(CourseTestCase): class CourseDetailsViewTest(CourseTestCase, MilestonesTestCaseMixin):
""" """
Tests for modifying content on the first course settings page (course dates, overview, etc.). Tests for modifying content on the first course settings page (course dates, overview, etc.).
""" """
...@@ -156,14 +156,12 @@ class CourseDetailsViewTest(CourseTestCase): ...@@ -156,14 +156,12 @@ class CourseDetailsViewTest(CourseTestCase):
@mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_PREREQUISITE_COURSES': True, 'MILESTONES_APP': True}) @mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_PREREQUISITE_COURSES': True, 'MILESTONES_APP': True})
def test_pre_requisite_course_list_present(self): def test_pre_requisite_course_list_present(self):
seed_milestone_relationship_types()
settings_details_url = get_url(self.course.id) settings_details_url = get_url(self.course.id)
response = self.client.get_html(settings_details_url) response = self.client.get_html(settings_details_url)
self.assertContains(response, "Prerequisite Course") self.assertContains(response, "Prerequisite Course")
@mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_PREREQUISITE_COURSES': True, 'MILESTONES_APP': True}) @mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_PREREQUISITE_COURSES': True, 'MILESTONES_APP': True})
def test_pre_requisite_course_update_and_fetch(self): def test_pre_requisite_course_update_and_fetch(self):
seed_milestone_relationship_types()
url = get_url(self.course.id) url = get_url(self.course.id)
resp = self.client.get_json(url) resp = self.client.get_json(url)
course_detail_json = json.loads(resp.content) course_detail_json = json.loads(resp.content)
...@@ -191,7 +189,6 @@ class CourseDetailsViewTest(CourseTestCase): ...@@ -191,7 +189,6 @@ class CourseDetailsViewTest(CourseTestCase):
@mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_PREREQUISITE_COURSES': True, 'MILESTONES_APP': True}) @mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_PREREQUISITE_COURSES': True, 'MILESTONES_APP': True})
def test_invalid_pre_requisite_course(self): def test_invalid_pre_requisite_course(self):
seed_milestone_relationship_types()
url = get_url(self.course.id) url = get_url(self.course.id)
resp = self.client.get_json(url) resp = self.client.get_json(url)
course_detail_json = json.loads(resp.content) course_detail_json = json.loads(resp.content)
...@@ -254,7 +251,6 @@ class CourseDetailsViewTest(CourseTestCase): ...@@ -254,7 +251,6 @@ class CourseDetailsViewTest(CourseTestCase):
@unittest.skipUnless(settings.FEATURES.get('ENTRANCE_EXAMS', False), True) @unittest.skipUnless(settings.FEATURES.get('ENTRANCE_EXAMS', False), True)
def test_entrance_exam_created_updated_and_deleted_successfully(self): def test_entrance_exam_created_updated_and_deleted_successfully(self):
seed_milestone_relationship_types()
settings_details_url = get_url(self.course.id) settings_details_url = get_url(self.course.id)
data = { data = {
'entrance_exam_enabled': 'true', 'entrance_exam_enabled': 'true',
...@@ -305,7 +301,6 @@ class CourseDetailsViewTest(CourseTestCase): ...@@ -305,7 +301,6 @@ class CourseDetailsViewTest(CourseTestCase):
test that creating an entrance exam should store the default value, if key missing in json request test that creating an entrance exam should store the default value, if key missing in json request
or entrance_exam_minimum_score_pct is an empty string or entrance_exam_minimum_score_pct is an empty string
""" """
seed_milestone_relationship_types()
settings_details_url = get_url(self.course.id) settings_details_url = get_url(self.course.id)
test_data_1 = { test_data_1 = {
'entrance_exam_enabled': 'true', 'entrance_exam_enabled': 'true',
......
"""
Unit tests for the gating feature in Studio
"""
from contentstore.signals import handle_item_deleted
from milestones.tests.utils import MilestonesTestCaseMixin
from mock import patch
from openedx.core.lib.gating import api as gating_api
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import ItemFactory, CourseFactory
class TestHandleItemDeleted(ModuleStoreTestCase, MilestonesTestCaseMixin):
"""
Test case for handle_score_changed django signal handler
"""
def setUp(self):
"""
Initial data setup
"""
super(TestHandleItemDeleted, self).setUp()
self.course = CourseFactory.create()
self.course.enable_subsection_gating = True
self.course.save()
self.chapter = ItemFactory.create(
parent=self.course,
category="chapter",
display_name="Chapter"
)
self.open_seq = ItemFactory.create(
parent=self.chapter,
category='sequential',
display_name="Open Sequential"
)
self.gated_seq = ItemFactory.create(
parent=self.chapter,
category='sequential',
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)
@patch('contentstore.signals.gating_api.set_required_content')
@patch('contentstore.signals.gating_api.remove_prerequisite')
def test_chapter_deleted(self, mock_remove_prereq, mock_set_required):
""" 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)
@patch('contentstore.signals.gating_api.set_required_content')
@patch('contentstore.signals.gating_api.remove_prerequisite')
def test_sequential_deleted(self, mock_remove_prereq, mock_set_required):
""" 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)
...@@ -20,10 +20,11 @@ from student.tests.factories import UserFactory ...@@ -20,10 +20,11 @@ from student.tests.factories import UserFactory
from util import milestones_helpers from util import milestones_helpers
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from contentstore.views.helpers import create_xblock from contentstore.views.helpers import create_xblock
from milestones.tests.utils import MilestonesTestCaseMixin
@patch.dict(settings.FEATURES, {'ENTRANCE_EXAMS': True}) @patch.dict(settings.FEATURES, {'ENTRANCE_EXAMS': True})
class EntranceExamHandlerTests(CourseTestCase): class EntranceExamHandlerTests(CourseTestCase, MilestonesTestCaseMixin):
""" """
Base test class for create, save, and delete Base test class for create, save, and delete
""" """
...@@ -36,7 +37,6 @@ class EntranceExamHandlerTests(CourseTestCase): ...@@ -36,7 +37,6 @@ class EntranceExamHandlerTests(CourseTestCase):
self.usage_key = self.course.location self.usage_key = self.course.location
self.course_url = '/course/{}'.format(unicode(self.course.id)) self.course_url = '/course/{}'.format(unicode(self.course.id))
self.exam_url = '/course/{}/entrance_exam/'.format(unicode(self.course.id)) self.exam_url = '/course/{}/entrance_exam/'.format(unicode(self.course.id))
milestones_helpers.seed_milestone_relationship_types()
self.milestone_relationship_types = milestones_helpers.get_milestone_relationship_types() self.milestone_relationship_types = milestones_helpers.get_milestone_relationship_types()
def test_entrance_exam_milestone_addition(self): def test_entrance_exam_milestone_addition(self):
......
"""
Unit tests for the gating feature in Studio
"""
import json
from mock import patch
from xmodule.modulestore.tests.django_utils import TEST_DATA_SPLIT_MODULESTORE
from xmodule.modulestore.tests.factories import ItemFactory
from contentstore.tests.utils import CourseTestCase
from contentstore.utils import reverse_usage_url
from contentstore.views.item import VisibilityState
from openedx.core.lib.gating.api import GATING_NAMESPACE_QUALIFIER
class TestSubsectionGating(CourseTestCase):
"""
Tests for the subsection gating feature
"""
MODULESTORE = TEST_DATA_SPLIT_MODULESTORE
def setUp(self):
"""
Initial data setup
"""
super(TestSubsectionGating, self).setUp()
# Enable subsection gating for the test course
self.course.enable_subsection_gating = True
self.save_course()
# create a chapter
self.chapter = ItemFactory.create(
parent_location=self.course.location,
category='chapter',
display_name='untitled chapter'
)
# create 2 sequentials
self.seq1 = ItemFactory.create(
parent_location=self.chapter.location,
category='sequential',
display_name='untitled sequential 1'
)
self.seq1_url = reverse_usage_url('xblock_handler', self.seq1.location)
self.seq2 = ItemFactory.create(
parent_location=self.chapter.location,
category='sequential',
display_name='untitled sequential 2'
)
self.seq2_url = reverse_usage_url('xblock_handler', self.seq2.location)
@patch('contentstore.views.item.gating_api.add_prerequisite')
def test_add_prerequisite(self, mock_add_prereq):
"""
Test adding a subsection as a prerequisite
"""
self.client.ajax_post(
self.seq1_url,
data={'isPrereq': True}
)
mock_add_prereq.assert_called_with(self.course.id, self.seq1.location)
@patch('contentstore.views.item.gating_api.remove_prerequisite')
def test_remove_prerequisite(self, mock_remove_prereq):
"""
Test removing a subsection as a prerequisite
"""
self.client.ajax_post(
self.seq1_url,
data={'isPrereq': False}
)
mock_remove_prereq.assert_called_with(self.seq1.location)
@patch('contentstore.views.item.gating_api.set_required_content')
def test_add_gate(self, mock_set_required_content):
"""
Test adding a gated subsection
"""
self.client.ajax_post(
self.seq2_url,
data={'prereqUsageKey': unicode(self.seq1.location), 'prereqMinScore': '100'}
)
mock_set_required_content.assert_called_with(
self.course.id,
self.seq2.location,
unicode(self.seq1.location),
'100'
)
@patch('contentstore.views.item.gating_api.set_required_content')
def test_remove_gate(self, mock_set_required_content):
"""
Test removing a gated subsection
"""
self.client.ajax_post(
self.seq2_url,
data={'prereqUsageKey': '', 'prereqMinScore': ''}
)
mock_set_required_content.assert_called_with(
self.course.id,
self.seq2.location,
'',
''
)
@patch('xmodule.course_module.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):
mock_is_prereq.return_value = True
mock_get_required_content.return_value = unicode(self.seq1.location), 100
mock_get_prereqs.return_value = [
{'namespace': '{}{}'.format(unicode(self.seq1.location), GATING_NAMESPACE_QUALIFIER)},
{'namespace': '{}{}'.format(unicode(self.seq2.location), GATING_NAMESPACE_QUALIFIER)}
]
resp = json.loads(self.client.get_json(self.seq2_url).content)
mock_is_prereq.assert_called_with(self.course.id, self.seq2.location)
mock_get_required_content.assert_called_with(self.course.id, self.seq2.location)
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['visibility_state'], VisibilityState.gated)
@patch('contentstore.signals.gating_api.set_required_content')
@patch('contentstore.signals.gating_api.remove_prerequisite')
def test_delete_item_signal_handler_called(self, mock_remove_prereq, mock_set_required):
seq3 = ItemFactory.create(
parent_location=self.chapter.location,
category='sequential',
display_name='untitled sequential 3'
)
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)
...@@ -26,10 +26,10 @@ from contentstore.tests.utils import CourseTestCase ...@@ -26,10 +26,10 @@ from contentstore.tests.utils import CourseTestCase
from openedx.core.lib.extract_tar import safetar_extractall from openedx.core.lib.extract_tar import safetar_extractall
from student import auth from student import auth
from student.roles import CourseInstructorRole, CourseStaffRole from student.roles import CourseInstructorRole, CourseStaffRole
from util.milestones_helpers import seed_milestone_relationship_types
from models.settings.course_metadata import CourseMetadata from models.settings.course_metadata import CourseMetadata
from util import milestones_helpers from util import milestones_helpers
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from milestones.tests.utils import MilestonesTestCaseMixin
TEST_DATA_CONTENTSTORE = copy.deepcopy(settings.CONTENTSTORE) TEST_DATA_CONTENTSTORE = copy.deepcopy(settings.CONTENTSTORE)
TEST_DATA_CONTENTSTORE['DOC_STORE_CONFIG']['db'] = 'test_xcontent_%s' % uuid4().hex TEST_DATA_CONTENTSTORE['DOC_STORE_CONFIG']['db'] = 'test_xcontent_%s' % uuid4().hex
...@@ -39,7 +39,7 @@ log = logging.getLogger(__name__) ...@@ -39,7 +39,7 @@ log = logging.getLogger(__name__)
@override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE) @override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE)
class ImportEntranceExamTestCase(CourseTestCase): class ImportEntranceExamTestCase(CourseTestCase, MilestonesTestCaseMixin):
""" """
Unit tests for importing a course with entrance exam Unit tests for importing a course with entrance exam
""" """
...@@ -51,7 +51,6 @@ class ImportEntranceExamTestCase(CourseTestCase): ...@@ -51,7 +51,6 @@ class ImportEntranceExamTestCase(CourseTestCase):
# Create tar test file ----------------------------------------------- # Create tar test file -----------------------------------------------
# OK course with entrance exam section: # OK course with entrance exam section:
seed_milestone_relationship_types()
entrance_exam_dir = tempfile.mkdtemp(dir=self.content_dir) entrance_exam_dir = tempfile.mkdtemp(dir=self.content_dir)
# test course being deeper down than top of tar file # test course being deeper down than top of tar file
embedded_exam_dir = os.path.join(entrance_exam_dir, "grandparent", "parent") embedded_exam_dir = os.path.join(entrance_exam_dir, "grandparent", "parent")
......
...@@ -842,6 +842,9 @@ INSTALLED_APPS = ( ...@@ -842,6 +842,9 @@ INSTALLED_APPS = (
# Credentials support # Credentials support
'openedx.core.djangoapps.credentials', 'openedx.core.djangoapps.credentials',
# edx-milestones service
'milestones',
) )
...@@ -948,9 +951,6 @@ OPTIONAL_APPS = ( ...@@ -948,9 +951,6 @@ OPTIONAL_APPS = (
# edxval # edxval
'edxval', 'edxval',
# milestones
'milestones',
# Organizations App (http://github.com/edx/edx-organizations) # Organizations App (http://github.com/edx/edx-organizations)
'organizations', 'organizations',
) )
......
...@@ -147,6 +147,14 @@ define(["jquery", "underscore", "gettext", "js/views/baseview"], ...@@ -147,6 +147,14 @@ define(["jquery", "underscore", "gettext", "js/views/baseview"],
return this.getActionBar().find('.action-' + type); return this.getActionBar().find('.action-' + type);
}, },
enableActionButton: function(type) {
this.getActionBar().find('.action-' + type).prop('disabled', false).removeClass('is-disabled');
},
disableActionButton: function(type) {
this.getActionBar().find('.action-' + type).prop('disabled', true).addClass('is-disabled');
},
resize: function() { resize: function() {
var top, left, modalWindow, modalWidth, modalHeight, var top, left, modalWindow, modalWidth, modalHeight,
availableWidth, availableHeight, maxWidth, maxHeight; availableWidth, availableHeight, maxWidth, maxHeight;
......
...@@ -31,7 +31,8 @@ define(["jquery", "underscore", "gettext", "common/js/components/utils/view_util ...@@ -31,7 +31,8 @@ define(["jquery", "underscore", "gettext", "common/js/components/utils/view_util
ready: 'ready', ready: 'ready',
unscheduled: 'unscheduled', unscheduled: 'unscheduled',
needsAttention: 'needs_attention', needsAttention: 'needs_attention',
staffOnly: 'staff_only' staffOnly: 'staff_only',
gated: 'gated'
}; };
/** /**
...@@ -73,15 +74,7 @@ define(["jquery", "underscore", "gettext", "common/js/components/utils/view_util ...@@ -73,15 +74,7 @@ define(["jquery", "underscore", "gettext", "common/js/components/utils/view_util
deleteXBlock = function(xblockInfo, xblockType) { deleteXBlock = function(xblockInfo, xblockType) {
var deletion = $.Deferred(), var deletion = $.Deferred(),
url = ModuleUtils.getUpdateUrl(xblockInfo.id), url = ModuleUtils.getUpdateUrl(xblockInfo.id),
xblockType = xblockType || gettext('component'); operation = function() {
ViewUtils.confirmThenRunOperation(
interpolate(gettext('Delete this %(xblock_type)s?'), { xblock_type: xblockType }, true),
interpolate(
gettext('Deleting this %(xblock_type)s is permanent and cannot be undone.'),
{ xblock_type: xblockType }, true
),
interpolate(gettext('Yes, delete this %(xblock_type)s'), { xblock_type: xblockType }, true),
function() {
ViewUtils.runOperationShowingMessage(gettext('Deleting'), ViewUtils.runOperationShowingMessage(gettext('Deleting'),
function() { function() {
return $.ajax({ return $.ajax({
...@@ -90,8 +83,47 @@ define(["jquery", "underscore", "gettext", "common/js/components/utils/view_util ...@@ -90,8 +83,47 @@ define(["jquery", "underscore", "gettext", "common/js/components/utils/view_util
}).success(function() { }).success(function() {
deletion.resolve(); deletion.resolve();
}); });
}); }
}); );
},
messageBody = interpolate(
gettext('Deleting this %(xblock_type)s is permanent and cannot be undone.'),
{ xblock_type: xblockType },
true
);
xblockType = xblockType || 'component';
if (xblockInfo.get('is_prereq')) {
messageBody += ' ' + gettext('Any content that has listed this content as a prerequisite will also have access limitations removed.'); // jshint ignore:line
ViewUtils.confirmThenRunOperation(
interpolate(
gettext('Delete this %(xblock_type)s (and prerequisite)?'),
{ xblock_type: xblockType },
true
),
messageBody,
interpolate(
gettext('Yes, delete this %(xblock_type)s'),
{ xblock_type: xblockType },
true
),
operation
);
} else {
ViewUtils.confirmThenRunOperation(
interpolate(
gettext('Delete this %(xblock_type)s?'),
{ xblock_type: xblockType },
true
),
messageBody,
interpolate(
gettext('Yes, delete this %(xblock_type)s'),
{ xblock_type: xblockType },
true
),
operation
);
}
return deletion.promise(); return deletion.promise();
}; };
...@@ -141,6 +173,9 @@ define(["jquery", "underscore", "gettext", "common/js/components/utils/view_util ...@@ -141,6 +173,9 @@ define(["jquery", "underscore", "gettext", "common/js/components/utils/view_util
if (visibilityState === VisibilityState.staffOnly) { if (visibilityState === VisibilityState.staffOnly) {
return 'is-staff-only'; return 'is-staff-only';
} }
if (visibilityState === VisibilityState.gated) {
return 'is-gated';
}
if (visibilityState === VisibilityState.live) { if (visibilityState === VisibilityState.live) {
return 'is-live'; return 'is-live';
} }
......
...@@ -188,6 +188,7 @@ $color-ready: $green; ...@@ -188,6 +188,7 @@ $color-ready: $green;
$color-warning: $orange-l2; $color-warning: $orange-l2;
$color-error: $red-l2; $color-error: $red-l2;
$color-staff-only: $black; $color-staff-only: $black;
$color-gated: $black;
$color-visibility-set: $black; $color-visibility-set: $black;
$color-heading-base: $gray-d2; $color-heading-base: $gray-d2;
......
...@@ -99,16 +99,15 @@ ...@@ -99,16 +99,15 @@
&:last-child { &:last-child {
margin-bottom: 0; margin-bottom: 0;
} }
.settings-tab { .settings-tabs-header {
margin-bottom: $baseline; margin-bottom: $baseline;
border-bottom: 1px solid $gray-l3; border-bottom: 1px solid $gray-l3;
li.settings-section { li.settings-tab-buttons {
display: inline-block; display: inline-block;
margin-right: $baseline; margin-right: $baseline;
.general-settings-button, .settings-tab-button {
.advanced-settings-button {
@extend %t-copy-sub1; @extend %t-copy-sub1;
@extend %t-regular; @extend %t-regular;
background-image: none; background-image: none;
...@@ -561,7 +560,7 @@ ...@@ -561,7 +560,7 @@
.course-outline-modal { .course-outline-modal {
.exam-time-list-fields, .exam-time-list-fields,
.exam-review-rules-list-fields { .exam-review-rules-list-fields {
margin: 0 0 ($baseline/2) ($baseline/2); margin: 0 0 ($baseline/2) 0;
} }
.list-fields { .list-fields {
.field-message { .field-message {
...@@ -742,5 +741,12 @@ ...@@ -742,5 +741,12 @@
margin-bottom: 0; margin-bottom: 0;
} }
} }
// UI: Access settings section
.edit-settings-access {
.gating-prereq {
margin-bottom: 10px;
}
}
} }
} }
...@@ -339,6 +339,20 @@ $outline-indent-width: $baseline; ...@@ -339,6 +339,20 @@ $outline-indent-width: $baseline;
} }
} }
// CASE: has gated content
&.is-gated {
// needed to make sure direct children only
> .section-status,
> .subsection-status,
> .unit-status {
.status-message .icon {
color: $color-gated;
}
}
}
// CASE: has unpublished content // CASE: has unpublished content
&.has-warnings { &.has-warnings {
...@@ -421,6 +435,11 @@ $outline-indent-width: $baseline; ...@@ -421,6 +435,11 @@ $outline-indent-width: $baseline;
border-left-color: $color-staff-only; border-left-color: $color-staff-only;
} }
// CASE: has gated content
&.is-gated {
border-left-color: $color-gated;
}
// CASE: has unpublished content // CASE: has unpublished content
&.has-warnings { &.has-warnings {
border-left-color: $color-warning; border-left-color: $color-warning;
...@@ -505,6 +524,11 @@ $outline-indent-width: $baseline; ...@@ -505,6 +524,11 @@ $outline-indent-width: $baseline;
border-left-color: $color-staff-only; border-left-color: $color-staff-only;
} }
// CASE: is presented for gated
&.is-gated {
border-left-color: $color-gated;
}
// CASE: has unpublished content // CASE: has unpublished content
&.has-warnings { &.has-warnings {
border-left-color: $color-warning; border-left-color: $color-warning;
...@@ -697,4 +721,3 @@ $outline-indent-width: $baseline; ...@@ -697,4 +721,3 @@ $outline-indent-width: $baseline;
} }
} }
} }
...@@ -149,6 +149,11 @@ ...@@ -149,6 +149,11 @@
} }
} }
// CASE: content is gated
&.is-gated {
@extend %bar-module-black;
}
.bar-mod-content { .bar-mod-content {
border: 0; border: 0;
padding: ($baseline/2) ($baseline*0.75) ($baseline/4) ($baseline*0.75); padding: ($baseline/2) ($baseline*0.75) ($baseline/4) ($baseline*0.75);
......
...@@ -21,7 +21,7 @@ from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration ...@@ -21,7 +21,7 @@ from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration
<%block name="header_extras"> <%block name="header_extras">
<link rel="stylesheet" type="text/css" href="${static.url('js/vendor/timepicker/jquery.timepicker.css')}" /> <link rel="stylesheet" type="text/css" href="${static.url('js/vendor/timepicker/jquery.timepicker.css')}" />
% for template_name in ['course-outline', 'xblock-string-field-editor', 'basic-modal', 'modal-button', 'course-outline-modal', 'due-date-editor', 'release-date-editor', 'grading-editor', 'publish-editor', 'staff-lock-editor', 'verification-access-editor', 'timed-examination-preference-editor', 'settings-tab-section']: % for template_name in ['course-outline', 'xblock-string-field-editor', 'basic-modal', 'modal-button', 'course-outline-modal', 'due-date-editor', 'release-date-editor', 'grading-editor', 'publish-editor', 'staff-lock-editor', 'verification-access-editor', 'timed-examination-preference-editor', 'access-editor', 'settings-modal-tabs']:
<script type="text/template" id="${template_name}-tpl"> <script type="text/template" id="${template_name}-tpl">
<%static:include path="js/${template_name}.underscore" /> <%static:include path="js/${template_name}.underscore" />
</script> </script>
......
<form>
<% if (xblockInfo.get('prereqs').length > 0) { %>
<h3 class="modal-section-title"><%- gettext('Limit Access') %></h3>
<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.') %>
</p>
<li class="field field-select">
<label class="label">
<%- gettext('Prerequisite:') %>
<select id="prereq" class="input">
<option value=""><%- gettext('No prerequisite') %></option>
<% _.each(xblockInfo.get('prereqs'), function(prereq){ %>
<option value="<%- prereq.block_usage_key %>"><%- prereq.block_display_name %></option>
<% }); %>
</select>
</label>
</li>
<li id="prereq_min_score_input" class="field field-input input-cosmetic">
<label class="label">
<%- gettext('Minimum Score:') %>
<input type="text" id="prereq_min_score" name="prereq_min_score" class="input input-text" size="3" />
</label>
</li>
<div id="prereq_min_score_error" class="message-status error">
<%- gettext('Minimum score must be an integer between 0 and 100.') %>
</div>
</ul>
</div>
<% } %>
<h3 class="modal-section-title"><%- gettext('Use this as a Prerequisite') %></h3>
<div class="modal-section-content gating-is-prereq">
<ul class="list-fields list-input">
<li class="field field-checkbox checkbox-cosmetic">
<input type="checkbox" id="is_prereq" name="is_prereq" class="input input-checkbox" />
<label for="is_prereq" class="label">
<i class="icon fa fa-check-square-o input-checkbox-checked"></i>
<i class="icon fa fa-square-o input-checkbox-unchecked"></i>
<%- gettext('Yes, set this as a prerequisite which can be used to limit access to other content') %>
</label>
</li>
</ul>
</div>
</form>
<%
var enable_proctored_exams = enable_proctored_exams;
var enable_timed_exams = enable_timed_exams;
%>
<div class="xblock-editor" data-locator="<%= xblockInfo.get('id') %>" data-course-key="<%= xblockInfo.get('courseKey') %>"> <div class="xblock-editor" data-locator="<%= xblockInfo.get('id') %>" data-course-key="<%= xblockInfo.get('courseKey') %>">
<% if (!( enable_proctored_exams || enable_timed_exams )) { %> <div class="message modal-introduction">
<div class="message modal-introduction"> <p><%- introductionMessage %></p>
<p><%- introductionMessage %></p> </div>
</div>
<% } %>
<div class="modal-section"></div> <div class="modal-section"></div>
</div> </div>
...@@ -2,9 +2,25 @@ ...@@ -2,9 +2,25 @@
var releasedToStudents = xblockInfo.get('released_to_students'); var releasedToStudents = xblockInfo.get('released_to_students');
var visibilityState = xblockInfo.get('visibility_state'); var visibilityState = xblockInfo.get('visibility_state');
var published = xblockInfo.get('published'); var published = xblockInfo.get('published');
var prereq = xblockInfo.get('prereq');
var statusMessage = null; var statusMessage = null;
var statusType = null; var statusType = null;
if (prereq) {
var prereqDisplayName = '';
_.each(xblockInfo.get('prereqs'), function (p) {
if (p.block_usage_key == prereq) {
prereqDisplayName = p.block_display_name;
return false;
}
});
statusType = 'gated';
statusMessage = interpolate(
gettext('Prerequisite: %(prereq_display_name)s'),
{prereq_display_name: prereqDisplayName},
true
);
}
if (staffOnlyMessage) { if (staffOnlyMessage) {
statusType = 'staff-only'; statusType = 'staff-only';
statusMessage = gettext('Contains staff only content'); statusMessage = gettext('Contains staff only content');
...@@ -28,6 +44,8 @@ if (statusType === 'warning') { ...@@ -28,6 +44,8 @@ if (statusType === 'warning') {
statusIconClass = 'fa-warning'; statusIconClass = 'fa-warning';
} else if (statusType === 'staff-only') { } else if (statusType === 'staff-only') {
statusIconClass = 'fa-lock'; statusIconClass = 'fa-lock';
} else if (statusType === 'gated') {
statusIconClass = 'fa-lock';
} }
var gradingType = gettext('Ungraded'); var gradingType = gettext('Ungraded');
......
<ul class="settings-tabs-header">
<% _.each(tabs, function(tab) { %>
<li class="settings-tab-buttons">
<button class="settings-tab-button" data-tab="<%- tab.name %>" href="#"><%- tab.displayName %></button>
</li>
<% }); %>
</ul>
<% _.each(tabs, function(tab) { %>
<div class='settings-tab <%- tab.name %>'></div>
<% }); %>
<ul class="settings-tab">
<li class="settings-section">
<button class="general-settings-button" href="#"><%- gettext('General Settings') %></button>
</li>
<li class="settings-section">
<button class="advanced-settings-button" href="#"><%- gettext('Advanced') %></button>
</li>
</ul>
<div class='general-settings'></div>
<div class='advanced-settings'></div>
<form> <form>
<h3 class="modal-section-title"><%= gettext('Set as a Special Exam') %></h3>
<div class="modal-section-content has-actions"> <div class="modal-section-content has-actions">
<div class='exam-time-list-fields'> <div class='exam-time-list-fields'>
<ul class="list-fields list-input"> <ul class="list-fields list-input">
......
...@@ -21,11 +21,11 @@ from xmodule.modulestore.tests.factories import CourseFactory ...@@ -21,11 +21,11 @@ from xmodule.modulestore.tests.factories import CourseFactory
from util.milestones_helpers import ( from util.milestones_helpers import (
get_pre_requisite_courses_not_completed, get_pre_requisite_courses_not_completed,
set_prerequisite_courses, set_prerequisite_courses,
seed_milestone_relationship_types
) )
from milestones.tests.utils import MilestonesTestCaseMixin
class TestCourseListing(ModuleStoreTestCase): class TestCourseListing(ModuleStoreTestCase, MilestonesTestCaseMixin):
""" """
Unit tests for getting the list of courses for a logged in user Unit tests for getting the list of courses for a logged in user
""" """
...@@ -126,7 +126,6 @@ class TestCourseListing(ModuleStoreTestCase): ...@@ -126,7 +126,6 @@ class TestCourseListing(ModuleStoreTestCase):
Sets two of them as pre-requisites of another course. Sets two of them as pre-requisites of another course.
Checks course where pre-requisite course is set has appropriate info. Checks course where pre-requisite course is set has appropriate info.
""" """
seed_milestone_relationship_types()
course_location2 = self.store.make_course_key('Org1', 'Course2', 'Run2') course_location2 = self.store.make_course_key('Org1', 'Course2', 'Run2')
self._create_course_with_access_groups(course_location2) self._create_course_with_access_groups(course_location2)
pre_requisite_course_location = self.store.make_course_key('Org1', 'Course3', 'Run3') pre_requisite_course_location = self.store.make_course_key('Org1', 'Course3', 'Run3')
......
...@@ -3,7 +3,7 @@ Utility library containing operations used/shared by multiple courseware modules ...@@ -3,7 +3,7 @@ Utility library containing operations used/shared by multiple courseware modules
""" """
def yield_dynamic_descriptor_descendants(descriptor, user_id, module_creator): # pylint: disable=invalid-name def yield_dynamic_descriptor_descendants(descriptor, user_id, module_creator=None): # pylint: disable=invalid-name
""" """
This returns all of the descendants of a descriptor. If the descriptor This returns all of the descendants of a descriptor. If the descriptor
has dynamic children, the module will be created using module_creator has dynamic children, the module will be created using module_creator
...@@ -23,12 +23,13 @@ def get_dynamic_descriptor_children(descriptor, user_id, module_creator=None, us ...@@ -23,12 +23,13 @@ def get_dynamic_descriptor_children(descriptor, user_id, module_creator=None, us
""" """
module_children = [] module_children = []
if descriptor.has_dynamic_children(): if descriptor.has_dynamic_children():
# do not rebind the module if it's already bound to a user. module = None
if descriptor.scope_ids.user_id and user_id == descriptor.scope_ids.user_id: if descriptor.scope_ids.user_id and user_id == descriptor.scope_ids.user_id:
# do not rebind the module if it's already bound to a user.
module = descriptor module = descriptor
else: elif module_creator:
module = module_creator(descriptor) module = module_creator(descriptor)
if module is not None: if module:
module_children = module.get_child_descriptors() module_children = module.get_child_descriptors()
else: else:
module_children = descriptor.get_children(usage_key_filter) module_children = descriptor.get_children(usage_key_filter)
......
""" """
Django module container for classes and operations related to the "Course Module" content type Django module container for classes and operations related to the "Course Module" content type
""" """
import json
import logging import logging
from cStringIO import StringIO from cStringIO import StringIO
from lxml import etree
from path import Path as path
import requests
from datetime import datetime from datetime import datetime
import requests
from django.utils.timezone import UTC
from lazy import lazy from lazy import lazy
from lxml import etree
from path import Path as path
from xblock.core import XBlock
from xblock.fields import Scope, List, String, Dict, Boolean, Integer, Float
from openedx.core.lib.gating import api as gating_api
from xmodule import course_metadata_utils from xmodule import course_metadata_utils
from xmodule.course_metadata_utils import DEFAULT_START_DATE from xmodule.course_metadata_utils import DEFAULT_START_DATE
from xmodule.exceptions import UndefinedContext from xmodule.exceptions import UndefinedContext
from xmodule.seq_module import SequenceDescriptor, SequenceModule
from xmodule.graders import grader_from_conf from xmodule.graders import grader_from_conf
from xmodule.tabs import CourseTabList, InvalidTabsException
from xmodule.mixin import LicenseMixin from xmodule.mixin import LicenseMixin
import json from xmodule.seq_module import SequenceDescriptor, SequenceModule
from xmodule.tabs import CourseTabList, InvalidTabsException
from xblock.core import XBlock
from xblock.fields import Scope, List, String, Dict, Boolean, Integer, Float
from .fields import Date from .fields import Date
from django.utils.timezone import UTC
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -756,6 +756,15 @@ class CourseFields(object): ...@@ -756,6 +756,15 @@ class CourseFields(object):
scope=Scope.settings scope=Scope.settings
) )
enable_subsection_gating = Boolean(
display_name=_("Enable Subsection Gating"),
help=_(
"Enter true or false. If this value is true, subsection gating is enabled in your course."
),
default=False,
scope=Scope.settings
)
class CourseModule(CourseFields, SequenceModule): # pylint: disable=abstract-method class CourseModule(CourseFields, SequenceModule): # pylint: disable=abstract-method
""" """
...@@ -778,6 +787,8 @@ class CourseDescriptor(CourseFields, SequenceDescriptor, LicenseMixin): ...@@ -778,6 +787,8 @@ class CourseDescriptor(CourseFields, SequenceDescriptor, LicenseMixin):
super(CourseDescriptor, self).__init__(*args, **kwargs) super(CourseDescriptor, self).__init__(*args, **kwargs)
_ = self.runtime.service(self, "i18n").ugettext _ = self.runtime.service(self, "i18n").ugettext
self._gating_prerequisites = None
if self.wiki_slug is None: if self.wiki_slug is None:
self.wiki_slug = self.location.course self.wiki_slug = self.location.course
...@@ -1384,6 +1395,18 @@ class CourseDescriptor(CourseFields, SequenceDescriptor, LicenseMixin): ...@@ -1384,6 +1395,18 @@ class CourseDescriptor(CourseFields, SequenceDescriptor, LicenseMixin):
""" """
return datetime.now(UTC()) <= self.start return datetime.now(UTC()) <= self.start
@property
def gating_prerequisites(self):
"""
Course content that can be used to gate other course content within this course.
Returns:
list: Returns a list of dicts containing the gating milestone data
"""
if not self._gating_prerequisites:
self._gating_prerequisites = gating_api.get_prerequisites(self.id)
return self._gating_prerequisites
class CourseSummary(object): class CourseSummary(object):
""" """
......
...@@ -1375,6 +1375,13 @@ class ModuleStoreWriteBase(ModuleStoreReadBase, ModuleStoreWrite): ...@@ -1375,6 +1375,13 @@ class ModuleStoreWriteBase(ModuleStoreReadBase, ModuleStoreWrite):
if self.signal_handler: if self.signal_handler:
self.signal_handler.send("course_deleted", course_key=course_key) self.signal_handler.send("course_deleted", course_key=course_key)
def _emit_item_deleted_signal(self, usage_key, user_id):
"""
Helper method used to emit the item_deleted signal.
"""
if self.signal_handler:
self.signal_handler.send("item_deleted", usage_key=usage_key, user_id=user_id)
def only_xmodules(identifier, entry_points): def only_xmodules(identifier, entry_points):
"""Only use entry_points that are supplied by the xmodule package""" """Only use entry_points that are supplied by the xmodule package"""
......
...@@ -90,12 +90,14 @@ class SignalHandler(object): ...@@ -90,12 +90,14 @@ class SignalHandler(object):
course_published = django.dispatch.Signal(providing_args=["course_key"]) course_published = django.dispatch.Signal(providing_args=["course_key"])
course_deleted = django.dispatch.Signal(providing_args=["course_key"]) course_deleted = django.dispatch.Signal(providing_args=["course_key"])
library_updated = django.dispatch.Signal(providing_args=["library_key"]) library_updated = django.dispatch.Signal(providing_args=["library_key"])
item_deleted = django.dispatch.Signal(providing_args=["usage_key", "user_id"])
_mapping = { _mapping = {
"pre_publish": pre_publish, "pre_publish": pre_publish,
"course_published": course_published, "course_published": course_published,
"course_deleted": course_deleted, "course_deleted": course_deleted,
"library_updated": library_updated, "library_updated": library_updated,
"item_deleted": item_deleted,
} }
def __init__(self, modulestore_class): def __init__(self, modulestore_class):
......
...@@ -1212,7 +1212,10 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase, Mongo ...@@ -1212,7 +1212,10 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase, Mongo
query['_id.revision'] = key_revision query['_id.revision'] = key_revision
for field in ['category', 'name']: for field in ['category', 'name']:
if field in qualifiers: if field in qualifiers:
query['_id.' + field] = qualifiers.pop(field) qualifier_value = qualifiers.pop(field)
if isinstance(qualifier_value, list):
qualifier_value = {'$in': qualifier_value}
query['_id.' + field] = qualifier_value
for key, value in (settings or {}).iteritems(): for key, value in (settings or {}).iteritems():
query['metadata.' + key] = value query['metadata.' + key] = value
......
...@@ -1190,7 +1190,9 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase): ...@@ -1190,7 +1190,9 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase):
block_name = qualifiers.pop('name') block_name = qualifiers.pop('name')
block_ids = [] block_ids = []
for block_id, block in course.structure['blocks'].iteritems(): for block_id, block in course.structure['blocks'].iteritems():
if block_name == block_id.id and _block_matches_all(block): # Do an in comparison on the name qualifier
# so that a list can be used to filter on block_id
if block_id.id in block_name and _block_matches_all(block):
block_ids.append(block_id) block_ids.append(block_id)
return self._load_items(course, block_ids, **kwargs) return self._load_items(course, block_ids, **kwargs)
...@@ -2587,6 +2589,8 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase): ...@@ -2587,6 +2589,8 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase):
if isinstance(usage_locator.course_key, LibraryLocator): if isinstance(usage_locator.course_key, LibraryLocator):
self._flag_library_updated_event(usage_locator.course_key) self._flag_library_updated_event(usage_locator.course_key)
self._emit_item_deleted_signal(usage_locator, user_id)
return result return result
@contract(root_block_key=BlockKey, blocks='dict(BlockKey: BlockData)') @contract(root_block_key=BlockKey, blocks='dict(BlockKey: BlockData)')
......
...@@ -1198,6 +1198,10 @@ class SplitModuleItemTests(SplitModuleTest): ...@@ -1198,6 +1198,10 @@ class SplitModuleItemTests(SplitModuleTest):
self.assertEqual(len(matches), 3) self.assertEqual(len(matches), 3)
matches = modulestore().get_items(locator, qualifiers={'category': 'garbage'}) matches = modulestore().get_items(locator, qualifiers={'category': 'garbage'})
self.assertEqual(len(matches), 0) self.assertEqual(len(matches), 0)
matches = modulestore().get_items(locator, qualifiers={'name': 'chapter1'})
self.assertEqual(len(matches), 1)
matches = modulestore().get_items(locator, qualifiers={'name': ['chapter1', 'chapter2']})
self.assertEqual(len(matches), 2)
matches = modulestore().get_items( matches = modulestore().get_items(
locator, locator,
qualifiers={'category': 'chapter'}, qualifiers={'category': 'chapter'},
......
...@@ -799,8 +799,13 @@ class XMLModuleStore(ModuleStoreReadBase): ...@@ -799,8 +799,13 @@ class XMLModuleStore(ModuleStoreReadBase):
def _block_matches_all(mod_loc, module): def _block_matches_all(mod_loc, module):
if category and mod_loc.category != category: if category and mod_loc.category != category:
return False return False
if name and mod_loc.name != name: if name:
return False if isinstance(name, list):
# Support for passing a list as the name qualifier
if mod_loc.name not in name:
return False
elif mod_loc.name != name:
return False
return all( return all(
self._block_matches(module, fields or {}) self._block_matches(module, fields or {})
for fields in [settings, content, qualifiers] for fields in [settings, content, qualifiers]
......
...@@ -548,11 +548,12 @@ class CourseOutlinePage(CoursePage, CourseOutlineContainer): ...@@ -548,11 +548,12 @@ class CourseOutlinePage(CoursePage, CourseOutlineContainer):
""" """
self.reindex_button.click() self.reindex_button.click()
def open_exam_settings_dialog(self): def open_subsection_settings_dialog(self, index=0):
""" """
clicks on the settings button of subsection. clicks on the settings button of subsection.
""" """
self.q(css=".subsection-header-actions .configure-button").first.click() self.q(css=".subsection-header-actions .configure-button").nth(index).click()
self.wait_for_element_presence('.course-outline-modal', 'Subsection settings modal is present.')
def change_problem_release_date_in_studio(self): def change_problem_release_date_in_studio(self):
""" """
...@@ -563,12 +564,12 @@ class CourseOutlinePage(CoursePage, CourseOutlineContainer): ...@@ -563,12 +564,12 @@ class CourseOutlinePage(CoursePage, CourseOutlineContainer):
self.q(css=".action-save").first.click() self.q(css=".action-save").first.click()
self.wait_for_ajax() self.wait_for_ajax()
def select_advanced_settings_tab(self): def select_advanced_tab(self):
""" """
Select the advanced settings tab Select the advanced settings tab
""" """
self.q(css=".advanced-settings-button").first.click() self.q(css=".settings-tab-button[data-tab='advanced']").first.click()
self.wait_for_element_presence('#id_not_timed', 'Advanced settings fields not present.') self.wait_for_element_presence('#id_not_timed', 'Special exam settings fields not present.')
def make_exam_proctored(self): def make_exam_proctored(self):
""" """
...@@ -645,6 +646,63 @@ class CourseOutlinePage(CoursePage, CourseOutlineContainer): ...@@ -645,6 +646,63 @@ class CourseOutlinePage(CoursePage, CourseOutlineContainer):
return True return True
def select_access_tab(self):
"""
Select the access settings tab.
"""
self.q(css=".settings-tab-button[data-tab='access']").first.click()
self.wait_for_element_visibility('#is_prereq', 'Gating settings fields are present.')
def make_gating_prerequisite(self):
"""
Makes a subsection a gating prerequisite.
"""
if not self.q(css="#is_prereq")[0].is_selected():
self.q(css='label[for="is_prereq"]').click()
self.q(css=".action-save").first.click()
self.wait_for_ajax()
def add_prerequisite_to_subsection(self, min_score):
"""
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=".action-save").first.click()
self.wait_for_ajax()
def gating_prerequisite_checkbox_is_visible(self):
"""
Returns True if the gating prerequisite checkbox is visible.
"""
# The Prerequisite checkbox is visible
return self.q(css="#is_prereq").visible
def gating_prerequisite_checkbox_is_checked(self):
"""
Returns True if the gating prerequisite checkbox is checked.
"""
# The Prerequisite checkbox is checked
return self.q(css="#is_prereq:checked").present
def gating_prerequisites_dropdown_is_visible(self):
"""
Returns True if the gating prerequisites dropdown is visible.
"""
# The Prerequisites dropdown is visible
return self.q(css="#prereq").visible
def gating_prerequisite_min_score_is_visible(self):
"""
Returns True if the gating prerequisite minimum score input is visible.
"""
# The Prerequisites dropdown is visible
return self.q(css="#prereq_min_score").visible
@property @property
def bottom_add_section_button(self): def bottom_add_section_button(self):
""" """
......
...@@ -218,4 +218,5 @@ class AdvancedSettingsPage(CoursePage): ...@@ -218,4 +218,5 @@ class AdvancedSettingsPage(CoursePage):
'cert_html_view_enabled', 'cert_html_view_enabled',
'enable_proctored_exams', 'enable_proctored_exams',
'enable_timed_exams', 'enable_timed_exams',
'enable_subsection_gating',
] ]
...@@ -217,7 +217,7 @@ class ProctoredExamTest(UniqueCourseTest): ...@@ -217,7 +217,7 @@ class ProctoredExamTest(UniqueCourseTest):
self._auto_auth("STAFF_TESTER", "staff101@example.com", True) self._auto_auth("STAFF_TESTER", "staff101@example.com", True)
self.course_outline.visit() self.course_outline.visit()
self.course_outline.open_exam_settings_dialog() self.course_outline.open_subsection_settings_dialog()
self.assertTrue(self.course_outline.proctoring_items_are_displayed()) self.assertTrue(self.course_outline.proctoring_items_are_displayed())
def test_proctored_exam_flow(self): def test_proctored_exam_flow(self):
...@@ -232,9 +232,9 @@ class ProctoredExamTest(UniqueCourseTest): ...@@ -232,9 +232,9 @@ class ProctoredExamTest(UniqueCourseTest):
LogoutPage(self.browser).visit() LogoutPage(self.browser).visit()
self._auto_auth("STAFF_TESTER", "staff101@example.com", True) self._auto_auth("STAFF_TESTER", "staff101@example.com", True)
self.course_outline.visit() self.course_outline.visit()
self.course_outline.open_exam_settings_dialog() self.course_outline.open_subsection_settings_dialog()
self.course_outline.select_advanced_settings_tab() self.course_outline.select_advanced_tab()
self.course_outline.make_exam_proctored() self.course_outline.make_exam_proctored()
LogoutPage(self.browser).visit() LogoutPage(self.browser).visit()
...@@ -256,9 +256,9 @@ class ProctoredExamTest(UniqueCourseTest): ...@@ -256,9 +256,9 @@ class ProctoredExamTest(UniqueCourseTest):
LogoutPage(self.browser).visit() LogoutPage(self.browser).visit()
self._auto_auth("STAFF_TESTER", "staff101@example.com", True) self._auto_auth("STAFF_TESTER", "staff101@example.com", True)
self.course_outline.visit() self.course_outline.visit()
self.course_outline.open_exam_settings_dialog() self.course_outline.open_subsection_settings_dialog()
self.course_outline.select_advanced_settings_tab() self.course_outline.select_advanced_tab()
self.course_outline.make_exam_timed() self.course_outline.make_exam_timed()
LogoutPage(self.browser).visit() LogoutPage(self.browser).visit()
...@@ -281,8 +281,8 @@ class ProctoredExamTest(UniqueCourseTest): ...@@ -281,8 +281,8 @@ class ProctoredExamTest(UniqueCourseTest):
self._auto_auth("STAFF_TESTER", "staff101@example.com", True) self._auto_auth("STAFF_TESTER", "staff101@example.com", True)
self.course_outline.visit() self.course_outline.visit()
self.course_outline.open_exam_settings_dialog() self.course_outline.open_subsection_settings_dialog()
self.course_outline.select_advanced_settings_tab() self.course_outline.select_advanced_tab()
self.course_outline.select_none_exam() self.course_outline.select_none_exam()
self.assertFalse(self.course_outline.time_allotted_field_visible()) self.assertFalse(self.course_outline.time_allotted_field_visible())
...@@ -300,8 +300,8 @@ class ProctoredExamTest(UniqueCourseTest): ...@@ -300,8 +300,8 @@ class ProctoredExamTest(UniqueCourseTest):
self._auto_auth("STAFF_TESTER", "staff101@example.com", True) self._auto_auth("STAFF_TESTER", "staff101@example.com", True)
self.course_outline.visit() self.course_outline.visit()
self.course_outline.open_exam_settings_dialog() self.course_outline.open_subsection_settings_dialog()
self.course_outline.select_advanced_settings_tab() self.course_outline.select_advanced_tab()
self.course_outline.select_timed_exam() self.course_outline.select_timed_exam()
self.assertTrue(self.course_outline.time_allotted_field_visible()) self.assertTrue(self.course_outline.time_allotted_field_visible())
...@@ -319,8 +319,8 @@ class ProctoredExamTest(UniqueCourseTest): ...@@ -319,8 +319,8 @@ class ProctoredExamTest(UniqueCourseTest):
self._auto_auth("STAFF_TESTER", "staff101@example.com", True) self._auto_auth("STAFF_TESTER", "staff101@example.com", True)
self.course_outline.visit() self.course_outline.visit()
self.course_outline.open_exam_settings_dialog() self.course_outline.open_subsection_settings_dialog()
self.course_outline.select_advanced_settings_tab() self.course_outline.select_advanced_tab()
self.course_outline.select_proctored_exam() self.course_outline.select_proctored_exam()
self.assertTrue(self.course_outline.time_allotted_field_visible()) self.assertTrue(self.course_outline.time_allotted_field_visible())
...@@ -338,8 +338,8 @@ class ProctoredExamTest(UniqueCourseTest): ...@@ -338,8 +338,8 @@ class ProctoredExamTest(UniqueCourseTest):
self._auto_auth("STAFF_TESTER", "staff101@example.com", True) self._auto_auth("STAFF_TESTER", "staff101@example.com", True)
self.course_outline.visit() self.course_outline.visit()
self.course_outline.open_exam_settings_dialog() self.course_outline.open_subsection_settings_dialog()
self.course_outline.select_advanced_settings_tab() self.course_outline.select_advanced_tab()
self.course_outline.select_proctored_exam() self.course_outline.select_proctored_exam()
self.assertTrue(self.course_outline.exam_review_rules_field_visible()) self.assertTrue(self.course_outline.exam_review_rules_field_visible())
...@@ -361,8 +361,8 @@ class ProctoredExamTest(UniqueCourseTest): ...@@ -361,8 +361,8 @@ class ProctoredExamTest(UniqueCourseTest):
self._auto_auth("STAFF_TESTER", "staff101@example.com", True) self._auto_auth("STAFF_TESTER", "staff101@example.com", True)
self.course_outline.visit() self.course_outline.visit()
self.course_outline.open_exam_settings_dialog() self.course_outline.open_subsection_settings_dialog()
self.course_outline.select_advanced_settings_tab() self.course_outline.select_advanced_tab()
self.course_outline.select_timed_exam() self.course_outline.select_timed_exam()
self.assertFalse(self.course_outline.exam_review_rules_field_visible()) self.assertFalse(self.course_outline.exam_review_rules_field_visible())
...@@ -386,8 +386,8 @@ class ProctoredExamTest(UniqueCourseTest): ...@@ -386,8 +386,8 @@ class ProctoredExamTest(UniqueCourseTest):
self._auto_auth("STAFF_TESTER", "staff101@example.com", True) self._auto_auth("STAFF_TESTER", "staff101@example.com", True)
self.course_outline.visit() self.course_outline.visit()
self.course_outline.open_exam_settings_dialog() self.course_outline.open_subsection_settings_dialog()
self.course_outline.select_advanced_settings_tab() self.course_outline.select_advanced_tab()
self.course_outline.select_practice_exam() self.course_outline.select_practice_exam()
self.assertTrue(self.course_outline.time_allotted_field_visible()) self.assertTrue(self.course_outline.time_allotted_field_visible())
......
# -*- coding: utf-8 -*-
"""
End-to-end tests for the gating feature.
"""
from textwrap import dedent
from ..helpers import UniqueCourseTest
from ...pages.studio.auto_auth import AutoAuthPage
from ...pages.studio.overview import CourseOutlinePage
from ...pages.lms.courseware import CoursewarePage
from ...pages.lms.problem import ProblemPage
from ...pages.common.logout import LogoutPage
from ...fixtures.course import CourseFixture, XBlockFixtureDesc
class GatingTest(UniqueCourseTest):
"""
Test gating feature in LMS.
"""
USERNAME = "STUDENT_TESTER"
EMAIL = "student101@example.com"
def setUp(self):
super(GatingTest, self).setUp()
self.logout_page = LogoutPage(self.browser)
self.courseware_page = CoursewarePage(self.browser, self.course_id)
self.course_outline = CourseOutlinePage(
self.browser,
self.course_info['org'],
self.course_info['number'],
self.course_info['run']
)
xml = dedent("""
<problem>
<p>What is height of eiffel tower without the antenna?.</p>
<multiplechoiceresponse>
<choicegroup label="What is height of eiffel tower without the antenna?" type="MultipleChoice">
<choice correct="false">324 meters<choicehint>Antenna is 24 meters high</choicehint></choice>
<choice correct="true">300 meters</choice>
<choice correct="false">224 meters</choice>
<choice correct="false">400 meters</choice>
</choicegroup>
</multiplechoiceresponse>
</problem>
""")
self.problem1 = XBlockFixtureDesc('problem', 'HEIGHT OF EIFFEL TOWER', data=xml)
# Install a course with sections/problems
course_fixture = CourseFixture(
self.course_info['org'],
self.course_info['number'],
self.course_info['run'],
self.course_info['display_name']
)
course_fixture.add_advanced_settings({
"enable_subsection_gating": {"value": "true"}
})
course_fixture.add_children(
XBlockFixtureDesc('chapter', 'Test Section 1').add_children(
XBlockFixtureDesc('sequential', 'Test Subsection 1').add_children(
self.problem1
),
XBlockFixtureDesc('sequential', 'Test Subsection 2').add_children(
XBlockFixtureDesc('problem', 'Test Problem 2')
)
)
).install()
def _auto_auth(self, username, email, staff):
"""
Logout and login with given credentials.
"""
self.logout_page.visit()
AutoAuthPage(self.browser, username=username, email=email,
course_id=self.course_id, staff=staff).visit()
def _setup_prereq(self):
"""
Make the first subsection a prerequisite
"""
# Login as staff
self._auto_auth("STAFF_TESTER", "staff101@example.com", True)
# Make the first subsection a prerequisite
self.course_outline.visit()
self.course_outline.open_subsection_settings_dialog(0)
self.course_outline.select_access_tab()
self.course_outline.make_gating_prerequisite()
def _setup_gated_subsection(self):
"""
Gate the second subsection on the first subsection
"""
# Login as staff
self._auto_auth("STAFF_TESTER", "staff101@example.com", True)
# Gate the second subsection based on the score achieved in the first subsection
self.course_outline.visit()
self.course_outline.open_subsection_settings_dialog(1)
self.course_outline.select_access_tab()
self.course_outline.add_prerequisite_to_subsection("80")
def test_subsection_gating_in_studio(self):
"""
Given that I am a staff member
When I visit the course outline page in studio.
And open the subsection edit dialog
Then I can view all settings related to Gating
And update those settings to gate a subsection
"""
self._setup_prereq()
# Assert settings are displayed correctly for a prerequisite subsection
self.course_outline.visit()
self.course_outline.open_subsection_settings_dialog(0)
self.course_outline.select_access_tab()
self.assertTrue(self.course_outline.gating_prerequisite_checkbox_is_visible())
self.assertTrue(self.course_outline.gating_prerequisite_checkbox_is_checked())
self.assertFalse(self.course_outline.gating_prerequisites_dropdown_is_visible())
self.assertFalse(self.course_outline.gating_prerequisite_min_score_is_visible())
self._setup_gated_subsection()
# Assert settings are displayed correctly for a gated subsection
self.course_outline.visit()
self.course_outline.open_subsection_settings_dialog(1)
self.course_outline.select_access_tab()
self.assertTrue(self.course_outline.gating_prerequisite_checkbox_is_visible())
self.assertTrue(self.course_outline.gating_prerequisites_dropdown_is_visible())
self.assertTrue(self.course_outline.gating_prerequisite_min_score_is_visible())
def test_gated_subsection_in_lms(self):
"""
Given that I am a student
When I visit the LMS Courseware
Then I cannot see a gated subsection
When I fulfill the gating Prerequisite
Then I can see the gated subsection
"""
self._setup_prereq()
self._setup_gated_subsection()
self._auto_auth(self.USERNAME, self.EMAIL, False)
self.courseware_page.visit()
self.assertEqual(self.courseware_page.num_subsections, 1)
# Fulfill prerequisite and verify that gated subsection is shown
problem_page = ProblemPage(self.browser)
self.assertEqual(problem_page.wait_for_page().problem_name, 'HEIGHT OF EIFFEL TOWER')
problem_page.click_choice('choice_1')
problem_page.click_check()
self.courseware_page.visit()
self.assertEqual(self.courseware_page.num_subsections, 2)
...@@ -210,10 +210,10 @@ class ProctoredExamsTest(BaseInstructorDashboardTest): ...@@ -210,10 +210,10 @@ class ProctoredExamsTest(BaseInstructorDashboardTest):
self.course_outline.visit() self.course_outline.visit()
# open the exam settings to make it a proctored exam. # open the exam settings to make it a proctored exam.
self.course_outline.open_exam_settings_dialog() self.course_outline.open_subsection_settings_dialog()
# select advanced settings tab # select advanced settings tab
self.course_outline.select_advanced_settings_tab() self.course_outline.select_advanced_tab()
self.course_outline.make_exam_proctored() self.course_outline.make_exam_proctored()
...@@ -236,10 +236,10 @@ class ProctoredExamsTest(BaseInstructorDashboardTest): ...@@ -236,10 +236,10 @@ class ProctoredExamsTest(BaseInstructorDashboardTest):
self.course_outline.visit() self.course_outline.visit()
# open the exam settings to make it a proctored exam. # open the exam settings to make it a proctored exam.
self.course_outline.open_exam_settings_dialog() self.course_outline.open_subsection_settings_dialog()
# select advanced settings tab # select advanced settings tab
self.course_outline.select_advanced_settings_tab() self.course_outline.select_advanced_tab()
self.course_outline.make_exam_timed() self.course_outline.make_exam_timed()
......
...@@ -24,10 +24,8 @@ from xmodule.modulestore.tests.factories import CourseFactory ...@@ -24,10 +24,8 @@ from xmodule.modulestore.tests.factories import CourseFactory
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from courseware.tests.helpers import LoginEnrollmentTestCase from courseware.tests.helpers import LoginEnrollmentTestCase
from util.milestones_helpers import ( from util.milestones_helpers import set_prerequisite_courses
seed_milestone_relationship_types, from milestones.tests.utils import MilestonesTestCaseMixin
set_prerequisite_courses,
)
FEATURES_WITH_STARTDATE = settings.FEATURES.copy() FEATURES_WITH_STARTDATE = settings.FEATURES.copy()
FEATURES_WITH_STARTDATE['DISABLE_START_DATES'] = False FEATURES_WITH_STARTDATE['DISABLE_START_DATES'] = False
...@@ -119,18 +117,12 @@ class AnonymousIndexPageTest(ModuleStoreTestCase): ...@@ -119,18 +117,12 @@ class AnonymousIndexPageTest(ModuleStoreTestCase):
@attr('shard_1') @attr('shard_1')
class PreRequisiteCourseCatalog(ModuleStoreTestCase, LoginEnrollmentTestCase): class PreRequisiteCourseCatalog(ModuleStoreTestCase, LoginEnrollmentTestCase, MilestonesTestCaseMixin):
""" """
Test to simulate and verify fix for disappearing courses in Test to simulate and verify fix for disappearing courses in
course catalog when using pre-requisite courses course catalog when using pre-requisite courses
""" """
@patch.dict(settings.FEATURES, {'ENABLE_PREREQUISITE_COURSES': True, 'MILESTONES_APP': True}) @patch.dict(settings.FEATURES, {'ENABLE_PREREQUISITE_COURSES': True, 'MILESTONES_APP': True})
def setUp(self):
super(PreRequisiteCourseCatalog, self).setUp()
seed_milestone_relationship_types()
@patch.dict(settings.FEATURES, {'ENABLE_PREREQUISITE_COURSES': True, 'MILESTONES_APP': True})
def test_course_with_prereq(self): def test_course_with_prereq(self):
""" """
Simulate having a course which has closed enrollments that has Simulate having a course which has closed enrollments that has
......
...@@ -21,13 +21,13 @@ from certificates.tests.factories import GeneratedCertificateFactory ...@@ -21,13 +21,13 @@ from certificates.tests.factories import GeneratedCertificateFactory
from util.milestones_helpers import ( from util.milestones_helpers import (
set_prerequisite_courses, set_prerequisite_courses,
milestones_achieved_by_user, milestones_achieved_by_user,
seed_milestone_relationship_types,
) )
from milestones.tests.utils import MilestonesTestCaseMixin
@attr('shard_1') @attr('shard_1')
@ddt @ddt
class CertificatesModelTest(ModuleStoreTestCase): class CertificatesModelTest(ModuleStoreTestCase, MilestonesTestCaseMixin):
""" """
Tests for the GeneratedCertificate model Tests for the GeneratedCertificate model
""" """
...@@ -92,7 +92,6 @@ class CertificatesModelTest(ModuleStoreTestCase): ...@@ -92,7 +92,6 @@ class CertificatesModelTest(ModuleStoreTestCase):
@patch.dict(settings.FEATURES, {'ENABLE_PREREQUISITE_COURSES': True, 'MILESTONES_APP': True}) @patch.dict(settings.FEATURES, {'ENABLE_PREREQUISITE_COURSES': True, 'MILESTONES_APP': True})
def test_course_milestone_collected(self): def test_course_milestone_collected(self):
seed_milestone_relationship_types()
student = UserFactory() student = UserFactory()
course = CourseFactory.create(org='edx', number='998', display_name='Test Course') course = CourseFactory.create(org='edx', number='998', display_name='Test Course')
pre_requisite_course = CourseFactory.create(org='edx', number='999', display_name='Pre requisite Course') pre_requisite_course = CourseFactory.create(org='edx', number='999', display_name='Pre requisite Course')
......
# Compute grades using real division, with no integer truncation # Compute grades using real division, with no integer truncation
from __future__ import division from __future__ import division
from collections import defaultdict
from functools import partial
import json import json
import random
import logging import logging
import random
from collections import defaultdict
from functools import partial
from contextlib import contextmanager import dogstats_wrapper as dog_stats_api
from django.conf import settings from django.conf import settings
from django.test.client import RequestFactory
from django.core.cache import cache from django.core.cache import cache
from django.test.client import RequestFactory
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
from opaque_keys.edx.locator import BlockUsageLocator
import dogstats_wrapper as dog_stats_api from openedx.core.lib.gating import api as gating_api
from courseware import courses from courseware import courses
from courseware.access import has_access from courseware.access import has_access
from courseware.model_data import FieldDataCache, ScoresClient from courseware.model_data import FieldDataCache, ScoresClient
from openedx.core.djangoapps.signals.signals import GRADES_UPDATED
from student.models import anonymous_id_for_user from student.models import anonymous_id_for_user
from util.db import outer_atomic from util.db import outer_atomic
from util.module_utils import yield_dynamic_descriptor_descendants from util.module_utils import yield_dynamic_descriptor_descendants
...@@ -25,10 +29,6 @@ from xmodule.modulestore.django import modulestore ...@@ -25,10 +29,6 @@ from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule.modulestore.exceptions import ItemNotFoundError
from .models import StudentModule from .models import StudentModule
from .module_render import get_module_for_descriptor from .module_render import get_module_for_descriptor
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
from openedx.core.djangoapps.signals.signals import GRADES_UPDATED
log = logging.getLogger("edx.courseware") log = logging.getLogger("edx.courseware")
...@@ -589,6 +589,9 @@ def _progress_summary(student, request, course, field_data_cache=None, scores_cl ...@@ -589,6 +589,9 @@ def _progress_summary(student, request, course, field_data_cache=None, scores_cl
# be hidden behind the ScoresClient. # be hidden behind the ScoresClient.
max_scores_cache.fetch_from_remote(field_data_cache.scorable_locations) max_scores_cache.fetch_from_remote(field_data_cache.scorable_locations)
# Check for gated content
gated_content = gating_api.get_gated_content(course, student)
chapters = [] chapters = []
locations_to_children = defaultdict(list) locations_to_children = defaultdict(list)
locations_to_weighted_scores = {} locations_to_weighted_scores = {}
...@@ -602,7 +605,7 @@ def _progress_summary(student, request, course, field_data_cache=None, scores_cl ...@@ -602,7 +605,7 @@ def _progress_summary(student, request, course, field_data_cache=None, scores_cl
for section_module in chapter_module.get_display_items(): for section_module in chapter_module.get_display_items():
# Skip if the section is hidden # Skip if the section is hidden
with outer_atomic(): with outer_atomic():
if section_module.hide_from_toc: if section_module.hide_from_toc or unicode(section_module.location) in gated_content:
continue continue
graded = section_module.graded graded = section_module.graded
...@@ -808,3 +811,72 @@ def _get_mock_request(student): ...@@ -808,3 +811,72 @@ def _get_mock_request(student):
request = RequestFactory().get('/') request = RequestFactory().get('/')
request.user = student request.user = student
return request return request
def _calculate_score_for_modules(user_id, course, modules):
"""
Calculates the cumulative score (percent) of the given modules
"""
# removing branch and version from exam modules locator
# otherwise student module would not return scores since module usage keys would not match
modules = [m for m in modules]
locations = [
BlockUsageLocator(
course_key=course.id,
block_type=module.location.block_type,
block_id=module.location.block_id
)
if isinstance(module.location, BlockUsageLocator) and module.location.version
else module.location
for module in modules
]
scores_client = ScoresClient(course.id, user_id)
scores_client.fetch_scores(locations)
# Iterate over all of the exam modules to get score percentage of user for each of them
module_percentages = []
ignore_categories = ['course', 'chapter', 'sequential', 'vertical', 'randomize']
for index, module in enumerate(modules):
if module.category not in ignore_categories and (module.graded or module.has_score):
module_score = scores_client.get(locations[index])
if module_score:
module_percentages.append(module_score.correct / module_score.total)
return sum(module_percentages) / float(len(module_percentages)) if module_percentages else 0
def get_module_score(user, course, module):
"""
Collects all children of the given module and calculates the cumulative
score for this set of modules for the given user.
Arguments:
user (User): The user
course (CourseModule): The course
module (XBlock): The module
Returns:
float: The cumulative score
"""
def inner_get_module(descriptor):
"""
Delegate to get_module_for_descriptor
"""
field_data_cache = FieldDataCache([descriptor], course.id, user)
return get_module_for_descriptor(
user,
_get_mock_request(user),
descriptor,
field_data_cache,
course.id,
course=course
)
modules = yield_dynamic_descriptor_descendants(
module,
user.id,
inner_get_module
)
return _calculate_score_for_modules(user.id, course, modules)
...@@ -5,14 +5,12 @@ Module rendering ...@@ -5,14 +5,12 @@ Module rendering
import hashlib import hashlib
import json import json
import logging import logging
import static_replace
from collections import OrderedDict from collections import OrderedDict
from functools import partial from functools import partial
from requests.auth import HTTPBasicAuth
import dogstats_wrapper as dog_stats_api
import dogstats_wrapper as dog_stats_api
import newrelic.agent
from capa.xqueue_interface import XQueueInterface
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.cache import cache from django.core.cache import cache
...@@ -22,11 +20,25 @@ from django.core.urlresolvers import reverse ...@@ -22,11 +20,25 @@ from django.core.urlresolvers import reverse
from django.http import Http404, HttpResponse from django.http import Http404, HttpResponse
from django.test.client import RequestFactory from django.test.client import RequestFactory
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from edx_proctoring.services import ProctoringService
from eventtracking import tracker
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import UsageKey, CourseKey
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from requests.auth import HTTPBasicAuth
from xblock.core import XBlock
from xblock.django.request import django_to_webob_request, webob_to_django_response
from xblock.exceptions import NoSuchHandlerError, NoSuchViewError
from xblock.reference.plugins import FSService
import newrelic.agent import static_replace
from openedx.core.lib.gating import api as gating_api
from capa.xqueue_interface import XQueueInterface
from courseware.access import has_access, get_user_role from courseware.access import has_access, get_user_role
from courseware.entrance_exams import (
get_entrance_exam_score,
user_must_complete_entrance_exam,
user_has_passed_entrance_exam
)
from courseware.masquerade import ( from courseware.masquerade import (
MasqueradingKeyValueStore, MasqueradingKeyValueStore,
filter_displayed_blocks, filter_displayed_blocks,
...@@ -35,20 +47,13 @@ from courseware.masquerade import ( ...@@ -35,20 +47,13 @@ from courseware.masquerade import (
) )
from courseware.model_data import DjangoKeyValueStore, FieldDataCache, set_score from courseware.model_data import DjangoKeyValueStore, FieldDataCache, set_score
from courseware.models import SCORE_CHANGED from courseware.models import SCORE_CHANGED
from courseware.entrance_exams import (
get_entrance_exam_score,
user_must_complete_entrance_exam,
user_has_passed_entrance_exam
)
from edxmako.shortcuts import render_to_string from edxmako.shortcuts import render_to_string
from eventtracking import tracker
from lms.djangoapps.lms_xblock.field_data import LmsFieldData from lms.djangoapps.lms_xblock.field_data import LmsFieldData
from lms.djangoapps.lms_xblock.runtime import LmsModuleSystem, unquote_slashes, quote_slashes
from lms.djangoapps.lms_xblock.models import XBlockAsidesConfig from lms.djangoapps.lms_xblock.models import XBlockAsidesConfig
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import UsageKey, CourseKey
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from openedx.core.djangoapps.bookmarks.services import BookmarksService from openedx.core.djangoapps.bookmarks.services import BookmarksService
from lms.djangoapps.lms_xblock.runtime import LmsModuleSystem, unquote_slashes, quote_slashes
from lms.djangoapps.verify_student.services import ReverificationService
from openedx.core.djangoapps.credit.services import CreditService
from openedx.core.lib.xblock_utils import ( from openedx.core.lib.xblock_utils import (
replace_course_urls, replace_course_urls,
replace_jump_to_id_urls, replace_jump_to_id_urls,
...@@ -59,29 +64,20 @@ from openedx.core.lib.xblock_utils import ( ...@@ -59,29 +64,20 @@ from openedx.core.lib.xblock_utils import (
) )
from student.models import anonymous_id_for_user, user_by_anonymous_id from student.models import anonymous_id_for_user, user_by_anonymous_id
from student.roles import CourseBetaTesterRole from student.roles import CourseBetaTesterRole
from xblock.core import XBlock from util import milestones_helpers
from xblock.django.request import django_to_webob_request, webob_to_django_response from util.json_request import JsonResponse
from xblock_django.user_service import DjangoXBlockUserService from util.model_utils import slugify
from xblock.exceptions import NoSuchHandlerError, NoSuchViewError from util.sandboxing import can_execute_unsafe_code, get_python_lib_zip
from xblock.reference.plugins import FSService
from xblock.runtime import KvsFieldData from xblock.runtime import KvsFieldData
from xblock_django.user_service import DjangoXBlockUserService
from xmodule.contentstore.django import contentstore from xmodule.contentstore.django import contentstore
from xmodule.error_module import ErrorDescriptor, NonStaffErrorDescriptor from xmodule.error_module import ErrorDescriptor, NonStaffErrorDescriptor
from xmodule.exceptions import NotFoundError, ProcessingError from xmodule.exceptions import NotFoundError, ProcessingError
from xmodule.modulestore.django import modulestore, ModuleI18nService
from xmodule.lti_module import LTIModule from xmodule.lti_module import LTIModule
from xmodule.mixin import wrap_with_license
from xmodule.modulestore.django import modulestore, ModuleI18nService
from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.x_module import XModuleDescriptor from xmodule.x_module import XModuleDescriptor
from xmodule.mixin import wrap_with_license
from util.json_request import JsonResponse
from util.model_utils import slugify
from util.sandboxing import can_execute_unsafe_code, get_python_lib_zip
from util import milestones_helpers
from lms.djangoapps.verify_student.services import ReverificationService
from edx_proctoring.services import ProctoringService
from openedx.core.djangoapps.credit.services import CreditService
from .field_overrides import OverrideFieldData from .field_overrides import OverrideFieldData
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -156,9 +152,13 @@ def toc_for_course(user, request, course, active_chapter, active_section, field_ ...@@ -156,9 +152,13 @@ def toc_for_course(user, request, course, active_chapter, active_section, field_
toc_chapters = list() toc_chapters = list()
chapters = course_module.get_display_items() chapters = course_module.get_display_items()
# See if the course is gated by one or more content milestones # Check for content which needs to be completed
# before the rest of the content is made available
required_content = milestones_helpers.get_required_content(course, user) required_content = milestones_helpers.get_required_content(course, user)
# Check for gated content
gated_content = gating_api.get_gated_content(course, user)
# The user may not actually have to complete the entrance exam, if one is required # The user may not actually have to complete the entrance exam, if one is required
if not user_must_complete_entrance_exam(request, user, course): if not user_must_complete_entrance_exam(request, user, course):
required_content = [content for content in required_content if not content == course.entrance_exam_id] required_content = [content for content in required_content if not content == course.entrance_exam_id]
...@@ -182,6 +182,10 @@ def toc_for_course(user, request, course, active_chapter, active_section, field_ ...@@ -182,6 +182,10 @@ def toc_for_course(user, request, course, active_chapter, active_section, field_
active = (chapter.url_name == active_chapter and active = (chapter.url_name == active_chapter and
section.url_name == active_section) section.url_name == active_section)
# Skip the current section if it is gated
if gated_content and unicode(section.location) in gated_content:
continue
if not section.hide_from_toc: if not section.hide_from_toc:
section_context = { section_context = {
'display_name': section.display_name_with_default_escaped, 'display_name': section.display_name_with_default_escaped,
......
...@@ -28,9 +28,9 @@ from xmodule.modulestore.tests.django_utils import ( ...@@ -28,9 +28,9 @@ from xmodule.modulestore.tests.django_utils import (
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
from util.milestones_helpers import ( from util.milestones_helpers import (
set_prerequisite_courses, set_prerequisite_courses,
seed_milestone_relationship_types,
get_prerequisite_courses_display, get_prerequisite_courses_display,
) )
from milestones.tests.utils import MilestonesTestCaseMixin
from lms.djangoapps.ccx.tests.factories import CcxFactory from lms.djangoapps.ccx.tests.factories import CcxFactory
from .helpers import LoginEnrollmentTestCase from .helpers import LoginEnrollmentTestCase
...@@ -41,7 +41,7 @@ SHIB_ERROR_STR = "The currently logged-in user account does not have permission ...@@ -41,7 +41,7 @@ SHIB_ERROR_STR = "The currently logged-in user account does not have permission
@attr('shard_1') @attr('shard_1')
class AboutTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase, EventTrackingTestCase): class AboutTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase, EventTrackingTestCase, MilestonesTestCaseMixin):
""" """
Tests about xblock. Tests about xblock.
""" """
...@@ -136,7 +136,6 @@ class AboutTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase, EventTrackingT ...@@ -136,7 +136,6 @@ class AboutTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase, EventTrackingT
@patch.dict(settings.FEATURES, {'ENABLE_PREREQUISITE_COURSES': True, 'MILESTONES_APP': True}) @patch.dict(settings.FEATURES, {'ENABLE_PREREQUISITE_COURSES': True, 'MILESTONES_APP': True})
def test_pre_requisite_course(self): def test_pre_requisite_course(self):
seed_milestone_relationship_types()
pre_requisite_course = CourseFactory.create(org='edX', course='900', display_name='pre requisite course') pre_requisite_course = CourseFactory.create(org='edX', course='900', display_name='pre requisite course')
course = CourseFactory.create(pre_requisite_courses=[unicode(pre_requisite_course.id)]) course = CourseFactory.create(pre_requisite_courses=[unicode(pre_requisite_course.id)])
self.setup_user() self.setup_user()
...@@ -151,7 +150,6 @@ class AboutTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase, EventTrackingT ...@@ -151,7 +150,6 @@ class AboutTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase, EventTrackingT
@patch.dict(settings.FEATURES, {'ENABLE_PREREQUISITE_COURSES': True, 'MILESTONES_APP': True}) @patch.dict(settings.FEATURES, {'ENABLE_PREREQUISITE_COURSES': True, 'MILESTONES_APP': True})
def test_about_page_unfulfilled_prereqs(self): def test_about_page_unfulfilled_prereqs(self):
seed_milestone_relationship_types()
pre_requisite_course = CourseFactory.create( pre_requisite_course = CourseFactory.create(
org='edX', org='edX',
course='900', course='900',
......
...@@ -54,8 +54,8 @@ from xmodule.modulestore.tests.django_utils import ( ...@@ -54,8 +54,8 @@ from xmodule.modulestore.tests.django_utils import (
from util.milestones_helpers import ( from util.milestones_helpers import (
set_prerequisite_courses, set_prerequisite_courses,
fulfill_course_milestone, fulfill_course_milestone,
seed_milestone_relationship_types,
) )
from milestones.tests.utils import MilestonesTestCaseMixin
from lms.djangoapps.ccx.models import CustomCourseForEdX from lms.djangoapps.ccx.models import CustomCourseForEdX
...@@ -151,7 +151,7 @@ class CoachAccessTestCaseCCX(SharedModuleStoreTestCase, LoginEnrollmentTestCase) ...@@ -151,7 +151,7 @@ class CoachAccessTestCaseCCX(SharedModuleStoreTestCase, LoginEnrollmentTestCase)
@attr('shard_1') @attr('shard_1')
@ddt.ddt @ddt.ddt
class AccessTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase): class AccessTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase, MilestonesTestCaseMixin):
""" """
Tests for the various access controls on the student dashboard Tests for the various access controls on the student dashboard
""" """
...@@ -428,7 +428,6 @@ class AccessTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase): ...@@ -428,7 +428,6 @@ class AccessTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase):
""" """
Test course access when a course has pre-requisite course yet to be completed Test course access when a course has pre-requisite course yet to be completed
""" """
seed_milestone_relationship_types()
user = UserFactory.create() user = UserFactory.create()
pre_requisite_course = CourseFactory.create( pre_requisite_course = CourseFactory.create(
...@@ -479,7 +478,6 @@ class AccessTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase): ...@@ -479,7 +478,6 @@ class AccessTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase):
""" """
Test courseware access when a course has pre-requisite course yet to be completed Test courseware access when a course has pre-requisite course yet to be completed
""" """
seed_milestone_relationship_types()
pre_requisite_course = CourseFactory.create( pre_requisite_course = CourseFactory.create(
org='edX', org='edX',
course='900', course='900',
......
...@@ -31,8 +31,8 @@ from util.milestones_helpers import ( ...@@ -31,8 +31,8 @@ from util.milestones_helpers import (
generate_milestone_namespace, generate_milestone_namespace,
add_course_content_milestone, add_course_content_milestone,
get_milestone_relationship_types, get_milestone_relationship_types,
seed_milestone_relationship_types,
) )
from milestones.tests.utils import MilestonesTestCaseMixin
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
...@@ -40,7 +40,7 @@ from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory ...@@ -40,7 +40,7 @@ from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
@attr('shard_1') @attr('shard_1')
@patch.dict('django.conf.settings.FEATURES', {'ENTRANCE_EXAMS': True, 'MILESTONES_APP': True}) @patch.dict('django.conf.settings.FEATURES', {'ENTRANCE_EXAMS': True, 'MILESTONES_APP': True})
class EntranceExamTestCases(LoginEnrollmentTestCase, ModuleStoreTestCase): class EntranceExamTestCases(LoginEnrollmentTestCase, ModuleStoreTestCase, MilestonesTestCaseMixin):
""" """
Check that content is properly gated. Check that content is properly gated.
...@@ -134,7 +134,6 @@ class EntranceExamTestCases(LoginEnrollmentTestCase, ModuleStoreTestCase): ...@@ -134,7 +134,6 @@ class EntranceExamTestCases(LoginEnrollmentTestCase, ModuleStoreTestCase):
display_name="Exam Problem - Problem 2" display_name="Exam Problem - Problem 2"
) )
seed_milestone_relationship_types()
add_entrance_exam_milestone(self.course, self.entrance_exam) add_entrance_exam_milestone(self.course, self.entrance_exam)
self.course.entrance_exam_enabled = True self.course.entrance_exam_enabled = True
......
...@@ -10,7 +10,21 @@ from nose.plugins.attrib import attr ...@@ -10,7 +10,21 @@ from nose.plugins.attrib import attr
from opaque_keys.edx.locations import SlashSeparatedCourseKey from opaque_keys.edx.locations import SlashSeparatedCourseKey
from opaque_keys.edx.locator import CourseLocator, BlockUsageLocator from opaque_keys.edx.locator import CourseLocator, BlockUsageLocator
from courseware.grades import field_data_cache_for_grading, grade, iterate_grades_for, MaxScoresCache, ProgressSummary from courseware.grades import (
field_data_cache_for_grading,
grade,
iterate_grades_for,
MaxScoresCache,
ProgressSummary,
get_module_score
)
from courseware.module_render import get_module
from courseware.model_data import FieldDataCache
from courseware.tests.helpers import (
LoginEnrollmentTestCase,
get_request_for_user
)
from capa.tests.response_xml_factory import MultipleChoiceResponseXMLFactory
from student.tests.factories import UserFactory from student.tests.factories import UserFactory
from student.models import CourseEnrollment from student.models import CourseEnrollment
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
...@@ -318,3 +332,140 @@ class TestProgressSummary(TestCase): ...@@ -318,3 +332,140 @@ class TestProgressSummary(TestCase):
earned, possible = self.progress_summary.score_for_module(self.loc_m) earned, possible = self.progress_summary.score_for_module(self.loc_m)
self.assertEqual(earned, 0) self.assertEqual(earned, 0)
self.assertEqual(possible, 0) self.assertEqual(possible, 0)
class TestGetModuleScore(LoginEnrollmentTestCase, ModuleStoreTestCase):
"""
Test get_module_score
"""
def setUp(self):
"""
Set up test course
"""
super(TestGetModuleScore, self).setUp()
self.course = CourseFactory.create()
self.chapter = ItemFactory.create(
parent=self.course,
category="chapter",
display_name="Test Chapter"
)
self.seq1 = ItemFactory.create(
parent=self.chapter,
category='sequential',
display_name="Test Sequential",
graded=True
)
self.seq2 = ItemFactory.create(
parent=self.chapter,
category='sequential',
display_name="Test Sequential",
graded=True
)
self.vert1 = ItemFactory.create(
parent=self.seq1,
category='vertical',
display_name='Test Vertical 1'
)
self.vert2 = ItemFactory.create(
parent=self.seq2,
category='vertical',
display_name='Test Vertical 2'
)
self.randomize = ItemFactory.create(
parent=self.vert2,
category='randomize',
display_name='Test Randomize'
)
problem_xml = MultipleChoiceResponseXMLFactory().build_xml(
question_text='The correct answer is Choice 3',
choices=[False, False, True, False],
choice_names=['choice_0', 'choice_1', 'choice_2', 'choice_3']
)
self.problem1 = ItemFactory.create(
parent=self.vert1,
category="problem",
display_name="Test Problem 1",
data=problem_xml
)
self.problem2 = ItemFactory.create(
parent=self.vert1,
category="problem",
display_name="Test Problem 2",
data=problem_xml
)
self.problem3 = ItemFactory.create(
parent=self.randomize,
category="problem",
display_name="Test Problem 3",
data=problem_xml
)
self.problem4 = ItemFactory.create(
parent=self.randomize,
category="problem",
display_name="Test Problem 4",
data=problem_xml
)
self.request = get_request_for_user(UserFactory())
self.client.login(username=self.request.user.username, password="test")
CourseEnrollment.enroll(self.request.user, self.course.id)
def test_get_module_score(self):
"""
Test test_get_module_score
"""
with self.assertNumQueries(1):
score = get_module_score(self.request.user, self.course, self.seq1)
self.assertEqual(score, 0)
answer_problem(self.course, self.request, self.problem1)
answer_problem(self.course, self.request, self.problem2)
with self.assertNumQueries(1):
score = get_module_score(self.request.user, self.course, self.seq1)
self.assertEqual(score, 1.0)
answer_problem(self.course, self.request, self.problem1)
answer_problem(self.course, self.request, self.problem2, 0)
with self.assertNumQueries(1):
score = get_module_score(self.request.user, self.course, self.seq1)
self.assertEqual(score, .5)
def test_get_module_score_with_randomize(self):
"""
Test test_get_module_score_with_randomize
"""
answer_problem(self.course, self.request, self.problem3)
answer_problem(self.course, self.request, self.problem4)
score = get_module_score(self.request.user, self.course, self.seq2)
self.assertEqual(score, 1.0)
def answer_problem(course, request, problem, score=1):
"""
Records a correct answer for the given problem.
Arguments:
course (Course): Course object, the course the required problem is in
request (Request): request Object
problem (xblock): xblock object, the problem to be answered
"""
user = request.user
grade_dict = {'value': score, 'max_value': 1, 'user_id': user.id}
field_data_cache = FieldDataCache.cache_for_descriptor_descendents(
course.id,
user,
course,
depth=2
)
# pylint: disable=protected-access
module = get_module(
user,
request,
problem.scope_ids.usage_id,
field_data_cache,
)._xmodule
module.system.publish(problem, 'grade', grade_dict)
...@@ -40,6 +40,7 @@ from courseware.tests.test_submitting_problems import TestSubmittingProblems ...@@ -40,6 +40,7 @@ from courseware.tests.test_submitting_problems import TestSubmittingProblems
from lms.djangoapps.lms_xblock.runtime import quote_slashes from lms.djangoapps.lms_xblock.runtime import quote_slashes
from lms.djangoapps.lms_xblock.field_data import LmsFieldData from lms.djangoapps.lms_xblock.field_data import LmsFieldData
from openedx.core.lib.courses import course_image_url from openedx.core.lib.courses import course_image_url
from openedx.core.lib.gating import api as gating_api
from student.models import anonymous_id_for_user from student.models import anonymous_id_for_user
from xmodule.modulestore.tests.django_utils import ( from xmodule.modulestore.tests.django_utils import (
TEST_DATA_MIXED_TOY_MODULESTORE, TEST_DATA_MIXED_TOY_MODULESTORE,
...@@ -66,6 +67,8 @@ from edx_proctoring.api import ( ...@@ -66,6 +67,8 @@ from edx_proctoring.api import (
from edx_proctoring.runtime import set_runtime_service from edx_proctoring.runtime import set_runtime_service
from edx_proctoring.tests.test_services import MockCreditService from edx_proctoring.tests.test_services import MockCreditService
from milestones.tests.utils import MilestonesTestCaseMixin
TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT
...@@ -1025,6 +1028,83 @@ class TestProctoringRendering(ModuleStoreTestCase): ...@@ -1025,6 +1028,83 @@ class TestProctoringRendering(ModuleStoreTestCase):
@attr('shard_1') @attr('shard_1')
class TestGatedSubsectionRendering(ModuleStoreTestCase, MilestonesTestCaseMixin):
"""
Test the toc for a course is rendered correctly when there is gated content
"""
def setUp(self):
"""
Set up the initial test data
"""
super(TestGatedSubsectionRendering, self).setUp()
self.course = CourseFactory.create()
self.course.enable_subsection_gating = True
self.course.save()
self.store.update_item(self.course, 0)
self.chapter = ItemFactory.create(
parent=self.course,
category="chapter",
display_name="Chapter"
)
self.open_seq = ItemFactory.create(
parent=self.chapter,
category='sequential',
display_name="Open Sequential"
)
self.gated_seq = ItemFactory.create(
parent=self.chapter,
category='sequential',
display_name="Gated Sequential"
)
self.request = RequestFactory().get('%s/%s/%s' % ('/courses', self.course.id, self.chapter.display_name))
self.request.user = UserFactory()
self.field_data_cache = FieldDataCache.cache_for_descriptor_descendents(
self.course.id, self.request.user, self.course, depth=2
)
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)
def _find_url_name(self, toc, url_name):
"""
Helper to return the TOC section associated with url_name
"""
for entry in toc:
if entry['url_name'] == url_name:
return entry
return None
def _find_sequential(self, toc, chapter_url_name, sequential_url_name):
"""
Helper to return the sequential associated with sequential_url_name
"""
chapter = self._find_url_name(toc, chapter_url_name)
if chapter:
return self._find_url_name(chapter['sections'], sequential_url_name)
return None
def test_toc_with_gated_sequential(self):
"""
Test generation of TOC for a course with a gated subsection
"""
actual = render.toc_for_course(
self.request.user,
self.request,
self.course,
self.chapter.display_name,
self.open_seq.display_name,
self.field_data_cache
)
self.assertIsNotNone(self._find_sequential(actual, 'Chapter', 'Open_Sequential'))
self.assertIsNone(self._find_sequential(actual, 'Chapter', 'Gated_Sequential'))
self.assertIsNone(self._find_sequential(actual, 'Non-existant_Chapter', 'Non-existant_Sequential'))
@attr('shard_1')
@ddt.ddt @ddt.ddt
class TestHtmlModifiers(ModuleStoreTestCase): class TestHtmlModifiers(ModuleStoreTestCase):
""" """
......
...@@ -20,12 +20,12 @@ from courseware.views import get_static_tab_contents, static_tab ...@@ -20,12 +20,12 @@ from courseware.views import get_static_tab_contents, static_tab
from student.models import CourseEnrollment from student.models import CourseEnrollment
from student.tests.factories import UserFactory from student.tests.factories import UserFactory
from util.milestones_helpers import ( from util.milestones_helpers import (
seed_milestone_relationship_types,
get_milestone_relationship_types, get_milestone_relationship_types,
add_milestone, add_milestone,
add_course_milestone, add_course_milestone,
add_course_content_milestone add_course_content_milestone
) )
from milestones.tests.utils import MilestonesTestCaseMixin
from xmodule import tabs as xmodule_tabs from xmodule import tabs as xmodule_tabs
from xmodule.modulestore.tests.django_utils import ( from xmodule.modulestore.tests.django_utils import (
TEST_DATA_MIXED_TOY_MODULESTORE, TEST_DATA_MIXED_CLOSED_MODULESTORE TEST_DATA_MIXED_TOY_MODULESTORE, TEST_DATA_MIXED_CLOSED_MODULESTORE
...@@ -310,7 +310,7 @@ class StaticTabDateTestCaseXML(LoginEnrollmentTestCase, ModuleStoreTestCase): ...@@ -310,7 +310,7 @@ class StaticTabDateTestCaseXML(LoginEnrollmentTestCase, ModuleStoreTestCase):
@attr('shard_1') @attr('shard_1')
@patch.dict('django.conf.settings.FEATURES', {'ENTRANCE_EXAMS': True, 'MILESTONES_APP': True}) @patch.dict('django.conf.settings.FEATURES', {'ENTRANCE_EXAMS': True, 'MILESTONES_APP': True})
class EntranceExamsTabsTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase): class EntranceExamsTabsTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase, MilestonesTestCaseMixin):
""" """
Validate tab behavior when dealing with Entrance Exams Validate tab behavior when dealing with Entrance Exams
""" """
...@@ -339,7 +339,6 @@ class EntranceExamsTabsTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase): ...@@ -339,7 +339,6 @@ class EntranceExamsTabsTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase):
self.setup_user() self.setup_user()
self.enroll(self.course) self.enroll(self.course)
self.user.is_staff = True self.user.is_staff = True
seed_milestone_relationship_types()
self.relationship_types = get_milestone_relationship_types() self.relationship_types = get_milestone_relationship_types()
def test_get_course_tabs_list_entrance_exam_enabled(self): def test_get_course_tabs_list_entrance_exam_enabled(self):
......
...@@ -38,7 +38,9 @@ from courseware.testutils import RenderXBlockTestMixin ...@@ -38,7 +38,9 @@ from courseware.testutils import RenderXBlockTestMixin
from courseware.tests.factories import StudentModuleFactory from courseware.tests.factories import StudentModuleFactory
from courseware.user_state_client import DjangoXBlockUserStateClient from courseware.user_state_client import DjangoXBlockUserStateClient
from edxmako.tests import mako_middleware_process_request from edxmako.tests import mako_middleware_process_request
from milestones.tests.utils import MilestonesTestCaseMixin
from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration
from openedx.core.lib.gating import api as gating_api
from student.models import CourseEnrollment from student.models import CourseEnrollment
from student.tests.factories import AdminFactory, UserFactory, CourseEnrollmentFactory from student.tests.factories import AdminFactory, UserFactory, CourseEnrollmentFactory
from util.tests.test_date_utils import fake_ugettext, fake_pgettext from util.tests.test_date_utils import fake_ugettext, fake_pgettext
...@@ -1263,6 +1265,55 @@ class TestIndexView(ModuleStoreTestCase): ...@@ -1263,6 +1265,55 @@ class TestIndexView(ModuleStoreTestCase):
self.assertIn("Activate Block ID: test_block_id", response.content) self.assertIn("Activate Block ID: test_block_id", response.content)
class TestIndexViewWithGating(ModuleStoreTestCase, MilestonesTestCaseMixin):
"""
Test the index view for a course with gated content
"""
def setUp(self):
"""
Set up the initial test data
"""
super(TestIndexViewWithGating, self).setUp()
self.user = UserFactory()
self.course = CourseFactory.create()
self.course.enable_subsection_gating = True
self.course.save()
self.store.update_item(self.course, 0)
self.chapter = ItemFactory.create(parent=self.course, category="chapter", display_name="Chapter")
self.open_seq = ItemFactory.create(parent=self.chapter, category='sequential', display_name="Open Sequential")
self.gated_seq = ItemFactory.create(parent=self.chapter, category='sequential', 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)
CourseEnrollmentFactory(user=self.user, course_id=self.course.id)
def test_index_with_gated_sequential(self):
"""
Test index view with a gated sequential raises Http404
"""
request = RequestFactory().get(
reverse(
'courseware_section',
kwargs={
'course_id': unicode(self.course.id),
'chapter': self.chapter.url_name,
'section': self.gated_seq.url_name,
}
)
)
request.user = self.user
mako_middleware_process_request(request)
with self.assertRaises(Http404):
__ = views.index(
request,
unicode(self.course.id),
chapter=self.chapter.url_name,
section=self.gated_seq.url_name
)
class TestRenderXBlock(RenderXBlockTestMixin, ModuleStoreTestCase): class TestRenderXBlock(RenderXBlockTestMixin, ModuleStoreTestCase):
""" """
Tests for the courseware.render_xblock endpoint. Tests for the courseware.render_xblock endpoint.
......
...@@ -2,36 +2,44 @@ ...@@ -2,36 +2,44 @@
Courseware views functions Courseware views functions
""" """
import logging
import json import json
import textwrap import logging
import urllib import urllib
from collections import OrderedDict from collections import OrderedDict
from datetime import datetime from datetime import datetime
from django.utils.translation import ugettext as _
import analytics
import newrelic.agent
from django.conf import settings from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.contrib.auth.models import User, AnonymousUser
from django.core.context_processors import csrf from django.core.context_processors import csrf
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.contrib.auth.models import User, AnonymousUser
from django.contrib.auth.decorators import login_required
from django.db import transaction from django.db import transaction
from django.db.models import Q from django.db.models import Q
from django.utils.timezone import UTC
from django.views.decorators.http import require_GET, require_POST, require_http_methods
from django.http import Http404, HttpResponse, HttpResponseBadRequest, HttpResponseForbidden from django.http import Http404, HttpResponse, HttpResponseBadRequest, HttpResponseForbidden
from django.shortcuts import redirect from django.shortcuts import redirect
from certificates import api as certs_api from django.utils.timezone import UTC
from edxmako.shortcuts import render_to_response, render_to_string, marketing_link from django.utils.translation import ugettext as _
from django.views.decorators.csrf import ensure_csrf_cookie
from django.views.decorators.cache import cache_control from django.views.decorators.cache import cache_control
from django.views.decorators.csrf import ensure_csrf_cookie
from django.views.decorators.http import require_GET, require_POST, require_http_methods
from eventtracking import tracker
from ipware.ip import get_ip from ipware.ip import get_ip
from markupsafe import escape from markupsafe import escape
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey, UsageKey
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from rest_framework import status from rest_framework import status
import newrelic.agent from xblock.fragment import Fragment
import shoppingcart
import survey.utils
import survey.views
from certificates import api as certs_api
from openedx.core.lib.gating import api as gating_api
from course_modes.models import CourseMode
from courseware import grades from courseware import grades
from courseware.access import has_access, has_ccx_coach_role, _adjust_start_date_for_beta_testers from courseware.access import has_access, has_ccx_coach_role, _adjust_start_date_for_beta_testers
from courseware.access_response import StartDateError from courseware.access_response import StartDateError
...@@ -49,55 +57,42 @@ from courseware.courses import ( ...@@ -49,55 +57,42 @@ from courseware.courses import (
UserNotEnrolled UserNotEnrolled
) )
from courseware.masquerade import setup_masquerade from courseware.masquerade import setup_masquerade
from courseware.model_data import FieldDataCache, ScoresClient
from courseware.models import StudentModuleHistory
from courseware.url_helpers import get_redirect_url
from courseware.user_state_client import DjangoXBlockUserStateClient
from edxmako.shortcuts import render_to_response, render_to_string, marketing_link
from instructor.enrollment import uses_shib
from microsite_configuration import microsite
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from openedx.core.djangoapps.credit.api import ( from openedx.core.djangoapps.credit.api import (
get_credit_requirement_status, get_credit_requirement_status,
is_user_eligible_for_credit, is_user_eligible_for_credit,
is_credit_course is_credit_course
) )
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from shoppingcart.models import CourseRegistrationCode
from courseware.models import StudentModuleHistory from shoppingcart.utils import is_shopping_cart_enabled
from courseware.model_data import FieldDataCache, ScoresClient
from .module_render import toc_for_course, get_module_for_descriptor, get_module, get_module_by_usage_id
from .entrance_exams import (
course_has_entrance_exam,
get_entrance_exam_content,
get_entrance_exam_score,
user_must_complete_entrance_exam,
user_has_passed_entrance_exam
)
from courseware.user_state_client import DjangoXBlockUserStateClient
from course_modes.models import CourseMode
from student.models import UserTestGroup, CourseEnrollment from student.models import UserTestGroup, CourseEnrollment
from student.views import is_course_blocked from student.views import is_course_blocked
from util.cache import cache, cache_if_anonymous from util.cache import cache, cache_if_anonymous
from util.date_utils import strftime_localized from util.date_utils import strftime_localized
from util.db import outer_atomic from util.db import outer_atomic
from xblock.fragment import Fragment from util.milestones_helpers import get_prerequisite_courses_display
from util.views import _record_feedback_in_zendesk
from util.views import ensure_valid_course_key
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError, NoPathToItem from xmodule.modulestore.exceptions import ItemNotFoundError, NoPathToItem
from xmodule.tabs import CourseTabList from xmodule.tabs import CourseTabList
from xmodule.x_module import STUDENT_VIEW from xmodule.x_module import STUDENT_VIEW
import shoppingcart
from shoppingcart.models import CourseRegistrationCode
from shoppingcart.utils import is_shopping_cart_enabled
from opaque_keys import InvalidKeyError
from util.milestones_helpers import get_prerequisite_courses_display
from util.views import _record_feedback_in_zendesk
from microsite_configuration import microsite
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from opaque_keys.edx.keys import CourseKey, UsageKey
from instructor.enrollment import uses_shib
import survey.utils
import survey.views
from util.views import ensure_valid_course_key
from eventtracking import tracker
import analytics
from courseware.url_helpers import get_redirect_url
from lms.djangoapps.ccx.custom_exception import CCXLocatorValidationException from lms.djangoapps.ccx.custom_exception import CCXLocatorValidationException
from .entrance_exams import (
course_has_entrance_exam,
get_entrance_exam_content,
get_entrance_exam_score,
user_must_complete_entrance_exam,
user_has_passed_entrance_exam
)
from .module_render import toc_for_course, get_module_for_descriptor, get_module, get_module_by_usage_id
from lang_pref import LANGUAGE_KEY from lang_pref import LANGUAGE_KEY
from openedx.core.djangoapps.user_api.preferences.api import get_user_preference from openedx.core.djangoapps.user_api.preferences.api import get_user_preference
...@@ -403,6 +398,14 @@ def _index_bulk_op(request, course_key, chapter, section, position): ...@@ -403,6 +398,14 @@ def _index_bulk_op(request, course_key, chapter, section, position):
and user_must_complete_entrance_exam(request, user, course): and user_must_complete_entrance_exam(request, user, course):
log.info(u'User %d tried to view course %s without passing entrance exam', user.id, unicode(course.id)) log.info(u'User %d tried to view course %s without passing entrance exam', user.id, unicode(course.id))
return redirect(reverse('courseware', args=[unicode(course.id)])) return redirect(reverse('courseware', args=[unicode(course.id)]))
# Gated Content Check
gated_content = gating_api.get_gated_content(course, user)
if section and gated_content:
for usage_key in gated_content:
if section in usage_key:
raise Http404
# check to see if there is a required survey that must be taken before # check to see if there is a required survey that must be taken before
# the user can access the course. # the user can access the course.
if survey.utils.must_answer_survey(course, user): if survey.utils.must_answer_survey(course, user):
......
"""
API for the gating djangoapp
"""
import logging
import json
from collections import defaultdict
from django.contrib.auth.models import User
from xmodule.modulestore.django import modulestore
from milestones import api as milestones_api
from openedx.core.lib.gating import api as gating_api
log = logging.getLogger(__name__)
def _get_xblock_parent(xblock, category=None):
"""
Returns the parent of the given XBlock. If an optional category is supplied,
traverses the ancestors of the XBlock and returns the first with the
given category.
Arguments:
xblock (XBlock): Get the parent of this XBlock
category (str): Find an ancestor with this category (e.g. sequential)
"""
parent = xblock.get_parent()
if parent and category:
if parent.category == category:
return parent
else:
return _get_xblock_parent(parent, category)
return parent
@gating_api.gating_enabled(default=False)
def evaluate_prerequisite(course, prereq_content_key, user_id):
"""
Finds the parent subsection of the content in the course and evaluates
any milestone relationships attached to that subsection. If the calculated
grade of the prerequisite subsection meets the minimum score required by
dependent subsections, the related milestone will be fulfilled for the user.
Arguments:
user_id (int): ID of User for which evaluation should occur
course (CourseModule): The course
prereq_content_key (UsageKey): The prerequisite content usage key
Returns:
None
"""
xblock = modulestore().get_item(prereq_content_key)
sequential = _get_xblock_parent(xblock, 'sequential')
if sequential:
prereq_milestone = gating_api.get_gating_milestone(
course.id,
sequential.location.for_branch(None),
'fulfills'
)
if prereq_milestone:
gated_content_milestones = defaultdict(list)
for milestone in gating_api.find_gating_milestones(course.id, None, 'requires'):
gated_content_milestones[milestone['id']].append(milestone)
gated_content = gated_content_milestones.get(prereq_milestone['id'])
if gated_content:
from courseware.grades import get_module_score
user = User.objects.get(id=user_id)
score = get_module_score(user, course, sequential) * 100
for milestone in gated_content:
# Default minimum score to 100
min_score = 100
requirements = milestone.get('requirements')
if requirements:
try:
min_score = int(requirements.get('min_score'))
except (ValueError, TypeError):
log.warning(
'Failed to find minimum score for gating milestone %s, defaulting to 100',
json.dumps(milestone)
)
if score >= min_score:
milestones_api.add_user_milestone({'id': user_id}, prereq_milestone)
else:
milestones_api.remove_user_milestone({'id': user_id}, prereq_milestone)
"""
Django AppConfig module for the Gating app
"""
from django.apps import AppConfig
class GatingConfig(AppConfig):
"""
Django AppConfig class for the gating app
"""
name = 'gating'
def ready(self):
# Import signals to wire up the signal handlers contained within
from gating import signals # pylint: disable=unused-variable
"""
Signal handlers for the gating djangoapp
"""
from django.dispatch import receiver
from opaque_keys.edx.keys import CourseKey, UsageKey
from xmodule.modulestore.django import modulestore
from courseware.models import SCORE_CHANGED
from gating import api as gating_api
@receiver(SCORE_CHANGED)
def handle_score_changed(**kwargs):
"""
Receives the SCORE_CHANGED signal sent by LMS when a student's score has changed
for a given component and triggers the evaluation of any milestone relationships
which are attached to the updated content.
Arguments:
kwargs (dict): Contains user ID, course key, and content usage key
Returns:
None
"""
course = modulestore().get_course(CourseKey.from_string(kwargs.get('course_id')))
if course.enable_subsection_gating:
gating_api.evaluate_prerequisite(
course,
UsageKey.from_string(kwargs.get('usage_id')),
kwargs.get('user_id'),
)
"""
Unit tests for gating.signals module
"""
from mock import patch
from ddt import ddt, data, unpack
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from courseware.tests.helpers import LoginEnrollmentTestCase
from milestones import api as milestones_api
from milestones.tests.utils import MilestonesTestCaseMixin
from openedx.core.lib.gating import api as gating_api
from gating.api import _get_xblock_parent, evaluate_prerequisite
class GatingTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase):
"""
Base TestCase class for setting up a basic course structure
and testing the gating feature
"""
def setUp(self):
"""
Initial data setup
"""
super(GatingTestCase, self).setUp()
# Patch Milestones feature flag
self.settings_patcher = patch.dict('django.conf.settings.FEATURES', {'MILESTONES_APP': True})
self.settings_patcher.start()
# create course
self.course = CourseFactory.create(
org='edX',
number='EDX101',
run='EDX101_RUN1',
display_name='edX 101'
)
self.course.enable_subsection_gating = True
self.course.save()
self.store.update_item(self.course, 0)
# create chapter
self.chapter1 = ItemFactory.create(
parent_location=self.course.location,
category='chapter',
display_name='untitled chapter 1'
)
# create sequentials
self.seq1 = ItemFactory.create(
parent_location=self.chapter1.location,
category='sequential',
display_name='untitled sequential 1'
)
self.seq2 = ItemFactory.create(
parent_location=self.chapter1.location,
category='sequential',
display_name='untitled sequential 2'
)
# create vertical
self.vert1 = ItemFactory.create(
parent_location=self.seq1.location,
category='vertical',
display_name='untitled vertical 1'
)
# create problem
self.prob1 = ItemFactory.create(
parent_location=self.vert1.location,
category='problem',
display_name='untitled problem 1'
)
# create orphan
self.prob2 = ItemFactory.create(
parent_location=self.course.location,
category='problem',
display_name='untitled problem 2'
)
def tearDown(self):
"""
Tear down initial setup
"""
self.settings_patcher.stop()
super(GatingTestCase, self).tearDown()
class TestGetXBlockParent(GatingTestCase):
"""
Tests for the get_xblock_parent function
"""
def test_get_direct_parent(self):
""" Test test_get_direct_parent """
result = _get_xblock_parent(self.vert1)
self.assertEqual(result.location, self.seq1.location)
def test_get_parent_with_category(self):
""" Test test_get_parent_of_category """
result = _get_xblock_parent(self.vert1, 'sequential')
self.assertEqual(result.location, self.seq1.location)
result = _get_xblock_parent(self.vert1, 'chapter')
self.assertEqual(result.location, self.chapter1.location)
def test_get_parent_none(self):
""" Test test_get_parent_none """
result = _get_xblock_parent(self.vert1, 'unit')
self.assertIsNone(result)
@ddt
class TestEvaluatePrerequisite(GatingTestCase, MilestonesTestCaseMixin):
"""
Tests for the evaluate_prerequisite function
"""
def setUp(self):
super(TestEvaluatePrerequisite, self).setUp()
self.user_dict = {'id': self.user.id}
self.prereq_milestone = None
def _setup_gating_milestone(self, min_score):
"""
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)
self.prereq_milestone = gating_api.get_gating_milestone(self.course.id, self.seq1.location, 'fulfills')
@patch('courseware.grades.get_module_score')
@data((.5, True), (1, True), (0, False))
@unpack
def test_min_score_achieved(self, module_score, result, mock_module_score):
""" Test test_min_score_achieved """
self._setup_gating_milestone(50)
mock_module_score.return_value = module_score
evaluate_prerequisite(self.course, self.prob1.location, self.user.id)
self.assertEqual(milestones_api.user_has_milestone(self.user_dict, self.prereq_milestone), result)
@patch('gating.api.log.warning')
@patch('courseware.grades.get_module_score')
@data((.5, False), (1, True))
@unpack
def test_invalid_min_score(self, module_score, result, mock_module_score, mock_log):
""" Test test_invalid_min_score """
self._setup_gating_milestone(None)
mock_module_score.return_value = module_score
evaluate_prerequisite(self.course, self.prob1.location, self.user.id)
self.assertEqual(milestones_api.user_has_milestone(self.user_dict, self.prereq_milestone), result)
self.assertTrue(mock_log.called)
@patch('courseware.grades.get_module_score')
def test_orphaned_xblock(self, mock_module_score):
""" Test test_orphaned_xblock """
evaluate_prerequisite(self.course, self.prob2.location, self.user.id)
self.assertFalse(mock_module_score.called)
@patch('courseware.grades.get_module_score')
def test_no_prerequisites(self, mock_module_score):
""" Test test_no_prerequisites """
evaluate_prerequisite(self.course, self.prob1.location, self.user.id)
self.assertFalse(mock_module_score.called)
@patch('courseware.grades.get_module_score')
def test_no_gated_content(self, mock_module_score):
""" Test test_no_gated_content """
# Setup gating milestones data
gating_api.add_prerequisite(self.course.id, self.seq1.location)
evaluate_prerequisite(self.course, self.prob1.location, self.user.id)
self.assertFalse(mock_module_score.called)
"""
Unit tests for gating.signals module
"""
from mock import patch
from opaque_keys.edx.keys import UsageKey
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from xmodule.modulestore.django import modulestore
from gating.signals import handle_score_changed
class TestHandleScoreChanged(ModuleStoreTestCase):
"""
Test case for handle_score_changed django signal handler
"""
def setUp(self):
super(TestHandleScoreChanged, self).setUp()
self.course = CourseFactory.create(org='TestX', number='TS01', run='2016_Q1')
self.test_user_id = 0
self.test_usage_key = UsageKey.from_string('i4x://the/content/key/12345678')
@patch('gating.signals.gating_api.evaluate_prerequisite')
def test_gating_enabled(self, mock_evaluate):
""" Test evaluate_prerequisite is called when course.enable_subsection_gating is True """
self.course.enable_subsection_gating = True
modulestore().update_item(self.course, 0)
handle_score_changed(
sender=None,
points_possible=1,
points_earned=1,
user_id=self.test_user_id,
course_id=unicode(self.course.id),
usage_id=unicode(self.test_usage_key)
)
mock_evaluate.assert_called_with(self.course, self.test_usage_key, self.test_user_id)
@patch('gating.signals.gating_api.evaluate_prerequisite')
def test_gating_disabled(self, mock_evaluate):
""" Test evaluate_prerequisite is not called when course.enable_subsection_gating is False """
handle_score_changed(
sender=None,
points_possible=1,
points_earned=1,
user_id=self.test_user_id,
course_id=unicode(self.course.id),
usage_id=unicode(self.test_usage_key)
)
mock_evaluate.assert_not_called()
...@@ -10,13 +10,15 @@ from xmodule.modulestore import ModuleStoreEnum ...@@ -10,13 +10,15 @@ from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from xmodule.modulestore.xml_importer import import_course_from_xml from xmodule.modulestore.xml_importer import import_course_from_xml
from milestones.tests.utils import MilestonesTestCaseMixin
from ..testutils import ( from ..testutils import (
MobileAPITestCase, MobileCourseAccessTestMixin, MobileAuthTestMixin MobileAPITestCase, MobileCourseAccessTestMixin, MobileAuthTestMixin
) )
@ddt.ddt @ddt.ddt
class TestUpdates(MobileAPITestCase, MobileAuthTestMixin, MobileCourseAccessTestMixin): class TestUpdates(MobileAPITestCase, MobileAuthTestMixin, MobileCourseAccessTestMixin, MilestonesTestCaseMixin):
""" """
Tests for /api/mobile/v0.5/course_info/{course_id}/updates Tests for /api/mobile/v0.5/course_info/{course_id}/updates
""" """
...@@ -82,7 +84,7 @@ class TestUpdates(MobileAPITestCase, MobileAuthTestMixin, MobileCourseAccessTest ...@@ -82,7 +84,7 @@ class TestUpdates(MobileAPITestCase, MobileAuthTestMixin, MobileCourseAccessTest
self.assertIn("Update" + str(num), update_data['content']) self.assertIn("Update" + str(num), update_data['content'])
class TestHandouts(MobileAPITestCase, MobileAuthTestMixin, MobileCourseAccessTestMixin): class TestHandouts(MobileAPITestCase, MobileAuthTestMixin, MobileCourseAccessTestMixin, MilestonesTestCaseMixin):
""" """
Tests for /api/mobile/v0.5/course_info/{course_id}/handouts Tests for /api/mobile/v0.5/course_info/{course_id}/handouts
""" """
......
...@@ -9,7 +9,6 @@ from courseware.tests.test_entrance_exam import answer_entrance_exam_problem, ad ...@@ -9,7 +9,6 @@ from courseware.tests.test_entrance_exam import answer_entrance_exam_problem, ad
from util.milestones_helpers import ( from util.milestones_helpers import (
add_prerequisite_course, add_prerequisite_course,
fulfill_course_milestone, fulfill_course_milestone,
seed_milestone_relationship_types,
) )
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
...@@ -82,7 +81,6 @@ class MobileAPIMilestonesMixin(object): ...@@ -82,7 +81,6 @@ class MobileAPIMilestonesMixin(object):
def _add_entrance_exam(self): def _add_entrance_exam(self):
""" Sets up entrance exam """ """ Sets up entrance exam """
seed_milestone_relationship_types()
self.course.entrance_exam_enabled = True self.course.entrance_exam_enabled = True
self.entrance_exam = ItemFactory.create( # pylint: disable=attribute-defined-outside-init self.entrance_exam = ItemFactory.create( # pylint: disable=attribute-defined-outside-init
...@@ -108,7 +106,6 @@ class MobileAPIMilestonesMixin(object): ...@@ -108,7 +106,6 @@ class MobileAPIMilestonesMixin(object):
def _add_prerequisite_course(self): def _add_prerequisite_course(self):
""" Helper method to set up the prerequisite course """ """ Helper method to set up the prerequisite course """
seed_milestone_relationship_types()
self.prereq_course = CourseFactory.create() # pylint: disable=attribute-defined-outside-init self.prereq_course = CourseFactory.create() # pylint: disable=attribute-defined-outside-init
add_prerequisite_course(self.course.id, self.prereq_course.id) add_prerequisite_course(self.course.id, self.prereq_course.id)
......
...@@ -23,10 +23,8 @@ from courseware.access_response import ( ...@@ -23,10 +23,8 @@ from courseware.access_response import (
from course_modes.models import CourseMode from course_modes.models import CourseMode
from openedx.core.lib.courses import course_image_url from openedx.core.lib.courses import course_image_url
from student.models import CourseEnrollment from student.models import CourseEnrollment
from util.milestones_helpers import ( from util.milestones_helpers import set_prerequisite_courses
set_prerequisite_courses, from milestones.tests.utils import MilestonesTestCaseMixin
seed_milestone_relationship_types,
)
from xmodule.course_module import DEFAULT_START_DATE from xmodule.course_module import DEFAULT_START_DATE
from xmodule.modulestore.tests.factories import ItemFactory, CourseFactory from xmodule.modulestore.tests.factories import ItemFactory, CourseFactory
from util.testing import UrlResetMixin from util.testing import UrlResetMixin
...@@ -66,7 +64,8 @@ class TestUserInfoApi(MobileAPITestCase, MobileAuthTestMixin): ...@@ -66,7 +64,8 @@ class TestUserInfoApi(MobileAPITestCase, MobileAuthTestMixin):
@ddt.ddt @ddt.ddt
class TestUserEnrollmentApi(UrlResetMixin, MobileAPITestCase, MobileAuthUserTestMixin, MobileCourseAccessTestMixin): class TestUserEnrollmentApi(UrlResetMixin, MobileAPITestCase, MobileAuthUserTestMixin,
MobileCourseAccessTestMixin, MilestonesTestCaseMixin):
""" """
Tests for /api/mobile/v0.5/users/<user_name>/course_enrollments/ Tests for /api/mobile/v0.5/users/<user_name>/course_enrollments/
""" """
...@@ -130,7 +129,6 @@ class TestUserEnrollmentApi(UrlResetMixin, MobileAPITestCase, MobileAuthUserTest ...@@ -130,7 +129,6 @@ class TestUserEnrollmentApi(UrlResetMixin, MobileAPITestCase, MobileAuthUserTest
settings.FEATURES, {'ENABLE_PREREQUISITE_COURSES': True, 'MILESTONES_APP': True, 'DISABLE_START_DATES': False} settings.FEATURES, {'ENABLE_PREREQUISITE_COURSES': True, 'MILESTONES_APP': True, 'DISABLE_START_DATES': False}
) )
def test_courseware_access(self): def test_courseware_access(self):
seed_milestone_relationship_types()
self.login() self.login()
course_with_prereq = CourseFactory.create(start=self.LAST_WEEK, mobile_available=True) course_with_prereq = CourseFactory.create(start=self.LAST_WEEK, mobile_available=True)
...@@ -315,7 +313,8 @@ class CourseStatusAPITestCase(MobileAPITestCase): ...@@ -315,7 +313,8 @@ class CourseStatusAPITestCase(MobileAPITestCase):
) )
class TestCourseStatusGET(CourseStatusAPITestCase, MobileAuthUserTestMixin, MobileCourseAccessTestMixin): class TestCourseStatusGET(CourseStatusAPITestCase, MobileAuthUserTestMixin,
MobileCourseAccessTestMixin, MilestonesTestCaseMixin):
""" """
Tests for GET of /api/mobile/v0.5/users/<user_name>/course_status_info/{course_id} Tests for GET of /api/mobile/v0.5/users/<user_name>/course_status_info/{course_id}
""" """
...@@ -333,7 +332,8 @@ class TestCourseStatusGET(CourseStatusAPITestCase, MobileAuthUserTestMixin, Mobi ...@@ -333,7 +332,8 @@ class TestCourseStatusGET(CourseStatusAPITestCase, MobileAuthUserTestMixin, Mobi
) )
class TestCourseStatusPATCH(CourseStatusAPITestCase, MobileAuthUserTestMixin, MobileCourseAccessTestMixin): class TestCourseStatusPATCH(CourseStatusAPITestCase, MobileAuthUserTestMixin,
MobileCourseAccessTestMixin, MilestonesTestCaseMixin):
""" """
Tests for PATCH of /api/mobile/v0.5/users/<user_name>/course_status_info/{course_id} Tests for PATCH of /api/mobile/v0.5/users/<user_name>/course_status_info/{course_id}
""" """
......
...@@ -19,6 +19,8 @@ from openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory ...@@ -19,6 +19,8 @@ from openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory
from openedx.core.djangoapps.course_groups.models import CourseUserGroupPartitionGroup from openedx.core.djangoapps.course_groups.models import CourseUserGroupPartitionGroup
from openedx.core.djangoapps.course_groups.cohorts import add_user_to_cohort, remove_user_from_cohort from openedx.core.djangoapps.course_groups.cohorts import add_user_to_cohort, remove_user_from_cohort
from milestones.tests.utils import MilestonesTestCaseMixin
from ..testutils import MobileAPITestCase, MobileAuthTestMixin, MobileCourseAccessTestMixin from ..testutils import MobileAPITestCase, MobileAuthTestMixin, MobileCourseAccessTestMixin
...@@ -407,9 +409,8 @@ class TestNonStandardCourseStructure(MobileAPITestCase, TestVideoAPIMixin): ...@@ -407,9 +409,8 @@ class TestNonStandardCourseStructure(MobileAPITestCase, TestVideoAPIMixin):
@ddt.ddt @ddt.ddt
class TestVideoSummaryList( class TestVideoSummaryList(TestVideoAPITestCase, MobileAuthTestMixin, MobileCourseAccessTestMixin,
TestVideoAPITestCase, MobileAuthTestMixin, MobileCourseAccessTestMixin, TestVideoAPIMixin # pylint: disable=bad-continuation TestVideoAPIMixin, MilestonesTestCaseMixin):
):
""" """
Tests for /api/mobile/v0.5/video_outlines/courses/{course_id}.. Tests for /api/mobile/v0.5/video_outlines/courses/{course_id}..
""" """
...@@ -863,9 +864,8 @@ class TestVideoSummaryList( ...@@ -863,9 +864,8 @@ class TestVideoSummaryList(
) )
class TestTranscriptsDetail( class TestTranscriptsDetail(TestVideoAPITestCase, MobileAuthTestMixin, MobileCourseAccessTestMixin,
TestVideoAPITestCase, MobileAuthTestMixin, MobileCourseAccessTestMixin, TestVideoAPIMixin # pylint: disable=bad-continuation TestVideoAPIMixin, MilestonesTestCaseMixin):
):
""" """
Tests for /api/mobile/v0.5/video_outlines/transcripts/{course_id}.. Tests for /api/mobile/v0.5/video_outlines/transcripts/{course_id}..
""" """
......
...@@ -1933,6 +1933,12 @@ INSTALLED_APPS = ( ...@@ -1933,6 +1933,12 @@ INSTALLED_APPS = (
# Credentials support # Credentials support
'openedx.core.djangoapps.credentials', 'openedx.core.djangoapps.credentials',
# edx-milestones service
'milestones',
# Gating of course content
'gating.apps.GatingConfig',
) )
# Migrations which are not in the standard module "migrations" # Migrations which are not in the standard module "migrations"
...@@ -2427,9 +2433,6 @@ OPTIONAL_APPS = ( ...@@ -2427,9 +2433,6 @@ OPTIONAL_APPS = (
# edxval # edxval
'edxval', 'edxval',
# milestones
'milestones',
# edX Proctoring # edX Proctoring
'edx_proctoring', 'edx_proctoring',
......
"""
API for the gating djangoapp
"""
import logging
from django.utils.translation import ugettext as _
from milestones import api as milestones_api
from opaque_keys.edx.keys import UsageKey
from xmodule.modulestore.django import modulestore
from openedx.core.lib.gating.exceptions import GatingValidationError
log = logging.getLogger(__name__)
# This is used to namespace gating-specific milestones
GATING_NAMESPACE_QUALIFIER = '.gating'
def _get_prerequisite_milestone(prereq_content_key):
"""
Get gating milestone associated with the given content usage key.
Arguments:
prereq_content_key (str|UsageKey): The content usage key
Returns:
dict: Milestone dict
"""
milestones = milestones_api.get_milestones("{usage_key}{qualifier}".format(
usage_key=prereq_content_key,
qualifier=GATING_NAMESPACE_QUALIFIER
))
if not milestones:
log.warning("Could not find gating milestone for prereq UsageKey %s", prereq_content_key)
return None
if len(milestones) > 1:
# We should only ever have one gating milestone per UsageKey
# Log a warning here and pick the first one
log.warning("Multiple gating milestones found for prereq UsageKey %s", prereq_content_key)
return milestones[0]
def _validate_min_score(min_score):
"""
Validates the minimum score entered by the Studio user.
Arguments:
min_score (str|int): The minimum score to validate
Returns:
None
Raises:
GatingValidationError: If the minimum score is not valid
"""
if min_score:
message = _("%(min_score)s is not a valid grade percentage") % {'min_score': min_score}
try:
min_score = int(min_score)
except ValueError:
raise GatingValidationError(message)
if min_score < 0 or min_score > 100:
raise GatingValidationError(message)
def gating_enabled(default=None):
"""
Decorator that checks the enable_subsection_gating course flag to
see if the subsection gating feature is active for a given course.
If not, calls to the decorated function return the specified default value.
Arguments:
default (ANY): The value to return if the enable_subsection_gating course flag is False
Returns:
ANY: The specified default value if the gating feature is off,
otherwise the result of the decorated function
"""
def wrap(f): # pylint: disable=missing-docstring
def function_wrapper(course, *args): # pylint: disable=missing-docstring
if not course.enable_subsection_gating:
return default
return f(course, *args)
return function_wrapper
return wrap
def find_gating_milestones(course_key, content_key=None, relationship=None, user=None):
"""
Finds gating milestone dicts related to the given supplied parameters.
Arguments:
course_key (str|CourseKey): The course key
content_key (str|UsageKey): The content usage key
relationship (str): The relationship type (e.g. 'requires')
user (dict): The user dict (e.g. {'id': 4})
Returns:
list: A list of milestone dicts
"""
return [
m for m in milestones_api.get_course_content_milestones(course_key, content_key, relationship, user)
if GATING_NAMESPACE_QUALIFIER in m.get('namespace')
]
def get_gating_milestone(course_key, content_key, relationship):
"""
Gets a single gating milestone dict related to the given supplied parameters.
Arguments:
course_key (str|CourseKey): The course key
content_key (str|UsageKey): The content usage key
relationship (str): The relationship type (e.g. 'requires')
Returns:
dict or None: The gating milestone dict or None
"""
try:
return find_gating_milestones(course_key, content_key, relationship)[0]
except IndexError:
return None
def get_prerequisites(course_key):
"""
Find all the gating milestones associated with a course and the
XBlock info associated with those gating milestones.
Arguments:
course_key (str|CourseKey): The course key
Returns:
list: A list of dicts containing the milestone and associated XBlock info
"""
course_content_milestones = find_gating_milestones(course_key)
milestones_by_block_id = {}
block_ids = []
for milestone in course_content_milestones:
prereq_content_key = milestone['namespace'].replace(GATING_NAMESPACE_QUALIFIER, '')
block_id = UsageKey.from_string(prereq_content_key).block_id
block_ids.append(block_id)
milestones_by_block_id[block_id] = milestone
result = []
for block in modulestore().get_items(course_key, qualifiers={'name': block_ids}):
milestone = milestones_by_block_id.get(block.location.block_id)
if milestone:
milestone['block_display_name'] = block.display_name
milestone['block_usage_key'] = unicode(block.location)
result.append(milestone)
return result
def add_prerequisite(course_key, prereq_content_key):
"""
Creates a new Milestone and CourseContentMilestone indicating that
the given course content fulfills a prerequisite for gating
Arguments:
course_key (str|CourseKey): The course key
prereq_content_key (str|UsageKey): The prerequisite content usage key
Returns:
None
"""
milestone = milestones_api.add_milestone(
{
'name': _('Gating milestone for {usage_key}').format(usage_key=unicode(prereq_content_key)),
'namespace': "{usage_key}{qualifier}".format(
usage_key=prereq_content_key,
qualifier=GATING_NAMESPACE_QUALIFIER
),
'description': _('System defined milestone'),
},
propagate=False
)
milestones_api.add_course_content_milestone(course_key, prereq_content_key, 'fulfills', milestone)
def remove_prerequisite(prereq_content_key):
"""
Removes the Milestone and CourseContentMilestones related to the gating
prerequisite which the given course content fulfills
Arguments:
prereq_content_key (str|UsageKey): The prerequisite content usage key
Returns:
None
"""
milestones = milestones_api.get_milestones("{usage_key}{qualifier}".format(
usage_key=prereq_content_key,
qualifier=GATING_NAMESPACE_QUALIFIER
))
for milestone in milestones:
milestones_api.remove_milestone(milestone.get('id'))
def is_prerequisite(course_key, prereq_content_key):
"""
Returns True if there is at least one CourseContentMilestone
which the given course content fulfills
Arguments:
course_key (str|CourseKey): The course key
prereq_content_key (str|UsageKey): The prerequisite content usage key
Returns:
bool: True if the course content fulfills a CourseContentMilestone, otherwise False
"""
return get_gating_milestone(
course_key,
prereq_content_key,
'fulfills'
) is not None
def set_required_content(course_key, gated_content_key, prereq_content_key, min_score):
"""
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`
milestone relationship.
Arguments:
course_key (str|CourseKey): The course key
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
Returns:
None
"""
milestone = None
for gating_milestone in find_gating_milestones(course_key, gated_content_key, 'requires'):
if not prereq_content_key or prereq_content_key not in gating_milestone.get('namespace'):
milestones_api.remove_course_content_milestone(course_key, gated_content_key, gating_milestone)
else:
milestone = gating_milestone
if prereq_content_key:
_validate_min_score(min_score)
requirements = {'min_score': min_score}
if not milestone:
milestone = _get_prerequisite_milestone(prereq_content_key)
milestones_api.add_course_content_milestone(course_key, gated_content_key, 'requires', milestone, requirements)
def get_required_content(course_key, gated_content_key):
"""
Returns the prerequisite content usage key and minimum score needed for fulfillment
of that prerequisite for the given gated_content_key.
Args:
course_key (str|CourseKey): The course 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
"""
milestone = get_gating_milestone(course_key, gated_content_key, 'requires')
if milestone:
return (
milestone.get('namespace', '').replace(GATING_NAMESPACE_QUALIFIER, ''),
milestone.get('requirements', {}).get('min_score')
)
else:
return None, None
@gating_enabled(default=[])
def get_gated_content(course, user):
"""
Returns the unfulfilled gated content usage keys in the given course.
Arguments:
course (CourseDescriptor): The course
user (User): The user
Returns:
list: The list of gated content usage keys for the given course
"""
# Get the unfulfilled gating milestones for this course, for this user
return [
m['content_id'] for m in find_gating_milestones(
course.id,
None,
'requires',
{'id': user.id}
)
]
"""
Exceptions for the course gating feature
"""
class GatingValidationError(Exception):
"""
Exception class for validation errors related to course gating information
"""
pass
"""
Tests for the gating API
"""
from mock import patch, MagicMock
from ddt import ddt, data
from milestones.tests.utils import MilestonesTestCaseMixin
from milestones import api as milestones_api
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, TEST_DATA_SPLIT_MODULESTORE
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
from openedx.core.lib.gating import api as gating_api
from openedx.core.lib.gating.exceptions import GatingValidationError
@ddt
@patch.dict('django.conf.settings.FEATURES', {'MILESTONES_APP': True})
class TestGatingApi(ModuleStoreTestCase, MilestonesTestCaseMixin):
"""
Tests for the gating API
"""
MODULESTORE = TEST_DATA_SPLIT_MODULESTORE
def setUp(self):
"""
Initial data setup
"""
super(TestGatingApi, self).setUp()
# create course
self.course = CourseFactory.create(
org='edX',
number='EDX101',
run='EDX101_RUN1',
display_name='edX 101'
)
self.course.enable_subsection_gating = True
self.course.save()
# create chapter
self.chapter1 = ItemFactory.create(
parent_location=self.course.location,
category='chapter',
display_name='untitled chapter 1'
)
# create sequentials
self.seq1 = ItemFactory.create(
parent_location=self.chapter1.location,
category='sequential',
display_name='untitled sequential 1'
)
self.seq2 = ItemFactory.create(
parent_location=self.chapter1.location,
category='sequential',
display_name='untitled sequential 2'
)
self.generic_milestone = {
'name': 'Test generic milestone',
'namespace': unicode(self.seq1.location),
}
@patch('openedx.core.lib.gating.api.log.warning')
def test_get_prerequisite_milestone_returns_none(self, mock_log):
""" Test test_get_prerequisite_milestone_returns_none """
prereq = gating_api._get_prerequisite_milestone(self.seq1.location) # pylint: disable=protected-access
self.assertIsNone(prereq)
self.assertTrue(mock_log.called)
def test_get_prerequisite_milestone_returns_milestone(self):
""" Test test_get_prerequisite_milestone_returns_milestone """
gating_api.add_prerequisite(self.course.id, self.seq1.location)
prereq = gating_api._get_prerequisite_milestone(self.seq1.location) # pylint: disable=protected-access
self.assertIsNotNone(prereq)
@data('', '0', '50', '100')
def test_validate_min_score_is_valid(self, min_score):
""" Test test_validate_min_score_is_valid """
self.assertIsNone(gating_api._validate_min_score(min_score)) # pylint: disable=protected-access
@data('abc', '-10', '110')
def test_validate_min_score_raises(self, min_score):
""" Test test_validate_min_score_non_integer """
with self.assertRaises(GatingValidationError):
gating_api._validate_min_score(min_score) # pylint: disable=protected-access
def test_find_gating_milestones(self):
""" Test test_find_gating_milestones """
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)
milestone = milestones_api.add_milestone(self.generic_milestone)
milestones_api.add_course_content_milestone(self.course.id, self.seq1.location, 'fulfills', milestone)
self.assertEqual(len(gating_api.find_gating_milestones(self.course.id, self.seq1.location, 'fulfills')), 1)
self.assertEqual(len(gating_api.find_gating_milestones(self.course.id, self.seq1.location, 'requires')), 0)
self.assertEqual(len(gating_api.find_gating_milestones(self.course.id, self.seq2.location, 'fulfills')), 0)
self.assertEqual(len(gating_api.find_gating_milestones(self.course.id, self.seq2.location, 'requires')), 1)
def test_get_gating_milestone_not_none(self):
""" Test test_get_gating_milestone_not_none """
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)
self.assertIsNotNone(gating_api.get_gating_milestone(self.course.id, self.seq1.location, 'fulfills'))
self.assertIsNotNone(gating_api.get_gating_milestone(self.course.id, self.seq2.location, 'requires'))
def test_get_gating_milestone_is_none(self):
""" Test test_get_gating_milestone_is_none """
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)
self.assertIsNone(gating_api.get_gating_milestone(self.course.id, self.seq1.location, 'requires'))
self.assertIsNone(gating_api.get_gating_milestone(self.course.id, self.seq2.location, 'fulfills'))
def test_prerequisites(self):
""" Test test_prerequisites """
gating_api.add_prerequisite(self.course.id, self.seq1.location)
prereqs = gating_api.get_prerequisites(self.course.id)
self.assertEqual(len(prereqs), 1)
self.assertEqual(prereqs[0]['block_display_name'], self.seq1.display_name)
self.assertEqual(prereqs[0]['block_usage_key'], unicode(self.seq1.location))
self.assertTrue(gating_api.is_prerequisite(self.course.id, self.seq1.location))
gating_api.remove_prerequisite(self.seq1.location)
self.assertEqual(len(gating_api.get_prerequisites(self.course.id)), 0)
self.assertFalse(gating_api.is_prerequisite(self.course.id, self.seq1.location))
def test_required_content(self):
""" 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)
prereq_content_key, min_score = 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)
gating_api.set_required_content(self.course.id, self.seq2.location, None, None)
prereq_content_key, min_score = gating_api.get_required_content(self.course.id, self.seq2.location)
self.assertIsNone(prereq_content_key)
self.assertIsNone(min_score)
def test_get_gated_content(self):
""" Test test_get_gated_content """
mock_user = MagicMock()
mock_user.id.return_value = 1
self.assertEqual(gating_api.get_gated_content(self.course, mock_user), [])
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)
milestone = milestones_api.get_course_content_milestones(self.course.id, self.seq2.location, 'requires')[0]
self.assertEqual(gating_api.get_gated_content(self.course, mock_user), [unicode(self.seq2.location)])
milestones_api.add_user_milestone({'id': mock_user.id}, milestone)
self.assertEqual(gating_api.get_gated_content(self.course, mock_user), [])
...@@ -87,7 +87,7 @@ git+https://github.com/edx/edx-val.git@0.0.8#egg=edxval==0.0.8 ...@@ -87,7 +87,7 @@ git+https://github.com/edx/edx-val.git@0.0.8#egg=edxval==0.0.8
-e git+https://github.com/pmitros/RecommenderXBlock.git@518234bc354edbfc2651b9e534ddb54f96080779#egg=recommender-xblock -e git+https://github.com/pmitros/RecommenderXBlock.git@518234bc354edbfc2651b9e534ddb54f96080779#egg=recommender-xblock
-e git+https://github.com/pmitros/RateXBlock.git@367e19c0f6eac8a5f002fd0f1559555f8e74bfff#egg=rate-xblock -e git+https://github.com/pmitros/RateXBlock.git@367e19c0f6eac8a5f002fd0f1559555f8e74bfff#egg=rate-xblock
-e git+https://github.com/pmitros/DoneXBlock.git@857bf365f19c904d7e48364428f6b93ff153fabd#egg=done-xblock -e git+https://github.com/pmitros/DoneXBlock.git@857bf365f19c904d7e48364428f6b93ff153fabd#egg=done-xblock
-e git+https://github.com/edx/edx-milestones.git@release-2015-11-17#egg=edx-milestones==0.1.5 git+https://github.com/edx/edx-milestones.git@v0.1.6#egg=edx-milestones==0.1.6
git+https://github.com/edx/edx-lint.git@v0.4.1#egg=edx_lint==0.4.1 git+https://github.com/edx/edx-lint.git@v0.4.1#egg=edx_lint==0.4.1
git+https://github.com/edx/xblock-utils.git@v1.0.2#egg=xblock-utils==1.0.2 git+https://github.com/edx/xblock-utils.git@v1.0.2#egg=xblock-utils==1.0.2
-e git+https://github.com/edx-solutions/xblock-google-drive.git@138e6fa0bf3a2013e904a085b9fed77dab7f3f21#egg=xblock-google-drive -e git+https://github.com/edx-solutions/xblock-google-drive.git@138e6fa0bf3a2013e904a085b9fed77dab7f3f21#egg=xblock-google-drive
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment