diff --git a/.jshintrc b/.jshintrc index d3b6256345..e23416c9d3 100644 --- a/.jshintrc +++ b/.jshintrc @@ -139,8 +139,11 @@ "appendSetFixtures", "spyOnEvent", + // Django i18n catalog globals + "interpolate", + "gettext", + // Miscellaneous globals - "JSON", - "gettext" + "JSON" ] } diff --git a/cms/djangoapps/contentstore/signals.py b/cms/djangoapps/contentstore/signals.py index becbe12fae..7f265abb3b 100644 --- a/cms/djangoapps/contentstore/signals.py +++ b/cms/djangoapps/contentstore/signals.py @@ -5,10 +5,12 @@ from pytz import UTC 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.proctoring import register_special_exams 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) @@ -48,3 +50,30 @@ def listen_for_library_update(sender, library_key, **kwargs): # pylint: disable from .tasks import update_library_index 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) diff --git a/cms/djangoapps/contentstore/tests/test_course_settings.py b/cms/djangoapps/contentstore/tests/test_course_settings.py index a5db1b297c..2b300b74b4 100644 --- a/cms/djangoapps/contentstore/tests/test_course_settings.py +++ b/cms/djangoapps/contentstore/tests/test_course_settings.py @@ -26,7 +26,7 @@ from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore.django import modulestore from xmodule.modulestore.tests.factories import CourseFactory from xmodule.tabs import InvalidTabsException -from util.milestones_helpers import seed_milestone_relationship_types +from milestones.tests.utils import MilestonesTestCaseMixin from .utils import CourseTestCase @@ -69,7 +69,7 @@ class CourseSettingsEncoderTest(CourseTestCase): @ddt.ddt -class CourseDetailsViewTest(CourseTestCase): +class CourseDetailsViewTest(CourseTestCase, MilestonesTestCaseMixin): """ Tests for modifying content on the first course settings page (course dates, overview, etc.). """ @@ -156,14 +156,12 @@ class CourseDetailsViewTest(CourseTestCase): @mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_PREREQUISITE_COURSES': True, 'MILESTONES_APP': True}) def test_pre_requisite_course_list_present(self): - seed_milestone_relationship_types() settings_details_url = get_url(self.course.id) response = self.client.get_html(settings_details_url) self.assertContains(response, "Prerequisite Course") @mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_PREREQUISITE_COURSES': True, 'MILESTONES_APP': True}) def test_pre_requisite_course_update_and_fetch(self): - seed_milestone_relationship_types() url = get_url(self.course.id) resp = self.client.get_json(url) course_detail_json = json.loads(resp.content) @@ -191,7 +189,6 @@ class CourseDetailsViewTest(CourseTestCase): @mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_PREREQUISITE_COURSES': True, 'MILESTONES_APP': True}) def test_invalid_pre_requisite_course(self): - seed_milestone_relationship_types() url = get_url(self.course.id) resp = self.client.get_json(url) course_detail_json = json.loads(resp.content) @@ -254,7 +251,6 @@ class CourseDetailsViewTest(CourseTestCase): @unittest.skipUnless(settings.FEATURES.get('ENTRANCE_EXAMS', False), True) def test_entrance_exam_created_updated_and_deleted_successfully(self): - seed_milestone_relationship_types() settings_details_url = get_url(self.course.id) data = { 'entrance_exam_enabled': 'true', @@ -305,7 +301,6 @@ class CourseDetailsViewTest(CourseTestCase): 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 """ - seed_milestone_relationship_types() settings_details_url = get_url(self.course.id) test_data_1 = { 'entrance_exam_enabled': 'true', diff --git a/cms/djangoapps/contentstore/tests/test_gating.py b/cms/djangoapps/contentstore/tests/test_gating.py new file mode 100644 index 0000000000..beacd7c240 --- /dev/null +++ b/cms/djangoapps/contentstore/tests/test_gating.py @@ -0,0 +1,57 @@ +""" +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) diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py index ca6a6160af..841475afce 100644 --- a/cms/djangoapps/contentstore/views/item.py +++ b/cms/djangoapps/contentstore/views/item.py @@ -3,44 +3,26 @@ from __future__ import absolute_import import hashlib import logging -from uuid import uuid4 -from datetime import datetime -from pytz import UTC -import json - from collections import OrderedDict +from datetime import datetime from functools import partial -from static_replace import replace_static_urls -from openedx.core.lib.xblock_utils import wrap_xblock, request_token +from uuid import uuid4 import dogstats_wrapper as dog_stats_api from django.conf import settings -from django.core.exceptions import PermissionDenied from django.contrib.auth.decorators import login_required +from django.contrib.auth.models import User +from django.core.exceptions import PermissionDenied from django.http import HttpResponseBadRequest, HttpResponse, Http404 from django.utils.translation import ugettext as _ from django.views.decorators.http import require_http_methods - +from opaque_keys.edx.keys import CourseKey +from opaque_keys.edx.locator import LibraryUsageLocator +from pytz import UTC from xblock.fields import Scope from xblock.fragment import Fragment -import xmodule -from xmodule.tabs import CourseTabList -from xmodule.modulestore import ModuleStoreEnum, EdxJSONEncoder -from xmodule.modulestore.django import modulestore -from xmodule.modulestore.exceptions import ItemNotFoundError, InvalidLocationError -from xmodule.modulestore.inheritance import own_metadata -from xmodule.modulestore.draft_and_published import DIRECT_ONLY_CATEGORIES -from xmodule.x_module import PREVIEW_VIEWS, STUDIO_VIEW, STUDENT_VIEW, DEPRECATION_VSCOMPAT_EVENT - -from xmodule.course_module import DEFAULT_START_DATE -from django.contrib.auth.models import User -from util.date_utils import get_default_time_display - -from util.json_request import expect_json, JsonResponse -from util.milestones_helpers import is_entrance_exams_enabled - -from student.auth import has_studio_write_access, has_studio_read_access +from cms.lib.xblock.authoring_mixin import VISIBILITY_VIEW from contentstore.utils import ( find_release_date_source, find_staff_lock_source, is_currently_visible_to_students, ancestor_has_staff_lock, has_children_visible_to_specific_content_groups, @@ -49,11 +31,23 @@ from contentstore.utils import ( from contentstore.views.helpers import is_unit, xblock_studio_url, xblock_primary_child_category, \ xblock_type_display_name, get_parent_xblock, create_xblock, usage_key_with_run from contentstore.views.preview import get_preview_fragment +from openedx.core.lib.gating import api as gating_api from edxmako.shortcuts import render_to_string from models.settings.course_grading import CourseGradingModel -from opaque_keys.edx.keys import CourseKey -from opaque_keys.edx.locator import LibraryUsageLocator -from cms.lib.xblock.authoring_mixin import VISIBILITY_VIEW +from openedx.core.lib.xblock_utils import wrap_xblock, request_token +from static_replace import replace_static_urls +from student.auth import has_studio_write_access, has_studio_read_access +from util.date_utils import get_default_time_display +from util.json_request import expect_json, JsonResponse +from util.milestones_helpers import is_entrance_exams_enabled +from xmodule.course_module import DEFAULT_START_DATE +from xmodule.modulestore import ModuleStoreEnum, EdxJSONEncoder +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.draft_and_published import DIRECT_ONLY_CATEGORIES +from xmodule.modulestore.exceptions import ItemNotFoundError, InvalidLocationError +from xmodule.modulestore.inheritance import own_metadata +from xmodule.tabs import CourseTabList +from xmodule.x_module import PREVIEW_VIEWS, STUDIO_VIEW, STUDENT_VIEW, DEPRECATION_VSCOMPAT_EVENT __all__ = [ 'orphan_handler', 'xblock_handler', 'xblock_view_handler', 'xblock_outline_handler', 'xblock_container_handler' @@ -110,6 +104,10 @@ def xblock_handler(request, usage_key_string): to None! Absent ones will be left alone. :nullout: which metadata fields to set to None :graderType: change how this unit is graded + :isPrereq: Set this xblock as a prerequisite which can be used to limit access to other xblocks + :prereqUsageKey: Use the xblock identified by this usage key to limit access to this xblock + :prereqMinScore: The minimum score that needs to be achieved on the prerequisite xblock + identifed by prereqUsageKey :publish: can be: 'make_public': publish the content 'republish': publish this item *only* if it was previously published @@ -163,6 +161,9 @@ def xblock_handler(request, usage_key_string): metadata=request.json.get('metadata'), nullout=request.json.get('nullout'), grader_type=request.json.get('graderType'), + is_prereq=request.json.get('isPrereq'), + prereq_usage_key=request.json.get('prereqUsageKey'), + prereq_min_score=request.json.get('prereqMinScore'), publish=request.json.get('publish'), ) elif request.method in ('PUT', 'POST'): @@ -379,7 +380,7 @@ def _update_with_callback(xblock, user, old_metadata=None, old_content=None): def _save_xblock(user, xblock, data=None, children_strings=None, metadata=None, nullout=None, - grader_type=None, publish=None): + grader_type=None, is_prereq=None, prereq_usage_key=None, prereq_min_score=None, publish=None): """ Saves xblock w/ its fields. Has special processing for grader_type, publish, and nullout and Nones in metadata. nullout means to truly set the field to None whereas nones in metadata mean to unset them (so they revert @@ -479,8 +480,8 @@ def _save_xblock(user, xblock, data=None, children_strings=None, metadata=None, xblock = _update_with_callback(xblock, user, old_metadata, old_content) # for static tabs, their containing course also records their display name + course = store.get_course(xblock.location.course_key) if xblock.location.category == 'static_tab': - course = store.get_course(xblock.location.course_key) # find the course's reference to this tab and update the name. static_tab = CourseTabList.get_tab_by_slug(course.tabs, xblock.location.name) # only update if changed @@ -497,9 +498,23 @@ def _save_xblock(user, xblock, data=None, children_strings=None, metadata=None, if grader_type is not None: result.update(CourseGradingModel.update_section_grader_type(xblock, grader_type, user)) - # If publish is set to 'republish' and this item is not in direct only categories and has previously been published, - # then this item should be republished. This is used by staff locking to ensure that changing the draft - # value of the staff lock will also update the published version, but only at the unit level. + # Save gating info + if xblock.category == 'sequential' and course.enable_subsection_gating: + if is_prereq is not None: + if is_prereq: + gating_api.add_prerequisite(xblock.location.course_key, xblock.location) + else: + gating_api.remove_prerequisite(xblock.location) + result['is_prereq'] = is_prereq + + if prereq_usage_key is not None: + gating_api.set_required_content( + xblock.location.course_key, xblock.location, prereq_usage_key, prereq_min_score + ) + + # If publish is set to 'republish' and this item is not in direct only categories and has previously been + # published, then this item should be republished. This is used by staff locking to ensure that changing the + # draft value of the staff lock will also update the published version, but only at the unit level. if publish == 'republish' and xblock.category not in DIRECT_ONLY_CATEGORIES: if modulestore().has_published_version(xblock): publish = 'make_public' @@ -751,6 +766,35 @@ def _get_module_info(xblock, rewrite_static_links=True, include_ancestor_info=Fa return xblock_info +def _get_gating_info(course, xblock): + """ + Returns a dict containing gating information for the given xblock which + can be added to xblock info responses. + + Arguments: + course (CourseDescriptor): The course + xblock (XBlock): The xblock + + Returns: + dict: Gating information + """ + info = {} + if xblock.category == 'sequential' and course.enable_subsection_gating: + info["is_prereq"] = gating_api.is_prerequisite(course.id, xblock.location) + info["prereqs"] = [ + p for p in course.gating_prerequisites if unicode(xblock.location) not in p['namespace'] + ] + prereq, prereq_min_score = gating_api.get_required_content( + course.id, + xblock.location + ) + info["prereq"] = prereq + info["prereq_min_score"] = prereq_min_score + if prereq: + info["visibility_state"] = VisibilityState.gated + return info + + def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=False, include_child_info=False, course_outline=False, include_children_predicate=NEVER, parent_xblock=None, graders=None, user=None, course=None): @@ -873,9 +917,14 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F "default_time_limit_minutes": xblock.default_time_limit_minutes }) - # Entrance exam subsection should be hidden. in_entrance_exam is inherited metadata, all children will have it. - if xblock.category == 'sequential' and getattr(xblock, "in_entrance_exam", False): - xblock_info["is_header_visible"] = False + # Update with gating info + xblock_info.update(_get_gating_info(course, xblock)) + + if xblock.category == 'sequential': + # Entrance exam subsection should be hidden. in_entrance_exam is + # inherited metadata, all children will have it. + if getattr(xblock, "in_entrance_exam", False): + xblock_info["is_header_visible"] = False if data is not None: xblock_info["data"] = data @@ -953,12 +1002,15 @@ class VisibilityState(object): staff_only - all of the block's content is to be shown to staff only Note: staff only items do not affect their parent's state. + + gated - all of the block's content is to be shown to students only after the configured prerequisite is met """ live = 'live' ready = 'ready' unscheduled = 'unscheduled' needs_attention = 'needs_attention' staff_only = 'staff_only' + gated = 'gated' def _compute_visibility_state(xblock, child_info, is_unit_with_changes): diff --git a/cms/djangoapps/contentstore/views/tests/test_entrance_exam.py b/cms/djangoapps/contentstore/views/tests/test_entrance_exam.py index a773f5e0e2..fbf2d770bd 100644 --- a/cms/djangoapps/contentstore/views/tests/test_entrance_exam.py +++ b/cms/djangoapps/contentstore/views/tests/test_entrance_exam.py @@ -20,10 +20,11 @@ from student.tests.factories import UserFactory from util import milestones_helpers from xmodule.modulestore.django import modulestore from contentstore.views.helpers import create_xblock +from milestones.tests.utils import MilestonesTestCaseMixin @patch.dict(settings.FEATURES, {'ENTRANCE_EXAMS': True}) -class EntranceExamHandlerTests(CourseTestCase): +class EntranceExamHandlerTests(CourseTestCase, MilestonesTestCaseMixin): """ Base test class for create, save, and delete """ @@ -36,7 +37,6 @@ class EntranceExamHandlerTests(CourseTestCase): self.usage_key = self.course.location self.course_url = '/course/{}'.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() def test_entrance_exam_milestone_addition(self): diff --git a/cms/djangoapps/contentstore/views/tests/test_gating.py b/cms/djangoapps/contentstore/views/tests/test_gating.py new file mode 100644 index 0000000000..770d467ff1 --- /dev/null +++ b/cms/djangoapps/contentstore/views/tests/test_gating.py @@ -0,0 +1,140 @@ +""" +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) diff --git a/cms/djangoapps/contentstore/views/tests/test_import_export.py b/cms/djangoapps/contentstore/views/tests/test_import_export.py index b26c93407e..7568c9ca5b 100644 --- a/cms/djangoapps/contentstore/views/tests/test_import_export.py +++ b/cms/djangoapps/contentstore/views/tests/test_import_export.py @@ -26,10 +26,10 @@ from contentstore.tests.utils import CourseTestCase from openedx.core.lib.extract_tar import safetar_extractall from student import auth from student.roles import CourseInstructorRole, CourseStaffRole -from util.milestones_helpers import seed_milestone_relationship_types from models.settings.course_metadata import CourseMetadata from util import milestones_helpers from xmodule.modulestore.django import modulestore +from milestones.tests.utils import MilestonesTestCaseMixin TEST_DATA_CONTENTSTORE = copy.deepcopy(settings.CONTENTSTORE) TEST_DATA_CONTENTSTORE['DOC_STORE_CONFIG']['db'] = 'test_xcontent_%s' % uuid4().hex @@ -39,7 +39,7 @@ log = logging.getLogger(__name__) @override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE) -class ImportEntranceExamTestCase(CourseTestCase): +class ImportEntranceExamTestCase(CourseTestCase, MilestonesTestCaseMixin): """ Unit tests for importing a course with entrance exam """ @@ -51,7 +51,6 @@ class ImportEntranceExamTestCase(CourseTestCase): # Create tar test file ----------------------------------------------- # OK course with entrance exam section: - seed_milestone_relationship_types() entrance_exam_dir = tempfile.mkdtemp(dir=self.content_dir) # test course being deeper down than top of tar file embedded_exam_dir = os.path.join(entrance_exam_dir, "grandparent", "parent") diff --git a/cms/envs/common.py b/cms/envs/common.py index 281c42baa7..359d3c3dc8 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -842,6 +842,9 @@ INSTALLED_APPS = ( # Credentials support 'openedx.core.djangoapps.credentials', + + # edx-milestones service + 'milestones', ) @@ -948,9 +951,6 @@ OPTIONAL_APPS = ( # edxval 'edxval', - # milestones - 'milestones', - # Organizations App (http://github.com/edx/edx-organizations) 'organizations', ) diff --git a/cms/static/js/spec/views/pages/course_outline_spec.js b/cms/static/js/spec/views/pages/course_outline_spec.js index e617c9fbf3..58fb78f28a 100644 --- a/cms/static/js/spec/views/pages/course_outline_spec.js +++ b/cms/static/js/spec/views/pages/course_outline_spec.js @@ -67,6 +67,10 @@ define(["jquery", "common/js/spec_helpers/ajax_helpers", "common/js/components/u edited_by: 'MockUser', course_graders: ["Lab", "Howework"], has_explicit_staff_lock: false, + is_prereq: false, + prereqs: [], + prereq: '', + prereq_min_score: '', child_info: { category: 'vertical', display_name: 'Unit', @@ -216,7 +220,8 @@ define(["jquery", "common/js/spec_helpers/ajax_helpers", "common/js/components/u 'course-outline', 'xblock-string-field-editor', 'modal-button', 'basic-modal', 'course-outline-modal', 'release-date-editor', 'due-date-editor', 'grading-editor', 'publish-editor', - 'staff-lock-editor', 'settings-tab-section', 'timed-examination-preference-editor' + 'staff-lock-editor', 'settings-modal-tabs', 'timed-examination-preference-editor', + 'access-editor' ]); appendSetFixtures(mockOutlinePage); mockCourseJSON = createMockCourseJSON({}, [ @@ -580,8 +585,9 @@ define(["jquery", "common/js/spec_helpers/ajax_helpers", "common/js/components/u describe("Subsection", function() { var getDisplayNameWrapper, setEditModalValues, mockServerValuesJson, - selectDisableSpecialExams, selectGeneralSettings, selectAdvancedSettings, - selectTimedExam, selectProctoredExam, selectPracticeExam; + selectDisableSpecialExams, selectBasicSettings, selectAdvancedSettings, + selectAccessSettings, selectTimedExam, selectProctoredExam, selectPracticeExam, + selectPrerequisite, selectLastPrerequisiteSubsection; getDisplayNameWrapper = function() { return getItemHeaders('subsection').find('.wrapper-xblock-field'); @@ -598,12 +604,16 @@ define(["jquery", "common/js/spec_helpers/ajax_helpers", "common/js/components/u this.$("#id_not_timed").prop('checked', true).trigger('change'); }; - selectGeneralSettings = function() { - this.$(".modal-section .general-settings-button").click(); + selectBasicSettings = function() { + this.$(".modal-section .settings-tab-button[data-tab='basic']").click(); }; selectAdvancedSettings = function() { - this.$(".modal-section .advanced-settings-button").click(); + this.$(".modal-section .settings-tab-button[data-tab='advanced']").click(); + }; + + selectAccessSettings = function() { + this.$(".modal-section .settings-tab-button[data-tab='access']").click(); }; selectTimedExam = function(time_limit) { @@ -624,6 +634,15 @@ define(["jquery", "common/js/spec_helpers/ajax_helpers", "common/js/components/u this.$("#id_time_limit").trigger('focusout'); }; + selectPrerequisite = function() { + this.$("#is_prereq").prop('checked', true).trigger('change'); + }; + + selectLastPrerequisiteSubsection = function(minScore) { + this.$("#prereq option:last").prop('selected', true).trigger('change'); + this.$("#prereq_min_score").val(minScore).trigger('keyup'); + }; + // Contains hard-coded dates because dates are presented in different formats. mockServerValuesJson = createMockSectionJSON({ release_date: 'Jan 01, 2970 at 05:00 UTC' @@ -637,6 +656,7 @@ define(["jquery", "common/js/spec_helpers/ajax_helpers", "common/js/components/u due: "2014-07-10T00:00:00Z", has_explicit_staff_lock: true, staff_only_message: true, + is_prereq: false, "is_time_limited": true, "is_practice_exam": false, "is_proctored_exam": true, @@ -710,21 +730,63 @@ define(["jquery", "common/js/spec_helpers/ajax_helpers", "common/js/components/u collapseItemsAndVerifyState('subsection'); expandItemsAndVerifyState('subsection'); }); - - it('can show general settings', function() { + + it('can show basic settings', function() { createCourseOutlinePage(this, mockCourseJSON, false); outlinePage.$('.outline-subsection .configure-button').click(); - selectGeneralSettings(); - expect($('.modal-section .general-settings-button')).toHaveClass('active'); - expect($('.modal-section .advanced-settings-button')).not.toHaveClass('active'); + selectBasicSettings(); + expect($('.modal-section .settings-tab-button[data-tab="basic"]')).toHaveClass('active'); + expect($('.modal-section .settings-tab-button[data-tab="advanced"]')).not.toHaveClass('active'); + expect($('.modal-section .settings-tab-button[data-tab="access"]')).not.toHaveClass('active'); }); it('can show advanced settings', function() { createCourseOutlinePage(this, mockCourseJSON, false); outlinePage.$('.outline-subsection .configure-button').click(); selectAdvancedSettings(); - expect($('.modal-section .general-settings-button')).not.toHaveClass('active'); - expect($('.modal-section .advanced-settings-button')).toHaveClass('active'); + expect($('.modal-section .settings-tab-button[data-tab="basic"]')).not.toHaveClass('active'); + expect($('.modal-section .settings-tab-button[data-tab="advanced"]')).toHaveClass('active'); + expect($('.modal-section .settings-tab-button[data-tab="access"]')).not.toHaveClass('active'); + }); + + it('can show access settings', function() { + createCourseOutlinePage(this, mockCourseJSON, false); + outlinePage.$('.outline-subsection .configure-button').click(); + selectAccessSettings(); + expect($('.modal-section .settings-tab-button[data-tab="basic"]')).not.toHaveClass('active'); + expect($('.modal-section .settings-tab-button[data-tab="advanced"]')).not.toHaveClass('active'); + expect($('.modal-section .settings-tab-button[data-tab="access"]')).toHaveClass('active'); + }); + + it('does not show settings tab headers if there is only one tab to show', function() { + var mockSubsectionJSON = createMockSubsectionJSON({}, []); + delete mockSubsectionJSON.is_prereq; + delete mockSubsectionJSON.prereqs; + delete mockSubsectionJSON.prereq; + delete mockSubsectionJSON.prereq_min_score; + var mockCourseJSON = createMockCourseJSON({ + enable_proctored_exams: false, + enable_timed_exams: false + }, [ + createMockSectionJSON({}, [mockSubsectionJSON]) + ]); + createCourseOutlinePage(this, mockCourseJSON, false); + outlinePage.$('.outline-subsection .configure-button').click(); + expect($(".settings-tabs-header").length).toBe(0); + }); + + it('can show correct editors for self_paced course', function() { + var mockCourseJSON = createMockCourseJSON({}, [ + createMockSectionJSON({}, [ + createMockSubsectionJSON({}, []) + ]) + ]); + createCourseOutlinePage(this, mockCourseJSON, false); + /* global course */ + course.set('self_paced', true); + outlinePage.$('.outline-subsection .configure-button').click(); + expect($(".edit-settings-release").length).toBe(0); + expect($(".grading-due-date").length).toBe(0); }); it('can select valid time', function() { @@ -759,6 +821,7 @@ define(["jquery", "common/js/spec_helpers/ajax_helpers", "common/js/components/u AjaxHelpers.expectJsonRequest(requests, 'POST', '/xblock/mock-subsection', { "graderType":"Lab", "publish": "republish", + "isPrereq": false, "metadata":{ "visible_to_staff_only": true, "start":"2014-07-09T00:00:00.000Z", @@ -988,6 +1051,176 @@ define(["jquery", "common/js/spec_helpers/ajax_helpers", "common/js/components/u expect($("#id_time_limit").val()).toBe("02:30"); }); + it('can select prerequisite', function() { + createCourseOutlinePage(this, mockCourseJSON, false); + outlinePage.$('.outline-subsection .configure-button').click(); + selectPrerequisite(); + expect($('#is_prereq').is(':checked')).toBe(true); + $('.wrapper-modal-window .action-save').click(); + }); + + it('can be deleted when it is a prerequisite', function() { + var promptSpy = EditHelpers.createPromptSpy(); + var mockCourseWithPrequisiteJSON = createMockCourseJSON({}, [ + createMockSectionJSON({}, [ + createMockSubsectionJSON({ + is_prereq: true, + }, []), + ]) + ]); + createCourseOutlinePage(this, mockCourseWithPrequisiteJSON, false); + getItemHeaders('subsection').find('.delete-button').click(); + EditHelpers.confirmPrompt(promptSpy); + AjaxHelpers.expectJsonRequest(requests, 'DELETE', '/xblock/mock-subsection'); + AjaxHelpers.respondWithJson(requests, {}); + AjaxHelpers.expectJsonRequest(requests, 'GET', '/xblock/outline/mock-section'); + }); + + it('can show a saved prerequisite correctly', function() { + var mockCourseWithPrequisiteJSON = createMockCourseJSON({}, [ + createMockSectionJSON({}, [ + createMockSubsectionJSON({ + is_prereq: true, + }, []), + ]) + ]); + createCourseOutlinePage(this, mockCourseWithPrequisiteJSON, false); + outlinePage.$('.outline-subsection .configure-button').click(); + expect($('#is_prereq').is(':checked')).toBe(true); + }); + + it('does not display prerequisite subsections if none are available', function() { + createCourseOutlinePage(this, mockCourseJSON, false); + outlinePage.$('.outline-subsection .configure-button').click(); + expect($('.gating-prereq').length).toBe(0); + }); + + it('can display available prerequisite subsections', function() { + var mockCourseWithPreqsJSON = createMockCourseJSON({}, [ + createMockSectionJSON({}, [ + createMockSubsectionJSON({ + prereqs: [{block_usage_key: 'usage_key', block_display_name: 'Prereq Subsection 1'}] + }, []), + ]) + ]); + createCourseOutlinePage(this, mockCourseWithPreqsJSON, false); + outlinePage.$('.outline-subsection .configure-button').click(); + expect($('.gating-prereq').length).toBe(1); + }); + + it('can select prerequisite subsection', function() { + var mockCourseWithPreqsJSON = createMockCourseJSON({}, [ + createMockSectionJSON({}, [ + createMockSubsectionJSON({ + prereqs: [{block_usage_key: 'usage_key', block_display_name: 'Prereq Subsection 1'}] + }, []), + ]) + ]); + createCourseOutlinePage(this, mockCourseWithPreqsJSON, false); + outlinePage.$('.outline-subsection .configure-button').click(); + selectLastPrerequisiteSubsection('80'); + expect($('#prereq_min_score_input').css('display')).not.toBe('none'); + expect($('#prereq option:selected').val()).toBe('usage_key'); + expect($('#prereq_min_score').val()).toBe('80'); + $('.wrapper-modal-window .action-save').click(); + }); + + it('can display gating correctly', function() { + var mockCourseWithPreqsJSON = createMockCourseJSON({}, [ + createMockSectionJSON({}, [ + createMockSubsectionJSON({ + visibility_state: 'gated', + prereqs: [{block_usage_key: 'usage_key', block_display_name: 'Prereq Subsection 1'}], + prereq: 'usage_key', + prereq_min_score: '80' + }, []), + ]) + ]); + createCourseOutlinePage(this, mockCourseWithPreqsJSON, false); + expect($(".outline-subsection .status-message-copy")).toContainText( + "Prerequisite: Prereq Subsection 1" + ); + }); + + it('can show a saved prerequisite subsection correctly', function() { + var mockCourseWithPreqsJSON = createMockCourseJSON({}, [ + createMockSectionJSON({}, [ + createMockSubsectionJSON({ + prereqs: [{block_usage_key: 'usage_key', block_display_name: 'Prereq Subsection 1'}], + prereq: 'usage_key', + prereq_min_score: '80' + }, []), + ]) + ]); + createCourseOutlinePage(this, mockCourseWithPreqsJSON, false); + outlinePage.$('.outline-subsection .configure-button').click(); + expect($('.gating-prereq').length).toBe(1); + expect($('#prereq option:selected').val()).toBe('usage_key'); + expect($('#prereq_min_score_input').css('display')).not.toBe('none'); + expect($('#prereq_min_score').val()).toBe('80'); + }); + + it('can display validation error on non-integer minimum score', function() { + var mockCourseWithPreqsJSON = createMockCourseJSON({}, [ + createMockSectionJSON({}, [ + createMockSubsectionJSON({ + prereqs: [{block_usage_key: 'usage_key', block_display_name: 'Prereq Subsection 1'}] + }, []), + ]) + ]); + createCourseOutlinePage(this, mockCourseWithPreqsJSON, false); + outlinePage.$('.outline-subsection .configure-button').click(); + selectLastPrerequisiteSubsection('abc'); + expect($('#prereq_min_score_error').css('display')).not.toBe('none'); + expect($(".wrapper-modal-window .action-save").prop('disabled')).toBe(true); + expect($(".wrapper-modal-window .action-save").hasClass('is-disabled')).toBe(true); + selectLastPrerequisiteSubsection('5.5'); + expect($('#prereq_min_score_error').css('display')).not.toBe('none'); + expect($(".wrapper-modal-window .action-save").prop('disabled')).toBe(true); + expect($(".wrapper-modal-window .action-save").hasClass('is-disabled')).toBe(true); + }); + + it('can display validation error on out of bounds minimum score', function() { + var mockCourseWithPreqsJSON = createMockCourseJSON({}, [ + createMockSectionJSON({}, [ + createMockSubsectionJSON({ + prereqs: [{block_usage_key: 'usage_key', block_display_name: 'Prereq Subsection 1'}] + }, []), + ]) + ]); + createCourseOutlinePage(this, mockCourseWithPreqsJSON, false); + outlinePage.$('.outline-subsection .configure-button').click(); + selectLastPrerequisiteSubsection('-5'); + expect($('#prereq_min_score_error').css('display')).not.toBe('none'); + expect($(".wrapper-modal-window .action-save").prop('disabled')).toBe(true); + expect($(".wrapper-modal-window .action-save").hasClass('is-disabled')).toBe(true); + selectLastPrerequisiteSubsection('105'); + expect($('#prereq_min_score_error').css('display')).not.toBe('none'); + expect($(".wrapper-modal-window .action-save").prop('disabled')).toBe(true); + expect($(".wrapper-modal-window .action-save").hasClass('is-disabled')).toBe(true); + }); + + it('does not display validation error on valid minimum score', function() { + var mockCourseWithPreqsJSON = createMockCourseJSON({}, [ + createMockSectionJSON({}, [ + createMockSubsectionJSON({ + prereqs: [{block_usage_key: 'usage_key', block_display_name: 'Prereq Subsection 1'}] + }, []), + ]) + ]); + createCourseOutlinePage(this, mockCourseWithPreqsJSON, false); + outlinePage.$('.outline-subsection .configure-button').click(); + selectAccessSettings(); + selectLastPrerequisiteSubsection(''); + expect($('#prereq_min_score_error').css('display')).toBe('none'); + selectLastPrerequisiteSubsection('80'); + expect($('#prereq_min_score_error').css('display')).toBe('none'); + selectLastPrerequisiteSubsection('0'); + expect($('#prereq_min_score_error').css('display')).toBe('none'); + selectLastPrerequisiteSubsection('100'); + expect($('#prereq_min_score_error').css('display')).toBe('none'); + }); + it('release date, due date, grading type, and staff lock can be cleared.', function() { createCourseOutlinePage(this, mockCourseJSON, false); outlinePage.$('.outline-item .outline-subsection .configure-button').click(); diff --git a/cms/static/js/views/modals/base_modal.js b/cms/static/js/views/modals/base_modal.js index 5ddc1e3099..e8f8696d59 100644 --- a/cms/static/js/views/modals/base_modal.js +++ b/cms/static/js/views/modals/base_modal.js @@ -147,6 +147,14 @@ define(["jquery", "underscore", "gettext", "js/views/baseview"], 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() { var top, left, modalWindow, modalWidth, modalHeight, availableWidth, availableHeight, maxWidth, maxHeight; diff --git a/cms/static/js/views/modals/course_outline_modals.js b/cms/static/js/views/modals/course_outline_modals.js index a430607212..b0d34a6334 100644 --- a/cms/static/js/views/modals/course_outline_modals.js +++ b/cms/static/js/views/modals/course_outline_modals.js @@ -14,7 +14,7 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', 'use strict'; var CourseOutlineXBlockModal, SettingsXBlockModal, PublishXBlockModal, AbstractEditor, BaseDateEditor, ReleaseDateEditor, DueDateEditor, GradingEditor, PublishEditor, StaffLockEditor, - VerificationAccessEditor, TimedExaminationPreferenceEditor; + VerificationAccessEditor, TimedExaminationPreferenceEditor, AccessEditor; CourseOutlineXBlockModal = BaseModal.extend({ events : { @@ -104,6 +104,7 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', }); SettingsXBlockModal = CourseOutlineXBlockModal.extend({ + getTitle: function () { return interpolate( gettext('%(display_name)s Settings'), @@ -112,37 +113,42 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', }, getIntroductionMessage: function () { - return interpolate( - gettext('Change the settings for %(display_name)s'), - { display_name: this.model.get('display_name') }, true - ); + var message = ''; + var tabs = this.options.tabs; + if (!tabs || tabs.length < 2) { + message = interpolate( + gettext('Change the settings for %(display_name)s'), + { display_name: this.model.get('display_name') }, true); + } + return message; }, initializeEditors: function () { - var special_exams_editors = this.options.special_exam_editors; - if (typeof special_exams_editors !== 'undefined' && special_exams_editors.length > 0) { - var tabs_html = this.loadTemplate('settings-tab-section'); - this.$('.modal-section').html(tabs_html); - this.options.editors = _.map(this.options.editors, function (Editor) { - return new Editor({ - parentElement: this.$('.modal-section .general-settings'), - model: this.model, - xblockType: this.options.xblockType, - enable_proctored_exams: this.options.enable_proctored_exams, - enable_timed_exams: this.options.enable_timed_exams - }); - }, this); - - this.options.special_exam_editors = _.map(special_exams_editors, function (Editor) { - return new Editor({ - parentElement: this.$('.modal-section .advanced-settings'), - model: this.model, - xblockType: this.options.xblockType, - enable_proctored_exams: this.options.enable_proctored_exams, - enable_timed_exams: this.options.enable_timed_exams - }); - }, this); - this.hideAdvancedSettings(); + var tabs = this.options.tabs; + if (tabs && tabs.length > 0) { + if (tabs.length > 1) { + var tabsTemplate = this.loadTemplate('settings-modal-tabs'); + this.$('.modal-section').html(tabsTemplate({tabs: tabs})); + _.each(this.options.tabs, function(tab) { + this.options.editors.push.apply( + this.options.editors, + _.map(tab.editors, function (Editor) { + return new Editor({ + parent: this, + parentElement: this.$('.modal-section .' + tab.name), + model: this.model, + xblockType: this.options.xblockType, + enable_proctored_exams: this.options.enable_proctored_exams, + enable_timed_exams: this.options.enable_timed_exams + }); + }, this) + ); + }, this); + this.showTab(tabs[0].name); + } else { + this.options.editors = tabs[0].editors; + CourseOutlineXBlockModal.prototype.initializeEditors.call(this); + } } else { CourseOutlineXBlockModal.prototype.initializeEditors.call(this); } @@ -150,8 +156,7 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', events: { 'click .action-save': 'save', - 'click .general-settings-button': 'showGeneralSettings', - 'click .advanced-settings-button': 'showAdvancedSettings' + 'click .settings-tab-button': 'handleShowTab', }, /** @@ -159,35 +164,22 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', * @return {Object} */ getRequestData: function () { - var combined_editors = this.options.editors.concat(this.options.special_exam_editors); - var requestData = _.map(combined_editors, function (editor) { + var requestData = _.map(this.options.editors, function (editor) { return editor.getRequestData(); }); return $.extend.apply(this, [true, {}].concat(requestData)); }, - hideAdvancedSettings: function() { - this.$('.modal-section .general-settings-button').addClass('active'); - this.$('.modal-section .advanced-settings-button').removeClass('active'); - this.$('.modal-section .general-settings').show(); - this.$('.modal-section .advanced-settings').hide(); - - }, - - hideGeneralSettings: function() { - this.$('.modal-section .general-settings-button').removeClass('active'); - this.$('.modal-section .advanced-settings-button').addClass('active'); - this.$('.modal-section .general-settings').hide(); - this.$('.modal-section .advanced-settings').show(); - }, - showGeneralSettings: function (event) { + handleShowTab: function (event) { event.preventDefault(); - this.hideAdvancedSettings(); + this.showTab($(event.target).data('tab')); }, - showAdvancedSettings: function (event) { - event.preventDefault(); - this.hideGeneralSettings(); + showTab: function (tab) { + this.$('.modal-section .settings-tab-button').removeClass('active'); + this.$('.modal-section .settings-tab-button[data-tab="' + tab + '"]').addClass('active'); + this.$('.modal-section .settings-tab').hide(); + this.$('.modal-section .' + tab).show(); } }); @@ -230,6 +222,7 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', templateName: null, initialize: function() { this.template = this.loadTemplate(this.templateName); + this.parent = this.options.parent; this.parentElement = this.options.parentElement; this.render(); }, @@ -488,6 +481,58 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', }; } }); + AccessEditor = AbstractEditor.extend({ + templateName: 'access-editor', + className: 'edit-settings-access', + events : { + 'change #prereq': 'handlePrereqSelect', + 'keyup #prereq_min_score': 'validateMinScore' + }, + afterRender: function () { + AbstractEditor.prototype.afterRender.call(this); + var prereq = this.model.get('prereq') || ''; + var prereq_min_score = this.model.get('prereq_min_score') || ''; + this.$('#is_prereq').prop('checked', this.model.get('is_prereq')); + this.$('#prereq option[value="' + prereq + '"]').prop('selected', true); + this.$('#prereq_min_score').val(prereq_min_score); + this.$('#prereq_min_score_input').toggle(prereq.length > 0); + }, + handlePrereqSelect: function () { + var showPrereqInput = this.$('#prereq option:selected').val().length > 0; + this.$('#prereq_min_score_input').toggle(showPrereqInput); + }, + validateMinScore: function () { + var minScore = this.$('#prereq_min_score').val().trim(); + var minScoreInt = parseInt(minScore); + // minScore needs to be an integer between 0 and 100 + if ( + minScore && + ( + typeof(minScoreInt) === 'undefined' || + String(minScoreInt) !== minScore || + minScoreInt < 0 || + minScoreInt > 100 + ) + ) { + this.$('#prereq_min_score_error').show(); + BaseModal.prototype.disableActionButton.call(this.parent, 'save'); + } else { + this.$('#prereq_min_score_error').hide(); + BaseModal.prototype.enableActionButton.call(this.parent, 'save'); + } + }, + getRequestData: function () { + var minScore = this.$('#prereq_min_score').val(); + if (minScore) { + minScore = minScore.trim(); + } + return { + isPrereq: this.$('#is_prereq').is(':checked'), + prereqUsageKey: this.$('#prereq option:selected').val(), + prereqMinScore: minScore + }; + } + }); GradingEditor = AbstractEditor.extend({ templateName: 'grading-editor', className: 'edit-settings-grading', @@ -687,20 +732,33 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', getEditModal: function (xblockInfo, options) { var editors = []; - var special_exam_editors = []; + var tabs = []; if (xblockInfo.isChapter()) { editors = [ReleaseDateEditor, StaffLockEditor]; } else if (xblockInfo.isSequential()) { - editors = [ReleaseDateEditor, GradingEditor, DueDateEditor]; + tabs.push({ + name: 'basic', + displayName: gettext('Basic'), + editors: [ReleaseDateEditor, GradingEditor, DueDateEditor, StaffLockEditor] + }); - var enable_special_exams = (options.enable_proctored_exams || options.enable_timed_exams); - if (enable_special_exams) { - special_exam_editors.push(TimedExaminationPreferenceEditor); + if (options.enable_proctored_exams || options.enable_timed_exams) { + tabs.push({ + name: 'advanced', + displayName: gettext('Advanced'), + editors: [TimedExaminationPreferenceEditor] + }); } - editors.push(StaffLockEditor); - + if (typeof(xblockInfo.get('is_prereq')) !== 'undefined') { + tabs.push({ + name: 'access', + // Translators: This label refers to access to course content. + displayName: gettext('Access'), + editors: [AccessEditor] + }); + } } else if (xblockInfo.isVertical()) { editors = [StaffLockEditor]; @@ -711,10 +769,13 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', /* globals course */ if (course.get('self_paced')) { editors = _.without(editors, ReleaseDateEditor, DueDateEditor); + _.each(tabs, function (tab) { + tab.editors = _.without(editors, ReleaseDateEditor, DueDateEditor); + }); } return new SettingsXBlockModal($.extend({ + tabs: tabs, editors: editors, - special_exam_editors: special_exam_editors, model: xblockInfo }, options)); }, diff --git a/cms/static/js/views/utils/xblock_utils.js b/cms/static/js/views/utils/xblock_utils.js index f1d51e7981..c72a449c40 100644 --- a/cms/static/js/views/utils/xblock_utils.js +++ b/cms/static/js/views/utils/xblock_utils.js @@ -31,7 +31,8 @@ define(["jquery", "underscore", "gettext", "common/js/components/utils/view_util ready: 'ready', unscheduled: 'unscheduled', 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 deleteXBlock = function(xblockInfo, xblockType) { var deletion = $.Deferred(), url = ModuleUtils.getUpdateUrl(xblockInfo.id), - xblockType = xblockType || gettext('component'); - 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() { + operation = function() { ViewUtils.runOperationShowingMessage(gettext('Deleting'), function() { return $.ajax({ @@ -90,8 +83,47 @@ define(["jquery", "underscore", "gettext", "common/js/components/utils/view_util }).success(function() { 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(); }; @@ -141,6 +173,9 @@ define(["jquery", "underscore", "gettext", "common/js/components/utils/view_util if (visibilityState === VisibilityState.staffOnly) { return 'is-staff-only'; } + if (visibilityState === VisibilityState.gated) { + return 'is-gated'; + } if (visibilityState === VisibilityState.live) { return 'is-live'; } diff --git a/cms/static/sass/_variables.scss b/cms/static/sass/_variables.scss index ad20b57221..fa9c3159d0 100644 --- a/cms/static/sass/_variables.scss +++ b/cms/static/sass/_variables.scss @@ -188,6 +188,7 @@ $color-ready: $green; $color-warning: $orange-l2; $color-error: $red-l2; $color-staff-only: $black; +$color-gated: $black; $color-visibility-set: $black; $color-heading-base: $gray-d2; diff --git a/cms/static/sass/elements/_modal-window.scss b/cms/static/sass/elements/_modal-window.scss index 958080e4f9..7e2f222d19 100644 --- a/cms/static/sass/elements/_modal-window.scss +++ b/cms/static/sass/elements/_modal-window.scss @@ -99,16 +99,15 @@ &:last-child { margin-bottom: 0; } - .settings-tab { + .settings-tabs-header { margin-bottom: $baseline; border-bottom: 1px solid $gray-l3; - li.settings-section { + li.settings-tab-buttons { display: inline-block; margin-right: $baseline; - .general-settings-button, - .advanced-settings-button { + .settings-tab-button { @extend %t-copy-sub1; @extend %t-regular; background-image: none; @@ -561,7 +560,7 @@ .course-outline-modal { .exam-time-list-fields, .exam-review-rules-list-fields { - margin: 0 0 ($baseline/2) ($baseline/2); + margin: 0 0 ($baseline/2) 0; } .list-fields { .field-message { @@ -742,5 +741,12 @@ margin-bottom: 0; } } + + // UI: Access settings section + .edit-settings-access { + .gating-prereq { + margin-bottom: 10px; + } + } } } diff --git a/cms/static/sass/elements/_modules.scss b/cms/static/sass/elements/_modules.scss index 5f71f912aa..7540c8700d 100644 --- a/cms/static/sass/elements/_modules.scss +++ b/cms/static/sass/elements/_modules.scss @@ -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 &.has-warnings { @@ -421,6 +435,11 @@ $outline-indent-width: $baseline; border-left-color: $color-staff-only; } + // CASE: has gated content + &.is-gated { + border-left-color: $color-gated; + } + // CASE: has unpublished content &.has-warnings { border-left-color: $color-warning; @@ -505,6 +524,11 @@ $outline-indent-width: $baseline; border-left-color: $color-staff-only; } + // CASE: is presented for gated + &.is-gated { + border-left-color: $color-gated; + } + // CASE: has unpublished content &.has-warnings { border-left-color: $color-warning; @@ -697,4 +721,3 @@ $outline-indent-width: $baseline; } } } - diff --git a/cms/static/sass/views/_container.scss b/cms/static/sass/views/_container.scss index 74a275e424..999a19ccaa 100644 --- a/cms/static/sass/views/_container.scss +++ b/cms/static/sass/views/_container.scss @@ -149,6 +149,11 @@ } } + // CASE: content is gated + &.is-gated { + @extend %bar-module-black; + } + .bar-mod-content { border: 0; padding: ($baseline/2) ($baseline*0.75) ($baseline/4) ($baseline*0.75); diff --git a/cms/templates/course_outline.html b/cms/templates/course_outline.html index c7a5f2582c..c88499567b 100644 --- a/cms/templates/course_outline.html +++ b/cms/templates/course_outline.html @@ -21,7 +21,7 @@ from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration <%block name="header_extras"> -% 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']: diff --git a/cms/templates/js/access-editor.underscore b/cms/templates/js/access-editor.underscore new file mode 100644 index 0000000000..39d0b59756 --- /dev/null +++ b/cms/templates/js/access-editor.underscore @@ -0,0 +1,45 @@ +
+ <% if (xblockInfo.get('prereqs').length > 0) { %> + + + <% } %> + + +
diff --git a/cms/templates/js/course-outline-modal.underscore b/cms/templates/js/course-outline-modal.underscore index cda1793672..ae56b24260 100644 --- a/cms/templates/js/course-outline-modal.underscore +++ b/cms/templates/js/course-outline-modal.underscore @@ -1,14 +1,6 @@ -<% -var enable_proctored_exams = enable_proctored_exams; -var enable_timed_exams = enable_timed_exams; -%> -
- <% if (!( enable_proctored_exams || enable_timed_exams )) { %> - - <% } %> +
- diff --git a/cms/templates/js/course-outline.underscore b/cms/templates/js/course-outline.underscore index 29c0c1aca9..8d0f4a8eb2 100644 --- a/cms/templates/js/course-outline.underscore +++ b/cms/templates/js/course-outline.underscore @@ -2,9 +2,25 @@ var releasedToStudents = xblockInfo.get('released_to_students'); var visibilityState = xblockInfo.get('visibility_state'); var published = xblockInfo.get('published'); +var prereq = xblockInfo.get('prereq'); var statusMessage = 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) { statusType = 'staff-only'; statusMessage = gettext('Contains staff only content'); @@ -28,6 +44,8 @@ if (statusType === 'warning') { statusIconClass = 'fa-warning'; } else if (statusType === 'staff-only') { statusIconClass = 'fa-lock'; +} else if (statusType === 'gated') { + statusIconClass = 'fa-lock'; } var gradingType = gettext('Ungraded'); diff --git a/cms/templates/js/settings-modal-tabs.underscore b/cms/templates/js/settings-modal-tabs.underscore new file mode 100644 index 0000000000..34c5f6f02b --- /dev/null +++ b/cms/templates/js/settings-modal-tabs.underscore @@ -0,0 +1,10 @@ + +<% _.each(tabs, function(tab) { %> +
+<% }); %> diff --git a/cms/templates/js/settings-tab-section.underscore b/cms/templates/js/settings-tab-section.underscore deleted file mode 100644 index d85611bbb7..0000000000 --- a/cms/templates/js/settings-tab-section.underscore +++ /dev/null @@ -1,10 +0,0 @@ - -
-
diff --git a/cms/templates/js/timed-examination-preference-editor.underscore b/cms/templates/js/timed-examination-preference-editor.underscore index 4dc1f3414b..061672ab0b 100644 --- a/cms/templates/js/timed-examination-preference-editor.underscore +++ b/cms/templates/js/timed-examination-preference-editor.underscore @@ -1,4 +1,5 @@
+