Commit eb02f2ad by Nimisha Asthagiri

LMS Update to hide subsection content based on due-date

TNL-4905
parent 1d768cde
...@@ -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)
params = {
'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)) contents.append(iteminfo)
self._capture_full_seq_item_metrics(display_items) return contents
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
...@@ -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 icon-bullhorn" aria-hidden="true"></span> <span class="pattern-library-shim icon alert-icon icon-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>
......
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