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:
Matthew Piatetsky
2020-10-14 16:54:39 -04:00
committed by GitHub
5 changed files with 257 additions and 25 deletions

View File

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

View File

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

View File

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

View 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

View File

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