Add contains_content_type_gated_content attribute to display items in the Sequence Metadata API. This attribute is used to display the content type gating paywall in frontend-app-learning.

Also, refactor existing timed exam code that checks for content_type_gated_content in a sequence to make it try with the new code
AA-613
This commit is contained in:
Matthew Piatetsky
2021-01-22 12:55:27 -05:00
parent e0e03dec5f
commit 84fb4679c9
5 changed files with 99 additions and 78 deletions

View File

@@ -12,6 +12,7 @@ from datetime import datetime
from functools import reduce
import six
from django.contrib.auth import get_user_model
from lxml import etree
from opaque_keys.edx.keys import UsageKey
from pkg_resources import resource_string
@@ -26,7 +27,7 @@ from xblock.fields import Boolean, Integer, List, Scope, String
from edx_toggles.toggles import LegacyWaffleFlag
from edx_toggles.toggles import WaffleFlag
from lms.djangoapps.courseware.toggles import COURSEWARE_PROCTORING_IMPROVEMENTS
from openedx.core.lib.graph_traversals import traverse_pre_order
from openedx.core.lib.graph_traversals import get_children, leaf_filter, traverse_pre_order
from .exceptions import NotFoundError
from .fields import Date
@@ -207,7 +208,7 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule):
def __init__(self, *args, **kwargs):
super(SequenceModule, self).__init__(*args, **kwargs)
self.gated_sequence_fragment = None
self.gated_sequence_paywall = None
# If position is specified in system, then use that instead.
position = getattr(self.system, 'position', None)
if position is not None:
@@ -286,10 +287,34 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule):
"""
Return the current runtime Django user.
"""
from django.contrib.auth.models import User
return User.objects.get(id=self.runtime.user_id)
return get_user_model().objects.get(id=self.runtime.user_id)
def gate_sequence_if_it_is_a_timed_exam_and_contains_content_type_gated_problems(self):
def _check_children_for_content_type_gating_paywall(self, item):
"""
If:
This xblock contains problems which this user cannot load due to content type gating
Then:
Return the first content type gating paywall (Fragment)
Else:
Return None
"""
try:
user = self._get_user()
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)):
return None
for block in traverse_pre_order(item, 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:
return gate_fragment.content
except get_user_model().DoesNotExist:
pass
return None
def gate_entire_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.
@@ -302,7 +327,7 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule):
Gate the entire sequence when we think the above problem can occur.
If:
1. This sequence is a timed exam
1. This sequence is a timed exam (this is currently being checked before calling)
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.
@@ -312,55 +337,14 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule):
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.
When gated_sequence_paywall 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 = self._get_user()
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
self.gated_sequence_paywall = self._check_children_for_content_type_gating_paywall(self)
def student_view(self, context):
_ = self.runtime.service(self, "i18n").ugettext
@@ -369,10 +353,6 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule):
banner_text = None
prereq_met = True
prereq_meta_info = {}
if TIMED_EXAM_GATING_WAFFLE_FLAG.is_enabled():
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:
banner_text = _('This subsection is unlocked for learners when they meet the prerequisite requirements.')
@@ -414,11 +394,16 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule):
staff is masquerading.
"""
_ = self.runtime.service(self, "i18n").ugettext
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.")
return banner_text, special_exam_html
if self.is_time_limited:
if TIMED_EXAM_GATING_WAFFLE_FLAG.is_enabled():
# set the self.gated_sequence_paywall variable
self.gate_entire_sequence_if_it_is_a_timed_exam_and_contains_content_type_gated_problems()
if self.gated_sequence_paywall is None:
special_exam_html = self._time_limited_student_view()
if special_exam_html:
banner_text = _("This exam is hidden from the learner.")
return banner_text, special_exam_html
def _hidden_content_student_view(self, context):
"""
@@ -482,10 +467,9 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule):
'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)
'exclude_units': context.get('exclude_units', False),
'gated_sequence_paywall': self.gated_sequence_paywall
}
if self.gated_sequence_fragment:
params['gated_sequence_fragment'] = self.gated_sequence_fragment.content
return params
@@ -654,7 +638,8 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule):
bookmarks_service = self.runtime.service(self, 'bookmarks')
except NoSuchServiceError:
bookmarks_service = None
context['username'] = self.runtime.service(self, 'user').get_current_user().opt_attrs.get(
user = self.runtime.service(self, 'user').get_current_user()
context['username'] = user.opt_attrs.get(
'edx-platform.username')
display_names = [
self.get_parent().display_name_with_default,
@@ -683,6 +668,8 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule):
content = rendered_item.content
else:
content = ''
contains_content_type_gated_content = self._check_children_for_content_type_gating_paywall(item) is not None
iteminfo = {
'content': content,
'page_title': getattr(item, 'tooltip_title', ''),
@@ -690,7 +677,8 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule):
'id': text_type(usage_id),
'bookmarked': is_bookmarked,
'path': " > ".join(display_names + [item.display_name_with_default]),
'graded': item.graded
'graded': item.graded,
'contains_content_type_gated_content': contains_content_type_gated_content,
}
if not render_items:
# The item url format can be defined in the template context like so:

