Commit 9d747fa6 by Nimisha Asthagiri Committed by GitHub

Merge pull request #12975 from edx/tnl/hide_content

LMS Hide Subsection Content
parents 3302b26e 37b8e636
......@@ -2,6 +2,10 @@
Tests for sequence module.
"""
# pylint: disable=no-member
from datetime import timedelta
import ddt
from django.utils.timezone import now
from freezegun import freeze_time
from mock import Mock
from xblock.reference.user_service import XBlockUser, UserService
from xmodule.tests import get_test_system
......@@ -24,10 +28,15 @@ class StubUserService(UserService):
return user
@ddt.ddt
class SequenceBlockTestCase(XModuleXmlImportTest):
"""
Tests for the Sequence Module.
"""
TODAY = now()
TOMORROW = TODAY + timedelta(days=1)
DAY_AFTER_TOMORROW = TOMORROW + timedelta(days=1)
@classmethod
def setUpClass(cls):
super(SequenceBlockTestCase, cls).setUpClass()
......@@ -54,13 +63,16 @@ class SequenceBlockTestCase(XModuleXmlImportTest):
chapter_1 = xml.ChapterFactory.build(parent=course) # has 2 child sequences
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 2 child sequences
chapter_4 = xml.ChapterFactory.build(parent=course) # has 1 child sequence, with hide_after_due
xml.SequenceFactory.build(parent=chapter_1)
xml.SequenceFactory.build(parent=chapter_1)
sequence_3_1 = xml.SequenceFactory.build(parent=chapter_3) # has 3 verticals
xml.SequenceFactory.build(parent=chapter_4)
xml.SequenceFactory.build(parent=chapter_4)
xml.SequenceFactory.build( # sequence_4_1
parent=chapter_4,
hide_after_due=str(True),
due=str(cls.TOMORROW),
)
for _ in range(3):
xml.VerticalFactory.build(parent=sequence_3_1)
......@@ -98,9 +110,7 @@ class SequenceBlockTestCase(XModuleXmlImportTest):
def test_render_student_view(self):
html = self._get_rendered_student_view(
self.sequence_3_1,
requested_child=None,
next_url='NextSequential',
prev_url='PrevSequential'
extra_context=dict(next_url='NextSequential', prev_url='PrevSequential'),
)
self._assert_view_at_position(html, expected_position=1)
self.assertIn(unicode(self.sequence_3_1.location), html)
......@@ -115,20 +125,15 @@ class SequenceBlockTestCase(XModuleXmlImportTest):
html = self._get_rendered_student_view(self.sequence_3_1, requested_child='last')
self._assert_view_at_position(html, expected_position=3)
def _get_rendered_student_view(self, sequence, requested_child, next_url=None, prev_url=None):
def _get_rendered_student_view(self, sequence, requested_child=None, extra_context=None):
"""
Returns the rendered student view for the given sequence and the
requested_child parameter.
"""
return sequence.xmodule_runtime.render(
sequence,
STUDENT_VIEW,
{
'requested_child': requested_child,
'next_url': next_url,
'prev_url': prev_url,
},
).content
context = {'requested_child': requested_child}
if extra_context:
context.update(extra_context)
return sequence.xmodule_runtime.render(sequence, STUDENT_VIEW, context).content
def _assert_view_at_position(self, rendered_html, expected_position):
"""
......@@ -140,3 +145,46 @@ class SequenceBlockTestCase(XModuleXmlImportTest):
html = self._get_rendered_student_view(self.sequence_3_1, requested_child=None)
for child in self.sequence_3_1.children:
self.assertIn("'page_title': '{}'".format(child.name), html)
def test_hidden_content_before_due(self):
html = self._get_rendered_student_view(self.sequence_4_1)
self.assertIn("seq_module.html", html)
self.assertIn("'banner_text': None", html)
@freeze_time(DAY_AFTER_TOMORROW)
@ddt.data(
(None, 'subsection'),
('Homework', 'homework'),
)
@ddt.unpack
def test_hidden_content_past_due(self, format_type, expected_text):
progress_url = 'http://test_progress_link'
self._set_sequence_format(self.sequence_4_1, format_type)
html = self._get_rendered_student_view(
self.sequence_4_1,
extra_context=dict(progress_url=progress_url),
)
self.assertIn("hidden_content.html", html)
self.assertIn(progress_url, html)
self.assertIn("'subsection_format': '{}'".format(expected_text), html)
@freeze_time(DAY_AFTER_TOMORROW)
def test_masquerade_hidden_content_past_due(self):
self._set_sequence_format(self.sequence_4_1, "Homework")
html = self._get_rendered_student_view(
self.sequence_4_1,
extra_context=dict(specific_masquerade=True),
)
self.assertIn("seq_module.html", html)
self.assertIn(
"'banner_text': 'Because the due date has passed, "
"this homework is hidden from the learner.'",
html
)
def _set_sequence_format(self, sequence, format_type):
"""
Sets the format field on the given sequence to the
given value.
"""
sequence._xmodule.format = format_type # pylint: disable=protected-access
......@@ -3,6 +3,7 @@ API function for retrieving course blocks data
"""
from lms.djangoapps.course_blocks.api import get_course_blocks, COURSE_BLOCK_ACCESS_TRANSFORMERS
from lms.djangoapps.course_blocks.transformers.hidden_content import HiddenContentTransformer
from openedx.core.lib.block_structure.transformers import BlockStructureTransformers
from .transformers.blocks_api import BlocksAPITransformer
......@@ -51,7 +52,7 @@ def get_blocks(
# create ordered list of transformers, adding BlocksAPITransformer at end.
transformers = BlockStructureTransformers()
if user is not None:
transformers += COURSE_BLOCK_ACCESS_TRANSFORMERS + [ProctoredExamTransformer()]
transformers += COURSE_BLOCK_ACCESS_TRANSFORMERS + [ProctoredExamTransformer(), HiddenContentTransformer()]
transformers += [
BlocksAPITransformer(
block_counts,
......
......@@ -2,11 +2,8 @@
API entry point to the course_blocks app with top-level
get_course_blocks function.
"""
from django.core.cache import cache
from openedx.core.djangoapps.content.block_structure.api import get_block_structure_manager
from openedx.core.lib.block_structure.manager import BlockStructureManager
from openedx.core.lib.block_structure.transformers import BlockStructureTransformers
from xmodule.modulestore.django import modulestore
from .transformers import (
library_content,
......
"""
Visibility Transformer implementation.
"""
from datetime import datetime
from pytz import utc
from openedx.core.lib.block_structure.transformer import BlockStructureTransformer, FilteringTransformerMixin
from xmodule.seq_module import SequenceModule
from .utils import collect_merged_boolean_field, collect_merged_date_field
MAXIMUM_DATE = utc.localize(datetime.max)
class HiddenContentTransformer(FilteringTransformerMixin, BlockStructureTransformer):
"""
A transformer that enforces the hide_after_due field on
blocks by removing children blocks from the block structure for
which the user does not have access. The due and hide_after_due
fields on a block is percolated down to its descendants, so that
all blocks enforce the hidden content settings from their ancestors.
For a block with multiple parents, access is denied only if
access is denied from all its parents.
Staff users are exempted from hidden content rules.
"""
VERSION = 1
MERGED_DUE_DATE = 'merged_due_date'
MERGED_HIDE_AFTER_DUE = 'merged_hide_after_due'
@classmethod
def name(cls):
"""
Unique identifier for the transformer's class;
same identifier used in setup.py.
"""
return "hidden_content"
@classmethod
def _get_merged_hide_after_due(cls, block_structure, block_key):
"""
Returns whether the block with the given block_key in the
given block_structure should be visible to staff only per
computed value from ancestry chain.
"""
return block_structure.get_transformer_block_field(
block_key, cls, cls.MERGED_HIDE_AFTER_DUE, False
)
@classmethod
def _get_merged_due_date(cls, block_structure, block_key):
"""
Returns the merged value for the start date for the block with
the given block_key in the given block_structure.
"""
return block_structure.get_transformer_block_field(
block_key, cls, cls.MERGED_DUE_DATE, False
)
@classmethod
def collect(cls, block_structure):
"""
Collects any information that's necessary to execute this
transformer's transform method.
"""
collect_merged_boolean_field(
block_structure,
transformer=cls,
xblock_field_name='hide_after_due',
merged_field_name=cls.MERGED_HIDE_AFTER_DUE,
)
collect_merged_date_field(
block_structure,
transformer=cls,
xblock_field_name='due',
merged_field_name=cls.MERGED_DUE_DATE,
default_date=MAXIMUM_DATE,
func_merge_parents=max,
func_merge_ancestors=min,
)
def transform_block_filters(self, usage_info, block_structure):
# Users with staff access bypass the Visibility check.
if usage_info.has_staff_access:
return [block_structure.create_universal_filter()]
return [
block_structure.create_removal_filter(
lambda block_key: self._is_block_hidden(block_structure, block_key),
),
]
def _is_block_hidden(self, block_structure, block_key):
"""
Returns whether the block with the given block_key should
be hidden, given the current time.
"""
due = self._get_merged_due_date(block_structure, block_key)
hide_after_due = self._get_merged_hide_after_due(block_structure, block_key)
return not SequenceModule.verify_current_content_visibility(due, hide_after_due)
......@@ -5,7 +5,7 @@ from openedx.core.lib.block_structure.transformer import BlockStructureTransform
from lms.djangoapps.courseware.access_utils import check_start_date
from xmodule.course_metadata_utils import DEFAULT_START_DATE
from .utils import get_field_on_block
from .utils import collect_merged_date_field
class StartDateTransformer(FilteringTransformerMixin, BlockStructureTransformer):
......@@ -36,7 +36,7 @@ class StartDateTransformer(FilteringTransformerMixin, BlockStructureTransformer)
return "start_date"
@classmethod
def get_merged_start_date(cls, block_structure, block_key):
def _get_merged_start_date(cls, block_structure, block_key):
"""
Returns the merged value for the start date for the block with
the given block_key in the given block_structure.
......@@ -53,35 +53,15 @@ class StartDateTransformer(FilteringTransformerMixin, BlockStructureTransformer)
"""
block_structure.request_xblock_fields('days_early_for_beta')
for block_key in block_structure.topological_traversal():
# compute merged value of start date from all parents
parents = block_structure.get_parents(block_key)
min_all_parents_start_date = min(
cls.get_merged_start_date(block_structure, parent_key)
for parent_key in parents
) if parents else None
# set the merged value for this block
block_start = get_field_on_block(block_structure.get_xblock(block_key), 'start')
if min_all_parents_start_date is None:
# no parents so just use value on block or default
merged_start_value = block_start or DEFAULT_START_DATE
elif not block_start:
# no value on this block so take value from parents
merged_start_value = min_all_parents_start_date
else:
# max of merged-start-from-all-parents and this block
merged_start_value = max(min_all_parents_start_date, block_start)
block_structure.set_transformer_block_field(
block_key,
cls,
cls.MERGED_START_DATE,
merged_start_value
)
collect_merged_date_field(
block_structure,
transformer=cls,
xblock_field_name='start',
merged_field_name=cls.MERGED_START_DATE,
default_date=DEFAULT_START_DATE,
func_merge_parents=min,
func_merge_ancestors=max,
)
def transform_block_filters(self, usage_info, block_structure):
# Users with staff access bypass the Start Date check.
......@@ -91,7 +71,7 @@ class StartDateTransformer(FilteringTransformerMixin, BlockStructureTransformer)
removal_condition = lambda block_key: not check_start_date(
usage_info.user,
block_structure.get_xblock_field(block_key, 'days_early_for_beta'),
self.get_merged_start_date(block_structure, block_key),
self._get_merged_start_date(block_structure, block_key),
usage_info.course_key,
)
return [block_structure.create_removal_filter(removal_condition)]
......@@ -255,7 +255,7 @@ class BlockParentsMapTestCase(TransformerRegistryTestMixin, ModuleStoreTestCase)
self,
test_user,
expected_user_accessible_blocks,
blocks_with_differing_access,
blocks_with_differing_access=None,
transformers=None,
):
"""
......@@ -272,7 +272,8 @@ class BlockParentsMapTestCase(TransformerRegistryTestMixin, ModuleStoreTestCase)
blocks_with_differing_access (set(int)): Set of
blocks (indices) whose access will differ from the
transformers result and the current implementation of
has_access.
has_access. If not provided, does not compare with
has_access results.
transformers (BlockStructureTransformers): An optional collection
of transformers that are to be executed. If not
......@@ -312,7 +313,6 @@ class BlockParentsMapTestCase(TransformerRegistryTestMixin, ModuleStoreTestCase)
# compute access results of the block
block_structure_result = xblock_key in block_structure
has_access_result = bool(has_access(user, 'load', self.get_block(i), course_key=self.course.id))
# compare with expected value
self.assertEquals(
......@@ -323,23 +323,25 @@ class BlockParentsMapTestCase(TransformerRegistryTestMixin, ModuleStoreTestCase)
)
)
# compare with has_access_result
if i in blocks_with_differing_access:
self.assertNotEqual(
block_structure_result,
has_access_result,
"block structure ({0}) & has_access ({1}) results are equal for block {2} for user {3}".format(
block_structure_result, has_access_result, i, user.username
if blocks_with_differing_access:
# compare with has_access_result
has_access_result = bool(has_access(user, 'load', self.get_block(i), course_key=self.course.id))
if i in blocks_with_differing_access:
self.assertNotEqual(
block_structure_result,
has_access_result,
"block structure ({0}) & has_access ({1}) results are equal for block {2} for user {3}".format(
block_structure_result, has_access_result, i, user.username
)
)
)
else:
self.assertEquals(
block_structure_result,
has_access_result,
"block structure ({0}) & has_access ({1}) results not equal for block {2} for user {3}".format(
block_structure_result, has_access_result, i, user.username
else:
self.assertEquals(
block_structure_result,
has_access_result,
"block structure ({0}) & has_access ({1}) results not equal for block {2} for user {3}".format(
block_structure_result, has_access_result, i, user.username
)
)
)
self.client.logout()
......
"""
Tests for HiddenContentTransformer.
"""
from datetime import timedelta
import ddt
from django.utils.timezone import now
from nose.plugins.attrib import attr
from ..hidden_content import HiddenContentTransformer
from .helpers import BlockParentsMapTestCase, update_block
@attr('shard_3')
@ddt.ddt
class HiddenContentTransformerTestCase(BlockParentsMapTestCase):
"""
VisibilityTransformer Test
"""
TRANSFORMER_CLASS_TO_TEST = HiddenContentTransformer
ALL_BLOCKS = {0, 1, 2, 3, 4, 5, 6}
class DueDateType(object):
"""
Use constant enum types for deterministic ddt test method names (rather than dynamically generated timestamps)
"""
none = 1,
future = 2,
past = 3
TODAY = now()
PAST_DATE = TODAY - timedelta(days=30)
FUTURE_DATE = TODAY + timedelta(days=30)
@classmethod
def due(cls, enum_value):
"""
Returns a start date for the given enum value
"""
if enum_value == cls.future:
return cls.FUTURE_DATE
elif enum_value == cls.past:
return cls.PAST_DATE
else:
return None
# Following test cases are based on BlockParentsMapTestCase.parents_map
@ddt.data(
({}, ALL_BLOCKS),
({0: DueDateType.none}, ALL_BLOCKS),
({0: DueDateType.future}, ALL_BLOCKS),
({1: DueDateType.none}, ALL_BLOCKS),
({1: DueDateType.future}, ALL_BLOCKS),
({4: DueDateType.none}, ALL_BLOCKS),
({4: DueDateType.future}, ALL_BLOCKS),
({0: DueDateType.past}, {}),
({1: DueDateType.past}, ALL_BLOCKS - {1, 3, 4}),
({2: DueDateType.past}, ALL_BLOCKS - {2, 5}),
({4: DueDateType.past}, ALL_BLOCKS - {4}),
({1: DueDateType.past, 2: DueDateType.past}, {0}),
({1: DueDateType.none, 2: DueDateType.past}, ALL_BLOCKS - {2, 5}),
({1: DueDateType.past, 2: DueDateType.none}, ALL_BLOCKS - {1, 3, 4}),
)
@ddt.unpack
def test_hidden_content(
self,
hide_due_values,
expected_visible_blocks,
):
for idx, due_date_type in hide_due_values.iteritems():
block = self.get_block(idx)
block.due = self.DueDateType.due(due_date_type)
block.hide_after_due = True
update_block(block)
self.assert_transform_results(
self.student,
expected_visible_blocks,
blocks_with_differing_access=None,
transformers=self.transformers,
)
......@@ -10,7 +10,103 @@ def get_field_on_block(block, field_name, default_value=None):
returns value from only a single parent chain
(e.g., doesn't take a union in DAGs).
"""
if block.fields[field_name].is_set_on(block):
return getattr(block, field_name)
else:
return default_value
try:
if block.fields[field_name].is_set_on(block):
return getattr(block, field_name)
except KeyError:
pass
return default_value
def collect_merged_boolean_field(
block_structure,
transformer,
xblock_field_name,
merged_field_name,
):
"""
Collects a boolean xBlock field of name xblock_field_name
for the given block_structure and transformer. The boolean
value is percolated down the hierarchy of the block_structure
and stored as a value of merged_field_name in the
block_structure.
Assumes that the boolean field is False, by default. So,
the value is ANDed across all parents for blocks with
multiple parents and ORed across all ancestors down a single
hierarchy chain.
"""
for block_key in block_structure.topological_traversal():
# compute merged value of the boolean field from all parents
parents = block_structure.get_parents(block_key)
all_parents_merged_value = all( # pylint: disable=invalid-name
block_structure.get_transformer_block_field(
parent_key, transformer, merged_field_name, False,
)
for parent_key in parents
) if parents else False
# set the merged value for this block
block_structure.set_transformer_block_field(
block_key,
transformer,
merged_field_name,
(
all_parents_merged_value or
get_field_on_block(
block_structure.get_xblock(block_key), xblock_field_name,
False,
)
)
)
def collect_merged_date_field(
block_structure,
transformer,
xblock_field_name,
merged_field_name,
default_date,
func_merge_parents=min,
func_merge_ancestors=max,
):
"""
Collects a date xBlock field of name xblock_field_name
for the given block_structure and transformer. The date
value is percolated down the hierarchy of the block_structure
and stored as a value of merged_field_name in the
block_structure.
"""
for block_key in block_structure.topological_traversal():
parents = block_structure.get_parents(block_key)
block_date = get_field_on_block(block_structure.get_xblock(block_key), xblock_field_name)
if not parents:
# no parents so just use value on block or default
merged_date_value = block_date or default_date
else:
# compute merged value of date from all parents
merged_all_parents_date = func_merge_parents(
block_structure.get_transformer_block_field(
parent_key, transformer, merged_field_name, default_date,
)
for parent_key in parents
)
if not block_date:
# no value on this block so take value from parents
merged_date_value = merged_all_parents_date
else:
# compute merged date of the block and the parent
merged_date_value = func_merge_ancestors(merged_all_parents_date, block_date)
block_structure.set_transformer_block_field(
block_key,
transformer,
merged_field_name,
merged_date_value
)
......@@ -2,6 +2,7 @@
Visibility Transformer implementation.
"""
from openedx.core.lib.block_structure.transformer import BlockStructureTransformer, FilteringTransformerMixin
from .utils import collect_merged_boolean_field
class VisibilityTransformer(FilteringTransformerMixin, BlockStructureTransformer):
......@@ -30,7 +31,7 @@ class VisibilityTransformer(FilteringTransformerMixin, BlockStructureTransformer
return "visibility"
@classmethod
def get_visible_to_staff_only(cls, block_structure, block_key):
def _get_visible_to_staff_only(cls, block_structure, block_key):
"""
Returns whether the block with the given block_key in the
given block_structure should be visible to staff only per
......@@ -46,26 +47,12 @@ class VisibilityTransformer(FilteringTransformerMixin, BlockStructureTransformer
Collects any information that's necessary to execute this
transformer's transform method.
"""
for block_key in block_structure.topological_traversal():
# compute merged value of visible_to_staff_only from all parents
parents = block_structure.get_parents(block_key)
all_parents_visible_to_staff_only = all( # pylint: disable=invalid-name
cls.get_visible_to_staff_only(block_structure, parent_key)
for parent_key in parents
) if parents else False
# set the merged value for this block
block_structure.set_transformer_block_field(
block_key,
cls,
cls.MERGED_VISIBLE_TO_STAFF_ONLY,
# merge visible_to_staff_only from all parents and this block
(
all_parents_visible_to_staff_only or
block_structure.get_xblock(block_key).visible_to_staff_only
)
)
collect_merged_boolean_field(
block_structure,
transformer=cls,
xblock_field_name='visible_to_staff_only',
merged_field_name=cls.MERGED_VISIBLE_TO_STAFF_ONLY,
)
def transform_block_filters(self, usage_info, block_structure):
# Users with staff access bypass the Visibility check.
......@@ -74,6 +61,6 @@ class VisibilityTransformer(FilteringTransformerMixin, BlockStructureTransformer
return [
block_structure.create_removal_filter(
lambda block_key: self.get_visible_to_staff_only(block_structure, block_key),
lambda block_key: self._get_visible_to_staff_only(block_structure, block_key),
)
]
......@@ -447,7 +447,7 @@ class CoursewareIndex(View):
return "{url}?child={requested_child}".format(
url=reverse(
'courseware_section',
args=[unicode(self.course.id), section_info['chapter_url_name'], section_info['url_name']],
args=[unicode(self.course_key), section_info['chapter_url_name'], section_info['url_name']],
),
requested_child=requested_child,
)
......@@ -455,6 +455,7 @@ class CoursewareIndex(View):
section_context = {
'activate_block_id': self.request.GET.get('activate_block_id'),
'requested_child': self.request.GET.get("child"),
'progress_url': reverse('progress', kwargs={'course_id': unicode(self.course_key)}),
}
if previous_of_active_section:
section_context['prev_url'] = _compute_section_url(previous_of_active_section, 'last')
......
<%page expression_filter="h"/>
<%!
from django.utils.translation import ugettext as _
from openedx.core.djangolib.markup import HTML, Text
%>
<div class="sequence hidden-content proctored-exam completed">
<h3>
${_("The due date for this {subsection_format} has passed.").format(
subsection_format=subsection_format,
)}
</h3>
<hr>
<p>
${Text(_(
"Because the due date has passed, this {subsection_format} "
"is no longer available.{line_break}If you have completed this {subsection_format}, "
"your grade is available on the {link_start}progress page{link_end}."
)).format(
subsection_format=subsection_format,
line_break=HTML("<br>"),
link_start=HTML("<a href='{}'>").format(progress_url),
link_end=HTML("</a>"),
)}
</p>
</div>
......@@ -3,12 +3,12 @@
<div id="sequence_${element_id}" class="sequence" data-id="${item_id}" data-position="${position}" data-ajax-url="${ajax_url}" data-next-url="${next_url}" data-prev-url="${prev_url}">
<div class="path"></div>
% if override_hidden_exam:
% if banner_text:
<div class="pattern-library-shim alert alert-information subsection-header" tabindex="-1">
<span class="pattern-library-shim icon alert-icon fa fa-bullhorn" aria-hidden="true"></span>
<div class="pattern-library-shim alert-message">
<p class="pattern-library-shim alert-copy">
${_("This exam is hidden from the learner.")}
${banner_text}
</p>
</div>
</div>
......
......@@ -49,6 +49,7 @@ setup(
"start_date = lms.djangoapps.course_blocks.transformers.start_date:StartDateTransformer",
"user_partitions = lms.djangoapps.course_blocks.transformers.user_partitions:UserPartitionTransformer",
"visibility = lms.djangoapps.course_blocks.transformers.visibility:VisibilityTransformer",
"hidden_content = lms.djangoapps.course_blocks.transformers.hidden_content:HiddenContentTransformer",
"course_blocks_api = lms.djangoapps.course_api.blocks.transformers.blocks_api:BlocksAPITransformer",
"proctored_exam = lms.djangoapps.course_api.blocks.transformers.proctored_exam:ProctoredExamTransformer",
"grades = lms.djangoapps.courseware.transformers.grades:GradesTransformer",
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment