[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.
This commit is contained in:
Matthew Piatetsky
2020-10-01 09:33:12 -04:00
committed by GitHub
parent 555ac7fd08
commit 95811992ca
3 changed files with 84 additions and 4 deletions

View File

@@ -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):

View File

@@ -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},

View File

@@ -21,6 +21,7 @@
</div>
% endif
% endif
% if not gated_sequence_fragment:
<div class="sequence-nav">
<button class="sequence-nav-button button-previous">
<span class="icon fa fa-chevron-prev" aria-hidden="true"></span>
@@ -98,9 +99,15 @@
</ol>
</nav>
</div>
% 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:
<h2 class="hd hd-2 unit-title">
${sequence_name}<span class="sr">${_("Content Locked")}</span>
</h2>
${gated_sequence_fragment | n, decode.utf8}
% else:
<div class="sr-is-focusable" tabindex="-1"></div>