[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:
committed by
GitHub
parent
555ac7fd08
commit
95811992ca
@@ -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):
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user