From 95811992ca4a3c0fcf1e6584b67c905f4ff36b39 Mon Sep 17 00:00:00 2001 From: Matthew Piatetsky Date: Thu, 1 Oct 2020 09:33:12 -0400 Subject: [PATCH] [REV-1508] Add functionality to limit timed exam access behind waffle flag (#25054) Re-applied bugfix #24214, but behind a waffle flag. The change gates timed exams for audit learners. We would like to limit access to timed exams to verified learners. However, some of the instructors would like to make their content free for all users, including graded content/timed exams (hence the Feature-Based Enrollment "FBE" exemption). As a result, we will be rolling the gated timed exams functionality out behind a waffle flag now, add the FBE exemption when we figure out how, and gradually turn the waffle flag on for everyone. --- common/lib/xmodule/xmodule/seq_module.py | 47 +++++++++++++++++-- .../xmodule/xmodule/tests/test_sequence.py | 34 +++++++++++++- lms/templates/seq_module.html | 7 +++ 3 files changed, 84 insertions(+), 4 deletions(-) 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: