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:
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user