Commit b02b1e4e by Xavier Antoviaque

Merge pull request #1 from open-craft/real-children

Real children (OC-88)
parents 879241be 6e16e851
*~
*.pyc
/.coverage
/xblock_mentoring.egg-info
/workbench.sqlite
/workbench.*
/dist
/templates
language: python
python:
- "2.7"
before_install:
- "export DISPLAY=:99"
- "sh -e /etc/init.d/xvfb start"
install:
- "pip install -e git://github.com/edx/xblock-sdk.git#egg=xblock-sdk"
- "pip install -r $VIRTUAL_ENV/src/xblock-sdk/requirements.txt"
- "pip install -r $VIRTUAL_ENV/src/xblock-sdk/test-requirements.txt"
- "pip install -r requirements.txt"
script: python run_tests.py --with-coverage --cover-package=mentoring && pep8 mentoring --max-line-length=120 && pylint mentoring --disable=all --enable=function-redefined,undefined-variable,unused-variable
notifications:
email: false
Mentoring XBlock
----------------
[![Build Status](https://travis-ci.org/open-craft/xblock-mentoring.svg?branch=master)](https://travis-ci.org/open-craft/xblock-mentoring)
This XBlock allows to automate the workflow of real-life mentoring,
within an edX course.
......@@ -511,23 +513,22 @@ Access it at [http://localhost:8000/](http://localhost:8000).
Running tests
-------------
First, make sure the [XBlock SDK (Workbench)](https://github.com/edx/xblock-sdk)
is installed in the same virtual environment as xblock-mentoring.
From the xblock-mentoring repository root, run the tests with the
following command:
```bash
$ DJANGO_SETTINGS_MODULE="workbench.settings_mentoring" nosetests --with-django
$ ./run_tests.py
```
If you want to run only the integration or the unit tests, append the directory to the command. You can also run separate modules in this manner.
```bash
$ DJANGO_SETTINGS_MODULE="workbench.settings_mentoring" nosetests --with-django tests/unit
$ ./run_tests.py mentoring/tests/unit
```
If you have not installed the xblock-sdk in the active virtualenv,
you might also have to prepend `PYTHONPATH=".:/path/to/xblock"` to the command above.
(`/path/to/xblock` is the path to the xblock-sdk, where the workbench resides).
Adding custom scenarios to the workbench
----------------------------------------
......@@ -542,16 +543,8 @@ $ cat > templates/xml/my_mentoring_scenario.xml
Restart the workbench to take the new scenarios into account.
If you modified a scenario already loaded in the workbench,
you will also have to purge and rebuild the database:
```bash
rm workbench.sqlite
./manage.py syncdb --settings=workbench.settings_mentoring <<<"no"
```
License
-------
The Image Explorer XBlock is available under the GNU Affero General
The Mentoring XBlock is available under the GNU Affero General
Public License (AGPLv3).
from .answer import AnswerBlock
from .choice import ChoiceBlock
from .dataexport import MentoringDataExportBlock
from .html import HTMLBlock
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
from .title import TitleBlock
from .header import SharedHeaderBlock
from .answer import AnswerBlock
from .choice import ChoiceBlock
from .html import HTMLBlock
from .mcq import MCQBlock
from .mrq import MRQBlock
from .message import MentoringMessageBlock
from .table import MentoringTableBlock, MentoringTableColumnBlock, MentoringTableColumnHeaderBlock
from .tip import TipBlock
from .title import TitleBlock
from .header import SharedHeaderBlock
......@@ -24,59 +24,97 @@
# Imports ###########################################################
import logging
from lazy import lazy
from xblock.fragment import Fragment
from mentoring.models import Answer
from .light_children import LightChild, Boolean, Scope, String, Integer, Float
from xblock.core import XBlock
from xblock.fields import Scope, Boolean, Float, Integer, String
from xblock.fragment import Fragment
from xblockutils.resources import ResourceLoader
from .step import StepMixin
from .models import Answer
from .utils import loader
# Globals ###########################################################
log = logging.getLogger(__name__)
loader = ResourceLoader(__name__)
# Classes ###########################################################
class AnswerBlock(LightChild, StepMixin):
class AnswerBlock(XBlock, StepMixin):
"""
A field where the student enters an answer
Must be included as a child of a mentoring block. Answers are persisted as django model instances
to make them searchable and referenceable across xblocks.
"""
read_only = Boolean(help="Display as a read-only field", default=False, scope=Scope.content)
default_from = String(help="If specified, the name of the answer to get the default value from",
default=None, scope=Scope.content)
min_characters = Integer(help="Minimum number of characters allowed for the answer",
default=0, scope=Scope.content)
question = String(help="Question to ask the student", scope=Scope.content, default="")
weight = Float(help="Defines the maximum total grade of the light child block.",
default=1, scope=Scope.content, enforce_type=True)
name = String(
help="The ID of this block. Should be unique unless you want the answer to be used in multiple places.",
default="",
scope=Scope.content
)
read_only = Boolean(
help="Display as a read-only field",
default=False,
scope=Scope.content
)
default_from = String(
help="If specified, get the default value from this answer.",
default=None,
scope=Scope.content
)
min_characters = Integer(
help="Minimum number of characters allowed for the answer",
default=0,
scope=Scope.content
)
question = String(
help="Question to ask the student",
scope=Scope.content,
default=""
)
weight = Float(
help="Defines the maximum total grade of the answer block.",
default=1,
scope=Scope.settings,
enforce_type=True
)
@classmethod
def init_block_from_node(cls, block, node, attr):
block.light_children = []
for child_id, xml_child in enumerate(node):
def parse_xml(cls, node, runtime, keys, id_generator):
block = runtime.construct_xblock_from_class(cls, keys)
# Load XBlock properties from the XML attributes:
for name, value in node.items():
setattr(block, name, value)
for xml_child in node:
if xml_child.tag == 'question':
block.question = xml_child.text
else:
cls.add_node_as_child(block, xml_child, child_id)
for name, value in attr:
setattr(block, name, value)
block.runtime.add_node_as_child(block, xml_child, id_generator)
return block
def _get_course_id(self):
""" Get a course ID if available """
return getattr(self.runtime, 'course_id', 'all')
def _get_student_id(self):
""" Get student anonymous ID or normal ID """
try:
return self.runtime.anonymous_student_id
except AttributeError:
return self.scope_ids.user_id
@lazy
def student_input(self):
"""
Use lazy property instead of XBlock field, as __init__() doesn't support
overwriting field values
The student input value, or a default which may come from another block.
Read from a Django model, since XBlock API doesn't yet support course-scoped
fields or generating instructor reports across many student responses.
"""
# Only attempt to locate a model object for this block when the answer has a name
if not self.name:
......@@ -92,32 +130,32 @@ class AnswerBlock(LightChild, StepMixin):
def mentoring_view(self, context=None):
if not self.read_only:
html = loader.custom_render_js_template('templates/html/answer_editable.html', {
html = loader.render_template('templates/html/answer_editable.html', {
'self': self,
})
else:
html = loader.custom_render_js_template('templates/html/answer_read_only.html', {
html = loader.render_template('templates/html/answer_read_only.html', {
'self': self,
})
fragment = Fragment(html)
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,
'public/js/answer.js'))
fragment.add_css_url(self.runtime.local_resource_url(self, 'public/css/answer.css'))
fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/answer.js'))
fragment.initialize_js('AnswerBlock')
return fragment
def mentoring_table_view(self, context=None):
html = loader.custom_render_js_template('templates/html/answer_table.html', {
html = loader.render_template('templates/html/answer_table.html', {
'self': self,
})
fragment = Fragment(html)
fragment.add_css_url(self.runtime.local_resource_url(self.xblock_container, 'public/css/answer_table.css'))
fragment.add_css_url(self.runtime.local_resource_url(self, 'public/css/answer_table.css'))
return fragment
def submit(self, submission):
if not self.read_only:
self.student_input = submission[0]['value'].strip()
self.save()
log.info(u'Answer submitted for`{}`: "{}"'.format(self.name, self.student_input))
return {
'student_input': self.student_input,
......@@ -134,12 +172,20 @@ class AnswerBlock(LightChild, StepMixin):
return 'correct' if (self.read_only or answer_length_ok) else 'incorrect'
@property
def completed(self):
return self.status == 'correct'
def save(self):
"""
Replicate data changes on the related Django model used for sharing of data accross XBlocks
"""
super(AnswerBlock, self).save()
student_id = self._get_student_id()
if not student_id:
return # save() gets called from the workbench homepage sometimes when there is no student ID
# Only attempt to locate a model object for this block when the answer has a name
if self.name:
answer_data = self.get_model_object()
......@@ -158,11 +204,10 @@ class AnswerBlock(LightChild, StepMixin):
if not name:
raise ValueError('AnswerBlock.name field need to be set to a non-null/empty value')
# TODO: Why do we need to use `xmodule_runtime` and not `runtime`?
student_id = self.xmodule_runtime.anonymous_student_id
course_id = self.xmodule_runtime.course_id
student_id = self._get_student_id()
course_id = self._get_course_id()
answer_data, created = Answer.objects.get_or_create(
answer_data, _ = Answer.objects.get_or_create(
student_id=student_id,
course_id=course_id,
name=name,
......
......@@ -23,35 +23,17 @@
# Imports ###########################################################
import logging
from .light_children import LightChild, Scope, String
from .utils import loader, ContextConstants
# Globals ###########################################################
log = logging.getLogger(__name__)
from .common import BlockWithContent
from xblock.fields import Scope, String
# Classes ###########################################################
class ChoiceBlock(LightChild):
class ChoiceBlock(BlockWithContent):
"""
Custom choice of an answer for a MCQ/MRQ
"""
TEMPLATE = 'templates/html/choice.html'
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="")
has_children = True
def render(self):
# return self.content
"""
Returns a fragment containing the formatted tip
"""
fragment, named_children = self.get_children_fragment({ContextConstants.AS_TEMPLATE: False})
fragment.add_content(loader.render_template('templates/html/choice.html', {
'self': self,
'named_children': named_children,
}))
return self.xblock_container.fragment_text_rewriting(fragment)
# -*- coding: utf-8 -*-
#
# Copyright (c) 2014-2015 Harvard, edX & OpenCraft
#
# 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 ###########################################################
from xblock.core import XBlock
from xblock.fields import Scope, String
from xblock.fragment import Fragment
from xblockutils.resources import ResourceLoader
# Classes ###########################################################
class BlockWithContent(XBlock):
"""
A block that can contain simple text content OR <html> blocks
with rich HTML content.
"""
TEMPLATE = None # Override in subclass
content = String(help="Content", scope=Scope.content, default="")
has_children = True
def fallback_view(self, view_name, context=None):
"""
Returns a fragment containing the HTML
"""
fragment = Fragment()
child_content = u""
for child_id in self.children:
child = self.runtime.get_block(child_id)
child_fragment = child.render('mentoring_view', {})
fragment.add_frag_resources(child_fragment)
child_content += child_fragment.content
fragment.add_content(ResourceLoader(__name__).render_template(self.TEMPLATE, {
'self': self,
'content': self.content,
'child_content': child_content,
}))
return fragment # TODO: fragment_text_rewriting
def get_html(self):
""" Render as HTML - not as a Fragment """
return self.fallback_view(None, None).content
......@@ -22,38 +22,13 @@
# "AGPLv3". If not, see <http://www.gnu.org/licenses/>.
#
import logging
from lxml import etree
from xblock.fragment import Fragment
from .light_children import LightChild, Scope, String
from .html import HTMLBlock
log = logging.getLogger(__name__)
class SharedHeaderBlock(LightChild):
class SharedHeaderBlock(HTMLBlock):
"""
A shared header block shown under the title.
"""
FIXED_CSS_CLASS = "shared-header"
content = String(help="HTML content of the header", scope=Scope.content, default="")
@classmethod
def init_block_from_node(cls, block, node, attr):
block.light_children = []
node.tag = 'div'
block.content = unicode(etree.tostring(node))
node.tag = 'shared-header'
return block
def student_view(self, context=None):
return Fragment(u"<script type='text/template' id='light-child-template'>\n{}\n</script>".format(
self.content
))
def mentoring_view(self, context=None):
return self.student_view(context)
def mentoring_table_view(self, context=None):
return self.student_view(context)
pass
......@@ -23,55 +23,44 @@
# Imports ###########################################################
import logging
from lxml import etree
from xblock.core import XBlock
from xblock.fields import Scope, String
from xblock.fragment import Fragment
from .light_children import LightChild, Scope, String
# Globals ###########################################################
from .utils import ContextConstants
log = logging.getLogger(__name__)
# Classes ###########################################################
class HTMLBlock(LightChild):
class HTMLBlock(XBlock):
"""
A simplistic replacement for the HTML XModule, as a light XBlock child
Render content as HTML
"""
FIXED_CSS_CLASS = "html_child"
content = String(help="HTML content", scope=Scope.content, default="")
css_class = String(help="CSS Class[es] applied to wrapper div element", scope=Scope.content, default="")
@classmethod
def init_block_from_node(cls, block, node, attr):
block.light_children = []
node.tag = 'div'
node_classes = (cls for cls in [node.get('class', ''), 'html_child'] if cls)
node.set('class', " ".join(node_classes))
block.content = unicode(etree.tostring(node))
node.tag = 'html'
return block
def parse_xml(cls, node, runtime, keys, id_generator):
"""
Construct this XBlock from the given XML node.
"""
block = runtime.construct_xblock_from_class(cls, keys)
def student_view(self, context=None):
as_template = context.get(ContextConstants.AS_TEMPLATE, True) if context is not None else True
if as_template:
return Fragment(u"<script type='text/template' id='{}'>\n{}\n</script>".format(
'light-child-template',
self.content
))
if node.get('class'): # Older API used "class" property, not "css_class"
node.set('css_class', node.get('css_class', node.get('class')))
del node.attrib['class']
block.css_class = node.get('css_class')
# bug? got AssertionError if I don't use unicode here. (assert isinstance(content, unicode))
# Although it is set when constructed?
return Fragment(unicode(self.content))
block.content = unicode(node.text or u"")
for child in node:
block.content += etree.tostring(child, encoding='unicode')
def mentoring_view(self, context=None):
return self.student_view(context)
return block
def mentoring_table_view(self, context=None):
return self.student_view(context)
def fallback_view(self, view_name, context=None):
""" Default view handler """
css_class = ' '.join(cls for cls in (self.css_class, self.FIXED_CSS_CLASS) if cls)
html = u'<div class="{classes}">{content}</div>'.format(classes=css_class, content=unicode(self.content))
return Fragment(html)
......@@ -25,10 +25,10 @@
import logging
from xblock.fields import Scope, String
from xblockutils.resources import ResourceLoader
from .light_children import Scope, String
from .questionnaire import QuestionnaireAbstractBlock
from .utils import loader
# Globals ###########################################################
......@@ -53,15 +53,15 @@ class MCQBlock(QuestionnaireAbstractBlock):
log.debug(u'Received MCQ submission: "%s"', submission)
correct = True
tips_fragments = []
tips_html = []
for tip in self.get_tips():
correct = correct and self.is_tip_correct(tip, submission)
if submission in tip.display_with_defaults:
tips_fragments.append(tip.render())
tips_html.append(tip.get_html())
formatted_tips = loader.render_template('templates/html/tip_choice_group.html', {
formatted_tips = ResourceLoader(__name__).render_template('templates/html/tip_choice_group.html', {
'self': self,
'tips_fragments': tips_fragments,
'tips_html': tips_html,
'completed': correct,
})
......
......@@ -23,32 +23,17 @@
# Imports ###########################################################
import logging
from .light_children import LightChild, Scope, String
from .utils import loader
# Globals ###########################################################
log = logging.getLogger(__name__)
from .common import BlockWithContent
from xblock.fields import Scope, String
# Classes ###########################################################
class MentoringMessageBlock(LightChild):
class MentoringMessageBlock(BlockWithContent):
"""
A message which can be conditionally displayed at the mentoring block level,
for example upon completion of the block
"""
TEMPLATE = 'templates/html/message.html'
content = String(help="Message to display upon completion", scope=Scope.content, default="")
type = String(help="Type of message", scope=Scope.content, default="completed")
has_children = True
def mentoring_view(self, context=None):
fragment, named_children = self.get_children_fragment(context, view_name='mentoring_view')
fragment.add_content(loader.render_template('templates/html/message.html', {
'self': self,
'named_children': named_children,
}))
return fragment
......@@ -25,10 +25,9 @@
import logging
from .light_children import List, Scope, Boolean
from xblock.fields import List, Scope, Boolean
from .questionnaire import QuestionnaireAbstractBlock
from .utils import loader
from xblockutils.resources import ResourceLoader
# Globals ###########################################################
......@@ -53,11 +52,11 @@ class MRQBlock(QuestionnaireAbstractBlock):
results = []
for choice in self.custom_choices:
choice_completed = True
choice_tips_fragments = []
choice_tips_html = []
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())
choice_tips_html.append(tip.get_html())
if ((not choice_selected and choice.value in tip.require_with_defaults) or
(choice_selected and choice.value in tip.reject_with_defaults)):
......@@ -72,10 +71,11 @@ class MRQBlock(QuestionnaireAbstractBlock):
}
# Only include tips/results in returned response if we want to display them
if not self.hide_results:
loader = ResourceLoader(__name__)
choice_result['completed'] = choice_completed
choice_result['tips'] = loader.render_template('templates/html/tip_choice_group.html', {
'self': self,
'tips_fragments': choice_tips_fragments,
'tips_html': choice_tips_html,
'completed': choice_completed,
})
......
......@@ -61,10 +61,12 @@ function MessageView(element, mentoring) {
};
}
function MCQBlock(runtime, element, mentoring) {
function MCQBlock(runtime, element) {
return {
mode: null,
mentoring: null,
init: function(options) {
this.mentoring = options.mentoring;
this.mode = options.mode;
$('input[type=radio]', element).on('change', options.onChange);
},
......@@ -83,6 +85,8 @@ function MCQBlock(runtime, element, mentoring) {
if (this.mode === 'assessment')
return;
mentoring = this.mentoring;
var messageView = MessageView(element, mentoring);
messageView.clearResult();
......@@ -123,7 +127,7 @@ function MCQBlock(runtime, element, mentoring) {
},
clearResult: function() {
MessageView(element, mentoring).clearResult();
MessageView(element, this.mentoring).clearResult();
},
validate: function(){
......@@ -136,7 +140,9 @@ function MCQBlock(runtime, element, mentoring) {
function MRQBlock(runtime, element, mentoring) {
return {
mode: null,
mentoring: null,
init: function(options) {
this.mentoring = options.mentoring;
this.mode = options.mode;
$('input[type=checkbox]', element).on('change', options.onChange);
},
......@@ -155,6 +161,8 @@ function MRQBlock(runtime, element, mentoring) {
if (this.mode === 'assessment')
return;
mentoring = this.mentoring;
var messageView = MessageView(element, mentoring);
if (result.message) {
......@@ -193,7 +201,7 @@ function MRQBlock(runtime, element, mentoring) {
},
clearResult: function() {
MessageView(element, mentoring).clearResult();
MessageView(element, this.mentoring).clearResult();
},
validate: function(){
......
......@@ -23,25 +23,20 @@
# Imports ###########################################################
import logging
from lxml import etree
from xblock.core import XBlock
from xblock.fields import Scope, String, Float
from xblock.fragment import Fragment
from xblockutils.resources import ResourceLoader
from .choice import ChoiceBlock
from .step import StepMixin
from .light_children import LightChild, Scope, String, Float
from .tip import TipBlock
from .utils import loader, ContextConstants
# Globals ###########################################################
log = logging.getLogger(__name__)
# Classes ###########################################################
class QuestionnaireAbstractBlock(LightChild, StepMixin):
class QuestionnaireAbstractBlock(XBlock, StepMixin):
"""
An abstract class used for MCQ/MRQ blocks
......@@ -56,34 +51,36 @@ class QuestionnaireAbstractBlock(LightChild, StepMixin):
default=1, scope=Scope.content, enforce_type=True)
valid_types = ('choices')
has_children = True
@classmethod
def init_block_from_node(cls, block, node, attr):
block.light_children = []
for child_id, xml_child in enumerate(node):
def parse_xml(cls, node, runtime, keys, id_generator):
block = runtime.construct_xblock_from_class(cls, keys)
# Load XBlock properties from the XML attributes:
for name, value in node.items():
setattr(block, name, value)
for xml_child in node:
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)
for name, value in attr:
setattr(block, name, value)
elif xml_child.tag is not etree.Comment:
block.runtime.add_node_as_child(block, xml_child, id_generator)
return block
def student_view(self, context=None):
name = self.__class__.__name__
as_template = context.get(ContextConstants.AS_TEMPLATE, True) if context is not None else True
name = getattr(self, "unmixed_class", 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/{}_{}.html'.format(name.lower(), self.type)
loader = ResourceLoader(__name__)
render_function = loader.custom_render_js_template if as_template else loader.render_template
html = render_function(template_path, {
html = loader.render_template(template_path, {
'self': self,
'custom_choices': self.custom_choices
})
......@@ -92,8 +89,7 @@ class QuestionnaireAbstractBlock(LightChild, StepMixin):
fragment.add_css(loader.render_template('public/css/questionnaire.css', {
'self': self
}))
fragment.add_javascript_url(self.runtime.local_resource_url(self.xblock_container,
'public/js/questionnaire.js'))
fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/questionnaire.js'))
fragment.initialize_js(name)
return fragment
......@@ -103,7 +99,8 @@ class QuestionnaireAbstractBlock(LightChild, StepMixin):
@property
def custom_choices(self):
custom_choices = []
for child in self.get_children_objects():
for child_id in self.children:
child = self.runtime.get_block(child_id)
if isinstance(child, ChoiceBlock):
custom_choices.append(child)
return custom_choices
......@@ -113,7 +110,8 @@ class QuestionnaireAbstractBlock(LightChild, StepMixin):
Returns the tips contained in this block
"""
tips = []
for child in self.get_children_objects():
for child_id in self.children:
child = self.runtime.get_block(child_id)
if isinstance(child, TipBlock):
tips.append(child)
return tips
......
# -*- coding: utf-8 -*-
#
# Copyright (c) 2014-2015 Harvard, edX & OpenCraft
#
# This software's license gives you freedom; you can copy, convey,
# propagate, redistribute and/or modify this program under the terms of
# the GNU Affero General Public License (AGPL) as published by the Free
# Software Foundation (FSF), either version 3 of the License, or (at your
# option) any later version of the AGPL published by the FSF.
#
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
# General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program in a file in the toplevel directory called
# "AGPLv3". If not, see <http://www.gnu.org/licenses/>.
#
from .utils import child_isinstance
class StepParentMixin(object):
"""
An XBlock mixin for a parent block containing Step children
"""
@property
def steps(self):
"""
Get the usage_ids of all of this XBlock's children that are "Steps"
"""
return [child_id for child_id in self.children if child_isinstance(self, child_id, StepMixin)]
class StepMixin(object):
"""
An XBlock mixin for a child block that is a "Step"
"""
@property
def step_number(self):
return list(self.get_parent().steps).index(self.scope_ids.usage_id) + 1
@property
def lonely_step(self):
if self.scope_ids.usage_id not in self.get_parent().steps:
raise ValueError("Step's parent should contain Step", self, self.get_parent().steps)
return len(self.get_parent().steps) == 1
......@@ -24,22 +24,23 @@
# Imports ###########################################################
import errno
import logging
from xblock.fields import Scope
from .utils import child_isinstance
from .light_children import LightChild, String
from .utils import loader
from xblock.core import XBlock
from xblock.fields import Scope, String
from xblock.fragment import Fragment
from xblockutils.resources import ResourceLoader
# Globals ###########################################################
log = logging.getLogger(__name__)
loader = ResourceLoader(__name__)
# Classes ###########################################################
class MentoringTableBlock(LightChild):
class MentoringTableBlock(XBlock):
"""
Table-type display of information from mentoring blocks
......@@ -50,11 +51,19 @@ class MentoringTableBlock(LightChild):
has_children = True
def student_view(self, context):
fragment, columns_frags = self.get_children_fragment(context, view_name='mentoring_table_view')
f, header_frags = self.get_children_fragment(context, view_name='mentoring_table_header_view')
bg_image_url = self.runtime.local_resource_url(self.xblock_container,
'public/img/{}-bg.png'.format(self.type))
fragment = Fragment()
columns_frags = []
header_frags = []
for child_id in self.children:
child = self.runtime.get_block(child_id)
column_fragment = child.render('mentoring_table_view', context)
fragment.add_frag_resources(column_fragment)
columns_frags.append((child.name, column_fragment))
header_fragment = child.render('mentoring_table_header_view', context)
fragment.add_frag_resources(header_fragment)
header_frags.append((child.name, header_fragment))
bg_image_url = self.runtime.local_resource_url(self, 'public/img/{}-bg.png'.format(self.type))
# Load an optional description for the background image, for accessibility
try:
......@@ -72,12 +81,9 @@ class MentoringTableBlock(LightChild):
'bg_image_url': bg_image_url,
'bg_image_description': bg_image_description,
}))
fragment.add_css_url(self.runtime.local_resource_url(self.xblock_container,
'public/css/mentoring-table.css'))
fragment.add_javascript_url(self.runtime.local_resource_url(self.xblock_container,
'public/js/vendor/jquery-shorten.js'))
fragment.add_javascript_url(self.runtime.local_resource_url(self.xblock_container,
'public/js/mentoring-table.js'))
fragment.add_css_url(self.runtime.local_resource_url(self, 'public/css/mentoring-table.css'))
fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/vendor/jquery-shorten.js'))
fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/mentoring-table.js'))
fragment.initialize_js('MentoringTableBlock')
return fragment
......@@ -87,47 +93,57 @@ class MentoringTableBlock(LightChild):
return self.student_view(context)
class MentoringTableColumnBlock(LightChild):
class MentoringTableColumnBlock(XBlock):
"""
Individual column of a mentoring table
"""
header = String(help="Header of the column", scope=Scope.content, default=None)
has_children = True
def mentoring_table_view(self, context):
"""
The content of the column
"""
fragment, named_children = self.get_children_fragment(
context, view_name='mentoring_table_view',
not_instance_of=MentoringTableColumnHeaderBlock)
fragment.add_content(loader.render_template('templates/html/mentoring-table-column.html', {
def _render_table_view(self, view_name, id_filter, template, context):
fragment = Fragment()
named_children = []
for child_id in self.children:
if id_filter(child_id):
child = self.runtime.get_block(child_id)
child_frag = child.render(view_name, context)
fragment.add_frag_resources(child_frag)
named_children.append((child.name, child_frag))
fragment.add_content(loader.render_template('templates/html/{}'.format(template), {
'self': self,
'named_children': named_children,
}))
return fragment
def mentoring_table_view(self, context):
"""
The content of the column
"""
return self._render_table_view(
view_name='mentoring_table_view',
id_filter=lambda child_id: not child_isinstance(self, child_id, MentoringTableColumnHeaderBlock),
template='mentoring-table-column.html',
context=context
)
def mentoring_table_header_view(self, context):
"""
The content of the column's header
"""
fragment, named_children = self.get_children_fragment(
context, view_name='mentoring_table_header_view',
instance_of=MentoringTableColumnHeaderBlock)
fragment.add_content(loader.render_template('templates/html/mentoring-table-header.html', {
'self': self,
'named_children': named_children,
}))
return fragment
return self._render_table_view(
view_name='mentoring_table_header_view',
id_filter=lambda child_id: child_isinstance(self, child_id, MentoringTableColumnHeaderBlock),
template='mentoring-table-header.html',
context=context
)
class MentoringTableColumnHeaderBlock(LightChild):
class MentoringTableColumnHeaderBlock(XBlock):
"""
Header content for a given column
"""
content = String(help="Body of the header", scope=Scope.content, default='')
def mentoring_table_header_view(self, context):
fragment = super(MentoringTableColumnHeaderBlock, self).children_view(context)
fragment.add_content(unicode(self.content))
return fragment
return Fragment(unicode(self.content))
<span class="choice-text">
{{ self.content }}
{{ child_content|safe }}
</span>
......@@ -9,7 +9,7 @@
<div class="choice-result fa icon-2x"></div>
<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.render.body_html|safe }}
{{ choice.get_html|safe }}
</label>
<div class="choice-tips"></div>
</div>
......
......@@ -35,7 +35,7 @@
<div class="choice">
<div class="choice-result fa icon-2x"></div>
<label><input type="radio" name="{{ self.name }}" value="{{ choice.value }}"{% if self.student_choice == '{{ choice.value }}' %} checked{% endif %} />
{{ choice.render.body_html|safe }}
{{ choice.get_html|safe }}
</label>
<div class="choice-tips"></div>
</div>
......
<div class="message {{ self.type }}">
{% for name, c in named_children %}
{{c.body_html|safe}}
{% endfor %}
{% if self.content %}
<p>{{ self.content }}
<p>{{ self.content }}</p>
{% endif %}
{{ child_content|safe }}
</div>
......@@ -11,7 +11,7 @@
<input class="choice-selector" type="checkbox" name="{{ self.name }}"
value="{{ choice.value }}"
{% if choice.value in self.student_choices %} checked{% endif %} />
{{ choice.render.body_html|safe }}
{{ choice.get_html|safe }}
</label>
<div class="choice-tips"></div>
</div>
......
<div
class="tip"
{% if width %}data-width="{{width}}"{% endif %}
{% if height %}data-height="{{height}}"{% endif %}
>
{% if self.content %}
<p>{{ self.content }}</p>
{% endif %}
{{ child_content|safe }}
</div>
<div class="tip-choice-group">
{% for tip_fragment in tips_fragments %}
{{ tip_fragment.body_html|safe }}
{% for tip_html in tips_html %}
{{ tip_html|safe }}
{% endfor %}
</div>
<div class="close icon-remove-sign fa fa-times-circle"></div>
......@@ -23,19 +23,12 @@
# Imports ###########################################################
import logging
from .light_children import LightChild, Scope, String
from .utils import loader
# Globals ###########################################################
log = logging.getLogger(__name__)
from .common import BlockWithContent
from xblock.fields import Scope, String
# Functions #########################################################
def commas_to_set(commas_str):
"""
Converts a comma-separated string to a set
......@@ -48,28 +41,18 @@ def commas_to_set(commas_str):
# Classes ###########################################################
class TipBlock(LightChild):
class TipBlock(BlockWithContent):
"""
Each choice can define a tip depending on selection
"""
TEMPLATE = 'templates/html/tip.html'
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)
width = String(help="Width of the tip popup", scope=Scope.content, default='')
height = String(help="Height of the tip popup", scope=Scope.content, default='')
has_children = True
def render(self):
"""
Returns a fragment containing the formatted tip
"""
fragment, named_children = self.get_children_fragment({})
fragment.add_content(loader.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):
......
......@@ -23,18 +23,13 @@
# Imports ###########################################################
import logging
from .light_children import LightChild, Scope, String
# Globals ###########################################################
log = logging.getLogger(__name__)
from xblock.core import XBlock
from xblock.fields import Scope, String
# Classes ###########################################################
class TitleBlock(LightChild):
class TitleBlock(XBlock):
"""
A simple html representation of a title, with the mentoring weight.
"""
......
"""
Helper methods
Should eventually be moved to xblock-utils.
"""
def child_isinstance(block, child_id, block_class_or_mixin):
"""
Is "block"'s child identified by usage_id "child_id" an instance of
"block_class_or_mixin"?
This is a bit complicated since it avoids the need to actually
instantiate the child block.
"""
def_id = block.runtime.id_reader.get_definition_id(child_id)
type_name = block.runtime.id_reader.get_block_type(def_id)
child_class = block.runtime.load_block_type(type_name)
return issubclass(child_class, block_class_or_mixin)
......@@ -24,24 +24,41 @@
# Imports ###########################################################
import logging
import unicodecsv
from itertools import groupby
from StringIO import StringIO
from webob import Response
from xblock.core import XBlock
from xblock.fields import String, Scope
from xblock.fragment import Fragment
from xblockutils.resources import ResourceLoader
from .models import Answer
from .utils import list2csv, loader
from .components.answer import AnswerBlock, Answer
# Globals ###########################################################
log = logging.getLogger(__name__)
loader = ResourceLoader(__name__)
# Utils ###########################################################
def list2csv(row):
"""
Convert a list to a CSV string (single row)
"""
f = StringIO()
writer = unicodecsv.writer(f, encoding='utf-8')
writer.writerow(row)
result = f.getvalue()
f.close()
return result
# Classes ###########################################################
class MentoringDataExportBlock(XBlock):
"""
An XBlock allowing the instructor team to export all the student answers as a CSV file
......@@ -50,20 +67,16 @@ class MentoringDataExportBlock(XBlock):
scope=Scope.settings)
def student_view(self, context):
"""
Main view of the data export block
"""
# Count how many 'Answer' blocks are in this course:
num_answer_blocks = sum(1 for i in self._get_answer_blocks())
html = loader.render_template('templates/html/dataexport.html', {
'self': self,
'download_url': self.runtime.handler_url(self, 'download_csv'),
'num_answer_blocks': num_answer_blocks,
})
fragment = Fragment(html)
fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/dataexport.js'))
fragment.add_css_url(self.runtime.local_resource_url(self, 'public/css/dataexport.css'))
fragment.initialize_js('MentoringDataExportBlock')
return fragment
def studio_view(self, context):
return Fragment(u'Studio view body')
return Fragment(html)
@XBlock.handler
def download_csv(self, request, suffix=''):
......@@ -73,8 +86,7 @@ class MentoringDataExportBlock(XBlock):
return response
def get_csv(self):
course_id = self.xmodule_runtime.course_id
course_id = getattr(self.runtime, "course_id", "all")
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')
......@@ -82,7 +94,7 @@ class MentoringDataExportBlock(XBlock):
yield list2csv([u'student_id'] + list(answers_names))
if answers_names:
for k, student_answers in groupby(answers, lambda x: x.student_id):
for _, student_answers in groupby(answers, lambda x: x.student_id):
row = []
next_answer_idx = 0
for answer in student_answers:
......@@ -99,3 +111,23 @@ class MentoringDataExportBlock(XBlock):
if row:
yield list2csv(row)
def _get_answer_blocks(self):
"""
Generator.
Searches the tree of XBlocks that includes this data export block
(i.e. search the current course)
and returns all the AnswerBlock blocks that we can see.
"""
root_block = self
while root_block.parent:
root_block = root_block.get_parent()
block_ids_left = set([root_block.scope_ids.usage_id])
while block_ids_left:
block = self.runtime.get_block(block_ids_left.pop())
if isinstance(block, AnswerBlock):
yield block
elif block.has_children:
block_ids_left |= set(block.children)
......@@ -53,12 +53,18 @@ class Answer(models.Model):
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.
DEPRECATED.
Django model previously used to store LightChild student data.
This is not used at all by any of the mentoring blocks but will
be kept here for the purpose of migrating data for other
LightChildren that are converted to XBlocks and need to migrate
data from Django to native XBlock fields.
"""
class Meta:
app_label = 'mentoring'
managed = False # Don't create this table. This class is only to migrate data from an existing table.
unique_together = (('student_id', 'course_id', 'name'),)
name = models.CharField(max_length=100, db_index=True)
......
.mentoring-dataexport {
margin: 10px;
}
function MentoringDataExportBlock(runtime, element) {
var downloadUrl = runtime.handlerUrl(element, 'download_csv');
$('button.download', element).click(function(ev) {
ev.preventDefault();
window.location = downloadUrl;
});
}
function MentoringBlock(runtime, element) {
var attemptsTemplate = _.template($('#xblock-attempts-template').html());
var data = $('.mentoring', element).data();
var children_dom = []; // Keep track of children. A Child need a single object scope for its data.
var children = [];
var children = runtime.children(element);
var step = data.step;
var mentoring = {
callIfExists: callIfExists,
setContent: setContent,
renderAttempts: renderAttempts,
renderDependency: renderDependency,
children: children,
initChildren: initChildren,
getChildByName: getChildByName,
hideAllChildren: hideAllChildren,
step: step,
publish_event: publish_event
};
function publish_event(data) {
$.ajax({
type: "POST",
......@@ -66,44 +78,23 @@ function MentoringBlock(runtime, element) {
}
}
function readChildren() {
var doms = $('.xblock-light-child', element);
$.each(doms, function(index, child_dom) {
var child_type = $(child_dom).attr('data-type');
var child = window[child_type];
children_dom.push(child_dom);
children.push(child);
if (typeof child !== 'undefined') {
child = child(runtime, child_dom, mentoring);
child.name = $(child_dom).attr('name');
children[children.length-1] = child;
}
});
}
/* Init and display a child. */
function displayChild(index, options) {
function initChildren(options) {
options = options || {};
options.mentoring = mentoring;
options.mode = data.mode;
if (index >= children.length)
return children.length;
var template = $('#light-child-template', children_dom[index]).html();
$(children_dom[index]).append(template);
$(children_dom[index]).show();
var child = children[index];
for (var i=0; i < children.length; i++) {
var child = children[i];
callIfExists(child, 'init', options);
return child;
}
}
function displayChildren(options) {
$.each(children_dom, function(index) {
displayChild(index, options);
});
function hideAllChildren() {
for (var i=0; i < children.length; i++) {
$(children[i].element).hide();
}
}
function getChildByName(element, name) {
function getChildByName(name) {
for (var i = 0; i < children.length; i++) {
var child = children[i];
if (child && child.name === name) {
......@@ -112,21 +103,6 @@ function MentoringBlock(runtime, element) {
}
}
var mentoring = {
callIfExists: callIfExists,
setContent: setContent,
renderAttempts: renderAttempts,
renderDependency: renderDependency,
readChildren: readChildren,
children_dom: children_dom,
children: children,
displayChild: displayChild,
displayChildren: displayChildren,
getChildByName: getChildByName,
step: step,
publish_event: publish_event
};
if (data.mode === 'standard') {
MentoringStandardView(runtime, element, mentoring);
}
......
......@@ -13,8 +13,11 @@ function MentoringAssessmentView(runtime, element, mentoring) {
checkmark.removeClass('checkmark-partially-correct icon-ok fa-check');
checkmark.removeClass('checkmark-incorrect icon-exclamation fa-exclamation');
/* hide all children */
$(':nth-child(2)', mentoring.children_dom).remove();
// Clear all selections
$('input[type=radio], input[type=checkbox]', element).prop('checked', false);
// hide all children
mentoring.hideAllChildren();
$('.grade').html('');
$('.attempts').html('');
......@@ -76,9 +79,18 @@ function MentoringAssessmentView(runtime, element, mentoring) {
reviewDOM.bind('click', renderGrade);
tryAgainDOM.bind('click', tryAgain);
active_child = mentoring.step-1;
mentoring.readChildren();
active_child = mentoring.step;
var options = {
onChange: onChange
};
mentoring.initChildren(options);
if (isDone()) {
renderGrade();
} else {
active_child = active_child - 1;
displayNextChild();
}
mentoring.renderDependency();
}
......@@ -92,24 +104,16 @@ function MentoringAssessmentView(runtime, element, mentoring) {
}
function displayNextChild() {
var options = {
onChange: onChange
};
cleanAll();
// find the next real child block to display. HTMLBlock are always displayed
++active_child;
while (1) {
var child = mentoring.displayChild(active_child, options);
active_child++;
var child = mentoring.children[active_child];
$(child.element).show();
mentoring.publish_event({
event_type: 'xblock.mentoring.assessment.shown',
exercise_id: $(child).attr('name')
exercise_id: child.name
});
if ((typeof child !== 'undefined') || active_child == mentoring.children.length-1)
break;
++active_child;
}
if (isDone())
renderGrade();
......@@ -151,8 +155,7 @@ function MentoringAssessmentView(runtime, element, mentoring) {
if (result.step != active_child+1) {
active_child = result.step-1;
displayNextChild();
}
else {
} else {
nextDOM.removeAttr("disabled");
reviewDOM.removeAttr("disabled");
}
......
......@@ -10,7 +10,7 @@ function MentoringStandardView(runtime, element, mentoring) {
$.each(results.submitResults || [], function(index, submitResult) {
var input = submitResult[0];
var result = submitResult[1];
var child = mentoring.getChildByName(element, input);
var child = mentoring.getChildByName(input);
var options = {
max_attempts: results.max_attempts,
num_attempts: results.num_attempts
......@@ -73,7 +73,7 @@ function MentoringStandardView(runtime, element, mentoring) {
onChange: onChange
};
mentoring.displayChildren(options);
mentoring.initChildren(options);
mentoring.renderAttempts();
mentoring.renderDependency();
......@@ -81,17 +81,6 @@ function MentoringStandardView(runtime, element, mentoring) {
validateXBlock();
}
function handleRefreshResults(results) {
$(element).html(results.html);
mentoring.readChildren();
initXBlockView();
}
function refreshXBlock() {
var handlerUrl = runtime.handlerUrl(element, 'view');
$.post(handlerUrl, '{}').success(handleRefreshResults);
}
// validate all children
function validateXBlock() {
var is_valid = true;
......@@ -100,8 +89,7 @@ function MentoringStandardView(runtime, element, mentoring) {
if ((data.max_attempts > 0) && (data.num_attempts >= data.max_attempts)) {
is_valid = false;
}
else {
} else {
for (var i = 0; i < children.length; i++) {
var child = children[i];
if (child && child.name !== undefined) {
......@@ -112,15 +100,12 @@ function MentoringStandardView(runtime, element, mentoring) {
}
}
}
if (!is_valid) {
submitDOM.attr('disabled','disabled');
}
else {
} else {
submitDOM.removeAttr("disabled");
}
}
// We need to manually refresh, XBlocks are currently loaded together with the section
refreshXBlock(element);
initXBlockView();
}
class StepParentMixin(object):
"""
A parent containing the Step objects
The parent must have a get_children_objects() method.
"""
@property
def steps(self):
return [child for child in self.get_children_objects() if isinstance(child, StepMixin)]
class StepMixin(object):
@property
def step_number(self):
return self.parent.steps.index(self) + 1
@property
def lonely_step(self):
if self not in self.parent.steps:
raise ValueError("Step's parent should contain Step", self, self.parents.steps)
return len(self.parent.steps) == 1
<span class="choice-text">
{% if self.content %}{{ self.content }}{% endif %}
{% for name, c in named_children %}
{{c.body_html|safe}}
{% endfor %}
</span>
\ No newline at end of file
<div class="mentoring-dataexport">
<h3>Answers data dump</h3>
<button class="download">Download CSV</button>
<p><a style="font-weight: bold;" href="{{ download_url }}">Download CSV Export</a> for the {{ num_answer_blocks }} answer{{ num_answer_blocks|pluralize }} in this course.</p>
</div>
......@@ -4,17 +4,15 @@
attempting this step.
</div>
{% if self.title or self.header %}
{% if title or header %}
<div class="title">
{% if self.title %} <h2 class="main">{{ self.title.content }}</h2> {% endif %}
{% if self.header %} <div class="shared-header">{{ self.header.content|safe }}</div> {% endif %}
{% if title %} <h2>{{ title }}</h2> {% endif %}
{% if header %} {{ header|safe }} {% endif %}
</div>
{% endif %}
<div class="{{self.mode}}-question-block">
{% for name, c in named_children %}
{{c.body_html|safe}}
{% endfor %}
{{child_content|safe}}
{% if self.display_submit %}
......
<div
class="tip"
{% if self.width %}data-width="{{self.width}}"{% endif %}
{% if self.height %}data-height="{{self.height}}"{% endif %}
>
{% if self.content %}<p>{{ self.content }}</p>{% endif %}
{% for name, c in named_children %}
{{c.body_html|safe}}
{% endfor %}
</div>
<mentoring url_name="{{ url_name }}" display_name="Nav tooltip title" weight="1" mode="assessment" max_attempts="10">
<mentoring display_name="Nav tooltip title" weight="1" mode="assessment" max_attempts="10">
<title>Default Title</title>
<shared-header>
<p>This paragraph is shared between <strong>all</strong> questions.</p>
......
<vertical_demo>
<html>
<p>Below are two mentoring blocks and a grade export block that
should be able to export their data as CSV.</p>
</html>
<mentoring display_name="Mentoring Block 1" mode="standard">
<title>Mentoring Block 1</title>
<html>
<p>Please answer the question below.</p>
</html>
<answer name="goal">
<question>What is your goal?</question>
</answer>
</mentoring>
<mentoring display_name="Mentoring Block 1" mode="assessment">
<title>Mentoring Block 2 (Assessment)</title>
<html>
<p>Please answer the question below.</p>
</html>
<answer name="inspired">
<question>Who has inspired you the most?</question>
</answer>
<answer name="meaning">
<question>What is the meaning of life?</question>
</answer>
</mentoring>
<mentoring-dataexport display_name="Data Export Test" />
</vertical_demo>
<mentoring url_name="{{ url_name }}" display_name="Nav tooltip title" weight="1" mode="standard">
<mentoring display_name="Nav tooltip title" weight="1" mode="standard">
<title>Default Title</title>
<html>
<p>Please answer the questions below.</p>
......
<mentoring url_name="{{ url_name }}" display_name="Nav tooltip title" weight="1" mode="standard">
<mentoring display_name="Nav tooltip title" weight="1" mode="standard">
<title>Default Title</title>
<html>
<p>Please answer the questions below.</p>
......
......@@ -2,8 +2,9 @@ from .base_test import MentoringBaseTest
CORRECT, INCORRECT, PARTIAL = "correct", "incorrect", "partially-correct"
class MentoringAssessmentTest(MentoringBaseTest):
def _selenium_bug_workaround_scroll_to(self, mentoring):
def _selenium_bug_workaround_scroll_to(self, mentoring, question):
"""Workaround for selenium bug:
Some version of Selenium has a bug that prevents scrolling
......@@ -21,7 +22,7 @@ class MentoringAssessmentTest(MentoringBaseTest):
control buttons to fit.
"""
controls = mentoring.find_element_by_css_selector("div.submit")
title = mentoring.find_element_by_css_selector("h3.question-title")
title = question.find_element_by_css_selector("h3.question-title")
controls.click()
title.click()
......@@ -40,13 +41,9 @@ class MentoringAssessmentTest(MentoringBaseTest):
self.assertIn("A Simple Assessment", mentoring.text)
self.assertIn("This paragraph is shared between all questions.", mentoring.text)
def assert_disabled(self, elem):
self.assertTrue(elem.is_displayed())
self.assertFalse(elem.is_enabled())
class _GetChoices(object):
def __init__(self, mentoring, selector=".choices"):
self._mcq = mentoring.find_element_by_css_selector(selector)
def __init__(self, question, selector=".choices"):
self._mcq = question.find_element_by_css_selector(selector)
@property
def text(self):
......@@ -59,7 +56,6 @@ class MentoringAssessmentTest(MentoringBaseTest):
for choice in self._mcq.find_elements_by_css_selector(".choice")}
def select(self, text):
state = {}
for choice in self._mcq.find_elements_by_css_selector(".choice"):
if choice.text == text:
choice.find_element_by_css_selector("input").click()
......@@ -74,7 +70,6 @@ class MentoringAssessmentTest(MentoringBaseTest):
for name, count in states.items():
self.assertEqual(len(mentoring.find_elements_by_css_selector(".checkmark-{}".format(name))), count)
def go_to_workbench_main_page(self):
self.browser.get(self.live_server_url)
......@@ -101,9 +96,9 @@ class MentoringAssessmentTest(MentoringBaseTest):
return "QUESTION"
def freeform_answer(self, number, mentoring, controls, text_input, result, saved_value="", last=False):
self.wait_until_text_in(self.question_text(number), mentoring)
question = self.expect_question_visible(number, mentoring)
self.assert_persistent_elements_present(mentoring)
self._selenium_bug_workaround_scroll_to(mentoring)
self._selenium_bug_workaround_scroll_to(mentoring, question)
answer = mentoring.find_element_by_css_selector("textarea.answer.editable")
......@@ -161,16 +156,17 @@ class MentoringAssessmentTest(MentoringBaseTest):
controls.next_question.click()
def single_choice_question(self, number, mentoring, controls, choice_name, result, last=False):
self.wait_until_text_in(self.question_text(number), mentoring)
question = self.expect_question_visible(number, mentoring)
self.assert_persistent_elements_present(mentoring)
self._selenium_bug_workaround_scroll_to(mentoring)
self.assertIn("Do you like this MCQ?", mentoring.text)
self.assertIn("Do you like this MCQ?", question.text)
self.assert_disabled(controls.submit)
self.ending_controls(controls, last)
self.assert_hidden(controls.try_again)
choices = self._GetChoices(mentoring)
choices = self._GetChoices(question)
expected_state = {"Yes": False, "Maybe not": False, "I don't understand": False}
self.assertEquals(choices.state, expected_state)
......@@ -188,9 +184,9 @@ class MentoringAssessmentTest(MentoringBaseTest):
self.do_post(controls, last)
def rating_question(self, number, mentoring, controls, choice_name, result, last=False):
self.wait_until_text_in(self.question_text(number), mentoring)
question = self.expect_question_visible(number, mentoring)
self.assert_persistent_elements_present(mentoring)
self._selenium_bug_workaround_scroll_to(mentoring)
self._selenium_bug_workaround_scroll_to(mentoring, question)
self.assertIn("How much do you rate this MCQ?", mentoring.text)
self.assert_disabled(controls.submit)
......@@ -218,19 +214,37 @@ class MentoringAssessmentTest(MentoringBaseTest):
self._assert_checkmark(mentoring, result)
self.do_post(controls, last)
def peek_at_multiple_choice_question(self, number, mentoring, controls, last=False):
def expect_question_visible(self, number, mentoring):
question_text = self.question_text(number)
self.wait_until_text_in(self.question_text(number), mentoring)
question_div = None
for xblock_div in mentoring.find_elements_by_css_selector('div.xblock-v1'):
header_text = xblock_div.find_elements_by_css_selector('h3.question-title')
if header_text and question_text in header_text[0].text:
question_div = xblock_div
self.assertTrue(xblock_div.is_displayed())
elif header_text:
self.assertFalse(xblock_div.is_displayed())
# else this is an HTML block or something else, not a question step
self.assertIsNotNone(question_div)
return question_div
def peek_at_multiple_choice_question(self, number, mentoring, controls, last=False):
question = self.expect_question_visible(number, mentoring)
self.assert_persistent_elements_present(mentoring)
self._selenium_bug_workaround_scroll_to(mentoring)
self._selenium_bug_workaround_scroll_to(mentoring, question)
self.assertIn("What do you like in this MRQ?", mentoring.text)
self.assert_disabled(controls.submit)
self.ending_controls(controls, last)
return question
def multiple_choice_question(self, number, mentoring, controls, choice_names, result, last=False):
self.peek_at_multiple_choice_question(number, mentoring, controls, last=last)
question = self.peek_at_multiple_choice_question(number, mentoring, controls, last=last)
choices = self._GetChoices(mentoring)
choices = self._GetChoices(question)
expected_choices = {
"Its elegance": False,
"Its beauty": False,
......@@ -283,14 +297,16 @@ class MentoringAssessmentTest(MentoringBaseTest):
expected_results = {
"correct": 2, "partial": 1, "incorrect": 1, "percentage": 63,
"num_attempts": 1, "max_attempts": 2}
"num_attempts": 1, "max_attempts": 2
}
self.peek_at_review(mentoring, controls, expected_results)
self.assert_clickable(controls.try_again)
controls.try_again.click()
self.freeform_answer(1, mentoring, controls, 'This is a different answer', CORRECT,
saved_value='This is the answer')
self.freeform_answer(
1, mentoring, controls, 'This is a different answer', CORRECT, saved_value='This is the answer'
)
self.single_choice_question(2, mentoring, controls, 'Yes', CORRECT)
self.rating_question(3, mentoring, controls, "1 - Not good at all", INCORRECT)
......@@ -299,7 +315,8 @@ class MentoringAssessmentTest(MentoringBaseTest):
expected_results = {
"correct": 3, "partial": 0, "incorrect": 1, "percentage": 75,
"num_attempts": 2, "max_attempts": 2}
"num_attempts": 2, "max_attempts": 2
}
self.peek_at_review(mentoring, controls, expected_results)
self.assert_disabled(controls.try_again)
......@@ -312,7 +329,8 @@ class MentoringAssessmentTest(MentoringBaseTest):
expected_results = {
"correct": 0, "partial": 0, "incorrect": 1, "percentage": 0,
"num_attempts": 1, "max_attempts": 2}
"num_attempts": 1, "max_attempts": 2
}
self.peek_at_review(mentoring, controls, expected_results)
......
# -*- coding: utf-8 -*-
#
# Copyright (c) 2014-2015 Harvard, edX & OpenCraft
#
# 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 ddt
import urllib2
from .base_test import MentoringBaseTest
# Classes ###########################################################
@ddt.ddt
class AnswerBlockTest(MentoringBaseTest):
"""
Test mentoring's data export tool.
"""
default_css_selector = 'body'
def go_to_page_as_student(self, page_name, student_id):
"""
Navigate to the page `page_name`, as listed on the workbench home
but override the student_id used
"""
self.browser.get(self.live_server_url)
href = self.browser.find_element_by_link_text(page_name).get_attribute("href")
href += "?student={}".format(student_id)
self.browser.get(href)
block = self.browser.find_element_by_css_selector(self._default_css_selector)
return block
def click_submit_button(self, page, mentoring_block_index):
"""
Click on one of the submit buttons on the page
"""
mentoring_div = page.find_elements_by_css_selector('div.mentoring')[mentoring_block_index]
submit = mentoring_div.find_element_by_css_selector('.submit .input-main')
self.assertTrue(submit.is_enabled())
submit.click()
self.wait_until_disabled(submit)
@ddt.data(
# student submissions, expected CSV text
(
[
("student10", "Essay1", "Essay2", "Essay3"),
("student20", "I didn't answer the last two questions", None, None),
],
(
u"student_id,goal,inspired,meaning\r\n"
"student10,Essay1,Essay2,Essay3\r\n"
"student20,I didn't answer the last two questions,,\r\n"
)
),
)
@ddt.unpack
def test_data_export_edit(self, submissions, expected_csv):
"""
Have students submit answers, then run an export and validate the output
"""
for student_id, answer1, answer2, answer3 in submissions:
page = self.go_to_page_as_student('Data Export', student_id)
answer1_field = page.find_element_by_css_selector('div[data-name=goal] textarea')
self.assertEqual(answer1_field.text, '')
answer1_field.send_keys(answer1)
self.click_submit_button(page, 0)
if answer2:
answer2_field = page.find_element_by_css_selector('div[data-name=inspired] textarea')
self.assertEqual(answer2_field.text, '')
answer2_field.send_keys(answer2)
self.click_submit_button(page, 1)
mentoring_div = page.find_elements_by_css_selector('div.mentoring')[1]
next = mentoring_div.find_element_by_css_selector('.submit .input-next')
next.click()
self.wait_until_disabled(next)
if answer3:
answer3_field = page.find_element_by_css_selector('div[data-name=meaning] textarea')
self.assertEqual(answer3_field.text, '')
answer3_field.send_keys(answer3)
self.click_submit_button(page, 1)
export_div = page.find_element_by_css_selector('.mentoring-dataexport')
self.assertIn("for the 3 answers in this course", export_div.text)
# Now "click" on the export link:
download_url = self.browser.find_element_by_link_text('Download CSV Export').get_attribute("href")
response = urllib2.urlopen(download_url)
headers = response.info()
self.assertTrue(headers['Content-Type'].startswith('text/csv'))
csv_data = response.read()
self.assertEqual(csv_data, expected_csv)
......@@ -176,6 +176,7 @@ class MCQBlockTest(MentoringBaseTest):
choice_wrapper = choices_list.find_elements_by_css_selector(".choice")[index]
choice_wrapper.find_element_by_css_selector(".choice-selector").click() # clicking on actual radio button
submit.click()
self.wait_until_disabled(submit)
item_feedback_icon = choice_wrapper.find_element_by_css_selector(".choice-result")
choice_wrapper.click()
item_feedback_icon.click() # clicking on item feedback icon
......@@ -193,9 +194,8 @@ class MCQBlockTest(MentoringBaseTest):
result = []
# this could be a list comprehension, but a bit complicated one - hence explicit loop
for choice_wrapper in questionnaire.find_elements_by_css_selector(".choice"):
choice_label = choice_wrapper.find_element_by_css_selector(".choice-label .choice-text")
light_child = choice_label.find_element_by_css_selector(".xblock-light-child")
result.append(light_child.find_element_by_css_selector("div").get_attribute('innerHTML'))
choice_label = choice_wrapper.find_element_by_css_selector("label .choice-text")
result.append(choice_label.find_element_by_css_selector("div.html_child").get_attribute('innerHTML'))
return result
......
<vertical_demo>
<html>
<p>Below are two mentoring blocks and a grade export block that
should be able to export their data as CSV.</p>
</html>
<mentoring display_name="Mentoring Block 1" mode="standard">
<title>Mentoring Block 1</title>
<html>
<p>Please answer the question below.</p>
</html>
<answer name="goal">
<question>What is your goal?</question>
</answer>
</mentoring>
<mentoring display_name="Mentoring Block 1" mode="assessment">
<title>Mentoring Block 2 (Assessment)</title>
<html>
<p>Please answer the question below.</p>
</html>
<answer name="inspired">
<question>Who has inspired you the most?</question>
</answer>
<answer name="meaning">
<question>What is the meaning of life?</question>
</answer>
</mentoring>
<mentoring-dataexport display_name="Data Export Test" />
</vertical_demo>
import copy
from mentoring import MentoringBlock
from mock import MagicMock, Mock
import unittest
from xblock.field_data import DictFieldData
class TestFieldMigration(unittest.TestCase):
"""
Test mentoring fields data migration
"""
def test_partial_completion_status_migration(self):
"""
Changed `completed` to `status` in `self.student_results` to accomodate partial responses
"""
# Instantiate a mentoring block with the old format
student_results = [
[u'goal',
{u'completed': True,
u'score': 1,
u'student_input': u'test',
u'weight': 1}],
[u'mcq_1_1',
{u'completed': False,
u'score': 0,
u'submission': u'maybenot',
u'weight': 1}],
]
mentoring = MentoringBlock(MagicMock(), DictFieldData({'student_results': student_results}), Mock())
self.assertEqual(copy.deepcopy(student_results), mentoring.student_results)
migrated_student_results = copy.deepcopy(student_results)
migrated_student_results[0][1]['status'] = 'correct'
migrated_student_results[1][1]['status'] = 'incorrect'
del migrated_student_results[0][1]['completed']
del migrated_student_results[1][1]['completed']
mentoring.migrate_fields()
self.assertEqual(migrated_student_results, mentoring.student_results)
import copy
import unittest
from mock import MagicMock, Mock
from xblock.field_data import DictFieldData
from mentoring import MentoringBlock
from mentoring.step import StepMixin, StepParentMixin
from mentoring.components.step import StepMixin, StepParentMixin
from mock import Mock
class Parent(StepParentMixin):
def get_children_objects(self):
return list(self._children)
@property
def children(self):
""" Return an ID for each of our chilren"""
return range(0, len(self._children))
@property
def runtime(self):
return Mock(
get_block=lambda i: self._children[i],
load_block_type=lambda i: type(self._children[i]),
id_reader=Mock(get_definition_id=lambda i: i, get_block_type=lambda i: i)
)
def _set_children_for_test(self, *children):
self._children = children
for child in self._children:
for idx, child in enumerate(self._children):
try:
child.parent = self
child.get_parent = lambda: self
child.scope_ids = Mock(usage_id=idx)
except AttributeError:
pass
class Step(StepMixin):
class BaseClass(object):
pass
class Step(BaseClass, StepMixin):
def __init__(self):
pass
......@@ -36,7 +47,8 @@ class TestStepMixin(unittest.TestCase):
step = Step()
block._children = [step]
self.assertSequenceEqual(block.steps, [step])
steps = [block.runtime.get_block(cid) for cid in block.steps]
self.assertSequenceEqual(steps, [step])
def test_only_steps_are_returned(self):
block = Parent()
......@@ -44,7 +56,8 @@ class TestStepMixin(unittest.TestCase):
step2 = Step()
block._set_children_for_test(step1, 1, "2", "Step", NotAStep(), False, step2, NotAStep())
self.assertSequenceEqual(block.steps, [step1, step2])
steps = [block.runtime.get_block(cid) for cid in block.steps]
self.assertSequenceEqual(steps, [step1, step2])
def test_proper_number_is_returned_for_step(self):
block = Parent()
......@@ -79,37 +92,3 @@ class TestStepMixin(unittest.TestCase):
self.assertFalse(step1.lonely_step)
self.assertFalse(step2.lonely_step)
class TestFieldMigration(unittest.TestCase):
"""
Test mentoring fields data migration
"""
def test_partial_completion_status_migration(self):
"""
Changed `completed` to `status` in `self.student_results` to accomodate partial responses
"""
# Instantiate a mentoring block with the old format
student_results = [
[ u'goal',
{ u'completed': True,
u'score': 1,
u'student_input': u'test',
u'weight': 1}],
[ u'mcq_1_1',
{ u'completed': False,
u'score': 0,
u'submission': u'maybenot',
u'weight': 1}],
]
mentoring = MentoringBlock(MagicMock(), DictFieldData({'student_results': student_results}), Mock())
self.assertEqual(copy.deepcopy(student_results), mentoring.student_results)
migrated_student_results = copy.deepcopy(student_results)
migrated_student_results[0][1]['status'] = 'correct'
migrated_student_results[1][1]['status'] = 'incorrect'
del migrated_student_results[0][1]['completed']
del migrated_student_results[1][1]['completed']
mentoring.migrate_fields()
self.assertEqual(migrated_student_results, mentoring.student_results)
# -*- 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/>.
#
import logging
import os
import pkg_resources
import unicodecsv
from cStringIO import StringIO
from django.template import Context, Template
from xblock.fragment import Fragment
from xblockutils.resources import ResourceLoader
log = logging.getLogger(__name__)
class MentoringResourceLoader(ResourceLoader):
def custom_render_js_template(self, template_path, context={}):
return self.render_js_template(template_path, 'light-child-template', context)
loader = MentoringResourceLoader(__name__)
def list2csv(row):
"""
Convert a list to a CSV string (single row)
"""
f = StringIO()
writer = unicodecsv.writer(f, encoding='utf-8')
writer.writerow(row)
f.seek(0)
return f.read()
class XBlockWithChildrenFragmentsMixin(object):
def get_children_fragment(self, context, view_name='student_view', instance_of=None,
not_instance_of=None):
"""
Returns a global fragment containing the resources used by the children views,
and a list of fragments, one per 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
the provided class
- `not_instance_of` allows to only return fragments for children which are *NOT*
instances of the provided class
"""
fragment = Fragment()
named_child_frags = []
for child_id in self.children: # pylint: disable=E1101
child = self.runtime.get_block(child_id)
if instance_of is not None and not isinstance(child, instance_of):
continue
if not_instance_of is not None and isinstance(child, not_instance_of):
continue
frag = self.runtime.render_child(child, view_name, context)
fragment.add_frag_resources(frag)
named_child_frags.append((child.name, frag))
return fragment, named_child_frags
def children_view(self, context, view_name='children_view'):
"""
Returns a fragment with the content of all the children's content, concatenated
"""
fragment, named_children = self.get_children_fragment(context)
for name, child_fragment in named_children:
fragment.add_content(child_fragment.content)
return fragment
class ContextConstants(object):
AS_TEMPLATE = 'as_template'
\ No newline at end of file
[REPORTS]
reports=no
include-ids=yes
[FORMAT]
max-line-length=120
[MESSAGES CONTROL]
disable=invalid-name
ddt
-e .
unicodecsv==0.9.4
-e git://github.com/nosedjango/nosedjango.git@ed7d7f9aa969252ff799ec159f828eaa8c1cbc5a#egg=nosedjango-dev
-e git://github.com/edx-solutions/xblock-utils.git#egg=xblock-utils
-e .
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Run tests for the Mentoring XBlock
This script is required to run our selenium tests inside the xblock-sdk workbench
because the workbench SDK's settings file is not inside any python module.
"""
import os
import sys
import workbench
if __name__ == "__main__":
# Find the location of the XBlock SDK. Note: it must be installed in development mode.
# ('python setup.py develop' or 'pip install -e')
xblock_sdk_dir = os.path.dirname(os.path.dirname(workbench.__file__))
sys.path.append(xblock_sdk_dir)
# Use the workbench settings file:
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings")
# Configure a range of ports in case the default port of 8081 is in use
os.environ.setdefault("DJANGO_LIVE_TEST_SERVER_ADDRESS", "localhost:8081-8099")
from django.conf import settings
settings.INSTALLED_APPS += ("mentoring", )
from django.core.management import execute_from_command_line
args = sys.argv[1:]
paths = [arg for arg in args if arg[0] != '-']
if not paths:
paths = ["mentoring/tests/"]
options = [arg for arg in args if arg not in paths]
execute_from_command_line([sys.argv[0], "test"] + paths + options)
......@@ -45,29 +45,27 @@ def package_data(pkg, root_list):
BLOCKS = [
'mentoring = mentoring:MentoringBlock',
'mentoring-dataexport = mentoring:MentoringDataExportBlock',
]
BLOCKS_CHILDREN = [
'mentoring-table = mentoring:MentoringTableBlock',
'column = mentoring:MentoringTableColumnBlock',
'header = mentoring:MentoringTableColumnHeaderBlock',
'answer = mentoring:AnswerBlock',
'quizz = mentoring:MCQBlock',
'mcq = mentoring:MCQBlock',
'mrq = mentoring:MRQBlock',
'message = mentoring:MentoringMessageBlock',
'tip = mentoring:TipBlock',
'choice = mentoring:ChoiceBlock',
'html = mentoring:HTMLBlock',
'title = mentoring:TitleBlock',
'shared-header = mentoring:SharedHeaderBlock',
'mentoring-table = mentoring.components:MentoringTableBlock',
'column = mentoring.components:MentoringTableColumnBlock',
'header = mentoring.components:MentoringTableColumnHeaderBlock',
'answer = mentoring.components:AnswerBlock',
'quizz = mentoring.components:MCQBlock',
'mcq = mentoring.components:MCQBlock',
'mrq = mentoring.components:MRQBlock',
'message = mentoring.components:MentoringMessageBlock',
'tip = mentoring.components:TipBlock',
'choice = mentoring.components:ChoiceBlock',
'html = mentoring.components:HTMLBlock',
'title = mentoring.components:TitleBlock',
'shared-header = mentoring.components:SharedHeaderBlock',
]
setup(
name='xblock-mentoring',
version='0.1',
description='XBlock - Mentoring',
packages=['mentoring', 'mentoring.migrations'],
packages=['mentoring'],
install_requires=[
'XBlock',
'xblock-utils',
......@@ -75,7 +73,6 @@ setup(
dependency_links = ['http://github.com/edx-solutions/xblock-utils/tarball/master#egg=xblock-utils'],
entry_points={
'xblock.v1': BLOCKS,
'xblock.light_children': BLOCKS_CHILDREN,
},
package_data=package_data("mentoring", ["static", "templates", "public", "migrations"]),
package_data=package_data("mentoring", ["static", "templates", "public"]),
)
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