Merge pull request #17083 from edx/bfiller/gated-content-combined

Conditionally display gated content in outline and courseware
This commit is contained in:
Bill Filler
2018-01-19 12:41:11 -05:00
committed by GitHub
28 changed files with 753 additions and 104 deletions

View File

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

View File

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

View File

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

View File

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

View File

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