diff --git a/common/lib/xmodule/xmodule/seq_module.py b/common/lib/xmodule/xmodule/seq_module.py index 87ff101cf2..5f56f0da17 100644 --- a/common/lib/xmodule/xmodule/seq_module.py +++ b/common/lib/xmodule/xmodule/seq_module.py @@ -12,6 +12,7 @@ from datetime import datetime from functools import reduce import six +from django.contrib.auth import get_user_model from lxml import etree from opaque_keys.edx.keys import UsageKey from pkg_resources import resource_string @@ -26,7 +27,7 @@ from xblock.fields import Boolean, Integer, List, Scope, String from edx_toggles.toggles import LegacyWaffleFlag from edx_toggles.toggles import WaffleFlag from lms.djangoapps.courseware.toggles import COURSEWARE_PROCTORING_IMPROVEMENTS -from openedx.core.lib.graph_traversals import traverse_pre_order +from openedx.core.lib.graph_traversals import get_children, leaf_filter, traverse_pre_order from .exceptions import NotFoundError from .fields import Date @@ -207,7 +208,7 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule): def __init__(self, *args, **kwargs): super(SequenceModule, self).__init__(*args, **kwargs) - self.gated_sequence_fragment = None + self.gated_sequence_paywall = None # If position is specified in system, then use that instead. position = getattr(self.system, 'position', None) if position is not None: @@ -286,10 +287,34 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule): """ Return the current runtime Django user. """ - from django.contrib.auth.models import User - return User.objects.get(id=self.runtime.user_id) + return get_user_model().objects.get(id=self.runtime.user_id) - def gate_sequence_if_it_is_a_timed_exam_and_contains_content_type_gated_problems(self): + def _check_children_for_content_type_gating_paywall(self, item): + """ + If: + This xblock contains problems which this user cannot load due to content type gating + Then: + Return the first content type gating paywall (Fragment) + Else: + Return None + """ + try: + user = self._get_user() + course_id = self.runtime.course_id + content_type_gating_service = self.runtime.service(self, 'content_type_gating') + if not (content_type_gating_service and + content_type_gating_service.enabled_for_enrollment(user=user, course_key=course_id)): + return None + + for block in traverse_pre_order(item, get_children, leaf_filter): + gate_fragment = content_type_gating_service.content_type_gate_for_block(user, block, course_id) + if gate_fragment is not None: + return gate_fragment.content + except get_user_model().DoesNotExist: + pass + return None + + def gate_entire_sequence_if_it_is_a_timed_exam_and_contains_content_type_gated_problems(self): """ Problem: Content type gating for FBE (Feature Based Enrollments) previously only gated individual blocks. @@ -302,7 +327,7 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule): Gate the entire sequence when we think the above problem can occur. If: - 1. This sequence is a timed exam + 1. This sequence is a timed exam (this is currently being checked before calling) 2. And this sequence contains problems which this user cannot load due to content type gating Then: We will gate access to the entire sequence. @@ -312,55 +337,14 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule): We are displaying the gating fragment within the sequence, as is done for gating for prereqs, rather than content type gating the entire sequence because that would remove the next/previous navigation. - When gated_sequence_fragment is not set to None, the sequence will be gated. + When gated_sequence_paywall is not set to None, the sequence will be gated. This functionality still needs to be replicated in the frontend-app-learning courseware MFE The ticket to track this is https://openedx.atlassian.net/browse/REV-1220 Note that this will break compatability with using sequences outside of edx-platform but we are ok with this for now """ - if not self.is_time_limited: - self.gated_sequence_fragment = None - return - - try: - user = self._get_user() - course_id = self.runtime.course_id - content_type_gating_service = self.runtime.service(self, 'content_type_gating') - if not (content_type_gating_service and - content_type_gating_service.enabled_for_enrollment(user=user, course_key=course_id)): - self.gated_sequence_fragment = None - return - - def leaf_filter(block): - # This function is used to check if this is a leaf block - # Blocks with children are not currently gated by content type gating - # Other than the outer function here - return ( - block.location.block_type not in ('chapter', 'sequential', 'vertical') and - not block.has_children - ) - - def get_children(parent): - # This function is used to get the children of a block in the traversal below - if parent.has_children: - return parent.get_children() - else: - return [] - - # If any block inside a timed exam has been gated by content type gating - # then gate the entire sequence. - # In order to avoid scope creep, we are not handling other potential causes - # of access failures as part of this work. - for block in traverse_pre_order(self, get_children, leaf_filter): - gate_fragment = content_type_gating_service.content_type_gate_for_block(user, block, course_id) - if gate_fragment is not None: - self.gated_sequence_fragment = gate_fragment - return - else: - self.gated_sequence_fragment = None - except User.DoesNotExist: - self.gated_sequence_fragment = None + self.gated_sequence_paywall = self._check_children_for_content_type_gating_paywall(self) def student_view(self, context): _ = self.runtime.service(self, "i18n").ugettext @@ -369,10 +353,6 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule): banner_text = None prereq_met = True prereq_meta_info = {} - - if TIMED_EXAM_GATING_WAFFLE_FLAG.is_enabled(): - self.gate_sequence_if_it_is_a_timed_exam_and_contains_content_type_gated_problems() - if self._required_prereq(): if self.runtime.user_is_staff: banner_text = _('This subsection is unlocked for learners when they meet the prerequisite requirements.') @@ -414,11 +394,16 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule): staff is masquerading. """ _ = self.runtime.service(self, "i18n").ugettext - if self.is_time_limited and not self.gated_sequence_fragment: - special_exam_html = self._time_limited_student_view() - if special_exam_html: - banner_text = _("This exam is hidden from the learner.") - return banner_text, special_exam_html + + if self.is_time_limited: + if TIMED_EXAM_GATING_WAFFLE_FLAG.is_enabled(): + # set the self.gated_sequence_paywall variable + self.gate_entire_sequence_if_it_is_a_timed_exam_and_contains_content_type_gated_problems() + if self.gated_sequence_paywall is None: + special_exam_html = self._time_limited_student_view() + if special_exam_html: + banner_text = _("This exam is hidden from the learner.") + return banner_text, special_exam_html def _hidden_content_student_view(self, context): """ @@ -482,10 +467,9 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule): 'show_completion': view != PUBLIC_VIEW, 'gated_content': self._get_gated_content_info(prereq_met, prereq_meta_info), 'sequence_name': self.display_name, - 'exclude_units': context.get('exclude_units', False) + 'exclude_units': context.get('exclude_units', False), + 'gated_sequence_paywall': self.gated_sequence_paywall } - if self.gated_sequence_fragment: - params['gated_sequence_fragment'] = self.gated_sequence_fragment.content return params @@ -654,7 +638,8 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule): bookmarks_service = self.runtime.service(self, 'bookmarks') except NoSuchServiceError: bookmarks_service = None - context['username'] = self.runtime.service(self, 'user').get_current_user().opt_attrs.get( + user = self.runtime.service(self, 'user').get_current_user() + context['username'] = user.opt_attrs.get( 'edx-platform.username') display_names = [ self.get_parent().display_name_with_default, @@ -683,6 +668,8 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule): content = rendered_item.content else: content = '' + + contains_content_type_gated_content = self._check_children_for_content_type_gating_paywall(item) is not None iteminfo = { 'content': content, 'page_title': getattr(item, 'tooltip_title', ''), @@ -690,7 +677,8 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule): 'id': text_type(usage_id), 'bookmarked': is_bookmarked, 'path': " > ".join(display_names + [item.display_name_with_default]), - 'graded': item.graded + 'graded': item.graded, + 'contains_content_type_gated_content': contains_content_type_gated_content, } if not render_items: # The item url format can be defined in the template context like so: diff --git a/common/lib/xmodule/xmodule/tests/test_sequence.py b/common/lib/xmodule/xmodule/tests/test_sequence.py index 1e07e83ff5..00bfbd3d4e 100644 --- a/common/lib/xmodule/xmodule/tests/test_sequence.py +++ b/common/lib/xmodule/xmodule/tests/test_sequence.py @@ -6,10 +6,11 @@ Tests for sequence module. import ast import json -from datetime import timedelta +from datetime import datetime, timedelta import ddt import six +from django.test.utils import override_settings from django.utils.timezone import now from freezegun import freeze_time from mock import Mock, patch @@ -18,6 +19,7 @@ from web_fragments.fragment import Fragment from edx_toggles.toggles.testutils import override_waffle_flag from common.djangoapps.student.tests.factories import UserFactory +from openedx.features.content_type_gating.models import ContentTypeGatingConfig from xmodule.seq_module import TIMED_EXAM_GATING_WAFFLE_FLAG, SequenceModule from xmodule.tests import get_test_system from xmodule.tests.helpers import StubUserService @@ -142,12 +144,13 @@ class SequenceBlockTestCase(XModuleXmlImportTest): seq_module = SequenceModule(runtime=Mock(position=2), descriptor=Mock(), scope_ids=Mock()) self.assertEqual(seq_module.position, 2) # matches position set in the runtime + @patch('xmodule.seq_module.SequenceModule._get_user', return_value=UserFactory.build()) @ddt.unpack @ddt.data( {'view': STUDENT_VIEW}, {'view': PUBLIC_VIEW}, ) - def test_render_student_view(self, view): + def test_render_student_view(self, mocked_user, view): # pylint: disable=unused-argument html = self._get_rendered_view( self.sequence_3_1, extra_context=dict(next_url='NextSequential', prev_url='PrevSequential'), @@ -160,8 +163,10 @@ class SequenceBlockTestCase(XModuleXmlImportTest): self.assertIn("'prev_url': 'PrevSequential'", html) self.assertNotIn("fa fa-check-circle check-circle is-hidden", html) + # pylint: disable=line-too-long @patch('xmodule.seq_module.SequenceModule._get_user', return_value=UserFactory.build()) - def test_timed_exam_gating_waffle_flag(self, mocked_user): + @patch('xmodule.seq_module.SequenceModule.gate_entire_sequence_if_it_is_a_timed_exam_and_contains_content_type_gated_problems') + def test_timed_exam_gating_waffle_flag(self, mocked_function, mocked_user): # pylint: disable=unused-argument """ Verify the code inside the waffle flag is not executed with the flag off Verify the code inside the waffle flag is executed with the flag on @@ -174,7 +179,7 @@ class SequenceBlockTestCase(XModuleXmlImportTest): extra_context=dict(next_url='NextSequential', prev_url='PrevSequential'), view=STUDENT_VIEW ) - mocked_user.assert_not_called() + mocked_function.assert_not_called() with override_waffle_flag(TIMED_EXAM_GATING_WAFFLE_FLAG, active=True): self._get_rendered_view( @@ -182,7 +187,7 @@ class SequenceBlockTestCase(XModuleXmlImportTest): extra_context=dict(next_url='NextSequential', prev_url='PrevSequential'), view=STUDENT_VIEW ) - mocked_user.assert_called_once() + mocked_function.assert_called_once() @override_waffle_flag(TIMED_EXAM_GATING_WAFFLE_FLAG, active=True) @patch('xmodule.seq_module.SequenceModule._get_user', return_value=UserFactory.build()) @@ -243,27 +248,30 @@ class SequenceBlockTestCase(XModuleXmlImportTest): self.assertIn('NextSequential', view) self.assertIn('PrevSequential', view) + @patch('xmodule.seq_module.SequenceModule._get_user', return_value=UserFactory.build()) @ddt.unpack @ddt.data( {'view': STUDENT_VIEW}, {'view': PUBLIC_VIEW}, ) - def test_student_view_first_child(self, view): + def test_student_view_first_child(self, mocked_user, view): # pylint: disable=unused-argument html = self._get_rendered_view( self.sequence_3_1, requested_child='first', view=view ) self._assert_view_at_position(html, expected_position=1) + @patch('xmodule.seq_module.SequenceModule._get_user', return_value=UserFactory.build()) @ddt.unpack @ddt.data( {'view': STUDENT_VIEW}, {'view': PUBLIC_VIEW}, ) - def test_student_view_last_child(self, view): + def test_student_view_last_child(self, mocked_user, view): # pylint: disable=unused-argument html = self._get_rendered_view(self.sequence_3_1, requested_child='last', view=view) self._assert_view_at_position(html, expected_position=3) - def test_tooltip(self): + @patch('xmodule.seq_module.SequenceModule._get_user', return_value=UserFactory.build()) + def test_tooltip(self, mocked_user): # pylint: disable=unused-argument html = self._get_rendered_view(self.sequence_3_1, requested_child=None) for child in self.sequence_3_1.children: self.assertIn("'page_title': '{}'".format(child.block_id), html) @@ -437,7 +445,8 @@ class SequenceBlockTestCase(XModuleXmlImportTest): ) self.assertIs(completion_return, None) - def test_handle_ajax_metadata(self): + @patch('xmodule.seq_module.SequenceModule._get_user', return_value=UserFactory.build()) + def test_handle_ajax_metadata(self, mocked_user): # pylint: disable=unused-argument """ Test that the sequence metadata is returned from the metadata ajax handler. @@ -450,6 +459,29 @@ class SequenceBlockTestCase(XModuleXmlImportTest): self.assertEqual(metadata['tag'], 'sequential') self.assertEqual(metadata['display_name'], self.sequence_3_1.display_name_with_default) + @patch('xmodule.seq_module.SequenceModule._get_user', return_value=UserFactory.build()) + @override_settings(FIELD_OVERRIDE_PROVIDERS=( + 'openedx.features.content_type_gating.field_override.ContentTypeGatingFieldOverride', + )) + def test_handle_ajax_metadata_content_type_gated_content(self, mocked_user): # pylint: disable=unused-argument + """ + The contains_content_type_gated_content field should reflect + whether the given item contains content type gated content + """ + self.sequence_5_1.xmodule_runtime._services['bookmarks'] = None # pylint: disable=protected-access + ContentTypeGatingConfig.objects.create(enabled=True, enabled_as_of=datetime(2018, 1, 1)) + metadata = json.loads(self.sequence_5_1.handle_ajax('metadata', {})) + self.assertEqual(metadata['items'][0]['contains_content_type_gated_content'], False) + + # When a block contains content type gated problems, set the contains_content_type_gated_content field + self.sequence_5_1.get_children()[0].get_children()[0].graded = True + self.sequence_5_1.runtime._services['content_type_gating'] = Mock(return_value=Mock( # pylint: disable=protected-access + enabled_for_enrollment=Mock(return_value=True), + content_type_gate_for_block=Mock(return_value=Fragment('i_am_gated')) + )) + metadata = json.loads(self.sequence_5_1.handle_ajax('metadata', {})) + self.assertEqual(metadata['items'][0]['contains_content_type_gated_content'], True) + def get_context_dict_from_string(self, data): """ Retrieve dictionary from string. diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py index 4c62a494f6..87ae45ae01 100644 --- a/lms/djangoapps/courseware/tests/test_views.py +++ b/lms/djangoapps/courseware/tests/test_views.py @@ -269,8 +269,8 @@ class IndexQueryTestCase(ModuleStoreTestCase): NUM_PROBLEMS = 20 @ddt.data( - (ModuleStoreEnum.Type.mongo, 10, 171), - (ModuleStoreEnum.Type.split, 4, 167), + (ModuleStoreEnum.Type.mongo, 10, 174), + (ModuleStoreEnum.Type.split, 4, 170), ) @ddt.unpack def test_index_query_counts(self, store_type, expected_mongo_query_count, expected_mysql_query_count): diff --git a/lms/templates/seq_module.html b/lms/templates/seq_module.html index 68b99334d5..c27729d700 100644 --- a/lms/templates/seq_module.html +++ b/lms/templates/seq_module.html @@ -21,7 +21,7 @@ % endif % endif - % if not gated_sequence_fragment: + % if not gated_sequence_paywall: