diff --git a/common/lib/xmodule/xmodule/seq_module.py b/common/lib/xmodule/xmodule/seq_module.py index ce91ba7b6f..63df229408 100644 --- a/common/lib/xmodule/xmodule/seq_module.py +++ b/common/lib/xmodule/xmodule/seq_module.py @@ -11,11 +11,11 @@ import logging from datetime import datetime from functools import reduce -from pkg_resources import resource_string - import six +from django.contrib.auth.models import User from lxml import etree from opaque_keys.edx.keys import UsageKey +from pkg_resources import resource_string from pytz import UTC from six import text_type from web_fragments.fragment import Fragment @@ -24,6 +24,8 @@ 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 .exceptions import NotFoundError from .fields import Date from .mako_module import MakoModuleDescriptor @@ -46,6 +48,12 @@ class_priority = ['video', 'problem'] # `django.utils.translation.ugettext_noop` because Django cannot be imported in this file _ = lambda text: text +TIMED_EXAM_GATING_WAFFLE_FLAG = WaffleFlag( + waffle_namespace="xmodule", + flag_name=u'rev_1377_rollout', + module_name=__name__, +) + class SequenceFields(object): has_children = True @@ -196,6 +204,7 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule): def __init__(self, *args, **kwargs): super(SequenceModule, self).__init__(*args, **kwargs) + self.gated_sequence_fragment = None # If position is specified in system, then use that instead. position = getattr(self.system, 'position', None) if position is not None: @@ -278,6 +287,32 @@ 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 + if self._required_prereq(): if self.runtime.user_is_staff: banner_text = _('This subsection is unlocked for learners when they meet the prerequisite requirements.') @@ -291,6 +326,7 @@ 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) + return self._student_or_public_view(context, prereq_met, prereq_meta_info, banner_text) def public_view(self, context): @@ -318,7 +354,7 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule): staff is masquerading. """ _ = self.runtime.service(self, "i18n").ugettext - if self.is_time_limited: + 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.") @@ -370,6 +406,7 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule): ) items = self._render_student_view_for_items(context, display_items, fragment, view) if prereq_met else [] + params = { 'items': items, 'element_id': self.location.html_id(), @@ -384,8 +421,12 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule): 'save_position': view != PUBLIC_VIEW, '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) } + if self.gated_sequence_fragment: + params['gated_sequence_fragment'] = self.gated_sequence_fragment.content + return params def _student_or_public_view(self, context, prereq_met, prereq_meta_info, banner_text=None, view=STUDENT_VIEW): diff --git a/common/lib/xmodule/xmodule/tests/test_sequence.py b/common/lib/xmodule/xmodule/tests/test_sequence.py index cded440e51..03919a5d4d 100644 --- a/common/lib/xmodule/xmodule/tests/test_sequence.py +++ b/common/lib/xmodule/xmodule/tests/test_sequence.py @@ -15,7 +15,9 @@ from freezegun import freeze_time from mock import Mock, patch from six.moves import range -from xmodule.seq_module import SequenceModule +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 from xmodule.tests import get_test_system from xmodule.tests.helpers import StubUserService from xmodule.tests.xml import XModuleXmlImportTest @@ -60,6 +62,7 @@ class SequenceBlockTestCase(XModuleXmlImportTest): xml.ChapterFactory.build(parent=course) # has 0 child sequences chapter_3 = xml.ChapterFactory.build(parent=course) # has 1 child sequence chapter_4 = xml.ChapterFactory.build(parent=course) # has 1 child sequence, with hide_after_due + chapter_5 = xml.ChapterFactory.build(parent=course) # has 1 child sequence, with a time limit xml.SequenceFactory.build(parent=chapter_1) xml.SequenceFactory.build(parent=chapter_1) @@ -73,6 +76,11 @@ class SequenceBlockTestCase(XModuleXmlImportTest): for _ in range(3): xml.VerticalFactory.build(parent=sequence_3_1) + xml.SequenceFactory.build( + parent=chapter_5, + is_time_limited=str(True) + ) + return course def _set_up_block(self, parent, index_in_parent): @@ -149,6 +157,30 @@ class SequenceBlockTestCase(XModuleXmlImportTest): self.assertIn("'prev_url': 'PrevSequential'", html) self.assertNotIn("fa fa-check-circle check-circle is-hidden", html) + @patch('xmodule.seq_module.User.objects.get', return_value=UserFactory.build()) + def test_timed_exam_gating_waffle_flag(self, mocked_user): + """ + 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 + """ + # the order of the overrides is important since the `assert_not_called` does + # not appear to be limited to just the override_waffle_flag = False scope + with override_waffle_flag(TIMED_EXAM_GATING_WAFFLE_FLAG, active=False): + self._get_rendered_view( + self.sequence_5_1, + extra_context=dict(next_url='NextSequential', prev_url='PrevSequential'), + view=STUDENT_VIEW + ) + mocked_user.assert_not_called() + + with override_waffle_flag(TIMED_EXAM_GATING_WAFFLE_FLAG, active=True): + self._get_rendered_view( + self.sequence_5_1, + extra_context=dict(next_url='NextSequential', prev_url='PrevSequential'), + view=STUDENT_VIEW + ) + mocked_user.assert_called_once() + @ddt.unpack @ddt.data( {'view': STUDENT_VIEW}, diff --git a/lms/templates/seq_module.html b/lms/templates/seq_module.html index 6a49a60b6e..68b99334d5 100644 --- a/lms/templates/seq_module.html +++ b/lms/templates/seq_module.html @@ -21,6 +21,7 @@ % endif % endif + % if not gated_sequence_fragment:
+ % endif % if not exclude_units: % 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']"/> + % elif gated_sequence_fragment: +

+ ${sequence_name}${_("Content Locked")} +

+ ${gated_sequence_fragment | n, decode.utf8} % else: