From eb02f2adc1c5da99e21d63daae6cef07dcf98368 Mon Sep 17 00:00:00 2001 From: Nimisha Asthagiri <nasthagiri@edx.org> Date: Mon, 11 Jul 2016 18:15:16 -0400 Subject: [PATCH] LMS Update to hide subsection content based on due-date TNL-4905 --- common/lib/xmodule/xmodule/seq_module.py | 210 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------------------------------------------------- common/lib/xmodule/xmodule/tests/test_sequence.py | 80 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------- lms/djangoapps/courseware/views/index.py | 3 ++- lms/templates/hidden_content.html | 26 ++++++++++++++++++++++++++ lms/templates/seq_module.html | 4 ++-- 5 files changed, 233 insertions(+), 90 deletions(-) create mode 100644 lms/templates/hidden_content.html diff --git a/common/lib/xmodule/xmodule/seq_module.py b/common/lib/xmodule/xmodule/seq_module.py index abe377b..11933b0 100644 --- a/common/lib/xmodule/xmodule/seq_module.py +++ b/common/lib/xmodule/xmodule/seq_module.py @@ -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,90 +178,157 @@ 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() + 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, # 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 = number_of_display_items or 1 + elif self.position is None or self.position > number_of_display_items: self.position = 1 - ## Returns a set of all types of all sub-children - contents = [] - - fragment = Fragment() - context = context or {} - + 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'] - - parent_module = self.get_parent() display_names = [ - parent_module.display_name_with_default, + self.get_parent().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) + contents = [] + for item in display_items: + is_bookmarked = bookmarks_service.is_bookmarked(usage_key=item.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) + progress = item.get_progress() + rendered_item = item.render(STUDENT_VIEW, context) + fragment.add_frag_resources(rendered_item) - childinfo = { - 'content': rendered_child.content, - 'page_title': getattr(child, 'tooltip_title', ''), + 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': child.get_icon_class(), - 'id': child.scope_ids.usage_id.to_deprecated_string(), + 'type': item.get_icon_class(), + 'id': item.scope_ids.usage_id.to_deprecated_string(), '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 = { - '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 + return contents def _locations_in_subtree(self, node): """ @@ -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 diff --git a/common/lib/xmodule/xmodule/tests/test_sequence.py b/common/lib/xmodule/xmodule/tests/test_sequence.py index fedb839..f98319b 100644 --- a/common/lib/xmodule/xmodule/tests/test_sequence.py +++ b/common/lib/xmodule/xmodule/tests/test_sequence.py @@ -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 diff --git a/lms/djangoapps/courseware/views/index.py b/lms/djangoapps/courseware/views/index.py index a361898..8fcb0f8 100644 --- a/lms/djangoapps/courseware/views/index.py +++ b/lms/djangoapps/courseware/views/index.py @@ -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') diff --git a/lms/templates/hidden_content.html b/lms/templates/hidden_content.html new file mode 100644 index 0000000..7e6f4b5 --- /dev/null +++ b/lms/templates/hidden_content.html @@ -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> diff --git a/lms/templates/seq_module.html b/lms/templates/seq_module.html index 7996ab7..6475416 100644 --- a/lms/templates/seq_module.html +++ b/lms/templates/seq_module.html @@ -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> -- libgit2 0.26.0