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(){
......
// Underscore.js 1.3.3
// (c) 2009-2012 Jeremy Ashkenas, DocumentCloud Inc.
// Underscore is freely distributable under the MIT license.
// Portions of Underscore are inspired or borrowed from Prototype,
// Oliver Steele's Functional, and John Resig's Micro-Templating.
// For all details and documentation:
// http://documentcloud.github.com/underscore
(function(){function r(a,c,d){if(a===c)return 0!==a||1/a==1/c;if(null==a||null==c)return a===c;a._chain&&(a=a._wrapped);c._chain&&(c=c._wrapped);if(a.isEqual&&b.isFunction(a.isEqual))return a.isEqual(c);if(c.isEqual&&b.isFunction(c.isEqual))return c.isEqual(a);var e=l.call(a);if(e!=l.call(c))return!1;switch(e){case "[object String]":return a==""+c;case "[object Number]":return a!=+a?c!=+c:0==a?1/a==1/c:a==+c;case "[object Date]":case "[object Boolean]":return+a==+c;case "[object RegExp]":return a.source==
c.source&&a.global==c.global&&a.multiline==c.multiline&&a.ignoreCase==c.ignoreCase}if("object"!=typeof a||"object"!=typeof c)return!1;for(var f=d.length;f--;)if(d[f]==a)return!0;d.push(a);var f=0,g=!0;if("[object Array]"==e){if(f=a.length,g=f==c.length)for(;f--&&(g=f in a==f in c&&r(a[f],c[f],d)););}else{if("constructor"in a!="constructor"in c||a.constructor!=c.constructor)return!1;for(var h in a)if(b.has(a,h)&&(f++,!(g=b.has(c,h)&&r(a[h],c[h],d))))break;if(g){for(h in c)if(b.has(c,h)&&!f--)break;
g=!f}}d.pop();return g}var s=this,I=s._,o={},k=Array.prototype,p=Object.prototype,i=k.slice,J=k.unshift,l=p.toString,K=p.hasOwnProperty,y=k.forEach,z=k.map,A=k.reduce,B=k.reduceRight,C=k.filter,D=k.every,E=k.some,q=k.indexOf,F=k.lastIndexOf,p=Array.isArray,L=Object.keys,t=Function.prototype.bind,b=function(a){return new m(a)};"undefined"!==typeof exports?("undefined"!==typeof module&&module.exports&&(exports=module.exports=b),exports._=b):s._=b;b.VERSION="1.3.3";var j=b.each=b.forEach=function(a,
c,d){if(a!=null)if(y&&a.forEach===y)a.forEach(c,d);else if(a.length===+a.length)for(var e=0,f=a.length;e<f;e++){if(e in a&&c.call(d,a[e],e,a)===o)break}else for(e in a)if(b.has(a,e)&&c.call(d,a[e],e,a)===o)break};b.map=b.collect=function(a,c,b){var e=[];if(a==null)return e;if(z&&a.map===z)return a.map(c,b);j(a,function(a,g,h){e[e.length]=c.call(b,a,g,h)});if(a.length===+a.length)e.length=a.length;return e};b.reduce=b.foldl=b.inject=function(a,c,d,e){var f=arguments.length>2;a==null&&(a=[]);if(A&&
a.reduce===A){e&&(c=b.bind(c,e));return f?a.reduce(c,d):a.reduce(c)}j(a,function(a,b,i){if(f)d=c.call(e,d,a,b,i);else{d=a;f=true}});if(!f)throw new TypeError("Reduce of empty array with no initial value");return d};b.reduceRight=b.foldr=function(a,c,d,e){var f=arguments.length>2;a==null&&(a=[]);if(B&&a.reduceRight===B){e&&(c=b.bind(c,e));return f?a.reduceRight(c,d):a.reduceRight(c)}var g=b.toArray(a).reverse();e&&!f&&(c=b.bind(c,e));return f?b.reduce(g,c,d,e):b.reduce(g,c)};b.find=b.detect=function(a,
c,b){var e;G(a,function(a,g,h){if(c.call(b,a,g,h)){e=a;return true}});return e};b.filter=b.select=function(a,c,b){var e=[];if(a==null)return e;if(C&&a.filter===C)return a.filter(c,b);j(a,function(a,g,h){c.call(b,a,g,h)&&(e[e.length]=a)});return e};b.reject=function(a,c,b){var e=[];if(a==null)return e;j(a,function(a,g,h){c.call(b,a,g,h)||(e[e.length]=a)});return e};b.every=b.all=function(a,c,b){var e=true;if(a==null)return e;if(D&&a.every===D)return a.every(c,b);j(a,function(a,g,h){if(!(e=e&&c.call(b,
a,g,h)))return o});return!!e};var G=b.some=b.any=function(a,c,d){c||(c=b.identity);var e=false;if(a==null)return e;if(E&&a.some===E)return a.some(c,d);j(a,function(a,b,h){if(e||(e=c.call(d,a,b,h)))return o});return!!e};b.include=b.contains=function(a,c){var b=false;if(a==null)return b;if(q&&a.indexOf===q)return a.indexOf(c)!=-1;return b=G(a,function(a){return a===c})};b.invoke=function(a,c){var d=i.call(arguments,2);return b.map(a,function(a){return(b.isFunction(c)?c||a:a[c]).apply(a,d)})};b.pluck=
function(a,c){return b.map(a,function(a){return a[c]})};b.max=function(a,c,d){if(!c&&b.isArray(a)&&a[0]===+a[0])return Math.max.apply(Math,a);if(!c&&b.isEmpty(a))return-Infinity;var e={computed:-Infinity};j(a,function(a,b,h){b=c?c.call(d,a,b,h):a;b>=e.computed&&(e={value:a,computed:b})});return e.value};b.min=function(a,c,d){if(!c&&b.isArray(a)&&a[0]===+a[0])return Math.min.apply(Math,a);if(!c&&b.isEmpty(a))return Infinity;var e={computed:Infinity};j(a,function(a,b,h){b=c?c.call(d,a,b,h):a;b<e.computed&&
(e={value:a,computed:b})});return e.value};b.shuffle=function(a){var b=[],d;j(a,function(a,f){d=Math.floor(Math.random()*(f+1));b[f]=b[d];b[d]=a});return b};b.sortBy=function(a,c,d){var e=b.isFunction(c)?c:function(a){return a[c]};return b.pluck(b.map(a,function(a,b,c){return{value:a,criteria:e.call(d,a,b,c)}}).sort(function(a,b){var c=a.criteria,d=b.criteria;return c===void 0?1:d===void 0?-1:c<d?-1:c>d?1:0}),"value")};b.groupBy=function(a,c){var d={},e=b.isFunction(c)?c:function(a){return a[c]};
j(a,function(a,b){var c=e(a,b);(d[c]||(d[c]=[])).push(a)});return d};b.sortedIndex=function(a,c,d){d||(d=b.identity);for(var e=0,f=a.length;e<f;){var g=e+f>>1;d(a[g])<d(c)?e=g+1:f=g}return e};b.toArray=function(a){return!a?[]:b.isArray(a)||b.isArguments(a)?i.call(a):a.toArray&&b.isFunction(a.toArray)?a.toArray():b.values(a)};b.size=function(a){return b.isArray(a)?a.length:b.keys(a).length};b.first=b.head=b.take=function(a,b,d){return b!=null&&!d?i.call(a,0,b):a[0]};b.initial=function(a,b,d){return i.call(a,
0,a.length-(b==null||d?1:b))};b.last=function(a,b,d){return b!=null&&!d?i.call(a,Math.max(a.length-b,0)):a[a.length-1]};b.rest=b.tail=function(a,b,d){return i.call(a,b==null||d?1:b)};b.compact=function(a){return b.filter(a,function(a){return!!a})};b.flatten=function(a,c){return b.reduce(a,function(a,e){if(b.isArray(e))return a.concat(c?e:b.flatten(e));a[a.length]=e;return a},[])};b.without=function(a){return b.difference(a,i.call(arguments,1))};b.uniq=b.unique=function(a,c,d){var d=d?b.map(a,d):a,
e=[];a.length<3&&(c=true);b.reduce(d,function(d,g,h){if(c?b.last(d)!==g||!d.length:!b.include(d,g)){d.push(g);e.push(a[h])}return d},[]);return e};b.union=function(){return b.uniq(b.flatten(arguments,true))};b.intersection=b.intersect=function(a){var c=i.call(arguments,1);return b.filter(b.uniq(a),function(a){return b.every(c,function(c){return b.indexOf(c,a)>=0})})};b.difference=function(a){var c=b.flatten(i.call(arguments,1),true);return b.filter(a,function(a){return!b.include(c,a)})};b.zip=function(){for(var a=
i.call(arguments),c=b.max(b.pluck(a,"length")),d=Array(c),e=0;e<c;e++)d[e]=b.pluck(a,""+e);return d};b.indexOf=function(a,c,d){if(a==null)return-1;var e;if(d){d=b.sortedIndex(a,c);return a[d]===c?d:-1}if(q&&a.indexOf===q)return a.indexOf(c);d=0;for(e=a.length;d<e;d++)if(d in a&&a[d]===c)return d;return-1};b.lastIndexOf=function(a,b){if(a==null)return-1;if(F&&a.lastIndexOf===F)return a.lastIndexOf(b);for(var d=a.length;d--;)if(d in a&&a[d]===b)return d;return-1};b.range=function(a,b,d){if(arguments.length<=
1){b=a||0;a=0}for(var d=arguments[2]||1,e=Math.max(Math.ceil((b-a)/d),0),f=0,g=Array(e);f<e;){g[f++]=a;a=a+d}return g};var H=function(){};b.bind=function(a,c){var d,e;if(a.bind===t&&t)return t.apply(a,i.call(arguments,1));if(!b.isFunction(a))throw new TypeError;e=i.call(arguments,2);return d=function(){if(!(this instanceof d))return a.apply(c,e.concat(i.call(arguments)));H.prototype=a.prototype;var b=new H,g=a.apply(b,e.concat(i.call(arguments)));return Object(g)===g?g:b}};b.bindAll=function(a){var c=
i.call(arguments,1);c.length==0&&(c=b.functions(a));j(c,function(c){a[c]=b.bind(a[c],a)});return a};b.memoize=function(a,c){var d={};c||(c=b.identity);return function(){var e=c.apply(this,arguments);return b.has(d,e)?d[e]:d[e]=a.apply(this,arguments)}};b.delay=function(a,b){var d=i.call(arguments,2);return setTimeout(function(){return a.apply(null,d)},b)};b.defer=function(a){return b.delay.apply(b,[a,1].concat(i.call(arguments,1)))};b.throttle=function(a,c){var d,e,f,g,h,i,j=b.debounce(function(){h=
g=false},c);return function(){d=this;e=arguments;f||(f=setTimeout(function(){f=null;h&&a.apply(d,e);j()},c));g?h=true:i=a.apply(d,e);j();g=true;return i}};b.debounce=function(a,b,d){var e;return function(){var f=this,g=arguments;d&&!e&&a.apply(f,g);clearTimeout(e);e=setTimeout(function(){e=null;d||a.apply(f,g)},b)}};b.once=function(a){var b=false,d;return function(){if(b)return d;b=true;return d=a.apply(this,arguments)}};b.wrap=function(a,b){return function(){var d=[a].concat(i.call(arguments,0));
return b.apply(this,d)}};b.compose=function(){var a=arguments;return function(){for(var b=arguments,d=a.length-1;d>=0;d--)b=[a[d].apply(this,b)];return b[0]}};b.after=function(a,b){return a<=0?b():function(){if(--a<1)return b.apply(this,arguments)}};b.keys=L||function(a){if(a!==Object(a))throw new TypeError("Invalid object");var c=[],d;for(d in a)b.has(a,d)&&(c[c.length]=d);return c};b.values=function(a){return b.map(a,b.identity)};b.functions=b.methods=function(a){var c=[],d;for(d in a)b.isFunction(a[d])&&
c.push(d);return c.sort()};b.extend=function(a){j(i.call(arguments,1),function(b){for(var d in b)a[d]=b[d]});return a};b.pick=function(a){var c={};j(b.flatten(i.call(arguments,1)),function(b){b in a&&(c[b]=a[b])});return c};b.defaults=function(a){j(i.call(arguments,1),function(b){for(var d in b)a[d]==null&&(a[d]=b[d])});return a};b.clone=function(a){return!b.isObject(a)?a:b.isArray(a)?a.slice():b.extend({},a)};b.tap=function(a,b){b(a);return a};b.isEqual=function(a,b){return r(a,b,[])};b.isEmpty=
function(a){if(a==null)return true;if(b.isArray(a)||b.isString(a))return a.length===0;for(var c in a)if(b.has(a,c))return false;return true};b.isElement=function(a){return!!(a&&a.nodeType==1)};b.isArray=p||function(a){return l.call(a)=="[object Array]"};b.isObject=function(a){return a===Object(a)};b.isArguments=function(a){return l.call(a)=="[object Arguments]"};b.isArguments(arguments)||(b.isArguments=function(a){return!(!a||!b.has(a,"callee"))});b.isFunction=function(a){return l.call(a)=="[object Function]"};
b.isString=function(a){return l.call(a)=="[object String]"};b.isNumber=function(a){return l.call(a)=="[object Number]"};b.isFinite=function(a){return b.isNumber(a)&&isFinite(a)};b.isNaN=function(a){return a!==a};b.isBoolean=function(a){return a===true||a===false||l.call(a)=="[object Boolean]"};b.isDate=function(a){return l.call(a)=="[object Date]"};b.isRegExp=function(a){return l.call(a)=="[object RegExp]"};b.isNull=function(a){return a===null};b.isUndefined=function(a){return a===void 0};b.has=function(a,
b){return K.call(a,b)};b.noConflict=function(){s._=I;return this};b.identity=function(a){return a};b.times=function(a,b,d){for(var e=0;e<a;e++)b.call(d,e)};b.escape=function(a){return(""+a).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#x27;").replace(/\//g,"&#x2F;")};b.result=function(a,c){if(a==null)return null;var d=a[c];return b.isFunction(d)?d.call(a):d};b.mixin=function(a){j(b.functions(a),function(c){M(c,b[c]=a[c])})};var N=0;b.uniqueId=
function(a){var b=N++;return a?a+b:b};b.templateSettings={evaluate:/<%([\s\S]+?)%>/g,interpolate:/<%=([\s\S]+?)%>/g,escape:/<%-([\s\S]+?)%>/g};var u=/.^/,n={"\\":"\\","'":"'",r:"\r",n:"\n",t:"\t",u2028:"\u2028",u2029:"\u2029"},v;for(v in n)n[n[v]]=v;var O=/\\|'|\r|\n|\t|\u2028|\u2029/g,P=/\\(\\|'|r|n|t|u2028|u2029)/g,w=function(a){return a.replace(P,function(a,b){return n[b]})};b.template=function(a,c,d){d=b.defaults(d||{},b.templateSettings);a="__p+='"+a.replace(O,function(a){return"\\"+n[a]}).replace(d.escape||
u,function(a,b){return"'+\n_.escape("+w(b)+")+\n'"}).replace(d.interpolate||u,function(a,b){return"'+\n("+w(b)+")+\n'"}).replace(d.evaluate||u,function(a,b){return"';\n"+w(b)+"\n;__p+='"})+"';\n";d.variable||(a="with(obj||{}){\n"+a+"}\n");var a="var __p='';var print=function(){__p+=Array.prototype.join.call(arguments, '')};\n"+a+"return __p;\n",e=new Function(d.variable||"obj","_",a);if(c)return e(c,b);c=function(a){return e.call(this,a,b)};c.source="function("+(d.variable||"obj")+"){\n"+a+"}";return c};
b.chain=function(a){return b(a).chain()};var m=function(a){this._wrapped=a};b.prototype=m.prototype;var x=function(a,c){return c?b(a).chain():a},M=function(a,c){m.prototype[a]=function(){var a=i.call(arguments);J.call(a,this._wrapped);return x(c.apply(b,a),this._chain)}};b.mixin(b);j("pop,push,reverse,shift,sort,splice,unshift".split(","),function(a){var b=k[a];m.prototype[a]=function(){var d=this._wrapped;b.apply(d,arguments);var e=d.length;(a=="shift"||a=="splice")&&e===0&&delete d[0];return x(d,
this._chain)}});j(["concat","join","slice"],function(a){var b=k[a];m.prototype[a]=function(){return x(b.apply(this._wrapped,arguments),this._chain)}});m.prototype.chain=function(){this._chain=true;return this};m.prototype.value=function(){return this._wrapped}}).call(this);
...@@ -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)
# -*- coding: utf-8 -*-
#
# Copyright (C) 2014 Harvard
#
# Authors:
# Xavier Antoviaque <xavier@antoviaque.org>
#
# This software's license gives you freedom; you can copy, convey,
# propagate, redistribute and/or modify this program under the terms of
# the GNU Affero General Public License (AGPL) as published by the Free
# Software Foundation (FSF), either version 3 of the License, or (at your
# option) any later version of the AGPL published by the FSF.
#
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
# General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program in a file in the toplevel directory called
# "AGPLv3". If not, see <http://www.gnu.org/licenses/>.
#
# Imports ###########################################################
import logging
import json
from lazy import lazy
from weakref import WeakKeyDictionary
from StringIO import StringIO
from lxml import etree
from django.core.urlresolvers import reverse
from xblock.core import XBlock
from xblock.fragment import Fragment
from xblock.plugin import Plugin
from xblockutils.publish_event import PublishEventMixin
from .models import LightChild as LightChildModel
try:
from xmodule_modifiers import replace_jump_to_id_urls
except:
# TODO-WORKBENCH-WORKAROUND: To allow to load from the workbench
replace_jump_to_id_urls = lambda a, b, c, d, frag, f: frag
from .utils import XBlockWithChildrenFragmentsMixin
# Globals ###########################################################
log = logging.getLogger(__name__)
# Classes ###########################################################
class LightChildrenMixin(XBlockWithChildrenFragmentsMixin):
"""
Allows to use lightweight children on a given XBlock, which will
have a similar behavior but will not be instanciated as full-fledged
XBlocks, which aren't correctly supported as children
TODO: Replace this once the support for XBlock children has matured
by a mixin implementing the following abstractions, used to keep
code reusable in the XBlocks:
* get_children_objects()
* Functionality of XBlockWithChildrenFragmentsMixin
* self.xblock_container for when we need a real XBlock reference
Other changes caused by LightChild use:
* overrides of `parse_xml()` have been replaced by overrides of
`init_block_from_node()` on LightChildren
* fields on LightChild don't have any persistence
"""
@classmethod
def parse_xml(cls, node, runtime, keys, id_generator):
log.debug('parse_xml called')
block = runtime.construct_xblock_from_class(cls, keys)
cls.init_block_from_node(block, node, node.items())
def _is_default(value):
xml_content_field = getattr(block.__class__, 'xml_content', None)
default_value = getattr(xml_content_field, 'default', None)
return value == default_value
is_default = getattr(block, 'is_default_xml_content', _is_default)
xml_content = getattr(block, 'xml_content', None)
if is_default(xml_content):
block.xml_content = etree.tostring(node)
return block
@classmethod
def init_block_from_node(cls, block, node, attr):
block.light_children = []
for child_id, xml_child in enumerate(node):
cls.add_node_as_child(block, xml_child, child_id)
for name, value in attr:
setattr(block, name, value)
return block
@classmethod
def add_node_as_child(cls, block, xml_child, child_id):
if xml_child.tag is etree.Comment:
return
# Instantiate child
child_class = cls.get_class_by_element(xml_child.tag)
child = child_class(block)
child.name = u'{}_{}'.format(block.name, child_id)
# Add any children the child may itself have
child_class.init_block_from_node(child, xml_child, xml_child.items())
text = xml_child.text
if text:
text = text.strip()
if text:
child.content = text
block.light_children.append(child)
@classmethod
def get_class_by_element(cls, xml_tag):
return LightChild.load_class(xml_tag)
def load_children_from_xml_content(self):
"""
Load light children from the `xml_content` attribute
"""
self.light_children = []
no_content = (not hasattr(self, 'xml_content') or not self.xml_content
or callable(self.xml_content))
if no_content:
return
parser = etree.XMLParser(remove_comments=True)
node = etree.parse(StringIO(self.xml_content), parser=parser).getroot()
LightChildrenMixin.init_block_from_node(self, node, node.items())
def get_children_objects(self):
"""
Replacement for ```[self.runtime.get_block(child_id) for child_id in self.children]```
"""
return self.light_children
def render_child(self, child, view_name, context):
"""
Replacement for ```self.runtime.render_child()```
"""
frag = getattr(child, view_name)(context)
frag.content = u'<div class="xblock-light-child" name="{}" data-type="{}">{}</div>'.format(
child.name, child.__class__.__name__, frag.content)
return frag
def get_children_fragment(self, context, view_name='student_view', instance_of=None,
not_instance_of=None):
fragment = Fragment()
named_child_frags = []
for child in self.get_children_objects():
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.render_child(child, view_name, context)
fragment.add_frag_resources(frag)
named_child_frags.append((child.name, frag))
return fragment, named_child_frags
class XBlockWithLightChildren(LightChildrenMixin, XBlock, PublishEventMixin):
"""
XBlock base class with support for LightChild
"""
def __init__(self, *args, **kwargs):
super(XBlockWithLightChildren, self).__init__(*args, **kwargs)
self.xblock_container = self
self.load_children_from_xml_content()
@XBlock.json_handler
def view(self, data, suffix=''):
"""
Current HTML view of the XBlock, for refresh by client
"""
frag = self.student_view({})
frag = self.fragment_text_rewriting(frag)
return {
'html': frag.content,
}
def fragment_text_rewriting(self, fragment):
"""
Do replacements like `/jump_to_id` URL rewriting in the provided text
"""
# TODO: Why do we need to use `xmodule_runtime` and not `runtime`?
try:
course_id = self.xmodule_runtime.course_id
except AttributeError:
# TODO-WORKBENCH-WORKAROUND: To allow to load from the workbench
course_id = 'sample-course'
try:
jump_to_url = reverse('jump_to_id', kwargs={'course_id': course_id, 'module_id': ''})
except:
# TODO-WORKBENCH-WORKAROUND: To allow to load from the workbench
jump_to_url = '/jump_to_id'
fragment = replace_jump_to_id_urls(course_id, jump_to_url, self, 'student_view', fragment, {})
return fragment
class LightChild(Plugin, LightChildrenMixin):
"""
Base class for the light children
"""
entry_point = 'xblock.light_children'
block_type = None
def __init__(self, parent):
self.parent = parent
try:
self.location = parent.location
except AttributeError:
self.location = None
self.scope_ids = parent.scope_ids
self.xblock_container = parent.xblock_container
self._student_data_loaded = False
@property
def runtime(self):
return self.parent.runtime
@property
def xmodule_runtime(self):
try:
xmodule_runtime = self.parent.xmodule_runtime
except AttributeError:
# TODO-WORKBENCH-WORKAROUND: To allow to load from the workbench
class xmodule_runtime(object):
course_id = 'sample-course'
anonymous_student_id = 'student1'
xmodule_runtime = xmodule_runtime()
return xmodule_runtime
@lazy
def student_data(self):
"""
Use lazy property instead of XBlock field, as __init__() doesn't support
overwriting field values
"""
if not self.name:
return ''
student_data = self.get_lightchild_model_object().student_data
return student_data
def load_student_data(self):
"""
Load the student data from the database.
"""
if self._student_data_loaded:
return
fields = self.get_fields_to_save()
if not fields or not self.student_data:
return
student_data = json.loads(self.student_data)
for field in fields:
if field in student_data:
setattr(self, field, student_data[field])
self._student_data_loaded = True
@classmethod
def get_fields_to_save(cls):
"""
Returns a list of all LightChildField of the class. Used for saving student data.
"""
return []
def save(self):
"""
Replicate data changes on the related Django model used for sharing of data accross XBlocks
"""
# Save all children
for child in self.get_children_objects():
child.save()
self.student_data = {}
# Get All LightChild fields to save
for field in self.get_fields_to_save():
self.student_data[field] = getattr(self, field)
if self.name:
lightchild_data = self.get_lightchild_model_object()
if lightchild_data.student_data != self.student_data:
lightchild_data.student_data = json.dumps(self.student_data)
lightchild_data.save()
def get_lightchild_model_object(self, name=None):
"""
Fetches the LightChild model object for the lightchild named `name`
"""
if not name:
name = self.name
if not name:
raise ValueError('LightChild.name field need to be set to a non-null/empty value')
student_id = self.xmodule_runtime.anonymous_student_id
course_id = self.xmodule_runtime.course_id
url_name = "%s-%s" % (self.xblock_container.url_name, name)
lightchild_data, created = LightChildModel.objects.get_or_create(
student_id=student_id,
course_id=course_id,
name=url_name,
)
return lightchild_data
def local_resource_url(self, block, uri):
return self.runtime.local_resource_url(block, uri, block_type=self.block_type)
class LightChildField(object):
"""
Fake field with no persistence - allows to keep XBlocks fields definitions on LightChild
"""
def __init__(self, *args, **kwargs):
self.default = kwargs.get('default', '')
self.data = WeakKeyDictionary()
def __get__(self, instance, name):
# A LightChildField can depend on student_data
instance.load_student_data()
return self.data.get(instance, self.default)
def __set__(self, instance, value):
self.data[instance] = value
class String(LightChildField):
def __init__(self, *args, **kwargs):
super(String, self).__init__(*args, **kwargs)
self.default = kwargs.get('default', '') or ''
# def split(self, *args, **kwargs):
# return self.value.split(*args, **kwargs)
class Integer(LightChildField):
def __init__(self, *args, **kwargs):
super(Integer, self).__init__(*args, **kwargs)
self.default = kwargs.get('default', 0)
def __set__(self, instance, value):
try:
self.data[instance] = int(value)
except (TypeError, ValueError): # not an integer
self.data[instance] = 0
class Boolean(LightChildField):
def __init__(self, *args, **kwargs):
super(Boolean, self).__init__(*args, **kwargs)
self.default = kwargs.get('default', False)
def __set__(self, instance, value):
if isinstance(value, basestring):
value = value.lower() == 'true'
self.data[instance] = value
class Float(LightChildField):
def __init__(self, *args, **kwargs):
super(Float, self).__init__(*args, **kwargs)
self.default = kwargs.get('default', 0)
def __set__(self, instance, value):
try:
self.data[instance] = float(value)
except (TypeError, ValueError): # not an integer
self.data[instance] = 0
class List(LightChildField):
def __init__(self, *args, **kwargs):
super(List, self).__init__(*args, **kwargs)
self.default = kwargs.get('default', [])
class Scope(object):
content = None
user_state = None
...@@ -24,8 +24,6 @@ ...@@ -24,8 +24,6 @@
# Imports ########################################################### # Imports ###########################################################
import logging import logging
import uuid
import re
from collections import namedtuple from collections import namedtuple
...@@ -36,42 +34,24 @@ from xblock.core import XBlock ...@@ -36,42 +34,24 @@ from xblock.core import XBlock
from xblock.fields import Boolean, Scope, String, Integer, Float, List from xblock.fields import Boolean, Scope, String, Integer, Float, List
from xblock.fragment import Fragment from xblock.fragment import Fragment
from .light_children import XBlockWithLightChildren from components import TitleBlock, SharedHeaderBlock, MentoringMessageBlock
from .title import TitleBlock from components.step import StepParentMixin, StepMixin
from .header import SharedHeaderBlock
from .message import MentoringMessageBlock
from .step import StepParentMixin
from .utils import loader
from xblockutils.resources import ResourceLoader
# Globals ########################################################### # Globals ###########################################################
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
loader = ResourceLoader(__name__)
default_xml_content = loader.render_template('templates/xml/mentoring_default.xml', {})
def _default_xml_content():
return loader.render_template(
'templates/xml/mentoring_default.xml',
{'url_name': 'mentoring-{}'.format(uuid.uuid4())})
def _is_default_xml_content(value):
UUID_PATTERN = '[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}'
DUMMY_UUID = '12345678-1234-1234-1234-123456789abc'
expected = _default_xml_content()
expected = re.sub(UUID_PATTERN, DUMMY_UUID, expected)
value = re.sub(UUID_PATTERN, DUMMY_UUID, value)
return value == expected
# Classes ########################################################### # Classes ###########################################################
Score = namedtuple("Score", ["raw", "percentage", "correct", "incorrect", "partially_correct"]) Score = namedtuple("Score", ["raw", "percentage", "correct", "incorrect", "partially_correct"])
class MentoringBlock(XBlockWithLightChildren, StepParentMixin):
class MentoringBlock(XBlock, StepParentMixin):
""" """
An XBlock providing mentoring capabilities An XBlock providing mentoring capabilities
...@@ -83,54 +63,108 @@ class MentoringBlock(XBlockWithLightChildren, StepParentMixin): ...@@ -83,54 +63,108 @@ class MentoringBlock(XBlockWithLightChildren, StepParentMixin):
@staticmethod @staticmethod
def is_default_xml_content(value): def is_default_xml_content(value):
return _is_default_xml_content(value) return value == default_xml_content
attempted = Boolean(help="Has the student attempted this mentoring step?", # Content
default=False, scope=Scope.user_state) MENTORING_MODES = ('standard', 'assessment')
completed = Boolean(help="Has the student completed this mentoring step?", mode = String(
default=False, scope=Scope.user_state) help="Mode of the mentoring. 'standard' or 'assessment'",
next_step = String(help="url_name of the next step the student must complete (global to all blocks)", default='standard',
default='mentoring_first', scope=Scope.preferences) scope=Scope.content,
followed_by = String(help="url_name of the step after the current mentoring block in workflow", values=MENTORING_MODES
default=None, scope=Scope.content) )
url_name = String(help="Name of the current step, used for URL building", followed_by = String(
default='mentoring-default', scope=Scope.content) help="url_name of the step after the current mentoring block in workflow.",
enforce_dependency = Boolean(help="Should the next step be the current block to complete?", default=None,
default=False, scope=Scope.content, enforce_type=True) scope=Scope.content
)
max_attempts = Integer(
help="Number of max attempts for this questions",
default=0,
scope=Scope.content,
enforce_type=True
)
url_name = String(
help="Name of the current step, used for URL building",
default='mentoring-default',
scope=Scope.content
# TODO in future: set this field's default to xblock.fields.UNIQUE_ID
# and remove self.url_name_with_default. Waiting until UNIQUE_ID support
# is available in edx-platform's pinned version of xblock. (See XBlock PR 249)
)
enforce_dependency = Boolean(
help="Should the next step be the current block to complete?",
default=False,
scope=Scope.content,
enforce_type=True
)
display_submit = Boolean(help="Allow to submit current block?", default=True, scope=Scope.content) display_submit = Boolean(help="Allow to submit current block?", default=True, scope=Scope.content)
xml_content = String(help="XML content", default=_default_xml_content, scope=Scope.content) xml_content = String(help="XML content", default=default_xml_content, scope=Scope.content)
weight = Float(help="Defines the maximum total grade of the block.",
default=1, scope=Scope.content, enforce_type=True) # Settings
num_attempts = Integer(help="Number of attempts a user has answered for this questions", weight = Float(
default=0, scope=Scope.user_state, enforce_type=True) help="Defines the maximum total grade of the block.",
max_attempts = Integer(help="Number of max attempts for this questions", default=0, default=1,
scope=Scope.content, enforce_type=True) scope=Scope.settings,
mode = String(help="Mode of the mentoring. 'standard' or 'assessment'", enforce_type=True
default='standard', scope=Scope.content) )
step = Integer(help="Keep track of the student assessment progress.", display_name = String(
default=0, scope=Scope.user_state, enforce_type=True) help="Display name of the component",
student_results = List(help="Store results of student choices.", default=[], default="Mentoring XBlock",
scope=Scope.user_state) scope=Scope.settings
)
display_name = String(help="Display name of the component", default="Mentoring XBlock",
scope=Scope.settings) # User state
attempted = Boolean(
help="Has the student attempted this mentoring step?",
default=False,
scope=Scope.user_state
)
completed = Boolean(
help="Has the student completed this mentoring step?",
default=False,
scope=Scope.user_state
)
num_attempts = Integer(
help="Number of attempts a user has answered for this questions",
default=0,
scope=Scope.user_state,
enforce_type=True
)
step = Integer(
help="Keep track of the student assessment progress.",
default=0,
scope=Scope.user_state,
enforce_type=True
)
student_results = List(
help="Store results of student choices.",
default=[],
scope=Scope.user_state
)
# Global user state
next_step = String(
help="url_name of the next step the student must complete (global to all blocks)",
default='mentoring_first',
scope=Scope.preferences
)
icon_class = 'problem' icon_class = 'problem'
has_score = True has_score = True
has_children = True
MENTORING_MODES = ('standard', 'assessment')
FLOATING_BLOCKS = (TitleBlock, MentoringMessageBlock, SharedHeaderBlock) FLOATING_BLOCKS = (TitleBlock, MentoringMessageBlock, SharedHeaderBlock)
FIELDS_TO_INIT = ('xml_content',)
@property @property
def is_assessment(self): def is_assessment(self):
return self.mode == 'assessment' return self.mode == 'assessment'
@property @property
def score(self): def score(self):
"""Compute the student score taking into account the light child weight.""" """Compute the student score taking into account the weight of each step."""
total_child_weight = sum(float(step.weight) for step in self.steps) weights = (float(self.runtime.get_block(step_id).weight) for step_id in self.steps)
total_child_weight = sum(weights)
if total_child_weight == 0: if total_child_weight == 0:
return (0, 0, 0, 0) return (0, 0, 0, 0)
score = sum(r[1]['score'] * r[1]['weight'] for r in self.student_results) / total_child_weight score = sum(r[1]['score'] * r[1]['weight'] for r in self.student_results) / total_child_weight
...@@ -144,28 +178,35 @@ class MentoringBlock(XBlockWithLightChildren, StepParentMixin): ...@@ -144,28 +178,35 @@ class MentoringBlock(XBlockWithLightChildren, StepParentMixin):
# Migrate stored data if necessary # Migrate stored data if necessary
self.migrate_fields() self.migrate_fields()
fragment, named_children = self.get_children_fragment( fragment = Fragment()
context, view_name='mentoring_view', title = u""
not_instance_of=self.FLOATING_BLOCKS, header = u""
) child_content = u""
for child_id in self.children:
child = self.runtime.get_block(child_id)
if isinstance(child, TitleBlock):
title = child.content
elif isinstance(child, SharedHeaderBlock):
header = child.render('mentoring_view', context).content
elif isinstance(child, MentoringMessageBlock):
pass # TODO
else:
child_fragment = child.render('mentoring_view', context)
fragment.add_frag_resources(child_fragment)
child_content += child_fragment.content
fragment.add_content(loader.render_template('templates/html/mentoring.html', { fragment.add_content(loader.render_template('templates/html/mentoring.html', {
'self': self, 'self': self,
'named_children': named_children, 'title': title,
'header': header,
'child_content': child_content,
'missing_dependency_url': self.has_missing_dependency and self.next_step_url, 'missing_dependency_url': self.has_missing_dependency and self.next_step_url,
})) }))
fragment.add_css_url(self.runtime.local_resource_url(self, 'public/css/mentoring.css')) fragment.add_css_url(self.runtime.local_resource_url(self, 'public/css/mentoring.css'))
fragment.add_javascript_url( fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/vendor/underscore-min.js'))
self.runtime.local_resource_url(self, 'public/js/vendor/underscore-min.js')) js_file = 'public/js/mentoring_{}_view.js'.format('assessment' if self.is_assessment else 'standard')
if self.is_assessment: fragment.add_javascript_url(self.runtime.local_resource_url(self, js_file))
fragment.add_javascript_url(
self.runtime.local_resource_url(self, 'public/js/mentoring_assessment_view.js')
)
else:
fragment.add_javascript_url(
self.runtime.local_resource_url(self, 'public/js/mentoring_standard_view.js')
)
fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/mentoring.js')) fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/mentoring.js'))
fragment.add_resource(loader.load_unicode('templates/html/mentoring_attempts.html'), "text/html") fragment.add_resource(loader.load_unicode('templates/html/mentoring_attempts.html'), "text/html")
fragment.add_resource(loader.load_unicode('templates/html/mentoring_grade.html'), "text/html") fragment.add_resource(loader.load_unicode('templates/html/mentoring_grade.html'), "text/html")
...@@ -190,7 +231,7 @@ class MentoringBlock(XBlockWithLightChildren, StepParentMixin): ...@@ -190,7 +231,7 @@ class MentoringBlock(XBlockWithLightChildren, StepParentMixin):
def additional_publish_event_data(self): def additional_publish_event_data(self):
return { return {
'user_id': self.scope_ids.user_id, 'user_id': self.scope_ids.user_id,
'component_id': self.url_name, 'component_id': self.url_name_with_default,
} }
@property @property
...@@ -219,7 +260,7 @@ class MentoringBlock(XBlockWithLightChildren, StepParentMixin): ...@@ -219,7 +260,7 @@ class MentoringBlock(XBlockWithLightChildren, StepParentMixin):
Returns True if the student needs to complete another step before being able to complete Returns True if the student needs to complete another step before being able to complete
the current one, and False otherwise the current one, and False otherwise
""" """
return self.enforce_dependency and (not self.completed) and (self.next_step != self.url_name) return self.enforce_dependency and (not self.completed) and (self.next_step != self.url_name_with_default)
@property @property
def next_step_url(self): def next_step_url(self):
...@@ -229,6 +270,24 @@ class MentoringBlock(XBlockWithLightChildren, StepParentMixin): ...@@ -229,6 +270,24 @@ class MentoringBlock(XBlockWithLightChildren, StepParentMixin):
return '/jump_to_id/{}'.format(self.next_step) return '/jump_to_id/{}'.format(self.next_step)
@XBlock.json_handler @XBlock.json_handler
def view(self, data, suffix=''):
"""
Current HTML view of the XBlock, for refresh by client
"""
frag = self.student_view({})
return {'html': frag.content}
@XBlock.json_handler
def publish_event(self, data, suffix=''):
"""
Publish data for analytics purposes
"""
event_type = data.pop('event_type')
self.runtime.publish(self, event_type, data)
return {'result': 'ok'}
@XBlock.json_handler
def submit(self, submissions, suffix=''): def submit(self, submissions, suffix=''):
log.info(u'Received submissions: {}'.format(submissions)) log.info(u'Received submissions: {}'.format(submissions))
self.attempted = True self.attempted = True
...@@ -238,7 +297,8 @@ class MentoringBlock(XBlockWithLightChildren, StepParentMixin): ...@@ -238,7 +297,8 @@ class MentoringBlock(XBlockWithLightChildren, StepParentMixin):
submit_results = [] submit_results = []
completed = True completed = True
for child in self.get_children_objects(): for child_id in self.children:
child = self.runtime.get_block(child_id)
if child.name and child.name in submissions: if child.name and child.name in submissions:
submission = submissions[child.name] submission = submissions[child.name]
child_result = child.submit(submission) child_result = child.submit(submission)
...@@ -264,7 +324,7 @@ class MentoringBlock(XBlockWithLightChildren, StepParentMixin): ...@@ -264,7 +324,7 @@ class MentoringBlock(XBlockWithLightChildren, StepParentMixin):
if self.has_missing_dependency: if self.has_missing_dependency:
completed = False completed = False
message = 'You need to complete all previous steps before being able to complete the current one.' message = 'You need to complete all previous steps before being able to complete the current one.'
elif completed and self.next_step == self.url_name: elif completed and self.next_step == self.url_name_with_default:
self.next_step = self.followed_by self.next_step = self.followed_by
# Once it was completed, lock score # Once it was completed, lock score
...@@ -287,7 +347,7 @@ class MentoringBlock(XBlockWithLightChildren, StepParentMixin): ...@@ -287,7 +347,7 @@ class MentoringBlock(XBlockWithLightChildren, StepParentMixin):
raw_score = self.score.raw raw_score = self.score.raw
self.publish_event_from_dict('xblock.mentoring.submitted', { self.runtime.publish(self, 'xblock.mentoring.submitted', {
'num_attempts': self.num_attempts, 'num_attempts': self.num_attempts,
'submitted_answer': submissions, 'submitted_answer': submissions,
'grade': raw_score, 'grade': raw_score,
...@@ -306,8 +366,9 @@ class MentoringBlock(XBlockWithLightChildren, StepParentMixin): ...@@ -306,8 +366,9 @@ class MentoringBlock(XBlockWithLightChildren, StepParentMixin):
completed = False completed = False
current_child = None current_child = None
children = [child for child in self.get_children_objects() children = [self.runtime.get_block(child_id) for child_id in self.children]
if not isinstance(child, self.FLOATING_BLOCKS)] children = [child for child in children if not isinstance(child, self.FLOATING_BLOCKS)]
steps = [child for child in children if isinstance(child, StepMixin)] # Faster than the self.steps property
for child in children: for child in children:
if child.name and child.name in submissions: if child.name and child.name in submissions:
...@@ -316,7 +377,7 @@ class MentoringBlock(XBlockWithLightChildren, StepParentMixin): ...@@ -316,7 +377,7 @@ class MentoringBlock(XBlockWithLightChildren, StepParentMixin):
# Assessment mode doesn't allow to modify answers # Assessment mode doesn't allow to modify answers
# This will get the student back at the step he should be # This will get the student back at the step he should be
current_child = child current_child = child
step = children.index(child) step = steps.index(child)
if self.step > step or self.max_attempts_reached: if self.step > step or self.max_attempts_reached:
step = self.step step = self.step
completed = False completed = False
...@@ -328,14 +389,13 @@ class MentoringBlock(XBlockWithLightChildren, StepParentMixin): ...@@ -328,14 +389,13 @@ class MentoringBlock(XBlockWithLightChildren, StepParentMixin):
if 'tips' in child_result: if 'tips' in child_result:
del child_result['tips'] del child_result['tips']
self.student_results.append([child.name, child_result]) self.student_results.append([child.name, child_result])
child.save()
completed = child_result['status'] completed = child_result['status']
event_data = {} event_data = {}
score = self.score score = self.score
if current_child == self.steps[-1]: if current_child == steps[-1]:
log.info(u'Last assessment step submitted: {}'.format(submissions)) log.info(u'Last assessment step submitted: {}'.format(submissions))
if not self.max_attempts_reached: if not self.max_attempts_reached:
self.runtime.publish(self, 'grade', { self.runtime.publish(self, 'grade', {
...@@ -352,7 +412,7 @@ class MentoringBlock(XBlockWithLightChildren, StepParentMixin): ...@@ -352,7 +412,7 @@ class MentoringBlock(XBlockWithLightChildren, StepParentMixin):
event_data['num_attempts'] = self.num_attempts event_data['num_attempts'] = self.num_attempts
event_data['submitted_answer'] = submissions event_data['submitted_answer'] = submissions
self.publish_event_from_dict('xblock.mentoring.assessment.submitted', event_data) self.runtime.publish(self, 'xblock.mentoring.assessment.submitted', event_data)
return { return {
'completed': completed, 'completed': completed,
...@@ -390,18 +450,13 @@ class MentoringBlock(XBlockWithLightChildren, StepParentMixin): ...@@ -390,18 +450,13 @@ class MentoringBlock(XBlockWithLightChildren, StepParentMixin):
def max_attempts_reached(self): def max_attempts_reached(self):
return self.max_attempts > 0 and self.num_attempts >= self.max_attempts return self.max_attempts > 0 and self.num_attempts >= self.max_attempts
def get_message_fragment(self, message_type):
for child in self.get_children_objects():
if isinstance(child, MentoringMessageBlock) and child.type == message_type:
frag = self.render_child(child, 'mentoring_view', {})
return self.fragment_text_rewriting(frag)
def get_message_html(self, message_type): def get_message_html(self, message_type):
fragment = self.get_message_fragment(message_type) html = u""
if fragment: for child_id in self.children:
return fragment.body_html() child = self.runtime.get_block(child_id)
else: if isinstance(child, MentoringMessageBlock) and child.type == message_type:
return '' html += child.render('mentoring_view', {}).content # TODO: frament_text_rewriting ?
return html
def studio_view(self, context): def studio_view(self, context):
""" """
...@@ -461,13 +516,12 @@ class MentoringBlock(XBlockWithLightChildren, StepParentMixin): ...@@ -461,13 +516,12 @@ class MentoringBlock(XBlockWithLightChildren, StepParentMixin):
def url_name_with_default(self): def url_name_with_default(self):
""" """
Ensure the `url_name` is set to a unique, non-empty value. Ensure the `url_name` is set to a unique, non-empty value.
This should ideally be handled by Studio, but we need to declare the attribute In future once hte pinned version of XBlock is updated,
to be able to use it from the workbench, and when this happen Studio doesn't set we can remove this and change the field to use the
a unique default value - this property gives either the set value, or if none is set xblock.fields.UNIQUE_ID flag instead.
a randomized default value
""" """
if self.url_name == 'mentoring-default': if self.url_name == 'mentoring-default':
return 'mentoring-{}'.format(uuid.uuid4()) return self.scope_ids.usage_id
else: else:
return self.url_name return self.url_name
......
...@@ -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