Commit a8b01cd6 by Xavier Antoviaque

Merge pull request #1 from edx-solutions/freetext-mrq

Studio editing of XML for free text answer, MRQ quiz type
parents 69a546ea b3d47cb7
*~ *~
*.pyc *.pyc
xblock_mentoring.egg-info /xblock_mentoring.egg-info
workbench.sqlite /workbench.sqlite
mentoring/templates/xml /dist
dist /templates
templates
...@@ -6,7 +6,7 @@ This XBlock allows to automate the workflow of real-life mentoring, within an ed ...@@ -6,7 +6,7 @@ This XBlock allows to automate the workflow of real-life mentoring, within an ed
It supports: It supports:
* **Free-form answers** (textarea) which can be shared accross different XBlock instances (for example, to remind a student of an answer he gave before). Editable or read-only. * **Free-form answers** (textarea) which can be shared accross different XBlock instances (for example, to remind a student of an answer he gave before). Editable or read-only.
* **Self-assessment quizzes** (multiple choice), to display predetermined feedback to a student based on his choices in the self-assessment. Supports rating scales and arbitrary answers. * **Self-assessment MCQs** (multiple choice), to display predetermined feedback to a student based on his choices in the self-assessment. Supports rating scales and arbitrary answers.
* **Progression tracking**, allowing to check that the student has completed the previous steps before allowing to complete a given XBlock instance. Provides a link to the next step to the student. * **Progression tracking**, allowing to check that the student has completed the previous steps before allowing to complete a given XBlock instance. Provides a link to the next step to the student.
* **Tables**, which allow to present answers from the student to free-form answers in a concise way. Supports custom headers. * **Tables**, which allow to present answers from the student to free-form answers in a concise way. Supports custom headers.
* **Data export**, to allow course authors to download a CSV file containing the free-form answers entered by the students * **Data export**, to allow course authors to download a CSV file containing the free-form answers entered by the students
...@@ -44,12 +44,12 @@ Second XBlock instance: ...@@ -44,12 +44,12 @@ Second XBlock instance:
</mentoring> </mentoring>
``` ```
### Self-assessment quizzes ### Self-assessment MCQs
```xml ```xml
<mentoring url_name="quizz_1" enforce_dependency="false"> <mentoring url_name="mcq_1" enforce_dependency="false">
<quizz name="quizz_1_1" type="choices"> <mcq name="mcq_1_1" type="choices">
<question>Do you like this quizz?</question> <question>Do you like this MCQ?</question>
<choice value="yes">Yes</choice> <choice value="yes">Yes</choice>
<choice value="maybenot">Maybe not</choice> <choice value="maybenot">Maybe not</choice>
<choice value="understand">I don't understand</choice> <choice value="understand">I don't understand</choice>
...@@ -57,16 +57,16 @@ Second XBlock instance: ...@@ -57,16 +57,16 @@ Second XBlock instance:
<tip display="yes">Great!</tip> <tip display="yes">Great!</tip>
<tip reject="maybenot">Ah, damn.</tip> <tip reject="maybenot">Ah, damn.</tip>
<tip reject="understand"><html><div id="test-custom-html">Really?</div></html></tip> <tip reject="understand"><html><div id="test-custom-html">Really?</div></html></tip>
</quizz> </mcq>
<quizz name="quizz_1_2" type="rating" low="Not good at all" high="Extremely good"> <mcq name="mcq_1_2" type="rating" low="Not good at all" high="Extremely good">
<question>How much do you rate this quizz?</question> <question>How much do you rate this MCQ?</question>
<choice value="notwant">I don't want to rate it</choice> <choice value="notwant">I don't want to rate it</choice>
<tip display="4,5">I love good grades.</tip> <tip display="4,5">I love good grades.</tip>
<tip reject="1,2,3">Will do better next time...</tip> <tip reject="1,2,3">Will do better next time...</tip>
<tip reject="notwant">Your loss!</tip> <tip reject="notwant">Your loss!</tip>
</quizz> </MCQ>
<message type="completed"> <message type="completed">
All is good now... All is good now...
...@@ -75,6 +75,28 @@ Second XBlock instance: ...@@ -75,6 +75,28 @@ Second XBlock instance:
</mentoring> </mentoring>
``` ```
### Self-assessment MRQs
```xml
<mentoring url_name="mcq_1" enforce_dependency="false">
<mrq name="mrq_1_1" type="choices" max_attempts="3">
<question>What do you like in this MRQ?</question>
<choice value="elegance">Its elegance</choice>
<choice value="beauty">Its beauty</choice>
<choice value="gracefulness">Its gracefulness</choice>
<choice value="bugs">Its bugs</choice>
<tip require="gracefulness">This MRQ is indeed very graceful</tip>
<tip require="elegance,beauty">This is something everyone has to like about this MRQ</tip>
<tip reject="bugs">Nah, there isn't any!</tip>
<message type="on-submit">Thank you for answering!</message>
</mrq>
<message type="completed">
All is good now...
<html><p>Congratulations!</p></html>
</message>
</mentoring>
### Tables ### Tables
```xml ```xml
...@@ -116,6 +138,11 @@ directory, enter: ...@@ -116,6 +138,11 @@ directory, enter:
$ pip install -e . $ pip install -e .
``` ```
Since `XBlock` and `xblock-mentoring` are both in development, it is recommended
to use the `XBlock` revision specified in the workbench/LMS requirements.txt
file. The main `XBlock` repository is not always ready to use in edx-platform
and you might experience some issues.
Custom workbench settings Custom workbench settings
------------------------- -------------------------
...@@ -157,7 +184,7 @@ $ DJANGO_SETTINGS_MODULE="workbench.settings_mentoring" PYTHONPATH=".:/path/to/x ...@@ -157,7 +184,7 @@ $ DJANGO_SETTINGS_MODULE="workbench.settings_mentoring" PYTHONPATH=".:/path/to/x
``` ```
`/path/to/xblock` is the path to the XBlock main repository (the one containing the workbench) `/path/to/xblock` is the path to the XBlock main repository (the one containing the workbench)
Adding custom scenarios to the workbench Adding custom scenarios to the workbench
---------------------------------------- ----------------------------------------
...@@ -170,4 +197,3 @@ $ cat > templates/xml/my_mentoring_scenario.xml ...@@ -170,4 +197,3 @@ $ cat > templates/xml/my_mentoring_scenario.xml
``` ```
Restart the workbench to take the new scenarios into account. Restart the workbench to take the new scenarios into account.
from .answer import AnswerBlock from .answer import AnswerBlock
from .choice import ChoiceBlock
from .dataexport import MentoringDataExportBlock from .dataexport import MentoringDataExportBlock
from .html import HTMLBlock from .html import HTMLBlock
from .quizz import QuizzBlock, QuizzChoiceBlock, QuizzTipBlock from .mcq import MCQBlock
from .mrq import MRQBlock
from .mentoring import MentoringBlock from .mentoring import MentoringBlock
from .message import MentoringMessageBlock from .message import MentoringMessageBlock
from .table import MentoringTableBlock, MentoringTableColumnBlock, MentoringTableColumnHeaderBlock from .table import MentoringTableBlock, MentoringTableColumnBlock, MentoringTableColumnHeaderBlock
from .tip import TipBlock
...@@ -55,7 +55,7 @@ class AnswerBlock(LightChild): ...@@ -55,7 +55,7 @@ class AnswerBlock(LightChild):
@lazy @lazy
def student_input(self): def student_input(self):
""" """
Use lazy property instead of XBlock field, as __init__() doesn't support Use lazy property instead of XBlock field, as __init__() doesn't support
overwriting field values overwriting field values
""" """
# Only attempt to locate a model object for this block when the answer has a name # Only attempt to locate a model object for this block when the answer has a name
...@@ -79,7 +79,7 @@ class AnswerBlock(LightChild): ...@@ -79,7 +79,7 @@ class AnswerBlock(LightChild):
html = render_template('templates/html/answer_read_only.html', { html = render_template('templates/html/answer_read_only.html', {
'self': self, 'self': self,
}) })
fragment = Fragment(html) fragment = Fragment(html)
fragment.add_css_url(self.runtime.local_resource_url(self.xblock_container, 'public/css/answer.css')) fragment.add_css_url(self.runtime.local_resource_url(self.xblock_container, 'public/css/answer.css'))
fragment.add_javascript_url(self.runtime.local_resource_url(self.xblock_container, fragment.add_javascript_url(self.runtime.local_resource_url(self.xblock_container,
......
# -*- coding: utf-8 -*-
#
# Copyright (C) 2014 Harvard
#
# Authors:
# Xavier Antoviaque <xavier@antoviaque.org>
#
# This software's license gives you freedom; you can copy, convey,
# propagate, redistribute and/or modify this program under the terms of
# the GNU Affero General Public License (AGPL) as published by the Free
# Software Foundation (FSF), either version 3 of the License, or (at your
# option) any later version of the AGPL published by the FSF.
#
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
# General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program in a file in the toplevel directory called
# "AGPLv3". If not, see <http://www.gnu.org/licenses/>.
#
# Imports ###########################################################
import logging
from .light_children import LightChild, Scope, String
# Globals ###########################################################
log = logging.getLogger(__name__)
# Classes ###########################################################
class ChoiceBlock(LightChild):
"""
Custom choice of an answer for a MCQ/MRQ
"""
value = String(help="Value of the choice when selected", scope=Scope.content, default="")
content = String(help="Human-readable version of the choice value", scope=Scope.content, default="")
...@@ -70,8 +70,10 @@ class MentoringDataExportBlock(XBlock): ...@@ -70,8 +70,10 @@ class MentoringDataExportBlock(XBlock):
return response return response
def get_csv(self): def get_csv(self):
answers = Answer.objects.all().order_by('student_id', 'name') course_id = self.xmodule_runtime.course_id
answers_names = Answer.objects.values_list('name', flat=True).distinct().order_by('name')
answers = Answer.objects.filter(course_id=course_id).order_by('student_id', 'name')
answers_names = answers.values_list('name', flat=True).distinct().order_by('name')
# Header line # Header line
yield list2csv([u'student_id'] + list(answers_names)) yield list2csv([u'student_id'] + list(answers_names))
......
...@@ -24,6 +24,10 @@ ...@@ -24,6 +24,10 @@
# Imports ########################################################### # Imports ###########################################################
import logging import logging
import json
from lazy import lazy
from weakref import WeakKeyDictionary
from cStringIO import StringIO from cStringIO import StringIO
from lxml import etree from lxml import etree
...@@ -34,6 +38,8 @@ from xblock.core import XBlock ...@@ -34,6 +38,8 @@ from xblock.core import XBlock
from xblock.fragment import Fragment from xblock.fragment import Fragment
from xblock.plugin import Plugin from xblock.plugin import Plugin
from .models import LightChild as LightChildModel
try: try:
from xmodule_modifiers import replace_jump_to_id_urls from xmodule_modifiers import replace_jump_to_id_urls
except: except:
...@@ -73,6 +79,7 @@ class LightChildrenMixin(XBlockWithChildrenFragmentsMixin): ...@@ -73,6 +79,7 @@ class LightChildrenMixin(XBlockWithChildrenFragmentsMixin):
@classmethod @classmethod
def parse_xml(cls, node, runtime, keys, id_generator): def parse_xml(cls, node, runtime, keys, id_generator):
log.debug('parse_xml called')
block = runtime.construct_xblock_from_class(cls, keys) block = runtime.construct_xblock_from_class(cls, keys)
cls.init_block_from_node(block, node, node.items()) cls.init_block_from_node(block, node, node.items())
block.xml_content = getattr(block, 'xml_content', '') or etree.tostring(node) block.xml_content = getattr(block, 'xml_content', '') or etree.tostring(node)
...@@ -132,6 +139,7 @@ class LightChildrenMixin(XBlockWithChildrenFragmentsMixin): ...@@ -132,6 +139,7 @@ class LightChildrenMixin(XBlockWithChildrenFragmentsMixin):
""" """
Replacement for ```self.runtime.render_child()``` Replacement for ```self.runtime.render_child()```
""" """
frag = getattr(child, view_name)(context) frag = getattr(child, view_name)(context)
frag.content = u'<div class="xblock-light-child" name="{}" data-type="{}">{}</div>'.format( frag.content = u'<div class="xblock-light-child" name="{}" data-type="{}">{}</div>'.format(
child.name, child.__class__.__name__, frag.content) child.name, child.__class__.__name__, frag.content)
...@@ -166,6 +174,7 @@ class XBlockWithLightChildren(LightChildrenMixin, XBlock): ...@@ -166,6 +174,7 @@ class XBlockWithLightChildren(LightChildrenMixin, XBlock):
""" """
Current HTML view of the XBlock, for refresh by client Current HTML view of the XBlock, for refresh by client
""" """
frag = self.student_view({}) frag = self.student_view({})
frag = self.fragment_text_rewriting(frag) frag = self.fragment_text_rewriting(frag)
...@@ -193,6 +202,7 @@ class XBlockWithLightChildren(LightChildrenMixin, XBlock): ...@@ -193,6 +202,7 @@ class XBlockWithLightChildren(LightChildrenMixin, XBlock):
fragment = replace_jump_to_id_urls(course_id, jump_to_url, self, 'student_view', fragment, {}) fragment = replace_jump_to_id_urls(course_id, jump_to_url, self, 'student_view', fragment, {})
return fragment return fragment
class LightChild(Plugin, LightChildrenMixin): class LightChild(Plugin, LightChildrenMixin):
""" """
Base class for the light children Base class for the light children
...@@ -202,6 +212,7 @@ class LightChild(Plugin, LightChildrenMixin): ...@@ -202,6 +212,7 @@ class LightChild(Plugin, LightChildrenMixin):
def __init__(self, parent): def __init__(self, parent):
self.parent = parent self.parent = parent
self.xblock_container = parent.xblock_container self.xblock_container = parent.xblock_container
self._student_data_loaded = False
@property @property
def runtime(self): def runtime(self):
...@@ -219,35 +230,137 @@ class LightChild(Plugin, LightChildrenMixin): ...@@ -219,35 +230,137 @@ class LightChild(Plugin, LightChildrenMixin):
xmodule_runtime = xmodule_runtime() xmodule_runtime = xmodule_runtime()
return xmodule_runtime return xmodule_runtime
@lazy
def student_data(self):
"""
Use lazy property instead of XBlock field, as __init__() doesn't support
overwriting field values
"""
if not self.name:
return ''
student_data = self.get_lightchild_model_object().student_data
return student_data
def load_student_data(self):
"""
Load the student data from the database.
"""
if self._student_data_loaded:
return
fields = self.get_fields_to_save()
if not fields or not self.student_data:
return
student_data = json.loads(self.student_data)
for field in fields:
if field in student_data:
setattr(self, field, student_data[field])
self._student_data_loaded = True
@classmethod
def get_fields_to_save(cls):
"""
Returns a list of all LightChildField of the class. Used for saving student data.
"""
return []
def save(self): def save(self):
pass """
Replicate data changes on the related Django model used for sharing of data accross XBlocks
"""
# Save all children
for child in self.get_children_objects():
child.save()
self.student_data = {}
# Get All LightChild fields to save
for field in self.get_fields_to_save():
self.student_data[field] = getattr(self, field)
if self.name:
lightchild_data = self.get_lightchild_model_object()
if lightchild_data.student_data != self.student_data:
lightchild_data.student_data = json.dumps(self.student_data)
lightchild_data.save()
def get_lightchild_model_object(self, name=None):
"""
Fetches the LightChild model object for the lightchild named `name`
"""
if not name:
name = self.name
if not name:
raise ValueError, 'LightChild.name field need to be set to a non-null/empty value'
student_id = self.xmodule_runtime.anonymous_student_id
course_id = self.xmodule_runtime.course_id
lightchild_data, created = LightChildModel.objects.get_or_create(
student_id=student_id,
course_id=course_id,
name=name,
)
return lightchild_data
class LightChildField(object): class LightChildField(object):
""" """
Fake field with no persistence - allows to keep XBlocks fields definitions on LightChild Fake field with no persistence - allows to keep XBlocks fields definitions on LightChild
""" """
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.value = kwargs.get('default', '') self.default = kwargs.get('default', '')
self.data = WeakKeyDictionary()
def __get__(self, instance, name):
# A LightChildField can depend on student_data
instance.load_student_data()
def __nonzero__(self): return self.data.get(instance, self.default)
return bool(self.value)
def __set__(self, instance, value):
self.data[instance] = value
class String(LightChildField): class String(LightChildField):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.value = kwargs.get('default', '') or '' super(String, self).__init__(*args, **kwargs)
self.default = kwargs.get('default', '') or ''
def __str__(self): # def split(self, *args, **kwargs):
return self.value # return self.value.split(*args, **kwargs)
def split(self, *args, **kwargs):
return self.value.split(*args, **kwargs) class Integer(LightChildField):
def __init__(self, *args, **kwargs):
super(Integer, self).__init__(*args, **kwargs)
self.default = kwargs.get('default', 0)
def __set__(self, instance, value):
try:
self.data[instance] = int(value)
except (TypeError, ValueError): # not an integer
self.data[instance] = 0
class Boolean(LightChildField): class Boolean(LightChildField):
pass pass
class List(LightChildField):
def __init__(self, *args, **kwargs):
super(List, self).__init__(*args, **kwargs)
self.default = kwargs.get('default', [])
class Scope(object): class Scope(object):
content = None content = None
user_state = None user_state = None
# -*- coding: utf-8 -*-
#
# Copyright (C) 2014 Harvard
#
# Authors:
# Xavier Antoviaque <xavier@antoviaque.org>
#
# This software's license gives you freedom; you can copy, convey,
# propagate, redistribute and/or modify this program under the terms of
# the GNU Affero General Public License (AGPL) as published by the Free
# Software Foundation (FSF), either version 3 of the License, or (at your
# option) any later version of the AGPL published by the FSF.
#
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
# General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program in a file in the toplevel directory called
# "AGPLv3". If not, see <http://www.gnu.org/licenses/>.
#
# Imports ###########################################################
import logging
from .light_children import Scope, String
from .questionnaire import QuestionnaireAbstractBlock
from .utils import render_template
# Globals ###########################################################
log = logging.getLogger(__name__)
# Classes ###########################################################
class MCQBlock(QuestionnaireAbstractBlock):
"""
An XBlock used to ask multiple-choice questions
"""
type = String(help="Type of MCQ", scope=Scope.content, default="choices")
student_choice = String(help="Last input submitted by the student", default="", scope=Scope.user_state)
low = String(help="Label for low ratings", scope=Scope.content, default="Less")
high = String(help="Label for high ratings", scope=Scope.content, default="More")
valid_types = ('rating', 'choices')
def submit(self, submission):
log.debug(u'Received MCQ submission: "%s"', submission)
completed = True
tips_fragments = []
for tip in self.get_tips():
completed = completed and self.is_tip_completed(tip, submission)
if submission in tip.display_with_defaults:
tips_fragments.append(tip.render())
formatted_tips = render_template('templates/html/tip_question_group.html', {
'self': self,
'tips_fragments': tips_fragments,
'submission': submission,
'submission_display': self.get_submission_display(submission),
})
self.student_choice = submission
result = {
'submission': submission,
'completed': completed,
'tips': formatted_tips,
}
log.debug(u'MCQ submission result: %s', result)
return result
def is_tip_completed(self, tip, submission):
if not submission:
return False
if submission in tip.reject_with_defaults:
return False
return True
...@@ -24,9 +24,14 @@ ...@@ -24,9 +24,14 @@
# Imports ########################################################### # Imports ###########################################################
import logging import logging
import uuid
from lxml import etree
from StringIO import StringIO
from xblock.core import XBlock from xblock.core import XBlock
from xblock.fields import Boolean, Scope, String from xblock.fields import Boolean, Scope, String
from xblock.fragment import Fragment
from .light_children import XBlockWithLightChildren from .light_children import XBlockWithLightChildren
from .message import MentoringMessageBlock from .message import MentoringMessageBlock
...@@ -44,8 +49,8 @@ class MentoringBlock(XBlockWithLightChildren): ...@@ -44,8 +49,8 @@ class MentoringBlock(XBlockWithLightChildren):
""" """
An XBlock providing mentoring capabilities An XBlock providing mentoring capabilities
Composed of text, answers input fields, and a set of multiple choice quizzes with advices. Composed of text, answers input fields, and a set of MRQ/MCQ with advices.
A set of conditions on the provided answers and quizzes choices will determine if the A set of conditions on the provided answers and MCQ/MRQ choices will determine if the
student is a) provided mentoring advices and asked to alter his answer, or b) is given the student is a) provided mentoring advices and asked to alter his answer, or b) is given the
ok to continue. ok to continue.
""" """
...@@ -58,20 +63,17 @@ class MentoringBlock(XBlockWithLightChildren): ...@@ -58,20 +63,17 @@ class MentoringBlock(XBlockWithLightChildren):
followed_by = String(help="url_name of the step after the current mentoring block in workflow", followed_by = String(help="url_name of the step after the current mentoring block in workflow",
default=None, scope=Scope.content) default=None, scope=Scope.content)
url_name = String(help="Name of the current step, used for URL building", url_name = String(help="Name of the current step, used for URL building",
default='mentoring', scope=Scope.content) default='mentoring-default', scope=Scope.content)
enforce_dependency = Boolean(help="Should the next step be the current block to complete?", enforce_dependency = Boolean(help="Should the next step be the current block to complete?",
default=True, scope=Scope.content) default=False, scope=Scope.content)
display_submit = Boolean(help="Allow to submit current block?", default=True, scope=Scope.content) display_submit = Boolean(help="Allow to submit current block?", default=True, scope=Scope.content)
xml_content = String(help="XML content", default='', scope=Scope.content) xml_content = String(help="XML content", default='', scope=Scope.content)
has_children = True
icon_class = 'problem' icon_class = 'problem'
def student_view(self, context): def student_view(self, context):
fragment, named_children = self.get_children_fragment(context, view_name='mentoring_view', fragment, named_children = self.get_children_fragment(context, view_name='mentoring_view',
not_instance_of=MentoringMessageBlock) not_instance_of=MentoringMessageBlock)
correct_icon_url = self.runtime.local_resource_url(self, 'public/img/correct-icon.png')
fragment.add_content(render_template('templates/html/mentoring.html', { fragment.add_content(render_template('templates/html/mentoring.html', {
'self': self, 'self': self,
'named_children': named_children, 'named_children': named_children,
...@@ -81,9 +83,8 @@ class MentoringBlock(XBlockWithLightChildren): ...@@ -81,9 +83,8 @@ class MentoringBlock(XBlockWithLightChildren):
fragment.add_javascript_url( fragment.add_javascript_url(
self.runtime.local_resource_url(self, 'public/js/vendor/underscore-min.js')) self.runtime.local_resource_url(self, 'public/js/vendor/underscore-min.js'))
fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/mentoring.js')) fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/mentoring.js'))
fragment.add_resource(load_resource('templates/html/mentoring_progress.html').format( fragment.add_resource(load_resource('templates/html/mentoring_progress.html'), "text/html")
completed=correct_icon_url), fragment.add_resource(load_resource('templates/html/mrqblock_attempts.html'), "text/html")
"text/html")
fragment.initialize_js('MentoringBlock') fragment.initialize_js('MentoringBlock')
...@@ -135,11 +136,11 @@ class MentoringBlock(XBlockWithLightChildren): ...@@ -135,11 +136,11 @@ class MentoringBlock(XBlockWithLightChildren):
elif completed and self.next_step == self.url_name: elif completed and self.next_step == self.url_name:
self.next_step = self.followed_by self.next_step = self.followed_by
log.warn(submit_results);
self.completed = bool(completed) self.completed = bool(completed)
return { return {
'submitResults': submit_results, 'submitResults': submit_results,
'completed': self.completed, 'completed': self.completed,
'attempted': self.attempted,
'message': message, 'message': message,
} }
...@@ -156,6 +157,64 @@ class MentoringBlock(XBlockWithLightChildren): ...@@ -156,6 +157,64 @@ class MentoringBlock(XBlockWithLightChildren):
else: else:
return '' return ''
def studio_view(self, context):
"""
Editing view in Studio
"""
fragment = Fragment()
fragment.add_content(render_template('templates/html/mentoring_edit.html', {
'self': self,
'xml_content': self.xml_content or self.default_xml_content,
}))
fragment.add_javascript(load_resource('public/js/mentoring_edit.js'))
fragment.add_css(load_resource('public/css/mentoring_edit.css'))
fragment.initialize_js('MentoringEditBlock')
return fragment
@XBlock.json_handler
def studio_submit(self, submissions, suffix=''):
log.info(u'Received studio submissions: {}'.format(submissions))
xml_content = submissions['xml_content']
try:
etree.parse(StringIO(xml_content))
except etree.XMLSyntaxError as e:
response = {
'result': 'error',
'message': e.message
}
else:
response = {
'result': 'success',
}
self.xml_content = xml_content
log.debug(u'Response from Studio: {}'.format(response))
return response
@property
def default_xml_content(self):
return render_template('templates/xml/mentoring_default.xml', {
'self': self,
'url_name': self.url_name_with_default,
})
@property
def url_name_with_default(self):
"""
Ensure the `url_name` is set to a unique, non-empty value.
This should ideally be handled by Studio, but we need to declare the attribute
to be able to use it from the workbench, and when this happen Studio doesn't set
a unique default value - this property gives either the set value, or if none is set
a randomized default value
"""
if self.url_name == 'mentoring-default':
return 'mentoring-{}'.format(uuid.uuid4())
else:
return self.url_name
@staticmethod @staticmethod
def workbench_scenarios(): def workbench_scenarios():
""" """
......
...@@ -43,4 +43,4 @@ class Migration(SchemaMigration): ...@@ -43,4 +43,4 @@ class Migration(SchemaMigration):
} }
} }
complete_apps = ['mentoring'] complete_apps = ['mentoring']
\ No newline at end of file
# -*- coding: utf-8 -*-
import datetime
from south.db import db
from south.v2 import SchemaMigration
from django.db import models
class Migration(SchemaMigration):
def forwards(self, orm):
# Adding model 'LightChild'
db.create_table('mentoring_lightchild', (
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
('name', self.gf('django.db.models.fields.CharField')(max_length=50, db_index=True)),
('student_id', self.gf('django.db.models.fields.CharField')(max_length=32, db_index=True)),
('course_id', self.gf('django.db.models.fields.CharField')(max_length=50, db_index=True)),
('student_data', self.gf('django.db.models.fields.TextField')(default='', blank=True)),
('created_on', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)),
('modified_on', self.gf('django.db.models.fields.DateTimeField')(auto_now=True, blank=True)),
))
db.send_create_signal('mentoring', ['LightChild'])
# Adding unique constraint on 'LightChild', fields ['student_id', 'course_id', 'name']
db.create_unique('mentoring_lightchild', ['student_id', 'course_id', 'name'])
def backwards(self, orm):
# Removing unique constraint on 'LightChild', fields ['student_id', 'course_id', 'name']
db.delete_unique('mentoring_lightchild', ['student_id', 'course_id', 'name'])
# Deleting model 'LightChild'
db.delete_table('mentoring_lightchild')
models = {
'mentoring.answer': {
'Meta': {'unique_together': "(('student_id', 'course_id', 'name'),)", 'object_name': 'Answer'},
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}),
'created_on': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'modified_on': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}),
'student_id': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}),
'student_input': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'})
},
'mentoring.lightchild': {
'Meta': {'unique_together': "(('student_id', 'course_id', 'name'),)", 'object_name': 'LightChild'},
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}),
'created_on': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
'student_data': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'modified_on': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}),
'student_id': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'})
}
}
complete_apps = ['mentoring']
...@@ -49,3 +49,21 @@ class Answer(models.Model): ...@@ -49,3 +49,21 @@ class Answer(models.Model):
# Force validation of max_length # Force validation of max_length
self.full_clean() self.full_clean()
super(Answer, self).save(*args, **kwargs) super(Answer, self).save(*args, **kwargs)
class LightChild(models.Model):
"""
Django model used to store LightChild student data that need to be shared and queried accross
XBlock instances (workaround). Since this is temporary, `data` are stored in json.
"""
class Meta:
app_label = 'mentoring'
unique_together = (('student_id', 'course_id', 'name'),)
name = models.CharField(max_length=50, db_index=True)
student_id = models.CharField(max_length=32, db_index=True)
course_id = models.CharField(max_length=50, db_index=True)
student_data = models.TextField(blank=True, default='')
created_on = models.DateTimeField('created on', auto_now_add=True)
modified_on = models.DateTimeField('modified on', auto_now=True)
# -*- coding: utf-8 -*-
#
# Copyright (C) 2014 edX
#
# Authors:
# Xavier Antoviaque <xavier@antoviaque.org>
#
# This software's license gives you freedom; you can copy, convey,
# propagate, redistribute and/or modify this program under the terms of
# the GNU Affero General Public License (AGPL) as published by the Free
# Software Foundation (FSF), either version 3 of the License, or (at your
# option) any later version of the AGPL published by the FSF.
#
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
# General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program in a file in the toplevel directory called
# "AGPLv3". If not, see <http://www.gnu.org/licenses/>.
#
# Imports ###########################################################
import logging
from .light_children import Integer, List, Scope
from .questionnaire import QuestionnaireAbstractBlock
from .utils import render_template
# Globals ###########################################################
log = logging.getLogger(__name__)
# Classes ###########################################################
class MRQBlock(QuestionnaireAbstractBlock):
"""
An XBlock used to ask multiple-response questions
"""
student_choices = List(help="Last submissions by the student", default=[], scope=Scope.user_state)
max_attempts = Integer(help="Number of max attempts for this questions", scope=Scope.content)
num_attempts = Integer(help="Number of attempts a user has answered for this questions", scope=Scope.user_state)
# TODO REMOVE THIS, ONLY NEEDED FOR LIGHTCHILDREN
@classmethod
def get_fields_to_save(cls):
return [
'num_attempts'
]
def submit(self, submissions):
log.debug(u'Received MRQ submissions: "%s"', submissions)
completed = True
results = []
for choice in self.custom_choices:
choice_completed = True
choice_tips_fragments = []
choice_selected = choice.value in submissions
for tip in self.get_tips():
if choice.value in tip.display_with_defaults:
choice_tips_fragments.append(tip.render())
if ((not choice_selected and choice.value in tip.require_with_defaults) or
(choice_selected and choice.value in tip.reject_with_defaults)):
choice_completed = False
completed = completed and choice_completed
results.append({
'value': choice.value,
'selected': choice_selected,
'completed': choice_completed,
'tips': render_template('templates/html/tip_choice_group.html', {
'self': self,
'tips_fragments': choice_tips_fragments,
'completed': choice_completed,
}),
})
self.message = u'Your answer is correct!' if completed else u'Your answer is incorrect.'
# Do not increase the counter is the answer is correct
if not completed:
setattr(self, 'num_attempts', self.num_attempts + 1)
if self.max_attempts > 0 and self.num_attempts >= self.max_attempts:
completed = True
self.message += u' You have reached the maximum number of attempts for this question. ' \
u'Your next answers won''t be saved. You can check the answer(s) using the "Show Answer(s)" button.'
else:
self.student_choices = submissions
result = {
'submissions': submissions,
'completed': completed,
'choices': results,
'message': self.message,
'max_attempts': self.max_attempts,
'num_attempts': self.num_attempts
}
log.debug(u'MRQ submissions result: %s', result)
return result
.mentoring .answer.editable { .mentoring .answer.editable {
height: 150px; height: 250px;
width: 100%; width: 100%;
margin-bottom: 20px; margin-bottom: 20px;
} }
......
...@@ -44,3 +44,20 @@ ...@@ -44,3 +44,20 @@
.mentoring h4 { .mentoring h4 {
margin-top: 25px; margin-top: 25px;
} }
.mentoring .submit {
margin-top: 20px;
}
.mentoring .progress .indicator {
display: inline-block;
vertical-align: middle;
}
.mentoring .progress .indicator .checkmark-correct {
color: #006600;
}
.mentoring .progress .indicator .checkmark-incorrect {
color: #ff0000;
}
.mentoring-edit .module-actions .error-message {
color: red;
}
.mentoring .rating .choices-list {
margin: 5px 0 10px;
}
.mentoring .choices .choices-list {
position: relative;
padding-top: 10px;
}
.mentoring .choices .choice {
margin: 10px 0;
}
.mentoring .choices .choice-checkbox {
display: inline-block;
margin-top: 5px;
margin-bottom: 5px;
}
.mentoring .choices .choice-result {
display: inline-block;
width: 40px;
vertical-align: middle;
cursor: pointer;
}
.mentoring .choices .choice-result.correct, .choice-answer.correct {
cursor: pointer;
color: #006600;
position: relative;
top: -3px;
}
.mentoring .choices .choice-result.incorrect {
text-align:center;
color: #ff0000;
}
.mentoring .choices .choice-tips,
.mentoring .choices .choice-message {
display: none;
color: #fff;
position: absolute;
top: 0;
right: 0;
background: none repeat scroll 0 0 #5C9DD5;
font-family: arial;
font-size: 14px;
height: 100%;
opacity: 0.9;
padding: 10px;
width: 300px;
}
.mentoring .choices .choice-tips .title {
margin: 0 0 5px;
font-size: 18px;
font-family: arial;
}
.mentoring .choices .choice-tips .tip-choice-group,
.mentoring .choices .choice-message .message-content {
position: relative;
}
.mentoring .choices .choice-tips .close,
.mentoring .choices .choice-message .close {
background-image: url({{ close_icon_url }});
cursor: pointer;
position: absolute;
top: -10px;
right: -10px;
width: 18px;
height: 21px;
}
.mentoring .choices .choice-tips p {
color: #fff;
}
.mentoring .rating .choice {
margin-right: 10px;
}
.mentoring .choices-list .choice-selector {
margin-right: 5px;
}
.mentoring .mrq-attempts {
display: inline-block;
vertical-align: baseline;
color: #777;
font-style: italic;
webkit-font-smoothing: antialiased;
}
.mentoring .mrq-attempts div {
display: inline-block;
}
.mentoring .rating .question,
.mentoring .choices .question {
font-weight: bold;
}
.mentoring .rating,
.mentoring .choices {
margin-bottom: 5px;
}
.mentoring .choices .choice {
margin-right: 10px;
}
.mentoring .choices .choice .choice-selector {
margin-right: 5px;
}
...@@ -34,7 +34,8 @@ function MentoringBlock(runtime, element) { ...@@ -34,7 +34,8 @@ function MentoringBlock(runtime, element) {
callIfExists(child, 'handleSubmit', result); callIfExists(child, 'handleSubmit', result);
}); });
$('.progress', element).data('completed', results.completed ? 'True' : 'False') $('.progress', element).data('completed', results.completed ? 'True' : 'False');
$('.progress', element).data('attempted', results.attempted ? 'True' : 'False');
renderProgress(); renderProgress();
// Messages should only be displayed upon hitting 'submit', not on page reload // Messages should only be displayed upon hitting 'submit', not on page reload
...@@ -73,7 +74,7 @@ function MentoringBlock(runtime, element) { ...@@ -73,7 +74,7 @@ function MentoringBlock(runtime, element) {
} }
function initXBlock() { function initXBlock() {
var submit_dom = $(element).find('.submit'); var submit_dom = $(element).find('.submit .input-main');
submit_dom.bind('click', function() { submit_dom.bind('click', function() {
var data = {}; var data = {};
...@@ -88,6 +89,12 @@ function MentoringBlock(runtime, element) { ...@@ -88,6 +89,12 @@ function MentoringBlock(runtime, element) {
$.post(handlerUrl, JSON.stringify(data)).success(handleSubmitResults); $.post(handlerUrl, JSON.stringify(data)).success(handleSubmitResults);
}); });
// init children (especially mrq blocks)
var children = getChildren(element);
_.each(children, function(child) {
callIfExists(child, 'init');
});
if (submit_dom.length) { if (submit_dom.length) {
renderProgress(); renderProgress();
} }
......
function MentoringEditBlock(runtime, element) {
var xmlEditorTextarea = $('.block-xml-editor', element),
xmlEditor = CodeMirror.fromTextArea(xmlEditorTextarea[0], { mode: 'xml' });
$('.save-button', element).bind('click', function() {
var handlerUrl = runtime.handlerUrl(element, 'studio_submit'),
data = {
'xml_content': xmlEditor.getValue(),
};
$('.error-message', element).html();
$.post(handlerUrl, JSON.stringify(data)).done(function(response) {
if (response.result === 'success') {
window.location.reload(false);
} else {
$('.error-message', element).html('Error: '+response.message);
}
});
});
}
// TODO: Split in two files
var mrqAttemptsTemplate = _.template($('#xblock-mrq-attempts').html());
function MCQBlock(runtime, element) {
return {
submit: function() {
var checkedRadio = $('input[type=radio]:checked', element);
if(checkedRadio.length) {
return checkedRadio.val();
} else {
return null;
}
},
handleSubmit: function(result) {
var tipsDom = $(element).parent().find('.messages'),
tipHtml = (result || {}).tips || '';
if(tipHtml) {
tipsDom.append(tipHtml);
}
}
};
}
function MRQBlock(runtime, element) {
return {
renderAttempts: function() {
var data = $('.mrq-attempts', element).data();
$('.mrq-attempts', element).html(mrqAttemptsTemplate(data));
// bind show answer button
var showAnswerButton = $('button', element);
if (showAnswerButton.length != 0) {
if (_.isUndefined(this.answers))
showAnswerButton.hide();
else
showAnswerButton.on('click', _.bind(this.toggleAnswers, this));
}
},
init: function() {
this.renderAttempts();
},
submit: function() {
// hide answers
var choiceInputDOM = $('.choice input', element),
choiceResultDOM = $('.choice-answer', choiceInputDOM.closest('.choice'));
choiceResultDOM.removeClass('incorrect icon-exclamation correct icon-ok');
var checkedCheckboxes = $('input[type=checkbox]:checked', element),
checkedValues = [];
$.each(checkedCheckboxes, function(index, checkedCheckbox) {
checkedValues.push($(checkedCheckbox).val());
});
return checkedValues;
},
handleSubmit: function(result) {
var messageDOM = $('.choice-message', element),
allPopupsDOM = $('.choice-tips, .choice-message', element),
clearPopupEvents = function() {
allPopupsDOM.hide();
$('.close', allPopupsDOM).off('click');
},
showPopup = function(popupDOM) {
clearPopupEvents();
popupDOM.show();
popupDOM.on('click', function() {
clearPopupEvents();
choiceTipsDOM.hide();
});
};
if (result.message) {
messageDOM.html('<div class="message-content"><div class="close"></div>' +
result.message + '</div>');
showPopup(messageDOM);
}
var answers = []; // used in displayAnswers
$.each(result.choices, function(index, choice) {
var choiceInputDOM = $('.choice input[value='+choice.value+']', element),
choiceDOM = choiceInputDOM.closest('.choice'),
choiceResultDOM = $('.choice-result', choiceDOM),
choiceAnswerDOM = $('.choice-answer', choiceDOM),
choiceTipsDOM = $('.choice-tips', choiceDOM),
choiceTipsCloseDOM;
/* update our answers dict */
answers.push({
input: choiceInputDOM,
answer: choice.completed ? choiceInputDOM.attr('checked') : !choiceInputDOM.attr('checked')
});
choiceResultDOM.removeClass('incorrect icon-exclamation correct icon-ok');
/* show hint if checked or max_attempts is disabled */
if (result.completed || choiceInputDOM.prop('checked') || result.max_attempts <= 0) {
if (choice.completed) {
choiceResultDOM.addClass('correct icon-ok');
} else if (!choice.completed) {
choiceResultDOM.addClass('incorrect icon-exclamation');
}
}
choiceTipsDOM.html(choice.tips);
choiceTipsCloseDOM = $('.close', choiceTipsDOM);
choiceResultDOM.off('click').on('click', function() {
showPopup(choiceTipsDOM);
});
choiceAnswerDOM.off('click').on('click', function() {
showPopup(choiceTipsDOM);
});
});
this.answers = answers;
$('.mrq-attempts', element).data('num_attempts', result.num_attempts);
this.renderAttempts();
},
toggleAnswers: function() {
var showAnswerButton = $('button span', element);
var answers_displayed = this.answers_displayed = !this.answers_displayed;
_.each(this.answers, function(answer) {
var choiceResultDOM = $('.choice-answer', answer.input.closest('.choice'));
choiceResultDOM.removeClass('correct icon-ok');
if (answers_displayed) {
if (answer.answer)
choiceResultDOM.addClass('correct icon-ok');
showAnswerButton.text('Hide Answer(s)');
}
else {
showAnswerButton.text('Show Answer(s)');
}
});
}
};
}
function QuizzBlock(runtime, element) {
return {
submit: function() {
var checkedRadio = $('input[type=radio]:checked', element);
if(checkedRadio.length) {
return checkedRadio.val();
} else {
return null;
}
},
handleSubmit: function(result) {
var tipsDom = $(element).parent().find('.messages'),
tipHtml = (result || {}).tips || '';
if(tipHtml) {
tipsDom.append(tipHtml);
}
}
}
}
...@@ -27,7 +27,9 @@ import logging ...@@ -27,7 +27,9 @@ import logging
from xblock.fragment import Fragment from xblock.fragment import Fragment
from .choice import ChoiceBlock
from .light_children import LightChild, Scope, String from .light_children import LightChild, Scope, String
from .tip import TipBlock
from .utils import render_template from .utils import render_template
...@@ -36,42 +38,30 @@ from .utils import render_template ...@@ -36,42 +38,30 @@ from .utils import render_template
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# Functions #########################################################
def commas_to_list(commas_str):
"""
Converts a comma-separated string to a list
"""
if commas_str is None:
return None # Means default value (which can be non-empty)
elif commas_str == '':
return [] # Means empty list
else:
return commas_str.split(',')
# Classes ########################################################### # Classes ###########################################################
class QuizzBlock(LightChild): class QuestionnaireAbstractBlock(LightChild):
""" """
An XBlock used to ask multiple-choice questions An abstract class used for MCQ/MRQ blocks
Must be a child of a MentoringBlock. Allow to display a tip/advice depending on the Must be a child of a MentoringBlock. Allow to display a tip/advice depending on the
values entered by the student, and supports multiple types of multiple-choice values entered by the student, and supports multiple types of multiple-choice
set, with preset choices and author-defined values. set, with preset choices and author-defined values.
""" """
type = String(help="Type of questionnaire", scope=Scope.content, default="choices")
question = String(help="Question to ask the student", scope=Scope.content, default="") question = String(help="Question to ask the student", scope=Scope.content, default="")
type = String(help="Type of quizz", scope=Scope.content, default="choices") message = String(help="General feedback provided when submiting", scope=Scope.content, default="")
student_choice = String(help="Last input submitted by the student", default="", scope=Scope.user_state)
low = String(help="Label for low ratings", scope=Scope.content, default="Less") valid_types = ('choices')
high = String(help="Label for high ratings", scope=Scope.content, default="More")
@classmethod @classmethod
def init_block_from_node(cls, block, node, attr): def init_block_from_node(cls, block, node, attr):
block.light_children = [] block.light_children = []
for child_id, xml_child in enumerate(node): for child_id, xml_child in enumerate(node):
if xml_child.tag == "question": if xml_child.tag == 'question':
block.question = xml_child.text block.question = xml_child.text
elif xml_child.tag == 'message' and xml_child.get('type') == 'on-submit':
block.message = (xml_child.text or '').strip()
else: else:
cls.add_node_as_child(block, xml_child, child_id) cls.add_node_as_child(block, xml_child, child_id)
...@@ -81,122 +71,51 @@ class QuizzBlock(LightChild): ...@@ -81,122 +71,51 @@ class QuizzBlock(LightChild):
return block return block
def mentoring_view(self, context=None): def mentoring_view(self, context=None):
if str(self.type) not in ('rating', 'choices'): name = self.__class__.__name__
raise ValueError, u'Invalid value for QuizzBlock.type: `{}`'.format(self.type)
template_path = 'templates/html/quizz_{}.html'.format(self.type) if str(self.type) not in self.valid_types:
raise ValueError, u'Invalid value for {}.type: `{}`'.format(name, self.type)
template_path = 'templates/html/{}_{}.html'.format(name.lower(), self.type)
html = render_template(template_path, { html = render_template(template_path, {
'self': self, 'self': self,
'custom_choices': self.custom_choices, 'custom_choices': self.custom_choices,
}) })
fragment = Fragment(html) fragment = Fragment(html)
fragment.add_css_url(self.runtime.local_resource_url(self.xblock_container, fragment.add_css(render_template('public/css/questionnaire.css', {
'public/css/quizz.css')) 'self': self,
'close_icon_url': self.runtime.local_resource_url(self.xblock_container,
'public/img/close.png'),
}))
fragment.add_javascript_url(self.runtime.local_resource_url(self.xblock_container, fragment.add_javascript_url(self.runtime.local_resource_url(self.xblock_container,
'public/js/quizz.js')) 'public/js/questionnaire.js'))
fragment.initialize_js('QuizzBlock') fragment.initialize_js(name)
return fragment return fragment
@property @property
def custom_choices(self): def custom_choices(self):
custom_choices = [] custom_choices = []
for child in self.get_children_objects(): for child in self.get_children_objects():
if isinstance(child, QuizzChoiceBlock): if isinstance(child, ChoiceBlock):
custom_choices.append(child) custom_choices.append(child)
return custom_choices return custom_choices
def submit(self, submission):
log.debug(u'Received quizz submission: "%s"', submission)
completed = True
tips_fragments = []
for tip in self.get_tips():
completed = completed and tip.is_completed(submission)
if tip.is_tip_displayed(submission):
tips_fragments.append(tip.render(submission))
formatted_tips = render_template('templates/html/tip_group.html', {
'self': self,
'tips_fragments': tips_fragments,
'submission': submission,
'submission_display': self.get_submission_display(submission),
})
self.student_choice = submission
result = {
'submission': submission,
'completed': completed,
'tips': formatted_tips,
}
log.debug(u'Quizz submission result: %s', result)
return result
def get_submission_display(self, submission):
"""
Get the human-readable version of a submission value
"""
for choice in self.custom_choices:
if choice.value == submission:
return choice.content
return submission
def get_tips(self): def get_tips(self):
""" """
Returns the tips contained in this block Returns the tips contained in this block
""" """
tips = [] tips = []
for child in self.get_children_objects(): for child in self.get_children_objects():
if isinstance(child, QuizzTipBlock): if isinstance(child, TipBlock):
tips.append(child) tips.append(child)
return tips return tips
def get_submission_display(self, submission):
class QuizzTipBlock(LightChild):
"""
Each quizz
"""
content = String(help="Text of the tip to provide if needed", scope=Scope.content, default="")
display = String(help="List of choices to display the tip for", scope=Scope.content, default=None)
reject = String(help="List of choices to reject", scope=Scope.content, default=None)
def render(self, submission):
""" """
Returns a fragment containing the formatted tip Get the human-readable version of a submission value
""" """
fragment, named_children = self.get_children_fragment({}) for choice in self.custom_choices:
fragment.add_content(render_template('templates/html/tip.html', { if choice.value == submission:
'self': self, return choice.content
'named_children': named_children, return submission
}))
return self.xblock_container.fragment_text_rewriting(fragment)
def is_completed(self, submission):
return submission and submission not in self.reject_with_defaults
def is_tip_displayed(self, submission):
return submission in self.display_with_defaults
@property
def display_with_defaults(self):
display = commas_to_list(self.display)
if display is None:
display = self.reject_with_defaults
else:
display += [choice for choice in self.reject_with_defaults
if choice not in display]
return display
@property
def reject_with_defaults(self):
reject = commas_to_list(self.reject)
log.debug(reject)
return reject or []
class QuizzChoiceBlock(LightChild):
"""
Custom choice of an answer for a quizz
"""
value = String(help="Value of the choice when selected", scope=Scope.content, default="")
content = String(help="Human-readable version of the choice value", scope=Scope.content, default="")
<fieldset class="choices">
<legend class="question">{{ self.question }}</legend>
<div class="choices-list">
{% for choice in custom_choices %}
<div class="choice">
<span class="choice-result"></span>
<label class="choice-label">
<input class="choice-selector" type="radio" name="{{ self.name }}" value="{{ choice.value }}"{% if self.student_choice == choice.value %} checked{% endif %}> {{ choice.content }}
</label>
</div>
{% endfor %}
</div>
</fieldset>
<fieldset class="rating"> <fieldset class="rating">
<legend class="question">{{ self.question }}</legend> <legend class="question">{{ self.question }}</legend>
<div class="choices"> <div class="choices-list">
<span class="low">{{ self.low }}</span> <span class="low">{{ self.low }}</span>
<span class="choice"> <span class="choice">
<label><input class="choice-selector" type="radio" name="{{ self.name }}" value="1"{% if self.student_choice == '1' %} checked{% endif %}>1</label> <label><input class="choice-selector" type="radio" name="{{ self.name }}" value="1"{% if self.student_choice == '1' %} checked{% endif %}>1</label>
......
...@@ -7,10 +7,12 @@ ...@@ -7,10 +7,12 @@
{{c.body_html|safe}} {{c.body_html|safe}}
{% endfor %} {% endfor %}
{% if self.display_submit %} {% if self.display_submit %}
<input type="button" value="submit" class="submit"></input> <div class="submit">
<span class="progress" data-completed="{{ self.completed }}"> <input type="button" class="input-main" value="Submit"></input>
<span class='indicator'></span> <span class="progress" data-completed="{{ self.completed }}" data-attempted="{{ self.attempted }}">
</span> <span class='indicator'></span>
</span>
</div>
{% endif %} {% endif %}
<div class="messages"></div> <div class="messages"></div>
</div> </div>
<!-- TODO: Replace by default edit view once available in Studio -->
<div class="mentoring-edit">
<div class="wrapper-comp-settings is-active" id="settings-tab">
<script id="metadata-editor-tpl" type="text/template">// JS crashes when empty //</script>
<textarea class="block-xml-editor">{{ xml_content }}</textarea>
</div>
<div class="row module-actions">
<a href="#" class="save-button action-primary action">Save</a>
<a href="#" class="cancel-button action-secondary action">Cancel</a>
<span class="error-message"></span>
</div>
</div>
<script type="text/template" id="xblock-progress-template"> <script type="text/template" id="xblock-progress-template">
<% if (completed === "True") {{ %> <% if (completed === "True") {{ %>
<img src="{completed}" alt="Completed"> <i class="icon-ok icon-2x checkmark-correct"></i>
<% }} else {{ %> <% }} else if (attempted === "True") {{ %>
(Not completed) <i class="icon-exclamation icon-2x checkmark-incorrect"></i>
<% }} %> <% }} %>
</script> </script>
<script type="text/template" id="xblock-mrq-attempts">
<% if (_.isNumber(max_attempts) && max_attempts > 0) {{ %>
<% if (num_attempts >= max_attempts) {{ %>
<button class="show">
<span class="show-label">Show Answer(s)</span>
</button>
<% }} %>
<div> You have used <%= _.min([num_attempts, max_attempts]) %> of <%= max_attempts %> attempts for this question.</div>
<% }} %>
</script>
<h2 class="problem-header">Multiple Response</h2>
<fieldset class="choices">
<legend class="question">{{ self.question }}</legend>
<div class="choices-list">
{% for choice in custom_choices %}
<div class="choice">
<span class="choice-result icon-2x"></span>
<div class="choice-checkbox">
<label class="choice-label">
<input class="choice-selector" type="checkbox" name="{{ self.name }}"
value="{{ choice.value }}"
{% if choice.value in self.student_choices %} checked{% endif %}>
{{ choice.content }}
</input>
</label>
</div>
<span class="choice-answer icon-2x"></span>
<div class="choice-tips"></div>
</div>
{% endfor %}
<div class="choice-message"></div>
</div>
</fieldset>
<div class="mrq-attempts" data-max_attempts="{{ self.max_attempts }}" data-num_attempts="{{ self.num_attempts }}"></div>
<fieldset class="choices">
<legend class="question">{{ self.question }}</legend>
<div class="choices">
{% for choice in custom_choices %}
<span class="choice">
<label><input class="choice-selector" type="radio" name="{{ self.name }}" value="{{ choice.value }}"{% if self.student_choice == choice.value %} checked{% endif %}> {{ choice.content }}</label>
</span>
{% endfor %}
</div>
</fieldset>
<fieldset class="yes-maybenot-understand">
<legend>{{ self.question }}</legend>
<div class="choices">
<span class="choice">
<label><input type="radio" name="{{ self.name }}" value="yes"{% if self.student_choice == 'yes' %} checked{% endif %}> Yes</label>
</span>
<span class="choice">
<label><input type="radio" name="{{ self.name }}" value="maybenot"{% if self.student_choice == 'maybenot' %} checked{% endif %}> Maybe not</label>
</span>
<span class="choice">
<label><input type="radio" name="{{ self.name }}" value="understand"{% if self.student_choice == 'understand' %} checked{% endif %}> I don't understand</label>
</span>
</div>
</fieldset>
<div class="tip-choice-group">
<div class="close"></div>
{% for tip_fragment in tips_fragments %}
{{ tip_fragment.body_html|safe }}
{% endfor %}
</div>
<div class="quizz-tip"> <div class="tip-question-group">
<strong> <strong>
To the question <span class="italic">"{{ self.question }}"</span>, To the question <span class="italic">"{{ self.question }}"</span>,
{% if submission %} {% if submission %}
......
<mentoring url_name="{{ url_name }}">
<html>
<p>What is your goal?</p>
</html>
<answer name="goal" />
<mcq name="mcq_1_1" type="choices">
<question>Do you like this MCQ?</question>
<choice value="yes">Yes</choice>
<choice value="maybenot">Maybe not</choice>
<choice value="understand">I don't understand</choice>
<tip display="yes">Great!</tip>
<tip reject="maybenot">Ah, damn.</tip>
<tip reject="understand"><html><div id="test-custom-html">Really?</div></html></tip>
</mcq>
<mcq name="mcq_1_2" type="rating" low="Not good at all" high="Extremely good">
<question>How much do you rate this MCQ?</question>
<choice value="notwant">I don't want to rate it</choice>
<tip display="4,5">I love good grades.</tip>
<tip reject="1,2,3">Will do better next time...</tip>
<tip reject="notwant">Your loss!</tip>
</mcq>
<mrq name="mrq_1_1" type="choices">
<question>What do you like in this MRQ?</question>
<choice value="elegance">Its elegance</choice>
<choice value="beauty">Its beauty</choice>
<choice value="gracefulness">Its gracefulness</choice>
<choice value="bugs">Its bugs</choice>
<tip require="gracefulness">This MRQ is indeed very graceful</tip>
<tip require="elegance,beauty">This is something everyone has to like about this MRQ</tip>
<tip reject="bugs">Nah, there isn't any!</tip>
<message type="on-submit">Thank you for answering!</message>
</mrq>
<message type="completed">
<html><p>Congratulations!</p></html>
</message>
</mentoring>
# -*- coding: utf-8 -*-
#
# Copyright (C) 2014 Harvard
#
# Authors:
# Xavier Antoviaque <xavier@antoviaque.org>
#
# This software's license gives you freedom; you can copy, convey,
# propagate, redistribute and/or modify this program under the terms of
# the GNU Affero General Public License (AGPL) as published by the Free
# Software Foundation (FSF), either version 3 of the License, or (at your
# option) any later version of the AGPL published by the FSF.
#
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
# General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program in a file in the toplevel directory called
# "AGPLv3". If not, see <http://www.gnu.org/licenses/>.
#
# Imports ###########################################################
import logging
from .light_children import LightChild, Scope, String
from .utils import render_template
# Globals ###########################################################
log = logging.getLogger(__name__)
# Functions #########################################################
def commas_to_set(commas_str):
"""
Converts a comma-separated string to a set
"""
if not commas_str:
return set()
else:
return set(commas_str.split(','))
# Classes ###########################################################
class TipBlock(LightChild):
"""
Each choice can define a tip depending on selection
"""
content = String(help="Text of the tip to provide if needed", scope=Scope.content, default="")
display = String(help="List of choices to display the tip for", scope=Scope.content, default=None)
reject = String(help="List of choices to reject", scope=Scope.content, default=None)
require = String(help="List of choices to require", scope=Scope.content, default=None)
def render(self):
"""
Returns a fragment containing the formatted tip
"""
fragment, named_children = self.get_children_fragment({})
fragment.add_content(render_template('templates/html/tip.html', {
'self': self,
'named_children': named_children,
}))
return self.xblock_container.fragment_text_rewriting(fragment)
@property
def display_with_defaults(self):
display = commas_to_set(self.display)
return display | self.reject_with_defaults | self.require_with_defaults
@property
def reject_with_defaults(self):
return commas_to_set(self.reject)
@property
def require_with_defaults(self):
return commas_to_set(self.require)
...@@ -31,7 +31,6 @@ import unicodecsv ...@@ -31,7 +31,6 @@ import unicodecsv
from cStringIO import StringIO from cStringIO import StringIO
from django.template import Context, Template from django.template import Context, Template
from xblock.fragment import Fragment from xblock.fragment import Fragment
from workbench.scenarios import add_xml_scenario
# Globals ########################################################### # Globals ###########################################################
...@@ -73,7 +72,6 @@ def get_scenarios_from_path(scenarios_path, include_identifier=False): ...@@ -73,7 +72,6 @@ def get_scenarios_from_path(scenarios_path, include_identifier=False):
""" """
base_fullpath = os.path.dirname(os.path.realpath(__file__)) base_fullpath = os.path.dirname(os.path.realpath(__file__))
scenarios_fullpath = os.path.join(base_fullpath, scenarios_path) scenarios_fullpath = os.path.join(base_fullpath, scenarios_path)
print scenarios_fullpath
scenarios = [] scenarios = []
if os.path.isdir(scenarios_fullpath): if os.path.isdir(scenarios_fullpath):
...@@ -94,10 +92,7 @@ def load_scenarios_from_path(scenarios_path): ...@@ -94,10 +92,7 @@ def load_scenarios_from_path(scenarios_path):
""" """
Load all xml files contained in a specified directory, as workbench scenarios Load all xml files contained in a specified directory, as workbench scenarios
""" """
scenarios = get_scenarios_from_path(scenarios_path, include_identifier=True) return get_scenarios_from_path(scenarios_path, include_identifier=True)
for identifier, title, xml_template in scenarios:
add_xml_scenario(identifier, title, xml_template)
return scenarios
# Classes ########################################################### # Classes ###########################################################
...@@ -110,9 +105,9 @@ class XBlockWithChildrenFragmentsMixin(object): ...@@ -110,9 +105,9 @@ class XBlockWithChildrenFragmentsMixin(object):
and a list of fragments, one per children and a list of fragments, one per children
- `view_name` allows to select a specific view method on the children - `view_name` allows to select a specific view method on the children
- `instance_of` allows to only return fragments for children which are instances of - `instance_of` allows to only return fragments for children which are instances of
the provided class the provided class
- `not_instance_of` allows to only return fragments for children which are *NOT* - `not_instance_of` allows to only return fragments for children which are *NOT*
instances of the provided class instances of the provided class
""" """
fragment = Fragment() fragment = Fragment()
......
...@@ -52,10 +52,12 @@ BLOCKS_CHILDREN = [ ...@@ -52,10 +52,12 @@ BLOCKS_CHILDREN = [
'column = mentoring:MentoringTableColumnBlock', 'column = mentoring:MentoringTableColumnBlock',
'header = mentoring:MentoringTableColumnHeaderBlock', 'header = mentoring:MentoringTableColumnHeaderBlock',
'answer = mentoring:AnswerBlock', 'answer = mentoring:AnswerBlock',
'quizz = mentoring:QuizzBlock', 'quizz = mentoring:MCQBlock',
'mcq = mentoring:MCQBlock',
'mrq = mentoring:MRQBlock',
'message = mentoring:MentoringMessageBlock', 'message = mentoring:MentoringMessageBlock',
'tip = mentoring:QuizzTipBlock', 'tip = mentoring:TipBlock',
'choice = mentoring:QuizzChoiceBlock', 'choice = mentoring:ChoiceBlock',
'html = mentoring:HTMLBlock', 'html = mentoring:HTMLBlock',
] ]
...@@ -63,7 +65,7 @@ setup( ...@@ -63,7 +65,7 @@ setup(
name='xblock-mentoring', name='xblock-mentoring',
version='0.1', version='0.1',
description='XBlock - Mentoring', description='XBlock - Mentoring',
packages=['mentoring'], packages=['mentoring', 'mentoring.migrations'],
install_requires=[ install_requires=[
'XBlock', 'XBlock',
], ],
...@@ -71,5 +73,5 @@ setup( ...@@ -71,5 +73,5 @@ setup(
'xblock.v1': BLOCKS, 'xblock.v1': BLOCKS,
'xblock.light_children': BLOCKS_CHILDREN, 'xblock.light_children': BLOCKS_CHILDREN,
}, },
package_data=package_data("mentoring", ["static", "templates", "public"]), package_data=package_data("mentoring", ["static", "templates", "public", "migrations"]),
) )
...@@ -54,14 +54,14 @@ class AnswerBlockTest(MentoringBaseTest): ...@@ -54,14 +54,14 @@ class AnswerBlockTest(MentoringBaseTest):
answer1 = mentoring.find_element_by_css_selector('textarea') answer1 = mentoring.find_element_by_css_selector('textarea')
self.assertEqual(answer1.text, '') self.assertEqual(answer1.text, '')
progress = mentoring.find_element_by_css_selector('.progress > .indicator') progress = mentoring.find_element_by_css_selector('.progress > .indicator')
self.assertEqual(progress.text, '(Not completed)') self.assertEqual(progress.text, '')
self.assertFalse(progress.find_elements_by_xpath('./*')) self.assertFalse(progress.find_elements_by_xpath('./*'))
# Submit without answer # Submit without answer
submit = mentoring.find_element_by_css_selector('input.submit') submit = mentoring.find_element_by_css_selector('input.submit')
submit.click() submit.click()
self.assertEqual(answer1.get_attribute('value'), '') self.assertEqual(answer1.get_attribute('value'), '')
self.assertEqual(progress.text, '(Not completed)') self.assertEqual(progress.text, '')
self.assertFalse(progress.find_elements_by_xpath('./*')) self.assertFalse(progress.find_elements_by_xpath('./*'))
# Submit an answer # Submit an answer
...@@ -88,7 +88,7 @@ class AnswerBlockTest(MentoringBaseTest): ...@@ -88,7 +88,7 @@ class AnswerBlockTest(MentoringBaseTest):
answer = mentoring.find_element_by_css_selector('blockquote.answer.read_only') answer = mentoring.find_element_by_css_selector('blockquote.answer.read_only')
self.assertEqual(answer.text, '') self.assertEqual(answer.text, '')
progress = mentoring.find_element_by_css_selector('.progress > .indicator') progress = mentoring.find_element_by_css_selector('.progress > .indicator')
self.assertEqual(progress.text, '(Not completed)') self.assertEqual(progress.text, '')
# Submit should allow to complete # Submit should allow to complete
submit = mentoring.find_element_by_css_selector('input.submit') submit = mentoring.find_element_by_css_selector('input.submit')
......
# -*- coding: utf-8 -*-
#
# Copyright (C) 2014 Harvard
#
# Authors:
# Xavier Antoviaque <xavier@antoviaque.org>
#
# This software's license gives you freedom; you can copy, convey,
# propagate, redistribute and/or modify this program under the terms of
# the GNU Affero General Public License (AGPL) as published by the Free
# Software Foundation (FSF), either version 3 of the License, or (at your
# option) any later version of the AGPL published by the FSF.
#
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
# General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program in a file in the toplevel directory called
# "AGPLv3". If not, see <http://www.gnu.org/licenses/>.
#
# Imports ###########################################################
import time
from mentoring.test_base import MentoringBaseTest
# Classes ###########################################################
class MCQBlockTest(MentoringBaseTest):
def test_mcq_choices_rating(self):
"""
Mentoring MCQ should display tips according to user choice
"""
# Initial MCQ status
mentoring = self.go_to_page('MCQ 1')
mcq1 = mentoring.find_element_by_css_selector('fieldset.choices')
mcq2 = mentoring.find_element_by_css_selector('fieldset.rating')
messages = mentoring.find_element_by_css_selector('.messages')
progress = mentoring.find_element_by_css_selector('.progress > .indicator')
self.assertEqual(messages.text, '')
self.assertFalse(messages.find_elements_by_xpath('./*'))
self.assertEqual(progress.text, '')
self.assertFalse(progress.find_elements_by_xpath('./*'))
mcq1_legend = mcq1.find_element_by_css_selector('legend')
mcq2_legend = mcq2.find_element_by_css_selector('legend')
self.assertEqual(mcq1_legend.text, 'Do you like this MCQ?')
self.assertEqual(mcq2_legend.text, 'How much do you rate this MCQ?')
mcq1_choices = mcq1.find_elements_by_css_selector('.choices .choice label')
mcq2_choices = mcq2.find_elements_by_css_selector('.choices .choice label')
self.assertEqual(len(mcq1_choices), 3)
self.assertEqual(len(mcq2_choices), 6)
self.assertEqual(mcq1_choices[0].text, 'Yes')
self.assertEqual(mcq1_choices[1].text, 'Maybe not')
self.assertEqual(mcq1_choices[2].text, "I don't understand")
self.assertEqual(mcq2_choices[0].text, '1')
self.assertEqual(mcq2_choices[1].text, '2')
self.assertEqual(mcq2_choices[2].text, '3')
self.assertEqual(mcq2_choices[3].text, '4')
self.assertEqual(mcq2_choices[4].text, '5')
self.assertEqual(mcq2_choices[5].text, "I don't want to rate it")
mcq1_choices_input = [
mcq1_choices[0].find_element_by_css_selector('input'),
mcq1_choices[1].find_element_by_css_selector('input'),
mcq1_choices[2].find_element_by_css_selector('input'),
]
mcq2_choices_input = [
mcq2_choices[0].find_element_by_css_selector('input'),
mcq2_choices[1].find_element_by_css_selector('input'),
mcq2_choices[2].find_element_by_css_selector('input'),
mcq2_choices[3].find_element_by_css_selector('input'),
mcq2_choices[4].find_element_by_css_selector('input'),
mcq2_choices[5].find_element_by_css_selector('input'),
]
self.assertEqual(mcq1_choices_input[0].get_attribute('value'), 'yes')
self.assertEqual(mcq1_choices_input[1].get_attribute('value'), 'maybenot')
self.assertEqual(mcq1_choices_input[2].get_attribute('value'), 'understand')
self.assertEqual(mcq2_choices_input[0].get_attribute('value'), '1')
self.assertEqual(mcq2_choices_input[1].get_attribute('value'), '2')
self.assertEqual(mcq2_choices_input[2].get_attribute('value'), '3')
self.assertEqual(mcq2_choices_input[3].get_attribute('value'), '4')
self.assertEqual(mcq2_choices_input[4].get_attribute('value'), '5')
self.assertEqual(mcq2_choices_input[5].get_attribute('value'), 'notwant')
# Submit without selecting anything
submit = mentoring.find_element_by_css_selector('input.submit')
submit.click()
tips = messages.find_elements_by_xpath('./*')
self.assertEqual(len(tips), 2)
self.assertEqual(tips[0].text, 'To the question "Do you like this MCQ?", you have not provided an answer.')
self.assertEqual(tips[1].text, 'To the question "How much do you rate this MCQ?", you have not provided an answer.')
self.assertEqual(progress.text, '')
self.assertFalse(progress.find_elements_by_xpath('./*'))
# Select only one option
mcq1_choices_input[1].click()
submit.click()
time.sleep(1)
tips = messages.find_elements_by_xpath('./*')
self.assertEqual(len(tips), 2)
self.assertEqual(tips[0].text, 'To the question "Do you like this MCQ?", you answered "Maybe not".\nAh, damn.')
self.assertEqual(tips[1].text, 'To the question "How much do you rate this MCQ?", you have not provided an answer.')
self.assertEqual(progress.text, '')
self.assertFalse(progress.find_elements_by_xpath('./*'))
# One with only display tip, one with reject tip - should not complete
mcq1_choices_input[0].click()
mcq2_choices_input[2].click()
submit.click()
time.sleep(1)
tips = messages.find_elements_by_xpath('./*')
self.assertEqual(len(tips), 2)
self.assertEqual(tips[0].text, 'To the question "Do you like this MCQ?", you answered "Yes".\nGreat!')
self.assertEqual(tips[1].text, 'To the question "How much do you rate this MCQ?", you answered "3".\nWill do better next time...')
self.assertEqual(progress.text, '')
self.assertFalse(progress.find_elements_by_xpath('./*'))
# Only display tips, to allow to complete
mcq1_choices_input[0].click()
mcq2_choices_input[3].click()
submit.click()
time.sleep(1)
tips = messages.find_elements_by_xpath('./*')
self.assertEqual(len(tips), 3)
self.assertEqual(tips[0].text, 'To the question "Do you like this MCQ?", you answered "Yes".\nGreat!')
self.assertEqual(tips[1].text, 'To the question "How much do you rate this MCQ?", you answered "4".\nI love good grades.')
self.assertEqual(tips[2].text, 'Congratulations!\nAll is good now...') # Includes child <html>
self.assertEqual(progress.text, '')
self.assertTrue(progress.find_elements_by_css_selector('img'))
...@@ -63,7 +63,7 @@ class MentoringProgressionTest(MentoringBaseTest): ...@@ -63,7 +63,7 @@ class MentoringProgressionTest(MentoringBaseTest):
self.assertFalse(mentoring.find_elements_by_css_selector('.warning')) self.assertFalse(mentoring.find_elements_by_css_selector('.warning'))
progress = mentoring.find_element_by_css_selector('.progress > .indicator') progress = mentoring.find_element_by_css_selector('.progress > .indicator')
self.assertEqual(progress.text, '(Not completed)') self.assertEqual(progress.text, '')
self.assertFalse(progress.find_elements_by_xpath('./*')) self.assertFalse(progress.find_elements_by_xpath('./*'))
mentoring = self.go_to_page('Progression 2') mentoring = self.go_to_page('Progression 2')
...@@ -82,7 +82,7 @@ class MentoringProgressionTest(MentoringBaseTest): ...@@ -82,7 +82,7 @@ class MentoringProgressionTest(MentoringBaseTest):
submit.click() submit.click()
progress = mentoring.find_element_by_css_selector('.progress > .indicator') progress = mentoring.find_element_by_css_selector('.progress > .indicator')
self.assertEqual(progress.text, '(Not completed)') self.assertEqual(progress.text, '')
self.assertFalse(progress.find_elements_by_xpath('./*')) self.assertFalse(progress.find_elements_by_xpath('./*'))
mentoring = self.go_to_page('Progression 2') mentoring = self.go_to_page('Progression 2')
......
# -*- coding: utf-8 -*-
#
# Copyright (C) 2014 Harvard
#
# Authors:
# Xavier Antoviaque <xavier@antoviaque.org>
#
# This software's license gives you freedom; you can copy, convey,
# propagate, redistribute and/or modify this program under the terms of
# the GNU Affero General Public License (AGPL) as published by the Free
# Software Foundation (FSF), either version 3 of the License, or (at your
# option) any later version of the AGPL published by the FSF.
#
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
# General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program in a file in the toplevel directory called
# "AGPLv3". If not, see <http://www.gnu.org/licenses/>.
#
# Imports ###########################################################
import time
from mentoring.test_base import MentoringBaseTest
# Classes ###########################################################
class QuizzBlockTest(MentoringBaseTest):
def test_quizz_choices_rating(self):
"""
Mentoring quizz should display tips according to user choice
"""
# Initial quizzes status
mentoring = self.go_to_page('Quizz 1')
quizz1 = mentoring.find_element_by_css_selector('fieldset.choices')
quizz2 = mentoring.find_element_by_css_selector('fieldset.rating')
messages = mentoring.find_element_by_css_selector('.messages')
progress = mentoring.find_element_by_css_selector('.progress > .indicator')
self.assertEqual(messages.text, '')
self.assertFalse(messages.find_elements_by_xpath('./*'))
self.assertEqual(progress.text, '(Not completed)')
self.assertFalse(progress.find_elements_by_xpath('./*'))
quizz1_legend = quizz1.find_element_by_css_selector('legend')
quizz2_legend = quizz2.find_element_by_css_selector('legend')
self.assertEqual(quizz1_legend.text, 'Do you like this quizz?')
self.assertEqual(quizz2_legend.text, 'How much do you rate this quizz?')
quizz1_choices = quizz1.find_elements_by_css_selector('.choices .choice label')
quizz2_choices = quizz2.find_elements_by_css_selector('.choices .choice label')
self.assertEqual(len(quizz1_choices), 3)
self.assertEqual(len(quizz2_choices), 6)
self.assertEqual(quizz1_choices[0].text, 'Yes')
self.assertEqual(quizz1_choices[1].text, 'Maybe not')
self.assertEqual(quizz1_choices[2].text, "I don't understand")
self.assertEqual(quizz2_choices[0].text, '1')
self.assertEqual(quizz2_choices[1].text, '2')
self.assertEqual(quizz2_choices[2].text, '3')
self.assertEqual(quizz2_choices[3].text, '4')
self.assertEqual(quizz2_choices[4].text, '5')
self.assertEqual(quizz2_choices[5].text, "I don't want to rate it")
quizz1_choices_input = [
quizz1_choices[0].find_element_by_css_selector('input'),
quizz1_choices[1].find_element_by_css_selector('input'),
quizz1_choices[2].find_element_by_css_selector('input'),
]
quizz2_choices_input = [
quizz2_choices[0].find_element_by_css_selector('input'),
quizz2_choices[1].find_element_by_css_selector('input'),
quizz2_choices[2].find_element_by_css_selector('input'),
quizz2_choices[3].find_element_by_css_selector('input'),
quizz2_choices[4].find_element_by_css_selector('input'),
quizz2_choices[5].find_element_by_css_selector('input'),
]
self.assertEqual(quizz1_choices_input[0].get_attribute('value'), 'yes')
self.assertEqual(quizz1_choices_input[1].get_attribute('value'), 'maybenot')
self.assertEqual(quizz1_choices_input[2].get_attribute('value'), 'understand')
self.assertEqual(quizz2_choices_input[0].get_attribute('value'), '1')
self.assertEqual(quizz2_choices_input[1].get_attribute('value'), '2')
self.assertEqual(quizz2_choices_input[2].get_attribute('value'), '3')
self.assertEqual(quizz2_choices_input[3].get_attribute('value'), '4')
self.assertEqual(quizz2_choices_input[4].get_attribute('value'), '5')
self.assertEqual(quizz2_choices_input[5].get_attribute('value'), 'notwant')
# Submit without selecting anything
submit = mentoring.find_element_by_css_selector('input.submit')
submit.click()
tips = messages.find_elements_by_xpath('./*')
self.assertEqual(len(tips), 2)
self.assertEqual(tips[0].text, 'To the question "Do you like this quizz?", you have not provided an answer.')
self.assertEqual(tips[1].text, 'To the question "How much do you rate this quizz?", you have not provided an answer.')
self.assertEqual(progress.text, '(Not completed)')
self.assertFalse(progress.find_elements_by_xpath('./*'))
# Select only one option
quizz1_choices_input[1].click()
submit.click()
time.sleep(1)
tips = messages.find_elements_by_xpath('./*')
self.assertEqual(len(tips), 2)
self.assertEqual(tips[0].text, 'To the question "Do you like this quizz?", you answered "Maybe not".\nAh, damn.')
self.assertEqual(tips[1].text, 'To the question "How much do you rate this quizz?", you have not provided an answer.')
self.assertEqual(progress.text, '(Not completed)')
self.assertFalse(progress.find_elements_by_xpath('./*'))
# One with only display tip, one with reject tip - should not complete
quizz1_choices_input[0].click()
quizz2_choices_input[2].click()
submit.click()
time.sleep(1)
tips = messages.find_elements_by_xpath('./*')
self.assertEqual(len(tips), 2)
self.assertEqual(tips[0].text, 'To the question "Do you like this quizz?", you answered "Yes".\nGreat!')
self.assertEqual(tips[1].text, 'To the question "How much do you rate this quizz?", you answered "3".\nWill do better next time...')
self.assertEqual(progress.text, '(Not completed)')
self.assertFalse(progress.find_elements_by_xpath('./*'))
# Only display tips, to allow to complete
quizz1_choices_input[0].click()
quizz2_choices_input[3].click()
submit.click()
time.sleep(1)
tips = messages.find_elements_by_xpath('./*')
self.assertEqual(len(tips), 3)
self.assertEqual(tips[0].text, 'To the question "Do you like this quizz?", you answered "Yes".\nGreat!')
self.assertEqual(tips[1].text, 'To the question "How much do you rate this quizz?", you answered "4".\nI love good grades.')
self.assertEqual(tips[2].text, 'Congratulations!\nAll is good now...') # Includes child <html>
self.assertEqual(progress.text, '')
self.assertTrue(progress.find_elements_by_css_selector('img'))
<vertical> <vertical>
<mentoring url_name="quizz_1" enforce_dependency="false"> <mentoring url_name="mcq_1" enforce_dependency="false">
<quizz name="quizz_1_1" type="choices"> <mcq name="mcq_1_1" type="choices">
<question>Do you like this quizz?</question> <question>Do you like this MCQ?</question>
<choice value="yes">Yes</choice> <choice value="yes">Yes</choice>
<choice value="maybenot">Maybe not</choice> <choice value="maybenot">Maybe not</choice>
<choice value="understand">I don't understand</choice> <choice value="understand">I don't understand</choice>
...@@ -9,16 +9,16 @@ ...@@ -9,16 +9,16 @@
<tip display="yes">Great!</tip> <tip display="yes">Great!</tip>
<tip reject="maybenot">Ah, damn.</tip> <tip reject="maybenot">Ah, damn.</tip>
<tip reject="understand"><html><div id="test-custom-html">Really?</div></html></tip> <tip reject="understand"><html><div id="test-custom-html">Really?</div></html></tip>
</quizz> </mcq>
<quizz name="quizz_1_2" type="rating" low="Not good at all" high="Extremely good"> <mcq name="mcq_1_2" type="rating" low="Not good at all" high="Extremely good">
<question>How much do you rate this quizz?</question> <question>How much do you rate this MCQ?</question>
<choice value="notwant">I don't want to rate it</choice> <choice value="notwant">I don't want to rate it</choice>
<tip display="4,5">I love good grades.</tip> <tip display="4,5">I love good grades.</tip>
<tip reject="1,2,3">Will do better next time...</tip> <tip reject="1,2,3">Will do better next time...</tip>
<tip reject="notwant">Your loss!</tip> <tip reject="notwant">Your loss!</tip>
</quizz> </mcq>
<message type="completed"> <message type="completed">
All is good now... All is good now...
......
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