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