Commit 1b9c44da by Xavier Antoviaque

Merge pull request #2 from open-craft/survey

Version 2 with Survey
parents 59758e5f 42dc00e3
# XBlock-Poll
> A user-friendly way to query students.
## Introduction
This XBlock enables a course author to create survey/poll elements to get
feedback from students. The XBlocks can either be *poll* or *survey* XBlocks. *Poll* XBlocks have one
question, and a series of answers. *Survey* XBlocks have several questions and a handful of (terse) answers that
a student is expect to answer each one from (Such as 'True', and 'False', or 'Agree' or 'Disagree')
## Features
Survey and Poll are both designed to minimize the amount of fiddling a course author will have to
do in order to create the user experience they desire. By default, answers in polls and questions in surveys
are able to be enhanced with markdown (though it is not recommended to do more than line formatting with it)
and images. Formatting for images is handled by the XBlock's formatters to keep a consistent and sane user experience.
The feedback section of a poll or survey is shown after a user has completed the block. It, along with a poll block's
question field, are intended to make full use of markdown.
## Notes
A poll or survey should not be deployed until its construction is finalized. Changing an answer or question can
cause previous respondent's answers to remap and give an inaccurate picture of the responses.
If a poll has changed enough that it leaves a previous voter's choice ambiguous, their response will be eliminated
from the tally upon their next visit, and they will be permitted to vote again. However, they will not lose progress
or their score.
Things that could make a poll's previous answers ambiguous include adding or removing a question, or adding or
removing an answer.
## Grading
Each block has a score value of 1, credited to the student upon completion of the block.
## Analytics
Two events are fired by the XBlocks-- one for viewing the results of a poll, and one for submitting the user's choice.
The resulting events look like this for polls:
{"username": "staff", "host": "precise64", "event_source": "server", "event_type": "xblock.poll.submitted", "context": {"course_user_tags": {}, "user_id": 1, "org_id": "JediAcademy", "module": {"display_name": "Poll"}, "course_id": "JediAcademy/FW301/2015", "path": "/courses/JediAcademy/FW301/2015/xblock/i4x:;_;_JediAcademy;_FW301;_poll;_2d25e451be884aa7a15b33860d7c9647/handler/vote"}, "time": "2015-01-12T19:13:39.199098+00:00", "ip": "10.0.2.2", "event": {"url_name": "2d25e451be884aa7a15b33860d7c9647", "choice": "B"}, "agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:34.0) Gecko/20100101 Firefox/34.0", "page": "x_module"}
{"username": "staff", "host": "precise64", "event_source": "server", "event_type": "xblock.poll.view_results", "context": {"course_user_tags": {}, "user_id": 1, "org_id": "JediAcademy", "module": {"display_name": "Poll"}, "course_id": "JediAcademy/FW301/2015", "path": "/courses/JediAcademy/FW301/2015/xblock/i4x:;_;_JediAcademy;_FW301;_poll;_2d25e451be884aa7a15b33860d7c9647/handler/get_results"}, "time": "2015-01-12T19:13:39.474514+00:00", "ip": "10.0.2.2", "event": {}, "agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:34.0) Gecko/20100101 Firefox/34.0", "page": "x_module"}
...And like this for surveys:
{"username": "staff", "host": "precise64", "event_source": "server", "event_type": "xblock.survey.submitted", "context": {"course_user_tags": {}, "user_id": 1, "org_id": "JediAcademy", "module": {"display_name": "Survey"}, "course_id": "JediAcademy/FW301/2015", "path": "/courses/JediAcademy/FW301/2015/xblock/i4x:;_;_JediAcademy;_FW301;_survey;_e4975240b6c64a1e988bad86ea917070/handler/vote"}, "time": "2015-01-12T19:13:13.115038+00:00", "ip": "10.0.2.2", "event": {"url_name": "e4975240b6c64a1e988bad86ea917070", "choices": {"enjoy": "Y", "learn": "M", "recommend": "N"}}, "agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:34.0) Gecko/20100101 Firefox/34.0", "page": "x_module"}
{"username": "staff", "host": "precise64", "event_source": "server", "event_type": "xblock.survey.view_results", "context": {"course_user_tags": {}, "user_id": 1, "org_id": "JediAcademy", "module": {"display_name": "Survey"}, "course_id": "JediAcademy/FW301/2015", "path": "/courses/JediAcademy/FW301/2015/xblock/i4x:;_;_JediAcademy;_FW301;_survey;_e4975240b6c64a1e988bad86ea917070/handler/get_results"}, "time": "2015-01-12T19:13:13.513909+00:00", "ip": "10.0.2.2", "event": {}, "agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:34.0) Gecko/20100101 Firefox/34.0", "page": "x_module"}
from .poll import PollBlock from .poll import PollBlock, SurveyBlock
\ No newline at end of file
...@@ -2,66 +2,153 @@ ...@@ -2,66 +2,153 @@
from collections import OrderedDict from collections import OrderedDict
from django.template import Template, Context from django.template import Template, Context
from markdown import markdown
import pkg_resources import pkg_resources
from xblock.core import XBlock from xblock.core import XBlock
from xblock.fields import Scope, String, Dict, List from xblock.fields import Scope, String, Dict, List
from xblock.fragment import Fragment from xblock.fragment import Fragment
from xblockutils.publish_event import PublishEventMixin
from xblockutils.resources import ResourceLoader from xblockutils.resources import ResourceLoader
from .utils import process_markdown
# When changing these constants, check the templates as well for places class ResourceMixin(object):
# where the user is informed about them. loader = ResourceLoader(__name__)
@staticmethod
def resource_string(path):
"""Handy helper for getting resources from our kit."""
data = pkg_resources.resource_string(__name__, path)
return data.decode("utf8")
def create_fragment(self, context, template, css, js, js_init):
html = Template(
self.resource_string(template)).render(Context(context))
frag = Fragment(html)
frag.add_javascript_url(
self.runtime.local_resource_url(
self, 'public/js/vendor/handlebars.js'))
frag.add_css(self.resource_string(css))
frag.add_javascript(self.resource_string(js))
frag.initialize_js(js_init)
return frag
class PollBase(XBlock, ResourceMixin, PublishEventMixin):
"""
Base class for Poll-like XBlocks.
"""
event_namespace = 'xblock.pollbase'
MAX_PARAGRAPH_LEN = 5000 def send_vote_event(self, choice_data):
# Let the LMS know the user has answered the poll.
self.runtime.publish(self, 'progress', {})
self.runtime.publish(self, 'grade', {
'value': 1,
'max_value': 1,
}
)
# The SDK doesn't set url_name.
event_dict = {'url_name': getattr(self, 'url_name', '')}
event_dict.update(choice_data)
self.publish_event_from_dict(
self.event_namespace + '.submitted',
event_dict,
)
MAX_URL_LEN = 1000 @staticmethod
def any_image(field):
"""
Find out if any answer has an image, since it affects layout.
"""
return any(value['img'] for value in dict(field).values())
MAX_ANSWER_LEN = 250 @staticmethod
def markdown_items(items):
"""
Convert all items' labels into markdown.
"""
return [[key, {'label': markdown(value['label']), 'img': value['img']}]
for key, value in items]
@staticmethod
def gather_items(data, result, noun, field, image=True):
"""
Gathers a set of label-img pairs from a data dict and puts them in order.
"""
items = []
if field not in data or not isinstance(data[field], list):
source_items = []
result['success'] = False
result['errors'].append(
"'{0}' is not present, or not a JSON array.".format(field))
else:
source_items = data[field]
# These two don't have mentions in the templates, but will cause error # Make sure all components are present and clean them.
# messages. for item in source_items:
if not isinstance(item, dict):
result['success'] = False
result['errors'].append(
"{0} {1} not a javascript object!".format(noun, item))
continue
key = item.get('key', '').strip()
if not key:
result['success'] = False
result['errors'].append(
"{0} {1} contains no key.".format(noun, item))
image_link = item.get('img', '').strip()
label = item.get('label', '').strip()
if not label:
if image and not image_link:
result['success'] = False
result['errors'].append(
"{0} has no text or img. Please make sure all {0}s "
"have one or the other, or both.".format(noun))
elif not image:
result['success'] = False
# If there's a bug in the code or the user just forgot to relabel a question,
# votes could be accidentally lost if we assume the omission was an
# intended deletion.
result['errors'].append("{0} was added with no label. "
"All {1}s must have labels. Please check the form. "
"Check the form and explicitly delete {1}s "
"if not needed.".format(noun, noun.lower()))
if image:
# Labels might have prefixed space for markdown, though it's unlikely.
items.append((key, {'label': label, 'img': image_link.strip()}))
else:
items.append([key, label])
MAX_ANSWERS = 25 if not len(items) > 1:
result['errors'].append(
"You must include at least two {0}s.".format(noun.lower()))
result['success'] = False
MAX_KEY_LEN = 100 return items
class PollBlock(XBlock): class PollBlock(PollBase):
""" """
Poll XBlock. Allows a teacher to poll users, and presents the results so Poll XBlock. Allows a teacher to poll users, and presents the results so
far of the poll to the user when finished. far of the poll to the user when finished.
""" """
display_name = String(default='Poll')
question = String(default='What is your favorite color?') question = String(default='What is your favorite color?')
# This will be converted into an OrderedDict. # This will be converted into an OrderedDict.
# Key, (Label, Image path) # Key, (Label, Image path)
answers = List( answers = List(
default=(('R', {'label': 'Red', 'img': None}), ('B', {'label': 'Blue', 'img': None}), default=(('R', {'label': 'Red', 'img': None}), ('B', {'label': 'Blue', 'img': None}),
('G', {'label': 'Green', 'img': None}), ('O', {'label': 'Other', 'img': None})), ('G', {'label': 'Green', 'img': None}), ('O', {'label': 'Other', 'img': None})),
scope=Scope.settings, help="The question on this poll." scope=Scope.settings, help="The answer options on this poll."
) )
feedback = String(default='', help="Text to display after the user votes.") feedback = String(default='', help="Text to display after the user votes.")
tally = Dict(default={'R': 0, 'B': 0, 'G': 0, 'O': 0}, tally = Dict(default={'R': 0, 'B': 0, 'G': 0, 'O': 0},
scope=Scope.user_state_summary, scope=Scope.user_state_summary,
help="Total tally of answers from students.") help="Total tally of answers from students.")
choice = String(scope=Scope.user_state, help="The student's answer") choice = String(scope=Scope.user_state, help="The student's answer")
event_namespace = 'xblock.poll'
loader = ResourceLoader(__name__)
def resource_string(self, path):
"""Handy helper for getting resources from our kit."""
data = pkg_resources.resource_string(__name__, path)
return data.decode("utf8")
@XBlock.json_handler
def get_results(self, data, suffix=''):
detail, total = self.tally_detail()
return {
'question': process_markdown(self.question), 'tally': detail,
'total': total, 'feedback': process_markdown(self.feedback),
}
def clean_tally(self): def clean_tally(self):
""" """
...@@ -79,23 +166,18 @@ class PollBlock(XBlock): ...@@ -79,23 +166,18 @@ class PollBlock(XBlock):
if key not in answers: if key not in answers:
del self.tally[key] del self.tally[key]
def any_image(self):
"""
Find out if any answer has an image, since it affects layout.
"""
return any(value['img'] for value in dict(self.answers).values())
def tally_detail(self): def tally_detail(self):
""" """
Tally all results. Return a detailed dictionary from the stored tally that the
Handlebars template can use.
""" """
tally = [] tally = []
answers = OrderedDict(self.answers) answers = OrderedDict(self.markdown_items(self.answers))
choice = self.get_choice() choice = self.get_choice()
total = 0 total = 0
self.clean_tally() self.clean_tally()
source_tally = self.tally source_tally = self.tally
any_img = self.any_image() any_img = self.any_image(self.answers)
for key, value in answers.items(): for key, value in answers.items():
count = int(source_tally[key]) count = int(source_tally[key])
tally.append({ tally.append({
...@@ -103,7 +185,7 @@ class PollBlock(XBlock): ...@@ -103,7 +185,7 @@ class PollBlock(XBlock):
'answer': value['label'], 'answer': value['label'],
'img': value['img'], 'img': value['img'],
'key': key, 'key': key,
'top': False, 'first': False,
'choice': False, 'choice': False,
'last': False, 'last': False,
'any_img': any_img, 'any_img': any_img,
...@@ -111,10 +193,10 @@ class PollBlock(XBlock): ...@@ -111,10 +193,10 @@ class PollBlock(XBlock):
total += count total += count
for answer in tally: for answer in tally:
try:
answer['percent'] = int(answer['count'] / float(total)) * 100
if answer['key'] == choice: if answer['key'] == choice:
answer['choice'] = True answer['choice'] = True
try:
answer['percent'] = round(answer['count'] / float(total) * 100)
except ZeroDivisionError: except ZeroDivisionError:
answer['percent'] = 0 answer['percent'] = 0
...@@ -122,8 +204,8 @@ class PollBlock(XBlock): ...@@ -122,8 +204,8 @@ class PollBlock(XBlock):
# This should always be true, but on the off chance there are # This should always be true, but on the off chance there are
# no answers... # no answers...
if tally: if tally:
# Mark the top item to make things easier for Handlebars. # Mark the first and last items to make things easier for Handlebars.
tally[0]['top'] = True tally[0]['first'] = True
tally[-1]['last'] = True tally[-1]['last'] = True
return tally, total return tally, total
...@@ -138,18 +220,6 @@ class PollBlock(XBlock): ...@@ -138,18 +220,6 @@ class PollBlock(XBlock):
else: else:
return None return None
def create_fragment(self, context, template, css, js, js_init):
html = Template(
self.resource_string(template)).render(Context(context))
frag = Fragment(html)
frag.add_javascript_url(
self.runtime.local_resource_url(
self, 'public/js/vendor/handlebars.js'))
frag.add_css(self.resource_string(css))
frag.add_javascript(self.resource_string(js))
frag.initialize_js(js_init)
return frag
def student_view(self, context=None): def student_view(self, context=None):
""" """
The primary view of the PollBlock, shown to students The primary view of the PollBlock, shown to students
...@@ -158,126 +228,73 @@ class PollBlock(XBlock): ...@@ -158,126 +228,73 @@ class PollBlock(XBlock):
if not context: if not context:
context = {} context = {}
js_template = self.resource_string( js_template = self.resource_string(
'/public/handlebars/results.handlebars') '/public/handlebars/poll_results.handlebars')
choice = self.get_choice() choice = self.get_choice()
context.update({ context.update({
'choice': choice, 'choice': choice,
# Offset so choices will always be True. # Offset so choices will always be True.
'answers': self.answers, 'answers': self.markdown_items(self.answers),
'question': process_markdown(self.question), 'question': markdown(self.question),
# Mustache is treating an empty string as true. # Mustache is treating an empty string as true.
'feedback': process_markdown(self.feedback) or False, 'feedback': markdown(self.feedback) or False,
'js_template': js_template, 'js_template': js_template,
'any_img': self.any_image(), 'any_img': self.any_image(self.answers),
# The SDK doesn't set url_name. # The SDK doesn't set url_name.
'url_name': getattr(self, 'url_name', ''), 'url_name': getattr(self, 'url_name', ''),
"display_name": self.display_name,
}) })
if self.choice: if self.choice:
detail, total = self.tally_detail() detail, total = self.tally_detail()
context.update({'tally': detail, 'total': total}) context.update({'tally': detail, 'total': total, 'plural': total > 1})
return self.create_fragment( return self.create_fragment(
context, "public/html/poll.html", "public/css/poll.css", context, "public/html/poll.html", "public/css/poll.css",
"public/js/poll.js", "PollBlock") "public/js/poll.js", "PollBlock")
@XBlock.json_handler
def load_answers(self, data, suffix=''):
return {
'answers': [
{
'key': key, 'text': value['label'], 'img': value['img']
}
for key, value in self.answers
]
}
def studio_view(self, context=None): def studio_view(self, context=None):
if not context: if not context:
context = {} context = {}
js_template = self.resource_string('/public/handlebars/studio.handlebars') js_template = self.resource_string('/public/handlebars/poll_studio.handlebars')
context.update({ context.update({
'question': self.question, 'question': self.question,
'display_name': self.display_name,
'feedback': self.feedback, 'feedback': self.feedback,
'js_template': js_template 'js_template': js_template
}) })
return self.create_fragment( return self.create_fragment(
context, "public/html/poll_edit.html", context, "public/html/poll_edit.html",
"/public/css/poll_edit.css", "public/js/poll_edit.js", "PollEditBlock") "/public/css/poll_edit.css", "public/js/poll_edit.js", "PollEdit")
@XBlock.json_handler @XBlock.json_handler
def studio_submit(self, data, suffix=''): def load_answers(self, data, suffix=''):
# I wonder if there's something for live validation feedback already. return {
'items': [
result = {'success': True, 'errors': []} {
question = data.get('question', '').strip()[:MAX_PARAGRAPH_LEN] 'key': key, 'text': value['label'], 'img': value['img'],
feedback = data.get('feedback', '').strip()[:MAX_PARAGRAPH_LEN] 'noun': 'answer', 'image': True,
if not question: }
result['errors'].append("You must specify a question.") for key, value in self.answers
result['success'] = False ],
}
answers = []
if 'answers' not in data or not isinstance(data['answers'], list):
source_answers = []
result['success'] = False
result['errors'].append(
"'answers' is not present, or not a JSON array.")
else:
source_answers = data['answers']
# Set a reasonable limit to the number of answers in a poll.
if len(source_answers) > MAX_ANSWERS:
result['success'] = False
result['errors'].append("")
# Make sure all components are present and clean them.
for answer in source_answers:
if not isinstance(answer, dict):
result['success'] = False
result['errors'].append(
"Answer {0} not a javascript object!".format(answer))
continue
key = answer.get('key', '').strip()
if not key:
result['success'] = False
result['errors'].append(
"Answer {0} contains no key.".format(answer))
if len(key) > MAX_KEY_LEN:
result['success'] = False
result['errors'].append("Key '{0}' too long.".format(key))
img = answer.get('img', '').strip()[:MAX_URL_LEN]
label = answer.get('label', '').strip()[:MAX_ANSWER_LEN]
if not (img or label):
result['success'] = False
result['errors'].append(
"Answer {0} has no text or img. One is needed.")
answers.append((key, {'label': label, 'img': img}))
if not len(answers) > 1:
result['errors'].append(
"You must include at least two answers.")
result['success'] = False
if not result['success']:
return result
self.answers = answers
self.question = question
self.feedback = feedback
# Tally will not be updated until the next attempt to use it, per
# scoping limitations.
return result @XBlock.json_handler
def get_results(self, data, suffix=''):
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),
'plural': total > 1, 'display_name': self.display_name,
}
@XBlock.json_handler @XBlock.json_handler
def vote(self, data, suffix=''): def vote(self, data, suffix=''):
""" """
An example handler, which increments the data. Sets the user's vote.
""" """
result = {'success': False, 'errors': []} result = {'success': False, 'errors': []}
if self.get_choice() is not None: if self.get_choice() is not None:
...@@ -298,10 +315,38 @@ class PollBlock(XBlock): ...@@ -298,10 +315,38 @@ class PollBlock(XBlock):
self.clean_tally() self.clean_tally()
self.choice = choice self.choice = choice
self.tally[choice] = self.tally.get(choice, 0) + 1 self.tally[choice] = self.tally.get(choice, 0) + 1
# Let the LMS know the user has answered the poll.
self.runtime.publish(self, 'progress', {})
result['success'] = True result['success'] = True
self.send_vote_event({'choice': self.choice})
return result
@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()
display_name = data.get('display_name', '').strip()
if not question:
result['errors'].append("You must specify a question.")
result['success'] = False
if not result['success']:
return result
answers = self.gather_items(data, result, 'Answer', 'answers')
self.answers = answers
self.question = question
self.feedback = feedback
self.display_name = display_name
# Tally will not be updated until the next attempt to use it, per
# scoping limitations.
return result return result
@staticmethod @staticmethod
...@@ -319,10 +364,318 @@ class PollBlock(XBlock): ...@@ -319,10 +364,318 @@ class PollBlock(XBlock):
("Customized Poll", ("Customized Poll",
""" """
<vertical_demo> <vertical_demo>
<poll url_name="poll_functions" question="## How long have you been studying with us?" <poll tally="{'long': 20, 'short': 29, 'not_saying': 15, 'longer' : 35}"
feedback="### Thank you&#10;&#10;for being a valued student." question="## How long have you been studying with us?"
tally="{'long': 20, 'short': 29, 'not_saying': 15, 'longer' : 35}" answers='[["longt", {"label": "A very long time", "img": null}], ["short", {"label": "Not very long", "img": null}], ["not_saying", {"label": "I shall not say", "img": null}], ["longer", {"label": "Longer than you", "img": null}]]'
answers="[['long', 'A very long time'], ['short', 'Not very long'], ['not_saying', 'I shall not say'], ['longer', 'Longer than you']]"/> feedback="### Thank you&#10;&#10;for being a valued student."/>
</vertical_demo> </vertical_demo>
"""), """),
] ]
class SurveyBlock(PollBase):
display_name = String(default='Survey')
answers = List(
default=(
('Y', 'Yes'), ('N', 'No'),
('M', 'Maybe')),
scope=Scope.settings, help="Answer choices for this Survey"
)
questions = List(
default=(
('enjoy', {'label': 'Are you enjoying the course?', 'img': None}),
('recommend', {'label': 'Would you recommend this course to your friends?', 'img': None}),
('learn', {'label': 'Do you think you will learn a lot?', 'img': None})
),
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},
'learn': {'Y': 0, 'N': 0, 'M': 0}},
scope=Scope.user_state_summary,
help="Total tally of answers from students."
)
choices = Dict(help="The user's answers", scope=Scope.user_state)
event_namespace = 'xblock.survey'
def student_view(self, context=None):
"""
The primary view of the PollBlock, shown to students
when viewing courses.
"""
if not context:
context = {}
js_template = self.resource_string(
'/public/handlebars/survey_results.handlebars')
choices = self.get_choices()
context.update({
'choices': choices,
# Offset so choices will always be True.
'answers': self.answers,
'js_template': js_template,
'questions': self.markdown_items(self.questions),
'any_img': self.any_image(self.questions),
# Mustache is treating an empty string as true.
'feedback': markdown(self.feedback) or False,
# The SDK doesn't set url_name.
'url_name': getattr(self, 'url_name', ''),
"display_name": self.display_name,
})
return self.create_fragment(
context, "public/html/survey.html", "public/css/poll.css",
"public/js/poll.js", "SurveyBlock")
def studio_view(self, context=None):
if not context:
context = {}
js_template = self.resource_string('/public/handlebars/poll_studio.handlebars')
context.update({
'feedback': self.feedback,
'display_name': self.display_name,
'js_template': js_template,
'multiquestion': True,
})
return self.create_fragment(
context, "public/html/poll_edit.html",
"/public/css/poll_edit.css", "public/js/poll_edit.js", "SurveyEdit")
def tally_detail(self):
"""
Return a detailed dictionary from the stored tally that the
Handlebars template can use.
"""
tally = []
questions = OrderedDict(self.markdown_items(self.questions))
default_answers = OrderedDict([(answer, 0) for answer, __ in self.answers])
choices = self.choices
total = 0
self.clean_tally()
source_tally = self.tally
# The result should always be the same-- just grab the first one.
for key, value in source_tally.items():
total = sum(value.values())
break
for key, value in questions.items():
# Order matters here.
answer_set = OrderedDict(default_answers)
answer_set.update(source_tally[key])
tally.append({
'label': value['label'],
'img': value['img'],
'answers': [
{
'count': count, 'choice': False,
'key': answer_key, 'top': False,
}
for answer_key, count in answer_set.items()],
'key': key,
'choice': False,
})
for question in tally:
highest = 0
top_index = None
for index, answer in enumerate(question['answers']):
if answer['key'] == choices[question['key']]:
answer['choice'] = True
# Find the most popular choice.
if answer['count'] > highest:
top_index = index
highest = answer['count']
try:
answer['percent'] = round(answer['count'] / float(total) * 100)
except ZeroDivisionError:
answer['percent'] = 0
question['answers'][top_index]['top'] = True
return tally, total
def clean_tally(self):
"""
Cleans the tally. Scoping prevents us from modifying this in the studio
and in the LMS the way we want to without undesirable side effects. So
we just clean it up on first access within the LMS, in case the studio
has made changes to the answers.
"""
questions = OrderedDict(self.questions)
answers = OrderedDict(self.answers)
default_answers = {answer: 0 for answer in answers.keys()}
for key in questions.keys():
if key not in self.tally:
self.tally[key] = dict(default_answers)
else:
# Answers may have changed, requiring an update for each
# question.
new_answers = dict(default_answers)
new_answers.update(self.tally[key])
for existing_key in self.tally[key]:
if existing_key not in default_answers:
del new_answers[existing_key]
self.tally[key] = new_answers
def remove_vote(self):
"""
If the poll has changed after a user has voted, remove their votes
from the tally.
This can only be done lazily-- once a user revisits, since we can't
edit the tally in the studio due to scoping issues.
This means a user's old votes may still count indefinitely after a
change, should they never revisit.
"""
questions = dict(self.questions)
answers = dict(self.answers)
for key, value in self.choices:
if key in questions:
if value in answers:
self.tally[key][value] -= 1
self.choices = None
self.save()
def get_choices(self):
"""
Gets the user's choices, if they're still valid.
"""
questions = dict(self.questions)
answers = dict(self.answers)
if self.choices is None:
return None
if sorted(questions.keys()) != sorted(self.choices.keys()):
self.remove_vote()
return None
for value in self.choices.values():
if value not in answers:
self.remove_vote()
return None
return self.choices
@XBlock.json_handler
def get_results(self, data, suffix=''):
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()],
'tally': detail, 'total': total, 'feedback': markdown(self.feedback),
'plural': total > 1, 'display_name': self.display_name,
}
@XBlock.json_handler
def load_answers(self, data, suffix=''):
return {
'items': [
{
'key': key, 'text': value,
'noun': 'answer', 'image': False,
}
for key, value in self.answers
],
}
@XBlock.json_handler
def load_questions(self, data, suffix=''):
return {
'items': [
{
'key': key, 'text': value['label'], 'img': value['img'],
'noun': 'question', 'image': True,
}
for key, value in self.questions
]
}
@XBlock.json_handler
def vote(self, data, suffix=''):
questions = dict(self.questions)
answers = dict(self.answers)
result = {'success': True, 'errors': []}
choices = self.get_choices()
if choices:
result['success'] = False
result['errors'].append("You have already voted in this poll.")
# 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()):
result['success'] = False
result['errors'].append(
"Not all questions were included, or unknown questions were "
"included. Try refreshing and trying again."
)
# Make sure the answer values are sane.
for key, value in data.items():
if value not in answers.keys():
result['success'] = False
result['errors'].append(
"Found unknown answer '%s' for question key '%s'" % (key, value))
if not result['success']:
return result
# Record the vote!
self.choices = data
self.clean_tally()
for key, value in self.choices.items():
self.tally[key][value] += 1
self.send_vote_event({'choices': self.choices})
return result
@XBlock.json_handler
def studio_submit(self, data, suffix=''):
# I wonder if there's something for live validation feedback already.
result = {'success': True, 'errors': []}
feedback = data.get('feedback', '').strip()
display_name = data.get('display_name', '').strip()
answers = self.gather_items(data, result, 'Answer', 'answers', image=False)
questions = self.gather_items(data, result, 'Question', 'questions')
if not result['success']:
return result
self.answers = answers
self.questions = questions
self.feedback = feedback
self.display_name = display_name
# Tally will not be updated until the next attempt to use it, per
# scoping limitations.
return result
@staticmethod
def workbench_scenarios():
"""
Canned scenarios for display in the workbench.
"""
return [
("Default Survey",
"""
<vertical_demo>
<survey />
</vertical_demo>
"""),
("Survey Functions",
"""
<vertical_demo>
<survey tally='{"q1": {"sa": 5, "a": 5, "n": 3, "d": 2, "sd": 5}, "q2": {"sa": 3, "a": 2, "n": 3, "d": 10, "sd": 2}, "q3": {"sa": 2, "a": 7, "n": 1, "d": 4, "sd": 6}, "q4": {"sa": 1, "a": 2, "n": 8, "d": 4, "sd": 5}}'
questions='[["q1", {"label": "I feel like this test will pass.", "img": null}], ["q2", {"label": "I like testing software", "img": null}], ["q3", {"label": "Testing is not necessary", "img": null}], ["q4", {"label": "I would fake a test result to get software deployed.", "img": null}]]'
answers='[["sa", "Strongly Agree"], ["a", "Agree"], ["n", "Neutral"], ["d", "Disagree"], ["sd", "Strongly Disagree"]]'
feedback="### Thank you&#10;&#10;for running the tests."/>
</vertical_demo>
""")
]
...@@ -61,6 +61,7 @@ li.poll-result { ...@@ -61,6 +61,7 @@ li.poll-result {
display: inline-block; display: inline-block;
margin-bottom: 5px; margin-bottom: 5px;
margin-top: 5px; margin-top: 5px;
white-space: nowrap;
} }
.poll-image { .poll-image {
...@@ -78,10 +79,18 @@ li.poll-result .poll-image { ...@@ -78,10 +79,18 @@ li.poll-result .poll-image {
margin-left: 0; margin-left: 0;
} }
.poll-image img{ .poll-image-td {
width: 25%;
}
.poll-image img, .poll-image-td img{
width: 100%; width: 100%;
} }
.poll-image-td{
display: inline-block;
}
.poll-percent-container { .poll-percent-container {
display: table-cell; display: table-cell;
text-align: left; text-align: left;
...@@ -102,3 +111,86 @@ li.poll-result .poll-image { ...@@ -102,3 +111,86 @@ li.poll-result .poll-image {
margin-bottom: 1em; margin-bottom: 1em;
font-size: smaller; font-size: smaller;
} }
.survey-table {
width: 100%;
border-collapse: collapse;
border: 0;
}
.survey-table td, .survey-table th {
border: 1px solid #CCC;
border-top: 0;
}
.survey-table tr:last-child td {
border-bottom: 0;
}
.survey-table tr td:first-child,
.survey-table tr th:first-child {
border-left: 0;
}
.survey-table tr td:last-child,
.survey-table tr th:last-child {
border-right: 0;
}
.survey-table thead {
border-top: 0;
background: none;
}
.survey-table thead tr th {
font-weight: normal;
font-size: .8rem;
}
.survey-table .survey-row {
background: none !important;
}
.survey-table .survey-option {
text-align: center;
vertical-align: middle;
}
.survey-option input {
margin: 0;
padding: 0;
}
th.survey-answer {
text-align: center;
width: 7%;
line-height: 1em;
padding-bottom: .25em;
}
.poll-header {
text-transform: uppercase;
}
.survey-percentage {
font-weight: bolder;
font-size: 1.2rem;
}
.survey-question {
font-weight: bold;
vertical-align: middle;
padding-top: .15em;
padding-bottom: .15em;
}
.survey-choice {
background-color: #e5ebee;
}
/* Counteract Markdown's wrapping in paragraphs */
.poll-answer p, .survey-question p, .poll-answer-label p{
margin: 0;
padding: 0;
}
...@@ -38,3 +38,7 @@ ...@@ -38,3 +38,7 @@
.poll-move { .poll-move {
float: right; float: right;
} }
.poll-setting-label {
text-transform: capitalize;
}
<script id="results" type="text/html"> <script id="poll-results-template" type="text/html">
{{{question}}} <h2 class="poll-header">{{display_name}}</h2>
<div class="poll-question-container">{{{question}}}</div>
<ul class="poll-answers-results"> <ul class="poll-answers-results">
{{#each tally}} {{#each tally}}
<li class="poll-result"> <li class="poll-result">
...@@ -17,11 +18,11 @@ ...@@ -17,11 +18,11 @@
{{/if}} {{/if}}
<div class="percentage-gauge-container"> <div class="percentage-gauge-container">
<div class="percentage-gauge" style="width:{{percent}}%;"> <div class="percentage-gauge" style="width:{{percent}}%;">
<label class="poll-answer-label" for="answer-{{key}}">{{answer}}</label> <label class="poll-answer-label" for="answer-{{key}}">{{{answer}}}</label>
</div> </div>
</div> </div>
<div class="poll-percent-container"> <div class="poll-percent-container">
<span class="poll-percent-display{{#if top}} poll-top-choice{{/if}}">{{percent}}%</span> <span class="poll-percent-display{{#if first}} poll-top-choice{{/if}}">{{percent}}%</span>
</div> </div>
</li> </li>
{{^last}} {{^last}}
...@@ -31,9 +32,10 @@ ...@@ -31,9 +32,10 @@
{{/each}} {{/each}}
</ul> </ul>
<input class="input-main" type="button" name="poll-submit" value="Submit" disabled> <input class="input-main" type="button" name="poll-submit" value="Submit" disabled>
<div class="poll-footnote">Results gathered from {{total}} respondent(s).</div> <div class="poll-footnote">Results gathered from {{total}} respondent{{#if plural}}s{{/if}}.</div>
{{#if feedback}} {{#if feedback}}
<hr /> <hr />
<h2 class="poll-header">Feedback</h2>
<div class="poll-feedback"> <div class="poll-feedback">
{{{feedback}}} {{{feedback}}}
</div> </div>
......
<script id="answer-form-component" type="text/html"> <script id="poll-form-component" type="text/html">
{{#each answers}} {{#each items}}
<li class="field comp-setting-entry is-set"> <li class="field comp-setting-entry is-set poll-{{noun}}-studio-item">
<div class="wrapper-comp-setting"> <div class="wrapper-comp-setting">
<label class="label setting-label" for="answer-{{key}}">Answer</label> <label class="label setting-label poll-setting-label" for="{{noun}}-{{key}}">{{noun}}</label>
<input class="input setting-input" name="answer-{{key}}" id="answer-{{key}}" value="{{text}}" type="text" /><br /> <input class="input setting-input" name="{{noun}}-{{key}}" id="{{noun}}-{{key}}" value="{{text}}" type="text" /><br />
<label class="label setting-label" for="img-answer-{{key}}">Image URL</label> {{#if image}}
<input class="input setting-input" name="img-answer-{{key}}" id="img-answer-{{key}}" value="{{img}}" type="text" /> <label class="label setting-label" for="img-{{noun}}-{{key}}">Image URL</label>
<input class="input setting-input" name="img-{{noun}}-{{key}}" id="img-{{noun}}-{{key}}" value="{{img}}" type="text" />
{{/if}}
<div class="poll-move"> <div class="poll-move">
<div class="poll-move-up">&#9650;</div> <div class="poll-move-up">&#9650;</div>
<div class="poll-move-down">&#9660;</div> <div class="poll-move-down">&#9660;</div>
</div> </div>
</div> </div>
<span class="tip setting-help"> <span class="tip setting-help">
Enter an answer for the user to select. An answer must have an image URL or text, and can have both. {{#if image}}This must have an image URL or text, and can have both.{{/if}}
(Text truncated at 250 characters, Image URL at 1000)
</span> </span>
<a href="#" class="button action-button poll-delete-answer" onclick="return false;">Delete</a> <a href="#" class="button action-button poll-delete-answer" onclick="return false;">Delete</a>
</li> </li>
......
<script id="survey-results-template" type="text/html">
<h3 class="poll-header">{{display_name}}</h3>
<table class="survey-table">
<thead>
<tr>
<th></th>
{{#each answers}}
<th class="survey-answer">{{{this}}}</th>
{{/each}}
</tr>
</thead>
{{#each tally}}
<tr class="survey-row">
<td class="survey-question">
{{#if img}}
<div class="poll-image-td">
<img src="{{img}}" />
</div>
{{/if}}
{{{label}}}
</td>
{{#each answers}}
<td class="survey-percentage survey-option{{#if choice}} survey-choice{{/if}}{{#if top}} poll-top-choice{{/if}}">{{percent}}%</td>
{{/each}}
</tr>
{{/each}}
</table>
<input class="input-main" type="button" name="poll-submit" value="Submit" disabled>
<div class="poll-footnote">Results gathered from {{total}} respondent{{#if plural}}s{{/if}}.</div>
{{#if feedback}}
<hr />
<h2 class="poll-header">Feedback</h2>
<div class="poll-feedback">
{{{feedback}}}
</div>
{{/if}}
</script>
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
<div class="poll-block"> <div class="poll-block">
{# If no form is present, the Javascript will load the results instead. #} {# If no form is present, the Javascript will load the results instead. #}
{% if not choice %} {% if not choice %}
<h2 class="poll-header">{{display_name}}</h2>
<form> <form>
<div class="poll-question-container"> <div class="poll-question-container">
{{question|safe}} {{question|safe}}
...@@ -19,7 +20,7 @@ ...@@ -19,7 +20,7 @@
</label> </label>
</div> </div>
{% endif %} {% endif %}
<label class="poll-answer" for="{{url_name}}-answer-{{key}}">{{value.label}}</label> <label class="poll-answer" for="{{url_name}}-answer-{{key}}">{{value.label|safe}}</label>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
......
...@@ -3,13 +3,21 @@ ...@@ -3,13 +3,21 @@
<form id="poll-form"> <form id="poll-form">
<ul class="list-input settings-list" id="poll-line-items"> <ul class="list-input settings-list" id="poll-line-items">
<li class="field comp-setting-entry is-set"> <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>
<input class="input setting-input" name="display_name" id="poll-display-name" value="{{ display_name }}" type="text" />
</div>
</li>
{% if not multiquestion %}
<li class="field comp-setting-entry is-set">
<h2><label for="poll-question-editor">Question/Prompt</label></h2> <h2><label for="poll-question-editor">Question/Prompt</label></h2>
<a href="//daringfireball.net/projects/markdown/syntax" target="_blank">Markdown Syntax</a> is supported. <a href="//daringfireball.net/projects/markdown/syntax" target="_blank">Markdown Syntax</a> is supported.
<div id="poll-question-editor-container"> <div id="poll-question-editor-container">
<textarea class="input setting-input" name="question" id="poll-question-editor">{{question}}</textarea> <textarea class="input setting-input" name="question" id="poll-question-editor">{{question}}</textarea>
</div> </div>
<span class="tip setting-help">Enter the prompt for the user. (Truncated after 5000 characters)</span> <span class="tip setting-help">Enter the prompt for the user.</span>
</li> </li>
{% endif %}
<li class="field comp-setting-entry is-set"> <li class="field comp-setting-entry is-set">
<h2><label for="poll-feedback-editor">Feedback</label></h2> <h2><label for="poll-feedback-editor">Feedback</label></h2>
<a href="//daringfireball.net/projects/markdown/syntax" target="_blank">Markdown Syntax</a> is supported. <a href="//daringfireball.net/projects/markdown/syntax" target="_blank">Markdown Syntax</a> is supported.
...@@ -18,7 +26,7 @@ ...@@ -18,7 +26,7 @@
</div> </div>
<span class="tip setting-help"> <span class="tip setting-help">
This text will be displayed for the user as some extra feedback after they have This text will be displayed for the user as some extra feedback after they have
submitted their response to the poll. (Truncated after 5000 characters) submitted their response to the poll.
</span> </span>
</li> </li>
<li class="field comp-setting-entry is-set"> <li class="field comp-setting-entry is-set">
...@@ -29,13 +37,29 @@ ...@@ -29,13 +37,29 @@
If you delete an answer, any votes for that answer will also be deleted. Students whose choices are deleted If you delete an answer, any votes for that answer will also be deleted. Students whose choices are deleted
may vote again, but will not lose course progress. may vote again, but will not lose course progress.
</p> </p>
{% if multiquestion %}
<p>
Questions must be similarly cared for. If a question's text is changed, any votes for that question will remain.
If a question is deleted, any student who previously took the survey will be permitted to retake it, but will not
lose course progress.
</p>
{% endif %}
</li> </li>
<li id="poll-answer-marker"></li>
<li id="poll-answer-end-marker"></li>
<li id="poll-question-marker"></li>
<li id="poll-question-end-marker"></li>
</ul> </ul>
<div class="xblock-actions"> <div class="xblock-actions">
<ul> <ul>
<li class="action-item" id="poll-add-answer"> <li class="action-item" id="poll-add-answer">
<a href="#" class="button action-button" class="poll-add-answer-link" onclick="return false;">Add Answer</a> <a href="#" class="button action-button" class="poll-add-item-link" onclick="return false;">Add Answer</a>
</li>
{% if multiquestion %}
<li class="action-item" id="poll-add-question">
<a href="#" class="button action-button" class="poll-add-item-link" onclick="return false;">Add Question</a>
</li> </li>
{% endif %}
<li class="action-item"> <li class="action-item">
<input id="poll-submit-options" type="submit" class="button action-primary save-button" value="Save" onclick="return false;" /> <input id="poll-submit-options" type="submit" class="button action-primary save-button" value="Save" onclick="return false;" />
</li> </li>
......
{{ js_template|safe }}
<div class="poll-block">
{# If no form is present, the Javascript will load the results instead. #}
{% if not choices %}
<h3 class="poll-header">{{display_name}}</h3>
<form>
<table class="survey-table">
<thead>
<tr>
<th></th>
{% for answer, label in answers %}
<th class="survey-answer">{{label}}</th>
{% endfor %}
</tr>
</thead>
{% for key, question in questions %}
<tr class="survey-row">
<td class="survey-question">
{% if question.img %}
<div class="poll-image-td">
<img src="{{question.img}}" />
</div>
{% endif %}
{{question.label|safe}}
</td>
{% for answer, answer_details in answers %}
<td class="survey-option">
<input type="radio" name="{{key}}" value="{{answer}}" />
</td>
{% endfor %}
</tr>
{% endfor %}
</table>
<input class="input-main" type="button" name="poll-submit" value="Submit" disabled />
</form>
{% endif %}
</div>
/* Javascript for PollBlock. */ /* Javascript for PollBlock. */
function PollBlock(runtime, element) {
var voteUrl = runtime.handlerUrl(element, 'vote'); function PollUtil (runtime, element, pollType) {
var tallyURL = runtime.handlerUrl(element, 'get_results'); var self = this;
var submit = $('input[type=button]', element); this.init = function() {
var resultsTemplate = Handlebars.compile($("#results", element).html()); // Initialization function used for both Poll Types
function getResults(data) { this.voteUrl = runtime.handlerUrl(element, 'vote');
if (! data['success']) { this.tallyURL = runtime.handlerUrl(element, 'get_results');
alert(data['errors'].join('\n')); this.submit = $('input[type=button]', element);
} this.answers = $('input[type=radio]', element);
$.ajax({ this.resultsTemplate = Handlebars.compile($("#" + pollType + "-results-template", element).html());
// Semantically, this would be better as GET, but we can use helper // If the submit button doesn't exist, the user has already
// functions with POST. // selected a choice. Render results instead of initializing machinery.
type: "POST", if (! self.submit.length) {
url: tallyURL, self.getResults({'success': true});
data: JSON.stringify({}), return false;
success: function (data) {
$('div.poll-block', element).html(resultsTemplate(data));
}
})
} }
return true;
};
this.pollInit = function(){
// Initialization function for PollBlocks.
function enableSubmit() {
submit.removeAttr("disabled");
answers.unbind("change.EnableSubmit");
}
// If the submit button doesn't exist, the user has already
// selected a choice.
if (submit.length) {
var radio = $('input[name=choice]:checked', element); var radio = $('input[name=choice]:checked', element);
submit.click(function (event) { self.submit.click(function () {
// Refresh. // Refresh.
radio = $(radio.selector, element); radio = $(radio.selector, element);
var choice = radio.val(); var choice = radio.val();
$.ajax({ $.ajax({
type: "POST", type: "POST",
url: voteUrl, url: self.voteUrl,
data: JSON.stringify({"choice": choice}), data: JSON.stringify({"choice": choice}),
success: getResults success: self.getResults
}); });
}); });
// If the user has refreshed the page, they may still have an answer // If the user has refreshed the page, they may still have an answer
// selected and the submit button should be enabled. // selected and the submit button should be enabled.
var answers = $('input[type=radio]', element); var answers = $('input[type=radio]', element);
if (! radio.val()) { if (! radio.val()) {
answers.bind("change.EnableSubmit", enableSubmit); answers.bind("change.enableSubmit", self.enableSubmit);
} else { } else {
enableSubmit(); self.enableSubmit();
} }
} else { };
getResults({'success': true});
this.surveyInit = function () {
// Initialization function for Survey Blocks
self.answers.bind("change.enableSubmit", self.verifyAll);
self.submit.click(function () {
$.ajax({
type: "POST",
url: self.voteUrl,
data: JSON.stringify(self.surveyChoices()),
success: self.getResults
})
});
// If the user has refreshed the page, they may still have an answer
// selected and the submit button should be enabled.
self.verifyAll();
};
this.surveyChoices = function () {
// Grabs all selections for survey answers, and returns a mapping for them.
var choices = {};
self.answers.each(function(index, el) {
el = $(el);
choices[el.prop('name')] = $(self.checkedElement(el)).val();
});
return choices;
};
this.checkedElement = function (el) {
// Given the DOM element of a radio, get the selector for the checked element
// with the same name.
return "input[name='" + el.prop('name') + "']:checked"
};
this.verifyAll = function () {
// Verify that all questions have an answer selected.
var doEnable = true;
self.answers.each(function (index, el) {
if (! $(self.checkedElement($(el)), element).length) {
doEnable = false;
return false
}
});
if (doEnable){
self.enableSubmit();
}
};
this.getResults = function (data) {
// Fetch the results from the server and render them.
if (!data['success']) {
alert(data['errors'].join('\n'));
}
$.ajax({
// Semantically, this would be better as GET, but we can use helper
// functions with POST.
type: "POST",
url: self.tallyURL,
data: JSON.stringify({}),
success: function (data) {
console.log(self);
$('div.poll-block', element).html(self.resultsTemplate(data));
}
})
};
this.enableSubmit = function () {
// Enable the submit button.
self.submit.removeAttr("disabled");
self.answers.unbind("change.enableSubmit");
};
var run_init = this.init();
if (run_init) {
var init_map = {'poll': self.pollInit, 'survey': self.surveyInit};
init_map[pollType]()
} }
}
function PollBlock(runtime, element) {
new PollUtil(runtime, element, 'poll');
}
function SurveyBlock(runtime, element) {
new PollUtil(runtime, element, 'survey');
} }
function PollEditBlock(runtime, element) {
var loadAnswers = runtime.handlerUrl(element, 'load_answers');
var temp = $('#answer-form-component', element).html();
var answerTemplate = Handlebars.compile(temp);
var pollLineItems =$('#poll-line-items', element);
function empowerDeletes(scope) { function PollEditUtil(runtime, element, pollType) {
var self = this;
// These URLs aren't validated in real time, so even if they don't exist for a type of block
// we can create a reference to them.
this.loadAnswers = runtime.handlerUrl(element, 'load_answers');
this.loadQuestions = runtime.handlerUrl(element, 'load_questions');
this.init = function () {
// Set up the editing form for a Poll or Survey.
var temp = $('#poll-form-component', element).html();
self.answerTemplate = Handlebars.compile(temp);
$(element).find('.cancel-button', element).bind('click', function() {
runtime.notify('cancel', {});
});
var button_mapping = self.mappings[pollType]['buttons'];
for (var key in button_mapping) {
if (button_mapping.hasOwnProperty(key)) {
$(key, element).click(
// The nature of the closure forces us to make a custom function here.
function (context_key) {
return function () {
// The degree of precision on date should be precise enough to avoid
// collisions in the real world.
var bottom = $(button_mapping[context_key]['bottomMarker']);
var new_item = $(self.answerTemplate(button_mapping[context_key]['itemList']));
bottom.before(new_item);
self.empowerDeletes(new_item);
self.empowerArrows(
new_item, button_mapping[context_key]['topMarker'],
button_mapping[context_key]['bottomMarker']
);
self.scrollTo(new_item);
new_item.fadeOut(0).fadeIn('slow', 'swing');
}
}(key)
)
}
}
$(element).find('.save-button', element).bind('click', self.pollSubmitHandler);
var mapping = self.mappings[pollType]['onLoad'];
for (var task in mapping) {
function load (taskItem){
$(function ($) {
$.ajax({
type: "POST",
url: taskItem['url'],
data: JSON.stringify({}),
success: taskItem['function']
});
});
}
load(mapping[task]);
}
};
this.scrollTo = function (item){
// Scrolls to the center of a particular item in the settings, then flash it.
var parent = $('#poll-line-items');
var item_center = parent.scrollTop() + item.position().top - parent.height()/2 + item.height() / 2;
parent.animate({ scrollTop: item_center }, "slow");
};
this.extend = function (obj1, obj2) {
// Mimics similar extend functions, making obj1 contain obj2's properties.
for (var attrname in obj2) {
if (obj2.hasOwnProperty(attrname)) {
obj1[attrname] = obj2[attrname]
}
}
return obj1;
};
this.makeNew = function(extra){
// Make a new empty line item, like a question or an answer.
// 'extra' should contain 'image', a boolean value that determines whether
// an image path field should be provided, and 'noun', which should be either
// 'question' or 'answer' depending on what is needed.
return self.extend({'key': new Date().getTime(), 'text': '', 'img': ''}, extra)
};
this.empowerDeletes = function (scope) {
// Activates the delete buttons on rendered line items.
$('.poll-delete-answer', scope).click(function () { $('.poll-delete-answer', scope).click(function () {
$(this).parent().remove(); $(this).parent().remove();
}); });
} };
/* this.empowerArrows = function(scope, topMarker, bottomMarker) {
The poll answers need to be reorderable. As the UL they are in is not // Activates the arrows on rendered line items.
easily isolated, we need to start checking their position to make
sure they aren't ordered above the other settings, which are also
in the list.
*/
var starting_point = 3;
function empowerArrows(scope) {
$('.poll-move-up', scope).click(function () { $('.poll-move-up', scope).click(function () {
var tag = $(this).parents('li'); var tag = $(this).parents('li');
if (tag.index() <= starting_point){ if (tag.index() <= ($(topMarker).index() + 1)){
return; return;
} }
tag.prev().before(tag); tag.prev().before(tag);
tag.fadeOut("fast", "swing").fadeIn("fast", "swing"); tag.fadeOut(0).fadeIn('slow', 'swing');
self.scrollTo(tag)
}); });
$('.poll-move-down', scope).click(function () { $('.poll-move-down', scope).click(function () {
var tag = $(this).parents('li'); var tag = $(this).parents('li');
if ((tag.index() >= (tag.parent().children().length - 1))) { if ((tag.index() >= ($(bottomMarker).index() - 1))) {
return; return;
} }
tag.next().after(tag); tag.next().after(tag);
tag.fadeOut("fast", "swing").fadeIn("fast", "swing"); tag.fadeOut(0).fadeIn('slow', 'swing');
self.scrollTo(tag)
}); });
} };
this.displayAnswers = function (data){
self.displayItems(data, '#poll-answer-marker', '#poll-answer-end-marker')
};
this.displayQuestions = function (data) {
self.displayItems(data, "#poll-question-marker", '#poll-question-end-marker')
};
function displayAnswers(data) { // This object is used to swap out values which differ between Survey and Poll blocks.
pollLineItems.append(answerTemplate(data)); this.mappings = {
empowerDeletes(element); 'poll': {
empowerArrows(element); 'buttons': {
'#poll-add-answer': {
'itemList': {'items': [self.makeNew({'image': true, 'noun': 'answer'})]},
'topMarker': '#poll-answer-marker', 'bottomMarker': '#poll-answer-end-marker'
} }
},
'onLoad': [{'url': self.loadAnswers, 'function': self.displayAnswers}],
'gather': [{'prefix': 'answer', 'field': 'answers'}]
},
'survey': {
'buttons': {
'#poll-add-answer': {
'itemList': {'items': [self.makeNew({'image': false, 'noun': 'answer'})]},
'topMarker': '#poll-answer-marker', 'bottomMarker': '#poll-answer-end-marker'
},
'#poll-add-question': {
'itemList': {'items': [self.makeNew({'image': true, 'noun': 'question'})]},
'topMarker': '#poll-question-marker', 'bottomMarker': '#poll-question-end-marker'
}
},
'onLoad': [
{'url': self.loadQuestions, 'function': self.displayQuestions},
{'url': self.loadAnswers, 'function': self.displayAnswers}
],
'gather': [{'prefix': 'answer', 'field': 'answers'}, {'prefix': 'question', 'field': 'questions'}]
}
};
$('#poll-add-answer', element).click(function () { this.displayItems = function(data, topMarker, bottomMarker) {
// The degree of precision on date should be precise enough to avoid // Loads the initial set of items that the block needs to edit.
// collisions in the real world. var result = $(self.answerTemplate(data));
pollLineItems.append(answerTemplate({'answers': [{'key': new Date().getTime(), 'text': ''}]})); $(bottomMarker).before(result);
var new_answer = $(pollLineItems.children().last()); self.empowerDeletes(result, topMarker, bottomMarker);
empowerDeletes(new_answer); self.empowerArrows(result, topMarker, bottomMarker);
empowerArrows(new_answer); };
new_answer.fadeOut(250).fadeIn(250);
});
$(element).find('.cancel-button', element).bind('click', function() { this.checkReturn = function(data) {
runtime.notify('cancel', {}); // Handle the return value JSON from the server.
}); // It would be better if we could have a different function
// for errors, as AJAX calls normally allow, but our version of XBlock
// does not support status codes other than 200 for JSON encoded
// responses.
if (data['success']) {
window.location.reload(false);
return;
}
alert(data['errors'].join('\n'));
};
$(element).find('.save-button', element).bind('click', function() { this.gather = function (scope, tracker, data, prefix, field) {
var handlerUrl = runtime.handlerUrl(element, 'studio_submit');
var data = {'answers': []};
var tracker = [];
$('#poll-form input', element).each(function(i) {
var key = 'label'; var key = 'label';
if (this.name.indexOf('answer-') >= 0){ var name = scope.name.replace(prefix + '-', '');
var name = this.name.replace('answer-', ''); if (scope.name.indexOf('img-') == 0){
if (this.name.indexOf('img-') == 0){
name = name.replace('img-', ''); name = name.replace('img-', '');
key = 'img' key = 'img'
} }
if (! (scope.name.indexOf(prefix + '-') >= 0)) {
return
}
if (tracker.indexOf(name) == -1){ if (tracker.indexOf(name) == -1){
tracker.push(name); tracker.push(name);
data['answers'].push({'key': name}) data[field].push({'key': name})
} }
var index = tracker.indexOf(name); var index = tracker.indexOf(name);
data['answers'][index][key] = this.value; data[field][index][key] = scope.value;
return return true
} };
data[this.name] = this.value
this.pollSubmitHandler = function () {
// Take all of the fields, serialize them, and pass them to the
// server for saving.
var handlerUrl = runtime.handlerUrl(element, 'studio_submit');
var data = {};
var tracker;
var gatherings = self.mappings[pollType]['gather'];
for (var gathering in gatherings) {
tracker = [];
var field = gatherings[gathering]['field'];
var prefix = gatherings[gathering]['prefix'];
data[field] = [];
$('#poll-form input', element).each(function () {
self.gather(this, tracker, data, prefix, field)
}); });
data['title'] = $('#poll-title', element).val(); }
data['display_name'] = $('#poll-display-name', element).val();
data['question'] = $('#poll-question-editor', element).val(); data['question'] = $('#poll-question-editor', element).val();
data['feedback'] = $('#poll-feedback-editor', element).val(); data['feedback'] = $('#poll-feedback-editor', element).val();
function check_return(data) {
if (data['success']) {
window.location.reload(false);
return;
}
alert(data['errors'].join('\n'));
}
$.ajax({ $.ajax({
type: "POST", type: "POST",
url: handlerUrl, url: handlerUrl,
data: JSON.stringify(data), data: JSON.stringify(data),
success: check_return success: self.checkReturn
});
}); });
};
$(function ($) { self.init();
$.ajax({ }
type: "POST",
url: loadAnswers, function PollEdit(runtime, element) {
data: JSON.stringify({}), new PollEditUtil(runtime, element, 'poll');
success: displayAnswers }
});
}); function SurveyEdit(runtime, element) {
new PollEditUtil(runtime, element, 'survey');
} }
-e . -e .
bleach
markdown markdown
-e git+https://github.com/edx-solutions/xblock-utils.git#egg=xblock-utils -e git+https://github.com/edx-solutions/xblock-utils.git#egg=xblock-utils
-e git://github.com/nosedjango/nosedjango.git@ed7d7f9aa969252ff799ec159f828eaa8c1cbc5a#egg=nosedjango-dev
ddt
...@@ -22,7 +22,7 @@ def package_data(pkg, roots): ...@@ -22,7 +22,7 @@ def package_data(pkg, roots):
setup( setup(
name='xblock-poll', name='xblock-poll',
version='0.1', version='0.2',
description='An XBlock for polling users.', description='An XBlock for polling users.',
packages=[ packages=[
'poll', 'poll',
...@@ -30,13 +30,13 @@ setup( ...@@ -30,13 +30,13 @@ setup(
install_requires=[ install_requires=[
'XBlock', 'XBlock',
'markdown', 'markdown',
'bleach',
'xblock-utils', 'xblock-utils',
], ],
dependency_links=['http://github.com/edx-solutions/xblock-utils/tarball/master#egg=xblock-utils'], dependency_links=['http://github.com/edx-solutions/xblock-utils/tarball/master#egg=xblock-utils'],
entry_points={ entry_points={
'xblock.v1': [ 'xblock.v1': [
'poll = poll:PollBlock', 'poll = poll:PollBlock',
'survey = poll:SurveyBlock',
] ]
}, },
package_data=package_data("poll", ["static", "public"]), package_data=package_data("poll", ["static", "public"]),
......
"""
Contains a list of lists that will be used as the DDT arguments for the markdown test.
"""
ddt_scenarios = [
[
"Poll Markdown", '.poll-question-container',
"""<h2>This is a test</h2>
<h1>This is only a &gt;&lt;test</h1>
<ul>
<li>One</li>
<li>Two</li>
<li>
<p>Three</p>
</li>
<li>
<p>First</p>
</li>
<li>Second</li>
<li>Third</li>
</ul>
<p>We shall find out if markdown is respected.</p>
<blockquote>
<p>"I have not yet begun to code."</p>
</blockquote>"""
],
[
"Poll Markdown", '.poll-feedback',
"""<h3>This is some feedback</h3>
<p><a href="http://www.example.com">This is a link</a></p>
<p><a href="http://www.example.com" target="_blank">This is also a link.</a></p>
<p>This is a paragraph with <em>emphasized</em> and <strong>bold</strong> text, and <strong><em>both</em></strong>.</p>""",
False
],
[
"Poll Markdown", "label.poll-answer", "<p>I <em>feel</em> like this test will <strong>pass</strong><code>test</code>.</p>",
True, False
],
[
"Poll Markdown", "label.poll-answer-label", "<p>I <em>feel</em> like this test will <strong>pass</strong><code>test</code>.</p>",
False, True
],
[
"Survey Markdown", '.survey-question', "<p>I <em>feel</em> like this test will <strong>pass</strong><code>test</code>.</p>"
],
[
"Survey Markdown", '.poll-feedback',
"""<h3>This is some feedback</h3>
<p><a href="http://www.example.com">This is a link</a></p>
<p><a href="http://www.example.com" target="_blank">This is also a link.</a></p>
<p>This is a paragraph with <em>emphasized</em> and <strong>bold</strong> text, and <strong><em>both</em></strong>.</p>""",
False
],
]
...@@ -7,9 +7,9 @@ from selenium.common.exceptions import NoSuchElementException ...@@ -7,9 +7,9 @@ from selenium.common.exceptions import NoSuchElementException
from .base_test import PollBaseTest from .base_test import PollBaseTest
class TestDefaults(PollBaseTest): class TestDefault(PollBaseTest):
""" """
Tests to run against the default poll. Tests to run against the default XBlock configurations.
""" """
def test_default_poll(self): def test_default_poll(self):
""" """
...@@ -17,7 +17,7 @@ class TestDefaults(PollBaseTest): ...@@ -17,7 +17,7 @@ class TestDefaults(PollBaseTest):
the tally displays afterward. Verifies that the feedback section does the tally displays afterward. Verifies that the feedback section does
not load since it is not enabled by default. not load since it is not enabled by default.
""" """
self.go_to_page('Defaults') self.go_to_page('Poll Defaults')
button = self.browser.find_element_by_css_selector('input[type=radio]') button = self.browser.find_element_by_css_selector('input[type=radio]')
button.click() button.click()
submit = self.get_submit() submit = self.get_submit()
...@@ -30,3 +30,29 @@ class TestDefaults(PollBaseTest): ...@@ -30,3 +30,29 @@ class TestDefaults(PollBaseTest):
# No feedback section. # No feedback section.
self.assertRaises(NoSuchElementException, self.browser.find_element_by_css_selector, '.poll-feedback') self.assertRaises(NoSuchElementException, self.browser.find_element_by_css_selector, '.poll-feedback')
def test_default_survey(self):
"""
Verifies that a default survey loads, that it can be voted on, and
that the tally displays afterward. Verifies that the feedback section
does not load since it is not enabled by default.
"""
self.go_to_page('Survey Defaults')
names = ['enjoy', 'recommend', 'learn']
# Select the first answer for each.
for name in names:
self.browser.find_element_by_css_selector('input[name="%s"]' % name).click()
submit = self.get_submit()
submit.click()
self.wait_until_exists('.survey-percentage')
# Should now be on the results page.
for element in self.browser.find_elements_by_css_selector('table > tr'):
# First element is question, second is first answer result.
self.assertEqual(element.find_elements_by_css_selector('td')[1].text, '100%')
# No feedback section.
self.assertRaises(NoSuchElementException, self.browser.find_element_by_css_selector, '.poll-feedback')
...@@ -50,15 +50,13 @@ class TestPollFunctions(PollBaseTest): ...@@ -50,15 +50,13 @@ class TestPollFunctions(PollBaseTest):
self.get_submit().click() self.get_submit().click()
# Not a good way to wait here, since all the elements we care about self.wait_until_exists('.poll-feedback')
# tracking don't exist yet.
time.sleep(1)
self.assertTrue(self.browser.find_element_by_css_selector('.poll-feedback').text, self.assertTrue(self.browser.find_element_by_css_selector('.poll-feedback').text,
"Thank you\nfor being a valued student.") "Thank you\nfor being a valued student.")
self.assertEqual(self.browser.find_element_by_css_selector('.poll-footnote').text, self.assertEqual(self.browser.find_element_by_css_selector('.poll-footnote').text,
'Results gathered from 100 respondent(s).') 'Results gathered from 100 respondents.')
self.assertFalse(self.browser.find_element_by_css_selector('input[name=poll-submit]').is_enabled()) self.assertFalse(self.browser.find_element_by_css_selector('input[name=poll-submit]').is_enabled())
...@@ -75,7 +73,133 @@ class TestPollFunctions(PollBaseTest): ...@@ -75,7 +73,133 @@ class TestPollFunctions(PollBaseTest):
self.get_submit().click() self.get_submit().click()
# Button will be reaplaced with a new disabled copy, not just disabled. # Button will be replaced with a new disabled copy, not just disabled.
self.wait_until_exists('input[name=poll-submit]:disabled')
self.go_to_page('Poll Functions')
self.assertFalse(self.get_submit().is_enabled())
class TestSurveyFunctions(PollBaseTest):
@staticmethod
def chunk_list(chunkable, max_size):
"""
Subdivides a list into several smaller lists.
"""
result = []
in_list = False
for index, item in enumerate(chunkable, start=1):
if not in_list:
result.append([])
in_list = True
result[-1].append(item)
if not index % max_size:
in_list = False
return result
def test_first_load(self):
"""
Checks the first load of the survey.
Verifies that the poll loads with the expected questions,
that the answers are shown in the expected order, that feedback is
not showing, and that the submit button is disabled.
"""
self.go_to_page('Survey Functions')
self.assertEqual(
[element.text for element in self.browser.find_elements_by_css_selector('.survey-question')],
[
"I feel like this test will pass.", "I like testing software", "Testing is not necessary",
"I would fake a test result to get software deployed."
]
)
self.assertEqual(
[element.text for element in self.browser.find_elements_by_css_selector('.survey-answer')],
[
"Strongly Agree", "Agree", "Neutral", "Disagree", "Strongly Disagree"
]
)
self.assertRaises(NoSuchElementException, self.browser.find_element_by_css_selector, '.poll-feedback')
submit_button = self.get_submit()
self.assertFalse(submit_button.is_enabled())
def fill_survey(self, assert_submit=False):
"""
Fills out the survey. Optionally checks if the submit button is
in the right state along the way.
"""
elements = self.browser.find_elements_by_css_selector('.survey-option input[type=radio]')
# Answers should be in sets of five.
questions = self.chunk_list(elements, 5)
# Disabled to start...
submit_button = self.get_submit()
if assert_submit:
self.assertFalse(submit_button.is_enabled())
# Strongly Agree: I feel like this test will pass.
questions[0][0].click()
if assert_submit:
self.assertFalse(submit_button.is_enabled())
# Disagree: Testing is not necessary
questions[2][3].click()
if assert_submit:
self.assertFalse(submit_button.is_enabled())
# Agree: I like testing software
questions[1][1].click()
if assert_submit:
self.assertFalse(submit_button.is_enabled())
# Strongly Disagree: I would fake a test result to get software deployed.
questions[3][4].click()
if assert_submit:
# Submit button should now be enabled!
self.assertTrue(submit_button.is_enabled())
def test_submit_enabled(self):
"""
Verify that the submit button is enabled only when every question
has an answer.
"""
self.go_to_page('Survey Functions')
self.fill_survey(assert_submit=True)
def test_survey_submission(self):
"""
Verify that the user can submit his or her vote and that the vote counts.
Also check that feedback is displayed afterward.
"""
self.go_to_page('Survey Functions')
self.fill_survey()
self.get_submit().click()
self.wait_until_exists('.poll-feedback')
self.assertEqual(self.browser.find_element_by_css_selector('.poll-footnote').text,
'Results gathered from 21 respondents.')
self.assertTrue(self.browser.find_element_by_css_selector('.poll-feedback').text,
"Thank you\nfor running the tests.")
def test_submit_not_enabled_on_revisit(self):
"""
Verify that revisiting the page post-vote does not re-enable the submit button.
"""
self.go_to_page('Survey Functions')
self.fill_survey()
self.get_submit().click()
# Button will be replaced with a new disabled copy, not just disabled.
self.wait_until_exists('input[name=poll-submit]:disabled') self.wait_until_exists('input[name=poll-submit]:disabled')
self.go_to_page('Poll Functions') self.go_to_page('Poll Functions')
......
...@@ -16,7 +16,7 @@ class TestLayout(PollBaseTest): ...@@ -16,7 +16,7 @@ class TestLayout(PollBaseTest):
""" """
Verify img tags are created for answers when they're all set. Verify img tags are created for answers when they're all set.
""" """
self.go_to_page('All Pictures') self.go_to_page('Poll All Pictures')
pics = self.browser.find_elements_by_css_selector('.poll-image') pics = self.browser.find_elements_by_css_selector('.poll-image')
self.assertEqual(len(pics), 4) self.assertEqual(len(pics), 4)
...@@ -32,7 +32,7 @@ class TestLayout(PollBaseTest): ...@@ -32,7 +32,7 @@ class TestLayout(PollBaseTest):
""" """
Verify layout is sane when only one answer has an image. Verify layout is sane when only one answer has an image.
""" """
self.go_to_page('One Picture') self.go_to_page('Poll One Picture')
pics = self.browser.find_elements_by_css_selector('.poll-image') pics = self.browser.find_elements_by_css_selector('.poll-image')
# On the polling page, there should only be one pics div. # On the polling page, there should only be one pics div.
self.assertEqual(len(pics), 1) self.assertEqual(len(pics), 1)
......
""" """
Tests to make sure that markdown is both useful and secure. Tests to make sure that markdown is both useful and secure.
""" """
from ddt import ddt, unpack, data
from .markdown_scenarios import ddt_scenarios
from .base_test import PollBaseTest from .base_test import PollBaseTest
@ddt
class MarkdownTestCase(PollBaseTest): class MarkdownTestCase(PollBaseTest):
""" """
Tests for the Markdown functionality. Tests for the Markdown functionality.
""" """
def test_question_markdown(self):
"""
Ensure Markdown is parsed for questions.
"""
self.go_to_page("Markdown")
self.assertEqual(
self.browser.find_element_by_css_selector('.poll-question-container').text,
"""This is a test
This is only a ><test
One
Two
Three
First
Second
Third
We shall find out if markdown is respected.
"I have not yet begun to code.\"""")
def test_feedback_markdown(self): def get_selector_text(self, selector):
return self.browser.find_element_by_css_selector(selector).get_attribute('innerHTML').strip()
@data(*ddt_scenarios)
@unpack
def test_markdown(self, page, selector, result, front=True, back=True):
""" """
Ensure Markdown is parsed for feedback. Test Markdown for a field.
selector is a CSS selector to check for markdown results
result is the desired result string
front means the check will be done before the form is submitted
back means it will be done afterward.
Both are checked by default.
""" """
self.go_to_page("Markdown") self.go_to_page(page)
if front:
self.assertEqual(self.get_selector_text(selector), result)
if back:
self.browser.find_element_by_css_selector('input[type=radio]').click() self.browser.find_element_by_css_selector('input[type=radio]').click()
self.get_submit().click() self.get_submit().click()
self.wait_until_exists('.poll-feedback') self.wait_until_exists('.poll-feedback')
self.assertEqual( self.assertEqual(self.get_selector_text(selector), result)
self.browser.find_element_by_css_selector('.poll-feedback').text,
"""This is some feedback
This is a link
This is also a link.
This is a paragraph with emphasized and bold text, and both.""")
\ No newline at end of file
<vertical_demo>
<poll tally="{'red': 20, 'fennec': 29, 'kit': 15, 'arctic' : 35}"
question="## What is your favorite kind of fox?"
answers="[['red', {'label': 'Red Fox', 'img': '../img/red_fox.png'}], ['fennec', {'label': 'Fennec Fox', 'img': '../img/fennec_fox.png'}], ['kit', {'label': 'Kit Fox', 'img': '../img/kit_fox.png'}], ['arctic', {'label': 'Arctic fox', 'img': '../img/arctic_fox.png'}]]" />
</vertical_demo>
\ No newline at end of file
<vertical_demo>
<poll url_name="markdown" question="## This is a test&#10;&#10;&lt;h1&gt;This is only a &amp;gt;&amp;lt;test&lt;/h1&gt;&#10;&#10;* One&#10;* Two&#10;* Three&#10;&#10;1. First&#10;2. Second&#10;3. Third&#10;&#10;We shall find out if markdown is respected.&#10;&#10;&gt; &quot;I have not yet begun to code.&quot;" feedback="### This is some feedback&#10;&#10;[This is a link](http://www.example.com)&#10;&#10;&lt;a href=&quot;http://www.example.com&quot; target=&quot;_blank&quot;&gt;This is also a link.&lt;/a&gt;&#10;&#10;This is a paragraph with *emphasized* and **bold** text, and **_both_**." />
</vertical_demo>
\ No newline at end of file
<vertical_demo>
<poll tally='{"red": 20, "fennec": 29, "kit": 15, "arctic" : 35}'
question="## What is your favorite kind of fox?"
answers='[["red", {"label": "Red Fox", "img": "../img/red_fox.png"}], ["fennec", {"label": "Fennec Fox", "img": "../img/fennec_fox.png"}], ["kit", {"label": "Kit Fox", "img": "../img/kit_fox.png"}], ["arctic", {"label": "Arctic fox", "img": "../img/arctic_fox.png"}]]' />
</vertical_demo>
<vertical_demo> <vertical_demo>
<poll tally="{'long': 20, 'short': 29, 'not_saying': 15, 'longer' : 35}" <poll tally="{'long': 20, 'short': 29, 'not_saying': 15, 'longer' : 35}"
question="## How long have you been studying with us?" question="## How long have you been studying with us?"
answers="[['long', {'label': 'A very long time', 'img': None}], ['short', {'label': 'Not very long', 'img': None}], ['not_saying', {'label': 'I shall not say', 'img': None}], ['longer', {'label': 'Longer than you', 'img': None}]]" answers='[["long", {"label": "A very long time", "img": null}], ["short", {"label": "Not very long", "img": null}], ["not_saying", {"label": "I shall not say", "img": null}], ["longer", {"label": "Longer than you", "img": null}]]'
feedback="### Thank you&#10;&#10;for being a valued student."/> feedback="### Thank you&#10;&#10;for being a valued student."/>
</vertical_demo> </vertical_demo>
<vertical_demo>
<poll url_name="markdown" question="## This is a test&#10;&#10;&lt;h1&gt;This is only a &amp;gt;&amp;lt;test&lt;/h1&gt;&#10;&#10;* One&#10;* Two&#10;* Three&#10;&#10;1. First&#10;2. Second&#10;3. Third&#10;&#10;We shall find out if markdown is respected.&#10;&#10;&gt; &quot;I have not yet begun to code.&quot;"
feedback="### This is some feedback&#10;&#10;[This is a link](http://www.example.com)&#10;&#10;&lt;a href=&quot;http://www.example.com&quot; target=&quot;_blank&quot;&gt;This is also a link.&lt;/a&gt;&#10;&#10;This is a paragraph with *emphasized* and **bold** text, and **_both_**."
answers='[["long", {"label": "I *feel* like this test will **pass**&lt;code&gt;test&lt;/code&gt;.", "img": null}]]'/>
</vertical_demo>
<vertical_demo>
<survey url_name="defaults"/>
</vertical_demo>
<vertical_demo>
<survey tally='{"q1": {"sa": 5, "a": 5, "n": 3, "d": 2, "sd": 5}, "q2": {"sa": 3, "a": 2, "n": 3, "d": 10, "sd": 2}, "q3": {"sa": 2, "a": 7, "n": 1, "d": 4, "sd": 6}, "q4": {"sa": 1, "a": 2, "n": 8, "d": 4, "sd": 5}}'
questions='[["q1", {"label": "I feel like this test will pass.", "img": null}], ["q2", {"label": "I like testing software", "img": null}], ["q3", {"label": "Testing is not necessary", "img": null}], ["q4", {"label": "I would fake a test result to get software deployed.", "img": null}]]'
answers='[["sa", "Strongly Agree"], ["a", "Agree"], ["n", "Neutral"], ["d", "Disagree"], ["sd", "Strongly Disagree"]]'
feedback="### Thank you&#10;&#10;for running the tests."/>
</vertical_demo>
<vertical_demo>
<survey url_name="markdown" questions='[["q1", {"label": "I *feel* like this test will **pass**&lt;code&gt;test&lt;/code&gt;.", "img": null}]]' feedback="### This is some feedback&#10;&#10;[This is a link](http://www.example.com)&#10;&#10;&lt;a href=&quot;http://www.example.com&quot; target=&quot;_blank&quot;&gt;This is also a link.&lt;/a&gt;&#10;&#10;This is a paragraph with *emphasized* and **bold** text, and **_both_**." />
</vertical_demo>
from unittest import TestCase from unittest import TestCase
from poll.utils import process_markdown from markdown import markdown
class ProcessMarkdownTest(TestCase): class ProcessMarkdownTest(TestCase):
...@@ -37,6 +37,6 @@ This is a paragraph of text, despite being just one sentence. ...@@ -37,6 +37,6 @@ This is a paragraph of text, despite being just one sentence.
<blockquote> <blockquote>
<p>This is going to be a blockquote.</p> <p>This is going to be a blockquote.</p>
</blockquote> </blockquote>
&lt;script type="text/javascript"&gt;breakstuff();&lt;/script&gt;""" <script type="text/javascript">breakstuff();</script>"""
) )
self.assertEqual(end_string, process_markdown(start_string)) self.assertEqual(end_string, markdown(start_string))
\ No newline at end of file
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