Commit b02b1e4e by Xavier Antoviaque

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

Real children (OC-88)
parents 879241be 6e16e851
*~ *~
*.pyc *.pyc
/.coverage
/xblock_mentoring.egg-info /xblock_mentoring.egg-info
/workbench.sqlite /workbench.*
/dist /dist
/templates /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 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, This XBlock allows to automate the workflow of real-life mentoring,
within an edX course. within an edX course.
...@@ -511,23 +513,22 @@ Access it at [http://localhost:8000/](http://localhost:8000). ...@@ -511,23 +513,22 @@ Access it at [http://localhost:8000/](http://localhost:8000).
Running tests 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 From the xblock-mentoring repository root, run the tests with the
following command: following command:
```bash ```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. 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 ```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 Adding custom scenarios to the workbench
---------------------------------------- ----------------------------------------
...@@ -542,16 +543,8 @@ $ cat > templates/xml/my_mentoring_scenario.xml ...@@ -542,16 +543,8 @@ $ cat > templates/xml/my_mentoring_scenario.xml
Restart the workbench to take the new scenarios into account. Restart the workbench to take the new scenarios into account.
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 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). Public License (AGPLv3).
from .answer import AnswerBlock
from .choice import ChoiceBlock
from .dataexport import MentoringDataExportBlock from .dataexport import MentoringDataExportBlock
from .html import HTMLBlock
from .mcq import MCQBlock
from .mrq import MRQBlock
from .mentoring import MentoringBlock 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 @@ ...@@ -24,59 +24,97 @@
# Imports ########################################################### # Imports ###########################################################
import logging import logging
from lazy import lazy 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 .step import StepMixin
from .models import Answer
from .utils import loader
# Globals ########################################################### # Globals ###########################################################
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
loader = ResourceLoader(__name__)
# Classes ########################################################### # Classes ###########################################################
class AnswerBlock(LightChild, StepMixin):
class AnswerBlock(XBlock, StepMixin):
""" """
A field where the student enters an answer 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 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. to make them searchable and referenceable across xblocks.
""" """
read_only = Boolean(help="Display as a read-only field", default=False, scope=Scope.content) name = String(
default_from = String(help="If specified, the name of the answer to get the default value from", help="The ID of this block. Should be unique unless you want the answer to be used in multiple places.",
default=None, scope=Scope.content) default="",
min_characters = Integer(help="Minimum number of characters allowed for the answer", scope=Scope.content
default=0, scope=Scope.content) )
question = String(help="Question to ask the student", scope=Scope.content, default="") read_only = Boolean(
weight = Float(help="Defines the maximum total grade of the light child block.", help="Display as a read-only field",
default=1, scope=Scope.content, enforce_type=True) 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 @classmethod
def init_block_from_node(cls, block, node, attr): def parse_xml(cls, node, runtime, keys, id_generator):
block.light_children = [] block = runtime.construct_xblock_from_class(cls, keys)
for child_id, xml_child in enumerate(node):
# 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': if xml_child.tag == 'question':
block.question = xml_child.text block.question = xml_child.text
else: else:
cls.add_node_as_child(block, xml_child, child_id) block.runtime.add_node_as_child(block, xml_child, id_generator)
for name, value in attr:
setattr(block, name, value)
return block 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 @lazy
def student_input(self): def student_input(self):
""" """
Use lazy property instead of XBlock field, as __init__() doesn't support The student input value, or a default which may come from another block.
overwriting field values 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 # Only attempt to locate a model object for this block when the answer has a name
if not self.name: if not self.name:
...@@ -92,32 +130,32 @@ class AnswerBlock(LightChild, StepMixin): ...@@ -92,32 +130,32 @@ class AnswerBlock(LightChild, StepMixin):
def mentoring_view(self, context=None): def mentoring_view(self, context=None):
if not self.read_only: 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, 'self': self,
}) })
else: 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, 'self': self,
}) })
fragment = Fragment(html) fragment = Fragment(html)
fragment.add_css_url(self.runtime.local_resource_url(self.xblock_container, 'public/css/answer.css')) fragment.add_css_url(self.runtime.local_resource_url(self, 'public/css/answer.css'))
fragment.add_javascript_url(self.runtime.local_resource_url(self.xblock_container, fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/answer.js'))
'public/js/answer.js'))
fragment.initialize_js('AnswerBlock') fragment.initialize_js('AnswerBlock')
return fragment return fragment
def mentoring_table_view(self, context=None): 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, 'self': self,
}) })
fragment = Fragment(html) 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 return fragment
def submit(self, submission): def submit(self, submission):
if not self.read_only: if not self.read_only:
self.student_input = submission[0]['value'].strip() self.student_input = submission[0]['value'].strip()
self.save()
log.info(u'Answer submitted for`{}`: "{}"'.format(self.name, self.student_input)) log.info(u'Answer submitted for`{}`: "{}"'.format(self.name, self.student_input))
return { return {
'student_input': self.student_input, 'student_input': self.student_input,
...@@ -134,12 +172,20 @@ class AnswerBlock(LightChild, StepMixin): ...@@ -134,12 +172,20 @@ class AnswerBlock(LightChild, StepMixin):
return 'correct' if (self.read_only or answer_length_ok) else 'incorrect' return 'correct' if (self.read_only or answer_length_ok) else 'incorrect'
@property
def completed(self):
return self.status == 'correct'
def save(self): def save(self):
""" """
Replicate data changes on the related Django model used for sharing of data accross XBlocks Replicate data changes on the related Django model used for sharing of data accross XBlocks
""" """
super(AnswerBlock, self).save() 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 # Only attempt to locate a model object for this block when the answer has a name
if self.name: if self.name:
answer_data = self.get_model_object() answer_data = self.get_model_object()
...@@ -158,11 +204,10 @@ class AnswerBlock(LightChild, StepMixin): ...@@ -158,11 +204,10 @@ class AnswerBlock(LightChild, StepMixin):
if not name: if not name:
raise ValueError('AnswerBlock.name field need to be set to a non-null/empty value') 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._get_student_id()
student_id = self.xmodule_runtime.anonymous_student_id course_id = self._get_course_id()
course_id = self.xmodule_runtime.course_id
answer_data, created = Answer.objects.get_or_create( answer_data, _ = Answer.objects.get_or_create(
student_id=student_id, student_id=student_id,
course_id=course_id, course_id=course_id,
name=name, name=name,
......
...@@ -23,35 +23,17 @@ ...@@ -23,35 +23,17 @@
# Imports ########################################################### # Imports ###########################################################
import logging from .common import BlockWithContent
from xblock.fields import Scope, String
from .light_children import LightChild, Scope, String
from .utils import loader, ContextConstants
# Globals ###########################################################
log = logging.getLogger(__name__)
# Classes ########################################################### # Classes ###########################################################
class ChoiceBlock(LightChild):
class ChoiceBlock(BlockWithContent):
""" """
Custom choice of an answer for a MCQ/MRQ 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="") 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="") 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 @@ ...@@ -22,38 +22,13 @@
# "AGPLv3". If not, see <http://www.gnu.org/licenses/>. # "AGPLv3". If not, see <http://www.gnu.org/licenses/>.
# #
import logging from .html import HTMLBlock
from lxml import etree
from xblock.fragment import Fragment
from .light_children import LightChild, Scope, String
log = logging.getLogger(__name__)
class SharedHeaderBlock(HTMLBlock):
class SharedHeaderBlock(LightChild):
""" """
A shared header block shown under the title. 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="") pass
@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)
...@@ -23,55 +23,44 @@ ...@@ -23,55 +23,44 @@
# Imports ########################################################### # Imports ###########################################################
import logging
from lxml import etree from lxml import etree
from xblock.core import XBlock
from xblock.fields import Scope, String
from xblock.fragment import Fragment from xblock.fragment import Fragment
from .light_children import LightChild, Scope, String
# Globals ###########################################################
from .utils import ContextConstants
log = logging.getLogger(__name__)
# Classes ########################################################### # 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="") 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 @classmethod
def init_block_from_node(cls, block, node, attr): def parse_xml(cls, node, runtime, keys, id_generator):
block.light_children = [] """
Construct this XBlock from the given XML node.
node.tag = 'div' """
node_classes = (cls for cls in [node.get('class', ''), 'html_child'] if cls) block = runtime.construct_xblock_from_class(cls, keys)
node.set('class', " ".join(node_classes))
block.content = unicode(etree.tostring(node))
node.tag = 'html'
return block if node.get('class'): # Older API used "class" property, not "css_class"
node.set('css_class', node.get('css_class', node.get('class')))
def student_view(self, context=None): del node.attrib['class']
as_template = context.get(ContextConstants.AS_TEMPLATE, True) if context is not None else True block.css_class = node.get('css_class')
if as_template:
return Fragment(u"<script type='text/template' id='{}'>\n{}\n</script>".format(
'light-child-template',
self.content
))
# bug? got AssertionError if I don't use unicode here. (assert isinstance(content, unicode)) block.content = unicode(node.text or u"")
# Although it is set when constructed? for child in node:
return Fragment(unicode(self.content)) block.content += etree.tostring(child, encoding='unicode')
def mentoring_view(self, context=None): return block
return self.student_view(context)
def mentoring_table_view(self, context=None): def fallback_view(self, view_name, context=None):
return self.student_view(context) """ 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 @@ ...@@ -25,10 +25,10 @@
import logging import logging
from xblock.fields import Scope, String
from xblockutils.resources import ResourceLoader
from .light_children import Scope, String
from .questionnaire import QuestionnaireAbstractBlock from .questionnaire import QuestionnaireAbstractBlock
from .utils import loader
# Globals ########################################################### # Globals ###########################################################
...@@ -53,15 +53,15 @@ class MCQBlock(QuestionnaireAbstractBlock): ...@@ -53,15 +53,15 @@ class MCQBlock(QuestionnaireAbstractBlock):
log.debug(u'Received MCQ submission: "%s"', submission) log.debug(u'Received MCQ submission: "%s"', submission)
correct = True correct = True
tips_fragments = [] tips_html = []
for tip in self.get_tips(): for tip in self.get_tips():
correct = correct and self.is_tip_correct(tip, submission) correct = correct and self.is_tip_correct(tip, submission)
if submission in tip.display_with_defaults: 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, 'self': self,
'tips_fragments': tips_fragments, 'tips_html': tips_html,
'completed': correct, 'completed': correct,
}) })
......
...@@ -23,32 +23,17 @@ ...@@ -23,32 +23,17 @@
# Imports ########################################################### # Imports ###########################################################
import logging from .common import BlockWithContent
from xblock.fields import Scope, String
from .light_children import LightChild, Scope, String
from .utils import loader
# Globals ###########################################################
log = logging.getLogger(__name__)
# Classes ########################################################### # Classes ###########################################################
class MentoringMessageBlock(LightChild):
class MentoringMessageBlock(BlockWithContent):
""" """
A message which can be conditionally displayed at the mentoring block level, A message which can be conditionally displayed at the mentoring block level,
for example upon completion of the block for example upon completion of the block
""" """
TEMPLATE = 'templates/html/message.html'
content = String(help="Message to display upon completion", scope=Scope.content, default="") content = String(help="Message to display upon completion", scope=Scope.content, default="")
type = String(help="Type of message", scope=Scope.content, default="completed") 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 @@ ...@@ -25,10 +25,9 @@
import logging import logging
from xblock.fields import List, Scope, Boolean
from .light_children import List, Scope, Boolean
from .questionnaire import QuestionnaireAbstractBlock from .questionnaire import QuestionnaireAbstractBlock
from .utils import loader from xblockutils.resources import ResourceLoader
# Globals ########################################################### # Globals ###########################################################
...@@ -53,11 +52,11 @@ class MRQBlock(QuestionnaireAbstractBlock): ...@@ -53,11 +52,11 @@ class MRQBlock(QuestionnaireAbstractBlock):
results = [] results = []
for choice in self.custom_choices: for choice in self.custom_choices:
choice_completed = True choice_completed = True
choice_tips_fragments = [] choice_tips_html = []
choice_selected = choice.value in submissions choice_selected = choice.value in submissions
for tip in self.get_tips(): for tip in self.get_tips():
if choice.value in tip.display_with_defaults: 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 if ((not choice_selected and choice.value in tip.require_with_defaults) or
(choice_selected and choice.value in tip.reject_with_defaults)): (choice_selected and choice.value in tip.reject_with_defaults)):
...@@ -72,10 +71,11 @@ class MRQBlock(QuestionnaireAbstractBlock): ...@@ -72,10 +71,11 @@ class MRQBlock(QuestionnaireAbstractBlock):
} }
# Only include tips/results in returned response if we want to display them # Only include tips/results in returned response if we want to display them
if not self.hide_results: if not self.hide_results:
loader = ResourceLoader(__name__)
choice_result['completed'] = choice_completed choice_result['completed'] = choice_completed
choice_result['tips'] = loader.render_template('templates/html/tip_choice_group.html', { choice_result['tips'] = loader.render_template('templates/html/tip_choice_group.html', {
'self': self, 'self': self,
'tips_fragments': choice_tips_fragments, 'tips_html': choice_tips_html,
'completed': choice_completed, 'completed': choice_completed,
}) })
......
...@@ -61,10 +61,12 @@ function MessageView(element, mentoring) { ...@@ -61,10 +61,12 @@ function MessageView(element, mentoring) {
}; };
} }
function MCQBlock(runtime, element, mentoring) { function MCQBlock(runtime, element) {
return { return {
mode: null, mode: null,
mentoring: null,
init: function(options) { init: function(options) {
this.mentoring = options.mentoring;
this.mode = options.mode; this.mode = options.mode;
$('input[type=radio]', element).on('change', options.onChange); $('input[type=radio]', element).on('change', options.onChange);
}, },
...@@ -83,6 +85,8 @@ function MCQBlock(runtime, element, mentoring) { ...@@ -83,6 +85,8 @@ function MCQBlock(runtime, element, mentoring) {
if (this.mode === 'assessment') if (this.mode === 'assessment')
return; return;
mentoring = this.mentoring;
var messageView = MessageView(element, mentoring); var messageView = MessageView(element, mentoring);
messageView.clearResult(); messageView.clearResult();
...@@ -123,7 +127,7 @@ function MCQBlock(runtime, element, mentoring) { ...@@ -123,7 +127,7 @@ function MCQBlock(runtime, element, mentoring) {
}, },
clearResult: function() { clearResult: function() {
MessageView(element, mentoring).clearResult(); MessageView(element, this.mentoring).clearResult();
}, },
validate: function(){ validate: function(){
...@@ -136,7 +140,9 @@ function MCQBlock(runtime, element, mentoring) { ...@@ -136,7 +140,9 @@ function MCQBlock(runtime, element, mentoring) {
function MRQBlock(runtime, element, mentoring) { function MRQBlock(runtime, element, mentoring) {
return { return {
mode: null, mode: null,
mentoring: null,
init: function(options) { init: function(options) {
this.mentoring = options.mentoring;
this.mode = options.mode; this.mode = options.mode;
$('input[type=checkbox]', element).on('change', options.onChange); $('input[type=checkbox]', element).on('change', options.onChange);
}, },
...@@ -155,6 +161,8 @@ function MRQBlock(runtime, element, mentoring) { ...@@ -155,6 +161,8 @@ function MRQBlock(runtime, element, mentoring) {
if (this.mode === 'assessment') if (this.mode === 'assessment')
return; return;
mentoring = this.mentoring;
var messageView = MessageView(element, mentoring); var messageView = MessageView(element, mentoring);
if (result.message) { if (result.message) {
...@@ -193,7 +201,7 @@ function MRQBlock(runtime, element, mentoring) { ...@@ -193,7 +201,7 @@ function MRQBlock(runtime, element, mentoring) {
}, },
clearResult: function() { clearResult: function() {
MessageView(element, mentoring).clearResult(); MessageView(element, this.mentoring).clearResult();
}, },
validate: function(){ validate: function(){
......
...@@ -23,25 +23,20 @@ ...@@ -23,25 +23,20 @@
# Imports ########################################################### # 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 xblock.fragment import Fragment
from xblockutils.resources import ResourceLoader
from .choice import ChoiceBlock from .choice import ChoiceBlock
from .step import StepMixin from .step import StepMixin
from .light_children import LightChild, Scope, String, Float
from .tip import TipBlock from .tip import TipBlock
from .utils import loader, ContextConstants
# Globals ###########################################################
log = logging.getLogger(__name__)
# Classes ########################################################### # Classes ###########################################################
class QuestionnaireAbstractBlock(LightChild, StepMixin):
class QuestionnaireAbstractBlock(XBlock, StepMixin):
""" """
An abstract class used for MCQ/MRQ blocks An abstract class used for MCQ/MRQ blocks
...@@ -56,34 +51,36 @@ class QuestionnaireAbstractBlock(LightChild, StepMixin): ...@@ -56,34 +51,36 @@ class QuestionnaireAbstractBlock(LightChild, StepMixin):
default=1, scope=Scope.content, enforce_type=True) default=1, scope=Scope.content, enforce_type=True)
valid_types = ('choices') valid_types = ('choices')
has_children = True
@classmethod @classmethod
def init_block_from_node(cls, block, node, attr): def parse_xml(cls, node, runtime, keys, id_generator):
block.light_children = [] block = runtime.construct_xblock_from_class(cls, keys)
for child_id, xml_child in enumerate(node):
# 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': if xml_child.tag == 'question':
block.question = xml_child.text block.question = xml_child.text
elif xml_child.tag == 'message' and xml_child.get('type') == 'on-submit': elif xml_child.tag == 'message' and xml_child.get('type') == 'on-submit':
block.message = (xml_child.text or '').strip() block.message = (xml_child.text or '').strip()
else: elif xml_child.tag is not etree.Comment:
cls.add_node_as_child(block, xml_child, child_id) block.runtime.add_node_as_child(block, xml_child, id_generator)
for name, value in attr:
setattr(block, name, value)
return block return block
def student_view(self, context=None): def student_view(self, context=None):
name = self.__class__.__name__ name = getattr(self, "unmixed_class", self.__class__).__name__
as_template = context.get(ContextConstants.AS_TEMPLATE, True) if context is not None else True
if str(self.type) not in self.valid_types: if str(self.type) not in self.valid_types:
raise ValueError(u'Invalid value for {}.type: `{}`'.format(name, self.type)) raise ValueError(u'Invalid value for {}.type: `{}`'.format(name, self.type))
template_path = 'templates/html/{}_{}.html'.format(name.lower(), 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 = loader.render_template(template_path, {
html = render_function(template_path, {
'self': self, 'self': self,
'custom_choices': self.custom_choices 'custom_choices': self.custom_choices
}) })
...@@ -92,8 +89,7 @@ class QuestionnaireAbstractBlock(LightChild, StepMixin): ...@@ -92,8 +89,7 @@ class QuestionnaireAbstractBlock(LightChild, StepMixin):
fragment.add_css(loader.render_template('public/css/questionnaire.css', { fragment.add_css(loader.render_template('public/css/questionnaire.css', {
'self': self 'self': self
})) }))
fragment.add_javascript_url(self.runtime.local_resource_url(self.xblock_container, fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/questionnaire.js'))
'public/js/questionnaire.js'))
fragment.initialize_js(name) fragment.initialize_js(name)
return fragment return fragment
...@@ -103,7 +99,8 @@ class QuestionnaireAbstractBlock(LightChild, StepMixin): ...@@ -103,7 +99,8 @@ class QuestionnaireAbstractBlock(LightChild, StepMixin):
@property @property
def custom_choices(self): def custom_choices(self):
custom_choices = [] 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): if isinstance(child, ChoiceBlock):
custom_choices.append(child) custom_choices.append(child)
return custom_choices return custom_choices
...@@ -113,7 +110,8 @@ class QuestionnaireAbstractBlock(LightChild, StepMixin): ...@@ -113,7 +110,8 @@ class QuestionnaireAbstractBlock(LightChild, StepMixin):
Returns the tips contained in this block Returns the tips contained in this block
""" """
tips = [] tips = []
for child in self.get_children_objects(): for child_id in self.children:
child = self.runtime.get_block(child_id)
if isinstance(child, TipBlock): if isinstance(child, TipBlock):
tips.append(child) tips.append(child)
return tips 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 @@ ...@@ -24,22 +24,23 @@
# Imports ########################################################### # Imports ###########################################################
import errno import errno
import logging
from xblock.fields import Scope from .utils import child_isinstance
from .light_children import LightChild, String from xblock.core import XBlock
from .utils import loader from xblock.fields import Scope, String
from xblock.fragment import Fragment
from xblockutils.resources import ResourceLoader
# Globals ########################################################### # Globals ###########################################################
log = logging.getLogger(__name__) loader = ResourceLoader(__name__)
# Classes ########################################################### # Classes ###########################################################
class MentoringTableBlock(LightChild):
class MentoringTableBlock(XBlock):
""" """
Table-type display of information from mentoring blocks Table-type display of information from mentoring blocks
...@@ -50,11 +51,19 @@ class MentoringTableBlock(LightChild): ...@@ -50,11 +51,19 @@ class MentoringTableBlock(LightChild):
has_children = True has_children = True
def student_view(self, context): def student_view(self, context):
fragment, columns_frags = self.get_children_fragment(context, view_name='mentoring_table_view') fragment = Fragment()
f, header_frags = self.get_children_fragment(context, view_name='mentoring_table_header_view') columns_frags = []
header_frags = []
bg_image_url = self.runtime.local_resource_url(self.xblock_container, for child_id in self.children:
'public/img/{}-bg.png'.format(self.type)) 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 # Load an optional description for the background image, for accessibility
try: try:
...@@ -72,12 +81,9 @@ class MentoringTableBlock(LightChild): ...@@ -72,12 +81,9 @@ class MentoringTableBlock(LightChild):
'bg_image_url': bg_image_url, 'bg_image_url': bg_image_url,
'bg_image_description': bg_image_description, 'bg_image_description': bg_image_description,
})) }))
fragment.add_css_url(self.runtime.local_resource_url(self.xblock_container, fragment.add_css_url(self.runtime.local_resource_url(self, 'public/css/mentoring-table.css'))
'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.xblock_container, fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/mentoring-table.js'))
'public/js/vendor/jquery-shorten.js'))
fragment.add_javascript_url(self.runtime.local_resource_url(self.xblock_container,
'public/js/mentoring-table.js'))
fragment.initialize_js('MentoringTableBlock') fragment.initialize_js('MentoringTableBlock')
return fragment return fragment
...@@ -87,47 +93,57 @@ class MentoringTableBlock(LightChild): ...@@ -87,47 +93,57 @@ class MentoringTableBlock(LightChild):
return self.student_view(context) return self.student_view(context)
class MentoringTableColumnBlock(LightChild): class MentoringTableColumnBlock(XBlock):
""" """
Individual column of a mentoring table Individual column of a mentoring table
""" """
header = String(help="Header of the column", scope=Scope.content, default=None) header = String(help="Header of the column", scope=Scope.content, default=None)
has_children = True has_children = True
def mentoring_table_view(self, context): def _render_table_view(self, view_name, id_filter, template, context):
""" fragment = Fragment()
The content of the column named_children = []
""" for child_id in self.children:
fragment, named_children = self.get_children_fragment( if id_filter(child_id):
context, view_name='mentoring_table_view', child = self.runtime.get_block(child_id)
not_instance_of=MentoringTableColumnHeaderBlock) child_frag = child.render(view_name, context)
fragment.add_content(loader.render_template('templates/html/mentoring-table-column.html', { 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, 'self': self,
'named_children': named_children, 'named_children': named_children,
})) }))
return fragment 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): def mentoring_table_header_view(self, context):
""" """
The content of the column's header The content of the column's header
""" """
fragment, named_children = self.get_children_fragment( return self._render_table_view(
context, view_name='mentoring_table_header_view', view_name='mentoring_table_header_view',
instance_of=MentoringTableColumnHeaderBlock) id_filter=lambda child_id: child_isinstance(self, child_id, MentoringTableColumnHeaderBlock),
fragment.add_content(loader.render_template('templates/html/mentoring-table-header.html', { template='mentoring-table-header.html',
'self': self, context=context
'named_children': named_children, )
}))
return fragment
class MentoringTableColumnHeaderBlock(LightChild): class MentoringTableColumnHeaderBlock(XBlock):
""" """
Header content for a given column Header content for a given column
""" """
content = String(help="Body of the header", scope=Scope.content, default='') content = String(help="Body of the header", scope=Scope.content, default='')
def mentoring_table_header_view(self, context): def mentoring_table_header_view(self, context):
fragment = super(MentoringTableColumnHeaderBlock, self).children_view(context) return Fragment(unicode(self.content))
fragment.add_content(unicode(self.content))
return fragment
<span class="choice-text">
{{ self.content }}
{{ child_content|safe }}
</span>
...@@ -9,7 +9,7 @@ ...@@ -9,7 +9,7 @@
<div class="choice-result fa icon-2x"></div> <div class="choice-result fa icon-2x"></div>
<label class="choice-label"> <label class="choice-label">
<input class="choice-selector" type="radio" name="{{ self.name }}" value="{{ choice.value }}"{% if self.student_choice == choice.value %} checked{% endif %} /> <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> </label>
<div class="choice-tips"></div> <div class="choice-tips"></div>
</div> </div>
......
...@@ -35,7 +35,7 @@ ...@@ -35,7 +35,7 @@
<div class="choice"> <div class="choice">
<div class="choice-result fa icon-2x"></div> <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 %} /> <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> </label>
<div class="choice-tips"></div> <div class="choice-tips"></div>
</div> </div>
......
<div class="message {{ self.type }}"> <div class="message {{ self.type }}">
{% for name, c in named_children %}
{{c.body_html|safe}}
{% endfor %}
{% if self.content %} {% if self.content %}
<p>{{ self.content }} <p>{{ self.content }}</p>
{% endif %} {% endif %}
{{ child_content|safe }}
</div> </div>
...@@ -11,7 +11,7 @@ ...@@ -11,7 +11,7 @@
<input class="choice-selector" type="checkbox" name="{{ self.name }}" <input class="choice-selector" type="checkbox" name="{{ self.name }}"
value="{{ choice.value }}" value="{{ choice.value }}"
{% if choice.value in self.student_choices %} checked{% endif %} /> {% if choice.value in self.student_choices %} checked{% endif %} />
{{ choice.render.body_html|safe }} {{ choice.get_html|safe }}
</label> </label>
<div class="choice-tips"></div> <div class="choice-tips"></div>
</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"> <div class="tip-choice-group">
{% for tip_fragment in tips_fragments %} {% for tip_html in tips_html %}
{{ tip_fragment.body_html|safe }} {{ tip_html|safe }}
{% endfor %} {% endfor %}
</div> </div>
<div class="close icon-remove-sign fa fa-times-circle"></div> <div class="close icon-remove-sign fa fa-times-circle"></div>
...@@ -23,19 +23,12 @@ ...@@ -23,19 +23,12 @@
# Imports ########################################################### # Imports ###########################################################
import logging from .common import BlockWithContent
from xblock.fields import Scope, String
from .light_children import LightChild, Scope, String
from .utils import loader
# Globals ###########################################################
log = logging.getLogger(__name__)
# Functions ######################################################### # Functions #########################################################
def commas_to_set(commas_str): def commas_to_set(commas_str):
""" """
Converts a comma-separated string to a set Converts a comma-separated string to a set
...@@ -48,28 +41,18 @@ def commas_to_set(commas_str): ...@@ -48,28 +41,18 @@ def commas_to_set(commas_str):
# Classes ########################################################### # Classes ###########################################################
class TipBlock(LightChild): class TipBlock(BlockWithContent):
""" """
Each choice can define a tip depending on selection 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="") 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) 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) 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) 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='') width = String(help="Width of the tip popup", scope=Scope.content, default='')
height = String(help="Height 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 @property
def display_with_defaults(self): def display_with_defaults(self):
......
...@@ -23,18 +23,13 @@ ...@@ -23,18 +23,13 @@
# Imports ########################################################### # Imports ###########################################################
import logging from xblock.core import XBlock
from xblock.fields import Scope, String
from .light_children import LightChild, Scope, String
# Globals ###########################################################
log = logging.getLogger(__name__)
# Classes ########################################################### # Classes ###########################################################
class TitleBlock(LightChild): class TitleBlock(XBlock):
""" """
A simple html representation of a title, with the mentoring weight. 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 @@ ...@@ -24,24 +24,41 @@
# Imports ########################################################### # Imports ###########################################################
import logging import logging
import unicodecsv
from itertools import groupby from itertools import groupby
from StringIO import StringIO
from webob import Response from webob import Response
from xblock.core import XBlock from xblock.core import XBlock
from xblock.fields import String, Scope from xblock.fields import String, Scope
from xblock.fragment import Fragment from xblock.fragment import Fragment
from xblockutils.resources import ResourceLoader
from .models import Answer from .components.answer import AnswerBlock, Answer
from .utils import list2csv, loader
# Globals ########################################################### # Globals ###########################################################
log = logging.getLogger(__name__) 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 ########################################################### # Classes ###########################################################
class MentoringDataExportBlock(XBlock): class MentoringDataExportBlock(XBlock):
""" """
An XBlock allowing the instructor team to export all the student answers as a CSV file An XBlock allowing the instructor team to export all the student answers as a CSV file
...@@ -50,20 +67,16 @@ class MentoringDataExportBlock(XBlock): ...@@ -50,20 +67,16 @@ class MentoringDataExportBlock(XBlock):
scope=Scope.settings) scope=Scope.settings)
def student_view(self, context): 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', { 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,
}) })
return Fragment(html)
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')
@XBlock.handler @XBlock.handler
def download_csv(self, request, suffix=''): def download_csv(self, request, suffix=''):
...@@ -73,8 +86,7 @@ class MentoringDataExportBlock(XBlock): ...@@ -73,8 +86,7 @@ class MentoringDataExportBlock(XBlock):
return response return response
def get_csv(self): 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 = Answer.objects.filter(course_id=course_id).order_by('student_id', 'name')
answers_names = answers.values_list('name', flat=True).distinct().order_by('name') answers_names = answers.values_list('name', flat=True).distinct().order_by('name')
...@@ -82,7 +94,7 @@ class MentoringDataExportBlock(XBlock): ...@@ -82,7 +94,7 @@ class MentoringDataExportBlock(XBlock):
yield list2csv([u'student_id'] + list(answers_names)) yield list2csv([u'student_id'] + list(answers_names))
if 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 = [] row = []
next_answer_idx = 0 next_answer_idx = 0
for answer in student_answers: for answer in student_answers:
...@@ -99,3 +111,23 @@ class MentoringDataExportBlock(XBlock): ...@@ -99,3 +111,23 @@ class MentoringDataExportBlock(XBlock):
if row: if row:
yield list2csv(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): ...@@ -53,12 +53,18 @@ class Answer(models.Model):
class LightChild(models.Model): class LightChild(models.Model):
""" """
Django model used to store LightChild student data that need to be shared and queried accross DEPRECATED.
XBlock instances (workaround). Since this is temporary, `data` are stored in json. 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: class Meta:
app_label = 'mentoring' 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'),) unique_together = (('student_id', 'course_id', 'name'),)
name = models.CharField(max_length=100, db_index=True) 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) { function MentoringBlock(runtime, element) {
var attemptsTemplate = _.template($('#xblock-attempts-template').html()); var attemptsTemplate = _.template($('#xblock-attempts-template').html());
var data = $('.mentoring', element).data(); var data = $('.mentoring', element).data();
var children_dom = []; // Keep track of children. A Child need a single object scope for its data. var children = runtime.children(element);
var children = [];
var step = data.step; 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) { function publish_event(data) {
$.ajax({ $.ajax({
type: "POST", type: "POST",
...@@ -66,44 +78,23 @@ function MentoringBlock(runtime, element) { ...@@ -66,44 +78,23 @@ function MentoringBlock(runtime, element) {
} }
} }
function readChildren() { function initChildren(options) {
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) {
options = options || {}; options = options || {};
options.mentoring = mentoring;
options.mode = data.mode; options.mode = data.mode;
if (index >= children.length) for (var i=0; i < children.length; i++) {
return children.length; var child = children[i];
callIfExists(child, 'init', options);
var template = $('#light-child-template', children_dom[index]).html(); }
$(children_dom[index]).append(template);
$(children_dom[index]).show();
var child = children[index];
callIfExists(child, 'init', options);
return child;
} }
function displayChildren(options) { function hideAllChildren() {
$.each(children_dom, function(index) { for (var i=0; i < children.length; i++) {
displayChild(index, options); $(children[i].element).hide();
}); }
} }
function getChildByName(element, name) { function getChildByName(name) {
for (var i = 0; i < children.length; i++) { for (var i = 0; i < children.length; i++) {
var child = children[i]; var child = children[i];
if (child && child.name === name) { if (child && child.name === name) {
...@@ -112,21 +103,6 @@ function MentoringBlock(runtime, element) { ...@@ -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') { if (data.mode === 'standard') {
MentoringStandardView(runtime, element, mentoring); MentoringStandardView(runtime, element, mentoring);
} }
......
...@@ -13,8 +13,11 @@ function MentoringAssessmentView(runtime, element, mentoring) { ...@@ -13,8 +13,11 @@ function MentoringAssessmentView(runtime, element, mentoring) {
checkmark.removeClass('checkmark-partially-correct icon-ok fa-check'); checkmark.removeClass('checkmark-partially-correct icon-ok fa-check');
checkmark.removeClass('checkmark-incorrect icon-exclamation fa-exclamation'); checkmark.removeClass('checkmark-incorrect icon-exclamation fa-exclamation');
/* hide all children */ // Clear all selections
$(':nth-child(2)', mentoring.children_dom).remove(); $('input[type=radio], input[type=checkbox]', element).prop('checked', false);
// hide all children
mentoring.hideAllChildren();
$('.grade').html(''); $('.grade').html('');
$('.attempts').html(''); $('.attempts').html('');
...@@ -76,9 +79,18 @@ function MentoringAssessmentView(runtime, element, mentoring) { ...@@ -76,9 +79,18 @@ function MentoringAssessmentView(runtime, element, mentoring) {
reviewDOM.bind('click', renderGrade); reviewDOM.bind('click', renderGrade);
tryAgainDOM.bind('click', tryAgain); tryAgainDOM.bind('click', tryAgain);
active_child = mentoring.step-1; active_child = mentoring.step;
mentoring.readChildren();
displayNextChild(); var options = {
onChange: onChange
};
mentoring.initChildren(options);
if (isDone()) {
renderGrade();
} else {
active_child = active_child - 1;
displayNextChild();
}
mentoring.renderDependency(); mentoring.renderDependency();
} }
...@@ -92,24 +104,16 @@ function MentoringAssessmentView(runtime, element, mentoring) { ...@@ -92,24 +104,16 @@ function MentoringAssessmentView(runtime, element, mentoring) {
} }
function displayNextChild() { function displayNextChild() {
var options = {
onChange: onChange
};
cleanAll(); cleanAll();
// find the next real child block to display. HTMLBlock are always displayed // find the next real child block to display. HTMLBlock are always displayed
++active_child; active_child++;
while (1) { var child = mentoring.children[active_child];
var child = mentoring.displayChild(active_child, options); $(child.element).show();
mentoring.publish_event({ mentoring.publish_event({
event_type: 'xblock.mentoring.assessment.shown', 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()) if (isDone())
renderGrade(); renderGrade();
...@@ -151,8 +155,7 @@ function MentoringAssessmentView(runtime, element, mentoring) { ...@@ -151,8 +155,7 @@ function MentoringAssessmentView(runtime, element, mentoring) {
if (result.step != active_child+1) { if (result.step != active_child+1) {
active_child = result.step-1; active_child = result.step-1;
displayNextChild(); displayNextChild();
} } else {
else {
nextDOM.removeAttr("disabled"); nextDOM.removeAttr("disabled");
reviewDOM.removeAttr("disabled"); reviewDOM.removeAttr("disabled");
} }
......
...@@ -10,7 +10,7 @@ function MentoringStandardView(runtime, element, mentoring) { ...@@ -10,7 +10,7 @@ function MentoringStandardView(runtime, element, mentoring) {
$.each(results.submitResults || [], function(index, submitResult) { $.each(results.submitResults || [], function(index, submitResult) {
var input = submitResult[0]; var input = submitResult[0];
var result = submitResult[1]; var result = submitResult[1];
var child = mentoring.getChildByName(element, input); var child = mentoring.getChildByName(input);
var options = { var options = {
max_attempts: results.max_attempts, max_attempts: results.max_attempts,
num_attempts: results.num_attempts num_attempts: results.num_attempts
...@@ -73,7 +73,7 @@ function MentoringStandardView(runtime, element, mentoring) { ...@@ -73,7 +73,7 @@ function MentoringStandardView(runtime, element, mentoring) {
onChange: onChange onChange: onChange
}; };
mentoring.displayChildren(options); mentoring.initChildren(options);
mentoring.renderAttempts(); mentoring.renderAttempts();
mentoring.renderDependency(); mentoring.renderDependency();
...@@ -81,17 +81,6 @@ function MentoringStandardView(runtime, element, mentoring) { ...@@ -81,17 +81,6 @@ function MentoringStandardView(runtime, element, mentoring) {
validateXBlock(); 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 // validate all children
function validateXBlock() { function validateXBlock() {
var is_valid = true; var is_valid = true;
...@@ -100,8 +89,7 @@ function MentoringStandardView(runtime, element, mentoring) { ...@@ -100,8 +89,7 @@ function MentoringStandardView(runtime, element, mentoring) {
if ((data.max_attempts > 0) && (data.num_attempts >= data.max_attempts)) { if ((data.max_attempts > 0) && (data.num_attempts >= data.max_attempts)) {
is_valid = false; is_valid = false;
} } else {
else {
for (var i = 0; i < children.length; i++) { for (var i = 0; i < children.length; i++) {
var child = children[i]; var child = children[i];
if (child && child.name !== undefined) { if (child && child.name !== undefined) {
...@@ -112,15 +100,12 @@ function MentoringStandardView(runtime, element, mentoring) { ...@@ -112,15 +100,12 @@ function MentoringStandardView(runtime, element, mentoring) {
} }
} }
} }
if (!is_valid) { if (!is_valid) {
submitDOM.attr('disabled','disabled'); submitDOM.attr('disabled','disabled');
} } else {
else {
submitDOM.removeAttr("disabled"); submitDOM.removeAttr("disabled");
} }
} }
// We need to manually refresh, XBlocks are currently loaded together with the section initXBlockView();
refreshXBlock(element);
} }
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"> <div class="mentoring-dataexport">
<h3>Answers data dump</h3> <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> </div>
...@@ -4,17 +4,15 @@ ...@@ -4,17 +4,15 @@
attempting this step. attempting this step.
</div> </div>
{% if self.title or self.header %} {% if title or header %}
<div class="title"> <div class="title">
{% if self.title %} <h2 class="main">{{ self.title.content }}</h2> {% endif %} {% if title %} <h2>{{ title }}</h2> {% endif %}
{% if self.header %} <div class="shared-header">{{ self.header.content|safe }}</div> {% endif %} {% if header %} {{ header|safe }} {% endif %}
</div> </div>
{% endif %} {% endif %}
<div class="{{self.mode}}-question-block"> <div class="{{self.mode}}-question-block">
{% for name, c in named_children %} {{child_content|safe}}
{{c.body_html|safe}}
{% endfor %}
{% if self.display_submit %} {% 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> <title>Default Title</title>
<shared-header> <shared-header>
<p>This paragraph is shared between <strong>all</strong> questions.</p> <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> <title>Default Title</title>
<html> <html>
<p>Please answer the questions below.</p> <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> <title>Default Title</title>
<html> <html>
<p>Please answer the questions below.</p> <p>Please answer the questions below.</p>
......
...@@ -2,8 +2,9 @@ from .base_test import MentoringBaseTest ...@@ -2,8 +2,9 @@ from .base_test import MentoringBaseTest
CORRECT, INCORRECT, PARTIAL = "correct", "incorrect", "partially-correct" CORRECT, INCORRECT, PARTIAL = "correct", "incorrect", "partially-correct"
class MentoringAssessmentTest(MentoringBaseTest): class MentoringAssessmentTest(MentoringBaseTest):
def _selenium_bug_workaround_scroll_to(self, mentoring): def _selenium_bug_workaround_scroll_to(self, mentoring, question):
"""Workaround for selenium bug: """Workaround for selenium bug:
Some version of Selenium has a bug that prevents scrolling Some version of Selenium has a bug that prevents scrolling
...@@ -21,7 +22,7 @@ class MentoringAssessmentTest(MentoringBaseTest): ...@@ -21,7 +22,7 @@ class MentoringAssessmentTest(MentoringBaseTest):
control buttons to fit. control buttons to fit.
""" """
controls = mentoring.find_element_by_css_selector("div.submit") 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() controls.click()
title.click() title.click()
...@@ -40,13 +41,9 @@ class MentoringAssessmentTest(MentoringBaseTest): ...@@ -40,13 +41,9 @@ class MentoringAssessmentTest(MentoringBaseTest):
self.assertIn("A Simple Assessment", mentoring.text) self.assertIn("A Simple Assessment", mentoring.text)
self.assertIn("This paragraph is shared between all questions.", 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): class _GetChoices(object):
def __init__(self, mentoring, selector=".choices"): def __init__(self, question, selector=".choices"):
self._mcq = mentoring.find_element_by_css_selector(selector) self._mcq = question.find_element_by_css_selector(selector)
@property @property
def text(self): def text(self):
...@@ -59,7 +56,6 @@ class MentoringAssessmentTest(MentoringBaseTest): ...@@ -59,7 +56,6 @@ class MentoringAssessmentTest(MentoringBaseTest):
for choice in self._mcq.find_elements_by_css_selector(".choice")} for choice in self._mcq.find_elements_by_css_selector(".choice")}
def select(self, text): def select(self, text):
state = {}
for choice in self._mcq.find_elements_by_css_selector(".choice"): for choice in self._mcq.find_elements_by_css_selector(".choice"):
if choice.text == text: if choice.text == text:
choice.find_element_by_css_selector("input").click() choice.find_element_by_css_selector("input").click()
...@@ -74,7 +70,6 @@ class MentoringAssessmentTest(MentoringBaseTest): ...@@ -74,7 +70,6 @@ class MentoringAssessmentTest(MentoringBaseTest):
for name, count in states.items(): for name, count in states.items():
self.assertEqual(len(mentoring.find_elements_by_css_selector(".checkmark-{}".format(name))), count) self.assertEqual(len(mentoring.find_elements_by_css_selector(".checkmark-{}".format(name))), count)
def go_to_workbench_main_page(self): def go_to_workbench_main_page(self):
self.browser.get(self.live_server_url) self.browser.get(self.live_server_url)
...@@ -101,9 +96,9 @@ class MentoringAssessmentTest(MentoringBaseTest): ...@@ -101,9 +96,9 @@ class MentoringAssessmentTest(MentoringBaseTest):
return "QUESTION" return "QUESTION"
def freeform_answer(self, number, mentoring, controls, text_input, result, saved_value="", last=False): 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.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") answer = mentoring.find_element_by_css_selector("textarea.answer.editable")
...@@ -161,16 +156,17 @@ class MentoringAssessmentTest(MentoringBaseTest): ...@@ -161,16 +156,17 @@ class MentoringAssessmentTest(MentoringBaseTest):
controls.next_question.click() controls.next_question.click()
def single_choice_question(self, number, mentoring, controls, choice_name, result, last=False): 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.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.assert_disabled(controls.submit)
self.ending_controls(controls, last) self.ending_controls(controls, last)
self.assert_hidden(controls.try_again) 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} expected_state = {"Yes": False, "Maybe not": False, "I don't understand": False}
self.assertEquals(choices.state, expected_state) self.assertEquals(choices.state, expected_state)
...@@ -188,9 +184,9 @@ class MentoringAssessmentTest(MentoringBaseTest): ...@@ -188,9 +184,9 @@ class MentoringAssessmentTest(MentoringBaseTest):
self.do_post(controls, last) self.do_post(controls, last)
def rating_question(self, number, mentoring, controls, choice_name, result, last=False): 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.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.assertIn("How much do you rate this MCQ?", mentoring.text)
self.assert_disabled(controls.submit) self.assert_disabled(controls.submit)
...@@ -218,19 +214,37 @@ class MentoringAssessmentTest(MentoringBaseTest): ...@@ -218,19 +214,37 @@ class MentoringAssessmentTest(MentoringBaseTest):
self._assert_checkmark(mentoring, result) self._assert_checkmark(mentoring, result)
self.do_post(controls, last) 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) 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.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.assertIn("What do you like in this MRQ?", mentoring.text)
self.assert_disabled(controls.submit) self.assert_disabled(controls.submit)
self.ending_controls(controls, last) self.ending_controls(controls, last)
return question
def multiple_choice_question(self, number, mentoring, controls, choice_names, result, last=False): 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 = { expected_choices = {
"Its elegance": False, "Its elegance": False,
"Its beauty": False, "Its beauty": False,
...@@ -282,24 +296,27 @@ class MentoringAssessmentTest(MentoringBaseTest): ...@@ -282,24 +296,27 @@ class MentoringAssessmentTest(MentoringBaseTest):
self.multiple_choice_question(4, mentoring, controls, ("Its beauty",), PARTIAL, last=True) self.multiple_choice_question(4, mentoring, controls, ("Its beauty",), PARTIAL, last=True)
expected_results = { expected_results = {
"correct": 2, "partial": 1, "incorrect": 1, "percentage": 63, "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.peek_at_review(mentoring, controls, expected_results)
self.assert_clickable(controls.try_again) self.assert_clickable(controls.try_again)
controls.try_again.click() controls.try_again.click()
self.freeform_answer(1, mentoring, controls, 'This is a different answer', CORRECT, self.freeform_answer(
saved_value='This is the 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.single_choice_question(2, mentoring, controls, 'Yes', CORRECT)
self.rating_question(3, mentoring, controls, "1 - Not good at all", INCORRECT) self.rating_question(3, mentoring, controls, "1 - Not good at all", INCORRECT)
user_selection = ("Its elegance", "Its beauty", "Its gracefulness") user_selection = ("Its elegance", "Its beauty", "Its gracefulness")
self.multiple_choice_question(4, mentoring, controls, user_selection, CORRECT, last=True) self.multiple_choice_question(4, mentoring, controls, user_selection, CORRECT, last=True)
expected_results = { expected_results = {
"correct": 3, "partial": 0, "incorrect": 1, "percentage": 75, "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.peek_at_review(mentoring, controls, expected_results)
self.assert_disabled(controls.try_again) self.assert_disabled(controls.try_again)
...@@ -312,11 +329,12 @@ class MentoringAssessmentTest(MentoringBaseTest): ...@@ -312,11 +329,12 @@ class MentoringAssessmentTest(MentoringBaseTest):
expected_results = { expected_results = {
"correct": 0, "partial": 0, "incorrect": 1, "percentage": 0, "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) self.peek_at_review(mentoring, controls, expected_results)
controls.try_again.click() controls.try_again.click()
# this is a wait and assertion all together - it waits until expected text is in mentoring block # this is a wait and assertion all together - it waits until expected text is in mentoring block
# and it fails with PrmoiseFailed exception if it's not # and it fails with PrmoiseFailed exception if it's not
self.wait_until_text_in(self.question_text(0), mentoring) self.wait_until_text_in(self.question_text(0), mentoring)
\ No newline at end of file
# -*- 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): ...@@ -176,6 +176,7 @@ class MCQBlockTest(MentoringBaseTest):
choice_wrapper = choices_list.find_elements_by_css_selector(".choice")[index] 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 choice_wrapper.find_element_by_css_selector(".choice-selector").click() # clicking on actual radio button
submit.click() submit.click()
self.wait_until_disabled(submit)
item_feedback_icon = choice_wrapper.find_element_by_css_selector(".choice-result") item_feedback_icon = choice_wrapper.find_element_by_css_selector(".choice-result")
choice_wrapper.click() choice_wrapper.click()
item_feedback_icon.click() # clicking on item feedback icon item_feedback_icon.click() # clicking on item feedback icon
...@@ -193,9 +194,8 @@ class MCQBlockTest(MentoringBaseTest): ...@@ -193,9 +194,8 @@ class MCQBlockTest(MentoringBaseTest):
result = [] result = []
# this could be a list comprehension, but a bit complicated one - hence explicit loop # 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"): for choice_wrapper in questionnaire.find_elements_by_css_selector(".choice"):
choice_label = choice_wrapper.find_element_by_css_selector(".choice-label .choice-text") choice_label = choice_wrapper.find_element_by_css_selector("label .choice-text")
light_child = choice_label.find_element_by_css_selector(".xblock-light-child") result.append(choice_label.find_element_by_css_selector("div.html_child").get_attribute('innerHTML'))
result.append(light_child.find_element_by_css_selector("div").get_attribute('innerHTML'))
return result return result
...@@ -233,4 +233,4 @@ class MCQBlockTest(MentoringBaseTest): ...@@ -233,4 +233,4 @@ class MCQBlockTest(MentoringBaseTest):
submit.click() submit.click()
self.wait_until_disabled(submit) self.wait_until_disabled(submit)
self.assertIn('Congratulations!', messages.text) self.assertIn('Congratulations!', messages.text)
\ No newline at end of file
<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 import unittest
from mock import MagicMock, Mock
from xblock.field_data import DictFieldData from mentoring.components.step import StepMixin, StepParentMixin
from mock import Mock
from mentoring import MentoringBlock
from mentoring.step import StepMixin, StepParentMixin
class Parent(StepParentMixin): class Parent(StepParentMixin):
def get_children_objects(self): @property
return list(self._children) 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): def _set_children_for_test(self, *children):
self._children = children self._children = children
for child in self._children: for idx, child in enumerate(self._children):
try: try:
child.parent = self child.get_parent = lambda: self
child.scope_ids = Mock(usage_id=idx)
except AttributeError: except AttributeError:
pass pass
class Step(StepMixin): class BaseClass(object):
pass
class Step(BaseClass, StepMixin):
def __init__(self): def __init__(self):
pass pass
...@@ -36,7 +47,8 @@ class TestStepMixin(unittest.TestCase): ...@@ -36,7 +47,8 @@ class TestStepMixin(unittest.TestCase):
step = Step() step = Step()
block._children = [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): def test_only_steps_are_returned(self):
block = Parent() block = Parent()
...@@ -44,7 +56,8 @@ class TestStepMixin(unittest.TestCase): ...@@ -44,7 +56,8 @@ class TestStepMixin(unittest.TestCase):
step2 = Step() step2 = Step()
block._set_children_for_test(step1, 1, "2", "Step", NotAStep(), False, step2, NotAStep()) 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): def test_proper_number_is_returned_for_step(self):
block = Parent() block = Parent()
...@@ -79,37 +92,3 @@ class TestStepMixin(unittest.TestCase): ...@@ -79,37 +92,3 @@ class TestStepMixin(unittest.TestCase):
self.assertFalse(step1.lonely_step) self.assertFalse(step1.lonely_step)
self.assertFalse(step2.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 ddt
-e .
unicodecsv==0.9.4 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 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): ...@@ -45,29 +45,27 @@ def package_data(pkg, root_list):
BLOCKS = [ BLOCKS = [
'mentoring = mentoring:MentoringBlock', 'mentoring = mentoring:MentoringBlock',
'mentoring-dataexport = mentoring:MentoringDataExportBlock', 'mentoring-dataexport = mentoring:MentoringDataExportBlock',
]
BLOCKS_CHILDREN = [ 'mentoring-table = mentoring.components:MentoringTableBlock',
'mentoring-table = mentoring:MentoringTableBlock', 'column = mentoring.components:MentoringTableColumnBlock',
'column = mentoring:MentoringTableColumnBlock', 'header = mentoring.components:MentoringTableColumnHeaderBlock',
'header = mentoring:MentoringTableColumnHeaderBlock', 'answer = mentoring.components:AnswerBlock',
'answer = mentoring:AnswerBlock', 'quizz = mentoring.components:MCQBlock',
'quizz = mentoring:MCQBlock', 'mcq = mentoring.components:MCQBlock',
'mcq = mentoring:MCQBlock', 'mrq = mentoring.components:MRQBlock',
'mrq = mentoring:MRQBlock', 'message = mentoring.components:MentoringMessageBlock',
'message = mentoring:MentoringMessageBlock', 'tip = mentoring.components:TipBlock',
'tip = mentoring:TipBlock', 'choice = mentoring.components:ChoiceBlock',
'choice = mentoring:ChoiceBlock', 'html = mentoring.components:HTMLBlock',
'html = mentoring:HTMLBlock', 'title = mentoring.components:TitleBlock',
'title = mentoring:TitleBlock', 'shared-header = mentoring.components:SharedHeaderBlock',
'shared-header = mentoring:SharedHeaderBlock',
] ]
setup( setup(
name='xblock-mentoring', name='xblock-mentoring',
version='0.1', version='0.1',
description='XBlock - Mentoring', description='XBlock - Mentoring',
packages=['mentoring', 'mentoring.migrations'], packages=['mentoring'],
install_requires=[ install_requires=[
'XBlock', 'XBlock',
'xblock-utils', 'xblock-utils',
...@@ -75,7 +73,6 @@ setup( ...@@ -75,7 +73,6 @@ setup(
dependency_links = ['http://github.com/edx-solutions/xblock-utils/tarball/master#egg=xblock-utils'], dependency_links = ['http://github.com/edx-solutions/xblock-utils/tarball/master#egg=xblock-utils'],
entry_points={ entry_points={
'xblock.v1': BLOCKS, '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