Commit d5d995d6 by Jonathan Piacenti

Added 'max submissions' functionality.

parent 09e8feda
......@@ -29,7 +29,7 @@ from markdown import markdown
import pkg_resources
from xblock.core import XBlock
from xblock.fields import Scope, String, Dict, List, Boolean
from xblock.fields import Scope, String, Dict, List, Boolean, Integer
from xblock.fragment import Fragment
from xblockutils.publish_event import PublishEventMixin
from xblockutils.resources import ResourceLoader
......@@ -63,6 +63,10 @@ class PollBase(XBlock, ResourceMixin, PublishEventMixin):
"""
event_namespace = 'xblock.pollbase'
private_results = Boolean(default=False, help="Whether or not to display results to the user.")
max_submissions = Integer(default=1, help="The maximum number of times a user may send a submission.")
submissions_count = Integer(
default=0, help="Number of times the user has sent a submission.", scope=Scope.user_state
)
feedback = String(default='', help="Text to display after the user votes.")
def send_vote_event(self, choice_data):
......@@ -152,6 +156,36 @@ class PollBase(XBlock, ResourceMixin, PublishEventMixin):
return items
def can_vote(self):
"""
Checks to see if the user is permitted to vote. This may not be the case if they used up their max_submissions.
"""
if self.max_submissions == 0:
return True
if self.max_submissions > self.submissions_count:
return True
return False
@staticmethod
def get_max_submissions(data, result, private_results):
"""
Gets the value of 'max_submissions' from studio submitted AJAX data, and checks for conflicts
with private_results, which may not be False when max_submissions is not 1, since that would mean
the student could change their answer based on other students' answers.
"""
try:
max_submissions = int(data['max_submissions'])
except (ValueError, KeyError):
max_submissions = 1
result['success'] = False
result['errors'].append('Maximum Submissions missing or not an integer.')
# Better to send an error than to confuse the user by thinking this would work.
if (max_submissions != 1) and not private_results:
result['success'] = False
result['errors'].append("Private results may not be False when Maximum Submissions is not 1.")
return max_submissions
class PollBlock(PollBase):
"""
......@@ -267,7 +301,8 @@ class PollBlock(PollBase):
'any_img': self.any_image(self.answers),
# The SDK doesn't set url_name.
'url_name': getattr(self, 'url_name', ''),
"display_name": self.display_name,
'display_name': self.display_name,
'can_vote': self.can_vote(),
})
if self.choice:
......@@ -288,7 +323,8 @@ class PollBlock(PollBase):
'display_name': self.display_name,
'private_results': self.private_results,
'feedback': self.feedback,
'js_template': js_template
'js_template': js_template,
'max_submissions': self.max_submissions,
})
return self.create_fragment(
context, "public/html/poll_edit.html",
......@@ -341,13 +377,22 @@ class PollBlock(PollBase):
result['errors'].append('No key "{choice}" in answers table.'.format(choice=choice))
return result
if old_choice is None:
# Reset submissions count if old choice is bogus.
self.submissions_count = 0
if not self.can_vote():
result['errors'].append('You have already voted as many times as you are allowed.')
self.clean_tally()
if old_choice is not None:
self.tally[old_choice] -= 1
self.choice = choice
self.tally[choice] += 1
self.submissions_count += 1
result['success'] = True
result['can_vote'] = self.can_vote()
self.send_vote_event({'choice': self.choice})
......@@ -359,6 +404,9 @@ class PollBlock(PollBase):
question = data.get('question', '').strip()
feedback = data.get('feedback', '').strip()
private_results = bool(data.get('private_results', False))
max_submissions = self.get_max_submissions(data, result, private_results)
display_name = data.get('display_name', '').strip()
if not question:
result['errors'].append("You must specify a question.")
......@@ -374,6 +422,7 @@ class PollBlock(PollBase):
self.feedback = feedback
self.private_results = private_results
self.display_name = display_name
self.max_submissions = max_submissions
# Tally will not be updated until the next attempt to use it, per
# scoping limitations.
......@@ -454,7 +503,8 @@ class SurveyBlock(PollBase):
'feedback': markdown(self.feedback) or False,
# The SDK doesn't set url_name.
'url_name': getattr(self, 'url_name', ''),
"block_name": self.block_name,
'block_name': self.block_name,
'can_vote': self.can_vote()
})
return self.create_fragment(
......@@ -482,6 +532,7 @@ class SurveyBlock(PollBase):
'display_name': self.block_name,
'private_results': self.private_results,
'js_template': js_template,
'max_submissions': self.max_submissions,
'multiquestion': True,
})
return self.create_fragment(
......@@ -649,6 +700,14 @@ class SurveyBlock(PollBase):
result['success'] = False
result['errors'].append("You have already voted in this poll.")
if not choices:
# Reset submissions count if choices are bogus.
self.submissions_count = 0
if not self.can_vote():
result['success'] = False
result['errors'].append('You have already voted as many times as you are allowed.')
# Make sure the user has included all questions, and hasn't included
# anything extra, which might indicate the questions have changed.
if not sorted(data.keys()) == sorted(questions.keys()):
......@@ -666,6 +725,7 @@ class SurveyBlock(PollBase):
"Found unknown answer '%s' for question key '%s'" % (key, value))
if not result['success']:
result['can_vote'] = self.can_vote()
return result
# Record the vote!
......@@ -675,8 +735,10 @@ class SurveyBlock(PollBase):
self.clean_tally()
for key, value in self.choices.items():
self.tally[key][value] += 1
self.submissions_count += 1
self.send_vote_event({'choices': self.choices})
result['can_vote'] = self.can_vote()
return result
......@@ -688,6 +750,7 @@ class SurveyBlock(PollBase):
feedback = data.get('feedback', '').strip()
block_name = data.get('display_name', '').strip()
private_results = bool(data.get('private_results', False))
max_submissions = self.get_max_submissions(data, result, private_results)
answers = self.gather_items(data, result, 'Answer', 'answers', image=False)
questions = self.gather_items(data, result, 'Question', 'questions')
......@@ -699,6 +762,7 @@ class SurveyBlock(PollBase):
self.questions = questions
self.feedback = feedback
self.private_results = private_results
self.max_submissions = max_submissions
self.block_name = block_name
# Tally will not be updated until the next attempt to use it, per
......
{{ js_template|safe }}
<div class="poll-block" data-private="{% if private_results %}1{% endif %}">
<div class="poll-block" data-private="{% if private_results %}1{% endif %}" data-can-vote="{% if can_vote %}1{% endif %}">
{# If no form is present, the Javascript will load the results instead. #}
{% if private_results or not choice %}
<h3 class="poll-header">{{display_name}}</h3>
......@@ -26,7 +26,7 @@
</ul>
<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>
<div class="poll-voting-thanks{% if not choice or can_vote %} 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 />
......
......@@ -4,7 +4,7 @@
<ul class="list-input settings-list" id="poll-line-items">
<li class="field comp-setting-entry is-set">
<div class="wrapper-comp-setting">
<label class="label setting-label poll-setting-label" for="display_name">Display Name</label>
<label class="label setting-label poll-setting-label" for="poll-display-name">Display Name</label>
<!-- In the case of surveys, this field will actually be used for block_name. -->
<input class="input setting-input" name="display_name" id="poll-display-name" value="{{ display_name }}" type="text" />
</div>
......@@ -44,6 +44,17 @@
</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-max-submissions">Maximum Submissions</label>
<input id="poll-max-submissions" type="number" min="0" step="1" value="{{ max_submissions }}"/>
</div>
<span class="tip setting-help">
Maximum number of times a user may submit a poll. **Setting this to a value other than 1 will imply that
'Private Results' should be true.** Setting it to 0 will allow infinite submissions.
resubmissions.
</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" data-private="{% if private_results %}1{% endif %}">
<div class="poll-block" data-private="{% if private_results %}1{% endif %}" data-can-vote="{% if can_vote %}1{% endif %}">
{# If no form is present, the Javascript will load the results instead. #}
{% if not choices or private_results %}
<h3 class="poll-header">{{block_name}}</h3>
......@@ -31,9 +31,9 @@
</tr>
{% endfor %}
</table>
<input class="input-main" type="button" name="poll-submit" value="{% if choices %}Resubmit{% else %}Submit{% endif %}" disabled />
<input class="input-main" type="button" name="poll-submit" value="{% if choices and can_vote %}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>
<div class="poll-voting-thanks{% if not choices or can_vote %} 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 />
......
......@@ -36,6 +36,10 @@ function PollUtil (runtime, element, pollType) {
success: self.getResults
});
});
// If the user has already reached their maximum submissions, all inputs should be disabled.
if (!$('div.poll-block', element).data('can-vote')) {
$('input', element).attr('disabled', true);
}
// If the user has refreshed the page, they may still have an answer
// selected and the submit button should be enabled.
var answers = $('input[type=radio]', element);
......@@ -97,15 +101,21 @@ function PollUtil (runtime, element, pollType) {
if (!data['success']) {
alert(data['errors'].join('\n'));
}
if ($('div.poll-block', element).attr('data-private')) {
var can_vote = data['can_vote'];
if ($('div.poll-block', element).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');
if (can_vote) {
$('input[name="poll-submit"]', element).val('Resubmit');
} else {
$('input', element).attr('disabled', true)
}
return;
}
// Used if results are not private, to show the user how other students voted.
$.ajax({
// Semantically, this would be better as GET, but we can use helper
// functions with POST.
......
......@@ -222,6 +222,7 @@ 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();
data['max_submissions'] = $('#poll-max-submissions', element).val();
// Convert to boolean for transfer.
data['private_results'] = eval($('#poll-private-results', element).val());
......
......@@ -24,9 +24,30 @@
from xblockutils.base_test import SeleniumBaseTest
# Default names for inputs for polls/surveys
DEFAULT_SURVEY_NAMES = ('enjoy', 'recommend', 'learn')
DEFAULT_POLL_NAMES = ('choice',)
class PollBaseTest(SeleniumBaseTest):
default_css_selector = 'div.poll-block'
module_name = __name__
def get_submit(self):
return self.browser.find_element_by_css_selector('input[name="poll-submit"]')
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'))
# -*- coding: utf-8 -*-
#
# Copyright (C) 2015 McKinsey Academy
#
# Authors:
# Jonathan Piacenti <jonathan@opencraft.com>
#
# 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/>.
#
from ddt import ddt, unpack, data
from tests.integration.base_test import PollBaseTest, DEFAULT_POLL_NAMES, DEFAULT_SURVEY_NAMES
scenarios_infinite = (
('Survey Max Submissions Infinite', DEFAULT_SURVEY_NAMES),
('Poll Max Submissions Infinite', DEFAULT_POLL_NAMES),
)
scenarios_max = (
('Survey Max Submissions', DEFAULT_SURVEY_NAMES),
('Poll Max Submissions', DEFAULT_POLL_NAMES),
)
@ddt
class TestPrivateResults(PollBaseTest):
@unpack
@data(*scenarios_infinite)
def test_infinite_submissions(self, page, names):
"""
We can't actually test infinite submissions, but we can be reasonably certain it will work
if it has worked a few times more than we have allocated, which should be '0' according to the
setting, which is actually code for 'as often as you like' rather than '0 attempts permitted'.
Try this by staying on the page, and by loading it up again.
"""
for __ in range(0, 2):
self.go_to_page(page)
for ___ in range(1, 5):
self.submission_run(names)
self.assertTrue(self.get_submit().is_enabled())
def submission_run(self, names):
self.do_submit(names)
self.browser.execute_script("$('.poll-voting-thanks').stop().addClass('poll-hidden').removeAttr('style')")
self.wait_until_hidden(self.browser.find_element_by_css_selector('.poll-voting-thanks'))
@unpack
@data(*scenarios_max)
def test_max_submissions_one_view(self, page, names):
"""
Verify that the user can't submit more than a certain number of times. Our XML allows two submissions.
"""
self.go_to_page(page)
for __ in range(0, 2):
self.do_submit(names)
self.assertFalse(self.get_submit().is_enabled())
@unpack
@data(*scenarios_max)
def test_max_submissions_reload(self, page, names):
"""
Same as above, but revisit the page between attempts.
"""
self.go_to_page(page)
self.do_submit(names)
self.go_to_page(page)
self.do_submit(names)
self.assertFalse(self.get_submit().is_enabled())
......@@ -23,10 +23,10 @@
from ddt import ddt, unpack, data
from selenium.common.exceptions import NoSuchElementException
from base_test import PollBaseTest
from base_test import PollBaseTest, DEFAULT_SURVEY_NAMES, DEFAULT_POLL_NAMES
scenarios = ('Survey Private', ['enjoy', 'recommend', 'learn']), ('Poll Private', ['choice'])
scenarios = ('Survey Private', DEFAULT_SURVEY_NAMES), ('Poll Private', DEFAULT_POLL_NAMES)
@ddt
class TestPrivateResults(PollBaseTest):
......@@ -34,22 +34,6 @@ 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):
......
<poll private_results="true" max_submissions="2" feedback="### Thank you&#10;&#10;for being a valued student."/>
<poll private_results="true" max_submissions="0" feedback="### Thank you&#10;&#10;for being a valued student."/>
<poll private_results="true" feedback="### Thank you&#10;&#10;for being a valued student."/>
<poll private_results="true" max_submissions="4" feedback="### Thank you&#10;&#10;for being a valued student."/>
<survey url_name="defaults" private_results="true" max_submissions="2"/>
<survey url_name="defaults" private_results="true" max_submissions="0"/>
<survey private_results="true" feedback="### Thank you&#10;&#10;for running the tests."/>
<survey private_results="true" max_submissions="4" 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