diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py index 81325cc9fa..a47837fe2c 100644 --- a/cms/djangoapps/contentstore/views/item.py +++ b/cms/djangoapps/contentstore/views/item.py @@ -106,6 +106,9 @@ def xblock_handler(request, usage_key_string): :children: the unicode representation of the UsageKeys of children for this xblock. :metadata: new values for the metadata fields. Any whose values are None will be deleted not set to None! Absent ones will be left alone. + :fields: any other xblock fields to be set. Only supported by update. + This is represented as a dictionary: + {'field_name': 'field_value'} :nullout: which metadata fields to set to None :graderType: change how this unit is graded :isPrereq: Set this xblock as a prerequisite which can be used to limit access to other xblocks @@ -169,6 +172,7 @@ def xblock_handler(request, usage_key_string): prereq_usage_key=request.json.get('prereqUsageKey'), prereq_min_score=request.json.get('prereqMinScore'), publish=request.json.get('publish'), + fields=request.json.get('fields'), ) elif request.method in ('PUT', 'POST'): if 'duplicate_source_locator' in request.json: @@ -431,11 +435,13 @@ def _update_with_callback(xblock, user, old_metadata=None, old_content=None): def _save_xblock(user, xblock, data=None, children_strings=None, metadata=None, nullout=None, - grader_type=None, is_prereq=None, prereq_usage_key=None, prereq_min_score=None, publish=None): + grader_type=None, is_prereq=None, prereq_usage_key=None, prereq_min_score=None, + publish=None, fields=None): """ Saves xblock w/ its fields. Has special processing for grader_type, publish, and nullout and Nones in metadata. nullout means to truly set the field to None whereas nones in metadata mean to unset them (so they revert to default). + """ store = modulestore() # Perform all xblock changes within a (single-versioned) transaction @@ -457,6 +463,10 @@ def _save_xblock(user, xblock, data=None, children_strings=None, metadata=None, else: data = old_content['data'] if 'data' in old_content else None + if fields: + for field_name in fields: + setattr(xblock, field_name, fields[field_name]) + if children_strings is not None: children = [] for child_string in children_strings: @@ -610,7 +620,7 @@ def _create_item(request): user=request.user, category=category, display_name=request.json.get('display_name'), - boilerplate=request.json.get('boilerplate') + boilerplate=request.json.get('boilerplate'), ) return JsonResponse( diff --git a/cms/djangoapps/contentstore/views/tests/test_item.py b/cms/djangoapps/contentstore/views/tests/test_item.py index 0e790b227a..1cb8a6f6b8 100644 --- a/cms/djangoapps/contentstore/views/tests/test_item.py +++ b/cms/djangoapps/contentstore/views/tests/test_item.py @@ -805,6 +805,22 @@ class TestEditItem(TestEditItemSetup): self.assertEqual(sequential.due, datetime(2010, 11, 22, 4, 0, tzinfo=UTC)) self.assertEqual(sequential.start, datetime(2010, 9, 12, 14, 0, tzinfo=UTC)) + def test_update_generic_fields(self): + new_display_name = 'New Display Name' + new_max_attempts = 2 + self.client.ajax_post( + self.problem_update_url, + data={ + 'fields': { + 'display_name': new_display_name, + 'max_attempts': new_max_attempts, + } + } + ) + problem = self.get_item_from_modulestore(self.problem_usage_key, verify_is_draft=True) + self.assertEqual(problem.display_name, new_display_name) + self.assertEqual(problem.max_attempts, new_max_attempts) + def test_delete_child(self): """ Test deleting a child. diff --git a/common/test/acceptance/fixtures/course.py b/common/test/acceptance/fixtures/course.py index f48fe74ea4..53c1974b2f 100644 --- a/common/test/acceptance/fixtures/course.py +++ b/common/test/acceptance/fixtures/course.py @@ -22,7 +22,8 @@ class XBlockFixtureDesc(object): Description of an XBlock, used to configure a course fixture. """ - def __init__(self, category, display_name, data=None, metadata=None, grader_type=None, publish='make_public'): + def __init__(self, category, display_name, data=None, + metadata=None, grader_type=None, publish='make_public', **kwargs): """ Configure the XBlock to be created by the fixture. These arguments have the same meaning as in the Studio REST API: @@ -41,6 +42,7 @@ class XBlockFixtureDesc(object): self.publish = publish self.children = [] self.locator = None + self.fields = kwargs def add_children(self, *args): """ @@ -59,13 +61,15 @@ class XBlockFixtureDesc(object): XBlocks are always set to public visibility. """ - return json.dumps({ + returned_data = { 'display_name': self.display_name, 'data': self.data, 'metadata': self.metadata, 'graderType': self.grader_type, - 'publish': self.publish - }) + 'publish': self.publish, + 'fields': self.fields, + } + return json.dumps(returned_data) def __str__(self): """ @@ -354,7 +358,7 @@ class CourseFixture(XBlockContainerFixture): 'children': None, 'data': handouts_html, 'id': self._handouts_loc, - 'metadata': dict() + 'metadata': dict(), }) response = self.session.post(url, data=payload, headers=self.headers) diff --git a/common/test/acceptance/pages/lms/conditional.py b/common/test/acceptance/pages/lms/conditional.py new file mode 100644 index 0000000000..139c9b7486 --- /dev/null +++ b/common/test/acceptance/pages/lms/conditional.py @@ -0,0 +1,42 @@ +""" +Conditional Pages +""" +from bok_choy.page_object import PageObject + +POLL_ANSWER = 'Yes, of course' + + +class ConditionalPage(PageObject): + """ + View of conditional page. + """ + + url = None + + def is_browser_on_page(self): + """ + Returns True if the browser is currently on the right page. + """ + return self.q(css='.conditional-wrapper').visible + + def is_content_visible(self): + """ + Returns True if the conditional's content has been revealed, + False otherwise + """ + return self.q(css='.hidden-contents').visible + + def fill_in_poll(self): + """ + Fills in a poll on the same page as the conditional + with the answer that matches POLL_ANSWER + """ + text_selector = '.poll_answer .text' + + text_options = self.q(css=text_selector).text + + # Out of the possible poll answers, we want + # to select the one that matches POLL_ANSWER and click it. + for idx, text in enumerate(text_options): + if text == POLL_ANSWER: + self.q(css=text_selector).nth(idx).click() diff --git a/common/test/acceptance/tests/lms/test_conditional.py b/common/test/acceptance/tests/lms/test_conditional.py new file mode 100644 index 0000000000..7d02665248 --- /dev/null +++ b/common/test/acceptance/tests/lms/test_conditional.py @@ -0,0 +1,127 @@ +""" +Bok choy acceptance tests for conditionals in the LMS +""" +from capa.tests.response_xml_factory import StringResponseXMLFactory +from ..helpers import UniqueCourseTest +from ...fixtures.course import CourseFixture, XBlockFixtureDesc +from ...pages.lms.courseware import CoursewarePage +from ...pages.lms.conditional import ConditionalPage, POLL_ANSWER +from ...pages.lms.problem import ProblemPage +from ...pages.studio.auto_auth import AutoAuthPage + + +class ConditionalTest(UniqueCourseTest): + """ + Test the conditional module in the lms. + """ + + def setUp(self): + super(ConditionalTest, self).setUp() + + self.courseware_page = CoursewarePage(self.browser, self.course_id) + AutoAuthPage( + self.browser, + course_id=self.course_id, + staff=False + ).visit() + + def install_course_fixture(self, block_type='problem'): + """ + Install a course fixture + """ + course_fixture = CourseFixture( + self.course_info['org'], + self.course_info['number'], + self.course_info['run'], + self.course_info['display_name'], + ) + vertical = XBlockFixtureDesc('vertical', 'Test Unit') + # populate the course fixture with the right conditional modules + course_fixture.add_children( + XBlockFixtureDesc('chapter', 'Test Section').add_children( + XBlockFixtureDesc('sequential', 'Test Subsection').add_children( + vertical + ) + ) + ) + course_fixture.install() + + # Construct conditional block + conditional_metadata = {} + source_block = None + if block_type == 'problem': + problem_factory = StringResponseXMLFactory() + problem_xml = problem_factory.build_xml( + question_text='The answer is "correct string"', + case_sensitive=False, + answer='correct string', + ), + problem = XBlockFixtureDesc('problem', 'Test Problem', data=problem_xml[0]) + conditional_metadata = { + 'xml_attributes': { + 'attempted': 'True' + } + } + source_block = problem + elif block_type == 'poll': + poll = XBlockFixtureDesc( + 'poll_question', + 'Conditional Poll', + question='Is this a good poll?', + answers=[ + {'id': 'yes', 'text': POLL_ANSWER}, + {'id': 'no', 'text': 'Of course not!'} + ], + ) + conditional_metadata = { + 'xml_attributes': { + 'poll_answer': 'yes' + } + } + source_block = poll + else: + raise NotImplementedError() + + course_fixture.create_xblock(vertical.locator, source_block) + # create conditional + conditional = XBlockFixtureDesc( + 'conditional', + 'Test Conditional', + metadata=conditional_metadata, + sources_list=[source_block.locator], + ) + result_block = XBlockFixtureDesc( + 'html', 'Conditional Contents', + data='
Hidden Contents

' + ) + course_fixture.create_xblock(vertical.locator, conditional) + course_fixture.create_xblock(conditional.locator, result_block) + + def test_conditional_hides_content(self): + self.install_course_fixture() + self.courseware_page.visit() + conditional_page = ConditionalPage(self.browser) + self.assertFalse(conditional_page.is_content_visible()) + + def test_conditional_displays_content(self): + self.install_course_fixture() + self.courseware_page.visit() + # Answer the problem + problem_page = ProblemPage(self.browser) + problem_page.fill_answer('correct string') + problem_page.click_check() + # The conditional does not update on its own, so we need to reload the page. + self.courseware_page.visit() + # Verify that we can see the content. + conditional_page = ConditionalPage(self.browser) + self.assertTrue(conditional_page.is_content_visible()) + + def test_conditional_handles_polls(self): + self.install_course_fixture(block_type='poll') + self.courseware_page.visit() + # Fill in the conditional page poll + conditional_page = ConditionalPage(self.browser) + conditional_page.fill_in_poll() + # The conditional does not update on its own, so we need to reload the page. + self.courseware_page.visit() + self.assertTrue(conditional_page.is_content_visible()) diff --git a/lms/djangoapps/courseware/features/conditional.feature b/lms/djangoapps/courseware/features/conditional.feature deleted file mode 100644 index 65080f76be..0000000000 --- a/lms/djangoapps/courseware/features/conditional.feature +++ /dev/null @@ -1,22 +0,0 @@ -@shard_2 -Feature: LMS.Conditional Module - As a student, I want to view a Conditional component in the LMS - - Scenario: A Conditional hides content when conditions aren't satisfied - Given that a course has a Conditional conditioned on problem attempted=True - And that the conditioned problem has not been attempted - When I view the conditional - Then the conditional contents are hidden - - Scenario: A Conditional shows content when conditions are satisfied - Given that a course has a Conditional conditioned on problem attempted=True - And that the conditioned problem has been attempted - When I view the conditional - Then the conditional contents are visible - - Scenario: A Conditional containing a Poll is updated when the poll is answered - Given that a course has a Conditional conditioned on poll poll_answer=yes - When I view the conditional - Then the conditional contents are hidden - When I answer the conditioned poll "yes" - Then the conditional contents are visible diff --git a/lms/djangoapps/courseware/features/conditional.py b/lms/djangoapps/courseware/features/conditional.py deleted file mode 100644 index d54c652db4..0000000000 --- a/lms/djangoapps/courseware/features/conditional.py +++ /dev/null @@ -1,126 +0,0 @@ -# pylint: disable=missing-docstring - -from lettuce import world, steps -from nose.tools import assert_in, assert_true - -from common import i_am_registered_for_the_course, visit_scenario_item -from problems_setup import add_problem_to_course, answer_problem - - -@steps -class ConditionalSteps(object): - COURSE_NUM = 'test_course' - - def setup_conditional(self, step, condition_type, condition, cond_value): - r'that a course has a Conditional conditioned on (?P\w+) (?P\w+)=(?P\w+)$' - - i_am_registered_for_the_course(step, self.COURSE_NUM) - - world.scenario_dict['VERTICAL'] = world.ItemFactory( - parent_location=world.scenario_dict['SECTION'].location, - category='vertical', - display_name="Test Vertical", - ) - - world.scenario_dict['WRAPPER'] = world.ItemFactory( - parent_location=world.scenario_dict['VERTICAL'].location, - category='wrapper', - display_name="Test Poll Wrapper" - ) - - if condition_type == 'problem': - world.scenario_dict['CONDITION_SOURCE'] = add_problem_to_course(self.COURSE_NUM, 'string') - elif condition_type == 'poll': - world.scenario_dict['CONDITION_SOURCE'] = world.ItemFactory( - parent_location=world.scenario_dict['WRAPPER'].location, - category='poll_question', - display_name='Conditional Poll', - data={ - 'question': 'Is this a good poll?', - 'answers': [ - {'id': 'yes', 'text': 'Yes, of course'}, - {'id': 'no', 'text': 'Of course not!'} - ], - } - ) - else: - raise Exception("Unknown condition type: {!r}".format(condition_type)) - - metadata = { - 'xml_attributes': { - condition: cond_value - } - } - - world.scenario_dict['CONDITIONAL'] = world.ItemFactory( - parent_location=world.scenario_dict['WRAPPER'].location, - category='conditional', - display_name="Test Conditional", - metadata=metadata, - sources_list=[world.scenario_dict['CONDITION_SOURCE'].location], - ) - - world.ItemFactory( - parent_location=world.scenario_dict['CONDITIONAL'].location, - category='html', - display_name='Conditional Contents', - data='
Hidden Contents

