diff --git a/common/djangoapps/util/milestones_helpers.py b/common/djangoapps/util/milestones_helpers.py index 8b79d29344..c5648c9242 100644 --- a/common/djangoapps/util/milestones_helpers.py +++ b/common/djangoapps/util/milestones_helpers.py @@ -2,7 +2,6 @@ """ Utility library for working with the edx-milestones app """ - from django.conf import settings from django.utils.translation import ugettext as _ @@ -12,6 +11,7 @@ from opaque_keys.edx.keys import CourseKey from milestones import api as milestones_api from milestones.exceptions import InvalidMilestoneRelationshipTypeException from milestones.models import MilestoneRelationshipType +from milestones.services import MilestonesService from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from xmodule.modulestore.django import modulestore import request_cache @@ -418,3 +418,16 @@ def remove_user_milestone(user, milestone): if not settings.FEATURES.get('MILESTONES_APP'): return None return milestones_api.remove_user_milestone(user, milestone) + + +def get_service(): + """ + Returns MilestonesService instance if feature flag enabled; + else returns None. + + Note: MilestonesService only has access to the functions + explicitly requested in the MilestonesServices class + """ + if not settings.FEATURES.get('MILESTONES_APP', False): + return None + return MilestonesService() diff --git a/common/djangoapps/util/tests/test_milestones_helpers.py b/common/djangoapps/util/tests/test_milestones_helpers.py index ad43c853b0..92b6772193 100644 --- a/common/djangoapps/util/tests/test_milestones_helpers.py +++ b/common/djangoapps/util/tests/test_milestones_helpers.py @@ -110,6 +110,11 @@ class MilestonesHelpersTestCase(ModuleStoreTestCase): response = milestones_helpers.add_user_milestone(self.user, self.milestone) self.assertIsNone(response) + def test_get_service_returns_none_when_app_disabled(self): + """MilestonesService is None when app disabled""" + response = milestones_helpers.get_service() + self.assertIsNone(response) + @patch.dict('django.conf.settings.FEATURES', {'MILESTONES_APP': True}) def test_any_unfulfilled_milestones(self): """ Tests any_unfulfilled_milestones for invalid arguments """ diff --git a/common/lib/xmodule/xmodule/seq_module.py b/common/lib/xmodule/xmodule/seq_module.py index 52c1742ecd..53fac7893c 100644 --- a/common/lib/xmodule/xmodule/seq_module.py +++ b/common/lib/xmodule/xmodule/seq_module.py @@ -137,6 +137,7 @@ class ProctoringFields(object): @XBlock.wants('proctoring') +@XBlock.wants('milestones') @XBlock.wants('credit') @XBlock.needs("user") @XBlock.needs("bookmarks") @@ -209,6 +210,8 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule): banner_text, special_html = special_html_view if special_html and not masquerading_as_specific_student: return Fragment(special_html) + else: + banner_text = self._gated_content_staff_banner() return self._student_view(context, banner_text) def _special_exam_student_view(self): @@ -249,6 +252,20 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule): return banner_text, hidden_content_html + def _gated_content_staff_banner(self): + """ + Checks whether the content is gated for learners. If so, + returns a banner_text depending on whether user is staff. + """ + milestones_service = self.runtime.service(self, 'milestones') + if milestones_service: + content_milestones = milestones_service.get_course_content_milestones( + self.course_id, self.location, 'requires' + ) + banner_text = _('This subsection is unlocked for learners when they meet the prerequisite requirements.') + if content_milestones and self.runtime.user_is_staff: + return banner_text + def _can_user_view_content(self): """ Returns whether the runtime user can view the content diff --git a/common/test/acceptance/pages/lms/courseware.py b/common/test/acceptance/pages/lms/courseware.py index 42af7bb1b9..513d23defa 100644 --- a/common/test/acceptance/pages/lms/courseware.py +++ b/common/test/acceptance/pages/lms/courseware.py @@ -229,6 +229,12 @@ class CoursewarePage(CoursePage): return self.entrance_exam_message_selector.is_present() \ and "You have passed the entrance exam" in self.entrance_exam_message_selector.text[0] + def has_banner(self): + """ + Returns boolean indicating presence of banner + """ + return self.q(css='.pattern-library-shim').is_present() + @property def is_timer_bar_present(self): """ diff --git a/common/test/acceptance/tests/lms/test_lms_gating.py b/common/test/acceptance/tests/lms/test_lms_gating.py index faa41d2763..32365d31cf 100644 --- a/common/test/acceptance/tests/lms/test_lms_gating.py +++ b/common/test/acceptance/tests/lms/test_lms_gating.py @@ -9,6 +9,7 @@ from ...pages.studio.auto_auth import AutoAuthPage from ...pages.studio.overview import CourseOutlinePage from ...pages.lms.courseware import CoursewarePage from ...pages.lms.problem import ProblemPage +from ...pages.lms.staff_view import StaffPage from ...pages.common.logout import LogoutPage from ...fixtures.course import CourseFixture, XBlockFixtureDesc @@ -17,8 +18,11 @@ class GatingTest(UniqueCourseTest): """ Test gating feature in LMS. """ - USERNAME = "STUDENT_TESTER" - EMAIL = "student101@example.com" + STAFF_USERNAME = "STAFF_TESTER" + STAFF_EMAIL = "staff101@example.com" + + STUDENT_USERNAME = "STUDENT_TESTER" + STUDENT_EMAIL = "student101@example.com" def setUp(self): super(GatingTest, self).setUp() @@ -82,7 +86,7 @@ class GatingTest(UniqueCourseTest): Make the first subsection a prerequisite """ # Login as staff - self._auto_auth("STAFF_TESTER", "staff101@example.com", True) + self._auto_auth(self.STAFF_USERNAME, self.STAFF_EMAIL, True) # Make the first subsection a prerequisite self.course_outline.visit() @@ -95,7 +99,7 @@ class GatingTest(UniqueCourseTest): Gate the second subsection on the first subsection """ # Login as staff - self._auto_auth("STAFF_TESTER", "staff101@example.com", True) + self._auto_auth(self.STAFF_USERNAME, self.STAFF_EMAIL, True) # Gate the second subsection based on the score achieved in the first subsection self.course_outline.visit() @@ -103,6 +107,15 @@ class GatingTest(UniqueCourseTest): self.course_outline.select_advanced_tab(desired_item='gated_content') self.course_outline.add_prerequisite_to_subsection("80") + def _fulfill_prerequisite(self): + """ + Fulfill the prerequisite needed to see gated content + """ + problem_page = ProblemPage(self.browser) + self.assertEqual(problem_page.wait_for_page().problem_name, 'HEIGHT OF EIFFEL TOWER') + problem_page.click_choice('choice_1') + problem_page.click_check() + def test_subsection_gating_in_studio(self): """ Given that I am a staff member @@ -132,7 +145,7 @@ class GatingTest(UniqueCourseTest): self.assertTrue(self.course_outline.gating_prerequisites_dropdown_is_visible()) self.assertTrue(self.course_outline.gating_prerequisite_min_score_is_visible()) - def test_gated_subsection_in_lms(self): + def test_gated_subsection_in_lms_for_student(self): """ Given that I am a student When I visit the LMS Courseware @@ -143,15 +156,58 @@ class GatingTest(UniqueCourseTest): self._setup_prereq() self._setup_gated_subsection() - self._auto_auth(self.USERNAME, self.EMAIL, False) + self._auto_auth(self.STUDENT_USERNAME, self.STUDENT_EMAIL, False) self.courseware_page.visit() self.assertEqual(self.courseware_page.num_subsections, 1) # Fulfill prerequisite and verify that gated subsection is shown - problem_page = ProblemPage(self.browser) - self.assertEqual(problem_page.wait_for_page().problem_name, 'HEIGHT OF EIFFEL TOWER') - problem_page.click_choice('choice_1') - problem_page.click_check() + self._fulfill_prerequisite() self.courseware_page.visit() self.assertEqual(self.courseware_page.num_subsections, 2) + + def test_gated_subsection_in_lms_for_staff(self): + """ + Given that I am a staff member + When I visit the LMS Courseware + Then I can see all gated subsections + Displayed along with notification banners + Then if I masquerade as a student + Then I cannot see a gated subsection + When I fufill the gating prerequisite + Then I can see the gated subsection (without a banner) + """ + self._setup_prereq() + self._setup_gated_subsection() + + # Fulfill prerequisites for specific student + self._auto_auth(self.STUDENT_USERNAME, self.STUDENT_EMAIL, False) + self.courseware_page.visit() + self._fulfill_prerequisite() + + self._auto_auth(self.STAFF_USERNAME, self.STAFF_EMAIL, True) + + self.courseware_page.visit() + staff_page = StaffPage(self.browser, self.course_id) + self.assertEqual(staff_page.staff_view_mode, 'Staff') + self.assertEqual(self.courseware_page.num_subsections, 2) + + # Click on gated section and check for banner + self.courseware_page.q(css='.chapter-content-container a').nth(1).click() + self.courseware_page.wait_for_page() + self.assertTrue(self.courseware_page.has_banner()) + + self.courseware_page.q(css='.chapter-content-container a').nth(0).click() + self.courseware_page.wait_for_page() + + staff_page.set_staff_view_mode('Student') + + self.assertEqual(self.courseware_page.num_subsections, 1) + self.assertFalse(self.courseware_page.has_banner()) + + staff_page.set_staff_view_mode_specific_student(self.STUDENT_USERNAME) + + self.assertEqual(self.courseware_page.num_subsections, 2) + self.courseware_page.q(css='.chapter-content-container a').nth(1).click() + self.courseware_page.wait_for_page() + self.assertFalse(self.courseware_page.has_banner()) diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index 91d3e8ea66..e36af37f88 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -755,6 +755,7 @@ def get_module_system_for_user(user, student_data, # TODO # pylint: disable=to 'user': DjangoXBlockUserService(user, user_is_staff=user_is_staff), "reverification": ReverificationService(), 'proctoring': ProctoringService(), + 'milestones': milestones_helpers.get_service(), 'credit': CreditService(), 'bookmarks': BookmarksService(user=user), }, diff --git a/openedx/core/lib/gating/api.py b/openedx/core/lib/gating/api.py index 77506444e9..f7087b57d3 100644 --- a/openedx/core/lib/gating/api.py +++ b/openedx/core/lib/gating/api.py @@ -4,6 +4,7 @@ API for the gating djangoapp import logging from django.utils.translation import ugettext as _ +from lms.djangoapps.courseware.access import _has_access_to_course from milestones import api as milestones_api from opaque_keys.edx.keys import UsageKey from xmodule.modulestore.django import modulestore @@ -285,12 +286,15 @@ def get_gated_content(course, user): Returns: list: The list of gated content usage keys for the given course """ - # Get the unfulfilled gating milestones for this course, for this user - return [ - m['content_id'] for m in find_gating_milestones( - course.id, - None, - 'requires', - {'id': user.id} - ) - ] + if _has_access_to_course(user, 'staff', course.id): + return [] + else: + # Get the unfulfilled gating milestones for this course, for this user + return [ + m['content_id'] for m in find_gating_milestones( + course.id, + None, + 'requires', + {'id': user.id} + ) + ] diff --git a/openedx/core/lib/gating/tests/test_api.py b/openedx/core/lib/gating/tests/test_api.py index 172555fdd0..e88bd3fb57 100644 --- a/openedx/core/lib/gating/tests/test_api.py +++ b/openedx/core/lib/gating/tests/test_api.py @@ -10,6 +10,7 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, TEST_DAT from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory from openedx.core.lib.gating import api as gating_api from openedx.core.lib.gating.exceptions import GatingValidationError +from student.tests.factories import UserFactory @attr('shard_2') @@ -154,19 +155,23 @@ class TestGatingApi(ModuleStoreTestCase, MilestonesTestCaseMixin): self.assertIsNone(min_score) def test_get_gated_content(self): - """ Test test_get_gated_content """ + """ + Verify staff bypasses gated content and student gets list of unfulfilled prerequisites. + """ - mock_user = MagicMock() - mock_user.id.return_value = 1 + staff = UserFactory(is_staff=True) + student = UserFactory(is_staff=False) - self.assertEqual(gating_api.get_gated_content(self.course, mock_user), []) + self.assertEqual(gating_api.get_gated_content(self.course, staff), []) + self.assertEqual(gating_api.get_gated_content(self.course, student), []) gating_api.add_prerequisite(self.course.id, self.seq1.location) gating_api.set_required_content(self.course.id, self.seq2.location, self.seq1.location, 100) milestone = milestones_api.get_course_content_milestones(self.course.id, self.seq2.location, 'requires')[0] - self.assertEqual(gating_api.get_gated_content(self.course, mock_user), [unicode(self.seq2.location)]) + self.assertEqual(gating_api.get_gated_content(self.course, staff), []) + self.assertEqual(gating_api.get_gated_content(self.course, student), [unicode(self.seq2.location)]) - milestones_api.add_user_milestone({'id': mock_user.id}, milestone) + milestones_api.add_user_milestone({'id': student.id}, milestone) # pylint: disable=no-member - self.assertEqual(gating_api.get_gated_content(self.course, mock_user), []) + self.assertEqual(gating_api.get_gated_content(self.course, student), []) diff --git a/requirements/edx/github.txt b/requirements/edx/github.txt index fb8084e384..13527b74c8 100644 --- a/requirements/edx/github.txt +++ b/requirements/edx/github.txt @@ -84,7 +84,7 @@ git+https://github.com/pmitros/RecommenderXBlock.git@v1.1#egg=recommender-xblock git+https://github.com/solashirai/crowdsourcehinter.git@518605f0a95190949fe77bd39158450639e2e1dc#egg=crowdsourcehinter-xblock==0.1 -e git+https://github.com/pmitros/RateXBlock.git@367e19c0f6eac8a5f002fd0f1559555f8e74bfff#egg=rate-xblock -e git+https://github.com/pmitros/DoneXBlock.git@857bf365f19c904d7e48364428f6b93ff153fabd#egg=done-xblock -git+https://github.com/edx/edx-milestones.git@v0.1.8#egg=edx-milestones==0.1.8 +git+https://github.com/edx/edx-milestones.git@v0.1.9#egg=edx-milestones==0.1.9 git+https://github.com/edx/xblock-utils.git@v1.0.2#egg=xblock-utils==1.0.2 -e git+https://github.com/edx-solutions/xblock-google-drive.git@138e6fa0bf3a2013e904a085b9fed77dab7f3f21#egg=xblock-google-drive -e git+https://github.com/edx/edx-reverification-block.git@0.0.5#egg=edx-reverification-block==0.0.5