Merge pull request #25151 from edx/REV-1510
[REV-1510] Gate sequence if it is a timed exam and contains content type gated problems
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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
|
||||
|
||||
32
openedx/features/content_type_gating/services.py
Normal file
32
openedx/features/content_type_gating/services.py
Normal file
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user