' - ) - - def setup_problem_attempts(self, step, not_attempted=None): - r'that the conditioned problem has (?Pnot )?been attempted$' - visit_scenario_item('CONDITION_SOURCE') - - if not_attempted is None: - answer_problem(self.COURSE_NUM, 'string', True) - world.css_click("button.check") - - def when_i_view_the_conditional(self, step): - r'I view the conditional$' - visit_scenario_item('CONDITIONAL') - world.wait_for_js_variable_truthy('$(".xblock-student_view[data-type=Conditional]").data("initialized")') - - def check_visibility(self, step, visible): - r'the conditional contents are (?P\w+)$' - world.wait_for_ajax_complete() - - assert_in(visible, ('visible', 'hidden')) - - if visible == 'visible': - world.wait_for_visible('.hidden-contents') - assert_true(world.css_visible('.hidden-contents')) - else: - assert_true(world.is_css_not_present('.hidden-contents')) - assert_true( - world.css_contains_text( - '.conditional-message', - 'must be attempted before this will become visible.' - ) - ) - - def answer_poll(self, step, answer): - r' I answer the conditioned poll "([^"]*)"$' - visit_scenario_item('CONDITION_SOURCE') - world.wait_for_js_variable_truthy('$(".xblock-student_view[data-type=Poll]").data("initialized")') - world.wait_for_ajax_complete() - - answer_text = [ - poll_answer['text'] - for poll_answer - in world.scenario_dict['CONDITION_SOURCE'].answers - if poll_answer['id'] == answer - ][0] - - text_selector = '.poll_answer .text' - - poll_texts = world.retry_on_exception( - lambda: [elem.text for elem in world.css_find(text_selector)] - ) - - for idx, poll_text in enumerate(poll_texts): - if poll_text == answer_text: - world.css_click(text_selector, index=idx) - return - - -ConditionalSteps()