Added subsection gating feature

This commit is contained in:
Douglas Hall
2015-12-16 21:08:08 -05:00
parent 1603a1ab4d
commit 37a7fdc0f2
68 changed files with 2527 additions and 369 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -21,7 +21,7 @@ from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration
<%block name="header_extras">
<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">
<%static:include path="js/${template_name}.underscore" />
</script>

View File

@@ -0,0 +1,45 @@
<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>

View File

@@ -1,14 +1,6 @@
<%
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') %>">
<% if (!( enable_proctored_exams || enable_timed_exams )) { %>
<div class="message modal-introduction">
<p><%- introductionMessage %></p>
</div>
<% } %>
<div class="message modal-introduction">
<p><%- introductionMessage %></p>
</div>
<div class="modal-section"></div>
</div>

View File

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

View File

@@ -0,0 +1,10 @@
<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>
<% }); %>

View File

@@ -1,10 +0,0 @@
<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>

View File

@@ -1,4 +1,5 @@
<form>
<h3 class="modal-section-title"><%= gettext('Set as a Special Exam') %></h3>
<div class="modal-section-content has-actions">
<div class='exam-time-list-fields'>
<ul class="list-fields list-input">