LMS Update to hide subsection content based on due-date
TNL-4905
This commit is contained in:
@@ -4,6 +4,8 @@ xModule implementation of a learning sequence
|
||||
|
||||
# pylint: disable=abstract-method
|
||||
import collections
|
||||
from datetime import datetime
|
||||
from django.utils.timezone import UTC
|
||||
import json
|
||||
import logging
|
||||
from pkg_resources import resource_string
|
||||
@@ -38,13 +40,22 @@ class SequenceFields(object):
|
||||
# NOTE: Position is 1-indexed. This is silly, but there are now student
|
||||
# positions saved on prod, so it's not easy to fix.
|
||||
position = Integer(help="Last tab viewed in this sequence", scope=Scope.user_state)
|
||||
|
||||
due = Date(
|
||||
display_name=_("Due Date"),
|
||||
help=_("Enter the date by which problems are due."),
|
||||
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(
|
||||
display_name=_("Is Entrance Exam"),
|
||||
help=_(
|
||||
@@ -97,16 +108,6 @@ class ProctoringFields(object):
|
||||
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(
|
||||
display_name=_("Is Practice Exam"),
|
||||
help=_(
|
||||
@@ -177,73 +178,91 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule):
|
||||
|
||||
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):
|
||||
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()
|
||||
|
||||
# If we're rendering this sequence, but no position is set yet,
|
||||
# or exceeds the length of the displayable items,
|
||||
# default the position to the first element
|
||||
if context.get('requested_child') == 'first':
|
||||
self.position = 1
|
||||
elif context.get('requested_child') == 'last':
|
||||
self.position = len(display_items) or 1
|
||||
elif self.position is None or self.position > len(display_items):
|
||||
self.position = 1
|
||||
|
||||
## Returns a set of all types of all sub-children
|
||||
contents = []
|
||||
self._update_position(context, len(display_items))
|
||||
|
||||
fragment = Fragment()
|
||||
context = context or {}
|
||||
|
||||
bookmarks_service = self.runtime.service(self, "bookmarks")
|
||||
context["username"] = self.runtime.service(self, "user").get_current_user().opt_attrs['edx-platform.username']
|
||||
|
||||
parent_module = self.get_parent()
|
||||
display_names = [
|
||||
parent_module.display_name_with_default,
|
||||
self.display_name_with_default
|
||||
]
|
||||
|
||||
# We do this up here because proctored exam functionality could bypass
|
||||
# rendering after this section.
|
||||
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
|
||||
|
||||
progress = child.get_progress()
|
||||
rendered_child = child.render(STUDENT_VIEW, context)
|
||||
fragment.add_frag_resources(rendered_child)
|
||||
|
||||
childinfo = {
|
||||
'content': rendered_child.content,
|
||||
'page_title': getattr(child, 'tooltip_title', ''),
|
||||
'progress_status': Progress.to_js_status_str(progress),
|
||||
'progress_detail': Progress.to_js_detail_str(progress),
|
||||
'type': child.get_icon_class(),
|
||||
'id': child.scope_ids.usage_id.to_deprecated_string(),
|
||||
'bookmarked': is_bookmarked,
|
||||
'path': " > ".join(display_names + [child.display_name_with_default]),
|
||||
}
|
||||
|
||||
contents.append(childinfo)
|
||||
|
||||
params = {
|
||||
'items': contents,
|
||||
'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,
|
||||
@@ -251,17 +270,66 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule):
|
||||
'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,
|
||||
'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)
|
||||
|
||||
# Get all descendant XBlock types and counts
|
||||
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,
|
||||
# or exceeds the length of the displayable items,
|
||||
# default the position to the first element
|
||||
if context.get('requested_child') == 'first':
|
||||
self.position = 1
|
||||
elif context.get('requested_child') == 'last':
|
||||
self.position = number_of_display_items or 1
|
||||
elif self.position is None or self.position > number_of_display_items:
|
||||
self.position = 1
|
||||
|
||||
def _render_student_view_for_items(self, context, display_items, fragment):
|
||||
"""
|
||||
Updates the given fragment with rendered student views of the given
|
||||
display_items. Returns a list of dict objects with information about
|
||||
the given display_items.
|
||||
"""
|
||||
bookmarks_service = self.runtime.service(self, "bookmarks")
|
||||
context["username"] = self.runtime.service(self, "user").get_current_user().opt_attrs['edx-platform.username']
|
||||
display_names = [
|
||||
self.get_parent().display_name_with_default,
|
||||
self.display_name_with_default
|
||||
]
|
||||
contents = []
|
||||
for item in display_items:
|
||||
is_bookmarked = bookmarks_service.is_bookmarked(usage_key=item.scope_ids.usage_id)
|
||||
context["bookmarked"] = is_bookmarked
|
||||
|
||||
progress = item.get_progress()
|
||||
rendered_item = item.render(STUDENT_VIEW, context)
|
||||
fragment.add_frag_resources(rendered_item)
|
||||
|
||||
iteminfo = {
|
||||
'content': rendered_item.content,
|
||||
'page_title': getattr(item, 'tooltip_title', ''),
|
||||
'progress_status': Progress.to_js_status_str(progress),
|
||||
'progress_detail': Progress.to_js_detail_str(progress),
|
||||
'type': item.get_icon_class(),
|
||||
'id': item.scope_ids.usage_id.to_deprecated_string(),
|
||||
'bookmarked': is_bookmarked,
|
||||
'path': " > ".join(display_names + [item.display_name_with_default]),
|
||||
}
|
||||
|
||||
contents.append(iteminfo)
|
||||
|
||||
return contents
|
||||
|
||||
def _locations_in_subtree(self, node):
|
||||
"""
|
||||
The usage keys for all descendants of an XBlock/XModule as a flat list.
|
||||
@@ -328,7 +396,7 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule):
|
||||
for block_type, count in curr_block_counts.items():
|
||||
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
|
||||
limited view. This ultimately calls down into edx_proctoring
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
26
lms/templates/hidden_content.html
Normal file
26
lms/templates/hidden_content.html
Normal file
@@ -0,0 +1,26 @@
|
||||
<%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 icon-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>
|
||||
|
||||
Reference in New Issue
Block a user