diff --git a/common/djangoapps/util/milestones_helpers.py b/common/djangoapps/util/milestones_helpers.py index b4bb71ff509d..41b704795891 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/course_home.py b/common/test/acceptance/pages/lms/course_home.py index 5cb9c448f557..21746a762c05 100644 --- a/common/test/acceptance/pages/lms/course_home.py +++ b/common/test/acceptance/pages/lms/course_home.py @@ -56,7 +56,7 @@ class CourseOutlinePage(PageObject): SECTION_SELECTOR = '.outline-item.section:nth-of-type({0})' SECTION_TITLES_SELECTOR = '.section-name span' SUBSECTION_SELECTOR = SECTION_SELECTOR + ' .subsection:nth-of-type({1}) .outline-item' - SUBSECTION_TITLES_SELECTOR = SECTION_SELECTOR + ' .subsection a span:first-child' + SUBSECTION_TITLES_SELECTOR = SECTION_SELECTOR + ' .subsection .subsection-title' OUTLINE_RESUME_COURSE_SELECTOR = '.outline-item .resume-right' def __init__(self, browser, parent_page): diff --git a/common/test/acceptance/pages/lms/courseware.py b/common/test/acceptance/pages/lms/courseware.py index 86e1aac162ad..571225ac38ef 100644 --- a/common/test/acceptance/pages/lms/courseware.py +++ b/common/test/acceptance/pages/lms/courseware.py @@ -30,6 +30,7 @@ def __init__(self, browser, course_id): 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 abe8cdb294e2..bc51481aa61f 100644 --- a/common/test/acceptance/tests/lms/test_lms.py +++ b/common/test/acceptance/tests/lms/test_lms.py @@ -25,7 +25,6 @@ from common.test.acceptance.pages.lms import BASE_URL from common.test.acceptance.pages.lms.account_settings import AccountSettingsPage from common.test.acceptance.pages.lms.auto_auth import AutoAuthPage -from common.test.acceptance.pages.lms.bookmarks import BookmarksPage from common.test.acceptance.pages.lms.create_mode import ModeCreationPage from common.test.acceptance.pages.lms.course_home import CourseHomePage from common.test.acceptance.pages.lms.course_info import CourseInfoPage @@ -635,6 +634,7 @@ def test_children_a11y(self): children_page.a11y_audit.check_for_accessibility_errors() +@attr(shard=1) class HighLevelTabTest(UniqueCourseTest): """ Tests that verify each of the high-level tabs available within a course. @@ -688,7 +688,6 @@ def setUp(self): # Auto-auth register for the course AutoAuthPage(self.browser, course_id=self.course_id).visit() - @attr(shard=1) def test_course_info(self): """ Navigate to the course info page. @@ -706,7 +705,6 @@ def test_course_info(self): self.assertEqual(len(handout_links), 1) self.assertIn('demoPDF.pdf', handout_links[0]) - @attr(shard=1) def test_progress(self): """ Navigate to the progress page. @@ -724,7 +722,6 @@ def test_progress(self): actual_scores = self.progress_page.scores(CHAPTER, SECTION) self.assertEqual(actual_scores, EXPECTED_SCORES) - @attr(shard=1) def test_static_tab(self): """ Navigate to a static tab (course content) @@ -734,7 +731,6 @@ def test_static_tab(self): self.tab_nav.go_to_tab('Test Static Tab') self.assertTrue(self.tab_nav.is_on_tab('Test Static Tab')) - @attr(shard=1) def test_static_tab_with_mathjax(self): """ Navigate to a static tab (course content) @@ -747,7 +743,6 @@ def test_static_tab_with_mathjax(self): # Verify that Mathjax has rendered self.tab_nav.mathjax_has_rendered() - @attr(shard=1) def test_wiki_tab_first_time(self): """ Navigate to the course wiki tab. When the wiki is accessed for @@ -769,7 +764,6 @@ def test_wiki_tab_first_time(self): self.assertEqual(expected_article_name, course_wiki.article_name) # TODO: TNL-6546: This whole function will be able to go away, replaced by test_course_home below. - @attr(shard=1) def test_courseware_nav(self): """ Navigate to a particular unit in the course. @@ -805,13 +799,9 @@ def test_courseware_nav(self): self.courseware_page.nav.go_to_section('Test Section 2', 'Test Subsection 3') self.assertTrue(self.courseware_page.nav.is_on_section('Test Section 2', 'Test Subsection 3')) - @attr(shard=1) - def test_course_home(self): + def test_course_home_tab(self): """ Navigate to the course home page using the tab. - - Includes smoke test of course outline, courseware page, and breadcrumbs. - """ # TODO: TNL-6546: Use tab navigation and remove course_home_page.visit(). #self.course_info_page.visit() @@ -825,56 +815,6 @@ def test_course_home(self): # Check that the tab lands on the course home page. self.assertTrue(self.course_home_page.is_browser_on_page()) - # Check that the course navigation appears correctly - EXPECTED_SECTIONS = { - 'Test Section': ['Test Subsection'], - 'Test Section 2': ['Test Subsection 2', 'Test Subsection 3'] - } - - actual_sections = self.course_home_page.outline.sections - for section, subsections in EXPECTED_SECTIONS.iteritems(): - self.assertIn(section, actual_sections) - self.assertEqual(actual_sections[section], EXPECTED_SECTIONS[section]) - - # Navigate to a particular section - self.course_home_page.outline.go_to_section('Test Section', 'Test Subsection') - - # Check the sequence items on the courseware page - EXPECTED_ITEMS = ['Test Problem 1', 'Test Problem 2', 'Test HTML'] - - actual_items = self.courseware_page.nav.sequence_items - self.assertEqual(len(actual_items), len(EXPECTED_ITEMS)) - for expected in EXPECTED_ITEMS: - self.assertIn(expected, actual_items) - - # Use outline breadcrumb to get back to course home page. - self.courseware_page.nav.go_to_outline() - - # Navigate to a particular section other than the default landing section. - self.course_home_page.outline.go_to_section('Test Section 2', 'Test Subsection 3') - self.assertTrue(self.courseware_page.nav.is_on_section('Test Section 2', 'Test Subsection 3')) - - # Verify that we can navigate to the bookmarks page - self.course_home_page.visit() - self.course_home_page.click_bookmarks_button() - bookmarks_page = BookmarksPage(self.browser, self.course_id) - self.assertTrue(bookmarks_page.is_browser_on_page()) - - # Test "Resume Course" button from header - self.course_home_page.visit() - self.course_home_page.resume_course_from_header() - self.assertTrue(self.courseware_page.nav.is_on_section('Test Section 2', 'Test Subsection 3')) - - # Test "Resume Course" button from within outline - self.course_home_page.visit() - self.course_home_page.outline.resume_course_from_outline() - self.assertTrue(self.courseware_page.nav.is_on_section('Test Section 2', 'Test Subsection 3')) - - @attr('a11y') - def test_course_home_a11y(self): - self.course_home_page.visit() - self.course_home_page.a11y_audit.check_for_accessibility_errors() - @attr(shard=1) class PDFTextBooksTabTest(UniqueCourseTest): @@ -1233,7 +1173,7 @@ def setUp(self): 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 +1185,40 @@ def setUp(self): 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 +1226,13 @@ def test_entrance_exam_section(self): 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 +1251,10 @@ def test_entrance_exam_section(self): 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_course_home.py b/common/test/acceptance/tests/lms/test_lms_course_home.py new file mode 100644 index 000000000000..c9b65c037df1 --- /dev/null +++ b/common/test/acceptance/tests/lms/test_lms_course_home.py @@ -0,0 +1,139 @@ +# -*- coding: utf-8 -*- +""" +End-to-end tests for the LMS that utilize the course home page and course outline. +""" +from contextlib import contextmanager +from nose.plugins.attrib import attr + +from ..helpers import auto_auth, load_data_str, UniqueCourseTest +from ...fixtures.course import CourseFixture, XBlockFixtureDesc +from ...pages.lms.bookmarks import BookmarksPage +from ...pages.lms.course_home import CourseHomePage +from ...pages.lms.courseware import CoursewarePage + + +class CourseHomeBaseTest(UniqueCourseTest): + """ + Provides base setup for course home tests. + """ + USERNAME = "STUDENT_TESTER" + EMAIL = "student101@example.com" + + def setUp(self): + """ + Initialize pages and install a course fixture. + """ + super(CourseHomeBaseTest, self).setUp() + + self.course_home_page = CourseHomePage(self.browser, self.course_id) + self.courseware_page = CoursewarePage(self.browser, self.course_id) + + # Install a course with sections and problems + course_fix = CourseFixture( + self.course_info['org'], + self.course_info['number'], + self.course_info['run'], + self.course_info['display_name'] + ) + + course_fix.add_children( + XBlockFixtureDesc('static_tab', 'Test Static Tab', data=r"static tab data with mathjax \(E=mc^2\)"), + XBlockFixtureDesc('chapter', 'Test Section').add_children( + XBlockFixtureDesc('sequential', 'Test Subsection').add_children( + XBlockFixtureDesc('problem', 'Test Problem 1', data=load_data_str('multiple_choice.xml')), + XBlockFixtureDesc('problem', 'Test Problem 2', data=load_data_str('formula_problem.xml')), + XBlockFixtureDesc('html', 'Test HTML'), + ) + ), + XBlockFixtureDesc('chapter', 'Test Section 2').add_children( + XBlockFixtureDesc('sequential', 'Test Subsection 2'), + XBlockFixtureDesc('sequential', 'Test Subsection 3').add_children( + XBlockFixtureDesc('problem', 'Test Problem A', data=load_data_str('multiple_choice.xml')) + ), + ) + ).install() + + # Auto-auth register for the course. + auto_auth(self.browser, self.USERNAME, self.EMAIL, False, self.course_id) + + +class CourseHomeTest(CourseHomeBaseTest): + """ + Tests the course home page with course outline. + """ + + def test_course_home(self): + """ + Smoke test of course outline, breadcrumbs to and from cours outline, and bookmarks. + """ + self.course_home_page.visit() + + # TODO: TNL-6546: Remove unified_course_view. + self.course_home_page.unified_course_view = True + self.courseware_page.nav.unified_course_view = True + + # Check that the tab lands on the course home page. + self.assertTrue(self.course_home_page.is_browser_on_page()) + + # Check that the course navigation appears correctly + EXPECTED_SECTIONS = { + 'Test Section': ['Test Subsection'], + 'Test Section 2': ['Test Subsection 2', 'Test Subsection 3'] + } + + actual_sections = self.course_home_page.outline.sections + for section, subsections in EXPECTED_SECTIONS.iteritems(): + self.assertIn(section, actual_sections) + self.assertEqual(actual_sections[section], EXPECTED_SECTIONS[section]) + + # Navigate to a particular section + self.course_home_page.outline.go_to_section('Test Section', 'Test Subsection') + + # Check the sequence items on the courseware page + EXPECTED_ITEMS = ['Test Problem 1', 'Test Problem 2', 'Test HTML'] + + actual_items = self.courseware_page.nav.sequence_items + self.assertEqual(len(actual_items), len(EXPECTED_ITEMS)) + for expected in EXPECTED_ITEMS: + self.assertIn(expected, actual_items) + + # Use outline breadcrumb to get back to course home page. + self.courseware_page.nav.go_to_outline() + + # Navigate to a particular section other than the default landing section. + self.course_home_page.outline.go_to_section('Test Section 2', 'Test Subsection 3') + self.assertTrue(self.courseware_page.nav.is_on_section('Test Section 2', 'Test Subsection 3')) + + # Verify that we can navigate to the bookmarks page + self.course_home_page.visit() + self.course_home_page.click_bookmarks_button() + bookmarks_page = BookmarksPage(self.browser, self.course_id) + self.assertTrue(bookmarks_page.is_browser_on_page()) + + # Test "Resume Course" button from header + self.course_home_page.visit() + self.course_home_page.resume_course_from_header() + self.assertTrue(self.courseware_page.nav.is_on_section('Test Section 2', 'Test Subsection 3')) + + # Test "Resume Course" button from within outline + self.course_home_page.visit() + self.course_home_page.outline.resume_course_from_outline() + self.assertTrue(self.courseware_page.nav.is_on_section('Test Section 2', 'Test Subsection 3')) + + +@attr('a11y') +class CourseHomeA11yTest(CourseHomeBaseTest): + """ + Tests the accessibility of the course home page with course outline. + """ + + def setUp(self): + super(CourseHomeA11yTest, self).setUp() + + def test_course_home_a11y(self): + """ + Test the accessibility of the course home page with course outline. + """ + course_home_page = CourseHomePage(self.browser, self.course_id) + course_home_page.visit() + course_home_page.a11y_audit.check_for_accessibility_errors() diff --git a/common/test/acceptance/tests/lms/test_lms_courseware.py b/common/test/acceptance/tests/lms/test_lms_courseware.py index b81aa1f06cb9..33145ebe80fb 100644 --- a/common/test/acceptance/tests/lms/test_lms_courseware.py +++ b/common/test/acceptance/tests/lms/test_lms_courseware.py @@ -129,7 +129,7 @@ def test_course_tree_breadcrumb(self): @ddt.ddt class ProctoredExamTest(UniqueCourseTest): """ - Test courseware. + Tests for proctored exams. """ USERNAME = "STUDENT_TESTER" EMAIL = "student101@example.com" 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 22510fe0eac9..f965c0e66a60 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 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 @@ def test_course_is_unblocked_as_soon_as_student_passes_entrance_exam(self): 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 @@ def test_course_is_unblocked_as_soon_as_student_passes_entrance_exam(self): 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 da6ed9617a4f..de1a4a09c9fe 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 @@ 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 @@ def test_course_updated_with_entrance_exam(self): 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 @@ def test_course_updated_with_entrance_exam(self): 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('Learner') + self.assertEqual(course_home.outline.num_sections, 1) + courseware = CoursewarePage(self.browser, self.course_id) - courseware.wait_for_page() + courseware.visit() StaffCoursewarePage(self.browser, self.course_id).set_staff_view_mode('Learner') - self.assertEqual(courseware.num_sections, 1) 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 68a6454804ed..e33c9252b9b1 100644 --- a/lms/djangoapps/course_api/blocks/api.py +++ b/lms/djangoapps/course_api/blocks/api.py @@ -7,7 +7,7 @@ from openedx.core.djangoapps.content.block_structure.transformers import BlockStructureTransformers from .transformers.blocks_api import BlocksAPITransformer -from .transformers.milestones import MilestonesTransformer +from .transformers.milestones import MilestonesAndSpecialExamsTransformer from .serializers import BlockSerializer, BlockDictSerializer @@ -51,8 +51,12 @@ def get_blocks( """ # create ordered list of transformers, adding BlocksAPITransformer at end. transformers = BlockStructureTransformers() + include_special_exams = False + if requested_fields is not None and 'special_exam_info' in requested_fields: + include_special_exams = True if user is not None: - transformers += COURSE_BLOCK_ACCESS_TRANSFORMERS + [MilestonesTransformer(), HiddenContentTransformer()] + transformers += COURSE_BLOCK_ACCESS_TRANSFORMERS + transformers += [MilestonesAndSpecialExamsTransformer(include_special_exams), 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 9e4d448eb527..af9dae0a0505 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 @@ def test_query_counts_cached(self, store_type, with_storage_backing): 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 @@ def test_query_counts_uncached(self, store_type_tuple, with_storage_backing): 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 7e0408ed12f3..36ee5e6bc1ba 100644 --- a/lms/djangoapps/course_api/blocks/transformers/__init__.py +++ b/lms/djangoapps/course_api/blocks/transformers/__init__.py @@ -6,6 +6,7 @@ from .student_view import StudentViewTransformer from .block_counts import BlockCountsTransformer from .navigation import BlockNavigationTransformer +from .milestones import MilestonesAndSpecialExamsTransformer class SupportedFieldType(object): @@ -44,6 +45,8 @@ def __init__( # 'student_view_multi_device' SupportedFieldType(StudentViewTransformer.STUDENT_VIEW_MULTI_DEVICE, StudentViewTransformer), + SupportedFieldType('special_exam_info', MilestonesAndSpecialExamsTransformer), + # 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 aaa93ec5519b..d0b33efe5147 100644 --- a/lms/djangoapps/course_api/blocks/transformers/milestones.py +++ b/lms/djangoapps/course_api/blocks/transformers/milestones.py @@ -2,19 +2,31 @@ 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 MilestonesAndSpecialExamsTransformer(BlockStructureTransformer): """ - Excludes all special exams (timed, proctored, practice proctored) from the student view. - Excludes all blocks with unfulfilled milestones from the student view. + A transformer that handles both milestones and special (timed) exams. + + It excludes all blocks with unfulfilled milestones from the student view. An entrance exam is considered a + milestone, and is not considered a "special exam". + + It also includes or excludes all special (timed) exams (timed, proctored, practice proctored) in/from the + student view, based on the value of `include_special_exams`. + """ WRITE_VERSION = 1 READ_VERSION = 1 @@ -23,6 +35,9 @@ class MilestonesTransformer(FilteringTransformerMixin, BlockStructureTransformer def name(cls): return "milestones" + def __init__(self, include_special_exams=True): + self.include_special_exams = include_special_exams + @classmethod def collect(cls, block_structure): """ @@ -35,28 +50,42 @@ def collect(cls, block_structure): 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. + """ + required_content = self.get_required_content(usage_info, block_structure) 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) - return [block_structure.create_removal_filter(user_gated_from_block)] + if usage_info.has_staff_access: + return False + elif self.has_pending_milestones_for_user(block_key, usage_info): + return True + elif self.gated_by_required_content(block_key, block_structure, required_content): + return True + elif (settings.FEATURES.get('ENABLE_SPECIAL_EXAMS', False) and + (self.is_special_exam(block_key, block_structure) and + not self.include_special_exams)): + return True + return False + + for block_key in block_structure.topological_traversal(): + if user_gated_from_block(block_key): + block_structure.remove_block(block_key, False) + elif self.is_special_exam(block_key, block_structure): + self.add_special_exam_info(block_key, block_structure, usage_info) @staticmethod def is_special_exam(block_key, block_structure): """ - Test whether the block is a special exam. These exams are always excluded - from the student view. + Test whether the block is a special exam. """ return ( block_structure.get_xblock_field(block_key, 'is_proctored_enabled') or @@ -76,3 +105,66 @@ def has_pending_milestones_for_user(block_key, usage_info): 'requires', usage_info.user.id )) + + # TODO: As part of a cleanup effort, this transformer should be split into + # MilestonesTransformer and SpecialExamsTransformer, which are completely independent. + def add_special_exam_info(self, block_key, block_structure, usage_info): + """ + For special exams, add the special exam information to the course blocks. + """ + special_exam_attempt_context = None + try: + # Calls into edx_proctoring subsystem to get relevant special exam information. + # This will return None, if (user, course_id, content_id) is not applicable. + special_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 special_exam_attempt_context: + # This user has special exam context for this block so add it. + block_structure.set_transformer_block_field( + block_key, + self, + 'special_exam_info', + special_exam_attempt_context, + ) + + @staticmethod + def get_required_content(usage_info, block_structure): + """ + Get the required content for the course. + + This takes into account if the user can skip the entrance exam. + + """ + course_key = block_structure.root_block_usage_key.course_key + user_can_skip_entrance_exam = EntranceExamConfiguration.user_can_skip_entrance_exam(usage_info.user, course_key) + required_content = milestones_helpers.get_required_content(course_key, usage_info.user) + + if not required_content: + return required_content + + if user_can_skip_entrance_exam: + # remove the entrance exam from required content + entrance_exam_id = block_structure.get_xblock_field(block_structure.root_block_usage_key, 'entrance_exam_id') + required_content = [content for content in required_content if not content == entrance_exam_id] + + return required_content + + @staticmethod + def gated_by_required_content(block_key, block_structure, required_content): + """ + Returns True if the current block associated with the block_key should be gated by the given required_content. + Returns False otherwise. + """ + if not required_content: + return False + + if block_key.block_type == 'chapter' and unicode(block_key) not in required_content: + return True + + return False 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 d3c26ac31a95..5652aae3f127 100644 --- a/lms/djangoapps/course_api/blocks/transformers/tests/test_milestones.py +++ b/lms/djangoapps/course_api/blocks/transformers/tests/test_milestones.py @@ -9,9 +9,10 @@ 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 +from ..milestones import MilestonesAndSpecialExamsTransformer from ...api import get_course_blocks @@ -22,7 +23,7 @@ class MilestonesTransformerTestCase(CourseStructureTestCase, MilestonesTestCaseM """ Test behavior of ProctoredExamTransformer """ - TRANSFORMER_CLASS_TO_TEST = MilestonesTransformer + TRANSFORMER_CLASS_TO_TEST = MilestonesAndSpecialExamsTransformer def setUp(self): """ @@ -38,6 +39,8 @@ def setUp(self): # 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 @@ def test_gated(self, gated_block_ref, gating_block_ref, expected_blocks_before_c 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 @@ def test_gated(self, gated_block_ref, gating_block_ref, expected_blocks_before_c 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 @@ def test_staff_access(self): self.setup_gated_section(self.blocks['H'], self.blocks['A']) self.get_blocks_and_check_against_expected(self.staff, expected_blocks) + def test_special_exams(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 10b8c1f3591e..00c5d70ee530 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 c85a61342257..81b4da78b414 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/lms/static/sass/features/_course-outline.scss b/lms/static/sass/features/_course-outline.scss index 7ea0b7e79192..6e9a98a57de2 100644 --- a/lms/static/sass/features/_course-outline.scss +++ b/lms/static/sass/features/_course-outline.scss @@ -35,21 +35,32 @@ list-style-type: none; a.outline-item { - display: block; + display: flex; + justify-content: space-between; padding: ($baseline / 2); &:hover { background-color: palette(primary, x-back); - text-decoration: none; + } + + .subsection-text { + .details { + font-size: $body-font-size; + color: $lms-gray; + font-style: italic; + } + } + + .subsection-actions { + .resume-right { + position: relative; + top: calc(50% - (#{$baseline} / 2)); + } } } &.current { border: 1px solid $lms-active-color; - - .resume-right { - @include float(right); - } } } } 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 32dd62acc75b..ac4332313e8f 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 @@ -38,14 +38,81 @@ href="${ subsection['lms_web_url'] }" id="${ subsection['id'] }" > - ${ subsection['display_name'] } - ${ _("This is your last visited course section.") } - % if subsection['current']: - - ${ _("Resume Course") } - - - % endif +