View File

@@ -6,10 +6,11 @@ Tests for sequence module.
import ast
import json
from datetime import timedelta
from datetime import datetime, timedelta
import ddt
import six
from django.test.utils import override_settings
from django.utils.timezone import now
from freezegun import freeze_time
from mock import Mock, patch
@@ -18,6 +19,7 @@ from web_fragments.fragment import Fragment
from edx_toggles.toggles.testutils import override_waffle_flag
from common.djangoapps.student.tests.factories import UserFactory
from openedx.features.content_type_gating.models import ContentTypeGatingConfig
from xmodule.seq_module import TIMED_EXAM_GATING_WAFFLE_FLAG, SequenceModule
from xmodule.tests import get_test_system
from xmodule.tests.helpers import StubUserService
@@ -142,12 +144,13 @@ class SequenceBlockTestCase(XModuleXmlImportTest):
seq_module = SequenceModule(runtime=Mock(position=2), descriptor=Mock(), scope_ids=Mock())
self.assertEqual(seq_module.position, 2) # matches position set in the runtime
@patch('xmodule.seq_module.SequenceModule._get_user', return_value=UserFactory.build())
@ddt.unpack
@ddt.data(
{'view': STUDENT_VIEW},
{'view': PUBLIC_VIEW},
)
def test_render_student_view(self, view):
def test_render_student_view(self, mocked_user, view): # pylint: disable=unused-argument
html = self._get_rendered_view(
self.sequence_3_1,
extra_context=dict(next_url='NextSequential', prev_url='PrevSequential'),
@@ -160,8 +163,10 @@ class SequenceBlockTestCase(XModuleXmlImportTest):
self.assertIn("'prev_url': 'PrevSequential'", html)
self.assertNotIn("fa fa-check-circle check-circle is-hidden", html)
# pylint: disable=line-too-long
@patch('xmodule.seq_module.SequenceModule._get_user', return_value=UserFactory.build())
def test_timed_exam_gating_waffle_flag(self, mocked_user):
@patch('xmodule.seq_module.SequenceModule.gate_entire_sequence_if_it_is_a_timed_exam_and_contains_content_type_gated_problems')
def test_timed_exam_gating_waffle_flag(self, mocked_function, mocked_user): # pylint: disable=unused-argument
"""
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
@@ -174,7 +179,7 @@ class SequenceBlockTestCase(XModuleXmlImportTest):
extra_context=dict(next_url='NextSequential', prev_url='PrevSequential'),
view=STUDENT_VIEW
)
mocked_user.assert_not_called()
mocked_function.assert_not_called()
with override_waffle_flag(TIMED_EXAM_GATING_WAFFLE_FLAG, active=True):
self._get_rendered_view(
@@ -182,7 +187,7 @@ class SequenceBlockTestCase(XModuleXmlImportTest):
extra_context=dict(next_url='NextSequential', prev_url='PrevSequential'),
view=STUDENT_VIEW
)
mocked_user.assert_called_once()
mocked_function.assert_called_once()
@override_waffle_flag(TIMED_EXAM_GATING_WAFFLE_FLAG, active=True)
@patch('xmodule.seq_module.SequenceModule._get_user', return_value=UserFactory.build())
@@ -243,27 +248,30 @@ class SequenceBlockTestCase(XModuleXmlImportTest):
self.assertIn('NextSequential', view)
self.assertIn('PrevSequential', view)
@patch('xmodule.seq_module.SequenceModule._get_user', return_value=UserFactory.build())
@ddt.unpack
@ddt.data(
{'view': STUDENT_VIEW},
{'view': PUBLIC_VIEW},
)
def test_student_view_first_child(self, view):
def test_student_view_first_child(self, mocked_user, view): # pylint: disable=unused-argument
html = self._get_rendered_view(
self.sequence_3_1, requested_child='first', view=view
)
self._assert_view_at_position(html, expected_position=1)
@patch('xmodule.seq_module.SequenceModule._get_user', return_value=UserFactory.build())
@ddt.unpack
@ddt.data(
{'view': STUDENT_VIEW},
{'view': PUBLIC_VIEW},
)
def test_student_view_last_child(self, view):
def test_student_view_last_child(self, mocked_user, view): # pylint: disable=unused-argument
html = self._get_rendered_view(self.sequence_3_1, requested_child='last', view=view)
self._assert_view_at_position(html, expected_position=3)
def test_tooltip(self):
@patch('xmodule.seq_module.SequenceModule._get_user', return_value=UserFactory.build())
def test_tooltip(self, mocked_user): # pylint: disable=unused-argument
html = self._get_rendered_view(self.sequence_3_1, requested_child=None)
for child in self.sequence_3_1.children:
self.assertIn("'page_title': '{}'".format(child.block_id), html)
@@ -437,7 +445,8 @@ class SequenceBlockTestCase(XModuleXmlImportTest):
)
self.assertIs(completion_return, None)
def test_handle_ajax_metadata(self):
@patch('xmodule.seq_module.SequenceModule._get_user', return_value=UserFactory.build())
def test_handle_ajax_metadata(self, mocked_user): # pylint: disable=unused-argument
"""
Test that the sequence metadata is returned from the
metadata ajax handler.
@@ -450,6 +459,29 @@ class SequenceBlockTestCase(XModuleXmlImportTest):
self.assertEqual(metadata['tag'], 'sequential')
self.assertEqual(metadata['display_name'], self.sequence_3_1.display_name_with_default)
@patch('xmodule.seq_module.SequenceModule._get_user', return_value=UserFactory.build())
@override_settings(FIELD_OVERRIDE_PROVIDERS=(
'openedx.features.content_type_gating.field_override.ContentTypeGatingFieldOverride',
))
def test_handle_ajax_metadata_content_type_gated_content(self, mocked_user): # pylint: disable=unused-argument
"""
The contains_content_type_gated_content field should reflect
whether the given item contains content type gated content
"""
self.sequence_5_1.xmodule_runtime._services['bookmarks'] = None # pylint: disable=protected-access
ContentTypeGatingConfig.objects.create(enabled=True, enabled_as_of=datetime(2018, 1, 1))
metadata = json.loads(self.sequence_5_1.handle_ajax('metadata', {}))
self.assertEqual(metadata['items'][0]['contains_content_type_gated_content'], False)
# When a block contains content type gated problems, set the contains_content_type_gated_content field
self.sequence_5_1.get_children()[0].get_children()[0].graded = True
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=Fragment('i_am_gated'))
))
metadata = json.loads(self.sequence_5_1.handle_ajax('metadata', {}))
self.assertEqual(metadata['items'][0]['contains_content_type_gated_content'], True)
def get_context_dict_from_string(self, data):
"""
Retrieve dictionary from string.

View File

@@ -269,8 +269,8 @@ class IndexQueryTestCase(ModuleStoreTestCase):
NUM_PROBLEMS = 20
@ddt.data(
(ModuleStoreEnum.Type.mongo, 10, 171),
(ModuleStoreEnum.Type.split, 4, 167),
(ModuleStoreEnum.Type.mongo, 10, 174),
(ModuleStoreEnum.Type.split, 4, 170),
)
@ddt.unpack
def test_index_query_counts(self, store_type, expected_mongo_query_count, expected_mysql_query_count):

View File

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

View File

@@ -483,7 +483,8 @@ class SequenceMetadata(DeveloperErrorViewMixin, APIView):
self.request,
str(usage_key.course_key),
str(usage_key),
disable_staff_debug_info=True)
disable_staff_debug_info=True,
will_recheck_access=True)
view = STUDENT_VIEW
if request.user.is_anonymous: