Commit 9d747fa6 by Nimisha Asthagiri Committed by GitHub

Merge pull request #12975 from edx/tnl/hide_content

LMS Hide Subsection Content
parents 3302b26e 37b8e636
...@@ -4,6 +4,8 @@ xModule implementation of a learning sequence ...@@ -4,6 +4,8 @@ xModule implementation of a learning sequence
# pylint: disable=abstract-method # pylint: disable=abstract-method
import collections import collections
from datetime import datetime
from django.utils.timezone import UTC
import json import json
import logging import logging
from pkg_resources import resource_string from pkg_resources import resource_string
...@@ -38,13 +40,22 @@ class SequenceFields(object): ...@@ -38,13 +40,22 @@ class SequenceFields(object):
# NOTE: Position is 1-indexed. This is silly, but there are now student # NOTE: Position is 1-indexed. This is silly, but there are now student
# positions saved on prod, so it's not easy to fix. # positions saved on prod, so it's not easy to fix.
position = Integer(help="Last tab viewed in this sequence", scope=Scope.user_state) position = Integer(help="Last tab viewed in this sequence", scope=Scope.user_state)
due = Date( due = Date(
display_name=_("Due Date"), display_name=_("Due Date"),
help=_("Enter the date by which problems are due."), help=_("Enter the date by which problems are due."),
scope=Scope.settings, scope=Scope.settings,
) )
# Entrance Exam flag -- see cms/contentstore/views/entrance_exam.py for usage hide_after_due = Boolean(
display_name=_("Hide sequence content After Due Date"),
help=_(
"If set, the sequence content is hidden for non-staff users after the due date has passed."
),
default=False,
scope=Scope.settings,
)
is_entrance_exam = Boolean( is_entrance_exam = Boolean(
display_name=_("Is Entrance Exam"), display_name=_("Is Entrance Exam"),
help=_( help=_(
...@@ -97,16 +108,6 @@ class ProctoringFields(object): ...@@ -97,16 +108,6 @@ class ProctoringFields(object):
scope=Scope.settings, scope=Scope.settings,
) )
hide_after_due = Boolean(
display_name=_("Hide Exam Results After Due Date"),
help=_(
"This setting overrides the default behavior of showing exam results after the due date has passed."
" Currently only supported for timed exams."
),
default=False,
scope=Scope.settings,
)
is_practice_exam = Boolean( is_practice_exam = Boolean(
display_name=_("Is Practice Exam"), display_name=_("Is Practice Exam"),
help=_( help=_(
...@@ -177,90 +178,157 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule): ...@@ -177,90 +178,157 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule):
raise NotFoundError('Unexpected dispatch type') raise NotFoundError('Unexpected dispatch type')
@classmethod
def verify_current_content_visibility(cls, due, hide_after_due):
"""
Returns whether the content visibility policy passes
for the given due date and hide_after_due values and
the current date-time.
"""
return (
not due or
not hide_after_due or
datetime.now(UTC()) < due
)
def student_view(self, context): def student_view(self, context):
context = context or {}
self._capture_basic_metrics()
banner_text = None
special_html_view = self._hidden_content_student_view(context) or self._special_exam_student_view()
if special_html_view:
masquerading_as_specific_student = context.get('specific_masquerade', False)
banner_text, special_html = special_html_view
if special_html and not masquerading_as_specific_student:
return Fragment(special_html)
return self._student_view(context, banner_text)
def _special_exam_student_view(self):
"""
Checks whether this sequential is a special exam. If so, returns
a banner_text or the fragment to display depending on whether
staff is masquerading.
"""
if self.is_time_limited:
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):
"""
Checks whether the content of this sequential is hidden from the
runtime user. If so, returns a banner_text or the fragment to
display depending on whether staff is masquerading.
"""
if not self._can_user_view_content():
subsection_format = (self.format or _("subsection")).lower() # pylint: disable=no-member
# Translators: subsection_format refers to the assignment
# type of the subsection, such as Homework, Lab, Exam, etc.
banner_text = _(
"Because the due date has passed, "
"this {subsection_format} is hidden from the learner."
).format(subsection_format=subsection_format)
hidden_content_html = self.system.render_template(
'hidden_content.html',
{
'subsection_format': subsection_format,
'progress_url': context.get('progress_url'),
}
)
return banner_text, hidden_content_html
def _can_user_view_content(self):
"""
Returns whether the runtime user can view the content
of this sequential.
"""
return (
self.runtime.user_is_staff or
self.verify_current_content_visibility(self.due, self.hide_after_due)
)
def _student_view(self, context, banner_text=None):
"""
Returns the rendered student view of the content of this
sequential. If banner_text is given, it is added to the
content.
"""
display_items = self.get_display_items() display_items = self.get_display_items()
self._update_position(context, len(display_items))
fragment = Fragment()
params = {
'items': self._render_student_view_for_items(context, display_items, fragment),
'element_id': self.location.html_id(),
'item_id': self.location.to_deprecated_string(),
'position': self.position,
'tag': self.location.category,
'ajax_url': self.system.ajax_url,
'next_url': context.get('next_url'),
'prev_url': context.get('prev_url'),
'banner_text': banner_text,
}
fragment.add_content(self.system.render_template("seq_module.html", params))
self._capture_full_seq_item_metrics(display_items)
self._capture_current_unit_metrics(display_items)
return fragment
def _update_position(self, context, number_of_display_items):
"""
Update the user's sequential position given the context and the
number_of_display_items
"""
# If we're rendering this sequence, but no position is set yet, # If we're rendering this sequence, but no position is set yet,
# or exceeds the length of the displayable items, # or exceeds the length of the displayable items,
# default the position to the first element # default the position to the first element
if context.get('requested_child') == 'first': if context.get('requested_child') == 'first':
self.position = 1 self.position = 1
elif context.get('requested_child') == 'last': elif context.get('requested_child') == 'last':
self.position = len(display_items) or 1 self.position = number_of_display_items or 1
elif self.position is None or self.position > len(display_items): elif self.position is None or self.position > number_of_display_items:
self.position = 1 self.position = 1
## Returns a set of all types of all sub-children def _render_student_view_for_items(self, context, display_items, fragment):
contents = [] """
Updates the given fragment with rendered student views of the given
fragment = Fragment() display_items. Returns a list of dict objects with information about
context = context or {} the given display_items.
"""
bookmarks_service = self.runtime.service(self, "bookmarks") bookmarks_service = self.runtime.service(self, "bookmarks")
context["username"] = self.runtime.service(self, "user").get_current_user().opt_attrs['edx-platform.username'] context["username"] = self.runtime.service(self, "user").get_current_user().opt_attrs['edx-platform.username']
parent_module = self.get_parent()
display_names = [ display_names = [
parent_module.display_name_with_default, self.get_parent().display_name_with_default,
self.display_name_with_default self.display_name_with_default
] ]
contents = []
# We do this up here because proctored exam functionality could bypass for item in display_items:
# rendering after this section. is_bookmarked = bookmarks_service.is_bookmarked(usage_key=item.scope_ids.usage_id)
self._capture_basic_metrics()
# Is this sequential part of a timed or proctored exam?
masquerading = context.get('specific_masquerade', False)
special_exam_html = None
if self.is_time_limited:
special_exam_html = self._time_limited_student_view(context)
# Do we have an applicable alternate rendering
# from the edx_proctoring subsystem?
if special_exam_html and not masquerading:
fragment.add_content(special_exam_html)
return fragment
for child in display_items:
is_bookmarked = bookmarks_service.is_bookmarked(usage_key=child.scope_ids.usage_id)
context["bookmarked"] = is_bookmarked context["bookmarked"] = is_bookmarked
progress = child.get_progress() progress = item.get_progress()
rendered_child = child.render(STUDENT_VIEW, context) rendered_item = item.render(STUDENT_VIEW, context)
fragment.add_frag_resources(rendered_child) fragment.add_frag_resources(rendered_item)
childinfo = { iteminfo = {
'content': rendered_child.content, 'content': rendered_item.content,
'page_title': getattr(child, 'tooltip_title', ''), 'page_title': getattr(item, 'tooltip_title', ''),
'progress_status': Progress.to_js_status_str(progress), 'progress_status': Progress.to_js_status_str(progress),
'progress_detail': Progress.to_js_detail_str(progress), 'progress_detail': Progress.to_js_detail_str(progress),
'type': child.get_icon_class(), 'type': item.get_icon_class(),
'id': child.scope_ids.usage_id.to_deprecated_string(), 'id': item.scope_ids.usage_id.to_deprecated_string(),
'bookmarked': is_bookmarked, 'bookmarked': is_bookmarked,
'path': " > ".join(display_names + [child.display_name_with_default]), 'path': " > ".join(display_names + [item.display_name_with_default]),
} }
contents.append(childinfo) contents.append(iteminfo)
params = { return contents
'items': contents,
'element_id': self.location.html_id(),
'item_id': self.location.to_deprecated_string(),
'position': self.position,
'tag': self.location.category,
'ajax_url': self.system.ajax_url,
'next_url': context.get('next_url'),
'prev_url': context.get('prev_url'),
'override_hidden_exam': masquerading and special_exam_html is not None,
}
fragment.add_content(self.system.render_template("seq_module.html", params))
self._capture_full_seq_item_metrics(display_items)
self._capture_current_unit_metrics(display_items)
# Get all descendant XBlock types and counts
return fragment
def _locations_in_subtree(self, node): def _locations_in_subtree(self, node):
""" """
...@@ -328,7 +396,7 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule): ...@@ -328,7 +396,7 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule):
for block_type, count in curr_block_counts.items(): for block_type, count in curr_block_counts.items():
newrelic.agent.add_custom_parameter('seq.current.block_counts.{}'.format(block_type), count) newrelic.agent.add_custom_parameter('seq.current.block_counts.{}'.format(block_type), count)
def _time_limited_student_view(self, context): def _time_limited_student_view(self):
""" """
Delegated rendering of a student view when in a time Delegated rendering of a student view when in a time
limited view. This ultimately calls down into edx_proctoring limited view. This ultimately calls down into edx_proctoring
......
...@@ -2,6 +2,10 @@ ...@@ -2,6 +2,10 @@
Tests for sequence module. Tests for sequence module.
""" """
# pylint: disable=no-member # 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 mock import Mock
from xblock.reference.user_service import XBlockUser, UserService from xblock.reference.user_service import XBlockUser, UserService
from xmodule.tests import get_test_system from xmodule.tests import get_test_system
...@@ -24,10 +28,15 @@ class StubUserService(UserService): ...@@ -24,10 +28,15 @@ class StubUserService(UserService):
return user return user
@ddt.ddt
class SequenceBlockTestCase(XModuleXmlImportTest): class SequenceBlockTestCase(XModuleXmlImportTest):
""" """
Tests for the Sequence Module. Tests for the Sequence Module.
""" """
TODAY = now()
TOMORROW = TODAY + timedelta(days=1)
DAY_AFTER_TOMORROW = TOMORROW + timedelta(days=1)
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
super(SequenceBlockTestCase, cls).setUpClass() super(SequenceBlockTestCase, cls).setUpClass()
...@@ -54,13 +63,16 @@ class SequenceBlockTestCase(XModuleXmlImportTest): ...@@ -54,13 +63,16 @@ class SequenceBlockTestCase(XModuleXmlImportTest):
chapter_1 = xml.ChapterFactory.build(parent=course) # has 2 child sequences chapter_1 = xml.ChapterFactory.build(parent=course) # has 2 child sequences
xml.ChapterFactory.build(parent=course) # has 0 child sequences xml.ChapterFactory.build(parent=course) # has 0 child sequences
chapter_3 = xml.ChapterFactory.build(parent=course) # has 1 child sequence 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)
xml.SequenceFactory.build(parent=chapter_1) xml.SequenceFactory.build(parent=chapter_1)
sequence_3_1 = xml.SequenceFactory.build(parent=chapter_3) # has 3 verticals sequence_3_1 = xml.SequenceFactory.build(parent=chapter_3) # has 3 verticals
xml.SequenceFactory.build(parent=chapter_4) xml.SequenceFactory.build( # sequence_4_1
xml.SequenceFactory.build(parent=chapter_4) parent=chapter_4,
hide_after_due=str(True),
due=str(cls.TOMORROW),
)
for _ in range(3): for _ in range(3):
xml.VerticalFactory.build(parent=sequence_3_1) xml.VerticalFactory.build(parent=sequence_3_1)
...@@ -98,9 +110,7 @@ class SequenceBlockTestCase(XModuleXmlImportTest): ...@@ -98,9 +110,7 @@ class SequenceBlockTestCase(XModuleXmlImportTest):
def test_render_student_view(self): def test_render_student_view(self):
html = self._get_rendered_student_view( html = self._get_rendered_student_view(
self.sequence_3_1, self.sequence_3_1,
requested_child=None, extra_context=dict(next_url='NextSequential', prev_url='PrevSequential'),
next_url='NextSequential',
prev_url='PrevSequential'
) )
self._assert_view_at_position(html, expected_position=1) self._assert_view_at_position(html, expected_position=1)
self.assertIn(unicode(self.sequence_3_1.location), html) self.assertIn(unicode(self.sequence_3_1.location), html)
...@@ -115,20 +125,15 @@ class SequenceBlockTestCase(XModuleXmlImportTest): ...@@ -115,20 +125,15 @@ class SequenceBlockTestCase(XModuleXmlImportTest):
html = self._get_rendered_student_view(self.sequence_3_1, requested_child='last') html = self._get_rendered_student_view(self.sequence_3_1, requested_child='last')
self._assert_view_at_position(html, expected_position=3) 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 Returns the rendered student view for the given sequence and the
requested_child parameter. requested_child parameter.
""" """
return sequence.xmodule_runtime.render( context = {'requested_child': requested_child}
sequence, if extra_context:
STUDENT_VIEW, context.update(extra_context)
{ return sequence.xmodule_runtime.render(sequence, STUDENT_VIEW, context).content
'requested_child': requested_child,
'next_url': next_url,
'prev_url': prev_url,
},
).content
def _assert_view_at_position(self, rendered_html, expected_position): def _assert_view_at_position(self, rendered_html, expected_position):
""" """
...@@ -140,3 +145,46 @@ class SequenceBlockTestCase(XModuleXmlImportTest): ...@@ -140,3 +145,46 @@ class SequenceBlockTestCase(XModuleXmlImportTest):
html = self._get_rendered_student_view(self.sequence_3_1, requested_child=None) html = self._get_rendered_student_view(self.sequence_3_1, requested_child=None)
for child in self.sequence_3_1.children: for child in self.sequence_3_1.children:
self.assertIn("'page_title': '{}'".format(child.name), html) 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 ...@@ -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.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 openedx.core.lib.block_structure.transformers import BlockStructureTransformers
from .transformers.blocks_api import BlocksAPITransformer from .transformers.blocks_api import BlocksAPITransformer
...@@ -51,7 +52,7 @@ def get_blocks( ...@@ -51,7 +52,7 @@ def get_blocks(
# create ordered list of transformers, adding BlocksAPITransformer at end. # create ordered list of transformers, adding BlocksAPITransformer at end.
transformers = BlockStructureTransformers() transformers = BlockStructureTransformers()
if user is not None: if user is not None:
transformers += COURSE_BLOCK_ACCESS_TRANSFORMERS + [ProctoredExamTransformer()] transformers += COURSE_BLOCK_ACCESS_TRANSFORMERS + [ProctoredExamTransformer(), HiddenContentTransformer()]
transformers += [ transformers += [
BlocksAPITransformer( BlocksAPITransformer(
block_counts, block_counts,
......
...@@ -2,11 +2,8 @@ ...@@ -2,11 +2,8 @@
API entry point to the course_blocks app with top-level API entry point to the course_blocks app with top-level
get_course_blocks function. 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.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 openedx.core.lib.block_structure.transformers import BlockStructureTransformers
from xmodule.modulestore.django import modulestore
from .transformers import ( from .transformers import (
library_content, 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 ...@@ -5,7 +5,7 @@ from openedx.core.lib.block_structure.transformer import BlockStructureTransform
from lms.djangoapps.courseware.access_utils import check_start_date from lms.djangoapps.courseware.access_utils import check_start_date
from xmodule.course_metadata_utils import DEFAULT_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): class StartDateTransformer(FilteringTransformerMixin, BlockStructureTransformer):
...@@ -36,7 +36,7 @@ class StartDateTransformer(FilteringTransformerMixin, BlockStructureTransformer) ...@@ -36,7 +36,7 @@ class StartDateTransformer(FilteringTransformerMixin, BlockStructureTransformer)
return "start_date" return "start_date"
@classmethod @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 Returns the merged value for the start date for the block with
the given block_key in the given block_structure. the given block_key in the given block_structure.
...@@ -53,35 +53,15 @@ class StartDateTransformer(FilteringTransformerMixin, BlockStructureTransformer) ...@@ -53,35 +53,15 @@ class StartDateTransformer(FilteringTransformerMixin, BlockStructureTransformer)
""" """
block_structure.request_xblock_fields('days_early_for_beta') block_structure.request_xblock_fields('days_early_for_beta')
for block_key in block_structure.topological_traversal(): collect_merged_date_field(
block_structure,
# compute merged value of start date from all parents transformer=cls,
parents = block_structure.get_parents(block_key) xblock_field_name='start',
min_all_parents_start_date = min( merged_field_name=cls.MERGED_START_DATE,
cls.get_merged_start_date(block_structure, parent_key) default_date=DEFAULT_START_DATE,
for parent_key in parents func_merge_parents=min,
) if parents else None func_merge_ancestors=max,
)
# 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
)
def transform_block_filters(self, usage_info, block_structure): def transform_block_filters(self, usage_info, block_structure):
# Users with staff access bypass the Start Date check. # Users with staff access bypass the Start Date check.
...@@ -91,7 +71,7 @@ class StartDateTransformer(FilteringTransformerMixin, BlockStructureTransformer) ...@@ -91,7 +71,7 @@ class StartDateTransformer(FilteringTransformerMixin, BlockStructureTransformer)
removal_condition = lambda block_key: not check_start_date( removal_condition = lambda block_key: not check_start_date(
usage_info.user, usage_info.user,
block_structure.get_xblock_field(block_key, 'days_early_for_beta'), 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, usage_info.course_key,
) )
return [block_structure.create_removal_filter(removal_condition)] return [block_structure.create_removal_filter(removal_condition)]
...@@ -255,7 +255,7 @@ class BlockParentsMapTestCase(TransformerRegistryTestMixin, ModuleStoreTestCase) ...@@ -255,7 +255,7 @@ class BlockParentsMapTestCase(TransformerRegistryTestMixin, ModuleStoreTestCase)
self, self,
test_user, test_user,
expected_user_accessible_blocks, expected_user_accessible_blocks,
blocks_with_differing_access, blocks_with_differing_access=None,
transformers=None, transformers=None,
): ):
""" """
...@@ -272,7 +272,8 @@ class BlockParentsMapTestCase(TransformerRegistryTestMixin, ModuleStoreTestCase) ...@@ -272,7 +272,8 @@ class BlockParentsMapTestCase(TransformerRegistryTestMixin, ModuleStoreTestCase)
blocks_with_differing_access (set(int)): Set of blocks_with_differing_access (set(int)): Set of
blocks (indices) whose access will differ from the blocks (indices) whose access will differ from the
transformers result and the current implementation of 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 transformers (BlockStructureTransformers): An optional collection
of transformers that are to be executed. If not of transformers that are to be executed. If not
...@@ -312,7 +313,6 @@ class BlockParentsMapTestCase(TransformerRegistryTestMixin, ModuleStoreTestCase) ...@@ -312,7 +313,6 @@ class BlockParentsMapTestCase(TransformerRegistryTestMixin, ModuleStoreTestCase)
# compute access results of the block # compute access results of the block
block_structure_result = xblock_key in block_structure 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 # compare with expected value
self.assertEquals( self.assertEquals(
...@@ -323,23 +323,25 @@ class BlockParentsMapTestCase(TransformerRegistryTestMixin, ModuleStoreTestCase) ...@@ -323,23 +323,25 @@ class BlockParentsMapTestCase(TransformerRegistryTestMixin, ModuleStoreTestCase)
) )
) )
# compare with has_access_result if blocks_with_differing_access:
if i in blocks_with_differing_access: # compare with has_access_result
self.assertNotEqual( has_access_result = bool(has_access(user, 'load', self.get_block(i), course_key=self.course.id))
block_structure_result, if i in blocks_with_differing_access:
has_access_result, self.assertNotEqual(
"block structure ({0}) & has_access ({1}) results are equal for block {2} for user {3}".format( block_structure_result,
block_structure_result, has_access_result, i, user.username 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:
else: self.assertEquals(
self.assertEquals( block_structure_result,
block_structure_result, has_access_result,
has_access_result, "block structure ({0}) & has_access ({1}) results not equal for block {2} for user {3}".format(
"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
block_structure_result, has_access_result, i, user.username )
) )
)
self.client.logout() 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): ...@@ -10,7 +10,103 @@ def get_field_on_block(block, field_name, default_value=None):
returns value from only a single parent chain returns value from only a single parent chain
(e.g., doesn't take a union in DAGs). (e.g., doesn't take a union in DAGs).
""" """
if block.fields[field_name].is_set_on(block): try:
return getattr(block, field_name) if block.fields[field_name].is_set_on(block):
else: return getattr(block, field_name)
return default_value 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 @@ ...@@ -2,6 +2,7 @@
Visibility Transformer implementation. Visibility Transformer implementation.
""" """
from openedx.core.lib.block_structure.transformer import BlockStructureTransformer, FilteringTransformerMixin from openedx.core.lib.block_structure.transformer import BlockStructureTransformer, FilteringTransformerMixin
from .utils import collect_merged_boolean_field
class VisibilityTransformer(FilteringTransformerMixin, BlockStructureTransformer): class VisibilityTransformer(FilteringTransformerMixin, BlockStructureTransformer):
...@@ -30,7 +31,7 @@ class VisibilityTransformer(FilteringTransformerMixin, BlockStructureTransformer ...@@ -30,7 +31,7 @@ class VisibilityTransformer(FilteringTransformerMixin, BlockStructureTransformer
return "visibility" return "visibility"
@classmethod @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 Returns whether the block with the given block_key in the
given block_structure should be visible to staff only per given block_structure should be visible to staff only per
...@@ -46,26 +47,12 @@ class VisibilityTransformer(FilteringTransformerMixin, BlockStructureTransformer ...@@ -46,26 +47,12 @@ class VisibilityTransformer(FilteringTransformerMixin, BlockStructureTransformer
Collects any information that's necessary to execute this Collects any information that's necessary to execute this
transformer's transform method. transformer's transform method.
""" """
for block_key in block_structure.topological_traversal(): collect_merged_boolean_field(
block_structure,
# compute merged value of visible_to_staff_only from all parents transformer=cls,
parents = block_structure.get_parents(block_key) xblock_field_name='visible_to_staff_only',
all_parents_visible_to_staff_only = all( # pylint: disable=invalid-name merged_field_name=cls.MERGED_VISIBLE_TO_STAFF_ONLY,
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
)
)
def transform_block_filters(self, usage_info, block_structure): def transform_block_filters(self, usage_info, block_structure):
# Users with staff access bypass the Visibility check. # Users with staff access bypass the Visibility check.
...@@ -74,6 +61,6 @@ class VisibilityTransformer(FilteringTransformerMixin, BlockStructureTransformer ...@@ -74,6 +61,6 @@ class VisibilityTransformer(FilteringTransformerMixin, BlockStructureTransformer
return [ return [
block_structure.create_removal_filter( 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): ...@@ -447,7 +447,7 @@ class CoursewareIndex(View):
return "{url}?child={requested_child}".format( return "{url}?child={requested_child}".format(
url=reverse( url=reverse(
'courseware_section', '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, requested_child=requested_child,
) )
...@@ -455,6 +455,7 @@ class CoursewareIndex(View): ...@@ -455,6 +455,7 @@ class CoursewareIndex(View):
section_context = { section_context = {
'activate_block_id': self.request.GET.get('activate_block_id'), 'activate_block_id': self.request.GET.get('activate_block_id'),
'requested_child': self.request.GET.get("child"), 'requested_child': self.request.GET.get("child"),
'progress_url': reverse('progress', kwargs={'course_id': unicode(self.course_key)}),
} }
if previous_of_active_section: if previous_of_active_section:
section_context['prev_url'] = _compute_section_url(previous_of_active_section, 'last') 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 @@ ...@@ -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 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> <div class="path"></div>
% if override_hidden_exam: % if banner_text:
<div class="pattern-library-shim alert alert-information subsection-header" tabindex="-1"> <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> <span class="pattern-library-shim icon alert-icon fa fa-bullhorn" aria-hidden="true"></span>
<div class="pattern-library-shim alert-message"> <div class="pattern-library-shim alert-message">
<p class="pattern-library-shim alert-copy"> <p class="pattern-library-shim alert-copy">
${_("This exam is hidden from the learner.")} ${banner_text}
</p> </p>
</div> </div>
</div> </div>
......
...@@ -49,6 +49,7 @@ setup( ...@@ -49,6 +49,7 @@ setup(
"start_date = lms.djangoapps.course_blocks.transformers.start_date:StartDateTransformer", "start_date = lms.djangoapps.course_blocks.transformers.start_date:StartDateTransformer",
"user_partitions = lms.djangoapps.course_blocks.transformers.user_partitions:UserPartitionTransformer", "user_partitions = lms.djangoapps.course_blocks.transformers.user_partitions:UserPartitionTransformer",
"visibility = lms.djangoapps.course_blocks.transformers.visibility:VisibilityTransformer", "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", "course_blocks_api = lms.djangoapps.course_api.blocks.transformers.blocks_api:BlocksAPITransformer",
"proctored_exam = lms.djangoapps.course_api.blocks.transformers.proctored_exam:ProctoredExamTransformer", "proctored_exam = lms.djangoapps.course_api.blocks.transformers.proctored_exam:ProctoredExamTransformer",
"grades = lms.djangoapps.courseware.transformers.grades:GradesTransformer", "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