diff --git a/common/test/acceptance/fixtures/course.py b/common/test/acceptance/fixtures/course.py index 90521be680ad9c6c105ed4fbff33947c8b50c049..2ea327ea0bc53cb5969a3da812818b26e2d31023 100644 --- a/common/test/acceptance/fixtures/course.py +++ b/common/test/acceptance/fixtures/course.py @@ -222,6 +222,27 @@ class CourseFixture(XBlockContainerFixture): """ self._configure_course() + @property + def course_outline(self): + """ + Retrieves course outline in JSON format. + """ + url = STUDIO_BASE_URL + '/course/' + self._course_key + "?format=json" + response = self.session.get(url, headers=self.headers) + + if not response.ok: + raise FixtureError( + "Could not retrieve course outline json. Status was {0}".format( + response.status_code)) + + try: + course_outline_json = response.json() + except ValueError: + raise FixtureError( + "Could not decode course outline as JSON: '{0}'".format(response) + ) + return course_outline_json + @property def _course_location(self): """ diff --git a/common/test/acceptance/pages/lms/courseware.py b/common/test/acceptance/pages/lms/courseware.py index a2395397e849e1306e1b3fa47e107dd6ba9dcf3d..359eda9a4c64d635fe48d49baeecfa7429197f0a 100644 --- a/common/test/acceptance/pages/lms/courseware.py +++ b/common/test/acceptance/pages/lms/courseware.py @@ -127,6 +127,33 @@ class CoursewarePage(CoursePage): # Wait for the unique exam code to appear. # elf.wait_for_element_presence(".proctored-exam-code", "unique exam code") + @property + def entrance_exam_message_selector(self): + """ + Return the entrance exam status message selector on the top of courseware page. + """ + return self.q(css='#content .container section.course-content .sequential-status-message') + + def has_entrance_exam_message(self): + """ + Returns boolean indicating presence entrance exam status message container div. + """ + return self.entrance_exam_message_selector.is_present() + + def has_passed_message(self): + """ + Returns boolean indicating presence of passed message. + """ + return self.entrance_exam_message_selector.is_present() \ + and "You have passed the entrance exam" in self.entrance_exam_message_selector.text[0] + + @property + def chapter_count_in_navigation(self): + """ + Returns count of chapters available on LHS navigation. + """ + return len(self.q(css='nav.course-navigation a.chapter')) + @property def is_timer_bar_present(self): """ diff --git a/common/test/acceptance/pages/lms/problem.py b/common/test/acceptance/pages/lms/problem.py index 7b5f979fe3b0a412ca2331d72c66edc23eab4104..83476815aa04a69d439a9a08036da3716b7b36b3 100644 --- a/common/test/acceptance/pages/lms/problem.py +++ b/common/test/acceptance/pages/lms/problem.py @@ -84,6 +84,13 @@ class ProblemPage(PageObject): self.q(css='div.problem button.hint-button').click() self.wait_for_ajax() + def click_choice(self, choice_value): + """ + Click the choice input(radio, checkbox or option) where value matches `choice_value` in choice group. + """ + self.q(css='div.problem .choicegroup input[value="' + choice_value + '"]').click() + self.wait_for_ajax() + def is_correct(self): """ Is there a "correct" status showing? diff --git a/common/test/acceptance/tests/lms/test_lms_entrance_exams.py b/common/test/acceptance/tests/lms/test_lms_entrance_exams.py new file mode 100644 index 0000000000000000000000000000000000000000..b598e602678f91354893d1b3a55c5b4f2ae4cfb6 --- /dev/null +++ b/common/test/acceptance/tests/lms/test_lms_entrance_exams.py @@ -0,0 +1,105 @@ +# -*- coding: utf-8 -*- +""" +Bok choy acceptance tests for Entrance exams in the LMS +""" +from textwrap import dedent + +from ..helpers import UniqueCourseTest +from ...pages.studio.auto_auth import AutoAuthPage +from ...pages.lms.courseware import CoursewarePage +from ...pages.lms.problem import ProblemPage +from ...fixtures.course import CourseFixture, XBlockFixtureDesc + + +class EntranceExamTest(UniqueCourseTest): + """ + Base class for tests of Entrance Exams in the LMS. + """ + USERNAME = "joe_student" + EMAIL = "joe@example.com" + + def setUp(self): + super(EntranceExamTest, self).setUp() + + self.xqueue_grade_response = None + + self.courseware_page = CoursewarePage(self.browser, self.course_id) + + # Install a course with a hierarchy and problems + course_fixture = CourseFixture( + self.course_info['org'], self.course_info['number'], + self.course_info['run'], self.course_info['display_name'], + settings={ + 'entrance_exam_enabled': 'true', + 'entrance_exam_minimum_score_pct': '50' + } + ) + + problem = self.get_problem() + course_fixture.add_children( + XBlockFixtureDesc('chapter', 'Test Section').add_children( + XBlockFixtureDesc('sequential', 'Test Subsection').add_children(problem) + ) + ).install() + + entrance_exam_subsection = None + outline = course_fixture.course_outline + for child in outline['child_info']['children']: + if child.get('display_name') == "Entrance Exam": + entrance_exam_subsection = child['child_info']['children'][0] + + if entrance_exam_subsection: + course_fixture.create_xblock(entrance_exam_subsection['id'], problem) + + # Auto-auth register for the course. + AutoAuthPage(self.browser, username=self.USERNAME, email=self.EMAIL, + course_id=self.course_id, staff=False).visit() + + def get_problem(self): + """ Subclasses should override this to complete the fixture """ + raise NotImplementedError() + + +class EntranceExamPassTest(EntranceExamTest): + """ + Tests the scenario when a student passes entrance exam. + """ + + def get_problem(self): + """ + Create a multiple choice problem + """ + xml = dedent(""" + <problem> + <p>What is height of eiffel tower without the antenna?.</p> + <multiplechoiceresponse> + <choicegroup label="What is height of eiffel tower without the antenna?" type="MultipleChoice"> + <choice correct="false">324 meters<choicehint>Antenna is 24 meters high</choicehint></choice> + <choice correct="true">300 meters</choice> + <choice correct="false">224 meters</choice> + <choice correct="false">400 meters</choice> + </choicegroup> + </multiplechoiceresponse> + </problem> + """) + return XBlockFixtureDesc('problem', 'HEIGHT OF EIFFEL TOWER', data=xml) + + def test_course_is_unblocked_as_soon_as_student_passes_entrance_exam(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_check() + self.courseware_page.wait_for_page() + self.assertTrue(self.courseware_page.has_passed_message()) + self.assertEqual(self.courseware_page.chapter_count_in_navigation, 2) diff --git a/lms/djangoapps/courseware/entrance_exams.py b/lms/djangoapps/courseware/entrance_exams.py index 7a02196dc013cd758d80c50c82fe0ced8807e192..8c6f73c7e1e8bdef62c01c3d2ebf29d21365a877 100644 --- a/lms/djangoapps/courseware/entrance_exams.py +++ b/lms/djangoapps/courseware/entrance_exams.py @@ -6,6 +6,7 @@ from django.conf import settings from courseware.access import has_access from courseware.model_data import FieldDataCache, ScoresClient from opaque_keys.edx.keys import UsageKey +from opaque_keys.edx.locator import BlockUsageLocator from student.models import EntranceExamConfiguration from util.milestones_helpers import get_required_content from util.module_utils import yield_dynamic_descriptor_descendants @@ -89,14 +90,25 @@ def _calculate_entrance_exam_score(user, course_descriptor, exam_modules): """ student_module_dict = {} scores_client = ScoresClient(course_descriptor.id, user.id) - locations = [exam_module.location for exam_module in exam_modules] + # removing branch and version from exam modules locator + # otherwise student module would not return scores since module usage keys would not match + locations = [ + BlockUsageLocator( + course_key=course_descriptor.id, + block_type=exam_module.location.block_type, + block_id=exam_module.location.block_id + ) + if isinstance(exam_module.location, BlockUsageLocator) and exam_module.location.version + else exam_module.location + for exam_module in exam_modules + ] scores_client.fetch_scores(locations) # Iterate over all of the exam modules to get score of user for each of them - for exam_module in exam_modules: - exam_module_score = scores_client.get(exam_module.location) + for index, exam_module in enumerate(exam_modules): + exam_module_score = scores_client.get(locations[index]) if exam_module_score: - student_module_dict[unicode(exam_module.location)] = { + student_module_dict[unicode(locations[index])] = { 'grade': exam_module_score.correct, 'max_grade': exam_module_score.total } @@ -104,10 +116,10 @@ def _calculate_entrance_exam_score(user, course_descriptor, exam_modules): module_percentages = [] ignore_categories = ['course', 'chapter', 'sequential', 'vertical'] - for module in exam_modules: + for index, module in enumerate(exam_modules): if module.graded and module.category not in ignore_categories: module_percentage = 0 - module_location = unicode(module.location) + module_location = unicode(locations[index]) if module_location in student_module_dict and student_module_dict[module_location]['max_grade']: student_module = student_module_dict[module_location] module_percentage = student_module['grade'] / student_module['max_grade'] diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index 43b9866782b3b5d8db82ff70bdd07c95f6665fb7..4c169391e3fccad6f774337bb997f08774ea50ae 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -38,7 +38,8 @@ from courseware.model_data import DjangoKeyValueStore, FieldDataCache, set_score from courseware.models import SCORE_CHANGED from courseware.entrance_exams import ( get_entrance_exam_score, - user_must_complete_entrance_exam + user_must_complete_entrance_exam, + user_has_passed_entrance_exam ) from edxmako.shortcuts import render_to_string from eventtracking import tracker @@ -1062,6 +1063,12 @@ def _invoke_xblock_handler(request, course_id, usage_id, handler, suffix, course try: with tracker.get_tracker().context(tracking_context_name, tracking_context): resp = instance.handle(handler, req, suffix) + if suffix == 'problem_check' \ + and course \ + and getattr(course, 'entrance_exam_enabled', False) \ + and getattr(instance, 'in_entrance_exam', False): + ee_data = {'entrance_exam_passed': user_has_passed_entrance_exam(request, course)} + resp = append_data_to_webob_response(resp, ee_data) except NoSuchHandlerError: log.exception("XBlock %s attempted to access missing handler %r", instance, handler) @@ -1178,3 +1185,22 @@ def _check_files_limits(files): return msg return None + + +def append_data_to_webob_response(response, data): + """ + Appends data to a JSON webob response. + + Arguments: + response (webob response object): the webob response object that needs to be modified + data (dict): dictionary containing data that needs to be appended to response body + + Returns: + (webob response object): webob response with updated body. + + """ + if getattr(response, 'content_type', None) == 'application/json': + response_data = json.loads(response.body) + response_data.update(data) + response.body = json.dumps(response_data) + return response diff --git a/lms/djangoapps/courseware/tabs.py b/lms/djangoapps/courseware/tabs.py index 89cc13ca68ca228e196294ea89d987bf0f13507d..76a46d692c56034bae71c88be713a899ad166ff6 100644 --- a/lms/djangoapps/courseware/tabs.py +++ b/lms/djangoapps/courseware/tabs.py @@ -294,8 +294,9 @@ def get_course_tab_list(request, course): # If the user has to take an entrance exam, we'll need to hide away all but the # "Courseware" tab. The tab is then renamed as "Entrance Exam". course_tab_list = [] + must_complete_ee = user_must_complete_entrance_exam(request, user, course) for tab in xmodule_tab_list: - if user_must_complete_entrance_exam(request, user, course): + if must_complete_ee: # Hide all of the tabs except for 'Courseware' # Rename 'Courseware' tab to 'Entrance Exam' if tab.type is not 'courseware': diff --git a/lms/djangoapps/courseware/tests/test_entrance_exam.py b/lms/djangoapps/courseware/tests/test_entrance_exam.py index 3d9dd1e81013d8f072186cccd41903fb66ec405a..2e0b392e00b6e94966cc605130023cbdcf3344a4 100644 --- a/lms/djangoapps/courseware/tests/test_entrance_exam.py +++ b/lms/djangoapps/courseware/tests/test_entrance_exam.py @@ -4,10 +4,12 @@ Tests use cases related to LMS Entrance Exam behavior, such as gated content acc from mock import patch, Mock from django.core.urlresolvers import reverse +from django.test.client import RequestFactory from nose.plugins.attrib import attr +from capa.tests.response_xml_factory import MultipleChoiceResponseXMLFactory from courseware.model_data import FieldDataCache -from courseware.module_render import toc_for_course, get_module +from courseware.module_render import toc_for_course, get_module, handle_xblock_callback from courseware.tests.factories import UserFactory, InstructorFactory, StaffFactory from courseware.tests.helpers import ( LoginEnrollmentTestCase, @@ -115,10 +117,16 @@ class EntranceExamTestCases(LoginEnrollmentTestCase, ModuleStoreTestCase): category='vertical', display_name='Exam Vertical - Unit 1' ) + problem_xml = MultipleChoiceResponseXMLFactory().build_xml( + question_text='The correct answer is Choice 3', + choices=[False, False, True, False], + choice_names=['choice_0', 'choice_1', 'choice_2', 'choice_3'] + ) self.problem_1 = ItemFactory.create( parent=subsection, category="problem", - display_name="Exam Problem - Problem 1" + display_name="Exam Problem - Problem 1", + data=problem_xml ) self.problem_2 = ItemFactory.create( parent=subsection, @@ -533,6 +541,28 @@ class EntranceExamTestCases(LoginEnrollmentTestCase, ModuleStoreTestCase): ) self.assertTrue(user_has_passed_entrance_exam(self.request, course)) + @patch.dict("django.conf.settings.FEATURES", {'ENABLE_MASQUERADE': False}) + def test_entrance_exam_xblock_response(self): + """ + Tests entrance exam xblock has `entrance_exam_passed` key in json response. + """ + request_factory = RequestFactory() + data = {'input_{}_2_1'.format(unicode(self.problem_1.location.html_id())): 'choice_2'} + request = request_factory.post( + 'problem_check', + data=data + ) + request.user = self.user + response = handle_xblock_callback( + request, + unicode(self.course.id), + unicode(self.problem_1.location), + 'xmodule_handler', + 'problem_check', + ) + self.assertEqual(response.status_code, 200) + self.assertIn('entrance_exam_passed', response.content) + def _assert_chapter_loaded(self, course, chapter): """ Asserts courseware chapter load successfully. diff --git a/lms/templates/courseware/courseware.html b/lms/templates/courseware/courseware.html index 78d825dfdfb3b7e629b0aa2fee7b2967c749b350..ae48ca1a0bad565e744a5685339a23eaa1e93e3b 100644 --- a/lms/templates/courseware/courseware.html +++ b/lms/templates/courseware/courseware.html @@ -206,6 +206,16 @@ ${fragment.foot_html()} current_score=int(round(entrance_exam_current_score * 100)) )} </p> + <script type="text/javascript"> + $(document).ajaxSuccess(function(event, xhr, settings) { + if (settings.url.indexOf("xmodule_handler/problem_check") > -1) { + var data = JSON.parse(xhr.responseText); + if (data.entrance_exam_passed){ + location.reload(); + } + } + }); + </script> % else: <p class="sequential-status-message"> ${_('Your score is {current_score}%. You have passed the entrance exam.').format(