diff --git a/common/lib/xmodule/xmodule/seq_module.py b/common/lib/xmodule/xmodule/seq_module.py index 9eec6ea374..7e00105621 100644 --- a/common/lib/xmodule/xmodule/seq_module.py +++ b/common/lib/xmodule/xmodule/seq_module.py @@ -366,23 +366,37 @@ class SequenceBlock( return json.dumps(self._get_completion(data)) raise NotFoundError('Unexpected dispatch type') - def get_metadata(self, view=STUDENT_VIEW): + def get_metadata(self, view=STUDENT_VIEW, context=None): """Returns a dict of some common block properties""" - context = {'exclude_units': True} + context = context or {} + context['exclude_units'] = True prereq_met = True prereq_meta_info = {} banner_text = None display_items = self.get_display_items() + course = self._get_course() + is_hidden_after_due = False if self._required_prereq(): if self.runtime.user_is_staff: - banner_text = _('This subsection is unlocked for learners when they meet the prerequisite requirements.') # lint-amnesty, pylint: disable=line-too-long + banner_text = _( + 'This subsection is unlocked for learners when they meet the prerequisite requirements.' + ) else: # check if prerequisite has been met prereq_met, prereq_meta_info = self._compute_is_prereq_met(True) + + if prereq_met and view == STUDENT_VIEW and not self._can_user_view_content(course): + if context.get('specific_masquerade', False): + # Still show the content, but flag to the staff user that the learner wouldn't be able to see it + banner_text = self._hidden_content_banner_text(course) + else: + is_hidden_after_due = True + meta = self._get_render_metadata(context, display_items, prereq_met, prereq_meta_info, banner_text, view) meta['display_name'] = self.display_name_with_default meta['format'] = getattr(self, 'format', '') + meta['is_hidden_after_due'] = is_hidden_after_due return meta @classmethod @@ -434,7 +448,10 @@ class SequenceBlock( self, self.course_id ) - def student_view(self, context): # lint-amnesty, pylint: disable=missing-function-docstring + def student_view(self, context): + """ + Renders the normal student view of the block in the LMS. + """ _ = self.runtime.service(self, "i18n").ugettext context = context or {} self._capture_basic_metrics() @@ -443,7 +460,9 @@ class SequenceBlock( prereq_meta_info = {} if self._required_prereq(): if self.runtime.user_is_staff: - banner_text = _('This subsection is unlocked for learners when they meet the prerequisite requirements.') # lint-amnesty, pylint: disable=line-too-long + banner_text = _( + 'This subsection is unlocked for learners when they meet the prerequisite requirements.' + ) else: # check if prerequisite has been met prereq_met, prereq_meta_info = self._compute_is_prereq_met(True) @@ -496,6 +515,16 @@ class SequenceBlock( banner_text = _("This exam is hidden from the learner.") return banner_text, special_exam_html + def _hidden_content_banner_text(self, course): + """ + Chooses a banner message to show for hidden content + """ + _ = self.runtime.service(self, 'i18n').gettext + if course.self_paced: + return _('Because the course has ended, this assignment is hidden from the learner.') + else: + return _('Because the due date has passed, this assignment is hidden from the learner.') + def _hidden_content_student_view(self, context): """ Checks whether the content of this sequential is hidden from the @@ -505,10 +534,7 @@ class SequenceBlock( _ = self.runtime.service(self, "i18n").ugettext course = self._get_course() if not self._can_user_view_content(course): - if course.self_paced: - banner_text = _("Because the course has ended, this assignment is hidden from the learner.") - else: - banner_text = _("Because the due date has passed, this assignment is hidden from the learner.") + banner_text = self._hidden_content_banner_text(course) hidden_content_html = self.system.render_template( 'hidden_content.html', @@ -535,7 +561,9 @@ class SequenceBlock( # NOTE (CCB): We default to true to maintain the behavior in place prior to allowing anonymous access access. return context.get('user_authenticated', True) - def _get_render_metadata(self, context, display_items, prereq_met, prereq_meta_info, banner_text=None, view=STUDENT_VIEW, fragment=None): # lint-amnesty, pylint: disable=line-too-long, missing-function-docstring + def _get_render_metadata(self, context, display_items, prereq_met, prereq_meta_info, banner_text=None, + view=STUDENT_VIEW, fragment=None): + """Returns a dictionary of sequence metadata, used by render methods and for the courseware API""" if prereq_met and not self._is_gate_fulfilled(): _ = self.runtime.service(self, "i18n").ugettext banner_text = _( diff --git a/common/lib/xmodule/xmodule/tests/test_sequence.py b/common/lib/xmodule/xmodule/tests/test_sequence.py index 034c2752dd..c51a642616 100644 --- a/common/lib/xmodule/xmodule/tests/test_sequence.py +++ b/common/lib/xmodule/xmodule/tests/test_sequence.py @@ -110,6 +110,11 @@ class SequenceBlockTestCase(XModuleXmlImportTest): module_system.descriptor_runtime = block._runtime # pylint: disable=protected-access block.xmodule_runtime = module_system + # The render operation will ask modulestore for the current course to get some data. As these tests were + # originally not written to be compatible with a real modulestore, we've mocked out the relevant return values. + module_system.modulestore = Mock() + module_system.modulestore.get_course.return_value = self.course + def _get_rendered_view(self, sequence, requested_child=None, @@ -124,12 +129,8 @@ class SequenceBlockTestCase(XModuleXmlImportTest): if extra_context: context.update(extra_context) - # The render operation will ask modulestore for the current course to get some data. As these tests were - # originally not written to be compatible with a real modulestore, we've mocked out the relevant return values. - with patch.object(SequenceBlock, '_get_course') as mock_course: - self.course.self_paced = self_paced - mock_course.return_value = self.course - return sequence.xmodule_runtime.render(sequence, view, context).content + self.course.self_paced = self_paced + return sequence.xmodule_runtime.render(sequence, view, context).content def _assert_view_at_position(self, rendered_html, expected_position): """ diff --git a/openedx/core/djangoapps/courseware_api/tests/test_views.py b/openedx/core/djangoapps/courseware_api/tests/test_views.py index 6fa1acca8e..d7722e1ede 100644 --- a/openedx/core/djangoapps/courseware_api/tests/test_views.py +++ b/openedx/core/djangoapps/courseware_api/tests/test_views.py @@ -387,7 +387,8 @@ class CourseApiTestViews(BaseCoursewareTests, MasqueradeMixin): assert courseware_data['is_mfe_proctored_exams_enabled'] == (is_globally_enabled and is_waffle_enabled) -class SequenceApiTestViews(BaseCoursewareTests): +@ddt.ddt +class SequenceApiTestViews(MasqueradeMixin, BaseCoursewareTests): """ Tests for the sequence REST API """ @@ -407,6 +408,32 @@ class SequenceApiTestViews(BaseCoursewareTests): assert response.data['display_name'] == 'sequence' assert len(response.data['items']) == 1 + @ddt.data( + (False, None, False, False), + (True, None, True, False), + (True, {'username': 'student'}, False, True), + # Masquerading as a limited-access learner here, but specific partition/group doesn't matter. + # We just want to test that masquerading as a non-specific learner has a different outcome. + (True, {'user_partition_id': 51, 'group_id': 1}, True, False), + ) + @ddt.unpack + def test_hidden_after_due(self, is_past_due, masquerade_config, expected_hidden, expected_banner): + """Validate the metadata when hide-after-due is set for a sequence""" + due = datetime.now() + timedelta(days=-1 if is_past_due else 1) + sequence = ItemFactory(parent=self.chapter, category='sequential', hide_after_due=True, due=due) + + CourseEnrollment.enroll(self.user, self.course.id) + + user = self.instructor if masquerade_config else self.user + self.client.login(username=user.username, password='foo') + if masquerade_config: + self.update_masquerade(**masquerade_config) + + response = self.client.get(f'/api/courseware/sequence/{sequence.location}') + assert response.status_code == 200 + assert response.data['is_hidden_after_due'] == expected_hidden + assert bool(response.data['banner_text']) == expected_banner + class ResumeApiTestViews(BaseCoursewareTests, CompletionWaffleTestMixin): """ diff --git a/openedx/core/djangoapps/courseware_api/views.py b/openedx/core/djangoapps/courseware_api/views.py index fad88a508f..18a42f8585 100644 --- a/openedx/core/djangoapps/courseware_api/views.py +++ b/openedx/core/djangoapps/courseware_api/views.py @@ -29,7 +29,7 @@ from lms.djangoapps.courseware.access_response import ( ) from lms.djangoapps.courseware.context_processor import user_timezone_locale_prefs from lms.djangoapps.courseware.courses import check_course_access -from lms.djangoapps.courseware.masquerade import setup_masquerade +from lms.djangoapps.courseware.masquerade import is_masquerading_as_specific_student, setup_masquerade from lms.djangoapps.courseware.module_render import get_module_by_usage_id from lms.djangoapps.courseware.tabs import get_course_tab_list from lms.djangoapps.courseware.toggles import ( @@ -545,7 +545,8 @@ class SequenceMetadata(DeveloperErrorViewMixin, APIView): if request.user.is_anonymous: view = PUBLIC_VIEW - return Response(sequence.get_metadata(view=view)) + context = {'specific_masquerade': is_masquerading_as_specific_student(request.user, usage_key.course_key)} + return Response(sequence.get_metadata(view=view, context=context)) class Resume(DeveloperErrorViewMixin, APIView):