diff --git a/common/lib/xmodule/xmodule/seq_module.py b/common/lib/xmodule/xmodule/seq_module.py index 63df22940862c0eb1b013a26fa516c20f0f55a7d..984ae85097cf6f5a96ae91ef20143bb100d8c843 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,6 +282,79 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule): datetime.now(UTC) < date ) + 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. + + 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 + """ + if not self.is_time_limited: + self.gated_sequence_fragment = None + return + + try: + user = User.objects.get(id=self.runtime.user_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 + + 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 + def student_view(self, context): _ = self.runtime.service(self, "i18n").ugettext context = context or {} @@ -288,30 +364,7 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule): 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 03919a5d4d69277aba0439936ca5c05f8050d11a..318d78815794fe4c4327945d62b1959162355778 100644 --- a/common/lib/xmodule/xmodule/tests/test_sequence.py +++ b/common/lib/xmodule/xmodule/tests/test_sequence.py @@ -14,6 +14,7 @@ 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 openedx.core.djangoapps.waffle_utils.testutils import override_waffle_flag from student.tests.factories import UserFactory @@ -76,10 +77,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 +184,65 @@ 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()) + 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 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 + 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 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) + @ddt.unpack @ddt.data( {'view': STUDENT_VIEW}, diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index 56bdccb55d3941d986b63313d1681861d44bce95..39bd0f47eff25da9b377531192181fe58b58956a 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 0000000000000000000000000000000000000000..b1c8f6622b5ca1461ceb0832ebf29363b3ad5e85 --- /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 10b59e7f9a5dad860101b7175d05fe4c43d1f059..8d4fde3986653651068c026719487e7e848ed685 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 + ) + )