diff --git a/common/djangoapps/util/milestones_helpers.py b/common/djangoapps/util/milestones_helpers.py index b4bb71ff50..41b7047958 100644 --- a/common/djangoapps/util/milestones_helpers.py +++ b/common/djangoapps/util/milestones_helpers.py @@ -207,7 +207,7 @@ def remove_course_milestones(course_key, user, relationship): milestones_api.remove_user_milestone({'id': user.id}, milestone) -def get_required_content(course, user): +def get_required_content(course_key, user): """ Queries milestones subsystem to see if the specified course is gated on one or more milestones, and if those milestones can be fulfilled via completion of a particular course content module @@ -217,7 +217,7 @@ def get_required_content(course, user): # Get all of the outstanding milestones for this course, for this user try: milestone_paths = get_course_milestones_fulfillment_paths( - unicode(course.id), + unicode(course_key), serialize_user(user) ) except InvalidMilestoneRelationshipTypeException: diff --git a/common/test/acceptance/pages/lms/courseware.py b/common/test/acceptance/pages/lms/courseware.py index 86e1aac162..571225ac38 100644 --- a/common/test/acceptance/pages/lms/courseware.py +++ b/common/test/acceptance/pages/lms/courseware.py @@ -30,6 +30,7 @@ class CoursewarePage(CoursePage): def is_browser_on_page(self): return self.q(css='.course-content').present + # TODO: TNL-6546: Remove and find callers @property def chapter_count_in_navigation(self): """ diff --git a/common/test/acceptance/tests/lms/test_lms.py b/common/test/acceptance/tests/lms/test_lms.py index abe8cdb294..5d3670377f 100644 --- a/common/test/acceptance/tests/lms/test_lms.py +++ b/common/test/acceptance/tests/lms/test_lms.py @@ -1233,7 +1233,7 @@ class EntranceExamTest(UniqueCourseTest): self.course_info['run'], self.course_info['display_name'] ).install() - self.courseware_page = CoursewarePage(self.browser, self.course_id) + self.course_home_page = CourseHomePage(self.browser, self.course_id) self.settings_page = SettingsPage( self.browser, self.course_info['org'], @@ -1245,6 +1245,40 @@ class EntranceExamTest(UniqueCourseTest): AutoAuthPage(self.browser, course_id=self.course_id).visit() def test_entrance_exam_section(self): + """ + Scenario: Any course that is enabled for an entrance exam, should have + entrance exam section in the course outline. + Given that I visit the course outline + And entrance exams are not yet enabled + Then I should not see an "Entrance Exam" section + When I log in as staff + And enable entrance exams + And I visit the course outline again as student + Then there should be an "Entrance Exam" chapter.' + """ + # visit the course outline and make sure there is no "Entrance Exam" section. + self.course_home_page.visit() + self.assertFalse('Entrance Exam' in self.course_home_page.outline.sections.keys()) + + # Logout and login as a staff. + LogoutPage(self.browser).visit() + AutoAuthPage(self.browser, course_id=self.course_id, staff=True).visit() + + # visit course settings page and set/enabled entrance exam for that course. + self.settings_page.visit() + self.settings_page.entrance_exam_field.click() + self.settings_page.save_changes() + + # Logout and login as a student. + LogoutPage(self.browser).visit() + AutoAuthPage(self.browser, course_id=self.course_id, staff=False).visit() + + # visit the course outline and make sure there is an "Entrance Exam" section. + self.course_home_page.visit() + self.assertTrue('Entrance Exam' in self.course_home_page.outline.sections.keys()) + + # TODO: TNL-6546: Remove test + def test_entrance_exam_section_2(self): """ Scenario: Any course that is enabled for an entrance exam, should have entrance exam chapter at course page. @@ -1252,12 +1286,13 @@ class EntranceExamTest(UniqueCourseTest): When I view the course that has an entrance exam Then there should be an "Entrance Exam" chapter.' """ + courseware_page = CoursewarePage(self.browser, self.course_id) entrance_exam_link_selector = '.accordion .course-navigation .chapter .group-heading' # visit course page and make sure there is not entrance exam chapter. - self.courseware_page.visit() - self.courseware_page.wait_for_page() + courseware_page.visit() + courseware_page.wait_for_page() self.assertFalse(element_has_text( - page=self.courseware_page, + page=courseware_page, css_selector=entrance_exam_link_selector, text='Entrance Exam' )) @@ -1276,10 +1311,10 @@ class EntranceExamTest(UniqueCourseTest): AutoAuthPage(self.browser, course_id=self.course_id, staff=False).visit() # visit course info page and make sure there is an "Entrance Exam" section. - self.courseware_page.visit() - self.courseware_page.wait_for_page() + courseware_page.visit() + courseware_page.wait_for_page() self.assertTrue(element_has_text( - page=self.courseware_page, + page=courseware_page, css_selector=entrance_exam_link_selector, text='Entrance Exam' )) diff --git a/common/test/acceptance/tests/lms/test_lms_entrance_exams.py b/common/test/acceptance/tests/lms/test_lms_entrance_exams.py index 22510fe0ea..f965c0e66a 100644 --- a/common/test/acceptance/tests/lms/test_lms_entrance_exams.py +++ b/common/test/acceptance/tests/lms/test_lms_entrance_exams.py @@ -6,6 +6,7 @@ from textwrap import dedent from common.test.acceptance.tests.helpers import UniqueCourseTest from common.test.acceptance.pages.studio.auto_auth import AutoAuthPage +from common.test.acceptance.pages.lms.course_home import CourseHomePage from common.test.acceptance.pages.lms.courseware import CoursewarePage from common.test.acceptance.pages.lms.problem import ProblemPage from common.test.acceptance.fixtures.course import CourseFixture, XBlockFixtureDesc @@ -92,6 +93,8 @@ class EntranceExamPassTest(EntranceExamTest): When I pass entrance exam Then I can see complete TOC of course And I can see message indicating my pass status + When I switch to course home page + Then I see 2 sections """ self.courseware_page.visit() problem_page = ProblemPage(self.browser) @@ -102,4 +105,29 @@ class EntranceExamPassTest(EntranceExamTest): problem_page.click_submit() self.courseware_page.wait_for_page() self.assertTrue(self.courseware_page.has_passed_message()) - self.assertEqual(self.courseware_page.chapter_count_in_navigation, 2) + + course_home_page = CourseHomePage(self.browser, self.course_id) + course_home_page.visit() + self.assertEqual(course_home_page.outline.num_sections, 2) + + # TODO: TNL-6546: Delete test using outline on courseware + def test_course_is_unblocked_as_soon_as_student_passes_entrance_exam_2(self): + """ + Scenario: Ensure that entrance exam status message is updated and courseware is unblocked as soon as + student passes entrance exam. + Given I have a course with entrance exam as pre-requisite + When I pass entrance exam + Then I can see complete TOC of course + And I can see message indicating my pass status + """ + self.courseware_page.visit() + problem_page = ProblemPage(self.browser) + self.assertEqual(problem_page.wait_for_page().problem_name, + 'HEIGHT OF EIFFEL TOWER') + self.assertTrue(self.courseware_page.has_entrance_exam_message()) + self.assertFalse(self.courseware_page.has_passed_message()) + problem_page.click_choice('choice_1') + problem_page.click_submit() + self.courseware_page.wait_for_page() + self.assertTrue(self.courseware_page.has_passed_message()) + self.assertEqual(self.courseware_page.num_sections, 2) diff --git a/common/test/acceptance/tests/studio/test_import_export.py b/common/test/acceptance/tests/studio/test_import_export.py index da6ed9617a..3a5c9d6071 100644 --- a/common/test/acceptance/tests/studio/test_import_export.py +++ b/common/test/acceptance/tests/studio/test_import_export.py @@ -14,6 +14,7 @@ from common.test.acceptance.pages.studio.import_export import ( ImportCoursePage) from common.test.acceptance.pages.studio.library import LibraryEditPage from common.test.acceptance.pages.studio.overview import CourseOutlinePage +from common.test.acceptance.pages.lms.course_home import CourseHomePage from common.test.acceptance.pages.lms.courseware import CoursewarePage from common.test.acceptance.pages.lms.staff_view import StaffCoursewarePage @@ -282,9 +283,13 @@ class TestEntranceExamCourseImport(ImportTestMixin, StudioCourseTest): When I visit the import page And I upload a course that has an entrance exam section named 'Entrance Exam' And I visit the course outline page again - The section named 'Entrance Exam' should now be available. - And when I switch the view mode to student view and Visit CourseWare - Then I see one section in the sidebar that is 'Entrance Exam' + The section named 'Entrance Exam' should now be available + When I visit the LMS Course Home page + Then I should see a section named 'Section' or 'Entrance Exam' + When I switch the view mode to student view + Then I should only see a section named 'Entrance Exam' + When I visit the courseware page + Then a message regarding the 'Entrance Exam' """ self.landing_page.visit() # Should not exist yet. @@ -300,10 +305,16 @@ class TestEntranceExamCourseImport(ImportTestMixin, StudioCourseTest): self.landing_page.section("Section") self.landing_page.view_live() + + course_home = CourseHomePage(self.browser, self.course_id) + course_home.visit() + self.assertEqual(course_home.outline.num_sections, 2) + course_home.preview.set_staff_view_mode('Student') + self.assertEqual(course_home.outline.num_sections, 1) + courseware = CoursewarePage(self.browser, self.course_id) - courseware.wait_for_page() - StaffCoursewarePage(self.browser, self.course_id).set_staff_view_mode('Learner') - self.assertEqual(courseware.num_sections, 1) + courseware.visit() + StaffCoursewarePage(self.browser, self.course_id).set_staff_view_mode('Student') self.assertIn( "To access course materials, you must score", courseware.entrance_exam_message_selector.text[0] ) diff --git a/lms/djangoapps/course_api/blocks/api.py b/lms/djangoapps/course_api/blocks/api.py index 68a6454804..64c62af896 100644 --- a/lms/djangoapps/course_api/blocks/api.py +++ b/lms/djangoapps/course_api/blocks/api.py @@ -51,8 +51,12 @@ def get_blocks( """ # create ordered list of transformers, adding BlocksAPITransformer at end. transformers = BlockStructureTransformers() + can_view_special_exam = False + if requested_fields is not None and 'special_exam' in requested_fields: + can_view_special_exam = True if user is not None: - transformers += COURSE_BLOCK_ACCESS_TRANSFORMERS + [MilestonesTransformer(), HiddenContentTransformer()] + transformers += COURSE_BLOCK_ACCESS_TRANSFORMERS + transformers += [MilestonesTransformer(can_view_special_exam), HiddenContentTransformer()] transformers += [ BlocksAPITransformer( block_counts, diff --git a/lms/djangoapps/course_api/blocks/tests/test_api.py b/lms/djangoapps/course_api/blocks/tests/test_api.py index 9e4d448eb5..af9dae0a05 100644 --- a/lms/djangoapps/course_api/blocks/tests/test_api.py +++ b/lms/djangoapps/course_api/blocks/tests/test_api.py @@ -146,7 +146,7 @@ class TestGetBlocksQueryCounts(SharedModuleStoreTestCase): self._get_blocks( course, expected_mongo_queries=0, - expected_sql_queries=5 if with_storage_backing else 4, + expected_sql_queries=6 if with_storage_backing else 5, ) @ddt.data( @@ -164,5 +164,5 @@ class TestGetBlocksQueryCounts(SharedModuleStoreTestCase): self._get_blocks( course, expected_mongo_queries, - expected_sql_queries=13 if with_storage_backing else 5, + expected_sql_queries=14 if with_storage_backing else 6, ) diff --git a/lms/djangoapps/course_api/blocks/transformers/__init__.py b/lms/djangoapps/course_api/blocks/transformers/__init__.py index 7e0408ed12..5a7a21b400 100644 --- a/lms/djangoapps/course_api/blocks/transformers/__init__.py +++ b/lms/djangoapps/course_api/blocks/transformers/__init__.py @@ -6,6 +6,7 @@ from lms.djangoapps.course_blocks.transformers.visibility import VisibilityTrans from .student_view import StudentViewTransformer from .block_counts import BlockCountsTransformer from .navigation import BlockNavigationTransformer +from .milestones import MilestonesTransformer class SupportedFieldType(object): @@ -44,6 +45,8 @@ SUPPORTED_FIELDS = [ # 'student_view_multi_device' SupportedFieldType(StudentViewTransformer.STUDENT_VIEW_MULTI_DEVICE, StudentViewTransformer), + SupportedFieldType('special_exam', MilestonesTransformer), + # set the block_field_name to None so the entire data for the transformer is serialized SupportedFieldType(None, BlockCountsTransformer, BlockCountsTransformer.BLOCK_COUNTS), diff --git a/lms/djangoapps/course_api/blocks/transformers/milestones.py b/lms/djangoapps/course_api/blocks/transformers/milestones.py index aaa93ec551..0f2ee9f01e 100644 --- a/lms/djangoapps/course_api/blocks/transformers/milestones.py +++ b/lms/djangoapps/course_api/blocks/transformers/milestones.py @@ -2,16 +2,22 @@ Milestones Transformer """ +import logging from django.conf import settings from openedx.core.djangoapps.content.block_structure.transformer import ( BlockStructureTransformer, FilteringTransformerMixin, ) +from edx_proctoring.exceptions import ProctoredExamNotFoundException +from edx_proctoring.api import get_attempt_status_summary +from student.models import EntranceExamConfiguration from util import milestones_helpers +log = logging.getLogger(__name__) -class MilestonesTransformer(FilteringTransformerMixin, BlockStructureTransformer): + +class MilestonesTransformer(BlockStructureTransformer): """ Excludes all special exams (timed, proctored, practice proctored) from the student view. Excludes all blocks with unfulfilled milestones from the student view. @@ -23,6 +29,9 @@ class MilestonesTransformer(FilteringTransformerMixin, BlockStructureTransformer def name(cls): return "milestones" + def __init__(self, can_view_special_exams=True): + self.can_view_special_exams = can_view_special_exams + @classmethod def collect(cls, block_structure): """ @@ -35,22 +44,79 @@ class MilestonesTransformer(FilteringTransformerMixin, BlockStructureTransformer block_structure.request_xblock_fields('is_proctored_enabled') block_structure.request_xblock_fields('is_practice_exam') block_structure.request_xblock_fields('is_timed_exam') + block_structure.request_xblock_fields('entrance_exam_id') - def transform_block_filters(self, usage_info, block_structure): - if usage_info.has_staff_access: - return [block_structure.create_universal_filter()] + def transform(self, usage_info, block_structure): + """ + Modify block structure according to the behavior of milestones and special exams. + """ + + def add_special_exam_info(block_key): + """ + Adds special exam information to course blocks. + """ + if self.is_special_exam(block_key, block_structure): + + # + # call into edx_proctoring subsystem + # to get relevant proctoring information regarding this + # level of the courseware + # + # This will return None, if (user, course_id, content_id) + # is not applicable + # + timed_exam_attempt_context = None + try: + timed_exam_attempt_context = get_attempt_status_summary( + usage_info.user.id, + unicode(block_key.course_key), + unicode(block_key) + ) + except ProctoredExamNotFoundException as ex: + log.exception(ex) + + if timed_exam_attempt_context: + # yes, user has proctoring context about + # this level of the courseware + # so add to the accordion data context + block_structure.set_transformer_block_field( + block_key, + self, + 'special_exam', + timed_exam_attempt_context, + ) + + root_key = block_structure.root_block_usage_key + course_key = root_key.course_key + user_can_skip = EntranceExamConfiguration.user_can_skip_entrance_exam(usage_info.user, course_key) + exam_id = block_structure.get_xblock_field(root_key, 'entrance_exam_id') + required_content = milestones_helpers.get_required_content(course_key, usage_info.user) + if user_can_skip: + required_content = [content for content in required_content if not content == exam_id] def user_gated_from_block(block_key): """ Checks whether the user is gated from accessing this block, first via special exams, then via a general milestones check. """ - return ( - settings.FEATURES.get('ENABLE_SPECIAL_EXAMS', False) and - self.is_special_exam(block_key, block_structure) - ) or self.has_pending_milestones_for_user(block_key, usage_info) + if usage_info.has_staff_access: + return False + elif self.has_pending_milestones_for_user(block_key, usage_info): + return True + elif required_content: + if block_key.block_type == 'chapter' and unicode(block_key) not in required_content: + return True + elif (settings.FEATURES.get('ENABLE_SPECIAL_EXAMS', False) and + (self.is_special_exam(block_key, block_structure) and + not self.can_view_special_exams)): + return True + return False - return [block_structure.create_removal_filter(user_gated_from_block)] + for block_key in block_structure.topological_traversal(): + if user_gated_from_block(block_key): + block_structure.remove_block(block_key, False) + else: + add_special_exam_info(block_key) @staticmethod def is_special_exam(block_key, block_structure): diff --git a/lms/djangoapps/course_api/blocks/transformers/tests/test_milestones.py b/lms/djangoapps/course_api/blocks/transformers/tests/test_milestones.py index d3c26ac31a..63a3816934 100644 --- a/lms/djangoapps/course_api/blocks/transformers/tests/test_milestones.py +++ b/lms/djangoapps/course_api/blocks/transformers/tests/test_milestones.py @@ -9,6 +9,7 @@ from gating import api as lms_gating_api from lms.djangoapps.course_blocks.transformers.tests.helpers import CourseStructureTestCase from milestones.tests.utils import MilestonesTestCaseMixin from openedx.core.lib.gating import api as gating_api +from openedx.core.djangoapps.content.block_structure.transformers import BlockStructureTransformers from student.tests.factories import CourseEnrollmentFactory from ..milestones import MilestonesTransformer @@ -38,6 +39,8 @@ class MilestonesTransformerTestCase(CourseStructureTestCase, MilestonesTestCaseM # Enroll user in course. CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id, is_active=True) + self.transformers = BlockStructureTransformers([self.TRANSFORMER_CLASS_TO_TEST(False)]) + def setup_gated_section(self, gated_block, gating_block): """ Test helper to create a gating requirement. @@ -157,7 +160,7 @@ class MilestonesTransformerTestCase(CourseStructureTestCase, MilestonesTestCaseM self.course.enable_subsection_gating = True self.setup_gated_section(self.blocks[gated_block_ref], self.blocks[gating_block_ref]) - with self.assertNumQueries(6): + with self.assertNumQueries(8): self.get_blocks_and_check_against_expected(self.user, expected_blocks_before_completion) # clear the request cache to simulate a new request @@ -171,7 +174,7 @@ class MilestonesTransformerTestCase(CourseStructureTestCase, MilestonesTestCaseM self.user, ) - with self.assertNumQueries(6): + with self.assertNumQueries(8): self.get_blocks_and_check_against_expected(self.user, self.ALL_BLOCKS_EXCEPT_SPECIAL) def test_staff_access(self): @@ -183,6 +186,30 @@ class MilestonesTransformerTestCase(CourseStructureTestCase, MilestonesTestCaseM self.setup_gated_section(self.blocks['H'], self.blocks['A']) self.get_blocks_and_check_against_expected(self.staff, expected_blocks) + def test_can_view_special(self): + """ + When the block structure transformers are set to allow users to view special exams, + ensure that we can see the special exams and not any of the otherwise gated blocks. + """ + self.transformers = BlockStructureTransformers([self.TRANSFORMER_CLASS_TO_TEST(True)]) + self.course.enable_subsection_gating = True + self.setup_gated_section(self.blocks['H'], self.blocks['A']) + expected_blocks = ( + 'course', 'A', 'B', 'C', 'ProctoredExam', 'D', 'E', 'PracticeExam', 'F', 'G', 'TimedExam', 'J', 'K' + ) + self.get_blocks_and_check_against_expected(self.user, expected_blocks) + # clear the request cache to simulate a new request + self.clear_caches() + + # this call triggers reevaluation of prerequisites fulfilled by the gating block. + with patch('gating.api._get_subsection_percentage', Mock(return_value=100)): + lms_gating_api.evaluate_prerequisite( + self.course, + Mock(location=self.blocks['A'].location), + self.user, + ) + self.get_blocks_and_check_against_expected(self.user, self.ALL_BLOCKS) + def get_blocks_and_check_against_expected(self, user, expected_blocks): """ Calls the course API as the specified user and checks the diff --git a/lms/djangoapps/courseware/entrance_exams.py b/lms/djangoapps/courseware/entrance_exams.py index 10b8c1f359..00c5d70ee5 100644 --- a/lms/djangoapps/courseware/entrance_exams.py +++ b/lms/djangoapps/courseware/entrance_exams.py @@ -55,7 +55,7 @@ def get_entrance_exam_content(user, course): """ Get the entrance exam content information (ie, chapter module) """ - required_content = get_required_content(course, user) + required_content = get_required_content(course.id, user) exam_module = None for content in required_content: diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index c85a613422..81b4da78b4 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -162,7 +162,7 @@ def toc_for_course(user, request, course, active_chapter, active_section, field_ # Check for content which needs to be completed # before the rest of the content is made available - required_content = milestones_helpers.get_required_content(course, user) + required_content = milestones_helpers.get_required_content(course.id, user) # The user may not actually have to complete the entrance exam, if one is required if user_can_skip_entrance_exam(user, course): diff --git a/openedx/features/course_experience/templates/course_experience/course-outline-fragment.html b/openedx/features/course_experience/templates/course_experience/course-outline-fragment.html index 32dd62acc7..3d5f66295b 100644 --- a/openedx/features/course_experience/templates/course_experience/course-outline-fragment.html +++ b/openedx/features/course_experience/templates/course_experience/course-outline-fragment.html @@ -27,6 +27,7 @@ from django.utils.translation import ugettext as _