From 762060022062ab123ca5fab1e703cd7ab48c4164 Mon Sep 17 00:00:00 2001 From: Matthew Piatetsky Date: Wed, 23 Sep 2020 09:12:11 -0400 Subject: [PATCH 1/4] Revert "Revert "[REV-774] Show content type gating upsell when a limited access learner tries to access a timed exam"" --- common/lib/xmodule/xmodule/seq_module.py | 1 + 1 file changed, 1 insertion(+) diff --git a/common/lib/xmodule/xmodule/seq_module.py b/common/lib/xmodule/xmodule/seq_module.py index 63df229408..7dd23b785f 100644 --- a/common/lib/xmodule/xmodule/seq_module.py +++ b/common/lib/xmodule/xmodule/seq_module.py @@ -287,6 +287,7 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule): prereq_met = True prereq_meta_info = {} + if TIMED_EXAM_GATING_WAFFLE_FLAG.is_enabled(): # Content type gating for FBE previously only gated individual blocks # This was an issue because audit learners could start a timed exam and then be unable to complete the exam From be2d534488006c47e730d0b725d7707cfc8b1ecf Mon Sep 17 00:00:00 2001 From: Matthew Piatetsky Date: Tue, 29 Sep 2020 16:23:14 -0400 Subject: [PATCH 2/4] Gate sequence if it is a timed exam and contains content type gated problems The previous attempt to implement this was bugged because it was not respecting manual exceptions to content type gating. This is fixed by checking has_access for each child problem of the sequence. REV-1510 --- common/lib/xmodule/xmodule/seq_module.py | 94 ++++++++++++++----- .../xmodule/xmodule/tests/test_sequence.py | 63 ++++++++++++- 2 files changed, 131 insertions(+), 26 deletions(-) diff --git a/common/lib/xmodule/xmodule/seq_module.py b/common/lib/xmodule/xmodule/seq_module.py index 7dd23b785f..ab22747b52 100644 --- a/common/lib/xmodule/xmodule/seq_module.py +++ b/common/lib/xmodule/xmodule/seq_module.py @@ -279,6 +279,74 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule): datetime.now(UTC) < date ) + def _has_access_check(self, *args): + """ + Helper method created for the following method + gate_sequence_if_it_is_a_timed_exam_and_contains_content_type_gated_problems + Created to simplify the relevant unit tests + This way the has_access call can be patched without impacting other has_access calls made during the test + """ + # importing here to avoid a circular import + from lms.djangoapps.courseware.access import has_access + return has_access(*args) + + def gate_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. + This was an issue because audit learners could start a timed exam + and then be unable to complete it because the graded content would be gated. + Even if they later upgraded, they could still be unable to complete the exam + because the timer could have expired. + + Solution: + Gate the entire sequence when we think the above problem can occur. + + If: + 1. This sequence is a timed exam + 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. + Otherwise, learners would have the ability to start their timer for an exam, + but then not have the ability to complete it. + + 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. + + 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 + """ + # importing here to avoid a circular import + from openedx.features.content_type_gating.models import ContentTypeGatingConfig + + if not self.is_time_limited: + return + + try: + user = User.objects.get(id=self.runtime.user_id) + if not ContentTypeGatingConfig.enabled_for_enrollment(user=user, course_key=self.runtime.course_id): + return + + for vertical in self.get_children(): + for block in vertical.get_children(): + problem_eligible_for_content_gating = (getattr(block, 'graded', False) and + block.has_score and + getattr(block, 'weight', 0) != 0) + if problem_eligible_for_content_gating: + access = self._has_access_check(user, 'load', block, self.course_id) + # If any block has been gated by content type gating inside the sequence + # and the sequence is a timed exam, 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. + if not access and access.error_code == 'incorrect_user_group': + self.gated_sequence_fragment = access.user_fragment + break + self.gated_sequence_fragment = None # Don't gate other cases + except User.DoesNotExist: + pass + def student_view(self, context): _ = self.runtime.service(self, "i18n").ugettext context = context or {} @@ -287,32 +355,8 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule): prereq_met = True prereq_meta_info = {} - if TIMED_EXAM_GATING_WAFFLE_FLAG.is_enabled(): - # Content type gating for FBE previously only gated individual blocks - # This was an issue because audit learners could start a timed exam and then be unable to complete the exam - # even if they later upgrade because the timer would have expired. - # For this reason we check if content gating is enabled for the user - # and gate the entire sequence in that case - # 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 self.is_time_limited: - try: - user = User.objects.get(id=self.runtime.user_id) - # importing here to avoid a circular import - from openedx.features.content_type_gating.models import ContentTypeGatingConfig - from openedx.features.content_type_gating.helpers import CONTENT_GATING_PARTITION_ID - if ContentTypeGatingConfig.enabled_for_enrollment(user=user, course_key=self.runtime.course_id): - # Get the content type gating locked content fragment to render for this sequence - partition = self.descriptor._get_user_partition(CONTENT_GATING_PARTITION_ID) # pylint: disable=protected-access - user_group = partition.scheme.get_group_for_user(self.runtime.course_id, user, partition) - self.gated_sequence_fragment = partition.access_denied_fragment( - self.descriptor, user, user_group, [] - ) - except User.DoesNotExist: - pass + 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: diff --git a/common/lib/xmodule/xmodule/tests/test_sequence.py b/common/lib/xmodule/xmodule/tests/test_sequence.py index 03919a5d4d..6ed038fff6 100644 --- a/common/lib/xmodule/xmodule/tests/test_sequence.py +++ b/common/lib/xmodule/xmodule/tests/test_sequence.py @@ -14,7 +14,9 @@ from django.utils.timezone import now from freezegun import freeze_time from mock import Mock, patch from six.moves import range +from web_fragments.fragment import Fragment +from lms.djangoapps.courseware.access_response import AccessResponse from openedx.core.djangoapps.waffle_utils.testutils import override_waffle_flag from student.tests.factories import UserFactory from xmodule.seq_module import TIMED_EXAM_GATING_WAFFLE_FLAG, SequenceModule @@ -76,10 +78,12 @@ class SequenceBlockTestCase(XModuleXmlImportTest): for _ in range(3): xml.VerticalFactory.build(parent=sequence_3_1) - xml.SequenceFactory.build( + sequence_5_1 = xml.SequenceFactory.build( parent=chapter_5, is_time_limited=str(True) ) + vertical_5_1 = xml.VerticalFactory.build(parent=sequence_5_1) + xml.ProblemFactory.build(parent=vertical_5_1) return course @@ -181,6 +185,63 @@ class SequenceBlockTestCase(XModuleXmlImportTest): ) mocked_user.assert_called_once() + @override_waffle_flag(TIMED_EXAM_GATING_WAFFLE_FLAG, active=True) + @patch('xmodule.seq_module.User.objects.get', return_value=UserFactory.build()) + @patch('openedx.features.content_type_gating.models.ContentTypeGatingConfig.enabled_for_enrollment', + return_value=True) + def test_that_timed_sequence_gating_respects_access_configurations(self, mocked_user, mocked_config): # pylint: disable=unused-argument + """ + Verify that if a time limited sequence contains content type gated problems, we gate the sequence + Verify that if a time limited sequence contains gated problems, but not due to content type gating, + then sequence is not gated + Verify that if all problems in a time limited sequence can be accessed, the sequence is not gated + """ + # 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 + gated_fragment = Fragment('i_am_gated') + + # When a time limited sequence contains content type gated problems, the sequence itself is gated + content_gating_error = AccessResponse(False, error_code="incorrect_user_group", user_fragment=gated_fragment) + with patch.object(SequenceModule, '_has_access_check', return_value=content_gating_error): + view = self._get_rendered_view( + self.sequence_5_1, + extra_context=dict(next_url='NextSequential', prev_url='PrevSequential'), + view=STUDENT_VIEW + ) + self.assertIn('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 a time limited sequence contains inaccessible problems for reasons other than content type gating + # the sequence is not gated, because handling these cases was out of scope for this ticket + some_other_error = AccessResponse(False, error_code="some_other_error", user_fragment=gated_fragment) + with patch.object(SequenceModule, '_has_access_check', return_value=some_other_error): + 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 all problems inside a time limited sequence can be accessed, the sequence is not gated + with patch.object(SequenceModule, '_has_access_check', return_value=AccessResponse(True)): + 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) + @ddt.unpack @ddt.data( {'view': STUDENT_VIEW}, From 2153057142df983a638b785ea5af0d54dd042957 Mon Sep 17 00:00:00 2001 From: Matthew Piatetsky Date: Thu, 8 Oct 2020 14:32:10 -0400 Subject: [PATCH 3/4] Add XBlock service for content type gating This is used in this commit to check if a timed exam contains any problems that have been gated by content type gating REV-1510 --- common/lib/xmodule/xmodule/seq_module.py | 70 +++++++------- .../xmodule/xmodule/tests/test_sequence.py | 91 ++++++++++--------- lms/djangoapps/courseware/module_render.py | 2 + .../features/content_type_gating/services.py | 32 +++++++ .../content_type_gating/tests/test_access.py | 83 +++++++++++++++++ 5 files changed, 202 insertions(+), 76 deletions(-) create mode 100644 openedx/features/content_type_gating/services.py diff --git a/common/lib/xmodule/xmodule/seq_module.py b/common/lib/xmodule/xmodule/seq_module.py index ab22747b52..1723d502b7 100644 --- a/common/lib/xmodule/xmodule/seq_module.py +++ b/common/lib/xmodule/xmodule/seq_module.py @@ -19,12 +19,14 @@ from pkg_resources import resource_string from pytz import UTC from six import text_type from web_fragments.fragment import Fragment + from xblock.completable import XBlockCompletionMode from xblock.core import XBlock from xblock.exceptions import NoSuchServiceError from xblock.fields import Boolean, Integer, List, Scope, String from openedx.core.djangoapps.waffle_utils import WaffleFlag +from openedx.core.lib.graph_traversals import traverse_pre_order from .exceptions import NotFoundError from .fields import Date @@ -189,6 +191,7 @@ class ProctoringFields(object): @XBlock.needs('user') @XBlock.needs('bookmarks') @XBlock.needs('i18n') +@XBlock.wants('content_type_gating') class SequenceModule(SequenceFields, ProctoringFields, XModule): """ Layout module which lays out content in a temporal sequence @@ -279,17 +282,6 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule): datetime.now(UTC) < date ) - def _has_access_check(self, *args): - """ - Helper method created for the following method - gate_sequence_if_it_is_a_timed_exam_and_contains_content_type_gated_problems - Created to simplify the relevant unit tests - This way the has_access call can be patched without impacting other has_access calls made during the test - """ - # importing here to avoid a circular import - from lms.djangoapps.courseware.access import has_access - return has_access(*args) - def gate_sequence_if_it_is_a_timed_exam_and_contains_content_type_gated_problems(self): """ Problem: @@ -313,39 +305,55 @@ 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. + 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 """ - # importing here to avoid a circular import - from openedx.features.content_type_gating.models import ContentTypeGatingConfig - if not self.is_time_limited: + self.gated_sequence_fragment = None return try: user = User.objects.get(id=self.runtime.user_id) - if not ContentTypeGatingConfig.enabled_for_enrollment(user=user, course_key=self.runtime.course_id): + 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 - for vertical in self.get_children(): - for block in vertical.get_children(): - problem_eligible_for_content_gating = (getattr(block, 'graded', False) and - block.has_score and - getattr(block, 'weight', 0) != 0) - if problem_eligible_for_content_gating: - access = self._has_access_check(user, 'load', block, self.course_id) - # If any block has been gated by content type gating inside the sequence - # and the sequence is a timed exam, 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. - if not access and access.error_code == 'incorrect_user_group': - self.gated_sequence_fragment = access.user_fragment - break - self.gated_sequence_fragment = None # Don't gate other cases + 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 + len(getattr(block, 'children', [])) == 0 + ) + + def get_children(parent): + # This function is used to get the children of a block in the traversal below + if not hasattr(parent, 'children'): + return [] + else: + return parent.get_children() + + # 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: - pass + self.gated_sequence_fragment = None def student_view(self, context): _ = self.runtime.service(self, "i18n").ugettext diff --git a/common/lib/xmodule/xmodule/tests/test_sequence.py b/common/lib/xmodule/xmodule/tests/test_sequence.py index 6ed038fff6..318d788157 100644 --- a/common/lib/xmodule/xmodule/tests/test_sequence.py +++ b/common/lib/xmodule/xmodule/tests/test_sequence.py @@ -16,7 +16,6 @@ from mock import Mock, patch from six.moves import range from web_fragments.fragment import Fragment -from lms.djangoapps.courseware.access_response import AccessResponse from openedx.core.djangoapps.waffle_utils.testutils import override_waffle_flag from student.tests.factories import UserFactory from xmodule.seq_module import TIMED_EXAM_GATING_WAFFLE_FLAG, SequenceModule @@ -187,60 +186,62 @@ class SequenceBlockTestCase(XModuleXmlImportTest): @override_waffle_flag(TIMED_EXAM_GATING_WAFFLE_FLAG, active=True) @patch('xmodule.seq_module.User.objects.get', return_value=UserFactory.build()) - @patch('openedx.features.content_type_gating.models.ContentTypeGatingConfig.enabled_for_enrollment', - return_value=True) - def test_that_timed_sequence_gating_respects_access_configurations(self, mocked_user, mocked_config): # pylint: disable=unused-argument + def test_that_timed_sequence_gating_respects_access_configurations(self, mocked_user): # pylint: disable=unused-argument """ Verify that if a time limited sequence contains content type gated problems, we gate the sequence - Verify that if a time limited sequence contains gated problems, but not due to content type gating, - then sequence is not gated - Verify that if all problems in a time limited sequence can be accessed, the sequence is not gated + 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 gated_fragment = Fragment('i_am_gated') # When a time limited sequence contains content type gated problems, the sequence itself is gated - content_gating_error = AccessResponse(False, error_code="incorrect_user_group", user_fragment=gated_fragment) - with patch.object(SequenceModule, '_has_access_check', return_value=content_gating_error): - view = self._get_rendered_view( - self.sequence_5_1, - extra_context=dict(next_url='NextSequential', prev_url='PrevSequential'), - view=STUDENT_VIEW - ) - self.assertIn('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) + 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) + )) + view = self._get_rendered_view( + self.sequence_5_1, + extra_context=dict(next_url='NextSequential', prev_url='PrevSequential'), + view=STUDENT_VIEW + ) + self.assertIn('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 a time limited sequence contains inaccessible problems for reasons other than content type gating - # the sequence is not gated, because handling these cases was out of scope for this ticket - some_other_error = AccessResponse(False, error_code="some_other_error", user_fragment=gated_fragment) - with patch.object(SequenceModule, '_has_access_check', return_value=some_other_error): - 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 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 all problems inside a time limited sequence can be accessed, the sequence is not gated - with patch.object(SequenceModule, '_has_access_check', return_value=AccessResponse(True)): - 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) @ddt.unpack @ddt.data( diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index 56bdccb55d..39bd0f47ef 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -87,6 +87,7 @@ from openedx.core.lib.xblock_utils import request_token as xblock_request_token from openedx.core.lib.xblock_utils import wrap_xblock from openedx.features.course_duration_limits.access import course_expiration_wrapper from openedx.features.discounts.utils import offer_banner_wrapper +from openedx.features.content_type_gating.services import ContentTypeGatingService from student.models import anonymous_id_for_user, user_by_anonymous_id from student.roles import CourseBetaTesterRole from track import contexts @@ -821,6 +822,7 @@ def get_module_system_for_user( 'gating': GatingService(), 'grade_utils': GradesUtilService(course_id=course_id), 'user_state': UserStateService(), + 'content_type_gating': ContentTypeGatingService(), }, get_user_role=lambda: get_user_role(user, course_id), descriptor_runtime=descriptor._runtime, # pylint: disable=protected-access diff --git a/openedx/features/content_type_gating/services.py b/openedx/features/content_type_gating/services.py new file mode 100644 index 0000000000..b1c8f6622b --- /dev/null +++ b/openedx/features/content_type_gating/services.py @@ -0,0 +1,32 @@ +""" +Content Type Gating service. +""" + +from lms.djangoapps.courseware.access import has_access +from openedx.features.content_type_gating.models import ContentTypeGatingConfig + + +class ContentTypeGatingService(object): + """ + Content Type Gating uses Block Transformers to gate sections of the course outline + 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): + """ + 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): + """ + Returns a Fragment of the content type gate (if any) that would appear for a given block + """ + problem_eligible_for_content_gating = (getattr(block, 'graded', False) and + block.has_score and + getattr(block, 'weight', 0) != 0) + if problem_eligible_for_content_gating: + access = has_access(user, 'load', block, course_id) + if (not access and access.error_code == 'incorrect_user_group'): + return access.user_fragment + 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 10b59e7f9a..8d4fde3986 100644 --- a/openedx/features/content_type_gating/tests/test_access.py +++ b/openedx/features/content_type_gating/tests/test_access.py @@ -45,6 +45,7 @@ from openedx.core.lib.url_utils import quote_slashes from openedx.features.content_type_gating.helpers import CONTENT_GATING_PARTITION_ID, CONTENT_TYPE_GATE_GROUP_IDS from openedx.features.content_type_gating.models import ContentTypeGatingConfig from openedx.features.content_type_gating.partitions import ContentTypeGatingPartition +from openedx.features.content_type_gating.services import ContentTypeGatingService from student.models import CourseEnrollment, FBEEnrollmentExclusion from student.roles import CourseInstructorRole from student.tests.factories import TEST_PASSWORD, CourseEnrollmentFactory, UserFactory @@ -1085,3 +1086,85 @@ class TestMessageDeduplication(ModuleStoreTestCase): is_gated=True, request_factory=self.request_factory, ) + + +@override_settings(FIELD_OVERRIDE_PROVIDERS=( + 'openedx.features.content_type_gating.field_override.ContentTypeGatingFieldOverride', +)) +class TestContentTypeGatingService(ModuleStoreTestCase): + """ + The ContentTypeGatingService was originally created as a helper class for timed exams + to check whether a sequence contains content type gated blocks + The content_type_gate_for_block can be used to return the content type gate for a given block + """ + + def setUp(self): + super(TestContentTypeGatingService, self).setUp() + + self.user = UserFactory.create() + self.request_factory = RequestFactory() + ContentTypeGatingConfig.objects.create(enabled=True, enabled_as_of=datetime(2018, 1, 1)) + + def _create_course(self): + course = CourseFactory.create(run='test', display_name='test') + CourseModeFactory.create(course_id=course.id, mode_slug='audit') + CourseModeFactory.create(course_id=course.id, mode_slug='verified') + blocks_dict = {} + with self.store.bulk_operations(course.id): + blocks_dict['chapter'] = ItemFactory.create( + parent=course, + category='chapter', + display_name='Week 1' + ) + blocks_dict['sequential'] = ItemFactory.create( + parent=blocks_dict['chapter'], + category='sequential', + display_name='Lesson 1' + ) + blocks_dict['vertical'] = ItemFactory.create( + parent=blocks_dict['sequential'], + category='vertical', + display_name='Lesson 1 Vertical - Unit 1' + ) + return { + 'course': course, + 'blocks': blocks_dict, + } + + def test_content_type_gate_for_block(self): + ''' 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['graded_1'] = ItemFactory.create( + parent=blocks_dict['vertical'], + category='problem', + graded=True, + metadata=METADATA, + ) + 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.assertIn( + 'content-paywall', + ContentTypeGatingService().content_type_gate_for_block( + self.user, blocks_dict['graded_1'], course['course'].id + ).content + ) + + # The method returns None for blocks that should not be gated + self.assertEquals( + None, + ContentTypeGatingService().content_type_gate_for_block( + self.user, blocks_dict['not_graded_1'], course['course'].id + ) + ) From dcecae4f5cef8053184aa7d26e01700f001d65d6 Mon Sep 17 00:00:00 2001 From: Matthew Piatetsky Date: Wed, 14 Oct 2020 15:52:41 -0400 Subject: [PATCH 4/4] address comments --- common/lib/xmodule/xmodule/seq_module.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/common/lib/xmodule/xmodule/seq_module.py b/common/lib/xmodule/xmodule/seq_module.py index 1723d502b7..984ae85097 100644 --- a/common/lib/xmodule/xmodule/seq_module.py +++ b/common/lib/xmodule/xmodule/seq_module.py @@ -331,15 +331,15 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule): # Other than the outer function here return ( block.location.block_type not in ('chapter', 'sequential', 'vertical') and - len(getattr(block, 'children', [])) == 0 + not block.has_children ) def get_children(parent): # This function is used to get the children of a block in the traversal below - if not hasattr(parent, 'children'): - return [] - else: + 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.