Commit e8a106ff by Josh McLaughlin

Add SwipeBlock for swipeable binary choice questions

parent 673a7533
...@@ -362,6 +362,50 @@ Identical to [MCQ questions](#multiple-choice-question-pb-mcq). ...@@ -362,6 +362,50 @@ Identical to [MCQ questions](#multiple-choice-question-pb-mcq).
When submitting the problem the data should be equal to the string value of the When submitting the problem the data should be equal to the string value of the
selected choice. Example: `"3"`. selected choice. Example: `"3"`.
Swipeable Binary Response (`pb-swipe`)
--------------------------------------
### `student_view_data`
- `id`: (string) The XBlock's ID
- `block_id`: (string) The XBlock's usage ID
- `display_name`: (string) The XBlock's display name
- `type`: (string): The XBlock's identifier, "pb-swipe"
- `question`: (string) The question contents
- `message`: (string) Feedback provided when submitting
- `img_url`: (string) URL to an associated image
- `weight`: (float) Overall value of the question
- `choices`: (array) A list of objects providing info about available
choices. See below for more info.
- `tips`: (array) A list of objects providing info about tips defined for the
problem. See below for more info.
#### `tips`
Each entry in the `tips` array contains these values:
- `content`: (string) The text content of the tip.
- `for_choices`: (array) A list of string values corresponding to choices to
which this tip applies to.
#### `choices`
Each item in the `choices` array contains these fields:
- `value`: (string) The value of the choice.
- `content`: (string) The description of the choice
### `student_view_user_state`
- `student_choice`: (string) The value of the last submitted choice.
### POST Submit Data
When submitting the problem the data should be a single object containing the
`"value"` property which has the value of the selected choice.
Example: `{"value": "blue"}`
Multiple Response Question (`pb-mrq`) Multiple Response Question (`pb-mrq`)
------------------------------------- -------------------------------------
......
...@@ -50,6 +50,7 @@ from xblockutils.studio_editable import ( ...@@ -50,6 +50,7 @@ from xblockutils.studio_editable import (
from problem_builder.answer import AnswerBlock, AnswerRecapBlock from problem_builder.answer import AnswerBlock, AnswerRecapBlock
from problem_builder.completion import CompletionBlock from problem_builder.completion import CompletionBlock
from problem_builder.mcq import MCQBlock, RatingBlock from problem_builder.mcq import MCQBlock, RatingBlock
from problem_builder.swipe import SwipeBlock
from problem_builder.mrq import MRQBlock from problem_builder.mrq import MRQBlock
from problem_builder.plot import PlotBlock from problem_builder.plot import PlotBlock
from problem_builder.slider import SliderBlock from problem_builder.slider import SliderBlock
...@@ -361,6 +362,14 @@ class MentoringBlock( ...@@ -361,6 +362,14 @@ class MentoringBlock(
except ImportError: except ImportError:
pass pass
try:
from xblock_django.models import XBlockConfiguration
opt = XBlockConfiguration.objects.filter(name="pb-swipe")
if opt.count() and opt.first().enabled:
additional_blocks.append(SwipeBlock)
except ImportError:
pass
message_block_shims = [ message_block_shims = [
NestedXBlockSpec( NestedXBlockSpec(
MentoringMessageBlock, MentoringMessageBlock,
......
...@@ -260,3 +260,7 @@ ...@@ -260,3 +260,7 @@
.mentoring .copyright a { .mentoring .copyright a {
color: #69C0E8; color: #69C0E8;
} }
.swipe-img {
max-width: 100%;
}
...@@ -176,6 +176,10 @@ function RatingBlock(runtime, element) { ...@@ -176,6 +176,10 @@ function RatingBlock(runtime, element) {
return MCQBlock(runtime, element); return MCQBlock(runtime, element);
} }
function SwipeBlock(runtime, element) {
return MCQBlock(runtime, element);
}
function MRQBlock(runtime, element) { function MRQBlock(runtime, element) {
return { return {
mode: null, mode: null,
......
# -*- 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 <http://www.gnu.org/licenses/>.
#
# Imports ###########################################################
import logging
from xblock.fields import Scope, String
from xblock.validation import ValidationMessage
from xblockutils.resources import ResourceLoader
from .mixins import StudentViewUserStateMixin
from .questionnaire import QuestionnaireAbstractBlock
from .sub_api import SubmittingXBlockMixin
# Globals ###########################################################
log = logging.getLogger(__name__)
loader = ResourceLoader(__name__)
# Make '_' a no-op so we can scrape strings
def _(text):
return text
# Classes ###########################################################
class SwipeBlock(SubmittingXBlockMixin, StudentViewUserStateMixin, QuestionnaireAbstractBlock):
"""
An XBlock used to ask binary-choice questions with a swiping interface
"""
CATEGORY = 'pb-swipe'
STUDIO_LABEL = _(u"Swipeable Binary Choice Question")
USER_STATE_FIELDS = ['num_attempts', 'student_choice']
message = String(
display_name=_("Message"),
help=_(
"General feedback provided when submitting. "
"(This is not shown if there is a more specific feedback tip for the choice selected by the learner.)"
),
scope=Scope.content,
default=""
)
student_choice = String(
# {Last input submitted by the student
default="",
scope=Scope.user_state,
)
correct_choice = String(
display_name=_("Correct Choice"),
help=_("Specify the value that students may select for this question to be considered correct."),
scope=Scope.content,
values_provider=QuestionnaireAbstractBlock.choice_values_provider,
)
img_url = String(
display_name=_("Image"),
help=_(
"Specify the URL of an image associated with this question."
),
scope=Scope.content,
default=""
)
editable_fields = QuestionnaireAbstractBlock.editable_fields + ('message', 'correct_choice', 'img_url',)
def calculate_results(self, submission):
correct = self.correct_choice == submission
return {
'submission': submission,
'message': self.message_formatted,
'status': 'correct' if correct else 'incorrect',
'weight': self.weight,
'score': 1 if correct else 0,
}
def get_results(self, previous_result):
return self.calculate_results(previous_result['submission'])
def get_last_result(self):
return self.get_results({'submission': self.student_choice}) if self.student_choice else {}
def submit(self, submission):
log.debug(u'Received Swipe submission: "%s"', submission)
result = self.calculate_results(submission['value'])
self.student_choice = submission['value']
log.debug(u'Swipe submission result: %s', result)
return result
def validate_field_data(self, validation, data):
"""
Validate this block's field data.
"""
super(SwipeBlock, self).validate_field_data(validation, data)
def add_error(msg):
validation.add(ValidationMessage(ValidationMessage.ERROR, msg))
if len(self.all_choice_values) == 0:
# Let's not set an error until at least one choice is added
return
if len(self.all_choice_values) != 2:
add_error(
self._(u"You must have exactly two choices.")
)
if not data.correct_choice:
add_error(
self._(u"You must indicate the correct answer, or the student will always get this question wrong.")
)
def student_view_data(self, context=None):
"""
Returns a JSON representation of the student_view of this XBlock,
retrievable from the Course Block API.
"""
return {
'id': self.name,
'block_id': unicode(self.scope_ids.usage_id),
'display_name': self.display_name_with_default,
'type': self.CATEGORY,
'question': self.question,
'message': self.message,
'img_url': self.expand_static_url(self.img_url),
'choices': [
{'value': choice['value'], 'content': choice['display_name']}
for choice in self.human_readable_choices
],
'weight': self.weight,
'tips': [tip.student_view_data() for tip in self.get_tips()],
}
def expand_static_url(self, url):
"""
This is required to make URLs like '/static/dnd-test-image.png' work (note: that is the
only portable URL format for static files that works across export/import and reruns).
This method is unfortunately a bit hackish since XBlock does not provide a low-level API
for this.
"""
if hasattr(self.runtime, 'replace_urls'):
url = self.runtime.replace_urls('"{}"'.format(url))[1:-1]
elif hasattr(self.runtime, 'course_id'):
# edX Studio uses a different runtime for 'studio_view' than 'student_view',
# and the 'studio_view' runtime doesn't provide the replace_urls API.
try:
from static_replace import replace_static_urls # pylint: disable=import-error
url = replace_static_urls('"{}"'.format(url), None, course_id=self.runtime.course_id)[1:-1]
except ImportError:
pass
return url
@property
def expanded_img_url(self):
return self.expand_static_url(self.img_url)
{% load i18n %}
{% if not hide_header %}
<h4 class="question-title" id="heading_{{ self.html_id }}">{{ self.display_name_with_default }}</h4>
{% endif %}
{% if self.img_url.strip %}
<img class="swipe-img" src="{{ self.expanded_img_url }}" alt="" />
{% endif %}
<fieldset class="choices questionnaire" id="{{ self.html_id }}">
<legend class="question field-group-hd">{{ self.question|safe }}</legend>
<div class="choices-list">
{% for choice in custom_choices %}
<div class="choice" aria-live="polite" aria-atomic="true">
<label class="choice-label"
aria-describedby="feedback_{{ self.html_id }} choice_tips_{{ self.html_id }}-{{ forloop.counter }}">
<span class="choice-result fa icon-2x"
aria-label=""
data-label_correct="{% trans "Correct" %}"
data-label_incorrect="{% trans "Incorrect" %}"></span>
<span class="choice-selector">
<input type="radio" name="{{ self.name }}" value="{{ choice.value }}"
{% if self.student_choice == choice.value and not hide_prev_answer %} checked{% endif %}
/>
</span>
<span class="choice-label-text">{{ choice.content|safe }}</span>
</label>
<div class="choice-tips-container">
<div class="choice-tips" id="choice_tips_{{ self.html_id }}-{{ forloop.counter }}"></div>
</div>
</div>
{% endfor %}
<div class="feedback" id="feedback_{{ self.html_id }}"></div>
</div>
</fieldset>
...@@ -56,6 +56,7 @@ BLOCKS = [ ...@@ -56,6 +56,7 @@ BLOCKS = [
'pb-answer = problem_builder.answer:AnswerBlock', 'pb-answer = problem_builder.answer:AnswerBlock',
'pb-answer-recap = problem_builder.answer:AnswerRecapBlock', 'pb-answer-recap = problem_builder.answer:AnswerRecapBlock',
'pb-mcq = problem_builder.mcq:MCQBlock', 'pb-mcq = problem_builder.mcq:MCQBlock',
'pb-swipe = problem_builder.swipe:SwipeBlock',
'pb-rating = problem_builder.mcq:RatingBlock', 'pb-rating = problem_builder.mcq:RatingBlock',
'pb-mrq = problem_builder.mrq:MRQBlock', 'pb-mrq = problem_builder.mrq:MRQBlock',
'pb-slider = problem_builder.slider:SliderBlock', 'pb-slider = problem_builder.slider:SliderBlock',
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment