From 2d1ff75ba0d4d135c257505653b73f213dfcd94f Mon Sep 17 00:00:00 2001 From: Zia Fazal Date: Fri, 9 Oct 2015 20:00:30 +0500 Subject: [PATCH] =?UTF-8?q?added=20user=E2=80=99s=20pass=20status=20to=20p?= =?UTF-8?q?roblem=5Fcheck=20response=20if=20problem=20is=20in=20Entrance?= =?UTF-8?q?=20Exam.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit unit test to check presence of entrance_exam_passed changed based on Matt's feedback changes after Asad's feedback --- common/test/acceptance/fixtures/course.py | 21 ++++ .../test/acceptance/pages/lms/courseware.py | 27 +++++ common/test/acceptance/pages/lms/problem.py | 7 ++ .../tests/lms/test_lms_entrance_exams.py | 105 ++++++++++++++++++ lms/djangoapps/courseware/entrance_exams.py | 24 +++- lms/djangoapps/courseware/module_render.py | 28 ++++- lms/djangoapps/courseware/tabs.py | 3 +- .../courseware/tests/test_entrance_exam.py | 34 +++++- lms/templates/courseware/courseware.html | 10 ++ 9 files changed, 249 insertions(+), 10 deletions(-) create mode 100644 common/test/acceptance/tests/lms/test_lms_entrance_exams.py diff --git a/common/test/acceptance/fixtures/course.py b/common/test/acceptance/fixtures/course.py index 90521be680..2ea327ea0b 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 a2395397e8..359eda9a4c 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 7b5f979fe3..83476815aa 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 0000000000..b598e60267 --- /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(""" + +

What is height of eiffel tower without the antenna?.

+ + + 324 metersAntenna is 24 meters high + 300 meters + 224 meters + 400 meters + + +
+ """) + 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 7a02196dc0..8c6f73c7e1 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 43b9866782..4c169391e3 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 89cc13ca68..76a46d692c 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 ae1fd8622a..7ee1f10b54 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, @@ -511,6 +519,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 bb6f91885e..775c230546 100644 --- a/lms/templates/courseware/courseware.html +++ b/lms/templates/courseware/courseware.html @@ -206,6 +206,16 @@ ${fragment.foot_html()} current_score=int(entrance_exam_current_score * 100) )}

+ % else:

${_('Your score is {current_score}%. You have passed the entrance exam.').format(