From 9e734442c2aa188177e778a29fd0f227254990e8 Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Tue, 28 Mar 2017 13:34:24 -0400 Subject: [PATCH 01/10] Initial version of new transformer. --- common/djangoapps/util/milestones_helpers.py | 4 +- .../test/acceptance/pages/lms/courseware.py | 1 + common/test/acceptance/tests/lms/test_lms.py | 49 +++++++++-- .../tests/lms/test_lms_entrance_exams.py | 30 ++++++- .../tests/studio/test_import_export.py | 23 +++-- lms/djangoapps/course_api/blocks/api.py | 6 +- .../course_api/blocks/tests/test_api.py | 4 +- .../blocks/transformers/__init__.py | 3 + .../blocks/transformers/milestones.py | 84 +++++++++++++++++-- .../transformers/tests/test_milestones.py | 31 ++++++- lms/djangoapps/courseware/entrance_exams.py | 2 +- lms/djangoapps/courseware/module_render.py | 2 +- .../course-outline-fragment.html | 1 + .../course_experience/views/course_outline.py | 2 +- 14 files changed, 209 insertions(+), 33 deletions(-) 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/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..5d3670377f2d 100644 --- a/common/test/acceptance/tests/lms/test_lms.py +++ b/common/test/acceptance/tests/lms/test_lms.py @@ -1233,7 +1233,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 +1245,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 +1286,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 +1311,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_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..3a5c9d60716d 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('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 68a6454804ed..64c62af8962a 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 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..5a7a21b4002f 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 MilestonesTransformer class SupportedFieldType(object): @@ -44,6 +45,8 @@ def __init__( # '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 aaa93ec5519b..0f2ee9f01e4f 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 @@ 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(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, + ) - def transform_block_filters(self, usage_info, block_structure): - if usage_info.has_staff_access: - return [block_structure.create_universal_filter()] + 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 d3c26ac31a95..63a3816934b7 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 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 @@ 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_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 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/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..3d5f66295b89 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 @@
    % for subsection in section.get('children') or []: + ${ subsection.get('special_exam', '') }
  1. Date: Fri, 7 Apr 2017 09:55:03 -0400 Subject: [PATCH 02/10] Fix preview change from 'Student' to 'Learner'. --- common/test/acceptance/tests/studio/test_import_export.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/common/test/acceptance/tests/studio/test_import_export.py b/common/test/acceptance/tests/studio/test_import_export.py index 3a5c9d60716d..de1a4a09c9fe 100644 --- a/common/test/acceptance/tests/studio/test_import_export.py +++ b/common/test/acceptance/tests/studio/test_import_export.py @@ -309,12 +309,12 @@ def test_course_updated_with_entrance_exam(self): 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') + course_home.preview.set_staff_view_mode('Learner') self.assertEqual(course_home.outline.num_sections, 1) courseware = CoursewarePage(self.browser, self.course_id) courseware.visit() - StaffCoursewarePage(self.browser, self.course_id).set_staff_view_mode('Student') + StaffCoursewarePage(self.browser, self.course_id).set_staff_view_mode('Learner') self.assertIn( "To access course materials, you must score", courseware.entrance_exam_message_selector.text[0] ) From b7e1a0d580727c252dc3865e2a0c8aa998a33c3a Mon Sep 17 00:00:00 2001 From: Brian Jacobel Date: Thu, 23 Mar 2017 15:58:57 -0400 Subject: [PATCH 03/10] Add new info to the course outline --- lms/static/sass/features/_course-outline.scss | 23 +++-- .../course-outline-fragment.html | 83 +++++++++++++++++-- .../course_experience/views/course_outline.py | 7 ++ 3 files changed, 98 insertions(+), 15 deletions(-) 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 3d5f66295b89..7c92a47101d9 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,7 +27,6 @@
      % for subsection in section.get('children') or []: - ${ subsection.get('special_exam', '') }
    1. - ${ subsection['display_name'] } - ${ _("This is your last visited course section.") } - % if subsection['current']: - - ${ _("Resume Course") } - - - % endif +
      + ## Subsection title + ${ subsection['display_name'] } + +
      + ## There are behavior differences between rendering of subsections which have + ## special_exam/timed examinations and those that do not. + ## + ## Proctoring exposes a exam status message field as well as a status icon + <% + if subsection.get('due') is None: + data_string = subsection['format'] + else: + if 'special_exam' in subsection: + data_string = _('due {date}') + else: + data_string = _("{subsection_format} due {{date}}").format(subsection_format=subsection['format']) + %> + % if subsection['format'] or due_date or 'special_exam' in subsection: + + % if 'special_exam' in subsection: + ## Display the proctored exam status icon and status message + + + ${subsection['special_exam'].get('short_description', '')} + + + ## completed proctored exam statuses should not show the due date + ## since the exam has already been submitted by the user + % if not subsection['special_exam'].get('in_completed_state', False): + + % endif + % else: + ## non-proctored section, we just show the exam format and the due date + ## this is the standard case in edx-platform + + + % if 'graded' in subsection and subsection['graded']: + + ${_("This content is graded")} + % endif + % endif + + % endif +
      +
      +
      + ## Resume button (if last visited section) + % if subsection['current']: + ${ _("This is your last visited course section.") } + + ${ _("Resume Course") } + + + %endif +
    2. % endfor diff --git a/openedx/features/course_experience/views/course_outline.py b/openedx/features/course_experience/views/course_outline.py index 8724a0f1736b..26e35936bbc7 100644 --- a/openedx/features/course_experience/views/course_outline.py +++ b/openedx/features/course_experience/views/course_outline.py @@ -29,6 +29,13 @@ def populate_children(self, block, all_blocks, course_position): for i in range(len(children)): child_id = block['children'][i] child_detail = self.populate_children(all_blocks[child_id], all_blocks, course_position) + + # Fake data; remove + child_detail = dict(child_detail, **{ + "format": "", + "due": "" + }) + block['children'][i] = child_detail block['children'][i]['current'] = course_position == child_detail['block_id'] From 584e7fdafbb514fb236f6cb55d69b56745a615dd Mon Sep 17 00:00:00 2001 From: Robert Raposa Date: Fri, 7 Apr 2017 15:51:22 -0400 Subject: [PATCH 04/10] Add graded homework to a11y test of course outline. Includes refactor of course home/course outline smoke test to its own file. --- common/test/acceptance/tests/lms/test_lms.py | 64 +----- .../tests/lms/test_lms_course_home.py | 182 ++++++++++++++++++ .../tests/lms/test_lms_courseware.py | 2 +- 3 files changed, 185 insertions(+), 63 deletions(-) create mode 100644 common/test/acceptance/tests/lms/test_lms_course_home.py diff --git a/common/test/acceptance/tests/lms/test_lms.py b/common/test/acceptance/tests/lms/test_lms.py index 5d3670377f2d..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): 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..04f5a347f555 --- /dev/null +++ b/common/test/acceptance/tests/lms/test_lms_course_home.py @@ -0,0 +1,182 @@ +# -*- 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.common.logout import LogoutPage +from ...pages.lms.bookmarks import BookmarksPage +from ...pages.lms.course_home import CourseHomePage +from ...pages.lms.courseware import CoursewarePage +from ...pages.studio.overview import CourseOutlinePage as StudioCourseOutlinePage + + +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() + + self.logout_page = LogoutPage(self.browser) + + self.studio_course_outline = StudioCourseOutlinePage( + self.browser, + self.course_info['org'], + self.course_info['number'], + self.course_info['run'] + ) + + # adds graded assignments to course home for testing course outline a11y + self._set_policy_for_subsection("Homework", 0) + + def test_course_home_a11y(self): + """ + Test the accessibility of the course home page with course outline. + """ + with self._logged_in_session(): + course_home_page = CourseHomePage(self.browser, self.course_id) + course_home_page.visit() + course_home_page.a11y_audit.check_for_accessibility_errors() + + def _set_policy_for_subsection(self, policy, section=0): + """ + Set the grading policy for the first subsection in the specified section. + If a section index is not provided, 0 is assumed. + """ + with self._logged_in_session(staff=True): + self.studio_course_outline.visit() + modal = self.studio_course_outline.section_at(section).subsection_at( + 0).edit() + modal.policy = policy + modal.save() + + @contextmanager + def _logged_in_session(self, staff=False): + """ + Ensure that the user is logged in and out appropriately at the beginning + and end of the current test. + """ + self.logout_page.visit() + try: + if staff: + auto_auth(self.browser, "STAFF_TESTER", "staff101@example.com", True, self.course_id) + else: + auto_auth(self.browser, self.USERNAME, self.EMAIL, False, self.course_id) + yield + finally: + self.logout_page.visit() 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" From cca52b90aafd7b8279590fc356480e7714d7cfc4 Mon Sep 17 00:00:00 2001 From: Robert Raposa Date: Mon, 10 Apr 2017 11:13:32 -0400 Subject: [PATCH 05/10] Fix failures with simplified subsection title selector. --- common/test/acceptance/pages/lms/course_home.py | 2 +- .../templates/course_experience/course-outline-fragment.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/openedx/features/course_experience/templates/course_experience/course-outline-fragment.html b/openedx/features/course_experience/templates/course_experience/course-outline-fragment.html index 7c92a47101d9..cbb92f0fb5a6 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 @@ -40,7 +40,7 @@ >
      ## Subsection title - ${ subsection['display_name'] } + ${ subsection['display_name'] }
      ## There are behavior differences between rendering of subsections which have From 31aa776e4e08c797dac94fd6cd82f9b10d31d443 Mon Sep 17 00:00:00 2001 From: Brian Jacobel Date: Mon, 10 Apr 2017 12:33:59 -0400 Subject: [PATCH 06/10] Begin client-side date processing using DateutilFactory --- .../course_experience/course-outline-fragment.html | 14 +++++++++----- .../course_experience/views/course_outline.py | 6 ------ 2 files changed, 9 insertions(+), 11 deletions(-) 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 cbb92f0fb5a6..6640440c08bc 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 @@ -49,14 +49,14 @@ ## Proctoring exposes a exam status message field as well as a status icon <% if subsection.get('due') is None: - data_string = subsection['format'] + data_string = subsection.get('format') else: if 'special_exam' in subsection: data_string = _('due {date}') else: - data_string = _("{subsection_format} due {{date}}").format(subsection_format=subsection['format']) + data_string = _("{subsection_format} due {{date}}").format(subsection_format=subsection.get('format')) %> - % if subsection['format'] or due_date or 'special_exam' in subsection: + % if subsection.get('format') or 'special_exam' in subsection: % if 'special_exam' in subsection: ## Display the proctored exam status icon and status message @@ -73,7 +73,7 @@ % if not subsection['special_exam'].get('in_completed_state', False):
      + +<%static:require_module_async module_name="js/dateutil_factory" class_name="DateUtilFactory"> + DateUtilFactory.transform('.localized-datetime'); + diff --git a/openedx/features/course_experience/views/course_outline.py b/openedx/features/course_experience/views/course_outline.py index 26e35936bbc7..d72283530920 100644 --- a/openedx/features/course_experience/views/course_outline.py +++ b/openedx/features/course_experience/views/course_outline.py @@ -30,12 +30,6 @@ def populate_children(self, block, all_blocks, course_position): child_id = block['children'][i] child_detail = self.populate_children(all_blocks[child_id], all_blocks, course_position) - # Fake data; remove - child_detail = dict(child_detail, **{ - "format": "", - "due": "" - }) - block['children'][i] = child_detail block['children'][i]['current'] = course_position == child_detail['block_id'] From aafd6a03ce2d4d164f322a9a13afe93a4cf8934e Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Mon, 10 Apr 2017 14:29:47 -0400 Subject: [PATCH 07/10] Add 'format' as a requested field. --- .../tests/lms/test_lms_course_home.py | 22 ---- lms/djangoapps/course_api/blocks/api.py | 8 +- .../blocks/transformers/__init__.py | 2 +- .../blocks/transformers/milestones.py | 104 ++++++++++-------- .../transformers/tests/test_milestones.py | 2 +- .../course-outline-fragment.html | 10 +- .../tests/views/test_course_outline.py | 24 +++- .../course_experience/views/course_outline.py | 2 +- 8 files changed, 88 insertions(+), 86 deletions(-) diff --git a/common/test/acceptance/tests/lms/test_lms_course_home.py b/common/test/acceptance/tests/lms/test_lms_course_home.py index 04f5a347f555..ba9f7cfa340d 100644 --- a/common/test/acceptance/tests/lms/test_lms_course_home.py +++ b/common/test/acceptance/tests/lms/test_lms_course_home.py @@ -134,16 +134,6 @@ def setUp(self): self.logout_page = LogoutPage(self.browser) - self.studio_course_outline = StudioCourseOutlinePage( - self.browser, - self.course_info['org'], - self.course_info['number'], - self.course_info['run'] - ) - - # adds graded assignments to course home for testing course outline a11y - self._set_policy_for_subsection("Homework", 0) - def test_course_home_a11y(self): """ Test the accessibility of the course home page with course outline. @@ -153,18 +143,6 @@ def test_course_home_a11y(self): course_home_page.visit() course_home_page.a11y_audit.check_for_accessibility_errors() - def _set_policy_for_subsection(self, policy, section=0): - """ - Set the grading policy for the first subsection in the specified section. - If a section index is not provided, 0 is assumed. - """ - with self._logged_in_session(staff=True): - self.studio_course_outline.visit() - modal = self.studio_course_outline.section_at(section).subsection_at( - 0).edit() - modal.policy = policy - modal.save() - @contextmanager def _logged_in_session(self, staff=False): """ diff --git a/lms/djangoapps/course_api/blocks/api.py b/lms/djangoapps/course_api/blocks/api.py index 64c62af8962a..234358d3a080 100644 --- a/lms/djangoapps/course_api/blocks/api.py +++ b/lms/djangoapps/course_api/blocks/api.py @@ -51,12 +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 + 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 - transformers += [MilestonesTransformer(can_view_special_exam), HiddenContentTransformer()] + transformers += [MilestonesTransformer(include_special_exams), HiddenContentTransformer()] transformers += [ BlocksAPITransformer( block_counts, diff --git a/lms/djangoapps/course_api/blocks/transformers/__init__.py b/lms/djangoapps/course_api/blocks/transformers/__init__.py index 5a7a21b4002f..9d34d0a7430a 100644 --- a/lms/djangoapps/course_api/blocks/transformers/__init__.py +++ b/lms/djangoapps/course_api/blocks/transformers/__init__.py @@ -45,7 +45,7 @@ def __init__( # 'student_view_multi_device' SupportedFieldType(StudentViewTransformer.STUDENT_VIEW_MULTI_DEVICE, StudentViewTransformer), - SupportedFieldType('special_exam', MilestonesTransformer), + SupportedFieldType('special_exam_info', 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 0f2ee9f01e4f..ad705637790e 100644 --- a/lms/djangoapps/course_api/blocks/transformers/milestones.py +++ b/lms/djangoapps/course_api/blocks/transformers/milestones.py @@ -19,7 +19,8 @@ class MilestonesTransformer(BlockStructureTransformer): """ - Excludes all special exams (timed, proctored, practice proctored) from the student view. + Adds special exams (timed, proctored, practice proctored) to the student view. + May exclude special exams. Excludes all blocks with unfulfilled milestones from the student view. """ WRITE_VERSION = 1 @@ -29,8 +30,8 @@ class MilestonesTransformer(BlockStructureTransformer): def name(cls): return "milestones" - def __init__(self, can_view_special_exams=True): - self.can_view_special_exams = can_view_special_exams + def __init__(self, include_special_exams=True): + self.include_special_exams = include_special_exams @classmethod def collect(cls, block_structure): @@ -51,48 +52,9 @@ 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 + course_key = block_structure.root_block_usage_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): """ @@ -103,12 +65,11 @@ def user_gated_from_block(block_key): 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 self.gated_by_required_content(block_key, block_structure, user_can_skip, 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)): + not self.include_special_exams)): return True return False @@ -116,7 +77,7 @@ def user_gated_from_block(block_key): if user_gated_from_block(block_key): block_structure.remove_block(block_key, False) else: - add_special_exam_info(block_key) + self.add_special_exam_info(block_key, block_structure, usage_info) @staticmethod def is_special_exam(block_key, block_structure): @@ -142,3 +103,50 @@ def has_pending_milestones_for_user(block_key, usage_info): 'requires', usage_info.user.id )) + + def add_special_exam_info(self, block_key, block_structure, usage_info): + """ + Adds special exam information to course blocks. + """ + if self.is_special_exam(block_key, block_structure): + + # call 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 = None + try: + 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: + # 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_info', + special_exam_attempt_context, + ) + + @staticmethod + def gated_by_required_content(block_key, block_structure, user_can_skip, 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 + exam_id = block_structure.get_xblock_field(block_structure.root_block_usage_key, 'entrance_exam_id') + if user_can_skip: + required_content = [content for content in required_content if not content == exam_id] + + 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 63a3816934b7..9955b5a4d00b 100644 --- a/lms/djangoapps/course_api/blocks/transformers/tests/test_milestones.py +++ b/lms/djangoapps/course_api/blocks/transformers/tests/test_milestones.py @@ -186,7 +186,7 @@ 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_can_view_special(self): + 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. 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 6640440c08bc..74929a1f2330 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 @@ -51,26 +51,26 @@ if subsection.get('due') is None: data_string = subsection.get('format') else: - if 'special_exam' in subsection: + if 'special_exam_info' in subsection: data_string = _('due {date}') else: data_string = _("{subsection_format} due {{date}}").format(subsection_format=subsection.get('format')) %> - % if subsection.get('format') or 'special_exam' in subsection: + % if subsection.get('format') or 'special_exam_info' in subsection: % if 'special_exam' in subsection: ## Display the proctored exam status icon and status message - ${subsection['special_exam'].get('short_description', '')} + ${subsection['special_exam_info'].get('short_description', '')} ## completed proctored exam statuses should not show the due date ## since the exam has already been submitted by the user - % if not subsection['special_exam'].get('in_completed_state', False): + % if not subsection['special_exam_info'].get('in_completed_state', False): Date: Tue, 11 Apr 2017 17:07:35 -0400 Subject: [PATCH 08/10] Clean up docs re: exam formats in outline html --- .../course_experience/course-outline-fragment.html | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 74929a1f2330..b908943aec1d 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 @@ -44,9 +44,9 @@
      ## There are behavior differences between rendering of subsections which have - ## special_exam/timed examinations and those that do not. + ## exams (timed, graded, etc) and those that do not. ## - ## Proctoring exposes a exam status message field as well as a status icon + ## Exam subsections expose exam status message field as well as a status icon <% if subsection.get('due') is None: data_string = subsection.get('format') @@ -59,7 +59,7 @@ % if subsection.get('format') or 'special_exam_info' in subsection: % if 'special_exam' in subsection: - ## Display the proctored exam status icon and status message + ## Display the exam status icon and status message