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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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">&nbsp</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>

View File

@@ -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!')}&nbsp;</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">

View File

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

View File

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

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

View File

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

View File

@@ -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">&nbsp;${_("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">&nbsp;${_("This content is graded")}</span>
% endif
% endif
</span>

View File

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

View File

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

View File

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

View File

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

View File

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