Conditionally display gated content in courseware
Display gated sections in course outline, navigation and in the course when user has met prerequiste condition. WL-1273, WL-1317
This commit is contained in:
@@ -344,11 +344,14 @@ def add_course_content_milestone(course_id, content_id, relationship, milestone)
|
||||
return milestones_api.add_course_content_milestone(course_id, content_id, relationship, milestone)
|
||||
|
||||
|
||||
def get_course_content_milestones(course_id, content_id, relationship, user_id=None):
|
||||
def get_course_content_milestones(course_id, content_id=None, relationship='requires', user_id=None):
|
||||
"""
|
||||
Client API operation adapter/wrapper
|
||||
Uses the request cache to store all of a user's
|
||||
milestones
|
||||
|
||||
Returns all content blocks in a course if content_id is None, otherwise it just returns that
|
||||
specific content block.
|
||||
"""
|
||||
if not settings.FEATURES.get('MILESTONES_APP'):
|
||||
return []
|
||||
@@ -367,6 +370,9 @@ def get_course_content_milestones(course_id, content_id, relationship, user_id=N
|
||||
user={"id": user_id}
|
||||
)
|
||||
|
||||
if content_id is None:
|
||||
return request_cache_dict[user_id][relationship]
|
||||
|
||||
return [m for m in request_cache_dict[user_id][relationship] if m['content_id'] == unicode(content_id)]
|
||||
|
||||
|
||||
|
||||
@@ -159,7 +159,7 @@ class ProctoringFields(object):
|
||||
|
||||
@XBlock.wants('proctoring')
|
||||
@XBlock.wants('verification')
|
||||
@XBlock.wants('milestones')
|
||||
@XBlock.wants('gating')
|
||||
@XBlock.wants('credit')
|
||||
@XBlock.needs('user')
|
||||
@XBlock.needs('bookmarks')
|
||||
@@ -231,8 +231,6 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule):
|
||||
banner_text, special_html = special_html_view
|
||||
if special_html and not masquerading_as_specific_student:
|
||||
return Fragment(special_html)
|
||||
else:
|
||||
banner_text = self._gated_content_staff_banner()
|
||||
return self._student_view(context, banner_text)
|
||||
|
||||
def _special_exam_student_view(self):
|
||||
@@ -270,20 +268,6 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule):
|
||||
|
||||
return banner_text, hidden_content_html
|
||||
|
||||
def _gated_content_staff_banner(self):
|
||||
"""
|
||||
Checks whether the content is gated for learners. If so,
|
||||
returns a banner_text depending on whether user is staff.
|
||||
"""
|
||||
milestones_service = self.runtime.service(self, 'milestones')
|
||||
if milestones_service:
|
||||
content_milestones = milestones_service.get_course_content_milestones(
|
||||
self.course_id, self.location, 'requires'
|
||||
)
|
||||
banner_text = _('This subsection is unlocked for learners when they meet the prerequisite requirements.')
|
||||
if content_milestones and self.runtime.user_is_staff:
|
||||
return banner_text
|
||||
|
||||
def _can_user_view_content(self, course):
|
||||
"""
|
||||
Returns whether the runtime user can view the content
|
||||
@@ -307,10 +291,21 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule):
|
||||
"""
|
||||
display_items = self.get_display_items()
|
||||
self._update_position(context, len(display_items))
|
||||
prereq_met = True
|
||||
prereq_meta_info = {}
|
||||
|
||||
if self._required_prereq():
|
||||
if self.runtime.user_is_staff:
|
||||
banner_text = _('This subsection is unlocked for learners when they meet the prerequisite requirements.')
|
||||
else:
|
||||
# check if prerequisite has been met
|
||||
prereq_met, prereq_meta_info = self._compute_is_prereq_met(True)
|
||||
if prereq_met and not self._is_gate_fulfilled():
|
||||
banner_text = _('This section is a prerequisite. You must complete this section in order to unlock additional content.')
|
||||
|
||||
fragment = Fragment()
|
||||
params = {
|
||||
'items': self._render_student_view_for_items(context, display_items, fragment),
|
||||
'items': self._render_student_view_for_items(context, display_items, fragment) if prereq_met else [],
|
||||
'element_id': self.location.html_id(),
|
||||
'item_id': text_type(self.location),
|
||||
'position': self.position,
|
||||
@@ -320,6 +315,7 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule):
|
||||
'prev_url': context.get('prev_url'),
|
||||
'banner_text': banner_text,
|
||||
'disable_navigation': not self.is_user_authenticated(context),
|
||||
'gated_content': self._get_gated_content_info(prereq_met, prereq_meta_info)
|
||||
}
|
||||
fragment.add_content(self.system.render_template("seq_module.html", params))
|
||||
|
||||
@@ -328,6 +324,68 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule):
|
||||
|
||||
return fragment
|
||||
|
||||
def _get_gated_content_info(self, prereq_met, prereq_meta_info):
|
||||
"""
|
||||
Returns a dict of information about gated_content context
|
||||
"""
|
||||
gated_content = {}
|
||||
gated_content['gated'] = not prereq_met
|
||||
gated_content['prereq_url'] = prereq_meta_info['url'] if not prereq_met else None
|
||||
gated_content['prereq_section_name'] = prereq_meta_info['display_name'] if not prereq_met else None
|
||||
gated_content['gated_section_name'] = self.display_name
|
||||
|
||||
return gated_content
|
||||
|
||||
def _is_gate_fulfilled(self):
|
||||
"""
|
||||
Determines if this section is a prereq and has any unfulfilled milestones.
|
||||
|
||||
Returns:
|
||||
True if section has no unfufilled milestones or is not a prerequisite.
|
||||
False otherwise
|
||||
"""
|
||||
gating_service = self.runtime.service(self, 'gating')
|
||||
if gating_service:
|
||||
fulfilled = gating_service.is_gate_fulfilled(
|
||||
self.course_id, self.location, self.runtime.user_id
|
||||
)
|
||||
return fulfilled
|
||||
|
||||
return True
|
||||
|
||||
def _required_prereq(self):
|
||||
"""
|
||||
Checks whether a prerequisite is required for this Section
|
||||
|
||||
Returns:
|
||||
milestone if a prereq is required, None otherwise
|
||||
"""
|
||||
gating_service = self.runtime.service(self, 'gating')
|
||||
if gating_service:
|
||||
milestone = gating_service.required_prereq(
|
||||
self.course_id, self.location, 'requires'
|
||||
)
|
||||
return milestone
|
||||
|
||||
return None
|
||||
|
||||
def _compute_is_prereq_met(self, recalc_on_unmet):
|
||||
"""
|
||||
Evaluate if the user has completed the prerequisite
|
||||
|
||||
Arguments:
|
||||
recalc_on_unmet: Recalculate the subsection grade if prereq has not yet been met
|
||||
|
||||
Returns:
|
||||
tuple: True|False,
|
||||
prereq_meta_info = { 'url': prereq_url, 'display_name': prereq_name}
|
||||
"""
|
||||
gating_service = self.runtime.service(self, 'gating')
|
||||
if gating_service:
|
||||
return gating_service.compute_is_prereq_met(self.location, self.runtime.user_id, recalc_on_unmet)
|
||||
|
||||
return True, {}
|
||||
|
||||
def _update_position(self, context, number_of_display_items):
|
||||
"""
|
||||
Update the user's sequential position given the context and the
|
||||
|
||||
@@ -118,6 +118,7 @@ class SequenceBlockTestCase(XModuleXmlImportTest):
|
||||
)
|
||||
self._assert_view_at_position(html, expected_position=1)
|
||||
self.assertIn(unicode(self.sequence_3_1.location), html)
|
||||
self.assertIn("'gated': False", html)
|
||||
self.assertIn("'next_url': 'NextSequential'", html)
|
||||
self.assertIn("'prev_url': 'PrevSequential'", html)
|
||||
|
||||
@@ -178,3 +179,97 @@ class SequenceBlockTestCase(XModuleXmlImportTest):
|
||||
)
|
||||
self.assertIn("hidden_content.html", html)
|
||||
self.assertIn(progress_url, html)
|
||||
|
||||
def _assert_gated(self, html, sequence):
|
||||
"""
|
||||
Assert sequence content is gated
|
||||
"""
|
||||
self.assertIn("seq_module.html", html)
|
||||
self.assertIn("'banner_text': None", html)
|
||||
self.assertIn("'items': []", html)
|
||||
self.assertIn("'gated': True", html)
|
||||
self.assertIn("'prereq_url': 'PrereqUrl'", html)
|
||||
self.assertIn("'prereq_section_name': 'PrereqSectionName'", html)
|
||||
self.assertIn("'gated_section_name': u'{}'".format(unicode(sequence.display_name)), html)
|
||||
self.assertIn("'next_url': 'NextSequential'", html)
|
||||
self.assertIn("'prev_url': 'PrevSequential'", html)
|
||||
|
||||
def _assert_prereq(self, html, sequence):
|
||||
"""
|
||||
Assert sequence is a prerequisite with unfulfilled gates
|
||||
"""
|
||||
self.assertIn("seq_module.html", html)
|
||||
self.assertIn(
|
||||
"'banner_text': 'This section is a prerequisite. "
|
||||
"You must complete this section in order to unlock additional content.'",
|
||||
html
|
||||
)
|
||||
self.assertIn("'gated': False", html)
|
||||
self.assertIn(unicode(sequence.location), html)
|
||||
self.assertIn("'prereq_url': None", html)
|
||||
self.assertIn("'prereq_section_name': None", html)
|
||||
self.assertIn("'next_url': 'NextSequential'", html)
|
||||
self.assertIn("'prev_url': 'PrevSequential'", html)
|
||||
|
||||
def _assert_ungated(self, html, sequence):
|
||||
"""
|
||||
Assert sequence is not gated
|
||||
"""
|
||||
self.assertIn("seq_module.html", html)
|
||||
self.assertIn("'banner_text': None", html)
|
||||
self.assertIn("'gated': False", html)
|
||||
self.assertIn(unicode(sequence.location), html)
|
||||
self.assertIn("'prereq_url': None", html)
|
||||
self.assertIn("'prereq_section_name': None", html)
|
||||
self.assertIn("'next_url': 'NextSequential'", html)
|
||||
self.assertIn("'prev_url': 'PrevSequential'", html)
|
||||
|
||||
def test_gated_content(self):
|
||||
"""
|
||||
Test when sequence is both a prerequisite for a sequence
|
||||
and gated on another prerequisite sequence
|
||||
"""
|
||||
# setup seq_1_2 as a gate and gated
|
||||
gating_mock_1_2 = Mock()
|
||||
gating_mock_1_2.return_value.is_gate_fulfilled.return_value = False
|
||||
gating_mock_1_2.return_value.required_prereq.return_value = True
|
||||
gating_mock_1_2.return_value.compute_is_prereq_met.return_value = [
|
||||
False,
|
||||
{'url': 'PrereqUrl', 'display_name': 'PrereqSectionName'}
|
||||
]
|
||||
self.sequence_1_2.xmodule_runtime._services['gating'] = gating_mock_1_2 # pylint: disable=protected-access
|
||||
self.sequence_1_2.display_name = 'sequence_1_2'
|
||||
|
||||
html = self._get_rendered_student_view(
|
||||
self.sequence_1_2,
|
||||
extra_context=dict(next_url='NextSequential', prev_url='PrevSequential'),
|
||||
)
|
||||
|
||||
# expect content to be gated, with no banner
|
||||
self._assert_gated(html, self.sequence_1_2)
|
||||
|
||||
# change seq_1_2 to be ungated, but still a gate (prequiste)
|
||||
gating_mock_1_2.return_value.is_gate_fulfilled.return_value = False
|
||||
gating_mock_1_2.return_value.required_prereq.return_value = True
|
||||
gating_mock_1_2.return_value.compute_is_prereq_met.return_value = [True, {}]
|
||||
|
||||
html = self._get_rendered_student_view(
|
||||
self.sequence_1_2,
|
||||
extra_context=dict(next_url='NextSequential', prev_url='PrevSequential'),
|
||||
)
|
||||
|
||||
# assert that content and preq banner is shown
|
||||
self._assert_prereq(html, self.sequence_1_2)
|
||||
|
||||
# change seq_1_2 to have no unfulfilled gates
|
||||
gating_mock_1_2.return_value.is_gate_fulfilled.return_value = True
|
||||
gating_mock_1_2.return_value.required_prereq.return_value = True
|
||||
gating_mock_1_2.return_value.compute_is_prereq_met.return_value = [True, {}]
|
||||
|
||||
html = self._get_rendered_student_view(
|
||||
self.sequence_1_2,
|
||||
extra_context=dict(next_url='NextSequential', prev_url='PrevSequential'),
|
||||
)
|
||||
|
||||
# assert content shown as normal
|
||||
self._assert_ungated(html, self.sequence_1_2)
|
||||
|
||||
@@ -83,7 +83,7 @@ class CourseOutlinePage(PageObject):
|
||||
SECTION_SELECTOR = '.outline-item.section:nth-of-type({0})'
|
||||
SECTION_TITLES_SELECTOR = '.section-name h3'
|
||||
SUBSECTION_SELECTOR = SECTION_SELECTOR + ' .subsection:nth-of-type({1}) .outline-item'
|
||||
SUBSECTION_TITLES_SELECTOR = SECTION_SELECTOR + ' .subsection .subsection-title'
|
||||
SUBSECTION_TITLES_SELECTOR = SECTION_SELECTOR + ' .subsection .subsection-title .subsection-title-name'
|
||||
OUTLINE_RESUME_COURSE_SELECTOR = '.outline-item .resume-right'
|
||||
|
||||
def __init__(self, browser, parent_page):
|
||||
|
||||
@@ -150,9 +150,13 @@ class GatingTest(UniqueCourseTest):
|
||||
"""
|
||||
Given that I am a student
|
||||
When I visit the LMS Courseware
|
||||
Then I cannot see a gated subsection
|
||||
Then I can see a gated subsection
|
||||
The gated subsection should have a lock icon
|
||||
and be in the format: "<Subsection Title> (Prerequisite Required)"
|
||||
When I fulfill the gating Prerequisite
|
||||
Then I can see the gated subsection
|
||||
Now the gated subsection should have an unlock icon
|
||||
and screen readers should read the section as: "<Subsection Title> Unlocked"
|
||||
"""
|
||||
self._setup_prereq()
|
||||
self._setup_gated_subsection()
|
||||
@@ -160,7 +164,7 @@ class GatingTest(UniqueCourseTest):
|
||||
self._auto_auth(self.STUDENT_USERNAME, self.STUDENT_EMAIL, False)
|
||||
|
||||
self.course_home_page.visit()
|
||||
self.assertEqual(self.course_home_page.outline.num_subsections, 1)
|
||||
self.assertEqual(self.course_home_page.outline.num_subsections, 2)
|
||||
|
||||
# Fulfill prerequisite and verify that gated subsection is shown
|
||||
self.courseware_page.visit()
|
||||
@@ -175,7 +179,9 @@ class GatingTest(UniqueCourseTest):
|
||||
Then I can see all gated subsections
|
||||
Displayed along with notification banners
|
||||
Then if I masquerade as a student
|
||||
Then I cannot see a gated subsection
|
||||
Then I can see a gated subsection
|
||||
The gated subsection should have a lock icon
|
||||
and be in the format: "<Subsection Title> (Prerequisite Required)"
|
||||
When I fufill the gating prerequisite
|
||||
Then I can see the gated subsection (without a banner)
|
||||
"""
|
||||
@@ -204,10 +210,11 @@ class GatingTest(UniqueCourseTest):
|
||||
|
||||
self.course_home_page.visit()
|
||||
self.course_home_page.preview.set_staff_view_mode('Learner')
|
||||
self.assertEqual(self.course_home_page.outline.num_subsections, 1)
|
||||
self.assertEqual(self.course_home_page.outline.num_subsections, 2)
|
||||
self.course_home_page.outline.go_to_section('Test Section 1', 'Test Subsection 1')
|
||||
self.courseware_page.wait_for_page()
|
||||
self.assertFalse(self.courseware_page.has_banner())
|
||||
# banner displayed informing section is a prereq
|
||||
self.assertTrue(self.courseware_page.has_banner())
|
||||
|
||||
self.course_home_page.visit()
|
||||
self.course_home_page.preview.set_staff_view_mode_specific_student(self.STUDENT_USERNAME)
|
||||
|
||||
@@ -56,10 +56,14 @@ def get_blocks(
|
||||
requested_fields = []
|
||||
include_completion = 'completion' in requested_fields
|
||||
include_special_exams = 'special_exam_info' in requested_fields
|
||||
include_gated_sections = 'show_gated_sections' in requested_fields
|
||||
|
||||
if user is not None:
|
||||
transformers += COURSE_BLOCK_ACCESS_TRANSFORMERS
|
||||
transformers += [MilestonesAndSpecialExamsTransformer(include_special_exams), HiddenContentTransformer()]
|
||||
transformers += [MilestonesAndSpecialExamsTransformer(
|
||||
include_special_exams=include_special_exams,
|
||||
include_gated_sections=include_gated_sections)]
|
||||
transformers += [HiddenContentTransformer()]
|
||||
transformers += [
|
||||
BlocksAPITransformer(
|
||||
block_counts,
|
||||
|
||||
@@ -21,8 +21,9 @@ class MilestonesAndSpecialExamsTransformer(BlockStructureTransformer):
|
||||
"""
|
||||
A transformer that handles both milestones and special (timed) exams.
|
||||
|
||||
It excludes all blocks with unfulfilled milestones from the student view. An entrance exam is considered a
|
||||
milestone, and is not considered a "special exam".
|
||||
It includes or excludes all unfulfilled milestones from the student view based on the value of `include_gated_sections`.
|
||||
|
||||
An entrance exam is considered a milestone, and is not considered a "special exam".
|
||||
|
||||
It also includes or excludes all special (timed) exams (timed, proctored, practice proctored) in/from the
|
||||
student view, based on the value of `include_special_exams`.
|
||||
@@ -35,8 +36,9 @@ class MilestonesAndSpecialExamsTransformer(BlockStructureTransformer):
|
||||
def name(cls):
|
||||
return "milestones"
|
||||
|
||||
def __init__(self, include_special_exams=True):
|
||||
def __init__(self, include_special_exams=True, include_gated_sections=True):
|
||||
self.include_special_exams = include_special_exams
|
||||
self.include_gated_sections = include_gated_sections
|
||||
|
||||
@classmethod
|
||||
def collect(cls, block_structure):
|
||||
@@ -66,10 +68,10 @@ class MilestonesAndSpecialExamsTransformer(BlockStructureTransformer):
|
||||
|
||||
if usage_info.has_staff_access:
|
||||
return False
|
||||
elif self.has_pending_milestones_for_user(block_key, usage_info):
|
||||
return True
|
||||
elif self.gated_by_required_content(block_key, block_structure, required_content):
|
||||
return True
|
||||
elif not self.include_gated_sections and self.has_pending_milestones_for_user(block_key, usage_info):
|
||||
return True
|
||||
elif (settings.FEATURES.get('ENABLE_SPECIAL_EXAMS', False) and
|
||||
(self.is_special_exam(block_key, block_structure) and
|
||||
not self.include_special_exams)):
|
||||
|
||||
@@ -137,18 +137,21 @@ class MilestonesTransformerTestCase(CourseStructureTestCase, MilestonesTestCaseM
|
||||
(
|
||||
'H',
|
||||
'A',
|
||||
('course', 'A', 'B', 'C',)
|
||||
('course', 'A', 'B', 'C', 'H', 'I')
|
||||
),
|
||||
(
|
||||
'H',
|
||||
'ProctoredExam',
|
||||
('course', 'A', 'B', 'C'),
|
||||
('course', 'A', 'B', 'C', 'H', 'I'),
|
||||
),
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_gated(self, gated_block_ref, gating_block_ref, expected_blocks_before_completion):
|
||||
"""
|
||||
First, checks that a student cannot see the gated block when it is gated by the gating block and no
|
||||
Students should be able to see gated content blocks before and after they have completed the
|
||||
prerequisite for it.
|
||||
|
||||
First, checks that a student can see the gated block when it is gated by the gating block and no
|
||||
attempt has been made to complete the gating block.
|
||||
Then, checks that the student can see the gated block after the gating block has been completed.
|
||||
|
||||
@@ -160,21 +163,21 @@ class MilestonesTransformerTestCase(CourseStructureTestCase, MilestonesTestCaseM
|
||||
self.course.enable_subsection_gating = True
|
||||
self.setup_gated_section(self.blocks[gated_block_ref], self.blocks[gating_block_ref])
|
||||
|
||||
with self.assertNumQueries(8):
|
||||
with self.assertNumQueries(6):
|
||||
self.get_blocks_and_check_against_expected(self.user, expected_blocks_before_completion)
|
||||
|
||||
# clear the request cache to simulate a new request
|
||||
self.clear_caches()
|
||||
|
||||
# this call triggers reevaluation of prerequisites fulfilled by the gating block.
|
||||
with patch('gating.api._get_subsection_percentage', Mock(return_value=100)):
|
||||
with patch('openedx.core.lib.gating.api._get_subsection_percentage', Mock(return_value=100)):
|
||||
lms_gating_api.evaluate_prerequisite(
|
||||
self.course,
|
||||
Mock(location=self.blocks[gating_block_ref].location),
|
||||
self.user,
|
||||
)
|
||||
|
||||
with self.assertNumQueries(8):
|
||||
with self.assertNumQueries(6):
|
||||
self.get_blocks_and_check_against_expected(self.user, self.ALL_BLOCKS_EXCEPT_SPECIAL)
|
||||
|
||||
def test_staff_access(self):
|
||||
@@ -195,14 +198,14 @@ class MilestonesTransformerTestCase(CourseStructureTestCase, MilestonesTestCaseM
|
||||
self.course.enable_subsection_gating = True
|
||||
self.setup_gated_section(self.blocks['H'], self.blocks['A'])
|
||||
expected_blocks = (
|
||||
'course', 'A', 'B', 'C', 'ProctoredExam', 'D', 'E', 'PracticeExam', 'F', 'G', 'TimedExam', 'J', 'K'
|
||||
'course', 'A', 'B', 'C', 'ProctoredExam', 'D', 'E', 'PracticeExam', 'F', 'G', 'TimedExam', 'J', 'K', 'H', 'I'
|
||||
)
|
||||
self.get_blocks_and_check_against_expected(self.user, expected_blocks)
|
||||
# clear the request cache to simulate a new request
|
||||
self.clear_caches()
|
||||
|
||||
# this call triggers reevaluation of prerequisites fulfilled by the gating block.
|
||||
with patch('gating.api._get_subsection_percentage', Mock(return_value=100)):
|
||||
with patch('openedx.core.lib.gating.api._get_subsection_percentage', Mock(return_value=100)):
|
||||
lms_gating_api.evaluate_prerequisite(
|
||||
self.course,
|
||||
Mock(location=self.blocks['A'].location),
|
||||
|
||||
@@ -494,7 +494,6 @@ def _has_access_descriptor(user, action, descriptor, course_key=None):
|
||||
|
||||
return (
|
||||
_visible_to_nonstaff_users(descriptor) and
|
||||
_can_access_descriptor_with_milestones(user, descriptor, course_key) and
|
||||
(
|
||||
_has_detached_class_tag(descriptor) or
|
||||
check_start_date(user, descriptor.days_early_for_beta, descriptor.start, course_key)
|
||||
|
||||
@@ -52,6 +52,7 @@ from openedx.core.djangoapps.crawlers.models import CrawlersConfig
|
||||
from openedx.core.djangoapps.credit.services import CreditService
|
||||
from openedx.core.djangoapps.monitoring_utils import set_custom_metrics_for_course_key, set_monitoring_transaction_name
|
||||
from openedx.core.djangoapps.util.user_utils import SystemUser
|
||||
from openedx.core.lib.gating.services import GatingService
|
||||
from openedx.core.lib.license import wrap_with_license
|
||||
from openedx.core.lib.url_utils import quote_slashes, unquote_slashes
|
||||
from openedx.core.lib.xblock_utils import request_token as xblock_request_token
|
||||
@@ -762,6 +763,7 @@ def get_module_system_for_user(
|
||||
'milestones': milestones_helpers.get_service(),
|
||||
'credit': CreditService(),
|
||||
'bookmarks': BookmarksService(user=user),
|
||||
'gating': GatingService(),
|
||||
},
|
||||
get_user_role=lambda: get_user_role(user, course_id),
|
||||
descriptor_runtime=descriptor._runtime, # pylint: disable=protected-access
|
||||
|
||||
@@ -1285,7 +1285,7 @@ class TestGatedSubsectionRendering(SharedModuleStoreTestCase, MilestonesTestCase
|
||||
self.field_data_cache
|
||||
)
|
||||
self.assertIsNotNone(self._find_sequential(actual['chapters'], 'Chapter', 'Open_Sequential'))
|
||||
self.assertIsNone(self._find_sequential(actual['chapters'], 'Chapter', 'Gated_Sequential'))
|
||||
self.assertIsNotNone(self._find_sequential(actual['chapters'], 'Chapter', 'Gated_Sequential'))
|
||||
self.assertIsNone(self._find_sequential(actual['chapters'], 'Non-existent_Chapter', 'Non-existent_Sequential'))
|
||||
self.assertIsNone(actual['previous_of_active_section'])
|
||||
self.assertIsNone(actual['next_of_active_section'])
|
||||
|
||||
@@ -2401,7 +2401,8 @@ class TestIndexViewWithGating(ModuleStoreTestCase, MilestonesTestCaseMixin):
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEquals(response.status_code, 404)
|
||||
self.assertEquals(response.status_code, 200)
|
||||
self.assertIn("Content Locked", response.content)
|
||||
|
||||
|
||||
class TestRenderXBlock(RenderXBlockTestMixin, ModuleStoreTestCase):
|
||||
|
||||
@@ -31,37 +31,7 @@ def evaluate_prerequisite(course, subsection_grade, user):
|
||||
gated_content = gated_content_milestones.get(prereq_milestone['id'])
|
||||
if gated_content:
|
||||
for milestone in gated_content:
|
||||
min_percentage = _get_minimum_required_percentage(milestone)
|
||||
subsection_percentage = _get_subsection_percentage(subsection_grade)
|
||||
if subsection_percentage >= min_percentage:
|
||||
milestones_helpers.add_user_milestone({'id': user.id}, prereq_milestone)
|
||||
else:
|
||||
milestones_helpers.remove_user_milestone({'id': user.id}, prereq_milestone)
|
||||
|
||||
|
||||
def _get_minimum_required_percentage(milestone):
|
||||
"""
|
||||
Returns the minimum percentage requirement for the given milestone.
|
||||
"""
|
||||
# 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(
|
||||
u'Gating: Failed to find minimum score for gating milestone %s, defaulting to 100',
|
||||
json.dumps(milestone)
|
||||
)
|
||||
return min_score
|
||||
|
||||
|
||||
def _get_subsection_percentage(subsection_grade):
|
||||
"""
|
||||
Returns the percentage value of the given subsection_grade.
|
||||
"""
|
||||
return subsection_grade.percent_graded * 100.0
|
||||
gating_api.update_milestone(milestone, subsection_grade, prereq_milestone, user.id)
|
||||
|
||||
|
||||
def evaluate_entrance_exam(course_grade, user):
|
||||
|
||||
@@ -78,7 +78,7 @@ class TestEvaluatePrerequisite(GatingTestCase, MilestonesTestCaseMixin):
|
||||
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('gating.api._get_subsection_percentage')
|
||||
@patch('openedx.core.lib.gating.api._get_subsection_percentage')
|
||||
@data((50, True), (100, True), (0, False))
|
||||
@unpack
|
||||
def test_min_score_achieved(self, module_score, result, mock_score):
|
||||
@@ -88,24 +88,24 @@ class TestEvaluatePrerequisite(GatingTestCase, MilestonesTestCaseMixin):
|
||||
evaluate_prerequisite(self.course, self.subsection_grade, self.user)
|
||||
self.assertEqual(milestones_api.user_has_milestone(self.user_dict, self.prereq_milestone), result)
|
||||
|
||||
@patch('gating.api.log.warning')
|
||||
@patch('gating.api._get_subsection_percentage')
|
||||
@patch('openedx.core.lib.gating.api._get_subsection_percentage')
|
||||
@patch('openedx.core.lib.gating.api._get_minimum_required_percentage')
|
||||
@data((50, False), (100, True))
|
||||
@unpack
|
||||
def test_invalid_min_score(self, module_score, result, mock_score, mock_log):
|
||||
def test_invalid_min_score(self, module_score, result, mock_min_score, mock_score):
|
||||
self._setup_gating_milestone(None)
|
||||
mock_score.return_value = module_score
|
||||
mock_min_score.return_value = 100
|
||||
|
||||
evaluate_prerequisite(self.course, self.subsection_grade, self.user)
|
||||
self.assertEqual(milestones_api.user_has_milestone(self.user_dict, self.prereq_milestone), result)
|
||||
self.assertTrue(mock_log.called)
|
||||
|
||||
@patch('gating.api._get_subsection_percentage')
|
||||
@patch('openedx.core.lib.gating.api._get_subsection_percentage')
|
||||
def test_no_prerequisites(self, mock_score):
|
||||
evaluate_prerequisite(self.course, self.subsection_grade, self.user)
|
||||
self.assertFalse(mock_score.called)
|
||||
|
||||
@patch('gating.api._get_subsection_percentage')
|
||||
@patch('openedx.core.lib.gating.api._get_subsection_percentage')
|
||||
def test_no_gated_content(self, mock_score):
|
||||
gating_api.add_prerequisite(self.course.id, self.seq1.location)
|
||||
|
||||
|
||||
@@ -120,7 +120,7 @@ class TestGatedContent(MilestonesTestCaseMixin, SharedModuleStoreTestCase):
|
||||
gating_api.set_required_content(self.course.id, str(self.seq2.location), str(self.seq1.location), min_score)
|
||||
self.prereq_milestone = gating_api.get_gating_milestone(self.course.id, self.seq1.location, 'fulfills')
|
||||
|
||||
def assert_access_to_gated_content(self, user, expected_access):
|
||||
def assert_access_to_gated_content(self, user):
|
||||
"""
|
||||
Verifies access to gated content for the given user is as expected.
|
||||
"""
|
||||
@@ -130,8 +130,8 @@ class TestGatedContent(MilestonesTestCaseMixin, SharedModuleStoreTestCase):
|
||||
# access to gating content (seq1) remains constant
|
||||
self.assertTrue(bool(has_access(user, 'load', self.seq1, self.course.id)))
|
||||
|
||||
# access to gated content (seq2) is as expected
|
||||
self.assertEquals(bool(has_access(user, 'load', self.seq2, self.course.id)), expected_access)
|
||||
# access to gated content (seq2) remains constant, access is prevented in SeqModule loading
|
||||
self.assertTrue(bool(has_access(user, 'load', self.seq2, self.course.id)))
|
||||
|
||||
def assert_user_has_prereq_milestone(self, user, expected_has_milestone):
|
||||
"""
|
||||
@@ -157,11 +157,11 @@ class TestGatedContent(MilestonesTestCaseMixin, SharedModuleStoreTestCase):
|
||||
|
||||
def test_gated_for_nonstaff(self):
|
||||
self.assert_user_has_prereq_milestone(self.non_staff_user, expected_has_milestone=False)
|
||||
self.assert_access_to_gated_content(self.non_staff_user, expected_access=False)
|
||||
self.assert_access_to_gated_content(self.non_staff_user)
|
||||
|
||||
def test_not_gated_for_staff(self):
|
||||
self.assert_user_has_prereq_milestone(self.staff_user, expected_has_milestone=False)
|
||||
self.assert_access_to_gated_content(self.staff_user, expected_access=True)
|
||||
self.assert_access_to_gated_content(self.staff_user)
|
||||
|
||||
def test_gated_content_always_in_grades(self):
|
||||
# start with a grade from a non-gated subsection
|
||||
@@ -169,7 +169,7 @@ class TestGatedContent(MilestonesTestCaseMixin, SharedModuleStoreTestCase):
|
||||
|
||||
# verify gated status and overall course grade percentage
|
||||
self.assert_user_has_prereq_milestone(self.non_staff_user, expected_has_milestone=False)
|
||||
self.assert_access_to_gated_content(self.non_staff_user, expected_access=False)
|
||||
self.assert_access_to_gated_content(self.non_staff_user)
|
||||
self.assert_course_grade(self.non_staff_user, .33)
|
||||
|
||||
# fulfill the gated requirements
|
||||
@@ -177,16 +177,16 @@ class TestGatedContent(MilestonesTestCaseMixin, SharedModuleStoreTestCase):
|
||||
|
||||
# verify gated status and overall course grade percentage
|
||||
self.assert_user_has_prereq_milestone(self.non_staff_user, expected_has_milestone=True)
|
||||
self.assert_access_to_gated_content(self.non_staff_user, expected_access=True)
|
||||
self.assert_access_to_gated_content(self.non_staff_user)
|
||||
self.assert_course_grade(self.non_staff_user, .67)
|
||||
|
||||
@ddt.data((1, 1, True), (1, 2, True), (1, 3, False), (0, 1, False))
|
||||
@ddt.unpack
|
||||
def test_ungating_when_fulfilled(self, earned, max_possible, result):
|
||||
self.assert_user_has_prereq_milestone(self.non_staff_user, expected_has_milestone=False)
|
||||
self.assert_access_to_gated_content(self.non_staff_user, expected_access=False)
|
||||
self.assert_access_to_gated_content(self.non_staff_user)
|
||||
|
||||
answer_problem(self.course, self.request, self.gating_prob1, earned, max_possible)
|
||||
|
||||
self.assert_user_has_prereq_milestone(self.non_staff_user, expected_has_milestone=result)
|
||||
self.assert_access_to_gated_content(self.non_staff_user, expected_access=result)
|
||||
self.assert_access_to_gated_content(self.non_staff_user)
|
||||
|
||||
@@ -63,7 +63,7 @@ class SubsectionGradeFactory(object):
|
||||
)
|
||||
self._unsaved_subsection_grades.clear()
|
||||
|
||||
def update(self, subsection, only_if_higher=None, score_deleted=False, force_update_subsections=False):
|
||||
def update(self, subsection, only_if_higher=None, score_deleted=False, force_update_subsections=False, persist_grade=True):
|
||||
"""
|
||||
Updates the SubsectionGrade object for the student and subsection.
|
||||
"""
|
||||
@@ -73,7 +73,7 @@ class SubsectionGradeFactory(object):
|
||||
subsection, self.course_data.structure, self._submissions_scores, self._csm_scores,
|
||||
)
|
||||
|
||||
if should_persist_grades(self.course_data.course_key):
|
||||
if persist_grade and should_persist_grades(self.course_data.course_key):
|
||||
if only_if_higher:
|
||||
try:
|
||||
grade_model = PersistentSubsectionGrade.read_grade(self.student.id, subsection.location)
|
||||
|
||||
26
lms/templates/_gated_content.html
Normal file
26
lms/templates/_gated_content.html
Normal file
@@ -0,0 +1,26 @@
|
||||
<%page args="prereq_url, prereq_section_name, gated_section_name" expression_filter="h"/>
|
||||
|
||||
<%!
|
||||
from django.utils.translation import ugettext as _
|
||||
from openedx.core.djangolib.markup import Text
|
||||
%>
|
||||
|
||||
<div class="xblock xblock-student_view xblock-student_view-vertical xblock-initialized" id="content_locked" role="tabpanel" aria-labelledby="tab_0">
|
||||
<h2 class="hd hd-2 unit-title">
|
||||
<span class="icon fa fa-lock" aria-hidden="true"> </span>${gated_section_name}
|
||||
<span class="sr">${_("Content Locked")}</span>
|
||||
</h2>
|
||||
<p/>
|
||||
<h3 class="hd hd-3 problem-header">
|
||||
${_("Content Locked")}
|
||||
</h3>
|
||||
<p>
|
||||
${Text(_(
|
||||
"You must complete the section '{prereq_section_name}' with the required score before you are able to view this content."
|
||||
)).format(prereq_section_name=prereq_section_name)}
|
||||
<p>
|
||||
<a href="${prereq_url}" class="btn btn-brand">${_("Go to Prerequisite Section")}
|
||||
</a>
|
||||
</p>
|
||||
</p>
|
||||
</div>
|
||||
@@ -4,7 +4,8 @@
|
||||
<div id="sequence_${element_id}" class="sequence" data-id="${item_id}" data-position="${position}" data-ajax-url="${ajax_url}" data-next-url="${next_url}" data-prev-url="${prev_url}">
|
||||
% if banner_text:
|
||||
<div class="pattern-library-shim alert alert-information subsection-header" tabindex="-1">
|
||||
<span class="pattern-library-shim icon alert-icon fa fa-bullhorn" aria-hidden="true"></span>
|
||||
<span class="pattern-library-shim icon alert-icon fa fa-info-circle" aria-hidden="true"></span>
|
||||
<span class="sr">${_('Important!')} </span>
|
||||
<div class="pattern-library-shim alert-message">
|
||||
<p class="pattern-library-shim alert-copy">
|
||||
${banner_text}
|
||||
@@ -23,6 +24,13 @@
|
||||
</button>
|
||||
<nav class="sequence-list-wrapper" aria-label="${_('Sequence')}">
|
||||
<ol id="sequence-list" role="tablist">
|
||||
% if gated_content['gated']:
|
||||
<li>
|
||||
<button class="active nav-item tab" title="${_('Content Locked')}" id="tab_0" role="tab" tabindex="-1" aria-selected="true" aria-expanded="true" aria-controls="content_locked" disabled>
|
||||
<span class="icon fa fa-lock" aria-hidden="true"></span>
|
||||
</button>
|
||||
</li>
|
||||
% else:
|
||||
% for idx, item in enumerate(items):
|
||||
<li role="presentation">
|
||||
<button class="seq_${item['type']} inactive nav-item tab"
|
||||
@@ -44,10 +52,14 @@
|
||||
</button>
|
||||
</li>
|
||||
% endfor
|
||||
% endif
|
||||
</ol>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
% if gated_content['gated']:
|
||||
<%include file="_gated_content.html" args="prereq_url=gated_content['prereq_url'], prereq_section_name=gated_content['prereq_section_name'], gated_section_name=gated_content['gated_section_name']"/>
|
||||
% else:
|
||||
<div class="sr-is-focusable" tabindex="-1"></div>
|
||||
|
||||
% for idx, item in enumerate(items):
|
||||
@@ -59,6 +71,7 @@
|
||||
</div>
|
||||
% endfor
|
||||
<div id="seq_content" role="tabpanel"></div>
|
||||
% endif
|
||||
|
||||
<nav class="sequence-bottom" aria-label="${_('Section')}">
|
||||
<button class="sequence-nav-button button-previous">
|
||||
|
||||
@@ -112,5 +112,4 @@ if __name__ == "__main__":
|
||||
startup.run()
|
||||
|
||||
from django.core.management import execute_from_command_line
|
||||
|
||||
execute_from_command_line([sys.argv[0]] + django_args)
|
||||
|
||||
@@ -3,13 +3,19 @@ API for the gating djangoapp
|
||||
"""
|
||||
import logging
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.utils.translation import ugettext as _
|
||||
from lms.djangoapps.courseware.access import _has_access_to_course
|
||||
from lms.djangoapps.course_blocks.api import get_course_blocks
|
||||
from lms.djangoapps.grades.subsection_grade_factory import SubsectionGradeFactory
|
||||
from milestones import api as milestones_api
|
||||
from opaque_keys.edx.keys import UsageKey
|
||||
|
||||
from lms.djangoapps.courseware.access import _has_access_to_course
|
||||
from opaque_keys.edx.locator import BlockUsageLocator
|
||||
from openedx.core.lib.gating.exceptions import GatingValidationError
|
||||
from util import milestones_helpers
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -143,7 +149,7 @@ def get_prerequisites(course_key):
|
||||
milestones_by_block_id = {}
|
||||
block_ids = []
|
||||
for milestone in course_content_milestones:
|
||||
prereq_content_key = milestone['namespace'].replace(GATING_NAMESPACE_QUALIFIER, '')
|
||||
prereq_content_key = _get_gating_block_id(milestone)
|
||||
block_id = UsageKey.from_string(prereq_content_key).block_id
|
||||
block_ids.append(block_id)
|
||||
milestones_by_block_id[block_id] = milestone
|
||||
@@ -268,7 +274,7 @@ def get_required_content(course_key, gated_content_key):
|
||||
milestone = get_gating_milestone(course_key, gated_content_key, 'requires')
|
||||
if milestone:
|
||||
return (
|
||||
milestone.get('namespace', '').replace(GATING_NAMESPACE_QUALIFIER, ''),
|
||||
_get_gating_block_id(milestone),
|
||||
milestone.get('requirements', {}).get('min_score')
|
||||
)
|
||||
else:
|
||||
@@ -299,3 +305,143 @@ def get_gated_content(course, user):
|
||||
{'id': user.id}
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def is_gate_fulfilled(course_key, gating_content_key, user_id):
|
||||
"""
|
||||
Determines if a prerequisite section specified by gating_content_key
|
||||
has any unfulfilled milestones.
|
||||
|
||||
Arguments:
|
||||
course_key (CourseUsageLocator): Course locator
|
||||
gating_content_key (BlockUsageLocator): The locator for the section content
|
||||
user_id: The id of the user
|
||||
|
||||
Returns:
|
||||
Returns True if section has no unfufilled milestones or is not a prerequisite.
|
||||
Returns False otherwise
|
||||
"""
|
||||
gating_milestone = get_gating_milestone(course_key, gating_content_key, "fulfills")
|
||||
if not gating_milestone:
|
||||
return True
|
||||
|
||||
unfulfilled_milestones = [
|
||||
m['content_id'] for m in find_gating_milestones(
|
||||
course_key,
|
||||
None,
|
||||
'requires',
|
||||
{'id': user_id}
|
||||
) if m['namespace'] == gating_milestone['namespace']
|
||||
]
|
||||
return not unfulfilled_milestones
|
||||
|
||||
|
||||
def compute_is_prereq_met(content_id, user_id, recalc_on_unmet=False):
|
||||
"""
|
||||
Returns true if the prequiste has been met for a given milestone.
|
||||
Will recalculate the subsection grade if specified and prereq unmet
|
||||
|
||||
Arguments:
|
||||
content_id (BlockUsageLocator): BlockUsageLocator for the content
|
||||
user_id: The id of the user
|
||||
recalc_on_unmet: Recalculate the grade if prereq has not yet been met
|
||||
|
||||
Returns:
|
||||
tuple: True|False,
|
||||
prereq_meta_info = { 'url': prereq_url|None, 'display_name': prereq_name|None}
|
||||
"""
|
||||
course_key = content_id.course_key
|
||||
|
||||
# if unfullfilled milestones exist it means prereq has not been met
|
||||
unfulfilled_milestones = milestones_helpers.get_course_content_milestones(
|
||||
course_key,
|
||||
content_id,
|
||||
'requires',
|
||||
user_id
|
||||
)
|
||||
|
||||
prereq_met = not unfulfilled_milestones
|
||||
prereq_meta_info = {'url': None, 'display_name': None}
|
||||
|
||||
if prereq_met or not recalc_on_unmet:
|
||||
return prereq_met, prereq_meta_info
|
||||
|
||||
milestone = unfulfilled_milestones[0]
|
||||
student = User.objects.get(id=user_id)
|
||||
store = modulestore()
|
||||
|
||||
with store.bulk_operations(course_key):
|
||||
subsection_usage_key = UsageKey.from_string(_get_gating_block_id(milestone))
|
||||
subsection = store.get_item(subsection_usage_key)
|
||||
prereq_meta_info = {
|
||||
'url': reverse('jump_to', kwargs={'course_id': course_key, 'location': subsection_usage_key}),
|
||||
'display_name': subsection.display_name
|
||||
}
|
||||
|
||||
try:
|
||||
subsection_structure = get_course_blocks(student, subsection_usage_key)
|
||||
if any(subsection_structure):
|
||||
subsection_grade_factory = SubsectionGradeFactory(student, course_structure=subsection_structure)
|
||||
if subsection_usage_key in subsection_structure:
|
||||
# this will force a recalcuation of the subsection grade
|
||||
subsection_grade = subsection_grade_factory.update(subsection_structure[subsection_usage_key], persist_grade=False)
|
||||
prereq_met = update_milestone(milestone, subsection_grade, milestone, user_id)
|
||||
except ItemNotFoundError as err:
|
||||
log.warning("Could not find course_block for subsection=%s error=%s", subsection_usage_key, err)
|
||||
|
||||
return prereq_met, prereq_meta_info
|
||||
|
||||
|
||||
def update_milestone(milestone, subsection_grade, prereq_milestone, user_id):
|
||||
"""
|
||||
Updates the milestone record based on evaluation of prerequisite met.
|
||||
|
||||
Arguments:
|
||||
milestone: The gated milestone being evaluated
|
||||
subsection_grade: The grade of the prerequisite subsection
|
||||
prerequisite_milestone: The gating milestone
|
||||
user_id: The id of the user
|
||||
|
||||
Returns:
|
||||
True if prerequisite has been met, False if not
|
||||
"""
|
||||
min_percentage = _get_minimum_required_percentage(milestone)
|
||||
subsection_percentage = _get_subsection_percentage(subsection_grade)
|
||||
if subsection_percentage >= min_percentage:
|
||||
milestones_helpers.add_user_milestone({'id': user_id}, prereq_milestone)
|
||||
return True
|
||||
else:
|
||||
milestones_helpers.remove_user_milestone({'id': user_id}, prereq_milestone)
|
||||
return False
|
||||
|
||||
|
||||
def _get_gating_block_id(milestone):
|
||||
"""
|
||||
Return the block id of the gating milestone
|
||||
"""
|
||||
return milestone.get('namespace', '').replace(GATING_NAMESPACE_QUALIFIER, '')
|
||||
|
||||
|
||||
def _get_minimum_required_percentage(milestone):
|
||||
"""
|
||||
Returns the minimum percentage requirement for the given milestone.
|
||||
"""
|
||||
# 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(
|
||||
u'Gating: Failed to find minimum score for gating milestone %s, defaulting to 100',
|
||||
json.dumps(milestone)
|
||||
)
|
||||
return min_score
|
||||
|
||||
|
||||
def _get_subsection_percentage(subsection_grade):
|
||||
"""
|
||||
Returns the percentage value of the given subsection_grade.
|
||||
"""
|
||||
return subsection_grade.percent_graded * 100.0
|
||||
|
||||
55
openedx/core/lib/gating/services.py
Normal file
55
openedx/core/lib/gating/services.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""
|
||||
A wrapper class to communicate with Gating api
|
||||
"""
|
||||
from . import api as gating_api
|
||||
|
||||
|
||||
class GatingService(object):
|
||||
"""
|
||||
An XBlock service to talk to the Gating api.
|
||||
"""
|
||||
|
||||
def compute_is_prereq_met(self, content_id, user_id, recalc_on_unmet=False):
|
||||
"""
|
||||
Returns true if the prequiste has been met for a given milestone
|
||||
|
||||
Arguments:
|
||||
content_id (BlockUsageLocator): BlockUsageLocator for the content
|
||||
user_id: The id of the user
|
||||
recalc_on_unmet: Recalculate the grade if prereq has not yet been met
|
||||
|
||||
Returns:
|
||||
tuple: True|False,
|
||||
prereq_meta_info = { 'url': prereq_url|None, 'display_name': prereq_name|None}
|
||||
"""
|
||||
return gating_api.compute_is_prereq_met(content_id, user_id, recalc_on_unmet)
|
||||
|
||||
def required_prereq(self, course_key, content_key, relationship):
|
||||
"""
|
||||
Returns the prerequisite if one is required
|
||||
|
||||
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
|
||||
"""
|
||||
return gating_api.get_gating_milestone(course_key, content_key, relationship)
|
||||
|
||||
def is_gate_fulfilled(self, course_key, gating_content_key, user_id):
|
||||
"""
|
||||
Determines if a prerequisite section specified by gating_content_key
|
||||
has any unfulfilled milestones.
|
||||
|
||||
Arguments:
|
||||
course_key (CourseUsageLocator): Course locator
|
||||
gating_content_key (BlockUsageLocator): The locator for the section content
|
||||
user_id: The id of the user
|
||||
|
||||
Returns:
|
||||
Returns True if section has no unfufilled milestones or is not a prerequisite.
|
||||
Returns False otherwise
|
||||
"""
|
||||
return gating_api.is_gate_fulfilled(course_key, gating_content_key, user_id)
|
||||
@@ -1,10 +1,13 @@
|
||||
"""
|
||||
Tests for the gating API
|
||||
"""
|
||||
import unittest
|
||||
|
||||
from mock import patch
|
||||
from mock import patch, Mock
|
||||
from nose.plugins.attrib import attr
|
||||
from ddt import ddt, data
|
||||
from django.conf import settings
|
||||
from lms.djangoapps.gating import api as lms_gating_api
|
||||
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
|
||||
@@ -175,3 +178,74 @@ class TestGatingApi(ModuleStoreTestCase, MilestonesTestCaseMixin):
|
||||
milestones_api.add_user_milestone({'id': student.id}, milestone) # pylint: disable=no-member
|
||||
|
||||
self.assertEqual(gating_api.get_gated_content(self.course, student), [])
|
||||
|
||||
def test_is_gate_fulfilled(self):
|
||||
"""
|
||||
Test if prereq section has any unfulfilled milestones
|
||||
"""
|
||||
student = UserFactory(is_staff=False)
|
||||
gating_api.add_prerequisite(self.course.id, self.seq1.location)
|
||||
gating_api.set_required_content(self.course.id, self.seq2.location, self.seq1.location, 100)
|
||||
milestone = milestones_api.add_milestone(self.generic_milestone)
|
||||
milestones_api.add_course_content_milestone(self.course.id, self.seq1.location, 'fulfills', milestone)
|
||||
|
||||
self.assertFalse(gating_api.is_gate_fulfilled(self.course.id, self.seq1.location, student.id))
|
||||
|
||||
# complete the prerequisite to unlock the gated content
|
||||
# this call triggers reevaluation of prerequisites fulfilled by the gating block.
|
||||
with patch.object(gating_api, '_get_subsection_percentage') as mock_grade:
|
||||
mock_grade.return_value = 75
|
||||
lms_gating_api.evaluate_prerequisite(
|
||||
self.course,
|
||||
Mock(location=self.seq1.location),
|
||||
student,
|
||||
)
|
||||
self.assertFalse(gating_api.is_gate_fulfilled(self.course.id, self.seq1.location, student.id))
|
||||
|
||||
mock_grade.return_value = 100
|
||||
lms_gating_api.evaluate_prerequisite(
|
||||
self.course,
|
||||
Mock(location=self.seq1.location),
|
||||
student,
|
||||
)
|
||||
self.assertTrue(gating_api.is_gate_fulfilled(self.course.id, self.seq1.location, student.id))
|
||||
|
||||
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
|
||||
def test_compute_is_prereq_met(self):
|
||||
"""
|
||||
Test if prereq has been met and force recompute
|
||||
"""
|
||||
student = UserFactory(is_staff=False)
|
||||
gating_api.add_prerequisite(self.course.id, self.seq1.location)
|
||||
gating_api.set_required_content(self.course.id, self.seq2.location, self.seq1.location, 100)
|
||||
|
||||
# complete the prerequisite to unlock the gated content
|
||||
# this call triggers reevaluation of prerequisites fulfilled by the gating block.
|
||||
with patch.object(gating_api, '_get_subsection_percentage') as mock_grade:
|
||||
mock_grade.return_value = 75
|
||||
# don't force recompute
|
||||
prereq_met, prereq_meta_info = gating_api.compute_is_prereq_met(self.seq2.location, student.id, False)
|
||||
self.assertFalse(prereq_met)
|
||||
self.assertIsNone(prereq_meta_info['url'])
|
||||
self.assertIsNone(prereq_meta_info['display_name'])
|
||||
|
||||
# force recompute
|
||||
prereq_met, prereq_meta_info = gating_api.compute_is_prereq_met(self.seq2.location, student.id, True)
|
||||
self.assertFalse(prereq_met)
|
||||
self.assertIsNotNone(prereq_meta_info['url'])
|
||||
self.assertIsNotNone(prereq_meta_info['display_name'])
|
||||
|
||||
# change to passing grade
|
||||
mock_grade.return_value = 100
|
||||
|
||||
# don't force recompute
|
||||
prereq_met, prereq_meta_info = gating_api.compute_is_prereq_met(self.seq2.location, student.id, False)
|
||||
self.assertFalse(prereq_met)
|
||||
self.assertIsNone(prereq_meta_info['url'])
|
||||
self.assertIsNone(prereq_meta_info['display_name'])
|
||||
|
||||
# force recompute
|
||||
prereq_met, prereq_meta_info = gating_api.compute_is_prereq_met(self.seq2.location, student.id, True)
|
||||
self.assertTrue(prereq_met)
|
||||
self.assertIsNotNone(prereq_meta_info['url'])
|
||||
self.assertIsNotNone(prereq_meta_info['display_name'])
|
||||
|
||||
@@ -35,9 +35,34 @@ from openedx.core.djangolib.markup import HTML, Text
|
||||
>
|
||||
<div class="subsection-text">
|
||||
## Subsection title
|
||||
<span class="subsection-title">${ subsection['display_name'] }</span>
|
||||
|
||||
<span class="subsection-title">
|
||||
% if subsection['id'] in gated_content:
|
||||
% if gated_content[subsection['id']]['completed_prereqs']:
|
||||
<span class="menu-icon icon fa fa-unlock"
|
||||
aria-hidden="true">
|
||||
</span>
|
||||
<span class="subsection-title-name">
|
||||
${ subsection['display_name'] }
|
||||
</span>
|
||||
<span class="sr"> ${_("Unlocked")}</span>
|
||||
% else:
|
||||
<span class="menu-icon icon fa fa-lock"
|
||||
aria-hidden="true">
|
||||
</span>
|
||||
<span class="subsection-title-name">
|
||||
${ subsection['display_name'] }
|
||||
</span>
|
||||
<span class="details">
|
||||
${ _("(Prerequisite required)") }
|
||||
</span>
|
||||
% endif
|
||||
% else:
|
||||
<span class="subsection-title-name">
|
||||
${ subsection['display_name'] }
|
||||
</span>
|
||||
% endif
|
||||
<div class="details">
|
||||
|
||||
## There are behavior differences between rendering of subsections which have
|
||||
## exams (timed, graded, etc) and those that do not.
|
||||
##
|
||||
@@ -91,7 +116,7 @@ from openedx.core.djangolib.markup import HTML, Text
|
||||
class="menu-icon icon fa fa-pencil-square-o"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<span class="sr">${_("This content is graded")}</span>
|
||||
<span class="sr"> ${_("This content is graded")}</span>
|
||||
% endif
|
||||
% endif
|
||||
</span>
|
||||
|
||||
@@ -176,7 +176,7 @@ class TestCourseHomePage(CourseHomePageTestCase):
|
||||
course_home_url(self.course)
|
||||
|
||||
# Fetch the view and verify the query counts
|
||||
with self.assertNumQueries(49, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST):
|
||||
with self.assertNumQueries(50, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST):
|
||||
with check_mongo_calls(4):
|
||||
url = course_home_url(self.course)
|
||||
self.client.get(url)
|
||||
|
||||
@@ -7,17 +7,23 @@ import json
|
||||
from django.core.urlresolvers import reverse
|
||||
from pyquery import PyQuery as pq
|
||||
from six import text_type
|
||||
from gating import api as lms_gating_api
|
||||
from mock import patch, Mock
|
||||
|
||||
from courseware.tests.factories import StaffFactory
|
||||
from openedx.core.lib.gating import api as gating_api
|
||||
from student.models import CourseEnrollment
|
||||
from student.tests.factories import UserFactory
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
|
||||
from milestones.tests.utils import MilestonesTestCaseMixin
|
||||
from lms.djangoapps.course_api.blocks.transformers.milestones import MilestonesAndSpecialExamsTransformer
|
||||
|
||||
from .test_course_home import course_home_url
|
||||
|
||||
TEST_PASSWORD = 'test'
|
||||
GATING_NAMESPACE_QUALIFIER = '.gating'
|
||||
|
||||
|
||||
class TestCourseOutlinePage(SharedModuleStoreTestCase):
|
||||
@@ -108,6 +114,138 @@ class TestCourseOutlinePage(SharedModuleStoreTestCase):
|
||||
self.assertNotIn(vertical.display_name, response_content)
|
||||
|
||||
|
||||
class TestCourseOutlinePageWithPrerequisites(SharedModuleStoreTestCase, MilestonesTestCaseMixin):
|
||||
"""
|
||||
Test the course outline view with prerequisites.
|
||||
"""
|
||||
TRANSFORMER_CLASS_TO_TEST = MilestonesAndSpecialExamsTransformer
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
"""
|
||||
Creates a test course that can be used for non-destructive tests
|
||||
"""
|
||||
|
||||
cls.PREREQ_REQUIRED = '(Prerequisite required)'
|
||||
cls.UNLOCKED = 'Unlocked'
|
||||
|
||||
with super(TestCourseOutlinePageWithPrerequisites, cls).setUpClassAndTestData():
|
||||
cls.course, cls.course_blocks = cls.create_test_course()
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
"""Set up and enroll our fake user in the course."""
|
||||
cls.user = UserFactory(password=TEST_PASSWORD)
|
||||
CourseEnrollment.enroll(cls.user, cls.course.id)
|
||||
|
||||
@classmethod
|
||||
def create_test_course(cls):
|
||||
"""Creates a test course."""
|
||||
|
||||
course = CourseFactory.create()
|
||||
course.enable_subsection_gating = True
|
||||
course_blocks = {}
|
||||
with cls.store.bulk_operations(course.id):
|
||||
course_blocks['chapter'] = ItemFactory.create(category='chapter', parent_location=course.location)
|
||||
course_blocks['prerequisite'] = ItemFactory.create(category='sequential', parent_location=course_blocks['chapter'].location, display_name='Prerequisite Exam')
|
||||
course_blocks['gated_content'] = ItemFactory.create(category='sequential', parent_location=course_blocks['chapter'].location, display_name='Gated Content')
|
||||
course_blocks['prerequisite_vertical'] = ItemFactory.create(category='vertical', parent_location=course_blocks['prerequisite'].location)
|
||||
course_blocks['gated_content_vertical'] = ItemFactory.create(category='vertical', parent_location=course_blocks['gated_content'].location)
|
||||
course.children = [course_blocks['chapter']]
|
||||
course_blocks['chapter'].children = [course_blocks['prerequisite'], course_blocks['gated_content']]
|
||||
course_blocks['prerequisite'].children = [course_blocks['prerequisite_vertical']]
|
||||
course_blocks['gated_content'].children = [course_blocks['gated_content_vertical']]
|
||||
if hasattr(cls, 'user'):
|
||||
CourseEnrollment.enroll(cls.user, course.id)
|
||||
return course, course_blocks
|
||||
|
||||
def setUp(self):
|
||||
"""
|
||||
Set up for the tests.
|
||||
"""
|
||||
super(TestCourseOutlinePageWithPrerequisites, self).setUp()
|
||||
self.client.login(username=self.user.username, password=TEST_PASSWORD)
|
||||
|
||||
def setup_gated_section(self, gated_block, gating_block):
|
||||
"""
|
||||
Test helper to create a gating requirement
|
||||
Args:
|
||||
gated_block: The block the that learner will not have access to until they complete the gating block
|
||||
gating_block: (The prerequisite) The block that must be completed to get access to the gated block
|
||||
"""
|
||||
|
||||
gating_api.add_prerequisite(self.course.id, unicode(gating_block.location))
|
||||
gating_api.set_required_content(self.course.id, gated_block.location, gating_block.location, 100)
|
||||
|
||||
def test_content_locked(self):
|
||||
"""
|
||||
Test that a sequential/subsection with unmet prereqs correctly indicated that its content is locked
|
||||
"""
|
||||
course = self.course
|
||||
self.setup_gated_section(self.course_blocks['gated_content'], self.course_blocks['prerequisite'])
|
||||
|
||||
response = self.client.get(course_home_url(course))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response_content = pq(response.content)
|
||||
|
||||
# check lock icon is present
|
||||
lock_icon = response_content('.fa-lock')
|
||||
self.assertTrue(lock_icon, "lock icon is not present, but should be")
|
||||
|
||||
subsection = lock_icon.parents('.subsection-title')
|
||||
|
||||
# check that subsection-title-name is the display name
|
||||
gated_subsection_title = self.course_blocks['gated_content'].display_name
|
||||
self.assertIn(gated_subsection_title, subsection.children('.subsection-title-name').html())
|
||||
|
||||
# check that it says prerequisite required
|
||||
self.assertIn(self.PREREQ_REQUIRED, subsection.children('.details').html())
|
||||
|
||||
# check that there is not a screen reader message
|
||||
self.assertFalse(subsection.children('.sr'))
|
||||
|
||||
def test_content_unlocked(self):
|
||||
"""
|
||||
Test that a sequential/subsection with unmet prereqs correctly indicated that its content is locked
|
||||
"""
|
||||
course = self.course
|
||||
self.setup_gated_section(self.course_blocks['gated_content'], self.course_blocks['prerequisite'])
|
||||
|
||||
# complete the prerequisite to unlock the gated content
|
||||
# this call triggers reevaluation of prerequisites fulfilled by the gating block.
|
||||
with patch('openedx.core.lib.gating.api._get_subsection_percentage', Mock(return_value=100)):
|
||||
lms_gating_api.evaluate_prerequisite(
|
||||
self.course,
|
||||
Mock(location=self.course_blocks['prerequisite'].location),
|
||||
self.user,
|
||||
)
|
||||
|
||||
response = self.client.get(course_home_url(course))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response_content = pq(response.content)
|
||||
|
||||
# check unlock icon is present
|
||||
unlock_icon = response_content('.fa-unlock')
|
||||
self.assertTrue(unlock_icon, "unlock icon is not present, but should be")
|
||||
|
||||
subsection = unlock_icon.parents('.subsection-title')
|
||||
|
||||
# check that subsection-title-name is the display name of gated content section
|
||||
gated_subsection_title = self.course_blocks['gated_content'].display_name
|
||||
self.assertIn(gated_subsection_title, subsection.children('.subsection-title-name').html())
|
||||
|
||||
# check that it doesn't say prerequisite required
|
||||
self.assertNotIn(self.PREREQ_REQUIRED, subsection.children('.subsection-title-name').html())
|
||||
|
||||
# check that there is a screen reader message
|
||||
self.assertTrue(subsection.children('.sr'))
|
||||
|
||||
# check that the screen reader message is correct
|
||||
self.assertIn(self.UNLOCKED, subsection.children('.sr').html())
|
||||
|
||||
|
||||
class TestCourseOutlineResumeCourse(SharedModuleStoreTestCase):
|
||||
"""
|
||||
Test start course and resume course for the course outline view.
|
||||
|
||||
@@ -126,7 +126,7 @@ class TestCourseUpdatesPage(SharedModuleStoreTestCase):
|
||||
course_updates_url(self.course)
|
||||
|
||||
# Fetch the view and verify that the query counts haven't changed
|
||||
with self.assertNumQueries(31, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST):
|
||||
with self.assertNumQueries(30, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST):
|
||||
with check_mongo_calls(4):
|
||||
url = course_updates_url(self.course)
|
||||
self.client.get(url)
|
||||
|
||||
@@ -67,7 +67,7 @@ def get_course_outline_block_tree(request, course_id):
|
||||
course_usage_key,
|
||||
user=request.user,
|
||||
nav_depth=3,
|
||||
requested_fields=['children', 'display_name', 'type', 'due', 'graded', 'special_exam_info', 'format'],
|
||||
requested_fields=['children', 'display_name', 'type', 'due', 'graded', 'special_exam_info', 'show_gated_sections', 'format'],
|
||||
block_types_filter=['course', 'chapter', 'sequential']
|
||||
)
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ from courseware.courses import get_course_overview_with_access
|
||||
from openedx.core.djangoapps.plugin_api.views import EdxFragmentView
|
||||
|
||||
from ..utils import get_course_outline_block_tree
|
||||
from util.milestones_helpers import get_course_content_milestones
|
||||
from xmodule.modulestore.django import modulestore
|
||||
|
||||
|
||||
class CourseOutlineFragmentView(EdxFragmentView):
|
||||
@@ -28,10 +30,34 @@ class CourseOutlineFragmentView(EdxFragmentView):
|
||||
if not course_block_tree:
|
||||
return None
|
||||
|
||||
content_milestones = self.get_content_milestones(request, course_key)
|
||||
|
||||
context = {
|
||||
'csrf': csrf(request)['csrf_token'],
|
||||
'course': course_overview,
|
||||
'blocks': course_block_tree,
|
||||
'gated_content': content_milestones
|
||||
}
|
||||
html = render_to_string('course_experience/course-outline-fragment.html', context)
|
||||
return Fragment(html)
|
||||
|
||||
def get_content_milestones(self, request, course_key):
|
||||
"""
|
||||
Returns dict of subsections with prerequisites and whether the prerequisite has been completed or not
|
||||
"""
|
||||
|
||||
all_course_prereqs = get_course_content_milestones(course_key)
|
||||
|
||||
content_ids_of_unfulfilled_prereqs = [
|
||||
milestone['content_id']
|
||||
for milestone in get_course_content_milestones(course_key, user_id=request.user.id)
|
||||
]
|
||||
|
||||
course_content_milestones = {
|
||||
milestone['content_id']: {
|
||||
'completed_prereqs': milestone['content_id'] not in content_ids_of_unfulfilled_prereqs
|
||||
}
|
||||
for milestone in all_course_prereqs
|
||||
}
|
||||
|
||||
return course_content_milestones
|
||||
|
||||
Reference in New Issue
Block a user