From ae7d0a1ed8b32ffe247f5bdb9ae28b50504324f5 Mon Sep 17 00:00:00 2001 From: Matthew Piatetsky Date: Mon, 1 Feb 2021 22:15:42 -0500 Subject: [PATCH] Return content type gate for staff users when masquerading as the Learner in Audit or Learner in Limited Access Roles This is necessary to display the content type gate in the UI AA-613 --- common/lib/xmodule/xmodule/seq_module.py | 45 ++++---------- .../xmodule/xmodule/tests/test_sequence.py | 60 +++---------------- lms/djangoapps/courseware/tests/test_views.py | 4 +- .../core/djangoapps/courseware_api/views.py | 7 +++ .../features/content_type_gating/services.py | 48 ++++++++++++++- .../content_type_gating/tests/test_access.py | 44 +++++++++++++- 6 files changed, 117 insertions(+), 91 deletions(-) diff --git a/common/lib/xmodule/xmodule/seq_module.py b/common/lib/xmodule/xmodule/seq_module.py index 5f56f0da17..3fe16f99bd 100644 --- a/common/lib/xmodule/xmodule/seq_module.py +++ b/common/lib/xmodule/xmodule/seq_module.py @@ -27,7 +27,6 @@ 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 get_children, leaf_filter, traverse_pre_order from .exceptions import NotFoundError from .fields import Date @@ -283,37 +282,6 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule): datetime.now(UTC) < date ) - def _get_user(self): - """ - Return the current runtime Django user. - """ - return get_user_model().objects.get(id=self.runtime.user_id) - - 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: @@ -344,7 +312,11 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule): Note that this will break compatability with using sequences outside of edx-platform but we are ok with this for now """ - self.gated_sequence_paywall = self._check_children_for_content_type_gating_paywall(self) + content_type_gating_service = self.runtime.service(self, 'content_type_gating') + if content_type_gating_service: + self.gated_sequence_paywall = content_type_gating_service.check_children_for_content_type_gating_paywall( + self, self.course_id + ) def student_view(self, context): _ = self.runtime.service(self, "i18n").ugettext @@ -669,7 +641,12 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule): else: content = '' - contains_content_type_gated_content = self._check_children_for_content_type_gating_paywall(item) is not None + content_type_gating_service = self.runtime.service(self, 'content_type_gating') + contains_content_type_gated_content = False + if content_type_gating_service: + contains_content_type_gated_content = content_type_gating_service.check_children_for_content_type_gating_paywall( # pylint:disable=line-too-long + item, self.course_id + ) is not None iteminfo = { 'content': content, 'page_title': getattr(item, 'tooltip_title', ''), diff --git a/common/lib/xmodule/xmodule/tests/test_sequence.py b/common/lib/xmodule/xmodule/tests/test_sequence.py index 00bfbd3d4e..e8c93923e9 100644 --- a/common/lib/xmodule/xmodule/tests/test_sequence.py +++ b/common/lib/xmodule/xmodule/tests/test_sequence.py @@ -144,13 +144,12 @@ 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, mocked_user, view): # pylint: disable=unused-argument + def test_render_student_view(self, view): html = self._get_rendered_view( self.sequence_3_1, extra_context=dict(next_url='NextSequential', prev_url='PrevSequential'), @@ -164,9 +163,8 @@ class SequenceBlockTestCase(XModuleXmlImportTest): 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()) @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 + def test_timed_exam_gating_waffle_flag(self, mocked_function): # 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 @@ -190,11 +188,9 @@ class SequenceBlockTestCase(XModuleXmlImportTest): 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()) - def test_that_timed_sequence_gating_respects_access_configurations(self, mocked_user): # pylint: disable=unused-argument + def test_that_timed_sequence_gating_respects_access_configurations(self): """ Verify that if a time limited sequence contains content type gated problems, we gate the sequence - Verify that if a time limited sequence does not contain content type gated problems, we do not gate the sequence """ # the one problem in this sequence needs to have graded set to true in order to test content type gating self.sequence_5_1.get_children()[0].get_children()[0].graded = True @@ -202,8 +198,7 @@ class SequenceBlockTestCase(XModuleXmlImportTest): # When a time limited sequence contains content type gated problems, the sequence itself is gated 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=gated_fragment) + check_children_for_content_type_gating_paywall=Mock(return_value=gated_fragment.content), )) view = self._get_rendered_view( self.sequence_5_1, @@ -216,62 +211,27 @@ class SequenceBlockTestCase(XModuleXmlImportTest): self.assertIn('NextSequential', view) self.assertIn('PrevSequential', view) - # When enabled_for_enrollment is false, the sequence itself is not gated - self.sequence_5_1.runtime._services['content_type_gating'] = Mock(return_value=Mock( # pylint: disable=protected-access - enabled_for_enrollment=Mock(return_value=False), - content_type_gate_for_block=Mock(return_value=gated_fragment) - )) - view = self._get_rendered_view( - self.sequence_5_1, - extra_context=dict(next_url='NextSequential', prev_url='PrevSequential'), - view=STUDENT_VIEW - ) - self.assertNotIn('i_am_gated', view) - # check a few elements to ensure the correct page was loaded - self.assertIn("seq_module.html", view) - self.assertIn('NextSequential', view) - self.assertIn('PrevSequential', view) - - # When content_type_gate_for_block returns None, the sequence itself is not gated - 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=None) - )) - view = self._get_rendered_view( - self.sequence_5_1, - extra_context=dict(next_url='NextSequential', prev_url='PrevSequential'), - view=STUDENT_VIEW - ) - self.assertNotIn('i_am_gated', view) - # check a few elements to ensure the correct page was loaded - self.assertIn("seq_module.html", view) - 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, mocked_user, view): # pylint: disable=unused-argument + def test_student_view_first_child(self, view): 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, mocked_user, view): # pylint: disable=unused-argument + def test_student_view_last_child(self, view): html = self._get_rendered_view(self.sequence_3_1, requested_child='last', view=view) self._assert_view_at_position(html, expected_position=3) - @patch('xmodule.seq_module.SequenceModule._get_user', return_value=UserFactory.build()) - def test_tooltip(self, mocked_user): # pylint: disable=unused-argument + def test_tooltip(self): 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) @@ -445,8 +405,7 @@ class SequenceBlockTestCase(XModuleXmlImportTest): ) self.assertIs(completion_return, None) - @patch('xmodule.seq_module.SequenceModule._get_user', return_value=UserFactory.build()) - def test_handle_ajax_metadata(self, mocked_user): # pylint: disable=unused-argument + def test_handle_ajax_metadata(self): """ Test that the sequence metadata is returned from the metadata ajax handler. @@ -459,11 +418,10 @@ 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 + def test_handle_ajax_metadata_content_type_gated_content(self): """ The contains_content_type_gated_content field should reflect whether the given item contains content type gated content diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py index 87ae45ae01..1ec90fbce2 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, 174), - (ModuleStoreEnum.Type.split, 4, 170), + (ModuleStoreEnum.Type.mongo, 10, 172), + (ModuleStoreEnum.Type.split, 4, 168), ) @ddt.unpack def test_index_query_counts(self, store_type, expected_mongo_query_count, expected_mysql_query_count): diff --git a/openedx/core/djangoapps/courseware_api/views.py b/openedx/core/djangoapps/courseware_api/views.py index 98dd432b31..51dc84bd1a 100644 --- a/openedx/core/djangoapps/courseware_api/views.py +++ b/openedx/core/djangoapps/courseware_api/views.py @@ -479,6 +479,13 @@ class SequenceMetadata(DeveloperErrorViewMixin, APIView): except InvalidKeyError: raise NotFound("Invalid usage key: '{}'.".format(usage_key_string)) + _, request.user = setup_masquerade( + request, + usage_key.course_key, + staff_access=has_access(request.user, 'staff', usage_key.course_key), + reset_masquerade_data=True, + ) + sequence, _ = get_module_by_usage_id( self.request, str(usage_key.course_key), diff --git a/openedx/features/content_type_gating/services.py b/openedx/features/content_type_gating/services.py index b1c8f6622b..f7b2381867 100644 --- a/openedx/features/content_type_gating/services.py +++ b/openedx/features/content_type_gating/services.py @@ -1,8 +1,13 @@ """ Content Type Gating service. """ +import crum from lms.djangoapps.courseware.access import has_access +from lms.djangoapps.courseware.masquerade import ( + is_masquerading_as_limited_access, is_masquerading_as_audit_enrollment, +) +from openedx.core.lib.graph_traversals import get_children, leaf_filter, traverse_pre_order from openedx.features.content_type_gating.models import ContentTypeGatingConfig @@ -12,13 +17,23 @@ class ContentTypeGatingService(object): and field overrides to gate course content. This service was created as a helper class for handling timed exams that contain content type gated problems. """ - def enabled_for_enrollment(self, **kwargs): + def _enabled_for_enrollment(self, **kwargs): """ Returns whether content type gating is enabled for a given user/course pair """ return ContentTypeGatingConfig.enabled_for_enrollment(**kwargs) - def content_type_gate_for_block(self, user, block, course_id): + def _is_masquerading_as_audit_or_limited_access(self, user, course_id): + return (is_masquerading_as_limited_access(user, course_id) or + is_masquerading_as_audit_enrollment(user, course_id)) + + def _get_user(self): + """ + Return the current request user. + """ + return crum.get_current_user() + + def _content_type_gate_for_block(self, user, block, course_id): """ Returns a Fragment of the content type gate (if any) that would appear for a given block """ @@ -29,4 +44,33 @@ class ContentTypeGatingService(object): access = has_access(user, 'load', block, course_id) if (not access and access.error_code == 'incorrect_user_group'): return access.user_fragment + + return None + + def check_children_for_content_type_gating_paywall(self, item, course_id): + """ + Arguments: + item (xblock such as a sequence or vertical block) + course_id (CourseLocator) + + 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 + """ + user = self._get_user() + if not user: return None + + if not self._enabled_for_enrollment(user=user, course_key=course_id): + return None + + # Check children for content type gated content + for block in traverse_pre_order(item, get_children, leaf_filter): + gate_fragment = self._content_type_gate_for_block(user, block, course_id) + if gate_fragment is not None: + return gate_fragment.content + + return None diff --git a/openedx/features/content_type_gating/tests/test_access.py b/openedx/features/content_type_gating/tests/test_access.py index faa0baae42..4134518e45 100644 --- a/openedx/features/content_type_gating/tests/test_access.py +++ b/openedx/features/content_type_gating/tests/test_access.py @@ -1156,7 +1156,7 @@ class TestContentTypeGatingService(ModuleStoreTestCase): # The method returns a content type gate for blocks that should be gated self.assertIn( 'content-paywall', - ContentTypeGatingService().content_type_gate_for_block( + ContentTypeGatingService()._content_type_gate_for_block( self.user, blocks_dict['graded_1'], course['course'].id ).content ) @@ -1164,7 +1164,47 @@ class TestContentTypeGatingService(ModuleStoreTestCase): # The method returns None for blocks that should not be gated self.assertEquals( None, - ContentTypeGatingService().content_type_gate_for_block( + ContentTypeGatingService()._content_type_gate_for_block( self.user, blocks_dict['not_graded_1'], course['course'].id ) ) + + @patch.object(ContentTypeGatingService, '_get_user', return_value=UserFactory.build()) + def test_check_children_for_content_type_gating_paywall(self, mocked_user): # pylint: disable=unused-argument + ''' Verify that the method returns a content type gate when appropriate ''' + course = self._create_course() + blocks_dict = course['blocks'] + CourseEnrollmentFactory.create( + user=self.user, + course_id=course['course'].id, + mode='audit' + ) + blocks_dict['not_graded_1'] = ItemFactory.create( + parent=blocks_dict['vertical'], + category='problem', + graded=False, + metadata=METADATA, + ) + + # The method returns a content type gate for blocks that should be gated + self.assertEquals( + None, + ContentTypeGatingService().check_children_for_content_type_gating_paywall( + blocks_dict['vertical'], course['course'].id + ) + ) + + blocks_dict['graded_1'] = ItemFactory.create( + parent=blocks_dict['vertical'], + category='problem', + graded=True, + metadata=METADATA, + ) + + # The method returns None for blocks that should not be gated + self.assertIn( + 'content-paywall', + ContentTypeGatingService().check_children_for_content_type_gating_paywall( + blocks_dict['vertical'], course['course'].id + ) + )