diff --git a/.travis.yml b/.travis.yml index 6c0587c4..dbb65671 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,12 +5,13 @@ before_install: - "export DISPLAY=:99" - "sh -e /etc/init.d/xvfb start" install: - - "pip install -e git://github.com/edx/xblock-sdk.git#egg=xblock-sdk" + - "pip install -e git://github.com/edx/xblock-sdk.git@22c1b2f173919bef22f2d9d9295ec5396d02dffd#egg=xblock-sdk" - "pip install -r requirements.txt" - - "pip install -r $VIRTUAL_ENV/src/xblock-sdk/requirements.txt" - - "pip install -r $VIRTUAL_ENV/src/xblock-sdk/test-requirements.txt" + - "pip install -r $VIRTUAL_ENV/src/xblock-sdk/requirements/base.txt" + - "pip install -r $VIRTUAL_ENV/src/xblock-sdk/requirements/test.txt" - "pip uninstall -y xblock-problem-builder && python setup.py sdist && pip install dist/xblock-problem-builder-2.0.tar.gz" - "pip install -r test_requirements.txt" + - "mkdir var" script: - pep8 problem_builder --max-line-length=120 - pylint problem_builder --disable=all --enable=function-redefined,undefined-variable,unused-variable diff --git a/problem_builder/answer.py b/problem_builder/answer.py index 68ddf973..d2e15d9c 100644 --- a/problem_builder/answer.py +++ b/problem_builder/answer.py @@ -152,13 +152,6 @@ class AnswerBlock(SubmittingXBlockMixin, AnswerMixin, QuestionMixin, StudioEdita default="", multiline_editor=True, ) - weight = Float( - display_name=_("Weight"), - help=_("Defines the maximum total grade of the answer block."), - default=1, - scope=Scope.settings, - enforce_type=True - ) editable_fields = ('question', 'name', 'min_characters', 'weight', 'default_from', 'display_name', 'show_title') diff --git a/problem_builder/mixins.py b/problem_builder/mixins.py index 3513638a..cdcafd85 100644 --- a/problem_builder/mixins.py +++ b/problem_builder/mixins.py @@ -1,5 +1,5 @@ from lazy import lazy -from xblock.fields import String, Boolean, Scope +from xblock.fields import String, Boolean, Float, Scope, UNIQUE_ID from xblockutils.helpers import child_isinstance from xblockutils.resources import ResourceLoader @@ -125,12 +125,25 @@ class QuestionMixin(EnumerableChildMixin): has_author_view = True # Fields: + name = String( + display_name=_("Question ID (name)"), + help=_("The ID of this question (required). Should be unique within this mentoring component."), + default=UNIQUE_ID, + scope=Scope.settings, # Must be scope.settings, or the unique ID will change every time this block is edited + ) display_name = String( display_name=_("Question title"), help=_('Leave blank to use the default ("Question 1", "Question 2", etc.)'), default="", # Blank will use 'Question x' - see display_name_with_default scope=Scope.content ) + weight = Float( + display_name=_("Weight"), + help=_("Defines the maximum total grade of this question."), + default=1, + scope=Scope.content, + enforce_type=True + ) @lazy def siblings(self): diff --git a/problem_builder/public/css/problem-builder-edit.css b/problem_builder/public/css/problem-builder-edit.css index 6d6c06a6..c1a0b3a3 100644 --- a/problem_builder/public/css/problem-builder-edit.css +++ b/problem_builder/public/css/problem-builder-edit.css @@ -49,3 +49,7 @@ margin-top: 1em; padding-top: 0.3em; } + +.xblock-author_view-pb-slider .url-name-footer { + margin: 0 -20px -20px -20px; /* Counteract spacing from xblock-render wrapper. */ +} diff --git a/problem_builder/public/css/problem-builder.css b/problem_builder/public/css/problem-builder.css index 41decf07..8417767a 100644 --- a/problem_builder/public/css/problem-builder.css +++ b/problem_builder/public/css/problem-builder.css @@ -64,6 +64,32 @@ margin-bottom: 0; } +.mentoring .xblock-pb-slider p label { + font-size: inherit; +} + +.mentoring .pb-slider-box { + max-width: 400px; +} + +.mentoring .pb-slider-range { + width: 100%; +} + +.mentoring .pb-slider-min-label { + float: left; +} + +.mentoring .pb-slider-max-label { + float: right; +} + +.mentoring .clearfix::after { + clear: both; + display: block; + content: " "; +} + .mentoring .attempts { margin-left: 10px; display: inline-block; diff --git a/problem_builder/public/js/mentoring_standard_view.js b/problem_builder/public/js/mentoring_standard_view.js index 7357d5ab..a484cb63 100644 --- a/problem_builder/public/js/mentoring_standard_view.js +++ b/problem_builder/public/js/mentoring_standard_view.js @@ -7,6 +7,7 @@ function MentoringStandardView(runtime, element, mentoring) { function handleSubmitResults(response, disable_submit) { messagesDOM.empty().hide(); + var all_have_results = response.results.length > 0; $.each(response.results || [], function(index, result_spec) { var input = result_spec[0]; var result = result_spec[1]; @@ -16,6 +17,7 @@ function MentoringStandardView(runtime, element, mentoring) { num_attempts: response.num_attempts }; callIfExists(child, 'handleSubmit', result, options); + all_have_results = all_have_results && !$.isEmptyObject(result); }); $('.attempts', element).data('max_attempts', response.max_attempts); @@ -29,10 +31,10 @@ function MentoringStandardView(runtime, element, mentoring) { messagesDOM.show(); } - // this method is called on successful submission and on page load - // results will be empty only for initial load if no submissions was made - // in such case we must allow submission to support submitting empty read-only long answer recaps - if (disable_submit || response.results.length > 0) { + // Disable the submit button if we have just submitted new answers, + // or if we have just [re]loaded the page and are showing a complete set + // of old answers. + if (disable_submit || all_have_results) { submitDOM.attr('disabled', 'disabled'); } } diff --git a/problem_builder/public/js/slider.js b/problem_builder/public/js/slider.js new file mode 100644 index 00000000..ea1d46fe --- /dev/null +++ b/problem_builder/public/js/slider.js @@ -0,0 +1,41 @@ +function SliderBlock(runtime, element) { + var $slider = $('.pb-slider-range', element); + return { + mode: null, + mentoring: null, + + value: function() { + return parseInt($slider.val()); + }, + + init: function(options) { + this.mentoring = options.mentoring; + this.mode = options.mode; + $slider.on('change', options.onChange); + }, + + submit: function() { + return this.value() / 100.0; + }, + + handleReview: function(result){ + $slider.val(result.submission * 100.0); + $slider.prop('disabled', true); + }, + + handleSubmit: function(result) { + // Show a green check if the user has submitted a valid value: + if (typeof result.submission !== "undefined") { + $('.submit-result', element).css('visibility', 'visible'); + } + }, + + clearResult: function() { + $('.submit-result', element).css('visibility', 'hidden'); + }, + + validate: function(){ + return Boolean(this.value() >= 0 && this.value() <= 100); + } + }; +} diff --git a/problem_builder/questionnaire.py b/problem_builder/questionnaire.py index 7e846809..301da80f 100644 --- a/problem_builder/questionnaire.py +++ b/problem_builder/questionnaire.py @@ -24,7 +24,7 @@ from lazy import lazy import uuid from xblock.core import XBlock -from xblock.fields import Scope, String, Float, UNIQUE_ID +from xblock.fields import Scope, String from xblock.fragment import Fragment from xblock.validation import ValidationMessage from xblockutils.helpers import child_isinstance @@ -61,13 +61,6 @@ class QuestionnaireAbstractBlock( values entered by the student, and supports multiple types of multiple-choice set, with preset choices and author-defined values. """ - name = String( - # This doesn't need to be a field but is kept for backwards compatibility with v1 student data - display_name=_("Question ID (name)"), - help=_("The ID of this question (required). Should be unique within this mentoring component."), - default=UNIQUE_ID, - scope=Scope.settings, # Must be scope.settings, or the unique ID will change every time this block is edited - ) question = String( display_name=_("Question"), help=_("Question to ask the student"), @@ -81,13 +74,6 @@ class QuestionnaireAbstractBlock( scope=Scope.content, default="" ) - weight = Float( - display_name=_("Weight"), - help=_("Defines the maximum total grade of this question."), - default=1, - scope=Scope.content, - enforce_type=True - ) editable_fields = ('question', 'message', 'weight', 'display_name', 'show_title') has_children = True answerable = True diff --git a/problem_builder/slider.py b/problem_builder/slider.py new file mode 100644 index 00000000..4097d768 --- /dev/null +++ b/problem_builder/slider.py @@ -0,0 +1,157 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2014-2015 Harvard, edX & OpenCraft +# +# This software's license gives you freedom; you can copy, convey, +# propagate, redistribute and/or modify this program under the terms of +# the GNU Affero General Public License (AGPL) as published by the Free +# Software Foundation (FSF), either version 3 of the License, or (at your +# option) any later version of the AGPL published by the FSF. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero +# General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program in a file in the toplevel directory called +# "AGPLv3". If not, see . +# + +# Imports ########################################################### + +import logging +import uuid + +from xblock.core import XBlock +from xblock.fields import Scope, String, Float +from xblock.fragment import Fragment +from xblockutils.studio_editable import StudioEditableXBlockMixin +from xblockutils.resources import ResourceLoader + +from .mixins import QuestionMixin, XBlockWithTranslationServiceMixin +from .sub_api import sub_api, SubmittingXBlockMixin + + +# Globals ########################################################### + +log = logging.getLogger(__name__) +loader = ResourceLoader(__name__) + + +# Make '_' a no-op so we can scrape strings +def _(text): + return text + +# Classes ########################################################### + + +@XBlock.needs("i18n") +class SliderBlock( + SubmittingXBlockMixin, QuestionMixin, StudioEditableXBlockMixin, XBlockWithTranslationServiceMixin, XBlock, +): + """ + An XBlock used by students to indicate a numeric value on a sliding scale. + The student's answer is always considered "correct". + """ + CATEGORY = 'pb-slider' + STUDIO_LABEL = _(u"Ranged Value Slider") + answerable = True + + min_label = String( + display_name=_("Low"), + help=_("Label for low end of the range"), + scope=Scope.content, + default=_("0%"), + ) + max_label = String( + display_name=_("High"), + help=_("Label for high end of the range"), + scope=Scope.content, + default=_("100%"), + ) + + question = String( + display_name=_("Question"), + help=_("Question to ask the student (optional)"), + scope=Scope.content, + default="", + multiline_editor=True, + ) + + student_value = Float( + # The value selected by the student + default=None, + scope=Scope.user_state, + ) + + editable_fields = ('min_label', 'max_label', 'display_name', 'question', 'show_title') + + def mentoring_view(self, context): + """ Main view of this block """ + context = context.copy() if context else {} + context['question'] = self.question + context['slider_id'] = 'pb-slider-{}'.format(uuid.uuid4().hex[:20]) + context['initial_value'] = int(self.student_value*100) if self.student_value is not None else 50 + context['min_label'] = self.min_label + context['max_label'] = self.max_label + context['title'] = self.display_name_with_default + context['hide_header'] = context.get('hide_header', False) or not self.show_title + context['instructions_string'] = self._("Select a value from {min_label} to {max_label}").format( + min_label=self.min_label, max_label=self.max_label + ) + html = loader.render_template('templates/html/slider.html', context) + + fragment = Fragment(html) + fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/slider.js')) + fragment.initialize_js('SliderBlock') + return fragment + + student_view = mentoring_view + preview_view = mentoring_view + + def author_view(self, context): + """ + Add some HTML to the author view that allows authors to see the ID of the block, so they + can refer to it in other blocks such as Plot blocks. + """ + context['hide_header'] = True # Header is already shown in the Studio wrapper + fragment = self.student_view(context) + fragment.add_content(loader.render_template('templates/html/slider_edit_footer.html', { + "url_name": self.url_name + })) + return fragment + + def get_last_result(self): + """ Return the current/last result in the required format """ + if self.student_value is None: + return {} + return { + 'submission': self.student_value, + 'status': 'correct', + 'tips': [], + 'weight': self.weight, + 'score': 1, + } + + def get_results(self, _previous_result_unused=None): + """ Alias for get_last_result() """ + return self.get_last_result() + + def submit(self, value): + log.debug(u'Received Slider submission: "%s"', value) + if value < 0 or value > 1: + return {} # Invalid + self.student_value = value + if sub_api: + # Also send to the submissions API: + sub_api.create_submission(self.student_item_key, {'value': value}) + result = self.get_last_result() + log.debug(u'Slider submission result: %s', result) + return result + + def validate_field_data(self, validation, data): + """ + Validate this block's field data. + """ + super(SliderBlock, self).validate_field_data(validation, data) diff --git a/problem_builder/step.py b/problem_builder/step.py index 2386b9d0..9372f63b 100644 --- a/problem_builder/step.py +++ b/problem_builder/step.py @@ -38,6 +38,7 @@ from problem_builder.mixins import EnumerableChildMixin, MessageParentMixin, StepParentMixin from problem_builder.mrq import MRQBlock from problem_builder.plot import PlotBlock +from problem_builder.slider import SliderBlock from problem_builder.table import MentoringTableBlock @@ -147,7 +148,7 @@ def allowed_nested_blocks(self): return [ NestedXBlockSpec(AnswerBlock, boilerplate='studio_default'), MCQBlock, RatingBlock, MRQBlock, HtmlBlockShim, - AnswerRecapBlock, MentoringTableBlock, PlotBlock + AnswerRecapBlock, MentoringTableBlock, PlotBlock, SliderBlock ] + additional_blocks @property diff --git a/problem_builder/templates/html/mentoring_add_buttons.html b/problem_builder/templates/html/mentoring_add_buttons.html index da36bf18..d03c7452 100644 --- a/problem_builder/templates/html/mentoring_add_buttons.html +++ b/problem_builder/templates/html/mentoring_add_buttons.html @@ -8,6 +8,7 @@
{% trans "Add New Component" %}
  • {% trans "Multiple Choice Question" %}
  • {% trans "Rating Question" %}
  • {% trans "Multiple Response Question" %}
  • +
  • {% trans "Ranged Value Slider" %}
  • {% trans "HTML" %}
  • {% trans "Long Answer Recap" %}
  • {% trans "Answer Recap Table" %}
  • diff --git a/problem_builder/templates/html/slider.html b/problem_builder/templates/html/slider.html new file mode 100644 index 00000000..09bb7c55 --- /dev/null +++ b/problem_builder/templates/html/slider.html @@ -0,0 +1,17 @@ +
    + {% if not hide_header %}

    {{ title }}

    {% endif %} + {% if question %} +

    + {% endif %} +
    + + + +
    +
    + +
    +
    diff --git a/problem_builder/templates/html/slider_edit_footer.html b/problem_builder/templates/html/slider_edit_footer.html new file mode 100644 index 00000000..1bb013e0 --- /dev/null +++ b/problem_builder/templates/html/slider_edit_footer.html @@ -0,0 +1,5 @@ +{% load i18n %} + diff --git a/problem_builder/tests/integration/test_slider.py b/problem_builder/tests/integration/test_slider.py new file mode 100644 index 00000000..148026d6 --- /dev/null +++ b/problem_builder/tests/integration/test_slider.py @@ -0,0 +1,146 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2014-2015 Harvard, edX & OpenCraft +# +# This software's license gives you freedom; you can copy, convey, +# propagate, redistribute and/or modify this program under the terms of +# the GNU Affero General Public License (AGPL) as published by the Free +# Software Foundation (FSF), either version 3 of the License, or (at your +# option) any later version of the AGPL published by the FSF. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero +# General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program in a file in the toplevel directory called +# "AGPLv3". If not, see . +# + +# Imports ########################################################### + +from .base_test import ProblemBuilderBaseTest, MentoringAssessmentBaseTest, GetChoices + + +# Classes ########################################################### + + +class SliderBlockTestMixins(object): + """ Mixins for testing slider blocks. Assumes only one slider block is on the page. """ + + def get_slider_value(self): + return int(self.browser.execute_script("return $('.pb-slider-range').val()")) + + def set_slider_value(self, val): + self.browser.execute_script("$('.pb-slider-range').val(arguments[0]).change()", val) + + +class SliderBlockTest(SliderBlockTestMixins, ProblemBuilderBaseTest): + """ + Tests for the SliderBlock inside a normal Problem Builder block. + """ + def test_simple_flow(self): + """ Test a regular Problem Builder block containing one slider """ + pb_wrapper = self.load_scenario("slider_problem.xml", {"include_mcq": False}) + self.wait_for_init() + # The initial value should be 50 and submit should be enabled since 50 is a valid value: + self.assertTrue(self.submit_button.is_enabled()) + self.assertEqual(self.get_slider_value(), 50) + self.expect_checkmark_visible(False) + # Set the value to 75: + self.set_slider_value(75) + self.assertEqual(self.get_slider_value(), 75) + self.click_submit(pb_wrapper) + # Now, we expect submit to be disabled and the checkmark to be visible: + self.expect_checkmark_visible(True) + self.assertFalse(self.submit_button.is_enabled()) + # Now change the value, and the button/checkmark should reset: + self.set_slider_value(45) + self.assertTrue(self.submit_button.is_enabled()) + self.expect_checkmark_visible(False) + # Now reload the page: + self.browser.execute_script("$(document).html(' ');") + pb_wrapper = self.go_to_view("student_view") + self.wait_for_init() + # Now the initial value should be 75 and submit should be disabled (to discourage submitting the same answer): + self.assertEqual(self.get_slider_value(), 75) + self.assertFalse(self.submit_button.is_enabled()) + self.expect_checkmark_visible(True) + + def test_simple_flow_with_peer(self): + """ Test a regular Problem Builder block containing one slider and an MCQ """ + pb_wrapper = self.load_scenario("slider_problem.xml", {"include_mcq": True}) + self.wait_for_init() + # The initial value should be 50 and submit should be disabled until an MCQ choice is selected + self.assertEqual(self.get_slider_value(), 50) + self.assertFalse(self.submit_button.is_enabled()) + self.expect_checkmark_visible(False) + # Set the value to 15: + self.set_slider_value(15) + self.assertEqual(self.get_slider_value(), 15) + self.assertFalse(self.submit_button.is_enabled()) + # Choose a choice: + GetChoices(pb_wrapper).select('Yes') + self.assertTrue(self.submit_button.is_enabled()) + self.click_submit(pb_wrapper) + # Now, we expect submit to be disabled and the checkmark to be visible: + self.expect_checkmark_visible(True) + self.assertFalse(self.submit_button.is_enabled()) + # Now change the value, and the button/checkmark should reset: + self.set_slider_value(20) + self.assertTrue(self.submit_button.is_enabled()) + self.expect_checkmark_visible(False) + + def wait_for_init(self): + """ Wait for the scenario to initialize """ + self.wait_until_hidden(self.browser.find_element_by_css_selector('.messages')) + + @property + def submit_button(self): + return self.browser.find_element_by_css_selector('.submit input.input-main') + + def expect_checkmark_visible(self, visible): + checkmark = self.browser.find_element_by_css_selector('.xblock-pb-slider .submit-result') + self.assertEqual(checkmark.is_displayed(), visible) + + +class SliderStepBlockTest(SliderBlockTestMixins, MentoringAssessmentBaseTest): + """ + Tests for the SliderBlock inside a Step Builder block. + """ + + def test_step_with_slider(self): + """ Test a regular Step Builder block containing one slider and an MCQ """ + step_builder, controls = self.load_assessment_scenario("slider_step.xml") + self.wait_for_init() + self.assertEqual(self.get_slider_value(), 50) + + # Check step 1 (the slider step): + question = self.expect_question_visible(1, step_builder, question_text="Information Reliability") + self.assertIn("How reliable is this information?", question.text) + self.assertIn("Select a value from 0% to 100%", question.text) # Screen reader explanation + self.assertTrue(controls.submit.is_enabled()) + self.assert_hidden(controls.try_again) + + self.set_slider_value(0) + controls.submit.click() + self.do_submit_wait(controls, last=False) + self.wait_until_clickable(controls.next_question) + controls.next_question.click() + + # Submit step 2: + question = self.expect_question_visible(2, step_builder) + GetChoices(question).select("Yes") + controls.submit.click() + self.do_submit_wait(controls, last=True) + self.wait_until_clickable(controls.review) + + controls.review.click() + self.wait_until_visible(controls.try_again) + # You can't get a slider question wrong, but it does count as one correct point by default: + self.assertIn("You answered 2 questions correctly", step_builder.text) + + def wait_for_init(self): + """ Wait for the scenario to initialize """ + self.wait_until_hidden(self.browser.find_element_by_css_selector('.assessment-review-tips')) diff --git a/problem_builder/tests/integration/xml_templates/slider_problem.xml b/problem_builder/tests/integration/xml_templates/slider_problem.xml new file mode 100644 index 00000000..cddf98a2 --- /dev/null +++ b/problem_builder/tests/integration/xml_templates/slider_problem.xml @@ -0,0 +1,12 @@ + + + + {% if include_mcq %} + + Yes + Maybe not + I don't understand + + {% endif %} + + diff --git a/problem_builder/tests/integration/xml_templates/slider_step.xml b/problem_builder/tests/integration/xml_templates/slider_step.xml new file mode 100644 index 00000000..00c1f4b7 --- /dev/null +++ b/problem_builder/tests/integration/xml_templates/slider_step.xml @@ -0,0 +1,16 @@ + + + + + + + + + Yes + No + + + + + + diff --git a/problem_builder/tests/unit/test_step.py b/problem_builder/tests/unit/test_step.py index 94e7637f..eaab18b5 100644 --- a/problem_builder/tests/unit/test_step.py +++ b/problem_builder/tests/unit/test_step.py @@ -108,7 +108,17 @@ def test_allowed_nested_blocks(self): block = MentoringStepBlock(Mock(), DictFieldData({}), Mock()) self.assertEqual( self.get_allowed_blocks(block), - ['pb-answer', 'pb-mcq', 'pb-rating', 'pb-mrq', 'html', 'pb-answer-recap', 'pb-table', 'sb-plot'] + [ + 'pb-answer', + 'pb-mcq', + 'pb-rating', + 'pb-mrq', + 'html', + 'pb-answer-recap', + 'pb-table', + 'sb-plot', + 'pb-slider', + ] ) from sys import modules xmodule_mock = Mock() @@ -121,7 +131,16 @@ def test_allowed_nested_blocks(self): with patch.dict(modules, fake_modules): self.assertEqual( self.get_allowed_blocks(block), [ - 'pb-answer', 'pb-mcq', 'pb-rating', 'pb-mrq', 'html', 'pb-answer-recap', - 'pb-table', 'sb-plot', 'video', 'imagemodal' + 'pb-answer', + 'pb-mcq', + 'pb-rating', + 'pb-mrq', + 'html', + 'pb-answer-recap', + 'pb-table', + 'sb-plot', + 'pb-slider', + 'video', + 'imagemodal', ] ) diff --git a/setup.py b/setup.py index 61e1534c..80fd6be8 100644 --- a/setup.py +++ b/setup.py @@ -54,6 +54,7 @@ def package_data(pkg, root_list): 'pb-mcq = problem_builder.mcq:MCQBlock', 'pb-rating = problem_builder.mcq:RatingBlock', 'pb-mrq = problem_builder.mrq:MRQBlock', + 'pb-slider = problem_builder.slider:SliderBlock', 'pb-message = problem_builder.message:MentoringMessageBlock', 'pb-tip = problem_builder.tip:TipBlock', 'pb-choice = problem_builder.choice:ChoiceBlock',