Commit 70d32445 by Jonathan Piacenti

Added ability to have polls that don't share results with users.

parent dae5a806
......@@ -174,6 +174,17 @@ If you need to remove a question or answer, you can use the `Delete` link:
**Remember**: You must have at least two answers (and at least two questions, in the case of polls). If a user has
voted on/for the item you've deleted, they will be permitted to vote again, but will not lose progress.
You may also create a poll or survey in which the results are not shown to the user. To do this, click the checkbox
for 'Private Results':
![Private Results](doc_img/private_results.png)
**Notes on Private Results**: Users will be able to change their vote on polls and surveys with this option enabled.
An analytics event will not be fired upon the student viewing the results, as the results are never visible. A user
will see a thank you message and any feedback provided upon submission:
![Private Results Submission](doc_img/private_results_submission.png)
When you are finished customizing your poll or survey, click the `Save` button:
![Save button](doc_img/save_button.png)
......
......@@ -6,7 +6,7 @@ from markdown import markdown
import pkg_resources
from xblock.core import XBlock
from xblock.fields import Scope, String, Dict, List
from xblock.fields import Scope, String, Dict, List, Boolean
from xblock.fragment import Fragment
from xblockutils.publish_event import PublishEventMixin
from xblockutils.resources import ResourceLoader
......@@ -39,6 +39,8 @@ class PollBase(XBlock, ResourceMixin, PublishEventMixin):
Base class for Poll-like XBlocks.
"""
event_namespace = 'xblock.pollbase'
private_results = Boolean(default=False, help="Whether or not to display results to the user.")
feedback = String(default='', help="Text to display after the user votes.")
def send_vote_event(self, choice_data):
# Let the LMS know the user has answered the poll.
......@@ -142,7 +144,6 @@ class PollBlock(PollBase):
('G', {'label': 'Green', 'img': None}), ('O', {'label': 'Other', 'img': None})),
scope=Scope.settings, help="The answer options on this poll."
)
feedback = String(default='', help="Text to display after the user votes.")
tally = Dict(default={'R': 0, 'B': 0, 'G': 0, 'O': 0},
scope=Scope.user_state_summary,
help="Total tally of answers from students.")
......@@ -236,6 +237,7 @@ class PollBlock(PollBase):
# Offset so choices will always be True.
'answers': self.markdown_items(self.answers),
'question': markdown(self.question),
'private_results': self.private_results,
# Mustache is treating an empty string as true.
'feedback': markdown(self.feedback) or False,
'js_template': js_template,
......@@ -261,6 +263,7 @@ class PollBlock(PollBase):
context.update({
'question': self.question,
'display_name': self.display_name,
'private_results': self.private_results,
'feedback': self.feedback,
'js_template': js_template
})
......@@ -282,8 +285,11 @@ class PollBlock(PollBase):
@XBlock.json_handler
def get_results(self, data, suffix=''):
self.publish_event_from_dict(self.event_namespace + '.view_results', {})
detail, total = self.tally_detail()
if self.private_results:
detail, total = {}, None
else:
self.publish_event_from_dict(self.event_namespace + '.view_results', {})
detail, total = self.tally_detail()
return {
'question': markdown(self.question), 'tally': detail,
'total': total, 'feedback': markdown(self.feedback),
......@@ -296,7 +302,8 @@ class PollBlock(PollBase):
Sets the user's vote.
"""
result = {'success': False, 'errors': []}
if self.get_choice() is not None:
old_choice = self.get_choice()
if (old_choice is not None) and not self.private_results:
result['errors'].append('You have already voted in this poll.')
return result
try:
......@@ -312,8 +319,10 @@ class PollBlock(PollBase):
return result
self.clean_tally()
if old_choice is not None:
self.tally[old_choice] -= 1
self.choice = choice
self.tally[choice] = self.tally.get(choice, 0) + 1
self.tally[choice] += 1
result['success'] = True
......@@ -323,11 +332,10 @@ class PollBlock(PollBase):
@XBlock.json_handler
def studio_submit(self, data, suffix=''):
# I wonder if there's something for live validation feedback already.
result = {'success': True, 'errors': []}
question = data.get('question', '').strip()
feedback = data.get('feedback', '').strip()
private_results = bool(data.get('private_results', False))
display_name = data.get('display_name', '').strip()
if not question:
result['errors'].append("You must specify a question.")
......@@ -341,6 +349,7 @@ class PollBlock(PollBase):
self.answers = answers
self.question = question
self.feedback = feedback
self.private_results = private_results
self.display_name = display_name
# Tally will not be updated until the next attempt to use it, per
......@@ -387,7 +396,6 @@ class SurveyBlock(PollBase):
),
scope=Scope.settings, help="Questions for this Survey"
)
feedback = String(default='', help="Text to display after the user votes.")
tally = Dict(
default={
'enjoy': {'Y': 0, 'N': 0, 'M': 0}, 'recommend': {'Y': 0, 'N': 0, 'M': 0},
......@@ -416,7 +424,8 @@ class SurveyBlock(PollBase):
# Offset so choices will always be True.
'answers': self.answers,
'js_template': js_template,
'questions': self.markdown_items(self.questions),
'questions': self.renderable_answers(self.questions, choices),
'private_results': self.private_results,
'any_img': self.any_image(self.questions),
# Mustache is treating an empty string as true.
'feedback': markdown(self.feedback) or False,
......@@ -429,6 +438,17 @@ class SurveyBlock(PollBase):
context, "public/html/survey.html", "public/css/poll.css",
"public/js/poll.js", "SurveyBlock")
def renderable_answers(self, questions, choices):
"""
Render markdown for questions, and annotate with answers
in the case of private_results.
"""
choices = choices or {}
markdown_questions = self.markdown_items(questions)
for key, value in markdown_questions:
value['choice'] = choices.get(key, None)
return markdown_questions
def studio_view(self, context=None):
if not context:
context = {}
......@@ -437,6 +457,7 @@ class SurveyBlock(PollBase):
context.update({
'feedback': self.feedback,
'display_name': self.block_name,
'private_results': self.private_results,
'js_template': js_template,
'multiquestion': True,
})
......@@ -533,7 +554,7 @@ class SurveyBlock(PollBase):
"""
questions = dict(self.questions)
answers = dict(self.answers)
for key, value in self.choices:
for key, value in self.choices.items():
if key in questions:
if value in answers:
self.tally[key][value] -= 1
......@@ -559,8 +580,11 @@ class SurveyBlock(PollBase):
@XBlock.json_handler
def get_results(self, data, suffix=''):
self.publish_event_from_dict(self.event_namespace + '.view_results', {})
detail, total = self.tally_detail()
if self.private_results:
detail, total = {}, None
else:
self.publish_event_from_dict(self.event_namespace + '.view_results', {})
detail, total = self.tally_detail()
return {
'answers': [
value for value in OrderedDict(self.answers).values()],
......@@ -598,7 +622,7 @@ class SurveyBlock(PollBase):
answers = dict(self.answers)
result = {'success': True, 'errors': []}
choices = self.get_choices()
if choices:
if choices and not self.private_results:
result['success'] = False
result['errors'].append("You have already voted in this poll.")
......@@ -622,6 +646,8 @@ class SurveyBlock(PollBase):
return result
# Record the vote!
if self.choices:
self.remove_vote()
self.choices = data
self.clean_tally()
for key, value in self.choices.items():
......@@ -638,6 +664,7 @@ class SurveyBlock(PollBase):
result = {'success': True, 'errors': []}
feedback = data.get('feedback', '').strip()
block_name = data.get('display_name', '').strip()
private_results = bool(data.get('private_results', False))
answers = self.gather_items(data, result, 'Answer', 'answers', image=False)
questions = self.gather_items(data, result, 'Question', 'questions')
......@@ -648,6 +675,7 @@ class SurveyBlock(PollBase):
self.answers = answers
self.questions = questions
self.feedback = feedback
self.private_results = private_results
self.block_name = block_name
# Tally will not be updated until the next attempt to use it, per
......
......@@ -195,3 +195,15 @@ th.survey-answer {
padding: 0;
}
.poll-voting-thanks span {
margin-top: .25em;
margin-bottom: .25em;
padding: .5em;
display: inline-block;
background-color: #e5ebee;
color: #e37222;
}
.poll-hidden {
display: none;
}
{{ js_template|safe }}
<div class="poll-block">
<div class="poll-block" data-private="{% if private_results %}1{% endif %}">
{# If no form is present, the Javascript will load the results instead. #}
{% if not choice %}
{% if private_results or not choice %}
<h3 class="poll-header">{{display_name}}</h3>
<form>
<div class="poll-question-container">
......@@ -10,7 +10,7 @@
<ul class="poll-answers">
{% for key, value in answers %}
<li class="poll-answer">
<input type="radio" name="choice" id="{{url_name}}-answer-{{key}}" value="{{key}}" />
<input type="radio" name="choice" id="{{url_name}}-answer-{{key}}" value="{{key}}" {% if choice == key %}checked{% endif %}/>
{% if value.img %}
<div class="poll-image">
<label for="{{url_name}}-answer-{{key}}" class="poll-image-label">
......@@ -24,7 +24,17 @@
</li>
{% endfor %}
</ul>
<input class="input-main" type="button" name="poll-submit" value="Submit" disabled />
<input class="input-main" type="button" name="poll-submit" value="{% if choice %}Resubmit{% else %}Submit{% endif %}" disabled />
</form>
<div class="poll-voting-thanks{% if not choice %} poll-hidden{% endif %}"><span>Thank you for your submission!</span></div>
{% if feedback %}
<div class="poll-feedback-container{% if not choice %} poll-hidden{% endif %}">
<hr />
<h3 class="poll-header">Feedback</h3>
<div class="poll-feedback">
{{feedback|safe}}
</div>
</div>
{% endif %}
{% endif %}
</div>
......@@ -31,6 +31,19 @@
</span>
</li>
<li class="field comp-setting-entry is-set">
<div class="wrapper-comp-setting">
<label class="label setting-label poll-setting-label" for="poll-private-results">Private Results</label>
<select id="poll-private-results" class="input setting-input" name="private_results">
<!-- So far as I can see, there's not a proper style for checkboxes. LTI module does it this way. -->
<option value="true" {% if private_results %} selected{% endif %}>True</option>
<option value="false" {% if not private_results %} selected{% endif %}>False</option>
</select>
</div>
<span class="tip setting-help">
If this is set True, don't display results of the poll to the user.
</span>
</li>
<li class="field comp-setting-entry is-set">
<p>
<strong>Notes:</strong>
If you change an answer's text, all students who voted for that choice will have their votes updated to
......
{{ js_template|safe }}
<div class="poll-block">
<div class="poll-block" data-private="{% if private_results %}1{% endif %}">
{# If no form is present, the Javascript will load the results instead. #}
{% if not choices %}
{% if not choices or private_results %}
<h3 class="poll-header">{{block_name}}</h3>
<form>
<table class="survey-table">
......@@ -25,13 +25,23 @@
</td>
{% for answer, answer_details in answers %}
<td class="survey-option">
<input type="radio" name="{{key}}" value="{{answer}}" />
<input type="radio" name="{{key}}" value="{{answer}}"{% if question.choice == answer %} checked{% endif %}/>
</td>
{% endfor %}
</tr>
{% endfor %}
</table>
<input class="input-main" type="button" name="poll-submit" value="Submit" disabled />
<input class="input-main" type="button" name="poll-submit" value="{% if choices %}Resubmit{% else %}Submit{% endif %}" disabled />
</form>
<div class="poll-voting-thanks{% if not choices %} poll-hidden{% endif %}"><span>Thank you for your submission!</span></div>
{% if feedback %}
<div class="poll-feedback-container{% if not choices %} poll-hidden{% endif %}">
<hr />
<h3 class="poll-header">Feedback</h3>
<div class="poll-feedback">
{{feedback|safe}}
</div>
</div>
{% endif %}
{% endif %}
</div>
......@@ -97,6 +97,15 @@ function PollUtil (runtime, element, pollType) {
if (!data['success']) {
alert(data['errors'].join('\n'));
}
if ($('div.poll-block', element).attr('data-private')) {
// User may be changing their vote. Give visual feedback that it was accepted.
var thanks = $('.poll-voting-thanks', element);
thanks.removeClass('poll-hidden');
thanks.fadeOut(0).fadeIn('slow', 'swing');
$('.poll-feedback-container', element).removeClass('poll-hidden');
$('input[name="poll-submit"]', element).val('Resubmit');
return;
}
$.ajax({
// Semantically, this would be better as GET, but we can use helper
// functions with POST.
......
......@@ -222,6 +222,8 @@ function PollEditUtil(runtime, element, pollType) {
data['display_name'] = $('#poll-display-name', element).val();
data['question'] = $('#poll-question-editor', element).val();
data['feedback'] = $('#poll-feedback-editor', element).val();
// Convert to boolean for transfer.
data['private_results'] = eval($('#poll-private-results', element).val());
if (notify) {
runtime.notify('save', {state: 'start', message: "Saving"});
......
......@@ -20,7 +20,7 @@ class TestPollFunctions(PollBaseTest):
answers = [element.text for element in answer_elements]
self.assertEqual(['A very long time', 'Not very long', 'I shall not say', 'Longer than you'], answers)
self.assertRaises(NoSuchElementException, self.browser.find_element_by_css_selector, '.poll-feedback')
self.assertFalse(self.browser.find_element_by_css_selector('.poll-feedback').is_displayed())
submit_button = self.get_submit()
self.assertFalse(submit_button.is_enabled())
......@@ -50,7 +50,7 @@ class TestPollFunctions(PollBaseTest):
self.get_submit().click()
self.wait_until_exists('.poll-feedback')
self.wait_until_exists('.poll-footnote')
self.assertTrue(self.browser.find_element_by_css_selector('.poll-feedback').text,
"Thank you\nfor being a valued student.")
......@@ -122,7 +122,7 @@ class TestSurveyFunctions(PollBaseTest):
]
)
self.assertRaises(NoSuchElementException, self.browser.find_element_by_css_selector, '.poll-feedback')
self.assertFalse(self.browser.find_element_by_css_selector('.poll-feedback').is_displayed())
submit_button = self.get_submit()
self.assertFalse(submit_button.is_enabled())
......@@ -181,7 +181,7 @@ class TestSurveyFunctions(PollBaseTest):
self.fill_survey()
self.get_submit().click()
self.wait_until_exists('.poll-feedback')
self.wait_until_exists('.poll-footnote')
self.assertEqual(self.browser.find_element_by_css_selector('.poll-footnote').text,
'Results gathered from 21 respondents.')
......
......@@ -3,7 +3,6 @@ Test to make sure the layout for results is sane when taking images into
account.
"""
from tests.integration.base_test import PollBaseTest
import time
class TestLayout(PollBaseTest):
......
......@@ -34,5 +34,5 @@ class MarkdownTestCase(PollBaseTest):
if back:
self.browser.find_element_by_css_selector('input[type=radio]').click()
self.get_submit().click()
self.wait_until_exists('.poll-feedback')
self.wait_until_exists('.poll-footnote')
self.assertEqual(self.get_selector_text(selector), result)
from ddt import ddt, unpack, data
from selenium.common.exceptions import NoSuchElementException
from base_test import PollBaseTest
scenarios = ('Survey Private', ['enjoy', 'recommend', 'learn']), ('Poll Private', ['choice'])
@ddt
class TestPrivateResults(PollBaseTest):
"""
Check the functionality of private results.
"""
def make_selections(self, names):
"""
Selects the first option for each named input.
"""
for name in names:
self.browser.find_element_by_css_selector('input[name="%s"]' % name).click()
def do_submit(self, names):
"""
Do selection and submit.
"""
self.make_selections(names)
submit = self.get_submit()
submit.click()
self.wait_until_clickable(self.browser.find_element_by_css_selector('.poll-voting-thanks'))
@unpack
@data(*scenarios)
def test_form_remains(self, page_name, names):
"""
User should still have a form presented after submitting so they can resubmit.
"""
self.go_to_page(page_name)
# Form should be there to begin with, of course.
self.browser.find_element_by_css_selector('div.poll-block form')
self.do_submit(names)
@unpack
@data(*scenarios)
def test_no_results(self, page_name, names):
"""
The handlebars template for results should never be called, and the form should persist.
"""
self.go_to_page(page_name)
self.do_submit(names)
# No results should be showing.
self.assertNotIn(self.browser.find_element_by_css_selector('div.poll-block').get_attribute('innerHTML'), 'poll-top-choice')
self.assertRaises(NoSuchElementException, self.browser.find_element_by_css_selector, '.poll-footnote')
@unpack
@data(*scenarios)
def test_submit_button(self, page_name, names):
self.go_to_page(page_name)
submit = self.get_submit()
self.assertIn('Submit', submit.get_attribute('outerHTML'))
self.make_selections(names)
submit.click()
self.wait_until_clickable(self.browser.find_element_by_css_selector('.poll-voting-thanks'))
self.assertIn('Resubmit', submit.get_attribute('outerHTML'), 'Resubmit')
# This should persist on page reload.
self.go_to_page(page_name)
submit = self.get_submit()
self.assertIn('Resubmit', submit.get_attribute('outerHTML'), 'Resubmit')
@unpack
@data(*scenarios)
def test_feedback_display(self, page_name, names):
self.go_to_page(page_name)
self.assertFalse(self.browser.find_element_by_css_selector('.poll-feedback').is_displayed())
self.do_submit(names)
self.assertTrue(self.browser.find_element_by_css_selector('.poll-feedback').is_displayed())
<poll private_results="true" feedback="### Thank you&#10;&#10;for being a valued student."/>
<survey private_results="true" feedback="### Thank you&#10;&#10;for running the tests."/>
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