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
xblock_mentoring.egg-info
workbench.sqlite
mentoring/templates/xml
dist
templates
/xblock_mentoring.egg-info
/workbench.sqlite
/dist
/templates
......@@ -6,7 +6,7 @@ This XBlock allows to automate the workflow of real-life mentoring, within an ed
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.
* **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.
* **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
......@@ -44,12 +44,12 @@ Second XBlock instance:
</mentoring>
```
### Self-assessment quizzes
### Self-assessment MCQs
```xml
<mentoring url_name="quizz_1" enforce_dependency="false">
<quizz name="quizz_1_1" type="choices">
<question>Do you like this quizz?</question>
<mentoring url_name="mcq_1" enforce_dependency="false">
<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>
......@@ -57,16 +57,16 @@ Second XBlock instance:
<tip display="yes">Great!</tip>
<tip reject="maybenot">Ah, damn.</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">
<question>How much do you rate this quizz?</question>
<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>
</quizz>
</MCQ>
<message type="completed">
All is good now...
......@@ -75,6 +75,28 @@ Second XBlock instance:
</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
```xml
......@@ -116,6 +138,11 @@ directory, enter:
$ 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
-------------------------
......@@ -170,4 +197,3 @@ $ cat > templates/xml/my_mentoring_scenario.xml
```
Restart the workbench to take the new scenarios into account.
from .answer import AnswerBlock
from .choice import ChoiceBlock
from .dataexport import MentoringDataExportBlock
from .html import HTMLBlock
from .quizz import QuizzBlock, QuizzChoiceBlock, QuizzTipBlock
from .mcq import MCQBlock
from .mrq import MRQBlock
from .mentoring import MentoringBlock
from .message import MentoringMessageBlock
from .table import MentoringTableBlock, MentoringTableColumnBlock, MentoringTableColumnHeaderBlock
from .tip import TipBlock
# -*- 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):
return response
def get_csv(self):
answers = Answer.objects.all().order_by('student_id', 'name')
answers_names = Answer.objects.values_list('name', flat=True).distinct().order_by('name')
course_id = self.xmodule_runtime.course_id
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
yield list2csv([u'student_id'] + list(answers_names))
......
......@@ -24,6 +24,10 @@
# Imports ###########################################################
import logging
import json
from lazy import lazy
from weakref import WeakKeyDictionary
from cStringIO import StringIO
from lxml import etree
......@@ -34,6 +38,8 @@ from xblock.core import XBlock
from xblock.fragment import Fragment
from xblock.plugin import Plugin
from .models import LightChild as LightChildModel
try:
from xmodule_modifiers import replace_jump_to_id_urls
except:
......@@ -73,6 +79,7 @@ class LightChildrenMixin(XBlockWithChildrenFragmentsMixin):
@classmethod
def parse_xml(cls, node, runtime, keys, id_generator):
log.debug('parse_xml called')
block = runtime.construct_xblock_from_class(cls, keys)
cls.init_block_from_node(block, node, node.items())
block.xml_content = getattr(block, 'xml_content', '') or etree.tostring(node)
......@@ -132,6 +139,7 @@ class LightChildrenMixin(XBlockWithChildrenFragmentsMixin):
"""
Replacement for ```self.runtime.render_child()```
"""
frag = getattr(child, view_name)(context)
frag.content = u'<div class="xblock-light-child" name="{}" data-type="{}">{}</div>'.format(
child.name, child.__class__.__name__, frag.content)
......@@ -166,6 +174,7 @@ class XBlockWithLightChildren(LightChildrenMixin, XBlock):
"""
Current HTML view of the XBlock, for refresh by client
"""
frag = self.student_view({})
frag = self.fragment_text_rewriting(frag)
......@@ -193,6 +202,7 @@ class XBlockWithLightChildren(LightChildrenMixin, XBlock):
fragment = replace_jump_to_id_urls(course_id, jump_to_url, self, 'student_view', fragment, {})
return fragment
class LightChild(Plugin, LightChildrenMixin):
"""
Base class for the light children
......@@ -202,6 +212,7 @@ class LightChild(Plugin, LightChildrenMixin):
def __init__(self, parent):
self.parent = parent
self.xblock_container = parent.xblock_container
self._student_data_loaded = False
@property
def runtime(self):
......@@ -219,35 +230,137 @@ class LightChild(Plugin, LightChildrenMixin):
xmodule_runtime = 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):
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):
"""
Fake field with no persistence - allows to keep XBlocks fields definitions on LightChild
"""
def __init__(self, *args, **kwargs):
self.value = kwargs.get('default', '')
self.default = kwargs.get('default', '')
self.data = WeakKeyDictionary()
def __get__(self, instance, name):
def __nonzero__(self):
return bool(self.value)
# A LightChildField can depend on student_data
instance.load_student_data()
return self.data.get(instance, self.default)
def __set__(self, instance, value):
self.data[instance] = value
class String(LightChildField):
def __init__(self, *args, **kwargs):
self.value = kwargs.get('default', '') or ''
super(String, self).__init__(*args, **kwargs)
self.default = kwargs.get('default', '') or ''
# def split(self, *args, **kwargs):
# return self.value.split(*args, **kwargs)
def __str__(self):
return self.value
class Integer(LightChildField):
def __init__(self, *args, **kwargs):
super(Integer, self).__init__(*args, **kwargs)
self.default = kwargs.get('default', 0)
def split(self, *args, **kwargs):
return self.value.split(*args, **kwargs)
def __set__(self, instance, value):
try:
self.data[instance] = int(value)
except (TypeError, ValueError): # not an integer
self.data[instance] = 0
class Boolean(LightChildField):
pass
class List(LightChildField):
def __init__(self, *args, **kwargs):
super(List, self).__init__(*args, **kwargs)
self.default = kwargs.get('default', [])
class Scope(object):
content = 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 @@
# Imports ###########################################################
import logging
import uuid
from lxml import etree
from StringIO import StringIO
from xblock.core import XBlock
from xblock.fields import Boolean, Scope, String
from xblock.fragment import Fragment
from .light_children import XBlockWithLightChildren
from .message import MentoringMessageBlock
......@@ -44,8 +49,8 @@ class MentoringBlock(XBlockWithLightChildren):
"""
An XBlock providing mentoring capabilities
Composed of text, answers input fields, and a set of multiple choice quizzes with advices.
A set of conditions on the provided answers and quizzes choices will determine if the
Composed of text, answers input fields, and a set of MRQ/MCQ with advices.
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
ok to continue.
"""
......@@ -58,20 +63,17 @@ class MentoringBlock(XBlockWithLightChildren):
followed_by = String(help="url_name of the step after the current mentoring block in workflow",
default=None, scope=Scope.content)
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?",
default=True, scope=Scope.content)
default=False, 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)
has_children = True
icon_class = 'problem'
def student_view(self, context):
fragment, named_children = self.get_children_fragment(context, view_name='mentoring_view',
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', {
'self': self,
'named_children': named_children,
......@@ -81,9 +83,8 @@ class MentoringBlock(XBlockWithLightChildren):
fragment.add_javascript_url(
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_resource(load_resource('templates/html/mentoring_progress.html').format(
completed=correct_icon_url),
"text/html")
fragment.add_resource(load_resource('templates/html/mentoring_progress.html'), "text/html")
fragment.add_resource(load_resource('templates/html/mrqblock_attempts.html'), "text/html")
fragment.initialize_js('MentoringBlock')
......@@ -135,11 +136,11 @@ class MentoringBlock(XBlockWithLightChildren):
elif completed and self.next_step == self.url_name:
self.next_step = self.followed_by
log.warn(submit_results);
self.completed = bool(completed)
return {
'submitResults': submit_results,
'completed': self.completed,
'attempted': self.attempted,
'message': message,
}
......@@ -156,6 +157,64 @@ class MentoringBlock(XBlockWithLightChildren):
else:
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
def workbench_scenarios():
"""
......
# -*- 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):
# Force validation of max_length
self.full_clean()
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 {
height: 150px;
height: 250px;
width: 100%;
margin-bottom: 20px;
}
......
......@@ -44,3 +44,20 @@
.mentoring h4 {
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) {
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();
// Messages should only be displayed upon hitting 'submit', not on page reload
......@@ -73,7 +74,7 @@ function MentoringBlock(runtime, element) {
}
function initXBlock() {
var submit_dom = $(element).find('.submit');
var submit_dom = $(element).find('.submit .input-main');
submit_dom.bind('click', function() {
var data = {};
......@@ -88,6 +89,12 @@ function MentoringBlock(runtime, element) {
$.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) {
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
from xblock.fragment import Fragment
from .choice import ChoiceBlock
from .light_children import LightChild, Scope, String
from .tip import TipBlock
from .utils import render_template
......@@ -36,42 +38,30 @@ from .utils import render_template
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 ###########################################################
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
values entered by the student, and supports multiple types of multiple-choice
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="")
type = String(help="Type of quizz", 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")
message = String(help="General feedback provided when submiting", scope=Scope.content, default="")
valid_types = ('choices')
@classmethod
def init_block_from_node(cls, block, node, attr):
block.light_children = []
for child_id, xml_child in enumerate(node):
if xml_child.tag == "question":
if xml_child.tag == 'question':
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:
cls.add_node_as_child(block, xml_child, child_id)
......@@ -81,122 +71,51 @@ class QuizzBlock(LightChild):
return block
def mentoring_view(self, context=None):
if str(self.type) not in ('rating', 'choices'):
raise ValueError, u'Invalid value for QuizzBlock.type: `{}`'.format(self.type)
name = self.__class__.__name__
if str(self.type) not in self.valid_types:
raise ValueError, u'Invalid value for {}.type: `{}`'.format(name, self.type)
template_path = 'templates/html/quizz_{}.html'.format(self.type)
template_path = 'templates/html/{}_{}.html'.format(name.lower(), self.type)
html = render_template(template_path, {
'self': self,
'custom_choices': self.custom_choices,
})
fragment = Fragment(html)
fragment.add_css_url(self.runtime.local_resource_url(self.xblock_container,
'public/css/quizz.css'))
fragment.add_css(render_template('public/css/questionnaire.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,
'public/js/quizz.js'))
fragment.initialize_js('QuizzBlock')
'public/js/questionnaire.js'))
fragment.initialize_js(name)
return fragment
@property
def custom_choices(self):
custom_choices = []
for child in self.get_children_objects():
if isinstance(child, QuizzChoiceBlock):
if isinstance(child, ChoiceBlock):
custom_choices.append(child)
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):
"""
Returns the tips contained in this block
"""
tips = []
for child in self.get_children_objects():
if isinstance(child, QuizzTipBlock):
if isinstance(child, TipBlock):
tips.append(child)
return tips
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
"""
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)
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):
def get_submission_display(self, submission):
"""
Custom choice of an answer for a quizz
Get the human-readable version of a submission value
"""
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="")
for choice in self.custom_choices:
if choice.value == submission:
return choice.content
return submission
<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">
<legend class="question">{{ self.question }}</legend>
<div class="choices">
<div class="choices-list">
<span class="low">{{ self.low }}</span>
<span class="choice">
<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 @@
{{c.body_html|safe}}
{% endfor %}
{% if self.display_submit %}
<input type="button" value="submit" class="submit"></input>
<span class="progress" data-completed="{{ self.completed }}">
<div class="submit">
<input type="button" class="input-main" value="Submit"></input>
<span class="progress" data-completed="{{ self.completed }}" data-attempted="{{ self.attempted }}">
<span class='indicator'></span>
</span>
</div>
{% endif %}
<div class="messages"></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">
<% if (completed === "True") {{ %>
<img src="{completed}" alt="Completed">
<% }} else {{ %>
(Not completed)
<i class="icon-ok icon-2x checkmark-correct"></i>
<% }} else if (attempted === "True") {{ %>
<i class="icon-exclamation icon-2x checkmark-incorrect"></i>
<% }} %>
</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>
To the question <span class="italic">"{{ self.question }}"</span>,
{% 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
from cStringIO import StringIO
from django.template import Context, Template
from xblock.fragment import Fragment
from workbench.scenarios import add_xml_scenario
# Globals ###########################################################
......@@ -73,7 +72,6 @@ def get_scenarios_from_path(scenarios_path, include_identifier=False):
"""
base_fullpath = os.path.dirname(os.path.realpath(__file__))
scenarios_fullpath = os.path.join(base_fullpath, scenarios_path)
print scenarios_fullpath
scenarios = []
if os.path.isdir(scenarios_fullpath):
......@@ -94,10 +92,7 @@ def load_scenarios_from_path(scenarios_path):
"""
Load all xml files contained in a specified directory, as workbench scenarios
"""
scenarios = 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
return get_scenarios_from_path(scenarios_path, include_identifier=True)
# Classes ###########################################################
......
......@@ -52,10 +52,12 @@ BLOCKS_CHILDREN = [
'column = mentoring:MentoringTableColumnBlock',
'header = mentoring:MentoringTableColumnHeaderBlock',
'answer = mentoring:AnswerBlock',
'quizz = mentoring:QuizzBlock',
'quizz = mentoring:MCQBlock',
'mcq = mentoring:MCQBlock',
'mrq = mentoring:MRQBlock',
'message = mentoring:MentoringMessageBlock',
'tip = mentoring:QuizzTipBlock',
'choice = mentoring:QuizzChoiceBlock',
'tip = mentoring:TipBlock',
'choice = mentoring:ChoiceBlock',
'html = mentoring:HTMLBlock',
]
......@@ -63,7 +65,7 @@ setup(
name='xblock-mentoring',
version='0.1',
description='XBlock - Mentoring',
packages=['mentoring'],
packages=['mentoring', 'mentoring.migrations'],
install_requires=[
'XBlock',
],
......@@ -71,5 +73,5 @@ setup(
'xblock.v1': BLOCKS,
'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):
answer1 = mentoring.find_element_by_css_selector('textarea')
self.assertEqual(answer1.text, '')
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('./*'))
# Submit without answer
submit = mentoring.find_element_by_css_selector('input.submit')
submit.click()
self.assertEqual(answer1.get_attribute('value'), '')
self.assertEqual(progress.text, '(Not completed)')
self.assertEqual(progress.text, '')
self.assertFalse(progress.find_elements_by_xpath('./*'))
# Submit an answer
......@@ -88,7 +88,7 @@ class AnswerBlockTest(MentoringBaseTest):
answer = mentoring.find_element_by_css_selector('blockquote.answer.read_only')
self.assertEqual(answer.text, '')
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 = 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):
self.assertFalse(mentoring.find_elements_by_css_selector('.warning'))
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('./*'))
mentoring = self.go_to_page('Progression 2')
......@@ -82,7 +82,7 @@ class MentoringProgressionTest(MentoringBaseTest):
submit.click()
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('./*'))
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>
<mentoring url_name="quizz_1" enforce_dependency="false">
<quizz name="quizz_1_1" type="choices">
<question>Do you like this quizz?</question>
<mentoring url_name="mcq_1" enforce_dependency="false">
<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>
......@@ -9,16 +9,16 @@
<tip display="yes">Great!</tip>
<tip reject="maybenot">Ah, damn.</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">
<question>How much do you rate this quizz?</question>
<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>
</quizz>
</mcq>
<message type="completed">
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